From 6b26b47cd480ce9fe4345a67db458c8817c4b8a1 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Wed, 18 Feb 2026 13:32:25 +0530 Subject: [PATCH] Add mapping for websearch from v1/messages to chat/completions --- .../adapters/transformation.py | 43 +++++- .../vertex_and_google_ai_studio_gemini.py | 2 +- ...al_pass_through_adapters_transformation.py | 128 ++++++++++++++++++ ...test_vertex_and_google_ai_studio_gemini.py | 80 +++++++++++ 4 files changed, 248 insertions(+), 5 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index efbac13735..8b21569546 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -299,6 +299,26 @@ class LiteLLMAnthropicMessagesAdapter: """ return ["messages", "metadata", "system", "tool_choice", "tools", "thinking", "output_format"] + def _is_web_search_tool(self, tool: Dict[str, Any]) -> bool: + """ + Check if a tool is an Anthropic web search tool. + + Anthropic web search tools have: + - type starting with "web_search" (e.g., "web_search_20260209") + - name = "web_search" + + Args: + tool: Tool definition dict + + Returns: + True if this is a web search tool + """ + tool_type = tool.get("type", "") + tool_name = tool.get("name", "") + return ( + isinstance(tool_type, str) and tool_type.startswith("web_search") + ) or tool_name == "web_search" + def translate_anthropic_messages_to_openai( # noqa: PLR0915 self, messages: List[ @@ -872,10 +892,25 @@ class LiteLLMAnthropicMessagesAdapter: if "tools" in anthropic_message_request: tools = anthropic_message_request["tools"] if tools: - new_kwargs["tools"], tool_name_mapping = self.translate_anthropic_tools_to_openai( - tools=cast(List[AllAnthropicToolsValues], tools), - model=new_kwargs.get("model"), - ) + # Separate web search tools from regular tools + web_search_tools = [] + regular_tools = [] + for tool in tools: + if self._is_web_search_tool(cast(Dict[str, Any], tool)): + web_search_tools.append(tool) + else: + regular_tools.append(tool) + + # If web search tools are present, add web_search_options parameter + if web_search_tools: + new_kwargs["web_search_options"] = {} # type: ignore + + # Only translate regular tools (non-web-search) + if regular_tools: + new_kwargs["tools"], tool_name_mapping = self.translate_anthropic_tools_to_openai( + tools=cast(List[AllAnthropicToolsValues], regular_tools), + model=new_kwargs.get("model"), + ) ## CONVERT THINKING if "thinking" in anthropic_message_request: diff --git a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py index bef83b6d35..daa82a46bd 100644 --- a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py +++ b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py @@ -1072,7 +1072,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): elif param == "modalities" and isinstance(value, list): response_modalities = self.map_response_modalities(value) optional_params["responseModalities"] = response_modalities - elif param == "web_search_options" and value and isinstance(value, dict): + elif param == "web_search_options" and isinstance(value, dict): _tools = self._map_web_search_options(value) optional_params = self._add_tools_to_optional_params( optional_params, [_tools] 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 f9e5c6d025..1ea1374cfb 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 @@ -1811,3 +1811,131 @@ def test_translate_openai_response_to_anthropic_input_tokens_no_cache(): # Validate: input_tokens should equal prompt_tokens when no caching assert anthropic_response["usage"]["input_tokens"] == 100 assert anthropic_response["usage"]["output_tokens"] == 50 + + +# ===================================================================== +# Web Search Tool Transformation Tests +# ===================================================================== + + +def test_is_web_search_tool(): + """Test detection of Anthropic web search tools.""" + adapter = LiteLLMAnthropicMessagesAdapter() + + # Tool with type starting with "web_search" should be detected + web_search_tool_with_type = { + "type": "web_search_20260209", + "name": "web_search", + } + assert adapter._is_web_search_tool(web_search_tool_with_type) is True + + # Tool with name "web_search" should be detected + web_search_tool_with_name = { + "name": "web_search", + } + assert adapter._is_web_search_tool(web_search_tool_with_name) is True + + # Regular function tool should not be detected + regular_tool = { + "name": "get_weather", + "description": "Get weather info", + "input_schema": {"type": "object"}, + } + assert adapter._is_web_search_tool(regular_tool) is False + + +def test_translate_anthropic_to_openai_with_web_search_tool(): + """ + Test that Anthropic web search tools are converted to web_search_options parameter. + + When a user sends an Anthropic /v1/messages request with {"type": "web_search_20260209"} + tool, it should be transformed to OpenAI format with web_search_options: {} parameter. + """ + from litellm.types.llms.anthropic import AnthropicMessagesRequest + + anthropic_request = AnthropicMessagesRequest( + model="gemini-2.5-flash-lite", + max_tokens=4096, + messages=[ + { + "role": "user", + "content": "Search for the current prices of AAPL and GOOGL", + } + ], + tools=[ + { + "type": "web_search_20260209", + "name": "web_search", + } + ], + ) + + adapter = LiteLLMAnthropicMessagesAdapter() + openai_request, tool_name_mapping = adapter.translate_anthropic_to_openai( + anthropic_message_request=anthropic_request + ) + + # web_search_options should be added + assert "web_search_options" in openai_request + assert openai_request["web_search_options"] == {} + + # web search tool should NOT be in the tools array + assert "tools" not in openai_request or openai_request.get("tools") == [] + + # tool_name_mapping should be empty since no regular tools were present + assert tool_name_mapping == {} + + +def test_translate_anthropic_to_openai_with_mixed_tools(): + """ + Test that web search tools are separated from regular tools. + + When a request has both web search tools and regular function tools, + only the regular tools should be in the tools array, and web_search_options + should be added. + """ + from litellm.types.llms.anthropic import AnthropicMessagesRequest + + anthropic_request = AnthropicMessagesRequest( + model="gemini-2.5-flash-lite", + max_tokens=4096, + messages=[ + { + "role": "user", + "content": "Get weather and search the web", + } + ], + tools=[ + { + "type": "web_search_20260209", + "name": "web_search", + }, + { + "name": "get_weather", + "description": "Get weather information", + "input_schema": { + "type": "object", + "properties": { + "location": {"type": "string"} + }, + }, + }, + ], + ) + + adapter = LiteLLMAnthropicMessagesAdapter() + openai_request, tool_name_mapping = adapter.translate_anthropic_to_openai( + anthropic_message_request=anthropic_request + ) + + # web_search_options should be added + assert "web_search_options" in openai_request + assert openai_request["web_search_options"] == {} + + # Only get_weather tool should be in the tools array + assert "tools" in openai_request + assert len(openai_request["tools"]) == 1 + assert openai_request["tools"][0]["function"]["name"] == "get_weather" + + # tool_name_mapping should be empty for short tool names + assert tool_name_mapping == {} diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py index 581d1e603d..6047da66b6 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py @@ -3224,6 +3224,7 @@ def test_video_metadata_only_for_gemini_3(): def test_chunk_parser_handles_prompt_feedback_block(): """Test chunk_parser correctly handles promptFeedback.blockReason""" from unittest.mock import Mock + from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( ModelResponseIterator, ) @@ -3260,6 +3261,7 @@ def test_chunk_parser_handles_prompt_feedback_block(): def test_chunk_parser_handles_prompt_feedback_safety_block(): """Test chunk_parser handles different blockReason types (SAFETY)""" from unittest.mock import Mock + from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( ModelResponseIterator, ) @@ -3294,6 +3296,7 @@ def test_chunk_parser_handles_prompt_feedback_safety_block(): def test_chunk_parser_handles_prompt_feedback_block_with_usage(): """Test chunk_parser correctly extracts usageMetadata when promptFeedback.blockReason is present""" from unittest.mock import Mock + from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( ModelResponseIterator, ) @@ -3429,3 +3432,80 @@ def test_vertex_ai_traffic_type_surfaced_in_responses_api(): assert responses_api_response.provider_specific_fields["traffic_type"] == "ON_DEMAND" + +def test_vertex_ai_web_search_options_parameter(): + """ + Test that web_search_options parameter is transformed to googleSearch tool. + + When a user provides web_search_options as a parameter (not as a tool in the tools array), + it should be transformed to Gemini's googleSearch tool. + + This is important for the /v1/messages -> chat/completions -> Gemini flow: + - Anthropic web search tool -> web_search_options parameter -> Gemini googleSearch tool + + Input (optional_params): + {"web_search_options": {}} + + Expected Output: + tools=[{"googleSearch": {}}] + """ + from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( + VertexGeminiConfig, + ) + + v = VertexGeminiConfig() + + # Simulate the map_openai_params flow + optional_params = {} + + # When web_search_options is present, it should be mapped to a tool + web_search_options = {} + _tools = v._map_web_search_options(web_search_options) + + # Verify the tool is a googleSearch tool + assert "googleSearch" in _tools, f"Expected googleSearch in tool, got {_tools.keys()}" + assert _tools["googleSearch"] == {}, f"Expected empty googleSearch config, got {_tools['googleSearch']}" + + +def test_vertex_ai_web_search_options_in_map_openai_params(): + """ + Test that web_search_options is properly handled in map_openai_params. + + This tests the full flow where web_search_options parameter is converted + to a googleSearch tool and added to optional_params. + + Input: + optional_params with web_search_options: {} + + Expected: + optional_params should have tools with googleSearch + """ + from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( + VertexGeminiConfig, + ) + + v = VertexGeminiConfig() + + # Simulate optional_params passed to map_openai_params + optional_params = { + "web_search_options": {} + } + + # Call the transformation that happens in map_openai_params + # Lines 1075-1079 in vertex_and_google_ai_studio_gemini.py (after fix) + web_search_value = optional_params.get("web_search_options") + if isinstance(web_search_value, dict): # Fixed: removed 'value and' check to support empty dicts + _tools = v._map_web_search_options(web_search_value) + # Simulate _add_tools_to_optional_params + optional_params = v._add_tools_to_optional_params(optional_params, [_tools]) + + # Remove web_search_options as it's been transformed + optional_params.pop("web_search_options", None) + + # Verify the transformation + assert "tools" in optional_params, "tools should be added to optional_params" + assert len(optional_params["tools"]) == 1, "Should have exactly one tool" + assert "googleSearch" in optional_params["tools"][0], "Tool should be googleSearch" + assert optional_params["tools"][0]["googleSearch"] == {}, "googleSearch should be empty config" + assert "web_search_options" not in optional_params, "web_search_options should be removed after transformation" +