diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index b7ab78cfb8..b9527f3a50 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -35,6 +35,7 @@ import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../accou import { MAIN_CODEX_ACCOUNT_ID } from "../main-account"; import { availableAccountGatedNativeModels, + codexModelEntitlementStateForAccount, isCodexModelEntitlementSnapshotCurrent, resolveCodexModelEntitlements, type CodexModelEntitlementSnapshot, @@ -1613,10 +1614,10 @@ function writeRetainedCatalogSync({ ? 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) + !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) + || (accountId !== undefined + && codexModelEntitlementStateForAccount(modelEntitlements, accountId, slug) === "granted") ))] as const; })) : new Map(); diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index e33e654481..b84bbcb909 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -73,6 +73,7 @@ import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "./accoun import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; import { availableAccountGatedNativeModels, + codexModelEntitlementStateForAccount, isCodexModelEntitlementSnapshotCurrent, resolveCodexModelEntitlements, type CodexModelEntitlementSnapshot, @@ -277,10 +278,10 @@ function prepareCatalog( ? 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) + !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) + || (accountId !== undefined + && codexModelEntitlementStateForAccount(modelEntitlements, accountId, slug) === "granted") ))] as const; })) : new Map(); diff --git a/src/codex/model-entitlements.ts b/src/codex/model-entitlements.ts index 419f4ddcf1..18b9437330 100644 --- a/src/codex/model-entitlements.ts +++ b/src/codex/model-entitlements.ts @@ -88,6 +88,20 @@ export function deriveGatedClientVersionFloor( */ const MEASURED_GATED_CLIENT_VERSION_MINIMUM = "0.144.0"; +/** + * Lowest versions measured to return each account-gated model when the account owns it. + * + * This is deliberately independent of the bundled upstream snapshot. The snapshot still + * records 0.142.2 for sol/terra/luna, while live measurements show that upstream omits them + * below 0.144.0. Daybreak has no snapshot row or independent minimum, so its omission remains + * authoritative instead of inheriting a guessed floor from another model. + */ +export const ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS: ReadonlyMap = new Map([ + ["gpt-5.6-sol", MEASURED_GATED_CLIENT_VERSION_MINIMUM], + ["gpt-5.6-terra", MEASURED_GATED_CLIENT_VERSION_MINIMUM], + ["gpt-5.6-luna", MEASURED_GATED_CLIENT_VERSION_MINIMUM], +]); + /** * Fallback when the snapshot records no usable gated floor. * @@ -285,10 +299,13 @@ interface CachedAccountModels { export interface CodexModelEntitlementSnapshot { readonly modelsByAccount: ReadonlyMap>; + readonly clientVersionByAccount: ReadonlyMap; readonly confirmedAccountIds: ReadonlySet; readonly credentialIdentities: ReadonlyMap; } +export type CodexModelEntitlementState = "granted" | "denied" | "unknown"; + export interface CodexModelEntitlementResolveOptions { readonly fetcher?: typeof fetch; /** @@ -501,10 +518,16 @@ async function fetchAccountModels( // that as authoritative is exactly how 2.36.0 denied sol/terra/luna to accounts that own // them (#3022). No usable rows means unconfirmed, on the 15s failure TTL, asked again. const usable = models !== null && models.size > 0; + const hasUnknownGatedAbsence = usable && [...ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS] + .some(([modelId, minimum]) => ( + !models.has(modelId) && compareClientVersions(clientVersion, minimum) < 0 + )); return { credentialIdentity: credential.credentialIdentity, clientVersion, - expiresAt: now + (usable ? MODEL_ROSTER_TTL_MS : MODEL_ROSTER_FAILURE_TTL_MS), + expiresAt: now + (usable && !hasUnknownGatedAbsence + ? MODEL_ROSTER_TTL_MS + : MODEL_ROSTER_FAILURE_TTL_MS), models: models ?? new Set(), confirmed: usable, }; @@ -823,11 +846,44 @@ export async function resolveCodexModelEntitlements( }))); return { modelsByAccount: new Map(results.map(({ credential, result }) => [credential.accountId, result.models])), + clientVersionByAccount: new Map(results.map(({ credential, result }) => ( + [credential.accountId, result.clientVersion] + ))), confirmedAccountIds: new Set(results.flatMap(({ credential, result }) => result.confirmed ? [credential.accountId] : [])), credentialIdentities: new Map(results.map(({ credential }) => [credential.accountId, credential.credentialIdentity])), }; } +function codexModelEntitlementStateForRoster( + models: ReadonlySet | undefined, + confirmed: boolean, + clientVersion: string | undefined, + modelId: string, +): CodexModelEntitlementState { + if (!models || !confirmed) return "unknown"; + // Positive evidence is authoritative regardless of which client version asked for it. + if (models.has(modelId)) return "granted"; + const minimum = ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS.get(modelId); + if (minimum && (!clientVersion || compareClientVersions(clientVersion, minimum) < 0)) { + return "unknown"; + } + return "denied"; +} + +/** Per-account tri-state authority. Positive projections admit only `granted`. */ +export function codexModelEntitlementStateForAccount( + snapshot: CodexModelEntitlementSnapshot, + accountId: string, + modelId: string, +): CodexModelEntitlementState { + return codexModelEntitlementStateForRoster( + snapshot.modelsByAccount.get(accountId), + snapshot.confirmedAccountIds.has(accountId), + snapshot.clientVersionByAccount?.get(accountId), + modelId, + ); +} + /** Fail-closed entitlement check for a Direct request's own forwarded ChatGPT credential. */ export async function isDirectCallerEntitledToCodexModel( headers: Headers, @@ -844,7 +900,12 @@ export async function isDirectCallerEntitledToCodexModel( options.now ?? Date.now(), clientVersion, ); - return result.confirmed && result.models.has(modelId); + return codexModelEntitlementStateForRoster( + result.models, + result.confirmed, + result.clientVersion, + modelId, + ) === "granted"; } export function entitledCodexAccountIdsForModel( @@ -852,8 +913,10 @@ export function entitledCodexAccountIdsForModel( 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] : [] + return new Set([...snapshot.modelsByAccount.keys()].flatMap(accountId => ( + codexModelEntitlementStateForAccount(snapshot, accountId, modelId) === "granted" + ? [accountId] + : [] ))); } @@ -862,10 +925,9 @@ export function availableAccountGatedNativeModels( eligibleAccountIds?: ReadonlySet, ): ReadonlySet { return new Set([...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(modelId => ( - [...snapshot.modelsByAccount].some(([accountId, models]) => ( + [...snapshot.modelsByAccount.keys()].some(accountId => ( (!eligibleAccountIds || eligibleAccountIds.has(accountId)) - && snapshot.confirmedAccountIds.has(accountId) - && models.has(modelId) + && codexModelEntitlementStateForAccount(snapshot, accountId, modelId) === "granted" )) ))); } @@ -890,9 +952,13 @@ export function cachedAvailableAccountGatedNativeModels( (!eligibleAccountIds || eligibleAccountIds.has(accountIdOfCacheKey(accountId))) && !accountIdOfCacheKey(accountId).startsWith(DIRECT_CALLER_ACCOUNT_PREFIX) && (version === null || entry.clientVersion === version) - && entry.confirmed && entry.expiresAt > now - && entry.models.has(modelId) + && codexModelEntitlementStateForRoster( + entry.models, + entry.confirmed, + entry.clientVersion, + modelId, + ) === "granted" )) ))); } diff --git a/src/server/index.ts b/src/server/index.ts index 6c7e53f062..18e4e5254a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -66,6 +66,7 @@ import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../codex import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account"; import { availableAccountGatedNativeModels, + codexModelEntitlementStateForAccount, resolveCodexModelEntitlements, } from "../codex/model-entitlements"; export { @@ -1211,10 +1212,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { 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) + !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) + || (accountId !== undefined + && codexModelEntitlementStateForAccount(modelEntitlements, accountId, slug) === "granted") ))] as const; })) : new Map(); diff --git a/tests/codex-model-entitlements.test.ts b/tests/codex-model-entitlements.test.ts index b9ab986ecb..2de1971b95 100644 --- a/tests/codex-model-entitlements.test.ts +++ b/tests/codex-model-entitlements.test.ts @@ -3,8 +3,10 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod import { tmpdir } from "node:os"; import { join } from "node:path"; import { + ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS, availableAccountGatedNativeModels, cachedAvailableAccountGatedNativeModels, + codexModelEntitlementStateForAccount, composeGatedClientVersionFloorForTests, compareClientVersionsForTests, codexEntitlementNegativeMemoForTests, @@ -20,6 +22,7 @@ import { resolveCodexModelEntitlements, seedCodexModelEntitlementsForTests, type CodexModelEntitlementCredentialSnapshot, + type CodexModelEntitlementState, } from "../src/codex/model-entitlements"; import { forceRefreshMainAccountToken, @@ -54,6 +57,14 @@ function roster(...slugs: string[]): Response { }); } +function projectedEntitlementState( + snapshot: Awaited>, + accountId: string, + modelId: string, +): CodexModelEntitlementState { + return codexModelEntitlementStateForAccount(snapshot, accountId, modelId); +} + function deferred(): { promise: Promise; resolve: (value: T) => void; @@ -214,6 +225,126 @@ describe("Codex account model entitlements", () => { }); +describe("tri-state entitlement authority", () => { + const directHeaders = (): Headers => new Headers({ + authorization: "Bearer tri-state-caller", + "chatgpt-account-id": "tri-state-account", + }); + + test("an omitted gated slug below its minimum is unknown and uses the failure TTL", async () => { + let fetches = 0; + const backend = (async () => { + fetches += 1; + return roster("gpt-5.5"); + }) as typeof fetch; + + expect(await isDirectCallerEntitledToCodexModel(directHeaders(), SOL, { + fetcher: backend, + now: 1_000, + clientVersion: "0.140.0", + })).toBe(false); + expect(await isDirectCallerEntitledToCodexModel(directHeaders(), SOL, { + fetcher: backend, + now: 15_999, + clientVersion: "0.140.0", + })).toBe(false); + expect(fetches).toBe(1); + expect(await isDirectCallerEntitledToCodexModel(directHeaders(), SOL, { + fetcher: backend, + now: 16_001, + clientVersion: "0.140.0", + })).toBe(false); + expect(fetches).toBe(2); + + const snapshot = await resolveCodexModelEntitlements({ codexAccounts: [] }, { + credentials: [credential("main")], + fetcher: (async () => roster("gpt-5.5")) as typeof fetch, + now: 20_000, + clientVersion: "0.140.0", + }); + expect(snapshot.clientVersionByAccount.get("main")).toBe("0.140.0"); + expect(projectedEntitlementState(snapshot, "main", SOL)).toBe("unknown"); + expect(entitledCodexAccountIdsForModel(snapshot, SOL)?.size).toBe(0); + expect(availableAccountGatedNativeModels(snapshot).has(SOL)).toBe(false); + }); + + test("an omitted gated slug at its minimum is denied", async () => { + const snapshot = await resolveCodexModelEntitlements({ codexAccounts: [] }, { + credentials: [credential("main")], + fetcher: (async () => roster("gpt-5.5")) as typeof fetch, + now: 1_000, + clientVersion: "0.144.0", + }); + + expect(projectedEntitlementState(snapshot, "main", SOL)).toBe("denied"); + expect(entitledCodexAccountIdsForModel(snapshot, SOL)?.size).toBe(0); + expect(availableAccountGatedNativeModels(snapshot).has(SOL)).toBe(false); + }); + + test("a present gated slug below its minimum is granted", async () => { + const snapshot = await resolveCodexModelEntitlements({ codexAccounts: [] }, { + credentials: [credential("main")], + fetcher: (async () => roster("gpt-5.5", SOL)) as typeof fetch, + now: 1_000, + clientVersion: "0.140.0", + }); + + expect(projectedEntitlementState(snapshot, "main", SOL)).toBe("granted"); + expect([...entitledCodexAccountIdsForModel(snapshot, SOL)!]).toEqual(["main"]); + expect(availableAccountGatedNativeModels(snapshot).has(SOL)).toBe(true); + }); + + test("Daybreak omission remains denied without a known minimum", async () => { + expect(ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS.get(SOL)).toBe("0.144.0"); + expect(ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS.has(DAYBREAK)).toBe(false); + + for (const clientVersion of ["0.140.0", "0.200.0"]) { + const snapshot = await resolveCodexModelEntitlements({ codexAccounts: [] }, { + credentials: [credential(`main-${clientVersion}`)], + fetcher: (async () => roster("gpt-5.5")) as typeof fetch, + now: 1_000, + clientVersion, + }); + expect(projectedEntitlementState(snapshot, `main-${clientVersion}`, DAYBREAK)).toBe("denied"); + expect(entitledCodexAccountIdsForModel(snapshot, DAYBREAK)?.size).toBe(0); + expect(availableAccountGatedNativeModels(snapshot).has(DAYBREAK)).toBe(false); + } + }); + + test("CHARACTERIZATION: no positive projection returns a gated slug absent from the roster", async () => { + const snapshot = await resolveCodexModelEntitlements({ codexAccounts: [] }, { + credentials: [credential("main")], + fetcher: (async () => roster("gpt-5.5")) as typeof fetch, + now: 1_000, + clientVersion: "0.140.0", + }); + expect(entitledCodexAccountIdsForModel(snapshot, SOL)?.size).toBe(0); + expect(availableAccountGatedNativeModels(snapshot).has(SOL)).toBe(false); + + seedCodexModelEntitlementsForTests("main", ["gpt-5.5"], 1_000, "0.140.0"); + expect(cachedAvailableAccountGatedNativeModels(1_001, undefined, "0.140.0").has(SOL)) + .toBe(false); + expect(await isDirectCallerEntitledToCodexModel(directHeaders(), SOL, { + fetcher: (async () => roster("gpt-5.5")) as typeof fetch, + now: 1_000, + clientVersion: "0.140.0", + })).toBe(false); + }); + + test("CHARACTERIZATION: an unconfirmed roster cannot grant a present gated slug", () => { + const snapshot = { + modelsByAccount: new Map([["main", new Set([SOL])]]), + clientVersionByAccount: new Map([["main", "0.140.0"]]), + confirmedAccountIds: new Set(), + credentialIdentities: new Map([["main", "test:main"]]), + }; + + expect(projectedEntitlementState(snapshot, "main", SOL)).toBe("unknown"); + expect(entitledCodexAccountIdsForModel(snapshot, SOL)?.size).toBe(0); + expect(availableAccountGatedNativeModels(snapshot).has(SOL)).toBe(false); + }); +}); + describe("ensureCodexEntitlementFreshness", () => { const originalOpenCodexHome = process.env.OPENCODEX_HOME; const originalCodexHome = process.env.CODEX_HOME;