ccx-native 0.1.0 → 0.2.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.
@@ -23,6 +23,14 @@ if TYPE_CHECKING:
23
23
  _TERMINAL_STATES = {"dead", "idle-killed", "crashed", "budget-stopped"}
24
24
 
25
25
 
26
+ def _system_error_reason(error) -> str:
27
+ """The crash reason, with Codex's own error message when there is one."""
28
+ message = error.get("message") if isinstance(error, dict) else None
29
+ if isinstance(message, str) and message.strip():
30
+ return "Codex thread system error: " + message.strip()[:500]
31
+ return "Codex thread system error"
32
+
33
+
26
34
  _PARENT_NOTIFY_FENCES = 32
27
35
 
28
36
 
@@ -1373,8 +1381,21 @@ class Worker:
1373
1381
  reason="Codex thread system error",
1374
1382
  )
1375
1383
  self._abandon_active_compact_locked("Codex thread system error")
1376
- self.record["lifecycle"]["state"] = "crashed"
1377
- self.record["lifecycle"]["lastError"] = "Codex thread system error"
1384
+ # Best effort: name the native cause when it is
1385
+ # already known (Codex may also report it later, on
1386
+ # turn/completed).
1387
+ lifecycle = self.record["lifecycle"]
1388
+ native_error = None
1389
+ active_error = self.record.get("activeTurnError")
1390
+ last_turn = self.record.get("lastTurn")
1391
+ if lifecycle.get("activeTurnId"):
1392
+ if (isinstance(active_error, dict) and active_error.get("turnId")
1393
+ == lifecycle.get("activeTurnId")):
1394
+ native_error = active_error.get("error")
1395
+ elif isinstance(last_turn, dict) and last_turn.get("status") == "failed":
1396
+ native_error = last_turn.get("error")
1397
+ lifecycle["state"] = "crashed"
1398
+ lifecycle["lastError"] = _system_error_reason(native_error)
1378
1399
  elif method == "turn/completed":
1379
1400
  changed = True
1380
1401
  turn = params.get("turn") or {}
@@ -1527,6 +1548,12 @@ class Worker:
1527
1548
  # demote a worker whose runtime health is already known to
1528
1549
  # be bad.
1529
1550
  self.record["lifecycle"]["state"] = "crashed"
1551
+ # Best effort: name the native cause (a 429, a refused
1552
+ # compact) instead of only the generic status.
1553
+ if self.record["lifecycle"].get("lastError") == _system_error_reason(None):
1554
+ self.record["lifecycle"]["lastError"] = (
1555
+ _system_error_reason(turn.get("error"))
1556
+ )
1530
1557
  elif isinstance(self.record.get("activeCompact"), dict):
1531
1558
  # A foreign/native turn can settle while compaction still
1532
1559
  # owns its own operation fence. Do not publish idle until
@@ -2134,31 +2161,32 @@ class Worker:
2134
2161
  return failure
2135
2162
 
2136
2163
  if op == "interrupt":
2164
+ # The notification reader publishes a successor turn under the
2165
+ # worker lock, so read state, turn and thread together once and
2166
+ # interrupt exactly the turn that was checked.
2167
+ with self.lock:
2168
+ state = self.record["lifecycle"]["state"]
2169
+ turn_id = self.record["lifecycle"].get("activeTurnId")
2170
+ # Full-UUID records keep the original thread under native.threadId.
2171
+ native = self.record.get("native") or {}
2172
+ thread_id = native.get("threadId") or self.record.get("threadId")
2137
2173
  expected_turn_id = message.get("expectedTurnId")
2138
- current_turn_id = self.record["lifecycle"].get("activeTurnId")
2139
- if (
2140
- expected_turn_id is not None
2141
- and expected_turn_id != current_turn_id
2142
- ):
2174
+ if expected_turn_id is not None and expected_turn_id != turn_id:
2143
2175
  return {
2144
2176
  "ok": False,
2145
2177
  "error": "active turn changed",
2146
2178
  "state": state,
2147
- "turnId": current_turn_id,
2179
+ "turnId": turn_id,
2148
2180
  "definitive": True,
2149
2181
  }
