cctally 1.82.0 → 1.83.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +52 -74
  3. package/bin/_cctally_alerts.py +8 -1
  4. package/bin/_cctally_cache.py +963 -149
  5. package/bin/_cctally_config.py +43 -4
  6. package/bin/_cctally_core.py +933 -759
  7. package/bin/_cctally_dashboard.py +157 -47
  8. package/bin/_cctally_dashboard_cache_report.py +13 -6
  9. package/bin/_cctally_dashboard_conversation.py +1 -0
  10. package/bin/_cctally_dashboard_envelope.py +186 -8
  11. package/bin/_cctally_dashboard_share.py +60 -20
  12. package/bin/_cctally_dashboard_sources.py +427 -128
  13. package/bin/_cctally_db.py +605 -128
  14. package/bin/_cctally_doctor.py +413 -28
  15. package/bin/_cctally_five_hour.py +12 -5
  16. package/bin/_cctally_journal.py +2050 -156
  17. package/bin/_cctally_journal_repair.py +519 -0
  18. package/bin/_cctally_milestone_history.py +142 -56
  19. package/bin/_cctally_milestones.py +179 -111
  20. package/bin/_cctally_parser.py +42 -0
  21. package/bin/_cctally_project.py +24 -18
  22. package/bin/_cctally_quota.py +139 -25
  23. package/bin/_cctally_record.py +279 -108
  24. package/bin/_cctally_rederive.py +1052 -0
  25. package/bin/_cctally_reporting.py +58 -53
  26. package/bin/_cctally_setup.py +1 -0
  27. package/bin/_cctally_source_analytics.py +4 -1
  28. package/bin/_cctally_statusline.py +11 -11
  29. package/bin/_cctally_store.py +1039 -31
  30. package/bin/_cctally_sync_week.py +17 -8
  31. package/bin/_cctally_tui.py +421 -54
  32. package/bin/_cctally_update.py +133 -8
  33. package/bin/_cctally_weekrefs.py +14 -0
  34. package/bin/_lib_aggregators.py +10 -6
  35. package/bin/_lib_cache_report.py +101 -9
  36. package/bin/_lib_codex_pools.py +82 -0
  37. package/bin/_lib_conversation_query.py +126 -33
  38. package/bin/_lib_dashboard_sources.py +126 -1
  39. package/bin/_lib_diff_kernel.py +28 -15
  40. package/bin/_lib_doctor.py +342 -4
  41. package/bin/_lib_journal.py +924 -2
  42. package/bin/_lib_jsonl.py +43 -14
  43. package/bin/_lib_pricing.py +140 -21
  44. package/bin/_lib_readme_refresh.py +401 -0
  45. package/bin/_lib_rederive.py +395 -0
  46. package/bin/_lib_share.py +58 -2
  47. package/bin/cctally +56 -8
  48. package/dashboard/static/assets/{index-DJP4gEB7.js → index-3bgCMVHb.js} +52 -52
  49. package/dashboard/static/assets/index-D27EIHEI.css +1 -0
  50. package/dashboard/static/dashboard.html +2 -2
  51. package/package.json +6 -1
  52. package/dashboard/static/assets/index-Dk1nplOz.css +0 -1
@@ -1468,6 +1468,94 @@ def _resolved_install_target(state: dict, channel: str) -> "str | None":
1468
1468
  return None
1469
1469
 
1470
1470
 
