@bitkyc08/opencodex 2.56.0 → 2.58.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 (206) hide show
  1. package/bin/ocx.mjs +10 -0
  2. package/gui/dist/assets/{index-D4zuyIxQ.js → index-BbrHOIY0.js} +21 -21
  3. package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/adapters/codebuddy/adapter.ts +2 -1
  7. package/src/adapters/codebuddy/scaffold-guard.ts +249 -0
  8. package/src/adapters/command-code.ts +12 -3
  9. package/src/adapters/cursor/cursor-errors.ts +15 -0
  10. package/src/adapters/cursor/discovery.ts +65 -1
  11. package/src/adapters/cursor/envelope-echo.ts +8 -2
  12. package/src/adapters/cursor/live-transport.ts +5 -1
  13. package/src/adapters/cursor/protobuf-events.ts +110 -11
  14. package/src/adapters/cursor/protobuf-request.ts +19 -1
  15. package/src/adapters/cursor/text-toolcall.ts +230 -0
  16. package/src/adapters/cursor/thread-continuity.ts +67 -0
  17. package/src/adapters/cursor/types.ts +5 -0
  18. package/src/adapters/cursor.ts +55 -5
  19. package/src/adapters/google-http.ts +38 -13
  20. package/src/adapters/google.ts +7 -7
  21. package/src/adapters/kiro/payload.ts +17 -3
  22. package/src/adapters/kiro/reasoning.ts +70 -7
  23. package/src/adapters/kiro/stream.ts +8 -2
  24. package/src/adapters/kiro/wire.ts +2 -1
  25. package/src/adapters/kiro-events.ts +21 -13
  26. package/src/adapters/mimo-free.ts +32 -17
  27. package/src/adapters/ollama-native.ts +42 -8
  28. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  29. package/src/adapters/openai-chat/tool-schema.ts +25 -7
  30. package/src/adapters/openai-chat.ts +8 -8
  31. package/src/adapters/openai-responses/passthrough.ts +62 -5
  32. package/src/adapters/openai-responses/request-strips.ts +43 -0
  33. package/src/adapters/physical-send.ts +50 -0
  34. package/src/bridge/errors.ts +26 -2
  35. package/src/bridge/response-json.ts +8 -2
  36. package/src/bridge/sse.ts +20 -2
  37. package/src/claude/desktop-profile.ts +66 -9
  38. package/src/claude/outbound.ts +32 -4
  39. package/src/cli/account-main.ts +1 -1
  40. package/src/cli/capabilities.ts +2 -2
  41. package/src/cli/combo.ts +10 -1
  42. package/src/cli/config-command.ts +35 -18
  43. package/src/cli/dispatch.ts +17 -4
  44. package/src/cli/index.ts +92 -7
  45. package/src/cli/registry.ts +2 -1
  46. package/src/cli/system-command.ts +74 -5
  47. package/src/cli/uninstall-client-state.ts +12 -0
  48. package/src/clients/config-export.ts +7 -3
  49. package/src/codex/account-label.ts +14 -3
  50. package/src/codex/account-store.ts +113 -26
  51. package/src/codex/account-usability.ts +21 -0
  52. package/src/codex/auth-api/login-flow.ts +14 -2
  53. package/src/codex/auth-api/reset-credit-service.ts +11 -2
  54. package/src/codex/auth-context.ts +199 -15
  55. package/src/codex/catalog/aggregation.ts +80 -1
  56. package/src/codex/catalog/model-visibility.ts +1 -0
  57. package/src/codex/catalog/remote.ts +30 -0
  58. package/src/codex/catalog/retained-sync.ts +9 -1
  59. package/src/codex/catalog/routed-gather.ts +38 -1
  60. package/src/codex/cli-install-provenance.ts +7 -1
  61. package/src/codex/convergence.ts +7 -2
  62. package/src/codex/desktop-app/types.ts +11 -2
  63. package/src/codex/desktop-app/windows.ts +5 -5
  64. package/src/codex/desktop-switches.ts +145 -0
  65. package/src/codex/history-job.ts +5 -1
  66. package/src/codex/history-provider.ts +33 -4
  67. package/src/codex/history-worker.ts +14 -1
  68. package/src/codex/inject/remove.ts +145 -7
  69. package/src/codex/inject/restore.ts +231 -32
  70. package/src/codex/inject.ts +12 -16
  71. package/src/codex/loopback-target.ts +9 -0
  72. package/src/codex/model-entitlements.ts +152 -15
  73. package/src/codex/native-profile-startup.ts +64 -20
  74. package/src/codex/pool-refresh-backoff.ts +12 -3
  75. package/src/codex/quota-rejection.ts +104 -15
  76. package/src/codex/routing/cache-affinity.ts +70 -0
  77. package/src/codex/routing/cooldown-math.ts +10 -0
  78. package/src/codex/routing/selection.ts +79 -2
  79. package/src/codex/routing/thread-affinity.ts +50 -2
  80. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  81. package/src/codex/routing.ts +29 -49
  82. package/src/codex/warmup.ts +1 -1
  83. package/src/combos/failover.ts +85 -0
  84. package/src/combos/request.ts +17 -10
  85. package/src/combos/types.ts +23 -2
  86. package/src/config/atomic-write.ts +83 -8
  87. package/src/config/pending-teardown.ts +31 -0
  88. package/src/config/schema/config-schema.ts +2 -0
  89. package/src/config/schema/leaf-validators.ts +1 -0
  90. package/src/generated/compatibility-version.json +272 -180
  91. package/src/images/loop.ts +1 -1
  92. package/src/lib/bounded-subprocess.ts +62 -10
  93. package/src/lib/errors.ts +17 -0
  94. package/src/lib/request-execution-budget.ts +147 -21
  95. package/src/lib/spend-reservation-ledger.ts +18 -0
  96. package/src/lib/state-store-registrations.ts +6 -2
  97. package/src/lib/test-home-guard.ts +85 -1
  98. package/src/lib/upstream-retry.ts +77 -10
  99. package/src/lib/windows-elevation.ts +76 -14
  100. package/src/lib/windows-secret-acl.ts +151 -15
  101. package/src/lib/windows-user-principal.ts +5 -1
  102. package/src/oauth/index.ts +2 -2
  103. package/src/oauth/key-providers.ts +2 -2
  104. package/src/providers/derive.ts +6 -0
  105. package/src/providers/kiro-models.ts +4 -3
  106. package/src/providers/label.ts +19 -1
  107. package/src/providers/model-discovery.ts +35 -7
  108. package/src/providers/registry/entries-core.ts +18 -0
  109. package/src/providers/registry/entries-extended.ts +59 -28
  110. package/src/providers/registry/model-seeds.ts +71 -17
  111. package/src/providers/registry/types.ts +9 -0
  112. package/src/responses/reasoning-envelope.ts +6 -3
  113. package/src/responses/spill-store.ts +17 -0
  114. package/src/responses/state/body-policy.ts +25 -0
  115. package/src/responses/state/spill-queue.ts +8 -6
  116. package/src/responses/state.ts +3 -22
  117. package/src/router.ts +4 -0
  118. package/src/routing/identity-domains.ts +21 -14
  119. package/src/routing/probe-lease.ts +103 -1
  120. package/src/server/auth-cors.ts +1 -0
  121. package/src/server/chat-completions.ts +3 -1
  122. package/src/server/chat-native.ts +37 -9
  123. package/src/server/index/live-sideband.ts +37 -1
  124. package/src/server/index/websocket-handler.ts +54 -3
  125. package/src/server/index.ts +5 -5
  126. package/src/server/inspection-tee.ts +107 -0
  127. package/src/server/live.ts +46 -1
  128. package/src/server/management/combo-routes.ts +10 -1
  129. package/src/server/management/config-routes.ts +27 -5
  130. package/src/server/models-capabilities.ts +24 -3
  131. package/src/server/relay-eager.ts +2 -0
  132. package/src/server/relay.ts +14 -19
  133. package/src/server/request-log.ts +127 -3
  134. package/src/server/response-log-body.ts +153 -0
  135. package/src/server/responses/account-change-state.ts +74 -0
  136. package/src/server/responses/adapter-continuation.ts +33 -7
  137. package/src/server/responses/adapter-delivery.ts +5 -11
  138. package/src/server/responses/adapter-dispatch.ts +84 -13
  139. package/src/server/responses/codex-ws-exchange.ts +65 -4
  140. package/src/server/responses/codex-ws-wire.ts +5 -0
  141. package/src/server/responses/collaboration.ts +74 -4
  142. package/src/server/responses/combo-session-recall.ts +68 -8
  143. package/src/server/responses/combo-stream-preflight.ts +68 -5
  144. package/src/server/responses/compact.ts +54 -13
  145. package/src/server/responses/core-auth.ts +2 -0
  146. package/src/server/responses/core-codex-account.ts +51 -3
  147. package/src/server/responses/core-combo.ts +129 -23
  148. package/src/server/responses/core-errors.ts +18 -0
  149. package/src/server/responses/core-options.ts +3 -0
  150. package/src/server/responses/core-replay.ts +105 -32
  151. package/src/server/responses/core.ts +3 -3
  152. package/src/server/responses/encrypted-payload.ts +0 -1
  153. package/src/server/responses/fetch-helpers.ts +4 -1
  154. package/src/server/responses/input-admission.ts +126 -6
  155. package/src/server/responses/native-injection-protocol.ts +42 -0
  156. package/src/server/responses/native-injection-replay.ts +105 -0
  157. package/src/server/responses/native-injection.ts +242 -0
  158. package/src/server/responses/native-response-control.ts +56 -0
  159. package/src/server/responses/native-response-json.ts +14 -0
  160. package/src/server/responses/native-response-output.ts +37 -0
  161. package/src/server/responses/native-steering-log.ts +44 -0
  162. package/src/server/responses/native-steering-policy.ts +49 -0
  163. package/src/server/responses/native-steering-replay.ts +126 -0
  164. package/src/server/responses/native-steering-settings.ts +76 -0
  165. package/src/server/responses/native-steering.ts +400 -0
  166. package/src/server/responses/native-tool-results.ts +130 -0
  167. package/src/server/responses/passthrough-delivery.ts +30 -6
  168. package/src/server/responses/passthrough-dispatch.ts +61 -11
  169. package/src/server/responses/passthrough-error.ts +38 -2
  170. package/src/server/responses/request-prepare.ts +173 -22
  171. package/src/server/responses/request-send-budget.ts +97 -2
  172. package/src/server/responses/request-spend.ts +147 -0
  173. package/src/server/responses/request-transport.ts +62 -3
  174. package/src/server/responses/run-turn-execution.ts +59 -31
  175. package/src/server/responses/sidecar-execution.ts +7 -13
  176. package/src/server/responses/terminal-guard.ts +65 -4
  177. package/src/server/responses/ws-upstream.ts +21 -1
  178. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  179. package/src/server/stop-teardown.ts +8 -1
  180. package/src/server/ws-bridge.ts +16 -1
  181. package/src/service/cli.ts +13 -1
  182. package/src/service/windows-ops.ts +210 -16
  183. package/src/service/windows-scheduler.ts +28 -21
  184. package/src/service.ts +1 -1
  185. package/src/types/config.ts +8 -1
  186. package/src/types/provider.ts +13 -0
  187. package/src/types/request.ts +8 -5
  188. package/src/types/tools.ts +24 -0
  189. package/src/types.ts +2 -0
  190. package/src/update/index.ts +10 -0
  191. package/src/update/stop-contract.d.mts +1 -0
  192. package/src/update/stop-contract.mjs +19 -0
  193. package/src/update/stop-decision.d.mts +1 -1
  194. package/src/update/stop-decision.mjs +12 -3
  195. package/src/usage/log.ts +1 -1
  196. package/src/vision/anthropic-describe.ts +1 -1
  197. package/src/vision/describe.ts +5 -5
  198. package/src/web-search/anthropic-executor.ts +1 -1
  199. package/src/web-search/exa-executor.ts +1 -1
  200. package/src/web-search/executor.ts +1 -1
  201. package/src/web-search/gemini-executor.ts +1 -1
  202. package/src/web-search/loop.ts +1 -1
  203. package/src/web-search/ollama-executor.ts +1 -1
  204. package/src/web-search/parse.ts +67 -14
  205. package/src/web-search/passthrough-bridge.ts +64 -31
  206. package/src/web-search/xai-executor.ts +1 -1
