mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-20 00:11:50 +00:00
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:
parent
8616d80ea9
commit
13d9d674f9
2 changed files with 387 additions and 59 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 == {}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue