From df4a9cb1fac7b2ee67d81970f6ff4ef69c7461f4 Mon Sep 17 00:00:00 2001 From: chilung Date: Wed, 28 Oct 2026 11:50:02 +0900 Subject: [PATCH 1/2] fix(gui): avoid quota refresh failures from paused accounts Rebased onto current dev as one commit (#7628); the branch predated the 2.94.0 version line, which failed the release version gate. --- .../src/content/docs/guides/web-dashboard.md | 2 +- .../docs/zh-tw/guides/web-dashboard.md | 2 +- gui/src/hooks/useProviderAccountPools.ts | 22 ++- .../provider-account-quota-loading.test.tsx | 149 ++++++++++++++++++ 4 files changed, 168 insertions(+), 7 deletions(-) diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index c19a1c825b4..4b44aa1a406 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -120,7 +120,7 @@ is visible) and never forces an upstream refresh. | **Startup safety** | Show whether injected Codex routing survives a restart, with separate service and launcher-shim health plus exact repair commands. | | **Windows tray** | Install a per-user login tray for one-click proxy start, stop, restart, dashboard access, and status. The tray is a controller, not a proxy restart service. | | **Codex autostart** | Allow an already-installed Codex launcher shim to run `ocx ensure`. This toggle does not install a shim or background service. | -| **Providers** | Add, edit, set the default (enabled providers only), enable/disable, and remove providers; manage OAuth account pools and API-key pools where supported. Removing the current default switches to the first remaining enabled provider when one exists; otherwise deletion is refused and the current default is kept. Provider Settings can disable live model discovery for endpoints with missing, slow, or oversized `/models` catalogs. For Claude (Anthropic) OAuth pools, each logged-in account shows its own 5-hour and weekly rate-limit bars (usage is per credential); a failed probe keeps the last-known bars and marks them unavailable until the next successful refresh. The Provider Overview shown when no provider is selected carries a **Refresh all quotas** control that forces one server-side re-read of every configured provider; a provider whose upstream probe fails keeps its last-good row, so the status line reports that the check completed rather than claiming every value is fresh, and each row's own age stays the per-provider freshness signal. | +| **Providers** | Add, edit, set the default (enabled providers only), enable/disable, and remove providers; manage OAuth account pools and API-key pools where supported. Removing the current default switches to the first remaining enabled provider when one exists; otherwise deletion is refused and the current default is kept. Provider Settings can disable live model discovery for endpoints with missing, slow, or oversized `/models` catalogs. For Claude (Anthropic) OAuth pools, each logged-in account shows its own 5-hour and weekly rate-limit bars (usage is per credential); a failed probe keeps the last-known bars and marks them unavailable until the next successful refresh. Refreshing quota skips paused OAuth accounts and disabled API keys; skipped rows without a cached reading remain unavailable but do not affect the refresh result. Refreshing reports failure when an unpaused OAuth account or enabled API key that supports quota reads has an unavailable reading. The Provider Overview shown when no provider is selected carries a **Refresh all quotas** control that forces one server-side re-read of every configured provider; a provider whose upstream probe fails keeps its last-good row, so the status line reports that the check completed rather than claiming every value is fresh, and each row's own age stays the per-provider freshness signal. | | **Add provider** | One search above the tabs reaches all four at once — Accounts, Free, Local, Paid. While a query is live the results are grouped by tab with a count each, and the selected tab stays put rather than jumping. Press ArrowDown in the search box to focus the first available result action, skipping disabled buttons; if no action is available, focus stays in the search box. Local runtimes (Ollama, vLLM, LM Studio, LiteLLM) have their own tab, and a long provider note clamps to two lines with the full text one click away. Claude Code CLI (subscription) sits under Paid with a Subscription CLI badge, and selecting it warns that it bills the signed-in Claude subscription through `claude -p` and never uses an API key (use `anthropic-apikey` for key billing). | | **Codex Auth** | Add ChatGPT/Codex pool accounts, select the next-session account, refresh 5h / weekly / 30d quotas, enable or disable quota auto-switch, set its 1–100% threshold, and configure transient-failure failover. | | **Subagents** | Feature up to five bare native or namespaced routed models in the `spawn_agent` override list. | diff --git a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md index cd1df472768..7042604ce7a 100644 --- a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md @@ -60,7 +60,7 @@ GUI session 簽發到服務的頁面中,並在到期或代理重啟時靜默 | **啟動安全** | 顯示注入的 Codex 路由能否在重啟後繼續工作,並分別顯示服務、launcher shim 狀態和準確的修復命令。 | | **Windows 托盤** | 安裝使用者登入托盤,一鍵控制代理啟動、停止、重啟、面板和狀態。托盤不是代理重啟服務。 | | **Codex 自動啟動** | 允許已安裝的 Codex launcher shim 執行 `ocx ensure`。此開關不會安裝 shim 或後臺服務。 | -| **Providers** | 新增、編輯、啟用/停用、刪除 provider,並在支援時管理 OAuth 帳號池和 API key 池。 | +| **Providers** | 新增、編輯、啟用/停用、刪除 provider,並在支援時管理 OAuth 帳號池和 API key 池。額度重新整理會略過暫停的 OAuth 帳號與停用的 API key;這些被略過的列即使沒有快取讀值,仍會顯示不可用,但不影響重新整理結果。當未暫停的 OAuth 帳號或已啟用且支援額度讀取的 API key 讀值不可用時,重新整理會回報失敗。 | | **Add provider** | 分頁上方的單一搜尋框可同時搜尋帳號、免費、本機、付費四個分頁。搜尋時選取的分頁不會跳轉,結果依分頁分組並顯示數量。本機執行環境(Ollama、vLLM、LM Studio、LiteLLM)有專屬分頁,過長的說明會截斷為兩行,點擊即可查看全文。 | | **Codex Auth** | 新增 ChatGPT/Codex 池帳號,選擇下一 session 的帳號,重新整理 5h / 每週 / 30d 配額,啟用或停用配額自動切換,設定其 1–100% 閾值和臨時故障 failover。 | | **Subagents** | 在 `spawn_agent` override 列表中置頂最多五個原生或路由模型。 | diff --git a/gui/src/hooks/useProviderAccountPools.ts b/gui/src/hooks/useProviderAccountPools.ts index 81011166529..a531542af4e 100644 --- a/gui/src/hooks/useProviderAccountPools.ts +++ b/gui/src/hooks/useProviderAccountPools.ts @@ -251,11 +251,21 @@ export function useProviderAccountPools(deps: { accounts: mergeQuotaRows(enriched, current[provider]?.accounts ?? [], true), }, }); - return !enriched.some(row => row.quotaUnavailable === true); + // Paused accounts are intentionally skipped; keep their row state without + // failing refreshes that successfully probed another quota-capable account. + const refreshable = enriched.filter(row => row.paused !== true && supportsQuotaRead(row)); + return !refreshable.some(row => row.quotaUnavailable === true); } catch { if (!currentQuota()) return false; setAccountSets(current => currentQuota() && current[provider] ? { - ...current, [provider]: { ...current[provider], accounts: unavailableQuotaRows(current[provider].accounts, rows) }, + ...current, + [provider]: { + ...current[provider], + accounts: unavailableQuotaRows( + current[provider].accounts, + rows.filter(row => row.paused !== true), + ), + }, } : current); return false; } @@ -311,11 +321,13 @@ export function useProviderAccountPools(deps: { setKeyPools(current => currentQuota() ? { ...current, [name]: currentRoster() ? mergeQuotaRows(enriched, current[name] ?? [], true) : mergeLateQuotaRows(current[name] ?? [], enriched) } : current); - return !enriched.some(row => row.quotaUnavailable === true); + const refreshable = enriched.filter(row => row.disabled !== true && supportsQuotaRead(row)); + return !refreshable.some(row => row.quotaUnavailable === true); } catch { + const attempted = rows.filter(row => row.disabled !== true && supportsQuotaRead(row)); if (currentQuota()) setKeyPools(current => currentQuota() - ? { ...current, [name]: unavailableQuotaRows(current[name] ?? [], rows) } : current); - return false; + ? { ...current, [name]: unavailableQuotaRows(current[name] ?? [], attempted) } : current); + return currentQuota() && attempted.length === 0; } }; if (refresh) return await enrich(); diff --git a/gui/tests/provider-account-quota-loading.test.tsx b/gui/tests/provider-account-quota-loading.test.tsx index d13fc9040a2..e173d1f1514 100644 --- a/gui/tests/provider-account-quota-loading.test.tsx +++ b/gui/tests/provider-account-quota-loading.test.tsx @@ -446,6 +446,155 @@ test("quota diagnostics normalize and clear on recovery or management failure", expect(pools.accountSets.oauth.accounts[0].quotaFailure).toBeUndefined(); }); +test("a paused account without cached quota does not fail a successful account refresh", async () => { + const accounts = [ + { id: "active", active: true, paused: false, quotaMode: "probe", quota: reading, quotaUnavailable: false }, + { id: "paused", active: false, paused: true, quotaMode: "probe", quota: null, quotaUnavailable: true }, + ]; + respond = async url => Response.json({ + activeAccountId: "active", + accounts: url.includes("quota=1") ? accounts : accounts.map(({ id, active, paused, quotaMode }) => ({ id, active, paused, quotaMode })), + }); + await act(async () => { + expect(await pools.fetchAccountSets(["google-antigravity"], true)).toBe(true); + }); + expect(pools.accountSets["google-antigravity"].accounts.find(row => row.id === "paused")) + .toMatchObject({ paused: true, quota: null, quotaUnavailable: true }); +}); + +test("a refresh with only paused accounts does not report a quota failure", async () => { + const accounts = [{ id: "paused", active: true, paused: true, quotaMode: "probe", quota: null, quotaUnavailable: true }]; + respond = async url => Response.json({ + activeAccountId: "paused", + accounts: url.includes("quota=1") ? accounts : accounts.map(({ id, active, paused, quotaMode }) => ({ id, active, paused, quotaMode })), + }); + await act(async () => { + expect(await pools.fetchAccountSets(["google-antigravity"], true)).toBe(true); + }); +}); + +test("an unavailable unpaused account still fails beside a skipped paused account", async () => { + const accounts = [ + { id: "active", active: true, paused: false, quotaMode: "probe", quota: reading, quotaUnavailable: true }, + { id: "paused", active: false, paused: true, quotaMode: "probe", quota: null, quotaUnavailable: true }, + ]; + respond = async url => Response.json({ + activeAccountId: "active", + accounts: url.includes("quota=1") ? accounts : accounts.map(({ id, active, paused, quotaMode }) => ({ id, active, paused, quotaMode })), + }); + await act(async () => { + expect(await pools.fetchAccountSets(["google-antigravity"], true)).toBe(false); + }); +}); + +for (const scenario of [ + { name: "a paused key is unavailable", enabledUnavailable: false, unsupportedUnavailable: false, allPaused: false, expected: true }, + { name: "an enabled key is unavailable", enabledUnavailable: true, unsupportedUnavailable: false, allPaused: false, expected: false }, + { name: "an unsupported key has unavailable quota", enabledUnavailable: false, unsupportedUnavailable: true, allPaused: false, expected: true }, + { name: "every key is paused", enabledUnavailable: false, unsupportedUnavailable: false, allPaused: true, expected: true }, +]) { + test(`API-key quota refresh result is scoped correctly when ${scenario.name}`, async () => { + const keys = scenario.allPaused + ? [{ id: "paused", masked: "paused", active: false, disabled: true, quotaMode: "probe", quota: null, quotaUnavailable: true }] + : [ + { id: "enabled", masked: "enabled", active: true, disabled: false, quotaMode: "probe", quota: scenario.enabledUnavailable ? null : reading, quotaUnavailable: scenario.enabledUnavailable }, + { id: "paused", masked: "paused", active: false, disabled: true, quotaMode: "probe", quota: null, quotaUnavailable: true }, + ...(scenario.unsupportedUnavailable ? [{ id: "unsupported", masked: "unsupported", active: false, disabled: false, quotaMode: "unsupported", quotaUnavailable: true }] : []), + ]; + respond = async url => Response.json({ + activeId: scenario.allPaused ? null : "enabled", + keys: url.includes("quota=1") ? keys : keys.map(({ id, active, disabled, quotaMode }) => ({ id, active, disabled, quotaMode })), + }); + await act(async () => { + expect(await pools.fetchKeyPools(["fixture"], true)).toBe(scenario.expected); + }); + expect(pools.keyPools.fixture.find(row => row.id === "paused")) + .toMatchObject({ disabled: true, quota: null, quotaUnavailable: true }); + }); +} + +test.each([ + { desc: "returns 503", error: () => new Response(null, { status: 503 }) }, + { desc: "throws", error: () => { throw new Error("quota network error"); } }, +])("OAuth quota-request rejection ($desc) marks unpaused account unavailable without touching paused account", async ({ error }) => { + const accounts = [ + { id: "active", active: true, paused: false, quotaMode: "probe" as const, quota: reading }, + { id: "paused", active: false, paused: true, quotaMode: "probe" as const, quota: reading }, + ]; + respond = async url => { + if (url.includes("quota=1")) return error(); + return Response.json({ activeAccountId: "active", accounts }); + }; + await act(async () => { + expect(await pools.fetchAccountSets(["google-antigravity"], true)).toBe(false); + }); + const current = pools.accountSets["google-antigravity"].accounts; + expect(current.find(row => row.id === "active")).toMatchObject({ quota: reading, quotaUnavailable: true, quotaPending: false }); + expect(current.find(row => row.id === "paused")).toMatchObject({ quota: reading, quotaUnavailable: false }); +}); + +test.each([ + { desc: "returns 503", error: () => new Response(null, { status: 503 }) }, + { desc: "throws", error: () => { throw new Error("quota network error"); } }, +])("OAuth quota-request rejection ($desc) when all accounts are paused returns false without marking paused accounts unavailable", async ({ error }) => { + const accounts = [ + { id: "paused-a", active: false, paused: true, quotaMode: "probe" as const, quota: reading }, + { id: "paused-b", active: false, paused: true, quotaMode: "probe" as const }, + ]; + respond = async url => { + if (url.includes("quota=1")) return error(); + return Response.json({ activeAccountId: null, accounts }); + }; + await act(async () => { + expect(await pools.fetchAccountSets(["google-antigravity"], true)).toBe(false); + }); + const current = pools.accountSets["google-antigravity"].accounts; + expect(current.find(row => row.id === "paused-a")).toMatchObject({ quota: reading, quotaUnavailable: false }); + expect(current.find(row => row.id === "paused-b")?.quotaUnavailable).toBe(false); +}); + +test.each([ + { desc: "returns 503", error: () => new Response(null, { status: 503 }) }, + { desc: "throws", error: () => { throw new Error("key quota network error"); } }, +])("API-key quota-request rejection ($desc) marks enabled key unavailable without touching disabled key", async ({ error }) => { + const keys = [ + { id: "enabled", masked: "enabled", active: true, disabled: false, quotaMode: "probe" as const, quota: reading }, + { id: "disabled", masked: "disabled", active: false, disabled: true, quotaMode: "probe" as const, quota: reading }, + ]; + respond = async url => { + if (url.includes("quota=1")) return error(); + return Response.json({ activeId: "enabled", keys }); + }; + await act(async () => { + expect(await pools.fetchKeyPools(["fixture"], true)).toBe(false); + }); + const current = pools.keyPools.fixture; + expect(current.find(row => row.id === "enabled")).toMatchObject({ quota: reading, quotaUnavailable: true, quotaPending: false }); + expect(current.find(row => row.id === "disabled")).toMatchObject({ quota: reading, quotaUnavailable: false }); +}); + +test.each([ + { desc: "returns 503", error: () => new Response(null, { status: 503 }) }, + { desc: "throws", error: () => { throw new Error("key quota network error"); } }, +])("API-key quota-request rejection ($desc) when all probe keys are disabled returns true without marking keys unavailable", async ({ error }) => { + const keys = [ + { id: "disabled-a", masked: "d-a", active: false, disabled: true, quotaMode: "probe" as const, quota: reading }, + { id: "disabled-b", masked: "d-b", active: false, disabled: true, quotaMode: "probe" as const }, + { id: "unsupported", masked: "u", active: true, disabled: false, quotaMode: "unsupported" as const }, + ]; + respond = async url => { + if (url.includes("quota=1")) return error(); + return Response.json({ activeId: "unsupported", keys }); + }; + await act(async () => { + expect(await pools.fetchKeyPools(["fixture"], true)).toBe(true); + }); + const current = pools.keyPools.fixture; + expect(current.find(row => row.id === "disabled-a")?.quotaUnavailable).toBe(false); + expect(current.find(row => row.id === "disabled-b")?.quotaUnavailable).toBe(false); + expect(current.find(row => row.id === "unsupported")?.quotaUnavailable).toBe(false); +}); + test("initial roster failure clears a settled diagnosis while retaining bars", async () => { await act(async () => { pools.setAccountSets({ fixture: { activeAccountId: "a", accounts: [ From d347d3fc8c7440e596adab248361cdcf427ca49f Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:16:22 +0900 Subject: [PATCH 2/2] fix(gui): satisfy React Doctor in provider quota refresh --- gui/src/hooks/useProviderAccountPools.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/gui/src/hooks/useProviderAccountPools.ts b/gui/src/hooks/useProviderAccountPools.ts index a531542af4e..822e4a9db00 100644 --- a/gui/src/hooks/useProviderAccountPools.ts +++ b/gui/src/hooks/useProviderAccountPools.ts @@ -324,10 +324,9 @@ export function useProviderAccountPools(deps: { const refreshable = enriched.filter(row => row.disabled !== true && supportsQuotaRead(row)); return !refreshable.some(row => row.quotaUnavailable === true); } catch { - const attempted = rows.filter(row => row.disabled !== true && supportsQuotaRead(row)); if (currentQuota()) setKeyPools(current => currentQuota() - ? { ...current, [name]: unavailableQuotaRows(current[name] ?? [], attempted) } : current); - return currentQuota() && attempted.length === 0; + ? { ...current, [name]: unavailableQuotaRows(current[name] ?? [], rows.filter(row => row.disabled !== true && supportsQuotaRead(row))) } : current); + return currentQuota() && !rows.some(row => row.disabled !== true && supportsQuotaRead(row)); } }; if (refresh) return await enrich();