"""Dollar-denominated metrics: per model, per subagent, per call position. :mod:`attribute` answers "where did the weighted tokens go"; this module puts list-price dollars on the same sessions so that model choice becomes visible, and computes the flat ``metrics`` dict every snapshot stores. Ledger entries name a key in that dict, so a key here is a contract -- rename one and old ledger lines silently become "unmeasured". """ from __future__ import annotations from collections import Counter from dataclasses import dataclass, field, replace from pathlib import Path from typing import TYPE_CHECKING from python_pkg.token_audit.pricing import usd if TYPE_CHECKING: from collections.abc import Sequence from python_pkg.token_audit.model import Session from python_pkg.token_audit.pricing import PriceBook SECONDS_PER_DAY = 86400 # Context size above which a call is "over budget" -- the session gate blocks # here. Measured 2026-10-04: per-call cost 21k weighted for calls 1-100 versus # 51k for calls 201-400, driven by context size, not by call count. CONTEXT_BUDGET = 300_000 # Model a mechanical subagent would move to; used only to size that lever. CHEAPER_MODEL = "claude-sonnet-5-5" # Call-position buckets for the cost curve (upper bounds, inclusive). BUCKETS: tuple[int, ...] = (100, 200, 400, 600, 1000) @dataclass class Tally: """Dollars and API calls accumulated along one axis.""" usd: float = 0.0 calls: int = 0 def add(self, cost: float) -> None: """Count one call costing ``cost`` dollars.""" self.usd += cost self.calls += 1 @dataclass class Span: """Earliest and latest record timestamp seen (0.0 means none yet).""" earliest: float = 0.0 latest: float = 0.0 @dataclass class Breakdown: """Everything priced in one pass over the sessions.""" main: Tally = field(default_factory=Tally) sub: Tally = field(default_factory=Tally) over_budget: Tally = field(default_factory=Tally) home_usd: float = 0.0 by_model: Counter[str] = field(default_factory=Counter) by_agent_type: Counter[str] = field(default_factory=Counter) by_description: Counter[str] = field(default_factory=Counter) sub_opus_usd: float = 0.0 # The same Opus subagent turns re-priced at the reference cheaper model: # what routing them there would have cost, for sizing that lever exactly. sub_opus_repriced_usd: float = 0.0 buckets: dict[str, Tally] = field(default_factory=dict) commits: int = 0 span: Span = field(default_factory=Span) @property def usd_main(self) -> float: """Main-conversation spend.""" return self.main.usd @property def usd_sub(self) -> float: """Subagent spend.""" return self.sub.usd @property def calls_main(self) -> int: """Main-conversation API calls.""" return self.main.calls @property def calls_sub(self) -> int: """Subagent API calls.""" return self.sub.calls @property def over_budget_calls(self) -> int: """Calls whose context exceeded :data:`CONTEXT_BUDGET`.""" return self.over_budget.calls @property def over_budget_usd(self) -> float: """Spend carried by calls over the context budget.""" return self.over_budget.usd @property def earliest(self) -> float: """Earliest record timestamp seen.""" return self.span.earliest @property def latest(self) -> float: """Latest record timestamp seen.""" return self.span.latest @property def usd_total(self) -> float: """Main plus subagent spend.""" return self.usd_main + self.usd_sub @property def calls(self) -> int: """Real API calls, main plus subagent.""" return self.calls_main + self.calls_sub @property def days(self) -> float: """Span actually covered by records, at least a minute's worth.""" return max(self.latest - self.earliest, 60.0) / SECONDS_PER_DAY def _bucket(index: int) -> str: """Label the call-position bucket a 1-based call index falls in.""" low = 1 for high in BUCKETS: if index <= high: return f"{low}-{high}" low = high + 1 return f"{low}+" def _stamp(found: Breakdown, stamp: float) -> None: """Widen the observed record span to include ``stamp``.""" if not stamp: return span = found.span span.earliest = min(span.earliest or stamp, stamp) span.latest = max(span.latest, stamp) def build(sessions: Sequence[Session], book: PriceBook) -> Breakdown: """Price every turn and roll it up along each axis.""" found = Breakdown() home = str(Path.home()) for session in sessions: is_sub = session.parent_id is not None found.commits += sum(1 for call in session.tools if call.commit) for index, turn in enumerate(session.turns, start=1): cost = usd(turn, book) _stamp(found, turn.timestamp) found.by_model[turn.model] += cost if turn.context > CONTEXT_BUDGET: found.over_budget.add(cost) if session.cwd == home: found.home_usd += cost if is_sub: found.sub.add(cost) found.by_agent_type[session.agent_type or "unknown"] += cost found.by_description[session.description or "?"] += cost if "opus" in turn.model: found.sub_opus_usd += cost cheap = replace(turn, model=CHEAPER_MODEL) found.sub_opus_repriced_usd += usd(cheap, book) else: found.main.add(cost) found.buckets.setdefault(_bucket(index), Tally()).add(cost) return found def _share(part: float, whole: float) -> float: """``part / whole`` rounded for storage, 0 when ``whole`` is 0.""" return round(part / whole, 4) if whole else 0.0 def metrics(found: Breakdown) -> dict[str, float]: """The flat metric dict snapshots store and ledger entries reference.""" total = found.usd_total opus_main = sum(c for m, c in found.by_model.items() if "opus" in m) return { "usd_total": round(total, 2), "usd_per_day": round(total / found.days, 2), "usd_per_call": round(total / found.calls, 5) if found.calls else 0.0, "calls": found.calls, "calls_per_day": round(found.calls / found.days, 1), "commits": found.commits, "usd_per_commit": round(total / found.commits, 2) if found.commits else 0.0, "subagent_usd_share": _share(found.usd_sub, total), "subagent_opus_usd_share": _share(found.sub_opus_usd, found.usd_sub), "opus_usd_share": _share(opus_main, total), "home_cwd_usd_share": _share(found.home_usd, total), "over_budget_call_share": _share(found.over_budget_calls, found.calls), "over_budget_usd_share": _share(found.over_budget_usd, total), "subagent_sonnet_saving_usd": round( found.sub_opus_usd - found.sub_opus_repriced_usd, 2 ), } def _money(rows: list[tuple[str, float]], total: float) -> list[str]: """Render ``(label, usd)`` rows as a markdown table with shares.""" out = ["| item | USD | share |", "|---|---|---|"] out += [f"| {k} | ${v:,.2f} | {_share(v, total) * 100:.1f}% |" for k, v in rows] return out def render(found: Breakdown, top: int = 12) -> list[str]: """Markdown sections for the dollar view.""" total = found.usd_total lines = [ "## Dollar view (list prices, deduplicated per API message)", "", f"**${total:,.2f}** over {found.days:.1f} days of records " f"({found.calls:,} API calls; main ${found.usd_main:,.2f}, " f"subagents ${found.usd_sub:,.2f}).", f"Calls with context over {CONTEXT_BUDGET:,} tokens: " f"{found.over_budget_calls:,} carrying " f"{_share(found.over_budget_usd, total) * 100:.1f}% of spend.", "", "### By model", "", ] lines += _money(found.by_model.most_common(top), total) lines += ["", "### Subagents by type", ""] lines += _money(found.by_agent_type.most_common(top), total) lines += ["", "### Costliest subagent tasks", ""] lines += _money(found.by_description.most_common(top), total) lines += ["", "### Cost per call by position in session (main only)", ""] lines += ["| calls | USD per call | calls |", "|---|---|---|"] for label in sorted(found.buckets, key=lambda b: int(b.split("-")[0].rstrip("+"))): tally = found.buckets[label] lines.append(f"| {label} | ${tally.usd / tally.calls:.4f} | {tally.calls:,} |") return lines