diff --git a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs index c6cdcfba5..d34c3b9f8 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs @@ -23,9 +23,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, - sanitize_anthropic_tool_use_id, + AnthropicToolIdRewriter, attach_seen_tool_ids, 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, reject_responses_builtin_tool_item, stable_id, string_value, @@ -178,6 +178,7 @@ impl FormatCodec for AnthropicMessagesCodec { .extensions .fields .insert(ANTHROPIC_REQUEST_KEY.to_string(), Value::Bool(true)); + attach_seen_tool_ids(&mut request.extensions.fields, &request.messages); Ok(DecodedRequest { request, @@ -311,7 +312,12 @@ impl FormatCodec for AnthropicMessagesCodec { output_config.insert("format".to_string(), format); } - let body = embed_preservation(Value::Object(body), &request.preservation, policy); + // Anthropic rejects repeated tool IDs, so a conversation that already + // carries a repeat (for example from a backend that reuses IDs) must + // reach the provider with one distinct ID per call and its result. + let mut body = Value::Object(body); + AnthropicToolIdRewriter::default().rewrite_body(&mut body); + let body = embed_preservation(body, &request.preservation, policy); Ok(EncodedRequest { body, diagnostics }) } diff --git a/crates/switchyard-translation/src/engine.rs b/crates/switchyard-translation/src/engine.rs index 9e739c503..aee169137 100644 --- a/crates/switchyard-translation/src/engine.rs +++ b/crates/switchyard-translation/src/engine.rs @@ -21,6 +21,7 @@ use crate::error::{Result, TranslationError}; use crate::format::FormatId; use crate::llm::{AggLlmResponse, LlmRequest, ProviderExtensions}; use crate::policy::TranslationPolicy; +use crate::util::{AnthropicToolIdRewriter, seen_tool_ids}; /// Encoded translation result with any diagnostics emitted along the way. #[derive(Debug)] @@ -221,6 +222,10 @@ impl TranslationEngine { &mut output.body, &crate::codex_custom_tools::custom_tool_names(request_extensions), ); + // A single response knows nothing about earlier turns, so the IDs the + // conversation already used seed the rewrite of this turn's calls. + let mut tool_ids = AnthropicToolIdRewriter::new(seen_tool_ids(request_extensions)); + tool_ids.rewrite_body(&mut output.body); Ok(output) } diff --git a/crates/switchyard-translation/src/helpers.rs b/crates/switchyard-translation/src/helpers.rs index 95ebb75e7..a28dce986 100644 --- a/crates/switchyard-translation/src/helpers.rs +++ b/crates/switchyard-translation/src/helpers.rs @@ -127,6 +127,10 @@ pub fn encode_stream_with_extensions( let origins = crate::codex_namespaces::qualified_tool_origins(request_extensions); let custom_tools = crate::codex_custom_tools::custom_tool_names(request_extensions); let mut custom_state = crate::codex_custom_tools::CustomToolCallStreamState::default(); + // One response knows nothing about earlier turns, so the IDs the conversation + // already used seed the rewrite of tool IDs across all of this stream's events. + let mut tool_ids = + crate::util::AnthropicToolIdRewriter::new(crate::util::seen_tool_ids(request_extensions)); let target_format: FormatId = target.into(); // The target is always a built-in wire format, so this lookup cannot fail; a // failure returns as an `Err` rather than a panic. @@ -153,6 +157,7 @@ pub fn encode_stream_with_extensions( for value in &mut encoded { stamp_streamed_response_model(value, target, served_model_for_events.as_deref()); crate::codex_namespaces::restore_qualified_tool_names(value, &origins); + tool_ids.rewrite_body(value); } // Argument deltas for a freeform tool cannot be expressed on the wire; the // rewritten completed item carries the input instead. @@ -187,6 +192,7 @@ pub fn encode_stream_with_extensions( served_model_for_events.as_deref(), ); crate::codex_namespaces::restore_qualified_tool_names(&mut value, &origins); + tool_ids.rewrite_body(&mut value); if crate::codex_custom_tools::restore_custom_tool_calls_in_event( &mut value, &custom_tools, diff --git a/crates/switchyard-translation/src/lib.rs b/crates/switchyard-translation/src/lib.rs index 4d0e81da6..d299623ca 100644 --- a/crates/switchyard-translation/src/lib.rs +++ b/crates/switchyard-translation/src/lib.rs @@ -34,6 +34,6 @@ pub use llm::*; pub use policy::*; pub use stream::*; pub use util::{ - PRESERVATION_METADATA_KEY, normalize_anthropic_tool_use_ids, prepare_request_for_target, - sanitize_anthropic_tool_use_id, + AnthropicToolIdRewriter, PRESERVATION_METADATA_KEY, SEEN_TOOL_IDS_KEY, + prepare_request_for_target, sanitize_anthropic_tool_use_id, }; diff --git a/crates/switchyard-translation/src/util.rs b/crates/switchyard-translation/src/util.rs index 5d3351c7c..0260813c3 100644 --- a/crates/switchyard-translation/src/util.rs +++ b/crates/switchyard-translation/src/util.rs @@ -3,7 +3,7 @@ //! Shared helpers for codec validation, diagnostics, and preservation metadata. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use serde_json::{Map, Value, json}; @@ -12,7 +12,10 @@ use switchyard_protocol::ModelId; use crate::diagnostic::TranslationDiagnostic; use crate::error::{Result, TranslationError}; use crate::format::{FormatId, WireFormat}; -use crate::llm::{ContentBlock, InstructionBlock, LlmRequest, Message, PreservationMetadata, Role}; +use crate::llm::{ + ContentBlock, InstructionBlock, LlmRequest, Message, PreservationMetadata, ProviderExtensions, + Role, +}; use crate::policy::{ LossyConversionPolicy, PreservationPolicy, TranslationPolicy, UnknownFieldPolicy, }; @@ -476,20 +479,156 @@ pub fn extract_preservation(body: &Value) -> PreservationMetadata { .unwrap_or_default() } -/// Normalizes Anthropic tool-use IDs while keeping tool_use/tool_result pairs aligned. -pub fn normalize_anthropic_tool_use_ids(value: Value) -> Value { - match value { - Value::Array(messages) => { - let mut id_map = BTreeMap::new(); - let mut used_ids = BTreeMap::new(); - Value::Array( - messages - .into_iter() - .map(|message| normalize_message_tool_ids(message, &mut id_map, &mut used_ids)) - .collect(), - ) +/// Metadata key holding the tool IDs an inbound conversation already uses. +pub const SEEN_TOOL_IDS_KEY: &str = "switchyard_seen_tool_ids"; + +/// Prefix for rewritten repeats of an already-used Anthropic tool ID. +const ANTHROPIC_TOOL_ID_COLLISION_PREFIX: &str = "sydup"; + +/// Rewrites tool IDs that repeat within one Anthropic-facing conversation. +/// +/// Anthropic rejects a conversation whose `tool_use` IDs are not unique, and +/// clients pair `tool_result` blocks to calls by ID alone, so a backend that +/// repeats an ID (`call_0`, `call_1`, `call_0`) would leave the conversation +/// unusable. An ID seen for the first time passes through unchanged; a repeat +/// mints `sydup{N}_{raw}` (N counts the occurrences, starting at 2), which +/// stays within Anthropic's allowed characters and keeps the original ID +/// readable after stripping `sydup{N}_`. The prefix is distinct from the +/// `sy64_` encoding scheme so the two never alias. +/// +/// The rewriter walks encoded Anthropic JSON, so the same pass serves request +/// bodies (`messages[].content[]`), response bodies (`content[]`), and stream +/// events (`content_block_start`). +#[derive(Default)] +pub struct AnthropicToolIdRewriter { + /// Maps a raw ID to the ID its most recent occurrence was given, so a + /// tool result resolves to the call it answers. + assigned: BTreeMap, + /// Every ID reserved so far: seeds, first occurrences, and minted IDs. + used: HashSet, +} + +impl AnthropicToolIdRewriter { + /// Creates a rewriter that treats `seen` as already-used conversation IDs. + pub fn new(seen: I) -> Self + where + I: IntoIterator, + I::Item: Into, + { + let mut rewriter = Self::default(); + for id in seen { + let id = id.into(); + rewriter.used.insert(id.clone()); + rewriter.assigned.insert(id.clone(), id); } - other => other, + rewriter + } + + /// Rewrites every `tool_use` and `tool_result` ID in `body` in place. + pub fn rewrite_body(&mut self, body: &mut Value) { + match body { + Value::Array(items) => { + for item in items { + self.rewrite_body(item); + } + } + Value::Object(object) => { + // Only a block's own ID field is rewritten; everything else is + // walked so nested blocks are still found. + let id_field = match object.get("type").and_then(Value::as_str) { + Some("tool_use") => Some("id"), + Some("tool_result") => Some("tool_use_id"), + _ => None, + }; + match id_field { + Some(field) => { + if let Some(raw) = object.get(field).and_then(Value::as_str) { + let raw = raw.to_owned(); + let rewritten = if field == "id" { + self.rewrite_call(&raw) + } else { + self.resolve_result(&raw) + }; + object.insert(field.to_string(), Value::String(rewritten)); + } + } + None => { + for value in object.values_mut() { + self.rewrite_body(value); + } + } + } + } + _ => {} + } + } + + // Gives the next occurrence of a call ID an unused Anthropic-facing ID. + fn rewrite_call(&mut self, raw: &str) -> String { + if self.used.insert(raw.to_string()) { + self.assigned.insert(raw.to_string(), raw.to_string()); + return raw.to_string(); + } + // Occurrence 2 and later: mint sydup{N}_{raw} until it is unused. A + // backend id that literally spells a minted candidate bumps N instead. + let mut occurrence = 2; + let mut candidate = self.mint(raw, occurrence); + while self.used.contains(&candidate) { + occurrence += 1; + candidate = self.mint(raw, occurrence); + } + self.used.insert(candidate.clone()); + self.assigned.insert(raw.to_string(), candidate.clone()); + candidate + } + + // Pairs a result with the ID its call was given; an unseen result ID is + // dangling history and passes through unchanged. + fn resolve_result(&mut self, raw: &str) -> String { + match self.assigned.get(raw) { + Some(assigned) => assigned.clone(), + None => self.rewrite_call(raw), + } + } + + fn mint(&self, raw: &str, occurrence: u32) -> String { + format!("{ANTHROPIC_TOOL_ID_COLLISION_PREFIX}{occurrence}_{raw}") + } +} + +/// Reads the tool IDs a request's extensions recorded for this conversation. +pub(crate) fn seen_tool_ids(extensions: &ProviderExtensions) -> Vec { + extensions + .fields + .get(SEEN_TOOL_IDS_KEY) + .and_then(Value::as_array) + .map(|ids| { + ids.iter() + .filter_map(Value::as_str) + .map(ToOwned::to_owned) + .collect() + }) + .unwrap_or_default() +} + +/// Records the tool IDs an inbound conversation already uses, so response +/// encoders can give backend repeats of those IDs distinct replacements. +pub(crate) fn attach_seen_tool_ids(fields: &mut Map, messages: &[Message]) { + let mut ids = Vec::new(); + for message in messages { + for block in &message.content { + match block { + ContentBlock::ToolCall(call) => ids.push(call.id.clone()), + ContentBlock::ToolResult(result) => ids.push(result.tool_call_id.clone()), + _ => {} + } + } + } + if !ids.is_empty() { + fields.insert( + SEEN_TOOL_IDS_KEY.to_string(), + Value::Array(ids.into_iter().map(Value::String).collect()), + ); } } @@ -522,105 +661,12 @@ pub(crate) fn desanitize_anthropic_tool_use_id(encoded: &str) -> String { .unwrap_or_else(|| encoded.to_string()) } -// Normalizes every content block in one Anthropic message. -fn normalize_message_tool_ids( - message: Value, - id_map: &mut BTreeMap, - used_ids: &mut BTreeMap, -) -> Value { - let Value::Object(mut message) = message else { - return message; - }; - let Some(content_value) = message.remove("content") else { - return Value::Object(message); - }; - let Value::Array(content) = content_value else { - message.insert("content".to_string(), content_value); - return Value::Object(message); - }; - let normalized = content - .into_iter() - .map(|block| normalize_tool_block(block, id_map, used_ids).unwrap_or_else(|block| block)) - .collect::>(); - message.insert("content".to_string(), Value::Array(normalized)); - Value::Object(message) -} - -// Rewrites tool_use/tool_result IDs and leaves unrelated blocks untouched. -fn normalize_tool_block( - block: Value, - id_map: &mut BTreeMap, - used_ids: &mut BTreeMap, -) -> std::result::Result { - let Value::Object(mut block_map) = block else { - return Err(block); - }; - match block_map.get("type").and_then(Value::as_str) { - Some("tool_use") => { - let raw = block_map - .get("id") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(); - let normalized = mapped_tool_id(&raw, id_map, used_ids); - if normalized != raw { - block_map.insert("id".to_string(), Value::String(normalized)); - Ok(Value::Object(block_map)) - } else { - Err(Value::Object(block_map)) - } - } - Some("tool_result") => { - let raw = block_map - .get("tool_use_id") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(); - let normalized = mapped_tool_id(&raw, id_map, used_ids); - if normalized != raw { - block_map.insert("tool_use_id".to_string(), Value::String(normalized)); - Ok(Value::Object(block_map)) - } else { - Err(Value::Object(block_map)) - } - } - _ => Err(Value::Object(block_map)), - } -} - -// Gives colliding raw IDs stable, deterministic suffixes. -fn mapped_tool_id( - raw: &str, - id_map: &mut BTreeMap, - used_ids: &mut BTreeMap, -) -> String { - if let Some(existing) = id_map.get(raw) { - return existing.clone(); - } - let mut candidate = sanitize_anthropic_tool_use_id(raw); - if let Some(owner) = used_ids.get(&candidate) - && owner != raw - { - candidate = format!("{}_{}", candidate, stable_suffix(raw)); - } - id_map.insert(raw.to_string(), candidate.clone()); - used_ids.insert(candidate.clone(), raw.to_string()); - candidate -} - -// Stable FNV-1a suffix for collision disambiguation. -fn stable_suffix(raw: &str) -> String { - let mut hash: u64 = 1469598103934665603; - for byte in raw.as_bytes() { - hash ^= u64::from(*byte); - hash = hash.wrapping_mul(1099511628211); - } - format!("{hash:08x}") -} - #[cfg(test)] mod tests { - use super::{desanitize_anthropic_tool_use_id, sanitize_anthropic_tool_use_id}; + use super::{ + AnthropicToolIdRewriter, desanitize_anthropic_tool_use_id, sanitize_anthropic_tool_use_id, + }; + use serde_json::json; // Keeps ordinary provider IDs unchanged while making unsafe IDs reversible. #[test] @@ -650,4 +696,46 @@ mod tests { assert_eq!(desanitize_anthropic_tool_use_id(&encoded), raw); assert_eq!(desanitize_anthropic_tool_use_id("sy64_%%%"), "sy64_%%%"); } + + // First occurrences and their results pass through; repeats get occurrence + // numbered IDs and each result still pairs with its own call. + #[test] + fn tool_id_rewriter_passes_unique_ids_and_mints_repeats() { + let mut body = json!([ + {"type": "tool_use", "id": "call_0"}, + {"type": "tool_result", "tool_use_id": "call_0"}, + {"type": "tool_use", "id": "call_9"}, + {"type": "tool_use", "id": "call_0"}, + {"type": "tool_result", "tool_use_id": "call_0"}, + {"type": "text", "text": "untouched"} + ]); + AnthropicToolIdRewriter::default().rewrite_body(&mut body); + + let string_field = |index: usize, field: &str| body[index][field].as_str().unwrap(); + assert_eq!(string_field(0, "id"), "call_0"); + assert_eq!(string_field(1, "tool_use_id"), "call_0"); + assert_eq!(string_field(2, "id"), "call_9"); + assert_eq!(string_field(3, "id"), "sydup2_call_0"); + assert_eq!(string_field(4, "tool_use_id"), "sydup2_call_0"); + assert_eq!(body[5]["text"], "untouched"); + } + + // IDs recorded from earlier turns collide on the first occurrence here, and + // a minted ID never aliases an ID the backend spelled literally. + #[test] + fn tool_id_rewriter_treats_seen_ids_as_used() { + let mut body = json!([ + {"type": "tool_use", "id": "call_0"}, + {"type": "tool_use", "id": "call_0"} + ]); + AnthropicToolIdRewriter::new(["call_0", "sydup2_call_0"]).rewrite_body(&mut body); + + assert_eq!(body[0]["id"], "sydup3_call_0"); + assert_eq!(body[1]["id"], "sydup4_call_0"); + // The original ID stays readable in the minted one. + assert_eq!( + body[1]["id"].as_str().unwrap().strip_prefix("sydup4_"), + Some("call_0") + ); + } } diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index 2af80f9d3..0ac9ce99c 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -4301,3 +4301,68 @@ fn responses_stored_tool_outputs_stay_tool_results() -> TestResult { assert_eq!(output["input"], outputs); Ok(()) } + +// Anthropic rejects a conversation whose tool_use IDs are not unique, and clients +// pair results to calls by ID alone. When a backend repeats a tool-call ID across +// turns (call_0, call_1, call_0), encoding the conversation to Anthropic must mint +// a distinct ID for the repeat while keeping each result paired with its own call. +#[test] +fn anthropic_request_rewrites_repeated_tool_call_ids() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "model": "route", + "messages": [ + {"role": "user", "content": "Read src/lib.rs"}, + {"role": "assistant", "content": null, "tool_calls": [ + {"id": "call_0", "type": "function", + "function": {"name": "read_file", "arguments": "{\"path\":\"src/lib.rs\"}"}} + ]}, + {"role": "tool", "tool_call_id": "call_0", "content": "fn main() {}"}, + {"role": "assistant", "content": null, "tool_calls": [ + {"id": "call_1", "type": "function", + "function": {"name": "read_file", "arguments": "{\"path\":\"src/main.rs\"}"}} + ]}, + {"role": "tool", "tool_call_id": "call_1", "content": "fn main() {}"}, + {"role": "assistant", "content": null, "tool_calls": [ + {"id": "call_0", "type": "function", + "function": {"name": "read_file", "arguments": "{\"path\":\"src/lib.rs\"}"}} + ]}, + {"role": "tool", "tool_call_id": "call_0", "content": "fn main() {}"} + ] + }); + + let request = engine + .decode_request( + WireFormat::OpenAiChat, + &body, + &switchyard_translation::TranslationPolicy::default(), + )? + .request; + let output = engine + .encode_request( + WireFormat::AnthropicMessages, + &request, + &switchyard_translation::TranslationPolicy::default(), + )? + .body; + + let mut call_ids = Vec::new(); + let mut result_ids = Vec::new(); + for message in output["messages"].as_array().ok_or("messages")? { + let Some(blocks) = message["content"].as_array() else { + continue; + }; + for block in blocks { + match block["type"].as_str() { + Some("tool_use") => call_ids.push(block["id"].as_str().ok_or("id")?), + Some("tool_result") => { + result_ids.push(block["tool_use_id"].as_str().ok_or("tool_use_id")?) + } + _ => {} + } + } + } + assert_eq!(call_ids, ["call_0", "call_1", "sydup2_call_0"]); + assert_eq!(result_ids, ["call_0", "call_1", "sydup2_call_0"]); + Ok(()) +} diff --git a/crates/switchyard-translation/tests/response_translation.rs b/crates/switchyard-translation/tests/response_translation.rs index 4280fa3f1..df3b2dd4f 100644 --- a/crates/switchyard-translation/tests/response_translation.rs +++ b/crates/switchyard-translation/tests/response_translation.rs @@ -1261,3 +1261,84 @@ fn responses_custom_tool_call_output_round_trips_with_request_extensions() -> Te assert_eq!(call["function"]["arguments"], "{\"input\":\"ls -la\"}"); Ok(()) } + +// Anthropic rejects tool_use IDs that repeat across a conversation. When the +// request history already used call_0 and the backend emits call_0 again, the +// encoded tool_use blocks must get fresh IDs; IDs the conversation has never +// seen pass through unchanged. +#[test] +fn anthropic_response_rewrites_tool_ids_reused_from_request_history() -> TestResult { + let engine = TranslationEngine::default(); + let policy = TranslationPolicy { + preservation: PreservationPolicy::Disabled, + ..TranslationPolicy::default() + }; + let request = json!({ + "model": "route", + "max_tokens": 64, + "messages": [ + {"role": "user", "content": "Read src/lib.rs"}, + {"role": "assistant", "content": [ + {"type": "tool_use", "id": "call_0", "name": "read_file", "input": {}} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "call_0", "content": "fn main() {}"} + ]} + ] + }); + let response = json!({ + "id": "chatcmpl-reuse", + "model": "grok", + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": null, "tool_calls": [ + {"id": "call_0", "type": "function", + "function": {"name": "read_file", "arguments": "{}"}}, + {"id": "call_9", "type": "function", + "function": {"name": "read_file", "arguments": "{}"}}, + {"id": "call_0", "type": "function", + "function": {"name": "read_file", "arguments": "{}"}} + ]}, + "finish_reason": "tool_calls" + }], + "usage": {"prompt_tokens": 4, "completion_tokens": 3, "total_tokens": 7} + }); + + let extensions = engine + .decode_request(WireFormat::AnthropicMessages, &request, &policy)? + .request + .extensions; + let agg = engine + .decode_response(WireFormat::OpenAiChat, &response, &policy)? + .response; + + let output = engine + .encode_response_with_extensions(WireFormat::AnthropicMessages, &agg, &extensions, &policy)? + .body; + let call_ids = tool_use_ids(&output); + assert_eq!(call_ids, ["sydup2_call_0", "call_9", "sydup3_call_0"]); + + // An empty history leaves the first occurrence of every ID untouched; only + // the repeat inside the same response is rewritten. + let plain = engine + .encode_response_with_extensions( + WireFormat::AnthropicMessages, + &agg, + &switchyard_translation::ProviderExtensions::default(), + &policy, + )? + .body; + assert_eq!(tool_use_ids(&plain), ["call_0", "call_9", "sydup2_call_0"]); + Ok(()) +} + +// Collects the tool_use IDs of an encoded Anthropic message body in order. +fn tool_use_ids(body: &serde_json::Value) -> Vec<&str> { + body["content"] + .as_array() + .expect("anthropic content array") + .iter() + .filter(|block| block["type"] == "tool_use") + .map(|block| block["id"].as_str().expect("tool_use id")) + .collect() +} diff --git a/crates/switchyard-translation/tests/stream_translation.rs b/crates/switchyard-translation/tests/stream_translation.rs index 01e9cc0da..06009b83e 100644 --- a/crates/switchyard-translation/tests/stream_translation.rs +++ b/crates/switchyard-translation/tests/stream_translation.rs @@ -7,11 +7,14 @@ pub mod common; use std::collections::HashMap; +use futures::{StreamExt, executor::block_on, stream}; + use pretty_assertions::assert_eq; use serde_json::{Value, json}; use switchyard_protocol::{LlmResponseStreamEvent, ResponseAccumulator, StopReason}; use switchyard_translation::{ - LlmResponseChunk, StreamTranslationState, TranslationEngine, WireFormat, decode_stream_event, + LlmResponseChunk, StreamTranslationState, TranslationEngine, TranslationPolicy, WireFormat, + decode_stream_event, }; use common::{REASONING_MODEL, text_and_encrypted_reasoning_details}; @@ -3202,3 +3205,106 @@ fn responses_terminal_snapshots_recover_missing_output_once() -> TestResult { } Ok(()) } + +// A single response can already repeat a tool-call ID (parallel calls from a +// careless backend). The Anthropic stream encoder must give each emitted +// tool_use block a distinct ID while leaving first-seen IDs untouched. +#[test] +fn anthropic_stream_rewrites_repeated_tool_call_ids() -> TestResult { + let call = |index: usize, id: &str| { + LlmResponseChunk::ToolCallDelta { + index, + id: Some(id.to_string()), + name: Some("read_file".to_string()), + arguments_delta: Some("{}".to_string()), + } + .into() + }; + let chunks: switchyard_translation::LlmResponseStream = stream::iter(vec![ + Ok(call(0, "call_0")), + Ok(call(1, "call_1")), + Ok(call(2, "call_0")), + Ok(LlmResponseChunk::MessageStop { + reason: Some("tool_calls".to_string()), + } + .into()), + ]) + .boxed(); + + let events = block_on( + switchyard_translation::encode_stream(chunks, WireFormat::AnthropicMessages, None)? + .collect::>(), + ) + .into_iter() + .collect::, switchyard_translation::LlmStreamError>>()?; + + assert_eq!( + streamed_tool_use_ids(&events), + ["call_0", "call_1", "sydup2_call_0"] + ); + Ok(()) +} + +// When the conversation history already used call_0, the request's seen IDs seed +// the stream encoder so a backend repeating that ID gets a fresh one. +#[test] +fn anthropic_stream_rewrites_tool_ids_reused_from_request_history() -> TestResult { + let engine = TranslationEngine::default(); + let request = json!({ + "model": "route", + "max_tokens": 64, + "messages": [ + {"role": "user", "content": "Read src/lib.rs"}, + {"role": "assistant", "content": [ + {"type": "tool_use", "id": "call_0", "name": "read_file", "input": {}} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "call_0", "content": "fn main() {}"} + ]} + ] + }); + let extensions = engine + .decode_request( + WireFormat::AnthropicMessages, + &request, + &TranslationPolicy::default(), + )? + .request + .extensions; + + let chunks: switchyard_translation::LlmResponseStream = + stream::iter(vec![Ok(LlmResponseChunk::ToolCallDelta { + index: 0, + id: Some("call_0".to_string()), + name: Some("read_file".to_string()), + arguments_delta: Some("{}".to_string()), + } + .into())]) + .boxed(); + + let events = block_on( + switchyard_translation::encode_stream_with_extensions( + chunks, + WireFormat::AnthropicMessages, + None, + &extensions, + )? + .collect::>(), + ) + .into_iter() + .collect::, switchyard_translation::LlmStreamError>>()?; + + assert_eq!(streamed_tool_use_ids(&events), ["sydup2_call_0"]); + Ok(()) +} + +// Collects the tool_use IDs from content_block_start events in order. +fn streamed_tool_use_ids(events: &[Value]) -> Vec<&str> { + events + .iter() + .filter(|event| { + event["type"] == "content_block_start" && event["content_block"]["type"] == "tool_use" + }) + .map(|event| event["content_block"]["id"].as_str().expect("tool_use id")) + .collect() +}