cctally 1.21.3 → 1.22.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 +17 -0
- package/bin/_cctally_alerts.py +26 -1
- package/bin/_cctally_config.py +135 -0
- package/bin/_cctally_core.py +120 -0
- package/bin/_cctally_dashboard.py +155 -23
- package/bin/_cctally_db.py +3 -0
- package/bin/_cctally_record.py +148 -0
- package/bin/_lib_alerts_payload.py +50 -0
- package/bin/_lib_budget.py +133 -0
- package/bin/_lib_doctor.py +74 -0
- package/bin/_lib_pricing.py +32 -5
- package/bin/_lib_pricing_check.py +201 -0
- package/bin/cctally +1141 -10
- package/bin/cctally-budget +4 -0
- package/dashboard/static/assets/index-BxmaYT1y.css +1 -0
- package/dashboard/static/assets/index-CLcd-Tnm.js +18 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-BJ16SzRL.js +0 -18
- package/dashboard/static/assets/index-C1xH9GBW.css +0 -1
package/bin/cctally
CHANGED
|
@@ -204,6 +204,11 @@ _AlertsConfigError = _cctally_core._AlertsConfigError
|
|
|
204
204
|
_ALERTS_CONFIG_VALID_KEYS = _cctally_core._ALERTS_CONFIG_VALID_KEYS
|
|
205
205
|
_validate_threshold_list = _cctally_core._validate_threshold_list
|
|
206
206
|
_get_alerts_config = _cctally_core._get_alerts_config
|
|
207
|
+
_BudgetConfigError = _cctally_core._BudgetConfigError
|
|
208
|
+
_BUDGET_DEFAULTS = _cctally_core._BUDGET_DEFAULTS
|
|
209
|
+
_BUDGET_CONFIG_VALID_KEYS = _cctally_core._BUDGET_CONFIG_VALID_KEYS
|
|
210
|
+
_get_budget_config = _cctally_core._get_budget_config
|
|
211
|
+
_budget_alerts_active = _cctally_core._budget_alerts_active
|
|
207
212
|
open_db = _cctally_core.open_db
|
|
208
213
|
WeekRef = _cctally_core.WeekRef
|
|
209
214
|
_canonicalize_optional_iso = _cctally_core._canonicalize_optional_iso
|
|
@@ -275,6 +280,35 @@ CODEX_FAST_MULTIPLIER_FALLBACK = _lib_pricing.CODEX_FAST_MULTIPLIER_FALLBACK
|
|
|
275
280
|
_codex_config_requests_fast_service_tier = _lib_pricing._codex_config_requests_fast_service_tier
|
|
276
281
|
_short_model_name = _lib_pricing._short_model_name
|
|
277
282
|
|
|
283
|
+
# Pricing-freshness check (spec 2026-05-29): pure-fn kernel re-exported here
|
|
284
|
+
# like the other _lib_* kernels. The kernel takes the pricing predicates +
|
|
285
|
+
# tables + observed rows as injected args; the I/O glue (cache scan, HTTP
|
|
286
|
+
# fetchers, the doctor coverage wiring) lives in bin/cctally.
|
|
287
|
+
_lib_pricing_check = _load_sibling("_lib_pricing_check")
|
|
288
|
+
classify_coverage = _lib_pricing_check.classify_coverage
|
|
289
|
+
scope_litellm = _lib_pricing_check.scope_litellm
|
|
290
|
+
diff_pricing = _lib_pricing_check.diff_pricing
|
|
291
|
+
stale_allowlist_entries = _lib_pricing_check.stale_allowlist_entries
|
|
292
|
+
check_table_shapes = _lib_pricing_check.check_table_shapes
|
|
293
|
+
pricing_issue_action = _lib_pricing_check.pricing_issue_action
|
|
294
|
+
CoverageGap = _lib_pricing_check.CoverageGap
|
|
295
|
+
DriftRow = _lib_pricing_check.DriftRow
|
|
296
|
+
DriftResult = _lib_pricing_check.DriftResult
|
|
297
|
+
PRICING_SNAPSHOT_DATE = _lib_pricing.PRICING_SNAPSHOT_DATE
|
|
298
|
+
PRICING_STALENESS_DAYS = _lib_pricing.PRICING_STALENESS_DAYS
|
|
299
|
+
PRICING_DRIFT_ALLOWLIST = _lib_pricing.PRICING_DRIFT_ALLOWLIST
|
|
300
|
+
LITELLM_PRICES_URL = _lib_pricing.LITELLM_PRICES_URL
|
|
301
|
+
|
|
302
|
+
# Budget kernel (spec 2026-05-29): pure-fn kernel re-exported here like the
|
|
303
|
+
# other _lib_* kernels. `project_linear` is the shared projection primitive
|
|
304
|
+
# (forecast routes its EOW % projection through it too — spec F1). The I/O
|
|
305
|
+
# glue (spend gather, the `budget` subcommand) lives in bin/cctally.
|
|
306
|
+
_lib_budget = _load_sibling("_lib_budget")
|
|
307
|
+
BudgetInputs = _lib_budget.BudgetInputs
|
|
308
|
+
BudgetStatus = _lib_budget.BudgetStatus
|
|
309
|
+
compute_budget_status = _lib_budget.compute_budget_status
|
|
310
|
+
project_linear = _lib_budget.project_linear
|
|
311
|
+
|
|
278
312
|
# Per-model context window (used by `cctally statusline` segment 4).
|
|
279
313
|
# Keep in sync with Anthropic's docs:
|
|
280
314
|
# https://docs.anthropic.com/en/docs/about-claude/models
|
|
@@ -337,9 +371,11 @@ def _nonneg_int(raw: str) -> int:
|
|
|
337
371
|
_lib_alerts_payload = _load_sibling("_lib_alerts_payload")
|
|
338
372
|
_alert_text_weekly = _lib_alerts_payload._alert_text_weekly
|
|
339
373
|
_alert_text_five_hour = _lib_alerts_payload._alert_text_five_hour
|
|
374
|
+
_alert_text_budget = _lib_alerts_payload._alert_text_budget
|
|
340
375
|
_escape_applescript_string = _lib_alerts_payload._escape_applescript_string
|
|
341
376
|
_build_alert_payload_weekly = _lib_alerts_payload._build_alert_payload_weekly
|
|
342
377
|
_build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_hour
|
|
378
|
+
_build_alert_payload_budget = _lib_alerts_payload._build_alert_payload_budget
|
|
343
379
|
|
|
344
380
|
_lib_five_hour = _load_sibling("_lib_five_hour")
|
|
345
381
|
_FIVE_HOUR_JITTER_FLOOR_SECONDS = _lib_five_hour._FIVE_HOUR_JITTER_FLOOR_SECONDS
|
|
@@ -692,6 +728,7 @@ _cctally_record = _load_sibling("_cctally_record")
|
|
|
692
728
|
_PERCENT_NORMALIZE_DECIMALS = _cctally_record._PERCENT_NORMALIZE_DECIMALS
|
|
693
729
|
_normalize_percent = _cctally_record._normalize_percent
|
|
694
730
|
maybe_record_milestone = _cctally_record.maybe_record_milestone
|
|
731
|
+
maybe_record_budget_milestone = _cctally_record.maybe_record_budget_milestone
|
|
695
732
|
_compute_block_totals = _cctally_record._compute_block_totals
|
|
696
733
|
maybe_update_five_hour_block = _cctally_record.maybe_update_five_hour_block
|
|
697
734
|
cmd_record_usage = _cctally_record.cmd_record_usage
|
|
@@ -2066,6 +2103,22 @@ def _warn_alerts_bad_config_once(exc: Exception) -> None:
|
|
|
2066
2103
|
eprint(f"[alerts] invalid config, skipping dispatch: {exc}")
|
|
2067
2104
|
|
|
2068
2105
|
|
|
2106
|
+
_BUDGET_BAD_CONFIG_WARNED = False # one-shot warn flag for malformed budget block
|
|
2107
|
+
|
|
2108
|
+
|
|
2109
|
+
def _warn_budget_bad_config_once(exc: Exception) -> None:
|
|
2110
|
+
"""Emit a single stderr warning per process when the budget config block
|
|
2111
|
+
is malformed, so a hand-edited bad block becomes a quiet no-op on the
|
|
2112
|
+
record-usage hot path instead of unthrottled per-tick stderr. Mirrors
|
|
2113
|
+
`_warn_alerts_bad_config_once`.
|
|
2114
|
+
"""
|
|
2115
|
+
global _BUDGET_BAD_CONFIG_WARNED
|
|
2116
|
+
if _BUDGET_BAD_CONFIG_WARNED:
|
|
2117
|
+
return
|
|
2118
|
+
_BUDGET_BAD_CONFIG_WARNED = True
|
|
2119
|
+
eprint(f"[budget] invalid config, skipping dispatch: {exc}")
|
|
2120
|
+
|
|
2121
|
+
|
|
2069
2122
|
# === Update subsystem main body (state/lock/log, install-method
|
|
2070
2123
|
# detection, version-check pipeline, install execution, dashboard
|
|
2071
2124
|
# UpdateWorker + _DashboardUpdateCheckThread, cmd_update,
|
|
@@ -3510,6 +3563,110 @@ def insert_percent_milestone(
|
|
|
3510
3563
|
return int(cur.rowcount)
|
|
3511
3564
|
|
|
3512
3565
|
|
|
3566
|
+
def insert_budget_milestone(
|
|
3567
|
+
conn: sqlite3.Connection,
|
|
3568
|
+
*,
|
|
3569
|
+
week_start_at: str,
|
|
3570
|
+
threshold: int,
|
|
3571
|
+
budget_usd: float,
|
|
3572
|
+
spent_usd: float,
|
|
3573
|
+
consumption_pct: float,
|
|
3574
|
+
commit: bool = True,
|
|
3575
|
+
) -> int:
|
|
3576
|
+
"""INSERT OR IGNORE a budget threshold crossing. Returns ``cur.rowcount``
|
|
3577
|
+
(1 = genuinely new crossing, 0 = INSERT OR IGNORE no-op on a pre-existing
|
|
3578
|
+
``(week_start_at, threshold)`` row).
|
|
3579
|
+
|
|
3580
|
+
Mirrors :func:`insert_percent_milestone`'s rowcount contract so the
|
|
3581
|
+
alert-fire predicate (`if inserted == 1`) is race-safe without a
|
|
3582
|
+
follow-up SELECT. ``alerted_at`` is left NULL — the caller stamps it in
|
|
3583
|
+
the SAME transaction BEFORE dispatching (set-then-dispatch invariant,
|
|
3584
|
+
CLAUDE.md Alerts gotcha). ``commit=False`` lets the caller bundle the
|
|
3585
|
+
INSERT with the follow-up ``alerted_at`` UPDATE in one transaction so a
|
|
3586
|
+
crash between them can't strand ``alerted_at`` NULL forever.
|
|
3587
|
+
"""
|
|
3588
|
+
cur = conn.execute(
|
|
3589
|
+
"INSERT OR IGNORE INTO budget_milestones "
|
|
3590
|
+
"(week_start_at, threshold, budget_usd, spent_usd, consumption_pct, "
|
|
3591
|
+
" crossed_at_utc) "
|
|
3592
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
3593
|
+
(
|
|
3594
|
+
week_start_at,
|
|
3595
|
+
int(threshold),
|
|
3596
|
+
float(budget_usd),
|
|
3597
|
+
float(spent_usd),
|
|
3598
|
+
float(consumption_pct),
|
|
3599
|
+
now_utc_iso(),
|
|
3600
|
+
),
|
|
3601
|
+
)
|
|
3602
|
+
if commit:
|
|
3603
|
+
conn.commit()
|
|
3604
|
+
return int(cur.rowcount)
|
|
3605
|
+
|
|
3606
|
+
|
|
3607
|
+
def _reconcile_budget_milestones_on_set(conn, *, target, thresholds, now_utc):
|
|
3608
|
+
"""Forward-only-from-set reconcile (spec §5): on `budget set`, every
|
|
3609
|
+
threshold ALREADY crossed for the current week is recorded with
|
|
3610
|
+
``alerted_at`` SET but WITHOUT dispatch — so setting a budget when you're
|
|
3611
|
+
already at 95% does NOT instant-popup. Thresholds not yet crossed get NO
|
|
3612
|
+
row, so they fire later via :func:`maybe_record_budget_milestone`.
|
|
3613
|
+
|
|
3614
|
+
A mid-week target change re-runs this; thresholds already alerted stay
|
|
3615
|
+
deduped via UNIQUE(week_start_at, threshold) + the ``alerted_at IS NULL``
|
|
3616
|
+
guard on the UPDATE (so an existing alerted row is never re-stamped).
|
|
3617
|
+
"""
|
|
3618
|
+
window = _resolve_current_budget_window(conn, now_utc)
|
|
3619
|
+
if window is None:
|
|
3620
|
+
return
|
|
3621
|
+
week_start_at, _week_end_at = window
|
|
3622
|
+
week_key = week_start_at.isoformat(timespec="seconds")
|
|
3623
|
+
spent = _sum_cost_for_range(week_start_at, now_utc, mode="auto")
|
|
3624
|
+
# target > 0 guaranteed by the caller (_cmd_budget_set passes the validated
|
|
3625
|
+
# weekly_usd); the else is belt-and-suspenders.
|
|
3626
|
+
consumption_pct = (spent / target * 100.0) if target > 0 else 0.0
|
|
3627
|
+
for t in sorted(thresholds):
|
|
3628
|
+
if consumption_pct + 1e-9 >= t:
|
|
3629
|
+
insert_budget_milestone(
|
|
3630
|
+
conn,
|
|
3631
|
+
week_start_at=week_key,
|
|
3632
|
+
threshold=t,
|
|
3633
|
+
budget_usd=target,
|
|
3634
|
+
spent_usd=spent,
|
|
3635
|
+
consumption_pct=consumption_pct,
|
|
3636
|
+
commit=False,
|
|
3637
|
+
)
|
|
3638
|
+
conn.execute(
|
|
3639
|
+
"UPDATE budget_milestones SET alerted_at = ? "
|
|
3640
|
+
"WHERE week_start_at = ? AND threshold = ? AND alerted_at IS NULL",
|
|
3641
|
+
(now_utc_iso(), week_key, t),
|
|
3642
|
+
)
|
|
3643
|
+
conn.commit()
|
|
3644
|
+
|
|
3645
|
+
|
|
3646
|
+
def _reconcile_budget_on_config_write(validated_budget):
|
|
3647
|
+
"""Forward-only reconcile shared by all three budget-config write
|
|
3648
|
+
paths (`budget set`, `config set budget.*`, dashboard POST
|
|
3649
|
+
/api/settings). Gated + best-effort: a budget with alerts off or no
|
|
3650
|
+
thresholds records nothing; a stats.db failure never fails the write.
|
|
3651
|
+
Runs OUTSIDE any config_writer_lock (open_db has its own locking)."""
|
|
3652
|
+
thresholds = validated_budget.get("alert_thresholds") or []
|
|
3653
|
+
if not (_budget_alerts_active(validated_budget) and thresholds):
|
|
3654
|
+
return
|
|
3655
|
+
try:
|
|
3656
|
+
conn = open_db()
|
|
3657
|
+
try:
|
|
3658
|
+
_reconcile_budget_milestones_on_set(
|
|
3659
|
+
conn,
|
|
3660
|
+
target=validated_budget["weekly_usd"],
|
|
3661
|
+
thresholds=thresholds,
|
|
3662
|
+
now_utc=_command_as_of(),
|
|
3663
|
+
)
|
|
3664
|
+
finally:
|
|
3665
|
+
conn.close()
|
|
3666
|
+
except Exception as exc: # best-effort; never fail the write
|
|
3667
|
+
eprint(f"[budget-milestone] reconcile on set failed: {exc}")
|
|
3668
|
+
|
|
3669
|
+
|
|
3513
3670
|
def _backfill_five_hour_blocks(conn: sqlite3.Connection) -> int:
|
|
3514
3671
|
"""One-shot historical backfill of five_hour_blocks from existing
|
|
3515
3672
|
weekly_usage_snapshots data. Idempotent via UNIQUE(five_hour_window_key)
|
|
@@ -7643,6 +7800,67 @@ def _apply_midweek_reset_override(
|
|
|
7643
7800
|
return week_start_at, samples
|
|
7644
7801
|
|
|
7645
7802
|
|
|
7803
|
+
def _resolve_current_budget_window(conn, now_utc):
|
|
7804
|
+
"""Return ``(effective_week_start_dt, week_end_dt)`` for the subscription
|
|
7805
|
+
week containing ``now_utc``, honoring a mid-week reset re-anchor; or
|
|
7806
|
+
``None`` if no snapshot exists yet.
|
|
7807
|
+
|
|
7808
|
+
Reuses the SAME reset-aware resolution forecast/weekly use
|
|
7809
|
+
(``_fetch_current_week_snapshots`` + ``_apply_midweek_reset_override``)
|
|
7810
|
+
so the budget display window and the alert-firing window (Task 3) agree.
|
|
7811
|
+
Unlike forecast's ``_load_forecast_inputs``, this does NOT short-circuit
|
|
7812
|
+
on an empty samples list — budget computes live spend from
|
|
7813
|
+
``session_entries`` regardless of whether a usage snapshot landed inside
|
|
7814
|
+
the window, so the worst case is ``spent_usd = 0`` (spec §6), not a
|
|
7815
|
+
no-window outcome.
|
|
7816
|
+
"""
|
|
7817
|
+
fetched = _fetch_current_week_snapshots(conn, now_utc)
|
|
7818
|
+
if fetched is None:
|
|
7819
|
+
return None
|
|
7820
|
+
week_start_at, week_end_at, samples = fetched
|
|
7821
|
+
week_start_at, _samples = _apply_midweek_reset_override(
|
|
7822
|
+
conn, week_start_at, week_end_at, samples
|
|
7823
|
+
)
|
|
7824
|
+
return (week_start_at, week_end_at)
|
|
7825
|
+
|
|
7826
|
+
|
|
7827
|
+
def _build_budget_status_inputs(conn, *, target_usd, now_utc, alert_thresholds):
|
|
7828
|
+
"""Gather live spend over the current subscription week and build a
|
|
7829
|
+
:class:`BudgetInputs`. Returns ``None`` when no week window resolves.
|
|
7830
|
+
|
|
7831
|
+
Spend is recomputed live via ``_sum_cost_for_range(..., mode="auto")``
|
|
7832
|
+
(the same path ``weekly`` / ``forecast`` use — pricing edits take effect
|
|
7833
|
+
immediately; F3's reconcile invariant is pinned here, NOT to snapshot
|
|
7834
|
+
``report``). ``recent_24h_usd`` is a second trailing-24h call that is NOT
|
|
7835
|
+
display-only: in ``_lib_budget.compute_budget_status`` it feeds
|
|
7836
|
+
``rate_recent → rate_high → projected_high → projected``, which drives the
|
|
7837
|
+
ok/warn/over verdict. It is therefore clamped to the current budget week
|
|
7838
|
+
(``max(week_start_at, now - 24h)``) so a heavy spend just before reset
|
|
7839
|
+
can't leak last week's dollars into a fresh week's verdict (false
|
|
7840
|
+
WARN/OVER).
|
|
7841
|
+
"""
|
|
7842
|
+
window = _resolve_current_budget_window(conn, now_utc)
|
|
7843
|
+
if window is None:
|
|
7844
|
+
return None
|
|
7845
|
+
week_start_at, week_end_at = window
|
|
7846
|
+
spent = _sum_cost_for_range(week_start_at, now_utc, mode="auto")
|
|
7847
|
+
# Clamp the recent-rate window at the week start: both bounds are tz-aware
|
|
7848
|
+
# so max() is well-defined. Without this, a brand-new week (now < week
|
|
7849
|
+
# start + 24h) would pull pre-reset spend into rate_recent and flip a
|
|
7850
|
+
# fresh verdict to warn/over.
|
|
7851
|
+
recent_start = max(week_start_at, now_utc - dt.timedelta(hours=24))
|
|
7852
|
+
recent_24h = _sum_cost_for_range(recent_start, now_utc, mode="auto")
|
|
7853
|
+
return BudgetInputs(
|
|
7854
|
+
target_usd=float(target_usd),
|
|
7855
|
+
spent_usd=float(spent),
|
|
7856
|
+
recent_24h_usd=float(recent_24h),
|
|
7857
|
+
week_start_at=week_start_at,
|
|
7858
|
+
week_end_at=week_end_at,
|
|
7859
|
+
now=now_utc,
|
|
7860
|
+
alert_thresholds=tuple(alert_thresholds),
|
|
7861
|
+
)
|
|
7862
|
+
|
|
7863
|
+
|
|
7646
7864
|
def _select_dollars_per_percent(
|
|
7647
7865
|
conn: sqlite3.Connection,
|
|
7648
7866
|
now_utc: dt.datetime,
|
|
@@ -7851,15 +8069,17 @@ def _compute_forecast(inputs: ForecastInputs, targets: list[int]) -> ForecastOut
|
|
|
7851
8069
|
else:
|
|
7852
8070
|
r_recent = None
|
|
7853
8071
|
|
|
7854
|
-
# Projected final %
|
|
8072
|
+
# Projected final % — routed through the shared project_linear primitive
|
|
8073
|
+
# (spec F1). r_recent is None ⇒ collapse to the average projection.
|
|
7855
8074
|
if r_recent is None:
|
|
7856
|
-
|
|
7857
|
-
|
|
8075
|
+
final_low, final_high = project_linear(
|
|
8076
|
+
inputs.p_now, inputs.remaining_hours, r_avg, r_avg
|
|
8077
|
+
)
|
|
7858
8078
|
else:
|
|
7859
|
-
|
|
7860
|
-
|
|
7861
|
-
|
|
7862
|
-
final_high = max(
|
|
8079
|
+
a, b = project_linear(
|
|
8080
|
+
inputs.p_now, inputs.remaining_hours, r_avg, r_recent
|
|
8081
|
+
)
|
|
8082
|
+
final_low, final_high = min(a, b), max(a, b)
|
|
7863
8083
|
|
|
7864
8084
|
already_capped = inputs.p_now >= 100.0
|
|
7865
8085
|
projected_cap = already_capped or final_high >= 100.0
|
|
@@ -8858,6 +9078,448 @@ def cmd_forecast(args: argparse.Namespace) -> int:
|
|
|
8858
9078
|
return 0
|
|
8859
9079
|
|
|
8860
9080
|
|
|
9081
|
+
# ── budget ──────────────────────────────────────────────────────────────
|
|
9082
|
+
# `cctally budget` — weekly equivalent-$ budget + pace + spend alerts.
|
|
9083
|
+
# cctally-original (NOT a ccusage drop-in) → flat surface only, no
|
|
9084
|
+
# claude/codex subgroup. Status reads live spend; `set`/`unset` write the
|
|
9085
|
+
# DEFAULT config (F4 — the path the alert firing in Task 3 reads). See
|
|
9086
|
+
# docs/commands/budget.md + spec §4/§6.
|
|
9087
|
+
|
|
9088
|
+
_BUDGET_JSON_SCHEMA_VERSION = 1
|
|
9089
|
+
|
|
9090
|
+
|
|
9091
|
+
def cmd_budget(args: argparse.Namespace) -> int:
|
|
9092
|
+
"""Dispatch `cctally budget [set AMOUNT | unset]`. See docs/commands/budget.md."""
|
|
9093
|
+
action = getattr(args, "action", None)
|
|
9094
|
+
|
|
9095
|
+
# F4: mutations always target the DEFAULT config; --config is read-only.
|
|
9096
|
+
# --format is a status-only render surface — reject it on set/unset.
|
|
9097
|
+
if action in {"set", "unset"} and getattr(args, "config", None):
|
|
9098
|
+
eprint(
|
|
9099
|
+
"cctally budget: --config is read-only; "
|
|
9100
|
+
"set/unset always write the default config"
|
|
9101
|
+
)
|
|
9102
|
+
return 2
|
|
9103
|
+
if action in {"set", "unset"} and getattr(args, "format", None):
|
|
9104
|
+
eprint("cctally budget: --format is not valid with set/unset")
|
|
9105
|
+
return 2
|
|
9106
|
+
|
|
9107
|
+
if action == "set":
|
|
9108
|
+
return _cmd_budget_set(args)
|
|
9109
|
+
if action == "unset":
|
|
9110
|
+
return _cmd_budget_unset(args)
|
|
9111
|
+
|
|
9112
|
+
# ── bare status ──
|
|
9113
|
+
# Early reject of bad share-flag combos BEFORE any DB/sync work
|
|
9114
|
+
# (mirrors cmd_forecast; calls sys.exit(2) directly inside).
|
|
9115
|
+
_share_validate_args(args)
|
|
9116
|
+
config = _load_claude_config_for_args(args) # honors --config read-only
|
|
9117
|
+
args._resolved_tz = resolve_display_tz(args, config)
|
|
9118
|
+
try:
|
|
9119
|
+
budget_cfg = _get_budget_config(config)
|
|
9120
|
+
except _BudgetConfigError as exc:
|
|
9121
|
+
eprint(f"cctally budget: {exc}")
|
|
9122
|
+
return 2
|
|
9123
|
+
target = budget_cfg["weekly_usd"]
|
|
9124
|
+
if target is None:
|
|
9125
|
+
return _budget_render_unset(args) # exit 0, friendly message
|
|
9126
|
+
|
|
9127
|
+
now_utc = _command_as_of() # honors the CCTALLY_AS_OF testing hook
|
|
9128
|
+
conn = open_db()
|
|
9129
|
+
inputs = _build_budget_status_inputs(
|
|
9130
|
+
conn,
|
|
9131
|
+
target_usd=target,
|
|
9132
|
+
now_utc=now_utc,
|
|
9133
|
+
alert_thresholds=budget_cfg["alert_thresholds"],
|
|
9134
|
+
)
|
|
9135
|
+
if inputs is None:
|
|
9136
|
+
# No usage snapshot yet → no resolvable week window (spec §6 worst case).
|
|
9137
|
+
if getattr(args, "format", None):
|
|
9138
|
+
snap = _build_budget_no_data_snapshot(args, budget_cfg, now_utc)
|
|
9139
|
+
_share_render_and_emit(snap, args)
|
|
9140
|
+
return 0
|
|
9141
|
+
if getattr(args, "json", False):
|
|
9142
|
+
print(json.dumps({
|
|
9143
|
+
"schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
|
|
9144
|
+
"status": "no_data",
|
|
9145
|
+
"weekly_usd": target,
|
|
9146
|
+
}))
|
|
9147
|
+
return 0
|
|
9148
|
+
print(f"Weekly budget: ${target:,.2f} — no usage data yet this week.")
|
|
9149
|
+
return 0
|
|
9150
|
+
|
|
9151
|
+
status = compute_budget_status(inputs)
|
|
9152
|
+
if getattr(args, "format", None):
|
|
9153
|
+
snap = _build_budget_snapshot(args, budget_cfg, inputs, status)
|
|
9154
|
+
_share_render_and_emit(snap, args)
|
|
9155
|
+
return 0
|
|
9156
|
+
if getattr(args, "json", False):
|
|
9157
|
+
return _budget_emit_json(budget_cfg, inputs, status)
|
|
9158
|
+
return _budget_render_terminal(args, budget_cfg, inputs, status)
|
|
9159
|
+
|
|
9160
|
+
|
|
9161
|
+
def _cmd_budget_set(args: argparse.Namespace) -> int:
|
|
9162
|
+
"""`cctally budget set AMOUNT` — write `budget.weekly_usd`, preserving the
|
|
9163
|
+
other budget keys. Writes the DEFAULT config (F4). Task 3 appends the
|
|
9164
|
+
forward-only milestone reconcile here."""
|
|
9165
|
+
raw = getattr(args, "amount", None)
|
|
9166
|
+
if raw is None:
|
|
9167
|
+
eprint("cctally budget: `set` requires an amount, e.g. cctally budget set 300")
|
|
9168
|
+
return 2
|
|
9169
|
+
try:
|
|
9170
|
+
amount = float(raw)
|
|
9171
|
+
except (TypeError, ValueError):
|
|
9172
|
+
eprint(f"cctally budget: amount must be a positive number, got {raw!r}")
|
|
9173
|
+
return 2
|
|
9174
|
+
if not math.isfinite(amount) or amount <= 0:
|
|
9175
|
+
eprint(f"cctally budget: amount must be a positive finite number, got {raw!r}")
|
|
9176
|
+
return 2
|
|
9177
|
+
|
|
9178
|
+
# Read-modify-write under config_writer_lock + _load_config_unlocked
|
|
9179
|
+
# (load_config inside the lock self-deadlocks — fcntl.flock is per-fd).
|
|
9180
|
+
# Re-validate the merged block via _get_budget_config before persisting.
|
|
9181
|
+
with config_writer_lock():
|
|
9182
|
+
config = _load_config_unlocked()
|
|
9183
|
+
existing = config.get("budget")
|
|
9184
|
+
if existing is not None and not isinstance(existing, dict):
|
|
9185
|
+
eprint("cctally budget: budget config must be an object")
|
|
9186
|
+
return 2
|
|
9187
|
+
block = dict(existing or {})
|
|
9188
|
+
block["weekly_usd"] = amount
|
|
9189
|
+
config["budget"] = block
|
|
9190
|
+
try:
|
|
9191
|
+
validated = _get_budget_config(config)
|
|
9192
|
+
except _BudgetConfigError as exc:
|
|
9193
|
+
eprint(f"cctally budget: {exc}")
|
|
9194
|
+
return 2
|
|
9195
|
+
block["weekly_usd"] = validated["weekly_usd"]
|
|
9196
|
+
config["budget"] = block
|
|
9197
|
+
save_config(config)
|
|
9198
|
+
|
|
9199
|
+
weekly_usd = validated["weekly_usd"]
|
|
9200
|
+
alerts_enabled = validated["alerts_enabled"]
|
|
9201
|
+
thresholds = validated["alert_thresholds"]
|
|
9202
|
+
|
|
9203
|
+
# Forward-only-from-set reconcile (Task 3, spec §5): record thresholds
|
|
9204
|
+
# ALREADY crossed with alerted_at set but WITHOUT dispatch, so setting a
|
|
9205
|
+
# budget mid-week doesn't instant-popup; only LATER crossings fire. Runs
|
|
9206
|
+
# OUTSIDE the config_writer_lock (open_db has its own locking; reusing the
|
|
9207
|
+
# config lock here would needlessly serialize a stats.db write behind it).
|
|
9208
|
+
# Shared with `config set budget.*` + dashboard POST /api/settings via
|
|
9209
|
+
# _reconcile_budget_on_config_write (gated on _budget_alerts_active — a
|
|
9210
|
+
# budget with alerts off or no thresholds records nothing).
|
|
9211
|
+
_reconcile_budget_on_config_write(validated)
|
|
9212
|
+
if getattr(args, "json", False):
|
|
9213
|
+
print(json.dumps({
|
|
9214
|
+
"status": "set",
|
|
9215
|
+
"weekly_usd": weekly_usd,
|
|
9216
|
+
"alerts_enabled": alerts_enabled,
|
|
9217
|
+
"alert_thresholds": list(thresholds),
|
|
9218
|
+
}))
|
|
9219
|
+
return 0
|
|
9220
|
+
alerts_part = "alerts on" if alerts_enabled else "alerts off"
|
|
9221
|
+
if thresholds:
|
|
9222
|
+
thr_part = " · thresholds " + " ".join(f"{t}%" for t in thresholds)
|
|
9223
|
+
else:
|
|
9224
|
+
thr_part = " · no thresholds"
|
|
9225
|
+
print(f"Weekly budget set to ${weekly_usd:,.2f} · {alerts_part}{thr_part}")
|
|
9226
|
+
return 0
|
|
9227
|
+
|
|
9228
|
+
|
|
9229
|
+
def _cmd_budget_unset(args: argparse.Namespace) -> int:
|
|
9230
|
+
"""`cctally budget unset` — clear `budget.weekly_usd` (preserve
|
|
9231
|
+
alerts_enabled / alert_thresholds). Idempotent."""
|
|
9232
|
+
with config_writer_lock():
|
|
9233
|
+
config = _load_config_unlocked()
|
|
9234
|
+
existing = config.get("budget")
|
|
9235
|
+
if existing is not None and not isinstance(existing, dict):
|
|
9236
|
+
eprint("cctally budget: budget config must be an object")
|
|
9237
|
+
return 2
|
|
9238
|
+
block = dict(existing or {})
|
|
9239
|
+
block["weekly_usd"] = None
|
|
9240
|
+
config["budget"] = block
|
|
9241
|
+
save_config(config)
|
|
9242
|
+
|
|
9243
|
+
if getattr(args, "json", False):
|
|
9244
|
+
print(json.dumps({"status": "unset", "weekly_usd": None}))
|
|
9245
|
+
return 0
|
|
9246
|
+
print("Weekly budget cleared")
|
|
9247
|
+
return 0
|
|
9248
|
+
|
|
9249
|
+
|
|
9250
|
+
def _budget_render_unset(args: argparse.Namespace) -> int:
|
|
9251
|
+
"""No budget set → friendly stdout message, exit 0 (NOT an error)."""
|
|
9252
|
+
if getattr(args, "format", None):
|
|
9253
|
+
snap = _build_budget_no_budget_snapshot(args)
|
|
9254
|
+
_share_render_and_emit(snap, args)
|
|
9255
|
+
return 0
|
|
9256
|
+
if getattr(args, "json", False):
|
|
9257
|
+
print(json.dumps({
|
|
9258
|
+
"schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
|
|
9259
|
+
"status": "unset",
|
|
9260
|
+
"weekly_usd": None,
|
|
9261
|
+
}))
|
|
9262
|
+
return 0
|
|
9263
|
+
print("No weekly budget set. Set one with: cctally budget set <amount>.")
|
|
9264
|
+
return 0
|
|
9265
|
+
|
|
9266
|
+
|
|
9267
|
+
def _budget_verdict_ansi_code(verdict: str) -> str:
|
|
9268
|
+
"""ANSI color code for a budget verdict: ok→green, warn→amber, over→red."""
|
|
9269
|
+
return {"ok": "32", "warn": "33", "over": "31"}.get(verdict, "32")
|
|
9270
|
+
|
|
9271
|
+
|
|
9272
|
+
def _budget_render_terminal(args, budget_cfg, inputs, status) -> int:
|
|
9273
|
+
"""Render the §4 status block to stdout. Datetimes via format_display_dt
|
|
9274
|
+
(honors display.tz). Verdict color ok→green / warn→amber / over→red."""
|
|
9275
|
+
color = _supports_color_stdout()
|
|
9276
|
+
tz_render = getattr(args, "_resolved_tz", None)
|
|
9277
|
+
|
|
9278
|
+
total_seconds = (inputs.week_end_at - inputs.week_start_at).total_seconds()
|
|
9279
|
+
elapsed_days = status.elapsed_fraction * total_seconds / 86400.0
|
|
9280
|
+
remaining_days = max(
|
|
9281
|
+
0.0, total_seconds * (1.0 - status.elapsed_fraction) / 86400.0
|
|
9282
|
+
)
|
|
9283
|
+
|
|
9284
|
+
ws = format_display_dt(inputs.week_start_at, tz_render, fmt="%Y-%m-%d", suffix=False)
|
|
9285
|
+
we = format_display_dt(inputs.week_end_at, tz_render, fmt="%Y-%m-%d", suffix=False)
|
|
9286
|
+
|
|
9287
|
+
lines = []
|
|
9288
|
+
lines.append(
|
|
9289
|
+
f"Weekly budget: ${inputs.target_usd:,.2f} "
|
|
9290
|
+
f"(subscription week {ws} → {we})"
|
|
9291
|
+
)
|
|
9292
|
+
lines.append("")
|
|
9293
|
+
lines.append(
|
|
9294
|
+
f" Spent so far ${status.spent_usd:,.2f} "
|
|
9295
|
+
f"{status.consumption_pct:.1f}% of budget"
|
|
9296
|
+
)
|
|
9297
|
+
lines.append(f" Remaining ${status.remaining_usd:,.2f}")
|
|
9298
|
+
lines.append(
|
|
9299
|
+
f" Pace ${status.daily_pace_usd:,.2f}/day · "
|
|
9300
|
+
f"{elapsed_days:.1f} d elapsed"
|
|
9301
|
+
)
|
|
9302
|
+
lines.append(
|
|
9303
|
+
f" Daily budget ${status.daily_budget_remaining_usd:,.2f}/day for the "
|
|
9304
|
+
f"{remaining_days:.1f} d left to stay under"
|
|
9305
|
+
)
|
|
9306
|
+
verdict_label = {"ok": "OK", "warn": "WARN", "over": "OVER"}.get(
|
|
9307
|
+
status.verdict, status.verdict.upper()
|
|
9308
|
+
)
|
|
9309
|
+
verdict_glyph = {"ok": "✓", "warn": "⚠", "over": "✗"}.get(status.verdict, "")
|
|
9310
|
+
verdict_text = _style_ansi(
|
|
9311
|
+
f"{verdict_glyph} {verdict_label}".strip(),
|
|
9312
|
+
_budget_verdict_ansi_code(status.verdict),
|
|
9313
|
+
color,
|
|
9314
|
+
)
|
|
9315
|
+
proj_line = (
|
|
9316
|
+
f" Projected EOW ${status.projected_eow_low_usd:,.0f}"
|
|
9317
|
+
f"–${status.projected_eow_high_usd:,.0f} → {verdict_text}"
|
|
9318
|
+
)
|
|
9319
|
+
if status.low_confidence:
|
|
9320
|
+
proj_line += " (LOW CONF — early in week)"
|
|
9321
|
+
lines.append(proj_line)
|
|
9322
|
+
lines.append("")
|
|
9323
|
+
lines.append(_budget_alerts_line(budget_cfg, status))
|
|
9324
|
+
|
|
9325
|
+
print("\n".join(lines))
|
|
9326
|
+
return 0
|
|
9327
|
+
|
|
9328
|
+
|
|
9329
|
+
def _budget_alerts_line(budget_cfg, status) -> str:
|
|
9330
|
+
"""Render the "Alerts: ..." footer line: on/off + thresholds + crossed."""
|
|
9331
|
+
enabled = budget_cfg["alerts_enabled"]
|
|
9332
|
+
thresholds = budget_cfg["alert_thresholds"]
|
|
9333
|
+
if not enabled:
|
|
9334
|
+
return " Alerts: off"
|
|
9335
|
+
if not thresholds:
|
|
9336
|
+
return " Alerts: on · no thresholds configured"
|
|
9337
|
+
thr_str = " · ".join(f"{t}%" for t in thresholds)
|
|
9338
|
+
crossed = status.crossed_thresholds
|
|
9339
|
+
if crossed:
|
|
9340
|
+
crossed_str = ", ".join(f"{t}%" for t in crossed)
|
|
9341
|
+
tail = f"({crossed_str} crossed)"
|
|
9342
|
+
else:
|
|
9343
|
+
tail = "(none crossed yet)"
|
|
9344
|
+
return f" Alerts: on · thresholds {thr_str} · {tail}"
|
|
9345
|
+
|
|
9346
|
+
|
|
9347
|
+
def _budget_emit_json(budget_cfg, inputs, status) -> int:
|
|
9348
|
+
"""Emit the full BudgetStatus + config echo + window as JSON (schemaVersion 1).
|
|
9349
|
+
Window timestamps are `…Z`, ignoring display.tz (every --json is UTC)."""
|
|
9350
|
+
payload = {
|
|
9351
|
+
"schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
|
|
9352
|
+
"status": "ok",
|
|
9353
|
+
"weekly_usd": inputs.target_usd,
|
|
9354
|
+
"alerts_enabled": budget_cfg["alerts_enabled"],
|
|
9355
|
+
"alert_thresholds": list(budget_cfg["alert_thresholds"]),
|
|
9356
|
+
"week_start_at": _iso_z(inputs.week_start_at),
|
|
9357
|
+
"week_end_at": _iso_z(inputs.week_end_at),
|
|
9358
|
+
"as_of": _iso_z(inputs.now),
|
|
9359
|
+
"spent_usd": status.spent_usd,
|
|
9360
|
+
"remaining_usd": status.remaining_usd,
|
|
9361
|
+
"consumption_pct": status.consumption_pct,
|
|
9362
|
+
"elapsed_fraction": status.elapsed_fraction,
|
|
9363
|
+
"projected_eow_low_usd": status.projected_eow_low_usd,
|
|
9364
|
+
"projected_eow_high_usd": status.projected_eow_high_usd,
|
|
9365
|
+
"daily_pace_usd": status.daily_pace_usd,
|
|
9366
|
+
"daily_budget_remaining_usd": status.daily_budget_remaining_usd,
|
|
9367
|
+
"verdict": status.verdict,
|
|
9368
|
+
"low_confidence": status.low_confidence,
|
|
9369
|
+
"crossed_thresholds": list(status.crossed_thresholds),
|
|
9370
|
+
}
|
|
9371
|
+
print(json.dumps(payload))
|
|
9372
|
+
return 0
|
|
9373
|
+
|
|
9374
|
+
|
|
9375
|
+
def _build_budget_snapshot(args, budget_cfg, inputs, status):
|
|
9376
|
+
"""Build a `_lib_share.ShareSnapshot` (cmd="budget") for `--format` output.
|
|
9377
|
+
|
|
9378
|
+
`--reveal-projects` is inert for budget — there are no ProjectCells, so
|
|
9379
|
+
`_scrub` returns the snapshot unchanged. No parallel renderer; the gate
|
|
9380
|
+
calls `_share_render_and_emit(snap, args)`."""
|
|
9381
|
+
_lib_share = _share_load_lib()
|
|
9382
|
+
tz_label = _share_display_tz_label(getattr(args, "_resolved_tz", None))
|
|
9383
|
+
period_label = _share_period_label(
|
|
9384
|
+
inputs.week_start_at, inputs.week_end_at, tz_label
|
|
9385
|
+
)
|
|
9386
|
+
period = _lib_share.PeriodSpec(
|
|
9387
|
+
start=inputs.week_start_at, end=inputs.week_end_at,
|
|
9388
|
+
display_tz=tz_label, label=period_label,
|
|
9389
|
+
)
|
|
9390
|
+
columns = (
|
|
9391
|
+
_lib_share.ColumnSpec(key="metric", label="Metric", align="left"),
|
|
9392
|
+
_lib_share.ColumnSpec(key="value", label="Value", align="right",
|
|
9393
|
+
emphasis=True),
|
|
9394
|
+
)
|
|
9395
|
+
rows = (
|
|
9396
|
+
_lib_share.Row(cells={"metric": _lib_share.TextCell("Spent so far"),
|
|
9397
|
+
"value": _lib_share.MoneyCell(status.spent_usd)}),
|
|
9398
|
+
_lib_share.Row(cells={"metric": _lib_share.TextCell("Consumption"),
|
|
9399
|
+
"value": _lib_share.PercentCell(status.consumption_pct)}),
|
|
9400
|
+
_lib_share.Row(cells={"metric": _lib_share.TextCell("Remaining"),
|
|
9401
|
+
"value": _lib_share.MoneyCell(status.remaining_usd)}),
|
|
9402
|
+
_lib_share.Row(cells={"metric": _lib_share.TextCell("Daily pace"),
|
|
9403
|
+
"value": _lib_share.MoneyCell(status.daily_pace_usd)}),
|
|
9404
|
+
_lib_share.Row(cells={"metric": _lib_share.TextCell("Projected EOW (low)"),
|
|
9405
|
+
"value": _lib_share.MoneyCell(status.projected_eow_low_usd)}),
|
|
9406
|
+
_lib_share.Row(cells={"metric": _lib_share.TextCell("Projected EOW (high)"),
|
|
9407
|
+
"value": _lib_share.MoneyCell(status.projected_eow_high_usd)}),
|
|
9408
|
+
)
|
|
9409
|
+
notes = ("LOW CONF — early in week",) if status.low_confidence else ()
|
|
9410
|
+
subtitle = " · ".join([
|
|
9411
|
+
period_label,
|
|
9412
|
+
args.theme,
|
|
9413
|
+
"real projects" if args.reveal_projects else "projects anonymized",
|
|
9414
|
+
])
|
|
9415
|
+
return _lib_share.ShareSnapshot(
|
|
9416
|
+
cmd="budget",
|
|
9417
|
+
title=f"Budget — week of {inputs.week_start_at.strftime('%b %d')}",
|
|
9418
|
+
subtitle=subtitle,
|
|
9419
|
+
period=period,
|
|
9420
|
+
columns=columns,
|
|
9421
|
+
rows=rows,
|
|
9422
|
+
chart=None,
|
|
9423
|
+
totals=(
|
|
9424
|
+
_lib_share.Totalled(label="Verdict", value=status.verdict.upper()),
|
|
9425
|
+
_lib_share.Totalled(label="Budget", value=f"${inputs.target_usd:,.2f}"),
|
|
9426
|
+
),
|
|
9427
|
+
notes=notes,
|
|
9428
|
+
generated_at=_share_now_utc(),
|
|
9429
|
+
version=_share_resolve_version(),
|
|
9430
|
+
)
|
|
9431
|
+
|
|
9432
|
+
|
|
9433
|
+
def _build_budget_no_data_snapshot(args, budget_cfg, now_utc):
|
|
9434
|
+
"""Share snapshot for "budget set but no usage data yet this week" — a
|
|
9435
|
+
uniformly-shaped artifact rather than free-form text. Computes the real
|
|
9436
|
+
subscription-week boundaries from config so the period label is meaningful."""
|
|
9437
|
+
_lib_share = _share_load_lib()
|
|
9438
|
+
config = _load_claude_config_for_args(args)
|
|
9439
|
+
tz = getattr(args, "_resolved_tz", None)
|
|
9440
|
+
tz_label = _share_display_tz_label(tz)
|
|
9441
|
+
week_start_name = get_week_start_name(
|
|
9442
|
+
config, getattr(args, "week_start_name", None)
|
|
9443
|
+
)
|
|
9444
|
+
ws_date, we_date = compute_week_bounds(now_utc, week_start_name)
|
|
9445
|
+
# internal fallback: host-local intentional
|
|
9446
|
+
local_tz = dt.datetime.now().astimezone().tzinfo
|
|
9447
|
+
week_start_dt = dt.datetime.combine(ws_date, dt.time.min, tzinfo=local_tz)
|
|
9448
|
+
week_end_dt = dt.datetime.combine(
|
|
9449
|
+
we_date + dt.timedelta(days=1), dt.time.min, tzinfo=local_tz
|
|
9450
|
+
)
|
|
9451
|
+
period_label = _share_period_label(week_start_dt, week_end_dt, tz_label)
|
|
9452
|
+
target = budget_cfg["weekly_usd"]
|
|
9453
|
+
subtitle = " · ".join([
|
|
9454
|
+
period_label, args.theme,
|
|
9455
|
+
"real projects" if args.reveal_projects else "projects anonymized",
|
|
9456
|
+
])
|
|
9457
|
+
return _lib_share.ShareSnapshot(
|
|
9458
|
+
cmd="budget",
|
|
9459
|
+
title=f"Budget — week of {week_start_dt.strftime('%b %d')}",
|
|
9460
|
+
subtitle=subtitle,
|
|
9461
|
+
period=_lib_share.PeriodSpec(
|
|
9462
|
+
start=week_start_dt, end=week_end_dt,
|
|
9463
|
+
display_tz=tz_label, label=period_label,
|
|
9464
|
+
),
|
|
9465
|
+
columns=(
|
|
9466
|
+
_lib_share.ColumnSpec(key="metric", label="Metric", align="left"),
|
|
9467
|
+
_lib_share.ColumnSpec(key="value", label="Value", align="right",
|
|
9468
|
+
emphasis=True),
|
|
9469
|
+
),
|
|
9470
|
+
rows=(
|
|
9471
|
+
_lib_share.Row(cells={
|
|
9472
|
+
"metric": _lib_share.TextCell("Weekly budget"),
|
|
9473
|
+
"value": _lib_share.MoneyCell(float(target)),
|
|
9474
|
+
}),
|
|
9475
|
+
),
|
|
9476
|
+
chart=None,
|
|
9477
|
+
totals=(),
|
|
9478
|
+
notes=("No usage data recorded for this week yet — run "
|
|
9479
|
+
"cctally record-usage to populate.",),
|
|
9480
|
+
generated_at=_share_now_utc(),
|
|
9481
|
+
version=_share_resolve_version(),
|
|
9482
|
+
)
|
|
9483
|
+
|
|
9484
|
+
|
|
9485
|
+
def _build_budget_no_budget_snapshot(args):
|
|
9486
|
+
"""Share snapshot for the "no budget set" status — a uniform artifact."""
|
|
9487
|
+
_lib_share = _share_load_lib()
|
|
9488
|
+
now_utc = _command_as_of()
|
|
9489
|
+
tz = getattr(args, "_resolved_tz", None)
|
|
9490
|
+
if tz is None:
|
|
9491
|
+
# Purely defensive: the sole caller (_budget_render_unset) already
|
|
9492
|
+
# resolves args._resolved_tz before dispatch, so this rarely fires —
|
|
9493
|
+
# resolve here anyway so the artifact always carries a tz label.
|
|
9494
|
+
config = _load_claude_config_for_args(args)
|
|
9495
|
+
tz = resolve_display_tz(args, config)
|
|
9496
|
+
tz_label = _share_display_tz_label(tz)
|
|
9497
|
+
period_label = _share_period_label(now_utc, now_utc, tz_label)
|
|
9498
|
+
subtitle = " · ".join([
|
|
9499
|
+
period_label, args.theme,
|
|
9500
|
+
"real projects" if args.reveal_projects else "projects anonymized",
|
|
9501
|
+
])
|
|
9502
|
+
return _lib_share.ShareSnapshot(
|
|
9503
|
+
cmd="budget",
|
|
9504
|
+
title="Budget — no budget set",
|
|
9505
|
+
subtitle=subtitle,
|
|
9506
|
+
period=_lib_share.PeriodSpec(
|
|
9507
|
+
start=now_utc, end=now_utc, display_tz=tz_label, label=period_label,
|
|
9508
|
+
),
|
|
9509
|
+
columns=(
|
|
9510
|
+
_lib_share.ColumnSpec(key="metric", label="Metric", align="left"),
|
|
9511
|
+
_lib_share.ColumnSpec(key="value", label="Value", align="right",
|
|
9512
|
+
emphasis=True),
|
|
9513
|
+
),
|
|
9514
|
+
rows=(),
|
|
9515
|
+
chart=None,
|
|
9516
|
+
totals=(),
|
|
9517
|
+
notes=("No weekly budget set. Set one with: cctally budget set <amount>.",),
|
|
9518
|
+
generated_at=_share_now_utc(),
|
|
9519
|
+
version=_share_resolve_version(),
|
|
9520
|
+
)
|
|
9521
|
+
|
|
9522
|
+
|
|
8861
9523
|
def cmd_percent_breakdown(args: argparse.Namespace) -> int:
|
|
8862
9524
|
config = load_config()
|
|
8863
9525
|
tz = resolve_display_tz(args, config)
|
|
@@ -10381,6 +11043,355 @@ _LEGACY_BACKUP_DIR_PREFIX = "cctally-legacy-hook-backup-"
|
|
|
10381
11043
|
_LEGACY_POLLER_SIGTERM_GRACE_S = 0.250
|
|
10382
11044
|
|
|
10383
11045
|
|
|
11046
|
+
# Private sentinel so `_pricing_observed_models` can tell "default 30-day
|
|
11047
|
+
# window" apart from an explicit `since=None` all-history scan.
|
|
11048
|
+
_PRICING_SCAN_DEFAULT_WINDOW = object()
|
|
11049
|
+
|
|
11050
|
+
|
|
11051
|
+
def _pricing_observed_models(now_utc, *, since=_PRICING_SCAN_DEFAULT_WINDOW):
|
|
11052
|
+
"""Read-only scan of the session-entry cache for observed models.
|
|
11053
|
+
|
|
11054
|
+
Returns a list of ``(provider, model, entry_count, token_total)`` tuples,
|
|
11055
|
+
one per DISTINCT model seen in ``cache.db`` (Claude ``session_entries`` +
|
|
11056
|
+
Codex ``codex_session_entries``). By default it scans the trailing 30-day
|
|
11057
|
+
window relative to ``now_utc`` (the `doctor` coverage signal — recent =
|
|
11058
|
+
actionable). Pass ``since=<datetime>`` to widen/narrow the window, or
|
|
11059
|
+
``since=None`` explicitly for an all-history scan (used by `pricing-check`).
|
|
11060
|
+
|
|
11061
|
+
Read-only / no-mutation contract (spec §5.1): mirrors the freshness read
|
|
11062
|
+
in this same function — guard on ``CACHE_DB_PATH.exists()``, raw
|
|
11063
|
+
``sqlite3.connect`` (NEVER ``open_cache_db()`` / ``sync_cache()`` /
|
|
11064
|
+
``load_config()`` / ``ensure_dirs()``), and treat a missing table/column as
|
|
11065
|
+
"no observed models" rather than crashing. ``doctor --json`` on a virgin
|
|
11066
|
+
HOME must not create ``APP_DIR`` — regression
|
|
11067
|
+
``test_pricing_observed_models_no_mutation_on_fresh_home``.
|
|
11068
|
+
"""
|
|
11069
|
+
out: list = []
|
|
11070
|
+
if not _cctally_core.CACHE_DB_PATH.exists():
|
|
11071
|
+
return out
|
|
11072
|
+
# Sentinel: the 30-day window is the default; `since=False` is not a
|
|
11073
|
+
# supported value, so distinguish "caller wants all-history" (None) from
|
|
11074
|
+
# "caller did not pass since" via a private marker.
|
|
11075
|
+
if since is _PRICING_SCAN_DEFAULT_WINDOW:
|
|
11076
|
+
cutoff_iso = (now_utc - dt.timedelta(days=30)).isoformat()
|
|
11077
|
+
elif since is None:
|
|
11078
|
+
cutoff_iso = None # all-history
|
|
11079
|
+
else:
|
|
11080
|
+
cutoff_iso = since.isoformat()
|
|
11081
|
+
try:
|
|
11082
|
+
conn = sqlite3.connect(str(_cctally_core.CACHE_DB_PATH))
|
|
11083
|
+
except sqlite3.Error:
|
|
11084
|
+
return out
|
|
11085
|
+
try:
|
|
11086
|
+
# Token-sum expressions use the ACTUAL cache column names from
|
|
11087
|
+
# bin/_cctally_db.py::_apply_cache_schema (verified — Claude uses
|
|
11088
|
+
# cache_create_tokens, NOT cache_creation_tokens; Codex carries a
|
|
11089
|
+
# materialized total_tokens covering input/cache/output/reasoning).
|
|
11090
|
+
for provider, table, tok_expr in (
|
|
11091
|
+
("claude", "session_entries",
|
|
11092
|
+
"COALESCE(input_tokens,0)+COALESCE(output_tokens,0)+"
|
|
11093
|
+
"COALESCE(cache_create_tokens,0)+COALESCE(cache_read_tokens,0)"),
|
|
11094
|
+
("codex", "codex_session_entries",
|
|
11095
|
+
"COALESCE(total_tokens,0)"),
|
|
11096
|
+
):
|
|
11097
|
+
where = "model IS NOT NULL"
|
|
11098
|
+
params: tuple = ()
|
|
11099
|
+
if cutoff_iso is not None:
|
|
11100
|
+
where = "timestamp_utc >= ? AND " + where
|
|
11101
|
+
params = (cutoff_iso,)
|
|
11102
|
+
try:
|
|
11103
|
+
rows = conn.execute(
|
|
11104
|
+
f"SELECT model, COUNT(*), SUM({tok_expr}) FROM {table} "
|
|
11105
|
+
f"WHERE {where} GROUP BY model",
|
|
11106
|
+
params,
|
|
11107
|
+
).fetchall()
|
|
11108
|
+
except sqlite3.OperationalError:
|
|
11109
|
+
rows = [] # table/column missing — treat as none
|
|
11110
|
+
for model, cnt, toks in rows:
|
|
11111
|
+
out.append((provider, model, int(cnt or 0), int(toks or 0)))
|
|
11112
|
+
finally:
|
|
11113
|
+
conn.close()
|
|
11114
|
+
return out
|
|
11115
|
+
|
|
11116
|
+
|
|
11117
|
+
# ── pricing-check network legs (spec §5.2) ──────────────────────────────
|
|
11118
|
+
#
|
|
11119
|
+
# Hidden dev hooks (like `project`'s CCTALLY_AS_OF): read at call time, NOT
|
|
11120
|
+
# import time, so a test/harness can set them in the child process env. They
|
|
11121
|
+
# are deliberately absent from `--help` — they only exist to make the
|
|
11122
|
+
# network legs deterministic in tests (invariant #4: no test hits the
|
|
11123
|
+
# network). When set to a path, the corresponding fetcher reads that local
|
|
11124
|
+
# JSON instead of issuing an HTTP request.
|
|
11125
|
+
_ENV_PRICING_LITELLM_FILE = "CCTALLY_PRICING_LITELLM_FILE"
|
|
11126
|
+
_ENV_PRICING_MODELS_FILE = "CCTALLY_PRICING_MODELS_FILE"
|
|
11127
|
+
|
|
11128
|
+
|
|
11129
|
+
def _fetch_litellm_prices() -> "tuple[dict, bool]":
|
|
11130
|
+
"""Fetch the LiteLLM model_prices map. Returns ``(data, ok)``.
|
|
11131
|
+
|
|
11132
|
+
NEVER raises to the caller: on any failure (bad inject file, network
|
|
11133
|
+
error, non-JSON, non-dict body) returns ``({}, False)`` so the drift
|
|
11134
|
+
leg degrades gracefully (spec invariant #1). Honors the hidden
|
|
11135
|
+
``CCTALLY_PRICING_LITELLM_FILE`` env hook for deterministic tests.
|
|
11136
|
+
"""
|
|
11137
|
+
inject = os.environ.get(_ENV_PRICING_LITELLM_FILE, "").strip()
|
|
11138
|
+
if inject:
|
|
11139
|
+
try:
|
|
11140
|
+
data = json.loads(pathlib.Path(inject).read_text())
|
|
11141
|
+
return (data, True) if isinstance(data, dict) else ({}, False)
|
|
11142
|
+
except Exception:
|
|
11143
|
+
return {}, False
|
|
11144
|
+
try:
|
|
11145
|
+
# UA can be plain here — LiteLLM is a raw GitHub JSON blob, not the
|
|
11146
|
+
# Anthropic rate-limited surface that requires `claude-code/*`.
|
|
11147
|
+
req = urllib.request.Request(
|
|
11148
|
+
LITELLM_PRICES_URL, headers={"User-Agent": "cctally"},
|
|
11149
|
+
)
|
|
11150
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
11151
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
11152
|
+
return (data, True) if isinstance(data, dict) else ({}, False)
|
|
11153
|
+
except Exception as exc:
|
|
11154
|
+
eprint(f"[pricing-check] LiteLLM fetch failed: {exc}")
|
|
11155
|
+
return {}, False
|
|
11156
|
+
|
|
11157
|
+
|
|
11158
|
+
def _fetch_anthropic_models_or_none() -> "dict | None":
|
|
11159
|
+
"""GET https://api.anthropic.com/v1/models with the Claude OAuth bearer.
|
|
11160
|
+
|
|
11161
|
+
Returns the parsed JSON object on success, or ``None`` on ANY failure
|
|
11162
|
+
(no token, 401/403, network error, non-JSON, non-dict body) so the
|
|
11163
|
+
existence leg degrades to ``status: degraded``. NEVER raises to the
|
|
11164
|
+
caller — wrapped in a broad try/except.
|
|
11165
|
+
|
|
11166
|
+
C0 DE-RISK SPIKE STATUS — UNVERIFIED LIVE REACHABILITY: this code path
|
|
11167
|
+
was authored WITHOUT a live `/v1/models` call (the sandbox has no real
|
|
11168
|
+
OAuth token). It is UNKNOWN whether the Claude Code OAuth bearer
|
|
11169
|
+
authorizes `GET /v1/models`; if the endpoint 401/403s, this function
|
|
11170
|
+
returns None and the existence leg reports `status: degraded` (the
|
|
11171
|
+
feature still stands on LiteLLM drift + the local coverage guard). The
|
|
11172
|
+
maintainer must run `cctally pricing-check` on a machine with a real
|
|
11173
|
+
OAuth token to confirm the leg actually reaches `status: ok`. The whole
|
|
11174
|
+
leg is fully exercisable offline via `CCTALLY_PRICING_MODELS_FILE`.
|
|
11175
|
+
"""
|
|
11176
|
+
try:
|
|
11177
|
+
token = _resolve_oauth_token()
|
|
11178
|
+
if not token:
|
|
11179
|
+
return None
|
|
11180
|
+
# Mirror the OAuth-usage UA discipline: Anthropic rate-limits
|
|
11181
|
+
# per-UA, so use `claude-code/<version>` (NOT Python-urllib).
|
|
11182
|
+
# RAW config read (NOT load_config / _load_config_unlocked — both
|
|
11183
|
+
# call ensure_dirs() and would mutate a fresh HOME, violating the
|
|
11184
|
+
# read-only contract). Honors an `oauth_usage.user_agent` override
|
|
11185
|
+
# when config.json exists; otherwise the default `claude-code/<v>`.
|
|
11186
|
+
raw_cfg = {}
|
|
11187
|
+
try:
|
|
11188
|
+
if _cctally_core.CONFIG_PATH.exists():
|
|
11189
|
+
parsed = json.loads(
|
|
11190
|
+
_cctally_core.CONFIG_PATH.read_text(encoding="utf-8"))
|
|
11191
|
+
if isinstance(parsed, dict):
|
|
11192
|
+
raw_cfg = parsed
|
|
11193
|
+
except (json.JSONDecodeError, OSError):
|
|
11194
|
+
raw_cfg = {}
|
|
11195
|
+
cfg = _get_oauth_usage_config(raw_cfg)
|
|
11196
|
+
user_agent = _resolve_oauth_usage_user_agent(cfg)
|
|
11197
|
+
req = urllib.request.Request(
|
|
11198
|
+
"https://api.anthropic.com/v1/models",
|
|
11199
|
+
headers={
|
|
11200
|
+
"Authorization": f"Bearer {token}",
|
|
11201
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
11202
|
+
"anthropic-version": "2023-06-01",
|
|
11203
|
+
"User-Agent": user_agent,
|
|
11204
|
+
},
|
|
11205
|
+
)
|
|
11206
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
11207
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
11208
|
+
return data if isinstance(data, dict) else None
|
|
11209
|
+
except Exception as exc:
|
|
11210
|
+
eprint(f"[pricing-check] /v1/models fetch failed: {exc}")
|
|
11211
|
+
return None
|
|
11212
|
+
|
|
11213
|
+
|
|
11214
|
+
def _pricing_existence_check() -> dict:
|
|
11215
|
+
"""Anthropic-only vendor `/v1/models` coverage gap.
|
|
11216
|
+
|
|
11217
|
+
Returns ``{"status": "ok"|"degraded"|"skipped", "unpriced_vendor_models":
|
|
11218
|
+
[...]}``. ``ok`` = the vendor list was obtained; the gap is the IDs the
|
|
11219
|
+
vendor offers that ``_resolve_model_pricing`` cannot price. ``degraded``
|
|
11220
|
+
= the fetch failed (no token / 401 / network / non-JSON). Honors the
|
|
11221
|
+
hidden ``CCTALLY_PRICING_MODELS_FILE`` env hook.
|
|
11222
|
+
|
|
11223
|
+
Codex existence is intentionally out of scope (no OpenAI credentials —
|
|
11224
|
+
spec §4 non-goals); the payload's existence block is Anthropic-only.
|
|
11225
|
+
"""
|
|
11226
|
+
inject = os.environ.get(_ENV_PRICING_MODELS_FILE, "").strip()
|
|
11227
|
+
if inject:
|
|
11228
|
+
try:
|
|
11229
|
+
raw = json.loads(pathlib.Path(inject).read_text())
|
|
11230
|
+
except Exception:
|
|
11231
|
+
return {"status": "degraded", "unpriced_vendor_models": []}
|
|
11232
|
+
else:
|
|
11233
|
+
raw = _fetch_anthropic_models_or_none()
|
|
11234
|
+
if raw is None:
|
|
11235
|
+
return {"status": "degraded", "unpriced_vendor_models": []}
|
|
11236
|
+
if not isinstance(raw, dict):
|
|
11237
|
+
return {"status": "degraded", "unpriced_vendor_models": []}
|
|
11238
|
+
ids = [m.get("id") for m in raw.get("data", []) if isinstance(m, dict) and m.get("id")]
|
|
11239
|
+
# Detection-only: warn=False so a vendor model we don't price doesn't
|
|
11240
|
+
# fire the cost-engine's one-shot stderr warning.
|
|
11241
|
+
gap = sorted(i for i in ids if _resolve_model_pricing(i, warn=False) is None)
|
|
11242
|
+
return {"status": "ok", "unpriced_vendor_models": gap}
|
|
11243
|
+
|
|
11244
|
+
|
|
11245
|
+
def cmd_pricing_check(args: argparse.Namespace) -> int:
|
|
11246
|
+
"""`cctally pricing-check` — detect stale/missing embedded pricing.
|
|
11247
|
+
|
|
11248
|
+
Three independently-degrading legs (spec §5.2):
|
|
11249
|
+
1. coverage (offline, ALL-HISTORY) — models in cache.db we can't price.
|
|
11250
|
+
2. drift (network, LiteLLM) — embedded value vs LiteLLM (direction-aware
|
|
11251
|
+
+ allowlist-suppressed).
|
|
11252
|
+
3. existence (network, Anthropic `/v1/models`) — vendor models absent
|
|
11253
|
+
from our table.
|
|
11254
|
+
|
|
11255
|
+
Exit-code precedence (spec invariant #1, §5.2):
|
|
11256
|
+
1 — ANY actionable finding (coverage gap OR value_drift OR
|
|
11257
|
+
missing_from_us OR an existence gap), EVEN IF a network leg
|
|
11258
|
+
degraded. Findings always win over degradation.
|
|
11259
|
+
0 — NO actionable findings (fully clean OR partially/fully degraded
|
|
11260
|
+
but nothing actionable). JSON still carries status=degraded.
|
|
11261
|
+
2 — argument/usage error (argparse handles before we run).
|
|
11262
|
+
|
|
11263
|
+
``status`` (ok|degraded) reports check COMPLETENESS; the exit code
|
|
11264
|
+
reports whether the operator must ACT. They are orthogonal: a degraded
|
|
11265
|
+
leg never masks a finding and never fabricates one.
|
|
11266
|
+
"""
|
|
11267
|
+
now_utc = _command_as_of()
|
|
11268
|
+
status = "ok"
|
|
11269
|
+
degraded: list[str] = []
|
|
11270
|
+
|
|
11271
|
+
# 1. Coverage — offline, all-history (since=None). Read-only scan; any
|
|
11272
|
+
# failure degrades to [] (the scan itself swallows DB errors).
|
|
11273
|
+
try:
|
|
11274
|
+
observed = _pricing_observed_models(now_utc, since=None)
|
|
11275
|
+
coverage = classify_coverage(
|
|
11276
|
+
observed,
|
|
11277
|
+
lambda m: _resolve_model_pricing(m, warn=False),
|
|
11278
|
+
_is_codex_fallback,
|
|
11279
|
+
)
|
|
11280
|
+
except Exception:
|
|
11281
|
+
coverage = []
|
|
11282
|
+
|
|
11283
|
+
drift = {"value_drift": [], "missing_from_us": [], "ahead_of_litellm": []}
|
|
11284
|
+
existence = {"status": "skipped", "unpriced_vendor_models": []}
|
|
11285
|
+
|
|
11286
|
+
if not args.offline:
|
|
11287
|
+
litellm, ok = _fetch_litellm_prices()
|
|
11288
|
+
if ok:
|
|
11289
|
+
scoped = scope_litellm(litellm)
|
|
11290
|
+
res = diff_pricing(
|
|
11291
|
+
CLAUDE_MODEL_PRICING, CODEX_MODEL_PRICING,
|
|
11292
|
+
scoped, PRICING_DRIFT_ALLOWLIST,
|
|
11293
|
+
)
|
|
11294
|
+
drift = {
|
|
11295
|
+
"value_drift": [dataclasses.asdict(r) for r in res.value_drift],
|
|
11296
|
+
"missing_from_us": list(res.missing_from_us),
|
|
11297
|
+
"ahead_of_litellm": list(res.ahead_of_litellm),
|
|
11298
|
+
}
|
|
11299
|
+
else:
|
|
11300
|
+
status = "degraded"
|
|
11301
|
+
degraded.append("litellm")
|
|
11302
|
+
existence = _pricing_existence_check()
|
|
11303
|
+
if existence["status"] == "degraded":
|
|
11304
|
+
status = "degraded"
|
|
11305
|
+
degraded.append("models_api")
|
|
11306
|
+
|
|
11307
|
+
# Actionable = any finding on a leg that ran. `ahead_of_litellm` is
|
|
11308
|
+
# NEVER actionable (invariant #2). A degraded leg contributes no finding.
|
|
11309
|
+
actionable = (
|
|
11310
|
+
bool(coverage)
|
|
11311
|
+
or bool(drift["value_drift"])
|
|
11312
|
+
or bool(drift["missing_from_us"])
|
|
11313
|
+
or bool(existence["unpriced_vendor_models"])
|
|
11314
|
+
)
|
|
11315
|
+
|
|
11316
|
+
payload = {
|
|
11317
|
+
"schemaVersion": 1,
|
|
11318
|
+
"status": status,
|
|
11319
|
+
"degraded_components": degraded,
|
|
11320
|
+
"snapshotDate": PRICING_SNAPSHOT_DATE,
|
|
11321
|
+
"coverage": [dataclasses.asdict(g) for g in coverage],
|
|
11322
|
+
"drift": drift,
|
|
11323
|
+
"existence": existence,
|
|
11324
|
+
"litellmSource": LITELLM_PRICES_URL,
|
|
11325
|
+
}
|
|
11326
|
+
|
|
11327
|
+
if getattr(args, "json", False):
|
|
11328
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
11329
|
+
else:
|
|
11330
|
+
_render_pricing_check_text(payload, offline=args.offline, actionable=actionable)
|
|
11331
|
+
|
|
11332
|
+
return 1 if actionable else 0
|
|
11333
|
+
|
|
11334
|
+
|
|
11335
|
+
def _render_pricing_check_text(payload: dict, *, offline: bool, actionable: bool) -> None:
|
|
11336
|
+
"""Human-readable render of the pricing-check payload. JSON is the
|
|
11337
|
+
machine contract; this is a readable summary for interactive use."""
|
|
11338
|
+
out = sys.stdout.write
|
|
11339
|
+
status = payload["status"]
|
|
11340
|
+
out(f"pricing-check (snapshot {payload['snapshotDate']})\n")
|
|
11341
|
+
if status == "degraded":
|
|
11342
|
+
out(f" status: degraded — incomplete check "
|
|
11343
|
+
f"({', '.join(payload['degraded_components'])} unavailable)\n")
|
|
11344
|
+
else:
|
|
11345
|
+
out(" status: ok\n")
|
|
11346
|
+
|
|
11347
|
+
cov = payload["coverage"]
|
|
11348
|
+
if cov:
|
|
11349
|
+
out(f"\n Coverage gaps ({len(cov)} model(s) we cannot price exactly):\n")
|
|
11350
|
+
for g in cov:
|
|
11351
|
+
kind = ("unpriced ($0)" if g["kind"] == "unpriced"
|
|
11352
|
+
else "approximated via gpt-5")
|
|
11353
|
+
entries = g["entry_count"]
|
|
11354
|
+
noun = "entry" if entries == 1 else "entries"
|
|
11355
|
+
out(f" • {g['model']} ({g['provider']}): {entries} "
|
|
11356
|
+
f"{noun} / {g['token_total']} tokens — {kind}\n")
|
|
11357
|
+
else:
|
|
11358
|
+
out("\n Coverage: all observed models priced.\n")
|
|
11359
|
+
|
|
11360
|
+
if offline:
|
|
11361
|
+
out("\n (offline — network drift + existence legs skipped)\n")
|
|
11362
|
+
else:
|
|
11363
|
+
vd = payload["drift"]["value_drift"]
|
|
11364
|
+
mu = payload["drift"]["missing_from_us"]
|
|
11365
|
+
if vd:
|
|
11366
|
+
out(f"\n Value drift vs LiteLLM ({len(vd)} field(s)):\n")
|
|
11367
|
+
for d in vd:
|
|
11368
|
+
out(f" • {d['model']}.{d['field']}: ours={d['ours']} "
|
|
11369
|
+
f"litellm={d['theirs']}\n")
|
|
11370
|
+
if mu:
|
|
11371
|
+
out(f"\n Models LiteLLM prices but we don't ({len(mu)}):\n")
|
|
11372
|
+
for m in mu:
|
|
11373
|
+
out(f" • {m}\n")
|
|
11374
|
+
if not vd and not mu and "litellm" not in payload["degraded_components"]:
|
|
11375
|
+
out("\n Drift: embedded pricing matches LiteLLM.\n")
|
|
11376
|
+
ex = payload["existence"]
|
|
11377
|
+
if ex["status"] == "ok":
|
|
11378
|
+
gap = ex["unpriced_vendor_models"]
|
|
11379
|
+
if gap:
|
|
11380
|
+
out(f"\n Vendor models not in our table ({len(gap)}):\n")
|
|
11381
|
+
for m in gap:
|
|
11382
|
+
out(f" • {m}\n")
|
|
11383
|
+
else:
|
|
11384
|
+
out("\n Existence: all vendor models priced.\n")
|
|
11385
|
+
elif ex["status"] == "degraded":
|
|
11386
|
+
out("\n Existence: /v1/models unavailable (skipped).\n")
|
|
11387
|
+
|
|
11388
|
+
# Single-sourced from cmd_pricing_check's exit-code predicate (don't
|
|
11389
|
+
# recompute the four-clause boolean here — it would drift).
|
|
11390
|
+
if actionable:
|
|
11391
|
+
out("\n Action: review CLAUDE_MODEL_PRICING / CODEX_MODEL_PRICING; "
|
|
11392
|
+
"bump PRICING_SNAPSHOT_DATE on sync.\n")
|
|
11393
|
+
|
|
11394
|
+
|
|
10384
11395
|
def doctor_gather_state(
|
|
10385
11396
|
*,
|
|
10386
11397
|
now_utc: "dt.datetime | None" = None,
|
|
@@ -10714,6 +11725,26 @@ def doctor_gather_state(
|
|
|
10714
11725
|
_compute_effective_update_available(update_state, update_suppress, now_utc)
|
|
10715
11726
|
)
|
|
10716
11727
|
|
|
11728
|
+
# ── Pricing coverage (spec §5.1) ─────────────────────────────────
|
|
11729
|
+
# Read-only trailing-30d scan + classification via the pure-fn kernel.
|
|
11730
|
+
# Any failure degrades to None so the check renders OK (never FAIL) and
|
|
11731
|
+
# the rest of the report is unaffected — same posture as the cache reads
|
|
11732
|
+
# above. `_pricing_observed_models` honors the no-mutation contract.
|
|
11733
|
+
pricing_coverage = None
|
|
11734
|
+
try:
|
|
11735
|
+
observed = _pricing_observed_models(now_utc)
|
|
11736
|
+
# Detection-only: pass warn=False so finding an unpriced model here does
|
|
11737
|
+
# NOT fire the cost-engine's `[cost] unknown model` stderr warning (this
|
|
11738
|
+
# is a read-only diagnostic, and the warning would also poison the
|
|
11739
|
+
# dedup set, suppressing a later genuine cost-path warning).
|
|
11740
|
+
pricing_coverage = classify_coverage(
|
|
11741
|
+
observed,
|
|
11742
|
+
lambda m: _resolve_model_pricing(m, warn=False),
|
|
11743
|
+
_is_codex_fallback,
|
|
11744
|
+
)
|
|
11745
|
+
except Exception:
|
|
11746
|
+
pricing_coverage = None
|
|
11747
|
+
|
|
10717
11748
|
# ── Meta ─────────────────────────────────────────────────────────
|
|
10718
11749
|
cctally_version_tuple = _lib_changelog._read_latest_changelog_version()
|
|
10719
11750
|
cctally_version = (
|
|
@@ -10759,6 +11790,8 @@ def doctor_gather_state(
|
|
|
10759
11790
|
dev_mode=_cctally_core.DEV_MODE,
|
|
10760
11791
|
app_dir=str(_cctally_core.APP_DIR),
|
|
10761
11792
|
is_dev_checkout=_cctally_core._is_dev_checkout(),
|
|
11793
|
+
# Pricing-freshness check (spec §5.1): trailing-30d coverage gaps.
|
|
11794
|
+
pricing_coverage=pricing_coverage,
|
|
10762
11795
|
)
|
|
10763
11796
|
|
|
10764
11797
|
|
|
@@ -11980,6 +13013,51 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
11980
13013
|
_add_share_args(fc, has_status_line=True)
|
|
11981
13014
|
fc.set_defaults(func=cmd_forecast)
|
|
11982
13015
|
|
|
13016
|
+
# budget — cctally-original (NOT a ccusage drop-in), so flat surface only;
|
|
13017
|
+
# no claude/codex subgroup. `--config` is honored read-only on bare status
|
|
13018
|
+
# but rejected on set/unset (F4). `--reveal-projects` is accepted for share
|
|
13019
|
+
# surface parity but inert (no per-project axis). `--tz` follows the sibling
|
|
13020
|
+
# reporting commands' precedence.
|
|
13021
|
+
bg = sub.add_parser(
|
|
13022
|
+
"budget",
|
|
13023
|
+
help="Weekly equivalent-$ budget + pace + spend alerts",
|
|
13024
|
+
formatter_class=CLIHelpFormatter,
|
|
13025
|
+
description=textwrap.dedent(
|
|
13026
|
+
"""\
|
|
13027
|
+
Track Claude equivalent-$ spend for the current subscription week
|
|
13028
|
+
against a weekly budget. Shows spend, pace, projected end-of-week,
|
|
13029
|
+
and a verdict (ok / warn / over). `budget set <amount>` and
|
|
13030
|
+
`budget unset` manage the budget; spend-crossing alerts fire from
|
|
13031
|
+
record-usage (see `cctally alerts`).
|
|
13032
|
+
"""
|
|
13033
|
+
),
|
|
13034
|
+
epilog=textwrap.dedent(
|
|
13035
|
+
"""\
|
|
13036
|
+
Examples:
|
|
13037
|
+
cctally budget
|
|
13038
|
+
cctally budget set 300
|
|
13039
|
+
cctally budget unset
|
|
13040
|
+
cctally budget --json
|
|
13041
|
+
cctally budget --format md
|
|
13042
|
+
"""
|
|
13043
|
+
),
|
|
13044
|
+
)
|
|
13045
|
+
bg.add_argument("action", nargs="?", choices=["set", "unset"], default=None,
|
|
13046
|
+
help="`set <amount>` to set the weekly budget, `unset` to clear it.")
|
|
13047
|
+
bg.add_argument("amount", nargs="?", default=None,
|
|
13048
|
+
help="Target USD for `budget set` (e.g. 300).")
|
|
13049
|
+
bg.add_argument("--config", default=None,
|
|
13050
|
+
help="Read status from this config file (read-only; "
|
|
13051
|
+
"rejected on set/unset).")
|
|
13052
|
+
bg.add_argument("--reveal-projects", action="store_true", dest="reveal_projects",
|
|
13053
|
+
help="Accepted for --format surface parity; inert for budget "
|
|
13054
|
+
"(no per-project axis).")
|
|
13055
|
+
bg.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
|
|
13056
|
+
help="Display timezone: local, utc, or IANA name. "
|
|
13057
|
+
"Overrides config display.tz for this call.")
|
|
13058
|
+
_add_share_args(bg)
|
|
13059
|
+
bg.set_defaults(func=cmd_budget)
|
|
13060
|
+
|
|
11983
13061
|
pb = sub.add_parser(
|
|
11984
13062
|
"percent-breakdown",
|
|
11985
13063
|
help="Show per-percent cost milestones for a week",
|
|
@@ -12871,14 +13949,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
12871
13949
|
Examples:
|
|
12872
13950
|
cctally alerts test
|
|
12873
13951
|
cctally alerts test --axis five-hour --threshold 95
|
|
13952
|
+
cctally alerts test --axis budget --threshold 100
|
|
12874
13953
|
"""),
|
|
12875
13954
|
)
|
|
12876
13955
|
p_alerts_test.add_argument(
|
|
12877
13956
|
"--axis",
|
|
12878
|
-
choices=["weekly", "five-hour"],
|
|
13957
|
+
choices=["weekly", "five-hour", "budget"],
|
|
12879
13958
|
default="weekly",
|
|
12880
|
-
help="Alert axis to simulate: weekly subscription window
|
|
12881
|
-
"(default: weekly).",
|
|
13959
|
+
help="Alert axis to simulate: weekly subscription window, 5h block, "
|
|
13960
|
+
"or equiv-$ budget (default: weekly).",
|
|
12882
13961
|
)
|
|
12883
13962
|
p_alerts_test.add_argument(
|
|
12884
13963
|
"--threshold",
|
|
@@ -13041,6 +14120,51 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
13041
14120
|
)
|
|
13042
14121
|
doctor_p.set_defaults(func=cmd_doctor)
|
|
13043
14122
|
|
|
14123
|
+
# ---- pricing-check (standalone diagnostic — NOT under claude/codex) ----
|
|
14124
|
+
pc_p = sub.add_parser(
|
|
14125
|
+
"pricing-check",
|
|
14126
|
+
help="Detect stale or missing embedded model pricing",
|
|
14127
|
+
formatter_class=CLIHelpFormatter,
|
|
14128
|
+
description=textwrap.dedent(
|
|
14129
|
+
"""\
|
|
14130
|
+
Check whether cctally's embedded model pricing is stale or
|
|
14131
|
+
missing, across three independently-degrading legs:
|
|
14132
|
+
|
|
14133
|
+
• coverage (offline, all-history) — models in your cached
|
|
14134
|
+
session data that cctally cannot price (Claude $0) or only
|
|
14135
|
+
approximates (Codex gpt-5 fallback).
|
|
14136
|
+
• drift (network, LiteLLM) — embedded price values vs the
|
|
14137
|
+
LiteLLM snapshot (direction-aware; allowlist-suppressed).
|
|
14138
|
+
• existence (network, Anthropic /v1/models) — vendor models the
|
|
14139
|
+
API offers that our table lacks. Maintainer-local (needs
|
|
14140
|
+
OAuth); degrades to skipped/degraded otherwise.
|
|
14141
|
+
|
|
14142
|
+
Exit codes:
|
|
14143
|
+
0 — no actionable findings (fully clean, OR partially/fully
|
|
14144
|
+
network-degraded but nothing actionable; --json still
|
|
14145
|
+
carries "status":"degraded").
|
|
14146
|
+
1 — any actionable finding (a coverage gap, value drift,
|
|
14147
|
+
missing-from-us, or an existence gap) — EVEN IF a network
|
|
14148
|
+
leg degraded. Findings always win over degradation.
|
|
14149
|
+
2 — argument/usage error.
|
|
14150
|
+
|
|
14151
|
+
"status" (ok|degraded) reports check completeness; the exit code
|
|
14152
|
+
reports whether you must act. They are orthogonal.
|
|
14153
|
+
|
|
14154
|
+
See docs/commands/pricing-check.md for the JSON schema.
|
|
14155
|
+
"""
|
|
14156
|
+
),
|
|
14157
|
+
)
|
|
14158
|
+
pc_p.add_argument(
|
|
14159
|
+
"--json", action="store_true",
|
|
14160
|
+
help="Emit machine-readable JSON to stdout (schemaVersion: 1)",
|
|
14161
|
+
)
|
|
14162
|
+
pc_p.add_argument(
|
|
14163
|
+
"--offline", action="store_true",
|
|
14164
|
+
help="Coverage only — skip both network legs (LiteLLM + /v1/models)",
|
|
14165
|
+
)
|
|
14166
|
+
pc_p.set_defaults(func=cmd_pricing_check)
|
|
14167
|
+
|
|
13044
14168
|
# `release` is its own standalone entry-point (bin/cctally-release);
|
|
13045
14169
|
# no `release` subparser is registered on the main `cctally` CLI.
|
|
13046
14170
|
# See docs/RELEASE.md.
|
|
@@ -15095,6 +16219,13 @@ def _post_command_update_hooks(command: str | None, args) -> None:
|
|
|
15095
16219
|
return
|
|
15096
16220
|
if command == "doctor":
|
|
15097
16221
|
return
|
|
16222
|
+
if command == "pricing-check":
|
|
16223
|
+
# Read-only diagnostic (same rationale as doctor): load_config() would
|
|
16224
|
+
# call ensure_dirs() and write a stub config.json on a fresh HOME, and
|
|
16225
|
+
# _spawn_background_update_check would write update-state.json/log. The
|
|
16226
|
+
# no-mutation contract (test_pricing_check_offline_does_not_mutate_
|
|
16227
|
+
# fresh_home) requires skipping the whole hook.
|
|
16228
|
+
return
|
|
15098
16229
|
if command == "repair-symlinks":
|
|
15099
16230
|
return
|
|
15100
16231
|
# Self-heal: reconcile current_version with the running binary's
|