cctally 1.100.0 → 1.102.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 (53) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +8 -2
  3. package/bin/_cctally_alerts.py +13 -2
  4. package/bin/_cctally_cache.py +3 -1
  5. package/bin/_cctally_cache_report.py +103 -6
  6. package/bin/_cctally_dashboard.py +1140 -282
  7. package/bin/_cctally_dashboard_conversation.py +12 -0
  8. package/bin/_cctally_dashboard_envelope.py +53 -61
  9. package/bin/_cctally_dashboard_share.py +101 -29
  10. package/bin/_cctally_dashboard_sources.py +663 -192
  11. package/bin/_cctally_diagnosis.py +1172 -0
  12. package/bin/_cctally_diagnosis_sources.py +4054 -0
  13. package/bin/_cctally_diff.py +20 -0
  14. package/bin/_cctally_forecast.py +329 -111
  15. package/bin/_cctally_milestone_history.py +10 -2
  16. package/bin/_cctally_parser.py +84 -0
  17. package/bin/_cctally_project.py +155 -47
  18. package/bin/_cctally_quota.py +14 -0
  19. package/bin/_cctally_record.py +151 -71
  20. package/bin/_cctally_refresh.py +105 -93
  21. package/bin/_cctally_share.py +9 -2
  22. package/bin/_cctally_source_analytics.py +40 -4
  23. package/bin/_cctally_statusline.py +8 -1
  24. package/bin/_cctally_tui.py +425 -234
  25. package/bin/_lib_alert_scope.py +685 -0
  26. package/bin/_lib_alerts_payload.py +112 -7
  27. package/bin/_lib_blocks.py +12 -0
  28. package/bin/_lib_cache_report.py +110 -1
  29. package/bin/_lib_codex_conversation.py +14 -0
  30. package/bin/_lib_codex_conversation_query.py +22 -8
  31. package/bin/_lib_codex_pools.py +20 -8
  32. package/bin/_lib_conversation.py +6 -3
  33. package/bin/_lib_conversation_query.py +256 -69
  34. package/bin/_lib_dashboard_sources.py +212 -24
  35. package/bin/_lib_diagnosis.py +1261 -0
  36. package/bin/_lib_forecast.py +62 -4
  37. package/bin/_lib_perf.py +12 -0
  38. package/bin/_lib_pricing.py +8 -7
  39. package/bin/_lib_readme_refresh.py +26 -5
  40. package/bin/_lib_render.py +31 -3
  41. package/bin/_lib_share_templates.py +150 -55
  42. package/bin/_lib_snapshot_cache.py +71 -13
  43. package/bin/_lib_source_identity.py +50 -2
  44. package/bin/_lib_subscription_weeks.py +65 -0
  45. package/bin/cctally +103 -16
  46. package/bin/cctally-explain +5 -0
  47. package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +1 -0
  48. package/dashboard/static/assets/index-Di2hljvB.css +1 -0
  49. package/dashboard/static/assets/index-XYCIWjVG.js +97 -0
  50. package/dashboard/static/dashboard.html +2 -2
  51. package/package.json +6 -1
  52. package/dashboard/static/assets/index-B5YfQEtn.css +0 -1
  53. package/dashboard/static/assets/index-Bt59nMMO.js +0 -97
@@ -63,6 +63,100 @@ _lib_accounts = _load_lib("_lib_accounts")
63
63
  _UNATTRIBUTED = _lib_accounts.UNATTRIBUTED
64
64
  _VENDOR_WIDE = _lib_accounts.VENDOR_WIDE
65
65
 