1471
+ def _beta_refresh_fallback_message(state: dict) -> str:
1472
+ """Actionable diagnostic for an install-time beta refresh failure."""
1473
+ cached = state.get("latest_version")
1474
+ status = state.get("check_status") or "fetch_failed"
1475
+ detail = state.get("check_error")
1476
+ reason = f"{status}: {detail}" if detail else str(status)
1477
+ if cached:
1478
+ return (
1479
+ f"could not refresh beta update target ({reason}); using cached "
1480
+ f"{cached}. Check npm registry access or rerun with "
1481
+ "`cctally update --version X.Y.Z`."
1482
+ )
1483
+ return (
1484
+ f"could not refresh beta update target ({reason}); no cached target "
1485
+ "is available. Check npm registry access or rerun with "
1486
+ "`cctally update --version X.Y.Z`."
1487
+ )
1488
+
1489
+
1490
+ def _resolve_install_operation_target(
1491
+ method: InstallMethod,
1492
+ state: dict,
1493
+ channel: str,
1494
+ *,
1495
+ explicit_version: "str | None",
1496
+ persist_refresh: bool,
1497
+ ) -> "tuple[str | None, dict, str | None]":
1498
+ """Resolve one user-initiated install's truthful target.
1499
+
1500
+ Explicit pins and every non-beta-npm path are byte-preserving pass-throughs.
1501
+ An unpinned npm beta operation resolves the live SemVer-max(beta, latest)
1502
+ before no-op/downgrade decisions. Real installs reuse the canonical check
1503
+ pipeline (including marker-first and last-known-good persistence); dry-runs
1504
+ perform the same registry resolution against an in-memory state copy so
1505
+ their no-mutation contract remains exact.
1506
+
1507
+ Returns ``(target, effective_state, warning)``. A failed refresh falls back
1508
+ to the cached target when one exists and supplies an actionable warning.
1509
+ """
1510
+ c = _cctally()
1511
+ if explicit_version is not None:
1512
+ return (explicit_version, state, None)
1513
+ if method.method != "npm" or channel != "beta":
1514
+ return (c._resolved_install_target(state, channel), state, None)
1515
+
1516
+ effective = dict(state)
1517
+ prior_cached_target = state.get("latest_version")
1518
+ warning = None
1519
+ if persist_refresh:
1520
+ c._do_update_check()
1521
+ effective = c._load_update_state() or effective
1522
+ if effective.get("check_status") != "ok":
1523
+ warning = _beta_refresh_fallback_message(effective)
1524
+ else:
1525
+ try:
1526
+ latest, dist_tag = c._resolve_npm_channel_target("beta")
1527
+ effective["latest_version"] = latest
1528
+ effective["latest_version_channel"] = "beta"
1529
+ effective["resolved_dist_tag"] = dist_tag
1530
+ except UpdateCheckRateLimited as e:
1531
+ effective["check_status"] = "rate_limited"
1532
+ effective["check_error"] = str(e)[:200]
1533
+ except (UpdateCheckNetworkError, UpdateCheckHTTPError) as e:
1534
+ effective["check_status"] = "fetch_failed"
1535
+ effective["check_error"] = str(e)[:200]
1536
+ except UpdateCheckParseError as e:
1537
+ effective["check_status"] = "parse_failed"
1538
+ effective["check_error"] = str(e)[:200]
1539
+ else:
1540
+ effective["check_status"] = "ok"
1541
+ effective["check_error"] = None
1542
+ if effective.get("check_status") != "ok":
1543
+ warning = _beta_refresh_fallback_message(effective)
1544
+
1545
+ # `_do_update_check` defaults a never-seen `latest_version` to the running
1546
+ # version so banner formatting remains comparable. That synthetic value is
1547
+ # not a last-known-good registry target and must not turn a failed first
1548
+ # install-time refresh into a misleading successful no-op.
1549
+ target = prior_cached_target if warning else effective.get("latest_version")
1550
+ if target is None:
1551
+ missing_cache_state = dict(effective)
1552
+ missing_cache_state.pop("latest_version", None)
1553
+ raise UpdateError(
1554
+ _beta_refresh_fallback_message(missing_cache_state)
1555
+ )
1556
+ return (target, effective, warning)
1557
+
1558
+
1471
1559
  def _resolved_update_command(state: dict, config: "dict | None") -> str:
