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