Repository navigation
feat(vertex_ai): bill explicit context cache storage per token-hour - #45019
Open
roasted-penguin wants to merge 9 commits into
Open
roasted-penguin wants to merge 9 commits into
roasted-penguin wants to merge 9 commits into
Conversation
Vertex AI and Gemini bill explicit context caches for storage by token-hour, on top of the input tokens used to create them. litellm never priced this, so cache storage never reached any spend aggregation. Add a cache_storage_cost_per_token_per_hour pricing key and, when litellm creates a cachedContents resource, record tokens x lifetime (expireTime - createTime, falling back to the requested ttl, then the one-hour provider default) on the request's logging object. completion_cost turns that into additional_costs["cache_storage_cost"] using the deployment or cost-map rate, so it lands in the same request's spend and cost breakdown. Reused caches and models without the key add nothing.
… diffs to additions cost-map-guard runs the base branch's generator under pull_request_target, so a new COST_DESCRIPTIONS entry in this PR made the committed schema diverge from what CI renders. Drop the description and regenerate the schema. Also revert unrelated reformatting of existing test files so the diff only adds the new tests.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Author
Contributor
|
…ce storage from the underlying model A deployment that sets only cache_storage_cost_per_token_per_hour no longer turns on custom pricing, so its input and output tokens keep the model's rates. The storage rate is read from the deployment entry first, then from the cost map entry of the underlying model rather than the deployment id, so custom token prices without a storage rate still bill storage.
Author
…ache-storage-cost # Conflicts: # tests/unit/test_cost_calculator.py # tests/unit/test_utils.py
Narrow the arguments passed to the storage cost helper, add the storage charge to the total outside the additional_costs sum, and type the new test helpers so the changed files add no reportUnknownArgumentType or reportGeneralTypeIssues errors. Raise the reportUnknownArgumentType limit by 97 for the new cache_storage_cost_per_token_per_hour pricing field: like every pricing field, it adds one diagnostic at each existing untyped GenericLiteLLMParams(**kwargs) call site (same approach as BerriAI#43764).
roasted-penguin
marked this pull request as ready for review
October 8, 2026 03:05
roasted-penguin
requested review from
kerrylu-berri,
mateo-berri and
ryan-crabbe-berri
as code owners
October 8, 2026 03:05
Author
…ache-storage-cost # Conflicts: # basedpyright-code-budget.json
The deployment-level `cache_storage_cost_per_token_per_hour` override moves
from `litellm_params` to `model_info`, next to `cost_per_ptu_per_hour`. It is
a separate per-hour charge rather than a token price, so it no longer joins
`CustomPricingLiteLLMParams`: it never switches on custom pricing, and every
untyped `GenericLiteLLMParams(**kwargs)` call site stops gaining a diagnostic,
which keeps basedpyright at its merge-base counts now that the budget file is
gone. The field is kept off the shared `{provider}/{model}` cost-map key so one
deployment's rate never reaches its siblings.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TLDR
Problem this solves:
How it solves it:
cache_storage_cost_per_token_per_houradditional_costs["cache_storage_cost"]on that requestUser Flow
Before: a developer whose prompts trigger litellm's automatic context caching on Vertex AI sees spend that misses the cache storage line of the Google bill
vertex_ai/gemini-2.5-prowith a large system message carryingcache_control: {"type": "ephemeral", "ttl": "3600s"}After: the request that created the cache also carries its storage charge
cache_controlsystem messagecache_storage_costin the cost breakdownRelevant issues
Related: #41861, #41874, #11049
#41874 bills the input tokens used to create the cache and says hourly storage is not modeled. This PR covers only storage and stays out of the lines #41874 changes as much as it can (it adds one call after the create response in each of the sync and async paths). #11049 proposed the same key name and was closed without landing
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/unit/<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).
Why this is billable and can be priced at creation
The Vertex AI context caching overview says "For explicit caching, there are also storage costs based on how long caches are stored. There are no storage costs for implicit caching" (https://cloud.google.com/vertex-ai/generative-ai/docs/context-cache/context-cache-overview). On a Cloud Billing export this shows up as a separate "... Text Input Caching Storage" style SKU metered in token-hours, apart from the input token SKUs
The Vertex AI pricing page has a "Context Cache Storage price for Explicit Caching" table priced per token per hour, with the same rate below and above 200K input tokens (https://cloud.google.com/vertex-ai/generative-ai/pricing, read 2026-10-07). Rates added to the cost map from that table:
gemini-2.5-pro,gemini-3.1-pro-preview,vertex_ai/gemini-3.1-pro-preview,vertex_ai/gemini-3-pro-previewvertex_ai/entries, plusgemini-3-flash-previewvertex_ai/entries, plus the 3.1 Flash Lite preview idsgemini-2.5-flash,gemini-2.5-flash-liteEntries the table does not name (dated preview snapshots,
-customtools, TTS, computer use, image and Gemma models, Gemini 2.0) and allgemini/(Google AI Studio) entries are left without the key, so they keep today's behavior. The AI Studio rates live on https://ai.google.dev/gemini-api/docs/pricing and can follow in a separate PRThe cost is fully known when litellm creates the cache. The cachedContents create response carries
usageMetadata.totalTokenCount,createTimeandexpireTime, so the charge istotalTokenCount x (expireTime - createTime) in hours x rate. If the response has no times, litellm falls back to thettlit sent, then to the 60 minute default ("The default expiration time of a context cache is 60 minutes after it's created", https://cloud.google.com/vertex-ai/generative-ai/docs/context-cache/context-cache-create)How it is wired
The vertex context caching code computes the token-hours with a small pure function (
litellm/llms/vertex_ai/context_caching/storage_cost.py) and records them on the request's logging object, only when it actually creates a cache.completion_costthen multiplies them by the rate, taking the deployment's own storage rate first and otherwise the cost map entry of the underlying model (not the deployment id), and adds the result asadditional_costs["cache_storage_cost"]. That puts the charge in the same request'sresponse_cost,cost_breakdownand SpendLogs row, so key, team, user and tag attribution come for freeI picked
additional_costsover the RAG-stylemodel_call_details["additional_response_cost"]. The latter is added to the total aftercompletion_costreturns, so it never appears incost_breakdown, skips the discount and margin config, and is invisible to callers that callcompletion_costdirectly._get_additional_costsitself is left alone because it only sees provider config and token counts, not the logging objectcache_storage_cost_per_token_per_houris added to the cost mapsample_spec, the generated JSON schema (regenerated with the unchanged generator, sincecost-map-guardrenders it with the base branch's generator), andModelInfoBase/get_model_info. A deployment overrides it undermodel_info, next tocost_per_ptu_per_hour, not underlitellm_params: it is a separate per-hour charge rather than a token price, so it never turns on custom pricing (a deployment that sets only this key keeps the model's token rates), and it is kept off the shared{provider}/{model}cost-map key so one deployment's rate never reaches its siblings.schema.d.tsgains the optional field on the routerModelInfoBackward compatibility: a model without the key, a request that reuses an existing cache, a request that passes its own
cached_content, and every non-Gemini request all add 0 and leave totals andcost_breakdownunchangedScreenshots / Proof of Fix
Live run against a real Vertex AI project through a local proxy (
litellm --config config.yaml, Postgres for SpendLogs). Project, credentials and resource names are omitted.config.yaml:Request (system prompt of ~6.5k tokens marked for caching with a 600s TTL; a fresh marker per run so each run creates a new cache):
Before (merge base
d364d5e7ca): the cache is created (cache_read_input_tokens: 6496), but nothing accounts for storing itAfter (PR tip
c231b7f530), withLITELLM_LOCAL_MODEL_COST_MAP=Trueso the proxy reads the cost map from this branch:Call 1 creates the cache. The create response reports
usageMetadata.totalTokenCount = 6498andexpireTime - createTime ≈ 600s, so storage = 6498 × (600 / 3600) h × $1e-6 = $0.00108300.00019764 + 0.0001725 + 0.00108297 = 0.00145311
Call 2 sends the identical request, reuses the existing cache, and adds no storage
Unit coverage, with only the HTTP client mocked:
uv run pytest tests/unit/llms/vertex_ai/context_caching/ tests/unit/test_cost_calculator.py tests/unit/test_utils.py tests/unit/test_model_prices_schema.py966 passedmake lintpassesNew tests:
tests/unit/llms/vertex_ai/context_caching/test_storage_cost.py: lifetime from the response, requested ttl fallback, 60 minute default, unparseable ttl, malformed or token-less responses give 0tests/unit/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py: creating a cache records tokens x hours on a real logging object, sync and async; a second identical request that reuses the cache records nothing, sync and asynctests/unit/test_cost_calculator.py: storage is added to the creating request's cost and breakdown at the cost map rate; a model without the key costs exactly the same as before; a deployment's custom rate wins over the cost map; a deployment with custom token prices but no storage rate bills storage at the underlying model's rate; a storage-onlymodel_infooverride keeps the underlying token prices and never reaches the shared backend key (Router based); storage joins provider additional costs such as the Azure Model Router fee instead of replacing themtests/unit/test_router_model_cost_isolation.py: the shared-key denylist guard also accepts the deployment-scoped storage ratetests/unit/test_utils.py:get_model_infosurfaces the key for every cost map entry that has itRates in tests are read from the cost map, never pinned
CI notes for
0b07cb5788(101 pass, 2 fail, both in the Rust workflow; this PR changes no Rust):rust-wheelfailstests/test_litellm_rust/test_traces.py::test_schema_setup_uses_configured_retention, the same failuremainshows right now (run 37735511563 at91f36f1774)rust-testran out of runner disk while compiling ("No space left on device" buildinglitellm-secrets); onmainthe same job is currently cancelled. A re-run needs repo admin rightsThe lint gates (basedpyright, type discipline, ruff strict, test quality) pass at their merge-base counts.
Type
🆕 New Feature
Caveats (if any)
Medium
cachedContentsroutes are not covered, follow-upLow
gemini/(AI Studio) entries have no storage rate yetmain, proxies that fetch the remote cost map (the default) see no storage rate and add 0; after merge the remote map carries the keyFinal Attestation