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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cctrack
3
- Version: 0.3.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cctrack"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Claude Code cost tracker — scans JSONL logs and reports token usage + estimated cost"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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, cache_write: int) -> float:
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
- if input_t == 0 and output_t == 0 and cache_read == 0 and cache_write == 0:
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. Dedup by session_id (last entry by _ts wins).
475
- Also reads legacy statusline.jsonl for migration.
476
- Returns list of {date_str, cost_usd, input_tokens, output_tokens, cache_read, cache_write, model, session_id}."""
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
- by_session: dict[str, dict] = {}
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
- if session_id in by_session:
510
- existing_ts = by_session[session_id].get("_ts", "")
511
- if ts <= existing_ts:
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
- by_session[session_id] = entry
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 by_session.items():
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": dt.strftime("%Y-%m-%d"),
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, then delete them."""
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
- by_session: dict[str, dict] = {}
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
- if sid in by_session and ts <= by_session[sid].get("_ts", ""):
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
- by_session[sid] = entry
670
+ by_session_day[key] = entry
595
671
  except (OSError, IOError):
596
672
  continue
597
673
 
598
- if not by_session:
599
- try:
600
- path.unlink()
601
- except OSError:
602
- pass
603
- continue
604
-
605
- day_cost = sum(float(e.get("cost_usd", 0.0)) for e in by_session.values())
606
- day_sessions = len(by_session)
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
- monthly_path = cctrack_dir / f"monthly-{month_str}.json"
610
- monthly_data: dict = {"month": month_str, "days": {}}
611
- if monthly_path.exists():
612
- try:
613
- monthly_data = json.loads(monthly_path.read_text())
614
- except (json.JSONDecodeError, OSError):
615
- monthly_data = {"month": month_str, "days": {}}
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
- monthly_data["days"][date_str] = {"cost": day_cost, "sessions": day_sessions}
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: float, sessions: int}}."""
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
- by_date[date_str] = {"cost": data["cost"], "sessions": data["sessions"]}
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] = {"cost": 0.0, "sessions": 0}
755
+ by_date[date_str] = _empty()
640
756
  recent_dates.add(date_str)
641
- by_date[date_str]["cost"] += e["cost_usd"]
642
- by_date[date_str]["sessions"] += 1
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, "cost": 0.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
- cost = calculate_cost(e["model"], e["input"], e["output"], e["cache_read"], e["cache_write"])
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"] + b["cache_write"]
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
- print(f" Input tokens: {format_tokens(m['input']):>15}")
800
- print(f" Output tokens: {format_tokens(m['output']):>15}")
801
- if m['cache_read'] or m['cache_write']:
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(m['cache_write']):>15}")
804
- print(f" Total tokens: {format_tokens(total_tokens(m)):>15}")
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':>12} {'Output':>12} {'Cache R':>12} {'Cache W':>12} {'Cost':>10} {'':>3}")
833
- print(f" {'─'*12} {'─'*12} {'─'*12} {'─'*12} {'─'*12} {'─'*10} {'─'*3}")
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':>12} {'Output':>12} {'Cache R':>12} {'Cache W':>12} {'Cost':>10}")
836
- print(f" {'─'*12} {'─'*12} {'─'*12} {'─'*12} {'─'*12} {'─'*10}")
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
- input_t = d["input"] if d else 0
844
- output_t = d["output"] if d else 0
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
- print(f" {date_str:<12} {format_tokens(input_t):>12} {format_tokens(output_t):>12} {format_tokens(cache_r):>12} {format_tokens(cache_w):>12} ${cost:>8.2f} {marker:>3}")
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):>12} {format_tokens(output_t):>12} {format_tokens(cache_r):>12} {format_tokens(cache_w):>12} ${cost:>8.2f}")
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':>12} {'Output':>12} {'Cache R':>12} {'Cache W':>12} {'Cost':>10}")
867
- print(f" {'─'*12} {'─'*12} {'─'*12} {'─'*12} {'─'*12} {'─'*10}")
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:>8.2f}"
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):>12} {format_tokens(output_t):>12} {format_tokens(cache_r):>12} {format_tokens(cache_w):>12} {cost_str}")
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