diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index d8da91d1dc7..be2f83650ce 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -115,7 +115,12 @@ else: # response side. _ANTHROPIC_TOOL_NAME_INVALID_CHARS = re.compile(r"[^a-zA-Z0-9_-]") _ANTHROPIC_TOOL_NAME_MAX_LEN = 128 -ANTHROPIC_TOOL_NAME_FORWARD_MAP_KEY = "_anthropic_tool_name_forward_map" +# Single, internal-only key on ``litellm_params`` used to thread the per- +# request reverse map (sanitized -> original) from request build to response +# parsing. ``litellm_params`` is never serialized to a provider; ``optional_ +# params`` IS (it becomes the JSON body via ``data = {**optional_params}``). +# Keep these two channels strictly separate -- never stash internal +# coordination state in ``optional_params``. ANTHROPIC_TOOL_NAME_REVERSE_MAP_KEY = "_anthropic_tool_name_map" @@ -893,6 +898,68 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): original_names.append(original) return _build_anthropic_tool_name_maps(original_names) + @staticmethod + def _sanitize_tool_names_in_request( + optional_params: Dict[str, Any], + ) -> Tuple[Dict[str, str], Dict[str, str]]: + """Sanitize ``optional_params['tools']`` and ``optional_params['tool_choice']`` + in place so every name matches Anthropic's ``^[a-zA-Z0-9_-]{1,128}$``. + + Returns ``(forward, reverse)`` for use by message-history rewriting + and response translation. ``forward[original] = sanitized`` is only + populated for names that were actually rewritten -- i.e. either + contained an invalid character or collided with another tool's + sanitized form. Names already valid AND unique pass through and are + absent from both maps. + + Only ``type == "custom"`` tools (the OpenAI function-tool shape) are + considered. Hosted tools (``web_search``, ``bash``, ``code_execution``, + ``computer_*``, ``mcp``, ...) own reserved names defined by Anthropic + and must not be touched. + """ + tools = optional_params.get("tools") + if not isinstance(tools, list) or not tools: + return {}, {} + + # 1. Collect originals from the Anthropic-shaped custom-tool entries. + # Order matters: the first occurrence wins the canonical slot; + # later collisions get numeric suffixes (see + # ``_build_anthropic_tool_name_maps``). + original_names: List[str] = [] + for t in tools: + if not isinstance(t, dict): + continue + if t.get("type") != "custom": + continue + name = t.get("name") + if isinstance(name, str) and name: + original_names.append(name) + + if not original_names: + return {}, {} + + forward, reverse = _build_anthropic_tool_name_maps(original_names) + if not forward: + # Every name was already valid -- nothing to do. + return forward, reverse + + # 2. Apply forward map in place to custom-tool names. + 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] + + # 3. Apply forward map to ``tool_choice`` when it targets a named tool. + 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] + + return forward, reverse + def _detect_tool_search_tools(self, tools: Optional[List]) -> bool: """Check if tool search tools are present in the tools list.""" if not tools: @@ -1199,26 +1266,16 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): non_default_params=non_default_params ) - # Build per-request tool-name maps once, up-front, so both `tools` - # and `tool_choice` see the same forward map regardless of the - # order params arrive in non_default_params. See - # _build_anthropic_tool_name_maps for the collision-handling rules. - _tools_param = non_default_params.get("tools") - _tool_name_forward_map: Dict[str, str] = {} - _tool_name_reverse_map: Dict[str, str] = {} - if _tools_param: - ( - _tool_name_forward_map, - _tool_name_reverse_map, - ) = self._build_request_tool_name_maps(_tools_param) - if _tool_name_forward_map: - optional_params[ANTHROPIC_TOOL_NAME_FORWARD_MAP_KEY] = ( - _tool_name_forward_map - ) - if _tool_name_reverse_map: - optional_params[ANTHROPIC_TOOL_NAME_REVERSE_MAP_KEY] = ( - _tool_name_reverse_map - ) + # NB: ``map_openai_params`` deliberately does NOT sanitize tool names + # here. Names are the *original* OpenAI names at this stage, and must + # remain so until ``transform_request`` -- which is the single + # chokepoint where Anthropic, Bedrock-Anthropic, and Vertex-Anthropic + # all pass through. Doing it there guarantees: + # 1. one source of truth for the per-request forward/reverse maps, + # 2. the maps land on ``litellm_params`` (internal), never on + # ``optional_params`` (which is serialized into the request body + # via ``data = {**optional_params}`` and would 400 with + # ``Extra inputs are not permitted``). for param, value in non_default_params.items(): if param == "max_tokens": @@ -1230,9 +1287,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): value if isinstance(value, int) else max(1, int(round(value))) ) elif param == "tools": - anthropic_tools, mcp_servers = self._map_tools( - value, name_forward_map=_tool_name_forward_map - ) + anthropic_tools, mcp_servers = self._map_tools(value) optional_params = self._add_tools_to_optional_params( optional_params=optional_params, tools=anthropic_tools ) @@ -1243,7 +1298,6 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): self._map_tool_choice( tool_choice=non_default_params.get("tool_choice"), parallel_tool_use=non_default_params.get("parallel_tool_calls"), - name_forward_map=_tool_name_forward_map, ) ) @@ -1659,21 +1713,33 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): headers=headers, optional_params=optional_params ) - # Rewrite tool_call names in prior assistant messages using the - # per-request forward map so the `tool_use` blocks we end up sending - # match the (rewritten) tool names in optional_params["tools"]. - # Without this, Anthropic still 400s on tool_use.name even if the - # tools array is clean. + # === Tool-name sanitization (single chokepoint) === + # Anthropic enforces ^[a-zA-Z0-9_-]{1,128}$ on every tool name. We + # sanitize *here* -- not in map_openai_params -- because: # - # Also propagate the reverse map onto litellm_params so - # transform_response / streaming can translate response tool_use - # names back to the caller's originals. - _forward = optional_params.get(ANTHROPIC_TOOL_NAME_FORWARD_MAP_KEY) - _reverse = optional_params.get(ANTHROPIC_TOOL_NAME_REVERSE_MAP_KEY) - if _forward: - messages = self._rewrite_tool_names_in_messages(messages, _forward) - if _reverse and isinstance(litellm_params, dict): - litellm_params[ANTHROPIC_TOOL_NAME_REVERSE_MAP_KEY] = _reverse + # - This function is the single boundary shared by AnthropicConfig, + # AmazonAnthropicConfig (Bedrock invoke), VertexAIAnthropicConfig, + # and AzureAnthropicConfig (all call ``super().transform_request`` + # or ``AnthropicConfig.transform_request(self, ...)``). Sanitizing + # once here covers every Anthropic-shaped request. + # - The forward/reverse maps are coordination state; they belong on + # ``litellm_params`` (internal-only), never on ``optional_params`` + # (which becomes the JSON body via ``{**optional_params}``). + # - It keeps ``map_openai_params`` a pure param translator with no + # side-channel state. + # + # The reverse map only contains entries for names that were actually + # rewritten -- so a tool legitimately named ``foo_bar`` is never + # incorrectly retyped to ``foo/bar`` on the response side. + # See _build_anthropic_tool_name_maps for the collision-handling + # rules and rationale. + _name_forward_map, _name_reverse_map = self._sanitize_tool_names_in_request( + optional_params=optional_params, + ) + if _name_forward_map: + messages = self._rewrite_tool_names_in_messages(messages, _name_forward_map) + if _name_reverse_map and isinstance(litellm_params, dict): + litellm_params[ANTHROPIC_TOOL_NAME_REVERSE_MAP_KEY] = _name_reverse_map # Separate system prompt from rest of message anthropic_system_message_list = self.translate_system_message(messages=messages) 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 4c686595418..f744967a328 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 @@ -3720,7 +3720,9 @@ def test_build_anthropic_tool_name_maps_three_way_collision(): def test_map_tools_sanitizes_function_tool_name(): - """Tools sent to Anthropic must have names matching ^[a-zA-Z0-9_-]{1,128}$.""" + """``_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() @@ -3780,7 +3782,12 @@ def test_map_tool_choice_no_forward_map_passes_through_valid_name(): assert out["name"] == "plain_tool" -def test_map_openai_params_stashes_forward_and_reverse_maps(): +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- + internal coordination state like the per-request forward/reverse name + maps, or Anthropic 400s with ``Extra inputs are not permitted``. + Sanitization belongs in ``transform_request``, not here.""" config = AnthropicConfig() optional_params: dict = {} config.map_openai_params( @@ -3799,25 +3806,22 @@ def test_map_openai_params_stashes_forward_and_reverse_maps(): model="claude-sonnet-4", drop_params=False, ) - # forward map (original -> sanitized) for use by message rewriting - assert "_anthropic_tool_name_forward_map" in optional_params - assert ( - optional_params["_anthropic_tool_name_forward_map"][ - "actions/download-job-logs-for-workflow-run" - ] - == "actions_download-job-logs-for-workflow-run" - ) - # reverse map (sanitized -> original) for response translation - assert "_anthropic_tool_name_map" in optional_params - assert ( - optional_params["_anthropic_tool_name_map"][ - "actions_download-job-logs-for-workflow-run" - ] - == "actions/download-job-logs-for-workflow-run" - ) + # No internal keys may appear in optional_params for ANY input. + for key in optional_params: + assert not key.startswith( + "_anthropic_tool_name" + ), f"optional_params leaked internal key {key!r}: {optional_params}" + # And no key starting with `_` either; optional_params should only + # contain documented Anthropic Messages API parameters. + for key in optional_params: + assert not key.startswith("_"), ( + f"optional_params leaked underscore-prefixed key {key!r}: " + f"{optional_params}" + ) def test_map_openai_params_no_maps_when_all_names_already_valid(): + """Sanity check: an all-valid tool list adds nothing weird either.""" config = AnthropicConfig() optional_params: dict = {} config.map_openai_params( @@ -3836,8 +3840,8 @@ def test_map_openai_params_no_maps_when_all_names_already_valid(): model="claude-sonnet-4", drop_params=False, ) - assert "_anthropic_tool_name_forward_map" not in optional_params - assert "_anthropic_tool_name_map" not in optional_params + for key in optional_params: + assert not key.startswith("_anthropic_tool_name") def test_rewrite_tool_names_in_messages_uses_forward_map(): @@ -4078,3 +4082,261 @@ def test_streaming_iterator_passthrough_when_name_not_in_map(): tool_calls = parsed.choices[0].delta.tool_calls assert tool_calls is not None and len(tool_calls) == 1 assert tool_calls[0]["function"]["name"] == "plain_tool" + + +# --------------------------------------------------------------------------- +# transform_request: end-to-end sanitization regression coverage +# --------------------------------------------------------------------------- + + +def _build_optional_params_for_tools(tools): + """Run a tools list through ``map_openai_params`` to get the same shape + ``transform_request`` will see from the router. Keeping this helper local + avoids duplicating the OpenAI->Anthropic param mapping in tests.""" + config = AnthropicConfig() + optional_params: dict = {} + config.map_openai_params( + non_default_params={"tools": tools}, + optional_params=optional_params, + model="claude-sonnet-4", + drop_params=False, + ) + return optional_params + + +def test_transform_request_does_not_leak_internal_keys_into_body(): + """REGRESSION for "_anthropic_tool_name_forward_map: Extra inputs are not + permitted". The dict returned by ``transform_request`` is what becomes + the JSON body POSTed to Anthropic. It must contain ONLY documented + Anthropic Messages fields -- no LiteLLM coordination state.""" + config = AnthropicConfig() + tools = [ + { + "type": "function", + "function": { + "name": "github_openapi_mcp-actions/download-job-logs-for-workflow-run", + "description": "d", + "parameters": {"type": "object", "properties": {}}, + }, + }, + { + "type": "function", + "function": { + "name": "plain_tool", + "description": "d", + "parameters": {"type": "object", "properties": {}}, + }, + }, + ] + optional_params = _build_optional_params_for_tools(tools) + litellm_params: dict = {} + + data = config.transform_request( + model="claude-sonnet-4", + messages=[{"role": "user", "content": "go"}], + optional_params=optional_params, + litellm_params=litellm_params, + headers={}, + ) + + # Body must not contain any LiteLLM-internal keys. + for key in data.keys(): + assert not key.startswith("_"), ( + f"transformed request body leaked underscore-prefixed key {key!r}; " + f"Anthropic will reject this with 'Extra inputs are not permitted'. " + f"body keys: {list(data.keys())}" + ) + + # Tool names in the body match Anthropic's pattern. + import re as _re + + for tool in data.get("tools", []): + name = tool.get("name") + assert isinstance(name, str) + assert _re.fullmatch( + r"[a-zA-Z0-9_-]{1,128}", name + ), f"sanitized tool name {name!r} still violates Anthropic regex" + + # Sent name for the bad tool is the disambiguated form, valid name passes through. + sent_names = {t["name"] for t in data["tools"]} + assert "github_openapi_mcp-actions_download-job-logs-for-workflow-run" in sent_names + assert "plain_tool" in sent_names + + # Reverse map landed on litellm_params (NOT optional_params, NOT body). + rmap = litellm_params["_anthropic_tool_name_map"] + assert ( + rmap["github_openapi_mcp-actions_download-job-logs-for-workflow-run"] + == "github_openapi_mcp-actions/download-job-logs-for-workflow-run" + ) + # The legitimately-named tool is not in the reverse map -- it round-trips + # untouched on the response side. + assert "plain_tool" not in rmap + + +def test_transform_request_no_reverse_map_when_all_names_valid(): + """If every name is already valid, ``litellm_params`` stays clean + (no reverse map key) -- minimizes blast radius for the common case.""" + config = AnthropicConfig() + tools = [ + { + "type": "function", + "function": { + "name": "plain_tool", + "description": "d", + "parameters": {"type": "object", "properties": {}}, + }, + }, + ] + optional_params = _build_optional_params_for_tools(tools) + litellm_params: dict = {} + + data = config.transform_request( + model="claude-sonnet-4", + messages=[{"role": "user", "content": "go"}], + optional_params=optional_params, + litellm_params=litellm_params, + headers={}, + ) + assert data["tools"][0]["name"] == "plain_tool" + assert "_anthropic_tool_name_map" not in litellm_params + + +def test_transform_request_sanitizes_tool_choice_named_tool(): + """``tool_choice={"type": "function", "function": {"name": ""}}`` + must arrive at Anthropic as ``{"type": "tool", "name": ""}``, + matching the sanitized name in the tools array.""" + config = AnthropicConfig() + tools = [ + { + "type": "function", + "function": { + "name": "actions/download-job-logs-for-workflow-run", + "parameters": {"type": "object", "properties": {}}, + }, + } + ] + optional_params = AnthropicConfig().map_openai_params( + non_default_params={ + "tools": tools, + "tool_choice": { + "type": "function", + "function": {"name": "actions/download-job-logs-for-workflow-run"}, + }, + }, + optional_params={}, + model="claude-sonnet-4", + drop_params=False, + ) + litellm_params: dict = {} + data = config.transform_request( + model="claude-sonnet-4", + messages=[{"role": "user", "content": "go"}], + optional_params=optional_params, + litellm_params=litellm_params, + headers={}, + ) + assert data["tool_choice"]["type"] == "tool" + assert data["tool_choice"]["name"] == "actions_download-job-logs-for-workflow-run" + assert data["tools"][0]["name"] == "actions_download-job-logs-for-workflow-run" + + +def test_transform_request_rewrites_tool_names_in_history(): + """Historical assistant messages with ``tool_calls`` referencing the bad + name must be rewritten to the sanitized form so Anthropic doesn't 400 on + ``tool_use.name`` mismatching the (sanitized) tools array.""" + config = AnthropicConfig() + tools = [ + { + "type": "function", + "function": { + "name": "actions/download-job-logs-for-workflow-run", + "parameters": {"type": "object", "properties": {}}, + }, + } + ] + optional_params = _build_optional_params_for_tools(tools) + messages = [ + {"role": "user", "content": "logs please"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "toolu_old", + "type": "function", + "function": { + "name": "actions/download-job-logs-for-workflow-run", + "arguments": "{}", + }, + } + ], + }, + {"role": "tool", "tool_call_id": "toolu_old", "content": "..."}, + {"role": "user", "content": "again"}, + ] + litellm_params: dict = {} + data = config.transform_request( + model="claude-sonnet-4", + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + headers={}, + ) + # Find the assistant tool_use block in the Anthropic-shaped messages. + tool_use_names = [] + for msg in data["messages"]: + content = msg.get("content") + if not isinstance(content, list): + continue + for block in content: + if isinstance(block, dict) and block.get("type") == "tool_use": + tool_use_names.append(block.get("name")) + assert ( + tool_use_names + ), "expected at least one tool_use block in transformed messages" + for name in tool_use_names: + assert name == "actions_download-job-logs-for-workflow-run", ( + f"history tool_use.name {name!r} not rewritten -- Anthropic will " + f"400 because it doesn't match the (sanitized) tools array" + ) + + +def test_sanitize_tool_names_in_request_skips_hosted_tools(): + """Hosted tools (web_search, computer_*, code_execution, ...) own + Anthropic-reserved names. The sanitizer must not enumerate them as + ``custom`` and must not rename them.""" + optional_params = { + "tools": [ + {"type": "web_search_20250305", "name": "web_search"}, + { + "type": "custom", + "name": "actions/download-job-logs-for-workflow-run", + "input_schema": {"type": "object", "properties": {}}, + }, + ], + } + forward, reverse = AnthropicConfig._sanitize_tool_names_in_request(optional_params) + # Only the custom tool was rewritten. + assert forward == { + "actions/download-job-logs-for-workflow-run": "actions_download-job-logs-for-workflow-run" + } + assert reverse == { + "actions_download-job-logs-for-workflow-run": "actions/download-job-logs-for-workflow-run" + } + # Hosted tool's name unchanged. + assert optional_params["tools"][0]["name"] == "web_search" + # Custom tool's name updated in place. + assert ( + optional_params["tools"][1]["name"] + == "actions_download-job-logs-for-workflow-run" + ) + + +def test_sanitize_tool_names_in_request_no_tools_is_noop(): + """Empty / missing tools must not error or pollute return.""" + forward, reverse = AnthropicConfig._sanitize_tool_names_in_request({}) + assert forward == {} + assert reverse == {} + forward, reverse = AnthropicConfig._sanitize_tool_names_in_request({"tools": []}) + assert forward == {} + assert reverse == {}