diff --git a/crates/switchyard-translation/src/codecs/responses/stream.rs b/crates/switchyard-translation/src/codecs/responses/stream.rs index d2c49754d..b3a760b38 100644 --- a/crates/switchyard-translation/src/codecs/responses/stream.rs +++ b/crates/switchyard-translation/src/codecs/responses/stream.rs @@ -290,7 +290,7 @@ fn decode_responses_stream( }) .unwrap_or_default() } - Some("response.completed") => { + Some("response.completed" | "response.incomplete") => { let mut out = Vec::new(); // Some providers send output only in the final snapshot. Reconcile it with // decoded deltas before emitting the stop, without repeating streamed content. @@ -314,19 +314,14 @@ fn decode_responses_stream( // use for such output; the stream must too, or stop-reason-driven tool loops // (Anthropic `tool_use`, Chat `tool_calls`) stop without running the tool. // Carries the Anthropic spelling because every encoder already maps it. - let reason = state.decoded_tool_call.then(|| "tool_use".to_string()); + let reason = if event_type == Some("response.incomplete") { + Some("max_tokens".to_string()) + } else { + state.decoded_tool_call.then(|| "tool_use".to_string()) + }; out.push(LlmResponseChunk::MessageStop { reason }); out } - // Carries the Anthropic spelling because every encoder already maps it. - Some("response.incomplete") => { - let mut out = Vec::new(); - decode_terminal_usage(state, event, &mut out); - out.push(LlmResponseChunk::MessageStop { - reason: Some("max_tokens".to_string()), - }); - out - } Some("response.failed") => vec![LlmResponseChunk::StreamError { message: event .get("response") diff --git a/crates/switchyard-translation/tests/stream_translation.rs b/crates/switchyard-translation/tests/stream_translation.rs index 9ac6058ae..cc578d394 100644 --- a/crates/switchyard-translation/tests/stream_translation.rs +++ b/crates/switchyard-translation/tests/stream_translation.rs @@ -2561,6 +2561,120 @@ fn responses_incomplete_event_translates_to_chat_length_finish() -> TestResult { Ok(()) } +#[test] +fn responses_incomplete_snapshots_preserve_text_once() -> TestResult { + let engine = TranslationEngine::default(); + let source = WireFormat::OpenAiResponses; + for target in [WireFormat::OpenAiChat, WireFormat::AnthropicMessages] { + for prefix in ["", "partial ", "partial answer"] { + let mut state = StreamTranslationState::new(source, target); + let mut events = Vec::new(); + let mut input = vec![json!({"type": "response.created", "response": { + "id": "resp_partial", "model": "fixture" + }})]; + if !prefix.is_empty() { + input.push(json!({"type": "response.output_text.delta", + "output_index": 0, "content_index": 0, "delta": prefix})); + } + input.push(json!({"type": "response.incomplete", "response": { + "status": "incomplete", "incomplete_details": {"reason": "max_output_tokens"}, + "output": [{"type": "message", "role": "assistant", "status": "incomplete", + "content": [{"type": "output_text", "text": "partial answer"}]}], + "usage": {"input_tokens": 7, "output_tokens": 2, "total_tokens": 9} + }})); + for event in input { + events.extend(engine.translate_event(&mut state, source, target, &event)?); + } + events.extend(engine.finish_stream(&mut state, target)?); + let text: String = events + .iter() + .filter_map(|event| { + if target == WireFormat::OpenAiChat { + event["choices"][0]["delta"]["content"].as_str() + } else { + event["delta"]["text"].as_str() + } + }) + .collect(); + assert_eq!(text, "partial answer", "{target:?}, prefix={prefix:?}"); + if target == WireFormat::OpenAiChat { + let terminal = events.last().ok_or("missing Chat terminal chunk")?; + assert_eq!(terminal["choices"][0]["finish_reason"], "length"); + assert_eq!(terminal["usage"]["prompt_tokens"], 7); + assert_eq!(terminal["usage"]["completion_tokens"], 2); + } else { + assert_eq!( + events.last().ok_or("missing message stop")?["type"], + "message_stop" + ); + let terminal = events + .iter() + .find(|event| event["type"] == "message_delta") + .ok_or("missing message delta")?; + assert_eq!(terminal["delta"]["stop_reason"], "max_tokens"); + assert_eq!(terminal["usage"]["output_tokens"], 2); + } + } + } + Ok(()) +} + +#[test] +fn responses_terminal_discriminators_preserve_stop_reasons() -> TestResult { + let engine = TranslationEngine::default(); + let source = WireFormat::OpenAiResponses; + let items = [ + json!({"type": "message", "role": "assistant", "content": [ + {"type": "output_text", "text": "answer"} + ]}), + json!({"type": "function_call", "call_id": "call_one", "name": "lookup", "arguments": "{}"}), + ]; + for target in [WireFormat::OpenAiChat, WireFormat::AnthropicMessages] { + for item in &items { + for is_incomplete in [false, true] { + for discriminator in ["type", "event"] { + let mut state = StreamTranslationState::new(source, target); + let terminal = if is_incomplete { + "response.incomplete" + } else { + "response.completed" + }; + let mut events = Vec::new(); + for input in [ + json!({"type": "response.created", "response": {"id": "resp_one", "model": "fixture"}}), + json!({"type": "response.output_item.done", "output_index": 0, "item": item}), + json!({discriminator: terminal, "response": {"output": [item]}}), + ] { + events.extend(engine.translate_event(&mut state, source, target, &input)?); + } + events.extend(engine.finish_stream(&mut state, target)?); + let reason = if target == WireFormat::OpenAiChat { + &events.last().ok_or("missing Chat terminal chunk")?["choices"][0]["finish_reason"] + } else { + &events + .iter() + .find(|event| event["type"] == "message_delta") + .ok_or("missing message delta")?["delta"]["stop_reason"] + }; + let expected = match (target, is_incomplete, item["type"].as_str()) { + (WireFormat::OpenAiChat, true, _) => "length", + (_, true, _) => "max_tokens", + (WireFormat::OpenAiChat, false, Some("function_call")) => "tool_calls", + (_, false, Some("function_call")) => "tool_use", + (WireFormat::OpenAiChat, false, _) => "stop", + _ => "end_turn", + }; + assert_eq!( + reason, expected, + "{target:?}, {terminal}, {discriminator}, {item}" + ); + } + } + } + } + Ok(()) +} + // Interleave text and two tool calls to check that deltas keep the right item IDs. // Completion events must include the full text and tool arguments, and the final // response must preserve each item's ID.