Skip to content

fix(translation): preserve tool IDs across Anthropic - #397

Open
ting-hong-shieh wants to merge 4 commits into
NVIDIA-NeMo:mainfrom
ting-hong-shieh:fix/reversible-tool-use-ids
Open

fix(translation): preserve tool IDs across Anthropic#397
ting-hong-shieh wants to merge 4 commits into
NVIDIA-NeMo:mainfrom
ting-hong-shieh:fix/reversible-tool-use-ids

Conversation

@ting-hong-shieh

@ting-hong-shieh ting-hong-shieh commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

What

  • Replace lossy Anthropic tool-use ID sanitization with sy64_-prefixed, unpadded Base64URL encoding.
  • Decode encoded tool_use.id and tool_result.tool_use_id values at Anthropic ingress, for both buffered and streaming paths.
  • Keep the provider-neutral IR and OpenAI Chat / Responses encoders free of Anthropic-specific escaping.
  • Keep ordinary [A-Za-z0-9_-] IDs unchanged, escape the reserved prefix, and pass malformed encoded values through unchanged.

Why

Some OpenAI-compatible models emit tool-call IDs containing characters that Anthropic does not accept. Kimi K2, for example, can emit IDs such as functions.list_skills:0 and expects the same ID on the next turn.

The previous sanitizer replaced unsupported characters with underscores. That mapping could not be reversed, so Switchyard replayed a different ID to the upstream model and could break multi-turn tool calling. The new encoding keeps Anthropic-facing IDs within the accepted character set while Anthropic decoders restore the original ID before it enters the neutral IR.

Closes #178

Before / after

Input upstream tool-call ID: functions.list_skills:0

Before:

{
  "anthropic_tool_use_id": "functions_list_skills_0",
  "replayed_openai_tool_call_id": "functions_list_skills_0",
  "replayed_openai_tool_result_id": "functions_list_skills_0"
}

After:

{
  "anthropic_tool_use_id": "sy64_ZnVuY3Rpb25zLmxpc3Rfc2tpbGxzOjA",
  "replayed_openai_tool_call_id": "functions.list_skills:0",
  "replayed_openai_tool_result_id": "functions.list_skills:0"
}

How tested

  • cargo fmt --all --check
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo test -p switchyard-translation
  • cargo test --workspace
  • uv run ruff check .
  • uv run mypy switchyard
  • uv run pytest tests/ -v -m "not integration" — 147 passed, 2 deselected
  • Anthropic → OpenAI Chat streaming regression restores the original ID

Checklist

  • No new classes or downstream public symbols.
  • Unit and integration tests cover the bug fix.
  • README and --help are unchanged because this does not add a configuration or command surface.
  • Commits signed off per the DCO.

Notes for reviewers

sy64_ is reserved for encoded IDs. An upstream ID that already starts with this prefix is encoded again before it reaches an Anthropic client, which keeps decoding unambiguous. Invalid prefixed input is left unchanged.

No live provider call was made.

Summary by CodeRabbit

  • Bug Fixes
    • Preserved tool-call and tool-result IDs accurately when translating between Anthropic, OpenAI Chat, and Responses formats.
    • Added reversible handling for IDs containing special characters, Unicode, empty values, or reserved prefixes.
    • Prevented malformed encoded IDs from being altered unexpectedly.
  • Tests
    • Added coverage for request and streaming translations, including regression cases for unsafe tool IDs.

Signed-off-by: Ting-Hong Shieh <32212900+ting-hong-shieh@users.noreply.github.com>
@ting-hong-shieh
ting-hong-shieh marked this pull request as ready for review August 13, 2026 14:45
@ting-hong-shieh
ting-hong-shieh requested a review from a team as a code owner August 13, 2026 14:45
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 8e49488f-35af-4a56-aa0a-d99295c3dade

📥 Commits

Reviewing files that changed from the base of the PR and between b256d93 and 18ef28b.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock, !Cargo.lock
📒 Files selected for processing (7)
  • Cargo.toml
  • crates/switchyard-translation/Cargo.toml
  • crates/switchyard-translation/src/codecs/openai_chat/buffered.rs
  • crates/switchyard-translation/src/codecs/responses/buffered.rs
  • crates/switchyard-translation/src/util.rs
  • crates/switchyard-translation/tests/request_translation.rs
  • crates/switchyard-translation/tests/stream_translation.rs

Walkthrough

The change replaces lossy tool ID sanitization with reversible URL-safe Base64 encoding. OpenAI Chat and Responses encoders restore original IDs before serialization. Request and streaming tests cover round trips, reserved prefixes, malformed values, Unicode, and empty IDs.

Changes

Tool ID round-trip

Layer / File(s) Summary
Reversible ID encoding
Cargo.toml, crates/switchyard-translation/Cargo.toml, crates/switchyard-translation/src/util.rs
The workspace adds base64. Tool IDs now use reversible URL-safe encoding with reserved-prefix handling. Utility tests cover safe, empty, Unicode, reserved-prefix, and malformed values.
Upstream ID restoration
crates/switchyard-translation/src/codecs/openai_chat/buffered.rs, crates/switchyard-translation/src/codecs/responses/buffered.rs
OpenAI Chat tool IDs and Responses call_id values are desanitized before serialization.
Translation regression coverage
crates/switchyard-translation/tests/request_translation.rs, crates/switchyard-translation/tests/stream_translation.rs
Request and streaming tests verify sanitized IDs are restored and unsafe IDs use the reversible encoding.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Mergeability Score: ⚪ Minimal · up to 18ef2

