@bitkyc08/opencodex 2.35.0 → 2.36.0-preview.20260830

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 (155) hide show
  1. package/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
  2. package/gui/dist/assets/index-DPl4nBMA.js +112 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +2 -1
  5. package/src/AGENTS.md +2 -1
  6. package/src/adapters/agentrouter.ts +50 -0
  7. package/src/adapters/anthropic.ts +1 -51
  8. package/src/adapters/cursor/call-id.ts +76 -8
  9. package/src/adapters/cursor/checkpoint-store.ts +6 -1
  10. package/src/adapters/cursor/cursor-errors.ts +44 -0
  11. package/src/adapters/cursor/native-exec.ts +13 -0
  12. package/src/adapters/cursor/protobuf-request.ts +651 -29
  13. package/src/adapters/cursor/tool-result-normalize.ts +3 -3
  14. package/src/adapters/cursor/transport-retry.ts +5 -1
  15. package/src/adapters/cursor.ts +15 -1
  16. package/src/adapters/empty-tool-output-annotation.ts +43 -0
  17. package/src/adapters/exec-tool-result-normalize.ts +70 -5
  18. package/src/adapters/google.ts +22 -2
  19. package/src/adapters/kiro.ts +26 -2
  20. package/src/adapters/ollama-native-url.ts +111 -0
  21. package/src/adapters/ollama-native.ts +1131 -0
  22. package/src/adapters/openai-chat.ts +30 -7
  23. package/src/adapters/openai-responses.ts +72 -4
  24. package/src/adapters/registry.ts +7 -0
  25. package/src/adapters/xai-web-search.ts +58 -0
  26. package/src/claude/desktop-3p.ts +21 -1
  27. package/src/claude/desktop-policy.ts +149 -0
  28. package/src/cli/account.ts +16 -2
  29. package/src/cli/claude-desktop.ts +13 -3
  30. package/src/cli/combo.ts +8 -5
  31. package/src/cli/doctor.ts +77 -11
  32. package/src/cli/help.ts +1 -1
  33. package/src/cli/index.ts +16 -0
  34. package/src/cli/models.ts +20 -3
  35. package/src/cli/registry.ts +2 -1
  36. package/src/cli/status.ts +140 -2
  37. package/src/cli/storage.ts +10 -1
  38. package/src/codex/account-runtime-state.ts +39 -5
  39. package/src/codex/account-store.ts +393 -13
  40. package/src/codex/account-usability.ts +11 -4
  41. package/src/codex/app-server-processes.ts +46 -5
  42. package/src/codex/auth-context.ts +160 -32
  43. package/src/codex/catalog/bundled.ts +7 -5
  44. package/src/codex/catalog/metadata.ts +1 -1
  45. package/src/codex/catalog/parsing.ts +57 -1
  46. package/src/codex/catalog/provider-fetch.ts +61 -4
  47. package/src/codex/catalog/sync.ts +4 -3
  48. package/src/codex/convergence.ts +3 -2
  49. package/src/codex/data/upstream-models.json +40 -8
  50. package/src/codex/inject-coordination.ts +111 -14
  51. package/src/codex/integration-record.ts +12 -2
  52. package/src/codex/main-account.ts +225 -1
  53. package/src/codex/model-entitlements.ts +339 -27
  54. package/src/codex/prompt-layers.ts +346 -7
  55. package/src/codex/prompt-text-probe.ts +272 -21
  56. package/src/codex/routing.ts +693 -132
  57. package/src/codex/runtime.ts +12 -0
  58. package/src/codex/subagent-model-fallback.ts +62 -24
  59. package/src/codex/user-identity.ts +33 -25
  60. package/src/combos/index.ts +1 -0
  61. package/src/combos/reset-window.ts +46 -0
  62. package/src/combos/resolve.ts +84 -2
  63. package/src/combos/types.ts +5 -2
  64. package/src/config/atomic-write.ts +104 -22
  65. package/src/config/provider-validation.ts +11 -0
  66. package/src/config.ts +75 -3
  67. package/src/generated/compatibility-version.json +207 -131
  68. package/src/generated/model-metadata.ts +1 -1
  69. package/src/grok/catalog.ts +71 -0
  70. package/src/grok/effort.ts +83 -0
  71. package/src/grok/inject.ts +952 -127
  72. package/src/grok/models.ts +56 -0
  73. package/src/grok/status.ts +21 -8
  74. package/src/grok/sync.ts +10 -18
  75. package/src/images/loop.ts +6 -3
  76. package/src/integrations/native/ownership-preflight.ts +4 -1
  77. package/src/lab/fabric/producer-isolate.ts +36 -3
  78. package/src/lib/destination-policy.ts +93 -7
  79. package/src/lib/redact.ts +6 -1
  80. package/src/lib/shadow-call.ts +38 -3
  81. package/src/lib/test-home-guard.ts +18 -3
  82. package/src/lib/upstream-retry.ts +43 -6
  83. package/src/lib/windows-secret-acl.ts +66 -0
  84. package/src/lib/windows-text.ts +28 -2
  85. package/src/lib/windows-user-principal.ts +35 -23
  86. package/src/oauth/account-quota-rank.ts +107 -0
  87. package/src/oauth/anthropic-routing.ts +125 -30
  88. package/src/oauth/chatgpt.ts +5 -1
  89. package/src/oauth/generic-account-failover.ts +114 -7
  90. package/src/oauth/index.ts +15 -8
  91. package/src/oauth/store.ts +16 -0
  92. package/src/providers/account-quota-disk.ts +79 -0
  93. package/src/providers/command-code-efforts.ts +24 -0
  94. package/src/providers/derive.ts +6 -0
  95. package/src/providers/key-failover.ts +33 -1
  96. package/src/providers/kiro-usage.ts +272 -0
  97. package/src/providers/ollama-show.ts +311 -0
  98. package/src/providers/openai-sidecar.ts +5 -0
  99. package/src/providers/quota-routing-cache.ts +32 -0
  100. package/src/providers/quota-types.ts +36 -0
  101. package/src/providers/quota-wire.ts +102 -0
  102. package/src/providers/quota.ts +208 -147
  103. package/src/providers/registry.ts +68 -8
  104. package/src/providers/slug-codec.ts +12 -4
  105. package/src/providers/vercel-gateway-routing.ts +108 -0
  106. package/src/router.ts +22 -12
  107. package/src/server/auth-cors.ts +26 -0
  108. package/src/server/catalog-download.ts +73 -0
  109. package/src/server/chat-native.ts +12 -2
  110. package/src/server/gui-static.ts +4 -1
  111. package/src/server/index.ts +132 -9
  112. package/src/server/management/agent-settings-routes.ts +38 -5
  113. package/src/server/management/codex-prompt-routes.ts +7 -1
  114. package/src/server/management/combo-routes.ts +10 -1
  115. package/src/server/management/config-routes.ts +9 -1
  116. package/src/server/management/context.ts +5 -0
  117. package/src/server/management/model-routes.ts +16 -6
  118. package/src/server/management/native-integration-routes.ts +12 -17
  119. package/src/server/management/oauth-account-routes.ts +13 -0
  120. package/src/server/management/provider-routes.ts +32 -5
  121. package/src/server/management/routing-profile-routes.ts +15 -0
  122. package/src/server/management/shadow-call-validation.ts +29 -0
  123. package/src/server/management-api.ts +7 -3
  124. package/src/server/request-log.ts +3 -5
  125. package/src/server/responses/agent-task-recovery-cache.ts +8 -0
  126. package/src/server/responses/agent-task-recovery.ts +52 -20
  127. package/src/server/responses/codex-auth-error.ts +26 -0
  128. package/src/server/responses/compact.ts +345 -10
  129. package/src/server/responses/core.ts +736 -108
  130. package/src/server/responses/empty-completion-guard.ts +16 -0
  131. package/src/server/responses/fetch-helpers.ts +42 -0
  132. package/src/server/responses/policy-fallback.ts +11 -6
  133. package/src/server/responses-undeclared-tool-guard.ts +16 -3
  134. package/src/server/startup-health-cache.ts +59 -13
  135. package/src/service-manager-probe.ts +115 -9
  136. package/src/service.ts +139 -40
  137. package/src/storage/cleanup.ts +10 -0
  138. package/src/storage/storage-mutation-coordinator.ts +14 -3
  139. package/src/tray/windows-tray.ps1 +10 -4
  140. package/src/tray/windows.ts +30 -2
  141. package/src/types/config.ts +27 -14
  142. package/src/types/provider.ts +54 -0
  143. package/src/types/tools.ts +13 -3
  144. package/src/types.ts +4 -0
  145. package/src/usage/summary.ts +421 -177
  146. package/src/vision/anthropic-describe.ts +3 -3
  147. package/src/vision/describe.ts +5 -3
  148. package/src/web-search/anthropic-executor.ts +9 -2
  149. package/src/web-search/exa-executor.ts +3 -3
  150. package/src/web-search/executor.ts +8 -3
  151. package/src/web-search/gemini-executor.ts +3 -3
  152. package/src/web-search/loop.ts +11 -3
  153. package/src/web-search/xai-executor.ts +3 -3
  154. package/gui/dist/assets/index-DNdRKXK9.js +0 -112
  155. package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
