From 0bce9516d8d987d5b209d1c92cce24d278941b55 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 04:51:56 +0900 Subject: [PATCH] fix(codex): gate account-native models by entitlement gpt-daybreak-blue-latest is in the static native set, so catalog sync copied it onto every account selector and Pool could bind a bare Daybreak request to an account whose authenticated roster never contained it. The upstream answered "The 'gpt-daybreak-blue-latest' model is not supported when using Codex with a ChatGPT account." Make the authenticated ChatGPT roster the source of truth: discover per-account entitlement, advertise the gated row only where an eligible account confirms it, and refuse selection of an account that cannot serve it. Discovery failures fail closed - the row disappears rather than being offered on unproven evidence. Carries @Ingwannu's #2101, with three corrections: Selector compact missed the wire rewrite. accountGatedCompactWireModel was derived from the caller's raw model string, and an account-qualified selector like side/gpt-daybreak-blue-latest does not match the gated map, so it still took the native compact endpoint the guard exists to avoid. It now derives from route.modelId, the same value core.ts normalizes from. Direct callers shared one 64-entry roster cache with main/Pool. A burst of distinct Direct callers evicted the very entries the catalog projects from, so the gated row vanished until rediscovery. The two classes now evict separately. A comment in native-models.ts still claimed routing never collapses Daybreak into gpt-5.6-sol, which the wire normalization does exactly. Stacked on #2137: this consumes the substituteMainCredential value that PR corrects, so it must not land ahead of it. Closes #2097 --- .../content/docs/guides/codex-app-models.md | 20 +- src/codex/account-usability.ts | 3 + src/codex/auth-context.ts | 57 ++- src/codex/catalog/metadata.ts | 20 +- src/codex/catalog/native-models.ts | 36 +- src/codex/catalog/sync.ts | 68 +++- src/codex/convergence.ts | 74 +++- src/codex/model-entitlements.ts | 353 ++++++++++++++++++ src/server/index.ts | 62 ++- src/server/responses/compact.ts | 56 ++- src/server/responses/core.ts | 142 +++++-- structure/03_catalog-and-subagents.md | 7 + structure/08_openai-provider-tiers.md | 51 +++ tests/claude-models-discovery.test.ts | 27 +- tests/codex-auth-context.test.ts | 117 ++++++ tests/codex-catalog-sync-hardening.test.ts | 31 +- ...odex-convergence-account-selectors.test.ts | 80 +++- tests/codex-model-entitlements.test.ts | 155 ++++++++ tests/native-model-toggle.test.ts | 42 ++- tests/server-auth.test.ts | 199 +++++++++- 20 files changed, 1477 insertions(+), 123 deletions(-) create mode 100644 src/codex/model-entitlements.ts create mode 100644 tests/codex-model-entitlements.test.ts diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index 0a5008f6fa..b0910bd9d5 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -23,9 +23,17 @@ the bare or API-key model list. The row is matched on the field shape a real cat which filters malformed entries — it does not prove the id came from an upstream response, since the cache is a user-owned file. See [Exact Codex account selectors](/reference/configuration/routing/#exact-codex-account-selectors). -`gpt-daybreak-blue-latest` follows that observation-only rule for account-qualified rows and is not -added to the bare native allowlist. A separate, explicit `customModels` entry can expose the same -wire id as `openai/gpt-daybreak-blue-latest` through the canonical Codex-login forward provider: +`gpt-daybreak-blue-latest` is account-gated. opencodex checks each authenticated ChatGPT account's +own Codex model roster before advertising or routing it. In Pool mode, the bare row exists only when +at least one eligible Pool account reports the slug. In Direct mode, the bare row follows the main +account used by the local catalog, and each request also checks the forwarded caller credential (or +the stored main credential when an OpenCodex admission bearer is substituted). A +`/gpt-daybreak-blue-latest` row exists only when that selector's mapped account reports it. +Pool routing excludes unentitled accounts. If no roster can be confirmed, the gated row fails closed +instead of spending a prompt on an upstream 400. + +A separate, explicit `customModels` entry can expose the same wire id as +`openai/gpt-daybreak-blue-latest` through the canonical Codex-login forward provider: ```json { @@ -41,8 +49,8 @@ wire id as `openai/gpt-daybreak-blue-latest` through the canonical Codex-login f Only that exact provider, endpoint, and model id receive the pinned Sol capability snapshot: 922,000 context, 829,800 automatic compaction, the native reasoning ladder, and native Codex tool -metadata. The request still sends `gpt-daybreak-blue-latest`; opencodex does not rewrite it to Sol, -does not create a bare row, and does not grant account entitlement. The separately billed +metadata. The request still sends `gpt-daybreak-blue-latest`; opencodex does not rewrite it to Sol +or grant account entitlement. The separately billed `openai-apikey/daybreak-blue-latest` API row is a different route and its 1,050,000 / 922,000 limits are never copied into the Codex-login row. @@ -68,7 +76,7 @@ gpt-5.6-sol # bare Codex-login route via Pool or Direct /gpt-5.6-sol # stored Codex account mapped by that selector openai-apikey/gpt-5.6-sol # API key openai/gpt-daybreak-blue-latest # explicit Codex-forward custom row (922,000) -/gpt-daybreak-blue-latest # observed account-qualified native id, when available +/gpt-daybreak-blue-latest # account-qualified native id, only when that account reports it openai-apikey/daybreak-blue-latest # separate API-key route (1,050,000 / 922,000) ``` diff --git a/src/codex/account-usability.ts b/src/codex/account-usability.ts index 7036521d0e..d508e19f4d 100644 --- a/src/codex/account-usability.ts +++ b/src/codex/account-usability.ts @@ -10,6 +10,8 @@ export interface CodexAccountUsabilityOptions { nativeMainSelectionOnly?: boolean; /** Test seam for proving whether routing attempted a physical native-token read. */ isMainAccountTokenLive?: typeof isMainAccountTokenLive; + /** Confirmed account ids for an account-gated model; omitted for ordinary native models. */ + modelEligibleAccountIds?: ReadonlySet; } export function isCodexAccountUsable( @@ -17,6 +19,7 @@ export function isCodexAccountUsable( accountId: string, options: CodexAccountUsabilityOptions = {}, ): boolean { + if (options.modelEligibleAccountIds && !options.modelEligibleAccountIds.has(accountId)) return false; if (accountId === MAIN_CODEX_ACCOUNT_ID) { // Startup recovery owns the physical auth/vault boundary. Never parse or select // native __main__ while an encrypted switch journal is pending or inconclusive. diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 1f8b120112..71a79b1b67 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -24,6 +24,13 @@ import { pickAlternateCodexAccount, resolveCodexAccountForThreadDetailed, } from "./routing"; +import { + entitledCodexAccountIdsForModel, + isDirectCallerEntitledToCodexModel, + resolveCodexModelEntitlements, + type CodexModelEntitlementSnapshot, +} from "./model-entitlements"; +import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models"; import type { CodexCooldownSource, CodexQuotaScope } from "./routing"; import { maskAccountId } from "../lib/privacy"; import { formatErrorResponse } from "../bridge"; @@ -289,6 +296,14 @@ export interface ResolveCodexAuthContextOptions { isMainAccountTokenLive?: () => boolean; getMainAccountToken?: typeof getMainAccountToken; primeCodexPoolQuotas?: (config: OcxConfig, reason: string) => Promise; + /** Test seam for account-gated native model discovery. */ + resolveCodexModelEntitlements?: ( + config: Pick, + ) => Promise; + /** Direct requests admitted with a proxy bearer substitute the stored native-main credential. */ + substituteMainCredentialForDirect?: boolean; + /** Test seam for a Direct request's own forwarded ChatGPT credential. */ + isDirectCallerEntitledToCodexModel?: (headers: Headers, modelId: string) => Promise; } export interface CodexAccountSelectionAdmission { @@ -312,8 +327,28 @@ export async function resolveCodexAuthContext( // selected stored credential even while the canonical OpenAI provider is globally Direct. if (mode === "direct" && fixedAccountId === undefined) { if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError(); + if (options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)) { + const entitled = options.substituteMainCredentialForDirect + ? entitledCodexAccountIdsForModel( + await (options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements)(config), + options.modelId, + )?.has(MAIN_CODEX_ACCOUNT_ID) === true + : await (options.isDirectCallerEntitledToCodexModel ?? isDirectCallerEntitledToCodexModel)( + headers, + options.modelId, + ); + if (!entitled) { + throw new CodexPoolAuthenticationError("The selected ChatGPT account does not support this model"); + } + } return { kind: "main", accountId: null }; } + const entitlementSnapshot = options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId) + ? await (options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements)(config) + : undefined; + const modelEligibleAccountIds = entitlementSnapshot + ? entitledCodexAccountIdsForModel(entitlementSnapshot, options.modelId) + : undefined; // Retained startup recovery makes the physical main identity ineligible. Routing // can still preserve service by selecting a healthy configured pool account. const nativeMainTrafficBlocked = isNativeMainTrafficBlocked(); @@ -325,6 +360,7 @@ export async function resolveCodexAuthContext( nativeMainSelectionOnly: !nativeMainTrafficBlocked && selectionAdmission?.mainProfileDraining === true, isMainAccountTokenLive: options.isMainAccountTokenLive, + modelEligibleAccountIds, }; let accountId: string; const quotaScope = codexQuotaScopeForModel(options.modelId); @@ -354,7 +390,11 @@ export async function resolveCodexAuthContext( const selected = resolution.status === "selected" ? resolution.accountId : null; if (!selected) { if (fixedAccountId !== undefined) { - throw new CodexPoolAuthenticationError("Selected Codex account is unavailable"); + throw new CodexPoolAuthenticationError( + modelEligibleAccountIds && !modelEligibleAccountIds.has(fixedAccountId) + ? "Selected Codex account does not support this model" + : "Selected Codex account is unavailable", + ); } // Recovery deliberately makes physical main ineligible. If no healthy // pool route is configured and main is the intended route, report the @@ -364,7 +404,9 @@ export async function resolveCodexAuthContext( if (nativeMainTrafficBlocked && !options.excludeAccountId) { throw new CodexMainProfileDrainingError(); } - throw new CodexPoolAuthenticationError(); + throw new CodexPoolAuthenticationError( + modelEligibleAccountIds ? "No eligible Codex account supports this model" : undefined, + ); } accountId = selected; if (accountId === MAIN_CODEX_ACCOUNT_ID && nativeMainTrafficBlocked) { @@ -377,6 +419,17 @@ export async function resolveCodexAuthContext( ) { throw new CodexMainProfileDrainingError(); } + // Some legacy Pool fallbacks preserve a configured active account even when it is not + // currently selectable, so token/cooldown code can produce the historical actionable error. + // Model entitlement is different: sending the request would spend a turn on an account whose + // authenticated roster already denied the model. Reassert this boundary after every selector. + if (modelEligibleAccountIds && !modelEligibleAccountIds.has(accountId)) { + throw new CodexPoolAuthenticationError( + fixedAccountId !== undefined + ? "Selected Codex account does not support this model" + : "No eligible Codex account supports this model", + ); + } if (fixedAccountId !== undefined) { if (isCodexAccountPaused(config, accountId)) { throw new CodexPoolAuthenticationError("Selected Codex account is unavailable"); diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index 6733f5aa6c..d071bf59bd 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -12,7 +12,7 @@ import { modelInList } from "../../types"; import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata"; import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; -import { getProviderRegistryEntry } from "../../providers/registry"; +import { getProviderRegistryEntry, providerCodexAccountMode } from "../../providers/registry"; import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap"; import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec"; import { identifyRoutedModel } from "../../adapters/identity"; @@ -38,6 +38,7 @@ import { readCurrentCatalogOrCache, readCurrentCodexCatalog, readCurrentCodexMod import { trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./account-models"; import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds"; import { + ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_DAYBREAK_BLUE_MODEL, NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS, NATIVE_OPENAI_MODELS, @@ -45,6 +46,8 @@ import { isNativeOpenAiCapabilityAliasModel, nativeOpenAiCapabilitySourceSlug, } from "./native-models"; +import { cachedAvailableAccountGatedNativeModels } from "../model-entitlements"; +import { MAIN_CODEX_ACCOUNT_ID } from "../main-account"; export { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds"; export { NATIVE_DAYBREAK_BLUE_MODEL, @@ -390,7 +393,14 @@ export function nativeModelRows(config: Pick !shadowed.has(slug)).map(slug => { + const bareEligibleAccountIds = providerCodexAccountMode( + OPENAI_CODEX_PROVIDER_ID, + config.providers?.[OPENAI_CODEX_PROVIDER_ID], + ) === "direct" ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined; + const availableGated = cachedAvailableAccountGatedNativeModels(Date.now(), bareEligibleAccountIds); + return NATIVE_OPENAI_MODELS + .filter(slug => !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableGated.has(slug)) + .filter(slug => !shadowed.has(slug)).map(slug => { const contextWindow = nativeOpenAiContextWindow(slug, limits); const maxInputTokens = nativeOpenAiMaxInputTokens(slug, limits); return { @@ -475,7 +485,11 @@ export function shouldUpgradeToUpstreamEntry(entry: RawEntry): boolean { export function nativeOpenAiSlugs(): string[] { const live = catalogNativeSlugs(); - return live.length > 0 ? unique([...live, ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS]) : NATIVE_OPENAI_MODELS; + const availableGated = cachedAvailableAccountGatedNativeModels(); + const candidates = live.length > 0 ? unique([...live, ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS]) : NATIVE_OPENAI_MODELS; + return candidates.filter(slug => ( + !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableGated.has(slug) + )); } const ACCOUNT_BOUND_OPENAI_NATIVE_PREFIX = /^(?:gpt-|o1-|o3-|o4-)/; diff --git a/src/codex/catalog/native-models.ts b/src/codex/catalog/native-models.ts index b835f232d4..3fd673f84b 100644 --- a/src/codex/catalog/native-models.ts +++ b/src/codex/catalog/native-models.ts @@ -1,12 +1,22 @@ /** ChatGPT/Codex wire id observed for the account-native Daybreak Blue surface. */ export const NATIVE_DAYBREAK_BLUE_MODEL = "gpt-daybreak-blue-latest"; +/** Native ChatGPT/Codex ids whose availability is proven per authenticated account. */ +export const ACCOUNT_GATED_NATIVE_OPENAI_MODELS: ReadonlySet = new Set([ + NATIVE_DAYBREAK_BLUE_MODEL, +]); + /** * Account-native aliases whose Codex capabilities track another pinned native row. * * This is catalog metadata inheritance only. Routing always preserves the requested - * wire id, so the ChatGPT/Codex `gpt-daybreak-*` surface never collapses into the - * separately billed API-key `daybreak-*-latest` surface or into `gpt-5.6-sol`. + * wire id for the separately billed API-key `daybreak-*-latest` surface, so the two never + * collapse into each other. + * + * The ChatGPT/Codex surface is different: an account-gated request IS rewritten to its + * canonical wire model before it leaves the process (`applyCodexAccountGatedWireNormalization` + * in src/server/responses/core.ts), because the authenticated backend rejects the gated slug + * on shards that do not carry it. The catalog keeps the product identity; only the wire moves. */ const NATIVE_OPENAI_CAPABILITY_SOURCES: Readonly> = Object.freeze({ [NATIVE_DAYBREAK_BLUE_MODEL]: "gpt-5.6-sol", @@ -16,14 +26,14 @@ const NATIVE_OPENAI_CAPABILITY_SOURCES: Readonly> = Objec * Native ids whose capability metadata is inherited from another pinned native row. * * Membership here is about METADATA INHERITANCE only, and is independent of whether the - * slug is also globally allowlisted in `NATIVE_OPENAI_MODELS`. `gpt-daybreak-blue-latest` - * is now in BOTH: it inherits Sol's capability shape AND ships as a globally supported - * native row (owner decision, devlog 260816_codexrs_multiagent_v2_and_history_perf/011). + * slug is also present in `NATIVE_OPENAI_MODELS`. `gpt-daybreak-blue-latest` is now in BOTH: + * it inherits Sol's capability shape AND is a supported account-gated native id (owner decision, + * devlog 260816_codexrs_multiagent_v2_and_history_perf/011). * * The maps that consume the union of these two lists (`PINNED_NATIVE_CAPABILITY_ENTRIES`, * `UPSTREAM_NATIVE_ENTRIES`) are keyed by slug, so an overlapping id collapses to one - * entry. Catalog row generation iterates `NATIVE_OPENAI_MODELS` alone, so it still emits - * exactly one bare row and one row per account selector. + * entry. Catalog row generation iterates `NATIVE_OPENAI_MODELS`, then entitlement evidence limits + * it to at most one bare row and one row per entitled account selector. */ export const NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS = Object.freeze( Object.keys(NATIVE_OPENAI_CAPABILITY_SOURCES), @@ -42,16 +52,14 @@ export function nativeOpenAiCapabilitySourceSlug(slug: string): string { * * `gpt-daybreak-blue-latest` is entitlement-gated upstream: it is absent from codex-rs's * bundled catalog and reaches a client only through an authenticated `/models` response. - * It is listed here by explicit owner decision so the row exists without waiting for an - * observation, because opencodex injects `model_catalog_json` and codex-rs therefore builds + * It is listed here by explicit owner decision so the capability template exists without waiting + * for an observation, because opencodex injects `model_catalog_json` and codex-rs therefore builds * a `StaticModelsManager` whose refresh is a no-op — an entitled account had no way to * discover it on a clean install. * - * Accepted tradeoff: an UNENTITLED account also sees the row. Catalog sync still succeeds; - * selecting the model reaches the canonical OpenAI provider and the backend answers 400 - * "model not supported for this account", which is relayed (a bare pooled route may first - * retry one alternate account on that exact body; a selector-qualified route is fixed and - * relays immediately). `disabledModels` hides the row but is NOT a runtime routing denial. + * Availability is not static: catalog sync and Pool routing require the account's authenticated + * `/models` roster to contain the slug. An unconfirmed or unentitled account never receives the + * request. `disabledModels` remains the independent user visibility control. * * Devlog: 260816_codexrs_multiagent_v2_and_history_perf/011 §4-bis. */ diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 1167bacc27..70b93ee7bd 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -30,7 +30,15 @@ import { providerDestinationResolvedError } from "../../lib/destination-policy"; import { redactSecretString } from "../../lib/redact"; import upstreamModelsSnapshot from "../data/upstream-models.json"; import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; +import { providerCodexAccountMode } from "../../providers/registry"; import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../account-namespaces"; +import { MAIN_CODEX_ACCOUNT_ID } from "../main-account"; +import { + availableAccountGatedNativeModels, + isCodexModelEntitlementSnapshotCurrent, + resolveCodexModelEntitlements, + type CodexModelEntitlementSnapshot, +} from "../model-entitlements"; 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 { } from "../internal/catalog-writer"; import { codexRuntimeStatePath } from "../runtime"; import { accountBoundNativeDisplayName, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./account-models"; +import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./native-models"; export const MAX_SPAWN_AGENT_MODEL_OVERRIDES = 5; @@ -1238,6 +1247,7 @@ interface RetainedCatalogSyncWrite { readonly read: RetainedCatalogSyncRead; readonly permit: CatalogWritePermit; readonly owningCodexHome: string; + readonly modelEntitlements: CodexModelEntitlementSnapshot; } function optionalFileBytes(path: string): string | null { @@ -1401,6 +1411,7 @@ function writeRetainedCatalogSync({ read, permit, owningCodexHome, + modelEntitlements, }: RetainedCatalogSyncWrite): RetainedCatalogSyncResult { const { catalogPath, catalog, onDiskCatalog } = read; const catalogModelsForMerge = catalogModelsForMergeWithNativeRecovery( @@ -1436,7 +1447,28 @@ function writeRetainedCatalogSync({ const modelPickerOrder = config.modelPickerOrder ?? []; const multiAgentMode: MultiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default"; const exactComboSlugs = exactComboCatalogSlugs(config); - const suppressedBareNativeSlugs = desktopAllowlistSuppressedNativeSlugs(config); + const bareEligibleAccountIds = providerCodexAccountMode( + OPENAI_CODEX_PROVIDER_ID, + config.providers[OPENAI_CODEX_PROVIDER_ID], + ) === "direct" ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined; + const availableBareGatedNativeSlugs = availableAccountGatedNativeModels( + modelEntitlements, + bareEligibleAccountIds, + ); + const availableAccountGatedNativeSlugs = availableAccountGatedNativeModels(modelEntitlements); + const availableBareNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => ( + !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableBareGatedNativeSlugs.has(slug) + )); + const availableAccountNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => ( + !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableAccountGatedNativeSlugs.has(slug) + )); + const unavailableGatedNativeSlugs = new Set([...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(slug => ( + !availableBareGatedNativeSlugs.has(slug) + ))); + const suppressedBareNativeSlugs = new Set([ + ...desktopAllowlistSuppressedNativeSlugs(config), + ...unavailableGatedNativeSlugs, + ]); const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE); const includeNativeOpenAi = shouldIncludeNativeOpenAi(config); const includeAccountBoundNativeOpenAi = shouldIncludeAccountBoundNativeOpenAi(config); @@ -1451,12 +1483,21 @@ function writeRetainedCatalogSync({ ...(onDiskCatalog?.models ?? []).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined), ]; - const accountNativeSlugs = accountSelectors.length > 0 - ? accountBoundNativeOpenAiSlugs(observedAccountNativeEntries) - : []; + const accountTargets = new Map(codexAccountNamespaceEntries(config)); const accountNativeSlugsBySelector = accountSelectors.length > 0 - ? accountBoundNativeOpenAiSlugsBySelector(config, observedAccountNativeEntries) + ? new Map([...accountBoundNativeOpenAiSlugsBySelector(config, observedAccountNativeEntries)].map(([selector, slugs]) => { + const target = accountTargets.get(selector); + const accountId = target && isMainCodexAccountTarget(target) ? MAIN_CODEX_ACCOUNT_ID : target; + const entitled = accountId ? modelEntitlements.modelsByAccount.get(accountId) : undefined; + const confirmed = accountId ? modelEntitlements.confirmedAccountIds.has(accountId) : false; + return [selector, slugs.filter(slug => ( + !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || (confirmed && entitled?.has(slug) === true) + ))] as const; + })) : new Map(); + const accountNativeSlugs = accountSelectors.length > 0 + ? [...new Set([...accountNativeSlugsBySelector.values()].flatMap(slugs => [...slugs]))] + : []; // Unknown account-native ids have no safe bare/global identity. They are only projected through // the selector map above; the no-selector catalog remains the static native/API-key surface. const observedNativeSlugs: string[] = []; @@ -1510,7 +1551,7 @@ function writeRetainedCatalogSync({ const accountBoundEntries = includeAccountBoundNativeOpenAi && accountSelectors.length > 0 ? buildCatalogEntriesFromObservedState({ template: template ? JSON.parse(JSON.stringify(template)) : null, - gptSlugs: NATIVE_OPENAI_MODELS, + gptSlugs: availableAccountNativeSlugs, goModels: [], featured, wsEnabled, @@ -1550,7 +1591,7 @@ function writeRetainedCatalogSync({ openaiContextCap, policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, - nativeBackfillSlugs: [...NATIVE_OPENAI_MODELS, ...observedNativeSlugs], + nativeBackfillSlugs: [...availableBareNativeSlugs, ...observedNativeSlugs], warningPolicy: "emit", }, }); @@ -1664,10 +1705,13 @@ export async function syncCatalogModels( evidence: retainedCatalogSyncEvidence(config, preflightRead.catalogPath, preflightRead.catalog), processEvidence: retainedCatalogProcessEvidence(), }; - const goModels = await gatherRoutedModels(config, { - comboOmissions, - providerModelOutcomes, - }); + const [goModels, modelEntitlements] = await Promise.all([ + gatherRoutedModels(config, { + comboOmissions, + providerModelOutcomes, + }), + resolveCodexModelEntitlements(config), + ]); const committed = withCatalogWriteSerialization(owningCodexHome, permit => { // Desired state can flip OFF during the provider await above. The catalog // evidence revalidation below cannot see that — intent lives in our config, @@ -1687,6 +1731,7 @@ export async function syncCatalogModels( } const current = revalidateRetainedCatalogSync(config, prepared); if (current === null) return null; + if (!isCodexModelEntitlementSnapshotCurrent(modelEntitlements)) return null; return writeRetainedCatalogSync({ config, goModels, @@ -1695,6 +1740,7 @@ export async function syncCatalogModels( read: current, permit, owningCodexHome, + modelEntitlements, }); }); if (committed.kind === "completed" && committed.value !== null) return committed.value; diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index 6d501ef85d..d5aeb893b7 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -66,6 +66,17 @@ import { supportedCodexReasoningEffortsFromObservedCatalog, } from "./catalog/effort"; import { codexRuntimeStatePath, peekCodexRuntimeProcessCache } from "./runtime"; +import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "./account-namespaces"; +import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; +import { + availableAccountGatedNativeModels, + isCodexModelEntitlementSnapshotCurrent, + resolveCodexModelEntitlements, + type CodexModelEntitlementSnapshot, +} from "./model-entitlements"; +import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models"; +import { providerCodexAccountMode } from "../providers/registry"; +import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; import { withCatalogWriteSerialization } from "./catalog-write-serialization"; import { publishHashedCodexCatalogBackup, @@ -95,7 +106,7 @@ export interface CatalogWriteReceipt { export type CodexCatalogCommitResult = | { readonly kind: "committed"; readonly changed: boolean; readonly writes: CatalogWriteReceipt } - | { readonly kind: "stale"; readonly reason: "generation" | "home-selection" | "source-observation" | "process-local" | "target-identity" | "candidate-consumed" } + | { readonly kind: "stale"; readonly reason: "generation" | "home-selection" | "source-observation" | "process-local" | "target-identity" | "candidate-consumed" | "account-entitlement" } | { readonly kind: "refused"; readonly reason: "source-unreadable" | "source-ambiguous" | "target-unsafe" } | { readonly kind: "failed"; readonly surface: "disk"; readonly writes: CatalogWriteReceipt }; @@ -122,6 +133,7 @@ interface CandidateState { readonly legacyBackup?: PreparedCatalogFileWrite; readonly changed: boolean; readonly notices: readonly CatalogNotice[]; + readonly modelEntitlements: CodexModelEntitlementSnapshot; } const candidateStates = new WeakMap(); @@ -217,6 +229,7 @@ function prepareCatalog( baseline: ReadonlyMap, baselineCatalogModels: readonly Readonly>[], degradedProviderNames: ReadonlySet, + modelEntitlements: CodexModelEntitlementSnapshot, nativeRecoverySources: readonly (readonly RawEntry[])[] = [], observedAccountNativeEntries: readonly RawEntry[] = [], ): RawCatalog { @@ -229,19 +242,46 @@ function prepareCatalog( const multiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default"; const exactComboSlugs = exactComboCatalogSlugs(config); - const suppressedBareNativeSlugs = desktopAllowlistSuppressedNativeSlugs(config); + const bareEligibleAccountIds = providerCodexAccountMode( + OPENAI_CODEX_PROVIDER_ID, + config.providers[OPENAI_CODEX_PROVIDER_ID], + ) === "direct" ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined; + const availableBareGatedNativeSlugs = availableAccountGatedNativeModels( + modelEntitlements, + bareEligibleAccountIds, + ); + const availableAccountGatedNativeSlugs = availableAccountGatedNativeModels(modelEntitlements); + const availableBareNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => ( + !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableBareGatedNativeSlugs.has(slug) + )); + const availableAccountNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => ( + !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableAccountGatedNativeSlugs.has(slug) + )); + const suppressedBareNativeSlugs = new Set([ + ...desktopAllowlistSuppressedNativeSlugs(config), + ...[...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(slug => !availableBareGatedNativeSlugs.has(slug)), + ]); const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE); const enabledProviders = Object.entries(config.providers).filter(([, provider]) => provider.disabled !== true); const includeNativeOpenAi = shouldIncludeNativeOpenAi(config); const accountSelectors = shouldIncludeAccountBoundNativeOpenAi(config) ? visibleCodexAccountSelectors(config) : []; - const accountNativeSlugs = accountSelectors.length > 0 - ? accountBoundNativeOpenAiSlugs(observedAccountNativeEntries) - : []; + const accountTargets = new Map(codexAccountNamespaceEntries(config)); const accountNativeSlugsBySelector = accountSelectors.length > 0 - ? accountBoundNativeOpenAiSlugsBySelector(config, observedAccountNativeEntries) + ? new Map([...accountBoundNativeOpenAiSlugsBySelector(config, observedAccountNativeEntries)].map(([selector, slugs]) => { + const target = accountTargets.get(selector); + const accountId = target && isMainCodexAccountTarget(target) ? MAIN_CODEX_ACCOUNT_ID : target; + const entitled = accountId ? modelEntitlements.modelsByAccount.get(accountId) : undefined; + const confirmed = accountId ? modelEntitlements.confirmedAccountIds.has(accountId) : false; + return [selector, slugs.filter(slug => ( + !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || (confirmed && entitled?.has(slug) === true) + ))] as const; + })) : new Map(); + const accountNativeSlugs = accountSelectors.length > 0 + ? [...new Set([...accountNativeSlugsBySelector.values()].flatMap(slugs => [...slugs]))] + : []; // Unknown account-native ids have no safe bare/global identity. They are only projected through // selector-qualified rows when a live selector is configured. const observedNativeSlugs: string[] = []; @@ -269,7 +309,7 @@ function prepareCatalog( ? [] : buildCatalogEntriesFromObservedState({ template: template ? JSON.parse(JSON.stringify(template)) : null, - gptSlugs: NATIVE_OPENAI_MODELS, + gptSlugs: availableAccountNativeSlugs, goModels: [], featured, wsEnabled: websocketsEnabled(config), @@ -314,7 +354,7 @@ function prepareCatalog( suppressedBareNativeSlugs, policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, - nativeBackfillSlugs: [...NATIVE_OPENAI_MODELS, ...observedNativeSlugs], + nativeBackfillSlugs: [...availableBareNativeSlugs, ...observedNativeSlugs], warningPolicy: "suppress", }, }); @@ -356,11 +396,14 @@ export async function gatherCodexCatalogCandidate( const providerModelOutcomes: CatalogGatherProviderModelOutcome[] = []; const discoveryPolicies: CatalogProviderDiscoveryPolicySnapshot[] = []; providerGatherStarted = true; - const routedModels = await gatherRoutedModelsForCatalogGather(snapshot.config, session, { - providerAuthOutcomes: authOutcomes, - providerModelOutcomes, - discoveryPolicySnapshots: discoveryPolicies, - }); + const [routedModels, modelEntitlements] = await Promise.all([ + gatherRoutedModelsForCatalogGather(snapshot.config, session, { + providerAuthOutcomes: authOutcomes, + providerModelOutcomes, + discoveryPolicySnapshots: discoveryPolicies, + }), + resolveCodexModelEntitlements(snapshot.config), + ]); const processLocal = processEvidence(source); const sourceEvidence = sealCatalogGatherEvidenceSession(session); if (!same(sourceEvidence.required, snapshot.sourceEvidence.required)) { @@ -399,6 +442,7 @@ export async function gatherCodexCatalogCandidate( new Set(providerModelOutcomes .filter(outcome => outcome.state === "degraded") .map(outcome => outcome.provider)), + modelEntitlements, [ catalogFrom(keyedBackupBytes)?.models ?? [], catalogFrom(legacyBackupBytes)?.models ?? [], @@ -452,6 +496,7 @@ export async function gatherCodexCatalogCandidate( changed: Buffer.from(activeBytes ?? []).toString("utf8") !== preparedCatalogBytes || Buffer.from(cacheBytes ?? []).toString("utf8") !== preparedCacheBytes, notices: Object.freeze([...notices]), + modelEntitlements, }); return { kind: "candidate", candidate }; } catch (error) { @@ -469,6 +514,9 @@ export async function gatherCodexCatalogCandidate( } function revalidateCandidate(state: CandidateState): CodexCatalogCommitResult | null { + if (!isCodexModelEntitlementSnapshotCurrent(state.modelEntitlements)) { + return { kind: "stale", reason: "account-entitlement" }; + } let session: CatalogFilesystemEvidenceSession; let validatingTargets = false; try { diff --git a/src/codex/model-entitlements.ts b/src/codex/model-entitlements.ts new file mode 100644 index 0000000000..d06100138b --- /dev/null +++ b/src/codex/model-entitlements.ts @@ -0,0 +1,353 @@ +import { createHash } from "node:crypto"; +import { readBoundedResponseBody } from "../lib/bounded-body"; +import type { OcxConfig } from "../types"; +import { isSelectableCodexPoolAccount } from "./account-id"; +import { getValidCodexToken, readCodexAccountRecord } from "./account-store"; +import { getMainAccountToken, MAIN_CODEX_ACCOUNT_ID } from "./main-account"; +import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models"; + +const CODEX_MODELS_URL = "https://chatgpt.com/backend-api/codex/models?client_version=0.0.0"; +const MODEL_ROSTER_TTL_MS = 5 * 60_000; +const MODEL_ROSTER_FAILURE_TTL_MS = 15_000; +const MODEL_ROSTER_TIMEOUT_MS = 8_000; +const MODEL_ROSTER_MAX_BYTES = 2 * 1024 * 1024; +const MODEL_ROSTER_CACHE_MAX = 64; +const DIRECT_CALLER_ACCOUNT_PREFIX = "__direct_codex__:"; + +export interface CodexModelEntitlementCredentialSnapshot { + readonly accountId: string; + readonly accessToken: string; + readonly chatgptAccountId: string; + /** Stable local identity for rejecting a catalog commit after credential replacement. */ + readonly credentialIdentity: string; +} + +interface CachedAccountModels { + readonly credentialIdentity: string; + readonly expiresAt: number; + readonly models: ReadonlySet; + readonly confirmed: boolean; +} + +export interface CodexModelEntitlementSnapshot { + readonly modelsByAccount: ReadonlyMap>; + readonly confirmedAccountIds: ReadonlySet; + readonly credentialIdentities: ReadonlyMap; +} + +export interface CodexModelEntitlementResolveOptions { + readonly fetcher?: typeof fetch; + readonly now?: number; + /** Test-only credential seam; production callers enumerate local main + Pool credentials. */ + readonly credentials?: readonly CodexModelEntitlementCredentialSnapshot[]; +} + +const accountModelsCache = new Map(); +const accountModelsFlights = new Map>(); + +/** + * Direct-caller entries are evicted separately from main/Pool entries. + * + * Direct keys are per-credential (`__direct_codex__:`) and unbounded in practice, while + * main/Pool keys are the evidence the CATALOG projects from. Sharing one LRU let 64 distinct + * Direct callers evict `__main__` and the Pool accounts, which makes the gated row vanish from + * the catalog until rediscovery — fail-closed flapping rather than a leak, but still a visible + * model disappearing for a reason the operator cannot see. Two budgets keep one class of caller + * from erasing the other's evidence. + */ +function boundedCacheSet(accountId: string, value: CachedAccountModels): void { + accountModelsCache.delete(accountId); + accountModelsCache.set(accountId, value); + const isDirect = (key: string): boolean => key.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX); + const evictClass = (direct: boolean): void => { + let count = 0; + for (const key of accountModelsCache.keys()) if (isDirect(key) === direct) count += 1; + while (count > MODEL_ROSTER_CACHE_MAX) { + let oldest: string | undefined; + for (const key of accountModelsCache.keys()) { + if (isDirect(key) === direct) { oldest = key; break; } + } + if (oldest === undefined) break; + accountModelsCache.delete(oldest); + count -= 1; + } + }; + evictClass(isDirect(accountId)); +} + +function currentCredentialIdentity(accountId: string): string | undefined { + if (accountId.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX)) { + return `direct:${accountId.slice(DIRECT_CALLER_ACCOUNT_PREFIX.length)}`; + } + if (accountId === MAIN_CODEX_ACCOUNT_ID) { + const token = getMainAccountToken(); + return token ? `main:${token.chatgptAccountId}` : undefined; + } + const record = readCodexAccountRecord(accountId); + if (!record?.credential || record.deletedAt != null) return undefined; + return `pool:${record.generation}:${record.credential.chatgptAccountId}`; +} + +async function accountCredentialSnapshot(accountId: string): Promise { + if (accountId === MAIN_CODEX_ACCOUNT_ID) { + const token = getMainAccountToken(); + return token + ? { + accountId, + accessToken: token.accessToken, + chatgptAccountId: token.chatgptAccountId, + credentialIdentity: `main:${token.chatgptAccountId}`, + } + : null; + } + try { + const token = await getValidCodexToken(accountId); + return { + accountId, + accessToken: token.accessToken, + chatgptAccountId: token.chatgptAccountId, + credentialIdentity: `pool:${token.generation}:${token.chatgptAccountId}`, + }; + } catch { + return null; + } +} + +function parseAccountModels(text: string): ReadonlySet | null { + try { + const payload = JSON.parse(text) as { models?: unknown }; + if (!Array.isArray(payload.models)) return null; + const models = payload.models.flatMap(entry => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return []; + const row = entry as { slug?: unknown; supported_in_api?: unknown; visibility?: unknown }; + if (typeof row.slug !== "string" || row.supported_in_api !== true || row.visibility === "hide") return []; + return [row.slug]; + }); + return new Set(models); + } catch { + return null; + } +} + +async function fetchAccountModels( + credential: CodexModelEntitlementCredentialSnapshot, + fetcher: typeof fetch, + now: number, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(new DOMException("Codex model discovery timed out", "TimeoutError")), MODEL_ROSTER_TIMEOUT_MS); + try { + const headers = new Headers({ + Authorization: `Bearer ${credential.accessToken}`, + Accept: "application/json", + }); + if (credential.chatgptAccountId) headers.set("ChatGPT-Account-Id", credential.chatgptAccountId); + const response = await fetcher(CODEX_MODELS_URL, { + headers, + redirect: "error", + signal: controller.signal, + }); + const body = await readBoundedResponseBody(response, { + signal: controller.signal, + maxBytes: MODEL_ROSTER_MAX_BYTES, + fatalUtf8: true, + }); + const models = response.ok && body.displaySafe && !body.truncated + ? parseAccountModels(body.text) + : null; + return { + credentialIdentity: credential.credentialIdentity, + expiresAt: now + (models ? MODEL_ROSTER_TTL_MS : MODEL_ROSTER_FAILURE_TTL_MS), + models: models ?? new Set(), + confirmed: models !== null, + }; + } catch { + return { + credentialIdentity: credential.credentialIdentity, + expiresAt: now + MODEL_ROSTER_FAILURE_TTL_MS, + models: new Set(), + confirmed: false, + }; + } finally { + clearTimeout(timer); + } +} + +function directCallerCredential(headers: Headers): CodexModelEntitlementCredentialSnapshot | null { + const match = /^Bearer\s+(\S+)$/i.exec(headers.get("authorization")?.trim() ?? ""); + if (!match) return null; + const accessToken = match[1]!; + const chatgptAccountId = headers.get("chatgpt-account-id")?.trim() ?? ""; + const fingerprint = createHash("sha256") + .update(accessToken) + .update("\0") + .update(chatgptAccountId) + .digest("hex"); + return { + accountId: `${DIRECT_CALLER_ACCOUNT_PREFIX}${fingerprint}`, + accessToken, + chatgptAccountId, + credentialIdentity: `direct:${fingerprint}`, + }; +} + +async function modelsForCredential( + credential: CodexModelEntitlementCredentialSnapshot, + fetcher: typeof fetch, + now: number, +): Promise { + const cached = accountModelsCache.get(credential.accountId); + if ( + cached + && cached.credentialIdentity === credential.credentialIdentity + && cached.expiresAt > now + ) return cached; + + const flightKey = `${credential.accountId}\u0000${credential.credentialIdentity}`; + const existing = accountModelsFlights.get(flightKey); + if (existing) return existing; + const flight = fetchAccountModels(credential, fetcher, now) + .then(result => { + if (currentCredentialIdentity(credential.accountId) === credential.credentialIdentity) { + boundedCacheSet(credential.accountId, result); + } + return result; + }) + .finally(() => { + if (accountModelsFlights.get(flightKey) === flight) accountModelsFlights.delete(flightKey); + }); + accountModelsFlights.set(flightKey, flight); + return flight; +} + +function candidateAccountIds(config: Pick): string[] { + return [ + MAIN_CODEX_ACCOUNT_ID, + ...(config.codexAccounts ?? []) + .filter(isSelectableCodexPoolAccount) + .map(account => account.id), + ]; +} + +/** + * Fetch the authenticated model roster for every locally usable Codex account. + * + * [Decision Log] + * - 목적과 의도: Account-gated native models must be advertised and selected only for accounts + * whose own authenticated upstream catalog confirms the model. + * - 기존 구현 및 제약 조건: The injected Codex catalog is static, while Pool may contain + * accounts with different entitlements. A global allowlist therefore exposed unusable rows. + * - 검토한 주요 대안: Infer access from plan labels, learn only after a failed prompt, or rewrite + * Daybreak to its current physical model. + * - 선택한 방식: Cache bounded authenticated `/models` rosters per credential generation and + * fail closed for unconfirmed accounts. + * - 다른 대안 대신 이 방식을 선택한 이유: Plan names do not prove grants, post-failure + * learning spends a real turn, and model rewriting changes the requested product identity. + * - 장점, 단점 및 영향: Catalog and routing share exact account evidence. Cold gated requests + * pay one bounded discovery call per account; discovery failure temporarily hides the gated row. + */ +export async function resolveCodexModelEntitlements( + config: Pick, + options: CodexModelEntitlementResolveOptions = {}, +): Promise { + const now = options.now ?? Date.now(); + const fetcher = options.fetcher ?? fetch; + const credentials = options.credentials + ? [...options.credentials] + : (await Promise.all(candidateAccountIds(config).map(accountCredentialSnapshot))) + .filter((value): value is CodexModelEntitlementCredentialSnapshot => value !== null); + const results = await Promise.all(credentials.map(async credential => ({ + credential, + result: await modelsForCredential(credential, fetcher, now), + }))); + return { + modelsByAccount: new Map(results.map(({ credential, result }) => [credential.accountId, result.models])), + confirmedAccountIds: new Set(results.flatMap(({ credential, result }) => result.confirmed ? [credential.accountId] : [])), + credentialIdentities: new Map(results.map(({ credential }) => [credential.accountId, credential.credentialIdentity])), + }; +} + +/** Fail-closed entitlement check for a Direct request's own forwarded ChatGPT credential. */ +export async function isDirectCallerEntitledToCodexModel( + headers: Headers, + modelId: string, + options: Pick = {}, +): Promise { + if (!ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(modelId)) return true; + const credential = directCallerCredential(headers); + if (!credential) return false; + const result = await modelsForCredential( + credential, + options.fetcher ?? fetch, + options.now ?? Date.now(), + ); + return result.confirmed && result.models.has(modelId); +} + +export function entitledCodexAccountIdsForModel( + snapshot: CodexModelEntitlementSnapshot, + modelId: string | undefined, +): ReadonlySet | undefined { + if (!modelId || !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(modelId)) return undefined; + return new Set([...snapshot.modelsByAccount].flatMap(([accountId, models]) => ( + snapshot.confirmedAccountIds.has(accountId) && models.has(modelId) ? [accountId] : [] + ))); +} + +export function availableAccountGatedNativeModels( + snapshot: CodexModelEntitlementSnapshot, + eligibleAccountIds?: ReadonlySet, +): ReadonlySet { + return new Set([...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(modelId => ( + [...snapshot.modelsByAccount].some(([accountId, models]) => ( + (!eligibleAccountIds || eligibleAccountIds.has(accountId)) + && snapshot.confirmedAccountIds.has(accountId) + && models.has(modelId) + )) + ))); +} + +/** Synchronous projection for management/catalog readers after a discovery pass. */ +export function cachedAvailableAccountGatedNativeModels( + now = Date.now(), + eligibleAccountIds?: ReadonlySet, +): ReadonlySet { + return new Set([...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(modelId => ( + [...accountModelsCache].some(([accountId, entry]) => ( + (!eligibleAccountIds || eligibleAccountIds.has(accountId)) + && !accountId.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX) + && entry.confirmed + && entry.expiresAt > now + && entry.models.has(modelId) + )) + ))); +} + +export function isCodexModelEntitlementSnapshotCurrent(snapshot: CodexModelEntitlementSnapshot): boolean { + for (const [accountId, identity] of snapshot.credentialIdentities) { + if (currentCredentialIdentity(accountId) !== identity) return false; + } + return true; +} + +export function invalidateCodexModelEntitlementsForAccount(accountId: string | null | undefined): void { + if (accountId) accountModelsCache.delete(accountId); +} + +export function resetCodexModelEntitlementCacheForTests(): void { + accountModelsCache.clear(); + accountModelsFlights.clear(); +} + +export function seedCodexModelEntitlementsForTests( + accountId: string, + models: readonly string[], + now = Date.now(), +): void { + boundedCacheSet(accountId, { + credentialIdentity: `test:${accountId}`, + expiresAt: now + MODEL_ROSTER_TTL_MS, + models: new Set(models), + confirmed: true, + }); +} diff --git a/src/server/index.ts b/src/server/index.ts index b33e3f1750..70433d845c 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -58,6 +58,12 @@ import { cooldownErrorMessage, } from "../codex/auth-context"; import { codexAccountNamespaceForModel } from "../codex/account-namespace-match"; +import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../codex/account-namespaces"; +import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account"; +import { + availableAccountGatedNativeModels, + resolveCodexModelEntitlements, +} from "../codex/model-entitlements"; export { clearThreadAccountMap, formatCodexProviderForLog, @@ -899,8 +905,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server ( + !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableBareGatedNativeSlugs.has(slug) + )); + const availableAccountNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => ( + !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableAccountGatedNativeSlugs.has(slug) + )); const nativeSlugs = includeNativeOpenAi - ? nativeOpenAiSlugs() + ? nativeOpenAiSlugs().filter(slug => ( + !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableBareGatedNativeSlugs.has(slug) + )) : []; const disabledNatives = disabledNativeSlugs(config); const disabledModels = new Set(config.disabledModels ?? []); const shadowedNativeSlugs = configuredNativeAliasSlugs(config); - const suppressedBareNativeSlugs = desktopAllowlistSuppressedNativeSlugs(config); + const suppressedBareNativeSlugs = new Set([ + ...desktopAllowlistSuppressedNativeSlugs(config), + ...[...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(slug => !availableBareGatedNativeSlugs.has(slug)), + ]); const accountSelectors = includeAccountBoundNativeOpenAi ? visibleCodexAccountSelectors(config) : []; + const accountTargets = new Map(codexAccountNamespaceEntries(config)); const accountNativeSlugsBySelector = includeAccountBoundNativeOpenAi - ? accountBoundNativeOpenAiSlugsBySelector(config) + ? new Map([...accountBoundNativeOpenAiSlugsBySelector(config)].map(([selector, slugs]) => { + const target = accountTargets.get(selector); + const accountId = target && isMainCodexAccountTarget(target) ? MAIN_CODEX_ACCOUNT_ID : target; + const entitled = accountId ? modelEntitlements.modelsByAccount.get(accountId) : undefined; + const confirmed = accountId ? modelEntitlements.confirmedAccountIds.has(accountId) : false; + return [selector, slugs.filter(slug => ( + !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || (confirmed && entitled?.has(slug) === true) + ))] as const; + })) : new Map(); const accountNativeSlugs = [...new Set( [...accountNativeSlugsBySelector.values()].flatMap(slugs => [...slugs]), )]; + const desktopNativeSlugs = desktopVisibleNativeSlugs(config).filter(slug => ( + !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableBareGatedNativeSlugs.has(slug) + )); const goEnabled = filterCatalogVisibleModels(goModels, config); const goOrdered = orderForSubagents(goEnabled, config.subagentModels); // Claude Code / Claude Desktop gateway model discovery (GET /v1/models with @@ -945,7 +988,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow })), config.claudeCode?.desktopProfile, ); @@ -962,7 +1005,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server 0 - ? [...new Set([...NATIVE_OPENAI_MODELS, ...accountNativeSlugs])] + ? [...new Set([ + ...availableAccountNativeSlugs, + ...accountNativeSlugs, + ])] : nativeSlugs; const entries = buildCatalogEntries( loadCatalogTemplate(), @@ -1043,7 +1089,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server 0 - ? NATIVE_OPENAI_MODELS.filter(slug => !disabledNatives.has(slug)) + ? availableBareNativeSlugs.filter(slug => !disabledNatives.has(slug)) : []; const bareSelectorNativeSlugs = accountSelectors.length > 0 ? selectorNativeSlugs diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index ea6813f599..adc9415ec6 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -126,6 +126,7 @@ import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/cat import { codexAuthContextLogLabel } from "../../codex/account-label"; import { + codexAccountGatedCanonicalWireModel, decodeRequestErrorResponse, handleResponses, preAuthUpstreamHostCircuitKey, @@ -304,6 +305,12 @@ export async function handleResponsesCompact( return formatErrorResponse(404, "invalid_request_error", err instanceof Error ? err.message : String(err)); } const selectedModelId = route.modelId; + // Derive from the RESOLVED route model, not the caller's raw string. An account-qualified + // selector like `side/gpt-daybreak-blue-latest` does not match the gated map — `slugsEquivalent` + // reads the account namespace as a routed provider prefix — so keying on `raw.model` sent + // exactly the selector form back down the native compact endpoint this guard exists to avoid. + // `route.modelId` is the same value `applyCodexAccountGatedWireNormalization` uses in core.ts. + const accountGatedCompactWireModel = codexAccountGatedCanonicalWireModel(selectedModelId); logCtx.requestedModel = raw.model; logCtx.model = selectedModelId; logCtx.routeDecision = route.routeDecision; @@ -337,7 +344,7 @@ export async function handleResponsesCompact( // Native /responses/compact exists on the canonical ChatGPT backend and on the // official OpenAI API. Any other Responses-shaped gateway must take the routed // summarizer path below, or compaction fails against an endpoint it never had (#422). - if (supportsNativeResponsesCompactEndpoint(route.providerName, route.provider)) { + if (supportsNativeResponsesCompactEndpoint(route.providerName, route.provider) && !accountGatedCompactWireModel) { if (req.signal.aborted) { return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request"); } @@ -371,6 +378,7 @@ export async function handleResponsesCompact( authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, { accountId: route.codexAccountId, modelId: selectedModelId, + substituteMainCredentialForDirect: substituteMainCredential, beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease), }); logCtx.accountLogLabel = codexAuthContextLogLabel(authCtx, config); @@ -668,7 +676,10 @@ export async function handleResponsesCompact( const inputItems = Array.isArray(raw.input) ? (raw.input as unknown[]) : []; const internalBody = { ...raw, - stream: false, + // Canonical ChatGPT Responses rejects non-streaming turns. Daybreak cannot use the + // native compact endpoint either, so run its synthetic compaction as SSE and collapse + // the completed event back into the v1 compact JSON contract below. + stream: accountGatedCompactWireModel ? true : false, input: [...inputItems, { type: "compaction_trigger" }], }; const internalHeaders = new Headers({ "content-type": "application/json" }); @@ -684,10 +695,36 @@ export async function handleResponsesCompact( const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, ...(admission ? { admission } : {}) }); if (!response.ok) return response; let json: { output?: unknown[]; status?: unknown; error?: unknown }; - try { - json = await response.json() as { output?: unknown[]; status?: unknown; error?: unknown }; - } catch { - return formatErrorResponse(502, "server_error", "compaction turn returned a non-JSON response"); + if (response.headers.get("content-type")?.includes("text/event-stream")) { + if (!response.body) { + return formatErrorResponse(502, "server_error", "compaction turn returned an empty event stream"); + } + const terminal = { status: "incomplete" as "completed" | "failed" | "incomplete" }; + let completed: { id?: unknown; output?: unknown; status?: unknown } | undefined; + await new Promise(resolve => { + consumeForInspection( + response.body!, + status => { terminal.status = status; }, + req.signal, + resolve, + undefined, + undefined, + value => { completed = value; }, + ); + }); + if (req.signal.aborted) { + return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request"); + } + if (terminal.status !== "completed" || !completed) { + return formatErrorResponse(502, "upstream_error", `compaction turn did not complete (status: ${terminal.status})`); + } + json = completed as { output?: unknown[]; status?: unknown; error?: unknown }; + } else { + try { + json = await response.json() as { output?: unknown[]; status?: unknown; error?: unknown }; + } catch { + return formatErrorResponse(502, "server_error", "compaction turn returned a non-JSON response"); + } } // The internal turn answers 200 even when it failed or was truncated, so the body // has to be inspected. Reporting a failure beats installing "(no summary @@ -716,6 +753,13 @@ export async function handleResponsesCompact( `compaction turn produced ${compactionItems.length} compaction items, expected exactly 1`, ); } + // The canonical Responses stream returns a real OpenAI-encrypted compaction item. OCX cannot + // and should not decrypt it; /responses/compact callers can consume that item directly. + if (accountGatedCompactWireModel) { + return new Response(JSON.stringify({ output: compactionItems }), { + headers: { "Content-Type": "application/json" }, + }); + } const encrypted = compactionItems[0]!.encrypted_content; const decoded = typeof encrypted === "string" ? decodeCompactionSummary(encrypted) : null; // An empty `ocx1:` envelope decodes to "" rather than null, so length is what matters. diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 90fac70755..cd3073276d 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -109,6 +109,12 @@ import { stripCodexRuntimeProviderFields, type CodexAuthContext, } from "../../codex/auth-context"; +import { + entitledCodexAccountIdsForModel, + invalidateCodexModelEntitlementsForAccount, + resolveCodexModelEntitlements, +} from "../../codex/model-entitlements"; +import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../../codex/catalog/native-models"; import { computeQuotaCooldown, formatCodexProviderForLog, @@ -530,6 +536,39 @@ type CodexPoolAccountRetryResult = authCtx: Extract; }; +const CODEX_ACCOUNT_GATED_CANONICAL_WIRE_MODELS: ReadonlyMap = new Map([ + // The authenticated catalog currently advertises Daybreak Blue, while successful responses + // identify the serving model as gpt-5.6-sol. Sending the selector itself is shard-dependent: + // live traffic can receive the exact unsupported-model 400 repeatedly from the same entitled + // account. Keep Daybreak as the admission/catalog identity, but use the stable serving id on + // the credential-bearing wire after entitlement selection has completed. + ["gpt-daybreak-blue-latest", "gpt-5.6-sol"], +]); + +export function codexAccountGatedCanonicalWireModel(modelId: string): string | undefined { + const exact = CODEX_ACCOUNT_GATED_CANONICAL_WIRE_MODELS.get(modelId); + if (exact) return exact; + for (const [selector, wireModel] of CODEX_ACCOUNT_GATED_CANONICAL_WIRE_MODELS) { + if (slugsEquivalent(modelId, selector)) return wireModel; + } + return undefined; +} + +function applyCodexAccountGatedWireNormalization(parsed: OcxParsedRequest, route: RouteResult): void { + if (!isCanonicalOpenAiForwardProvider(route.provider)) return; + const wireModel = codexAccountGatedCanonicalWireModel(route.modelId); + if (!wireModel) return; + + parsed.modelId = wireModel; + if (!parsed._rawBody || typeof parsed._rawBody !== "object") return; + const raw = parsed._rawBody as Record; + raw.model = wireModel; + // Daybreak's authenticated catalog does not advertise retention support, and the upstream + // rejects this optional Codex hint before model execution. Removing it preserves request + // semantics while avoiding an otherwise terminal pre-stream 400. + delete raw.prompt_cache_retention; +} + /** * Workspace-denial evidence for a 403, read from the upstream body. * @@ -580,22 +619,32 @@ async function retryCodexPoolOnAlternateAccount( req, config, route, parsed, logCtx, options, firstAuthCtx, firstResponse, outcomeStatus, upstream, connectMs, passthroughEstimate, stream, } = args; - // Defense in depth: exact account selectors must never reach alternate-account resolution, - // even if a future caller forgets to guard this helper. - if (firstAuthCtx.fixedAccount) return { kind: "no-alternate" }; const inboundWire = options.inboundWire ?? "responses"; let retryAuthCtx: CodexAuthContext | undefined; + if (outcomeStatus === 400 && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(route.modelId)) { + invalidateCodexModelEntitlementsForAccount(firstAuthCtx.accountId); + const refreshed = await resolveCodexModelEntitlements(config); + if (entitledCodexAccountIdsForModel(refreshed, route.modelId)?.has(firstAuthCtx.accountId)) { + // The authenticated roster still grants this exact model. Retry on the same account: + // upstream shards can briefly disagree during a gated-model rollout, but a pre-stream 400 + // proves no output was committed and keeps this replay bounded. + retryAuthCtx = firstAuthCtx; + } + } + // Exact account selectors may retry the same confirmed account above, but must never resolve + // an alternate. Quota failures and a refreshed entitlement miss remain terminal. + if (!retryAuthCtx && firstAuthCtx.fixedAccount) return { kind: "no-alternate" }; try { - retryAuthCtx = await resolveCodexAuthContext( - req.headers, - config, - "pool", - { - excludeAccountId: firstAuthCtx.accountId, - modelId: route.modelId, - beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease), - }, - ); + retryAuthCtx ??= await resolveCodexAuthContext( + req.headers, + config, + "pool", + { + excludeAccountId: firstAuthCtx.accountId, + modelId: route.modelId, + beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease), + }, + ); } catch (error) { if ( !(error instanceof CodexPoolAuthenticationError) @@ -677,27 +726,50 @@ async function retryCodexPoolOnAlternateAccount( logCtx.accountLogLabel, ); - noteAttemptSend(logCtx.activeAttempt, passthroughEstimate); + const retrySameConfirmedAccount = outcomeStatus === 400 + && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(route.modelId) + && retryAuthCtx.accountId === firstAuthCtx.accountId; + // Live Daybreak traffic has produced long runs of unsupported-model 400s from different + // upstream shards even while the authenticated roster continues to grant the model. Permit + // seven additional same-account sends (eight total including the original), re-checking the + // exact allow-listed body and fresh entitlement before every later send. Alternate-account and + // quota recovery retain their historical one-send bound. + const maxRetrySends = retrySameConfirmedAccount ? 7 : 1; + let retrySendCount = 0; let upstreamResponse: Response; try { - upstreamResponse = await fetchWithHeaderTimeout( - request.url, - { - method: request.method, - headers: request.headers, - body: request.body, - }, - upstream.signal, - connectMs, - stream, - providerFetch(route.provider, options.codexWsRuntimeIdentity, { - providerName: route.providerName, - modelId: route.modelId, - }), - // Credential-bearing forward send: never follow a redirect into a - // dead-host rejection after the credential was seen (#914). - route.provider.authMode === "forward", - ); + while (true) { + noteAttemptSend(logCtx.activeAttempt, passthroughEstimate); + upstreamResponse = await fetchWithHeaderTimeout( + request.url, + { + method: request.method, + headers: request.headers, + body: request.body, + }, + upstream.signal, + connectMs, + stream, + providerFetch(route.provider, options.codexWsRuntimeIdentity, { + providerName: route.providerName, + modelId: route.modelId, + }), + // Credential-bearing forward send: never follow a redirect into a + // dead-host rejection after the credential was seen (#914). + route.provider.authMode === "forward", + ); + retrySendCount += 1; + if (!retrySameConfirmedAccount || retrySendCount >= maxRetrySends) break; + if (!await shouldRetryCodexPoolAccountModel400( + upstreamResponse, + route.modelId, + options.abortSignal, + )) break; + invalidateCodexModelEntitlementsForAccount(retryAuthCtx.accountId); + const refreshed = await resolveCodexModelEntitlements(config); + if (!entitledCodexAccountIdsForModel(refreshed, route.modelId)?.has(retryAuthCtx.accountId)) break; + await upstreamResponse.body?.cancel().catch(() => undefined); + } } catch (error) { // Attribute the transport failure to the alternate account (already selected). return { kind: "transport", error, authCtx: retryAuthCtx }; @@ -1102,6 +1174,7 @@ async function resolveResponsesCodexAuth( authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, { accountId: route.codexAccountId, modelId: route.modelId, + substituteMainCredentialForDirect: substituteMainCredential, beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease), }); options.onCodexAuthContextResolved?.(authCtx); @@ -2133,6 +2206,7 @@ async function handleResponsesInner( } route.provider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode); + applyCodexAccountGatedWireNormalization(parsed, route); logCtx.provider = route.codexAccountNamespace ? `${route.providerName}-${route.codexAccountNamespace}` : formatCodexProviderForLog(route.providerName, codexLogAccountId(authCtx), config); @@ -2692,7 +2766,7 @@ async function handleResponsesInner( } } - if (usesCodexForwardPoolAuth(authCtx, route.provider) && !authCtx.fixedAccount) { + if (usesCodexForwardPoolAuth(authCtx, route.provider)) { let poolRetryOutcome: number | undefined; if (await shouldRetryCodexPoolAccountModel400( upstreamResponse, @@ -2700,7 +2774,7 @@ async function handleResponsesInner( options.abortSignal, )) { poolRetryOutcome = 400; - } else if (shouldRetryCodexPoolAccountQuota(upstreamResponse)) { + } else if (!authCtx.fixedAccount && shouldRetryCodexPoolAccountQuota(upstreamResponse)) { // Pre-stream only: once SSE has begun, mid-stream quota stays terminal. poolRetryOutcome = upstreamResponse.status; } diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index 9f4b6cad33..49272b9f1c 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -44,6 +44,13 @@ are emitted only as selector-qualified rows whose account provenance matches. Th the bare native or API-key model list. This keeps account-scoped upstream ids such as `gpt-daybreak-blue-latest` callable without treating them as a static release allowlist. +Account-gated native ids are a stricter subset. Their authenticated ChatGPT `/models` roster is +cached per credential generation with a bounded timeout. A bare gated row is emitted only when at +least one confirmed eligible account reports it; a selector-qualified row is emitted only when the +mapped account reports it. A failed or malformed discovery is not positive evidence and therefore +hides the gated row until a later refresh. The same snapshot gates Pool selection, so the catalog +and runtime cannot disagree by advertising through one account and dispatching through another. + The app-server's model list comes from this shared catalog, not from patching the App. Codex Desktop may still apply its remote native-only allowlist after `model/list`; an explicitly configured combo `nativeAlias` is the bounded compatibility path. It replaces one supported bare native row with a diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index 49acd21758..a43325674b 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -115,6 +115,57 @@ preserving a stale one would block every later migration. `daybreak-blue-latest` are distinct wire surfaces. An observed native row follows the pinned Sol capability metadata, but routing strips only the account selector and keeps `gpt-daybreak-blue-latest` byte-for-byte; it never expands the bare list or substitutes Sol. +- Account-gated native rows use each account's authenticated Codex `/models` roster as the + availability authority. Pool selection excludes accounts whose confirmed roster omits the model; + selector rows are generated only for the mapped entitled account. The bare row uses any eligible + account in Pool mode but only main-account evidence in Direct mode; a Direct turn independently + checks the forwarded caller credential, or stored main when an admission bearer is substituted. + Discovery failures fail closed. If an + entitled account still receives the exact pre-stream unsupported-model 400, opencodex invalidates + that account's roster and permits at most seven additional same-account sends, re-confirming the + exact rejection and fresh grant before each later send; otherwise ordinary eligible-account + failover applies. + +- `gpt-daybreak-blue-latest` remains the catalog and entitlement identity, but the canonical + ChatGPT wire uses `gpt-5.6-sol`, the serving id reported by successful Daybreak responses. + Daybreak compaction uses the existing synthetic `/responses` compaction path instead of the + native `/responses/compact` endpoint, whose model support is selector-specific. The internal + turn stays streaming as required by the canonical ChatGPT backend, and OCX returns the opaque + encrypted compaction item without attempting to decrypt or re-encode it. + The optional `prompt_cache_retention` hint is removed on this route because Daybreak's + authenticated catalog does not advertise it and upstream rejects it before execution. + +[Decision Log] +- 목적과 의도: Preserve the account-gated Daybreak UX while avoiding shard-dependent selector + rejection and the unsupported prompt-cache retention parameter. +- 기존 구현 및 제약 조건: The authenticated roster grants Daybreak, but live successful + responses report `gpt-5.6-sol`; the selector can still fail eight consecutive times. +- 검토한 주요 대안: Increase retries indefinitely, hide Daybreak entirely, or canonicalize only + the credential-bearing wire model after entitlement selection. +- 선택한 방식: Keep Daybreak for visibility and account authorization, then send the stable + serving id and remove only the unsupported optional retention hint. +- 다른 대안 대신 이 방식을 선택한 이유: It keeps fail-closed entitlement checks and avoids + unbounded duplicate requests while preserving the user-facing model choice. +- 장점, 단점 및 영향: Requests become deterministic and cheaper; this relies on the serving id + observed from successful upstream responses and must be revisited if the roster exposes a + first-class wire id later. + +[Decision Log] +- 목적과 의도: Prevent account-gated native models from being shown or dispatched through a + ChatGPT account that upstream does not authorize. +- 기존 구현 및 제약 조건: A static global Daybreak row solved clean-install discovery for + entitled accounts, but Pool accounts can hold different grants and Codex's injected catalog does + not refresh itself. +- 검토한 주요 대안: Infer grants from plan labels, learn only from prompt failures, bind Daybreak + permanently to main, or rewrite the wire id to `gpt-5.6-sol`. +- 선택한 방식: Share bounded authenticated per-account model-roster evidence between catalog sync, + `/v1/models`, and Pool auth selection. +- 다른 대안 대신 이 방식을 선택한 이유: Plan labels and account position do not prove a grant; + failure-only learning wastes a turn; permanent main binding rejects valid secondary grants; wire + rewriting changes the requested product identity. +- 장점, 단점 및 영향: Entitled accounts retain clean-install discovery while unentitled accounts + never receive the gated dispatch. A cold gated request may pay one bounded roster fetch per + account, and an unavailable discovery temporarily hides the model rather than guessing. - The two GPT-5.6 surfaces advertise different windows on purpose. API rows use 1,050,000 context with 922,000 max input. Codex-login rows default to the live catalog 272,000 (auto-compact 244,800) and only rise to 922,000 / 829,800 when the user turns the 1M diff --git a/tests/claude-models-discovery.test.ts b/tests/claude-models-discovery.test.ts index ef47458a7f..19f58ac795 100644 --- a/tests/claude-models-discovery.test.ts +++ b/tests/claude-models-discovery.test.ts @@ -370,16 +370,33 @@ test("Codex discovery exposes the observed native as a selector row plus one glo opencodex_account_observed_native: true, }], }), "utf8"); + writeFileSync(join(isolatedCodexHome!.path, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-token", account_id: "main-account" }, + }), "utf8"); const { resetCatalogRuntimeStateForTests } = await import("../src/codex/catalog"); + const { resetCodexModelEntitlementCacheForTests } = await import("../src/codex/model-entitlements"); resetCatalogRuntimeStateForTests(); + resetCodexModelEntitlementCacheForTests(); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input, init) => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input : input.url); + if (url.hostname === "chatgpt.com" && url.pathname.endsWith("/models")) { + return Response.json({ models: [{ + slug: "gpt-daybreak-blue-latest", + supported_in_api: true, + visibility: "list", + }] }); + } + return originalFetch(input, init); + }) as typeof fetch; const server = startServer(0); try { const plain = await fetch(new URL("/v1/models", server.url)) .then(response => response.json()) as { data: Array<{ id: string }> }; expect(plain.data).toContainEqual(expect.objectContaining({ id: "team/gpt-daybreak-blue-latest" })); - // Daybreak is globally allowlisted (owner decision, devlog 260816_.../011), so the bare - // id is now discoverable too, exactly once. + // Main's authenticated roster confirmed Daybreak above, so the bare id is discoverable + // exactly once alongside the mapped selector row. expect(plain.data.filter(model => model.id === "gpt-daybreak-blue-latest")).toHaveLength(1); const managementUrl = new URL("http://localhost/api/models"); @@ -389,9 +406,8 @@ test("Codex discovery exposes the observed native as a selector row plus one glo config, ); const management = await managementResponse!.json() as Array<{ id: string; native?: boolean }>; - // Once Daybreak is globally allowlisted the management surface reports it under its - // GLOBAL bare identity rather than as an account-qualified discovery row - // (model-rows.ts:59 / metadata.ts:243). Exactly one row, and no selector duplicate. + // The confirmed main entitlement makes the management surface report the bare native row. + // Management rows intentionally use the bare identity rather than duplicating selector rows. expect(management).toContainEqual(expect.objectContaining({ id: "gpt-daybreak-blue-latest", native: true, @@ -417,6 +433,7 @@ test("Codex discovery exposes the observed native as a selector row plus one glo expect(anthropic.data.some(model => model.id === claudeCodeNativeAlias("gpt-daybreak-blue-latest"))).toBe(false); expect(anthropic.data.some(model => model.id === claudeCodeNativeAlias("team/gpt-daybreak-blue-latest"))).toBe(false); } finally { + globalThis.fetch = originalFetch; await server.stop(true); } }); diff --git a/tests/codex-auth-context.test.ts b/tests/codex-auth-context.test.ts index ade8606a40..b4e9f77d86 100644 --- a/tests/codex-auth-context.test.ts +++ b/tests/codex-auth-context.test.ts @@ -65,6 +65,7 @@ import { codexAccountSelectionForTurn, tryAdmitTurn, } from "../src/server/lifecycle"; +import type { CodexModelEntitlementSnapshot } from "../src/codex/model-entitlements"; let testDir: string; let previousOpencodexHome: string | undefined; @@ -342,6 +343,122 @@ describe("Codex auth context", () => { .rejects.toBeInstanceOf(CodexDirectAuthenticationError); }); + test("direct account-gated routing checks the caller credential, not local account state", async () => { + let callerChecks = 0; + let localDiscoveries = 0; + const headers = new Headers({ authorization: "Bearer caller", "chatgpt-account-id": "caller-account" }); + await expect(resolveCodexAuthContext(headers, config(), "direct", { + modelId: "gpt-daybreak-blue-latest", + isDirectCallerEntitledToCodexModel: async (received, modelId) => { + callerChecks += 1; + expect(received).toBe(headers); + expect(modelId).toBe("gpt-daybreak-blue-latest"); + return true; + }, + resolveCodexModelEntitlements: async () => { + localDiscoveries += 1; + throw new Error("must not inspect local accounts"); + }, + })).resolves.toEqual({ kind: "main", accountId: null }); + expect(callerChecks).toBe(1); + expect(localDiscoveries).toBe(0); + }); + + test("Direct admission-bearer substitution checks the stored main account grant", async () => { + const entitledMain: CodexModelEntitlementSnapshot = { + modelsByAccount: new Map([[MAIN_CODEX_ACCOUNT_ID, new Set(["gpt-daybreak-blue-latest"])]]), + confirmedAccountIds: new Set([MAIN_CODEX_ACCOUNT_ID]), + credentialIdentities: new Map(), + }; + let callerChecks = 0; + await expect(resolveCodexAuthContext( + new Headers({ authorization: "Bearer ocx-admission" }), + config(), + "direct", + { + modelId: "gpt-daybreak-blue-latest", + substituteMainCredentialForDirect: true, + resolveCodexModelEntitlements: async () => entitledMain, + isDirectCallerEntitledToCodexModel: async () => { + callerChecks += 1; + return false; + }, + }, + )).resolves.toEqual({ kind: "main", accountId: null }); + expect(callerChecks).toBe(0); + }); + + test("account-gated native routing skips an active account without the model grant", async () => { + const cfg = config(); + writeFileSync(join(testDir, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-token", account_id: "main-account" }, + })); + saveCodexAccountCredential("pool-a", { + accessToken: "pool-token", + refreshToken: "pool-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool-account", + }); + const entitlementSnapshot: CodexModelEntitlementSnapshot = { + modelsByAccount: new Map([ + [MAIN_CODEX_ACCOUNT_ID, new Set(["gpt-daybreak-blue-latest"])], + ["pool-a", new Set(["gpt-5.6-sol"])], + ]), + confirmedAccountIds: new Set([MAIN_CODEX_ACCOUNT_ID, "pool-a"]), + credentialIdentities: new Map(), + }; + + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { + modelId: "gpt-daybreak-blue-latest", + isMainAccountTokenLive: () => true, + getMainAccountToken: () => ({ accessToken: "main-token", chatgptAccountId: "main-account" }), + resolveCodexModelEntitlements: async () => entitlementSnapshot, + primeCodexPoolQuotas: async () => {}, + })).resolves.toMatchObject({ + kind: "main-pool", + accountId: MAIN_CODEX_ACCOUNT_ID, + }); + }); + + test("exact account-gated routing fails closed for an unentitled account", async () => { + const cfg = config(); + saveCodexAccountCredential("pool-a", { + accessToken: "pool-token", + refreshToken: "pool-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool-account", + }); + const entitlementSnapshot: CodexModelEntitlementSnapshot = { + modelsByAccount: new Map([["pool-a", new Set(["gpt-5.6-sol"])]]), + confirmedAccountIds: new Set(["pool-a"]), + credentialIdentities: new Map(), + }; + + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { + accountId: "pool-a", + modelId: "gpt-daybreak-blue-latest", + resolveCodexModelEntitlements: async () => entitlementSnapshot, + })).rejects.toThrow("Selected Codex account does not support this model"); + }); + + test("ordinary native models do not pay the entitlement discovery path", async () => { + saveCodexAccountCredential("pool-a", { + accessToken: "pool-token", + refreshToken: "pool-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool-account", + }); + let discoveries = 0; + await expect(resolveCodexAuthContext(new Headers(), config(), "pool", { + modelId: "gpt-5.6-sol", + resolveCodexModelEntitlements: async () => { + discoveries += 1; + throw new Error("must not run"); + }, + })).resolves.toMatchObject({ kind: "pool", accountId: "pool-a" }); + expect(discoveries).toBe(0); + }); + test("exact account resolution overrides Direct without consulting Pool selection", async () => { const cfg = config(); cfg.activeCodexAccountId = "pool-b"; diff --git a/tests/codex-catalog-sync-hardening.test.ts b/tests/codex-catalog-sync-hardening.test.ts index 0e65cdc814..29d84ff661 100644 --- a/tests/codex-catalog-sync-hardening.test.ts +++ b/tests/codex-catalog-sync-hardening.test.ts @@ -4,6 +4,7 @@ import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../src/codex/catalog/native-models"; const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); @@ -379,7 +380,7 @@ describe("Codex catalog sync hardening", () => { expect(JSON.stringify(rows)).not.toContain("Private Display Name"); }); - test("account sync preserves an observed account-only native id without creating a bare row", () => { + test("account sync preserves an observed gated native only after the mapped account confirms it", () => { const catalogPath = join(codexHome, "catalog.json"); writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); writeFileSync(catalogPath, JSON.stringify({ @@ -393,8 +394,22 @@ describe("Codex catalog sync hardening", () => { opencodex_account_observed_native: true, }], }, null, 2) + "\n"); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-token", account_id: "main-account" }, + }), "utf8"); const r = runScript(codexHome, opencodexHome, ` + globalThis.fetch = async input => { + const url = new URL(typeof input === "string" ? input : input.url); + if (url.hostname === "chatgpt.com" && url.pathname.endsWith("/models")) { + return Response.json({ models: [{ + slug: "gpt-daybreak-blue-latest", + supported_in_api: true, + visibility: "list" + }] }); + } + throw new Error("unexpected fetch"); + }; const { syncCatalogModels } = require("./src/codex/catalog"); syncCatalogModels({ providers: { @@ -419,8 +434,8 @@ describe("Codex catalog sync hardening", () => { use_responses_lite: true, supports_parallel_tool_calls: true, }); - // Daybreak is globally allowlisted now (owner decision, devlog 260816_.../011), - // so the bare row IS expected — exactly once — alongside the account-qualified row. + // Main's authenticated roster grants Daybreak, so Pool publishes one bare row alongside + // the exact selector row. The observed cache row alone is not entitlement evidence. expect(rows.filter(row => row.slug === "gpt-daybreak-blue-latest")).toHaveLength(1); }); @@ -471,9 +486,9 @@ describe("Codex catalog sync hardening", () => { opencodex_catalog_kind: "custom-model-v1", }); expect(daybreak?.base_instructions).toContain("powered by the gpt-daybreak-blue-latest"); - // The global native row exists (owner decision); the explicit Codex-forward custom row - // above is a separate identity and must not collapse into it. - expect(rows.filter(row => row.slug === "gpt-daybreak-blue-latest")).toHaveLength(1); + // The explicit custom row is independent of native account entitlement. With no confirmed + // account roster, the account-gated bare row stays absent instead of collapsing into it. + expect(rows.filter(row => row.slug === "gpt-daybreak-blue-latest")).toHaveLength(0); expect(rows.some(row => row.slug === "main/gpt-daybreak-blue-latest")).toBe(false); // The separately billed API-key alias must still never reach the Codex surface. expect(rows.some(row => row.slug === "openai-apikey/daybreak-blue-latest")).toBe(false); @@ -640,7 +655,9 @@ describe("Codex catalog sync hardening", () => { expect(r.status).toBe(0); const result = JSON.parse(r.stdout) as { picker: string[]; native: string[]; fallback: string[] }; expect(result.picker).toContain("gpt-5.3-codex-spark"); - expect(result.native).toEqual(result.fallback); + expect(result.native).toEqual( + result.fallback.filter(slug => !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug)), + ); }); test("account sync recovers supported natives that were hidden before selectors existed", () => { diff --git a/tests/codex-convergence-account-selectors.test.ts b/tests/codex-convergence-account-selectors.test.ts index eedd2616d4..48d15e6da7 100644 --- a/tests/codex-convergence-account-selectors.test.ts +++ b/tests/codex-convergence-account-selectors.test.ts @@ -44,6 +44,9 @@ import { } from "../src/codex/runtime"; import { markModelsFetchFailure } from "../src/codex/model-cache"; import { legacyCustomModelCatalogSlugs } from "../src/codex/custom-model-catalog-migration"; +import { resetCodexModelEntitlementCacheForTests } from "../src/codex/model-entitlements"; +import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../src/codex/catalog/native-models"; +import { saveCodexAccountCredential } from "../src/codex/account-store"; // The canonical-bytes case spawns real syncs and runs ~2.5s in isolation, on this // tree and on a clean baseline alike. That is half of bun's 5s default, but full @@ -223,6 +226,7 @@ function primeCodexRuntimeFixture(): void { process.env.CODEX_CLI_PATH = createCodexRuntimeFixture(); resetCatalogRuntimeStateForTests(); resetCodexRuntimeResolveCacheForTests(); + resetCodexModelEntitlementCacheForTests(); expect(loadBundledCodexCatalog()?.models?.[0]?.slug).toBe("gpt-5.5"); } @@ -263,6 +267,7 @@ beforeEach(() => { process.env.OPENCODEX_HOME = opencodexHome; resetCatalogRuntimeStateForTests(); resetCodexRuntimeResolveCacheForTests(); + resetCodexModelEntitlementCacheForTests(); }); afterEach(() => { @@ -357,6 +362,9 @@ test("convergence drops unsupported bare native rows and never qualifies them", test("convergence projects the observed Daybreak row onto its selector and one bare row", async () => { writeCatalog([nativeEntry()]); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-token", account_id: "main-chatgpt-account" }, + })); writeFileSync(join(codexHome, "models_cache.json"), JSON.stringify({ models: [{ slug: "gpt-daybreak-blue-latest", @@ -371,7 +379,24 @@ test("convergence projects the observed Daybreak row onto its selector and one b }], }, null, 2) + "\n"); - const catalog = await convergeCatalog(config(true)); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input, init) => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input : input.url); + if (url.hostname === "chatgpt.com" && url.pathname.endsWith("/models")) { + return Response.json({ models: [{ + slug: "gpt-daybreak-blue-latest", + supported_in_api: true, + visibility: "list", + }] }); + } + return originalFetch(input, init); + }) as typeof fetch; + let catalog: RawCatalog; + try { + catalog = await convergeCatalog(config(true)); + } finally { + globalThis.fetch = originalFetch; + } const models = catalog.models ?? []; const daybreak = models.find(entry => entry.slug === "desktop/gpt-daybreak-blue-latest"); expect(daybreak).toMatchObject({ @@ -388,14 +413,54 @@ test("convergence projects the observed Daybreak row onto its selector and one b }); expect((daybreak?.supported_reasoning_levels as Array<{ effort: string }>).map(level => level.effort)) .toEqual(["low", "medium", "high", "xhigh", "max", "ultra"]); - // Daybreak is globally allowlisted (owner decision, devlog 260816_.../011). A global - // native is seeded onto EVERY visible selector, not only the one that observed it, and the - // bare row now exists. Each must appear exactly once despite the observation also present. - expect(models.filter(entry => entry.slug === "team/gpt-daybreak-blue-latest")).toHaveLength(1); + // Main's authenticated roster confirms Daybreak, so the bare row and main selector exist. + // The side account has no confirmed grant and must not receive a selector row. + expect(models.filter(entry => entry.slug === "team/gpt-daybreak-blue-latest")).toHaveLength(0); expect(models.filter(entry => entry.slug === "desktop/gpt-daybreak-blue-latest")).toHaveLength(1); expect(models.filter(entry => entry.slug === "gpt-daybreak-blue-latest")).toHaveLength(1); }); +test("Direct convergence does not borrow a Pool-only Daybreak grant for the bare row", async () => { + writeCatalog([nativeEntry()]); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-token", account_id: "main-chatgpt-account" }, + })); + saveCodexAccountCredential("side-account-id", { + accessToken: "side-token", + refreshToken: "side-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "side-chatgpt-account", + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input, init) => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input : input.url); + if (url.hostname === "chatgpt.com" && url.pathname.endsWith("/models")) { + const accountId = new Headers(init?.headers).get("chatgpt-account-id"); + return Response.json({ models: [ + { slug: "gpt-5.6-sol", supported_in_api: true, visibility: "list" }, + ...(accountId === "side-chatgpt-account" + ? [{ slug: "gpt-daybreak-blue-latest", supported_in_api: true, visibility: "list" }] + : []), + ] }); + } + return originalFetch(input, init); + }) as typeof fetch; + const directConfig = config(true); + directConfig.providers.openai!.codexAccountMode = "direct"; + let catalog: RawCatalog; + try { + catalog = await convergeCatalog(directConfig); + } finally { + globalThis.fetch = originalFetch; + } + const models = catalog.models ?? []; + + expect(models.filter(entry => entry.slug === "gpt-daybreak-blue-latest")).toHaveLength(0); + expect(models.filter(entry => entry.slug === "desktop/gpt-daybreak-blue-latest")).toHaveLength(0); + expect(models.filter(entry => entry.slug === "team/gpt-daybreak-blue-latest")).toHaveLength(1); +}); + test("convergence preserves unrelated foreign rows alongside fresh configured provider rows", async () => { writeCatalog([ nativeEntry(), @@ -806,7 +871,10 @@ test("retained sync and convergence produce identical canonical bytes in either const models = (JSON.parse(bytes) as RawCatalog).models ?? []; const slugs = models ?.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []) ?? []; - for (const slug of NATIVE_OPENAI_MODELS) expect(slugs).toContain(slug); + for (const slug of NATIVE_OPENAI_MODELS) { + if (ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug)) expect(slugs).not.toContain(slug); + else expect(slugs).toContain(slug); + } expect(slugs).not.toContain("gpt-legacy-unsupported"); expect(slugs).toContain("user-native"); expect(models.find(entry => entry.slug === "gpt-5.6-sol")?.visibility) diff --git a/tests/codex-model-entitlements.test.ts b/tests/codex-model-entitlements.test.ts new file mode 100644 index 0000000000..85a55c703e --- /dev/null +++ b/tests/codex-model-entitlements.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { + availableAccountGatedNativeModels, + cachedAvailableAccountGatedNativeModels, + entitledCodexAccountIdsForModel, + isDirectCallerEntitledToCodexModel, + resetCodexModelEntitlementCacheForTests, + resolveCodexModelEntitlements, + cachedAvailableAccountGatedNativeModels, + seedCodexModelEntitlementsForTests, + seedCodexModelEntitlementsForTests, + type CodexModelEntitlementCredentialSnapshot, +} from "../src/codex/model-entitlements"; + +const DAYBREAK = "gpt-daybreak-blue-latest"; + +function credential(accountId: string): CodexModelEntitlementCredentialSnapshot { + return { + accountId, + accessToken: `token-${accountId}`, + chatgptAccountId: `chatgpt-${accountId}`, + credentialIdentity: `test:${accountId}`, + }; +} + +function roster(...slugs: string[]): Response { + return Response.json({ + models: slugs.map(slug => ({ slug, supported_in_api: true, visibility: "list" })), + }); +} + +beforeEach(() => resetCodexModelEntitlementCacheForTests()); + +describe("Codex account model entitlements", () => { + test("keeps account-gated models scoped to the authenticated account roster", async () => { + const snapshot = await resolveCodexModelEntitlements({ codexAccounts: [] }, { + credentials: [credential("main"), credential("secondary")], + fetcher: (async (_input, init) => { + const accountId = new Headers(init?.headers).get("chatgpt-account-id"); + return accountId === "chatgpt-main" + ? roster("gpt-5.6-sol", DAYBREAK) + : roster("gpt-5.6-sol"); + }) as typeof fetch, + now: 1_000, + }); + + expect([...entitledCodexAccountIdsForModel(snapshot, DAYBREAK)!]).toEqual(["main"]); + expect([...availableAccountGatedNativeModels(snapshot)]).toEqual([DAYBREAK]); + expect(entitledCodexAccountIdsForModel(snapshot, "gpt-5.6-sol")).toBeUndefined(); + }); + + test("fails closed when an account roster cannot be confirmed", async () => { + const snapshot = await resolveCodexModelEntitlements({ codexAccounts: [] }, { + credentials: [credential("broken")], + fetcher: (async () => new Response("not-json", { status: 502 })) as typeof fetch, + now: 1_000, + }); + + expect(snapshot.confirmedAccountIds.size).toBe(0); + expect(entitledCodexAccountIdsForModel(snapshot, DAYBREAK)?.size).toBe(0); + expect(availableAccountGatedNativeModels(snapshot).size).toBe(0); + }); + + test("ignores hidden or API-disabled rows", async () => { + const snapshot = await resolveCodexModelEntitlements({ codexAccounts: [] }, { + credentials: [credential("main")], + fetcher: (async () => Response.json({ models: [ + { slug: DAYBREAK, supported_in_api: true, visibility: "hide" }, + { slug: "gpt-disabled", supported_in_api: false, visibility: "list" }, + ] })) as typeof fetch, + now: 1_000, + }); + + expect(snapshot.confirmedAccountIds.has("main")).toBe(true); + expect(entitledCodexAccountIdsForModel(snapshot, DAYBREAK)?.size).toBe(0); + }); + + test("checks a Direct caller's own bearer instead of a local Pool account", async () => { + let seenAuthorization = ""; + let seenAccount = ""; + const entitled = await isDirectCallerEntitledToCodexModel( + new Headers({ + authorization: "Bearer caller-token", + "chatgpt-account-id": "caller-account", + }), + DAYBREAK, + { + fetcher: (async (_input, init) => { + const headers = new Headers(init?.headers); + seenAuthorization = headers.get("authorization") ?? ""; + seenAccount = headers.get("chatgpt-account-id") ?? ""; + return roster("gpt-5.6-sol", DAYBREAK); + }) as typeof fetch, + now: 1_000, + }, + ); + + expect(entitled).toBe(true); + expect(seenAuthorization).toBe("Bearer caller-token"); + expect(seenAccount).toBe("caller-account"); + }); + + test("Direct entitlement fails closed on an unconfirmed roster", async () => { + await expect(isDirectCallerEntitledToCodexModel( + new Headers({ authorization: "Bearer caller-token" }), + DAYBREAK, + { + fetcher: (async () => new Response("unavailable", { status: 503 })) as typeof fetch, + now: 1_000, + }, + )).resolves.toBe(false); + }); + + test("Direct-caller rosters do not evict main/Pool entitlement evidence", async () => { + // The catalog projects ONLY from main/Pool keys. Under a single shared LRU, a burst of + // distinct Direct callers pushed those out and the gated row vanished from the catalog until + // rediscovery — fail-closed flapping whose cause an operator cannot see. + seedCodexModelEntitlementsForTests("main", [DAYBREAK], 1_000); + expect([...cachedAvailableAccountGatedNativeModels(1_000)]).toContain(DAYBREAK); + + // Far more distinct Direct callers than the per-class cache bound of 64. + for (let i = 0; i < 80; i += 1) { + await isDirectCallerEntitledToCodexModel( + new Headers({ authorization: `Bearer caller-${i}` }), + DAYBREAK, + { fetcher: (async () => roster(DAYBREAK)) as typeof fetch, now: 1_000 }, + ); + } + + // With one shared 64-entry LRU this read came back empty. The main grant is a different + // eviction class and is still inside its TTL, so it must survive. + expect([...cachedAvailableAccountGatedNativeModels(1_000)]).toContain(DAYBREAK); + }); + + test("Direct-caller rosters do not evict main/Pool entitlement evidence", async () => { + // The catalog projects ONLY from main/Pool keys. Under a single shared LRU, a burst of + // distinct Direct callers pushed those out and the gated row vanished from the catalog + // until rediscovery — fail-closed flapping whose cause an operator cannot see. + seedCodexModelEntitlementsForTests("main", [DAYBREAK], 1_000); + expect([...cachedAvailableAccountGatedNativeModels(1_000)]).toContain(DAYBREAK); + + // Far more distinct Direct callers than the per-class cache bound of 64. + for (let i = 0; i < 80; i += 1) { + await isDirectCallerEntitledToCodexModel( + new Headers({ authorization: `Bearer caller-${i}` }), + DAYBREAK, + { fetcher: (async () => roster(DAYBREAK)) as typeof fetch, now: 1_000 }, + ); + } + + // With one shared 64-entry LRU this read came back empty. The main grant is a different + // eviction class and is still inside its TTL, so it must survive. + expect([...cachedAvailableAccountGatedNativeModels(1_000)]).toContain(DAYBREAK); + }); +}); diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index 08e0c4eb19..3dabda66c6 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; import { accountBoundNativeOpenAiSlugs, accountBoundNativeDisplayName, @@ -23,6 +23,13 @@ import { import { handleManagementAPI } from "../src/server/management-api"; import { applyMultiAgentMode, applyNativeOpenAiContextOverride } from "../src/codex/catalog/parsing"; import type { OcxConfig } from "../src/types"; +import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../src/codex/catalog/native-models"; +import { + resetCodexModelEntitlementCacheForTests, + seedCodexModelEntitlementsForTests, +} from "../src/codex/model-entitlements"; + +afterEach(() => resetCodexModelEntitlementCacheForTests()); function makeConfig(overrides: Partial = {}): OcxConfig { return { port: 10100, providers: {}, defaultProvider: "openai", ...overrides } as OcxConfig; @@ -63,13 +70,32 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(filtered.length).toBe(all.length - 1); }); - test("nativeModelRows lists the full static supported set regardless of disabled state", () => { + test("nativeModelRows hides account-gated ids until an authenticated roster confirms them", () => { const rows = nativeModelRows({ disabledModels: ["gpt-5.6-sol"] }); - expect(rows.map(r => r.slug)).toEqual([...NATIVE_OPENAI_MODELS]); + expect(rows.map(r => r.slug)).toEqual( + NATIVE_OPENAI_MODELS.filter(slug => !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug)), + ); expect(rows.find(r => r.slug === "gpt-5.6-sol")?.disabled).toBe(true); expect(rows.find(r => r.slug === "gpt-5.5")?.disabled).toBe(false); // Known context metadata rides along for the dashboard. expect(rows.find(r => r.slug === "gpt-5.6-sol")?.contextWindow).toBe(272_000); + + seedCodexModelEntitlementsForTests("main", ["gpt-daybreak-blue-latest"]); + expect(nativeModelRows({ disabledModels: [] }).map(row => row.slug)) + .toContain("gpt-daybreak-blue-latest"); + }); + + test("Direct bare rows use only main entitlement while Pool may use any eligible account", () => { + seedCodexModelEntitlementsForTests("pool-a", ["gpt-daybreak-blue-latest"]); + const direct = makeConfig({ + providers: { openai: { authMode: "forward", codexAccountMode: "direct" } }, + }); + const pool = makeConfig({ + providers: { openai: { authMode: "forward", codexAccountMode: "pool" } }, + }); + + expect(nativeModelRows(direct).map(row => row.slug)).not.toContain("gpt-daybreak-blue-latest"); + expect(nativeModelRows(pool).map(row => row.slug)).toContain("gpt-daybreak-blue-latest"); }); test("a per-model window sets the native row and never exceeds the measured ceiling", () => { @@ -276,7 +302,7 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(observedAccountBoundNativeOpenAiSlugs(observedEntries)).toEqual(["gpt-future-unlisted"]); }); - test("gpt-daybreak-blue-latest ships as a global native row without an observation", () => { + test("gpt-daybreak-blue-latest has one native capability template when selected for emission", () => { const entries = buildCatalogEntries( nativeTemplate(), [...NATIVE_OPENAI_MODELS], @@ -290,8 +316,8 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { new Set(), ); const bare = entries.filter(entry => entry.slug === "gpt-daybreak-blue-latest"); - // Exactly one row: the slug sits in BOTH NATIVE_OPENAI_MODELS and - // NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS, and that overlap must not duplicate it. + // Exactly one row: entitlement decides whether the caller passes this slug into the builder; + // once selected, its overlap with the capability-alias list must not duplicate it. expect(bare).toHaveLength(1); // Capability is inherited from gpt-5.6-sol, so it is a recursive-capable v2 delegate. expect(bare[0]?.multi_agent_version).toBe("v2"); @@ -590,7 +616,9 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { ); const rows = await modelsRes!.json() as Array<{ namespaced: string; native?: boolean; disabled: boolean }>; const nativeRows = rows.filter(r => r.native); - expect(nativeRows.map(r => r.namespaced)).toEqual([...NATIVE_OPENAI_MODELS]); + expect(nativeRows.map(r => r.namespaced)).toEqual( + NATIVE_OPENAI_MODELS.filter(slug => !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug)), + ); expect(nativeRows.find(r => r.namespaced === "gpt-5.6-sol")?.disabled).toBe(true); // Native rows lead the response so the GUI pins the group first. expect(rows[0]?.native).toBe(true); diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index dc3dfbe273..cf50d5250d 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -44,6 +44,7 @@ import { ownedServiceHomeInspection } from "./helpers/owned-service-home-inspect import { configuredAdminToken } from "../src/lib/admin-secrets"; import { SYSTEM_RESTART_CAPABILITY_VERSION } from "../src/lib/system-restart-contract"; import { LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION } from "../src/lib/local-provider-reload-contract"; +import { resetCodexModelEntitlementCacheForTests } from "../src/codex/model-entitlements"; import { watchdogMs } from "./helpers/ci-watchdog"; const previousApiToken = process.env.OPENCODEX_API_AUTH_TOKEN; @@ -141,6 +142,7 @@ afterEach(() => { clearAccountNeedsReauth("pool-a"); clearAccountNeedsReauth("pool-b"); clearAccountQuota(); + resetCodexModelEntitlementCacheForTests(); if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); }); @@ -161,6 +163,7 @@ type PoolRetryHarness = { model?: string; path?: "/v1/responses" | "/v1/responses/compact"; callerBearer?: boolean; + extraBody?: Record; }) => Promise; restoreFetch: () => void; server: ReturnType; @@ -200,6 +203,7 @@ async function startPoolRetryHarness( reauthAccountIds?: string[]; omitCredentialAccountIds?: string[]; combos?: OcxConfig["combos"]; + modelRosterByAccount?: Record; } = {}, ): Promise { await removeTestDirBestEffort(TEST_DIR); @@ -208,6 +212,7 @@ async function startPoolRetryHarness( clearCodexUpstreamHealth(); clearThreadAccountMap(); clearAccountQuota(); + resetCodexModelEntitlementCacheForTests(); clearRequestLogsForTests(); clearAccountNeedsReauth("pool-a"); clearAccountNeedsReauth("pool-b"); @@ -223,6 +228,15 @@ async function startPoolRetryHarness( port: 0, async fetch(request) { const accountId = request.headers.get("chatgpt-account-id") ?? "missing"; + if (new URL(request.url).pathname === "/models") { + return Response.json({ + models: (options.modelRosterByAccount?.[accountId] ?? []).map(slug => ({ + slug, + supported_in_api: true, + visibility: "list", + })), + }); + } dispatches.push(accountId); return reply(accountId, request); }, @@ -296,13 +310,14 @@ async function startPoolRetryHarness( model = POOL_RETRY_MODEL, path = "/v1/responses", callerBearer = true, + extraBody = {}, } = {}) => originalGlobalFetch(new URL(path, server.url), { method: "POST", headers: { "content-type": "application/json", ...(callerBearer ? { authorization: "Bearer inbound-token" } : {}), }, - body: JSON.stringify({ model, input: path.endsWith("/compact") ? [] : "hello", stream }), + body: JSON.stringify({ model, input: path.endsWith("/compact") ? [] : "hello", stream, ...extraBody }), signal, }), }; @@ -2108,6 +2123,188 @@ describe("server local API auth", () => { } }); + test("#2097: account-gated model selection skips an unentitled active Pool account", async () => { + const model = "gpt-daybreak-blue-latest"; + const harness = await startPoolRetryHarness( + accountId => Response.json({ id: accountId, status: "completed", output: [] }), + { + modelRosterByAccount: { + "acct-pool-a": ["gpt-5.6-sol"], + "acct-pool-b": ["gpt-5.6-sol", model], + }, + }, + ); + try { + const response = await harness.request({ model }); + expect(response.status).toBe(200); + expect((await response.json() as { id: string }).id).toBe("acct-pool-b"); + expect(harness.dispatches).toEqual(["acct-pool-b"]); + } finally { + await stopPoolRetryHarness(harness); + } + }); + + test("#2097: Daybreak keeps its entitlement identity but uses the stable wire model", async () => { + const model = "gpt-daybreak-blue-latest"; + let upstreamBody: Record | undefined; + const harness = await startPoolRetryHarness( + async (_accountId, request) => { + upstreamBody = await request.json() as Record; + return Response.json({ id: "canonical-wire-success", status: "completed", output: [] }); + }, + { + secondAccount: false, + modelRosterByAccount: { "acct-pool-a": ["gpt-5.6-sol", model] }, + }, + ); + try { + const response = await harness.request({ + model, + extraBody: { prompt_cache_retention: "24h" }, + }); + expect(response.status).toBe(200); + expect(upstreamBody?.model).toBe("gpt-5.6-sol"); + expect(upstreamBody).not.toHaveProperty("prompt_cache_retention"); + expect(harness.dispatches).toEqual(["acct-pool-a"]); + } finally { + await stopPoolRetryHarness(harness); + } + }); + + test("#2097: Daybreak compact uses the stable wire model without retention", async () => { + const model = "gpt-daybreak-blue-latest"; + let upstreamBody: Record | undefined; + let upstreamUrl = ""; + const harness = await startPoolRetryHarness( + async (_accountId, request) => { + upstreamUrl = request.url; + upstreamBody = await request.json() as Record; + return new Response([ + 'event: response.output_item.done', + 'data: {"type":"response.output_item.done","output_index":0,"item":{"type":"compaction","encrypted_content":"gAAAAAB-test-opaque"}}', + '', + 'event: response.completed', + 'data: {"type":"response.completed","response":{"status":"completed","output":[]}}', + '', + 'data: [DONE]', + '', + ].join("\n"), { + headers: { "content-type": "text/event-stream" }, + }); + }, + { + secondAccount: false, + modelRosterByAccount: { "acct-pool-a": ["gpt-5.6-sol", model] }, + }, + ); + try { + const response = await harness.request({ + model, + path: "/v1/responses/compact", + extraBody: { prompt_cache_retention: "24h" }, + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + output: [{ type: "compaction", encrypted_content: "gAAAAAB-test-opaque" }], + }); + expect(upstreamUrl).toEndWith("/responses"); + expect(upstreamBody?.model).toBe("gpt-5.6-sol"); + expect(upstreamBody?.stream).toBe(true); + expect(upstreamBody).not.toHaveProperty("prompt_cache_retention"); + expect(harness.dispatches).toEqual(["acct-pool-a"]); + } finally { + await stopPoolRetryHarness(harness); + } + }); + + test("#2097: a confirmed entitled account survives two transient unsupported-model 400s in place", async () => { + const model = "gpt-daybreak-blue-latest"; + let attempts = 0; + const harness = await startPoolRetryHarness( + () => ++attempts <= 2 + ? rejectionResponse(unsupportedModelBody(model)) + : Response.json({ id: "same-account-success", status: "completed", output: [] }), + { + secondAccount: false, + modelRosterByAccount: { "acct-pool-a": ["gpt-5.6-sol", model] }, + }, + ); + try { + const response = await harness.request({ model }); + expect(response.status).toBe(200); + expect((await response.json() as { id: string }).id).toBe("same-account-success"); + expect(harness.dispatches).toEqual(["acct-pool-a", "acct-pool-a", "acct-pool-a"]); + } finally { + await stopPoolRetryHarness(harness); + } + }); + + test("#2097: an exact account-gated selector may retry only its confirmed account in place", async () => { + const model = "gpt-daybreak-blue-latest"; + let attempts = 0; + const harness = await startPoolRetryHarness( + () => ++attempts <= 2 + ? rejectionResponse(unsupportedModelBody(model)) + : Response.json({ id: "same-exact-account-success", status: "completed", output: [] }), + { + accountMode: "direct", + activeAccountId: "pool-b", + accountNamespaces: { side: "pool-a" }, + modelRosterByAccount: { "acct-pool-a": ["gpt-5.6-sol", model] }, + }, + ); + try { + const response = await harness.request({ model: `side/${model}`, callerBearer: false }); + expect(response.status).toBe(200); + expect((await response.json() as { id: string }).id).toBe("same-exact-account-success"); + expect(harness.dispatches).toEqual(["acct-pool-a", "acct-pool-a", "acct-pool-a"]); + expect(loadConfig().activeCodexAccountId).toBe("pool-b"); + } finally { + await stopPoolRetryHarness(harness); + } + }); + + test("#2097: repeated gated-model rejection remains bounded at eight total sends", async () => { + const model = "gpt-daybreak-blue-latest"; + const body = unsupportedModelBody(model); + const harness = await startPoolRetryHarness( + () => rejectionResponse(body), + { + secondAccount: false, + modelRosterByAccount: { "acct-pool-a": ["gpt-5.6-sol", model] }, + }, + ); + try { + const response = await harness.request({ model }); + expect(response.status).toBe(400); + expect(await response.text()).toBe(body); + expect(harness.dispatches).toEqual(Array(8).fill("acct-pool-a")); + } finally { + await stopPoolRetryHarness(harness); + } + }); + + test("#2097: an account-gated model with no confirmed grant fails before upstream dispatch", async () => { + const model = "gpt-daybreak-blue-latest"; + const harness = await startPoolRetryHarness( + () => Response.json({ id: "must-not-dispatch" }), + { + modelRosterByAccount: { + "acct-pool-a": ["gpt-5.6-sol"], + "acct-pool-b": ["gpt-5.6-sol"], + }, + }, + ); + try { + const response = await harness.request({ model }); + expect(response.status).toBe(401); + expect(await response.text()).toContain("No eligible Codex account supports this model"); + expect(harness.dispatches).toEqual([]); + } finally { + await stopPoolRetryHarness(harness); + } + }); + test.each([400, 402, 429])("exact account selector preserves the original %d without switching accounts", async status => { const body = status === 400 ? unsupportedModelBody()