Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions tests/test_cache_write_1h.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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
5 changes: 3 additions & 2 deletions tests/test_live_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}

Expand Down
46 changes: 36 additions & 10 deletions tokeneconomizer/pricing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -95,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
Expand All @@ -109,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
Expand All @@ -124,6 +131,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
Expand Down Expand Up @@ -151,7 +176,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
Expand All @@ -165,7 +191,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 "
Expand Down