From 18ef28bd343d23325d5ea19d56104a4e1fcff1b7 Mon Sep 17 00:00:00 2001 From: Ting-Hong Shieh <32212900+ting-hong-shieh@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:53:25 +0800 Subject: [PATCH 1/2] fix(translation): preserve tool IDs across Anthropic Signed-off-by: Ting-Hong Shieh <32212900+ting-hong-shieh@users.noreply.github.com> --- Cargo.lock | 1 + Cargo.toml | 1 + crates/switchyard-translation/Cargo.toml | 1 + .../src/codecs/openai_chat/buffered.rs | 10 +- .../src/codecs/responses/buffered.rs | 10 +- crates/switchyard-translation/src/util.rs | 77 ++++++++-- .../tests/request_translation.rs | 132 +++++++++++++++++- .../tests/stream_translation.rs | 45 ++++++ 8 files changed, 249 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 39871f0e9..d4439bff5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2392,6 +2392,7 @@ name = "switchyard-translation" version = "0.2.0" dependencies = [ "async-stream", + "base64", "futures", "pretty_assertions", "serde", diff --git a/Cargo.toml b/Cargo.toml index 07d133bb3..0ea78f101 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ rust-version = "1.96.1" [workspace.dependencies] async-stream = "0.3" async-trait = "0.1" +base64 = "0.22" futures = "0.3" futures-util = "0.3" http = "1" diff --git a/crates/switchyard-translation/Cargo.toml b/crates/switchyard-translation/Cargo.toml index ee3acc187..1a13fffaa 100644 --- a/crates/switchyard-translation/Cargo.toml +++ b/crates/switchyard-translation/Cargo.toml @@ -17,6 +17,7 @@ keywords = ["llm", "translation", "openai", "anthropic"] publish = ["crates-io"] [dependencies] +base64.workspace = true serde.workspace = true serde_json.workspace = true switchyard-protocol.workspace = true diff --git a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs index dbe77ca2a..f6381601c 100644 --- a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs +++ b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs @@ -21,9 +21,9 @@ use crate::llm::{ }; use crate::policy::{DeterministicIdPolicy, TranslationPolicy}; use crate::util::{ - capture_request_preservation, capture_response_preservation, embed_preservation, - exact_preserved_request, exact_preserved_response, json_string, object, push_lossy, stable_id, - string_value, validate_request_capabilities, + capture_request_preservation, capture_response_preservation, desanitize_anthropic_tool_use_id, + embed_preservation, exact_preserved_request, exact_preserved_response, json_string, object, + push_lossy, stable_id, string_value, validate_request_capabilities, }; /// Format codec for OpenAI Chat Completions payloads. @@ -709,7 +709,7 @@ fn encode_message_with_tool_results_to_openai( )?; out.push(json!({ "role": "tool", - "tool_call_id": result.tool_call_id, + "tool_call_id": desanitize_anthropic_tool_use_id(&result.tool_call_id), "content": text_from_blocks(&result.content, " "), })); } else { @@ -767,7 +767,7 @@ fn encode_message_without_tool_results_to_openai( .iter() .filter_map(|block| match block { ContentBlock::ToolCall(call) => Some(json!({ - "id": call.id, + "id": desanitize_anthropic_tool_use_id(&call.id), "type": "function", "function": { "name": call.name, diff --git a/crates/switchyard-translation/src/codecs/responses/buffered.rs b/crates/switchyard-translation/src/codecs/responses/buffered.rs index 025dbe35f..c73662017 100644 --- a/crates/switchyard-translation/src/codecs/responses/buffered.rs +++ b/crates/switchyard-translation/src/codecs/responses/buffered.rs @@ -24,9 +24,9 @@ use crate::llm::{ }; use crate::policy::{DeterministicIdPolicy, TranslationPolicy}; use crate::util::{ - capture_request_preservation, capture_response_preservation, embed_preservation, - exact_preserved_request, exact_preserved_response, json_string, push_lossy, stable_id, - string_value, validate_request_capabilities, + capture_request_preservation, capture_response_preservation, desanitize_anthropic_tool_use_id, + embed_preservation, exact_preserved_request, exact_preserved_response, json_string, push_lossy, + stable_id, string_value, validate_request_capabilities, }; /// Format codec for OpenAI Responses payloads. @@ -973,13 +973,13 @@ fn encode_responses_special_input(block: &ContentBlock) -> Option { })), ContentBlock::ToolCall(call) => Some(json!({ "type": "function_call", - "call_id": call.id, + "call_id": desanitize_anthropic_tool_use_id(&call.id), "name": call.name, "arguments": json_string(&call.arguments), })), ContentBlock::ToolResult(result) => Some(json!({ "type": "function_call_output", - "call_id": result.tool_call_id, + "call_id": desanitize_anthropic_tool_use_id(&result.tool_call_id), "output": text_from_blocks(&result.content, " "), })), _ => None, diff --git a/crates/switchyard-translation/src/util.rs b/crates/switchyard-translation/src/util.rs index 9fcf769fd..9a75250d1 100644 --- a/crates/switchyard-translation/src/util.rs +++ b/crates/switchyard-translation/src/util.rs @@ -5,6 +5,7 @@ use std::collections::BTreeMap; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use serde_json::{Map, Value, json}; use crate::diagnostic::TranslationDiagnostic; @@ -20,6 +21,8 @@ pub const SWITCHYARD_METADATA_KEY: &str = "_switchyard_translation"; /// Public alias for the embedded preservation metadata key. pub const PRESERVATION_METADATA_KEY: &str = SWITCHYARD_METADATA_KEY; +const ANTHROPIC_TOOL_ID_ENCODING_PREFIX: &str = "sy64_"; + /// Reads a JSON object or returns a typed translation error at the given path. pub fn object<'a>(value: &'a Value, path: &str) -> Result<&'a Map> { value @@ -327,23 +330,33 @@ pub fn normalize_anthropic_tool_use_ids(value: Value) -> Value { } } -/// Converts a single ID into Anthropic-safe characters. +/// Converts an ID into a reversible Anthropic-safe representation. pub fn sanitize_anthropic_tool_use_id(raw: &str) -> String { - let sanitized = raw - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' { - ch - } else { - '_' - } - }) - .collect::(); - if sanitized.is_empty() { - "toolu_empty".to_string() - } else { - sanitized + let is_safe = !raw.is_empty() + && raw + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-'); + if is_safe && !raw.starts_with(ANTHROPIC_TOOL_ID_ENCODING_PREFIX) { + return raw.to_string(); } + + format!( + "{ANTHROPIC_TOOL_ID_ENCODING_PREFIX}{}", + URL_SAFE_NO_PAD.encode(raw.as_bytes()) + ) +} + +/// Restores an ID encoded by [`sanitize_anthropic_tool_use_id`]. +pub(crate) fn desanitize_anthropic_tool_use_id(encoded: &str) -> String { + let Some(payload) = encoded.strip_prefix(ANTHROPIC_TOOL_ID_ENCODING_PREFIX) else { + return encoded.to_string(); + }; + + URL_SAFE_NO_PAD + .decode(payload) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .unwrap_or_else(|| encoded.to_string()) } // Normalizes every content block in one Anthropic message. @@ -441,3 +454,37 @@ fn stable_suffix(raw: &str) -> String { } format!("{hash:08x}") } + +#[cfg(test)] +mod tests { + use super::{desanitize_anthropic_tool_use_id, sanitize_anthropic_tool_use_id}; + + // Keeps ordinary provider IDs unchanged while making unsafe IDs reversible. + #[test] + fn anthropic_tool_id_encoding_round_trips() { + assert_eq!( + sanitize_anthropic_tool_use_id("call_abc-123"), + "call_abc-123" + ); + + for raw in ["", "functions.list_skills:0", "工具/lookup"] { + let encoded = sanitize_anthropic_tool_use_id(raw); + assert!( + encoded + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') + ); + assert_eq!(desanitize_anthropic_tool_use_id(&encoded), raw); + } + } + + // Escapes the reserved prefix and leaves malformed encoded values untouched. + #[test] + fn anthropic_tool_id_encoding_disambiguates_its_prefix() { + let raw = "sy64_Zm9v"; + let encoded = sanitize_anthropic_tool_use_id(raw); + assert_ne!(encoded, raw); + assert_eq!(desanitize_anthropic_tool_use_id(&encoded), raw); + assert_eq!(desanitize_anthropic_tool_use_id("sy64_%%%"), "sy64_%%%"); + } +} diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index 71c765d01..613d02d7a 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -5,7 +5,9 @@ use pretty_assertions::assert_eq; use serde_json::{Value, json}; -use switchyard_translation::{TranslationEngine, TranslationPolicy, WireFormat}; +use switchyard_translation::{ + TranslationEngine, TranslationPolicy, WireFormat, sanitize_anthropic_tool_use_id, +}; type TestResult = std::result::Result<(), Box>; @@ -310,6 +312,126 @@ fn anthropic_tool_result_followup_text_splits_to_openai_messages() -> TestResult Ok(()) } +// Restores IDs sanitized on the Anthropic response leg before calling OpenAI Chat upstreams. +#[test] +fn anthropic_tool_ids_are_restored_for_openai_chat() -> TestResult { + let engine = TranslationEngine::default(); + let raw_id = "functions.list_skills:0"; + let upstream_response = json!({ + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 0, + "model": "kimi-k2", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": raw_id, + "type": "function", + "function": {"name": "list_skills", "arguments": "{}"} + }] + }, + "finish_reason": "tool_calls" + }] + }); + let anthropic_response = engine + .translate_response( + WireFormat::OpenAiChat, + WireFormat::AnthropicMessages, + &upstream_response, + &TranslationPolicy::default(), + )? + .body; + let safe_id = anthropic_response["content"] + .as_array() + .and_then(|content| content.iter().find(|block| block["type"] == "tool_use")) + .and_then(|block| block["id"].as_str()) + .ok_or_else(|| format!("translated tool_use should have an ID: {anthropic_response}"))?; + assert_ne!(safe_id, raw_id); + let body = json!({ + "model": "claude-sonnet-4-20250514", + "messages": [ + { + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": safe_id, + "name": "list_skills", + "input": {} + }] + }, + { + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": safe_id, + "content": "done" + }] + } + ], + "max_tokens": 100 + }); + + let output = engine + .translate_request( + WireFormat::AnthropicMessages, + WireFormat::OpenAiChat, + &body, + &TranslationPolicy::default(), + )? + .body; + + assert_eq!(output["messages"][0]["tool_calls"][0]["id"], raw_id); + assert_eq!(output["messages"][1]["tool_call_id"], raw_id); + Ok(()) +} + +// Restores the same IDs for OpenAI Responses function calls and outputs. +#[test] +fn anthropic_tool_ids_are_restored_for_openai_responses() -> TestResult { + let engine = TranslationEngine::default(); + let raw_id = "functions.list_skills:0"; + let safe_id = sanitize_anthropic_tool_use_id(raw_id); + let body = json!({ + "model": "claude-sonnet-4-20250514", + "messages": [ + { + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": safe_id, + "name": "list_skills", + "input": {} + }] + }, + { + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": safe_id, + "content": "done" + }] + } + ], + "max_tokens": 100 + }); + + let output = engine + .translate_request( + WireFormat::AnthropicMessages, + WireFormat::OpenAiResponses, + &body, + &TranslationPolicy::default(), + )? + .body; + + assert_eq!(output["input"][0]["call_id"], raw_id); + assert_eq!(output["input"][1]["call_id"], raw_id); + Ok(()) +} + // Verifies structured Anthropic system blocks remain separated in OpenAI system text. #[test] fn anthropic_structured_system_blocks_preserve_boundaries_for_openai_chat() -> TestResult { @@ -1243,12 +1365,16 @@ fn openai_tool_results_are_merged_when_translating_to_anthropic() -> TestResult assert_eq!( output["messages"][1]["content"][0]["id"], - "call_bad_id_with_space" + sanitize_anthropic_tool_use_id("call.bad:id/with space") ); assert_eq!( output["messages"][2]["content"], json!([ - {"type": "tool_result", "tool_use_id": "call_bad_id_with_space", "content": "one"}, + { + "type": "tool_result", + "tool_use_id": sanitize_anthropic_tool_use_id("call.bad:id/with space"), + "content": "one" + }, {"type": "tool_result", "tool_use_id": "call_2", "content": "two"} ]) ); diff --git a/crates/switchyard-translation/tests/stream_translation.rs b/crates/switchyard-translation/tests/stream_translation.rs index 3aeed7159..23e2aa174 100644 --- a/crates/switchyard-translation/tests/stream_translation.rs +++ b/crates/switchyard-translation/tests/stream_translation.rs @@ -8,6 +8,7 @@ use serde_json::json; use switchyard_protocol::{ResponseAccumulator, StopReason}; use switchyard_translation::{ LlmResponseChunk, StreamTranslationState, TranslationEngine, WireFormat, decode_stream_event, + sanitize_anthropic_tool_use_id, }; type TestResult = std::result::Result<(), Box>; @@ -303,6 +304,50 @@ fn openai_chat_stream_event_translates_to_anthropic_message_events() -> TestResu Ok(()) } +// Verifies unsafe streamed tool IDs use the same reversible Anthropic-safe encoding. +#[test] +fn openai_chat_stream_tool_id_is_anthropic_safe() -> TestResult { + let engine = TranslationEngine::default(); + let mut state = + StreamTranslationState::new(WireFormat::OpenAiChat, WireFormat::AnthropicMessages); + let raw_id = "functions.list_skills:0"; + let chunk = json!({ + "id": "chatcmpl-test", + "object": "chat.completion.chunk", + "model": "moonshotai/kimi-k2", + "choices": [{ + "index": 0, + "delta": { + "tool_calls": [{ + "index": 0, + "id": raw_id, + "type": "function", + "function": {"name": "list_skills", "arguments": "{}"} + }] + }, + "finish_reason": null + }] + }); + + let events = engine.translate_event( + &mut state, + WireFormat::OpenAiChat, + WireFormat::AnthropicMessages, + &chunk, + )?; + let Some(tool_start) = events.iter().find(|event| { + event["type"] == "content_block_start" && event["content_block"]["type"] == "tool_use" + }) else { + return Err("expected an Anthropic tool_use content block".into()); + }; + + assert_eq!( + tool_start["content_block"]["id"], + sanitize_anthropic_tool_use_id(raw_id) + ); + Ok(()) +} + // Verifies Anthropic usage and stop events become terminal OpenAI chunks. #[test] fn anthropic_stream_usage_and_stop_translate_to_openai_chunks() -> TestResult { From e534e88cfb15ee85a6d496e98d621eff1a13ca49 Mon Sep 17 00:00:00 2001 From: Ting-Hong Shieh <32212900+ting-hong-shieh@users.noreply.github.com> Date: Sat, 22 Aug 2026 08:45:07 +0800 Subject: [PATCH 2/2] fix(translation): decode Anthropic tool IDs on ingress Signed-off-by: Ting-Hong Shieh <32212900+ting-hong-shieh@users.noreply.github.com> --- .../src/codecs/anthropic/buffered.rs | 12 +- .../src/codecs/anthropic/stream.rs | 4 +- .../src/codecs/openai_chat/buffered.rs | 10 +- .../src/codecs/responses/buffered.rs | 10 +- .../tests/request_translation.rs | 133 ++---------------- .../tests/stream_translation.rs | 47 +++---- 6 files changed, 45 insertions(+), 171 deletions(-) diff --git a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs index 87dc58b6c..0a7a9cd67 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs @@ -19,10 +19,10 @@ use crate::llm::{ SamplingParams, StopReason, ToolCall, ToolChoice, ToolDefinition, ToolResult, Usage, }; use crate::policy::{DeterministicIdPolicy, TranslationPolicy}; -use crate::util::sanitize_anthropic_tool_use_id; use crate::util::{ - capture_request_preservation, capture_response_preservation, embed_preservation, - exact_preserved_request, exact_preserved_response, + capture_request_preservation, capture_response_preservation, desanitize_anthropic_tool_use_id, + embed_preservation, exact_preserved_request, exact_preserved_response, + sanitize_anthropic_tool_use_id, }; use crate::util::{ json_string, push_lossy, stable_id, string_value, validate_request_capabilities, @@ -589,7 +589,7 @@ fn decode_anthropic_content_block( .get("id") .and_then(Value::as_str) .filter(|id| !id.is_empty()) - .map(ToOwned::to_owned) + .map(desanitize_anthropic_tool_use_id) .unwrap_or_else(|| match &policy.deterministic_ids { DeterministicIdPolicy::GenerateStable { prefix } => { stable_id(prefix, generated_counter) @@ -607,8 +607,8 @@ fn decode_anthropic_content_block( tool_call_id: block .get("tool_use_id") .and_then(Value::as_str) - .unwrap_or_default() - .to_string(), + .map(desanitize_anthropic_tool_use_id) + .unwrap_or_default(), content: decode_tool_result_content(block.get("content").unwrap_or(&Value::Null)), is_error: block.get("is_error").and_then(Value::as_bool), })], diff --git a/crates/switchyard-translation/src/codecs/anthropic/stream.rs b/crates/switchyard-translation/src/codecs/anthropic/stream.rs index cd09a4066..7095b2456 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/stream.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/stream.rs @@ -11,7 +11,7 @@ use crate::codecs::stream::{ target_message_id_or_source_message_id, target_model_or_source_model, }; use crate::format::{FormatId, WireFormat}; -use crate::util::sanitize_anthropic_tool_use_id; +use crate::util::{desanitize_anthropic_tool_use_id, sanitize_anthropic_tool_use_id}; /// Stream codec for Anthropic Messages events. pub struct AnthropicMessagesStreamCodec; @@ -336,7 +336,7 @@ fn decode_anthropic_content_block_start(object: &Map) -> Vec Some(json!({ - "id": desanitize_anthropic_tool_use_id(&call.id), + "id": call.id, "type": "function", "function": { "name": call.name, diff --git a/crates/switchyard-translation/src/codecs/responses/buffered.rs b/crates/switchyard-translation/src/codecs/responses/buffered.rs index a8cf107fb..db5ccab95 100644 --- a/crates/switchyard-translation/src/codecs/responses/buffered.rs +++ b/crates/switchyard-translation/src/codecs/responses/buffered.rs @@ -24,9 +24,9 @@ use crate::llm::{ }; use crate::policy::{DeterministicIdPolicy, TranslationPolicy}; use crate::util::{ - capture_request_preservation, capture_response_preservation, desanitize_anthropic_tool_use_id, - embed_preservation, exact_preserved_request, exact_preserved_response, json_string, push_lossy, - stable_id, string_value, validate_request_capabilities, + capture_request_preservation, capture_response_preservation, embed_preservation, + exact_preserved_request, exact_preserved_response, json_string, push_lossy, stable_id, + string_value, validate_request_capabilities, }; /// Format codec for OpenAI Responses payloads. @@ -997,13 +997,13 @@ fn encode_responses_special_input(block: &ContentBlock) -> Option { })), ContentBlock::ToolCall(call) => Some(json!({ "type": "function_call", - "call_id": desanitize_anthropic_tool_use_id(&call.id), + "call_id": call.id, "name": call.name, "arguments": json_string(&call.arguments), })), ContentBlock::ToolResult(result) => Some(json!({ "type": "function_call_output", - "call_id": desanitize_anthropic_tool_use_id(&result.tool_call_id), + "call_id": result.tool_call_id, "output": text_from_blocks(&result.content, " "), })), _ => None, diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index 3f9c7d085..2e1397897 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -286,12 +286,17 @@ fn anthropic_unknown_content_does_not_leak_into_responses_request_blocks() -> Te #[test] fn anthropic_tool_result_followup_text_splits_to_openai_messages() -> TestResult { let engine = TranslationEngine::default(); + let raw_id = "functions.list_skills:0"; let body = json!({ "model": "claude-sonnet-4-20250514", "messages": [{ "role": "user", "content": [ - {"type": "tool_result", "tool_use_id": "toolu_1", "content": "72F"}, + { + "type": "tool_result", + "tool_use_id": sanitize_anthropic_tool_use_id(raw_id), + "content": "72F" + }, {"type": "text", "text": "Now summarize it."} ] }], @@ -310,133 +315,13 @@ fn anthropic_tool_result_followup_text_splits_to_openai_messages() -> TestResult assert_eq!( output["messages"], json!([ - {"role": "tool", "tool_call_id": "toolu_1", "content": "72F"}, + {"role": "tool", "tool_call_id": raw_id, "content": "72F"}, {"role": "user", "content": "Now summarize it."} ]) ); Ok(()) } -// Restores IDs sanitized on the Anthropic response leg before calling OpenAI Chat upstreams. -#[test] -fn anthropic_tool_ids_are_restored_for_openai_chat() -> TestResult { - let engine = TranslationEngine::default(); - let raw_id = "functions.list_skills:0"; - let upstream_response = json!({ - "id": "chatcmpl-test", - "object": "chat.completion", - "created": 0, - "model": "kimi-k2", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": null, - "tool_calls": [{ - "id": raw_id, - "type": "function", - "function": {"name": "list_skills", "arguments": "{}"} - }] - }, - "finish_reason": "tool_calls" - }] - }); - let anthropic_response = engine - .translate_response( - WireFormat::OpenAiChat, - WireFormat::AnthropicMessages, - &upstream_response, - &TranslationPolicy::default(), - )? - .body; - let safe_id = anthropic_response["content"] - .as_array() - .and_then(|content| content.iter().find(|block| block["type"] == "tool_use")) - .and_then(|block| block["id"].as_str()) - .ok_or_else(|| format!("translated tool_use should have an ID: {anthropic_response}"))?; - assert_ne!(safe_id, raw_id); - let body = json!({ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "assistant", - "content": [{ - "type": "tool_use", - "id": safe_id, - "name": "list_skills", - "input": {} - }] - }, - { - "role": "user", - "content": [{ - "type": "tool_result", - "tool_use_id": safe_id, - "content": "done" - }] - } - ], - "max_tokens": 100 - }); - - let output = engine - .translate_request( - WireFormat::AnthropicMessages, - WireFormat::OpenAiChat, - &body, - &TranslationPolicy::default(), - )? - .body; - - assert_eq!(output["messages"][0]["tool_calls"][0]["id"], raw_id); - assert_eq!(output["messages"][1]["tool_call_id"], raw_id); - Ok(()) -} - -// Restores the same IDs for OpenAI Responses function calls and outputs. -#[test] -fn anthropic_tool_ids_are_restored_for_openai_responses() -> TestResult { - let engine = TranslationEngine::default(); - let raw_id = "functions.list_skills:0"; - let safe_id = sanitize_anthropic_tool_use_id(raw_id); - let body = json!({ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "assistant", - "content": [{ - "type": "tool_use", - "id": safe_id, - "name": "list_skills", - "input": {} - }] - }, - { - "role": "user", - "content": [{ - "type": "tool_result", - "tool_use_id": safe_id, - "content": "done" - }] - } - ], - "max_tokens": 100 - }); - - let output = engine - .translate_request( - WireFormat::AnthropicMessages, - WireFormat::OpenAiResponses, - &body, - &TranslationPolicy::default(), - )? - .body; - - assert_eq!(output["input"][0]["call_id"], raw_id); - assert_eq!(output["input"][1]["call_id"], raw_id); - Ok(()) -} - // Verifies Anthropic multimodal blocks retain provider fields inside tool results. #[test] fn anthropic_tool_result_multimodal_blocks_round_trip_complete() -> TestResult { @@ -2109,6 +1994,7 @@ fn responses_to_chat_preserves_tool_choice_when_tools_survive() -> TestResult { #[test] fn anthropic_tool_use_encodes_responses_arguments_as_json_string() -> TestResult { let engine = TranslationEngine::default(); + let raw_id = "functions.list_skills:0"; let body = json!({ "model": "claude-sonnet", "messages": [ @@ -2117,7 +2003,7 @@ fn anthropic_tool_use_encodes_responses_arguments_as_json_string() -> TestResult "role": "assistant", "content": [{ "type": "tool_use", - "id": "toolu_1", + "id": sanitize_anthropic_tool_use_id(raw_id), "name": "get_weather", "input": {"city": "SF"} }] @@ -2143,6 +2029,7 @@ fn anthropic_tool_use_encodes_responses_arguments_as_json_string() -> TestResult let arguments = call["arguments"] .as_str() .ok_or("function_call arguments must be a JSON string")?; + assert_eq!(call["call_id"], raw_id); assert_eq!( serde_json::from_str::(arguments)?, json!({"city": "SF"}) diff --git a/crates/switchyard-translation/tests/stream_translation.rs b/crates/switchyard-translation/tests/stream_translation.rs index 74d8cc32b..3c043d59a 100644 --- a/crates/switchyard-translation/tests/stream_translation.rs +++ b/crates/switchyard-translation/tests/stream_translation.rs @@ -12,7 +12,6 @@ use serde_json::{Value, json}; use switchyard_protocol::{LlmResponseStreamEvent, ResponseAccumulator, StopReason}; use switchyard_translation::{ LlmResponseChunk, StreamTranslationState, TranslationEngine, WireFormat, decode_stream_event, - sanitize_anthropic_tool_use_id, }; use common::{REASONING_MODEL, text_and_encrypted_reasoning_details}; @@ -401,46 +400,34 @@ fn openai_chat_stream_event_translates_to_anthropic_message_events() -> TestResu Ok(()) } -// Verifies unsafe streamed tool IDs use the same reversible Anthropic-safe encoding. +// Restores Anthropic-safe IDs before emitting OpenAI tool-call deltas. #[test] -fn openai_chat_stream_tool_id_is_anthropic_safe() -> TestResult { +fn anthropic_stream_tool_id_is_restored_for_openai_chat() -> TestResult { let engine = TranslationEngine::default(); let mut state = - StreamTranslationState::new(WireFormat::OpenAiChat, WireFormat::AnthropicMessages); + StreamTranslationState::new(WireFormat::AnthropicMessages, WireFormat::OpenAiChat); let raw_id = "functions.list_skills:0"; - let chunk = json!({ - "id": "chatcmpl-test", - "object": "chat.completion.chunk", - "model": "moonshotai/kimi-k2", - "choices": [{ - "index": 0, - "delta": { - "tool_calls": [{ - "index": 0, - "id": raw_id, - "type": "function", - "function": {"name": "list_skills", "arguments": "{}"} - }] - }, - "finish_reason": null - }] + let event = json!({ + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "tool_use", + "id": "sy64_ZnVuY3Rpb25zLmxpc3Rfc2tpbGxzOjA", + "name": "list_skills", + "input": {} + } }); - let events = engine.translate_event( + let chunks = engine.translate_event( &mut state, - WireFormat::OpenAiChat, WireFormat::AnthropicMessages, - &chunk, + WireFormat::OpenAiChat, + &event, )?; - let Some(tool_start) = events.iter().find(|event| { - event["type"] == "content_block_start" && event["content_block"]["type"] == "tool_use" - }) else { - return Err("expected an Anthropic tool_use content block".into()); - }; assert_eq!( - tool_start["content_block"]["id"], - sanitize_anthropic_tool_use_id(raw_id) + chunks[0]["choices"][0]["delta"]["tool_calls"][0]["id"], + raw_id ); Ok(()) }