Skip to content
Merged
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
24 changes: 21 additions & 3 deletions docs-site/src/content/docs/reference/configuration/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -1158,6 +1158,7 @@ second account.
| --- | --- | --- | --- |
| `oauthAccountFailover.enabled?` | `boolean` | presence-driven | Global override for the **pre-dispatch account preference** only. `false` stops a healthy request being steered toward the account with more known headroom. It does **not** disable 429 rotation. |
| `providers.<name>.oauthAccountFailover.enabled?` | `boolean` | inherits | Per-provider override for the same preference; beats the global setting in either direction. `false` declines the preference for this provider even when the global setting is `true`, and `true` opts this provider in even when the global setting is `false`. Reactive 429 rotation is unaffected either way. |
| `providers.<name>.oauthAccountFailover.sessionAffinity?` | `"conversation" \| "none"` | `"none"` | Opt-in conversation pinning for generic OAuth pools. Requires proactive `enabled: true` (provider or inherited global value). Existing sessions keep their account ahead of the new-session strategy until it becomes unavailable. |
| `providers.<name>.oauthAccountFailover.strategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first" \| "least-loaded"` (Kiro only) | — | Kiro's `least-loaded` picks the eligible account with the fewest in-flight requests, using account order for ties. It requires `pool.kernel` and proactive preference; other strategies retain their existing rules. `reset-first` also requires `pool.kernel`: among eligible accounts that are not exhausted, the first attempt (while the proactive preference is on) and a 429 rotation both prefer the account whose quota resets soonest (the weekly window when one is reported, otherwise the 5-hour window), with more headroom breaking ties. Exhausted accounts go last. Without quota evidence, or when a passive provider has evidence for only some accounts, it falls back to headroom order. The Anthropic pool does not accept it. |
| `providers.kiro.oauthAccountFailover.maxConcurrentPerAccount?` | integer 1–100 | unlimited | A process-local per-account queue holds a slot through response completion or cancellation. A full selected account waits at most 250 ms, then returns 503 `account_capacity` with `Retry-After: 1`. The cap does not move the request to another account. |
| `providers.<name>.oauthAccountFailover.autoSwitchThreshold?` | `number` | `80` | 0–100 usage percent at which `fill-first` advances off the active account (#695). Set with `ocx account auto-switch <provider> threshold <n>`. Read only under `pool.kernel` with `strategy: "fill-first"`; an account with no measured usage counts as under the threshold. |
Expand Down Expand Up @@ -1187,9 +1188,26 @@ and the `ocx account strategy` / `ocx account auto-switch` / `ocx account sticky
preference. `quotaWindow` is not part of the generic contract. Codex (`/api/codex-auth`) and Anthropic
(`anthropicAccountPool`) keep their own contracts unchanged.

Deliberately narrower than `anthropicAccountPool`: no session affinity, no quota-ranked
selection, no probe leases. It answers one question — the account that just returned 429 is
cooled, is there another one available.
Set `providers.<name>.oauthAccountFailover.sessionAffinity` to `"conversation"` together with
`enabled: true` to keep a conversation on its admitted account. Session identity uses the client
thread marker, session headers, thread header, then `prompt_cache_key`; a shared Desktop cache
cohort is excluded. Bindings are isolated by provider, configuration home and authenticated caller
principal. Unauthenticated local callers share the local scope. No session marker means no binding.

New sessions use the existing strategy (`pool.kernel` still gates round-robin/fill-first).
Existing sessions stay pinned even when another account has more quota. A pause, reauthentication
requirement, family cooldown or incompatible model roster releases the binding; existing bounded
failure recovery binds the admitted replacement. Kiro's concurrency cap still waits or refuses
rather than moving a healthy bound conversation merely to balance load.

In this mode automatic choices, including unkeyed requests and failovers, stay request-local:
the dashboard's selected account remains the operator's selection. Request history still attributes
the serving account. Manual selection, same-account reselection and roster/policy revision changes
invalidate provider bindings, including writes from another process. An ordinary refresh keeps the
same account. Fill-first retains its current account in memory without changing the saved selection.
Bindings expire after 24 hours without admission, are bounded to 2,000 entries process-wide
(including fill-first cursors), and are lost on restart. Set `sessionAffinity: "none"` or disable
proactive preference to release them. Codex and Anthropic retain their own affinity contracts.

The Codex pool and the Anthropic pool are excluded and keep their own rotation; enabling this
changes neither. A provider with a single stored account is a strict no-op, and no cooldown is
Expand Down
2 changes: 2 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,8 @@
"antigravity-account-roster.test.ts": "adapters/google",
"gemini-inline.test.ts": "images",
"gemini-web-search.test.ts": "adapters/google",
"generic-session-affinity.test.ts": "oauth",
"server-generic-oauth-affinity.test.ts": "server",
"generic-oauth-failover.test.ts": "oauth",
"generic-oauth-refusal-composition.test.ts": "oauth", "github-copilot-auto.test.ts": "providers/github-copilot", "github-copilot-auto-runtime.test.ts": "providers/github-copilot", "github-copilot-catalog-hints.test.ts": "providers/github-copilot",
"github-copilot-account-origin.test.ts": "providers/github-copilot",
Expand Down
22 changes: 1 addition & 21 deletions src/oauth/anthropic-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import { clearAllAnthropicFamilyQuota, anthropicModelQuotaFor, anthropicModelPer
*/
import { clearAllAnthropicRatePauses, anthropicRatePolicyFor, classifyAnthropic429, anthropicRetryAfterMs } from "./anthropic-rate-limit-policy";
import { REFRESH_SKEW_MS } from "./refresh-policy";
import { createHash } from "node:crypto";
import { captureOAuthAccountSelection, commitOAuthAccountSelection, credentialGeneration, getAccountSet, getAccountCredential, getAccountCredentialWithStatus } from "./store";
import type { OAuthAccessSnapshot } from "./index";
import { getCachedProviderAccountQuota } from "../providers/quota";
Expand Down Expand Up @@ -273,26 +272,7 @@ export function formatAnthropicProviderForLog(
return `${providerName}-${formatAnthropicAccountOrdinal(accountId)}`;
}

export function anthropicSessionKeyFromParts(input: {
sessionIdHeader?: string | null;
threadIdHeader?: string | null;
promptCacheKey?: string | null;
clientThreadId?: string | null;
/** When true, prompt_cache_key is a shared Desktop cohort — ignore it for affinity. */
promptCacheKeyIsSharedCohort?: boolean;
}): string | null {
const preferred = input.clientThreadId?.trim()
|| input.sessionIdHeader?.trim()
|| input.threadIdHeader?.trim()
|| "";
if (preferred) {
return preferred.length <= 128 ? preferred : createHash("sha256").update(preferred).digest("hex");
}
if (input.promptCacheKeyIsSharedCohort) return null;
const cacheKey = input.promptCacheKey?.trim() ?? "";
if (!cacheKey) return null;
return cacheKey.length <= 128 ? cacheKey : createHash("sha256").update(cacheKey).digest("hex");
}
export { oauthSessionKeyFromParts as anthropicSessionKeyFromParts } from "./session-affinity-key";


export type AnthropicCooldownRecoveryClaim = Readonly<{
Expand Down
17 changes: 12 additions & 5 deletions src/oauth/generic-account-failover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import { isBuiltinAnthropicInstanceRow } from "../providers/anthropic-instance";
* exists is Anthropic's — behind its own opt-in. So xAI, Cursor, Kimi, GitHub Copilot,
* Antigravity and Nous have no recovery path on a 429 even with several accounts logged in.
*
* Deliberately narrower than the Anthropic pool: no session affinity, no quota-ranked selection,
* no probe leases. Those carry provider-specific meaning; this module only answers "the account
* that just 429'd is cooled, is there another one we may use".
* Conversation ownership lives in generic-session-affinity.ts; this module owns account
* eligibility, quota-ranked preference and bounded refusal recovery. Probe leases remain
* provider-specific.
*
* NOT a home for Codex (`codex/routing.ts` owns quota scopes and probe leases) or Anthropic
* (`oauth/anthropic-routing.ts` owns affinity and a fail-closed local-cli credential rule).
Expand Down Expand Up @@ -420,6 +420,7 @@ function pickFillFirstGenericAccount(
now: number,
requestedModelId?: string | null,
accountRows?: ReadonlyMap<string, ProviderAccount>,
preferBelowThreshold = false,
): string | null {
const stableAll = stableGenericRoster(providerName);
if (stableAll.length < 2) return null;
Expand All @@ -435,6 +436,11 @@ function pickFillFirstGenericAccount(
}
const start = activeId ? stableAll.indexOf(activeId) : -1;
const ring = start >= 0 ? [...stableAll.slice(start + 1), ...stableAll.slice(0, start)] : stableAll;
if (preferBelowThreshold) {
const below = ring.find(id => id !== activeId && eligible.has(id)
&& !isOverAutoSwitchThreshold(providerName, id, threshold, requestedModelId, accountRows?.get(id)));
if (below) return below;
}
for (const id of ring) {
if (id !== activeId && eligible.has(id)) return id;
}
Expand Down Expand Up @@ -642,6 +648,7 @@ export function preferredInitialAccount(
providerName: string,
now = Date.now(),
requestedModelId?: string | null,
activeAccountOverride?: string,
): string | null {
// The PROACTIVE predicate, not the reactive one: this steers a request upstream has not
// refused, so `oauthAccountFailover.enabled: false` must still be able to refuse it.
Expand All @@ -650,7 +657,7 @@ export function preferredInitialAccount(
// active id separately would delay manual selection and account removal.
const selected = getAccountSet(providerName);
if (!selected) return null;
const active = selected.activeAccountId;
const active = activeAccountOverride ?? selected.activeAccountId;
const accountRows = new Map(selected.accounts.map(account => [account.id, account]));
const order = selected.accounts
.filter(account => account.paused !== true && account.needsReauth !== true)
Expand Down Expand Up @@ -702,7 +709,7 @@ export function preferredInitialAccount(
return picked && picked !== active ? picked : null;
}
if (strategy === "fill-first") {
const picked = pickFillFirstGenericAccount(config, providerName, active, now, requestedModelId, accountRows);
const picked = pickFillFirstGenericAccount(config, providerName, active, now, requestedModelId, accountRows, activeAccountOverride !== undefined);
return picked && picked !== active ? picked : null;
}
if (strategy === "reset-first") {
Expand Down
125 changes: 125 additions & 0 deletions src/oauth/generic-session-affinity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/** Request-local conversation ownership for opt-in generic OAuth pools. No credentials are retained. */
import { createHash } from "node:crypto";
import { getConfigDir } from "../config/paths";
import { subscribeAccountSelections } from "../lib/account-selection-events";
import type { OcxConfig } from "../types";
import { classifyModelFamilyForQuota } from "./account-quota-rank";
import { eligibleFailoverAccounts, isGenericFailoverProvider, preferKiroModelSupport, preferredInitialAccount } from "./generic-account-failover";
import type { OAuthAccessSnapshot } from "./index";
import { captureOAuthAccountSelection, credentialGeneration, getAccountCredentialWithStatus } from "./store";
import type { OAuthAccountSelection } from "./types";

const IDLE_TTL_MS = 24 * 60 * 60_000;
const MAX_ENTRIES = 2_000;
const bindings = new Map<string, { accountId: string; lastUsedAt: number; selection: OAuthAccountSelection }>();
const prefix = (provider: string) => `${JSON.stringify([getConfigDir(), provider])}\u0000`;
const key = (provider: string, session: string) => prefix(provider) + "session:" + createHash("sha256").update(session).digest("hex");
const cursorKey = (provider: string) => prefix(provider) + "fill-first";

/** A persisted operator/roster/policy change revokes old session choices before its writer returns. */
export function clearGenericSessionAffinity(provider?: string): void {
if (!provider) { bindings.clear(); return; }
const scope = prefix(provider);
for (const id of bindings.keys()) if (id.startsWith(scope)) bindings.delete(id);
}
subscribeAccountSelections(event => { if (event.kind === "oauth") clearGenericSessionAffinity(event.provider); });

/** Unlike new-session strategy selection, keeping the surviving account needs no 2+ quorum. */
export function genericSessionAffinityEnabled(config: OcxConfig, provider: string): boolean {
const row = config.providers[provider];
const enabled = !!row && row.disabled !== true && provider !== "anthropic2" && isGenericFailoverProvider(provider, row)
&& row.oauthAccountFailover?.sessionAffinity === "conversation"
&& (row.oauthAccountFailover.enabled ?? config.oauthAccountFailover?.enabled) === true;
if (!enabled) clearGenericSessionAffinity(provider);
return enabled;
}

export function genericSessionAccountEligible(
config: OcxConfig, provider: string, accountId: string, model?: string | null, now = Date.now(), allowActiveFallback = false,
): boolean {
const eligible = eligibleFailoverAccounts(provider, now, classifyModelFamilyForQuota(provider, model));
// Existing conversation ownership is independent of transient per-account load.
if (preferKiroModelSupport(provider, eligible, model).includes(accountId)) return true;
// Cached automatic exclusions remain preferences when no alternative exists. Preserve the
// ordinary active/singleton attempt and its upstream refusal instead of inventing a conflict.
if (!allowActiveFallback || eligible.length > 0 || captureOAuthAccountSelection(provider)?.accountId !== accountId) return false;
const row = getAccountCredentialWithStatus(provider, accountId);
return !!row && !row.paused && !row.needsReauth;
}

/** Peek only. The admitted snapshot, never a speculative token lookup, establishes ownership. */
export function genericSessionAccount(
config: OcxConfig, provider: string, session: string | null, model?: string | null, now = Date.now(),
): string | null {
if (!genericSessionAffinityEnabled(config, provider) || !session) return null;
return readBinding(config, provider, key(provider, session), model, now);
}

function readBinding(config: OcxConfig, provider: string, id: string, model: string | null | undefined, now: number): string | null {
const bound = bindings.get(id);
if (!bound) return null;
const current = captureOAuthAccountSelection(provider);
if (!current || current.accountId !== bound.selection.accountId || current.revision !== bound.selection.revision
|| now - bound.lastUsedAt >= IDLE_TTL_MS || now < bound.lastUsedAt
|| !genericSessionAccountEligible(config, provider, bound.accountId, model, now)) {
bindings.delete(id);
return null;
}
return bound.accountId;
}

/** New sessions use the configured strategy; an ineligible active account cannot defeat release. */
export function preferredGenericSessionAccount(
config: OcxConfig, provider: string, session: string | null, model?: string | null, now = Date.now(),
): string | null {
if (!genericSessionAffinityEnabled(config, provider)) return null;
const bound = genericSessionAccount(config, provider, session, model, now);
if (bound) return bound;
const cursor = readBinding(config, provider, cursorKey(provider), model, now);
const active = cursor ?? captureOAuthAccountSelection(provider)?.accountId;
const preferred = preferredInitialAccount(config, provider, now, model, active);
if (preferred) return preferred;
if (active && genericSessionAccountEligible(config, provider, active, model, now)) return active;
return preferKiroModelSupport(provider,
eligibleFailoverAccounts(provider, now, classifyModelFamilyForQuota(provider, model)), model, config)[0]
?? captureOAuthAccountSelection(provider)?.accountId ?? null;
}

/**
* Validate against the operator's persisted selection without moving it. ALL automatic choices
* in affinity mode use this receipt, including unkeyed requests and failovers, so concurrent
* conversations cannot invalidate each other's selection revision. Manual A→B→A still fences.
*/
export function admitGenericSessionAccount(
config: OcxConfig, provider: string, expected: OAuthAccountSelection,
snapshot: OAuthAccessSnapshot, model?: string | null, modeEnabled = genericSessionAffinityEnabled(config, provider),
): OAuthAccountSelection | null {
if (!modeEnabled || snapshot.provider !== provider) return null;
const current = captureOAuthAccountSelection(provider);
if (!current || current.accountId !== expected.accountId || current.revision !== expected.revision) return null;
const row = getAccountCredentialWithStatus(provider, snapshot.accountId);
if (!row || row.paused || row.needsReauth || credentialGeneration(row.credential) !== snapshot.generation
|| !genericSessionAccountEligible(config, provider, snapshot.accountId, model, Date.now(), true)) return null;
return current;
}

export function bindGenericSessionAccount(
config: OcxConfig, provider: string, session: string | null, accountId: string, model?: string | null, now = Date.now(),
): void {
if (!genericSessionAffinityEnabled(config, provider)
|| !genericSessionAccountEligible(config, provider, accountId, model, now)) return;
for (const [id, entry] of bindings) {
if (now - entry.lastUsedAt >= IDLE_TTL_MS || now < entry.lastUsedAt) bindings.delete(id);
}
const selection = captureOAuthAccountSelection(provider);
if (!selection) return;
const keep = (id: string) => {
bindings.delete(id);
bindings.set(id, { accountId, lastUsedAt: now, selection });
};
const hit = session && genericSessionAccount(config, provider, session, model, now) === accountId;
if (!hit && config.pool?.kernel === true && config.providers[provider]?.oauthAccountFailover?.strategy === "fill-first")
keep(cursorKey(provider));
if (session) keep(key(provider, session));
while (bindings.size > MAX_ENTRIES) bindings.delete(bindings.keys().next().value!);
}
23 changes: 23 additions & 0 deletions src/oauth/session-affinity-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createHash } from "node:crypto";

/** Shared conversation identity precedence; Desktop cohort cache keys never bind sessions. */
export function oauthSessionKeyFromParts(input: {
sessionIdHeader?: string | null;
threadIdHeader?: string | null;
promptCacheKey?: string | null;
clientThreadId?: string | null;
/** When true, prompt_cache_key is a shared Desktop cohort — ignore it for affinity. */
promptCacheKeyIsSharedCohort?: boolean;
}): string | null {
const preferred = input.clientThreadId?.trim()
|| input.sessionIdHeader?.trim()
|| input.threadIdHeader?.trim()
|| "";
if (preferred) {
return preferred.length <= 128 ? preferred : createHash("sha256").update(preferred).digest("hex");
}
if (input.promptCacheKeyIsSharedCohort) return null;
const cacheKey = input.promptCacheKey?.trim() ?? "";
if (!cacheKey) return null;
return cacheKey.length <= 128 ? cacheKey : createHash("sha256").update(cacheKey).digest("hex");
}
Loading
Loading