From 7933e1473f4a28c212867e1a08db2c26b14c7abf Mon Sep 17 00:00:00 2001 From: Alex Steiner Date: Thu, 13 Aug 2026 16:10:30 -0700 Subject: [PATCH 1/3] fix(translation): preserve chat reasoning details Signed-off-by: Alex Steiner --- .../libsy-llm-client/tests/observability.rs | 1 + crates/libsy/src/algorithms/util/affinity.rs | 1 + crates/libsy/src/algorithms/util/llm_judge.rs | 1 + crates/protocol/src/llm.rs | 3 + crates/protocol/src/stream.rs | 34 +++++++-- .../src/codecs/anthropic/buffered.rs | 3 + .../src/codecs/anthropic/stream.rs | 13 ++++ .../src/codecs/common.rs | 18 ++++- .../src/codecs/openai_chat/buffered.rs | 74 ++++++++++++++++++- .../src/codecs/openai_chat/stream.rs | 35 +++++++-- .../src/codecs/responses/buffered.rs | 3 + .../src/codecs/responses/stream.rs | 6 ++ .../tests/request_translation.rs | 65 +++++++++++++++- .../tests/response_translation.rs | 65 ++++++++++++++++ .../tests/stream_translation.rs | 52 +++++++++++++ 15 files changed, 356 insertions(+), 18 deletions(-) diff --git a/crates/libsy-llm-client/tests/observability.rs b/crates/libsy-llm-client/tests/observability.rs index aefae5bc2..211745a98 100644 --- a/crates/libsy-llm-client/tests/observability.rs +++ b/crates/libsy-llm-client/tests/observability.rs @@ -568,6 +568,7 @@ async fn affinity_warns_once_when_request_has_no_usable_identity() -> switchyard content: vec![ContentBlock::Reasoning { text: "provider reasoning".to_string(), signature: None, + details: Vec::new(), }], }], ..LlmRequest::default() diff --git a/crates/libsy/src/algorithms/util/affinity.rs b/crates/libsy/src/algorithms/util/affinity.rs index d372fdef0..71378b8b8 100644 --- a/crates/libsy/src/algorithms/util/affinity.rs +++ b/crates/libsy/src/algorithms/util/affinity.rs @@ -520,6 +520,7 @@ mod tests { ContentBlock::Reasoning { text: "Internal provider reasoning.".to_string(), signature: Some("provider-signature".to_string()), + details: Vec::new(), }, ], }); diff --git a/crates/libsy/src/algorithms/util/llm_judge.rs b/crates/libsy/src/algorithms/util/llm_judge.rs index 63223bd6c..4fe36fbd2 100644 --- a/crates/libsy/src/algorithms/util/llm_judge.rs +++ b/crates/libsy/src/algorithms/util/llm_judge.rs @@ -403,6 +403,7 @@ mod tests { ContentBlock::Reasoning { text: r#"{"ok":false}"#.to_string(), signature: None, + details: Vec::new(), }, ); } diff --git a/crates/protocol/src/llm.rs b/crates/protocol/src/llm.rs index c2386c23f..04cfddddc 100644 --- a/crates/protocol/src/llm.rs +++ b/crates/protocol/src/llm.rs @@ -87,6 +87,9 @@ pub enum ContentBlock { text: String, /// Provider signature used to validate or continue the reasoning block. signature: Option, + /// Structured reasoning details that must be replayed without modification. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + details: Vec, }, /// Image content. Image { diff --git a/crates/protocol/src/stream.rs b/crates/protocol/src/stream.rs index e51b68dce..987ea9274 100644 --- a/crates/protocol/src/stream.rs +++ b/crates/protocol/src/stream.rs @@ -193,11 +193,18 @@ impl AggLlmResponse { text, }); } - ContentBlock::Reasoning { text, .. } => { - chunks.push(LlmResponseChunk::ReasoningDelta { - index: output_index, - text, - }); + ContentBlock::Reasoning { text, details, .. } => { + if !details.is_empty() { + chunks.push(LlmResponseChunk::ReasoningDetailsDelta { + index: output_index, + details, + }); + } else { + chunks.push(LlmResponseChunk::ReasoningDelta { + index: output_index, + text, + }); + } } ContentBlock::ToolCall(tool) => { let args = serde_json::to_string(&tool.arguments).unwrap_or_default(); @@ -272,6 +279,13 @@ pub enum LlmResponseChunk { /// Reasoning fragment. text: String, }, + /// Adds structured reasoning details to one output index. + ReasoningDetailsDelta { + /// Provider output index. + index: usize, + /// Reasoning detail objects in provider order. + details: Vec, + }, /// Adds or updates a tool call at one index. ToolCallDelta { /// Tool-call index within the response. @@ -322,6 +336,7 @@ pub struct ResponseAccumulator { model: Option, text: String, reasoning: Option, + reasoning_details: Vec, tool_calls: BTreeMap, usage: Usage, stop_reason: Option, @@ -359,6 +374,9 @@ impl ResponseAccumulator { .get_or_insert_with(String::new) .push_str(&text); } + LlmResponseChunk::ReasoningDetailsDelta { details, .. } => { + self.reasoning_details.extend(details); + } LlmResponseChunk::ToolCallDelta { index, id, @@ -388,10 +406,11 @@ impl ResponseAccumulator { /// tool calls (by ascending delta index) — a single assistant output. pub fn finish(self) -> AggLlmResponse { let mut content = Vec::new(); - if let Some(reasoning) = self.reasoning { + if self.reasoning.is_some() || !self.reasoning_details.is_empty() { content.push(ContentBlock::Reasoning { - text: reasoning, + text: self.reasoning.unwrap_or_default(), signature: None, + details: self.reasoning_details, }); } if !self.text.is_empty() { @@ -611,6 +630,7 @@ mod tests { ContentBlock::Reasoning { text: "think".to_string(), signature: None, + details: Vec::new(), }, ContentBlock::Text { text: "answer".to_string(), diff --git a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs index fe5573039..75f4d6848 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs @@ -525,6 +525,7 @@ fn decode_anthropic_content_block( .and_then(Value::as_str) .filter(|signature| !signature.is_empty()) .map(ToOwned::to_owned), + details: Vec::new(), }], Some("tool_use") => vec![ContentBlock::ToolCall(ToolCall { id: block @@ -794,6 +795,7 @@ fn encode_one_anthropic_response_block(block: &ContentBlock) -> Vec { ContentBlock::Reasoning { text, signature: None, + .. } => vec![json!({ "type": "thinking", "thinking": text, @@ -812,6 +814,7 @@ fn encode_one_anthropic_block(block: &ContentBlock) -> Vec { ContentBlock::Reasoning { text, signature: Some(signature), + .. } if !signature.is_empty() => vec![json!({ "type": "thinking", "thinking": text, diff --git a/crates/switchyard-translation/src/codecs/anthropic/stream.rs b/crates/switchyard-translation/src/codecs/anthropic/stream.rs index 450d796e1..a430d32c7 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/stream.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/stream.rs @@ -6,6 +6,7 @@ use serde_json::{Map, Value, json}; use crate::LlmResponseChunk; +use crate::codecs::common::reasoning_text_from_details; use crate::codecs::stream::{ StreamCodec, StreamTranslationState, record_source_identity, target_message_id_or_source_message_id, target_model_or_source_model, @@ -192,6 +193,18 @@ fn encode_anthropic_stream( })); out } + LlmResponseChunk::ReasoningDetailsDelta { details, .. } => { + let Some(text) = reasoning_text_from_details(&details) else { + return Vec::new(); + }; + let mut out = ensure_anthropic_reasoning_block(state); + out.push(json!({ + "type": "content_block_delta", + "index": state.reasoning_block_index.unwrap_or(0), + "delta": {"type": "thinking_delta", "thinking": text}, + })); + out + } LlmResponseChunk::ToolCallDelta { index, id, diff --git a/crates/switchyard-translation/src/codecs/common.rs b/crates/switchyard-translation/src/codecs/common.rs index ffd8e34c6..f4f43fb98 100644 --- a/crates/switchyard-translation/src/codecs/common.rs +++ b/crates/switchyard-translation/src/codecs/common.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Provider-agnostic helpers shared by buffered wire-format codecs. +//! Provider-agnostic helpers shared by wire-format codecs. use serde_json::{Map, Value}; @@ -41,6 +41,22 @@ pub(crate) fn reasoning_text_from_blocks(content: &[ContentBlock], separator: &s .join(separator) } +/// Extracts displayable text from structured reasoning details. +pub(crate) fn reasoning_text_from_details(details: &[Value]) -> Option { + let parts = details + .iter() + .filter_map(Value::as_object) + .filter_map(|detail| { + detail + .get("text") + .or_else(|| detail.get("summary")) + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + }) + .collect::>(); + (!parts.is_empty()).then(|| parts.join("\n")) +} + /// Copies unknown provider fields into the IR extension map. pub(crate) fn provider_extensions( object: &Map, diff --git a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs index 32d0de1a2..3a09740bf 100644 --- a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs +++ b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs @@ -6,7 +6,8 @@ use serde_json::{Map, Value, json}; use crate::codecs::common::{ - is_known_role_name, provider_extensions, reasoning_text_from_blocks, text_from_blocks, + is_known_role_name, provider_extensions, reasoning_text_from_blocks, + reasoning_text_from_details, text_from_blocks, }; use crate::codecs::{ DecodedRequest, DecodedResponse, EncodedRequest, EncodedResponse, FormatCodec, @@ -361,6 +362,12 @@ impl FormatCodec for OpenAiChatCodec { { message["reasoning_content"] = Value::String(reasoning); } + if let Some(details) = output + .map(|output| reasoning_details_from_blocks(&output.content)) + .filter(|details| !details.is_empty()) + { + message["reasoning_details"] = Value::Array(details); + } if !tool_calls.is_empty() { message["tool_calls"] = Value::Array(tool_calls); } @@ -389,6 +396,36 @@ impl FormatCodec for OpenAiChatCodec { // Pulls OpenAI-compatible reasoning fields into private reasoning IR blocks. fn prepend_openai_reasoning_blocks(content: &mut Vec, object: &Map) { + if let Some(details) = object + .get("reasoning_details") + .and_then(Value::as_array) + .filter(|details| !details.is_empty()) + { + let text = reasoning_text_from_details(details).or_else(|| { + ["reasoning_content", "reasoning"] + .into_iter() + .find_map(|key| object.get(key).and_then(Value::as_str)) + .filter(|text| !text.is_empty()) + .map(ToOwned::to_owned) + }); + let signature = details.iter().find_map(|detail| { + detail + .get("signature") + .and_then(Value::as_str) + .filter(|signature| !signature.is_empty()) + .map(ToOwned::to_owned) + }); + content.insert( + 0, + ContentBlock::Reasoning { + text: text.unwrap_or_default(), + signature, + details: details.clone(), + }, + ); + return; + } + let reasoning = ["reasoning_content", "reasoning"] .into_iter() .filter_map(|key| object.get(key).and_then(Value::as_str)) @@ -396,6 +433,7 @@ fn prepend_openai_reasoning_blocks(content: &mut Vec, object: &Map .map(|text| ContentBlock::Reasoning { text: text.to_string(), signature: None, + details: Vec::new(), }) .collect::>(); if reasoning.is_empty() { @@ -407,6 +445,19 @@ fn prepend_openai_reasoning_blocks(content: &mut Vec, object: &Map *content = merged; } +// Returns structured reasoning details in their original order. +fn reasoning_details_from_blocks(content: &[ContentBlock]) -> Vec { + content + .iter() + .filter_map(|block| match block { + ContentBlock::Reasoning { details, .. } => Some(details.as_slice()), + _ => None, + }) + .flatten() + .cloned() + .collect() +} + /// Decodes OpenAI role strings into normalized roles. /// /// Unknown role strings are rejected with [`TranslationError::InvalidValue`] so @@ -804,6 +855,27 @@ fn encode_message_without_tool_results_to_openai( "role": role, "content": encode_openai_content(&content_blocks, message.role, diagnostics, policy)?, }); + let reasoning_details = reasoning_details_from_blocks(&message.content); + if reasoning_details.is_empty() { + let reasoning = message + .content + .iter() + .filter_map(|block| match block { + ContentBlock::Reasoning { + text, + signature: None, + .. + } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n"); + if !reasoning.is_empty() { + message_json["reasoning"] = Value::String(reasoning); + } + } else { + message_json["reasoning_details"] = Value::Array(reasoning_details); + } if !tool_calls.is_empty() { message_json["tool_calls"] = Value::Array(tool_calls); if message_json["content"] == Value::String(String::new()) { diff --git a/crates/switchyard-translation/src/codecs/openai_chat/stream.rs b/crates/switchyard-translation/src/codecs/openai_chat/stream.rs index b4c6273b1..f39379bd5 100644 --- a/crates/switchyard-translation/src/codecs/openai_chat/stream.rs +++ b/crates/switchyard-translation/src/codecs/openai_chat/stream.rs @@ -105,14 +105,25 @@ fn decode_openai_chat_stream( text: text.to_string(), }); } - for reasoning_key in ["reasoning_content", "reasoning"] { - if let Some(text) = delta.get(reasoning_key).and_then(Value::as_str) - && !text.is_empty() - { - out.push(LlmResponseChunk::ReasoningDelta { - index: 0, - text: text.to_string(), - }); + if let Some(details) = delta + .get("reasoning_details") + .and_then(Value::as_array) + .filter(|details| !details.is_empty()) + { + out.push(LlmResponseChunk::ReasoningDetailsDelta { + index: 0, + details: details.clone(), + }); + } else { + for reasoning_key in ["reasoning_content", "reasoning"] { + if let Some(text) = delta.get(reasoning_key).and_then(Value::as_str) + && !text.is_empty() + { + out.push(LlmResponseChunk::ReasoningDelta { + index: 0, + text: text.to_string(), + }); + } } } if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) { @@ -194,6 +205,14 @@ fn encode_openai_chat_stream( None, )] } + LlmResponseChunk::ReasoningDetailsDelta { details, .. } => { + vec![openai_stream_chunk( + state, + json!({"reasoning_details": details}), + None, + None, + )] + } LlmResponseChunk::ToolCallDelta { index, id, diff --git a/crates/switchyard-translation/src/codecs/responses/buffered.rs b/crates/switchyard-translation/src/codecs/responses/buffered.rs index 025dbe35f..a31fda0a8 100644 --- a/crates/switchyard-translation/src/codecs/responses/buffered.rs +++ b/crates/switchyard-translation/src/codecs/responses/buffered.rs @@ -585,6 +585,7 @@ fn decode_responses_reasoning_item(item: &Map) -> Vec Vec { .unwrap_or_default() .to_string(), signature: None, + details: Vec::new(), }); } Some("input_image") => { @@ -966,6 +968,7 @@ fn encode_responses_special_input(block: &ContentBlock) -> Option { ContentBlock::Reasoning { text, signature: None, + .. } => Some(json!({ "type": "reasoning", "content": [{"type": "reasoning_text", "text": text}], diff --git a/crates/switchyard-translation/src/codecs/responses/stream.rs b/crates/switchyard-translation/src/codecs/responses/stream.rs index 8a7d324ec..0b37611c0 100644 --- a/crates/switchyard-translation/src/codecs/responses/stream.rs +++ b/crates/switchyard-translation/src/codecs/responses/stream.rs @@ -6,6 +6,7 @@ use serde_json::{Value, json}; use crate::LlmResponseChunk; +use crate::codecs::common::reasoning_text_from_details; use crate::codecs::stream::{ StreamCodec, StreamTranslationState, record_source_identity, target_message_id_or_source_message_id, target_model_or_source_model, @@ -170,6 +171,11 @@ fn encode_responses_stream( LlmResponseChunk::ReasoningDelta { text, .. } => { encode_responses_reasoning_delta(state, text) } + LlmResponseChunk::ReasoningDetailsDelta { details, .. } => { + reasoning_text_from_details(&details) + .map(|text| encode_responses_reasoning_delta(state, text)) + .unwrap_or_default() + } LlmResponseChunk::ToolCallDelta { index, id, diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index e14d38e9b..e40c09d51 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -1000,6 +1000,7 @@ fn responses_reasoning_items_attach_to_tool_call_turn_for_openai_chat() -> TestR ); assert_eq!(messages.len(), 4); assert_eq!(messages[1], json!({"role": "assistant", "content": "\n\n"})); + assert_eq!(messages[2]["reasoning"], "Check the python setup."); assert_eq!(messages[2]["tool_calls"][0]["id"], "call-1"); assert_eq!(messages[3]["role"], "tool"); Ok(()) @@ -1035,12 +1036,74 @@ fn responses_reasoning_item_merges_into_next_assistant_message_for_openai_chat() output["messages"], json!([ {"role": "user", "content": "Check the file"}, - {"role": "assistant", "content": "Let me check."} + { + "role": "assistant", + "content": "Let me check.", + "reasoning": "Reading." + } ]) ); Ok(()) } +// Verifies Chat reasoning details survive normalization when exact preservation is disabled. +#[test] +fn openai_chat_reasoning_details_round_trip_in_assistant_history() -> TestResult { + let engine = TranslationEngine::default(); + let policy = TranslationPolicy { + preservation: switchyard_translation::PreservationPolicy::Disabled, + ..TranslationPolicy::default() + }; + let details = json!([ + { + "type": "reasoning.summary", + "summary": "Inspect the environment.", + "id": "reasoning-1", + "format": "openai-responses-v1", + "index": 0 + }, + { + "type": "reasoning.encrypted", + "data": "opaque-encrypted-reasoning", + "id": "reasoning-1", + "format": "openai-responses-v1", + "index": 1 + } + ]); + let body = json!({ + "model": "z-ai/glm-5.2", + "messages": [ + {"role": "user", "content": "Inspect the environment"}, + { + "role": "assistant", + "content": null, + "reasoning": "fallback text", + "reasoning_details": details, + "tool_calls": [{ + "id": "call-1", + "type": "function", + "function": {"name": "shell", "arguments": "{\"command\":\"pwd\"}"} + }] + }, + {"role": "tool", "tool_call_id": "call-1", "content": "/workspace"} + ] + }); + + let output = engine + .translate_request( + WireFormat::OpenAiChat, + WireFormat::OpenAiChat, + &body, + &policy, + )? + .body; + + assert_eq!(output["messages"][1]["reasoning_details"], details); + assert!(output["messages"][1].get("reasoning").is_none()); + assert_eq!(output["messages"][1]["tool_calls"][0]["id"], "call-1"); + Ok(()) +} + // Verifies merged reasoning re-emerges as a Responses reasoning item ahead of // the turn's function call when encoding back to the Responses format. #[test] diff --git a/crates/switchyard-translation/tests/response_translation.rs b/crates/switchyard-translation/tests/response_translation.rs index cc863ece3..5645e614c 100644 --- a/crates/switchyard-translation/tests/response_translation.rs +++ b/crates/switchyard-translation/tests/response_translation.rs @@ -331,6 +331,71 @@ fn openai_reasoning_response_translates_to_responses_reasoning_item() -> TestRes Ok(()) } +// Verifies structured Chat reasoning survives buffered decode and re-encode. +#[test] +fn openai_chat_response_round_trips_reasoning_details() -> TestResult { + let engine = TranslationEngine::default(); + let policy = TranslationPolicy { + preservation: switchyard_translation::PreservationPolicy::Disabled, + ..TranslationPolicy::default() + }; + let details = json!([ + { + "type": "reasoning.text", + "text": "Inspect the tool result.", + "signature": "opaque-signature", + "id": "reasoning-1", + "format": "anthropic-claude-v1", + "index": 0 + }, + { + "type": "reasoning.encrypted", + "data": "opaque-encrypted-reasoning", + "id": "reasoning-1", + "format": "anthropic-claude-v1", + "index": 1 + } + ]); + let body = json!({ + "id": "chatcmpl-test", + "model": "z-ai/glm-5.2", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": null, + "reasoning": "fallback text", + "reasoning_details": details, + "tool_calls": [{ + "id": "call-1", + "type": "function", + "function": {"name": "shell", "arguments": "{\"command\":\"pwd\"}"} + }] + }, + "finish_reason": "tool_calls" + }] + }); + + let output = engine + .translate_response( + WireFormat::OpenAiChat, + WireFormat::OpenAiChat, + &body, + &policy, + )? + .body; + + assert_eq!( + output["choices"][0]["message"]["reasoning_details"], + details + ); + assert_eq!( + output["choices"][0]["message"]["reasoning_content"], + "Inspect the tool result." + ); + Ok(()) +} + // Verifies reasoning-only responses do not synthesize visible output text. #[test] fn openai_reasoning_only_response_translates_to_responses_reasoning_only() -> TestResult { diff --git a/crates/switchyard-translation/tests/stream_translation.rs b/crates/switchyard-translation/tests/stream_translation.rs index 3aeed7159..5e8a31190 100644 --- a/crates/switchyard-translation/tests/stream_translation.rs +++ b/crates/switchyard-translation/tests/stream_translation.rs @@ -769,6 +769,58 @@ fn openai_chat_reasoning_stream_fields_do_not_become_anthropic_text() -> TestRes Ok(()) } +// Verifies structured Chat reasoning deltas are replayed without flattening. +#[test] +fn openai_chat_stream_round_trips_reasoning_details() -> TestResult { + let engine = TranslationEngine::default(); + let mut state = StreamTranslationState::new(WireFormat::OpenAiChat, WireFormat::OpenAiChat); + let details = json!([ + { + "type": "reasoning.text", + "text": "Inspect the environment.", + "signature": "opaque-signature", + "id": "reasoning-1", + "format": "anthropic-claude-v1", + "index": 0 + }, + { + "type": "reasoning.encrypted", + "data": "opaque-encrypted-reasoning", + "id": "reasoning-1", + "format": "anthropic-claude-v1", + "index": 1 + } + ]); + let chunk = json!({ + "id": "chatcmpl-test", + "object": "chat.completion.chunk", + "model": "z-ai/glm-5.2", + "choices": [{ + "index": 0, + "delta": { + "reasoning": "fallback text", + "reasoning_details": details + }, + "finish_reason": null + }] + }); + + let events = engine.translate_event( + &mut state, + WireFormat::OpenAiChat, + WireFormat::OpenAiChat, + &chunk, + )?; + + assert_eq!(events.len(), 1); + assert_eq!( + events[0]["choices"][0]["delta"]["reasoning_details"], + details + ); + assert!(events[0]["choices"][0]["delta"].get("reasoning").is_none()); + Ok(()) +} + // Verifies Anthropic thinking deltas become OpenAI reasoning_content, not content. #[test] fn anthropic_thinking_stream_deltas_do_not_become_openai_chat_content() -> TestResult { From 350f837b923bd9fbb1b67c2df4cc876d22519c87 Mon Sep 17 00:00:00 2001 From: Alex Steiner Date: Thu, 13 Aug 2026 16:29:17 -0700 Subject: [PATCH 2/3] fix(translation): retain reasoning fallback text Signed-off-by: Alex Steiner --- .../libsy-llm-client/tests/observability.rs | 1 + crates/libsy/src/algorithms/util/affinity.rs | 1 + crates/libsy/src/algorithms/util/llm_judge.rs | 1 + crates/protocol/src/llm.rs | 3 + crates/protocol/src/stream.rs | 63 ++++++++++++++- .../src/codecs/anthropic/buffered.rs | 1 + .../src/codecs/anthropic/stream.rs | 8 +- .../src/codecs/common.rs | 7 +- .../src/codecs/openai_chat/buffered.rs | 36 +++++++-- .../src/codecs/openai_chat/stream.rs | 28 +++++-- .../src/codecs/responses/buffered.rs | 2 + .../src/codecs/responses/stream.rs | 13 +-- .../tests/request_translation.rs | 39 +++++++++ .../tests/stream_translation.rs | 80 +++++++++++++++++++ 14 files changed, 258 insertions(+), 25 deletions(-) diff --git a/crates/libsy-llm-client/tests/observability.rs b/crates/libsy-llm-client/tests/observability.rs index 211745a98..dcb2f85e2 100644 --- a/crates/libsy-llm-client/tests/observability.rs +++ b/crates/libsy-llm-client/tests/observability.rs @@ -569,6 +569,7 @@ async fn affinity_warns_once_when_request_has_no_usable_identity() -> switchyard text: "provider reasoning".to_string(), signature: None, details: Vec::new(), + fallback_text: None, }], }], ..LlmRequest::default() diff --git a/crates/libsy/src/algorithms/util/affinity.rs b/crates/libsy/src/algorithms/util/affinity.rs index 71378b8b8..8048ed988 100644 --- a/crates/libsy/src/algorithms/util/affinity.rs +++ b/crates/libsy/src/algorithms/util/affinity.rs @@ -521,6 +521,7 @@ mod tests { text: "Internal provider reasoning.".to_string(), signature: Some("provider-signature".to_string()), details: Vec::new(), + fallback_text: None, }, ], }); diff --git a/crates/libsy/src/algorithms/util/llm_judge.rs b/crates/libsy/src/algorithms/util/llm_judge.rs index 4fe36fbd2..e74eac7fd 100644 --- a/crates/libsy/src/algorithms/util/llm_judge.rs +++ b/crates/libsy/src/algorithms/util/llm_judge.rs @@ -404,6 +404,7 @@ mod tests { text: r#"{"ok":false}"#.to_string(), signature: None, details: Vec::new(), + fallback_text: None, }, ); } diff --git a/crates/protocol/src/llm.rs b/crates/protocol/src/llm.rs index 04cfddddc..ecd4a6874 100644 --- a/crates/protocol/src/llm.rs +++ b/crates/protocol/src/llm.rs @@ -90,6 +90,9 @@ pub enum ContentBlock { /// Structured reasoning details that must be replayed without modification. #[serde(default, skip_serializing_if = "Vec::is_empty")] details: Vec, + /// Plaintext fallback when structured details contain no displayable text. + #[serde(default, skip_serializing_if = "Option::is_none")] + fallback_text: Option, }, /// Image content. Image { diff --git a/crates/protocol/src/stream.rs b/crates/protocol/src/stream.rs index 987ea9274..fb7590df2 100644 --- a/crates/protocol/src/stream.rs +++ b/crates/protocol/src/stream.rs @@ -193,11 +193,17 @@ impl AggLlmResponse { text, }); } - ContentBlock::Reasoning { text, details, .. } => { + ContentBlock::Reasoning { + text, + details, + fallback_text, + .. + } => { if !details.is_empty() { chunks.push(LlmResponseChunk::ReasoningDetailsDelta { index: output_index, details, + fallback_text, }); } else { chunks.push(LlmResponseChunk::ReasoningDelta { @@ -285,6 +291,8 @@ pub enum LlmResponseChunk { index: usize, /// Reasoning detail objects in provider order. details: Vec, + /// Plaintext fallback when the details contain no displayable text. + fallback_text: Option, }, /// Adds or updates a tool call at one index. ToolCallDelta { @@ -337,6 +345,7 @@ pub struct ResponseAccumulator { text: String, reasoning: Option, reasoning_details: Vec, + reasoning_fallback: Option, tool_calls: BTreeMap, usage: Usage, stop_reason: Option, @@ -374,8 +383,20 @@ impl ResponseAccumulator { .get_or_insert_with(String::new) .push_str(&text); } - LlmResponseChunk::ReasoningDetailsDelta { details, .. } => { + LlmResponseChunk::ReasoningDetailsDelta { + details, + fallback_text, + .. + } => { self.reasoning_details.extend(details); + if let Some(text) = fallback_text { + self.reasoning + .get_or_insert_with(String::new) + .push_str(&text); + self.reasoning_fallback + .get_or_insert_with(String::new) + .push_str(&text); + } } LlmResponseChunk::ToolCallDelta { index, @@ -411,6 +432,7 @@ impl ResponseAccumulator { text: self.reasoning.unwrap_or_default(), signature: None, details: self.reasoning_details, + fallback_text: self.reasoning_fallback, }); } if !self.text.is_empty() { @@ -631,6 +653,7 @@ mod tests { text: "think".to_string(), signature: None, details: Vec::new(), + fallback_text: None, }, ContentBlock::Text { text: "answer".to_string(), @@ -669,6 +692,42 @@ mod tests { assert_eq!(recovered.outputs[0].content, original.outputs[0].content); } + #[test] + fn into_stream_retains_encrypted_reasoning_and_fallback_text() { + let details = vec![json!({ + "type": "reasoning.encrypted", + "data": "opaque-encrypted-reasoning" + })]; + let original = AggLlmResponse { + outputs: vec![ResponseOutput { + role: Role::Assistant, + content: vec![ContentBlock::Reasoning { + text: "fallback reasoning".to_string(), + signature: None, + details: details.clone(), + fallback_text: Some("fallback reasoning".to_string()), + }], + stop_reason: Some(StopReason::EndTurn), + }], + ..AggLlmResponse::default() + }; + + let recovered = block_on(LlmResponse::Stream(original.into_stream()).into_agg()) + .expect("into_agg failed"); + let ContentBlock::Reasoning { + text, + details: recovered_details, + fallback_text, + .. + } = &recovered.outputs[0].content[0] + else { + panic!("expected reasoning block"); + }; + assert_eq!(text, "fallback reasoning"); + assert_eq!(recovered_details, &details); + assert_eq!(fallback_text.as_deref(), Some("fallback reasoning")); + } + #[test] fn into_agg_preserves_stream_item_error() { let response = LlmResponse::Stream(Box::pin(stream::once(async { diff --git a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs index 75f4d6848..b26b9fcff 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs @@ -526,6 +526,7 @@ fn decode_anthropic_content_block( .filter(|signature| !signature.is_empty()) .map(ToOwned::to_owned), details: Vec::new(), + fallback_text: None, }], Some("tool_use") => vec![ContentBlock::ToolCall(ToolCall { id: block diff --git a/crates/switchyard-translation/src/codecs/anthropic/stream.rs b/crates/switchyard-translation/src/codecs/anthropic/stream.rs index a430d32c7..fcb40968a 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/stream.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/stream.rs @@ -193,8 +193,12 @@ fn encode_anthropic_stream( })); out } - LlmResponseChunk::ReasoningDetailsDelta { details, .. } => { - let Some(text) = reasoning_text_from_details(&details) else { + LlmResponseChunk::ReasoningDetailsDelta { + details, + fallback_text, + .. + } => { + let Some(text) = reasoning_text_from_details(&details).or(fallback_text) else { return Vec::new(); }; let mut out = ensure_anthropic_reasoning_block(state); diff --git a/crates/switchyard-translation/src/codecs/common.rs b/crates/switchyard-translation/src/codecs/common.rs index f4f43fb98..cfe1c4f80 100644 --- a/crates/switchyard-translation/src/codecs/common.rs +++ b/crates/switchyard-translation/src/codecs/common.rs @@ -49,9 +49,14 @@ pub(crate) fn reasoning_text_from_details(details: &[Value]) -> Option { .filter_map(|detail| { detail .get("text") - .or_else(|| detail.get("summary")) .and_then(Value::as_str) .filter(|text| !text.is_empty()) + .or_else(|| { + detail + .get("summary") + .and_then(Value::as_str) + .filter(|summary| !summary.is_empty()) + }) }) .collect::>(); (!parts.is_empty()).then(|| parts.join("\n")) diff --git a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs index 3a09740bf..5a495e239 100644 --- a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs +++ b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs @@ -401,13 +401,12 @@ fn prepend_openai_reasoning_blocks(content: &mut Vec, object: &Map .and_then(Value::as_array) .filter(|details| !details.is_empty()) { - let text = reasoning_text_from_details(details).or_else(|| { - ["reasoning_content", "reasoning"] - .into_iter() - .find_map(|key| object.get(key).and_then(Value::as_str)) - .filter(|text| !text.is_empty()) - .map(ToOwned::to_owned) - }); + let detail_text = reasoning_text_from_details(details); + let fallback_text = ["reasoning_content", "reasoning"] + .into_iter() + .find_map(|key| object.get(key).and_then(Value::as_str)) + .filter(|text| !text.is_empty()) + .map(ToOwned::to_owned); let signature = details.iter().find_map(|detail| { detail .get("signature") @@ -418,9 +417,17 @@ fn prepend_openai_reasoning_blocks(content: &mut Vec, object: &Map content.insert( 0, ContentBlock::Reasoning { - text: text.unwrap_or_default(), + text: detail_text + .clone() + .or_else(|| fallback_text.clone()) + .unwrap_or_default(), signature, details: details.clone(), + fallback_text: if detail_text.is_none() { + fallback_text + } else { + None + }, }, ); return; @@ -434,6 +441,7 @@ fn prepend_openai_reasoning_blocks(content: &mut Vec, object: &Map text: text.to_string(), signature: None, details: Vec::new(), + fallback_text: None, }) .collect::>(); if reasoning.is_empty() { @@ -875,6 +883,18 @@ fn encode_message_without_tool_results_to_openai( } } else { message_json["reasoning_details"] = Value::Array(reasoning_details); + let fallback = message + .content + .iter() + .filter_map(|block| match block { + ContentBlock::Reasoning { fallback_text, .. } => fallback_text.as_deref(), + _ => None, + }) + .collect::>() + .join("\n"); + if !fallback.is_empty() { + message_json["reasoning"] = Value::String(fallback); + } } if !tool_calls.is_empty() { message_json["tool_calls"] = Value::Array(tool_calls); diff --git a/crates/switchyard-translation/src/codecs/openai_chat/stream.rs b/crates/switchyard-translation/src/codecs/openai_chat/stream.rs index f39379bd5..b0dd134b0 100644 --- a/crates/switchyard-translation/src/codecs/openai_chat/stream.rs +++ b/crates/switchyard-translation/src/codecs/openai_chat/stream.rs @@ -6,6 +6,7 @@ use serde_json::{Map, Value, json}; use crate::LlmResponseChunk; +use crate::codecs::common::reasoning_text_from_details; use crate::codecs::stream::{ StreamCodec, StreamTranslationState, record_source_identity, state_source_is, string_field, target_model_or_source_model, @@ -110,9 +111,19 @@ fn decode_openai_chat_stream( .and_then(Value::as_array) .filter(|details| !details.is_empty()) { + let fallback_text = if reasoning_text_from_details(details).is_none() { + ["reasoning_content", "reasoning"] + .into_iter() + .find_map(|key| delta.get(key).and_then(Value::as_str)) + .filter(|text| !text.is_empty()) + .map(ToOwned::to_owned) + } else { + None + }; out.push(LlmResponseChunk::ReasoningDetailsDelta { index: 0, details: details.clone(), + fallback_text, }); } else { for reasoning_key in ["reasoning_content", "reasoning"] { @@ -205,13 +216,16 @@ fn encode_openai_chat_stream( None, )] } - LlmResponseChunk::ReasoningDetailsDelta { details, .. } => { - vec![openai_stream_chunk( - state, - json!({"reasoning_details": details}), - None, - None, - )] + LlmResponseChunk::ReasoningDetailsDelta { + details, + fallback_text, + .. + } => { + let mut delta = json!({"reasoning_details": details}); + if let Some(text) = fallback_text { + delta["reasoning"] = Value::String(text); + } + vec![openai_stream_chunk(state, delta, None, None)] } LlmResponseChunk::ToolCallDelta { index, diff --git a/crates/switchyard-translation/src/codecs/responses/buffered.rs b/crates/switchyard-translation/src/codecs/responses/buffered.rs index a31fda0a8..f96c64d24 100644 --- a/crates/switchyard-translation/src/codecs/responses/buffered.rs +++ b/crates/switchyard-translation/src/codecs/responses/buffered.rs @@ -586,6 +586,7 @@ fn decode_responses_reasoning_item(item: &Map) -> Vec Vec { .to_string(), signature: None, details: Vec::new(), + fallback_text: None, }); } Some("input_image") => { diff --git a/crates/switchyard-translation/src/codecs/responses/stream.rs b/crates/switchyard-translation/src/codecs/responses/stream.rs index 0b37611c0..83547a100 100644 --- a/crates/switchyard-translation/src/codecs/responses/stream.rs +++ b/crates/switchyard-translation/src/codecs/responses/stream.rs @@ -171,11 +171,14 @@ fn encode_responses_stream( LlmResponseChunk::ReasoningDelta { text, .. } => { encode_responses_reasoning_delta(state, text) } - LlmResponseChunk::ReasoningDetailsDelta { details, .. } => { - reasoning_text_from_details(&details) - .map(|text| encode_responses_reasoning_delta(state, text)) - .unwrap_or_default() - } + LlmResponseChunk::ReasoningDetailsDelta { + details, + fallback_text, + .. + } => reasoning_text_from_details(&details) + .or(fallback_text) + .map(|text| encode_responses_reasoning_delta(state, text)) + .unwrap_or_default(), LlmResponseChunk::ToolCallDelta { index, id, diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index e40c09d51..61de05910 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -1104,6 +1104,45 @@ fn openai_chat_reasoning_details_round_trip_in_assistant_history() -> TestResult Ok(()) } +// Verifies encrypted-only Chat details retain the plaintext fallback needed by providers. +#[test] +fn openai_chat_encrypted_reasoning_details_retain_fallback() -> TestResult { + let engine = TranslationEngine::default(); + let policy = TranslationPolicy { + preservation: switchyard_translation::PreservationPolicy::Disabled, + ..TranslationPolicy::default() + }; + let details = json!([{ + "type": "reasoning.encrypted", + "data": "opaque-encrypted-reasoning", + "id": "reasoning-1", + "format": "openai-responses-v1", + "index": 0 + }]); + let body = json!({ + "model": "z-ai/glm-5.2", + "messages": [{ + "role": "assistant", + "content": null, + "reasoning": "fallback text", + "reasoning_details": details + }] + }); + + let output = engine + .translate_request( + WireFormat::OpenAiChat, + WireFormat::OpenAiChat, + &body, + &policy, + )? + .body; + + assert_eq!(output["messages"][0]["reasoning_details"], details); + assert_eq!(output["messages"][0]["reasoning"], "fallback text"); + Ok(()) +} + // Verifies merged reasoning re-emerges as a Responses reasoning item ahead of // the turn's function call when encoding back to the Responses format. #[test] diff --git a/crates/switchyard-translation/tests/stream_translation.rs b/crates/switchyard-translation/tests/stream_translation.rs index 5e8a31190..9c128c21d 100644 --- a/crates/switchyard-translation/tests/stream_translation.rs +++ b/crates/switchyard-translation/tests/stream_translation.rs @@ -821,6 +821,86 @@ fn openai_chat_stream_round_trips_reasoning_details() -> TestResult { Ok(()) } +// Verifies encrypted-only Chat deltas retain both opaque details and plaintext fallback. +#[test] +fn openai_chat_stream_retains_encrypted_details_and_fallback() -> TestResult { + let engine = TranslationEngine::default(); + let mut state = StreamTranslationState::new(WireFormat::OpenAiChat, WireFormat::OpenAiChat); + let details = json!([{ + "type": "reasoning.encrypted", + "data": "opaque-encrypted-reasoning" + }]); + let chunk = json!({ + "id": "chatcmpl-test", + "object": "chat.completion.chunk", + "model": "z-ai/glm-5.2", + "choices": [{ + "index": 0, + "delta": { + "reasoning": "fallback text", + "reasoning_details": details + }, + "finish_reason": null + }] + }); + + let events = engine.translate_event( + &mut state, + WireFormat::OpenAiChat, + WireFormat::OpenAiChat, + &chunk, + )?; + + assert_eq!(events.len(), 1); + assert_eq!( + events[0]["choices"][0]["delta"]["reasoning_details"], + details + ); + assert_eq!( + events[0]["choices"][0]["delta"]["reasoning"], + "fallback text" + ); + Ok(()) +} + +// Verifies an empty detail text does not mask a usable summary. +#[test] +fn openai_chat_stream_uses_summary_when_detail_text_is_empty() -> TestResult { + let engine = TranslationEngine::default(); + let mut state = + StreamTranslationState::new(WireFormat::OpenAiChat, WireFormat::AnthropicMessages); + let chunk = json!({ + "id": "chatcmpl-test", + "object": "chat.completion.chunk", + "model": "z-ai/glm-5.2", + "choices": [{ + "index": 0, + "delta": { + "reasoning_details": [{ + "type": "reasoning.summary", + "text": "", + "summary": "usable summary" + }] + }, + "finish_reason": null + }] + }); + + let events = engine.translate_event( + &mut state, + WireFormat::OpenAiChat, + WireFormat::AnthropicMessages, + &chunk, + )?; + + assert!(events.iter().any(|event| { + event["type"] == "content_block_delta" + && event["delta"]["type"] == "thinking_delta" + && event["delta"]["thinking"] == "usable summary" + })); + Ok(()) +} + // Verifies Anthropic thinking deltas become OpenAI reasoning_content, not content. #[test] fn anthropic_thinking_stream_deltas_do_not_become_openai_chat_content() -> TestResult { From ad2e175e3fa508f9882df35a56537fbd7168b6d5 Mon Sep 17 00:00:00 2001 From: Alex Steiner Date: Fri, 14 Aug 2026 09:01:33 -0700 Subject: [PATCH 3/3] refactor(translation): simplify chat reasoning handling Signed-off-by: Alex Steiner --- crates/protocol/src/llm.rs | 3 +- .../src/codecs/common.rs | 13 ++ .../src/codecs/openai_chat/buffered.rs | 117 ++++++++++-------- .../src/codecs/openai_chat/stream.rs | 7 +- .../tests/stream_translation.rs | 1 + 5 files changed, 80 insertions(+), 61 deletions(-) diff --git a/crates/protocol/src/llm.rs b/crates/protocol/src/llm.rs index ecd4a6874..fd4c372a7 100644 --- a/crates/protocol/src/llm.rs +++ b/crates/protocol/src/llm.rs @@ -87,7 +87,8 @@ pub enum ContentBlock { text: String, /// Provider signature used to validate or continue the reasoning block. signature: Option, - /// Structured reasoning details that must be replayed without modification. + /// Structured reasoning details, such as an encrypted `{ "type": + /// "reasoning.encrypted", "data": "..." }` object, replayed without modification. #[serde(default, skip_serializing_if = "Vec::is_empty")] details: Vec, /// Plaintext fallback when structured details contain no displayable text. diff --git a/crates/switchyard-translation/src/codecs/common.rs b/crates/switchyard-translation/src/codecs/common.rs index cfe1c4f80..78e5a7f89 100644 --- a/crates/switchyard-translation/src/codecs/common.rs +++ b/crates/switchyard-translation/src/codecs/common.rs @@ -62,6 +62,19 @@ pub(crate) fn reasoning_text_from_details(details: &[Value]) -> Option { (!parts.is_empty()).then(|| parts.join("\n")) } +/// Returns the first non-empty string stored under the requested keys. +pub(crate) fn first_nonempty_string<'a>( + object: &'a Map, + keys: &[&str], +) -> Option<&'a str> { + keys.iter().find_map(|key| { + object + .get(*key) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + }) +} + /// Copies unknown provider fields into the IR extension map. pub(crate) fn provider_extensions( object: &Map, diff --git a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs index 5a495e239..ff12d4283 100644 --- a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs +++ b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs @@ -6,7 +6,7 @@ use serde_json::{Map, Value, json}; use crate::codecs::common::{ - is_known_role_name, provider_extensions, reasoning_text_from_blocks, + first_nonempty_string, is_known_role_name, provider_extensions, reasoning_text_from_blocks, reasoning_text_from_details, text_from_blocks, }; use crate::codecs::{ @@ -402,10 +402,7 @@ fn prepend_openai_reasoning_blocks(content: &mut Vec, object: &Map .filter(|details| !details.is_empty()) { let detail_text = reasoning_text_from_details(details); - let fallback_text = ["reasoning_content", "reasoning"] - .into_iter() - .find_map(|key| object.get(key).and_then(Value::as_str)) - .filter(|text| !text.is_empty()) + let fallback_text = first_nonempty_string(object, &["reasoning_content", "reasoning"]) .map(ToOwned::to_owned); let signature = details.iter().find_map(|detail| { detail @@ -433,24 +430,17 @@ fn prepend_openai_reasoning_blocks(content: &mut Vec, object: &Map return; } - let reasoning = ["reasoning_content", "reasoning"] - .into_iter() - .filter_map(|key| object.get(key).and_then(Value::as_str)) - .filter(|text| !text.is_empty()) - .map(|text| ContentBlock::Reasoning { - text: text.to_string(), - signature: None, - details: Vec::new(), - fallback_text: None, - }) - .collect::>(); - if reasoning.is_empty() { - return; + if let Some(text) = first_nonempty_string(object, &["reasoning_content", "reasoning"]) { + content.insert( + 0, + ContentBlock::Reasoning { + text: text.to_string(), + signature: None, + details: Vec::new(), + fallback_text: None, + }, + ); } - - let mut merged = reasoning; - merged.append(content); - *content = merged; } // Returns structured reasoning details in their original order. @@ -863,39 +853,7 @@ fn encode_message_without_tool_results_to_openai( "role": role, "content": encode_openai_content(&content_blocks, message.role, diagnostics, policy)?, }); - let reasoning_details = reasoning_details_from_blocks(&message.content); - if reasoning_details.is_empty() { - let reasoning = message - .content - .iter() - .filter_map(|block| match block { - ContentBlock::Reasoning { - text, - signature: None, - .. - } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join("\n"); - if !reasoning.is_empty() { - message_json["reasoning"] = Value::String(reasoning); - } - } else { - message_json["reasoning_details"] = Value::Array(reasoning_details); - let fallback = message - .content - .iter() - .filter_map(|block| match block { - ContentBlock::Reasoning { fallback_text, .. } => fallback_text.as_deref(), - _ => None, - }) - .collect::>() - .join("\n"); - if !fallback.is_empty() { - message_json["reasoning"] = Value::String(fallback); - } - } + encode_openai_message_reasoning(&mut message_json, &message.content); if !tool_calls.is_empty() { message_json["tool_calls"] = Value::Array(tool_calls); if message_json["content"] == Value::String(String::new()) { @@ -905,6 +863,55 @@ fn encode_message_without_tool_results_to_openai( Ok(message_json) } +// Adds either plaintext reasoning or structured details to an OpenAI Chat message. +fn encode_openai_message_reasoning(message: &mut Value, content: &[ContentBlock]) { + let details = reasoning_details_from_blocks(content); + if details.is_empty() { + encode_openai_message_plaintext_reasoning(message, content); + } else { + encode_openai_message_structured_reasoning(message, content, details); + } +} + +// Adds reasoning blocks that have no structured provider representation. +fn encode_openai_message_plaintext_reasoning(message: &mut Value, content: &[ContentBlock]) { + let reasoning = content + .iter() + .filter_map(|block| match block { + ContentBlock::Reasoning { + text, + signature: None, + .. + } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n"); + if !reasoning.is_empty() { + message["reasoning"] = Value::String(reasoning); + } +} + +// Adds exact provider details and any plaintext fallback they require. +fn encode_openai_message_structured_reasoning( + message: &mut Value, + content: &[ContentBlock], + details: Vec, +) { + message["reasoning_details"] = Value::Array(details); + let fallback = content + .iter() + .filter_map(|block| match block { + ContentBlock::Reasoning { fallback_text, .. } => fallback_text.as_deref(), + _ => None, + }) + .collect::>() + .join("\n"); + if !fallback.is_empty() { + message["reasoning"] = Value::String(fallback); + } +} + // Checks whether any block in a message is a tool result. fn message_has_tool_results(message: &Message) -> bool { message diff --git a/crates/switchyard-translation/src/codecs/openai_chat/stream.rs b/crates/switchyard-translation/src/codecs/openai_chat/stream.rs index b0dd134b0..8aad141a0 100644 --- a/crates/switchyard-translation/src/codecs/openai_chat/stream.rs +++ b/crates/switchyard-translation/src/codecs/openai_chat/stream.rs @@ -6,7 +6,7 @@ use serde_json::{Map, Value, json}; use crate::LlmResponseChunk; -use crate::codecs::common::reasoning_text_from_details; +use crate::codecs::common::{first_nonempty_string, reasoning_text_from_details}; use crate::codecs::stream::{ StreamCodec, StreamTranslationState, record_source_identity, state_source_is, string_field, target_model_or_source_model, @@ -112,10 +112,7 @@ fn decode_openai_chat_stream( .filter(|details| !details.is_empty()) { let fallback_text = if reasoning_text_from_details(details).is_none() { - ["reasoning_content", "reasoning"] - .into_iter() - .find_map(|key| delta.get(key).and_then(Value::as_str)) - .filter(|text| !text.is_empty()) + first_nonempty_string(delta, &["reasoning_content", "reasoning"]) .map(ToOwned::to_owned) } else { None diff --git a/crates/switchyard-translation/tests/stream_translation.rs b/crates/switchyard-translation/tests/stream_translation.rs index 9c128c21d..772f2fe40 100644 --- a/crates/switchyard-translation/tests/stream_translation.rs +++ b/crates/switchyard-translation/tests/stream_translation.rs @@ -837,6 +837,7 @@ fn openai_chat_stream_retains_encrypted_details_and_fallback() -> TestResult { "choices": [{ "index": 0, "delta": { + "reasoning_content": "", "reasoning": "fallback text", "reasoning_details": details },