From a7129bb5ccf4ce07ea663bc7cffbd4de048ec075 Mon Sep 17 00:00:00 2001 From: Hanbin Noh <282618027+hanbinnoh@users.noreply.github.com> Date: Thu, 13 Aug 2026 03:01:36 +0900 Subject: [PATCH 1/7] feat: command-code OAuth account pool with Codex-style rotation Add an opt-in Command Code OAuth account pool mirroring the Codex pool rotation strategy (quota / round-robin / fill-first, sticky session affinity, 429 failover, selection-order priority, manual pin): - Generic OAuth pool router (oauth-pool-routing.ts) shared by providers; command-code adapter (command-code-routing.ts) wires config hooks - Per-account 5h + weekly quota from GET /alpha/billing/credits (fiveHourPercent / weeklyPercent), with provider-level report seeding - Management API: pool config GET/PUT/PATCH, priority, clear-cooldown, cooldown in health projection; CLI auto-switch/priority/clear-cooldown extended to anthropic + command-code - Responses core: pool selection per session, 429 failover (bounded) - GUI: account panel Add account shows a paste box accepting a redirect URL / auth code / raw API key (Command Code), wired through the existing /api/oauth/login/code path - Tests: 21 pool-routing unit tests, pool management API tests, CLI auto-switch/priority tests, per-account quota tests --- README.md | 7 + .../provider-workspace/ProviderAuthPanel.tsx | 64 ++ .../components/provider-workspace/types.ts | 2 + gui/src/i18n/de.ts | 4 +- gui/src/i18n/en.ts | 2 + gui/src/i18n/ja.ts | 4 +- gui/src/i18n/ko.ts | 4 +- gui/src/i18n/ru.ts | 4 +- gui/src/i18n/tr.ts | 4 +- gui/src/i18n/zh-TW.ts | 4 +- gui/src/i18n/zh.ts | 4 +- gui/src/pages/Providers.tsx | 3 +- gui/src/pages/use-providers-oauth.ts | 21 +- .../styles/provider-workspace-settings.css | 1 + src/cli/account-api.ts | 2 +- src/cli/account-extended.ts | 41 +- src/codex/pool-rotation.ts | 7 + src/lib/state-store-registrations.ts | 2 + src/oauth/command-code-routing.ts | 170 ++++ src/oauth/health.ts | 9 +- src/oauth/oauth-pool-routing.ts | 735 ++++++++++++++++++ src/providers/quota.ts | 94 ++- src/server/management/oauth-account-routes.ts | 131 +++- src/server/responses/core.ts | 113 +++ src/types.ts | 19 + src/usage/log.ts | 2 + tests/account-pool-management-api.test.ts | 192 +++++ tests/cli-account.test.ts | 70 +- tests/command-code-account-pool.test.ts | 413 ++++++++++ tests/provider-account-quota.test.ts | 100 +++ 30 files changed, 2185 insertions(+), 43 deletions(-) create mode 100644 src/oauth/command-code-routing.ts create mode 100644 src/oauth/oauth-pool-routing.ts create mode 100644 tests/command-code-account-pool.test.ts diff --git a/README.md b/README.md index 25f5ebb56..3dc1e96f9 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,13 @@ account exclusion, affinity expiry, or 401/403 and 429 recovery can rebind them. selection order when one of them — usually your Codex Desktop login — should only be reached for once the others are drained. +OAuth providers that support multiple logins (Anthropic, Command Code) get the same +rotation engine via an opt-in account pool (`config.AccountPool.enabled`): +sticky session affinity, 429 failover, and new-session quota / round-robin / fill-first +picking with per-account 5h + weekly usage bars in the dashboard. The GUI's account +panel shows an **Add account** button that opens the login and also accepts a pasted +redirect URL / authorization code / raw API key (Command Code). + ### For agents ```bash diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index 60c15225d..0d988b3af 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -119,6 +119,10 @@ export default function ProviderAuthPanel({ const [addingKey, setAddingKey] = useState(false); const [newKey, setNewKey] = useState(""); const [keyBusy, setKeyBusy] = useState(false); + const [manualCode, setManualCode] = useState(""); + const [manualCodeBusy, setManualCodeBusy] = useState(false); + const [manualCodeMsg, setManualCodeMsg] = useState(""); + const [manualCodeOk, setManualCodeOk] = useState(true); const [importBusy, setImportBusy] = useState(false); const [importStatus, setImportStatus] = useState<"idle" | "invalid" | "failed" | "complete">("idle"); const [importResult, setImportResult] = useState(null); @@ -195,6 +199,24 @@ export default function ProviderAuthPanel({ } }; + const submitManualCode = async () => { + const input = manualCode.trim(); + if (!input || manualCodeBusy || !authHandlers.onSubmitManualCode) return; + setManualCodeBusy(true); + setManualCodeMsg(""); + try { + await authHandlers.onSubmitManualCode(item.name, input); + setManualCode(""); + setManualCodeOk(true); + setManualCodeMsg(t("prov.pasteOk")); + } catch { + setManualCodeOk(false); + setManualCodeMsg(t("prov.pasteFail", { error: "network error" })); + } finally { + setManualCodeBusy(false); + } + }; + const importCockpitFile = async (file: File | undefined) => { if (!file || importBusy) return; setImportBusy(true); @@ -326,6 +348,48 @@ export default function ProviderAuthPanel({ )} + {authHandlers.onSubmitManualCode && ( +
+
+ {item.name === "command-code" + ? t("prov.pasteCommandCodeHint") + : t("prov.pasteRedirectHint")} +
+
+ { setManualCode(e.target.value); setManualCodeMsg(""); }} + onKeyDown={e => { + if (e.key === "Enter" && manualCode.trim()) { + e.preventDefault(); + void submitManualCode(); + } + }} + placeholder={item.name === "command-code" ? t("prov.pasteCommandCodePlaceholder") : t("prov.pasteRedirect")} + aria-label={item.name === "command-code" ? t("prov.pasteCommandCodePlaceholder") : t("prov.pasteRedirect")} + disabled={manualCodeBusy} + className="input text-label" + style={{ flex: 1 }} + /> + +
+ {manualCodeMsg && ( +
+ {manualCodeMsg} +
+ )} +
+ )} {authHandlers.onCancelLogin && ( {manualCodeMsg && ( -
+
{manualCodeMsg}
)} diff --git a/gui/tests/provider-auth-manual-code.test.tsx b/gui/tests/provider-auth-manual-code.test.tsx index bcdb67c60..ed2aa5677 100644 --- a/gui/tests/provider-auth-manual-code.test.tsx +++ b/gui/tests/provider-auth-manual-code.test.tsx @@ -27,7 +27,9 @@ beforeEach(() => { win = new Window({ url: "http://localhost/" }); Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); rejection = new Error("invalid authorization code"); - submit = mock(async () => { throw rejection; }); + submit = mock(async () => { + if (rejection) throw rejection; + }); Object.defineProperties(globalThis, { document: { configurable: true, value: win.document }, window: { configurable: true, value: win }, navigator: { configurable: true, value: win.navigator }, localStorage: { configurable: true, value: win.localStorage }, @@ -81,11 +83,20 @@ test("masks pasted Command Code credentials and preserves rejection feedback", a }); expect(submit).toHaveBeenCalledWith("command-code", "user_secret"); expect(host.textContent).toContain("invalid authorization code"); + expect(host.querySelector('[role="alert"]')?.textContent).toContain("invalid authorization code"); - rejection = null; + rejection = {}; await act(async () => { (host.querySelector(".pwi-auth-paste button") as HTMLButtonElement).click(); await new Promise(resolve => setTimeout(resolve, 0)); }); expect(host.textContent).toContain("Could not submit code: Network error. Check that the proxy is running and try again."); + expect(host.querySelector('[role="alert"]')?.textContent).toContain("Network error"); + + rejection = null; + await act(async () => { + (host.querySelector(".pwi-auth-paste button") as HTMLButtonElement).click(); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + expect(host.querySelector('[role="status"]')?.textContent).toContain("Code submitted — finishing login…"); }); diff --git a/src/cli/account-extended.ts b/src/cli/account-extended.ts index 58b5ad02f..ba93ef12c 100644 --- a/src/cli/account-extended.ts +++ b/src/cli/account-extended.ts @@ -346,10 +346,9 @@ export async function cmdAutoSwitch(args: string[], deps: AccountDeps): Promise< const action = args.shift(); if (!name || !action) return usage(); const classified = configAndType(deps, name); - const oauthPool = !("error" in classified) && classified.type === "oauth" - && (name === "anthropic" || name === "command-code"); + const oauthPool = !('error' in classified) && classified.type === "oauth" && name === "command-code"; if ("error" in classified || (classified.type !== "codex" && !oauthPool)) { - return usage("Error: auto-switch applies to the openai Codex account pool, anthropic, and command-code"); + return usage("Error: auto-switch applies to the openai Codex account pool and command-code"); } let threshold: number | undefined; if (action === "on" && args.length === 0) threshold = 80; @@ -583,8 +582,8 @@ export async function cmdImport(args: string[], deps: AccountDeps): Promise { const wantsJson = flag(args, "--json"); @@ -593,9 +592,9 @@ export async function cmdClearCooldown(args: string[], deps: AccountDeps): Promi if (!name || !requestedId || args.length) return usage(); const classified = configAndType(deps, name); if ("error" in classified) return usage(`Error: ${classified.error}`); - const oauthPool = classified.type === "oauth" && (name === "anthropic" || name === "command-code"); + const oauthPool = classified.type === "oauth" && name === "command-code"; if (classified.type !== "codex" && !oauthPool) { - return usage(`Error: ${name} cooldown clearing applies to Codex, anthropic, and command-code pools only`); + return usage(`Error: ${name} cooldown clearing applies to Codex and command-code pools only`); } const id = requestedId === "main" ? MAIN_ID : requestedId; const baseUrl = await resolveBaseUrl(deps); @@ -656,9 +655,9 @@ export async function cmdPriority(args: string[], deps: AccountDeps): Promise; activeAccountPinned?: string; @@ -512,15 +515,15 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< if (!(await setAccountAlias(provider, accountId, alias || undefined))) return jsonResponse({ error: "account not found" }, 404); return jsonResponse({ ok: true, provider, accountId, alias: alias || null }); } - // Selection order (priority) for OAuth account pools (anthropic / command-code): + // Selection order (priority) for the Command Code OAuth account pool: // Codex-pool parity — higher used earlier; setting an order also releases any // manual pin so the newer operator statement wins. if (url.pathname === "/api/oauth/accounts/pool/priority" && req.method === "PUT") { const body = await readManagementJsonBodyOr(req, {}) as { provider?: unknown; id?: unknown; priority?: unknown }; const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : ""; const id = typeof body.id === "string" ? body.id.trim() : ""; - if (provider !== "anthropic" && provider !== "command-code") { - return jsonResponse({ error: "priority is only supported for anthropic and command-code pools" }, 400); + if (provider !== "command-code") { + return jsonResponse({ error: "priority is only supported for the command-code pool" }, 400); } if (!id) return jsonResponse({ error: "missing id" }, 400); const { getAccountSet } = await import("../../oauth/store"); @@ -538,8 +541,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< priority = parsed; } - const poolKey = provider === "anthropic" ? "anthropicAccountPool" : "commandCodeAccountPool"; - const existingPool = config[poolKey] as { + const existingPool = config.commandCodeAccountPool as { accountPriorities?: Record; activeAccountPinned?: string; }; @@ -550,7 +552,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const next: Record = {}; for (const [key, value] of priorities) next[key] = value; const merged = { - ...(config[poolKey] ?? {}), + ...(config.commandCodeAccountPool ?? {}), } as { enabled?: boolean; autoSwitchThreshold?: number; @@ -563,7 +565,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< else delete merged.accountPriorities; // Newer statement wins: an order supersedes a manual pin. delete merged.activeAccountPinned; - config[poolKey] = merged; + config.commandCodeAccountPool = merged; saveConfigPreservingClaudeCode(config); reconcileLiveStateStores(); return jsonResponse({ ok: true, provider, id, priority }); diff --git a/tests/account-pool-management-api.test.ts b/tests/account-pool-management-api.test.ts index fb5a3394f..7e9c733b6 100644 --- a/tests/account-pool-management-api.test.ts +++ b/tests/account-pool-management-api.test.ts @@ -579,3 +579,18 @@ describe("Command Code account pool strategy management API", () => { } }); }); + +test("Anthropic pool rejects Command Code priority and pin controls", async () => { + const server = startServer(0); + try { + const response = await fetch(new URL("/api/oauth/accounts/pool", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: "anthropic", enabled: true, accountPriorities: { account: 1 } }), + }); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: "account priorities and manual pins are only supported for command-code" }); + } finally { + await server.stop(true); + } +}); diff --git a/tests/cli-account.test.ts b/tests/cli-account.test.ts index 4fb981fb2..fcf937cd4 100644 --- a/tests/cli-account.test.ts +++ b/tests/cli-account.test.ts @@ -818,7 +818,7 @@ describe("ocx account CLI (issue #180 matrix)", () => { const missingProvider = await run(["auto-switch"]); expect(wrongProvider.code).toBe(1); - expect(wrongProvider.stderr).toContain("auto-switch applies to the openai Codex account pool, anthropic, and command-code"); + expect(wrongProvider.stderr).toContain("auto-switch applies to the openai Codex account pool and command-code"); expect(invalidThreshold.code).toBe(1); expect(invalidThreshold.stderr).toContain("integer 0-100"); expect(missingProvider.code).toBe(1); @@ -1327,7 +1327,7 @@ describe("ocx account CLI (issue #180 matrix)", () => { const result = await run(["priority", "kiro", "acct_1", "first"]); expect(result.code).toBe(1); - expect(result.stderr).toContain("selection order applies to the openai Codex account pool, anthropic, and command-code"); + expect(result.stderr).toContain("selection order applies to the openai Codex account pool and command-code"); }); // Both paths reach the proxy through different helpers — the read through diff --git a/tests/server-rate-limit-retry-e2e.test.ts b/tests/server-rate-limit-retry-e2e.test.ts index 467f6ca52..10294316c 100644 --- a/tests/server-rate-limit-retry-e2e.test.ts +++ b/tests/server-rate-limit-retry-e2e.test.ts @@ -3,6 +3,8 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../src/config"; +import { clearCommandCodeAccountPoolState } from "../src/oauth/command-code-routing"; +import { getAccountSet, saveCredential, setActiveAccount } from "../src/oauth/store"; import { clearKeyCooldowns } from "../src/providers/key-failover"; import { startServer } from "../src/server"; import type { OcxConfig } from "../src/types"; @@ -19,6 +21,7 @@ beforeEach(() => { testDir = mkdtempSync(join(tmpdir(), "ocx-ratelimit-e2e-")); process.env.OPENCODEX_HOME = testDir; clearKeyCooldowns(); + clearCommandCodeAccountPoolState(); }); afterEach(() => { @@ -28,6 +31,7 @@ afterEach(() => { isolatedCodexHome = null; if (testDir) removeTreeWithRetry(testDir); clearKeyCooldowns(); + clearCommandCodeAccountPoolState(); }); const okChatCompletion = JSON.stringify({ @@ -47,6 +51,61 @@ async function postResponses(serverUrl: string, model: string): Promise { + test("Command Code OAuth pool rotates accounts on consecutive 429s and stops when all are cooled", async () => { + const originalFetch = globalThis.fetch; + const seenAuth: string[] = []; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url === "https://api.commandcode.ai/alpha/generate") { + seenAuth.push(new Headers(init?.headers).get("authorization") ?? ""); + return new Response(JSON.stringify({ error: { message: "rate limited" } }), { + status: 429, + headers: { "content-type": "application/json" }, + }); + } + return originalFetch(input, init); + }) as typeof fetch; + + let server: ReturnType | null = null; + try { + for (const [accountId, access] of [["account-a", "access-a"], ["account-b", "access-b"], ["account-c", "access-c"]] as const) { + await saveCredential("command-code", { + access, + refresh: access, + expires: Date.now() + 3_600_000, + accountId, + email: `${accountId}@example.test`, + }); + } + const accounts = getAccountSet("command-code")!.accounts; + const first = accounts.find(account => account.credential.accountId === "account-a")!; + await setActiveAccount("command-code", first.id); + saveConfig({ + port: 0, + hostname: "127.0.0.1", + defaultProvider: "command-code", + providers: { + "command-code": { adapter: "command-code", baseUrl: "https://api.commandcode.ai", authMode: "oauth" }, + }, + commandCodeAccountPool: { enabled: true, autoSwitchThreshold: 80 }, + } as OcxConfig); + server = startServer(0); + + const response = await postResponses(server.url, "command-code/deepseek/deepseek-v4-flash"); + expect(response.status).toBe(429); + // Each rotation invalidates the same-target request cache: the next send + // is rebuilt with the next OAuth bearer, not replayed with the cooled one. + expect(seenAuth).toEqual(["Bearer access-a", "Bearer access-b", "Bearer access-c"]); + expect(seenAuth).toHaveLength(3); + } finally { + try { + await server?.stop(true); + } finally { + globalThis.fetch = originalFetch; + } + } + }); + test("single-key provider replays the identical request until upstream succeeds", async () => { const originalFetch = globalThis.fetch; const seenBodies: string[] = []; From 14e82aee71831228d3eee4576655adefa3fefbea Mon Sep 17 00:00:00 2001 From: Hanbin Noh <282618027+hanbinnoh@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:26:29 +0900 Subject: [PATCH 6/7] fix(command-code): address Ingwannu + CodeRabbit follow-ups - gui: split de pasteCommandCodeHint into two sentences - responses: remove dead Command Code branch from fetchTerminalGuardContinuation (anthropic-only guard, no reachable command-code adapter path) --- gui/src/i18n/de.ts | 2 +- src/server/responses/core.ts | 34 ---------------------------------- 2 files changed, 1 insertion(+), 35 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a35069abf..4ad919484 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -365,7 +365,7 @@ export const de: Record = { "prov.pasteRedirect": "Redirect-URL oder Code einfügen", "prov.pasteRedirectHint": "Falls der Browser einen localhost-Fehler zeigt, kopieren Sie die vollständige URL aus der Adressleiste und fügen Sie sie hier ein (oder fügen Sie den Autorisierungscode ein).", "prov.pasteCommandCodePlaceholder": "Command-Code-API-Schlüssel oder Redirect-URL einfügen", - "prov.pasteCommandCodeHint": "Fügen Sie einen Command-Code-API-Schlüssel (user_… aus ~/.commandcode/auth.json) ein, um ihn als weiteres Konto hinzuzufügen, oder die Redirect-URL aus dem Browser.", + "prov.pasteCommandCodeHint": "Fügen Sie einen Command-Code-API-Schlüssel (user_… aus ~/.commandcode/auth.json) ein, um ihn als weiteres Konto hinzuzufügen. Alternativ können Sie die Redirect-URL aus dem Browser einfügen.", "prov.pasteSubmit": "Senden", "prov.pasteSubmitting": "Wird gesendet…", "prov.pasteOk": "Code gesendet — Anmeldung wird abgeschlossen…", diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 5983347af..b1be8b18a 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -3848,40 +3848,6 @@ async function handleResponsesInner( } } } - if ( - response.status === 429 - && commandCodePoolAccountId - && isCommandCodeAccountPoolEnabled(config) - && commandCodePoolFailovers < COMMAND_CODE_POOL_MAX_FAILOVERS_PER_REQUEST - ) { - const nextAccountId = rotateCommandCodeAccountOn429( - config, - commandCodePoolAccountId, - response.headers.get("retry-after"), - commandCodeSessionKey, - ); - if (nextAccountId) { - try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ } - try { - const accessToken = await getCommandCodePoolAccessToken(nextAccountId); - commandCodePoolAccountId = nextAccountId; - commandCodePoolFailovers += 1; - route.provider = { ...route.provider, apiKey: accessToken }; - invalidateSameTargetRequest(); - promoteCommandCodeActiveAccount(nextAccountId); - logCtx.provider = formatCommandCodeProviderForLog("command-code", nextAccountId, config); - activeAdapter = resolveAdapter( - resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), - config.cacheRetention, - ); - sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel); - nextContinuationRecoveryKind = "command-code-oauth-429"; - continue; - } catch { - // fall through to emit continuation error below - } - } - } if (shouldAttemptImageTierRetry({ status: response.status, adapterName: activeAdapter.name, From 281c6c94bb7b4c037c73a16493d9a79c226fa595 Mon Sep 17 00:00:00 2001 From: Hanbin Noh <282618027+hanbinnoh@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:55:48 +0900 Subject: [PATCH 7/7] fix(oauth): keep anthropic pool routes untouched, add command-code alongside Wibias: previous pool/clear-cooldown handlers rewrote the shared anthropic path to a generic poolKey dispatch. Keep anthropic exactly as on upstream/dev (verbatim) and handle command-code in separate branches alongside it, so this Command Code PR does not generalize anthropic management behavior. No anthropic contract change. Co-authored-by: Wibias review 2026-08-13 --- src/server/management/oauth-account-routes.ts | 92 ++++++++++++++----- 1 file changed, 67 insertions(+), 25 deletions(-) diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index a832eb8da..31b69d337 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -322,21 +322,32 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< return jsonResponse({ ok: true, provider, activeAccountId: body.accountId }); } - // Opt-in OAuth account pools (Anthropic #294 / Command Code): enable/threshold/strategy + 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" && provider !== "command-code") { - return jsonResponse({ error: "pool config is only supported for anthropic and command-code" }, 400); + if (provider === "anthropic") { + const pool = config.anthropicAccountPool ?? {}; + return jsonResponse({ + provider, + enabled: pool.enabled === true, + autoSwitchThreshold: typeof pool.autoSwitchThreshold === "number" ? pool.autoSwitchThreshold : 80, + strategy: normalizeAccountPoolStrategy(pool.strategy), + stickyLimit: normalizeAccountPoolStickyLimit(pool.stickyLimit), + experimental: true, + }); } - const pool = provider === "anthropic" ? config.anthropicAccountPool ?? {} : config.commandCodeAccountPool ?? {}; - return jsonResponse({ - provider, - enabled: pool.enabled === true, - autoSwitchThreshold: typeof pool.autoSwitchThreshold === "number" ? pool.autoSwitchThreshold : 80, - strategy: normalizeAccountPoolStrategy(pool.strategy), - stickyLimit: normalizeAccountPoolStickyLimit(pool.stickyLimit), - experimental: true, - }); + if (provider === "command-code") { + const pool = config.commandCodeAccountPool ?? {}; + return jsonResponse({ + provider, + enabled: pool.enabled === true, + autoSwitchThreshold: typeof pool.autoSwitchThreshold === "number" ? pool.autoSwitchThreshold : 80, + strategy: normalizeAccountPoolStrategy(pool.strategy), + stickyLimit: normalizeAccountPoolStickyLimit(pool.stickyLimit), + experimental: true, + }); + } + return jsonResponse({ error: "pool config is only supported for anthropic and command-code" }, 400); } if (url.pathname === "/api/oauth/accounts/pool" && (req.method === "PUT" || req.method === "PATCH")) { const parsedBody = await readManagementJsonBodyOr(req, {}); @@ -353,10 +364,39 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< activeAccountPinned?: unknown; }; const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : ""; - if (provider !== "anthropic" && provider !== "command-code") { - return jsonResponse({ error: "pool config is only supported for anthropic and command-code" }, 400); + if (provider !== "anthropic" && provider !== "command-code") return jsonResponse({ error: "pool config is only supported for anthropic and command-code" }, 400); + if (provider === "anthropic") { + // Anthropic path — upstream verbatim. Do not generalize. + 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 (typeof body.autoSwitchThreshold !== "number" || !Number.isInteger(body.autoSwitchThreshold) || body.autoSwitchThreshold < 0 || body.autoSwitchThreshold > 100) return jsonResponse({ error: "autoSwitchThreshold must be an integer 0-100" }, 400); + 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; + } + if (body.accountPriorities !== undefined || body.activeAccountPinned !== undefined) return jsonResponse({ error: "account priorities and manual pins are only supported for command-code" }, 400); + config.anthropicAccountPool = { enabled, autoSwitchThreshold: threshold, ...(strategy !== undefined ? { strategy } : {}), ...(stickyLimit !== undefined ? { stickyLimit } : {}) }; + saveConfigPreservingClaudeCode(config); + reconcileLiveStateStores(); + return jsonResponse({ ok: true, provider, enabled, autoSwitchThreshold: threshold, strategy: normalizeAccountPoolStrategy(strategy), stickyLimit: normalizeAccountPoolStickyLimit(stickyLimit), experimental: true }); } - const poolKey = provider === "anthropic" ? "anthropicAccountPool" : "commandCodeAccountPool"; + // Command Code path — alongside, not merged with Anthropic. + const poolKey = "commandCodeAccountPool" as const; let enabled = config[poolKey]?.enabled === true; if (body.enabled !== undefined) { if (typeof body.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400); @@ -390,9 +430,6 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< } stickyLimit = parsed; } - if (provider !== "command-code" && (body.accountPriorities !== undefined || body.activeAccountPinned !== undefined)) { - return jsonResponse({ error: "account priorities and manual pins are only supported for command-code" }, 400); - } const poolConfig = (config[poolKey] ?? {}) as { accountPriorities?: Record; activeAccountPinned?: string; @@ -442,14 +479,19 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const body = await readManagementJsonBodyOr(req, {}) as { provider?: unknown; accountId?: unknown }; const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : ""; const accountId = typeof body.accountId === "string" ? body.accountId.trim() : ""; - if (provider !== "anthropic" && provider !== "command-code") { - return jsonResponse({ error: "clear-cooldown is only supported for anthropic and command-code" }, 400); + if (provider === "anthropic") { + if (!accountId) return jsonResponse({ error: "missing accountId" }, 400); + const { clearAnthropicAccountCooldown } = await import("../../oauth/anthropic-routing"); + const cleared = clearAnthropicAccountCooldown(accountId); + return jsonResponse({ ok: true, cleared }); } - if (!accountId) return jsonResponse({ error: "missing accountId" }, 400); - const cleared = provider === "anthropic" - ? (await import("../../oauth/anthropic-routing")).clearAnthropicAccountCooldown(accountId) - : (await import("../../oauth/command-code-routing")).clearCommandCodeAccountCooldown(accountId); - return jsonResponse({ ok: true, cleared }); + if (provider === "command-code") { + if (!accountId) return jsonResponse({ error: "missing accountId" }, 400); + const { clearCommandCodeAccountCooldown } = await import("../../oauth/command-code-routing"); + const cleared = clearCommandCodeAccountCooldown(accountId); + return jsonResponse({ ok: true, cleared }); + } + return jsonResponse({ error: "clear-cooldown is only supported for anthropic and command-code" }, 400); } if (url.pathname === "/api/oauth/accounts/import" && req.method === "POST") {