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
2 changes: 1 addition & 1 deletion desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
14 changes: 10 additions & 4 deletions src/oauth/anthropic-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -778,22 +778,28 @@ 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) => {
if (name.startsWith("anthropic-ratelimit-unified-")) unified.push(`${name.slice(28)}=${value}`);
});
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);
Expand Down
12 changes: 7 additions & 5 deletions tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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);
Expand Down
Loading