@iislee/opencodex 2.11.0 → 2.31.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 (675) hide show
  1. package/AGENTS_INSTALL.md +109 -0
  2. package/README.md +114 -19
  3. package/bin/ocx.mjs +164 -36
  4. package/bin/package-main.mjs +1 -1
  5. package/gui/dist/assets/index-BF38heuV.js +104 -0
  6. package/gui/dist/assets/index-DMiI18Kv.css +1 -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 +19 -10
  42. package/src/adapters/anthropic-output-schema.ts +137 -0
  43. package/src/adapters/anthropic.ts +376 -52
  44. package/src/adapters/base.ts +54 -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 +601 -0
  48. package/src/adapters/cursor/checkpoint-store.ts +303 -0
  49. package/src/adapters/cursor/cursor-errors.ts +129 -6
  50. package/src/adapters/cursor/discovery.ts +61 -4
  51. package/src/adapters/cursor/effort-map.ts +27 -3
  52. package/src/adapters/cursor/framing.ts +39 -0
  53. package/src/adapters/cursor/h2-pool.ts +123 -0
  54. package/src/adapters/cursor/http1-bidi.ts +361 -0
  55. package/src/adapters/cursor/images.ts +704 -0
  56. package/src/adapters/cursor/live-models.ts +174 -58
  57. package/src/adapters/cursor/live-transport.ts +609 -170
  58. package/src/adapters/cursor/native-exec-common.ts +23 -2
  59. package/src/adapters/cursor/native-exec-fs.ts +10 -7
  60. package/src/adapters/cursor/native-exec-network.ts +1 -1
  61. package/src/adapters/cursor/native-exec-shell.ts +0 -1
  62. package/src/adapters/cursor/native-exec.ts +101 -14
  63. package/src/adapters/cursor/protobuf-events.ts +829 -11
  64. package/src/adapters/cursor/protobuf-request.ts +383 -65
  65. package/src/adapters/cursor/request-builder.ts +237 -31
  66. package/src/adapters/cursor/tool-definitions.ts +242 -12
  67. package/src/adapters/cursor/tool-result-normalize.ts +92 -0
  68. package/src/adapters/cursor/transport.ts +22 -0
  69. package/src/adapters/cursor/types.ts +28 -1
  70. package/src/adapters/cursor.ts +133 -7
  71. package/src/adapters/google-antigravity-replay.ts +558 -34
  72. package/src/adapters/google-antigravity-wire.ts +43 -10
  73. package/src/adapters/google-http.ts +38 -10
  74. package/src/adapters/google-tool-schema.ts +80 -15
  75. package/src/adapters/google-truncation.ts +11 -0
  76. package/src/adapters/google.ts +618 -74
  77. package/src/adapters/identity.ts +39 -6
  78. package/src/adapters/kiro-errors.ts +11 -0
  79. package/src/adapters/kiro-events.ts +19 -1
  80. package/src/adapters/kiro-thinking.ts +18 -2
  81. package/src/adapters/kiro-tools.ts +10 -1
  82. package/src/adapters/kiro.ts +92 -54
  83. package/src/adapters/mimo-free.ts +17 -0
  84. package/src/adapters/openai-chat-url.ts +11 -0
  85. package/src/adapters/openai-chat.ts +1311 -336
  86. package/src/adapters/openai-responses-url.ts +16 -0
  87. package/src/adapters/openai-responses.ts +830 -56
  88. package/src/adapters/registry.ts +175 -0
  89. package/src/adapters/responses-tool-schema.ts +67 -0
  90. package/src/adapters/tool-call-id.ts +119 -0
  91. package/src/adapters/tool-catalog-nudge.ts +104 -21
  92. package/src/adapters/xai-web-search.ts +185 -0
  93. package/src/bridge.ts +428 -61
  94. package/src/chat/inbound.ts +43 -19
  95. package/src/chat/outbound.ts +82 -26
  96. package/src/claude/agents-inject.ts +32 -9
  97. package/src/claude/context-windows.ts +21 -5
  98. package/src/claude/desktop-3p.ts +243 -9
  99. package/src/claude/gateway-cache.ts +41 -4
  100. package/src/claude/inbound.ts +72 -3
  101. package/src/claude/model-info.ts +38 -15
  102. package/src/claude/outbound.ts +70 -16
  103. package/src/cli/account-api.ts +35 -1
  104. package/src/cli/account-auth.ts +33 -6
  105. package/src/cli/account-catalog-refresh.ts +14 -0
  106. package/src/cli/account-extended.ts +389 -2
  107. package/src/cli/account-main.ts +317 -0
  108. package/src/cli/account.ts +30 -6
  109. package/src/cli/agent.ts +45 -1
  110. package/src/cli/claude-agent-startup-sync.ts +73 -0
  111. package/src/cli/claude-desktop.ts +29 -4
  112. package/src/cli/claude.ts +103 -34
  113. package/src/cli/codex-log-guard-doctor.ts +103 -0
  114. package/src/cli/codex-shim-autorestore.ts +2 -0
  115. package/src/cli/codex-shim-readiness.ts +76 -0
  116. package/src/cli/combo.ts +8 -0
  117. package/src/cli/config-command.ts +74 -10
  118. package/src/cli/dispatch.ts +593 -0
  119. package/src/cli/doctor.ts +315 -43
  120. package/src/cli/ensure-desired-integrations.ts +152 -0
  121. package/src/cli/export-command.ts +46 -20
  122. package/src/cli/help.ts +20 -271
  123. package/src/cli/index.ts +378 -519
  124. package/src/cli/init.ts +4 -17
  125. package/src/cli/integrations.ts +120 -2
  126. package/src/cli/lab.ts +607 -0
  127. package/src/cli/launcher-context.ts +77 -0
  128. package/src/cli/minimax.ts +497 -0
  129. package/src/cli/models-runtime.ts +35 -2
  130. package/src/cli/models.ts +100 -14
  131. package/src/cli/observe.ts +92 -3
  132. package/src/cli/opencode.ts +4 -2
  133. package/src/cli/provider-runtime.ts +18 -1
  134. package/src/cli/provider.ts +24 -3
  135. package/src/cli/ready.ts +301 -0
  136. package/src/cli/registry.ts +437 -0
  137. package/src/cli/root.ts +86 -0
  138. package/src/cli/route-policy.ts +92 -0
  139. package/src/cli/runtime-api.ts +6 -3
  140. package/src/cli/star-prompt.ts +71 -15
  141. package/src/cli/status.ts +10 -3
  142. package/src/cli/system-restart-client.ts +146 -0
  143. package/src/cli/tray-proxy.ts +153 -6
  144. package/src/cli/v2.ts +105 -10
  145. package/src/cli.ts +1 -1
  146. package/src/clients/config-export.ts +1358 -21
  147. package/src/codex/account-label.ts +14 -1
  148. package/src/codex/account-lifecycle.ts +130 -13
  149. package/src/codex/account-namespaces.ts +49 -3
  150. package/src/codex/account-priority.ts +83 -0
  151. package/src/codex/account-store.ts +29 -2
  152. package/src/codex/account-usability.ts +25 -2
  153. package/src/codex/admission.ts +256 -0
  154. package/src/codex/affinity-debug.ts +162 -0
  155. package/src/codex/app-server-processes.ts +493 -106
  156. package/src/codex/app-server-restart-service.ts +232 -0
  157. package/src/codex/auth-api.ts +849 -242
  158. package/src/codex/auth-collision.ts +5 -3
  159. package/src/codex/auth-context.ts +345 -32
  160. package/src/codex/autostart-health.ts +8 -1
  161. package/src/codex/catalog/account-models.ts +67 -0
  162. package/src/codex/catalog/aggregation.ts +68 -10
  163. package/src/codex/catalog/bundled.ts +331 -33
  164. package/src/codex/catalog/effort.ts +121 -30
  165. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  166. package/src/codex/catalog/kinds.ts +2 -0
  167. package/src/codex/catalog/metadata.ts +529 -45
  168. package/src/codex/catalog/native-models.ts +72 -0
  169. package/src/codex/catalog/parsing.ts +224 -30
  170. package/src/codex/catalog/provider-fetch.ts +1460 -134
  171. package/src/codex/catalog/sync.ts +1449 -186
  172. package/src/codex/catalog-admission.ts +199 -0
  173. package/src/codex/catalog-refresh-status.ts +105 -0
  174. package/src/codex/catalog-write-serialization.ts +242 -0
  175. package/src/codex/catalog.ts +6 -3
  176. package/src/codex/codex-write-lock.ts +384 -0
  177. package/src/codex/convergence-types.ts +614 -0
  178. package/src/codex/convergence.ts +651 -0
  179. package/src/codex/coordinator-doctor.ts +332 -0
  180. package/src/codex/custom-model-catalog-migration.ts +176 -0
  181. package/src/codex/desired-state.ts +230 -0
  182. package/src/codex/features.ts +636 -39
  183. package/src/codex/generation.ts +202 -0
  184. package/src/codex/history-job.ts +407 -0
  185. package/src/codex/history-lock.ts +242 -0
  186. package/src/codex/history-migration-guardian.ts +26 -20
  187. package/src/codex/history-provider.ts +231 -28
  188. package/src/codex/history-transition.ts +105 -0
  189. package/src/codex/history-worker.ts +220 -0
  190. package/src/codex/inject-coordination.ts +290 -0
  191. package/src/codex/inject.ts +1073 -152
  192. package/src/codex/injected-marker.ts +37 -3
  193. package/src/codex/integration-record.ts +266 -0
  194. package/src/codex/internal/catalog-writer.ts +203 -0
  195. package/src/codex/internal/history-writer.ts +80 -0
  196. package/src/codex/journal.ts +66 -4
  197. package/src/codex/log-guard/inspect.ts +506 -0
  198. package/src/codex/log-guard/lock.ts +150 -0
  199. package/src/codex/log-guard/maintenance.ts +403 -0
  200. package/src/codex/log-guard/path-safety.ts +88 -0
  201. package/src/codex/log-guard/policy.ts +44 -0
  202. package/src/codex/log-guard/processes.ts +205 -0
  203. package/src/codex/log-guard/protection.ts +489 -0
  204. package/src/codex/log-guard/sqlite-errors.ts +9 -0
  205. package/src/codex/main-account-cache.ts +24 -0
  206. package/src/codex/main-account.ts +29 -1
  207. package/src/codex/management-convergence.ts +167 -0
  208. package/src/codex/model-cache.ts +56 -10
  209. package/src/codex/model-entitlements.ts +353 -0
  210. package/src/codex/native-main-admission.ts +47 -0
  211. package/src/codex/native-main-auth-temp.ts +187 -0
  212. package/src/codex/native-main-claim.ts +178 -0
  213. package/src/codex/native-main-lock-file.ts +162 -0
  214. package/src/codex/native-main-owner.ts +329 -0
  215. package/src/codex/native-profile-api.ts +247 -0
  216. package/src/codex/native-profile-manager.ts +1531 -0
  217. package/src/codex/native-profile-processes.ts +121 -0
  218. package/src/codex/native-profile-recovery.ts +99 -0
  219. package/src/codex/native-profile-stage-store.ts +387 -0
  220. package/src/codex/native-profile-startup.ts +492 -0
  221. package/src/codex/native-profile-store.ts +855 -0
  222. package/src/codex/native-profile-types.ts +120 -0
  223. package/src/codex/native-residue.ts +682 -0
  224. package/src/codex/paths.ts +80 -1
  225. package/src/codex/plan-from-token.ts +140 -0
  226. package/src/codex/plan.ts +40 -0
  227. package/src/codex/plugins-doctor.ts +1 -1
  228. package/src/codex/pool-rotation.ts +74 -4
  229. package/src/codex/project-config-warnings.ts +20 -6
  230. package/src/codex/prompt-journal.ts +352 -0
  231. package/src/codex/prompt-layers.ts +967 -0
  232. package/src/codex/prompt-lock.ts +143 -0
  233. package/src/codex/quota-rejection.ts +298 -0
  234. package/src/codex/quota.ts +175 -13
  235. package/src/codex/refresh.ts +11 -2
  236. package/src/codex/reset-credit-recovery.ts +1044 -0
  237. package/src/codex/routing.ts +505 -94
  238. package/src/codex/runtime.ts +159 -38
  239. package/src/codex/shim.ts +1009 -28
  240. package/src/codex/subagent-model-fallback.ts +350 -35
  241. package/src/codex/sync.ts +191 -2
  242. package/src/codex/transition-state.ts +612 -0
  243. package/src/codex/upstream-host-health.ts +368 -0
  244. package/src/codex/user-identity.ts +557 -0
  245. package/src/codex/warmup.ts +187 -81
  246. package/src/codex/write-coordination.ts +114 -0
  247. package/src/combos/failover.ts +20 -0
  248. package/src/combos/index.ts +4 -0
  249. package/src/combos/request.ts +32 -0
  250. package/src/combos/types.ts +81 -9
  251. package/src/config/provider-name.ts +24 -0
  252. package/src/config.ts +1762 -140
  253. package/src/generated/compatibility-version.json +3164 -0
  254. package/src/generated/{jawcode-model-metadata.ts → model-metadata.ts} +19 -17
  255. package/src/grok/inject.ts +16 -5
  256. package/src/grok/inspect.ts +45 -0
  257. package/src/grok/sync.ts +2 -2
  258. package/src/images/loop.ts +152 -29
  259. package/src/images/plan.ts +23 -13
  260. package/src/integrations/config-io.ts +269 -0
  261. package/src/integrations/journal.ts +315 -0
  262. package/src/integrations/merge.ts +135 -0
  263. package/src/integrations/mutation-flight.ts +71 -0
  264. package/src/integrations/native/ownership-preflight.ts +202 -0
  265. package/src/integrations/omp-yaml-source.ts +358 -0
  266. package/src/integrations/owned-refresh.ts +74 -0
  267. package/src/integrations/ownership.ts +111 -0
  268. package/src/integrations/registry.ts +159 -0
  269. package/src/integrations/serialize.ts +314 -0
  270. package/src/integrations/state.ts +361 -0
  271. package/src/integrations/store.ts +103 -0
  272. package/src/integrations/writer-lock.ts +98 -0
  273. package/src/integrations/writer.ts +691 -0
  274. package/src/lab/artifacts/sanitize.ts +586 -0
  275. package/src/lab/artifacts/secure-fs.ts +475 -0
  276. package/src/lab/artifacts/store.ts +310 -0
  277. package/src/lab/automation/budgets.ts +78 -0
  278. package/src/lab/automation/config-persistence.ts +256 -0
  279. package/src/lab/automation/constants.ts +39 -0
  280. package/src/lab/automation/cooldown.ts +103 -0
  281. package/src/lab/automation/dispatch.ts +211 -0
  282. package/src/lab/automation/index.ts +13 -0
  283. package/src/lab/automation/orchestrator.ts +499 -0
  284. package/src/lab/automation/persistence.ts +512 -0
  285. package/src/lab/automation/planner.ts +371 -0
  286. package/src/lab/automation/policy.ts +136 -0
  287. package/src/lab/automation/queue.ts +191 -0
  288. package/src/lab/automation/recovery.ts +24 -0
  289. package/src/lab/automation/route-context.ts +21 -0
  290. package/src/lab/automation/run-key.ts +44 -0
  291. package/src/lab/automation/runs-query.ts +34 -0
  292. package/src/lab/automation/types.ts +160 -0
  293. package/src/lab/conformance/assertion.ts +325 -0
  294. package/src/lab/conformance/digest.ts +22 -0
  295. package/src/lab/conformance/executor.ts +741 -0
  296. package/src/lab/conformance/fixture-provider.ts +27 -0
  297. package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
  298. package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
  299. package/src/lab/conformance/harness-budget.ts +47 -0
  300. package/src/lab/conformance/index.ts +5 -0
  301. package/src/lab/conformance/jcs.ts +64 -0
  302. package/src/lab/conformance/json-pointer.ts +39 -0
  303. package/src/lab/conformance/manifest.ts +180 -0
  304. package/src/lab/conformance/mcp-stub.ts +179 -0
  305. package/src/lab/conformance/negative-controls.ts +164 -0
  306. package/src/lab/conformance/observation.ts +355 -0
  307. package/src/lab/conformance/runner.ts +68 -0
  308. package/src/lab/conformance/sse-normalize.ts +59 -0
  309. package/src/lab/conformance/suite-manifest.ts +78 -0
  310. package/src/lab/conformance/types.ts +214 -0
  311. package/src/lab/constants.ts +126 -0
  312. package/src/lab/digest.ts +64 -0
  313. package/src/lab/events/errors.ts +9 -0
  314. package/src/lab/events/limits.ts +117 -0
  315. package/src/lab/events/types.ts +229 -0
  316. package/src/lab/events/validate.ts +781 -0
  317. package/src/lab/fabric/constants.ts +40 -0
  318. package/src/lab/fabric/executor.ts +492 -0
  319. package/src/lab/fabric/index.ts +80 -0
  320. package/src/lab/fabric/manifest.ts +222 -0
  321. package/src/lab/fabric/observe.ts +489 -0
  322. package/src/lab/fabric/patch.ts +79 -0
  323. package/src/lab/fabric/producer-child.ts +139 -0
  324. package/src/lab/fabric/producer-isolate.ts +276 -0
  325. package/src/lab/fabric/producer-protocol.ts +61 -0
  326. package/src/lab/fabric/scratch.ts +439 -0
  327. package/src/lab/fabric/subject.ts +106 -0
  328. package/src/lab/fabric/types.ts +134 -0
  329. package/src/lab/fabric/verifier.ts +98 -0
  330. package/src/lab/index.ts +54 -0
  331. package/src/lab/ledger/artifact-refs.ts +127 -0
  332. package/src/lab/ledger/invalidation.ts +136 -0
  333. package/src/lab/ledger/purge.ts +310 -0
  334. package/src/lab/ledger/store.ts +532 -0
  335. package/src/lab/live/credential-lease.ts +53 -0
  336. package/src/lab/live/destination.ts +155 -0
  337. package/src/lab/live/executor.ts +336 -0
  338. package/src/lab/live/inert-tools.ts +56 -0
  339. package/src/lab/live/manifest.ts +85 -0
  340. package/src/lab/live/mcp-loopback.ts +57 -0
  341. package/src/lab/live/runner.ts +19 -0
  342. package/src/lab/live/sandbox.ts +61 -0
  343. package/src/lab/live/suite-manifest.ts +41 -0
  344. package/src/lab/live/transport.ts +118 -0
  345. package/src/lab/live/types.ts +197 -0
  346. package/src/lab/observe/from-conformance.ts +301 -0
  347. package/src/lab/observe/from-live.ts +117 -0
  348. package/src/lab/paths.ts +153 -0
  349. package/src/lab/projection/rebuild.ts +495 -0
  350. package/src/lab/projection/schema.ts +135 -0
  351. package/src/lab/projection/verdicts.ts +474 -0
  352. package/src/lab/projection/verification.ts +412 -0
  353. package/src/lab/public/bundle.ts +217 -0
  354. package/src/lab/public/community-authority.ts +175 -0
  355. package/src/lab/public/community-files.ts +29 -0
  356. package/src/lab/public/community.ts +479 -0
  357. package/src/lab/public/file-safety.ts +155 -0
  358. package/src/lab/public/ids.ts +26 -0
  359. package/src/lab/public/index.ts +16 -0
  360. package/src/lab/public/mutation-lock.ts +424 -0
  361. package/src/lab/public/operator.ts +353 -0
  362. package/src/lab/public/origin-purge.ts +79 -0
  363. package/src/lab/public/origin.ts +203 -0
  364. package/src/lab/public/privacy.ts +143 -0
  365. package/src/lab/public/private-file.ts +261 -0
  366. package/src/lab/public/project.ts +124 -0
  367. package/src/lab/public/purge-test-fault.ts +21 -0
  368. package/src/lab/public/purge.ts +223 -0
  369. package/src/lab/public/registry.ts +44 -0
  370. package/src/lab/public/revocation.ts +252 -0
  371. package/src/lab/public/signature.ts +243 -0
  372. package/src/lab/public/storage.ts +105 -0
  373. package/src/lab/public/strict-json.ts +206 -0
  374. package/src/lab/public/time.ts +26 -0
  375. package/src/lab/public/types.ts +172 -0
  376. package/src/lab/public/validate.ts +391 -0
  377. package/src/lab/query/catalog.ts +101 -0
  378. package/src/lab/query/connection.ts +107 -0
  379. package/src/lab/query/constants.ts +4 -0
  380. package/src/lab/query/cursor.ts +132 -0
  381. package/src/lab/query/dto-map.ts +277 -0
  382. package/src/lab/query/errors.ts +22 -0
  383. package/src/lab/query/freshness.ts +53 -0
  384. package/src/lab/query/index.ts +45 -0
  385. package/src/lab/query/latest-observation.ts +59 -0
  386. package/src/lab/query/passive-production.ts +159 -0
  387. package/src/lab/query/queries.ts +444 -0
  388. package/src/lab/query/types.ts +266 -0
  389. package/src/lab/subject/behavior-fingerprint.ts +77 -0
  390. package/src/lab/subject/installation-salt.ts +112 -0
  391. package/src/lab/subject/protocol-subject.ts +80 -0
  392. package/src/lab/subject/route-subject.ts +74 -0
  393. package/src/lib/app-owned-memory-stores.ts +22 -0
  394. package/src/lib/bounded-body.ts +153 -9
  395. package/src/lib/bun-runtime.ts +125 -12
  396. package/src/lib/bun-stream-caps.ts +13 -9
  397. package/src/lib/codex-restart-contract.ts +120 -0
  398. package/src/lib/config-ownership.ts +6 -2
  399. package/src/lib/destination-policy.ts +65 -1
  400. package/src/lib/errors.ts +44 -2
  401. package/src/lib/fabric-task-execution-authority.ts +7 -0
  402. package/src/lib/fabric-task-host.ts +29 -0
  403. package/src/lib/lab-activation.ts +223 -0
  404. package/src/lib/lab-live-execution-authority.ts +13 -0
  405. package/src/lib/lab-live-host.ts +30 -0
  406. package/src/lib/lab-live-pinned-sender.ts +56 -0
  407. package/src/lib/lab-live-route-production.ts +130 -0
  408. package/src/lib/lab-passive-linker-registration.ts +26 -0
  409. package/src/lib/local-management-attestation.ts +51 -0
  410. package/src/lib/local-management-capability.ts +100 -0
  411. package/src/lib/local-provider-reload-contract.ts +100 -0
  412. package/src/lib/optional-shutdown-hooks.ts +57 -0
  413. package/src/lib/pinned-http.ts +145 -26
  414. package/src/lib/process-control.ts +4 -1
  415. package/src/lib/provider-outbound.ts +49 -9
  416. package/src/lib/redact.ts +419 -3
  417. package/src/lib/self-launch-argv.ts +15 -0
  418. package/src/lib/server-resource-ownership.ts +71 -0
  419. package/src/lib/shadow-call.ts +35 -4
  420. package/src/lib/sse-decoder.ts +41 -0
  421. package/src/lib/state-store-registrations.ts +10 -2
  422. package/src/lib/system-restart-contract.ts +73 -0
  423. package/src/lib/token-estimate.ts +19 -2
  424. package/src/lib/tool-argument-integers.ts +202 -0
  425. package/src/lib/translator-budget.ts +44 -0
  426. package/src/lib/upstream-http-version.ts +57 -0
  427. package/src/lib/upstream-reachability.ts +95 -0
  428. package/src/lib/upstream-retry.ts +156 -3
  429. package/src/lib/windows-atomic-replace.ts +155 -0
  430. package/src/lib/windows-elevation.ts +70 -2
  431. package/src/lib/windows-secret-acl.ts +409 -69
  432. package/src/lib/windows-service-wrappers.ts +72 -0
  433. package/src/lib/windows-text.ts +106 -0
  434. package/src/lib/windows-user-principal.ts +341 -0
  435. package/src/lib/winsw.ts +33 -5
  436. package/src/oauth/account-import/google-antigravity-adapter.ts +74 -0
  437. package/src/oauth/account-import/index.ts +15 -0
  438. package/src/oauth/account-import/parser.ts +83 -0
  439. package/src/oauth/account-import/registry.ts +18 -0
  440. package/src/oauth/account-import/service.ts +75 -0
  441. package/src/oauth/account-import/types.ts +91 -0
  442. package/src/oauth/anthropic.ts +12 -1
  443. package/src/oauth/callback-server.ts +8 -2
  444. package/src/oauth/chatgpt.ts +12 -1
  445. package/src/oauth/command-code.ts +239 -0
  446. package/src/oauth/cursor.ts +46 -5
  447. package/src/oauth/google-antigravity.ts +35 -3
  448. package/src/oauth/health.ts +20 -12
  449. package/src/oauth/index.ts +398 -66
  450. package/src/oauth/key-providers.ts +16 -0
  451. package/src/oauth/kimi.ts +16 -2
  452. package/src/oauth/kiro.ts +50 -6
  453. package/src/oauth/local-token-detect.ts +11 -2
  454. package/src/oauth/log.ts +3 -1
  455. package/src/oauth/login-cli.ts +88 -28
  456. package/src/oauth/nous.ts +798 -0
  457. package/src/oauth/store.ts +119 -21
  458. package/src/oauth/token-guardian.ts +9 -3
  459. package/src/pi/models.ts +2 -2
  460. package/src/providers/alibaba-region-migration.ts +1 -1
  461. package/src/providers/antigravity-models.ts +521 -31
  462. package/src/providers/base-url-choices.ts +10 -0
  463. package/src/providers/codex-capacity.ts +292 -0
  464. package/src/providers/command-code-efforts.ts +144 -0
  465. package/src/providers/context-cap.ts +22 -5
  466. package/src/providers/cursor-pool.ts +72 -0
  467. package/src/providers/derive.ts +253 -6
  468. package/src/providers/fastwire.ts +501 -0
  469. package/src/providers/free-directory.ts +10 -7
  470. package/src/providers/google-vertex-location.ts +14 -0
  471. package/src/providers/key-failover.ts +71 -3
  472. package/src/providers/label.ts +1 -1
  473. package/src/providers/model-discovery-limits.ts +16 -0
  474. package/src/providers/model-discovery.ts +115 -22
  475. package/src/providers/model-rename-migration.ts +255 -0
  476. package/src/providers/model-rename-startup.ts +28 -0
  477. package/src/providers/openai-sidecar.ts +72 -4
  478. package/src/providers/openai-tier-startup.ts +31 -2
  479. package/src/providers/openai-tiers.ts +119 -4
  480. package/src/providers/openai-virtual-models.ts +1 -0
  481. package/src/providers/opencode-zen-rate-limit.ts +102 -0
  482. package/src/providers/provider-id-rewrite.ts +29 -0
  483. package/src/providers/quota.ts +1319 -38
  484. package/src/providers/registry.ts +1429 -111
  485. package/src/providers/request-pacing.ts +310 -0
  486. package/src/providers/service-tier.ts +277 -0
  487. package/src/providers/slug-codec.ts +42 -6
  488. package/src/providers/static-model-discovery.ts +86 -0
  489. package/src/providers/xai-responses-opt-in.ts +15 -0
  490. package/src/providers/xai-transport.ts +11 -4
  491. package/src/reasoning-effort.ts +49 -1
  492. package/src/responses/compaction.ts +26 -1
  493. package/src/responses/custom-tool-compat.ts +266 -0
  494. package/src/responses/hosted-tool-policy.ts +9 -0
  495. package/src/responses/namespace-tool-compat.ts +355 -0
  496. package/src/responses/parser.ts +220 -38
  497. package/src/responses/provider-continuation.ts +98 -0
  498. package/src/responses/provider-opaque-metadata.ts +73 -0
  499. package/src/responses/reasoning-envelope.ts +9 -1
  500. package/src/responses/reasoning-replay-cache.ts +426 -0
  501. package/src/responses/schema.ts +7 -1
  502. package/src/responses/spill-store.ts +75 -10
  503. package/src/responses/state.ts +565 -27
  504. package/src/responses/thought-signature-replay.ts +347 -0
  505. package/src/responses/tool-search-compat.ts +301 -0
  506. package/src/responses/truncated-stop-reason.ts +60 -0
  507. package/src/router.ts +366 -30
  508. package/src/routing/analytics.ts +378 -0
  509. package/src/routing/capability.ts +244 -0
  510. package/src/routing/compatibility/assemble.ts +73 -0
  511. package/src/routing/compatibility/behavior.ts +278 -0
  512. package/src/routing/compatibility/catalog.ts +99 -0
  513. package/src/routing/compatibility/endpoint.ts +52 -0
  514. package/src/routing/compatibility/lab-evidence-provider.ts +130 -0
  515. package/src/routing/compatibility/policy.ts +181 -0
  516. package/src/routing/compatibility/provider-slot.ts +56 -0
  517. package/src/routing/compatibility/reader.ts +110 -0
  518. package/src/routing/compatibility/subject.ts +191 -0
  519. package/src/routing/compatibility/types.ts +64 -0
  520. package/src/routing/compatibility/version.ts +104 -0
  521. package/src/routing/cost.ts +77 -0
  522. package/src/routing/evaluator.ts +495 -0
  523. package/src/routing/health.ts +412 -0
  524. package/src/routing/history/cursor.ts +43 -0
  525. package/src/routing/history/indexer.ts +605 -0
  526. package/src/routing/history/schema.ts +72 -0
  527. package/src/routing/profile-namespace.ts +15 -0
  528. package/src/routing/profile.ts +547 -0
  529. package/src/routing/quota.ts +145 -0
  530. package/src/routing/request-evidence.ts +45 -0
  531. package/src/routing/trace.ts +776 -0
  532. package/src/server/adapter-resolve.ts +2 -29
  533. package/src/server/auth-cors.ts +267 -46
  534. package/src/server/background-lifecycle.ts +182 -0
  535. package/src/server/chat-completions.ts +130 -56
  536. package/src/server/chat-native-sse.ts +331 -0
  537. package/src/server/chat-native.ts +426 -0
  538. package/src/server/claude-messages.ts +159 -43
  539. package/src/server/direct-local-http.ts +347 -0
  540. package/src/server/effort-policy.ts +18 -0
  541. package/src/server/github-copilot-responses-repair.ts +338 -0
  542. package/src/server/gui-static.ts +39 -10
  543. package/src/server/images.ts +94 -12
  544. package/src/server/index.ts +865 -181
  545. package/src/server/lifecycle.ts +284 -13
  546. package/src/server/live.ts +136 -17
  547. package/src/server/local-management-read-client.ts +90 -0
  548. package/src/server/local-provider-reload-client.ts +137 -0
  549. package/src/server/management/agent-settings-routes.ts +398 -103
  550. package/src/server/management/api-key-usage.ts +31 -5
  551. package/src/server/management/body.ts +6 -0
  552. package/src/server/management/combo-routes.ts +62 -24
  553. package/src/server/management/config-routes.ts +464 -51
  554. package/src/server/management/context.ts +80 -2
  555. package/src/server/management/integration-routes.ts +498 -0
  556. package/src/server/management/lab-automation-routes.ts +206 -0
  557. package/src/server/management/lab-routes.ts +563 -0
  558. package/src/server/management/logs-usage-routes.ts +101 -32
  559. package/src/server/management/model-routes.ts +189 -131
  560. package/src/server/management/model-rows.ts +163 -0
  561. package/src/server/management/native-integration-routes.ts +769 -0
  562. package/src/server/management/oauth-account-routes.ts +80 -4
  563. package/src/server/management/provider-capability-config.ts +48 -0
  564. package/src/server/management/provider-routes.ts +764 -157
  565. package/src/server/management/request-history-routes.ts +191 -0
  566. package/src/server/management/routing-analytics-routes.ts +74 -0
  567. package/src/server/management/routing-profile-routes.ts +380 -0
  568. package/src/server/management/shared.ts +27 -11
  569. package/src/server/management/sidebar-routes.ts +47 -31
  570. package/src/server/management/storage-log-guard-routes.ts +186 -0
  571. package/src/server/management/sync-response.ts +69 -0
  572. package/src/server/management/system-restart.ts +288 -32
  573. package/src/server/management/system-routes.ts +77 -0
  574. package/src/server/management/usage-summary-cache.ts +9 -1
  575. package/src/server/management/vision-sidecar-options.ts +167 -0
  576. package/src/server/management/web-search-sidecar-options.ts +120 -0
  577. package/src/server/management-api.ts +115 -14
  578. package/src/server/management-auth.ts +220 -5
  579. package/src/server/passive-route-linker.ts +66 -0
  580. package/src/server/ports.ts +41 -1
  581. package/src/server/proxy-liveness.ts +132 -5
  582. package/src/server/readiness.ts +99 -0
  583. package/src/server/relay-eager.ts +82 -42
  584. package/src/server/relay.ts +236 -76
  585. package/src/server/request-decompress.ts +113 -6
  586. package/src/server/request-log.ts +235 -22
  587. package/src/server/responses/agent-task-recovery-cache.ts +143 -0
  588. package/src/server/responses/agent-task-recovery.ts +465 -0
  589. package/src/server/responses/collaboration.ts +204 -35
  590. package/src/server/responses/compact.ts +442 -55
  591. package/src/server/responses/core.ts +2872 -331
  592. package/src/server/responses/empty-completion-guard.ts +276 -0
  593. package/src/server/responses/encrypted-payload.ts +62 -39
  594. package/src/server/responses/fetch-helpers.ts +79 -4
  595. package/src/server/responses/input-admission.ts +185 -0
  596. package/src/server/responses/pacing-overload.ts +13 -0
  597. package/src/server/responses/policy-fallback.ts +178 -0
  598. package/src/server/responses/responses-field-backfill.ts +251 -0
  599. package/src/server/responses/terminal-guard.ts +26 -5
  600. package/src/server/responses/upstream-error.ts +5 -0
  601. package/src/server/responses/ws-upstream.ts +308 -0
  602. package/src/server/responses-custom-tool-repair.ts +282 -0
  603. package/src/server/responses-item-id-repair.ts +54 -6
  604. package/src/server/responses-json-events.ts +90 -0
  605. package/src/server/responses-model-rewrite.ts +29 -0
  606. package/src/server/responses-reasoning-summary-rewrite.ts +178 -0
  607. package/src/server/responses-snapshot-repair.ts +621 -0
  608. package/src/server/responses-terminal-repair.ts +342 -0
  609. package/src/server/responses-tool-search-repair.ts +267 -0
  610. package/src/server/responses-undeclared-tool-guard.ts +153 -0
  611. package/src/server/responses.ts +18 -2
  612. package/src/server/search.ts +78 -13
  613. package/src/server/sse-frame-buffer.ts +292 -0
  614. package/src/server/sse-payload-rewrite.ts +110 -22
  615. package/src/server/startup-action-control.ts +8 -1
  616. package/src/server/startup-health-cache.ts +19 -1
  617. package/src/server/system-env.ts +80 -9
  618. package/src/server/ws-bridge.ts +39 -38
  619. package/src/service-manager-probe.ts +892 -0
  620. package/src/service.ts +1111 -90
  621. package/src/sidecar/auth.ts +92 -0
  622. package/src/sidecar/candidates.ts +83 -0
  623. package/src/storage/cleanup.ts +2 -2
  624. package/src/storage/scanner.ts +1 -1
  625. package/src/storage/worker-lifecycle.ts +14 -14
  626. package/src/tray/windows-tray.ps1 +83 -9
  627. package/src/tray/windows.ts +43 -16
  628. package/src/types/accounts.ts +37 -0
  629. package/src/types/config.ts +845 -0
  630. package/src/types/provider.ts +545 -0
  631. package/src/types/request.ts +384 -0
  632. package/src/types/tools.ts +131 -0
  633. package/src/types/wire.ts +80 -0
  634. package/src/types.ts +104 -1236
  635. package/src/update/index.ts +32 -19
  636. package/src/update/job.ts +442 -67
  637. package/src/update/notify.ts +12 -6
  638. package/src/update/npm-cache-preflight.d.mts +47 -0
  639. package/src/update/npm-cache-preflight.mjs +201 -0
  640. package/src/update/transactional-install.d.mts +22 -0
  641. package/src/update/transactional-install.mjs +259 -0
  642. package/src/usage/cost.ts +0 -0
  643. package/src/usage/expected-prices.ts +268 -16
  644. package/src/usage/log.ts +606 -41
  645. package/src/usage/summary.ts +177 -9
  646. package/src/usage/user-cost-overlay-reconciler.ts +313 -0
  647. package/src/usage/user-cost-overlays.ts +314 -0
  648. package/src/vision/anthropic-describe.ts +10 -6
  649. package/src/vision/backends.ts +97 -0
  650. package/src/vision/describe.ts +9 -3
  651. package/src/vision/eligibility.ts +250 -0
  652. package/src/vision/index.ts +238 -24
  653. package/src/vision/reasoning.ts +55 -0
  654. package/src/vision/routed-describe.ts +175 -0
  655. package/src/vision/timeout-bounds.ts +9 -0
  656. package/src/web-search/anthropic-executor.ts +13 -7
  657. package/src/web-search/backends.ts +108 -0
  658. package/src/web-search/exa-executor.ts +88 -0
  659. package/src/web-search/executor.ts +11 -3
  660. package/src/web-search/gemini-executor.ts +141 -0
  661. package/src/web-search/index.ts +150 -15
  662. package/src/web-search/loop.ts +279 -50
  663. package/src/web-search/parse.ts +125 -30
  664. package/src/web-search/sources.ts +60 -0
  665. package/src/web-search/xai-executor.ts +219 -0
  666. package/gui/dist/assets/index-DTpMHS4F.js +0 -67
  667. package/gui/dist/assets/index-ZNVDE3C7.css +0 -1
  668. package/gui/dist/provider-icons/antigravity.svg +0 -1
  669. package/gui/dist/provider-icons/claude.svg +0 -1
  670. package/gui/dist/provider-icons/copilot.svg +0 -1
  671. package/gui/dist/provider-icons/cursor.svg +0 -2
  672. package/gui/dist/provider-icons/gemini.svg +0 -1
  673. package/gui/dist/provider-icons/grok-color.svg +0 -1
  674. package/gui/dist/provider-icons/kiro.svg +0 -14
  675. package/src/cli/internal-dispatch.ts +0 -20
