fix(vertex_ai): exclude Gemini Google Search grounding tokens from input token billing - #33742
Conversation
…put token billing Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
|
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Greptile SummaryThis PR fixes a billing overcharge for Gemini requests that use Grounding with Google Search: previously,
Confidence Score: 4/5The non-streaming billing fix is correct and well-tested; the streaming path works under the current Gemini API format but contains a fragile dependency that could silently over-bill if the API delivers usage and candidates in separate events. The core logic is sound and clearly fixes the overcharging bug for non-streaming requests. The
|
| Filename | Overview |
|---|---|
| litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py | Adds _response_has_search_grounding and uses it to exclude Google Search tool-use tokens from prompt_tokens; streaming path relies on an implicit assumption that usageMetadata and candidates (with grounding metadata) arrive in the same chunk, and total_tokens now exceeds prompt_tokens + completion_tokens for search-grounded responses. |
| tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py | Adds three new unit tests covering search-grounding detection, search-grounding token exclusion, and URL-context token inclusion; all use mocked data (no network calls). No streaming-path test for the new billing logic. |
Comments Outside Diff (1)
-
litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py, line 3247-3253 (link)Streaming path may silently over-bill search-grounding calls
_calculate_usageis called with the raw streamingprocessed_chunk, and_response_has_search_groundingimmediately returnsFalseif"candidates"is absent from that chunk. In practice the Gemini API sendsusageMetadatain the same SSE event as the final candidates batch (which containsgroundingMetadata), so this usually works — but the existinggrounding_metadatalist, already extracted from the current chunk's candidates by the caller just a few lines above, is not forwarded into_calculate_usage. If the API ever delivers a usage-only final event (nocandidates), the search-grounding check fails silently andtoolUsePromptTokenCountis folded intoprompt_tokens, reproducing the original over-billing bug for streaming consumers. The safer approach would be to computebillable_tool_use_prompt_tokensinside_apply_stream_usage_metadatausing the already-availablegrounding_metadataargument — e.g.,has_search = bool(_calculate_web_search_requests(grounding_metadata))— instead of re-deriving it from the chunk.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!
Reviews (1): Last reviewed commit: "fix(vertex_ai): exclude Google Search gr..." | Re-trigger Greptile
| billable_tool_use_prompt_tokens = ( | ||
| 0 | ||
| if VertexGeminiConfig._response_has_search_grounding(completion_response) | ||
| else (tool_use_prompt_tokens or 0) | ||
| ) | ||
|
|
||
| completion_tokens = response_tokens or completion_response["usageMetadata"].get("candidatesTokenCount", 0) | ||
| if not VertexGeminiConfig.is_candidate_token_count_inclusive(usage_metadata) and reasoning_tokens: | ||
| completion_tokens = reasoning_tokens + completion_tokens | ||
| ## GET USAGE ## | ||
| usage = Usage( | ||
| prompt_tokens=usage_metadata.get("promptTokenCount", 0) + (tool_use_prompt_tokens or 0), | ||
| prompt_tokens=usage_metadata.get("promptTokenCount", 0) + billable_tool_use_prompt_tokens, |
There was a problem hiding this comment.
total_tokens invariant broken for search-grounding responses
After this change, for search-grounding responses total_tokens (taken verbatim from totalTokenCount) will always be greater than prompt_tokens + completion_tokens by exactly the excluded toolUsePromptTokenCount. The test test_vertex_ai_search_grounding_tool_use_tokens_excluded_from_prompt_tokens explicitly asserts this gap. Any downstream code (cost calculators, budget checks, token accounting) that assumes total_tokens == prompt_tokens + completion_tokens will silently produce wrong results for search-grounded requests. Consider whether total_tokens should be adjusted to promptTokenCount + billable_tool_use_prompt_tokens + candidatesTokenCount + thoughtsTokenCount, or at minimum add a comment explaining the deliberate discrepancy in the Usage constructor call.
…itellm_gemini_tool_use_input_cost
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…/merge-skew flake The model_info / get_model_info_with_id endpoint tests drove refactored endpoints with bare, unspec'd MagicMock routers and models. Because the mocks were unspec'd, any attribute or method the (refactored) endpoints newly read auto-materialized a child MagicMock, and whether that child was reached depended on process-global state (premium_user, and the real get_available_models_for_user chain reading litellm globals) that sibling tests in the same xdist worker mutate. When reached, the MagicMock either unpacked to empty (a, b = mock.method() -> 'not enough values to unpack (expected 2, got 0)') or leaked into RouterModelInfo(**model_info) and failed Pydantic str validation. Pass in isolation, fail under xdist. The original TestModelInfoEndpoint failure (BerriAI#33807 CI) was the same class surfaced by merge skew: BerriAI#33721 added a get_configured_token_limits unpack to create_model_info_response, and CI's merge commit ran that against the un-updated bare-mock test before the BerriAI#33742 band-aid landed. Fix (test-only, no product change): - TestModelInfoEndpoint: mock the real seam (get_available_models_for_user), configure the router methods the endpoint actually calls, return a real Deployment, and drop the dead proxy_server.get_key_models/get_team_models/ get_complete_model_list patches the refactor had stranded. - TestGetModelInfoWithIdBlocked: spec the model mock so unset enterprise columns read as None instead of child MagicMocks. - test_ProxyConfig_get_model_info_with_id_missing_model_id_raises: pin premium_user so the asserted AttributeError no longer flips with the ambient license global.
Relevant issues
Follow-up to #33533 (which surfaced Gemini
toolUsePromptTokenCount) and the cost concern raised in #33198Linear ticket
Pre-Submission checklist
Screenshots / Proof of Fix
Live proxy (
litellm/proxy/proxy_cli.py), realgemini/gemini-2.5-flashcalls costing real spend. Token counts differ per call because they're independent live requestsBefore, base
litellm_internal_stagingate59add11cd(the #33533 behavior): Google Search grounding tool-use tokens are folded intoprompt_tokens, so they get billed at the input token rate on top of the separate web search feeAfter, this branch at
7cc6262d22, Google Search grounding: tool-use tokens stay visible inprompt_tokens_details.tool_use_tokensbut are excluded fromprompt_tokensAfter, this branch at
7cc6262d22, URL context (a non-search server-side tool): tool-use tokens are billed as input tokens, so they remain folded intoprompt_tokensType
🐛 Bug Fix
Changes
Per Google's pricing (https://ai.google.dev/gemini-api/docs/pricing), retrieved context from Grounding with Google Search is not charged as input tokens; you pay a separate per-request / per-query search fee instead. Other server-side tools (URL context, File Search, code execution) do bill their tool-use tokens at the input token rate.
toolUsePromptTokenCountis the same aggregate field for all of them, so #33533's unconditionalprompt_tokens = promptTokenCount + toolUsePromptTokenCountovercharges search-grounded requests, double-counting the retrieved tokens once at the input rate and again via the flat search feeThis keys the fold-in on whether the response actually performed a Google Search, detected via
groundingMetadata.webSearchQueries(the same signal that drivesweb_search_requestsand the search fee). URL context also emitsgroundingMetadata, but withgroundingChunksand nowebSearchQueries, so presence ofgroundingMetadataalone is not sufficientprompt_tokens_details.tool_use_tokensis still populated in every case for observability, so thetotal_tokensgap that search grounding introduces stays explainable. The new_response_has_search_groundinghelper reuses the existing_extract_candidate_metadataand_calculate_web_search_requests, so both the streaming and non-streaming usage paths share one decisionTests in
test_vertex_and_google_ai_studio_gemini.pycover search grounding (tool tokens excluded fromprompt_tokens, still surfaced), URL context withgroundingChunksbut nowebSearchQueries(tool tokens included), and the detection helper itselfFinal Attestation
Link to Devin session: https://app.devin.ai/sessions/25ff7523b62f4304a1dde7132834ad6a
Requested by: @krrish-berri-2