From 38c107ea2dfedb361f61b8d63af046d7f98205a7 Mon Sep 17 00:00:00 2001 From: world-executed Date: Thu, 22 Oct 2026 23:03:39 +0800 Subject: [PATCH 1/2] [Rust Frontend] Accept duplicate reasoning aliases in chat messages An assistant history message carrying both `reasoning` and `reasoning_content` failed deserialization with serde's opaque `duplicate field` error, permanently breaking multi-turn agent sessions on the Rust frontend. Normalize the reasoning aliases to the primary `reasoning` field before dispatching to the derived impl, mirroring the Python frontend's _normalize_messages_before. Fixes #63130 Assisted-by: opencode (GLM by Z.ai) Signed-off-by: world-executed --- .../routes/openai/chat_completions/convert.rs | 38 +++++++++ .../server/src/routes/openai/utils/types.rs | 82 ++++++++++++++++++- 2 files changed, 118 insertions(+), 2 deletions(-) diff --git a/rust/src/server/src/routes/openai/chat_completions/convert.rs b/rust/src/server/src/routes/openai/chat_completions/convert.rs index 3573ca95f7b6..686593067ae4 100644 --- a/rust/src/server/src/routes/openai/chat_completions/convert.rs +++ b/rust/src/server/src/routes/openai/chat_completions/convert.rs @@ -1445,6 +1445,44 @@ mod tests { ); } + #[test] + fn prepare_chat_request_tolerates_duplicate_reasoning_aliases() { + // Agent harnesses replaying prior assistant turns emit both + // spellings; the Python frontend accepts this history, so the + // request must not fail with serde's `duplicate field` error. + let request = ChatCompletionRequest { + messages: vec![ + serde_json::from_value(json!({ + "role": "assistant", + "content": "answer", + "reasoning": "primary", + "reasoning_content": "legacy", + })) + .expect("duplicate reasoning aliases are accepted"), + ], + add_generation_prompt: Some(false), + ..base_request() + }; + + let prepared = prepare_chat_request( + request, + &served(&["Qwen/Qwen1.5-0.5B-Chat"]), + ResolvedRequestContext::default(), + ) + .expect("request is valid"); + assert_eq!( + prepared.chat_request.messages, + vec![VllmChatMessage::assistant_blocks(vec![ + AssistantContentBlock::Reasoning { + text: "primary".to_string(), + }, + AssistantContentBlock::Text { + text: "answer".to_string(), + }, + ])] + ); + } + #[test] fn prepare_chat_request_accepts_tools_and_tool_history() { let request = ChatCompletionRequest { diff --git a/rust/src/server/src/routes/openai/utils/types.rs b/rust/src/server/src/routes/openai/utils/types.rs index 0a937c244ced..1abbfc46a569 100644 --- a/rust/src/server/src/routes/openai/utils/types.rs +++ b/rust/src/server/src/routes/openai/utils/types.rs @@ -124,7 +124,9 @@ pub fn validate_stop(stop: &StringOrArray) -> Result<(), validator::ValidationEr #[cfg(test)] mod tests { - use super::{StringOrArray, validate_stop}; + use serde_json::json; + + use super::{ChatMessage, StringOrArray, validate_stop}; #[test] fn validate_stop_accepts_four_stop_strings() { @@ -151,6 +153,56 @@ mod tests { "stop strings must contain at most 4 items" ); } + + fn assistant_reasoning(message: ChatMessage) -> Option { + match message { + ChatMessage::Assistant { reasoning, .. } => reasoning, + other => panic!("expected assistant message, got {other:?}"), + } + } + + #[test] + fn assistant_message_accepts_reasoning_with_every_alias() { + // Agent harnesses replaying prior assistant turns emit both the + // primary field and the legacy alias; serde's derived impls would + // fail with `duplicate field` before any normalization runs. + let message: ChatMessage = serde_json::from_value(json!({ + "role": "assistant", + "content": "ok", + "reasoning": "primary", + "reasoning_content": "legacy", + "thinking": "alias", + })) + .expect("all reasoning aliases together are accepted"); + + assert_eq!(assistant_reasoning(message).as_deref(), Some("primary")); + } + + #[test] + fn assistant_message_falls_back_to_alias_when_reasoning_is_null() { + let message: ChatMessage = serde_json::from_value(json!({ + "role": "assistant", + "content": "ok", + "reasoning": null, + "reasoning_content": "legacy", + })) + .expect("null reasoning with a legacy alias is accepted"); + + assert_eq!(assistant_reasoning(message).as_deref(), Some("legacy")); + } + + #[test] + fn assistant_message_prefers_legacy_alias_over_thinking() { + let message: ChatMessage = serde_json::from_value(json!({ + "role": "assistant", + "content": "ok", + "reasoning_content": "legacy", + "thinking": "alias", + })) + .expect("legacy alias together with thinking is accepted"); + + assert_eq!(assistant_reasoning(message).as_deref(), Some("legacy")); + } } // ============================================================================ @@ -516,6 +568,31 @@ impl Serialize for ChatMessage { } } +/// Merge the reasoning aliases of a message into the primary `reasoning` field. +/// +/// Serde rejects a payload carrying two keys that map to one field with an +/// opaque `duplicate field` error. Mirror the Python frontend's +/// `_normalize_messages_before`: drop the legacy spellings and keep +/// `reasoning` when it is set, otherwise promote an alias value. Agent +/// harnesses replaying prior assistant turns emit both spellings, and the +/// Python frontend accepts them. +fn normalize_reasoning_aliases(message: &mut Value) { + let Some(message) = message.as_object_mut() else { + return; + }; + for alias in ["reasoning_content", "thinking"] { + let Some(alias_value) = message.remove(alias) else { + continue; + }; + if alias_value.is_null() { + continue; + } + if message.get("reasoning").is_none_or(Value::is_null) { + message.insert("reasoning".to_string(), alias_value); + } + } +} + impl<'de> Deserialize<'de> for ChatMessage { /// Dispatch on the role tag: standard roles keep their derived errors, /// and any other string role becomes [`ChatMessage::Custom`]. @@ -523,7 +600,8 @@ impl<'de> Deserialize<'de> for ChatMessage { where D: Deserializer<'de>, { - let value = Value::deserialize(deserializer)?; + let mut value = Value::deserialize(deserializer)?; + normalize_reasoning_aliases(&mut value); let is_custom = value .get("role") .and_then(Value::as_str) From 9fbc652c9b94f73cc7f5bbde28e22917f5d551e8 Mon Sep 17 00:00:00 2001 From: Bugen Zhao Date: Mon, 2 Nov 2026 11:26:07 +0000 Subject: [PATCH 2/2] [Rust Frontend] Make reasoning alias normalization the only alias source `normalize_reasoning_aliases` removes `reasoning_content` and `thinking` before the derived impl runs, so the `#[serde(alias)]` attributes on `reasoning` were unreachable. Drop them and keep the alias set in one place. Fold the alias tests into one table at the owning type, adding reversed key order, an empty `reasoning` winning, and a wrong-type alias being dropped; remove the `prepare_chat_request` test, which re-checked unchanged lowering. Note in the docs that `thinking` is a Rust-only, lowest-precedence alias. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CJckCCBHXGwmtTiEEqpHQq Signed-off-by: Bugen Zhao --- .../routes/openai/chat_completions/convert.rs | 38 ------ .../server/src/routes/openai/utils/types.rs | 109 ++++++++---------- 2 files changed, 48 insertions(+), 99 deletions(-) diff --git a/rust/src/server/src/routes/openai/chat_completions/convert.rs b/rust/src/server/src/routes/openai/chat_completions/convert.rs index 686593067ae4..3573ca95f7b6 100644 --- a/rust/src/server/src/routes/openai/chat_completions/convert.rs +++ b/rust/src/server/src/routes/openai/chat_completions/convert.rs @@ -1445,44 +1445,6 @@ mod tests { ); } - #[test] - fn prepare_chat_request_tolerates_duplicate_reasoning_aliases() { - // Agent harnesses replaying prior assistant turns emit both - // spellings; the Python frontend accepts this history, so the - // request must not fail with serde's `duplicate field` error. - let request = ChatCompletionRequest { - messages: vec![ - serde_json::from_value(json!({ - "role": "assistant", - "content": "answer", - "reasoning": "primary", - "reasoning_content": "legacy", - })) - .expect("duplicate reasoning aliases are accepted"), - ], - add_generation_prompt: Some(false), - ..base_request() - }; - - let prepared = prepare_chat_request( - request, - &served(&["Qwen/Qwen1.5-0.5B-Chat"]), - ResolvedRequestContext::default(), - ) - .expect("request is valid"); - assert_eq!( - prepared.chat_request.messages, - vec![VllmChatMessage::assistant_blocks(vec![ - AssistantContentBlock::Reasoning { - text: "primary".to_string(), - }, - AssistantContentBlock::Text { - text: "answer".to_string(), - }, - ])] - ); - } - #[test] fn prepare_chat_request_accepts_tools_and_tool_history() { let request = ChatCompletionRequest { diff --git a/rust/src/server/src/routes/openai/utils/types.rs b/rust/src/server/src/routes/openai/utils/types.rs index 1abbfc46a569..a88d82116856 100644 --- a/rust/src/server/src/routes/openai/utils/types.rs +++ b/rust/src/server/src/routes/openai/utils/types.rs @@ -124,8 +124,6 @@ pub fn validate_stop(stop: &StringOrArray) -> Result<(), validator::ValidationEr #[cfg(test)] mod tests { - use serde_json::json; - use super::{ChatMessage, StringOrArray, validate_stop}; #[test] @@ -154,54 +152,45 @@ mod tests { ); } - fn assistant_reasoning(message: ChatMessage) -> Option { - match message { - ChatMessage::Assistant { reasoning, .. } => reasoning, - other => panic!("expected assistant message, got {other:?}"), - } - } - #[test] - fn assistant_message_accepts_reasoning_with_every_alias() { - // Agent harnesses replaying prior assistant turns emit both the - // primary field and the legacy alias; serde's derived impls would - // fail with `duplicate field` before any normalization runs. - let message: ChatMessage = serde_json::from_value(json!({ - "role": "assistant", - "content": "ok", - "reasoning": "primary", - "reasoning_content": "legacy", - "thinking": "alias", - })) - .expect("all reasoning aliases together are accepted"); - - assert_eq!(assistant_reasoning(message).as_deref(), Some("primary")); - } - - #[test] - fn assistant_message_falls_back_to_alias_when_reasoning_is_null() { - let message: ChatMessage = serde_json::from_value(json!({ - "role": "assistant", - "content": "ok", - "reasoning": null, - "reasoning_content": "legacy", - })) - .expect("null reasoning with a legacy alias is accepted"); - - assert_eq!(assistant_reasoning(message).as_deref(), Some("legacy")); - } - - #[test] - fn assistant_message_prefers_legacy_alias_over_thinking() { - let message: ChatMessage = serde_json::from_value(json!({ - "role": "assistant", - "content": "ok", - "reasoning_content": "legacy", - "thinking": "alias", - })) - .expect("legacy alias together with thinking is accepted"); - - assert_eq!(assistant_reasoning(message).as_deref(), Some("legacy")); + fn assistant_reasoning_aliases_fold_into_reasoning() { + for (fields, expected) in [ + ( + r#""reasoning":"canonical","reasoning_content":"legacy","thinking":"thought""#, + Some("canonical"), + ), + ( + r#""reasoning_content":"legacy","reasoning":"canonical""#, + Some("canonical"), + ), + ( + r#""reasoning":null,"reasoning_content":"legacy""#, + Some("legacy"), + ), + (r#""reasoning":"","reasoning_content":"legacy""#, Some("")), + ( + r#""reasoning":"canonical","reasoning_content":42"#, + Some("canonical"), + ), + ( + r#""reasoning_content":"legacy","thinking":"thought""#, + Some("legacy"), + ), + ( + r#""reasoning_content":null,"thinking":"thought""#, + Some("thought"), + ), + (r#""reasoning_content":null"#, None), + ] { + let message = serde_json::from_str(&format!( + r#"{{"role":"assistant","content":"answer",{fields}}}"# + )) + .unwrap_or_else(|error| panic!("{fields}: {error}")); + let ChatMessage::Assistant { reasoning, .. } = message else { + panic!("{fields}: expected an assistant message, got {message:?}"); + }; + assert_eq!(reasoning.as_deref(), expected, "{fields}"); + } } } @@ -510,9 +499,8 @@ pub enum ChatMessage { content: Option, name: Option, tool_calls: Option>, - /// Reasoning content for reasoning-capable models. - #[serde(alias = "reasoning_content")] - #[serde(alias = "thinking")] + /// Reasoning content for reasoning-capable models. Its aliases are + /// folded in by `normalize_reasoning_aliases`. reasoning: Option, }, #[serde(rename = "tool")] @@ -568,14 +556,12 @@ impl Serialize for ChatMessage { } } -/// Merge the reasoning aliases of a message into the primary `reasoning` field. +/// Fold the `reasoning_content` and `thinking` aliases of a message into +/// `reasoning`, so clients replaying history may send several spellings. /// -/// Serde rejects a payload carrying two keys that map to one field with an -/// opaque `duplicate field` error. Mirror the Python frontend's -/// `_normalize_messages_before`: drop the legacy spellings and keep -/// `reasoning` when it is set, otherwise promote an alias value. Agent -/// harnesses replaying prior assistant turns emit both spellings, and the -/// Python frontend accepts them. +/// Mirrors the Python frontend's `_normalize_messages_before`: aliases are +/// always removed, and the first non-null one fills `reasoning` only when it is +/// absent or null. `thinking` is a Rust-only alias with the lowest precedence. fn normalize_reasoning_aliases(message: &mut Value) { let Some(message) = message.as_object_mut() else { return; @@ -594,8 +580,9 @@ fn normalize_reasoning_aliases(message: &mut Value) { } impl<'de> Deserialize<'de> for ChatMessage { - /// Dispatch on the role tag: standard roles keep their derived errors, - /// and any other string role becomes [`ChatMessage::Custom`]. + /// Normalize the reasoning aliases, then dispatch on the role tag: standard + /// roles keep their derived errors, and any other string role becomes + /// [`ChatMessage::Custom`]. fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>,