@@ -1,16 +1,18 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { createHash } from "node:crypto";
2
+ import { createHash, createHmac, randomBytes } from "node:crypto";
3
3
  import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
4
4
  import { delimiter, dirname, join, resolve } from "node:path";
5
- import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../../config";
5
+ import { atomicWriteFile, expandUserPath, getConfigDir, resolveEnvValue, websocketsEnabled } from "../../config";
6
6
  import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "../paths";
7
7
  import {
8
8
  clearModelCache,
9
9
  clearProviderDiscoveryStatus,
10
+ captureModelCacheGeneration,
10
11
  DEFAULT_MODEL_CACHE_TTL_MS,
11
12
  getFreshCached,
12
13
  getStaleCached,
13
14
  isModelsFetchCoolingDown,
15
+ isModelCacheGenerationCurrent,
14
16
  markModelsFetchFailure,
15
17
  markProviderDiscoveryFailed,
16
18
  markProviderDiscoveryOk,
@@ -18,14 +20,27 @@ import {
18
20
  setCached,
19
21
  type ProviderModelDiscoveryFailure,
20
22
  } from "../model-cache";
21
- import { buildModelsRequest, resolveModelsAuthToken } from "../../oauth";
23
+ import {
24
+ buildModelsRequest,
25
+ getValidAccessTokenSnapshot,
26
+ observeActiveOAuthAccessToken,
27
+ resolveModelsAuthToken,
28
+ type OAuthActiveTokenObservation,
29
+ } from "../../oauth";
22
30
  import type { OcxConfig, OcxProviderConfig } from "../../types";
23
31
  import { modelInList } from "../../types";
24
32
  import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort";
25
- import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive, listJawcodeModelMetadata, resolveJawcodeProvider } from "../../generated/jawcode-model-metadata";
33
+ import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata";
26
34
  import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive";
