From f7af44a5055e55bb6c7d3be0a6154daaad14fe4b Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:55:15 -0700 Subject: [PATCH 1/3] fix(anthropic-adapter): pass provider-native and OpenAI-format tools through on /v1/messages --- .../adapters/transformation.py | 20 ++++++++ ...al_pass_through_adapters_transformation.py | 48 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 109017bda27..ca90df2ff75 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -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. @@ -770,6 +786,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}" diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index ee09baf28b6..7073d702db4 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -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. From ba7c268a6ab496c93b7e0ebd5177bd268f54a30e Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:16:12 -0700 Subject: [PATCH 2/3] fix(proxy): enforce tool allowlist on OpenAI-format tools sent to /v1/messages --- .../chat/guardrail_translation/handler.py | 9 +++---- .../base_llm/guardrail_translation/utils.py | 12 +++++++-- .../proxy/test_tools_allowlist_enforcement.py | 27 +++++++++++++++++++ 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/litellm/llms/anthropic/chat/guardrail_translation/handler.py b/litellm/llms/anthropic/chat/guardrail_translation/handler.py index 721a6653597..2afea1444f9 100644 --- a/litellm/llms/anthropic/chat/guardrail_translation/handler.py +++ b/litellm/llms/anthropic/chat/guardrail_translation/handler.py @@ -677,12 +677,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 tool names from Anthropic messages request (tools[].name, or + tools[].function.name for OpenAI-format tools the bridge forwards verbatim).""" + return [name for tool in data.get("tools") or [] if (name := anthropic_tool_name(tool))] @classmethod def _extract_input_text_and_images( diff --git a/litellm/llms/base_llm/guardrail_translation/utils.py b/litellm/llms/base_llm/guardrail_translation/utils.py index 1546adbb0bd..4494f8451f4 100644 --- a/litellm/llms/base_llm/guardrail_translation/utils.py +++ b/litellm/llms/base_llm/guardrail_translation/utils.py @@ -210,8 +210,16 @@ def openai_tool_name(tool: object) -> str | None: 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 + """Anthropic tools carry a flat ``name``; an OpenAI-format function tool, which the + non-Anthropic bridge forwards verbatim, carries it under ``function.name`` instead.""" + if not isinstance(tool, dict): + return None + flat_name: Final = tool.get("name") + if isinstance(flat_name, str): + return flat_name + 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 function_name if isinstance(function_name, str) else None def merge_returned_tools_into_request_tools( diff --git a/tests/test_litellm/proxy/test_tools_allowlist_enforcement.py b/tests/test_litellm/proxy/test_tools_allowlist_enforcement.py index 31f5fbf606b..c881effada5 100644 --- a/tests/test_litellm/proxy/test_tools_allowlist_enforcement.py +++ b/tests/test_litellm/proxy/test_tools_allowlist_enforcement.py @@ -93,6 +93,19 @@ 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_generate_content_tools(self): data = { "tools": [ @@ -159,6 +172,20 @@ 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_disallowed_custom_tool_raises_on_responses_route(self): token = _token(metadata={"allowed_tools": ["other_tool"]}) From 6d7fafa3470541d797077d8e7854000d7c7f4c79 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:37:11 -0700 Subject: [PATCH 3/3] fix(anthropic): close hybrid tool-name allowlist gap and keep native tools through guardrails --- .../chat/guardrail_translation/handler.py | 21 +++++++++++---- .../adapters/transformation.py | 4 +-- .../base_llm/guardrail_translation/utils.py | 19 +++++++------ .../test_anthropic_guardrail_handler.py | 18 +++++++++++++ .../proxy/test_tools_allowlist_enforcement.py | 27 +++++++++++++++++++ 5 files changed, 74 insertions(+), 15 deletions(-) diff --git a/litellm/llms/anthropic/chat/guardrail_translation/handler.py b/litellm/llms/anthropic/chat/guardrail_translation/handler.py index 2afea1444f9..b9ca18c7843 100644 --- a/litellm/llms/anthropic/chat/guardrail_translation/handler.py +++ b/litellm/llms/anthropic/chat/guardrail_translation/handler.py @@ -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,9 +688,9 @@ class AnthropicMessagesHandler(BaseTranslation): ) def extract_request_tool_names(self, data: dict) -> list[str]: - """Extract tool names from Anthropic messages request (tools[].name, or - tools[].function.name for OpenAI-format tools the bridge forwards verbatim).""" - return [name for tool in data.get("tools") or [] if (name := anthropic_tool_name(tool))] + """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( diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index ca90df2ff75..c958f9f242f 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -27,7 +27,7 @@ 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: +def is_provider_native_tool_dict(tool: Mapping[str, object]) -> bool: if len(tool) != 1: return False key, value = next(iter(tool.items())) @@ -786,7 +786,7 @@ class LiteLLMAnthropicMessagesAdapter: new_tools.append(tool) continue - if _is_openai_function_tool(tool) or _is_provider_native_tool_dict(tool): + 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 diff --git a/litellm/llms/base_llm/guardrail_translation/utils.py b/litellm/llms/base_llm/guardrail_translation/utils.py index 4494f8451f4..aefe3861e3c 100644 --- a/litellm/llms/base_llm/guardrail_translation/utils.py +++ b/litellm/llms/base_llm/guardrail_translation/utils.py @@ -209,17 +209,20 @@ def openai_tool_name(tool: object) -> str | None: return flat_name if isinstance(flat_name, str) else None -def anthropic_tool_name(tool: object) -> str | None: - """Anthropic tools carry a flat ``name``; an OpenAI-format function tool, which the - non-Anthropic bridge forwards verbatim, carries it under ``function.name`` instead.""" +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 None - flat_name: Final = tool.get("name") - if isinstance(flat_name, str): - return flat_name + 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 function_name if isinstance(function_name, str) 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: + names: Final = anthropic_tool_names(tool) + return names[0] if names else None def merge_returned_tools_into_request_tools( diff --git a/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py b/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py index 2b392456763..46bdd855a28 100644 --- a/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py +++ b/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py @@ -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, diff --git a/tests/test_litellm/proxy/test_tools_allowlist_enforcement.py b/tests/test_litellm/proxy/test_tools_allowlist_enforcement.py index c881effada5..3e9c7c14b95 100644 --- a/tests/test_litellm/proxy/test_tools_allowlist_enforcement.py +++ b/tests/test_litellm/proxy/test_tools_allowlist_enforcement.py @@ -106,6 +106,19 @@ class TestExtractRequestToolNames: "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": [ @@ -186,6 +199,20 @@ 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_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"]})