diff --git a/crates/gateway/src/v1/openrouter.rs b/crates/gateway/src/v1/openrouter.rs index bebe575..50ea3c9 100644 --- a/crates/gateway/src/v1/openrouter.rs +++ b/crates/gateway/src/v1/openrouter.rs @@ -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::().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, - max_tokens: u32, + #[serde(skip_serializing_if = "Option::is_none")] + max_tokens: Option, 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, };