diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 89443b411a0..b740ac4b04d 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.13" +version = "2.65.0-vadymhimself.14" dependencies = [ "dbus", "reqwest 0.12.24", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 8025aa37b90..7dd83ef2149 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.13" +version = "2.65.0-vadymhimself.14" 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 f1aac86816d..ad4df303a82 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.13", + "version": "2.65.0-vadymhimself.14", "identifier": "com.opencodex.desktop", "build": { "frontendDist": "../ui", diff --git a/package.json b/package.json index ad42b6ba2b0..3cc26b9567c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/oauth/anthropic-routing.ts b/src/oauth/anthropic-routing.ts index 13c22b44747..47b8941d31f 100644 --- a/src/oauth/anthropic-routing.ts +++ b/src/oauth/anthropic-routing.ts @@ -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"; @@ -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, @@ -478,6 +490,7 @@ function pruneExpiredAffinity(now: number): void { export type AnthropicAccountSelectionReason = | "pool-disabled" | "affinity" + | "hash" | "active" | "lowest-usage" | "only-eligible" @@ -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). @@ -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). */ @@ -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. @@ -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"); @@ -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); diff --git a/tests/adapters/anthropic/anthropic-account-pool.test.ts b/tests/adapters/anthropic/anthropic-account-pool.test.ts index a00ba198abe..144cb7cba54 100644 --- a/tests/adapters/anthropic/anthropic-account-pool.test.ts +++ b/tests/adapters/anthropic/anthropic-account-pool.test.ts @@ -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 () => { @@ -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)); @@ -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); });