From 4f6e434c4991992a20cc3e2ec580fe8c0ff4b0f6 Mon Sep 17 00:00:00 2001 From: Elias Shieh <32212900+ting-hong-shieh@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:06:15 +0800 Subject: [PATCH 1/2] fix(translation): replay assistant reasoning as reasoning_content too Assistant reasoning is replayed in request history as `reasoning`, which most OpenAI-compatible providers read. Reasoning-required upstreams look for `reasoning_content` specifically and treat it as present-or-absent rather than reading the alias, so a follow-up turn is rejected with "The reasoning_content in the thinking mode must be passed back to the API" even though the reasoning was replayed. Write both spellings from a single helper wherever reasoning text is emitted, covering the plaintext path and the text that structured details cannot represent. The decode side already accepts either spelling, so a replayed request round-trips unchanged. Both spellings carry the same text, so a provider that reads either sees the same reasoning, and a provider that rejects unknown message fields was already receiving `reasoning`. Closes #449 Signed-off-by: Elias Shieh <32212900+ting-hong-shieh@users.noreply.github.com> --- .../src/codecs/openai_chat/buffered.rs | 21 +++-- .../tests/request_translation.rs | 82 ++++++++++++++++++- 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs index d829ca31..ca504be5 100644 --- a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs +++ b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs @@ -881,9 +881,7 @@ fn encode_openai_message_plaintext_reasoning(message: &mut Value, content: &[Con }) .collect::>() .join("\n"); - if !reasoning.is_empty() { - message["reasoning"] = Value::String(reasoning); - } + set_openai_reasoning_text(message, reasoning); } // Adds exact provider details and text that cannot be recovered from those details. @@ -907,9 +905,22 @@ fn encode_openai_message_structured_reasoning( }) .collect::>() .join("\n"); - if !fallback.is_empty() { - message["reasoning"] = Value::String(fallback); + set_openai_reasoning_text(message, fallback); +} + +// Writes replayed reasoning under both OpenAI-compatible spellings. +// +// `reasoning` is what most OpenAI-compatible providers read. Reasoning-required +// upstreams look for `reasoning_content` specifically and reject a follow-up turn +// whose assistant history lacks it, treating the field as present-or-absent rather +// than reading the alias, so both are written. The decode side already accepts +// either spelling. +fn set_openai_reasoning_text(message: &mut Value, reasoning: String) { + if reasoning.is_empty() { + return; } + message["reasoning"] = Value::String(reasoning.clone()); + message["reasoning_content"] = Value::String(reasoning); } // Checks whether any block in a message is a tool result. diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index b7c4121a..719ee993 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -8,7 +8,7 @@ pub mod common; use pretty_assertions::assert_eq; use serde_json::{Value, json}; use switchyard_translation::{ - LossyConversionPolicy, TranslationEngine, TranslationPolicy, WireFormat, + LossyConversionPolicy, PreservationPolicy, TranslationEngine, TranslationPolicy, WireFormat, }; use common::{REASONING_MODEL, normalized_policy, shell_tool_call}; @@ -1010,6 +1010,85 @@ fn responses_reasoning_items_attach_to_tool_call_turn_for_openai_chat() -> TestR Ok(()) } +// Reasoning-required upstreams look for `reasoning_content` and treat it as +// present-or-absent, so replaying only `reasoning` fails the next turn. Both +// spellings carry the same text, and tool calls stay alongside them. +#[test] +fn openai_chat_replays_reasoning_under_both_spellings() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "model": "deepseek-reasoner", + "messages": [ + {"role": "user", "content": "What is 2+2? Use the calculator."}, + { + "role": "assistant", + "content": "I'll call the tool.", + "reasoning_content": "The user wants 2+2. Call the calculator.", + "tool_calls": [{ + "id": "call_abc123", + "type": "function", + "function": {"name": "calculator", "arguments": "{\"a\": 2, \"b\": 2}"} + }] + }, + {"role": "tool", "tool_call_id": "call_abc123", "content": "4"} + ] + }); + + let output = engine + .translate_request( + WireFormat::OpenAiChat, + WireFormat::OpenAiChat, + &body, + &TranslationPolicy { + preservation: PreservationPolicy::Disabled, + ..TranslationPolicy::default() + }, + )? + .body; + + let assistant = &output["messages"][1]; + assert_eq!( + assistant["reasoning_content"], + "The user wants 2+2. Call the calculator." + ); + assert_eq!(assistant["reasoning"], assistant["reasoning_content"]); + assert_eq!(assistant["content"], "I'll call the tool."); + assert_eq!(assistant["tool_calls"][0]["id"], "call_abc123"); + assert_eq!(output["messages"][2]["role"], "tool"); + assert_eq!(output["messages"][2]["tool_call_id"], "call_abc123"); + Ok(()) +} + +// A turn carrying no reasoning must not gain either spelling. +#[test] +fn openai_chat_without_reasoning_sends_neither_spelling() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "model": "deepseek-reasoner", + "messages": [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + {"role": "user", "content": "continue"} + ] + }); + + let output = engine + .translate_request( + WireFormat::OpenAiChat, + WireFormat::OpenAiChat, + &body, + &TranslationPolicy { + preservation: PreservationPolicy::Disabled, + ..TranslationPolicy::default() + }, + )? + .body; + + assert!(output["messages"][1].get("reasoning").is_none()); + assert!(output["messages"][1].get("reasoning_content").is_none()); + Ok(()) +} + // Verifies a reasoning item merges into the assistant message that follows it. #[test] fn responses_reasoning_item_merges_into_next_assistant_message_for_openai_chat() -> TestResult { @@ -1043,6 +1122,7 @@ fn responses_reasoning_item_merges_into_next_assistant_message_for_openai_chat() { "role": "assistant", "content": "Let me check.", + "reasoning_content": "Reading.", "reasoning": "Reading." } ]) From 737adc1c0b0aaa67659393f8160549abc6b0ae17 Mon Sep 17 00:00:00 2001 From: Elias Shieh <32212900+ting-hong-shieh@users.noreply.github.com> Date: Tue, 18 Aug 2026 06:37:07 +0800 Subject: [PATCH 2/2] test(translation): pin both reasoning spellings on the structured path The new tests covered the plaintext path only. `openai_chat_encrypted_reasoning_details_retain_fallback` exercises the other branch, where encrypted details carry no readable text and the fallback is emitted, so it now asserts `reasoning_content` alongside `reasoning`. Signed-off-by: Elias Shieh <32212900+ting-hong-shieh@users.noreply.github.com> --- crates/switchyard-translation/tests/request_translation.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index 719ee993..b6eb0bbe 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -1213,6 +1213,7 @@ fn openai_chat_encrypted_reasoning_details_retain_fallback() -> TestResult { assert_eq!(output["messages"][0]["reasoning_details"], details); assert_eq!(output["messages"][0]["reasoning"], "fallback text"); + assert_eq!(output["messages"][0]["reasoning_content"], "fallback text"); Ok(()) }