@bitkyc08/opencodex 2.26.0 → 2.28.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 (83) hide show
  1. package/gui/dist/assets/{index-RL6b1bTV.js → index-D2sP-biU.js} +14 -14
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/anthropic.ts +60 -1
  5. package/src/adapters/base.ts +16 -2
  6. package/src/adapters/command-code.ts +4 -3
  7. package/src/adapters/cursor/cursor-errors.ts +15 -0
  8. package/src/adapters/cursor/live-transport.ts +14 -1
  9. package/src/adapters/google-antigravity-replay.ts +16 -8
  10. package/src/adapters/google.ts +22 -5
  11. package/src/adapters/openai-chat.ts +189 -60
  12. package/src/adapters/openai-responses.ts +37 -0
  13. package/src/adapters/tool-catalog-nudge.ts +1 -1
  14. package/src/bridge.ts +11 -5
  15. package/src/cli/doctor.ts +76 -0
  16. package/src/cli/help.ts +2 -0
  17. package/src/cli/index.ts +19 -6
  18. package/src/cli/models.ts +13 -6
  19. package/src/codex/account-usability.ts +3 -0
  20. package/src/codex/app-server-processes.ts +269 -37
  21. package/src/codex/auth-api.ts +22 -5
  22. package/src/codex/auth-context.ts +108 -3
  23. package/src/codex/catalog/aggregation.ts +3 -0
  24. package/src/codex/catalog/metadata.ts +17 -3
  25. package/src/codex/catalog/native-models.ts +22 -14
  26. package/src/codex/catalog/parsing.ts +20 -3
  27. package/src/codex/catalog/provider-fetch.ts +8 -0
  28. package/src/codex/catalog/sync.ts +63 -15
  29. package/src/codex/convergence.ts +61 -13
  30. package/src/codex/log-guard/path-safety.ts +52 -3
  31. package/src/codex/model-entitlements.ts +353 -0
  32. package/src/codex/native-profile-startup.ts +100 -2
  33. package/src/codex/quota.ts +28 -3
  34. package/src/codex/routing.ts +14 -8
  35. package/src/codex/user-identity.ts +21 -1
  36. package/src/config/provider-name.ts +24 -0
  37. package/src/config.ts +11 -24
  38. package/src/generated/compatibility-version.json +110 -70
  39. package/src/images/loop.ts +11 -4
  40. package/src/lib/destination-policy.ts +47 -0
  41. package/src/lib/shadow-call.ts +15 -0
  42. package/src/lib/state-store-registrations.ts +8 -2
  43. package/src/oauth/index.ts +33 -5
  44. package/src/oauth/store.ts +11 -5
  45. package/src/providers/antigravity-models.ts +70 -5
  46. package/src/providers/derive.ts +12 -2
  47. package/src/providers/fastwire.ts +39 -8
  48. package/src/providers/quota.ts +9 -2
  49. package/src/providers/registry.ts +120 -6
  50. package/src/providers/service-tier.ts +50 -15
  51. package/src/responses/parser.ts +59 -11
  52. package/src/responses/state.ts +162 -5
  53. package/src/responses/tool-search-compat.ts +301 -0
  54. package/src/router.ts +17 -3
  55. package/src/routing/capability.ts +26 -9
  56. package/src/routing/compatibility/behavior.ts +44 -6
  57. package/src/routing/profile.ts +1 -1
  58. package/src/server/chat-native.ts +11 -2
  59. package/src/server/index.ts +59 -8
  60. package/src/server/management/agent-settings-routes.ts +16 -2
  61. package/src/server/management/shared.ts +3 -1
  62. package/src/server/request-log.ts +31 -0
  63. package/src/server/responses/collaboration.ts +34 -9
  64. package/src/server/responses/compact.ts +54 -7
  65. package/src/server/responses/core.ts +259 -43
  66. package/src/server/responses/input-admission.ts +7 -2
  67. package/src/server/responses/responses-field-backfill.ts +88 -6
  68. package/src/server/responses/terminal-guard.ts +10 -0
  69. package/src/server/responses-tool-search-repair.ts +217 -0
  70. package/src/server/system-env.ts +74 -5
  71. package/src/service-manager-probe.ts +99 -0
  72. package/src/service.ts +86 -6
  73. package/src/tray/windows.ts +25 -5
  74. package/src/types/accounts.ts +37 -0
  75. package/src/types/config.ts +818 -0
  76. package/src/types/provider.ts +521 -0
  77. package/src/types/request.ts +358 -0
  78. package/src/types/tools.ts +131 -0
  79. package/src/types/wire.ts +80 -0
  80. package/src/types.ts +103 -1883
  81. package/src/usage/cost.ts +37 -1
  82. package/src/usage/log.ts +4 -0
  83. package/src/web-search/loop.ts +11 -4
@@ -12,7 +12,8 @@ import { ConfigMutationLockError } from "../config";
12
12
  import { isCodexAccountUsable } from "./account-usability";
13
13
  import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
