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.
- package/CHANGELOG.md +37 -0
- package/bin/_cctally_alerts.py +26 -1
- package/bin/_cctally_cache.py +355 -31
- package/bin/_cctally_config.py +153 -11
- package/bin/_cctally_core.py +204 -42
- package/bin/_cctally_dashboard.py +510 -61
- package/bin/_cctally_db.py +756 -163
- package/bin/_cctally_doctor.py +11 -0
- package/bin/_cctally_forecast.py +700 -57
- package/bin/_cctally_milestones.py +252 -47
- package/bin/_cctally_parser.py +44 -4
- package/bin/_cctally_record.py +380 -133
- package/bin/_cctally_weekrefs.py +30 -6
- package/bin/_lib_alert_axes.py +12 -2
- package/bin/_lib_alerts_payload.py +95 -3
- package/bin/_lib_budget.py +48 -0
- package/bin/_lib_conversation.py +177 -0
- package/bin/_lib_conversation_query.py +620 -0
- package/bin/_lib_doctor.py +60 -1
- package/bin/_lib_jsonl.py +69 -50
- package/bin/_lib_transcript_access.py +80 -0
- package/bin/cctally +29 -2
- package/dashboard/static/assets/index-BGaWg6ys.js +47 -0
- package/dashboard/static/assets/{index-D34qf0LE.css → index-BqQ5xdX0.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-C2F1_Mxt.js +0 -18
package/bin/_cctally_record.py
CHANGED
|
@@ -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
|
|
301
|
-
return sys.modules["cctally"].
|
|
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
|
|
333
|
-
return sys.modules["cctally"].
|
|
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
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
``
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
``
|
|
741
|
-
|
|
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
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
|
779
|
-
|
|
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
|
|
785
|
-
|
|
786
|
-
spent =
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
-
#
|
|
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(
|
|
1062
|
-
|
|
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
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
blocks.
|
|
1191
|
+
band) for ``weekly_pct``, ``budget_usd`` (any Claude period — #135) and/or
|
|
1192
|
+
``codex_budget_usd`` (#135). Its OWN detect-and-arm — NOT 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
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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
|
-
|
|
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
|
|
1094
|
-
|
|
1095
|
-
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
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
|
|
1208
|
-
" AND alerted_at IS NULL",
|
|
1209
|
-
(now_utc_iso(), p["week_start_at"], p["
|
|
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:
|
|
2873
|
-
#
|
|
2874
|
-
#
|
|
2875
|
-
#
|
|
2876
|
-
#
|
|
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:
|