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/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
- p_final_avg = inputs.p_now + r_avg * inputs.remaining_hours
7857
- final_low = final_high = p_final_avg
8075
+ final_low, final_high = project_linear(
8076
+ inputs.p_now, inputs.remaining_hours, r_avg, r_avg
8077
+ )
7858
8078
  else:
7859
- p_final_avg = inputs.p_now + r_avg * inputs.remaining_hours
7860
- p_final_recent = inputs.p_now + r_recent * inputs.remaining_hours
7861
- final_low = min(p_final_avg, p_final_recent)
7862
- final_high = max(p_final_avg, p_final_recent)
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 or 5h block "
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