From 4276eda8414556b8d47025fc2b49d6ac56d1f030 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Wed, 11 Nov 2026 09:27:47 +0800 Subject: [PATCH] fix(anthropic-pool): an allowed-status 429 fails the request over again, still cooling nothing (.20) #15 stopped cooling on a 429 whose unified status says "allowed". That was right, because such a 429 is a refusal of the request's shape and cooling on it benched every client on the account. But it also stopped the failover, so the 429 went back to the caller. A combo then moves to its next provider and model, which loses more cache than an account switch. 3f6b2f355 had fixed exactly that bounce. The allowed 429 now moves this request to another account and cools no account. Rejected and header-less 429s cool and fail over as before. Pinned requests still never switch. Known ceiling: when every account repeats the refusal, the request can alternate accounts up to ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST times before the 429 returns. Refused calls bill no tokens. Co-Authored-By: Claude Code --- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- package.json | 2 +- src/oauth/anthropic-routing.ts | 14 ++++++++++---- .../anthropic/anthropic-ratelimit-headers.test.ts | 12 +++++++----- 6 files changed, 21 insertions(+), 13 deletions(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index eb45b470877..adae31bc3b2 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2541,7 +2541,7 @@ dependencies = [ [[package]] name = "opencodex-desktop" -version = "2.65.0-vadymhimself.19" +version = "2.65.0-vadymhimself.20" dependencies = [ "dbus", "reqwest 0.12.24", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 299699ed3fa..504a80fa49a 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencodex-desktop" -version = "2.65.0-vadymhimself.19" +version = "2.65.0-vadymhimself.20" description = "OpenCodex desktop shell" authors = ["OpenCodex contributors"] license = "MIT" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index bfc1eff3f77..acf9de30119 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCodex", - "version": "2.65.0-vadymhimself.19", + "version": "2.65.0-vadymhimself.20", "identifier": "com.opencodex.desktop", "build": { "frontendDist": "../ui", diff --git a/package.json b/package.json index 11bce44e4c6..70606548ca2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.65.0-vadymhimself.19", + "version": "2.65.0-vadymhimself.20", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", diff --git a/src/oauth/anthropic-routing.ts b/src/oauth/anthropic-routing.ts index ddfbe85031b..f54ca6787d3 100644 --- a/src/oauth/anthropic-routing.ts +++ b/src/oauth/anthropic-routing.ts @@ -778,9 +778,13 @@ export function rotateAnthropicAccountOn429( // A 429 whose unified status says the account is still allowed is not a limit: upstream refused this // request's shape (e.g. a replay without the Claude Code identity). Cooling on it benched every client // on the account. A rejected window, or a 429 with no unified status at all, still cools as before. + // It still fails THIS request over: handed back, a combo moves to its next provider and model, which + // loses more cache than the account switch. ponytail: no per-request tried set, so a refusal every + // account repeats alternates accounts up to ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST times before the + // 429 returns (refused calls bill no tokens); pass the tried accounts in if that shows up in the logs. const status = header("status"); const limited = status === undefined || [status, header("5h-status"), header("7d-status")].includes("rejected"); - const failover = limited && (isAnthropicAccountPoolEnabled(config) || hasAnthropicFailoverQuorum(now)); + const failover = isAnthropicAccountPoolEnabled(config) || hasAnthropicFailoverQuorum(now); // Which limit refused is only in these headers: log every unified one on every 429 (no credential is among them). const unified: string[] = []; (rateLimitHeaders as Headers | null | undefined)?.forEach?.((value, name) => { @@ -788,12 +792,14 @@ export function rotateAnthropicAccountOn429( }); console.warn(`[anthropic-pool] 429 on ${formatAnthropicAccountOrdinal(failedAccountId)} model=${modelId ?? "-"}` + ` retry-after=${retryAfterHeader?.trim() || "-"} ${unified.join(" ") || "unified=-"}` - + (failover + + (failover && limited ? ` cooldown=${Math.ceil(cooldownMs / 1000)}s until ${new Date(now + cooldownMs).toISOString()} (${cooldownSource}) scope=${family ?? "account"}` : ` cooldown=none (${limited ? "no failover pool" : "request refused, account not limited"})`)); if (!failover) return null; - upstreamHealth.set(family ? familyKey(failedAccountId, family) : failedAccountId, { cooldownUntil: now + cooldownMs, cooldownSource }); - sweepExpiredOnWrite(now); + if (limited) { + upstreamHealth.set(family ? familyKey(failedAccountId, family) : failedAccountId, { cooldownUntil: now + cooldownMs, cooldownSource }); + sweepExpiredOnWrite(now); + } // Unbind only the session that hit the 429; others on the account move on their own next call. const key = normalizeAffinityComponent(sessionKey); sessionAffinity.delete(key); diff --git a/tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts b/tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts index f73c04dc7b9..a0f418f7f47 100644 --- a/tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts +++ b/tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts @@ -152,7 +152,7 @@ describe("a model-scoped weekly 429 cools only that model family", () => { }); describe("a request-shape 429 does not cool the account", () => { - test("an allowed unified status fails only the request, while a header-less 429 still fails over", async () => { + test("an allowed unified status fails the request over without cooling either account, while a header-less 429 still cools", async () => { const start = Date.now(); const ids = await seed(2); const allowed = new Headers({ @@ -162,10 +162,12 @@ describe("a request-shape 429 does not cool the account", () => { }); const warn = spyOn(console, "warn").mockImplementation(() => {}); try { - expect(rotateAnthropicAccountOn429(poolEnabled(), ids[0]!, "30", "s1", start, allowed, "claude-opus-5")).toBeNull(); - expect(rotateAnthropicAccountOn429(poolEnabled(), ids[1]!, null, "s2", start, allowed, "claude-sonnet-5-5")).toBeNull(); - expect(warn.mock.calls.map(c => String(c[0])).filter(l => l.startsWith("[anthropic-pool] 429 on ")) - .every(l => l.endsWith(" cooldown=none (request refused, account not limited)"))).toBe(true); + // Handed back, a combo would move to its next provider and model; the request moves account instead. + expect(rotateAnthropicAccountOn429(poolEnabled(), ids[0]!, "30", "s1", start, allowed, "claude-opus-5")).toBe(ids[1]!); + expect(rotateAnthropicAccountOn429(poolEnabled(), ids[1]!, null, "s2", start, allowed, "claude-sonnet-5-5")).toBe(ids[0]!); + const lines = warn.mock.calls.map(c => String(c[0])).filter(l => l.startsWith("[anthropic-pool] 429 on ")); + expect(lines.filter(l => l.includes(" model=")).map(l => l.endsWith(" cooldown=none (request refused, account not limited)"))).toEqual([true, true]); + expect(lines.filter(l => l.includes("; failing over to "))).toHaveLength(2); } finally { warn.mockRestore(); } expect(ids.map(id => getAnthropicAccountHealthSnapshot(id, start))).toEqual([null, null]); expect(getEligibleAnthropicAccounts(start, "opus")).toEqual(ids);