cctally 1.8.1 → 1.9.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.
@@ -28,6 +28,23 @@ Frozen ``*View`` dataclasses + builders:
28
28
  - ``TrendView`` + ``build_trend_view(conn, *, now_utc, n, display_tz)``
29
29
  - ``SessionsView`` + ``build_sessions_view(entries, *, now_utc, limit,
30
30
  display_tz)``
31
+ - ``BlocksView`` + ``build_blocks_view(entries, *, now_utc,
32
+ recorded_windows, block_start_overrides, range_start, range_end,
33
+ display_tz, mode)`` — heuristic-aware (cmd_blocks + dashboard); and
34
+ ``build_blocks_view_from_table_rows(block_dicts, *, period_start,
35
+ period_end, display_tz)`` — API-anchored (cmd_five_hour_blocks
36
+ share). Issue #56.
37
+ - ``ForecastView`` + ``build_forecast_view(conn, *, now_utc, targets,
38
+ skip_sync, display_tz)`` — wraps the existing math kernel
39
+ (``_load_forecast_inputs`` + ``_compute_forecast``) and surfaces the
40
+ per-method projection / verdict / header-routing / budget fields
41
+ consumers used to re-derive. Issue #57.
42
+ - ``CodexDailyView`` / ``CodexMonthlyView`` / ``CodexWeeklyView`` /
43
+ ``CodexSessionView`` + ``build_codex_{daily,monthly,weekly,session}_view``
44
+ — wrap the existing ``_aggregate_codex_*`` kernel; preserve the
45
+ intentional divergences from upstream (LiteLLM token semantics,
46
+ duplicate-event dedup, ``codex-session`` descending-by-last-activity,
47
+ ``CODEX_LEGACY_FALLBACK_MODEL`` warning). Issue #58.
31
48
 
32
49
  Each ``*View`` carries ``rows`` (typed row tuple) plus a parallel
33
50
  ``aggregated`` ``BucketUsage`` tuple where CLI byte-stable JSON requires
@@ -166,6 +183,29 @@ class MonthlyPeriodRow:
166
183
  models: list[dict[str, Any]]
167
184
 
168
185
 
186
+ @dataclass
187
+ class BlocksPanelRow:
188
+ """One row of the dashboard's Blocks panel.
189
+
190
+ Subset of the ``Block`` dataclass — drops token counts (panel is
191
+ cost-driven; tokens belong to a future modal), drops ``entries_count``
192
+ / ``is_gap`` / ``burn_rate`` / ``projection`` (panel doesn't render
193
+ them), and pre-formats ``label`` server-side for the local-tz
194
+ "HH:MM MMM DD" display.
195
+
196
+ Moved from ``bin/_cctally_tui.py`` alongside ``DailyPanelRow`` so
197
+ the BlocksView builder can construct rows without an import edge
198
+ back into the TUI module.
199
+ """
200
+ start_at: str # ISO-8601 UTC
201
+ end_at: str # ISO-8601 UTC, start_at + 5h
202
+ anchor: str # 'recorded' | 'heuristic'
203
+ is_active: bool # now_utc < end_at AND entries_count > 0
204
+ cost_usd: float
205
+ models: list[dict[str, Any]] # ModelCostRow shape, sorted desc by cost
206
+ label: str # "HH:MM MMM DD" in local tz, e.g. "14:00 Apr 26"
207
+
208
+
169
209
  @dataclass
170
210
  class DailyPanelRow:
171
211
  """One row of the dashboard's Daily heatmap panel.
@@ -625,6 +665,16 @@ class TrendView:
625
665
  dashboard envelope adapter emits it as ``trend.avg_dollars_per_pct``
626
666
  so the React layer doesn't re-derive.
627
667
 
668
+ ``median_dpp_non_current_4w`` is the median of the last 4 non-current
669
+ ``dollars_per_percent`` values (``None`` when fewer than 4 valid
670
+ samples). Matches the rule TrendModal.tsx's ``median4NonCurrent``
671
+ helper used to compute client-side; pre-computed on the View so
672
+ the dashboard envelope can surface it as
673
+ ``trend.history_median_dpp`` (issue #59). The 8-row panel call also
674
+ populates the field — but the dashboard envelope only surfaces the
675
+ 12-row history's median (panel modal vs panel-wide are different
676
+ summaries).
677
+
628
678
  Row ordering matches ``cmd_report`` / ``_tui_build_trend``:
629
679
  chronological (oldest first), suitable for the TUI sparkline left-
630
680
  to-right walk + cmd_report's `--json` trend list (which is then
@@ -632,6 +682,7 @@ class TrendView:
632
682
  """
633
683
  rows: "tuple[TuiTrendRow, ...]" = () # oldest-first
634
684
  avg_dollars_per_pct: "float | None" = None
685
+ median_dpp_non_current_4w: "float | None" = None
635
686
  period_start: "dt.datetime | None" = None
636
687
  period_end: "dt.datetime | None" = None
637
688
  display_tz_label: str = ""
@@ -865,9 +916,45 @@ def build_trend_view(conn, *, now_utc, n=8, display_tz=None):
865
916
  if r.dollars_per_percent is not None]
866
917
  avg = (sum(valid_dpps) / len(valid_dpps)) if len(valid_dpps) >= 3 else None
867
918
 
