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 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
|
||||||
|
|||||||
Reference in New Issue
Block a user