@bitkyc08/opencodex 2.27.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 (45) hide show
  1. package/gui/dist/assets/{index-7jlKgmJd.js → index-D2sP-biU.js} +11 -11
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/anthropic.ts +59 -0
  5. package/src/adapters/base.ts +2 -0
  6. package/src/adapters/google-antigravity-replay.ts +16 -8
  7. package/src/adapters/google.ts +21 -4
  8. package/src/adapters/openai-chat.ts +151 -54
  9. package/src/adapters/openai-responses.ts +37 -0
  10. package/src/cli/index.ts +19 -6
  11. package/src/codex/account-usability.ts +3 -0
  12. package/src/codex/auth-api.ts +22 -5
  13. package/src/codex/auth-context.ts +55 -2
  14. package/src/codex/catalog/metadata.ts +17 -3
  15. package/src/codex/catalog/native-models.ts +22 -14
  16. package/src/codex/catalog/sync.ts +57 -11
  17. package/src/codex/convergence.ts +61 -13
  18. package/src/codex/model-entitlements.ts +353 -0
  19. package/src/codex/quota.ts +28 -3
  20. package/src/codex/routing.ts +14 -8
  21. package/src/generated/compatibility-version.json +51 -39
  22. package/src/lib/destination-policy.ts +47 -0
  23. package/src/lib/shadow-call.ts +15 -0
  24. package/src/oauth/index.ts +33 -5
  25. package/src/oauth/store.ts +11 -5
  26. package/src/providers/fastwire.ts +39 -8
  27. package/src/providers/quota.ts +9 -2
  28. package/src/providers/registry.ts +74 -5
  29. package/src/providers/service-tier.ts +16 -8
  30. package/src/responses/parser.ts +3 -9
  31. package/src/responses/tool-search-compat.ts +301 -0
  32. package/src/router.ts +7 -0
  33. package/src/routing/capability.ts +26 -9
  34. package/src/routing/compatibility/behavior.ts +41 -3
  35. package/src/server/chat-native.ts +11 -2
  36. package/src/server/index.ts +54 -8
  37. package/src/server/management/agent-settings-routes.ts +16 -2
  38. package/src/server/request-log.ts +31 -0
  39. package/src/server/responses/compact.ts +54 -7
  40. package/src/server/responses/core.ts +246 -39
  41. package/src/server/responses/responses-field-backfill.ts +88 -6
  42. package/src/server/responses/terminal-guard.ts +10 -0
  43. package/src/server/responses-tool-search-repair.ts +217 -0
  44. package/src/server/system-env.ts +74 -5
  45. package/src/usage/log.ts +4 -0
