testsAndMisc/python_pkg/token_audit/model.py

165 lines
6.2 KiB
Python

"""Types and cost weights shared by every stage of the audit.
The whole package exists to answer one question — *what did last week actually
cost?* — and the answer depends entirely on how the four token classes are
weighted. Ranking by raw token count is actively misleading here: in a measured
7-day window cache reads were 99% of raw volume but only ~82% of weighted cost,
while output was 1.5% of volume and ~7.5% of cost. A report that ranked by raw
counts would point at the wrong culprits, so the weights live here as the single
source of truth rather than being re-derived per module.
"""
from __future__ import annotations
from dataclasses import dataclass, field
# Relative price of each token class, normalised so plain input == 1.0. These
# mirror Anthropic's published multipliers (cache write 1.25x, cache read 0.1x,
# output 5x); they are ratios, not currency, so the report stays valid whatever
# the absolute per-token price happens to be.
WEIGHTS: dict[str, float] = {
"input_tokens": 1.0,
"cache_creation_input_tokens": 1.25,
"cache_read_input_tokens": 0.1,
"output_tokens": 5.0,
}
# Token classes in the order they should appear in reports.
TOKEN_KINDS: tuple[str, ...] = (
"input_tokens",
"cache_creation_input_tokens",
"cache_read_input_tokens",
"output_tokens",
)
# A turn whose context is below this fraction of the previous turn's is treated
# as sitting after a compaction boundary. Compaction discards earlier messages,
# so images loaded before it stop being re-sent and must stop being charged.
# Without this the image estimate roughly doubles and produces the nonsense
# result of single sessions costing >100% of their own measured spend.
COMPACTION_DROP_RATIO = 0.7
# Extensions whose Read results arrive as images rather than text.
IMAGE_SUFFIXES: frozenset[str] = frozenset(
{".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"},
)
def weighted(usage: dict[str, int]) -> float:
"""Return the cost-weighted token total for one request's usage block."""
return sum(WEIGHTS[kind] * usage.get(kind, 0) for kind in TOKEN_KINDS)
@dataclass(frozen=True)
class Turn:
"""One assistant response, with the token accounting the API reported.
``context`` is what the request actually re-sent (cache read + cache
creation). It is the quantity that grows as a session accumulates history,
and the quantity that collapses at a compaction boundary, so it drives both
the session-length analysis and the image-amplification model.
``message_id`` is the API message id. One API message can be written to the
transcript as SEVERAL JSONL records -- one per ``tool_use`` block -- all
sharing the id. Counting records therefore overstates turns and makes every
turn look like it carried exactly one tool call. Grouping by this id is what
makes the batching ratio come out right (measured 2026-08-28: 2.8% batched,
not the 0.0% a per-record count reports).
"""
usage: dict[str, int]
context: int
model: str
is_sidechain: bool
message_id: str = ""
tool_calls: int = 0
timestamp: float = 0.0
@property
def cost(self) -> float:
"""Cost-weighted tokens attributable to this turn."""
return weighted(self.usage)
@dataclass(frozen=True)
class ToolCall:
"""A tool invocation paired with the size of the result it returned.
``result_tokens`` is an estimate: transcripts store the rendered result, so
the character count divided by four is the best available proxy. It is used
only for ranking tools against each other, never added to the reconciled
totals, which come exclusively from the API's own usage numbers.
"""
name: str
result_tokens: int
path: str | None = None
skill: str | None = None
# A Bash call that ran ``git commit`` or ``finish_auto.sh``: the closest
# transcript-visible proxy for "an issue got solved", used for USD/commit.
commit: bool = False
@property
def is_image(self) -> bool:
"""Whether this call returned an image rather than text."""
if self.path is None:
return False
dot = self.path.rfind(".")
return dot != -1 and self.path[dot:].lower() in IMAGE_SUFFIXES
@property
def mcp_server(self) -> str | None:
"""The MCP server this tool belongs to, or ``None`` for builtins."""
parts = self.name.split("__")
expected_parts = 3
return parts[1] if len(parts) >= expected_parts and parts[0] == "mcp" else None
@dataclass
class Session:
"""Everything one transcript file contributes to the audit."""
session_id: str
path: str
cwd: str | None = None
turns: list[Turn] = field(default_factory=list)
tools: list[ToolCall] = field(default_factory=list)
# Set only for subagent transcripts: the spawning session and the
# agentType / description from the sibling ``.meta.json``.
parent_id: str | None = None
agent_type: str | None = None
description: str | None = None
# Cost-weighted tokens spent re-sending images that were still live in
# context. Computed by :mod:`imagecost`, which needs the interleaved order
# of images and turns and so cannot be derived from these lists alone.
image_cost: float = 0.0
@property
def cost(self) -> float:
"""Cost-weighted tokens for the whole session."""
return sum(turn.cost for turn in self.turns)
@property
def turn_count(self) -> int:
"""Number of assistant responses in the session."""
return len(self.turns)
@property
def max_context(self) -> int:
"""Largest context this session ever re-sent in a single request."""
return max((turn.context for turn in self.turns), default=0)
@property
def cold_start_tokens(self) -> int:
"""Tokens written to cache before any cache existed to read from.
The first request of a session pays to cache the whole fixed prefix —
system prompt, tool schemas, CLAUDE.md, rules and memories. Isolating it
shows what every new session costs before it does any work.
"""
return sum(
turn.usage.get("cache_creation_input_tokens", 0)
for turn in self.turns
if turn.usage.get("cache_read_input_tokens", 0) == 0
)