cctrack 0.3.0__tar.gz → 0.4.1__tar.gz
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.
- {cctrack-0.3.0 → cctrack-0.4.1}/PKG-INFO +5 -2
- {cctrack-0.3.0 → cctrack-0.4.1}/README.md +4 -1
- {cctrack-0.3.0 → cctrack-0.4.1}/pyproject.toml +1 -1
- {cctrack-0.3.0 → cctrack-0.4.1}/src/cctrack/__init__.py +389 -122
- {cctrack-0.3.0 → cctrack-0.4.1}/tests/test_cctrack.py +56 -0
- {cctrack-0.3.0 → cctrack-0.4.1}/tests/test_hook_reporting.py +230 -0
- {cctrack-0.3.0 → cctrack-0.4.1}/.github/workflows/ci.yml +0 -0
- {cctrack-0.3.0 → cctrack-0.4.1}/.github/workflows/publish.yml +0 -0
- {cctrack-0.3.0 → cctrack-0.4.1}/.gitignore +0 -0
- {cctrack-0.3.0 → cctrack-0.4.1}/CLAUDE.md +0 -0
- {cctrack-0.3.0 → cctrack-0.4.1}/LICENSE +0 -0
- {cctrack-0.3.0 → cctrack-0.4.1}/src/cctrack/__main__.py +0 -0
- {cctrack-0.3.0 → cctrack-0.4.1}/tests/test_hook.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cctrack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Claude Code cost tracker — scans JSONL logs and reports token usage + estimated cost
|
|
5
5
|
Project-URL: Homepage, https://github.com/rappdw/cctrack
|
|
6
6
|
Project-URL: Repository, https://github.com/rappdw/cctrack
|
|
@@ -86,11 +86,14 @@ Daily breakdown:
|
|
|
86
86
|
|
|
87
87
|
## Remote aggregation
|
|
88
88
|
|
|
89
|
-
With `--remote`, cctrack SSHs to each host
|
|
89
|
+
With `--remote`, cctrack SSHs to each host and streams back both the JSONL logs and the host's statusline hook data (`~/.claude/cctrack/`) for local parsing, so authoritative hook costs from every machine are summed per day. Requires SSH key auth.
|
|
90
90
|
|
|
91
91
|
```bash
|
|
92
92
|
# Aggregate this machine + DGX server + laptop
|
|
93
93
|
cctrack --remote dgx macbook-air
|
|
94
|
+
|
|
95
|
+
# For accurate aggregate costs, install the hook on each host too
|
|
96
|
+
cctrack --install-hook --remote dgx macbook-air
|
|
94
97
|
```
|
|
95
98
|
|
|
96
99
|
## Rate card
|
|
@@ -61,11 +61,14 @@ Daily breakdown:
|
|
|
61
61
|
|
|
62
62
|
## Remote aggregation
|
|
63
63
|
|
|
64
|
-
With `--remote`, cctrack SSHs to each host
|
|
64
|
+
With `--remote`, cctrack SSHs to each host and streams back both the JSONL logs and the host's statusline hook data (`~/.claude/cctrack/`) for local parsing, so authoritative hook costs from every machine are summed per day. Requires SSH key auth.
|
|
65
65
|
|
|
66
66
|
```bash
|
|
67
67
|
# Aggregate this machine + DGX server + laptop
|
|
68
68
|
cctrack --remote dgx macbook-air
|
|
69
|
+
|
|
70
|
+
# For accurate aggregate costs, install the hook on each host too
|
|
71
|
+
cctrack --install-hook --remote dgx macbook-air
|
|
69
72
|
```
|
|
70
73
|
|
|
71
74
|
## Rate card
|
|
@@ -26,16 +26,18 @@ from pathlib import Path
|
|
|
26
26
|
|
|
27
27
|
# Longer/more-specific prefixes MUST come first — "claude-opus-4-6" would
|
|
28
28
|
# otherwise match "claude-opus-4" and get the wrong rate.
|
|
29
|
+
# cache_write is the 5-minute TTL rate (1.25× input); cache_write_1h is the
|
|
30
|
+
# 1-hour TTL rate (2× input). Claude Code hits the 1h tier heavily in practice.
|
|
29
31
|
RATES = [
|
|
30
|
-
{"family": "claude-opus-4-7", "input": 5.0, "output": 25.0, "cache_read": 0.50, "cache_write": 6.25},
|
|
31
|
-
{"family": "claude-opus-4-6", "input": 5.0, "output": 25.0, "cache_read": 0.50, "cache_write": 6.25},
|
|
32
|
-
{"family": "claude-opus-4-5", "input": 5.0, "output": 25.0, "cache_read": 0.50, "cache_write": 6.25},
|
|
33
|
-
{"family": "claude-opus-4", "input": 15.0, "output": 75.0, "cache_read": 1.50, "cache_write": 18.75},
|
|
34
|
-
{"family": "claude-sonnet-4", "input": 3.0, "output": 15.0, "cache_read": 0.30, "cache_write": 3.75},
|
|
35
|
-
{"family": "claude-sonnet-3", "input": 3.0, "output": 15.0, "cache_read": 0.30, "cache_write": 3.75},
|
|
36
|
-
{"family": "claude-haiku-4", "input": 1.0, "output": 5.0, "cache_read": 0.10, "cache_write": 1.25},
|
|
37
|
-
{"family": "claude-haiku-3-5", "input": 0.80, "output": 4.0, "cache_read": 0.08, "cache_write": 1.00},
|
|
38
|
-
{"family": "claude-haiku-3", "input": 0.25, "output": 1.25, "cache_read": 0.03, "cache_write": 0.30},
|
|
32
|
+
{"family": "claude-opus-4-7", "input": 5.0, "output": 25.0, "cache_read": 0.50, "cache_write": 6.25, "cache_write_1h": 10.0},
|
|
33
|
+
{"family": "claude-opus-4-6", "input": 5.0, "output": 25.0, "cache_read": 0.50, "cache_write": 6.25, "cache_write_1h": 10.0},
|
|
34
|
+
{"family": "claude-opus-4-5", "input": 5.0, "output": 25.0, "cache_read": 0.50, "cache_write": 6.25, "cache_write_1h": 10.0},
|
|
35
|
+
{"family": "claude-opus-4", "input": 15.0, "output": 75.0, "cache_read": 1.50, "cache_write": 18.75, "cache_write_1h": 30.0},
|
|
36
|
+
{"family": "claude-sonnet-4", "input": 3.0, "output": 15.0, "cache_read": 0.30, "cache_write": 3.75, "cache_write_1h": 6.0},
|
|
37
|
+
{"family": "claude-sonnet-3", "input": 3.0, "output": 15.0, "cache_read": 0.30, "cache_write": 3.75, "cache_write_1h": 6.0},
|
|
38
|
+
{"family": "claude-haiku-4", "input": 1.0, "output": 5.0, "cache_read": 0.10, "cache_write": 1.25, "cache_write_1h": 2.0},
|
|
39
|
+
{"family": "claude-haiku-3-5", "input": 0.80, "output": 4.0, "cache_read": 0.08, "cache_write": 1.00, "cache_write_1h": 1.60},
|
|
40
|
+
{"family": "claude-haiku-3", "input": 0.25, "output": 1.25, "cache_read": 0.03, "cache_write": 0.30, "cache_write_1h": 0.50},
|
|
39
41
|
]
|
|
40
42
|
|
|
41
43
|
SONNET_RATES = RATES[4] # fallback
|
|
@@ -49,13 +51,15 @@ def get_rates(model: str) -> dict:
|
|
|
49
51
|
return SONNET_RATES
|
|
50
52
|
|
|
51
53
|
|
|
52
|
-
def calculate_cost(model: str, input_t: int, output_t: int, cache_read: int,
|
|
54
|
+
def calculate_cost(model: str, input_t: int, output_t: int, cache_read: int,
|
|
55
|
+
cache_write: int, cache_write_1h: int = 0) -> float:
|
|
53
56
|
r = get_rates(model)
|
|
54
57
|
return (
|
|
55
58
|
input_t / 1_000_000 * r["input"]
|
|
56
59
|
+ output_t / 1_000_000 * r["output"]
|
|
57
60
|
+ cache_read / 1_000_000 * r["cache_read"]
|
|
58
61
|
+ cache_write / 1_000_000 * r["cache_write"]
|
|
62
|
+
+ cache_write_1h / 1_000_000 * r["cache_write_1h"]
|
|
59
63
|
)
|
|
60
64
|
|
|
61
65
|
|
|
@@ -122,6 +126,84 @@ def parse_remote_events(host: str) -> list[dict]:
|
|
|
122
126
|
return all_events
|
|
123
127
|
|
|
124
128
|
|
|
129
|
+
HOOK_FILE_SEP = "___CCTRACK_HOOK_FILE___"
|
|
130
|
+
MONTHLY_FILE_SEP = "___CCTRACK_MONTHLY_FILE___"
|
|
131
|
+
|
|
132
|
+
# Hook data locations on remote hosts: the host's own cctrack dir plus any
|
|
133
|
+
# Sandy sandbox equivalents (mirrors discover_hook_data_dirs).
|
|
134
|
+
REMOTE_HOOK_DIRS = "~/.claude/cctrack ~/.sandy/sandboxes"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def fetch_remote_hook_data(host: str) -> str:
|
|
138
|
+
"""SSH to host and cat all statusline JSONL + monthly summary files.
|
|
139
|
+
Returns raw output with file-type separators."""
|
|
140
|
+
remote_cmd = (
|
|
141
|
+
f"find {REMOTE_HOOK_DIRS} -name 'statusline*.jsonl' "
|
|
142
|
+
f"-exec echo '{HOOK_FILE_SEP}' \\; -exec cat {{}} \\; 2>/dev/null; "
|
|
143
|
+
f"find {REMOTE_HOOK_DIRS} -name 'monthly-*.json' "
|
|
144
|
+
f"-exec echo '{MONTHLY_FILE_SEP}' \\; -exec cat {{}} \\; 2>/dev/null; "
|
|
145
|
+
f"true"
|
|
146
|
+
)
|
|
147
|
+
cmd = ["ssh", "-o", "ConnectTimeout=10", "-o", "BatchMode=yes", host, remote_cmd]
|
|
148
|
+
try:
|
|
149
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
150
|
+
if result.returncode != 0 and not result.stdout:
|
|
151
|
+
print(f" Warning: SSH to {host} failed: {result.stderr.strip()}", file=sys.stderr)
|
|
152
|
+
return ""
|
|
153
|
+
return result.stdout
|
|
154
|
+
except subprocess.TimeoutExpired:
|
|
155
|
+
print(f" Warning: SSH to {host} timed out", file=sys.stderr)
|
|
156
|
+
return ""
|
|
157
|
+
except FileNotFoundError:
|
|
158
|
+
print(f" Warning: ssh command not found", file=sys.stderr)
|
|
159
|
+
return ""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def parse_remote_hook_data(host: str) -> tuple[list[dict], dict]:
|
|
163
|
+
"""Fetch and parse hook data from a remote host.
|
|
164
|
+
Returns (hook_events, monthly_summaries) in the same shapes as
|
|
165
|
+
parse_hook_events and load_monthly_hook_summaries."""
|
|
166
|
+
raw = fetch_remote_hook_data(host)
|
|
167
|
+
if not raw:
|
|
168
|
+
return [], {}
|
|
169
|
+
|
|
170
|
+
# Statusline chunks come first, monthly chunks after (find order above).
|
|
171
|
+
head, _, monthly_raw = raw.partition(MONTHLY_FILE_SEP)
|
|
172
|
+
|
|
173
|
+
lines: list[str] = []
|
|
174
|
+
for chunk in head.split(HOOK_FILE_SEP):
|
|
175
|
+
lines.extend(chunk.splitlines())
|
|
176
|
+
events = _hook_events_from_lines(lines)
|
|
177
|
+
|
|
178
|
+
monthly: dict[str, dict] = {}
|
|
179
|
+
for chunk in monthly_raw.split(MONTHLY_FILE_SEP):
|
|
180
|
+
chunk = chunk.strip()
|
|
181
|
+
if not chunk:
|
|
182
|
+
continue
|
|
183
|
+
try:
|
|
184
|
+
data = json.loads(chunk)
|
|
185
|
+
except (json.JSONDecodeError, ValueError):
|
|
186
|
+
continue
|
|
187
|
+
for date_str, day_data in data.get("days", {}).items():
|
|
188
|
+
entry = {
|
|
189
|
+
"cost": float(day_data.get("cost", 0.0)),
|
|
190
|
+
"sessions": int(day_data.get("sessions", 0)),
|
|
191
|
+
}
|
|
192
|
+
merge_monthly_summaries(monthly, {date_str: entry})
|
|
193
|
+
return events, monthly
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def merge_monthly_summaries(target: dict, source: dict) -> None:
|
|
197
|
+
"""Merge per-date {cost, sessions} summaries from source into target,
|
|
198
|
+
summing where dates overlap (e.g. same day on multiple hosts)."""
|
|
199
|
+
for date_str, data in source.items():
|
|
200
|
+
if date_str in target:
|
|
201
|
+
target[date_str]["cost"] += data["cost"]
|
|
202
|
+
target[date_str]["sessions"] += data["sessions"]
|
|
203
|
+
else:
|
|
204
|
+
target[date_str] = dict(data)
|
|
205
|
+
|
|
206
|
+
|
|
125
207
|
# ── Hook infrastructure ───────────────────────────────────────────────
|
|
126
208
|
|
|
127
209
|
HOOK_SCRIPT = """\
|
|
@@ -432,9 +514,19 @@ def parse_lines(lines: list[str]) -> list[dict]:
|
|
|
432
514
|
input_t = usage.get("input_tokens", 0)
|
|
433
515
|
output_t = usage.get("output_tokens", 0)
|
|
434
516
|
cache_read = usage.get("cache_read_input_tokens", 0)
|
|
435
|
-
cache_write = usage.get("cache_creation_input_tokens", 0)
|
|
436
517
|
|
|
437
|
-
|
|
518
|
+
# Prefer nested cache_creation.{ephemeral_5m,ephemeral_1h}_input_tokens
|
|
519
|
+
# — 1h cache writes cost 2× input vs 1.25× for 5m, and Claude Code
|
|
520
|
+
# commonly uses the 1h tier. Fall back to flat cache_creation_input_tokens
|
|
521
|
+
# as all-5m for older logs that didn't emit the split.
|
|
522
|
+
cc = usage.get("cache_creation") or {}
|
|
523
|
+
cache_write = cc.get("ephemeral_5m_input_tokens", 0)
|
|
524
|
+
cache_write_1h = cc.get("ephemeral_1h_input_tokens", 0)
|
|
525
|
+
if not cc:
|
|
526
|
+
cache_write = usage.get("cache_creation_input_tokens", 0)
|
|
527
|
+
|
|
528
|
+
if (input_t == 0 and output_t == 0 and cache_read == 0
|
|
529
|
+
and cache_write == 0 and cache_write_1h == 0):
|
|
438
530
|
continue
|
|
439
531
|
|
|
440
532
|
entry = {
|
|
@@ -444,6 +536,7 @@ def parse_lines(lines: list[str]) -> list[dict]:
|
|
|
444
536
|
"output": output_t,
|
|
445
537
|
"cache_read": cache_read,
|
|
446
538
|
"cache_write": cache_write,
|
|
539
|
+
"cache_write_1h": cache_write_1h,
|
|
447
540
|
}
|
|
448
541
|
|
|
449
542
|
req_id = event.get("requestId", "")
|
|
@@ -470,61 +563,47 @@ def parse_events(files: list[str]) -> list[dict]:
|
|
|
470
563
|
|
|
471
564
|
# ── Hook data parsing ──────────────────────────────────────────────────
|
|
472
565
|
|
|
473
|
-
def
|
|
474
|
-
"""Parse statusline
|
|
475
|
-
|
|
476
|
-
Returns list of {date_str, cost_usd, input_tokens,
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
if not cctrack_dir.exists():
|
|
480
|
-
return []
|
|
566
|
+
def _hook_events_from_lines(lines) -> list[dict]:
|
|
567
|
+
"""Parse statusline JSONL lines. For each (session_id, date) pair, keeps the
|
|
568
|
+
entry with the latest _ts — the session's cumulative values as of end-of-day
|
|
569
|
+
for that date. Returns list of {date_str, session_id, cost_usd, input_tokens,
|
|
570
|
+
output_tokens, cache_read, cache_write, model}."""
|
|
571
|
+
by_session_day: dict[tuple[str, str], dict] = {}
|
|
481
572
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
if legacy.exists():
|
|
487
|
-
files.append(legacy)
|
|
488
|
-
|
|
489
|
-
for filepath in files:
|
|
573
|
+
for line in lines:
|
|
574
|
+
line = line.strip()
|
|
575
|
+
if not line:
|
|
576
|
+
continue
|
|
490
577
|
try:
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
if not line:
|
|
495
|
-
continue
|
|
496
|
-
try:
|
|
497
|
-
entry = json.loads(line)
|
|
498
|
-
except (json.JSONDecodeError, ValueError):
|
|
499
|
-
continue
|
|
500
|
-
|
|
501
|
-
session_id = entry.get("session_id", "")
|
|
502
|
-
if not session_id:
|
|
503
|
-
continue
|
|
504
|
-
|
|
505
|
-
ts = entry.get("_ts", "")
|
|
506
|
-
if not ts:
|
|
507
|
-
continue
|
|
508
|
-
|
|
509
|
-
if session_id in by_session:
|
|
510
|
-
existing_ts = by_session[session_id].get("_ts", "")
|
|
511
|
-
if ts <= existing_ts:
|
|
512
|
-
continue
|
|
578
|
+
entry = json.loads(line)
|
|
579
|
+
except (json.JSONDecodeError, ValueError):
|
|
580
|
+
continue
|
|
513
581
|
|
|
514
|
-
|
|
515
|
-
|
|
582
|
+
session_id = entry.get("session_id", "")
|
|
583
|
+
if not session_id:
|
|
516
584
|
continue
|
|
517
585
|
|
|
518
|
-
results = []
|
|
519
|
-
for session_id, entry in by_session.items():
|
|
520
586
|
ts = entry.get("_ts", "")
|
|
587
|
+
if not ts:
|
|
588
|
+
continue
|
|
589
|
+
|
|
521
590
|
try:
|
|
522
591
|
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
523
592
|
except (ValueError, AttributeError):
|
|
524
593
|
continue
|
|
594
|
+
date_str = dt.strftime("%Y-%m-%d")
|
|
525
595
|
|
|
596
|
+
key = (session_id, date_str)
|
|
597
|
+
if key in by_session_day:
|
|
598
|
+
if ts <= by_session_day[key].get("_ts", ""):
|
|
599
|
+
continue
|
|
600
|
+
|
|
601
|
+
by_session_day[key] = entry
|
|
602
|
+
|
|
603
|
+
results = []
|
|
604
|
+
for (session_id, date_str), entry in by_session_day.items():
|
|
526
605
|
results.append({
|
|
527
|
-
"date_str":
|
|
606
|
+
"date_str": date_str,
|
|
528
607
|
"cost_usd": float(entry.get("cost_usd", 0.0)),
|
|
529
608
|
"input_tokens": int(entry.get("input_tokens", 0)),
|
|
530
609
|
"output_tokens": int(entry.get("output_tokens", 0)),
|
|
@@ -537,6 +616,75 @@ def parse_hook_events(cctrack_dir: Path | None = None) -> list[dict]:
|
|
|
537
616
|
return results
|
|
538
617
|
|
|
539
618
|
|
|
619
|
+
def parse_hook_events(cctrack_dir: Path | None = None) -> list[dict]:
|
|
620
|
+
"""Parse statusline-*.jsonl files from a local hook data directory.
|
|
621
|
+
Also reads legacy statusline.jsonl for migration. See _hook_events_from_lines
|
|
622
|
+
for the per-(session, date) dedup semantics."""
|
|
623
|
+
if cctrack_dir is None:
|
|
624
|
+
cctrack_dir = CCTRACK_DIR
|
|
625
|
+
if not cctrack_dir.exists():
|
|
626
|
+
return []
|
|
627
|
+
|
|
628
|
+
files = sorted(cctrack_dir.glob("statusline-*.jsonl"))
|
|
629
|
+
legacy = cctrack_dir / "statusline.jsonl"
|
|
630
|
+
if legacy.exists():
|
|
631
|
+
files.append(legacy)
|
|
632
|
+
|
|
633
|
+
lines: list[str] = []
|
|
634
|
+
for filepath in files:
|
|
635
|
+
try:
|
|
636
|
+
with open(filepath, "r", errors="replace") as f:
|
|
637
|
+
lines.extend(f.readlines())
|
|
638
|
+
except (OSError, IOError):
|
|
639
|
+
continue
|
|
640
|
+
|
|
641
|
+
return _hook_events_from_lines(lines)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def compute_hook_deltas(events: list[dict]) -> list[dict]:
|
|
645
|
+
"""Convert per-(session, date) cumulative records into per-(session, date)
|
|
646
|
+
daily deltas. For each session, sorts its records by date and computes
|
|
647
|
+
day-over-day deltas for cost_usd, input_tokens, output_tokens. The first
|
|
648
|
+
visible day for a session uses 0 as the prior cumulative. Cache fields are
|
|
649
|
+
snapshot (not cumulative), so they're passed through unchanged. Records
|
|
650
|
+
with zero cost delta and zero token delta are dropped."""
|
|
651
|
+
by_session: dict[str, list[dict]] = defaultdict(list)
|
|
652
|
+
for e in events:
|
|
653
|
+
by_session[e["session_id"]].append(e)
|
|
654
|
+
|
|
655
|
+
result = []
|
|
656
|
+
for sid, session_events in by_session.items():
|
|
657
|
+
session_events.sort(key=lambda e: e["date_str"])
|
|
658
|
+
prev_cost = 0.0
|
|
659
|
+
prev_in = 0
|
|
660
|
+
prev_out = 0
|
|
661
|
+
for e in session_events:
|
|
662
|
+
cost = float(e.get("cost_usd", 0.0))
|
|
663
|
+
in_t = int(e.get("input_tokens", 0))
|
|
664
|
+
out_t = int(e.get("output_tokens", 0))
|
|
665
|
+
|
|
666
|
+
d_cost = max(0.0, cost - prev_cost)
|
|
667
|
+
d_in = max(0, in_t - prev_in)
|
|
668
|
+
d_out = max(0, out_t - prev_out)
|
|
669
|
+
|
|
670
|
+
prev_cost, prev_in, prev_out = cost, in_t, out_t
|
|
671
|
+
|
|
672
|
+
if d_cost == 0.0 and d_in == 0 and d_out == 0:
|
|
673
|
+
continue
|
|
674
|
+
|
|
675
|
+
result.append({
|
|
676
|
+
"date_str": e["date_str"],
|
|
677
|
+
"session_id": sid,
|
|
678
|
+
"cost_usd": d_cost,
|
|
679
|
+
"input_tokens": d_in,
|
|
680
|
+
"output_tokens": d_out,
|
|
681
|
+
"cache_read": int(e.get("cache_read", 0)),
|
|
682
|
+
"cache_write": int(e.get("cache_write", 0)),
|
|
683
|
+
"model": e.get("model", ""),
|
|
684
|
+
})
|
|
685
|
+
return result
|
|
686
|
+
|
|
687
|
+
|
|
540
688
|
def load_monthly_hook_summaries(cctrack_dir: Path | None = None) -> dict:
|
|
541
689
|
"""Load pre-aggregated monthly-YYYY-MM.json summary files.
|
|
542
690
|
Returns {date_str: {cost: float, sessions: int}}."""
|
|
@@ -561,7 +709,10 @@ def load_monthly_hook_summaries(cctrack_dir: Path | None = None) -> dict:
|
|
|
561
709
|
|
|
562
710
|
def rollup_old_hook_files(cctrack_dir: Path | None = None,
|
|
563
711
|
retention_days: int = HOOK_RETENTION_DAYS) -> None:
|
|
564
|
-
"""Roll up daily JSONL files older than retention_days into monthly summaries,
|
|
712
|
+
"""Roll up daily JSONL files older than retention_days into monthly summaries,
|
|
713
|
+
then delete them. Computes per-session daily deltas within the rollup pass so
|
|
714
|
+
a session spanning multiple days contributes correctly to each day (rather
|
|
715
|
+
than double-counting cumulative values across days)."""
|
|
565
716
|
if cctrack_dir is None:
|
|
566
717
|
cctrack_dir = CCTRACK_DIR
|
|
567
718
|
if not cctrack_dir.exists():
|
|
@@ -569,12 +720,21 @@ def rollup_old_hook_files(cctrack_dir: Path | None = None,
|
|
|
569
720
|
|
|
570
721
|
cutoff = (datetime.now(timezone.utc) - timedelta(days=retention_days)).strftime("%Y-%m-%d")
|
|
571
722
|
|
|
723
|
+
old_paths: list[tuple[str, Path]] = []
|
|
572
724
|
for path in sorted(cctrack_dir.glob("statusline-*.jsonl")):
|
|
573
725
|
date_str = path.stem.replace("statusline-", "")
|
|
574
726
|
if not date_str or date_str >= cutoff:
|
|
575
727
|
continue
|
|
728
|
+
old_paths.append((date_str, path))
|
|
729
|
+
|
|
730
|
+
if not old_paths:
|
|
731
|
+
return
|
|
576
732
|
|
|
577
|
-
|
|
733
|
+
# Parse all old files into per-(session, date) latest cumulative snapshots.
|
|
734
|
+
by_session_day: dict[tuple[str, str], dict] = {}
|
|
735
|
+
empty_paths: list[Path] = []
|
|
736
|
+
for date_str, path in old_paths:
|
|
737
|
+
had_any_row = False
|
|
578
738
|
try:
|
|
579
739
|
with open(path, "r", errors="replace") as f:
|
|
580
740
|
for line in f:
|
|
@@ -589,35 +749,68 @@ def rollup_old_hook_files(cctrack_dir: Path | None = None,
|
|
|
589
749
|
ts = entry.get("_ts", "")
|
|
590
750
|
if not sid or not ts:
|
|
591
751
|
continue
|
|
592
|
-
|
|
752
|
+
had_any_row = True
|
|
753
|
+
key = (sid, date_str)
|
|
754
|
+
if key in by_session_day and ts <= by_session_day[key].get("_ts", ""):
|
|
593
755
|
continue
|
|
594
|
-
|
|
756
|
+
by_session_day[key] = entry
|
|
595
757
|
except (OSError, IOError):
|
|
596
758
|
continue
|
|
597
759
|
|
|
598
|
-
if not
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
760
|
+
if not had_any_row:
|
|
761
|
+
empty_paths.append(path)
|
|
762
|
+
|
|
763
|
+
# Compute per-session per-day deltas by walking each session chronologically.
|
|
764
|
+
by_session: dict[str, list[tuple[str, dict]]] = defaultdict(list)
|
|
765
|
+
for (sid, date_str), entry in by_session_day.items():
|
|
766
|
+
by_session[sid].append((date_str, entry))
|
|
767
|
+
|
|
768
|
+
day_costs: dict[str, float] = defaultdict(float)
|
|
769
|
+
day_session_ids: dict[str, set] = defaultdict(set)
|
|
770
|
+
|
|
771
|
+
for sid, records in by_session.items():
|
|
772
|
+
records.sort(key=lambda r: r[0])
|
|
773
|
+
prev_cost = 0.0
|
|
774
|
+
for date_str, entry in records:
|
|
775
|
+
cost = float(entry.get("cost_usd", 0.0))
|
|
776
|
+
delta = max(0.0, cost - prev_cost)
|
|
777
|
+
prev_cost = cost
|
|
778
|
+
day_costs[date_str] += delta
|
|
779
|
+
day_session_ids[date_str].add(sid)
|
|
780
|
+
|
|
781
|
+
# Write per-day deltas into the appropriate monthly file and delete dailies.
|
|
782
|
+
by_month: dict[str, dict] = {}
|
|
783
|
+
for date_str in sorted(day_costs.keys()):
|
|
608
784
|
month_str = date_str[:7]
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
785
|
+
if month_str not in by_month:
|
|
786
|
+
monthly_path = cctrack_dir / f"monthly-{month_str}.json"
|
|
787
|
+
if monthly_path.exists():
|
|
788
|
+
try:
|
|
789
|
+
by_month[month_str] = json.loads(monthly_path.read_text())
|
|
790
|
+
except (json.JSONDecodeError, OSError):
|
|
791
|
+
by_month[month_str] = {"month": month_str, "days": {}}
|
|
792
|
+
else:
|
|
793
|
+
by_month[month_str] = {"month": month_str, "days": {}}
|
|
794
|
+
by_month[month_str].setdefault("days", {})
|
|
795
|
+
by_month[month_str]["days"][date_str] = {
|
|
796
|
+
"cost": day_costs[date_str],
|
|
797
|
+
"sessions": len(day_session_ids[date_str]),
|
|
798
|
+
}
|
|
616
799
|
|
|
617
|
-
|
|
800
|
+
for month_str, data in by_month.items():
|
|
801
|
+
monthly_path = cctrack_dir / f"monthly-{month_str}.json"
|
|
802
|
+
try:
|
|
803
|
+
monthly_path.write_text(json.dumps(data, indent=2) + "\n")
|
|
804
|
+
except OSError:
|
|
805
|
+
pass
|
|
618
806
|
|
|
807
|
+
for _, path in old_paths:
|
|
808
|
+
try:
|
|
809
|
+
path.unlink()
|
|
810
|
+
except OSError:
|
|
811
|
+
pass
|
|
812
|
+
for path in empty_paths:
|
|
619
813
|
try:
|
|
620
|
-
monthly_path.write_text(json.dumps(monthly_data, indent=2) + "\n")
|
|
621
814
|
path.unlink()
|
|
622
815
|
except OSError:
|
|
623
816
|
pass
|
|
@@ -625,21 +818,38 @@ def rollup_old_hook_files(cctrack_dir: Path | None = None,
|
|
|
625
818
|
|
|
626
819
|
def aggregate_hook_data(events: list[dict], monthly_summaries: dict | None = None) -> dict:
|
|
627
820
|
"""Aggregate hook events by date, merging with pre-aggregated monthly data.
|
|
628
|
-
Returns {date_str: {cost
|
|
821
|
+
Returns {date_str: {cost, sessions, input_tokens, output_tokens, cache_read, cache_write, models}}.
|
|
822
|
+
Note: cache_read/cache_write come from current_usage (snapshot of the last message's
|
|
823
|
+
context), so aggregating them is not strictly meaningful — treat as indicative only."""
|
|
824
|
+
def _empty() -> dict:
|
|
825
|
+
return {"cost": 0.0, "sessions": 0, "input_tokens": 0, "output_tokens": 0,
|
|
826
|
+
"cache_read": 0, "cache_write": 0, "models": {}}
|
|
827
|
+
|
|
629
828
|
by_date: dict[str, dict] = {}
|
|
630
829
|
|
|
631
830
|
if monthly_summaries:
|
|
632
831
|
for date_str, data in monthly_summaries.items():
|
|
633
|
-
|
|
832
|
+
bucket = _empty()
|
|
833
|
+
bucket["cost"] = data["cost"]
|
|
834
|
+
bucket["sessions"] = data["sessions"]
|
|
835
|
+
by_date[date_str] = bucket
|
|
634
836
|
|
|
635
837
|
recent_dates: set[str] = set()
|
|
636
838
|
for e in events:
|
|
637
839
|
date_str = e["date_str"]
|
|
638
840
|
if date_str not in recent_dates:
|
|
639
|
-
by_date[date_str] =
|
|
841
|
+
by_date[date_str] = _empty()
|
|
640
842
|
recent_dates.add(date_str)
|
|
641
|
-
by_date[date_str]
|
|
642
|
-
|
|
843
|
+
bucket = by_date[date_str]
|
|
844
|
+
bucket["cost"] += e["cost_usd"]
|
|
845
|
+
bucket["sessions"] += 1
|
|
846
|
+
bucket["input_tokens"] += e.get("input_tokens", 0)
|
|
847
|
+
bucket["output_tokens"] += e.get("output_tokens", 0)
|
|
848
|
+
bucket["cache_read"] += e.get("cache_read", 0)
|
|
849
|
+
bucket["cache_write"] += e.get("cache_write", 0)
|
|
850
|
+
model = e.get("model", "")
|
|
851
|
+
if model:
|
|
852
|
+
bucket["models"][model] = bucket["models"].get(model, 0) + 1
|
|
643
853
|
|
|
644
854
|
return by_date
|
|
645
855
|
|
|
@@ -647,14 +857,15 @@ def aggregate_hook_data(events: list[dict], monthly_summaries: dict | None = Non
|
|
|
647
857
|
# ── Aggregation ─────────────────────────────────────────────────────────
|
|
648
858
|
|
|
649
859
|
def new_bucket() -> dict:
|
|
650
|
-
return {"input": 0, "output": 0, "cache_read": 0, "cache_write": 0,
|
|
860
|
+
return {"input": 0, "output": 0, "cache_read": 0, "cache_write": 0,
|
|
861
|
+
"cache_write_1h": 0, "cost": 0.0, "models": {}}
|
|
651
862
|
|
|
652
863
|
|
|
653
864
|
def aggregate(events: list[dict]) -> tuple[dict, dict]:
|
|
654
865
|
"""
|
|
655
866
|
Returns:
|
|
656
|
-
daily: {date_str: {input, output, cache_read, cache_write, cost}}
|
|
657
|
-
monthly: {month_str: {input, output, cache_read, cache_write, cost}}
|
|
867
|
+
daily: {date_str: {input, output, cache_read, cache_write, cache_write_1h, cost, models}}
|
|
868
|
+
monthly: {month_str: {input, output, cache_read, cache_write, cache_write_1h, cost, models}}
|
|
658
869
|
"""
|
|
659
870
|
daily = defaultdict(new_bucket)
|
|
660
871
|
monthly = defaultdict(new_bucket)
|
|
@@ -672,14 +883,20 @@ def aggregate(events: list[dict]) -> tuple[dict, dict]:
|
|
|
672
883
|
date_str = dt.strftime("%Y-%m-%d")
|
|
673
884
|
month_str = dt.strftime("%Y-%m")
|
|
674
885
|
|
|
675
|
-
|
|
886
|
+
cache_1h = e.get("cache_write_1h", 0)
|
|
887
|
+
cost = calculate_cost(e["model"], e["input"], e["output"],
|
|
888
|
+
e["cache_read"], e["cache_write"], cache_1h)
|
|
889
|
+
model = e.get("model", "")
|
|
676
890
|
|
|
677
891
|
for bucket in (daily[date_str], monthly[month_str]):
|
|
678
892
|
bucket["input"] += e["input"]
|
|
679
893
|
bucket["output"] += e["output"]
|
|
680
894
|
bucket["cache_read"] += e["cache_read"]
|
|
681
895
|
bucket["cache_write"] += e["cache_write"]
|
|
896
|
+
bucket["cache_write_1h"] += cache_1h
|
|
682
897
|
bucket["cost"] += cost
|
|
898
|
+
if model:
|
|
899
|
+
bucket["models"][model] = bucket["models"].get(model, 0) + 1
|
|
683
900
|
|
|
684
901
|
return dict(daily), dict(monthly)
|
|
685
902
|
|
|
@@ -690,8 +907,26 @@ def format_tokens(n: int) -> str:
|
|
|
690
907
|
return f"{n:,}"
|
|
691
908
|
|
|
692
909
|
|
|
910
|
+
def format_model(models: dict) -> str:
|
|
911
|
+
"""Format a model counter dict as 'short-name' or 'short-name+N' when
|
|
912
|
+
multiple models appear. Returns empty string for no models.
|
|
913
|
+
Strips the 'claude-' prefix and any trailing YYYYMMDD date suffix."""
|
|
914
|
+
if not models:
|
|
915
|
+
return ""
|
|
916
|
+
ranked = sorted(models.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
917
|
+
primary = ranked[0][0]
|
|
918
|
+
short = primary[len("claude-"):] if primary.startswith("claude-") else primary
|
|
919
|
+
parts = short.split("-")
|
|
920
|
+
if len(parts) > 1 and parts[-1].isdigit() and len(parts[-1]) == 8:
|
|
921
|
+
short = "-".join(parts[:-1])
|
|
922
|
+
if len(ranked) > 1:
|
|
923
|
+
short += f"+{len(ranked) - 1}"
|
|
924
|
+
return short
|
|
925
|
+
|
|
926
|
+
|
|
693
927
|
def total_tokens(b: dict) -> int:
|
|
694
|
-
return b["input"] + b["output"] + b["cache_read"]
|
|
928
|
+
return (b["input"] + b["output"] + b["cache_read"]
|
|
929
|
+
+ b["cache_write"] + b.get("cache_write_1h", 0))
|
|
695
930
|
|
|
696
931
|
|
|
697
932
|
def _compute_accuracy_factor(daily: dict, hook_daily: dict, hook_install_date: str | None) -> tuple[float, bool]:
|
|
@@ -796,12 +1031,25 @@ def print_report(daily: dict, monthly: dict, days: int | None = None,
|
|
|
796
1031
|
print(f"{current_month_name} — month to date (day {day_of_month}, {days_active} active)")
|
|
797
1032
|
print("───────────────────────────────────────")
|
|
798
1033
|
if m:
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
if
|
|
1034
|
+
hook_in_mtd = sum(hook_daily.get(d, {}).get("input_tokens", 0) for d in current_days_all)
|
|
1035
|
+
hook_out_mtd = sum(hook_daily.get(d, {}).get("output_tokens", 0) for d in current_days_all)
|
|
1036
|
+
input_t = hook_in_mtd if hook_in_mtd else m["input"]
|
|
1037
|
+
output_t = hook_out_mtd if hook_out_mtd else m["output"]
|
|
1038
|
+
print(f" Input tokens: {format_tokens(input_t):>15}")
|
|
1039
|
+
print(f" Output tokens: {format_tokens(output_t):>15}")
|
|
1040
|
+
cache_w_total = m['cache_write'] + m.get('cache_write_1h', 0)
|
|
1041
|
+
if m['cache_read'] or cache_w_total:
|
|
802
1042
|
print(f" Cache read: {format_tokens(m['cache_read']):>15}")
|
|
803
|
-
print(f" Cache write: {format_tokens(
|
|
804
|
-
|
|
1043
|
+
print(f" Cache write: {format_tokens(cache_w_total):>15}")
|
|
1044
|
+
total = input_t + output_t + m['cache_read'] + cache_w_total
|
|
1045
|
+
print(f" Total tokens: {format_tokens(total):>15}")
|
|
1046
|
+
mtd_model = format_model(m.get("models", {}))
|
|
1047
|
+
if mtd_model:
|
|
1048
|
+
print(f" Model: {mtd_model:>15}")
|
|
1049
|
+
|
|
1050
|
+
mtd_sessions = sum(hook_daily.get(d, {}).get("sessions", 0) for d in current_days_all)
|
|
1051
|
+
if mtd_sessions:
|
|
1052
|
+
print(f" Sessions: {mtd_sessions:>15}")
|
|
805
1053
|
|
|
806
1054
|
if hook_install_date and est_days > 0:
|
|
807
1055
|
factor_source = "measured" if factor_is_measured else "estimated ~20-25%"
|
|
@@ -829,26 +1077,32 @@ def print_report(daily: dict, monthly: dict, days: int | None = None,
|
|
|
829
1077
|
if current_month_days:
|
|
830
1078
|
print("Daily breakdown:")
|
|
831
1079
|
if hook_install_date:
|
|
832
|
-
print(f" {'Date':<12} {'Input':>
|
|
833
|
-
print(f" {'─'*12} {'─'*
|
|
1080
|
+
print(f" {'Date':<12} {'Input':>11} {'Output':>11} {'Cache R':>11} {'Cache W':>11} {'Sess':>5} {'Model':<12} {'Cost':>9} {'':>2}")
|
|
1081
|
+
print(f" {'─'*12} {'─'*11} {'─'*11} {'─'*11} {'─'*11} {'─'*5} {'─'*12} {'─'*9} {'─'*2}")
|
|
834
1082
|
else:
|
|
835
|
-
print(f" {'Date':<12} {'Input':>
|
|
836
|
-
print(f" {'─'*12} {'─'*
|
|
1083
|
+
print(f" {'Date':<12} {'Input':>11} {'Output':>11} {'Cache R':>11} {'Cache W':>11} {'Model':<12} {'Cost':>9}")
|
|
1084
|
+
print(f" {'─'*12} {'─'*11} {'─'*11} {'─'*11} {'─'*11} {'─'*12} {'─'*9}")
|
|
837
1085
|
display_days = current_month_days
|
|
838
1086
|
if days is not None:
|
|
839
1087
|
display_days = display_days[:days]
|
|
840
1088
|
for date_str in display_days:
|
|
841
1089
|
cost, is_auth = _date_cost(date_str)
|
|
842
1090
|
d = daily.get(date_str)
|
|
843
|
-
|
|
844
|
-
|
|
1091
|
+
hook = hook_daily.get(date_str, {})
|
|
1092
|
+
hook_in = hook.get("input_tokens", 0)
|
|
1093
|
+
hook_out = hook.get("output_tokens", 0)
|
|
1094
|
+
input_t = hook_in if hook_in else (d["input"] if d else 0)
|
|
1095
|
+
output_t = hook_out if hook_out else (d["output"] if d else 0)
|
|
845
1096
|
cache_r = d["cache_read"] if d else 0
|
|
846
|
-
cache_w = d["cache_write"] if d else 0
|
|
1097
|
+
cache_w = (d["cache_write"] + d.get("cache_write_1h", 0)) if d else 0
|
|
1098
|
+
model_str = format_model(d.get("models", {})) if d else format_model(hook.get("models", {}))
|
|
847
1099
|
if hook_install_date:
|
|
848
1100
|
marker = "" if is_auth else "~"
|
|
849
|
-
|
|
1101
|
+
sessions = hook.get("sessions", 0)
|
|
1102
|
+
sess_str = str(sessions) if sessions else "-"
|
|
1103
|
+
print(f" {date_str:<12} {format_tokens(input_t):>11} {format_tokens(output_t):>11} {format_tokens(cache_r):>11} {format_tokens(cache_w):>11} {sess_str:>5} {model_str:<12} ${cost:>7.2f} {marker:>2}")
|
|
850
1104
|
else:
|
|
851
|
-
print(f" {date_str:<12} {format_tokens(input_t):>
|
|
1105
|
+
print(f" {date_str:<12} {format_tokens(input_t):>11} {format_tokens(output_t):>11} {format_tokens(cache_r):>11} {format_tokens(cache_w):>11} {model_str:<12} ${cost:>7.2f}")
|
|
852
1106
|
|
|
853
1107
|
if hook_install_date and current_month_days:
|
|
854
1108
|
has_estimated = any(not _date_cost(d)[1] for d in current_month_days)
|
|
@@ -863,8 +1117,8 @@ def print_report(daily: dict, monthly: dict, days: int | None = None,
|
|
|
863
1117
|
if prev_months:
|
|
864
1118
|
print()
|
|
865
1119
|
print("Previous months:")
|
|
866
|
-
print(f" {'Month':<12} {'Input':>
|
|
867
|
-
print(f" {'─'*12} {'─'*
|
|
1120
|
+
print(f" {'Month':<12} {'Input':>11} {'Output':>11} {'Cache R':>11} {'Cache W':>11} {'Model':<12} {'Cost':>9}")
|
|
1121
|
+
print(f" {'─'*12} {'─'*11} {'─'*11} {'─'*11} {'─'*11} {'─'*12} {'─'*9}")
|
|
868
1122
|
for month_str in prev_months:
|
|
869
1123
|
m = monthly.get(month_str)
|
|
870
1124
|
month_days_list = [d for d in prev_month_days if d.startswith(month_str)]
|
|
@@ -890,12 +1144,13 @@ def print_report(daily: dict, monthly: dict, days: int | None = None,
|
|
|
890
1144
|
input_t = m["input"] if m else 0
|
|
891
1145
|
output_t = m["output"] if m else 0
|
|
892
1146
|
cache_r = m["cache_read"] if m else 0
|
|
893
|
-
cache_w = m["cache_write"] if m else 0
|
|
1147
|
+
cache_w = (m["cache_write"] + m.get("cache_write_1h", 0)) if m else 0
|
|
1148
|
+
model_str = format_model(m.get("models", {})) if m else ""
|
|
894
1149
|
|
|
895
|
-
cost_str = f"${month_cost:>
|
|
1150
|
+
cost_str = f"${month_cost:>7.2f}"
|
|
896
1151
|
if hook_install_date and month_has_est and not month_has_hook:
|
|
897
1152
|
cost_str += " ~"
|
|
898
|
-
print(f" {month_str + days_str:<12} {format_tokens(input_t):>
|
|
1153
|
+
print(f" {month_str + days_str:<12} {format_tokens(input_t):>11} {format_tokens(output_t):>11} {format_tokens(cache_r):>11} {format_tokens(cache_w):>11} {model_str:<12} {cost_str}")
|
|
899
1154
|
|
|
900
1155
|
print()
|
|
901
1156
|
|
|
@@ -969,21 +1224,33 @@ def main():
|
|
|
969
1224
|
except (ValueError, AttributeError):
|
|
970
1225
|
pass
|
|
971
1226
|
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
1227
|
+
all_hook_events: list[dict] = []
|
|
1228
|
+
all_monthly: dict[str, dict] = {}
|
|
1229
|
+
|
|
1230
|
+
for hdir in discover_hook_data_dirs():
|
|
1231
|
+
rollup_old_hook_files(hdir)
|
|
1232
|
+
all_hook_events.extend(compute_hook_deltas(parse_hook_events(hdir)))
|
|
1233
|
+
merge_monthly_summaries(all_monthly, load_monthly_hook_summaries(hdir))
|
|
1234
|
+
|
|
1235
|
+
# Remote hook data: each host's deltas are computed independently so a
|
|
1236
|
+
# session's cumulative history never mixes across hosts.
|
|
1237
|
+
if args.remote:
|
|
1238
|
+
for host in args.remote:
|
|
1239
|
+
remote_hook_events, remote_monthly = parse_remote_hook_data(host)
|
|
1240
|
+
all_hook_events.extend(compute_hook_deltas(remote_hook_events))
|
|
1241
|
+
merge_monthly_summaries(all_monthly, remote_monthly)
|
|
1242
|
+
|
|
1243
|
+
if all_hook_events or all_monthly:
|
|
1244
|
+
hook_daily = aggregate_hook_data(all_hook_events, all_monthly)
|
|
1245
|
+
|
|
1246
|
+
# Hook coverage may start earlier than the local install date (e.g. a
|
|
1247
|
+
# remote host installed the hook first). Treat the earliest observed
|
|
1248
|
+
# hook date as the effective install date so those days count as
|
|
1249
|
+
# authoritative rather than being scaled by the accuracy factor.
|
|
1250
|
+
if hook_daily:
|
|
1251
|
+
earliest_hook_date = min(hook_daily.keys())
|
|
1252
|
+
if hook_install_date is None or earliest_hook_date < hook_install_date:
|
|
1253
|
+
hook_install_date = earliest_hook_date
|
|
987
1254
|
|
|
988
1255
|
# Suggest hook if not installed
|
|
989
1256
|
if not is_hook_installed():
|
|
@@ -172,6 +172,62 @@ def test_calculate_cost_zero_tokens():
|
|
|
172
172
|
assert cost == 0.0
|
|
173
173
|
|
|
174
174
|
|
|
175
|
+
def test_calculate_cost_1h_cache_write():
|
|
176
|
+
# Opus 4.7: 1M 1h-cache-write tokens at $10/M = $10.00 (2× input rate)
|
|
177
|
+
cost = cctrack.calculate_cost("claude-opus-4-7", 0, 0, 0, 0, 1_000_000)
|
|
178
|
+
assert abs(cost - 10.0) < 0.001
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_calculate_cost_1h_vs_5m_cache_write_differ():
|
|
182
|
+
# Same 1M tokens priced differently depending on TTL.
|
|
183
|
+
cost_5m = cctrack.calculate_cost("claude-opus-4-7", 0, 0, 0, 1_000_000, 0)
|
|
184
|
+
cost_1h = cctrack.calculate_cost("claude-opus-4-7", 0, 0, 0, 0, 1_000_000)
|
|
185
|
+
assert abs(cost_5m - 6.25) < 0.001
|
|
186
|
+
assert abs(cost_1h - 10.0) < 0.001
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_get_rates_include_1h_cache_write():
|
|
190
|
+
# Every family should expose a cache_write_1h rate at 2× input.
|
|
191
|
+
for r in cctrack.RATES:
|
|
192
|
+
assert "cache_write_1h" in r
|
|
193
|
+
assert abs(r["cache_write_1h"] - 2.0 * r["input"]) < 0.001
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_parse_lines_extracts_1h_cache_split():
|
|
197
|
+
# Nested cache_creation.ephemeral_{5m,1h}_input_tokens should be picked up.
|
|
198
|
+
event = make_event()
|
|
199
|
+
event["message"]["usage"]["cache_creation_input_tokens"] = 1000
|
|
200
|
+
event["message"]["usage"]["cache_creation"] = {
|
|
201
|
+
"ephemeral_5m_input_tokens": 300,
|
|
202
|
+
"ephemeral_1h_input_tokens": 700,
|
|
203
|
+
}
|
|
204
|
+
events = cctrack.parse_lines([json.dumps(event)])
|
|
205
|
+
assert len(events) == 1
|
|
206
|
+
assert events[0]["cache_write"] == 300
|
|
207
|
+
assert events[0]["cache_write_1h"] == 700
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_parse_lines_flat_cache_creation_treated_as_5m():
|
|
211
|
+
# Legacy logs without the nested object treat the flat field as 5m.
|
|
212
|
+
event = make_event(cache_write=1000)
|
|
213
|
+
events = cctrack.parse_lines([json.dumps(event)])
|
|
214
|
+
assert len(events) == 1
|
|
215
|
+
assert events[0]["cache_write"] == 1000
|
|
216
|
+
assert events[0]["cache_write_1h"] == 0
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_aggregate_1h_cache_cost():
|
|
220
|
+
# 1M 1h-cache-write tokens on opus-4-7 → $10 via aggregation.
|
|
221
|
+
events = [
|
|
222
|
+
{"model": "claude-opus-4-7", "timestamp": "2026-03-15T10:00:00Z",
|
|
223
|
+
"input": 0, "output": 0, "cache_read": 0,
|
|
224
|
+
"cache_write": 0, "cache_write_1h": 1_000_000},
|
|
225
|
+
]
|
|
226
|
+
daily, monthly = cctrack.aggregate(events)
|
|
227
|
+
assert abs(daily["2026-03-15"]["cost"] - 10.0) < 0.001
|
|
228
|
+
assert daily["2026-03-15"]["cache_write_1h"] == 1_000_000
|
|
229
|
+
|
|
230
|
+
|
|
175
231
|
# ── File discovery tests ───────────────────────────────────────────────
|
|
176
232
|
|
|
177
233
|
def test_discover_files_finds_jsonl():
|
|
@@ -159,6 +159,207 @@ def test_parse_hook_events_reads_legacy_file():
|
|
|
159
159
|
assert events[0]["session_id"] == "legacy_1"
|
|
160
160
|
|
|
161
161
|
|
|
162
|
+
def test_parse_hook_events_session_spans_multiple_days():
|
|
163
|
+
"""A session active on multiple days returns one record per (session, day)
|
|
164
|
+
with that day's maximum cumulative values."""
|
|
165
|
+
with tempfile.TemporaryDirectory() as d:
|
|
166
|
+
write_hook_jsonl(d, [
|
|
167
|
+
make_hook_entry(session_id="sA", ts="2026-04-14T10:00:00+00:00", cost_usd=2.00, input_tokens=100, output_tokens=50),
|
|
168
|
+
make_hook_entry(session_id="sA", ts="2026-04-14T18:00:00+00:00", cost_usd=5.00, input_tokens=300, output_tokens=150),
|
|
169
|
+
], date="2026-04-14")
|
|
170
|
+
write_hook_jsonl(d, [
|
|
171
|
+
make_hook_entry(session_id="sA", ts="2026-04-15T09:00:00+00:00", cost_usd=8.00, input_tokens=500, output_tokens=250),
|
|
172
|
+
make_hook_entry(session_id="sA", ts="2026-04-15T16:00:00+00:00", cost_usd=12.00, input_tokens=800, output_tokens=400),
|
|
173
|
+
], date="2026-04-15")
|
|
174
|
+
events = cctrack.parse_hook_events(cctrack_dir=Path(d))
|
|
175
|
+
by_date = {e["date_str"]: e for e in events}
|
|
176
|
+
assert len(events) == 2
|
|
177
|
+
assert by_date["2026-04-14"]["cost_usd"] == 5.00
|
|
178
|
+
assert by_date["2026-04-14"]["input_tokens"] == 300
|
|
179
|
+
assert by_date["2026-04-15"]["cost_usd"] == 12.00
|
|
180
|
+
assert by_date["2026-04-15"]["input_tokens"] == 800
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ── compute_hook_deltas tests ──────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
def test_compute_hook_deltas_single_day_session():
|
|
186
|
+
"""Session visible on one day produces one delta equal to its cumulative."""
|
|
187
|
+
events = [
|
|
188
|
+
{"date_str": "2026-04-15", "session_id": "s1", "cost_usd": 3.0,
|
|
189
|
+
"input_tokens": 100, "output_tokens": 50, "cache_read": 0, "cache_write": 0, "model": "m"},
|
|
190
|
+
]
|
|
191
|
+
deltas = cctrack.compute_hook_deltas(events)
|
|
192
|
+
assert len(deltas) == 1
|
|
193
|
+
assert deltas[0]["cost_usd"] == 3.0
|
|
194
|
+
assert deltas[0]["input_tokens"] == 100
|
|
195
|
+
assert deltas[0]["output_tokens"] == 50
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_compute_hook_deltas_session_spans_days():
|
|
199
|
+
"""A session spanning days produces one delta per day, each being the
|
|
200
|
+
day-over-day increment rather than the cumulative value."""
|
|
201
|
+
events = [
|
|
202
|
+
{"date_str": "2026-04-14", "session_id": "sA", "cost_usd": 5.00,
|
|
203
|
+
"input_tokens": 300, "output_tokens": 150, "cache_read": 0, "cache_write": 0, "model": "m"},
|
|
204
|
+
{"date_str": "2026-04-15", "session_id": "sA", "cost_usd": 12.00,
|
|
205
|
+
"input_tokens": 800, "output_tokens": 400, "cache_read": 0, "cache_write": 0, "model": "m"},
|
|
206
|
+
]
|
|
207
|
+
deltas = cctrack.compute_hook_deltas(events)
|
|
208
|
+
by_date = {d["date_str"]: d for d in deltas}
|
|
209
|
+
assert abs(by_date["2026-04-14"]["cost_usd"] - 5.00) < 0.001
|
|
210
|
+
assert abs(by_date["2026-04-15"]["cost_usd"] - 7.00) < 0.001
|
|
211
|
+
assert by_date["2026-04-14"]["input_tokens"] == 300
|
|
212
|
+
assert by_date["2026-04-15"]["input_tokens"] == 500
|
|
213
|
+
assert by_date["2026-04-14"]["output_tokens"] == 150
|
|
214
|
+
assert by_date["2026-04-15"]["output_tokens"] == 250
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_compute_hook_deltas_drops_zero_records():
|
|
218
|
+
"""A record whose cumulative didn't advance from the prior day is dropped."""
|
|
219
|
+
events = [
|
|
220
|
+
{"date_str": "2026-04-14", "session_id": "sA", "cost_usd": 5.0,
|
|
221
|
+
"input_tokens": 100, "output_tokens": 50, "cache_read": 0, "cache_write": 0, "model": "m"},
|
|
222
|
+
{"date_str": "2026-04-15", "session_id": "sA", "cost_usd": 5.0,
|
|
223
|
+
"input_tokens": 100, "output_tokens": 50, "cache_read": 0, "cache_write": 0, "model": "m"},
|
|
224
|
+
]
|
|
225
|
+
deltas = cctrack.compute_hook_deltas(events)
|
|
226
|
+
assert len(deltas) == 1
|
|
227
|
+
assert deltas[0]["date_str"] == "2026-04-14"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_compute_hook_deltas_clamps_negatives():
|
|
231
|
+
"""Cumulative values should never decrease, but if they do (bad data),
|
|
232
|
+
deltas clamp to zero rather than produce negative costs."""
|
|
233
|
+
events = [
|
|
234
|
+
{"date_str": "2026-04-14", "session_id": "sA", "cost_usd": 5.0,
|
|
235
|
+
"input_tokens": 100, "output_tokens": 50, "cache_read": 0, "cache_write": 0, "model": "m"},
|
|
236
|
+
{"date_str": "2026-04-15", "session_id": "sA", "cost_usd": 3.0,
|
|
237
|
+
"input_tokens": 80, "output_tokens": 40, "cache_read": 0, "cache_write": 0, "model": "m"},
|
|
238
|
+
]
|
|
239
|
+
deltas = cctrack.compute_hook_deltas(events)
|
|
240
|
+
# Day 2 delta is negative, so it's dropped entirely (all-zero after clamping).
|
|
241
|
+
assert len(deltas) == 1
|
|
242
|
+
assert deltas[0]["date_str"] == "2026-04-14"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_compute_hook_deltas_multiple_sessions_independent():
|
|
246
|
+
"""Each session's deltas are computed independently."""
|
|
247
|
+
events = [
|
|
248
|
+
{"date_str": "2026-04-14", "session_id": "sA", "cost_usd": 10.0,
|
|
249
|
+
"input_tokens": 0, "output_tokens": 0, "cache_read": 0, "cache_write": 0, "model": "m"},
|
|
250
|
+
{"date_str": "2026-04-15", "session_id": "sA", "cost_usd": 15.0,
|
|
251
|
+
"input_tokens": 0, "output_tokens": 0, "cache_read": 0, "cache_write": 0, "model": "m"},
|
|
252
|
+
{"date_str": "2026-04-15", "session_id": "sB", "cost_usd": 4.0,
|
|
253
|
+
"input_tokens": 0, "output_tokens": 0, "cache_read": 0, "cache_write": 0, "model": "m"},
|
|
254
|
+
]
|
|
255
|
+
deltas = cctrack.compute_hook_deltas(events)
|
|
256
|
+
by_key = {(d["date_str"], d["session_id"]): d["cost_usd"] for d in deltas}
|
|
257
|
+
assert by_key[("2026-04-14", "sA")] == 10.0
|
|
258
|
+
assert by_key[("2026-04-15", "sA")] == 5.0
|
|
259
|
+
assert by_key[("2026-04-15", "sB")] == 4.0
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ── Remote hook data tests ─────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
def _remote_raw(hook_chunks: list[list[dict]], monthly_chunks: list[dict]) -> str:
|
|
265
|
+
"""Build a raw SSH output stream the way fetch_remote_hook_data returns it."""
|
|
266
|
+
parts = []
|
|
267
|
+
for entries in hook_chunks:
|
|
268
|
+
parts.append(cctrack.HOOK_FILE_SEP)
|
|
269
|
+
for e in entries:
|
|
270
|
+
parts.append(json.dumps(e))
|
|
271
|
+
for doc in monthly_chunks:
|
|
272
|
+
parts.append(cctrack.MONTHLY_FILE_SEP)
|
|
273
|
+
parts.append(json.dumps(doc))
|
|
274
|
+
return "\n".join(parts) + "\n"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_parse_remote_hook_data_basic():
|
|
278
|
+
"""Statusline entries and monthly summaries are both parsed from the stream."""
|
|
279
|
+
raw = _remote_raw(
|
|
280
|
+
hook_chunks=[
|
|
281
|
+
[make_hook_entry(session_id="r1", ts="2026-04-15T10:00:00+00:00", cost_usd=3.00)],
|
|
282
|
+
[make_hook_entry(session_id="r2", ts="2026-04-16T10:00:00+00:00", cost_usd=7.00)],
|
|
283
|
+
],
|
|
284
|
+
monthly_chunks=[
|
|
285
|
+
{"month": "2026-01", "days": {"2026-01-10": {"cost": 10.0, "sessions": 2}}},
|
|
286
|
+
],
|
|
287
|
+
)
|
|
288
|
+
with patch("cctrack.fetch_remote_hook_data", return_value=raw):
|
|
289
|
+
events, monthly = cctrack.parse_remote_hook_data("fakehost")
|
|
290
|
+
assert len(events) == 2
|
|
291
|
+
by_session = {e["session_id"]: e for e in events}
|
|
292
|
+
assert by_session["r1"]["cost_usd"] == 3.00
|
|
293
|
+
assert by_session["r2"]["date_str"] == "2026-04-16"
|
|
294
|
+
assert monthly == {"2026-01-10": {"cost": 10.0, "sessions": 2}}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_parse_remote_hook_data_dedup_across_files():
|
|
298
|
+
"""Same (session, date) in multiple remote files: latest _ts wins."""
|
|
299
|
+
raw = _remote_raw(
|
|
300
|
+
hook_chunks=[
|
|
301
|
+
[make_hook_entry(session_id="rA", ts="2026-04-15T10:00:00+00:00", cost_usd=2.00)],
|
|
302
|
+
[make_hook_entry(session_id="rA", ts="2026-04-15T18:00:00+00:00", cost_usd=6.00)],
|
|
303
|
+
],
|
|
304
|
+
monthly_chunks=[],
|
|
305
|
+
)
|
|
306
|
+
with patch("cctrack.fetch_remote_hook_data", return_value=raw):
|
|
307
|
+
events, monthly = cctrack.parse_remote_hook_data("fakehost")
|
|
308
|
+
assert len(events) == 1
|
|
309
|
+
assert events[0]["cost_usd"] == 6.00
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def test_parse_remote_hook_data_empty():
|
|
313
|
+
with patch("cctrack.fetch_remote_hook_data", return_value=""):
|
|
314
|
+
events, monthly = cctrack.parse_remote_hook_data("fakehost")
|
|
315
|
+
assert events == []
|
|
316
|
+
assert monthly == {}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def test_parse_remote_hook_data_malformed_monthly_skipped():
|
|
320
|
+
raw = (cctrack.HOOK_FILE_SEP + "\n"
|
|
321
|
+
+ json.dumps(make_hook_entry(session_id="ok", cost_usd=1.0)) + "\n"
|
|
322
|
+
+ cctrack.MONTHLY_FILE_SEP + "\n"
|
|
323
|
+
+ "not json\n")
|
|
324
|
+
with patch("cctrack.fetch_remote_hook_data", return_value=raw):
|
|
325
|
+
events, monthly = cctrack.parse_remote_hook_data("fakehost")
|
|
326
|
+
assert len(events) == 1
|
|
327
|
+
assert monthly == {}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_fetch_remote_hook_data_timeout():
|
|
331
|
+
with patch("subprocess.run", side_effect=cctrack.subprocess.TimeoutExpired("ssh", 60)):
|
|
332
|
+
result = cctrack.fetch_remote_hook_data("fakehost")
|
|
333
|
+
assert result == ""
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def test_merge_monthly_summaries_sums_overlapping_dates():
|
|
337
|
+
"""Same date on two hosts sums cost and sessions."""
|
|
338
|
+
target = {"2026-01-10": {"cost": 10.0, "sessions": 2}}
|
|
339
|
+
cctrack.merge_monthly_summaries(target, {
|
|
340
|
+
"2026-01-10": {"cost": 5.0, "sessions": 1},
|
|
341
|
+
"2026-01-11": {"cost": 3.0, "sessions": 1},
|
|
342
|
+
})
|
|
343
|
+
assert target["2026-01-10"] == {"cost": 15.0, "sessions": 3}
|
|
344
|
+
assert target["2026-01-11"] == {"cost": 3.0, "sessions": 1}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def test_remote_and_local_hook_costs_sum_per_date():
|
|
348
|
+
"""Regression: a date covered by hooks on two hosts must sum both hosts'
|
|
349
|
+
costs, not keep just one host's value."""
|
|
350
|
+
local_events = cctrack.compute_hook_deltas([
|
|
351
|
+
{"date_str": "2026-06-11", "session_id": "local_s", "cost_usd": 9.05,
|
|
352
|
+
"input_tokens": 0, "output_tokens": 0, "cache_read": 0, "cache_write": 0, "model": "m"},
|
|
353
|
+
])
|
|
354
|
+
remote_events = cctrack.compute_hook_deltas([
|
|
355
|
+
{"date_str": "2026-06-11", "session_id": "air_s", "cost_usd": 340.90,
|
|
356
|
+
"input_tokens": 0, "output_tokens": 0, "cache_read": 0, "cache_write": 0, "model": "m"},
|
|
357
|
+
])
|
|
358
|
+
hook_daily = cctrack.aggregate_hook_data(local_events + remote_events)
|
|
359
|
+
assert abs(hook_daily["2026-06-11"]["cost"] - 349.95) < 0.001
|
|
360
|
+
assert hook_daily["2026-06-11"]["sessions"] == 2
|
|
361
|
+
|
|
362
|
+
|
|
162
363
|
# ── aggregate_hook_data tests ──────────────────────────────────────────
|
|
163
364
|
|
|
164
365
|
def test_aggregate_hook_data_single_day():
|
|
@@ -316,6 +517,35 @@ def test_rollup_appends_to_existing_monthly():
|
|
|
316
517
|
assert data["days"]["2025-12-02"]["cost"] == 7.00
|
|
317
518
|
|
|
318
519
|
|
|
520
|
+
def test_rollup_cross_day_session_attributes_deltas():
|
|
521
|
+
"""A session active across multiple rolled-up days should have its
|
|
522
|
+
cumulative cost attributed as per-day deltas, not double-counted."""
|
|
523
|
+
with tempfile.TemporaryDirectory() as d:
|
|
524
|
+
# Session sA: cumulative rises 0 → 5 on day 1, 5 → 12 on day 2.
|
|
525
|
+
# Session sB: only active on day 2, cumulative rises 0 → 3.
|
|
526
|
+
write_hook_jsonl(d, [
|
|
527
|
+
make_hook_entry(session_id="sA", ts="2025-12-01T10:00:00+00:00", cost_usd=2.00),
|
|
528
|
+
make_hook_entry(session_id="sA", ts="2025-12-01T18:00:00+00:00", cost_usd=5.00),
|
|
529
|
+
], date="2025-12-01")
|
|
530
|
+
write_hook_jsonl(d, [
|
|
531
|
+
make_hook_entry(session_id="sA", ts="2025-12-02T10:00:00+00:00", cost_usd=12.00),
|
|
532
|
+
make_hook_entry(session_id="sB", ts="2025-12-02T11:00:00+00:00", cost_usd=3.00),
|
|
533
|
+
], date="2025-12-02")
|
|
534
|
+
|
|
535
|
+
cctrack.rollup_old_hook_files(cctrack_dir=Path(d), retention_days=0)
|
|
536
|
+
|
|
537
|
+
data = json.loads(Path(d, "monthly-2025-12.json").read_text())
|
|
538
|
+
# Day 1: sA delta = 5.00. Total = 5.00, 1 session.
|
|
539
|
+
assert abs(data["days"]["2025-12-01"]["cost"] - 5.00) < 0.001
|
|
540
|
+
assert data["days"]["2025-12-01"]["sessions"] == 1
|
|
541
|
+
# Day 2: sA delta = 12 - 5 = 7.00, sB delta = 3.00. Total = 10.00, 2 sessions.
|
|
542
|
+
assert abs(data["days"]["2025-12-02"]["cost"] - 10.00) < 0.001
|
|
543
|
+
assert data["days"]["2025-12-02"]["sessions"] == 2
|
|
544
|
+
# Sum equals actual session totals: sA $12 + sB $3 = $15.
|
|
545
|
+
total = sum(day["cost"] for day in data["days"].values())
|
|
546
|
+
assert abs(total - 15.00) < 0.001
|
|
547
|
+
|
|
548
|
+
|
|
319
549
|
def test_rollup_deletes_empty_daily():
|
|
320
550
|
"""Daily file with no valid sessions is just deleted."""
|
|
321
551
|
with tempfile.TemporaryDirectory() as d:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|