27
- import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../providers/registry";
28
- import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap";
35
+ import {
36
+ captureFastPolicyAuthority,
37
+ fastPolicyForModel,
38
+ serviceTierSupportFromPolicy,
39
+ } from "../../providers/service-tier";
40
+ import type { FastPolicyAuthority } from "../../providers/fastwire";
41
+ import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../providers/registry";
42
+ import { parseAntigravityAvailableModels, registerAntigravityDiscoveredWireModels } from "../../providers/antigravity-models";
43
+ import { applyProviderContextCap, providerContextCap, resolveUnknownRoutedContextWindow } from "../../providers/context-cap";
29
44
  import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec";
30
45
  import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity";
31
46
  import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery";
@@ -42,6 +57,7 @@ import type { NormalizedComboConfig } from "../../combos/types";
42
57
  import {
43
58
  ProviderOutboundPolicyError,
44
59
  providerOutboundGet,
60
+ providerOutboundPost,
45
61
  providerRedirectError,
46
62
  } from "../../lib/provider-outbound";
47
63
  import { redactSecretString } from "../../lib/redact";
@@ -51,25 +67,175 @@ import {
51
67
  resolveProviderModelDiscovery,
52
68
  type ModelDiscoveryResponseFailure,
53
69
  type ProviderModelsApiItem,
70
+ type ResolvedProviderModelDiscovery,
54
71
  } from "../../providers/model-discovery";
55
72
  import upstreamModelsSnapshot from "../data/upstream-models.json";
56
73
  import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } from "../../lib/admission";
57
74
 
58
75
 
59
- import { JAWCODE_CATALOG_AUGMENT_PROVIDERS, catalogModelSlug, shouldExposeRoutedModel } from "./parsing";
76
+ import { CODEX_CUSTOM_MODEL_CATALOG_KIND, JAWCODE_CATALOG_AUGMENT_PROVIDERS, catalogModelSlug, shouldExposeRoutedModel } from "./parsing";
60
77
  import type { CatalogModel } from "./parsing";
61
- import { disabledNativeSlugs, hasComboTargets, nativeInputModalities, nativeOpenAiContextWindow, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata";
78
+ import { disabledNativeSlugs, hasComboTargets, isNativeOpenAiCapabilityAliasModel, NATIVE_GPT56_MAX_INPUT_TOKENS, nativeContextLimits, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata";
62
79
  import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation";
63
80
  import type { ComboCatalogOmission } from "./aggregation";
81
+ import type { CatalogGatherProviderAuthEvidence } from "./filesystem-evidence";
82
+ import type {
83
+ CatalogAdmissionSnapshot,
84
+ CatalogDiscoveryPolicyField,
85
+ CatalogGatherAuthorityIdentity,
86
+ CatalogProviderDiscoveryPolicySnapshot,
87
+ CatalogProcessLocalEvidence,
88
+ CatalogSourceEvidence,
89
+ CatalogTrustedOpenAiApiPolicySnapshot,
90
+ } from "../convergence-types";
91
+
92
+ export type { CatalogGatherProviderAuthEvidence } from "./filesystem-evidence";
64
93
 
65
94
  /** Concurrent gatherRoutedModels callers with the same catalog identity share one live discovery.
66
95
  * Keyed by gatherFlightKey so a different config cannot join or evict the wrong flight. */
96
+ export interface CatalogGatherProviderAuthOutcome {
97
+ readonly provider: string;
98
+ readonly state: OAuthActiveTokenObservation["kind"];
99
+ }
100
+
101
+ export interface CatalogGatherProviderModelOutcome {
102
+ readonly provider: string;
103
+ readonly state: "authoritative" | "degraded";
104
+ }
105
+
106
+ export interface GatherRoutedModelsOptions {
107
+ comboOmissions?: ComboCatalogOmission[];
108
+ providerAuthOutcomes?: CatalogGatherProviderAuthOutcome[];
109
+ /** Flight-local authority of each provider's returned model rows. */
110
+ providerModelOutcomes?: CatalogGatherProviderModelOutcome[];
111
+ /** Internal convergence sink for the immutable policy that produced the returned rows. */
112
+ discoveryPolicySnapshots?: CatalogProviderDiscoveryPolicySnapshot[];
113
+ /**
114
+ * Management-UI fast path: never block on upstream `/models`.
115
+ * Serves per-provider TTL/stale/configured seeds immediately and, when any live
116
+ * provider still needs a network probe, kicks a background full gather so the
117
+ * next call (or a short GUI re-poll) sees fresh rows. Codex catalog sync and
118
+ * `/v1/models` must omit this — they need the authoritative live set.
119
+ */
120
+ preferCached?: boolean;
121
+ }
122
+
67
123
  interface GatherFlightResult {
68
124
  models: CatalogModel[];
69
125
  comboOmissions: ComboCatalogOmission[];
126
+ providerAuthOutcomes: readonly CatalogGatherProviderAuthOutcome[];
127
+ providerModelOutcomes: readonly CatalogGatherProviderModelOutcome[];
128
+ discoveryPolicySnapshots: readonly CatalogProviderDiscoveryPolicySnapshot[];
129
+ }
130
+
131
+ interface ProviderModelsResult {
132
+ readonly models: CatalogModel[];
133
+ readonly outcome: CatalogGatherProviderModelOutcome;
134
+ }
135
+
136
+ interface ModelsAuthResolution {
137
+ readonly apiKey: string | undefined;
138
+ readonly observed: boolean;
139
+ readonly oauthApiBaseUrl?: string;
140
+ readonly oauthProjectId?: string;
141
+ }
142
+
143
+ type ModelsAuthResolver =
144
+ | { readonly kind: "refreshing" }
145
+ | {
146
+ readonly kind: "observed";
147
+ readonly resolve: (name: string, provider: OcxProviderConfig) => ModelsAuthResolution;
148
+ };
149
+
150
+ type ModelsAuthResolverFactory = (
151
+ outcomes: CatalogGatherProviderAuthOutcome[],
152
+ ) => ModelsAuthResolver;
153
+
154
+ interface CapturedModelsRequest {
155
+ readonly method: "GET" | "POST";
156
+ readonly url: string;
157
+ readonly headersWithoutCredential: Readonly<Record<string, string>>;
158
+ readonly headersWithCredential: Readonly<Record<string, string>>;
70
159
  }
71
160
 
72
- const gatherInflight = new Map<string, Promise<GatherFlightResult>>();
161
+ interface CapturedProviderGather {
162
+ readonly name: string;
163
+ readonly provider: OcxProviderConfig;
164
+ readonly discovery: ResolvedProviderModelDiscovery;
165
+ readonly policy: CatalogProviderDiscoveryPolicySnapshot;
166
+ readonly request: CapturedModelsRequest;
167
+ readonly fastPolicyAuthority: FastPolicyAuthority;
168
+ readonly observedAuth?: ModelsAuthResolution;
169
+ /**
170
+ * Configured model ids this provider must keep even when live discovery omits
171
+ * them — combo targets that are also listed in providers.*.models (OCX-111).
172
+ * Combo-only ids (not in models[]) stay out of the public catalog and are
173
+ * synthesized for combo derivation instead (#1305).
174
+ */
175
+ readonly retainConfiguredModelIds?: ReadonlySet<string>;
176
+ }
177
+
178
+ interface GatherFlightCapture {
179
+ readonly discoveryPolicyIdentity: string;
180
+ readonly authIdentity: string;
181
+ readonly providerGraphIdentity: string;
182
+ readonly discoveryPolicySnapshots: readonly CatalogProviderDiscoveryPolicySnapshot[];
183
+ readonly providers: readonly CapturedProviderGather[];
184
+ readonly authResolver: ModelsAuthResolver;
185
+ readonly providerAuthOutcomes: readonly CatalogGatherProviderAuthOutcome[];
186
+ readonly openAiApiPolicy: CatalogTrustedOpenAiApiPolicySnapshot;
187
+ }
188
+
189
+ interface GatherInflightEntry {
190
+ readonly discoveryPolicyIdentity: string;
191
+ /**
192
+ * The credential half of the join decision.
193
+ *
194
+ * `gatherFlightKey`'s fingerprint carries endpoints and model lists but no
195
+ * `authMode`, key or headers, and discovery policy does not carry them either.
196
+ * Two admissions differing ONLY in credential therefore produced the same key
197
+ * and the same policy, so the second joined the first and published rows the
198
+ * old key had fetched — reproduced against the real routes by rotating a key
199
+ * through `/api/providers/keys` mid-flight.
200
+ *
201
+ * Now REDUNDANT with `providerGraphIdentity`, which hashes the whole provider
202
+ * row and therefore covers `apiKey` too: removing this term alone leaves the
203
+ * credential regression green. It is kept deliberately, for two reasons. It
204
+ * covers what the graph cannot — the RESOLVED auth (`observedAuth`) and the
205
+ * final materialized headers, which are derived rather than stored, so an
206
+ * OAuth token that changes while the row is byte-identical still separates
207
+ * admissions. And it states the credential rule where a reader looks for it,
208
+ * instead of leaving it as an emergent property of hashing everything.
209
+ */
210
+ readonly authIdentity: string;
211
+ /**
212
+ * The whole admitted provider graph, not a chosen subset.
213
+ *
214
+ * `providerCatalogFingerprint` is an ALLOW-LIST, so every field it forgot was
215
+ * silently treated as equivalence: credentials leaked a flight until
216
+ * `authIdentity` landed, and `reasoningEfforts` leaked one after that — both
217
+ * reproduced against real routes. Enumerating fields cannot converge, because
218
+ * the next field added to a provider row inherits the same defect. This
219
+ * identity therefore covers the enriched, frozen provider objects the flight
220
+ * actually gathered from, so a join is refused unless the admissions agree on
221
+ * everything rather than on everything somebody remembered to list.
222
+ */
223
+ readonly providerGraphIdentity: string;
224
+ readonly promise: Promise<GatherFlightResult>;
225
+ }
226
+
227
+ function withCanonicalOpenAiForwardAuthDefault(
228
+ name: string,
229
+ provider: OcxProviderConfig,
230
+ ): OcxProviderConfig {
231
+ if (name !== OPENAI_CODEX_PROVIDER_ID || provider.authMode !== undefined) return provider;
232
+ const candidate = { ...provider, authMode: "forward" as const };
233
+ return isCanonicalOpenAiForwardProvider(candidate) ? candidate : provider;
234
+ }
235
+
236
+ const gatherInflight = new Map<string, GatherInflightEntry[]>();
237
+ const CATALOG_GATHER_AUTHORITY_KEY = randomBytes(32);
238
+ const REQUEST_CREDENTIAL_SENTINEL = `ocx-catalog-credential-${randomBytes(16).toString("hex")}`;
73
239
  const MAX_CONCURRENT_CATALOG_GATHERS = 8;
74
240
  const gatherGate = createAdmissionGate("catalog_gathers", MAX_CONCURRENT_CATALOG_GATHERS);
75
241
 
@@ -95,6 +261,310 @@ function stableJson(value: unknown): string {
95
261
  });
96
262
  }
97
263
 
