Skip to content
Draft
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.13"
version = "2.65.0-vadymhimself.14"
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.13",
"version": "2.65.0-vadymhimself.14",
"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.13",
"version": "2.65.0-vadymhimself.14",
"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
35 changes: 28 additions & 7 deletions src/oauth/anthropic-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@
* Intentionally narrower than the Codex pool: no mid-session quota rotation,
* soft-avoid ladders, or probe leases. Anthropic OAuth is ToS-sensitive.
*
* Affinity is process-local (lost on restart). Cooldown uses Retry-After when present, else
* the reset time of whichever rate-limit window upstream reports as rejected, else a default
* backoff. 401/403 credential failures should set needsReauth on the store (existing OAuth
* Affinity is cached per process; under round-robin an unbound keyed session takes the
* rendezvous-hash winner of its key over the eligible accounts, so a fresh process lands it on the
* same account; a session moved off a cooled seat stays bound to its runner-up in that process.
* Cooldown uses Retry-After when present, else the reset time of whichever
* rate-limit window upstream reports as rejected, else a default backoff. 401/403 credential failures should set needsReauth on the store (existing OAuth
* path) so the account is excluded from eligibility.
*/
import { createHash } from "node:crypto";
Expand Down Expand Up @@ -449,6 +451,16 @@ function pickNextFillFirstAnthropicAccount(
return fallback ?? ordered[0] ?? null;
}

/** Rendezvous hash: highest sha256(key \0 account) wins, so every process maps a session alike. */
function sessionHashAccount(key: string, eligible: string[]): string | null {
let best: string | null = null, top = "";
for (const id of key ? eligible : []) {
const h = createHash("sha256").update(`${key}\0${id}`).digest("hex");
if (h > top) { top = h; best = id; }
}
return best;
}

function pickAlternateAnthropicAccount(
config: OcxConfig,
excludeId: string,
Expand Down Expand Up @@ -478,6 +490,7 @@ function pruneExpiredAffinity(now: number): void {
export type AnthropicAccountSelectionReason =
| "pool-disabled"
| "affinity"
| "hash"
| "active"
| "lowest-usage"
| "only-eligible"
Expand Down Expand Up @@ -616,6 +629,9 @@ export function resolveAnthropicAccountForSession(
}

const strategy = anthropicPoolStrategy(config);
// Round-robin's pick is arbitrary, so a keyed session takes its hash seat instead: same in every process.
const hashed = strategy === "round-robin" ? sessionHashAccount(key, getEligibleAnthropicAccounts(now)) : null;
if (hashed) return { accountId: hashed, reason: "hash" };
// No session identity (Desktop turns without a sticky key): hold the current
// active under RR/fill-first instead of treating every turn as a new session.
// Round-robin only when there is a real new-session key (or active is unusable).
Expand Down Expand Up @@ -700,7 +716,7 @@ export function clearAnthropicSessionAffinityForAccount(accountId: string): void
}

/**
* Record a 429 for `failedAccountId`, cool it, clear its affinity, and pick a failover
* Record a 429 for `failedAccountId`, cool it, unbind the hitting session, and pick a failover
* account. Does NOT promote the store active account — caller should promote only after a
* successful retry (or token resolve).
*/
Expand Down Expand Up @@ -734,7 +750,9 @@ export function rotateAnthropicAccountOn429(
: resetDerived !== undefined ? "reset-derived" : "default",
});
sweepExpiredOnWrite(now);
clearAnthropicSessionAffinityForAccount(failedAccountId);
// 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);
notePoolRotationFailure(POOL_KEY_ANTHROPIC, failedAccountId);
// A rotation means the roster in use just changed; do not answer the next activation question
// from a count read taken before the failure.
Expand All @@ -744,8 +762,11 @@ export function rotateAnthropicAccountOn429(
// presence-only recovery must not silently reactivate round-robin/fill-first merely
// because those dormant values remain in config. The quota picker is the neutral
// recovery policy already used by the default strategy.
const hashRunnerUp = isAnthropicAccountPoolEnabled(config) && anthropicPoolStrategy(config) === "round-robin"
? sessionHashAccount(key, getEligibleAnthropicAccounts(now).filter(id => id !== failedAccountId))
: null;
const next = isAnthropicAccountPoolEnabled(config)
? pickAlternateAnthropicAccount(config, failedAccountId, now)
? hashRunnerUp ?? pickAlternateAnthropicAccount(config, failedAccountId, now)
: pickLowestUsage(config, failedAccountId, now);
if (!next) {
console.warn("[anthropic-pool] all eligible Anthropic OAuth accounts are in cooldown; returning 429");
Expand Down Expand Up @@ -792,7 +813,7 @@ export function commitAnthropicSelectionRouting(
const current = captureOAuthAccountSelection(PROVIDER);
if (current?.accountId !== committed.accountId || current.revision !== committed.revision) return false;
if (isAnthropicAccountPoolEnabled(options.config)) {
if (anthropicPoolStrategy(options.config) === "round-robin" && options.reason !== "affinity") {
if (anthropicPoolStrategy(options.config) === "round-robin" && options.reason !== "affinity" && options.reason !== "hash") {
const limit = stickyLimitForPool(options.config);
const picked = pickRoundRobinAccount(POOL_KEY_ANTHROPIC, getEligibleAnthropicAccounts(), limit);
if (picked !== accountId) seedPoolRotationAccount(POOL_KEY_ANTHROPIC, accountId);
Expand Down
79 changes: 23 additions & 56 deletions tests/adapters/anthropic/anthropic-account-pool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,19 +368,30 @@ describe("anthropic account pool", () => {
expect(label).not.toContain("deadbeef");
});

test("round-robin strategy rotates unbound new sessions", async () => {
const { aId, bId, cId } = await seedThreeAccounts();
setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 10 });
setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 10 });
setCachedProviderAccountQuotaForTests("anthropic", cId, { fiveHourPercent: 10 });
test("round-robin keyed sessions take a stable hash seat; a 429 unbinds only the hitting session", async () => {
await seedThreeAccounts();
const config = cfg(true, 80, { strategy: "round-robin" });
const seats = () => Array.from({ length: 60 }, (_, i) => resolveAnthropicAccountForSession(`s${i}`, config).accountId);

const picks = [
(await admitAnthropic("sess-1", config)).accountId,
(await admitAnthropic("sess-2", config)).accountId,
(await admitAnthropic("sess-3", config)).accountId,
];
expect(new Set(picks).size).toBe(3);
const first = seats();
expect(new Set(first).size).toBe(3);
// A fresh process: empty affinity, cooldowns and round-robin ring (its stored selection admitted first).
clearAnthropicAccountPoolState();
clearPoolRotationState();
await admitAnthropic("", cfg(false));
expect(seats()).toEqual(first);

const seat = first[0]!;
const bystander = `s${first.indexOf(seat, 1)}`;
bindAnthropicSessionAffinity("s0", seat);
bindAnthropicSessionAffinity(bystander, seat);
const next = rotateAnthropicAccountOn429(config, seat, "60", "s0");
expect(anthropicSessionAffinitySizeForTests()).toBe(1);
expect(next).toBeTruthy();
expect(next).not.toBe(seat);
// The cooled hash seat fails over to the session's runner-up, and the bystander moves off it too.
expect(resolveAnthropicAccountForSession("s0", config)).toEqual({ accountId: next, reason: "hash" });
expect(resolveAnthropicAccountForSession(bystander, config).accountId).not.toBe(seat);
});

test("null/empty session key holds active under RR instead of rotating every turn", async () => {
Expand Down Expand Up @@ -449,50 +460,6 @@ describe("anthropic account pool", () => {
expect(resolveAnthropicAccountForSession(null, config).reason).toBe("active");
});

test("stickyLimit holds across successive unbound resolves", async () => {
const { aId, bId, cId } = await seedThreeAccounts();
setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 10 });
setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 10 });
setCachedProviderAccountQuotaForTests("anthropic", cId, { fiveHourPercent: 10 });
const config = cfg(true, 80, { strategy: "round-robin", stickyLimit: 3 });

const first = (await admitAnthropic("s1", config)).accountId;
expect(first).toBeTruthy();
expect((await admitAnthropic("s2", config)).accountId).toBe(first);
expect((await admitAnthropic("s3", config)).accountId).toBe(first);
const fourth = (await admitAnthropic("s4", config)).accountId;
expect(fourth).not.toBe(first);
});

test("429 / notePoolRotationFailure advances past sticky while account stays eligible", async () => {
const { aId, bId, cId } = await seedThreeAccounts();
setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 10 });
setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 10 });
setCachedProviderAccountQuotaForTests("anthropic", cId, { fiveHourPercent: 10 });
const config = cfg(true, 80, { strategy: "round-robin", stickyLimit: 10 });

const sticky = (await admitAnthropic("sticky-1", config)).accountId!;
expect(resolveAnthropicAccountForSession("sticky-2", config).accountId).toBe(sticky);

notePoolRotationFailure(POOL_KEY_ANTHROPIC, sticky);
const afterClear = (await admitAnthropic("sticky-3", config)).accountId;
expect(afterClear).toBeTruthy();
expect(afterClear).not.toBe(sticky);

// Re-establish sticky, then 429-cool the sticky account — failover + ring must leave it.
clearPoolRotationState();
const again = (await admitAnthropic("again-1", config)).accountId!;
expect(resolveAnthropicAccountForSession("again-2", config).accountId).toBe(again);
const failover = rotateAnthropicAccountOn429(config, again, "30");
expect(failover).toBeTruthy();
expect(failover).not.toBe(again);
// After cooldown the failed account is unusable; null key holds active only when eligible.
await setActiveAccount("anthropic", failover!);
const unboundAfter429 = resolveAnthropicAccountForSession("again-3", config).accountId;
expect(unboundAfter429).not.toBe(again);
expect([bId, cId, aId].filter(id => id !== again)).toContain(unboundAfter429);
});

test("fill-first skips drained successors when advancing past threshold", async () => {
const { aId, bId, cId } = await seedThreeAccounts();
const ordered = [aId, bId, cId].sort((a, b) => a.localeCompare(b));
Expand Down Expand Up @@ -532,7 +499,7 @@ describe("anthropic account pool", () => {

const before = getAccountSet("anthropic")!.activeAccountId;
const picks = Array.from({ length: 3 }, (_, i) => resolveAnthropicAccountForSession(`promo-${i}`, config));
expect(new Set(picks.map(p => p.accountId)).size).toBe(1);
expect(picks.every(p => p.accountId)).toBe(true);
expect(getAccountSet("anthropic")!.activeAccountId).toBe(before);
});

Expand Down
Loading