66
+ # Next-step routing (#620 S1, D11): every axis offers the command that explains
67
+ # it, scoped to that alert's own provider and window. The scope is derived from
68
+ # the retained context by `_lib_alert_scope`; nothing here parses an alert id.
69
+ _lib_alert_scope = _load_lib("_lib_alert_scope")
70
+ derive_alert_scope = _lib_alert_scope.derive_alert_scope
71
+ format_next_step = _lib_alert_scope.format_next_step
72
+ alert_next_step_command = _lib_alert_scope.alert_next_step_command
73
+
74
+
75
+ next_step_line = _lib_alert_scope.next_step_line
76
+
77
+
78
+ def alert_next_step_line(
79
+ payload: dict, tz: "ZoneInfo | None", now: "dt.datetime | None" = None,
80
+ ) -> str:
81
+ """The one affordance line an alert body ends with.
82
+
83
+ The command's own arguments stay in UTC, because that is what the
84
+ selectors they feed accept (``--block-start`` is documented naive-is-UTC);
85
+ the trailing scope statement is what renders in the display zone.
86
+
87
+ ``now`` is the instant the liveness test compares against; it defaults to
88
+ the current UTC instant. The clock read lives here rather than in
89
+ ``_lib_alert_scope`` so that kernel stays a pure leaf.
90
+ """
91
+ axis = payload.get("axis")
92
+ ctx = payload.get("context") or {}
93
+ scope = derive_alert_scope(axis, ctx, payload.get("account_key"))
94
+ command = alert_next_step_command(axis, ctx, scope)
95
+ if command is None:
96
+ return format_next_step(
97
+ None,
98
+ unavailable_reason=_lib_alert_scope.alert_target_unavailable_reason(
99
+ axis, ctx, scope
100
+ ),
101
+ )
102
+ # A command that reports only the live window cannot address one that has
103
+ # already closed. Offering it anyway would report the current window under
104
+ # a line naming the closed one, so the navigation is withheld with its
105
+ # cause — the same treatment a purged five-hour block already gets.
106
+ if (
107
+ _lib_alert_scope.command_reports_the_live_period_only(command)
108
+ and scope.window_end is not None
109
+ ):
110
+ if now is None:
111
+ now = dt.datetime.now(dt.timezone.utc)
112
+ if scope.window_end <= now:
113
+ return format_next_step(
114
+ None,
115
+ unavailable_reason=_lib_alert_scope.closed_window_reason(command),
116
+ )
117
+ if scope.available:
118
+ return next_step_line(
119
+ command,
120
+ provider=scope.provider,
121
+ window_start=scope.window_start,
122
+ window_end=scope.window_end,
123
+ tz=tz,
124
+ granularity=scope.window_granularity,
125
+ )
126
+ # A live-window command with no derivable length: name the provider and
127
+ # say what is missing, never a window the alert did not describe.
128
+ detail = (
129
+ f"{scope.provider} · {scope.withheld_reason}"
130
+ if scope.provider else scope.withheld_reason
131
+ )
132
+ return format_next_step(command, detail=detail)
133
+
134
+
135
+ def synthetic_preview_week_start(
136
+ now: "dt.datetime | None" = None,
137
+ ) -> dt.datetime:
138
+ """The week start the two `alerts test --axis weekly` previews use.
139
+
140
+ A real crossing carries `percent_milestones.week_start_at`, the week's
141
+ reset instant, and a preview that omitted it rendered the day-granularity
142
+ fallback — two bare dates with no reset hour and no zone — which is not
143
+ what a real crossing produces. The instant is hour-aligned and one day
144
+ back so its reset hour is a real clock reading rather than the midnight
145
+ that would render identically to the fallback it replaces.
146
+ """
147
+ if now is None:
148
+ now = dt.datetime.now(dt.timezone.utc)
149
+ return (now - dt.timedelta(days=1)).replace(
150
+ minute=0, second=0, microsecond=0
151
+ )
152
+
153
+
154
+ def _with_next_step(body: str, payload: dict, tz: "ZoneInfo | None") -> str:
155
+ """Append the affordance as its own line. AppleScript collapses the
156
+ newline to a space (``_escape_applescript_string``) and ``notify-send``
157
+ keeps it, so both notifiers render the whole sentence."""
158
+ return f"{body}\n{alert_next_step_line(payload, tz)}"
159
+
66
160
 
67
161
  def _alert_text_weekly(payload: dict, tz: "ZoneInfo | None") -> tuple[str, str, str]:
68
162
  """Build (title, subtitle, body) for a weekly threshold alert.
@@ -96,7 +190,7 @@ def _alert_text_weekly(payload: dict, tz: "ZoneInfo | None") -> tuple[str, str,
96
190
  body = f"${cumulative:.2f} spent so far - ${float(dpp):.2f} per 1%"
97
191
  else:
98
192
  body = f"${cumulative:.2f} spent so far"
99
- return title, subtitle, body
193
+ return title, subtitle, _with_next_step(body, payload, tz)
100
194
 
101
195
 
102
196
  def _alert_text_five_hour(payload: dict, tz: "ZoneInfo | None") -> tuple[str, str, str]:
@@ -121,7 +215,7 @@ def _alert_text_five_hour(payload: dict, tz: "ZoneInfo | None") -> tuple[str, st
121
215
  body = f"${cost:.2f} spent in this block - current model: {model}"
122
216
  else:
123
217
  body = f"${cost:.2f} spent in this block"
124
- return title, subtitle, body
218
+ return title, subtitle, _with_next_step(body, payload, tz)
125
219
 
126
220
 
127
221
  def _escape_applescript_string(s: str) -> str:
@@ -147,6 +241,7 @@ def _build_alert_payload_weekly(
147
241
  cumulative_cost_usd: float,
148
242
  dollars_per_percent: "float | None",
149
243
  account_key: str = _UNATTRIBUTED,
244
+ week_start_at: "str | None" = None,
150
245
  ) -> dict:
151
246
  """Build the alert payload for a weekly threshold crossing.
152
247
 
@@ -155,7 +250,16 @@ def _build_alert_payload_weekly(
155
250
  (set-then-dispatch invariant, spec §3.2). Consumers (envelope builders,
156
251
  test inspectors) read the ``alerted_at`` field as the authoritative
157
252
  "alert was attempted" timestamp. ``account_key`` (#341) is the real account
158
- the crossing wrote under (per-account family)."""
253
+ the crossing wrote under (per-account family).
254
+
255
+ ``week_start_at`` is the week's RESET INSTANT, retained on the milestone
256
+ row and already a declared ``AlertEntry.context`` key. Without it a reader
257
+ can only fall back to ``week_start_date``, which names the right week but
258
+ places both bounds at UTC midnight — wrong by the whole reset-hour offset
259
+ for every account whose week does not reset at midnight. Empty string when
260
+ the row predates the column, matching the five-hour axis's
261
+ ``block_start_at``: the key stays on the wire and the reader degrades to
262
+ day granularity rather than inventing a clock reading."""
159
263
  return {
160
264
  "id": f"weekly:{week_start_date}:{threshold}",
161
265
  "axis": "weekly",
@@ -165,6 +269,7 @@ def _build_alert_payload_weekly(
165
269
  "account_key": str(account_key),
166
270
  "context": {
167
271
  "week_start_date": week_start_date,
272
+ "week_start_at": week_start_at or "",
168
273
  "cumulative_cost_usd": float(cumulative_cost_usd),
169
274
  "dollars_per_percent": (
170
275
  float(dollars_per_percent) if dollars_per_percent is not None else None
@@ -224,7 +329,7 @@ def _alert_text_budget(payload: dict, tz: "ZoneInfo | None") -> tuple[str, str,
224
329
  budget = float(ctx.get("budget_usd") or 0.0)
225
330
  consumption = float(ctx.get("consumption_pct") or 0.0)
226
331
  body = f"${spent:,.2f} of ${budget:,.2f} ({consumption:.0f}% of budget)"
227
- return title, subtitle, body
332
+ return title, subtitle, _with_next_step(body, payload, tz)
228
333
 
229
334
 
230
335
  def _build_alert_payload_budget(
@@ -298,7 +403,7 @@ def _alert_text_project_budget(
298
403
  f"Project {project} - ${spent:,.2f} of ${budget:,.2f} "
299
404
  f"({consumption:.0f}% of budget)"
300
405
  )
301
- return title, subtitle, body
406
+ return title, subtitle, _with_next_step(body, payload, tz)
302
407
 
303
408
 
304
409
  def _build_alert_payload_project_budget(
@@ -374,7 +479,7 @@ def _alert_text_codex_budget(
374
479
  f"Codex - ${spent:,.2f} of ${budget:,.2f} "
375
480
  f"({consumption:.0f}% of budget)"
376
481
  )
377
- return title, subtitle, body
482
+ return title, subtitle, _with_next_step(body, payload, tz)
378
483
 
379
484
 
380
485
  def _build_alert_payload_codex_budget(
@@ -451,7 +556,7 @@ def _alert_text_projected(payload: dict, tz: "ZoneInfo | None") -> tuple[str, st
451
556
  f"Projected ${proj:,.2f} of ${denom:,.2f} budget "
452
557
  f"(week-average pace)"
453
558
  )
454
- return title, subtitle, body
559
+ return title, subtitle, _with_next_step(body, payload, tz)
455
560
 
456
561
 
457
562
  def _build_alert_payload_projected(
@@ -105,6 +105,7 @@ def _group_entries_into_blocks(
105
105
  dt.datetime, tuple[dt.datetime, dt.datetime]
106
106
  ] | None = None,
107
107
  now: dt.datetime | None = None,
108
+ _entry_membership: dict[int, list[UsageEntry]] | None = None,
108
109
  ) -> list[Block]:
109
110
  """Group sorted UsageEntry objects into 5-hour blocks with gap detection.
110
111
 
@@ -138,6 +139,13 @@ def _group_entries_into_blocks(
138
139
 
139
140
  `now` pins the current instant (typically via `_command_as_of()`). When
140
141
  omitted, falls back to wall clock so existing callers are unaffected.
142
+
143
+ ``_entry_membership`` is an internal detail-route seam. When supplied,
144
+ each non-gap result object's identity maps to the exact entries assigned
145
+ by this grouping pass. That ownership is stricter than a later
146
+ ``start <= timestamp < end`` filter when adjacent recorded windows overlap
147
+ after a reset shift; consumers that render per-entry samples must use the
148
+ grouping decision rather than independently reconstructing it.
141
149
  """
142
150
  if not entries:
143
151
  return []
@@ -264,6 +272,8 @@ def _group_entries_into_blocks(
264
272
  anchor="recorded",
265
273
  )
266
274
  first_entry_ts_by_block[id(blk)] = bucket_sorted[0].timestamp
275
+ if _entry_membership is not None:
276
+ _entry_membership[id(blk)] = bucket_sorted
267
277
  recorded_block_objs.append(blk)
268
278
 
269
279
  # Phase 2: Build heuristic Block objects from raw_blocks using _aggregate_block
@@ -278,6 +288,8 @@ def _group_entries_into_blocks(
278
288
  )
279
289
  if block_entries:
280
290
  first_entry_ts_by_block[id(blk)] = block_entries[0].timestamp
291
+ if _entry_membership is not None:
292
+ _entry_membership[id(blk)] = block_entries
281
293
  heuristic_block_objs.append(blk)
282
294
 
283
295
  # Merge + sort by start_time
@@ -17,7 +17,9 @@ from __future__ import annotations
17
17
 
18
18
  import datetime as dt
19
19
  from dataclasses import dataclass, field
20
- from typing import Any, Callable, Iterable, Literal, NamedTuple, Optional
20
+ from typing import (
21
+ Any, Callable, Iterable, Literal, NamedTuple, Optional, Sequence,
22
+ )
21
23
  from zoneinfo import ZoneInfo
22
24
 
23
25
 
@@ -853,6 +855,113 @@ def _compute_baseline_median(
853
855
  return statistics.median(values)
854
856
 
855
857
 
858
+ class CacheRowVerdict(NamedTuple):
859
+ """One row's four-state Cache Report verdict (#620 S1 D6).
860
+
861
+ ``observed`` travels beside ``state`` rather than inside it: a day with
862
+ no activity is still ``unevaluated``, and whether anything happened is a
863
+ separate fact from whether the predicates ran.
864
+ """
865
+ state: str # "anomalous" | "clean" | "partial" | "unevaluated"
866
+ triggered: bool
867
+ unevaluated: "tuple[CacheAnomalyReason, ...]"
868
+ observed: bool
869
+
870
+
871
+ # The worded terminal labels. The dashboard renders glyph + CSS class from
872
+ # `cacheRowFlagGlyph` / `cacheRowFlagClass`; the terminal has no CSS and a
873
+ # bare glyph cannot say which of the three non-anomalous states a row is in,
874
+ # which is exactly the defect: an unmarked tick meant "evaluated and clean",
875
+ # "one predicate skipped" and "nothing evaluated at all" alike.
876
+ #
877
+ # WIDTH IS A HARD CONSTRAINT, and it is why these read `not eval` rather than
878
+ # the spec's `· not evaluated`. `_layout_cache_table` gives a left-aligned,
879
+ # non-expandable column a compact floor of 8 characters, and at the shipped
880
+ # 120-column default the daily table has EXACTLY 8 columns of slack for a
881
+ # twelfth column once the ultra-compact fallback has run:
882
+ # 120 - 34 border - 10 Date - 12 Models - 56 (8 numeric x 7) = 8.
883
+ # A 15-character `· not evaluated` cannot be rendered there; it truncates to
884
+ # `· not e…`, which is unreadable and is the overflow class the plan refuses
885
+ # to re-take a golden over. The glyph is dropped from the label rather than
886
+ # the word, because an anomalous row already carries the red anomaly glyph on
887
+ # its first cell — so no state loses its marker, and every state gains a word
888
+ # that fits.
889
+ _CACHE_ROW_VERDICT_LABELS: "dict[str, str]" = {
890
+ "anomalous": "anomaly",
891
+ "clean": "clear",
892
+ "partial": "partial",
893
+ "unevaluated": "not eval",
894
+ }
895
+
896
+
897
+ def cache_row_verdict(
898
+ *,
899
+ triggered: bool,
900
+ reasons: "Sequence[CacheAnomalyReason] | None" = None,
901
+ unevaluated: "Sequence[CacheAnomalyReason] | None" = None,
902
+ predicates: "Sequence[CacheAnomalyReason] | None" = None,
903
+ observed: "bool | None" = None,
904
+ ) -> CacheRowVerdict:
905
+ """Classify one row into the four Cache Report states (#620 S1 D6).
906
+
907
+ Mirrors ``cacheRowVerdict`` in
908
+ ``dashboard/web/src/lib/cacheReportVerdict.ts`` exactly, including
909
+ triggered precedence. The two are separate implementations — one literal
910
+ classifier is not possible across the language boundary without code
911
+ generation — and they are pinned to each other by a single shared
912
+ truth-table fixture, ``tests/fixtures/cache-report-verdict-vectors.json``,
913
+ which both ``tests/test_620_cache_report_verdict.py`` and
914
+ ``dashboard/web/__tests__/cacheReportVerdictParity.test.ts`` read.
915
+
916
+ ``predicates`` is the set the PROVIDER can evaluate at all, defaulting to
917
+ ``CACHE_ANOMALY_PREDICATES`` (Claude's pair). Codex can only ever
918
+ evaluate ``cache_drop``, so a Codex row carrying it alone is FULLY
919
+ unevaluated; resolving it against the Claude pair would call it
920
+ ``partial``, which claims some other predicate ran when none exists.
921
+ Not applicable is not unevaluated.
922
+
923
+ ``observed=None`` means observed, so a row from a producer that does not
924
+ publish the field reads as it does today.
925
+
926
+ ``reasons`` is accepted and deliberately unused in the classification:
927
+ the state is a function of ``triggered`` and the unevaluated set. It is
928
+ part of the signature because the truth table carries it and a caller
929
+ should not have to know which half the rule reads.
930
+ """
931
+ unevaluated_t = tuple(unevaluated or ())
932
+ predicate_t = tuple(
933
+ predicates if predicates is not None else CACHE_ANOMALY_PREDICATES
934
+ )
935
+ # Triggered wins first. A thin-baseline cache_drop beside a real
936
+ # net_negative is still a real anomaly, and the unevaluated presentation
937
+ # must never swallow it.
938
+ if triggered:
939
+ state = "anomalous"
940
+ elif not unevaluated_t:
941
+ state = "clean"
942
+ elif len(unevaluated_t) >= len(predicate_t):
943
+ state = "unevaluated"
944
+ else:
945
+ state = "partial"
946
+ return CacheRowVerdict(
947
+ state=state,
948
+ triggered=bool(triggered),
949
+ unevaluated=unevaluated_t,
950
+ observed=observed is not False,
951
+ )
952
+
953
+
954
+ def cache_row_verdict_label(state: str) -> str:
955
+ """The worded terminal label for one verdict state.
956
+
957
+ There is no `unicode_ok` axis: every label above is pure ASCII, so an
958
+ ASCII-fallback table would be a verbatim copy and the parameter would
959
+ select between two identical results while every caller still had to
960
+ pass it. A future label that does need a glyph adds the axis then.
961
+ """
962
+ return _CACHE_ROW_VERDICT_LABELS[state]
963
+
964
+
856
965
  def _classify_anomalies(
857
966
  rows: list[CacheRow],
858
967
  *,
@@ -2142,6 +2142,20 @@ def infer_codex_event_turns(
2142
2142
  return turns, current
2143
2143
 
2144
2144
 
2145
+ def fold_codex_event_turns(events) -> dict:
2146
+ """Map each prefetched physical event's ``line_offset`` to its turn.
2147
+
2148
+ The pure half of ``_file_turn_map`` (#620 S3 X3): the caller supplies the
2149
+ ordered ``codex_conversation_events`` batch and this returns
2150
+ ``{line_offset: turn_id | None}`` over the same lifecycle inference cost
2151
+ attribution and normalized prose already share. Opens no database.
2152
+ """
2153
+ materialized = list(events)
2154
+ turns, _terminal = infer_codex_event_turns(materialized)
2155
+ return {event.line_offset: turn
2156
+ for event, turn in zip(materialized, turns)}
2157
+
2158
+
2145
2159
  def _is_late_turn_anchor(
2146
2160
  record_type: str | None, payload_type: str | None, explicit_turn: str | None,
2147
2161
  ) -> bool:
@@ -1272,15 +1272,18 @@ def _tokens_union(tokens: dict) -> dict:
1272
1272
  # ── cost attribution (§5.4) ───────────────────────────────────────────────────
1273
1273
 
1274
1274
 
1275
- def _file_turn_map(conn: sqlite3.Connection, source_path: str) -> dict[int, str | None]:
1276
- """Exact physical-offset → canonical logical turn for one retained file.
1277
-
1278
- Cost attribution and normalized prose use the same pure lifecycle inference,
1279
- including resumed segments whose native proof arrives on task completion.
1275
+ def _codex_file_events(conn: sqlite3.Connection, source_path: str) -> list:
1276
+ """One retained file's physical events, in offset order.
1277
+
1278
+ Split out of ``_file_turn_map`` (#620 S3 X3) so the turn attribution can
1279
+ run as a pure fold over a prefetched batch. This walk is UNBOUNDED by
1280
+ construction: it reads every event the file retains, however far the
1281
+ in-window entry sits behind that history, which is why the diagnosis
1282
+ budgets its own use of it rather than calling this directly.
1280
1283
  """
1281
1284
  from _lib_jsonl import CodexPhysicalEvent
1282
1285
 
1283
- events = [
1286
+ return [
1284
1287
  CodexPhysicalEvent(*row)
1285
1288
  for row in conn.execute(
1286
1289
  "SELECT source_path, line_offset, source_root_key, conversation_key, "
@@ -1291,8 +1294,19 @@ def _file_turn_map(conn: sqlite3.Connection, source_path: str) -> dict[int, str
1291
1294
  (source_path,),
1292
1295
  )
1293
1296
  ]
1294
- turns, _terminal = kern.infer_codex_event_turns(events)
1295
- return {event.line_offset: turn for event, turn in zip(events, turns)}
1297
+
1298
+
1299
+ def _file_turn_map(conn: sqlite3.Connection, source_path: str) -> dict[int, str | None]:
1300
+ """Exact physical-offset → canonical logical turn for one retained file.
1301
+
1302
+ Cost attribution and normalized prose use the same pure lifecycle inference,
1303
+ including resumed segments whose native proof arrives on task completion.
1304
+
1305
+ Since #620 S3 this is a thin wrapper: it fetches and delegates to
1306
+ ``fold_codex_event_turns``. Its signature, return shape and every caller
1307
+ are unchanged.
1308
+ """
1309
+ return kern.fold_codex_event_turns(_codex_file_events(conn, source_path))
1296
1310
 
1297
1311
 
1298
1312
  def _attribute_costs(conn: sqlite3.Connection, conversation_key: str, effective_speed: str):
@@ -38,18 +38,30 @@ def is_model_scoped_codex_quota(logical_limit_key: object, limit_name: object) -
38
38
  return codex_model_scoped_quota_pool(limit_name) is not None
39
39
 
40
40
 
41
- def _key_has_model_pool(logical_limit_key: object) -> bool:
41
+ def codex_key_model_pool(logical_limit_key: object) -> str | None:
42
+ """Return the ``modelPool`` an interpreted quota key names, else ``None``.
43
+
44
+ The name axis of the same classification ``is_model_scoped_codex_quota``
45
+ answers as a boolean. Callers that need to PRESENT the pool (#620 S1's
46
+ alert scope) read it here rather than parsing the key themselves, so pool
47
+ classification keeps exactly one home.
48
+ """
42
49
  if not isinstance(logical_limit_key, str):
43
- return False
50
+ return None
44
51
  try:
45
52
  payload = json.loads(logical_limit_key)
46
53
  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
- )
54
+ return None
55
+ if not isinstance(payload, dict):
56
+ return None
57
+ pool = payload.get("modelPool")
58
+ if not isinstance(pool, str) or not pool.strip():
59
+ return None
60
+ return pool.strip().lower()
61
+
62
+
63
+ def _key_has_model_pool(logical_limit_key: object) -> bool:
64
+ return codex_key_model_pool(logical_limit_key) is not None
53
65
 
54
66
 
55
67
  def codex_history_is_model_scoped(history, *, baseline=None) -> bool:
@@ -55,10 +55,13 @@ def _join_text_blocks(blocks):
55
55
  migration-011 consumer rebuilds the marker text from blocks_json to feed
56
56
  _extract_command_invocation, so this lives in the parser kernel (the one
57
57
  _cctally_cache already imports) to keep the two derivations identical."""
58
- if not blocks:
58
+ if not isinstance(blocks, list) or not blocks:
59
59
  return ""
60
- return "\n".join(b.get("text", "") or ""
61
- for b in blocks if b.get("kind") == "text")
60
+ return "\n".join(
61
+ text for b in blocks
62
+ if isinstance(b, dict) and b.get("kind") == "text"
63
+ and isinstance((text := b.get("text")), str)
64
+ )
62
65
 
63
66
 
64
67
  def _extract_command_invocation(blocks, text):