From 6de15ad5f57f8fa27f0edae653f8fe5eb2d9c6a4 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 28 Aug 2026 17:07:12 -0500 Subject: [PATCH] gateway/v1: tolerate reasoning-model responses on the openrouter lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/gateway/src/v1/openrouter.rs | 44 +++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/crates/gateway/src/v1/openrouter.rs b/crates/gateway/src/v1/openrouter.rs index 610c5eb..bebe575 100644 --- a/crates/gateway/src/v1/openrouter.rs +++ b/crates/gateway/src/v1/openrouter.rs @@ -102,7 +102,21 @@ pub async fn chat( let latency_ms = t0.elapsed().as_millis(); let choice = parsed.choices.into_iter().next() .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 chars: usize = req.messages.iter().map(|m| m.text().chars().count()).sum(); @@ -169,7 +183,18 @@ struct ORChoice { } #[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, + #[serde(default)] + reasoning: Option, +} #[derive(Deserialize)] struct ORUsage { prompt_tokens: u32, completion_tokens: u32 } @@ -178,6 +203,21 @@ struct ORUsage { prompt_tokens: u32, completion_tokens: u32 } mod tests { 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] fn resolve_openrouter_key_does_not_panic() { // Smoke test — all three sources may or may not be set depending