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..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 { @@ -356,6 +400,48 @@ fn openai_chat_stream_event_translates_to_anthropic_message_events() -> TestResu Ok(()) } +// 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(); + 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!( + event_labels(&events), + vec![ + "message_start", + "thinking_start", + "thinking_delta", + "signature_delta", + "thinking_stop", + "text_start", + "text_delta", + ] + ); + Ok(()) +} + // Verifies Anthropic usage and stop events become terminal OpenAI chunks. #[test] fn anthropic_stream_usage_and_stop_translate_to_openai_chunks() -> TestResult {