264
+ function framed(value: string): string {
265
+ return `${Buffer.byteLength(value, "utf8")}:${value}`;
266
+ }
267
+
268
+ function canonicalAuthorityEncoding(value: unknown): string {
269
+ if (value === null) return "null";
270
+ if (value === undefined) return "undefined";
271
+ if (typeof value === "string") return `string${framed(value)}`;
272
+ if (typeof value === "boolean") return value ? "boolean1" : "boolean0";
273
+ if (typeof value === "number") {
274
+ if (!Number.isFinite(value)) throw new TypeError("Catalog authority cannot encode a non-finite number.");
275
+ const encoded = Object.is(value, -0) ? "-0" : String(value);
276
+ return `number${framed(encoded)}`;
277
+ }
278
+ if (Array.isArray(value)) {
279
+ return `array${value.length}:${value.map(item => framed(canonicalAuthorityEncoding(item))).join("")}`;
280
+ }
281
+ if (typeof value === "object") {
282
+ const record = value as Record<string, unknown>;
283
+ const keys = Object.keys(record).sort((left, right) => left.localeCompare(right));
284
+ return `object${keys.length}:${keys.map(key => (
285
+ `${framed(key)}${framed(canonicalAuthorityEncoding(record[key]))}`
286
+ )).join("")}`;
287
+ }
288
+ throw new TypeError(`Catalog authority cannot encode ${typeof value}.`);
289
+ }
290
+
291
+ function keyedGatherIdentity(domain: string, value: unknown): string {
292
+ return createHmac("sha256", CATALOG_GATHER_AUTHORITY_KEY)
293
+ .update(framed(domain))
294
+ .update(framed(canonicalAuthorityEncoding(value)))
295
+ .digest("hex");
296
+ }
297
+
298
+ function keyedGatherBytesIdentity(domain: string, value: Uint8Array): string {
299
+ return createHmac("sha256", CATALOG_GATHER_AUTHORITY_KEY)
300
+ .update(framed(domain))
301
+ .update(`${value.byteLength}:`)
302
+ .update(value)
303
+ .digest("hex");
304
+ }
305
+
306
+ export function createCatalogGatherAuthorityIdentity(
307
+ snapshot: CatalogAdmissionSnapshot,
308
+ sourceEvidence: CatalogSourceEvidence,
309
+ processLocal: CatalogProcessLocalEvidence,
310
+ discoveryPolicies: readonly CatalogProviderDiscoveryPolicySnapshot[],
311
+ ): CatalogGatherAuthorityIdentity {
312
+ const sourceEvidenceIdentity = keyedGatherIdentity("catalog-source-evidence-v1", sourceEvidence);
313
+ const processLocalEvidenceIdentity = keyedGatherIdentity("catalog-process-local-v1", processLocal);
314
+ const discoveryPolicyIdentity = keyedGatherIdentity("catalog-discovery-policy-v1", discoveryPolicies);
315
+ return Object.freeze({
316
+ version: 1 as const,
317
+ authorityId: keyedGatherIdentity("catalog-authority-v1", {
318
+ admittedConfig: snapshot.configIdentity,
319
+ discoveryPolicyIdentity,
320
+ sourceEvidenceIdentity,
321
+ processLocalEvidenceIdentity,
322
+ }),
323
+ admittedConfig: Object.freeze({
324
+ ...snapshot.configIdentity,
325
+ generation: Object.freeze({ ...snapshot.configIdentity.generation }),
326
+ }),
327
+ authSnapshotIdentity: keyedGatherIdentity(
328
+ "catalog-auth-v1",
329
+ sourceEvidence.conditional["provider-auth-selection"],
330
+ ),
331
+ discoveryPolicyIdentity,
332
+ nativeCatalogSourceIdentity: keyedGatherIdentity(
333
+ "catalog-native-v1",
334
+ sourceEvidence.conditional["native-catalog-selection"],
335
+ ),
336
+ sourceEvidenceIdentity,
337
+ processLocalEvidenceIdentity,
338
+ });
339
+ }
340
+
341
+ function detachedClone<T>(value: T): T {
342
+ if (Array.isArray(value)) return value.map(item => detachedClone(item)) as T;
343
+ if (value && typeof value === "object") {
344
+ const clone: Record<string, unknown> = {};
345
+ for (const key of Object.keys(value)) {
346
+ clone[key] = detachedClone((value as Record<string, unknown>)[key]);
347
+ }
348
+ return clone as T;
349
+ }
350
+ return value;
351
+ }
352
+
353
+ function recursivelyFreeze<T>(value: T): T {
354
+ if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
355
+ for (const nested of Object.values(value as Record<string, unknown>)) recursivelyFreeze(nested);
356
+ return Object.freeze(value);
357
+ }
358
+
359
+ function detachedFrozen<T>(value: T): T {
360
+ return recursivelyFreeze(detachedClone(value));
361
+ }
362
+
363
+ function capturedField<T extends object, K extends keyof T>(
364
+ value: T | undefined,
365
+ key: K,
366
+ ): CatalogDiscoveryPolicyField<T[K]> {
367
+ if (!value || !Object.hasOwn(value, key)) return Object.freeze({ state: "absent" });
368
+ return detachedFrozen({ state: "present" as const, value: value[key] });
369
+ }
370
+
371
+ function captureTrustedOpenAiApiPolicy(
372
+ name: string,
373
+ registryTransportMatch: boolean,
374
+ ): CatalogTrustedOpenAiApiPolicySnapshot {
375
+ if (name !== OPENAI_API_PROVIDER_ID) return Object.freeze({ state: "unused" });
376
+ if (!registryTransportMatch) return Object.freeze({ state: "transport-mismatch" });
377
+ const entry = getProviderRegistryEntry(name);
378
+ if (!entry?.models) return Object.freeze({ state: "registry-models-absent" });
379
+ return detachedFrozen({
380
+ state: "captured" as const,
381
+ models: entry.models,
382
+ ...(entry.modelContextWindows ? { modelContextWindows: entry.modelContextWindows } : {}),
383
+ ...(entry.modelMaxInputTokens ? { modelMaxInputTokens: entry.modelMaxInputTokens } : {}),
384
+ ...(entry.modelInputModalities ? { modelInputModalities: entry.modelInputModalities } : {}),
385
+ ...(entry.modelReasoningEfforts ? { modelReasoningEfforts: entry.modelReasoningEfforts } : {}),
386
+ });
387
+ }
388
+
389
+ function captureModelsRequest(
390
+ name: string,
391
+ provider: OcxProviderConfig,
392
+ observedAuth: ModelsAuthResolution | undefined,
393
+ ): CapturedModelsRequest {
394
+ const observed = observedAuth
395
+ ? { oauthApiBaseUrl: observedAuth.oauthApiBaseUrl }
396
+ : undefined;
397
+ const withoutCredential = buildModelsRequest(provider, undefined, name, observed);
398
+ const withCredential = buildModelsRequest(provider, REQUEST_CREDENTIAL_SENTINEL, name, observed);
399
+ const method = withoutCredential.method ?? "GET";
400
+ if (withoutCredential.url !== withCredential.url || method !== (withCredential.method ?? "GET")) {
401
+ throw new TypeError(`Provider model discovery URL for ${name} depends on credential bytes.`);
402
+ }
403
+ return detachedFrozen({
404
+ method,
405
+ url: withoutCredential.url,
406
+ headersWithoutCredential: withoutCredential.headers,
407
+ headersWithCredential: withCredential.headers,
408
+ });
409
+ }
410
+
411
+ function captureProviderGather(
412
+ name: string,
413
+ configured: OcxProviderConfig,
414
+ authResolver: ModelsAuthResolver,
415
+ retainConfiguredModelIds?: ReadonlySet<string>,
416
+ ): CapturedProviderGather {
417
+ const enriched = detachedClone(withCanonicalOpenAiForwardAuthDefault(name, configured));
418
+ enrichProviderFromRegistry(name, enriched);
419
+ const registryTransportMatch = providerMatchesRegistryTransport(name, enriched);
420
+ const provider = recursivelyFreeze(enriched);
421
+ const fastPolicyAuthority = captureFastPolicyAuthority(
422
+ name,
423
+ provider,
424
+ registryTransportMatch,
425
+ configured,
426
+ );
427
+ const observedAuth = authResolver.kind === "observed"
428
+ && provider.authMode !== "forward"
429
+ && provider.liveModels !== false
430
+ ? authResolver.resolve(name, provider)
431
+ : undefined;
432
+ const request = captureModelsRequest(name, provider, observedAuth);
433
+ const resolved = resolveProviderModelDiscovery(name, provider);
434
+ const discovery = detachedFrozen({
435
+ ...(resolved.spec ? { spec: resolved.spec } : {}),
436
+ maxResponseBytes: resolved.maxResponseBytes,
437
+ maxModels: resolved.maxModels,
438
+ });
439
+ const trustedOpenAiApi = captureTrustedOpenAiApiPolicy(name, registryTransportMatch);
440
+ const policy = detachedFrozen({
441
+ provider: name,
442
+ registryTransportMatch,
443
+ location: {
444
+ spec: discovery.spec ? "present" as const : "absent" as const,
445
+ url: capturedField(discovery.spec, "url"),
446
+ path: capturedField(discovery.spec, "path"),
447
+ query: capturedField(discovery.spec, "query"),
448
+ },
449
+ finalMethod: request.method,
450
+ finalUrl: request.url,
451
+ filter: capturedField(discovery.spec, "filter"),
452
+ maxResponseBytes: discovery.maxResponseBytes,
453
+ maxModels: discovery.maxModels,
454
+ trustedOpenAiApi,
455
+ });
456
+ return Object.freeze({
457
+ name,
458
+ provider,
459
+ discovery,
460
+ policy,
461
+ request,
462
+ fastPolicyAuthority,
463
+ ...(observedAuth ? { observedAuth: Object.freeze({ ...observedAuth }) } : {}),
464
+ ...(retainConfiguredModelIds && retainConfiguredModelIds.size > 0
465
+ ? { retainConfiguredModelIds }
466
+ : {}),
467
+ });
468
+ }
469
+
470
+ /** Model ids each provider must retain for combo catalog derivation (OCX-111). */
471
+ export function configuredComboTargetModelsByProvider(
472
+ config: Pick<OcxConfig, "combos">,
473
+ ): Map<string, ReadonlySet<string>> {
474
+ const byProvider = new Map<string, Set<string>>();
475
+ for (const id of listComboIds(config)) {
476
+ const combo = getCombo(config, id);
477
+ if (!combo) continue;
478
+ for (const target of combo.targets) {
479
+ let models = byProvider.get(target.provider);
480
+ if (!models) {
481
+ models = new Set();
482
+ byProvider.set(target.provider, models);
483
+ }
484
+ models.add(target.model);
485
+ }
486
+ }
487
+ return byProvider;
488
+ }
489
+
490
+ function captureGatherFlight(
491
+ config: OcxConfig,
492
+ createAuthResolver: ModelsAuthResolverFactory,
493
+ ): GatherFlightCapture {
494
+ const providerAuthOutcomes: CatalogGatherProviderAuthOutcome[] = [];
495
+ const authResolver = createAuthResolver(providerAuthOutcomes);
496
+ const comboTargetsByProvider = configuredComboTargetModelsByProvider(config);
497
+ const providers = Object.entries(config.providers)
498
+ .filter(([, provider]) => provider.disabled !== true)
499
+ .map(([name, provider]) => captureProviderGather(
500
+ name,
501
+ provider,
502
+ authResolver,
503
+ comboTargetsByProvider.get(name),
504
+ ));
505
+ const discoveryPolicySnapshots = Object.freeze(providers.map(provider => provider.policy));
506
+ return Object.freeze({
507
+ discoveryPolicyIdentity: keyedGatherIdentity("catalog-discovery-policy-v1", discoveryPolicySnapshots),
508
+ // Credentials are hashed under the same unexported per-process key, never
509
+ // stored or compared in the clear: this value can reach a map key and must
510
+ // not disclose a token. The final headers are included because a static
511
+ // header can carry authority just as an `apiKey` can.
512
+ authIdentity: keyedGatherIdentity("catalog-gather-auth-v1", providers.map(provider => ({
513
+ name: provider.name,
514
+ authMode: provider.provider.authMode ?? null,
515
+ liveModels: provider.provider.liveModels ?? null,
516
+ credential: provider.provider.apiKey ?? null,
517
+ observedAuth: provider.observedAuth ?? null,
518
+ headers: provider.request.headersWithCredential,
519
+ url: provider.request.url,
520
+ }))),
521
+ // Every enriched provider row the flight will gather from, in admission order.
522
+ // Anything that can change a catalog row lives in here by construction.
523
+ providerGraphIdentity: keyedGatherIdentity("catalog-gather-provider-graph-v1",
524
+ providers.map(provider => ({
525
+ name: provider.name,
526
+ // `fetch` is a caller-owned transport executor, not admitted state: the
527
+ // outbound transport honors it so a caller can supply its own HTTP path.
528
+ // It is the one member of a provider row that is legitimately a function,
529
+ // so it is dropped here rather than allowed to break every encode.
530
+ provider: omitProviderTransportExecutor(provider.provider),
531
+ fastPolicyAuthority: provider.fastPolicyAuthority,
532
+ // Combo retention is capture-time state, not a provider-row field. Two
533
+ // gathers that share providers but differ in combo targets must not join.
534
+ retainConfiguredModelIds: [...(provider.retainConfiguredModelIds ?? [])].sort(),
535
+ }))),
536
+ discoveryPolicySnapshots,
537
+ providers: Object.freeze(providers),
538
+ authResolver,
539
+ providerAuthOutcomes: Object.freeze([...providerAuthOutcomes]),
540
+ openAiApiPolicy: providers.find(provider => provider.name === OPENAI_API_PROVIDER_ID)?.policy.trustedOpenAiApi
541
+ ?? Object.freeze({ state: "unused" as const }),
542
+ });
543
+ }
544
+
545
+ /**
546
+ * Drop the caller-owned transport executor before hashing a provider row.
547
+ *
548
+ * Fails closed on anything ELSE that cannot be encoded: the point of hashing the
549
+ * whole row is that no field escapes the comparison, so a second function member
550
+ * must surface as an encode error rather than being quietly skipped here.
551
+ */
552
+ function omitProviderTransportExecutor(provider: OcxProviderConfig): Record<string, unknown> {
553
+ const entries = Object.entries(provider).filter(([key]) => key !== "fetch");
554
+ return Object.fromEntries(entries);
555
+ }
556
+
557
+ function materializeCapturedHeaders(
558
+ request: CapturedModelsRequest,
559
+ apiKey: string | undefined,
560
+ ): Record<string, string> {
561
+ const source = apiKey ? request.headersWithCredential : request.headersWithoutCredential;
562
+ return Object.fromEntries(Object.entries(source).map(([name, value]) => [
563
+ name,
564
+ apiKey ? value.split(REQUEST_CREDENTIAL_SENTINEL).join(apiKey) : value,
565
+ ]));
566
+ }
567
+
98
568
  function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Record<string, unknown> {
99
569
  return {
100
570
  n: name,
@@ -114,6 +584,7 @@ function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Reco
114
584
  defRe: prov.modelDefaultReasoningEfforts ?? null,
115
585
  rsSum: prov.modelSupportsReasoningSummaries ?? null,
116
586
  rsDel: prov.modelReasoningSummaryDelivery ?? null,
587
+ serviceTier: prov.modelSupportsServiceTier ?? null,
117
588
  noVis: [...(prov.noVisionModels ?? [])].sort(),
118
589
  ptc: prov.parallelToolCalls ?? null,
119
590
  gMode: prov.googleMode ?? null,
@@ -185,15 +656,23 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig,
185
656
  const reasoningEfforts = configuredReasoningEfforts(prov, model.id);
186
657
  const defaultReasoningEffort = modelRecordValue(prov.modelDefaultReasoningEfforts, model.id) ?? model.defaultReasoningEffort;
187
658
  const supportsReasoningSummaries = configuredReasoningSummarySupport(prov, model.id);
659
+ const fastPolicy = fastPolicyForModel(prov, model.id, name);
660
+ const supportsServiceTier = serviceTierSupportFromPolicy(fastPolicy);
661
+ const {
662
+ supportsServiceTier: _staleServiceTier,
663
+ fastTierDescription: _staleFastTierDescription,
664
+ ...modelWithoutServiceTier
665
+ } = model;
666
+ // 已发现窗口只允许被配置值压低;缺窗口时,已开的 Context cap 就是实际窗口。
667
+ const discoveredWindow = typeof model.contextWindow === "number" && model.contextWindow > 0
668
+ ? model.contextWindow
669
+ : undefined;
670
+ const hintedWindow = discoveredWindow !== undefined
671
+ ? (configuredCap !== undefined ? Math.min(discoveredWindow, configuredCap) : discoveredWindow)
672
+ : (configuredCap ?? (providerCap !== undefined ? resolveUnknownRoutedContextWindow(providerCap) : undefined));
188
673
  const hinted = {
189
- ...model,
190
- ...(configuredCap !== undefined
191
- ? {
192
- contextWindow: typeof model.contextWindow === "number" && model.contextWindow > 0
193
- ? Math.min(model.contextWindow, configuredCap)
194
- : configuredCap,
195
- }
196
- : {}),
674
+ ...modelWithoutServiceTier,
675
+ ...(hintedWindow !== undefined ? { contextWindow: hintedWindow } : {}),
197
676
  ...(inputModalities ? { inputModalities } : {}),
198
677
  ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
199
678
  ...(configuredMaxInput !== undefined
@@ -205,12 +684,17 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig,
205
684
  : {}),
206
685
  ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}),
207
686
  ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}),
687
+ ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}),
688
+ ...(supportsServiceTier === true && fastPolicy.fastTierDescription !== undefined
689
+ ? { fastTierDescription: fastPolicy.fastTierDescription }
690
+ : {}),
208
691
  ...(prov.adapter === "kiro" ? { supportsVerbosity: false } : {}),
209
692
  // Default-on for openai-chat providers (explicit false opts out); other adapters
210
693
  // advertise only on explicit opt-in.
211
694
  ...(prov.parallelToolCalls === true || (prov.adapter === "openai-chat" && prov.parallelToolCalls !== false)
212
695
  ? { parallelToolCalls: true }
213
696
  : {}),
697
+ ...(prov.codexToolMode !== undefined ? { codexToolMode: prov.codexToolMode } : {}),
214
698
  };
215
699
  const capped = applyProviderContextCap(hinted.contextWindow, providerCap);
216
700
  if (providerCap !== undefined && capped !== hinted.contextWindow) {
@@ -229,6 +713,159 @@ export function applyConfigHintsToCachedModels(name: string, prov: OcxProviderCo
229
713
  return models.map(model => applyProviderConfigHints(name, prov, model, contextCap));
230
714
  }
231
715
 
716
+
717
+ /**
718
+ * Last-resort context window for combo member synthesis when discovery,
719
+ * provider config, and an enabled Context cap all omit one. Matches the
720
+ * catalog entry default in `normalizeRoutedCatalogEntry` so incomplete live
721
+ * rows still catalog. An enabled Context cap is the operator-facing window,
722
+ * not a clamp on this placeholder.
723
+ */
724
+ const COMBO_MEMBER_CONTEXT_FALLBACK = 128_000;
725
+
726
+ interface ComboCatalogMemberFallback {
727
+ readonly contextWindow?: number;
728
+ /** Input ceiling when it is lower than the window (native GPT-5.6: 922k under 1.05M). */
729
+ readonly maxInputTokens?: number;
730
+ readonly inputModalities?: readonly string[];
731
+ readonly reasoningEfforts?: readonly string[];
732
+ }
733
+
734
+ /**
735
+ * Resolve a combo target to a catalog member for derivation.
736
+ * Prefer discovery metadata; when the target is missing from the gather map or
737
+ * lacks a positive contextWindow, synthesize from the (registry-enriched)
738
+ * provider config so combos remain catalogued when targets are configured but
739
+ * discovery metadata is incomplete. Disabled providers stay unresolved.
740
+ * When hints still omit contextWindow, prefer known maxInputTokens, else the
741
+ * enabled Context cap, else COMBO_MEMBER_CONTEXT_FALLBACK so a live row
742
+ * without ctx does not drop the whole combo from the public catalog.
743
+ */
744
+ export function resolveComboCatalogMember(
745
+ target: { provider: string; model: string },
746
+ memberByKey: ReadonlyMap<string, CatalogModel>,
747
+ providers: ReadonlyMap<string, OcxProviderConfig>,
748
+ contextCap?: number,
749
+ fallback?: ComboCatalogMemberFallback,
750
+ ): CatalogModel | undefined {
751
+ const existing = memberByKey.get(targetKey(target));
752
+ const prov = providers.get(target.provider);
753
+ // Disabled providers never contribute members — even a complete discovery row
754
+ // is unusable for catalog derivation while the provider is off.
755
+ if (prov?.disabled === true) return undefined;
756
+
757
+ const withFallbackMetadata = (member: CatalogModel): CatalogModel => {
758
+ if (!fallback) return member;
759
+ const contextWindow = typeof member.contextWindow === "number" && member.contextWindow > 0
760
+ ? member.contextWindow
761
+ : undefined;
762
+ const addMaxInput = contextWindow !== undefined
763
+ && !(typeof member.maxInputTokens === "number" && member.maxInputTokens > 0);
764
+ const addModalities = (!Array.isArray(member.inputModalities) || member.inputModalities.length === 0)
765
+ && fallback.inputModalities !== undefined;
766
+ const addReasoning = member.reasoningEfforts === undefined
767
+ && fallback.reasoningEfforts !== undefined;
768
+ if (!addMaxInput && !addModalities && !addReasoning) return member;
769
+ return {
770
+ ...member,
771
+ // Never claim a larger input budget than the window, and prefer the model's own
772
+ // measured ceiling when the fallback carries one.
773
+ ...(addMaxInput
774
+ ? { maxInputTokens: Math.min(fallback.maxInputTokens ?? contextWindow!, contextWindow!) }
775
+ : {}),
776
+ ...(addModalities ? { inputModalities: [...fallback.inputModalities!] } : {}),
777
+ ...(addReasoning ? { reasoningEfforts: [...fallback.reasoningEfforts!] } : {}),
778
+ };
779
+ };
780
+
781
+ // Complete live/configured rows still honour providerContextCaps so a high
782
+ // discovery window cannot outrun an operator-configured cap. Native-alias
783
+ // fallback metadata may fill only capability gaps; it never raises an explicit
784
+ // discovered/configured context window.
785
+ if (
786
+ existing
787
+ && typeof existing.contextWindow === "number"
788
+ && existing.contextWindow > 0
789
+ ) {
790
+ const capped = applyProviderContextCap(existing.contextWindow, contextCap);
791
+ if (capped === undefined || capped === existing.contextWindow) {
792
+ return withFallbackMetadata(existing);
793
+ }
794
+ const maxInput = typeof existing.maxInputTokens === "number" && existing.maxInputTokens > 0
795
+ ? Math.min(existing.maxInputTokens, capped)
796
+ : Math.min(fallback?.maxInputTokens ?? capped, capped);
797
+ return withFallbackMetadata({
798
+ ...existing,
799
+ contextWindow: capped,
800
+ maxInputTokens: maxInput,
801
+ contextCap,
802
+ contextCapped: true as const,
803
+ });
804
+ }
805
+
806
+ const base: CatalogModel = existing ?? {
807
+ id: target.model,
808
+ provider: target.provider,
809
+ };
810
+ const hinted = prov
811
+ ? applyProviderConfigHints(target.provider, prov, base, contextCap)
812
+ : base;
813
+ const hintedContext = typeof hinted.contextWindow === "number" && hinted.contextWindow > 0
814
+ ? hinted.contextWindow
815
+ : undefined;
816
+ const knownMaxInput = typeof hinted.maxInputTokens === "number" && hinted.maxInputTokens > 0
817
+ ? hinted.maxInputTokens
818
+ : (typeof base.maxInputTokens === "number" && base.maxInputTokens > 0
819
+ ? base.maxInputTokens
820
+ : undefined);
821
+ // Kept OUT of knownMaxInput on purpose: that value doubles as a context-window fallback
822
+ // below, and a native alias whose input ceiling (922k) is lower than its window (1.05M)
823
+ // would otherwise shrink the advertised window to the input limit.
824
+ const fallbackMaxInput = existing || prov ? fallback?.maxInputTokens : undefined;
825
+ // Real discovery/config values win. A native alias is the next fallback tier.
826
+ // The generic 128k/text synthesis from #1305 remains the final fallback.
827
+ const fallbackContext = existing || prov ? fallback?.contextWindow : undefined;
828
+ const uncappedContext = hintedContext
829
+ ?? knownMaxInput
830
+ ?? fallbackContext
831
+ ?? (existing || prov ? resolveUnknownRoutedContextWindow(contextCap) : undefined);
832
+ if (uncappedContext === undefined) return undefined;
833
+ // 真发现值才压低。resolveUnknownRoutedContextWindow 已经把 cap 当成窗口填进去了,不能再 min 一次。
834
+ const usedDiscoveredWindow = hintedContext !== undefined || knownMaxInput !== undefined || fallbackContext !== undefined;
835
+ const cappedContext = usedDiscoveredWindow
836
+ ? applyProviderContextCap(uncappedContext, contextCap)
837
+ : uncappedContext;
838
+ const contextWindow = cappedContext ?? uncappedContext;
839
+ const fallbackCapped = usedDiscoveredWindow
840
+ && contextCap !== undefined
841
+ && cappedContext !== undefined
842
+ && cappedContext !== uncappedContext;
843
+
844
+ const inputModalities = hinted.inputModalities
845
+ ?? base.inputModalities
846
+ ?? (fallback?.inputModalities ? [...fallback.inputModalities] : undefined)
847
+ ?? ["text"];
848
+ const reasoningEfforts = hinted.reasoningEfforts
849
+ ?? (prov ? configuredReasoningEfforts(prov, target.model) : undefined)
850
+ ?? base.reasoningEfforts
851
+ ?? (fallback?.reasoningEfforts ? [...fallback.reasoningEfforts] : undefined);
852
+ // The model's own measured input ceiling still applies when discovery gave us nothing:
853
+ // GPT-5.6 advertises a 1.05M window but refuses input past 922k.
854
+ const effectiveMaxInput = knownMaxInput ?? fallbackMaxInput;
855
+ const maxInputTokens = effectiveMaxInput !== undefined
856
+ ? Math.min(effectiveMaxInput, contextWindow)
857
+ : contextWindow;
858
+
859
+ return {
860
+ ...hinted,
861
+ inputModalities,
862
+ ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
863
+ contextWindow,
864
+ maxInputTokens,
865
+ ...(fallbackCapped ? { contextCap, contextCapped: true as const } : {}),
866
+ };
867
+ }
868
+
232
869
  export function isDatedVariantId(liveId: string, configuredId: string): boolean {
233
870
  if (!liveId.startsWith(`${configuredId}-`)) return false;
234
871
  return /^\d{8}$/.test(liveId.slice(configuredId.length + 1));
@@ -273,10 +910,25 @@ export function warnDroppedConfiguredIdsOnce(name: string, droppedConfiguredIds:
273
910
  );
274
911
  }
275
912
 
913
+ /**
914
+ * Z.AI and Neuralwatt advertise GLM reasoning as a bare boolean, which would otherwise
915
+ * collapse to the four-tier default ladder that omits `max`. These two helpers name the
916
+ * ladder each GLM generation actually honours on the wire.
917
+ */
918
+ /** GLM-5.2 and its 1M alias: the full five-tier ladder including `max`. */
276
919
  export function isGlm52ModelId(id: string): boolean {
277
- const normalized = id.toLowerCase();
920
+ const normalized = id.trim().toLowerCase();
278
921
  return normalized === "glm-5.2" || normalized === "glm-5.2[1m]";
279
922
  }
923
+ /**
924
+ * GLM-5.3 and its 1M alias. 260814: docs.z.ai/devpack/latest-model folds every incoming
925
+ * effort into three effective tiers (low/minimal/light -> low, medium/high -> high,
926
+ * xhigh/max/ultra -> max), so a boolean capability must not be expanded to five rows.
927
+ */
928
+ export function isGlm53ModelId(id: string): boolean {
929
+ const normalized = id.trim().toLowerCase();
930
+ return normalized === "glm-5.3" || normalized === "glm-5.3[1m]";
931
+ }
280
932
 
281
933
  function plainRecord(value: unknown): Record<string, unknown> | undefined {
282
934
  return value !== null && typeof value === "object" && !Array.isArray(value)
@@ -313,7 +965,9 @@ function normalizedStringList(value: unknown, maxItems = 32, maxLength = 64): st
313
965
  function modelCapabilities(item: ProviderModelsApiItem): string[] | undefined {
314
966
  const metadata = plainRecord(item.metadata);
315
967
  const metadataCapabilities = metadata?.capabilities;
316
- const capabilityRecord = plainRecord(metadataCapabilities) ?? plainRecord(item.capabilities);
968
+ const capabilityRecord = plainRecord(metadataCapabilities)
969
+ ?? plainRecord(item.capabilities)
970
+ ?? plainRecord(item.features);
317
971
  const out = new Set<string>();
318
972
  for (const list of [item.capabilities, item.features, item.supported_features, metadataCapabilities]) {
319
973
  for (const capability of normalizedStringList(list) ?? []) out.add(capability);
@@ -343,7 +997,9 @@ function modelInputModalities(
343
997
  capabilities: readonly string[] | undefined,
344
998
  ): string[] | undefined {
345
999
  const metadata = plainRecord(item.metadata);
346
- const capabilityRecord = plainRecord(metadata?.capabilities) ?? plainRecord(item.capabilities);
1000
+ const capabilityRecord = plainRecord(metadata?.capabilities)
1001
+ ?? plainRecord(item.capabilities)
1002
+ ?? plainRecord(item.features);
347
1003
  const explicit = normalizedStringList(
348
1004
  item.input_modalities
349
1005
  ?? item.modalities
@@ -358,8 +1014,26 @@ function modelInputModalities(
358
1014
  value === "text" || value === "image" || value === "audio"
359
1015
  ));
360
1016
  if (explicit && explicit.length > 0) return explicit;
1017
+ const architecture = plainRecord(item.architecture);
1018
+ const architectureModality = typeof architecture?.modality === "string"
1019
+ ? normalizedMetadataString(architecture.modality, 64)
1020
+ : undefined;
1021
+ if (architectureModality?.includes("->")) {
1022
+ const [rawInput = ""] = architectureModality.split("->");
1023
+ const inferred = rawInput
1024
+ .split("+")
1025
+ .filter(value => value === "text" || value === "image" || value === "audio");
1026
+ if (inferred.length > 0) return [...new Set(inferred)];
1027
+ }
361
1028
  if (capabilityRecord?.vision === false) return ["text"];
362
- if (capabilityRecord?.vision === true || capabilities?.some(value => value === "vision" || value === "image-input")) {
1029
+ if (capabilityRecord?.vision === true || capabilities?.some(value => (
1030
+ value === "vision" || value === "image-input" || value === "image_input"
1031
+ // llama.cpp and Ollama-compatible servers report vision as "multimodal" —
1032
+ // it is the only image signal those servers emit (#1797). Mapped to the
1033
+ // closed `text|image` enum rather than passed through: an out-of-enum
1034
+ // modality makes Codex reject the entire catalog file.
1035
+ || value === "multimodal"
1036
+ ))) {
363
1037
  return ["text", "image"];
364
1038
  }
365
1039
  return undefined;
@@ -377,17 +1051,35 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid
377
1051
  item.context_size,
378
1052
  item.max_model_len,
379
1053
  item.max_context_length,
1054
+ // llama.cpp reports the served context under `meta`: `n_ctx` is what the
1055
+ // server was actually started with, `n_ctx_train` the model's trained
1056
+ // maximum. Prefer the served value — routing must not promise a window the
1057
+ // running server will refuse. Both come LAST so no provider already
1058
+ // supplying a recognized field changes behavior (#1797).
1059
+ plainRecord(item.meta)?.n_ctx,
1060
+ plainRecord(item.meta)?.n_ctx_train,
380
1061
  );
381
1062
  const maxInputTokens = positiveSafeInteger(limits?.max_input_tokens, item.max_input_tokens);
382
- const rawReasoningEfforts = capabilityRecord?.reasoning_effort ?? item.reasoning_efforts;
1063
+ // Some OpenAI-compatible catalogs expose the selectable ladder under
1064
+ // `reasoning_parameters.efforts` instead of the older `reasoning_efforts` key.
1065
+ // Treat both as model metadata: otherwise a valid upstream capability disappears
1066
+ // before client exporters (including omp) can advertise it.
1067
+ const reasoningParameters = plainRecord(item.reasoning_parameters)
1068
+ ?? plainRecord(metadata?.reasoning_parameters)
1069
+ ?? plainRecord(capabilityRecord?.reasoning_parameters);
1070
+ const rawReasoningEfforts = capabilityRecord?.reasoning_effort
1071
+ ?? item.reasoning_efforts
1072
+ ?? reasoningParameters?.efforts;
383
1073
  const listedReasoningEfforts = normalizedStringList(rawReasoningEfforts, 8, 24);
384
1074
  const reasoningEfforts = listedReasoningEfforts
385
1075
  ? sanitizeCodexReasoningEfforts(listedReasoningEfforts)
386
1076
  : typeof rawReasoningEfforts === "boolean"
387
1077
  ? (rawReasoningEfforts
388
- ? ((providerName === "neuralwatt" || providerName === "zai") && isGlm52ModelId(item.id)
389
- ? ["low", "medium", "high", "xhigh", "max"]
390
- : ["low", "medium", "high", "xhigh"])
1078
+ ? ((providerName === "neuralwatt" || providerName === "zai") && isGlm53ModelId(item.id)
1079
+ ? ["low", "high", "max"]
1080
+ : (providerName === "neuralwatt" || providerName === "zai") && isGlm52ModelId(item.id)
1081
+ ? ["low", "medium", "high", "xhigh", "max"]
1082
+ : ["low", "medium", "high", "xhigh"])
391
1083
  : [])
392
1084
  : undefined;
393
1085
  const capabilities = modelCapabilities(item);
@@ -407,8 +1099,49 @@ function boundedOwnedBy(value: unknown): string | undefined {
407
1099
  return value;
408
1100
  }
409
1101
 
410
- export async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs: number, contextCap?: number): Promise<CatalogModel[]> {
411
- if (prov.authMode === "forward") return []; // ChatGPT backend has no /models
1102
+ const refreshingModelsAuthResolver: ModelsAuthResolver = { kind: "refreshing" };
1103
+
1104
+ function observedModelsAuthResolver(
1105
+ authStoreBuffer: Uint8Array | null,
1106
+ outcomes: CatalogGatherProviderAuthOutcome[],
1107
+ ): ModelsAuthResolver {
1108
+ return {
1109
+ kind: "observed",
1110
+ resolve(name, provider) {
1111
+ if (provider.authMode === "forward") return { apiKey: undefined, observed: true };
1112
+ if (provider.authMode !== "oauth") {
1113
+ return { apiKey: resolveEnvValue(provider.apiKey), observed: true };
1114
+ }
1115
+
1116
+ const observation = observeActiveOAuthAccessToken(name, authStoreBuffer);
1117
+ outcomes.push({ provider: name, state: observation.kind });
1118
+ if (observation.kind !== "available") return { apiKey: undefined, observed: true };
1119
+ return {
1120
+ apiKey: observation.snapshot.accessToken,
1121
+ observed: true,
1122
+ ...(observation.snapshot.apiBaseUrl ? { oauthApiBaseUrl: observation.snapshot.apiBaseUrl } : {}),
1123
+ ...(observation.snapshot.projectId ? { oauthProjectId: observation.snapshot.projectId } : {}),
1124
+ };
1125
+ },
1126
+ };
1127
+ }
1128
+
1129
+ async function fetchProviderModelsWithAuth(
1130
+ captured: CapturedProviderGather,
1131
+ ttlMs: number,
1132
+ contextCap: number | undefined,
1133
+ resolveAuth: ModelsAuthResolver,
1134
+ ): Promise<ProviderModelsResult> {
1135
+ const { name, provider: prov, discovery, request } = captured;
1136
+ const observed = (
1137
+ models: CatalogModel[],
1138
+ state: CatalogGatherProviderModelOutcome["state"],
1139
+ ): ProviderModelsResult => ({ models, outcome: { provider: name, state } });
1140
+ // Capture before any credential refresh or outbound await. OAuth account changes clear this
1141
+ // generation, so a request started with the former account cannot later publish its result.
1142
+ const cacheGeneration = captureModelCacheGeneration(name);
1143
+ const isCurrentCacheGeneration = () => isModelCacheGenerationCurrent(name, cacheGeneration);
1144
+ if (prov.authMode === "forward") return observed([], "authoritative"); // ChatGPT backend has no /models
412
1145
  const seedVertexDefault = prov.adapter === "google"
413
1146
  && prov.googleMode === "vertex"
414
1147
  && (prov.models?.length ?? 0) === 0
@@ -419,13 +1152,48 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
419
1152
  provider: name,
420
1153
  ...catalogHintsFromProviderConfig(name, prov, id, contextCap),
421
1154
  }));
1155
+ const withConfiguredRetention = (
1156
+ models: CatalogModel[],
1157
+ options?: { retainComboTargets?: boolean; warnDrops?: boolean },
1158
+ ): CatalogModel[] => {
1159
+ const { models: merged, droppedConfiguredIds } = mergeConfiguredModelsIntoLiveCatalog({
1160
+ name,
1161
+ provider: prov,
1162
+ models,
1163
+ configured,
1164
+ retainConfiguredModelIds: captured.retainConfiguredModelIds,
1165
+ contextCap,
1166
+ seedVertexDefault,
1167
+ retainComboTargets: options?.retainComboTargets,
1168
+ });
1169
+ if (
1170
+ options?.warnDrops === true
1171
+ && droppedConfiguredIds.length > 0
1172
+ && name !== OPENAI_API_PROVIDER_ID
1173
+ && !QUIET_AUTHORITATIVE_CATALOG_PROVIDERS.has(name)
1174
+ ) {
1175
+ warnDroppedConfiguredIdsOnce(name, droppedConfiguredIds);
1176
+ }
1177
+ return merged;
1178
+ };
422
1179
  // Static catalogs never need an OAuth refresh or an upstream model request. Clear any
423
1180
  // discovery failure left by an older live configuration even when the account is logged out.
424
1181
  if (prov.liveModels === false) {
425
1182
  clearProviderDiscoveryStatus(name);
426
- return configured;
1183
+ return observed(configured, "authoritative");
427
1184
  }
428
- const apiKey = await resolveModelsAuthToken(name, prov);
1185
+ const auth: ModelsAuthResolution = captured.observedAuth ?? (resolveAuth.kind === "refreshing"
1186
+ ? prov.authMode === "oauth" && effectiveGoogleMode(name, prov) === "cloud-code-assist"
1187
+ ? await getValidAccessTokenSnapshot(name)
1188
+ .then(snapshot => ({
1189
+ apiKey: snapshot.accessToken,
1190
+ observed: false,
1191
+ ...(snapshot.projectId ? { oauthProjectId: snapshot.projectId } : {}),
1192
+ }))
1193
+ .catch(() => ({ apiKey: undefined, observed: false }))
1194
+ : { apiKey: await resolveModelsAuthToken(name, prov), observed: false }
1195
+ : resolveAuth.resolve(name, prov));
1196
+ const apiKey = auth.apiKey;
429
1197
  // A configured default is a real callable selector and must remain discoverable when a
430
1198
  // compatible provider's live /models request fails (issue #308). Keep this separate from the
431
1199
  // explicit static list: `liveModels: false` + empty `models[]` intentionally publishes zero
@@ -444,56 +1212,107 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
444
1212
  : models
445
1213
  );
446
1214
  if (prov.adapter === "cursor") {
447
- if (!apiKey) return configured;
1215
+ if (!apiKey) return observed(configured, "degraded");
448
1216
  // Cursor uses a bespoke GetUsableModels RPC (not /models), returning the full effort-suffixed
449
1217
  // variants this PLAN can use. Keep the base-model UX (the request builder appends the effort
450
1218
  // suffix) but filter the static seed to the bases the account actually has — so models not on the
451
1219
  // plan (e.g. claude-fable-5) drop out instead of failing ERROR_BAD_MODEL_NAME. Fall back to the seed.
452
1220
  const cachedCursor = getFreshCached(name, ttlMs);
453
- if (cachedCursor) return applyConfigHintsToCachedModels(name, prov, cachedCursor);
1221
+ if (cachedCursor) {
1222
+ return observed(
1223
+ withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cachedCursor)),
1224
+ "authoritative",
1225
+ );
1226
+ }
454
1227
  if (isModelsFetchCoolingDown(name)) {
455
1228
  const cooling = getStaleCached(name);
456
- return cooling ? applyConfigHintsToCachedModels(name, prov, cooling) : configured;
1229
+ return observed(
1230
+ withConfiguredRetention(
1231
+ cooling ? applyConfigHintsToCachedModels(name, prov, cooling) : configured,
1232
+ ),
1233
+ "degraded",
1234
+ );
457
1235
  }
458
- const liveResult = await fetchCursorUsableModels({ apiKey, baseUrl: prov.baseUrl });
1236
+ const cursorFetch = (prov as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch;
1237
+ const liveResult = await fetchCursorUsableModels({
1238
+ apiKey,
1239
+ baseUrl: prov.baseUrl,
1240
+ upstreamHttpVersion: prov.upstreamHttpVersion,
1241
+ ...(cursorFetch ? { fetch: cursorFetch } : {}),
1242
+ });
459
1243
  if (liveResult.ok) {
460
1244
  const available = filterCursorConfiguredModelsByLiveDiscovery(configured, liveResult.models);
461
1245
  const result = available.length > 0 ? available : configured;
462
- // Count what discovery actually returned, not the configured rows we fall back to.
1246
+ // Cache the discovery-filtered roster without combo retention so a later
1247
+ // gather can re-apply the current capture's retain set on read.
1248
+ const forCache = withConfiguredRetention(result, { retainComboTargets: false });
1249
+ if (!setCached(name, forCache, Date.now(), cacheGeneration)) {
1250
+ return observed(withConfiguredRetention(configured), "degraded");
1251
+ }
463
1252
  markProviderDiscoveryOk(name, liveResult.models.length);
464
- setCached(name, result);
465
- return result;
1253
+ return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative");
1254
+ }
1255
+ if (isCurrentCacheGeneration()) {
1256
+ markModelsFetchFailure(name);
1257
+ markProviderDiscoveryFailed(name, { reason: "provider" });
1258
+ console.warn(
1259
+ `[opencodex] Cursor model discovery for "${name}" failed [${liveResult.error}]${liveResult.detail ? `: ${liveResult.detail}` : ""}; using stale/static catalog degradation.`,
1260
+ );
466
1261
  }
467
- markModelsFetchFailure(name);
468
- markProviderDiscoveryFailed(name, { reason: "provider" });
469
- console.warn(
470
- `[opencodex] Cursor model discovery for "${name}" failed [${liveResult.error}]${liveResult.detail ? `: ${liveResult.detail}` : ""}; using stale/static catalog degradation.`,
471
- );
472
1262
  const staleCursor = getStaleCached(name);
473
- return staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor) : configured;
1263
+ return observed(
1264
+ withConfiguredRetention(
1265
+ staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor) : configured,
1266
+ ),
1267
+ "degraded",
1268
+ );
474
1269
  }
475
1270
  if (prov.authMode === "oauth" && !apiKey) {
476
1271
  // No usable token (logged out, or account marked needsReauth). Still surface the
477
1272
  // configured static catalog so the GUI Models tab / rail counts are not empty —
478
1273
  // matching Cursor's !apiKey → configured degradation and fetch-failure fallback.
479
- return configured;
1274
+ return observed(configured, "degraded");
480
1275
  }
1276
+ const cloudCodeAssist = effectiveGoogleMode(name, prov) === "cloud-code-assist";
1277
+ const project = prov.project ?? auth.oauthProjectId;
1278
+ if (cloudCodeAssist && !project) return observed(configured, "degraded");
481
1279
  const fresh = getFreshCached(name, ttlMs);
482
- if (fresh) return withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, fresh, contextCap)); // dedups Codex's frequent /v1/models polling within the TTL
1280
+ if (fresh) {
1281
+ return observed(
1282
+ withConfiguredRetention(
1283
+ withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, fresh, contextCap)),
1284
+ ),
1285
+ "authoritative",
1286
+ ); // dedups Codex's frequent /v1/models polling within the TTL
1287
+ }
483
1288
  if (isModelsFetchCoolingDown(name)) {
484
1289
  // A recently-failed provider (unreachable API, missing proxy, bad key) must not re-pay the
485
1290
  // fetch timeout on every catalog poll — the dashboard polls this path per page load.
486
1291
  const stale = getStaleCached(name);
487
- return stale ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) : failedDiscoveryConfigured;
1292
+ return observed(
1293
+ withConfiguredRetention(
1294
+ stale
1295
+ ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap))
1296
+ : failedDiscoveryConfigured,
1297
+ ),
1298
+ "degraded",
1299
+ );
488
1300
  }
489
- const discovery = resolveProviderModelDiscovery(name, prov);
490
- const { url, headers } = buildModelsRequest(prov, apiKey, name);
1301
+ const url = request.url;
1302
+ const headers = materializeCapturedHeaders(request, apiKey);
491
1303
  const urlClass = new URL(url).hostname.endsWith("aiplatform.googleapis.com")
492
1304
  ? "vertex-aiplatform"
493
1305
  : "provider-models";
494
1306
  const failedDiscoveryFallback = (
495
1307
  failure: ProviderModelDiscoveryFailure,
496
1308
  ): { models: CatalogModel[]; fallback: "stale" | "configured"; shouldLog: boolean } => {
1309
+ if (!isCurrentCacheGeneration()) {
1310
+ return {
1311
+ models: withConfiguredRetention(failedDiscoveryConfigured),
1312
+ fallback: "configured",
1313
+ shouldLog: false,
1314
+ };
1315
+ }
497
1316
  // Decide logging BEFORE recording the new status, so we can compare against the prior one and
498
1317
  // suppress an identical repeated failure (#395 log flood). The failure stays observable via the
499
1318
  // discovery-status API regardless.
@@ -502,18 +1321,26 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
502
1321
  markProviderDiscoveryFailed(name, failure);
503
1322
  const stale = getStaleCached(name);
504
1323
  return {
505
- models: stale
506
- ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap))
507
- : failedDiscoveryConfigured,
1324
+ models: withConfiguredRetention(
1325
+ stale
1326
+ ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap))
1327
+ : failedDiscoveryConfigured,
1328
+ ),
508
1329
  fallback: stale ? "stale" : "configured",
509
1330
  shouldLog,
510
1331
  };
511
1332
  };
512
1333
  try {
513
- const res = await providerOutboundGet(name, prov, url, {
514
- headers,
515
- signal: AbortSignal.timeout(8000),
516
- });
1334
+ const res = request.method === "POST"
1335
+ ? await providerOutboundPost(name, prov, url, {
1336
+ headers,
1337
+ body: JSON.stringify({ project }),
1338
+ signal: AbortSignal.timeout(8000),
1339
+ })
1340
+ : await providerOutboundGet(name, prov, url, {
1341
+ headers,
1342
+ signal: AbortSignal.timeout(8000),
1343
+ });
517
1344
  const redirectError = await providerRedirectError(res, url);
518
1345
  if (redirectError) {
519
1346
  const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status });
@@ -522,7 +1349,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
522
1349
  `[opencodex] Provider model discovery for "${name}" ${redirectError} [urlClass=${urlClass}, fallback=${fallback}].`,
523
1350
  );
524
1351
  }
525
- return models;
1352
+ return observed(models, "degraded");
526
1353
  }
527
1354
  if (!res.ok) {
528
1355
  const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status });
@@ -531,7 +1358,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
531
1358
  `[opencodex] Provider model discovery for "${name}" failed with HTTP ${res.status} [urlClass=${urlClass}, fallback=${fallback}].`,
532
1359
  );
533
1360
  }
534
- return models;
1361
+ return observed(models, "degraded");
535
1362
  }
536
1363
 
537
1364
  const contentType = (
@@ -550,7 +1377,40 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
550
1377
  `[opencodex] Provider model discovery for "${name}" ${diagnostic} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
551
1378
  );
552
1379
  }
553
- return models;
1380
+ return observed(models, "degraded");
1381
+ }
1382
+ const antigravity = cloudCodeAssist
1383
+ ? parseAntigravityAvailableModels(bounded.value, discovery.maxModels)
1384
+ : undefined;
1385
+ if (cloudCodeAssist && !antigravity) {
1386
+ const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" });
1387
+ if (shouldLog) {
1388
+ console.warn(
1389
+ `[opencodex] Provider model discovery for "${name}" returned malformed CCA model data [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
1390
+ );
1391
+ }
1392
+ return observed(models, "degraded");
1393
+ }
1394
+ if (antigravity) {
1395
+ const live = antigravity.map(model => applyProviderConfigHints(name, prov, {
1396
+ id: model.id,
1397
+ provider: name,
1398
+ // CCA only exposes a numeric thinking budget. Until the adapter owns an exact Codex
1399
+ // effort-to-wire mapping for a newly discovered model, do not advertise a false ladder.
1400
+ reasoningEfforts: [],
1401
+ ...(model.contextWindow ? { contextWindow: model.contextWindow } : {}),
1402
+ ...(model.inputModalities ? { inputModalities: model.inputModalities } : {}),
1403
+ }, contextCap));
1404
+ const forCache = withConfiguredRetention(live, { retainComboTargets: false });
1405
+ if (!setCached(name, forCache, Date.now(), cacheGeneration)) {
1406
+ return observed(withConfiguredRetention(configured), "degraded");
1407
+ }
1408
+ registerAntigravityDiscoveredWireModels(prov.baseUrl, antigravity, {
1409
+ provider: name,
1410
+ cacheGeneration,
1411
+ });
1412
+ markProviderDiscoveryOk(name, live.length);
1413
+ return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative");
554
1414
  }
555
1415
  const extracted = extractProviderModelItems(bounded.value, discovery);
