@bitkyc08/opencodex 2.63.0 → 2.64.0-preview.20260923

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 (262) hide show
  1. package/AGENTS_INSTALL.md +4 -3
  2. package/README.md +46 -33
  3. package/assets/download-linux.svg +10 -0
  4. package/assets/download-macos.svg +10 -0
  5. package/assets/download-windows.svg +10 -0
  6. package/bin/ocx.mjs +12 -4
  7. package/gui/dist/assets/App-D5eN1TID.js +50 -0
  8. package/gui/dist/assets/App-I5AnaSLh.css +1 -0
  9. package/gui/dist/assets/{Tray-CncKDBTp.js → Tray-Bh_ErQDh.js} +1 -1
  10. package/gui/dist/assets/index-BEq4OCOz.js +86 -0
  11. package/gui/dist/assets/index-DiBRuK-d.css +1 -0
  12. package/gui/dist/assets/usage-companion-chart-DBG_37kQ.js +1 -0
  13. package/gui/dist/index.html +2 -2
  14. package/native/remote-workspace-helper/src/protocol.rs +5 -0
  15. package/package.json +4 -1
  16. package/src/adapters/anthropic.ts +66 -7
  17. package/src/adapters/codebuddy/adapter.ts +112 -16
  18. package/src/adapters/codebuddy/mcp-server.ts +180 -0
  19. package/src/adapters/codebuddy/scaffold-guard.ts +25 -8
  20. package/src/adapters/codebuddy/tool-bridge.ts +597 -0
  21. package/src/adapters/coding-agent/protocol.ts +123 -10
  22. package/src/adapters/coding-agent/turn.ts +324 -2
  23. package/src/adapters/command-code-restored-schema.ts +112 -0
  24. package/src/adapters/command-code-tool-text.ts +589 -0
  25. package/src/adapters/command-code.ts +80 -6
  26. package/src/adapters/cursor/catalog.ts +12 -0
  27. package/src/adapters/cursor/current-request.ts +46 -0
  28. package/src/adapters/cursor/discovery.ts +1 -1
  29. package/src/adapters/cursor/effort-map.ts +4 -0
  30. package/src/adapters/cursor/native-exec.ts +15 -0
  31. package/src/adapters/cursor/protobuf-events.ts +40 -12
  32. package/src/adapters/cursor/protobuf-request.ts +83 -18
  33. package/src/adapters/cursor/request-builder.ts +5 -4
  34. package/src/adapters/cursor/tool-guidance.ts +1 -1
  35. package/src/adapters/devin/live-models.ts +7 -0
  36. package/src/adapters/inline-think-tags.ts +251 -0
  37. package/src/adapters/kiro/adapter.ts +1 -0
  38. package/src/adapters/kiro/stream.ts +5 -3
  39. package/src/adapters/kiro/usage.ts +4 -3
  40. package/src/adapters/mimo-free.ts +37 -18
  41. package/src/adapters/openai-chat/messages.ts +5 -5
  42. package/src/adapters/openai-chat/tool-schema.ts +124 -2
  43. package/src/adapters/openai-chat.ts +13 -3
  44. package/src/adapters/openai-responses/passthrough.ts +15 -2
  45. package/src/adapters/openai-responses/reasoning.ts +14 -1
  46. package/src/adapters/openai-responses/request-strips.ts +29 -3
  47. package/src/adapters/openai-responses/tool-output-recovery.ts +9 -3
  48. package/src/adapters/qoder/adapter.ts +15 -12
  49. package/src/adapters/qoder/scaffold-guard.ts +45 -13
  50. package/src/bridge/internal.ts +20 -0
  51. package/src/bridge/sse.ts +14 -5
  52. package/src/claude/agents-inject.ts +2 -1
  53. package/src/claude/desktop-applied-marker.ts +44 -0
  54. package/src/claude/desktop-profile.ts +22 -12
  55. package/src/claude/inbound.ts +8 -3
  56. package/src/cli/access.ts +22 -5
  57. package/src/cli/account-api.ts +6 -0
  58. package/src/cli/account-auth.ts +5 -2
  59. package/src/cli/account.ts +5 -6
  60. package/src/cli/aside-profiles.ts +61 -1
  61. package/src/cli/claude-desktop.ts +3 -2
  62. package/src/cli/claude.ts +3 -1
  63. package/src/cli/codex-cli-update.ts +2 -2
  64. package/src/cli/codex-shim-autorestore.ts +3 -0
  65. package/src/cli/dispatch.ts +12 -4
  66. package/src/cli/index.ts +98 -10
  67. package/src/cli/registry.ts +1 -1
  68. package/src/cli/resolve.ts +113 -1
  69. package/src/cli/root.ts +5 -0
  70. package/src/cli/stop-approval.ts +186 -0
  71. package/src/cli/stop-report.ts +1 -1
  72. package/src/cli/system-restart-client.ts +19 -4
  73. package/src/client/hub-client.ts +25 -18
  74. package/src/clients/config-export.ts +12 -4
  75. package/src/codex/account-auto-switch.ts +59 -0
  76. package/src/codex/account-lifecycle.ts +7 -0
  77. package/src/codex/account-priority.ts +10 -0
  78. package/src/codex/account-usability.ts +9 -0
  79. package/src/codex/auth-api/account-list.ts +5 -0
  80. package/src/codex/auth-api/login-flow.ts +15 -5
  81. package/src/codex/auth-api/login-state.ts +5 -16
  82. package/src/codex/auth-api/pool-mode-gate.ts +30 -7
  83. package/src/codex/auth-api/routes.ts +39 -3
  84. package/src/codex/auth-context.ts +145 -17
  85. package/src/codex/catalog/build-entries.ts +2 -1
  86. package/src/codex/catalog/effort.ts +8 -2
  87. package/src/codex/catalog/metadata.ts +33 -7
  88. package/src/codex/catalog/native-models.ts +102 -5
  89. package/src/codex/catalog/parsing.ts +2 -0
  90. package/src/codex/catalog/provider-models.ts +19 -6
  91. package/src/codex/cli-installation-targets.ts +44 -40
  92. package/src/codex/codex-write-lock.ts +32 -2
  93. package/src/codex/inject/multi-agent-v2.ts +90 -0
  94. package/src/codex/inject/plan.ts +365 -0
  95. package/src/codex/inject.ts +237 -362
  96. package/src/codex/log-guard/maintenance.ts +30 -13
  97. package/src/codex/project-config-warnings.ts +47 -7
  98. package/src/codex/prompt-layers/encoding.ts +41 -0
  99. package/src/codex/prompt-layers/toml-read.ts +1 -1
  100. package/src/codex/prompt-layers.ts +17 -13
  101. package/src/codex/prompt-text-probe.ts +20 -10
  102. package/src/codex/quota-observation-freshness.ts +34 -0
  103. package/src/codex/quota-rejection.ts +8 -4
  104. package/src/codex/quota.ts +4 -2
  105. package/src/codex/routing/selection.ts +32 -9
  106. package/src/codex/routing.ts +53 -11
  107. package/src/codex/subagent-model-fallback.ts +4 -3
  108. package/src/codex/sync.ts +11 -0
  109. package/src/codex/windows-installation-files.ts +33 -11
  110. package/src/codex/write-coordination.ts +4 -0
  111. package/src/combos/request.ts +6 -2
  112. package/src/companion/settings.ts +6 -1
  113. package/src/config/atomic-write.ts +12 -1
  114. package/src/config/derived-registries.ts +29 -0
  115. package/src/config/diagnostics.ts +17 -0
  116. package/src/config/live-reconcile.ts +11 -2
  117. package/src/config/load-degrade.ts +9 -2
  118. package/src/config/persist-unlocked.ts +4 -3
  119. package/src/config/persisted-mutation.ts +94 -0
  120. package/src/config/provider-validation.ts +1 -1
  121. package/src/config/proxy-env.ts +62 -12
  122. package/src/config/rebase-provenance.ts +80 -1
  123. package/src/config/schema/config-schema.ts +5 -0
  124. package/src/config/schema/leaf-validators.ts +50 -1
  125. package/src/config/subagent-models.ts +41 -15
  126. package/src/config.ts +18 -95
  127. package/src/generated/compatibility-version.json +330 -218
  128. package/src/generated/model-metadata.ts +8 -5
  129. package/src/github/star-state.ts +46 -6
  130. package/src/grok/inject.ts +74 -40
  131. package/src/grok/status.ts +11 -4
  132. package/src/integrations/cursor-effort-table.ts +50 -13
  133. package/src/integrations/raycast-detect.ts +19 -4
  134. package/src/integrations/serialize.ts +11 -1
  135. package/src/lib/bounded-body.ts +30 -0
  136. package/src/lib/crash-guard.ts +52 -4
  137. package/src/lib/local-aside-sync-contract.ts +41 -0
  138. package/src/lib/package-tree-integrity.ts +181 -9
  139. package/src/lib/proxy-env.ts +18 -1
  140. package/src/lib/request-failure-attribution.ts +1 -0
  141. package/src/lib/request-failure-model.ts +3 -0
  142. package/src/lib/service-secrets.ts +99 -2
  143. package/src/lib/socks5-fetch.ts +43 -14
  144. package/src/lib/token-estimate.ts +17 -2
  145. package/src/oauth/command-code.ts +3 -2
  146. package/src/oauth/generic-account-failover.ts +29 -1
  147. package/src/oauth/index.ts +24 -16
  148. package/src/oauth/login-flow-state.ts +5 -5
  149. package/src/oauth/meta-muse-device.ts +49 -7
  150. package/src/oauth/store.ts +74 -9
  151. package/src/providers/alibaba-region-backup.ts +16 -1
  152. package/src/providers/anthropic-fast.ts +89 -0
  153. package/src/providers/anthropic-reset-grant-ledger.ts +288 -0
  154. package/src/providers/anthropic-reset-grants.ts +329 -0
  155. package/src/providers/claude-cli-identity.ts +12 -0
  156. package/src/providers/command-code-efforts.ts +184 -102
  157. package/src/providers/derive.ts +11 -3
  158. package/src/providers/fastwire.ts +16 -3
  159. package/src/providers/label.ts +8 -3
  160. package/src/providers/model-rename-fields.ts +1 -0
  161. package/src/providers/openai-virtual-models.ts +1 -0
  162. package/src/providers/quota/vendor-probes-oauth.ts +2 -1
  163. package/src/providers/reasoning-metadata.ts +38 -15
  164. package/src/providers/registry/entries-core.ts +95 -7
  165. package/src/providers/registry/entries-extended.ts +21 -10
  166. package/src/providers/registry/model-ids.ts +1 -0
  167. package/src/providers/registry/model-seeds.ts +31 -3
  168. package/src/providers/registry/types.ts +3 -1
  169. package/src/providers/resolved-model-policy-merge.ts +38 -8
  170. package/src/providers/resolved-model-policy.ts +4 -2
  171. package/src/providers/service-tier.ts +3 -1
  172. package/src/reasoning-effort.ts +6 -5
  173. package/src/responses/code-mode-helper-compat.ts +14 -4
  174. package/src/responses/code-mode-shell-input.ts +54 -0
  175. package/src/responses/custom-tool-compat.ts +173 -5
  176. package/src/responses/parser.ts +38 -2
  177. package/src/responses/reasoning-replay-cache.ts +26 -0
  178. package/src/router.ts +11 -1
  179. package/src/routing/history/indexer.ts +7 -2
  180. package/src/routing/history/schema.ts +3 -1
  181. package/src/server/adapter-resolve.ts +2 -2
  182. package/src/server/auth-cors.ts +3 -0
  183. package/src/server/chat-completions.ts +14 -1
  184. package/src/server/chat-native.ts +17 -0
  185. package/src/server/claude-messages.ts +4 -3
  186. package/src/server/direct-local-http.ts +45 -19
  187. package/src/server/gui-session.ts +6 -15
  188. package/src/server/index/package-tree-guard.ts +53 -0
  189. package/src/server/index/serve-options.ts +17 -3
  190. package/src/server/index/startup-warnings.ts +17 -0
  191. package/src/server/index.ts +15 -17
  192. package/src/server/lifecycle.ts +8 -0
  193. package/src/server/live.ts +54 -4
  194. package/src/server/management/agent-settings-routes.ts +44 -8
  195. package/src/server/management/anthropic-reset-grant-routes.ts +252 -0
  196. package/src/server/management/codex-prompt-routes.ts +5 -1
  197. package/src/server/management/config-routes.ts +14 -4
  198. package/src/server/management/oauth-account-routes.ts +17 -1
  199. package/src/server/management/provider-overwrite-carry.ts +164 -0
  200. package/src/server/management/provider-routes.ts +36 -6
  201. package/src/server/management/remote-workspace-routes.ts +2 -2
  202. package/src/server/management/route-registry.ts +2 -0
  203. package/src/server/management/shadow-call-validation.ts +39 -0
  204. package/src/server/management/system-restart.ts +72 -6
  205. package/src/server/management-api.ts +16 -2
  206. package/src/server/management-auth.ts +52 -8
  207. package/src/server/proxy-liveness.ts +110 -4
  208. package/src/server/request-log.ts +26 -9
  209. package/src/server/request-metrics.ts +5 -0
  210. package/src/server/responses/adapter-continuation.ts +6 -3
  211. package/src/server/responses/adapter-dispatch.ts +65 -1
  212. package/src/server/responses/compact.ts +31 -1
  213. package/src/server/responses/compaction-routing.ts +28 -3
  214. package/src/server/responses/core-codex-account.ts +85 -26
  215. package/src/server/responses/core-combo-failure.ts +16 -15
  216. package/src/server/responses/core-normalize.ts +5 -1
  217. package/src/server/responses/core-opaque-recovery.ts +50 -2
  218. package/src/server/responses/core-options.ts +2 -0
  219. package/src/server/responses/core-replay.ts +7 -0
  220. package/src/server/responses/core.ts +1 -1
  221. package/src/server/responses/encrypted-payload.ts +23 -9
  222. package/src/server/responses/passthrough-delivery.ts +55 -35
  223. package/src/server/responses/passthrough-dispatch.ts +12 -13
  224. package/src/server/responses/policy-fallback.ts +12 -1
  225. package/src/server/responses/request-prepare.ts +46 -7
  226. package/src/server/responses/request-spend.ts +31 -15
  227. package/src/server/responses/run-turn-execution.ts +6 -5
  228. package/src/server/responses/shadow-target-availability.ts +61 -0
  229. package/src/server/responses-custom-tool-repair.ts +2 -0
  230. package/src/service/claim.ts +185 -0
  231. package/src/service/cli.ts +10 -1
  232. package/src/service/guarded-manager-target.ts +151 -0
  233. package/src/service/guards.ts +47 -48
  234. package/src/service/managing-cli.ts +170 -0
  235. package/src/service/orchestration.ts +3 -1
  236. package/src/service/state.ts +4 -0
  237. package/src/service/systemd.ts +16 -1
  238. package/src/service.ts +1 -1
  239. package/src/types/config.ts +8 -0
  240. package/src/types/provider.ts +16 -0
  241. package/src/types/request.ts +10 -0
  242. package/src/types/tools.ts +9 -1
  243. package/src/types/wire.ts +68 -4
  244. package/src/types.ts +1 -0
  245. package/src/update/job.ts +10 -16
  246. package/src/update/npm-invocation.mjs +17 -16
  247. package/src/update/transactional-install.d.mts +22 -1
  248. package/src/update/transactional-install.mjs +176 -19
  249. package/src/update/update-failure-guidance.d.mts +8 -0
  250. package/src/update/update-failure-guidance.mjs +47 -0
  251. package/src/usage/expected-prices.ts +54 -12
  252. package/src/usage/log.ts +117 -3
  253. package/src/usage/telemetry-contract.ts +1 -0
  254. package/src/usage/timeline.ts +34 -6
  255. package/src/usage/user-cost-overlay-reconciler.ts +3 -3
  256. package/src/vision/reasoning.ts +2 -4
  257. package/src/web-search/xai-executor.ts +14 -4
  258. package/gui/dist/assets/App-BqrsSrIR.js +0 -50
  259. package/gui/dist/assets/index-C6SJrh0N.js +0 -86
  260. package/gui/dist/assets/index-DdDunwDb.css +0 -1
  261. package/gui/dist/assets/usage-companion-chart-CzAAAB1o.js +0 -1
  262. package/src/adapters/kiro-thinking.ts +0 -112
