Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)。
Expand Down
20 changes: 15 additions & 5 deletions src/auth/account-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
63 changes: 63 additions & 0 deletions tests/unit/auth/account-pool-has-available.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof getConfig>);

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);
});
});
Loading