cctally 1.7.0 → 1.7.1

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.
@@ -0,0 +1,194 @@
1
+ """Alert-payload constructors and notification text builders.
2
+
3
+ Pure-fn layer (no I/O at import time): holds the deterministic helpers that
4
+ shape an alert's structured payload (`_build_alert_payload_*`) and render
5
+ its (title, subtitle, body) triple (`_alert_text_*`), plus the AppleScript
6
+ string-escape used to embed the rendered triple in an `osascript` literal
7
+ (`_escape_applescript_string`).
8
+
9
+ The companion I/O surface — `_alerts_log_path`, `_dispatch_alert_notification`,
10
+ the alerts-config validators — stays in `bin/cctally` because it touches
11
+ the filesystem (mkdir + append-log), spawns subprocesses, and reads
12
+ `os.environ` for the integration-harness escape hatch.
13
+
14
+ Cross-sibling dependency: `_alert_text_five_hour` calls `format_display_dt`,
15
+ which lives in `_lib_display_tz`. We load it via a local `_load_lib` helper
16
+ (spec_from_file_location, same shape as `bin/cctally:_load_sibling`) so this
17
+ pure layer remains independent of `bin/`'s sys.path posture and free of any
18
+ back-import of `cctally`.
19
+
20
+ `bin/cctally` re-exports every public symbol below so internal call sites
21
+ and `SourceFileLoader`-based tests (e.g. `bin/cctally-alerts-dispatch-test`)
22
+ resolve unchanged. A private `_eprint` duplicates `bin/cctally:eprint` per
23
+ the split design's §5.3 contract.
24
+
25
+ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import datetime as dt
30
+ import pathlib
31
+ import sys
32
+ from typing import Any
33
+
34
+ from zoneinfo import ZoneInfo
35
+
36
+
37
+ def _eprint(*args: Any) -> None:
38
+ print(*args, file=sys.stderr)
39
+
40
+
41
+ def _load_lib(name: str):
42
+ cached = sys.modules.get(name)
43
+ if cached is not None:
44
+ return cached
45
+ import importlib.util as _ilu
46
+ p = pathlib.Path(__file__).resolve().parent / f"{name}.py"
47
+ spec = _ilu.spec_from_file_location(name, p)
48
+ mod = _ilu.module_from_spec(spec)
49
+ sys.modules[name] = mod
50
+ spec.loader.exec_module(mod)
51
+ return mod
52
+
53
+
54
+ _lib_display_tz = _load_lib("_lib_display_tz")
55
+ format_display_dt = _lib_display_tz.format_display_dt
56
+
57
+
58
+ def _alert_text_weekly(payload: dict, tz: "ZoneInfo | None") -> tuple[str, str, str]:
59
+ """Build (title, subtitle, body) for a weekly threshold alert.
60
+
61
+ Most datetime renders go through ``format_display_dt`` (chokepoint
62
+ rule), but ``week_start_date`` is a CALENDAR DAY (``YYYY-MM-DD``),
63
+ not a clock instant — tagging it as UTC-midnight and then routing
64
+ through ``format_display_dt(..., tz)`` would shift the wall-clock
65
+ day for non-UTC ``display.tz`` (e.g. "Sun, Apr 27" → "Sat, Apr 26"
66
+ in ``America/Los_Angeles``). Render the date directly via
67
+ ``dt.date.fromisoformat`` so the rendered weekday/day matches the
68
+ calendar date the user thinks of as "this week".
69
+ """
70
+ threshold = int(payload["threshold"])
71
+ title = f"cctally - Weekly usage {threshold}% reached"
72
+ ctx = payload.get("context") or {}
73
+ week_start_date = ctx.get("week_start_date")
74
+ if week_start_date:
75
+ # Calendar-day render: ``week_start_date`` is a date, not an
76
+ # instant; bypass tz conversion to avoid the off-by-one shift
77
+ # documented above. ``tz`` is accepted for signature parity
78
+ # with peer alert builders and intentionally unused here.
79
+ subtitle = "Week starting " + dt.date.fromisoformat(
80
+ week_start_date
81
+ ).strftime("%a, %b %d")
82
+ else:
83
+ subtitle = "Current week"
84
+ cumulative = float(ctx.get("cumulative_cost_usd") or 0.0)
85
+ dpp = ctx.get("dollars_per_percent")
86
+ if dpp is not None:
87
+ body = f"${cumulative:.2f} spent so far - ${float(dpp):.2f} per 1%"
88
+ else:
89
+ body = f"${cumulative:.2f} spent so far"
90
+ return title, subtitle, body
91
+
92
+
93
+ def _alert_text_five_hour(payload: dict, tz: "ZoneInfo | None") -> tuple[str, str, str]:
94
+ """Build (title, subtitle, body) for a 5h-block threshold alert.
95
+
96
+ All datetime renders go through ``format_display_dt`` (chokepoint rule).
97
+ """
98
+ threshold = int(payload["threshold"])
99
+ title = f"cctally - 5h-block usage {threshold}% reached"
100
+ ctx = payload.get("context") or {}
101
+ bsa_iso = ctx.get("block_start_at")
102
+ if bsa_iso:
103
+ bsa = dt.datetime.fromisoformat(str(bsa_iso).replace("Z", "+00:00"))
104
+ subtitle = "Block started " + format_display_dt(
105
+ bsa, tz, fmt="%H:%M", suffix=False
106
+ )
107
+ else:
108
+ subtitle = "Current 5h block"
109
+ cost = float(ctx.get("block_cost_usd") or 0.0)
110
+ model = ctx.get("primary_model")
111
+ if model:
112
+ body = f"${cost:.2f} spent in this block - current model: {model}"
113
+ else:
114
+ body = f"${cost:.2f} spent in this block"
115
+ return title, subtitle, body
116
+
117
+
118
+ def _escape_applescript_string(s: str) -> str:
119
+ """Escape ``s`` for embedding inside an AppleScript double-quoted literal.
120
+
121
+ Order matters: backslashes first (otherwise the inserted backslashes
122
+ from the double-quote escape get re-escaped), then double quotes,
123
+ then newlines/CRs collapse to spaces (AppleScript chokes on raw NL).
124
+ """
125
+ return (
126
+ s.replace("\\", "\\\\")
127
+ .replace('"', '\\"')
128
+ .replace("\n", " ")
129
+ .replace("\r", " ")
130
+ )
131
+
132
+
133
+ def _build_alert_payload_weekly(
134
+ *,
135
+ threshold: int,
136
+ crossed_at_utc: str,
137
+ week_start_date: str,
138
+ cumulative_cost_usd: float,
139
+ dollars_per_percent: "float | None",
140
+ ) -> dict:
141
+ """Build the alert payload for a weekly threshold crossing.
142
+
143
+ ``alerted_at`` mirrors ``crossed_at`` here because the production caller
144
+ sets the DB ``alerted_at`` BEFORE invoking ``_dispatch_alert_notification``
145
+ (set-then-dispatch invariant, spec §3.2). Consumers (envelope builders,
146
+ test inspectors) read the ``alerted_at`` field as the authoritative
147
+ "alert was attempted" timestamp.
148
+ """
149
+ return {
150
+ "id": f"weekly:{week_start_date}:{threshold}",
151
+ "axis": "weekly",
152
+ "threshold": int(threshold),
153
+ "crossed_at": crossed_at_utc,
154
+ "alerted_at": crossed_at_utc, # set-then-dispatch
155
+ "context": {
156
+ "week_start_date": week_start_date,
157
+ "cumulative_cost_usd": float(cumulative_cost_usd),
158
+ "dollars_per_percent": (
159
+ float(dollars_per_percent) if dollars_per_percent is not None else None
160
+ ),
161
+ },
162
+ }
163
+
164
+
165
+ def _build_alert_payload_five_hour(
166
+ *,
167
+ threshold: int,
168
+ crossed_at_utc: str,
169
+ five_hour_window_key: int,
170
+ block_start_at: str,
171
+ block_cost_usd: float,
172
+ primary_model: "str | None",
173
+ ) -> dict:
174
+ """Build the alert payload for a 5h-block threshold crossing.
175
+
176
+ See ``_build_alert_payload_weekly`` for the ``alerted_at == crossed_at``
177
+ rationale. ``primary_model`` is the highest-cost model active in the
178
+ block (resolved via ``_resolve_primary_model_for_block``); ``None`` when
179
+ the rollup-children child table is empty (e.g., direct-JSONL-fallback
180
+ path before lazy backfill).
181
+ """
182
+ return {
183
+ "id": f"five_hour:{five_hour_window_key}:{threshold}",
184
+ "axis": "five_hour",
185
+ "threshold": int(threshold),
186
+ "crossed_at": crossed_at_utc,
187
+ "alerted_at": crossed_at_utc, # set-then-dispatch
188
+ "context": {
189
+ "five_hour_window_key": int(five_hour_window_key),
190
+ "block_start_at": block_start_at,
191
+ "block_cost_usd": float(block_cost_usd),
192
+ "primary_model": primary_model,
193
+ },
194
+ }
@@ -0,0 +1,414 @@
1
+ """5-hour activity block grouping + JSON serialization.
2
+
3
+ Pure-fn layer (no I/O at import time): holds the `Block` dataclass and
4
+ the four helpers that group a sorted `UsageEntry` list into per-block
5
+ aggregates (`_aggregate_block`), assemble a non-gap block from those
6
+ aggregates (`_build_activity_block`), drive the recorded + heuristic
7
+ grouping pass (`_group_entries_into_blocks`), and serialize the result
8
+ to ccusage-compatible JSON (`_blocks_to_json`).
9
+
10
+ The 5-hour window constant (`BLOCK_DURATION`) and the hour-floor helper
11
+ (`_floor_to_hour`) move along with the rest of the block-grouping
12
+ domain: both are referenced from non-extracted callers in bin/cctally
13
+ (week-reset bookkeeping, dashboard plumbing) which now resolve them
14
+ through the re-export block — same identity, same value, zero behavior
15
+ change.
16
+
17
+ Sibling dependencies (loaded at module-load time via `_load_lib`):
18
+ * `_lib_jsonl.UsageEntry` — typing + the dataclass the aggregator
19
+ iterates over.
20
+ * `_lib_pricing._calculate_entry_cost` — per-entry cost computation
21
+ inside `_aggregate_block`.
22
+
23
+ This is a pure-domain leaf in the sibling graph; **zero call-time
24
+ back-references to `bin/cctally`**. No `_cctally()` accessor needed.
25
+
26
+ `bin/cctally` re-exports every public symbol below so the ~10 internal
27
+ call sites + SourceFileLoader-based tests
28
+ (`tests/test_dashboard_api_block`, `tests/test_blocks_recorded_anchor`)
29
+ resolve unchanged.
30
+
31
+ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import bisect
36
+ import datetime as dt
37
+ import json
38
+ import pathlib
39
+ import sys
40
+ from dataclasses import dataclass
41
+ from typing import Any
42
+
43
+
44
+ def _load_lib(name: str):
45
+ cached = sys.modules.get(name)
46
+ if cached is not None:
47
+ return cached
48
+ import importlib.util as _ilu
49
+ p = pathlib.Path(__file__).resolve().parent / f"{name}.py"
50
+ spec = _ilu.spec_from_file_location(name, p)
51
+ mod = _ilu.module_from_spec(spec)
52
+ sys.modules[name] = mod
53
+ spec.loader.exec_module(mod)
54
+ return mod
55
+
56
+
57
+ _lib_jsonl = _load_lib("_lib_jsonl")
58
+ UsageEntry = _lib_jsonl.UsageEntry
59
+
60
+ _lib_pricing = _load_lib("_lib_pricing")
61
+ _calculate_entry_cost = _lib_pricing._calculate_entry_cost
62
+
63
+
64
+ @dataclass
65
+ class Block:
66
+ start_time: dt.datetime # Block start, floored to hour
67
+ end_time: dt.datetime # start_time + 5h
68
+ actual_end_time: dt.datetime | None # Timestamp of last entry
69
+ is_active: bool
70
+ is_gap: bool
71
+ entries_count: int
72
+ input_tokens: int
73
+ output_tokens: int
74
+ cache_creation_tokens: int
75
+ cache_read_tokens: int
76
+ total_tokens: int
77
+ cost_usd: float
78
+ models: list[str] # Full model names (e.g. "claude-opus-4-6")
79
+ burn_rate: dict[str, float] | None
80
+ projection: dict[str, Any] | None
81
+ anchor: str = "heuristic" # "recorded" | "heuristic" — gap rows keep default
82
+
83
+
84
+ def _floor_to_hour(ts: dt.datetime) -> dt.datetime:
85
+ """Floor a datetime to the start of its hour."""
86
+ return ts.replace(minute=0, second=0, microsecond=0)
87
+
88
+
89
+ BLOCK_DURATION = dt.timedelta(hours=5)
90
+
91
+
92
+ def _group_entries_into_blocks(
93
+ entries: list[UsageEntry],
94
+ mode: str = "auto",
95
+ *,
96
+ recorded_windows: list[dt.datetime] | None = None,
97
+ now: dt.datetime | None = None,
98
+ ) -> list[Block]:
99
+ """Group sorted UsageEntry objects into 5-hour blocks with gap detection.
100
+
101
+ Returns a list of Block objects (activity blocks and gap blocks interleaved).
102
+ The last block is marked active if now < block_start + 5h.
103
+
104
+ When `recorded_windows` is non-empty, entries whose timestamp falls in
105
+ [R - BLOCK_DURATION, R) for some R in recorded_windows are partitioned
106
+ into per-R buckets and built as 'recorded' blocks. Leftover entries
107
+ run through the existing gap-detection heuristic (anchor='heuristic').
108
+
109
+ `now` pins the current instant (typically via `_command_as_of()`). When
110
+ omitted, falls back to wall clock so existing callers are unaffected.
111
+ """
112
+ if not entries:
113
+ return []
114
+
115
+ entries_sorted = sorted(entries, key=lambda e: e.timestamp)
116
+ if now is None:
117
+ now = dt.datetime.now(dt.timezone.utc)
118
+
119
+ recorded_windows = sorted(recorded_windows or [])
120
+
121
+ # ── Partition entries by recorded windows ──────────────────────────
122
+ # For each R in recorded_windows, entries whose timestamp falls in
123
+ # [R - BLOCK_DURATION, R) go into recorded_buckets[R]. Everything else
124
+ # (gaps between recorded windows, or fully outside any window) drops
125
+ # into `leftover` and runs through the existing heuristic grouper.
126
+ # Task 5 will consume recorded_buckets; for now it is built but unused.
127
+ recorded_buckets: dict[dt.datetime, list[UsageEntry]] = {
128
+ R: [] for R in recorded_windows
129
+ }
130
+ leftover: list[UsageEntry] = []
131
+ for entry in entries_sorted:
132
+ idx = bisect.bisect_right(recorded_windows, entry.timestamp)
133
+ if idx < len(recorded_windows):
134
+ R = recorded_windows[idx]
135
+ if R - BLOCK_DURATION <= entry.timestamp:
136
+ recorded_buckets[R].append(entry)
137
+ continue
138
+ leftover.append(entry)
139
+
140
+ # Phase 1: Group leftover entries into raw activity blocks
141
+ raw_blocks: list[dict[str, Any]] = []
142
+ current_block_start: dt.datetime | None = None
143
+ current_block_end: dt.datetime | None = None
144
+ current_entries: list[UsageEntry] = []
145
+
146
+ for entry in leftover:
147
+ if current_block_end is None or entry.timestamp >= current_block_end:
148
+ # Flush previous block
149
+ if current_entries and current_block_start is not None and current_block_end is not None:
150
+ raw_blocks.append({
151
+ "start": current_block_start,
152
+ "end": current_block_end,
153
+ "entries": current_entries,
154
+ })
155
+ # Start new block
156
+ current_block_start = _floor_to_hour(entry.timestamp)
157
+ current_block_end = current_block_start + BLOCK_DURATION
158
+ current_entries = [entry]
159
+ else:
160
+ current_entries.append(entry)
161
+
162
+ # Flush last block
163
+ if current_entries and current_block_start is not None and current_block_end is not None:
164
+ raw_blocks.append({
165
+ "start": current_block_start,
166
+ "end": current_block_end,
167
+ "entries": current_entries,
168
+ })
169
+
170
+ # Clamp each raw_block's end so it cannot overlap a later recorded
171
+ # window. Entries in `leftover` are by construction earlier than the
172
+ # next recorded R - 5h boundary, so the heuristic block belongs to a
173
+ # PREVIOUS 5h window that ended no later than that boundary. Without
174
+ # this clamp, the +5h heuristic span can cross into the recorded
175
+ # window and produce two simultaneously-active rows.
176
+ if recorded_windows:
177
+ for rb in raw_blocks:
178
+ idx = bisect.bisect_right(recorded_windows, rb["start"])
179
+ if idx < len(recorded_windows):
180
+ next_R_start = recorded_windows[idx] - BLOCK_DURATION
181
+ if rb["start"] < next_R_start < rb["end"]:
182
+ rb["end"] = next_R_start
183
+
184
+ # Track the "actual first entry timestamp" for each block so Phase 3
185
+ # can compute gap ends the same way the legacy interleaved code did
186
+ # (gap.end_time = first-entry-timestamp of the next block, not the
187
+ # floor-to-hour window start). Maps id(block) -> actual first ts.
188
+ first_entry_ts_by_block: dict[int, dt.datetime] = {}
189
+
190
+ # Phase 1.5: Build recorded Block objects from non-empty buckets
191
+ recorded_block_objs: list[Block] = []
192
+ for R in recorded_windows:
193
+ bucket = recorded_buckets[R]
194
+ if not bucket:
195
+ continue
196
+ start_time = R - BLOCK_DURATION
197
+ end_time = R
198
+ bucket_sorted = sorted(bucket, key=lambda e: e.timestamp)
199
+ blk = _build_activity_block(
200
+ bucket_sorted, start_time, end_time, now, mode,
201
+ anchor="recorded",
202
+ )
203
+ first_entry_ts_by_block[id(blk)] = bucket_sorted[0].timestamp
204
+ recorded_block_objs.append(blk)
205
+
206
+ # Phase 2: Build heuristic Block objects from raw_blocks using _aggregate_block
207
+ heuristic_block_objs: list[Block] = []
208
+ for rb in raw_blocks:
209
+ block_entries = rb["entries"]
210
+ start_time = rb["start"]
211
+ end_time = rb["end"]
212
+ blk = _build_activity_block(
213
+ block_entries, start_time, end_time, now, mode,
214
+ anchor="heuristic",
215
+ )
216
+ if block_entries:
217
+ first_entry_ts_by_block[id(blk)] = block_entries[0].timestamp
218
+ heuristic_block_objs.append(blk)
219
+
220
+ # Merge + sort by start_time
221
+ all_blocks = sorted(
222
+ recorded_block_objs + heuristic_block_objs,
223
+ key=lambda b: b.start_time,
224
+ )
225
+
226
+ # Phase 3: Gap-row insertion as a post-pass over the merged list.
227
+ # Preserves legacy gap semantics: gap.start = prev.actual_end_time,
228
+ # gap.end = first-entry-timestamp of next block (NOT floor-to-hour).
229
+ final_blocks: list[Block] = []
230
+ for i, b in enumerate(all_blocks):
231
+ if i > 0:
232
+ prev = all_blocks[i - 1]
233
+ if prev.end_time < b.start_time:
234
+ first_entry_ts = first_entry_ts_by_block.get(id(b), b.start_time)
235
+ prev_actual_end = prev.actual_end_time or prev.end_time
236
+ final_blocks.append(Block(
237
+ start_time=prev_actual_end,
238
+ end_time=first_entry_ts,
239
+ actual_end_time=None,
240
+ is_active=False,
241
+ is_gap=True,
242
+ entries_count=0,
243
+ input_tokens=0,
244
+ output_tokens=0,
245
+ cache_creation_tokens=0,
246
+ cache_read_tokens=0,
247
+ total_tokens=0,
248
+ cost_usd=0.0,
249
+ models=[],
250
+ burn_rate=None,
251
+ projection=None,
252
+ ))
253
+ final_blocks.append(b)
254
+
255
+ return final_blocks
256
+
257
+
258
+ def _aggregate_block(
259
+ entries: list[UsageEntry],
260
+ start_time: dt.datetime,
261
+ end_time: dt.datetime,
262
+ now: dt.datetime,
263
+ mode: str,
264
+ ) -> dict[str, Any]:
265
+ """Aggregate token / cost / burn / projection for one block's entries.
266
+
267
+ Pure function — no I/O. Shared by the recorded-block path and the
268
+ heuristic-block path in `_group_entries_into_blocks` so per-block
269
+ math stays in one place.
270
+
271
+ Returns a dict with keys:
272
+ input_tokens, output_tokens, cache_creation_tokens,
273
+ cache_read_tokens, total_tokens, cost_usd, models,
274
+ burn_rate (dict|None), projection (dict|None)
275
+ """
276
+ total_input = 0
277
+ total_output = 0
278
+ total_cc = 0
279
+ total_cr = 0
280
+ total_cost = 0.0
281
+ model_set: set[str] = set()
282
+ for entry in entries:
283
+ usage = entry.usage
284
+ total_input += usage.get("input_tokens", 0)
285
+ total_output += usage.get("output_tokens", 0)
286
+ total_cc += usage.get("cache_creation_input_tokens", 0)
287
+ total_cr += usage.get("cache_read_input_tokens", 0)
288
+ total_cost += _calculate_entry_cost(
289
+ entry.model, usage, mode=mode, cost_usd=entry.cost_usd,
290
+ )
291
+ model_set.add(entry.model)
292
+ total_tokens = total_input + total_output + total_cc + total_cr
293
+
294
+ burn_rate = None
295
+ projection = None
296
+ is_active = now < end_time
297
+ if is_active:
298
+ elapsed = (now - start_time).total_seconds()
299
+ elapsed_minutes = elapsed / 60.0
300
+ remaining_seconds = (end_time - now).total_seconds()
301
+ remaining_minutes = max(remaining_seconds / 60.0, 0)
302
+ if elapsed_minutes > 0:
303
+ tokens_per_minute = total_tokens / elapsed_minutes
304
+ cost_per_hour = (total_cost / elapsed_minutes) * 60
305
+ burn_rate = {
306
+ "tokensPerMinute": tokens_per_minute,
307
+ "costPerHour": cost_per_hour,
308
+ }
309
+ total_block_minutes = BLOCK_DURATION.total_seconds() / 60.0
310
+ projected_tokens = tokens_per_minute * total_block_minutes
311
+ projected_cost = (cost_per_hour / 60.0) * total_block_minutes
312
+ projection = {
313
+ "totalTokens": int(projected_tokens),
314
+ "totalCost": round(projected_cost, 2),
315
+ "remainingMinutes": int(remaining_minutes),
316
+ }
317
+
318
+ return {
319
+ "input_tokens": total_input,
320
+ "output_tokens": total_output,
321
+ "cache_creation_tokens": total_cc,
322
+ "cache_read_tokens": total_cr,
323
+ "total_tokens": total_tokens,
324
+ "cost_usd": total_cost,
325
+ "models": sorted(model_set),
326
+ "burn_rate": burn_rate,
327
+ "projection": projection,
328
+ }
329
+
330
+
331
+ def _build_activity_block(
332
+ entries: list[UsageEntry],
333
+ start_time: dt.datetime,
334
+ end_time: dt.datetime,
335
+ now: dt.datetime,
336
+ mode: str,
337
+ *,
338
+ anchor: str,
339
+ ) -> Block:
340
+ """Build a non-gap Block from a pre-sorted entries list.
341
+
342
+ Shared by the recorded-window path (anchor='recorded') and the
343
+ heuristic-grouping path (anchor='heuristic') inside
344
+ `_group_entries_into_blocks`. Keeps per-block field assembly in one
345
+ place so the two builder sites cannot drift.
346
+
347
+ `entries` may be empty; `actual_end_time` is `None` in that case
348
+ (mirrors the legacy heuristic behavior). Callers that need to
349
+ populate the gap-row side-map (first-entry timestamp) do so on the
350
+ returned Block — that side-map write is deliberately left at the
351
+ call site so each caller can gate it on its own emptiness rule.
352
+ """
353
+ agg = _aggregate_block(entries, start_time, end_time, now, mode)
354
+ return Block(
355
+ start_time=start_time,
356
+ end_time=end_time,
357
+ actual_end_time=entries[-1].timestamp if entries else None,
358
+ is_active=now < end_time,
359
+ is_gap=False,
360
+ entries_count=len(entries),
361
+ input_tokens=agg["input_tokens"],
362
+ output_tokens=agg["output_tokens"],
363
+ cache_creation_tokens=agg["cache_creation_tokens"],
364
+ cache_read_tokens=agg["cache_read_tokens"],
365
+ total_tokens=agg["total_tokens"],
366
+ cost_usd=agg["cost_usd"],
367
+ models=agg["models"],
368
+ burn_rate=agg["burn_rate"],
369
+ projection=agg["projection"],
370
+ anchor=anchor,
371
+ )
372
+
373
+
374
+ def _blocks_to_json(blocks: list[Block]) -> str:
375
+ """Serialize blocks to JSON matching upstream ccusage's output structure."""
376
+
377
+ def _iso_utc(ts: dt.datetime) -> str:
378
+ return ts.astimezone(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.") + \
379
+ f"{ts.astimezone(dt.timezone.utc).microsecond // 1000:03d}Z"
380
+
381
+ result = []
382
+ for block in blocks:
383
+ if block.is_gap:
384
+ block_id = f"gap-{_iso_utc(block.start_time)}"
385
+ else:
386
+ block_id = _iso_utc(block.start_time)
387
+
388
+ obj: dict[str, Any] = {
389
+ "id": block_id,
390
+ "startTime": _iso_utc(block.start_time),
391
+ "endTime": _iso_utc(block.end_time),
392
+ "actualEndTime": _iso_utc(block.actual_end_time) if block.actual_end_time else None,
393
+ "isActive": block.is_active,
394
+ "isGap": block.is_gap,
395
+ }
396
+ if not block.is_gap:
397
+ obj["anchor"] = block.anchor
398
+ obj.update({
399
+ "entries": block.entries_count,
400
+ "tokenCounts": {
401
+ "inputTokens": block.input_tokens,
402
+ "outputTokens": block.output_tokens,
403
+ "cacheCreationInputTokens": block.cache_creation_tokens,
404
+ "cacheReadInputTokens": block.cache_read_tokens,
405
+ },
406
+ "totalTokens": block.total_tokens,
407
+ "costUSD": block.cost_usd,
408
+ "models": block.models,
409
+ "burnRate": block.burn_rate,
410
+ "projection": block.projection,
411
+ })
412
+ result.append(obj)
413
+
414
+ return json.dumps({"blocks": result}, indent=2)