Skip to content
Closed
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
164 changes: 89 additions & 75 deletions gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
/**
* Opt-in Anthropic OAuth account pool controls (#294).
* Experimental — shows a strong warning because the feature is not battle-tested.
* OAuth account-pool controls for Anthropic and generic providers.
*
* Anthropic keeps quotaWindow and its experimental warning. Generic OAuth
* providers (including Google Antigravity) share the same /api/pool/settings
* contract without quotaWindow: the toggle is proactive pre-dispatch selection,
* while 429 rotation stays presence-driven.
*/
import { useCallback, useEffect, useState } from "react";
import { useT } from "../../i18n/shared";
Expand Down Expand Up @@ -32,16 +36,30 @@ type PoolState = {
strategy: AccountPoolStrategy;
stickyLimit: number;
quotaWindow: AccountPoolQuotaWindow;
supported: string[];
};

function controlId(provider: string, suffix: string): string {
const safe = provider.replace(/[^a-z0-9-]+/gi, "-").replace(/^-+|-+$/g, "") || "oauth";
if (provider === "anthropic") {
if (suffix === "quota-window") return "anthropic-pool-quota-window";
if (suffix === "strategy") return "anthropic-pool-strategy";
if (suffix === "sticky-limit") return "anthropic-pool-sticky-limit";
}
return safe + "-" + suffix;
}