@@ -12,7 +12,15 @@ import { isCodexAccountPaused } from "./account-pause";
12
12
  import { ConfigMutationLockError } from "../config";
13
13
  import { isCodexAccountUsable } from "./account-usability";
14
14
  import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
15
- import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken, isMainAccountTokenLive } from "./main-account";
15
+ import {
16
+ MAIN_CODEX_ACCOUNT_ID,
17
+ MainAccountTokenRefreshError,
18
+ MainAuthJsonChangedDuringRefreshError,
19
+ getMainAccountToken,
20
+ getValidMainAccountToken,
21
+ isMainAccountTokenLive,
22
+ type NativeMainRefreshDependencies,
23
+ } from "./main-account";
16
24
  import { isNativeMainTrafficBlocked, nativeMainStartupGateSnapshot } from "./native-profile-startup";
17
25
  import type { NativeMainStartupBlockReason } from "./native-profile-startup";
18
26
  import {
@@ -39,6 +47,7 @@ import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
39
47
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
40
48
  import { captureConfigGeneration } from "../lib/state-store-sweeper";
41
49
  import { retainedUtf8Bytes } from "../lib/admission";
50
+ import { extractAccountId } from "../oauth/chatgpt";
42
51
 
43
52
  const CODEX_AFFINITY_COMPONENT_MAX_BYTES = 512;
44
53
  const CODEX_APP_AFFINITY_KEY = randomBytes(32);
@@ -97,7 +106,7 @@ export type CodexAuthContext =
97
106
  }
98
107
  | {
99
108
  // Main Codex account participating in rotation: token injected from ~/.codex/auth.json
100
- // (Option A). Distinct from "main" (passthrough fallback that forwards the client token).
109
+ // (Option A). Distinct from "main" (request-owned passthrough or Direct mode).
101
110
  kind: "main-pool";
102
111
  accountId: string;
103
112
  writerGeneration: number;
@@ -317,6 +326,8 @@ export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown):
317
326
  && !(cause instanceof CodexCredentialRefreshLockTimeoutError)
