gateway/v1: tolerate reasoning-model responses on the openrouter lane

Reasoning models (GLM-5.x, deepseek-v4-pro) return content:null plus a
reasoning field when max_tokens exhausts mid-thought; ORMessageResp's bare
String made serde reject the whole body ('invalid openrouter response'),
taking down every reasoning model on the lane — including phase2's
configured z-ai/glm-5.2. content/reasoning are now Options; content stays
the only text returned (empty when null, so callers' schema validation +
retry budgets handle it deterministically); reasoning-only responses log a
warn with finish_reason. Unit test pins both shapes.

Found during the 2026-08-28 VIX founder-lane dogfood.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
root
2026-08-28 17:07:12 -05:00
co-authored by Claude Fable 5
parent 8dd0d46c3c
commit 6de15ad5f5
+42 -2
View File
@@ -102,7 +102,21 @@ pub async fn chat(
let latency_ms = t0.elapsed().as_millis(); let latency_ms = t0.elapsed().as_millis();
let choice = parsed.choices.into_iter().next() let choice = parsed.choices.into_iter().next()
.ok_or_else(|| "openrouter returned no choices".to_string())?; .ok_or_else(|| "openrouter returned no choices".to_string())?;
let text = choice.message.content; let reasoning_only = choice.message.content.is_none() && choice.message.reasoning.is_some();
let text = choice.message.content.unwrap_or_default();
if reasoning_only {
// The model spent its whole budget thinking. Return well-formed empty
// content (callers' schema validation + retry budgets handle it
// deterministically) rather than a decode error that reads as a
// gateway fault — and never pass thinking-text off as an answer.
tracing::warn!(
target: "v1.chat",
provider = "openrouter",
model = %model,
finish = %choice.finish_reason.as_deref().unwrap_or("?"),
"reasoning-only response (content null) — raise max_tokens or use a non-reasoning model",
);
}
let prompt_tokens = parsed.usage.as_ref().map(|u| u.prompt_tokens).unwrap_or_else(|| { let prompt_tokens = parsed.usage.as_ref().map(|u| u.prompt_tokens).unwrap_or_else(|| {
let chars: usize = req.messages.iter().map(|m| m.text().chars().count()).sum(); let chars: usize = req.messages.iter().map(|m| m.text().chars().count()).sum();
@@ -169,7 +183,18 @@ struct ORChoice {
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct ORMessageResp { content: String } struct ORMessageResp {
// Reasoning models (GLM-5.x, deepseek-v4-pro) return `content: null` plus a
// `reasoning` field when they exhaust max_tokens mid-thought. A bare String
// here made serde reject the whole body ("invalid openrouter response",
// 2026-08-28) — which took down every reasoning model on the lane,
// including phase2's configured glm-5.2. Content stays the only text we
// ever return; reasoning is observability, never an answer.
#[serde(default)]
content: Option<String>,
#[serde(default)]
reasoning: Option<String>,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct ORUsage { prompt_tokens: u32, completion_tokens: u32 } struct ORUsage { prompt_tokens: u32, completion_tokens: u32 }
@@ -178,6 +203,21 @@ struct ORUsage { prompt_tokens: u32, completion_tokens: u32 }
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn reasoning_model_null_content_parses() {
// GLM-5.x / deepseek-v4-pro shape: content null + reasoning present
// (the 2026-08-28 decode failure). Must parse, not error.
let body = r#"{"choices":[{"message":{"role":"assistant","content":null,"reasoning":"thinking…"},"finish_reason":"length"}],"usage":{"prompt_tokens":5,"completion_tokens":10}}"#;
let parsed: ORChatResponse = serde_json::from_str(body).expect("null content must parse");
let choice = &parsed.choices[0];
assert!(choice.message.content.is_none());
assert_eq!(choice.message.reasoning.as_deref(), Some("thinking…"));
// And the normal shape still parses with reasoning absent.
let normal = r#"{"choices":[{"message":{"role":"assistant","content":"OK"},"finish_reason":"stop"}]}"#;
let parsed: ORChatResponse = serde_json::from_str(normal).expect("normal shape");
assert_eq!(parsed.choices[0].message.content.as_deref(), Some("OK"));
}
#[test] #[test]
fn resolve_openrouter_key_does_not_panic() { fn resolve_openrouter_key_does_not_panic() {
// Smoke test — all three sources may or may not be set depending // Smoke test — all three sources may or may not be set depending