@iislee/opencodex 2.11.0 → 2.35.0

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