diff --git a/Cargo.lock b/Cargo.lock index 405dda9da..67cd3091a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2393,6 +2393,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 98433d8c1..5fd58c945 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/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(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 e689e5b21..2e1397897 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -9,6 +9,7 @@ use pretty_assertions::assert_eq; use serde_json::{Value, json}; use switchyard_translation::{ LossyConversionPolicy, TranslationEngine, TranslationPolicy, WireFormat, + sanitize_anthropic_tool_use_id, }; use common::{REASONING_MODEL, normalized_policy, shell_tool_call}; @@ -285,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."} ] }], @@ -309,7 +315,7 @@ 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."} ]) ); @@ -1723,12 +1729,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"} ]) ); @@ -1984,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": [ @@ -1992,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"} }] @@ -2018,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 c726bee06..3c043d59a 100644 --- a/crates/switchyard-translation/tests/stream_translation.rs +++ b/crates/switchyard-translation/tests/stream_translation.rs @@ -400,6 +400,38 @@ fn openai_chat_stream_event_translates_to_anthropic_message_events() -> TestResu Ok(()) } +// Restores Anthropic-safe IDs before emitting OpenAI tool-call deltas. +#[test] +fn anthropic_stream_tool_id_is_restored_for_openai_chat() -> TestResult { + let engine = TranslationEngine::default(); + let mut state = + StreamTranslationState::new(WireFormat::AnthropicMessages, WireFormat::OpenAiChat); + let raw_id = "functions.list_skills:0"; + let event = json!({ + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "tool_use", + "id": "sy64_ZnVuY3Rpb25zLmxpc3Rfc2tpbGxzOjA", + "name": "list_skills", + "input": {} + } + }); + + let chunks = engine.translate_event( + &mut state, + WireFormat::AnthropicMessages, + WireFormat::OpenAiChat, + &event, + )?; + + assert_eq!( + chunks[0]["choices"][0]["delta"]["tool_calls"][0]["id"], + raw_id + ); + Ok(()) +} + // A mixed chunk must emit reasoning before text, matching the buffered decoder. #[test] fn openai_chat_mixed_reasoning_and_content_stream_in_reasoning_first_order() -> TestResult {