diff --git a/litellm/llms/anthropic/chat/handler.py b/litellm/llms/anthropic/chat/handler.py index 558662885f2..672de6a7054 100644 --- a/litellm/llms/anthropic/chat/handler.py +++ b/litellm/llms/anthropic/chat/handler.py @@ -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 ), diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index be2f83650ce..835da42e071 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -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 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 f744967a328..c8e265a0ff7 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 @@ -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