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:
@@ -57,7 +57,7 @@ pub async fn chat(
|
||||
// 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 body = ORChatBody {
|
||||
let mut body = ORChatBody {
|
||||
model: model.clone(),
|
||||
// Pass content through verbatim — preserves OpenAI's multimodal
|
||||
// content-parts shape (`[{type:"text",text:"..."}, ...]`) so the
|
||||
@@ -66,7 +66,7 @@ pub async fn chat(
|
||||
role: m.role.clone(),
|
||||
content: m.content.clone(),
|
||||
}).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),
|
||||
stream: false,
|
||||
};
|
||||
@@ -77,18 +77,61 @@ pub async fn chat(
|
||||
.map_err(|e| format!("build client: {e}"))?;
|
||||
|
||||
let t0 = std::time::Instant::now();
|
||||
let resp = client
|
||||
.post(format!("{}/chat/completions", OR_BASE_URL))
|
||||
.bearer_auth(key)
|
||||
// OpenRouter recommends Referer + Title for attribution; absent
|
||||
// headers do not fail the call but help us see our traffic in
|
||||
// their dashboard.
|
||||
.header("HTTP-Referer", "https://vcp.devop.live")
|
||||
.header("X-Title", "Lakehouse Scrum")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("openrouter.ai unreachable: {e}"))?;
|
||||
let send = |client: reqwest::Client, key: String, body_json: serde_json::Value| async move {
|
||||
client
|
||||
.post(format!("{}/chat/completions", OR_BASE_URL))
|
||||
.bearer_auth(key)
|
||||
// OpenRouter recommends Referer + Title for attribution; absent
|
||||
// headers do not fail the call but help us see our traffic in
|
||||
// their dashboard.
|
||||
.header("HTTP-Referer", "https://vcp.devop.live")
|
||||
.header("X-Title", "Lakehouse Scrum")
|
||||
.json(&body_json)
|
||||
.send()
|
||||
.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();
|
||||
if !status.is_success() {
|
||||
@@ -160,7 +203,8 @@ pub async fn chat(
|
||||
struct ORChatBody {
|
||||
model: String,
|
||||
messages: Vec<ORMessage>,
|
||||
max_tokens: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max_tokens: Option<u32>,
|
||||
temperature: f64,
|
||||
stream: bool,
|
||||
}
|
||||
@@ -232,7 +276,7 @@ mod tests {
|
||||
messages: vec![
|
||||
ORMessage { role: "user".into(), content: "review this".into() },
|
||||
],
|
||||
max_tokens: 800,
|
||||
max_tokens: Some(800),
|
||||
temperature: 0.3,
|
||||
stream: false,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user