Summary
sanitize_anthropic_tool_use_id (crates/switchyard-translation/src/util.rs) rewrites upstream tool-call ids that contain characters outside [A-Za-z0-9_-] by replacing each offending char with _. The mapping is lossy (non-injective) and there is no inverse anywhere in the codebase: on the next turn, the client replays the sanitized id and Switchyard forwards it to the upstream verbatim (codecs/openai_chat/buffered.rs — tool_call id and tool message tool_call_id; same on the Responses encoder's call_id).
For models whose native tool-call ids use such characters, the upstream model receives a corrupted version of its own id format on every continuation turn. Concretely: Kimi K2 served by vLLM (kimi_k2 tool-call parser) emits ids shaped functions.<name>:<index>, e.g. functions.list_skills:0. Switchyard turns this into functions_list_skills_0, and replaying that id makes Kimi K2 dead-stop instead of continuing the tool loop: it returns an empty response (buffered: finish_reason:"stop", explicit tool_calls:[], empty content; streaming: finish_reason:"tool_calls" with zero tool-call deltas). The Anthropic-side client then sees an assistant turn with no tool_use where one was expected, which breaks every multi-turn tool loop (agent frameworks surface this as a tool-call parse failure).
Reproduction / evidence
Anthropic /v1/messages client → Switchyard (format: openai chat) → vLLM serving a Kimi K2 model. Turn 1 (tool available) works: Kimi emits a valid tool_calls entry, Switchyard returns a clean tool_use (id sanitized). Turn 2 (replay assistant tool_use + tool_result) fails: Switchyard forwards the sanitized id upstream and Kimi degenerates.
Byte-matched A/B directly against the vLLM endpoint (temperature 0, identical request bytes, only the replayed assistant tool_calls[0].id + tool message tool_call_id varied):
| replayed id |
tool call emitted on continuation |
functions_list_skills_0 (sanitized) |
1/16 (~94% failure — empty response) |
functions.list_skills:0 (native) |
16/16 |
call_abc123 / sw_00000001 (foreign) |
16/16 |
toolu_01AbCdEfGhJkMnPqRs (Anthropic-style) |
16/16 |
Notable: completely foreign ids are fine — the failure is specific to the sanitized mutation of the model's own id template (./: → _), which apparently corrupts the model's tool-call bookkeeping for the next generation. Verified identical behavior at an older pin and current main (the sanitizer is unchanged since the initial commit).
Streaming flavor of the degenerate mode (raw upstream, 5/5): final chunk {"delta":{},"finish_reason":"tool_calls"} with zero tool-call deltas — Switchyard faithfully translates that to stop_reason:"tool_use" with 0 tool_use blocks, so the drop is upstream-model behavior triggered by the corrupted id, not a Switchyard streaming bug.
Why sanitization can't simply be removed
api.anthropic.com enforces ^[a-zA-Z0-9_-]+$ on replayed tool_use.id server-side (undocumented — the docs type id as plain string; only tool names have a documented pattern), so unsanitized ids like functions.list_skills:0 would 400 against genuine Anthropic backends and strict Anthropic-schema clients. Sanitization is load-bearing; it just needs to be reversible.
Proposed fix: reversible charset-safe encoding + restore on the upstream-bound direction
- Encode (in
sanitize_anthropic_tool_use_id): if the raw id is non-empty, all chars in [A-Za-z0-9_-], and doesn't start with the escape prefix → return unchanged (zero behavior change for toolu_* / call_* traffic). Otherwise return e.g. sy64_ + base64url-nopad(raw) — base64url's alphabet is exactly the Anthropic-safe charset, and the single change covers all response-side emit sites.
- Decode: a
desanitize_anthropic_tool_use_id (strip sy64_, base64url-decode, pass through unchanged on any failure) applied at the upstream-bound emit points: openai_chat tool_call id + tool message tool_call_id, and the Responses encoder's two call_id sites.
Properties: injective (no collisions; double-encode disambiguated by the prefix guard), stateless (the original bytes travel inside the id itself — no server-side map needed), and the upstream model always gets back exactly the id it emitted. The "regenerate + id map" alternative doesn't work in a stateless server (the original→generated mapping is destroyed at response time and can't be rebuilt from the replayed conversation).
Rough size: ~50–70 lines across util.rs + the four upstream-bound call sites, plus tests (a regression test replaying a functions.<name>:<index>-style id through /v1/messages, buffered + streaming).
Related
Happy to submit a PR implementing the above if the approach sounds right.
Summary
sanitize_anthropic_tool_use_id(crates/switchyard-translation/src/util.rs) rewrites upstream tool-call ids that contain characters outside[A-Za-z0-9_-]by replacing each offending char with_. The mapping is lossy (non-injective) and there is no inverse anywhere in the codebase: on the next turn, the client replays the sanitized id and Switchyard forwards it to the upstream verbatim (codecs/openai_chat/buffered.rs— tool_callidand tool messagetool_call_id; same on the Responses encoder'scall_id).For models whose native tool-call ids use such characters, the upstream model receives a corrupted version of its own id format on every continuation turn. Concretely: Kimi K2 served by vLLM (kimi_k2 tool-call parser) emits ids shaped
functions.<name>:<index>, e.g.functions.list_skills:0. Switchyard turns this intofunctions_list_skills_0, and replaying that id makes Kimi K2 dead-stop instead of continuing the tool loop: it returns an empty response (buffered:finish_reason:"stop", explicittool_calls:[], empty content; streaming:finish_reason:"tool_calls"with zero tool-call deltas). The Anthropic-side client then sees an assistant turn with notool_usewhere one was expected, which breaks every multi-turn tool loop (agent frameworks surface this as a tool-call parse failure).Reproduction / evidence
Anthropic
/v1/messagesclient → Switchyard (format: openai chat) → vLLM serving a Kimi K2 model. Turn 1 (tool available) works: Kimi emits a validtool_callsentry, Switchyard returns a cleantool_use(id sanitized). Turn 2 (replay assistanttool_use+tool_result) fails: Switchyard forwards the sanitized id upstream and Kimi degenerates.Byte-matched A/B directly against the vLLM endpoint (temperature 0, identical request bytes, only the replayed assistant
tool_calls[0].id+ tool messagetool_call_idvaried):functions_list_skills_0(sanitized)functions.list_skills:0(native)call_abc123/sw_00000001(foreign)toolu_01AbCdEfGhJkMnPqRs(Anthropic-style)Notable: completely foreign ids are fine — the failure is specific to the sanitized mutation of the model's own id template (
./:→_), which apparently corrupts the model's tool-call bookkeeping for the next generation. Verified identical behavior at an older pin and current main (the sanitizer is unchanged since the initial commit).Streaming flavor of the degenerate mode (raw upstream, 5/5): final chunk
{"delta":{},"finish_reason":"tool_calls"}with zero tool-call deltas — Switchyard faithfully translates that tostop_reason:"tool_use"with 0tool_useblocks, so the drop is upstream-model behavior triggered by the corrupted id, not a Switchyard streaming bug.Why sanitization can't simply be removed
api.anthropic.com enforces
^[a-zA-Z0-9_-]+$on replayedtool_use.idserver-side (undocumented — the docs typeidas plain string; only tool names have a documented pattern), so unsanitized ids likefunctions.list_skills:0would 400 against genuine Anthropic backends and strict Anthropic-schema clients. Sanitization is load-bearing; it just needs to be reversible.Proposed fix: reversible charset-safe encoding + restore on the upstream-bound direction
sanitize_anthropic_tool_use_id): if the raw id is non-empty, all chars in[A-Za-z0-9_-], and doesn't start with the escape prefix → return unchanged (zero behavior change fortoolu_*/call_*traffic). Otherwise return e.g.sy64_+ base64url-nopad(raw) — base64url's alphabet is exactly the Anthropic-safe charset, and the single change covers all response-side emit sites.desanitize_anthropic_tool_use_id(stripsy64_, base64url-decode, pass through unchanged on any failure) applied at the upstream-bound emit points: openai_chat tool_callid+ tool messagetool_call_id, and the Responses encoder's twocall_idsites.Properties: injective (no collisions; double-encode disambiguated by the prefix guard), stateless (the original bytes travel inside the id itself — no server-side map needed), and the upstream model always gets back exactly the id it emitted. The "regenerate + id map" alternative doesn't work in a stateless server (the original→generated mapping is destroyed at response time and can't be rebuilt from the replayed conversation).
Rough size: ~50–70 lines across
util.rs+ the four upstream-bound call sites, plus tests (a regression test replaying afunctions.<name>:<index>-style id through/v1/messages, buffered + streaming).Related
Happy to submit a PR implementing the above if the approach sounds right.