gateway/v1: 402 affordability clamp-retry on the openrouter lane

Stripping max_tokens on a 402 made things worse (the precheck then
prices the model's own 65k maximum). Instead: parse the 'can only
afford N' budget from the error and retry once clamped to N when
N >= 256 — small answers survive a thin balance; anything less
surfaces the 402 verbatim, because the real fix is funding the
account the key belongs to.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
root
2026-08-28 17:12:53 -05:00
co-authored by Claude Fable 5
parent 6de15ad5f5
commit c97f52de82
+60 -16
View File
@@ -57,7 +57,7 @@ pub async fn chat(
// form so OpenRouter sees the raw model id (e.g. "openai/gpt-oss-120b:free"). // form so OpenRouter sees the raw model id (e.g. "openai/gpt-oss-120b:free").
let model = req.model.strip_prefix("openrouter/").unwrap_or(&req.model).to_string(); let model = req.model.strip_prefix("openrouter/").unwrap_or(&req.model).to_string();
let body = ORChatBody { let mut body = ORChatBody {
model: model.clone(), model: model.clone(),
// Pass content through verbatim — preserves OpenAI's multimodal // Pass content through verbatim — preserves OpenAI's multimodal
// content-parts shape (`[{type:"text",text:"..."}, ...]`) so the // content-parts shape (`[{type:"text",text:"..."}, ...]`) so the
@@ -66,7 +66,7 @@ pub async fn chat(
role: m.role.clone(), role: m.role.clone(),
content: m.content.clone(), content: m.content.clone(),
}).collect(), }).collect(),
max_tokens: req.max_tokens.unwrap_or(800), max_tokens: Some(req.max_tokens.unwrap_or(800)),
temperature: req.temperature.unwrap_or(0.3), temperature: req.temperature.unwrap_or(0.3),
stream: false, stream: false,
}; };
@@ -77,18 +77,61 @@ pub async fn chat(
.map_err(|e| format!("build client: {e}"))?; .map_err(|e| format!("build client: {e}"))?;
let t0 = std::time::Instant::now(); let t0 = std::time::Instant::now();
let resp = client let send = |client: reqwest::Client, key: String, body_json: serde_json::Value| async move {
.post(format!("{}/chat/completions", OR_BASE_URL)) client
.bearer_auth(key) .post(format!("{}/chat/completions", OR_BASE_URL))
// OpenRouter recommends Referer + Title for attribution; absent .bearer_auth(key)
// headers do not fail the call but help us see our traffic in // OpenRouter recommends Referer + Title for attribution; absent
// their dashboard. // headers do not fail the call but help us see our traffic in
.header("HTTP-Referer", "https://vcp.devop.live") // their dashboard.
.header("X-Title", "Lakehouse Scrum") .header("HTTP-Referer", "https://vcp.devop.live")
.json(&body) .header("X-Title", "Lakehouse Scrum")
.send() .json(&body_json)
.await .send()
.map_err(|e| format!("openrouter.ai unreachable: {e}"))?; .await
.map_err(|e| format!("openrouter.ai unreachable: {e}"))
};
let mut resp = send(
client.clone(),
key.to_string(),
serde_json::to_value(&body).map_err(|e| format!("serialize body: {e}"))?,
)
.await?;
// OpenRouter's affordability precheck reserves max_tokens × price against
// the account balance. Measured 2026-08-28: stripping the cap makes it
// WORSE (the precheck then prices the model's own maximum, e.g. 65536) —
// so a low-balance 402 surfaces verbatim and the fix is funding the
// account the key belongs to, not request-shape games. One clamp retry:
// when the error names an affordable budget ≥256 tokens, retry once at
// that budget so small answers still get through on a thin balance.
if resp.status() == reqwest::StatusCode::PAYMENT_REQUIRED && body.max_tokens.is_some() {
let err_body = resp.text().await.unwrap_or_else(|_| "?".into());
let afford = err_body
.split("can only afford ")
.nth(1)
.and_then(|s| s.split(|c: char| !c.is_ascii_digit()).next())
.and_then(|n| n.parse::<u32>().ok());
match afford {
Some(n) if n >= 256 => {
tracing::warn!(
target: "v1.chat",
provider = "openrouter",
model = %model,
afford = n,
"402 affordability precheck — clamping max_tokens to the stated budget",
);
body.max_tokens = Some(n);
resp = send(
client.clone(),
key.to_string(),
serde_json::to_value(&body).map_err(|e| format!("serialize body: {e}"))?,
)
.await?;
}
_ => return Err(format!("openrouter.ai 402 Payment Required: {}", err_body)),
}
}
let status = resp.status(); let status = resp.status();
if !status.is_success() { if !status.is_success() {
@@ -160,7 +203,8 @@ pub async fn chat(
struct ORChatBody { struct ORChatBody {
model: String, model: String,
messages: Vec<ORMessage>, messages: Vec<ORMessage>,
max_tokens: u32, #[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<u32>,
temperature: f64, temperature: f64,
stream: bool, stream: bool,
} }
@@ -232,7 +276,7 @@ mod tests {
messages: vec![ messages: vec![
ORMessage { role: "user".into(), content: "review this".into() }, ORMessage { role: "user".into(), content: "review this".into() },
], ],
max_tokens: 800, max_tokens: Some(800),
temperature: 0.3, temperature: 0.3,
stream: false, stream: false,
}; };