@@ -39,7 +39,12 @@ import {
39
39
  pickAlternateCodexAccount,
40
40
  resolveCodexAccountForThreadDetailed,
41
41
  type CodexAffinityDecision,
42
+ type CodexThreadResolution,
43
+ type TransientProbeGrant,
42
44
  } from "./routing";
45
+ // The half-open TRANSIENT-HOLD lease (#4701). Not the quota-cooldown probe lease imported from
46
+ // ./routing above -- different module, different domain, and a request never holds both.
47
+ import { releaseTransientProbe } from "../routing/probe-lease";
43
48
  import {
44
49
  codexConversationIdentity,
45
50
  recordCodexThreadLineage,
@@ -47,6 +52,7 @@ import {
47
52
  type CodexThreadLineage,
48
53
  } from "./lineage";
49
54
  import {
55
+ cachedDeniedCodexAccountIdsForModel,
50
56
  entitledCodexAccountIdsForModel,
51
57
  isDirectCallerEntitledToCodexModel,
52
58
  resolveCodexModelEntitlements,
@@ -90,6 +96,44 @@ function requestOwnedMainPinHasQuotaHeadroom(config: OcxConfig): boolean {
90
96
  return usage >= CODEX_UNKNOWN_USAGE_SCORE || usage < threshold;
91
97
  }
92
98
 
99
+ /**
100
+ * Whether a request carrying its OWN main credential still serves on main because the operator
101
+ * manually pinned it (#3166), split from the surrounding resolution so request preview can ask
102
+ * the identical question (#4850).
103
+ *
104
+ * Exported for exactly one reason: two copies of this fence is how #4850 happened. Final
105
+ * authentication honoured the ownership boundary while request preview, computing its fence from
106
+ * recovery and drain state alone, still handed pool eligibility the default liveness probe and
107
+ * opened the physical `auth.json` twice per spawn. A predicate one caller can forget is a
108
+ * predicate the other caller will eventually disagree with.
109
+ *
110
+ * Read-free by construction, which is what makes it usable on the fenced side. Every input is
111
+ * config, policy, or in-memory runtime state: the pin fields, the paused list, the cached quota
112
+ * score, and `callerMatchesObservedMain`, which compares HMAC digests against the observed
113
+ * credential record in `main-account-cache.ts`. Nothing here opens a file.
114
+ *
115
+ * `candidate` is the pin before the hard-lock question, because the caller still owes the
116
+ * pending-binding check that only final authentication can fail closed on.
117
+ */
118
+ export function requestOwnedMainPinState(
119
+ headers: Headers,
120
+ config: OcxConfig,
121
+ policy: CodexAuthPolicyConfig,
122
+ requestScopedMainCredential: boolean,
123
+ fixedAccountId: string | undefined,
124
+ ): { candidate: boolean; preserve: boolean } {
125
+ const candidate = requestScopedMainCredential
126
+ && fixedAccountId === undefined
127
+ && config.activeCodexAccountPinned === MAIN_CODEX_ACCOUNT_ID
128
+ && isEffectiveCodexAccountPinned(config)
129
+ && !policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID)
130
+ && requestOwnedMainPinHasQuotaHeadroom(config);
131
+ return {
132
+ candidate,
133
+ preserve: candidate && !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy)),
134
+ };
135
+ }
136
+
93
137
  /**
94
138
  * Every thread keys as ITSELF, never as its parent (#4546, wp8).
95
139
  *
@@ -202,6 +246,12 @@ export type CodexAuthContext =
202
246
  affinityDecision?: CodexAffinityDecision;
203
247
  /** Scope that owns `probeLeaseId`, when it is a scoped recovery probe. */
