Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion src/codex/model-entitlements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,9 @@ const MODEL_ROSTER_VERSIONS_PER_ACCOUNT_MAX = 4;
* roster.
*/
const MODEL_ROSTER_FLIGHTS_PER_ACCOUNT_MAX = 4;
/** Distinct, caller-selected roster versions admitted per account in one roster TTL. */
const MODEL_ROSTER_VERSION_MISSES_PER_ACCOUNT_MAX = 4;
const accountModelsMisses = new Map<string, number[]>();
const DIRECT_CALLER_ACCOUNT_PREFIX = "__direct_codex__:";

export interface CodexModelEntitlementCredentialSnapshot {
Expand Down Expand Up @@ -457,6 +460,7 @@ async function modelsForCredential(
fetcher: typeof fetch,
now: number,
clientVersion: string,
trustedClientVersion: string,
): Promise<CachedAccountModels> {
const cached = accountModelsCache.get(cacheKeyFor(credential.accountId, clientVersion));
if (
Expand All @@ -469,6 +473,32 @@ async function modelsForCredential(
const existing = accountModelsFlights.get(flightKey);
if (existing) return existing;

// The inbound version is useful compatibility evidence, but it is also an untrusted cache-key
// dimension. Bound completed misses as well as concurrent flights so cycling versions cannot
// turn one data-plane request into renewable authenticated requests under every stored token.
// The locally selected runtime (or bundled floor) is exempt: it has one stable cache key and
// must remain refreshable even after an untrusted caller spends this account's allowance.
if (
!credential.accountId.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX)
&& clientVersion !== trustedClientVersion
) {
const missKey = `${credential.accountId}\u0000${credential.credentialIdentity}`;
const recent = (accountModelsMisses.get(missKey) ?? [])
.filter(startedAt => startedAt > now - MODEL_ROSTER_TTL_MS);
Comment on lines +485 to +487

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove stale credential generations from miss accounting

For Pool accounts, a normal access-token refresh increments the credential generation, so this identity-qualified key changes even when the account ID stays the same. Expired entries are pruned only when that exact old key is queried again, while the invalidation function is called only on model-400 retry paths, not routine credential refreshes; consequently, an active caller can leave one permanent map entry per account generation for the lifetime of the process. Replace the prior identity's entry when a generation changes or opportunistically remove expired keys so this new protection does not introduce an unbounded memory cache.

Useful? React with 👍 / 👎.

if (recent.length >= MODEL_ROSTER_VERSION_MISSES_PER_ACCOUNT_MAX) {
accountModelsMisses.set(missKey, recent);
return {
credentialIdentity: credential.credentialIdentity,
clientVersion,
expiresAt: now,
models: new Set(),
confirmed: false,
};
}
recent.push(now);
accountModelsMisses.set(missKey, recent);
Comment on lines +486 to +499

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve retries for a repeated client version

When one legitimate non-runtime client_version is retried during an upstream outage, every retry after the 15-second failure-cache expiry appends another timestamp because the budget does not record which version caused the miss. After four failed attempts, even that same version is refused until the five-minute roster window expires, so an upstream recovery after roughly one minute can leave gated models unavailable for several additional minutes. Track distinct versions as documented, or allow a previously counted version to continue retrying on the existing failure TTL.

Useful? React with 👍 / 👎.

}

// Bound concurrency per account before opening another upstream request.
let liveForAccount = 0;
for (const key of accountModelsFlights.keys()) {
Expand Down Expand Up @@ -529,6 +559,10 @@ export async function resolveCodexModelEntitlements(
): Promise<CodexModelEntitlementSnapshot> {
const now = options.now ?? Date.now();
const fetcher = options.fetcher ?? fetch;
const trustedClientVersion = resolveCodexEntitlementClientVersion(
null,
options.loadPersistedRuntime ?? loadPersistedCodexRuntime,
);
const clientVersion = resolveCodexEntitlementClientVersion(
options.clientVersion,
options.loadPersistedRuntime ?? loadPersistedCodexRuntime,
Expand All @@ -542,7 +576,7 @@ export async function resolveCodexModelEntitlements(
.filter((value): value is CodexModelEntitlementCredentialSnapshot => value !== null);
const results = await Promise.all(credentials.map(async credential => ({
credential,
result: await modelsForCredential(credential, fetcher, now, clientVersion),
result: await modelsForCredential(credential, fetcher, now, clientVersion, trustedClientVersion),
})));
return {
modelsByAccount: new Map(results.map(({ credential, result }) => [credential.accountId, result.models])),
Expand All @@ -566,6 +600,7 @@ export async function isDirectCallerEntitledToCodexModel(
options.fetcher ?? fetch,
options.now ?? Date.now(),
clientVersion,
clientVersion,
);
return result.confirmed && result.models.has(modelId);
}
Expand Down Expand Up @@ -634,11 +669,15 @@ export function invalidateCodexModelEntitlementsForAccount(accountId: string | n
for (const key of [...accountModelsCache.keys()]) {
if (accountIdOfCacheKey(key) === accountId) accountModelsCache.delete(key);
}
for (const key of [...accountModelsMisses.keys()]) {
if (accountIdOfCacheKey(key) === accountId) accountModelsMisses.delete(key);
}
}

export function resetCodexModelEntitlementCacheForTests(): void {
accountModelsCache.clear();
accountModelsFlights.clear();
accountModelsMisses.clear();
runtimeVersionMemo = null;
}

Expand Down
23 changes: 23 additions & 0 deletions tests/codex-model-entitlements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,29 @@ describe("entitlement client version (#2886)", () => {
expect(results.filter(Boolean).length).toBeLessThanOrEqual(4);
});

test("completed caller-selected misses have a renewable-work budget", async () => {
let fetches = 0;
const backend = (async () => { fetches += 1; return roster(SOL); }) as typeof fetch;
const options = (clientVersion: string | null) => ({
credentials: [credential("rate-bounded")],
fetcher: backend,
now: 1_000,
clientVersion,
loadPersistedRuntime: () => null,
});

for (let i = 0; i < 12; i += 1) {
await resolveCodexModelEntitlements({ codexAccounts: [] }, options(`0.${500 + i}.0`));
}
expect(fetches).toBe(4);

// An attacker cannot spend the stable local-version path's capacity. It remains available
// for catalog refresh and routing even after every caller-selected allowance was consumed.
const trusted = await resolveCodexModelEntitlements({ codexAccounts: [] }, options(null));
expect(fetches).toBe(5);
expect(trusted.confirmedAccountIds.has("rate-bounded")).toBe(true);
});

test("the placeholder 0.0.0 is never accepted as a client version", async () => {
// 0.0.0 is exactly what shipped, and it is a syntactically valid version string, so the
// guard has to reject it by value rather than by shape.
Expand Down
Loading