1472
1560
  """Channel-aware install command for the `--check` renderers / envelope.
1473
1561
 
@@ -1936,11 +2024,20 @@ def _do_update_install(
1936
2024
  channel = c.resolve_update_channel(config)
1937
2025
  state = c._load_update_state() or {}
1938
2026
 
1939
- # Beta resolves the exact target from the cached max(beta, latest); an
1940
- # explicit --version pin always wins (the deliberate override).
1941
- resolved_version = version
1942
- if resolved_version is None:
1943
- resolved_version = c._resolved_install_target(state, channel)
2027
+ # A user-initiated bare beta operation refreshes before exact-target
2028
+ # selection and before the no-op/downgrade guard. Dry-run resolves the same
2029
+ # live target without writing state or the throttle marker.
2030
+ resolved_version, state, refresh_warning = (
2031
+ _resolve_install_operation_target(
2032
+ method,
2033
+ state,
2034
+ channel,
2035
+ explicit_version=version,
2036
+ persist_refresh=not dry_run,
2037
+ )
2038
+ )
2039
+ if refresh_warning:
2040
+ print(f"Warning: {refresh_warning}", file=sys.stderr)
1944
2041
 
1945
2042
  # Downgrade refusal for a BARE (unpinned) install: no-op + exit 0 when
1946
2043
  # the resolved target is not SemVer-newer than the installed version.
@@ -2152,12 +2249,40 @@ class UpdateWorker:
2152
2249
  log_fd = None
2153
2250
  try:
2154
2251
  method = c._detect_install_method(mutate=True)
2155
- c._preflight_install(method, version)
2252
+ config = c.load_config()
2253
+ channel = c.resolve_update_channel(config)
2254
+ state = c._load_update_state() or {}
2255
+ resolved_version, _state, refresh_warning = (
2256
+ _resolve_install_operation_target(
2257
+ method,
2258
+ state,
2259
+ channel,
2260
+ explicit_version=version,
2261
+ persist_refresh=True,
2262
+ )
2263
+ )
2264
+ if refresh_warning:
2265
+ self._emit(
2266
+ run_id,
2267
+ {"type": "stderr", "data": f"Warning: {refresh_warning}"},
2268
+ )
2269
+ if resolved_version is not None:
2270
+ self._emit(
2271
+ run_id,
2272
+ {
2273
+ "type": "target",
2274
+ "version": resolved_version,
2275
+ "command": c._format_update_command(
2276
+ method.method, resolved_version
2277
+ ),
2278
+ },
2279
+ )
2280
+ c._preflight_install(method, resolved_version)
2156
2281
  _cctally_core.UPDATE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
2157
2282
  lock_fd = c._acquire_update_lock()
2158
2283
  log_fd = open(_cctally_core.UPDATE_LOG_PATH, "a", encoding="utf-8")
2159
2284
  _log_update_event(log_fd, "INSTALL_START", method=method.method)
2160
- for step_name, cmd in c._build_update_steps(method, version):
2285
+ for step_name, cmd in c._build_update_steps(method, resolved_version):
2161
2286
  self._emit(run_id, {"type": "step", "name": step_name})
2162
2287
  _log_update_event(log_fd, "STEP_START", name=step_name)
2163
2288
  rc = c._run_streaming(
@@ -2176,7 +2301,7 @@ class UpdateWorker:
2176
2301
  self._emit(run_id, {"type": "done", "success": False})
2177
2302
  return
2178
2303
  _log_update_event(log_fd, "INSTALL_SUCCESS")
2179
- c._stamp_install_success_to_state(version, method)
2304
+ c._stamp_install_success_to_state(resolved_version, method)
2180
2305
  entrypoint, exec_argv = c._resolve_execvp_target()
2181
2306
  self._emit(run_id, {"type": "execvp", "argv": exec_argv})
2182
2307
  try:
@@ -576,6 +576,7 @@ def _week_ref_has_reset_event(
576
576
 
577
577
  def _compute_cost_for_weekref(
578
578
  ref: WeekRef, *, skip_sync: bool = False, account_key: "str | None" = None,
579
+ as_of: "str | None" = None,
579
580
  ) -> float | None:
580
581
  """Live-compute USD cost over `ref`'s (possibly reset-adjusted) range
581
582
  straight from session_entries. Mirrors what cmd_sync_week writes into
@@ -588,6 +589,11 @@ def _compute_cost_for_weekref(
588
589
  once at the top of the rebuild); ``build_trend_view`` calls this once per