318
327
  && !(cause instanceof CodexCredentialRefreshBusyError)
319
328
  && !(cause instanceof CodexCredentialRefreshStaleError)
329
+ && !(cause instanceof MainAuthJsonChangedDuringRefreshError)
330
+ && !(cause instanceof MainAccountTokenRefreshError && cause.reason === "transient")
320
331
  && !(cause instanceof ConfigMutationLockError);
321
332
  }
322
333
 
@@ -331,11 +342,16 @@ export interface ResolveCodexAuthContextOptions {
331
342
  /** Test-only native credential read seams. */
332
343
  isMainAccountTokenLive?: () => boolean;
333
344
  getMainAccountToken?: typeof getMainAccountToken;
345
+ getValidMainAccountToken?: typeof getValidMainAccountToken;
346
+ nativeMainRefreshDependencies?: NativeMainRefreshDependencies;
347
+ signal?: AbortSignal;
334
348
  primeCodexPoolQuotas?: (config: OcxConfig, reason: string) => Promise<void>;
335
349
  /** Test seam for account-gated native model discovery. */
336
350
  resolveCodexModelEntitlements?: typeof resolveCodexModelEntitlements;
337
351
  /** Direct requests admitted with a proxy bearer substitute the stored native-main credential. */
338
352
  substituteMainCredentialForDirect?: boolean;
353
+ /** A validated native Codex bearer may serve this request without entering Pool state. */
354
+ requestScopedMainCredential?: boolean;
339
355
  /** Test seam for a Direct request's own forwarded ChatGPT credential. */
340
356
  isDirectCallerEntitledToCodexModel?: (headers: Headers, modelId: string) => Promise<boolean>;
341
357
  }
@@ -353,36 +369,86 @@ export async function resolveCodexAuthContext(
353
369
  options: ResolveCodexAuthContextOptions = {},
354
370
  ): Promise<CodexAuthContext> {
355
371
  const writerGeneration = captureConfigGeneration();
372
+ const requestScopedMainCredential = options.requestScopedMainCredential === true
373
+ && hasCallerCodexBearer(headers);
356
374
  const fixedAccountId = options.accountId;
357
375
  if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) {
358
376
  throw new Error("Codex auth context cannot select and exclude an account simultaneously");
359
377
  }
360
- // An explicit namespace binding is stronger than the provider's default mode. It must use the
361
- // selected stored credential even while the canonical OpenAI provider is globally Direct.
362
- if (mode === "direct" && fixedAccountId === undefined) {
378
+ const resolveCallerOwnedMainContext = async (): Promise<CodexAuthContext> => {
363
379
  if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError();
364
- if (options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)) {
365
- const entitled = options.substituteMainCredentialForDirect
366
- ? entitledCodexAccountIdsForModel(
367
- await (options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements)(config),
368
- options.modelId,
369
- )?.has(MAIN_CODEX_ACCOUNT_ID) === true
370
- : await (options.isDirectCallerEntitledToCodexModel ?? isDirectCallerEntitledToCodexModel)(
371
- headers,
372
- options.modelId,
373
- );
374
- if (!entitled) {
375
- throw new CodexPoolAuthenticationError("The selected ChatGPT account does not support this model");
380
+ const substituteStoredMain = options.substituteMainCredentialForDirect === true;
381
+ if (!substituteStoredMain) {
382
+ if (options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)) {
383
+ const entitled = await (
384
+ options.isDirectCallerEntitledToCodexModel ?? isDirectCallerEntitledToCodexModel
385
+ )(headers, options.modelId);
386
+ if (!entitled) {
387
+ throw new CodexPoolAuthenticationError("The selected ChatGPT account does not support this model");
388
+ }
376
389
  }
390
+ return { kind: "main", accountId: null };
377
391
  }
378
- return { kind: "main", accountId: null };
392
+
393
+ // Admission-bearer Direct requests later replace the proxy secret with the stored
394
+ // native-main credential. Reserve and claim that physical profile before entitlement
395
+ // discovery or materialization can read it; caller-owned Direct credentials never enter
396
+ // this branch. A missing turn admission must fail closed instead of recreating an
397
+ // untracked native-main read.
398
+ if (isNativeMainTrafficBlocked()) throw new CodexMainProfileDrainingError();
399
+ const directSelectionAdmission = options.beginCodexAccountSelection?.();
400
+ if (!directSelectionAdmission) throw new CodexMainProfileDrainingError();
401
+ try {
402
+ if (
403
+ directSelectionAdmission.mainProfileDraining
404
+ || !directSelectionAdmission.claimMainProfile()
405
+ || isNativeMainTrafficBlocked()
406
+ ) {
407
+ throw new CodexMainProfileDrainingError();
408
+ }
409
+ if (options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)) {
410
+ const entitled = entitledCodexAccountIdsForModel(
411
+ await (options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements)(config, {
412
+ signal: options.signal,
413
+ nativeMainRefreshDependencies: options.nativeMainRefreshDependencies,
414
+ }),
415
+ options.modelId,
416
+ )?.has(MAIN_CODEX_ACCOUNT_ID) === true;
417
+ if (!entitled) {
418
+ throw new CodexPoolAuthenticationError("The selected ChatGPT account does not support this model");
419
+ }
420
+ }
421
+ return { kind: "main", accountId: null };
422
+ } finally {
423
+ // The short selector reservation ends here. A successful claim remains owned by
424
+ // the enclosing turn lease until the request or transferred stream settles.
425
+ directSelectionAdmission.release();
426
+ }
427
+ };
428
+ // An explicit namespace binding is stronger than the provider's default mode. It must use the
429
+ // selected stored credential even while the canonical OpenAI provider is globally Direct.
430
+ // A request-owned bearer is deliberately not represented as `main-pool`: Pool account ids own
431
+ // durable health, quota, and affinity state, while this credential exists for one request only.
432
+ if ((mode === "direct" && fixedAccountId === undefined)
433
+ || (requestScopedMainCredential && fixedAccountId === MAIN_CODEX_ACCOUNT_ID)) {
434
+ return resolveCallerOwnedMainContext();
379
435
  }
