cctally 1.27.1 → 1.29.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.
@@ -269,6 +269,30 @@ def _build_alert_payload_project_budget(*args, **kwargs):
269
269
  return sys.modules["cctally"]._build_alert_payload_project_budget(*args, **kwargs)
270
270
 
271
271
 
272
+ def _build_alert_payload_codex_budget(*args, **kwargs):
273
+ return sys.modules["cctally"]._build_alert_payload_codex_budget(*args, **kwargs)
274
+
275
+
276
+ def _budget_crossings(*args, **kwargs):
277
+ return sys.modules["cctally"]._budget_crossings(*args, **kwargs)
278
+
279
+
280
+ def _resolve_budget_window(*args, **kwargs):
281
+ return sys.modules["cctally"]._resolve_budget_window(*args, **kwargs)
282
+
283
+
284
+ def _budget_spend_for_vendor(*args, **kwargs):
285
+ return sys.modules["cctally"]._budget_spend_for_vendor(*args, **kwargs)
286
+
287
+
288
+ def _resolve_codex_budget_period_window(*args, **kwargs):
289
+ return sys.modules["cctally"]._resolve_codex_budget_period_window(*args, **kwargs)
290
+
291
+
292
+ def resolve_display_tz(*args, **kwargs):
293
+ return sys.modules["cctally"].resolve_display_tz(*args, **kwargs)
294
+
295
+
272
296
  def _sum_cost_by_project(*args, **kwargs):
273
297
  return sys.modules["cctally"]._sum_cost_by_project(*args, **kwargs)
274
298
 
@@ -297,12 +321,8 @@ def _resolve_current_budget_window(*args, **kwargs):
297
321
  return sys.modules["cctally"]._resolve_current_budget_window(*args, **kwargs)
298
322
 
299
323
 
300
- def _sum_cost_for_range(*args, **kwargs):
301
- return sys.modules["cctally"]._sum_cost_for_range(*args, **kwargs)
302
-
303
-
304
- def insert_budget_milestone(*args, **kwargs):
305
- return sys.modules["cctally"].insert_budget_milestone(*args, **kwargs)
324
+ def _resolve_claude_budget_window(*args, **kwargs):
325
+ return sys.modules["cctally"]._resolve_claude_budget_window(*args, **kwargs)
306
326
 
307
327
 
308
328
  def insert_projected_milestone(*args, **kwargs):
@@ -329,8 +349,8 @@ def _assess_forecast_confidence(*args, **kwargs):
329
349
  return sys.modules["cctally"]._assess_forecast_confidence(*args, **kwargs)
330
350
 
331
351
 
332
- def _build_budget_status_inputs(*args, **kwargs):
333
- return sys.modules["cctally"]._build_budget_status_inputs(*args, **kwargs)
352
+ def _build_vendor_budget_inputs(*args, **kwargs):
353
+ return sys.modules["cctally"]._build_vendor_budget_inputs(*args, **kwargs)
334
354
 
335
355
 
336
356
  def compute_budget_status(*args, **kwargs):
