Merge pull request #38431 from BerriAI/litellm_fix_messages_native_tools

fix(anthropic-adapter): pass provider-native and OpenAI-format tools through on /v1/messages
This commit is contained in:
Mateo Wang 2026-08-27 15:24:07 -07:00 committed by GitHub
commit 4ef1c28877
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 169 additions and 10 deletions

View file

@ -24,10 +24,12 @@ from litellm._logging import verbose_proxy_logger
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import (
LiteLLMAnthropicMessagesAdapter,
is_provider_native_tool_dict,
)
from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation
from litellm.llms.base_llm.guardrail_translation.utils import (
anthropic_tool_name,
anthropic_tool_names,
effective_scan_only_tool_results_for_guardrail,
effective_skip_system_message_for_guardrail,
effective_skip_tool_message_for_guardrail,
@ -360,7 +362,13 @@ class AnthropicMessagesHandler(BaseTranslation):
structured_messages: Final = [full_structured_messages[index] for index in scoped_message_indices]
tools_to_check: Final[list[ChatCompletionToolParam]] = (
[] if scan_only_tool_results else chat_completion_compatible_request.get("tools", [])
[]
if scan_only_tool_results
else [
tool
for tool in chat_completion_compatible_request.get("tools", [])
if not is_provider_native_tool_dict(tool)
]
)
# Step 1: Extract all text content and images
@ -419,7 +427,10 @@ class AnthropicMessagesHandler(BaseTranslation):
tool_name=anthropic_tool_name,
)
if scan_only_tool_results
else anthropic_tools
else [
*(tool for tool in data.get("tools") or [] if is_provider_native_tool_dict(tool)),
*anthropic_tools,
]
)
guardrailed_structured_messages: Final = guardrailed_inputs.get("structured_messages")
@ -677,12 +688,9 @@ class AnthropicMessagesHandler(BaseTranslation):
)
def extract_request_tool_names(self, data: dict) -> list[str]:
"""Extract tool names from Anthropic messages request (tools[].name)."""
names: Final[list[str]] = []
for tool in data.get("tools") or []:
if isinstance(tool, dict) and tool.get("name"):
names.append(str(tool["name"]))
return names
"""Extract every tool name in an Anthropic messages request: tools[].name, plus
tools[].function.name for OpenAI-format tools the bridge forwards verbatim."""
return [name for tool in data.get("tools") or [] for name in anthropic_tool_names(tool)]
@classmethod
def _extract_input_text_and_images(

View file

@ -18,6 +18,22 @@ TOOL_NAME_PREFIX_LENGTH: Final = OPENAI_MAX_TOOL_NAME_LENGTH - TOOL_NAME_HASH_LE
PROVIDERS_PROXYING_AN_UNKNOWN_BACKEND: Final = frozenset({"litellm_proxy"})
_ANTHROPIC_TOOL_SCHEMA_KEYS: Final = frozenset(
{"name", "type", "input_schema", "description", "cache_control", "strict"}
)
def _is_openai_function_tool(tool: Mapping[str, object]) -> bool:
return tool.get("type") == "function" and "function" in tool
def is_provider_native_tool_dict(tool: Mapping[str, object]) -> bool:
if len(tool) != 1:
return False
key, value = next(iter(tool.items()))
return key not in _ANTHROPIC_TOOL_SCHEMA_KEYS and isinstance(value, dict)
def truncate_tool_name(name: str) -> str:
"""
Truncate tool names that exceed OpenAI's 64-character limit.
@ -698,6 +714,10 @@ class LiteLLMAnthropicMessagesAdapter:
new_tools.append(tool)
continue
if _is_openai_function_tool(tool) or is_provider_native_tool_dict(tool):
new_tools.append(cast(ChatCompletionToolParam, tool)) # cast-ok: passed through verbatim to provider
continue
raw_name = tool.get("name")
if raw_name is None or (isinstance(raw_name, str) and not str(raw_name).strip()):
original_name = f"litellm_unnamed_tool_{idx}"

View file

@ -209,9 +209,20 @@ def openai_tool_name(tool: object) -> str | None:
return flat_name if isinstance(flat_name, str) else None
def anthropic_tool_names(tool: object) -> tuple[str, ...]:
"""Every name a /v1/messages tool dict can act under: the flat Anthropic ``name`` plus
``function.name`` for OpenAI-format tools the bridge forwards verbatim. Allowlist checks
must see both, or a decoy flat name could smuggle a disallowed ``function.name`` through."""
if not isinstance(tool, dict):
return ()
function: Final = tool.get("function") if tool.get("type") == "function" else None
function_name: Final = function.get("name") if isinstance(function, dict) else None
return tuple(name for name in (tool.get("name"), function_name) if isinstance(name, str) and name)
def anthropic_tool_name(tool: object) -> str | None:
name: Final = tool.get("name") if isinstance(tool, dict) else None
return name if isinstance(name, str) else None
names: Final = anthropic_tool_names(tool)
return names[0] if names else None
def merge_returned_tools_into_request_tools(

View file

@ -290,6 +290,24 @@ class TestAnthropicMessagesHandlerInputProcessing:
assert data.get("litellm_metadata", {}).get("guardrails")
assert guardrail.dynamic_params == {"policy_id": "policy-123"}
@pytest.mark.asyncio
async def test_provider_native_tools_survive_guardrail_round_trip(self):
handler = AnthropicMessagesHandler()
guardrail = MockPassThroughGuardrail(guardrail_name="test")
data = {
"model": "gemini-2.5-flash",
"messages": [{"role": "user", "content": "coffee shops near Union Square?"}],
"tools": [
{"googleMaps": {"enable_widget": True}},
{"name": "get_weather", "input_schema": {"type": "object", "properties": {}}},
],
}
await handler.process_input_messages(data=data, guardrail_to_apply=guardrail)
assert {"googleMaps": {"enable_widget": True}} in data["tools"]
assert [tool["name"] for tool in data["tools"] if "name" in tool] == ["get_weather"]
@pytest.mark.asyncio
async def test_midturn_system_correction_is_guardrailed_when_top_level_system_is_skipped(
self,

View file

@ -16,6 +16,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import (
)
from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import (
OPENAI_MAX_TOOL_NAME_LENGTH,
AnthropicAdapter,
LiteLLMAnthropicMessagesAdapter,
create_tool_name_mapping,
truncate_tool_name,
@ -2307,6 +2308,53 @@ def test_translate_anthropic_tools_to_openai_fills_missing_tool_name():
assert result[1]["function"]["name"] == "litellm_unnamed_tool_1"
def test_translate_anthropic_tools_to_openai_passes_provider_native_tool_dicts_through():
"""Deployment-level provider-native tools (e.g. Gemini googleMaps) must reach the provider transformation verbatim (LIT-6286)."""
tools = [
{"googleMaps": {}},
{"googleSearch": {}},
{
"name": "get_weather",
"input_schema": {"type": "object", "properties": {"location": {"type": "string"}}},
},
]
adapter = LiteLLMAnthropicMessagesAdapter()
result, tool_name_mapping = adapter.translate_anthropic_tools_to_openai(tools=tools, model=None)
assert result[0] == {"googleMaps": {}}
assert result[1] == {"googleSearch": {}}
assert result[2]["function"]["name"] == "get_weather"
assert tool_name_mapping == {}
def test_translate_anthropic_tools_to_openai_passes_openai_function_tools_through():
"""A tool already in OpenAI function format must pass through unchanged instead of becoming litellm_unnamed_tool_N."""
openai_tool = {
"type": "function",
"function": {
"name": "get_weather",
"parameters": {"type": "object", "properties": {"location": {"type": "string"}}},
},
}
adapter = LiteLLMAnthropicMessagesAdapter()
result, _ = adapter.translate_anthropic_tools_to_openai(tools=[openai_tool], model=None)
assert result == [openai_tool]
def test_translate_completion_input_params_keeps_provider_native_tools():
"""/v1/messages request translation must keep router-merged provider-native tools in kwargs['tools'] (LIT-6286)."""
adapter = AnthropicAdapter()
translated = adapter.translate_completion_input_params(
{
"model": "gemini/gemini-2.5-flash",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "coffee shops near Union Square"}],
"tools": [{"googleMaps": {}}],
}
)
assert translated is not None
assert translated["tools"] == [{"googleMaps": {}}]
def test_translate_openai_content_to_anthropic_reasoning_content_without_thinking_blocks():
"""
Test that reasoning_content is converted to thinking block when thinking_blocks is not present.

View file

@ -93,6 +93,32 @@ class TestExtractRequestToolNames:
"run_sql",
]
def test_anthropic_openai_format_tools_forwarded_by_bridge(self):
data = {
"tools": [
{"type": "function", "function": {"name": "get_weather"}},
{"name": "run_sql"},
{"googleSearch": {}},
]
}
assert extract_request_tool_names("/v1/messages", data) == [
"get_weather",
"run_sql",
]
def test_anthropic_hybrid_tool_yields_every_name(self):
data = {
"tools": [
{"type": "function", "name": "decoy", "function": {"name": "blocked_fn"}},
{"type": "function", "name": "", "function": {"name": "hidden_fn"}},
]
}
assert extract_request_tool_names("/v1/messages", data) == [
"decoy",
"blocked_fn",
"hidden_fn",
]
def test_generate_content_tools(self):
data = {
"tools": [
@ -159,6 +185,34 @@ class TestCheckToolsAllowlist:
assert exc_info.value.type == ProxyErrorTypes.tool_access_denied
assert "get_weather" in str(exc_info.value.message)
@pytest.mark.asyncio
async def test_disallowed_openai_format_tool_raises_on_messages_route(self):
token = _token(metadata={"allowed_tools": ["other_tool"]})
body = {"tools": [{"type": "function", "function": {"name": "get_weather"}}]}
with pytest.raises(ProxyException) as exc_info:
await check_tools_allowlist(
request_body=body,
valid_token=token,
team_object=None,
route="/v1/messages",
)
assert exc_info.value.type == ProxyErrorTypes.tool_access_denied
assert "get_weather" in str(exc_info.value.message)
@pytest.mark.asyncio
async def test_hybrid_tool_with_decoy_name_raises_on_messages_route(self):
token = _token(metadata={"allowed_tools": ["decoy"]})
body = {"tools": [{"type": "function", "name": "decoy", "function": {"name": "run_sql"}}]}
with pytest.raises(ProxyException) as exc_info:
await check_tools_allowlist(
request_body=body,
valid_token=token,
team_object=None,
route="/v1/messages",
)
assert exc_info.value.type == ProxyErrorTypes.tool_access_denied
assert "run_sql" in str(exc_info.value.message)
@pytest.mark.asyncio
async def test_disallowed_custom_tool_raises_on_responses_route(self):
token = _token(metadata={"allowed_tools": ["other_tool"]})