cctally 1.73.0 → 1.74.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.
@@ -42,7 +42,7 @@ import os
42
42
  import pathlib
43
43
  import sys
44
44
  from dataclasses import dataclass
45
- from typing import Any, Callable
45
+ from typing import Any, Callable, Iterable
46
46
 
47
47
 
48
48
  def _cctally():
@@ -414,6 +414,17 @@ class CodexSessionUsage:
414
414
  codex_root: str = ""
415
415
 
416
416
 
417
+ @dataclass(frozen=True)
418
+ class CodexSessionIdentity:
419
+ """Caller-supplied identity for the pure Codex session accumulator."""
420
+
421
+ group_key: tuple[str, str]
422
+ session_id_path: str
423
+ session_file: str
424
+ directory: str
425
+ codex_root: str
426
+
427
+
417
428
  @dataclass
418
429
  class ClaudeSessionUsage:
419
430
  """Aggregated Claude usage for one sessionId (may span multiple JSONL files)."""
@@ -619,41 +630,19 @@ def _codex_home_root_from_prefix(root_prefix: str) -> str:
619
630
  return s
620
631
 
621
632
 
622
- def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard") -> list[CodexSessionUsage]:
623
- """Group by session file path (upstream-compatible).
624
-
625
- Sessions are keyed by the full relative-path-without-.jsonl rather than
626
- the inner UUID. Result is sorted by last_activity descending (most
627
- recent first), matching upstream's default view.
628
-
629
- Per-model breakdowns include `isFallback: bool` — true when the model is
630
- absent from CODEX_MODEL_PRICING.
631
- """
633
+ def _aggregate_codex_sessions_keyed(
634
+ entries: Iterable[tuple[CodexEntry, CodexSessionIdentity]],
635
+ speed: str = "standard",
636
+ ) -> list[CodexSessionUsage]:
637
+ """Pure Codex session arithmetic over caller-owned file identities."""
632
638
  by_session: dict[tuple[str, str], dict[str, Any]] = {}
633
- for entry in entries:
634
- id_path, file_name, directory = _session_path_parts(entry.source_path)
635
- # Disambiguate identical relative paths under DIFFERENT $CODEX_HOME
636
- # roots (issue #108). _session_path_parts strips the matched root, so
637
- # <rootA>/sessions/2026/04/17/rollout-x.jsonl and the same relative
638
- # path under <rootB> both yield id_path "2026/04/17/rollout-x";
639
- # grouping on id_path alone would silently merge two distinct sessions
640
- # (summed tokens, one UUID). Key on (root_prefix, id_path), where
641
- # root_prefix is source_path with the id_path tail removed. Single-root
642
- # data — and the bare-relative fixture form — has a constant prefix, so
643
- # the grouping, insertion order, and every golden stay byte-identical;
644
- # only a genuine cross-root collision splits into separate rows.
645
- suffix = id_path + ".jsonl"
646
- sp = entry.source_path
647
- root_prefix = sp[: -len(suffix)] if sp.endswith(suffix) else sp
648
- sess = by_session.setdefault((root_prefix, id_path), {
639
+ for entry, identity in entries:
640
+ sess = by_session.setdefault(identity.group_key, {
649
641
  "session_id_uuid": entry.session_id,
650
- "session_id_path": id_path,
651
- "session_file": file_name,
652
- "directory": directory,
653
- # Matched $CODEX_HOME root (home-root form) — issue #110 display
654
- # disambiguator. Derived from the same root_prefix that keys the
655
- # group, so it's constant per group.
656
- "codex_root": _codex_home_root_from_prefix(root_prefix),
642
+ "session_id_path": identity.session_id_path,
643
+ "session_file": identity.session_file,
644
+ "directory": identity.directory,
645
+ "codex_root": identity.codex_root,
657
646
  "input": 0, "cached_input": 0, "output": 0, "reasoning": 0,
658
647
  "cost": 0.0, "models": {}, "models_order": [],
659
648
  "last": entry.timestamp,
@@ -724,6 +713,31 @@ def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard"
724
713
  return result
725
714
 
726
715
 
716
+ def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard") -> list[CodexSessionUsage]:
717
+ """Group CLI Codex entries by their existing filesystem-derived identity.
718
+
719
+ The wrapper preserves the historic CLI path parsing and byte-stable output;
720
+ only the accumulation itself lives in the keyed pure seam above.
721
+ """
722
+ keyed: list[tuple[CodexEntry, CodexSessionIdentity]] = []
723
+ for entry in entries:
724
+ id_path, file_name, directory = _session_path_parts(entry.source_path)
725
+ # Disambiguate equal relative paths under separate configured roots.
726
+ suffix = id_path + ".jsonl"
727
+ source_path = entry.source_path
728
+ root_prefix = (
729
+ source_path[: -len(suffix)] if source_path.endswith(suffix) else source_path
730
+ )
731
+ keyed.append((entry, CodexSessionIdentity(
732
+ group_key=(root_prefix, id_path),
733
+ session_id_path=id_path,
734
+ session_file=file_name,
735
+ directory=directory,
736
+ codex_root=_codex_home_root_from_prefix(root_prefix),
737
+ )))
738
+ return _aggregate_codex_sessions_keyed(keyed, speed=speed)
739
+
740
+
727
741
  def _aggregate_claude_sessions(
728
742
  entries: list["_JoinedClaudeEntry"],
729
743
  mode: str = "auto",
@@ -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 []
@@ -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
+ )