export default function AnthropicAccountPoolSettings({
apiBase,
accountCount,
provider = "anthropic",
}: {
apiBase: string;
accountCount: number;
provider?: string;
}) {
const t = useT();
const isAnthropic = provider === "anthropic";
const [state, setState] = useState<PoolState | null>(null);
const [draft, setDraft] = useState("80");
const [stickyDraft, setStickyDraft] = useState(String(DEFAULT_ACCOUNT_POOL_STICKY_LIMIT));
Expand All @@ -52,17 +70,8 @@ export default function AnthropicAccountPoolSettings({
useEffect(() => {
let cancelled = false;
const ac = new AbortController();
// Promise chain rather than async/await: every setter then lives in a `.then`
// callback guarded by the same `cancelled` flag, which is the shape static analysis
// (react-doctor no-set-state-after-await-in-effect) can actually verify. The
// behaviour is unchanged — the guard and the abort controller were already here.
//
// Deferred by a microtask, not a timer: a timer had to be cancelled in cleanup, so a
// mount-then-unmount dropped the request entirely. The abort controller already covers
// in-flight cancellation, which is the part that actually needs to be cancellable.
void Promise.resolve()
// Through the shared pool client, which speaks the one contract every kind answers on.
.then(() => getPoolSettings(apiBase, "anthropic", (input, init) => fetch(input, init), { signal: ac.signal }))
.then(() => getPoolSettings(apiBase, provider, (input, init) => fetch(input, init), { signal: ac.signal }))
.then(settings => {
if (!settings) throw new Error("load");
return settings;
Expand All @@ -72,11 +81,14 @@ export default function AnthropicAccountPoolSettings({
const nextThreshold = typeof json.autoSwitchThreshold === "number" ? json.autoSwitchThreshold : 80;
const nextSticky = normalizeAccountPoolStickyLimit(json.stickyLimit);
setState({
enabled: json.enabled === true,
enabled: json.enabled === true || json.enabledEffective === true,
threshold: nextThreshold,
strategy: normalizeAccountPoolStrategy(json.strategy),
stickyLimit: nextSticky,
quotaWindow: normalizeAccountPoolQuotaWindow(json.quotaWindow),
quotaWindow: json.quotaWindow == null
? DEFAULT_ACCOUNT_POOL_QUOTA_WINDOW
: normalizeAccountPoolQuotaWindow(json.quotaWindow),
supported: json.supported,
});
setDraft(String(nextThreshold));
setStickyDraft(String(nextSticky));
Expand All @@ -90,7 +102,7 @@ export default function AnthropicAccountPoolSettings({
cancelled = true;
ac.abort();
};
}, [apiBase]);
}, [apiBase, provider]);

const save = useCallback(async (next: {
enabled: boolean;
Expand All @@ -106,34 +118,36 @@ export default function AnthropicAccountPoolSettings({
strategy: next.strategy,
stickyLimit: next.stickyLimit,
quotaWindow: next.quotaWindow,
supported: previousState?.supported ?? [],
});
setSaving(true);
setError(null);
try {
// The client owns the field mapping: `threshold` becomes `autoSwitchThreshold` and the
// provider is always sent, so no call site can forget either.
const json = await putPoolSettings(apiBase, "anthropic", {
const json = await putPoolSettings(apiBase, provider, {
enabled: next.enabled,
threshold: next.threshold,
strategy: next.strategy,
stickyLimit: next.stickyLimit,
quotaWindow: next.quotaWindow,
...(isAnthropic ? { quotaWindow: next.quotaWindow } : {}),
});
if (!json) throw new Error("save");
const savedStrategy = normalizeAccountPoolStrategy(json?.strategy ?? next.strategy);
const savedSticky = normalizeAccountPoolStickyLimit(json?.stickyLimit ?? next.stickyLimit);
const savedWindow = normalizeAccountPoolQuotaWindow(json?.quotaWindow ?? next.quotaWindow);
const savedWindow = json?.quotaWindow == null
? next.quotaWindow
: normalizeAccountPoolQuotaWindow(json.quotaWindow);
setState({
enabled: next.enabled,
threshold: next.threshold,
strategy: savedStrategy,
stickyLimit: savedSticky,
quotaWindow: savedWindow,
supported: json.supported.length > 0 ? json.supported : (previousState?.supported ?? []),
});
setDraft(String(next.threshold));
setStickyDraft(String(savedSticky));
} catch {
setError(t("anthropicPool.saveFailed"));
setError(t(isAnthropic ? "anthropicPool.saveFailed" : "genericPool.saveFailed"));
if (previousState) {
setState(previousState);
setDraft(String(previousState.threshold));
Expand All @@ -142,52 +156,46 @@ export default function AnthropicAccountPoolSettings({
} finally {
setSaving(false);
}
}, [apiBase, state, t]);
}, [apiBase, isAnthropic, provider, state, t]);

const enabled = state?.enabled === true;
const threshold = state?.threshold ?? 80;
const strategy = state?.strategy ?? DEFAULT_ACCOUNT_POOL_STRATEGY;
const stickyLimit = state?.stickyLimit ?? DEFAULT_ACCOUNT_POOL_STICKY_LIMIT;
const quotaWindow = state?.quotaWindow ?? DEFAULT_ACCOUNT_POOL_QUOTA_WINDOW;
// The window is inert ONLY under round-robin, which never scores a usage bar at any stage.
//
// A 0 threshold is not inertness: it disables PROACTIVE usage-based switching, but
// new-session selection and 429 recovery still consult the configured window (see
// pickLowestUsage / rotateAnthropicAccountOn429). Treating fill-first + threshold 0 as
// inert told operators the window had no effect when it still governed two routing stages.
const showQuotaWindow = isAnthropic || (state?.supported ?? []).includes("quotaWindow");
const quotaWindowInert = strategy === "round-robin";
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);
const titleKey = isAnthropic ? "anthropicPool.title" : "genericPool.title";
const enabledDesc = isAnthropic
? (threshold === 0
? t("anthropicPool.enabledNoProactiveDesc", { window: t(QUOTA_WINDOW_LABEL_KEYS[quotaWindow]) })
: t("anthropicPool.enabledDesc", { threshold, window: t(QUOTA_WINDOW_LABEL_KEYS[quotaWindow]) }))
: (threshold === 0 ? t("genericPool.enabledNoProactiveDesc") : t("genericPool.enabledDesc", { threshold }));
const disabledDesc = t(isAnthropic ? "anthropicPool.disabledDesc" : "genericPool.disabledDesc");

return (
<div className="card anthropic-pool-card" aria-busy={loading || saving}>
<div className="card-row" style={{ alignItems: "flex-start", gap: 12 }}>
<div style={{ flex: 1 }}>
<strong>{t("anthropicPool.title")}</strong>
<strong>{t(titleKey)}</strong>
<div className="card-sub" style={{ marginTop: 4 }}>
{loadError
? t("anthropicPool.loadFailed")
? t(isAnthropic ? "anthropicPool.loadFailed" : "genericPool.loadFailed")
: loading
? t("common.loading")
: enabled
? threshold === 0
? t("anthropicPool.enabledNoProactiveDesc", {
window: t(QUOTA_WINDOW_LABEL_KEYS[quotaWindow]),
})
: t("anthropicPool.enabledDesc", {
threshold,
window: t(QUOTA_WINDOW_LABEL_KEYS[quotaWindow]),
})
: t("anthropicPool.disabledDesc")}
? enabledDesc
: disabledDesc}
</div>
</div>
<button
type="button"
className={`toggle ${enabled ? "on" : ""}`}
disabled={toggleDisabled}
aria-pressed={enabled}
aria-label={t("anthropicPool.title")}
aria-label={t(titleKey)}
title={enabled ? t("anthropicPool.on") : t("anthropicPool.off")}
onClick={() => {
void save({
Expand All @@ -204,17 +212,19 @@ export default function AnthropicAccountPoolSettings({
</div>

<div role="alert" className="card-sub anthropic-pool-card__notice">
{t("anthropicPool.experimentalWarning")}
{t(isAnthropic ? "anthropicPool.experimentalWarning" : "genericPool.notice")}
</div>

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

{enabled && state && (
<>
<label className="field anthropic-pool-card__field">
<span className="field-label">{t("anthropicPool.threshold")}</span>
<span className="field-label">{t(isAnthropic ? "anthropicPool.threshold" : "genericPool.threshold")}</span>
<input
className="input mono"
type="number"
Expand All @@ -223,7 +233,7 @@ export default function AnthropicAccountPoolSettings({
step={1}
value={draft}
disabled={saving}
aria-label={t("anthropicPool.thresholdAria")}
aria-label={t(isAnthropic ? "anthropicPool.thresholdAria" : "genericPool.thresholdAria")}
onChange={(event) => setDraft(event.target.value)}
onBlur={() => {
const parsed = Number(draft);
Expand All @@ -243,15 +253,17 @@ export default function AnthropicAccountPoolSettings({
}
}}
/>
<div className="card-sub" style={{ marginTop: 4 }}>{t("anthropicPool.thresholdHelp")}</div>
<div className="card-sub" style={{ marginTop: 4 }}>
{t(isAnthropic ? "anthropicPool.thresholdHelp" : "genericPool.thresholdHelp")}
</div>
</label>

<AccountPoolStrategyControls
strategy={strategy}
stickyDraft={stickyDraft}
disabled={saving}
strategySelectId="anthropic-pool-strategy"
stickyInputId="anthropic-pool-sticky-limit"
strategySelectId={controlId(provider, "strategy")}
stickyInputId={controlId(provider, "sticky-limit")}
onStrategyChange={(next) => {
if (next === strategy) return;
void save({
Expand Down Expand Up @@ -284,34 +296,36 @@ export default function AnthropicAccountPoolSettings({
}}
/>

<div className="field anthropic-pool-card__field anthropic-pool-card__field--quota-window">
<span className="field-label">{t("accountPool.quotaWindow")}</span>
<Select
id="anthropic-pool-quota-window"
value={quotaWindow}
options={ACCOUNT_POOL_QUOTA_WINDOWS.map((value) => ({
value,
label: t(QUOTA_WINDOW_LABEL_KEYS[value]),
}))}
disabled={saving || quotaWindowInert}
label={t("accountPool.quotaWindow")}
onChange={(next) => {
const parsed = normalizeAccountPoolQuotaWindow(next);
if (parsed === quotaWindow) return;
void save({
enabled: true,
threshold,
strategy,
stickyLimit,
quotaWindow: parsed,
});
}}
/>
<div className="card-sub" style={{ marginTop: 4 }}>{t("accountPool.quotaWindowDesc")}</div>
<div className="card-sub" style={{ marginTop: 4 }}>
{quotaWindowInert ? t("accountPool.quotaWindowInert") : t("accountPool.quotaWindowHint")}
{showQuotaWindow && (
<div className="field anthropic-pool-card__field anthropic-pool-card__field--quota-window">
<span className="field-label">{t("accountPool.quotaWindow")}</span>
<Select
id={controlId(provider, "quota-window")}
value={quotaWindow}
options={ACCOUNT_POOL_QUOTA_WINDOWS.map((value) => ({
value,
label: t(QUOTA_WINDOW_LABEL_KEYS[value]),
}))}
disabled={saving || quotaWindowInert}
label={t("accountPool.quotaWindow")}
onChange={(next) => {
const parsed = normalizeAccountPoolQuotaWindow(next);
if (parsed === quotaWindow) return;
void save({
enabled: true,
threshold,
strategy,
stickyLimit,
quotaWindow: parsed,
});
}}
/>
<div className="card-sub" style={{ marginTop: 4 }}>{t("accountPool.quotaWindowDesc")}</div>
<div className="card-sub" style={{ marginTop: 4 }}>
{quotaWindowInert ? t("accountPool.quotaWindowInert") : t("accountPool.quotaWindowHint")}
</div>
</div>
</div>
)}
</>
)}

Expand Down
11 changes: 7 additions & 4 deletions gui/src/components/provider-workspace/ProviderAuthPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -389,10 +389,14 @@ export default function ProviderAuthPanel({
)}
{isOauth && (
<>
{item.name === "anthropic" && (
<AnthropicAccountPoolSettings apiBase={apiBase} accountCount={accounts.length} />
{item.name !== "openai" && (
<AnthropicAccountPoolSettings
apiBase={apiBase}
accountCount={accounts.length}
provider={item.name}
/>
)}

{/* Only show initial login row when no accounts exist yet */}
{accounts.length === 0 && (
<div className="pwi-auth-status-row">
Expand Down Expand Up @@ -632,4 +636,3 @@ export default function ProviderAuthPanel({
</section>
);
}

11 changes: 11 additions & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2926,4 +2926,15 @@ export const de: Record<TKey, string> = {
"pws.statsClaudeAvailable": "Für Claude verfügbar",
"pws.statsGeminiAvailable": "Für Gemini verfügbar",
"pws.statsAvailable": "Verfügbar",
"genericPool.title": "Account-Pool",
"genericPool.enabledDesc": "Neue Sitzungen bevorzugen Restquote unter {threshold} %. Bei 429 wechselt die Anfrage weiterhin zwischen angemeldeten Accounts.",
"genericPool.enabledNoProactiveDesc": "Die vorausschauende nutzungsbasierte Umschaltung ist bei Schwelle 0 aus. Bei 429 wechselt die Anfrage weiterhin zwischen angemeldeten Accounts.",
"genericPool.disabledDesc": "Der aktive Account bleibt, bis er fehlschlägt. Bei 429 wechselt die Anfrage weiterhin zwischen angemeldeten Accounts — das lässt sich nicht abschalten.",
"genericPool.notice": "Dieser Schalter steuert nur die vorausschauende Auswahl vor dem Versand. Round-Robin und Fill-first werden hier gespeichert und greifen, wenn der gemeinsame Pool-Kernel an ist.",
"genericPool.needTwoAccounts": "Fügen Sie mindestens zwei OAuth-Accounts hinzu, bevor Sie die vorausschauende Auswahl aktivieren.",
"genericPool.threshold": "Schwelle für vorausschauende Nutzung",
"genericPool.thresholdAria": "Schwelle für vorausschauende Nutzung, Prozent",
"genericPool.thresholdHelp": "0 schaltet die vorausschauende Auswahl aus und behält den aktiven Account bis zu einem 429. Standard 80. Wirkt, wenn Quotendaten vorliegen.",
"genericPool.loadFailed": "Pool-Einstellungen konnten nicht geladen werden.",
"genericPool.saveFailed": "Pool-Einstellungen konnten nicht gespeichert werden.",
};
12 changes: 12 additions & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2051,6 +2051,18 @@ export const en = {
"anthropicPool.on": "On",
"anthropicPool.off": "Off",

"genericPool.title": "Account pool",
"genericPool.enabledDesc": "New sessions prefer remaining quota under {threshold}%. A 429 still failovers among logged-in accounts.",
"genericPool.enabledNoProactiveDesc": "Proactive usage-based switching is off at threshold 0. A 429 still failovers among logged-in accounts.",
"genericPool.disabledDesc": "The active account is kept until it fails. A 429 still failovers among logged-in accounts — that cannot be turned off.",
"genericPool.notice": "This switch only controls proactive selection before dispatch. Round-robin and fill-first are saved here and apply when the shared pool kernel is on.",
"genericPool.needTwoAccounts": "Add at least two OAuth accounts before enabling proactive selection.",
"genericPool.threshold": "Proactive usage threshold",
"genericPool.thresholdAria": "Proactive usage threshold, percent",
"genericPool.thresholdHelp": "0 disables proactive picking and keeps the active account until a 429. Default 80. Applies when quota evidence exists.",
"genericPool.loadFailed": "Pool settings could not be loaded.",
"genericPool.saveFailed": "Pool settings could not be saved.",

"accountPool.strategy": "Rotation strategy",
"accountPool.strategyDesc": "How OpenCodex assigns an account to a new/unbound task.",
"accountPool.strategyResetFirst": "Soonest reset first",
Expand Down
11 changes: 11 additions & 0 deletions gui/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2914,4 +2914,15 @@ export const fr: Record<TKey, string> = {
"pws.statsClaudeAvailable": "Disponibles pour Claude",
"pws.statsGeminiAvailable": "Disponibles pour Gemini",
"pws.statsAvailable": "Disponibles",
"genericPool.title": "Pool de comptes",
"genericPool.enabledDesc": "Les nouvelles sessions préfèrent un quota restant sous {threshold} %. Un 429 bascule toujours entre les comptes connectés.",
"genericPool.enabledNoProactiveDesc": "Le basculement proactif selon l’usage est désactivé au seuil 0. Un 429 bascule toujours entre les comptes connectés.",
"genericPool.disabledDesc": "Le compte actif est conservé jusqu’à un échec. Un 429 bascule toujours entre les comptes connectés — cela ne peut pas être désactivé.",
"genericPool.notice": "Cet interrupteur ne commande que la sélection proactive avant l’envoi. Round-robin et fill-first sont enregistrés ici et s’appliquent lorsque le noyau de pool partagé est activé.",
"genericPool.needTwoAccounts": "Ajoutez au moins deux comptes OAuth avant d’activer la sélection proactive.",
"genericPool.threshold": "Seuil d’usage proactif",
"genericPool.thresholdAria": "Seuil d’usage proactif, en pourcentage",
"genericPool.thresholdHelp": "0 désactive le choix proactif et garde le compte actif jusqu’à un 429. Valeur par défaut : 80. S’applique lorsqu’il existe des données de quota.",
"genericPool.loadFailed": "Impossible de charger les paramètres du pool.",
"genericPool.saveFailed": "Impossible d’enregistrer les paramètres du pool.",
};
Loading
Loading