204
248
  probeQuotaScope?: CodexQuotaScope;
249
+ /**
250
+ * Set when this request is the ONE dispatch admitted to test an account held under a
251
+ * transient 5xx hold (#4701). Echo it into the upstream outcome so the trial is settled
252
+ * by the request that ran it, and release it on any path that never reaches upstream.
253
+ */
254
+ transientProbe?: TransientProbeGrant;
205
255
  }
206
256
  | {
207
257
  // Main Codex account participating in rotation: token injected from ~/.codex/auth.json
@@ -222,6 +272,8 @@ export type CodexAuthContext =
222
272
  probeLeaseId?: string;
223
273
  quotaScope?: CodexQuotaScope;
224
274
  probeQuotaScope?: CodexQuotaScope;
275
+ /** See `pool.transientProbe`. */
276
+ transientProbe?: TransientProbeGrant;
225
277
  };
226
278
 
227
279
  /** Probe lease carried by this context, when it holds one. */
@@ -234,11 +286,24 @@ export function codexProbeQuotaScope(ctx: CodexAuthContext | undefined): CodexQu
234
286
  return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.probeQuotaScope : undefined;
235
287
  }
236
288
 
289
+ /** The transient-hold recovery probe carried by this context, when it holds one (#4701). */
290
+ export function codexTransientProbeGrant(ctx: CodexAuthContext | undefined): TransientProbeGrant | undefined {
291
+ return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.transientProbe : undefined;
292
+ }
293
+
237
294
  /**
238
295
  * Hand back a probe lease for a request that will not reach upstream. Safe to
239
296
  * call with a context that holds no lease.
297
+ *
298
+ * BOTH leases, deliberately. A context can carry the quota-cooldown probe or the transient-hold
299
+ * probe, and every one of the ~30 call sites that already hands back the first is a path where
300
+ * the second would leak too. Releasing them together is what makes those sites correct for the
301
+ * new lease without re-deriving the discard set by hand -- the failure mode being avoided is a
302
+ * held account nobody may probe because the request that held the trial went away quietly.
240
303
  */
241
304
  export function releaseCodexAuthContextProbeLease(ctx: CodexAuthContext | undefined): void {
305
+ const transientProbe = codexTransientProbeGrant(ctx);
306
+ if (transientProbe) releaseTransientProbe(transientProbe.lease);
242
307
  const leaseId = codexProbeLeaseId(ctx);
243
308
  if (!ctx || ctx.kind === "main" || !leaseId) return;
244
309
  if (ctx.probeQuotaScope) releaseCodexQuotaScopeProbeLease(ctx.accountId!, ctx.probeQuotaScope, leaseId);
@@ -424,6 +489,44 @@ export class CodexReserveHelperUnsupportedError extends CodexReserveUnavailableE
424
489
  }
425
490
  }
426
491
 