@@ -728,116 +748,154 @@ def maybe_record_milestone(
728
748
  conn.close()
729
749
 
730
750
 
731
- def maybe_record_budget_milestone(saved: dict[str, Any]) -> None:
732
- """Fire equiv-$ budget alerts on ACTUAL-spend threshold crossings
733
- (Approach A — called from ``cmd_record_usage`` alongside the weekly-% /
734
- 5h-% milestone helpers). Gated, hot-path-cheap, set-then-dispatch,
735
- fire-once. Errors are logged, not raised (the caller also wraps).
736
-
737
- ``saved`` is accepted for call-site symmetry with
738
- ``maybe_record_milestone`` / ``maybe_update_five_hour_block`` but is
739
- unused: the budget window + live spend are resolved from the DB +
740
- ``session_entries`` independently (a budget crossing depends on
741
- cumulative equiv-$ spend, not on the just-recorded 7d-% snapshot).
751
+ def _record_budget_milestone_for_vendor(
752
+ *, vendor, target, thresholds, period, config, tz, build_payload
753
+ ) -> None:
754
+ """Shared budget-milestone firing core for both vendors (#143).
755
+
756
+ Hot-path ordering is preserved verbatim (spec §4.2 / [Pre-probe before
757
+ sync_cache]): ``open_db`` cheap ``_resolve_budget_window(vendor=…)``
758
+ unified pre-probe (which configured thresholds are STILL un-recorded for this
759
+ window/period) **skip the cost SUM entirely when nothing is pending**
760
+ ``_budget_spend_for_vendor(vendor=…)`` (the costly leg)
761
+ ``_budget_crossings(vendor=…)`` (INSERT-and-arm, set-then-dispatch,
762
+ fire-once via rowcount) → single durable commit → post-commit dispatch.
763
+
764
+ The pre-probe's ``period = ? OR period IS NULL`` arm (#137) makes a pre-011
765
+ NULL-period row for this window count as already-recorded (no spurious
766
+ upgrade re-fire); a row under the SAME concrete ``period`` also counts
767
+ (fire-once). The cost SUM is skipped ONLY when every threshold already has a
768
+ row — a partial prior run still forces the SUM for the remaining thresholds
769
+ ([Dedup mustn't gate side effects]).
770
+
771
+ ``build_payload`` is the vendor's at-fire payload adapter (keeps the dispatch
772
+ ``id`` byte-stable per vendor); it is invoked with
773
+ ``threshold`` / ``crossed_at_utc`` / ``period_key`` / ``period`` /
774
+ ``budget_usd`` / ``spent_usd`` / ``consumption_pct`` keyword args.
742
775
  """
743
- # Gate FIRST (hot-path discipline): no budget or alerts off → zero
744
- # overhead for non-budget users. `load_config()` is safe outside any
745
- # writer lock — atomic-rename guarantees whole-byte reads. A malformed
746
- # budget block is a quiet warn-once no-op (mirrors weekly/5h), NOT an
747
- # unthrottled per-tick stderr via the caller's wrapper.
748
- try:
749
- budget_cfg = _get_budget_config(load_config())
750
- except _BudgetConfigError as exc:
751
- _warn_budget_bad_config_once(exc)
752
- return
753
- if not _budget_alerts_active(budget_cfg):
754
- return
755
- target = budget_cfg["weekly_usd"]
756
- thresholds = budget_cfg["alert_thresholds"]
757
- if not thresholds:
758
- return
759
-
760
776
  now_utc = _command_as_of()
761
777
  pending_alerts: list[dict[str, Any]] = []
762
778
  conn = open_db()
763
779
  try:
764
- window = _resolve_current_budget_window(conn, now_utc)
765
- if window is None:
766
- return # no resolvable week window yet (spec §6 worst case)
767
- week_start_at, _week_end_at = window
768
- week_key = week_start_at.isoformat(timespec="seconds")
780
+ start_at = _resolve_budget_window(
781
+ conn, vendor=vendor, now_utc=now_utc, period=period,
782
+ config=config, tz=tz,
783
+ )
784
+ if start_at is None:
785
+ return # no resolvable window yet (claude subscription-week pre-snapshot)
786
+ period_key = start_at.isoformat(timespec="seconds")
769
787
 
770
- # Pre-probe (hot-path discipline + [Dedup mustn't gate side effects]):
771
- # which configured thresholds are STILL un-recorded for this week?
772
- # The cost SUM is skipped ONLY when every threshold already has a row
773
- # — so a partial prior run that recorded some-but-not-all thresholds
774
- # still gets the remaining ones a SUM + crossing-check. The skip never
775
- # owes a crossing: an un-recorded threshold always forces the SUM.
776
788
  present = {
777
789
  int(r[0]) for r in conn.execute(
778
- "SELECT threshold FROM budget_milestones WHERE week_start_at = ?",
779
- (week_key,),
790
+ "SELECT threshold FROM budget_milestones "
791
+ "WHERE vendor = ? AND period_start_at = ? "
792
+ " AND (period = ? OR period IS NULL)",
793
+ (vendor, period_key, period),
780
794
  )
781
795
  }
782
796
  pending = [t for t in sorted(thresholds) if t not in present]
783
797
  if not pending:
784
- return # nothing left to cross this week → skip the cost SUM
785
-
786
- spent = _sum_cost_for_range(week_start_at, now_utc, mode="auto")
787
- # target > 0 is guaranteed by _get_budget_config (weekly_usd None is
788
- # excluded by _budget_alerts_active above); the else is belt-and-suspenders.
789
- consumption_pct = (spent / target * 100.0) if target > 0 else 0.0
790
- for t in pending:
791
- # +1e-9 snap-up: spent/target*100 can land one ULP below an
792
- # integer threshold (CLAUDE.md float-floor gotcha).
793
- if consumption_pct + 1e-9 >= t:
794
- inserted = insert_budget_milestone(
795
- conn,
796
- week_start_at=week_key,
797
- threshold=t,
798
- budget_usd=target,
799
- spent_usd=spent,
800
- consumption_pct=consumption_pct,
801
- commit=False,
802
- )
803
- # Only the genuine-new-crossing winner (rowcount==1) dispatches;
804
- # a racing record-usage instance gets rowcount==0 and skips.
805
- if inserted == 1:
806
- crossed_at = now_utc_iso()
807
- # set-then-dispatch: alerted_at lands on the row BEFORE
808
- # the osascript Popen, sharing this transaction with the
809
- # INSERT (commit=False) so a crash between them is
810
- # impossible. `alerted_at IS NULL` guard is write-once
811
- # defense-in-depth.
812
- conn.execute(
813
- "UPDATE budget_milestones SET alerted_at = ? "
814
- "WHERE week_start_at = ? AND threshold = ? "
815
- " AND alerted_at IS NULL",
816
- (crossed_at, week_key, t),
817
- )
818
- pending_alerts.append(_build_alert_payload_budget(
819
- threshold=t,
820
- crossed_at_utc=crossed_at,
821
- week_start_at=week_key,
822
- budget_usd=target,
823
- spent_usd=spent,
824
- consumption_pct=consumption_pct,
825
- ))
798
+ return # nothing left this window → skip the cost SUM
799
+
800
+ spent = _budget_spend_for_vendor(
801
+ conn, vendor=vendor, start_at=start_at, now_utc=now_utc
802
+ )
803
+ # Shared INSERT-and-arm core (set-then-dispatch, fire-once via rowcount);
804
+ # commit=False inside, so this conn owns the single durable commit below.
805
+ for t, crossed_at, sp, tg, pct in _budget_crossings(
806
+ conn,
807
+ vendor=vendor,
808
+ period_key=period_key,
809
+ period=period,
810
+ thresholds=pending,
811
+ target=target,
812
+ spent=spent,
813
+ now_utc=now_utc,
814
+ ):
815
+ pending_alerts.append(build_payload(
816
+ threshold=t,
817
+ crossed_at_utc=crossed_at,
818
+ period_key=period_key,
819
+ period=period,
820
+ budget_usd=tg,
821
+ spent_usd=sp,
822
+ consumption_pct=pct,
823
+ ))
826
824
  # Single commit: every INSERT + its alerted_at marker durable together.
827
825
  conn.commit()
828
826
  except Exception as exc:
829
- eprint(f"[budget-milestone] error recording budget milestone: {exc}")
827
+ eprint(f"[budget-milestone:{vendor}] error recording budget milestone: {exc}")
830
828
  finally:
831
829
  conn.close()
832
830
 
833
831
  # Dispatch AFTER commit; a dispatch failure NEVER rolls back the milestone
834
- # (set-then-dispatch invariant — one queue attempt per crossing, deduped
835
- # on the alerted_at column).
832
+ # (set-then-dispatch invariant — one queue attempt per crossing, deduped on
833
+ # the alerted_at column).
836
834
  for payload in pending_alerts:
837
835
  try:
838
836
  _dispatch_alert_notification(payload, mode="real")
839
837
  except Exception as dispatch_exc:
840
- eprint(f"[budget-alerts] dispatch failed: {dispatch_exc}")
838
+ eprint(f"[budget-alerts:{vendor}] dispatch failed: {dispatch_exc}")
839
+
840
+
841
+ def maybe_record_budget_milestone(saved: dict[str, Any]) -> None:
842
+ """Fire Claude equiv-$ budget alerts on ACTUAL-spend threshold crossings
843
+ (axis ``budget`` — called from ``cmd_record_usage`` alongside the weekly-% /
844
+ 5h-% milestone helpers). Thin vendor adapter over
845
+ :func:`_record_budget_milestone_for_vendor` (#143): reads the Claude budget
846
+ config block, gates, resolves ``target`` / ``thresholds`` / ``period``, and
847
+ passes the Claude payload builder. Gated, hot-path-cheap, set-then-dispatch,
848
+ fire-once. Errors are logged, not raised (the caller also wraps).
849
+
850
+ ``saved`` is accepted for call-site symmetry with
851
+ ``maybe_record_milestone`` / ``maybe_update_five_hour_block`` but is
852
+ unused: the budget window + live spend are resolved from the DB +
853
+ ``session_entries`` independently (a budget crossing depends on
854
+ cumulative equiv-$ spend, not on the just-recorded 7d-% snapshot).
855
+ """
856
+ # Gate FIRST (hot-path discipline): no budget or alerts off → zero
857
+ # overhead for non-budget users. `load_config()` is safe outside any
858
+ # writer lock — atomic-rename guarantees whole-byte reads. A malformed
859
+ # budget block is a quiet warn-once no-op (mirrors weekly/5h), NOT an
860
+ # unthrottled per-tick stderr via the caller's wrapper. One config read
861
+ # services both the gate and the calendar-window tz resolution.
862
+ config = load_config()
863
+ try:
864
+ budget_cfg = _get_budget_config(config)
865
+ except _BudgetConfigError as exc:
866
+ _warn_budget_bad_config_once(exc)
867
+ return
868
+ if not _budget_alerts_active(budget_cfg):
869
+ return
870
+ thresholds = budget_cfg["alert_thresholds"]
871
+ if not thresholds:
872
+ return
873
+ # Period generalization (spec §6): subscription-week resolves the snapshot-
874
+ # anchored window; a calendar period (calendar-week / calendar-month)
875
+ # resolves the window purely from `now` + the period. config/tz are
876
+ # resolved once for the calendar branch.
877
+ period = budget_cfg.get("period", "subscription-week")
878
+ tz = resolve_display_tz(argparse.Namespace(tz=None), config)
879
+ _record_budget_milestone_for_vendor(
880
+ vendor="claude",
881
+ target=budget_cfg["weekly_usd"],
882
+ thresholds=thresholds,
883
+ period=period,
884
+ config=config,
885
+ tz=tz,
886
+ # The Claude payload builder takes the legacy `week_start_at=` kwarg
887
+ # (its value is the resolved period-start instant, == period_key), so
888
+ # the at-fire dispatch id stays byte-stable `budget:<period_start_at>:<t>`.
889
+ build_payload=lambda **kw: _build_alert_payload_budget(
890
+ threshold=kw["threshold"],
891
+ crossed_at_utc=kw["crossed_at_utc"],
892
+ week_start_at=kw["period_key"],
893
+ budget_usd=kw["budget_usd"],
894
+ spent_usd=kw["spent_usd"],
895
+ consumption_pct=kw["consumption_pct"],
896
+ period=kw["period"],
897
+ ),
898
+ )
841
899
 
842
900
 
843
901
  def maybe_record_project_budget_milestone(saved: dict[str, Any]) -> None:
@@ -1005,6 +1063,72 @@ def maybe_record_project_budget_milestone(saved: dict[str, Any]) -> None:
1005
1063
  eprint(f"[project-budget-alerts] dispatch failed: {dispatch_exc}")
1006
1064
 
1007
1065
 
1066
+ def maybe_record_codex_budget_milestone(saved: dict[str, Any]) -> None:
1067
+ """Fire Codex budget alerts on ACTUAL-Codex-spend threshold crossings (axis
1068
+ ``codex_budget``, calendar-period-codex-budgets spec §6 — the gap the Codex
1069
+ spec review flagged: Codex usage never flows through ``record-usage``, so the
1070
+ Claude budget axes can't catch it). Thin vendor adapter over
1071
+ :func:`_record_budget_milestone_for_vendor` (#143): reads the ``budget.codex``
1072
+ config block, gates, resolves ``target`` / ``thresholds`` / ``period``, and
1073
+ passes the Codex payload builder.
1074
+
1075
+ Called from ``cmd_record_usage`` alongside the weekly-% / 5h-% / budget /
1076
+ project-budget milestone helpers AND opportunistically from ``cmd_budget``
1077
+ (the public name is kept so that call site is unchanged). Forward-only /
1078
+ fire-once, so the double-trigger never double-fires. Gated, hot-path-cheap,
1079
+ set-then-dispatch. Errors are logged, not raised (the caller also wraps).
1080
+
1081
+ Unlike the Claude budget axis, Codex has NO subscription week: the period
1082
+ window is resolved purely from ``now`` + the configured calendar period
1083
+ (calendar-week / calendar-month) — it NEVER touches
1084
+ ``weekly_usage_snapshots`` (the shared core's ``_resolve_budget_window``
1085
+ dispatches to the pure calendar window for ``vendor='codex'``).
1086
+
1087
+ ``saved`` is accepted for call-site symmetry with the sibling helpers but is
1088
+ unused: Codex spend is resolved from the cache DB independent of the
1089
+ just-recorded 7d-% snapshot.
1090
+ """
1091
+ # Gate FIRST (hot-path discipline): no Codex budget OR alerts off → zero
1092
+ # overhead for non-Codex-budget users. `load_config()` is safe outside any
1093
+ # writer lock (atomic-rename). A malformed budget block is a quiet warn-once
1094
+ # no-op (mirrors maybe_record_budget_milestone). One config read services
1095
+ # both the gate and the calendar-window tz resolution.
1096
+ config = load_config()
1097
+ try:
1098
+ budget_cfg = _get_budget_config(config)
1099
+ except _BudgetConfigError as exc:
1100
+ _warn_budget_bad_config_once(exc)
1101
+ return
1102
+ codex_cfg = budget_cfg.get("codex")
1103
+ if not codex_cfg or not codex_cfg.get("alerts_enabled"):
1104
+ return
1105
+ target = codex_cfg.get("amount_usd")
1106
+ thresholds = codex_cfg.get("alert_thresholds") or []
1107
+ if target is None or not thresholds:
1108
+ return
1109
+ tz = resolve_display_tz(argparse.Namespace(tz=None), config)
1110
+ _record_budget_milestone_for_vendor(
1111
+ vendor="codex",
1112
+ target=target,
1113
+ thresholds=thresholds,
1114
+ period=codex_cfg["period"],
1115
+ config=config,
1116
+ tz=tz,
1117
+ # The Codex payload builder takes `period_start_at=` directly (== the
1118
+ # resolved period-start instant, == period_key), so the at-fire dispatch
1119
+ # id stays byte-stable `codex_budget:<period_start_at>:<threshold>`.
1120
+ build_payload=lambda **kw: _build_alert_payload_codex_budget(
1121
+ threshold=kw["threshold"],
1122
+ crossed_at_utc=kw["crossed_at_utc"],
1123
+ period_start_at=kw["period_key"],
1124
+ period=kw["period"],
1125
+ budget_usd=kw["budget_usd"],
1126
+ spent_usd=kw["spent_usd"],
1127
+ consumption_pct=kw["consumption_pct"],
1128
+ ),
1129
+ )
1130
+
1131
+
1008
1132
  def _weekly_pct_week_avg_projection(conn, now_utc):
1009
1133
  """Compute the week-AVERAGE weekly-% projection for the current
1010
1134
  subscription week, snapshot-only (CHEAP — no cost SUM, no ``sync_cache``).
@@ -1058,27 +1182,39 @@ def _weekly_pct_week_avg_projection(conn, now_utc):
1058
1182
  return (projected_pct, confidence == "low")
1059
1183
 
1060
1184
 
1061
- def maybe_record_projected_alert(saved: dict[str, Any]) -> None:
1062
- """Projected-pace detect-and-arm (axis ``projected``, #121).
1185
+ def maybe_record_projected_alert(
1186
+ saved: dict[str, Any], *, only_metrics=None
1187
+ ) -> None:
1188
+ """Projected-pace detect-and-arm (axis ``projected``, #121 / #135).
1063
1189
 
1064
1190
  Fires on the WEEK-AVERAGE projection (never the displayed high-end verdict
1065
- band) for ``weekly_pct`` and/or ``budget_usd``. Its OWN detect-and-arm
1066
- NOT folded into ``maybe_record_milestone`` (Section 1 / Codex P0-3)called
1067
- from ``cmd_record_usage`` in its own ``try`` after the weekly/5h/budget
1068
- blocks.
1191
+ band) for ``weekly_pct``, ``budget_usd`` (any Claude period #135) and/or
1192
+ ``codex_budget_usd`` (#135). Its OWN detect-and-armNOT folded into
1193
+ ``maybe_record_milestone`` (Section 1 / Codex P0-3) called from
1194
+ ``cmd_record_usage`` in its own ``try`` after the weekly/5h/budget blocks.
1069
1195
 
1070
1196
  Master gates (Codex P1-2): ``weekly_pct`` fires only under
1071
1197
  ``alerts.enabled && alerts.projected_enabled``; ``budget_usd`` only under
1072
- ``_budget_alerts_active(budget_cfg) && budget.projected_enabled``. Both
1073
- toggles default OFF (no surprise notifications on upgrade). When NEITHER is
1074
- on, returns after only a cheap config read — no projection math, no cost
1075
- work.
1198
+ ``_budget_alerts_active(budget_cfg) && budget.projected_enabled`` (#135:
1199
+ ALL Claude periods, not just subscription-week); ``codex_budget_usd`` only
1200
+ under ``codex.alerts_enabled && codex.projected_enabled`` with a set
1201
+ ``amount_usd`` + ``alert_thresholds`` (mirrors
1202
+ ``maybe_record_codex_budget_milestone``'s gate — there is no
1203
+ ``_codex_budget_alerts_active`` helper). All toggles default OFF (no
1204
+ surprise notifications on upgrade). When NONE is on, returns after only a
1205
+ cheap config read — no projection math, no cost work.
1206
+
1207
+ ``only_metrics`` (#135): when a set of metric names is passed (the
1208
+ opportunistic ``cctally budget`` fire passes ``{"codex_budget_usd"}``), only
1209
+ those legs run — so that interactive fire never pops a ``weekly_pct`` /
1210
+ Claude-``budget_usd`` notification. ``None`` (the record path) = every
1211
+ enabled leg.
1076
1212
 
1077
1213
  Pre-probe (Codex P1-1): a metric whose levels are ALL already latched is
1078
1214
  skipped BEFORE any projection / cost work.
1079
1215
 
1080
1216
  Snap-up (Codex P2-1): a level fires when ``projected + 1e-9 >= threshold``.
1081
- Latch / fire-once: ``UNIQUE(week_start_at, metric, threshold)`` + the
1217
+ Latch / fire-once: ``UNIQUE(week_start_at, period, metric, threshold)`` + the
1082
1218
  rowcount==1 predicate — a later recovery neither un-fires nor re-fires.
1083
1219
  Mid-week reset re-anchors ``week_start_at`` (budget pattern; no
1084
1220
  ``reset_event_id``).
@@ -1087,12 +1223,17 @@ def maybe_record_projected_alert(saved: dict[str, Any]) -> None:
1087
1223
  txn, commit, THEN best-effort dispatch. A dispatch failure never rolls back
1088
1224
  the milestone.
1089
1225
 
1090
- The ``budget_usd`` leg reuses the SAME ``_build_budget_status_inputs`` +
1226
+ Both budget legs reuse the SAME ``_build_vendor_budget_inputs`` +
1091
1227
  ``compute_budget_status`` path that produces ``budget --json``'s
1092
1228
  ``week_avg_projection_usd`` (the reconcile-bound field) — value-exact by
1093
- construction. The cache is already warm from the actual-budget axis's spend
1094
- SUM this same tick, so this is not a second aggregation pass; the pre-probe
1095
- additionally skips it entirely when all budget levels are latched.
1229
+ construction, keyed on the calendar/subscription period-start instant in the
1230
+ back-compat ``week_start_at`` column. The Claude leg passes ``skip_sync=True``
1231
+ (the cache is warmed by the actual-budget axis's spend SUM this same tick);
1232
+ the Codex leg passes ``skip_sync=False`` (R5: Codex has no other record-path
1233
+ warmer — ``maybe_record_codex_budget_milestone`` short-circuits before its SUM
1234
+ when all actual levels are latched, so a ``skip_sync=True`` Codex leg could
1235
+ read a cold cache and under-count; the delta-sync is a near-no-op when warm).
1236
+ The pre-probe skips each leg entirely when all its levels are already latched.
1096
1237
  """
1097
1238
  # The `projected_enabled` toggles are validated keys on the alerts/budget
1098
1239
  # blocks (bool-validated; default OFF), so read them straight off the
@@ -1114,12 +1255,44 @@ def maybe_record_projected_alert(saved: dict[str, Any]) -> None:
1114
1255
  weekly_on = bool(alerts_cfg.get("enabled")) and bool(
1115
1256
  alerts_cfg.get("projected_enabled")
1116
1257
  )
1258
+ # #135: the Claude `budget_usd` leg now fires for ANY period (calendar-week /
1259
+ # calendar-month / subscription-week). `_build_vendor_budget_inputs` resolves
1260
+ # the correct window per period, and the milestone keys on that period-start
1261
+ # instant (in the back-compat `week_start_at` column) — the same key the
1262
+ # actual-budget axis uses — so there is no window/key mismatch any more.
1117
1263
  budget_on = _budget_alerts_active(budget_cfg) and bool(
1118
1264
  budget_cfg.get("projected_enabled")
1119
1265
  )
1120
- if not (weekly_on or budget_on):
1266
+ # #135: the Codex `codex_budget_usd` leg. No `_codex_budget_alerts_active`
1267
+ # helper exists, so inline the gate mirroring
1268
+ # `maybe_record_codex_budget_milestone`: a Codex budget block with alerts +
1269
+ # projected on and a set amount/thresholds. (Projected requires
1270
+ # `alerts_enabled` too — same as the Claude leg, where `_budget_alerts_active`
1271
+ # requires it — documented in budget.md, not UI-enforced.)
1272
+ codex_cfg = budget_cfg.get("codex") or {}
1273
+ codex_on = (
1274
+ bool(codex_cfg)
1275
+ and bool(codex_cfg.get("alerts_enabled"))
1276
+ and bool(codex_cfg.get("projected_enabled"))
1277
+ and codex_cfg.get("amount_usd") is not None
1278
+ and bool(codex_cfg.get("alert_thresholds"))
1279
+ )
1280
+ # only_metrics scopes the opportunistic `cctally budget` fire to the Codex
1281
+ # leg so it never pops a weekly_pct / Claude budget_usd notification.
1282
+ if only_metrics is not None:
1283
+ weekly_on = weekly_on and "weekly_pct" in only_metrics
1284
+ budget_on = budget_on and "budget_usd" in only_metrics
1285
+ codex_on = codex_on and "codex_budget_usd" in only_metrics
1286
+ if not (weekly_on or budget_on or codex_on):
1121
1287
  return # cheap config-only path — non-projected users pay nothing
1122
1288
 
1289
+ # Both budget legs resolve their window via _build_vendor_budget_inputs in
1290
+ # CONFIG tz (Namespace(tz=None)) — like maybe_record_codex_budget_milestone
1291
+ # — so a `cctally budget --tz X` opportunistic fire near a period boundary
1292
+ # resolves the SAME period_start_at dedup key as the record path and never
1293
+ # forks / double-fires.
1294
+ config_tz = resolve_display_tz(argparse.Namespace(tz=None), cfg)
1295
+
1123
1296
  now_utc = _command_as_of()
1124
1297
  pending: list[dict[str, Any]] = []
1125
1298
  conn = open_db()
@@ -1134,9 +1307,10 @@ def maybe_record_projected_alert(saved: dict[str, Any]) -> None:
1134
1307
  )
1135
1308
  week_key = ws_at.isoformat(timespec="seconds")
1136
1309
  levels = (90, 100)
1310
+ # weekly_pct is the Anthropic subscription week (#137).
1137
1311
  if not _projected_levels_already_latched(
1138
- conn, week_start_at=week_key, metric="weekly_pct",
1139
- levels=levels,
1312
+ conn, week_start_at=week_key, period="subscription-week",
1313
+ metric="weekly_pct", levels=levels,
1140
1314
  ):
1141
1315
  proj = _weekly_pct_week_avg_projection(conn, now_utc)
1142
1316
  if proj is not None and not proj[1]:
@@ -1145,33 +1319,40 @@ def maybe_record_projected_alert(saved: dict[str, Any]) -> None:
1145
1319
  if value + 1e-9 >= t:
1146
1320
  pending.append(dict(
1147
1321
  week_start_at=week_key,
1322
+ period="subscription-week",
1148
1323
  metric="weekly_pct",
1149
1324
  threshold=t,
1150
1325
  projected_value=value,
1151
1326
  denominator=100.0,
1152
1327
  ))
1153
1328
 
1154
- # ── budget_usd leg (reuses the tick's spend via the shared path) ─────
1329
+ # ── budget_usd leg (any Claude period #135; shared factory) ────────
1155
1330
  if budget_on:
1156
1331
  target = budget_cfg["weekly_usd"]
1157
1332
  thresholds = tuple(
1158
1333
  sorted(set(int(t) for t in budget_cfg["alert_thresholds"]))
1159
1334
  )
1160
- window = _resolve_current_budget_window(conn, now_utc)
1335
+ claude_period = budget_cfg.get("period", "subscription-week")
1336
+ # Resolve the window key CHEAPLY first (SUM-free, same resolver the
1337
+ # actual-budget axis uses) so the pre-probe can short-circuit BEFORE
1338
+ # _build_vendor_budget_inputs runs any cost SUM / cache sync — the
1339
+ # pre-probe-runs-first contract (spec §3.4; mirrors the actual axis).
1340
+ window = _resolve_claude_budget_window(
1341
+ conn, now_utc, period=claude_period, config=cfg, tz=config_tz
1342
+ )
1161
1343
  if window is not None and thresholds:
1162
1344
  b_ws_at, _b_we_at = window
1163
1345
  b_week_key = b_ws_at.isoformat(timespec="seconds")
1164
1346
  if not _projected_levels_already_latched(
1165
- conn, week_start_at=b_week_key, metric="budget_usd",
1166
- levels=thresholds,
1347
+ conn, week_start_at=b_week_key, period=claude_period,
1348
+ metric="budget_usd", levels=thresholds,
1167
1349
  ):
1168
- # skip_sync=True: the actual-budget axis
1169
- # (maybe_record_budget_milestone) already ran a
1350
+ # skip_sync=True: the actual-budget axis already ran a
1170
1351
  # _sum_cost_for_range this same tick, warming the cache.
1171
- # Avoids a redundant JSONL ingest pass here.
1172
- inputs = _build_budget_status_inputs(
1173
- conn, target_usd=target, now_utc=now_utc,
1174
- alert_thresholds=thresholds, skip_sync=True,
1352
+ inputs = _build_vendor_budget_inputs(
1353
+ vendor="claude", period=claude_period, target_usd=target,
1354
+ alert_thresholds=thresholds, now_utc=now_utc, config=cfg,
1355
+ tz=config_tz, skip_sync=True,
1175
1356
  )
1176
1357
  if inputs is not None:
1177
1358
  status = compute_budget_status(inputs)
@@ -1181,18 +1362,66 @@ def maybe_record_projected_alert(saved: dict[str, Any]) -> None:
1181
1362
  if value + 1e-9 >= (t / 100.0) * float(target):
1182
1363
  pending.append(dict(
1183
1364
  week_start_at=b_week_key,
1365
+ period=claude_period,
1184
1366
  metric="budget_usd",
1185
1367
  threshold=t,
1186
1368
  projected_value=value,
1187
1369
  denominator=float(target),
1188
1370
  ))
1189
1371
 
1372
+ # ── codex_budget_usd leg (#135; skip_sync=False — R5) ────────────────
1373
+ if codex_on:
1374
+ c_target = codex_cfg["amount_usd"]
1375
+ c_thresholds = tuple(
1376
+ sorted(set(int(t) for t in codex_cfg["alert_thresholds"]))
1377
+ )
1378
+ c_period = codex_cfg["period"]
1379
+ # Cheap, SUM-free window key first (pure calendar resolution), so the
1380
+ # pre-probe short-circuits BEFORE any Codex cache sync / cost SUM —
1381
+ # spec §3.4 (pre-probe runs FIRST).
1382
+ c_window = _resolve_codex_budget_period_window(
1383
+ c_period, now_utc, cfg, config_tz
1384
+ )
1385
+ if c_window is not None and c_thresholds:
1386
+ c_ws_at, _c_we_at = c_window
1387
+ c_week_key = c_ws_at.isoformat(timespec="seconds")
1388
+ if not _projected_levels_already_latched(
1389
+ conn, week_start_at=c_week_key, period=c_period,
1390
+ metric="codex_budget_usd", levels=c_thresholds,
1391
+ ):
1392
+ # skip_sync=False (R5): Codex has no other record-path cache
1393
+ # warmer (maybe_record_codex_budget_milestone short-circuits
1394
+ # before its SUM when all actual levels are latched), so a
1395
+ # skip_sync=True leg could read a cold cache and under-count.
1396
+ # The pre-probe above already gated this, so a sync only runs
1397
+ # when a cross is genuinely owed; it's a near-no-op when warm.
1398
+ c_inputs = _build_vendor_budget_inputs(
1399
+ vendor="codex", period=c_period, target_usd=c_target,
1400
+ alert_thresholds=c_thresholds, now_utc=now_utc,
1401
+ config=cfg, tz=config_tz, skip_sync=False,
1402
+ )
1403
+ if c_inputs is not None:
1404
+ c_status = compute_budget_status(c_inputs)
1405
+ if not c_status.low_confidence:
1406
+ value = c_status.week_avg_projection_usd
1407
+ for t in c_thresholds:
1408
+ if value + 1e-9 >= (t / 100.0) * float(c_target):
1409
+ pending.append(dict(
1410
+ week_start_at=c_week_key,
1411
+ period=c_period,
1412
+ metric="codex_budget_usd",
1413
+ threshold=t,
1414
+ projected_value=value,
1415
+ denominator=float(c_target),
1416
+ ))
1417
+
1190
1418
  # ── arm (set-then-dispatch): INSERT + stamp alerted_at in one txn ────
1191
1419
  fired: list[dict[str, Any]] = []
1192
1420
  for p in pending:
1193
1421
  inserted = insert_projected_milestone(
1194
1422
  conn,
1195
1423
  week_start_at=p["week_start_at"],
1424
+ period=p["period"],
1196
1425
  metric=p["metric"],
1197
1426
  threshold=p["threshold"],
1198
1427
  projected_value=p["projected_value"],
@@ -1200,14 +1429,15 @@ def maybe_record_projected_alert(saved: dict[str, Any]) -> None:
1200
1429
  commit=False,
1201
1430
  )
1202
1431
  # Only the genuine-new-crossing winner (rowcount==1) arms+dispatches;
1203
- # a racing record-usage instance gets rowcount==0 and skips.
1432
+ # a racing record-usage instance gets rowcount==0 and skips. The
1433
+ # alerted_at UPDATE keys on the CONCRETE `period` (#137).
1204
1434
  if inserted == 1:
1205
1435
  conn.execute(
1206
1436
  "UPDATE projected_milestones SET alerted_at = ? "
1207
- "WHERE week_start_at = ? AND metric = ? AND threshold = ? "
1208
- " AND alerted_at IS NULL",
1209
- (now_utc_iso(), p["week_start_at"], p["metric"],
1210
- p["threshold"]),
1437
+ "WHERE week_start_at = ? AND period = ? AND metric = ? "
1438
+ " AND threshold = ? AND alerted_at IS NULL",
1439
+ (now_utc_iso(), p["week_start_at"], p["period"],
1440
+ p["metric"], p["threshold"]),
1211
1441
  )
1212
1442
  fired.append(p)
1213
1443
  # Single commit: every INSERT + its alerted_at marker durable together.
@@ -2810,6 +3040,8 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
2810
3040
  (maybe_record_budget_milestone, "budget-milestone"),
2811
3041
  (maybe_record_project_budget_milestone,
2812
3042
  "project-budget-milestone"),
3043
+ (maybe_record_codex_budget_milestone,
3044
+ "codex-budget-milestone"),
2813
3045
  (maybe_record_projected_alert, "projected-alert"),
2814
3046
  ):
2815
3047
  try:
@@ -2869,11 +3101,26 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
2869
3101
  except Exception as exc:
2870
3102
  eprint(f"[project-budget-milestone] unexpected error: {exc}")
2871
3103
 
2872
- # NEW: projected-pace alert firing (axis `projected`, #121). Runs in its
2873
- # OWN detect-and-arm AFTER the weekly/5h/budget blocks; gated up front on
2874
- # (alerts.enabled && alerts.projected_enabled) ||
2875
- # (_budget_alerts_active && budget.projected_enabled) both toggles
2876
- # default OFF, so non-projected users pay only a cheap config read.
3104
+ # NEW: Codex budget alert firing (axis `codex_budget`, calendar-period-codex-
3105
+ # budgets). Gated FIRST on a configured budget.codex + alerts_enabled non-
3106
+ # Codex-budget users pay only one config read. Codex usage never flows through
3107
+ # record-usage, so this is one of the two firing triggers (the other is the
3108
+ # opportunistic fire on `cctally budget`); forward-only/fire-once means the
3109
+ # double-trigger never double-fires.
3110
+ try:
3111
+ maybe_record_codex_budget_milestone(saved)
3112
+ except Exception as exc:
3113
+ eprint(f"[codex-budget-milestone] unexpected error: {exc}")
3114
+
3115
+ # NEW: projected-pace alert firing (axis `projected`, #121/#135). Runs in
3116
+ # its OWN detect-and-arm AFTER the weekly/5h/budget/codex blocks; gated up
3117
+ # front on (alerts.enabled && alerts.projected_enabled) ||
3118
+ # (_budget_alerts_active && budget.projected_enabled — ANY Claude period,
3119
+ # #135) || (codex.alerts_enabled && codex.projected_enabled, #135) — all
3120
+ # toggles default OFF, so non-projected users pay only a cheap config read.
3121
+ # No only_metrics on the record path: every enabled leg runs. The Codex leg
3122
+ # relies on the codex-budget block above (:3129) having warmed the cache,
3123
+ # but is robust even if it short-circuited (skip_sync=False self-syncs, R5).
2877
3124
  try:
2878
3125
  maybe_record_projected_alert(saved)
2879
3126
  except Exception as exc: