From a4d290ddf121489113a84afac40b5b64668c8fd4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 5 May 2026 18:14:22 +0000 Subject: [PATCH] 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. --- litellm/llms/anthropic/chat/transformation.py | 14 ++++++++++++-- .../chat/test_anthropic_chat_transformation.py | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 77d5d7e1f0c..64d885767cf 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -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", diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py index 9ea715428d5..7054c515017 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -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-