cctally 1.100.0 → 1.102.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +8 -2
  3. package/bin/_cctally_alerts.py +13 -2
  4. package/bin/_cctally_cache.py +3 -1
  5. package/bin/_cctally_cache_report.py +103 -6
  6. package/bin/_cctally_dashboard.py +1140 -282
  7. package/bin/_cctally_dashboard_conversation.py +12 -0
  8. package/bin/_cctally_dashboard_envelope.py +53 -61
  9. package/bin/_cctally_dashboard_share.py +101 -29
  10. package/bin/_cctally_dashboard_sources.py +663 -192
  11. package/bin/_cctally_diagnosis.py +1172 -0
  12. package/bin/_cctally_diagnosis_sources.py +4054 -0
  13. package/bin/_cctally_diff.py +20 -0
  14. package/bin/_cctally_forecast.py +329 -111
  15. package/bin/_cctally_milestone_history.py +10 -2
  16. package/bin/_cctally_parser.py +84 -0
  17. package/bin/_cctally_project.py +155 -47
  18. package/bin/_cctally_quota.py +14 -0
  19. package/bin/_cctally_record.py +151 -71
  20. package/bin/_cctally_refresh.py +105 -93
  21. package/bin/_cctally_share.py +9 -2
  22. package/bin/_cctally_source_analytics.py +40 -4
  23. package/bin/_cctally_statusline.py +8 -1
  24. package/bin/_cctally_tui.py +425 -234
  25. package/bin/_lib_alert_scope.py +685 -0
  26. package/bin/_lib_alerts_payload.py +112 -7
  27. package/bin/_lib_blocks.py +12 -0
  28. package/bin/_lib_cache_report.py +110 -1
  29. package/bin/_lib_codex_conversation.py +14 -0
  30. package/bin/_lib_codex_conversation_query.py +22 -8
  31. package/bin/_lib_codex_pools.py +20 -8
  32. package/bin/_lib_conversation.py +6 -3
  33. package/bin/_lib_conversation_query.py +256 -69
  34. package/bin/_lib_dashboard_sources.py +212 -24
  35. package/bin/_lib_diagnosis.py +1261 -0
  36. package/bin/_lib_forecast.py +62 -4
  37. package/bin/_lib_perf.py +12 -0
  38. package/bin/_lib_pricing.py +8 -7
  39. package/bin/_lib_readme_refresh.py +26 -5
  40. package/bin/_lib_render.py +31 -3
  41. package/bin/_lib_share_templates.py +150 -55
  42. package/bin/_lib_snapshot_cache.py +71 -13
  43. package/bin/_lib_source_identity.py +50 -2
  44. package/bin/_lib_subscription_weeks.py +65 -0
  45. package/bin/cctally +103 -16
  46. package/bin/cctally-explain +5 -0
  47. package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +1 -0
  48. package/dashboard/static/assets/index-Di2hljvB.css +1 -0
  49. package/dashboard/static/assets/index-XYCIWjVG.js +97 -0
  50. package/dashboard/static/dashboard.html +2 -2
  51. package/package.json +6 -1
  52. package/dashboard/static/assets/index-B5YfQEtn.css +0 -1
  53. package/dashboard/static/assets/index-Bt59nMMO.js +0 -97
@@ -101,7 +101,14 @@ CapabilityStatus = Literal[
101
101
  # DESTRUCTIVE entry in this ledger rather than an additive one, and the null
102
102
  # stub is what makes it survivable across an in-place `execvp` update. Removing
103
103
  # the stub needs a separate change once no v9 bundle can be live.
104
- SOURCE_SCHEMA_VERSION = 10
104
+ # 10 -> 11 (#565): decorated providers now contribute certified per-account
105
+ # cycle legs to `sources.all.data.combined` instead of forcing
106
+ # `multi_account_unsupported`. Combined and leg `total_tokens` may be null when
107
+ # spend is certified but an equivalent account-scoped token operand is not;
108
+ # each leg gains `scope`, and account-cycle legs publish their exact account
109
+ # contribution periods. These fields change both shape and meaning, so the
110
+ # version moves even though undecorated arithmetic remains unchanged.
111
+ SOURCE_SCHEMA_VERSION = 11
105
112
  DEFAULT_SOURCE = "claude"
106
113
  SOURCE_ORDER = ("claude", "codex", "all")
107
114
  SOURCE_FRESHNESS_DOMAINS = ("hero", "quota", "sessions")
@@ -212,6 +219,10 @@ class SourceDashboardState:
212
219
  # would present as single-account and publish the one number §3.2 forbids,
213
220
  # on precisely the install where it is wrong.
214
221
  account_scope: Mapping[str, object] | None = None
222
+ # Server-only evidence used to compose decorated providers without
223
+ # re-deriving account truth from the public card shape. The carrier travels
224
+ # with the exact generation whose card operands it certifies.
225
+ combined_accounting: Mapping[str, object] | None = None
215
226
  # Request-gated transcript content. This mapping is frozen with the source
216
227
  # generation but is deliberately outside ``data``: source serialization
217
228
  # publishes only ``data``, then the HTTP/SSE envelope layer injects a label
@@ -280,6 +291,10 @@ class SourceDashboardState:
280
291
  object.__setattr__(self, "clock_data", _freeze(self.clock_data))
281
292
  if self.account_scope is not None:
282
293
  object.__setattr__(self, "account_scope", _freeze(self.account_scope))
294
+ if self.combined_accounting is not None:
295
+ object.__setattr__(
296
+ self, "combined_accounting", _freeze(self.combined_accounting),
297
+ )
283
298
  if self.aggregate_scope is not None:
284
299
  object.__setattr__(
285
300
  self, "aggregate_scope", _freeze(self.aggregate_scope),
@@ -402,6 +417,7 @@ def degrade_source_state(
402
417
  # it here would turn a transient provider failure into
403
418
  # `account_scope_unresolved` on an install whose count read fine.
404
419
  account_scope=prior.account_scope,
420
+ combined_accounting=prior.combined_accounting,
405
421
  private_session_labels=prior.private_session_labels,
406
422
  # #556 S2 §3.6: the carrier travels with the rows it describes. A
407
423
  # degraded generation retains `prior.data`, so it must retain the range
@@ -520,12 +536,12 @@ def _cause_message(cause: _CombinedCause) -> str:
520
536
  f"{provider}'s account count could not be read, so a combined "
521
537
  "total is withheld."
522
538
  )
523
- if cause.code == "multi_account_unsupported":
524
- count = detail.get("account_count")
525
- return (
526
- f"{provider} has {count} accounts on separate cycles, so a "
527
- "combined total is not published; see the per-account cards."
528
- )
539
+ if cause.code == "account_cost_unresolved":
540
+ return f"{provider} account spend could not be certified."
541
+ if cause.code == "account_cycle_unresolved":
542
+ return f"{provider} account cycle bounds could not be certified."
543
+ if cause.code == "account_reconciliation_failed":
544
+ return f"{provider} account cards did not reconcile with accounting."
529
545
  if cause.code == "claude_cycle_unresolved":
530
546
  return "Claude's current subscription week could not be resolved."
531
547
  if cause.code == "codex_projection_incoherent":
@@ -604,6 +620,149 @@ def _period_instant(value: object) -> str | None:
604
620
  return parsed.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z")
605
621
 
606
622
 
623
+ def _aware_period_instant(value: object) -> str | None:
624
+ """Canonicalize an explicitly aware bound; naive values fail closed."""
625
+ if not isinstance(value, str) or not value:
626
+ return None
627
+ try:
628
+ parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
629
+ except ValueError:
630
+ return None
631
+ if parsed.tzinfo is None or parsed.utcoffset() is None:
632
+ return None
633
+ return parsed.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z")
634
+
635
+
636
+ _ACCOUNT_PERIOD_KINDS: Mapping[PhysicalSource, frozenset[str]] = MappingProxyType({
637
+ "claude": frozenset({"subscription_week"}),
638
+ "codex": frozenset({"native_7_day_cycle", "trailing_7_day_cycle"}),
639
+ })
640
+
641
+
642
+ def _decorated_combined_leg(
643
+ state: SourceDashboardState,
644
+ provider: PhysicalSource,
645
+ ) -> tuple[Mapping[str, object] | None, tuple[_CombinedCause, ...]]:
646
+ """Validate a decorated provider's frozen card certificate at composition."""
647
+ carrier = state.combined_accounting
648
+ if not isinstance(carrier, Mapping):
649
+ return None, (_CombinedCause(
650
+ 5, provider, "account_reconciliation_failed"),)
651
+ if carrier.get("scope") != "account_cycles":
652
+ return None, (_CombinedCause(
653
+ 5, provider, "account_reconciliation_failed"),)
654
+ status = carrier.get("status")
655
+ if status == "unresolved":
656
+ cause = carrier.get("cause")
657
+ if cause == "account_cost_unresolved":
658
+ return None, (_CombinedCause(3, provider, cause),)
659
+ if cause == "account_cycle_unresolved":
660
+ return None, (_CombinedCause(4, provider, cause),)
661
+ return None, (_CombinedCause(
662
+ 5, provider, "account_reconciliation_failed"),)
663
+ if status != "certified":
664
+ return None, (_CombinedCause(
665
+ 5, provider, "account_reconciliation_failed"),)
666
+
667
+ data = state.data
668
+ cards = data.get("accounts") if isinstance(data, Mapping) else None
669
+ contributions = carrier.get("contributions")
670
+ if not isinstance(cards, (tuple, list)) or not isinstance(
671
+ contributions, (tuple, list)
672
+ ):
673
+ return None, (_CombinedCause(
674
+ 5, provider, "account_reconciliation_failed"),)
675
+
676
+ card_rows: list[tuple[str, float]] = []
677
+ for card in cards:
678
+ if not isinstance(card, Mapping):
679
+ return None, (_CombinedCause(
680
+ 5, provider, "account_reconciliation_failed"),)
681
+ key = card.get("accountKey")
682
+ cost = card.get("spendUsd")
683
+ if (
684
+ not isinstance(key, str) or not key
685
+ or _counter_reason(cost, integral=False) is not None
686
+ ):
687
+ return None, (_CombinedCause(
688
+ 5, provider, "account_reconciliation_failed"),)
689
+ card_rows.append((key, float(cost)))
690
+ card_keys = [key for key, _cost in card_rows]
691
+ if len(card_keys) != len(set(card_keys)):
692
+ return None, (_CombinedCause(
693
+ 5, provider, "account_reconciliation_failed"),)
694
+
695
+ public_rows: list[dict[str, object]] = []
696
+ contribution_keys: list[str] = []
697
+ for index, contribution in enumerate(contributions):
698
+ if not isinstance(contribution, Mapping):
699
+ return None, (_CombinedCause(
700
+ 5, provider, "account_reconciliation_failed"),)
701
+ key = contribution.get("account_key")
702
+ cost = contribution.get("cost_usd")
703
+ if (
704
+ not isinstance(key, str) or not key
705
+ or _counter_reason(cost, integral=False) is not None
706
+ ):
707
+ return None, (_CombinedCause(
708
+ 5, provider, "account_reconciliation_failed"),)
709
+ contribution_keys.append(key)
710
+ period = contribution.get("period")
711
+ if not isinstance(period, Mapping):
712
+ return None, (_CombinedCause(4, provider, "account_cycle_unresolved"),)
713
+ kind = period.get("kind")
714
+ start_at = _aware_period_instant(period.get("start_at"))
715
+ end_at = _aware_period_instant(period.get("end_at"))
716
+ start_instant = (
717
+ dt.datetime.fromisoformat(start_at.replace("Z", "+00:00"))
718
+ if start_at is not None else None
719
+ )
720
+ end_instant = (
721
+ dt.datetime.fromisoformat(end_at.replace("Z", "+00:00"))
722
+ if end_at is not None else None
723
+ )
724
+ if (
725
+ kind not in _ACCOUNT_PERIOD_KINDS[provider]
726
+ or start_instant is None or end_instant is None
727
+ or start_instant >= end_instant
728
+ ):
729
+ return None, (_CombinedCause(4, provider, "account_cycle_unresolved"),)
730
+ if index >= len(card_rows):
731
+ return None, (_CombinedCause(
732
+ 5, provider, "account_reconciliation_failed"),)
733
+ card_key, card_cost = card_rows[index]
734
+ if key != card_key or abs(float(cost) - card_cost) > 1e-9:
735
+ return None, (_CombinedCause(
736
+ 5, provider, "account_reconciliation_failed"),)
737
+ public_rows.append({
738
+ "account_key": key,
739
+ "cost_usd": card_cost,
740
+ "period": {
741
+ "kind": kind, "start_at": start_at, "end_at": end_at,
742
+ },
743
+ })
744
+ if contribution_keys != card_keys:
745
+ return None, (_CombinedCause(
746
+ 5, provider, "account_reconciliation_failed"),)
747
+
748
+ total = math.fsum(cost for _key, cost in card_rows)
749
+ hero = _combined_hero(state)
750
+ hero_cost = hero.get("cost_usd") if hero is not None else None
751
+ if (
752
+ _counter_reason(hero_cost, integral=False) is not None
753
+ or abs(float(hero_cost) - total) > 1e-9
754
+ ):
755
+ return None, (_CombinedCause(
756
+ 5, provider, "account_reconciliation_failed"),)
757
+ return {
758
+ "state": "current",
759
+ "scope": "account_cycles",
760
+ "cost_usd": total,
761
+ "total_tokens": None,
762
+ "accounts": tuple(public_rows),
763
+ }, ()
764
+
765
+
607
766
  def canonical_alerted_at(value: object) -> str:
608
767
  """Normalize an aware ISO-8601 firing instant to one UTC ``Z`` spelling.
609
768
 
@@ -710,7 +869,7 @@ def _combined_leg(
710
869
  else ("claude_cycle_unresolved",)
711
870
  )
712
871
  return None, tuple(
713
- _CombinedCause(4, provider, code) for code in codes
872
+ _CombinedCause(6, provider, code) for code in codes
714
873
  )
715
874
  cost = hero.get("cost_usd") if hero is not None else None
716
875
  tokens = hero.get("total_tokens") if hero is not None else None
@@ -718,15 +877,18 @@ def _combined_leg(
718
877
  if period is None and state.availability == "empty":
719
878
  # Numeric zeros and no period, so nothing presents `$0` as observed
720
879
  # spend inside a named cycle.
721
- return {"state": "empty", "cost_usd": 0.0, "total_tokens": 0}, ()
880
+ return {
881
+ "state": "empty", "scope": "provider_cycle",
882
+ "cost_usd": 0.0, "total_tokens": 0,
883
+ }, ()
722
884
  if provider == "claude" and cost is None and tokens is None:
723
885
  # Accounting exists but no subscription week resolved. Claude's hero
724
886
  # capability stays `supported` in that state, so this is its own
725
887
  # detection rather than the capability branch above.
726
- return None, (_CombinedCause(4, provider, "claude_cycle_unresolved"),)
888
+ return None, (_CombinedCause(6, provider, "claude_cycle_unresolved"),)
727
889
  causes = tuple(
728
890
  _CombinedCause(
729
- 5, provider, "invalid_counter", {"field": field, "reason": reason},
891
+ 7, provider, "invalid_counter", {"field": field, "reason": reason},
730
892
  )
731
893
  for field, reason in (
732
894
  ("cost_usd", _counter_reason(cost, integral=False)),
@@ -738,6 +900,7 @@ def _combined_leg(
738
900
  return None, causes
739
901
  return {
740
902
  "state": "current",
903
+ "scope": "provider_cycle",
741
904
  "cost_usd": float(cost),
742
905
  "total_tokens": int(tokens),
743
906
  **({"period": period} if period is not None else {}),
@@ -782,11 +945,10 @@ def _combined_outcome(
782
945
  ) -> tuple[Mapping[str, object] | None, Mapping[str, object] | None]:
783
946
  """Return ``(combined, combined_unavailable)`` — exactly one is not None.
784
947
 
785
- Cause precedence (§3.7), first match wins, Claude before Codex at equal
786
- precedence: provider incoherence, unresolved account scope, decoration, a
787
- hero capability outside {supported, derived} reported as the provider's own
788
- reason, then an invalid counter. Every cause found is listed, ordered so
789
- that `causes[0]` is always the winner.
948
+ Cause precedence (§6.5), first match wins, Claude before Codex at equal
949
+ precedence: provider incoherence, unresolved account scope, unresolved
950
+ account spend, unresolved account periods, reconciliation, provider-cycle
951
+ capability, then invalid counters.
790
952
  """
791
953
  pairs: tuple[tuple[PhysicalSource, SourceDashboardState], ...] = (
792
954
  ("claude", claude), ("codex", codex),
@@ -800,18 +962,18 @@ def _combined_outcome(
800
962
  if count is None:
801
963
  causes.append(
802
964
  _CombinedCause(2, provider, "account_scope_unresolved"))
803
- elif count > 1:
804
- causes.append(_CombinedCause(
805
- 3, provider, "multi_account_unsupported",
806
- {"account_count": count},
807
- ))
808
965
  legs: dict[str, Mapping[str, object]] = {}
809
966
  for provider, state in pairs:
810
967
  if not _coherent_provider(state):
811
968
  # An incoherent generation's data cannot be trusted to yield a leg
812
969
  # OR a leg-level cause; precedence 1 already withholds the figure.
813
970
  continue
814
- leg, leg_causes = _combined_leg(state, provider)
971
+ count = _real_account_count(state)
972
+ leg, leg_causes = (
973
+ _decorated_combined_leg(state, provider)
974
+ if count is not None and count > 1
975
+ else _combined_leg(state, provider)
976
+ )
815
977
  causes.extend(leg_causes)
816
978
  if leg is not None:
817
979
  legs[provider] = leg
@@ -835,9 +997,15 @@ def _combined_outcome(
835
997
  ),
836
998
  }
837
999
  qualifications = _combined_qualifications(legs, claude, codex)
1000
+ token_values = tuple(legs[provider]["total_tokens"] for provider in ("claude", "codex"))
838
1001
  return {
839
- "cost_usd": float(legs["claude"]["cost_usd"]) + float(legs["codex"]["cost_usd"]),
840
- "total_tokens": int(legs["claude"]["total_tokens"]) + int(legs["codex"]["total_tokens"]),
1002
+ "cost_usd": math.fsum(
1003
+ float(legs[provider]["cost_usd"]) for provider in ("claude", "codex")
1004
+ ),
1005
+ "total_tokens": (
1006
+ None if any(value is None for value in token_values)
1007
+ else sum(int(value) for value in token_values)
1008
+ ),
841
1009
  "legs": legs,
842
1010
  **({"qualifications": qualifications} if qualifications else {}),
843
1011
  }, None
@@ -980,6 +1148,26 @@ def aggregate_scope_identity(scope: object) -> str:
980
1148
  return "|".join(parts)
981
1149
 
982
1150
 
1151
+ def combined_accounting_version(
1152
+ base_version: str, carrier: Mapping[str, object] | None,
1153
+ ) -> str:
1154
+ """Bind a decorated source version to its frozen account certificate."""
1155
+ if carrier is None:
1156
+ return base_version
1157
+
1158
+ def _plain(value: object) -> object:
1159
+ if isinstance(value, Mapping):
1160
+ return {str(key): _plain(item) for key, item in value.items()}
1161
+ if isinstance(value, (tuple, list)):
1162
+ return [_plain(item) for item in value]
1163
+ return value
1164
+
1165
+ material = json.dumps(
1166
+ _plain(carrier), sort_keys=True, separators=(",", ":"),
1167
+ ).encode("utf-8")
1168
+ return f"{base_version}:c{hashlib.sha256(material).hexdigest()[:16]}"
1169
+
1170
+
983
1171
  def _aggregate_scope_range(state: SourceDashboardState) -> dict | None:
984
1172
  scope = getattr(state, "aggregate_scope", None)
985
1173
  if not isinstance(scope, Mapping):