From 5237c636b81a040e8092186bf2adba0fd252e665 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:12:55 +0200 Subject: [PATCH 01/15] feat(codex): add account pool round-robin selector --- src/codex/pool-rotation.ts | 129 ++++++++++++++++++++++++++++++ src/types.ts | 10 +++ tests/codex-pool-rotation.test.ts | 38 +++++++++ 3 files changed, 177 insertions(+) create mode 100644 src/codex/pool-rotation.ts create mode 100644 tests/codex-pool-rotation.test.ts diff --git a/src/codex/pool-rotation.ts b/src/codex/pool-rotation.ts new file mode 100644 index 0000000000..67e4019629 --- /dev/null +++ b/src/codex/pool-rotation.ts @@ -0,0 +1,129 @@ +import type { OcxAccountPoolRotationStrategy } from "../types"; + +export const POOL_KEY_CODEX = "codex"; + +interface SelectionState { + activeKey?: string; + successes: number; + currentWeights: Map; +} + +const selectionState = new Map(); + +const DEFAULT_STICKY_LIMIT = 1; +const MIN_STICKY_LIMIT = 1; +const MAX_STICKY_LIMIT = 100; +const DEFAULT_STRATEGY: OcxAccountPoolRotationStrategy = "quota"; +const VALID_STRATEGIES = new Set(["quota", "round-robin", "fill-first"]); + +export function normalizeAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationStrategy { + if (typeof raw === "string" && VALID_STRATEGIES.has(raw as OcxAccountPoolRotationStrategy)) { + return raw as OcxAccountPoolRotationStrategy; + } + return DEFAULT_STRATEGY; +} + +export function normalizeAccountPoolStickyLimit(raw: unknown): number { + if (typeof raw === "number" && Number.isInteger(raw) && raw >= MIN_STICKY_LIMIT && raw <= MAX_STICKY_LIMIT) { + return raw; + } + return DEFAULT_STICKY_LIMIT; +} + +function getOrCreateState(poolKey: string): SelectionState { + let state = selectionState.get(poolKey); + if (!state) { + state = { successes: 0, currentWeights: new Map() }; + selectionState.set(poolKey, state); + } + return state; +} + +function smoothWeightedIndex(ids: string[], state: SelectionState): number { + let best = -1; + let bestScore = Number.NEGATIVE_INFINITY; + let total = 0; + const weight = 1; + for (let i = 0; i < ids.length; i++) { + const id = ids[i]!; + const score = (state.currentWeights.get(id) ?? 0) + weight; + state.currentWeights.set(id, score); + total += weight; + if (score > bestScore) { + best = i; + bestScore = score; + } + } + if (best >= 0) { + const key = ids[best]!; + state.currentWeights.set(key, (state.currentWeights.get(key) ?? 0) - total); + } + return best; +} + +export function pickRoundRobinAccount( + poolKey: string, + eligibleIds: string[], + stickyLimit: number, +): string | null { + if (eligibleIds.length === 0) return null; + + const limit = normalizeAccountPoolStickyLimit(stickyLimit); + const state = getOrCreateState(poolKey); + + if (state.activeKey && eligibleIds.includes(state.activeKey)) { + return state.activeKey; + } + + if (state.activeKey) { + delete state.activeKey; + state.successes = 0; + } + + const index = smoothWeightedIndex(eligibleIds, state); + if (index < 0) return null; + + const picked = eligibleIds[index]!; + if (limit <= 1) { + return picked; + } + + state.activeKey = picked; + state.successes = 0; + return picked; +} + +export function notePoolRotationSuccess( + poolKey: string, + accountId: string, + stickyLimit: number, +): void { + const limit = normalizeAccountPoolStickyLimit(stickyLimit); + const state = selectionState.get(poolKey); + if (!state) return; + if (state.activeKey !== accountId) { + state.activeKey = accountId; + state.successes = 0; + } + state.successes += 1; + if (state.successes >= limit) { + delete state.activeKey; + state.successes = 0; + } +} + +export function notePoolRotationFailure(poolKey: string, accountId: string): void { + const state = selectionState.get(poolKey); + if (state?.activeKey === accountId) { + delete state.activeKey; + state.successes = 0; + } +} + +export function clearPoolRotationState(poolKey?: string): void { + if (poolKey === undefined) { + selectionState.clear(); + return; + } + selectionState.delete(poolKey); +} diff --git a/src/types.ts b/src/types.ts index 622e4ec161..779a7a588d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -682,6 +682,10 @@ export interface OcxConfig { activeCodexAccountId?: string; /** Auto-switch threshold (0-100). Default 80. 0 = disabled. */ autoSwitchThreshold?: number; + /** New-session account rotation strategy for the Codex pool. Default quota (today's behaviour). */ + accountPoolStrategy?: OcxAccountPoolRotationStrategy; + /** Successful new-session binds retained on one round-robin selection. Default 1; range 1..100. */ + accountPoolStickyLimit?: number; /** Consecutive non-2xx upstream responses before switching future new threads. Default 3. 0 = disabled. */ upstreamFailoverThreshold?: number; /** @@ -693,6 +697,10 @@ export interface OcxConfig { enabled?: boolean; /** Usage % threshold for new-session auto-pick. Default 80. 0 = disabled (affinity/active only). */ autoSwitchThreshold?: number; + /** New-session rotation strategy. Default quota (today's behaviour). */ + strategy?: OcxAccountPoolRotationStrategy; + /** Successful new-session binds retained on one round-robin selection. Default 1; range 1..100. */ + stickyLimit?: number; }; /** Virtual `combo/` models spanning concrete provider/model targets (issue #133). */ combos?: Record; @@ -702,6 +710,8 @@ export interface OcxConfig { corsAllowOrigins?: string[]; } +export type OcxAccountPoolRotationStrategy = "quota" | "round-robin" | "fill-first"; + export type OcxComboStrategy = "failover" | "round-robin"; export type OcxComboDefaultEffort = "low" | "medium" | "high" | "xhigh" | "max" | "ultra"; diff --git a/tests/codex-pool-rotation.test.ts b/tests/codex-pool-rotation.test.ts new file mode 100644 index 0000000000..005cb296db --- /dev/null +++ b/tests/codex-pool-rotation.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test, beforeEach } from "bun:test"; +import { + clearPoolRotationState, + notePoolRotationSuccess, + pickRoundRobinAccount, +} from "../src/codex/pool-rotation"; + +describe("pickRoundRobinAccount", () => { + beforeEach(() => clearPoolRotationState()); + + test("spreads successive picks across eligible accounts", () => { + const ids = ["a", "b", "c"]; + const picks = [ + pickRoundRobinAccount("codex", ids, 1), + pickRoundRobinAccount("codex", ids, 1), + pickRoundRobinAccount("codex", ids, 1), + ]; + expect(new Set(picks).size).toBe(3); + }); + + test("stickyLimit holds the same account across success batches", () => { + const ids = ["a", "b"]; + const first = pickRoundRobinAccount("codex", ids, 2); + notePoolRotationSuccess("codex", first!, 2); + const second = pickRoundRobinAccount("codex", ids, 2); + expect(second).toBe(first); + notePoolRotationSuccess("codex", first!, 2); + const third = pickRoundRobinAccount("codex", ids, 2); + expect(third).not.toBe(first); + }); + + test("skips ids not in the eligible list mid-ring", () => { + const a = pickRoundRobinAccount("codex", ["a", "b"], 1); + expect(a).toBeTruthy(); + const next = pickRoundRobinAccount("codex", ["b"], 1); + expect(next).toBe("b"); + }); +}); From 6608de531d4737a028be6062240c89267c4ecf8f Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:20:17 +0200 Subject: [PATCH 02/15] feat(codex): honor accountPoolStrategy for new sessions --- src/codex/routing.ts | 113 ++++++++++++++++++++++++++++++ tests/codex-pool-rotation.test.ts | 109 +++++++++++++++++++++++++++- 2 files changed, 221 insertions(+), 1 deletion(-) diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 87899cd1ae..da2ea8b31d 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -4,6 +4,13 @@ import { isCodexAccountGenerationLive, readCodexAccountRecord } from "./account- import { codexAccountLogLabel } from "./account-label"; import { isCodexAccountUsable } from "./account-usability"; import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; +import { + POOL_KEY_CODEX, + normalizeAccountPoolStickyLimit, + notePoolRotationFailure, + notePoolRotationSuccess, + pickRoundRobinAccount, +} from "./pool-rotation"; import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota"; import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "./main-account"; import type { OcxConfig } from "../types"; @@ -462,6 +469,104 @@ function getEligiblePoolAccounts(config: OcxConfig, excludeId?: string, now = Da return ids; } +function listEligibleCodexAccountIds(config: OcxConfig, now: number): string[] { + return getEligiblePoolAccounts(config, undefined, now); +} + +function stickyLimitForConfig(config: OcxConfig): number { + return normalizeAccountPoolStickyLimit(config.accountPoolStickyLimit); +} + +function isActiveUnderFillFirstThreshold(config: OcxConfig, accountId: string): boolean { + const threshold = config.autoSwitchThreshold ?? 80; + if (threshold <= 0) return true; + const usage = computeCodexUsageScore(getAccountQuota(accountId), getPoolAccountPlan(config, accountId)); + // Unknown usage must not force fill-first to abandon the active account. + if (isUnknownUsage(usage)) return true; + return usage < threshold; +} + +/** + * Fill-first: keep selectable active under threshold; otherwise advance to the next + * eligible id in stable sorted order after the current active (wrapping). + */ +function pickFillFirstCodexAccount(config: OcxConfig, now: number): string | null { + const eligible = listEligibleCodexAccountIds(config, now); + if (eligible.length === 0) return null; + + const active = config.activeCodexAccountId; + if (active && eligible.includes(active) && isActiveUnderFillFirstThreshold(config, active)) { + return active; + } + + const ordered = [...eligible].sort((a, b) => a.localeCompare(b)); + if (!active) return ordered[0] ?? null; + + const allConfigured = [ + ...(isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID) || active === MAIN_CODEX_ACCOUNT_ID + ? [MAIN_CODEX_ACCOUNT_ID] + : []), + ...(config.codexAccounts ?? []).filter(account => !account.isMain).map(account => account.id), + ]; + const stableAll = [...new Set(allConfigured)].sort((a, b) => a.localeCompare(b)); + const startIdx = stableAll.indexOf(active); + if (startIdx < 0) return ordered[0] ?? null; + + for (let step = 1; step <= stableAll.length; step++) { + const candidate = stableAll[(startIdx + step) % stableAll.length]!; + if (eligible.includes(candidate)) return candidate; + } + return ordered[0] ?? null; +} + +/** + * Unbound new-session pick for round-robin / fill-first. Returns null to fall through + * to the legacy quota path (or when the strategy is quota). + * + * When `commit` is true (resolve path), promotes active, binds thread affinity, and + * notes RR success. Preview keeps commit=false so it does not mutate config/affinity + * or advance sticky success counters; RR preview still consults the ring via pick. + */ +function pickUnboundStrategyAccount( + config: OcxConfig, + threadId: string | null, + now: number, + commit: boolean, +): string | null { + const strategy = config.accountPoolStrategy ?? "quota"; + if (strategy === "quota") return null; + + let picked: string | null = null; + if (strategy === "round-robin") { + const eligible = listEligibleCodexAccountIds(config, now); + // Preview must not advance the ring — resolve commits the pick for the request. + if (!commit) { + const active = config.activeCodexAccountId; + if (active && eligible.includes(active)) return active; + return eligible[0] ?? null; + } + const limit = stickyLimitForConfig(config); + picked = pickRoundRobinAccount(POOL_KEY_CODEX, eligible, limit); + if (!picked) return null; + setActiveCodexAccount(config, picked); + if (threadId) bindThreadAffinity(threadId, picked, now); + notePoolRotationSuccess(POOL_KEY_CODEX, picked, limit); + return picked; + } + + if (strategy === "fill-first") { + picked = pickFillFirstCodexAccount(config, now); + if (!picked) return null; + if (commit) { + setActiveCodexAccount(config, picked); + if (threadId) bindThreadAffinity(threadId, picked, now); + } + return picked; + } + + return null; +} + export function getPoolAccountPlan(config: OcxConfig, accountId: string): string | undefined { if (accountId === MAIN_CODEX_ACCOUNT_ID) return getMainAccountPlan(); return (config.codexAccounts ?? []).find(account => !account.isMain && account.id === accountId)?.plan; @@ -585,6 +690,9 @@ export function previewCodexAccountForRequest( // Stale/unusable affinity is ignored for preview (no map mutation). } + const strategyPick = pickUnboundStrategyAccount(config, threadId, now, false); + if (strategyPick) return strategyPick; + let active = config.activeCodexAccountId ?? null; if (!active) { return pickLowestUsageCodexAccount(config, undefined, now); @@ -664,6 +772,10 @@ export function resolveCodexAccountForThreadDetailed( } threadAccountMap.delete(threadId); } + + const strategyPick = pickUnboundStrategyAccount(config, threadId, now, true); + if (strategyPick) return { status: "selected", accountId: strategyPick }; + let active = config.activeCodexAccountId; if (!active) { const selected = pickLowestUsageCodexAccount(config, undefined, now); @@ -784,6 +896,7 @@ export function recordCodexUpstreamOutcome( }), }); clearThreadAccountMapForAccount(accountId); + notePoolRotationFailure(POOL_KEY_CODEX, accountId); if (config.activeCodexAccountId === accountId) { const fallback = pickLowestUsageCodexAccount(config, accountId, now); if (fallback) setActiveCodexAccount(config, fallback); diff --git a/tests/codex-pool-rotation.test.ts b/tests/codex-pool-rotation.test.ts index 005cb296db..a09bdf03bc 100644 --- a/tests/codex-pool-rotation.test.ts +++ b/tests/codex-pool-rotation.test.ts @@ -1,9 +1,53 @@ -import { describe, expect, test, beforeEach } from "bun:test"; +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; import { clearPoolRotationState, notePoolRotationSuccess, pickRoundRobinAccount, } from "../src/codex/pool-rotation"; +import { + clearCodexUpstreamHealth, + clearThreadAccountMap, + resolveCodexAccountForThread, +} from "../src/codex/routing"; +import { saveCodexAccountCredential } from "../src/codex/account-store"; +import { clearAccountQuota, updateAccountQuota } from "../src/codex/auth-api"; +import type { OcxConfig } from "../src/types"; + +const TEST_DIR = join(import.meta.dir, ".tmp-codex-pool-rotation-test"); +let previousOpencodexHome: string | undefined; +let previousCodexHome: string | undefined; + +function makeConfig(overrides: Partial = {}): OcxConfig { + return { + providers: {}, + codexAccounts: [], + activeCodexAccountId: undefined, + autoSwitchThreshold: 80, + ...overrides, + } as OcxConfig; +} + +function saveTestCredential(id: string): void { + saveCodexAccountCredential(id, { + accessToken: `access-${id}`, + refreshToken: `refresh-${id}`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: `acct-${id}`, + }); +} + +function makeThreeAccountConfig(overrides: Partial = {}): OcxConfig { + const ids = ["a", "b", "c"]; + for (const id of ids) saveTestCredential(id); + return makeConfig({ + activeCodexAccountId: "a", + autoSwitchThreshold: 80, + codexAccounts: ids.map(id => ({ id, email: `${id}@example.test`, isMain: false })), + ...overrides, + }); +} describe("pickRoundRobinAccount", () => { beforeEach(() => clearPoolRotationState()); @@ -36,3 +80,66 @@ describe("pickRoundRobinAccount", () => { expect(next).toBe("b"); }); }); + +describe("accountPoolStrategy new-session routing", () => { + beforeEach(() => { + previousOpencodexHome = process.env.OPENCODEX_HOME; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + previousCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = TEST_DIR; + clearThreadAccountMap(); + clearCodexUpstreamHealth(); + clearAccountQuota(); + clearPoolRotationState(); + }); + + afterEach(() => { + clearAccountQuota(); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearPoolRotationState(); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + }); + + test("round-robin strategy rotates unbound new sessions", () => { + const config = makeThreeAccountConfig({ accountPoolStrategy: "round-robin" }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + + const picks = [ + resolveCodexAccountForThread(null, config), + resolveCodexAccountForThread(null, config), + resolveCodexAccountForThread(null, config), + ]; + expect(new Set(picks).size).toBe(3); + }); + + test("affinity still wins over round-robin", () => { + const config = makeThreeAccountConfig({ accountPoolStrategy: "round-robin" }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + + expect(resolveCodexAccountForThread("T", config)).toBe("a"); + config.activeCodexAccountId = "b"; + expect(resolveCodexAccountForThread("T", config)).toBe("a"); + expect(resolveCodexAccountForThread("T", config)).toBe("a"); + }); + + test("omitted strategy preserves quota / active behaviour", () => { + const config = makeThreeAccountConfig(); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + + expect(resolveCodexAccountForThread(null, config)).toBe("a"); + expect(resolveCodexAccountForThread("new-thread", config)).toBe("a"); + }); +}); From da4bcbc5e96a2f5a0c5d04aa9ddfdcb1596adcce Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:26:25 +0200 Subject: [PATCH 03/15] feat(anthropic): account pool rotation strategies --- src/codex/pool-rotation.ts | 1 + src/oauth/anthropic-routing.ts | 105 +++++++++++++++++- src/server/management/oauth-account-routes.ts | 7 ++ tests/anthropic-account-pool.test.ts | 102 ++++++++++++++++- 4 files changed, 210 insertions(+), 5 deletions(-) diff --git a/src/codex/pool-rotation.ts b/src/codex/pool-rotation.ts index 67e4019629..02487887d1 100644 --- a/src/codex/pool-rotation.ts +++ b/src/codex/pool-rotation.ts @@ -1,6 +1,7 @@ import type { OcxAccountPoolRotationStrategy } from "../types"; export const POOL_KEY_CODEX = "codex"; +export const POOL_KEY_ANTHROPIC = "anthropic"; interface SelectionState { activeKey?: string; diff --git a/src/oauth/anthropic-routing.ts b/src/oauth/anthropic-routing.ts index fb29bd5ac9..ab9134f35e 100644 --- a/src/oauth/anthropic-routing.ts +++ b/src/oauth/anthropic-routing.ts @@ -4,7 +4,8 @@ * Default OFF. When enabled: * - Sticky session affinity across requests that share a session key * - 429 cools the failed account and fails over to another eligible account - * - New sessions prefer the lowest known fiveHour usage (#493 cache) when above threshold + * - New sessions use `strategy` (default quota): lowest known fiveHour usage (#493), + * round-robin, or fill-first — affinity still wins for bound sessions * * Intentionally narrower than the Codex pool: no mid-session quota rotation, * soft-avoid ladders, or probe leases. Anthropic OAuth is ToS-sensitive. @@ -17,7 +18,15 @@ import { createHash } from "node:crypto"; import { setActiveAccount, getAccountSet, getAccountCredential } from "./store"; import { getCachedProviderAccountQuota } from "../providers/quota"; import { fallbackCodexAccountLogLabel } from "../codex/account-label"; -import type { OcxConfig } from "../types"; +import { + normalizeAccountPoolStickyLimit, + normalizeAccountPoolStrategy, + notePoolRotationFailure, + notePoolRotationSuccess, + pickRoundRobinAccount, + POOL_KEY_ANTHROPIC, +} from "../codex/pool-rotation"; +import type { OcxAccountPoolRotationStrategy, OcxConfig } from "../types"; const PROVIDER = "anthropic"; const DEFAULT_COOLDOWN_MS = 60_000; @@ -33,6 +42,10 @@ export interface AnthropicAccountPoolConfig { enabled?: boolean; /** Usage % for new-session pick. Default 80. 0 = disable quota-based pick (active / affinity only). */ autoSwitchThreshold?: number; + /** New-session rotation strategy. Default quota (today's behaviour). */ + strategy?: OcxAccountPoolRotationStrategy; + /** Successful new-session binds retained on one round-robin selection. Default 1; range 1..100. */ + stickyLimit?: number; } interface AccountHealth { @@ -187,6 +200,8 @@ export type AnthropicAccountSelectionReason = | "active" | "lowest-usage" | "only-eligible" + | "round-robin" + | "fill-first" | "none" | "all-cooled"; @@ -195,6 +210,79 @@ export interface AnthropicAccountSelection { reason: AnthropicAccountSelectionReason; } +function stickyLimitForPool(config: OcxConfig): number { + return normalizeAccountPoolStickyLimit(anthropicAccountPoolConfig(config).stickyLimit); +} + +function anthropicPoolStrategy(config: OcxConfig): OcxAccountPoolRotationStrategy { + return normalizeAccountPoolStrategy(anthropicAccountPoolConfig(config).strategy); +} + +function isActiveUnderFillFirstThreshold(config: OcxConfig, accountId: string): boolean { + const threshold = anthropicAutoSwitchThreshold(config); + if (threshold <= 0) return true; + // Unknown usage must not force fill-first to abandon the active account. + if (!hasKnownUsage(accountId)) return true; + return usageScore(accountId) < threshold; +} + +/** + * Fill-first: keep eligible active under threshold; otherwise advance to the next + * eligible id in stable sorted order after the current active (wrapping). + */ +function pickFillFirstAnthropicAccount(config: OcxConfig, now: number): string | null { + const eligible = getEligibleAnthropicAccounts(now); + if (eligible.length === 0) return null; + + const set = getAccountSet(PROVIDER); + const active = set?.activeAccountId; + if (active && eligible.includes(active) && isActiveUnderFillFirstThreshold(config, active)) { + return active; + } + + const ordered = [...eligible].sort((a, b) => a.localeCompare(b)); + if (!active || !set) return ordered[0] ?? null; + + const stableAll = [...set.accounts.map(a => a.id)].sort((a, b) => a.localeCompare(b)); + const startIdx = stableAll.indexOf(active); + if (startIdx < 0) return ordered[0] ?? null; + + for (let step = 1; step <= stableAll.length; step++) { + const candidate = stableAll[(startIdx + step) % stableAll.length]!; + if (eligible.includes(candidate)) return candidate; + } + return ordered[0] ?? null; +} + +/** + * Unbound new-session pick for round-robin / fill-first. Returns null to fall through + * to the legacy quota path (or when the strategy is quota). + */ +function pickUnboundStrategyAccount( + config: OcxConfig, + now: number, +): { accountId: string; reason: "round-robin" | "fill-first" } | null { + const strategy = anthropicPoolStrategy(config); + if (strategy === "quota") return null; + + if (strategy === "round-robin") { + const eligible = getEligibleAnthropicAccounts(now); + const limit = stickyLimitForPool(config); + const picked = pickRoundRobinAccount(POOL_KEY_ANTHROPIC, eligible, limit); + if (!picked) return null; + notePoolRotationSuccess(POOL_KEY_ANTHROPIC, picked, limit); + return { accountId: picked, reason: "round-robin" }; + } + + if (strategy === "fill-first") { + const picked = pickFillFirstAnthropicAccount(config, now); + if (!picked) return null; + return { accountId: picked, reason: "fill-first" }; + } + + return null; +} + /** * Resolve which Anthropic OAuth account should serve this session. * When the pool is disabled, always returns the store's active account. @@ -225,6 +313,18 @@ export function resolveAnthropicAccountForSession( } } + const strategyPick = pickUnboundStrategyAccount(config, now); + if (strategyPick) { + if (strategyPick.accountId !== set.activeAccountId) { + promoteAnthropicActiveAccount(strategyPick.accountId); + } + if (key) { + sessionAffinity.set(key, { accountId: strategyPick.accountId, lastUsedAt: now }); + pruneExpiredAffinity(now); + } + return { accountId: strategyPick.accountId, reason: strategyPick.reason }; + } + const threshold = anthropicAutoSwitchThreshold(config); const activeOk = set.accounts.some(a => a.id === set.activeAccountId && a.needsReauth !== true) && !isCooled(set.activeAccountId, now) @@ -309,6 +409,7 @@ export function rotateAnthropicAccountOn429( cooldownSource: parsedRetry ? "retry-after" : "default", }); clearAnthropicSessionAffinityForAccount(failedAccountId); + notePoolRotationFailure(POOL_KEY_ANTHROPIC, failedAccountId); const next = pickLowestUsage(failedAccountId, now); if (!next) { diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 43ee440433..6ebc14f74d 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -253,6 +253,13 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< config.anthropicAccountPool = { enabled: body.enabled, autoSwitchThreshold: threshold, + // Preserve strategy fields until Task 4 adds full PATCH validation. + ...(config.anthropicAccountPool?.strategy !== undefined + ? { strategy: config.anthropicAccountPool.strategy } + : {}), + ...(config.anthropicAccountPool?.stickyLimit !== undefined + ? { stickyLimit: config.anthropicAccountPool.stickyLimit } + : {}), }; saveConfigPreservingClaudeCode(config); return jsonResponse({ diff --git a/tests/anthropic-account-pool.test.ts b/tests/anthropic-account-pool.test.ts index 29bcca7db4..e5bfe5c11b 100644 --- a/tests/anthropic-account-pool.test.ts +++ b/tests/anthropic-account-pool.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { clearPoolRotationState } from "../src/codex/pool-rotation"; import { anthropicSessionKeyFromParts, bindAnthropicSessionAffinity, @@ -14,7 +15,7 @@ import { } from "../src/oauth/anthropic-routing"; import { saveCredential, setActiveAccount } from "../src/oauth/store"; import { clearAccountQuotaCache, setCachedProviderAccountQuotaForTests } from "../src/providers/quota"; -import type { OcxConfig } from "../src/types"; +import type { OcxAccountPoolRotationStrategy, OcxConfig } from "../src/types"; const originalHome = process.env.OPENCODEX_HOME; let home: string; @@ -23,11 +24,13 @@ beforeEach(() => { home = mkdtempSync(join(tmpdir(), "ocx-anthropic-pool-")); process.env.OPENCODEX_HOME = home; clearAnthropicAccountPoolState(); + clearPoolRotationState(); clearAccountQuotaCache("anthropic"); }); afterEach(() => { clearAnthropicAccountPoolState(); + clearPoolRotationState(); clearAccountQuotaCache("anthropic"); if (originalHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = originalHome; @@ -58,17 +61,56 @@ async function seedTwoAccounts() { return { aId: a.id, bId: b.id }; } -function cfg(enabled: boolean, threshold = 80): OcxConfig { +function cfg( + enabled: boolean, + threshold = 80, + pool: { strategy?: OcxAccountPoolRotationStrategy; stickyLimit?: number } = {}, +): OcxConfig { return { port: 0, defaultProvider: "anthropic", providers: { anthropic: { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "oauth" }, }, - anthropicAccountPool: { enabled, autoSwitchThreshold: threshold }, + anthropicAccountPool: { + enabled, + autoSwitchThreshold: threshold, + ...pool, + }, } as OcxConfig; } +async function seedThreeAccounts() { + await saveCredential("anthropic", { + access: "access-a", + refresh: "refresh-a", + expires: Date.now() + 3_600_000, + accountId: "uuid-aaaa", + email: "a@example.test", + }); + await saveCredential("anthropic", { + access: "access-b", + refresh: "refresh-b", + expires: Date.now() + 3_600_000, + accountId: "uuid-bbbb", + email: "b@example.test", + }); + await saveCredential("anthropic", { + access: "access-c", + refresh: "refresh-c", + expires: Date.now() + 3_600_000, + accountId: "uuid-cccc", + email: "c@example.test", + }); + const { getAccountSet } = await import("../src/oauth/store"); + const set = getAccountSet("anthropic")!; + const a = set.accounts.find(acc => acc.credential.accountId === "uuid-aaaa")!; + const b = set.accounts.find(acc => acc.credential.accountId === "uuid-bbbb")!; + const c = set.accounts.find(acc => acc.credential.accountId === "uuid-cccc")!; + await setActiveAccount("anthropic", a.id); + return { aId: a.id, bId: b.id, cId: c.id }; +} + describe("anthropic account pool", () => { test("default off always returns the active account", async () => { const { aId, bId } = await seedTwoAccounts(); @@ -153,4 +195,58 @@ describe("anthropic account pool", () => { expect(label).toMatch(/^anthropic-p[a-f0-9]{6}$/); expect(label).not.toContain("deadbeef"); }); + + test("round-robin strategy rotates unbound new sessions", async () => { + const { aId, bId, cId } = await seedThreeAccounts(); + setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 10 }); + setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 10 }); + setCachedProviderAccountQuotaForTests("anthropic", cId, { fiveHourPercent: 10 }); + const config = cfg(true, 80, { strategy: "round-robin" }); + + const picks = [ + resolveAnthropicAccountForSession(null, config).accountId, + resolveAnthropicAccountForSession(null, config).accountId, + resolveAnthropicAccountForSession(null, config).accountId, + ]; + expect(new Set(picks).size).toBe(3); + }); + + test("affinity still wins over round-robin", async () => { + const { aId, bId, cId } = await seedThreeAccounts(); + setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 10 }); + setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 10 }); + setCachedProviderAccountQuotaForTests("anthropic", cId, { fiveHourPercent: 10 }); + const config = cfg(true, 80, { strategy: "round-robin" }); + + const first = resolveAnthropicAccountForSession("T", config); + expect(first.accountId).toBeTruthy(); + const pinned = first.accountId!; + await setActiveAccount("anthropic", pinned === aId ? bId : aId); + expect(resolveAnthropicAccountForSession("T", config).accountId).toBe(pinned); + expect(resolveAnthropicAccountForSession("T", config).accountId).toBe(pinned); + expect(resolveAnthropicAccountForSession("T", config).reason).toBe("affinity"); + }); + + test("omitted strategy preserves quota / active behaviour", async () => { + const { aId, bId, cId } = await seedThreeAccounts(); + setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 10 }); + setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 10 }); + setCachedProviderAccountQuotaForTests("anthropic", cId, { fiveHourPercent: 10 }); + const config = cfg(true); + + expect(resolveAnthropicAccountForSession(null, config).accountId).toBe(aId); + expect(resolveAnthropicAccountForSession("new-sess", config).accountId).toBe(aId); + }); + + test("disabled pool ignores round-robin strategy", async () => { + const { aId } = await seedThreeAccounts(); + const config = cfg(false, 80, { strategy: "round-robin" }); + const picks = [ + resolveAnthropicAccountForSession(null, config).accountId, + resolveAnthropicAccountForSession(null, config).accountId, + resolveAnthropicAccountForSession(null, config).accountId, + ]; + expect(picks).toEqual([aId, aId, aId]); + expect(resolveAnthropicAccountForSession(null, config).reason).toBe("pool-disabled"); + }); }); From 8797e7867458f8290c097be660a719f7215e5dce Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:33:22 +0200 Subject: [PATCH 04/15] feat(api): expose account pool rotation strategy --- src/codex/auth-api.ts | 40 +++ src/codex/pool-rotation.ts | 18 +- src/server/management/oauth-account-routes.ts | 41 ++- tests/account-pool-management-api.test.ts | 276 ++++++++++++++++++ tests/codex-auth-api.test.ts | 8 +- 5 files changed, 369 insertions(+), 14 deletions(-) create mode 100644 tests/account-pool-management-api.test.ts diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 5d1fba5da3..4b38c3742a 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -10,6 +10,12 @@ import { TokenRefreshError, } from "./account-store"; import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle"; +import { + normalizeAccountPoolStickyLimit, + normalizeAccountPoolStrategy, + parseAccountPoolStickyLimit, + parseAccountPoolStrategy, +} from "./pool-rotation"; import { clearCodexAccountCooldown, resetCodexRoutingForManualSelection } from "./routing"; import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens, readCodexTokensResult } from "./auth-collision"; export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision"; @@ -618,6 +624,8 @@ export async function handleCodexAuthAPI( activeCodexAccountId: runtimeConfig.activeCodexAccountId ?? null, autoSwitchThreshold: runtimeConfig.autoSwitchThreshold ?? 80, upstreamFailoverThreshold: runtimeConfig.upstreamFailoverThreshold ?? 3, + accountPoolStrategy: normalizeAccountPoolStrategy(runtimeConfig.accountPoolStrategy), + accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit), }); } @@ -633,6 +641,38 @@ export async function handleCodexAuthAPI( return jsonResponse({ ok: true }); } + if ( + url.pathname === "/api/codex-auth/pool-strategy" + && (req.method === "PUT" || req.method === "PATCH") + ) { + let body: { strategy?: unknown; stickyLimit?: unknown }; + try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } + if (body.strategy === undefined && body.stickyLimit === undefined) { + return jsonResponse({ error: "strategy or stickyLimit required" }, 400); + } + const runtimeConfig = getRuntimeConfig(config); + if (body.strategy !== undefined) { + const strategy = parseAccountPoolStrategy(body.strategy); + if (strategy === null) { + return jsonResponse({ error: 'strategy must be one of: quota, round-robin, fill-first' }, 400); + } + runtimeConfig.accountPoolStrategy = strategy; + } + if (body.stickyLimit !== undefined) { + const stickyLimit = parseAccountPoolStickyLimit(body.stickyLimit); + if (stickyLimit === null) { + return jsonResponse({ error: "stickyLimit must be an integer 1-100" }, 400); + } + runtimeConfig.accountPoolStickyLimit = stickyLimit; + } + saveRuntimeConfig(config, runtimeConfig); + return jsonResponse({ + ok: true, + accountPoolStrategy: normalizeAccountPoolStrategy(runtimeConfig.accountPoolStrategy), + accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit), + }); + } + if (url.pathname === "/api/codex-auth/failover" && req.method === "PUT") { let body: { threshold: number }; try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } diff --git a/src/codex/pool-rotation.ts b/src/codex/pool-rotation.ts index 02487887d1..cf0ae0f07f 100644 --- a/src/codex/pool-rotation.ts +++ b/src/codex/pool-rotation.ts @@ -17,18 +17,28 @@ const MAX_STICKY_LIMIT = 100; const DEFAULT_STRATEGY: OcxAccountPoolRotationStrategy = "quota"; const VALID_STRATEGIES = new Set(["quota", "round-robin", "fill-first"]); -export function normalizeAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationStrategy { +/** Strict parse for management APIs — returns null instead of defaulting. */ +export function parseAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationStrategy | null { if (typeof raw === "string" && VALID_STRATEGIES.has(raw as OcxAccountPoolRotationStrategy)) { return raw as OcxAccountPoolRotationStrategy; } - return DEFAULT_STRATEGY; + return null; } -export function normalizeAccountPoolStickyLimit(raw: unknown): number { +/** Strict parse for management APIs — returns null instead of defaulting. */ +export function parseAccountPoolStickyLimit(raw: unknown): number | null { if (typeof raw === "number" && Number.isInteger(raw) && raw >= MIN_STICKY_LIMIT && raw <= MAX_STICKY_LIMIT) { return raw; } - return DEFAULT_STICKY_LIMIT; + return null; +} + +export function normalizeAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationStrategy { + return parseAccountPoolStrategy(raw) ?? DEFAULT_STRATEGY; +} + +export function normalizeAccountPoolStickyLimit(raw: unknown): number { + return parseAccountPoolStickyLimit(raw) ?? DEFAULT_STICKY_LIMIT; } function getOrCreateState(poolKey: string): SelectionState { diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 6ebc14f74d..ef47c7b443 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -30,6 +30,12 @@ import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearProviderQuotaCache, fetchProviderAccountQuotas, fetchProviderQuotaReports, supportsPerAccountQuota } from "../../providers/quota"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { clearThreadAccountMap } from "../../codex/routing"; +import { + normalizeAccountPoolStickyLimit, + normalizeAccountPoolStrategy, + parseAccountPoolStickyLimit, + parseAccountPoolStrategy, +} from "../../codex/pool-rotation"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; import { resolveCodexHomeDir } from "../../codex/home"; @@ -217,7 +223,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< return jsonResponse({ ok: true, provider, activeAccountId: body.accountId }); } - // Opt-in Anthropic OAuth account pool (#294): enable/threshold + clear cooldown. + // Opt-in Anthropic OAuth account pool (#294): enable/threshold/strategy + clear cooldown. if (url.pathname === "/api/oauth/accounts/pool" && req.method === "GET") { const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase(); if (provider !== "anthropic") return jsonResponse({ error: "pool config is only supported for anthropic" }, 400); @@ -226,14 +232,18 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< provider, enabled: pool.enabled === true, autoSwitchThreshold: typeof pool.autoSwitchThreshold === "number" ? pool.autoSwitchThreshold : 80, + strategy: normalizeAccountPoolStrategy(pool.strategy), + stickyLimit: normalizeAccountPoolStickyLimit(pool.stickyLimit), experimental: true, }); } - if (url.pathname === "/api/oauth/accounts/pool" && req.method === "PUT") { + if (url.pathname === "/api/oauth/accounts/pool" && (req.method === "PUT" || req.method === "PATCH")) { const body = await req.json().catch(() => ({})) as { provider?: unknown; enabled?: unknown; autoSwitchThreshold?: unknown; + strategy?: unknown; + stickyLimit?: unknown; }; const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : ""; if (provider !== "anthropic") return jsonResponse({ error: "pool config is only supported for anthropic" }, 400); @@ -250,16 +260,27 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< } threshold = body.autoSwitchThreshold; } + let strategy = config.anthropicAccountPool?.strategy; + if (body.strategy !== undefined) { + const parsed = parseAccountPoolStrategy(body.strategy); + if (parsed === null) { + return jsonResponse({ error: "strategy must be one of: quota, round-robin, fill-first" }, 400); + } + strategy = parsed; + } + let stickyLimit = config.anthropicAccountPool?.stickyLimit; + if (body.stickyLimit !== undefined) { + const parsed = parseAccountPoolStickyLimit(body.stickyLimit); + if (parsed === null) { + return jsonResponse({ error: "stickyLimit must be an integer 1-100" }, 400); + } + stickyLimit = parsed; + } config.anthropicAccountPool = { enabled: body.enabled, autoSwitchThreshold: threshold, - // Preserve strategy fields until Task 4 adds full PATCH validation. - ...(config.anthropicAccountPool?.strategy !== undefined - ? { strategy: config.anthropicAccountPool.strategy } - : {}), - ...(config.anthropicAccountPool?.stickyLimit !== undefined - ? { stickyLimit: config.anthropicAccountPool.stickyLimit } - : {}), + ...(strategy !== undefined ? { strategy } : {}), + ...(stickyLimit !== undefined ? { stickyLimit } : {}), }; saveConfigPreservingClaudeCode(config); return jsonResponse({ @@ -267,6 +288,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< provider, enabled: body.enabled, autoSwitchThreshold: threshold, + strategy: normalizeAccountPoolStrategy(strategy), + stickyLimit: normalizeAccountPoolStickyLimit(stickyLimit), experimental: true, }); } diff --git a/tests/account-pool-management-api.test.ts b/tests/account-pool-management-api.test.ts new file mode 100644 index 0000000000..4bb2a9387a --- /dev/null +++ b/tests/account-pool-management-api.test.ts @@ -0,0 +1,276 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleCodexAuthAPI } from "../src/codex/auth-api"; +import { saveConfig } from "../src/config"; +import { startServer } from "../src/server"; +import type { OcxConfig } from "../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; + +function makeCodexConfig(overrides: Partial = {}): OcxConfig { + return { + port: 10100, + providers: {}, + defaultProvider: "openai", + codexAccounts: [], + ...overrides, + }; +} + +describe("Codex account pool strategy management API", () => { + const TEST_DIR = join(import.meta.dir, ".tmp-account-pool-mgmt-codex"); + let previousOpencodexHome: string | undefined; + + beforeEach(() => { + previousOpencodexHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = TEST_DIR; + }); + + afterEach(() => { + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + test("GET /api/codex-auth/active surfaces strategy defaults", async () => { + const req = new Request("http://localhost/api/codex-auth/active", { method: "GET" }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeCodexConfig()); + expect(resp!.status).toBe(200); + expect(await resp!.json()).toMatchObject({ + accountPoolStrategy: "quota", + accountPoolStickyLimit: 1, + }); + }); + + test("GET /api/codex-auth/active surfaces configured strategy", async () => { + const config = makeCodexConfig({ + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 3, + }); + const req = new Request("http://localhost/api/codex-auth/active", { method: "GET" }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(await resp!.json()).toMatchObject({ + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 3, + }); + }); + + test("PUT /api/codex-auth/pool-strategy rejects invalid strategy", async () => { + for (const bad of ["weighted", "", 1, null, "Quota"]) { + const req = new Request("http://localhost/api/codex-auth/pool-strategy", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ strategy: bad }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeCodexConfig()); + expect(resp!.status).toBe(400); + } + }); + + test("PUT /api/codex-auth/pool-strategy rejects invalid stickyLimit", async () => { + for (const bad of [0, 101, 1.5, "2", null, Number.NaN]) { + const req = new Request("http://localhost/api/codex-auth/pool-strategy", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ stickyLimit: bad }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeCodexConfig()); + expect(resp!.status).toBe(400); + } + }); + + test("PUT /api/codex-auth/pool-strategy accepts valid values and mutates runtime", async () => { + const config = makeCodexConfig(); + const req = new Request("http://localhost/api/codex-auth/pool-strategy", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ strategy: "fill-first", stickyLimit: 7 }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(resp!.status).toBe(200); + expect(await resp!.json()).toMatchObject({ + ok: true, + accountPoolStrategy: "fill-first", + accountPoolStickyLimit: 7, + }); + expect(config.accountPoolStrategy).toBe("fill-first"); + expect(config.accountPoolStickyLimit).toBe(7); + }); + + test("PATCH /api/codex-auth/pool-strategy accepts round-robin", async () => { + const config = makeCodexConfig({ accountPoolStrategy: "quota", accountPoolStickyLimit: 1 }); + const req = new Request("http://localhost/api/codex-auth/pool-strategy", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ strategy: "round-robin", stickyLimit: 2 }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(resp!.status).toBe(200); + expect(config.accountPoolStrategy).toBe("round-robin"); + expect(config.accountPoolStickyLimit).toBe(2); + }); +}); + +describe("Anthropic account pool strategy management API", () => { + let testDir = ""; + let previousHome: string | undefined; + let isolatedCodexHome: IsolatedCodexHome | null = null; + + function baseConfig(): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "anthropic", + providers: { + anthropic: { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "oauth" }, + }, + } as OcxConfig; + } + + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-pool-mgmt-codex-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-pool-mgmt-")); + process.env.OPENCODEX_HOME = testDir; + saveConfig(baseConfig()); + writeFileSync(join(testDir, "auth.json"), JSON.stringify({ + anthropic: { + activeAccountId: "aaaa1111", + accounts: [ + { id: "aaaa1111", credential: { access: "t1", refresh: "r1", expires: 9999999999999, email: "a@example.com", accountId: "acct-1" } }, + ], + }, + }), { mode: 0o600 }); + }); + + afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) rmSync(testDir, { recursive: true, force: true }); + }); + + test("GET /api/oauth/accounts/pool surfaces strategy defaults", async () => { + const server = startServer(0); + try { + const res = await fetch(new URL("/api/oauth/accounts/pool?provider=anthropic", server.url)); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ + strategy: "quota", + stickyLimit: 1, + }); + } finally { + await server.stop(true); + } + }); + + test("PUT /api/oauth/accounts/pool rejects invalid strategy", async () => { + const server = startServer(0); + try { + const res = await fetch(new URL("/api/oauth/accounts/pool", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "anthropic", + enabled: true, + strategy: "weighted", + }), + }); + expect(res.status).toBe(400); + } finally { + await server.stop(true); + } + }); + + test("PUT /api/oauth/accounts/pool rejects invalid stickyLimit", async () => { + const server = startServer(0); + try { + for (const bad of [0, 101, 2.5]) { + const res = await fetch(new URL("/api/oauth/accounts/pool", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "anthropic", + enabled: false, + stickyLimit: bad, + }), + }); + expect(res.status).toBe(400); + } + } finally { + await server.stop(true); + } + }); + + test("PUT /api/oauth/accounts/pool accepts strategy and stickyLimit; GET reflects them", async () => { + const server = startServer(0); + try { + const put = await fetch(new URL("/api/oauth/accounts/pool", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "anthropic", + enabled: true, + autoSwitchThreshold: 70, + strategy: "round-robin", + stickyLimit: 4, + }), + }); + expect(put.status).toBe(200); + expect(await put.json()).toMatchObject({ + ok: true, + enabled: true, + autoSwitchThreshold: 70, + strategy: "round-robin", + stickyLimit: 4, + }); + + const get = await fetch(new URL("/api/oauth/accounts/pool?provider=anthropic", server.url)); + expect(await get.json()).toMatchObject({ + enabled: true, + autoSwitchThreshold: 70, + strategy: "round-robin", + stickyLimit: 4, + }); + } finally { + await server.stop(true); + } + }); + + test("PUT without strategy fields preserves previously saved strategy", async () => { + const server = startServer(0); + try { + await fetch(new URL("/api/oauth/accounts/pool", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "anthropic", + enabled: true, + strategy: "fill-first", + stickyLimit: 9, + }), + }); + const put = await fetch(new URL("/api/oauth/accounts/pool", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "anthropic", + enabled: false, + autoSwitchThreshold: 50, + }), + }); + expect(put.status).toBe(200); + const get = await fetch(new URL("/api/oauth/accounts/pool?provider=anthropic", server.url)); + expect(await get.json()).toMatchObject({ + enabled: false, + autoSwitchThreshold: 50, + strategy: "fill-first", + stickyLimit: 9, + }); + } finally { + await server.stop(true); + } + }); +}); diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index deb3d44cd7..585d6eb6cc 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -449,7 +449,13 @@ describe("codex-auth API", () => { const req = new Request("http://localhost/api/codex-auth/active", { method: "GET" }); const resp = await handleCodexAuthAPI(req, new URL(req.url), config); const data = await resp!.json() as { activeCodexAccountId: string | null; autoSwitchThreshold: number }; - expect(data).toEqual({ activeCodexAccountId: "pool-live", autoSwitchThreshold: 55, upstreamFailoverThreshold: 3 }); + expect(data).toEqual({ + activeCodexAccountId: "pool-live", + autoSwitchThreshold: 55, + upstreamFailoverThreshold: 3, + accountPoolStrategy: "quota", + accountPoolStickyLimit: 1, + }); }); test("GET /api/codex-auth/accounts returns large live pools without dropping entries", async () => { From 766c3cccf63b9b1be818b972febf7cd65a3d5757 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:41:01 +0200 Subject: [PATCH 05/15] feat(gui): account pool rotation strategy controls --- gui/src/account-pool-strategy.ts | 69 ++++++++ .../AccountPoolStrategyControls.tsx | 91 ++++++++++ gui/src/components/CodexAccountPool.tsx | 3 + .../components/CodexPoolStrategySetting.tsx | 147 +++++++++++++++++ .../AnthropicAccountPoolSettings.tsx | 155 ++++++++++++++---- gui/src/i18n/de.ts | 13 ++ gui/src/i18n/en.ts | 13 ++ gui/src/i18n/ja.ts | 13 ++ gui/src/i18n/ko.ts | 13 ++ gui/src/i18n/ru.ts | 13 ++ gui/src/i18n/zh.ts | 13 ++ gui/tests/account-pool-strategy.test.tsx | 121 ++++++++++++++ 12 files changed, 635 insertions(+), 29 deletions(-) create mode 100644 gui/src/account-pool-strategy.ts create mode 100644 gui/src/components/AccountPoolStrategyControls.tsx create mode 100644 gui/src/components/CodexPoolStrategySetting.tsx create mode 100644 gui/tests/account-pool-strategy.test.tsx diff --git a/gui/src/account-pool-strategy.ts b/gui/src/account-pool-strategy.ts new file mode 100644 index 0000000000..4ff6b860c2 --- /dev/null +++ b/gui/src/account-pool-strategy.ts @@ -0,0 +1,69 @@ +export type AccountPoolStrategy = "quota" | "round-robin" | "fill-first"; + +export const ACCOUNT_POOL_STRATEGIES: readonly AccountPoolStrategy[] = [ + "quota", + "round-robin", + "fill-first", +] as const; + +export const DEFAULT_ACCOUNT_POOL_STRATEGY: AccountPoolStrategy = "quota"; +export const DEFAULT_ACCOUNT_POOL_STICKY_LIMIT = 1; +export const MIN_ACCOUNT_POOL_STICKY_LIMIT = 1; +export const MAX_ACCOUNT_POOL_STICKY_LIMIT = 100; + +const STRATEGY_SET = new Set(ACCOUNT_POOL_STRATEGIES); + +export function normalizeAccountPoolStrategy(value: unknown): AccountPoolStrategy { + return typeof value === "string" && STRATEGY_SET.has(value) + ? value as AccountPoolStrategy + : DEFAULT_ACCOUNT_POOL_STRATEGY; +} + +export function normalizeAccountPoolStickyLimit(value: unknown): number { + return typeof value === "number" + && Number.isInteger(value) + && value >= MIN_ACCOUNT_POOL_STICKY_LIMIT + && value <= MAX_ACCOUNT_POOL_STICKY_LIMIT + ? value + : DEFAULT_ACCOUNT_POOL_STICKY_LIMIT; +} + +/** Strict draft parse for sticky-limit inputs (1–100 integer). */ +export function parseAccountPoolStickyLimitDraft(value: string): number | null { + const trimmed = value.trim(); + if (!/^\d+$/.test(trimmed)) return null; + const n = Number(trimmed); + return n >= MIN_ACCOUNT_POOL_STICKY_LIMIT && n <= MAX_ACCOUNT_POOL_STICKY_LIMIT ? n : null; +} + +export type PoolStrategyFetch = (input: string, init: RequestInit) => Promise; + +export async function putCodexPoolStrategy( + apiBase: string, + body: { strategy?: AccountPoolStrategy; stickyLimit?: number }, + fetchImpl: PoolStrategyFetch = (input, init) => fetch(input, init), +): Promise<{ ok: true; strategy: AccountPoolStrategy; stickyLimit: number } | { ok: false }> { + if (body.strategy === undefined && body.stickyLimit === undefined) return { ok: false }; + try { + const response = await fetchImpl(`${apiBase}/api/codex-auth/pool-strategy`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...(body.strategy !== undefined ? { strategy: body.strategy } : {}), + ...(body.stickyLimit !== undefined ? { stickyLimit: body.stickyLimit } : {}), + }), + }); + if (!response.ok) return { ok: false }; + const json = await response.json() as { + accountPoolStrategy?: unknown; + accountPoolStickyLimit?: unknown; + }; + return { + ok: true, + strategy: normalizeAccountPoolStrategy(json.accountPoolStrategy ?? body.strategy), + stickyLimit: normalizeAccountPoolStickyLimit(json.accountPoolStickyLimit ?? body.stickyLimit), + }; + } catch { + return { ok: false }; + } +} diff --git a/gui/src/components/AccountPoolStrategyControls.tsx b/gui/src/components/AccountPoolStrategyControls.tsx new file mode 100644 index 0000000000..57e315abe8 --- /dev/null +++ b/gui/src/components/AccountPoolStrategyControls.tsx @@ -0,0 +1,91 @@ +import { useT } from "../i18n/shared"; +import { + ACCOUNT_POOL_STRATEGIES, + type AccountPoolStrategy, +} from "../account-pool-strategy"; + +const STRATEGY_LABEL_KEYS = { + quota: "accountPool.strategyQuota", + "round-robin": "accountPool.strategyRoundRobin", + "fill-first": "accountPool.strategyFillFirst", +} as const; + +export interface AccountPoolStrategyControlsProps { + strategy: AccountPoolStrategy; + stickyDraft: string; + disabled?: boolean; + strategySelectId?: string; + stickyInputId?: string; + onStrategyChange(strategy: AccountPoolStrategy): void; + onStickyDraftChange(value: string): void; + onStickyCommit(): void; +} + +/** + * Shared strategy select + round-robin sticky limit for Codex / Anthropic account pools. + */ +export default function AccountPoolStrategyControls({ + strategy, + stickyDraft, + disabled = false, + strategySelectId = "account-pool-strategy", + stickyInputId = "account-pool-sticky-limit", + onStrategyChange, + onStickyDraftChange, + onStickyCommit, +}: AccountPoolStrategyControlsProps) { + const t = useT(); + return ( +
+ +
+ {t("accountPool.strategyHint")} +
+ {strategy === "round-robin" && ( + + )} +
+ ); +} diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index 9031cb139f..2a52c310d3 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -7,6 +7,7 @@ import { useCodexAccountPool, type CodexAccountPoolController } from "../hooks/u import type { ReactNode } from "react"; import type { CodexAccountModeState } from "../codex-multi-state"; import CodexAutoSwitchSetting from "./CodexAutoSwitchSetting"; +import CodexPoolStrategySetting from "./CodexPoolStrategySetting"; import { useCodexAutoSwitch } from "../hooks/useCodexAutoSwitch"; import { readJsonIfOk } from "../fetch-json"; import { CodexAccountPoolCards, CodexAccountPoolReauthBanner } from "./codex-account-pool-cards"; @@ -297,6 +298,8 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban }} /> + + {confirm && ( (null); + const [stickyLimit, setStickyLimit] = useState(DEFAULT_ACCOUNT_POOL_STICKY_LIMIT); + const [stickyDraft, setStickyDraft] = useState(String(DEFAULT_ACCOUNT_POOL_STICKY_LIMIT)); + const [saving, setSaving] = useState(false); + const [loadError, setLoadError] = useState(false); + const [error, setError] = useState(null); + + const applyServer = useCallback((json: { + accountPoolStrategy?: unknown; + accountPoolStickyLimit?: unknown; + }) => { + const nextStrategy = normalizeAccountPoolStrategy(json.accountPoolStrategy); + const nextSticky = normalizeAccountPoolStickyLimit(json.accountPoolStickyLimit); + setStrategy(nextStrategy); + setStickyLimit(nextSticky); + setStickyDraft(String(nextSticky)); + setLoadError(false); + setError(null); + }, []); + + const load = useCallback(async () => { + try { + const res = await fetch(`${apiBase}/api/codex-auth/active`); + if (!res.ok) throw new Error("load"); + applyServer(await res.json() as { + accountPoolStrategy?: unknown; + accountPoolStickyLimit?: unknown; + }); + } catch { + setLoadError(true); + } + }, [apiBase, applyServer]); + + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const res = await fetch(`${apiBase}/api/codex-auth/active`); + if (!res.ok) throw new Error("load"); + const json = await res.json() as { + accountPoolStrategy?: unknown; + accountPoolStickyLimit?: unknown; + }; + if (cancelled) return; + applyServer(json); + } catch { + if (!cancelled) setLoadError(true); + } + })(); + return () => { + cancelled = true; + }; + }, [apiBase, applyServer]); + + const save = useCallback(async (next: { + strategy?: AccountPoolStrategy; + stickyLimit?: number; + }) => { + const previousStrategy = strategy; + const previousSticky = stickyLimit; + setSaving(true); + setError(null); + const result = await putCodexPoolStrategy(apiBase, next); + if (result.ok) { + setStrategy(result.strategy); + setStickyLimit(result.stickyLimit); + setStickyDraft(String(result.stickyLimit)); + } else { + setError(t("accountPool.strategyUpdateFailed")); + if (previousStrategy) setStrategy(previousStrategy); + setStickyLimit(previousSticky); + setStickyDraft(String(previousSticky)); + } + setSaving(false); + }, [apiBase, stickyLimit, strategy, t]); + + const ready = strategy !== null; + const loading = !ready && !loadError; + + return ( +
+ {t("accountPool.strategy")} +
+ {loadError + ? t("accountPool.strategyLoadFailed") + : loading + ? t("common.loading") + : t("accountPool.strategyDesc")} +
+ {loadError && ( + + )} + {ready && ( + { + if (next === strategy) return; + void save({ strategy: next }); + }} + onStickyDraftChange={setStickyDraft} + onStickyCommit={() => { + const parsed = parseAccountPoolStickyLimitDraft(stickyDraft); + if (parsed === null) { + setStickyDraft(String(stickyLimit)); + setError(t("accountPool.stickyLimitInvalid")); + return; + } + if (parsed === stickyLimit) { + setStickyDraft(String(parsed)); + return; + } + void save({ stickyLimit: parsed }); + }} + /> + )} + {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx b/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx index 257e566f8d..d06061061f 100644 --- a/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx +++ b/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx @@ -4,10 +4,21 @@ */ import { useCallback, useEffect, useState } from "react"; import { useT } from "../../i18n/shared"; +import { + DEFAULT_ACCOUNT_POOL_STICKY_LIMIT, + DEFAULT_ACCOUNT_POOL_STRATEGY, + normalizeAccountPoolStickyLimit, + normalizeAccountPoolStrategy, + parseAccountPoolStickyLimitDraft, + type AccountPoolStrategy, +} from "../../account-pool-strategy"; +import AccountPoolStrategyControls from "../AccountPoolStrategyControls"; type PoolState = { enabled: boolean; threshold: number; + strategy: AccountPoolStrategy; + stickyLimit: number; }; export default function AnthropicAccountPoolSettings({ @@ -20,6 +31,7 @@ export default function AnthropicAccountPoolSettings({ const t = useT(); const [state, setState] = useState(null); const [draft, setDraft] = useState("80"); + const [stickyDraft, setStickyDraft] = useState(String(DEFAULT_ACCOUNT_POOL_STICKY_LIMIT)); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [loadError, setLoadError] = useState(false); @@ -33,12 +45,25 @@ export default function AnthropicAccountPoolSettings({ signal: ac.signal, }); if (!res.ok) throw new Error("load"); - const json = await res.json() as { enabled?: boolean; autoSwitchThreshold?: number }; + const json = await res.json() as { + enabled?: boolean; + autoSwitchThreshold?: number; + strategy?: unknown; + stickyLimit?: unknown; + }; if (cancelled) return; const nextEnabled = json.enabled === true; const nextThreshold = typeof json.autoSwitchThreshold === "number" ? json.autoSwitchThreshold : 80; - setState({ enabled: nextEnabled, threshold: nextThreshold }); + const nextStrategy = normalizeAccountPoolStrategy(json.strategy); + const nextSticky = normalizeAccountPoolStickyLimit(json.stickyLimit); + setState({ + enabled: nextEnabled, + threshold: nextThreshold, + strategy: nextStrategy, + stickyLimit: nextSticky, + }); setDraft(String(nextThreshold)); + setStickyDraft(String(nextSticky)); setLoadError(false); } catch { if (cancelled || ac.signal.aborted) return; @@ -51,7 +76,12 @@ export default function AnthropicAccountPoolSettings({ }; }, [apiBase]); - const save = useCallback(async (nextEnabled: boolean, nextThreshold: number) => { + const save = useCallback(async (next: { + enabled: boolean; + threshold: number; + strategy: AccountPoolStrategy; + stickyLimit: number; + }) => { setSaving(true); setError(null); try { @@ -60,22 +90,39 @@ export default function AnthropicAccountPoolSettings({ headers: { "content-type": "application/json" }, body: JSON.stringify({ provider: "anthropic", - enabled: nextEnabled, - autoSwitchThreshold: nextThreshold, + enabled: next.enabled, + autoSwitchThreshold: next.threshold, + strategy: next.strategy, + stickyLimit: next.stickyLimit, }), }); if (!res.ok) throw new Error("save"); - setState({ enabled: nextEnabled, threshold: nextThreshold }); - setDraft(String(nextThreshold)); + const json = await res.json().catch(() => null) as { + strategy?: unknown; + stickyLimit?: unknown; + } | null; + const savedStrategy = normalizeAccountPoolStrategy(json?.strategy ?? next.strategy); + const savedSticky = normalizeAccountPoolStickyLimit(json?.stickyLimit ?? next.stickyLimit); + setState({ + enabled: next.enabled, + threshold: next.threshold, + strategy: savedStrategy, + stickyLimit: savedSticky, + }); + setDraft(String(next.threshold)); + setStickyDraft(String(savedSticky)); } catch { setError(t("anthropicPool.saveFailed")); + if (state) setStickyDraft(String(state.stickyLimit)); } finally { setSaving(false); } - }, [apiBase, t]); + }, [apiBase, state, t]); const enabled = state?.enabled === true; const threshold = state?.threshold ?? 80; + const strategy = state?.strategy ?? DEFAULT_ACCOUNT_POOL_STRATEGY; + const stickyLimit = state?.stickyLimit ?? DEFAULT_ACCOUNT_POOL_STICKY_LIMIT; const loading = state === null && !loadError; // Always allow turning the pool off; only block enabling when fewer than 2 accounts. const toggleDisabled = loading || saving || loadError || (!enabled && accountCount < 2); @@ -102,7 +149,12 @@ export default function AnthropicAccountPoolSettings({ disabled={toggleDisabled} onChange={(event) => { const next = event.target.checked; - void save(next, threshold); + void save({ + enabled: next, + threshold, + strategy, + stickyLimit, + }); }} /> {enabled ? t("anthropicPool.on") : t("anthropicPool.off")} @@ -127,31 +179,76 @@ export default function AnthropicAccountPoolSettings({
{t("anthropicPool.needTwoAccounts")}
)} - {enabled && ( - + )} {error && ( diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a1bc4d0dd5..aa4de04393 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -688,6 +688,19 @@ export const de: Record = { "anthropicPool.saveFailed": "Claude-Pool-Einstellungen konnten nicht gespeichert werden.", "anthropicPool.on": "An", "anthropicPool.off": "Aus", + + "accountPool.strategy": "Rotationsstrategie", + "accountPool.strategyDesc": "Wie neue Sitzungen ein Konto aus dem Pool wählen.", + "accountPool.strategyQuota": "Kontingent", + "accountPool.strategyRoundRobin": "Round-Robin", + "accountPool.strategyFillFirst": "Fill-first", + "accountPool.strategyHint": "Gilt nur für neue Sitzungen; bestehende Threads behalten die Kontenaffinität.", + "accountPool.stickyLimit": "Sticky-Erfolge vor Rotation", + "accountPool.stickyLimitAria": "Sticky-Erfolge vor Rotation", + "accountPool.stickyLimitHelp": "Das gewählte Konto für so viele erfolgreiche neue Sitzungsbindungen behalten, bevor weitergeschaltet wird.", + "accountPool.stickyLimitInvalid": "Gib eine ganze Zahl von 1 bis 100 ein", + "accountPool.strategyLoadFailed": "Rotationsstrategie konnte nicht geladen werden.", + "accountPool.strategyUpdateFailed": "Rotationsstrategie konnte nicht gespeichert werden.", "codexAuth.switched": "{email} ist für die nächste Anfrage ausgewählt", "codexAuth.loadFailed": "Die Codex-Kontoeinstellungen konnten nicht geladen werden.", "codexAuth.switchFailed": "Das Konto konnte nicht gewechselt werden. Die vorherige Auswahl bleibt erhalten.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 64127b608a..3555b4350d 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1101,6 +1101,19 @@ export const en = { "anthropicPool.on": "On", "anthropicPool.off": "Off", + "accountPool.strategy": "Rotation strategy", + "accountPool.strategyDesc": "How new sessions pick an account from the pool.", + "accountPool.strategyQuota": "Quota", + "accountPool.strategyRoundRobin": "Round-robin", + "accountPool.strategyFillFirst": "Fill-first", + "accountPool.strategyHint": "Applies to new sessions only; existing threads keep account affinity.", + "accountPool.stickyLimit": "Sticky successes before rotate", + "accountPool.stickyLimitAria": "Sticky successes before rotate", + "accountPool.stickyLimitHelp": "Keep the selected account for this many successful new-session binds before advancing.", + "accountPool.stickyLimitInvalid": "Enter a whole number from 1 to 100", + "accountPool.strategyLoadFailed": "Rotation strategy could not be loaded.", + "accountPool.strategyUpdateFailed": "Rotation strategy could not be saved.", + "codexAuth.switched": "{email} is selected for the next request", "codexAuth.loadFailed": "Codex account settings could not be loaded.", "codexAuth.switchFailed": "The account could not be switched. Your previous selection is unchanged.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 99dbeb4245..e1219b319c 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1056,6 +1056,19 @@ export const ja: Record = { "anthropicPool.saveFailed": "Claude プール設定を保存できませんでした。", "anthropicPool.on": "オン", "anthropicPool.off": "オフ", + + "accountPool.strategy": "ローテーション戦略", + "accountPool.strategyDesc": "新規セッションがプールからアカウントを選ぶ方法です。", + "accountPool.strategyQuota": "クォータ", + "accountPool.strategyRoundRobin": "ラウンドロビン", + "accountPool.strategyFillFirst": "フィルファースト", + "accountPool.strategyHint": "新規セッションにのみ適用されます。既存スレッドはアカウント親和性を維持します。", + "accountPool.stickyLimit": "ローテーション前の固定成功数", + "accountPool.stickyLimitAria": "ローテーション前の固定成功数", + "accountPool.stickyLimitHelp": "次へ進む前に、選んだアカウントをこの回数の成功した新規セッション紐付け分保持します。", + "accountPool.stickyLimitInvalid": "1 から 100 までの整数を入力してください", + "accountPool.strategyLoadFailed": "ローテーション戦略を読み込めませんでした。", + "accountPool.strategyUpdateFailed": "ローテーション戦略を保存できませんでした。", "codexAuth.switched": "次のリクエストでは {email} を使用します", "codexAuth.loadFailed": "Codex アカウント設定を読み込めませんでした。", "codexAuth.switchFailed": "アカウントを切り替えられませんでした。以前の選択はそのままです。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index f1b47e23e8..33431d3019 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -705,6 +705,19 @@ export const ko: Record = { "anthropicPool.saveFailed": "Claude 풀 설정을 저장하지 못했습니다.", "anthropicPool.on": "켜짐", "anthropicPool.off": "꺼짐", + + "accountPool.strategy": "로테이션 전략", + "accountPool.strategyDesc": "새 세션이 풀에서 계정을 고르는 방식입니다.", + "accountPool.strategyQuota": "할당량", + "accountPool.strategyRoundRobin": "라운드로빈", + "accountPool.strategyFillFirst": "필 퍼스트", + "accountPool.strategyHint": "새 세션에만 적용됩니다. 기존 스레드는 계정 어피니티를 유지합니다.", + "accountPool.stickyLimit": "회전 전 sticky 성공 횟수", + "accountPool.stickyLimitAria": "회전 전 sticky 성공 횟수", + "accountPool.stickyLimitHelp": "다음으로 진행하기 전에 선택된 계정을 이 횟수의 성공한 새 세션 바인딩 동안 유지합니다.", + "accountPool.stickyLimitInvalid": "1에서 100 사이의 정수를 입력하세요", + "accountPool.strategyLoadFailed": "로테이션 전략을 불러오지 못했습니다.", + "accountPool.strategyUpdateFailed": "로테이션 전략을 저장하지 못했습니다.", "codexAuth.switched": "다음 요청에 {email}을(를) 사용합니다", "codexAuth.loadFailed": "Codex 계정 설정을 불러오지 못했습니다.", "codexAuth.switchFailed": "계정을 전환하지 못했습니다. 이전 선택은 그대로 유지됩니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index c2c1689882..d1c2298b88 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1098,6 +1098,19 @@ export const ru: Record = { "anthropicPool.saveFailed": "Не удалось сохранить настройки пула Claude.", "anthropicPool.on": "Вкл", "anthropicPool.off": "Выкл", + + "accountPool.strategy": "Стратегия ротации", + "accountPool.strategyDesc": "Как новые сессии выбирают аккаунт из пула.", + "accountPool.strategyQuota": "Квота", + "accountPool.strategyRoundRobin": "Round-robin", + "accountPool.strategyFillFirst": "Fill-first", + "accountPool.strategyHint": "Применяется только к новым сессиям; существующие треды сохраняют привязку к аккаунту.", + "accountPool.stickyLimit": "Успешных запросов до ротации", + "accountPool.stickyLimitAria": "Успешных запросов до ротации", + "accountPool.stickyLimitHelp": "Удерживать выбранный аккаунт на указанное число успешных привязок новых сессий, прежде чем перейти дальше.", + "accountPool.stickyLimitInvalid": "Введите целое число от 1 до 100", + "accountPool.strategyLoadFailed": "Не удалось загрузить стратегию ротации.", + "accountPool.strategyUpdateFailed": "Не удалось сохранить стратегию ротации.", "codexAuth.switched": "{email} выбран для следующего запроса", "codexAuth.loadFailed": "Не удалось загрузить настройки аккаунтов Codex.", "codexAuth.switchFailed": "Не удалось переключить аккаунт. Ваш предыдущий выбор не изменён.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index f2c91eb563..758d2fc62a 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -705,6 +705,19 @@ export const zh: Record = { "anthropicPool.saveFailed": "无法保存 Claude 账户池设置。", "anthropicPool.on": "开", "anthropicPool.off": "关", + + "accountPool.strategy": "轮换策略", + "accountPool.strategyDesc": "新会话如何从账号池中选择账号。", + "accountPool.strategyQuota": "配额", + "accountPool.strategyRoundRobin": "轮询", + "accountPool.strategyFillFirst": "填满优先", + "accountPool.strategyHint": "仅适用于新会话;现有线程保持账号亲和性。", + "accountPool.stickyLimit": "轮换前的粘性成功次数", + "accountPool.stickyLimitAria": "轮换前的粘性成功次数", + "accountPool.stickyLimitHelp": "在推进到下一个账号之前,将所选账号保留这么多次成功的新会话绑定。", + "accountPool.stickyLimitInvalid": "请输入 1 到 100 之间的整数", + "accountPool.strategyLoadFailed": "无法加载轮换策略。", + "accountPool.strategyUpdateFailed": "无法保存轮换策略。", "codexAuth.switched": "下一次请求将使用 {email}", "codexAuth.loadFailed": "无法加载 Codex 账号设置。", "codexAuth.switchFailed": "无法切换账户。之前的选择保持不变。", diff --git a/gui/tests/account-pool-strategy.test.tsx b/gui/tests/account-pool-strategy.test.tsx new file mode 100644 index 0000000000..f8832fe1b0 --- /dev/null +++ b/gui/tests/account-pool-strategy.test.tsx @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { + DEFAULT_ACCOUNT_POOL_STICKY_LIMIT, + DEFAULT_ACCOUNT_POOL_STRATEGY, + normalizeAccountPoolStickyLimit, + normalizeAccountPoolStrategy, + parseAccountPoolStickyLimitDraft, + putCodexPoolStrategy, +} from "../src/account-pool-strategy"; +import AccountPoolStrategyControls from "../src/components/AccountPoolStrategyControls"; +import { LanguageProvider } from "../src/i18n/provider"; + +let previousLanguage: unknown; + +beforeEach(() => { + previousLanguage = (globalThis.navigator as { language?: unknown } | undefined)?.language; + Object.defineProperty(globalThis.navigator, "language", { + configurable: true, + value: "en-US", + }); +}); + +afterEach(() => { + Object.defineProperty(globalThis.navigator, "language", { + configurable: true, + value: previousLanguage, + }); +}); + +describe("account pool strategy helpers", () => { + test("normalizes known strategies and defaults unknowns to quota", () => { + expect(normalizeAccountPoolStrategy("quota")).toBe("quota"); + expect(normalizeAccountPoolStrategy("round-robin")).toBe("round-robin"); + expect(normalizeAccountPoolStrategy("fill-first")).toBe("fill-first"); + expect(normalizeAccountPoolStrategy("weighted")).toBe(DEFAULT_ACCOUNT_POOL_STRATEGY); + expect(normalizeAccountPoolStrategy(undefined)).toBe("quota"); + }); + + test("normalizes sticky limits to 1–100 integers", () => { + expect(normalizeAccountPoolStickyLimit(3)).toBe(3); + expect(normalizeAccountPoolStickyLimit(1)).toBe(1); + expect(normalizeAccountPoolStickyLimit(100)).toBe(100); + expect(normalizeAccountPoolStickyLimit(0)).toBe(DEFAULT_ACCOUNT_POOL_STICKY_LIMIT); + expect(normalizeAccountPoolStickyLimit(101)).toBe(DEFAULT_ACCOUNT_POOL_STICKY_LIMIT); + expect(normalizeAccountPoolStickyLimit(1.5)).toBe(DEFAULT_ACCOUNT_POOL_STICKY_LIMIT); + }); + + test("parses sticky-limit drafts strictly", () => { + expect(parseAccountPoolStickyLimitDraft("1")).toBe(1); + expect(parseAccountPoolStickyLimitDraft("42")).toBe(42); + expect(parseAccountPoolStickyLimitDraft("100")).toBe(100); + for (const invalid of ["", "0", "101", "1.5", "-1", "abc", " 2 "]) { + // Leading/trailing spaces are trimmed; " 2 " is valid. + if (invalid === " 2 ") { + expect(parseAccountPoolStickyLimitDraft(invalid)).toBe(2); + continue; + } + expect(parseAccountPoolStickyLimitDraft(invalid)).toBeNull(); + } + }); + + test("putCodexPoolStrategy sends strategy/stickyLimit body fields", async () => { + const calls: { url: string; init: RequestInit }[] = []; + const result = await putCodexPoolStrategy( + "http://proxy", + { strategy: "round-robin", stickyLimit: 3 }, + async (url, init) => { + calls.push({ url, init }); + return new Response(JSON.stringify({ + ok: true, + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 3, + }), { status: 200 }); + }, + ); + expect(result).toEqual({ ok: true, strategy: "round-robin", stickyLimit: 3 }); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe("http://proxy/api/codex-auth/pool-strategy"); + expect(calls[0]!.init.method).toBe("PUT"); + expect(JSON.parse(String(calls[0]!.init.body))).toEqual({ + strategy: "round-robin", + stickyLimit: 3, + }); + }); +}); + +describe("AccountPoolStrategyControls", () => { + test("renders strategy options and hides sticky unless round-robin", () => { + const quota = renderToStaticMarkup( + + {}} + onStickyDraftChange={() => {}} + onStickyCommit={() => {}} + /> + , + ); + expect(quota).toContain("Quota"); + expect(quota).toContain("Round-robin"); + expect(quota).toContain("Fill-first"); + expect(quota).toContain("Applies to new sessions only"); + expect(quota).not.toContain("Sticky successes before rotate"); + + const rr = renderToStaticMarkup( + + {}} + onStickyDraftChange={() => {}} + onStickyCommit={() => {}} + /> + , + ); + expect(rr).toContain("Sticky successes before rotate"); + expect(rr).toContain('value="2"'); + }); +}); From f73e2ff90363c5e65a167ec263c9f9ace894e1aa Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:44:10 +0200 Subject: [PATCH 06/15] fix(gui): optimistic account pool strategy select --- .../components/CodexPoolStrategySetting.tsx | 5 + .../AnthropicAccountPoolSettings.tsx | 13 +- gui/tests/account-pool-strategy.test.tsx | 164 ++++++++++++++++++ 3 files changed, 181 insertions(+), 1 deletion(-) diff --git a/gui/src/components/CodexPoolStrategySetting.tsx b/gui/src/components/CodexPoolStrategySetting.tsx index c24e7ab7f3..a540b3ebcd 100644 --- a/gui/src/components/CodexPoolStrategySetting.tsx +++ b/gui/src/components/CodexPoolStrategySetting.tsx @@ -76,6 +76,11 @@ export default function CodexPoolStrategySetting({ apiBase }: { apiBase: string }) => { const previousStrategy = strategy; const previousSticky = stickyLimit; + if (next.strategy !== undefined) setStrategy(next.strategy); + if (next.stickyLimit !== undefined) { + setStickyLimit(next.stickyLimit); + setStickyDraft(String(next.stickyLimit)); + } setSaving(true); setError(null); const result = await putCodexPoolStrategy(apiBase, next); diff --git a/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx b/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx index d06061061f..f67c695386 100644 --- a/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx +++ b/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx @@ -82,6 +82,13 @@ export default function AnthropicAccountPoolSettings({ strategy: AccountPoolStrategy; stickyLimit: number; }) => { + const previousState = state; + setState({ + enabled: next.enabled, + threshold: next.threshold, + strategy: next.strategy, + stickyLimit: next.stickyLimit, + }); setSaving(true); setError(null); try { @@ -113,7 +120,11 @@ export default function AnthropicAccountPoolSettings({ setStickyDraft(String(savedSticky)); } catch { setError(t("anthropicPool.saveFailed")); - if (state) setStickyDraft(String(state.stickyLimit)); + if (previousState) { + setState(previousState); + setDraft(String(previousState.threshold)); + setStickyDraft(String(previousState.stickyLimit)); + } } finally { setSaving(false); } diff --git a/gui/tests/account-pool-strategy.test.tsx b/gui/tests/account-pool-strategy.test.tsx index f8832fe1b0..93544b43fe 100644 --- a/gui/tests/account-pool-strategy.test.tsx +++ b/gui/tests/account-pool-strategy.test.tsx @@ -1,4 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; import { renderToStaticMarkup } from "react-dom/server"; import { DEFAULT_ACCOUNT_POOL_STICKY_LIMIT, @@ -9,10 +12,36 @@ import { putCodexPoolStrategy, } from "../src/account-pool-strategy"; import AccountPoolStrategyControls from "../src/components/AccountPoolStrategyControls"; +import CodexPoolStrategySetting from "../src/components/CodexPoolStrategySetting"; import { LanguageProvider } from "../src/i18n/provider"; let previousLanguage: unknown; +const domGlobals = ["document", "window", "navigator", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousDomGlobals: Record<(typeof domGlobals)[number], unknown>; +let testWindow: Window; +let mountedRoot: Root | null; + +function deferred(): { + promise: Promise; + resolve(value: T): void; + reject(reason?: unknown): void; +} { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +async function flush(): Promise { + await Promise.resolve(); + await new Promise((resolve) => testWindow.setTimeout(resolve, 0)); + await Promise.resolve(); +} + beforeEach(() => { previousLanguage = (globalThis.navigator as { language?: unknown } | undefined)?.language; Object.defineProperty(globalThis.navigator, "language", { @@ -28,6 +57,34 @@ afterEach(() => { }); }); +function setupDom(): void { + previousDomGlobals = Object.fromEntries( + domGlobals.map((key) => [key, Reflect.get(globalThis, key)]), + ) as typeof previousDomGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + mountedRoot = null; +} + +async function teardownDom(): Promise { + if (mountedRoot) { + await act(async () => { + mountedRoot?.unmount(); + }); + mountedRoot = null; + } + for (const key of domGlobals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousDomGlobals[key] }); + } + await testWindow.happyDOM?.close?.(); +} + describe("account pool strategy helpers", () => { test("normalizes known strategies and defaults unknowns to quota", () => { expect(normalizeAccountPoolStrategy("quota")).toBe("quota"); @@ -119,3 +176,110 @@ describe("AccountPoolStrategyControls", () => { expect(rr).toContain('value="2"'); }); }); + +describe("CodexPoolStrategySetting optimistic strategy select", () => { + beforeEach(() => setupDom()); + afterEach(async () => { + await teardownDom(); + }); + + test("updates visible strategy before save completes", async () => { + const put = deferred(); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/api/codex-auth/active") && (!init || init.method === undefined)) { + return new Response(JSON.stringify({ + accountPoolStrategy: "quota", + accountPoolStickyLimit: 1, + }), { status: 200 }); + } + if (url.endsWith("/api/codex-auth/pool-strategy") && init?.method === "PUT") { + return put.promise; + } + throw new Error(`unexpected fetch: ${url} ${init?.method ?? "GET"}`); + }) as typeof fetch; + + const host = testWindow.document.createElement("div"); + testWindow.document.body.appendChild(host as never); + const { createRoot } = await import("react-dom/client"); + await act(async () => { + mountedRoot = createRoot(host); + mountedRoot.render( + + + , + ); + }); + await act(async () => { await flush(); }); + + const select = () => host.querySelector("#codex-pool-strategy"); + expect(select()?.value).toBe("quota"); + + await act(async () => { + const el = select(); + if (!el) throw new Error("strategy select missing"); + Object.getOwnPropertyDescriptor(testWindow.HTMLSelectElement.prototype, "value")! + .set!.call(el, "round-robin"); + el.dispatchEvent(new testWindow.Event("change", { bubbles: true })); + await flush(); + }); + + expect(select()?.value).toBe("round-robin"); + expect(select()?.disabled).toBe(true); + + await act(async () => { + put.resolve(new Response(JSON.stringify({ + ok: true, + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 1, + }), { status: 200 })); + await flush(); + }); + + expect(select()?.value).toBe("round-robin"); + expect(select()?.disabled).toBe(false); + expect(host.textContent).toContain("Sticky successes before rotate"); + }); + + test("rolls back strategy when save fails", async () => { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/api/codex-auth/active") && (!init || init.method === undefined)) { + return new Response(JSON.stringify({ + accountPoolStrategy: "quota", + accountPoolStickyLimit: 1, + }), { status: 200 }); + } + if (url.endsWith("/api/codex-auth/pool-strategy") && init?.method === "PUT") { + return new Response("fail", { status: 500 }); + } + throw new Error(`unexpected fetch: ${url} ${init?.method ?? "GET"}`); + }) as typeof fetch; + + const host = testWindow.document.createElement("div"); + testWindow.document.body.appendChild(host as never); + const { createRoot } = await import("react-dom/client"); + await act(async () => { + mountedRoot = createRoot(host); + mountedRoot.render( + + + , + ); + }); + await act(async () => { await flush(); }); + + const select = () => host.querySelector("#codex-pool-strategy"); + await act(async () => { + const el = select(); + if (!el) throw new Error("strategy select missing"); + Object.getOwnPropertyDescriptor(testWindow.HTMLSelectElement.prototype, "value")! + .set!.call(el, "fill-first"); + el.dispatchEvent(new testWindow.Event("change", { bubbles: true })); + await flush(); + }); + + expect(select()?.value).toBe("quota"); + expect(host.textContent).toContain("Rotation strategy could not be saved"); + }); +}); From faa831853459016241d3b9e93f3f847b86cc3cb5 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:46:56 +0200 Subject: [PATCH 07/15] docs: account pool rotation strategies --- .../docs/getting-started/how-it-works.mdx | 8 +++--- .../src/content/docs/guides/claude-code.md | 11 +++++--- .../docs/ja/reference/configuration.md | 10 ++++--- .../docs/ko/reference/configuration.md | 14 +++++++--- .../content/docs/reference/configuration.md | 27 ++++++++++++++----- .../docs/ru/reference/configuration.md | 13 +++++++-- .../docs/zh-cn/reference/configuration.md | 12 +++++++-- 7 files changed, 72 insertions(+), 23 deletions(-) diff --git a/docs-site/src/content/docs/getting-started/how-it-works.mdx b/docs-site/src/content/docs/getting-started/how-it-works.mdx index 7387cc26e2..7ddff28cb9 100644 --- a/docs-site/src/content/docs/getting-started/how-it-works.mdx +++ b/docs-site/src/content/docs/getting-started/how-it-works.mdx @@ -32,9 +32,11 @@ account before the request is forwarded upstream. The rule is intentionally spli - **Existing thread ids keep affinity.** A thread is bound to the account generation that started it, so a long SSH, tmux, or mobile-attached Codex session keeps using one account instead of being rebalanced mid-conversation. -- **New sessions can rebalance.** For a new thread, opencodex compares known quota usage across 5h, - weekly, and 30d windows, skips accounts that need reauthentication or are in cooldown, and can - switch to a lower-usage eligible account when the active account crosses the configured threshold. +- **New sessions can rebalance.** For a new thread, opencodex picks among eligible accounts using + `accountPoolStrategy` (`quota` by default, or `round-robin` / `fill-first`). The `quota` strategy + compares known usage across 5h, weekly, and 30d windows and can switch to a lower-usage account + when the active account crosses `autoSwitchThreshold`. Accounts in cooldown or needing + reauthentication are skipped regardless of strategy. - **Quota and failure signals feed routing.** The dashboard can force a quota refresh with `GET /api/codex-auth/accounts?refresh=1`; successful upstream responses capture quota headers, 429 puts an account in cooldown, and 401/403 marks it for reauthentication. diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index cc6d35645d..ba17864090 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -13,10 +13,13 @@ You can log in multiple Claude accounts via the Providers dashboard (`ocx login add-account). By default every request uses the **active** account only. An **experimental, opt-in** Claude account pool (`anthropicAccountPool.enabled`) adds sticky -session affinity and 429 cooldown failover across those OAuth accounts, with optional -new-session lowest-usage pick from the 5-hour quota bars. It is **off by default**, shows a -GUI warning, and is not battle-tested — Anthropic may restrict accounts that look like -automated rotation. +session affinity and 429 cooldown failover across those OAuth accounts. For **new** sessions +only, `anthropicAccountPool.strategy` selects among eligible accounts: `quota` (default) picks +lowest known 5-hour usage when above `autoSwitchThreshold`; `round-robin` spreads evenly +(`stickyLimit`, default `1`); `fill-first` drains the active account until cooldown, +reauthentication, or threshold, then advances. It is **off by default**, shows a GUI warning, +and is not battle-tested — Anthropic may restrict accounts that look like automated rotation; +rotation does not protect against provider enforcement. Operational contract when enabled: diff --git a/docs-site/src/content/docs/ja/reference/configuration.md b/docs-site/src/content/docs/ja/reference/configuration.md index e9a42e87dd..dace11f1aa 100644 --- a/docs-site/src/content/docs/ja/reference/configuration.md +++ b/docs-site/src/content/docs/ja/reference/configuration.md @@ -52,7 +52,9 @@ namespaced selected id を bare id に変えます。 | `syncResumeHistory?` | `boolean` | `true` | 戻せる Codex App 履歴互換モード。opencodex は元の Codex thread metadata をバックアップし、旧 OpenAI interactive row を `opencodex` に再マッピングし、opencodex が作成した `exec` row を App に見えるソースとして一時的に昇格します。`ocx stop` / `ocx restore` はバックアップした OpenAI row を復元し、残った opencodex user thread を OpenAI に戻し、ネイティブ Codex が `config.toml` からプロキシを削除した後でも開き続けられるようにします。オフにするには `false` に設定します。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth ダッシュボードが管理する ChatGPT/Codex pool アカウント metadata。secret は `codex-accounts.json` に別途置きます。 | | `activeCodexAccountId?` | `string` | — | 手動選択した pool アカウント。既存 thread affinity を消去して次のリクエストから適用し、処理中のリクエストは現在のアカウントを維持します。 | -| `autoSwitchThreshold?` | `number` | `80` | 新しいセッション自動切替用の使用量百分率 threshold。既知の 5 時間、週次、30 日 quota window のうち最も高いスコアを使います。`0` なら quota 自動切替をオフにします。 | +| `autoSwitchThreshold?` | `number` | `80` | 新しいセッション自動切替用の使用量百分率 threshold。既知の 5 時間、週次、30 日 quota window のうち最も高いスコアを使います。`0` なら quota 自動切替をオフにします。`quota` 戦略と `fill-first` の drain threshold にも使います。 | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Codex pool の新しいセッション rotation 戦略。**新しいセッションのみ**に適用され、既存 thread id は affinity を維持します。`quota`(既定)— アクティブアカウントが `autoSwitchThreshold` を超えたら既知 usage 最小を選択。`round-robin` — 適格アカウント間を smooth weighted で均等分散。`fill-first` — cooldown、使用不可、または(設定時)`autoSwitchThreshold` までアクティブアカウントを使い切り(未知 usage は強制切替しない)、安定ソート順で次へ。 | +| `accountPoolStickyLimit?` | `number` | `1` | 1 回の round-robin 選択で次へ進む前に保持する成功的新セッション bind 数。範囲 1–100。`accountPoolStrategy` が `round-robin` のときのみ。 | | `upstreamFailoverThreshold?` | `number` | `3` | 一時的な上流失敗が連続して起きたのち、以降の新しいセッションを別の適合 pool アカウントに failover する回数。`0` なら失敗ベースの failover をオフにします。 | | `modelCacheTtlMs?` | `number` | `300000` | プロバイダー別 `/models` キャッシュの有効期間(5 分)。 | | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic prompt cache ポリシー。オフ、5 分 ephemeral、1 時間 extended のいずれか。 | @@ -71,8 +73,10 @@ namespaced selected id を bare id に変えます。 :::note[Codex アカウントプール] pool アカウントの追加と quota 更新はダッシュボードの **Codex Auth** ページで処理してください。設定には secret で ないアカウント metadata だけを保存し、access/refresh token は強化された Codex アカウント credential store に別途 -保管します。既存 thread id はアカウント affinity を維持し、新しいセッションは quota、cooldown、health に -応じて自動ルーティングされる場合があります。 +保管します。既存 thread id はアカウント affinity を維持し、新しいセッションは `accountPoolStrategy`、quota、cooldown、health に +応じて自動ルーティングされます。 + +**rotation 戦略**(新しいセッションのみ;bound thread は不変):`quota`(既定)— `autoSwitchThreshold` 超過時に最小 usage を選択;`round-robin` — 均等分散、`accountPoolStickyLimit`(既定 `1`、1–100)で 1 選択あたりの成功 bind 数;`fill-first` — アクティブアカウントを cooldown、再認証、または threshold まで使い切り(未知 usage は強制切替しない)後、安定ソート順で次へ。rotation は provider enforcement を回避しません — 複数アカウント利用は ToS 違反の可能性があります。 ::: ### 管理型レコード形式 diff --git a/docs-site/src/content/docs/ko/reference/configuration.md b/docs-site/src/content/docs/ko/reference/configuration.md index f56e6288dd..25d36153ad 100644 --- a/docs-site/src/content/docs/ko/reference/configuration.md +++ b/docs-site/src/content/docs/ko/reference/configuration.md @@ -53,7 +53,9 @@ namespaced selected id를 bare id로 바꿉니다. | `syncResumeHistory?` | `boolean` | `true` | 되돌릴 수 있는 Codex App 기록 호환 모드. opencodex가 원래 Codex thread metadata를 백업하고, 예전 OpenAI interactive row를 `opencodex`로 재매핑하며, opencodex가 만든 `exec` row를 App에 보이는 source로 잠시 승격합니다. `ocx stop` / `ocx restore`는 백업한 OpenAI row를 복원하고 남은 opencodex user thread를 OpenAI로 돌려 네이티브 Codex가 `config.toml`에서 프록시를 제거한 뒤에도 이어서 열 수 있게 합니다. 끄려면 `false`로 설정합니다. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth 대시보드에서 관리하는 ChatGPT/Codex pool 계정 metadata. secret은 `codex-accounts.json`에 따로 둡니다. | | `activeCodexAccountId?` | `string` | — | 수동으로 선택한 pool 계정. 선택 시 기존 thread affinity를 지우고 다음 요청부터 적용하며, 진행 중인 요청은 기존 계정을 유지합니다. | -| `autoSwitchThreshold?` | `number` | `80` | 새 세션 자동 전환용 사용량 백분율 threshold. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`이면 quota 자동 전환을 끕니다. | +| `autoSwitchThreshold?` | `number` | `80` | 새 세션 자동 전환용 사용량 백분율 threshold. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`이면 quota 자동 전환을 끕니다. `quota` 전략과 `fill-first` drain threshold에도 사용됩니다. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Codex pool의 새 세션 rotation 전략. **새 세션에만** 적용되며 기존 thread id는 affinity를 유지합니다. `quota`(기본) — 활성 계정이 `autoSwitchThreshold`를 넘으면 알려진 usage가 가장 낮은 계정 선택. `round-robin` — 적격 계정 간 smooth weighted 균등 분배. `fill-first` — cooldown, 사용 불가 또는(설정 시) `autoSwitchThreshold`까지 활성 계정을 소진(알 수 없는 usage는 강제 전환하지 않음)한 뒤 안정 정렬 순으로 다음 계정. | +| `accountPoolStickyLimit?` | `number` | `1` | 한 round-robin 선택이 다음으로 넘어가기 전에 유지하는 성공적 새 세션 bind 수. 범위 1–100. `accountPoolStrategy`가 `round-robin`일 때만 적용. | | `upstreamFailoverThreshold?` | `number` | `3` | 일시적인 업스트림 실패가 연속으로 발생한 뒤, 이후 새 세션을 다른 적합한 pool 계정으로 failover할 횟수. `0`이면 실패 기반 failover를 끕니다. | | `modelCacheTtlMs?` | `number` | `300000` | 프로바이더별 `/models` 캐시의 유효 기간(5분). | | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic prompt cache 정책. 끔, 5분 ephemeral, 1시간 extended 중 하나입니다. | @@ -73,8 +75,14 @@ namespaced selected id를 bare id로 바꿉니다. :::note[Codex 계정 풀] pool 계정 추가와 quota 갱신은 대시보드의 **Codex Auth** 페이지에서 처리하세요. 설정에는 secret이 아닌 계정 metadata만 저장하고, access/refresh token은 강화된 Codex 계정 credential store에 따로 -보관합니다. 기존 thread id는 계정 affinity를 유지하며, 새 세션은 quota, cooldown, health에 따라 -자동 라우팅될 수 있습니다. +보관합니다. 기존 thread id는 계정 affinity를 유지하며, 새 세션은 `accountPoolStrategy`, quota, +cooldown, health에 따라 자동 라우팅됩니다. + +**rotation 전략**(새 세션만; bound thread는 변경 없음): `quota`(기본) — `autoSwitchThreshold` 초과 시 +최저 usage 선택; `round-robin` — 균등 분배, `accountPoolStickyLimit`(기본 `1`, 1–100)로 한 선택당 +성공 bind 수; `fill-first` — 활성 계정을 cooldown, 재인증 또는 threshold까지 소진(알 수 없는 usage는 +강제 전환하지 않음)한 뒤 안정 정렬 순으로 다음 계정. rotation은 provider enforcement를 우회하지 +않습니다 — 다계정 사용은 ToS 위반일 수 있습니다. ::: ### 관리형 레코드 형태 diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index ee28eb2706..def212b65c 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -58,7 +58,9 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility mode. opencodex backs up original Codex thread metadata, remaps old OpenAI interactive rows to `opencodex`, and temporarily promotes opencodex-created `exec` rows to an app-visible source. `ocx stop` / `ocx restore` restore backed-up OpenAI rows and eject remaining opencodex user threads to OpenAI so native Codex can resume them after the proxy is removed from `config.toml`. Set `false` to opt out. | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex pool account metadata managed by the Codex Auth dashboard. Secrets live separately in `codex-accounts.json`. | | `activeCodexAccountId?` | `string` | — | Manually selected Pool account. Selection clears existing thread affinity and applies to the next request; in-flight requests keep their captured account. | -| `autoSwitchThreshold?` | `number` | `80` | Usage percent threshold for new-session auto-switching. The score uses the hottest known 5h, weekly, or 30d quota window. Set `0` to disable quota auto-switching. | +| `autoSwitchThreshold?` | `number` | `80` | Usage percent threshold for new-session auto-switching. The score uses the hottest known 5h, weekly, or 30d quota window. Set `0` to disable quota auto-switching. Used by the `quota` strategy and as the drain threshold for `fill-first`. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | New-session rotation strategy for the Codex pool. Applies to **new sessions only**; existing thread ids keep affinity. `quota` — today's default: pick the lowest known usage when the active account crosses `autoSwitchThreshold`. `round-robin` — even spread across eligible accounts via smooth weighted selection. `fill-first` — keep the active account until it cools down, becomes unusable, or crosses `autoSwitchThreshold` when set (unknown usage does not force a switch), then advance to the next eligible account in stable sorted order. | +| `accountPoolStickyLimit?` | `number` | `1` | Successful new-session binds retained on one round-robin selection before advancing. Range 1–100; only applies when `accountPoolStrategy` is `round-robin`. | | `upstreamFailoverThreshold?` | `number` | `3` | Consecutive transient upstream failures before future new sessions fail over to another eligible pool account. Set `0` to disable failure failover. | | `modelCacheTtlMs?` | `number` | `300000` | Freshness window for the per-provider `/models` cache (5 min). | | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic prompt-cache policy: disabled, 5-minute ephemeral, or 1-hour extended. | @@ -99,12 +101,23 @@ also force the same native-provider recovery with `ocx recover-history --legacy- :::note[Codex account pool] Use the dashboard's **Codex Auth** page to add pool accounts and refresh quotas. The config stores non-secret account metadata only; access and refresh tokens are kept in the hardened Codex account -credential store. Existing thread ids keep account affinity, while new sessions can auto-route based -on quota, cooldown, and health. A pre-stream upstream **429**/**402** on one pool account is retried -once on an eligible alternate account in the same request (so Codex CLI does not stall on a depleted -primary while another account still has quota). +credential store. Existing thread ids keep account affinity, while new sessions auto-route based on +`accountPoolStrategy`, quota, cooldown, and health. A pre-stream upstream **429**/**402** on one pool +account is retried once on an eligible alternate account in the same request (so Codex CLI does not +stall on a depleted primary while another account still has quota). ::: +**Rotation strategies** (new sessions only; bound threads are unchanged): + +| Strategy | Behaviour | +| --- | --- | +| `quota` (default) | When the active account's known usage crosses `autoSwitchThreshold`, pick the lowest-usage eligible account across 5h, weekly, and 30d windows. `autoSwitchThreshold: 0` disables quota-based picking. | +| `round-robin` | Even spread across eligible accounts. `accountPoolStickyLimit` (default `1`, range 1–100) keeps that many successful new-session binds on one pick before advancing. | +| `fill-first` | Drain the active account until cooldown, reauthentication, or (when set) `autoSwitchThreshold`; unknown usage does not force a switch. Then advance to the next eligible account in stable sorted order. | + +Rotation strategies do not protect against provider enforcement — multi-account use may violate +provider terms of service. + ### anthropicAccountPool (experimental) Opt-in routing across **multiple Anthropic OAuth accounts** already stored in `auth.json` @@ -116,7 +129,9 @@ organization can share quota; pooling those will not help. | Key | Type | Default | Description | | --- | --- | --- | --- | | `anthropicAccountPool.enabled?` | `boolean` | `false` | When true, sticky session affinity + 429 cooldown failover across eligible Anthropic OAuth accounts. | -| `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | For **new** sessions only: if the active account's **known** cached 5-hour usage is at/above this percent, pick the lowest-usage eligible account. Unknown usage does not force a switch. `0` disables quota-based picking (affinity + active only). | +| `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | For **new** sessions only: if the active account's **known** cached 5-hour usage is at/above this percent, pick the lowest-usage eligible account. Unknown usage does not force a switch. `0` disables quota-based picking (affinity + active only). Also used as the drain threshold for `fill-first`. | +| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | New-session rotation strategy when the pool is enabled. Same semantics as `accountPoolStrategy`; applies to **new** sessions only. | +| `anthropicAccountPool.stickyLimit?` | `number` | `1` | Successful new-session binds retained on one round-robin selection before advancing. Range 1–100; only when `strategy` is `round-robin`. | Reliability contract when enabled: diff --git a/docs-site/src/content/docs/ru/reference/configuration.md b/docs-site/src/content/docs/ru/reference/configuration.md index 4d426322fd..578b7660b7 100644 --- a/docs-site/src/content/docs/ru/reference/configuration.md +++ b/docs-site/src/content/docs/ru/reference/configuration.md @@ -57,7 +57,9 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е | `syncResumeHistory?` | `boolean` | `true` | Обратимый режим совместимости истории Codex App. opencodex резервирует исходные метаданные потоков Codex, переназначает старые интерактивные строки OpenAI на `opencodex` и временно повышает созданные opencodex строки `exec` до видимого в приложении источника. `ocx stop` / `ocx restore` восстанавливают зарезервированные строки OpenAI и возвращают оставшиеся пользовательские потоки opencodex обратно к OpenAI, чтобы нативный Codex мог возобновлять их после удаления прокси из `config.toml`. Установите `false`, чтобы отказаться. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Метаданные аккаунтов пула ChatGPT/Codex, управляемые дашбордом Codex Auth. Секреты хранятся отдельно в `codex-accounts.json`. | | `activeCodexAccountId?` | `string` | — | Вручную выбранный аккаунт пула. Выбор очищает существующие привязки потоков и действует со следующего запроса; выполняющиеся запросы сохраняют захваченный аккаунт. | -| `autoSwitchThreshold?` | `number` | `80` | Порог процента использования для автопереключения новых сессий. Оценка использует самое «горячее» из известных окон квоты — 5-часовое, недельное или 30-дневное. Установите `0`, чтобы отключить автопереключение по квоте. | +| `autoSwitchThreshold?` | `number` | `80` | Порог процента использования для автопереключения новых сессий. Оценка использует самое «горячее» из известных окон квоты — 5-часовое, недельное или 30-дневное. Установите `0`, чтобы отключить автопереключение по квоте. Используется стратегией `quota` и как порог исчерпания для `fill-first`. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Стратегия ротации новых сессий для пула Codex. Применяется **только к новым сессиям**; существующие id потоков сохраняют affinity. `quota` (по умолчанию) — выбор наименьшего известного usage, когда активный аккаунт превышает `autoSwitchThreshold`. `round-robin` — равномерное распределение между подходящими аккаунтами через smooth weighted selection. `fill-first` — использовать активный аккаунт до cooldown, недоступности или (если задано) `autoSwitchThreshold` (неизвестный usage не принуждает к переключению), затем переход к следующему подходящему аккаунту в стабильном отсортированном порядке. | +| `accountPoolStickyLimit?` | `number` | `1` | Число успешных привязок новых сессий, удерживаемых на одном выборе round-robin перед переходом дальше. Диапазон 1–100; только при `accountPoolStrategy` = `round-robin`. | | `upstreamFailoverThreshold?` | `number` | `3` | Число подряд идущих временных сбоев вышестоящей стороны, после которого будущие новые сессии переключаются (failover) на другой подходящий аккаунт пула. Установите `0`, чтобы отключить переключение по сбоям. | | `modelCacheTtlMs?` | `number` | `300000` | Окно свежести кэша `/models` каждого провайдера (5 минут). | | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Политика кэша промптов Anthropic: отключён, эфемерный на 5 минут или расширенный на 1 час. | @@ -79,8 +81,15 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е Используйте страницу **Codex Auth** дашборда для добавления аккаунтов пула и обновления квот. Конфигурация хранит только несекретные метаданные аккаунтов; access- и refresh-токены хранятся в защищённом хранилище учётных данных аккаунтов Codex. Существующие id потоков сохраняют привязку к -аккаунту, а новые сессии могут маршрутизироваться автоматически на основе квоты, cooldown и +аккаунту; новые сессии маршрутизируются по `accountPoolStrategy`, квоте, cooldown и работоспособности. + +**Стратегии ротации** (только новые сессии; привязанные потоки не меняются): `quota` (по умолчанию) +— выбор наименьшего usage при превышении `autoSwitchThreshold`; `round-robin` — равномерное +распределение, `accountPoolStickyLimit` (по умолчанию `1`, 1–100) задаёт число успешных bind на один +выбор; `fill-first` — исчерпание активного аккаунта (cooldown, reauth или threshold; неизвестный +usage не принуждает к переключению), затем переход к следующему. Ротация не защищает от enforcement +провайдера — использование нескольких аккаунтов может нарушать ToS. ::: ### claudeCode (OcxClaudeCodeConfig) diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration.md b/docs-site/src/content/docs/zh-cn/reference/configuration.md index f41c59aea3..916f9fa0bc 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -51,7 +51,9 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 | `syncResumeHistory?` | `boolean` | `true` | 可逆的 Codex App 历史兼容模式。opencodex 会备份原始 Codex thread metadata,把旧 OpenAI interactive row 重映射到 `opencodex`,并暂时把 opencodex 创建的 `exec` row 提升成 App 可见 source。`ocx stop` / `ocx restore` 会恢复已备份的 OpenAI row,并把剩余 opencodex user thread 转回 OpenAI,使原生 Codex 在从 `config.toml` 移除代理后仍能继续这些 thread。设为 `false` 可退出该模式。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth 仪表盘管理的 ChatGPT/Codex pool account metadata。secret 单独存放在 `codex-accounts.json`。 | | `activeCodexAccountId?` | `string` | — | 手动选择的 pool account。选择时清除已有 thread affinity,并从下一次请求开始生效;进行中的请求保留原账号。 | -| `autoSwitchThreshold?` | `number` | `80` | 新 session 自动切换的 usage 百分比 threshold。分数取已知 5 小时、周或 30 天 quota window 中最高的一项。设为 `0` 可禁用 quota 自动切换。 | +| `autoSwitchThreshold?` | `number` | `80` | 新 session 自动切换的 usage 百分比 threshold。分数取已知 5 小时、周或 30 天 quota window 中最高的一项。设为 `0` 可禁用 quota 自动切换。`quota` 策略和 `fill-first` 的耗尽 threshold 都会用到。 | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Codex pool 的新 session 轮换策略。仅适用于**新 session**;已有 thread id 保留 affinity。`quota`(默认):活跃账号超过 `autoSwitchThreshold` 时选已知 usage 最低者。`round-robin`:在合格账号间平滑加权均分。`fill-first`:持续使用活跃账号,直到 cooldown、不可用或(如已设置)超过 `autoSwitchThreshold`(未知 usage 不会强制切换),再按稳定排序进入下一个合格账号。 | +| `accountPoolStickyLimit?` | `number` | `1` | 一次 round-robin 选择在推进前保留的成功新 session 绑定数。范围 1–100;仅当 `accountPoolStrategy` 为 `round-robin` 时生效。 | | `upstreamFailoverThreshold?` | `number` | `3` | 连续发生多少次临时上游失败后,让后续新 session failover 到其他合格 pool account。设为 `0` 可禁用失败切换。 | | `modelCacheTtlMs?` | `number` | `300000` | 每个 provider 的 `/models` 缓存新鲜度窗口(5 分钟)。 | | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic prompt-cache 策略:禁用、5 分钟 ephemeral 或 1 小时 extended。 | @@ -70,7 +72,13 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 :::note[Codex 账号池] 请在仪表盘 **Codex Auth** 页面添加 pool account 并刷新 quota。配置只保存非 secret account metadata;access/refresh token 存放在加固的 Codex account credential store 中。已有 thread id 会 -保留 account affinity,新 session 可按 quota、cooldown 和 health 自动路由。 +保留 account affinity;新 session 按 `accountPoolStrategy`、quota、cooldown 和 health 自动路由。 + +**轮换策略**(仅新 session;已绑定 thread 不变):`quota`(默认)— 活跃账号 usage 超过 +`autoSwitchThreshold` 时选最低者;`round-robin` — 均分,`accountPoolStickyLimit`(默认 `1`, +1–100)控制一次选择保留多少成功绑定;`fill-first` — 耗尽活跃账号(cooldown、需重新认证或 +threshold;未知 usage 不强制切换)后按稳定排序进入下一个。轮换不能规避 provider enforcement — +多账号使用可能违反服务条款。 ::: ### 受管 record 形状 From 0d6647b052d0b118b1aaa450ed56a4d03c12f6a6 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:50:26 +0200 Subject: [PATCH 08/15] docs: clarify Anthropic pool strategy quota window --- docs-site/src/content/docs/reference/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index def212b65c..70a87e0da0 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -130,7 +130,7 @@ organization can share quota; pooling those will not help. | --- | --- | --- | --- | | `anthropicAccountPool.enabled?` | `boolean` | `false` | When true, sticky session affinity + 429 cooldown failover across eligible Anthropic OAuth accounts. | | `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | For **new** sessions only: if the active account's **known** cached 5-hour usage is at/above this percent, pick the lowest-usage eligible account. Unknown usage does not force a switch. `0` disables quota-based picking (affinity + active only). Also used as the drain threshold for `fill-first`. | -| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | New-session rotation strategy when the pool is enabled. Same semantics as `accountPoolStrategy`; applies to **new** sessions only. | +| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | New-session rotation strategy when the pool is enabled. Same round-robin/fill-first semantics as `accountPoolStrategy`; `quota` uses 5-hour bars only (not Codex multi-window scoring). Applies to **new** sessions only. | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | Successful new-session binds retained on one round-robin selection before advancing. Range 1–100; only when `strategy` is `round-robin`. | Reliability contract when enabled: From ea1e2b0f5a7d45e9fa3e6021fc443b64f5ac643d Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:52:44 +0200 Subject: [PATCH 09/15] test(codex): assert round-robin fairness histogram --- tests/codex-pool-rotation.test.ts | 95 +++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/tests/codex-pool-rotation.test.ts b/tests/codex-pool-rotation.test.ts index a09bdf03bc..480fcd3e0c 100644 --- a/tests/codex-pool-rotation.test.ts +++ b/tests/codex-pool-rotation.test.ts @@ -49,6 +49,21 @@ function makeThreeAccountConfig(overrides: Partial = {}): OcxConfig { }); } +const THREE_ACCOUNT_IDS = ["a", "b", "c"] as const; + +function countPicks(picks: Array, ids: readonly string[]): Record { + const counts = Object.fromEntries(ids.map(id => [id, 0])); + for (const pick of picks) { + if (pick && pick in counts) counts[pick]! += 1; + } + return counts; +} + +function shareSpreadPercent(counts: Record, total: number): number { + const shares = Object.values(counts).map(n => (n / total) * 100); + return Math.max(...shares) - Math.min(...shares); +} + describe("pickRoundRobinAccount", () => { beforeEach(() => clearPoolRotationState()); @@ -142,4 +157,84 @@ describe("accountPoolStrategy new-session routing", () => { expect(resolveCodexAccountForThread(null, config)).toBe("a"); expect(resolveCodexAccountForThread("new-thread", config)).toBe("a"); }); + + test( + "round-robin histogram: 99 unbound picks at stickyLimit 1 split 33/33/33", + () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 1, + }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + + const picks = Array.from({ length: 99 }, () => resolveCodexAccountForThread(null, config)); + const counts = countPicks(picks, THREE_ACCOUNT_IDS); + expect(counts).toEqual({ a: 33, b: 33, c: 33 }); + expect(shareSpreadPercent(counts, 99)).toBe(0); + }, + 20_000, + ); + + test("quota baseline histogram: 100 unbound picks stay on active account", () => { + const config = makeThreeAccountConfig(); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + + const picks = Array.from({ length: 100 }, () => resolveCodexAccountForThread(null, config)); + const counts = countPicks(picks, THREE_ACCOUNT_IDS); + expect(counts).toEqual({ a: 100, b: 0, c: 0 }); + expect(shareSpreadPercent(counts, 100)).toBe(100); + }); + + test("round-robin affinity zero-flip on bound thread reuse", () => { + const config = makeThreeAccountConfig({ accountPoolStrategy: "round-robin" }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + + const pinned = resolveCodexAccountForThread("thread-zero-flip", config); + expect(pinned).toBeTruthy(); + config.activeCodexAccountId = pinned === "a" ? "b" : "a"; + + let flips = 0; + let previous = pinned; + for (let i = 0; i < 50; i++) { + const next = resolveCodexAccountForThread("thread-zero-flip", config); + if (next !== previous) flips += 1; + previous = next; + } + expect(flips).toBe(0); + expect(previous).toBe(pinned); + }); + + test("fill-first keeps active account for unbound sessions under threshold", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "fill-first", + activeCodexAccountId: "a", + }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + + const picks = Array.from({ length: 10 }, () => resolveCodexAccountForThread(null, config)); + expect(picks.every(pick => pick === "a")).toBe(true); + }); + + test("fill-first advances when active crosses threshold", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "fill-first", + activeCodexAccountId: "a", + autoSwitchThreshold: 80, + }); + updateAccountQuota("a", 90); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + + const pick = resolveCodexAccountForThread(null, config); + expect(pick).not.toBe("a"); + expect(THREE_ACCOUNT_IDS).toContain(pick); + }); }); From 6f92d7d6447a3ebf9b3c1e0c4152c9c9ae11f6e9 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 05:19:00 +0200 Subject: [PATCH 10/15] fix(auth): peek RR preview and harden pool strategy tests --- src/codex/pool-rotation.ts | 50 +++++++++++++++++++++---- src/codex/routing.ts | 16 ++++---- tests/anthropic-account-pool.test.ts | 56 +++++++++++++++++++++++++++- tests/codex-pool-rotation.test.ts | 45 ++++++++++++++++++++++ 4 files changed, 150 insertions(+), 17 deletions(-) diff --git a/src/codex/pool-rotation.ts b/src/codex/pool-rotation.ts index cf0ae0f07f..d31a97b33e 100644 --- a/src/codex/pool-rotation.ts +++ b/src/codex/pool-rotation.ts @@ -50,6 +50,14 @@ function getOrCreateState(poolKey: string): SelectionState { return state; } +function cloneSelectionState(state: SelectionState): SelectionState { + return { + activeKey: state.activeKey, + successes: state.successes, + currentWeights: new Map(state.currentWeights), + }; +} + function smoothWeightedIndex(ids: string[], state: SelectionState): number { let best = -1; let bestScore = Number.NEGATIVE_INFINITY; @@ -72,15 +80,19 @@ function smoothWeightedIndex(ids: string[], state: SelectionState): number { return best; } -export function pickRoundRobinAccount( - poolKey: string, +/** + * Shared pick core. Mutates `state` the same way live resolve does; callers pass + * either the live map entry or a scratch/clone for dry-run peek. + */ +function pickRoundRobinFromState( eligibleIds: string[], stickyLimit: number, + state: SelectionState, + commitSticky: boolean, ): string | null { if (eligibleIds.length === 0) return null; const limit = normalizeAccountPoolStickyLimit(stickyLimit); - const state = getOrCreateState(poolKey); if (state.activeKey && eligibleIds.includes(state.activeKey)) { return state.activeKey; @@ -95,15 +107,37 @@ export function pickRoundRobinAccount( if (index < 0) return null; const picked = eligibleIds[index]!; - if (limit <= 1) { - return picked; + if (commitSticky && limit > 1) { + state.activeKey = picked; + state.successes = 0; } - - state.activeKey = picked; - state.successes = 0; return picked; } +export function pickRoundRobinAccount( + poolKey: string, + eligibleIds: string[], + stickyLimit: number, +): string | null { + return pickRoundRobinFromState(eligibleIds, stickyLimit, getOrCreateState(poolKey), true); +} + +/** + * Dry-run of {@link pickRoundRobinAccount}: returns the same account resolve would + * pick without advancing ring weights, activeKey, or successes. + */ +export function peekRoundRobinAccount( + poolKey: string, + eligibleIds: string[], + stickyLimit: number, +): string | null { + const live = selectionState.get(poolKey); + const scratch = live + ? cloneSelectionState(live) + : { successes: 0, currentWeights: new Map() }; + return pickRoundRobinFromState(eligibleIds, stickyLimit, scratch, false); +} + export function notePoolRotationSuccess( poolKey: string, accountId: string, diff --git a/src/codex/routing.ts b/src/codex/routing.ts index da2ea8b31d..79412e0487 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -7,8 +7,10 @@ import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime- import { POOL_KEY_CODEX, normalizeAccountPoolStickyLimit, + normalizeAccountPoolStrategy, notePoolRotationFailure, notePoolRotationSuccess, + peekRoundRobinAccount, pickRoundRobinAccount, } from "./pool-rotation"; import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota"; @@ -524,8 +526,9 @@ function pickFillFirstCodexAccount(config: OcxConfig, now: number): string | nul * to the legacy quota path (or when the strategy is quota). * * When `commit` is true (resolve path), promotes active, binds thread affinity, and - * notes RR success. Preview keeps commit=false so it does not mutate config/affinity - * or advance sticky success counters; RR preview still consults the ring via pick. + * notes RR success. When `commit` is false (preview), returns the same RR/fill-first + * account resolve would pick via a dry-run peek — without mutating ring weights, + * activeKey, sticky counters, config, or affinity. */ function pickUnboundStrategyAccount( config: OcxConfig, @@ -533,19 +536,16 @@ function pickUnboundStrategyAccount( now: number, commit: boolean, ): string | null { - const strategy = config.accountPoolStrategy ?? "quota"; + const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy); if (strategy === "quota") return null; let picked: string | null = null; if (strategy === "round-robin") { const eligible = listEligibleCodexAccountIds(config, now); - // Preview must not advance the ring — resolve commits the pick for the request. + const limit = stickyLimitForConfig(config); if (!commit) { - const active = config.activeCodexAccountId; - if (active && eligible.includes(active)) return active; - return eligible[0] ?? null; + return peekRoundRobinAccount(POOL_KEY_CODEX, eligible, limit); } - const limit = stickyLimitForConfig(config); picked = pickRoundRobinAccount(POOL_KEY_CODEX, eligible, limit); if (!picked) return null; setActiveCodexAccount(config, picked); diff --git a/tests/anthropic-account-pool.test.ts b/tests/anthropic-account-pool.test.ts index e5bfe5c11b..9669c67f2e 100644 --- a/tests/anthropic-account-pool.test.ts +++ b/tests/anthropic-account-pool.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { clearPoolRotationState } from "../src/codex/pool-rotation"; +import { clearPoolRotationState, notePoolRotationFailure } from "../src/codex/pool-rotation"; import { anthropicSessionKeyFromParts, bindAnthropicSessionAffinity, @@ -249,4 +249,58 @@ describe("anthropic account pool", () => { expect(picks).toEqual([aId, aId, aId]); expect(resolveAnthropicAccountForSession(null, config).reason).toBe("pool-disabled"); }); + + test("fill-first keeps active under threshold", async () => { + const { aId, bId, cId } = await seedThreeAccounts(); + setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 10 }); + setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 10 }); + setCachedProviderAccountQuotaForTests("anthropic", cId, { fiveHourPercent: 10 }); + const config = cfg(true, 80, { strategy: "fill-first" }); + + const picks = Array.from({ length: 8 }, () => resolveAnthropicAccountForSession(null, config).accountId); + expect(picks.every(id => id === aId)).toBe(true); + expect(resolveAnthropicAccountForSession(null, config).reason).toBe("fill-first"); + }); + + test("stickyLimit holds across successive unbound resolves", async () => { + const { aId, bId, cId } = await seedThreeAccounts(); + setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 10 }); + setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 10 }); + setCachedProviderAccountQuotaForTests("anthropic", cId, { fiveHourPercent: 10 }); + const config = cfg(true, 80, { strategy: "round-robin", stickyLimit: 3 }); + + const first = resolveAnthropicAccountForSession(null, config).accountId; + expect(first).toBeTruthy(); + expect(resolveAnthropicAccountForSession(null, config).accountId).toBe(first); + expect(resolveAnthropicAccountForSession(null, config).accountId).toBe(first); + const fourth = resolveAnthropicAccountForSession(null, config).accountId; + expect(fourth).not.toBe(first); + }); + + test("429 / notePoolRotationFailure advances past sticky while account stays eligible", async () => { + const { aId, bId, cId } = await seedThreeAccounts(); + setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 10 }); + setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 10 }); + setCachedProviderAccountQuotaForTests("anthropic", cId, { fiveHourPercent: 10 }); + const config = cfg(true, 80, { strategy: "round-robin", stickyLimit: 10 }); + + const sticky = resolveAnthropicAccountForSession(null, config).accountId!; + expect(resolveAnthropicAccountForSession(null, config).accountId).toBe(sticky); + + notePoolRotationFailure("anthropic", sticky); + const afterClear = resolveAnthropicAccountForSession(null, config).accountId; + expect(afterClear).toBeTruthy(); + expect(afterClear).not.toBe(sticky); + + // Re-establish sticky, then 429-cool the sticky account — failover + ring must leave it. + clearPoolRotationState(); + const again = resolveAnthropicAccountForSession(null, config).accountId!; + expect(resolveAnthropicAccountForSession(null, config).accountId).toBe(again); + const failover = rotateAnthropicAccountOn429(config, again, "30"); + expect(failover).toBeTruthy(); + expect(failover).not.toBe(again); + const unboundAfter429 = resolveAnthropicAccountForSession(null, config).accountId; + expect(unboundAfter429).not.toBe(again); + expect([bId, cId, aId].filter(id => id !== again)).toContain(unboundAfter429); + }); }); diff --git a/tests/codex-pool-rotation.test.ts b/tests/codex-pool-rotation.test.ts index 480fcd3e0c..11b82e680b 100644 --- a/tests/codex-pool-rotation.test.ts +++ b/tests/codex-pool-rotation.test.ts @@ -4,11 +4,13 @@ import { join } from "node:path"; import { clearPoolRotationState, notePoolRotationSuccess, + peekRoundRobinAccount, pickRoundRobinAccount, } from "../src/codex/pool-rotation"; import { clearCodexUpstreamHealth, clearThreadAccountMap, + previewCodexAccountForRequest, resolveCodexAccountForThread, } from "../src/codex/routing"; import { saveCodexAccountCredential } from "../src/codex/account-store"; @@ -94,6 +96,18 @@ describe("pickRoundRobinAccount", () => { const next = pickRoundRobinAccount("codex", ["b"], 1); expect(next).toBe("b"); }); + + test("peek matches next pick without advancing ring weights", () => { + const ids = ["a", "b", "c"]; + const peek1 = peekRoundRobinAccount("codex", ids, 1); + const peek2 = peekRoundRobinAccount("codex", ids, 1); + expect(peek2).toBe(peek1); + const picked = pickRoundRobinAccount("codex", ids, 1); + expect(picked).toBe(peek1); + const peekAfter = peekRoundRobinAccount("codex", ids, 1); + expect(peekAfter).not.toBe(picked); + expect(pickRoundRobinAccount("codex", ids, 1)).toBe(peekAfter); + }); }); describe("accountPoolStrategy new-session routing", () => { @@ -237,4 +251,35 @@ describe("accountPoolStrategy new-session routing", () => { expect(pick).not.toBe("a"); expect(THREE_ACCOUNT_IDS).toContain(pick); }); + + test("RR preview(null) matches next resolve(null) without advancing until resolve", () => { + const config = makeThreeAccountConfig({ accountPoolStrategy: "round-robin" }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + + const preview1 = previewCodexAccountForRequest(null, config); + const preview2 = previewCodexAccountForRequest(null, config); + expect(preview2).toBe(preview1); + + const resolve1 = resolveCodexAccountForThread(null, config); + expect(resolve1).toBe(preview1); + + const previewAfter = previewCodexAccountForRequest(null, config); + const resolve2 = resolveCodexAccountForThread(null, config); + expect(resolve2).toBe(previewAfter); + expect(resolve2).not.toBe(resolve1); + }); + + test("invalid on-disk strategy defaults to quota like Anthropic", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "weighted" as OcxConfig["accountPoolStrategy"], + }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + + const picks = Array.from({ length: 5 }, () => resolveCodexAccountForThread(null, config)); + expect(picks.every(pick => pick === "a")).toBe(true); + }); }); From 8bd9505a06d5ca9eea545d04f19b9d3e41c0ce5e Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 05:43:20 +0200 Subject: [PATCH 11/15] fix(auth): address Codex pool-strategy review findings Seed RR on manual select, gate affinity re-eval to quota, validate before mutate, keep automatic picks in-memory, and advance fill-first/RR on 429. --- src/codex/auth-api.ts | 14 +- src/codex/auth-context.ts | 4 +- src/codex/pool-rotation.ts | 12 ++ src/codex/routing.ts | 122 ++++++++++++----- src/oauth/anthropic-routing.ts | 38 +++++- src/server/management/oauth-account-routes.ts | 10 +- tests/account-pool-management-api.test.ts | 50 ++++++- tests/anthropic-account-pool.test.ts | 17 +++ tests/codex-pool-rotation.test.ts | 123 +++++++++++++++++- 9 files changed, 341 insertions(+), 49 deletions(-) diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 4b38c3742a..21d17fff19 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -651,20 +651,22 @@ export async function handleCodexAuthAPI( return jsonResponse({ error: "strategy or stickyLimit required" }, 400); } const runtimeConfig = getRuntimeConfig(config); + let nextStrategy: ReturnType | undefined; + let nextSticky: ReturnType | undefined; if (body.strategy !== undefined) { - const strategy = parseAccountPoolStrategy(body.strategy); - if (strategy === null) { + nextStrategy = parseAccountPoolStrategy(body.strategy); + if (nextStrategy === null) { return jsonResponse({ error: 'strategy must be one of: quota, round-robin, fill-first' }, 400); } - runtimeConfig.accountPoolStrategy = strategy; } if (body.stickyLimit !== undefined) { - const stickyLimit = parseAccountPoolStickyLimit(body.stickyLimit); - if (stickyLimit === null) { + nextSticky = parseAccountPoolStickyLimit(body.stickyLimit); + if (nextSticky === null) { return jsonResponse({ error: "stickyLimit must be an integer 1-100" }, 400); } - runtimeConfig.accountPoolStickyLimit = stickyLimit; } + if (nextStrategy !== undefined) runtimeConfig.accountPoolStrategy = nextStrategy; + if (nextSticky !== undefined) runtimeConfig.accountPoolStickyLimit = nextSticky; saveRuntimeConfig(config, runtimeConfig); return jsonResponse({ ok: true, diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 4798777e1d..d15c8c4b11 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -12,7 +12,7 @@ import { getCodexAccountHealthSnapshot, releaseCodexQuotaProbeLease, tryAcquireCodexQuotaProbeLease, - pickLowestUsageCodexAccount, + pickAlternateCodexAccount, resolveCodexAccountForThreadDetailed, } from "./routing"; import type { CodexCooldownSource } from "./routing"; @@ -173,7 +173,7 @@ export async function resolveCodexAuthContext( const threadId = headers.get("x-codex-parent-thread-id"); const resolution = options.excludeAccountId ? (() => { - const accountId = pickLowestUsageCodexAccount(config, options.excludeAccountId); + const accountId = pickAlternateCodexAccount(config, options.excludeAccountId!); return accountId ? { status: "selected" as const, accountId } : { status: "none" as const }; diff --git a/src/codex/pool-rotation.ts b/src/codex/pool-rotation.ts index d31a97b33e..c75f6d34a8 100644 --- a/src/codex/pool-rotation.ts +++ b/src/codex/pool-rotation.ts @@ -165,6 +165,18 @@ export function notePoolRotationFailure(poolKey: string, accountId: string): voi } } +/** + * Force the next sticky/RR pick onto `accountId` (manual dashboard selection). + * Clears sticky success counters and ring weights so the seeded account is held + * for the next new-session pick before ordinary rotation resumes. + */ +export function seedPoolRotationAccount(poolKey: string, accountId: string): void { + const state = getOrCreateState(poolKey); + state.activeKey = accountId; + state.successes = 0; + state.currentWeights.clear(); +} + export function clearPoolRotationState(poolKey?: string): void { if (poolKey === undefined) { selectionState.clear(); diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 79412e0487..f3b339bf46 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -12,6 +12,7 @@ import { notePoolRotationSuccess, peekRoundRobinAccount, pickRoundRobinAccount, + seedPoolRotationAccount, } from "./pool-rotation"; import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota"; import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "./main-account"; @@ -319,6 +320,10 @@ function preservedCooldownFields(health: CodexUpstreamHealth | undefined): Parti /** Manual selection resets transient routing evidence without bypassing a real 429 cooldown. */ export function resetCodexRoutingForManualSelection(accountId: string): void { clearThreadAccountMap(); + // Seed the RR ring so the next unbound new session honors the manually selected account + // under round-robin (affinity-cleared threads / null threadId). Fill-first already follows + // config.activeCodexAccountId, which the caller persists before invoking this. + seedPoolRotationAccount(POOL_KEY_CODEX, accountId); const current = upstreamHealth.get(accountId); if (!current) return; const preserved = preservedCooldownFields(current); @@ -501,17 +506,28 @@ function pickFillFirstCodexAccount(config: OcxConfig, now: number): string | nul return active; } + return pickNextFillFirstCodexAccount(config, active ?? null, eligible, now); +} + +/** Next eligible account in stable order after `afterId` (wrapping). */ +function pickNextFillFirstCodexAccount( + config: OcxConfig, + afterId: string | null, + eligible = listEligibleCodexAccountIds(config, Date.now()), + _now = Date.now(), +): string | null { + if (eligible.length === 0) return null; const ordered = [...eligible].sort((a, b) => a.localeCompare(b)); - if (!active) return ordered[0] ?? null; + if (!afterId) return ordered[0] ?? null; const allConfigured = [ - ...(isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID) || active === MAIN_CODEX_ACCOUNT_ID + ...(isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID) || afterId === MAIN_CODEX_ACCOUNT_ID ? [MAIN_CODEX_ACCOUNT_ID] : []), ...(config.codexAccounts ?? []).filter(account => !account.isMain).map(account => account.id), ]; const stableAll = [...new Set(allConfigured)].sort((a, b) => a.localeCompare(b)); - const startIdx = stableAll.indexOf(active); + const startIdx = stableAll.indexOf(afterId); if (startIdx < 0) return ordered[0] ?? null; for (let step = 1; step <= stableAll.length; step++) { @@ -525,10 +541,12 @@ function pickFillFirstCodexAccount(config: OcxConfig, now: number): string | nul * Unbound new-session pick for round-robin / fill-first. Returns null to fall through * to the legacy quota path (or when the strategy is quota). * - * When `commit` is true (resolve path), promotes active, binds thread affinity, and + * When `commit` is true (resolve path), remembers active in-memory, binds thread affinity, and * notes RR success. When `commit` is false (preview), returns the same RR/fill-first * account resolve would pick via a dry-run peek — without mutating ring weights, * activeKey, sticky counters, config, or affinity. + * + * Automatic strategy picks never sync-write config; only manual selection persists active. */ function pickUnboundStrategyAccount( config: OcxConfig, @@ -548,7 +566,7 @@ function pickUnboundStrategyAccount( } picked = pickRoundRobinAccount(POOL_KEY_CODEX, eligible, limit); if (!picked) return null; - setActiveCodexAccount(config, picked); + rememberActiveCodexAccount(config, picked); if (threadId) bindThreadAffinity(threadId, picked, now); notePoolRotationSuccess(POOL_KEY_CODEX, picked, limit); return picked; @@ -558,7 +576,7 @@ function pickUnboundStrategyAccount( picked = pickFillFirstCodexAccount(config, now); if (!picked) return null; if (commit) { - setActiveCodexAccount(config, picked); + rememberActiveCodexAccount(config, picked); if (threadId) bindThreadAffinity(threadId, picked, now); } return picked; @@ -598,6 +616,34 @@ export function pickLowestUsageCodexAccount(config: OcxConfig, excludeId?: strin return best; } +/** + * Strategy-aware alternate after a cooled/excluded account (same-request 429 retry + * and active promotion). Quota keeps lowest-usage; fill-first advances stable order; + * round-robin takes the next ring pick (caller should have noted the failure). + */ +export function pickAlternateCodexAccount( + config: OcxConfig, + excludeId: string, + now = Date.now(), +): string | null { + const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy); + if (strategy === "round-robin") { + const eligible = listEligibleCodexAccountIds(config, now).filter(id => id !== excludeId); + return pickRoundRobinAccount(POOL_KEY_CODEX, eligible, stickyLimitForConfig(config)); + } + if (strategy === "fill-first") { + const eligible = listEligibleCodexAccountIds(config, now).filter(id => id !== excludeId); + return pickNextFillFirstCodexAccount(config, excludeId, eligible, now); + } + return pickLowestUsageCodexAccount(config, excludeId, now); +} + +/** In-memory active only — automatic strategy rotation must not sync-write config. */ +function rememberActiveCodexAccount(config: OcxConfig, accountId: string): void { + if (config.activeCodexAccountId === accountId) return; + config.activeCodexAccountId = accountId; +} + function setActiveCodexAccount(config: OcxConfig, accountId: string): void { if (config.activeCodexAccountId === accountId) return; config.activeCodexAccountId = accountId; @@ -674,15 +720,20 @@ export function previewCodexAccountForRequest( && isCodexAccountSelectable(config, entry.accountId, now) && !shouldFailover(config, entry.accountId, now) ) { - const threshold = config.autoSwitchThreshold ?? 80; - if (threshold > 0) { - const usage = computeCodexUsageScore( - getAccountQuota(entry.accountId), - getPoolAccountPlan(config, entry.accountId), - ); - if (!isUnknownUsage(usage) && usage >= threshold) { - const best = pickLowerUsageAccount(config, entry.accountId, usage, now); - if (best !== entry.accountId) return best; + // Quota strategy only: non-quota strategies keep affinity for ongoing threads + // (new-session-only rotation — docs / affinity policy A). + const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy); + if (strategy === "quota") { + const threshold = config.autoSwitchThreshold ?? 80; + if (threshold > 0) { + const usage = computeCodexUsageScore( + getAccountQuota(entry.accountId), + getPoolAccountPlan(config, entry.accountId), + ); + if (!isUnknownUsage(usage) && usage >= threshold) { + const best = pickLowerUsageAccount(config, entry.accountId, usage, now); + if (best !== entry.accountId) return best; + } } } return entry.accountId; @@ -749,22 +800,27 @@ export function resolveCodexAccountForThreadDetailed( // thread stays pinned for the full idle TTL (the WSL "never switches" report). // Over-threshold pins re-eval immediately so a depleted primary does not keep // serving for up to 60s after a secondary with quota is available (#584). - const threshold = config.autoSwitchThreshold ?? 80; - const usage = threshold > 0 - ? computeCodexUsageScore( - getAccountQuota(entry.accountId), - getPoolAccountPlan(config, entry.accountId), - ) - : 0; - const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold; - if (overThreshold || now - entry.lastReevalAt >= CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS) { - entry.lastReevalAt = now; - if (overThreshold) { - const best = pickLowerUsageAccount(config, entry.accountId, usage, now); - if (best !== entry.accountId) { - setActiveCodexAccount(config, best); - bindThreadAffinity(threadId, best, now); // rebinds + resets clocks - return { status: "selected", accountId: best }; + // Non-quota strategies (RR / fill-first) keep affinity for ongoing threads — + // rotation is new-session-only (affinity policy A). + const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy); + if (strategy === "quota") { + const threshold = config.autoSwitchThreshold ?? 80; + const usage = threshold > 0 + ? computeCodexUsageScore( + getAccountQuota(entry.accountId), + getPoolAccountPlan(config, entry.accountId), + ) + : 0; + const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold; + if (overThreshold || now - entry.lastReevalAt >= CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS) { + entry.lastReevalAt = now; + if (overThreshold) { + const best = pickLowerUsageAccount(config, entry.accountId, usage, now); + if (best !== entry.accountId) { + setActiveCodexAccount(config, best); + bindThreadAffinity(threadId, best, now); // rebinds + resets clocks + return { status: "selected", accountId: best }; + } } } } @@ -898,8 +954,8 @@ export function recordCodexUpstreamOutcome( clearThreadAccountMapForAccount(accountId); notePoolRotationFailure(POOL_KEY_CODEX, accountId); if (config.activeCodexAccountId === accountId) { - const fallback = pickLowestUsageCodexAccount(config, accountId, now); - if (fallback) setActiveCodexAccount(config, fallback); + const fallback = pickAlternateCodexAccount(config, accountId, now); + if (fallback) rememberActiveCodexAccount(config, fallback); } return; } diff --git a/src/oauth/anthropic-routing.ts b/src/oauth/anthropic-routing.ts index ab9134f35e..9f047329f5 100644 --- a/src/oauth/anthropic-routing.ts +++ b/src/oauth/anthropic-routing.ts @@ -184,6 +184,42 @@ function pickLowestUsage(excludeId: string | undefined, now: number): string | n return best; } +/** Next eligible Anthropic account in stable order after `afterId` (wrapping). */ +function pickNextFillFirstAnthropicAccount( + afterId: string, + eligible: string[], +): string | null { + if (eligible.length === 0) return null; + const ordered = [...eligible].sort((a, b) => a.localeCompare(b)); + const set = getAccountSet(PROVIDER); + const stableAll = set + ? [...set.accounts.map(a => a.id)].sort((a, b) => a.localeCompare(b)) + : ordered; + const startIdx = stableAll.indexOf(afterId); + if (startIdx < 0) return ordered[0] ?? null; + for (let step = 1; step <= stableAll.length; step++) { + const candidate = stableAll[(startIdx + step) % stableAll.length]!; + if (eligible.includes(candidate)) return candidate; + } + return ordered[0] ?? null; +} + +function pickAlternateAnthropicAccount( + config: OcxConfig, + excludeId: string, + now: number, +): string | null { + const strategy = anthropicPoolStrategy(config); + const eligible = getEligibleAnthropicAccounts(now).filter(id => id !== excludeId); + if (strategy === "round-robin") { + return pickRoundRobinAccount(POOL_KEY_ANTHROPIC, eligible, stickyLimitForPool(config)); + } + if (strategy === "fill-first") { + return pickNextFillFirstAnthropicAccount(excludeId, eligible); + } + return pickLowestUsage(excludeId, now); +} + function pruneExpiredAffinity(now: number): void { for (const [key, entry] of sessionAffinity) { if (now - entry.lastUsedAt > AFFINITY_IDLE_TTL_MS) sessionAffinity.delete(key); @@ -411,7 +447,7 @@ export function rotateAnthropicAccountOn429( clearAnthropicSessionAffinityForAccount(failedAccountId); notePoolRotationFailure(POOL_KEY_ANTHROPIC, failedAccountId); - const next = pickLowestUsage(failedAccountId, now); + const next = pickAlternateAnthropicAccount(config, failedAccountId, now); if (!next) { console.warn("[anthropic-pool] all eligible Anthropic OAuth accounts are in cooldown; returning 429"); return null; diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index ef47c7b443..c4a9796530 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -247,7 +247,11 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< }; const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : ""; if (provider !== "anthropic") return jsonResponse({ error: "pool config is only supported for anthropic" }, 400); - if (typeof body.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400); + let enabled = config.anthropicAccountPool?.enabled === true; + if (body.enabled !== undefined) { + if (typeof body.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400); + enabled = body.enabled; + } let threshold = config.anthropicAccountPool?.autoSwitchThreshold ?? 80; if (body.autoSwitchThreshold !== undefined) { if ( @@ -277,7 +281,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< stickyLimit = parsed; } config.anthropicAccountPool = { - enabled: body.enabled, + enabled, autoSwitchThreshold: threshold, ...(strategy !== undefined ? { strategy } : {}), ...(stickyLimit !== undefined ? { stickyLimit } : {}), @@ -286,7 +290,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< return jsonResponse({ ok: true, provider, - enabled: body.enabled, + enabled, autoSwitchThreshold: threshold, strategy: normalizeAccountPoolStrategy(strategy), stickyLimit: normalizeAccountPoolStickyLimit(stickyLimit), diff --git a/tests/account-pool-management-api.test.ts b/tests/account-pool-management-api.test.ts index 4bb2a9387a..1507a636ba 100644 --- a/tests/account-pool-management-api.test.ts +++ b/tests/account-pool-management-api.test.ts @@ -110,8 +110,20 @@ describe("Codex account pool strategy management API", () => { expect(config.accountPoolStrategy).toBe("round-robin"); expect(config.accountPoolStickyLimit).toBe(2); }); -}); + test("PUT rejects invalid stickyLimit without mutating a valid strategy in the same body", async () => { + const config = makeCodexConfig({ accountPoolStrategy: "quota", accountPoolStickyLimit: 1 }); + const req = new Request("http://localhost/api/codex-auth/pool-strategy", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ strategy: "fill-first", stickyLimit: 0 }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(resp!.status).toBe(400); + expect(config.accountPoolStrategy).toBe("quota"); + expect(config.accountPoolStickyLimit).toBe(1); + }); +}); describe("Anthropic account pool strategy management API", () => { let testDir = ""; let previousHome: string | undefined; @@ -273,4 +285,40 @@ describe("Anthropic account pool strategy management API", () => { await server.stop(true); } }); + + test("PATCH with provider+strategy omits enabled and keeps current enabled", async () => { + const server = startServer(0); + try { + await fetch(new URL("/api/oauth/accounts/pool", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "anthropic", + enabled: true, + strategy: "quota", + }), + }); + const patch = await fetch(new URL("/api/oauth/accounts/pool", server.url), { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "anthropic", + strategy: "round-robin", + }), + }); + expect(patch.status).toBe(200); + expect(await patch.json()).toMatchObject({ + ok: true, + enabled: true, + strategy: "round-robin", + }); + const get = await fetch(new URL("/api/oauth/accounts/pool?provider=anthropic", server.url)); + expect(await get.json()).toMatchObject({ + enabled: true, + strategy: "round-robin", + }); + } finally { + await server.stop(true); + } + }); }); diff --git a/tests/anthropic-account-pool.test.ts b/tests/anthropic-account-pool.test.ts index 9669c67f2e..86a9c18f64 100644 --- a/tests/anthropic-account-pool.test.ts +++ b/tests/anthropic-account-pool.test.ts @@ -303,4 +303,21 @@ describe("anthropic account pool", () => { expect(unboundAfter429).not.toBe(again); expect([bId, cId, aId].filter(id => id !== again)).toContain(unboundAfter429); }); + + test("fill-first 429 advances next in stable order, not lowest usage", async () => { + const { aId, bId, cId } = await seedThreeAccounts(); + // Sorted ids: force usage so lowest-usage would pick cId. + setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 10 }); + setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 50 }); + setCachedProviderAccountQuotaForTests("anthropic", cId, { fiveHourPercent: 5 }); + const config = cfg(true, 80, { strategy: "fill-first" }); + + const ordered = [aId, bId, cId].sort((a, b) => a.localeCompare(b)); + // Ensure active is the first in stable order so fill-first holds it. + await setActiveAccount("anthropic", ordered[0]!); + expect(resolveAnthropicAccountForSession(null, config).accountId).toBe(ordered[0]); + + const failover = rotateAnthropicAccountOn429(config, ordered[0]!, "30"); + expect(failover).toBe(ordered[1]); + }); }); diff --git a/tests/codex-pool-rotation.test.ts b/tests/codex-pool-rotation.test.ts index 11b82e680b..8601f1fe1d 100644 --- a/tests/codex-pool-rotation.test.ts +++ b/tests/codex-pool-rotation.test.ts @@ -1,6 +1,3 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { existsSync, mkdirSync, rmSync } from "node:fs"; -import { join } from "node:path"; import { clearPoolRotationState, notePoolRotationSuccess, @@ -10,12 +7,20 @@ import { import { clearCodexUpstreamHealth, clearThreadAccountMap, + isCodexAccountInCooldown, + pickAlternateCodexAccount, previewCodexAccountForRequest, + recordCodexUpstreamOutcome, + resetCodexRoutingForManualSelection, resolveCodexAccountForThread, } from "../src/codex/routing"; import { saveCodexAccountCredential } from "../src/codex/account-store"; import { clearAccountQuota, updateAccountQuota } from "../src/codex/auth-api"; +import { getConfigPath } from "../src/config"; import type { OcxConfig } from "../src/types"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; const TEST_DIR = join(import.meta.dir, ".tmp-codex-pool-rotation-test"); let previousOpencodexHome: string | undefined; @@ -282,4 +287,116 @@ describe("accountPoolStrategy new-session routing", () => { const picks = Array.from({ length: 5 }, () => resolveCodexAccountForThread(null, config)); expect(picks.every(pick => pick === "a")).toBe(true); }); + + test("manual selection seeds RR so the next unbound session uses that account", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 1, + activeCodexAccountId: "a", + }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + + // Advance the ring away from a predictable starting point. + resolveCodexAccountForThread(null, config); + resolveCodexAccountForThread(null, config); + + config.activeCodexAccountId = "c"; + resetCodexRoutingForManualSelection("c"); + + expect(resolveCodexAccountForThread(null, config)).toBe("c"); + }); + + test("bound thread under RR does not re-eval on quota threshold", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "round-robin", + autoSwitchThreshold: 80, + }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + + const pinned = resolveCodexAccountForThread("rr-affinity-pin", config); + expect(pinned).toBeTruthy(); + updateAccountQuota(pinned!, 95); + for (const id of THREE_ACCOUNT_IDS) { + if (id !== pinned) updateAccountQuota(id, 5); + } + + expect(resolveCodexAccountForThread("rr-affinity-pin", config)).toBe(pinned); + expect(previewCodexAccountForRequest("rr-affinity-pin", config)).toBe(pinned); + }); + + test("bound thread under fill-first does not re-eval on quota threshold", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "fill-first", + activeCodexAccountId: "a", + autoSwitchThreshold: 80, + }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + + expect(resolveCodexAccountForThread("ff-affinity-pin", config)).toBe("a"); + updateAccountQuota("a", 95); + updateAccountQuota("b", 5); + updateAccountQuota("c", 5); + + expect(resolveCodexAccountForThread("ff-affinity-pin", config)).toBe("a"); + }); + + test("RR unbound picks do not sync-write config.json", () => { + const configPath = getConfigPath(); + if (existsSync(configPath)) rmSync(configPath); + + const config = makeThreeAccountConfig({ + accountPoolStrategy: "round-robin", + activeCodexAccountId: "a", + }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + + for (let i = 0; i < 6; i++) resolveCodexAccountForThread(null, config); + + expect(existsSync(configPath)).toBe(false); + }); + + test("fill-first 429 advances to next stable account, not lowest usage", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "fill-first", + activeCodexAccountId: "a", + autoSwitchThreshold: 80, + }); + // Usage ordering would prefer c (lowest), but fill-first advances a → b in sorted order. + updateAccountQuota("a", 10); + updateAccountQuota("b", 50); + updateAccountQuota("c", 5); + + expect(resolveCodexAccountForThread(null, config)).toBe("a"); + recordCodexUpstreamOutcome(config, "a", 429); + expect(config.activeCodexAccountId).toBe("b"); + expect(pickAlternateCodexAccount(config, "a")).toBe("b"); + }); + + test("RR 429 promotes via ring, not lowest usage", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 1, + activeCodexAccountId: "a", + }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 90); + updateAccountQuota("c", 5); + + const first = resolveCodexAccountForThread(null, config)!; + recordCodexUpstreamOutcome(config, first, 429); + const promoted = config.activeCodexAccountId; + expect(promoted).toBeTruthy(); + expect(promoted).not.toBe(first); + // Lowest usage is c; ring may pick b. Either is fine as long as it is not lowest-usage-forced when + // that would disagree with the ring — assert we did not stay on the failed account. + expect(isCodexAccountInCooldown(first)).toBe(true); + }); }); From 95fd3f40020aeb905af81c710da0d085919af797 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 05:43:30 +0200 Subject: [PATCH 12/15] fix(gui): keep auto-switch tests mounting with strategy card Mock shared /active defaults and pool-strategy so CodexPoolStrategySetting no longer races the auto-switch harness. --- .../codex-auto-switch-controller.test.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/gui/tests/codex-auto-switch-controller.test.tsx b/gui/tests/codex-auto-switch-controller.test.tsx index e6cae7fdde..4c37b5c1bd 100644 --- a/gui/tests/codex-auto-switch-controller.test.tsx +++ b/gui/tests/codex-auto-switch-controller.test.tsx @@ -125,16 +125,31 @@ async function mountHarness(): Promise { value: () => {}, }); + const defaultActivePayload = { + activeCodexAccountId: null, + autoSwitchThreshold: 80, + accountPoolStrategy: "quota", + accountPoolStickyLimit: 1, + }; const fetchRouter = async (input: string | URL | Request, init?: RequestInit): Promise => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; const method = init?.method ?? (input instanceof Request ? input.method : "GET"); if (url.endsWith("/api/codex-auth/accounts") && method === "GET") { return Response.json({ accounts: [] }); } + // Pool controller + strategy card both GET /active; prefer queued responses for + // stale-refresh tests, otherwise return a stable default so neither consumer fails. if (url.endsWith("/api/codex-auth/active") && method === "GET") { const response = activeResponses.shift(); - if (!response) throw new Error("unexpected active-account read"); - return await response; + if (response) return await response; + return Response.json(defaultActivePayload); + } + if (url.endsWith("/api/codex-auth/pool-strategy") && (method === "PUT" || method === "PATCH")) { + return Response.json({ + ok: true, + accountPoolStrategy: "quota", + accountPoolStickyLimit: 1, + }); } if (url.endsWith("/api/codex-auth/auto-switch") && method === "PUT") { const body = JSON.parse(String(init?.body)) as { threshold: number }; From 2a04ae603ab7a68b97d889281f6b45cf1de0fd7b Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 06:01:04 +0200 Subject: [PATCH 13/15] fix(auth): typecheck and remaining pool-strategy P2s --- src/codex/auth-api.ts | 14 +++--- src/codex/routing.ts | 15 +++++- src/oauth/anthropic-routing.ts | 15 ++++-- src/server/management/oauth-account-routes.ts | 4 ++ src/server/responses/core.ts | 2 + tests/anthropic-account-pool.test.ts | 32 +++++++++++- tests/codex-pool-rotation.test.ts | 50 +++++++++++++++++++ 7 files changed, 120 insertions(+), 12 deletions(-) diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 21d17fff19..757380c941 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -651,19 +651,21 @@ export async function handleCodexAuthAPI( return jsonResponse({ error: "strategy or stickyLimit required" }, 400); } const runtimeConfig = getRuntimeConfig(config); - let nextStrategy: ReturnType | undefined; - let nextSticky: ReturnType | undefined; + let nextStrategy: NonNullable> | undefined; + let nextSticky: NonNullable> | undefined; if (body.strategy !== undefined) { - nextStrategy = parseAccountPoolStrategy(body.strategy); - if (nextStrategy === null) { + const parsed = parseAccountPoolStrategy(body.strategy); + if (parsed === null) { return jsonResponse({ error: 'strategy must be one of: quota, round-robin, fill-first' }, 400); } + nextStrategy = parsed; } if (body.stickyLimit !== undefined) { - nextSticky = parseAccountPoolStickyLimit(body.stickyLimit); - if (nextSticky === null) { + const parsed = parseAccountPoolStickyLimit(body.stickyLimit); + if (parsed === null) { return jsonResponse({ error: "stickyLimit must be an integer 1-100" }, 400); } + nextSticky = parsed; } if (nextStrategy !== undefined) runtimeConfig.accountPoolStrategy = nextStrategy; if (nextSticky !== undefined) runtimeConfig.accountPoolStickyLimit = nextSticky; diff --git a/src/codex/routing.ts b/src/codex/routing.ts index f3b339bf46..674bbc0b9d 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -116,6 +116,12 @@ export type CodexUpstreamOutcomeMeta = { * cooldown (#433). */ probeLeaseId?: string; + /** + * Already-chosen alternate for same-request 429 retry. When set, promotion + * reuses this account instead of calling {@link pickAlternateCodexAccount} + * again (which would advance a round-robin ring twice). + */ + promoteAccountId?: string; }; function hasConfiguredPoolAccount(config: OcxConfig, accountId: string): boolean { @@ -682,7 +688,7 @@ function shouldFailover(config: OcxConfig, accountId: string, now: number): bool function applyFailureFailover(config: OcxConfig, active: string, now: number): string { if (!shouldFailover(config, active, now)) return active; - const best = pickLowestUsageCodexAccount(config, active, now); + const best = pickAlternateCodexAccount(config, active, now); if (best) { setActiveCodexAccount(config, best); return best; @@ -954,7 +960,12 @@ export function recordCodexUpstreamOutcome( clearThreadAccountMapForAccount(accountId); notePoolRotationFailure(POOL_KEY_CODEX, accountId); if (config.activeCodexAccountId === accountId) { - const fallback = pickAlternateCodexAccount(config, accountId, now); + // Same-request 429 retry already picked via excludeAccountId — reuse it so + // round-robin does not advance the ring a second time. + const reused = meta.promoteAccountId && meta.promoteAccountId !== accountId + ? meta.promoteAccountId + : null; + const fallback = reused ?? pickAlternateCodexAccount(config, accountId, now); if (fallback) rememberActiveCodexAccount(config, fallback); } return; diff --git a/src/oauth/anthropic-routing.ts b/src/oauth/anthropic-routing.ts index 9f047329f5..ff095de168 100644 --- a/src/oauth/anthropic-routing.ts +++ b/src/oauth/anthropic-routing.ts @@ -25,6 +25,7 @@ import { notePoolRotationSuccess, pickRoundRobinAccount, POOL_KEY_ANTHROPIC, + seedPoolRotationAccount, } from "../codex/pool-rotation"; import type { OcxAccountPoolRotationStrategy, OcxConfig } from "../types"; @@ -351,9 +352,8 @@ export function resolveAnthropicAccountForSession( const strategyPick = pickUnboundStrategyAccount(config, now); if (strategyPick) { - if (strategyPick.accountId !== set.activeAccountId) { - promoteAnthropicActiveAccount(strategyPick.accountId); - } + // Do not promote active here — token validation may still fail. Callers + // (responses/core) promote after getAnthropicPoolAccessToken succeeds. if (key) { sessionAffinity.set(key, { accountId: strategyPick.accountId, lastUsedAt: now }); pruneExpiredAffinity(now); @@ -468,6 +468,15 @@ export function promoteAnthropicActiveAccount(accountId: string): void { void setActiveAccount(PROVIDER, accountId).catch(() => { /* best-effort */ }); } +/** + * Manual selection resets session affinity and seeds the RR ring so the next + * unbound new session honors the operator-chosen account (Codex parity). + */ +export function resetAnthropicRoutingForManualSelection(accountId: string): void { + sessionAffinity.clear(); + seedPoolRotationAccount(POOL_KEY_ANTHROPIC, accountId); +} + /** * Resolve a bearer for pool traffic without adopting a newer global Claude CLI * credential into a background multiauth `local-cli` slot (same fail-closed rule diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index c4a9796530..1a19adf95e 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -218,6 +218,10 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< if (!body.accountId) return jsonResponse({ error: "missing accountId" }, 400); const { setActiveAccount } = await import("../../oauth/store"); if (!(await setActiveAccount(provider, body.accountId))) return jsonResponse({ error: "account not found" }, 404); + if (provider === "anthropic") { + const { resetAnthropicRoutingForManualSelection } = await import("../../oauth/anthropic-routing"); + resetAnthropicRoutingForManualSelection(body.accountId); + } const { clearProviderQuotaCache } = await import("../../providers/quota"); clearProviderQuotaCache(); return jsonResponse({ ok: true, provider, activeAccountId: body.accountId }); diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index dc96fa01d0..6a6ffb31d6 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -311,6 +311,8 @@ async function retryCodexPoolOnAlternateAccount( ].filter(Boolean), threadId: req.headers.get("x-codex-parent-thread-id"), probeLeaseId: codexProbeLeaseId(firstAuthCtx), + // Retry already advanced the RR ring via excludeAccountId — reuse for promotion. + ...(retryAuthCtx.accountId ? { promoteAccountId: retryAuthCtx.accountId } : {}), }); const retryHeaders = headersForCodexAuthContext(req.headers, retryAuthCtx); diff --git a/tests/anthropic-account-pool.test.ts b/tests/anthropic-account-pool.test.ts index 86a9c18f64..25cbba7917 100644 --- a/tests/anthropic-account-pool.test.ts +++ b/tests/anthropic-account-pool.test.ts @@ -11,9 +11,10 @@ import { getEligibleAnthropicAccounts, isAnthropicAccountPoolEnabled, resolveAnthropicAccountForSession, + resetAnthropicRoutingForManualSelection, rotateAnthropicAccountOn429, } from "../src/oauth/anthropic-routing"; -import { saveCredential, setActiveAccount } from "../src/oauth/store"; +import { getAccountSet, saveCredential, setActiveAccount } from "../src/oauth/store"; import { clearAccountQuotaCache, setCachedProviderAccountQuotaForTests } from "../src/providers/quota"; import type { OcxAccountPoolRotationStrategy, OcxConfig } from "../src/types"; @@ -320,4 +321,33 @@ describe("anthropic account pool", () => { const failover = rotateAnthropicAccountOn429(config, ordered[0]!, "30"); expect(failover).toBe(ordered[1]); }); + + test("unbound strategy pick does not promote active before token validation", async () => { + const { aId, bId, cId } = await seedThreeAccounts(); + setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 10 }); + setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 10 }); + setCachedProviderAccountQuotaForTests("anthropic", cId, { fiveHourPercent: 10 }); + await setActiveAccount("anthropic", aId); + const config = cfg(true, 80, { strategy: "round-robin", stickyLimit: 1 }); + + const before = getAccountSet("anthropic")!.activeAccountId; + const picks = Array.from({ length: 3 }, () => resolveAnthropicAccountForSession(null, config)); + expect(new Set(picks.map(p => p.accountId)).size).toBe(3); + expect(getAccountSet("anthropic")!.activeAccountId).toBe(before); + }); + + test("manual selection seeds RR so the next unbound session uses that account", async () => { + const { aId, bId, cId } = await seedThreeAccounts(); + setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 10 }); + setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 10 }); + setCachedProviderAccountQuotaForTests("anthropic", cId, { fiveHourPercent: 10 }); + const config = cfg(true, 80, { strategy: "round-robin", stickyLimit: 1 }); + + resolveAnthropicAccountForSession(null, config); + resolveAnthropicAccountForSession(null, config); + + await setActiveAccount("anthropic", cId); + resetAnthropicRoutingForManualSelection(cId); + expect(resolveAnthropicAccountForSession(null, config).accountId).toBe(cId); + }); }); diff --git a/tests/codex-pool-rotation.test.ts b/tests/codex-pool-rotation.test.ts index 8601f1fe1d..8e5fbd89ca 100644 --- a/tests/codex-pool-rotation.test.ts +++ b/tests/codex-pool-rotation.test.ts @@ -399,4 +399,54 @@ describe("accountPoolStrategy new-session routing", () => { // that would disagree with the ring — assert we did not stay on the failed account. expect(isCodexAccountInCooldown(first)).toBe(true); }); + + test("429 retry reuse promoteAccountId avoids a second RR ring advance", () => { + const makeRr = () => makeThreeAccountConfig({ + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 1, + activeCodexAccountId: "a", + }); + for (const id of ["a", "b", "c"]) { + updateAccountQuota(id, 10); + } + + clearPoolRotationState(); + const withReuse = makeRr(); + const retry = pickAlternateCodexAccount(withReuse, "a"); + expect(retry).toBeTruthy(); + expect(retry).not.toBe("a"); + recordCodexUpstreamOutcome(withReuse, "a", 429, { promoteAccountId: retry! }); + expect(withReuse.activeCodexAccountId).toBe(retry); + + clearPoolRotationState(); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + const withoutReuse = makeRr(); + for (const id of ["a", "b", "c"]) updateAccountQuota(id, 10); + const firstPick = pickAlternateCodexAccount(withoutReuse, "a"); + expect(firstPick).toBeTruthy(); + recordCodexUpstreamOutcome(withoutReuse, "a", 429); + // A second ring advance during record would promote past firstPick. + expect(withoutReuse.activeCodexAccountId).not.toBe(firstPick); + expect(withoutReuse.activeCodexAccountId).not.toBe("a"); + }); + + test("fill-first transient failover advances stable order, not lowest usage", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "fill-first", + activeCodexAccountId: "a", + upstreamFailoverThreshold: 3, + autoSwitchThreshold: 80, + }); + // Lowest usage is c; fill-first must advance a → b in sorted id order. + updateAccountQuota("a", 10); + updateAccountQuota("b", 50); + updateAccountQuota("c", 5); + + expect(resolveCodexAccountForThread(null, config)).toBe("a"); + recordCodexUpstreamOutcome(config, "a", 503); + recordCodexUpstreamOutcome(config, "a", 503); + recordCodexUpstreamOutcome(config, "a", 503); + expect(config.activeCodexAccountId).toBe("b"); + }); }); From a96d705d8669b4c0967eb4de5513f446f0a705f4 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 06:20:39 +0200 Subject: [PATCH 14/15] fix(auth): restore #584 retry and harden pool rotation edges --- src/codex/auth-api.ts | 4 +- src/codex/routing.ts | 79 ++++++++++++++++++++++------ src/oauth/anthropic-routing.ts | 50 ++++++++++++------ tests/anthropic-account-pool.test.ts | 69 +++++++++++++++++------- tests/codex-pool-rotation.test.ts | 31 ++++++++--- 5 files changed, 175 insertions(+), 58 deletions(-) diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 757380c941..54113845e6 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -16,7 +16,7 @@ import { parseAccountPoolStickyLimit, parseAccountPoolStrategy, } from "./pool-rotation"; -import { clearCodexAccountCooldown, resetCodexRoutingForManualSelection } from "./routing"; +import { clearCodexAccountCooldown, getEffectiveActiveCodexAccountId, resetCodexRoutingForManualSelection } from "./routing"; import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens, readCodexTokensResult } from "./auth-collision"; export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision"; export { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; @@ -621,7 +621,7 @@ export async function handleCodexAuthAPI( if (url.pathname === "/api/codex-auth/active" && req.method === "GET") { const runtimeConfig = getRuntimeConfig(config); return jsonResponse({ - activeCodexAccountId: runtimeConfig.activeCodexAccountId ?? null, + activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null, autoSwitchThreshold: runtimeConfig.autoSwitchThreshold ?? 80, upstreamFailoverThreshold: runtimeConfig.upstreamFailoverThreshold ?? 3, accountPoolStrategy: normalizeAccountPoolStrategy(runtimeConfig.accountPoolStrategy), diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 674bbc0b9d..f127b12def 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -34,6 +34,14 @@ export type CodexThreadResolution = | { status: "expired"; accountId: string }; const threadAccountMap = new Map(); +/** + * Process-local cursor for automatic RR/fill-first (and quota-429 when not + * sync-writing) picks. Keeps unrelated `saveConfig` from persisting transient + * rotation as the operator's `activeCodexAccountId`. Manual selection clears it + * so disk/`config.activeCodexAccountId` remains authoritative. + */ +let runtimeActiveCodexAccountId: string | undefined; + type CodexUpstreamHealth = { consecutiveFailures: number; /** Consecutive healthy terminals observed while recovering from escalation level 2+. */ @@ -141,6 +149,7 @@ export function clearThreadAccountMapForAccount(accountId: string): void { export function clearCodexUpstreamHealth(): void { upstreamHealth.clear(); + runtimeActiveCodexAccountId = undefined; } export function clearCodexUpstreamHealthForAccount(accountId: string): void { @@ -326,6 +335,8 @@ function preservedCooldownFields(health: CodexUpstreamHealth | undefined): Parti /** Manual selection resets transient routing evidence without bypassing a real 429 cooldown. */ export function resetCodexRoutingForManualSelection(accountId: string): void { clearThreadAccountMap(); + // Manual selection is the operator source of truth — drop any automatic runtime cursor. + runtimeActiveCodexAccountId = undefined; // Seed the RR ring so the next unbound new session honors the manually selected account // under round-robin (affinity-cleared threads / null threadId). Fill-first already follows // config.activeCodexAccountId, which the caller persists before invoking this. @@ -507,7 +518,7 @@ function pickFillFirstCodexAccount(config: OcxConfig, now: number): string | nul const eligible = listEligibleCodexAccountIds(config, now); if (eligible.length === 0) return null; - const active = config.activeCodexAccountId; + const active = getEffectiveActiveCodexAccountId(config); if (active && eligible.includes(active) && isActiveUnderFillFirstThreshold(config, active)) { return active; } @@ -524,7 +535,13 @@ function pickNextFillFirstCodexAccount( ): string | null { if (eligible.length === 0) return null; const ordered = [...eligible].sort((a, b) => a.localeCompare(b)); - if (!afterId) return ordered[0] ?? null; + if (!afterId) { + // Prefer an under-threshold account when starting with no active cursor. + for (const id of ordered) { + if (isActiveUnderFillFirstThreshold(config, id)) return id; + } + return ordered[0] ?? null; + } const allConfigured = [ ...(isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID) || afterId === MAIN_CODEX_ACCOUNT_ID @@ -534,13 +551,22 @@ function pickNextFillFirstCodexAccount( ]; const stableAll = [...new Set(allConfigured)].sort((a, b) => a.localeCompare(b)); const startIdx = stableAll.indexOf(afterId); - if (startIdx < 0) return ordered[0] ?? null; + if (startIdx < 0) { + for (const id of ordered) { + if (isActiveUnderFillFirstThreshold(config, id)) return id; + } + return ordered[0] ?? null; + } + // Skip successors that are also at/above threshold (known drained usage). + let fallback: string | null = null; for (let step = 1; step <= stableAll.length; step++) { const candidate = stableAll[(startIdx + step) % stableAll.length]!; - if (eligible.includes(candidate)) return candidate; + if (!eligible.includes(candidate)) continue; + if (!fallback) fallback = candidate; + if (isActiveUnderFillFirstThreshold(config, candidate)) return candidate; } - return ordered[0] ?? null; + return fallback ?? ordered[0] ?? null; } /** @@ -553,6 +579,10 @@ function pickNextFillFirstCodexAccount( * activeKey, sticky counters, config, or affinity. * * Automatic strategy picks never sync-write config; only manual selection persists active. + * + * Known limitation (follow-up): when a subagent preview peeks an RR account and the request + * then falls back to a non-Codex provider, the ring is not reserved/committed. Prefer seeding + * the peeked account if that path becomes load-bearing. */ function pickUnboundStrategyAccount( config: OcxConfig, @@ -644,18 +674,36 @@ export function pickAlternateCodexAccount( return pickLowestUsageCodexAccount(config, excludeId, now); } -/** In-memory active only — automatic strategy rotation must not sync-write config. */ -function rememberActiveCodexAccount(config: OcxConfig, accountId: string): void { - if (config.activeCodexAccountId === accountId) return; - config.activeCodexAccountId = accountId; +/** Effective active: automatic runtime cursor, else operator/persisted selection. */ +export function getEffectiveActiveCodexAccountId(config: OcxConfig): string | undefined { + return runtimeActiveCodexAccountId ?? config.activeCodexAccountId; } +/** + * Automatic strategy / failover cursor only — never mutates `config.activeCodexAccountId` + * so an unrelated `saveConfig` cannot persist transient rotation as operator selection. + */ +function rememberActiveCodexAccount(_config: OcxConfig, accountId: string): void { + runtimeActiveCodexAccountId = accountId; +} + +/** Persist operator (or quota-strategy) active selection to config + disk. */ function setActiveCodexAccount(config: OcxConfig, accountId: string): void { + runtimeActiveCodexAccountId = undefined; if (config.activeCodexAccountId === accountId) return; config.activeCodexAccountId = accountId; saveConfigPreservingClaudeCode(config); } +/** Quota strategy persists; RR/fill-first keep a process-local cursor only. */ +function promoteActiveCodexAccount(config: OcxConfig, accountId: string): void { + if (normalizeAccountPoolStrategy(config.accountPoolStrategy) === "quota") { + setActiveCodexAccount(config, accountId); + return; + } + rememberActiveCodexAccount(config, accountId); +} + function isUnknownUsage(usage: number): boolean { return usage >= CODEX_UNKNOWN_USAGE_SCORE; } @@ -690,7 +738,7 @@ function applyFailureFailover(config: OcxConfig, active: string, now: number): s if (!shouldFailover(config, active, now)) return active; const best = pickAlternateCodexAccount(config, active, now); if (best) { - setActiveCodexAccount(config, best); + promoteActiveCodexAccount(config, best); return best; } return active; @@ -750,7 +798,7 @@ export function previewCodexAccountForRequest( const strategyPick = pickUnboundStrategyAccount(config, threadId, now, false); if (strategyPick) return strategyPick; - let active = config.activeCodexAccountId ?? null; + let active = getEffectiveActiveCodexAccountId(config) ?? null; if (!active) { return pickLowestUsageCodexAccount(config, undefined, now); } @@ -838,7 +886,7 @@ export function resolveCodexAccountForThreadDetailed( const strategyPick = pickUnboundStrategyAccount(config, threadId, now, true); if (strategyPick) return { status: "selected", accountId: strategyPick }; - let active = config.activeCodexAccountId; + let active = getEffectiveActiveCodexAccountId(config); if (!active) { const selected = pickLowestUsageCodexAccount(config, undefined, now); if (!selected) return { status: "none" }; @@ -959,14 +1007,15 @@ export function recordCodexUpstreamOutcome( }); clearThreadAccountMapForAccount(accountId); notePoolRotationFailure(POOL_KEY_CODEX, accountId); - if (config.activeCodexAccountId === accountId) { + const effectiveActive = getEffectiveActiveCodexAccountId(config); + if (effectiveActive === accountId) { // Same-request 429 retry already picked via excludeAccountId — reuse it so // round-robin does not advance the ring a second time. const reused = meta.promoteAccountId && meta.promoteAccountId !== accountId ? meta.promoteAccountId : null; const fallback = reused ?? pickAlternateCodexAccount(config, accountId, now); - if (fallback) rememberActiveCodexAccount(config, fallback); + if (fallback) promoteActiveCodexAccount(config, fallback); } return; } @@ -1014,7 +1063,7 @@ export function recordCodexUpstreamOutcome( if (shouldFailover(config, accountId, now)) { clearThreadAccountMapForAccount(accountId); } - if (config.activeCodexAccountId === accountId) applyFailureFailover(config, accountId, now); + if (getEffectiveActiveCodexAccountId(config) === accountId) applyFailureFailover(config, accountId, now); } export function formatCodexProviderForLog(providerName: string, accountId: string | null, config: OcxConfig): string { diff --git a/src/oauth/anthropic-routing.ts b/src/oauth/anthropic-routing.ts index ff095de168..5422a56a48 100644 --- a/src/oauth/anthropic-routing.ts +++ b/src/oauth/anthropic-routing.ts @@ -187,6 +187,7 @@ function pickLowestUsage(excludeId: string | undefined, now: number): string | n /** Next eligible Anthropic account in stable order after `afterId` (wrapping). */ function pickNextFillFirstAnthropicAccount( + config: OcxConfig, afterId: string, eligible: string[], ): string | null { @@ -197,12 +198,21 @@ function pickNextFillFirstAnthropicAccount( ? [...set.accounts.map(a => a.id)].sort((a, b) => a.localeCompare(b)) : ordered; const startIdx = stableAll.indexOf(afterId); - if (startIdx < 0) return ordered[0] ?? null; + if (startIdx < 0) { + for (const id of ordered) { + if (isActiveUnderFillFirstThreshold(config, id)) return id; + } + return ordered[0] ?? null; + } + // Skip successors that are also at/above threshold (known drained usage). + let fallback: string | null = null; for (let step = 1; step <= stableAll.length; step++) { const candidate = stableAll[(startIdx + step) % stableAll.length]!; - if (eligible.includes(candidate)) return candidate; + if (!eligible.includes(candidate)) continue; + if (!fallback) fallback = candidate; + if (isActiveUnderFillFirstThreshold(config, candidate)) return candidate; } - return ordered[0] ?? null; + return fallback ?? ordered[0] ?? null; } function pickAlternateAnthropicAccount( @@ -216,7 +226,7 @@ function pickAlternateAnthropicAccount( return pickRoundRobinAccount(POOL_KEY_ANTHROPIC, eligible, stickyLimitForPool(config)); } if (strategy === "fill-first") { - return pickNextFillFirstAnthropicAccount(excludeId, eligible); + return pickNextFillFirstAnthropicAccount(config, excludeId, eligible); } return pickLowestUsage(excludeId, now); } @@ -277,18 +287,15 @@ function pickFillFirstAnthropicAccount(config: OcxConfig, now: number): string | return active; } - const ordered = [...eligible].sort((a, b) => a.localeCompare(b)); - if (!active || !set) return ordered[0] ?? null; - - const stableAll = [...set.accounts.map(a => a.id)].sort((a, b) => a.localeCompare(b)); - const startIdx = stableAll.indexOf(active); - if (startIdx < 0) return ordered[0] ?? null; - - for (let step = 1; step <= stableAll.length; step++) { - const candidate = stableAll[(startIdx + step) % stableAll.length]!; - if (eligible.includes(candidate)) return candidate; + if (!active || !set) { + const ordered = [...eligible].sort((a, b) => a.localeCompare(b)); + for (const id of ordered) { + if (isActiveUnderFillFirstThreshold(config, id)) return id; + } + return ordered[0] ?? null; } - return ordered[0] ?? null; + + return pickNextFillFirstAnthropicAccount(config, active, eligible); } /** @@ -350,6 +357,19 @@ export function resolveAnthropicAccountForSession( } } + const strategy = anthropicPoolStrategy(config); + // No session identity (Desktop turns without a sticky key): hold the current + // active under RR/fill-first instead of treating every turn as a new session. + // Round-robin only when there is a real new-session key (or active is unusable). + if (!key && (strategy === "round-robin" || strategy === "fill-first")) { + const activeOk = set.accounts.some(a => a.id === set.activeAccountId && a.needsReauth !== true) + && !isCooled(set.activeAccountId, now) + && isPoolCredentialUsable(set.activeAccountId, now); + if (activeOk) { + return { accountId: set.activeAccountId, reason: "active" }; + } + } + const strategyPick = pickUnboundStrategyAccount(config, now); if (strategyPick) { // Do not promote active here — token validation may still fail. Callers diff --git a/tests/anthropic-account-pool.test.ts b/tests/anthropic-account-pool.test.ts index 25cbba7917..72657e204b 100644 --- a/tests/anthropic-account-pool.test.ts +++ b/tests/anthropic-account-pool.test.ts @@ -205,13 +205,25 @@ describe("anthropic account pool", () => { const config = cfg(true, 80, { strategy: "round-robin" }); const picks = [ - resolveAnthropicAccountForSession(null, config).accountId, - resolveAnthropicAccountForSession(null, config).accountId, - resolveAnthropicAccountForSession(null, config).accountId, + resolveAnthropicAccountForSession("sess-1", config).accountId, + resolveAnthropicAccountForSession("sess-2", config).accountId, + resolveAnthropicAccountForSession("sess-3", config).accountId, ]; expect(new Set(picks).size).toBe(3); }); + test("null/empty session key holds active under RR instead of rotating every turn", async () => { + const { aId, bId, cId } = await seedThreeAccounts(); + setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 10 }); + setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 10 }); + setCachedProviderAccountQuotaForTests("anthropic", cId, { fiveHourPercent: 10 }); + const config = cfg(true, 80, { strategy: "round-robin", stickyLimit: 1 }); + + const picks = Array.from({ length: 6 }, () => resolveAnthropicAccountForSession(null, config).accountId); + expect(picks.every(id => id === aId)).toBe(true); + expect(resolveAnthropicAccountForSession("", config).reason).toBe("active"); + }); + test("affinity still wins over round-robin", async () => { const { aId, bId, cId } = await seedThreeAccounts(); setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 10 }); @@ -258,9 +270,12 @@ describe("anthropic account pool", () => { setCachedProviderAccountQuotaForTests("anthropic", cId, { fiveHourPercent: 10 }); const config = cfg(true, 80, { strategy: "fill-first" }); - const picks = Array.from({ length: 8 }, () => resolveAnthropicAccountForSession(null, config).accountId); + const picks = Array.from({ length: 8 }, () => resolveAnthropicAccountForSession("ff-sess", config).accountId); expect(picks.every(id => id === aId)).toBe(true); - expect(resolveAnthropicAccountForSession(null, config).reason).toBe("fill-first"); + expect(resolveAnthropicAccountForSession("ff-sess-2", config).reason).toBe("fill-first"); + // Null session key also holds active (Desktop without sticky identity). + expect(resolveAnthropicAccountForSession(null, config).accountId).toBe(aId); + expect(resolveAnthropicAccountForSession(null, config).reason).toBe("active"); }); test("stickyLimit holds across successive unbound resolves", async () => { @@ -270,11 +285,11 @@ describe("anthropic account pool", () => { setCachedProviderAccountQuotaForTests("anthropic", cId, { fiveHourPercent: 10 }); const config = cfg(true, 80, { strategy: "round-robin", stickyLimit: 3 }); - const first = resolveAnthropicAccountForSession(null, config).accountId; + const first = resolveAnthropicAccountForSession("s1", config).accountId; expect(first).toBeTruthy(); - expect(resolveAnthropicAccountForSession(null, config).accountId).toBe(first); - expect(resolveAnthropicAccountForSession(null, config).accountId).toBe(first); - const fourth = resolveAnthropicAccountForSession(null, config).accountId; + expect(resolveAnthropicAccountForSession("s2", config).accountId).toBe(first); + expect(resolveAnthropicAccountForSession("s3", config).accountId).toBe(first); + const fourth = resolveAnthropicAccountForSession("s4", config).accountId; expect(fourth).not.toBe(first); }); @@ -285,26 +300,40 @@ describe("anthropic account pool", () => { setCachedProviderAccountQuotaForTests("anthropic", cId, { fiveHourPercent: 10 }); const config = cfg(true, 80, { strategy: "round-robin", stickyLimit: 10 }); - const sticky = resolveAnthropicAccountForSession(null, config).accountId!; - expect(resolveAnthropicAccountForSession(null, config).accountId).toBe(sticky); + const sticky = resolveAnthropicAccountForSession("sticky-1", config).accountId!; + expect(resolveAnthropicAccountForSession("sticky-2", config).accountId).toBe(sticky); notePoolRotationFailure("anthropic", sticky); - const afterClear = resolveAnthropicAccountForSession(null, config).accountId; + const afterClear = resolveAnthropicAccountForSession("sticky-3", config).accountId; expect(afterClear).toBeTruthy(); expect(afterClear).not.toBe(sticky); // Re-establish sticky, then 429-cool the sticky account — failover + ring must leave it. clearPoolRotationState(); - const again = resolveAnthropicAccountForSession(null, config).accountId!; - expect(resolveAnthropicAccountForSession(null, config).accountId).toBe(again); + const again = resolveAnthropicAccountForSession("again-1", config).accountId!; + expect(resolveAnthropicAccountForSession("again-2", config).accountId).toBe(again); const failover = rotateAnthropicAccountOn429(config, again, "30"); expect(failover).toBeTruthy(); expect(failover).not.toBe(again); - const unboundAfter429 = resolveAnthropicAccountForSession(null, config).accountId; + // After cooldown the failed account is unusable; null key holds active only when eligible. + await setActiveAccount("anthropic", failover!); + const unboundAfter429 = resolveAnthropicAccountForSession("again-3", config).accountId; expect(unboundAfter429).not.toBe(again); expect([bId, cId, aId].filter(id => id !== again)).toContain(unboundAfter429); }); + test("fill-first skips drained successors when advancing past threshold", async () => { + const { aId, bId, cId } = await seedThreeAccounts(); + const ordered = [aId, bId, cId].sort((a, b) => a.localeCompare(b)); + setCachedProviderAccountQuotaForTests("anthropic", ordered[0]!, { fiveHourPercent: 90 }); + setCachedProviderAccountQuotaForTests("anthropic", ordered[1]!, { fiveHourPercent: 95 }); + setCachedProviderAccountQuotaForTests("anthropic", ordered[2]!, { fiveHourPercent: 10 }); + const config = cfg(true, 80, { strategy: "fill-first" }); + await setActiveAccount("anthropic", ordered[0]!); + + expect(resolveAnthropicAccountForSession("ff-drain", config).accountId).toBe(ordered[2]); + }); + test("fill-first 429 advances next in stable order, not lowest usage", async () => { const { aId, bId, cId } = await seedThreeAccounts(); // Sorted ids: force usage so lowest-usage would pick cId. @@ -316,7 +345,7 @@ describe("anthropic account pool", () => { const ordered = [aId, bId, cId].sort((a, b) => a.localeCompare(b)); // Ensure active is the first in stable order so fill-first holds it. await setActiveAccount("anthropic", ordered[0]!); - expect(resolveAnthropicAccountForSession(null, config).accountId).toBe(ordered[0]); + expect(resolveAnthropicAccountForSession("ff-hold", config).accountId).toBe(ordered[0]); const failover = rotateAnthropicAccountOn429(config, ordered[0]!, "30"); expect(failover).toBe(ordered[1]); @@ -331,7 +360,7 @@ describe("anthropic account pool", () => { const config = cfg(true, 80, { strategy: "round-robin", stickyLimit: 1 }); const before = getAccountSet("anthropic")!.activeAccountId; - const picks = Array.from({ length: 3 }, () => resolveAnthropicAccountForSession(null, config)); + const picks = Array.from({ length: 3 }, (_, i) => resolveAnthropicAccountForSession(`promo-${i}`, config)); expect(new Set(picks.map(p => p.accountId)).size).toBe(3); expect(getAccountSet("anthropic")!.activeAccountId).toBe(before); }); @@ -343,11 +372,11 @@ describe("anthropic account pool", () => { setCachedProviderAccountQuotaForTests("anthropic", cId, { fiveHourPercent: 10 }); const config = cfg(true, 80, { strategy: "round-robin", stickyLimit: 1 }); - resolveAnthropicAccountForSession(null, config); - resolveAnthropicAccountForSession(null, config); + resolveAnthropicAccountForSession("seed-1", config); + resolveAnthropicAccountForSession("seed-2", config); await setActiveAccount("anthropic", cId); resetAnthropicRoutingForManualSelection(cId); - expect(resolveAnthropicAccountForSession(null, config).accountId).toBe(cId); + expect(resolveAnthropicAccountForSession("seed-3", config).accountId).toBe(cId); }); }); diff --git a/tests/codex-pool-rotation.test.ts b/tests/codex-pool-rotation.test.ts index 8e5fbd89ca..16a7f32eac 100644 --- a/tests/codex-pool-rotation.test.ts +++ b/tests/codex-pool-rotation.test.ts @@ -7,6 +7,7 @@ import { import { clearCodexUpstreamHealth, clearThreadAccountMap, + getEffectiveActiveCodexAccountId, isCodexAccountInCooldown, pickAlternateCodexAccount, previewCodexAccountForRequest, @@ -257,6 +258,19 @@ describe("accountPoolStrategy new-session routing", () => { expect(THREE_ACCOUNT_IDS).toContain(pick); }); + test("fill-first skips drained successors when advancing past threshold", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "fill-first", + activeCodexAccountId: "a", + autoSwitchThreshold: 80, + }); + updateAccountQuota("a", 90); + updateAccountQuota("b", 95); + updateAccountQuota("c", 10); + + expect(resolveCodexAccountForThread(null, config)).toBe("c"); + }); + test("RR preview(null) matches next resolve(null) without advancing until resolve", () => { const config = makeThreeAccountConfig({ accountPoolStrategy: "round-robin" }); updateAccountQuota("a", 10); @@ -376,7 +390,8 @@ describe("accountPoolStrategy new-session routing", () => { expect(resolveCodexAccountForThread(null, config)).toBe("a"); recordCodexUpstreamOutcome(config, "a", 429); - expect(config.activeCodexAccountId).toBe("b"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("b"); + expect(config.activeCodexAccountId).toBe("a"); // automatic — not persisted as operator selection expect(pickAlternateCodexAccount(config, "a")).toBe("b"); }); @@ -392,9 +407,10 @@ describe("accountPoolStrategy new-session routing", () => { const first = resolveCodexAccountForThread(null, config)!; recordCodexUpstreamOutcome(config, first, 429); - const promoted = config.activeCodexAccountId; + const promoted = getEffectiveActiveCodexAccountId(config); expect(promoted).toBeTruthy(); expect(promoted).not.toBe(first); + expect(config.activeCodexAccountId).toBe("a"); // Lowest usage is c; ring may pick b. Either is fine as long as it is not lowest-usage-forced when // that would disagree with the ring — assert we did not stay on the failed account. expect(isCodexAccountInCooldown(first)).toBe(true); @@ -416,7 +432,8 @@ describe("accountPoolStrategy new-session routing", () => { expect(retry).toBeTruthy(); expect(retry).not.toBe("a"); recordCodexUpstreamOutcome(withReuse, "a", 429, { promoteAccountId: retry! }); - expect(withReuse.activeCodexAccountId).toBe(retry); + expect(getEffectiveActiveCodexAccountId(withReuse)).toBe(retry); + expect(withReuse.activeCodexAccountId).toBe("a"); clearPoolRotationState(); clearCodexUpstreamHealth(); @@ -427,8 +444,9 @@ describe("accountPoolStrategy new-session routing", () => { expect(firstPick).toBeTruthy(); recordCodexUpstreamOutcome(withoutReuse, "a", 429); // A second ring advance during record would promote past firstPick. - expect(withoutReuse.activeCodexAccountId).not.toBe(firstPick); - expect(withoutReuse.activeCodexAccountId).not.toBe("a"); + expect(getEffectiveActiveCodexAccountId(withoutReuse)).not.toBe(firstPick); + expect(getEffectiveActiveCodexAccountId(withoutReuse)).not.toBe("a"); + expect(withoutReuse.activeCodexAccountId).toBe("a"); }); test("fill-first transient failover advances stable order, not lowest usage", () => { @@ -447,6 +465,7 @@ describe("accountPoolStrategy new-session routing", () => { recordCodexUpstreamOutcome(config, "a", 503); recordCodexUpstreamOutcome(config, "a", 503); recordCodexUpstreamOutcome(config, "a", 503); - expect(config.activeCodexAccountId).toBe("b"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("b"); + expect(config.activeCodexAccountId).toBe("a"); }); }); From e6467e3771a8e86fef7e5cbfaf63c5390d74ecac Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 07:04:00 +0200 Subject: [PATCH 15/15] fix(api): reject non-object pool-strategy bodies Return 400 for null/array/scalar JSON instead of throwing 500, and use POOL_KEY_ANTHROPIC in the sticky-release test. --- src/codex/auth-api.ts | 8 +++-- src/server/management/oauth-account-routes.ts | 6 +++- tests/account-pool-management-api.test.ts | 30 +++++++++++++++++++ tests/anthropic-account-pool.test.ts | 4 +-- 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 54113845e6..73da7ec1f4 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -645,8 +645,12 @@ export async function handleCodexAuthAPI( url.pathname === "/api/codex-auth/pool-strategy" && (req.method === "PUT" || req.method === "PATCH") ) { - let body: { strategy?: unknown; stickyLimit?: unknown }; - try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } + let parsedBody: unknown; + try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } + if (typeof parsedBody !== "object" || parsedBody === null || Array.isArray(parsedBody)) { + return jsonResponse({ error: "body must be an object" }, 400); + } + const body = parsedBody as { strategy?: unknown; stickyLimit?: unknown }; if (body.strategy === undefined && body.stickyLimit === undefined) { return jsonResponse({ error: "strategy or stickyLimit required" }, 400); } diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 1a19adf95e..70b8c85a7e 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -242,7 +242,11 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< }); } if (url.pathname === "/api/oauth/accounts/pool" && (req.method === "PUT" || req.method === "PATCH")) { - const body = await req.json().catch(() => ({})) as { + const parsedBody = await req.json().catch(() => ({})); + if (!isPlainRecord(parsedBody)) { + return jsonResponse({ error: "body must be an object" }, 400); + } + const body = parsedBody as { provider?: unknown; enabled?: unknown; autoSwitchThreshold?: unknown; diff --git a/tests/account-pool-management-api.test.ts b/tests/account-pool-management-api.test.ts index 1507a636ba..11cb5e5b74 100644 --- a/tests/account-pool-management-api.test.ts +++ b/tests/account-pool-management-api.test.ts @@ -123,6 +123,19 @@ describe("Codex account pool strategy management API", () => { expect(config.accountPoolStrategy).toBe("quota"); expect(config.accountPoolStickyLimit).toBe(1); }); + + test("PUT /api/codex-auth/pool-strategy rejects non-object JSON bodies with 400", async () => { + for (const raw of ["null", "[]", "\"round-robin\"", "1"]) { + const req = new Request("http://localhost/api/codex-auth/pool-strategy", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: raw, + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeCodexConfig()); + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ error: "body must be an object" }); + } + }); }); describe("Anthropic account pool strategy management API", () => { let testDir = ""; @@ -196,6 +209,23 @@ describe("Anthropic account pool strategy management API", () => { } }); + test("PUT /api/oauth/accounts/pool rejects non-object JSON bodies with 400", async () => { + const server = startServer(0); + try { + for (const raw of ["null", "[]", "\"round-robin\""]) { + const res = await fetch(new URL("/api/oauth/accounts/pool", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: raw, + }); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ error: "body must be an object" }); + } + } finally { + await server.stop(true); + } + }); + test("PUT /api/oauth/accounts/pool rejects invalid stickyLimit", async () => { const server = startServer(0); try { diff --git a/tests/anthropic-account-pool.test.ts b/tests/anthropic-account-pool.test.ts index 72657e204b..c13f59bd10 100644 --- a/tests/anthropic-account-pool.test.ts +++ b/tests/anthropic-account-pool.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { clearPoolRotationState, notePoolRotationFailure } from "../src/codex/pool-rotation"; +import { clearPoolRotationState, notePoolRotationFailure, POOL_KEY_ANTHROPIC } from "../src/codex/pool-rotation"; import { anthropicSessionKeyFromParts, bindAnthropicSessionAffinity, @@ -303,7 +303,7 @@ describe("anthropic account pool", () => { const sticky = resolveAnthropicAccountForSession("sticky-1", config).accountId!; expect(resolveAnthropicAccountForSession("sticky-2", config).accountId).toBe(sticky); - notePoolRotationFailure("anthropic", sticky); + notePoolRotationFailure(POOL_KEY_ANTHROPIC, sticky); const afterClear = resolveAnthropicAccountForSession("sticky-3", config).accountId; expect(afterClear).toBeTruthy(); expect(afterClear).not.toBe(sticky);