919
+ # Issue #59 — pre-compute the 4-week-median-non-current dpp scalar
920
+ # the dashboard's Trend modal hero KV displays. Rule mirrors
921
+ # ``TrendModal.tsx::median4NonCurrent`` byte-for-byte:
922
+ # * Drop EXACTLY ONE row by index — the same row
923
+ # ``findCurrentIndex`` would pick: the FIRST ``is_current``
924
+ # row, or ``rows.length - 1`` (the last row) when no row is
925
+ # marked current. This matters when (a) the Bug D credited-
926
+ # week split emits two rows with the same ``current_key``
927
+ # (both ``is_current=True``; we drop only the first) and (b)
928
+ # cost-only histories with no usage snapshot have every row
929
+ # ``is_current=False`` (we still drop the last row).
930
+ # * Keep only non-None / finite dpp values.
931
+ # * Take the LAST 4 (chronological-last, since ``rows`` is
932
+ # oldest-first); sort ascending; return the midpoint
933
+ # ``(s[1] + s[2]) / 2``.
934
+ # Returns ``None`` when fewer than 4 non-current valid samples
935
+ # remain (matches the modal's empty-state). The 8-row panel call
936
+ # populates this too — harmless because the envelope only surfaces
937
+ # the 12-row history's value.
938
+ if rows:
939
+ cur_idx = next(
940
+ (i for i, r in enumerate(rows) if r.is_current),
941
+ len(rows) - 1,
942
+ )
943
+ else:
944
+ cur_idx = -1
945
+ non_cur_dpps = [r.dollars_per_percent
946
+ for i, r in enumerate(rows)
947
+ if i != cur_idx and r.dollars_per_percent is not None]
948
+ if len(non_cur_dpps) >= 4:
949
+ last4 = sorted(non_cur_dpps[-4:])
950
+ median_dpp_4w = (last4[1] + last4[2]) / 2
951
+ else:
952
+ median_dpp_4w = None
953
+
868
954
  return TrendView(
869
955
  rows=tuple(rows),
870
956
  avg_dollars_per_pct=avg,
957
+ median_dpp_non_current_4w=median_dpp_4w,
871
958
  period_start=None,
872
959
  period_end=now_utc,
873
960
  display_tz_label=_display_tz_label(display_tz),
@@ -991,3 +1078,700 @@ def build_sessions_view(entries, *, now_utc, limit=None, display_tz=None):
991
1078
  period_end=now_utc,
992
1079
  display_tz_label=_display_tz_label(display_tz),
993
1080
  )