package/src/cli/index.ts CHANGED
@@ -46,9 +46,8 @@ import { loadServiceTokenFromFile } from "../lib/service-secrets";
46
46
  import { diagnoseService, isServiceOwnershipError, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "../service";
47
47
  import { startupHealthSummary } from "../codex/autostart-health";
48
48
  import { drainAndShutdown, isRecyclingForExit, startServer } from "../server";
49
- import { injectSystemEnv, revertSystemEnv } from "../server/system-env";
49
+ import { injectSystemEnv, reconcileShellHook, revertSystemEnv, uninstallShellHook } from "../server/system-env";
50
50
  import { buildDesktop3pRegistry } from "../claude/desktop-3p";
51
- import { installShellHook, uninstallShellHook } from "../server/system-env";
52
51
  import { startTokenGuardian } from "../oauth/token-guardian";
53
52
  import { startHistoryMigrationGuardian } from "../codex/history-migration-guardian";
54
53
  import { maybeShowStarPrompt } from "./star-prompt";
@@ -57,6 +56,18 @@ import { maybeShowUpdatePrompt } from "../update/notify";
57
56
  import { syncModelsToCodex } from "../codex/sync";
58
57
  import { setIntegrationEnabled, shouldSyncCodexOnStart, shouldSyncGrokOnStart, syncCodexOnStartIfEnabled } from "../codex/desired-state";
59
58
 
59
+ /**
60
+ * A failed shell-hook reconcile is not cosmetic: a stale hook keeps sourcing
61
+ * `claude-env.sh` from every new interactive shell, pointing at a proxy or a CLI that may no
62
+ * longer exist. `reconcileShellHook` already reports `state: "failed"`, but both call sites
63
+ * discarded it, so the one outcome the user has to act on was the one they never saw.
64
+ */
65
+ function reportShellHookFailure(result: { state: "installed" | "absent" | "failed"; reason?: string }): void {
66
+ if (result.state !== "failed") return;
67
+ console.warn(` Claude shell hook not reconciled${result.reason ? `: ${result.reason}` : ""}`);
68
+ console.warn(" Check ~/.zshrc for the '# opencodex claude-env hook' block.");
69
+ }
70
+
60
71
 
61
72
  import { removeOwnedConfigState } from "../lib/config-ownership";
62
73
  import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
@@ -366,9 +377,10 @@ async function handleStart(options: { block?: boolean } = {}) {
366
377
 
367
378
  // System-wide env injection AFTER signal handlers are registered (crash safety:
368
379
  // syncCleanup reverts even if injection itself or subsequent startup steps fail).
369
- await injectSystemEnv(port, config).catch(() => {});
370
- // Auto-install .zshrc hook (idempotent skips if already present).
371
- installShellHook();
380
+ const systemEnv = await injectSystemEnv(port, config).catch(() => ({ injected: false }));
381
+ // The hook is useful only for an installed Claude Code CLI. Reconcile instead of
382
+ // appending unconditionally so stale OpenCodex-owned hooks are removed as well.
383
+ reportShellHookFailure(reconcileShellHook(systemEnv.injected));
372
384
 
373
385
  await maybeShowStarPrompt(); // once-only Yes/No GitHub-star prompt on first interactive start
374
386
  // Post-startup sync drives the readiness gate AND the #1046 stale app-server
@@ -455,7 +467,8 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom
455
467
  });
456
468
  if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native.");
457
469
  // Ensure env file exists for already-running proxy (may have been deleted or pre-dates this feature).
458
- await injectSystemEnv(live.port, config).catch(() => {});
470
+ const systemEnv = await injectSystemEnv(live.port, config).catch(() => ({ injected: false }));
471
+ reportShellHookFailure(reconcileShellHook(systemEnv.injected));
459
472
  // Refresh the Grok Build fence too (same contract as start). live.hostname is the
460
473
  // hostname the running proxy actually bound — config.hostname may have drifted.
461
474
  try {
@@ -10,6 +10,8 @@ export interface CodexAccountUsabilityOptions {
10
10
  nativeMainSelectionOnly?: boolean;
11
11
  /** Test seam for proving whether routing attempted a physical native-token read. */
12
12
  isMainAccountTokenLive?: typeof isMainAccountTokenLive;
13
+ /** Confirmed account ids for an account-gated model; omitted for ordinary native models. */
14
+ modelEligibleAccountIds?: ReadonlySet<string>;
13
15
  }
14
16
 
15
17
  export function isCodexAccountUsable(
@@ -17,6 +19,7 @@ export function isCodexAccountUsable(
17
19
  accountId: string,
18
20
  options: CodexAccountUsabilityOptions = {},
19
21
  ): boolean {
22
+ if (options.modelEligibleAccountIds && !options.modelEligibleAccountIds.has(accountId)) return false;
20
23
  if (accountId === MAIN_CODEX_ACCOUNT_ID) {
21
24
  // Startup recovery owns the physical auth/vault boundary. Never parse or select
22
25
  // native __main__ while an encrypted switch journal is pending or inconclusive.
@@ -644,12 +644,13 @@ async function retryMainAccountInfoIfIdentityChanged(
644
644
  requestAccountId: string | null,
645
645
  retriesRemaining: number,
646
646
  nativeMainLease: AdmissionLease,
647
+ explicitRefresh: boolean,
647
648
  ): Promise<MainAccountInfoFetchResult | null> {
648
649
  const currentAccountId = getMainChatgptAccountId();
649
650
  if (currentAccountId === null || currentAccountId === requestAccountId) return null;
650
651
  reconcileMainCodexAccountRuntimeState();
651
652
  return retriesRemaining > 0
652
- ? fetchMainAccountInfoWhileOwned(true, retriesRemaining - 1, nativeMainLease)
653
+ ? fetchMainAccountInfoWhileOwned(true, retriesRemaining - 1, nativeMainLease, explicitRefresh)
653
654
  : { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true };
654
655
  }
655
656
 
@@ -696,6 +697,13 @@ async function fetchMainAccountInfoWhileOwned(
696
697
  forceRefresh: boolean,
697
698
  retriesRemaining: number,
698
699
  nativeMainLease: AdmissionLease,
700
+ /**
701
+ * Whether the *caller* asked for this refresh. `forceRefresh` also means "bypass the
702
+ * cache", and `retryMainAccountInfoIfIdentityChanged` re-enters with it set purely to
703
+ * re-read after the identity changed. Keeping the two apart stops that retry from
704
+ * promoting a background poll into operator intent below.
705
+ */
706
+ explicitRefresh: boolean = forceRefresh,
699
707
  ): Promise<MainAccountInfoFetchResult> {
700
708
  const writerGeneration = captureConfigGeneration();
701
709
  reconcileMainCodexAccountRuntimeState();
@@ -724,7 +732,7 @@ async function fetchMainAccountInfoWhileOwned(
724
732
  });
725
733
  if (!resp.ok) {
726
734
  const terminalAuthFailure = await isTerminalMainAuthResponse(resp, isMainAccountTokenVerifiablyLive());
727
- const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease);
735
+ const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
728
736
  if (retried) return retried;
729
737
  if (terminalAuthFailure) {
730
738
  clearMainAccountInfoCache();
@@ -733,7 +741,7 @@ async function fetchMainAccountInfoWhileOwned(
733
741
  return { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true };
734
742
  }
735
743
  const data = (await resp.json()) as WhamUsageResponse;
736
- const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease);
744
+ const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
737
745
  if (retried) return retried;
738
746
  const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan());
739
747
  const quota = parseUsageQuota({ ...data, ...(plan ? { plan_type: plan } : {}) });
@@ -745,7 +753,16 @@ async function fetchMainAccountInfoWhileOwned(
745
753
  ts: Date.now(),
746
754
  };
747
755
  setMainAccountInfoCache(result);
748
- clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
756
+ // Only an explicit refresh may retract a reauth quarantine. A 200 from
757
+ // /wham/usage proves the token authenticates to the usage endpoint; it does not
758
+ // prove the account can serve Responses traffic, which is a different backend path
759
+ // and still answers 403 for a workspace the token may no longer select (#327).
760
+ // Letting the background poll clear the flag put such an account straight back into
761
+ // rotation: the next request failed the same way and re-marked it, so needsReauth
762
+ // never settled and the dashboard kept showing nothing — the symptom #327 reported.
763
+ // An explicit refresh is an operator asking to re-evaluate, normally right after
764
+ // signing in again, so it stays authoritative.
765
+ if (explicitRefresh) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
749
766
  // Mirror main quota + plan into the shared stores so the rotation engine can
750
767
  // score and auto-switch the main account exactly like a pool account (Option A).
751
768
  setMainAccountPlan(result.plan);
@@ -760,7 +777,7 @@ async function fetchMainAccountInfoWhileOwned(
760
777
  ...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
761
778
  };
762
779
  } catch {
763
- const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease);
780
+ const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
764
781
  return retried ?? { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true };
765
782
  }
766
783
  }
