From 1eddf068e2171e99e33926b0fe87d4dce35bfd71 Mon Sep 17 00:00:00 2001 From: kvishalv Date: Tue, 10 Nov 2026 20:49:41 -0800 Subject: [PATCH 1/2] pricing: bill cache hits at the model's own fraction on Opus 5.5, Sonnet 5.5, Fable, Mythos weights_for gave every claude-* id outside MODEL_WEIGHTS one curve with cache_read=0.10 and called the ratios uniform. Anthropic's price list bills a cache hit at 0.05x input on Opus 5.5 and Sonnet 5.5 and at 0.025x on Fable and Mythos 5.1 and 5.2; every other ratio is the same on all models. Cache hits are most of a session's tokens, so a Claude Code session on one of these models was priced well above its bill. Pick the ratio by model in _claude_weights, on the id with dots as dashes, so a router's spelling and a dated snapshot match too. The live ledger test prices Opus 5.5 at 435 per request, not 485, and an "id outside the table" test uses Opus 4.5. --- tests/test_cache_write_1h.py | 44 ++++++++++++++++++++++++++++++++++-- tests/test_live_hooks.py | 5 ++-- tokeneconomizer/pricing.py | 29 ++++++++++++++++++++++-- 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/tests/test_cache_write_1h.py b/tests/test_cache_write_1h.py index 2007472..e35cd58 100644 --- a/tests/test_cache_write_1h.py +++ b/tests/test_cache_write_1h.py @@ -2,9 +2,12 @@ and 2x for the 1-hour one, and a message's usage says how its cache writes split between them (`cache_creation: {ephemeral_5m_input_tokens, ephemeral_1h_input_tokens}`). Claude Code on a subscription writes to the 1-hour cache, so pricing every cache write at 1.25x understates such a -session.""" +session. A cache hit is billed at 0.1x input except on a few models (0.05x on Opus 5.5 and +Sonnet 5.5, 0.025x on Fable and Mythos 5.1 and 5.2), and hits are most of a session's tokens.""" import json +import pytest + from tokeneconomizer import pricing, trace from tokeneconomizer.plugin import accounting @@ -35,7 +38,7 @@ def test_a_row_without_the_split_is_priced_as_before(): def test_a_claude_id_outside_the_table_gets_the_one_hour_rate_too(): - row = _row(1000, one_hour=1000, model="claude-opus-5-5") + row = _row(1000, one_hour=1000, model="claude-opus-4-5") assert pricing.effective_tokens(row) == BASE + 1000 * 2.0 @@ -121,3 +124,40 @@ def test_an_unpriced_model_keeps_the_fallback_cache_write_rate(): "cache_creation_input_tokens": 1000, "cache_creation_1h_input_tokens": 1000, "model": "some-new-model"} assert accounting.effective(usage) == (10 + 1000 * 1.25, "fallback") + + +# ---- cache hits ------------------------------------------------------------------------------ +@pytest.mark.parametrize("model, ratio", [ + ("claude-opus-5-5", 0.05), ("claude-sonnet-5-5", 0.05), + ("claude-fable-5-1", 0.025), ("claude-fable-5-2", 0.025), + ("claude-mythos-5-1", 0.025), ("claude-mythos-5-2", 0.025), + ("claude-sonnet-5.5", 0.05), # a router's dotted spelling + ("anthropic/claude-opus-5.5", 0.05), # and its provider prefix + ("claude-opus-5-5-20261001", 0.05), # a dated snapshot +]) +def test_a_cache_hit_is_billed_at_the_models_own_fraction(model, ratio): + assert pricing.weights_for(_row(model=model))["cache_read"] == ratio + cache_hits = 10_000 * ratio + assert pricing.effective_tokens(_row(0, model=model)) == 100 + cache_hits + 10 * 5.0 + + +@pytest.mark.parametrize("model", [ + "claude-opus-5", "claude-opus-4-8", "claude-sonnet-5", "claude-sonnet-4-6", + "claude-haiku-5-5", "claude-haiku-4-5", "claude-fable-5", "claude-mythos-5", + "claude-opus-4.5", +]) +def test_the_other_claude_models_keep_the_usual_cache_hit_fraction(model): + assert pricing.weights_for(_row(model=model))["cache_read"] == 0.10 + assert pricing.effective_tokens(_row(0, model=model)) == BASE + + +def test_a_session_on_opus_5_5_is_priced_with_its_cheaper_cache_hits(tmp_path): + entries = [{"type": "assistant", "message": { + "id": "m1", "model": "claude-opus-5-5", "role": "assistant", + "usage": {"input_tokens": 10, "cache_read_input_tokens": 1000, + "cache_creation_input_tokens": 0, "output_tokens": 50}, + "content": [{"type": "text", "text": "x"}]}}] + eff, priced_by = accounting.effective(trace.claude_session_usage(_transcript( + tmp_path / "s.jsonl", entries))) + assert priced_by == "model" + assert eff == 10 + 1000 * 0.05 + 50 * 5.0 diff --git a/tests/test_live_hooks.py b/tests/test_live_hooks.py index 2149747..3dd6392 100644 --- a/tests/test_live_hooks.py +++ b/tests/test_live_hooks.py @@ -864,8 +864,9 @@ def test_each_session_is_folded_once_and_priced(env, repo, tmp_path): ledger = store_for(env, repo).read_lines("ledger.jsonl") assert len(ledger) == 1 rec = ledger[0] - # 3 requests x (10 fresh + 0.1 x 1000 cached + 1.25 x 100 written + 5 x 50 output) - assert rec["requests"] == 3 and rec["effective_tokens"] == pytest.approx(3 * 485.0) + # 3 requests x (10 fresh + 0.05 x 1000 cached + 1.25 x 100 written + 5 x 50 output); the + # session's model, Opus 5.5, bills a cache hit at 0.05x input, not the usual 0.1x + assert rec["requests"] == 3 and rec["effective_tokens"] == pytest.approx(3 * 435.0) assert rec["priced_by"] == "model" and set(rec["overhead"]) == { "optimizer_tokens", "spiral_judge_tokens", "spiral_nudge_writer_tokens"} diff --git a/tokeneconomizer/pricing.py b/tokeneconomizer/pricing.py index b188cab..0881f53 100644 --- a/tokeneconomizer/pricing.py +++ b/tokeneconomizer/pricing.py @@ -31,10 +31,16 @@ `cache_write_1h`; a table without that entry, or a row without the field, prices every cache-write token at `cache_write` as before. +Claude's cache-hit price is not uniform either: Anthropic bills a cache hit at 0.1x input, but at +0.05x on Opus 5.5 and Sonnet 5.5 and at 0.025x on Fable and Mythos 5.1 and 5.2 +(`_CLAUDE_CACHE_READ`). Cache hits are 90% or more of an agent session's tokens, so a wrong ratio +moves the whole figure. + Callers may override with an explicit effective_tokens(rec, weights=...). When the per-task breakdown is absent (older runs), effective_tokens() falls back to `tokens_total` at full weight so nothing crashes — call has_breakdown() to detect this. """ +import re # --------------------------------------------------------------------------- # Per-model-id price ratios (fresh-input-token equivalents; input := 1.0). @@ -124,6 +130,24 @@ # weights_for(); effective_tokens() applies it automatically. WEIGHTS = WEIGHTS_CLAUDE +# Claude models whose cache hits are not billed at 0.1x input (Anthropic's price list, +# https://platform.claude.com/docs/en/about-claude/pricing). Every other ratio of the Claude +# curve is the same on every model. Matched on the id with dots as dashes, so a router's +# `claude-sonnet-5.5` and a dated snapshot match too. +_CLAUDE_CACHE_READ = ( + (re.compile(r"claude-(opus|sonnet)-5-5\b"), 0.05), + (re.compile(r"claude-(fable|mythos)-5-[12]\b"), 0.025), +) + + +def _claude_weights(key): + """The Claude curve for a lower-cased claude-* id, with the model's own cache-hit ratio.""" + name = key.replace(".", "-") + for pattern, cache_read in _CLAUDE_CACHE_READ: + if pattern.search(name): + return dict(WEIGHTS_CLAUDE, cache_read=cache_read) + return WEIGHTS_CLAUDE + def resolve_model(rec): """The billed model id for a row: its explicit `model`, else the agent's default @@ -151,7 +175,8 @@ def weights_for(rec): * resolve the billed id (resolve_model) — explicit `model`, else agent default; * id in MODEL_WEIGHTS -> that model's exact ratios; * id is a claude-* variant -> Claude curve (covers dotted ids like claude-opus-4.8 - not in the catalog snapshot; ratios are uniform); + not in the catalog snapshot), with the model's own + cache-hit ratio where it differs (_CLAUDE_CACHE_READ); * id present but UNPRICED -> raise ValueError (hard-fail: a new/unknown model must be added to MODEL_WEIGHTS, never silently mispriced); * no resolvable id at all -> Claude curve (legacy rows / claude / copilot: keeps @@ -165,7 +190,7 @@ def weights_for(rec): if key in MODEL_WEIGHTS: return MODEL_WEIGHTS[key] if "claude" in key: - return WEIGHTS_CLAUDE + return _claude_weights(key) raise ValueError( f"pricing: no weight entry for billed model {mid!r} " f"(agent={rec.get('agent')!r}). Add it to pricing.MODEL_WEIGHTS " From ee5340f8952d248d990c35624e4213f8c939fa46 Mon Sep 17 00:00:00 2001 From: kvishalv Date: Wed, 11 Nov 2026 06:32:52 -0800 Subject: [PATCH 2/2] pricing: say that the Claude cache-hit ratio is not shared by every model Two comments in pricing.py still called the Claude ratios the same on every model: the one above WEIGHTS_CLAUDE and the one above AGENT_DEFAULT_MODEL. Point both to _CLAUDE_CACHE_READ. No code changes. --- tokeneconomizer/pricing.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tokeneconomizer/pricing.py b/tokeneconomizer/pricing.py index 0881f53..922015c 100644 --- a/tokeneconomizer/pricing.py +++ b/tokeneconomizer/pricing.py @@ -101,11 +101,11 @@ } # Billed model id for an agent when a row does NOT pin one (rec['model'] is None). -# Claude/Copilot agents map to None -> the Claude curve (their ratios are model-uniform -# across Opus/Sonnet/Haiku, so an exact id is unnecessary). Codex maps to codex's built-in -# default, gpt-5.6-sol (verified from codex's session rollout). Probed against the OpenAI -# Platform on 2026-09-09: `gpt-5.6-sol` is both listed by /v1/models AND accepted by -# /v1/responses (HTTP 200). +# Claude/Copilot agents map to None -> the Claude curve (a row that names its Claude model is +# priced by it, see _CLAUDE_CACHE_READ; one that names none gets the usual ratios). Codex maps +# to codex's built-in default, gpt-5.6-sol (verified from codex's session rollout). Probed +# against the OpenAI Platform on 2026-09-09: `gpt-5.6-sol` is both listed by /v1/models AND +# accepted by /v1/responses (HTTP 200). # # The trap a catalog check alone will not catch: the `-codex` ALIASES are listed by # /v1/models and 404 on /v1/responses ("does not exist or you do not have access to it" for @@ -115,9 +115,10 @@ "codex": "gpt-5.6-sol", } -# Claude-family price ratios (Opus/Sonnet/Haiku share them). Used as the default when a -# row has no resolvable model id (legacy rows, claude/copilot agents) so those stay -# byte-identical to historical behavior. Also the value of the back-compat WEIGHTS alias. +# Claude-family price ratios (Opus/Sonnet/Haiku share them, except the cache-hit ratio of a +# few models: see _CLAUDE_CACHE_READ). Used as the default when a row has no resolvable model +# id (legacy rows, claude/copilot agents) so those stay byte-identical to historical behavior. +# Also the value of the back-compat WEIGHTS alias. WEIGHTS_CLAUDE = { "input": 1.0, # input_tokens = fresh/uncached prompt tokens "cache_read": 0.10, # cache_read_input_tokens