Skip to content

fix: short-circuit websearch for github_copilot provider - #24143

Merged
5 commits merged into
BerriAI:litellm_oss_staging_03_19_2026from
johnib:fix/websearch-short-circuit-copilot
Mar 20, 2026
Merged

fix: short-circuit websearch for github_copilot provider#24143
5 commits merged into
BerriAI:litellm_oss_staging_03_19_2026from
johnib:fix/websearch-short-circuit-copilot

Conversation

@johnib

@johnib johnib commented Mar 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #21733websearch_interception doesn't work with github_copilot provider.

Root cause: Claude Code sends web search as a separate, standalone /v1/messages request (simple prompt, single web_search_20250305 tool). For github_copilot, this request falls to the adapter path which:

  1. Strips the web search tool → converts to web_search_options: {} → Copilot API ignores it
  2. Has no stream reconversion from chat-completions back to Anthropic SSE format
  3. Sends an unnecessary round-trip to the backend LLM

Fix: Detect web-search-only requests early at the /v1/messages entry point and short-circuit: extract the query, call Tavily/Perplexity directly via the existing _execute_search() method, and return a synthetic AnthropicMessagesResponse. No adapter, no backend LLM call, no agentic loop.

This approach:

  • Works for github_copilot and any other non-Anthropic provider
  • Reuses existing _execute_search() (search provider resolution) and FakeAnthropicMessagesStreamIterator (SSE wrapping)
  • Handles both stream: true and stream: false
  • Doesn't touch the adapter path or the existing agentic loop (zero regression risk for Bedrock/Vertex)

Changes

File Change
litellm/integrations/websearch_interception/handler.py Added try_short_circuit_search() + _extract_search_query()
litellm/llms/anthropic/experimental_pass_through/messages/handler.py Added _try_websearch_short_circuit(), called before adapter dispatch
tests/.../test_websearch_short_circuit.py 18 new unit tests

Test plan

  • All 18 new short-circuit tests pass
  • All 37 existing websearch interception tests still pass (55 total, 2 skipped integration)
  • Manual test with github_copilot provider + Tavily + Claude Code web search
  • Verify Bedrock/Vertex paths are unaffected (existing tests cover this)

…lot)

For providers like github_copilot that don't natively support web search,
Claude Code's search sub-conversations were falling through to the adapter
path which strips the web_search tool and has no stream reconversion.

Instead of routing search requests through the full LLM pipeline, detect
web-search-only requests early (all tools are web_search, simple prompt)
and execute the search directly via Tavily/Perplexity, returning a
synthetic Anthropic response. No adapter, no backend LLM call needed.

Fixes #21733
@vercel

vercel Bot commented Mar 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Mar 19, 2026 11:13pm

Request Review

@codspeed-hq

codspeed-hq Bot commented Mar 19, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 16 untouched benchmarks


Comparing johnib:fix/websearch-short-circuit-copilot (32cb6f0) with main (2d3cff9)

Open in CodSpeed

@greptile-apps

greptile-apps Bot commented Mar 19, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes web search interception for the github_copilot provider by adding a short-circuit path that detects web-search-only /v1/messages requests and executes the search directly via Tavily/Perplexity, returning a synthetic AnthropicMessagesResponse without touching the backend LLM. The root cause was that Copilot's adapter path strips the web search tool and has no stream reconversion, making the existing agentic loop path non-functional for it.

Key changes:

  • WebSearchInterceptionLogger.try_short_circuit_search() — detects web-search-only requests for providers in enabled_providers that lack native Anthropic Messages support (guarded by ProviderConfigManager.get_provider_anthropic_messages_config), executes the search, and returns a synthetic response. Providers with native support (bedrock, vertex_ai, etc.) are correctly excluded to preserve their full agentic loop.
  • anthropic_messages() — captures original_stream before the pre-request hook converts it to False, derives custom_llm_provider via a get_llm_provider() fallback when not supplied, and calls _try_websearch_short_circuit() with the original stream flag so streaming callers receive SSE events rather than a plain dict.
  • 18 new unit tests, all using AsyncMock — no real network calls, compliant with the test-folder policy.

Minor issues found:

  • The test file contains an empty # Query extraction tests section (a leftover placeholder with no tests). The _extract_search_query() method mentioned in the PR description was folded into try_short_circuit_search() using get_last_user_message() directly, but the section header was not cleaned up. Structured-content messages (where content is a list of blocks rather than a plain string) are not covered by any test — the short-circuit silently falls through to the broken adapter path if get_last_user_message returns None for that format.

