cctally 1.96.1 → 1.97.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 +24 -0
- package/bin/_cctally_dashboard_envelope.py +14 -0
- package/bin/_cctally_dashboard_share.py +25 -5
- package/bin/_cctally_dashboard_sources.py +44 -23
- package/bin/_cctally_db.py +10 -0
- package/bin/_cctally_doctor.py +15 -10
- package/bin/_cctally_journal_repair.py +7 -6
- package/bin/_cctally_parser.py +47 -22
- package/bin/_cctally_tui.py +203 -34
- package/bin/_lib_dashboard_sources.py +376 -85
- package/bin/_lib_journal_router.py +14 -0
- package/bin/_lib_pricing.py +48 -38
- package/bin/_lib_share.py +191 -36
- package/bin/cctally +35 -1
- package/dashboard/static/assets/index-BSESoPIK.css +1 -0
- package/dashboard/static/assets/{index-Bhr5gZ14.js → index-DgsMz5hA.js} +55 -55
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-DfN_fsLZ.css +0 -1
|
@@ -36,7 +36,16 @@ CapabilityStatus = Literal[
|
|
|
36
36
|
# 3 -> 4 (#465): the Codex cache report retired its transitional
|
|
37
37
|
# `cache_hit_percent` alias and changed structurally inapplicable figures from
|
|
38
38
|
# numeric placeholders to null.
|
|
39
|
-
|
|
39
|
+
# 4 -> 5 (#556 S1): Claude's `hero.cost_usd` / `hero.total_tokens` changed
|
|
40
|
+
# MEANING — they were a thirty-day accounting rollup and are now current-cycle
|
|
41
|
+
# actuals, matching what the same-named Codex fields have always meant. The All
|
|
42
|
+
# source's `data.combined` also gained a required `legs` object and the optional
|
|
43
|
+
# `qualifications` / `combined_unavailable` companions, which supersedes normal-
|
|
44
|
+
# payload byte identity for this version (spec §3.5). No client branches on this
|
|
45
|
+
# number; after an in-place `execvp` update a still-loaded old client renders the
|
|
46
|
+
# new figure under old copy until it reloads, and that one-reconnect transient is
|
|
47
|
+
# accepted, consistent with the 2 -> 3 precedent above.
|
|
48
|
+
SOURCE_SCHEMA_VERSION = 5
|
|
40
49
|
DEFAULT_SOURCE = "claude"
|
|
41
50
|
SOURCE_ORDER = ("claude", "codex", "all")
|
|
42
51
|
SOURCE_FRESHNESS_DOMAINS = ("hero", "quota", "sessions")
|
|
@@ -133,6 +142,20 @@ class SourceDashboardState:
|
|
|
133
142
|
# They are deliberately separate from ``data`` so no internal accounting
|
|
134
143
|
# evidence becomes part of the public source-envelope contract.
|
|
135
144
|
clock_data: Mapping[str, object] | None = None
|
|
145
|
+
# #556 S1 §3.8 — the provider's authoritative REAL account count, resolved
|
|
146
|
+
# by the builder and carried here so `compose_all_state` can apply the
|
|
147
|
+
# single-account gate. Server-only, in the same class as `clock_data`:
|
|
148
|
+
# deliberately outside `data`, so no account cardinality enters the public
|
|
149
|
+
# source envelope. Shape: ``{"real_account_count": int}``.
|
|
150
|
+
#
|
|
151
|
+
# ``None`` means UNRESOLVED and must fail closed (withhold the combined
|
|
152
|
+
# figure), never "undecorated". Inferring decoration from the published
|
|
153
|
+
# `data.accounts` is forbidden for exactly this reason: both physical
|
|
154
|
+
# builders swallow a decoration-read failure and fall back to the
|
|
155
|
+
# undecorated shape, so a two-account install whose account read failed
|
|
156
|
+
# would present as single-account and publish the one number §3.2 forbids,
|
|
157
|
+
# on precisely the install where it is wrong.
|
|
158
|
+
account_scope: Mapping[str, object] | None = None
|
|
136
159
|
# Request-gated transcript content. This mapping is frozen with the source
|
|
137
160
|
# generation but is deliberately outside ``data``: source serialization
|
|
138
161
|
# publishes only ``data``, then the HTTP/SSE envelope layer injects a label
|
|
@@ -181,6 +204,8 @@ class SourceDashboardState:
|
|
|
181
204
|
object.__setattr__(self, "data", _freeze(self.data))
|
|
182
205
|
if self.clock_data is not None:
|
|
183
206
|
object.__setattr__(self, "clock_data", _freeze(self.clock_data))
|
|
207
|
+
if self.account_scope is not None:
|
|
208
|
+
object.__setattr__(self, "account_scope", _freeze(self.account_scope))
|
|
184
209
|
if self.private_session_labels is not None:
|
|
185
210
|
private_session_labels = {
|
|
186
211
|
_nonempty_string(key, "private session label key"):
|
|
@@ -295,6 +320,10 @@ def degrade_source_state(
|
|
|
295
320
|
domain: "stale" for domain in SOURCE_FRESHNESS_DOMAINS
|
|
296
321
|
},
|
|
297
322
|
clock_data=prior.clock_data,
|
|
323
|
+
# #556 S1 §3.8: a degraded generation must not LOSE the count. Dropping
|
|
324
|
+
# it here would turn a transient provider failure into
|
|
325
|
+
# `account_scope_unresolved` on an install whose count read fine.
|
|
326
|
+
account_scope=prior.account_scope,
|
|
298
327
|
private_session_labels=prior.private_session_labels,
|
|
299
328
|
)
|
|
300
329
|
|
|
@@ -365,69 +394,334 @@ def reuse_coherent_source_state(
|
|
|
365
394
|
return prior if _coherent_provider(prior) and prior.data_version == data_version else None
|
|
366
395
|
|
|
367
396
|
|
|
368
|
-
|
|
369
|
-
|
|
397
|
+
# === #556 S1 — the typed combined outcome (spec §3.5, §3.7) =================
|
|
398
|
+
#
|
|
399
|
+
# `combined` is the sum, over both providers, of that provider's accounting
|
|
400
|
+
# actuals within its OWN current cycle: Claude's subscription week and Codex's
|
|
401
|
+
# native 7-day cycle. The two legs are deliberately not one shared range — the
|
|
402
|
+
# property bought is that each leg reconciles with its provider tab, and each
|
|
403
|
+
# leg therefore names the cycle it covers.
|
|
370
404
|
|
|
371
|
-
|
|
372
|
-
|
|
405
|
+
_PROVIDER_LABELS: Mapping[str, str] = MappingProxyType(
|
|
406
|
+
{"claude": "Claude", "codex": "Codex"},
|
|
407
|
+
)
|
|
408
|
+
_LEG_PERIOD_KINDS: Mapping[str, tuple[str, str, str, str]] = MappingProxyType({
|
|
409
|
+
# provider -> (kind, label, hero container key, (start key, end key))
|
|
410
|
+
"claude": ("subscription_week", "Claude subscription week",
|
|
411
|
+
"current_week", "week_start_at|reset_at_utc"),
|
|
412
|
+
"codex": ("native_7_day_cycle", "Codex native 7-day cycle",
|
|
413
|
+
"cycle", "start_at|resets_at"),
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@dataclass(frozen=True)
|
|
418
|
+
class _CombinedCause:
|
|
419
|
+
"""One reason the combined figure is withheld, with its precedence rank."""
|
|
420
|
+
|
|
421
|
+
precedence: int
|
|
422
|
+
provider: PhysicalSource
|
|
423
|
+
code: str
|
|
424
|
+
detail: Mapping[str, object] | None = None
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _cause_message(cause: _CombinedCause) -> str:
|
|
428
|
+
"""Public-safe prose for one cause. Never echoes a rejected value."""
|
|
429
|
+
provider = _PROVIDER_LABELS.get(cause.provider, cause.provider)
|
|
430
|
+
detail = cause.detail or {}
|
|
431
|
+
if cause.code == "provider_incoherent":
|
|
432
|
+
return (
|
|
433
|
+
f"{provider} data is not current, so a combined total is withheld."
|
|
434
|
+
)
|
|
435
|
+
if cause.code == "account_scope_unresolved":
|
|
436
|
+
return (
|
|
437
|
+
f"{provider}'s account count could not be read, so a combined "
|
|
438
|
+
"total is withheld."
|
|
439
|
+
)
|
|
440
|
+
if cause.code == "multi_account_unsupported":
|
|
441
|
+
count = detail.get("account_count")
|
|
442
|
+
return (
|
|
443
|
+
f"{provider} has {count} accounts on separate cycles, so a "
|
|
444
|
+
"combined total is not published; see the per-account cards."
|
|
445
|
+
)
|
|
446
|
+
if cause.code == "claude_cycle_unresolved":
|
|
447
|
+
return "Claude's current subscription week could not be resolved."
|
|
448
|
+
if cause.code == "codex_projection_incoherent":
|
|
449
|
+
return "Codex quota projection is unavailable."
|
|
450
|
+
if cause.code == "codex_cycle_unavailable":
|
|
451
|
+
return "Codex native reset cycle is unavailable."
|
|
452
|
+
if cause.code == "invalid_counter":
|
|
453
|
+
return (
|
|
454
|
+
f"{provider} reported an unusable {detail.get('field')} counter "
|
|
455
|
+
f"({detail.get('reason')})."
|
|
456
|
+
)
|
|
457
|
+
return f"{provider} data cannot contribute to a combined total."
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _combined_hero(state: SourceDashboardState) -> Mapping[str, object] | None:
|
|
461
|
+
data = state.data
|
|
462
|
+
if not isinstance(data, Mapping):
|
|
463
|
+
return None
|
|
464
|
+
hero = data.get("hero")
|
|
465
|
+
return hero if isinstance(hero, Mapping) else None
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _real_account_count(state: SourceDashboardState) -> int | None:
|
|
469
|
+
"""The provider's authoritative REAL account count, or ``None``.
|
|
470
|
+
|
|
471
|
+
``None`` is UNRESOLVED and fails closed (§3.8). Decoration is never
|
|
472
|
+
inferred from published data, because both physical builders swallow a
|
|
473
|
+
decoration-read failure and fall back to the undecorated shape.
|
|
373
474
|
"""
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
475
|
+
scope = getattr(state, "account_scope", None)
|
|
476
|
+
if not isinstance(scope, Mapping):
|
|
477
|
+
return None
|
|
478
|
+
count = scope.get("real_account_count")
|
|
479
|
+
if isinstance(count, bool) or not isinstance(count, int) or count < 0:
|
|
480
|
+
return None
|
|
481
|
+
return count
|
|
380
482
|
|
|
381
483
|
|
|
382
|
-
def
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
484
|
+
def _counter_reason(value: object, *, integral: bool) -> str | None:
|
|
485
|
+
"""Return why ``value`` is unusable as a counter, or ``None`` if it is fine.
|
|
486
|
+
|
|
487
|
+
Booleans are rejected: ``True`` is an ``int`` in Python, and a flag that
|
|
488
|
+
leaked into a counter slot would otherwise be summed as 1.
|
|
489
|
+
"""
|
|
490
|
+
if value is None:
|
|
491
|
+
return "missing"
|
|
492
|
+
if isinstance(value, bool):
|
|
493
|
+
return "non_integer"
|
|
494
|
+
if integral:
|
|
495
|
+
if not isinstance(value, int):
|
|
496
|
+
return "non_integer"
|
|
497
|
+
else:
|
|
498
|
+
if not isinstance(value, (int, float)):
|
|
499
|
+
return "non_integer"
|
|
500
|
+
if not math.isfinite(value):
|
|
501
|
+
return "non_finite"
|
|
502
|
+
return "negative" if value < 0 else None
|
|
390
503
|
|
|
391
504
|
|
|
392
|
-
def
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
505
|
+
def _period_instant(value: object) -> str | None:
|
|
506
|
+
"""One canonical UTC spelling for a published period bound.
|
|
507
|
+
|
|
508
|
+
The two providers reach this with different spellings of the same
|
|
509
|
+
convention — Claude's bounds arrive from the legacy envelope's `_iso_z`
|
|
510
|
+
(`...Z`) and Codex's from `datetime.isoformat()` (`...+00:00`). Publishing
|
|
511
|
+
both would make every client parse two forms of one field for no reason.
|
|
512
|
+
"""
|
|
513
|
+
if not isinstance(value, str) or not value:
|
|
397
514
|
return None
|
|
398
|
-
# #359: the hero counters are backward-looking accounting actuals. A stale
|
|
399
|
-
# but still-live quota boundary pauses projections; it does not invalidate
|
|
400
|
-
# the retained cost/token sums that each provider already keeps visible.
|
|
401
|
-
# Composition therefore retains the compatible number and discloses the
|
|
402
|
-
# stale boundary through All's hero-domain freshness + local warning.
|
|
403
|
-
for state in (claude, codex):
|
|
404
|
-
hero_capability = state.capabilities.get("hero")
|
|
405
|
-
if hero_capability is None or hero_capability.status not in {"supported", "derived"}:
|
|
406
|
-
return None
|
|
407
515
|
try:
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
516
|
+
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
517
|
+
except ValueError:
|
|
518
|
+
return None
|
|
519
|
+
if parsed.tzinfo is None:
|
|
520
|
+
parsed = parsed.replace(tzinfo=dt.timezone.utc)
|
|
521
|
+
return parsed.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z")
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _leg_period(
|
|
525
|
+
provider: PhysicalSource, hero: Mapping[str, object] | None,
|
|
526
|
+
) -> Mapping[str, object] | None:
|
|
527
|
+
"""The named cycle a `current` leg covers, or ``None`` when unresolvable."""
|
|
528
|
+
kind, label, container_key, bound_keys = _LEG_PERIOD_KINDS[provider]
|
|
529
|
+
container = hero.get(container_key) if isinstance(hero, Mapping) else None
|
|
530
|
+
if not isinstance(container, Mapping):
|
|
531
|
+
return None
|
|
532
|
+
start_key, end_key = bound_keys.split("|")
|
|
533
|
+
start_at = _period_instant(container.get(start_key))
|
|
534
|
+
end_at = _period_instant(container.get(end_key))
|
|
535
|
+
if start_at is None or end_at is None:
|
|
424
536
|
return None
|
|
425
537
|
return {
|
|
426
|
-
"
|
|
427
|
-
"total_tokens": claude_tokens + codex_tokens,
|
|
538
|
+
"kind": kind, "label": label, "start_at": start_at, "end_at": end_at,
|
|
428
539
|
}
|
|
429
540
|
|
|
430
541
|
|
|
542
|
+
_CODEX_HERO_FAILURE_ORDER: tuple[str, ...] = (
|
|
543
|
+
"codex_projection_incoherent", "codex_cycle_unavailable",
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _codex_hero_failure_codes(state: SourceDashboardState) -> tuple[str, ...]:
|
|
548
|
+
"""EVERY Codex hero failure present, in the order §3.7 fixes.
|
|
549
|
+
|
|
550
|
+
Codex computes projection coherence and cycle resolution independently and
|
|
551
|
+
emits BOTH warnings, so §3.5 lists both as causes and §3.7 fixes their
|
|
552
|
+
order: an incoherent projection certificate invalidates the evidence the
|
|
553
|
+
cycle resolution rests on, and is therefore the earlier cause. The first
|
|
554
|
+
element is the winner, so the top-level `code` is unchanged by listing the
|
|
555
|
+
rest.
|
|
556
|
+
"""
|
|
557
|
+
codes = {warning.code for warning in state.warnings}
|
|
558
|
+
found = tuple(code for code in _CODEX_HERO_FAILURE_ORDER if code in codes)
|
|
559
|
+
if found:
|
|
560
|
+
return found
|
|
561
|
+
# No warning names the failure — fall back to the hero capability's own
|
|
562
|
+
# semantics, which can only describe one of the two.
|
|
563
|
+
capability = state.capabilities.get("hero")
|
|
564
|
+
semantics = capability.semantics if capability is not None else None
|
|
565
|
+
if semantics == "projection-incoherent":
|
|
566
|
+
return ("codex_projection_incoherent",)
|
|
567
|
+
return ("codex_cycle_unavailable",)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _combined_leg(
|
|
571
|
+
state: SourceDashboardState, provider: PhysicalSource,
|
|
572
|
+
) -> tuple[Mapping[str, object] | None, tuple[_CombinedCause, ...]]:
|
|
573
|
+
"""Build one leg, or the causes that stop it contributing.
|
|
574
|
+
|
|
575
|
+
A leg is `empty` when the provider reports no accounting AND no cycle:
|
|
576
|
+
`availability == "empty"` and no period resolves. Both halves are needed.
|
|
577
|
+
`availability` alone is not the fact, because it is computed over the
|
|
578
|
+
dashboard's VISIBLE range while Codex's hero is a separate cycle-bounded
|
|
579
|
+
read — a Codex provider can be `empty` in the visible range while its
|
|
580
|
+
current cycle holds real spend. An unresolved period alone is not the fact
|
|
581
|
+
either, because for Claude that is a FAILURE when accounting exists
|
|
582
|
+
(§3.7, "empty versus unresolved").
|
|
583
|
+
"""
|
|
584
|
+
hero = _combined_hero(state)
|
|
585
|
+
capability = state.capabilities.get("hero")
|
|
586
|
+
if capability is None or capability.status not in {"supported", "derived"}:
|
|
587
|
+
codes = (
|
|
588
|
+
_codex_hero_failure_codes(state) if provider == "codex"
|
|
589
|
+
else ("claude_cycle_unresolved",)
|
|
590
|
+
)
|
|
591
|
+
return None, tuple(
|
|
592
|
+
_CombinedCause(4, provider, code) for code in codes
|
|
593
|
+
)
|
|
594
|
+
cost = hero.get("cost_usd") if hero is not None else None
|
|
595
|
+
tokens = hero.get("total_tokens") if hero is not None else None
|
|
596
|
+
period = _leg_period(provider, hero)
|
|
597
|
+
if period is None and state.availability == "empty":
|
|
598
|
+
# Numeric zeros and no period, so nothing presents `$0` as observed
|
|
599
|
+
# spend inside a named cycle.
|
|
600
|
+
return {"state": "empty", "cost_usd": 0.0, "total_tokens": 0}, ()
|
|
601
|
+
if provider == "claude" and cost is None and tokens is None:
|
|
602
|
+
# Accounting exists but no subscription week resolved. Claude's hero
|
|
603
|
+
# capability stays `supported` in that state, so this is its own
|
|
604
|
+
# detection rather than the capability branch above.
|
|
605
|
+
return None, (_CombinedCause(4, provider, "claude_cycle_unresolved"),)
|
|
606
|
+
causes = tuple(
|
|
607
|
+
_CombinedCause(
|
|
608
|
+
5, provider, "invalid_counter", {"field": field, "reason": reason},
|
|
609
|
+
)
|
|
610
|
+
for field, reason in (
|
|
611
|
+
("cost_usd", _counter_reason(cost, integral=False)),
|
|
612
|
+
("total_tokens", _counter_reason(tokens, integral=True)),
|
|
613
|
+
)
|
|
614
|
+
if reason is not None
|
|
615
|
+
)
|
|
616
|
+
if causes:
|
|
617
|
+
return None, causes
|
|
618
|
+
return {
|
|
619
|
+
"state": "current",
|
|
620
|
+
"cost_usd": float(cost),
|
|
621
|
+
"total_tokens": int(tokens),
|
|
622
|
+
**({"period": period} if period is not None else {}),
|
|
623
|
+
}, ()
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _combined_qualifications(
|
|
627
|
+
legs: Mapping[str, Mapping[str, object]],
|
|
628
|
+
claude: SourceDashboardState,
|
|
629
|
+
codex: SourceDashboardState,
|
|
630
|
+
) -> tuple[Mapping[str, object], ...]:
|
|
631
|
+
"""Notes that qualify a PUBLISHED figure (§4.3). Empty means omit the key."""
|
|
632
|
+
qualifications: list[Mapping[str, object]] = []
|
|
633
|
+
for provider in ("claude", "codex"):
|
|
634
|
+
if legs[provider].get("state") == "empty":
|
|
635
|
+
label = _PROVIDER_LABELS[provider]
|
|
636
|
+
qualifications.append({
|
|
637
|
+
"code": "provider_empty",
|
|
638
|
+
"message": f"{label} has no accounting in its current cycle.",
|
|
639
|
+
"provider": provider,
|
|
640
|
+
})
|
|
641
|
+
# public #5: the Codex ingest backlog is LIFTED here rather than read from
|
|
642
|
+
# the provider field by the All surfaces, so the figure and its disclosure
|
|
643
|
+
# cannot disagree. The provider field stays published for the Codex tab.
|
|
644
|
+
data = codex.data
|
|
645
|
+
backlog = data.get("ingest_backlog") if isinstance(data, Mapping) else None
|
|
646
|
+
if isinstance(backlog, Mapping) and backlog:
|
|
647
|
+
qualifications.append({
|
|
648
|
+
"code": "codex_ingest_backlog",
|
|
649
|
+
"message": (
|
|
650
|
+
"Codex has pending accounting to ingest, so its cycle total "
|
|
651
|
+
"may be incomplete."
|
|
652
|
+
),
|
|
653
|
+
"provider": "codex",
|
|
654
|
+
})
|
|
655
|
+
return tuple(qualifications)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _combined_outcome(
|
|
659
|
+
claude: SourceDashboardState,
|
|
660
|
+
codex: SourceDashboardState,
|
|
661
|
+
) -> tuple[Mapping[str, object] | None, Mapping[str, object] | None]:
|
|
662
|
+
"""Return ``(combined, combined_unavailable)`` — exactly one is not None.
|
|
663
|
+
|
|
664
|
+
Cause precedence (§3.7), first match wins, Claude before Codex at equal
|
|
665
|
+
precedence: provider incoherence, unresolved account scope, decoration, a
|
|
666
|
+
hero capability outside {supported, derived} reported as the provider's own
|
|
667
|
+
reason, then an invalid counter. Every cause found is listed, ordered so
|
|
668
|
+
that `causes[0]` is always the winner.
|
|
669
|
+
"""
|
|
670
|
+
pairs: tuple[tuple[PhysicalSource, SourceDashboardState], ...] = (
|
|
671
|
+
("claude", claude), ("codex", codex),
|
|
672
|
+
)
|
|
673
|
+
causes: list[_CombinedCause] = []
|
|
674
|
+
for provider, state in pairs:
|
|
675
|
+
if not _coherent_provider(state):
|
|
676
|
+
causes.append(_CombinedCause(1, provider, "provider_incoherent"))
|
|
677
|
+
for provider, state in pairs:
|
|
678
|
+
count = _real_account_count(state)
|
|
679
|
+
if count is None:
|
|
680
|
+
causes.append(
|
|
681
|
+
_CombinedCause(2, provider, "account_scope_unresolved"))
|
|
682
|
+
elif count > 1:
|
|
683
|
+
causes.append(_CombinedCause(
|
|
684
|
+
3, provider, "multi_account_unsupported",
|
|
685
|
+
{"account_count": count},
|
|
686
|
+
))
|
|
687
|
+
legs: dict[str, Mapping[str, object]] = {}
|
|
688
|
+
for provider, state in pairs:
|
|
689
|
+
if not _coherent_provider(state):
|
|
690
|
+
# An incoherent generation's data cannot be trusted to yield a leg
|
|
691
|
+
# OR a leg-level cause; precedence 1 already withholds the figure.
|
|
692
|
+
continue
|
|
693
|
+
leg, leg_causes = _combined_leg(state, provider)
|
|
694
|
+
causes.extend(leg_causes)
|
|
695
|
+
if leg is not None:
|
|
696
|
+
legs[provider] = leg
|
|
697
|
+
if causes:
|
|
698
|
+
ordered = sorted(
|
|
699
|
+
causes,
|
|
700
|
+
key=lambda cause: (
|
|
701
|
+
cause.precedence, 0 if cause.provider == "claude" else 1,
|
|
702
|
+
),
|
|
703
|
+
)
|
|
704
|
+
return None, {
|
|
705
|
+
"code": ordered[0].code,
|
|
706
|
+
"message": _cause_message(ordered[0]),
|
|
707
|
+
"causes": tuple(
|
|
708
|
+
{
|
|
709
|
+
"provider": cause.provider,
|
|
710
|
+
"code": cause.code,
|
|
711
|
+
**({"detail": cause.detail} if cause.detail else {}),
|
|
712
|
+
}
|
|
713
|
+
for cause in ordered
|
|
714
|
+
),
|
|
715
|
+
}
|
|
716
|
+
qualifications = _combined_qualifications(legs, claude, codex)
|
|
717
|
+
return {
|
|
718
|
+
"cost_usd": float(legs["claude"]["cost_usd"]) + float(legs["codex"]["cost_usd"]),
|
|
719
|
+
"total_tokens": int(legs["claude"]["total_tokens"]) + int(legs["codex"]["total_tokens"]),
|
|
720
|
+
"legs": legs,
|
|
721
|
+
**({"qualifications": qualifications} if qualifications else {}),
|
|
722
|
+
}, None
|
|
723
|
+
|
|
724
|
+
|
|
431
725
|
def _combined_alert_rows(
|
|
432
726
|
claude: SourceDashboardState,
|
|
433
727
|
codex: SourceDashboardState,
|
|
@@ -461,35 +755,18 @@ def compose_all_state(
|
|
|
461
755
|
"""Compose provider-labeled sections without inventing blended semantics."""
|
|
462
756
|
if claude.source != "claude" or codex.source != "codex":
|
|
463
757
|
raise ValueError("all composition requires Claude and Codex provider states")
|
|
464
|
-
combined =
|
|
758
|
+
combined, combined_unavailable = _combined_outcome(claude, codex)
|
|
465
759
|
providers_coherent = _coherent_provider(claude) and _coherent_provider(codex)
|
|
466
|
-
stale_cycle_providers = (
|
|
467
|
-
_stale_cycle_providers(claude, codex) if providers_coherent else ()
|
|
468
|
-
)
|
|
469
|
-
# #359: the warning qualifies a retained combined actual. It stays
|
|
470
|
-
# All-local and keeps the composed source partial so the header status also
|
|
471
|
-
# names the caveat; provider envelopes remain independently coherent.
|
|
472
|
-
all_local_warnings: tuple[SourceDashboardWarning, ...] = ()
|
|
473
|
-
if stale_cycle_providers:
|
|
474
|
-
all_local_warnings = (SourceDashboardWarning(
|
|
475
|
-
"combined_totals_stale",
|
|
476
|
-
f"{' and '.join(stale_cycle_providers)} quota evidence is stale; "
|
|
477
|
-
"combined totals use retained actuals.",
|
|
478
|
-
"hero",
|
|
479
|
-
),)
|
|
480
760
|
if providers_coherent:
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
"
|
|
486
|
-
if
|
|
487
|
-
else
|
|
488
|
-
"empty"
|
|
489
|
-
if claude.availability == "empty" and codex.availability == "empty"
|
|
490
|
-
else "ok"
|
|
491
|
-
)
|
|
761
|
+
availability: Availability = (
|
|
762
|
+
"partial"
|
|
763
|
+
if combined is None or "partial" in (claude.availability, codex.availability)
|
|
764
|
+
else (
|
|
765
|
+
"empty"
|
|
766
|
+
if claude.availability == "empty" and codex.availability == "empty"
|
|
767
|
+
else "ok"
|
|
492
768
|
)
|
|
769
|
+
)
|
|
493
770
|
freshness: Freshness = "fresh"
|
|
494
771
|
else:
|
|
495
772
|
availability = "partial"
|
|
@@ -506,22 +783,33 @@ def compose_all_state(
|
|
|
506
783
|
source_domain_freshness(codex, domain)
|
|
507
784
|
for domain in SOURCE_FRESHNESS_DOMAINS
|
|
508
785
|
],
|
|
509
|
-
|
|
786
|
+
# #556 S1: the WHOLE outcome, not merely whether one exists. The
|
|
787
|
+
# legs, their periods, the qualifications and the withheld cause
|
|
788
|
+
# are all published, and the account scope and hero counters that
|
|
789
|
+
# produce them are not otherwise in this material — so hashing only
|
|
790
|
+
# `combined is not None` would leave materially different All
|
|
791
|
+
# states sharing one `data_version` (invariant 6).
|
|
792
|
+
combined, combined_unavailable,
|
|
510
793
|
],
|
|
511
794
|
separators=(",", ":"),
|
|
795
|
+
sort_keys=True,
|
|
512
796
|
).encode("utf-8")
|
|
513
797
|
data_version = "all:" + hashlib.sha256(version_material).hexdigest()[:24]
|
|
514
|
-
successes = (
|
|
515
|
-
|
|
798
|
+
successes = (claude.last_success_at, codex.last_success_at)
|
|
799
|
+
# §4.6: `None` unless BOTH providers have one, otherwise the older.
|
|
800
|
+
# Filtering `None` before `min` let one provider's success masquerade as
|
|
801
|
+
# All's while the client keys "no successful snapshot yet" on null.
|
|
802
|
+
last_success_at = None if None in successes else min(successes)
|
|
516
803
|
return SourceDashboardState(
|
|
517
804
|
source="all",
|
|
518
805
|
availability=availability,
|
|
519
806
|
freshness=freshness,
|
|
520
|
-
# All-
|
|
521
|
-
#
|
|
522
|
-
# `
|
|
523
|
-
#
|
|
524
|
-
|
|
807
|
+
# §4.2: the All-local `combined_totals_stale` warning is retired. The
|
|
808
|
+
# combined figure's own disclosure now travels in `data.combined`
|
|
809
|
+
# (`qualifications`) and `data.combined_unavailable`, which is typed and
|
|
810
|
+
# has provenance — All flattens both providers' warnings into one tuple
|
|
811
|
+
# with no provenance field, so warning order could never carry it.
|
|
812
|
+
warnings=tuple((*claude.warnings, *codex.warnings)),
|
|
525
813
|
data_version=data_version,
|
|
526
814
|
last_success_at=last_success_at,
|
|
527
815
|
capabilities={
|
|
@@ -532,6 +820,9 @@ def compose_all_state(
|
|
|
532
820
|
},
|
|
533
821
|
data={
|
|
534
822
|
"combined": combined,
|
|
823
|
+
# Emitted iff the figure is withheld; omitted-when-inapplicable.
|
|
824
|
+
**({"combined_unavailable": combined_unavailable}
|
|
825
|
+
if combined is None else {}),
|
|
535
826
|
"alerts": {"rows": _combined_alert_rows(claude, codex)},
|
|
536
827
|
"providers": {
|
|
537
828
|
"claude": claude.data,
|
|
@@ -27,6 +27,20 @@ import hashlib
|
|
|
27
27
|
RETAINED_RECORD_TYPES = frozenset({"evt", "correction", "correction_batch", "op"})
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
def selector_slot(record):
|
|
31
|
+
"""Return one position-preserving input slot for the shared selector.
|
|
32
|
+
|
|
33
|
+
Every successfully decoded journal line consumes one sequence number in
|
|
34
|
+
``resolve_effective_events``. Decision records therefore stay decoded,
|
|
35
|
+
while observations and other irrelevant records become ``None`` rather
|
|
36
|
+
than being dropped (which renumbers durable protocol fingerprints) or kept
|
|
37
|
+
as dictionaries (which makes memory follow observation volume).
|
|
38
|
+
"""
|
|
39
|
+
if record.get("t") in RETAINED_RECORD_TYPES:
|
|
40
|
+
return record
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
30
44
|
class LastSeenAccumulator:
|
|
31
45
|
"""Reproduce `_derive_account_last_seen`'s contribution set from a stream.
|
|
32
46
|
|