Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,15 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
refunded the review budget but left the conversation's stall latch set, so
every later eligible turn silently bypassed the advisor. The latch now clears
whenever the reserved review is refunded or the budget is already spent.
- **Streamed Responses tool calls end with a tool-use stop reason** — the
Responses stream decoder reported every `response.completed` as a plain
completion, so a streamed `function_call` reached Anthropic clients as
`stop_reason: "end_turn"` and Chat clients as `finish_reason: "stop"`.
Stop-reason-driven tool loops, including the official Anthropic TypeScript
SDK tool runner, then returned the unfinished tool-use turn without running
the tool. The decoder now reports `tool_use` when the completed output holds
a `function_call` or `custom_tool_call`, or when it already decoded tool
deltas, matching the buffered decoder.
- **Encrypted-only reasoning items open no summary part** — the Responses
stream encoder opened a `reasoning_summary_part` for every reasoning item and
closed it only when text had streamed, so an encrypted-only item left a part
Expand Down
24 changes: 23 additions & 1 deletion crates/switchyard-translation/src/codecs/responses/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ fn decode_responses_stream(
.get("delta")
.and_then(Value::as_str)
.map(|delta| {
state.decoded_tool_call = true;
// Recorded so `response.output_item.done`, which repeats
// the complete arguments, can tell it is a repeat.
state
Expand Down Expand Up @@ -272,7 +273,18 @@ fn decode_responses_stream(
state.saw_backend_usage = true;
out.push(LlmResponseChunk::Usage(usage));
}
out.push(LlmResponseChunk::MessageStop { reason: None });
// A completed response that produced a tool call ended the turn to run that
// tool, not because the assistant was done. The buffered decoder reports tool
// 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 has_tool_call = event
.get("response")
.and_then(|response| response.get("output"))
.and_then(Value::as_array)
.is_some_and(|items| items.iter().any(is_responses_tool_call_item));
let reason = (has_tool_call || 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.
Expand Down Expand Up @@ -608,6 +620,7 @@ fn decode_responses_output_item_added(
if item_type != Some("function_call") && item_type != Some("custom_tool_call") {
return Vec::new();
}
state.decoded_tool_call = true;
// A freeform call's `input` becomes the single `input` argument; it is only complete on
// the done event, so nothing is emitted for it here beyond id and name.
let arguments_delta = if item_type == Some("custom_tool_call") {
Expand Down Expand Up @@ -641,6 +654,14 @@ fn decode_responses_output_item_added(
}]
}

// Whether a Responses output item is a client tool call the consumer must run.
fn is_responses_tool_call_item(item: &Value) -> bool {
matches!(
item.get("type").and_then(Value::as_str),
Some("function_call" | "custom_tool_call")
)
}

// Emits a final tool-call argument delta when Responses only supplies arguments at item end.
fn decode_responses_output_item_done(
event: &Value,
Expand All @@ -660,6 +681,7 @@ fn decode_responses_output_item_done(
if item_type != Some("function_call") && item_type != Some("custom_tool_call") {
return Vec::new();
}
state.decoded_tool_call = true;
let custom_arguments = (item_type == Some("custom_tool_call")).then(|| {
json!({
crate::codex_custom_tools::INPUT_ARGUMENT:
Expand Down
3 changes: 3 additions & 0 deletions crates/switchyard-translation/src/codecs/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ pub struct StreamTranslationState {
pub(crate) decoded_reasoning: BTreeMap<usize, String>,
/// Output indexes whose encrypted reasoning payload was already decoded.
pub(crate) decoded_reasoning_encrypted: std::collections::BTreeSet<usize>,
/// Set once a tool call was observed while DECODING, so a terminal event that names no
/// stop reason can still report tool use.
pub(crate) decoded_tool_call: bool,

pub(crate) response_created: bool,
pub(crate) response_text_started: bool,
Expand Down
136 changes: 136 additions & 0 deletions crates/switchyard-translation/tests/stream_translation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1285,6 +1285,142 @@ fn responses_buffered_and_streamed_outputs_match() -> TestResult {
Ok(())
}

// The Responses events a provider streams for one completed function call, ending with a
// `response.completed` whose output repeats the finished call.
fn responses_function_call_stream() -> Vec<Value> {
let call = json!({
"id": "fc_1",
"type": "function_call",
"status": "completed",
"call_id": "call_1",
"name": "get_weather",
"arguments": "{\"city\":\"Paris\"}"
});
vec![
json!({
"type": "response.created",
"response": {"id": "resp_1", "model": "gpt-5.6", "status": "in_progress", "output": []}
}),
json!({
"type": "response.output_item.added",
"output_index": 0,
"item": {
"id": "fc_1",
"type": "function_call",
"status": "in_progress",
"call_id": "call_1",
"name": "get_weather",
"arguments": ""
}
}),
json!({
"type": "response.function_call_arguments.delta",
"item_id": "fc_1",
"output_index": 0,
"delta": "{\"city\":\"Paris\"}"
}),
json!({
"type": "response.function_call_arguments.done",
"item_id": "fc_1",
"output_index": 0,
"name": "get_weather",
"arguments": "{\"city\":\"Paris\"}"
}),
json!({"type": "response.output_item.done", "output_index": 0, "item": call}),
json!({
"type": "response.completed",
"response": {"id": "resp_1", "model": "gpt-5.6", "status": "completed", "output": [call]}
}),
]
}

// Translates a whole source stream into `target` events, including the encoder's finish.
fn translate_stream(
engine: &TranslationEngine,
source: WireFormat,
target: WireFormat,
events: &[Value],
) -> std::result::Result<Vec<Value>, Box<dyn std::error::Error + Send + Sync>> {
let mut state = StreamTranslationState::new(source, target);
let mut out = Vec::new();
for event in events {
out.extend(engine.translate_event(&mut state, source, target, event)?);
}
out.extend(engine.finish_stream(&mut state, target)?);
Ok(out)
}

// A streamed Responses function call must end with the tool-use stop reason the buffered decoder
// reports: Anthropic `tool_use`, Chat `tool_calls`. Anthropic's tool runners dispatch on it;
// `end_turn` makes them return the unfinished tool-use turn without running the tool. A bare
// `response.completed` with no output array falls back to the argument deltas already decoded,
// while a text-only stream still reports no reason.
#[test]
fn responses_function_call_stream_ends_with_tool_use_on_every_wire() -> TestResult {
let engine = TranslationEngine::default();
let stream = responses_function_call_stream();

let anthropic = translate_stream(
&engine,
WireFormat::OpenAiResponses,
WireFormat::AnthropicMessages,
&stream,
)?;
let stop_reasons: Vec<&Value> = anthropic
.iter()
.filter(|event| event["type"] == "message_delta")
.map(|event| &event["delta"]["stop_reason"])
.collect();
assert_eq!(stop_reasons, vec![&json!("tool_use")]);
assert!(anthropic.iter().any(|event| {
event["type"] == "content_block_start"
&& event["content_block"]["type"] == "tool_use"
&& event["content_block"]["name"] == "get_weather"
}));

let chat = translate_stream(
&engine,
WireFormat::OpenAiResponses,
WireFormat::OpenAiChat,
&stream,
)?;
let finish_reasons: Vec<&Value> = chat
.iter()
.filter_map(|event| event["choices"][0].get("finish_reason"))
.filter(|reason| !reason.is_null())
.collect();
assert_eq!(finish_reasons, vec![&json!("tool_calls")]);
assert!(chat.iter().any(|event| {
event["choices"][0]["delta"]["tool_calls"][0]["function"]["name"] == "get_weather"
}));

// Delta-only fallback: `response.created`, one argument delta, then a bare completion with
// no output-item events at all.
let bare_completed = json!({"type": "response.completed", "response": {}});
let mut state =
StreamTranslationState::new(WireFormat::OpenAiResponses, WireFormat::OpenAiResponses);
let mut last = Vec::new();
for event in [&stream[0], &stream[2], &bare_completed] {
last = decode_stream_event(&mut state, WireFormat::OpenAiResponses, event);
}
assert_eq!(
last,
vec![LlmResponseChunk::MessageStop {
reason: Some("tool_use".to_string())
}]
);

let mut state =
StreamTranslationState::new(WireFormat::OpenAiResponses, WireFormat::OpenAiResponses);
let text = json!({"type": "response.output_text.delta", "output_index": 0, "delta": "hi"});
decode_stream_event(&mut state, WireFormat::OpenAiResponses, &text);
assert_eq!(
decode_stream_event(&mut state, WireFormat::OpenAiResponses, &bare_completed),
vec![LlmResponseChunk::MessageStop { reason: None }]
);
Ok(())
}

// An OpenAI-shaped error frame carries no `choices`, so it must decode to a stream error
// instead of a bare message start that silently drops the upstream message.
#[test]
Expand Down
Loading