cctally 1.60.0 → 1.62.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 +24 -0
- package/bin/_cctally_cache.py +479 -54
- package/bin/_cctally_core.py +8 -0
- package/bin/_cctally_dashboard.py +1467 -234
- package/bin/_cctally_db.py +27 -1
- package/bin/_cctally_doctor.py +2 -0
- package/bin/_cctally_forecast.py +24 -2
- package/bin/_cctally_parser.py +28 -2
- package/bin/_cctally_tui.py +708 -16
- package/bin/_cctally_update.py +49 -9
- package/bin/_cctally_weekrefs.py +101 -2
- package/bin/_lib_aggregators.py +98 -71
- package/bin/_lib_cache_report.py +455 -67
- package/bin/_lib_doctor.py +10 -0
- package/bin/_lib_pricing.py +31 -4
- package/bin/_lib_snapshot_cache.py +1797 -0
- package/bin/_lib_view_models.py +107 -12
- package/bin/cctally +57 -0
- package/dashboard/static/assets/index-0jzYm75p.css +1 -0
- package/dashboard/static/assets/{index-DQH6UPc_.js → index-D_Ylyqsf.js} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-uGwZSssU.css +0 -1
package/bin/_cctally_update.py
CHANGED
|
@@ -196,7 +196,7 @@ import threading
|
|
|
196
196
|
import time
|
|
197
197
|
import urllib.error
|
|
198
198
|
import urllib.request
|
|
199
|
-
from dataclasses import dataclass
|
|
199
|
+
from dataclasses import dataclass, replace
|
|
200
200
|
from typing import Any, Callable
|
|
201
201
|
|
|
202
202
|
|
|
@@ -1872,12 +1872,22 @@ class _DashboardUpdateCheckThread(threading.Thread):
|
|
|
1872
1872
|
via ``update.check.enabled = false`` is honoured inside the gate
|
|
1873
1873
|
so the thread becomes a no-op without needing teardown.
|
|
1874
1874
|
|
|
1875
|
-
After a successful check
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1875
|
+
After a successful check (or an out-of-band ``current_version``
|
|
1876
|
+
self-heal), republishes the current snapshot via the SSE hub so
|
|
1877
|
+
long-open dashboard tabs in ``--no-sync`` mode pick up the fresh
|
|
1878
|
+
``latest_version`` / ``current_version`` written to
|
|
1879
|
+
``update-state.json``. Since M4 (#268) the SSE envelope reads
|
|
1880
|
+
update-state / update-suppress off the snapshot's
|
|
1881
|
+
``envelope_precompute`` rather than re-reading the state file per
|
|
1882
|
+
envelope build, so a BARE publish of the held snapshot would surface
|
|
1883
|
+
the stale precompute. The republish therefore rebuilds
|
|
1884
|
+
``envelope_precompute`` (small config/state JSON I/O, no DB, no
|
|
1885
|
+
module-cache mutation — see :meth:`_republish_with_fresh_envelope`)
|
|
1886
|
+
on a fresh ``dataclasses.replace`` snapshot before publishing, and
|
|
1887
|
+
persists it to the snapshot ref so the held snapshot stays current.
|
|
1888
|
+
Under ``--no-sync`` this thread is the ONLY refresher of that
|
|
1889
|
+
precompute (the data-sync thread that otherwise rebuilds it is
|
|
1890
|
+
disabled).
|
|
1881
1891
|
"""
|
|
1882
1892
|
|
|
1883
1893
|
daemon = True
|
|
@@ -1917,14 +1927,14 @@ class _DashboardUpdateCheckThread(threading.Thread):
|
|
|
1917
1927
|
):
|
|
1918
1928
|
snap = self._ref.get()
|
|
1919
1929
|
if snap is not None:
|
|
1920
|
-
self.
|
|
1930
|
+
self._republish_with_fresh_envelope(snap)
|
|
1921
1931
|
config = load_config()
|
|
1922
1932
|
if c._is_update_check_due(config):
|
|
1923
1933
|
c._do_update_check()
|
|
1924
1934
|
if self._hub is not None and self._ref is not None:
|
|
1925
1935
|
snap = self._ref.get()
|
|
1926
1936
|
if snap is not None:
|
|
1927
|
-
self.
|
|
1937
|
+
self._republish_with_fresh_envelope(snap)
|
|
1928
1938
|
except Exception as e:
|
|
1929
1939
|
# Log but never propagate — this thread must keep
|
|
1930
1940
|
# ticking so a transient registry hiccup doesn't
|
|
@@ -1940,6 +1950,36 @@ class _DashboardUpdateCheckThread(threading.Thread):
|
|
|
1940
1950
|
pass
|
|
1941
1951
|
self._stop_event.wait(c.UPDATE_DASHBOARD_CHECK_POLL_S)
|
|
1942
1952
|
|
|
1953
|
+
def _republish_with_fresh_envelope(self, snap) -> None:
|
|
1954
|
+
"""Republish ``snap`` with a freshly-rebuilt ``envelope_precompute``.
|
|
1955
|
+
|
|
1956
|
+
Since M4 (#268) the SSE envelope reads update-state / update-suppress
|
|
1957
|
+
off ``snap.envelope_precompute`` rather than re-reading the JSON files
|
|
1958
|
+
per client. ``_do_update_check`` / ``_self_heal_current_version`` write
|
|
1959
|
+
``update-state.json`` OUT OF BAND (advancing no DB signature and no
|
|
1960
|
+
config render key), so a bare re-publish of the HELD snapshot would
|
|
1961
|
+
surface the stale precompute — freezing the version banner on a
|
|
1962
|
+
``--no-sync`` dashboard whose data-sync thread (the only other
|
|
1963
|
+
precompute refresher) is disabled.
|
|
1964
|
+
|
|
1965
|
+
``_tui_precompute_envelope_config`` reads only the config / update-state
|
|
1966
|
+
/ update-suppress files and mutates NO module cache, so calling it from
|
|
1967
|
+
this thread is safe (no shared-cache-mutation / Codex F7 hazard).
|
|
1968
|
+
``dataclasses.replace`` yields a NEW snapshot sharing ``prior``'s
|
|
1969
|
+
immutable rows, so an SSE client serializing the previously-published
|
|
1970
|
+
snapshot can't observe a torn value. The fresh snapshot is persisted to
|
|
1971
|
+
the ref so the held snapshot stays current for the next reader.
|
|
1972
|
+
"""
|
|
1973
|
+
c = _cctally()
|
|
1974
|
+
fresh = replace(
|
|
1975
|
+
snap,
|
|
1976
|
+
envelope_precompute=(
|
|
1977
|
+
c._cctally_tui._tui_precompute_envelope_config(load_config())
|
|
1978
|
+
),
|
|
1979
|
+
)
|
|
1980
|
+
self._ref.set(fresh)
|
|
1981
|
+
self._hub.publish(fresh)
|
|
1982
|
+
|
|
1943
1983
|
|
|
1944
1984
|
def cmd_update(args) -> int:
|
|
1945
1985
|
"""`cctally update` entry point — routes by mode flag (spec §4.1)."""
|
package/bin/_cctally_weekrefs.py
CHANGED
|
@@ -31,6 +31,7 @@ from __future__ import annotations
|
|
|
31
31
|
import datetime as dt
|
|
32
32
|
import sqlite3
|
|
33
33
|
import sys
|
|
34
|
+
import threading
|
|
34
35
|
from dataclasses import replace
|
|
35
36
|
|
|
36
37
|
from _cctally_core import (
|
|
@@ -220,6 +221,68 @@ def _apply_reset_events_to_weekrefs(
|
|
|
220
221
|
return out
|
|
221
222
|
|
|
222
223
|
|
|
224
|
+
# === #269 M4.4 — backfill-rescan process guard ==============================
|
|
225
|
+
#
|
|
226
|
+
# `_backfill_week_reset_events` rescans ALL `weekly_usage_snapshots` on every
|
|
227
|
+
# `open_db()`. Its output is NOT a pure function of the snapshots alone: the
|
|
228
|
+
# in-place-credit branch also reads existing `week_reset_events`, and the
|
|
229
|
+
# reset-events-regenerate contract deletes `week_reset_events` and re-runs the
|
|
230
|
+
# backfill WITHOUT a snapshot insert. So a `MAX(weekly_usage_snapshots.id)`-only
|
|
231
|
+
# guard would skip a needed regeneration. Guard instead on a process-level memo
|
|
232
|
+
# of BOTH `MAX(weekly_usage_snapshots.id)` AND a `week_reset_events` signature
|
|
233
|
+
# `(COUNT(*), MAX(rowid))`, scoped per DB FILE identity: skip the rescan only
|
|
234
|
+
# when both are unchanged since the last successful backfill. Byte-identical (a
|
|
235
|
+
# snapshot insert advances the WUS id; a reset-event delete/clear moves the
|
|
236
|
+
# reset-event sig; a backfill's own inserts move the sig so the next open is a
|
|
237
|
+
# clean skip). Production snapshots are append-only, so the WUS id moves on
|
|
238
|
+
# every new capture. In-memory / temp DBs (no file identity) are never memoized
|
|
239
|
+
# (always rescanned) — a `:memory:` identity could otherwise collide across
|
|
240
|
+
# distinct databases.
|
|
241
|
+
|
|
242
|
+
_BACKFILL_MEMO_LOCK = threading.Lock()
|
|
243
|
+
_BACKFILL_RESET_EVENTS_MEMO: dict = {} # db_path -> (max_wus_id, (count, max_rowid))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _reset_backfill_reset_events_memo() -> None:
|
|
247
|
+
"""Drop the backfill-guard memo (test hook + isolation)."""
|
|
248
|
+
with _BACKFILL_MEMO_LOCK:
|
|
249
|
+
_BACKFILL_RESET_EVENTS_MEMO.clear()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _backfill_db_identity(conn: sqlite3.Connection) -> "str | None":
|
|
253
|
+
"""The `main` schema's on-disk file path, or None for in-memory/temp DBs
|
|
254
|
+
(empty file string) — the memo key. None ⇒ never memoize (always rescan)."""
|
|
255
|
+
try:
|
|
256
|
+
for _seq, name, file in conn.execute("PRAGMA database_list"):
|
|
257
|
+
if name == "main":
|
|
258
|
+
return file or None
|
|
259
|
+
except sqlite3.Error:
|
|
260
|
+
pass
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _backfill_reset_events_signature(
|
|
265
|
+
conn: sqlite3.Connection,
|
|
266
|
+
) -> "tuple[int, tuple[int, int]]":
|
|
267
|
+
"""`(MAX(weekly_usage_snapshots.id), (COUNT, MAX(rowid)) over
|
|
268
|
+
week_reset_events)` — two O(1) MAX/COUNT descents vs the full-table rescan.
|
|
269
|
+
Degrades to zero legs on a missing table so it never raises."""
|
|
270
|
+
try:
|
|
271
|
+
max_wus = conn.execute(
|
|
272
|
+
"SELECT COALESCE(MAX(id), 0) FROM weekly_usage_snapshots"
|
|
273
|
+
).fetchone()[0]
|
|
274
|
+
except sqlite3.DatabaseError:
|
|
275
|
+
max_wus = 0
|
|
276
|
+
try:
|
|
277
|
+
row = conn.execute(
|
|
278
|
+
"SELECT COUNT(*), COALESCE(MAX(rowid), 0) FROM week_reset_events"
|
|
279
|
+
).fetchone()
|
|
280
|
+
wre = (int(row[0]), int(row[1]))
|
|
281
|
+
except sqlite3.DatabaseError:
|
|
282
|
+
wre = (0, 0)
|
|
283
|
+
return (int(max_wus), wre)
|
|
284
|
+
|
|
285
|
+
|
|
223
286
|
def _backfill_week_reset_events(conn: sqlite3.Connection) -> None:
|
|
224
287
|
"""One-shot scan over historical snapshots to synthesize reset events
|
|
225
288
|
for past mid-week resets the tool lived through before this feature
|
|
@@ -244,6 +307,26 @@ def _backfill_week_reset_events(conn: sqlite3.Connection) -> None:
|
|
|
244
307
|
window. See ``_is_reset_drop`` for the full rationale.
|
|
245
308
|
"""
|
|
246
309
|
c = _cctally()
|
|
310
|
+
# #269 M4.4: skip the full rescan when neither the snapshots nor the
|
|
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.
|
|
321
|
+
db_id = _backfill_db_identity(conn)
|
|
322
|
+
sig = _backfill_reset_events_signature(conn)
|
|
323
|
+
if db_id is not None:
|
|
324
|
+
with _BACKFILL_MEMO_LOCK:
|
|
325
|
+
if _BACKFILL_RESET_EVENTS_MEMO.get(db_id) == sig:
|
|
326
|
+
# Preserve the original's transaction-flush behavior on the
|
|
327
|
+
# skip path (harmless when there is nothing pending).
|
|
328
|
+
conn.commit()
|
|
329
|
+
return
|
|
247
330
|
try:
|
|
248
331
|
rows = conn.execute(
|
|
249
332
|
"SELECT captured_at_utc, week_end_at, weekly_percent "
|
|
@@ -372,6 +455,13 @@ def _backfill_week_reset_events(conn: sqlite3.Connection) -> None:
|
|
|
372
455
|
# (e.g. _backfill_five_hour_blocks) don't trip "cannot start a
|
|
373
456
|
# transaction within a transaction".
|
|
374
457
|
conn.commit()
|
|
458
|
+
# #269 M4.4: store the POST-backfill signature (the scan's own inserts move
|
|
459
|
+
# the reset-event sig, so the next open with no snapshot change is a clean
|
|
460
|
+
# skip). Only after a completed scan — the early `except` return above
|
|
461
|
+
# deliberately does not memoize a partial state.
|
|
462
|
+
if db_id is not None:
|
|
463
|
+
with _BACKFILL_MEMO_LOCK:
|
|
464
|
+
_BACKFILL_RESET_EVENTS_MEMO[db_id] = _backfill_reset_events_signature(conn)
|
|
375
465
|
|
|
376
466
|
|
|
377
467
|
# Minimum weekly_percent drop that counts as a goodwill reset (percentage
|
|
@@ -458,11 +548,20 @@ def _week_ref_has_reset_event(
|
|
|
458
548
|
return row is not None
|
|
459
549
|
|
|
460
550
|
|
|
461
|
-
def _compute_cost_for_weekref(
|
|
551
|
+
def _compute_cost_for_weekref(
|
|
552
|
+
ref: WeekRef, *, skip_sync: bool = False
|
|
553
|
+
) -> float | None:
|
|
462
554
|
"""Live-compute USD cost over `ref`'s (possibly reset-adjusted) range
|
|
463
555
|
straight from session_entries. Mirrors what cmd_sync_week writes into
|
|
464
556
|
weekly_cost_snapshots, minus the cache write — used for reset-affected
|
|
465
557
|
weeks where the cached range disagrees with the effective range.
|
|
558
|
+
|
|
559
|
+
``skip_sync`` (default ``False``) is threaded to ``_sum_cost_for_range``
|
|
560
|
+
so the caller can read the cache without triggering a JSONL ingest.
|
|
561
|
+
The #268 dashboard/TUI sync-thread rebuild passes ``True`` (it ingests
|
|
562
|
+
once at the top of the rebuild); ``build_trend_view`` calls this once per
|
|
563
|
+
reset-event week, so without the flag each reset week re-globbed the whole
|
|
564
|
+
``~/.claude/projects`` tree — the CPU peg the sync-once refactor removes.
|
|
466
565
|
"""
|
|
467
566
|
c = _cctally()
|
|
468
567
|
if not ref.week_start_at or not ref.week_end_at:
|
|
@@ -474,7 +573,7 @@ def _compute_cost_for_weekref(ref: WeekRef) -> float | None:
|
|
|
474
573
|
return None
|
|
475
574
|
if end <= start:
|
|
476
575
|
return 0.0
|
|
477
|
-
return c._sum_cost_for_range(start, end, mode="auto")
|
|
576
|
+
return c._sum_cost_for_range(start, end, mode="auto", skip_sync=skip_sync)
|
|
478
577
|
|
|
479
578
|
|
|
480
579
|
def _apply_overlap_clamp_to_weekrefs(refs: list[WeekRef]) -> list[WeekRef]:
|
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(
|