Blog

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 rate
  • cache_read_input_tokens — the cached part, at a discounted rate
  • cache_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 o200k BPE proxy and says so (tokens.method() reports bpe-estimate). Measured against Anthropic’s own messages.count_tokens over 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, tokenguard says 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.
  • acttrace produces 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.

RSS feed → · all posts →