492
+ /**
493
+ * Every account bound to this conversation is held after upstream failures, the recovery
494
+ * budget for this window is spent, and there is no detour left -- so this request is refused
495
+ * BEFORE any upstream I/O (#4701).
496
+ *
497
+ * This is not a quota cooldown, and the message below says so. It subclasses
498
+ * {@link CodexAccountCooldownError} for one reason: the deadline-carrying refusal has exactly
499
+ * one representation in this codebase, and roughly a dozen transports already map it to a 429
500
+ * with `Retry-After` and treat it as an expected terminal answer rather than a credential
501
+ * fault. Introducing a parallel type would mean either re-deriving that handling in every one
502
+ * of them or silently falling through to a 500 in the ones that were missed.
503
+ *
504
+ * What must NOT be inherited is the quota wording -- "cooling down", `ocx account
505
+ * clear-cooldown` -- because none of it describes a 5xx hold and following it would do
506
+ * nothing. {@link cooldownErrorMessage} therefore returns this class's own message verbatim,
507
+ * the same escape hatch {@link CodexMainAccountHardLockError} and
508
+ * {@link CodexReserveUnavailableError} already use.
509
+ *
510
+ * `cooldownUntil` carries the limiter's own change point, which is strictly in the future:
511
+ * either the moment the held account may next be probed or the moment the recovery window
512
+ * moves, whichever is later. A refusal that answered `now` would busy-loop the caller into
513
+ * the same load it just declined.
514
+ */
515
+ export class CodexRecoveryWithheldError extends CodexAccountCooldownError {
516
+ /** The sibling still remembered for this thread, when one exists but is itself unusable. */
517
+ readonly detourAccountId?: string;
518
+
519
+ constructor(accountId: string, retryAt: number, detourAccountId?: string) {
520
+ super(accountId, retryAt);
521
+ this.name = "CodexRecoveryWithheldError";
522
+ this.detourAccountId = detourAccountId;
523
+ this.message = `Codex account (${cooldownAccountLabel(accountId)}) is held after repeated upstream`
524
+ + ` failures and the pool's recovery budget for this window is spent, so nothing was sent`
525
+ + ` upstream. Retry after ${new Date(retryAt).toISOString()}.`
526
+ + " This clears on its own as the account recovers; no cooldown to lift and no account to switch.";
527
+ }
528
+ }
529
+
427
530
  export type CodexAuthPolicyConfig = Readonly<Pick<OcxConfig,
428
531
  "codexMainAccountHardLock" | "codexDesktopAuthless" | "runtimeRole" | "pausedCodexAccountIds"
429
532
  >>;
@@ -634,7 +737,12 @@ export function cooldownAccountLabel(accountId: string): string {
634
737
  * injected `openai_base_url` in config.toml.
635
738
  */
636
739
  export function cooldownErrorMessage(err: CodexAccountCooldownError, accountSelector?: string): string {
637
- if (err instanceof CodexMainAccountHardLockError || err instanceof CodexReserveUnavailableError) return err.message;
740
+ if (err instanceof CodexMainAccountHardLockError
741
+ || err instanceof CodexReserveUnavailableError
742
+ // A transient-hold refusal is not a quota cooldown. Its own wording is the only accurate
743
+ // one, and the quota recovery advice below would send the operator after a cooldown that
744
+ // does not exist (#4701).
745
+ || err instanceof CodexRecoveryWithheldError) return err.message;
638
746
  const until = new Date(err.cooldownUntil).toISOString();
639
747
  const scopeLabels: Record<CodexQuotaScope, string> = {
640
748
  shared: "shared native quota", reserve: "Reserve quota",
@@ -717,6 +825,11 @@ export interface ResolveCodexAuthContextOptions {
717
825
  requestScopedMainCredential?: boolean;
718
826
  /** Test seam for a Direct request's own forwarded ChatGPT credential. */
719
827
  isDirectCallerEntitledToCodexModel?: (headers: Headers, modelId: string) => Promise<boolean>;
828
+ /**
829
+ * This request's conversation carries live uploaded-file references (#4778). Retains the bound
830
+ * account across a VOLUNTARY quota move; involuntary release is untouched.
831
+ */
832
+ retainAccountForUploadedFiles?: boolean;
720
833
  }
721
834
 
722
835
  export interface CodexAccountSelectionAdmission {
@@ -741,19 +854,15 @@ export async function resolveCodexAuthContext(
741
854
  throw new CodexReserveUnavailableError();
742
855
  }
743
856
  const fixedAccountId = reserve ? MAIN_CODEX_ACCOUNT_ID : options.accountId;
744
- const requestOwnedMainPinCandidate = requestScopedMainCredential
745
- && fixedAccountId === undefined
746
- && config.activeCodexAccountPinned === MAIN_CODEX_ACCOUNT_ID
747
- && isEffectiveCodexAccountPinned(config)
748
- && !policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID)
749
- && requestOwnedMainPinHasQuotaHeadroom(config);
857
+ const {
858
+ candidate: requestOwnedMainPinCandidate,
859
+ preserve: preserveRequestOwnedMainPin,
860
+ } = requestOwnedMainPinState(headers, config, policy, requestScopedMainCredential, fixedAccountId);
750
861
  // During an owned startup, equality cannot be established until recovery and the
751
862
  // memory-only policy binding finish. This read-only fence never probes a foreign home.
752
863
  if (policy.codexMainAccountHardLock === true && requestOwnedMainPinCandidate && isMainAccountPolicyBindingPending()) {
753
864
  throw new CodexMainProfileDrainingError();
754
865
  }
755
- const preserveRequestOwnedMainPin = requestOwnedMainPinCandidate
756
- && !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy));
757
866
  if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) {
758
867
  throw new Error("Codex auth context cannot select and exclude an account simultaneously");
759
868
  }
