refactor(postmortem): report raw token counts, drop dollar estimates
Remove the price-table cost derivation entirely. The transcript carries no metered cost, and list prices vary by plan — so converting tokens to a dollar figure dressed an estimate up as a number. Report raw token counts instead, kept split by class (input / output / cache-creation / cache-read) with a `total_tokens` sum per scope. Script: drop DEFAULT_PRICING / load_pricing / rate_for / cost_of and the --pricing flag; aggregate_transcript and analyze_subagents no longer take a pricing arg; every `cost_usd` field becomes `total_tokens`; the pricing warning is gone. SKILL.md: the first graded axis is now "Token spend & efficiency", the scorecard and output format cite token counts, and the Iron Law / Red Flags now forbid converting tokens to a cost. cache_hit_ratio and the active-session warning are unchanged.
This commit is contained in:
@@ -13,11 +13,11 @@ traps that only deterministic code gets right every time:
|
||||
usage object. Counting per-line double-counts tokens. We dedup
|
||||
per requestId (max per field within a request, then sum across
|
||||
requests).
|
||||
2. The transcript carries NO costUSD and NO durationMs (both null).
|
||||
Cost is derived from tokens x a per-model price table; wall-clock
|
||||
from timestamp deltas. The price table is a LIST-PRICE ESTIMATE
|
||||
and is surfaced in the output so the report never presents a
|
||||
derived dollar figure as if it were metered.
|
||||
2. The transcript carries NO durationMs (always null), so wall-clock
|
||||
is derived from timestamp deltas. The numbers reported here are
|
||||
raw token counts, not a dollar estimate — different token classes
|
||||
(input / output / cache) are reported separately rather than
|
||||
collapsed into a single priced figure.
|
||||
|
||||
Stdlib only. No network. Read-only against ~/.claude.
|
||||
"""
|
||||
@@ -30,21 +30,6 @@ import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# --- Pricing -------------------------------------------------------------
|
||||
# USD per million tokens. Anthropic public list prices; treated as an
|
||||
# ESTIMATE, not a metered cost. Override wholesale via the
|
||||
# POSTMORTEM_PRICING env var (JSON of the same shape) or --pricing.
|
||||
# Keys are matched as substrings against message.model, longest first.
|
||||
DEFAULT_PRICING = {
|
||||
"opus": {"input": 15.0, "output": 75.0,
|
||||
"cache_write_5m": 18.75, "cache_write_1h": 30.0, "cache_read": 1.5},
|
||||
"sonnet": {"input": 3.0, "output": 15.0,
|
||||
"cache_write_5m": 3.75, "cache_write_1h": 6.0, "cache_read": 0.30},
|
||||
"haiku": {"input": 1.0, "output": 5.0,
|
||||
"cache_write_5m": 1.25, "cache_write_1h": 2.0, "cache_read": 0.10},
|
||||
}
|
||||
PRICING_AS_OF = "2026-01 list prices (estimate, override via POSTMORTEM_PRICING)"
|
||||
|
||||
_TOK_ALT = "DONE_WITH_CONCERNS|NEEDS_CONTEXT|BLOCKED|PARTIAL|DONE" # longest-first
|
||||
# Authoritative signal: the implement end-report leads with a structured
|
||||
# "Status: DONE" line (optionally wrapped in markdown bold or a list
|
||||
@@ -63,26 +48,6 @@ STATUS_STANDALONE_RE = re.compile(
|
||||
re.IGNORECASE | re.MULTILINE)
|
||||
|
||||
|
||||
def load_pricing(override_json):
|
||||
if override_json:
|
||||
return json.loads(override_json)
|
||||
env = os.environ.get("POSTMORTEM_PRICING")
|
||||
if env:
|
||||
return json.loads(env)
|
||||
return DEFAULT_PRICING
|
||||
|
||||
|
||||
def rate_for(model, pricing):
|
||||
if not model:
|
||||
return pricing.get("opus"), True
|
||||
for key in sorted(pricing, key=len, reverse=True):
|
||||
if key in model:
|
||||
return pricing[key], False
|
||||
# Unknown model: fall back to the most expensive known tier so the
|
||||
# estimate errs high rather than silently undercounting.
|
||||
return pricing.get("opus", next(iter(pricing.values()))), True
|
||||
|
||||
|
||||
def parse_ts(s):
|
||||
if not s:
|
||||
return None
|
||||
@@ -97,6 +62,14 @@ def empty_tokens():
|
||||
"cache_creation_5m": 0, "cache_creation_1h": 0}
|
||||
|
||||
|
||||
def token_total(tokens):
|
||||
"""Raw count of every token the session moved. cache_creation_5m/1h are
|
||||
a sub-split of cache_creation, so they are excluded to avoid double
|
||||
counting."""
|
||||
return (tokens["input"] + tokens["output"]
|
||||
+ tokens["cache_creation"] + tokens["cache_read"])
|
||||
|
||||
|
||||
def add_tokens(acc, usage):
|
||||
"""Add one request's deduped usage into an accumulator."""
|
||||
acc["input"] += usage.get("input_tokens", 0) or 0
|
||||
@@ -114,14 +87,6 @@ def add_tokens(acc, usage):
|
||||
acc["cache_creation_1h"] += f1 or 0
|
||||
|
||||
|
||||
def cost_of(tokens, rate):
|
||||
return (tokens["input"] * rate["input"]
|
||||
+ tokens["output"] * rate["output"]
|
||||
+ tokens["cache_creation_5m"] * rate["cache_write_5m"]
|
||||
+ tokens["cache_creation_1h"] * rate["cache_write_1h"]
|
||||
+ tokens["cache_read"] * rate["cache_read"]) / 1_000_000.0
|
||||
|
||||
|
||||
def iter_lines(path):
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
||||
for line in fh:
|
||||
@@ -134,9 +99,9 @@ def iter_lines(path):
|
||||
continue
|
||||
|
||||
|
||||
def aggregate_transcript(path, pricing):
|
||||
"""Return token totals, cost, model mix, and per-request count for one
|
||||
JSONL transcript (main or subagent). Dedups usage by requestId."""
|
||||
def aggregate_transcript(path):
|
||||
"""Return token totals, model mix, and per-request count for one JSONL
|
||||
transcript (main or subagent). Dedups usage by requestId."""
|
||||
# requestId -> {field: max seen}, plus model per request.
|
||||
req_usage = {}
|
||||
req_model = {}
|
||||
@@ -175,18 +140,10 @@ def aggregate_transcript(path, pricing):
|
||||
tokens = empty_tokens()
|
||||
server = {"web_search": 0, "web_fetch": 0}
|
||||
models = {}
|
||||
cost = 0.0
|
||||
unknown_model = False
|
||||
for key, usage in req_usage.items():
|
||||
add_tokens(tokens, usage)
|
||||
model = req_model.get(key)
|
||||
models[model] = models.get(model, 0) + 1
|
||||
rate, unk = rate_for(model, pricing)
|
||||
unknown_model = unknown_model or unk
|
||||
# per-request cost so each request bills at its own model's rate
|
||||
per = empty_tokens()
|
||||
add_tokens(per, usage)
|
||||
cost += cost_of(per, rate)
|
||||
sv = usage.get("_server", {})
|
||||
server["web_search"] += sv.get("web_search_requests", 0)
|
||||
server["web_fetch"] += sv.get("web_fetch_requests", 0)
|
||||
@@ -194,10 +151,9 @@ def aggregate_transcript(path, pricing):
|
||||
return {
|
||||
"requests": len(req_usage),
|
||||
"tokens": tokens,
|
||||
"cost_usd": round(cost, 4),
|
||||
"total_tokens": token_total(tokens),
|
||||
"models": models,
|
||||
"server_tools": server,
|
||||
"unknown_model": unknown_model,
|
||||
}
|
||||
|
||||
|
||||
@@ -209,7 +165,6 @@ def cache_hit_ratio(tokens):
|
||||
def analyze_main(path):
|
||||
"""Tool mix, error rate, skills, slash commands, prompts, wall-clock."""
|
||||
tool_calls = {}
|
||||
tool_errors = {}
|
||||
error_total = 0
|
||||
interrupted = 0
|
||||
skills = []
|
||||
@@ -217,7 +172,7 @@ def analyze_main(path):
|
||||
tasks = []
|
||||
user_prompts = 0
|
||||
first_ts = last_ts = None
|
||||
cwd = project = version = None
|
||||
cwd = version = None
|
||||
|
||||
for rec in iter_lines(path):
|
||||
ts = parse_ts(rec.get("timestamp"))
|
||||
@@ -271,8 +226,6 @@ def analyze_main(path):
|
||||
if isinstance(tur, dict) and tur.get("interrupted"):
|
||||
interrupted += 1
|
||||
|
||||
# error attribution by tool name needs pairing result->call; we report
|
||||
# the session-wide error_total and leave per-tool to the weak signal.
|
||||
return {
|
||||
"tool_calls": tool_calls,
|
||||
"total_tool_calls": sum(tool_calls.values()),
|
||||
@@ -313,7 +266,7 @@ def subagent_terminal_status(path):
|
||||
return "unknown"
|
||||
|
||||
|
||||
def analyze_subagents(session_dir, pricing):
|
||||
def analyze_subagents(session_dir):
|
||||
out = []
|
||||
sub_dir = os.path.join(session_dir, "subagents")
|
||||
for jl in sorted(glob.glob(os.path.join(sub_dir, "agent-*.jsonl"))):
|
||||
@@ -325,7 +278,7 @@ def analyze_subagents(session_dir, pricing):
|
||||
meta = json.load(fh)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
agg = aggregate_transcript(jl, pricing)
|
||||
agg = aggregate_transcript(jl)
|
||||
out.append({
|
||||
"agent_id": os.path.basename(jl)[len("agent-"):-len(".jsonl")],
|
||||
"agent_type": meta.get("agentType"),
|
||||
@@ -334,7 +287,7 @@ def analyze_subagents(session_dir, pricing):
|
||||
"models": agg["models"],
|
||||
"requests": agg["requests"],
|
||||
"tokens": agg["tokens"],
|
||||
"cost_usd": agg["cost_usd"],
|
||||
"total_tokens": agg["total_tokens"],
|
||||
"terminal_status": subagent_terminal_status(jl),
|
||||
})
|
||||
return out
|
||||
@@ -373,31 +326,26 @@ def main():
|
||||
ap.add_argument("--file", help="explicit path to a main transcript .jsonl")
|
||||
ap.add_argument("--cwd", help="project cwd to resolve logs for (default: $PWD)")
|
||||
ap.add_argument("--project-dir", help="explicit ~/.claude/projects/<slug> dir")
|
||||
ap.add_argument("--pricing", help="JSON price table override")
|
||||
args = ap.parse_args()
|
||||
|
||||
pricing = load_pricing(args.pricing)
|
||||
main_path, session_dir = resolve_session(args)
|
||||
session_id = os.path.basename(main_path)[:-len(".jsonl")]
|
||||
|
||||
main_agg = aggregate_transcript(main_path, pricing)
|
||||
main_agg = aggregate_transcript(main_path)
|
||||
main_meta = analyze_main(main_path)
|
||||
|
||||
subagents = []
|
||||
if session_dir and os.path.isdir(session_dir):
|
||||
subagents = analyze_subagents(session_dir, pricing)
|
||||
subagents = analyze_subagents(session_dir)
|
||||
|
||||
sub_tokens = empty_tokens()
|
||||
sub_cost = 0.0
|
||||
for s in subagents:
|
||||
for k in sub_tokens:
|
||||
sub_tokens[k] += s["tokens"][k]
|
||||
sub_cost += s["cost_usd"]
|
||||
|
||||
total_tokens = empty_tokens()
|
||||
for k in total_tokens:
|
||||
total_tokens[k] = main_agg["tokens"][k] + sub_tokens[k]
|
||||
total_cost = round(main_agg["cost_usd"] + sub_cost, 4)
|
||||
|
||||
warnings = []
|
||||
is_active = False
|
||||
@@ -407,13 +355,8 @@ def main():
|
||||
is_active = True
|
||||
warnings.append("Session appears ACTIVE (last event <2 min ago); "
|
||||
"totals are partial and will grow.")
|
||||
if main_agg["unknown_model"] or any(s.get("models") and
|
||||
any(rate_for(m, pricing)[1] for m in s["models"])
|
||||
for s in subagents):
|
||||
warnings.append("One or more models were not in the price table; "
|
||||
"billed at the opus tier (estimate errs high).")
|
||||
warnings.append("Cost is DERIVED from tokens x list prices, not metered. "
|
||||
"Price basis: " + PRICING_AS_OF)
|
||||
warnings.append("Token counts are raw usage; wall-clock is derived from "
|
||||
"timestamp deltas (the transcript carries no durationMs).")
|
||||
|
||||
report = {
|
||||
"session": {
|
||||
@@ -430,7 +373,7 @@ def main():
|
||||
"requests": main_agg["requests"],
|
||||
"models": main_agg["models"],
|
||||
"tokens": main_agg["tokens"],
|
||||
"cost_usd": main_agg["cost_usd"],
|
||||
"total_tokens": main_agg["total_tokens"],
|
||||
"cache_hit_ratio": cache_hit_ratio(main_agg["tokens"]),
|
||||
"server_tools": main_agg["server_tools"],
|
||||
"user_prompts": main_meta["user_prompts"],
|
||||
@@ -451,15 +394,13 @@ def main():
|
||||
"subagent_totals": {
|
||||
"count": len(subagents),
|
||||
"tokens": sub_tokens,
|
||||
"cost_usd": round(sub_cost, 4),
|
||||
"total_tokens": token_total(sub_tokens),
|
||||
},
|
||||
"totals": {
|
||||
"tokens": total_tokens,
|
||||
"total_tokens": token_total(total_tokens),
|
||||
"cache_hit_ratio": cache_hit_ratio(total_tokens),
|
||||
"cost_usd": total_cost,
|
||||
},
|
||||
"pricing_used": pricing,
|
||||
"pricing_basis": PRICING_AS_OF,
|
||||
"warnings": warnings,
|
||||
}
|
||||
json.dump(report, sys.stdout, indent=2)
|
||||
|
||||
Reference in New Issue
Block a user