fix(anthropic): drop dead tool-name forward-map params, fix mypy + caller-mutation
Some checks failed
Unit Tests: Proxy DB Operations / proxy-db (auth-checks, tests/proxy_unit_tests/test_auth_checks.py tests/proxy_unit_tests/test_user_api_key_auth.py, 20, 8) (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-db (key-generation, tests/proxy_unit_tests/test_key_generate_prisma.py, 30, 0) (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-db (proxy-utils, tests/proxy_unit_tests/test_proxy_utils.py, 20, 8) (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-db (remaining, tests/proxy_unit_tests --ignore=tests/proxy_unit_tests/test_key_generate_prisma.py --ignore=tests/proxy_unit_tests/test_auth_checks.py --ignore=tests/proxy_unit_tests/test_user_api_key_auth.py --ignore=tests/proxy_unit_tests/test_p… (push) Has been cancelled
Unit Tests: Security / security (push) Has been cancelled

- remove unused `name_forward_map` param from `_map_tool_choice`,
  `_map_tool_helper`, `_map_tools` and the `_apply_anthropic_tool_name_forward`
  helper. Production sanitization runs in `_sanitize_tool_names_in_request`
  at `transform_request`; these params were never threaded through.
- handler.py: use `ANTHROPIC_TOOL_NAME_REVERSE_MAP_KEY` constant instead of
  the hardcoded `"_anthropic_tool_name_map"` string.
- fix mypy `"object" has no attribute "__iter__"` in
  `_rewrite_tool_names_in_messages` by guarding `tool_calls` with
  `isinstance(..., list)`.
- `_sanitize_tool_names_in_request`: build a new tools list with copy-on-
  change entries (and copy `tool_choice` on rewrite) so a caller reusing
  the same tool list/dicts across requests doesn't see its inputs
  permanently rewritten.
- doc-comment `_build_request_tool_name_maps` clarifying it operates on
  OpenAI-format tools (vs `_sanitize_tool_names_in_request` which runs
  on Anthropic-format tools post-`_map_tools`).
- tests: drop 3 tests pinning the now-removed param paths; add coverage
  for tool_calls + None function_call rewrite and caller-dict immutability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Krrish Dholakia 2026-05-01 13:25:03 -07:00
parent 18e5aa418e
commit 9571149fe2
3 changed files with 92 additions and 104 deletions

View file

@ -65,7 +65,7 @@ from litellm.types.utils import (
from ...base import BaseLLM
from ..common_utils import AnthropicError, process_anthropic_headers
from .transformation import AnthropicConfig
from .transformation import ANTHROPIC_TOOL_NAME_REVERSE_MAP_KEY, AnthropicConfig
if TYPE_CHECKING:
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
@ -242,7 +242,7 @@ class AnthropicChatCompletion(BaseLLM):
json_mode=json_mode,
speed=optional_params.get("speed") if optional_params else None,
tool_name_reverse_map=(
litellm_params.get("_anthropic_tool_name_map")
litellm_params.get(ANTHROPIC_TOOL_NAME_REVERSE_MAP_KEY)
if isinstance(litellm_params, dict)
else None
),
@ -472,7 +472,7 @@ class AnthropicChatCompletion(BaseLLM):
json_mode=json_mode,
speed=optional_params.get("speed") if optional_params else None,
tool_name_reverse_map=(
litellm_params.get("_anthropic_tool_name_map")
litellm_params.get(ANTHROPIC_TOOL_NAME_REVERSE_MAP_KEY)
if isinstance(litellm_params, dict)
else None
),

View file

@ -186,17 +186,6 @@ def _build_anthropic_tool_name_maps(
return forward, reverse
def _apply_anthropic_tool_name_forward(
name: str, forward: Optional[Dict[str, str]]
) -> str:
"""Look up `name` in the forward map; return as-is if absent."""
if not isinstance(name, str) or not name:
return name
if forward and name in forward:
return forward[name]
return name
class AnthropicConfig(AnthropicModelInfo, BaseConfig):
"""
Reference: https://docs.anthropic.com/claude/reference/messages_post
@ -486,7 +475,6 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
self,
tool_choice: Optional[str],
parallel_tool_use: Optional[bool],
name_forward_map: Optional[Dict[str, str]] = None,
) -> Optional[AnthropicMessagesToolChoice]:
_tool_choice: Optional[AnthropicMessagesToolChoice] = None
if tool_choice == "auto":
@ -510,12 +498,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
_tool_name = tool_choice.get("function", {}).get("name")
if _tool_name is not None:
_tool_choice = AnthropicMessagesToolChoice(type="tool")
# Apply the per-request forward map. If the original
# name was already valid (and thus not in the map),
# this is a no-op pass-through.
_tool_choice["name"] = _apply_anthropic_tool_name_forward(
_tool_name, name_forward_map
)
_tool_choice["name"] = _tool_name
if parallel_tool_use is not None:
# Anthropic uses 'disable_parallel_tool_use' flag to determine if parallel tool use is allowed
@ -534,7 +517,6 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
def _map_tool_helper( # noqa: PLR0915
self,
tool: ChatCompletionToolParam,
name_forward_map: Optional[Dict[str, str]] = None,
) -> Tuple[Optional[AllAnthropicToolsValues], Optional[AnthropicMcpServerTool]]:
returned_tool: Optional[AllAnthropicToolsValues] = None
mcp_server: Optional[AnthropicMcpServerTool] = None
@ -572,9 +554,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
)
_tool = AnthropicMessagesTool(
name=_apply_anthropic_tool_name_forward(
tool["function"]["name"], name_forward_map
),
name=tool["function"]["name"],
input_schema=input_anthropic_schema,
type="custom",
)
@ -794,7 +774,6 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
def _map_tools(
self,
tools: List,
name_forward_map: Optional[Dict[str, str]] = None,
) -> Tuple[List[AllAnthropicToolsValues], List[AnthropicMcpServerTool]]:
anthropic_tools = []
mcp_servers = []
@ -802,9 +781,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
if "input_schema" in tool: # assume in anthropic format
anthropic_tools.append(tool)
else: # assume openai tool call
new_tool, mcp_server_tool = self._map_tool_helper(
tool, name_forward_map=name_forward_map
)
new_tool, mcp_server_tool = self._map_tool_helper(tool)
if new_tool is not None:
anthropic_tools.append(new_tool)
@ -838,7 +815,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
new_messages.append(msg)
continue
new_msg = dict(msg)
if tool_calls:
if isinstance(tool_calls, list):
new_calls = []
for tc in tool_calls:
if not isinstance(tc, dict):
@ -879,9 +856,16 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
) -> Tuple[Dict[str, str], Dict[str, str]]:
"""Build the (forward, reverse) tool-name maps for an OpenAI tools list.
See _build_anthropic_tool_name_maps for the rules. Pulls the original
name out of either ``{"function": {"name": ...}}`` (legacy OpenAI shape)
or ``{"name": ...}`` (rare top-level shape).
Operates on **OpenAI-format** tool dicts (pre-``_map_tools``). The
production sanitization path uses ``_sanitize_tool_names_in_request``
instead, which operates on **Anthropic-format** tools (post-
``_map_tools``, where ``type == "custom"``). This helper exists for
callers that need to compute the maps from the raw OpenAI shape --
e.g. test setup or future pre-mapping consumers.
See _build_anthropic_tool_name_maps for the collision rules. Pulls
the original name out of either ``{"function": {"name": ...}}``
(legacy OpenAI shape) or ``{"name": ...}`` (rare top-level shape).
"""
original_names: List[str] = []
for tool in tools or []:
@ -943,20 +927,33 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
# Every name was already valid -- nothing to do.
return forward, reverse
# 2. Apply forward map in place to custom-tool names.
# 2. Apply forward map. Build a new list with copy-on-change entries
# so a caller reusing the same tool list/dicts across requests
# doesn't see its inputs permanently rewritten (which would also
# drop the original key from `forward` on the next request).
new_tools: List[Any] = []
for t in tools:
if not isinstance(t, dict) or t.get("type") != "custom":
continue
name = t.get("name")
if isinstance(name, str) and name in forward:
t["name"] = forward[name]
if (
isinstance(t, dict)
and t.get("type") == "custom"
and isinstance(t.get("name"), str)
and t["name"] in forward
):
new_tools.append({**t, "name": forward[t["name"]]})
else:
new_tools.append(t)
optional_params["tools"] = new_tools
# 3. Apply forward map to ``tool_choice`` when it targets a named tool.
# 3. Same for ``tool_choice`` when it targets a named tool. Copy
# rather than mutate for the same reason as above.
tool_choice = optional_params.get("tool_choice")
if isinstance(tool_choice, dict) and tool_choice.get("type") == "tool":
tc_name = tool_choice.get("name")
if isinstance(tc_name, str) and tc_name in forward:
tool_choice["name"] = forward[tc_name]
optional_params["tool_choice"] = {
**tool_choice,
"name": forward[tc_name],
}
return forward, reverse

View file

@ -3719,69 +3719,6 @@ def test_build_anthropic_tool_name_maps_three_way_collision():
assert reverse == {"foo_bar_2": "foo/bar", "foo_bar_3": "foo.bar"}
def test_map_tools_sanitizes_function_tool_name():
"""``_map_tools`` does NOT sanitize on its own (sanitization happens in
``transform_request``). When given a forward map, it applies it; when not,
it passes names through. This test pins the explicit-map behavior."""
import re as _re
config = AnthropicConfig()
bad_name = "github_openapi_mcp-actions/download-job-logs-for-workflow-run"
tools = [
{
"type": "function",
"function": {
"name": bad_name,
"description": "desc",
"parameters": {
"type": "object",
"properties": {"x": {"type": "string"}},
},
},
}
]
forward, _ = config._build_request_tool_name_maps(tools)
anthropic_tools, _ = config._map_tools(tools, name_forward_map=forward)
assert len(anthropic_tools) == 1
sent_name = anthropic_tools[0]["name"]
assert _re.fullmatch(
r"[a-zA-Z0-9_-]{1,128}", sent_name
), f"sanitized name {sent_name!r} still violates Anthropic's regex"
assert sent_name == "github_openapi_mcp-actions_download-job-logs-for-workflow-run"
def test_map_tool_choice_sanitizes_named_tool():
config = AnthropicConfig()
forward = {
"actions/download-job-logs-for-workflow-run": (
"actions_download-job-logs-for-workflow-run"
)
}
tool_choice = {
"type": "function",
"function": {"name": "actions/download-job-logs-for-workflow-run"},
}
out = config._map_tool_choice(
tool_choice=tool_choice, parallel_tool_use=None, name_forward_map=forward
)
assert out is not None
assert out["type"] == "tool"
assert out["name"] == "actions_download-job-logs-for-workflow-run"
def test_map_tool_choice_no_forward_map_passes_through_valid_name():
"""tool_choice with an already-valid name and no map -> unchanged."""
config = AnthropicConfig()
tool_choice = {"type": "function", "function": {"name": "plain_tool"}}
out = config._map_tool_choice(
tool_choice=tool_choice, parallel_tool_use=None, name_forward_map=None
)
assert out is not None
assert out["name"] == "plain_tool"
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-
@ -3915,6 +3852,60 @@ def test_rewrite_tool_names_in_messages_leaves_unmapped_names_alone():
assert messages[0]["tool_calls"][0]["function"]["name"] == "foo_bar"
def test_rewrite_tool_names_in_messages_with_tool_calls_and_none_function_call():
"""When a message has tool_calls but function_call is explicitly None,
the rewrite must still apply to tool_calls and leave function_call as
None. Pins behavior at the boundary where ``new_msg = dict(msg)``
copies the explicit-None key forward."""
config = AnthropicConfig()
forward_map = {"foo/bar": "foo_bar"}
messages = [
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "foo/bar", "arguments": "{}"},
}
],
"function_call": None,
},
]
out = config._rewrite_tool_names_in_messages(messages, forward_map)
assert out[0]["tool_calls"][0]["function"]["name"] == "foo_bar"
assert out[0]["function_call"] is None
# input list must not be mutated
assert messages[0]["tool_calls"][0]["function"]["name"] == "foo/bar"
def test_sanitize_tool_names_in_request_does_not_mutate_caller_tool_dicts():
"""REGRESSION: a caller reusing the same tool list/dicts across requests
must not see its inputs permanently rewritten. _sanitize_tool_names_in_request
builds a new list with copy-on-change entries."""
config = AnthropicConfig()
original_name = "actions/download-job-logs-for-workflow-run"
caller_tool = {
"type": "custom",
"name": original_name,
"input_schema": {"type": "object", "properties": {}},
}
caller_tools = [caller_tool]
optional_params: dict = {"tools": caller_tools}
forward, reverse = config._sanitize_tool_names_in_request(
optional_params=optional_params
)
assert forward.get(original_name)
sanitized = forward[original_name]
assert optional_params["tools"][0]["name"] == sanitized
# caller's original dict + list must not be touched
assert caller_tool["name"] == original_name
assert caller_tools[0] is caller_tool
def test_transform_parsed_response_reverse_maps_tool_names():
"""End-to-end: rewritten tool name in Anthropic response -> original in OpenAI tool_calls."""
import json as _json