From fbb4da4dc3ce57e15ed05b21d97f44cdda0b3af5 Mon Sep 17 00:00:00 2001 From: agentHits <140916359+agentHits@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:46:41 -0400 Subject: [PATCH] feat(gui): mount generic OAuth pool settings on Antigravity accounts Show the existing /api/pool/settings panel for Google Antigravity and other generic OAuth providers in the accounts workspace. The toggle maps to proactive oauthAccountFailover.enabled; quotaWindow stays Anthropic-only. --- .../AnthropicAccountPoolSettings.tsx | 164 ++++++++-------- .../provider-workspace/ProviderAuthPanel.tsx | 11 +- gui/src/i18n/de.ts | 11 ++ gui/src/i18n/en.ts | 12 ++ gui/src/i18n/fr.ts | 11 ++ gui/src/i18n/ja.ts | 11 ++ gui/src/i18n/ko.ts | 11 ++ gui/src/i18n/ru.ts | 11 ++ gui/src/i18n/tr.ts | 11 ++ gui/src/i18n/zh-TW.ts | 11 ++ gui/src/i18n/zh.ts | 11 ++ .../generic-oauth-pool-settings.test.tsx | 177 ++++++++++++++++++ 12 files changed, 373 insertions(+), 79 deletions(-) create mode 100644 gui/tests/generic-oauth-pool-settings.test.tsx diff --git a/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx b/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx index 84120b54c4..4c18a3ea17 100644 --- a/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx +++ b/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx @@ -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"; @@ -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(null); const [draft, setDraft] = useState("80"); const [stickyDraft, setStickyDraft] = useState(String(DEFAULT_ACCOUNT_POOL_STICKY_LIMIT)); @@ -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; @@ -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)); @@ -90,7 +102,7 @@ export default function AnthropicAccountPoolSettings({ cancelled = true; ac.abort(); }; - }, [apiBase]); + }, [apiBase, provider]); const save = useCallback(async (next: { enabled: boolean; @@ -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)); @@ -142,44 +156,38 @@ 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 (
- {t("anthropicPool.title")} + {t(titleKey)}
{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}
- {t("anthropicPool.experimentalWarning")} + {t(isAnthropic ? "anthropicPool.experimentalWarning" : "genericPool.notice")}
{accountCount < 2 && ( -
{t("anthropicPool.needTwoAccounts")}
+
+ {t(isAnthropic ? "anthropicPool.needTwoAccounts" : "genericPool.needTwoAccounts")} +
)} {enabled && state && ( <> { if (next === strategy) return; void save({ @@ -284,34 +296,36 @@ export default function AnthropicAccountPoolSettings({ }} /> -
- {t("accountPool.quotaWindow")} - ({ + 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, + }); + }} + /> +
{t("accountPool.quotaWindowDesc")}
+
+ {quotaWindowInert ? t("accountPool.quotaWindowInert") : t("accountPool.quotaWindowHint")} +
-
+ )} )} diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index e6174b9649..a404cd5149 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -389,10 +389,14 @@ export default function ProviderAuthPanel({ )} {isOauth && ( <> - {item.name === "anthropic" && ( - + {item.name !== "openai" && ( + )} - + {/* Only show initial login row when no accounts exist yet */} {accounts.length === 0 && (
@@ -632,4 +636,3 @@ export default function ProviderAuthPanel({ ); } - diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index c81d3a4690..df3e598a43 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2926,4 +2926,15 @@ export const de: Record = { "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.", }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 35a42816b4..0ba4e96831 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -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", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 4022489a65..3d198c8855 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2914,4 +2914,15 @@ export const fr: Record = { "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.", }; diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index cca60cfc5d..8aded7dd3b 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2947,4 +2947,15 @@ export const ja: Record = { "pws.statsClaudeAvailable": "Claude利用可能", "pws.statsGeminiAvailable": "Gemini利用可能", "pws.statsAvailable": "利用可能", + "genericPool.title": "アカウントプール", + "genericPool.enabledDesc": "新しいセッションは残りのクォータが {threshold}% 未満のアカウントを優先します。429 ではログイン済みアカウント間で引き続きフェイルオーバーします。", + "genericPool.enabledNoProactiveDesc": "しきい値 0 では使用量に基づく先行切り替えはオフです。429 ではログイン済みアカウント間で引き続きフェイルオーバーします。", + "genericPool.disabledDesc": "失敗するまでアクティブアカウントを維持します。429 ではログイン済みアカウント間で引き続きフェイルオーバーし、オフにはできません。", + "genericPool.notice": "このスイッチは送信前の先行選択だけを制御します。ラウンドロビンと fill-first はここに保存され、共有プールカーネルがオンのときに適用されます。", + "genericPool.needTwoAccounts": "先行選択を有効にする前に、OAuth アカウントを少なくとも 2 つ追加してください。", + "genericPool.threshold": "先行使用量のしきい値", + "genericPool.thresholdAria": "先行使用量のしきい値(パーセント)", + "genericPool.thresholdHelp": "0 は先行選択を無効にし、429 までアクティブアカウントを維持します。既定値は 80。クォータ証拠があるときに適用されます。", + "genericPool.loadFailed": "プール設定を読み込めませんでした。", + "genericPool.saveFailed": "プール設定を保存できませんでした。", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 33a689b09b..7581dcd0e6 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2948,4 +2948,15 @@ export const ko: Record = { "pws.statsClaudeAvailable": "Claude 사용 가능", "pws.statsGeminiAvailable": "Gemini 사용 가능", "pws.statsAvailable": "사용 가능", + "genericPool.title": "계정 풀", + "genericPool.enabledDesc": "새 세션은 남은 할당량이 {threshold}% 미만인 계정을 우선합니다. 429는 로그인한 계정 사이에서 계속 장애 조치됩니다.", + "genericPool.enabledNoProactiveDesc": "사용량 기반 선제 전환은 임계값 0에서 꺼집니다. 429는 로그인한 계정 사이에서 계속 장애 조치됩니다.", + "genericPool.disabledDesc": "활성 계정은 실패할 때까지 유지됩니다. 429는 로그인한 계정 사이에서 계속 장애 조치되며 끌 수 없습니다.", + "genericPool.notice": "이 스위치는 전송 전 선제 선택만 제어합니다. 라운드로빈과 fill-first는 여기에 저장되며 공유 풀 커널이 켜져 있을 때 적용됩니다.", + "genericPool.needTwoAccounts": "선제 선택을 켜기 전에 OAuth 계정을 두 개 이상 추가하세요.", + "genericPool.threshold": "선제 사용량 임계값", + "genericPool.thresholdAria": "선제 사용량 임계값, 퍼센트", + "genericPool.thresholdHelp": "0은 선제 선택을 끄고 429 전까지 활성 계정을 유지합니다. 기본값 80. 할당량 증거가 있을 때 적용됩니다.", + "genericPool.loadFailed": "풀 설정을 불러오지 못했습니다.", + "genericPool.saveFailed": "풀 설정을 저장하지 못했습니다.", }; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 5cb735a3bc..193dece8bc 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2949,4 +2949,15 @@ export const ru: Record = { "pws.statsClaudeAvailable": "Доступны Claude", "pws.statsGeminiAvailable": "Доступны Gemini", "pws.statsAvailable": "Доступны", + "genericPool.title": "Пул аккаунтов", + "genericPool.enabledDesc": "Новые сессии предпочитают остаток квоты ниже {threshold}%. При 429 запрос всё равно переключается между вошедшими аккаунтами.", + "genericPool.enabledNoProactiveDesc": "Упреждающее переключение по использованию выключено при пороге 0. При 429 запрос всё равно переключается между вошедшими аккаунтами.", + "genericPool.disabledDesc": "Активный аккаунт сохраняется, пока не откажет. При 429 запрос всё равно переключается между вошедшими аккаунтами — это нельзя отключить.", + "genericPool.notice": "Этот переключатель управляет только упреждающим выбором до отправки. Round-robin и fill-first сохраняются здесь и применяются, когда включён общий pool kernel.", + "genericPool.needTwoAccounts": "Добавьте хотя бы два OAuth-аккаунта, прежде чем включать упреждающий выбор.", + "genericPool.threshold": "Порог упреждающего использования", + "genericPool.thresholdAria": "Порог упреждающего использования, проценты", + "genericPool.thresholdHelp": "0 отключает упреждающий выбор и держит активный аккаунт до 429. По умолчанию 80. Срабатывает, когда есть данные квоты.", + "genericPool.loadFailed": "Не удалось загрузить настройки пула.", + "genericPool.saveFailed": "Не удалось сохранить настройки пула.", }; diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 94e63bd4d8..6ed985f05e 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2949,4 +2949,15 @@ export const tr: Record = { "pws.statsClaudeAvailable": "Claude için uygun", "pws.statsGeminiAvailable": "Gemini için uygun", "pws.statsAvailable": "Kullanılabilir", + "genericPool.title": "Hesap havuzu", + "genericPool.enabledDesc": "Yeni oturumlar kalan kotası {threshold}% altındaki hesabı tercih eder. 429 yine oturum açmış hesaplar arasında geçiş yapar.", + "genericPool.enabledNoProactiveDesc": "Kullanıma göre proaktif geçiş eşik 0 iken kapalıdır. 429 yine oturum açmış hesaplar arasında geçiş yapar.", + "genericPool.disabledDesc": "Etkin hesap başarısız olana kadar tutulur. 429 yine oturum açmış hesaplar arasında geçiş yapar ve bu kapatılamaz.", + "genericPool.notice": "Bu anahtar yalnızca gönderimden önceki proaktif seçimi kontrol eder. Round-robin ve fill-first burada saklanır ve paylaşılan havuz çekirdeği açıkken uygulanır.", + "genericPool.needTwoAccounts": "Proaktif seçimi açmadan önce en az iki OAuth hesabı ekleyin.", + "genericPool.threshold": "Proaktif kullanım eşiği", + "genericPool.thresholdAria": "Proaktif kullanım eşiği, yüzde", + "genericPool.thresholdHelp": "0, proaktif seçimi kapatır ve 429 olana kadar etkin hesabı tutar. Varsayılan 80. Kota verisi varken uygulanır.", + "genericPool.loadFailed": "Havuz ayarları yüklenemedi.", + "genericPool.saveFailed": "Havuz ayarları kaydedilemedi.", }; diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index e2e63f5008..93f68e87f5 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2912,4 +2912,15 @@ export const zhTW: Record = { "pws.statsClaudeAvailable": "Claude 可用", "pws.statsGeminiAvailable": "Gemini 可用", "pws.statsAvailable": "可用", + "genericPool.title": "帳號池", + "genericPool.enabledDesc": "新工作階段會優先選擇剩餘配額低於 {threshold}% 的帳號。遇到 429 仍會在已登入帳號之間備援。", + "genericPool.enabledNoProactiveDesc": "閾值為 0 時會關閉依用量的主動切換。遇到 429 仍會在已登入帳號之間備援。", + "genericPool.disabledDesc": "在失敗之前會維持目前帳號。遇到 429 仍會在已登入帳號之間備援,且無法關閉。", + "genericPool.notice": "這個開關只控制送出前的主動選擇。輪詢與填滿優先會儲存在這裡,並在共用池核心開啟後生效。", + "genericPool.needTwoAccounts": "啟用主動選擇前請至少新增兩個 OAuth 帳號。", + "genericPool.threshold": "主動用量閾值", + "genericPool.thresholdAria": "主動用量閾值,百分比", + "genericPool.thresholdHelp": "0 會關閉主動選擇,並在遇到 429 之前維持目前帳號。預設 80。僅在有配額資料時生效。", + "genericPool.loadFailed": "無法載入帳號池設定。", + "genericPool.saveFailed": "無法儲存帳號池設定。", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 58aef945d6..c24d9f229b 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2947,4 +2947,15 @@ export const zh: Record = { "pws.statsClaudeAvailable": "Claude 可用", "pws.statsGeminiAvailable": "Gemini 可用", "pws.statsAvailable": "可用", + "genericPool.title": "账号池", + "genericPool.enabledDesc": "新会话优先选择剩余配额低于 {threshold}% 的账号。遇到 429 仍会在已登录账号之间故障转移。", + "genericPool.enabledNoProactiveDesc": "阈值为 0 时关闭按用量的主动切换。遇到 429 仍会在已登录账号之间故障转移。", + "genericPool.disabledDesc": "在失败之前会保持当前账号。遇到 429 仍会在已登录账号之间故障转移,且无法关闭。", + "genericPool.notice": "此开关只控制发送前的主动选择。轮询和填满优先会保存在这里,并在共享池内核开启后生效。", + "genericPool.needTwoAccounts": "启用主动选择前请至少添加两个 OAuth 账号。", + "genericPool.threshold": "主动用量阈值", + "genericPool.thresholdAria": "主动用量阈值,百分比", + "genericPool.thresholdHelp": "0 会关闭主动选择,并在遇到 429 之前保持当前账号。默认 80。仅在存在配额数据时生效。", + "genericPool.loadFailed": "无法加载账号池设置。", + "genericPool.saveFailed": "无法保存账号池设置。", }; diff --git a/gui/tests/generic-oauth-pool-settings.test.tsx b/gui/tests/generic-oauth-pool-settings.test.tsx new file mode 100644 index 0000000000..900ea70d9d --- /dev/null +++ b/gui/tests/generic-oauth-pool-settings.test.tsx @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import AnthropicAccountPoolSettings from "../src/components/provider-workspace/AnthropicAccountPoolSettings"; +import { LanguageProvider } from "../src/i18n/provider"; + +let previousLanguage: unknown; + +const domGlobals = ["document", "window", "navigator", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousDomGlobals: Record<(typeof domGlobals)[number], unknown>; +let testWindow: Window; +let mountedRoots: Root[]; + +async function flush(): Promise { + await Promise.resolve(); + await new Promise((resolve) => testWindow.setTimeout(resolve, 0)); + await Promise.resolve(); +} + +function setupDom(): void { + previousDomGlobals = Object.fromEntries( + domGlobals.map((key) => [key, Reflect.get(globalThis, key)]), + ) as typeof previousDomGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + mountedRoots = []; +} + +async function teardownDom(): Promise { + for (const root of mountedRoots) { + await act(async () => { + root.unmount(); + }); + } + mountedRoots = []; + for (const key of domGlobals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousDomGlobals[key] }); + } + await testWindow.happyDOM?.close?.(); +} + +type PoolPayload = { + enabled: boolean; + autoSwitchThreshold: number; + strategy: string; + stickyLimit: number; + supported?: string[]; +}; + +function stubPool(initial: PoolPayload): { gets: string[]; puts: Record[] } { + const puts: Record[] = []; + const gets: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/api/pool/settings") && init?.method === "PUT") { + const body = init.body ? JSON.parse(String(init.body)) as Record : {}; + puts.push(body); + return new Response(JSON.stringify({ + ...body, + supported: ["enabled", "strategy", "stickyLimit", "autoSwitchThreshold"], + }), { status: 200 }); + } + if (url.includes("/api/pool/settings")) { + gets.push(url); + return new Response(JSON.stringify({ + ...initial, + supported: initial.supported ?? ["enabled", "strategy", "stickyLimit", "autoSwitchThreshold"], + }), { status: 200 }); + } + throw new Error(`unexpected fetch: ${url} ${init?.method ?? "GET"}`); + }) as typeof fetch; + return { gets, puts }; +} + +async function mountPool(accountCount = 2): Promise { + const host = testWindow.document.createElement("div"); + testWindow.document.body.appendChild(host as never); + const { createRoot } = await import("react-dom/client"); + await act(async () => { + const root = createRoot(host); + mountedRoots.push(root); + root.render( + + + , + ); + }); + await act(async () => { await flush(); }); + return host as unknown as HTMLElement; +} + +beforeEach(() => { + previousLanguage = (globalThis.navigator as { language?: unknown } | undefined)?.language; + Object.defineProperty(globalThis.navigator, "language", { + configurable: true, + value: "en-US", + }); + setupDom(); +}); + +afterEach(async () => { + await teardownDom(); + Object.defineProperty(globalThis.navigator, "language", { + configurable: true, + value: previousLanguage, + }); +}); + +describe("generic OAuth account pool settings", () => { + test("loads google-antigravity through the unified pool settings contract", async () => { + const { gets } = stubPool({ + enabled: true, + autoSwitchThreshold: 80, + strategy: "quota", + stickyLimit: 1, + }); + const host = await mountPool(); + + expect(gets.some((url) => url.includes("provider=google-antigravity"))).toBe(true); + expect(host.textContent).toContain("Account pool"); + expect(host.textContent).toContain("shared pool kernel"); + expect(host.querySelector("#google-antigravity-pool-quota-window")).toBeNull(); + expect(host.textContent).not.toContain("Quota window"); + }); + + test("omits quotaWindow from generic saves", async () => { + const { puts } = stubPool({ + enabled: true, + autoSwitchThreshold: 80, + strategy: "quota", + stickyLimit: 1, + }); + const host = await mountPool(); + const toggle = host.querySelector("button.toggle"); + if (!toggle) throw new Error("toggle missing"); + + await act(async () => { + toggle.click(); + await flush(); + }); + + expect(puts).toHaveLength(1); + expect(puts[0]).toEqual({ + provider: "google-antigravity", + enabled: false, + autoSwitchThreshold: 80, + strategy: "quota", + stickyLimit: 1, + }); + expect(puts[0]).not.toHaveProperty("quotaWindow"); + }); + + test("keeps the proactive toggle off until two accounts exist", async () => { + stubPool({ + enabled: false, + autoSwitchThreshold: 80, + strategy: "quota", + stickyLimit: 1, + }); + const host = await mountPool(1); + const toggle = host.querySelector("button.toggle"); + if (!toggle) throw new Error("toggle missing"); + expect(toggle.disabled).toBe(true); + expect(host.textContent).toContain("at least two OAuth accounts"); + }); +});