diff --git a/src/openhuman/inference/provider/compatible_tests.rs b/src/openhuman/inference/provider/compatible_tests.rs index 3ca4a8114..53f98ed9a 100644 --- a/src/openhuman/inference/provider/compatible_tests.rs +++ b/src/openhuman/inference/provider/compatible_tests.rs @@ -3204,6 +3204,52 @@ fn reasoning_and_reasoning_content_both_present_in_stream_delta_does_not_error() ); } +/// Regression for Sentry TAURI-RUST-85R: NVIDIA's OpenAI-compat endpoint returns +/// `reasoning_content` TWICE in the same message object for some thinking models +/// (e.g. `stepfun-ai/step-3.7-flash`). The derived `Shadow` struct strict-rejected +/// the repeated key with `duplicate field \`reasoning_content\``, dropping the +/// whole completion (2,037 events). The map-fold deserializer tolerates it — +/// the last non-null copy wins. +#[test] +fn duplicate_reasoning_content_key_does_not_error() { + let json = r#"{"choices":[{"message":{"content":null,"reasoning_content":"first cot","reasoning_content":"second cot"}}]}"#; + let resp: ApiChatResponse = serde_json::from_str(json) + .expect("a repeated reasoning_content key must parse without a duplicate-field error"); + assert_eq!( + resp.choices[0].message.reasoning_content.as_deref(), + Some("second cot"), + "the last non-null reasoning_content copy wins" + ); +} + +/// A repeated `reasoning_content` whose second copy is `null` must not clobber +/// the real first value — the CoT has to survive to be replayed verbatim. +#[test] +fn duplicate_reasoning_content_key_null_second_copy_keeps_value() { + let json = r#"{"choices":[{"message":{"content":null,"reasoning_content":"real cot","reasoning_content":null}}]}"#; + let resp: ApiChatResponse = serde_json::from_str(json) + .expect("a repeated reasoning_content key must parse without a duplicate-field error"); + assert_eq!( + resp.choices[0].message.reasoning_content.as_deref(), + Some("real cot"), + "a null second copy must not clobber the real value" + ); +} + +/// Same duplicate-key regression on the streaming delta path (TAURI-RUST-85R +/// also hits the native stream parser at `compatible_stream_native.rs`). +#[test] +fn duplicate_reasoning_content_key_in_stream_delta_does_not_error() { + let json = r#"{"choices":[{"delta":{"reasoning_content":"first cot","reasoning_content":"second cot"},"finish_reason":null}]}"#; + let chunk: StreamChunkResponse = serde_json::from_str(json) + .expect("a repeated reasoning_content key must parse without a duplicate-field error"); + assert_eq!( + chunk.choices[0].delta.reasoning_content.as_deref(), + Some("second cot"), + "the last non-null reasoning_content copy wins" + ); +} + /// End-to-end: a tool-call turn whose reasoning arrived under the `reasoning` /// alias must still be surfaced by `parse_native_response` so the agent loop /// can replay it on the follow-up request (the issue #3094 failure path). diff --git a/src/openhuman/inference/provider/compatible_types.rs b/src/openhuman/inference/provider/compatible_types.rs index 65bbe105b..189339c0b 100644 --- a/src/openhuman/inference/provider/compatible_types.rs +++ b/src/openhuman/inference/provider/compatible_types.rs @@ -350,34 +350,84 @@ pub(crate) struct ResponseMessage { // A serde `alias` maps both names onto one field slot, which makes a provider // that emits BOTH keys in the same object (some OpenRouter / vLLM-SGLang // proxies do) fail with `duplicate field \`reasoning_content\``, dropping the -// entire response. Deserializing them as separate optional fields tolerates -// any combination; the canonical `reasoning_content` wins when both are present. +// entire response. A derived `Shadow` struct fixes the distinct-name collision +// but still strict-rejects a key REPEATED in the same object — NVIDIA's compat +// endpoint returns `reasoning_content` twice for some thinking models +// (e.g. `stepfun-ai/step-3.7-flash`), which dropped the whole completion +// (TAURI-RUST-85R: 2,037 events). So fold over the map by hand: each known key +// overwrites (last non-null wins), duplicates are tolerated, and the canonical +// `reasoning_content` still wins over the `reasoning` alias. impl<'de> Deserialize<'de> for ResponseMessage { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { - #[derive(Deserialize)] - struct Shadow { - #[serde(default)] - content: Option, - #[serde(default)] - reasoning_content: Option, - #[serde(default)] - reasoning: Option, - #[serde(default)] - tool_calls: Option>, - #[serde(default)] - function_call: Option, + use serde::de::{IgnoredAny, MapAccess, Visitor}; + + struct ResponseMessageVisitor; + + impl<'de> Visitor<'de> for ResponseMessageVisitor { + type Value = ResponseMessage; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("an OpenAI-compatible chat completion message object") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut content: Option = None; + let mut reasoning_content: Option = None; + let mut reasoning: Option = None; + let mut tool_calls: Option> = None; + let mut function_call: Option = None; + + // A repeated key overwrites rather than erroring (last non-null + // wins) — a `null` second copy must not clobber a real value. + while let Some(key) = map.next_key::()? { + match key.as_str() { + "content" => { + if let Some(v) = map.next_value::>()? { + content = Some(v); + } + } + "reasoning_content" => { + if let Some(v) = map.next_value::>()? { + reasoning_content = Some(v); + } + } + "reasoning" => { + if let Some(v) = map.next_value::>()? { + reasoning = Some(v); + } + } + "tool_calls" => { + if let Some(v) = map.next_value::>>()? { + tool_calls = Some(v); + } + } + "function_call" => { + if let Some(v) = map.next_value::>()? { + function_call = Some(v); + } + } + _ => { + map.next_value::()?; + } + } + } + + Ok(ResponseMessage { + content, + reasoning_content: reasoning_content.or(reasoning), + tool_calls, + function_call, + }) + } } - let shadow = Shadow::deserialize(deserializer)?; - Ok(ResponseMessage { - content: shadow.content, - reasoning_content: shadow.reasoning_content.or(shadow.reasoning), - tool_calls: shadow.tool_calls, - function_call: shadow.function_call, - }) + deserializer.deserialize_map(ResponseMessageVisitor) } } @@ -501,32 +551,72 @@ pub(crate) struct StreamDelta { } // Manual `Deserialize` for the same reason as `ResponseMessage`: a streaming -// delta that carries both `reasoning` and `reasoning_content` must not fail -// with `duplicate field`. They deserialize as distinct keys and fold into the -// canonical `reasoning_content` (canonical wins when both are present). +// delta that carries both `reasoning` and `reasoning_content` — or the SAME key +// twice (NVIDIA compat SSE, TAURI-RUST-85R) — must not fail with `duplicate +// field`. Fold over the map by hand so duplicates overwrite (last non-null +// wins) and the canonical `reasoning_content` wins over the `reasoning` alias. impl<'de> Deserialize<'de> for StreamDelta { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { - #[derive(Deserialize)] - struct Shadow { - #[serde(default)] - content: Option, - #[serde(default)] - reasoning_content: Option, - #[serde(default)] - reasoning: Option, - #[serde(default)] - tool_calls: Option>, + use serde::de::{IgnoredAny, MapAccess, Visitor}; + + struct StreamDeltaVisitor; + + impl<'de> Visitor<'de> for StreamDeltaVisitor { + type Value = StreamDelta; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("an OpenAI-compatible streaming delta object") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut content: Option = None; + let mut reasoning_content: Option = None; + let mut reasoning: Option = None; + let mut tool_calls: Option> = None; + + while let Some(key) = map.next_key::()? { + match key.as_str() { + "content" => { + if let Some(v) = map.next_value::>()? { + content = Some(v); + } + } + "reasoning_content" => { + if let Some(v) = map.next_value::>()? { + reasoning_content = Some(v); + } + } + "reasoning" => { + if let Some(v) = map.next_value::>()? { + reasoning = Some(v); + } + } + "tool_calls" => { + if let Some(v) = map.next_value::>>()? { + tool_calls = Some(v); + } + } + _ => { + map.next_value::()?; + } + } + } + + Ok(StreamDelta { + content, + reasoning_content: reasoning_content.or(reasoning), + tool_calls, + }) + } } - let shadow = Shadow::deserialize(deserializer)?; - Ok(StreamDelta { - content: shadow.content, - reasoning_content: shadow.reasoning_content.or(shadow.reasoning), - tool_calls: shadow.tool_calls, - }) + deserializer.deserialize_map(StreamDeltaVisitor) } }