Skip to content

fix(websearch_interception): bill intercepted searches to the calling key - #35708

Merged
shivamrawat1 merged 3 commits into
litellm_internal_stagingfrom
devin_ai_lit_5033_websearch_interception_spend
Aug 8, 2026
Merged

fix(websearch_interception): bill intercepted searches to the calling key#35708
shivamrawat1 merged 3 commits into
litellm_internal_stagingfrom
devin_ai_lit_5033_websearch_interception_spend

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Intercepted web searches never appear in Logs
  • Their provider cost hits nobody's budget
  • The search goes out with no owner attached

How it solves it:

  • Search now carries the calling key's attribution metadata
  • Cost lands on that key, user, team and org
  • Search gets its own log row, priced correctly

User Flow

Before: a developer using a web-search tool through the gateway pays for every search out of band, and their admin cannot see or cap it

  1. The admin configures a search tool and turns on web search interception, then issues a key with "max_budget": 1
  2. The developer sends POST http://localhost:4000/v1/messages with "tools": [{"type": "web_search_20250305", ...}] and gets back an answer built from live search results
  3. The admin opens http://localhost:4000/ui/?page=logs and sees only the LLM request. The search that ran to answer it is nowhere
  4. GET http://localhost:4000/key/info for that key still reports spend: 0, so the search was free as far as the budget is concerned
  5. Any key can keep driving searches past its max_budget because nothing the search costs is ever counted against it

After: the same request records the search too, so it shows up in Logs and counts against the key's budget

  1. The admin configures a search tool and turns on web search interception, then issues a key with "max_budget": 1
  2. The developer sends POST http://localhost:4000/v1/messages with "tools": [{"type": "web_search_20250305", ...}] and gets back an answer built from live search results
  3. The admin opens http://localhost:4000/ui/?page=logs and now sees two rows, the LLM request plus a asearch row for the search, attributed to the same key and user
  4. GET http://localhost:4000/key/info for that key reports the search's cost in spend
  5. A key that exhausts its max_budget on searches is cut off like it would be on any other spend

Relevant issues

Linear ticket

Resolves LIT-5033

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Screenshots / Proof of Fix

Live proxy against Postgres with real Exa searches and real OpenAI calls, the same request run twice: once on this branch at 24888d56a6, once at that same commit with litellm/integrations/websearch_interception/handler.py checked out from litellm_internal_staging at 8b16ee1dc2

  1. Config
model_list:
  - model_name: gpt-4o-mini
    litellm_params:
      model: openai/gpt-4o-mini
      api_key: os.environ/OPENAI_API_KEY
search_tools:
  - search_tool_name: exa-search
    litellm_params:
      search_provider: exa_ai
      api_key: os.environ/EXA_API_KEY
litellm_settings:
  callbacks: ["websearch_interception"]
  websearch_interception_params:
    enabled_providers: ["openai"]
    search_tool_name: exa-search
