From c0aa96459460f81f35fd7f3c8e057cb7de850546 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:09:07 +0200 Subject: [PATCH 1/2] feat(anthropic): opt-in Claude OAuth account pool (#294) Add experimental, default-off routing across stored Anthropic OAuth accounts: sticky session affinity, 429 cooldown failover, and new-session lowest 5h-usage pick. Includes GUI toggle with an explicit not-battle-tested warning, management API, docs, and focused regressions. --- .../src/content/docs/guides/claude-code.md | 11 + .../content/docs/reference/configuration.md | 19 ++ .../AnthropicAccountPoolSettings.tsx | 162 ++++++++++ .../provider-workspace/ProviderAuthPanel.tsx | 4 + gui/src/i18n/de.ts | 13 + gui/src/i18n/en.ts | 15 + gui/src/i18n/ja.ts | 13 + gui/src/i18n/ko.ts | 13 + gui/src/i18n/ru.ts | 13 + gui/src/i18n/zh.ts | 13 + src/oauth/anthropic-routing.ts | 298 ++++++++++++++++++ src/oauth/health.ts | 6 + src/providers/quota.ts | 23 ++ src/server/management/oauth-account-routes.ts | 58 ++++ src/server/responses/core.ts | 89 +++++- src/types.ts | 10 + src/usage/log.ts | 2 + tests/anthropic-account-pool.test.ts | 127 ++++++++ 18 files changed, 876 insertions(+), 13 deletions(-) create mode 100644 gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx create mode 100644 src/oauth/anthropic-routing.ts create mode 100644 tests/anthropic-account-pool.test.ts diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index fb814832ef..bf473bc7b7 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -7,6 +7,17 @@ opencodex serves `POST /v1/messages` (plus `count_tokens`) alongside `/v1/respon Code can use every routed provider — OAuth logins, account pools, key failover and sidecars included — with zero extra auth work. +## Claude OAuth account pool (experimental) + +You can log in multiple Claude accounts via the Providers dashboard (`ocx login anthropic` / +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. See [Configuration](/reference/configuration/#anthropicaccountpool-experimental). + ## Quickstart ```bash diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index c25f595257..4539c537bc 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -103,6 +103,25 @@ credential store. Existing thread ids keep account affinity, while new sessions on quota, cooldown, and health. ::: +### anthropicAccountPool (experimental) + +Opt-in routing across **multiple Anthropic OAuth accounts** already stored in `auth.json` +(issue [#294](https://github.com/lidge-jun/opencodex/issues/294)). **Default off.** This is +experimental and not battle-tested — enable only if you accept the risk that Anthropic may +restrict accounts that look like automated multi-account rotation. Accounts under the same +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 cached 5-hour usage is at/above this percent, pick the lowest-usage eligible account. `0` disables quota-based picking (affinity + active only). | + +Toggle and warning also appear on **Providers → anthropic → Accounts** in the GUI. +:::caution[Experimental] +Leave this disabled unless you understand Anthropic account policy risk. Prefer manual +`ocx account use anthropic ` switching when unsure. +::: + ### claudeCode (OcxClaudeCodeConfig) Claude Code inbound settings consumed by the `/v1/messages` surface, the `ocx claude` diff --git a/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx b/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx new file mode 100644 index 0000000000..d585119dfa --- /dev/null +++ b/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx @@ -0,0 +1,162 @@ +/** + * Opt-in Anthropic OAuth account pool controls (#294). + * Experimental — shows a strong warning because the feature is not battle-tested. + */ +import { useCallback, useEffect, useState } from "react"; +import { useT } from "../../i18n/shared"; + +type PoolState = { + enabled: boolean; + threshold: number; +}; + +export default function AnthropicAccountPoolSettings({ + apiBase, + accountCount, +}: { + apiBase: string; + accountCount: number; +}) { + const t = useT(); + const [state, setState] = useState(null); + const [draft, setDraft] = useState("80"); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [loadError, setLoadError] = useState(false); + + useEffect(() => { + let cancelled = false; + const ac = new AbortController(); + void (async () => { + try { + const res = await fetch(`${apiBase}/api/oauth/accounts/pool?provider=anthropic`, { + signal: ac.signal, + }); + if (!res.ok) throw new Error("load"); + const json = await res.json() as { enabled?: boolean; autoSwitchThreshold?: number }; + if (cancelled) return; + const nextEnabled = json.enabled === true; + const nextThreshold = typeof json.autoSwitchThreshold === "number" ? json.autoSwitchThreshold : 80; + setState({ enabled: nextEnabled, threshold: nextThreshold }); + setDraft(String(nextThreshold > 0 ? nextThreshold : 80)); + setLoadError(false); + } catch { + if (cancelled || ac.signal.aborted) return; + setLoadError(true); + } + })(); + return () => { + cancelled = true; + ac.abort(); + }; + }, [apiBase]); + + const save = useCallback(async (nextEnabled: boolean, nextThreshold: number) => { + setSaving(true); + setError(null); + try { + const res = await fetch(`${apiBase}/api/oauth/accounts/pool`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + provider: "anthropic", + enabled: nextEnabled, + autoSwitchThreshold: nextThreshold, + }), + }); + if (!res.ok) throw new Error("save"); + setState({ enabled: nextEnabled, threshold: nextThreshold }); + setDraft(String(nextThreshold > 0 ? nextThreshold : 80)); + } catch { + setError(t("anthropicPool.saveFailed")); + } finally { + setSaving(false); + } + }, [apiBase, t]); + + const enabled = state?.enabled === true; + const threshold = state?.threshold ?? 80; + const loading = state === null && !loadError; + + return ( +
+
+
+ {t("anthropicPool.title")} +
+ {loadError + ? t("anthropicPool.loadFailed") + : loading + ? t("common.loading") + : enabled + ? t("anthropicPool.enabledDesc", { threshold }) + : t("anthropicPool.disabledDesc")} +
+
+ +
+ +
+ {t("anthropicPool.experimentalWarning")} +
+ + {accountCount < 2 && ( +
{t("anthropicPool.needTwoAccounts")}
+ )} + + {enabled && ( + + )} + + {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index 45049d16e5..efa7bed39f 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -19,6 +19,7 @@ import { oauthHealthShowsReauth, } from "../../oauth-health-display"; import CodexAccountPool from "../CodexAccountPool"; +import AnthropicAccountPoolSettings from "./AnthropicAccountPoolSettings"; import { LoginUrlBlock } from "../login-url-block"; import QuotaBars from "../QuotaBars"; import { useCopyFeedback } from "../use-copy-feedback"; @@ -105,6 +106,9 @@ export default function ProviderAuthPanel({
{isOauth && ( <> + {item.name === "anthropic" && ( + + )}