Auditor drill: OpenRouter lane sweep + warren Gitea repoint (post-PR11 polish) #14
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
reasoning: Option<String>,
|
||||
}
|
||||
|
||||
#[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
|
||||
|
||||
Reference in New Issue
Block a user