556
1416
  if (!extracted.ok) {
@@ -566,7 +1426,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
566
1426
  `[opencodex] Provider model discovery for "${name}" ${diagnostic[extracted.reason]} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
567
1427
  );
568
1428
  }
569
- return models;
1429
+ return observed(models, "degraded");
570
1430
  }
571
1431
  const items = extracted.items;
572
1432
  const live = items.map(m => {
@@ -582,37 +1442,24 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
582
1442
  // Capture the count BEFORE the alias/configured augmentation below pushes extra rows into
583
1443
  // `live`; otherwise configured entries would be reported as discovered ones.
584
1444
  const liveModelCount = live.length;
585
- const liveIds = new Set(live.map(m => m.id));
586
- // Dated-release aliases (Anthropic pattern): older models may appear in the live catalog
587
- // ONLY under their dated id (claude-haiku-4-5-20251001) while the config names the
588
- // API-valid alias (claude-haiku-4-5). Such aliases are real, callable models keep them
589
- // in the authoritative catalog (alias id, hints from the dated live entry) instead of
590
- // dropping them and warning on every poll.
591
- const droppedConfiguredIds: string[] = [];
592
- for (const m of configured) {
593
- if (liveIds.has(m.id)) continue;
594
- const dated = live.find(l => isDatedVariantId(l.id, m.id));
595
- if (dated) {
596
- // Reapply config hints so alias-keyed overrides (modelContextWindows etc.) win.
597
- live.push(applyProviderConfigHints(name, prov, { ...dated, id: m.id }, contextCap));
598
- } else if (seedVertexDefault || shouldRetainConfiguredProviderModel(name, m.id)) {
599
- live.push(m);
600
- } else {
601
- droppedConfiguredIds.push(m.id);
602
- }
603
- }
604
- if (live.length === 0 && name !== OPENAI_API_PROVIDER_ID) {
1445
+ // Dated-release aliases + configured retention (compat allow-list, combo targets,
1446
+ // Vertex default). Cache without combo retention so a later gather re-applies the
1447
+ // current capture's retain set on read (warm-cache OCX-111 / #1308).
1448
+ const forCache = withConfiguredRetention(live, { retainComboTargets: false });
1449
+ const returned = withConfiguredRetention(forCache, { warnDrops: true });
1450
+ const droppedConfiguredIds = configured
1451
+ .map(model => model.id)
1452
+ .filter(id => !returned.some(model => model.id === id));
1453
+ if (returned.length === 0 && name !== OPENAI_API_PROVIDER_ID) {
605
1454
  console.warn(
606
1455
  `[opencodex] Provider model discovery for "${name}" returned an authoritative empty catalog; ${droppedConfiguredIds.length > 0 ? `dropping configured model ids: ${droppedConfiguredIds.join(", ")}` : "no models will be exposed"}.`,
607
1456
  );
608
- } else if (droppedConfiguredIds.length > 0
609
- && name !== OPENAI_API_PROVIDER_ID
610
- && !QUIET_AUTHORITATIVE_CATALOG_PROVIDERS.has(name)) {
611
- warnDroppedConfiguredIdsOnce(name, droppedConfiguredIds);
1457
+ }
1458
+ if (!setCached(name, forCache, Date.now(), cacheGeneration)) {
1459
+ return observed(withConfiguredRetention(configured), "degraded");
612
1460
  }
613
1461
  markProviderDiscoveryOk(name, liveModelCount);
614
- setCached(name, live);
615
- return live;
1462
+ return observed(returned, "authoritative");
616
1463
  } catch (error) {
617
1464
  if (error instanceof ProviderOutboundPolicyError) {
618
1465
  const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "blocked" });
@@ -621,7 +1468,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
621
1468
  `[opencodex] Provider model discovery for "${name}" was blocked by destination policy: ${error.message} [urlClass=${urlClass}, fallback=${fallback}].`,
622
1469
  );
623
1470
  }
624
- return models;
1471
+ return observed(models, "degraded");
625
1472
  }
626
1473
  const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "network" });
627
1474
  if (shouldLog) {
@@ -629,10 +1476,25 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
629
1476
  `[opencodex] Provider model discovery for "${name}" threw ${error instanceof Error ? error.name : "unknown"} [urlClass=${urlClass}, fallback=${fallback}].`,
630
1477
  );
631
1478
  }
632
- return models;
1479
+ return observed(models, "degraded");
633
1480
  }
634
1481
  }
635
1482
 
1483
+ export async function fetchProviderModels(
1484
+ name: string,
1485
+ prov: OcxProviderConfig,
1486
+ ttlMs: number,
1487
+ contextCap?: number,
1488
+ ): Promise<CatalogModel[]> {
1489
+ const captured = captureProviderGather(name, prov, refreshingModelsAuthResolver);
1490
+ return (await fetchProviderModelsWithAuth(
1491
+ captured,
1492
+ ttlMs,
1493
+ contextCap,
1494
+ refreshingModelsAuthResolver,
1495
+ )).models;
1496
+ }
1497
+
636
1498
  export function shouldExposeProviderModel(providerName: string, modelId: string): boolean {
637
1499
  if (providerName === "opencode-free") return modelId === "big-pickle" || modelId.endsWith("-free");
638
1500
  return true;
@@ -644,6 +1506,60 @@ export function shouldRetainConfiguredProviderModel(providerName: string, modelI
644
1506
  return false;
645
1507
  }
646
1508
 
1509
+ /**
1510
+ * Fold dated-release aliases and retain configured rows that must survive an
1511
+ * authoritative live roster (compatibility allow-list, combo targets, Vertex
1512
+ * default). Used on every discovery return — live, fresh cache, stale, and
1513
+ * failure fallback — so a warm cache captured before a combo existed still
1514
+ * surfaces the configured target (OCX-111 / #1308).
1515
+ *
1516
+ * Cache writes should pass `retainComboTargets: false` so combo retention is
1517
+ * re-applied on read against the current capture, not frozen into the TTL entry.
1518
+ */
1519
+ export function mergeConfiguredModelsIntoLiveCatalog(opts: {
1520
+ name: string;
1521
+ provider: OcxProviderConfig;
1522
+ models: readonly CatalogModel[];
1523
+ configured: readonly CatalogModel[];
1524
+ retainConfiguredModelIds?: ReadonlySet<string>;
1525
+ contextCap?: number;
1526
+ seedVertexDefault?: boolean;
1527
+ retainComboTargets?: boolean;
1528
+ }): { models: CatalogModel[]; droppedConfiguredIds: string[] } {
1529
+ const {
1530
+ name,
1531
+ provider: prov,
1532
+ configured,
1533
+ retainConfiguredModelIds,
1534
+ contextCap,
1535
+ seedVertexDefault,
1536
+ retainComboTargets = true,
1537
+ } = opts;
1538
+ const out = [...opts.models];
1539
+ const present = new Set(out.map(model => model.id));
1540
+ const droppedConfiguredIds: string[] = [];
1541
+ for (const candidate of configured) {
1542
+ if (present.has(candidate.id)) continue;
1543
+ const dated = out.find(live => isDatedVariantId(live.id, candidate.id));
1544
+ if (dated) {
1545
+ out.push(applyProviderConfigHints(name, prov, { ...dated, id: candidate.id }, contextCap));
1546
+ present.add(candidate.id);
1547
+ continue;
1548
+ }
1549
+ if (
1550
+ seedVertexDefault === true
1551
+ || shouldRetainConfiguredProviderModel(name, candidate.id)
1552
+ || (retainComboTargets && retainConfiguredModelIds?.has(candidate.id) === true)
1553
+ ) {
1554
+ out.push(candidate);
1555
+ present.add(candidate.id);
1556
+ continue;
1557
+ }
1558
+ droppedConfiguredIds.push(candidate.id);
1559
+ }
1560
+ return { models: out, droppedConfiguredIds };
1561
+ }
1562
+
647
1563
  export function filterCatalogVisibleModels(
648
1564
  models: CatalogModel[],
649
1565
  config: Pick<OcxConfig, "disabledModels" | "providers">,
@@ -655,11 +1571,12 @@ export function filterCatalogVisibleModels(
655
1571
  if (Array.isArray(sel) && sel.length > 0) allowByProvider.set(name, new Set(sel));
656
1572
  }
657
1573
  return models.filter(m => {
1574
+ const nativeAlias = m.provider === COMBO_NAMESPACE && m.nativeAlias === true;
658
1575
  // disabledModels may be stored raw (canonical) or encoded (legacy UI writes).
659
1576
  for (const stored of disabled) {
660
1577
  // Combo management stores the public alias, while canonical `combo/<id>` references
661
1578
  // remain valid for backward compatibility through slugEquals below.
662
- if (m.alias !== undefined && stored === catalogModelSlug(m)) return false;
1579
+ if (m.alias !== undefined && stored === catalogModelSlug(m) && !nativeAlias) return false;
663
1580
  if (slugEquals(stored, m.provider, m.id)) return false;
664
1581
  }
665
1582
  const allow = allowByProvider.get(m.provider);
@@ -667,37 +1584,261 @@ export function filterCatalogVisibleModels(
667
1584
  });
668
1585
  }
669
1586
 
1587
+
1588
+ /**
1589
+ * Local-only model list for one provider: fresh TTL cache, else stale last-good,
1590
+ * else configured/static seeds. Never performs network I/O.
1591
+ *
1592
+ * `needsLive` is true when a background discovery should still run (no fresh
1593
+ * cache and not in failure cooldown). Cooling-down providers stay on their
1594
+ * fallback so a dead upstream cannot re-stall every management page load.
1595
+ */
1596
+ export function peekProviderModelsLocal(
1597
+ name: string,
1598
+ prov: OcxProviderConfig,
1599
+ ttlMs: number,
1600
+ contextCap?: number,
1601
+ ): { models: CatalogModel[]; needsLive: boolean } {
1602
+ if (prov.authMode === "forward") return { models: [], needsLive: false };
1603
+
1604
+ const seedVertexDefault = prov.adapter === "google"
1605
+ && prov.googleMode === "vertex"
1606
+ && (prov.models?.length ?? 0) === 0
1607
+ && Boolean(prov.defaultModel);
1608
+ const configuredIds = seedVertexDefault && prov.defaultModel
1609
+ ? [prov.defaultModel]
1610
+ : (prov.models ?? []);
1611
+ const configured: CatalogModel[] = configuredIds.map(id => ({
1612
+ id,
1613
+ provider: name,
1614
+ ...catalogHintsFromProviderConfig(name, prov, id, contextCap),
1615
+ }));
1616
+
1617
+ if (prov.liveModels === false) {
1618
+ return { models: configured, needsLive: false };
1619
+ }
1620
+
1621
+ const failedDiscoveryConfigured = configured.length > 0 || !prov.defaultModel || prov.adapter !== "anthropic"
1622
+ ? configured
1623
+ : [{
1624
+ id: prov.defaultModel!,
1625
+ provider: name,
1626
+ ...catalogHintsFromProviderConfig(name, prov, prov.defaultModel!, contextCap),
1627
+ }];
1628
+
1629
+ const vertexDefaultSeed = seedVertexDefault ? configured[0] : undefined;
1630
+ const withVertexDefaultSeed = (models: CatalogModel[]): CatalogModel[] => (
1631
+ vertexDefaultSeed && !models.some(model => model.id === vertexDefaultSeed.id)
1632
+ ? [...models, vertexDefaultSeed]
1633
+ : models
1634
+ );
1635
+
1636
+ const fresh = getFreshCached(name, ttlMs);
1637
+ if (fresh) {
1638
+ return {
1639
+ models: withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, fresh, contextCap)),
1640
+ needsLive: false,
1641
+ };
1642
+ }
1643
+
1644
+ if (isModelsFetchCoolingDown(name)) {
1645
+ const cooling = getStaleCached(name);
1646
+ return {
1647
+ models: cooling
1648
+ ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, cooling, contextCap))
1649
+ : failedDiscoveryConfigured,
1650
+ needsLive: false,
1651
+ };
1652
+ }
1653
+
1654
+ const stale = getStaleCached(name);
1655
+ if (stale) {
1656
+ return {
1657
+ models: withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)),
1658
+ // Stale-while-revalidate: paint last-good now, refresh in the background.
1659
+ needsLive: true,
1660
+ };
1661
+ }
1662
+
1663
+ return { models: failedDiscoveryConfigured, needsLive: true };
1664
+ }
1665
+
1666
+ /** True when any enabled live provider still needs a network `/models` probe. */
1667
+ export function catalogGatherNeedsLiveRefresh(config: OcxConfig): boolean {
1668
+ const ttlMs = config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS;
1669
+ for (const [name, prov] of Object.entries(config.providers)) {
1670
+ if (prov.disabled === true) continue;
1671
+ const enriched = { ...prov };
1672
+ enrichProviderFromRegistry(name, enriched);
1673
+ const { needsLive } = peekProviderModelsLocal(
1674
+ name,
1675
+ enriched,
1676
+ ttlMs,
1677
+ providerContextCap(config, name),
1678
+ );
1679
+ if (needsLive) return true;
1680
+ }
1681
+ return false;
1682
+ }
1683
+
1684
+ /**
1685
+ * Fire-and-forget full gather. Shared so management fast paths kick one flight
1686
+ * without awaiting it (joiners still share gatherInflight).
1687
+ */
1688
+ export function kickGatherRoutedModelsBackground(config: OcxConfig): void {
1689
+ void gatherRoutedModels(config).catch(() => {
1690
+ /* discovery status / cooldown already recorded per provider */
1691
+ });
1692
+ }
1693
+
670
1694
  export async function gatherRoutedModels(
671
1695
  config: OcxConfig,
672
- options?: { comboOmissions?: ComboCatalogOmission[] },
1696
+ options?: GatherRoutedModelsOptions,
673
1697
  ): Promise<CatalogModel[]> {
674
- const key = gatherFlightKey(config);
675
- let promise = gatherInflight.get(key);
676
- if (!promise) {
1698
+ if (options?.preferCached) {
1699
+ const local = await gatherRoutedModelsLocal(config);
1700
+ if (options.comboOmissions) {
1701
+ options.comboOmissions.length = 0;
1702
+ options.comboOmissions.push(...local.comboOmissions);
1703
+ }
1704
+ if (options.providerAuthOutcomes) {
1705
+ options.providerAuthOutcomes.length = 0;
1706
+ options.providerAuthOutcomes.push(...local.providerAuthOutcomes);
1707
+ }
1708
+ if (options.discoveryPolicySnapshots) {
1709
+ options.discoveryPolicySnapshots.length = 0;
1710
+ options.discoveryPolicySnapshots.push(...local.discoveryPolicySnapshots);
1711
+ }
1712
+ if (local.needsLive) kickGatherRoutedModelsBackground(config);
1713
+ return local.models;
1714
+ }
1715
+ return gatherRoutedModelsWithAuth(
1716
+ config,
1717
+ `refreshing:${gatherFlightKey(config)}`,
1718
+ () => refreshingModelsAuthResolver,
1719
+ options,
1720
+ );
1721
+ }
1722
+
1723
+ /**
1724
+ * Catalog-gather model discovery using only auth-store bytes already captured by the
1725
+ * filesystem-evidence owner. This entry point never reaches the refreshing resolver.
1726
+ */
1727
+ export async function gatherRoutedModelsForCatalogGather(
1728
+ config: OcxConfig,
1729
+ evidence: CatalogGatherProviderAuthEvidence,
1730
+ options?: GatherRoutedModelsOptions,
1731
+ ): Promise<CatalogModel[]> {
1732
+ const authStoreBuffer = evidence.authStoreBuffer === null
1733
+ ? null
1734
+ : Uint8Array.from(evidence.authStoreBuffer);
1735
+ const authIdentity = authStoreBuffer === null
1736
+ ? "absent"
1737
+ : keyedGatherBytesIdentity("catalog-observed-auth-v1", authStoreBuffer);
1738
+ return gatherRoutedModelsWithAuth(
1739
+ config,
1740
+ `observed:${authIdentity}:${gatherFlightKey(config)}`,
1741
+ outcomes => observedModelsAuthResolver(authStoreBuffer, outcomes),
1742
+ options,
1743
+ );
1744
+ }
1745
+
1746
+ async function gatherRoutedModelsWithAuth(
1747
+ config: OcxConfig,
1748
+ key: string,
1749
+ createAuthResolver: ModelsAuthResolverFactory,
1750
+ options?: GatherRoutedModelsOptions,
1751
+ ): Promise<CatalogModel[]> {
1752
+ const capture = captureGatherFlight(config, createAuthResolver);
1753
+ const bucket = gatherInflight.get(key) ?? [];
1754
+ let entry = bucket.find(candidate => (
1755
+ candidate.discoveryPolicyIdentity === capture.discoveryPolicyIdentity
1756
+ && candidate.authIdentity === capture.authIdentity
1757
+ && candidate.providerGraphIdentity === capture.providerGraphIdentity
1758
+ ));
1759
+ if (!entry) {
677
1760
  const lease = gatherGate.tryAcquire();
678
1761
  if (!lease) throw new CatalogGatherBusyError();
679
1762
  // Claim the slot synchronously before any await so same-key callers join this flight.
680
- // Distinct keys keep their own entries a second config must not evict the first.
681
- const flight = gatherRoutedModelsUncached(config).finally(() => {
682
- if (gatherInflight.get(key) === flight) gatherInflight.delete(key);
1763
+ // Distinct authorities retain separate entries even when their legacy bucket matches.
1764
+ let ownedEntry!: GatherInflightEntry;
1765
+ const flight = gatherRoutedModelsUncached(config, capture).finally(() => {
1766
+ const current = gatherInflight.get(key);
1767
+ const index = current?.indexOf(ownedEntry) ?? -1;
1768
+ if (current && index >= 0) current.splice(index, 1);
1769
+ if (current?.length === 0) gatherInflight.delete(key);
683
1770
  lease.release();
684
1771
  });
685
- gatherInflight.set(key, flight);
686
- promise = flight;
1772
+ ownedEntry = Object.freeze({
1773
+ discoveryPolicyIdentity: capture.discoveryPolicyIdentity,
1774
+ authIdentity: capture.authIdentity,
1775
+ providerGraphIdentity: capture.providerGraphIdentity,
1776
+ promise: flight,
1777
+ });
1778
+ bucket.push(ownedEntry);
1779
+ gatherInflight.set(key, bucket);
1780
+ entry = ownedEntry;
687
1781
  }
688
- const { models, comboOmissions } = await promise;
1782
+ const {
1783
+ models,
1784
+ comboOmissions,
1785
+ providerAuthOutcomes,
1786
+ providerModelOutcomes,
1787
+ discoveryPolicySnapshots,
1788
+ } = await entry.promise;
689
1789
  if (options?.comboOmissions) {
690
1790
  options.comboOmissions.length = 0;
691
1791
  options.comboOmissions.push(...comboOmissions);
692
1792
  }
1793
+ if (options?.providerAuthOutcomes) {
1794
+ options.providerAuthOutcomes.length = 0;
1795
+ options.providerAuthOutcomes.push(...providerAuthOutcomes);
1796
+ }
1797
+ if (options?.providerModelOutcomes) {
1798
+ options.providerModelOutcomes.length = 0;
1799
+ options.providerModelOutcomes.push(...providerModelOutcomes);
1800
+ }
1801
+ if (options?.discoveryPolicySnapshots) {
1802
+ options.discoveryPolicySnapshots.length = 0;
1803
+ options.discoveryPolicySnapshots.push(...discoveryPolicySnapshots);
1804
+ }
693
1805
  return models;
694
1806
  }
695
1807
 
1808
+
1809
+ /**
1810
+ * Management fast path: assemble from TTL/stale/configured seeds only.
1811
+ * Never hits the network for `/models`; callers may kick a background gather when needsLive.
1812
+ */
1813
+ async function gatherRoutedModelsLocal(
1814
+ config: OcxConfig,
1815
+ ): Promise<GatherFlightResult & { needsLive: boolean }> {
1816
+ const capture = captureGatherFlight(config, () => refreshingModelsAuthResolver);
1817
+ const ttlMs = config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS;
1818
+ let needsLive = false;
1819
+ const lists = capture.providers.map(provider => {
1820
+ const peeked = peekProviderModelsLocal(
1821
+ provider.name,
1822
+ provider.provider,
1823
+ ttlMs,
1824
+ providerContextCap(config, provider.name),
1825
+ );
1826
+ if (peeked.needsLive) needsLive = true;
1827
+ return peeked.models;
1828
+ });
1829
+ const assembled = await gatherRoutedModelsUncached(config, capture, lists);
1830
+ return { ...assembled, needsLive };
1831
+ }
1832
+
696
1833
  async function gatherRoutedModelsUncached(
697
1834
  config: OcxConfig,
1835
+ capture: GatherFlightCapture,
1836
+ precomputedLists?: CatalogModel[][],
698
1837
  ): Promise<GatherFlightResult> {
699
1838
  // Flight-local list: joiners copy from the resolved promise, not a process-global last write.
700
1839
  const localOmissions: ComboCatalogOmission[] = [];
1840
+ const localProviderAuthOutcomes = capture.providerAuthOutcomes;
1841
+ const resolveAuth = capture.authResolver;
701
1842
  const ttlMs = config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS;
702
1843
  // Persisted provider entries can predate newer registry fields (noVisionModels,
703
1844
  // modelInputModalities, ...). The ROUTER merges registry seeds at request time
@@ -705,18 +1846,28 @@ async function gatherRoutedModelsUncached(
705
1846
  // same merged view or its advertisements drift from actual proxy behavior (e.g. a
706
1847
  // vision-sidecar model advertised text-only, blocking image attachments app-side).
707
1848
  // Enrich a CLONE: hydrated defaults must never leak into the persisted config.
708
- const activeProviders = Object.entries(config.providers)
709
- .filter(([, prov]) => prov.disabled !== true)
710
- .map(([name, prov]): [string, OcxProviderConfig] => {
711
- const enriched = { ...prov };
712
- enrichProviderFromRegistry(name, enriched);
713
- return [name, enriched];
714
- });
715
- const lists = await Promise.all(
716
- activeProviders.map(([name, prov]) => fetchProviderModels(name, prov, ttlMs, providerContextCap(config, name))),
1849
+ const activeProviders = capture.providers;
1850
+ const providerResults = precomputedLists
1851
+ ? activeProviders.map((provider, index) => ({
1852
+ models: precomputedLists[index] ?? [],
1853
+ // Local/SWR path: rows come from cache or config seeds, not a live probe.
1854
+ outcome: { provider: provider.name, state: "authoritative" as const },
1855
+ }))
1856
+ : await Promise.all(
1857
+ activeProviders.map(provider => fetchProviderModelsWithAuth(
1858
+ provider,
1859
+ ttlMs,
1860
+ providerContextCap(config, provider.name),
1861
+ resolveAuth,
1862
+ )),
1863
+ );
1864
+ const lists = providerResults.map(result => result.models);
1865
+ const apiAugmented = augmentRoutedModelsWithCapturedOpenAiApiRows(
1866
+ lists.flat(),
1867
+ config,
1868
+ capture.openAiApiPolicy,
717
1869
  );
718
- const apiAugmented = augmentRoutedModelsWithRegistryOpenAiApiRows(lists.flat(), config);
719
- const all = augmentRoutedModelsWithJawcodeMetadata(apiAugmented, activeProviders.map(([name]) => name), config.providers, config)
1870
+ const all = augmentRoutedModelsWithMetadata(apiAugmented, activeProviders.map(provider => provider.name), config.providers, config)
720
1871
  // Drop image/video generation models (e.g. Grok image/video) by default. Cursor's static catalog
721
1872
  // intentionally mirrors Cursor's public model table, including Gemini image preview, so the
722
1873
  // exposure decision goes through shouldExposeRoutedModel (single choke point).
@@ -747,16 +1898,29 @@ async function gatherRoutedModelsUncached(
747
1898
  // configs that will never need it.
748
1899
  } else {
749
1900
  const disabled = disabledNativeSlugs(config);
1901
+ const openaiContextCap = nativeContextLimits(config);
1902
+ const requiredNativeComboTargets = new Set(listComboIds(config).flatMap(id => {
1903
+ const combo = getCombo(config, id);
1904
+ return combo?.targets.flatMap(target => (
1905
+ target.provider === "openai" ? [target.model] : []
1906
+ )) ?? [];
1907
+ }));
750
1908
  for (const slug of nativeOpenAiSlugs()) {
751
- if (disabled.has(slug)) continue;
752
- const contextWindow = nativeOpenAiContextWindow(slug);
1909
+ // A bare native disable key hides the native row, not a combo that targets it.
1910
+ // Keep synthetic native metadata available to those combos.
1911
+ if (disabled.has(slug) && !requiredNativeComboTargets.has(slug)) continue;
1912
+ const contextWindow = nativeOpenAiContextWindow(slug, openaiContextCap);
753
1913
  if (contextWindow === undefined) continue;
754
1914
  const synthetic: CatalogModel = {
755
1915
  provider: "openai",
756
1916
  id: slug,
757
1917
  owned_by: "openai",
758
1918
  contextWindow,
759
- maxInputTokens: contextWindow,
1919
+ // Input limit, not the total window. These coincide for native GPT-5.6 today (the
1920
+ // advertised 922,000 window is already capped at its measured ceiling), but the two
1921
+ // stay separate fields because routed/API rows of the same family run a wider window.
1922
+ // Falls back to the window for slugs with no separate ceiling.
1923
+ maxInputTokens: Math.min(nativeOpenAiMaxInputTokens(slug, openaiContextCap) ?? contextWindow, contextWindow),
760
1924
  inputModalities: nativeInputModalities(slug),
761
1925
  reasoningEfforts: nativeReasoningEfforts(slug),
762
1926
  ...(nativeParallelToolCalls(slug) ? { parallelToolCalls: true } : {}),
@@ -767,51 +1931,200 @@ async function gatherRoutedModelsUncached(
767
1931
  if (!memberByKey.has(key)) memberByKey.set(key, synthetic);
768
1932
  }
769
1933
  }
1934
+ // Enriched (registry-hydrated) provider clones — shared by combo member synthesis and
1935
+ // custom-model vision-sidecar inheritance so both see the same merged registry view.
1936
+ const enrichedByName = new Map(activeProviders.map(provider => [provider.name, provider.provider]));
770
1937
  for (const id of listComboIds(config)) {
771
1938
  const combo = getCombo(config, id);
772
1939
  if (!combo) continue;
1940
+ const nativeContextWindow = combo.nativeAlias && combo.alias
1941
+ ? nativeOpenAiContextWindow(combo.alias, nativeContextLimits(config))
1942
+ : undefined;
1943
+ const nativeAliasMaxInput = combo.nativeAlias && combo.alias
1944
+ ? (combo.alias.startsWith("gpt-5.6-") || combo.alias.includes("daybreak")
1945
+ ? NATIVE_GPT56_MAX_INPUT_TOKENS
1946
+ : nativeOpenAiMaxInputTokens(combo.alias) ?? nativeOpenAiContextWindow(combo.alias))
1947
+ : undefined;
1948
+ const nativeAliasFallback = combo.nativeAlias && combo.alias && nativeContextWindow !== undefined
1949
+ ? {
1950
+ contextWindow: nativeContextWindow,
1951
+ ...(nativeAliasMaxInput !== undefined ? { maxInputTokens: nativeAliasMaxInput } : {}),
1952
+ inputModalities: nativeInputModalities(combo.alias),
1953
+ reasoningEfforts: nativeReasoningEfforts(combo.alias),
1954
+ }
1955
+ : undefined;
773
1956
  const members = combo.targets
774
- .map(target => memberByKey.get(targetKey(target)))
1957
+ .map(target => resolveComboCatalogMember(
1958
+ target,
1959
+ memberByKey,
1960
+ enrichedByName,
1961
+ providerContextCap(config, target.provider),
1962
+ nativeAliasFallback,
1963
+ ))
775
1964
  .filter((member): member is CatalogModel => member !== undefined);
776
1965
  const derived = deriveComboCatalogModel(id, combo, members);
777
- if (derived) all.push(derived);
1966
+ if (derived) {
1967
+ const nativeDefault = combo.nativeAlias && combo.alias
1968
+ ? nativeDefaultReasoningEffort(combo.alias)
1969
+ : undefined;
1970
+ if (combo.defaultEffort === null
1971
+ && nativeDefault
1972
+ && derived.reasoningEfforts?.includes(nativeDefault)) {
1973
+ derived.defaultReasoningEffort = nativeDefault;
1974
+ }
1975
+ all.push(derived);
1976
+ }
778
1977
  else warnUncataloguedComboOnce(id, combo, members, localOmissions);
779
1978
  }
780
1979
  replaceLastComboCatalogOmissions(localOmissions);
781
1980
  all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
782
- // Enriched (registry-hydrated) provider clones, keyed by name the same view used above so
783
- // custom rows get the same noVisionModels / inputModalities treatment as discovered rows.
784
- const enrichedByName = new Map(activeProviders);
1981
+ // Provider-derived rows keyed by their Codex-facing slug: a custom override replaces the row
1982
+ // with the same slug below, so that row's provider capability metadata is the inheritance source.
1983
+ const replacedByRoutedSlug = new Map(all.map(model => [routedSlug(model.provider, model.id), model]));
785
1984
  const customModels = (config.customModels ?? []).map(cm => {
786
1985
  const rawProvider = config.providers[cm.provider];
1986
+ const effectiveProvider = enrichedByName.get(cm.provider) ?? rawProvider;
1987
+ // Registry routing backfills an omitted authMode on the built-in OpenAI provider to
1988
+ // forward. Keep the catalog projection on the same contract while still failing closed
1989
+ // for every explicit non-forward mode and every non-canonical endpoint.
1990
+ const providerForCanonicalCheck = rawProvider
1991
+ ? withCanonicalOpenAiForwardAuthDefault(cm.provider, rawProvider)
1992
+ : undefined;
1993
+ const codexForwardNativeCapabilityAlias = cm.provider === OPENAI_CODEX_PROVIDER_ID
1994
+ && providerForCanonicalCheck !== undefined
1995
+ && isCanonicalOpenAiForwardProvider(providerForCanonicalCheck)
1996
+ && isNativeOpenAiCapabilityAliasModel(cm.modelId);
1997
+ const customNativeLimits = {
1998
+ ...nativeContextLimits(config),
1999
+ ...(typeof cm.contextWindow === "number" && cm.contextWindow > 0
2000
+ ? { modelWindows: { ...(nativeContextLimits(config).modelWindows ?? {}), [cm.modelId]: cm.contextWindow } }
2001
+ : {}),
2002
+ };
2003
+ const nativeAliasContextWindow = codexForwardNativeCapabilityAlias
2004
+ ? nativeOpenAiContextWindow(cm.modelId, customNativeLimits)
2005
+ : undefined;
2006
+ const customContextWindow = cm.contextWindow
2007
+ ? nativeAliasContextWindow !== undefined
2008
+ ? nativeAliasContextWindow
2009
+ : cm.contextWindow
2010
+ : nativeAliasContextWindow;
2011
+ const nativeAliasMaxInputTokens = codexForwardNativeCapabilityAlias
2012
+ ? nativeOpenAiMaxInputTokens(cm.modelId, customNativeLimits)
2013
+ : undefined;
2014
+ const customMaxInputTokens = nativeAliasMaxInputTokens !== undefined && customContextWindow !== undefined
2015
+ ? Math.min(nativeAliasMaxInputTokens, customContextWindow)
2016
+ : nativeAliasMaxInputTokens;
2017
+ const nativeAliasDefaultEffort = codexForwardNativeCapabilityAlias
2018
+ ? nativeDefaultReasoningEffort(cm.modelId)
2019
+ : undefined;
787
2020
  const supportsReasoningSummaries = configuredReasoningSummarySupport(rawProvider, cm.modelId);
2021
+ const fastPolicy = effectiveProvider
2022
+ ? fastPolicyForModel(effectiveProvider, cm.modelId, cm.provider)
2023
+ : undefined;
2024
+ const supportsServiceTier = fastPolicy
2025
+ ? serviceTierSupportFromPolicy(fastPolicy)
2026
+ : undefined;
788
2027
  const base: CatalogModel = {
789
2028
  id: cm.modelId,
790
2029
  provider: cm.provider,
2030
+ catalogKind: CODEX_CUSTOM_MODEL_CATALOG_KIND,
791
2031
  // Display-only label: never feeds routing (customModels are keyed by routedSlug below).
792
- ...(cm.displayName ? { displayName: cm.displayName } : {}),
793
- ...(cm.contextWindow ? { contextWindow: cm.contextWindow } : {}),
794
- ...(cm.inputModalities ? { inputModalities: cm.inputModalities } : {}),
2032
+ ...(cm.displayName
2033
+ ? { displayName: cm.displayName }
2034
+ : codexForwardNativeCapabilityAlias ? { displayName: "Daybreak Blue" } : {}),
2035
+ ...(customContextWindow !== undefined ? { contextWindow: customContextWindow } : {}),
2036
+ ...(customMaxInputTokens !== undefined ? { maxInputTokens: customMaxInputTokens } : {}),
2037
+ ...(cm.inputModalities
2038
+ ? { inputModalities: cm.inputModalities }
2039
+ : codexForwardNativeCapabilityAlias ? { inputModalities: nativeInputModalities(cm.modelId) } : {}),
795
2040
  ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}),
2041
+ // Native-alias defaults apply only where the custom row declares nothing: the explicit
2042
+ // spreads below must win (later in object order), so a stored `[]` stays empty and a
2043
+ // declared ladder is never replaced by the alias's native ladder.
2044
+ ...(codexForwardNativeCapabilityAlias
2045
+ ? {
2046
+ codexForwardNativeCapabilityAlias: true,
2047
+ parallelToolCalls: nativeParallelToolCalls(cm.modelId),
2048
+ ...(Array.isArray(cm.reasoningEfforts)
2049
+ ? {}
2050
+ : {
2051
+ reasoningEfforts: nativeReasoningEfforts(cm.modelId),
2052
+ ...(nativeAliasDefaultEffort ? { defaultReasoningEffort: nativeAliasDefaultEffort } : {}),
2053
+ }),
2054
+ }
2055
+ : {}),
2056
+ // Explicit custom-row ladder wins over the inherited provider row below: the merge only
2057
+ // gap-fills, so a stored `[]` (explicit "no reasoning") or a declared ladder is kept
2058
+ // verbatim instead of being replaced by the replaced row's metadata.
2059
+ ...(Array.isArray(cm.reasoningEfforts) ? { reasoningEfforts: [...cm.reasoningEfforts] } : {}),
2060
+ ...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}),
2061
+ ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}),
2062
+ ...(supportsServiceTier === true && fastPolicy?.fastTierDescription !== undefined
2063
+ ? { fastTierDescription: fastPolicy.fastTierDescription }
2064
+ : {}),
2065
+ ...(cm.codexToolMode !== undefined
2066
+ ? { codexToolMode: cm.codexToolMode }
2067
+ : effectiveProvider?.codexToolMode !== undefined
2068
+ ? { codexToolMode: effectiveProvider.codexToolMode }
2069
+ : {}),
796
2070
  };
2071
+ // #962: the dedupe below drops the provider-derived row this custom row replaces. Inherit that
2072
+ // row's provider capability metadata (reasoning ladder, default effort, parallel tool calls,
2073
+ // context, ...) so the generated catalog keeps advertising what the router actually provides.
2074
+ // Explicit custom fields win by construction; this only fills gaps. Without it a
2075
+ // noReasoningModels model loses its empty ladder and the catalog synthesizes the generic one,
2076
+ // which Codex then rejects for spawn_agent with effort "none".
2077
+ const replaced = replacedByRoutedSlug.get(routedSlug(cm.provider, cm.modelId));
2078
+ // The final ladder is what the catalog will advertise; the inherited default only rides
2079
+ // along when it is actually a member — otherwise a provider default like "xhigh" would
2080
+ // re-apply onto a narrower custom ladder and override the fallback in applyReasoningLevels.
2081
+ const effectiveLadder = base.reasoningEfforts ?? replaced?.reasoningEfforts;
2082
+ const merged: CatalogModel = replaced ? {
2083
+ ...base,
2084
+ ...(base.contextWindow === undefined && replaced.contextWindow !== undefined ? { contextWindow: replaced.contextWindow } : {}),
2085
+ ...(base.maxInputTokens === undefined && replaced.maxInputTokens !== undefined ? { maxInputTokens: replaced.maxInputTokens } : {}),
2086
+ ...(base.inputModalities === undefined && replaced.inputModalities !== undefined ? { inputModalities: replaced.inputModalities } : {}),
2087
+ ...(base.reasoningEfforts === undefined && replaced.reasoningEfforts !== undefined ? { reasoningEfforts: replaced.reasoningEfforts } : {}),
2088
+ ...(base.defaultReasoningEffort === undefined && replaced.defaultReasoningEffort !== undefined
2089
+ && Array.isArray(effectiveLadder) && effectiveLadder.includes(replaced.defaultReasoningEffort)
2090
+ ? { defaultReasoningEffort: replaced.defaultReasoningEffort } : {}),
2091
+ ...(base.parallelToolCalls === undefined && replaced.parallelToolCalls !== undefined ? { parallelToolCalls: replaced.parallelToolCalls } : {}),
2092
+ ...(base.supportsVerbosity === undefined && replaced.supportsVerbosity !== undefined ? { supportsVerbosity: replaced.supportsVerbosity } : {}),
2093
+ ...(base.supportsReasoningSummaries === undefined && replaced.supportsReasoningSummaries !== undefined ? { supportsReasoningSummaries: replaced.supportsReasoningSummaries } : {}),
2094
+ ...(base.codexToolMode === undefined && replaced.codexToolMode !== undefined ? { codexToolMode: replaced.codexToolMode } : {}),
2095
+ ...(base.capabilities === undefined && replaced.capabilities !== undefined ? { capabilities: replaced.capabilities } : {}),
2096
+ } : base;
797
2097
  // Vision-sidecar coverage ONLY: if the custom model is in the enriched provider's
798
2098
  // noVisionModels, advertise image input so the Codex app lets images reach the sidecar
799
2099
  // (#349/#344). Deliberately NOT the full applyProviderConfigHints pass — custom rows are a
800
2100
  // user override, so their explicit contextWindow / inputModalities / reasoning fields must be
801
2101
  // preserved verbatim (the hint pass would cap context and overwrite modalities from registry).
802
2102
  const enrichedProvider = enrichedByName.get(cm.provider) ?? rawProvider;
803
- if (enrichedProvider && modelInList(enrichedProvider.noVisionModels, base.id)) {
804
- const current = base.inputModalities ?? ["text"];
2103
+ if (enrichedProvider && modelInList(enrichedProvider.noVisionModels, merged.id)) {
2104
+ const current = merged.inputModalities ?? ["text"];
805
2105
  if (!current.includes("image")) {
806
- return { ...base, inputModalities: [...current, "image"] };
2106
+ return { ...merged, inputModalities: [...current, "image"] };
807
2107
  }
808
2108
  }
809
- return base;
2109
+ return merged;
810
2110
  });
811
2111
  // Custom rows override discovered rows that encode to the same Codex-facing slug.
812
2112
  const customKeys = new Set(customModels.map(c => routedSlug(c.provider, c.id)));
813
2113
  const deduped = all.filter(m => !customKeys.has(routedSlug(m.provider, m.id)));
814
- return { models: [...deduped, ...customModels], comboOmissions: localOmissions };
2114
+ const providerModelOutcomes = providerResults.map(result => (
2115
+ result.outcome.provider === OPENAI_API_PROVIDER_ID
2116
+ && capture.openAiApiPolicy.state === "captured"
2117
+ && capture.openAiApiPolicy.models !== undefined
2118
+ ? { provider: result.outcome.provider, state: "authoritative" as const }
2119
+ : result.outcome
2120
+ ));
2121
+ return {
2122
+ models: [...deduped, ...customModels],
2123
+ comboOmissions: localOmissions,
2124
+ providerAuthOutcomes: localProviderAuthOutcomes,
2125
+ providerModelOutcomes,
2126
+ discoveryPolicySnapshots: capture.discoveryPolicySnapshots,
2127
+ };
815
2128
  }
816
2129
 
817
2130
  export function augmentRoutedModelsWithRegistryOpenAiApiRows(
@@ -820,15 +2133,28 @@ export function augmentRoutedModelsWithRegistryOpenAiApiRows(
820
2133
  ): CatalogModel[] {
821
2134
  const configured = config.providers[OPENAI_API_PROVIDER_ID];
822
2135
  if (!configured || configured.disabled === true || !providerMatchesRegistryTransport(OPENAI_API_PROVIDER_ID, configured)) return models;
823
- const entry = getProviderRegistryEntry(OPENAI_API_PROVIDER_ID);
824
- if (!entry?.models) return models;
2136
+ return augmentRoutedModelsWithCapturedOpenAiApiRows(
2137
+ models,
2138
+ config,
2139
+ captureTrustedOpenAiApiPolicy(OPENAI_API_PROVIDER_ID, true),
2140
+ );
2141
+ }
2142
+
2143
+ function augmentRoutedModelsWithCapturedOpenAiApiRows(
2144
+ models: CatalogModel[],
2145
+ config: OcxConfig,
2146
+ policy: CatalogTrustedOpenAiApiPolicySnapshot,
2147
+ ): CatalogModel[] {
2148
+ if (policy.state !== "captured" || !policy.models) return models;
2149
+ const configured = config.providers[OPENAI_API_PROVIDER_ID];
2150
+ if (!configured || configured.disabled === true) return models;
825
2151
 
826
2152
  const existingById = new Map(
827
2153
  models.filter(model => model.provider === OPENAI_API_PROVIDER_ID).map(model => [model.id, model]),
828
2154
  );
829
- const trustedRows = entry.models.map((id): CatalogModel => {
830
- const officialContext = entry.modelContextWindows?.[id];
831
- const officialMaxInput = entry.modelMaxInputTokens?.[id];
2155
+ const trustedRows = policy.models.map((id): CatalogModel => {
2156
+ const officialContext = policy.modelContextWindows?.[id];
2157
+ const officialMaxInput = policy.modelMaxInputTokens?.[id];
832
2158
  const userContext = configured.modelContextWindows?.[id] ?? configured.contextWindow;
833
2159
  const userMaxInput = configured.modelMaxInputTokens?.[id];
834
2160
  const providerCap = providerContextCap(config, OPENAI_API_PROVIDER_ID);
@@ -844,8 +2170,8 @@ export function augmentRoutedModelsWithRegistryOpenAiApiRows(
844
2170
  owned_by: OPENAI_API_PROVIDER_ID,
845
2171
  ...(contextWindow ? { contextWindow } : {}),
846
2172
  ...(maxInputTokens ? { maxInputTokens } : {}),
847
- ...(entry.modelInputModalities?.[id] ? { inputModalities: [...entry.modelInputModalities[id]!] } : {}),
848
- ...(entry.modelReasoningEfforts?.[id] ? { reasoningEfforts: [...entry.modelReasoningEfforts[id]!] } : {}),
2173
+ ...(policy.modelInputModalities?.[id] ? { inputModalities: [...policy.modelInputModalities[id]!] } : {}),
2174
+ ...(policy.modelReasoningEfforts?.[id] ? { reasoningEfforts: [...policy.modelReasoningEfforts[id]!] } : {}),
849
2175
  };
850
2176
  });
851
2177
 
@@ -867,7 +2193,7 @@ export function augmentRoutedModelsWithRegistryOpenAiApiRows(
867
2193
  ];
868
2194
  }
869
2195
 
870
- export function augmentRoutedModelsWithJawcodeMetadata(
2196
+ export function augmentRoutedModelsWithMetadata(
871
2197
  models: CatalogModel[],
872
2198
  providerNames: string[],
873
2199
  providers?: Record<string, OcxProviderConfig>,
@@ -878,9 +2204,9 @@ export function augmentRoutedModelsWithJawcodeMetadata(
878
2204
  for (const provider of providerNames) {
879
2205
  if (!JAWCODE_CATALOG_AUGMENT_PROVIDERS.has(provider)) continue;
880
2206
  if (providers?.[provider]?.liveModels === false) continue;
881
- const jawcodeProvider = resolveJawcodeProvider(provider);
2207
+ const jawcodeProvider = resolveMetadataProvider(provider);
882
2208
  if (!jawcodeProvider) continue;
883
- for (const meta of listJawcodeModelMetadata(jawcodeProvider)) {
2209
+ for (const meta of listModelMetadata(jawcodeProvider)) {
884
2210
  const key = `${provider}/${meta.id}`;
885
2211
  if (seen.has(key)) continue;
886
2212
  seen.add(key);