Skip to content

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

Closed

Description

@argszero

Summary

cache_write_input_per_m exists in the model config: it is deserialized, validated, documented and present in the example config. Nothing prices it. This issue proposes wiring it into the billing path, so a model that configures a cache-write price actually charges for cache-creation tokens.

CONTRIBUTING.md asks that a large change be discussed in an issue before it is built, which is why this is a proposal rather than a pull request.

What the repository says today

config/config.example.toml section 4 (model catalogue) states that the field is expressed but not billed. Translated from the Chinese original:

Cache-write price (rant 2026-10-16T14:08:14): optional cache_write_input_per_m (per million tokens), default 0 means cache writes are not billed, as today. This field currently only expresses, it does not bill: there is no path in the upstream response to obtain the cache-write token count (gateway parse_usage / sse do not produce it), so wiring it into billing needs another usage parse and is a later change.

src/config.rs:472-479 carries the same note on the field itself, and src/config.rs:541-548 rejects a negative value.

One correction to that note

The note's conclusion is right; its second clause is too strong. The count already arrives and is already deserialized — what is missing is a billing path, not a parse path:

  • src/sse.rs:164 — StreamUsage already declares cache_creation_input_tokens: Option<u32>.
  • src/sse.rs:585 — build_anthropic_usage_json reads it.
  • src/protocol.rs:1217 — the Chat/Responses to Anthropic conversion reads it out of the JSON body.

Both conversion sites use it for shape only: each subtracts it from prompt_tokens with saturating_sub, so an Anthropic-shaped usage does not report cache writes as ordinary input, and then writes cache_creation_input_tokens back into the converted payload. Nothing downstream of those two functions reads it.

sse::extract_cache_read_tokens extracts the read side only, so "sse does not produce it" holds for the extract_* helpers but not for StreamUsage itself.

Stated precisely, the gap is: the cache-creation token count reaches the process and is discarded before settlement.

Where it is dropped

  • src/gateway.rs:58-120 — parse_usage returns a 3-tuple (input, cached, output); its Anthropic branch reads input_tokens, cache_read_input_tokens and output_tokens, but not cache_creation_input_tokens.
  • src/gateway.rs:839 — parse_anthropic_usage, used for the streaming message_start event, likewise returns (input, cached).
  • src/gateway.rs:736 — UsageCapture keeps the same three-way split.
  • src/gateway.rs:161 — settle_usage(input_tokens, cached_tokens, output_tokens) writes transactions, usage_records and keys.used from those three numbers.
  • src/billing.rs:44 — raw_cost(input_tokens, cached_tokens, output_tokens, input_per_m, cache_hit_input_per_m, output_per_m) has no cache-write operand. calc_points (src/billing.rs:58) wraps it, and the only other caller is src/gateway.rs:218, inside settle_usage.

Storage has no room for it either

  • The models table (src/db.rs) holds only provider, model, currency, input_per_m, output_per_m, updated_at. Every other price column — cache_hit_input_per_m, the peak_* triple, tiers_json — arrives additively through ensure_column (src/db.rs:469), and dao::get_model_price (src/dao.rs:202) selects them explicitly. So cache_write_input_per_m is absent from the database even where config sets it: the seed_models INSERT list (src/db.rs:633) does not carry it.
  • usage_records and transactions each store tokens, cached_tokens, output_tokens, and no cache-write column, so even a correct charge would not be auditable per row.

Proposed scope

  1. Storage — ensure_column("models", "cache_write_input_per_m", ...), extend the seed_models INSERT and its ON CONFLICT DO UPDATE list, and add the column to dao::get_model_price's SELECT. Add a cache-write token column to usage_records and transactions so the charge can be audited.
  2. Carry the count — extend parse_usage, parse_anthropic_usage, UsageCapture and settle_usage with a fourth count. sse.rs already holds the field; the OpenAI-family spellings under prompt_tokens_details need the same treatment cached_tokens already gets.
  3. Price it — extend raw_cost and calc_points with a cache-write operand, priced at cache_write_input_per_m for the tier selected by billing::select_tier.
  4. Tests — a raw_cost gold case with a non-zero cache-write price; a settle_usage database test asserting both the charge and the stored column; an idempotency test alongside the existing settlement ones. cargo test, cargo fmt --check and cargo clippy --all-targets green.

Questions for a maintainer

  • Tiers. ModelTier (src/config.rs) carries input_per_m, cache_hit_input_per_m and output_per_m. Should a tier also be able to carry a cache-write price, or is the model-level value enough for the four models that currently have tiers (gemini-3.1-pro, grok-4.6, grok-4.7, minimax-m3)?
  • Fallback when unset. The default stays 0, meaning free — today's behaviour, so no existing bill moves. Is that the intent, or should an unset cache-write price fall back to input_per_m on the grounds that some providers charge cache writes above the input rate?
  • Schema version. Is one column on models plus two on the usage tables worth a SCHEMA_VERSION bump, or does the additive ensure_column path stay unbumped, as the earlier price columns did?