14
14
  import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken, isMainAccountTokenLive } from "./main-account";
15
- import { isNativeMainTrafficBlocked } from "./native-profile-startup";
15
+ import { isNativeMainTrafficBlocked, nativeMainStartupGateSnapshot } from "./native-profile-startup";
16
+ import type { NativeMainStartupBlockReason } from "./native-profile-startup";
16
17
  import {
17
18
  codexQuotaScopeForModel,
18
19
  getCodexQuotaHealthSnapshot,
@@ -23,6 +24,13 @@ import {
23
24
  pickAlternateCodexAccount,
24
25
  resolveCodexAccountForThreadDetailed,
25
26
  } from "./routing";
27
+ import {
28
+ entitledCodexAccountIdsForModel,
29
+ isDirectCallerEntitledToCodexModel,
30
+ resolveCodexModelEntitlements,
31
+ type CodexModelEntitlementSnapshot,
32
+ } from "./model-entitlements";
33
+ import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models";
26
34
  import type { CodexCooldownSource, CodexQuotaScope } from "./routing";
27
35
  import { maskAccountId } from "../lib/privacy";
28
36
  import { formatErrorResponse } from "../bridge";
@@ -116,12 +124,63 @@ export const CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE =
116
124
  "OpenCodex local native-main profile maintenance is active; retry this request";
117
125
 
118
126
  export class CodexMainProfileDrainingError extends Error {
127
+ /**
128
+ * Which startup-gate state fenced this request, when one did. Undefined means the
129
+ * fence came from somewhere other than the startup gate — the turn-drain claim race
130
+ * throws this same error while the gate reads `ready`, and inventing a reason there
131
+ * would point the next report at a gate that never closed.
132
+ *
133
+ * Captured here rather than at the throw sites because this is the last moment it is
134
+ * both in scope and still true: every catch site has already lost it, and re-reading
135
+ * the gate later can observe a recovery that completed in between (#2108).
136
+ */
137
+ readonly reason?: NativeMainStartupBlockReason;
138
+
119
139
  constructor() {
120
140
  super(CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE);
121
141
  this.name = "CodexMainProfileDrainingError";
142
+ const gate = nativeMainStartupGateSnapshot();
143
+ if (gate.status !== "blocked") return;
144
+ this.reason = gate.reason;
145
+ reportNativeMainFenceReason(gate.reason);
122
146
  }
123
147
  }
124
148
 
149
+ /**
150
+ * #2108: a reboot could leave this fence closed until `ocx restart`, and the report was
151
+ * unactionable because the settled reason was never written anywhere. It cannot ride the
152
+ * message (claude-messages.ts matches that string exactly to keep the fence a 503 rather
153
+ * than an Anthropic 529) and it cannot ride a header (/api/logs reads only error.message
154
+ * from the body, and the Claude surface rebuilds its response headers from scratch), so
155
+ * stdout is the one surface that covers every path this fence fires on.
156
+ *
157
+ * Deduped per distinct reason: the original report shows three 503s in eleven seconds and
158
+ * a real client retries harder than that, so a per-request line would bury the signal.
159
+ * Only the reason is emitted; the snapshot's homeId is derived from a profile directory.
160
+ */
161
+ const reportedFenceReasons = new Set<NativeMainStartupBlockReason>();
162
+
163
+ function reportNativeMainFenceReason(reason: NativeMainStartupBlockReason): void {
164
+ if (reportedFenceReasons.has(reason)) return;
165
+ reportedFenceReasons.add(reason);
166
+ console.warn(
167
+ `native-main admission is fenced (reason: ${reason}); native model requests return 503 until it clears`,
168
+ );
169
+ }
170
+
171
+ /**
172
+ * Test-only reset for the dedup set above.
173
+ *
174
+ * The dedup is process-lifetime module state, so it is order-sensitive across test files
175
+ * sharing one Bun process: whichever file constructs this error first consumes the one-shot
176
+ * warn, and a later file asserting on it would see nothing and pass vacuously. Any test that
177
+ * asserts on the warn must call this first — an `afterEach` in the asserting file is not
178
+ * enough on its own, because the consuming file may not be the asserting one.
179
+ */
180
+ export function __resetNativeMainFenceReasonLog(): void {
181
+ reportedFenceReasons.clear();
182
+ }
183
+
125
184
  export function codexMainProfileDrainingResponse(): Response {
126
185
  const response = formatErrorResponse(503, "server_busy", CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE);
127
186
  const headers = new Headers(response.headers);
@@ -237,6 +296,14 @@ export interface ResolveCodexAuthContextOptions {
237
296
  isMainAccountTokenLive?: () => boolean;
238
297
  getMainAccountToken?: typeof getMainAccountToken;
239
298
  primeCodexPoolQuotas?: (config: OcxConfig, reason: string) => Promise<void>;
299
+ /** Test seam for account-gated native model discovery. */
300
+ resolveCodexModelEntitlements?: (
301
+ config: Pick<OcxConfig, "codexAccounts">,
302
+ ) => Promise<CodexModelEntitlementSnapshot>;
303
+ /** Direct requests admitted with a proxy bearer substitute the stored native-main credential. */
304
+ substituteMainCredentialForDirect?: boolean;
305
+ /** Test seam for a Direct request's own forwarded ChatGPT credential. */
306
+ isDirectCallerEntitledToCodexModel?: (headers: Headers, modelId: string) => Promise<boolean>;
240
307
  }
241
308
 
242
309
  export interface CodexAccountSelectionAdmission {
@@ -260,8 +327,28 @@ export async function resolveCodexAuthContext(
260
327
  // selected stored credential even while the canonical OpenAI provider is globally Direct.
261
328
  if (mode === "direct" && fixedAccountId === undefined) {
262
329
  if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError();
330
+ if (options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)) {
331
+ const entitled = options.substituteMainCredentialForDirect
332
+ ? entitledCodexAccountIdsForModel(
333
+ await (options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements)(config),
334
+ options.modelId,
335
+ )?.has(MAIN_CODEX_ACCOUNT_ID) === true
336
+ : await (options.isDirectCallerEntitledToCodexModel ?? isDirectCallerEntitledToCodexModel)(
337
+ headers,
338
+ options.modelId,
339
+ );
340
+ if (!entitled) {
341
+ throw new CodexPoolAuthenticationError("The selected ChatGPT account does not support this model");
342
+ }
343
+ }
263
344
  return { kind: "main", accountId: null };
264
345
  }
346
+ const entitlementSnapshot = options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)
347
+ ? await (options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements)(config)
348
+ : undefined;
349
+ const modelEligibleAccountIds = entitlementSnapshot
350
+ ? entitledCodexAccountIdsForModel(entitlementSnapshot, options.modelId)
351
+ : undefined;
265
352
  // Retained startup recovery makes the physical main identity ineligible. Routing
266
353
  // can still preserve service by selecting a healthy configured pool account.
267
354
  const nativeMainTrafficBlocked = isNativeMainTrafficBlocked();
@@ -273,6 +360,7 @@ export async function resolveCodexAuthContext(
273
360
  nativeMainSelectionOnly: !nativeMainTrafficBlocked
274
361
  && selectionAdmission?.mainProfileDraining === true,
275
362
  isMainAccountTokenLive: options.isMainAccountTokenLive,
363
+ modelEligibleAccountIds,
276
364
  };
277
365
  let accountId: string;
278
366
  const quotaScope = codexQuotaScopeForModel(options.modelId);
@@ -302,7 +390,11 @@ export async function resolveCodexAuthContext(
302
390
  const selected = resolution.status === "selected" ? resolution.accountId : null;
303
391
  if (!selected) {
304
392
  if (fixedAccountId !== undefined) {
305
- throw new CodexPoolAuthenticationError("Selected Codex account is unavailable");
393
+ throw new CodexPoolAuthenticationError(
394
+ modelEligibleAccountIds && !modelEligibleAccountIds.has(fixedAccountId)
395
+ ? "Selected Codex account does not support this model"
396
+ : "Selected Codex account is unavailable",
397
+ );
306
398
  }
307
399
  // Recovery deliberately makes physical main ineligible. If no healthy
308
400
  // pool route is configured and main is the intended route, report the
@@ -312,7 +404,9 @@ export async function resolveCodexAuthContext(
312
404
  if (nativeMainTrafficBlocked && !options.excludeAccountId) {
313
405
  throw new CodexMainProfileDrainingError();
314
406
  }
315
- throw new CodexPoolAuthenticationError();
407
+ throw new CodexPoolAuthenticationError(
408
+ modelEligibleAccountIds ? "No eligible Codex account supports this model" : undefined,
409
+ );
316
410
  }
317
411
  accountId = selected;
318
412
  if (accountId === MAIN_CODEX_ACCOUNT_ID && nativeMainTrafficBlocked) {
@@ -325,6 +419,17 @@ export async function resolveCodexAuthContext(
325
419
  ) {
326
420
  throw new CodexMainProfileDrainingError();
327
421
  }
422
+ // Some legacy Pool fallbacks preserve a configured active account even when it is not
423
+ // currently selectable, so token/cooldown code can produce the historical actionable error.
424
+ // Model entitlement is different: sending the request would spend a turn on an account whose
425
+ // authenticated roster already denied the model. Reassert this boundary after every selector.
426
+ if (modelEligibleAccountIds && !modelEligibleAccountIds.has(accountId)) {
427
+ throw new CodexPoolAuthenticationError(
428
+ fixedAccountId !== undefined
429
+ ? "Selected Codex account does not support this model"
430
+ : "No eligible Codex account supports this model",
431
+ );
432
+ }
328
433
  if (fixedAccountId !== undefined) {
329
434
  if (isCodexAccountPaused(config, accountId)) {
330
435
  throw new CodexPoolAuthenticationError("Selected Codex account is unavailable");
@@ -183,6 +183,9 @@ export function deriveComboCatalogModel(
183
183
  ? { supportsServiceTier: false }
184
184
  : {}),
185
185
  ...(members.some(member => member.supportsReasoningSummaries === false) ? { supportsReasoningSummaries: false } : {}),
186
+ ...(members.every(member => member.codexToolMode === "shell")
187
+ ? { codexToolMode: "shell" as const }
188
+ : {}),
186
189
  };
187
190
  }
188
191
 
@@ -12,7 +12,7 @@ import { modelInList } from "../../types";
12
12
  import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort";
13
13
  import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata";
14
14
  import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive";
15
- import { getProviderRegistryEntry } from "../../providers/registry";
15
+ import { getProviderRegistryEntry, providerCodexAccountMode } from "../../providers/registry";
16
16
  import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap";
17
17
  import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec";
18
18
  import { identifyRoutedModel } from "../../adapters/identity";
@@ -38,6 +38,7 @@ import { readCurrentCatalogOrCache, readCurrentCodexCatalog, readCurrentCodexMod
38
38
  import { trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./account-models";
39
39
  import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds";
40
40
  import {
41
+ ACCOUNT_GATED_NATIVE_OPENAI_MODELS,
41
42
  NATIVE_DAYBREAK_BLUE_MODEL,
42
43
  NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS,
43
44
  NATIVE_OPENAI_MODELS,
@@ -45,6 +46,8 @@ import {
45
46
  isNativeOpenAiCapabilityAliasModel,
46
47
  nativeOpenAiCapabilitySourceSlug,
47
48
  } from "./native-models";
49
+ import { cachedAvailableAccountGatedNativeModels } from "../model-entitlements";
50
+ import { MAIN_CODEX_ACCOUNT_ID } from "../main-account";
48
51
  export { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds";
49
52
  export {
50
53
  NATIVE_DAYBREAK_BLUE_MODEL,
@@ -390,7 +393,14 @@ export function nativeModelRows(config: Pick<OcxConfig, "disabledModels" | "comb
390
393
  // Both user levers, not just the cap: a per-model window set from the dashboard has to show
391
394
  // up on the row the dashboard itself renders.
392
395
  const limits = nativeContextLimits(config);
393
- return NATIVE_OPENAI_MODELS.filter(slug => !shadowed.has(slug)).map(slug => {
396
+ const bareEligibleAccountIds = providerCodexAccountMode(
397
+ OPENAI_CODEX_PROVIDER_ID,
398
+ config.providers?.[OPENAI_CODEX_PROVIDER_ID],
399
+ ) === "direct" ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined;
400
+ const availableGated = cachedAvailableAccountGatedNativeModels(Date.now(), bareEligibleAccountIds);
401
+ return NATIVE_OPENAI_MODELS
402
+ .filter(slug => !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableGated.has(slug))
403
+ .filter(slug => !shadowed.has(slug)).map(slug => {
394
404
  const contextWindow = nativeOpenAiContextWindow(slug, limits);
395
405
  const maxInputTokens = nativeOpenAiMaxInputTokens(slug, limits);
396
406
  return {
@@ -475,7 +485,11 @@ export function shouldUpgradeToUpstreamEntry(entry: RawEntry): boolean {
475
485
 
476
486
  export function nativeOpenAiSlugs(): string[] {
477
487
  const live = catalogNativeSlugs();
478
- return live.length > 0 ? unique([...live, ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS]) : NATIVE_OPENAI_MODELS;
488
+ const availableGated = cachedAvailableAccountGatedNativeModels();
489
+ const candidates = live.length > 0 ? unique([...live, ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS]) : NATIVE_OPENAI_MODELS;
490
+ return candidates.filter(slug => (
491
+ !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableGated.has(slug)
492
+ ));
479
493
  }
480
494
 
481
495
  const ACCOUNT_BOUND_OPENAI_NATIVE_PREFIX = /^(?:gpt-|o1-|o3-|o4-)/;
@@ -1,12 +1,22 @@
1
1
  /** ChatGPT/Codex wire id observed for the account-native Daybreak Blue surface. */
2
2
  export const NATIVE_DAYBREAK_BLUE_MODEL = "gpt-daybreak-blue-latest";
3
3
 
4
+ /** Native ChatGPT/Codex ids whose availability is proven per authenticated account. */
5
+ export const ACCOUNT_GATED_NATIVE_OPENAI_MODELS: ReadonlySet<string> = new Set([
6
+ NATIVE_DAYBREAK_BLUE_MODEL,
7
+ ]);
8
+
4
9
  /**
5
10
  * Account-native aliases whose Codex capabilities track another pinned native row.
6
11
  *
7
12
  * This is catalog metadata inheritance only. Routing always preserves the requested
8
- * wire id, so the ChatGPT/Codex `gpt-daybreak-*` surface never collapses into the
9
- * separately billed API-key `daybreak-*-latest` surface or into `gpt-5.6-sol`.
13
+ * wire id for the separately billed API-key `daybreak-*-latest` surface, so the two never
14
+ * collapse into each other.
15
+ *
16
+ * The ChatGPT/Codex surface is different: an account-gated request IS rewritten to its
17
+ * canonical wire model before it leaves the process (`applyCodexAccountGatedWireNormalization`
18
+ * in src/server/responses/core.ts), because the authenticated backend rejects the gated slug
19
+ * on shards that do not carry it. The catalog keeps the product identity; only the wire moves.
10
20
  */
11
21
  const NATIVE_OPENAI_CAPABILITY_SOURCES: Readonly<Record<string, string>> = Object.freeze({
12
22
  [NATIVE_DAYBREAK_BLUE_MODEL]: "gpt-5.6-sol",
@@ -16,14 +26,14 @@ const NATIVE_OPENAI_CAPABILITY_SOURCES: Readonly<Record<string, string>> = Objec
16
26
  * Native ids whose capability metadata is inherited from another pinned native row.
17
27
  *
18
28
  * Membership here is about METADATA INHERITANCE only, and is independent of whether the
19
- * slug is also globally allowlisted in `NATIVE_OPENAI_MODELS`. `gpt-daybreak-blue-latest`
20
- * is now in BOTH: it inherits Sol's capability shape AND ships as a globally supported
21
- * native row (owner decision, devlog 260816_codexrs_multiagent_v2_and_history_perf/011).
29
+ * slug is also present in `NATIVE_OPENAI_MODELS`. `gpt-daybreak-blue-latest` is now in BOTH:
30
+ * it inherits Sol's capability shape AND is a supported account-gated native id (owner decision,
31
+ * devlog 260816_codexrs_multiagent_v2_and_history_perf/011).
22
32
  *
23
33
  * The maps that consume the union of these two lists (`PINNED_NATIVE_CAPABILITY_ENTRIES`,
24
34
  * `UPSTREAM_NATIVE_ENTRIES`) are keyed by slug, so an overlapping id collapses to one
25
- * entry. Catalog row generation iterates `NATIVE_OPENAI_MODELS` alone, so it still emits
26
- * exactly one bare row and one row per account selector.
35
+ * entry. Catalog row generation iterates `NATIVE_OPENAI_MODELS`, then entitlement evidence limits
36
+ * it to at most one bare row and one row per entitled account selector.
27
37
  */
28
38
  export const NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS = Object.freeze(
29
39
  Object.keys(NATIVE_OPENAI_CAPABILITY_SOURCES),
@@ -42,16 +52,14 @@ export function nativeOpenAiCapabilitySourceSlug(slug: string): string {
42
52
  *
43
53
  * `gpt-daybreak-blue-latest` is entitlement-gated upstream: it is absent from codex-rs's
44
54
  * bundled catalog and reaches a client only through an authenticated `/models` response.
45
- * It is listed here by explicit owner decision so the row exists without waiting for an
46
- * observation, because opencodex injects `model_catalog_json` and codex-rs therefore builds
55
+ * It is listed here by explicit owner decision so the capability template exists without waiting
56
+ * for an observation, because opencodex injects `model_catalog_json` and codex-rs therefore builds
47
57
  * a `StaticModelsManager` whose refresh is a no-op — an entitled account had no way to
48
58
  * discover it on a clean install.
49
59
  *
50
- * Accepted tradeoff: an UNENTITLED account also sees the row. Catalog sync still succeeds;
51
- * selecting the model reaches the canonical OpenAI provider and the backend answers 400
52
- * "model not supported for this account", which is relayed (a bare pooled route may first
53
- * retry one alternate account on that exact body; a selector-qualified route is fixed and
54
- * relays immediately). `disabledModels` hides the row but is NOT a runtime routing denial.
60
+ * Availability is not static: catalog sync and Pool routing require the account's authenticated
61
+ * `/models` roster to contain the slug. An unconfirmed or unentitled account never receives the
62
+ * request. `disabledModels` remains the independent user visibility control.
55
63
  *
56
64
  * Devlog: 260816_codexrs_multiagent_v2_and_history_perf/011 §4-bis.
57
65
  */
@@ -126,6 +126,12 @@ export interface CatalogModel {
126
126
  /** Whether this exact routed model has a verified OpenAI-compatible service tier. */
127
127
  supportsServiceTier?: boolean;
128
128
  supportsReasoningSummaries?: boolean;
129
+ /**
130
+ * Codex tool calling mode for this routed model.
131
+ * "code_mode_only" (default) sets entry.tool_mode = "code_mode_only".
132
+ * "shell" leaves tool_mode unset so Codex declares top-level shell tools (exec_command).
133
+ */
134
+ codexToolMode?: "code_mode_only" | "shell";
129
135
  /** Normalized upstream capability names retained for management/API consumers (#485 follow-up). */
130
136
  capabilities?: string[];
131
137
  /** OpenCodex-only catalog ownership marker; Codex ignores the serialized extension field. */
@@ -423,7 +429,14 @@ export function catalogEntryIsNativeChatGpt(entry: RawEntry): boolean {
423
429
 
424
430
  export const ROUTED_CODEX_TOOL_MODE = "code_mode_only";
425
431
 
426
- export function applyRoutedCodexToolMode(entry: RawEntry): RawEntry {
432
+ export function applyRoutedCodexToolMode(
433
+ entry: RawEntry,
434
+ toolMode?: "code_mode_only" | "shell" | string,
435
+ ): RawEntry {
436
+ if (toolMode === "shell") {
437
+ delete entry.tool_mode;
438
+ return entry;
439
+ }
427
440
  entry.tool_mode = ROUTED_CODEX_TOOL_MODE;
428
441
  return entry;
429
442
  }
@@ -490,10 +503,14 @@ export function applyMultiAgentMode(
490
503
  return entries;
491
504
  }
492
505
 
493
- export function normalizeRoutedCatalogEntry(entry: RawEntry, parallelToolCalls = false): RawEntry {
506
+ export function normalizeRoutedCatalogEntry(
507
+ entry: RawEntry,
508
+ parallelToolCalls = false,
509
+ toolMode?: "code_mode_only" | "shell" | string,
510
+ ): RawEntry {
494
511
  delete entry.model_messages;
495
512
  delete entry.tool_mode;
496
- applyRoutedCodexToolMode(entry);
513
+ applyRoutedCodexToolMode(entry, toolMode);
497
514
  delete entry.multi_agent_version;
498
515
  delete entry.use_responses_lite;
499
516
  delete entry.supports_websockets;
@@ -413,6 +413,7 @@ function captureProviderGather(
413
413
  name,
414
414
  provider,
415
415
  registryTransportMatch,
416
+ configured,
416
417
  );
417
418
  const observedAuth = authResolver.kind === "observed"
418
419
  && provider.authMode !== "forward"
@@ -676,6 +677,7 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig,
676
677
  ...(prov.parallelToolCalls === true || (prov.adapter === "openai-chat" && prov.parallelToolCalls !== false)
677
678
  ? { parallelToolCalls: true }
678
679
  : {}),
680
+ ...(prov.codexToolMode !== undefined ? { codexToolMode: prov.codexToolMode } : {}),
679
681
  };
680
682
  const capped = applyProviderContextCap(hinted.contextWindow, providerCap);
681
683
  if (providerCap !== undefined && capped !== hinted.contextWindow) {
@@ -1881,6 +1883,11 @@ async function gatherRoutedModelsUncached(
1881
1883
  ...(Array.isArray(cm.reasoningEfforts) ? { reasoningEfforts: [...cm.reasoningEfforts] } : {}),
1882
1884
  ...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}),
1883
1885
  ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}),
1886
+ ...(cm.codexToolMode !== undefined
1887
+ ? { codexToolMode: cm.codexToolMode }
1888
+ : effectiveProvider?.codexToolMode !== undefined
1889
+ ? { codexToolMode: effectiveProvider.codexToolMode }
1890
+ : {}),
1884
1891
  };
1885
1892
  // #962: the dedupe below drops the provider-derived row this custom row replaces. Inherit that
1886
1893
  // row's provider capability metadata (reasoning ladder, default effort, parallel tool calls,
@@ -1905,6 +1912,7 @@ async function gatherRoutedModelsUncached(
1905
1912
  ...(base.parallelToolCalls === undefined && replaced.parallelToolCalls !== undefined ? { parallelToolCalls: replaced.parallelToolCalls } : {}),
1906
1913
  ...(base.supportsVerbosity === undefined && replaced.supportsVerbosity !== undefined ? { supportsVerbosity: replaced.supportsVerbosity } : {}),
1907
1914
  ...(base.supportsReasoningSummaries === undefined && replaced.supportsReasoningSummaries !== undefined ? { supportsReasoningSummaries: replaced.supportsReasoningSummaries } : {}),
1915
+ ...(base.codexToolMode === undefined && replaced.codexToolMode !== undefined ? { codexToolMode: replaced.codexToolMode } : {}),
1908
1916
  ...(base.capabilities === undefined && replaced.capabilities !== undefined ? { capabilities: replaced.capabilities } : {}),
1909
1917
  } : base;
1910
1918
  // Vision-sidecar coverage ONLY: if the custom model is in the enriched provider's
@@ -30,7 +30,15 @@ import { providerDestinationResolvedError } from "../../lib/destination-policy";
30
30
  import { redactSecretString } from "../../lib/redact";
31
31
  import upstreamModelsSnapshot from "../data/upstream-models.json";
32
32
  import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
33
+ import { providerCodexAccountMode } from "../../providers/registry";
33
34
  import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../account-namespaces";
35
+ import { MAIN_CODEX_ACCOUNT_ID } from "../main-account";
36
+ import {
37
+ availableAccountGatedNativeModels,
38
+ isCodexModelEntitlementSnapshotCurrent,
39
+ resolveCodexModelEntitlements,
40
+ type CodexModelEntitlementSnapshot,
41
+ } from "../model-entitlements";
34
42
 
35
43
 
36
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, readNativeBaseline } from "./parsing";
@@ -64,6 +72,7 @@ import {
64
72
  } from "../internal/catalog-writer";
65
73
  import { codexRuntimeStatePath } from "../runtime";
66
74
  import { accountBoundNativeDisplayName, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./account-models";
75
+ import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./native-models";
67
76
 
68
77
  export const MAX_SPAWN_AGENT_MODEL_OVERRIDES = 5;
69
78
 
@@ -331,7 +340,9 @@ export function deriveEntry(
331
340
  // This exact provider/model pair is the ChatGPT/Codex forward surface. Keep the pinned
332
341
  // native tool/search/responses-lite contract while preserving the routed slug and wire id.
333
342
  if (!codexForwardNativeCapabilityAlias) {
334
- normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true);
343
+ normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true, model?.codexToolMode);
344
+ } else if (model?.codexToolMode !== undefined) {
345
+ applyRoutedCodexToolMode(e, model.codexToolMode);
335
346
  }
336
347
  if (model) applyCatalogMetadata(e, model.provider, model.id, model.contextCap);
337
348
  applyCatalogModelMetadata(e, model);
@@ -358,8 +369,8 @@ export function deriveEntry(
358
369
  });
359
370
  }
360
371
  // Fallback when no template is available (best-effort; strict parser may need more).
361
- // All routed fallbacks enable deferred code-mode tool exposure; otherwise the nested catalog
362
- // expands into `exec.description` and can exceed Cursor's 120 KB serialized tool limit (#1830).
372
+ // Routed fallbacks default to code-mode tool exposure (or shell mode when codexToolMode === "shell");
373
+ // otherwise the nested catalog expands into `exec.description` and can exceed Cursor's 120 KB serialized tool limit (#1830).
363
374
  // Cursor still omits hosted web-search metadata because runTurn bypasses that separate sidecar.
364
375
  const isCursorFallback = isRouted && model?.provider === "cursor";
365
376
  const entry: RawEntry = {
@@ -373,7 +384,7 @@ export function deriveEntry(
373
384
  : {}),
374
385
  };
375
386
  if (isRouted) {
376
- applyRoutedCodexToolMode(entry);
387
+ applyRoutedCodexToolMode(entry, model?.codexToolMode);
377
388
  applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact);
378
389
  }
379
390
  else {
@@ -1236,6 +1247,7 @@ interface RetainedCatalogSyncWrite {
1236
1247
  readonly read: RetainedCatalogSyncRead;
1237
1248
  readonly permit: CatalogWritePermit;
1238
1249
  readonly owningCodexHome: string;
1250
+ readonly modelEntitlements: CodexModelEntitlementSnapshot;
1239
1251
  }
1240
1252
 
1241
1253
  function optionalFileBytes(path: string): string | null {
@@ -1399,6 +1411,7 @@ function writeRetainedCatalogSync({
1399
1411
  read,
1400
1412
  permit,
1401
1413
  owningCodexHome,
1414
+ modelEntitlements,
1402
1415
  }: RetainedCatalogSyncWrite): RetainedCatalogSyncResult {
1403
1416
  const { catalogPath, catalog, onDiskCatalog } = read;
1404
1417
  const catalogModelsForMerge = catalogModelsForMergeWithNativeRecovery(
@@ -1434,7 +1447,28 @@ function writeRetainedCatalogSync({
1434
1447
  const modelPickerOrder = config.modelPickerOrder ?? [];
1435
1448
  const multiAgentMode: MultiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default";
1436
1449
  const exactComboSlugs = exactComboCatalogSlugs(config);
1437
- const suppressedBareNativeSlugs = desktopAllowlistSuppressedNativeSlugs(config);
1450
+ const bareEligibleAccountIds = providerCodexAccountMode(
1451
+ OPENAI_CODEX_PROVIDER_ID,
1452
+ config.providers[OPENAI_CODEX_PROVIDER_ID],
1453
+ ) === "direct" ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined;
1454
+ const availableBareGatedNativeSlugs = availableAccountGatedNativeModels(
1455
+ modelEntitlements,
1456
+ bareEligibleAccountIds,
1457
+ );
1458
+ const availableAccountGatedNativeSlugs = availableAccountGatedNativeModels(modelEntitlements);
1459
+ const availableBareNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => (
1460
+ !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableBareGatedNativeSlugs.has(slug)
1461
+ ));
1462
+ const availableAccountNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => (
1463
+ !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableAccountGatedNativeSlugs.has(slug)
1464
+ ));
1465
+ const unavailableGatedNativeSlugs = new Set([...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(slug => (
1466
+ !availableBareGatedNativeSlugs.has(slug)
1467
+ )));
1468
+ const suppressedBareNativeSlugs = new Set([
1469
+ ...desktopAllowlistSuppressedNativeSlugs(config),
1470
+ ...unavailableGatedNativeSlugs,
1471
+ ]);
1438
1472
  const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE);
1439
1473
  const includeNativeOpenAi = shouldIncludeNativeOpenAi(config);
1440
1474
  const includeAccountBoundNativeOpenAi = shouldIncludeAccountBoundNativeOpenAi(config);
@@ -1449,12 +1483,21 @@ function writeRetainedCatalogSync({
1449
1483
  ...(onDiskCatalog?.models ?? []).filter(entry =>
1450
1484
  trustedAccountBoundNativeCatalogSlug(entry) !== undefined),
1451
1485
  ];
1452
- const accountNativeSlugs = accountSelectors.length > 0
1453
- ? accountBoundNativeOpenAiSlugs(observedAccountNativeEntries)
1454
- : [];
1486
+ const accountTargets = new Map(codexAccountNamespaceEntries(config));
1455
1487
  const accountNativeSlugsBySelector = accountSelectors.length > 0
1456
- ? accountBoundNativeOpenAiSlugsBySelector(config, observedAccountNativeEntries)
1488
+ ? new Map([...accountBoundNativeOpenAiSlugsBySelector(config, observedAccountNativeEntries)].map(([selector, slugs]) => {
1489
+ const target = accountTargets.get(selector);
1490
+ const accountId = target && isMainCodexAccountTarget(target) ? MAIN_CODEX_ACCOUNT_ID : target;
1491
+ const entitled = accountId ? modelEntitlements.modelsByAccount.get(accountId) : undefined;
1492
+ const confirmed = accountId ? modelEntitlements.confirmedAccountIds.has(accountId) : false;
1493
+ return [selector, slugs.filter(slug => (
1494
+ !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || (confirmed && entitled?.has(slug) === true)
1495
+ ))] as const;
1496
+ }))
1457
1497
  : new Map<string, readonly string[]>();
1498
+ const accountNativeSlugs = accountSelectors.length > 0
1499
+ ? [...new Set([...accountNativeSlugsBySelector.values()].flatMap(slugs => [...slugs]))]
1500
+ : [];
1458
1501
  // Unknown account-native ids have no safe bare/global identity. They are only projected through
1459
1502
  // the selector map above; the no-selector catalog remains the static native/API-key surface.
1460
1503
  const observedNativeSlugs: string[] = [];
@@ -1508,7 +1551,7 @@ function writeRetainedCatalogSync({
1508
1551
  const accountBoundEntries = includeAccountBoundNativeOpenAi && accountSelectors.length > 0
1509
1552
  ? buildCatalogEntriesFromObservedState({
1510
1553
  template: template ? JSON.parse(JSON.stringify(template)) : null,
1511
- gptSlugs: NATIVE_OPENAI_MODELS,
1554
+ gptSlugs: availableAccountNativeSlugs,
1512
1555
  goModels: [],
1513
1556
  featured,
1514
1557
  wsEnabled,
@@ -1548,7 +1591,7 @@ function writeRetainedCatalogSync({
1548
1591
  openaiContextCap,
1549
1592
  policy: {
1550
1593
  ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY,
1551
- nativeBackfillSlugs: [...NATIVE_OPENAI_MODELS, ...observedNativeSlugs],
1594
+ nativeBackfillSlugs: [...availableBareNativeSlugs, ...observedNativeSlugs],
1552
1595
  warningPolicy: "emit",
1553
1596
  },
1554
1597
  });
@@ -1662,10 +1705,13 @@ export async function syncCatalogModels(
1662
1705
  evidence: retainedCatalogSyncEvidence(config, preflightRead.catalogPath, preflightRead.catalog),
1663
1706
  processEvidence: retainedCatalogProcessEvidence(),
1664
1707
  };
1665
- const goModels = await gatherRoutedModels(config, {
1666
- comboOmissions,
1667
- providerModelOutcomes,
1668
- });
1708
+ const [goModels, modelEntitlements] = await Promise.all([
1709
+ gatherRoutedModels(config, {
1710
+ comboOmissions,
1711
+ providerModelOutcomes,
1712
+ }),
1713
+ resolveCodexModelEntitlements(config),
1714
+ ]);
1669
1715
  const committed = withCatalogWriteSerialization(owningCodexHome, permit => {
1670
1716
  // Desired state can flip OFF during the provider await above. The catalog
1671
1717
  // evidence revalidation below cannot see that — intent lives in our config,
@@ -1685,6 +1731,7 @@ export async function syncCatalogModels(
1685
1731
  }
1686
1732
  const current = revalidateRetainedCatalogSync(config, prepared);
1687
1733
  if (current === null) return null;
1734
+ if (!isCodexModelEntitlementSnapshotCurrent(modelEntitlements)) return null;
1688
1735
  return writeRetainedCatalogSync({
1689
1736
  config,
1690
1737
  goModels,
@@ -1693,6 +1740,7 @@ export async function syncCatalogModels(
1693
1740
  read: current,
1694
1741
  permit,
1695
1742
  owningCodexHome,
1743
+ modelEntitlements,
1696
1744
  });
1697
1745
  });
1698
1746
  if (committed.kind === "completed" && committed.value !== null) return committed.value;