Fix order-dependent collision in _build_anthropic_tool_name_maps

Use a two-pass approach: first pre-register all already-valid tool names
in the 'used' set, then sanitize/disambiguate names that need rewriting.
This ensures valid names always have priority regardless of input order,
preventing duplicate tool names on the wire when e.g. 'foo/bar' appears
before 'foo_bar' in the tool list.

Add regression test for the reversed ordering case.
This commit is contained in:
Cursor Agent 2026-05-05 18:14:22 +00:00
parent 767cadd007
commit a4d290ddf1
No known key found for this signature in database
2 changed files with 29 additions and 2 deletions

View file

@ -175,14 +175,22 @@ def _build_anthropic_tool_name_maps(
"""
forward: Dict[str, str] = {}
used: set = set()
# First pass: reserve slots for names that are already valid so they
# always have priority regardless of input order.
for original in original_names:
if not isinstance(original, str) or not original:
continue
candidate = _basic_sanitize_anthropic_tool_name(original)
if candidate == original:
# Already valid. Reserve the slot but don't put it in `forward`
# -- we want untouched names to skip translation entirely.
used.add(candidate)
# Second pass: sanitize/disambiguate names that need rewriting.
for original in original_names:
if not isinstance(original, str) or not original:
continue
candidate = _basic_sanitize_anthropic_tool_name(original)
if candidate == original:
continue
# Disambiguate against names already chosen this request.
unique = candidate
@ -197,6 +205,8 @@ def _build_anthropic_tool_name_maps(
used.add(unique)
reverse = {v: k for k, v in forward.items()}
return forward, reverse
REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT: Dict[str, str] = {
"low": "low",
"minimal": "low",

View file

@ -4026,6 +4026,23 @@ def test_build_anthropic_tool_name_maps_three_way_collision():
assert reverse == {"foo_bar_2": "foo/bar", "foo_bar_3": "foo.bar"}
def test_build_anthropic_tool_name_maps_reverse_order_collision():
"""REGRESSION: when the invalid name appears *before* the valid name that
its sanitized form collides with, both must still end up with distinct
names on the wire."""
from litellm.llms.anthropic.chat.transformation import (
_build_anthropic_tool_name_maps,
)
forward, reverse = _build_anthropic_tool_name_maps(["foo/bar", "foo_bar"])
# The valid name keeps its slot untouched.
assert "foo_bar" not in forward
# The rewritten one gets a disambiguating suffix.
assert forward["foo/bar"] == "foo_bar_2"
assert reverse == {"foo_bar_2": "foo/bar"}
assert "foo_bar" not in reverse
def test_map_openai_params_does_not_pollute_optional_params_with_internal_keys():
"""REGRESSION: ``optional_params`` is what becomes the JSON body sent to
Anthropic (``data = {**optional_params}``). It MUST NOT carry LiteLLM-