From da65a155dca8ee73fb5d82b152eb6da6ea7f5c3d Mon Sep 17 00:00:00 2001 From: 0xxmemo Date: Mon, 6 Apr 2026 14:00:07 -0500 Subject: [PATCH 1/8] Enhance WebSearchInterceptionLogger to include api_base in search tool parameters - Updated the WebSearchInterceptionLogger to extract and utilize the api_base parameter from the search tool's litellm_params. - Modified the search execution logic to pass the api_base along with the search_provider to the asearch function, improving flexibility in API interactions. - Ensured backward compatibility by maintaining existing functionality while adding the new parameter handling. --- .../websearch_interception/handler.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/litellm/integrations/websearch_interception/handler.py b/litellm/integrations/websearch_interception/handler.py index 2e5a8734085..fc3a7a2cd91 100644 --- a/litellm/integrations/websearch_interception/handler.py +++ b/litellm/integrations/websearch_interception/handler.py @@ -805,8 +805,9 @@ class WebSearchInterceptionLogger(CustomLogger): ) llm_router = None - # Determine search provider from router's search_tools + # Determine search provider and api_base from router's search_tools search_provider: Optional[str] = None + api_base: Optional[str] = None if llm_router is not None and hasattr(llm_router, "search_tools"): if self.search_tool_name: # Find specific search tool by name @@ -817,9 +818,9 @@ class WebSearchInterceptionLogger(CustomLogger): ] if matching_tools: search_tool = matching_tools[0] - search_provider = search_tool.get("litellm_params", {}).get( - "search_provider" - ) + litellm_params = search_tool.get("litellm_params", {}) + search_provider = litellm_params.get("search_provider") + api_base = litellm_params.get("api_base") verbose_logger.debug( f"WebSearchInterception: Found search tool '{self.search_tool_name}' " f"with provider '{search_provider}'" @@ -833,9 +834,9 @@ class WebSearchInterceptionLogger(CustomLogger): # If no specific tool or not found, use first available if not search_provider and llm_router.search_tools: first_tool = llm_router.search_tools[0] - search_provider = first_tool.get("litellm_params", {}).get( - "search_provider" - ) + litellm_params = first_tool.get("litellm_params", {}) + search_provider = litellm_params.get("search_provider") + api_base = api_base or litellm_params.get("api_base") verbose_logger.debug( f"WebSearchInterception: Using first available search tool with provider '{search_provider}'" ) @@ -851,7 +852,7 @@ class WebSearchInterceptionLogger(CustomLogger): verbose_logger.debug( f"WebSearchInterception: Executing search for '{query}' using provider '{search_provider}'" ) - result = await litellm.asearch(query=query, search_provider=search_provider) + result = await litellm.asearch(query=query, search_provider=search_provider, api_base=api_base) # Format using transformation function search_result_text = WebSearchTransformation.format_search_response(result) From f7be3c1b77b6067690c8d062dd5814b50e63a144 Mon Sep 17 00:00:00 2001 From: 0xxmemo Date: Mon, 6 Apr 2026 15:11:22 -0500 Subject: [PATCH 2/8] Refactor WebSearchInterceptionLogger to standardize web search response format - Updated the WebSearchInterceptionLogger to execute web searches through the configured provider and return responses in a native Anthropic format. - Implemented structured parsing of search results into hits for the web_search_tool_result, ensuring compatibility with Claude Code's WebSearchTool parser. - Removed legacy checks for providers with native Anthropic Messages support, streamlining the short-circuiting logic for web search requests. --- .../websearch_interception/handler.py | 92 ++++++++++++------- 1 file changed, 61 insertions(+), 31 deletions(-) diff --git a/litellm/integrations/websearch_interception/handler.py b/litellm/integrations/websearch_interception/handler.py index fc3a7a2cd91..81850272579 100644 --- a/litellm/integrations/websearch_interception/handler.py +++ b/litellm/integrations/websearch_interception/handler.py @@ -81,11 +81,13 @@ class WebSearchInterceptionLogger(CustomLogger): Short-circuit web-search-only requests by executing the search directly. Claude Code sends web search as a separate, standalone /v1/messages - request with a simple prompt and only web_search tool(s). For providers - that don't natively support web search (e.g. github_copilot), there is - no need to route this through the backend LLM — we can detect the - pattern, execute the search via Tavily/Perplexity, and return a - synthetic Anthropic response immediately. + request with a simple prompt and only web_search tool(s). We execute + the search via the configured provider (SearXNG/Tavily/Perplexity) + and return a synthetic response in native Anthropic format + (server_tool_use + web_search_tool_result) so Claude Code's + WebSearchTool parser works correctly. + + All providers are handled uniformly through this single funnel. Args: model: Model name from the request @@ -108,28 +110,6 @@ class WebSearchInterceptionLogger(CustomLogger): ): return None - # Only short-circuit for providers without native Anthropic Messages - # support. Providers that have a BaseAnthropicMessagesConfig (bedrock, - # vertex_ai, azure_ai, anthropic) already use the agentic loop, which - # includes a follow-up LLM call to synthesize the answer from search - # results. Short-circuiting those would skip that synthesis step and - # return raw search text — a regression for existing users. - try: - provider_enum = LlmProviders(provider_str) - anthropic_config = ( - ProviderConfigManager.get_provider_anthropic_messages_config( - model=model, provider=provider_enum - ) - ) - if anthropic_config is not None: - verbose_logger.debug( - f"WebSearchInterception: Skipping short-circuit for {provider_str} " - "(provider has native Anthropic Messages support, using agentic loop)" - ) - return None - except (ValueError, Exception): - pass # unknown provider enum → safe to short-circuit - # All tools must be web search tools if not all(is_web_search_tool(t) for t in tools): return None @@ -157,21 +137,71 @@ class WebSearchInterceptionLogger(CustomLogger): ) search_result_text = f"Search failed: {e}" - # Build synthetic Anthropic response + # Parse search results into structured hits for web_search_tool_result + search_hits = [] + for block in search_result_text.split("\n\n"): + title, url, snippet = "", "", "" + for line in block.strip().splitlines(): + if line.startswith("Title: "): + title = line[7:] + elif line.startswith("URL: "): + url = line[5:] + elif line.startswith("Snippet: "): + snippet = line[9:] + if url: + hit: Dict[str, Any] = { + "type": "web_search_result", + "url": url, + "title": title or url, + "encrypted_content": "", + "page_age": None, + } + if snippet: + hit["snippet"] = snippet + search_hits.append(hit) + + tool_use_id = f"srvtoolu_{str(uuid.uuid4()).replace('-', '')[:24]}" + + # Build response in native Anthropic format so Claude Code's + # WebSearchTool parser sees server_tool_use + web_search_tool_result. + content: List[Dict[str, Any]] = [ + { + "type": "server_tool_use", + "id": tool_use_id, + "name": "web_search", + "input": {"query": query}, + }, + { + "type": "web_search_tool_result", + "tool_use_id": tool_use_id, + "content": search_hits, + }, + { + "type": "text", + "text": search_result_text, + }, + ] + response: Dict[str, Any] = { "id": f"msg_{str(uuid.uuid4())}", "type": "message", "role": "assistant", "model": model, - "content": [{"type": "text", "text": search_result_text}], + "content": content, "stop_reason": "end_turn", "stop_sequence": None, - "usage": {"input_tokens": 0, "output_tokens": 0}, + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "server_tool_use": { + "web_search_requests": 1, + }, + }, } verbose_logger.debug( "WebSearchInterception: Short-circuit search completed, " - f"returning synthetic response ({len(search_result_text)} chars)" + f"returning native format ({len(search_hits)} hits)" ) return response From d210bd4c4ea4bd1d30893d9e43c4a1411e5b4eb6 Mon Sep 17 00:00:00 2001 From: 0xxmemo Date: Mon, 6 Apr 2026 15:38:20 -0500 Subject: [PATCH 3/8] Update web search tool naming and enhance tool definitions - Changed the standard web search tool name from "litellm_web_search" to "WebSearch" for consistency. - Updated the documentation and descriptions in the web search tool functions to reflect the new naming and improve clarity. - Enhanced the logic for identifying web search tools to accommodate both the new and legacy names, ensuring compatibility across different formats. --- litellm/constants.py | 2 +- .../websearch_interception/tools.py | 75 +++++++------------ .../websearch_interception/transformation.py | 3 +- 3 files changed, 32 insertions(+), 48 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index 1af53b2dae0..0fcbc499ad8 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -468,7 +468,7 @@ ANTHROPIC_WEB_SEARCH_TOOL_MAX_USES = { # LiteLLM standard web search tool name # Used for web search interception across providers -LITELLM_WEB_SEARCH_TOOL_NAME = "litellm_web_search" +LITELLM_WEB_SEARCH_TOOL_NAME = "WebSearch" DEFAULT_IMAGE_ENDPOINT_MODEL = "dall-e-2" DEFAULT_VIDEO_ENDPOINT_MODEL = "sora-2" diff --git a/litellm/integrations/websearch_interception/tools.py b/litellm/integrations/websearch_interception/tools.py index e373b64cdda..96ee97a594f 100644 --- a/litellm/integrations/websearch_interception/tools.py +++ b/litellm/integrations/websearch_interception/tools.py @@ -13,36 +13,28 @@ from litellm.constants import LITELLM_WEB_SEARCH_TOOL_NAME def get_litellm_web_search_tool() -> Dict[str, Any]: """ - Get the standard LiteLLM web search tool definition. + Get the web search tool definition in Anthropic format. - This is the canonical tool definition that all native web search tools - (like Anthropic's web_search_20250305, Claude Code's web_search, etc.) - are converted to for interception. + Uses the same name and schema that Claude Code expects so it appears + as the native WebSearch tool in the client. Returns: - Dict containing the Anthropic-style tool definition with: - - name: Tool name - - description: What the tool does - - input_schema: JSON schema for tool parameters - - Example: - >>> tool = get_litellm_web_search_tool() - >>> tool['name'] - 'litellm_web_search' + Dict containing the Anthropic-style tool definition. """ return { "name": LITELLM_WEB_SEARCH_TOOL_NAME, "description": ( - "Search the web for information. Use this when you need current " - "information or answers to questions that require up-to-date data." + "Search the web for current information. Returns search results " + "with titles, URLs, and snippets. Use this tool when you need " + "up-to-date information beyond your knowledge cutoff." ), "input_schema": { "type": "object", "properties": { "query": { "type": "string", - "description": "The search query to execute", - } + "description": "The search query to use", + }, }, "required": ["query"], }, @@ -51,7 +43,7 @@ def get_litellm_web_search_tool() -> Dict[str, Any]: def get_litellm_web_search_tool_openai() -> Dict[str, Any]: """ - Get the standard LiteLLM web search tool definition in OpenAI format. + Get the web search tool definition in OpenAI format. Used by async_pre_call_deployment_hook which runs in the chat completions path where tools must be in OpenAI format (type: "function" with @@ -65,16 +57,17 @@ def get_litellm_web_search_tool_openai() -> Dict[str, Any]: "function": { "name": LITELLM_WEB_SEARCH_TOOL_NAME, "description": ( - "Search the web for information. Use this when you need current " - "information or answers to questions that require up-to-date data." + "Search the web for current information. Returns search results " + "with titles, URLs, and snippets. Use this tool when you need " + "up-to-date information beyond your knowledge cutoff." ), "parameters": { "type": "object", "properties": { "query": { "type": "string", - "description": "The search query to execute", - } + "description": "The search query to use", + }, }, "required": ["query"], }, @@ -82,45 +75,35 @@ def get_litellm_web_search_tool_openai() -> Dict[str, Any]: } +_WEB_SEARCH_NAMES = {LITELLM_WEB_SEARCH_TOOL_NAME, "WebSearch", "web_search", "litellm_web_search"} + + def is_web_search_tool_chat_completion(tool: Dict[str, Any]) -> bool: """ - Check if a tool is a web search tool for Chat Completions API (strict check). + Check if a tool is a web search tool for Chat Completions API. - This is a stricter version that ONLY checks for the exact LiteLLM web search tool name. - Use this for Chat Completions API to avoid false positives with user-defined tools. - - Detects ONLY: - - LiteLLM standard: name == "litellm_web_search" (Anthropic format) - - OpenAI format: type == "function" with function.name == "litellm_web_search" + Detects: + - Anthropic format: name in {litellm_web_search, WebSearch, web_search} + - OpenAI format: type == "function" with function.name in same set Args: tool: Tool dictionary to check Returns: - True if tool is exactly the LiteLLM web search tool - - Example: - >>> is_web_search_tool_chat_completion({"name": "litellm_web_search"}) - True - >>> is_web_search_tool_chat_completion({"type": "function", "function": {"name": "litellm_web_search"}}) - True - >>> is_web_search_tool_chat_completion({"name": "web_search"}) - False - >>> is_web_search_tool_chat_completion({"name": "WebSearch"}) - False + True if tool is a web search tool """ tool_name = tool.get("name", "") tool_type = tool.get("type", "") - # Check for OpenAI format: {"type": "function", "function": {"name": "litellm_web_search"}} + # Check for OpenAI format if tool_type == "function" and "function" in tool: function_def = tool.get("function", {}) function_name = function_def.get("name", "") - if function_name == LITELLM_WEB_SEARCH_TOOL_NAME: + if function_name in _WEB_SEARCH_NAMES: return True - # Check for LiteLLM standard tool (Anthropic format) - if tool_name == LITELLM_WEB_SEARCH_TOOL_NAME: + # Check for Anthropic format + if tool_name in _WEB_SEARCH_NAMES: return True return False @@ -175,8 +158,8 @@ def is_web_search_tool(tool: Dict[str, Any]) -> bool: if tool_name == "web_search" and tool_type: return True - # Check for legacy WebSearch format - if tool_name == "WebSearch": + # Check for legacy names + if tool_name in ("WebSearch", "litellm_web_search"): return True return False diff --git a/litellm/integrations/websearch_interception/transformation.py b/litellm/integrations/websearch_interception/transformation.py index f777a7d7418..0a3876ae8bf 100644 --- a/litellm/integrations/websearch_interception/transformation.py +++ b/litellm/integrations/websearch_interception/transformation.py @@ -100,11 +100,11 @@ class WebSearchTransformation: block_input = getattr(block, "input", {}) # Check for LiteLLM standard or legacy web search tools - # Handles: litellm_web_search, WebSearch, web_search if block_type == "tool_use" and block_name in ( LITELLM_WEB_SEARCH_TOOL_NAME, "WebSearch", "web_search", + "litellm_web_search", ): # Convert to dict for easier handling tool_call = { @@ -194,6 +194,7 @@ class WebSearchTransformation: LITELLM_WEB_SEARCH_TOOL_NAME, "WebSearch", "web_search", + "litellm_web_search", ): # Parse arguments (might be JSON string) if isinstance(function_arguments, str): From cf6c277386295090a411f0ea6040d71b390dddef Mon Sep 17 00:00:00 2001 From: 0xxmemo Date: Mon, 6 Apr 2026 15:56:14 -0500 Subject: [PATCH 4/8] Add support for server_tool_use and web_search_tool_result in FakeAnthropicMessagesStreamIterator - Implemented handling for "server_tool_use" and "web_search_tool_result" block types in the FakeAnthropicMessagesStreamIterator. - Added logic to emit content_block_start and content_block_stop events for both block types, aligning with Anthropic's native streaming format. - Enhanced the iterator's functionality to support new message types, improving integration with external tools. --- .../messages/fake_stream_iterator.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/fake_stream_iterator.py b/litellm/llms/anthropic/experimental_pass_through/messages/fake_stream_iterator.py index 7fc9b00f2c7..9dde357f4ee 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/fake_stream_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/fake_stream_iterator.py @@ -169,6 +169,42 @@ class FakeAnthropicMessagesStreamIterator: block_dict = cast(Dict[str, Any], block) chunks.extend(self._create_content_block_chunks(block_dict, index)) + elif block_type == "server_tool_use": + # Emit the full block as content_block_start (same as + # Anthropic's native streaming format). + content_block_start = { + "type": "content_block_start", + "index": index, + "content_block": block_dict, + } + chunks.append( + f"event: content_block_start\ndata: {json.dumps(content_block_start)}\n\n".encode() + ) + + # content_block_stop + content_block_stop = {"type": "content_block_stop", "index": index} + chunks.append( + f"event: content_block_stop\ndata: {json.dumps(content_block_stop)}\n\n".encode() + ) + + elif block_type == "web_search_tool_result": + # Emit the full block as content_block_start (same as + # Anthropic's native streaming format). + content_block_start = { + "type": "content_block_start", + "index": index, + "content_block": block_dict, + } + chunks.append( + f"event: content_block_start\ndata: {json.dumps(content_block_start)}\n\n".encode() + ) + + # content_block_stop + content_block_stop = {"type": "content_block_stop", "index": index} + chunks.append( + f"event: content_block_stop\ndata: {json.dumps(content_block_stop)}\n\n".encode() + ) + # 5. message_delta event (with final usage and stop_reason) # Include cache usage fields so clients that only read message_delta # (like Claude Code's SDK) see the full input token breakdown. From 0379a0fe9b25d72d4bb320e70266b5f12e5a055c Mon Sep 17 00:00:00 2001 From: 0xxmemo Date: Mon, 6 Apr 2026 16:09:30 -0500 Subject: [PATCH 5/8] Update tests and fix FakeAnthropicMessagesStreamIterator for native format - Update test_websearch_short_circuit.py to validate native Anthropic response format (server_tool_use + web_search_tool_result) instead of plain text - Add tests for structured search hits, tool_use_id linking, usage tracking, and uniform provider handling - Fix server_tool_use/web_search_tool_result block handling in _create_content_block_chunks method (was orphaned from cherry-pick) Co-Authored-By: Claude Opus 4.6 --- .../messages/fake_stream_iterator.py | 48 +--- .../test_websearch_short_circuit.py | 211 +++++++++++------- 2 files changed, 147 insertions(+), 112 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/fake_stream_iterator.py b/litellm/llms/anthropic/experimental_pass_through/messages/fake_stream_iterator.py index 9dde357f4ee..12fd5ba6f91 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/fake_stream_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/fake_stream_iterator.py @@ -128,6 +128,18 @@ class FakeAnthropicMessagesStreamIterator: f"event: content_block_delta\ndata: {json.dumps(content_block_delta)}\n\n".encode() ) + elif block_type in ("server_tool_use", "web_search_tool_result"): + # Emit the full block as content_block_start — same as + # Anthropic's native streaming format for server-side tools. + content_block_start = { + "type": "content_block_start", + "index": index, + "content_block": block_dict, + } + chunks.append( + f"event: content_block_start\ndata: {json.dumps(content_block_start)}\n\n".encode() + ) + content_block_stop = {"type": "content_block_stop", "index": index} chunks.append( f"event: content_block_stop\ndata: {json.dumps(content_block_stop)}\n\n".encode() @@ -169,42 +181,6 @@ class FakeAnthropicMessagesStreamIterator: block_dict = cast(Dict[str, Any], block) chunks.extend(self._create_content_block_chunks(block_dict, index)) - elif block_type == "server_tool_use": - # Emit the full block as content_block_start (same as - # Anthropic's native streaming format). - content_block_start = { - "type": "content_block_start", - "index": index, - "content_block": block_dict, - } - chunks.append( - f"event: content_block_start\ndata: {json.dumps(content_block_start)}\n\n".encode() - ) - - # content_block_stop - content_block_stop = {"type": "content_block_stop", "index": index} - chunks.append( - f"event: content_block_stop\ndata: {json.dumps(content_block_stop)}\n\n".encode() - ) - - elif block_type == "web_search_tool_result": - # Emit the full block as content_block_start (same as - # Anthropic's native streaming format). - content_block_start = { - "type": "content_block_start", - "index": index, - "content_block": block_dict, - } - chunks.append( - f"event: content_block_start\ndata: {json.dumps(content_block_start)}\n\n".encode() - ) - - # content_block_stop - content_block_stop = {"type": "content_block_stop", "index": index} - chunks.append( - f"event: content_block_stop\ndata: {json.dumps(content_block_stop)}\n\n".encode() - ) - # 5. message_delta event (with final usage and stop_reason) # Include cache usage fields so clients that only read message_delta # (like Claude Code's SDK) see the full input token breakdown. diff --git a/tests/test_litellm/integrations/websearch_interception/test_websearch_short_circuit.py b/tests/test_litellm/integrations/websearch_interception/test_websearch_short_circuit.py index 82c1c9839e7..8a81d736f16 100644 --- a/tests/test_litellm/integrations/websearch_interception/test_websearch_short_circuit.py +++ b/tests/test_litellm/integrations/websearch_interception/test_websearch_short_circuit.py @@ -3,6 +3,9 @@ Unit tests for WebSearch Short-Circuit Tests the short-circuit path that detects web-search-only /v1/messages requests and executes the search directly without routing through the backend LLM. + +The response uses native Anthropic format (server_tool_use + web_search_tool_result) +so Claude Code's WebSearchTool parser works correctly. """ from unittest.mock import AsyncMock, patch @@ -48,11 +51,103 @@ class TestTryShortCircuitSearch: assert result["type"] == "message" assert result["role"] == "assistant" assert result["stop_reason"] == "end_turn" - assert len(result["content"]) == 1 - assert result["content"][0]["type"] == "text" - assert "Result" in result["content"][0]["text"] + # Native format: server_tool_use + web_search_tool_result + text + assert result["content"][0]["type"] == "server_tool_use" + assert result["content"][0]["name"] == "web_search" + assert result["content"][1]["type"] == "web_search_tool_result" + assert result["content"][2]["type"] == "text" + assert "Result" in result["content"][2]["text"] mock_search.assert_called_once_with("Search for Claude Code releases") + @pytest.mark.asyncio + async def test_native_format_search_hits(self): + """Search results are structured as web_search_result hits""" + logger = WebSearchInterceptionLogger(enabled_providers=["anthropic"]) + + with patch.object( + logger, "_execute_search", new_callable=AsyncMock + ) as mock_search: + mock_search.return_value = ( + "Title: First Result\nURL: https://example.com/1\nSnippet: first\n\n" + "Title: Second Result\nURL: https://example.com/2\nSnippet: second" + ) + + result = await logger.try_short_circuit_search( + model="anthropic/claude-sonnet-4", + messages=[{"role": "user", "content": "Search query"}], + tools=[{"type": "web_search_20250305", "name": "web_search"}], + custom_llm_provider="anthropic", + ) + + assert result is not None + hits = result["content"][1]["content"] + assert len(hits) == 2 + assert hits[0]["type"] == "web_search_result" + assert hits[0]["url"] == "https://example.com/1" + assert hits[0]["title"] == "First Result" + assert hits[1]["url"] == "https://example.com/2" + + @pytest.mark.asyncio + async def test_server_tool_use_has_query(self): + """server_tool_use block contains the original search query""" + logger = WebSearchInterceptionLogger(enabled_providers=["anthropic"]) + + with patch.object( + logger, "_execute_search", new_callable=AsyncMock + ) as mock_search: + mock_search.return_value = "Title: R\nURL: https://x.com\nSnippet: s" + + result = await logger.try_short_circuit_search( + model="anthropic/claude-sonnet-4", + messages=[{"role": "user", "content": "trending AI topics"}], + tools=[{"type": "web_search_20250305", "name": "web_search"}], + custom_llm_provider="anthropic", + ) + + stu = result["content"][0] + assert stu["type"] == "server_tool_use" + assert stu["name"] == "web_search" + assert stu["input"]["query"] == "trending AI topics" + assert stu["id"].startswith("srvtoolu_") + + @pytest.mark.asyncio + async def test_tool_use_id_links_blocks(self): + """server_tool_use.id matches web_search_tool_result.tool_use_id""" + logger = WebSearchInterceptionLogger(enabled_providers=["anthropic"]) + + with patch.object( + logger, "_execute_search", new_callable=AsyncMock + ) as mock_search: + mock_search.return_value = "Title: R\nURL: https://x.com\nSnippet: s" + + result = await logger.try_short_circuit_search( + model="anthropic/claude-sonnet-4", + messages=[{"role": "user", "content": "query"}], + tools=[{"type": "web_search_20250305", "name": "web_search"}], + custom_llm_provider="anthropic", + ) + + assert result["content"][0]["id"] == result["content"][1]["tool_use_id"] + + @pytest.mark.asyncio + async def test_usage_includes_web_search_requests(self): + """Usage includes server_tool_use.web_search_requests count""" + logger = WebSearchInterceptionLogger(enabled_providers=["anthropic"]) + + with patch.object( + logger, "_execute_search", new_callable=AsyncMock + ) as mock_search: + mock_search.return_value = "Title: R\nURL: https://x.com\nSnippet: s" + + result = await logger.try_short_circuit_search( + model="anthropic/claude-sonnet-4", + messages=[{"role": "user", "content": "query"}], + tools=[{"type": "web_search_20250305", "name": "web_search"}], + custom_llm_provider="anthropic", + ) + + assert result["usage"]["server_tool_use"]["web_search_requests"] == 1 + @pytest.mark.asyncio async def test_does_not_short_circuit_mixed_tools(self): """Mix of web_search and other tools → NOT short-circuited""" @@ -115,27 +210,26 @@ class TestTryShortCircuitSearch: assert result is None @pytest.mark.asyncio - async def test_does_not_short_circuit_bedrock(self): - """Bedrock has native agentic loop support → NOT short-circuited. + async def test_short_circuits_all_enabled_providers_uniformly(self): + """All enabled providers (including anthropic, bedrock) go through + the same short-circuit funnel — no provider is skipped.""" + for provider in ["anthropic", "bedrock", "github_copilot"]: + logger = WebSearchInterceptionLogger(enabled_providers=[provider]) - Providers with a BaseAnthropicMessagesConfig (bedrock, vertex_ai, etc.) - use the agentic loop which includes a follow-up LLM synthesis step. - The short-circuit must not fire for them. - """ - logger = WebSearchInterceptionLogger( - enabled_providers=["bedrock", "github_copilot"] - ) + with patch.object( + logger, "_execute_search", new_callable=AsyncMock + ) as mock_search: + mock_search.return_value = "Title: R\nURL: https://x.com\nSnippet: s" - result = await logger.try_short_circuit_search( - model="bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0", - messages=[{"role": "user", "content": "Search for something"}], - tools=[ - {"type": "web_search_20250305", "name": "web_search", "max_uses": 8} - ], - custom_llm_provider="bedrock", - ) + result = await logger.try_short_circuit_search( + model=f"{provider}/claude-sonnet-4", + messages=[{"role": "user", "content": "search query"}], + tools=[{"type": "web_search_20250305", "name": "web_search"}], + custom_llm_provider=provider, + ) - assert result is None + assert result is not None, f"Short-circuit should fire for {provider}" + assert result["content"][0]["type"] == "server_tool_use" @pytest.mark.asyncio async def test_does_not_short_circuit_no_messages(self): @@ -173,7 +267,10 @@ class TestTryShortCircuitSearch: ) assert result is not None - assert "Search failed" in result["content"][0]["text"] + # Error text is in the last content block (text) + text_block = result["content"][-1] + assert text_block["type"] == "text" + assert "Search failed" in text_block["text"] @pytest.mark.asyncio async def test_response_has_valid_structure(self): @@ -183,7 +280,7 @@ class TestTryShortCircuitSearch: with patch.object( logger, "_execute_search", new_callable=AsyncMock ) as mock_search: - mock_search.return_value = "search results here" + mock_search.return_value = "Title: R\nURL: https://x.com\nSnippet: test" result = await logger.try_short_circuit_search( model="github_copilot/claude-sonnet-4", @@ -203,11 +300,8 @@ class TestTryShortCircuitSearch: assert result["stop_sequence"] is None assert "usage" in result assert "content" in result - - -# --------------------------------------------------------------------------- -# Query extraction tests -# --------------------------------------------------------------------------- + # Content has 3 blocks: server_tool_use, web_search_tool_result, text + assert len(result["content"]) == 3 # --------------------------------------------------------------------------- @@ -237,7 +331,7 @@ class TestShortCircuitEntryPoint: @pytest.mark.asyncio async def test_returns_dict_when_not_streaming(self): - """Non-streaming short-circuit → returns dict""" + """Non-streaming short-circuit → returns dict with native format""" from litellm.llms.anthropic.experimental_pass_through.messages.handler import ( _try_websearch_short_circuit, ) @@ -246,7 +340,7 @@ class TestShortCircuitEntryPoint: with patch.object( logger, "_execute_search", new_callable=AsyncMock ) as mock_search: - mock_search.return_value = "results" + mock_search.return_value = "Title: R\nURL: https://x.com\nSnippet: results" with patch("litellm.callbacks", [logger]): result = await _try_websearch_short_circuit( model="github_copilot/claude-sonnet-4", @@ -257,7 +351,8 @@ class TestShortCircuitEntryPoint: ) assert isinstance(result, dict) - assert result["content"][0]["text"] == "results" + assert result["content"][0]["type"] == "server_tool_use" + assert result["content"][1]["type"] == "web_search_tool_result" @pytest.mark.asyncio async def test_returns_stream_iterator_when_streaming(self): @@ -273,7 +368,9 @@ class TestShortCircuitEntryPoint: with patch.object( logger, "_execute_search", new_callable=AsyncMock ) as mock_search: - mock_search.return_value = "streaming results" + mock_search.return_value = ( + "Title: Result\nURL: https://example.com\nSnippet: streaming results" + ) with patch("litellm.callbacks", [logger]): result = await _try_websearch_short_circuit( model="github_copilot/claude-sonnet-4", @@ -291,12 +388,12 @@ class TestShortCircuitEntryPoint: chunks.append(chunk) assert len(chunks) > 0 - # First chunk should be message_start assert b"event: message_start" in chunks[0] - # Last chunk should be message_stop assert b"event: message_stop" in chunks[-1] - # Should contain the search results text + # Should contain server_tool_use and web_search_tool_result blocks all_data = b"".join(chunks) + assert b"server_tool_use" in all_data + assert b"web_search_tool_result" in all_data assert b"streaming results" in all_data @pytest.mark.asyncio @@ -321,12 +418,7 @@ class TestShortCircuitEntryPoint: @pytest.mark.asyncio async def test_uses_original_stream_not_hook_converted(self): - """Verify that the entry point passes original_stream to the short-circuit. - - The pre-request hook converts stream=True → stream=False for the agentic - loop. The short-circuit must use the ORIGINAL stream value so streaming - callers get SSE events instead of a plain dict. - """ + """Verify that the entry point passes original_stream to the short-circuit.""" from litellm.llms.anthropic.experimental_pass_through.messages.fake_stream_iterator import ( FakeAnthropicMessagesStreamIterator, ) @@ -338,47 +430,14 @@ class TestShortCircuitEntryPoint: with patch.object( logger, "_execute_search", new_callable=AsyncMock ) as mock_search: - mock_search.return_value = "streaming results" + mock_search.return_value = "Title: R\nURL: https://x.com\nSnippet: s" with patch("litellm.callbacks", [logger]): - # Simulate what anthropic_messages() does: original_stream=True - # is passed to the short-circuit, even though the hook would have - # already converted stream to False in request_kwargs. result = await _try_websearch_short_circuit( model="github_copilot/claude-sonnet-4", messages=[{"role": "user", "content": "search query"}], tools=[{"type": "web_search_20250305", "name": "web_search"}], custom_llm_provider="github_copilot", - stream=True, # original_stream, NOT the hook-converted value + stream=True, ) - # Must return a stream iterator, not a plain dict assert isinstance(result, FakeAnthropicMessagesStreamIterator) - - @pytest.mark.asyncio - async def test_short_circuits_with_provider_from_model_string(self): - """Provider embedded in model string (custom_llm_provider=None) should - still fire the short-circuit when the caller propagates the derived - provider. - """ - from litellm.llms.anthropic.experimental_pass_through.messages.handler import ( - _try_websearch_short_circuit, - ) - - logger = WebSearchInterceptionLogger(enabled_providers=["github_copilot"]) - with patch.object( - logger, "_execute_search", new_callable=AsyncMock - ) as mock_search: - mock_search.return_value = "results" - with patch("litellm.callbacks", [logger]): - # Simulate the caller having derived custom_llm_provider from - # the model string before calling _try_websearch_short_circuit - result = await _try_websearch_short_circuit( - model="github_copilot/claude-sonnet-4", - messages=[{"role": "user", "content": "search query"}], - tools=[{"type": "web_search_20250305", "name": "web_search"}], - custom_llm_provider="github_copilot", - stream=False, - ) - - assert result is not None - assert result["content"][0]["text"] == "results" From 05ddb2eeb504688463737f6c4fd8072ed3c4bb01 Mon Sep 17 00:00:00 2001 From: 0xxmemo Date: Mon, 6 Apr 2026 16:12:14 -0500 Subject: [PATCH 6/8] Restore native provider skip for upstream compatibility Providers with native Anthropic Messages support (anthropic, bedrock, vertex_ai) skip the short-circuit and let their API handle web_search_20250305 natively. Only non-native providers (github_copilot, etc.) get the synthetic native-format response. This preserves upstream behavior: Anthropic's API handles web search server-side for providers that support it. Co-Authored-By: Claude Opus 4.6 --- .../websearch_interception/handler.py | 33 +++++++-- litellm/llms/kimi_code/__init__.py | 1 + litellm/llms/kimi_code/chat/__init__.py | 1 + litellm/llms/local/__init__.py | 0 litellm/llms/local/embedding/__init__.py | 0 litellm/llms/local/transcription/__init__.py | 0 litellm/llms/qwen_portal/__init__.py | 0 .../test_websearch_short_circuit.py | 74 +++++++++++-------- 8 files changed, 74 insertions(+), 35 deletions(-) create mode 100644 litellm/llms/kimi_code/__init__.py create mode 100644 litellm/llms/kimi_code/chat/__init__.py create mode 100644 litellm/llms/local/__init__.py create mode 100644 litellm/llms/local/embedding/__init__.py create mode 100644 litellm/llms/local/transcription/__init__.py create mode 100644 litellm/llms/qwen_portal/__init__.py diff --git a/litellm/integrations/websearch_interception/handler.py b/litellm/integrations/websearch_interception/handler.py index 81850272579..f72244a1e8a 100644 --- a/litellm/integrations/websearch_interception/handler.py +++ b/litellm/integrations/websearch_interception/handler.py @@ -81,13 +81,15 @@ class WebSearchInterceptionLogger(CustomLogger): Short-circuit web-search-only requests by executing the search directly. Claude Code sends web search as a separate, standalone /v1/messages - request with a simple prompt and only web_search tool(s). We execute - the search via the configured provider (SearXNG/Tavily/Perplexity) - and return a synthetic response in native Anthropic format - (server_tool_use + web_search_tool_result) so Claude Code's - WebSearchTool parser works correctly. + request with a simple prompt and only web_search tool(s). For providers + that don't natively support web search, we execute the search via the + configured provider (SearXNG/Tavily/Perplexity) and return a synthetic + response in native Anthropic format (server_tool_use + + web_search_tool_result) so Claude Code's WebSearchTool parser works. - All providers are handled uniformly through this single funnel. + Providers with native Anthropic Messages support (anthropic, bedrock, + vertex_ai, azure_ai) are skipped — their API handles web search + natively and returns the correct format already. Args: model: Model name from the request @@ -110,6 +112,25 @@ class WebSearchInterceptionLogger(CustomLogger): ): return None + # Skip providers with native Anthropic Messages support — their API + # handles web_search_20250305 natively, returning server_tool_use + + # web_search_tool_result in the correct format already. + try: + provider_enum = LlmProviders(provider_str) + anthropic_config = ( + ProviderConfigManager.get_provider_anthropic_messages_config( + model=model, provider=provider_enum + ) + ) + if anthropic_config is not None: + verbose_logger.debug( + f"WebSearchInterception: Skipping short-circuit for {provider_str} " + "(provider has native web search support)" + ) + return None + except (ValueError, Exception): + pass # unknown provider enum → safe to short-circuit + # All tools must be web search tools if not all(is_web_search_tool(t) for t in tools): return None diff --git a/litellm/llms/kimi_code/__init__.py b/litellm/llms/kimi_code/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/litellm/llms/kimi_code/__init__.py @@ -0,0 +1 @@ + diff --git a/litellm/llms/kimi_code/chat/__init__.py b/litellm/llms/kimi_code/chat/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/litellm/llms/kimi_code/chat/__init__.py @@ -0,0 +1 @@ + diff --git a/litellm/llms/local/__init__.py b/litellm/llms/local/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/local/embedding/__init__.py b/litellm/llms/local/embedding/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/local/transcription/__init__.py b/litellm/llms/local/transcription/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/qwen_portal/__init__.py b/litellm/llms/qwen_portal/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/integrations/websearch_interception/test_websearch_short_circuit.py b/tests/test_litellm/integrations/websearch_interception/test_websearch_short_circuit.py index 8a81d736f16..8ec2dcc2115 100644 --- a/tests/test_litellm/integrations/websearch_interception/test_websearch_short_circuit.py +++ b/tests/test_litellm/integrations/websearch_interception/test_websearch_short_circuit.py @@ -62,7 +62,7 @@ class TestTryShortCircuitSearch: @pytest.mark.asyncio async def test_native_format_search_hits(self): """Search results are structured as web_search_result hits""" - logger = WebSearchInterceptionLogger(enabled_providers=["anthropic"]) + logger = WebSearchInterceptionLogger(enabled_providers=["github_copilot"]) with patch.object( logger, "_execute_search", new_callable=AsyncMock @@ -73,10 +73,10 @@ class TestTryShortCircuitSearch: ) result = await logger.try_short_circuit_search( - model="anthropic/claude-sonnet-4", + model="github_copilot/claude-sonnet-4", messages=[{"role": "user", "content": "Search query"}], tools=[{"type": "web_search_20250305", "name": "web_search"}], - custom_llm_provider="anthropic", + custom_llm_provider="github_copilot", ) assert result is not None @@ -90,7 +90,7 @@ class TestTryShortCircuitSearch: @pytest.mark.asyncio async def test_server_tool_use_has_query(self): """server_tool_use block contains the original search query""" - logger = WebSearchInterceptionLogger(enabled_providers=["anthropic"]) + logger = WebSearchInterceptionLogger(enabled_providers=["github_copilot"]) with patch.object( logger, "_execute_search", new_callable=AsyncMock @@ -98,10 +98,10 @@ class TestTryShortCircuitSearch: mock_search.return_value = "Title: R\nURL: https://x.com\nSnippet: s" result = await logger.try_short_circuit_search( - model="anthropic/claude-sonnet-4", + model="github_copilot/claude-sonnet-4", messages=[{"role": "user", "content": "trending AI topics"}], tools=[{"type": "web_search_20250305", "name": "web_search"}], - custom_llm_provider="anthropic", + custom_llm_provider="github_copilot", ) stu = result["content"][0] @@ -113,7 +113,7 @@ class TestTryShortCircuitSearch: @pytest.mark.asyncio async def test_tool_use_id_links_blocks(self): """server_tool_use.id matches web_search_tool_result.tool_use_id""" - logger = WebSearchInterceptionLogger(enabled_providers=["anthropic"]) + logger = WebSearchInterceptionLogger(enabled_providers=["github_copilot"]) with patch.object( logger, "_execute_search", new_callable=AsyncMock @@ -121,10 +121,10 @@ class TestTryShortCircuitSearch: mock_search.return_value = "Title: R\nURL: https://x.com\nSnippet: s" result = await logger.try_short_circuit_search( - model="anthropic/claude-sonnet-4", + model="github_copilot/claude-sonnet-4", messages=[{"role": "user", "content": "query"}], tools=[{"type": "web_search_20250305", "name": "web_search"}], - custom_llm_provider="anthropic", + custom_llm_provider="github_copilot", ) assert result["content"][0]["id"] == result["content"][1]["tool_use_id"] @@ -132,7 +132,7 @@ class TestTryShortCircuitSearch: @pytest.mark.asyncio async def test_usage_includes_web_search_requests(self): """Usage includes server_tool_use.web_search_requests count""" - logger = WebSearchInterceptionLogger(enabled_providers=["anthropic"]) + logger = WebSearchInterceptionLogger(enabled_providers=["github_copilot"]) with patch.object( logger, "_execute_search", new_callable=AsyncMock @@ -140,10 +140,10 @@ class TestTryShortCircuitSearch: mock_search.return_value = "Title: R\nURL: https://x.com\nSnippet: s" result = await logger.try_short_circuit_search( - model="anthropic/claude-sonnet-4", + model="github_copilot/claude-sonnet-4", messages=[{"role": "user", "content": "query"}], tools=[{"type": "web_search_20250305", "name": "web_search"}], - custom_llm_provider="anthropic", + custom_llm_provider="github_copilot", ) assert result["usage"]["server_tool_use"]["web_search_requests"] == 1 @@ -210,26 +210,42 @@ class TestTryShortCircuitSearch: assert result is None @pytest.mark.asyncio - async def test_short_circuits_all_enabled_providers_uniformly(self): - """All enabled providers (including anthropic, bedrock) go through - the same short-circuit funnel — no provider is skipped.""" - for provider in ["anthropic", "bedrock", "github_copilot"]: - logger = WebSearchInterceptionLogger(enabled_providers=[provider]) + async def test_does_not_short_circuit_native_providers(self): + """Providers with native Anthropic Messages support (anthropic, bedrock, + vertex_ai) are skipped — their API handles web search natively.""" + for provider in ["anthropic", "bedrock"]: + logger = WebSearchInterceptionLogger( + enabled_providers=[provider, "github_copilot"] + ) - with patch.object( - logger, "_execute_search", new_callable=AsyncMock - ) as mock_search: - mock_search.return_value = "Title: R\nURL: https://x.com\nSnippet: s" + result = await logger.try_short_circuit_search( + model=f"{provider}/claude-sonnet-4", + messages=[{"role": "user", "content": "search query"}], + tools=[{"type": "web_search_20250305", "name": "web_search"}], + custom_llm_provider=provider, + ) - result = await logger.try_short_circuit_search( - model=f"{provider}/claude-sonnet-4", - messages=[{"role": "user", "content": "search query"}], - tools=[{"type": "web_search_20250305", "name": "web_search"}], - custom_llm_provider=provider, - ) + assert result is None, f"Short-circuit should NOT fire for native provider {provider}" - assert result is not None, f"Short-circuit should fire for {provider}" - assert result["content"][0]["type"] == "server_tool_use" + @pytest.mark.asyncio + async def test_short_circuits_non_native_providers(self): + """Non-native providers (github_copilot, etc.) get short-circuited.""" + logger = WebSearchInterceptionLogger(enabled_providers=["github_copilot"]) + + with patch.object( + logger, "_execute_search", new_callable=AsyncMock + ) as mock_search: + mock_search.return_value = "Title: R\nURL: https://x.com\nSnippet: s" + + result = await logger.try_short_circuit_search( + model="github_copilot/claude-sonnet-4", + messages=[{"role": "user", "content": "search query"}], + tools=[{"type": "web_search_20250305", "name": "web_search"}], + custom_llm_provider="github_copilot", + ) + + assert result is not None + assert result["content"][0]["type"] == "server_tool_use" @pytest.mark.asyncio async def test_does_not_short_circuit_no_messages(self): From 5d54adf2de28feb58ab5da1bb8588a8dec7a37f1 Mon Sep 17 00:00:00 2001 From: 0xxmemo Date: Mon, 6 Apr 2026 16:12:39 -0500 Subject: [PATCH 7/8] Remove fork-specific provider files from PR branch --- litellm/llms/kimi_code/__init__.py | 1 - litellm/llms/kimi_code/chat/__init__.py | 1 - litellm/llms/local/__init__.py | 0 litellm/llms/local/embedding/__init__.py | 0 litellm/llms/local/transcription/__init__.py | 0 litellm/llms/qwen_portal/__init__.py | 0 6 files changed, 2 deletions(-) delete mode 100644 litellm/llms/kimi_code/__init__.py delete mode 100644 litellm/llms/kimi_code/chat/__init__.py delete mode 100644 litellm/llms/local/__init__.py delete mode 100644 litellm/llms/local/embedding/__init__.py delete mode 100644 litellm/llms/local/transcription/__init__.py delete mode 100644 litellm/llms/qwen_portal/__init__.py diff --git a/litellm/llms/kimi_code/__init__.py b/litellm/llms/kimi_code/__init__.py deleted file mode 100644 index 8b137891791..00000000000 --- a/litellm/llms/kimi_code/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/litellm/llms/kimi_code/chat/__init__.py b/litellm/llms/kimi_code/chat/__init__.py deleted file mode 100644 index 8b137891791..00000000000 --- a/litellm/llms/kimi_code/chat/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/litellm/llms/local/__init__.py b/litellm/llms/local/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/litellm/llms/local/embedding/__init__.py b/litellm/llms/local/embedding/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/litellm/llms/local/transcription/__init__.py b/litellm/llms/local/transcription/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/litellm/llms/qwen_portal/__init__.py b/litellm/llms/qwen_portal/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 From 573b02a35f3bef1d870e60f82b756caca5927dcc Mon Sep 17 00:00:00 2001 From: 0xxmemo Date: Mon, 6 Apr 2026 16:33:36 -0500 Subject: [PATCH 8/8] Fix review feedback: encrypted_content and message ID format - Put snippet text in encrypted_content so the model can read page content from search results (empty string meant the model only saw titles/URLs) - Strip hyphens from synthetic message ID to match Anthropic's compact alphanumeric format (msg_ not msg_--...) Co-Authored-By: Claude Opus 4.6 --- litellm/integrations/websearch_interception/handler.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/litellm/integrations/websearch_interception/handler.py b/litellm/integrations/websearch_interception/handler.py index f72244a1e8a..4eb3571d5e9 100644 --- a/litellm/integrations/websearch_interception/handler.py +++ b/litellm/integrations/websearch_interception/handler.py @@ -174,11 +174,9 @@ class WebSearchInterceptionLogger(CustomLogger): "type": "web_search_result", "url": url, "title": title or url, - "encrypted_content": "", + "encrypted_content": snippet or "", "page_age": None, } - if snippet: - hit["snippet"] = snippet search_hits.append(hit) tool_use_id = f"srvtoolu_{str(uuid.uuid4()).replace('-', '')[:24]}" @@ -204,7 +202,7 @@ class WebSearchInterceptionLogger(CustomLogger): ] response: Dict[str, Any] = { - "id": f"msg_{str(uuid.uuid4())}", + "id": f"msg_{str(uuid.uuid4()).replace('-', '')[:20]}", "type": "message", "role": "assistant", "model": model,