Repository navigation
feat(billing): bill cache-write (cache-creation) tokens - #433
Merged
Merged
Conversation
added 2 commits
October 24, 2026 02:41
`cache_write_input_per_m` was deserialized, validated and documented, but reached neither the database nor the settlement path: `models` had no such column, `dao::get_model_price` did not select it, and `billing::raw_cost` took no cache-write operand. The config note explained the gap as "there is no path to obtain the cache-write token count", which was too strong: the count already arrives in `usage.cache_creation_input_tokens` and `sse.rs` already deserializes it — only for protocol conversion, after which it is dropped. What was missing was a billing path, not a parse path. The wire, Anthropic only (#432): * storage — additive `ensure_column` on `models`, `usage_records` and `transactions` (no `SCHEMA_VERSION` bump, matching the earlier price columns); `seed_models` carries the price in its INSERT and its `ON CONFLICT DO UPDATE` list; `dao::get_model_price` selects it. * carry — `parse_usage`, `parse_anthropic_usage`, `UsageCapture` and `settle_usage` gain the fourth count. Protocols that do not report the field pass 0 rather than a guess. * price — `raw_cost`/`calc_points` take the operand, priced by the model-level `cache_write_input_per_m`. Tiers and peak hours do not carry it, so selection cannot change it. * archive — both archive specs and their row-to-JSON builders cover the new column, so archived detail does not silently lose it. `tokens` keeps its meaning (input + cached + output): five readers derive "input" as `tokens - cached_tokens - output_tokens`, so folding cache writes in would inflate the input shown for every Anthropic request. The entry guard now also counts the fourth segment, so a response reporting only cache writes is no longer dropped as "no data to settle". Default stays 0 and never falls back to `input_per_m`, so no existing bill moves; a test pins that a call with cache-write tokens charges exactly the same points as the same call without them. Refs #432
The change widened settlement's entry guard to include the fourth count, so a response reporting only cache-write tokens is no longer dropped as "no data to settle". That widening has a second face, and it is the one that would be lost silently: a response reporting nothing at all must still take the old early-return path, or "unmetered" becomes "billed zero" and the distinction the surrounding comments insist on is gone. Both sides are now asserted on ledger values rather than on the function returning: * absent field at the default price — the points and the recorded cost of a call carrying cache-write tokens are bit-identical to the same call without them, and both equal the pre-change arithmetic (1000 * $2/M = 0.002 USD * 7.2 = 0.0144 points), so the equality is between two real settlements and not two early returns. * nothing reported — no transaction row and no balance movement. The ledger query reads the two tables separately: one settlement writes two transactions (consume + earn) but one usage_records row, so joining them on id pairs rows that do not belong together. Refs #432
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.
Summary
Bills cache-write (cache-creation) tokens on the Anthropic path, so a model that configures a cache-write price actually charges for the prompt it writes into the upstream cache.
cache_write_input_per_mwas deserialized, validated and documented, but reached neither the database nor settlement: themodelstable had no such column,dao::get_model_pricedid not select it, andbilling::raw_costhad no cache-write operand. The config note explained that gap as "there is no path in the upstream response to obtain the cache-write token count". That was too strong, and this change corrects it: the count already arrives andsse.rsalready deserializescache_creation_input_tokens— for protocol conversion only, after which it is dropped. What was missing was a billing path, not a parse path.Related Issue
Closes #432
Changes
ensure_columnonmodels,usage_recordsandtransactions;seed_modelscarries the price in its INSERT and itsON CONFLICT DO UPDATElist;dao::get_model_priceselects it. NoSCHEMA_VERSIONbump — the earlier price columns (cache_hit_input_per_m,peak_*,tiers_json) arrived the same way, and the bump stays reserved for shapes an additive column cannot express.parse_usage,parse_anthropic_usage,UsageCaptureandsettle_usagegain the fourth count, read fromusage.cache_creation_input_tokens. Protocols that do not report the field pass 0 rather than a guess.raw_costandcalc_pointstake the operand, priced at the model-levelcache_write_input_per_m.ModelTierandpeak_*carry no cache-write variant, so neither tier selection nor peak hours can change it.the_archive_covers_every_column_of_the_tabletest caught this when the column was added, which is the guard working as intended.)config/config.example.tomlsection 4 and the doc comment on the field insrc/config.rsno longer say the field is unbilled, since this change is what makes that obsolete.Two decisions worth stating, because they are the ones a reviewer would otherwise ask about:
tokenskeeps its meaning (input + cached + output). Five readers derive "input" astokens - cached_tokens - output_tokens(routes/wallet.rssort key and two aggregates,db.rsmonthly aggregate), so folding cache writes intotokenswould inflate the input shown for every Anthropic request that reports them. The count lives in its own column instead.input_per_m. A fallback would reprice every deployment that never opted in. The settlement guard now also counts the fourth segment, so a response reporting only cache writes is no longer dropped as "no data to settle".Known boundaries (not addressed here)
openai_chat→ outboundanthropic) records usage throughsse's converter, which carries three counts; cache writes on that path count as 0. See the double-count check below for why that cannot become a wrong number.src/routes/admin_models.rs) does not expose the new price. Config is the declared single source for the model catalogue andseed_modelsoverwrites runtime edits on the next start; extending the admin surface is a separate change with its own UI.transactions_rollup) does not carry the column. Nothing reads it yet, and the detail archive does carry it; carrying it into the fold is part of that table's own additive list when something needs it.UsageCapture::finishapplies the OpenAI-shaped(input - cached)correction to Anthropic too, while Anthropic'sinput_tokensexcludes cache reads already. Streaming Anthropic therefore under-counts the uncached input (in the new test's example: 0 where the non-streaming parser reports 100). Fixing it raises existing bills, which is outside this change's invariant, so a test pins the current behaviour and names it as a separate issue.Verification of the "no existing bill moves" invariant
Three checks a settlement change owes, all run on this branch (cut from
259c497, which is stillupstream/main):prompt_tokensfor shape and writes them back into the converted payload, so the risk was that payload reaching settlement and carrying the count twice. It cannot, for three structural reasons: settlement never parses a converted body — the non-streaming path callsparse_usageon the raw upstream bytes beforetransform_responseruns; the streaming cross-protocol path takes its numbers fromsse::UsageSlot, which isArc<Mutex<Option<(f64, f64, f64)>>>and structurally holds three counts; and that path passes 0 explicitly as the fourth. The converted Anthropic-shaped usage is written only to the client-facing stream (latest_usage/pending_message_delta), never back into settlement.Tests
cargo test— 582 passed, 0 failed (baseline 579, plus three new tests below)cargo fmt --check— cleancargo clippy --all-targets— clean, no new warningsNew tests:
billing::tests::calc_points_cache_write_is_a_third_input_price— the arithmetic: the three input segments do not overlap, and a 0 price charges 0.gateway::tests::both_usage_parsers_read_anthropic_cache_creation_tokens— the count is read on both the non-streaming and the streaming path, and is 0 for protocols that do not report it.gateway::tests::settle_bills_cache_write_tokens_at_the_configured_price— the wire end to end: with the default price a call carrying cache-write tokens charges exactly the same points as the same call without them; with a price set, the charge equals the configured rate and both ledger columns hold the count, whiletokensstays input + cached + output.Checklist
feat/bill-cache-write-tokens)feat(billing): bill cache-write (cache-creation) tokens)