This change preserves tool-call IDs across translation boundaries and adds regression coverage; no actionable merge-blocking risk remains beyond normal checks and review.

Poem

I’m a rabbit with IDs in a neat little row,
Safe through the tunnel wherever they go.
Base64 wraps them, then sends them back true,
Chat and Responses know what to do.
Hop, hop—tool loops continue anew! 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes implement reversible tool ID encoding, upstream restoration for Chat and Responses, and buffered and streaming regression tests required by issue #178.
Out of Scope Changes check ✅ Passed All reported changes support reversible tool ID translation, dependency setup, or focused regression coverage for issue #178.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: preserving tool IDs across Anthropic translation.

Comment @coderabbitai help to get the list of available commands.

@ayushag-nv

Copy link
Copy Markdown
Contributor

@ting-hong-shieh Thanks for putting this up. Can you always do the before and after the change snapshot of the results. It helps me to do better and fast reviews.

@ting-hong-shieh

Copy link
Copy Markdown
Contributor Author

Thanks, @ayushag-nv. I ran the same in-process buffered translation at the PR base (b256d936) and current head (18ef28bd): OpenAI Chat response → Anthropic response → Anthropic replay → OpenAI Chat request. No provider call was involved.

Input upstream tool-call ID: functions.list_skills:0

Before (b256d936):

{
  "anthropic_tool_use_id": "functions_list_skills_0",
  "replayed_openai_tool_call_id": "functions_list_skills_0",
  "replayed_openai_tool_result_id": "functions_list_skills_0",
  "upstream_emitted_id": "functions.list_skills:0"
}

The unsupported characters are replaced with underscores, and both replayed OpenAI IDs differ from the ID emitted by the upstream model.

After (18ef28bd):

{
  "anthropic_tool_use_id": "sy64_ZnVuY3Rpb25zLmxpc3Rfc2tpbGxzOjA",
  "replayed_openai_tool_call_id": "functions.list_skills:0",
  "replayed_openai_tool_result_id": "functions.list_skills:0",
  "upstream_emitted_id": "functions.list_skills:0"
}

The Anthropic-facing ID stays within the accepted character set, while the OpenAI tool call and tool result both recover the exact upstream ID on replay.

Signed-off-by: Ting-Hong Shieh <32212900+ting-hong-shieh@users.noreply.github.com>
@ayushag-nv

Copy link
Copy Markdown
Contributor

@nachiketb-nvidia @elyasmnvidian to review

Signed-off-by: Elias Shieh <32212900+ting-hong-shieh@users.noreply.github.com>
@ting-hong-shieh

Copy link
Copy Markdown
Contributor Author

Resolved the merge conflict and updated the branch. Thanks!

@nachiketb-nvidia nachiketb-nvidia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes here: the buffered path restores reversible Anthropic-safe tool IDs, but the streaming path does not.

Concrete issue:

  • Anthropic stream tool_use.id = sy64_ZnVuY3Rpb25zLmxpc3Rfc2tpbGxzOjA
  • Expected OpenAI tool-call ID: functions.list_skills:0
  • Actual OpenAI stream output keeps the encoded sy64_... ID

Suggested fix:

  • Keep sanitize_anthropic_tool_use_id(...) when encoding to Anthropic.
  • Decode IDs when reading from Anthropic, not when writing to OpenAI/Responses.
  • Apply that in:
    • Anthropic buffered tool_use.id
    • Anthropic buffered tool_result.tool_use_id
    • Anthropic stream content_block_start tool IDs
  • Remove desanitize_anthropic_tool_use_id(...) calls/imports from OpenAI Chat / Responses buffered encoders.
  • After that cleanup, OpenAI Chat / Responses encoders should just emit the neutral IR IDs directly.
  • Add one streaming regression test for Anthropic -> OpenAI Chat or Responses. one test should be enough, remove the rest
    This keeps the Anthropic-specific escaping localized to the Anthropic codec and makes buffered + streaming behavior consistent.

Signed-off-by: Ting-Hong Shieh <32212900+ting-hong-shieh@users.noreply.github.com>
@ting-hong-shieh

Copy link
Copy Markdown
Contributor Author

Thanks, @nachiketb-nvidia. Addressed this in e534e88:

  • moved ID decoding to the Anthropic buffered decoder for tool_use.id and tool_result.tool_use_id
  • moved streaming ID decoding to Anthropic content_block_start
  • removed Anthropic-specific desanitization from OpenAI Chat and Responses encoders
  • replaced the forward streaming test with one Anthropic → OpenAI Chat regression and removed the redundant standalone request tests; existing buffered tests now cover tool calls and results

Validation:

  • cargo fmt --all --check
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo test -p switchyard-translation
  • cargo test --workspace
  • Ruff and mypy
  • Pytest: 147 passed, 2 deselected

No live provider call was made. Could you please take another look?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants