fix(provider): tolerate duplicate reasoning_content key in compat parse (#4207)

This commit is contained in:
Mega Mind
2026-06-28 21:25:08 -07:00
committed by GitHub
parent d4fb491e0f
commit 5239ef3ceb
2 changed files with 176 additions and 40 deletions
@@ -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).
@@ -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<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
struct Shadow {
#[serde(default)]
content: Option<String>,
#[serde(default)]
reasoning_content: Option<String>,
#[serde(default)]
reasoning: Option<String>,
#[serde(default)]
tool_calls: Option<Vec<ToolCall>>,
#[serde(default)]
function_call: Option<Function>,
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<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: MapAccess<'de>,
{
let mut content: Option<String> = None;
let mut reasoning_content: Option<String> = None;
let mut reasoning: Option<String> = None;
let mut tool_calls: Option<Vec<ToolCall>> = None;
let mut function_call: Option<Function> = 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::<String>()? {
match key.as_str() {
"content" => {
if let Some(v) = map.next_value::<Option<String>>()? {
content = Some(v);
}
}
"reasoning_content" => {
if let Some(v) = map.next_value::<Option<String>>()? {
reasoning_content = Some(v);
}
}
"reasoning" => {
if let Some(v) = map.next_value::<Option<String>>()? {
reasoning = Some(v);
}
}
"tool_calls" => {
if let Some(v) = map.next_value::<Option<Vec<ToolCall>>>()? {
tool_calls = Some(v);
}
}
"function_call" => {
if let Some(v) = map.next_value::<Option<Function>>()? {
function_call = Some(v);
}
}
_ => {
map.next_value::<IgnoredAny>()?;
}
}
}
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<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
struct Shadow {
#[serde(default)]
content: Option<String>,
#[serde(default)]
reasoning_content: Option<String>,
#[serde(default)]
reasoning: Option<String>,
#[serde(default)]
tool_calls: Option<Vec<StreamToolCallDelta>>,
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<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: MapAccess<'de>,
{
let mut content: Option<String> = None;
let mut reasoning_content: Option<String> = None;
let mut reasoning: Option<String> = None;
let mut tool_calls: Option<Vec<StreamToolCallDelta>> = None;
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"content" => {
if let Some(v) = map.next_value::<Option<String>>()? {
content = Some(v);
}
}
"reasoning_content" => {
if let Some(v) = map.next_value::<Option<String>>()? {
reasoning_content = Some(v);
}
}
"reasoning" => {
if let Some(v) = map.next_value::<Option<String>>()? {
reasoning = Some(v);
}
}
"tool_calls" => {
if let Some(v) = map.next_value::<Option<Vec<StreamToolCallDelta>>>()? {
tool_calls = Some(v);
}
}
_ => {
map.next_value::<IgnoredAny>()?;
}
}
}
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)
}
}