380
- const affinityKey = fixedAccountId === undefined ? codexPoolAffinityKey(headers) : undefined;
436
+ // A caller bearer can still accompany a request that selects a configured Pool account. Do not
437
+ // let that request read, delete, or create a file-main affinity binding while deciding whether a
438
+ // stored account is available; only the stored credential selected below may own Pool state.
439
+ const affinityKey = fixedAccountId === undefined && !requestScopedMainCredential
440
+ ? codexPoolAffinityKey(headers)
441
+ : undefined;
381
442
  // Retained startup recovery makes the physical main identity ineligible. Routing
382
- // can still preserve service by selecting a healthy configured pool account.
443
+ // can still preserve service by selecting a healthy configured pool account. A
444
+ // request-owned bearer likewise cannot inspect or reconcile file-main state.
383
445
  const nativeMainTrafficBlocked = isNativeMainTrafficBlocked();
384
446
  const selectionAdmission = options.beginCodexAccountSelection?.();
385
- const nativeMainReadsForbidden = nativeMainTrafficBlocked || selectionAdmission?.mainProfileDraining === true;
447
+ const nativeMainReadsForbidden = requestScopedMainCredential
448
+ || nativeMainTrafficBlocked
449
+ || selectionAdmission?.mainProfileDraining === true;
450
+ const nativeMainSelectionOnly = !nativeMainTrafficBlocked
451
+ && selectionAdmission?.mainProfileDraining === true;
386
452
  let accountId: string;
387
453
  const quotaScope = codexQuotaScopeForModel(options.modelId);
388
454
  try {
@@ -390,7 +456,11 @@ export async function resolveCodexAuthContext(
390
456
  ? new Set([MAIN_CODEX_ACCOUNT_ID])
391
457
  : undefined;
392
458
  const entitlementSnapshot = options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)
393
- ? await (options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements)(config, { excludeAccountIds })
459
+ ? await (options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements)(config, {
460
+ excludeAccountIds,
461
+ signal: options.signal,
462
+ nativeMainRefreshDependencies: options.nativeMainRefreshDependencies,
463
+ })
394
464
  : undefined;
395
465
  const entitledAccountIds = entitlementSnapshot
396
466
  ? entitledCodexAccountIdsForModel(entitlementSnapshot, options.modelId)
@@ -401,9 +471,10 @@ export async function resolveCodexAuthContext(
401
471
  const selectionOptions = {
402
472
  // Temporary switch drain keeps the candidate until the atomic claim rejects
403
473
  // it. Retained recovery makes main wholly ineligible so pool routing continues.
404
- nativeMainSelectionOnly: !nativeMainTrafficBlocked
405
- && selectionAdmission?.mainProfileDraining === true,
406
- isMainAccountTokenLive: options.isMainAccountTokenLive,
474
+ nativeMainSelectionOnly,
475
+ isMainAccountTokenLive: requestScopedMainCredential
476
+ ? () => false
477
+ : options.isMainAccountTokenLive,
407
478
  modelEligibleAccountIds,
408
479
  };
409
480
  // A pre-drain selector reserves the native identity while reconciliation and
@@ -425,10 +496,20 @@ export async function resolveCodexAuthContext(
425
496
  ? { status: "selected" as const, accountId: selected }
426
497
  : { status: "none" as const };
427
498
  })()
428
- : resolveCodexAccountForThreadDetailed(affinityKey ?? null, config, Date.now(), quotaScope, selectionOptions);
499
+ : resolveCodexAccountForThreadDetailed(
500
+ affinityKey ?? null,
501
+ config,
502
+ Date.now(),
503
+ quotaScope,
504
+ selectionOptions,
505
+ options.modelId,
506
+ );
429
507
  if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId);
430
508
  const selected = resolution.status === "selected" ? resolution.accountId : null;