589
590
  reset-event week, so without the flag each reset week re-globbed the whole
590
591
  ``~/.claude/projects`` tree — the CPU peg the sync-once refactor removes.
592
+
593
+ ``as_of`` (#410 Task A) is the retained triggering observation clock. The
594
+ reset-adjusted range end is clamped to it before the cache query so this
595
+ alternate milestone-cost path has the same replay boundary as
596
+ ``compute_week_cost(as_of=...)``.
591
597
  """
592
598
  c = _cctally()
593
599
  if not ref.week_start_at or not ref.week_end_at:
@@ -599,6 +605,14 @@ def _compute_cost_for_weekref(
599
605
  return None
600
606
  if end <= start:
601
607
  return 0.0
608
+ if as_of is not None:
609
+ try:
610
+ retained_end = parse_iso_datetime(as_of, "asOf")
611
+ except ValueError:
612
+ return None
613
+ end = min(end, retained_end)
614
+ if end <= start:
615
+ return 0.0
602
616
  return c._sum_cost_for_range(
603
617
  start, end, mode="auto", skip_sync=skip_sync, account_key=account_key)
604
618
 
@@ -69,6 +69,8 @@ CodexEntry = _lib_jsonl.CodexEntry
69
69
 
70
70
  _lib_pricing = _load_lib("_lib_pricing")
71
71
  _calculate_entry_cost = _lib_pricing._calculate_entry_cost
72
+ # #195: the single construction point for every cost-feeding usage dict.
73
+ claude_usage_dict = _lib_pricing.claude_usage_dict
72
74
  _calculate_codex_entry_cost = _lib_pricing._calculate_codex_entry_cost
73
75
  _is_codex_fallback = _lib_pricing._is_codex_fallback
74
76
 
@@ -800,12 +802,14 @@ def _aggregate_claude_sessions(
800
802
  if entry.project_path:
801
803
  sess["project_path"] = entry.project_path
802
804
 
803
- usage = {
804
- "input_tokens": entry.input_tokens,
805
- "output_tokens": entry.output_tokens,
806
- "cache_creation_input_tokens": entry.cache_creation_tokens,
807
- "cache_read_input_tokens": entry.cache_read_tokens,
808
- }
805
+ usage = claude_usage_dict( # #195 chokepoint
806
+ input_tokens=entry.input_tokens,
807
+ output_tokens=entry.output_tokens,
808
+ cache_creation_tokens=entry.cache_creation_tokens,
809
+ cache_read_tokens=entry.cache_read_tokens,
810
+ cache_1h_tokens=getattr(entry, "cache_1h_tokens", None),
811
+ speed=getattr(entry, "speed", None),
812
+ )
809
813
  cost = _calculate_entry_cost(entry.model, usage, mode=mode, cost_usd=entry.cost_usd)
810
814
 
811
815
  sess["input"] += entry.input_tokens
@@ -56,6 +56,52 @@ def _import_stable_sum():
56
56
  stable_sum = _import_stable_sum()
57
57
 
58
58
 
59
+ def _import_pricing_kernel():
60
+ """Resolve the ``_lib_pricing`` symbols this kernel needs (#195/#413):
61
+ ``CACHE_WRITE_1H_MULTIPLIER`` (the derived 1-hour cache-write rate) and
62
+ ``claude_usage_dict`` (the single cost-feeding usage-dict constructor).
63
+
64
+ Same shape and same justification as ``_import_stable_sum`` above:
65
+ ``_lib_pricing`` is a PURE stdlib leaf with no sibling imports, so binding
66
+ it here is acyclic and leaves this kernel's purity contract (no I/O, no
67
+ logging, no environment reads, no SQLite) intact. In practice both loaders
68
+ of this file have already imported ``_lib_pricing``, so the ``sys.modules``
69
+ fast path is what runs; the path-load fallback exists for a bare
70
+ file-path load.
71
+ """
72
+ import sys
73
+ if "_lib_pricing" in sys.modules:
74
+ m = sys.modules["_lib_pricing"]
75
+ return (
76
+ m.CACHE_WRITE_1H_MULTIPLIER,
77
+ m._claude_fast_multiplier,
78
+ m.claude_usage_dict,
79
+ )
80
+ from pathlib import Path
81
+ import importlib.util
82
+ bin_dir = Path(__file__).resolve().parent
83
+ if str(bin_dir) not in sys.path:
84
+ sys.path.insert(0, str(bin_dir))
85
+ spec = importlib.util.spec_from_file_location(
86
+ "_lib_pricing", bin_dir / "_lib_pricing.py")
87
+ m = importlib.util.module_from_spec(spec)
88
+ sys.modules["_lib_pricing"] = m
89
+ try:
90
+ spec.loader.exec_module(m)
91
+ except Exception:
92
+ sys.modules.pop("_lib_pricing", None)
93
+ raise
94
+ return (
95
+ m.CACHE_WRITE_1H_MULTIPLIER,
96
+ m._claude_fast_multiplier,
97
+ m.claude_usage_dict,
98
+ )
99
+
100
+
101
+ (CACHE_WRITE_1H_MULTIPLIER, _claude_fast_multiplier,
102
+ claude_usage_dict) = _import_pricing_kernel()
103
+
104
+
59
105
  # Anthropic's per-call >200K-tokens tier — kept in sync with bin/_lib_pricing.
60
106
  # Callers may override via the ``tiered_threshold`` kwarg.
61
107
  DEFAULT_TIERED_THRESHOLD = 200_000
@@ -232,13 +278,18 @@ def _compute_entry_cache_dollars(
232
278
  *,
233
279
  pricing: dict,
234
280
  tiered_threshold: int = DEFAULT_TIERED_THRESHOLD,
281
+ cache_1h_tokens: int | None = None,
282
+ speed=None,
235
283
  ) -> tuple[float, float, float]:
236
284
  """Return ``(saved_usd, wasted_usd, net_usd)`` for a single entry.
237
285
 
238
286
  ``saved_usd`` = ``cache_read_tokens × (base_rate − read_rate)``
239
287
  — what you'd have paid without caching.
240
- ``wasted_usd`` = ``cache_creation_tokens × (create_rate − base_rate)``
241
- — premium paid to write cache.
288
+ ``wasted_usd`` = the cache-WRITE premium over base input, priced by TTL
289
+ (#195): the 1-hour portion against ``2 ×`` base input, the REMAINDER
290
+ against today's create rate. ``cache_1h_tokens is None`` (split
291
+ unknown) and ``== 0`` (all-5m) both take the verbatim pre-#195
292
+ expression, so this stays byte-identical for every pre-split row.
242
293
  ``net_usd`` = ``saved_usd − wasted_usd``. Positive = caching helped.
243
294
 
244
295
  Applies Anthropic's per-call >200K-tokens tier (mirrors the
@@ -289,8 +340,29 @@ def _compute_entry_cache_dollars(
289
340
  "cache_creation_input_token_cost_above_200k_tokens",
290
341
  )
291
342
 
343
+ multiplier = (
344
+ _claude_fast_multiplier(model) if speed == "fast" else 1.0
345
+ )
346
+ if multiplier != 1.0:
347
+ base_for_read *= multiplier
348
+ read_rate *= multiplier
349
+ base_for_create *= multiplier
350
+ create_rate *= multiplier
351
+
292
352
  saved = cache_read_tokens * max(0.0, base_for_read - read_rate)
293
- wasted = cache_creation_tokens * max(0.0, create_rate - base_for_create)
353
+ # #195: the write premium is TTL-dependent. Split the creation tokens the
354
+ # same way _cache_create_cost does — 1h portion against the 2x rate, the
355
+ # REMAINDER against today's create_rate — so Wasted $/Net $ cannot disagree
356
+ # with the total cost the pricing kernel reports. Cache reads remain 0.1x
357
+ # of the effective base rate, so fast mode scales their absolute savings.
358
+ h = 0 if cache_1h_tokens is None else max(
359
+ 0, min(int(cache_1h_tokens), cache_creation_tokens))
360
+ if h == 0:
361
+ wasted = cache_creation_tokens * max(0.0, create_rate - base_for_create)
362
+ else:
363
+ rate_1h = base_for_create * CACHE_WRITE_1H_MULTIPLIER
364
+ wasted = (h * max(0.0, rate_1h - base_for_create)
365
+ + (cache_creation_tokens - h) * max(0.0, create_rate - base_for_create))
294
366
  net = saved - wasted
295
367
  return (saved, wasted, net)
296
368
 
@@ -369,6 +441,10 @@ def _aggregate_cache_by_day(
369
441
  else:
370
442
  saved, wasted, net = _compute_entry_cache_dollars(
371
443
  entry.model, create_tok, read_tok, pricing=pricing,
444
+ # #195: the normalized flat key the ingest chokepoint writes;
445
+ # absent on a pre-split row -> None -> unchanged pricing.
446
+ cache_1h_tokens=entry.usage.get("cache_creation_1h_input_tokens"),
447
+ speed=entry.usage.get("speed"),
372
448
  )
373
449
  models = day_model_buckets.setdefault(day_key, {})
374
450
  b = models.setdefault(entry.model, _Bucket())
@@ -522,12 +598,15 @@ def _aggregate_cache_by_session(
522
598
  mb_raw.cache_read_tokens += entry.cache_read_tokens
523
599
  mb_raw.cost += cost_calculator(
524
600
  entry.model,
525
- {
526
- "input_tokens": entry.input_tokens,
527
- "output_tokens": entry.output_tokens,
528
- "cache_creation_input_tokens": entry.cache_creation_tokens,
529
- "cache_read_input_tokens": entry.cache_read_tokens,
530
- },
601
+ claude_usage_dict( # #195 chokepoint
602
+ input_tokens=entry.input_tokens,
603
+ output_tokens=entry.output_tokens,
604
+ cache_creation_tokens=entry.cache_creation_tokens,
605
+ cache_read_tokens=entry.cache_read_tokens,
606
+ # getattr: duck-typed entry contract (see below).
607
+ cache_1h_tokens=getattr(entry, "cache_1h_tokens", None),
608
+ speed=getattr(entry, "speed", None),
609
+ ),
531
610
  "auto",
532
611
  entry.cost_usd,
533
612
  )
@@ -536,6 +615,13 @@ def _aggregate_cache_by_session(
536
615
  entry.cache_creation_tokens,
537
616
  entry.cache_read_tokens,
538
617
  pricing=pricing,
618
+ # #195: getattr, not attribute access. This is a PURE kernel with
619
+ # a duck-typed entry contract — callers pass `_JoinedClaudeEntry`
620
+ # in production and lightweight stand-ins in tests, so a hard
621
+ # `.cache_1h_tokens` would break every stand-in. Default None ==
622
+ # split unknown, which prices exactly as before.
623
+ cache_1h_tokens=getattr(entry, "cache_1h_tokens", None),
624
+ speed=getattr(entry, "speed", None),
539
625
  )
540
626
  mb_raw.saved_usd += saved
541
627
  mb_raw.wasted_usd += wasted
@@ -813,6 +899,10 @@ def _aggregate_cache_breakdown(
813
899
  getattr(e, "cache_creation_tokens", 0),
814
900
  getattr(e, "cache_read_tokens", 0),
815
901
  pricing=pricing,
902
+ # #195: getattr default None == split unknown, so an entry type
903
+ # that does not carry the split prices exactly as before.
904
+ cache_1h_tokens=getattr(e, "cache_1h_tokens", None),
905
+ speed=getattr(e, "speed", None),
816
906
  )
817
907
  b.saved_usd += saved
818
908
  b.wasted_usd += wasted
@@ -921,6 +1011,8 @@ def aggregate_by_day_project(
921
1011
  read_tok = getattr(e, "cache_read_tokens", 0)
922
1012
  _s, _w, net = _compute_entry_cache_dollars(
923
1013
  model, create_tok, read_tok, pricing=pricing,
1014
+ cache_1h_tokens=getattr(e, "cache_1h_tokens", None), # #195
1015
+ speed=getattr(e, "speed", None),
924
1016
  )
925
1017
  nets.setdefault(k, []).append(net)
926
1018
  t = toks.setdefault(k, [0, 0, 0])
@@ -0,0 +1,82 @@
1
+ """Codex quota-pool classification (#373).
2
+
3
+ A pure leaf module: stdlib only, no cctally imports. It lives apart from
4
+ ``_lib_quota.py`` so ``_lib_jsonl`` need not import the quota kernel (and
5
+ ``_lib_accounts`` through it) merely to classify a pool label, and so module
6
+ load order in ``bin/cctally`` is unaffected.
7
+
8
+ Spec: docs/superpowers/specs/2026-07-25-373-codex-foreign-pool-phantom-week.md
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+
14
+
15
+ def codex_model_scoped_quota_pool(model: object) -> str | None:
16
+ """Return the native model pool when Codex documents it as separate.
17
+
18
+ GPT Codex Spark runs against its own allowance and does not consume the
19
+ standard Codex quota. Accepts either the sticky rollout model or the
20
+ native ``limit_name``; both spell the pool the same way once normalized.
21
+ """
22
+ if not isinstance(model, str):
23
+ return None
24
+ normalized = model.strip().lower()
25
+ return normalized if "-codex-spark" in normalized else None
26
+
27
+
28
+ def is_model_scoped_codex_quota(logical_limit_key: object, limit_name: object) -> bool:
29
+ """Whether a window sits outside account-level standard quota.
30
+
31
+ Two INDEPENDENT axes -- an unparseable key leaves the first unknown but
32
+ must still let the second fire. ``limit_id`` is deliberately not an axis:
33
+ treating an unknown id as non-standard would demote the real account quota
34
+ the moment the provider renamed it (#373 spec §6 Q1).
35
+ """
36
+ if _key_has_model_pool(logical_limit_key):
37
+ return True
38
+ return codex_model_scoped_quota_pool(limit_name) is not None
39
+
40
+
41
+ def _key_has_model_pool(logical_limit_key: object) -> bool:
42
+ if not isinstance(logical_limit_key, str):
43
+ return False
44
+ try:
45
+ payload = json.loads(logical_limit_key)
46
+ except (json.JSONDecodeError, TypeError, ValueError):
47
+ return False
48
+ return (
49
+ isinstance(payload, dict)
50
+ and isinstance(payload.get("modelPool"), str)
51
+ and bool(payload["modelPool"].strip())
52
+ )
53
+
54
+
55
+ def codex_history_is_model_scoped(history, *, baseline=None) -> bool:
56
+ """Classify one QuotaHistory, reading the label from an AUTHORITATIVE
57
+ observation rather than from ``history.identity``.
58
+
59
+ ``build_history`` groups by identity equality and ``limit_id``/``limit_name``
60
+ are ``compare=False``, so ``history.identity`` retains whichever label was
61
+ seen FIRST. The authority is the baseline observation when the caller has
62
+ one, else the latest physical observation.
63
+
64
+ ``history.identity`` is NEVER consulted for the label, not even as a
65
+ last-resort widening when the authority is unlabelled (#373 spec §7.1).
66
+ The retained identity is an artefact of iteration order, so reading it
67
+ would make the same two observations classify differently depending on the
68
+ order they arrived in — nondeterminism, not a safety net. Unlabelled
69
+ evidence therefore fails closed to ``False`` (standard quota), a direction
70
+ that can only keep a window in the account cycle and never demote a real
71
+ one out of it.
72
+ """
73
+ identity = getattr(history, "identity", None)
74
+ if _key_has_model_pool(getattr(identity, "logical_limit_key", None)):
75
+ return True
76
+ authority = baseline
77
+ if authority is None:
78
+ physical = getattr(history, "physical_observations", ()) or ()
79
+ if physical:
80
+ authority = max(physical, key=lambda o: o.captured_at)
81
+ label = getattr(getattr(authority, "identity", None), "limit_name", None)
82
+ return codex_model_scoped_quota_pool(label) is not None