ADR-030: Cost settlement correctness — streaming usage, cache tiers, and unpriced routes¶
Date: 2026-07-28
Status: Accepted
Related: ADR-003 (governance differentiation — chargeback is the headline
claim), ADR-007 (chargeback report), ADR-006 (one pricing table per topology
generation), ADR-018 (audit cost ref), ADR-029 (model-level fallback — a new
source of unpriced targets)
Context¶
Cost came out 0 µUSD for every request on the live demo deployment. Measured
directly against GET /admin/logs:
| model | input_tokens | output_tokens | cost_micros |
|---|---|---|---|
global.anthropic.claude-opus-5 |
96 | 79 | 0 |
global.anthropic.claude-opus-5 |
1 | 1896 | 0 |
global.anthropic.claude-sonnet-5 |
158 | 7 | 0 |
Tokens were counted correctly; only the money was wrong. The arithmetic in
internal/pricing (integer µUSD, round-half-even via math/big) turned out to
be correct — every defect was in the inputs to it. Five independent ones:
- Streaming billed output tokens only. The settlement path kept the last
usage-bearing frame's top-level
usage, which on the Anthropic wire ismessage_delta— and amessage_deltacommonly carriesoutput_tokensalone. Input and prompt-cache counts ridemessage_start, nested undermessage.usage, and were never read. Measured on the repo's own streaming fixture: 5 µUSD billed where 52 was correct — a 10.4x under-bill. Claude Code traffic is effectively all streaming, so this was the dominant error. - Cache writes always billed at the 5-minute tier.
schema.CacheCreationalready carried the TTL split and no settlement path read it, so the 1-hour tier (2x the input rate against 5m's 1.25x) was unreachable in production. The code comment claiming the schema "does not yet split cache_creation by TTL" was false. - Cache rates had to be declared per model, or they were zero. A config
setting only
input_per_mtok/output_per_mtok— the natural thing to write, and exactly what the demo does — billed every cache read and write at zero. On a prompt-cache-heavy workload that is most of the spend. - Bedrock cross-region prefixes missed the rate table. The demo routed to
global.anthropic.claude-*while pricing (at most) the unprefixed id, so lookup missed entirely. Bedrock Converse additionally dropped cache tokens at the client-struct level, while the InvokeModel passthrough preserved them — the same model costing different amounts depending on API mode. pricing.on_missing: "block"was dead config.Table.OnMissing()had no caller outside the package.allowandblockbehaved identically: cost 0,pricing_missing: true, request served. An operator who setblockbelieving unpriced traffic was refused was serving it free.
Two smaller ones in the same path: the OpenAI-compatible ingress billed cached
prompt tokens at the full input rate (prompt_tokens includes
cached_tokens) — over-billing, the opposite direction; and a stream that broke
mid-flight skipped settlement entirely, so tokens already delivered were free
with no flag to show it.
Why it survived: no test anywhere asserted the settled cost of a streaming
request. internal/pricing's tests called CostUSDMicros directly with a
hand-built Usage, validating the arithmetic and nothing about the mapping.
Worse, the mock provider put input and output on message_delta — a shape no
real upstream produces — so the streaming tests that existed could not have
caught defect 1.
Decision¶
2026-10-02 extension: per-attempt whole-context price tiers¶
An optional paired short_context_limit/long_context config declaration
selects rates from the full disjoint input/cache sum. Equality stays short;
strictly exceeding the limit applies long rates to the entire attempt, including
output. All ingress settlement paths and checked shared/durable settlement use
the same selector. Reservations conservatively take each token class's maximum
across both tiers; a long rate may be lower for one class and higher for another.
Malformed overlays cannot borrow a bundled/base price to bypass refusal.
CostRef.context_tier is appended with omitempty, backed by an exact legacy
fixture and mixed-version/tamper hash-chain test. OTel settlement events retain
each retry's observed amount and version/tier; span scalar cost remains the last
attempt for compatibility. Audit remains the ledger because trace sampling and
export loss are possible. Historical audit/financial liabilities are not repriced.
Verified Standard commercial GPT cards require exact global/regional price
rows, explicit model-specific cache prices, and special treatment where a card
publishes no cache-write price. The old blanket statements below about region
price parity and fixed cache ratios are legacy defaults, not current provider
facts. Tiered Standard requests explicitly selecting an unpriced service tier
refuse before billing. An existing tiered row cannot be flattened by
pricing sync. See the pricing guide.
Observation evidence and sync protection (2026-10-02)¶
Usage-to-audit conversion is shared across the four inference ingresses. New records append optional completeness/TTL evidence without changing historical bytes, legacy cost calculation or durable authority settlement. A flat cache write mapped to 5m is an estimate, not a measured TTL. Unknown or unsupported TTLs must remain distinguishable from complete 5m/1h observations.
Each traced cost settlement snapshots its own routed model, config provider, token counts, price metadata and uncertainty, with a generated event ID for export deduplication. The span scalar remains the last settled attempt. Events are diagnostic and do not establish complete billing coverage or correlate client and gateway records. No user, key, session or prompt data is added to metrics.
The input/output-only sync generator must preserve richer existing overrides, reject bounded single dimensions as well as multiple dimensions, and explicitly report unsupported native Bedrock Responses routes. It must not manufacture a region/cache/service-tier price. See the pricing guide for the consumer contract and remaining integration limits.
1. Fold streaming usage across frames; never overwrite¶
New schema.MergeUsage(acc, next) folds each field to its latest non-nil value
(Anthropic refines counts across frames rather than adding, so summing would
double-bill). All three ingress handlers now fold both ev.Chunk.Message.Usage
(message_start) and ev.Chunk.Usage (message_delta).
2. Resolve cache-write TTL tiers in one place¶
Follow-up (2026-10-11): CacheWriteAccounting separates known total quantity
from known TTL breakdown. A partial split must not replace a larger flat total:
total 100 plus reported 5m 40 still counts 100 tokens for quota and context-price
selection. Known 1h tokens retain that price; the unclassified remainder follows
the legacy 5m estimate with unknown TTL evidence. Flat and split are never added.
Negative, overflowing or contradictory nonzero complete splits are invalid and
cannot mutate accounting counters. The legacy all-zero split fallback remains.
Unknown TTL fields cannot prove a monetary authority refund. Wire observations
and historical audit bytes are preserved. See ADR-039's lifecycle follow-up for
checked totals and reservation ownership.
New schema.Usage.CacheWriteTiers() returns (write5m, write1h). The TTL split
is authoritative when complete and consistent; the flat cache_creation_input_tokens is a
fallback attributed to the cheaper 5m tier. Never summed — a provider
sending both would otherwise double-count every cache write.
This lives on the wire type because the mapping was open-coded at six call sites
(settle + observeTokens across three handlers) and every one dropped the
1h tier. One tested function replaces six chances to get it wrong.
3. Derive cache rates from the input rate¶
Cache rates are fixed multiples of input on every provider that publishes them, verified against two independent sources:
| ratio | Anthropic first-party | Amazon Bedrock | |
|---|---|---|---|
| cache read | 0.1x | $3.00→$0.30, $5.00→$0.50 | $6.00→$0.60 |
| cache write 5m | 1.25x | $3.00→$3.75, $5.00→$6.25 | $6.00→$7.50 |
| cache write 1h | 2.0x | $3.00→$6.00, $5.00→$10.00 | $6.00→$12.00 |
Bundled()'s hardcoded figures match these ratios exactly, and a test asserts
the derivation reproduces them. So an operator declares two numbers; an
explicitly-set cache rate still wins, for special pricing agreements.
4. Strip the Bedrock cross-region prefix on lookup — and nothing else¶
CostUSDMicros retries with a single leading global. / us. / eu. /
apac. / us-gov. stripped. These are the same model reached through different
routing, with no published price differential. Exact match always wins, so a
per-prefix rate stays declarable.
Model versions are deliberately NOT collapsed. Opus 4.6/4.7/4.8/5 all cost $5/$25 today, but that is a property of the current table, not a guarantee. Silently billing a new model at an old model's rate is the same class of bug as billing it at zero.
Provider stays in the key: Bedrock Opus 4.8 is $6.00/$30.00 against first-party's $5.00/$25.00. The two are not interchangeable.
5. on_missing governs strictness — at boot and at runtime¶
Rather than overriding the operator's declaration, it is now enforced:
block—live.BuildStaterefuses to boot when any configured route has no rate, naming the routes. At runtimegovernance.PricingGuarddenies with 402 /pricing_missing.allow(default) — boot logs the unpriced routes loudly and continues. This is a legitimate posture: a self-hosted vLLM deployment may genuinely have no meaningful per-token price. Silence was the bug, not permissiveness.
An unrecognized on_missing value is now a load error; it used to fall through
to allow, so "blcok" silently disabled the control.
The two enforcement points are complementary, not redundant. Boot validation sees only config-declared routes; the runtime guard covers what it cannot — models registered through UI-write (ADR-008) and targets appended by a model-level fallback (ADR-029).
Pricing-override keys are also cross-checked against the real topology unconditionally: a key naming a nonexistent provider or model is a typo, never a policy, and at runtime it was indistinguishable from a missing rate.
6. Rate updates are a reviewed commit, not a runtime fetch¶
Keeping rates current was raised as a case for fetching them dynamically. No authoritative machine-readable source exists, which we verified rather than assumed:
- The AWS Price List API carries no current Claude SKUs. All 91 rows for
AmazonBedrockinap-northeast-2are Nova;get-attribute-values --attribute-name modelreturns onlyClaude 2.0 / 2.1 / 3 Haiku / 3 Sonnet / Instant. - Anthropic publishes no pricing API — only documentation pages.
Even with a source, a gateway that fetches rates at runtime is wrong here: the audit chain is tamper-evident by design, and a cost that can change because a web page changed makes chargeback unprovable. It also violates the project's "no external SaaS dependency" constraint.
So the goal — a newly released model must not silently bill at zero — is met
structurally instead: boot refuses (or warns loudly), the runtime guard denies,
PricingVersion becomes a real config-declared label so a disputed invoice can
be pinned to the rates that produced it, and the derivation above reduces adding
a model to reading one documented figure and writing two numbers.
Consequences¶
Positive¶
- Streaming requests bill their full usage. On the measured fixture the settled cost goes from 5 to 52 µUSD.
- Cache tokens bill correctly on all three ingresses and both Bedrock API modes.
- A config declaring only input/output now prices cache correctly, so the most common config shape is no longer silently wrong.
- Unpriced routes cannot be silent: boot fails or warns, runtime can deny, and
the audit record carries a real
pricing_version. - Regression is now guarded by tests that assert settled cost end to end — verified to fail against the pre-fix code, not merely to pass against the new.
Negative¶
- Bills go up. Anyone who was under-billed will see costs rise — for cache-heavy streaming workloads, by an order of magnitude. That is the correction, but it will look like a regression to whoever reads the dashboard first, and per-team budgets calibrated against the broken figures may start tripping.
on_missing: "block"is now load-bearing. A deployment that set it without full rate coverage will refuse to boot after upgrading. Intended, but it is a breaking change for that configuration;allowis unaffected.- The 0.1/1.25/2.0 ratios are an observed invariant, not a contract. They hold across two providers today and reproduce the bundled table exactly. If a provider ever publishes cache pricing off that ratio, the explicit-override path is the escape hatch and the ratios need revisiting.
- Zero is not expressible as an explicit cache rate. Because
ConfigRateuses value floats,0means "derive". No provider publishes free cache tokens, so this trades an unreachable case for fixing the common one. - The mock provider's streaming shape changed. Any test asserting the old (unrealistic) usage shape needed updating — five did.
References¶
pkg/schema/usage.go—MergeUsage,CacheWriteTiersinternal/pricing/{pricing,fromconfig}.go— derivation, prefix lookup,HasRateinternal/live/live.go—validatePricingCoverage,UnpricedTargetsinternal/governance/governance.go—PricingGuardproviders/bedrock/{client,converse}.go— Converse cache tokensinternal/openai/convert.go—cached_tokenssplit