431
509
  if (!selected) {
510
+ if (requestScopedMainCredential && fixedAccountId === undefined && !options.excludeAccountId) {
511
+ return await resolveCallerOwnedMainContext();
512
+ }
432
513
  if (fixedAccountId !== undefined) {
433
514
  throw new CodexPoolAuthenticationError(
434
515
  modelEligibleAccountIds && !modelEligibleAccountIds.has(fixedAccountId)
@@ -436,12 +517,13 @@ export async function resolveCodexAuthContext(
436
517
  : "Selected Codex account is unavailable",
437
518
  );
438
519
  }
439
- // Recovery deliberately makes physical main ineligible. If no healthy
440
- // pool route is configured and main is the intended route, report the
441
- // temporary fence rather than misclassifying that credential as invalid.
520
+ // Recovery or a turn drain deliberately makes physical main unobservable.
521
+ // If no healthy pool route is available, report the temporary fence rather
522
+ // than turning a credential we were forbidden to inspect into a permanent
523
+ // model-entitlement denial.
442
524
  // A configured pool retry/exclusion that finds no alternate preserves its
443
525
  // ordinary pool-auth failure instead of being mislabeled as a main fence.
444
- if (nativeMainTrafficBlocked && !options.excludeAccountId) {
526
+ if (nativeMainReadsForbidden && !options.excludeAccountId) {
445
527
  throw new CodexMainProfileDrainingError();
446
528
  }
447
529
  throw new CodexPoolAuthenticationError(
@@ -523,8 +605,21 @@ export async function resolveCodexAuthContext(
523
605
  }
524
606
 
525
607
  if (accountId === MAIN_CODEX_ACCOUNT_ID) {
526
- // Main account in rotation: inject the read-only auth.json token and fail closed if it vanished.
527
- const token = (options.getMainAccountToken ?? getMainAccountToken)();
608
+ // Main account in rotation: refresh auth.json before upstream I/O and fail closed if it vanished.
609
+ let token: { accessToken: string; chatgptAccountId: string } | null;
610
+ try {
611
+ token = await (options.getValidMainAccountToken ?? getValidMainAccountToken)({
612
+ signal: options.signal,
613
+ ...(options.nativeMainRefreshDependencies ?? {}),
614
+ });
615
+ } catch (cause) {
616
+ if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
617
+ else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
618
+ if (shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
619
+ markAccountNeedsReauth(accountId, writerGeneration);
620
+ }
621
+ throw new CodexAuthContextError(accountId, cause);
622
+ }
528
623
  if (!token) {
529
624
  // Nothing will reach upstream, so give the probe back instead of burning it.
530
625
  if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
@@ -634,6 +729,12 @@ export function materializeCodexUpstreamAuth(
634
729
  selected.set("chatgpt-account-id", ctx.chatgptAccountId);
635
730
  return selected;
636
731
  }
732
+ if (ctx.kind === "main" && options.substituteMainCredential !== true
733
+ && !selected.has("chatgpt-account-id")) {
734
+ const bearer = selected.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
735
+ const accountId = bearer ? extractAccountId(undefined, bearer) : undefined;
736
+ if (accountId) selected.set("chatgpt-account-id", accountId);
737
+ }
637
738
  if (ctx.kind === "main" && options.substituteMainCredential === true) {
638
739
  const stored = getMainAccountToken();
639
740
  // Fail BEFORE any upstream I/O. Falling through here would send the admission secret.
@@ -647,6 +748,33 @@ export function materializeCodexUpstreamAuth(
647
748
  return selected;
648
749
  }
649
750
 
751
+ export async function materializeCodexUpstreamAuthAsync(
752
+ headers: Headers,
753
+ ctx: CodexAuthContext,
754
+ options: {
755
+ substituteMainCredential?: boolean;
756
+ signal?: AbortSignal;
757
+ nativeMainRefreshDependencies?: NativeMainRefreshDependencies;
758
+ } = {},
759
+ ): Promise<Headers> {
760
+ if (ctx.kind !== "main" || options.substituteMainCredential !== true) {
761
+ return materializeCodexUpstreamAuth(headers, ctx, options);
762
+ }
763
+ const selected = new Headers();
764
+ for (const name of FORWARD_HEADERS) {
765
+ const value = headers.get(name);
766
+ if (value) selected.set(name, value);
767
+ }
768
+ const stored = await getValidMainAccountToken({
769
+ signal: options.signal,
770
+ ...(options.nativeMainRefreshDependencies ?? {}),
771
+ });
772
+ if (!stored?.accessToken) throw new CodexMainSubstitutionUnavailableError();
773
+ selected.set("authorization", `Bearer ${stored.accessToken}`);
774
+ if (stored.chatgptAccountId) selected.set("chatgpt-account-id", stored.chatgptAccountId);
775
+ return selected;
776
+ }
777
+
650
778
  /** @deprecated Prefer materializeCodexUpstreamAuth; kept for call sites without admission context. */
651
779
  export function headersForCodexAuthContext(headers: Headers, ctx: CodexAuthContext): Headers {
652
780
  return materializeCodexUpstreamAuth(headers, ctx);
@@ -31,7 +31,7 @@ import { redactSecretString } from "../../lib/redact";
31
31
  import upstreamModelsSnapshot from "../data/upstream-models.json";
32
32
 
33
33
 
34
- import { activeCodexModelsCachePath, catalogBackupPathFor, findNativeTemplate, isDefaultCatalogPath, legacyCatalogBackupPath, parseCatalogJson, readCatalog, readCatalogBackup, readCodexCatalogPath } from "./parsing";
34
+ import { activeCodexModelsCachePath, catalogBackupPathFor, findNativeTemplate, findSupportedNativeTemplate, isDefaultCatalogPath, legacyCatalogBackupPath, parseCatalogJson, readCatalog, readCatalogBackup, readCodexCatalogPath } from "./parsing";
35
35
  import type { RawCatalog, RawEntry } from "./parsing";
36
36
  import { codexExecInvocation, isSpawnableCodexCandidate } from "../exec-invocation";
37
37
  import {
@@ -541,9 +541,11 @@ export function readCurrentCodexModelsCache(): RawCatalog | null {
541
541
  export function loadCatalogTemplate(): RawEntry | null {
542
542
  const catalogPath = readCodexCatalogPath();
543
543
  const bundled = loadBundledCodexCatalog();
544
- const native = findNativeTemplate(readCatalog(catalogPath))
545
- ?? findNativeTemplate(readCatalogBackup(catalogPath))
546
- ?? findNativeTemplate(readCatalog(activeCodexModelsCachePath()))
547
- ?? findNativeTemplate(bundled ? JSON.parse(JSON.stringify(bundled)) as RawCatalog : null);
544
+ // Template inheritance only. The validity gates in this file keep `findNativeTemplate`
545
+ // so a catalog carrying only a newly launched native row stays valid (#2813).
546
+ const native = findSupportedNativeTemplate(readCatalog(catalogPath))
547
+ ?? findSupportedNativeTemplate(readCatalogBackup(catalogPath))
548
+ ?? findSupportedNativeTemplate(readCatalog(activeCodexModelsCachePath()))
549
+ ?? findSupportedNativeTemplate(bundled ? JSON.parse(JSON.stringify(bundled)) as RawCatalog : null);
548
550
  return native ? JSON.parse(JSON.stringify(native)) : null;
549
551
  }
@@ -548,7 +548,7 @@ function hasNativeCatalogRowShape(entry: RawEntry): boolean {
548
548
  return typeof entry.base_instructions === "string"
549
549
  && entry.base_instructions.length > 0
550
550
  && (typeof entry.comp_hash === "string" || entry.comp_hash === null)
551
- && entry.shell_type === "shell_command"
551
+ && (entry.shell_type === "unified_exec" || entry.shell_type === "shell_command")
552
552
  && Array.isArray(levels)
553
553
  && levels.length > 0
554
554
  && levels.every(level => typeof level === "object" && level !== null
@@ -264,6 +264,34 @@ export function findNativeTemplate(catalog: RawCatalog | null): RawEntry | null
264
264
  ) ?? null;
265
265
  }
266
266
 
267
+ /**
268
+ * Template selection, as opposed to catalog VALIDITY.
269
+ *
270
+ * `findNativeTemplate` answers "does this look like a real catalog?" and must stay
271
+ * permissive: four call sites use it as a validity gate, and a catalog holding only a
272
+ * newly launched native model has to keep passing or sync falls back to stale data.
273
+ *
274
+ * This answers a different question — "which row should every routed model inherit
275
+ * from?" — and must be strict. `deriveEntry` deep-clones the chosen row, so an unknown
276
+ * bare row carrying `base_instructions` would become the template for every routed
277
+ * model and hand them its native eligibility metadata. #2813 is the report that made
278
+ * that concrete: a Reserve-shaped row injected by the client is exactly such a row.
279
+ *
280
+ * Returning null is safe and expected; `deriveEntry` falls back to a conservative
281
+ * synthetic template.
282
+ */
283
+ export function findSupportedNativeTemplate(catalog: RawCatalog | null): RawEntry | null {
284
+ return catalog?.models?.find(
285
+ m => typeof m.slug === "string"
286
+ && SUPPORTED_NATIVE_OPENAI_SLUGS.has(m.slug)
287
+ && !m.slug.includes("/")
288
+ && "base_instructions" in m
289
+ && m.opencodex_catalog_kind !== CODEX_NATIVE_ALIAS_CATALOG_KIND
290
+ && m.owned_by !== COMBO_NAMESPACE
291
+ && !(typeof m.description === "string" && m.description.startsWith("Routed via opencodex → ")),
292
+ ) ?? null;
293
+ }
294
+
267
295
  /**
268
296
  * Native OpenAI slugs that do NOT support the Fast (priority) service tier.
269
297
  * Upstream may advertise service_tiers for these models, but the tier is not
@@ -403,10 +431,22 @@ export function ensureStrictCatalogFields(
403
431
  entry: RawEntry,
404
432
  options: { preserveExactInputModalities?: boolean; isRouted?: boolean } = {},
405
433
  ): RawEntry {
434
+ if (entry.shell_type === "default" || entry.shell_type === "local" || entry.shell_type === "shell_command") {
435
+ entry.shell_type = "unified_exec";
436
+ }
437
+ if (typeof entry.node_repl_disabled !== "boolean") entry.node_repl_disabled = false;
438
+ if (typeof entry.node_repl_auto_review_required !== "boolean") entry.node_repl_auto_review_required = false;
439
+ if (typeof entry.include_plugin_usage_instructions !== "boolean") entry.include_plugin_usage_instructions = false;
440
+ if (typeof entry.include_apps_usage_instructions !== "boolean") entry.include_apps_usage_instructions = true;
406
441
  if (typeof entry.supports_reasoning_summaries !== "boolean") entry.supports_reasoning_summaries = false;
407
442
  if (typeof entry.default_reasoning_summary !== "string") entry.default_reasoning_summary = "none";
408
443
  if (typeof entry.support_verbosity !== "boolean") entry.support_verbosity = true;
409
- if (typeof entry.default_verbosity !== "string") entry.default_verbosity = "low";
444
+ // A row that has declared it does NOT support verbosity must not also ship a default for the
445
+ // control it just disowned: Codex seeds its picker from `default_verbosity`, so leaving the
446
+ // strict-fields fallback in place re-creates the dead toggle the explicit opt-out removed.
447
+ // Scoped to an explicit `false`, so rows that never declare a capability keep the default.
448
+ if (entry.support_verbosity === false) delete entry.default_verbosity;
449
+ else if (typeof entry.default_verbosity !== "string") entry.default_verbosity = "low";
410
450
  if (typeof entry.apply_patch_tool_type !== "string") entry.apply_patch_tool_type = "freeform";
411
451
  if (!entry.truncation_policy || typeof entry.truncation_policy !== "object" || Array.isArray(entry.truncation_policy)) {
412
452
  entry.truncation_policy = { mode: "tokens", limit: 10000 };
@@ -438,6 +478,22 @@ export function ensureStrictCatalogFields(
438
478
  }
439
479
  if (typeof entry.effective_context_window_percent !== "number") entry.effective_context_window_percent = 95;
440
480
  if (typeof entry.comp_hash !== "string") entry.comp_hash = "opencodex";
481
+ // Routed rows must not carry NATIVE eligibility metadata. `deriveEntry` deep-clones a
482
+ // native template and deletes a fixed denylist, so these five survive onto rows backed
483
+ // by unrelated provider credentials — advertising ChatGPT plan eligibility for a model
484
+ // that never touches a ChatGPT account (#2813).
485
+ //
486
+ // This lives here rather than only in `normalizeRoutedCatalogEntry` because that runs on
487
+ // freshly derived rows only. Degraded-provider and foreign routed rows are preserved
488
+ // from disk and reach the merge through this function alone, so sanitizing there would
489
+ // leave already-contaminated rows contaminated forever.
490
+ if (options.isRouted === true) {
491
+ entry.supported_in_api = true;
492
+ delete entry.available_in_plans;
493
+ delete entry.minimal_client_version;
494
+ delete entry.availability_nux;
495
+ delete entry.upgrade;
496
+ }
441
497
  return ensureAutoCompactTokenLimit(entry);
442
498
  }
443
499
 
@@ -71,6 +71,7 @@ import {
71
71
  type ProviderModelsApiItem,
72
72
  type ResolvedProviderModelDiscovery,
73
73
  } from "../../providers/model-discovery";
74
+ import { applyConfiguredHeadersLast, fetchOllamaShowEnrichment, ollamaShowEnrichable } from "../../providers/ollama-show";
74
75
  import upstreamModelsSnapshot from "../data/upstream-models.json";
75
76
  import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } from "../../lib/admission";
76
77
 
@@ -962,6 +963,7 @@ export const CALLABLE_CONFIGURED_COMPATIBILITY_MODELS: Readonly<Record<string, R
962
963
  ]),
963
964
  xai: new Set([
964
965
  "grok-4.3",
966
+ "grok-4.20-multi-agent-0309",
965
967
  "grok-4.20-0309-reasoning",
966
968
  "grok-4.20-0309-non-reasoning",
967
969
  "grok-build-0.1",
@@ -1093,8 +1095,24 @@ function modelInputModalities(
1093
1095
  .filter(value => value === "text" || value === "image" || value === "audio");
1094
1096
  if (inferred.length > 0) return [...new Set(inferred)];
1095
1097
  }
1096
- if (capabilityRecord?.vision === false) return ["text"];
1097
- if (capabilityRecord?.vision === true || capabilities?.some(value => (
1098
+ // GitHub Copilot nests vision support one level down as `capabilities.supports.vision`, so the
1099
+ // flat read alone finds nothing and every Copilot model falls through to `["text"]` — Codex then
1100
+ // refuses image attachments on models that accept them (#2941). Precedence is by specificity:
1101
+ // a flat boolean is authoritative when present, the nested boolean is consulted only otherwise,
1102
+ // and a non-boolean at either level decides NOTHING so the signals below still apply. Two things
1103
+ // this ordering deliberately avoids: a deny-wins rule across both levels would flip a provider
1104
+ // reporting flat `true` with nested `false` from image-capable to text-only, changing behaviour
1105
+ // that predates Copilot support; and a truthy test would let the string `"no"` advertise image
1106
+ // input. The payload also carries a SECOND `vision` key under `limits` holding an image count,
1107
+ // which is why this reads one exact path instead of searching `capabilities` for a vision-ish key.
1108
+ const nestedSupports = plainRecord(capabilityRecord?.supports);
1109
+ const explicitVisionSupport = typeof capabilityRecord?.vision === "boolean"
1110
+ ? capabilityRecord.vision
1111
+ : typeof nestedSupports?.vision === "boolean"
1112
+ ? nestedSupports.vision
1113
+ : undefined;
1114
+ if (explicitVisionSupport === false) return ["text"];
1115
+ if (explicitVisionSupport === true || capabilities?.some(value => (
1098
1116
  value === "vision" || value === "image-input" || value === "image_input"
1099
1117
  // llama.cpp and Ollama-compatible servers report vision as "multimodal" —
1100
1118
  // it is the only image signal those servers emit (#1797). Mapped to the
@@ -1370,7 +1388,16 @@ async function fetchProviderModelsWithAuth(
1370
1388
  );
1371
1389
  }
1372
1390
  const url = request.url;
1373
- const headers = materializeCapturedHeaders(request, apiKey);
1391
+ let headers = materializeCapturedHeaders(request, apiKey);
1392
+ // One Ollama authority contract: for canonical ollama-cloud/ollama-native rows, discovery
1393
+ // (/v1/models), enrichment (/api/show) and inference (/api/chat) must all materialize the
1394
+ // SAME effective credential/header authority. buildModelsRequest's generic tail writes the
1395
+ // generated Bearer AFTER configured headers, but the native inference adapter applies
1396
+ // provider.headers LAST (configured wins, case-insensitive collapse). Reapply the configured
1397
+ // provider headers here so the whole Ollama request family shares that one authority.
1398
+ if (ollamaShowEnrichable(name, prov)) {
1399
+ headers = applyConfiguredHeadersLast(headers, prov.headers);
1400
+ }
1374
1401
  const urlClass = new URL(url).hostname.endsWith("aiplatform.googleapis.com")
1375
1402
  ? "vertex-aiplatform"
1376
1403
  : "provider-models";
@@ -1500,13 +1527,40 @@ async function fetchProviderModelsWithAuth(
1500
1527
  return observed(models, "degraded");
1501
1528
  }
1502
1529
  const items = extracted.items;
1530
+ // Ollama Cloud enrichment: /v1/models carries no per-model context or capability metadata,
1531
+ // so a newly announced id would otherwise publish generic defaults. /api/show fills that
1532
+ // per model, fail-soft, bounded, and cached with this gather's result. Explicit configured
1533
+ // metadata keeps its normal precedence (applyProviderConfigHints applies the discovered
1534
+ // window only where exact config is absent, and the provider context cap still caps it).
1535
+ const showEnrichment = ollamaShowEnrichable(name, prov)
1536
+ ? await fetchOllamaShowEnrichment({
1537
+ headers,
1538
+ discoveryUrl: request.url,
1539
+ modelIds: items.map(m => m.id),
1540
+ provider: prov,
1541
+ }).catch(() => undefined)
1542
+ : undefined;
1503
1543
  const live = items.map(m => {
1504
1544
  const ownedBy = boundedOwnedBy(m.owned_by);
1545
+ // Precedence: the authoritative /v1/models row wins; /api/show fills only metadata the
1546
+ // models-API row does not carry. applyProviderConfigHints then applies explicit
1547
+ // configured metadata over both, and the provider context cap still caps the result.
1548
+ const modelsApiHints = catalogHintsFromModelsApiItem(name, m);
1549
+ const show = showEnrichment?.metadata.get(m.id);
1550
+ const discoveredHints = {
1551
+ ...modelsApiHints,
1552
+ ...(modelsApiHints.contextWindow === undefined && show?.contextWindow !== undefined
1553
+ ? { contextWindow: show.contextWindow }
1554
+ : {}),
1555
+ ...(modelsApiHints.inputModalities === undefined && show?.nativeVision === true
1556
+ ? { inputModalities: ["text", "image"] as string[] }
1557
+ : {}),
1558
+ };
1505
1559
  return applyProviderConfigHints(name, prov, {
1506
1560
  id: m.id,
1507
1561
  provider: name,
1508
1562
  ...(ownedBy ? { owned_by: ownedBy } : {}),
1509
- ...catalogHintsFromModelsApiItem(name, m),
1563
+ ...discoveredHints,
1510
1564
  }, contextCap);
1511
1565
  })
1512
1566
  .filter(m => shouldExposeProviderModel(name, m.id));
@@ -1568,6 +1622,9 @@ export async function fetchProviderModels(
1568
1622
 
1569
1623
  export function shouldExposeProviderModel(providerName: string, modelId: string): boolean {
1570
1624
  if (providerName === "opencode-free") return modelId === "big-pickle" || modelId.endsWith("-free");
1625
+ // xAI /models advertises both the dated deployment and this floating alias.
1626
+ // Keep only grok-4.20-multi-agent-0309; the alias is the same server-side id.
1627
+ if (providerName === "xai" && modelId === "grok-4.20-multi-agent-beta-latest") return false;
1571
1628
  return true;
1572
1629
  }
1573
1630
 
@@ -41,7 +41,7 @@ import {
41
41
  } from "../model-entitlements";
42
42
 
43
43
 
44
- import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readCodexCatalogPathForHome, readConfiguredAutoReviewModel, readNativeBaseline } from "./parsing";
44
+ import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, findSupportedNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readCodexCatalogPathForHome, readConfiguredAutoReviewModel, readNativeBaseline } from "./parsing";
45
45
  import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing";
46
46
  import { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, nativeContextLimits, observedAccountBoundNativeEntries, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry, type NativeContextLimitsInput } from "./metadata";
47
47
  import {
@@ -375,7 +375,7 @@ export function deriveEntry(
375
375
  const isCursorFallback = isRouted && model?.provider === "cursor";
376
376
  const entry: RawEntry = {
377
377
  slug, display_name: routedDisplayName(slug), description: desc,
378
- shell_type: "shell_command", visibility: "list", supported_in_api: true,
378
+ shell_type: "unified_exec", visibility: "list", supported_in_api: true,
379
379
  priority, base_instructions: "You are a helpful coding assistant.",
380
380
  ...(isRouted
381
381
  ? isCursorFallback
@@ -1543,7 +1543,8 @@ function writeRetainedCatalogSync({
1543
1543
  catalog,
1544
1544
  onDiskCatalog,
1545
1545
  );
1546
- const template = findNativeTemplate(catalog);
1546
+ // Strict selector for template inheritance; the validity gate above keeps the broad one.
1547
+ const template = findSupportedNativeTemplate(catalog);
1547
1548
 
1548
1549
  try {
1549
1550
  // Once-only: preserve the PRISTINE pre-opencodex catalog as the native-priority baseline
@@ -33,7 +33,7 @@ import {
33
33
  import {
34
34
  catalogBackupPathFor,
35
35
  catalogHasRoutedEntries,
36
- findNativeTemplate,
36
+ findSupportedNativeTemplate,
37
37
  legacyCatalogBackupPath,
38
38
  parseCatalogJson,
39
39
  type RawCatalog,
@@ -238,7 +238,8 @@ function prepareCatalog(
238
238
  observedAccountNativeEntries: readonly RawEntry[] = [],
239
239
  ): RawCatalog {
240
240
  const catalog = JSON.parse(JSON.stringify(source.catalog)) as RawCatalog;
241
- const template = findNativeTemplate(catalog);
241
+ // Strict selector: an unknown bare row must never become the routed template (#2813).
242
+ const template = findSupportedNativeTemplate(catalog);
242
243
  const enabled = filterCatalogVisibleModels(routedModels, config);
243
244
  const featured = config.subagentModels ?? [];
244
245
  const ordered = orderForSubagents(enabled, featured);