I am glad to implement this along the lines above, but I would rather not change the settlement path on a guess. A word on those three questions and I will open the PR. If the scope is too large for a single change, I will split it into three — storage, then carrying the count, then pricing — as separate PRs.

Activity

  1. argszero commented on Oct 23, 2026

    @argszero
    OwnerAuthor

    I'd like to work on this. I will wait for a word on the three questions above before touching the settlement path; if the answers are not in yet, I will start with step 1 (storage), which is purely additive and moves no existing bill.

  2. argszero commented on Oct 23, 2026

    @argszero
    OwnerAuthor

    Answering the three questions myself, since the proposal's author and the implementer are the same account here. The short version: the scope is the Anthropic path only, and the change must not move a single existing bill.

    1. Tiers — no, model-level only. The four models that carry [[models.tiers]] today are gemini-3.1-pro, grok-4.6, grok-4.7 and minimax-m3. None of them is the path that emits cache_creation_input_tokens, so a per-tier cache-write price would be config that nothing can ever charge — a field with no writer, which is the exact defect this issue exists to remove. ModelTier stays untouched. A per-tier cache-write price becomes real work only if a tiered provider starts billing cache writes; until then the field would be decorative.

    2. Unset means free — the default stays 0, with no fallback. Falling back to input_per_m would silently reprice every existing deployment that never opted in: config that says nothing would start charging more than it does today. 0 keeps the invariant this change is judged by — no existing bill moves — and a deployer who wants the charge sets the price. Note this is not merely the conservative choice: some providers charge cache writes above the plain input rate, so a fallback would also be wrong in the cases it appeared to help.

    3. No SCHEMA_VERSION bump. The earlier price columns (cache_hit_input_per_m, the peak_* triple, tiers_json) all arrived as additive ensure_column calls without a bump, and the new columns are the same shape: one on models, plus the token count on usage_records and transactions. The version bump has been reserved for shapes an additive column cannot express.

    Implementation follows in one PR rather than three. The reason is testability, not convenience: a column with no writer cannot be tested, and a charge with no column cannot be audited, so splitting the wire would leave two of the three pieces unverifiable. Scope is the Anthropic path (usage.cache_creation_input_tokens) on both the non-streaming and streaming parsers. The OpenAI-family spellings are left alone: if a provider does not send a field, the count is 0 rather than a guess.

  3. argszero commented on Oct 23, 2026

    @argszero
    OwnerAuthor

    Implemented in #433, merged as 357ab01. Recording what is now billed and what is deliberately still free, so this does not have to be rediscovered later.

    Now billed. Anthropic cache creation. usage.cache_creation_input_tokens reaches settlement from both parsers (parse_usage for non-streaming, UsageCapture / parse_anthropic_usage for streaming) and is priced at the model's cache_write_input_per_m, which now exists as a column on models alongside the other price columns. The count is stored on usage_records and transactions so a charge can be audited per row. Three boundaries are structural, not omissions: ModelTier carries no cache-write price, so tier selection cannot change it; peak_* has no cache-write variant, so peak hours cannot either; and the default price stays 0 with no fallback to input_per_m, so a deployment that never sets the field bills exactly what it billed before.

    Still free, and why that is the end of it. Nothing in the OpenAI-family shape we parse carries a separate cache-write count. prompt_tokens_details models cached_tokens and nothing else in sse::PromptTokensDetails, and prompt_tokens_details.cache_creation_tokens is not a field any of them emits. The one catalogue provider that does distinguish cache writes is DeepSeek, and its spelling needs no new field: prompt_tokens = prompt_cache_hit_tokens + prompt_cache_miss_tokens, so the miss segment is the write, and parse_usage already bills it — prompt_tokens - cached is priced at input_per_m. That segment is not free today; it is billed at the input rate, which is the rate DeepSeek charges for it.

    So there is no OpenAI-family follow-up to file. If a provider starts emitting a distinct cache-write count, it belongs on this thread and the fix is the same shape as this one: carry the count, price it, store it.

    Two things this change leaves open, named rather than hidden.

    • Cross-protocol streaming (inbound openai_chat, outbound anthropic) records usage through sse::UsageSlot, which is Arc<Mutex<Option<(f64, f64, f64)>>> — three counts by construction — so cache writes on that path bill at 0. Settlement never parses a converted body (it reads the raw upstream bytes before transform_response, or the slot), so this cannot double count; it simply does not count yet.
    • src/routes/admin_models.rs cannot set the new price, so a model created through the admin API prices cache writes at zero. Config is the declared single source for the catalogue and seed_models overwrites runtime edits on restart, so this matches how peak_* already behaves — but it is a real gap in the admin surface, and it is the next thing worth doing rather than a docs line.

    An unrelated defect found while writing the tests, not fixed here: UsageCapture::finish applies the OpenAI-shaped (input - cached) correction to Anthropic too, while Anthropic's input_tokens already excludes cache reads. Streaming Anthropic therefore under-counts uncached input — 0 where the non-streaming parser reports 100 on the same payload. Fixing it raises existing bills, so a test pins the current behaviour and it needs its own decision.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions