cctally 1.73.0 → 1.75.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.
@@ -272,8 +272,9 @@ def unavailable_source_state(
272
272
 
273
273
  def _coherent_provider(state: SourceDashboardState) -> bool:
274
274
  return (
275
- state.availability in ("ok", "empty")
275
+ state.availability in ("ok", "empty", "partial")
276
276
  and state.freshness == "fresh"
277
+ and bool(state.data_version)
277
278
  and state.data is not None
278
279
  )
279
280
 
@@ -302,6 +303,10 @@ def _combined_metrics(
302
303
  ) -> Mapping[str, object] | None:
303
304
  if not (_coherent_provider(claude) and _coherent_provider(codex)):
304
305
  return None
306
+ for state in (claude, codex):
307
+ hero_capability = state.capabilities.get("hero")
308
+ if hero_capability is None or hero_capability.status not in {"supported", "derived"}:
309
+ return None
305
310
  try:
306
311
  claude_hero = claude.data["hero"]
307
312
  codex_hero = codex.data["hero"]
@@ -360,19 +365,27 @@ def compose_all_state(
360
365
  if claude.source != "claude" or codex.source != "codex":
361
366
  raise ValueError("all composition requires Claude and Codex provider states")
362
367
  combined = _combined_metrics(claude, codex)
363
- coherent = combined is not None
364
- if coherent:
368
+ providers_coherent = _coherent_provider(claude) and _coherent_provider(codex)
369
+ if providers_coherent:
365
370
  availability: Availability = (
366
- "empty"
367
- if claude.availability == "empty" and codex.availability == "empty"
368
- else "ok"
371
+ "partial"
372
+ if combined is None or "partial" in (claude.availability, codex.availability)
373
+ else (
374
+ "empty"
375
+ if claude.availability == "empty" and codex.availability == "empty"
376
+ else "ok"
377
+ )
369
378
  )
370
379
  freshness: Freshness = "fresh"
371
380
  else:
372
381
  availability = "partial"
373
382
  freshness = "stale"
374
383
  version_material = json.dumps(
375
- [claude.data_version, codex.data_version, coherent],
384
+ [
385
+ claude.data_version, claude.availability, claude.freshness,
386
+ codex.data_version, codex.availability, codex.freshness,
387
+ combined is not None,
388
+ ],
376
389
  separators=(",", ":"),
377
390
  ).encode("utf-8")
378
391
  data_version = "all:" + hashlib.sha256(version_material).hexdigest()[:24]
@@ -218,6 +218,12 @@ class DoctorState:
218
218
  # arbitration pipeline. None means the gather could not inspect it;
219
219
  # absent transport/tombstones are normal when Claude is closed.
220
220
  statusline_pipeline: Optional[dict] = None
221
+ # #312: all-history, root-qualified Codex accounting metadata partition.
222
+ # The I/O layer reports an explicit error instead of silently converting a
223
+ # failed health query into zero rows, so doctor can distinguish an empty
224
+ # retained corpus from unreadable metadata.
225
+ codex_project_metadata_health: Optional[dict] = None
226
+ codex_project_metadata_error: Optional[str] = None
221
227
 
222
228
 
223
229
  @dataclasses.dataclass(frozen=True)
@@ -876,6 +882,43 @@ def _check_data_codex_cache(s: DoctorState) -> CheckResult:
876
882
  )
877
883
 
878
884
 
885
+ def _check_data_codex_project_metadata(s: DoctorState) -> CheckResult:
886
+ """Report the identity-safe all-history Codex metadata partition."""
887
+ if s.codex_project_metadata_error is not None:
888
+ return CheckResult(
889
+ id="data.codex_project_metadata", title="Codex project metadata",
890
+ severity="fail", summary="metadata health could not be read",
891
+ remediation="Run `cctally doctor --verbose` after checking cache.db.",
892
+ details={"error": s.codex_project_metadata_error},
893
+ )
894
+
895
+ health = s.codex_project_metadata_health or {}
896
+ total_rows = int(health.get("total_rows", 0))
897
+ qualified_rows = int(health.get("qualified_rows", 0))
898
+ missing_key_rows = int(health.get("missing_conversation_key_rows", 0))
899
+ missing_join_rows = int(health.get("missing_thread_join_rows", 0))
900
+ incomplete_rows = missing_key_rows + missing_join_rows
901
+ details = {
902
+ "total_rows": total_rows,
903
+ "qualified_rows": qualified_rows,
904
+ "missing_conversation_key_rows": missing_key_rows,
905
+ "missing_thread_join_rows": missing_join_rows,
906
+ "incomplete_rows": incomplete_rows,
907
+ }
908
+ if incomplete_rows:
909
+ return CheckResult(
910
+ id="data.codex_project_metadata", title="Codex project metadata",
911
+ severity="warn",
912
+ summary=f"{incomplete_rows} accounting row(s) need metadata repair",
913
+ remediation="Run `cctally cache-sync --source codex --rebuild`.",
914
+ details=details,
915
+ )
916
+ return CheckResult(
917
+ id="data.codex_project_metadata", title="Codex project metadata",
918
+ severity="ok", summary="qualified", remediation=None, details=details,
919
+ )
920
+
921
+
879
922
  def _check_data_codex_quota(s: DoctorState) -> CheckResult:
880
923
  """Report local physical Codex quota evidence without fabricating a window."""
881
924
  raw_windows = s.codex_quota_windows or []
@@ -1607,10 +1650,10 @@ def _check_db_integrity(s: DoctorState) -> CheckResult:
1607
1650
  summary=f"stats.db quick_check: {s.stats_db_quick_check}",
1608
1651
  remediation=(
1609
1652
  "stats.db (the non-re-derivable DB) reports corruption. "
1610
- "Back up the file FIRST, then try "
1611
- "`sqlite3 <path> \".recover\"` into a fresh file; "
1612
- "`cctally db recover` does not repair corruption. Please "
1613
- "file a bug. Do not delete the file."),
1653
+ "Stop the dashboard and other cctally processes, then run "
1654
+ "`cctally db repair --db stats --yes`. The command preserves "
1655
+ "a backup of the corrupt original before replacing anything. "
1656
+ "Do not copy, restore, move, or delete the live DB by hand."),
1614
1657
  details=details,
1615
1658
  )
1616
1659
  if s.cache_db_quick_check is not None and s.cache_db_quick_check != "ok":
@@ -1722,6 +1765,7 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
1722
1765
  ("data.statusline_pipeline", "_check_statusline_pipeline"),
1723
1766
  ("data.cache_sync_state", "_check_data_cache_sync_state"),
1724
1767
  ("data.codex_cache", "_check_data_codex_cache"),
1768
+ ("data.codex_project_metadata", "_check_data_codex_project_metadata"),
1725
1769
  ("data.codex_quota", "_check_data_codex_quota"),
1726
1770
  ("data.parse_health", "_check_data_parse_health"),
1727
1771
  ("data.forked_buckets", "_check_data_forked_buckets"),
package/bin/_lib_jsonl.py CHANGED
@@ -720,6 +720,14 @@ def _accounting_from_record(
720
720
  _codex_skip(state, "no-last-token-usage")
721
721
  return None, last_total_tokens, filename_session_id_warned
722
722
 
723
+ # Forked/subagent rollouts can begin with a copied slice of their parent's
724
+ # physical history before the child's first model-bearing turn_context.
725
+ # Those records remain in the physical event stream, but projecting them
726
+ # into accounting would duplicate the parent's usage under model="unknown".
727
+ if (state.model is None and state.thread is not None
728
+ and state.thread.parent_thread_id is not None):
729
+ return None, last_total_tokens, filename_session_id_warned
730
+
723
731
  total_token_usage = info.get("total_token_usage")
724
732
  if isinstance(total_token_usage, dict):
725
733
  try:
@@ -839,6 +847,9 @@ def _iter_codex_fused_records_with_offsets(
839
847
  session_id = _codex_string(payload.get("id"))
840
848
  if session_id is not None:
841
849
  state.session_id = session_id
850
+ session_model = _codex_string(payload.get("model"))
851
+ if session_model is not None:
852
+ state.model = session_model.strip()
842
853
  thread_update = _thread_metadata_from_session_meta(
843
854
  payload, path_str, source_root_key
844
855
  )
@@ -53,7 +53,7 @@ def _chip_for_model(name: str) -> str:
53
53
  # Date the embedded pricing snapshots below were last verified against
54
54
  # vendor sources. Bump whenever CLAUDE_MODEL_PRICING / CODEX_MODEL_PRICING
55
55
  # is synced. Read by `pricing-check` + the release pre-flight staleness nudge.
56
- PRICING_SNAPSHOT_DATE = "2026-07-10"
56
+ PRICING_SNAPSHOT_DATE = "2026-07-19"
57
57
  PRICING_STALENESS_DAYS = 60 # release pre-flight WARNs past this age
58
58
 
59
59
  # Canonical machine-readable pricing source (Claude values + Codex values).
@@ -99,7 +99,7 @@ PRICING_DRIFT_ALLOWLIST: list[dict] = [
99
99
 
100
100
  # Anthropic API pricing snapshot:
101
101
  # - Source: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
102
- # - Captured: 2026-07-01 (see PRICING_SNAPSHOT_DATE)
102
+ # - Captured: 2026-07-19 (see PRICING_SNAPSHOT_DATE)
103
103
  # - Verified by maintainer against docs.claude.com/en/docs/about-claude/pricing;
104
104
  # update in PRs touching this table.
105
105
  # 2026-06-10: added claude-fable-5 ($10/$50 per MTok; 1M context, no
@@ -321,8 +321,8 @@ _unknown_model_warnings: set[str] = set()
321
321
  #
322
322
  # Codex (OpenAI) API pricing snapshot:
323
323
  # - Source: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
324
- # - Captured: 2026-05-30 (see PRICING_SNAPSHOT_DATE)
325
- # - As of the 2026-05-30 sync (issue #123) this carries every openai-provider
324
+ # - Captured: 2026-07-19 (see PRICING_SNAPSHOT_DATE)
325
+ # - As of the 2026-07-19 sync this carries every openai-provider
326
326
  # gpt-5* model the LiteLLM snapshot lists, so `pricing-check`'s scope finds
327
327
  # nothing missing. Models absent from this table still fall back to `gpt-5`
328
328
  # pricing with isFallback=true (matches upstream's LEGACY_FALLBACK_MODEL
@@ -332,6 +332,9 @@ _unknown_model_warnings: set[str] = set()
332
332
  # `pricing-check` as missing_from_us (gpt-5.6-sol had real usage falling back
333
333
  # to gpt-5). Exact model_prices_and_context_window.json values, each with the
334
334
  # above-272k tier (max_input_tokens 1050000).
335
+ # 2026-07-19: verified all tracked values and the complete openai-provider
336
+ # gpt-5* model set against the live LiteLLM snapshot; no named table entries
337
+ # were missing. Official OpenAI model pages were used as the vendor cross-check.
335
338
  #
336
339
  # Billing rules:
337
340
  # - reasoning_output_tokens is billed at the *output* rate (matches
@@ -34,11 +34,17 @@ class DriftResult:
34
34
  ahead_of_litellm: list[str] # informational; never actionable
35
35
 
36
36
 
37
+ _CODEX_UNATTRIBUTED_MODEL = "unknown"
38
+
39
+
37
40
  def classify_coverage(observed, resolve_claude, is_codex_fallback) -> list[CoverageGap]:
38
41
  """observed: iterable of (provider, model, entry_count, token_total).
39
42
 
40
43
  Claude model with resolve_claude(model) is None -> kind="unpriced".
41
44
  Codex model with is_codex_fallback(model) True -> kind="fallback".
45
+ The ingest-only Codex ``unknown`` sentinel is skipped because it records
46
+ missing model metadata, not a model identifier that can be added to the
47
+ embedded pricing table.
42
48
  Priced models produce no gap. Order preserved.
43
49
  """
44
50
  gaps: list[CoverageGap] = []
@@ -46,7 +52,7 @@ def classify_coverage(observed, resolve_claude, is_codex_fallback) -> list[Cover
46
52
  if provider == "claude":
47
53
  if resolve_claude(model) is None:
48
54
  gaps.append(CoverageGap("claude", model, "unpriced", entry_count, token_total))
49
- elif provider == "codex":
55
+ elif provider == "codex" and model != _CODEX_UNATTRIBUTED_MODEL:
50
56
  if is_codex_fallback(model):
51
57
  gaps.append(CoverageGap("codex", model, "fallback", entry_count, token_total))
52
58
  return gaps
package/bin/_lib_quota.py CHANGED
@@ -407,7 +407,7 @@ def _single_identity_history(observations: Iterable[QuotaObservation]) -> QuotaH
407
407
  def forecast_quota(
408
408
  observations: Iterable[QuotaObservation], as_of: dt.datetime,
409
409
  ) -> QuotaForecast:
410
- """Fit a reset-scoped quota forecast from positive adjacent intervals only."""
410
+ """Forecast reset-scoped quota use from elapsed native-cycle pace."""
411
411
  _require_aware(as_of, "as_of")
412
412
  history = _single_identity_history(observations)
413
413
  if history is None:
@@ -423,28 +423,29 @@ def forecast_quota(
423
423
  if observation.resets_at == baseline.resets_at and observation.captured_at <= as_of
424
424
  )
425
425
  points = tuple(sorted(points, key=physical_order_key))
426
- usable_elapsed_seconds = 0.0
427
- positive_delta_percent = 0.0
428
426
  sample_count = 0
429
427
  for prior, current in zip(points, points[1:]):
430
428
  elapsed_seconds = (current.captured_at - prior.captured_at).total_seconds()
431
429
  delta_percent = current.used_percent - prior.used_percent
432
430
  if elapsed_seconds > 0 and delta_percent > 0:
433
- usable_elapsed_seconds += elapsed_seconds
434
- positive_delta_percent += delta_percent
435
431
  sample_count += 1
436
432
 
437
433
  remaining_seconds = max(0, int((baseline.resets_at - as_of).total_seconds()))
438
- if usable_elapsed_seconds > 0:
439
- rate = positive_delta_percent / (usable_elapsed_seconds / 3600)
434
+ native_window_seconds = baseline.identity.window_minutes * 60
435
+ cycle_start = baseline.resets_at - dt.timedelta(minutes=baseline.identity.window_minutes)
436
+ cycle_elapsed_seconds = min(
437
+ float(native_window_seconds),
438
+ max(0.0, (as_of - cycle_start).total_seconds()),
439
+ )
440
+ if sample_count > 0 and cycle_elapsed_seconds > 0:
441
+ rate = baseline.used_percent / (cycle_elapsed_seconds / 3600)
440
442
  projected = min(100.0, max(
441
443
  baseline.used_percent,
442
444
  baseline.used_percent + rate * (remaining_seconds / 3600),
443
445
  ))
444
- native_window_seconds = baseline.identity.window_minutes * 60
445
- if sample_count >= 4 and usable_elapsed_seconds >= native_window_seconds * 0.25:
446
+ if sample_count >= 4 and cycle_elapsed_seconds >= native_window_seconds * 0.25:
446
447
  confidence: Literal["high", "medium", "low"] = "high"
447
- elif sample_count >= 2 and usable_elapsed_seconds >= native_window_seconds * 0.10:
448
+ elif sample_count >= 2 and cycle_elapsed_seconds >= native_window_seconds * 0.10:
448
449
  confidence = "medium"
449
450
  else:
450
451
  confidence = "low"
@@ -469,7 +470,7 @@ def forecast_quota(
469
470
  resets_at=baseline.resets_at,
470
471
  remaining_seconds=remaining_seconds,
471
472
  sample_count=sample_count,
472
- sample_span_seconds=int(usable_elapsed_seconds),
473
+ sample_span_seconds=int(cycle_elapsed_seconds) if sample_count > 0 else 0,
473
474
  confidence=confidence,
474
475
  )
475
476
 
@@ -1884,20 +1884,8 @@ def build_codex_weekly_view(entries, *, now_utc, tz_name=None,
1884
1884
  )
1885
1885
 
1886
1886
 
1887
- def build_codex_session_view(entries, *, now_utc, tz_name=None, speed="standard"):
1888
- """Build a ``CodexSessionView`` from a list of ``CodexEntry`` (issue #58).
1889
-
1890
- ``rows`` order mirrors the aggregator: descending by
1891
- ``last_activity`` (upstream parity).
1892
- ``cmd_codex_session`` reverses to ASC when ``--order asc``.
1893
-
1894
- ``period_start`` is set to ``min(s.last_activity)`` across emitted
1895
- sessions when any exist — best-available approximation since
1896
- ``CodexSessionUsage`` doesn't carry a ``first_activity`` field (the
1897
- aggregator only tracks ``last`` per session). ``None`` on empty.
1898
- """
1899
- _agg = _load_lib("_lib_aggregators")
1900
- sessions = _agg._aggregate_codex_sessions(entries, speed=speed)
1887
+ def _build_codex_session_view_from_rows(sessions, *, now_utc, tz_name):
1888
+ """Wrap already-aggregated Codex sessions in the public view contract."""
1901
1889
  total_cost = 0.0
1902
1890
  total_tok = 0
1903
1891
  earliest = None
@@ -1915,3 +1903,51 @@ def build_codex_session_view(entries, *, now_utc, tz_name=None, speed="standard"
1915
1903
  period_end=now_utc,
1916
1904
  display_tz_label=_codex_tz_label(tz_name),
1917
1905
  )
1906
+
1907
+
1908
+ def build_codex_session_view(entries, *, now_utc, tz_name=None, speed="standard"):
1909
+ """Build a ``CodexSessionView`` from CLI ``CodexEntry`` rows (issue #58)."""
1910
+ _agg = _load_lib("_lib_aggregators")
1911
+ return _build_codex_session_view_from_rows(
1912
+ _agg._aggregate_codex_sessions(entries, speed=speed),
1913
+ now_utc=now_utc,
1914
+ tz_name=tz_name,
1915
+ )
1916
+
1917
+
1918
+ def build_rooted_codex_session_view(entries, *, now_utc, tz_name=None, speed="standard"):
1919
+ """Build dashboard sessions from cache-rooted accounting without I/O.
1920
+
1921
+ The file identity is lexical: ``(source_root_key, source_path)``. It
1922
+ deliberately does not inspect configured roots or touch the filesystem,
1923
+ and a native session id remains payload rather than a grouping key.
1924
+ """
1925
+ _agg = _load_lib("_lib_aggregators")
1926
+ keyed = []
1927
+ for entry in entries:
1928
+ source_path = str(getattr(entry, "source_path"))
1929
+ source_root_key = str(getattr(entry, "source_root_key"))
1930
+ path = pathlib.PurePosixPath(source_path)
1931
+ codex_entry = _agg.CodexEntry(
1932
+ timestamp=getattr(entry, "timestamp"),
1933
+ session_id=str(getattr(entry, "session_id") or ""),
1934
+ model=str(getattr(entry, "model")),
1935
+ input_tokens=int(getattr(entry, "input_tokens")),
1936
+ cached_input_tokens=int(getattr(entry, "cached_input_tokens")),
1937
+ output_tokens=int(getattr(entry, "output_tokens")),
1938
+ reasoning_output_tokens=int(getattr(entry, "reasoning_output_tokens")),
1939
+ total_tokens=int(getattr(entry, "total_tokens")),
1940
+ source_path=source_path,
1941
+ )
1942
+ keyed.append((codex_entry, _agg.CodexSessionIdentity(
1943
+ group_key=(source_root_key, source_path),
1944
+ session_id_path=source_path,
1945
+ session_file=path.stem,
1946
+ directory=str(path.parent),
1947
+ codex_root=source_root_key,
1948
+ )))
1949
+ return _build_codex_session_view_from_rows(
1950
+ _agg._aggregate_codex_sessions_keyed(keyed, speed=speed),
1951
+ now_utc=now_utc,
1952
+ tz_name=tz_name,
1953
+ )
package/bin/cctally CHANGED
@@ -933,6 +933,9 @@ Migration = _cctally_db.Migration
933
933
  DowngradeDetected = _cctally_db.DowngradeDetected
934
934
  ProdMigrationRefused = _cctally_db.ProdMigrationRefused
935
935
  StatsDbCorruptError = _cctally_db.StatsDbCorruptError
936
+ StatsDbMaintenanceError = _cctally_db.StatsDbMaintenanceError
937
+ _is_sqlite_corruption_error = _cctally_db._is_sqlite_corruption_error
938
+ _stats_corruption_guidance = _cctally_db._stats_corruption_guidance
936
939
  _STATS_MIGRATIONS = _cctally_db._STATS_MIGRATIONS
937
940
  _CACHE_MIGRATIONS = _cctally_db._CACHE_MIGRATIONS
938
941
  _make_migration_decorator = _cctally_db._make_migration_decorator
@@ -957,6 +960,8 @@ _db_path_for_label = _cctally_db._db_path_for_label
957
960
  cmd_db_skip = _cctally_db.cmd_db_skip
958
961
  cmd_db_unskip = _cctally_db.cmd_db_unskip
959
962
  cmd_db_recover = _cctally_db.cmd_db_recover
963
+ cmd_db_repair = _cctally_db.cmd_db_repair
964
+ cmd_db_backup = _cctally_db.cmd_db_backup
960
965
  cmd_db_checkpoint = _cctally_db.cmd_db_checkpoint
961
966
  cmd_db_vacuum = _cctally_db.cmd_db_vacuum
962
967
 
@@ -1384,6 +1389,7 @@ cmd_codex_quota_statusline = _cctally_quota.cmd_codex_quota_statusline
1384
1389
  cmd_codex_quota_forecast = _cctally_quota.cmd_codex_quota_forecast
1385
1390
  cmd_codex_quota_blocks = _cctally_quota.cmd_codex_quota_blocks
1386
1391
  cmd_codex_quota_breakdown = _cctally_quota.cmd_codex_quota_breakdown
1392
+ cmd_codex_percent_breakdown = _cctally_quota.cmd_codex_percent_breakdown
1387
1393
 
1388
1394
  # S3's qualified Codex accounting adapter is intentionally cache-only: callers
1389
1395
  # that need project identity must not downgrade to an unqualified rollout parse.
@@ -2926,6 +2932,7 @@ _is_reset_drop = _cctally_weekrefs._is_reset_drop
2926
2932
  # handler (C8, #125 Batch B). Parser dispatch reaches c.cmd_percent_breakdown.
2927
2933
  _cctally_percent_breakdown = _load_sibling("_cctally_percent_breakdown")
2928
2934
  cmd_percent_breakdown = _cctally_percent_breakdown.cmd_percent_breakdown
2935
+ _render_percent_breakdown_terminal = _cctally_percent_breakdown._render_percent_breakdown_terminal
2929
2936
 
2930
2937
 
2931
2938
  # _is_cctally_hook_command, SetupError, _load_claude_settings,
@@ -3207,7 +3214,26 @@ def main(argv: list[str] | None = None) -> int:
3207
3214
  # + recovery guidance, exit 2. Caught before the generic handler so it
3208
3215
  # never renders as a raw traceback.
3209
3216
  eprint(f"cctally: {exc}")
3210
- return 2
3217
+ return 3
3218
+ except StatsDbMaintenanceError as exc:
3219
+ eprint(f"cctally: {exc}")
3220
+ return 3
3221
+ except sqlite3.DatabaseError as exc:
3222
+ if _is_sqlite_corruption_error(exc):
3223
+ eprint(
3224
+ "cctally: a SQLite database is corrupt — run `cctally doctor` "
3225
+ "to identify it. For stats.db run `cctally db repair --db "
3226
+ "stats --yes`; cache.db is re-derivable via `cctally "
3227
+ "cache-sync --rebuild`."
3228
+ )
3229
+ return 3
3230
+ if _lib_log.debug_enabled():
3231
+ _lib_log.get_logger("cli").debug(
3232
+ "unhandled exception in %r",
3233
+ getattr(args, "command", None), exc_info=True,
3234
+ )
3235
+ eprint(f"Error: {exc}")
3236
+ rc = 1
3211
3237
  except Exception as exc: # pragma: no cover
3212
3238
  # #279 S2 F2: CCTALLY_DEBUG=1 surfaces the full traceback on stderr
3213
3239
  # via the _lib_log chokepoint; default mode stays byte-identical.