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.
@@ -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
@@ -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
-
@@ -1798,20 +1798,26 @@ def _tui_sessions_cached(
1798
1798
 
1799
1799
  def _fetch_affected_session_entries(
1800
1800
  cache_conn: "sqlite3.Connection",
1801
- last_seen_max_id: int,
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 with
1806
- ``id > last_seen`` — expanded across ``session_id`` siblings so a resumed
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 ``last_seen``) so the fetch stays a single timestamp-ordered result —
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 new row ...
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 id > ?) af "
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 id > ?"
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, last_seen_max_id, last_seen_max_id),
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}")
@@ -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:
@@ -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
- bucket = by_bucket.setdefault(key, {
131
- "input": 0,
132
- "output": 0,
133
- "cache_create": 0,
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
- inp = int(usage.get("input_tokens", 0) or 0)
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(