Anthropic bills prompt caching at three rates. Your cost code probably knows about one
A cached Claude call splits usage across input_tokens, cache_read_input_tokens and cache_creation_input_tokens — three different prices in one response. Here is what the normalized event looks like, and the live numbers from a real gpt-4o-vs-Claude comparison.
Anthropic’s prompt caching is the rare optimisation that is genuinely free money: put the stable part of your prompt behind a cache breakpoint and stop paying full price for it on every turn.
It also has the most easily mis-accounted billing shape of any major provider, because one response carries three different prices:
input_tokens— the uncached part, at the normal input ratecache_read_input_tokens— the cached part, at a discounted ratecache_creation_input_tokens— writing the cache, at a premium rate
Any cost code that reads response.usage.input_tokens and multiplies by one number is wrong three
ways at once. And it will be wrong quietly, because the number it produces still looks like a cost.
What the normalized event looks like
instrument() folds cache reads into input_tokens as a cached subset — because that is what
they are, input you are being billed for at a different rate — and tracks cache writes as their own
billed category. So the estimate follows Anthropic’s actual formula rather than a simplification of it:
uv run python recipes/providers/anthropic/main.py
gate : BLOCKED by keyword_deny - provider saw 0 call(s), $0
usage : 1,800 in (800 cache-read) + 300 cache-write -> 200 out
cost : $0.00736500 (uncached + cache-read + cache-write + output)
refused : pre-flight block: projected $0.00387300 would exceed cap $0.00001 (model=claude-sonnet-4-6)
cassette : replayed 1 call, 0 provider call(s), $0
verify() : True - ok: 9 entries, head e3da0ddeba8e… (signatures verified; metadata signature verified)
Read the cost line as four addends, not one. 1,800 in (800 cache-read) means 1,000 tokens at the full
input rate and 800 at the cache-read rate. That is the arithmetic your invoice does, and it is now the
arithmetic your budget does. (The other four lines are the rest of the recipe’s governed walk — a
blocked injection, a pre-flight refusal, a $0 cassette replay — and the audit section below comes back
to them.)
Two providers, same prompt, real numbers
The cookbook’s chat playground can drive either provider through the same seam. The same three-word prompt, live, on the same afternoon:
openai · gpt-4o · 21 in / 5 out · $0.000102500
anthropic · claude-sonnet-4-6 · 19 in / 8 out · $0.00017700
Both are real measured calls. Neither number is a benchmark and neither is a recommendation — a
three-word prompt tells you nothing about which model you should use. What it does tell you is that the
comparison is possible at all, in one place, in the same units, without a spreadsheet — because both
providers arrive on the bus as the same normalized event with a Decimal cost.
The Decimal matters more than it sounds. Provider costs live in the seventh decimal place. A float
accumulator over a million calls drifts, and it drifts in a number somebody is going to reconcile
against an invoice. Money is Decimal in Python and decimal.js in TypeScript, everywhere, with no
float path.
And the call is evidence
The same recipe writes the whole walk into a hash-chained acttrace file and verifies it offline:
verify() : True - ok: 9 entries, head e3da0ddeba8e… (signatures verified; metadata signature verified)
Nine entries: the chain open; the blocked injection attempt, as a guardrail decision; the decision
open, the model call with its usage and cost, the recorded decision, the decision close; the
pre-flight refusal, as a budget event for a call that was never sent; and the recorded turn plus its
$0 replay. Your head hash will differ from that one and should — entries carry
timestamps, so every run produces a different chain head. The entry count and verify() are what
reproduce. verify() re-walks the file with no network and no service. Edit one
byte anywhere in a hashed payload and it fails at that exact entry — that is the whole design, and
it is why the file is the evidence rather than a log that says it is.
A rate that can say where it came from — and a host’s rate is not a lab’s
Everything above is only as good as the four numbers underneath it: input, cache-read, cache-write,
output. So prices.explain(model) exists to answer “which source priced this call, as of when, and
is one of my own registrations overriding it?” — the question a cost review actually asks, and the
one a bare dollar figure cannot.
from cendor.core import prices
e = prices.explain("claude-sonnet-4-6")
print(e.summary())
# claude-sonnet-4-6: cache_write=0.00000375 cached=3E-7 input=0.000003 output=0.000015
# — exact, from modelsdev as of 2026-03-13
e.row_source # which source THAT rate came from — per row, not per table
e.row_asof # that source's own as-of date, never the day you fetched it
e.notes # honest caveats: resale pricing, an undatable table, an unpriced model
row_asof being the source’s date rather than the fetch date is the load-bearing detail: a fetch
date would make a six-month-old rate look like today’s.
⚠️ And here is why the provenance is not decoration — a measured, live example on a Claude model.
Building the price feed, claude-3-5-haiku came back at $1 in / $5 out per MTok. Anthropic’s own
published rate is $0.80 / $4. Nothing was corrupt: the aggregator reaches that model only through
vertex_ai/claude-3-5-haiku, so the number was Vertex’s resale price, published under the bare
model id. A gateway sells you someone else’s model at its own price, and a cost report that silently
substitutes one for the other is wrong and confident about it.
openrouter and vercel are gateway catalogs for the same reason, and explain() says so rather
than leaving it in the docs:
note: openrouter publishes gateway RESALE prices — what the gateway charges you,
which may differ from the model lab's own rate
⚠️ The first fix over-corrected, which is worth recording too: skipping every namespaced id cut the feed from 2,585 rows to 820 and threw away real host-only models. The rule that survived is narrower — a host-namespaced id may not overwrite a bare one.
Neither OpenAI nor Anthropic publishes a pricing API. Their model-list endpoints carry ids only
(their docs do serve keyless markdown price mirrors, useful as drift detectors and nothing more). That
is the whole reason a reconciled feed exists: the two clouds’ own billing catalogs plus the MIT
aggregators, dated, per-row-provenanced, behind validation gates, on a static file with no account and
no server —
cendorhq.github.io/cendor-prices/prices.json (opens in a new tab).
A bare prices.refresh() pulls it. Your own register_model_price outranks all of it, forever.
Honest limits
- A pre-flight projection for a Claude model is an estimate, and it under-counts. Everything above
is settled cost, computed from the usage Anthropic reported — exact. But a budget that refuses a call
before sending it has to count the prompt itself, and there is no offline Claude tokenizer, so
Cendor counts through the
o200kBPE proxy and says so (tokens.method()reportsbpe-estimate). Measured against Anthropic’s ownmessages.count_tokensover 27 samples — prose, code and JSON at three sizes each — the official count is 1.49× the proxy for Opus 4.7 / Sonnet 5 / Fable 5 (range 1.32–1.66) and 1.14× for Sonnet 4.5 / Haiku 4.5. So a token cap of N binds at roughly N ÷ 1.49 of Anthropic’s tokens. No scaling factor is applied, deliberately: the ratio tracks the content — JSON lands at ~1.32–1.35, prose at ~1.49–1.66 — so one number would over-count JSON while under-counting prose, and a confidently wrong count is worse than a documented estimate.tokens.register()makes it exact for your workload, including by delegating to Anthropic’s endpoint (a network call per count, so it is opt-in, not a default a local-first library could adopt). - Cache pricing is a dated table, and nothing phones home on its own. The rates ship in a bundled
snapshot with a date on it — generated from the feed above, not typed by hand, so every row carries
its own source and as-of date.
prices.refresh()pulls a newer one deliberately, in memory, for this process only: there is no implicit cache, because a hidden cache is exactly how prices go invisibly stale. Persisting one is explicit (prices.save(path)/load(path)). And when a USD budget prices a call from a table older than 45 days,tokenguardsays so once per process (StalePriceTableWarning) — after a price rise a stale table under-estimates, so the cap binds late and you overspend. - Cendor does not decide your cache breakpoints. It prices what you did. Where to put the breakpoint is a prompt-design question and it is yours.
- A cache write costs more than an uncached read. Caching a prompt you use twice can be a loss. The numbers above make that visible; they do not make it go away.
acttraceproduces evidence to support a compliance case — not a guarantee, and not legal advice.- The model id in this recipe is a real, current one at the time of writing. Model ids retire; check yours rather than copying a blog post’s.
Run it yourself
uv run python recipes/providers/anthropic/main.py # offline, no key
# against the real API (records a redacted cassette):
RECORD=1 ANTHROPIC_API_KEY=sk-ant-... uv run --group apps python recipes/providers/anthropic/main.py
anthropic is not a base dependency of the cookbook, which is why the live path needs --group apps.