Skip to content

feat(billing): bill cache-write (cache-creation) tokens - #433

Merged
argszero merged 2 commits into
mainfrom
feat/bill-cache-write-tokens
Oct 23, 2026
Merged

argszero merged 2 commits into
mainfrom
feat/bill-cache-write-tokens

Conversation

@argszero

@argszero argszero commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

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_m was deserialized, validated and documented, but reached neither the database nor settlement: the models table had no such column, dao::get_model_price did not select it, and billing::raw_cost had 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 and sse.rs already deserializes cache_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

  • Storage: additive ensure_column on models, usage_records and transactions; seed_models carries the price in its INSERT and its ON CONFLICT DO UPDATE list; dao::get_model_price selects it. No SCHEMA_VERSION bump — 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.
  • Carry: parse_usage, parse_anthropic_usage, UsageCapture and settle_usage gain the fourth count, read from usage.cache_creation_input_tokens. Protocols that do not report the field pass 0 rather than a guess.
  • Price: raw_cost and calc_points take the operand, priced at the model-level cache_write_input_per_m. ModelTier and peak_* carry no cache-write variant, so neither tier selection nor peak hours can change it.
  • Archive: both archive specs and their row-to-JSON builders cover the new column, so archived detail does not silently lose it. (The repo's own the_archive_covers_every_column_of_the_table test caught this when the column was added, which is the guard working as intended.)
  • Config notes: config/config.example.toml section 4 and the doc comment on the field in src/config.rs no 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:

  • tokens keeps its meaning (input + cached + output). Five readers derive "input" as tokens - cached_tokens - output_tokens (routes/wallet.rs sort key and two aggregates, db.rs monthly aggregate), so folding cache writes into tokens would inflate the input shown for every Anthropic request that reports them. The count lives in its own column instead.
  • Unset stays 0 and never falls back to 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)

  • Cross-protocol streaming (inbound openai_chat → outbound anthropic) records usage through sse'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.
  • The admin model editor (src/routes/admin_models.rs) does not expose the new price. Config is the declared single source for the model catalogue and seed_models overwrites runtime edits on the next start; extending the admin surface is a separate change with its own UI.
  • The rolled-up read model (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.
  • A pre-existing asymmetry found in passing, deliberately not fixed: UsageCapture::finish applies the OpenAI-shaped (input - cached) correction to Anthropic too, while Anthropic's input_tokens excludes 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 still upstream/main):

  1. Absent field, default price — bill unchanged. A call carrying cache-write tokens and the same call without them settle to bit-identical points and bit-identical recorded cost, and both equal the pre-change arithmetic (1000 x $2/M = 0.002 USD x 7.2 = 0.0144 points). Asserted on the ledger rows, not on the function returning: the equality is between two settled bills, which is what makes it evidence.
  2. The widened guard, both directions. A response reporting only cache-write tokens settles (it used to be dropped as "no data"). A response reporting nothing at all — all four counts zero — still writes no transaction row and moves no balance, so "unmetered" has not quietly become "billed zero".
  3. No double count on the untouched paths. Bridging OpenAI to Anthropic subtracts cache writes from prompt_tokens for 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 calls parse_usage on the raw upstream bytes before transform_response runs; the streaming cross-protocol path takes its numbers from sse::UsageSlot, which is Arc<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 — clean
  • cargo clippy --all-targets — clean, no new warnings

New 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, while tokens stays input + cached + output.

Checklist

  • Branch follows the convention (feat/bill-cache-write-tokens)
  • Conventional Commits (feat(billing): bill cache-write (cache-creation) tokens)
  • Single responsibility — one wire, not three split PRs: a column with no writer cannot be tested and a charge with no column cannot be audited

argszero 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
@argszero
argszero merged commit 357ab01 into main Oct 23, 2026
2 checks passed
@argszero
argszero deleted the feat/bill-cache-write-tokens branch October 23, 2026 22:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(billing): price cache-write (cache-creation) tokens

1 participant