general_settings:
  master_key: sk-1234
  1. litellm --config lit5033_config.yaml --detailed_debug --port 4000 2>&1 | tee litellm.log

  2. KEY=$(curl -s -X POST http://localhost:4000/key/generate -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" -d '{"user_id":"search-budget-qa","max_budget":1}' | jq -r .key)

  3. Web search request through the interception path

curl -s -X POST http://localhost:4000/v1/messages -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"model":"gpt-4o-mini","max_tokens":1024,"tools":[{"type":"web_search_20250305","name":"web_search","max_uses":2}],"messages":[{"role":"user","content":"what shipped in the latest litellm release?"}]}'
{"id":"msg_fd2cf339-7b81-4afb-a416-09f409e2c732","type":"message","role":"assistant","model":"gpt-4o-mini","content":[{"type":"text","text":"Title: Releases · BerriAI/litellm - GitHub\nURL: https://github.com/BerriAI/litellm/releases ...
  1. curl -s http://localhost:4000/spend/logs -H "Authorization: Bearer sk-1234" | jq '.[] | {call_type, model, model_group, spend, user}' plus curl -s http://localhost:4000/key/info -H "Authorization: Bearer $KEY" | jq .info.spend

With the handler from litellm_internal_staging only the LLM row lands and the Exa query is free

{"call_type": "anthropic_messages", "model": "openai/gpt-4o-mini", "model_group": "gpt-4o-mini", "spend": 0, "user": "search-budget-qa-before"}
key spend: 0

On this branch the search gets its own row, priced and attributed to the calling key and user, and the key's spend moves so max_budget covers it

{"call_type": "asearch", "model": "exa_ai/search", "model_group": "exa-search", "spend": 0.005, "user": "search-budget-qa"}
{"call_type": "anthropic_messages", "model": "openai/gpt-4o-mini", "model_group": "gpt-4o-mini", "spend": 0, "user": "search-budget-qa"}
key spend: 0.005

Full metadata on that new row, showing where the attribution comes from

{
  "request_id": "df37e382-6450-4795-833c-0fbe488117cf",
  "call_type": "asearch",
  "model": "exa_ai/search",
  "model_group": "exa-search",
  "custom_llm_provider": "exa_ai",
  "api_base": "https://api.exa.ai/search",
  "spend": 0.005,
  "user": "search-budget-qa",
  "metadata": {
    "user_api_key": "e49bdcd8c776e87d1a64e58d9dd97ac777223886c1bc8af67ed5e9d96cfee50e",
    "user_api_key_user_id": "search-budget-qa",
    "user_api_key_team_id": null,
    "cost_breakdown": {"input_cost": 0.005, "output_cost": 0, "total_cost": 0.005}
  }
}

Both rows also show up on http://localhost:4000/ui/?page=logs

Type

🐛 Bug Fix

Changes

WebSearchInterceptionLogger._execute_search() called litellm.asearch() with nothing but the search tool's own litellm_params, so the resulting call had no owner. _PROXY_track_cost_callback skips any call whose metadata has no key, user, team or end user, which is why the search's cost was computed correctly and then dropped: no SpendLogs row, nothing on the Logs page, no budget movement. The handler already had the originating request's kwargs in hand, it reads user_api_key_auth out of them for the search tool permission checks, it just never passed the attribution along

The search now goes out with the calling key's metadata, built from the same UserAPIKeyAuth the permission checks use, so the row lands on the right key, user, team and org and shows model_group as the resolved search tool the way a direct /v1/search request does. search_tool_name is resolved off the selected tool rather than only its raw search_provider

search_metadata: Final = (
    None
    if user_api_key_auth is None
    else self._build_search_request_metadata(user_api_key_auth=..., search_tool_name=...)
)
result: Final = (
    await litellm.asearch(query=query, search_provider=search_provider, **search_kwargs)
    if search_metadata is None
    else await litellm.asearch(
        query=query, search_provider=search_provider, litellm_metadata=search_metadata, **search_kwargs
    )
)

SDK callers keep the old behavior, since there is no key to attribute a search to outside the proxy

Not in scope here: RPM parity. An earlier revision of this PR also invoked the v3 rate limiter's pre-call hook for the intercepted search, which Greptile correctly flagged, since that hook reserves a parallel-request slot and TPM budget that only its own success or failure callback releases. The reservation bookkeeping lives on a ContextVar stash pinned to the originating request's litellm_call_id, so a nested call reserving through it would both leak its own reservation and clobber the parent request's, which is worse than the gap it closes. Doing this properly means giving the intercepted search its own request context so the limiter's release callbacks match up, which belongs in its own PR. Token usage from the search does still reach the TPM counters through the search's success event now that the metadata is attached

Two regression tests cover the fix: attribution metadata reaches asearch and is accepted by the spend gate, and an SDK call with no proxy auth context still sends no proxy metadata

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

Link to Devin session: https://app.devin.ai/sessions/8ad0aa5b32ad4848b7fae25718a5a035
Requested by: @mateo-berri

@CLAassistant

CLAassistant commented Aug 3, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ mateo-berri
❌ devin-ai-integration[bot]
You have signed the CLA already but the status is still pending? Let us recheck it.

@greptile-apps

greptile-apps Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR attributes intercepted web-search calls to the originating proxy key so their costs appear in spend logs and apply to the appropriate key, user, team, and organization budgets.

  • Builds sanitized attribution metadata from the authenticated request context.
  • Forwards that metadata and the resolved search-tool name to litellm.asearch.
  • Preserves metadata-free behavior for SDK calls without proxy authentication context.
  • Adds regression coverage for attributed proxy calls and unattributed SDK calls.

Confidence Score: 5/5

The PR appears safe to merge.

The previously reported limiter-reservation issue is resolved because the intercepted search no longer invokes the proxy limiter pre-call hook, and forwarding litellm_metadata to asearch does not independently enter that reservation pipeline; no blocking failure remains.

Important Files Changed

Filename Overview
litellm/integrations/websearch_interception/handler.py The intercepted search now receives authenticated attribution metadata without restoring the previously reported unmatched limiter reservation.
tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py Tests verify attribution fields, spend-gate eligibility, selected search parameters, and metadata-free SDK behavior.

Reviews (3): Last reviewed commit: "Merge remote-tracking branch 'origin/lit..." | Re-trigger Greptile

Comment thread litellm/integrations/websearch_interception/handler.py Outdated
… against the calling key

An intercepted web search called litellm.asearch() with only the search tool's litellm_params, so the search request carried no owner. The proxy's spend hook skips any call with no key, user or team attached, so the search's provider cost never reached SpendLogs; it was missing from the Logs page and never counted against the caller's budget. The same path never ran the rate limiter either, so an intercepted search was free of the key's RPM/TPM limits.

The search now carries the originating key's attribution metadata (key hash, alias, user, team, org, plus model_group set to the resolved search tool) and runs the caller's rate limit checks before hitting the provider, matching what a direct /v1/search request gets. SDK calls with no proxy auth context are unchanged.

Resolves LIT-5033

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration
devin-ai-integration Bot force-pushed the devin_ai_lit_5033_websearch_interception_spend branch from fbc7330 to 8466ed0 Compare August 7, 2026 02:32
@devin-ai-integration devin-ai-integration Bot changed the title fix(websearch_interception): bill and rate limit intercepted searches against the calling key fix(websearch_interception): bill intercepted searches to the calling key Aug 7, 2026
@shivamrawat1

Copy link
Copy Markdown
Collaborator

@greptile review again

@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.75000% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...llm/integrations/websearch_interception/handler.py 93.75% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing devin_ai_lit_5033_websearch_interception_spend (24888d5) with litellm_internal_staging (b0fd3e1)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (24888d5) during the generation of this report, so 4c1fb9b was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

devin-ai-integration Bot and others added 2 commits August 8, 2026 02:13
…metadata forwarding

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…evin_ai_lit_5033_websearch_interception_spend

# Conflicts:
#	litellm/integrations/websearch_interception/handler.py
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri mateo-berri left a comment

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.

LGTM

@shivamrawat1
shivamrawat1 merged commit 7b89b3a into litellm_internal_staging Aug 8, 2026
78 checks passed
@shivamrawat1
shivamrawat1 deleted the devin_ai_lit_5033_websearch_interception_spend branch August 8, 2026 19:41
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.

3 participants