From 61be0a128b0228213cd282d7da6f1ab442683fe8 Mon Sep 17 00:00:00 2001 From: Artem Rozumenko Date: Tue, 18 Aug 2026 14:32:50 +0300 Subject: [PATCH 1/2] fix(translation): emit Responses tool arguments once when decoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `response.output_item.done` repeats the complete function-call arguments the delta events already carried, so the decoder suppresses it when it matches what has been seen. That comparison read `StreamToolState::arguments` — a field no decoder writes. It is populated by the Anthropic encoder, so the check only held when a single StreamTranslationState performed both halves of the translation. libsy buffers a streamed turn with its own state and encodes later, so every llm_classifier route emitted the arguments twice. Passthrough, where one state does decode and encode, was unaffected — which is why this survived. Observed through switchyard-server 0.2.0 against a live Azure gpt-5.6 target: an Anthropic client accumulating input_json_delta received `{"skill":"x"}{"skill":"x"}`. Claude Code rejects that with "InputValidationError: The parameter '' type is expected as 'object' but provided as 'string'", making every tool-using agent session fail on any llm_classifier route. The decoder now accumulates into its own `decoded_arguments`, so the deduplication holds regardless of which state encodes, or whether anything encodes at all. Co-Authored-By: Claude Opus 5 Signed-off-by: Artem Rozumenko --- .../src/codecs/responses/stream.rs | 49 +++++++++++++------ .../src/codecs/stream.rs | 8 +++ .../tests/stream_translation.rs | 49 +++++++++++++++++++ 3 files changed, 92 insertions(+), 14 deletions(-) diff --git a/crates/switchyard-translation/src/codecs/responses/stream.rs b/crates/switchyard-translation/src/codecs/responses/stream.rs index b076e3300..856057d2b 100644 --- a/crates/switchyard-translation/src/codecs/responses/stream.rs +++ b/crates/switchyard-translation/src/codecs/responses/stream.rs @@ -101,7 +101,7 @@ fn decode_responses_stream( }) .unwrap_or_default() } - Some("response.output_item.added") => decode_responses_output_item_added(event), + Some("response.output_item.added") => decode_responses_output_item_added(event, state), Some("response.function_call_arguments.delta") => { let output_index = event .get("output_index") @@ -111,6 +111,14 @@ fn decode_responses_stream( .get("delta") .and_then(Value::as_str) .map(|delta| { + // Recorded so `response.output_item.done`, which repeats + // the complete arguments, can tell it is a repeat. + state + .tool_states + .entry(output_index as usize) + .or_default() + .decoded_arguments + .push_str(delta); vec![LlmResponseChunk::ToolCallDelta { index: output_index as usize, id: None, @@ -325,7 +333,10 @@ fn finish_responses_stream(state: &mut StreamTranslationState) -> Vec { } // Converts Responses function-call item creation into a neutral tool-call delta. -fn decode_responses_output_item_added(event: &Value) -> Vec { +fn decode_responses_output_item_added( + event: &Value, + state: &mut StreamTranslationState, +) -> Vec { let Some(item) = event.get("item").and_then(Value::as_object) else { return Vec::new(); }; @@ -336,6 +347,19 @@ fn decode_responses_output_item_added(event: &Value) -> Vec { .get("output_index") .and_then(Value::as_u64) .unwrap_or(0) as usize; + let arguments_delta = item + .get("arguments") + .and_then(Value::as_str) + .filter(|arguments| !arguments.is_empty()) + .map(ToOwned::to_owned); + if let Some(arguments) = arguments_delta.as_deref() { + state + .tool_states + .entry(index) + .or_default() + .decoded_arguments + .push_str(arguments); + } vec![LlmResponseChunk::ToolCallDelta { index, id: item @@ -347,18 +371,14 @@ fn decode_responses_output_item_added(event: &Value) -> Vec { .get("name") .and_then(Value::as_str) .map(ToOwned::to_owned), - arguments_delta: item - .get("arguments") - .and_then(Value::as_str) - .filter(|arguments| !arguments.is_empty()) - .map(ToOwned::to_owned), + arguments_delta, }] } // Emits a final tool-call argument delta when Responses only supplies arguments at item end. fn decode_responses_output_item_done( event: &Value, - state: &StreamTranslationState, + state: &mut StreamTranslationState, ) -> Vec { let Some(item) = event.get("item").and_then(Value::as_object) else { return Vec::new(); @@ -372,12 +392,13 @@ fn decode_responses_output_item_done( .unwrap_or(0) as usize; let arguments = item.get("arguments").and_then(Value::as_str); if let Some(arguments) = arguments { - let existing = state - .tool_states - .get(&index) - .map(|tool| tool.arguments.as_str()) - .unwrap_or(""); - if !arguments.is_empty() && arguments != existing { + // Compared against what THIS decoder has seen. Reading the encoder's + // `arguments` instead only deduplicates when a single state performs + // both halves of the translation, and silently duplicates when a + // caller buffers the stream with its own state. + let tool = state.tool_states.entry(index).or_default(); + if !arguments.is_empty() && arguments != tool.decoded_arguments { + tool.decoded_arguments.push_str(arguments); return vec![LlmResponseChunk::ToolCallDelta { index, id: None, diff --git a/crates/switchyard-translation/src/codecs/stream.rs b/crates/switchyard-translation/src/codecs/stream.rs index bcd586a27..7bd2a8f87 100644 --- a/crates/switchyard-translation/src/codecs/stream.rs +++ b/crates/switchyard-translation/src/codecs/stream.rs @@ -69,6 +69,14 @@ pub(crate) struct StreamToolState { pub(crate) id: Option, pub(crate) name: Option, pub(crate) arguments: String, + /// Arguments observed while DECODING the source stream. + /// + /// Separate from `arguments`, which encoders accumulate. A decoder that + /// deduplicates against `arguments` only works when one state performs + /// both halves of the translation; when a caller buffers a stream with its + /// own state and encodes later, the field is empty and the duplicate is + /// emitted. + pub(crate) decoded_arguments: String, pub(crate) pending_arguments: String, pub(crate) started: bool, pub(crate) content_index: Option, diff --git a/crates/switchyard-translation/tests/stream_translation.rs b/crates/switchyard-translation/tests/stream_translation.rs index 98cf5f1ce..5572207f3 100644 --- a/crates/switchyard-translation/tests/stream_translation.rs +++ b/crates/switchyard-translation/tests/stream_translation.rs @@ -1198,3 +1198,52 @@ fn responses_incomplete_event_translates_to_chat_length_finish() -> TestResult { assert_eq!(terminal["choices"][0]["finish_reason"], "length"); Ok(()) } + +// Decoding alone must yield the function-call arguments exactly once. +// +// `response.output_item.done` repeats the complete arguments that the delta +// events already carried, so the decoder drops it when it matches what it has +// seen. That comparison reads state the DECODER never writes — it is filled by +// the Anthropic encoder — so it only holds when one state happens to do both +// halves. libsy buffers a turn with its own state and no Anthropic encode, and +// the arguments are then emitted twice. +// +// Observed through switchyard-server 0.2.0 against a live Azure gpt-5.6 +// target: every llm_classifier route doubled its streamed tool arguments, +// while passthrough over the identical path was correct. Claude Code rejects +// the result with "InputValidationError: parameter type is expected as +// 'object' but provided as 'string'". +#[test] +fn responses_decode_emits_tool_arguments_once() -> TestResult { + let engine = TranslationEngine::default(); + let mut state = StreamTranslationState::default(); + let arguments = r#"{"skill":"demo:thing","args":{}}"#; + + let upstream = [ + json!({"type": "response.output_item.added", "output_index": 0, + "item": {"type": "function_call", "call_id": "call_1", + "name": "Skill", "arguments": ""}}), + json!({"type": "response.function_call_arguments.delta", + "output_index": 0, "delta": arguments}), + json!({"type": "response.output_item.done", "output_index": 0, + "item": {"type": "function_call", "call_id": "call_1", + "name": "Skill", "arguments": arguments}}), + ]; + + let mut seen = String::new(); + for event in upstream { + let decoded = engine.decode_stream_event(&mut state, WireFormat::OpenAiResponses, event)?; + for chunk in decoded.normalized() { + if let LlmResponseChunk::ToolCallDelta { + arguments_delta: Some(delta), + .. + } = chunk + { + seen.push_str(delta); + } + } + } + + assert_eq!(seen, arguments); + Ok(()) +} From 01ea7048383082334c02a7256f5c4f984ed87d36 Mon Sep 17 00:00:00 2001 From: Artem Date: Tue, 18 Aug 2026 14:46:20 +0300 Subject: [PATCH 2/2] Update crates/switchyard-translation/tests/stream_translation.rs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Artem Rozumenko --- .../tests/stream_translation.rs | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/crates/switchyard-translation/tests/stream_translation.rs b/crates/switchyard-translation/tests/stream_translation.rs index 5572207f3..3190e57bb 100644 --- a/crates/switchyard-translation/tests/stream_translation.rs +++ b/crates/switchyard-translation/tests/stream_translation.rs @@ -1199,20 +1199,7 @@ fn responses_incomplete_event_translates_to_chat_length_finish() -> TestResult { Ok(()) } -// Decoding alone must yield the function-call arguments exactly once. -// -// `response.output_item.done` repeats the complete arguments that the delta -// events already carried, so the decoder drops it when it matches what it has -// seen. That comparison reads state the DECODER never writes — it is filled by -// the Anthropic encoder — so it only holds when one state happens to do both -// halves. libsy buffers a turn with its own state and no Anthropic encode, and -// the arguments are then emitted twice. -// -// Observed through switchyard-server 0.2.0 against a live Azure gpt-5.6 -// target: every llm_classifier route doubled its streamed tool arguments, -// while passthrough over the identical path was correct. Claude Code rejects -// the result with "InputValidationError: parameter type is expected as -// 'object' but provided as 'string'". +// A completion event must not repeat function-call arguments from delta events. #[test] fn responses_decode_emits_tool_arguments_once() -> TestResult { let engine = TranslationEngine::default();