Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs-site/src/content/docs/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions docs-site/src/content/docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` switching when unsure.
:::

### claudeCode (OcxClaudeCodeConfig)

Claude Code inbound settings consumed by the `/v1/messages` surface, the `ocx claude`
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PoolState | null>(null);
const [draft, setDraft] = useState("80");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="card" style={{ marginTop: 12 }} aria-busy={loading || saving}>
<div className="card-row" style={{ alignItems: "flex-start", gap: 12 }}>
<div style={{ flex: 1 }}>
<strong>{t("anthropicPool.title")}</strong>
<div className="card-sub" style={{ marginTop: 4 }}>
{loadError
? t("anthropicPool.loadFailed")
: loading
? t("common.loading")
: enabled
? t("anthropicPool.enabledDesc", { threshold })
: t("anthropicPool.disabledDesc")}
</div>
</div>
<label className="toggle" style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
<input
type="checkbox"
checked={enabled}
disabled={toggleDisabled}
onChange={(event) => {
const next = event.target.checked;
void save(next, threshold);
}}
/>
<span>{enabled ? t("anthropicPool.on") : t("anthropicPool.off")}</span>
</label>
</div>

<div
role="alert"
className="card-sub"
style={{
marginTop: 10,
padding: "8px 10px",
border: "1px solid var(--border, #c9a227)",
borderRadius: 6,
background: "color-mix(in srgb, var(--warn, #c9a227) 12%, transparent)",
}}
>
{t("anthropicPool.experimentalWarning")}
</div>

{accountCount < 2 && (
<div className="card-sub" style={{ marginTop: 8 }}>{t("anthropicPool.needTwoAccounts")}</div>
)}

{enabled && (
<label className="field" style={{ display: "block", marginTop: 12 }}>
<span className="field-label">{t("anthropicPool.threshold")}</span>
<input
className="input mono"
type="number"
min={0}
max={100}
step={1}
value={draft}
disabled={saving}
aria-label={t("anthropicPool.thresholdAria")}
onChange={(event) => setDraft(event.target.value)}
onBlur={() => {
const parsed = Number(draft);
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) {
setDraft(String(threshold));
setError(t("anthropicPool.thresholdInvalid"));
return;
}
if (parsed !== threshold) void save(true, parsed);
}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/>
<div className="card-sub" style={{ marginTop: 4 }}>{t("anthropicPool.thresholdHelp")}</div>
</label>
)}

{error && (
<div role="alert" className="card-sub" style={{ marginTop: 8, color: "var(--danger, #c44)" }}>
{error}
</div>
)}
</div>
);
}
4 changes: 4 additions & 0 deletions gui/src/components/provider-workspace/ProviderAuthPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -105,6 +106,9 @@ export default function ProviderAuthPanel({
<div className="pwi-auth-body">
{isOauth && (
<>
{item.name === "anthropic" && (
<AnthropicAccountPoolSettings apiBase={apiBase} accountCount={accounts.length} />
)}
<div className="pwi-auth-status-row">
<span className={`pwi-auth-dot ${activeNeedsReauth ? "pwi-auth-dot--warn" : loggedIn ? "pwi-auth-dot--ok" : "pwi-auth-dot--off"}`} aria-hidden="true" />
<span className="pwi-auth-status-text">
Expand Down
13 changes: 13 additions & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,19 @@ export const de: Record<TKey, string> = {
"codexAuth.autoSwitchThresholdInvalid": "Gib eine ganze Zahl von 1 bis 100 ein",
"codexAuth.autoSwitchUpdated": "Der automatische Kontowechsel wurde aktualisiert",
"codexAuth.autoSwitchUpdateFailed": "Die Aktualisierung konnte nicht bestätigt werden. Der zuletzt bestätigte Wert wird angezeigt.",
"anthropicPool.title": "Claude-Kontenpool (experimentell)",
"anthropicPool.enabledDesc": "Bei 429 wird das Konto gekühlt und umgeschaltet. Neue Sitzungen bevorzugen Nutzung unter {threshold}% (5-Stunden-Balken).",
"anthropicPool.disabledDesc": "Nutzt nur das aktive Claude-Konto. Nur aktivieren, wenn experimentelles Routing akzeptabel ist.",
"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.",
"anthropicPool.needTwoAccounts": "Füge mindestens zwei Claude-OAuth-Konten hinzu, bevor du den Pool aktivierst.",
"anthropicPool.threshold": "Nutzungsschwelle für neue Sitzungen",
"anthropicPool.thresholdAria": "Nutzungsschwelle für neue Sitzungen in Prozent",
"anthropicPool.thresholdHelp": "0 deaktiviert die kontingentbasierte Auswahl (nur Affinität + aktives Konto). Standard 80.",
"anthropicPool.thresholdInvalid": "Gib eine ganze Zahl von 0 bis 100 ein",
"anthropicPool.loadFailed": "Claude-Pool-Einstellungen konnten nicht geladen werden.",
"anthropicPool.saveFailed": "Claude-Pool-Einstellungen konnten nicht gespeichert werden.",
"anthropicPool.on": "An",
"anthropicPool.off": "Aus",
"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.",
Expand Down
15 changes: 15 additions & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1079,6 +1079,21 @@ export const en = {
"codexAuth.autoSwitchThresholdInvalid": "Enter a whole number from 1 to 100",
"codexAuth.autoSwitchUpdated": "Automatic account switching updated",
"codexAuth.autoSwitchUpdateFailed": "The update could not be confirmed. The last confirmed value is shown.",

"anthropicPool.title": "Claude account pool (experimental)",
"anthropicPool.enabledDesc": "On 429, cools the account and fails over. New sessions prefer usage under {threshold}% (5-hour bar).",
"anthropicPool.disabledDesc": "Uses only the active Claude account. Enable only if you accept experimental routing.",
"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.",
"anthropicPool.needTwoAccounts": "Add at least two Claude OAuth accounts before enabling the pool.",
"anthropicPool.threshold": "New-session usage threshold",
"anthropicPool.thresholdAria": "New-session usage threshold, percent",
"anthropicPool.thresholdHelp": "0 disables quota-based picking (affinity + active account only). Default 80.",
"anthropicPool.thresholdInvalid": "Enter a whole number from 0 to 100",
"anthropicPool.loadFailed": "Claude pool settings could not be loaded.",
"anthropicPool.saveFailed": "Claude pool settings could not be saved.",
"anthropicPool.on": "On",
"anthropicPool.off": "Off",

"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.",
Expand Down
13 changes: 13 additions & 0 deletions gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1036,6 +1036,19 @@ export const ja: Record<TKey, string> = {
"codexAuth.autoSwitchThresholdInvalid": "1 から 100 までの整数を入力してください",
"codexAuth.autoSwitchUpdated": "アカウントの自動切り替え設定を更新しました",
"codexAuth.autoSwitchUpdateFailed": "更新を確認できませんでした。最後に確認された値を表示しています。",
"anthropicPool.title": "Claude アカウントプール(実験的)",
"anthropicPool.enabledDesc": "429 時にアカウントをクールダウンしてフェイルオーバーします。新規セッションは 5 時間使用率が {threshold}% 未満のアカウントを優先します。",
"anthropicPool.disabledDesc": "アクティブな Claude アカウントのみを使用します。実験的ルーティングを受け入れる場合のみ有効にしてください。",
"anthropicPool.experimentalWarning": "実験的で十分に検証されていません。自動的な複数アカウント回転に見える行為は Anthropic により制限される可能性があります。同一組織はクォータを共有することがあり、その場合プールしても効果がありません。リスクを理解していない場合はオフのままにしてください。",
"anthropicPool.needTwoAccounts": "プールを有効にする前に、Claude OAuth アカウントを 2 つ以上追加してください。",
"anthropicPool.threshold": "新規セッションの使用率しきい値",
"anthropicPool.thresholdAria": "新規セッションの使用率しきい値(パーセント)",
"anthropicPool.thresholdHelp": "0 はクォータに基づく選択を無効にします(アフィニティ + アクティブアカウントのみ)。デフォルト 80。",
"anthropicPool.thresholdInvalid": "0 から 100 までの整数を入力してください",
"anthropicPool.loadFailed": "Claude プール設定を読み込めませんでした。",
"anthropicPool.saveFailed": "Claude プール設定を保存できませんでした。",
"anthropicPool.on": "オン",
"anthropicPool.off": "オフ",
"codexAuth.switched": "次のリクエストでは {email} を使用します",
"codexAuth.loadFailed": "Codex アカウント設定を読み込めませんでした。",
"codexAuth.switchFailed": "アカウントを切り替えられませんでした。以前の選択はそのままです。",
Expand Down
13 changes: 13 additions & 0 deletions gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,19 @@ export const ko: Record<TKey, string> = {
"codexAuth.autoSwitchThresholdInvalid": "1~100 사이의 정수를 입력하세요",
"codexAuth.autoSwitchUpdated": "자동 계정 전환 설정을 저장했습니다",
"codexAuth.autoSwitchUpdateFailed": "자동 계정 전환 설정 변경을 확인하지 못했습니다. 마지막으로 확인된 값을 표시합니다.",
"anthropicPool.title": "Claude 계정 풀(실험적)",
"anthropicPool.enabledDesc": "429 시 계정을 쿨다운하고 장애 조치합니다. 새 세션은 5시간 사용량이 {threshold}% 미만인 계정을 우선합니다.",
"anthropicPool.disabledDesc": "활성 Claude 계정만 사용합니다. 실험적 라우팅을 감수할 때만 켜세요.",
"anthropicPool.experimentalWarning": "실험적이며 충분히 검증되지 않았습니다. 자동 다중 계정 로테이션처럼 보이는 동작은 Anthropic이 계정을 제한할 수 있습니다. 같은 조직은 할당량을 공유할 수 있어 풀링이 도움이 되지 않을 수 있습니다. 위험을 이해하지 못하면 꺼 두세요.",
"anthropicPool.needTwoAccounts": "풀을 켜기 전에 Claude OAuth 계정을 두 개 이상 추가하세요.",
"anthropicPool.threshold": "새 세션 사용량 임계값",
"anthropicPool.thresholdAria": "새 세션 사용량 임계값(퍼센트)",
"anthropicPool.thresholdHelp": "0은 할당량 기반 선택을 끕니다(어피니티 + 활성 계정만). 기본값 80.",
"anthropicPool.thresholdInvalid": "0에서 100 사이의 정수를 입력하세요",
"anthropicPool.loadFailed": "Claude 풀 설정을 불러오지 못했습니다.",
"anthropicPool.saveFailed": "Claude 풀 설정을 저장하지 못했습니다.",
"anthropicPool.on": "켜짐",
"anthropicPool.off": "꺼짐",
"codexAuth.switched": "다음 요청에 {email}을(를) 사용합니다",
"codexAuth.loadFailed": "Codex 계정 설정을 불러오지 못했습니다.",
"codexAuth.switchFailed": "계정을 전환하지 못했습니다. 이전 선택은 그대로 유지됩니다.",
Expand Down
Loading
Loading