Sanitize tool-call IDs on the Anthropic Messages wire - #52
Conversation
The Anthropic Messages API validates tool_use.id against ^[a-zA-Z0-9_-]+$. Other wires accept arbitrary IDs (e.g. Kimi K3 emits <toolName>:<counter> like ls:0), so replaying a cross-provider history to an Anthropic-wire model — model switch mid-conversation, /compact summarizing with Claude — fails with a 400. Add sanitizeToolCallIdsForAnthropic: deterministic character replacement plus a message-index suffix for per-request uniqueness, with positional call/result pairing (tool-message results use j-1, provider-executed assistant-message results use their own index). Conforming, first-use IDs pass through untouched, so pure-Claude histories are a zero-copy no-op. Conforming duplicates are index-suffixed via first-occurrence tracking. Warns on non-conforming IDs, same-message base collisions, and orphan tool-results. Wire in via a shared streamTextAnthropicWire choke point used by AnthropicClient, PositAiClient (anthropic-messages branch), and SnowflakeClient (chatAnthropic); BedrockClient and GoogleVertexClient sanitize conditionally on their Anthropic routes. Fixes posit-dev/assistant#2016
|
Some automated feedback from
|
Problem
The Anthropic Messages API validates
tool_use.id/tool_result.tool_use_idagainst^[a-zA-Z0-9_-]+$. Other wires accept arbitrary IDs — e.g. Kimi K3 emits<toolName>:<counter>likels:0, with the counter resetting per turn so duplicates across history are the norm. When a conversation containing such IDs is replayed to an Anthropic-wire model (model switch mid-conversation,/compactsummarizing with Claude Haiku), the API rejects the request:Fixes posit-dev/assistant#2016.
Fix
The constraint is a property of the target wire, so Anthropic-wire clients now sanitize outbound IDs unconditionally via a new
sanitizeToolCallIdsForAnthropichelper (tool-call-ids.ts):[^a-zA-Z0-9_-]→-); empty IDs fall back tocall.<base>-<i>whereiis the message index in the outbound array. Pairing is positional: atool-resultin atoolmessage atjusesj-1; a provider-executed result in an assistant message uses its own index. Deterministic and prompt-cache-stable because history is append-only.tool-resultparts.Wiring goes through a shared
streamTextAnthropicWire()choke point so future Anthropic-wire clients get sanitization by default: AnthropicClient, PositAiClient (anthropic-messagesbranch), and SnowflakeClient (chatAnthropic) use the wrapper; BedrockClient and GoogleVertexClient sanitize conditionally on their Anthropic routes since thosestreamTextcall sites are shared across protocols.Tests
ls:0in two turns), same-turn parallel calls, conforming-duplicate suffixing, empty-ID fallback, no-op passthrough, collision/orphan warnings.PositAiClientwith the observed Kimils:0format repeated across two turns, asserting the serialized request body contains only pattern-conforming, unique, paired IDs. Verified to fail on the unfixed code.Full bridge suite (286 tests) and monorepo
check-typespass.