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.
- agent_cost_attribution/__init__.py +12 -0
- agent_cost_attribution/__main__.py +3 -0
- agent_cost_attribution/cli.py +59 -0
- agent_cost_attribution/health.py +50 -0
- agent_cost_attribution/ledger.py +107 -0
- agent_cost_attribution/pricing.py +51 -0
- agent_cost_attribution/routing.py +122 -0
- agent_cost_attribution-0.1.0.dist-info/METADATA +194 -0
- agent_cost_attribution-0.1.0.dist-info/RECORD +12 -0
- agent_cost_attribution-0.1.0.dist-info/WHEEL +5 -0
- agent_cost_attribution-0.1.0.dist-info/licenses/LICENSE +21 -0
- agent_cost_attribution-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,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
|
+
[](https://github.com/Jott2121/agent-cost-attribution/actions/workflows/ci.yml)
|
|
29
|
+
[](https://github.com/Jott2121/agent-cost-attribution/actions/workflows/codeql.yml)
|
|
30
|
+
[](https://github.com/Jott2121/agent-cost-attribution/actions/workflows/ci.yml)
|
|
31
|
+

|
|
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,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
|