From 89899345881c30289df40c454c934cb4bd711998 Mon Sep 17 00:00:00 2001 From: icebear0828 Date: Sat, 16 May 2026 13:31:19 -0700 Subject: [PATCH] fix(auth): honor quota.skip_exhausted in isAuthenticated isAuthenticated() previously short-circuited any account whose cachedQuota was reported exhausted, regardless of the quota.skip_exhausted config. That diverges from hasAvailableAccounts and AccountLifecycle.acquire(), which both still allow acquiring limit-reached accounts when skip_exhausted=false (the documented opt-in for letting requests retry against still-limit-reached accounts on the chance the upstream window has actually rolled). The mismatch causes any pool where every account is at the cached limit to fail isAuthenticated() while routing would still produce a viable account, so every router that calls accountPool.isAuthenticated() (`/v1/chat/completions`, `/v1/messages`, `/v1/responses`, model listing, health) returns 401 even though acquire() would succeed. Gate the cachedQuota check on the same skipExhausted flag. Use !== false so a missing quota config block (tests with minimal getConfig mocks, hand-rolled configs) treats the default as true, matching the schema default. Tests cover empty pool, default-skip non-exhausted, default-skip exhausted (still 401), explicit skip_exhausted=false exhausted (now authenticated), and disabled accounts (still 401). --- CHANGELOG.md | 1 + src/auth/account-registry.ts | 20 ++++-- .../auth/account-pool-has-available.test.ts | 63 +++++++++++++++++++ 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26130d94..be4de9b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ ### Fixed +- `AccountRegistry.isAuthenticated()` 现在尊重 `quota.skip_exhausted` 配置:此前不论该开关如何配置,`isAuthenticated` 都会把 `cachedQuota` 已耗尽的活跃账号一律视作不可用,导致 `quota.skip_exhausted=false` 的部署在所有号都 `limit_reached=true` 但仍可被 `AccountLifecycle.acquire()` 取走的情况下,被 `accountPool.isAuthenticated()` 守门的路由(`/v1/chat/completions` / `/v1/messages` / `/v1/responses` / model 列表 / health)全部 401。现在 `isAuthenticated` 与 `hasAvailableAccounts` 用同一套规则:`skipExhausted ? !hasReachedCachedQuota(entry) : true`。配置默认值不变(默认仍跳过)。新增 5 个单测覆盖空池 / 默认 skip / 默认非 skip / `skip_exhausted=false` 配额耗尽路径 / disabled 账号 0 容忍(`src/auth/account-registry.ts`、`tests/unit/auth/account-pool-has-available.test.ts`) - `/v1/responses` passthrough streaming / non-streaming paths now collect `function_call.call_id` from `response.output_item.done` and forward it through response metadata so implicit resume can validate following `function_call_output` turns instead of falling back to full-history replay. Oversized missing-tool-call replays are guarded with 413, and regression coverage now proves the issue red/green across the Responses format adapter (`src/routes/responses.ts`, `src/routes/shared/proxy-handler.ts`, `tests/unit/routes/responses-passthrough-metadata.test.ts`, `tests/integration/proxy-handler.test.ts`). - Release bump workflows now require runtime file changes in addition to meaningful commit subjects before tagging a beta or stable build. This prevents squash-promotion history divergence from re-counting old dev commits, and prevents workflow/docs/test-only fixes from producing empty Electron releases (`.github/workflows/bump-electron.yml`, `.github/workflows/bump-electron-beta.yml`, `tests/unit/ci/package-boundary.test.ts`). - Release bump workflows now skip the release-notes workflow hotfix subject itself, so promoting the stable-notes CI fix to `master` does not create an empty desktop release on the next scheduled bump (`.github/workflows/bump-electron.yml`, `.github/workflows/bump-electron-beta.yml`, `tests/unit/ci/package-boundary.test.ts`). diff --git a/src/auth/account-registry.ts b/src/auth/account-registry.ts index 7f0c1f73..a4f904e7 100644 --- a/src/auth/account-registry.ts +++ b/src/auth/account-registry.ts @@ -293,13 +293,23 @@ export class AccountRegistry { isAuthenticated(): boolean { const now = new Date(); + // Mirror hasAvailableAccounts: skip_exhausted defaults to true per schema. + // Using !== false (vs === true) lets call sites with minimal config mocks + // observe the same default behavior as production. + const skipExhausted = getConfig().quota?.skip_exhausted !== false; for (const entry of this.accounts.values()) { this.refreshStatus(entry, now); - // "Authenticated" used to imply "has a usable account". After retiring - // status="rate_limited", we treat any cachedQuota-exhausted account as - // unusable too — otherwise an all-exhausted pool would falsely report - // authenticated and produce confusing 4xx on requests. - if (entry.status === "active" && !hasReachedCachedQuota(entry)) return true; + // "Authenticated" implies "has a usable account". Gate the cachedQuota + // check on quota.skip_exhausted to stay consistent with hasAvailableAccounts + // and AccountLifecycle.acquire(): when skip_exhausted=false the operator + // has opted into still acquiring quota-exhausted accounts, so they remain + // usable and we must report authenticated. + if ( + entry.status === "active" && + (!skipExhausted || !hasReachedCachedQuota(entry)) + ) { + return true; + } } return false; } diff --git a/tests/unit/auth/account-pool-has-available.test.ts b/tests/unit/auth/account-pool-has-available.test.ts index d0af29ad..bced521a 100644 --- a/tests/unit/auth/account-pool-has-available.test.ts +++ b/tests/unit/auth/account-pool-has-available.test.ts @@ -184,3 +184,66 @@ describe("AccountPool.hasAvailableAccounts", () => { expect(pool.hasAvailableAccounts()).toBe(false); }); }); + +describe("AccountPool.isAuthenticated", () => { + let pool: AccountPool; + + beforeEach(() => { + vi.mocked(isTokenExpired).mockReturnValue(false); + pool = new AccountPool({ rotationStrategy: "least_used" }); + }); + + it("returns false for empty pool", () => { + expect(pool.isAuthenticated()).toBe(false); + }); + + it("returns true when an active non-exhausted account exists", () => { + pool.addAccount("token-a"); + expect(pool.isAuthenticated()).toBe(true); + }); + + it("returns false when only quota-exhausted accounts exist and skip_exhausted=true (default)", () => { + const id = pool.addAccount("token-a"); + pool.updateCachedQuota(id, makeQuota({ + rate_limit: { + allowed: false, + limit_reached: true, + used_percent: 100, + reset_at: Math.floor(Date.now() / 1000) + 3600, + limit_window_seconds: 3600, + }, + })); + expect(pool.isAuthenticated()).toBe(false); + }); + + it("returns true when only quota-exhausted accounts exist and skip_exhausted=false (P1 fix)", async () => { + const { getConfig } = await import("@src/config.js"); + vi.mocked(getConfig).mockReturnValueOnce({ + auth: { + jwt_token: null, + rotation_strategy: "least_used", + rate_limit_backoff_seconds: 60, + max_concurrent_per_account: 3, + }, + quota: { skip_exhausted: false }, + } as ReturnType); + + const id = pool.addAccount("token-a"); + pool.updateCachedQuota(id, makeQuota({ + rate_limit: { + allowed: false, + limit_reached: true, + used_percent: 100, + reset_at: Math.floor(Date.now() / 1000) + 3600, + limit_window_seconds: 3600, + }, + })); + expect(pool.isAuthenticated()).toBe(true); + }); + + it("returns false when only disabled accounts exist, regardless of skip_exhausted", () => { + const id = pool.addAccount("token-a"); + pool.markStatus(id, "disabled"); + expect(pool.isAuthenticated()).toBe(false); + }); +});