2150
2182
  if state not in {"busy", "waiting"}:
2151
2183
  return {"ok": False, "definitive": True,
2152
2184
  "error": "no active turn", "state": state}
2153
- turn_id = self.record["lifecycle"].get("activeTurnId")
2154
2185
  if not turn_id:
2155
2186
  return {"ok": False, "definitive": True,
2156
2187
  "error": "active turn id is unavailable", "state": state}
2157
- # Full-UUID records keep the original thread under native.threadId.
2158
- native = self.record.get("native") or {}
2159
2188
  self.client.request("turn/interrupt", {
2160
- "threadId": native.get("threadId") or self.record["threadId"],
2161
- "turnId": turn_id,
2189
+ "threadId": thread_id, "turnId": turn_id,
2162
2190
  })
2163
2191
  return {"ok": True, "state": state, "turnId": turn_id}
2164
2192
  if op in {"approve", "reject"}:
@@ -0,0 +1,131 @@
1
+ """Read one Codex thread's history from Codex's local history database.
2
+
3
+ Codex keeps a SQLite projection of every thread's rollout,
4
+ ``$CODEX_HOME/thread_history_1.sqlite``. Its ``thread_items.item_json`` holds
5
+ the same ThreadItem JSON app-server returns, so a thread rebuilt from it feeds
6
+ :func:`ccx.ops.conversation.build_codex` unchanged and yields the same turn
7
+ sequence and item ordinals. Reading it takes milliseconds where app-server
8
+ paging can take minutes, and loads nothing into codexd.
9
+
10
+ It is Codex's private store, so every read is defensive: opened read-only
11
+ (``mode=ro``, never ``immutable=1``, so SQLite still honours the WAL), gated on
12
+ the tables and columns used here, short, and always closed. Any doubt returns
13
+ ``None`` and the caller falls back to app-server ``thread/read``.
14
+
15
+ The thread id must come from the worker's verified original source
16
+ (``codex.telemetry._original_source``), never from a record field read here.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import sqlite3
23
+ from pathlib import Path
24
+ from urllib.parse import quote
25
+
26
+ HISTORY_DB_NAME = "thread_history_1.sqlite"
27
+
28
+ # The schema this reader was checked against (2026-11, Codex `_sqlx_migrations`
29
+ # version 8). A newer version is still read when the required columns exist;
30
+ # the document then says so in a warning.
31
+ VERIFIED_SCHEMA_VERSION = 8
32
+
33
+ _REQUIRED = {
34
+ "thread_turns": {"thread_id", "turn_id", "rollout_ordinal", "status",
35
+ "error_json", "started_at", "completed_at", "duration_ms"},
36
+ "thread_items": {"thread_id", "turn_id", "item_id", "rollout_ordinal",
37
+ "item_json"},
38
+ "_sqlx_migrations": {"version", "success"},
39
+ }
40
+ _TURN_STATUSES = frozenset({"completed", "failed", "inProgress", "interrupted"})
41
+ _BUSY_TIMEOUT_S = 2.0
42
+
43
+
44
+ def history_db_path() -> Path:
45
+ # Like Codex itself, an empty CODEX_HOME means unset.
46
+ return Path(os.environ.get("CODEX_HOME") or "~/.codex").expanduser() / HISTORY_DB_NAME
47
+
48
+
49
+ def _schema_ok(conn: sqlite3.Connection) -> bool:
50
+ for table, columns in _REQUIRED.items():
51
+ found = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
52
+ if not columns <= found:
53
+ return False
54
+ return True
55
+
56
+
57
+ def read_thread_db(thread_id: str, *, path: Path | None = None) -> dict | None:
58
+ """Return the ``thread/read(includeTurns)`` shape for one thread, or ``None``.
59
+
60
+ Turns come in ``rollout_ordinal`` order, including turns with no items;
61
+ each turn's items come in their own ``rollout_ordinal`` order. The result
62
+ carries ``historySource: "codex-history-db"`` and ``historyDbVersion``.
63
+ """
64
+ if not isinstance(thread_id, str) or not thread_id:
65
+ return None
66
+ db = path or history_db_path()
67
+ if not db.is_file():
68
+ return None
69
+ try:
70
+ conn = sqlite3.connect(f"file:{quote(str(db))}?mode=ro", uri=True,
71
+ timeout=_BUSY_TIMEOUT_S)
72
+ except sqlite3.Error:
73
+ return None
74
+ try:
75
+ if not _schema_ok(conn):
76
+ return None
77
+ row = conn.execute(
78
+ "SELECT max(version) FROM _sqlx_migrations WHERE success").fetchone()
79
+ version = row[0] if row else None
80
+ # One read transaction: turns and items come from the same snapshot.
81
+ conn.execute("BEGIN")
82
+ turn_rows = conn.execute(
83
+ "SELECT turn_id, status, error_json, started_at, completed_at, duration_ms"
84
+ " FROM thread_turns WHERE thread_id = ? ORDER BY rollout_ordinal",
85
+ (thread_id,)).fetchall()
86
+ item_rows = conn.execute(
87
+ "SELECT turn_id, item_json FROM thread_items"
88
+ " WHERE thread_id = ? ORDER BY rollout_ordinal",
89
+ (thread_id,)).fetchall()
90
+ conn.execute("COMMIT")
91
+ except sqlite3.Error:
92
+ return None
93
+ finally:
94
+ conn.close()
95
+
96
+ if not turn_rows:
97
+ return None
98
+ items: dict[str, list[dict]] = {}
99
+ for turn_id, item_json in item_rows:
100
+ try:
101
+ item = json.loads(item_json)
102
+ except (TypeError, ValueError):
103
+ return None
104
+ if not isinstance(item, dict):
105
+ return None
106
+ items.setdefault(turn_id, []).append(item)
107
+ known = {r[0] for r in turn_rows}
108
+ if set(items) - known:
109
+ return None # items of a turn the projection doesn't list: not coherent
110
+
111
+ turns = []
112
+ for turn_id, status, error_json, started, completed, duration in turn_rows:
113
+ if status not in _TURN_STATUSES:
114
+ return None
115
+ error = None
116
+ if error_json:
117
+ try:
118
+ error = json.loads(error_json)
119
+ except ValueError:
120
+ return None
121
+ turns.append({
122
+ "id": turn_id, "status": status, "error": error,
123
+ "startedAt": started, "completedAt": completed, "durationMs": duration,
124
+ "items": items.get(turn_id, []), "itemsView": "full",
125
+ })
126
+ return {"id": thread_id, "turns": turns, "historySource": "codex-history-db",
127
+ "historyDbVersion": version}
128
+
129
+
130
+ __all__ = ["HISTORY_DB_NAME", "VERIFIED_SCHEMA_VERSION", "history_db_path",
131
+ "read_thread_db"]
@@ -54,6 +54,8 @@ CODEXD_BOOT_TIMEOUT_S = 60
54
54
  # How long a caller without a deadline waits for a stopping codexd to exit. Its
55
55
  # shutdown closes the app-server (up to three 3s waits) before it lets go.
56
56
  CODEXD_STOP_WAIT_S = 15
57
+ # One presence probe's reply budget; a slower one is retried, not refused.
58
+ PRESENCE_PROBE_S = 5
57
59
 
58
60
 
59
61
  @contextmanager
@@ -104,8 +106,8 @@ def _short_lived_app_server(
104
106
  client.close()
105
107
 
106
108
 
107
- def list_models(*, cwd: str, timeout: float = 30) -> list[str]:
108
- """Return every model ID advertised by Codex's stable ``model/list`` API."""
109
+ def list_model_entries(*, cwd: str, timeout: float = 30) -> list[dict]:
110
+ """Return model/list's item dictionaries (default visibility, every page)."""
109
111
  try:
110
112
  with _short_lived_app_server(
111
113
  cwd=cwd,
@@ -113,18 +115,15 @@ def list_models(*, cwd: str, timeout: float = 30) -> list[str]:
113
115
  timeout=timeout,
114
116
  rejection_prefix="unsupported request during model list read",
115
117
  ) as client:
116
- models: list[str] = []
118
+ entries: list[dict] = []
117
119
  cursor: str | None = None
118
120
  seen_cursors: set[str] = set()
119
121
  while True:
120
122
  params = {"cursor": cursor} if cursor is not None else {}
121
123
  page = client.request("model/list", params, timeout=timeout)
122
124
  for item in page.get("data") or []:
123
- if not isinstance(item, dict):
124
- continue
125
- model_id = item.get("model") or item.get("id")
126
- if isinstance(model_id, str) and model_id and model_id not in models:
127
- models.append(model_id)
125
+ if isinstance(item, dict):
126
+ entries.append(item)
128
127
  next_cursor = page.get("nextCursor")
129
128
  if not isinstance(next_cursor, str) or not next_cursor:
130
129
  break
@@ -132,15 +131,27 @@ def list_models(*, cwd: str, timeout: float = 30) -> list[str]:
132
131
  raise RuntimeError("model/list returned a repeated cursor")
