diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 0aebeaae229..61fe5fc4c18 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -27,6 +27,7 @@ import { type CodexUpstreamOutcomeMeta, } from "./routing/cooldown-math"; import { + carriesQuotaRefusal, codexPoolKeyForScope, codexQuotaScopeForModel, deleteAccountHealth, @@ -37,6 +38,7 @@ import { getCodexAccountCooldownUntil, getCodexAccountSoftAvoidUntil, getCodexQuotaHealthSnapshot, + hasUnrecoveredCodexQuotaRefusal, isCodexAccountSoftAvoided, isCodexQuotaAvoided, isHealthAccountAdmissible, @@ -92,6 +94,7 @@ import { getEligiblePoolAccounts, getPoolAccountPlanForSelection, hasCodexQuotaHeadroom, + hasCodexSharedStateQuotaHeadroom, isCodexAccountPlanExcluded, isCodexAccountSelectable, isHealthySharedCodexSelection, @@ -483,45 +486,6 @@ export function resolveCodexAccountForThread( return resolution.status === "selected" ? resolution.accountId : null; } -function carriesQuotaRefusal(health: CodexUpstreamHealth | undefined): boolean { - return health?.lastFailureStatus === 429 || health?.lastFailureStatus === 402; -} - -/** - * Has this account refused a request on quota without serving one since? - * - * Thread affinity is a prompt-cache optimization and every rule around it is a preference: - * `autoSwitchThreshold` is a hint that an account is getting busy, and `pool.cacheAffinity` - * deliberately raises that bar further. A refusal is not a preference, and once the account has - * told THIS thread it cannot serve, the binding has nothing left to optimize. - * - * The distinction matters because the cooldown a 429 writes is deliberately short. A reset - * announcement is advisory — plan quota routinely frees up before the advertised instant — so - * {@link CODEX_MAX_RESET_DERIVED_COOLDOWN_MS} caps it at 15 minutes. The five-hour window that - * announcement describes is not capped, so an account whose burst window is spent looks - * selectable again long before it is. For an unbound request that is correct: going back to find - * out is how the pool learns the window moved. For a BOUND thread it is a loop with no exit — - * the cooldown lapses, the account still scores lowest on the only window this proxy has a - * reading for (its weekly bar, untouched by a burst limit), the thread rebinds, and earns the - * identical 429. Cleared affinity does not help: the next request re-derives the same choice. - * From the Codex side that reads exactly as reported — a new session rotates normally while an - * existing one is locked to an exhausted account until the proxy is restarted, because a restart - * is the only thing that drops the binding and the stale health together. - * - * `lastFailureStatus` is the right evidence because of when it ends: {@link preservedCooldownFields} - * strips it from every recovery write, so it survives exactly until the account actually serves a - * request again. Nothing here blocks that — selection is untouched, so unbound traffic still probes - * the account and the first success releases every thread this refused. - * - * Scope follows where the refusal was recorded. An account-wide throttle lands in - * `upstreamHealth` and releases every lane; a reset-derived refusal lands against one native - * quota group, so a spent Spark window still cannot displace the same thread's Terra binding. - */ -function hasUnrecoveredCodexQuotaRefusal(accountId: string, quotaScope?: CodexQuotaScope): boolean { - if (carriesQuotaRefusal(getAccountHealth(accountId))) return true; - return quotaScope !== undefined && carriesQuotaRefusal(scopedHealthFor(accountId, quotaScope)); -} - function previewReusableAffinityAccount( entry: ThreadAffinityEntry | undefined, config: OcxConfig, @@ -946,7 +910,7 @@ export function resolveCodexAccountForThreadDetailed( // the account has already told this thread it cannot serve it. const quotaRefused = hasUnrecoveredCodexQuotaRefusal(entry.accountId, quotaScope); const healthyForSharedAffinity = selectableForSharedState - && hasCodexQuotaHeadroom(config, entry.accountId, sharedSelectionOptions, now) + && hasCodexSharedStateQuotaHeadroom(config, entry.accountId, quotaScope, sharedSelectionOptions, now) && !quotaRefused && !failoverReady; if ( @@ -1144,7 +1108,7 @@ export function resolveCodexAccountForThreadDetailed( sharedSelectionOptions, ); const activeHealthyForSharedSelection = activeSelectableForSharedState - && hasCodexQuotaHeadroom(config, active, sharedSelectionOptions, now) + && hasCodexSharedStateQuotaHeadroom(config, active, quotaScope, sharedSelectionOptions, now) && !shouldFailover(config, active, now); if (!isCodexAccountSelectable(config, active, now, quotaScope, selectionOptions)) { const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope, selectionOptions); diff --git a/src/codex/routing/health-store.ts b/src/codex/routing/health-store.ts index 9c0d922b970..57d4cc1ab30 100644 --- a/src/codex/routing/health-store.ts +++ b/src/codex/routing/health-store.ts @@ -350,6 +350,45 @@ export function deleteAccountHealth(accountId: string): void { upstreamHealth.delete(accountId); } +export function carriesQuotaRefusal(health: CodexUpstreamHealth | undefined): boolean { + return health?.lastFailureStatus === 429 || health?.lastFailureStatus === 402; +} + +/** + * Has this account refused a request on quota without serving one since? + * + * Thread affinity is a prompt-cache optimization and every rule around it is a preference: + * `autoSwitchThreshold` is a hint that an account is getting busy, and `pool.cacheAffinity` + * deliberately raises that bar further. A refusal is not a preference, and once the account has + * told THIS thread it cannot serve, the binding has nothing left to optimize. + * + * The distinction matters because the cooldown a 429 writes is deliberately short. A reset + * announcement is advisory — plan quota routinely frees up before the advertised instant — so + * {@link CODEX_MAX_RESET_DERIVED_COOLDOWN_MS} caps it at 15 minutes. The five-hour window that + * announcement describes is not capped, so an account whose burst window is spent looks + * selectable again long before it is. For an unbound request that is correct: going back to find + * out is how the pool learns the window moved. For a BOUND thread it is a loop with no exit — + * the cooldown lapses, the account still scores lowest on the only window this proxy has a + * reading for (its weekly bar, untouched by a burst limit), the thread rebinds, and earns the + * identical 429. Cleared affinity does not help: the next request re-derives the same choice. + * From the Codex side that reads exactly as reported — a new session rotates normally while an + * existing one is locked to an exhausted account until the proxy is restarted, because a restart + * is the only thing that drops the binding and the stale health together. + * + * `lastFailureStatus` is the right evidence because of when it ends: {@link preservedCooldownFields} + * strips it from every recovery write, so it survives exactly until the account actually serves a + * request again. Nothing here blocks that — selection is untouched, so unbound traffic still probes + * the account and the first success releases every thread this refused. + * + * Scope follows where the refusal was recorded. An account-wide throttle lands in + * `upstreamHealth` and releases every lane; a reset-derived refusal lands against one native + * quota group, so a spent Spark window still cannot displace the same thread's Terra binding. + */ +export function hasUnrecoveredCodexQuotaRefusal(accountId: string, quotaScope?: CodexQuotaScope): boolean { + if (carriesQuotaRefusal(getAccountHealth(accountId))) return true; + return quotaScope !== undefined && carriesQuotaRefusal(scopedHealthFor(accountId, quotaScope)); +} + export function listScopedHealthEntries(accountId: string): Array<[CodexQuotaScope, CodexUpstreamHealth]> { return [...(quotaScopedHealth.get(accountId) ?? [])]; } diff --git a/src/codex/routing/selection.ts b/src/codex/routing/selection.ts index 2720a5eaafc..9ac5538a000 100644 --- a/src/codex/routing/selection.ts +++ b/src/codex/routing/selection.ts @@ -22,6 +22,7 @@ import { dropSpentCredentialFailure, getAccountHealth, getCodexQuotaHealthSnapshot, + hasUnrecoveredCodexQuotaRefusal, isCodexAccountSoftAvoided, isCodexQuotaAvoided, isIndependentCodexQuotaScope, @@ -276,6 +277,40 @@ export function isCacheAffinityEnabled(config: OcxConfig): boolean { return config.pool?.cacheAffinity !== false; } +/** + * Whether quota may retire shared state while cache affinity is active. + * + * A threshold crossing is a hint that an account is getting busy, not evidence it cannot + * serve — the same bar {@link mayRebindAffinityForQuota} applies to a live binding. Shared + * state held across a model detour gets that exhaustion boundary for the same reason: the + * detour is request-scoped, so retiring the binding over a hint pays a cold prefix for + * nothing. New/unbound selection still reads {@link hasCodexQuotaHeadroom}; only + * preservation of an existing shared selection or thread binding qualifies here. Like the + * live-binding rule, the configured threshold plays no role once retention applies: a + * genuinely exhausted (>=100%) account releases even with threshold switching disabled, + * while the fallback above keeps a disabled threshold's "never drained on quota alone". + */ +export function hasCodexSharedStateQuotaHeadroom( + config: OcxConfig, + accountId: string, + quotaScope: CodexQuotaScope | undefined, + selectionOptions?: CodexAccountUsabilityOptions, + now: number = Date.now(), +): boolean { + if ( + !isCacheAffinityEnabled(config) + || accountPoolStrategyForScope(config, quotaScope) !== "quota" + ) { + return hasCodexQuotaHeadroom(config, accountId, selectionOptions, now); + } + const usage = computeCodexUsageScore( + getAccountQuota(accountId), + getPoolAccountPlanForSelection(config, accountId, selectionOptions), + now, + ); + return isUnknownUsage(usage) || usage < 100; +} + /** Earliest future shared short/weekly reset; missing evidence and ties use usage order. */ export function pickResetFirstCodexAccount( config: OcxConfig, @@ -726,7 +761,8 @@ export function isHealthySharedCodexSelection( selectionOptions: CodexAccountUsabilityOptions | undefined, ): boolean { return isCodexAccountSelectable(config, accountId, now, quotaScope, selectionOptions) - && hasCodexQuotaHeadroom(config, accountId, selectionOptions, now) + && hasCodexSharedStateQuotaHeadroom(config, accountId, quotaScope, selectionOptions, now) + && !hasUnrecoveredCodexQuotaRefusal(accountId, quotaScope) && !shouldFailover(config, accountId, now); } diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index c9479b1b051..0fde5683acd 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -387,8 +387,11 @@ has headroom, auth resolution validates the caller bearer's own gated-model rost request-owned credential before stored-Pool selection. The credential never enters Pool persistence, affinity, entitlement cache, or health state, and this decision never reads the physical main credential. If the caller lacks the requested model, a stored-account model detour may serve the request without -clearing the healthy shared main pin. A paused or quota-drained main skips this exception and follows the -ordinary Pool promotion path. +clearing the healthy shared main pin. With quota-strategy cache affinity, the same detour preserves an +ordinary added-account binding and shared selection beyond the proactive-switch threshold until genuine +exhaustion; pause, cooldown, reauthentication, quota refusal, and failover evidence still retire shared +state normally. A paused or quota-drained main skips the request-owned credential exception and follows +the ordinary Pool promotion path. > Decision record: [ADR-0086](../decisions/ADR-0086-public-provider-contract.md) diff --git a/tests/codex-integration/codex-routing.test.ts b/tests/codex-integration/codex-routing.test.ts index b769a74c707..724c8a18d67 100644 --- a/tests/codex-integration/codex-routing.test.ts +++ b/tests/codex-integration/codex-routing.test.ts @@ -2477,6 +2477,89 @@ describe("codex account selection order", () => { expect(resolveCodexAccountForThread("model-gated-task", config, now + 2, "shared")).toBe("b"); }); + test("cache affinity preserves an over-threshold shared binding across a model detour", () => { + const config = orderedConfig({ + accountPoolStrategy: "quota", + activeCodexAccountId: "a", + activeCodexAccountPinned: "a", + autoSwitchThreshold: 80, + pool: { cacheAffinity: true }, + }); + const now = Date.now(); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + + expect(resolveCodexAccountForThread("cache-affine-model-detour", config, now, "shared")).toBe("a"); + updateAccountQuota("a", 90); + expect(resolveCodexAccountForThreadDetailed( + "cache-affine-model-detour", + config, + now + 1, + "shared", + { modelEligibleAccountIds: new Set(["b"]) }, + )).toMatchObject({ status: "selected", accountId: "b" }); + + expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); + expect(resolveCodexAccountForThread("cache-affine-model-detour", config, now + 2, "shared")).toBe("a"); + }); + + test("cache affinity releases a fully exhausted shared binding across a model detour", () => { + const config = orderedConfig({ + accountPoolStrategy: "quota", + activeCodexAccountId: "a", + activeCodexAccountPinned: "a", + autoSwitchThreshold: 80, + pool: { cacheAffinity: true }, + }); + const now = Date.now(); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + + expect(resolveCodexAccountForThread("cache-affine-exhausted-detour", config, now, "shared")).toBe("a"); + updateAccountQuota("a", 100); + expect(resolveCodexAccountForThreadDetailed( + "cache-affine-exhausted-detour", + config, + now + 1, + "shared", + { modelEligibleAccountIds: new Set(["b"]) }, + )).toMatchObject({ status: "selected", accountId: "b" }); + + // Genuine exhaustion is the live-binding bar: the shared cursor follows the account + // that actually served instead of staying parked on the drained one. + expect(getEffectiveActiveCodexAccountId(config)).toBe("b"); + expect(resolveCodexAccountForThread("cache-affine-exhausted-detour", config, now + 2, "shared")).toBe("b"); + }); + + test("cache affinity releases an exhausted shared binding even with quota switching disabled", () => { + const config = orderedConfig({ + accountPoolStrategy: "quota", + activeCodexAccountId: "a", + activeCodexAccountPinned: "a", + autoSwitchThreshold: 0, + pool: { cacheAffinity: true }, + }); + const now = Date.now(); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + + expect(resolveCodexAccountForThread("cache-affine-disabled-detour", config, now, "shared")).toBe("a"); + updateAccountQuota("a", 100); + expect(resolveCodexAccountForThreadDetailed( + "cache-affine-disabled-detour", + config, + now + 1, + "shared", + { modelEligibleAccountIds: new Set(["b"]) }, + )).toMatchObject({ status: "selected", accountId: "b" }); + + // Genuine exhaustion drops the binding even when threshold switching is disabled -- + // the same >=100% boundary a live binding gets -- and the shared selection follows + // the account that actually served. + expect(getEffectiveActiveCodexAccountId(config)).toBe("b"); + expect(resolveCodexAccountForThread("cache-affine-disabled-detour", config, now + 2, "shared")).toBe("b"); + }); + test("repeated model-gated round-robin requests reuse a separate detour affinity", () => { const now = 1_800_000_000_000; const threadId = "model-detour-affinity"; @@ -3174,7 +3257,7 @@ describe("codex account selection order", () => { activeCodexAccountPinned: "b", }); updateAccountQuota("a", 10); - updateAccountQuota("b", 90); + updateAccountQuota("b", 100); expect(resolveCodexAccountForThreadDetailed( null, @@ -3218,7 +3301,7 @@ describe("codex account selection order", () => { activeCodexAccountPinned: "b", }); updateAccountQuota("a", 10); - updateAccountQuota("b", 90); + updateAccountQuota("b", 100); expect(resolveCodexAccountForThreadDetailed( null,