fix(cost_tracking): map OpenAI cache_write_tokens for prompt cache creation billing - #34046
Conversation
🤖 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:
|
|
|
Greptile SummaryThis PR fixes missing cache-write token billing for OpenAI's
Confidence Score: 5/5Safe to merge — changes are scoped to token-detail normalization and spend-log derivation, with no schema changes or backwards-incompatible API surface modifications. The fix is well-contained: the setattr mirror is correctly guarded so that Pydantic's internal field initialization is handled by the explicit assignment in init; the double-count fix via _summable_prompt_token_fields is logically sound and covered by an exact-value regression test; the Anthropic path is guarded so existing values are never clobbered. Tests are all mock-based and cover the edge cases. No files require special attention.
|
| Filename | Overview |
|---|---|
| litellm/types/utils.py | Adds cache_write_tokens as canonical field on PromptTokensDetailsWrapper with __setattr__ mirror to cache_creation_tokens; __init__ normalization ensures both fields stay in sync and are deleted together when absent. |
| litellm/cost_calculator.py | Adds _summable_prompt_token_fields to prevent double-counting the mirrored cache_write_tokens/cache_creation_tokens pair in combine_usage_objects; correctly skips cache_creation_tokens and lets the __setattr__ mirror handle it. |
| litellm/litellm_core_utils/llm_cost_calc/utils.py | Reads cache_write_tokens first (OpenAI name) then falls back to cache_creation_tokens (Anthropic name) in _parse_prompt_tokens_details; removes now-redundant kimi-k2 fallback in get_token_type_cost_breakdown since _parse_prompt_tokens_details covers it. |
| litellm/proxy/spend_tracking/spend_tracking_utils.py | Refactors prompt_tokens_details lookup to check the Responses API usage_object fallback and derives cache_creation_input_tokens from cache_write_tokens in spend logs; Anthropic-populated cache_creation_input_tokens is guarded against overwrite. |
| litellm/responses/utils.py | Passes cache_write_tokens through in _transform_response_api_usage_to_chat_usage for the typed-object path; the dict path already works via PromptTokensDetailsWrapper(**input_tokens_details). |
| tests/test_litellm/test_cost_calculator.py | Adds regression test verifying combine_usage_objects sums the mirrored field pair exactly once (single 50-token object stays 50; two 50-token objects combine to 100). |
| tests/test_litellm/test_utils.py | Adds four targeted wrapper tests: OpenAI cache_write_tokens populates both names; Anthropic cache_creation_input_tokens normalizes onto both names; no cache-write fields when absent; bidirectional sync on post-construction assignment. |
| tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py | Adds billing regression tests for gpt-5.6 cache-write tokens via Chat Completions and Responses API; asserts exact expected cost at cache_creation_input_token_cost rate using local cost map. |
| tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py | Adds four spend-log tests: OpenAI cache_write_tokens maps to cache_creation_input_tokens; Anthropic value is preserved; zero/absent counts stay absent; Responses API fallback path reads from usage_object. |
| tests/test_litellm/responses/test_responses_utils.py | Adds LIT-4633 regression test asserting _transform_response_api_usage_to_chat_usage preserves input_tokens_details.cache_write_tokens and mirrors it to cache_creation_tokens on the resulting Usage object. |
Reviews (4): Last reviewed commit: "fix(cost_calculator): sum mirrored cache..." | Re-trigger Greptile
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Proof of fix: OpenAI cache-write tokens now logged and billed at the 1.25x rateRan the proxy locally against the real OpenAI API ( BEFORE ( AFTER (this branch): same call now bills Before vs after QA recording Spend log rowsTested via Devin: https://app.devin.ai/sessions/54e6ef97949f4a85942817e18e84e587 |
mateo-berri
left a comment
There was a problem hiding this comment.
The bidirectional sync between cache_write_tokens and cache_creation_tokens runs only in init. Any code that assigns to one field directly on an existing instance (e.g. wrapper.cache_write_tokens = X without also updating cache_creation_tokens) will leave the pair silently out of sync. The PR handles all current mutation sites correctly (the Anthropic else-branch in Usage.init sets both manually), but this pattern is easy to miss for future contributors. A @field_validator or a setattr override that keeps the two fields in sync would make the invariant self-enforcing.
Is this a legit concern? Or no
|
@mateo-berri re the sync-only-in- Same commit also lands the exact LIT-4633 repro. That Nvidia case comes through the Responses API ( |
Responses API QA (LIT-4633): cache-write tokens now logged + billed on
|
|
@gerptile review again with new commits that resolve p2 issue |
QA on tip: prompt-cache-creation (cache_write) billing verified liveRan a live before/after against real
The per-token rate is the billing proof: BASE bills cache-creation tokens at the plain input rate (~1.0e-6) and drops them from spend logs and the UI; TIP bills at the 1.25x cache-write rate and surfaces Response usage on TIP also mirrors the two names (chat Before vs after QA recording Admin UI Logs detail: TIP (after)Chat: "Cache Creation Tokens: 44,552" row, and the Cost Breakdown is now consistent (Input $0.00000300 + Cache Write $0.05569000 (44,552 tokens) + Output $0.00002400 = $0.05571700) Responses API (LIT-4633): "Cache Creation Tokens: 44,554" row, and "Cache Write Cost: $0.05569250 (44,554 tokens)" BASE (before): bug visibleChat on BASE has no logged cache creation and the breakdown is internally inconsistent: a negative Input Cost (-$0.01112100) and an itemized Cache Write Cost ($0.05562000) that does not match the billed total ($0.04460100, plain rate). TIP makes itemization and total agree and bills at 1.25x Responses on BASE shows no cache-write handling at all (Input + Output only, plain rate), the clean LIT-4633 repro |
Live QA for 44c7d62: mid-stream fallback no longer doubles cache tokensRan the proxy from this branch on a random port with a Postgres spend DB. Model group Before ( After ( One precision on blast radius: on this fallback surface the spend log was correct even before the fix because |
…eation billing Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The Responses API (/v1/responses) usage transform rebuilt prompt token details and dropped OpenAI's input_tokens_details.cache_write_tokens, so gpt-5.6 cache-creation tokens were never logged or billed via that route. Map it in the transform, and make PromptTokensDetailsWrapper keep cache_write_tokens and cache_creation_tokens in sync on assignment. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…es API logs On the /v1/responses path the response usage is not chat-Usage-shaped, so additional_usage_values could not derive cache tokens from response_obj.usage and the Admin UI Logs cache-creation token row stayed empty. Fall back to the normalized standard_logging usage_object's prompt_tokens_details for both the cache-read and cache-creation counts. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…itemization (#34309) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…_usage_objects combine_usage_objects iterates prompt_tokens_details model_fields and sums each; with cache_write_tokens and cache_creation_tokens now mirroring each other via __setattr__, the pair was summed twice, doubling cache creation counts for Anthropic batch cost calc, mid-stream fallback usage merges, and realtime usage. Collapse the mirrored pair to one representative before summing.
44c7d62 to
d3f5c6d
Compare
QA on tip (follow-up d3f5c6d): streaming cache-creation tokens logged once (1x), not doubledRe-ran the live before/after against real
This follow-up lives in
Key streaming assertion: on TIP the logged and billed cache-creation count is 44,635, which equals the provider's Before vs after QA recording Admin UI Logs: TIP streaming (after)Cache Creation Tokens: 44,635; breakdown Input $0.00000300 + Cache Write $0.05579375 (44,635 tokens) + Output $0.00002400 = $0.05582075 TIP non-streaming chat and Responses API rows show the same 1.25x billing and populated cache-creation count |
QA, live mid-stream router fallback: the
|
| Metric | BUGGY | TIP |
|---|---|---|
| Fallback model | openai/gpt-5.6-luna | openai/gpt-5.6-luna |
Streamed cache_creation_tokens |
44,545 (1x) | 44,629 (1x) |
| Any ~88k doubled value live? | No | No |
Spend-log spend |
$0.05579225 | $0.05588525 |
Spend-log cache_creation_input_tokens |
44,545 (1x) | 44,629 (1x) |
| Effective rate (spend / prompt tok) | 1.2524e-6 | 1.2521e-6 |
Dollar delta attributable to the doubling: $0.00. The reason: the final streamed usage and the spend log for a recovered fallback come from the real fallback deployment's own accounting (1x, via ChunkProcessor, which overwrites on the same request id), so the doubled combine_usage_objects output is effectively inert in this path. The fix is still correct and worth keeping (it removes a latent double-count and makes the unit behavior provably 1x), but a customer would not have seen doubled billing through mid-stream fallback
One unrelated note observed on this path: in mid-stream fallback the response cost headers reflect the primary deployment (x-litellm-response-cost-original: 0.0, model-id flaky-primary-1) and there is no positive x-litellm-response-cost header on the streamed response; the billed cost lands in the spend log and UI. Identical on both builds, so it's a pre-existing fallback quirk, not related to this fix
If it's worth confirming whether the doubling ever bites a real billed path, the other combine_usage_objects callers are Anthropic batch cost calc and realtime usage merge; those are a separate investigation from mid-stream fallback
Before vs after (BUGGY vs TIP) recording
QA, Anthropic batch + realtime: the
|
| Measurement | BUGGY | TIP |
|---|---|---|
AnthropicBatchesConfig.transform_response combine (the caller) |
29,606 (2x) | 14,803 (1x) |
Real cost/spend path calculate_batch_cost_and_usage (per-item) |
14,803 (1x) | 14,803 (1x) |
Live spend-log cache_creation_input_tokens |
14,835 (1x, = provider) | 14,781 (1x, = provider) |
Live spend-log spend |
$0.01856175 | $0.01849425 |
The combine bug does fire on real Anthropic tokens: BUGGY's transform_response doubles cache-creation to 2x, TIP stays 1x, so the fix works on real data. But the batch cost/spend that customers actually see is computed per-item in _handle_completed_batch -> calculate_batch_cost_and_usage, which never consumes the combined object, so the retrieved usage, computed cost, and Postgres spend are 1x on both builds. Dollar delta to customer billing: $0.00
Realtime usage merge: does not apply. OpenAI realtime reports only cached_tokens (cache READ), which is not part of the mirrored cache_write/cache_creation pair, and realtime models have no cache_creation_input_token_cost and no prompt-caching write. So this caller cannot exhibit the doubling regardless of the build
Net across all three combine_usage_objects callers (mid-stream fallback, Anthropic batch, realtime): d3f5c6d is a correct defensive fix that makes the combine output provably 1x, but none of the live paths produced customer-visible doubled billing. The doubling was latent, not customer-facing, on the paths that exist today
QA recording (BUGGY vs TIP batch spend detail)
Seven live e2e tests covering cost-tracking regressions that currently ship unnoticed: cache-write tokens billed at the cache-creation rate (BerriAI#34046), per-component cost_breakdown on the spend row (BerriAI#31686), cache reads billed at the cache-read discount on streamed calls (BerriAI#34812), cache tokens surviving the anthropic-messages to Responses bridge (BerriAI#34957), priority-tier rates applied to input, output and reasoning (BerriAI#35923, BerriAI#35925), the per-component response cost headers summing to the total (BerriAI#36965), and cost injected into the final usage frame of an /openai passthrough stream (BerriAI#36503). Every test registers its own deployment with a distinct custom rate per component, so a component billed at the wrong rate cannot pass. The shared helpers in cost_rows.py encode the one thing the two surfaces disagree on: the spend row's input_cost is gross of cache while the response's cost-input header is net of it.


















TLDR
Problem this solves:
How it solves it:
cache_write_tokensas canonical OpenAI field on the usage wrappercache_creation_tokensvia__setattr__cache_creation_input_tokensfrom it (no schema change)Relevant issues
Linear ticket
Resolves LIT-4633
Pre-Submission checklist
Screenshots / Proof of Fix
Live proxy, real OpenAI
gpt-5.6-luna, real spend, Postgres-backed spend logs + daily metrics. Each request sends a fresh ~18k-token unique prefix to force a cache write.Before (base
3810130105)After (this branch,
790141ad78)Per-token rate confirms the billing fix: before 0.017574 / 17547 ~ 1.00e-6 (plain input rate); after 0.02321325 / 18549 ~ 1.25e-6 (the 1.25x cache-write rate the cost map defines for the gpt-5.6 family). Cache reads were already correct and stay correct.
The LIT-4633 repro comes through the Responses API (
/v1/responses), whereusage.input_tokens_details.cache_write_tokenswas dropped by the usage transform. This is now mapped in_transform_response_api_usage_to_chat_usageand covered bytest_transform_response_api_usage_maps_cache_write_tokens, so the same normalization (and thelitellm_input_cache_creation_tokens_metric) applies to that route.QA on tip (2026-07-23)
Re-verified with the branch merged onto the current
litellm_internal_stagingtipa507394841(283 commits ahead of the PR base; merge is conflict-free). On tip this branch also composes with the newer kimi-k2cache_write_tokenshandling incost_calculator.pyanddb_spend_update_writer.py: the fallback this branch deletes fromget_token_type_cost_breakdownis covered because_parse_prompt_tokens_detailsnow readscache_write_tokensfirst, so no path loses the countUnit QA on the merge: the suites for
llm_cost_calc,spend_tracking,responsesandtests/test_litellm/test_utils.pypass (1081 tests), plus a sweep over every othertests/test_litellmfile touchingprompt_tokens_details/cache_creation_tokens(streaming aggregation, Anthropic adapters, prometheus token detail metrics, cost calculator: 1550 passed). The one failure in that sweep,test_main.py::test_openai_env_base[OPENAI_API_BASE], fails identically on tip without this branch, so it is pre-existing and unrelatedLive proxy on the merge, real OpenAI
gpt-5.6-luna, Postgres spend DB, fresh ~42k-token unique prefixes:Type
Bug Fix
Changes
OpenAI's
gpt-5.6family returns cache-write tokens inusage.prompt_tokens_details.cache_write_tokens(Chat Completions) andusage.input_tokens_details.cache_write_tokens(Responses API), a field absent from the OpenAI SDK's typed token-details models. LiteLLM only ever mapped the Anthropic/Bedrock top-levelcache_creation_input_tokensonto its internalcache_creation_tokens, so for OpenAI the cache-write count was never surfaced: it was dropped from spend logs and the tokens were billed at the plain input rate instead of the cache-write rate defined in the cost map. This was reported by customers for the GPT-5.6 seriesThis makes
cache_write_tokensthe canonical OpenAI-native name onPromptTokensDetailsWrapperand normalizes both provider dialects onto it. The wrapper keepscache_write_tokens(OpenAI) andcache_creation_tokens(Anthropic/Bedrock, kept for backwards compatibility) in sync automatically: assigning either mirrors to the other via__setattr__, so downstream cost calc, streaming aggregation, and logging can read or write either name and always agreeUsage.__init__maps the Anthropic/Bedrockcache_creation_input_tokensparam ontocache_write_tokens. The Responses API usage transform (_transform_response_api_usage_to_chat_usage) now carriesinput_tokens_details.cache_write_tokensthrough instead of dropping it while rebuilding the token details. The cost parser readscache_write_tokensfirst, then falls back tocache_creation_tokensFor spend logs,
get_logging_payloadderives the existingcache_creation_input_tokensfield fromprompt_tokens_details.cache_write_tokens, mirroring how it already derivescache_read_input_tokensfromcached_tokens; daily metrics reuse that same field, so there are no new DB columns. On the Responses API route the response usage is not chat-Usage-shaped, soadditional_usage_valueshas noprompt_tokens_detailsto read; in that case it falls back to the normalizedstandard_loggingusage_object, which is what makes the Admin UI Logs "Cache Creation Tokens" row render for/v1/responsesReview found the
__setattr__mirror introduced a double-count inBaseTokenUsageProcessor.combine_usage_objects, which sums every field inprompt_tokens_details.model_fields: the mirrored pair got added twice (a single 50-token usage combined to 100), which would have overbilled Anthropic batch cost calc, mid-stream fallback usage merges, and realtime usage aggregation._summable_prompt_token_fieldsnow collapses the mirrored pair to one representative before summingQA runbook
Live flows (proxy from this branch with a gpt-5.6 family model and a Postgres
DATABASE_URL; every cache write needs a fresh, never-seen prefix above the caching minimum, ~18k tokens is comfortable):/v1/chat/completionswith a fresh unique system prefix; expectprompt_tokens_detailsto carry equalcache_write_tokensandcache_creation_tokens, andx-litellm-response-costto price those tokens at the model'scache_creation_input_token_cost(1.25x input for gpt-5.6)cached_tokensequal to the previous write count,cache_write_tokens0, and the cheaper cache-read cost/v1/responseswith a fresh prefix; expectusage.input_tokens_details.cache_write_tokensin the response and the same cache-write pricing (this is the LIT-4633 route)LiteLLM_SpendLogs, expectmetadata.additional_usage_values.cache_creation_input_tokenspopulated for both write requests (chat and responses) and absent for the pure readLiteLLM_DailyUserSpendand the Admin UI Logs "Cache Creation Tokens" row, expect cache creation to accumulate with no schema changeoverloaded_errorSSE event and falls back to a real Anthropic deployment using a freshcache_controlprefix; expect the final usage chunk'scache_write_tokens/cache_creation_tokensto equal the provider's actual write count instead of double it (live proof in the PR comments, before 69640 vs after 34878 on ~35k-token prompts)Added tests and what they pin down:
tests/test_litellm/test_utils.py(wrapper normalization): OpenAIprompt_tokens_details.cache_write_tokensand Anthropic top-levelcache_creation_input_tokensboth populate the field pair, the pair stays mirrored on post-construction assignment, and neither field materializes on a read-only cache hittests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py(billing): gpt-5.6 cache-write tokens are billed atcache_creation_input_token_cost, including the Responses API breakdown itemizationtests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py(spend logs):cache_creation_input_tokensis derived fromcache_write_tokens, Anthropic's explicit value is preserved and not clobbered, zero or missing counts stay absent, and the Responses API route fills it from theusage_objectfallbacktests/test_litellm/responses/test_responses_utils.py(LIT-4633 regression):_transform_response_api_usage_to_chat_usagecarriesinput_tokens_details.cache_write_tokensthrough instead of dropping ittests/test_litellm/test_cost_calculator.py(aggregation regression):combine_usage_objectssums the mirroredcache_write_tokens/cache_creation_tokenspair once; a single 50-token usage stays 50 and two combine to 100, not doubleFinal Attestation
Link to Devin session: https://app.devin.ai/sessions/dc0e2d7e95374c7eb7fcbaf0d21ff1f6
Requested by: @mateo-berri