133
132
  seen_cursors.add(next_cursor)
134
133
  cursor = next_cursor
135
- if not models:
136
- raise RuntimeError("model/list returned no model IDs")
137
- return models
134
+ if not entries:
135
+ raise RuntimeError("model/list returned no model entries")
136
+ return entries
138
137
  except (OSError, RuntimeError) as error:
139
138
  raise SessionUnavailable(
140
139
  f"Codex model list unavailable: {error}"
141
140
  ) from error
142
141
 
143
142
 
143
+ def list_models(*, cwd: str, timeout: float = 30) -> list[str]:
144
+ """Return every model ID advertised by Codex's stable model/list API."""
145
+ models: list[str] = []
146
+ for item in list_model_entries(cwd=cwd, timeout=timeout):
147
+ model_id = item.get("model") or item.get("id")
148
+ if isinstance(model_id, str) and model_id and model_id not in models:
149
+ models.append(model_id)
150
+ if not models:
151
+ raise SessionUnavailable("Codex model list unavailable: model/list returned no model IDs")
152
+ return models
153
+
154
+
144
155
  def validate_model(model: str, *, cwd: str, timeout: float = 30) -> list[str]:
145
156
  """Validate one explicit model and return the complete advertised ID list."""
146
157
  if not isinstance(model, str) or not model:
@@ -233,7 +244,7 @@ def observation_daemon(
233
244
  return False
234
245
  if transport != RUNTIME_SERVED:
235
246
  raise SessionUnavailable(
236
- "codexd presence is inconclusive; no second app-server was started",
247
+ inconclusive_presence(probe, "no second app-server was started"),
237
248
  short=short or "codexd",
238
249
  )
239
250
  require_compatible_daemon(probe, short=short)
@@ -292,8 +303,15 @@ def _lifetime_lock_held() -> bool | None:
292
303
 
293
304
  def _transport_failure(error: BaseException) -> dict[str, Any]:
294
305
  """Classify a failed socket observation without confusing timeout with absence."""
295
- if isinstance(error, (TimeoutError, socket.timeout)):
296
- presence = RUNTIME_INCONCLUSIVE
306
+ if isinstance(error, (TimeoutError, socket.timeout, EOFError)):
307
+ # A busy machine or a daemon mid-exit: unknown, but worth asking again
308
+ # inside the caller's budget. Never evidence of absence.
309
+ return {
310
+ "ok": False,
311
+ "error": f"codexd unavailable: {error.__class__.__name__}",
312
+ "transport": RUNTIME_INCONCLUSIVE,
313
+ "retry": True,
314
+ }
297
315
  elif isinstance(error, OSError) and getattr(error, "errno", None) in {
298
316
  2, # ENOENT: no socket file
299
317
  32, # EPIPE: the listener closed under the request
@@ -536,7 +554,8 @@ def preflight_daemon(
536
554
  return probe
537
555
  if probe.get("transport") != RUNTIME_SERVED:
538
556
  raise SessionUnavailable(
539
- "codexd presence is inconclusive", short=short or "codexd"
557
+ inconclusive_presence(probe, "nothing was sent"),
558
+ short=short or "codexd",
540
559
  )
541
560
  require_compatible_daemon(probe, short=short)
542
561
  return None
@@ -569,9 +588,16 @@ def rpc(
569
588
  )
570
589
 
571
590
 
572
- def daemon_status(timeout: float = 2) -> dict[str, Any]:
573
- """Probe the daemon. Deliberately unchecked: status is a recovery tool."""
574
- return daemon_rpc({"op": "daemon-status"}, timeout=timeout)
591
+ def daemon_status(timeout: float = 2, *, presence_only: bool = False) -> dict[str, Any]:
592
+ """Probe the daemon. Deliberately unchecked: status is a recovery tool.
593
+
594
+ ``presence_only`` skips the per-worker states, which take every worker's
595
+ lock; a daemon that predates the flag ignores it and lists them anyway.
596
+ """
597
+ message: dict[str, Any] = {"op": "daemon-status"}
598
+ if presence_only:
599
+ message["workers"] = False
600
+ return daemon_rpc(message, timeout=timeout)
575
601
 
576
602
 
577
603
  def _stopping(probe: dict[str, Any]) -> bool:
@@ -584,22 +610,37 @@ def _stopping(probe: dict[str, Any]) -> bool:
584
610
 
585
611
 
586
612
  def settled_daemon_status(deadline: float | None) -> dict[str, Any]:
587
- """Probe the daemon, waiting out one that is binding or shutting down.
613
+ """Probe the daemon, waiting out one that is slow, binding or shutting down.
588
614
 
589
615
  A stopping codexd refuses new loads and is about to release the runtime,
590
- so neither "use it" nor "start another" is right yet. Waiting is bounded by
591
- the caller's deadline (or ``CODEXD_STOP_WAIT_S``); what is still
592
- transitional then is returned as the inconclusive probe it is.
616
+ so neither "use it" nor "start another" is right yet; a probe that timed
617
+ out says nothing either way. Both are asked again until the caller's
618
+ deadline (or ``CODEXD_STOP_WAIT_S``); what is still unknown then is
619
+ returned as the inconclusive probe it is, with how long it was waited for.
593
620
  """
594
- limit = (
595
- deadline if deadline is not None
596
- else time.monotonic() + CODEXD_STOP_WAIT_S
597
- )
621
+ started = time.monotonic()
622
+ limit = deadline if deadline is not None else started + CODEXD_STOP_WAIT_S
623
+ attempts = 0
598
624
  while True:
599
- probe = daemon_status(timeout=max(0.25, remaining(deadline, 1)))
600
- if not _stopping(probe) or time.monotonic() >= limit:
625
+ attempts += 1
626
+ probe = daemon_status(timeout=remaining(limit, PRESENCE_PROBE_S),
627
+ presence_only=True)
628
+ now = time.monotonic()
629
+ if not (_stopping(probe) or probe.get("retry")) or now >= limit:
630
+ if probe.get("transport") == RUNTIME_INCONCLUSIVE:
631
+ probe = {**probe, "attempts": attempts,
632
+ "waitedSeconds": round(now - started, 1)}
601
633
  return probe
602
- time.sleep(min(0.05, max(0.0, limit - time.monotonic())))
634
+ time.sleep(min(0.1, max(0.0, limit - now)))
635
+
636
+
637
+ def inconclusive_presence(probe: dict[str, Any], outcome: str) -> str:
638
+ """The refusal for a presence probe that never settled, with its evidence."""
639
+ error = str(probe.get("error") or "no reply").removeprefix("codexd unavailable: ")
640
+ attempts = probe.get("attempts") or 1
641
+ tried = (f" after {attempts} probes over {probe.get('waitedSeconds')}s"
642
+ if attempts > 1 else "")
643
+ return f"codexd presence is inconclusive ({error}{tried}); {outcome}"
603
644
 
604
645
 
605
646
  def observe_worker(
@@ -1198,7 +1239,7 @@ def ensure_daemon(deadline: float | None = None) -> dict[str, Any]:
1198
1239
  probe.get("transport") == RUNTIME_INCONCLUSIVE and probe.get("transitional")
1199
1240
  ):
1200
1241
  raise SessionUnavailable(
1201
- "codexd presence is inconclusive; refusing to start a second daemon",
1242
+ inconclusive_presence(probe, "refusing to start a second daemon"),
1202
1243
  short="codexd",
1203
1244
  )
1204
1245
  with daemon_spawn_lock(deadline):
@@ -1211,7 +1252,7 @@ def ensure_daemon(deadline: float | None = None) -> dict[str, Any]:
1211
1252
  "transitional"
1212
1253
  ):
1213
1254
  raise SessionUnavailable(
1214
- "codexd presence is inconclusive; refusing to start a second daemon",
1255
+ inconclusive_presence(probe, "refusing to start a second daemon"),
1215
1256
  short="codexd",
1216
1257
  )
1217
1258
  process = None
@@ -1389,6 +1430,7 @@ __all__ = [
1389
1430
  "daemon_stop",
1390
1431
  "ensure_daemon",
1391
1432
  "identity",
1433
+ "list_model_entries",
1392
1434
  "list_models",
1393
1435
  "daemon_generation",
1394
1436
  "daemon_spawn_lock",
package/ccx/models.py ADDED
@@ -0,0 +1,225 @@
1
+ """Read-only, engine-neutral model catalogs with private per-engine caching."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from datetime import datetime, timezone
7
+ import hashlib
8
+ from pathlib import Path
9
+ import time
10
+
11
+ from ccx import constants
12
+ from ccx.claude.oauth import oauth_organization_uuid
13
+ from ccx.claude.usage_control import request_models
14
+ from ccx.codex.runtime import list_model_entries
15
+ from ccx.errors import InvalidRequest
16
+ from ccx.system.private_json import _atomic_private_json, _read_private_json
17
+ from ccx.types import ModelCatalog, ModelInfo
18
+
19
+ _CACHE_TTL_S = 24 * 60 * 60
20
+ _CACHE_VERSION = 1
21
+ _ENGINES = ("claude", "codex")
22
+
23
+
24
+ def _text(value: object) -> str | None:
25
+ return value if isinstance(value, str) and value else None
26
+
27
+
28
+ def _efforts(value: object) -> list[str]:
29
+ if not isinstance(value, list):
30
+ return []
31
+ return list(dict.fromkeys(entry for entry in value
32
+ if isinstance(entry, str) and entry))
33
+
34
+
35
+ def normalize_claude_models(entries: object) -> list[ModelInfo]:
36
+ """Project Claude initialize's models only, with per-model capabilities."""
37
+ if not isinstance(entries, list):
38
+ return []
39
+ models = []
40
+ for item in entries:
41
+ if not isinstance(item, dict):
42
+ continue
43
+ ident = _text(item.get("value"))
44
+ resolved = _text(item.get("resolvedModel"))
45
+ name = _text(item.get("displayName"))
46
+ if not (ident and resolved and name):
47
+ continue
48
+ models.append(ModelInfo(
49
+ id=ident,
50
+ resolved=resolved,
51
+ display_name=name,
52
+ description=(item.get("description")
53
+ if isinstance(item.get("description"), str) else None),
54
+ alias=ident != resolved,
55
+ default=ident == "default",
56
+ efforts=(_efforts(item.get("supportedEffortLevels"))
57
+ if item.get("supportsEffort") is True else []),
58
+ default_effort=None,
59
+ features={
60
+ "fast": item.get("supportsFastMode") is True,
61
+ "auto": item.get("supportsAutoMode") is True,
62
+ "thinkingOff": item.get("supportsThinkingOff") is True,
63
+ },
64
+ ))
65
+ return models
66
+
67
+
68
+ def normalize_codex_models(entries: object) -> list[ModelInfo]:
69
+ """Project model/list items; Codex's model ID is the --model spelling."""
70
+ if not isinstance(entries, list):
71
+ return []
72
+ models = []
73
+ seen = set()
74
+ for item in entries:
75
+ if not isinstance(item, dict) or item.get("hidden") is True:
76
+ continue
77
+ ident = _text(item.get("model")) or _text(item.get("id"))
78
+ if not ident or ident in seen:
79
+ continue
80
+ seen.add(ident)
81
+ efforts = item.get("supportedReasoningEfforts")
82
+ levels = (_efforts([entry.get("reasoningEffort") for entry in efforts
83
+ if isinstance(entry, dict)])
84
+ if isinstance(efforts, list) else [])
85
+ models.append(ModelInfo(
86
+ id=ident,
87
+ resolved=ident,
88
+ display_name=_text(item.get("displayName")) or ident,
89
+ description=(item.get("description")
90
+ if isinstance(item.get("description"), str) else None),
91
+ alias=False,
92
+ default=item.get("isDefault") is True,
93
+ efforts=levels,
94
+ default_effort=_text(item.get("defaultReasoningEffort")),
95
+ features={},
96
+ ))
97
+ return models
98
+
99
+
100
+ def _cache_path(engine: str) -> Path:
101
+ return constants.CCX_ROOT / f"models-{engine}.json"
102
+
103
+
104
+ def _local_claude_version() -> str | None:
105
+ # Bypass oauth.py's process-wide lru_cache: a live CcxClient must notice
106
+ # when the installed Claude Code binary changes beneath it.
107
+ from ccx.claude.oauth import claude_code_version
108
+ return claude_code_version.__wrapped__()
109
+
110
+
111
+ def _cache_key(engine: str) -> dict:
112
+ if engine == "codex":
113
+ # No cheap native Codex version helper; this engine uses the TTL only.
114
+ return {}
115
+ org = oauth_organization_uuid()
116
+ return {
117
+ "version": _local_claude_version(),
118
+ "orgDigest": hashlib.sha256(org.encode()).hexdigest() if org else None,
119
+ }
120
+
121
+
122
+ def _read_cache(engine: str) -> tuple[ModelCatalog | None, dict]:
123
+ try:
124
+ data = _read_private_json(_cache_path(engine))
125
+ if not isinstance(data, dict) or data.get("version") != _CACHE_VERSION:
126
+ return None, {}
127
+ catalog = ModelCatalog.from_dict(data["catalog"])
128
+ if catalog.engine != engine:
129
+ return None, {}
130
+ key = data.get("key")
131
+ return catalog, key if isinstance(key, dict) else {}
132
+ except (OSError, ValueError, KeyError, TypeError, UnicodeError):
133
+ return None, {}
134
+
135
+
136
+ def _fresh(catalog: ModelCatalog, saved_key: dict, current_key: dict) -> bool:
137
+ if catalog.engine == "claude":
138
+ if saved_key.get("orgDigest") != current_key.get("orgDigest"):
139
+ return False
140
+ # If the installed version cannot be read, only TTL/org can be checked.
141
+ local_version = current_key.get("version")
142
+ if local_version is not None and catalog.engine_version != local_version:
143
+ return False
144
+ try:
145
+ stamped = datetime.fromisoformat(catalog.fetched_at.replace("Z", "+00:00"))
146
+ if stamped.tzinfo is None:
147
+ return False
148
+ age = time.time() - stamped.timestamp()
149
+ except (ValueError, OverflowError):
150
+ return False
151
+ return 0 <= age < _CACHE_TTL_S
152
+
153
+
154
+ def _probe(engine: str) -> tuple[str | None, list[ModelInfo]]:
155
+ if engine == "claude":
156
+ reply = request_models()
157
+ if reply.get("kind") != "success":
158
+ raise RuntimeError(reply.get("error") or "initialize unavailable")
159
+ body = reply["body"]
160
+ return _text(body.get("claude_code_version")), normalize_claude_models(body.get("models"))
161
+ # Account-level catalog, independent of the calling project's cwd.
162
+ return None, normalize_codex_models(list_model_entries(cwd=str(Path.home())))
163
+
164
+
165
+ def _builtin(engine: str, note: str) -> ModelCatalog:
166
+ # Only Claude has known aliases; inventing Codex model names would be wrong.
167
+ models = ([ModelInfo(
168
+ id=alias, resolved=None, display_name=alias.title(),
169
+ description=None, alias=True, default=False, efforts=[],
170
+ default_effort=None, features={},
171
+ ) for alias in constants.MODEL_ALIASES] if engine == "claude" else [])
172
+ return ModelCatalog(
173
+ engine=engine, engine_version=None, source="builtin",
174
+ fetched_at=_now(), note=note, models=models,
175
+ )
176
+
177
+
178
+ def _now() -> str:
179
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
180
+
181
+
182
+ def _catalog(engine: str, *, refresh: bool) -> ModelCatalog:
183
+ cached, saved_key = _read_cache(engine)
184
+ try:
185
+ current_key = _cache_key(engine)
186
+ except Exception:
187
+ # Broken or unavailable local identity metadata cannot block the other
188
+ # engine's catalog (or the provider read for this engine).
189
+ current_key = {}
190
+ if not refresh and cached is not None and _fresh(cached, saved_key, current_key):
191
+ return replace(cached, source="cache")
192
+
193
+ try:
194
+ version, models = _probe(engine)
195
+ if not models:
196
+ raise RuntimeError("no valid model entries")
197
+ except Exception as error:
198
+ note = f"{engine.capitalize()} model list unavailable: {error}"
199
+ if cached is not None:
200
+ return replace(cached, source="stale", note=note)
201
+ return _builtin(engine, note)
202
+
203
+ result = ModelCatalog(
204
+ engine=engine, engine_version=version, source="live",
205
+ fetched_at=_now(), note=None, models=models,
206
+ )
207
+ try:
208
+ _atomic_private_json(_cache_path(engine), {
209
+ "version": _CACHE_VERSION,
210
+ "key": ({"version": version, "orgDigest": current_key.get("orgDigest")}
211
+ if engine == "claude" else {}),
212
+ "catalog": result.as_dict(),
213
+ })
214
+ except OSError:
215
+ # A live listing remains useful if the private cache cannot be saved.
216
+ pass
217
+ return result
218
+
219
+
220
+ def read_models(*, engine: str | None = None, refresh: bool = False) -> list[ModelCatalog]:
221
+ """Return one or both model catalogs; probe failures degrade per engine."""
222
+ if engine is not None and engine not in _ENGINES:
223
+ raise InvalidRequest("engine must be claude or codex")
224
+ return [_catalog(name, refresh=refresh)
225
+ for name in ((engine,) if engine else _ENGINES)]