Claude Code writes an assistant message as one transcript record per content block, each repeating the call's full usage; the parser summed them all, so tokens and cost read ~1.93x too high. Calls are now keyed on (message id, request id), priced once, and the charge is re-homed onto the tool card the call drove. pricing.py moves from family prices (every Opus at $5/$25, flat 0.1x cache reads) to a per-version book (Opus 5.5 $4/$20/$0.20, Fable 5.1 cache reads $0.25, Sonnet 5.x $2/$10, Haiku 5.5 $0.10/$0.50 + its >100K tier, fast mode 2x), stamped on every summary as priceBook. It also adds plan_units: what the subscription limits count (each new model metered at its predecessor's price, Fable x1.75). Summaries gain planUnits, byModel (per effort), meterBins and fastCalls; subagents roll them up; project cost splits price per model. PARSER_VERSION 27 re-parses everything: dashboard totals drop to list price (~3.7x lower). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
205 lines
8.6 KiB
Python
205 lines
8.6 KiB
Python
"""Per-model pricing and the usage arithmetic every cost figure is built on.
|
||
|
||
Claude models are priced from the per-version book below; a pi-harness session
|
||
is priced from the OpenRouter catalogue (``models.openrouter``) when its id is
|
||
known there.
|
||
|
||
Two currencies come out of it:
|
||
|
||
* **cost** — the API list price of the tokens, at the exact model version (and
|
||
speed) that produced them. What the same work would have cost on the API.
|
||
* **plan units** — what the subscription's usage limits appear to count. The
|
||
limits did not follow the latest per-token price cuts: fitted against the
|
||
official `/usage` percentages, a new model is metered at its predecessor's
|
||
price (Opus 5.5 as Opus 5, Fable 5.1 as Fable 5…) and a Fable token weighs
|
||
~1.75× its own price relative to Opus. One constant then explains every
|
||
period since July. ``plan_pct`` turns units into "% of the 5h / week window"
|
||
with a per-account calibration (scripts/usage-calibration.py,
|
||
docs/usage-calibration.md).
|
||
"""
|
||
|
||
import re
|
||
|
||
import models
|
||
from models import openrouter
|
||
|
||
# Bumped whenever a price below changes, and stamped on every parsed summary
|
||
# (``priceBook``) so a figure can always be traced to the book that made it.
|
||
PRICE_BOOK = "2026-10-12"
|
||
|
||
# USD / million tokens: (input, output, cache read). Cache writes are 1.25×
|
||
# (5-minute TTL) / 2× (1-hour TTL) the input price on every model. Keyed by the
|
||
# exact model id, date suffix stripped (`claude-haiku-4-5-20251001` →
|
||
# `claude-haiku-4-5`). The newest models cut the cache-read price below the old
|
||
# flat 10 %: Opus 5.5 0.05×, Fable 5.1 0.025×.
|
||
MODEL_PRICES: dict[str, tuple[float, float, float]] = {
|
||
"claude-fable-5-1": (10.0, 50.0, 0.25),
|
||
"claude-mythos-5-1": (10.0, 50.0, 0.25),
|
||
"claude-fable-5": (10.0, 50.0, 1.00),
|
||
"claude-mythos-5": (10.0, 50.0, 1.00),
|
||
"claude-opus-5-5": (4.0, 20.0, 0.20),
|
||
"claude-opus-5": (5.0, 25.0, 0.50),
|
||
"claude-opus-4-8": (5.0, 25.0, 0.50),
|
||
"claude-opus-4-7": (5.0, 25.0, 0.50),
|
||
"claude-opus-4-6": (5.0, 25.0, 0.50),
|
||
"claude-opus-4-5": (5.0, 25.0, 0.50),
|
||
"claude-opus-4-1": (15.0, 75.0, 1.50),
|
||
"claude-opus-4": (15.0, 75.0, 1.50),
|
||
"claude-sonnet-5-5": (2.0, 10.0, 0.20),
|
||
"claude-sonnet-5": (2.0, 10.0, 0.20),
|
||
"claude-sonnet-4-6": (3.0, 15.0, 0.30),
|
||
"claude-sonnet-4-5": (3.0, 15.0, 0.30),
|
||
"claude-sonnet-4": (3.0, 15.0, 0.30),
|
||
"claude-haiku-5-5": (0.10, 0.50, 0.01),
|
||
"claude-haiku-4-5": (1.0, 5.0, 0.10),
|
||
}
|
||
# An id the book doesn't know (a release newer than this file) is priced as the
|
||
# newest model of its family.
|
||
FAMILY_FALLBACK = {"fable": "claude-fable-5-1", "mythos": "claude-mythos-5-1",
|
||
"opus": "claude-opus-5-5", "sonnet": "claude-sonnet-5-5",
|
||
"haiku": "claude-haiku-5-5"}
|
||
# Fast mode (`usage.speed == "fast"`, Opus only) bills 2× every token type.
|
||
FAST_MULT = 2.0
|
||
# Claude Haiku 5.5's long-prompt tier: a request whose prompt (input + cache
|
||
# read + cache write) exceeds 100K tokens bills 5× ($0.50 / $2.50).
|
||
HAIKU_LONG = ("claude-haiku-5-5", 100_000, 5.0)
|
||
# Non-Claude fallbacks: `qwen` is the *last-resort* estimate for a pi-harness
|
||
# OpenRouter session whose model isn't in the catalogue; `qwen-local` is the
|
||
# EVOX2 LM Studio provider, which is free. A pi transcript record carrying the
|
||
# runner-mirrored `costUSD` (pi's own per-message provider cost) overrides both.
|
||
OTHER_PRICES = {"qwen": (0.15, 1.0, 0.015), "qwen-local": (0.0, 0.0, 0.0)}
|
||
|
||
# Plan meter: the model each Claude id is metered *as* by the subscription
|
||
# limits (its predecessor's price), and the extra weight of a Fable/Mythos
|
||
# token. Fitted 2026-10-12 on Jul–Oct personal + Oct work data.
|
||
PLAN_METER_AS = {
|
||
"claude-opus-5-5": "claude-opus-5",
|
||
"claude-fable-5-1": "claude-fable-5",
|
||
"claude-mythos-5-1": "claude-mythos-5",
|
||
"claude-sonnet-5-5": "claude-sonnet-5",
|
||
"claude-haiku-5-5": "claude-haiku-4-5",
|
||
}
|
||
PLAN_FABLE_WEIGHT = 1.75
|
||
|
||
_DATE_SUFFIX = re.compile(r"-\d{8}$")
|
||
|
||
|
||
def model_key(model: str) -> str | None:
|
||
"""The price-book key for a Claude model id, or None for a non-Claude one."""
|
||
m = _DATE_SUFFIX.sub("", (model or "").lower().strip())
|
||
if m.startswith("us.anthropic.") or m.startswith("anthropic."):
|
||
m = m.split("anthropic.", 1)[1]
|
||
if m in MODEL_PRICES:
|
||
return m
|
||
for fam, key in FAMILY_FALLBACK.items():
|
||
if fam in m and "/" not in m and "qwen" not in m:
|
||
return key
|
||
return None
|
||
|
||
|
||
def rates_for(model: str) -> tuple[float, float, float]:
|
||
"""(input, output, cache-read) price in USD / million tokens for a model.
|
||
|
||
**A pi-harness session is priced from the OpenRouter catalogue** (APPE's
|
||
daily models.dev sync — see `openrouter.py`), so a run on any of the several
|
||
hundred pickable models is costed at that model's real rates, cache read
|
||
included. Only an id the catalogue doesn't know falls through to the flat
|
||
qwen estimate. Claude models use the per-version book.
|
||
|
||
A model the pi picker declares as EVOX2-local is free and skips the
|
||
catalogue entirely — its id may well carry a vendor prefix
|
||
(`ggml-org/qwen3.8-27b`), which would otherwise read as OpenRouter's.
|
||
"""
|
||
m = (model or "").lower()
|
||
local = models.pi_provider(model) == "evox2"
|
||
if "/" in m and not local: # vendor-prefixed ⇒ an OpenRouter model
|
||
hit = openrouter.rates(model)
|
||
if hit:
|
||
return hit
|
||
key = model_key(model)
|
||
if key:
|
||
return MODEL_PRICES[key]
|
||
if "qwen" in m or "/" in m:
|
||
# Anything the picker calls EVOX2-local is free; every other qwen or
|
||
# vendor-prefixed id ran on OpenRouter (priced above when known).
|
||
return OTHER_PRICES["qwen-local"] if local or "/" not in m else OTHER_PRICES["qwen"]
|
||
return MODEL_PRICES[FAMILY_FALLBACK["opus"]]
|
||
|
||
|
||
def price_for(model: str) -> tuple[float, float]:
|
||
"""(input, output) $/Mtok — `rates_for` without the cache-read rate."""
|
||
pi, po, _ = rates_for(model)
|
||
return pi, po
|
||
|
||
|
||
def norm_usage(u: dict) -> dict:
|
||
cc = u.get("cache_creation") or {}
|
||
c5 = cc.get("ephemeral_5m_input_tokens", 0) or 0
|
||
c1 = cc.get("ephemeral_1h_input_tokens", 0) or 0
|
||
flat = u.get("cache_creation_input_tokens", 0) or 0
|
||
split = c5 + c1
|
||
return {
|
||
"input": u.get("input_tokens", 0) or 0,
|
||
"output": u.get("output_tokens", 0) or 0,
|
||
"cacheRead": u.get("cache_read_input_tokens", 0) or 0,
|
||
"cacheWriteTokens": split if split else flat,
|
||
"cacheWriteUnits": (c5 * 1.25 + c1 * 2.0) if split else flat * 1.25,
|
||
}
|
||
|
||
|
||
def add_usage(a: dict, b: dict) -> dict:
|
||
return {k: a[k] + b[k] for k in a}
|
||
|
||
|
||
def zero_usage() -> dict:
|
||
return {"input": 0, "output": 0, "cacheRead": 0,
|
||
"cacheWriteTokens": 0, "cacheWriteUnits": 0.0}
|
||
|
||
|
||
def usage_tokens(u: dict) -> int:
|
||
return int(u["input"] + u["cacheWriteTokens"] + u["cacheRead"] + u["output"])
|
||
|
||
|
||
def _tier_mult(u: dict, key: str | None, speed: str | None) -> float:
|
||
mult = FAST_MULT if speed == "fast" else 1.0
|
||
if key == HAIKU_LONG[0] and (u["input"] + u["cacheRead"]
|
||
+ u["cacheWriteTokens"]) > HAIKU_LONG[1]:
|
||
mult *= HAIKU_LONG[2]
|
||
return mult
|
||
|
||
|
||
def _cost(u: dict, rates: tuple[float, float, float]) -> float:
|
||
pi, po, pc = (r / 1e6 for r in rates)
|
||
return (u["input"] * pi + u["cacheWriteUnits"] * pi
|
||
+ u["cacheRead"] * pc + u["output"] * po)
|
||
|
||
|
||
def usage_cost(u: dict, model: str, speed: str | None = None) -> float:
|
||
"""List price of one usage block at `model` (and `speed`). Exact per API
|
||
call; on a usage *sum* the long-prompt tier can't apply (prompt sizes are
|
||
gone), which is why the parser prices every call on its own."""
|
||
return _cost(u, rates_for(model)) * _tier_mult(u, model_key(model), speed)
|
||
|
||
|
||
def usage_cache_cost(u: dict, model: str, speed: str | None = None) -> float:
|
||
"""The share of a turn's cost that is replayed cached prompt (billed at the
|
||
model's own cache-read rate). The rest is fresh tokens the model actually
|
||
had to read or write this turn."""
|
||
_, _, pc = rates_for(model)
|
||
return u["cacheRead"] * (pc / 1e6) * _tier_mult(u, model_key(model), speed)
|
||
|
||
|
||
def plan_units(u: dict, model: str, speed: str | None = None) -> float:
|
||
"""What a usage block weighs on the subscription's usage limits, in
|
||
"legacy-price dollars": the tokens priced as the model they are metered as
|
||
(``PLAN_METER_AS``), Fable/Mythos weighted ``PLAN_FABLE_WEIGHT``×. 0 for a
|
||
non-Claude model — a pi run on OpenRouter or EVOX2 doesn't touch the plan."""
|
||
key = model_key(model)
|
||
if key is None:
|
||
return 0.0
|
||
as_key = PLAN_METER_AS.get(key, key)
|
||
units = _cost(u, MODEL_PRICES[as_key]) * _tier_mult(u, key, speed)
|
||
if "fable" in key or "mythos" in key:
|
||
units *= PLAN_FABLE_WEIGHT
|
||
return units
|