@@ -870,6 +979,15 @@ export async function resolveCodexAuthContext(
870
979
  // Why this request is on this account, carried to the request log so a move reads as an event
871
980
  // instead of something inferred from account labels across lines (#4546).
872
981
  let affinityDecision: CodexAffinityDecision | undefined;
982
+ // The half-open trial this request was granted, if it is the one allowed to test a held
983
+ // account. Declared out here because the release paths below and the returned context are on
984
+ // opposite sides of several throws (#4701).
985
+ let transientProbe: TransientProbeGrant | undefined;
986
+ const releaseTransientProbeGrant = (): void => {
987
+ if (!transientProbe) return;
988
+ releaseTransientProbe(transientProbe.lease);
989
+ transientProbe = undefined;
990
+ };
873
991
  // Retained startup recovery makes the physical main identity ineligible. Routing
874
992
  // can still preserve service by selecting a healthy configured pool account. A
875
993
  // request-owned bearer likewise cannot inspect or reconcile file-main state.
@@ -900,6 +1018,23 @@ export async function resolveCodexAuthContext(
900
1018
  const modelEligibleAccountIds = entitledAccountIds
901
1019
  ? new Set([...entitledAccountIds].filter(candidate => !excludeAccountIds?.has(candidate)))
902
1020
  : undefined;
1021
+ // #4768: the flagships stay visible and never fail closed, so this is evidence routing may
1022
+ // ORDER by, not evidence it may refuse on. Read synchronously from rosters discovery has
1023
+ // already gathered -- no upstream fetch joins the request path for the most commonly
1024
+ // requested models in the product -- and passed to selection as a preference that is dropped
1025
+ // whenever honouring it would leave no candidate.
1026
+ //
1027
+ // Under the SAME exclusion the entitlement snapshot above uses. The reader validates each
1028
+ // cached roster against the account's current credential, and for native main that is a
1029
+ // synchronous read of the physical stored token -- exactly what this request is forbidden to
1030
+ // touch while a profile switch drains it or while it is served by a request-owned credential.
1031
+ // Excluding main here costs nothing: the preference is an ordering hint, so main becomes
1032
+ // unknown rather than denied, and unknown leaves selection exactly as it was.
1033
+ const deniedModelAccountIds = cachedDeniedCodexAccountIdsForModel(
1034
+ options.modelId,
1035
+ undefined,
1036
+ { excludeAccountIds },
1037
+ );
903
1038
  const selectionOptions = {
904
1039
  // Temporary switch drain keeps the candidate until the atomic claim rejects
905
1040
  // it. Retained recovery makes main wholly ineligible so pool routing continues.
@@ -911,13 +1046,21 @@ export async function resolveCodexAuthContext(
911
1046
  ? () => preserveRequestOwnedMainPin
912
1047
  : options.isMainAccountTokenLive,
913
1048
  modelEligibleAccountIds,
1049
+ deniedModelAccountIds,
1050
+ // Request-scoped and deliberately absent from `sharedStateSelectionOptions`: one
1051
+ // conversation's attachments say nothing about where unrelated threads should be served.
1052
+ retainAccountForUploadedFiles: options.retainAccountForUploadedFiles === true,
914
1053
  };
915
1054
  // A pre-drain selector reserves the native identity while reconciliation and
916
1055
  // routing inspect it. Selectors arriving after the fence skip reconciliation
917
1056
  // and may still route to non-main pool accounts without touching switch state.
918
1057
  if (reserve && !nativeMainReadsForbidden && !selectionAdmission) throw new CodexMainProfileDrainingError();
919
1058
  if (!nativeMainReadsForbidden) reconcileMainCodexAccountRuntimeState();
920
- const resolution = fixedAccountId !== undefined
1059
+ // Annotated, not inferred. The two literals below carry neither `affinity` nor
1060
+ // `transientProbe`, so an inferred union makes `"k" in resolution` widen those reads to
1061
+ // `unknown` and a discriminant narrowing fail outright. Contextually typing every branch to
1062
+ // the resolver's own union is what lets the reads below stay total.
1063
+ const resolution: CodexThreadResolution = fixedAccountId !== undefined
921
1064
  ? { status: "selected" as const, accountId: fixedAccountId }
922
1065
  : options.excludeAccountId
923
1066
  ? (() => {
@@ -942,8 +1085,17 @@ export async function resolveCodexAuthContext(
942
1085
  lineage,
943
1086
  );
944
1087
  if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId);
1088
+ // THE REFUSAL. Every candidate is held, the recovery budget is spent, and no detour is
1089
+ // left -- so this request must not reach upstream at all. Returning the held account here
1090
+ // is what #4701 is about: under a provider-wide 503 that is every bound request piling
1091
+ // onto an account already known to be failing. Thrown before any credential is read, so
1092
+ // nothing is sent and nothing is spent.
1093
+ if (resolution.status === "withheld") {
1094
+ throw new CodexRecoveryWithheldError(resolution.accountId, resolution.retryAt, resolution.detourAccountId);
1095
+ }
945
1096
  const selected = resolution.status === "selected" ? resolution.accountId : null;
946
- affinityDecision = "affinity" in resolution ? resolution.affinity : undefined;
1097
+ affinityDecision = resolution.affinity;
1098
+ transientProbe = resolution.status === "selected" ? resolution.transientProbe : undefined;
947
1099
  if (!selected) {
948
1100
  // A retry that excluded a failed Pool account may still use the validated caller-owned
949
1101
  // main credential. Treating every exclusion as if main itself had failed strands a healthy
@@ -1025,12 +1177,25 @@ export async function resolveCodexAuthContext(
1025
1177
  throw new CodexPoolAuthenticationError("Selected Codex account is unavailable");
1026
1178
  }
1027
1179
  }
1180
+ } catch (cause) {
1181
+ // Selection granted a trial and then a later policy check refused the account. The trial
1182
+ // never runs, so hand it back instead of leaving the held account unprobeable until its
1183
+ // deadline lapses (#4701).
1184
+ releaseTransientProbeGrant();
1185
+ throw cause;
1028
1186
  } finally {
1029
1187
  selectionAdmission?.release();
1030
1188
  }
1031
1189
  // Legacy selectors may retain an unusable account for actionable errors. A
1032
1190
  // deferred credential must never become request auth through that fallback.
1033
- assertCodexAccountValidationReady(accountId);
1191
+ try {
1192
+ assertCodexAccountValidationReady(accountId);
1193
+ } catch (cause) {
1194
+ // Nothing will reach upstream, so give the trial back instead of leaving the held account
1195
+ // unprobeable until the lease deadline lapses (#4701).
1196
+ releaseTransientProbeGrant();
1197
+ throw cause;
1198
+ }
1034
1199
  // Lazy prime: if the selected account has no quota yet, the pool is likely
1035
1200
  // unprimed (dashboard never opened, or startup prime was blocked). Kick a
1036
1201
  // best-effort prime so the NEXT routing decision has real scores. This never
@@ -1049,6 +1214,13 @@ export async function resolveCodexAuthContext(
1049
1214
  // a literal Retry-After reads very differently to a user than a reset-derived guess.
1050
1215
  const cooldown = getCodexQuotaHealthSnapshot(accountId, quotaScope);
1051
1216
  const cooldownUntil = cooldown?.cooldownUntil;
1217
+ // A transient-hold trial and a quota cooldown cannot both describe this account:
1218
+ // `isTransientOnlyAffinityBlock` refuses to recognise a transient hold on an account carrying
1219
+ // quota health, so the cooldown branch below is unreachable while a trial is held. That is
1220
+ // also why no request pays two recovery permits for one send. The release is defensive --
1221
+ // should that invariant ever move, the trial is handed back rather than stranded behind a
1222
+ // refusal that belongs to the other domain.
1223
+ if (cooldownUntil && transientProbe) releaseTransientProbeGrant();
1052
1224
  // A cooled-down account never sends traffic, so upstream recovery can never be
1053
1225
  // observed and the cooldown outlives the real limit. Admit one probe per
1054
1226
  // interval; its outcome decides whether the cooldown ends (#433).
@@ -1089,6 +1261,7 @@ export async function resolveCodexAuthContext(
1089
1261
  if (token) mainQuotaWriter = observeSelectedMainCredential(token, mainQuotaWriter);
1090
1262
  assertMainAccountPolicy(policy);
1091
1263
  } catch (cause) {
1264
+ releaseTransientProbeGrant();
1092
1265
  if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
1093
1266
  else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
1094
1267
  if (cause instanceof CodexMainAccountHardLockError) throw cause;
@@ -1099,15 +1272,23 @@ export async function resolveCodexAuthContext(
1099
1272
  }
1100
1273
  if (!token) {
1101
1274
  // Nothing will reach upstream, so give the probe back instead of burning it.
1275
+ releaseTransientProbeGrant();
1102
1276
  if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
1103
1277
  else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
1104
1278
  throw new CodexPoolAuthenticationError(
1105
1279
  fixedAccountId !== undefined ? "Selected Codex account is unavailable" : undefined,
1106
1280
  );
1107
1281
  }
1108
- const reserveAuthorization = reserve
1109
- ? await authorizeReserveCredential(token, mainQuotaWriter, policy, options.signal, undefined, writerGeneration)
1110
- : undefined;
1282
+ let reserveAuthorization: MainReserveAuthorization | undefined;
1283
+ try {
1284
+ reserveAuthorization = reserve
1285
+ ? await authorizeReserveCredential(token, mainQuotaWriter, policy, options.signal, undefined, writerGeneration)
1286
+ : undefined;
1287
+ } catch (cause) {
1288
+ // A Reserve refusal ends the request here, so the trial it was holding never runs.
1289
+ releaseTransientProbeGrant();
1290
+ throw cause;
1291
+ }
1111
1292
  return {
1112
1293
  kind: "main-pool",
1113
1294
  accountId,
@@ -1121,6 +1302,7 @@ export async function resolveCodexAuthContext(
1121
1302
  ...(quotaScope ? { quotaScope } : {}),
1122
1303
  ...(probeLeaseId ? { probeLeaseId } : {}),
1123
1304
  ...(probeQuotaScope ? { probeQuotaScope } : {}),
1305
+ ...(transientProbe ? { transientProbe } : {}),
1124
1306
  };
1125
1307
  }
1126
1308
 
@@ -1141,8 +1323,10 @@ export async function resolveCodexAuthContext(
1141
1323
  ...(probeLeaseId ? { probeLeaseId } : {}),
1142
1324
  ...(probeQuotaScope ? { probeQuotaScope } : {}),
1143
1325
  ...(affinityDecision ? { affinityDecision } : {}),
1326
+ ...(transientProbe ? { transientProbe } : {}),
1144
1327
  };
1145
1328
  } catch (cause) {
1329
+ releaseTransientProbeGrant();
1146
1330
  if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
1147
1331
  else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
1148
1332
  if (!options.signal?.aborted && shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
@@ -34,7 +34,7 @@ import upstreamModelsSnapshot from "../data/upstream-models.json";
34
34
 
35
35
 
36
36
  import { catalogModelSlug } from "./parsing";
37
- import type { CatalogModel } from "./parsing";
37
+ import type { CatalogModel, RawEntry } from "./parsing";
38
38
 
39
39
  export const openAiApiCollisionWarnings = new Set<string>();
40
40
 
@@ -222,6 +222,85 @@ export function safeCatalogWarningLabel(value: string): string {
222
222
  .slice(0, 200);
223
223
  }
224
224
 
225
+ /**
226
+ * Keep the first row of each slug and drop the rest (#4730).
227
+ *
228
+ * First-win is the only answer that agrees with the ordering already decided upstream: the merge
229
+ * ranks rows, so its first occurrence is the row it chose. Distinct slugs are never touched — an
230
+ * alias row and the canonical routed row of the same provider model are two different public names
231
+ * and both survive — and a row without a string slug passes through untouched.
232
+ */
233
+ export function dedupeCatalogEntriesBySlug(models: RawEntry[]): RawEntry[] {
234
+ const seen = new Set<string>();
235
+ const out: RawEntry[] = [];
236
+ for (const entry of models) {
237
+ if (typeof entry.slug !== "string") {
238
+ out.push(entry);
239
+ continue;
240
+ }
241
+ if (seen.has(entry.slug)) continue;
242
+ seen.add(entry.slug);
243
+ out.push(entry);
244
+ }
245
+ return out;
246
+ }
247
+
248
+ /**
249
+ * Every slug this proxy writes into the Codex catalog must appear exactly once (#4730).
250
+ *
251
+ * The cost of breaking it is the whole file: a slug-unique validating consumer refuses the catalog
252
+ * outright, so one duplicated row takes every model with it. A 2.56.0 report carried 507 rows for
253
+ * 72 unique slugs, every duplicate byte-identical, and Codex rejected the file as `source-invalid`.
254
+ *
255
+ * This is a write-boundary invariant rather than a repair of one producer, and that distinction is
256
+ * deliberate: the reported catalog is evidence that some emit path can double a row, but nothing in
257
+ * this tree has been shown to be that path, and a guard that only covered the producer someone
258
+ * guessed at would leave the file corruptible by the next one. Both writers that serialize a merged
259
+ * catalog call this as their LAST mutation — `writeRetainedCatalogSync` and the management
260
+ * convergence commit — so uniqueness holds for the exact bytes that land on disk.
261
+ *
262
+ * Ordering is load-bearing. Running the guard before the effort clamp would be unsound:
263
+ * `clampCatalogModelsToObservedCodexSupport` splices whole rows out when an exact-reserve ladder
264
+ * clamps empty, so dropping a later same-slug row first can leave the slug with no row at all once
265
+ * the surviving one is spliced.
266
+ *
267
+ * @param models - The finished row list, already clamped and finalized.
268
+ * @param warn - Whether to report on `console.warn`. The convergence path merges under
269
+ * `warningPolicy: "suppress"` and stays silent for the same reason.
270
+ * @returns The original array when it was already unique, so an unchanged catalog stays a no-op
271
+ * write; otherwise a first-win copy.
272
+ */
273
+ export function enforceCatalogSlugUniqueness(models: RawEntry[], warn: boolean): RawEntry[] {
274
+ const deduped = dedupeCatalogEntriesBySlug(models);
275
+ if (deduped.length === models.length) return models;
276
+ if (warn) {
277
+ // A dropped row that differs from the kept one means two emit paths disagree about the same
278
+ // slug's content. First-win still stands, but the operator needs to see WHICH slugs diverged
279
+ // instead of silently losing data. The baseline is the row the dedupe actually keeps — the
280
+ // FIRST occurrence — so the reported divergence is measured against what lands on disk.
281
+ const keptBySlug = new Map<string, RawEntry>();
282
+ for (const entry of models) {
283
+ if (typeof entry.slug !== "string" || keptBySlug.has(entry.slug)) continue;
284
+ keptBySlug.set(entry.slug, entry);
285
+ }
286
+ const divergentSlugs = new Set<string>();
287
+ for (const entry of models) {
288
+ if (typeof entry.slug !== "string") continue;
289
+ const kept = keptBySlug.get(entry.slug);
290
+ if (kept && kept !== entry && JSON.stringify(kept) !== JSON.stringify(entry)) {
291
+ divergentSlugs.add(entry.slug);
292
+ }
293
+ }
294
+ const divergentNote = divergentSlugs.size > 0
295
+ ? `; divergent content on: ${[...divergentSlugs].slice(0, 5).map(safeCatalogWarningLabel).join(", ")}${divergentSlugs.size > 5 ? ", …" : ""}`
296
+ : "";
297
+ console.warn(
298
+ `[opencodex] catalog sync dropped ${models.length - deduped.length} duplicate slug row(s), keeping the first occurrence of each slug (#4730)${divergentNote}.`,
299
+ );
300
+ }
301
+ return deduped;
302
+ }
303
+
225
304
  export function comboCatalogWarningSignature(
226
305
  combo: NormalizedComboConfig,
227
306
  members: readonly CatalogModel[],
@@ -290,6 +290,7 @@ export function filterCatalogVisibleModels(
290
290
  }
291
291
  return models.filter(m => {
292
292
  if (initialModelSelectionPending(config.providers[m.provider])) return false;
293
+ if (config.providers[m.provider]?.disabled === true) return false;
293
294
  const nativeAlias = m.provider === COMBO_NAMESPACE && m.nativeAlias === true;
294
295
  // disabledModels may be stored raw (canonical) or encoded (legacy UI writes).
295
296
  for (const stored of disabled) {
@@ -113,12 +113,42 @@ function safeTimeout(value: number | undefined): number {
113
113
  ? Math.min(Math.floor(value), 120_000) : DEFAULT_TIMEOUT_MS;
114
114
  }
115
115
 
116
+ /** Match Bun fetch's environment routing, not the broader WebSocket NO_PROXY grammar. */
117
+ function catalogRequestUsesBunHttpProxy(url: URL): boolean {
118
+ if (url.protocol !== "http:") return false;
119
+ const proxy = process.env.http_proxy || process.env.HTTP_PROXY;
120
+ if (!proxy || proxy === '""' || proxy === "''") return false;
121
+ const hostname = url.hostname.toLowerCase();
122
+ const host = url.host.toLowerCase();
123
+ // Bun env_loader::is_no_proxy (1.4.2): lowercase wins unless empty, ASCII
124
+ // whitespace only, no scheme/path/wildcard/bracket/trailing-dot normalization.
125
+ const bypasses = process.env.no_proxy || process.env.NO_PROXY || "";
126
+ for (let entry of bypasses.split(",")) {
127
+ entry = entry.replace(/^[ \t\n\r\v\f]+|[ \t\n\r\v\f]+$/g, "")
128
+ .replace(/[A-Z]/g, letter => letter.toLowerCase());
129
+ if (entry === "*") return false;
130
+ if (entry.startsWith(".")) entry = entry.slice(1);
131
+ if (!entry) continue;
132
+ const hasPort = entry.startsWith("[")
133
+ ? entry.includes("]:")
134
+ : (entry.match(/:/g)?.length ?? 0) === 1;
135
+ if (hasPort ? host === entry : hostname === entry || hostname.endsWith(`.${entry}`)) return false;
136
+ }
137
+ return true;
138
+ }
139
+
116
140
  export async function fetchRemoteCatalog(
117
141
  input: string,
118
142
  options: Pick<PullRemoteCatalogOptions, "token" | "timeoutMs" | "maxBytes" | "fetchImpl"> = {},
119
143
  ): Promise<{ document: RemoteCatalogDocument; content: string }> {
120
144
  const url = validateRemoteCatalogUrl(input);
121
145
  const token = validateToken(options.token);
146
+ if (catalogRequestUsesBunHttpProxy(url)) {
147
+ throw new RemoteCatalogError(
148
+ "insecure_http_refused",
149
+ "Loopback HTTP catalog requests must bypass outbound HTTP proxy routing",
150
+ );
151
+ }
122
152
  const headers = new Headers({ Accept: "application/json" });
123
153
  if (token !== undefined) headers.set("Authorization", `Bearer ${token}`);
124
154
  let response: Response;
@@ -51,7 +51,7 @@ import { bundledCatalogCacheState, loadBundledCodexCatalog } from "./bundled";
51
51
  import { isMultiAgentV2Enabled } from "../features";
52
52
  import { clampCatalogModelsToCodexSupport } from "./effort";
53
53
  import { filterCatalogVisibleModels, gatherRoutedModels, type CatalogGatherProviderModelOutcome } from "./provider-fetch";
54
- import { exactComboCatalogSlugs, type ComboCatalogOmission } from "./aggregation";
54
+ import { dedupeCatalogEntriesBySlug, enforceCatalogSlugUniqueness, exactComboCatalogSlugs, type ComboCatalogOmission } from "./aggregation";
55
55
  import {
56
56
  withCatalogWriteSerialization,
57
57
  type CatalogWritePermit,
@@ -522,6 +522,9 @@ function writeRetainedCatalogSync({
522
522
  });
523
523
  clampCatalogModelsToCodexSupport(catalog.models);
524
524
  finalizeAutoReviewModelOverride(catalog.models, catalogModelsForMerge, config);
525
+ // Last mutation before serialization; see `enforceCatalogSlugUniqueness` for why the ordering
526
+ // against the effort clamp is load-bearing rather than cosmetic.
527
+ catalog.models = enforceCatalogSlugUniqueness(catalog.models, true);
525
528
 
526
529
  const added = goEntries.length + accountBoundEntries.length;
527
530
  const content = `${JSON.stringify(catalog, null, 2)}\n`;
@@ -553,6 +556,11 @@ function writeRetainedCatalogSync({
553
556
  };
554
557
  }
555
558
 
559
+ // Re-exported so the #4730 unit regression keeps importing the guard from the sync module it
560
+ // guards; the implementation lives in ./aggregation because the management convergence commit
561
+ // is the second writer that has to apply the identical rule.
562
+ export { dedupeCatalogEntriesBySlug };
563
+
556
564
  export async function syncCatalogModels(
557
565
  config: OcxConfig,
558
566
  options?: CodexCatalogSyncOptions,
@@ -420,6 +420,42 @@ async function gatherRoutedModelsUncached(
420
420
  if (!memberByKey.has(key)) memberByKey.set(key, synthetic);
421
421
  }
422
422
  }
423
+ // [Decision Log]
424
+ // - 목적과 의도: combo derivation must see the same explicit custom-model capabilities that the
425
+ // final Models inventory publishes. Previously customModels were materialized only after this
426
+ // map had already derived every combo, so one row could say image while its combo said text.
427
+ // - 기존 구현 및 제약 조건: provider/discovery rows remain the inheritance source, and native
428
+ // OpenAI synthesis must run first so a sparse custom row cannot hide native hard limits.
429
+ // - 검토한 주요 대안: move the full custom-row materializer ahead of combos, or overlay only the
430
+ // explicit custom fields onto this private derivation map after provider/native inheritance.
431
+ // - 선택한 방식: use the scoped post-inheritance overlay; the existing final materializer stays
432
+ // the single owner of public custom-row construction and deduplication.
433
+ // - 다른 대안 대신 이 방식을 선택한 이유: moving the large materializer would reorder public
434
+ // catalog production and warning behavior, while this map is already private to combo input.
435
+ // - 장점, 단점 및 영향: custom context/modality/reasoning/tool-mode declarations now constrain
436
+ // their combos without widening unrelated rows; omitted fields retain provider/native limits.
437
+ for (const custom of config.customModels ?? []) {
438
+ const key = `${custom.provider}/${custom.modelId}`;
439
+ const inherited = memberByKey.get(key) ?? {
440
+ provider: custom.provider,
441
+ id: custom.modelId,
442
+ owned_by: custom.provider,
443
+ };
444
+ memberByKey.set(key, {
445
+ ...inherited,
446
+ catalogKind: CODEX_CUSTOM_MODEL_CATALOG_KIND,
447
+ ...(typeof custom.contextWindow === "number" && custom.contextWindow > 0
448
+ ? { contextWindow: custom.contextWindow }
449
+ : {}),
450
+ ...(Array.isArray(custom.inputModalities)
451
+ ? { inputModalities: [...custom.inputModalities] }
452
+ : {}),
453
+ ...(Array.isArray(custom.reasoningEfforts)
454
+ ? { reasoningEfforts: [...custom.reasoningEfforts] }
455
+ : {}),
456
+ ...(custom.codexToolMode !== undefined ? { codexToolMode: custom.codexToolMode } : {}),
457
+ });
458
+ }
423
459
  // Enriched (registry-hydrated) provider clones — shared by combo member synthesis and
424
460
  // custom-model vision-sidecar inheritance so both see the same merged registry view.
425
461
  const enrichedByName = new Map(activeProviders.map(provider => [provider.name, provider.provider]));
@@ -480,7 +516,8 @@ async function gatherRoutedModelsUncached(
480
516
  // with the same slug below, so that row's provider capability metadata is the inheritance source.
481
517
  const replacedByRoutedSlug = new Map(all.map(model => [routedSlug(model.provider, model.id), model]));
482
518
  const customModels = (config.customModels ?? []).map(cm => {
483
- const rawProvider = config.providers[cm.provider];
519
+ const rawProvider = config.providers[cm.provider]?.disabled !== true
520
+ ? config.providers[cm.provider] : undefined;
484
521
  const effectiveProvider = enrichedByName.get(cm.provider) ?? rawProvider;
485
522
  // Registry routing backfills an omitted authMode on the built-in OpenAI provider to
486
523
  // forward. Keep the catalog projection on the same contract while still failing closed
@@ -587,8 +587,14 @@ export async function inspectCodexCliInstall(
587
587
  const platform = deps.platform ?? process.platform;
588
588
  const candidate = observeCodexRuntimeCandidateReadOnly(deps);
589
589
  if (!candidate) {
590
+ // This slice reads no candidate or configuration file on Windows, so an
591
+ // absent proof-captured environment candidate does not establish that no
592
+ // Codex CLI exists: a persisted selection is simply never consulted there.
593
+ // Report the deferral that actually happened instead of the stronger claim
594
+ // that the candidate is unavailable. POSIX retains its existing result
595
+ // when no candidate is observed.
590
596
  return isWindowsPlatform(platform)
591
- ? unknownWindowsReport("candidate_unavailable")
597
+ ? unknownWindowsReport("windows_inspection_deferred")
592
598
  : unknownReport("candidate_unavailable");
593
599
  }
594
600
 
@@ -49,7 +49,7 @@ import {
49
49
  orderForSubagents,
50
50
  } from "./catalog/sync";
51
51
  import { multiAgentV2EnabledFromConfigText } from "./features";
52
- import { exactComboCatalogSlugs } from "./catalog/aggregation";
52
+ import { enforceCatalogSlugUniqueness, exactComboCatalogSlugs } from "./catalog/aggregation";
53
53
  import {
54
54
  isNativeAliasCatalogEntry,
55
55
  accountBoundNativeOpenAiSlugs,
@@ -386,7 +386,12 @@ function prepareCatalog(
386
386
  : null,
387
387
  );
388
388
  finalizeAutoReviewModelOverride(mergedModels, catalogModels, config);
389
- catalog.models = mergedModels;
389
+ // The second writer of this file. A dashboard model toggle, a combo edit, or a Codex account
390
+ // login reaches `convergeCodexCatalog` and commits through `fixedCommit`, never through
391
+ // `writeRetainedCatalogSync`, so the #4730 uniqueness guard has to stand here too or the same
392
+ // `source-invalid` rejection returns by a different route. Silent because this merge runs under
393
+ // `warningPolicy: "suppress"`.
394
+ catalog.models = enforceCatalogSlugUniqueness(mergedModels, false);
390
395
  return catalog;
391
396
  }
392
397