agent-cost-attribution 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ """agent-cost-attribution — per-stage token attribution for multi-agent workflow runs."""
2
+ from agent_cost_attribution.ledger import (
3
+ AgentRecord, agents, decompose, load_run, summary, total_tokens, verify_invariant,
4
+ )
5
+ from agent_cost_attribution.health import detect_degradation, is_healthy
6
+ from agent_cost_attribution.pricing import estimate_cost, cost_by_stage, total_cost, blended_rate
7
+
8
+ __all__ = [
9
+ "AgentRecord", "agents", "decompose", "load_run", "summary", "total_tokens",
10
+ "verify_invariant", "detect_degradation", "is_healthy",
11
+ "estimate_cost", "cost_by_stage", "total_cost", "blended_rate",
12
+ ]
@@ -0,0 +1,3 @@
1
+ from agent_cost_attribution.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,59 @@
1
+ """CLI: python -m agent_cost_attribution <run.json | run-dir> ...
2
+
3
+ Prints a per-stage token waterfall + a silent-degradation health report for each run. Workload-
4
+ agnostic: any wf_*.json (or a directory of them) works — this is the reusable surface.
5
+ """
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from agent_cost_attribution.ledger import load_run, summary
10
+ from agent_cost_attribution.health import detect_degradation
11
+ from agent_cost_attribution.pricing import cost_by_stage, DEFAULT_INPUT_SHARE
12
+
13
+
14
+ def _iter_paths(args):
15
+ for a in args:
16
+ p = Path(a)
17
+ if p.is_dir():
18
+ yield from sorted(p.glob("wf_*.json"))
19
+ else:
20
+ yield p
21
+
22
+
23
+ def render(run, *, input_share=DEFAULT_INPUT_SHARE):
24
+ s = summary(run)
25
+ costs = cost_by_stage(run, input_share=input_share)
26
+ total_usd = sum(costs.values())
27
+ head = (f"{s['workflow'] or '?'} {s['run_id']} status={s['status']} "
28
+ f"total={s['total_tokens']:,} tok ~${total_usd:,.2f} invariant_ok={s['invariant_ok']}")
29
+ lines = [head]
30
+ for stage, st in sorted(s["stages"].items(), key=lambda kv: -kv[1]["tokens"]):
31
+ bar = "#" * int(round(st["pct"] / 2))
32
+ usd = costs.get(stage, 0.0)
33
+ lines.append(f" {stage:14s} {st['tokens']:>9,} {st['pct']:5.1f}% ~${usd:>7,.2f} n={st['count']:<3d} {bar}")
34
+ findings = detect_degradation(run)
35
+ if findings:
36
+ lines.append(" ! DEGRADED — cost numbers on this run are NOT publishable:")
37
+ for f in findings:
38
+ lines.append(f" [{f['severity']}] {f['stage']}: {f['kind']} — {f['detail']}")
39
+ lines.append(f" ($ = estimate: list prices, {input_share:.0%}-input blend; telemetry has no I/O split)")
40
+ return "\n".join(lines)
41
+
42
+
43
+ def main(argv=None):
44
+ argv = sys.argv[1:] if argv is None else argv
45
+ if not argv:
46
+ print("usage: python -m agent_cost_attribution <run.json | run-dir> ...", file=sys.stderr)
47
+ return 2
48
+ paths = list(_iter_paths(argv))
49
+ if not paths:
50
+ print("no run files found", file=sys.stderr)
51
+ return 1
52
+ for p in paths:
53
+ run = load_run(p)
54
+ if not run:
55
+ print(f"{p}: unreadable/empty", file=sys.stderr)
56
+ continue
57
+ print(render(run))
58
+ print()
59
+ return 0
@@ -0,0 +1,50 @@
1
+ """Silent-degradation detector for multi-agent workflow runs.
2
+
3
+ The failure that broke the cost baseline (wf_3d01df61): a stage's agents can all ERROR — or run
4
+ anomalously cheap — while the run still reports a status and a plausible totalTokens, so a naive
5
+ per-stage decomposition silently mis-attributes cost (Fetch looked like 66% only because every
6
+ verifier crashed at ~1588 tok). This flags such stages so a cost number is never published on a
7
+ degraded run.
8
+ """
9
+ import statistics
10
+
11
+ from agent_cost_attribution.ledger import agents
12
+
13
+
14
+ def detect_degradation(run, *, min_agents=5, cheap_ratio=0.25):
15
+ """Findings (empty == healthy). Each: {stage, kind, severity, detail}.
16
+
17
+ kind 'errors' : a stage has agents in state 'error'.
18
+ kind 'cheap' : a stage of >= min_agents whose mean tokens/agent is < cheap_ratio of the run-wide
19
+ baseline (median of per-stage mean costs) — the signature of a mass-degraded stage.
20
+ """
21
+ ags = agents(run)
22
+ findings = []
23
+
24
+ by_stage = {}
25
+ for a in ags:
26
+ by_stage.setdefault(a.stage, []).append(a)
27
+
28
+ # Run-wide baseline: median of per-stage mean token costs (one data point per stage).
29
+ # Using per-stage means rather than per-agent tokens prevents a degraded stage's own
30
+ # cheap agents from dragging the median down and hiding itself.
31
+ stage_means = [statistics.mean(r.tokens for r in rows)
32
+ for rows in by_stage.values() if rows]
33
+ median = statistics.median(stage_means) if stage_means else 0
34
+
35
+ for stage, rows in by_stage.items():
36
+ errs = [r for r in rows if r.state == "error"]
37
+ if errs:
38
+ findings.append({"stage": stage, "kind": "errors", "severity": "high",
39
+ "detail": f"{len(errs)}/{len(rows)} agents in state 'error'"})
40
+ if median and len(rows) >= min_agents:
41
+ mean_tok = statistics.mean(r.tokens for r in rows)
42
+ if mean_tok < cheap_ratio * median:
43
+ findings.append({"stage": stage, "kind": "cheap", "severity": "high",
44
+ "detail": f"mean {mean_tok:.0f} tok/agent over {len(rows)} agents "
45
+ f"< {cheap_ratio:.0%} of run median {median:.0f}"})
46
+ return findings
47
+
48
+
49
+ def is_healthy(run, **kw):
50
+ return not detect_degradation(run, **kw)
@@ -0,0 +1,107 @@
1
+ """Per-stage token attribution for multi-agent workflow runs.
2
+
3
+ Reusable + stdlib-only: reads platform workflow telemetry (wf_*.json) and decomposes totalTokens
4
+ across stages (phases). No dependency on any workflow's internals, so it works for ANY workflow run,
5
+ not just deep-research.
6
+
7
+ Verified schema: a run JSON has top-level `totalTokens` and `workflowProgress`, a list whose entries
8
+ are either type=="workflow_phase" (headers, no tokens) or type=="workflow_agent" (per-agent records
9
+ with `phaseTitle`, `state`, `tokens`, `model`). Invariant: agent tokens sum EXACTLY to totalTokens.
10
+
11
+ Fail-soft: a missing/corrupt run degrades to {}, never crashes a caller.
12
+ """
13
+ import json
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ _AGENT = "workflow_agent"
18
+
19
+
20
+ def _as_int(value):
21
+ """Coerce to int, fail-soft to 0 (a corrupt token/count field must never crash a parse)."""
22
+ try:
23
+ return int(value or 0)
24
+ except (ValueError, TypeError):
25
+ return 0
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class AgentRecord:
30
+ agent_id: str
31
+ label: str
32
+ stage: str
33
+ model: str
34
+ state: str
35
+ tokens: int
36
+ tool_calls: int
37
+
38
+
39
+ def load_run(path):
40
+ """Load a run JSON; fail-soft to {} on missing/corrupt/non-object."""
41
+ try:
42
+ data = json.loads(Path(path).read_text())
43
+ except (OSError, ValueError, TypeError):
44
+ return {}
45
+ return data if isinstance(data, dict) else {}
46
+
47
+
48
+ def agents(run):
49
+ """Per-agent records (type==workflow_agent). Phase-header rows are skipped."""
50
+ out = []
51
+ for e in (run.get("workflowProgress") or []):
52
+ if not isinstance(e, dict) or e.get("type") != _AGENT:
53
+ continue
54
+ out.append(AgentRecord(
55
+ agent_id=e.get("agentId", ""),
56
+ label=e.get("label", ""),
57
+ stage=e.get("phaseTitle", "?"),
58
+ model=e.get("model", ""),
59
+ state=e.get("state", "?"),
60
+ tokens=_as_int(e.get("tokens")),
61
+ tool_calls=_as_int(e.get("toolCalls")),
62
+ ))
63
+ return out
64
+
65
+
66
+ def total_tokens(run):
67
+ """The run's reported total token count, fail-soft to 0."""
68
+ return _as_int(run.get("totalTokens"))
69
+
70
+
71
+ def decompose(run):
72
+ """{stage: summed tokens}, preserving first-seen (phase) order."""
73
+ out = {}
74
+ for a in agents(run):
75
+ out[a.stage] = out.get(a.stage, 0) + a.tokens
76
+ return out
77
+
78
+
79
+ def verify_invariant(run):
80
+ """True iff per-agent tokens sum to the run's totalTokens — the parser's correctness check.
81
+
82
+ A run with no agent rows returns False (no data is not a satisfied invariant)."""
83
+ ags = agents(run)
84
+ if not ags:
85
+ return False
86
+ return sum(a.tokens for a in ags) == total_tokens(run)
87
+
88
+
89
+ def summary(run):
90
+ """Per-stage decomposition with pct/count/state-mix, plus the invariant flag."""
91
+ tot = total_tokens(run)
92
+ stages = {}
93
+ for a in agents(run):
94
+ s = stages.setdefault(a.stage, {"tokens": 0, "count": 0, "states": {}})
95
+ s["tokens"] += a.tokens
96
+ s["count"] += 1
97
+ s["states"][a.state] = s["states"].get(a.state, 0) + 1
98
+ for s in stages.values():
99
+ s["pct"] = round(100 * s["tokens"] / tot, 1) if tot else 0.0
100
+ return {
101
+ "run_id": run.get("runId", ""),
102
+ "workflow": run.get("workflowName", ""),
103
+ "status": run.get("status", ""),
104
+ "total_tokens": tot,
105
+ "invariant_ok": verify_invariant(run),
106
+ "stages": stages,
107
+ }
@@ -0,0 +1,51 @@
1
+ """Estimated dollar cost for token counts, by model.
2
+
3
+ CAVEAT (important, surfaced in output): workflow telemetry reports a SINGLE token count per agent with
4
+ NO input/output split. Dollar figures are therefore ESTIMATES — we price the token count at a per-model
5
+ blended rate using a configurable input-share assumption (default 0.85: agentic/tool-heavy runs are
6
+ input-dominated). Prices are public list prices and change over time; update PRICES as needed.
7
+ """
8
+
9
+ # $ per 1M tokens (input, output) — public list prices, verified at platform.claude.com 2026-06-09.
10
+ PRICES = {
11
+ "fable": (10.0, 50.0),
12
+ "opus": (5.0, 25.0),
13
+ "sonnet": (3.0, 15.0),
14
+ "haiku": (1.0, 5.0),
15
+ }
16
+ _UNKNOWN = PRICES["opus"] # unknown model → priced as opus (sensible default)
17
+ DEFAULT_INPUT_SHARE = 0.85
18
+
19
+
20
+ def _tier(model):
21
+ m = (model or "").lower()
22
+ for tier in ("fable", "opus", "sonnet", "haiku"):
23
+ if tier in m:
24
+ return tier
25
+ return None
26
+
27
+
28
+ def blended_rate(model, *, input_share=DEFAULT_INPUT_SHARE):
29
+ """Blended $ per single token for `model`, given the input-share assumption."""
30
+ inp, outp = PRICES.get(_tier(model), _UNKNOWN)
31
+ per_mtok = input_share * inp + (1.0 - input_share) * outp
32
+ return per_mtok / 1_000_000.0
33
+
34
+
35
+ def estimate_cost(tokens, model, *, input_share=DEFAULT_INPUT_SHARE):
36
+ """Estimated USD to process `tokens` on `model`. An ESTIMATE — see module caveat."""
37
+ return (tokens or 0) * blended_rate(model, input_share=input_share)
38
+
39
+
40
+ def cost_by_stage(run, *, input_share=DEFAULT_INPUT_SHARE):
41
+ """{stage: estimated USD}, summed PER AGENT (so per-agent model routing is reflected)."""
42
+ from agent_cost_attribution.ledger import agents
43
+ out = {}
44
+ for a in agents(run):
45
+ out[a.stage] = out.get(a.stage, 0.0) + estimate_cost(a.tokens, a.model, input_share=input_share)
46
+ return out
47
+
48
+
49
+ def total_cost(run, *, input_share=DEFAULT_INPUT_SHARE):
50
+ """Estimated USD for the whole run."""
51
+ return sum(cost_by_stage(run, input_share=input_share).values())
@@ -0,0 +1,122 @@
1
+ """Route every model call to the cheapest sufficient model — the enforcement half of A1.
2
+
3
+ The meter (this repo) tells you WHERE tokens go per stage; this module is the policy layer
4
+ that makes the fix stick: a single role table maps each workload to a model + effort, so
5
+ "right-size the model per task" (PLAYBOOK A1) is one table instead of N scattered call
6
+ sites. The case study that produced this pattern is in ROUTING.md.
7
+
8
+ Design rules (each one earned by a real failure, see ROUTING.md):
9
+ - One table, no hardcoded models at call sites — an unpinned call silently inherits your
10
+ most expensive default.
11
+ - Unknown role -> KeyError (fail closed): a typo'd role must surface, not silently route.
12
+ - Overrides file -> fail SOFT: a corrupt tuning file must degrade to defaults, never
13
+ silence a production routine.
14
+ - Per-message escalation is IMPERATIVE-ONLY and tag-overridable: a missed escalation is
15
+ recoverable (the user types the tag); a false escalation silently burns budget.
16
+
17
+ Dependency-free, ~120 lines. Pair with `pricing.savings_estimate`-style numbers via
18
+ `savings_estimate()` below — but treat those as planning estimates; the meter measures.
19
+ """
20
+ import json
21
+ import re
22
+ from pathlib import Path
23
+
24
+
25
+ class Router:
26
+ """Role -> {"model", "effort"} resolution with optional fail-soft file overrides.
27
+
28
+ `roles` is your table, e.g. {"chat": {"model": "...", "effort": "medium"}, ...}.
29
+ `overrides_path` (JSON: {role: {model?, effort?}}) lets you re-route live without a
30
+ deploy; partial overrides merge over defaults, garbage is ignored per-key.
31
+ """
32
+
33
+ def __init__(self, roles, overrides_path=None):
34
+ self.roles = dict(roles)
35
+ # Coerce str -> Path: fail-soft is for CORRUPT files, not for hiding a caller's
36
+ # type mistake (a str path silently disabling live tuning is the worst outcome).
37
+ self.overrides_path = (Path(overrides_path) if isinstance(overrides_path, str)
38
+ else overrides_path)
39
+
40
+ def _overrides(self):
41
+ if self.overrides_path is None:
42
+ return {}
43
+ try:
44
+ loaded = json.loads(self.overrides_path.read_text())
45
+ except (OSError, ValueError, TypeError):
46
+ return {}
47
+ return loaded if isinstance(loaded, dict) else {}
48
+
49
+ def resolve(self, role):
50
+ """{"model", "effort"} for `role`. Unknown role raises KeyError (fail closed)."""
51
+ cfg = dict(self.roles[role])
52
+ ov = self._overrides().get(role)
53
+ if isinstance(ov, dict):
54
+ m = ov.get("model")
55
+ if isinstance(m, str) and m.strip():
56
+ cfg["model"] = m.strip()
57
+ if "effort" in ov:
58
+ e = ov["effort"]
59
+ cfg["effort"] = e.strip() if isinstance(e, str) and e.strip() else None
60
+ return cfg
61
+
62
+ def model_for(self, role):
63
+ return self.resolve(role)["model"]
64
+
65
+ def effort_for(self, role):
66
+ return self.resolve(role)["effort"]
67
+
68
+
69
+ class MessageRouter:
70
+ """Per-message routing for a chat-style agent: explicit !tag > escalation regex >
71
+ cheap default. The tag is stripped from the text before the model sees it.
72
+
73
+ `tags` maps tag names to {"model", "effort"} (typed as `!name` anywhere in a message).
74
+ `deep_pattern` is YOUR escalation regex — keep it imperative-only (see module
75
+ docstring for why false escalations are the failure mode, not missed ones).
76
+ """
77
+
78
+ def __init__(self, router, *, tags, deep_pattern,
79
+ default_role="chat", deep_role="chat_deep"):
80
+ self.router = router
81
+ self.tags = {k.lower(): dict(v) for k, v in tags.items()}
82
+ if self.tags:
83
+ names = "|".join(re.escape(k) for k in sorted(self.tags))
84
+ self._tag_re = re.compile(rf"(?:(?<=\s)|^)!({names})\b", re.I)
85
+ else:
86
+ self._tag_re = re.compile(r"(?!x)x") # never matches: no tags configured
87
+ self._deep_re = re.compile(deep_pattern, re.I)
88
+ self.default_role = default_role
89
+ self.deep_role = deep_role
90
+
91
+ def route(self, text):
92
+ """Returns ({"model", "effort"}, cleaned_text)."""
93
+ text = text if isinstance(text, str) else ""
94
+ m = self._tag_re.search(text)
95
+ if m:
96
+ cleaned = self._tag_re.sub("", text, count=1)
97
+ cleaned = re.sub(r"[ \t]{2,}", " ", cleaned).strip()
98
+ return dict(self.tags[m.group(1).lower()]), cleaned
99
+ if self._deep_re.search(text):
100
+ return self.router.resolve(self.deep_role), text
101
+ return self.router.resolve(self.default_role), text
102
+
103
+
104
+ def savings_estimate(traffic, roles, *, baseline_model, input_share=None):
105
+ """PLANNING estimate of routed vs uniform-baseline spend. NOT a measurement.
106
+
107
+ `traffic` maps role -> token volume (get real volumes from the meter's per-stage
108
+ waterfall). Returns {"baseline_usd", "routed_usd", "saved_usd", "saved_pct"}, priced
109
+ with this repo's `pricing` blended rates (same caveats: list prices, input-share
110
+ assumption). Publish measured deltas from the meter, not this — this is for deciding
111
+ whether the routing work is worth doing.
112
+ """
113
+ from agent_cost_attribution import pricing
114
+ share = pricing.DEFAULT_INPUT_SHARE if input_share is None else input_share
115
+ router = Router(roles)
116
+ baseline = routed = 0.0
117
+ for role, tokens in traffic.items():
118
+ baseline += pricing.estimate_cost(tokens, baseline_model, input_share=share)
119
+ routed += pricing.estimate_cost(tokens, router.model_for(role), input_share=share)
120
+ saved = baseline - routed
121
+ return {"baseline_usd": baseline, "routed_usd": routed, "saved_usd": saved,
122
+ "saved_pct": (100.0 * saved / baseline) if baseline else 0.0}
@@ -0,0 +1,194 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-cost-attribution
3
+ Version: 0.1.0
4
+ Summary: Per-stage token/cost attribution and silent-degradation detection for multi-agent workflow runs (stdlib-only).
5
+ Author: Jeff Otterson
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Jott2121/agent-cost-attribution
8
+ Project-URL: Repository, https://github.com/Jott2121/agent-cost-attribution
9
+ Project-URL: Issues, https://github.com/Jott2121/agent-cost-attribution/issues
10
+ Keywords: ai,agents,llm,cost,tokens,observability,telemetry
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Quality Assurance
20
+ Classifier: Topic :: System :: Monitoring
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Dynamic: license-file
25
+
26
+ # agent-cost-attribution
27
+
28
+ [![CI](https://github.com/Jott2121/agent-cost-attribution/actions/workflows/ci.yml/badge.svg)](https://github.com/Jott2121/agent-cost-attribution/actions/workflows/ci.yml)
29
+ [![CodeQL](https://github.com/Jott2121/agent-cost-attribution/actions/workflows/codeql.yml/badge.svg)](https://github.com/Jott2121/agent-cost-attribution/actions/workflows/codeql.yml)
30
+ [![Coverage](https://raw.githubusercontent.com/Jott2121/agent-cost-attribution/python-coverage-comment-action-data/badge.svg)](https://github.com/Jott2121/agent-cost-attribution/actions/workflows/ci.yml)
31
+ ![Python](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11%20%7C%203.12-blue)
32
+
33
+ **Get the most capability per token out of agentic coding, and prove it. Headline result, measured by the meter in this repo: routing four mechanical agents to a cheap model cut run cost 67% (~$0.60 → ~$0.20) with identical facts extracted ([receipt](https://github.com/Jott2121/agent-cost-attribution/blob/main/examples/routing-savings.md)).**
34
+
35
+ Agentic workflows (fan-out subagents, multi-step research, tool-heavy pipelines) burn tokens fast,
36
+ and most of the waste is invisible. You can't fix what you can't see, and the platform's own "success"
37
+ flag is wrong more often than you'd think.
38
+
39
+ > 🧩 One layer of a five-repo [**cost-governance stack**](https://github.com/Jott2121/bow#the-system-a-cost-governance-stack) for operating AI agents cost-efficiently; [bow](https://github.com/Jott2121/bow) is the flagship that runs every layer in production.
40
+
41
+ This repo is two things:
42
+
43
+ 1. **`agent_cost_attribution`**: a tiny, dependency-free **meter**. Point it at a workflow run's
44
+ telemetry and get a per-stage token waterfall, plus a **silent-degradation check** that flags runs
45
+ that *reported success while quietly breaking*.
46
+ 2. **[PLAYBOOK.md](https://github.com/Jott2121/agent-cost-attribution/blob/main/PLAYBOOK.md)**: a playbook of transferable practices for cutting token burn in
47
+ agentic coding **without losing capability**, each rated by expected savings, axis (tokens vs cost
48
+ vs latency), capability risk, and effort.
49
+
50
+ Everything here is **measured, not asserted**. The numbers in this repo were produced by the meter
51
+ included here; point it at your own telemetry to do the same.
52
+
53
+ ## Try it in 30 seconds (zero dependencies, sample included)
54
+
55
+ ```bash
56
+ git clone https://github.com/Jott2121/agent-cost-attribution
57
+ cd agent-cost-attribution
58
+ python3 -m agent_cost_attribution examples/sample-run.json
59
+ ```
60
+
61
+ Output (from the synthetic sample shipped in [`examples/sample-run.json`](https://github.com/Jott2121/agent-cost-attribution/blob/main/examples/sample-run.json)):
62
+
63
+ ```
64
+ sample-research wf_sample-001 status=completed total=1,006,200 tok ~$3.92 invariant_ok=True
65
+ Verify 720,748 71.6% ~$ 3.46 n=12 ####################################
66
+ Search 254,248 25.3% ~$ 0.41 n=6 #############
67
+ Scope 31,204 3.1% ~$ 0.05 n=1 ##
68
+ ($ = estimate: list prices, 85%-input blend; telemetry has no I/O split)
69
+ ```
70
+
71
+ Then point it at your own runs:
72
+
73
+ ```bash
74
+ python3 -m agent_cost_attribution path/to/run.json
75
+ python3 -m agent_cost_attribution path/to/runs-dir/ # every wf_*.json in a directory
76
+ ```
77
+
78
+ **Where does `run.json` come from?** The meter reads Claude Code workflow telemetry: the `wf_*.json`
79
+ files that Claude Code writes automatically when you run agentic workflows, found under
80
+ `~/.claude/projects/*/tasks/`. If you run Claude Code workflows, you already have these files; no
81
+ instrumentation, SDK, or proxy is required. The shipped sample is synthetic but format-exact, so you
82
+ can see the output before you have telemetry of your own.
83
+
84
+ You get three things per run: **tokens**, an estimated **dollar cost**, and a **trust check**.
85
+
86
+ - **`invariant_ok`** means the per-agent token counts sum **exactly** to the run total. The parser is
87
+ checking itself, so you can believe the breakdown.
88
+ - The **`~$` figures are estimates**: each agent's tokens are priced at its model's list price using a
89
+ documented input/output blend (the telemetry exposes only a single token count, no I/O split), so read
90
+ them as a calibrated band, not a billing statement. Because they're priced **per agent**, a
91
+ model-routing win shows up directly (route a stage to a cheaper model and its `~$` drops).
92
+ - A **`DEGRADED`** banner appears when a stage errored or ran anomalously cheap. Cost numbers on a
93
+ degraded run aren't trustworthy and shouldn't be published.
94
+
95
+ ## How this differs from Langfuse / Helicone
96
+
97
+ Langfuse, Helicone, and similar LLM observability platforms are excellent at what they do: they sit
98
+ in your request path (SDK or proxy), capture every call, and give you dashboards over time. This tool
99
+ is deliberately the opposite shape. It is a **post-hoc forensic meter**: a single stdlib-only Python
100
+ package, no server, no account, no instrumentation, that reads telemetry files the platform already
101
+ wrote and answers two narrow questions per run: *which stage of this multi-agent workflow ate the
102
+ tokens*, and *can I trust this run's numbers at all* (the invariant check plus silent-degradation
103
+ detection). If you want fleet-wide dashboards, use those platforms. If you want to audit one run's
104
+ cost attribution in 30 seconds with zero setup, use this.
105
+
106
+ ## The headline finding (why you should trust the method)
107
+
108
+ I built this meter to support an optimization plan I'd already written. **The meter overturned my own
109
+ plan.** I had assumed the token whale was the page-**Fetch** stage; the telemetry showed it was the
110
+ **Verify** stage (50-74% of healthy runs vs Fetch's ~19-37%). The "expensive" run I'd anchored my
111
+ baseline on turned out to be a **silently broken outlier**: it reported `status=completed` while all
112
+ 75 of its verifier agents had errored, which is the *only* reason Fetch looked dominant there.
113
+
114
+ Along the way the meter also showed the platform's own status flag was unreliable in **both**
115
+ directions: one run said `completed` but was broken; another said `failed` but was perfectly healthy.
116
+ The lesson, and the reason the silent-degradation check exists: **trust per-stage health, not the
117
+ run's self-report.** Full numbers in [`examples/self-correction-deep-research.md`](https://github.com/Jott2121/agent-cost-attribution/blob/main/examples/self-correction-deep-research.md).
118
+
119
+ Measurement that kills your own hypothesis is the whole point. The rest of this repo is built on it.
120
+
121
+ ## The playbook (TL;DR; full version in [PLAYBOOK.md](https://github.com/Jott2121/agent-cost-attribution/blob/main/PLAYBOOK.md))
122
+
123
+ - **Right-size the model per task**: mechanical sub-tasks on a cheap model, judgment/synthesis on the
124
+ strong one. (This repo's own builder/reviewer agents ran on the cheaper tier.)
125
+ - **Fan out only for read-heavy parallel work; keep writes single-threaded.**
126
+ - **Scope context tightly**: read the slice you need, don't re-read, hand a sub-agent only what it needs.
127
+ - **Don't re-gather redundantly · kill junk cheaply before expensive stages · dedup inputs · make your caps actually cap.**
128
+ - **Prompt caching · structured outputs.**
129
+ - **Meter every run · gate on quality · refuse the unsafe shortcut and prove it.**
130
+
131
+ Each practice is reported on the right axis and **never double-counted** (routing changes cost-per-token,
132
+ not token *count*; caching is a cost-axis win; only genuine token-count reductions go on the headline).
133
+
134
+ **Measured proof:** [`examples/routing-savings.md`](https://github.com/Jott2121/agent-cost-attribution/blob/main/examples/routing-savings.md), a live before/after
135
+ where routing four mechanical agents to a cheap model cut run cost **67% (~$0.60 to ~$0.20)** with
136
+ identical facts extracted, isolated by the meter to exactly the routed stage. Reproducible from
137
+ [`examples/routing-demo.js`](https://github.com/Jott2121/agent-cost-attribution/blob/main/examples/routing-demo.js).
138
+
139
+ ## Making A1 stick: the routing layer ([ROUTING.md](https://github.com/Jott2121/agent-cost-attribution/blob/main/ROUTING.md))
140
+
141
+ Right-sizing the model per task fails in practice when the model is a string literal at N call
142
+ sites: one unpinned call silently inherits your most expensive default. [ROUTING.md](https://github.com/Jott2121/agent-cost-attribution/blob/main/ROUTING.md)
143
+ is the case study of retrofitting a live agent fleet (11 call sites, zero routing, all premium-pinned)
144
+ with a central role table, per-message escalation (`!tag` > imperative-only heuristic > cheap
145
+ default), fail-soft live tuning, and an independent cross-model QC pass that caught the policy's own
146
+ over-escalation bug before ship. Audited, built, and live in one day. The reusable module is
147
+ [`agent_cost_attribution/routing.py`](https://github.com/Jott2121/agent-cost-attribution/blob/main/agent_cost_attribution/routing.py) (stdlib-only, like the meter):
148
+ `Router` (fail-closed roles, fail-soft overrides), `MessageRouter` (chat escalation), and
149
+ `savings_estimate()` (planning estimates from the meter's real volumes; labeled estimates, never
150
+ published as measurements).
151
+
152
+ ## What's here
153
+
154
+ ```
155
+ README.md - this file
156
+ PLAYBOOK.md - the practices, each with what / why / how / savings / risk
157
+ ROUTING.md - case study + design rules: retrofitting model routing onto a live fleet in a day
158
+ agent_cost_attribution/ - the meter (stdlib-only): ledger, health, cli, plus routing.py (A1's enforcement layer)
159
+ tests/ - the meter's tests (the sum==total invariant is golden-tested)
160
+ examples/ - measured worked examples + sample-run.json (synthetic quickstart telemetry)
161
+ pyproject.toml - package metadata + pytest config
162
+ .github/ - CI (pytest on Python 3.9-3.12 + a smoke run of the meter on the sample)
163
+ LICENSE - MIT
164
+ ```
165
+
166
+ ## Who it's for
167
+
168
+ Anyone running agentic workflows who wants maximum capability per token, and a way to *find their own
169
+ waste* instead of guessing. It's also a worked demonstration of rigorous, measured agentic-coding
170
+ practice: measure, attribute, gate on quality, publish what you kept *and* what you killed.
171
+
172
+ ## Reliability & security
173
+
174
+ A meter people trust has to be measured itself, so the repo is gated:
175
+
176
+ - **Coverage-gated test matrix** — pytest on Python 3.9–3.12, build fails below the coverage floor (currently 96% covered), plus a smoke-test of the meter on the shipped sample run.
177
+ - **CodeQL** — `security-extended` static analysis on every push, PR, and weekly; findings surface in the Security tab.
178
+ - **Pinned supply chain** — GitHub Actions pinned to commit SHAs, kept current by **Dependabot**.
179
+ - **Branch protection** — `main` requires CI + CodeQL to pass before a merge.
180
+ - **Disclosure policy** — see [SECURITY.md](https://github.com/Jott2121/agent-cost-attribution/blob/main/SECURITY.md); private reporting is enabled.
181
+
182
+ ## About
183
+
184
+ Built by **Jeff Otterson** ([Jott2121](https://github.com/Jott2121)). Part of the Fleet Mode line:
185
+ [**bow**](https://github.com/Jott2121/bow) (the flagship agent case study) ·
186
+ [**fleet-mode**](https://github.com/Jott2121/fleet-mode) (the orchestration doctrine as a live skill) ·
187
+ [**agent-gate**](https://github.com/Jott2121/agent-gate) · [**rag-guard**](https://github.com/Jott2121/rag-guard) ·
188
+ [**sabot**](https://github.com/Jott2121/sabot) (own-checks fault detection — its silent
189
+ model-downgrade operator grew out of the degraded-run detector in this repo).
190
+ The same discipline throughout: measure it, gate it, keep the receipts.
191
+
192
+ ## License
193
+
194
+ MIT, see [LICENSE](https://github.com/Jott2121/agent-cost-attribution/blob/main/LICENSE).
@@ -0,0 +1,12 @@
1
+ agent_cost_attribution/__init__.py,sha256=neLU0N0eFos2K5fNSrKlLL_tqkFN9uDA5FnC4bxZMXw,620
2
+ agent_cost_attribution/__main__.py,sha256=f9i3gFdt-T9IChQkPpTSg-UkywcIJrN4kMK59jRplUM,70
3
+ agent_cost_attribution/cli.py,sha256=d1eLijYOmN_zEZA88kgNQf_vkEvXaBjkHyRN96aY-7M,2254
4
+ agent_cost_attribution/health.py,sha256=Yf7RVSEYlzm8uwqHhrYJWG1jevOuIJSIUdzKcEfpqzI,2334
5
+ agent_cost_attribution/ledger.py,sha256=iw5l-eKWqSnzwtdeyIOV3-oQ1ONeARK67ku5GvrudWA,3396
6
+ agent_cost_attribution/pricing.py,sha256=twGFtOtGbAN247v75OLZ8uKFRZHOcZ12UkBYg04R6a0,2036
7
+ agent_cost_attribution/routing.py,sha256=F5YoDsA2eVi9TzWvKnq0sLJq62M08uLUKuEu63sGZBE,5699
8
+ agent_cost_attribution-0.1.0.dist-info/licenses/LICENSE,sha256=pbwOmZQ2k7MAsOJyV2f9ZOfIXLCpS2Tp232WUv16shI,1070
9
+ agent_cost_attribution-0.1.0.dist-info/METADATA,sha256=xi_plj2O5K9wSF3LPF0GiNIEu7Zfq_1zpTRhH_NDx_w,12639
10
+ agent_cost_attribution-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
11
+ agent_cost_attribution-0.1.0.dist-info/top_level.txt,sha256=JxnwI3G67HudA6Few1CZk_9AFGv4maMj9O0cpWc9F94,23
12
+ agent_cost_attribution-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jeff Otterson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ agent_cost_attribution