cctally 1.61.0 → 1.63.0
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 +15 -0
- package/bin/_cctally_cache.py +156 -6
- package/bin/_cctally_config.py +26 -0
- package/bin/_cctally_dashboard.py +670 -162
- package/bin/_cctally_db.py +27 -1
- package/bin/_cctally_parser.py +15 -0
- package/bin/_cctally_statusline.py +9 -1
- package/bin/_cctally_tui.py +74 -9
- package/bin/_cctally_weekrefs.py +9 -0
- package/bin/_lib_aggregators.py +98 -71
- package/bin/_lib_cache_report.py +455 -67
- package/bin/_lib_pricing.py +31 -4
- package/bin/_lib_snapshot_cache.py +869 -60
- package/bin/_lib_statusline.py +25 -13
- package/package.json +1 -1
package/bin/_cctally_db.py
CHANGED
|
@@ -2310,7 +2310,9 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
|
|
|
2310
2310
|
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
2311
2311
|
usage_extra_json TEXT,
|
|
2312
2312
|
cost_usd_raw REAL,
|
|
2313
|
-
speed TEXT
|
|
2313
|
+
speed TEXT,
|
|
2314
|
+
mutation_seq INTEGER NOT NULL DEFAULT 0,
|
|
2315
|
+
mutation_min_ts TEXT
|
|
2314
2316
|
);
|
|
2315
2317
|
CREATE INDEX IF NOT EXISTS idx_entries_timestamp
|
|
2316
2318
|
ON session_entries(timestamp_utc);
|
|
@@ -2474,6 +2476,30 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
|
|
|
2474
2476
|
# after cost_usd_raw to match the CREATE TABLE order; cache migration 008
|
|
2475
2477
|
# then backfills it from the legacy blob on existing rows.
|
|
2476
2478
|
add_column_if_missing(conn, "session_entries", "speed", "TEXT")
|
|
2479
|
+
# #270: the durable per-row mutation signal. ``mutation_seq`` is a change
|
|
2480
|
+
# stamp bumped on every insert + every WHERE-passing in-place UPSERT (from a
|
|
2481
|
+
# cache_meta counter, ingest side); ``mutation_min_ts`` records the EARLIEST
|
|
2482
|
+
# event time the row has ever held, so a finalization that overwrites
|
|
2483
|
+
# ``timestamp_utc`` and moves the row across a bucket boundary still lets the
|
|
2484
|
+
# closed-bucket watermark reach the OLD bucket. Idempotent column-adds (no
|
|
2485
|
+
# marker, no version — a pure column+index add, not a framework migration);
|
|
2486
|
+
# ``INTEGER NOT NULL DEFAULT 0`` is a metadata-only add in SQLite (no table
|
|
2487
|
+
# rewrite on a large session_entries). The covering index
|
|
2488
|
+
# ``idx_entries_mutation_seq`` backs the one new query shape
|
|
2489
|
+
# ``MIN(mutation_min_ts) WHERE mutation_seq > ?`` (index-only range-min over
|
|
2490
|
+
# the delta) plus the ``WHERE mutation_seq > ?`` filters. Both the column
|
|
2491
|
+
# adds AND the index MUST run BEFORE the legacy FTS early-return below (the
|
|
2492
|
+
# ``_apply_cache_schema_legacy_early_return_before_new_table`` gotcha), so an
|
|
2493
|
+
# old cache.db that early-returns still receives them. The index create is
|
|
2494
|
+
# kept OUT of the top executescript on purpose: on an existing DB the CREATE
|
|
2495
|
+
# TABLE there is a no-op, so the columns do not exist yet and an index over
|
|
2496
|
+
# them would raise — it must follow the add_column_if_missing calls.
|
|
2497
|
+
add_column_if_missing(
|
|
2498
|
+
conn, "session_entries", "mutation_seq", "INTEGER NOT NULL DEFAULT 0")
|
|
2499
|
+
add_column_if_missing(conn, "session_entries", "mutation_min_ts", "TEXT")
|
|
2500
|
+
conn.execute(
|
|
2501
|
+
"CREATE INDEX IF NOT EXISTS idx_entries_mutation_seq "
|
|
2502
|
+
"ON session_entries(mutation_seq, mutation_min_ts)")
|
|
2477
2503
|
# Existing-DB guard for the skill-content fold link (cctally-dev
|
|
2478
2504
|
# skill-content-nesting): the message-level sourceToolUseID. Idempotent
|
|
2479
2505
|
# column-add (no marker, no version); cache migration 006 then re-ingests
|
package/bin/_cctally_parser.py
CHANGED
|
@@ -868,6 +868,21 @@ def _build_statusline_parser(subparsers, name, *, help_text, xref):
|
|
|
868
868
|
action="store_false",
|
|
869
869
|
help="Suppress cctally 5h%%/7d%% segment.",
|
|
870
870
|
)
|
|
871
|
+
p.add_argument(
|
|
872
|
+
"--usage-only",
|
|
873
|
+
dest="usage_only",
|
|
874
|
+
action="store_true",
|
|
875
|
+
default=None,
|
|
876
|
+
help="Render only subscription usage percentages, e.g. "
|
|
877
|
+
"`5h 36%% · 7d 35%%` (config key statusline.usage_only).",
|
|
878
|
+
)
|
|
879
|
+
p.add_argument(
|
|
880
|
+
"--no-usage-only",
|
|
881
|
+
dest="usage_only",
|
|
882
|
+
action="store_false",
|
|
883
|
+
help="Render the full statusline even when statusline.usage_only "
|
|
884
|
+
"is enabled in config.",
|
|
885
|
+
)
|
|
871
886
|
p.add_argument(
|
|
872
887
|
"--config",
|
|
873
888
|
dest="config",
|
|
@@ -218,6 +218,14 @@ def cmd_statusline(args: argparse.Namespace) -> int:
|
|
|
218
218
|
)
|
|
219
219
|
ext_on = True
|
|
220
220
|
|
|
221
|
+
usage_only = _resolve(args.usage_only, "usage_only", False)
|
|
222
|
+
if not isinstance(usage_only, bool):
|
|
223
|
+
warn_once(
|
|
224
|
+
f"cctally statusline: invalid statusline.usage_only="
|
|
225
|
+
f"{usage_only!r}; using False"
|
|
226
|
+
)
|
|
227
|
+
usage_only = False
|
|
228
|
+
|
|
221
229
|
tz_name = _resolve_statusline_tz(getattr(args, "timezone", None), cfg, warn_once)
|
|
222
230
|
|
|
223
231
|
# Color: explicit CLI > NO_COLOR env > TTY detect.
|
|
@@ -232,6 +240,7 @@ def cmd_statusline(args: argparse.Namespace) -> int:
|
|
|
232
240
|
context_low_threshold=int(args.context_low_threshold),
|
|
233
241
|
context_medium_threshold=int(args.context_medium_threshold),
|
|
234
242
|
cctally_extensions=bool(ext_on),
|
|
243
|
+
usage_only=bool(usage_only),
|
|
235
244
|
color=bool(color),
|
|
236
245
|
display_tz_name=tz_name,
|
|
237
246
|
debug=bool(args.debug),
|
|
@@ -634,4 +643,3 @@ def _build_statusline_injections(warn_once):
|
|
|
634
643
|
warn_once=warn_once,
|
|
635
644
|
)
|
|
636
645
|
|
|
637
|
-
|
package/bin/_cctally_tui.py
CHANGED
|
@@ -1798,20 +1798,26 @@ def _tui_sessions_cached(
|
|
|
1798
1798
|
|
|
1799
1799
|
def _fetch_affected_session_entries(
|
|
1800
1800
|
cache_conn: "sqlite3.Connection",
|
|
1801
|
-
|
|
1801
|
+
last_seen_seq: int,
|
|
1802
1802
|
range_start: dt.datetime,
|
|
1803
1803
|
range_end: dt.datetime,
|
|
1804
1804
|
) -> "list":
|
|
1805
|
-
"""Fetch (timestamp-ASC) every entry of every session touched by a row
|
|
1806
|
-
|
|
1807
|
-
session re-aggregates WHOLE — within ``[range_start, range_end]``.
|
|
1805
|
+
"""Fetch (timestamp-ASC) every entry of every session touched by a row
|
|
1806
|
+
CHANGED since ``last_seen_seq`` — expanded across ``session_id`` siblings so
|
|
1807
|
+
a resumed session re-aggregates WHOLE — within ``[range_start, range_end]``.
|
|
1808
1808
|
|
|
1809
1809
|
The column list + ``LEFT JOIN`` mirror ``get_claude_session_entries``
|
|
1810
1810
|
EXACTLY (including the materialized ``speed`` column → ``usage_extra``), so
|
|
1811
1811
|
the per-session aggregate is byte-identical to the from-scratch pass. The
|
|
1812
1812
|
affected-source-paths set is inlined as a SQL subquery (parameterized only
|
|
1813
|
-
by ``
|
|
1813
|
+
by ``last_seen_seq``) so the fetch stays a single timestamp-ordered result —
|
|
1814
1814
|
no Python ``IN`` list to chunk, and stable first-seen model order.
|
|
1815
|
+
|
|
1816
|
+
#270 (§7d, Codex-2c): the two affected-path subqueries key on
|
|
1817
|
+
``mutation_seq > ?``, NOT ``id > ?`` — so an id-stable in-place finalization
|
|
1818
|
+
of an EXISTING session's row (which leaves ``MAX(id)`` flat) still selects
|
|
1819
|
+
that session's sibling paths and it re-aggregates WHOLE. On a pure-insert
|
|
1820
|
+
interval ``{mutation_seq > last}`` == ``{id > last}``, so byte-identical.
|
|
1815
1821
|
"""
|
|
1816
1822
|
c = _cctally()
|
|
1817
1823
|
_JoinedClaudeEntry = c._JoinedClaudeEntry
|
|
@@ -1825,23 +1831,23 @@ def _fetch_affected_session_entries(
|
|
|
1825
1831
|
"LEFT JOIN session_files sf ON sf.path = se.source_path "
|
|
1826
1832
|
"WHERE se.timestamp_utc >= ? AND se.timestamp_utc <= ? "
|
|
1827
1833
|
" AND se.source_path IN ("
|
|
1828
|
-
# Sibling paths of every session_id touched by a
|
|
1834
|
+
# Sibling paths of every session_id touched by a changed row ...
|
|
1829
1835
|
" SELECT sf2.path FROM session_files sf2 WHERE sf2.session_id IN ("
|
|
1830
1836
|
" SELECT sf1.session_id FROM session_files sf1 "
|
|
1831
1837
|
" JOIN (SELECT DISTINCT source_path FROM session_entries "
|
|
1832
|
-
" WHERE
|
|
1838
|
+
" WHERE mutation_seq > ?) af "
|
|
1833
1839
|
" ON af.source_path = sf1.path "
|
|
1834
1840
|
" WHERE sf1.session_id IS NOT NULL"
|
|
1835
1841
|
" )"
|
|
1836
1842
|
# ... UNION the touched files themselves (covers fallback / not-yet-
|
|
1837
1843
|
# backfilled session_files rows keyed by filename stem).
|
|
1838
1844
|
" UNION "
|
|
1839
|
-
" SELECT DISTINCT source_path FROM session_entries WHERE
|
|
1845
|
+
" SELECT DISTINCT source_path FROM session_entries WHERE mutation_seq > ?"
|
|
1840
1846
|
" ) "
|
|
1841
1847
|
"ORDER BY se.timestamp_utc ASC"
|
|
1842
1848
|
)
|
|
1843
1849
|
rows = cache_conn.execute(
|
|
1844
|
-
sql, (start_iso, end_iso,
|
|
1850
|
+
sql, (start_iso, end_iso, last_seen_seq, last_seen_seq),
|
|
1845
1851
|
).fetchall()
|
|
1846
1852
|
return [
|
|
1847
1853
|
_JoinedClaudeEntry(
|
|
@@ -2155,6 +2161,22 @@ def _tui_build_snapshot(
|
|
|
2155
2161
|
# `reconcile_projects_env_cache` succeeds. Off ⇒ `_build_projects_envelope`
|
|
2156
2162
|
# does the full-window walk (CLI / drill / test byte-identical).
|
|
2157
2163
|
use_projects_env_cache = False
|
|
2164
|
+
# ── #271 M3: Bug-K pre-credit segment cache (spec §18) ──────────────
|
|
2165
|
+
# A DEDICATED flag (Codex-BK-2 — NOT piggybacked on `use_group_a_cache`):
|
|
2166
|
+
# OFF by default, flipped ON only on the dashboard sync-thread NON-IDLE
|
|
2167
|
+
# path after the once-per-rebuild `reconcile_bugk_cache` succeeds. Off ⇒
|
|
2168
|
+
# the Weekly builder's Bug-K synthesis re-folds the closed pre-credit
|
|
2169
|
+
# window directly each tick (CLI / share / test byte-identical). A
|
|
2170
|
+
# failed/absent reconcile must fall back to direct compute, never reuse
|
|
2171
|
+
# stale segment state.
|
|
2172
|
+
use_bugk_segment_cache = False
|
|
2173
|
+
# ── #272: cache-report per-day cache (spec §5/§6) ───────────────────
|
|
2174
|
+
# Same gating: OFF by default, flipped ON only on the dashboard
|
|
2175
|
+
# sync-thread NON-IDLE path after the once-per-rebuild
|
|
2176
|
+
# `reconcile_cache_report_cache` succeeds. Off ⇒ `build_cache_report_snapshot`
|
|
2177
|
+
# fetches + folds the full 14d window every tick (byte-identical to the
|
|
2178
|
+
# cached path). A failed/absent reconcile falls back to full recompute.
|
|
2179
|
+
use_cache_report_cache = False
|
|
2158
2180
|
# ── Three-path dispatch — idle short-circuit (spec §3, #268 M5.1) ──
|
|
2159
2181
|
# Compute the composite data-version signature (cheap MAX(id) descents
|
|
2160
2182
|
# over cache.db + stats.db + the reset-event change-signal + the
|
|
@@ -2222,6 +2244,7 @@ def _tui_build_snapshot(
|
|
|
2222
2244
|
_sc.reconcile_weekref_cache(
|
|
2223
2245
|
_rc_cache_conn,
|
|
2224
2246
|
max_entry_id=dispatch_sig.max_entry_id,
|
|
2247
|
+
max_mutation_seq=dispatch_sig.entry_mutation_seq,
|
|
2225
2248
|
reset_sig=dispatch_sig.reset_sig,
|
|
2226
2249
|
)
|
|
2227
2250
|
use_weekref_cost_cache = True
|
|
@@ -2233,10 +2256,50 @@ def _tui_build_snapshot(
|
|
|
2233
2256
|
_sc.reconcile_projects_env_cache(
|
|
2234
2257
|
_rc_cache_conn,
|
|
2235
2258
|
max_entry_id=dispatch_sig.max_entry_id,
|
|
2259
|
+
max_mutation_seq=dispatch_sig.entry_mutation_seq,
|
|
2236
2260
|
max_wus_id=dispatch_sig.max_wus_id,
|
|
2237
2261
|
sf_sig=_sc.session_files_sig(_rc_cache_conn),
|
|
2238
2262
|
)
|
|
2239
2263
|
use_projects_env_cache = True
|
|
2264
|
+
# #271 M3: reconcile the Bug-K pre-credit segment cache
|
|
2265
|
+
# with the SAME short-lived cache conn (its watermark
|
|
2266
|
+
# query lives in cache.db), using the dispatch-signature
|
|
2267
|
+
# legs already computed. Only after this succeeds does
|
|
2268
|
+
# `_dashboard_build_weekly_periods` opt into the cache
|
|
2269
|
+
# below (`use_bugk_segment_cache=True`).
|
|
2270
|
+
_sc.reconcile_bugk_cache(
|
|
2271
|
+
_rc_cache_conn,
|
|
2272
|
+
max_entry_id=dispatch_sig.max_entry_id,
|
|
2273
|
+
max_mutation_seq=dispatch_sig.entry_mutation_seq,
|
|
2274
|
+
reset_sig=dispatch_sig.reset_sig,
|
|
2275
|
+
)
|
|
2276
|
+
use_bugk_segment_cache = True
|
|
2277
|
+
# #272: reconcile the cache-report per-day cache with the
|
|
2278
|
+
# SAME short-lived cache conn (its watermark query +
|
|
2279
|
+
# session_files_sig both live in cache.db), using the
|
|
2280
|
+
# dispatch-signature legs already computed. `sf_sig`
|
|
2281
|
+
# closes the lazy-`project_path`-backfill hole (Codex-1);
|
|
2282
|
+
# `tz_key` full-clears on a display-tz change (every
|
|
2283
|
+
# calendar-day key shifts). Only after this succeeds does
|
|
2284
|
+
# `build_cache_report_snapshot` opt into the cache below.
|
|
2285
|
+
_sc.reconcile_cache_report_cache(
|
|
2286
|
+
_rc_cache_conn,
|
|
2287
|
+
max_entry_id=dispatch_sig.max_entry_id,
|
|
2288
|
+
max_mutation_seq=dispatch_sig.entry_mutation_seq,
|
|
2289
|
+
reset_sig=dispatch_sig.reset_sig,
|
|
2290
|
+
sf_sig=_sc.session_files_sig(_rc_cache_conn),
|
|
2291
|
+
# `_load_sibling` is the canonical idempotent
|
|
2292
|
+
# sibling-access idiom (it returns the already-loaded
|
|
2293
|
+
# cctally-bound kernel instance). `_lib_cache_report`
|
|
2294
|
+
# is loaded eagerly at import (bin/cctally), so this
|
|
2295
|
+
# is a plain re-fetch of a populated `sys.modules`
|
|
2296
|
+
# entry — NOT a first-tick KeyError guard.
|
|
2297
|
+
bucket_tz=_cctally()._load_sibling(
|
|
2298
|
+
"_lib_cache_report"
|
|
2299
|
+
)._resolve_bucket_tz(_build_display_tz),
|
|
2300
|
+
tz_key=str(_build_display_tz),
|
|
2301
|
+
)
|
|
2302
|
+
use_cache_report_cache = True
|
|
2240
2303
|
finally:
|
|
2241
2304
|
_rc_cache_conn.close()
|
|
2242
2305
|
except Exception as exc:
|
|
@@ -2327,6 +2390,7 @@ def _tui_build_snapshot(
|
|
|
2327
2390
|
weekly_periods = _dashboard_build_weekly_periods(
|
|
2328
2391
|
conn, now_utc, n=12, skip_sync=skip_sync,
|
|
2329
2392
|
use_group_a_cache=True,
|
|
2393
|
+
use_bugk_segment_cache=use_bugk_segment_cache,
|
|
2330
2394
|
)
|
|
2331
2395
|
# ``stable_sum`` (math.fsum) returns float ``0.0`` on empty rows,
|
|
2332
2396
|
# so the envelope stays byte-stable with the pre-fix ``0.0`` shape
|
|
@@ -2560,6 +2624,7 @@ def _tui_build_snapshot(
|
|
|
2560
2624
|
anomaly_window_days=_dash_mod.CACHE_REPORT_ANOMALY_WINDOW_DAYS,
|
|
2561
2625
|
display_tz=_build_display_tz,
|
|
2562
2626
|
skip_sync=skip_sync,
|
|
2627
|
+
use_cache_report_cache=use_cache_report_cache,
|
|
2563
2628
|
)
|
|
2564
2629
|
except Exception as exc:
|
|
2565
2630
|
errors.append(f"cache-report: {exc}")
|
package/bin/_cctally_weekrefs.py
CHANGED
|
@@ -309,6 +309,15 @@ def _backfill_week_reset_events(conn: sqlite3.Connection) -> None:
|
|
|
309
309
|
c = _cctally()
|
|
310
310
|
# #269 M4.4: skip the full rescan when neither the snapshots nor the
|
|
311
311
|
# reset events moved since the last successful backfill on this DB file.
|
|
312
|
+
#
|
|
313
|
+
# #271 §9d rider (from the #269 final review): the skip signature
|
|
314
|
+
# (`_backfill_reset_events_signature`, a MAX(id)/COUNT descent) is blind to a
|
|
315
|
+
# NON-max `weekly_usage_snapshots` DELETE — dropping a middle row leaves
|
|
316
|
+
# MAX(id) and could leave COUNT unchanged vs a prior state, so a rescan may
|
|
317
|
+
# be skipped. That is byte-safe because this backfill is ADD-ONLY
|
|
318
|
+
# (`INSERT OR IGNORE INTO week_reset_events`): a skipped rescan can only fail
|
|
319
|
+
# to *add* an event, never *remove* a needed one, matching the pre-#269
|
|
320
|
+
# idempotent behavior exactly.
|
|
312
321
|
db_id = _backfill_db_identity(conn)
|
|
313
322
|
sig = _backfill_reset_events_signature(conn)
|
|
314
323
|
if db_id is not None:
|
package/bin/_lib_aggregators.py
CHANGED
|
@@ -106,6 +106,94 @@ class BucketUsage:
|
|
|
106
106
|
model_breakdowns: list[dict[str, Any]] # Sorted by cost desc
|
|
107
107
|
|
|
108
108
|
|
|
109
|
+
def _new_bucket_acc() -> dict[str, Any]:
|
|
110
|
+
"""A fresh running accumulator for one bucket (the pre-``BucketUsage`` shape).
|
|
111
|
+
|
|
112
|
+
The single per-bucket accumulation state shared by the full-pass
|
|
113
|
+
``_aggregate_buckets`` and #271's incremental current-bucket accumulator, so
|
|
114
|
+
the byte-identity proof reduces to "same primitive, entries appended in the
|
|
115
|
+
same order" (spec §6).
|
|
116
|
+
"""
|
|
117
|
+
return {
|
|
118
|
+
"input": 0, "output": 0, "cache_create": 0, "cache_read": 0,
|
|
119
|
+
"cost": 0.0, "models": {}, "models_order": [],
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _fold_entry(acc: dict[str, Any], entry: UsageEntry, mode: str = "auto") -> None:
|
|
124
|
+
"""Fold one entry into ``acc`` (the exact per-entry logic of ``_aggregate_buckets``).
|
|
125
|
+
|
|
126
|
+
Carries over verbatim: the ``<synthetic>`` skip (never touches ``acc`` for
|
|
127
|
+
them, so it is safe over an unfiltered delta stream), the ``-fast``
|
|
128
|
+
``display_model`` suffix, the ``_calculate_entry_cost`` call, the four
|
|
129
|
+
integer token ``+=``, the running float ``cost +=``, the per-model
|
|
130
|
+
sub-bucket sums, and the first-seen ``models_order.append``. Order-sensitive:
|
|
131
|
+
the running ``cost`` is a left-fold, so callers MUST fold in the same order
|
|
132
|
+
the full pass would (the pinned ``(timestamp_utc, id)`` order, #271 §5).
|
|
133
|
+
"""
|
|
134
|
+
if entry.model == "<synthetic>":
|
|
135
|
+
return
|
|
136
|
+
usage = entry.usage
|
|
137
|
+
display_model = f"{entry.model}-fast" if usage.get("speed") == "fast" else entry.model
|
|
138
|
+
inp = int(usage.get("input_tokens", 0) or 0)
|
|
139
|
+
out = int(usage.get("output_tokens", 0) or 0)
|
|
140
|
+
cc = int(usage.get("cache_creation_input_tokens", 0) or 0)
|
|
141
|
+
cr = int(usage.get("cache_read_input_tokens", 0) or 0)
|
|
142
|
+
cost = _calculate_entry_cost(
|
|
143
|
+
entry.model, usage, mode=mode, cost_usd=entry.cost_usd,
|
|
144
|
+
)
|
|
145
|
+
acc["input"] += inp
|
|
146
|
+
acc["output"] += out
|
|
147
|
+
acc["cache_create"] += cc
|
|
148
|
+
acc["cache_read"] += cr
|
|
149
|
+
acc["cost"] += cost
|
|
150
|
+
model_bucket = acc["models"].setdefault(display_model, {
|
|
151
|
+
"input": 0, "output": 0, "cache_create": 0, "cache_read": 0, "cost": 0.0,
|
|
152
|
+
})
|
|
153
|
+
model_bucket["input"] += inp
|
|
154
|
+
model_bucket["output"] += out
|
|
155
|
+
model_bucket["cache_create"] += cc
|
|
156
|
+
model_bucket["cache_read"] += cr
|
|
157
|
+
model_bucket["cost"] += cost
|
|
158
|
+
if display_model not in acc["models_order"]:
|
|
159
|
+
acc["models_order"].append(display_model)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _finalize_bucket(bucket_key: str, acc: dict[str, Any]) -> BucketUsage:
|
|
163
|
+
"""Build the immutable ``BucketUsage`` from a running accumulator.
|
|
164
|
+
|
|
165
|
+
Copies ``models`` / ``model_breakdowns`` so the returned row shares no
|
|
166
|
+
mutable state with ``acc`` (F7 — a live accumulator may keep being folded,
|
|
167
|
+
e.g. #271's persisted current-bucket state).
|
|
168
|
+
"""
|
|
169
|
+
model_breakdowns = [
|
|
170
|
+
{
|
|
171
|
+
"modelName": model,
|
|
172
|
+
"inputTokens": mb["input"],
|
|
173
|
+
"outputTokens": mb["output"],
|
|
174
|
+
"cacheCreationTokens": mb["cache_create"],
|
|
175
|
+
"cacheReadTokens": mb["cache_read"],
|
|
176
|
+
"cost": mb["cost"],
|
|
177
|
+
}
|
|
178
|
+
for model, mb in acc["models"].items()
|
|
179
|
+
]
|
|
180
|
+
model_breakdowns.sort(key=lambda m: m["cost"], reverse=True)
|
|
181
|
+
total_tokens = (
|
|
182
|
+
acc["input"] + acc["output"] + acc["cache_create"] + acc["cache_read"]
|
|
183
|
+
)
|
|
184
|
+
return BucketUsage(
|
|
185
|
+
bucket=bucket_key,
|
|
186
|
+
input_tokens=acc["input"],
|
|
187
|
+
output_tokens=acc["output"],
|
|
188
|
+
cache_creation_tokens=acc["cache_create"],
|
|
189
|
+
cache_read_tokens=acc["cache_read"],
|
|
190
|
+
total_tokens=total_tokens,
|
|
191
|
+
cost_usd=acc["cost"],
|
|
192
|
+
models=list(acc["models_order"]),
|
|
193
|
+
model_breakdowns=model_breakdowns,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
109
197
|
def _aggregate_buckets(
|
|
110
198
|
entries: list[UsageEntry],
|
|
111
199
|
key_fn: Callable[[UsageEntry], str],
|
|
@@ -117,84 +205,23 @@ def _aggregate_buckets(
|
|
|
117
205
|
The returned list is sorted by bucket key ascending — callers reverse
|
|
118
206
|
for --order desc. Model breakdowns within each bucket are sorted by
|
|
119
207
|
descending cost, matching upstream ccusage.
|
|
208
|
+
|
|
209
|
+
The per-entry accumulation is the shared ``_fold_entry`` primitive (spec
|
|
210
|
+
§6): a ``<synthetic>`` entry is skipped BEFORE keying (so a synthetic-only
|
|
211
|
+
key never materializes an empty bucket), and each surviving entry is folded
|
|
212
|
+
into its bucket's running accumulator, finalized per key in ascending order.
|
|
120
213
|
"""
|
|
121
214
|
by_bucket: dict[str, dict[str, Any]] = {}
|
|
122
|
-
models_order: dict[str, list[str]] = {}
|
|
123
|
-
|
|
124
215
|
for entry in entries:
|
|
125
216
|
if entry.model == "<synthetic>":
|
|
126
217
|
continue
|
|
127
|
-
usage = entry.usage
|
|
128
|
-
display_model = f"{entry.model}-fast" if usage.get("speed") == "fast" else entry.model
|
|
129
218
|
key = key_fn(entry)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
"cache_read": 0,
|
|
135
|
-
"cost": 0.0,
|
|
136
|
-
"models": {},
|
|
137
|
-
})
|
|
138
|
-
order = models_order.setdefault(key, [])
|
|
219
|
+
acc = by_bucket.get(key)
|
|
220
|
+
if acc is None:
|
|
221
|
+
by_bucket[key] = acc = _new_bucket_acc()
|
|
222
|
+
_fold_entry(acc, entry, mode)
|
|
139
223
|
|
|
140
|
-
|
|
141
|
-
out = int(usage.get("output_tokens", 0) or 0)
|
|
142
|
-
cc = int(usage.get("cache_creation_input_tokens", 0) or 0)
|
|
143
|
-
cr = int(usage.get("cache_read_input_tokens", 0) or 0)
|
|
144
|
-
cost = _calculate_entry_cost(
|
|
145
|
-
entry.model, usage, mode=mode, cost_usd=entry.cost_usd,
|
|
146
|
-
)
|
|
147
|
-
|
|
148
|
-
bucket["input"] += inp
|
|
149
|
-
bucket["output"] += out
|
|
150
|
-
bucket["cache_create"] += cc
|
|
151
|
-
bucket["cache_read"] += cr
|
|
152
|
-
bucket["cost"] += cost
|
|
153
|
-
|
|
154
|
-
model_bucket = bucket["models"].setdefault(display_model, {
|
|
155
|
-
"input": 0,
|
|
156
|
-
"output": 0,
|
|
157
|
-
"cache_create": 0,
|
|
158
|
-
"cache_read": 0,
|
|
159
|
-
"cost": 0.0,
|
|
160
|
-
})
|
|
161
|
-
model_bucket["input"] += inp
|
|
162
|
-
model_bucket["output"] += out
|
|
163
|
-
model_bucket["cache_create"] += cc
|
|
164
|
-
model_bucket["cache_read"] += cr
|
|
165
|
-
model_bucket["cost"] += cost
|
|
166
|
-
|
|
167
|
-
if display_model not in order:
|
|
168
|
-
order.append(display_model)
|
|
169
|
-
|
|
170
|
-
result: list[BucketUsage] = []
|
|
171
|
-
for key in sorted(by_bucket.keys()):
|
|
172
|
-
b = by_bucket[key]
|
|
173
|
-
model_breakdowns = [
|
|
174
|
-
{
|
|
175
|
-
"modelName": model,
|
|
176
|
-
"inputTokens": mb["input"],
|
|
177
|
-
"outputTokens": mb["output"],
|
|
178
|
-
"cacheCreationTokens": mb["cache_create"],
|
|
179
|
-
"cacheReadTokens": mb["cache_read"],
|
|
180
|
-
"cost": mb["cost"],
|
|
181
|
-
}
|
|
182
|
-
for model, mb in b["models"].items()
|
|
183
|
-
]
|
|
184
|
-
model_breakdowns.sort(key=lambda m: m["cost"], reverse=True)
|
|
185
|
-
total_tokens = b["input"] + b["output"] + b["cache_create"] + b["cache_read"]
|
|
186
|
-
result.append(BucketUsage(
|
|
187
|
-
bucket=key,
|
|
188
|
-
input_tokens=b["input"],
|
|
189
|
-
output_tokens=b["output"],
|
|
190
|
-
cache_creation_tokens=b["cache_create"],
|
|
191
|
-
cache_read_tokens=b["cache_read"],
|
|
192
|
-
total_tokens=total_tokens,
|
|
193
|
-
cost_usd=b["cost"],
|
|
194
|
-
models=models_order[key],
|
|
195
|
-
model_breakdowns=model_breakdowns,
|
|
196
|
-
))
|
|
197
|
-
return result
|
|
224
|
+
return [_finalize_bucket(key, by_bucket[key]) for key in sorted(by_bucket.keys())]
|
|
198
225
|
|
|
199
226
|
|
|
200
227
|
def _aggregate_daily(
|