Confidence Score: 4/5

  • Safe to merge with minor cleanup; the short-circuit logic is well-guarded and regression risk for existing bedrock/vertex users is properly mitigated.
  • The core logic is sound — the provider guard via ProviderConfigManager prevents the short-circuit from firing for providers that rely on the full agentic loop, original_stream is captured before hooks can mutate it, and the custom_llm_provider derivation fallback handles the model-string embedding case. All 18 new tests are properly mocked. The only deductions are: the empty test section placeholder, the lack of structured-content message coverage, and zero-token synthetic usage (a known accepted limitation noted by the author).
  • No files require special attention beyond the empty test section placeholder in tests/test_litellm/integrations/websearch_interception/test_websearch_short_circuit.py.

Important Files Changed

Filename Overview
litellm/integrations/websearch_interception/handler.py Adds try_short_circuit_search() to detect web-search-only requests and execute the search directly. Provider guard via ProviderConfigManager.get_provider_anthropic_messages_config correctly prevents the short-circuit from firing for bedrock/vertex_ai (which use the full agentic loop). Zero-token synthetic response is a known limitation.
litellm/llms/anthropic/experimental_pass_through/messages/handler.py Adds _try_websearch_short_circuit() called after pre-request hooks. Correctly captures original_stream before hooks convert it, and adds a get_llm_provider() fallback to derive custom_llm_provider when not supplied. Short-circuit return bypasses anthropic_messages_handler but the @client decorator will still fire success hooks with the zero-token synthetic response.
tests/test_litellm/integrations/websearch_interception/test_websearch_short_circuit.py 18 new unit tests covering short-circuit detection, provider guards, streaming vs non-streaming, and entry-point behaviour. All tests use AsyncMock — no real network calls, compliant with the test-folder rule. An empty # Query extraction tests section is a leftover placeholder with no tests.

Sequence Diagram

sequenceDiagram
    participant Client as Claude Code Client
    participant AM as anthropic_messages()
    participant PRH as _execute_pre_request_hooks()
    participant WSI as WebSearchInterceptionLogger
    participant SC as try_short_circuit_search()
    participant Search as Tavily / Perplexity
    participant LLM as Backend LLM (github_copilot)

    Client->>AM: POST /v1/messages\n(stream=True, tools=[web_search_20250305])
    Note over AM: original_stream = True
    AM->>PRH: run pre-request hooks
    PRH->>WSI: async_pre_request_hook()
    Note over WSI: converts web_search_20250305 → litellm_web_search\nconverts stream=True → stream=False
    WSI-->>PRH: modified request_kwargs
    PRH-->>AM: request_kwargs (stream=False, tools=[litellm_web_search])
    AM->>AM: derive custom_llm_provider via get_llm_provider()
    AM->>SC: _try_websearch_short_circuit(stream=original_stream=True)
    SC->>WSI: try_short_circuit_search()
    Note over WSI: provider in enabled_providers? ✓\nprovider has native AnthropicMessagesConfig? ✗ (github_copilot)\nall tools are web search? ✓\nquery extracted from last user message
    WSI->>Search: _execute_search(query)
    Search-->>WSI: search result text
    WSI-->>SC: synthetic AnthropicMessagesResponse dict
    SC->>SC: stream=True → wrap in FakeAnthropicMessagesStreamIterator
    SC-->>AM: FakeAnthropicMessagesStreamIterator
    AM-->>Client: SSE stream (synthetic response)\n[LLM never called]

    Note over LLM: ← Backend LLM bypassed entirely
Loading

Last reviewed commit: "fix: guard short-cir..."

Comment on lines +210 to +218
short_circuit_response = await _try_websearch_short_circuit(
model=model,
messages=messages,
tools=tools,
custom_llm_provider=custom_llm_provider,
stream=stream,
)
if short_circuit_response is not None:
return short_circuit_response

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.

