cctrack 0.3.0__tar.gz → 0.4.0__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.0}/PKG-INFO +1 -1
- {cctrack-0.3.0 → cctrack-0.4.0}/pyproject.toml +1 -1
- {cctrack-0.3.0 → cctrack-0.4.0}/src/cctrack/__init__.py +250 -81
- {cctrack-0.3.0 → cctrack-0.4.0}/tests/test_cctrack.py +56 -0
- {cctrack-0.3.0 → cctrack-0.4.0}/tests/test_hook_reporting.py +129 -0
- {cctrack-0.3.0 → cctrack-0.4.0}/.github/workflows/ci.yml +0 -0
- {cctrack-0.3.0 → cctrack-0.4.0}/.github/workflows/publish.yml +0 -0
- {cctrack-0.3.0 → cctrack-0.4.0}/.gitignore +0 -0
- {cctrack-0.3.0 → cctrack-0.4.0}/CLAUDE.md +0 -0
- {cctrack-0.3.0 → cctrack-0.4.0}/LICENSE +0 -0
- {cctrack-0.3.0 → cctrack-0.4.0}/README.md +0 -0
- {cctrack-0.3.0 → cctrack-0.4.0}/src/cctrack/__main__.py +0 -0
- {cctrack-0.3.0 → cctrack-0.4.0}/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.0
|
|
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
|
|
@@ -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
|
|
|
@@ -432,9 +436,19 @@ def parse_lines(lines: list[str]) -> list[dict]:
|
|
|
432
436
|
input_t = usage.get("input_tokens", 0)
|
|
433
437
|
output_t = usage.get("output_tokens", 0)
|
|
434
438
|
cache_read = usage.get("cache_read_input_tokens", 0)
|
|
435
|
-
cache_write = usage.get("cache_creation_input_tokens", 0)
|
|
436
439
|
|
|
437
|
-
|
|
440
|
+
# Prefer nested cache_creation.{ephemeral_5m,ephemeral_1h}_input_tokens
|
|
441
|
+
# — 1h cache writes cost 2× input vs 1.25× for 5m, and Claude Code
|
|
442
|
+
# commonly uses the 1h tier. Fall back to flat cache_creation_input_tokens
|
|
443
|
+
# as all-5m for older logs that didn't emit the split.
|
|
444
|
+
cc = usage.get("cache_creation") or {}
|
|
445
|
+
cache_write = cc.get("ephemeral_5m_input_tokens", 0)
|
|
446
|
+
cache_write_1h = cc.get("ephemeral_1h_input_tokens", 0)
|
|
447
|
+
if not cc:
|
|
448
|
+
cache_write = usage.get("cache_creation_input_tokens", 0)
|
|
449
|
+
|
|
450
|
+
if (input_t == 0 and output_t == 0 and cache_read == 0
|
|
451
|
+
and cache_write == 0 and cache_write_1h == 0):
|
|
438
452
|
continue
|
|
439
453
|
|
|
440
454
|
entry = {
|
|
@@ -444,6 +458,7 @@ def parse_lines(lines: list[str]) -> list[dict]:
|
|
|
444
458
|
"output": output_t,
|
|
445
459
|
"cache_read": cache_read,
|
|
446
460
|
"cache_write": cache_write,
|
|
461
|
+
"cache_write_1h": cache_write_1h,
|
|
447
462
|
}
|
|
448
463
|
|
|
449
464
|
req_id = event.get("requestId", "")
|
|
@@ -471,15 +486,17 @@ def parse_events(files: list[str]) -> list[dict]:
|
|
|
471
486
|
# ── Hook data parsing ──────────────────────────────────────────────────
|
|
472
487
|
|
|
473
488
|
def parse_hook_events(cctrack_dir: Path | None = None) -> list[dict]:
|
|
474
|
-
"""Parse statusline-*.jsonl files.
|
|
475
|
-
|
|
476
|
-
|
|
489
|
+
"""Parse statusline-*.jsonl files. For each (session_id, date) pair, keeps the
|
|
490
|
+
entry with the latest _ts — the session's cumulative values as of end-of-day
|
|
491
|
+
for that date. Also reads legacy statusline.jsonl for migration.
|
|
492
|
+
Returns list of {date_str, session_id, cost_usd, input_tokens, output_tokens,
|
|
493
|
+
cache_read, cache_write, model}."""
|
|
477
494
|
if cctrack_dir is None:
|
|
478
495
|
cctrack_dir = CCTRACK_DIR
|
|
479
496
|
if not cctrack_dir.exists():
|
|
480
497
|
return []
|
|
481
498
|
|
|
482
|
-
|
|
499
|
+
by_session_day: dict[tuple[str, str], dict] = {}
|
|
483
500
|
|
|
484
501
|
files = sorted(cctrack_dir.glob("statusline-*.jsonl"))
|
|
485
502
|
legacy = cctrack_dir / "statusline.jsonl"
|
|
@@ -506,25 +523,26 @@ def parse_hook_events(cctrack_dir: Path | None = None) -> list[dict]:
|
|
|
506
523
|
if not ts:
|
|
507
524
|
continue
|
|
508
525
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
526
|
+
try:
|
|
527
|
+
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
528
|
+
except (ValueError, AttributeError):
|
|
529
|
+
continue
|
|
530
|
+
date_str = dt.strftime("%Y-%m-%d")
|
|
531
|
+
|
|
532
|
+
key = (session_id, date_str)
|
|
533
|
+
if key in by_session_day:
|
|
534
|
+
if ts <= by_session_day[key].get("_ts", ""):
|
|
512
535
|
continue
|
|
513
536
|
|
|
514
|
-
|
|
537
|
+
entry["_date_str"] = date_str
|
|
538
|
+
by_session_day[key] = entry
|
|
515
539
|
except (OSError, IOError):
|
|
516
540
|
continue
|
|
517
541
|
|
|
518
542
|
results = []
|
|
519
|
-
for session_id, entry in
|
|
520
|
-
ts = entry.get("_ts", "")
|
|
521
|
-
try:
|
|
522
|
-
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
523
|
-
except (ValueError, AttributeError):
|
|
524
|
-
continue
|
|
525
|
-
|
|
543
|
+
for (session_id, date_str), entry in by_session_day.items():
|
|
526
544
|
results.append({
|
|
527
|
-
"date_str":
|
|
545
|
+
"date_str": date_str,
|
|
528
546
|
"cost_usd": float(entry.get("cost_usd", 0.0)),
|
|
529
547
|
"input_tokens": int(entry.get("input_tokens", 0)),
|
|
530
548
|
"output_tokens": int(entry.get("output_tokens", 0)),
|
|
@@ -537,6 +555,50 @@ def parse_hook_events(cctrack_dir: Path | None = None) -> list[dict]:
|
|
|
537
555
|
return results
|
|
538
556
|
|
|
539
557
|
|
|
558
|
+
def compute_hook_deltas(events: list[dict]) -> list[dict]:
|
|
559
|
+
"""Convert per-(session, date) cumulative records into per-(session, date)
|
|
560
|
+
daily deltas. For each session, sorts its records by date and computes
|
|
561
|
+
day-over-day deltas for cost_usd, input_tokens, output_tokens. The first
|
|
562
|
+
visible day for a session uses 0 as the prior cumulative. Cache fields are
|
|
563
|
+
snapshot (not cumulative), so they're passed through unchanged. Records
|
|
564
|
+
with zero cost delta and zero token delta are dropped."""
|
|
565
|
+
by_session: dict[str, list[dict]] = defaultdict(list)
|
|
566
|
+
for e in events:
|
|
567
|
+
by_session[e["session_id"]].append(e)
|
|
568
|
+
|
|
569
|
+
result = []
|
|
570
|
+
for sid, session_events in by_session.items():
|
|
571
|
+
session_events.sort(key=lambda e: e["date_str"])
|
|
572
|
+
prev_cost = 0.0
|
|
573
|
+
prev_in = 0
|
|
574
|
+
prev_out = 0
|
|
575
|
+
for e in session_events:
|
|
576
|
+
cost = float(e.get("cost_usd", 0.0))
|
|
577
|
+
in_t = int(e.get("input_tokens", 0))
|
|
578
|
+
out_t = int(e.get("output_tokens", 0))
|
|
579
|
+
|
|
580
|
+
d_cost = max(0.0, cost - prev_cost)
|
|
581
|
+
d_in = max(0, in_t - prev_in)
|
|
582
|
+
d_out = max(0, out_t - prev_out)
|
|
583
|
+
|
|
584
|
+
prev_cost, prev_in, prev_out = cost, in_t, out_t
|
|
585
|
+
|
|
586
|
+
if d_cost == 0.0 and d_in == 0 and d_out == 0:
|
|
587
|
+
continue
|
|
588
|
+
|
|
589
|
+
result.append({
|
|
590
|
+
"date_str": e["date_str"],
|
|
591
|
+
"session_id": sid,
|
|
592
|
+
"cost_usd": d_cost,
|
|
593
|
+
"input_tokens": d_in,
|
|
594
|
+
"output_tokens": d_out,
|
|
595
|
+
"cache_read": int(e.get("cache_read", 0)),
|
|
596
|
+
"cache_write": int(e.get("cache_write", 0)),
|
|
597
|
+
"model": e.get("model", ""),
|
|
598
|
+
})
|
|
599
|
+
return result
|
|
600
|
+
|
|
601
|
+
|
|
540
602
|
def load_monthly_hook_summaries(cctrack_dir: Path | None = None) -> dict:
|
|
541
603
|
"""Load pre-aggregated monthly-YYYY-MM.json summary files.
|
|
542
604
|
Returns {date_str: {cost: float, sessions: int}}."""
|
|
@@ -561,7 +623,10 @@ def load_monthly_hook_summaries(cctrack_dir: Path | None = None) -> dict:
|
|
|
561
623
|
|
|
562
624
|
def rollup_old_hook_files(cctrack_dir: Path | None = None,
|
|
563
625
|
retention_days: int = HOOK_RETENTION_DAYS) -> None:
|
|
564
|
-
"""Roll up daily JSONL files older than retention_days into monthly summaries,
|
|
626
|
+
"""Roll up daily JSONL files older than retention_days into monthly summaries,
|
|
627
|
+
then delete them. Computes per-session daily deltas within the rollup pass so
|
|
628
|
+
a session spanning multiple days contributes correctly to each day (rather
|
|
629
|
+
than double-counting cumulative values across days)."""
|
|
565
630
|
if cctrack_dir is None:
|
|
566
631
|
cctrack_dir = CCTRACK_DIR
|
|
567
632
|
if not cctrack_dir.exists():
|
|
@@ -569,12 +634,21 @@ def rollup_old_hook_files(cctrack_dir: Path | None = None,
|
|
|
569
634
|
|
|
570
635
|
cutoff = (datetime.now(timezone.utc) - timedelta(days=retention_days)).strftime("%Y-%m-%d")
|
|
571
636
|
|
|
637
|
+
old_paths: list[tuple[str, Path]] = []
|
|
572
638
|
for path in sorted(cctrack_dir.glob("statusline-*.jsonl")):
|
|
573
639
|
date_str = path.stem.replace("statusline-", "")
|
|
574
640
|
if not date_str or date_str >= cutoff:
|
|
575
641
|
continue
|
|
642
|
+
old_paths.append((date_str, path))
|
|
576
643
|
|
|
577
|
-
|
|
644
|
+
if not old_paths:
|
|
645
|
+
return
|
|
646
|
+
|
|
647
|
+
# Parse all old files into per-(session, date) latest cumulative snapshots.
|
|
648
|
+
by_session_day: dict[tuple[str, str], dict] = {}
|
|
649
|
+
empty_paths: list[Path] = []
|
|
650
|
+
for date_str, path in old_paths:
|
|
651
|
+
had_any_row = False
|
|
578
652
|
try:
|
|
579
653
|
with open(path, "r", errors="replace") as f:
|
|
580
654
|
for line in f:
|
|
@@ -589,35 +663,68 @@ def rollup_old_hook_files(cctrack_dir: Path | None = None,
|
|
|
589
663
|
ts = entry.get("_ts", "")
|
|
590
664
|
if not sid or not ts:
|
|
591
665
|
continue
|
|
592
|
-
|
|
666
|
+
had_any_row = True
|
|
667
|
+
key = (sid, date_str)
|
|
668
|
+
if key in by_session_day and ts <= by_session_day[key].get("_ts", ""):
|
|
593
669
|
continue
|
|
594
|
-
|
|
670
|
+
by_session_day[key] = entry
|
|
595
671
|
except (OSError, IOError):
|
|
596
672
|
continue
|
|
597
673
|
|
|
598
|
-
if not
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
674
|
+
if not had_any_row:
|
|
675
|
+
empty_paths.append(path)
|
|
676
|
+
|
|
677
|
+
# Compute per-session per-day deltas by walking each session chronologically.
|
|
678
|
+
by_session: dict[str, list[tuple[str, dict]]] = defaultdict(list)
|
|
679
|
+
for (sid, date_str), entry in by_session_day.items():
|
|
680
|
+
by_session[sid].append((date_str, entry))
|
|
681
|
+
|
|
682
|
+
day_costs: dict[str, float] = defaultdict(float)
|
|
683
|
+
day_session_ids: dict[str, set] = defaultdict(set)
|
|
684
|
+
|
|
685
|
+
for sid, records in by_session.items():
|
|
686
|
+
records.sort(key=lambda r: r[0])
|
|
687
|
+
prev_cost = 0.0
|
|
688
|
+
for date_str, entry in records:
|
|
689
|
+
cost = float(entry.get("cost_usd", 0.0))
|
|
690
|
+
delta = max(0.0, cost - prev_cost)
|
|
691
|
+
prev_cost = cost
|
|
692
|
+
day_costs[date_str] += delta
|
|
693
|
+
day_session_ids[date_str].add(sid)
|
|
694
|
+
|
|
695
|
+
# Write per-day deltas into the appropriate monthly file and delete dailies.
|
|
696
|
+
by_month: dict[str, dict] = {}
|
|
697
|
+
for date_str in sorted(day_costs.keys()):
|
|
608
698
|
month_str = date_str[:7]
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
699
|
+
if month_str not in by_month:
|
|
700
|
+
monthly_path = cctrack_dir / f"monthly-{month_str}.json"
|
|
701
|
+
if monthly_path.exists():
|
|
702
|
+
try:
|
|
703
|
+
by_month[month_str] = json.loads(monthly_path.read_text())
|
|
704
|
+
except (json.JSONDecodeError, OSError):
|
|
705
|
+
by_month[month_str] = {"month": month_str, "days": {}}
|
|
706
|
+
else:
|
|
707
|
+
by_month[month_str] = {"month": month_str, "days": {}}
|
|
708
|
+
by_month[month_str].setdefault("days", {})
|
|
709
|
+
by_month[month_str]["days"][date_str] = {
|
|
710
|
+
"cost": day_costs[date_str],
|
|
711
|
+
"sessions": len(day_session_ids[date_str]),
|
|
712
|
+
}
|
|
616
713
|
|
|
617
|
-
|
|
714
|
+
for month_str, data in by_month.items():
|
|
715
|
+
monthly_path = cctrack_dir / f"monthly-{month_str}.json"
|
|
716
|
+
try:
|
|
717
|
+
monthly_path.write_text(json.dumps(data, indent=2) + "\n")
|
|
718
|
+
except OSError:
|
|
719
|
+
pass
|
|
618
720
|
|
|
721
|
+
for _, path in old_paths:
|
|
722
|
+
try:
|
|
723
|
+
path.unlink()
|
|
724
|
+
except OSError:
|
|
725
|
+
pass
|
|
726
|
+
for path in empty_paths:
|
|
619
727
|
try:
|
|
620
|
-
monthly_path.write_text(json.dumps(monthly_data, indent=2) + "\n")
|
|
621
728
|
path.unlink()
|
|
622
729
|
except OSError:
|
|
623
730
|
pass
|
|
@@ -625,21 +732,38 @@ def rollup_old_hook_files(cctrack_dir: Path | None = None,
|
|
|
625
732
|
|
|
626
733
|
def aggregate_hook_data(events: list[dict], monthly_summaries: dict | None = None) -> dict:
|
|
627
734
|
"""Aggregate hook events by date, merging with pre-aggregated monthly data.
|
|
628
|
-
Returns {date_str: {cost
|
|
735
|
+
Returns {date_str: {cost, sessions, input_tokens, output_tokens, cache_read, cache_write, models}}.
|
|
736
|
+
Note: cache_read/cache_write come from current_usage (snapshot of the last message's
|
|
737
|
+
context), so aggregating them is not strictly meaningful — treat as indicative only."""
|
|
738
|
+
def _empty() -> dict:
|
|
739
|
+
return {"cost": 0.0, "sessions": 0, "input_tokens": 0, "output_tokens": 0,
|
|
740
|
+
"cache_read": 0, "cache_write": 0, "models": {}}
|
|
741
|
+
|
|
629
742
|
by_date: dict[str, dict] = {}
|
|
630
743
|
|
|
631
744
|
if monthly_summaries:
|
|
632
745
|
for date_str, data in monthly_summaries.items():
|
|
633
|
-
|
|
746
|
+
bucket = _empty()
|
|
747
|
+
bucket["cost"] = data["cost"]
|
|
748
|
+
bucket["sessions"] = data["sessions"]
|
|
749
|
+
by_date[date_str] = bucket
|
|
634
750
|
|
|
635
751
|
recent_dates: set[str] = set()
|
|
636
752
|
for e in events:
|
|
637
753
|
date_str = e["date_str"]
|
|
638
754
|
if date_str not in recent_dates:
|
|
639
|
-
by_date[date_str] =
|
|
755
|
+
by_date[date_str] = _empty()
|
|
640
756
|
recent_dates.add(date_str)
|
|
641
|
-
by_date[date_str]
|
|
642
|
-
|
|
757
|
+
bucket = by_date[date_str]
|
|
758
|
+
bucket["cost"] += e["cost_usd"]
|
|
759
|
+
bucket["sessions"] += 1
|
|
760
|
+
bucket["input_tokens"] += e.get("input_tokens", 0)
|
|
761
|
+
bucket["output_tokens"] += e.get("output_tokens", 0)
|
|
762
|
+
bucket["cache_read"] += e.get("cache_read", 0)
|
|
763
|
+
bucket["cache_write"] += e.get("cache_write", 0)
|
|
764
|
+
model = e.get("model", "")
|
|
765
|
+
if model:
|
|
766
|
+
bucket["models"][model] = bucket["models"].get(model, 0) + 1
|
|
643
767
|
|
|
644
768
|
return by_date
|
|
645
769
|
|
|
@@ -647,14 +771,15 @@ def aggregate_hook_data(events: list[dict], monthly_summaries: dict | None = Non
|
|
|
647
771
|
# ── Aggregation ─────────────────────────────────────────────────────────
|
|
648
772
|
|
|
649
773
|
def new_bucket() -> dict:
|
|
650
|
-
return {"input": 0, "output": 0, "cache_read": 0, "cache_write": 0,
|
|
774
|
+
return {"input": 0, "output": 0, "cache_read": 0, "cache_write": 0,
|
|
775
|
+
"cache_write_1h": 0, "cost": 0.0, "models": {}}
|
|
651
776
|
|
|
652
777
|
|
|
653
778
|
def aggregate(events: list[dict]) -> tuple[dict, dict]:
|
|
654
779
|
"""
|
|
655
780
|
Returns:
|
|
656
|
-
daily: {date_str: {input, output, cache_read, cache_write, cost}}
|
|
657
|
-
monthly: {month_str: {input, output, cache_read, cache_write, cost}}
|
|
781
|
+
daily: {date_str: {input, output, cache_read, cache_write, cache_write_1h, cost, models}}
|
|
782
|
+
monthly: {month_str: {input, output, cache_read, cache_write, cache_write_1h, cost, models}}
|
|
658
783
|
"""
|
|
659
784
|
daily = defaultdict(new_bucket)
|
|
660
785
|
monthly = defaultdict(new_bucket)
|
|
@@ -672,14 +797,20 @@ def aggregate(events: list[dict]) -> tuple[dict, dict]:
|
|
|
672
797
|
date_str = dt.strftime("%Y-%m-%d")
|
|
673
798
|
month_str = dt.strftime("%Y-%m")
|
|
674
799
|
|
|
675
|
-
|
|
800
|
+
cache_1h = e.get("cache_write_1h", 0)
|
|
801
|
+
cost = calculate_cost(e["model"], e["input"], e["output"],
|
|
802
|
+
e["cache_read"], e["cache_write"], cache_1h)
|
|
803
|
+
model = e.get("model", "")
|
|
676
804
|
|
|
677
805
|
for bucket in (daily[date_str], monthly[month_str]):
|
|
678
806
|
bucket["input"] += e["input"]
|
|
679
807
|
bucket["output"] += e["output"]
|
|
680
808
|
bucket["cache_read"] += e["cache_read"]
|
|
681
809
|
bucket["cache_write"] += e["cache_write"]
|
|
810
|
+
bucket["cache_write_1h"] += cache_1h
|
|
682
811
|
bucket["cost"] += cost
|
|
812
|
+
if model:
|
|
813
|
+
bucket["models"][model] = bucket["models"].get(model, 0) + 1
|
|
683
814
|
|
|
684
815
|
return dict(daily), dict(monthly)
|
|
685
816
|
|
|
@@ -690,8 +821,26 @@ def format_tokens(n: int) -> str:
|
|
|
690
821
|
return f"{n:,}"
|
|
691
822
|
|
|
692
823
|
|
|
824
|
+
def format_model(models: dict) -> str:
|
|
825
|
+
"""Format a model counter dict as 'short-name' or 'short-name+N' when
|
|
826
|
+
multiple models appear. Returns empty string for no models.
|
|
827
|
+
Strips the 'claude-' prefix and any trailing YYYYMMDD date suffix."""
|
|
828
|
+
if not models:
|
|
829
|
+
return ""
|
|
830
|
+
ranked = sorted(models.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
831
|
+
primary = ranked[0][0]
|
|
832
|
+
short = primary[len("claude-"):] if primary.startswith("claude-") else primary
|
|
833
|
+
parts = short.split("-")
|
|
834
|
+
if len(parts) > 1 and parts[-1].isdigit() and len(parts[-1]) == 8:
|
|
835
|
+
short = "-".join(parts[:-1])
|
|
836
|
+
if len(ranked) > 1:
|
|
837
|
+
short += f"+{len(ranked) - 1}"
|
|
838
|
+
return short
|
|
839
|
+
|
|
840
|
+
|
|
693
841
|
def total_tokens(b: dict) -> int:
|
|
694
|
-
return b["input"] + b["output"] + b["cache_read"]
|
|
842
|
+
return (b["input"] + b["output"] + b["cache_read"]
|
|
843
|
+
+ b["cache_write"] + b.get("cache_write_1h", 0))
|
|
695
844
|
|
|
696
845
|
|
|
697
846
|
def _compute_accuracy_factor(daily: dict, hook_daily: dict, hook_install_date: str | None) -> tuple[float, bool]:
|
|
@@ -796,12 +945,25 @@ def print_report(daily: dict, monthly: dict, days: int | None = None,
|
|
|
796
945
|
print(f"{current_month_name} — month to date (day {day_of_month}, {days_active} active)")
|
|
797
946
|
print("───────────────────────────────────────")
|
|
798
947
|
if m:
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
if
|
|
948
|
+
hook_in_mtd = sum(hook_daily.get(d, {}).get("input_tokens", 0) for d in current_days_all)
|
|
949
|
+
hook_out_mtd = sum(hook_daily.get(d, {}).get("output_tokens", 0) for d in current_days_all)
|
|
950
|
+
input_t = hook_in_mtd if hook_in_mtd else m["input"]
|
|
951
|
+
output_t = hook_out_mtd if hook_out_mtd else m["output"]
|
|
952
|
+
print(f" Input tokens: {format_tokens(input_t):>15}")
|
|
953
|
+
print(f" Output tokens: {format_tokens(output_t):>15}")
|
|
954
|
+
cache_w_total = m['cache_write'] + m.get('cache_write_1h', 0)
|
|
955
|
+
if m['cache_read'] or cache_w_total:
|
|
802
956
|
print(f" Cache read: {format_tokens(m['cache_read']):>15}")
|
|
803
|
-
print(f" Cache write: {format_tokens(
|
|
804
|
-
|
|
957
|
+
print(f" Cache write: {format_tokens(cache_w_total):>15}")
|
|
958
|
+
total = input_t + output_t + m['cache_read'] + cache_w_total
|
|
959
|
+
print(f" Total tokens: {format_tokens(total):>15}")
|
|
960
|
+
mtd_model = format_model(m.get("models", {}))
|
|
961
|
+
if mtd_model:
|
|
962
|
+
print(f" Model: {mtd_model:>15}")
|
|
963
|
+
|
|
964
|
+
mtd_sessions = sum(hook_daily.get(d, {}).get("sessions", 0) for d in current_days_all)
|
|
965
|
+
if mtd_sessions:
|
|
966
|
+
print(f" Sessions: {mtd_sessions:>15}")
|
|
805
967
|
|
|
806
968
|
if hook_install_date and est_days > 0:
|
|
807
969
|
factor_source = "measured" if factor_is_measured else "estimated ~20-25%"
|
|
@@ -829,26 +991,32 @@ def print_report(daily: dict, monthly: dict, days: int | None = None,
|
|
|
829
991
|
if current_month_days:
|
|
830
992
|
print("Daily breakdown:")
|
|
831
993
|
if hook_install_date:
|
|
832
|
-
print(f" {'Date':<12} {'Input':>
|
|
833
|
-
print(f" {'─'*12} {'─'*
|
|
994
|
+
print(f" {'Date':<12} {'Input':>11} {'Output':>11} {'Cache R':>11} {'Cache W':>11} {'Sess':>5} {'Model':<12} {'Cost':>9} {'':>2}")
|
|
995
|
+
print(f" {'─'*12} {'─'*11} {'─'*11} {'─'*11} {'─'*11} {'─'*5} {'─'*12} {'─'*9} {'─'*2}")
|
|
834
996
|
else:
|
|
835
|
-
print(f" {'Date':<12} {'Input':>
|
|
836
|
-
print(f" {'─'*12} {'─'*
|
|
997
|
+
print(f" {'Date':<12} {'Input':>11} {'Output':>11} {'Cache R':>11} {'Cache W':>11} {'Model':<12} {'Cost':>9}")
|
|
998
|
+
print(f" {'─'*12} {'─'*11} {'─'*11} {'─'*11} {'─'*11} {'─'*12} {'─'*9}")
|
|
837
999
|
display_days = current_month_days
|
|
838
1000
|
if days is not None:
|
|
839
1001
|
display_days = display_days[:days]
|
|
840
1002
|
for date_str in display_days:
|
|
841
1003
|
cost, is_auth = _date_cost(date_str)
|
|
842
1004
|
d = daily.get(date_str)
|
|
843
|
-
|
|
844
|
-
|
|
1005
|
+
hook = hook_daily.get(date_str, {})
|
|
1006
|
+
hook_in = hook.get("input_tokens", 0)
|
|
1007
|
+
hook_out = hook.get("output_tokens", 0)
|
|
1008
|
+
input_t = hook_in if hook_in else (d["input"] if d else 0)
|
|
1009
|
+
output_t = hook_out if hook_out else (d["output"] if d else 0)
|
|
845
1010
|
cache_r = d["cache_read"] if d else 0
|
|
846
|
-
cache_w = d["cache_write"] if d else 0
|
|
1011
|
+
cache_w = (d["cache_write"] + d.get("cache_write_1h", 0)) if d else 0
|
|
1012
|
+
model_str = format_model(d.get("models", {})) if d else format_model(hook.get("models", {}))
|
|
847
1013
|
if hook_install_date:
|
|
848
1014
|
marker = "" if is_auth else "~"
|
|
849
|
-
|
|
1015
|
+
sessions = hook.get("sessions", 0)
|
|
1016
|
+
sess_str = str(sessions) if sessions else "-"
|
|
1017
|
+
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
1018
|
else:
|
|
851
|
-
print(f" {date_str:<12} {format_tokens(input_t):>
|
|
1019
|
+
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
1020
|
|
|
853
1021
|
if hook_install_date and current_month_days:
|
|
854
1022
|
has_estimated = any(not _date_cost(d)[1] for d in current_month_days)
|
|
@@ -863,8 +1031,8 @@ def print_report(daily: dict, monthly: dict, days: int | None = None,
|
|
|
863
1031
|
if prev_months:
|
|
864
1032
|
print()
|
|
865
1033
|
print("Previous months:")
|
|
866
|
-
print(f" {'Month':<12} {'Input':>
|
|
867
|
-
print(f" {'─'*12} {'─'*
|
|
1034
|
+
print(f" {'Month':<12} {'Input':>11} {'Output':>11} {'Cache R':>11} {'Cache W':>11} {'Model':<12} {'Cost':>9}")
|
|
1035
|
+
print(f" {'─'*12} {'─'*11} {'─'*11} {'─'*11} {'─'*11} {'─'*12} {'─'*9}")
|
|
868
1036
|
for month_str in prev_months:
|
|
869
1037
|
m = monthly.get(month_str)
|
|
870
1038
|
month_days_list = [d for d in prev_month_days if d.startswith(month_str)]
|
|
@@ -890,12 +1058,13 @@ def print_report(daily: dict, monthly: dict, days: int | None = None,
|
|
|
890
1058
|
input_t = m["input"] if m else 0
|
|
891
1059
|
output_t = m["output"] if m else 0
|
|
892
1060
|
cache_r = m["cache_read"] if m else 0
|
|
893
|
-
cache_w = m["cache_write"] if m else 0
|
|
1061
|
+
cache_w = (m["cache_write"] + m.get("cache_write_1h", 0)) if m else 0
|
|
1062
|
+
model_str = format_model(m.get("models", {})) if m else ""
|
|
894
1063
|
|
|
895
|
-
cost_str = f"${month_cost:>
|
|
1064
|
+
cost_str = f"${month_cost:>7.2f}"
|
|
896
1065
|
if hook_install_date and month_has_est and not month_has_hook:
|
|
897
1066
|
cost_str += " ~"
|
|
898
|
-
print(f" {month_str + days_str:<12} {format_tokens(input_t):>
|
|
1067
|
+
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
1068
|
|
|
900
1069
|
print()
|
|
901
1070
|
|
|
@@ -975,7 +1144,7 @@ def main():
|
|
|
975
1144
|
all_monthly: dict[str, dict] = {}
|
|
976
1145
|
for hdir in hook_dirs:
|
|
977
1146
|
rollup_old_hook_files(hdir)
|
|
978
|
-
all_hook_events.extend(parse_hook_events(hdir))
|
|
1147
|
+
all_hook_events.extend(compute_hook_deltas(parse_hook_events(hdir)))
|
|
979
1148
|
for date_str, data in load_monthly_hook_summaries(hdir).items():
|
|
980
1149
|
if date_str in all_monthly:
|
|
981
1150
|
all_monthly[date_str]["cost"] += data["cost"]
|
|
@@ -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,106 @@ 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
|
+
|
|
162
262
|
# ── aggregate_hook_data tests ──────────────────────────────────────────
|
|
163
263
|
|
|
164
264
|
def test_aggregate_hook_data_single_day():
|
|
@@ -316,6 +416,35 @@ def test_rollup_appends_to_existing_monthly():
|
|
|
316
416
|
assert data["days"]["2025-12-02"]["cost"] == 7.00
|
|
317
417
|
|
|
318
418
|
|
|
419
|
+
def test_rollup_cross_day_session_attributes_deltas():
|
|
420
|
+
"""A session active across multiple rolled-up days should have its
|
|
421
|
+
cumulative cost attributed as per-day deltas, not double-counted."""
|
|
422
|
+
with tempfile.TemporaryDirectory() as d:
|
|
423
|
+
# Session sA: cumulative rises 0 → 5 on day 1, 5 → 12 on day 2.
|
|
424
|
+
# Session sB: only active on day 2, cumulative rises 0 → 3.
|
|
425
|
+
write_hook_jsonl(d, [
|
|
426
|
+
make_hook_entry(session_id="sA", ts="2025-12-01T10:00:00+00:00", cost_usd=2.00),
|
|
427
|
+
make_hook_entry(session_id="sA", ts="2025-12-01T18:00:00+00:00", cost_usd=5.00),
|
|
428
|
+
], date="2025-12-01")
|
|
429
|
+
write_hook_jsonl(d, [
|
|
430
|
+
make_hook_entry(session_id="sA", ts="2025-12-02T10:00:00+00:00", cost_usd=12.00),
|
|
431
|
+
make_hook_entry(session_id="sB", ts="2025-12-02T11:00:00+00:00", cost_usd=3.00),
|
|
432
|
+
], date="2025-12-02")
|
|
433
|
+
|
|
434
|
+
cctrack.rollup_old_hook_files(cctrack_dir=Path(d), retention_days=0)
|
|
435
|
+
|
|
436
|
+
data = json.loads(Path(d, "monthly-2025-12.json").read_text())
|
|
437
|
+
# Day 1: sA delta = 5.00. Total = 5.00, 1 session.
|
|
438
|
+
assert abs(data["days"]["2025-12-01"]["cost"] - 5.00) < 0.001
|
|
439
|
+
assert data["days"]["2025-12-01"]["sessions"] == 1
|
|
440
|
+
# Day 2: sA delta = 12 - 5 = 7.00, sB delta = 3.00. Total = 10.00, 2 sessions.
|
|
441
|
+
assert abs(data["days"]["2025-12-02"]["cost"] - 10.00) < 0.001
|
|
442
|
+
assert data["days"]["2025-12-02"]["sessions"] == 2
|
|
443
|
+
# Sum equals actual session totals: sA $12 + sB $3 = $15.
|
|
444
|
+
total = sum(day["cost"] for day in data["days"].values())
|
|
445
|
+
assert abs(total - 15.00) < 0.001
|
|
446
|
+
|
|
447
|
+
|
|
319
448
|
def test_rollup_deletes_empty_daily():
|
|
320
449
|
"""Daily file with no valid sessions is just deleted."""
|
|
321
450
|
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
|
|
File without changes
|