1081
+
1082
+
1083
+ # === BlocksView + build_blocks_view (Issue #56) ============================
1084
+
1085
+
1086
+ @dataclass(frozen=True)
1087
+ class BlocksView:
1088
+ """Blocks domain view — covers two structurally distinct paths under
1089
+ one dataclass.
1090
+
1091
+ 1. **Heuristic-aware** (``cmd_blocks`` + dashboard Blocks panel):
1092
+ built via ``build_blocks_view(entries, ...)``. Calls
1093
+ ``_lib_blocks._group_entries_into_blocks`` and fills BOTH
1094
+ ``rows`` (``tuple[BlocksPanelRow, ...]`` — non-gap, dashboard-
1095
+ shape, newest-first) and ``aggregated`` (``tuple[Block, ...]``
1096
+ — gaps included, CLI-shape, oldest-first per
1097
+ ``_group_entries_into_blocks``'s contract). ``total_cost_usd``
1098
+ / ``total_tokens`` are summed over non-gap blocks so the React
1099
+ panel's footer ``total === sum(visible rows)`` invariant holds.
1100
+
1101
+ 2. **API-anchored** (``cmd_five_hour_blocks`` + share snapshot):
1102
+ built via ``build_blocks_view_from_table_rows(rows, ...)``.
1103
+ Reads sqlite-Row-derived dicts from the ``five_hour_blocks``
1104
+ TABLE; leaves ``rows`` empty (consumers read ``aggregated``
1105
+ directly). Reset-aware (CLAUDE.md 5-hour gotcha block,
1106
+ spec §3.2) — totals come from the table's per-block columns,
1107
+ NOT recomputed from ``session_entries``.
1108
+
1109
+ Both builders return BlocksView; consumers branch on which field
1110
+ they need. ``period_start`` / ``period_end`` carry time-window
1111
+ bounds (used by the share path's ``PeriodSpec`` and by future
1112
+ consumers that need a period label).
1113
+ """
1114
+ rows: "tuple[BlocksPanelRow, ...]" = ()
1115
+ aggregated: tuple = () # tuple[Block, ...] for heuristic; tuple[dict, ...] for API-anchored. Forward-ref kept untyped to avoid an import-time edge into _lib_blocks.
1116
+ total_cost_usd: float = 0.0
1117
+ total_tokens: int = 0
1118
+ period_start: "dt.datetime | None" = None
1119
+ period_end: "dt.datetime | None" = None
1120
+ display_tz_label: str = ""
1121
+
1122
+
1123
+ def build_blocks_view(
1124
+ entries,
1125
+ *,
1126
+ now_utc,
1127
+ recorded_windows=None,
1128
+ block_start_overrides=None,
1129
+ range_start=None,
1130
+ range_end=None,
1131
+ display_tz=None,
1132
+ mode="auto",
1133
+ skip_rows: bool = False,
1134
+ ):
1135
+ """Build a ``BlocksView`` from raw ``UsageEntry`` list (heuristic-
1136
+ aware path; spec §6 blocks follow-up).
1137
+
1138
+ ``aggregated`` carries the full Block list (gaps included), in
1139
+ oldest-first order — consumed by ``cmd_blocks`` via
1140
+ ``_blocks_to_json`` / ``_render_blocks_table``. ``rows`` carries
1141
+ non-gap ``BlocksPanelRow`` entries (newest-first) — consumed by
1142
+ ``_dashboard_build_blocks_panel`` and the dashboard envelope
1143
+ serializer.
1144
+
1145
+ Per-row enrichment for the dashboard rows uses
1146
+ ``_lib_pricing._calculate_entry_cost`` (single pricing source-of-
1147
+ truth, mirrors the historical inline body of
1148
+ ``_dashboard_build_blocks_panel``). ``label`` is pre-formatted via
1149
+ ``format_display_dt`` ("HH:MM MMM DD" in display_tz).
1150
+
1151
+ Totals are summed over non-gap blocks (gaps contribute zero by
1152
+ construction — they carry zero cost / tokens). Caller-supplied
1153
+ ``range_start`` / ``range_end`` override the period metadata when
1154
+ provided (the CLI ``cmd_blocks`` --since/--until path passes them
1155
+ explicitly; the dashboard path passes the week window).
1156
+
1157
+ ``skip_rows=True`` skips the dashboard-row construction loop
1158
+ entirely — leaves ``view.rows = ()`` while still populating
1159
+ ``aggregated`` + totals. The per-block per-model enrichment scans
1160
+ every entry for every non-gap block (O(B × N)); the CLI
1161
+ ``cmd_blocks`` reads only ``view.aggregated`` and discards rows,
1162
+ so it opts in to skip that work on large histories. Dashboard /
1163
+ share callers leave the default (``False``) to keep their
1164
+ consumers fed.
1165
+ """
1166
+ _lib_blocks = _load_lib("_lib_blocks")
1167
+ _lib_pricing = _load_lib("_lib_pricing")
1168
+ c = _cctally()
1169
+ blocks = _lib_blocks._group_entries_into_blocks(
1170
+ entries,
1171
+ mode=mode,
1172
+ recorded_windows=recorded_windows,
1173
+ block_start_overrides=block_start_overrides,
1174
+ now=now_utc,
1175
+ )
1176
+ rows: list = []
1177
+ total_cost = 0.0
1178
+ total_tok = 0
1179
+ if blocks:
1180
+ for b in blocks:
1181
+ if b.is_gap:
1182
+ continue
1183
+ if not skip_rows:
1184
+ # Per-block per-model breakdown for the dashboard row.
1185
+ # Mirrors `_dashboard_build_blocks_panel`'s historical
1186
+ # inline body — re-aggregates entries inside the block
1187
+ # interval through the single pricing chokepoint so per-
1188
+ # model costs reconcile exactly with `b.cost_usd`.
1189
+ per_model: dict[str, float] = {}
1190
+ for e in entries:
1191
+ if b.start_time <= e.timestamp < b.end_time:
1192
+ cost = _lib_pricing._calculate_entry_cost(
1193
+ e.model, e.usage, mode=mode, cost_usd=e.cost_usd,
1194
+ )
1195
+ per_model[e.model] = per_model.get(e.model, 0.0) + cost
1196
+ model_breakdowns = [
1197
+ {"modelName": name, "cost": cost}
1198
+ for name, cost in sorted(
1199
+ per_model.items(), key=lambda kv: -kv[1],
1200
+ )
1201
+ ]
1202
+ local_label = c.format_display_dt(
1203
+ b.start_time, display_tz, fmt="%H:%M %b %d", suffix=True,
1204
+ )
1205
+ rows.append(BlocksPanelRow(
1206
+ start_at=b.start_time.astimezone(dt.timezone.utc).isoformat(),
1207
+ end_at=b.end_time.astimezone(dt.timezone.utc).isoformat(),
1208
+ anchor=b.anchor,
1209
+ is_active=bool(b.is_active and b.entries_count > 0),
1210
+ cost_usd=b.cost_usd,
1211
+ models=_model_breakdowns_to_models_late(
1212
+ model_breakdowns, b.cost_usd,
1213
+ ),
1214
+ label=local_label,
1215
+ ))
1216
+ total_cost += b.cost_usd
1217
+ total_tok += b.total_tokens
1218
+ rows.sort(key=lambda r: r.start_at, reverse=True)
1219
+
1220
+ # Period defaults: caller-supplied range wins; otherwise fall back
1221
+ # to block extent (first block's start) so the share builder /
1222
+ # period-label paths get a sensible window.
1223
+ period_start_dt = range_start
1224
+ if period_start_dt is None and blocks:
1225
+ period_start_dt = blocks[0].start_time
1226
+ period_end_dt = range_end or now_utc
1227
+
1228
+ return BlocksView(
1229
+ rows=tuple(rows),
1230
+ aggregated=tuple(blocks),
1231
+ total_cost_usd=total_cost,
1232
+ total_tokens=total_tok,
1233
+ period_start=period_start_dt,
1234
+ period_end=period_end_dt,
1235
+ display_tz_label=_display_tz_label(display_tz),
1236
+ )
1237
+
1238
+
1239
+ def build_blocks_view_from_table_rows(
1240
+ block_dicts,
1241
+ *,
1242
+ period_start=None,
1243
+ period_end=None,
1244
+ display_tz=None,
1245
+ ):
1246
+ """Build a ``BlocksView`` from API-anchored ``five_hour_blocks``
1247
+ table rows (issue #56 — share path).
1248
+
1249
+ Reset-aware totals (CLAUDE.md 5-hour gotcha block, spec §3.2):
1250
+ ``total_cost_usd`` is summed from each row's ``total_cost_usd``
1251
+ column (already credit-aware at write time);
1252
+ ``total_tokens`` is summed across the four token columns
1253
+ (``total_input_tokens`` + ``total_output_tokens`` +
1254
+ ``total_cache_create_tokens`` + ``total_cache_read_tokens``).
1255
+ No recomputation from ``session_entries`` — preserves the
1256
+ write-time invariant that ``five_hour_blocks.total_cost_usd``
1257
+ is the authoritative per-block cost.
1258
+
1259
+ ``rows`` is left empty — the API-anchored consumers
1260
+ (``_five_hour_blocks_to_json``, ``_render_five_hour_blocks_table``,
1261
+ ``_build_five_hour_blocks_snapshot``) read ``aggregated`` (the
1262
+ underlying dict list) directly. ``BlocksPanelRow`` doesn't carry
1263
+ the API-anchored extras (``final_five_hour_percent``,
1264
+ ``crossed_seven_day_reset``, ``credits``, ...) so synthesizing
1265
+ rows on this path would lose data.
1266
+
1267
+ ``block_dicts`` is consumed as-is; the caller controls ordering
1268
+ (``cmd_five_hour_blocks`` produces newest-first DESC).
1269
+ """
1270
+ rows_seq = list(block_dicts)
1271
+ total_cost = sum(
1272
+ float(d.get("total_cost_usd") or 0.0) for d in rows_seq
1273
+ )
1274
+ total_tok = sum(
1275
+ int(d.get("total_input_tokens") or 0)
1276
+ + int(d.get("total_output_tokens") or 0)
1277
+ + int(d.get("total_cache_create_tokens") or 0)
1278
+ + int(d.get("total_cache_read_tokens") or 0)
1279
+ for d in rows_seq
1280
+ )
1281
+ return BlocksView(
1282
+ rows=(),
1283
+ aggregated=tuple(rows_seq),
1284
+ total_cost_usd=total_cost,
1285
+ total_tokens=total_tok,
1286
+ period_start=period_start,
1287
+ period_end=period_end,
1288
+ display_tz_label=_display_tz_label(display_tz),
1289
+ )
1290
+
1291
+
1292
+ # === ForecastView + build_forecast_view (Issue #57) ========================
1293
+
1294
+
1295
+ _FORECAST_VERDICT_GOOD = "GOOD"
1296
+ _FORECAST_VERDICT_WARN = "WARN"
1297
+ _FORECAST_VERDICT_OVER = "OVER"
1298
+ _FORECAST_VERDICT_LOW_CONF = "LOW CONF"
1299
+
1300
+
1301
+ @dataclass(frozen=True)
1302
+ class ForecastView:
1303
+ """Forecast domain view — wraps the existing math kernel.
1304
+
1305
+ Unlike the rows-shaped domain views, ``ForecastView`` projects a
1306
+ *singular* week into the future. The wrapped ``output`` carries the
1307
+ full ``ForecastOutput`` math result (inputs + r_avg + r_recent +
1308
+ final_percent_{low,high} + budgets[] + cap_at), and the View
1309
+ additively surfaces fields that consumers used to re-derive:
1310
+
1311
+ * ``verdict`` — TUI design-language mapping ("GOOD" / "WARN" /
1312
+ "OVER" / "LOW CONF"). Mirrors ``_tui_verdict_of``.
1313
+ * ``dashboard_verdict`` — dashboard envelope's mapping ("ok" /
1314
+ "cap" / "capped"). Mirrors the per-method routing in
1315
+ ``snapshot_to_envelope``.
1316
+ * ``week_avg_projection_pct`` / ``recent_24h_projection_pct`` —
1317
+ per-method projections from ``r_avg`` / ``r_recent``. The
1318
+ recent-24h value is ``None`` when ``r_recent`` is ``None`` or its
1319
+ projection equals ``week_avg_projection_pct`` (no new info).
1320
+ Routing labels stay correct on decelerating weeks where
1321
+ ``r_recent < r_avg``.
1322
+ * ``header_projection_pct`` — "pick pessimistic when verdict
1323
+ warns" routing the dashboard header runs. Surfaced once on the
1324
+ view so the header field and the verdict pill always tell the
1325
+ same story.
1326
+ * ``budget_100_per_day_usd`` / ``budget_90_per_day_usd`` — the
1327
+ matching ``BudgetRow.dollars_per_day`` values, ``None`` when the
1328
+ target is out of headroom.
1329
+ * ``confidence`` / ``low_confidence`` / ``low_confidence_reasons`` —
1330
+ mirrors ``inputs.confidence``. Surfaced separately so callers can
1331
+ key on ``view.low_confidence`` without crawling
1332
+ ``view.output.inputs``.
1333
+
1334
+ ``output`` is ``None`` when ``_load_forecast_inputs`` returned
1335
+ ``None`` (no current-week snapshot). The View still constructs in
1336
+ that case so consumers can render an empty-state from a uniformly-
1337
+ shaped object; ``verdict`` is then ``"LOW CONF"`` and the projection
1338
+ / budget fields are ``None``.
1339
+
1340
+ ``period_start`` / ``period_end`` carry the subscription-week
1341
+ bounds (``inputs.week_start_at`` / ``inputs.week_end_at``), mirroring
1342
+ the other domain views.
1343
+ """
1344
+ output: Any | None = None # ForecastOutput | None — forward-ref kept untyped to avoid an import-time edge into cctally's dataclasses.
1345
+ verdict: str = _FORECAST_VERDICT_LOW_CONF
1346
+ dashboard_verdict: str = "ok"
1347
+ confidence: str = "unknown"
1348
+ low_confidence: bool = False
1349
+ low_confidence_reasons: tuple = ()
1350
+ week_avg_projection_pct: "float | None" = None
1351
+ recent_24h_projection_pct: "float | None" = None
1352
+ header_projection_pct: "float | None" = None
1353
+ budget_100_per_day_usd: "float | None" = None
1354
+ budget_90_per_day_usd: "float | None" = None
1355
+ period_start: "dt.datetime | None" = None
1356
+ period_end: "dt.datetime | None" = None
1357
+ display_tz_label: str = ""
1358
+ targets: tuple = ()
1359
+
1360
+
1361
+ def _forecast_verdict_of(output) -> str:
1362
+ """Design-language verdict for a ``ForecastOutput``. Mirrors
1363
+ ``_tui_verdict_of`` but lives on the view-model layer so consumers
1364
+ don't have to round-trip through ``_cctally_tui``.
1365
+
1366
+ None output OR low confidence → ``"LOW CONF"``. Otherwise threshold
1367
+ on ``final_percent_high``: ≥100 → OVER, ≥90 → WARN, else GOOD.
1368
+ """
1369
+ if output is None:
1370
+ return _FORECAST_VERDICT_LOW_CONF
1371
+ inputs = getattr(output, "inputs", None)
1372
+ if inputs is not None and getattr(inputs, "confidence", "high") == "low":
1373
+ return _FORECAST_VERDICT_LOW_CONF
1374
+ high = float(getattr(output, "final_percent_high", 0.0))
1375
+ if high >= 100:
1376
+ return _FORECAST_VERDICT_OVER
1377
+ if high >= 90:
1378
+ return _FORECAST_VERDICT_WARN
1379
+ return _FORECAST_VERDICT_GOOD
1380
+
1381
+
1382
+ def _forecast_dashboard_verdict_of(output) -> str:
1383
+ """Dashboard-envelope verdict ("ok"/"cap"/"capped"). Pure helper
1384
+ used by ``snapshot_to_envelope`` and ``build_forecast_view``."""
1385
+ if output is None:
1386
+ return "ok"
1387
+ if getattr(output, "already_capped", False):
1388
+ return "capped"
1389
+ if getattr(output, "projected_cap", False):
1390
+ return "cap"
1391
+ return "ok"
1392
+
1393
+
1394
+ def _forecast_projection_pcts(output) -> "tuple[float | None, float | None]":
1395
+ """Return (week_avg_projection_pct, recent_24h_projection_pct).
1396
+
1397
+ Decomposes the dual-method projections from ``r_avg`` / ``r_recent``
1398
+ + ``inputs.p_now`` + ``inputs.remaining_hours``. Mirrors the routing
1399
+ in ``snapshot_to_envelope``: recent-24h is ``None`` when ``r_recent``
1400
+ is ``None`` or its projection equals the week-avg projection (no
1401
+ new info — a second method that agrees with the first contributes
1402
+ nothing to the user-facing range).
1403
+ """
1404
+ if output is None:
1405
+ return None, None
1406
+ inputs = getattr(output, "inputs", None)
1407
+ if inputs is None:
1408
+ return None, None
1409
+ p_now = getattr(inputs, "p_now", None)
1410
+ rem = getattr(inputs, "remaining_hours", None)
1411
+ r_avg = getattr(output, "r_avg", None)
1412
+ r_recent = getattr(output, "r_recent", None)
1413
+ week_avg_pct = None
1414
+ if p_now is not None and rem is not None and r_avg is not None:
1415
+ week_avg_pct = p_now + r_avg * rem
1416
+ recent_pct = None
1417
+ if p_now is not None and rem is not None and r_recent is not None:
1418
+ candidate = p_now + r_recent * rem
1419
+ # Suppress the second projection only when it adds no info.
1420
+ if week_avg_pct is None or candidate != week_avg_pct:
1421
+ recent_pct = candidate
1422
+ return week_avg_pct, recent_pct
1423
+
1424
+
1425
+ def _forecast_header_projection_pct(
1426
+ week_avg_pct: "float | None",
1427
+ recent_24h_pct: "float | None",
1428
+ dashboard_verdict: str,
1429
+ ) -> "float | None":
1430
+ """Header field routing: when the verdict warns ("cap"/"capped")
1431
+ and recent-24h is the more pessimistic of the two, surface that
1432
+ so the header number and the verdict pill agree. Otherwise the
1433
+ week-avg projection wins (the historical default).
1434
+ """
1435
+ if (
1436
+ dashboard_verdict in ("cap", "capped")
1437
+ and recent_24h_pct is not None
1438
+ and week_avg_pct is not None
1439
+ and recent_24h_pct > week_avg_pct
1440
+ ):
1441
+ return recent_24h_pct
1442
+ return week_avg_pct
1443
+
1444
+
1445
+ def _forecast_budgets(output) -> "tuple[float | None, float | None]":
1446
+ """Pull the (100%, 90%) ``BudgetRow.dollars_per_day`` pair from a
1447
+ ``ForecastOutput.budgets`` list. Either may be ``None`` when the
1448
+ target is out of headroom (``BudgetRow.dollars_per_day is None``).
1449
+ """
1450
+ if output is None:
1451
+ return None, None
1452
+ b100 = None
1453
+ b90 = None
1454
+ for b in getattr(output, "budgets", None) or []:
1455
+ tp = getattr(b, "target_percent", None)
1456
+ dpd = getattr(b, "dollars_per_day", None)
1457
+ if tp == 100:
1458
+ b100 = dpd
1459
+ elif tp == 90:
1460
+ b90 = dpd
1461
+ return b100, b90
1462
+
1463
+
1464
+ def build_forecast_view(
1465
+ conn,
1466
+ *,
1467
+ now_utc,
1468
+ targets=(100, 90),
1469
+ skip_sync: bool = False,
1470
+ display_tz=None,
1471
+ ):
1472
+ """Build a ``ForecastView`` (issue #57).
1473
+
1474
+ Wraps the existing math kernel (``_load_forecast_inputs`` +
1475
+ ``_compute_forecast``) without duplicating logic. Always returns a
1476
+ ``ForecastView`` — when ``_load_forecast_inputs`` returns ``None``
1477
+ (no current-week snapshot), the View constructs with
1478
+ ``output=None`` + ``verdict="LOW CONF"`` so empty-state callers
1479
+ don't branch on the wrapper itself.
1480
+
1481
+ ``targets`` are the percent ceilings forwarded to
1482
+ ``_compute_forecast`` (default ``(100, 90)`` — matches both
1483
+ ``cmd_forecast``'s ``--targets`` default and the TUI sync thread's
1484
+ hard-coded value). ``skip_sync`` honours
1485
+ ``cctally forecast --no-sync`` (and the dashboard's sync-thread
1486
+ refresh skip).
1487
+ """
1488
+ c = _cctally()
1489
+ inputs = c._load_forecast_inputs(conn, now_utc, skip_sync=skip_sync)
1490
+ if inputs is None:
1491
+ return ForecastView(
1492
+ output=None,
1493
+ verdict=_FORECAST_VERDICT_LOW_CONF,
1494
+ dashboard_verdict="ok",
1495
+ confidence="unknown",
1496
+ low_confidence=False,
1497
+ low_confidence_reasons=(),
1498
+ week_avg_projection_pct=None,
1499
+ recent_24h_projection_pct=None,
1500
+ header_projection_pct=None,
1501
+ budget_100_per_day_usd=None,
1502
+ budget_90_per_day_usd=None,
1503
+ period_start=None,
1504
+ period_end=None,
1505
+ display_tz_label=_display_tz_label(display_tz),
1506
+ targets=tuple(int(t) for t in targets),
1507
+ )
1508
+ output = c._compute_forecast(inputs, list(int(t) for t in targets))
1509
+ verdict = _forecast_verdict_of(output)
1510
+ dashboard_verdict = _forecast_dashboard_verdict_of(output)
1511
+ week_avg_pct, recent_pct = _forecast_projection_pcts(output)
1512
+ header_pct = _forecast_header_projection_pct(
1513
+ week_avg_pct, recent_pct, dashboard_verdict,
1514
+ )
1515
+ b100, b90 = _forecast_budgets(output)
1516
+ confidence = getattr(inputs, "confidence", "high")
1517
+ return ForecastView(
1518
+ output=output,
1519
+ verdict=verdict,
1520
+ dashboard_verdict=dashboard_verdict,
1521
+ confidence=confidence,
1522
+ low_confidence=(confidence == "low"),
1523
+ low_confidence_reasons=tuple(
1524
+ getattr(inputs, "low_confidence_reasons", None) or ()
1525
+ ),
1526
+ week_avg_projection_pct=week_avg_pct,
1527
+ recent_24h_projection_pct=recent_pct,
1528
+ header_projection_pct=header_pct,
1529
+ budget_100_per_day_usd=b100,
1530
+ budget_90_per_day_usd=b90,
1531
+ period_start=getattr(inputs, "week_start_at", None),
1532
+ period_end=getattr(inputs, "week_end_at", None),
1533
+ display_tz_label=_display_tz_label(display_tz),
1534
+ targets=tuple(int(t) for t in targets),
1535
+ )
1536
+
1537
+
1538
+ # === Codex domain views + builders (Issue #58) =============================
1539
+ #
1540
+ # Codex domain is CLI-only — no dashboard panel, no share consumer. The
1541
+ # four views below wrap the existing ``_aggregate_codex_{daily,monthly,
1542
+ # weekly,sessions}`` math kernel without changing it, so the
1543
+ # intentional divergences from upstream documented in CLAUDE.md (LiteLLM
1544
+ # token semantics, duplicate-event dedup, descending-by-last-activity
1545
+ # session sort, ``CODEX_LEGACY_FALLBACK_MODEL`` warning) are preserved
1546
+ # end-to-end.
1547
+ #
1548
+ # Naming differences from the Claude views are deliberate:
1549
+ #
1550
+ # - The slot carrying the aggregator output is named ``rows`` (not
1551
+ # ``aggregated``) — Codex has no parallel typed surface row dataclass
1552
+ # to pair with, so the aggregator's typed output IS the surface (same
1553
+ # precedent as ``TrendView.rows`` of typed ``TuiTrendRow``).
1554
+ # - ``display_tz_label`` is the already-resolved string label
1555
+ # (``tz_name or _local_tz_name()``), not the ``zoneinfo.ZoneInfo.key``
1556
+ # the Claude views emit via ``_display_tz_label(tzinfo)``. Codex
1557
+ # commands plumb a string ``tz_name`` end-to-end (see
1558
+ # ``_resolve_codex_tz_name``); the View carries the rendered label so
1559
+ # ``cmd_codex_*`` can read it directly.
1560
+ #
1561
+ # Bucket ordering: ``_aggregate_codex_daily`` / ``_aggregate_codex_monthly``
1562
+ # / ``_aggregate_codex_weekly`` return ASC (earliest bucket first); the
1563
+ # View carries that order. ``cmd_codex_*`` reverses to DESC when
1564
+ # ``--order desc``.
1565
+ #
1566
+ # Session ordering: ``_aggregate_codex_sessions`` returns DESC
1567
+ # (most-recent last_activity first); the View carries that order.
1568
+ # ``cmd_codex_session`` reverses to ASC when ``--order asc``. The
1569
+ # upstream-parity DESC default matches ``ccusage-codex``'s session view.
1570
+
1571
+
1572
+ @dataclass(frozen=True)
1573
+ class CodexDailyView:
1574
+ """Codex daily-bucket view (CLI-only).
1575
+
1576
+ ``rows`` is the parallel ``CodexBucketUsage`` tuple in ASC order
1577
+ (earliest bucket first) — same as the aggregator's default.
1578
+ ``cmd_codex_daily`` reverses for ``--order desc``.
1579
+ """
1580
+ rows: tuple = () # tuple[CodexBucketUsage, ...]
1581
+ total_cost_usd: float = 0.0
1582
+ total_tokens: int = 0
1583
+ period_start: "dt.datetime | None" = None
1584
+ period_end: "dt.datetime | None" = None
1585
+ display_tz_label: str = ""
1586
+
1587
+
1588
+ @dataclass(frozen=True)
1589
+ class CodexMonthlyView:
1590
+ """Codex monthly-bucket view (CLI-only).
1591
+
1592
+ ``rows`` is the parallel ``CodexBucketUsage`` tuple in ASC order
1593
+ (earliest bucket first). ``cmd_codex_monthly`` reverses for
1594
+ ``--order desc``.
1595
+ """
1596
+ rows: tuple = () # tuple[CodexBucketUsage, ...]
1597
+ total_cost_usd: float = 0.0
1598
+ total_tokens: int = 0
1599
+ period_start: "dt.datetime | None" = None
1600
+ period_end: "dt.datetime | None" = None
1601
+ display_tz_label: str = ""
1602
+
1603
+
1604
+ @dataclass(frozen=True)
1605
+ class CodexWeeklyView:
1606
+ """Codex weekly-bucket view (CLI-only).
1607
+
1608
+ ``rows`` is the parallel ``CodexBucketUsage`` tuple in ASC order
1609
+ (earliest week-start first). ``cmd_codex_weekly`` reverses for
1610
+ ``--order desc``. Week-start day is resolved by the caller
1611
+ (``week_start_idx``) from config.json + ``WEEKDAY_MAP``.
1612
+ """
1613
+ rows: tuple = () # tuple[CodexBucketUsage, ...]
1614
+ total_cost_usd: float = 0.0
1615
+ total_tokens: int = 0
1616
+ period_start: "dt.datetime | None" = None
1617
+ period_end: "dt.datetime | None" = None
1618
+ display_tz_label: str = ""
1619
+
1620
+
1621
+ @dataclass(frozen=True)
1622
+ class CodexSessionView:
1623
+ """Codex session view (CLI-only).
1624
+
1625
+ ``rows`` is the parallel ``CodexSessionUsage`` tuple in DESC order
1626
+ (most-recent ``last_activity`` first) — matches upstream
1627
+ ``ccusage-codex`` and the aggregator's default sort.
1628
+ ``cmd_codex_session`` reverses for ``--order asc``.
1629
+ """
1630
+ rows: tuple = () # tuple[CodexSessionUsage, ...]
1631
+ total_sessions: int = 0
1632
+ total_cost_usd: float = 0.0
1633
+ total_tokens: int = 0
1634
+ period_start: "dt.datetime | None" = None
1635
+ period_end: "dt.datetime | None" = None
1636
+ display_tz_label: str = ""
1637
+
1638
+
1639
+ def _codex_tz_label(tz_name: "str | None") -> str:
1640
+ """Render the timezone label the way ``cmd_codex_*`` already does
1641
+ (``tz_name or _local_tz_name()``). Centralized here so the four
1642
+ builders share one chokepoint."""
1643
+ if tz_name:
1644
+ return tz_name
1645
+ return _cctally()._local_tz_name()
1646
+
1647
+
1648
+ def _codex_bucket_totals(buckets) -> "tuple[float, int]":
1649
+ """Sum ``cost_usd`` and ``total_tokens`` across a
1650
+ ``CodexBucketUsage`` list."""
1651
+ total_cost = 0.0
1652
+ total_tok = 0
1653
+ for b in buckets:
1654
+ total_cost += b.cost_usd
1655
+ total_tok += b.total_tokens
1656
+ return total_cost, total_tok
1657
+
1658
+
1659
+ def _codex_period_start_from_date_bucket(buckets) -> "dt.datetime | None":
1660
+ """Parse the earliest ``YYYY-MM-DD`` bucket key (daily / weekly)
1661
+ into a UTC datetime at midnight. ``None`` when ``buckets`` is empty."""
1662
+ if not buckets:
1663
+ return None
1664
+ try:
1665
+ d = dt.date.fromisoformat(buckets[0].bucket)
1666
+ except ValueError:
1667
+ return None
1668
+ return dt.datetime.combine(d, dt.time.min, tzinfo=dt.timezone.utc)
1669
+
1670
+
1671
+ def _codex_period_start_from_month_bucket(buckets) -> "dt.datetime | None":
1672
+ """Parse the earliest ``YYYY-MM`` bucket key (monthly) into a UTC
1673
+ datetime at the 1st-of-month midnight. ``None`` when ``buckets`` is
1674
+ empty or the key is malformed."""
1675
+ if not buckets:
1676
+ return None
1677
+ try:
1678
+ yr, mo = buckets[0].bucket.split("-")
1679
+ return dt.datetime(int(yr), int(mo), 1, tzinfo=dt.timezone.utc)
1680
+ except (ValueError, IndexError):
1681
+ return None
1682
+
1683
+
1684
+ def build_codex_daily_view(entries, *, now_utc, tz_name=None):
1685
+ """Build a ``CodexDailyView`` from a list of ``CodexEntry`` (issue #58).
1686
+
1687
+ Delegates bucketing to ``_aggregate_codex_daily`` (LiteLLM-snapshot
1688
+ pricing + Codex token semantics — see CLAUDE.md "Codex (OpenAI)
1689
+ parity" gotcha block). ``tz_name`` plumbs through verbatim
1690
+ (None → host-local fallback inside the aggregator).
1691
+ """
1692
+ _agg = _load_lib("_lib_aggregators")
1693
+ buckets = _agg._aggregate_codex_daily(entries, tz_name=tz_name)
1694
+ total_cost, total_tok = _codex_bucket_totals(buckets)
1695
+ return CodexDailyView(
1696
+ rows=tuple(buckets),
1697
+ total_cost_usd=total_cost,
1698
+ total_tokens=total_tok,
1699
+ period_start=_codex_period_start_from_date_bucket(buckets),
1700
+ period_end=now_utc,
1701
+ display_tz_label=_codex_tz_label(tz_name),
1702
+ )
1703
+
1704
+
1705
+ def build_codex_monthly_view(entries, *, now_utc, tz_name=None):
1706
+ """Build a ``CodexMonthlyView`` from a list of ``CodexEntry`` (issue #58).
1707
+
1708
+ Same wrap-the-kernel posture as ``build_codex_daily_view``; bucket
1709
+ key is ``YYYY-MM`` so ``period_start`` resolves to the 1st of the
1710
+ earliest visible month at UTC midnight.
1711
+ """
1712
+ _agg = _load_lib("_lib_aggregators")
1713
+ buckets = _agg._aggregate_codex_monthly(entries, tz_name=tz_name)
1714
+ total_cost, total_tok = _codex_bucket_totals(buckets)
1715
+ return CodexMonthlyView(
1716
+ rows=tuple(buckets),
1717
+ total_cost_usd=total_cost,
1718
+ total_tokens=total_tok,
1719
+ period_start=_codex_period_start_from_month_bucket(buckets),
1720
+ period_end=now_utc,
1721
+ display_tz_label=_codex_tz_label(tz_name),
1722
+ )
1723
+
1724
+
1725
+ def build_codex_weekly_view(entries, *, now_utc, tz_name=None,
1726
+ week_start_idx=0):
1727
+ """Build a ``CodexWeeklyView`` from a list of ``CodexEntry`` (issue #58).
1728
+
1729
+ ``week_start_idx`` is the resolved Mon=0..Sun=6 index the caller
1730
+ pulls from config via ``get_week_start_name`` + ``WEEKDAY_MAP``.
1731
+ Bucket key is the ISO date of the week's first day in the display
1732
+ timezone (matches ``_aggregate_codex_weekly`` contract).
1733
+ """
1734
+ _agg = _load_lib("_lib_aggregators")
1735
+ buckets = _agg._aggregate_codex_weekly(entries, tz_name, week_start_idx)
1736
+ total_cost, total_tok = _codex_bucket_totals(buckets)
1737
+ return CodexWeeklyView(
1738
+ rows=tuple(buckets),
1739
+ total_cost_usd=total_cost,
1740
+ total_tokens=total_tok,
1741
+ period_start=_codex_period_start_from_date_bucket(buckets),
1742
+ period_end=now_utc,
1743
+ display_tz_label=_codex_tz_label(tz_name),
1744
+ )
1745
+
1746
+
1747
+ def build_codex_session_view(entries, *, now_utc, tz_name=None):
1748
+ """Build a ``CodexSessionView`` from a list of ``CodexEntry`` (issue #58).
1749
+
1750
+ ``rows`` order mirrors the aggregator: descending by
1751
+ ``last_activity`` (upstream parity).
1752
+ ``cmd_codex_session`` reverses to ASC when ``--order asc``.
1753
+
1754
+ ``period_start`` is set to ``min(s.last_activity)`` across emitted
1755
+ sessions when any exist — best-available approximation since
1756
+ ``CodexSessionUsage`` doesn't carry a ``first_activity`` field (the
1757
+ aggregator only tracks ``last`` per session). ``None`` on empty.
1758
+ """
1759
+ _agg = _load_lib("_lib_aggregators")
1760
+ sessions = _agg._aggregate_codex_sessions(entries)
1761
+ total_cost = 0.0
1762
+ total_tok = 0
1763
+ earliest = None
1764
+ for s in sessions:
1765
+ total_cost += s.cost_usd
1766
+ total_tok += s.total_tokens
1767
+ if earliest is None or s.last_activity < earliest:
1768
+ earliest = s.last_activity
1769
+ return CodexSessionView(
1770
+ rows=tuple(sessions),
1771
+ total_sessions=len(sessions),
1772
+ total_cost_usd=total_cost,
1773
+ total_tokens=total_tok,
1774
+ period_start=earliest,
1775
+ period_end=now_utc,
1776
+ display_tz_label=_codex_tz_label(tz_name),
1777
+ )