1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
|
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
HERE = Path(__file__).resolve().parent.parent
WF = HERE / "wf.py"
sys.path.insert(0, str(HERE))
from wflib import usage # noqa: E402
def entry(mid, req, model, ts, inp=0, cw5=0, cw1h=0, cr=0, out=0, type="assistant", stop=None, content=None,
uuid=None):
u = {"input_tokens": inp, "cache_creation_input_tokens": cw5 + cw1h, "cache_read_input_tokens": cr,
"output_tokens": out}
if cw1h:
u["cache_creation"] = {"ephemeral_5m_input_tokens": cw5, "ephemeral_1h_input_tokens": cw1h}
d = {"type": type, "requestId": req, "timestamp": ts,
"message": {"id": mid, "model": model, "role": "assistant", "usage": u}}
if stop:
d["message"]["stop_reason"] = stop
if content is not None:
d["message"]["content"] = content
if uuid:
d["uuid"] = uuid
return json.dumps(d)
# Subagent transcripts: stop_reason always null, output_tokens = message_start placeholder.
# e1: thinking (signature 1800 chars) + text 1000 chars + tool_use (input json 100 chars), placeholder 8.
# by hand: 0.3*(1800-800) + 0.3*1000 + (30 + 0.44*100) = 300 + 300 + 74 = 674
# e2: same tool_use again but stop_reason set (final): reported 50 is trusted.
# e3: no content, placeholder 9 kept (nothing to estimate).
TOOL = {"type": "tool_use", "id": "t1", "name": "Bash", "input": {"a": "y" * 91}} # json.dumps → 100 chars
SUB = "\n".join([
entry("e1", "q1", "claude-sonnet-5-5", "2026-10-04T10:00:00Z", out=8, uuid="u1",
content=[{"type": "thinking", "thinking": "", "signature": "z" * 1800}]),
entry("e1", "q1", "claude-sonnet-5-5", "2026-10-04T10:00:01Z", out=8, uuid="u2",
content=[{"type": "text", "text": "x" * 1000}]),
entry("e1", "q1", "claude-sonnet-5-5", "2026-10-04T10:00:01Z", out=8, uuid="u2",
content=[{"type": "text", "text": "x" * 1000}]), # duplicated line: counted once
entry("e1", "q1", "claude-sonnet-5-5", "2026-10-04T10:00:02Z", out=8, uuid="u3", content=[TOOL]),
entry("e2", "q2", "claude-sonnet-5-5", "2026-10-04T10:01:00Z", out=50, stop="tool_use", content=[TOOL]),
entry("e3", "q3", "claude-sonnet-5-5", "2026-10-04T10:02:00Z", out=9),
]) + "\n"
# main session, opus 5.5: m1 streamed as 2 entries (same usage), m2 as 2 entries (out 4000 then 10000).
# by hand: m1 = 1000*4 + 100000*5 + 2000*20 = 544000 µ$; m2 = 50000*8 + 1e6*0.2 + 10000*20 = 800000 µ$
MAIN = "\n".join([
json.dumps({"type": "user", "timestamp": "2026-10-04T09:00:00Z", "message": {"role": "user", "content": "hi"}}),
entry("m1", "r1", "claude-opus-5-5", "2026-10-04T09:00:01Z", inp=1000, cw5=100000, out=2000),
entry("m1", "r1", "claude-opus-5-5", "2026-10-04T09:00:02Z", inp=1000, cw5=100000, out=2000),
"not json",
json.dumps({"type": "attachment", "timestamp": "2026-10-04T09:00:03Z"}),
entry("m2", "r2", "claude-opus-5-5", "2026-10-04T09:01:00Z", cw1h=50000, cr=1000000, out=4000),
entry("m2", "r2", "claude-opus-5-5", "2026-10-04T09:01:05Z", cw1h=50000, cr=1000000, out=10000),
entry("m9", "r9", "<synthetic>", "2026-10-04T09:02:00Z", out=5),
]) + "\n"
# subagent, sonnet 5.5: s1 = 10*2 + 20000*2.5 + 30000*0.2 + 500*10 = 61020 µ$; s2 = 50000*0.2 + 1500*10 = 25000 µ$
AGENT = "\n".join([
entry("s1", "q1", "claude-sonnet-5-5", "2026-10-04T10:00:00Z", inp=10, cw5=20000, cr=30000, out=500),
entry("s2", "q2", "claude-sonnet-5-5", "2026-10-04T12:00:00Z", cr=50000, out=1500),
entry("s3", "q3", "claude-mystery-1", "2026-10-04T12:30:00Z", inp=7, out=3),
]) + "\n"
SID = "11111111-2222-3333-4444-555555555555"
class Parse(unittest.TestCase):
def test_dedupes_streamed_entries_and_takes_last_output(self):
got = usage.parse(MAIN)
self.assertEqual(list(got), ["claude-opus-5-5"])
u = got["claude-opus-5-5"]
self.assertEqual((u.turns, u.inp, u.cw5, u.cw1h, u.cr, u.out), (2, 1000, 100000, 50000, 1000000, 12000))
self.assertEqual(u.cw, 150000)
self.assertAlmostEqual(usage.cost("claude-opus-5-5", u), 1.344)
def test_models_kept_apart_unknown_price_none(self):
got = usage.parse(AGENT)
s = got["claude-sonnet-5-5"]
self.assertEqual((s.turns, s.cr, s.out), (2, 80000, 2000))
self.assertAlmostEqual(usage.cost("claude-sonnet-5-5", s), 0.08602)
self.assertIsNone(usage.cost("claude-mystery-1", got["claude-mystery-1"]))
def test_since_until_filter_on_utc_timestamp(self):
s = usage.parse(AGENT, since="2026-10-04T11:00", until="2026-10-04T12:10")["claude-sonnet-5-5"]
self.assertEqual((s.turns, s.cr), (1, 50000))
self.assertAlmostEqual(usage.cost("claude-sonnet-5-5", s), 0.025)
def test_dated_model_id_priced_by_prefix(self):
u = usage.Usage(turns=1, inp=1000000, out=1000000)
self.assertAlmostEqual(usage.cost("claude-haiku-4-5-20251001", u), 6.0)
self.assertAlmostEqual(usage.cost("claude-opus-5", u), 30.0)
def test_subagent_output_estimated_from_content(self):
u = usage.parse(SUB)["claude-sonnet-5-5"]
self.assertEqual((u.turns, u.out, u.est), (3, 674 + 50 + 9, 1))
def test_log_line_marks_estimate(self):
line = usage.log_line("T", "p", "t-x", "1h", "done", "a1", usage.parse(SUB))
self.assertIn(" out=733 est=1 usd=", line)
self.assertNotIn(" est=", usage.log_line("T", "p", "t-x", "1h", "done", "a1", usage.parse(AGENT)))
def test_log_line_lane_and_model(self):
by = usage.parse(SUB)
self.assertIn(" lane=fast model=sonnet effort=", usage.log_line("T", "p", "t-x", "1h", "done", "a1", by, lane="fast"))
self.assertIn(" lane=sonnet model=sonnet effort=", usage.log_line("T", "p", "t-x", "1h", "done", "a1", by))
def test_report_lane_model_key(self):
es = [{"lane": "fast", "model": "sonnet", "outcome": "done", "usd": 1.0, "turns": 10, "effort": "1h"},
{"lane": "fast", "model": "opus", "outcome": "done", "usd": 2.0, "turns": 20, "effort": "1h"},
{"lane": "opus", "outcome": "done", "usd": 3.0, "turns": 30, "effort": "1h"}]
self.assertEqual(usage.report(es, ("1h",)), [
("fast/opus", "all", 1, 1, 0, 2.0, 2.0, 2.0, 20, None), ("fast/opus", "1h", 1, 1, 0, 2.0, 2.0, 2.0, 20, None),
("fast/sonnet", "all", 1, 1, 0, 1.0, 1.0, 1.0, 10, None), ("fast/sonnet", "1h", 1, 1, 0, 1.0, 1.0, 1.0, 10, None),
("opus", "all", 1, 1, 0, 3.0, 3.0, 3.0, 30, None), ("opus", "1h", 1, 1, 0, 3.0, 3.0, 3.0, 30, None)])
def test_fmt(self):
self.assertEqual([usage.fmt(n) for n in (999, 1500, 8948413)], ["999", "1.5k", "8.95M"])
def tcall(mid, ctx, name, inp, tid):
return json.dumps({"type": "assistant", "timestamp": "2026-10-04T10:00:00Z", "message": {
"id": mid, "model": "claude-sonnet-5-5", "usage": {"input_tokens": 10, "cache_read_input_tokens": ctx - 10},
"content": [{"type": "tool_use", "id": tid, "name": name, "input": inp}]}})
def tres(tid, text):
return json.dumps({"type": "user", "message": {"role": "user", "content": [
{"type": "tool_result", "tool_use_id": tid, "content": text}]}})
# agent A: ctx 1000 Read(400 chars=100 tok), 2000 grep(800=200), 3000 Edit, 4000 git commit.
# exp 3000 / mut 3000 / book 4000 of 10000; first edit after 2 calls, pre 3000 = 30%, context +2000
EXA = "\n".join([
tcall("a1", 1000, "Read", {"file_path": "/w/proj/.worktrees/x/src/a.py"}, "t1"), tres("t1", "x" * 400),
tcall("a2", 2000, "Bash", {"command": "grep -n foo src/a.py 2>/dev/null"}, "t2"), tres("t2", "y" * 800),
tcall("a3", 3000, "Edit", {"file_path": "/w/proj/src/a.py"}, "t3"), tres("t3", "ok"),
tcall("a4", 4000, "Bash", {"command": "git commit -m x"}, "t4"), tres("t4", "done"),
]) + "\n"
# agent B: 4 exploring calls of 1000 each, reads src/a.py (100 tok) + sed -n of b.py (400 chars)
EXB = "\n".join([
tcall("b1", 1000, "Read", {"file_path": "/w/proj/src/a.py"}, "u1"), tres("u1", "x" * 400),
tcall("b2", 1000, "Bash", {"command": "sed -n 1,9p src/b.py"}, "u2"), tres("u2", "z" * 400),
tcall("b3", 1000, "Bash", {"command": "ls"}, "u3"), tres("u3", "q"),
tcall("b4", 1000, "Glob", {}, "u4"), tres("u4", "q"),
]) + "\n"
class Explore(unittest.TestCase):
def test_one_agent(self):
e = usage.explore(EXA, root="/w/proj")
self.assertEqual((e.calls, e.first_edit, e.pre, e.ctx_growth), (4, 2, 3000, 2000))
self.assertEqual(e.cost, {"exp": 3000, "mut": 3000, "book": 4000})
self.assertEqual(e.results, {"Read": 100, "grep": 200, "Edit": 0, "other": 1})
self.assertEqual(e.files, {"src/a.py": 300})
def test_call_kind_book_is_writes_only(self):
kind = lambda c: usage.call_kind({"name": "Bash", "input": {"command": c}})
for c in ("git worktree list", "git branch --list 'fast/*'", "git branch --show-current", "git status --short"):
self.assertEqual(kind(c), "exp", c)
for c in ("git worktree add .w/x -b x master", "git worktree remove .w/x", "git branch -d x",
"python3 /projects/public/workflow/wf.py finish t-x -m ok", "git switch -c x"):
self.assertEqual(kind(c), "book", c)
def test_since_drops_early_calls(self):
e = usage.explore(EXA.replace("10:00:00Z", "09:00:00Z", 2), since="2026-10-04T10")
self.assertEqual(e.calls, 2)
def test_report(self):
a, b = usage.explore(EXA, root="/w/proj"), usage.explore(EXB, root="/w/proj")
r = usage.explore_report([("A", a), ("B", b)], min_pre=4)
self.assertEqual(r["rows"], [("A", 4, 2, 0.3, 0.3), ("B", 4, 4, 1.0, 0.0)])
self.assertEqual((r["total"], r["cost"]["exp"]), (14000, 7000))
self.assertEqual((r["pre_n"], r["pre_share"], r["pre_calls"], r["pre_ctx"]), (1, 0.3, 2, 2000))
self.assertEqual(r["results"]["sed-cat"], 100)
self.assertEqual(r["files"], [("src/a.py", 2, 400)])
self.assertEqual(usage.explore_report([("A", a)], min_calls=5)["rows"], [])
class Cli(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.home = Path(self.tmp.name)
proj = self.home / "projects" / "-work-demo"
sub = proj / SID / "subagents"
sub.mkdir(parents=True)
(proj / f"{SID}.jsonl").write_text(MAIN)
(sub / "agent-abc123.jsonl").write_text(AGENT)
(sub / "agent-abc123.meta.json").write_text(json.dumps({"description": "wf-worker sonnet t-x"}))
def tearDown(self):
self.tmp.cleanup()
def wf(self, *args, sid="", cwd=None):
env = {**os.environ, "CLAUDE_CONFIG_DIR": str(self.home), "CLAUDE_CODE_SESSION_ID": sid}
r = subprocess.run([sys.executable, str(WF), "usage", *args], capture_output=True, text=True,
cwd=cwd or self.tmp.name, env=env, timeout=30)
return r.returncode, r.stdout, r.stderr
def test_explore_skips_main_and_short_agents(self):
code, out, err = self.wf("--session", SID[:8], "--explore")
self.assertEqual((code, err), (0, ""))
self.assertEqual(out.strip(), "no subagent with >= 4 calls")
def test_session_rows_and_total(self):
code, out, err = self.wf("--session", SID[:8])
self.assertEqual((code, err), (0, ""))
rows = [l.split() for l in out.strip().split("\n")[1:]]
self.assertEqual(rows[0], ["main", "opus-5-5", "2", "1.0k", "150.0k", "1.00M", "12.0k", "1.34"])
self.assertEqual(rows[1], ["abc123", "wf-worker", "sonnet", "t-x",
"sonnet-5-5", "2", "10", "20.0k", "80.0k", "2.0k", "0.09"])
self.assertEqual(rows[2][-7:], ["mystery-1", "1", "7", "0", "0", "3", "?"])
self.assertEqual(rows[3], ["total", "5", "1.0k", "170.0k", "1.08M", "14.0k", "1.43+"])
def test_session_from_env(self):
code, out, _ = self.wf(sid=SID)
self.assertEqual(code, 0)
self.assertIn("abc123", out)
def test_agent_only(self):
code, out, err = self.wf("--agent", "agent-abc123", "--since", "2026-10-04T11:00")
self.assertEqual((code, err), (0, ""))
lines = out.strip().split("\n")
self.assertNotIn("main", out)
self.assertEqual(lines[1].split()[-7:], ["sonnet-5-5", "1", "0", "0", "50.0k", "1.5k", "0.03"])
def test_log_appends_one_key_value_line(self):
root = self.home / "demo"
root.mkdir()
(root / "workflow.toml").write_text("format = 1\n")
code, out, err = self.wf("--agent", "abc123", "--log", "t-x", "done", "--effort", "1h", cwd=root)
self.assertEqual((code, err), (0, ""))
code, out, err = self.wf("--agent", "abc123", "--log", "t-x", "handback", cwd=root)
log = (root / "out" / "wf-cost.log").read_text().split("\n")
self.assertEqual(len(log), 3) # two lines + trailing newline
# lane = model with most turns (sonnet 2 vs mystery 1); usd = known models only
self.assertRegex(log[0], r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ project=demo task=t-x lane=sonnet model=sonnet effort=1h "
r"outcome=done turns=3 in=17 cw=20000 cr=80000 out=2003 usd=0\.0860 agent=abc123$")
self.assertIn(" effort=- outcome=handback ", log[1])
self.assertEqual(out, log[1] + "\n")
def test_estimated_out_marked_with_tilde(self):
other = self.home / "projects" / "-work-demo" / "other-session" / "subagents"
other.mkdir(parents=True)
(other / "agent-est1.jsonl").write_text(SUB)
code, out, err = self.wf("--agent", "est1")
self.assertEqual((code, err), (0, ""))
self.assertEqual(out.strip().split("\n")[1].split()[-3:], ["0", "~733", "0.01"])
def test_log_needs_agent(self):
code, out, err = self.wf("--log", "t-x", "done")
self.assertEqual((code, err), (2, "wf: --log needs --agent\n"))
def test_log_effort_must_be_an_estimate(self):
code, out, err = self.wf("--agent", "abc123", "--log", "t-x", "done", "--effort", "medium")
self.assertEqual(code, 2)
self.assertIn("invalid choice: 'medium'", err)
def test_missing_agent_one_line_error(self):
code, out, err = self.wf("--agent", "nope")
self.assertEqual((code, out), (1, ""))
self.assertEqual(err, "wf: no transcript for agent nope\n")
LOG_A = """\
2026-10-01T10:00:00Z project=a task=t-1 lane=sonnet effort=1h outcome=done turns=10 usd=0.50
2026-10-01T11:00:00Z project=a task=t-2 lane=sonnet effort=1h outcome=handback turns=30 usd=1.50
garbage line
"""
LOG_B = """\
2026-10-02T10:00:00Z project=b task=t-3 lane=sonnet effort=<1h outcome=done turns=20 usd=0.40
2026-10-02T11:00:00Z project=b task=t-4 lane=opus effort=5h outcome=done turns=80 usd=3.00
2026-10-03T11:00:00Z project=b task=t-5 lane=opus effort=1h outcome=done turns=40 usd=2.00
"""
class Report(unittest.TestCase):
def test_rows_by_lane_then_effort(self):
entries = usage.parse_log(LOG_A + LOG_B)
self.assertEqual(len(entries), 5)
rows = usage.report(entries, ("<1h", "1h", "5h"))
# lane, effort, n, done, other, median $, total $, $/done, median turns — all by hand
self.assertEqual(rows, [
("opus", "all", 2, 2, 0, 2.5, 5.0, 2.5, 60, None),
("opus", "1h", 1, 1, 0, 2.0, 2.0, 2.0, 40, None),
("opus", "5h", 1, 1, 0, 3.0, 3.0, 3.0, 80, None),
("sonnet", "all", 3, 2, 1, 0.5, 2.4, 1.2, 20, None),
("sonnet", "<1h", 1, 1, 0, 0.4, 0.4, 0.4, 20, None),
("sonnet", "1h", 2, 1, 1, 1.0, 2.0, 2.0, 20, None),
])
def test_duration(self):
line = usage.log_line("T", "p", "t-x", "1h", "done", "a1", usage.parse(SUB), dur=125)
self.assertIn(" dur=125 agent=a1", line)
self.assertNotIn("dur=", usage.log_line("T", "p", "t-x", "1h", "done", "a1", usage.parse(SUB)))
log = ("2026-10-04T10:00:00Z project=a task=t-6 lane=fast effort=<1h outcome=done turns=1 usd=1 dur=100\n"
"2026-10-04T10:00:00Z project=a task=t-7 lane=fast effort=<1h outcome=done turns=1 usd=1 dur=300\n"
"2026-10-04T10:00:00Z project=a task=t-8 lane=fast effort=<1h outcome=done turns=1 usd=1\n")
self.assertEqual(usage.report(usage.parse_log(log), ("<1h",))[0][9], 200)
def test_done_gate_red_counts_as_done(self):
log = ("2026-10-04T10:00:00Z project=a task=t-6 lane=fast effort=<1h outcome=done+gate-red turns=12 usd=1.00\n"
"2026-10-04T11:00:00Z project=a task=t-7 lane=fast effort=<1h outcome=donex turns=8 usd=0.50\n")
rows = usage.report(usage.parse_log(log), ("<1h",))
self.assertEqual(rows[0], ("fast", "all", 2, 1, 1, 0.75, 1.5, 1.5, 10, None))
def test_since_and_no_done(self):
entries = usage.parse_log(LOG_A + LOG_B, since="2026-10-01T10:30")
rows = usage.report(entries, ("1h",))
self.assertIn(("sonnet", "all", 2, 1, 1, 0.95, 1.9, 1.9, 25, None), rows)
rows = usage.report(usage.parse_log(LOG_A, since="2026-10-01T10:30"), ("1h",))
self.assertEqual(rows[0], ("sonnet", "all", 1, 0, 1, 1.5, 1.5, None, 30, None))
def test_cli_reads_every_project(self):
with tempfile.TemporaryDirectory() as tmp:
for name, text in (("a", LOG_A), ("b", LOG_B)):
(Path(tmp) / name / "out").mkdir(parents=True)
(Path(tmp) / name / "workflow.toml").write_text("format = 1\n")
(Path(tmp) / name / "out" / "wf-cost.log").write_text(text)
r = subprocess.run([sys.executable, str(WF), "usage", "--report"], capture_output=True, text=True,
cwd=tmp, env={**os.environ, "WF_ROOT": tmp}, timeout=30)
self.assertEqual((r.returncode, r.stderr), (0, ""))
lines = [l.split() for l in r.stdout.strip().split("\n")]
self.assertEqual(lines[0], ["lane", "effort", "n", "done", "other", "med$", "total$", "$/done", "med_turns", "med_dur"])
self.assertEqual(lines[1], ["opus", "all", "2", "2", "0", "2.50", "5.00", "2.50", "60", "-"])
self.assertEqual(lines[6], ["sonnet", "1h", "2", "1", "1", "1.00", "2.00", "2.00", "20", "-"])
if __name__ == "__main__":
unittest.main()
|