Skip to content

Commit da212d3

Browse files
authored
feat(anthropic): opt-in Claude OAuth account pool (lidge-jun#294) (lidge-jun#578)
* feat(anthropic): opt-in Claude OAuth account pool (lidge-jun#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. * fix(anthropic): address pool review feedback for lidge-jun#294 Return 429 when all accounts are cooling, bound per-request failover, skip unsafe local-cli refresh, and keep affinity/GUI/docs aligned with the reliability contract.
1 parent de35caa commit da212d3

19 files changed

Lines changed: 1097 additions & 13 deletions

File tree

docs-site/src/content/docs/guides/claude-code.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,30 @@ opencodex serves `POST /v1/messages` (plus `count_tokens`) alongside `/v1/respon
77
Code can use every routed provider — OAuth logins, account pools, key failover and sidecars
88
included — with zero extra auth work.
99

10+
## Claude OAuth account pool (experimental)
11+
12+
You can log in multiple Claude accounts via the Providers dashboard (`ocx login anthropic` /
13+
add-account). By default every request uses the **active** account only.
14+
15+
An **experimental, opt-in** Claude account pool (`anthropicAccountPool.enabled`) adds sticky
16+
session affinity and 429 cooldown failover across those OAuth accounts, with optional
17+
new-session lowest-usage pick from the 5-hour quota bars. It is **off by default**, shows a
18+
GUI warning, and is not battle-tested — Anthropic may restrict accounts that look like
19+
automated rotation.
20+
21+
Operational contract when enabled:
22+
23+
- Upstream **429** cools that account using `Retry-After` when present (else a default backoff),
24+
clears its affinities, and may rotate to another eligible account within the same request
25+
(bounded).
26+
- Affinity is **process-local** (lost on proxy restart).
27+
- **401/403** credential failures quarantine the account (`needsReauth`) so it is excluded from
28+
selection until re-authenticated.
29+
- If every eligible account is cooling, the proxy returns **429** (not 401) with `Retry-After`
30+
when known.
31+
32+
See [Configuration](/reference/configuration/#anthropicaccountpool-experimental).
33+
1034
## Quickstart
1135

1236
```bash

docs-site/src/content/docs/reference/configuration.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,34 @@ credential store. Existing thread ids keep account affinity, while new sessions
103103
on quota, cooldown, and health.
104104
:::
105105

106+
### anthropicAccountPool (experimental)
107+
108+
Opt-in routing across **multiple Anthropic OAuth accounts** already stored in `auth.json`
109+
(issue [#294](https://github.com/lidge-jun/opencodex/issues/294)). **Default off.** This is
110+
experimental and not battle-tested — enable only if you accept the risk that Anthropic may
111+
restrict accounts that look like automated multi-account rotation. Accounts under the same
112+
organization can share quota; pooling those will not help.
113+
114+
| Key | Type | Default | Description |
115+
| --- | --- | --- | --- |
116+
| `anthropicAccountPool.enabled?` | `boolean` | `false` | When true, sticky session affinity + 429 cooldown failover across eligible Anthropic OAuth accounts. |
117+
| `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). |
118+
119+
Reliability contract when enabled:
120+
121+
- A provider **429** records cooldown from `Retry-After` (capped) or a default backoff, clears
122+
that account's affinities, and may rotate within the request (bounded attempts).
123+
- Affinity maps are **process-local** (lost on restart) and size-bounded.
124+
- Credential **401/403** failures mark `needsReauth` and exclude the account until login is fixed.
125+
- When all eligible accounts are cooling, clients receive **429** with `Retry-After` when known —
126+
not an authentication error.
127+
128+
Toggle and warning also appear on **Providers → anthropic → Accounts** in the GUI.
129+
:::caution[Experimental]
130+
Leave this disabled unless you understand Anthropic account policy risk. Prefer manual
131+
`ocx account use anthropic <id>` switching when unsure.
132+
:::
133+
106134
### claudeCode (OcxClaudeCodeConfig)
107135

108136
Claude Code inbound settings consumed by the `/v1/messages` surface, the `ocx claude`
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/**
2+
* Opt-in Anthropic OAuth account pool controls (#294).
3+
* Experimental — shows a strong warning because the feature is not battle-tested.
4+
*/
5+
import { useCallback, useEffect, useState } from "react";
6+
import { useT } from "../../i18n/shared";
7+
8+
type PoolState = {
9+
enabled: boolean;
10+
threshold: number;
11+
};
12+
13+
export default function AnthropicAccountPoolSettings({
14+
apiBase,
15+
accountCount,
16+
}: {
17+
apiBase: string;
18+
accountCount: number;
19+
}) {
20+
const t = useT();
21+
const [state, setState] = useState<PoolState | null>(null);
22+
const [draft, setDraft] = useState("80");
23+
const [saving, setSaving] = useState(false);
24+
const [error, setError] = useState<string | null>(null);
25+
const [loadError, setLoadError] = useState(false);
26+
27+
useEffect(() => {
28+
let cancelled = false;
29+
const ac = new AbortController();
30+
void (async () => {
31+
try {
32+
const res = await fetch(`${apiBase}/api/oauth/accounts/pool?provider=anthropic`, {
33+
signal: ac.signal,
34+
});
35+
if (!res.ok) throw new Error("load");
36+
const json = await res.json() as { enabled?: boolean; autoSwitchThreshold?: number };
37+
if (cancelled) return;
38+
const nextEnabled = json.enabled === true;
39+
const nextThreshold = typeof json.autoSwitchThreshold === "number" ? json.autoSwitchThreshold : 80;
40+
setState({ enabled: nextEnabled, threshold: nextThreshold });
41+
setDraft(String(nextThreshold));
42+
setLoadError(false);
43+
} catch {
44+
if (cancelled || ac.signal.aborted) return;
45+
setLoadError(true);
46+
}
47+
})();
48+
return () => {
49+
cancelled = true;
50+
ac.abort();
51+
};
52+
}, [apiBase]);
53+
54+
const save = useCallback(async (nextEnabled: boolean, nextThreshold: number) => {
55+
setSaving(true);
56+
setError(null);
57+
try {
58+
const res = await fetch(`${apiBase}/api/oauth/accounts/pool`, {
59+
method: "PUT",
60+
headers: { "content-type": "application/json" },
61+
body: JSON.stringify({
62+
provider: "anthropic",
63+
enabled: nextEnabled,
64+
autoSwitchThreshold: nextThreshold,
65+
}),
66+
});
67+
if (!res.ok) throw new Error("save");
68+
setState({ enabled: nextEnabled, threshold: nextThreshold });
69+
setDraft(String(nextThreshold));
70+
} catch {
71+
setError(t("anthropicPool.saveFailed"));
72+
} finally {
73+
setSaving(false);
74+
}
75+
}, [apiBase, t]);
76+
77+
const enabled = state?.enabled === true;
78+
const threshold = state?.threshold ?? 80;
79+
const loading = state === null && !loadError;
80+
// Always allow turning the pool off; only block enabling when fewer than 2 accounts.
81+
const toggleDisabled = loading || saving || loadError || (!enabled && accountCount < 2);
82+
83+
return (
84+
<div className="card" style={{ marginTop: 12 }} aria-busy={loading || saving}>
85+
<div className="card-row" style={{ alignItems: "flex-start", gap: 12 }}>
86+
<div style={{ flex: 1 }}>
87+
<strong>{t("anthropicPool.title")}</strong>
88+
<div className="card-sub" style={{ marginTop: 4 }}>
89+
{loadError
90+
? t("anthropicPool.loadFailed")
91+
: loading
92+
? t("common.loading")
93+
: enabled
94+
? t("anthropicPool.enabledDesc", { threshold })
95+
: t("anthropicPool.disabledDesc")}
96+
</div>
97+
</div>
98+
<label className="toggle" style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
99+
<input
100+
type="checkbox"
101+
checked={enabled}
102+
disabled={toggleDisabled}
103+
onChange={(event) => {
104+
const next = event.target.checked;
105+
void save(next, threshold);
106+
}}
107+
/>
108+
<span>{enabled ? t("anthropicPool.on") : t("anthropicPool.off")}</span>
109+
</label>
110+
</div>
111+
112+
<div
113+
role="alert"
114+
className="card-sub"
115+
style={{
116+
marginTop: 10,
117+
padding: "8px 10px",
118+
border: "1px solid var(--border, #c9a227)",
119+
borderRadius: 6,
120+
background: "color-mix(in srgb, var(--warn, #c9a227) 12%, transparent)",
121+
}}
122+
>
123+
{t("anthropicPool.experimentalWarning")}
124+
</div>
125+
126+
{accountCount < 2 && (
127+
<div className="card-sub" style={{ marginTop: 8 }}>{t("anthropicPool.needTwoAccounts")}</div>
128+
)}
129+
130+
{enabled && (
131+
<label className="field" style={{ display: "block", marginTop: 12 }}>
132+
<span className="field-label">{t("anthropicPool.threshold")}</span>
133+
<input
134+
className="input mono"
135+
type="number"
136+
min={0}
137+
max={100}
138+
step={1}
139+
value={draft}
140+
disabled={saving}
141+
aria-label={t("anthropicPool.thresholdAria")}
142+
onChange={(event) => setDraft(event.target.value)}
143+
onBlur={() => {
144+
const parsed = Number(draft);
145+
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) {
146+
setDraft(String(threshold));
147+
setError(t("anthropicPool.thresholdInvalid"));
148+
return;
149+
}
150+
if (parsed !== threshold) void save(true, parsed);
151+
}}
152+
/>
153+
<div className="card-sub" style={{ marginTop: 4 }}>{t("anthropicPool.thresholdHelp")}</div>
154+
</label>
155+
)}
156+
157+
{error && (
158+
<div role="alert" className="card-sub" style={{ marginTop: 8, color: "var(--danger, #c44)" }}>
159+
{error}
160+
</div>
161+
)}
162+
</div>
163+
);
164+
}

gui/src/components/provider-workspace/ProviderAuthPanel.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
oauthHealthShowsReauth,
2020
} from "../../oauth-health-display";
2121
import CodexAccountPool from "../CodexAccountPool";
22+
import AnthropicAccountPoolSettings from "./AnthropicAccountPoolSettings";
2223
import { LoginUrlBlock } from "../login-url-block";
2324
import QuotaBars from "../QuotaBars";
2425
import { useCopyFeedback } from "../use-copy-feedback";
@@ -105,6 +106,9 @@ export default function ProviderAuthPanel({
105106
<div className="pwi-auth-body">
106107
{isOauth && (
107108
<>
109+
{item.name === "anthropic" && (
110+
<AnthropicAccountPoolSettings apiBase={apiBase} accountCount={accounts.length} />
111+
)}
108112
<div className="pwi-auth-status-row">
109113
<span className={`pwi-auth-dot ${activeNeedsReauth ? "pwi-auth-dot--warn" : loggedIn ? "pwi-auth-dot--ok" : "pwi-auth-dot--off"}`} aria-hidden="true" />
110114
<span className="pwi-auth-status-text">

gui/src/i18n/de.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,19 @@ export const de: Record<TKey, string> = {
668668
"codexAuth.autoSwitchThresholdInvalid": "Gib eine ganze Zahl von 1 bis 100 ein",
669669
"codexAuth.autoSwitchUpdated": "Der automatische Kontowechsel wurde aktualisiert",
670670
"codexAuth.autoSwitchUpdateFailed": "Die Aktualisierung konnte nicht bestätigt werden. Der zuletzt bestätigte Wert wird angezeigt.",
671+
"anthropicPool.title": "Claude-Kontenpool (experimentell)",
672+
"anthropicPool.enabledDesc": "Bei 429 wird das Konto gekühlt und umgeschaltet. Neue Sitzungen bevorzugen Nutzung unter {threshold}% (5-Stunden-Balken).",
673+
"anthropicPool.disabledDesc": "Nutzt nur das aktive Claude-Konto. Nur aktivieren, wenn experimentelles Routing akzeptabel ist.",
674+
"anthropicPool.experimentalWarning": "Experimentell und nicht kampferprobt. Anthropic kann Konten einschränken, die wie automatische Multi-Konto-Rotation wirken. Dieselbe Organisation kann Kontingent teilen — Pooling hilft dann nicht. Ausgeschaltet lassen, sofern das Risiko unklar ist.",
675+
"anthropicPool.needTwoAccounts": "Füge mindestens zwei Claude-OAuth-Konten hinzu, bevor du den Pool aktivierst.",
676+
"anthropicPool.threshold": "Nutzungsschwelle für neue Sitzungen",
677+
"anthropicPool.thresholdAria": "Nutzungsschwelle für neue Sitzungen in Prozent",
678+
"anthropicPool.thresholdHelp": "0 deaktiviert die kontingentbasierte Auswahl (nur Affinität + aktives Konto). Standard 80.",
679+
"anthropicPool.thresholdInvalid": "Gib eine ganze Zahl von 0 bis 100 ein",
680+
"anthropicPool.loadFailed": "Claude-Pool-Einstellungen konnten nicht geladen werden.",
681+
"anthropicPool.saveFailed": "Claude-Pool-Einstellungen konnten nicht gespeichert werden.",
682+
"anthropicPool.on": "An",
683+
"anthropicPool.off": "Aus",
671684
"codexAuth.switched": "{email} ist für die nächste Anfrage ausgewählt",
672685
"codexAuth.loadFailed": "Die Codex-Kontoeinstellungen konnten nicht geladen werden.",
673686
"codexAuth.switchFailed": "Das Konto konnte nicht gewechselt werden. Die vorherige Auswahl bleibt erhalten.",

gui/src/i18n/en.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1079,6 +1079,21 @@ export const en = {
10791079
"codexAuth.autoSwitchThresholdInvalid": "Enter a whole number from 1 to 100",
10801080
"codexAuth.autoSwitchUpdated": "Automatic account switching updated",
10811081
"codexAuth.autoSwitchUpdateFailed": "The update could not be confirmed. The last confirmed value is shown.",
1082+
1083+
"anthropicPool.title": "Claude account pool (experimental)",
1084+
"anthropicPool.enabledDesc": "On 429, cools the account and fails over. New sessions prefer usage under {threshold}% (5-hour bar).",
1085+
"anthropicPool.disabledDesc": "Uses only the active Claude account. Enable only if you accept experimental routing.",
1086+
"anthropicPool.experimentalWarning": "Experimental and not battle-tested. Anthropic may restrict accounts that look like automated multi-account rotation. Same organization can share quota — pooling those accounts will not help. Keep this off unless you understand the risk.",
1087+
"anthropicPool.needTwoAccounts": "Add at least two Claude OAuth accounts before enabling the pool.",
1088+
"anthropicPool.threshold": "New-session usage threshold",
1089+
"anthropicPool.thresholdAria": "New-session usage threshold, percent",
1090+
"anthropicPool.thresholdHelp": "0 disables quota-based picking (affinity + active account only). Default 80.",
1091+
"anthropicPool.thresholdInvalid": "Enter a whole number from 0 to 100",
1092+
"anthropicPool.loadFailed": "Claude pool settings could not be loaded.",
1093+
"anthropicPool.saveFailed": "Claude pool settings could not be saved.",
1094+
"anthropicPool.on": "On",
1095+
"anthropicPool.off": "Off",
1096+
10821097
"codexAuth.switched": "{email} is selected for the next request",
10831098
"codexAuth.loadFailed": "Codex account settings could not be loaded.",
10841099
"codexAuth.switchFailed": "The account could not be switched. Your previous selection is unchanged.",

gui/src/i18n/ja.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1036,6 +1036,19 @@ export const ja: Record<TKey, string> = {
10361036
"codexAuth.autoSwitchThresholdInvalid": "1 から 100 までの整数を入力してください",
10371037
"codexAuth.autoSwitchUpdated": "アカウントの自動切り替え設定を更新しました",
10381038
"codexAuth.autoSwitchUpdateFailed": "更新を確認できませんでした。最後に確認された値を表示しています。",
1039+
"anthropicPool.title": "Claude アカウントプール(実験的)",
1040+
"anthropicPool.enabledDesc": "429 時にアカウントをクールダウンしてフェイルオーバーします。新規セッションは 5 時間使用率が {threshold}% 未満のアカウントを優先します。",
1041+
"anthropicPool.disabledDesc": "アクティブな Claude アカウントのみを使用します。実験的ルーティングを受け入れる場合のみ有効にしてください。",
1042+
"anthropicPool.experimentalWarning": "実験的で十分に検証されていません。自動的な複数アカウント回転に見える行為は Anthropic により制限される可能性があります。同一組織はクォータを共有することがあり、その場合プールしても効果がありません。リスクを理解していない場合はオフのままにしてください。",
1043+
"anthropicPool.needTwoAccounts": "プールを有効にする前に、Claude OAuth アカウントを 2 つ以上追加してください。",
1044+
"anthropicPool.threshold": "新規セッションの使用率しきい値",
1045+
"anthropicPool.thresholdAria": "新規セッションの使用率しきい値(パーセント)",
1046+
"anthropicPool.thresholdHelp": "0 はクォータに基づく選択を無効にします(アフィニティ + アクティブアカウントのみ)。デフォルト 80。",
1047+
"anthropicPool.thresholdInvalid": "0 から 100 までの整数を入力してください",
1048+
"anthropicPool.loadFailed": "Claude プール設定を読み込めませんでした。",
1049+
"anthropicPool.saveFailed": "Claude プール設定を保存できませんでした。",
1050+
"anthropicPool.on": "オン",
1051+
"anthropicPool.off": "オフ",
10391052
"codexAuth.switched": "次のリクエストでは {email} を使用します",
10401053
"codexAuth.loadFailed": "Codex アカウント設定を読み込めませんでした。",
10411054
"codexAuth.switchFailed": "アカウントを切り替えられませんでした。以前の選択はそのままです。",

gui/src/i18n/ko.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,19 @@ export const ko: Record<TKey, string> = {
685685
"codexAuth.autoSwitchThresholdInvalid": "1~100 사이의 정수를 입력하세요",
686686
"codexAuth.autoSwitchUpdated": "자동 계정 전환 설정을 저장했습니다",
687687
"codexAuth.autoSwitchUpdateFailed": "자동 계정 전환 설정 변경을 확인하지 못했습니다. 마지막으로 확인된 값을 표시합니다.",
688+
"anthropicPool.title": "Claude 계정 풀(실험적)",
689+
"anthropicPool.enabledDesc": "429 시 계정을 쿨다운하고 장애 조치합니다. 새 세션은 5시간 사용량이 {threshold}% 미만인 계정을 우선합니다.",
690+
"anthropicPool.disabledDesc": "활성 Claude 계정만 사용합니다. 실험적 라우팅을 감수할 때만 켜세요.",
691+
"anthropicPool.experimentalWarning": "실험적이며 충분히 검증되지 않았습니다. 자동 다중 계정 로테이션처럼 보이는 동작은 Anthropic이 계정을 제한할 수 있습니다. 같은 조직은 할당량을 공유할 수 있어 풀링이 도움이 되지 않을 수 있습니다. 위험을 이해하지 못하면 꺼 두세요.",
692+
"anthropicPool.needTwoAccounts": "풀을 켜기 전에 Claude OAuth 계정을 두 개 이상 추가하세요.",
693+
"anthropicPool.threshold": "새 세션 사용량 임계값",
694+
"anthropicPool.thresholdAria": "새 세션 사용량 임계값(퍼센트)",
695+
"anthropicPool.thresholdHelp": "0은 할당량 기반 선택을 끕니다(어피니티 + 활성 계정만). 기본값 80.",
696+
"anthropicPool.thresholdInvalid": "0에서 100 사이의 정수를 입력하세요",
697+
"anthropicPool.loadFailed": "Claude 풀 설정을 불러오지 못했습니다.",
698+
"anthropicPool.saveFailed": "Claude 풀 설정을 저장하지 못했습니다.",
699+
"anthropicPool.on": "켜짐",
700+
"anthropicPool.off": "꺼짐",
688701
"codexAuth.switched": "다음 요청에 {email}을(를) 사용합니다",
689702
"codexAuth.loadFailed": "Codex 계정 설정을 불러오지 못했습니다.",
690703
"codexAuth.switchFailed": "계정을 전환하지 못했습니다. 이전 선택은 그대로 유지됩니다.",

0 commit comments

Comments
 (0)