cctally 1.8.1 → 1.8.2
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 +11 -0
- package/bin/_cctally_dashboard.py +158 -98
- package/bin/_cctally_tui.py +156 -31
- package/bin/_lib_view_models.py +784 -0
- package/bin/cctally +118 -34
- package/dashboard/static/assets/{index-CfXu9Fx_.js → index-cWE5HB8O.js} +2 -2
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +1 -1
package/bin/_lib_view_models.py
CHANGED
|
@@ -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
|
+
)
|