fix(xai): bill from the cost xAI reports instead of recomputing it - #36281
fix(xai): bill from the cost xAI reports instead of recomputing it#36281Acacian wants to merge 1 commit into
Conversation
Greptile SummaryThe PR teaches the xAI adapters to normalize provider-reported billing ticks into USD and prefer that total over local token and search-cost reconstruction.
Confidence Score: 5/5The PR appears safe to merge because no blocking failure remains. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| litellm/llms/xai/chat/transformation.py | Normalizes xAI-reported cost for both non-streaming responses and streaming usage chunks. |
| litellm/llms/xai/common_utils.py | Adds centralized validation and conversion from xAI billing ticks to USD. |
| litellm/llms/xai/cost_calculator.py | Prefers validated provider-reported totals and suppresses duplicate web-search charges. |
| litellm/llms/xai/responses/transformation.py | Carries normalized provider-reported costs through the Responses API adapter. |
| tests/test_litellm/llms/xai/test_xai_cost_calculator.py | Covers reported-cost preference, fallback behavior, validation, and tool-surcharge suppression. |
Reviews (7): Last reviewed commit: "fix(xai): bill from the cost xAI reports..." | Re-trigger Greptile
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
@greptileai both findings addressed in the two follow-up commits, please re-review |
PR overviewThis pull request changes xAI billing to use the cost reported by xAI rather than recomputing it locally in the cost calculator. One security issue remains open after two others were addressed. A non-finite provider-reported cost can bypass budget enforcement and corrupt spend counters when an attacker can control the xAI-compatible endpoint; validation should reject NaN and infinite values. Open issues (1)
Fixed/addressed: 2 · PR risk: 6/10 |
|
@greptileai security finding fixed too, please re-review |
e393725 to
0894696
Compare
|
@greptileai reworked to use usage.cost instead of a parallel field, so responses/utils.py is untouched now. Please re-review |
|
@mateo-berri you have recent history on both This is ready for a human look: CI is green, the branch is clean against The short version: xAI reports what it charged on |
e209a97 to
78112c0
Compare
|
@greptileai rebased onto current staging and reworked on top of merged #30817, which changed the fallback to per-call. Please re-review |
028c9c3 to
1018f6d
Compare
|
@greptileai rebased onto current staging, resolved two test import conflicts, and refreshed the proof of fix hashes. Please re-review |
xAI states the amount it charged in usage.cost_in_usd_ticks, at 10^10 ticks to the dollar, and that figure covers tokens and every server-side tool invocation together. The xAI chat and responses transformations restate it in USD on usage.cost, the field litellm already carries a provider-stated cost in, and the xAI cost calculator bills from it the way the perplexity calculator does Routing it through usage.cost rather than a private field means the streaming chunk assembler carries it too, and no provider-neutral file has to learn about an xAI wire field Only a non-negative integer is trusted, so an endpoint a caller can point litellm at cannot report a negative amount to subtract from its own recorded spend. Absent a usable figure nothing changes: the existing token math and the $5 per 1,000 web search calls fallback both run as before The web search surcharge is suppressed once the reported total applies, since that total already covers the search calls
1018f6d to
ddcbba0
Compare
| reported_cost: Final[object] = getattr(usage, "cost", None) | ||
| if not isinstance(reported_cost, (int, float)) or isinstance(reported_cost, bool): | ||
| return None | ||
| if reported_cost < 0: |
There was a problem hiding this comment.
Medium: Non-finite costs bypass budget enforcement
This check accepts NaN because NaN < 0 is false, and usage.cost can come directly from the provider response rather than the integer-tick conversion. An attacker allowed to select an xAI-compatible endpoint can return cost: NaN; that value reaches key, user, and team spend counters, after which comparisons such as spend >= max_budget remain false. Reject values for which math.isfinite(reported_cost) is false before returning the reported cost.
|
@greptileai pushed ddcbba0 after the last ping, moving one helper to module scope to clear the basedpyright budget. Please re-review |
TLDR
Problem this solves:
web_search_callsis priced, other tool calls cost nothingHow it solves it:
usage.cost, the channel every route readsUser Flow
Before: a developer running Grok with server side web search sees spend that does not match their xAI invoice
"model": "xai/grok-4-latest"and"tools": [{"type": "web_search"}]"stream": trueand get a third number in the logs, because the streamed route adds nothing for the searches at allAfter: the same request is logged at the amount xAI says it charged
"model": "xai/grok-4-latest"and"tools": [{"type": "web_search"}]"stream": truelogs that same figure, and so does the same request sent to https://litellm-domain/v1/responsesRelevant issues
Relates to #35829, which reports that a provider-reported
usage.costis trusted verbatim as USD and records phantom spend when the upstream denominates that field in something else. This PR does not widen that path, and it is worth being explicit about whyusage.cost_in_usd_ticks, an integer denominated by a documented constant of 10,000,000,000 ticks to the dollar (https://docs.x.ai/developers/cost-tracking)usage.cost.xai_reported_cost_in_usd()accepts only a non-negative integer, refusing bool as an int subclass, and returns USD. A malformed, negative or absent value yields None and the request falls back to the existing pricing with the search surcharge left intactusage.costhere is an already validated USD amount derived from a field with a defined unit, not an arbitrary number of unknown denomination. Provider-reported usage.cost is trusted verbatim as USD spend with no unit validation — causes astronomical phantom spend #35829's failure mode is the generic path accepting the latter, and nothing in this PR loosens thatOne consequence is worth stating rather than leaving to be discovered: on the streaming path
streaming_handler.py::_propagate_usage_cost_to_hidden_params()copiesusage.costinto_hidden_params, andresponse_cost_calculator()returns that beforecompletion_cost()runs, sollms/xai/cost_calculator.pyis not consulted for streamed requests. That is safe here precisely because the conversion and the validation already happened in the adapter. The guard in the cost calculator covers the non-streaming path, where usage reaches it directlyNothing in the tracker reports the remaining xAI mismatch itself. I searched issues for xai, grok, live search, web search cost and cost_in_usd_ticks, and the only other xAI cost bug on file is #15338, which is about tiered token pricing and is already closed. So there is no issue to close here
Linear ticket
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*,make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more@greptileaito re-request a review after pushing changes)Delays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
Screenshots / Proof of Fix
To be clear about what this is: not a live capture, but a documented xAI usage payload replayed through the transformations and then through the cost path spend tracking calls, at both commits. I do not have an xAI key with server side search billing enabled to capture a real invoice against. The curl commands a reviewer can run against a live proxy are at the end, and I am happy to add that run if you would rather have it
Before (40423e6)
PYTHONPATH=. uv run python pof.pyAfter (ddcbba0)
PYTHONPATH=. uv run python pof.pyOn the two non streamed routes the 12 searches were charged at $0.005 each, $0.06 on top of the $0.0246 token estimate, against the $0.0138 xAI actually charged. The streamed route dropped the searches entirely and priced from tokens alone, which is why it landed somewhere else again. All three now agree with the provider
To run it against a live proxy with a real xAI key instead, with
XAI_API_KEYset and the proxy started as in CLAUDE.md:then open http://localhost:4000/ui/?page=logs and compare the logged spend for those two requests against https://console.x.ai usage for the same minute
Type
🐛 Bug Fix
Changes
Since #30817 the xAI cost calculator counts server side searches from
usage.server_side_tool_usage_details.web_search_callsand prices them at xAI's published $5 per 1,000 calls, falling back to that list rate because no xai entry in the price map carriessearch_context_cost_per_query. That is the right rate for that one tool, and LiteLLM is still doing xAI's arithmetic for itEvery xAI response already states the amount charged, in
usage.cost_in_usd_ticks, at 10^10 ticks to the dollar. Per xAI's cost tracking docs that single figure is "the actual amount billed, after all applicable discounts (including prompt caching reductions) have been applied, and inclusive of all token costs and server-side tool invocation costs". This PR reads it and prices the request from it, which is the shapellms/perplexity/cost_calculator.pyalready uses for a provider that reports its own cost.get_cost_for_web_search_requestneeded no change, because the xAI calculator now returns 0 for the search surcharge once the reported total has been applied, the same way the perplexity branch two lines above it doesThree things the re-derived number cannot reach, all of which the reported one already accounts for. xAI's pricing page lists Web Search, X Search and Code Execution at $5 per 1,000 calls, Collections Search at $2.50 and File Attachments at $10, but only
web_search_callsis priced today, so a request whoseserver_side_tool_usage_detailsreportsx_search_callsorcode_execution_callsis billed nothing for them. Prompt caching is the second:xai/grok-4andxai/grok-4-latestcarry nocache_read_input_token_cost, so cached input is priced at the full $3 per 1M while xAI has already discounted it. The third is drift, since each of those rates needs a matching LiteLLM change every time xAI revises oneThe conversion lives in the xAI chat and responses transformations, which restate the ticks in USD on
usage.cost. That is the field litellm already carries a provider stated cost in, and using it is what keeps this change inside the provider adapter:ResponseAPILoggingUtilsalready copiesusage.costonto the chat Usage for/v1/responses, and the streaming chunk assembler already carriescostthrough aggregation, so neither had to learn an xAI wire field. An earlier revision of this PR instead invented a parallel channel and taughtlitellm/responses/utils.pyto preserve unmodelled provider fields, which changed/v1/responsesfor every provider to serve one. That is gone; the diff is now entirely underlitellm/llms/xai/and its mirrored testsWithout a reported figure nothing changes: the existing token math and the existing per-call search surcharge both run exactly as before
Only the documented shape of that field is trusted, a non-negative integer, with bool refused since it is an int subclass. A caller who can set
api_basecontrols the response body, so a negative amount would otherwise be billed as negative and subtracted from that caller's own recorded spend, slipping past a budget. Anything that is not a non-negative integer falls back to token pricing with the search surcharge still appliedWhat that check cannot do is make a self-hosted
api_basetrustworthy, and neither can token pricing: the token counts come from the same response body, so an endpoint reporting"prompt_tokens": 0, "completion_tokens": 0already logs $0 today. Trusting a reported total hands such a caller nothing they did not already have, and it is the exposure the perplexity calculator has carried for a whileOn #35829, which reports a nano denominated
usage.costrecorded verbatim as $3,144,000 of spend: this PR is the narrow per provider opt in that issue asks for as its first suggested fix, "only trustusage.costfor providers where the calling code explicitly opts in (e.g. Perplexity's owncost_calculator.pyalready does this narrowly and correctly)". The unit is converted from xAI's documented tick unit before it ever reachesusage.costrather than guessed, so the confusion in that report cannot arise on this path. The blanket trust inresponse_cost_calculatorthat the issue is actually about is untouched and still wants a decision above this fileOn why not put the price in
model_info, as #24372 did for gemini: that was right for gemini, since Google does not tell you what it charged and a configured price is the only option there. xAI does tell you. Going that way means adding tool prices to all 44 xai entries and keeping every one of them current through each xAI revision, and it still cannot express what xAI bills, since the server side tools are charged per invocation across several distinct tools at different rates and the model decides how many of each to callNot touched: the model price maps, and any provider neutral file
Caveats
Medium
Low
_hidden_params, bypassing the xAI calculatorFinal Attestation