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()