mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
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
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:
parent
18e5aa418e
commit
9571149fe2
3 changed files with 92 additions and 104 deletions
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue