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.
- package/CHANGELOG.md +73 -0
- package/README.md +8 -2
- package/bin/_cctally_alerts.py +13 -2
- package/bin/_cctally_cache.py +3 -1
- package/bin/_cctally_cache_report.py +103 -6
- package/bin/_cctally_dashboard.py +1140 -282
- package/bin/_cctally_dashboard_conversation.py +12 -0
- package/bin/_cctally_dashboard_envelope.py +53 -61
- package/bin/_cctally_dashboard_share.py +101 -29
- package/bin/_cctally_dashboard_sources.py +663 -192
- package/bin/_cctally_diagnosis.py +1172 -0
- package/bin/_cctally_diagnosis_sources.py +4054 -0
- package/bin/_cctally_diff.py +20 -0
- package/bin/_cctally_forecast.py +329 -111
- package/bin/_cctally_milestone_history.py +10 -2
- package/bin/_cctally_parser.py +84 -0
- package/bin/_cctally_project.py +155 -47
- package/bin/_cctally_quota.py +14 -0
- package/bin/_cctally_record.py +151 -71
- package/bin/_cctally_refresh.py +105 -93
- package/bin/_cctally_share.py +9 -2
- package/bin/_cctally_source_analytics.py +40 -4
- package/bin/_cctally_statusline.py +8 -1
- package/bin/_cctally_tui.py +425 -234
- package/bin/_lib_alert_scope.py +685 -0
- package/bin/_lib_alerts_payload.py +112 -7
- package/bin/_lib_blocks.py +12 -0
- package/bin/_lib_cache_report.py +110 -1
- package/bin/_lib_codex_conversation.py +14 -0
- package/bin/_lib_codex_conversation_query.py +22 -8
- package/bin/_lib_codex_pools.py +20 -8
- package/bin/_lib_conversation.py +6 -3
- package/bin/_lib_conversation_query.py +256 -69
- package/bin/_lib_dashboard_sources.py +212 -24
- package/bin/_lib_diagnosis.py +1261 -0
- package/bin/_lib_forecast.py +62 -4
- package/bin/_lib_perf.py +12 -0
- package/bin/_lib_pricing.py +8 -7
- package/bin/_lib_readme_refresh.py +26 -5
- package/bin/_lib_render.py +31 -3
- package/bin/_lib_share_templates.py +150 -55
- package/bin/_lib_snapshot_cache.py +71 -13
- package/bin/_lib_source_identity.py +50 -2
- package/bin/_lib_subscription_weeks.py +65 -0
- package/bin/cctally +103 -16
- package/bin/cctally-explain +5 -0
- package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +1 -0
- package/dashboard/static/assets/index-Di2hljvB.css +1 -0
- package/dashboard/static/assets/index-XYCIWjVG.js +97 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +6 -1
- package/dashboard/static/assets/index-B5YfQEtn.css +0 -1
- package/dashboard/static/assets/index-Bt59nMMO.js +0 -97
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
"""Alert scope derivation — the exact window, provider and account an alert
|
|
2
|
+
describes, read from the fields that alert already retains (#620 S1, D11).
|
|
3
|
+
|
|
4
|
+
Pure leaf: stdlib only, no database access, no clock read, no I/O. Model-pool
|
|
5
|
+
classification delegates to ``_lib_codex_pools`` so that classification keeps
|
|
6
|
+
one home.
|
|
7
|
+
|
|
8
|
+
Two rules shape the whole module.
|
|
9
|
+
|
|
10
|
+
**The window END is derived, never stored.** These rows live in stats.db,
|
|
11
|
+
which returns early at ``STATS_INDEX_EPOCH`` before ``add_column_if_missing``
|
|
12
|
+
is reached, so a ``window_end_at`` column would cost an epoch bump plus
|
|
13
|
+
journal, replay, cutover, rebuild and rederive parity — disproportionate to a
|
|
14
|
+
value the retained fields already determine. Recorded caveat: a future change
|
|
15
|
+
to the ``BUDGET_PERIODS`` definitions would make derivation disagree with fire
|
|
16
|
+
time, and would then require the stamp.
|
|
17
|
+
|
|
18
|
+
**Scope is never read from the alert id.** The client contract states the id
|
|
19
|
+
is opaque and never parsed, the journal contract independently forbids parsing
|
|
20
|
+
event ids, and Claude alert ids omit the account entirely — so the id could not
|
|
21
|
+
carry the scope even if parsing it were permitted. ``derive_alert_scope`` takes
|
|
22
|
+
no id, and ``tests/test_620_alert_scope.py`` asserts structurally that no code
|
|
23
|
+
path here reads one.
|
|
24
|
+
|
|
25
|
+
Returning a bare ``None`` for "no window" was rejected: it cannot carry the
|
|
26
|
+
cause, and a caller cannot distinguish "no window" from "window withheld
|
|
27
|
+
because the account is vendor-wide". ``AlertScope`` follows the #556 typed-
|
|
28
|
+
withholding shape instead — it always returns, and it always says which state
|
|
29
|
+
it is in.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import datetime as dt
|
|
34
|
+
import pathlib
|
|
35
|
+
import sys
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _load_lib(name):
|
|
40
|
+
cached = sys.modules.get(name)
|
|
41
|
+
if cached is not None:
|
|
42
|
+
return cached
|
|
43
|
+
import importlib.util as _ilu
|
|
44
|
+
p = pathlib.Path(__file__).resolve().parent / f"{name}.py"
|
|
45
|
+
spec = _ilu.spec_from_file_location(name, p)
|
|
46
|
+
mod = _ilu.module_from_spec(spec)
|
|
47
|
+
sys.modules[name] = mod
|
|
48
|
+
spec.loader.exec_module(mod)
|
|
49
|
+
return mod
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_lib_codex_pools = _load_lib("_lib_codex_pools")
|
|
53
|
+
# Rendering a derived window for a reader is a display concern, so it goes
|
|
54
|
+
# through the datetime chokepoint like every other user-facing instant.
|
|
55
|
+
_format_display_dt = _load_lib("_lib_display_tz").format_display_dt
|
|
56
|
+
|
|
57
|
+
# Both durable sentinels. `*` marks a vendor-wide row (budget-family and the
|
|
58
|
+
# account-blind project-budget table); `unattributed` is a real account bucket,
|
|
59
|
+
# not a vendor-wide stamp, so it stays account-scoped.
|
|
60
|
+
VENDOR_WIDE_KEY = "*"
|
|
61
|
+
|
|
62
|
+
SUBSCRIPTION_WEEK = dt.timedelta(days=7)
|
|
63
|
+
CALENDAR_WEEK = dt.timedelta(days=7)
|
|
64
|
+
FIVE_HOUR_WINDOW = dt.timedelta(hours=5)
|
|
65
|
+
|
|
66
|
+
ACCOUNT_SCOPE = "account"
|
|
67
|
+
VENDOR_WIDE_SCOPE = "vendor_wide"
|
|
68
|
+
|
|
69
|
+
# Window precision. Every axis but `weekly`'s fallback retains a real instant.
|
|
70
|
+
WINDOW_INSTANT = "instant"
|
|
71
|
+
WINDOW_DAY = "day"
|
|
72
|
+
|
|
73
|
+
# Every axis this kernel knows. `quota` is CLI-only: it is absent from the
|
|
74
|
+
# dashboard alert envelope, so six of the seven reach the client.
|
|
75
|
+
ALERT_AXES = (
|
|
76
|
+
"weekly",
|
|
77
|
+
"five_hour",
|
|
78
|
+
"budget",
|
|
79
|
+
"codex_budget",
|
|
80
|
+
"project_budget",
|
|
81
|
+
"projected",
|
|
82
|
+
"quota",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class AlertScope:
|
|
88
|
+
"""What one alert is about. ``available`` is the discriminator.
|
|
89
|
+
|
|
90
|
+
When ``available`` is ``True``, ``withheld_reason`` is ``None`` and both
|
|
91
|
+
window bounds are set. When it is ``False``, ``withheld_reason`` is a
|
|
92
|
+
non-empty sentence a surface can render verbatim, and the bounds are
|
|
93
|
+
``None``. The other fields are populated as far as the retained evidence
|
|
94
|
+
reaches, so a withheld window can still name its provider.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
available: bool
|
|
98
|
+
withheld_reason: "str | None"
|
|
99
|
+
provider: "str | None"
|
|
100
|
+
account_key: "str | None"
|
|
101
|
+
account_scope: "str | None"
|
|
102
|
+
model_pool: "str | None"
|
|
103
|
+
cost_basis: "str | None"
|
|
104
|
+
window_start: "dt.datetime | None"
|
|
105
|
+
window_end: "dt.datetime | None"
|
|
106
|
+
# How precisely the retained fields fix the bounds. ``instant`` means the
|
|
107
|
+
# row retained a real clock reading. ``day`` means it retained only a
|
|
108
|
+
# calendar date, so the bounds are that date at UTC midnight and a surface
|
|
109
|
+
# must not state a time or a zone it does not have. Meaningful only when
|
|
110
|
+
# ``available``.
|
|
111
|
+
window_granularity: str = WINDOW_INSTANT
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _withheld(reason, *, provider=None, account_key=None, account_scope=None,
|
|
115
|
+
model_pool=None, cost_basis=None) -> AlertScope:
|
|
116
|
+
return AlertScope(
|
|
117
|
+
available=False,
|
|
118
|
+
withheld_reason=reason,
|
|
119
|
+
provider=provider,
|
|
120
|
+
account_key=account_key,
|
|
121
|
+
account_scope=account_scope,
|
|
122
|
+
model_pool=model_pool,
|
|
123
|
+
cost_basis=cost_basis,
|
|
124
|
+
window_start=None,
|
|
125
|
+
window_end=None,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _parse_instant(value) -> "dt.datetime | None":
|
|
130
|
+
"""Read a retained ISO-8601 instant. A naive value means UTC."""
|
|
131
|
+
if isinstance(value, dt.datetime):
|
|
132
|
+
parsed = value
|
|
133
|
+
elif isinstance(value, str) and value.strip():
|
|
134
|
+
try:
|
|
135
|
+
parsed = dt.datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
|
136
|
+
except ValueError:
|
|
137
|
+
return None
|
|
138
|
+
else:
|
|
139
|
+
return None
|
|
140
|
+
if parsed.tzinfo is None:
|
|
141
|
+
parsed = parsed.replace(tzinfo=dt.timezone.utc)
|
|
142
|
+
return parsed.astimezone(dt.timezone.utc)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _parse_calendar_day(value) -> "dt.datetime | None":
|
|
146
|
+
"""Read a retained ``YYYY-MM-DD`` calendar day as its UTC midnight."""
|
|
147
|
+
if not isinstance(value, str) or not value.strip():
|
|
148
|
+
return None
|
|
149
|
+
try:
|
|
150
|
+
day = dt.date.fromisoformat(value.strip())
|
|
151
|
+
except ValueError:
|
|
152
|
+
return None
|
|
153
|
+
return dt.datetime(day.year, day.month, day.day, tzinfo=dt.timezone.utc)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _parse_epoch_seconds(value) -> "dt.datetime | None":
|
|
157
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
158
|
+
return None
|
|
159
|
+
try:
|
|
160
|
+
return dt.datetime.fromtimestamp(int(value), tz=dt.timezone.utc)
|
|
161
|
+
except (OverflowError, OSError, ValueError):
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _add_calendar_month(instant: dt.datetime) -> dt.datetime:
|
|
166
|
+
"""Advance one civil month on the instant's own fields.
|
|
167
|
+
|
|
168
|
+
A ``calendar-month`` window start is the local first-of-month converted to
|
|
169
|
+
UTC, so advancing the month field reproduces the next window start exactly
|
|
170
|
+
whenever the zone's offset is unchanged across the boundary. A period that
|
|
171
|
+
straddles a DST transition derives an end one hour away from the instant
|
|
172
|
+
the next window actually starts at; the caveat is recorded in
|
|
173
|
+
``docs/alerts-gotchas.md``.
|
|
174
|
+
"""
|
|
175
|
+
year = instant.year + (1 if instant.month == 12 else 0)
|
|
176
|
+
month = 1 if instant.month == 12 else instant.month + 1
|
|
177
|
+
day = instant.day
|
|
178
|
+
while day > 1:
|
|
179
|
+
try:
|
|
180
|
+
return instant.replace(year=year, month=month, day=day)
|
|
181
|
+
except ValueError:
|
|
182
|
+
day -= 1
|
|
183
|
+
return instant.replace(year=year, month=month, day=1)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def period_window_end(period, window_start) -> "dt.datetime | None":
|
|
187
|
+
"""End of the half-open ``[start, end)`` window a budget period names."""
|
|
188
|
+
if window_start is None:
|
|
189
|
+
return None
|
|
190
|
+
if period == "subscription-week":
|
|
191
|
+
return window_start + SUBSCRIPTION_WEEK
|
|
192
|
+
if period == "calendar-week":
|
|
193
|
+
return window_start + CALENDAR_WEEK
|
|
194
|
+
if period == "calendar-month":
|
|
195
|
+
return _add_calendar_month(window_start)
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _account_scope_for(account_key) -> str:
|
|
200
|
+
return VENDOR_WIDE_SCOPE if account_key == VENDOR_WIDE_KEY else ACCOUNT_SCOPE
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _normalize_account_key(account_key) -> "str | None":
|
|
204
|
+
if account_key is None:
|
|
205
|
+
return None
|
|
206
|
+
text = str(account_key).strip()
|
|
207
|
+
return text or None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def derive_alert_scope(axis, context, account_key=None) -> AlertScope:
|
|
211
|
+
"""Derive one alert's scope from ``context`` plus its stamped account.
|
|
212
|
+
|
|
213
|
+
``axis`` is the alert axis; ``context`` is the alert's retained context
|
|
214
|
+
mapping; ``account_key`` is the top-level stamp the row wrote under. No
|
|
215
|
+
argument names or carries an alert identifier.
|
|
216
|
+
"""
|
|
217
|
+
ctx = context if isinstance(context, dict) else {}
|
|
218
|
+
key = _normalize_account_key(account_key)
|
|
219
|
+
handler = _AXIS_HANDLERS.get(axis)
|
|
220
|
+
if handler is None:
|
|
221
|
+
return _withheld(f"unknown alert axis: {axis!r}")
|
|
222
|
+
return handler(ctx, key)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _scope_weekly(ctx, key) -> AlertScope:
|
|
226
|
+
# A subscription week is anchored to a real reset instant, which is what
|
|
227
|
+
# `percent_milestones.week_start_at` retains and what the envelope and the
|
|
228
|
+
# dispatch payload both publish. `week_start_date` is the same week's
|
|
229
|
+
# calendar label and is all a pre-`week_start_at` row has; falling back to
|
|
230
|
+
# it recovers the week but not the reset hour, so the granularity drops
|
|
231
|
+
# with it rather than a midnight instant being invented.
|
|
232
|
+
start = _parse_instant(ctx.get("week_start_at"))
|
|
233
|
+
granularity = WINDOW_INSTANT
|
|
234
|
+
if start is None:
|
|
235
|
+
start = _parse_calendar_day(ctx.get("week_start_date"))
|
|
236
|
+
granularity = WINDOW_DAY
|
|
237
|
+
common = {
|
|
238
|
+
"provider": "claude",
|
|
239
|
+
"account_key": key,
|
|
240
|
+
"account_scope": _account_scope_for(key),
|
|
241
|
+
"cost_basis": "cumulative_cost_usd",
|
|
242
|
+
}
|
|
243
|
+
if start is None:
|
|
244
|
+
return _withheld(
|
|
245
|
+
"the weekly alert retains no week start, so its window cannot be "
|
|
246
|
+
"derived",
|
|
247
|
+
**common,
|
|
248
|
+
)
|
|
249
|
+
return AlertScope(
|
|
250
|
+
available=True,
|
|
251
|
+
withheld_reason=None,
|
|
252
|
+
model_pool=None,
|
|
253
|
+
window_start=start,
|
|
254
|
+
window_end=start + SUBSCRIPTION_WEEK,
|
|
255
|
+
window_granularity=granularity,
|
|
256
|
+
**common,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _scope_five_hour(ctx, key) -> AlertScope:
|
|
261
|
+
common = {
|
|
262
|
+
"provider": "claude",
|
|
263
|
+
"account_key": key,
|
|
264
|
+
"account_scope": _account_scope_for(key),
|
|
265
|
+
"cost_basis": "block_cost_usd",
|
|
266
|
+
}
|
|
267
|
+
start = _parse_instant(ctx.get("block_start_at"))
|
|
268
|
+
if start is not None:
|
|
269
|
+
return AlertScope(
|
|
270
|
+
available=True,
|
|
271
|
+
withheld_reason=None,
|
|
272
|
+
model_pool=None,
|
|
273
|
+
window_start=start,
|
|
274
|
+
window_end=start + FIVE_HOUR_WINDOW,
|
|
275
|
+
**common,
|
|
276
|
+
)
|
|
277
|
+
# `five_hour_window_key` is the RESET epoch floored to ten minutes
|
|
278
|
+
# (`_canonical_5h_window_key`), so it names the window's END. Reading it as
|
|
279
|
+
# a start would place the window five hours early. The floor also means the
|
|
280
|
+
# start recovered this way is within ten minutes of the recorded block
|
|
281
|
+
# start, which is why the block join is preferred when it resolves.
|
|
282
|
+
end = _parse_epoch_seconds(ctx.get("five_hour_window_key"))
|
|
283
|
+
if end is None:
|
|
284
|
+
return _withheld(
|
|
285
|
+
"the five-hour alert retains no block start, so its window cannot "
|
|
286
|
+
"be derived",
|
|
287
|
+
**common,
|
|
288
|
+
)
|
|
289
|
+
return AlertScope(
|
|
290
|
+
available=True,
|
|
291
|
+
withheld_reason=None,
|
|
292
|
+
model_pool=None,
|
|
293
|
+
window_start=end - FIVE_HOUR_WINDOW,
|
|
294
|
+
window_end=end,
|
|
295
|
+
**common,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _scope_budget_family(ctx, key, *, provider, default_period) -> AlertScope:
|
|
300
|
+
period = ctx.get("period") or default_period
|
|
301
|
+
start = _parse_instant(ctx.get("period_start_at")) or _parse_instant(
|
|
302
|
+
ctx.get("week_start_at")
|
|
303
|
+
)
|
|
304
|
+
common = {
|
|
305
|
+
"provider": provider,
|
|
306
|
+
"account_key": key,
|
|
307
|
+
"account_scope": _account_scope_for(key),
|
|
308
|
+
"cost_basis": "spent_usd",
|
|
309
|
+
}
|
|
310
|
+
if start is None:
|
|
311
|
+
return _withheld(
|
|
312
|
+
"the budget alert retains no period start, so its window cannot "
|
|
313
|
+
"be derived",
|
|
314
|
+
**common,
|
|
315
|
+
)
|
|
316
|
+
end = period_window_end(period, start)
|
|
317
|
+
if end is None:
|
|
318
|
+
return _withheld(
|
|
319
|
+
f"the budget alert names no derivable period (got {period!r})",
|
|
320
|
+
**common,
|
|
321
|
+
)
|
|
322
|
+
return AlertScope(
|
|
323
|
+
available=True,
|
|
324
|
+
withheld_reason=None,
|
|
325
|
+
model_pool=None,
|
|
326
|
+
window_start=start,
|
|
327
|
+
window_end=end,
|
|
328
|
+
**common,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _scope_budget(ctx, key) -> AlertScope:
|
|
333
|
+
return _scope_budget_family(
|
|
334
|
+
ctx, key, provider="claude", default_period="subscription-week"
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _scope_codex_budget(ctx, key) -> AlertScope:
|
|
339
|
+
# No default: Codex has no subscription week, and guessing between the two
|
|
340
|
+
# calendar periods would double or halve the window.
|
|
341
|
+
return _scope_budget_family(
|
|
342
|
+
ctx, key, provider="codex", default_period=None
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _scope_project_budget(ctx, key) -> AlertScope:
|
|
347
|
+
# `project_budget_milestones` is deliberately account-blind and stamped
|
|
348
|
+
# `*`, so the scope is vendor-wide whatever the caller passes. Narrowing it
|
|
349
|
+
# to an account would invent an attribution the row never recorded.
|
|
350
|
+
period = ctx.get("period") or "subscription-week"
|
|
351
|
+
start = _parse_instant(ctx.get("week_start_at")) or _parse_instant(
|
|
352
|
+
ctx.get("period_start_at")
|
|
353
|
+
)
|
|
354
|
+
common = {
|
|
355
|
+
"provider": "claude",
|
|
356
|
+
"account_key": key,
|
|
357
|
+
"account_scope": VENDOR_WIDE_SCOPE,
|
|
358
|
+
"cost_basis": "spent_usd",
|
|
359
|
+
}
|
|
360
|
+
if start is None:
|
|
361
|
+
return _withheld(
|
|
362
|
+
"the project-budget alert retains no window start, so its window "
|
|
363
|
+
"cannot be derived",
|
|
364
|
+
**common,
|
|
365
|
+
)
|
|
366
|
+
end = period_window_end(period, start)
|
|
367
|
+
if end is None:
|
|
368
|
+
return _withheld(
|
|
369
|
+
f"the project-budget alert names no derivable period (got "
|
|
370
|
+
f"{period!r})",
|
|
371
|
+
**common,
|
|
372
|
+
)
|
|
373
|
+
return AlertScope(
|
|
374
|
+
available=True,
|
|
375
|
+
withheld_reason=None,
|
|
376
|
+
model_pool=None,
|
|
377
|
+
window_start=start,
|
|
378
|
+
window_end=end,
|
|
379
|
+
**common,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _scope_projected(ctx, key) -> AlertScope:
|
|
384
|
+
metric = ctx.get("metric")
|
|
385
|
+
provider = "codex" if metric == "codex_budget_usd" else "claude"
|
|
386
|
+
# `weekly_pct` projects against the percent cap, which retains no dollar
|
|
387
|
+
# basis; the two budget metrics project a dollar figure.
|
|
388
|
+
cost_basis = None if metric == "weekly_pct" else "projected_value"
|
|
389
|
+
common = {
|
|
390
|
+
"provider": provider,
|
|
391
|
+
"account_key": key,
|
|
392
|
+
"account_scope": _account_scope_for(key),
|
|
393
|
+
"cost_basis": cost_basis,
|
|
394
|
+
}
|
|
395
|
+
start = _parse_instant(ctx.get("week_start_at")) or _parse_instant(
|
|
396
|
+
ctx.get("period_start_at")
|
|
397
|
+
)
|
|
398
|
+
if start is None:
|
|
399
|
+
return _withheld(
|
|
400
|
+
"the projected alert retains no period start, so its window "
|
|
401
|
+
"cannot be derived",
|
|
402
|
+
**common,
|
|
403
|
+
)
|
|
404
|
+
# The metric fixes the period for `weekly_pct` — a weekly-percent
|
|
405
|
+
# projection is a subscription week by construction. The budget metrics do
|
|
406
|
+
# not: a Claude budget runs on any of three periods and a Codex budget on
|
|
407
|
+
# either calendar period, so an absent `period` is withheld rather than
|
|
408
|
+
# guessed.
|
|
409
|
+
period = ctx.get("period")
|
|
410
|
+
if period is None and metric == "weekly_pct":
|
|
411
|
+
period = "subscription-week"
|
|
412
|
+
end = period_window_end(period, start)
|
|
413
|
+
if end is None:
|
|
414
|
+
return _withheld(
|
|
415
|
+
f"the projected alert retains no period for metric {metric!r}, so "
|
|
416
|
+
"its window length is unknown",
|
|
417
|
+
**common,
|
|
418
|
+
)
|
|
419
|
+
return AlertScope(
|
|
420
|
+
available=True,
|
|
421
|
+
withheld_reason=None,
|
|
422
|
+
model_pool=None,
|
|
423
|
+
window_start=start,
|
|
424
|
+
window_end=end,
|
|
425
|
+
**common,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _scope_quota(ctx, key) -> AlertScope:
|
|
430
|
+
source = ctx.get("source")
|
|
431
|
+
provider = str(source) if isinstance(source, str) and source.strip() else None
|
|
432
|
+
pool = _lib_codex_pools.codex_key_model_pool(ctx.get("logical_limit_key"))
|
|
433
|
+
common = {
|
|
434
|
+
"provider": provider,
|
|
435
|
+
"account_key": key,
|
|
436
|
+
"account_scope": _account_scope_for(key),
|
|
437
|
+
# `quota_threshold_events` retains source, root, account, logical
|
|
438
|
+
# limit, slot, duration and reset — and no dollar cost basis. Where a
|
|
439
|
+
# cost is needed it is recomputed, never assumed retained.
|
|
440
|
+
"cost_basis": None,
|
|
441
|
+
}
|
|
442
|
+
end = _parse_instant(ctx.get("resets_at_utc"))
|
|
443
|
+
minutes = ctx.get("window_minutes")
|
|
444
|
+
if end is None:
|
|
445
|
+
return _withheld(
|
|
446
|
+
"the quota alert retains no reset instant, so its window cannot "
|
|
447
|
+
"be derived",
|
|
448
|
+
model_pool=pool,
|
|
449
|
+
**common,
|
|
450
|
+
)
|
|
451
|
+
if isinstance(minutes, bool) or not isinstance(minutes, (int, float)):
|
|
452
|
+
return _withheld(
|
|
453
|
+
"the quota alert retains no window duration, so its window start "
|
|
454
|
+
"cannot be derived",
|
|
455
|
+
model_pool=pool,
|
|
456
|
+
**common,
|
|
457
|
+
)
|
|
458
|
+
if int(minutes) <= 0:
|
|
459
|
+
return _withheld(
|
|
460
|
+
f"the quota alert names a non-positive window duration "
|
|
461
|
+
f"({int(minutes)} minutes)",
|
|
462
|
+
model_pool=pool,
|
|
463
|
+
**common,
|
|
464
|
+
)
|
|
465
|
+
return AlertScope(
|
|
466
|
+
available=True,
|
|
467
|
+
withheld_reason=None,
|
|
468
|
+
model_pool=pool,
|
|
469
|
+
window_start=end - dt.timedelta(minutes=int(minutes)),
|
|
470
|
+
window_end=end,
|
|
471
|
+
**common,
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
_AXIS_HANDLERS = {
|
|
476
|
+
"weekly": _scope_weekly,
|
|
477
|
+
"five_hour": _scope_five_hour,
|
|
478
|
+
"budget": _scope_budget,
|
|
479
|
+
"codex_budget": _scope_codex_budget,
|
|
480
|
+
"project_budget": _scope_project_budget,
|
|
481
|
+
"projected": _scope_projected,
|
|
482
|
+
"quota": _scope_quota,
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
# ─────────────────────────── the remediation idiom ──────────────────────────
|
|
487
|
+
# One affordance shape for every warning state in the product, alert bodies
|
|
488
|
+
# and CLI reports alike. `doctor` already renders `→ <remediation>`; this keeps
|
|
489
|
+
# the arrow and fixes the verb so a reader learns exactly one form.
|
|
490
|
+
|
|
491
|
+
def inclusive_last_day(window_end: dt.datetime) -> "dt.date":
|
|
492
|
+
"""The last calendar day a half-open ``[start, end)`` window touches.
|
|
493
|
+
|
|
494
|
+
`--since` / `--until` selectors are inclusive whole days, so a window
|
|
495
|
+
ending at 2026-06-02 14:00 still contains fourteen hours of 2026-06-02 and
|
|
496
|
+
must name it. Subtracting a whole day is only correct when the end lands
|
|
497
|
+
exactly on midnight, which a subscription week anchored to a real reset
|
|
498
|
+
instant almost never does.
|
|
499
|
+
"""
|
|
500
|
+
return (window_end - dt.timedelta(microseconds=1)).date()
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def format_scope_detail(provider, window_start, window_end, tz,
|
|
504
|
+
granularity=WINDOW_INSTANT) -> "str | None":
|
|
505
|
+
"""The scope statement a next-step line ends with.
|
|
506
|
+
|
|
507
|
+
Both instants go through ``format_display_dt`` (chokepoint rule); only the
|
|
508
|
+
end carries the zone label, because both bounds are in the same zone. A
|
|
509
|
+
command's own arguments are NOT rendered here — those stay in UTC, which
|
|
510
|
+
is what the selectors they feed accept.
|
|
511
|
+
|
|
512
|
+
``WINDOW_DAY`` renders bare calendar dates and no zone label. The bounds
|
|
513
|
+
then came from a retained ``YYYY-MM-DD``, so a clock reading would be
|
|
514
|
+
invented; and converting that day's UTC midnight into a non-UTC display
|
|
515
|
+
zone would move the printed date off the calendar day the row recorded
|
|
516
|
+
(the same shift ``_alert_text_weekly`` avoids for ``week_start_date``).
|
|
517
|
+
"""
|
|
518
|
+
parts = []
|
|
519
|
+
if provider:
|
|
520
|
+
parts.append(str(provider))
|
|
521
|
+
if window_start is not None and window_end is not None:
|
|
522
|
+
if granularity == WINDOW_DAY:
|
|
523
|
+
parts.append(f"{window_start:%Y-%m-%d} → {window_end:%Y-%m-%d}")
|
|
524
|
+
else:
|
|
525
|
+
start = _format_display_dt(
|
|
526
|
+
window_start, tz, fmt="%Y-%m-%d %H:%M", suffix=False
|
|
527
|
+
)
|
|
528
|
+
end = _format_display_dt(
|
|
529
|
+
window_end, tz, fmt="%Y-%m-%d %H:%M", suffix=True
|
|
530
|
+
)
|
|
531
|
+
parts.append(f"{start} → {end}")
|
|
532
|
+
return " · ".join(parts) or None
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def next_step_line(command: "str | None", *, provider=None, window_start=None,
|
|
536
|
+
window_end=None, tz=None, granularity=WINDOW_INSTANT,
|
|
537
|
+
unavailable_reason: "str | None" = None) -> str:
|
|
538
|
+
"""Render the affordance for one warning state, scope statement included.
|
|
539
|
+
|
|
540
|
+
This is the single entry point every warning state uses — CLI reports and
|
|
541
|
+
alert bodies alike — so a reader learns the form once.
|
|
542
|
+
"""
|
|
543
|
+
return format_next_step(
|
|
544
|
+
command,
|
|
545
|
+
detail=format_scope_detail(
|
|
546
|
+
provider, window_start, window_end, tz, granularity
|
|
547
|
+
),
|
|
548
|
+
unavailable_reason=unavailable_reason,
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def format_next_step(command: "str | None", *, detail: "str | None" = None,
|
|
553
|
+
unavailable_reason: "str | None" = None) -> str:
|
|
554
|
+
"""Render one next-step line.
|
|
555
|
+
|
|
556
|
+
With a ``command``, the line offers it and appends ``detail`` (the scope
|
|
557
|
+
the command addresses) after an em dash. Without one, the line states why
|
|
558
|
+
no scoped explanation is available — a closed or underivable window says
|
|
559
|
+
so and offers nothing, rather than silently substituting a window the
|
|
560
|
+
alert never described.
|
|
561
|
+
"""
|
|
562
|
+
if command:
|
|
563
|
+
line = f"→ Run `{command}`"
|
|
564
|
+
return f"{line} — {detail}" if detail else line
|
|
565
|
+
reason = unavailable_reason or "the window this alert describes is not addressable"
|
|
566
|
+
return f"→ No scoped explanation: {reason}"
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
UNRETAINED_FIVE_HOUR_BLOCK = (
|
|
570
|
+
"the block this alert names is no longer retained, so its five-hour "
|
|
571
|
+
"detail cannot be re-opened"
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
# `cctally budget` bare status resolves `--vendor` and `--period` and then
|
|
575
|
+
# consumes them only in its `set` / `unset` branches: the read path takes the
|
|
576
|
+
# period from config (`budget_cfg["period"]`), and the command carries no
|
|
577
|
+
# historical selector at all. Naming those flags would state a scope the
|
|
578
|
+
# command cannot honour, so the whole budget family offers the bare form.
|
|
579
|
+
LIVE_PERIOD_ONLY_COMMAND = "cctally budget"
|
|
580
|
+
|
|
581
|
+
# `cctally forecast` is the same shape for the same reason: its only time knob
|
|
582
|
+
# is `--as-of`, registered `argparse.SUPPRESS` as a test hook rather than a
|
|
583
|
+
# user-facing selector, so the command reports whichever subscription week is
|
|
584
|
+
# live when it runs.
|
|
585
|
+
LIVE_WEEK_ONLY_COMMAND = "cctally forecast --explain"
|
|
586
|
+
|
|
587
|
+
CLOSED_BUDGET_PERIOD = (
|
|
588
|
+
"the budget period this alert names has already closed, and `cctally "
|
|
589
|
+
"budget` reports only the live period"
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
CLOSED_FORECAST_WEEK = (
|
|
593
|
+
"the week this alert names has already closed, and `cctally forecast` "
|
|
594
|
+
"reports only the live week"
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
# Which command reports the live window only, and what to say once the
|
|
598
|
+
# alert's own window has closed. One table so a new live-window command
|
|
599
|
+
# cannot be classified without also stating its cause.
|
|
600
|
+
_LIVE_WINDOW_ONLY_COMMANDS = {
|
|
601
|
+
LIVE_PERIOD_ONLY_COMMAND: CLOSED_BUDGET_PERIOD,
|
|
602
|
+
LIVE_WEEK_ONLY_COMMAND: CLOSED_FORECAST_WEEK,
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def command_reports_the_live_period_only(command) -> bool:
|
|
607
|
+
"""True when ``command`` addresses whichever period is live when it runs.
|
|
608
|
+
|
|
609
|
+
Following such a command from an alert whose period has closed would
|
|
610
|
+
report the current window under a line that names the closed one — the
|
|
611
|
+
silent substitution the as-of contract forbids. Deciding that a period HAS
|
|
612
|
+
closed needs a clock, which this module never reads, so the caller does
|
|
613
|
+
the comparison; this states only which commands the question applies to.
|
|
614
|
+
"""
|
|
615
|
+
return command in _LIVE_WINDOW_ONLY_COMMANDS
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def closed_window_reason(command) -> str:
|
|
619
|
+
"""Why a live-window command is withheld once its window has closed."""
|
|
620
|
+
return _LIVE_WINDOW_ONLY_COMMANDS.get(command, CLOSED_BUDGET_PERIOD)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def alert_target_unavailable_reason(axis, context, scope: AlertScope) -> str:
|
|
624
|
+
"""Why no command is offered. One place, so every surface says the same.
|
|
625
|
+
|
|
626
|
+
A withheld scope states its own cause. An available scope with no command
|
|
627
|
+
means the window is known but its target is gone, which today is only the
|
|
628
|
+
purged five-hour block.
|
|
629
|
+
"""
|
|
630
|
+
if scope.withheld_reason:
|
|
631
|
+
return scope.withheld_reason
|
|
632
|
+
if axis == "five_hour":
|
|
633
|
+
return UNRETAINED_FIVE_HOUR_BLOCK
|
|
634
|
+
return "the window this alert describes is not addressable"
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def alert_next_step_command(axis, context, scope: AlertScope) -> "str | None":
|
|
638
|
+
"""The command that explains one alert axis, scoped to its own window.
|
|
639
|
+
|
|
640
|
+
Returns ``None`` when the scope is withheld, because every command below
|
|
641
|
+
needs the window the scope failed to derive — except the projected axis,
|
|
642
|
+
whose targets address the live window by construction and so carry no
|
|
643
|
+
window selector.
|
|
644
|
+
"""
|
|
645
|
+
ctx = context if isinstance(context, dict) else {}
|
|
646
|
+
if axis == "five_hour" and not _parse_instant(ctx.get("block_start_at")):
|
|
647
|
+
# The window still derives from the retained reset key, but the block
|
|
648
|
+
# row it names has been purged, so `--block-start` would select
|
|
649
|
+
# nothing. Knowing the window and being able to re-open its detail are
|
|
650
|
+
# different claims, and only the second one is what a command asserts.
|
|
651
|
+
return None
|
|
652
|
+
if axis == "projected":
|
|
653
|
+
if ctx.get("metric") in ("budget_usd", "codex_budget_usd"):
|
|
654
|
+
return LIVE_PERIOD_ONLY_COMMAND
|
|
655
|
+
return LIVE_WEEK_ONLY_COMMAND
|
|
656
|
+
if not scope.available:
|
|
657
|
+
return None
|
|
658
|
+
start = scope.window_start
|
|
659
|
+
end = scope.window_end
|
|
660
|
+
if axis == "weekly":
|
|
661
|
+
return f"cctally percent-breakdown --week-start {start:%Y-%m-%d}"
|
|
662
|
+
if axis == "five_hour":
|
|
663
|
+
return f"cctally five-hour-breakdown --block-start {start:%Y-%m-%dT%H:%M}"
|
|
664
|
+
if axis in ("budget", "codex_budget"):
|
|
665
|
+
return LIVE_PERIOD_ONLY_COMMAND
|
|
666
|
+
if axis == "project_budget":
|
|
667
|
+
# `--until` is inclusive, so the half-open end renders as the last
|
|
668
|
+
# calendar day the window touches.
|
|
669
|
+
last_day = inclusive_last_day(end)
|
|
670
|
+
project = ctx.get("project")
|
|
671
|
+
selector = f" --project {project}" if project else ""
|
|
672
|
+
return (
|
|
673
|
+
f"cctally project --since {start:%Y-%m-%d} "
|
|
674
|
+
f"--until {last_day:%Y-%m-%d}{selector}"
|
|
675
|
+
)
|
|
676
|
+
if axis == "quota":
|
|
677
|
+
root = ctx.get("source_root_key")
|
|
678
|
+
limit = ctx.get("logical_limit_key")
|
|
679
|
+
parts = [f"cctally codex quota breakdown --reset-at {end:%Y-%m-%dT%H:%M:%SZ}"]
|
|
680
|
+
if root:
|
|
681
|
+
parts.append(f"--root-key {root}")
|
|
682
|
+
if limit:
|
|
683
|
+
parts.append(f"--limit-key {limit}")
|
|
684
|
+
return " ".join(parts)
|
|
685
|
+
return None
|