Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions litellm/integrations/websearch_interception/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import asyncio
import math
import uuid
from typing import Any, Dict, List, Optional, Tuple, Union, cast

import litellm
Expand All @@ -28,6 +29,7 @@
WebSearchInterceptionConfig,
)
from litellm.types.utils import LlmProviders
from litellm.utils import ProviderConfigManager


class WebSearchInterceptionLogger(CustomLogger):
Expand Down Expand Up @@ -67,6 +69,111 @@ def __init__(
self.search_tool_name = search_tool_name
self._request_has_websearch = False # Track if current request has web search

async def try_short_circuit_search(
self,
model: str,
messages: List[Dict],
tools: Optional[List[Dict]],
custom_llm_provider: Optional[str],
) -> Optional[Dict[str, Any]]:
"""
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.

Args:
model: Model name from the request
messages: Messages list from the request
tools: Tools list from the request
custom_llm_provider: Provider name

Returns:
An AnthropicMessagesResponse dict if short-circuited, or None to
continue normal processing.
"""
if not tools:
return None

# Check if provider is in enabled list
provider_str = custom_llm_provider or ""
if (
self.enabled_providers is not None
and provider_str not in self.enabled_providers
):
return None
Comment on lines +102 to +108

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Short-circuit fires for bedrock (and all enabled_providers), bypassing the agentic loop

The provider guard uses enabled_providers, which defaults to ["bedrock"]. If a user has enabled_providers=["bedrock"] (the default) or enabled_providers=["bedrock", "github_copilot"], any bedrock request whose tools consist solely of web-search tools will hit this short-circuit and receive raw search text — skipping the follow-up LLM call that the existing agentic loop produces.

For bedrock the expected flow is:

  1. Intercept web-search tool_use in the model response
  2. Execute the search
  3. Make a follow-up request to the LLM with the results
  4. Return the LLM-generated answer

The short-circuit skips step 3 entirely, so callers get raw Tavily/Perplexity text instead of a synthesized answer. This is a behavioural regression for any existing bedrock user who has deployed WebSearchInterceptionLogger.

The PR description says "zero regression risk for Bedrock/Vertex" but the code does not include a guard to restrict short-circuiting to providers that genuinely lack native web-search support (e.g., a separate short_circuit_providers list or a capability flag in model_prices_and_context_window.json).

# Example: introduce a separate set to distinguish short-circuit providers
# from agentic-loop providers
if provider_str not in self.short_circuit_providers:
    return None

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added a guard that checks ProviderConfigManager.get_provider_anthropic_messages_config(). Providers with native support (bedrock, vertex_ai, etc.) skip the short-circuit and use the existing agentic loop.


# 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
Comment on lines +132 to +134

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Short-circuit fires for any provider when enabled_providers includes None-like strings

is_web_search_tool(t) correctly recognises both web_search_20250305-typed tools AND tools already converted by async_pre_request_hook to the litellm_web_search format. However, the short-circuit is only meant for providers that cannot natively handle web search (like github_copilot). For providers that ARE listed in enabled_providers and DO handle the Anthropic agentic loop (e.g., bedrock), hitting this short-circuit would bypass the full agentic loop that these providers rely on.

The root of the issue is that enabled_providers serves double duty: it controls both the existing agentic-loop path and now the new short-circuit path. A bedrock user who configured enabled_providers=["bedrock"] would have their web-search requests short-circuited by this new code, skipping the follow-up LLM call that the agentic loop is designed to produce.

Consider introducing a separate short_circuit_providers list (or a flag like short_circuit_unsupported=True) to distinguish between providers where the existing agentic loop should run versus providers where the short-circuit is appropriate.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the short-circuit now checks get_provider_anthropic_messages_config() and skips providers with native agentic loop support.


# Extract search query from the last user message
from litellm.litellm_core_utils.prompt_templates.common_utils import (
get_last_user_message,
)

query = get_last_user_message(messages)
if not query:
return None
Comment on lines +99 to +143

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Short-circuit fires on tools already converted by pre-request hook

try_short_circuit_search() is called after _execute_pre_request_hooks() has already run async_pre_request_hook, which converts native web_search_20250305 tools to the litellm_web_search format. At the point this method is called, tools may therefore contain litellm_web_search entries rather than the original native tools.

The is_web_search_tool() check (line 109) correctly recognises both formats, so the detection still works. However, the _extract_search_query() call (line 113) extracts the query from the messages (not the tool inputs), so it's unaffected.

The subtle risk is that on a non-github_copilot provider where the pre-hook does NOT run (tools remain as web_search_20250305), the check still fires — meaning the short-circuit could trigger for any provider whose tools happen to all be web search tools, not just the github_copilot path. The provider guard (provider_str not in self.enabled_providers) prevents this, but it relies entirely on the user having configured enabled_providers correctly. A comment here clarifying that the tools at this point may already be in the litellm_web_search format would reduce future confusion.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — is_web_search_tool() handles both formats correctly, and the provider guard prevents false triggers. No change needed.


verbose_logger.debug(
"WebSearchInterception: Short-circuit search detected "
f"(provider={provider_str}, query='{query}')"
)

# Execute search
try:
search_result_text = await self._execute_search(query)
except Exception as e:
verbose_logger.error(
f"WebSearchInterception: Short-circuit search failed: {e}"
)
search_result_text = f"Search failed: {e}"

# Build synthetic Anthropic response
response: Dict[str, Any] = {
"id": f"msg_{str(uuid.uuid4())}",
"type": "message",
"role": "assistant",
"model": model,
"content": [{"type": "text", "text": search_result_text}],
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 0, "output_tokens": 0},
}
Comment on lines +159 to +169

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Zero token usage in synthetic response affects billing/rate-limiting

