fix(anthropic): build tool-name maps in transform_request, not optional_params

The previous patch stashed the per-request forward and reverse tool-name
maps under ``optional_params["_anthropic_tool_name_forward_map"]`` and
``optional_params["_anthropic_tool_name_map"]``. ``optional_params`` is
the dict that becomes the JSON body via ``data = {**optional_params}``,
so those internal keys leaked over the wire and Anthropic 400'd with:

  _anthropic_tool_name_forward_map: Extra inputs are not permitted

Worse, this meant *every* request whose tool list contained any name with
an invalid character (the exact case the patch was meant to fix) regressed
into a confusing meta-error pointing at LiteLLM's internal map instead of
the offending tool.

Fix: move all tool-name sanitization into ``transform_request``, which is
the single chokepoint already shared by ``AnthropicConfig``,
``AmazonAnthropicConfig`` (Bedrock invoke), ``VertexAIAnthropicConfig``,
and ``AzureAnthropicConfig`` (all call ``super().transform_request`` /
``AnthropicConfig.transform_request(self, ...)``). New static helper
``_sanitize_tool_names_in_request`` walks the already-Anthropic-shaped
``optional_params["tools"]`` (only ``type=="custom"`` entries -- hosted
tool names are reserved by Anthropic and must not be touched), builds
the per-request forward/reverse maps, and applies the forward map in
place to ``tools[*].name`` and ``tool_choice.name``. The reverse map is
stashed exclusively on ``litellm_params`` (which is never serialized to
a provider) under ``_anthropic_tool_name_map`` for the response paths
to consume.

Side effect of this restructure: ``map_openai_params`` is now a pure
OpenAI->Anthropic param translator with no side-channel state, which
matches its contract everywhere else in the codebase.

Tests: replaced the now-incorrect "stashes maps in optional_params"
tests with regressions that assert no underscore-prefixed keys appear
in either ``optional_params`` after ``map_openai_params`` or in the
final ``transform_request`` body. Added end-to-end coverage for:
sanitization in ``transform_request``, ``tool_choice`` rewriting,
historical ``tool_calls`` rewriting in messages, and hosted-tool
passthrough.

Made-with: Cursor
This commit is contained in:
Krrish Dholakia 2026-04-29 12:06:29 -07:00
parent 8616d80ea9
commit 13d9d674f9
2 changed files with 387 additions and 59 deletions

View file

@ -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)

View file

@ -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": "<bad/name>"}}``
must arrive at Anthropic as ``{"type": "tool", "name": "<sanitized>"}``,
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 == {}