Skip to content
Closed
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
46 changes: 5 additions & 41 deletions src/codex/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
type CodexUpstreamOutcomeMeta,
} from "./routing/cooldown-math";
import {
carriesQuotaRefusal,
codexPoolKeyForScope,
codexQuotaScopeForModel,
deleteAccountHealth,
Expand All @@ -37,6 +38,7 @@ import {
getCodexAccountCooldownUntil,
getCodexAccountSoftAvoidUntil,
getCodexQuotaHealthSnapshot,
hasUnrecoveredCodexQuotaRefusal,
isCodexAccountSoftAvoided,
isCodexQuotaAvoided,
isHealthAccountAdmissible,
Expand Down Expand Up @@ -92,6 +94,7 @@ import {
getEligiblePoolAccounts,
getPoolAccountPlanForSelection,
hasCodexQuotaHeadroom,
hasCodexSharedStateQuotaHeadroom,
isCodexAccountPlanExcluded,
isCodexAccountSelectable,
isHealthySharedCodexSelection,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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);
Expand Down
39 changes: 39 additions & 0 deletions src/codex/routing/health-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ?? [])];
}
Expand Down
38 changes: 37 additions & 1 deletion src/codex/routing/selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
dropSpentCredentialFailure,
getAccountHealth,
getCodexQuotaHealthSnapshot,
hasUnrecoveredCodexQuotaRefusal,
isCodexAccountSoftAvoided,
isCodexQuotaAvoided,
isIndependentCodexQuotaScope,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}

Expand Down
7 changes: 5 additions & 2 deletions structure/providers/openai-tiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
87 changes: 85 additions & 2 deletions tests/codex-integration/codex-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -3174,7 +3257,7 @@ describe("codex account selection order", () => {
activeCodexAccountPinned: "b",
});
updateAccountQuota("a", 10);
updateAccountQuota("b", 90);
updateAccountQuota("b", 100);

expect(resolveCodexAccountForThreadDetailed(
null,
Expand Down Expand Up @@ -3218,7 +3301,7 @@ describe("codex account selection order", () => {
activeCodexAccountPinned: "b",
});
updateAccountQuota("a", 10);
updateAccountQuota("b", 90);
updateAccountQuota("b", 100);

expect(resolveCodexAccountForThreadDetailed(
null,
Expand Down
Loading