@iislee/opencodex 2.11.0 → 2.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (733) hide show
  1. package/AGENTS_INSTALL.md +109 -0
  2. package/README.md +117 -22
  3. package/bin/ocx.mjs +245 -88
  4. package/bin/package-main.mjs +1 -1
  5. package/gui/dist/assets/index-Be1sDpWw.css +1 -0
  6. package/gui/dist/assets/index-D4OKgUV4.js +114 -0
  7. package/gui/dist/index.html +2 -2
  8. package/gui/dist/provider-icons/alibaba-color.svg +1 -1
  9. package/gui/dist/provider-icons/antigravity-color.svg +1 -1
  10. package/gui/dist/provider-icons/claude-color.svg +1 -1
  11. package/gui/dist/provider-icons/cline-color.svg +16 -0
  12. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
  13. package/gui/dist/provider-icons/commandcode-color.svg +1 -0
  14. package/gui/dist/provider-icons/copilot-color.svg +1 -1
  15. package/gui/dist/provider-icons/cursor-color.svg +1 -1
  16. package/gui/dist/provider-icons/deepseek-color.svg +1 -1
  17. package/gui/dist/provider-icons/firepass-color.svg +1 -1
  18. package/gui/dist/provider-icons/fireworks-color.svg +1 -1
  19. package/gui/dist/provider-icons/gemini-color.svg +1 -1
  20. package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
  21. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
  22. package/gui/dist/provider-icons/grok.svg +1 -1
  23. package/gui/dist/provider-icons/groq-color.svg +1 -1
  24. package/gui/dist/provider-icons/huggingface-color.svg +1 -1
  25. package/gui/dist/provider-icons/kimi-color.svg +1 -1
  26. package/gui/dist/provider-icons/kiro-color.svg +2 -2
  27. package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
  28. package/gui/dist/provider-icons/mistral-color.svg +1 -1
  29. package/gui/dist/provider-icons/moonshot-color.svg +1 -1
  30. package/gui/dist/provider-icons/nvidia-color.svg +1 -1
  31. package/gui/dist/provider-icons/ollama-color.svg +1 -1
  32. package/gui/dist/provider-icons/openai.svg +1 -1
  33. package/gui/dist/provider-icons/opencode.svg +2 -1
  34. package/gui/dist/provider-icons/openrouter-color.svg +1 -1
  35. package/gui/dist/provider-icons/pi.svg +2 -2
  36. package/gui/dist/provider-icons/qianfan-color.svg +1 -1
  37. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
  38. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
  39. package/gui/dist/provider-icons/vllm-color.svg +1 -1
  40. package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
  41. package/package.json +21 -10
  42. package/src/adapters/anthropic-output-schema.ts +137 -0
  43. package/src/adapters/anthropic.ts +475 -62
  44. package/src/adapters/base.ts +82 -7
  45. package/src/adapters/client-fingerprint.ts +18 -12
  46. package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +69 -0
  47. package/src/adapters/command-code.ts +637 -0
  48. package/src/adapters/cursor/call-id.ts +44 -0
  49. package/src/adapters/cursor/catalog.ts +541 -0
  50. package/src/adapters/cursor/checkpoint-store.ts +308 -0
  51. package/src/adapters/cursor/cursor-errors.ts +144 -6
  52. package/src/adapters/cursor/discovery.ts +122 -14
  53. package/src/adapters/cursor/effort-map.ts +106 -4
  54. package/src/adapters/cursor/envelope-echo.ts +290 -0
  55. package/src/adapters/cursor/framing.ts +39 -0
  56. package/src/adapters/cursor/h2-pool.ts +123 -0
  57. package/src/adapters/cursor/http1-bidi.ts +361 -0
  58. package/src/adapters/cursor/images.ts +704 -0
  59. package/src/adapters/cursor/live-models.ts +180 -59
  60. package/src/adapters/cursor/live-transport.ts +623 -170
  61. package/src/adapters/cursor/message-mapper.ts +4 -1
  62. package/src/adapters/cursor/native-exec-common.ts +23 -2
  63. package/src/adapters/cursor/native-exec-desktop.ts +23 -0
  64. package/src/adapters/cursor/native-exec-fs.ts +10 -7
  65. package/src/adapters/cursor/native-exec-network.ts +1 -1
  66. package/src/adapters/cursor/native-exec-shell.ts +5 -3
  67. package/src/adapters/cursor/native-exec.ts +121 -14
  68. package/src/adapters/cursor/protobuf-events.ts +829 -11
  69. package/src/adapters/cursor/protobuf-request.ts +447 -75
  70. package/src/adapters/cursor/request-builder.ts +271 -35
  71. package/src/adapters/cursor/tool-definitions.ts +252 -12
  72. package/src/adapters/cursor/tool-result-normalize.ts +115 -0
  73. package/src/adapters/cursor/transport.ts +22 -0
  74. package/src/adapters/cursor/types.ts +48 -1
  75. package/src/adapters/cursor.ts +336 -39
  76. package/src/adapters/exec-tool-result-normalize.ts +99 -0
  77. package/src/adapters/google-antigravity-replay.ts +713 -41
  78. package/src/adapters/google-antigravity-wire.ts +48 -10
  79. package/src/adapters/google-errors.ts +44 -12
  80. package/src/adapters/google-http.ts +38 -10
  81. package/src/adapters/google-tool-schema.ts +80 -15
  82. package/src/adapters/google-truncation.ts +11 -0
  83. package/src/adapters/google.ts +742 -86
  84. package/src/adapters/identity.ts +39 -6
  85. package/src/adapters/image.ts +1 -1
  86. package/src/adapters/kiro-constants.ts +27 -0
  87. package/src/adapters/kiro-errors.ts +11 -0
  88. package/src/adapters/kiro-events.ts +19 -1
  89. package/src/adapters/kiro-thinking.ts +18 -2
  90. package/src/adapters/kiro-tools.ts +65 -17
  91. package/src/adapters/kiro.ts +270 -73
  92. package/src/adapters/mimo-free.ts +17 -0
  93. package/src/adapters/openai-chat-url.ts +11 -0
  94. package/src/adapters/openai-chat.ts +1377 -344
  95. package/src/adapters/openai-responses-url.ts +16 -0
  96. package/src/adapters/openai-responses.ts +1164 -64
  97. package/src/adapters/registry.ts +175 -0
  98. package/src/adapters/responses-tool-schema.ts +67 -0
  99. package/src/adapters/run-turn-queue.ts +36 -1
  100. package/src/adapters/tool-call-id.ts +119 -0
  101. package/src/adapters/tool-catalog-nudge.ts +105 -21
  102. package/src/adapters/xai-tool-schema.ts +436 -0
  103. package/src/adapters/xai-web-search.ts +186 -0
  104. package/src/bridge.ts +512 -92
  105. package/src/chat/inbound.ts +57 -20
  106. package/src/chat/outbound.ts +97 -33
  107. package/src/claude/agents-inject.ts +40 -10
  108. package/src/claude/context-windows.ts +37 -14
  109. package/src/claude/desktop-3p.ts +243 -9
  110. package/src/claude/gateway-cache.ts +41 -4
  111. package/src/claude/inbound.ts +72 -3
  112. package/src/claude/model-info.ts +38 -15
  113. package/src/claude/outbound.ts +88 -21
  114. package/src/cli/access.ts +46 -3
  115. package/src/cli/account-api.ts +150 -19
  116. package/src/cli/account-auth.ts +33 -6
  117. package/src/cli/account-catalog-refresh.ts +14 -0
  118. package/src/cli/account-extended.ts +648 -28
  119. package/src/cli/account-main.ts +317 -0
  120. package/src/cli/account.ts +97 -19
  121. package/src/cli/agent.ts +53 -2
  122. package/src/cli/alias.ts +66 -0
  123. package/src/cli/capabilities-command.ts +94 -0
  124. package/src/cli/capabilities.ts +496 -0
  125. package/src/cli/claude-agent-startup-sync.ts +73 -0
  126. package/src/cli/claude-desktop.ts +60 -15
  127. package/src/cli/claude.ts +129 -35
  128. package/src/cli/codex-log-guard-doctor.ts +103 -0
  129. package/src/cli/codex-shim-autorestore.ts +2 -0
  130. package/src/cli/codex-shim-readiness.ts +76 -0
  131. package/src/cli/combo.ts +8 -0
  132. package/src/cli/config-command.ts +74 -10
  133. package/src/cli/dispatch.ts +821 -0
  134. package/src/cli/doctor.ts +440 -56
  135. package/src/cli/ensure-desired-integrations.ts +152 -0
  136. package/src/cli/export-command.ts +46 -20
  137. package/src/cli/help.ts +34 -274
  138. package/src/cli/index.ts +417 -528
  139. package/src/cli/init.ts +5 -17
  140. package/src/cli/inspect.ts +230 -0
  141. package/src/cli/integrations.ts +120 -2
  142. package/src/cli/lab.ts +607 -0
  143. package/src/cli/launcher-context.ts +77 -0
  144. package/src/cli/minimax.ts +497 -0
  145. package/src/cli/models-runtime.ts +130 -2
  146. package/src/cli/models.ts +118 -16
  147. package/src/cli/observe.ts +123 -11
  148. package/src/cli/opencode.ts +4 -2
  149. package/src/cli/provider-runtime.ts +34 -3
  150. package/src/cli/provider.ts +25 -3
  151. package/src/cli/ready.ts +301 -0
  152. package/src/cli/registry.ts +476 -0
  153. package/src/cli/root.ts +86 -0
  154. package/src/cli/route-policy.ts +92 -0
  155. package/src/cli/runtime-api.ts +57 -10
  156. package/src/cli/star-prompt.ts +71 -15
  157. package/src/cli/status.ts +51 -4
  158. package/src/cli/storage.ts +234 -0
  159. package/src/cli/system-command.ts +16 -0
  160. package/src/cli/system-restart-client.ts +146 -0
  161. package/src/cli/tray-proxy.ts +153 -6
  162. package/src/cli/usage-report.ts +184 -0
  163. package/src/cli/v2.ts +137 -18
  164. package/src/cli/version-skew.ts +46 -0
  165. package/src/cli.ts +1 -1
  166. package/src/clients/config-export.ts +1442 -23
  167. package/src/clients/effective-status.ts +30 -2
  168. package/src/clients/probes/cline.ts +81 -0
  169. package/src/codex/account-label.ts +35 -1
  170. package/src/codex/account-lifecycle.ts +130 -13
  171. package/src/codex/account-namespaces.ts +49 -3
  172. package/src/codex/account-pause.ts +2 -1
  173. package/src/codex/account-priority.ts +84 -0
  174. package/src/codex/account-store.ts +29 -2
  175. package/src/codex/account-usability.ts +25 -2
  176. package/src/codex/admission.ts +256 -0
  177. package/src/codex/affinity-debug.ts +162 -0
  178. package/src/codex/app-server-processes.ts +570 -107
  179. package/src/codex/app-server-restart-service.ts +232 -0
  180. package/src/codex/auth-api.ts +893 -246
  181. package/src/codex/auth-collision.ts +5 -3
  182. package/src/codex/auth-context.ts +348 -32
  183. package/src/codex/autostart-health.ts +24 -1
  184. package/src/codex/catalog/account-models.ts +67 -0
  185. package/src/codex/catalog/aggregation.ts +86 -10
  186. package/src/codex/catalog/bundled.ts +331 -33
  187. package/src/codex/catalog/effort.ts +138 -32
  188. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  189. package/src/codex/catalog/kinds.ts +2 -0
  190. package/src/codex/catalog/metadata.ts +555 -45
  191. package/src/codex/catalog/native-models.ts +75 -0
  192. package/src/codex/catalog/parsing.ts +269 -30
  193. package/src/codex/catalog/provider-fetch.ts +1626 -144
  194. package/src/codex/catalog/sync.ts +1576 -187
  195. package/src/codex/catalog-admission.ts +199 -0
  196. package/src/codex/catalog-refresh-status.ts +105 -0
  197. package/src/codex/catalog-write-serialization.ts +242 -0
  198. package/src/codex/catalog.ts +6 -3
  199. package/src/codex/codex-write-lock.ts +386 -0
  200. package/src/codex/convergence-types.ts +614 -0
  201. package/src/codex/convergence.ts +676 -0
  202. package/src/codex/coordinator-doctor.ts +332 -0
  203. package/src/codex/custom-model-catalog-migration.ts +176 -0
  204. package/src/codex/desired-state.ts +230 -0
  205. package/src/codex/desktop-app-restart.ts +355 -0
  206. package/src/codex/features.ts +636 -39
  207. package/src/codex/generation.ts +202 -0
  208. package/src/codex/history-job.ts +436 -0
  209. package/src/codex/history-lock.ts +242 -0
  210. package/src/codex/history-manifest.ts +112 -0
  211. package/src/codex/history-migration-guardian.ts +30 -24
  212. package/src/codex/history-provider.ts +1016 -235
  213. package/src/codex/history-transition.ts +105 -0
  214. package/src/codex/history-worker.ts +223 -0
  215. package/src/codex/inject-coordination.ts +373 -0
  216. package/src/codex/inject.ts +1114 -152
  217. package/src/codex/injected-marker.ts +37 -3
  218. package/src/codex/integration-record.ts +266 -0
  219. package/src/codex/internal/catalog-writer.ts +203 -0
  220. package/src/codex/internal/history-writer.ts +81 -0
  221. package/src/codex/journal.ts +66 -4
  222. package/src/codex/log-guard/inspect.ts +524 -0
  223. package/src/codex/log-guard/lock.ts +150 -0
  224. package/src/codex/log-guard/maintenance.ts +403 -0
  225. package/src/codex/log-guard/path-safety.ts +88 -0
  226. package/src/codex/log-guard/policy.ts +44 -0
  227. package/src/codex/log-guard/processes.ts +205 -0
  228. package/src/codex/log-guard/protection.ts +489 -0
  229. package/src/codex/log-guard/sqlite-errors.ts +9 -0
  230. package/src/codex/main-account-cache.ts +24 -0
  231. package/src/codex/main-account.ts +29 -1
  232. package/src/codex/management-convergence.ts +167 -0
  233. package/src/codex/model-cache.ts +56 -10
  234. package/src/codex/model-entitlements.ts +360 -0
  235. package/src/codex/native-main-admission.ts +47 -0
  236. package/src/codex/native-main-auth-temp.ts +187 -0
  237. package/src/codex/native-main-claim.ts +178 -0
  238. package/src/codex/native-main-lock-file.ts +162 -0
  239. package/src/codex/native-main-owner.ts +329 -0
  240. package/src/codex/native-profile-api.ts +247 -0
  241. package/src/codex/native-profile-manager.ts +1531 -0
  242. package/src/codex/native-profile-processes.ts +121 -0
  243. package/src/codex/native-profile-recovery.ts +99 -0
  244. package/src/codex/native-profile-stage-store.ts +387 -0
  245. package/src/codex/native-profile-startup.ts +622 -0
  246. package/src/codex/native-profile-store.ts +855 -0
  247. package/src/codex/native-profile-types.ts +120 -0
  248. package/src/codex/native-residue.ts +675 -0
  249. package/src/codex/paths.ts +80 -1
  250. package/src/codex/plan-from-token.ts +140 -0
  251. package/src/codex/plan.ts +40 -0
  252. package/src/codex/plugins-doctor.ts +1 -1
  253. package/src/codex/pool-rotation.ts +74 -4
  254. package/src/codex/project-config-warnings.ts +20 -6
  255. package/src/codex/prompt-journal.ts +352 -0
  256. package/src/codex/prompt-layers.ts +1313 -0
  257. package/src/codex/prompt-lock.ts +143 -0
  258. package/src/codex/prompt-text-probe.ts +238 -0
  259. package/src/codex/quota-rejection.ts +298 -0
  260. package/src/codex/quota.ts +264 -21
  261. package/src/codex/refresh.ts +11 -2
  262. package/src/codex/reset-credit-recovery.ts +1044 -0
  263. package/src/codex/routing.ts +514 -94
  264. package/src/codex/runtime.ts +159 -38
  265. package/src/codex/shim.ts +1065 -31
  266. package/src/codex/subagent-model-fallback.ts +437 -43
  267. package/src/codex/sync.ts +191 -2
  268. package/src/codex/transition-state.ts +720 -0
  269. package/src/codex/upstream-host-health.ts +368 -0
  270. package/src/codex/user-identity.ts +557 -0
  271. package/src/codex/warmup.ts +187 -81
  272. package/src/codex/write-coordination.ts +114 -0
  273. package/src/combos/failover.ts +47 -0
  274. package/src/combos/index.ts +4 -0
  275. package/src/combos/request.ts +32 -0
  276. package/src/combos/types.ts +91 -9
  277. package/src/compatibility/index.ts +26 -0
  278. package/src/compatibility/manifest.ts +253 -0
  279. package/src/compatibility/openai-responses.ts +121 -0
  280. package/src/config/atomic-write.ts +219 -0
  281. package/src/config/paths.ts +40 -0
  282. package/src/config/process-state.ts +309 -0
  283. package/src/config/provider-name.ts +24 -0
  284. package/src/config/provider-validation.ts +177 -0
  285. package/src/config/rebase-provenance.ts +68 -0
  286. package/src/config.ts +1713 -709
  287. package/src/generated/compatibility-version.json +3324 -0
  288. package/src/generated/{jawcode-model-metadata.ts → model-metadata.ts} +19 -17
  289. package/src/grok/inject.ts +16 -5
  290. package/src/grok/inspect.ts +45 -0
  291. package/src/grok/sync.ts +2 -2
  292. package/src/images/loop.ts +157 -33
  293. package/src/images/plan.ts +24 -13
  294. package/src/integrations/config-io.ts +269 -0
  295. package/src/integrations/journal.ts +315 -0
  296. package/src/integrations/merge.ts +135 -0
  297. package/src/integrations/mutation-flight.ts +71 -0
  298. package/src/integrations/native/ownership-preflight.ts +202 -0
  299. package/src/integrations/omp-yaml-source.ts +358 -0
  300. package/src/integrations/owned-refresh.ts +74 -0
  301. package/src/integrations/ownership-policy.ts +160 -0
  302. package/src/integrations/ownership.ts +155 -0
  303. package/src/integrations/registry.ts +166 -0
  304. package/src/integrations/serialize.ts +314 -0
  305. package/src/integrations/state.ts +433 -0
  306. package/src/integrations/store.ts +103 -0
  307. package/src/integrations/writer-lock.ts +98 -0
  308. package/src/integrations/writer.ts +715 -0
  309. package/src/lab/artifacts/sanitize.ts +586 -0
  310. package/src/lab/artifacts/secure-fs.ts +475 -0
  311. package/src/lab/artifacts/store.ts +310 -0
  312. package/src/lab/automation/budgets.ts +78 -0
  313. package/src/lab/automation/config-persistence.ts +256 -0
  314. package/src/lab/automation/constants.ts +39 -0
  315. package/src/lab/automation/cooldown.ts +103 -0
  316. package/src/lab/automation/dispatch.ts +211 -0
  317. package/src/lab/automation/index.ts +13 -0
  318. package/src/lab/automation/orchestrator.ts +499 -0
  319. package/src/lab/automation/persistence.ts +512 -0
  320. package/src/lab/automation/planner.ts +371 -0
  321. package/src/lab/automation/policy.ts +136 -0
  322. package/src/lab/automation/queue.ts +191 -0
  323. package/src/lab/automation/recovery.ts +24 -0
  324. package/src/lab/automation/route-context.ts +21 -0
  325. package/src/lab/automation/run-key.ts +44 -0
  326. package/src/lab/automation/runs-query.ts +34 -0
  327. package/src/lab/automation/types.ts +160 -0
  328. package/src/lab/conformance/assertion.ts +325 -0
  329. package/src/lab/conformance/digest.ts +22 -0
  330. package/src/lab/conformance/executor.ts +741 -0
  331. package/src/lab/conformance/fixture-provider.ts +27 -0
  332. package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
  333. package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
  334. package/src/lab/conformance/harness-budget.ts +47 -0
  335. package/src/lab/conformance/index.ts +5 -0
  336. package/src/lab/conformance/jcs.ts +64 -0
  337. package/src/lab/conformance/json-pointer.ts +39 -0
  338. package/src/lab/conformance/manifest.ts +180 -0
  339. package/src/lab/conformance/mcp-stub.ts +179 -0
  340. package/src/lab/conformance/negative-controls.ts +164 -0
  341. package/src/lab/conformance/observation.ts +355 -0
  342. package/src/lab/conformance/runner.ts +68 -0
  343. package/src/lab/conformance/sse-normalize.ts +59 -0
  344. package/src/lab/conformance/suite-manifest.ts +78 -0
  345. package/src/lab/conformance/types.ts +214 -0
  346. package/src/lab/constants.ts +126 -0
  347. package/src/lab/digest.ts +64 -0
  348. package/src/lab/events/errors.ts +9 -0
  349. package/src/lab/events/limits.ts +117 -0
  350. package/src/lab/events/types.ts +229 -0
  351. package/src/lab/events/validate.ts +781 -0
  352. package/src/lab/fabric/constants.ts +40 -0
  353. package/src/lab/fabric/executor.ts +492 -0
  354. package/src/lab/fabric/index.ts +80 -0
  355. package/src/lab/fabric/manifest.ts +222 -0
  356. package/src/lab/fabric/observe.ts +489 -0
  357. package/src/lab/fabric/patch.ts +79 -0
  358. package/src/lab/fabric/producer-child.ts +139 -0
  359. package/src/lab/fabric/producer-isolate.ts +276 -0
  360. package/src/lab/fabric/producer-protocol.ts +61 -0
  361. package/src/lab/fabric/scratch.ts +439 -0
  362. package/src/lab/fabric/subject.ts +106 -0
  363. package/src/lab/fabric/types.ts +134 -0
  364. package/src/lab/fabric/verifier.ts +98 -0
  365. package/src/lab/index.ts +54 -0
  366. package/src/lab/ledger/artifact-refs.ts +127 -0
  367. package/src/lab/ledger/invalidation.ts +136 -0
  368. package/src/lab/ledger/purge.ts +310 -0
  369. package/src/lab/ledger/store.ts +532 -0
  370. package/src/lab/live/credential-lease.ts +53 -0
  371. package/src/lab/live/destination.ts +155 -0
  372. package/src/lab/live/executor.ts +336 -0
  373. package/src/lab/live/inert-tools.ts +56 -0
  374. package/src/lab/live/manifest.ts +85 -0
  375. package/src/lab/live/mcp-loopback.ts +57 -0
  376. package/src/lab/live/runner.ts +19 -0
  377. package/src/lab/live/sandbox.ts +61 -0
  378. package/src/lab/live/suite-manifest.ts +41 -0
  379. package/src/lab/live/transport.ts +118 -0
  380. package/src/lab/live/types.ts +197 -0
  381. package/src/lab/observe/from-conformance.ts +301 -0
  382. package/src/lab/observe/from-live.ts +117 -0
  383. package/src/lab/paths.ts +153 -0
  384. package/src/lab/projection/rebuild.ts +495 -0
  385. package/src/lab/projection/schema.ts +135 -0
  386. package/src/lab/projection/verdicts.ts +474 -0
  387. package/src/lab/projection/verification.ts +412 -0
  388. package/src/lab/public/bundle.ts +217 -0
  389. package/src/lab/public/community-authority.ts +175 -0
  390. package/src/lab/public/community-files.ts +29 -0
  391. package/src/lab/public/community.ts +479 -0
  392. package/src/lab/public/file-safety.ts +155 -0
  393. package/src/lab/public/ids.ts +26 -0
  394. package/src/lab/public/index.ts +16 -0
  395. package/src/lab/public/mutation-lock.ts +424 -0
  396. package/src/lab/public/operator.ts +353 -0
  397. package/src/lab/public/origin-purge.ts +79 -0
  398. package/src/lab/public/origin.ts +203 -0
  399. package/src/lab/public/privacy.ts +143 -0
  400. package/src/lab/public/private-file.ts +261 -0
  401. package/src/lab/public/project.ts +124 -0
  402. package/src/lab/public/purge-test-fault.ts +21 -0
  403. package/src/lab/public/purge.ts +223 -0
  404. package/src/lab/public/registry.ts +44 -0
  405. package/src/lab/public/revocation.ts +252 -0
  406. package/src/lab/public/signature.ts +243 -0
  407. package/src/lab/public/storage.ts +105 -0
  408. package/src/lab/public/strict-json.ts +206 -0
  409. package/src/lab/public/time.ts +26 -0
  410. package/src/lab/public/types.ts +172 -0
  411. package/src/lab/public/validate.ts +391 -0
  412. package/src/lab/query/catalog.ts +101 -0
  413. package/src/lab/query/connection.ts +107 -0
  414. package/src/lab/query/constants.ts +4 -0
  415. package/src/lab/query/cursor.ts +132 -0
  416. package/src/lab/query/dto-map.ts +277 -0
  417. package/src/lab/query/errors.ts +22 -0
  418. package/src/lab/query/freshness.ts +53 -0
  419. package/src/lab/query/index.ts +45 -0
  420. package/src/lab/query/latest-observation.ts +59 -0
  421. package/src/lab/query/passive-production.ts +159 -0
  422. package/src/lab/query/queries.ts +444 -0
  423. package/src/lab/query/types.ts +266 -0
  424. package/src/lab/subject/behavior-fingerprint.ts +77 -0
  425. package/src/lab/subject/installation-salt.ts +112 -0
  426. package/src/lab/subject/protocol-subject.ts +80 -0
  427. package/src/lab/subject/route-subject.ts +74 -0
  428. package/src/lib/admin-secrets.ts +24 -0
  429. package/src/lib/app-owned-memory-stores.ts +22 -0
  430. package/src/lib/bounded-body.ts +167 -11
  431. package/src/lib/bun-runtime.ts +125 -12
  432. package/src/lib/bun-stream-caps.ts +13 -9
  433. package/src/lib/codex-restart-contract.ts +120 -0
  434. package/src/lib/config-ownership.ts +26 -2
  435. package/src/lib/destination-policy.ts +65 -1
  436. package/src/lib/errors.ts +80 -5
  437. package/src/lib/fabric-task-execution-authority.ts +7 -0
  438. package/src/lib/fabric-task-host.ts +29 -0
  439. package/src/lib/lab-activation.ts +223 -0
  440. package/src/lib/lab-live-execution-authority.ts +13 -0
  441. package/src/lib/lab-live-host.ts +30 -0
  442. package/src/lib/lab-live-pinned-sender.ts +56 -0
  443. package/src/lib/lab-live-route-production.ts +130 -0
  444. package/src/lib/lab-passive-linker-registration.ts +26 -0
  445. package/src/lib/local-management-attestation.ts +51 -0
  446. package/src/lib/local-management-capability.ts +100 -0
  447. package/src/lib/local-provider-reload-contract.ts +100 -0
  448. package/src/lib/optional-shutdown-hooks.ts +57 -0
  449. package/src/lib/package-tree-integrity.ts +101 -0
  450. package/src/lib/pinned-http.ts +145 -26
  451. package/src/lib/process-control.ts +6 -2
  452. package/src/lib/provider-outbound.ts +49 -9
  453. package/src/lib/redact.ts +419 -3
  454. package/src/lib/self-launch-argv.ts +15 -0
  455. package/src/lib/server-resource-ownership.ts +71 -0
  456. package/src/lib/service-secrets.ts +15 -0
  457. package/src/lib/shadow-call.ts +35 -4
  458. package/src/lib/sse-decoder.ts +41 -0
  459. package/src/lib/state-store-registrations.ts +10 -2
  460. package/src/lib/system-restart-contract.ts +73 -0
  461. package/src/lib/token-estimate.ts +19 -2
  462. package/src/lib/tool-argument-integers.ts +253 -0
  463. package/src/lib/translator-budget.ts +44 -0
  464. package/src/lib/upstream-http-version.ts +57 -0
  465. package/src/lib/upstream-reachability.ts +95 -0
  466. package/src/lib/upstream-retry.ts +156 -3
  467. package/src/lib/windows-atomic-replace.ts +155 -0
  468. package/src/lib/windows-elevation.ts +70 -2
  469. package/src/lib/windows-secret-acl.ts +409 -69
  470. package/src/lib/windows-service-wrappers.ts +72 -0
  471. package/src/lib/windows-text.ts +106 -0
  472. package/src/lib/windows-user-principal.ts +341 -0
  473. package/src/lib/winsw.ts +33 -5
  474. package/src/oauth/account-import/google-antigravity-adapter.ts +74 -0
  475. package/src/oauth/account-import/index.ts +15 -0
  476. package/src/oauth/account-import/parser.ts +83 -0
  477. package/src/oauth/account-import/registry.ts +18 -0
  478. package/src/oauth/account-import/service.ts +75 -0
  479. package/src/oauth/account-import/types.ts +91 -0
  480. package/src/oauth/anthropic.ts +12 -1
  481. package/src/oauth/callback-server.ts +30 -4
  482. package/src/oauth/chatgpt.ts +12 -1
  483. package/src/oauth/cline.ts +203 -0
  484. package/src/oauth/command-code.ts +239 -0
  485. package/src/oauth/cursor.ts +46 -5
  486. package/src/oauth/generic-account-failover.ts +231 -0
  487. package/src/oauth/google-antigravity.ts +35 -3
  488. package/src/oauth/health.ts +20 -12
  489. package/src/oauth/index.ts +462 -71
  490. package/src/oauth/key-providers.ts +25 -0
  491. package/src/oauth/kimi.ts +25 -3
  492. package/src/oauth/kiro.ts +95 -6
  493. package/src/oauth/local-token-detect.ts +13 -2
  494. package/src/oauth/log.ts +3 -1
  495. package/src/oauth/login-cli.ts +88 -28
  496. package/src/oauth/nous.ts +798 -0
  497. package/src/oauth/open-browser-choice.ts +26 -0
  498. package/src/oauth/store.ts +133 -26
  499. package/src/oauth/token-guardian.ts +9 -3
  500. package/src/oauth/types.ts +15 -0
  501. package/src/pi/models.ts +2 -2
  502. package/src/providers/alibaba-region-migration.ts +1 -1
  503. package/src/providers/antigravity-models.ts +521 -31
  504. package/src/providers/auto-compact-budget.ts +65 -0
  505. package/src/providers/base-url-choices.ts +10 -0
  506. package/src/providers/codex-capacity.ts +292 -0
  507. package/src/providers/command-code-efforts.ts +176 -0
  508. package/src/providers/context-cap.ts +26 -8
  509. package/src/providers/cursor-pool.ts +72 -0
  510. package/src/providers/default-aliases.ts +65 -0
  511. package/src/providers/derive.ts +281 -6
  512. package/src/providers/fastwire.ts +507 -0
  513. package/src/providers/free-directory.ts +10 -7
  514. package/src/providers/google-vertex-location.ts +14 -0
  515. package/src/providers/key-failover.ts +71 -3
  516. package/src/providers/label.ts +35 -2
  517. package/src/providers/model-discovery-limits.ts +16 -0
  518. package/src/providers/model-discovery.ts +115 -22
  519. package/src/providers/model-presets.ts +119 -0
  520. package/src/providers/model-rename-migration.ts +255 -0
  521. package/src/providers/model-rename-startup.ts +28 -0
  522. package/src/providers/new-model-policy.ts +146 -0
  523. package/src/providers/openai-sidecar.ts +72 -4
  524. package/src/providers/openai-tier-startup.ts +31 -2
  525. package/src/providers/openai-tiers.ts +119 -4
  526. package/src/providers/openai-virtual-models.ts +1 -0
  527. package/src/providers/opencode-zen-rate-limit.ts +102 -0
  528. package/src/providers/provider-id-rewrite.ts +30 -0
  529. package/src/providers/quota.ts +1379 -40
  530. package/src/providers/registry.ts +1545 -112
  531. package/src/providers/request-pacing.ts +310 -0
  532. package/src/providers/service-tier.ts +277 -0
  533. package/src/providers/slug-codec.ts +94 -6
  534. package/src/providers/static-model-discovery.ts +86 -0
  535. package/src/providers/xai-responses-opt-in.ts +15 -0
  536. package/src/providers/xai-transport.ts +32 -4
  537. package/src/reasoning-effort.ts +68 -3
  538. package/src/responses/apply-patch-envelope.ts +63 -0
  539. package/src/responses/code-mode-helper-compat.ts +50 -0
  540. package/src/responses/compaction.ts +26 -1
  541. package/src/responses/custom-tool-compat.ts +384 -0
  542. package/src/responses/hosted-tool-policy.ts +9 -0
  543. package/src/responses/namespace-tool-compat.ts +435 -0
  544. package/src/responses/parser.ts +225 -38
  545. package/src/responses/provider-continuation.ts +98 -0
  546. package/src/responses/provider-opaque-metadata.ts +73 -0
  547. package/src/responses/reasoning-envelope.ts +9 -1
  548. package/src/responses/reasoning-replay-cache.ts +504 -0
  549. package/src/responses/schema.ts +12 -2
  550. package/src/responses/spill-store.ts +75 -10
  551. package/src/responses/state.ts +659 -31
  552. package/src/responses/thought-signature-replay.ts +364 -0
  553. package/src/responses/tool-search-compat.ts +301 -0
  554. package/src/responses/truncated-stop-reason.ts +60 -0
  555. package/src/responses/turn-termination.ts +107 -0
  556. package/src/router.ts +405 -31
  557. package/src/routing/analytics.ts +379 -0
  558. package/src/routing/capability.ts +244 -0
  559. package/src/routing/compatibility/assemble.ts +73 -0
  560. package/src/routing/compatibility/behavior.ts +278 -0
  561. package/src/routing/compatibility/catalog.ts +99 -0
  562. package/src/routing/compatibility/endpoint.ts +52 -0
  563. package/src/routing/compatibility/lab-evidence-provider.ts +130 -0
  564. package/src/routing/compatibility/policy.ts +181 -0
  565. package/src/routing/compatibility/provider-slot.ts +56 -0
  566. package/src/routing/compatibility/reader.ts +110 -0
  567. package/src/routing/compatibility/subject.ts +191 -0
  568. package/src/routing/compatibility/types.ts +64 -0
  569. package/src/routing/compatibility/version.ts +104 -0
  570. package/src/routing/cost.ts +77 -0
  571. package/src/routing/evaluator.ts +495 -0
  572. package/src/routing/health.ts +412 -0
  573. package/src/routing/history/cursor.ts +43 -0
  574. package/src/routing/history/indexer.ts +605 -0
  575. package/src/routing/history/schema.ts +72 -0
  576. package/src/routing/profile-namespace.ts +15 -0
  577. package/src/routing/profile.ts +547 -0
  578. package/src/routing/quota.ts +155 -0
  579. package/src/routing/request-evidence.ts +45 -0
  580. package/src/routing/trace.ts +776 -0
  581. package/src/server/adapter-resolve.ts +2 -29
  582. package/src/server/auth-cors.ts +289 -48
  583. package/src/server/background-lifecycle.ts +182 -0
  584. package/src/server/chat-completions.ts +154 -72
  585. package/src/server/chat-native-sse.ts +331 -0
  586. package/src/server/chat-native.ts +445 -0
  587. package/src/server/claude-messages.ts +160 -44
  588. package/src/server/direct-local-http.ts +347 -0
  589. package/src/server/effort-policy.ts +18 -0
  590. package/src/server/github-copilot-responses-repair.ts +338 -0
  591. package/src/server/gui-static.ts +39 -10
  592. package/src/server/images.ts +94 -12
  593. package/src/server/index.ts +1021 -190
  594. package/src/server/lifecycle.ts +336 -14
  595. package/src/server/live.ts +136 -17
  596. package/src/server/local-management-read-client.ts +90 -0
  597. package/src/server/local-provider-reload-client.ts +137 -0
  598. package/src/server/management/agent-settings-routes.ts +433 -116
  599. package/src/server/management/api-key-usage.ts +31 -5
  600. package/src/server/management/body.ts +6 -0
  601. package/src/server/management/codex-prompt-routes.ts +570 -0
  602. package/src/server/management/combo-routes.ts +64 -25
  603. package/src/server/management/config-routes.ts +511 -51
  604. package/src/server/management/context.ts +89 -2
  605. package/src/server/management/integration-routes.ts +498 -0
  606. package/src/server/management/lab-automation-routes.ts +206 -0
  607. package/src/server/management/lab-routes.ts +563 -0
  608. package/src/server/management/logs-usage-routes.ts +127 -52
  609. package/src/server/management/model-routes.ts +461 -133
  610. package/src/server/management/model-rows.ts +167 -0
  611. package/src/server/management/native-integration-routes.ts +770 -0
  612. package/src/server/management/oauth-account-routes.ts +90 -8
  613. package/src/server/management/provider-capability-config.ts +48 -0
  614. package/src/server/management/provider-routes.ts +836 -159
  615. package/src/server/management/request-history-routes.ts +191 -0
  616. package/src/server/management/route-registry.ts +332 -0
  617. package/src/server/management/routing-analytics-routes.ts +74 -0
  618. package/src/server/management/routing-profile-routes.ts +380 -0
  619. package/src/server/management/shared.ts +27 -11
  620. package/src/server/management/sidebar-routes.ts +47 -31
  621. package/src/server/management/storage-log-guard-routes.ts +186 -0
  622. package/src/server/management/sync-response.ts +69 -0
  623. package/src/server/management/system-restart.ts +289 -33
  624. package/src/server/management/system-routes.ts +77 -0
  625. package/src/server/management/usage-summary-cache.ts +9 -1
  626. package/src/server/management/vision-sidecar-options.ts +167 -0
  627. package/src/server/management/web-search-sidecar-options.ts +120 -0
  628. package/src/server/management-api.ts +126 -23
  629. package/src/server/management-auth.ts +220 -5
  630. package/src/server/passive-route-linker.ts +66 -0
  631. package/src/server/port-reclaim.ts +1 -1
  632. package/src/server/ports.ts +41 -1
  633. package/src/server/proxy-liveness.ts +161 -10
  634. package/src/server/readiness.ts +99 -0
  635. package/src/server/relay-eager.ts +221 -54
  636. package/src/server/relay.ts +479 -87
  637. package/src/server/request-decompress.ts +113 -6
  638. package/src/server/request-log-conversation.ts +51 -0
  639. package/src/server/request-log.ts +298 -31
  640. package/src/server/responses/agent-task-recovery-cache.ts +143 -0
  641. package/src/server/responses/agent-task-recovery.ts +465 -0
  642. package/src/server/responses/codex-auth-error.ts +55 -0
  643. package/src/server/responses/collaboration.ts +239 -36
  644. package/src/server/responses/combo-stream-preflight.ts +178 -0
  645. package/src/server/responses/compact.ts +444 -72
  646. package/src/server/responses/core.ts +3579 -436
  647. package/src/server/responses/empty-completion-guard.ts +333 -0
  648. package/src/server/responses/encrypted-payload.ts +62 -39
  649. package/src/server/responses/fetch-helpers.ts +89 -99
  650. package/src/server/responses/input-admission.ts +187 -0
  651. package/src/server/responses/pacing-overload.ts +13 -0
  652. package/src/server/responses/passthrough-error.ts +33 -9
  653. package/src/server/responses/policy-fallback.ts +178 -0
  654. package/src/server/responses/responses-field-backfill.ts +343 -0
  655. package/src/server/responses/terminal-guard.ts +26 -5
  656. package/src/server/responses/upstream-error.ts +5 -0
  657. package/src/server/responses/ws-upstream.ts +381 -0
  658. package/src/server/responses-custom-tool-repair.ts +363 -0
  659. package/src/server/responses-item-id-repair.ts +54 -6
  660. package/src/server/responses-json-events.ts +90 -0
  661. package/src/server/responses-model-rewrite.ts +29 -0
  662. package/src/server/responses-reasoning-summary-rewrite.ts +178 -0
  663. package/src/server/responses-snapshot-repair.ts +621 -0
  664. package/src/server/responses-terminal-repair.ts +363 -0
  665. package/src/server/responses-tool-search-repair.ts +267 -0
  666. package/src/server/responses-undeclared-tool-guard.ts +376 -0
  667. package/src/server/responses.ts +18 -2
  668. package/src/server/search.ts +78 -13
  669. package/src/server/sse-frame-buffer.ts +319 -0
  670. package/src/server/sse-payload-rewrite.ts +110 -22
  671. package/src/server/startup-action-control.ts +8 -1
  672. package/src/server/startup-health-cache.ts +19 -1
  673. package/src/server/system-env.ts +80 -9
  674. package/src/server/ws-bridge.ts +53 -40
  675. package/src/service-manager-probe.ts +892 -0
  676. package/src/service.ts +1153 -94
  677. package/src/sidecar/auth.ts +92 -0
  678. package/src/sidecar/candidates.ts +83 -0
  679. package/src/storage/cleanup.ts +2 -2
  680. package/src/storage/policy-job.ts +14 -4
  681. package/src/storage/policy.ts +88 -23
  682. package/src/storage/scanner.ts +1 -1
  683. package/src/storage/worker-lifecycle.ts +14 -14
  684. package/src/tray/windows-tray.ps1 +83 -9
  685. package/src/tray/windows.ts +43 -16
  686. package/src/types/accounts.ts +37 -0
  687. package/src/types/config.ts +911 -0
  688. package/src/types/provider.ts +614 -0
  689. package/src/types/request.ts +397 -0
  690. package/src/types/tools.ts +236 -0
  691. package/src/types/wire.ts +80 -0
  692. package/src/types.ts +107 -1237
  693. package/src/update/index.ts +37 -23
  694. package/src/update/job.ts +445 -68
  695. package/src/update/notify.ts +12 -6
  696. package/src/update/npm-cache-preflight.d.mts +47 -0
  697. package/src/update/npm-cache-preflight.mjs +201 -0
  698. package/src/update/transactional-install.d.mts +22 -0
  699. package/src/update/transactional-install.mjs +266 -0
  700. package/src/usage/cost.ts +0 -0
  701. package/src/usage/expected-prices.ts +287 -16
  702. package/src/usage/log.ts +666 -50
  703. package/src/usage/summary.ts +388 -17
  704. package/src/usage/user-cost-overlay-reconciler.ts +313 -0
  705. package/src/usage/user-cost-overlays.ts +314 -0
  706. package/src/vision/anthropic-describe.ts +10 -6
  707. package/src/vision/backends.ts +97 -0
  708. package/src/vision/describe.ts +27 -16
  709. package/src/vision/eligibility.ts +250 -0
  710. package/src/vision/index.ts +238 -24
  711. package/src/vision/reasoning.ts +55 -0
  712. package/src/vision/routed-describe.ts +175 -0
  713. package/src/vision/timeout-bounds.ts +9 -0
  714. package/src/web-search/anthropic-executor.ts +13 -7
  715. package/src/web-search/backends.ts +108 -0
  716. package/src/web-search/exa-executor.ts +119 -0
  717. package/src/web-search/executor.ts +21 -6
  718. package/src/web-search/gemini-executor.ts +141 -0
  719. package/src/web-search/index.ts +150 -15
  720. package/src/web-search/loop.ts +284 -54
  721. package/src/web-search/parse.ts +125 -30
  722. package/src/web-search/sources.ts +60 -0
  723. package/src/web-search/xai-executor.ts +219 -0
  724. package/gui/dist/assets/index-DTpMHS4F.js +0 -67
  725. package/gui/dist/assets/index-ZNVDE3C7.css +0 -1
  726. package/gui/dist/provider-icons/antigravity.svg +0 -1
  727. package/gui/dist/provider-icons/claude.svg +0 -1
  728. package/gui/dist/provider-icons/copilot.svg +0 -1
  729. package/gui/dist/provider-icons/cursor.svg +0 -2
  730. package/gui/dist/provider-icons/gemini.svg +0 -1
  731. package/gui/dist/provider-icons/grok-color.svg +0 -1
  732. package/gui/dist/provider-icons/kiro.svg +0 -14
  733. package/src/cli/internal-dispatch.ts +0 -20
@@ -1,23 +1,23 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
- import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
3
+ import { existsSync, readFileSync } from "node:fs";
4
4
  import { delimiter, dirname, join, resolve } from "node:path";
5
- import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../../config";
6
- import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "../paths";
5
+ import { expandUserPath, loadConfig, readConfigDiagnostics, websocketsEnabled } from "../../config";
6
+ import { shouldSyncCodexOnStart } from "../desired-state";
7
+ import { legacyCustomModelCatalogSlugs } from "../custom-model-catalog-migration";
8
+ import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, getCodexHome, readRootTomlString, resolveCodexConfigPath } from "../paths";
7
9
  import { clearModelCache, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, markModelsFetchFailure, setCached } from "../model-cache";
8
10
  import { buildModelsRequest, resolveModelsAuthToken } from "../../oauth";
9
11
  import type { OcxConfig, OcxProviderConfig } from "../../types";
10
12
  import { modelInList } from "../../types";
11
13
  import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort";
12
- import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive, listJawcodeModelMetadata, resolveJawcodeProvider } from "../../generated/jawcode-model-metadata";
14
+ import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata";
13
15
  import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive";
14
- import { getProviderRegistryEntry } from "../../providers/registry";
15
16
  import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap";
16
- import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec";
17
- import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity";
17
+ import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../../providers/slug-codec";
18
+ import { identifyRoutedModel } from "../../adapters/identity";
18
19
  import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery";
19
20
  import { fetchCursorUsableModels } from "../../adapters/cursor/live-models";
20
- import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
21
21
  import {
22
22
  COMBO_NAMESPACE,
23
23
  comboModelId,
@@ -29,20 +29,65 @@ import type { NormalizedComboConfig } from "../../combos/types";
29
29
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
30
30
  import { redactSecretString } from "../../lib/redact";
31
31
  import upstreamModelsSnapshot from "../data/upstream-models.json";
32
+ import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
33
+ import { providerCodexAccountMode } from "../../providers/registry";
34
+ import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../account-namespaces";
35
+ import { MAIN_CODEX_ACCOUNT_ID } from "../main-account";
36
+ import {
37
+ availableAccountGatedNativeModels,
38
+ isCodexModelEntitlementSnapshotCurrent,
39
+ resolveCodexModelEntitlements,
40
+ type CodexModelEntitlementSnapshot,
41
+ } from "../model-entitlements";
32
42
 
33
43
 
34
- import { activeCodexModelsCachePath, applyJawcodeCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, catalogModelSlug, ensureCatalogBackup, ensureStrictCatalogFields, findNativeTemplate, isRoutedModelCompatibilityExcluded, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing";
35
- import type { CatalogModel, MultiAgentMode, RawEntry } from "./parsing";
36
- import { applyNativeVisibility, disabledNativeSlugs, isUnsupportedOpenAiNativeSlug, nativeOpenAiSlugs, shouldUpgradeToUpstreamEntry, upstreamNativeEntry } from "./metadata";
37
- import { loadCatalogForSync, resetBundledCatalogCacheForTests } from "./bundled";
44
+ import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readCodexCatalogPathForHome, readConfiguredAutoReviewModel, readNativeBaseline } from "./parsing";
45
+ import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing";
46
+ import { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, nativeContextLimits, observedAccountBoundNativeEntries, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry, type NativeContextLimitsInput } from "./metadata";
47
+ import {
48
+ bundledCatalogCacheState,
49
+ loadBundledCodexCatalog,
50
+ resetBundledCatalogCacheForTests,
51
+ } from "./bundled";
38
52
  import { isMultiAgentV2Enabled } from "../features";
39
53
  import { applyCatalogModelMetadata, applyReasoningLevels, catalogEntryEfforts, clampCatalogModelsToCodexSupport, ensureGpt56ReasoningLevels, ensureUltraReasoningLevel, isGpt56NativeSlug } from "./effort";
40
- import { clearGatherRoutedModelsInflight, filterCatalogVisibleModels, gatherRoutedModels, lastDropWarnSignature } from "./provider-fetch";
41
- import { clearLastComboCatalogOmissions, comboCatalogWarningSignatures, comboMasqueradeCollisionWarnings, exactComboCatalogSlugs, openAiApiCollisionWarnings, resolveSlugAliasCollisions, slugAliasCollisionWarnings, warnComboMasqueradeCollisionOnce } from "./aggregation";
54
+ import {
55
+ clearGatherRoutedModelsInflight,
56
+ filterCatalogVisibleModels,
57
+ gatherRoutedModels,
58
+ lastDropWarnSignature,
59
+ type CatalogGatherProviderModelOutcome,
60
+ } from "./provider-fetch";
61
+ import { accountSelectorShadowCollisionWarnings, clearLastComboCatalogOmissions, comboCatalogWarningSignatures, comboMasqueradeCollisionWarnings, comboUnrestorableShadowWarnings, exactComboCatalogSlugs, openAiApiCollisionWarnings, resolveSlugAliasCollisions, slugAliasCollisionWarnings, warnAccountSelectorShadowedProviderOnce, warnComboMasqueradeCollisionOnce, warnComboUnrestorableShadowOnce } from "./aggregation";
42
62
  import type { ComboCatalogOmission } from "./aggregation";
63
+ import {
64
+ withCatalogWriteSerialization,
65
+ type CatalogWritePermit,
66
+ } from "../catalog-write-serialization";
67
+ import {
68
+ publishHashedCodexCatalogBackup,
69
+ publishLegacyCodexCatalogBackup,
70
+ replaceActiveCodexCatalog,
71
+ replaceCodexModelsCache,
72
+ } from "../internal/catalog-writer";
73
+ import { codexRuntimeStatePath } from "../runtime";
74
+ import { accountBoundNativeDisplayName, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./account-models";
75
+ import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./native-models";
43
76
 
44
77
  export const MAX_SPAWN_AGENT_MODEL_OVERRIDES = 5;
45
78
 
79
+ // Base for config.modelPickerOrder display priorities (#1649). modelPickerOrder is a DISPLAY-ONLY
80
+ // reordering of the Codex model picker: it rewrites a row's Codex-visible `priority` but never the
81
+ // spawn_agent candidate window. The window is derived from SPAWN_PRIORITY_FIELD (the natural
82
+ // priority captured before the override), so display order and spawn candidates are decoupled.
83
+ export const PICKER_ORDER_PRIORITY_BASE = 1_000;
84
+
85
+ // OpenCodex-private catalog field: the spawn_agent candidate priority a row would have WITHOUT
86
+ // modelPickerOrder. Codex ignores unknown catalog fields (same as opencodex_catalog_kind), so this
87
+ // is invisible to Codex; effectiveSubagentRoster reads it so a display reorder cannot change which
88
+ // rows are spawn_agent candidates. Absent on rows modelPickerOrder did not move.
89
+ export const SPAWN_PRIORITY_FIELD = "opencodex_spawn_priority";
90
+
46
91
  export type SpawnAgentSurface = "v1" | "v2";
47
92
 
48
93
  export type SubagentRosterExclusionReason =
@@ -54,21 +99,36 @@ export type SubagentRosterExclusionReason =
54
99
  /**
55
100
  * Whether a catalog entry may be offered as a V2 subagent model.
56
101
  *
57
- * Upstream (codex-rs 92938d880) requires `multi_agent_version === "v2"` exactly,
58
- * because upstream assumes a single backend serves every model. opencodex routes
59
- * many providers, so that equality would reject the cross-provider spawns this
60
- * proxy exists to enable.
102
+ * Upstream changed this rule in codex-rs `6d4d9442c` ("Support leaf models in
103
+ * multi-agent v2"). `model_supports_multi_agent_backend`
104
+ * (core/src/tools/handlers/multi_agents_common.rs:36-42) now admits EVERY model
105
+ * except one explicitly marked `disabled`; the older `== Some(V2)` equality that
106
+ * `92938d880` introduced is gone.
107
+ *
108
+ * The field no longer answers "may I be a delegation target". It answers "does the
109
+ * CHILD get collaboration tools": `collab_tools_enabled`
110
+ * (core/src/tools/spec_plan.rs:599-610) grants a child recursive tools only when its
111
+ * own catalog value is exactly `Some(V2)`. The three-way distinction survives, but it
112
+ * now means eligible-recursive / eligible-LEAF / excluded:
113
+ *
114
+ * - `"v2"` -> eligible, and the child may itself delegate.
115
+ * - `"v1"` -> eligible LEAF worker. This is upstream's pin for `gpt-5.6-luna`
116
+ * (models-manager/models.json); excluding it here is exactly what
117
+ * kept Luna out of opencodex's roster.
118
+ * - absent/null -> eligible LEAF worker (routed or unpinned-native model).
119
+ * - `"disabled"` -> the sole capability-based exclusion.
61
120
  *
62
- * Decision (option B, devlog 260730_codex_rs_upstream_v2_live_handoff/060): any
63
- * model opencodex actually routes is eligible. An entry pinned to a DIFFERENT
64
- * multi-agent backend (`v1`) stays excluded, because that pin is a real capability
65
- * statement rather than an absence of information. An unpinned entry (null or
66
- * absent) is a routed or unpinned-native model and is allowed. The three-way
67
- * distinction is the substance; do not flatten it into a truthiness check.
121
+ * This is the roster filter only. Catalog STAMPING is a separate concern owned by
122
+ * `applyMultiAgentMode`, including the `keepNativeChatGptOnV1` policy (#1728) that
123
+ * keeps ChatGPT-native rows on `v1` so a native parent can still spawn a routed child
124
+ * despite backend-encrypted NEW_TASK bodies (#92). Recognizing those `v1` rows as
125
+ * eligible leaves here is what makes that policy usable, not a contradiction of it.
126
+ *
127
+ * Devlog: 260816_codexrs_multiagent_v2_and_history_perf/011 (C1), superseding the
128
+ * option-B decision in 260730_codex_rs_upstream_v2_live_handoff/060.
68
129
  */
69
130
  export function isEligibleV2SubagentEntry(entry: RawEntry): boolean {
70
- const pinned = entry.multi_agent_version;
71
- return pinned === "v2" || pinned === null || pinned === undefined;
131
+ return entry.multi_agent_version !== "disabled";
72
132
  }
73
133
 
74
134
  export interface EffectiveSubagentModel {
@@ -88,63 +148,96 @@ export interface EffectiveSubagentRoster {
88
148
  excluded: SubagentRosterExclusion[];
89
149
  }
90
150
 
91
- export function configuredCatalogEntry(entries: RawEntry[], configured: string): RawEntry | undefined {
151
+ export function configuredCatalogEntry(entries: readonly RawEntry[], configured: string): RawEntry | undefined {
92
152
  return entries.find(entry => entry.slug === configured)
93
153
  ?? entries.find(entry => typeof entry.slug === "string" && slugsEquivalent(configured, entry.slug));
94
154
  }
95
155
 
156
+ function configuredSubagentModelMatchesEntry(configured: string, entry: RawEntry): boolean {
157
+ if (typeof entry.slug !== "string") return false;
158
+ if (slugsEquivalent(configured, entry.slug)) return true;
159
+ const nativeSlug = trustedAccountBoundNativeCatalogSlug(entry);
160
+ return !configured.includes("/")
161
+ && nativeSlug !== undefined
162
+ && SUPPORTED_NATIVE_OPENAI_SLUGS.has(nativeSlug)
163
+ && slugsEquivalent(configured, nativeSlug);
164
+ }
165
+
96
166
  export function effectiveSubagentRoster(
97
167
  configuredModels: readonly string[],
98
168
  surface: SpawnAgentSurface,
169
+ catalogEntries?: readonly RawEntry[],
99
170
  ): EffectiveSubagentRoster {
100
171
  const configured = configuredModels
101
172
  .filter(model => model.trim().length > 0)
102
173
  .filter((model, index, all) =>
103
174
  !all.slice(0, index).some(previous => slugsEquivalent(previous, model))
104
175
  );
105
- const entries = readCatalog(readCodexCatalogPath())?.models ?? [];
176
+ const entries = catalogEntries ?? readCatalog(readCodexCatalogPath())?.models ?? [];
106
177
  const ordered = entries
107
178
  .map((entry, index) => ({ entry, index }))
108
179
  .filter(({ entry }) => typeof entry.slug === "string")
109
180
  .filter(({ entry }) => entry.visibility === "list")
110
181
  .filter(({ entry }) => surface !== "v2" || isEligibleV2SubagentEntry(entry))
111
182
  .sort((left, right) => {
112
- const leftPriority = typeof left.entry.priority === "number" && Number.isFinite(left.entry.priority)
113
- ? left.entry.priority : Number.MAX_SAFE_INTEGER;
114
- const rightPriority = typeof right.entry.priority === "number" && Number.isFinite(right.entry.priority)
115
- ? right.entry.priority : Number.MAX_SAFE_INTEGER;
183
+ // Spawn candidates rank by the natural priority (SPAWN_PRIORITY_FIELD when present), so a
184
+ // modelPickerOrder display reorder (#1649) can never change candidate membership. Rows the
185
+ // override did not move fall back to their Codex-visible `priority`.
186
+ const spawnPriorityOf = (entry: RawEntry): number => {
187
+ const spawn = entry[SPAWN_PRIORITY_FIELD];
188
+ if (typeof spawn === "number" && Number.isFinite(spawn)) return spawn;
189
+ return typeof entry.priority === "number" && Number.isFinite(entry.priority)
190
+ ? entry.priority : Number.MAX_SAFE_INTEGER;
191
+ };
192
+ const leftPriority = spawnPriorityOf(left.entry);
193
+ const rightPriority = spawnPriorityOf(right.entry);
116
194
  return leftPriority - rightPriority || left.index - right.index;
117
195
  })
118
196
  .slice(0, MAX_SPAWN_AGENT_MODEL_OVERRIDES);
197
+ const orderedEntries = new Set(ordered.map(({ entry }) => entry));
119
198
 
120
199
  const candidates = ordered.map(({ entry }) => ({
121
200
  model: entry.slug as string,
122
201
  efforts: catalogEntryEfforts(entry),
123
202
  }));
124
- const advertised = candidates.filter(candidate =>
125
- configured.some(model => slugsEquivalent(model, candidate.model))
126
- );
203
+ const advertised = ordered
204
+ .filter(({ entry }) => configured.some(model => configuredSubagentModelMatchesEntry(model, entry)))
205
+ .map(({ entry }) => ({
206
+ model: entry.slug as string,
207
+ efforts: catalogEntryEfforts(entry),
208
+ }));
127
209
  const excluded = configured.flatMap((model): SubagentRosterExclusion[] => {
128
- const entry = configuredCatalogEntry(entries, model);
129
- if (!entry) return [{ configured: model, reason: "missing_catalog_entry" }];
130
- const catalogModel = entry.slug as string;
131
- if (entry.visibility !== "list") {
132
- return [{ configured: model, catalogModel, reason: "picker_hidden" }];
133
- }
134
- if (surface === "v2" && !isEligibleV2SubagentEntry(entry)) {
135
- return [{ configured: model, catalogModel, reason: "surface_incompatible" }];
210
+ const matchingEntries = entries.filter(entry => configuredSubagentModelMatchesEntry(model, entry));
211
+ if (matchingEntries.some(entry => orderedEntries.has(entry))) return [];
212
+ if (matchingEntries.length === 0) return [{ configured: model, reason: "missing_catalog_entry" }];
213
+ const visibleCompatible = matchingEntries.find(entry =>
214
+ entry.visibility === "list"
215
+ && (surface !== "v2" || isEligibleV2SubagentEntry(entry))
216
+ );
217
+ if (visibleCompatible) {
218
+ return [{
219
+ configured: model,
220
+ catalogModel: visibleCompatible.slug as string,
221
+ reason: "outside_display_limit",
222
+ }];
136
223
  }
137
- if (!candidates.some(candidate => candidate.model === catalogModel)) {
138
- return [{ configured: model, catalogModel, reason: "outside_display_limit" }];
224
+ const visible = matchingEntries.find(entry => entry.visibility === "list");
225
+ if (visible) {
226
+ return [{
227
+ configured: model,
228
+ catalogModel: visible.slug as string,
229
+ reason: "surface_incompatible",
230
+ }];
139
231
  }
140
- return [];
232
+ const hidden = configuredCatalogEntry(entries, model) ?? matchingEntries[0]!;
233
+ return [{ configured: model, catalogModel: hidden.slug as string, reason: "picker_hidden" }];
141
234
  });
142
235
  return { candidates, advertised, excluded };
143
236
  }
144
237
 
145
- export function finishUpstreamNativeEntry(clone: RawEntry, priority: number): RawEntry {
238
+ export function finishUpstreamNativeEntry(clone: RawEntry, priority: number, contextCap?: NativeContextLimitsInput): RawEntry {
146
239
  if (priority !== 9) clone.priority = priority;
147
- applyNativeOpenAiContextOverride(clone);
240
+ applyNativeOpenAiContextOverride(clone, contextCap);
148
241
  // GPT-5.6 natives keep their exact upstream ladders (e.g. luna has max but no ultra).
149
242
  // Older natives (gpt-5.5 / 5.4 / 5.4-mini / 5.3-codex-spark) get mock max + ultra
150
243
  // (wire-clamped to xhigh). Ultra is always advertised regardless of v2 toggle.
@@ -156,7 +249,36 @@ export function isExactComboCatalogModel(
156
249
  model: CatalogModel | undefined,
157
250
  exactComboSlugs: ReadonlySet<string>,
158
251
  ): boolean {
159
- return model !== undefined && exactComboSlugs.has(catalogModelSlug(model));
252
+ return model?.provider === COMBO_NAMESPACE && exactComboSlugs.has(catalogModelSlug(model));
253
+ }
254
+
255
+ function isExactComboCatalogEntry(
256
+ entry: RawEntry,
257
+ exactComboSlugs: ReadonlySet<string>,
258
+ ): boolean {
259
+ return entry.owned_by === COMBO_NAMESPACE
260
+ && typeof entry.slug === "string"
261
+ && exactComboSlugs.has(entry.slug);
262
+ }
263
+
264
+ /**
265
+ * Friendly Codex-picker label for a routed `provider/model` slug. Command Code's two config
266
+ * ids differ by a single dash (`command-code` vs `commandcode`), so relabel them to the
267
+ * lowercase-dash style the opencode presets use: `commandcode-auth/x` and `commandcode-api/x`.
268
+ * The model-id portion also carries a redundant `<vendor>-` prefix (`deepseek-deepseek-v4-flash`)
269
+ * that is dropped for display. All other providers keep the raw slug exactly as before.
270
+ */
271
+ function routedDisplayName(slug: string): string {
272
+ const slash = slug.indexOf("/");
273
+ if (slash <= 0) return slug;
274
+ const provider = slug.slice(0, slash);
275
+ let model = slug.slice(slash + 1);
276
+ if (provider === "command-code" || provider === "commandcode") {
277
+ const m = model.match(/^([a-z0-9]+)-([a-z0-9]+(?:-[a-z0-9]+)+)$/i);
278
+ if (m && model.startsWith(`${m[1]}-${m[1]}-`)) model = model.slice(m[1]!.length + 1);
279
+ return `${provider === "command-code" ? "commandcode-auth" : "commandcode-api"}/${model}`;
280
+ }
281
+ return slug;
160
282
  }
161
283
 
162
284
  export function deriveEntry(
@@ -166,20 +288,24 @@ export function deriveEntry(
166
288
  priority: number,
167
289
  model?: CatalogModel,
168
290
  exactComboSlugs: ReadonlySet<string> = new Set(),
291
+ contextCap?: NativeContextLimitsInput,
169
292
  ): RawEntry {
170
293
  const preserveExact = isExactComboCatalogModel(model, exactComboSlugs);
294
+ const codexForwardNativeCapabilityAlias = model?.codexForwardNativeCapabilityAlias === true
295
+ ? upstreamNativeEntry(model.id)
296
+ : null;
171
297
  const isRouted = model !== undefined;
172
298
  if (!isRouted && !slug.includes("/")) {
173
299
  // Supported native slug covered by the upstream snapshot: use the REAL entry (exact
174
300
  // reasoning ladder — e.g. luna has no ultra — default effort, identity, model_messages)
175
301
  // instead of cloning an older template.
176
302
  const upstream = upstreamNativeEntry(slug);
177
- if (upstream) return finishUpstreamNativeEntry(upstream, priority);
303
+ if (upstream) return finishUpstreamNativeEntry(upstream, priority, contextCap);
178
304
  }
179
- if (template) {
180
- const e = JSON.parse(JSON.stringify(template)) as RawEntry;
305
+ if (template || codexForwardNativeCapabilityAlias) {
306
+ const e = JSON.parse(JSON.stringify(codexForwardNativeCapabilityAlias ?? template)) as RawEntry;
181
307
  e.slug = slug;
182
- e.display_name = slug;
308
+ e.display_name = routedDisplayName(slug);
183
309
  e.description = desc;
184
310
  e.priority = priority;
185
311
  e.visibility = "list";
@@ -188,23 +314,41 @@ export function deriveEntry(
188
314
  // Routed (namespaced) models inherit the gpt template — correct its OpenAI/GPT identity
189
315
  // and advertise the reasoning ladder Codex accepts.
190
316
  if (isRouted) {
317
+ // A routed model is NOT the native template: never inherit its context
318
+ // window when /models omits context metadata (#992). Known metadata
319
+ // restores exact values below; an enabled Context cap fills the gap;
320
+ // otherwise the strict-fields fallback supplies the 128k triple.
321
+ if (!codexForwardNativeCapabilityAlias) {
322
+ delete e.context_window;
323
+ delete e.max_context_window;
324
+ delete e.auto_compact_token_limit;
325
+ }
191
326
  // Native id for identity text + metadata lookups — the slug may be an encoded
192
327
  // alias (`provider/vendor-model`); the model object carries the native id.
193
328
  const modelName = model?.id ?? slug.slice(slug.indexOf("/") + 1);
194
329
  if (typeof e.base_instructions === "string") {
195
330
  // Proxy-neutral: keep the GPT-5/OpenAI disclaimer but never advertise the opencodex proxy
196
331
  // (leaking that into base_instructions is a non-first-party signature → ToS risk).
197
- e.base_instructions = e.base_instructions.replace(
198
- CODEX_GPT5_IDENTITY_LINE,
199
- `You are a coding agent powered by the ${modelName} model. Do not claim to be GPT-5 or made by OpenAI.`,
200
- );
332
+ e.base_instructions = identifyRoutedModel(e.base_instructions, modelName);
333
+ }
334
+ applyReasoningLevels(
335
+ e,
336
+ model?.reasoningEfforts,
337
+ model?.defaultReasoningEffort,
338
+ preserveExact || codexForwardNativeCapabilityAlias !== null,
339
+ );
340
+ // This exact provider/model pair is the ChatGPT/Codex forward surface. Keep the pinned
341
+ // native tool/search/responses-lite contract while preserving the routed slug and wire id.
342
+ if (!codexForwardNativeCapabilityAlias) {
343
+ normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true, model?.codexToolMode);
344
+ } else if (model?.codexToolMode !== undefined) {
345
+ applyRoutedCodexToolMode(e, model.codexToolMode);
201
346
  }
202
- applyReasoningLevels(e, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact);
203
- normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true);
204
- if (model) applyJawcodeCatalogMetadata(e, model.provider, model.id, model.contextCap);
347
+ if (model) applyCatalogMetadata(e, model.provider, model.id, model.contextCap);
205
348
  applyCatalogModelMetadata(e, model);
349
+ if (model?.catalogKind) e.opencodex_catalog_kind = model.catalogKind;
206
350
  } else {
207
- applyNativeOpenAiContextOverride(e);
351
+ applyNativeOpenAiContextOverride(e, contextCap);
208
352
  if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(e);
209
353
  else ensureUltraReasoningLevel(e);
210
354
  // Non-5.6 natives (5.5, 5.4, 5.4-mini, spark) do not support responses-lite;
@@ -225,28 +369,61 @@ export function deriveEntry(
225
369
  });
226
370
  }
227
371
  // Fallback when no template is available (best-effort; strict parser may need more).
372
+ // Routed fallbacks default to code-mode tool exposure (or shell mode when codexToolMode === "shell");
373
+ // otherwise the nested catalog expands into `exec.description` and can exceed Cursor's 120 KB serialized tool limit (#1830).
374
+ // Cursor still omits hosted web-search metadata because runTurn bypasses that separate sidecar.
375
+ const isCursorFallback = isRouted && model?.provider === "cursor";
228
376
  const entry: RawEntry = {
229
- slug, display_name: slug, description: desc,
377
+ slug, display_name: routedDisplayName(slug), description: desc,
230
378
  shell_type: "shell_command", visibility: "list", supported_in_api: true,
231
379
  priority, base_instructions: "You are a helpful coding assistant.",
232
- ...(isRouted ? { web_search_tool_type: "text_and_image", supports_search_tool: true } : {}),
380
+ ...(isRouted
381
+ ? isCursorFallback
382
+ ? { supports_search_tool: true }
383
+ : { web_search_tool_type: "text_and_image", supports_search_tool: true }
384
+ : {}),
233
385
  };
234
386
  if (isRouted) {
387
+ applyRoutedCodexToolMode(entry, model?.codexToolMode);
235
388
  applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact);
236
389
  }
237
390
  else {
238
391
  applyReasoningLevels(entry, isGpt56NativeSlug(slug) ? undefined : ["low", "medium", "high", "xhigh"]);
239
392
  if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(entry);
240
393
  }
241
- if (model && isRouted) applyJawcodeCatalogMetadata(entry, model.provider, model.id, model.contextCap);
394
+ if (model && isRouted) applyCatalogMetadata(entry, model.provider, model.id, model.contextCap);
242
395
  applyCatalogModelMetadata(entry, model);
243
- if (!isRouted) applyNativeOpenAiContextOverride(entry);
396
+ if (model?.catalogKind) entry.opencodex_catalog_kind = model.catalogKind;
397
+ if (!isRouted) applyNativeOpenAiContextOverride(entry, contextCap);
244
398
  return ensureStrictCatalogFields(normalizeServiceTiers(entry), {
245
399
  preserveExactInputModalities: preserveExact,
246
400
  isRouted,
247
401
  });
248
402
  }
249
403
 
404
+ export interface ObservedCatalogEntryBuildInput {
405
+ readonly template: RawEntry | null;
406
+ readonly gptSlugs: readonly string[];
407
+ readonly goModels: readonly CatalogModel[];
408
+ readonly featured?: readonly string[];
409
+ /** Optional full picker ordering (config.modelPickerOrder); orders non-featured rows. */
410
+ readonly modelPickerOrder?: readonly string[];
411
+ readonly wsEnabled: boolean;
412
+ readonly multiAgentMode: MultiAgentMode;
413
+ readonly exactComboSlugs: ReadonlySet<string>;
414
+ readonly accountSelectors: readonly string[];
415
+ readonly suppressedBareNativeSlugs: ReadonlySet<string>;
416
+ readonly disabledNativeAccountSlugs: ReadonlySet<string>;
417
+ readonly multiAgentV2Enabled: boolean;
418
+ readonly keepNativeChatGptOnV1?: boolean;
419
+ readonly openaiContextCap?: NativeContextLimitsInput;
420
+ /** Additional native ids to clone under account selectors, without creating bare rows. */
421
+ readonly accountNativeSlugs?: readonly string[];
422
+ /** Per-selector account ids; unknown observations must not be copied to unrelated accounts. */
423
+ readonly accountNativeSlugsBySelector?: ReadonlyMap<string, readonly string[]>;
424
+ }
425
+
426
+ /** Build entries with the process-observed Codex feature state. */
250
427
  export function buildCatalogEntries(
251
428
  template: RawEntry | null,
252
429
  gptSlugs: string[],
@@ -255,24 +432,175 @@ export function buildCatalogEntries(
255
432
  wsEnabled = false,
256
433
  multiAgentMode: MultiAgentMode = "default",
257
434
  exactComboSlugs: ReadonlySet<string> = new Set(),
435
+ accountSelectors: readonly string[] = [],
436
+ suppressedBareNativeSlugs: ReadonlySet<string> = new Set(),
437
+ disabledNativeAccountSlugs: ReadonlySet<string> = new Set(),
438
+ contextCap?: NativeContextLimitsInput,
439
+ accountNativeSlugs?: readonly string[],
440
+ accountNativeSlugsBySelector?: ReadonlyMap<string, readonly string[]>,
441
+ keepNativeChatGptOnV1 = false,
258
442
  ): RawEntry[] {
443
+ return buildCatalogEntriesFromObservedState({
444
+ template,
445
+ gptSlugs,
446
+ goModels,
447
+ featured,
448
+ wsEnabled,
449
+ multiAgentMode,
450
+ exactComboSlugs,
451
+ accountSelectors,
452
+ suppressedBareNativeSlugs,
453
+ disabledNativeAccountSlugs,
454
+ multiAgentV2Enabled: isMultiAgentV2Enabled(),
455
+ keepNativeChatGptOnV1,
456
+ openaiContextCap: contextCap,
457
+ accountNativeSlugs,
458
+ accountNativeSlugsBySelector,
459
+ });
460
+ }
461
+
462
+ /** Build entries solely from caller-observed inputs, with no feature-state filesystem read. */
463
+ export function buildCatalogEntriesFromObservedState({
464
+ template,
465
+ gptSlugs,
466
+ goModels,
467
+ featured,
468
+ modelPickerOrder,
469
+ wsEnabled,
470
+ multiAgentMode,
471
+ exactComboSlugs,
472
+ accountSelectors,
473
+ suppressedBareNativeSlugs,
474
+ disabledNativeAccountSlugs,
475
+ multiAgentV2Enabled,
476
+ keepNativeChatGptOnV1,
477
+ openaiContextCap,
478
+ accountNativeSlugs,
479
+ accountNativeSlugsBySelector,
480
+ }: ObservedCatalogEntryBuildInput): RawEntry[] {
259
481
  // Codex's models-manager sorts by `priority` ASC and advertises the first 5 picker-visible
260
482
  // models to spawn_agent (sort_by_key(priority) + MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT=5). Catalog
261
483
  // ARRAY order is discarded — so "featuring" a model = giving it the LOWEST priority (0..N-1) so
262
484
  // it sorts to the front. This works for native gpt slugs AND routed slugs alike.
263
485
  const rank = new Map((featured ?? []).map((slug, i) => [slug, i] as const));
486
+ const priorityStride = Math.max(accountSelectors.length, 1);
487
+ // Optional full picker order (#1649). Independent of the 5-slot spawn_agent cap: it only
488
+ // rewrites the Codex-visible display `priority` of listed non-featured routed rows so a >5
489
+ // catalog stays put across rebuilds. Featured rows keep their existing 0..N-1 band; when
490
+ // modelPickerOrder is unset the helper is a no-op and every priority below is byte-identical to
491
+ // before. The spawn_agent candidate window is derived separately from SPAWN_PRIORITY_FIELD, so
492
+ // this display reorder cannot change which rows are spawn candidates.
493
+ const pickerOrder = Array.isArray(modelPickerOrder)
494
+ ? modelPickerOrder.filter((id): id is string => typeof id === "string" && id.length > 0)
495
+ : [];
496
+ const pickerOrderRank = new Map(pickerOrder.map((slug, i) => [slug, i] as const));
497
+ const pickerOrderActive = pickerOrder.length > 0;
498
+ // The display band reuses the existing high priority tier (>= PICKER_ORDER_PRIORITY_BASE, the
499
+ // same 1_000+ neighborhood account rows occupy), keeping listed rows visually after the featured
500
+ // band. Candidate membership does not depend on this — see SPAWN_PRIORITY_FIELD.
501
+ /**
502
+ * Priority for a non-featured routed row that is explicitly LISTED in modelPickerOrder. Listed
503
+ * slugs sort in declared order within the high picker-order display tier
504
+ * (>= PICKER_ORDER_PRIORITY_BASE). This sets the Codex-visible `priority` only; the caller records
505
+ * the row's natural priority in SPAWN_PRIORITY_FIELD so the spawn_agent candidate window is
506
+ * unchanged. Returns undefined when the feature is off or the row is not listed, so those rows
507
+ * keep their original assignment (default 5 / account 1_000+) untouched.
508
+ *
509
+ * Scope: only the generic routed `<provider>/<model>` rows call this (see the goModels loop
510
+ * below). Native passthrough rows and account-qualified native rows keep their own priority
511
+ * logic and are intentionally not reordered here — this matches the documented contract on
512
+ * OcxConfig.modelPickerOrder (route native ordering through subagentModels instead).
513
+ */
514
+ const pickerOrderPriority = (slug: string, altSlug?: string): number | undefined => {
515
+ if (!pickerOrderActive) return undefined;
516
+ const hit = pickerOrderRank.get(slug) ?? (altSlug !== undefined ? pickerOrderRank.get(altSlug) : undefined);
517
+ if (hit === undefined) return undefined;
518
+ return PICKER_ORDER_PRIORITY_BASE + hit * priorityStride;
519
+ };
264
520
  const out: RawEntry[] = [];
265
- const collisionSkipped = resolveSlugAliasCollisions(goModels);
521
+ const nativeEntries: RawEntry[] = [];
522
+ const collisionSkipped = resolveSlugAliasCollisions([...goModels]);
523
+ const emittedNativeAliases = new Set<CatalogModel>();
524
+ const emittedNativeAliasSlugs = new Set<string>();
525
+ const nativeAliasesBySlug = new Map<string, CatalogModel>();
526
+ for (const model of goModels) {
527
+ if (model.provider !== COMBO_NAMESPACE
528
+ || model.nativeAlias !== true
529
+ || typeof model.alias !== "string"
530
+ || model.alias.includes("/")) continue;
531
+ if (nativeAliasesBySlug.has(model.alias)) {
532
+ collisionSkipped.add(model);
533
+ if (!slugAliasCollisionWarnings.has(model.alias)) {
534
+ slugAliasCollisionWarnings.add(model.alias);
535
+ console.warn(
536
+ `[opencodex] native combo alias collision on "${model.alias}": keeping the first configured combo and omitting later duplicates from the catalog.`,
537
+ );
538
+ }
539
+ continue;
540
+ }
541
+ nativeAliasesBySlug.set(model.alias, model);
542
+ }
266
543
  const comboPublicSlugs = new Set(goModels
267
544
  .filter(model => model.provider === COMBO_NAMESPACE)
268
545
  .map(catalogModelSlug));
269
546
  for (const slug of gptSlugs) {
270
- const e = deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9);
271
- if (rank.has(slug)) e.priority = rank.get(slug)!;
272
- out.push(e);
547
+ const native = deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9, undefined, new Set(), openaiContextCap);
548
+ if (rank.has(slug)) native.priority = rank.get(slug)!;
549
+ nativeEntries.push(native);
550
+ const nativeAlias = nativeAliasesBySlug.get(slug);
551
+ if (!nativeAlias || collisionSkipped.has(nativeAlias)) {
552
+ if (!suppressedBareNativeSlugs.has(slug)) out.push(native);
553
+ continue;
554
+ }
555
+ const routed = deriveEntry(
556
+ template,
557
+ slug,
558
+ `Routed via opencodex → ${nativeAlias.provider} (${nativeAlias.owned_by ?? nativeAlias.provider}).`,
559
+ 5,
560
+ nativeAlias,
561
+ exactComboSlugs,
562
+ );
563
+ routed.opencodex_catalog_kind = CODEX_NATIVE_ALIAS_CATALOG_KIND;
564
+ const rankHit = rank.get(slug) ?? rank.get(`${nativeAlias.provider}/${nativeAlias.id}`);
565
+ if (rankHit !== undefined) routed.priority = rankHit * priorityStride;
566
+ else if (accountSelectors.length > 0) routed.priority = 1_000 + (typeof routed.priority === "number" ? routed.priority : 5);
567
+ out.push(routed);
568
+ emittedNativeAliases.add(nativeAlias);
569
+ emittedNativeAliasSlugs.add(slug);
570
+ }
571
+ const nativeEntriesBySlug = new Map(nativeEntries.map(entry => [String(entry.slug), entry] as const));
572
+ for (const [selectorIndex, selector] of accountSelectors.entries()) {
573
+ const selectorNativeSlugs = accountNativeSlugsBySelector?.get(selector)
574
+ ?? accountNativeSlugs
575
+ ?? gptSlugs;
576
+ const accountNativeEntries = selectorNativeSlugs.map(slug => (
577
+ nativeEntriesBySlug.get(slug)
578
+ ?? deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9, undefined, new Set(), openaiContextCap)
579
+ ));
580
+ for (const [nativeIndex, native] of accountNativeEntries.entries()) {
581
+ const nativeSlug = String(native.slug);
582
+ if (disabledNativeAccountSlugs.has(nativeSlug)) continue;
583
+ const e = JSON.parse(JSON.stringify(native)) as RawEntry;
584
+ const catalogSlug = `${selector}/${nativeSlug}`;
585
+ e.slug = catalogSlug;
586
+ e.display_name = accountBoundNativeDisplayName(selector, native);
587
+ // Codex ignores this OpenCodex extension; preserve the native comp_hash unchanged.
588
+ e.opencodex_catalog_kind = CODEX_ACCOUNT_BOUND_CATALOG_KIND;
589
+ const exactRank = rank.get(catalogSlug);
590
+ // A bare featured id belongs to the compatibility combo once shadowed. Exact
591
+ // account-qualified picks still rank normally, but the account clone must not
592
+ // inherit the bare alias rank and consume another top spawn_agent slot.
593
+ const inheritedRank = emittedNativeAliasSlugs.has(nativeSlug) ? undefined : rank.get(nativeSlug);
594
+ const featuredRank = exactRank ?? inheritedRank;
595
+ e.priority = featuredRank !== undefined
596
+ ? featuredRank * priorityStride + selectorIndex
597
+ : ((featured?.length ?? 0) + nativeIndex) * accountSelectors.length + selectorIndex;
598
+ e.visibility = "list";
599
+ out.push(e);
600
+ }
273
601
  }
274
602
  for (const m of goModels) {
275
- if (collisionSkipped.has(m)) continue;
603
+ if (collisionSkipped.has(m) || emittedNativeAliases.has(m)) continue;
276
604
  const slug = catalogModelSlug(m);
277
605
  if (m.provider !== COMBO_NAMESPACE && comboPublicSlugs.has(slug)) {
278
606
  warnComboMasqueradeCollisionOnce(slug);
@@ -288,9 +616,29 @@ export function buildCatalogEntries(
288
616
  m,
289
617
  exactComboSlugs,
290
618
  );
619
+ if (m.provider === COMBO_NAMESPACE && m.nativeAlias === true && !slug.includes("/")) {
620
+ e.opencodex_catalog_kind = CODEX_NATIVE_ALIAS_CATALOG_KIND;
621
+ }
291
622
  // Featured picks may be stored raw (legacy) or encoded — honor both.
292
623
  const rankHit = rank.get(slug) ?? rank.get(`${m.provider}/${m.id}`);
293
- if (rankHit !== undefined) e.priority = rankHit;
624
+ // Natural priority: what the row would get WITHOUT modelPickerOrder. This is the value the
625
+ // spawn_agent candidate window is derived from (see effectiveSubagentRoster), so it must never
626
+ // move when modelPickerOrder reorders the picker.
627
+ if (rankHit !== undefined) e.priority = rankHit * priorityStride;
628
+ else if (accountSelectors.length > 0) {
629
+ // Keep the generated account rows together in Codex's priority-sorted flat picker.
630
+ e.priority = 1_000 + (typeof e.priority === "number" ? e.priority : 5);
631
+ }
632
+ // #1649: modelPickerOrder is a DISPLAY-ONLY override. Record the natural priority spawn_agent
633
+ // must keep using, then let modelPickerOrder move only the Codex-visible `priority`. Featured
634
+ // rows are never overridden (their rank is authoritative for both display and spawn).
635
+ if (rankHit === undefined) {
636
+ const pickerPriority = pickerOrderPriority(slug, `${m.provider}/${m.id}`);
637
+ if (pickerPriority !== undefined) {
638
+ e[SPAWN_PRIORITY_FIELD] = typeof e.priority === "number" ? e.priority : 5;
639
+ e.priority = pickerPriority;
640
+ }
641
+ }
294
642
  out.push(e);
295
643
  }
296
644
  // Central capability override (phase 120.4): the advertised flag must match the implemented WS
@@ -305,7 +653,9 @@ export function buildCatalogEntries(
305
653
  delete entry.prefer_websockets;
306
654
  }
307
655
  }
308
- return applyMultiAgentMode(out, multiAgentMode, isMultiAgentV2Enabled());
656
+ return applyMultiAgentMode(out, multiAgentMode, multiAgentV2Enabled, {
657
+ keepNativeChatGptOnV1,
658
+ });
309
659
  }
310
660
 
311
661
  export function resetCatalogRuntimeStateForTests(): void {
@@ -315,8 +665,10 @@ export function resetCatalogRuntimeStateForTests(): void {
315
665
  comboCatalogWarningSignatures.clear();
316
666
  slugAliasCollisionWarnings.clear();
317
667
  comboMasqueradeCollisionWarnings.clear();
668
+ comboUnrestorableShadowWarnings.clear();
669
+ accountSelectorShadowCollisionWarnings.clear();
318
670
  clearLastComboCatalogOmissions();
319
- clearModelCache();
671
+ clearModelCache(undefined, "eviction");
320
672
  clearGatherRoutedModelsInflight();
321
673
  }
322
674
 
@@ -344,124 +696,361 @@ export function orderForSubagents(goModels: CatalogModel[], featured?: string[])
344
696
  * row.
345
697
  */
346
698
  function isOcxAuthoredRoutedEntry(entry: RawEntry): boolean {
699
+ if (isNativeAliasCatalogEntry(entry)) return true;
347
700
  const desc = typeof entry.description === "string" ? entry.description : "";
348
701
  const slug = typeof entry.slug === "string" ? entry.slug : "";
349
702
  return slug.includes("/") && desc.startsWith("Routed via opencodex → ");
350
703
  }
351
704
 
352
- export function mergeCatalogEntriesForSync(
353
- catalogModels: RawEntry[],
354
- routedEntries: RawEntry[],
355
- baseline: Map<string, number>,
356
- featured: string[],
357
- wsEnabled: boolean,
358
- goIds: Set<string> = new Set(),
359
- template: RawEntry | null = null,
360
- disabledNative: Set<string> = new Set(),
361
- gatheredProviderNames: Set<string> = new Set(routedEntries.flatMap(entry => {
362
- const slug = typeof entry.slug === "string" ? entry.slug : "";
363
- const slash = slug.indexOf("/");
364
- return slash > 0 ? [slug.slice(0, slash)] : [];
365
- })),
366
- multiAgentMode: MultiAgentMode = "default",
367
- exactComboSlugs: ReadonlySet<string> = new Set(),
368
- hasPhysicalComboProvider = false,
369
- includeNativeOpenAi = true,
705
+ function recoverableNativeSlug(entry: RawEntry): string | null {
706
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
707
+ return SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug)
708
+ && !isNativeAliasCatalogEntry(entry)
709
+ && entry.owned_by !== COMBO_NAMESPACE
710
+ ? slug
711
+ : null;
712
+ }
713
+
714
+ /** Append missing supported native rows from trusted catalog sources only. */
715
+ export function mergeCatalogModelsWithNativeRecovery(
716
+ primaryCatalogModels: readonly RawEntry[],
717
+ nativeRecoverySources: readonly (readonly RawEntry[])[],
370
718
  ): RawEntry[] {
719
+ const merged = [...primaryCatalogModels];
720
+ const recoveredNativeSlugs = new Set(primaryCatalogModels.flatMap(entry => {
721
+ const slug = recoverableNativeSlug(entry);
722
+ return slug === null ? [] : [slug];
723
+ }));
724
+ for (const source of nativeRecoverySources) {
725
+ for (const entry of source) {
726
+ const slug = recoverableNativeSlug(entry);
727
+ if (slug === null || recoveredNativeSlugs.has(slug)) continue;
728
+ merged.push(structuredClone(entry) as RawEntry);
729
+ recoveredNativeSlugs.add(slug);
730
+ }
731
+ }
732
+ return merged;
733
+ }
734
+
735
+ export interface ObservedCatalogMergePolicy {
736
+ /** Required observed/fixed set; the core merge never consults ambient catalog state. */
737
+ readonly nativeBackfillSlugs: readonly string[];
738
+ /** Whether unsupported OpenAI-family bare rows survive the merge. */
739
+ readonly unsupportedNativeEntries: "preserve" | "drop";
740
+ /** Whether merge-policy collision/preservation warnings belong to this caller's flow. */
741
+ readonly warningPolicy: "emit" | "suppress";
742
+ }
743
+
744
+ /** Content policy shared by every writer of the canonical Codex model catalog. */
745
+ export const CANONICAL_NATIVE_CATALOG_CONTENT_POLICY: Readonly<
746
+ Pick<ObservedCatalogMergePolicy, "nativeBackfillSlugs" | "unsupportedNativeEntries">
747
+ > = Object.freeze({
748
+ nativeBackfillSlugs: Object.freeze([...NATIVE_OPENAI_MODELS]),
749
+ unsupportedNativeEntries: "drop",
750
+ });
751
+
752
+ export interface ObservedCatalogMergeInput {
753
+ readonly catalogModels: readonly RawEntry[];
754
+ readonly baselineCatalogModels: readonly RawEntry[];
755
+ readonly routedEntries: readonly RawEntry[];
756
+ readonly baseline: ReadonlyMap<string, number>;
757
+ readonly featured: readonly string[];
758
+ readonly wsEnabled: boolean;
759
+ readonly template: RawEntry | null;
760
+ readonly disabledModels: ReadonlySet<string>;
761
+ readonly selectedModelsByProvider: ReadonlyMap<string, ReadonlySet<string>>;
762
+ readonly gatheredProviderNames: ReadonlySet<string>;
763
+ readonly degradedProviderNames: ReadonlySet<string>;
764
+ readonly legacyCustomModelSlugs: ReadonlySet<string>;
765
+ readonly multiAgentMode: MultiAgentMode;
766
+ readonly multiAgentV2Enabled: boolean;
767
+ readonly keepNativeChatGptOnV1?: boolean;
768
+ readonly exactComboSlugs: ReadonlySet<string>;
769
+ readonly hasPhysicalComboProvider: boolean;
770
+ readonly includeNativeOpenAi: boolean;
771
+ readonly accountBoundEntries: readonly RawEntry[];
772
+ readonly suppressedBareNativeSlugs?: ReadonlySet<string>;
773
+ readonly policy: ObservedCatalogMergePolicy;
774
+ readonly openaiContextCap?: NativeContextLimitsInput;
775
+ }
776
+
777
+ /**
778
+ * Deterministically merge one fully observed catalog state.
779
+ *
780
+ * Every non-catalog input is explicit so evidence-bound convergence cannot
781
+ * accidentally fall back to process-ambient catalog discovery or merge-policy warnings.
782
+ */
783
+ export function mergeCatalogEntriesFromObservedState({
784
+ catalogModels,
785
+ baselineCatalogModels,
786
+ routedEntries,
787
+ baseline,
788
+ featured,
789
+ wsEnabled,
790
+ template,
791
+ disabledModels,
792
+ selectedModelsByProvider,
793
+ gatheredProviderNames,
794
+ degradedProviderNames,
795
+ legacyCustomModelSlugs,
796
+ multiAgentMode,
797
+ multiAgentV2Enabled,
798
+ keepNativeChatGptOnV1,
799
+ exactComboSlugs,
800
+ hasPhysicalComboProvider,
801
+ includeNativeOpenAi,
802
+ accountBoundEntries,
803
+ suppressedBareNativeSlugs = new Set(),
804
+ policy,
805
+ openaiContextCap,
806
+ }: ObservedCatalogMergeInput): RawEntry[] {
807
+ // Raw catalog rows contain nested arrays/objects that normalization mutates. Detach every row at
808
+ // the observed-core boundary so callers can safely retain evidence objects or repeat the merge.
809
+ const detachedCatalogModels = catalogModels.map(entry => structuredClone(entry) as RawEntry);
810
+ const detachedBaselineCatalogModels = baselineCatalogModels
811
+ .map(entry => structuredClone(entry) as RawEntry);
812
+ const detachedRoutedEntries = routedEntries.map(entry => structuredClone(entry) as RawEntry);
813
+ const detachedAccountBoundEntries = accountBoundEntries
814
+ .map(entry => structuredClone(entry) as RawEntry);
815
+ const disabledModelKeys = new Set([...disabledModels].map(slugEquivalenceKey));
816
+ const legacyCustomModelKeys = new Set(
817
+ [...legacyCustomModelSlugs].map(slugEquivalenceKey),
818
+ );
819
+ const selectedModelKeysByProvider = new Map([...selectedModelsByProvider].map(([provider, models]) => (
820
+ [provider, new Set([...models].map(model => slugEquivalenceKey(routedSlug(provider, model))))] as const
821
+ )));
822
+ const freshAccountKeys = new Set(detachedAccountBoundEntries.flatMap(entry => (
823
+ typeof entry.slug === "string" ? [slugEquivalenceKey(entry.slug)] : []
824
+ )));
825
+ const wouldSurviveUnreplaced = (entry: RawEntry): boolean => {
826
+ if (entry.owned_by === COMBO_NAMESPACE
827
+ || trustedAccountBoundNativeCatalogSlug(entry) !== undefined
828
+ || entry.opencodex_catalog_kind === CODEX_CUSTOM_MODEL_CATALOG_KIND
829
+ || isOcxAuthoredRoutedEntry(entry)
830
+ || typeof entry.slug !== "string") return false;
831
+ const slug = entry.slug;
832
+ if (!slug.includes("/")) {
833
+ if (!includeNativeOpenAi || policy.nativeBackfillSlugs.includes(slug)) return false;
834
+ return policy.unsupportedNativeEntries === "preserve" || !isUnsupportedOpenAiNativeSlug(slug);
835
+ }
836
+ if (isRoutedModelCompatibilityExcluded(slug)) return false;
837
+ if (!hasPhysicalComboProvider && slug.startsWith(`${COMBO_NAMESPACE}/`)) return false;
838
+ const key = slugEquivalenceKey(slug);
839
+ if (freshAccountKeys.has(key)) return false;
840
+ if (disabledModelKeys.has(key)) return false;
841
+ const slash = slug.indexOf("/");
842
+ const provider = slug.slice(0, slash);
843
+ const selected = selectedModelKeysByProvider.get(provider);
844
+ if (selected !== undefined && !selected.has(key)) return false;
845
+ return !gatheredProviderNames.has(provider) || degradedProviderNames.has(provider);
846
+ };
847
+ const validRoutedEntries = detachedRoutedEntries.filter(entry => {
848
+ return !isExactComboCatalogEntry(entry, exactComboSlugs)
849
+ || (Array.isArray(entry.input_modalities) && entry.input_modalities.length > 0);
850
+ });
851
+ const restorableCatalogKeys = new Set(detachedBaselineCatalogModels.flatMap(entry => (
852
+ wouldSurviveUnreplaced(entry) && typeof entry.slug === "string"
853
+ ? [slugEquivalenceKey(entry.slug)]
854
+ : []
855
+ )));
856
+ const unrestorableCatalogKeys = new Set(detachedCatalogModels.flatMap(entry => {
857
+ if (!wouldSurviveUnreplaced(entry) || typeof entry.slug !== "string") return [];
858
+ const key = slugEquivalenceKey(entry.slug);
859
+ return restorableCatalogKeys.has(key) ? [] : [key];
860
+ }));
861
+ const admittedRoutedEntries = validRoutedEntries.filter(entry => {
862
+ if (!isExactComboCatalogEntry(entry, exactComboSlugs)) return true;
863
+ const slug = entry.slug as string;
864
+ const key = slugEquivalenceKey(slug);
865
+ if (!unrestorableCatalogKeys.has(key)) return true;
866
+ if (policy.warningPolicy === "emit") warnComboUnrestorableShadowOnce(slug);
867
+ return false;
868
+ });
869
+ // A fresh non-custom row authoritatively resolves a historically ambiguous slug as a normal
870
+ // provider model. Persist that classification so the durable deletion evidence cannot remove
871
+ // the legitimate row during a later degraded refresh.
872
+ for (const entry of admittedRoutedEntries) {
873
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
874
+ if (!slug
875
+ || entry.opencodex_catalog_kind !== undefined
876
+ || entry.owned_by === COMBO_NAMESPACE
877
+ || !isOcxAuthoredRoutedEntry(entry)
878
+ || !legacyCustomModelKeys.has(slugEquivalenceKey(slug))) continue;
879
+ entry.opencodex_catalog_kind = CODEX_PROVIDER_MODEL_CATALOG_KIND;
880
+ }
881
+ const freshExactComboEntries = new Set(admittedRoutedEntries.filter(entry => (
882
+ isExactComboCatalogEntry(entry, exactComboSlugs)
883
+ && typeof entry.description === "string"
884
+ && entry.description.startsWith(`Routed via opencodex → ${COMBO_NAMESPACE} (`)
885
+ )));
371
886
  const rank = new Map(featured.map((slug, i) => [slug, i] as const));
372
- const native = includeNativeOpenAi
373
- ? catalogModels
887
+ const freshEquivalentKeys = new Set(admittedRoutedEntries.flatMap(entry => (
888
+ typeof entry.slug === "string" ? [slugEquivalenceKey(entry.slug)] : []
889
+ )));
890
+ const freshEquivalent = (slug: string): boolean => (
891
+ freshEquivalentKeys.has(slugEquivalenceKey(slug))
892
+ );
893
+ const freshBareComboAliases = new Set(admittedRoutedEntries.flatMap(entry => (
894
+ typeof entry.slug === "string"
895
+ && !entry.slug.includes("/")
896
+ && entry.owned_by === COMBO_NAMESPACE
897
+ ? [entry.slug]
898
+ : []
899
+ )));
900
+ const staleComboKeys = new Set(detachedCatalogModels.flatMap(entry => (
901
+ typeof entry.slug === "string"
902
+ && entry.owned_by === COMBO_NAMESPACE
903
+ && !freshEquivalent(entry.slug)
904
+ ? [slugEquivalenceKey(entry.slug)]
905
+ : []
906
+ )));
907
+ const currentNonComboKeys = new Set(detachedCatalogModels.flatMap(entry => (
908
+ entry.owned_by !== COMBO_NAMESPACE && typeof entry.slug === "string"
909
+ ? [slugEquivalenceKey(entry.slug)]
910
+ : []
911
+ )));
912
+ const restoredComboShadows = detachedBaselineCatalogModels.filter(entry => {
913
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
914
+ if (!slug || entry.owned_by === COMBO_NAMESPACE) return false;
915
+ const key = slugEquivalenceKey(slug);
916
+ return staleComboKeys.has(key) && !currentNonComboKeys.has(key);
917
+ });
918
+ const catalogModelsForMerge = [...detachedCatalogModels, ...restoredComboShadows];
919
+ const nativePriority = (slug: string, fallback: unknown): number => {
920
+ const base = baseline.get(slug)
921
+ ?? (typeof fallback === "number" ? fallback : 9);
922
+ if (rank.has(slug)) return rank.get(slug)!;
923
+ return featured.length > 0 ? Math.max(base, featured.length + 100) : base;
924
+ };
925
+ const nativeSourceEntries = includeNativeOpenAi
926
+ ? catalogModelsForMerge
374
927
  .filter(m => typeof m.slug === "string"
375
928
  && !(m.slug as string).includes("/")
376
929
  && m.owned_by !== COMBO_NAMESPACE
377
- && !goIds.has(m.slug as string)
378
- && !isUnsupportedOpenAiNativeSlug(m.slug as string))
930
+ && (policy.unsupportedNativeEntries === "preserve"
931
+ || policy.nativeBackfillSlugs.includes(m.slug as string)
932
+ || !isUnsupportedOpenAiNativeSlug(m.slug as string)))
379
933
  .map(m => {
380
934
  const slug = m.slug as string;
381
- // Featured models rank first (rank order); non-featured natives are pushed below the featured
382
- // block when any model is featured, else keep their pristine baseline priority.
383
- const baselinePriority = baseline.get(slug) ?? (m.priority as number);
384
- const priority = rank.has(slug)
385
- ? rank.get(slug)!
386
- : featured.length > 0
387
- ? Math.max(typeof baselinePriority === "number" ? baselinePriority : 9, featured.length + 100)
388
- : baselinePriority;
389
935
  // Fallback-quality entries (ocx synthesis / codex-rs model_info fallback: display_name
390
936
  // stamped with the bare slug) are upgraded to the pinned upstream snapshot entry so a
391
937
  // previously synthesized ladder (e.g. luna advertising ultra) self-heals on sync. A
392
938
  // genuine catalog entry (real display name) is preserved untouched.
393
939
  if (shouldUpgradeToUpstreamEntry(m)) {
394
940
  const upstream = upstreamNativeEntry(slug)!;
395
- const upgradePriority = rank.has(slug)
396
- ? rank.get(slug)!
397
- : featured.length > 0
398
- ? Math.max(typeof upstream.priority === "number" ? upstream.priority : 9, featured.length + 100)
399
- : typeof upstream.priority === "number" ? upstream.priority : priority;
400
- const finished = finishUpstreamNativeEntry(upstream, 9);
401
- finished.priority = upgradePriority;
941
+ const finished = finishUpstreamNativeEntry(upstream, 9, openaiContextCap);
942
+ finished.priority = nativePriority(slug, upstream.priority);
402
943
  return finished;
403
944
  }
404
- const preserved = normalizeServiceTiers({ ...m, priority });
945
+ const preserved = normalizeServiceTiers({ ...m, priority: nativePriority(slug, m.priority) });
405
946
  // Older natives kept from disk still need the mock top tiers (max + ultra always
406
947
  // for subagent max spawns; wire-clamped to the model's real top rung).
407
948
  if (!isGpt56NativeSlug(slug)) ensureUltraReasoningLevel(preserved);
408
949
  return preserved;
409
950
  })
410
951
  : [];
952
+ const native = nativeSourceEntries.filter(entry =>
953
+ typeof entry.slug !== "string"
954
+ || (!freshBareComboAliases.has(entry.slug) && !suppressedBareNativeSlugs.has(entry.slug))
955
+ );
411
956
 
412
957
  // Backfill any native OpenAI slug that the on-disk catalog is missing (e.g. gpt-5.5), so a
413
958
  // routed provider exposing the same id can never delete the native OpenAI/Codex base row.
414
959
  // Skip when no enabled canonical openai provider exists (#636) — bare gpt-* would 404.
415
960
  const nativeSlugs = new Set(native.flatMap(m => typeof m.slug === "string" ? [m.slug] : []));
416
961
  if (includeNativeOpenAi) {
417
- for (const slug of nativeOpenAiSlugs()) {
418
- if (nativeSlugs.has(slug)) continue;
419
- nativeSlugs.add(slug);
420
- const priority = rank.has(slug)
421
- ? rank.get(slug)!
422
- : featured.length > 0
423
- ? featured.length + 100
424
- : 9;
425
- native.push(deriveEntry(template ? JSON.parse(JSON.stringify(template)) : null, slug, "OpenAI native model (Codex OAuth passthrough).", priority));
426
- }
962
+ for (const slug of policy.nativeBackfillSlugs) {
963
+ if (nativeSlugs.has(slug) || freshBareComboAliases.has(slug) || suppressedBareNativeSlugs.has(slug)) continue;
964
+ nativeSlugs.add(slug);
965
+ const entry = deriveEntry(
966
+ template ? JSON.parse(JSON.stringify(template)) : null,
967
+ slug,
968
+ "OpenAI native model (Codex OAuth passthrough).",
969
+ nativePriority(slug, upstreamNativeEntry(slug)?.priority),
970
+ undefined,
971
+ new Set(),
972
+ openaiContextCap,
973
+ );
974
+ entry.priority = nativePriority(slug, upstreamNativeEntry(slug)?.priority);
975
+ native.push(entry);
976
+ }
427
977
  }
428
978
 
979
+ const nativeSourceBySlug = new Map([...nativeSourceEntries, ...native].flatMap(entry =>
980
+ typeof entry.slug === "string" ? [[entry.slug, entry] as const] : []
981
+ ));
982
+ const alignedAccountBoundEntries = detachedAccountBoundEntries.map(entry => {
983
+ const nativeSlug = trustedAccountBoundNativeCatalogSlug(entry);
984
+ const source = nativeSlug === undefined ? undefined : nativeSourceBySlug.get(nativeSlug);
985
+ if (!source) return entry;
986
+ const aligned = JSON.parse(JSON.stringify(source)) as RawEntry;
987
+ aligned.slug = entry.slug;
988
+ aligned.display_name = entry.display_name;
989
+ aligned.priority = entry.priority;
990
+ aligned.visibility = "list";
991
+ aligned.opencodex_catalog_kind = CODEX_ACCOUNT_BOUND_CATALOG_KIND;
992
+ return aligned;
993
+ });
994
+
429
995
  const freshSlugs = new Set(
430
- routedEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []),
996
+ admittedRoutedEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []),
431
997
  );
432
- let finalRoutedEntries = routedEntries;
433
- const preservingExistingRouted = routedEntries.length === 0
434
- && catalogModels.some(m => typeof m.slug === "string" && (m.slug as string).includes("/"));
435
- if (preservingExistingRouted) {
436
- // #855: transient-fetch protection keeps existing rows, but rows OpenCodex
437
- // itself authored for a provider that is no longer configured are ghosts,
438
- // not protected foreign entries.
439
- finalRoutedEntries = catalogModels.filter(m => {
440
- if (typeof m.slug !== "string" || !(m.slug as string).includes("/")) return false;
441
- const provider = (m.slug as string).slice(0, (m.slug as string).indexOf("/"));
442
- return !(isOcxAuthoredRoutedEntry(m) && !gatheredProviderNames.has(provider));
443
- });
444
- } else {
445
- const preservedForeignRouted = catalogModels.filter(m => {
446
- if (typeof m.slug !== "string" || !m.slug.includes("/")) return false;
447
- const provider = m.slug.slice(0, m.slug.indexOf("/"));
448
- if (gatheredProviderNames.has(provider) || freshSlugs.has(m.slug)) return false;
449
- // #855: an OpenCodex-authored row whose provider was deleted is a ghost;
450
- // only genuinely foreign rows (Cursor, user tooling) are preserved.
451
- return !isOcxAuthoredRoutedEntry(m);
452
- });
453
- finalRoutedEntries = [...routedEntries, ...preservedForeignRouted];
454
- }
998
+ const existingRoutedEntries = catalogModelsForMerge.filter(m =>
999
+ typeof m.slug === "string"
1000
+ && (m.slug.includes("/") || isNativeAliasCatalogEntry(m))
1001
+ && trustedAccountBoundNativeCatalogSlug(m) === undefined
1002
+ );
1003
+ const preservedRoutedEntries = existingRoutedEntries.filter(entry => {
1004
+ const slug = entry.slug as string;
1005
+ if (freshEquivalent(slug)) return false;
1006
+ if (isNativeAliasCatalogEntry(entry)) return exactComboSlugs.has(slug);
1007
+ // Current custom rows are always regenerated from config, even while provider discovery is
1008
+ // degraded. A marked row absent from the fresh projection is therefore an intentional delete.
1009
+ if (entry.opencodex_catalog_kind === CODEX_CUSTOM_MODEL_CATALOG_KIND) return false;
1010
+ // Before custom rows had a marker, a config deletion could otherwise be mistaken for a
1011
+ // provider outage. Only explicit save-boundary evidence may classify an unmarked OpenCodex
1012
+ // row; foreign and future-marked rows fail closed and remain preserved.
1013
+ if (entry.opencodex_catalog_kind === undefined
1014
+ && entry.owned_by !== COMBO_NAMESPACE
1015
+ && isOcxAuthoredRoutedEntry(entry)
1016
+ && legacyCustomModelKeys.has(slugEquivalenceKey(slug))) return false;
1017
+ const provider = slug.slice(0, slug.indexOf("/"));
1018
+ if (gatheredProviderNames.has(provider)) {
1019
+ // A provider-local degraded observation preserves only that namespace. Authoritative empty
1020
+ // catalogs and successful removals still delete stale rows even when another provider fails.
1021
+ return degradedProviderNames.has(provider);
1022
+ }
1023
+ // Deleted/disabled providers cannot retain OpenCodex-authored ghosts. Foreign catalog rows
1024
+ // remain outside provider ownership and survive unless a fresh row replaces their exact slug.
1025
+ return !isOcxAuthoredRoutedEntry(entry);
1026
+ });
1027
+ let finalRoutedEntries = [...admittedRoutedEntries, ...preservedRoutedEntries];
1028
+ finalRoutedEntries = finalRoutedEntries.filter(entry => {
1029
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
1030
+ if (!slug.includes("/")) return true;
1031
+ if (disabledModelKeys.has(slugEquivalenceKey(slug))) return false;
1032
+ // Provider allowlists own provider rows, not a current combo's public alias. Exempt only an
1033
+ // identity from this gather's generated combo projection: provider discovery may supply a
1034
+ // spoofed `owned_by`, and persisted combo-shaped rows are not fresh authority.
1035
+ if (freshExactComboEntries.has(entry)) return true;
1036
+ const slash = slug.indexOf("/");
1037
+ const provider = slug.slice(0, slash);
1038
+ const selected = selectedModelKeysByProvider.get(provider);
1039
+ return selected === undefined || selected.has(slugEquivalenceKey(slug));
1040
+ });
455
1041
  if (!hasPhysicalComboProvider) {
456
1042
  finalRoutedEntries = finalRoutedEntries.filter(entry => {
457
1043
  const slug = typeof entry.slug === "string" ? entry.slug : "";
458
1044
  const comboOwned = slug.startsWith(`${COMBO_NAMESPACE}/`) || entry.owned_by === COMBO_NAMESPACE;
459
- return !comboOwned || freshSlugs.has(slug);
1045
+ const retainedNativeAlias = isNativeAliasCatalogEntry(entry) && exactComboSlugs.has(slug);
1046
+ return !comboOwned || freshSlugs.has(slug) || retainedNativeAlias;
460
1047
  });
461
1048
  }
462
1049
  finalRoutedEntries = finalRoutedEntries.filter(entry => {
463
1050
  const slug = typeof entry.slug === "string" ? entry.slug : "";
464
- return !exactComboSlugs.has(slug)
1051
+ const retainedNativeAlias = isNativeAliasCatalogEntry(entry) && exactComboSlugs.has(slug);
1052
+ return retainedNativeAlias
1053
+ || !isExactComboCatalogEntry(entry, exactComboSlugs)
465
1054
  || (Array.isArray(entry.input_modalities) && entry.input_modalities.length > 0);
466
1055
  });
467
1056
  // Reapply final catalog policy to rows preserved from disk. Those rows bypass
@@ -469,17 +1058,40 @@ export function mergeCatalogEntriesForSync(
469
1058
  finalRoutedEntries = finalRoutedEntries.filter(entry =>
470
1059
  typeof entry.slug !== "string" || !isRoutedModelCompatibilityExcluded(entry.slug)
471
1060
  );
472
- if (preservingExistingRouted) {
473
- console.warn(`[opencodex] catalog sync: routed model fetch returned empty; preserving ${finalRoutedEntries.length} existing routed entr${finalRoutedEntries.length === 1 ? "y" : "ies"} on disk.`);
1061
+ const accountBoundSlugs = new Set(alignedAccountBoundEntries.flatMap(entry =>
1062
+ typeof entry.slug === "string" ? [entry.slug] : []
1063
+ ));
1064
+ finalRoutedEntries = finalRoutedEntries.filter(entry => {
1065
+ if (typeof entry.slug !== "string" || !accountBoundSlugs.has(entry.slug)) return true;
1066
+ if (freshSlugs.has(entry.slug) && policy.warningPolicy === "emit") {
1067
+ warnAccountSelectorShadowedProviderOnce(entry.slug);
1068
+ }
1069
+ return false;
1070
+ });
1071
+ const finalRoutedEntrySet = new Set(finalRoutedEntries);
1072
+ const degradedPreservedCount = preservedRoutedEntries.filter(entry => {
1073
+ if (!finalRoutedEntrySet.has(entry)) return false;
1074
+ const slug = entry.slug as string;
1075
+ const provider = slug.slice(0, slug.indexOf("/"));
1076
+ return gatheredProviderNames.has(provider) && degradedProviderNames.has(provider);
1077
+ }).length;
1078
+ if (degradedPreservedCount > 0 && policy.warningPolicy === "emit") {
1079
+ console.warn(`[opencodex] catalog sync: provider discovery degraded; preserving ${degradedPreservedCount} existing routed entr${degradedPreservedCount === 1 ? "y" : "ies"} on disk.`);
474
1080
  }
475
1081
 
476
- const mergedEntries = [...native, ...finalRoutedEntries].map(m => {
1082
+ const managedEntries = [...finalRoutedEntries, ...alignedAccountBoundEntries];
1083
+ const observedNativeSlugs = new Set(alignedAccountBoundEntries.flatMap(entry => {
1084
+ const slug = trustedAccountBoundNativeCatalogSlug(entry);
1085
+ return slug === undefined ? [] : [slug];
1086
+ }));
1087
+ for (const slug of policy.nativeBackfillSlugs) observedNativeSlugs.add(slug);
1088
+ const mergedEntries = [...native, ...managedEntries].map(m => {
477
1089
  const normalized = normalizeServiceTiers(m);
478
- applyNativeOpenAiContextOverride(normalized);
479
- const exactCombo = typeof m.slug === "string" && exactComboSlugs.has(m.slug);
1090
+ if (!isNativeAliasCatalogEntry(normalized)) applyNativeOpenAiContextOverride(normalized, openaiContextCap);
1091
+ const exactCombo = isExactComboCatalogEntry(m, exactComboSlugs);
480
1092
  const e = ensureStrictCatalogFields(normalized, {
481
1093
  preserveExactInputModalities: exactCombo,
482
- isRouted: finalRoutedEntries.includes(m),
1094
+ isRouted: finalRoutedEntrySet.has(m),
483
1095
  });
484
1096
  // Mock-max universality (260709): preserved routed entries from disk may predate
485
1097
  // the max rung — ensure it here so subagent max spawns validate on every
@@ -502,35 +1114,453 @@ export function mergeCatalogEntriesForSync(
502
1114
  }
503
1115
  return e;
504
1116
  });
505
- // Native enable/disable (single choke point: bare slugs in `disabledModels`). Runs as the
506
- // LAST pass so the upstream-upgrade branch above can never clobber a hide flag back to list.
507
- return applyMultiAgentMode(applyNativeVisibility(mergedEntries, disabledNative), multiAgentMode, isMultiAgentV2Enabled());
1117
+ // Native enable/disable runs as the LAST pass so the upstream-upgrade branch above can never
1118
+ // clobber a hide flag back to list. Bare ids disable every account clone; qualified ids disable
1119
+ // only their generated account row.
1120
+ const versionedEntries = applyMultiAgentMode(
1121
+ applyNativeVisibility(mergedEntries, disabledModels, alignedAccountBoundEntries.length > 0, observedNativeSlugs),
1122
+ multiAgentMode,
1123
+ multiAgentV2Enabled,
1124
+ { keepNativeChatGptOnV1 },
1125
+ );
1126
+ for (const entry of versionedEntries) {
1127
+ const kind = entry.opencodex_catalog_kind;
1128
+ if (trustedAccountBoundNativeCatalogSlug(entry) === undefined
1129
+ && kind !== CODEX_CUSTOM_MODEL_CATALOG_KIND
1130
+ && kind !== CODEX_PROVIDER_MODEL_CATALOG_KIND) continue;
1131
+ // Canonicalize extension-field order after every normalizer. This keeps an unchanged catalog
1132
+ // byte-idempotent whether an owned row was freshly built or retained from the prior pass.
1133
+ delete entry.opencodex_catalog_kind;
1134
+ entry.opencodex_catalog_kind = kind;
1135
+ }
1136
+ return versionedEntries;
1137
+ }
1138
+
1139
+ /** Merge retained-sync rows using the process-observed Codex feature state. */
1140
+ export function mergeCatalogEntriesForSync(
1141
+ catalogModels: RawEntry[],
1142
+ routedEntries: RawEntry[],
1143
+ baseline: Map<string, number>,
1144
+ featured: string[],
1145
+ wsEnabled: boolean,
1146
+ _goIds: Set<string> = new Set(),
1147
+ template: RawEntry | null = null,
1148
+ disabledModels: ReadonlySet<string> = new Set(),
1149
+ gatheredProviderNames?: Set<string>,
1150
+ multiAgentMode: MultiAgentMode = "default",
1151
+ exactComboSlugs: ReadonlySet<string> = new Set(),
1152
+ hasPhysicalComboProvider = false,
1153
+ includeNativeOpenAi = true,
1154
+ accountBoundEntries: readonly RawEntry[] = [],
1155
+ legacyCustomModelSlugs: ReadonlySet<string> = new Set(),
1156
+ suppressedBareNativeSlugs: ReadonlySet<string> = new Set(
1157
+ routedEntries.flatMap(entry => (
1158
+ isNativeAliasCatalogEntry(entry) && typeof entry.slug === "string" ? [entry.slug] : []
1159
+ )),
1160
+ ),
1161
+ openaiContextCap?: NativeContextLimitsInput,
1162
+ keepNativeChatGptOnV1 = false,
1163
+ ): RawEntry[] {
1164
+ // Retained for source compatibility with the original helper contract. Raw provider ids must
1165
+ // not suppress same-named native rows; actual admitted combo entries own that decision now.
1166
+ void _goIds;
1167
+ const effectiveGatheredProviderNames = gatheredProviderNames ?? new Set(
1168
+ routedEntries.flatMap(entry => {
1169
+ // A slashed combo alias is not evidence that its public prefix is an authoritative provider
1170
+ // namespace. Treating it as one would let the combo replace an unrestorable foreign row.
1171
+ if (isExactComboCatalogEntry(entry, exactComboSlugs)) return [];
1172
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
1173
+ const slash = slug.indexOf("/");
1174
+ return slash > 0 ? [slug.slice(0, slash)] : [];
1175
+ }),
1176
+ );
1177
+ return mergeCatalogEntriesFromObservedState({
1178
+ catalogModels,
1179
+ baselineCatalogModels: [],
1180
+ routedEntries,
1181
+ baseline,
1182
+ featured,
1183
+ wsEnabled,
1184
+ template,
1185
+ disabledModels,
1186
+ selectedModelsByProvider: new Map(),
1187
+ gatheredProviderNames: effectiveGatheredProviderNames,
1188
+ degradedProviderNames: new Set(),
1189
+ legacyCustomModelSlugs,
1190
+ multiAgentMode,
1191
+ multiAgentV2Enabled: isMultiAgentV2Enabled(),
1192
+ keepNativeChatGptOnV1,
1193
+ exactComboSlugs,
1194
+ hasPhysicalComboProvider,
1195
+ includeNativeOpenAi,
1196
+ accountBoundEntries,
1197
+ suppressedBareNativeSlugs,
1198
+ openaiContextCap,
1199
+ policy: {
1200
+ ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY,
1201
+ warningPolicy: "emit",
1202
+ },
1203
+ });
1204
+ }
1205
+
1206
+ interface RetainedCatalogSyncRead {
1207
+ readonly catalogPath: string;
1208
+ readonly catalog: RawCatalog;
1209
+ readonly onDiskCatalog: RawCatalog | null;
1210
+ readonly modelsCache: RawCatalog | null;
1211
+ readonly evidence: string;
1212
+ /**
1213
+ * Process-local epochs, baselined AFTER our own gather rather than with the
1214
+ * filesystem bytes above. See `retainedCatalogProcessEvidence`.
1215
+ */
1216
+ readonly processEvidence: string;
508
1217
  }
509
1218
 
510
- export async function syncCatalogModels(config: OcxConfig): Promise<{
1219
+ interface RetainedCatalogSyncResult {
511
1220
  added: number;
512
1221
  path: string;
513
1222
  catalogWritten: boolean;
514
1223
  comboOmissions: ComboCatalogOmission[];
515
- }> {
1224
+ /** `desired_disabled` observed under K after the provider await; nothing was written. */
1225
+ skippedReason?: "desired_disabled";
1226
+ }
1227
+
1228
+ /**
1229
+ * Catalog/cache commit overrides.
1230
+ *
1231
+ * An explicit `ocx sync` is also the refresh path for side profiles that consume
1232
+ * the OpenCodex catalog without injection (for example a custom `model_provider`
1233
+ * that routes to the proxy). In that mode the Codex integration toggle only
1234
+ * governs config/history injection; the catalog and models cache may still be
1235
+ * refreshed, so `allowWhenDesiredDisabled` lets the commit path ignore the OFF
1236
+ * gate that otherwise protects a fully native home.
1237
+ */
1238
+ export interface CodexCatalogSyncOptions {
1239
+ allowWhenDesiredDisabled?: boolean;
1240
+ }
1241
+
1242
+ interface RetainedCatalogSyncWrite {
1243
+ readonly config: OcxConfig;
1244
+ readonly goModels: CatalogModel[];
1245
+ readonly providerModelOutcomes: readonly CatalogGatherProviderModelOutcome[];
1246
+ readonly comboOmissions: ComboCatalogOmission[];
1247
+ readonly read: RetainedCatalogSyncRead;
1248
+ readonly permit: CatalogWritePermit;
1249
+ readonly owningCodexHome: string;
1250
+ readonly modelEntitlements: CodexModelEntitlementSnapshot;
1251
+ }
1252
+
1253
+ function optionalFileBytes(path: string): string | null {
1254
+ try {
1255
+ return readFileSync(path).toString("base64");
1256
+ } catch (error) {
1257
+ if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") return null;
1258
+ throw error;
1259
+ }
1260
+ }
1261
+
1262
+ function loadCatalogForRetainedSync(path: string): RawCatalog | null {
1263
+ const bundled = isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null;
1264
+ if (bundled) return JSON.parse(JSON.stringify(bundled)) as RawCatalog;
1265
+ const active = readCatalog(path);
1266
+ // A valid configured custom file remains the content authority even when it has no bare native
1267
+ // template. The null-template builder is deliberate; a stale backup must not replace active
1268
+ // custom root metadata merely because the current file contains only routed rows.
1269
+ if (active && (!isDefaultCatalogPath(path) || findNativeTemplate(active))) return active;
1270
+ return readCatalog(catalogBackupPathFor(path))
1271
+ ?? (isDefaultCatalogPath(path) ? readCatalog(legacyCatalogBackupPath()) : null)
1272
+ ?? readCatalog(activeCodexModelsCachePath())
1273
+ ?? active;
1274
+ }
1275
+
1276
+ function retainedCatalogSyncEvidence(
1277
+ config: OcxConfig,
1278
+ catalogPath: string,
1279
+ catalog: RawCatalog,
1280
+ ): string {
1281
+ return JSON.stringify({
1282
+ config,
1283
+ catalogPath,
1284
+ catalog,
1285
+ catalogBytes: optionalFileBytes(catalogPath),
1286
+ hashedBackupBytes: optionalFileBytes(catalogBackupPathFor(catalogPath)),
1287
+ legacyBackupBytes: isDefaultCatalogPath(catalogPath)
1288
+ ? optionalFileBytes(legacyCatalogBackupPath()) : null,
1289
+ modelsCacheBytes: optionalFileBytes(activeCodexModelsCachePath()),
1290
+ // The persisted runtime selection is a pre-await filesystem input, not a
1291
+ // process epoch: another PROCESS can move runtime authority by rewriting this
1292
+ // file, and that move is invisible to our in-process memo. Recorded PRESENT or
1293
+ // ABSENT, because its absence is what makes the resolver fall back.
1294
+ runtimeStateBytes: optionalFileBytes(codexRuntimeStatePath()),
1295
+ });
1296
+ }
1297
+
1298
+ /**
1299
+ * The bundled-template half of the same evidence, observed separately.
1300
+ *
1301
+ * The runtime process memo is deliberately NOT here, and that exclusion took three
1302
+ * attempts to get honest. Gathering resolves the Codex runtime lazily and under its
1303
+ * own cache key, so this path cannot pre-settle that memo: baselining it before the
1304
+ * await always detected our own side effect and refused every write, and baselining
1305
+ * it after the await captured a runtime that ANOTHER process had moved as though it
1306
+ * were ours — a catalog prepared from R1 committing after authority reached R2.
1307
+ *
1308
+ * Runtime authority is covered where it is actually durable instead: the persisted
1309
+ * `codex-runtime.json` bytes sit in the pre-await filesystem evidence, PRESENT or
1310
+ * ABSENT, so a cross-process runtime move is caught. What is left uncovered, and is
1311
+ * written down rather than papered over, is a same-process in-memory runtime swap
1312
+ * that never touches that file — WP11 owns the lock that makes that case decidable.
1313
+ */
1314
+ function retainedCatalogProcessEvidence(): string {
1315
+ return JSON.stringify({
1316
+ bundledCatalogCache: bundledCatalogCacheState(),
1317
+ });
1318
+ }
1319
+
1320
+ /**
1321
+ * Capture every local catalog input the retained sync path consults before its
1322
+ * provider await. The exact evidence is compared after K acquisition; a newer
1323
+ * catalog/backup/cache or target selection makes this attempt a no-write.
1324
+ */
1325
+ function readRetainedCatalogSync(config: OcxConfig): RetainedCatalogSyncRead | null {
516
1326
  const catalogPath = readCodexCatalogPath();
517
- const catalog = loadCatalogForSync(catalogPath);
518
- if (!catalog) return { added: 0, path: catalogPath, catalogWritten: false, comboOmissions: [] };
1327
+ const catalog = loadCatalogForRetainedSync(catalogPath);
1328
+ if (!catalog) return null;
519
1329
 
520
1330
  // The bundled catalog is a reliable native template on the default path, but it is not the
521
1331
  // merge source. Preservation must inspect the file that this sync is about to overwrite;
522
1332
  // otherwise an empty/partial provider gather cannot see routed or user-native rows on disk.
523
1333
  const onDiskCatalog = readCatalog(catalogPath);
524
- const catalogModelsForMerge = onDiskCatalog?.models ?? catalog.models ?? [];
1334
+ const modelsCache = readCatalog(activeCodexModelsCachePath());
1335
+ const evidence = retainedCatalogSyncEvidence(config, catalogPath, catalog);
1336
+ // `processEvidence` is filled in after the provider await, not here.
1337
+ return { catalogPath, catalog, onDiskCatalog, modelsCache, evidence, processEvidence: "" };
1338
+ }
1339
+
1340
+ function revalidateRetainedCatalogSync(
1341
+ config: OcxConfig,
1342
+ prepared: RetainedCatalogSyncRead,
1343
+ ): RetainedCatalogSyncRead | null {
1344
+ const catalogPath = readCodexCatalogPath();
1345
+ if (catalogPath !== prepared.catalogPath) return null;
1346
+ const evidence = retainedCatalogSyncEvidence(config, catalogPath, prepared.catalog);
1347
+ if (evidence !== prepared.evidence) return null;
1348
+ if (retainedCatalogProcessEvidence() !== prepared.processEvidence) return null;
1349
+ return {
1350
+ catalogPath,
1351
+ catalog: JSON.parse(JSON.stringify(prepared.catalog)) as RawCatalog,
1352
+ onDiskCatalog: readCatalog(catalogPath),
1353
+ modelsCache: readCatalog(activeCodexModelsCachePath()),
1354
+ evidence,
1355
+ processEvidence: prepared.processEvidence,
1356
+ };
1357
+ }
1358
+
1359
+ /**
1360
+ * Exact bytes currently on disk at `path`, or null when unreadable/absent.
1361
+ *
1362
+ * Deliberately a Buffer rather than a decoded string: `readFileSync(path, "utf8")`
1363
+ * substitutes U+FFFD for every invalid byte, so a file holding a raw 0x80 decodes
1364
+ * equal to prepared content holding a legitimately encoded U+FFFD. Comparing the
1365
+ * decoded strings would then classify a malformed catalog as identical, skip the
1366
+ * atomic repair write, and leave the corruption on disk while reporting
1367
+ * `catalogWritten: false`.
1368
+ */
1369
+ function currentCatalogFileContent(path: string): Buffer | null {
1370
+ try {
1371
+ return readFileSync(path);
1372
+ } catch {
1373
+ return null;
1374
+ }
1375
+ }
1376
+
1377
+ function pristineCatalogBytes(read: RetainedCatalogSyncRead): string | null {
1378
+ if (read.onDiskCatalog && !catalogHasRoutedEntries(read.onDiskCatalog)) {
1379
+ try {
1380
+ return readFileSync(read.catalogPath, "utf8");
1381
+ } catch {
1382
+ return null;
1383
+ }
1384
+ }
1385
+ return catalogHasRoutedEntries(read.catalog)
1386
+ ? null
1387
+ : `${JSON.stringify(read.catalog, null, 2)}\n`;
1388
+ }
1389
+
1390
+ function catalogModelsForMergeWithNativeRecovery(
1391
+ catalogPath: string,
1392
+ catalog: RawCatalog,
1393
+ onDiskCatalog: RawCatalog | null,
1394
+ ): RawEntry[] {
1395
+ const primaryCatalogModels = onDiskCatalog?.models ?? catalog.models ?? [];
1396
+ // Native-alias compatibility can omit disabled native rows from the effective catalog because
1397
+ // Desktop's remote allowlist ignores `visibility: "hide"`. Keep current/pristine native recovery
1398
+ // sources beside the on-disk rows so re-enabling a model restores its real metadata. Routed and
1399
+ // user-authored rows still come only from the on-disk catalog.
1400
+ return mergeCatalogModelsWithNativeRecovery(primaryCatalogModels, [
1401
+ catalog.models ?? [],
1402
+ readCatalogBackup(catalogPath)?.models ?? [],
1403
+ ]);
1404
+ }
1405
+
1406
+ const AUTO_REVIEW_MODEL_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\s]/;
1407
+
1408
+ export function isValidAutoReviewModel(value: unknown): value is string {
1409
+ if (typeof value !== "string") return false;
1410
+ const trimmed = value.trim();
1411
+ return Boolean(trimmed)
1412
+ && trimmed.length <= 1024
1413
+ && !AUTO_REVIEW_MODEL_CONTROL_CHARS.test(trimmed);
1414
+ }
1415
+
1416
+ export type AutoReviewModelOverrideResult = "absent" | "applied" | "invalid" | "unresolved";
1417
+
1418
+ function isRoutedCatalogEntry(entry: RawEntry): boolean {
1419
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
1420
+ return slug.includes("/")
1421
+ || (typeof entry.description === "string" && entry.description.startsWith("Routed via opencodex → "));
1422
+ }
1423
+
1424
+ function clearAutoReviewModelOverride(
1425
+ models: readonly RawEntry[],
1426
+ sourceModels: readonly RawEntry[] = [],
1427
+ ): void {
1428
+ const observedModels = [...models, ...sourceModels];
1429
+ const configuredValues = new Set(observedModels.flatMap(entry => {
1430
+ const value = entry?.auto_review_model_override;
1431
+ return typeof value === "string" && value.trim() ? [value] : [];
1432
+ }));
1433
+ const globalStamp = configuredValues.size === 1
1434
+ && observedModels.some(entry => {
1435
+ const value = entry.auto_review_model_override;
1436
+ return isRoutedCatalogEntry(entry)
1437
+ && typeof value === "string"
1438
+ && value.trim().length > 0
1439
+ && configuredValues.has(value);
1440
+ })
1441
+ && observedModels.every(entry => {
1442
+ const value = entry?.auto_review_model_override;
1443
+ return value === null
1444
+ || value === undefined
1445
+ || (typeof value === "string" && configuredValues.has(value));
1446
+ });
1447
+ for (const entry of models) {
1448
+ if (!entry || typeof entry !== "object") continue;
1449
+ const current = entry.auto_review_model_override;
1450
+ if (isRoutedCatalogEntry(entry)
1451
+ || (globalStamp && typeof current === "string" && configuredValues.has(current))) {
1452
+ entry.auto_review_model_override = null;
1453
+ }
1454
+ }
1455
+ }
1456
+
1457
+ function warnAutoReviewModelDiagnostic(
1458
+ reason: "invalid" | "unresolved",
1459
+ configured: string,
1460
+ ): void {
1461
+ const safeConfigured = JSON.stringify(redactSecretString(configured));
1462
+ const detail = reason === "unresolved"
1463
+ ? "the selector was not found in the final catalog"
1464
+ : "the selector format is invalid";
1465
+ console.warn(
1466
+ `[opencodex] auto_review_model ${detail} (${safeConfigured}); preserving normal upstream auto-review behavior.`,
1467
+ );
1468
+ }
1469
+
1470
+ function preserveNativeAutoReviewModelOverrides(
1471
+ models: readonly RawEntry[],
1472
+ sourceModels: readonly RawEntry[],
1473
+ ): void {
1474
+ const existing = new Map<string, string | null>();
1475
+ for (const entry of sourceModels) {
1476
+ const slug = typeof entry.slug === "string" ? entry.slug : undefined;
1477
+ const value = entry.auto_review_model_override;
1478
+ if (!slug || isRoutedCatalogEntry(entry)) continue;
1479
+ if (typeof value === "string" || value === null) existing.set(slug, value);
1480
+ }
1481
+ for (const entry of models) {
1482
+ const slug = typeof entry.slug === "string" ? entry.slug : undefined;
1483
+ if (!slug || isRoutedCatalogEntry(entry) || !existing.has(slug)) continue;
1484
+ entry.auto_review_model_override = existing.get(slug) ?? null;
1485
+ }
1486
+ }
1487
+
1488
+ export function applyAutoReviewModelOverride(
1489
+ models: RawEntry[] | undefined,
1490
+ autoReviewModel: string | null | undefined,
1491
+ sourceModels: readonly RawEntry[] = [],
1492
+ ): AutoReviewModelOverrideResult {
1493
+ if (!models || !Array.isArray(models)) return "absent";
1494
+ if (autoReviewModel === null || autoReviewModel === undefined) {
1495
+ clearAutoReviewModelOverride(models, sourceModels);
1496
+ return "absent";
1497
+ }
1498
+ const trimmed = autoReviewModel.trim();
1499
+ if (!trimmed) {
1500
+ clearAutoReviewModelOverride(models, sourceModels);
1501
+ return "absent";
1502
+ }
1503
+ if (!isValidAutoReviewModel(trimmed)) {
1504
+ clearAutoReviewModelOverride(models, sourceModels);
1505
+ warnAutoReviewModelDiagnostic("invalid", trimmed);
1506
+ return "invalid";
1507
+ }
1508
+ if (!configuredCatalogEntry(models, trimmed)) {
1509
+ clearAutoReviewModelOverride(models, sourceModels);
1510
+ warnAutoReviewModelDiagnostic("unresolved", trimmed);
1511
+ return "unresolved";
1512
+ }
1513
+ for (const entry of models) {
1514
+ if (entry && typeof entry === "object") {
1515
+ entry.auto_review_model_override = trimmed;
1516
+ }
1517
+ }
1518
+ return "applied";
1519
+ }
1520
+
1521
+ /** Apply the root Codex auto-review selector after the final catalog merge. */
1522
+ export function finalizeAutoReviewModelOverride(
1523
+ models: RawEntry[] | undefined,
1524
+ sourceModels: readonly RawEntry[] = [],
1525
+ ): AutoReviewModelOverrideResult {
1526
+ if (models && sourceModels.length > 0) preserveNativeAutoReviewModelOverrides(models, sourceModels);
1527
+ return applyAutoReviewModelOverride(models, readConfiguredAutoReviewModel(), sourceModels);
1528
+ }
525
1529
 
1530
+ function writeRetainedCatalogSync({
1531
+ config,
1532
+ goModels,
1533
+ providerModelOutcomes,
1534
+ comboOmissions,
1535
+ read,
1536
+ permit,
1537
+ owningCodexHome,
1538
+ modelEntitlements,
1539
+ }: RetainedCatalogSyncWrite): RetainedCatalogSyncResult {
1540
+ const { catalogPath, catalog, onDiskCatalog } = read;
1541
+ const catalogModelsForMerge = catalogModelsForMergeWithNativeRecovery(
1542
+ catalogPath,
1543
+ catalog,
1544
+ onDiskCatalog,
1545
+ );
526
1546
  const template = findNativeTemplate(catalog);
527
1547
 
528
- const comboOmissions: ComboCatalogOmission[] = [];
529
- const goModels = await gatherRoutedModels(config, { comboOmissions });
530
1548
  try {
531
1549
  // Once-only: preserve the PRISTINE pre-opencodex catalog as the native-priority baseline
532
1550
  // (later syncs would otherwise overwrite it with featured-modified priorities).
533
- ensureCatalogBackup(catalogPath, catalog);
1551
+ const pristine = pristineCatalogBytes(read);
1552
+ if (pristine !== null) {
1553
+ publishHashedCodexCatalogBackup(permit, owningCodexHome, {
1554
+ path: catalogBackupPathFor(catalogPath),
1555
+ content: pristine,
1556
+ });
1557
+ if (isDefaultCatalogPath(catalogPath)) {
1558
+ publishLegacyCodexCatalogBackup(permit, owningCodexHome, {
1559
+ path: legacyCatalogBackupPath(),
1560
+ content: pristine,
1561
+ });
1562
+ }
1563
+ }
534
1564
  } catch { /* backup best-effort */ }
535
1565
 
536
1566
  // Hide disabled models from Codex, then feature the chosen subagent models (native OR routed)
@@ -538,83 +1568,442 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{
538
1568
  const enabledGo = filterCatalogVisibleModels(goModels, config);
539
1569
  const featured = config.subagentModels ?? [];
540
1570
  const orderedGoModels = orderForSubagents(enabledGo, featured); // stable tie-break among equal priorities
1571
+ const modelPickerOrder = config.modelPickerOrder ?? [];
541
1572
  const multiAgentMode: MultiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default";
542
1573
  const exactComboSlugs = exactComboCatalogSlugs(config);
1574
+ const bareEligibleAccountIds = providerCodexAccountMode(
1575
+ OPENAI_CODEX_PROVIDER_ID,
1576
+ config.providers[OPENAI_CODEX_PROVIDER_ID],
1577
+ ) === "direct" ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined;
1578
+ const availableBareGatedNativeSlugs = availableAccountGatedNativeModels(
1579
+ modelEntitlements,
1580
+ bareEligibleAccountIds,
1581
+ );
1582
+ const availableAccountGatedNativeSlugs = availableAccountGatedNativeModels(modelEntitlements);
1583
+ const availableBareNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => (
1584
+ !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableBareGatedNativeSlugs.has(slug)
1585
+ ));
1586
+ const availableAccountNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => (
1587
+ !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableAccountGatedNativeSlugs.has(slug)
1588
+ ));
1589
+ const unavailableGatedNativeSlugs = new Set([...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(slug => (
1590
+ !availableBareGatedNativeSlugs.has(slug)
1591
+ )));
1592
+ const suppressedBareNativeSlugs = new Set([
1593
+ ...desktopAllowlistSuppressedNativeSlugs(config),
1594
+ ...unavailableGatedNativeSlugs,
1595
+ ]);
543
1596
  const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE);
544
- const goEntries = buildCatalogEntries(template ? JSON.parse(JSON.stringify(template)) : null, [], orderedGoModels, featured, websocketsEnabled(config), multiAgentMode, exactComboSlugs);
1597
+ const includeNativeOpenAi = shouldIncludeNativeOpenAi(config);
1598
+ const includeAccountBoundNativeOpenAi = shouldIncludeAccountBoundNativeOpenAi(config);
1599
+ // Both user levers. Passing only the cap here is what let a per-model window the dashboard
1600
+ // had accepted get written back at full width in the on-disk catalog.
1601
+ const openaiContextCap = nativeContextLimits(config);
1602
+ const accountSelectors = includeAccountBoundNativeOpenAi
1603
+ ? visibleCodexAccountSelectors(config)
1604
+ : [];
1605
+ const observedAccountNativeEntries = [
1606
+ ...(read.modelsCache?.models ?? []),
1607
+ ...(onDiskCatalog?.models ?? []).filter(entry =>
1608
+ trustedAccountBoundNativeCatalogSlug(entry) !== undefined),
1609
+ ];
1610
+ const accountTargets = new Map(codexAccountNamespaceEntries(config));
1611
+ const accountNativeSlugsBySelector = accountSelectors.length > 0
1612
+ ? new Map([...accountBoundNativeOpenAiSlugsBySelector(config, observedAccountNativeEntries)].map(([selector, slugs]) => {
1613
+ const target = accountTargets.get(selector);
1614
+ const accountId = target && isMainCodexAccountTarget(target) ? MAIN_CODEX_ACCOUNT_ID : target;
1615
+ const entitled = accountId ? modelEntitlements.modelsByAccount.get(accountId) : undefined;
1616
+ const confirmed = accountId ? modelEntitlements.confirmedAccountIds.has(accountId) : false;
1617
+ return [selector, slugs.filter(slug => (
1618
+ !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || (confirmed && entitled?.has(slug) === true)
1619
+ ))] as const;
1620
+ }))
1621
+ : new Map<string, readonly string[]>();
1622
+ const accountNativeSlugs = accountSelectors.length > 0
1623
+ ? [...new Set([...accountNativeSlugsBySelector.values()].flatMap(slugs => [...slugs]))]
1624
+ : [];
1625
+ // Unknown account-native ids have no safe bare/global identity. They are only projected through
1626
+ // the selector map above; the no-selector catalog remains the static native/API-key surface.
1627
+ const observedNativeSlugs: string[] = [];
1628
+ const wsEnabled = websocketsEnabled(config);
1629
+ const multiAgentV2Enabled = isMultiAgentV2Enabled();
1630
+ const goEntries = buildCatalogEntriesFromObservedState({
1631
+ template: template ? JSON.parse(JSON.stringify(template)) : null,
1632
+ gptSlugs: [],
1633
+ goModels: orderedGoModels,
1634
+ featured,
1635
+ modelPickerOrder,
1636
+ wsEnabled,
1637
+ multiAgentMode,
1638
+ exactComboSlugs,
1639
+ accountSelectors,
1640
+ suppressedBareNativeSlugs,
1641
+ disabledNativeAccountSlugs: new Set(),
1642
+ multiAgentV2Enabled,
1643
+ openaiContextCap,
1644
+ });
545
1645
  // Keep genuine native entries (gpt-*, codex-*) with their real per-model fields and append
546
1646
  // routed providers as namespaced slugs. Cursor and other adopted providers can expose model ids
547
1647
  // like `gpt-5.5`; those must not delete the native OpenAI/Codex base row.
1648
+ const baselineCatalog = readCatalogBackup(catalogPath);
548
1649
  const baseline = readNativeBaseline(catalogPath);
549
- const goIds = new Set(enabledGo.map(m => m.id));
550
1650
  const gatheredProviderNames = new Set(
551
1651
  Object.entries(config.providers ?? {})
552
1652
  .filter(([, prov]) => prov.disabled !== true)
553
1653
  .map(([name]) => name),
554
1654
  );
1655
+ const degradedProviderNames = new Set(
1656
+ providerModelOutcomes
1657
+ .filter(outcome => outcome.state === "degraded")
1658
+ .map(outcome => outcome.provider),
1659
+ );
1660
+ const selectedModelsByProvider = new Map<string, ReadonlySet<string>>(
1661
+ Object.entries(config.providers ?? {}).flatMap(([name, provider]) => (
1662
+ provider.disabled !== true
1663
+ && Array.isArray(provider.selectedModels)
1664
+ && provider.selectedModels.length > 0
1665
+ ? [[name, new Set(provider.selectedModels)] as const]
1666
+ : []
1667
+ )),
1668
+ );
555
1669
  // Central WS capability override on the FINAL on-disk catalog (the file Codex reads). Applies to
556
1670
  // native AND routed so the advertised flag matches the implemented endpoint (phase 120.4) and a
557
1671
  // native template can never leak supports_websockets while the flag is off.
558
- const wsEnabled = websocketsEnabled(config);
559
- const enabledProviders = Object.entries(config.providers ?? {})
560
- .filter(([, prov]) => prov.disabled !== true);
561
- const hasCanonicalOpenai = enabledProviders.some(([name, prov]) =>
562
- name === "openai" && isCanonicalOpenAiForwardProvider(prov),
563
- );
564
1672
  // #636: when the user only configured non-OpenAI providers (e.g. kimi), do not advertise
565
1673
  // bare gpt-* rows that hard-404 via NoEnabledOpenAiProviderError. Keep natives when no
566
1674
  // providers are configured yet (fresh install / catalog bootstrap tests).
567
- const includeNativeOpenAi = enabledProviders.length === 0 || hasCanonicalOpenai;
568
- catalog.models = mergeCatalogEntriesForSync(catalogModelsForMerge, goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider, includeNativeOpenAi);
1675
+ const accountBoundEntries = includeAccountBoundNativeOpenAi && accountSelectors.length > 0
1676
+ ? buildCatalogEntriesFromObservedState({
1677
+ template: template ? JSON.parse(JSON.stringify(template)) : null,
1678
+ gptSlugs: availableAccountNativeSlugs,
1679
+ goModels: [],
1680
+ featured,
1681
+ wsEnabled,
1682
+ multiAgentMode,
1683
+ exactComboSlugs,
1684
+ accountSelectors,
1685
+ suppressedBareNativeSlugs,
1686
+ disabledNativeAccountSlugs: new Set([...disabledNativeSlugs(config)].filter(slug => suppressedBareNativeSlugs.has(slug))),
1687
+ multiAgentV2Enabled,
1688
+ keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true,
1689
+ openaiContextCap,
1690
+ accountNativeSlugs,
1691
+ accountNativeSlugsBySelector,
1692
+ }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined)
1693
+ : [];
1694
+ catalog.models = mergeCatalogEntriesFromObservedState({
1695
+ catalogModels: catalogModelsForMerge,
1696
+ baselineCatalogModels: baselineCatalog?.models ?? [],
1697
+ routedEntries: goEntries,
1698
+ baseline,
1699
+ featured,
1700
+ wsEnabled,
1701
+ template,
1702
+ disabledModels: new Set(config.disabledModels ?? []),
1703
+ selectedModelsByProvider,
1704
+ gatheredProviderNames,
1705
+ degradedProviderNames,
1706
+ legacyCustomModelSlugs: legacyCustomModelCatalogSlugs(config),
1707
+ multiAgentMode,
1708
+ multiAgentV2Enabled,
1709
+ keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true,
1710
+ exactComboSlugs,
1711
+ hasPhysicalComboProvider,
1712
+ includeNativeOpenAi,
1713
+ accountBoundEntries,
1714
+ suppressedBareNativeSlugs,
1715
+ openaiContextCap,
1716
+ policy: {
1717
+ ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY,
1718
+ nativeBackfillSlugs: [...availableBareNativeSlugs, ...observedNativeSlugs],
1719
+ warningPolicy: "emit",
1720
+ },
1721
+ });
569
1722
  clampCatalogModelsToCodexSupport(catalog.models);
1723
+ finalizeAutoReviewModelOverride(catalog.models, catalogModelsForMerge);
1724
+
1725
+ const added = goEntries.length + accountBoundEntries.length;
1726
+ const content = `${JSON.stringify(catalog, null, 2)}\n`;
1727
+ // A byte-identical rewrite is not a catalog change, but every mtime-keyed reader
1728
+ // has to treat it as one. The app-server staleness classifier (#857) is the one
1729
+ // that matters: it compares this file's mtime against each running Codex's start
1730
+ // time, so an ordinary `ocx start` — or any dashboard action that re-syncs an
1731
+ // unchanged model set — marked every already-running Codex as holding an outdated
1732
+ // in-memory catalog. Since #1407 that verdict silences opencodex's own model
1733
+ // guidance entirely (no preferred model, no roster) for the rest of that Codex's
1734
+ // lifetime, so a configured injectionModel stops reaching the session even though
1735
+ // nothing about the catalog changed. Skipping the no-op write keeps both the mtime
1736
+ // and `catalogWritten` honest; `added` still reports the routed rows the catalog
1737
+ // carries, because they are on disk either way.
1738
+ const onDiskBytes = currentCatalogFileContent(catalogPath);
1739
+ if (onDiskBytes !== null && onDiskBytes.equals(Buffer.from(content, "utf8"))) {
1740
+ return { added, path: catalogPath, catalogWritten: false, comboOmissions };
1741
+ }
570
1742
 
571
- atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n");
572
- return { added: goEntries.length, path: catalogPath, catalogWritten: true, comboOmissions };
1743
+ replaceActiveCodexCatalog(permit, owningCodexHome, {
1744
+ path: catalogPath,
1745
+ content,
1746
+ });
1747
+ return {
1748
+ added,
1749
+ path: catalogPath,
1750
+ catalogWritten: true,
1751
+ comboOmissions,
1752
+ };
573
1753
  }
574
1754
 
575
- export function restoreCodexCatalog(): { removed: number; kept: number; path: string } {
576
- const catalogPath = readCodexCatalogPath();
1755
+ function visibleAccountReplacementNatives(
1756
+ models: readonly RawEntry[],
1757
+ disabledModels: ReadonlySet<string> | null,
1758
+ ): Map<string, boolean> {
1759
+ const replacements = new Map<string, boolean>();
1760
+ for (const entry of models) {
1761
+ const nativeSlug = trustedAccountBoundNativeCatalogSlug(entry);
1762
+ if (nativeSlug === undefined || !SUPPORTED_NATIVE_OPENAI_SLUGS.has(nativeSlug)) continue;
1763
+ const exactSlug = typeof entry.slug === "string" ? entry.slug : "";
1764
+ const visible = entry.visibility === "list"
1765
+ || (disabledModels !== null
1766
+ && (disabledModels.has(nativeSlug) || disabledModels.has(exactSlug)));
1767
+ replacements.set(nativeSlug, (replacements.get(nativeSlug) ?? true) && visible);
1768
+ }
1769
+ return replacements;
1770
+ }
1771
+
1772
+ function restoreAccountHiddenBareNatives(
1773
+ entries: readonly RawEntry[],
1774
+ replacementVisibility: ReadonlyMap<string, boolean>,
1775
+ disabledModels: ReadonlySet<string> | null,
1776
+ ): RawEntry[] {
1777
+ return entries.map(entry => {
1778
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
1779
+ if (
1780
+ entry.visibility !== "hide"
1781
+ || !SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug)
1782
+ || replacementVisibility.get(slug) !== true
1783
+ || disabledModels === null
1784
+ || disabledModels.has(slug)
1785
+ ) {
1786
+ return entry;
1787
+ }
1788
+ return { ...entry, visibility: "list" };
1789
+ });
1790
+ }
1791
+
1792
+ function currentDisabledModelsForRestore(): Set<string> | null {
1793
+ try {
1794
+ const diagnostics = readConfigDiagnostics();
1795
+ if (diagnostics.source === "fallback" || diagnostics.error !== null) return null;
1796
+ return new Set(diagnostics.config.disabledModels ?? []);
1797
+ } catch {
1798
+ // An unreadable config cannot safely authorize a visibility change during restore.
1799
+ return null;
1800
+ }
1801
+ }
1802
+
1803
+ export async function syncCatalogModels(
1804
+ config: OcxConfig,
1805
+ options?: CodexCatalogSyncOptions,
1806
+ ): Promise<RetainedCatalogSyncResult> {
1807
+ const owningCodexHome = getCodexHome();
1808
+ const preflightRead = readRetainedCatalogSync(config);
1809
+ if (preflightRead === null) {
1810
+ return {
1811
+ added: 0,
1812
+ path: readCodexCatalogPath(),
1813
+ catalogWritten: false,
1814
+ comboOmissions: [],
1815
+ };
1816
+ }
1817
+
1818
+ const comboOmissions: ComboCatalogOmission[] = [];
1819
+ const providerModelOutcomes: CatalogGatherProviderModelOutcome[] = [];
1820
+ // Settle the bundled template, then baseline, and only then await. Reading it
1821
+ // here makes the memo ours before anyone else can move it, so a bundled swap
1822
+ // during the await is an outside change rather than our own side effect.
1823
+ //
1824
+ // The persisted runtime selection is covered by the filesystem evidence above
1825
+ // rather than by a process epoch; see `retainedCatalogProcessEvidence` for why
1826
+ // the in-memory runtime memo cannot be baselined honestly from this path.
1827
+ loadBundledCodexCatalog();
1828
+ const prepared: RetainedCatalogSyncRead = {
1829
+ ...preflightRead,
1830
+ evidence: retainedCatalogSyncEvidence(config, preflightRead.catalogPath, preflightRead.catalog),
1831
+ processEvidence: retainedCatalogProcessEvidence(),
1832
+ };
1833
+ const [goModels, modelEntitlements] = await Promise.all([
1834
+ gatherRoutedModels(config, {
1835
+ comboOmissions,
1836
+ providerModelOutcomes,
1837
+ }),
1838
+ resolveCodexModelEntitlements(config),
1839
+ ]);
1840
+ const committed = withCatalogWriteSerialization(owningCodexHome, permit => {
1841
+ // Desired state can flip OFF during the provider await above. The catalog
1842
+ // evidence revalidation below cannot see that — intent lives in our config,
1843
+ // not in the catalog files — so the policy is re-read here, under K, right
1844
+ // before the only write. A lost race becomes the discriminated skip instead
1845
+ // of a routed catalog/cache surviving a completed disable. An explicit
1846
+ // catalog-only sync opts out of that gate: the user asked for a refresh even
1847
+ // when injection is OFF, and the toggle only protects config/history writes.
1848
+ if (!shouldSyncCodexOnStart(loadConfig()) && options?.allowWhenDesiredDisabled !== true) {
1849
+ return {
1850
+ added: 0,
1851
+ path: prepared.catalogPath,
1852
+ catalogWritten: false,
1853
+ comboOmissions,
1854
+ skippedReason: "desired_disabled" as const,
1855
+ };
1856
+ }
1857
+ const current = revalidateRetainedCatalogSync(config, prepared);
1858
+ if (current === null) return null;
1859
+ if (!isCodexModelEntitlementSnapshotCurrent(modelEntitlements)) return null;
1860
+ return writeRetainedCatalogSync({
1861
+ config,
1862
+ goModels,
1863
+ providerModelOutcomes,
1864
+ comboOmissions,
1865
+ read: current,
1866
+ permit,
1867
+ owningCodexHome,
1868
+ modelEntitlements,
1869
+ });
1870
+ });
1871
+ if (committed.kind === "completed" && committed.value !== null) return committed.value;
1872
+ return {
1873
+ added: 0,
1874
+ path: prepared.catalogPath,
1875
+ catalogWritten: false,
1876
+ comboOmissions,
1877
+ };
1878
+ }
1879
+
1880
+ export function restoreCodexCatalogWithPermit(
1881
+ permit: CatalogWritePermit,
1882
+ owningCodexHome: string,
1883
+ /**
1884
+ * The catalog this injection actually wrote, when it is known (#1798).
1885
+ *
1886
+ * Re-resolving from the CURRENT config is wrong after a Codex app rewrite that dropped
1887
+ * `model_catalog_json`: that sends restore to the default catalog while the routed file we
1888
+ * really wrote is left untouched. The recorded path is the file whose routing is ours.
1889
+ */
1890
+ injectedCatalogPath?: string | null,
1891
+ ): { removed: number; kept: number; path: string } {
1892
+ const catalogPath = injectedCatalogPath ?? readCodexCatalogPath();
577
1893
  const catalog = readCatalog(catalogPath);
578
1894
  if (!catalog || !Array.isArray(catalog.models)) return { removed: 0, kept: 0, path: catalogPath };
1895
+ const disabledModels = currentDisabledModelsForRestore();
1896
+ const replacementVisibility = visibleAccountReplacementNatives(catalog.models, disabledModels);
579
1897
  const backup = readCatalogBackup(catalogPath);
580
1898
  if (backup && Array.isArray(backup.models)) {
581
1899
  const removed = (catalog.models ?? []).filter(m => typeof m.slug === "string" && m.slug.includes("/")).length;
582
1900
  const backupSlugs = new Set(backup.models.flatMap(m => typeof m.slug === "string" ? [m.slug] : []));
583
- const userNativeAdditions = (catalog.models ?? []).filter(m =>
584
- typeof m.slug === "string" && !m.slug.includes("/") && !backupSlugs.has(m.slug)
1901
+ const userNativeAdditions = restoreAccountHiddenBareNatives(
1902
+ (catalog.models ?? []).filter(m =>
1903
+ typeof m.slug === "string" && !m.slug.includes("/") && !backupSlugs.has(m.slug)
1904
+ ),
1905
+ replacementVisibility,
1906
+ disabledModels,
585
1907
  );
586
1908
  const restored = {
587
1909
  ...backup,
588
1910
  models: [...backup.models, ...userNativeAdditions],
589
1911
  };
590
- atomicWriteFile(catalogPath, JSON.stringify(restored, null, 2) + "\n");
1912
+ replaceActiveCodexCatalog(permit, owningCodexHome, {
1913
+ path: catalogPath,
1914
+ content: `${JSON.stringify(restored, null, 2)}\n`,
1915
+ });
591
1916
  return { removed, kept: restored.models.length, path: catalogPath };
592
1917
  }
593
1918
  const before = catalog.models.length;
594
- const native = catalog.models.filter(m => !(typeof m.slug === "string" && m.slug.includes("/")));
1919
+ const native = restoreAccountHiddenBareNatives(
1920
+ catalog.models.filter(m => !(typeof m.slug === "string" && m.slug.includes("/"))),
1921
+ replacementVisibility,
1922
+ disabledModels,
1923
+ );
595
1924
  const removed = before - native.length;
596
1925
  if (removed > 0) {
597
1926
  catalog.models = native;
598
- atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n");
1927
+ replaceActiveCodexCatalog(permit, owningCodexHome, {
1928
+ path: catalogPath,
1929
+ content: `${JSON.stringify(catalog, null, 2)}\n`,
1930
+ });
599
1931
  }
600
1932
  return { removed, kept: native.length, path: catalogPath };
601
1933
  }
602
1934
 
1935
+ export function restoreCodexCatalog(): { removed: number; kept: number; path: string } {
1936
+ const owningCodexHome = getCodexHome();
1937
+ const outcome = withCatalogWriteSerialization(
1938
+ owningCodexHome,
1939
+ permit => restoreCodexCatalogWithPermit(permit, owningCodexHome),
1940
+ );
1941
+ return outcome.kind === "completed"
1942
+ ? outcome.value
1943
+ : { removed: 0, kept: 0, path: readCodexCatalogPath() };
1944
+ }
1945
+
603
1946
  /** Force Codex's models_cache stale from the on-disk catalog. Returns whether a cache write occurred. */
604
- export function invalidateCodexModelsCache(): boolean {
1947
+ export function invalidateCodexModelsCacheWithPermit(
1948
+ permit: CatalogWritePermit,
1949
+ owningCodexHome: string,
1950
+ options?: CodexCatalogSyncOptions,
1951
+ ): boolean {
605
1952
  try {
606
- const catalogPath = readCodexCatalogPath();
1953
+ // This permit is a REACQUISITION: refreshCodexModelCatalog's commit released
1954
+ // K before this rewrite runs, so the commit-path desired-state check cannot
1955
+ // cover it. A disable landing in that gap must not be overwritten by a
1956
+ // routed cache write — re-read intent under this permit, same as the commit.
1957
+ // The catalog-only sync override applies here too so an explicit refresh
1958
+ // keeps the cache consistent with the catalog it just wrote.
1959
+ if (!shouldSyncCodexOnStart(loadConfig()) && options?.allowWhenDesiredDisabled !== true) return false;
1960
+ const catalogPath = readCodexCatalogPathForHome(owningCodexHome);
607
1961
  if (!existsSync(catalogPath)) return false;
608
1962
  const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
609
1963
  const models = catalog.models ?? catalog;
1964
+ const cachePath = join(owningCodexHome, "models_cache.json");
1965
+ const currentCache = readCatalog(cachePath);
1966
+ const existingSlugs = new Set(models.flatMap((entry: RawEntry) =>
1967
+ typeof entry.slug === "string" ? [entry.slug] : []));
1968
+ const currentConfig = loadConfig();
1969
+ const mainSelectors = visibleCodexAccountSelectors(currentConfig).filter(selector => {
1970
+ const target = new Map(codexAccountNamespaceEntries(currentConfig)).get(selector);
1971
+ return isMainCodexAccountTarget(target ?? "");
1972
+ });
1973
+ const observedAccountModels = observedAccountBoundNativeEntries(currentCache?.models ?? [])
1974
+ .filter(entry => {
1975
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
1976
+ return !existingSlugs.has(slug);
1977
+ })
1978
+ .map(entry => ({
1979
+ ...entry,
1980
+ // Keep the observation in Codex's cache without advertising a new bare picker row. The
1981
+ // next OpenCodex catalog sync consumes this marker and creates only selector-qualified
1982
+ // rows for the currently configured public account selectors.
1983
+ visibility: "hide",
1984
+ opencodex_account_observed_native: true,
1985
+ opencodex_account_observed_selectors: mainSelectors,
1986
+ }));
610
1987
  const wrapper = {
611
1988
  fetched_at: "2000-01-01T00:00:00Z",
612
1989
  client_version: "0.0.0",
613
- models,
1990
+ models: [...models, ...observedAccountModels],
614
1991
  };
615
- atomicWriteFile(activeCodexModelsCachePath(), JSON.stringify(wrapper, null, 2) + "\n");
1992
+ replaceCodexModelsCache(permit, owningCodexHome, {
1993
+ path: cachePath,
1994
+ content: `${JSON.stringify(wrapper, null, 2)}\n`,
1995
+ });
616
1996
  return true;
617
1997
  } catch {
618
1998
  return false;
619
1999
  }
620
2000
  }
2001
+
2002
+ export function invalidateCodexModelsCache(options?: CodexCatalogSyncOptions): boolean {
2003
+ const owningCodexHome = getCodexHome();
2004
+ const outcome = withCatalogWriteSerialization(
2005
+ owningCodexHome,
2006
+ permit => invalidateCodexModelsCacheWithPermit(permit, owningCodexHome, options),
2007
+ );
2008
+ return outcome.kind === "completed" && outcome.value;
2009
+ }