cdx-manager 0.24.2 → 0.24.4

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/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <br clear="left"/>
6
6
 
7
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.24.2-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
7
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.24.4-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
8
8
 
9
9
  **The right AI coding account, when you need it.** CDX Manager keeps your Codex and Claude accounts in separate profiles, shows which ones have quota, and helps you pick the next usable session from your terminal.
10
10
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.24.2
1
+ 0.24.4
@@ -0,0 +1,13 @@
1
+ # CDX Manager 0.24.3
2
+
3
+ Release date: 2026-10-29
4
+
5
+ This patch preserves observed serving models in run history and reports for more reliable usage attribution and pricing. (#57)
6
+
7
+ ## Runs and statistics
8
+
9
+ - Expose `usage_model` separately from the requested `model` in completed run JSON and registry reports, and retain observed model evidence in interactive history even when token usage is unavailable.
10
+ - Recover Codex model evidence from the exact rollout identified by `thread.started.thread_id`, while keeping token totals from the run's own output. Configured defaults alone do not establish the serving model; missing or mixed model evidence stays unpriced.
11
+ - Group Claude's `[1m]` context variants with their base model in `cdx stats --by model`, preserving the original model string and per-run pricing.
12
+
13
+ These changes apply to newly recorded runs; existing history is not rewritten.
@@ -0,0 +1,16 @@
1
+ # CDX Manager 0.24.4
2
+
3
+ Release date: 2026-10-29
4
+
5
+ This patch adds explicit recovery of missing serving models in existing run history, using evidence tied to each run. (#59)
6
+
7
+ ## History and statistics
8
+
9
+ - Add `cdx history backfill-models`, with a read-only preview by default and `--dry-run`, `--apply`, and `--json` options. Recover models from preserved run output, matching run reports, or exact Codex and Claude conversation transcripts. (#59)
10
+ - Keep missing or ambiguous evidence unknown rather than guessing from configured models or the latest conversation. Preserve existing models and token counts; report recovery counts, unknown reasons, and samples. (#59)
11
+ - With `--apply`, back up the original history and replace it atomically under the history lock. Repeated runs leave recovered rows alone. Subsequent statistics can price recovered models when the local price table recognizes them. (#59)
12
+
13
+ ## Maintenance
14
+
15
+ - Record tray companion checksums for v0.24.3. (`bae6f34b733c91ef44070d25bb86015fa31f71ca`)
16
+ - Record release archive checksums for v0.24.3. (`8a1d4938a11f83cbe2eb8abc324a55b909f72837`)
@@ -595,6 +595,28 @@
595
595
  "x86_64-pc-windows-msvc": "89a7e43654ea5f0d4efc20a14c1398377a1d48485f1b9867b199e85aa9c25daf",
596
596
  "x86_64-unknown-linux-musl": "a36a3589a3766d67e44a0466b11a18bda4297963416eec7b5b9d23a5c9f363a4"
597
597
  }
598
+ },
599
+ "v0.24.2": {
600
+ "github_tarball_sha256": "90ea3d050c278500b975bbc8bfe1b185f90fc35dfcf6b07d84eb59439bb24e58",
601
+ "github_zip_sha256": "3c53f0f0550416ebe3d7281e9c823a682a2c3560130df6fafec48d35278f4f8a",
602
+ "tray_assets": {
603
+ "aarch64-apple-darwin": "8fc48c8772b5ea50700933f0836e153104ef328b395de059749bd5a5aa864076",
604
+ "aarch64-unknown-linux-musl": "ce7708f4196cfdf7b7226ba3199e772e767c2d470c37d19589cb41bfc35de7af",
605
+ "x86_64-apple-darwin": "2d1d6e84ee08d9dea5a707332a39509edbe68b05e3d062bdae93e9c6704db543",
606
+ "x86_64-pc-windows-msvc": "eecc8f8c46d2ce50e6cf6ec6c48c10a10e165ec7ec0058cfb55c8aa486d17d7b",
607
+ "x86_64-unknown-linux-musl": "4cf3289c5d9d7a540a032a11d27e93093d69ec507e1796a726f429c0670f7bc5"
608
+ }
609
+ },
610
+ "v0.24.3": {
611
+ "github_tarball_sha256": "4dfcc2c6a8f500fca04436ea9a22525827924a8193f79f3f285dfc3025762c42",
612
+ "github_zip_sha256": "66950a74e2bf8a9a349818a612a50217e10a70b92621bfa414d2d19df00aff61",
613
+ "tray_assets": {
614
+ "aarch64-apple-darwin": "3aaf5564a4ec896bd1e2a8400e653661ecdb216dfd87970ee3af69ddf7d088b0",
615
+ "aarch64-unknown-linux-musl": "2ff0d2272fb5d2d21631df986c050f8196a687fb6381453216d07748ed1b873f",
616
+ "x86_64-apple-darwin": "db6b646575195365879c7b25d406c0734bdbef7154f25e7f38342cafbf88d74a",
617
+ "x86_64-pc-windows-msvc": "6da838217c01251b809bac1e188217dcfbb00c2102b901cfda43cf2fc0696aa2",
618
+ "x86_64-unknown-linux-musl": "c6651dd909d486c2b0bb209e0d813ab044dc7b5ffb1a07078c8ec7a69126e028"
619
+ }
598
620
  }
599
621
  }
600
622
  }
package/docs/cli.md CHANGED
@@ -28,6 +28,7 @@
28
28
  | `cdx set <name>\|--sessions all\|a,b\|--provider PROVIDER [--power minimal\|low\|medium\|high\|xhigh] [--permission review\|default\|auto\|full] [--fast on\|off] [--rtk on\|off] [--logics on\|off] [--notify on\|off] [--agent-state on\|off] [--notify-preview on\|off] [--scrub-env on\|off] [--shared-memory on\|off] [--retro on\|off] [--policy use\|ask-first\|never] [--model MODEL] [--fallback-model MODEL[,MODEL...]] [--budget USD] [--extra-args 'ARGS'] [--priority 0..100] [--json]` | Persist launch settings for one or more sessions |
29
29
  | `cdx unset <name>\|--sessions all\|a,b\|--provider PROVIDER (--power\|--reasoning-effort\|--permission\|--fast\|--rtk\|--logics\|--notify\|--agent-state\|--notify-preview\|--scrub-env\|--shared-memory\|--retro\|--policy\|--model\|--fallback-model\|--budget\|--extra-args\|--priority\|--all) [--json]` | Remove persisted launch settings and fall back to provider defaults |
30
30
  | `cdx history [name] [--limit N] [--summary] [--since 7d\|today\|DATE] [--from DATE] [--to DATE] [--json]` | Show recent launch history or aggregate total launch time per assistant, optionally filtered by period |
31
+ | `cdx history backfill-models [--dry-run \| --apply] [--json]` | Recover missing historical usage models from evidence tied to each run; defaults to a read-only preview |
31
32
  | `cdx last [--json]` | Launch the most recent existing session from launch history |
32
33
  | `cdx resume <name> [--conversation <id>] [--json]` | Resume the provider-native conversation for a session using the named command form; `--conversation` reopens that exact conversation and refuses with `conversation_not_found` if the profile has no such transcript |
33
34
  | `cdx can-resume <name> [--conversation <id>] [--json]` | Check whether a session supports native resume without launching the provider; `--conversation` checks that exact conversation and reports `resumable: false` with `conversation_not_found` instead of falling back to the most recent one |
@@ -269,6 +270,20 @@ The figure uses standard public list prices, excluding Fast/priority, Batch/Flex
269
270
 
270
271
  `cdx stats` counts completed history attempts. A headless failover has one logical run in `cdx runs` but one history row per attempted session, including failed attempts that reported usage. Period filtering includes the entire run in every period it overlaps, so adjacent daily results can both include a run crossing midnight; adding daily totals may exceed a combined period. JSON `accounting` names this policy and the current price-table source. `usage_runs` and `priced_runs` show how much of the token total has a dollar estimate. Live runs are absent until their history row is written, and unknown usage is different from measured zero.
271
272
 
273
+ Completed run JSON and registry reports expose `usage_model` separately from the requested `model`. Pricing uses the observed model from provider output; Codex can also supply it through the exact rollout identified by `thread.started.thread_id`. Configured defaults alone never establish serving-model identity, and missing or mixed model evidence stays unpriced. `--by model` groups Claude's `[1m]` context variant with its base model while preserving the original model string and per-run pricing. New runs record this evidence automatically. To recover missing models in existing history, run:
274
+
275
+ ```bash
276
+ cdx history backfill-models # Preview only (the default)
277
+ cdx history backfill-models --dry-run --json # Machine-readable preview
278
+ cdx history backfill-models --apply # Save proven models with a backup
279
+ ```
280
+
281
+ The command considers only history rows with usage and an empty or placeholder `usage_model`. Evidence is checked in order: the row's preserved JSON/JSONL stdout, a preserved run-registry report with the same run id, profile, provider and stdout path, then the exact Codex thread rollout or Claude session transcript. Thread/session ids must come from the row or its stdout, or from a preserved transcript path recorded as an exact conversation-id match. The current profile is used only to locate an exact transcript under its auth home; its configured model and latest conversation/transcript never establish a historical model. The requested `model` in a report is also ignored.
282
+
283
+ Only one serving model is accepted. Claude `[1m]` variants count as the same model for this check and for stats grouping; the observed spelling is preserved. Mixed models, incomplete turn models, malformed or oversized evidence, conflicting identities and missing exact transcripts stay unknown. For resumed conversations, the conservative rule is that all recorded turns in the exact transcript must agree: this command does not infer historical turn boundaries. Ambiguous higher-priority evidence stops recovery rather than being overridden by a lower-priority source. Missing files may fall through to the next source.
284
+
285
+ Text and JSON output include counts by evidence kind, counts of unknown reasons and up to 20 samples. Dry-run leaves history and run reports unchanged. `--apply` changes only missing models and their evidence annotation, preserving token counts and unrelated history bytes. It holds the history lock, saves the original bytes to a unique `state/launch_history.jsonl.models-<id>.bak` file, then atomically replaces history. The backup path is printed; a repeated apply leaves already recovered rows alone. `--apply` and `--dry-run` cannot be combined. Subsequent `cdx stats` calls price recovered models when the local price table knows them; unknown models remain unpriced.
286
+
272
287
  The built-in price table ships with **cdx-manager**. Run `cdx update` to receive new model prices; updating Codex or Claude alone does not refresh it. Then check `cdx --version` and `cdx stats --json`. Existing runs with a recorded model are recalculated using the new table when stats are read. `CDX_TOKEN_PRICES` overrides still take precedence. Maintainers: follow [the model and pricing update runbook](https://github.com/AlexAgo83/cdx-manager/blob/main/logics/runbook/run_005_maintaining_the_token_price_table.md) for source checks, tests, release preparation and installed-version verification.
273
288
 
274
289
  A model absent from the table stays unpriced rather than being charged a default tier, and the totals line names it — which is exactly the key `CDX_TOKEN_PRICES` wants:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.24.2",
3
+ "version": "0.24.4",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.24.2"
7
+ version = "0.24.4"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -458,16 +458,16 @@ def _attach_interactive_usage(session, run_info, history=None, baseline=None, un
458
458
  # way as an id match would let a wrong attribution read as a
459
459
  # measurement, which is the failure this replaced.
460
460
  run_info["provider_transcript_match"] = match
461
- if not cumulative:
462
- return run_info
463
-
464
- run_info["usage_cumulative"] = cumulative
465
461
  if model:
466
462
  # Which model actually served the run, read from the transcript rather
467
463
  # than inferred from the session's configured launch settings -- where
468
464
  # `model` is optional and usually unset, because the session takes the
469
465
  # provider's default.
470
466
  run_info["usage_model"] = model
467
+ if not cumulative:
468
+ return run_info
469
+
470
+ run_info["usage_cumulative"] = cumulative
471
471
  if uncertain_reason:
472
472
  run_info["usage_attribution"] = uncertain_reason
473
473
  return run_info
@@ -687,6 +687,7 @@ def handle_run(rest, ctx):
687
687
  )
688
688
  usage, usage_model = extract_run_observation(
689
689
  run_session.get("provider"), run_info.get("stdout_path"),
690
+ run_session.get("authHome"),
690
691
  )
691
692
  run_info = {**run_info, "usage": usage, "usage_model": usage_model}
692
693
  ctx["service"]["record_launch_history"](run_session["name"], {
@@ -807,6 +808,7 @@ def handle_run(rest, ctx):
807
808
  if run_info and run_id:
808
809
  usage, usage_model = extract_run_observation(
809
810
  run_session.get("provider"), run_info.get("stdout_path"),
811
+ run_session.get("authHome"),
810
812
  )
811
813
  run_info = {**run_info, "usage": usage, "usage_model": usage_model}
812
814
  ctx["service"]["record_launch_history"](run_session["name"], {
@@ -491,7 +491,8 @@ def _stats_group(entry, by):
491
491
  """The project (launch folder) or the model a run is counted under."""
492
492
  if by == "project":
493
493
  return str(entry.get("cwd") or "").rstrip("/\\") or "-"
494
- return entry.get("usage_model") or "-"
494
+ model = entry.get("usage_model") or "-"
495
+ return model.removesuffix("[1m]") if model.startswith("claude-") else model
495
496
 
496
497
 
497
498
  def _project_labels(paths):
@@ -601,6 +602,30 @@ def handle_configs(rest, ctx):
601
602
  return 0
602
603
 
603
604
  def handle_history(rest, ctx):
605
+ if rest and rest[0] == "backfill-models":
606
+ usage = "Usage: cdx history backfill-models [--dry-run | --apply] [--json]"
607
+ parsed = _parse_flag_args(rest[1:], {
608
+ "--dry-run": {"key": "dry_run", "type": "bool", "default": False},
609
+ "--apply": {"key": "apply", "type": "bool", "default": False},
610
+ "--json": {"key": "json", "type": "bool", "default": False},
611
+ }, usage)
612
+ if parsed["apply"] and parsed["dry_run"]:
613
+ raise CdxError(usage)
614
+ result = ctx["service"]["backfill_history_models"](dry_run=not parsed["apply"])
615
+ if parsed["json"]:
616
+ _write_json(ctx, _json_success("history", "Backfilled history models", **result))
617
+ else:
618
+ mode = "Dry run" if result["dry_run"] else "Applied"
619
+ ctx["out"](f"{mode}: {result['candidates']} missing models; {result['still_unknown']} still unknown; {result['backfilled']} written.\n")
620
+ for kind, count in result["recoverable"].items():
621
+ ctx["out"](f"Recoverable from {kind}: {count}\n")
622
+ for reason, count in result["unknown_reasons"].items():
623
+ ctx["out"](f"Unknown ({reason}): {count}\n")
624
+ for sample in result["samples"]:
625
+ ctx["out"](f" {sample['session_name']} {sample['run_id'] or sample['started_at']}: {sample['model'] or sample['reason']} ({sample['evidence'] or 'no evidence'})\n")
626
+ if result["backup_path"]:
627
+ ctx["out"](f"Backup: {result['backup_path']}\n")
628
+ return 0
604
629
  now_fn = ctx["options"].get("now") or time.time
605
630
  now = datetime.fromtimestamp(now_fn()).astimezone()
606
631
  parsed = _parse_history_args(rest, now=now)
@@ -0,0 +1,201 @@
1
+ """Recover serving models using only evidence attached to a history row."""
2
+
3
+ import json
4
+ import os
5
+ import time
6
+
7
+ from .interactive_usage import MAX_TRANSCRIPT_BYTES, MAX_TRANSCRIPT_SCAN_SECONDS
8
+ from .run_registry import registry_path
9
+
10
+ EVIDENCE_KINDS = ("stdout", "run_report", "codex_rollout", "claude_transcript")
11
+
12
+
13
+ def missing_model(value):
14
+ return not value or (isinstance(value, str) and value.startswith("<"))
15
+
16
+
17
+ def _read_records(path):
18
+ if not path:
19
+ return [], None
20
+ if not isinstance(path, (str, os.PathLike)):
21
+ return [], "invalid_evidence_path"
22
+ try:
23
+ with open(path, "rb") as handle:
24
+ raw = handle.read(MAX_TRANSCRIPT_BYTES + 1)
25
+ if len(raw) > MAX_TRANSCRIPT_BYTES:
26
+ return [], "evidence_too_large"
27
+ text = raw.decode("utf-8")
28
+ try:
29
+ records = [json.loads(text)]
30
+ except ValueError:
31
+ records = [json.loads(line) for line in text.splitlines() if line.strip()]
32
+ if not all(isinstance(record, dict) for record in records):
33
+ return [], "invalid_evidence"
34
+ return records, None
35
+ except FileNotFoundError:
36
+ return [], None
37
+ except (OSError, UnicodeError, ValueError):
38
+ return [], "unreadable_evidence"
39
+
40
+
41
+ def _single_model(models):
42
+ if any(not isinstance(model, str) or missing_model(model) for model in models):
43
+ return None, "incomplete_model_evidence"
44
+ # Context size variants already share a model group in stats.
45
+ groups = {model.removesuffix("[1m]") if model.startswith("claude-") else model for model in models}
46
+ if len(groups) > 1:
47
+ return None, "ambiguous_models"
48
+ return (models[0], None) if models else (None, None)
49
+
50
+
51
+ def _stdout_models(records):
52
+ models = []
53
+ for record in records:
54
+ if record.get("launcher") == "cdx" or record.get("action") == "run":
55
+ if not missing_model(record.get("usage_model")):
56
+ models.append(record["usage_model"])
57
+ continue
58
+ message = record.get("message")
59
+ if isinstance(message, dict) and message.get("model") == "<synthetic>":
60
+ continue
61
+ if isinstance(message, dict) and (message.get("role") == "assistant" or record.get("type") == "assistant"):
62
+ models.append(message.get("model"))
63
+ elif "model" in record and ("usage" in record or record.get("type") == "result"):
64
+ models.append(record["model"])
65
+ model_usage = record.get("modelUsage")
66
+ if isinstance(model_usage, dict):
67
+ models.extend(model_usage)
68
+ return models
69
+
70
+
71
+ def _exact_id(entry, records, provider):
72
+ key = "thread_id" if provider == "codex" else "session_id"
73
+ identifiers = {record[key] for record in records if isinstance(record.get(key), str) and record[key]}
74
+ for identifier in (entry.get(key), entry.get("conversation_id")):
75
+ if identifier:
76
+ if not isinstance(identifier, str):
77
+ return None, "invalid_conversation_id"
78
+ identifiers.add(identifier)
79
+ if len(identifiers) > 1:
80
+ return None, "ambiguous_conversation_ids"
81
+ return next(iter(identifiers), None), None
82
+
83
+
84
+ def _matches_transcript(provider, name, identifier):
85
+ if provider == "codex":
86
+ return name.startswith("rollout-") and name.endswith(f"-{identifier}.jsonl")
87
+ return name == f"{identifier}.jsonl"
88
+
89
+
90
+ def _exact_transcript(provider, auth_home, identifier):
91
+ if not auth_home:
92
+ return None, None
93
+ root = os.path.join(auth_home, "sessions") if provider == "codex" else os.path.join(auth_home, ".claude", "projects")
94
+ paths = []
95
+ deadline = time.monotonic() + MAX_TRANSCRIPT_SCAN_SECONDS
96
+ for directory, subdirs, names in os.walk(root):
97
+ if time.monotonic() > deadline:
98
+ return None, "transcript_scan_limit"
99
+ subdirs[:] = [name for name in subdirs if name != "subagents"]
100
+ for name in names:
101
+ if _matches_transcript(provider, name, identifier):
102
+ paths.append(os.path.join(directory, name))
103
+ if len(paths) > 1:
104
+ return None, "multiple_exact_transcripts"
105
+ return next(iter(paths), None), None
106
+
107
+
108
+ def recover_model(entry, session=None, reports=()):
109
+ """Return (model, evidence kind, reason); never consult live model settings."""
110
+ provider = entry.get("provider")
111
+ records, error = _read_records(entry.get("stdout_path"))
112
+ if error:
113
+ return None, None, error
114
+ for record in records:
115
+ if record.get("launcher") == "cdx" or record.get("action") == "run":
116
+ if (not entry.get("run_id") or record.get("run_id") != entry["run_id"]
117
+ or record.get("session") != entry.get("session_name") or record.get("provider") != provider):
118
+ return None, None, "report_identity_mismatch"
119
+ model, error = _single_model(_stdout_models(records))
120
+ if model or error:
121
+ return model, "stdout", error
122
+
123
+ # A logical run may fail over across profiles. Its final report does not
124
+ # prove the model used by an earlier attempt with the same run id.
125
+ report_models = []
126
+ for report in reports:
127
+ if not entry.get("run_id") or report.get("run_id") != entry["run_id"]:
128
+ continue
129
+ if report.get("session") != entry.get("session_name") or report.get("provider") != provider:
130
+ continue
131
+ artifacts = report.get("artifacts") or {}
132
+ if not isinstance(artifacts, dict):
133
+ continue
134
+ if not entry.get("stdout_path") or artifacts.get("stdout_path") != entry["stdout_path"]:
135
+ continue
136
+ model = report.get("usage_model")
137
+ if isinstance(model, str) and not missing_model(model):
138
+ report_models.append(model)
139
+ model, error = _single_model(report_models)
140
+ if model or error:
141
+ return model, "run_report", error
142
+
143
+ if provider not in ("codex", "claude"):
144
+ return None, None, "no_run_model_evidence"
145
+ identifier, error = _exact_id(entry, records, provider)
146
+ if error:
147
+ return None, None, error
148
+ path = entry.get("provider_transcript_path")
149
+ # A preserved path resolved by recency is not proof of conversation identity.
150
+ if path and not isinstance(path, (str, os.PathLike)):
151
+ return None, None, "invalid_evidence_path"
152
+ if not identifier and path and entry.get("provider_transcript_match") == "conversation_id":
153
+ identifier = os.path.basename(path).removesuffix(".jsonl")
154
+ if provider == "codex":
155
+ identifier = identifier[-36:]
156
+ if not identifier:
157
+ return None, None, "no_run_conversation_id"
158
+ if not path:
159
+ path, error = _exact_transcript(provider, (session or {}).get("authHome"), identifier)
160
+ if error:
161
+ return None, None, error
162
+ elif not _matches_transcript(provider, os.path.basename(path), identifier):
163
+ return None, None, "transcript_identity_mismatch"
164
+ transcript, error = _read_records(path)
165
+ if error:
166
+ return None, None, error
167
+ if not transcript:
168
+ return None, None, "exact_transcript_missing"
169
+ models = []
170
+ for record in transcript:
171
+ recorded_id = record.get("sessionId") if provider == "claude" else None
172
+ if provider == "codex" and record.get("type") == "session_meta":
173
+ payload = record.get("payload")
174
+ if not isinstance(payload, dict):
175
+ return None, None, "invalid_evidence"
176
+ recorded_id = payload.get("id")
177
+ if recorded_id and recorded_id != identifier:
178
+ return None, None, "transcript_identity_mismatch"
179
+ if provider == "codex" and record.get("type") == "turn_context":
180
+ payload = record.get("payload")
181
+ if not isinstance(payload, dict):
182
+ return None, None, "invalid_evidence"
183
+ models.append(payload.get("model"))
184
+ elif provider == "claude" and record.get("type") == "assistant":
185
+ message = record.get("message")
186
+ if not isinstance(message, dict):
187
+ return None, None, "invalid_evidence"
188
+ model = message.get("model")
189
+ if model != "<synthetic>":
190
+ models.append(model)
191
+ # Requiring the whole exact conversation to agree is conservative for
192
+ # resumed runs: mixed conversations stay unknown without turn boundaries.
193
+ model, error = _single_model(models)
194
+ return model, "codex_rollout" if provider == "codex" else "claude_transcript", error or (None if model else "no_turn_models")
195
+
196
+
197
+ def read_run_reports(base_dir):
198
+ """Read the preserved registry without refreshing or changing run state."""
199
+ records, _error = _read_records(registry_path(base_dir))
200
+ runs = records[0].get("runs", []) if records else []
201
+ return [run for run in runs if isinstance(run, dict)] if isinstance(runs, list) else []
@@ -4,7 +4,7 @@ import sys
4
4
  from .cli_args import ARG_CODE_INVALID_VALUE
5
5
  from .errors import CdxArgumentError, CdxError
6
6
  from .provider_runtime import headless_permission_disables_network
7
- from .run_usage import empty_usage, extract_run_usage
7
+ from .run_usage import empty_usage, extract_run_observation
8
8
 
9
9
  PROMPT_STDIN = "-"
10
10
 
@@ -182,11 +182,11 @@ def run_launch_payload(api_schema_version, parsed, session, artifacts, cwd=None,
182
182
  def run_result_payload(api_schema_version, ok, parsed, session, run_info=None,
183
183
  error=None, error_source=None, error_code=None, selection_row=None):
184
184
  run_info = run_info or {}
185
- usage = run_info.get("usage") if isinstance(run_info.get("usage"), dict) else (
186
- extract_run_usage(session.get("provider"), run_info.get("stdout_path"))
187
- if session else
188
- empty_usage()
189
- )
185
+ usage, usage_model = run_info.get("usage"), run_info.get("usage_model")
186
+ if not isinstance(usage, dict):
187
+ usage, usage_model = extract_run_observation(
188
+ session.get("provider"), run_info.get("stdout_path"), session.get("authHome"),
189
+ ) if session else (empty_usage(), None)
190
190
  message = None if ok else (
191
191
  _provider_failure_message(error, run_info, error_code)
192
192
  if error_source == "provider"
@@ -221,6 +221,7 @@ def run_result_payload(api_schema_version, ok, parsed, session, run_info=None,
221
221
  "stdout_path": run_info.get("stdout_path"),
222
222
  "stderr_path": run_info.get("stderr_path"),
223
223
  "usage": usage,
224
+ "usage_model": usage_model,
224
225
  "warnings": warnings,
225
226
  "error": None if ok else {
226
227
  "source": error_source or "cdx",
@@ -358,6 +358,7 @@ class RunRegistry:
358
358
  # what `cdx run-status` and `cdx run-report` publish, and it
359
359
  # used to store whichever shape the producing reader emitted.
360
360
  run["usage"] = coerce_usage(final_payload.get("usage"))
361
+ run["usage_model"] = final_payload.get("usage_model")
361
362
  run["final_payload"] = final_payload
362
363
  if error:
363
364
  run["error"] = error
package/src/run_usage.py CHANGED
@@ -413,7 +413,7 @@ def extract_run_usage(provider, stdout_path):
413
413
  return extract_run_observation(provider, stdout_path)[0]
414
414
 
415
415
 
416
- def extract_run_observation(provider, stdout_path):
416
+ def extract_run_observation(provider, stdout_path, auth_home=None):
417
417
  if not stdout_path or not provider:
418
418
  return empty_usage(), None
419
419
  if provider not in SUPPORTED_PROVIDERS:
@@ -444,9 +444,46 @@ def extract_run_observation(provider, stdout_path):
444
444
  model_usage = record.get("modelUsage")
445
445
  if isinstance(model_usage, dict):
446
446
  models.update(model_usage)
447
+ if provider == "codex" and auth_home:
448
+ models.update(_codex_run_models(records, auth_home))
447
449
  return usage, next(iter(models)) if len(models) == 1 else None
448
450
 
449
451
 
452
+ def _codex_run_models(records, auth_home):
453
+ # Exec's token totals belong to this run; a rollout's totals may include
454
+ # earlier turns. Read only model evidence from the exact thread's rollout.
455
+ from .interactive_usage import MAX_TRANSCRIPT_BYTES, conversation_transcript
456
+
457
+ thread_ids = {record.get("thread_id") for record in records
458
+ if isinstance(record, dict) and record.get("type") == "thread.started"
459
+ and isinstance(record.get("thread_id"), str) and record["thread_id"]}
460
+ if len(thread_ids) != 1:
461
+ return set()
462
+ path = conversation_transcript("codex", auth_home, next(iter(thread_ids)))
463
+ if not path:
464
+ return set()
465
+ models = set()
466
+ try:
467
+ with open(path, encoding="utf-8", errors="replace") as handle:
468
+ text = handle.read(MAX_TRANSCRIPT_BYTES + 1)
469
+ if len(text) > MAX_TRANSCRIPT_BYTES:
470
+ return set()
471
+ for line in text.splitlines():
472
+ try:
473
+ record = json.loads(line)
474
+ except ValueError:
475
+ continue
476
+ if not isinstance(record, dict) or record.get("type") != "turn_context":
477
+ continue
478
+ payload = record.get("payload")
479
+ model = payload.get("model") if isinstance(payload, dict) else None
480
+ if isinstance(model, str) and model:
481
+ models.add(model)
482
+ except OSError:
483
+ return set()
484
+ return models
485
+
486
+
450
487
  def _parse_json_records(text):
451
488
  stripped = text.strip()
452
489
  try:
@@ -765,6 +765,18 @@ def backfill_interactive_usage(store, name=None, dry_run=True):
765
765
  return store["backfill_launch_usage"](updates, dry_run=dry_run)
766
766
 
767
767
 
768
+ def backfill_history_models(store, base_dir, dry_run=True):
769
+ """Recover historical models from evidence belonging to each history row."""
770
+ from .history_models import read_run_reports, recover_model
771
+
772
+ sessions = {session["name"]: session for session in store["list_sessions"]()}
773
+ reports = read_run_reports(base_dir)
774
+ return store["backfill_launch_models"](
775
+ lambda entry: recover_model(entry, sessions.get(entry.get("session_name")), reports),
776
+ dry_run=dry_run,
777
+ )
778
+
779
+
768
780
  def refresh_interactive_usage_models(store, dry_run=True):
769
781
  """Replace a placeholder model with the exact transcript's real model."""
770
782
  from .interactive_usage import MATCH_CONVERSATION_ID, extract_interactive_usage
@@ -997,6 +1009,7 @@ def create_session_service(options=None):
997
1009
  "drop_unvouched_usage": store["drop_unvouched_usage"],
998
1010
  "backfill_interactive_usage": partial(backfill_interactive_usage, store),
999
1011
  "refresh_interactive_usage_models": partial(refresh_interactive_usage_models, store),
1012
+ "backfill_history_models": partial(backfill_history_models, store, base_dir),
1000
1013
  "update_auth_state": partial(update_auth_state, store),
1001
1014
  "get_status_row": partial(get_status_row, store, base_dir, env, fetch_codex_status),
1002
1015
  "get_status_rows": partial(get_status_rows, store, base_dir, env, fetch_codex_status),
@@ -343,6 +343,58 @@ def create_session_store(base_dir):
343
343
  _fsync_directory(state_dir)
344
344
  return {"scanned": scanned, "backfilled": backfilled, "dry_run": dry_run}
345
345
 
346
+ def backfill_launch_models(resolve, dry_run=True):
347
+ """Correct only missing models, under the history lock, with a backup."""
348
+ import uuid
349
+
350
+ from .history_models import EVIDENCE_KINDS, missing_model
351
+
352
+ result = {"dry_run": dry_run, "scanned": 0, "candidates": 0,
353
+ "recoverable": dict.fromkeys(EVIDENCE_KINDS, 0), "still_unknown": 0,
354
+ "backfilled": 0, "backup_path": None, "unknown_reasons": {}, "samples": []}
355
+ with _file_lock(lock_file):
356
+ try:
357
+ with open(launch_history_file, "rb") as handle:
358
+ original = handle.read()
359
+ except FileNotFoundError:
360
+ return result
361
+ rewritten = []
362
+ for line in original.splitlines(keepends=True):
363
+ try:
364
+ entry = json.loads(line)
365
+ except (ValueError, UnicodeError):
366
+ rewritten.append(line)
367
+ continue
368
+ if not isinstance(entry, dict):
369
+ rewritten.append(line)
370
+ continue
371
+ result["scanned"] += 1
372
+ if entry.get("usage") and missing_model(entry.get("usage_model")):
373
+ result["candidates"] += 1
374
+ model, kind, reason = resolve(entry)
375
+ if model:
376
+ result["recoverable"][kind] += 1
377
+ entry["usage_model"] = model
378
+ entry["usage_model_backfilled_reason"] = kind
379
+ line = (json.dumps(entry, separators=(",", ":")) + "\n").encode("utf-8")
380
+ else:
381
+ result["still_unknown"] += 1
382
+ reasons = result["unknown_reasons"]
383
+ reasons[reason] = reasons.get(reason, 0) + 1
384
+ if len(result["samples"]) < 20:
385
+ result["samples"].append({"session_name": entry.get("session_name"),
386
+ "run_id": entry.get("run_id"), "started_at": entry.get("started_at"),
387
+ "model": model, "evidence": kind, "reason": reason})
388
+ rewritten.append(line)
389
+ recovered = sum(result["recoverable"].values())
390
+ if not dry_run and recovered:
391
+ backup = f"{launch_history_file}.models-{uuid.uuid4().hex}.bak"
392
+ atomic_write(backup, original)
393
+ atomic_write(launch_history_file, b"".join(rewritten))
394
+ result["backup_path"] = backup
395
+ result["backfilled"] = recovered
396
+ return result
397
+
346
398
  def list_launch_history(session_name=None, limit=20):
347
399
  with _file_lock(lock_file):
348
400
  try:
@@ -380,5 +432,6 @@ def create_session_store(base_dir):
380
432
  "append_launch_history": append_launch_history,
381
433
  "drop_unvouched_usage": drop_unvouched_usage,
382
434
  "backfill_launch_usage": backfill_launch_usage,
435
+ "backfill_launch_models": backfill_launch_models,
383
436
  "list_launch_history": list_launch_history,
384
437
  }