-
Notifications
You must be signed in to change notification settings - Fork 0
[WRONG BRANCH] fix(codex): bound entitlement version cache misses #392
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
|
@@ -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 ( | ||
|
|
@@ -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); | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When one legitimate non-runtime Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| // Bound concurrency per account before opening another upstream request. | ||
| let liveForAccount = 0; | ||
| for (const key of accountModelsFlights.keys()) { | ||
|
|
@@ -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, | ||
|
|
@@ -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])), | ||
|
|
@@ -566,6 +600,7 @@ export async function isDirectCallerEntitledToCodexModel( | |
| options.fetcher ?? fetch, | ||
| options.now ?? Date.now(), | ||
| clientVersion, | ||
| clientVersion, | ||
| ); | ||
| return result.confirmed && result.models.has(modelId); | ||
| } | ||
|
|
@@ -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; | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 👍 / 👎.