@@ -24,6 +24,13 @@ import {
24
24
  pickAlternateCodexAccount,
25
25
  resolveCodexAccountForThreadDetailed,
26
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";
27
34
  import type { CodexCooldownSource, CodexQuotaScope } from "./routing";
28
35
  import { maskAccountId } from "../lib/privacy";
29
36
  import { formatErrorResponse } from "../bridge";
@@ -289,6 +296,14 @@ export interface ResolveCodexAuthContextOptions {
289
296
  isMainAccountTokenLive?: () => boolean;
290
297
  getMainAccountToken?: typeof getMainAccountToken;
291
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>;
292
307
  }
293
308
 
294
309
  export interface CodexAccountSelectionAdmission {
@@ -312,8 +327,28 @@ export async function resolveCodexAuthContext(
312
327
  // selected stored credential even while the canonical OpenAI provider is globally Direct.
313
328
  if (mode === "direct" && fixedAccountId === undefined) {
314
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
+ }
315
344
  return { kind: "main", accountId: null };
316
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;
317
352
  // Retained startup recovery makes the physical main identity ineligible. Routing
318
353
  // can still preserve service by selecting a healthy configured pool account.
319
354
  const nativeMainTrafficBlocked = isNativeMainTrafficBlocked();
@@ -325,6 +360,7 @@ export async function resolveCodexAuthContext(
325
360
  nativeMainSelectionOnly: !nativeMainTrafficBlocked
326
361
  && selectionAdmission?.mainProfileDraining === true,
327
362
  isMainAccountTokenLive: options.isMainAccountTokenLive,
363
+ modelEligibleAccountIds,
328
364
  };
329
365
  let accountId: string;
330
366
  const quotaScope = codexQuotaScopeForModel(options.modelId);
@@ -354,7 +390,11 @@ export async function resolveCodexAuthContext(
354
390
  const selected = resolution.status === "selected" ? resolution.accountId : null;
355
391
  if (!selected) {
356
392
  if (fixedAccountId !== undefined) {
357
- 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
+ );
358
398
  }
359
399
  // Recovery deliberately makes physical main ineligible. If no healthy
360
400
  // pool route is configured and main is the intended route, report the
@@ -364,7 +404,9 @@ export async function resolveCodexAuthContext(
364
404
  if (nativeMainTrafficBlocked && !options.excludeAccountId) {
365
405
  throw new CodexMainProfileDrainingError();
366
406
  }
367
- throw new CodexPoolAuthenticationError();
407
+ throw new CodexPoolAuthenticationError(
408
+ modelEligibleAccountIds ? "No eligible Codex account supports this model" : undefined,
409
+ );
368
410
  }
369
411
  accountId = selected;
370
412
  if (accountId === MAIN_CODEX_ACCOUNT_ID && nativeMainTrafficBlocked) {
@@ -377,6 +419,17 @@ export async function resolveCodexAuthContext(
377
419
  ) {
378
420
  throw new CodexMainProfileDrainingError();
379
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
+ }
380
433
  if (fixedAccountId !== undefined) {
381
434
  if (isCodexAccountPaused(config, accountId)) {
382
435
  throw new CodexPoolAuthenticationError("Selected Codex account is unavailable");
@@ -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
  */
@@ -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
 
@@ -1238,6 +1247,7 @@ interface RetainedCatalogSyncWrite {
1238
1247
  readonly read: RetainedCatalogSyncRead;
1239
1248
  readonly permit: CatalogWritePermit;
1240
1249
  readonly owningCodexHome: string;
1250
+ readonly modelEntitlements: CodexModelEntitlementSnapshot;
1241
1251
  }
1242
1252
 
1243
1253
  function optionalFileBytes(path: string): string | null {
@@ -1401,6 +1411,7 @@ function writeRetainedCatalogSync({
1401
1411
  read,
1402
1412
  permit,
1403
1413
  owningCodexHome,
1414
+ modelEntitlements,
1404
1415
  }: RetainedCatalogSyncWrite): RetainedCatalogSyncResult {
1405
1416
  const { catalogPath, catalog, onDiskCatalog } = read;
1406
1417
  const catalogModelsForMerge = catalogModelsForMergeWithNativeRecovery(
@@ -1436,7 +1447,28 @@ function writeRetainedCatalogSync({
1436
1447
  const modelPickerOrder = config.modelPickerOrder ?? [];
1437
1448
  const multiAgentMode: MultiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default";
1438
1449
  const exactComboSlugs = exactComboCatalogSlugs(config);
1439
- 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
+ ]);
1440
1472
  const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE);
1441
1473
  const includeNativeOpenAi = shouldIncludeNativeOpenAi(config);
1442
1474
  const includeAccountBoundNativeOpenAi = shouldIncludeAccountBoundNativeOpenAi(config);
@@ -1451,12 +1483,21 @@ function writeRetainedCatalogSync({
1451
1483
  ...(onDiskCatalog?.models ?? []).filter(entry =>
1452
1484
  trustedAccountBoundNativeCatalogSlug(entry) !== undefined),
1453
1485
  ];
1454
- const accountNativeSlugs = accountSelectors.length > 0
1455
- ? accountBoundNativeOpenAiSlugs(observedAccountNativeEntries)
1456
- : [];
1486
+ const accountTargets = new Map(codexAccountNamespaceEntries(config));
1457
1487
  const accountNativeSlugsBySelector = accountSelectors.length > 0
1458
- ? 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
+ }))
1459
1497
  : new Map<string, readonly string[]>();
1498
+ const accountNativeSlugs = accountSelectors.length > 0
1499
+ ? [...new Set([...accountNativeSlugsBySelector.values()].flatMap(slugs => [...slugs]))]
1500
+ : [];
1460
1501
  // Unknown account-native ids have no safe bare/global identity. They are only projected through
1461
1502
  // the selector map above; the no-selector catalog remains the static native/API-key surface.
1462
1503
  const observedNativeSlugs: string[] = [];
@@ -1510,7 +1551,7 @@ function writeRetainedCatalogSync({
1510
1551
  const accountBoundEntries = includeAccountBoundNativeOpenAi && accountSelectors.length > 0
1511
1552
  ? buildCatalogEntriesFromObservedState({
1512
1553
  template: template ? JSON.parse(JSON.stringify(template)) : null,
1513
- gptSlugs: NATIVE_OPENAI_MODELS,
1554
+ gptSlugs: availableAccountNativeSlugs,
1514
1555
  goModels: [],
1515
1556
  featured,
1516
1557
  wsEnabled,
@@ -1550,7 +1591,7 @@ function writeRetainedCatalogSync({
1550
1591
  openaiContextCap,
1551
1592
  policy: {
1552
1593
  ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY,
1553
- nativeBackfillSlugs: [...NATIVE_OPENAI_MODELS, ...observedNativeSlugs],
1594
+ nativeBackfillSlugs: [...availableBareNativeSlugs, ...observedNativeSlugs],
1554
1595
  warningPolicy: "emit",
1555
1596
  },
1556
1597
  });
@@ -1664,10 +1705,13 @@ export async function syncCatalogModels(
1664
1705
  evidence: retainedCatalogSyncEvidence(config, preflightRead.catalogPath, preflightRead.catalog),
1665
1706
  processEvidence: retainedCatalogProcessEvidence(),
1666
1707
  };
1667
- const goModels = await gatherRoutedModels(config, {
1668
- comboOmissions,
1669
- providerModelOutcomes,
1670
- });
1708
+ const [goModels, modelEntitlements] = await Promise.all([
1709
+ gatherRoutedModels(config, {
1710
+ comboOmissions,
1711
+ providerModelOutcomes,
1712
+ }),
1713
+ resolveCodexModelEntitlements(config),
1714
+ ]);
1671
1715
  const committed = withCatalogWriteSerialization(owningCodexHome, permit => {
1672
1716
  // Desired state can flip OFF during the provider await above. The catalog
1673
1717
  // evidence revalidation below cannot see that — intent lives in our config,
@@ -1687,6 +1731,7 @@ export async function syncCatalogModels(
1687
1731
  }
1688
1732
  const current = revalidateRetainedCatalogSync(config, prepared);
1689
1733
  if (current === null) return null;
1734
+ if (!isCodexModelEntitlementSnapshotCurrent(modelEntitlements)) return null;
1690
1735
  return writeRetainedCatalogSync({
1691
1736
  config,
1692
1737
  goModels,
@@ -1695,6 +1740,7 @@ export async function syncCatalogModels(
1695
1740
  read: current,
1696
1741
  permit,
1697
1742
  owningCodexHome,
1743
+ modelEntitlements,
1698
1744
  });
1699
1745
  });
1700
1746
  if (committed.kind === "completed" && committed.value !== null) return committed.value;