@@ -1,3 +1,4 @@
1
+ import { codexAccountPriorityFailbackEnabled } from "./account-priority";
1
2
  import type { PoolQuotaWriter } from "./quota-types";
2
3
  import { createHash, timingSafeEqual } from "node:crypto";
3
4
  import {
@@ -80,6 +81,7 @@ import { CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, isCodexReserveHelperUnsupport
80
81
  import type { DataPlaneAdmission } from "../server/auth-cors";
81
82
  import { getMainReserveAuthorization, isMainReserveAuthorizationLive, nativeUserIdClaims, type MainReserveAuthorization } from "./reserve-availability";
82
83
  import { UpstreamRetryEvidenceError } from "../lib/upstream-retry";
84
+ import { getEffectiveCodexAutoSwitchThreshold } from "./account-auto-switch";
83
85
 
84
86
  /**
85
87
  * A request-owned bearer cannot inspect the physical main credential for its plan, but cached
@@ -90,7 +92,7 @@ import { UpstreamRetryEvidenceError } from "../lib/upstream-retry";
90
92
  * request that already brought its own credential (#3157).
91
93
  */
92
94
  function requestOwnedMainPinHasQuotaHeadroom(config: OcxConfig): boolean {
93
- const threshold = config.autoSwitchThreshold ?? 80;
95
+ const threshold = getEffectiveCodexAutoSwitchThreshold(config, MAIN_CODEX_ACCOUNT_ID);
94
96
  if (threshold <= 0) return true;
95
97
  const usage = computeCodexUsageScore(getAccountQuota(MAIN_CODEX_ACCOUNT_ID));
96
98
  return usage >= CODEX_UNKNOWN_USAGE_SCORE || usage < threshold;
@@ -121,6 +123,7 @@ export function requestOwnedMainPinState(
121
123
  policy: CodexAuthPolicyConfig,
122
124
  requestScopedMainCredential: boolean,
123
125
  fixedAccountId: string | undefined,
126
+ quotaScope?: CodexQuotaScope,
124
127
  ): { candidate: boolean; preserve: boolean } {
125
128
  const candidate = requestScopedMainCredential
126
129
  && fixedAccountId === undefined
@@ -130,7 +133,9 @@ export function requestOwnedMainPinState(
130
133
  && requestOwnedMainPinHasQuotaHeadroom(config);
131
134
  return {
132
135
  candidate,
133
- preserve: candidate && !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy)),
136
+ preserve: candidate && !(callerMatchesObservedMain(headers)
137
+ && (isMainAccountHardLocked(policy)
138
+ || getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope)?.cooldownUntil)),
134
139
  };
135
140
  }
136
141
 
@@ -181,6 +186,42 @@ function poolStateEligible(
181
186
  return fixedAccountId === undefined && !requestScopedMainCredential;
182
187
  }
183
188
 
189
+ /**
190
+ * Does main have a live credential for a request that carries main's own bearer?
191
+ *
192
+ * The one expression final authentication and every preview must agree on, for the same reason
193
+ * `poolStateEligible` is: preview exists to predict the resolution, and a preview that scores main
194
+ * differently hands subagent fallback a different account than the one that serves (#4850).
195
+ *
196
+ * A forwardable request-owned bearer IS main's live credential -- it is exactly what would be
197
+ * sent if selection named main -- so the honest answer is yes, without reading the stored
198
+ * credential. An effective manual pin answers yes as it always did. The answer is no while the
199
+ * physical identity is fenced for a different reason, because retained recovery and a profile
200
+ * drain must keep main out of routing even for a request holding its own bearer.
201
+ */
202
+ export function requestOwnedMainCredentialIsLive(inputs: {
203
+ preserveRequestOwnedMainPin: boolean;
204
+ requestScopedMainCredential: boolean;
205
+ nativeMainTrafficBlocked: boolean;
206
+ mainProfileDraining: boolean;
207
+ /**
208
+ * The caller's own credential is one of the Pool subscriptions currently in cooldown.
209
+ *
210
+ * Final authentication only. Cooldown identity is not modelled by preview and never was --
211
+ * `callerIsCooledPoolAccount` has no other caller -- because it decides a refusal rather than
212
+ * which account serves, so a preview that scores main while the resolution refuses still hands
213
+ * subagent fallback the right account. Passed explicitly at both preview sites so the asymmetry
214
+ * is stated rather than inherited from a default.
215
+ */
216
+ callerOwnsCooledPoolSubscription: boolean;
217
+ }): boolean {
218
+ return inputs.preserveRequestOwnedMainPin
219
+ || (inputs.requestScopedMainCredential
220
+ && !inputs.nativeMainTrafficBlocked
221
+ && !inputs.mainProfileDraining
222
+ && !inputs.callerOwnsCooledPoolSubscription);
223
+ }
224
+
184
225
  /**
185
226
  * May this request own Pool affinity state at all?
186
227
  *
@@ -598,6 +639,20 @@ function selectedCodexToken(headers: Headers): { accessToken: string; chatgptAcc
598
639
  };
599
640
  }
600
641
 
642
+ /**
643
+ * The workspace account id a request-owned `main` credential materializes under, or
644
+ * `undefined` when the caller's headers carry none. This is the `chatgpt-account-id`
645
+ * `materializeCodexUpstreamAuth` would set for a caller-owned `{ kind: "main" }` context,
646
+ * read here without touching a credential store so a rotation gate can compare workspace
647
+ * scope before a send is ever built.
648
+ */
649
+ export function callerCodexWorkspaceAccountId(headers: Headers): string | undefined {
650
+ const explicit = headers.get("chatgpt-account-id");
651
+ if (explicit) return explicit;
652
+ const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
653
+ return bearer ? extractAccountId(undefined, bearer) : undefined;
654
+ }
655
+
601
656
  function assertMaterializedReserve(headers: Headers, ctx: CodexAuthContext, options: CodexAuthMaterializationOptions): void {
602
657
  if (!requiresReserveAuthorization(options.config, options.modelId, options.admission)) return;
603
658
  assertReserveAdmission(options.config!);
@@ -702,6 +757,31 @@ function callerIsCooledPoolAccount(headers: Headers, config: OcxConfig, accountI
702
757
  return true;
703
758
  }
704
759
 
760
+ /**
761
+ * Does the caller's own credential belong to a Pool subscription that is currently cooled?
762
+ *
763
+ * The cooldown fallback below asks this of the SELECTED account, which was sufficient while a
764
+ * request-owned bearer could not make main a candidate: the cooled account was the selection, so
765
+ * the question and the refusal sat at the same place. Once main takes part in ordering (#5019) the
766
+ * cooled account is no longer selected, and serving its own credential as main would resurrect the
767
+ * cooldown it is inside — so eligibility has to ask the same question of every cooled sibling.
768
+ *
769
+ * `callerIsCooledPoolAccount` fails closed on an unreadable caller identity, which is preserved
770
+ * here: an opaque bearer counts as owning any cooled subscription rather than escaping it.
771
+ */
772
+ function callerOwnsAnyCooledPoolSubscription(
773
+ headers: Headers,
774
+ config: OcxConfig,
775
+ quotaScope: CodexQuotaScope | undefined,
776
+ ): boolean {
777
+ for (const account of config.codexAccounts ?? []) {
778
+ if (account.id === MAIN_CODEX_ACCOUNT_ID) continue;
779
+ if (!getCodexQuotaHealthSnapshot(account.id, quotaScope)?.cooldownUntil) continue;
780
+ if (callerIsCooledPoolAccount(headers, config, account.id)) return true;
781
+ }
782
+ return false;
783
+ }
784
+
705
785
  function captureObservedMainWriter(): MainQuotaWriter | undefined {
706
786
  const identityKey = getObservedMainQuotaIdentityKey();
707
787
  return identityKey === undefined ? undefined : {
@@ -856,15 +936,31 @@ export async function resolveCodexAuthContext(
856
936
  throw new CodexReserveUnavailableError();
857
937
  }
858
938
  const fixedAccountId = reserve ? MAIN_CODEX_ACCOUNT_ID : options.accountId;
859
- const {
860
- candidate: requestOwnedMainPinCandidate,
861
- preserve: preserveRequestOwnedMainPin,
862
- } = requestOwnedMainPinState(headers, config, policy, requestScopedMainCredential, fixedAccountId);
939
+ const quotaScope = codexQuotaScopeForModel(options.modelId);
940
+ // Pool pins and fallback must not resurrect an observed main credential that is cooling
941
+ // down. Unrelated caller-owned credentials and explicit Direct keep their own policy.
942
+ // The identity match and scoped health read are memory-only; never probe the auth file.
943
+ const callerOwnedMainPoolCooldown = () => mode === "pool" && callerMatchesObservedMain(headers)
944
+ ? getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope)
945
+ : null;
946
+ const assertCallerOwnedMainPoolNotCooled = () => {
947
+ const cooldown = callerOwnedMainPoolCooldown();
948
+ if (cooldown?.cooldownUntil) {
949
+ throw new CodexAccountCooldownError(
950
+ MAIN_CODEX_ACCOUNT_ID, cooldown.cooldownUntil, cooldown.cooldownSource, cooldown.quotaScope,
951
+ );
952
+ }
953
+ };
954
+ const mainPinState = () => requestOwnedMainPinState(
955
+ headers, config, policy, requestScopedMainCredential, fixedAccountId, quotaScope,
956
+ );
957
+ const requestOwnedMainPinCandidate = mainPinState().candidate;
863
958
  // During an owned startup, equality cannot be established until recovery and the
864
959
  // memory-only policy binding finish. This read-only fence never probes a foreign home.
865
960
  if (policy.codexMainAccountHardLock === true && requestOwnedMainPinCandidate && isMainAccountPolicyBindingPending()) {
866
961
  throw new CodexMainProfileDrainingError();
867
962
  }
963
+ const preserveRequestOwnedMainPin = () => mainPinState().preserve;
868
964
  if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) {
869
965
  throw new Error("Codex auth context cannot select and exclude an account simultaneously");
870
966
  }
@@ -878,6 +974,7 @@ export async function resolveCodexAuthContext(
878
974
  throw new CodexMainProfileDrainingError();
879
975
  }
880
976
  if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(policy);
977
+ assertCallerOwnedMainPoolNotCooled();
881
978
  if (reserve) {
882
979
  const selected = materializeCodexUpstreamAuth(headers, { kind: "main", accountId: null }, { config: policy });
883
980
  const token = selectedCodexToken(selected);
@@ -897,6 +994,7 @@ export async function resolveCodexAuthContext(
897
994
  }
898
995
  }
899
996
  if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(policy);
997
+ assertCallerOwnedMainPoolNotCooled();
900
998
  return { kind: "main", accountId: null };
901
999
  }
902
1000
 
@@ -946,13 +1044,13 @@ export async function resolveCodexAuthContext(
946
1044
  // is the one exception where that exclusion is selection evidence in the opposite direction.
947
1045
  // Validate the caller's own gated-model roster before using it, and fall through to a Pool model
948
1046
  // detour when it lacks the grant. This branch performs no physical-main credential read.
949
- if (preserveRequestOwnedMainPin) {
1047
+ if (preserveRequestOwnedMainPin()) {
950
1048
  const callerEntitled = !options.modelId
951
1049
  || !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)
952
1050
  || await (
953
1051
  options.isDirectCallerEntitledToCodexModel ?? isDirectCallerEntitledToCodexModel
954
1052
  )(headers, options.modelId);
955
- if (callerEntitled && !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy))) {
1053
+ if (callerEntitled && preserveRequestOwnedMainPin()) {
956
1054
  return { kind: "main", accountId: null };
957
1055
  }
958
1056
  }
@@ -1001,7 +1099,21 @@ export async function resolveCodexAuthContext(
1001
1099
  const nativeMainSelectionOnly = !nativeMainTrafficBlocked
1002
1100
  && selectionAdmission?.mainProfileDraining === true;
1003
1101
  let accountId: string;
1004
- const quotaScope = codexQuotaScopeForModel(options.modelId);
1102
+ // Answering this with the manual-pin predicate made `codexAccountUnusableReason` report
1103
+ // `main_credential_unavailable` for every UNPINNED request, so `getEligiblePoolAccounts` never
1104
+ // listed main and the strategy compared only the stored accounts. With one stored sibling the
1105
+ // pool degraded to "stored account until it cannot serve, then main", discarding the usage,
1106
+ // priority and reset ordering the operator configured (#5019).
1107
+ const requestOwnedMainCredentialLive = () => requestOwnedMainCredentialIsLive({
1108
+ preserveRequestOwnedMainPin: preserveRequestOwnedMainPin(),
1109
+ requestScopedMainCredential,
1110
+ nativeMainTrafficBlocked,
1111
+ mainProfileDraining: selectionAdmission?.mainProfileDraining === true,
1112
+ // Keeps the cooled account as the selection when the caller owns it, so the refusal is still
1113
+ // produced by the cooldown machinery below rather than by a second rule beside it.
1114
+ callerOwnsCooledPoolSubscription: requestScopedMainCredential
1115
+ && callerOwnsAnyCooledPoolSubscription(headers, config, quotaScope),
1116
+ });
1005
1117
  try {
1006
1118
  const excludeAccountIds = nativeMainReadsForbidden
1007
1119
  ? new Set([MAIN_CODEX_ACCOUNT_ID])
@@ -1042,13 +1154,15 @@ export async function resolveCodexAuthContext(
1042
1154
  // it. Retained recovery makes main wholly ineligible so pool routing continues.
1043
1155
  nativeMainSelectionOnly,
1044
1156
  isMainAccountTokenLive: requestScopedMainCredential
1045
- // Main stays excluded from this request's model roster below. This synthetic liveness is
1046
- // consulted only by shared-state preservation, so a caller-owned pin survives a model
1047
- // detour without reading or selecting the physical main credential.
1048
- ? () => preserveRequestOwnedMainPin
1157
+ // Main stays excluded from this request's model roster below, so an account-gated model
1158
+ // still cannot be served from a candidacy this answer creates. Everything else -- pool
1159
+ // eligibility, shared-state preservation, and a caller-owned pin surviving a model detour
1160
+ // -- is answered without reading or selecting the physical main credential.
1161
+ ? requestOwnedMainCredentialLive
1049
1162
  : options.isMainAccountTokenLive,
1050
1163
  modelEligibleAccountIds,
1051
1164
  deniedModelAccountIds,
1165
+ requestOwnedMainCredential: requestScopedMainCredential,
1052
1166
  // Request-scoped and deliberately absent from `sharedStateSelectionOptions`: one
1053
1167
  // conversation's attachments say nothing about where unrelated threads should be served.
1054
1168
  retainAccountForUploadedFiles: options.retainAccountForUploadedFiles === true,
@@ -1098,6 +1212,16 @@ export async function resolveCodexAuthContext(
1098
1212
  const selected = resolution.status === "selected" ? resolution.accountId : null;
1099
1213
  affinityDecision = resolution.affinity;
1100
1214
  transientProbe = resolution.status === "selected" ? resolution.transientProbe : undefined;
1215
+ // Main now takes part in ordering when the request carries its own main bearer (#5019), so
1216
+ // selection can name it outright rather than only through the no-candidate fallback below.
1217
+ // Serve that selection from the credential the request arrived with: the read fence above
1218
+ // forbids this request from claiming, reading or reconciling the stored main profile, and a
1219
+ // request-owned credential owns no Pool state, so there is nothing here to claim, prime or
1220
+ // cool down. Hand back any trial first -- no Pool account is being sent to.
1221
+ if (selected === MAIN_CODEX_ACCOUNT_ID && requestScopedMainCredential) {
1222
+ releaseTransientProbeGrant();
1223
+ return await resolveCallerOwnedMainContext();
1224
+ }
1101
1225
  if (!selected) {
1102
1226
  // A retry that excluded a failed Pool account may still use the validated caller-owned
1103
1227
  // main credential. Treating every exclusion as if main itself had failed strands a healthy
@@ -1202,13 +1326,17 @@ export async function resolveCodexAuthContext(
1202
1326
  // unprimed (dashboard never opened, or startup prime was blocked). Kick a
1203
1327
  // best-effort prime so the NEXT routing decision has real scores. This never
1204
1328
  // blocks the current request, and the helper's single-flight guard collapses
1205
- // repeated triggers into one pass.
1206
- if (fixedAccountId === undefined && !nativeMainReadsForbidden && !getAccountQuota(accountId)) {
1329
+ // repeated triggers into one pass. Opt-in priority failback also refreshes inactive
1330
+ // accounts; that path has a five-minute attempt limit inside the prime helper.
1331
+ const priorityFailback = codexAccountPriorityFailbackEnabled(config, accountId);
1332
+ if (fixedAccountId === undefined && !nativeMainReadsForbidden
1333
+ && (!getAccountQuota(accountId) || priorityFailback)) {
1334
+ const reason = priorityFailback ? "priority-failback" : "pre-route";
1207
1335
  if (options.primeCodexPoolQuotas) {
1208
- void options.primeCodexPoolQuotas(config, "pre-route").catch(() => {});
1336
+ void options.primeCodexPoolQuotas(config, reason).catch(() => {});
1209
1337
  } else {
1210
1338
  import("./auth-api")
1211
- .then(({ primeCodexPoolQuotas }) => primeCodexPoolQuotas(config, "pre-route"))
1339
+ .then(({ primeCodexPoolQuotas }) => primeCodexPoolQuotas(config, reason))
1212
1340
  .catch(() => {});
1213
1341
  }
1214
1342
  }
@@ -437,7 +437,8 @@ export interface ObservedCatalogMergePolicy {
437
437
  export const CANONICAL_NATIVE_CATALOG_CONTENT_POLICY: Readonly<
438
438
  Pick<ObservedCatalogMergePolicy, "nativeBackfillSlugs" | "unsupportedNativeEntries">
439
439
  > = Object.freeze({
440
- nativeBackfillSlugs: Object.freeze([...NATIVE_OPENAI_MODELS]),
440
+ // A getter: configured natives join NATIVE_OPENAI_MODELS after this module loads.
441
+ get nativeBackfillSlugs() { return Object.freeze([...NATIVE_OPENAI_MODELS]); },
441
442
  unsupportedNativeEntries: "drop",
442
443
  });
443
444
 
@@ -444,6 +444,10 @@ export interface CatalogEffortCompatibility {
444
444
  readonly affectedModels: readonly string[];
445
445
  }
446
446
 
447
+ // These parser-valid sentinels do not appear in native model ladders, so absence from an
448
+ // observed bundled catalog is not evidence that the selected Codex runtime rejects them.
449
+ const CODEX_PARSER_SENTINEL_EFFORTS = new Set(["none", "minimal"]);
450
+
447
451
  /**
448
452
  * Report which reasoning efforts in a catalog the local Codex runtime would reject, without
449
453
  * changing anything.
@@ -463,9 +467,11 @@ export function catalogEffortCompatibility(
463
467
  const unsupported = new Set<string>();
464
468
  const affected: string[] = [];
465
469
  for (const entry of models) {
466
- const rejected = catalogEntryEfforts(entry).filter(effort => !supported.has(effort));
470
+ const accepts = (effort: string): boolean => supported.has(effort)
471
+ || CODEX_PARSER_SENTINEL_EFFORTS.has(effort);
472
+ const rejected = catalogEntryEfforts(entry).filter(effort => !accepts(effort));
467
473
  const fallback = typeof entry.default_reasoning_level === "string"
468
- && !supported.has(entry.default_reasoning_level)
474
+ && !accepts(entry.default_reasoning_level)
469
475
  ? [entry.default_reasoning_level]
470
476
  : [];
471
477
  if (rejected.length === 0 && fallback.length === 0) continue;
@@ -41,6 +41,7 @@ import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds";
41
41
  import { RESERVE_METADATA_SOURCE_FIELD } from "./reserve";
42
42
  import {
43
43
  ACCOUNT_GATED_NATIVE_OPENAI_MODELS,
44
+ NATIVE_GPT6_CONTEXT,
44
45
  NATIVE_DAYBREAK_BLUE_MODEL,
45
46
  NATIVE_GPT6_ASTRA_MINOR_MODEL,
46
47
  NATIVE_GPT6_ASTRA_MODEL,
@@ -52,10 +53,13 @@ import {
52
53
  SELF_DESCRIBED_NATIVE_OPENAI_MODELS,
53
54
  SUPPORTED_NATIVE_OPENAI_SLUGS,
54
55
  RETIRED_NATIVE_OPENAI_MODELS,
56
+ configuredNativeOpenAiModels,
55
57
  hasNativeOpenAiCapabilityMetadata,
58
+ isConfiguredNativeOpenAiModel,
56
59
  isNativeOpenAiCapabilityAliasModel,
57
60
  nativeOpenAiAliasPresentation,
58
61
  nativeOpenAiCapabilitySourceSlug,
62
+ subscribeConfiguredNativeOpenAiModels,
59
63
  } from "./native-models";
60
64
  import { cachedAvailableAccountGatedNativeModels } from "../model-entitlements";
61
65
  import { MAIN_CODEX_ACCOUNT_ID } from "../main-account";
@@ -186,15 +190,16 @@ export const NATIVE_OPENAI_CONTEXT_OVERRIDES: Record<string, { contextWindow?: n
186
190
  // family's measured 922,000 clamp — advertising 922,000 here over-stated the ceiling by 50k.
187
191
  // maxInputTokens is clamped to the resolved window by nativeOpenAiMaxInputTokens, so this reads
188
192
  // 272,000 by default and 872,000 only under the long-window opt-in.
189
- [NATIVE_GPT6_ASTRA_MODEL]: { contextWindow: 272_000, maxContextWindow: 872_000, maxInputTokens: 872_000 },
193
+ [NATIVE_GPT6_ASTRA_MODEL]: { ...NATIVE_GPT6_CONTEXT },
190
194
  // Sol and Luna ship the same 272,000 / 872,000 pair in their roster rows (probe of
191
195
  // /backend-api/codex/models?client_version=0.155.0, 2026-09-23). Unmeasured here, so they take
192
196
  // the row's own ceiling rather than the GPT-5.6 family's measured 922,000.
193
- [NATIVE_GPT6_SOL_MODEL]: { contextWindow: 272_000, maxContextWindow: 872_000, maxInputTokens: 872_000 },
194
- [NATIVE_GPT6_LUNA_MODEL]: { contextWindow: 272_000, maxContextWindow: 872_000, maxInputTokens: 872_000 },
197
+ [NATIVE_GPT6_SOL_MODEL]: { ...NATIVE_GPT6_CONTEXT },
198
+ [NATIVE_GPT6_LUNA_MODEL]: { ...NATIVE_GPT6_CONTEXT },
195
199
  // Astra Minor borrows Astra's row, so it inherits Astra's numbers. No account we hold can reach
196
200
  // it, so this is inheritance, not a measurement.
197
- [NATIVE_GPT6_ASTRA_MINOR_MODEL]: { contextWindow: 272_000, maxContextWindow: 872_000, maxInputTokens: 872_000 },
201
+ [NATIVE_GPT6_ASTRA_MINOR_MODEL]: { ...NATIVE_GPT6_CONTEXT },
202
+ // Configured natives (providers.openai.models) are added at registration with the same pair.
198
203
  };
199
204
 
200
205
  // Snapshot rows plus roster-captured rows the snapshot lacks (see pinned-models.ts).
@@ -562,7 +567,8 @@ function upstreamNativeEntryForSlug(slug: string): RawEntry | undefined {
562
567
  // Keyed on the SOURCE so an alias of a self-described row (gpt-6-astra-minor -> gpt-6-astra)
563
568
  // is admitted the same way an alias of a GPT-5.6 row (Daybreak -> Sol) always was; for a
564
569
  // self-described slug itself the source is the slug, so nothing else changes.
565
- if (!sourceSlug.startsWith("gpt-5.6-") && !SELF_DESCRIBED_NATIVE_OPENAI_MODELS.has(sourceSlug)) {
570
+ if (!sourceSlug.startsWith("gpt-5.6-") && !SELF_DESCRIBED_NATIVE_OPENAI_MODELS.has(sourceSlug)
571
+ && !isConfiguredNativeOpenAiModel(slug)) {
566
572
  return undefined;
567
573
  }
568
574
  const source = PINNED_UPSTREAM_MODELS.get(sourceSlug);
@@ -621,6 +627,24 @@ export const UPSTREAM_NATIVE_ENTRIES: Map<string, RawEntry> = new Map(
621
627
  }),
622
628
  );
623
629
 
630
+ // Configured natives join the three per-slug tables in place: other modules hold these exact
631
+ // objects, so a replacement would go unseen. Built-in ids are never configured, so a removal
632
+ // cannot delete a built-in row.
633
+ subscribeConfiguredNativeOpenAiModels((current, removed) => {
634
+ for (const slug of removed) {
635
+ PINNED_NATIVE_CAPABILITY_ENTRIES.delete(slug);
636
+ UPSTREAM_NATIVE_ENTRIES.delete(slug);
637
+ delete NATIVE_OPENAI_CONTEXT_OVERRIDES[slug];
638
+ }
639
+ for (const slug of current) {
640
+ const pinned = pinnedNativeCapabilityEntry(slug);
641
+ if (pinned) PINNED_NATIVE_CAPABILITY_ENTRIES.set(slug, pinned);
642
+ const upstream = upstreamNativeEntryForSlug(slug);
643
+ if (upstream) UPSTREAM_NATIVE_ENTRIES.set(slug, upstream);
644
+ NATIVE_OPENAI_CONTEXT_OVERRIDES[slug] = { ...NATIVE_GPT6_CONTEXT };
645
+ }
646
+ });
647
+
624
648
  export function upstreamNativeEntry(slug: string): RawEntry | null {
625
649
  const entry = UPSTREAM_NATIVE_ENTRIES.get(slug);
626
650
  if (!entry) return null;
@@ -672,7 +696,9 @@ export function shouldUpgradeToUpstreamEntry(entry: RawEntry): boolean {
672
696
  export function nativeOpenAiSlugs(): string[] {
673
697
  const live = catalogNativeSlugs();
674
698
  const availableGated = cachedAvailableAccountGatedNativeModels();
675
- const candidates = live.length > 0 ? unique([...live, ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS]) : NATIVE_OPENAI_MODELS;
699
+ const candidates = live.length > 0
700
+ ? unique([...live, ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS, ...configuredNativeOpenAiModels()])
701
+ : NATIVE_OPENAI_MODELS;
676
702
  return candidates.filter(slug => (
677
703
  !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableGated.has(slug)
678
704
  ));
@@ -881,5 +907,5 @@ function catalogNativeSlugs(): string[] {
881
907
  export function listCatalogNativeSlugs(): string[] {
882
908
  // Ensure documented additions (e.g. gpt-6-astra) appear even when the bundled catalog
883
909
  // predates the slug — mirrors nativeOpenAiSlugs() which already merges them for /v1/models.
884
- return unique([...catalogNativeSlugs(), ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS]);
910
+ return unique([...catalogNativeSlugs(), ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS, ...configuredNativeOpenAiModels()]);
885
911
  }
@@ -48,6 +48,17 @@ export const NATIVE_GPT6_LUNA_MODEL = "gpt-6-luna";
48
48
  */
49
49
  export const NATIVE_GPT6_ASTRA_MINOR_MODEL = "gpt-6-astra-minor";
50
50
 
51
+ /**
52
+ * Context pair every GPT-6 native ships in its upstream row: a 272,000-token default window
53
+ * against an 872,000-token ceiling (and input limit) reached only through the long-window opt-in.
54
+ * The built-in GPT-6 rows and every configured native (below) inherit this one value.
55
+ */
56
+ export const NATIVE_GPT6_CONTEXT: Readonly<{ contextWindow: number; maxContextWindow: number; maxInputTokens: number }> =
57
+ Object.freeze({ contextWindow: 272_000, maxContextWindow: 872_000, maxInputTokens: 872_000 });
58
+
59
+ /** Pinned row a configured native borrows its capability metadata from. */
60
+ export const CONFIGURED_NATIVE_OPENAI_TEMPLATE_MODEL = NATIVE_GPT6_SOL_MODEL;
61
+
51
62
  /**
52
63
  * Native ChatGPT/Codex ids whose availability is proven per authenticated account.
53
64
  *
@@ -132,7 +143,7 @@ export const NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS = Object.freeze(
132
143
  );
133
144
 
134
145
  export function isNativeOpenAiCapabilityAliasModel(slug: string): boolean {
135
- return Object.hasOwn(NATIVE_OPENAI_CAPABILITY_SOURCES, slug);
146
+ return Object.hasOwn(NATIVE_OPENAI_CAPABILITY_SOURCES, slug) || configuredNativeSlugs.has(slug);
136
147
  }
137
148
 
138
149
  /**
@@ -151,7 +162,8 @@ export function hasNativeOpenAiCapabilityMetadata(slug: string): boolean {
151
162
  }
152
163
 
153
164
  export function nativeOpenAiCapabilitySourceSlug(slug: string): string {
154
- return NATIVE_OPENAI_CAPABILITY_SOURCES[slug] ?? slug;
165
+ return NATIVE_OPENAI_CAPABILITY_SOURCES[slug]
166
+ ?? (configuredNativeSlugs.has(slug) ? CONFIGURED_NATIVE_OPENAI_TEMPLATE_MODEL : slug);
155
167
  }
156
168
 
157
169
  /**
@@ -171,7 +183,22 @@ export const NATIVE_OPENAI_ALIAS_PRESENTATION: Readonly<Record<string, { display
171
183
  });
172
184
 
173
185
  export function nativeOpenAiAliasPresentation(slug: string): { displayName: string; description: string } | undefined {
174
- return NATIVE_OPENAI_ALIAS_PRESENTATION[slug];
186
+ return NATIVE_OPENAI_ALIAS_PRESENTATION[slug]
187
+ ?? (configuredNativeSlugs.has(slug) ? configuredNativePresentation(slug) : undefined);
188
+ }
189
+
190
+ /** `gpt-6-nova` -> `GPT-6-Nova`, the same casing upstream uses for its own GPT-6 rows. */
191
+ export function configuredNativeOpenAiDisplayName(slug: string): string {
192
+ return slug.split("-").map((part, index) => (
193
+ index === 0 ? part.toUpperCase() : part.charAt(0).toUpperCase() + part.slice(1)
194
+ )).join("-");
195
+ }
196
+
197
+ function configuredNativePresentation(slug: string): { displayName: string; description: string } {
198
+ return {
199
+ displayName: configuredNativeOpenAiDisplayName(slug),
200
+ description: "OpenAI native model added through providers.openai.models; uses GPT-6-Sol capabilities.",
201
+ };
175
202
  }
176
203
 
177
204
  /**
@@ -190,17 +217,87 @@ export function nativeOpenAiAliasPresentation(slug: string): { displayName: stri
190
217
  *
191
218
  * Devlog: 260816_codexrs_multiagent_v2_and_history_perf/011 §4-bis.
192
219
  */
193
- export const NATIVE_OPENAI_MODELS = [
220
+ const BUILT_IN_NATIVE_OPENAI_MODELS: readonly string[] = Object.freeze([
194
221
  "gpt-5.5",
195
222
  "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna",
196
223
  NATIVE_DAYBREAK_BLUE_MODEL,
197
224
  NATIVE_GPT6_ASTRA_MODEL,
198
225
  NATIVE_GPT6_SOL_MODEL, NATIVE_GPT6_LUNA_MODEL,
199
226
  NATIVE_GPT6_ASTRA_MINOR_MODEL,
200
- ];
227
+ ]);
228
+
229
+ /**
230
+ * The built-in list plus every configured native, appended in config order. The array and the Set
231
+ * below are shared by reference across the catalog, `/v1/models` and the dashboard, so
232
+ * registration edits them in place rather than replacing them.
233
+ */
234
+ export const NATIVE_OPENAI_MODELS: string[] = [...BUILT_IN_NATIVE_OPENAI_MODELS];
201
235
 
202
236
  export const SUPPORTED_NATIVE_OPENAI_SLUGS = new Set(NATIVE_OPENAI_MODELS);
203
237
 
238
+ /**
239
+ * Configured natives: bare `gpt-*` ids an operator lists under `providers.openai.models` on the
240
+ * canonical Codex forward provider, so a new upstream GPT model needs a config entry rather than a
241
+ * release — the way a Claude id listed under `providers.anthropic.models` already works.
242
+ *
243
+ * A configured native borrows `gpt-6-sol`'s pinned row (ladder, modalities, instructions, speed
244
+ * tiers) under its own generated name, uses the GPT-6 272k/872k context pair, and is never
245
+ * account-gated. Filtering the config lives in `src/config/derived-registries.ts`; this module stays
246
+ * import-free because the GUI bundles it. Registration runs inside `loadConfig` and every config
247
+ * persist/reconcile path, so any process that loads config — `ocx ensure` included — sees it.
248
+ */
249
+ const configuredNativeSlugs = new Set<string>();
250
+ type ConfiguredNativeListener = (current: readonly string[], removed: readonly string[]) => void;
251
+ const configuredNativeListeners: ConfiguredNativeListener[] = [];
252
+
253
+ const CONFIGURED_NATIVE_SLUG = /^gpt-[a-z0-9][a-z0-9.-]*$/;
254
+
255
+ /** Whether a bare id may become a configured native (shape, not built in, not retired or reserve). */
256
+ export function isEligibleConfiguredNativeOpenAiModel(id: string): boolean {
257
+ return CONFIGURED_NATIVE_SLUG.test(id)
258
+ && !BUILT_IN_NATIVE_OPENAI_MODELS.includes(id)
259
+ && !RETIRED_NATIVE_OPENAI_MODELS.has(id)
260
+ && id !== NATIVE_RESERVE_MODEL;
261
+ }
262
+
263
+ export function configuredNativeOpenAiModels(): readonly string[] {
264
+ return [...configuredNativeSlugs];
265
+ }
266
+
267
+ export function isConfiguredNativeOpenAiModel(slug: string): boolean {
268
+ return configuredNativeSlugs.has(slug);
269
+ }
270
+
271
+ /** Replace the configured set. Ineligible ids are ignored; built-in entries are never touched. */
272
+ export function setConfiguredNativeOpenAiModels(ids: readonly string[]): void {
273
+ const next = [...new Set(ids.filter(isEligibleConfiguredNativeOpenAiModel))];
274
+ const previous = [...configuredNativeSlugs];
275
+ if (next.length === previous.length && next.every((id, index) => id === previous[index])) return;
276
+ const removed = previous.filter(id => !next.includes(id));
277
+ for (const id of previous) {
278
+ configuredNativeSlugs.delete(id);
279
+ SUPPORTED_NATIVE_OPENAI_SLUGS.delete(id);
280
+ const index = NATIVE_OPENAI_MODELS.indexOf(id);
281
+ if (index >= 0) NATIVE_OPENAI_MODELS.splice(index, 1);
282
+ }
283
+ for (const id of next) {
284
+ configuredNativeSlugs.add(id);
285
+ SUPPORTED_NATIVE_OPENAI_SLUGS.add(id);
286
+ NATIVE_OPENAI_MODELS.push(id);
287
+ }
288
+ for (const listener of configuredNativeListeners) listener(next, removed);
289
+ }
290
+
291
+ /** Keep derived tables in step; the listener runs immediately with the current set. */
292
+ export function subscribeConfiguredNativeOpenAiModels(listener: ConfiguredNativeListener): void {
293
+ configuredNativeListeners.push(listener);
294
+ listener(configuredNativeOpenAiModels(), []);
295
+ }
296
+
297
+ export function resetConfiguredNativeOpenAiModelsForTests(): void {
298
+ setConfiguredNativeOpenAiModels([]);
299
+ }
300
+
204
301
  /**
205
302
  * Natives this runtime used to ship that upstream has since retired.
206
303
  *
@@ -194,6 +194,8 @@ export const ROUTED_MODEL_COMPATIBILITY_EXCLUSIONS = new Set([
194
194
  * keep appearing with its capabilities broken. Excluding the slug is what actually takes
195
195
  * it out of the routed catalog.
196
196
  */
197
+ "deepseek/deepseek-v4-pro",
198
+ "opencode-go/deepseek-v4-pro",
197
199
  "command-code/deepseek-deepseek-v4-pro",
198
200
  "commandcode/deepseek-deepseek-v4-pro",
199
201
  "orcarouter/deepseek-deepseek-v4-pro",
@@ -56,6 +56,7 @@ import { cursorLiveRosterScope, recordLiveCursorClaudeModels, recordLiveCursorMa
56
56
  import { fetchQoderModels } from "../../adapters/qoder/live-models";
57
57
  import { resolveQoderProfile } from "../../adapters/qoder/profiles";
58
58
  import { fetchDevinUsableModels } from "../../adapters/devin/live-models";
59
+ import { resolveDevinApiBaseUrl } from "../../oauth/devin/api-base";
59
60
  import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
60
61
  import {
61
62
  COMBO_NAMESPACE,
@@ -216,11 +217,15 @@ export async function fetchProviderModelsWithAuth(
216
217
  return observed(configured, "authoritative");
217
218
  }
218
219
  const auth: ModelsAuthResolution = captured.observedAuth ?? (resolveAuth.kind === "refreshing"
219
- ? prov.authMode === "oauth" && effectiveGoogleMode(name, prov) === "cloud-code-assist"
220
+ ? prov.authMode === "oauth" && (
221
+ effectiveGoogleMode(name, prov) === "cloud-code-assist"
222
+ || prov.adapter === "devin"
223
+ )
220
224
  ? await getValidAccessTokenSnapshot(name)
221
225
  .then(snapshot => ({
222
226
  apiKey: snapshot.accessToken,
223
227
  observed: false,
228
+ ...(snapshot.apiBaseUrl ? { oauthApiBaseUrl: snapshot.apiBaseUrl } : {}),
224
229
  ...(snapshot.projectId ? { oauthProjectId: snapshot.projectId } : {}),
225
230
  }))
226
231
  .catch(() => ({ apiKey: undefined, observed: false }))
@@ -289,10 +294,13 @@ export async function fetchProviderModelsWithAuth(
289
294
  }
290
295
  if (prov.adapter === "devin") {
291
296
  if (!apiKey) return observed(configured, "degraded");
292
- // Devin's usable-model list is entitlement-specific. Bind cache reads/writes to an
293
- // irreversible credential fingerprint so a credential switch cannot observe another
294
- // account's roster or stale fallback (the Qoder precedent above).
295
- const authorityIdentity = createHash("sha256").update(apiKey).digest("hex");
297
+ // Both the credential and its validated tenant destination own this roster.
298
+ // The registered Devin route ignores a saved baseUrl override; discovery must
299
+ // use that same fixed destination when the stored tenant URL is invalid.
300
+ const configuredBase = name === "devin" ? getProviderRegistryEntry(name)?.baseUrl ?? prov.baseUrl : prov.baseUrl;
301
+ const destination = resolveDevinApiBaseUrl(auth.oauthApiBaseUrl ?? configuredBase);
302
+ const authorityIdentity = createHash("sha256")
303
+ .update(JSON.stringify([apiKey, destination])).digest("hex");
296
304
  const cachedDevin = getFreshCached(name, ttlMs, Date.now(), authorityIdentity);
297
305
  if (cachedDevin) {
298
306
  return observed(
@@ -309,7 +317,12 @@ export async function fetchProviderModelsWithAuth(
309
317
  "degraded",
310
318
  );
311
319
  }
312
- const liveResult = await fetchDevinUsableModels({ apiKey, baseUrl: prov.baseUrl });
320
+ // The OAuth snapshot owns both values: never combine one account's durable
321
+ // key with the registry's default host or another account's tenant host.
322
+ const liveResult = await fetchDevinUsableModels({
323
+ apiKey,
324
+ baseUrl: destination,
325
+ });
313
326
  if (liveResult.ok) {
314
327
  // Live catalog is the source of truth — use the discovered base models
315
328
  // directly, not a filtered subset of the static seed.