diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index fb814832ef..cc6d35645d 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -7,6 +7,30 @@ 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. + +Operational contract when enabled: + +- Upstream **429** cools that account using `Retry-After` when present (else a default backoff), + clears its affinities, and may rotate to another eligible account within the same request + (bounded). +- Affinity is **process-local** (lost on proxy restart). +- **401/403** credential failures quarantine the account (`needsReauth`) so it is excluded from + selection until re-authenticated. +- If every eligible account is cooling, the proxy returns **429** (not 401) with `Retry-After` + when known. + +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..9aa34230b4 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -103,6 +103,34 @@ 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 **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). | + +Reliability contract when enabled: + +- A provider **429** records cooldown from `Retry-After` (capped) or a default backoff, clears + that account's affinities, and may rotate within the request (bounded attempts). +- Affinity maps are **process-local** (lost on restart) and size-bounded. +- Credential **401/403** failures mark `needsReauth` and exclude the account until login is fixed. +- When all eligible accounts are cooling, clients receive **429** with `Retry-After` when known — + not an authentication error. + +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..257e566f8d --- /dev/null +++ b/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx @@ -0,0 +1,164 @@ +/** + * 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)); + 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)); + } 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; + // Always allow turning the pool off; only block enabling when fewer than 2 accounts. + const toggleDisabled = loading || saving || loadError || (!enabled && accountCount < 2); + + 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" && ( + + )}