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.
- package/CHANGELOG.md +7 -0
- package/bin/_cctally_alerts.py +231 -0
- package/bin/_cctally_cache.py +1432 -0
- package/bin/_cctally_config.py +560 -0
- package/bin/_cctally_dashboard.py +5218 -0
- package/bin/_cctally_db.py +1729 -0
- package/bin/_cctally_record.py +2120 -0
- package/bin/_cctally_refresh.py +812 -0
- package/bin/_cctally_release.py +751 -0
- package/bin/_cctally_setup.py +1571 -0
- package/bin/_cctally_sync_week.py +110 -0
- package/bin/_cctally_tui.py +4381 -0
- package/bin/_cctally_update.py +2132 -0
- package/bin/_lib_aggregators.py +712 -0
- package/bin/_lib_alerts_payload.py +194 -0
- package/bin/_lib_blocks.py +414 -0
- package/bin/_lib_diff_kernel.py +1618 -0
- package/bin/_lib_display_tz.py +361 -0
- package/bin/_lib_doctor.py +58 -0
- package/bin/_lib_five_hour.py +82 -0
- package/bin/_lib_jsonl.py +403 -0
- package/bin/_lib_pricing.py +520 -0
- package/bin/_lib_render.py +2785 -0
- package/bin/_lib_semver.py +105 -0
- package/bin/_lib_subscription_weeks.py +492 -0
- package/bin/cctally +11034 -35415
- package/package.json +24 -1
|
@@ -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)
|