Comment on lines +210 to +215
short_circuit_response = await _try_websearch_short_circuit(
model=model,
messages=messages,
tools=tools,
custom_llm_provider=custom_llm_provider,
stream=stream,

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 custom_llm_provider not updated after pre-request hook extraction

When a caller passes custom_llm_provider=None but embeds the provider in the model string (e.g., model="github_copilot/claude-sonnet-4"), _execute_pre_request_hooks() derives the provider internally and stores it in request_kwargs["litellm_params"]["custom_llm_provider"]. However, that key is immediately popped (request_kwargs.pop("litellm_params", None)) and the outer custom_llm_provider local variable is never updated.

This means _try_websearch_short_circuit receives custom_llm_provider=None, leading to provider_str = "" in try_short_circuit_search, which will never match any entry in enabled_providers, silently skipping the short-circuit.

Since anthropic_messages_handler (called further down) independently calls litellm.get_llm_provider() to derive the provider, it would be straightforward to derive it once here and pass the result to both call sites:

# Extract modified parameters
tools = request_kwargs.pop("tools", tools)
stream = request_kwargs.pop("stream", stream)
# Propagate the provider derived inside the pre-request hooks, if not already set
if not custom_llm_provider:
    custom_llm_provider = request_kwargs.get("litellm_params", {}).get(
        "custom_llm_provider"
    )
request_kwargs.pop("litellm_params", None)
kwargs.update(request_kwargs)

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 extract custom_llm_provider from litellm_params before popping, with a fallback to litellm.get_llm_provider().

Comment on lines +97 to +115
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

# All tools must be web search tools
if not all(is_web_search_tool(t) for t in tools):
return None

# Extract search query from the last user message
query = self._extract_search_query(messages)
if not query:
return None

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.

@CLAassistant

CLAassistant commented Mar 19, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

Comment on lines +131 to +143
# Build synthetic Anthropic response
from uuid import uuid4

response: Dict[str, Any] = {
"id": f"msg_{uuid4().hex[:24]}",
"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},
}

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.

Comment on lines +108 to +110
# All tools must be web search tools
if not all(is_web_search_tool(t) for t in tools):
return None

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.

Addresses Greptile review feedback:
- Save original stream flag before pre-request hooks convert it, so
  streaming callers get SSE events instead of a plain dict
- Propagate custom_llm_provider derived inside _execute_pre_request_hooks
  when it was not explicitly passed by the caller
- Add tests covering both scenarios
- Replace hand-rolled _extract_search_query with existing
  get_last_user_message from common_utils
- Use full UUID (str(uuid.uuid4())) to match codebase convention
- Move uuid import to module level per CLAUDE.md
Comment on lines +101 to +107
# 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

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.

Comment on lines +208 to +212
# Propagate the provider derived inside pre-request hooks, if not already set
if not custom_llm_provider:
custom_llm_provider = request_kwargs.get("litellm_params", {}).get(
"custom_llm_provider"
)

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 custom_llm_provider propagation can still be None when the model string contains the provider

_execute_pre_request_hooks derives custom_llm_provider from the model string internally and stores it in request_kwargs["litellm_params"]["custom_llm_provider"]. The new code reads it back correctly here. However, _execute_pre_request_hooks constructs request_kwargs as:

request_kwargs = {
    "tools": tools,
    "stream": stream,
    "litellm_params": {"custom_llm_provider": custom_llm_provider},
    **kwargs,  # ← if kwargs contains "litellm_params", this key wins
}

If the caller passes a litellm_params key in **kwargs (which is common in the proxy path), the **kwargs expansion overwrites the "litellm_params" key that was explicitly set above, and the derived custom_llm_provider is lost. In that scenario the if not custom_llm_provider: branch here reads back None or the caller-supplied value, not the model-derived value.

The root-cause fix is to merge rather than overwrite in _execute_pre_request_hooks, but at minimum the propagation here should fall back to calling litellm.get_llm_provider(model=model) directly when request_kwargs["litellm_params"] is missing custom_llm_provider:

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

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 fallback to litellm.get_llm_provider(model=model) when litellm_params extraction returns None.

- Skip short-circuit for providers that have a BaseAnthropicMessagesConfig
  (bedrock, vertex_ai, azure_ai, anthropic) — they use the agentic loop
  which includes a follow-up LLM synthesis step. Short-circuiting would
  return raw search text instead of an LLM-synthesized answer.
- Add fallback to litellm.get_llm_provider() for custom_llm_provider
  derivation when litellm_params is overwritten by kwargs.
- Add test for bedrock guard.

Addresses Greptile review comments #3 and #4.
@ghost
ghost changed the base branch from main to litellm_oss_staging_03_19_2026 March 20, 2026 01:42
@ghost
ghost merged commit 61dde5e into BerriAI:litellm_oss_staging_03_19_2026 Mar 20, 2026
38 of 39 checks passed
fzowl pushed a commit to fzowl/litellm that referenced this pull request Jun 24, 2026
…uit-copilot

fix: short-circuit websearch for github_copilot provider
This pull request was closed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: websearch_interception callback does not work with github_copilot provider

2 participants