Files
ai-agent/backend/conversations/pricing.py
Gabriel Vidal 2408c4dc6f fix(costs): price each API call once, at its exact model version
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>
2026-10-13 00:13:54 +02:00

205 lines
8.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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