From 4144f8c2e12fa33a804cad08d25a73f2dc2274ef Mon Sep 17 00:00:00 2001 From: Blake Date: Wed, 12 Aug 2026 13:22:40 -0500 Subject: [PATCH 1/3] fix(translation): preserve reasoning order in mixed stream chunks Signed-off-by: Blake --- .../src/codecs/openai_chat/stream.rs | 16 ++-- .../tests/stream_translation.rs | 78 +++++++++++++++++++ 2 files changed, 86 insertions(+), 8 deletions(-) diff --git a/crates/switchyard-translation/src/codecs/openai_chat/stream.rs b/crates/switchyard-translation/src/codecs/openai_chat/stream.rs index a50dcded1..6972850e9 100644 --- a/crates/switchyard-translation/src/codecs/openai_chat/stream.rs +++ b/crates/switchyard-translation/src/codecs/openai_chat/stream.rs @@ -98,14 +98,6 @@ fn decode_openai_chat_stream( continue; }; if let Some(delta) = choice.get("delta").and_then(Value::as_object) { - if let Some(text) = delta.get("content").and_then(Value::as_str) - && !text.is_empty() - { - out.push(LlmResponseChunk::TextDelta { - index: 0, - text: text.to_string(), - }); - } if let Some(details) = delta .get("reasoning_details") .and_then(Value::as_array) @@ -134,6 +126,14 @@ fn decode_openai_chat_stream( } } } + if let Some(text) = delta.get("content").and_then(Value::as_str) + && !text.is_empty() + { + out.push(LlmResponseChunk::TextDelta { + index: 0, + text: text.to_string(), + }); + } if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) { for tool_call in tool_calls { if let Some(tool_call) = tool_call.as_object() { diff --git a/crates/switchyard-translation/tests/stream_translation.rs b/crates/switchyard-translation/tests/stream_translation.rs index a938a3094..8149ef654 100644 --- a/crates/switchyard-translation/tests/stream_translation.rs +++ b/crates/switchyard-translation/tests/stream_translation.rs @@ -356,6 +356,84 @@ fn openai_chat_stream_event_translates_to_anthropic_message_events() -> TestResu Ok(()) } +// Verifies mixed reasoning and content emit reasoning before text for Anthropic clients for Anthropic clients. +#[test] +fn openai_chat_mixed_reasoning_and_content_stream_in_reasoning_first_order() -> 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": "nvidia/nvidia/nemotron-3-ultra-nvfp4", + "choices": [{ + "index": 0, + "delta": { + "reasoning_content": ".", + "content": "Hello" + }, + "finish_reason": null + }] + }); + + let events = engine.translate_event( + &mut state, + WireFormat::OpenAiChat, + WireFormat::AnthropicMessages, + &chunk, + )?; + + assert_eq!( + events, + vec![ + json!({ + "type": "message_start", + "message": { + "id": "msg_chatcmpl-test", + "type": "message", + "role": "assistant", + "model": "nvidia/nvidia/nemotron-3-ultra-nvfp4", + "content": [], + "stop_reason": null, + "stop_sequence": null, + "usage": {"input_tokens": 0, "output_tokens": 0} + } + }), + json!({ + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "thinking", + "thinking": "", + "signature": "" + } + }), + json!({ + "type": "content_block_delta", + "index": 0, + "delta": {"type": "thinking_delta", "thinking": "."} + }), + json!({ + "type": "content_block_delta", + "index": 0, + "delta": {"type": "signature_delta", "signature": ""} + }), + json!({"type": "content_block_stop", "index": 0}), + json!({ + "type": "content_block_start", + "index": 1, + "content_block": {"type": "text", "text": ""} + }), + json!({ + "type": "content_block_delta", + "index": 1, + "delta": {"type": "text_delta", "text": "Hello"} + }), + ] + ); + Ok(()) +} + // Verifies Anthropic usage and stop events become terminal OpenAI chunks. #[test] fn anthropic_stream_usage_and_stop_translate_to_openai_chunks() -> TestResult { From c88eb179252e6dcc61ee2f4cc90994b1e0d4fd00 Mon Sep 17 00:00:00 2001 From: Blake Date: Wed, 12 Aug 2026 13:53:14 -0500 Subject: [PATCH 2/3] fix(translation): deduplicate test doc comment Signed-off-by: Blake --- crates/switchyard-translation/tests/stream_translation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/switchyard-translation/tests/stream_translation.rs b/crates/switchyard-translation/tests/stream_translation.rs index 8149ef654..acd2051a3 100644 --- a/crates/switchyard-translation/tests/stream_translation.rs +++ b/crates/switchyard-translation/tests/stream_translation.rs @@ -356,7 +356,7 @@ fn openai_chat_stream_event_translates_to_anthropic_message_events() -> TestResu Ok(()) } -// Verifies mixed reasoning and content emit reasoning before text for Anthropic clients for Anthropic clients. +// A mixed chunk must emit reasoning before text, matching the buffered decoder. #[test] fn openai_chat_mixed_reasoning_and_content_stream_in_reasoning_first_order() -> TestResult { let engine = TranslationEngine::default(); From 440743c2a7a09dbcf272c4c00d0e4cac853fb712 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 18 Aug 2026 08:05:00 -0500 Subject: [PATCH 3/3] test(translation): assert event labels for reasoning-first order Replace the duplicated per-event JSON assertion with an event_labels helper that reduces the stream to ordered labels, keeping the reasoning-before-text ordering check readable. Signed-off-by: Blake --- .../tests/stream_translation.rs | 98 ++++++++++--------- 1 file changed, 53 insertions(+), 45 deletions(-) diff --git a/crates/switchyard-translation/tests/stream_translation.rs b/crates/switchyard-translation/tests/stream_translation.rs index acd2051a3..c726bee06 100644 --- a/crates/switchyard-translation/tests/stream_translation.rs +++ b/crates/switchyard-translation/tests/stream_translation.rs @@ -5,8 +5,10 @@ pub mod common; +use std::collections::HashMap; + use pretty_assertions::assert_eq; -use serde_json::json; +use serde_json::{Value, json}; use switchyard_protocol::{LlmResponseStreamEvent, ResponseAccumulator, StopReason}; use switchyard_translation::{ LlmResponseChunk, StreamTranslationState, TranslationEngine, WireFormat, decode_stream_event, @@ -16,6 +18,48 @@ use common::{REASONING_MODEL, text_and_encrypted_reasoning_details}; type TestResult = std::result::Result<(), Box>; +// Reduces Anthropic stream events to ordered labels (`_start`, ``, +// `_stop`) so ordering assertions stay readable without restating each payload. +fn event_labels(events: &[Value]) -> Vec { + let mut block_types: HashMap = HashMap::new(); + events + .iter() + .map(|event| { + let index = event + .get("index") + .and_then(Value::as_u64) + .unwrap_or_default(); + match event + .get("type") + .and_then(Value::as_str) + .unwrap_or_default() + { + "content_block_start" => { + let block_type = event + .get("content_block") + .and_then(|block| block.get("type")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + block_types.insert(index, block_type.clone()); + format!("{block_type}_start") + } + "content_block_delta" => event + .get("delta") + .and_then(|delta| delta.get("type")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + "content_block_stop" => { + let block_type = block_types.get(&index).cloned().unwrap_or_default(); + format!("{block_type}_stop") + } + other => other.to_string(), + } + }) + .collect() +} + // Same-format replay returns the same parsed JSON value, including provider-specific fields. #[test] fn preserved_same_format_events_replay_unknown_fields_exactly() -> TestResult { @@ -384,51 +428,15 @@ fn openai_chat_mixed_reasoning_and_content_stream_in_reasoning_first_order() -> )?; assert_eq!( - events, + event_labels(&events), vec![ - json!({ - "type": "message_start", - "message": { - "id": "msg_chatcmpl-test", - "type": "message", - "role": "assistant", - "model": "nvidia/nvidia/nemotron-3-ultra-nvfp4", - "content": [], - "stop_reason": null, - "stop_sequence": null, - "usage": {"input_tokens": 0, "output_tokens": 0} - } - }), - json!({ - "type": "content_block_start", - "index": 0, - "content_block": { - "type": "thinking", - "thinking": "", - "signature": "" - } - }), - json!({ - "type": "content_block_delta", - "index": 0, - "delta": {"type": "thinking_delta", "thinking": "."} - }), - json!({ - "type": "content_block_delta", - "index": 0, - "delta": {"type": "signature_delta", "signature": ""} - }), - json!({"type": "content_block_stop", "index": 0}), - json!({ - "type": "content_block_start", - "index": 1, - "content_block": {"type": "text", "text": ""} - }), - json!({ - "type": "content_block_delta", - "index": 1, - "delta": {"type": "text_delta", "text": "Hello"} - }), + "message_start", + "thinking_start", + "thinking_delta", + "signature_delta", + "thinking_stop", + "text_start", + "text_delta", ] ); Ok(())