cctally 1.22.2 → 1.22.4

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.
@@ -0,0 +1,393 @@
1
+ """`cctally pricing-check` subcommand entry point.
2
+
3
+ I/O sibling: holds the network/existence fetchers + `cmd_pricing_check`
4
+ + the text renderer + `_pricing_observed_models` (the offline cache-scan
5
+ coverage leg, #125 Batch E C7), plus the two `_ENV_PRICING_*`
6
+ test-injection env-var name constants (module-private here). The pure
7
+ decision kernel lives in `_lib_pricing_check` (imported qualified,
8
+ module-top).
9
+
10
+ Honest *name* imports are KERNEL-ONLY (`_cctally_core`). The qualified
11
+ `_lib_pricing_check` import is the eagerly-preloaded library kernel
12
+ (bin/cctally:287). Every other sibling-homed symbol the command calls is
13
+ reached via the call-time `_cctally()` accessor so test monkeypatches
14
+ through `cctally`'s namespace are preserved — see spec §3.2.
15
+
16
+ bin/cctally re-exports `cmd_pricing_check` (eager) so the parser's
17
+ `set_defaults(func=c.cmd_pricing_check)` resolves unchanged.
18
+
19
+ Spec: docs/superpowers/specs/2026-05-30-extract-diagnostics-cmd-design.md
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import dataclasses
25
+ import datetime as dt
26
+ import json
27
+ import os
28
+ import pathlib
29
+ import sqlite3
30
+ import sys
31
+ import urllib.request
32
+
33
+ import _cctally_core
34
+ import _lib_pricing_check
35
+ from _cctally_core import _command_as_of, eprint
36
+
37
+
38
+ def _cctally():
39
+ """Resolve the current `cctally` module at call-time (spec §3.2)."""
40
+ return sys.modules["cctally"]
41
+
42
+
43
+ # ── pricing-check network legs (spec §5.2) ──────────────────────────────
44
+ #
45
+ # Hidden dev hooks (like `project`'s CCTALLY_AS_OF): read at call time, NOT
46
+ # import time, so a test/harness can set them in the child process env. They
47
+ # are deliberately absent from `--help` — they only exist to make the
48
+ # network legs deterministic in tests (invariant #4: no test hits the
49
+ # network). When set to a path, the corresponding fetcher reads that local
50
+ # JSON instead of issuing an HTTP request.
51
+ _ENV_PRICING_LITELLM_FILE = "CCTALLY_PRICING_LITELLM_FILE"
52
+ _ENV_PRICING_MODELS_FILE = "CCTALLY_PRICING_MODELS_FILE"
53
+
54
+
55
+ def _fetch_litellm_prices() -> "tuple[dict, bool]":
56
+ """Fetch the LiteLLM model_prices map. Returns ``(data, ok)``.
57
+
58
+ NEVER raises to the caller: on any failure (bad inject file, network
59
+ error, non-JSON, non-dict body) returns ``({}, False)`` so the drift
60
+ leg degrades gracefully (spec invariant #1). Honors the hidden
61
+ ``CCTALLY_PRICING_LITELLM_FILE`` env hook for deterministic tests.
62
+ """
63
+ c = _cctally()
64
+ inject = os.environ.get(_ENV_PRICING_LITELLM_FILE, "").strip()
65
+ if inject:
66
+ try:
67
+ data = json.loads(pathlib.Path(inject).read_text())
68
+ return (data, True) if isinstance(data, dict) else ({}, False)
69
+ except Exception:
70
+ return {}, False
71
+ try:
72
+ # UA can be plain here — LiteLLM is a raw GitHub JSON blob, not the
73
+ # Anthropic rate-limited surface that requires `claude-code/*`.
74
+ req = urllib.request.Request(
75
+ c.LITELLM_PRICES_URL, headers={"User-Agent": "cctally"},
76
+ )
77
+ with urllib.request.urlopen(req, timeout=15) as resp:
78
+ data = json.loads(resp.read().decode("utf-8"))
79
+ return (data, True) if isinstance(data, dict) else ({}, False)
80
+ except Exception as exc:
81
+ eprint(f"[pricing-check] LiteLLM fetch failed: {exc}")
82
+ return {}, False
83
+
84
+
85
+ def _fetch_anthropic_models_or_none() -> "dict | None":
86
+ """GET https://api.anthropic.com/v1/models with the Claude OAuth bearer.
87
+
88
+ Returns the parsed JSON object on success, or ``None`` on ANY failure
89
+ (no token, 401/403, network error, non-JSON, non-dict body) so the
90
+ existence leg degrades to ``status: degraded``. NEVER raises to the
91
+ caller — wrapped in a broad try/except.
92
+
93
+ C0 DE-RISK SPIKE STATUS — UNVERIFIED LIVE REACHABILITY: this code path
94
+ was authored WITHOUT a live `/v1/models` call (the sandbox has no real
95
+ OAuth token). It is UNKNOWN whether the Claude Code OAuth bearer
96
+ authorizes `GET /v1/models`; if the endpoint 401/403s, this function
97
+ returns None and the existence leg reports `status: degraded` (the
98
+ feature still stands on LiteLLM drift + the local coverage guard). The
99
+ maintainer must run `cctally pricing-check` on a machine with a real
100
+ OAuth token to confirm the leg actually reaches `status: ok`. The whole
101
+ leg is fully exercisable offline via `CCTALLY_PRICING_MODELS_FILE`.
102
+ """
103
+ c = _cctally()
104
+ try:
105
+ token = c._resolve_oauth_token()
106
+ if not token:
107
+ return None
108
+ # Mirror the OAuth-usage UA discipline: Anthropic rate-limits
109
+ # per-UA, so use `claude-code/<version>` (NOT Python-urllib).
110
+ # RAW config read (NOT load_config / _load_config_unlocked — both
111
+ # call ensure_dirs() and would mutate a fresh HOME, violating the
112
+ # read-only contract). Honors an `oauth_usage.user_agent` override
113
+ # when config.json exists; otherwise the default `claude-code/<v>`.
114
+ raw_cfg = {}
115
+ try:
116
+ if _cctally_core.CONFIG_PATH.exists():
117
+ parsed = json.loads(
118
+ _cctally_core.CONFIG_PATH.read_text(encoding="utf-8"))
119
+ if isinstance(parsed, dict):
120
+ raw_cfg = parsed
121
+ except (json.JSONDecodeError, OSError):
122
+ raw_cfg = {}
123
+ cfg = c._get_oauth_usage_config(raw_cfg)
124
+ user_agent = c._resolve_oauth_usage_user_agent(cfg)
125
+ req = urllib.request.Request(
126
+ "https://api.anthropic.com/v1/models",
127
+ headers={
128
+ "Authorization": f"Bearer {token}",
129
+ "anthropic-beta": "oauth-2025-04-20",
130
+ "anthropic-version": "2023-06-01",
131
+ "User-Agent": user_agent,
132
+ },
133
+ )
134
+ with urllib.request.urlopen(req, timeout=15) as resp:
135
+ data = json.loads(resp.read().decode("utf-8"))
136
+ return data if isinstance(data, dict) else None
137
+ except Exception as exc:
138
+ eprint(f"[pricing-check] /v1/models fetch failed: {exc}")
139
+ return None
140
+
141
+
142
+ def _pricing_existence_check() -> dict:
143
+ """Anthropic-only vendor `/v1/models` coverage gap.
144
+
145
+ Returns ``{"status": "ok"|"degraded"|"skipped", "unpriced_vendor_models":
146
+ [...]}``. ``ok`` = the vendor list was obtained; the gap is the IDs the
147
+ vendor offers that ``_resolve_model_pricing`` cannot price. ``degraded``
148
+ = the fetch failed (no token / 401 / network / non-JSON). Honors the
149
+ hidden ``CCTALLY_PRICING_MODELS_FILE`` env hook.
150
+
151
+ Codex existence is intentionally out of scope (no OpenAI credentials —
152
+ spec §4 non-goals); the payload's existence block is Anthropic-only.
153
+ """
154
+ c = _cctally()
155
+ inject = os.environ.get(_ENV_PRICING_MODELS_FILE, "").strip()
156
+ if inject:
157
+ try:
158
+ raw = json.loads(pathlib.Path(inject).read_text())
159
+ except Exception:
160
+ return {"status": "degraded", "unpriced_vendor_models": []}
161
+ else:
162
+ raw = _fetch_anthropic_models_or_none()
163
+ if raw is None:
164
+ return {"status": "degraded", "unpriced_vendor_models": []}
165
+ if not isinstance(raw, dict):
166
+ return {"status": "degraded", "unpriced_vendor_models": []}
167
+ ids = [m.get("id") for m in raw.get("data", []) if isinstance(m, dict) and m.get("id")]
168
+ # Detection-only: warn=False so a vendor model we don't price doesn't
169
+ # fire the cost-engine's one-shot stderr warning.
170
+ gap = sorted(i for i in ids if c._resolve_model_pricing(i, warn=False) is None)
171
+ return {"status": "ok", "unpriced_vendor_models": gap}
172
+
173
+
174
+ # Private sentinel so `_pricing_observed_models` can tell "default 30-day
175
+ # window" apart from an explicit `since=None` all-history scan.
176
+ _PRICING_SCAN_DEFAULT_WINDOW = object()
177
+
178
+
179
+ def _pricing_observed_models(now_utc, *, since=_PRICING_SCAN_DEFAULT_WINDOW):
180
+ """Read-only scan of the session-entry cache for observed models.
181
+
182
+ Returns a list of ``(provider, model, entry_count, token_total)`` tuples,
183
+ one per DISTINCT model seen in ``cache.db`` (Claude ``session_entries`` +
184
+ Codex ``codex_session_entries``). By default it scans the trailing 30-day
185
+ window relative to ``now_utc`` (the `doctor` coverage signal — recent =
186
+ actionable). Pass ``since=<datetime>`` to widen/narrow the window, or
187
+ ``since=None`` explicitly for an all-history scan (used by `pricing-check`).
188
+
189
+ Read-only / no-mutation contract (spec §5.1): mirrors the freshness read
190
+ in this same function — guard on ``CACHE_DB_PATH.exists()``, raw
191
+ ``sqlite3.connect`` (NEVER ``open_cache_db()`` / ``sync_cache()`` /
192
+ ``load_config()`` / ``ensure_dirs()``), and treat a missing table/column as
193
+ "no observed models" rather than crashing. ``doctor --json`` on a virgin
194
+ HOME must not create ``APP_DIR`` — regression
195
+ ``test_pricing_observed_models_no_mutation_on_fresh_home``.
196
+ """
197
+ out: list = []
198
+ if not _cctally_core.CACHE_DB_PATH.exists():
199
+ return out
200
+ # Sentinel: the 30-day window is the default; `since=False` is not a
201
+ # supported value, so distinguish "caller wants all-history" (None) from
202
+ # "caller did not pass since" via a private marker.
203
+ if since is _PRICING_SCAN_DEFAULT_WINDOW:
204
+ cutoff_iso = (now_utc - dt.timedelta(days=30)).isoformat()
205
+ elif since is None:
206
+ cutoff_iso = None # all-history
207
+ else:
208
+ cutoff_iso = since.isoformat()
209
+ try:
210
+ conn = sqlite3.connect(str(_cctally_core.CACHE_DB_PATH))
211
+ except sqlite3.Error:
212
+ return out
213
+ try:
214
+ # Token-sum expressions use the ACTUAL cache column names from
215
+ # bin/_cctally_db.py::_apply_cache_schema (verified — Claude uses
216
+ # cache_create_tokens, NOT cache_creation_tokens; Codex carries a
217
+ # materialized total_tokens covering input/cache/output/reasoning).
218
+ for provider, table, tok_expr in (
219
+ ("claude", "session_entries",
220
+ "COALESCE(input_tokens,0)+COALESCE(output_tokens,0)+"
221
+ "COALESCE(cache_create_tokens,0)+COALESCE(cache_read_tokens,0)"),
222
+ ("codex", "codex_session_entries",
223
+ "COALESCE(total_tokens,0)"),
224
+ ):
225
+ where = "model IS NOT NULL"
226
+ params: tuple = ()
227
+ if cutoff_iso is not None:
228
+ where = "timestamp_utc >= ? AND " + where
229
+ params = (cutoff_iso,)
230
+ try:
231
+ rows = conn.execute(
232
+ f"SELECT model, COUNT(*), SUM({tok_expr}) FROM {table} "
233
+ f"WHERE {where} GROUP BY model",
234
+ params,
235
+ ).fetchall()
236
+ except sqlite3.OperationalError:
237
+ rows = [] # table/column missing — treat as none
238
+ for model, cnt, toks in rows:
239
+ out.append((provider, model, int(cnt or 0), int(toks or 0)))
240
+ finally:
241
+ conn.close()
242
+ return out
243
+
244
+
245
+ def cmd_pricing_check(args: argparse.Namespace) -> int:
246
+ """`cctally pricing-check` — detect stale/missing embedded pricing.
247
+
248
+ Three independently-degrading legs (spec §5.2):
249
+ 1. coverage (offline, ALL-HISTORY) — models in cache.db we can't price.
250
+ 2. drift (network, LiteLLM) — embedded value vs LiteLLM (direction-aware
251
+ + allowlist-suppressed).
252
+ 3. existence (network, Anthropic `/v1/models`) — vendor models absent
253
+ from our table.
254
+
255
+ Exit-code precedence (spec invariant #1, §5.2):
256
+ 1 — ANY actionable finding (coverage gap OR value_drift OR
257
+ missing_from_us OR an existence gap), EVEN IF a network leg
258
+ degraded. Findings always win over degradation.
259
+ 0 — NO actionable findings (fully clean OR partially/fully degraded
260
+ but nothing actionable). JSON still carries status=degraded.
261
+ 2 — argument/usage error (argparse handles before we run).
262
+
263
+ ``status`` (ok|degraded) reports check COMPLETENESS; the exit code
264
+ reports whether the operator must ACT. They are orthogonal: a degraded
265
+ leg never masks a finding and never fabricates one.
266
+ """
267
+ c = _cctally()
268
+ now_utc = _command_as_of()
269
+ status = "ok"
270
+ degraded: list[str] = []
271
+
272
+ # 1. Coverage — offline, all-history (since=None). Read-only scan; any
273
+ # failure degrades to [] (the scan itself swallows DB errors).
274
+ try:
275
+ observed = _pricing_observed_models(now_utc, since=None)
276
+ coverage = _lib_pricing_check.classify_coverage(
277
+ observed,
278
+ lambda m: c._resolve_model_pricing(m, warn=False),
279
+ c._is_codex_fallback,
280
+ )
281
+ except Exception:
282
+ coverage = []
283
+
284
+ drift = {"value_drift": [], "missing_from_us": [], "ahead_of_litellm": []}
285
+ existence = {"status": "skipped", "unpriced_vendor_models": []}
286
+
287
+ if not args.offline:
288
+ litellm, ok = _fetch_litellm_prices()
289
+ if ok:
290
+ scoped = _lib_pricing_check.scope_litellm(litellm)
291
+ res = _lib_pricing_check.diff_pricing(
292
+ c.CLAUDE_MODEL_PRICING, c.CODEX_MODEL_PRICING,
293
+ scoped, c.PRICING_DRIFT_ALLOWLIST,
294
+ )
295
+ drift = {
296
+ "value_drift": [dataclasses.asdict(r) for r in res.value_drift],
297
+ "missing_from_us": list(res.missing_from_us),
298
+ "ahead_of_litellm": list(res.ahead_of_litellm),
299
+ }
300
+ else:
301
+ status = "degraded"
302
+ degraded.append("litellm")
303
+ existence = _pricing_existence_check()
304
+ if existence["status"] == "degraded":
305
+ status = "degraded"
306
+ degraded.append("models_api")
307
+
308
+ # Actionable = any finding on a leg that ran. `ahead_of_litellm` is
309
+ # NEVER actionable (invariant #2). A degraded leg contributes no finding.
310
+ actionable = (
311
+ bool(coverage)
312
+ or bool(drift["value_drift"])
313
+ or bool(drift["missing_from_us"])
314
+ or bool(existence["unpriced_vendor_models"])
315
+ )
316
+
317
+ payload = {
318
+ "schemaVersion": 1,
319
+ "status": status,
320
+ "degraded_components": degraded,
321
+ "snapshotDate": c.PRICING_SNAPSHOT_DATE,
322
+ "coverage": [dataclasses.asdict(g) for g in coverage],
323
+ "drift": drift,
324
+ "existence": existence,
325
+ "litellmSource": c.LITELLM_PRICES_URL,
326
+ }
327
+
328
+ if getattr(args, "json", False):
329
+ print(json.dumps(payload, indent=2, sort_keys=True))
330
+ else:
331
+ _render_pricing_check_text(payload, offline=args.offline, actionable=actionable)
332
+
333
+ return 1 if actionable else 0
334
+
335
+
336
+ def _render_pricing_check_text(payload: dict, *, offline: bool, actionable: bool) -> None:
337
+ """Human-readable render of the pricing-check payload. JSON is the
338
+ machine contract; this is a readable summary for interactive use."""
339
+ out = sys.stdout.write
340
+ status = payload["status"]
341
+ out(f"pricing-check (snapshot {payload['snapshotDate']})\n")
342
+ if status == "degraded":
343
+ out(f" status: degraded — incomplete check "
344
+ f"({', '.join(payload['degraded_components'])} unavailable)\n")
345
+ else:
346
+ out(" status: ok\n")
347
+
348
+ cov = payload["coverage"]
349
+ if cov:
350
+ out(f"\n Coverage gaps ({len(cov)} model(s) we cannot price exactly):\n")
351
+ for g in cov:
352
+ kind = ("unpriced ($0)" if g["kind"] == "unpriced"
353
+ else "approximated via gpt-5")
354
+ entries = g["entry_count"]
355
+ noun = "entry" if entries == 1 else "entries"
356
+ out(f" • {g['model']} ({g['provider']}): {entries} "
357
+ f"{noun} / {g['token_total']} tokens — {kind}\n")
358
+ else:
359
+ out("\n Coverage: all observed models priced.\n")
360
+
361
+ if offline:
362
+ out("\n (offline — network drift + existence legs skipped)\n")
363
+ else:
364
+ vd = payload["drift"]["value_drift"]
365
+ mu = payload["drift"]["missing_from_us"]
366
+ if vd:
367
+ out(f"\n Value drift vs LiteLLM ({len(vd)} field(s)):\n")
368
+ for d in vd:
369
+ out(f" • {d['model']}.{d['field']}: ours={d['ours']} "
370
+ f"litellm={d['theirs']}\n")
371
+ if mu:
372
+ out(f"\n Models LiteLLM prices but we don't ({len(mu)}):\n")
373
+ for m in mu:
374
+ out(f" • {m}\n")
375
+ if not vd and not mu and "litellm" not in payload["degraded_components"]:
376
+ out("\n Drift: embedded pricing matches LiteLLM.\n")
377
+ ex = payload["existence"]
378
+ if ex["status"] == "ok":
379
+ gap = ex["unpriced_vendor_models"]
380
+ if gap:
381
+ out(f"\n Vendor models not in our table ({len(gap)}):\n")
382
+ for m in gap:
383
+ out(f" • {m}\n")
384
+ else:
385
+ out("\n Existence: all vendor models priced.\n")
386
+ elif ex["status"] == "degraded":
387
+ out("\n Existence: /v1/models unavailable (skipped).\n")
388
+
389
+ # Single-sourced from cmd_pricing_check's exit-code predicate (don't
390
+ # recompute the four-clause boolean here — it would drift).
391
+ if actionable:
392
+ out("\n Action: review CLAUDE_MODEL_PRICING / CODEX_MODEL_PRICING; "
393
+ "bump PRICING_SNAPSHOT_DATE on sync.\n")
@@ -109,8 +109,10 @@ What stays in bin/cctally:
109
109
  ``_build_alert_payload_five_hour``, ``eprint``,
110
110
  ``get_claude_session_entries``, ``_FIVE_HOUR_JITTER_FLOOR_SECONDS``,
111
111
  ``_RESET_PCT_DROP_THRESHOLD`` — boundary helpers, already-extracted
112
- subsystems, or constants that belong in bin/cctally. All accessed
113
- via the same shim/``c.X`` pattern.
112
+ subsystems, or constants reached through the cctally namespace
113
+ (``_RESET_PCT_DROP_THRESHOLD`` now lives in ``bin/_cctally_weekrefs.py``,
114
+ re-exported on the cctally ns). All accessed via the same shim/``c.X``
115
+ pattern.
114
116
 
115
117
  §5.6 audit on this extraction's monkeypatch surface:
116
118
  - ``cmd_record_usage`` — patched via ``monkeypatch.setitem(ns, …)``
@@ -381,7 +383,8 @@ _logged_window_key_coerce_failure = False
381
383
  # _cctally_core.HOOK_TICK_LOG_DIR / _PATH / _ROTATED_PATH / _ROTATE_BYTES
382
384
  # _cctally_core.HOOK_TICK_THROTTLE_PATH / _LOCK_PATH
383
385
  # c._FIVE_HOUR_JITTER_FLOOR_SECONDS — _lib_five_hour.* re-export
384
- # c._RESET_PCT_DROP_THRESHOLD — bin/cctally module-level constant
386
+ # c._RESET_PCT_DROP_THRESHOLD — bin/_cctally_weekrefs.py constant (re-exported on cctally ns)
387
+ # c._is_reset_drop — bin/_cctally_weekrefs.py helper (re-exported on cctally ns)
385
388
  # c.HOOK_TICK_DEFAULT_THROTTLE_SECONDS
386
389
 
387
390
 
@@ -1637,7 +1640,7 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
1637
1640
  if (
1638
1641
  prior_end_dt > now_utc
1639
1642
  and prior_pct is not None
1640
- and (float(prior_pct) - float(weekly_percent)) >= c._RESET_PCT_DROP_THRESHOLD
1643
+ and c._is_reset_drop(prior_pct, weekly_percent)
1641
1644
  ):
1642
1645
  # See _backfill_week_reset_events for why we floor
1643
1646
  # the reset moment to the hour (natural display
@@ -1665,7 +1668,7 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
1665
1668
  if (
1666
1669
  prior_end_dt > now_utc
1667
1670
  and prior_pct is not None
1668
- and (float(prior_pct) - float(weekly_percent)) >= c._RESET_PCT_DROP_THRESHOLD
1671
+ and c._is_reset_drop(prior_pct, weekly_percent)
1669
1672
  ):
1670
1673
  # Pre-check (Q5 belt-and-suspenders): suppress duplicate
1671
1674
  # event rows for the same new_week_end_at across