Skip to content

Commit c24e945

Browse files
committed
fix(translation): defer Chat tool names until stream completion
Signed-off-by: Bhuvan Agrawal <11240550+bhuvan002@users.noreply.github.com>
1 parent 3487392 commit c24e945

3 files changed

Lines changed: 44 additions & 31 deletions

File tree

‎crates/switchyard-translation/src/codecs/openai_chat/stream.rs‎

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -158,22 +158,14 @@ fn decode_openai_chat_stream(
158158
.and_then(|function| function.get("arguments"))
159159
.and_then(Value::as_str)
160160
.map(ToOwned::to_owned);
161-
// Keep name fragments together until argument text begins.
162-
let name = if arguments_delta
163-
.as_deref()
164-
.is_some_and(|args| !args.is_empty())
165-
{
166-
state.pending_chat_tool_names.remove(&index)
167-
} else {
168-
None
169-
};
170161
out.push(LlmResponseChunk::ToolCallDelta {
171162
index,
172163
id: tool_call
173164
.get("id")
174165
.and_then(Value::as_str)
175166
.map(ToOwned::to_owned),
176-
name,
167+
// Names may continue after arguments begin; emit them at finish_reason.
168+
name: None,
177169
arguments_delta,
178170
});
179171
}

‎crates/switchyard-translation/tests/anthropic_parallel_tools.rs‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,8 @@ fn parallel_chat_tools_stream_as_ordered_nonoverlapping_anthropic_blocks() -> Te
5555
]}),
5656
)?;
5757
assert!(
58-
first_fragments
59-
.iter()
60-
.any(|event| { event["delta"]["partial_json"] == "{\"city\":\"Pa" }),
61-
"the first tool should still stream its arguments before EOF"
58+
first_fragments.is_empty(),
59+
"tool names are not complete yet"
6260
);
6361
events.extend(first_fragments);
6462
events.extend(translate(

‎crates/switchyard-translation/tests/stream_translation.rs‎

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,22 +22,30 @@ type TestResult = std::result::Result<(), Box<dyn std::error::Error + Send + Syn
2222
fn fragmented_chat_tool_names_are_complete_in_cross_format_streams() -> TestResult {
2323
let engine = TranslationEngine::default();
2424
for target in [WireFormat::AnthropicMessages, WireFormat::OpenAiResponses] {
25-
for arguments in ["{}", ""] {
25+
for (early_arguments, arguments) in [("{", "}"), ("", "{}"), ("", "")] {
2626
let mut state = StreamTranslationState::new(WireFormat::OpenAiChat, target);
2727
let mut events = Vec::new();
2828
for event in [
2929
json!({"choices": [{"delta": {"tool_calls": [{"index": 0,
3030
"id": "call_weather", "function": {"name": "wea", "arguments": ""}}]}}]}),
31+
json!({"choices": [{"delta": {"tool_calls": [{"index": 0,
32+
"function": {"arguments": early_arguments}}]}}]}),
3133
json!({"choices": [{"delta": {"tool_calls": [{"index": 0,
3234
"function": {"name": "ther", "arguments": arguments}}]}}]}),
3335
json!({"choices": [{"delta": {}, "finish_reason": "tool_calls"}]}),
3436
] {
35-
events.extend(engine.translate_event(
36-
&mut state,
37-
WireFormat::OpenAiChat,
38-
target,
39-
&event,
40-
)?);
37+
let translated =
38+
engine.translate_event(&mut state, WireFormat::OpenAiChat, target, &event)?;
39+
if event["choices"][0]["finish_reason"].is_null() {
40+
assert!(
41+
translated.iter().all(|event| {
42+
event["type"] != "content_block_start"
43+
&& event["type"] != "response.output_item.added"
44+
}),
45+
"tool name must not be announced before completion"
46+
);
47+
}
48+
events.extend(translated);
4149
}
4250
events.extend(engine.finish_stream(&mut state, target)?);
4351
let names: Vec<_> = events
@@ -56,6 +64,17 @@ fn fragmented_chat_tool_names_are_complete_in_cross_format_streams() -> TestResu
5664
vec!["weather", "weather"]
5765
};
5866
assert_eq!(names, expected, "{target:?}, arguments={arguments:?}");
67+
let emitted_arguments: String = events
68+
.iter()
69+
.filter_map(|event| {
70+
if event["type"] == "response.function_call_arguments.delta" {
71+
event["delta"].as_str()
72+
} else {
73+
event["delta"]["partial_json"].as_str()
74+
}
75+
})
76+
.collect();
77+
assert_eq!(emitted_arguments, format!("{early_arguments}{arguments}"));
5978
}
6079
}
6180
Ok(())
@@ -2114,24 +2133,28 @@ fn translated_responses_text_and_tool_events_keep_item_identity_until_done() ->
21142133
.iter()
21152134
.filter(|event| event["output_index"] == index)
21162135
.collect::<Vec<_>>();
2136+
let mut expected_types = vec![
2137+
"response.output_item.added",
2138+
"response.function_call_arguments.delta",
2139+
"response.function_call_arguments.done",
2140+
"response.output_item.done",
2141+
];
2142+
if source == WireFormat::AnthropicMessages {
2143+
expected_types.insert(2, "response.function_call_arguments.delta");
2144+
}
21172145
assert_eq!(
21182146
tool_events
21192147
.iter()
21202148
.map(|event| event["type"].as_str().unwrap_or_default())
21212149
.collect::<Vec<_>>(),
2122-
[
2123-
"response.output_item.added",
2124-
"response.function_call_arguments.delta",
2125-
"response.function_call_arguments.delta",
2126-
"response.function_call_arguments.done",
2127-
"response.output_item.done"
2128-
]
2150+
expected_types
21292151
);
2130-
let done = tool_events[3];
2152+
let done = tool_events[tool_events.len() - 2];
21312153
assert_eq!(done["name"], name);
21322154
assert_eq!(done["arguments"], arguments);
2133-
assert_eq!(tool_events[4]["item"]["id"], items[&index]["id"]);
2134-
assert_eq!(tool_events[4]["item"]["call_id"], call_id);
2155+
let item_done = tool_events[tool_events.len() - 1];
2156+
assert_eq!(item_done["item"]["id"], items[&index]["id"]);
2157+
assert_eq!(item_done["item"]["call_id"], call_id);
21352158
}
21362159
let completed = events.last().ok_or("missing response completion")?;
21372160
assert_eq!(completed["type"], "response.completed");

0 commit comments

Comments
 (0)