cctally 1.80.4 → 1.82.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 +32 -0
- package/bin/_cctally_account.py +397 -0
- package/bin/_cctally_alerts.py +58 -2
- package/bin/_cctally_cache.py +696 -166
- package/bin/_cctally_cache_report.py +21 -3
- package/bin/_cctally_config.py +119 -8
- package/bin/_cctally_core.py +343 -48
- package/bin/_cctally_dashboard.py +32 -7
- package/bin/_cctally_dashboard_conversation.py +6 -2
- package/bin/_cctally_dashboard_envelope.py +49 -0
- package/bin/_cctally_dashboard_share.py +78 -1
- package/bin/_cctally_dashboard_sources.py +434 -48
- package/bin/_cctally_db.py +659 -152
- package/bin/_cctally_diff.py +9 -0
- package/bin/_cctally_doctor.py +201 -26
- package/bin/_cctally_five_hour.py +110 -69
- package/bin/_cctally_forecast.py +112 -43
- package/bin/_cctally_journal.py +591 -80
- package/bin/_cctally_milestones.py +180 -67
- package/bin/_cctally_parser.py +87 -0
- package/bin/_cctally_percent_breakdown.py +24 -15
- package/bin/_cctally_project.py +12 -1
- package/bin/_cctally_quota.py +150 -39
- package/bin/_cctally_record.py +491 -141
- package/bin/_cctally_reporting.py +66 -14
- package/bin/_cctally_setup.py +9 -1
- package/bin/_cctally_source_analytics.py +56 -7
- package/bin/_cctally_statusline.py +56 -8
- package/bin/_cctally_store.py +20 -8
- package/bin/_cctally_sync_week.py +30 -10
- package/bin/_cctally_tui.py +99 -18
- package/bin/_cctally_weekrefs.py +38 -15
- package/bin/_lib_accounts.py +325 -0
- package/bin/_lib_alerts_payload.py +25 -4
- package/bin/_lib_cache_writer_lock.py +77 -0
- package/bin/_lib_codex_hooks.py +40 -1
- package/bin/_lib_diff_kernel.py +28 -14
- package/bin/_lib_doctor.py +160 -0
- package/bin/_lib_journal.py +65 -2
- package/bin/_lib_pricing.py +27 -3
- package/bin/_lib_quota.py +81 -4
- package/bin/_lib_render.py +19 -5
- package/bin/_lib_share.py +25 -0
- package/bin/_lib_snapshot_cache.py +8 -0
- package/bin/_lib_subscription_weeks.py +16 -2
- package/bin/_lib_view_models.py +18 -9
- package/bin/cctally +43 -4
- package/dashboard/static/assets/index-DJP4gEB7.js +92 -0
- package/dashboard/static/assets/index-Dk1nplOz.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-BzWujBS3.js +0 -92
- package/dashboard/static/assets/index-Cc2ykqF_.css +0 -1
|
@@ -29,6 +29,7 @@ from _cctally_source_analytics import (
|
|
|
29
29
|
load_qualified_codex_entries,
|
|
30
30
|
)
|
|
31
31
|
import _lib_log
|
|
32
|
+
import _lib_accounts
|
|
32
33
|
from _lib_dashboard_sources import (
|
|
33
34
|
CapabilityRecord,
|
|
34
35
|
ProjectionCoherence,
|
|
@@ -69,6 +70,47 @@ DASHBOARD_QUOTA_OBSERVATION_LIMIT = 1000
|
|
|
69
70
|
DASHBOARD_QUOTA_RECENT_DAYS = 35
|
|
70
71
|
|
|
71
72
|
|
|
73
|
+
def accounts_identity_digest(stats_conn: sqlite3.Connection) -> str:
|
|
74
|
+
"""Digest of the account registry + the providers' live active-account state
|
|
75
|
+
(#341 spec §4 finding 9 — the identity-only invalidation signal).
|
|
76
|
+
|
|
77
|
+
Empty when NO account has ever been observed (the ``accounts`` registry is
|
|
78
|
+
empty): every <=1-account install and every pre-accounts fixture is
|
|
79
|
+
byte-neutral (the caller never appends an empty digest to a version/idle
|
|
80
|
+
signature), and no identity-file read happens on the idle path. Once accounts
|
|
81
|
+
exist it folds together (a) each registry row's ``account_key`` / provider /
|
|
82
|
+
label / label_source — catching a new account observed or a label edit — and
|
|
83
|
+
(b) the CURRENTLY-active account keys (``resolve_active_account_keys`` reads
|
|
84
|
+
``~/.claude.json`` + each Codex root's ``auth.json``, stable-read + best
|
|
85
|
+
effort) — catching an in-place account SWITCH with zero new ingested rows.
|
|
86
|
+
Folded into both the outer dispatch/idle signature and each physical source's
|
|
87
|
+
``data_version``, so a switch rebuilds the source state (flipping the
|
|
88
|
+
``active`` marker) on the very next tick without any new rows.
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
row = stats_conn.execute("SELECT COUNT(*) FROM accounts").fetchone()
|
|
92
|
+
except sqlite3.Error:
|
|
93
|
+
return ""
|
|
94
|
+
if not row or not row[0]:
|
|
95
|
+
return "" # no account ever observed -> byte-stable, no identity I/O
|
|
96
|
+
parts: list[str] = []
|
|
97
|
+
try:
|
|
98
|
+
for r in stats_conn.execute(
|
|
99
|
+
"SELECT account_key, provider, label, label_source FROM accounts "
|
|
100
|
+
"ORDER BY provider, account_key"
|
|
101
|
+
):
|
|
102
|
+
parts.append(f"{r[0]}|{r[1]}|{r[2] or ''}|{r[3] or ''}")
|
|
103
|
+
except sqlite3.Error:
|
|
104
|
+
pass
|
|
105
|
+
try:
|
|
106
|
+
import _cctally_account
|
|
107
|
+
active = sorted(_cctally_account.resolve_active_account_keys())
|
|
108
|
+
except Exception:
|
|
109
|
+
active = []
|
|
110
|
+
parts.append("active\x1f" + ",".join(active))
|
|
111
|
+
return hashlib.sha256("\x00".join(parts).encode("utf-8")).hexdigest()[:16]
|
|
112
|
+
|
|
113
|
+
|
|
72
114
|
class CodexCycleUnavailable(RuntimeError):
|
|
73
115
|
"""No single active native seven-day boundary can bound hero accounting."""
|
|
74
116
|
|
|
@@ -128,50 +170,78 @@ def _is_model_scoped_codex_quota(logical_limit_key: object) -> bool:
|
|
|
128
170
|
def _resolve_codex_weekly_cycle(
|
|
129
171
|
observations: Iterable[object],
|
|
130
172
|
now_utc: dt.datetime,
|
|
131
|
-
) -> CodexCycleBoundary:
|
|
132
|
-
"""
|
|
133
|
-
|
|
134
|
-
|
|
173
|
+
) -> list[CodexCycleBoundary]:
|
|
174
|
+
"""Resolve one active 10,080-minute native cycle PER ACCOUNT (#341 spec §4).
|
|
175
|
+
|
|
176
|
+
Returns a list with one :class:`CodexCycleBoundary` per account that has
|
|
177
|
+
exactly one active weekly boundary — N simultaneously-active account cycles
|
|
178
|
+
are each valid instead of collapsing to ``CodexCycleUnavailable("conflicting")``.
|
|
179
|
+
``conflicting`` now fires only for a genuine conflict WITHIN one account.
|
|
180
|
+
Raises ``CodexCycleUnavailable`` only when NO account yields a live cycle. A
|
|
181
|
+
single-account install returns a 1-element list = today's boundary
|
|
182
|
+
byte-for-byte (the hero path is unchanged, spec R8).
|
|
183
|
+
"""
|
|
184
|
+
per_account: dict[str, dict[tuple[int, dt.datetime], list[tuple[object, object]]]] = {}
|
|
185
|
+
accounts_seen: set[str] = set()
|
|
186
|
+
stale_by_account: dict[str, bool] = {}
|
|
135
187
|
for history in build_history(tuple(observations)):
|
|
136
188
|
if history.identity.window_minutes != 10_080:
|
|
137
189
|
continue
|
|
138
190
|
if _is_model_scoped_codex_quota(history.identity.logical_limit_key):
|
|
139
191
|
continue
|
|
192
|
+
account = history.identity.account_key
|
|
193
|
+
accounts_seen.add(account)
|
|
140
194
|
baseline = select_baseline(history.observations, now_utc)
|
|
141
195
|
if baseline is None or baseline.resets_at <= now_utc:
|
|
142
196
|
continue
|
|
143
197
|
if quota_freshness(history.physical_observations, now_utc).state != "fresh":
|
|
144
|
-
|
|
198
|
+
stale_by_account[account] = True
|
|
145
199
|
continue
|
|
146
200
|
boundary = (history.identity.window_minutes, baseline.resets_at)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
201
|
+
per_account.setdefault(account, {}).setdefault(boundary, []).append((history, baseline))
|
|
202
|
+
cycles: list[CodexCycleBoundary] = []
|
|
203
|
+
reasons: list[str] = []
|
|
204
|
+
for account in sorted(accounts_seen | set(per_account)):
|
|
205
|
+
boundaries = per_account.get(account, {})
|
|
206
|
+
if len(boundaries) != 1:
|
|
207
|
+
# Within one account: 0 boundaries -> stale/missing; >=2 -> conflicting.
|
|
208
|
+
reasons.append(
|
|
209
|
+
"conflicting" if boundaries
|
|
210
|
+
else ("stale" if stale_by_account.get(account) else "missing")
|
|
211
|
+
)
|
|
212
|
+
continue
|
|
213
|
+
(window_minutes, resets_at), candidates = next(iter(boundaries.items()))
|
|
214
|
+
# Preserve the existing hero's max-used-percent choice, then pin every
|
|
215
|
+
# remaining tie deterministically. The selected full identity—not the
|
|
216
|
+
# union of sibling roots/slots/limits—owns the account's hero cycle.
|
|
217
|
+
history, baseline = max(
|
|
218
|
+
candidates,
|
|
219
|
+
key=lambda item: (
|
|
220
|
+
float(item[1].used_percent),
|
|
221
|
+
item[1].captured_at.astimezone(UTC),
|
|
222
|
+
item[0].identity.source_root_key,
|
|
223
|
+
item[0].identity.logical_limit_key,
|
|
224
|
+
item[0].identity.observed_slot,
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
selected_identity = history.identity
|
|
228
|
+
cycles.append(CodexCycleBoundary(
|
|
229
|
+
window_minutes=window_minutes,
|
|
230
|
+
start_at=resets_at - dt.timedelta(minutes=window_minutes),
|
|
231
|
+
resets_at=resets_at,
|
|
232
|
+
source_root_keys=(selected_identity.source_root_key,),
|
|
233
|
+
used_percent=float(baseline.used_percent),
|
|
234
|
+
quota_identity=selected_identity,
|
|
235
|
+
))
|
|
236
|
+
if not cycles:
|
|
237
|
+
# Aggregate reason: for a single account this is exactly the old reason
|
|
238
|
+
# (byte-stable); across accounts, conflicting > stale > missing.
|
|
239
|
+
if "conflicting" in reasons:
|
|
240
|
+
raise CodexCycleUnavailable("conflicting")
|
|
241
|
+
if "stale" in reasons:
|
|
242
|
+
raise CodexCycleUnavailable("stale")
|
|
243
|
+
raise CodexCycleUnavailable("missing")
|
|
244
|
+
return cycles
|
|
175
245
|
|
|
176
246
|
|
|
177
247
|
def _codex_weekly_periods(
|
|
@@ -432,13 +502,27 @@ def _public_copy(value: object) -> object:
|
|
|
432
502
|
return value
|
|
433
503
|
|
|
434
504
|
|
|
435
|
-
def source_detail_lookup(
|
|
505
|
+
def source_detail_lookup(
|
|
506
|
+
bundle: object, source: str, resource: str, key: str,
|
|
507
|
+
account: "str | None" = None,
|
|
508
|
+
) -> dict[str, object]:
|
|
436
509
|
"""Find one provider-owned opaque row without I/O, ingest, or fallback.
|
|
437
510
|
|
|
438
511
|
The handler has already parsed the fixed route grammar. This adapter only
|
|
439
512
|
reads the frozen bundle published by the dashboard owner thread, so a
|
|
440
513
|
request cannot accidentally trigger cache sync, rollout parsing, or a
|
|
441
514
|
Claude fallback for a Codex key.
|
|
515
|
+
|
|
516
|
+
Server-side account row-ownership (#341 Task 4, spec §4 finding 10).
|
|
517
|
+
``account`` is the qualifier the modal captured with ``(source, account)`` at
|
|
518
|
+
open. When it is provided AND any row matching ``key`` carries an
|
|
519
|
+
``account_key`` (the decorated wire shape — two accounts can share one opaque
|
|
520
|
+
resource key), the server VERIFIES ownership: it returns the row owned by
|
|
521
|
+
``account`` and NEVER a different account's row — a key resolving only to
|
|
522
|
+
another account raises ``SourceResourceNotFound`` rather than leaking it.
|
|
523
|
+
Account-agnostic rows (no ``account_key``: sessions/projects per Decision R4,
|
|
524
|
+
or any undecorated source) ignore the qualifier and match by key alone, so
|
|
525
|
+
the undecorated path stays byte-identical.
|
|
442
526
|
"""
|
|
443
527
|
if source not in ("claude", "codex") or resource not in _RESOURCE_ROWS:
|
|
444
528
|
raise SourceCapabilityUnavailable()
|
|
@@ -454,10 +538,23 @@ def source_detail_lookup(bundle: object, source: str, resource: str, key: str) -
|
|
|
454
538
|
rows = data[domain][rows_key]
|
|
455
539
|
except (KeyError, TypeError) as exc:
|
|
456
540
|
raise SourceCapabilityUnavailable() from exc
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
541
|
+
key_matches = [
|
|
542
|
+
row for row in rows
|
|
543
|
+
if isinstance(row, Mapping) and row.get("key") == key
|
|
544
|
+
]
|
|
545
|
+
if not key_matches:
|
|
546
|
+
raise SourceResourceNotFound()
|
|
547
|
+
if account is not None:
|
|
548
|
+
# Any key-match that carries an account_key is account-scoped: enforce
|
|
549
|
+
# ownership so a fetch qualified to account X can never fall through to
|
|
550
|
+
# account Y's row sharing the same opaque key.
|
|
551
|
+
scoped = [row for row in key_matches if "account_key" in row]
|
|
552
|
+
if scoped:
|
|
553
|
+
owned = [row for row in scoped if row.get("account_key") == account]
|
|
554
|
+
if not owned:
|
|
555
|
+
raise SourceResourceNotFound()
|
|
556
|
+
return _public_copy(owned[0]) # type: ignore[return-value]
|
|
557
|
+
return _public_copy(key_matches[0]) # type: ignore[return-value]
|
|
461
558
|
|
|
462
559
|
|
|
463
560
|
def codex_projection_coherence(
|
|
@@ -1200,6 +1297,18 @@ def _quota_read_model(
|
|
|
1200
1297
|
history_rows: list[dict[str, object]] = []
|
|
1201
1298
|
milestone_rows: list[dict[str, object]] = []
|
|
1202
1299
|
active_rows: list[dict[str, object]] = []
|
|
1300
|
+
# R8 (#341 Task 4): the per-account `account_key` is serialized onto each
|
|
1301
|
+
# history row ONLY when the Codex provider has >1 REAL account, so the idle
|
|
1302
|
+
# clock (`_clock_cycle_validity`) can scope weekly-cycle validity per account
|
|
1303
|
+
# instead of degrading a genuine multi-account state to `conflicting`. A
|
|
1304
|
+
# <=1-real-account install (all fixtures) stays byte-identical (no key added).
|
|
1305
|
+
_codex_decorated = False
|
|
1306
|
+
try:
|
|
1307
|
+
import _cctally_account
|
|
1308
|
+
_codex_decorated = _cctally_account.provider_is_decorated(
|
|
1309
|
+
context.stats_conn, "codex")
|
|
1310
|
+
except Exception:
|
|
1311
|
+
_codex_decorated = False
|
|
1203
1312
|
for history in histories:
|
|
1204
1313
|
identity = history.identity
|
|
1205
1314
|
key_parts = (
|
|
@@ -1214,6 +1323,7 @@ def _quota_read_model(
|
|
|
1214
1323
|
history_rows.append({
|
|
1215
1324
|
"key": dashboard_resource_key("quota", "codex", *key_parts),
|
|
1216
1325
|
"source": "codex",
|
|
1326
|
+
**({"account_key": identity.account_key} if _codex_decorated else {}),
|
|
1217
1327
|
"label": _native_limit_label(identity.limit_name, identity.window_minutes),
|
|
1218
1328
|
"observed_slot": identity.observed_slot,
|
|
1219
1329
|
"window_minutes": identity.window_minutes,
|
|
@@ -1395,9 +1505,23 @@ def _clock_cycle_validity(
|
|
|
1395
1505
|
histories: Iterable[object],
|
|
1396
1506
|
now_utc: dt.datetime,
|
|
1397
1507
|
) -> tuple[bool, str]:
|
|
1398
|
-
"""Re-evaluate frozen weekly evidence without touching cache or rollouts.
|
|
1399
|
-
|
|
1400
|
-
|
|
1508
|
+
"""Re-evaluate frozen weekly evidence without touching cache or rollouts.
|
|
1509
|
+
|
|
1510
|
+
Per-account (#341 Task 4): boundaries are grouped by the history row's
|
|
1511
|
+
``account_key`` (serialized only when the Codex provider is DECORATED, i.e.
|
|
1512
|
+
>1 real account — R8). The idle clock mirrors the build-time
|
|
1513
|
+
``_resolve_codex_weekly_cycle`` resolution: an account yields a live cycle iff
|
|
1514
|
+
it has EXACTLY ONE fresh future boundary, and the hero is valid iff AT LEAST
|
|
1515
|
+
ONE account does (raising the aggregate ``conflicting > stale > missing``
|
|
1516
|
+
reason only when NO account yields one). When ``account_key`` is absent
|
|
1517
|
+
(<=1-real-account install — no decoration) every row falls into one global
|
|
1518
|
+
bucket, so this reduces EXACTLY to the prior single-boundary logic
|
|
1519
|
+
(byte-stable). This removes the documented placeholder degrade where two real
|
|
1520
|
+
accounts with distinct weekly cycles collapsed the whole hero to
|
|
1521
|
+
``conflicting`` on the idle clock.
|
|
1522
|
+
"""
|
|
1523
|
+
per_account: dict[str, set[dt.datetime]] = {}
|
|
1524
|
+
stale_by_account: dict[str, bool] = {}
|
|
1401
1525
|
for raw_history in histories:
|
|
1402
1526
|
if not isinstance(raw_history, Mapping):
|
|
1403
1527
|
continue
|
|
@@ -1415,15 +1539,29 @@ def _clock_cycle_validity(
|
|
|
1415
1539
|
continue
|
|
1416
1540
|
if resets_at <= now_utc:
|
|
1417
1541
|
continue
|
|
1542
|
+
# None account_key (undecorated / <=1 real account) -> one global bucket
|
|
1543
|
+
# == today's behavior. A real key buckets per account.
|
|
1544
|
+
acct = raw_history.get("account_key")
|
|
1545
|
+
bucket = acct if acct is not None else "__all__"
|
|
1418
1546
|
if raw_history.get("freshness") != "fresh":
|
|
1419
|
-
|
|
1547
|
+
stale_by_account[bucket] = True
|
|
1420
1548
|
continue
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1549
|
+
per_account.setdefault(bucket, set()).add(resets_at)
|
|
1550
|
+
accounts = set(per_account) | set(stale_by_account)
|
|
1551
|
+
reasons: list[str] = []
|
|
1552
|
+
for acct in accounts:
|
|
1553
|
+
boundaries = per_account.get(acct, set())
|
|
1554
|
+
if len(boundaries) == 1:
|
|
1555
|
+
return True, "ok" # at least one account yields a live cycle
|
|
1556
|
+
reasons.append(
|
|
1557
|
+
"conflicting" if boundaries
|
|
1558
|
+
else ("stale" if stale_by_account.get(acct) else "missing")
|
|
1559
|
+
)
|
|
1560
|
+
if "conflicting" in reasons:
|
|
1561
|
+
return False, "conflicting"
|
|
1562
|
+
if "stale" in reasons:
|
|
1425
1563
|
return False, "stale"
|
|
1426
|
-
return False, "missing"
|
|
1564
|
+
return False, "missing"
|
|
1427
1565
|
|
|
1428
1566
|
|
|
1429
1567
|
def _refresh_budget_status_clock(
|
|
@@ -1953,6 +2091,218 @@ def _build_codex_native_weekly_view(
|
|
|
1953
2091
|
)
|
|
1954
2092
|
|
|
1955
2093
|
|
|
2094
|
+
def _codex_account_five_hour_percent(
|
|
2095
|
+
observations: Iterable[object],
|
|
2096
|
+
now_utc: dt.datetime,
|
|
2097
|
+
) -> dict[str, float]:
|
|
2098
|
+
"""Per-account current five-hour (300-minute) used-percent (#341 Task 4).
|
|
2099
|
+
|
|
2100
|
+
Account key -> the highest active-window used-percent among that account's
|
|
2101
|
+
fresh sibling 300-minute windows. Used to render the per-account hero card's
|
|
2102
|
+
5h bar; account-blind physical breakdown readers are untouched.
|
|
2103
|
+
"""
|
|
2104
|
+
result: dict[str, float] = {}
|
|
2105
|
+
for history in build_history(tuple(observations)):
|
|
2106
|
+
if history.identity.window_minutes != 300:
|
|
2107
|
+
continue
|
|
2108
|
+
baseline = select_baseline(history.observations, now_utc)
|
|
2109
|
+
if baseline is None or baseline.resets_at <= now_utc:
|
|
2110
|
+
continue
|
|
2111
|
+
acct = history.identity.account_key
|
|
2112
|
+
pct = float(baseline.used_percent)
|
|
2113
|
+
if acct not in result or pct > result[acct]:
|
|
2114
|
+
result[acct] = pct
|
|
2115
|
+
return result
|
|
2116
|
+
|
|
2117
|
+
|
|
2118
|
+
def _codex_accounts_wire(
|
|
2119
|
+
context: DashboardReadContext,
|
|
2120
|
+
*,
|
|
2121
|
+
quota_observations: Iterable[object],
|
|
2122
|
+
cycles: list["CodexCycleBoundary"],
|
|
2123
|
+
accounting_start: dt.datetime,
|
|
2124
|
+
accounting_end: dt.datetime,
|
|
2125
|
+
) -> tuple[list[dict[str, object]], list[dict[str, object]]]:
|
|
2126
|
+
"""Return ``(accounts_wire, hero_cycles_wire)`` for a decorated Codex source.
|
|
2127
|
+
|
|
2128
|
+
Caller must gate on ``provider_is_decorated(stats_conn, "codex")`` — this
|
|
2129
|
+
builds nothing for a <=1-real-account install (the whole surface is absent,
|
|
2130
|
+
so the envelope stays byte-identical, spec R8). Each account carries
|
|
2131
|
+
``{accountKey, label, plan, active, weeklyPercent, fiveHourPercent, resetsAt,
|
|
2132
|
+
spendUsd, inputTokens, cachedInputTokens, outputTokens,
|
|
2133
|
+
reasoningOutputTokens, totalTokens, unattributed?}``; ``hero_cycles_wire`` is
|
|
2134
|
+
the thin per-account cycle-boundary list the hero renders (``cycles[]``).
|
|
2135
|
+
"""
|
|
2136
|
+
import _cctally_account
|
|
2137
|
+
active_keys = _cctally_account.resolve_active_account_keys()
|
|
2138
|
+
five_hour = _codex_account_five_hour_percent(quota_observations, context.now_utc)
|
|
2139
|
+
cycle_by_account: dict[str, "CodexCycleBoundary"] = {}
|
|
2140
|
+
for cyc in cycles:
|
|
2141
|
+
acct = (
|
|
2142
|
+
cyc.quota_identity.account_key if cyc.quota_identity is not None
|
|
2143
|
+
else _lib_accounts.UNATTRIBUTED
|
|
2144
|
+
)
|
|
2145
|
+
cycle_by_account.setdefault(acct, cyc)
|
|
2146
|
+
# Registry accounts (real, deterministically ordered) + the unattributed
|
|
2147
|
+
# sentinel when it has any retained accounting (it renders dimmed, totals
|
|
2148
|
+
# only). Registry rows never include the sentinel.
|
|
2149
|
+
reg = _cctally_account.load_accounts(context.stats_conn, "codex")
|
|
2150
|
+
plan_by_key = {r["account_key"]: r.get("plan_type") for r in reg}
|
|
2151
|
+
ordered_keys = [r["account_key"] for r in reg]
|
|
2152
|
+
# Include unattributed last iff it has cycle/5h/spend evidence.
|
|
2153
|
+
unattributed_rows = load_cached_rooted_codex_accounting_entries(
|
|
2154
|
+
accounting_start, accounting_end, speed=context.speed,
|
|
2155
|
+
cache_conn=context.cache_conn, account_key=_lib_accounts.UNATTRIBUTED,
|
|
2156
|
+
)
|
|
2157
|
+
if (
|
|
2158
|
+
unattributed_rows
|
|
2159
|
+
or _lib_accounts.UNATTRIBUTED in cycle_by_account
|
|
2160
|
+
or _lib_accounts.UNATTRIBUTED in five_hour
|
|
2161
|
+
):
|
|
2162
|
+
ordered_keys.append(_lib_accounts.UNATTRIBUTED)
|
|
2163
|
+
|
|
2164
|
+
def _totals(rows: tuple[object, ...]) -> dict[str, object]:
|
|
2165
|
+
entries = _codex_entries_from_accounting(rows)
|
|
2166
|
+
cost = build_codex_daily_view(
|
|
2167
|
+
entries, now_utc=context.now_utc, tz_name=context.display_tz_name,
|
|
2168
|
+
speed=context.speed,
|
|
2169
|
+
).total_cost_usd if entries else 0.0
|
|
2170
|
+
return {
|
|
2171
|
+
"spendUsd": cost,
|
|
2172
|
+
"inputTokens": sum(e.input_tokens for e in entries),
|
|
2173
|
+
"cachedInputTokens": sum(e.cached_input_tokens for e in entries),
|
|
2174
|
+
"outputTokens": sum(e.output_tokens for e in entries),
|
|
2175
|
+
"reasoningOutputTokens": sum(e.reasoning_output_tokens for e in entries),
|
|
2176
|
+
"totalTokens": sum(e.total_tokens for e in entries),
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
accounts_wire: list[dict[str, object]] = []
|
|
2180
|
+
hero_cycles_wire: list[dict[str, object]] = []
|
|
2181
|
+
for key in ordered_keys:
|
|
2182
|
+
cyc = cycle_by_account.get(key)
|
|
2183
|
+
is_unattributed = key == _lib_accounts.UNATTRIBUTED
|
|
2184
|
+
if cyc is not None and not is_unattributed:
|
|
2185
|
+
cycle_end = min(accounting_end, cyc.resets_at)
|
|
2186
|
+
rows = load_cached_rooted_codex_accounting_entries(
|
|
2187
|
+
cyc.start_at, cycle_end, speed=context.speed,
|
|
2188
|
+
cache_conn=context.cache_conn,
|
|
2189
|
+
source_root_keys=cyc.source_root_keys, account_key=key,
|
|
2190
|
+
)
|
|
2191
|
+
totals = _totals(rows)
|
|
2192
|
+
elif is_unattributed:
|
|
2193
|
+
totals = _totals(unattributed_rows)
|
|
2194
|
+
else:
|
|
2195
|
+
# A real account without a live weekly cycle: totals over the
|
|
2196
|
+
# accounting range so the card still shows spend (no bars/reset).
|
|
2197
|
+
rows = load_cached_rooted_codex_accounting_entries(
|
|
2198
|
+
accounting_start, accounting_end, speed=context.speed,
|
|
2199
|
+
cache_conn=context.cache_conn, account_key=key,
|
|
2200
|
+
)
|
|
2201
|
+
totals = _totals(rows)
|
|
2202
|
+
card: dict[str, object] = {
|
|
2203
|
+
"accountKey": key,
|
|
2204
|
+
"label": _cctally_account.account_label(context.stats_conn, key),
|
|
2205
|
+
"plan": plan_by_key.get(key),
|
|
2206
|
+
"active": key in active_keys,
|
|
2207
|
+
"weeklyPercent": (
|
|
2208
|
+
None if is_unattributed or cyc is None else cyc.used_percent
|
|
2209
|
+
),
|
|
2210
|
+
"fiveHourPercent": (None if is_unattributed else five_hour.get(key)),
|
|
2211
|
+
"resetsAt": (
|
|
2212
|
+
None if is_unattributed or cyc is None
|
|
2213
|
+
else cyc.resets_at.astimezone(UTC).isoformat()
|
|
2214
|
+
),
|
|
2215
|
+
**totals,
|
|
2216
|
+
}
|
|
2217
|
+
if is_unattributed:
|
|
2218
|
+
card["unattributed"] = True
|
|
2219
|
+
accounts_wire.append(card)
|
|
2220
|
+
if cyc is not None and not is_unattributed:
|
|
2221
|
+
hero_cycles_wire.append({
|
|
2222
|
+
"accountKey": key,
|
|
2223
|
+
"window_minutes": cyc.window_minutes,
|
|
2224
|
+
"start_at": cyc.start_at.astimezone(UTC).isoformat(),
|
|
2225
|
+
"resets_at": cyc.resets_at.astimezone(UTC).isoformat(),
|
|
2226
|
+
"used_percent": cyc.used_percent,
|
|
2227
|
+
"cost_usd": totals["spendUsd"],
|
|
2228
|
+
"total_tokens": totals["totalTokens"],
|
|
2229
|
+
})
|
|
2230
|
+
return accounts_wire, hero_cycles_wire
|
|
2231
|
+
|
|
2232
|
+
|
|
2233
|
+
def _claude_accounts_wire(
|
|
2234
|
+
stats_conn: sqlite3.Connection,
|
|
2235
|
+
*,
|
|
2236
|
+
now_utc: dt.datetime,
|
|
2237
|
+
) -> list[dict[str, object]]:
|
|
2238
|
+
"""Per-account Claude hero cards (#341 Task 4, Ruling C).
|
|
2239
|
+
|
|
2240
|
+
Symmetric with ``_codex_accounts_wire``: the caller gates on
|
|
2241
|
+
``provider_is_decorated(stats_conn, "claude")`` (>1 REAL account, R8), so a
|
|
2242
|
+
<=1-real-account install builds nothing and its envelope stays byte-identical
|
|
2243
|
+
on BOTH goldens. Each card carries
|
|
2244
|
+
``{accountKey, label, plan, active, weeklyPercent, fiveHourPercent, resetsAt,
|
|
2245
|
+
spendUsd, unattributed?}`` drawn from the ALREADY-account-scoped stats
|
|
2246
|
+
snapshots (``weekly_usage_snapshots``/``weekly_cost_snapshots`` both hold
|
|
2247
|
+
``account_key`` — Section 6 scope matrix), taking each account's latest
|
|
2248
|
+
captured row as its current-cycle state. spendUsd is the snapshotted weekly
|
|
2249
|
+
cost (the ``report`` semantics), account-scoped. The unattributed bucket
|
|
2250
|
+
renders last, dimmed/totals-only, iff it has any retained snapshot.
|
|
2251
|
+
"""
|
|
2252
|
+
import _cctally_account
|
|
2253
|
+
active_keys = _cctally_account.resolve_active_account_keys()
|
|
2254
|
+
reg = _cctally_account.load_accounts(stats_conn, "claude")
|
|
2255
|
+
plan_by_key = {r["account_key"]: r.get("plan_type") for r in reg}
|
|
2256
|
+
ordered_keys = [r["account_key"] for r in reg]
|
|
2257
|
+
|
|
2258
|
+
def _latest_usage(key: str):
|
|
2259
|
+
return stats_conn.execute(
|
|
2260
|
+
"SELECT weekly_percent, five_hour_percent, week_end_at "
|
|
2261
|
+
"FROM weekly_usage_snapshots WHERE account_key=? "
|
|
2262
|
+
"ORDER BY captured_at_utc DESC LIMIT 1",
|
|
2263
|
+
(key,),
|
|
2264
|
+
).fetchone()
|
|
2265
|
+
|
|
2266
|
+
def _latest_cost(key: str) -> float:
|
|
2267
|
+
row = stats_conn.execute(
|
|
2268
|
+
"SELECT cost_usd FROM weekly_cost_snapshots WHERE account_key=? "
|
|
2269
|
+
"ORDER BY captured_at_utc DESC LIMIT 1",
|
|
2270
|
+
(key,),
|
|
2271
|
+
).fetchone()
|
|
2272
|
+
return float(row[0]) if row is not None and row[0] is not None else 0.0
|
|
2273
|
+
|
|
2274
|
+
# Include the unattributed bucket last iff it retained any snapshot.
|
|
2275
|
+
unattr_usage = _latest_usage(_lib_accounts.UNATTRIBUTED)
|
|
2276
|
+
unattr_cost = stats_conn.execute(
|
|
2277
|
+
"SELECT 1 FROM weekly_cost_snapshots WHERE account_key=? LIMIT 1",
|
|
2278
|
+
(_lib_accounts.UNATTRIBUTED,),
|
|
2279
|
+
).fetchone()
|
|
2280
|
+
if unattr_usage is not None or unattr_cost is not None:
|
|
2281
|
+
ordered_keys.append(_lib_accounts.UNATTRIBUTED)
|
|
2282
|
+
|
|
2283
|
+
cards: list[dict[str, object]] = []
|
|
2284
|
+
for key in ordered_keys:
|
|
2285
|
+
is_unattributed = key == _lib_accounts.UNATTRIBUTED
|
|
2286
|
+
usage = _latest_usage(key)
|
|
2287
|
+
weekly_pct = usage[0] if usage is not None else None
|
|
2288
|
+
five_hour_pct = usage[1] if usage is not None else None
|
|
2289
|
+
resets_at = usage[2] if usage is not None else None
|
|
2290
|
+
card: dict[str, object] = {
|
|
2291
|
+
"accountKey": key,
|
|
2292
|
+
"label": _cctally_account.account_label(stats_conn, key),
|
|
2293
|
+
"plan": plan_by_key.get(key),
|
|
2294
|
+
"active": key in active_keys,
|
|
2295
|
+
"weeklyPercent": None if is_unattributed else weekly_pct,
|
|
2296
|
+
"fiveHourPercent": None if is_unattributed else five_hour_pct,
|
|
2297
|
+
"resetsAt": None if is_unattributed else resets_at,
|
|
2298
|
+
"spendUsd": _latest_cost(key),
|
|
2299
|
+
}
|
|
2300
|
+
if is_unattributed:
|
|
2301
|
+
card["unattributed"] = True
|
|
2302
|
+
cards.append(card)
|
|
2303
|
+
return cards
|
|
2304
|
+
|
|
2305
|
+
|
|
1956
2306
|
def build_codex_source_state(
|
|
1957
2307
|
context: DashboardReadContext,
|
|
1958
2308
|
*,
|
|
@@ -2037,8 +2387,16 @@ def build_codex_source_state(
|
|
|
2037
2387
|
)
|
|
2038
2388
|
budget_entries = _codex_entries_from_accounting(accounting_entries)
|
|
2039
2389
|
cycle_reason: str | None = None
|
|
2390
|
+
cycles_all: list[CodexCycleBoundary] = []
|
|
2040
2391
|
try:
|
|
2041
|
-
|
|
2392
|
+
# Per-account list (#341 Task 2). ``cycles_all`` drives the per-account
|
|
2393
|
+
# hero cards (Task 4, gated on decoration); ``cycle`` stays the first
|
|
2394
|
+
# account's boundary (sorted by account_key) as the interim single hero —
|
|
2395
|
+
# for a single-account install this IS today's single boundary
|
|
2396
|
+
# (byte-stable), and a multi-account install no longer degrades to
|
|
2397
|
+
# `conflicting`.
|
|
2398
|
+
cycles_all = _resolve_codex_weekly_cycle(quota_observations, context.now_utc)
|
|
2399
|
+
cycle = cycles_all[0] if cycles_all else None
|
|
2042
2400
|
except CodexCycleUnavailable as exc:
|
|
2043
2401
|
cycle = None
|
|
2044
2402
|
cycle_reason = exc.reason
|
|
@@ -2176,6 +2534,32 @@ def build_codex_source_state(
|
|
|
2176
2534
|
"Codex native reset cycle is unavailable.",
|
|
2177
2535
|
"hero",
|
|
2178
2536
|
))
|
|
2537
|
+
# #341 Task 4: the conditional per-account wire. Built ONLY when the Codex
|
|
2538
|
+
# provider has >1 REAL account (R8) — a <=1-real-account install adds nothing
|
|
2539
|
+
# so its envelope is byte-identical to today. The array ships EVERY account's
|
|
2540
|
+
# projection (client-side chip filter); the hero renders per-account cards.
|
|
2541
|
+
accounts_wire: list[dict[str, object]] = []
|
|
2542
|
+
hero_cycles_wire: list[dict[str, object]] = []
|
|
2543
|
+
try:
|
|
2544
|
+
import _cctally_account
|
|
2545
|
+
_codex_decorated = _cctally_account.provider_is_decorated(
|
|
2546
|
+
context.stats_conn, "codex")
|
|
2547
|
+
except Exception:
|
|
2548
|
+
_codex_decorated = False
|
|
2549
|
+
if _codex_decorated:
|
|
2550
|
+
try:
|
|
2551
|
+
accounts_wire, hero_cycles_wire = _codex_accounts_wire(
|
|
2552
|
+
context,
|
|
2553
|
+
quota_observations=quota_observations,
|
|
2554
|
+
cycles=cycles_all,
|
|
2555
|
+
accounting_start=accounting_start,
|
|
2556
|
+
accounting_end=accounting_end,
|
|
2557
|
+
)
|
|
2558
|
+
except (sqlite3.Error, QualifiedMetadataUnavailable):
|
|
2559
|
+
# A per-account wire failure must never fail the whole source build;
|
|
2560
|
+
# degrade to the byte-stable undecorated shape.
|
|
2561
|
+
accounts_wire = []
|
|
2562
|
+
hero_cycles_wire = []
|
|
2179
2563
|
return SourceDashboardState(
|
|
2180
2564
|
source="codex",
|
|
2181
2565
|
availability=availability,
|
|
@@ -2228,7 +2612,9 @@ def build_codex_source_state(
|
|
|
2228
2612
|
"quota": quota["summary"],
|
|
2229
2613
|
"budget": configured_budget,
|
|
2230
2614
|
"alerts": {"count": len(alerts)},
|
|
2615
|
+
**({"cycles": hero_cycles_wire} if _codex_decorated else {}),
|
|
2231
2616
|
},
|
|
2617
|
+
**({"accounts": accounts_wire} if _codex_decorated else {}),
|
|
2232
2618
|
"periods": {
|
|
2233
2619
|
"daily": _period_wire(daily),
|
|
2234
2620
|
"monthly": _period_wire(monthly),
|