The synthetic response hardcodes input_tokens: 0 and output_tokens: 0. This flows into the @client decorator's success hooks, which use these values for SpendLog entries and rate-limit accounting. For paid providers like github_copilot, every short-circuited search will appear as a zero-cost call in usage dashboards, making it impossible to correlate actual search costs.

The existing _execute_agentic_loop path has the same limitation (it also returns whatever the backend reports), but since the short-circuit completely bypasses the LLM, there's no natural token count to fall back on. Consider at minimum estimating tokens from the query length and result length, or adding a dedicated usage.search_calls field:

# Rough token estimate: 1 token ≈ 4 chars
estimated_input = max(1, len(query) // 4)
estimated_output = max(1, len(search_result_text) // 4)

response: Dict[str, Any] = {
    ...
    "usage": {"input_tokens": estimated_input, "output_tokens": estimated_output},
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional — no LLM call is made, so 0 tokens is accurate. Search provider costs are tracked separately.


verbose_logger.debug(
"WebSearchInterception: Short-circuit search completed, "
f"returning synthetic response ({len(search_result_text)} chars)"
)
return response

async def async_pre_call_deployment_hook(
self, kwargs: Dict[str, Any], call_type: Optional[Any]
) -> Optional[dict]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,54 @@ async def _execute_pre_request_hooks(
return request_kwargs


async def _try_websearch_short_circuit(
model: str,
messages: List[Dict],
tools: Optional[List[Dict]],
custom_llm_provider: Optional[str],
stream: Optional[bool],
) -> Optional[Union[AnthropicMessagesResponse, AsyncIterator]]:
"""
Attempt to short-circuit a web-search-only request.

Claude Code sends web search as a separate, standalone /v1/messages
request. For providers that don't natively support web search (e.g.
github_copilot), we detect this pattern, execute the search via
Tavily/Perplexity, and return a synthetic Anthropic response — bypassing
the backend LLM entirely.

Returns the synthetic response if short-circuited, or None to continue
normal processing.
"""
if not litellm.callbacks:
return None

from litellm.integrations.websearch_interception.handler import (
WebSearchInterceptionLogger,
)

for callback in litellm.callbacks:
if not isinstance(callback, WebSearchInterceptionLogger):
continue

response = await callback.try_short_circuit_search(
model=model,
messages=messages,
tools=tools,
custom_llm_provider=custom_llm_provider,
)
if response is not None:
if stream:
from litellm.llms.anthropic.experimental_pass_through.messages.fake_stream_iterator import (
FakeAnthropicMessagesStreamIterator,
)

return FakeAnthropicMessagesStreamIterator(response)
return response

return None


@client
async def anthropic_messages(
max_tokens: int,
Expand All @@ -138,6 +186,12 @@ async def anthropic_messages(
"""
Async: Make llm api request in Anthropic /messages API spec
"""
# Save original stream flag before pre-request hooks can convert it.
# The websearch interception hook converts stream=True → stream=False
# for the agentic loop, but the short-circuit path needs to know
# whether the caller originally requested streaming.
original_stream = stream

# Execute pre-request hooks to allow CustomLoggers to modify request
request_kwargs = await _execute_pre_request_hooks(
model=model,
Expand All @@ -151,11 +205,38 @@ async def anthropic_messages(
# Extract modified parameters
tools = request_kwargs.pop("tools", tools)
stream = request_kwargs.pop("stream", stream)
# Propagate the provider derived inside pre-request hooks, if not already set.
# The litellm_params dict may have been overwritten by **kwargs in
# _execute_pre_request_hooks, so fall back to get_llm_provider() if needed.
if not custom_llm_provider:
custom_llm_provider = request_kwargs.get("litellm_params", {}).get(
"custom_llm_provider"
)
if not custom_llm_provider:
try:
_, custom_llm_provider, _, _ = litellm.get_llm_provider(model=model)
except Exception:
pass
# Remove litellm_params from kwargs (only needed for hooks)
request_kwargs.pop("litellm_params", None)
# Merge back any other modifications
kwargs.update(request_kwargs)

# Short-circuit web-search-only requests: detect the pattern, execute
# search directly via Tavily/Perplexity, and return a synthetic response
# without ever touching the backend LLM or the adapter path.
# Use original_stream (not the hook-converted stream) so streaming
# callers get SSE events instead of a plain dict.
short_circuit_response = await _try_websearch_short_circuit(
model=model,
messages=messages,
tools=tools,
custom_llm_provider=custom_llm_provider,
stream=original_stream,
)
if short_circuit_response is not None:
return short_circuit_response
Comment on lines +230 to +238

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Streaming short-circuit path is unreachable for github_copilot

The async_pre_request_hook in WebSearchInterceptionLogger converts stream=True to stream=False for providers in enabled_providers (which includes github_copilot). This conversion happens inside _execute_pre_request_hooks() at line 190, before _try_websearch_short_circuit() is called here.

As a result, by the time _try_websearch_short_circuit is called with the extracted stream value (line 215), stream is already False for any github_copilot streaming request. This means:

  1. _try_websearch_short_circuit will always return a plain dict (never a FakeAnthropicMessagesStreamIterator) for the primary use case this PR targets.
  2. A client that sent stream=True will receive a non-streaming JSON body instead of the expected SSE format, likely causing a protocol error.

The _websearch_interception_converted_stream flag is stored in kwargs but is never checked by _try_websearch_short_circuit. The fix would be to read the original stream value before the hooks run and pass it to the short-circuit:

# Save original stream flag before hooks can convert it
original_stream = stream

request_kwargs = await _execute_pre_request_hooks(
    model=model,
    messages=messages,
    tools=tools,
    stream=stream,
    custom_llm_provider=custom_llm_provider,
    **kwargs,
)

tools = request_kwargs.pop("tools", tools)
stream = request_kwargs.pop("stream", stream)
request_kwargs.pop("litellm_params", None)
kwargs.update(request_kwargs)

short_circuit_response = await _try_websearch_short_circuit(
    model=model,
    messages=messages,
    tools=tools,
    custom_llm_provider=custom_llm_provider,
    stream=original_stream,   # use original, not the hook-converted value
)

This bug is not covered by the new tests because test_returns_stream_iterator_when_streaming passes stream=True directly to _try_websearch_short_circuit, bypassing the hook conversion step entirely.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — we now save original_stream before hooks run and pass that to the short-circuit.


loop = asyncio.get_event_loop()
kwargs["is_async"] = True

Expand Down
Loading
Loading