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 {