diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cca4ec6..6bbae11f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,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`) - Claude Code 2.1.84 计费头 strip 行为新增 rotation 变体回归覆盖:实测 Claude Code 把 `x-anthropic-billing-header: cc_version=...; cc_entrypoint=cli; cch=<5hex>;` 作为 `system[0]` 独立块下发,`cc_version` 后缀和 `cch` 每请求 reroll;新增 `it.each` 覆盖 5 个真实 `cc_version` 后缀(c8e / 76b / f51 / 5b4 / 4f3)与"两次不同 cch 产出同一份 `instructions`"的不变性断言,防止后续改 strip(如改 `startsWith` → 正则、或加 inline 清洗)意外让 `cch` 漏进 `instructions` 污染上游 prompt cache(`tests/unit/translation/anthropic-to-codex.test.ts`) - Dashboard Errors tab now has a real clear action: `DELETE /admin/error-logs` removes current + backup JSONL files and the read cursor so repeated `StreamUpstreamPrematureClose` groups can be cleared from the page instead of only marked read. Anthropic setup defaults now map Opus 4.7 → `gpt-5.5` and Sonnet 4.6 → `gpt-5.4`, and the built-in Anthropic API-key catalog lists Claude Opus 4.7 (`src/logs/error-log.ts`, `src/routes/admin/error-logs.ts`, `shared/hooks/use-error-logs.ts`, `web/src/pages/ErrorsPage.tsx`, `web/src/components/AnthropicSetup.tsx`, `src/auth/api-key-catalog.ts`). - Claude Code 的 `Read` 工具参数里如果 `pages` 传成空字符串或空白字符串,会在 Codex → Anthropic 转换时被自动剔除,避免 GPT-5.5 反复触发 `Read tool validation error: Invalid pages parameter: ""` 并重试隔离工作树;对应单测覆盖流式与非流式两条路径,以及非空 PDF 页码范围保留(`src/translation/codex-to-anthropic.ts`、`tests/unit/translation/codex-to-anthropic-read-pages.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); + }); +});