426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
467 lines
19 KiB
TypeScript
467 lines
19 KiB
TypeScript
import { CODING_AGENT_SELECTOR_BRIDGE_SYMBOL } from "@elizaos/core";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
buildRotatedSubprocessEnv,
|
|
isSubscriptionLimitError,
|
|
type RotationAccountSelection,
|
|
resetRotationStateForTests,
|
|
rotationAgentTypeForBackend,
|
|
rotationEnabled,
|
|
withAccountRotation,
|
|
} from "../src/account-rotation";
|
|
import { ProviderApiError } from "../src/provider-errors";
|
|
|
|
/**
|
|
* Issue #11180 Gap A: the chat brain must (1) authenticate its FIRST warm
|
|
* session from the account pool when one is present — an app-connected
|
|
* subscription is used immediately, not stored-but-unused until a limit error;
|
|
* ambient stays the fallback when the pool is empty — and (2) rotate to the
|
|
* next healthy pooled account on a subscription limit, and ONLY on a
|
|
* subscription limit — a non-limit error must fall straight through to the
|
|
* caller's provider-failover chain.
|
|
*
|
|
* These drive the pure rotation logic with a FAKE coding-agent selector bridge
|
|
* installed on the `globalThis` symbol (the real bridge lives in app-core; the
|
|
* plugin only reads the contract off the symbol). No real pool, no real SDK, no
|
|
* second live account needed to prove the logic — exactly as the issue's test
|
|
* plan requires.
|
|
*/
|
|
|
|
const BRIDGE_SYMBOL = CODING_AGENT_SELECTOR_BRIDGE_SYMBOL;
|
|
|
|
interface FakeBridge {
|
|
select: ReturnType<typeof vi.fn>;
|
|
markRateLimited: ReturnType<typeof vi.fn>;
|
|
recordUsage: ReturnType<typeof vi.fn>;
|
|
}
|
|
|
|
function installFakeBridge(selections: Array<RotationAccountSelection | null>): FakeBridge {
|
|
let i = 0;
|
|
const bridge: FakeBridge = {
|
|
select: vi.fn(async () => {
|
|
const next = i < selections.length ? selections[i] : null;
|
|
i += 1;
|
|
return next;
|
|
}),
|
|
markRateLimited: vi.fn(async () => undefined),
|
|
recordUsage: vi.fn(async () => undefined),
|
|
};
|
|
(globalThis as Record<symbol, unknown>)[BRIDGE_SYMBOL] = bridge;
|
|
return bridge;
|
|
}
|
|
|
|
function uninstallBridge(): void {
|
|
delete (globalThis as Record<symbol, unknown>)[BRIDGE_SYMBOL];
|
|
}
|
|
|
|
function account(id: string): RotationAccountSelection {
|
|
return {
|
|
providerId: "anthropic-subscription",
|
|
accountId: id,
|
|
label: id,
|
|
source: "oauth",
|
|
strategy: "least-used",
|
|
envPatch: { CLAUDE_CODE_OAUTH_TOKEN: `tok-${id}` },
|
|
};
|
|
}
|
|
|
|
const enabledGetter = () => undefined;
|
|
|
|
afterEach(() => {
|
|
uninstallBridge();
|
|
resetRotationStateForTests();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("isSubscriptionLimitError", () => {
|
|
it("classifies the session handler's own limit throw", () => {
|
|
expect(
|
|
isSubscriptionLimitError(
|
|
new Error(
|
|
"[cli-inference:sdk] subscription rate limit reached: You've hit your session limit"
|
|
)
|
|
)
|
|
).toBe(true);
|
|
});
|
|
|
|
it("classifies 429 / 529 status envelopes", () => {
|
|
expect(
|
|
isSubscriptionLimitError(new ProviderApiError("upstream API Error: 429", { statusCode: 429 }))
|
|
).toBe(true);
|
|
expect(
|
|
isSubscriptionLimitError(new ProviderApiError("upstream API Error: 529", { statusCode: 529 }))
|
|
).toBe(true);
|
|
expect(isSubscriptionLimitError(new Error("API Error: 429 rate limited"))).toBe(true);
|
|
});
|
|
|
|
it("classifies provider quota / rate-limit vocabulary", () => {
|
|
expect(isSubscriptionLimitError(new Error("usage limit reached"))).toBe(true);
|
|
expect(isSubscriptionLimitError(new Error("quota exhausted for this key"))).toBe(true);
|
|
expect(isSubscriptionLimitError(new Error("too many requests"))).toBe(true);
|
|
});
|
|
|
|
it("classifies OpenAI's classic quota envelope (inverted word order, no 429 literal)", () => {
|
|
// The real envelope: message text alone, no statusCode on the thrown error —
|
|
// the exact shape a codex-sdk turn surfaces. Must rotate, not tier-failover.
|
|
expect(
|
|
isSubscriptionLimitError(
|
|
new Error(
|
|
"You exceeded your current quota, please check your plan and billing details. " +
|
|
"For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors."
|
|
)
|
|
)
|
|
).toBe(true);
|
|
// Truncated variants: either envelope half alone still classifies.
|
|
expect(isSubscriptionLimitError(new Error("You exceeded your current quota"))).toBe(true);
|
|
expect(isSubscriptionLimitError(new Error("please check your plan and billing details"))).toBe(
|
|
true
|
|
);
|
|
// The machine-readable error code from the JSON envelope body.
|
|
expect(
|
|
isSubscriptionLimitError(
|
|
new Error('{"error":{"type":"insufficient_quota","code":"insufficient_quota"}}')
|
|
)
|
|
).toBe(true);
|
|
});
|
|
|
|
it("does NOT classify prose that merely talks about quotas / billing", () => {
|
|
expect(
|
|
isSubscriptionLimitError(
|
|
new Error("the user asked how quotas work and whether billing resets monthly")
|
|
)
|
|
).toBe(false);
|
|
expect(
|
|
isSubscriptionLimitError(new Error("your quota looks fine; billing details are unchanged"))
|
|
).toBe(false);
|
|
});
|
|
|
|
it("does NOT classify non-limit errors (would burn a healthy account)", () => {
|
|
expect(
|
|
isSubscriptionLimitError(new Error("[cli-inference:sdk] empty completion (subtype=success)"))
|
|
).toBe(false);
|
|
expect(
|
|
isSubscriptionLimitError(
|
|
new ProviderApiError("API Error: 400 messages: text content blocks must be non-empty", {
|
|
statusCode: 400,
|
|
})
|
|
)
|
|
).toBe(false);
|
|
expect(isSubscriptionLimitError(new Error("401 unauthorized"))).toBe(false);
|
|
expect(isSubscriptionLimitError(new Error("route: model emitted no decision"))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("rotationAgentTypeForBackend", () => {
|
|
it("maps only the SDK backends to a rotation agent type", () => {
|
|
expect(rotationAgentTypeForBackend("claude-sdk")).toBe("claude");
|
|
expect(rotationAgentTypeForBackend("codex-sdk")).toBe("codex");
|
|
// Cold CLIs read the single on-disk login — out of scope (Gap B / CLI shim).
|
|
expect(rotationAgentTypeForBackend("claude")).toBeNull();
|
|
expect(rotationAgentTypeForBackend("codex")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("rotationEnabled", () => {
|
|
it("defaults ON and honors the opt-out flag", () => {
|
|
expect(rotationEnabled(() => undefined)).toBe(true);
|
|
expect(rotationEnabled(() => "1")).toBe(true);
|
|
for (const off of ["0", "false", "no", "off", "OFF", " Off "]) {
|
|
expect(rotationEnabled(() => off)).toBe(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("buildRotatedSubprocessEnv", () => {
|
|
it("keeps ambient process env intact while selected account auth wins in subprocess env", () => {
|
|
const saved = {
|
|
CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN,
|
|
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
|
CODEX_HOME: process.env.CODEX_HOME,
|
|
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
|
};
|
|
process.env.CLAUDE_CODE_OAUTH_TOKEN = "ambient-claude-token";
|
|
process.env.ANTHROPIC_API_KEY = "ambient-anthropic-key";
|
|
process.env.CODEX_HOME = "/ambient/codex";
|
|
process.env.OPENAI_API_KEY = "ambient-openai-key";
|
|
|
|
try {
|
|
const claudeEnv = buildRotatedSubprocessEnv("claude", {
|
|
CLAUDE_CODE_OAUTH_TOKEN: "selected-claude-token",
|
|
});
|
|
expect(claudeEnv.CLAUDE_CODE_OAUTH_TOKEN).toBe("selected-claude-token");
|
|
expect(claudeEnv.ANTHROPIC_API_KEY).toBeUndefined();
|
|
expect(claudeEnv.PATH).toBe(process.env.PATH);
|
|
|
|
const codexEnv = buildRotatedSubprocessEnv("codex", { CODEX_HOME: "/selected/codex" });
|
|
expect(codexEnv.CODEX_HOME).toBe("/selected/codex");
|
|
expect(codexEnv.OPENAI_API_KEY).toBeUndefined();
|
|
expect(codexEnv.PATH).toBe(process.env.PATH);
|
|
|
|
expect(process.env.CLAUDE_CODE_OAUTH_TOKEN).toBe("ambient-claude-token");
|
|
expect(process.env.ANTHROPIC_API_KEY).toBe("ambient-anthropic-key");
|
|
expect(process.env.CODEX_HOME).toBe("/ambient/codex");
|
|
expect(process.env.OPENAI_API_KEY).toBe("ambient-openai-key");
|
|
} finally {
|
|
for (const [key, value] of Object.entries(saved)) {
|
|
if (value === undefined) delete process.env[key];
|
|
else process.env[key] = value;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("withAccountRotation", () => {
|
|
const ctx = (overrides: Record<string, unknown> = {}) => ({
|
|
backend: "claude-sdk",
|
|
getValue: enabledGetter,
|
|
onRotate: vi.fn(),
|
|
...overrides,
|
|
});
|
|
|
|
it("uses a pooled account for the FIRST warm-session auth — no ambient token needed", async () => {
|
|
// THE app-connect regression: a machine with NO ambient CLI login but a
|
|
// pooled (app-connected) subscription must serve the very first turn from
|
|
// the pool, not fail / sit stored-but-unused until a limit error.
|
|
const savedToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
const bridge = installFakeBridge([account("b")]);
|
|
const attempt = vi.fn(async (env?: Record<string, string | undefined>) => {
|
|
expect(env?.CLAUDE_CODE_OAUTH_TOKEN).toBe("tok-b");
|
|
return "first-turn-on-pooled-account";
|
|
});
|
|
const c = ctx();
|
|
try {
|
|
await expect(withAccountRotation(attempt, c as never)).resolves.toBe(
|
|
"first-turn-on-pooled-account"
|
|
);
|
|
expect(attempt).toHaveBeenCalledTimes(1);
|
|
// Pool consulted BEFORE the first attempt, without an exclude list.
|
|
expect(bridge.select).toHaveBeenCalledTimes(1);
|
|
expect(bridge.select.mock.calls[0][1]?.exclude).toBeUndefined();
|
|
expect(bridge.select.mock.invocationCallOrder[0]).toBeLessThan(
|
|
attempt.mock.invocationCallOrder[0]
|
|
);
|
|
// Any stale ambient-auth'd warm session is evicted before the attempt.
|
|
expect(c.onRotate).toHaveBeenCalledTimes(1);
|
|
// Usage recorded against the initially-selected account on success.
|
|
expect(bridge.recordUsage).toHaveBeenCalledWith("anthropic-subscription", "b", { ok: true });
|
|
// The pooled token never leaks into the parent process env.
|
|
expect(process.env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
|
|
} finally {
|
|
if (savedToken !== undefined) process.env.CLAUDE_CODE_OAUTH_TOKEN = savedToken;
|
|
}
|
|
});
|
|
|
|
it("falls back to the ambient credential when the pool is empty (select → null)", async () => {
|
|
const bridge = installFakeBridge([null]);
|
|
const attempt = vi.fn(async (env?: Record<string, string | undefined>) => {
|
|
expect(env).toBeUndefined();
|
|
return "ambient-answer";
|
|
});
|
|
const c = ctx();
|
|
await expect(withAccountRotation(attempt, c as never)).resolves.toBe("ambient-answer");
|
|
expect(attempt).toHaveBeenCalledTimes(1);
|
|
expect(bridge.select).toHaveBeenCalledTimes(1);
|
|
expect(c.onRotate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("falls back to the ambient credential when the initial pool selection throws", async () => {
|
|
const bridge = installFakeBridge([]);
|
|
bridge.select.mockRejectedValueOnce(new Error("pool store unavailable"));
|
|
const attempt = vi.fn(async (env?: Record<string, string | undefined>) => {
|
|
expect(env).toBeUndefined();
|
|
return "ambient-answer";
|
|
});
|
|
await expect(withAccountRotation(attempt, ctx() as never)).resolves.toBe("ambient-answer");
|
|
expect(attempt).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("rotates on a subscription-limit error then succeeds on the next account", async () => {
|
|
const savedToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
const savedKey = process.env.ANTHROPIC_API_KEY;
|
|
process.env.CLAUDE_CODE_OAUTH_TOKEN = "ambient-token";
|
|
process.env.ANTHROPIC_API_KEY = "ambient-key";
|
|
const bridge = installFakeBridge([account("b"), account("c")]);
|
|
let calls = 0;
|
|
const seenEnv: Array<Record<string, string | undefined> | undefined> = [];
|
|
const attempt = vi.fn(async (env?: Record<string, string | undefined>) => {
|
|
seenEnv.push(env);
|
|
calls += 1;
|
|
if (calls === 1) throw new Error("subscription rate limit reached: session limit");
|
|
return "answer-on-account-c";
|
|
});
|
|
const c = ctx();
|
|
try {
|
|
await expect(withAccountRotation(attempt, c as never)).resolves.toBe("answer-on-account-c");
|
|
expect(attempt).toHaveBeenCalledTimes(2);
|
|
// First attempt already runs on the pool-selected account b (pool-first),
|
|
// with the ambient token/key stripped from the subprocess env.
|
|
expect(seenEnv[0]?.CLAUDE_CODE_OAUTH_TOKEN).toBe("tok-b");
|
|
expect(seenEnv[0]?.ANTHROPIC_API_KEY).toBeUndefined();
|
|
// b limits → rotate to c.
|
|
expect(seenEnv[1]?.CLAUDE_CODE_OAUTH_TOKEN).toBe("tok-c");
|
|
expect(seenEnv[1]?.ANTHROPIC_API_KEY).toBeUndefined();
|
|
expect(seenEnv[1]?.PATH).toBe(process.env.PATH);
|
|
expect(bridge.select).toHaveBeenCalledTimes(2);
|
|
// The limited account b was marked + excluded from the rotation select.
|
|
expect(bridge.markRateLimited).toHaveBeenCalledWith(
|
|
"anthropic-subscription",
|
|
"b",
|
|
expect.any(Number),
|
|
expect.any(String)
|
|
);
|
|
expect(bridge.select.mock.calls[1][1].exclude).toContain("b");
|
|
// Selected tokens are scoped to the subprocess env only.
|
|
expect(process.env.CLAUDE_CODE_OAUTH_TOKEN).toBe("ambient-token");
|
|
expect(process.env.ANTHROPIC_API_KEY).toBe("ambient-key");
|
|
// Torn down once for the initial selection, once for the rotation.
|
|
expect(c.onRotate).toHaveBeenCalledTimes(2);
|
|
// Usage recorded against the account we rotated INTO on success.
|
|
expect(bridge.recordUsage).toHaveBeenCalledWith("anthropic-subscription", "c", { ok: true });
|
|
} finally {
|
|
if (savedToken === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
else process.env.CLAUDE_CODE_OAUTH_TOKEN = savedToken;
|
|
if (savedKey === undefined) delete process.env.ANTHROPIC_API_KEY;
|
|
else process.env.ANTHROPIC_API_KEY = savedKey;
|
|
}
|
|
});
|
|
|
|
it("reuses a selected subprocess env on later turns without reselecting or mutating process.env", async () => {
|
|
const savedToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
process.env.CLAUDE_CODE_OAUTH_TOKEN = "ambient-token";
|
|
const bridge = installFakeBridge([account("b")]);
|
|
try {
|
|
await expect(
|
|
withAccountRotation(
|
|
async (env?: Record<string, string | undefined>) => {
|
|
expect(env?.CLAUDE_CODE_OAUTH_TOKEN).toBe("tok-b");
|
|
return "first-turn";
|
|
},
|
|
ctx({ sessionKey: "stable-session" }) as never
|
|
)
|
|
).resolves.toBe("first-turn");
|
|
|
|
const secondAttempt = vi.fn(async (env?: Record<string, string | undefined>) => {
|
|
expect(env?.CLAUDE_CODE_OAUTH_TOKEN).toBe("tok-b");
|
|
return "still-on-selected-account";
|
|
});
|
|
await expect(
|
|
withAccountRotation(secondAttempt, ctx({ sessionKey: "stable-session" }) as never)
|
|
).resolves.toBe("still-on-selected-account");
|
|
|
|
expect(secondAttempt).toHaveBeenCalledTimes(1);
|
|
expect(bridge.select).toHaveBeenCalledTimes(1);
|
|
expect(process.env.CLAUDE_CODE_OAUTH_TOKEN).toBe("ambient-token");
|
|
} finally {
|
|
if (savedToken === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
else process.env.CLAUDE_CODE_OAUTH_TOKEN = savedToken;
|
|
}
|
|
});
|
|
|
|
it("rotates on OpenAI's classic quota envelope (the pre-fix silent tier-failover)", async () => {
|
|
const bridge = installFakeBridge([account("b"), account("c")]);
|
|
let calls = 0;
|
|
const attempt = vi.fn(async () => {
|
|
calls += 1;
|
|
if (calls === 1) {
|
|
throw new Error(
|
|
"You exceeded your current quota, please check your plan and billing details."
|
|
);
|
|
}
|
|
return "answer-on-account-c";
|
|
});
|
|
const c = ctx({ backend: "codex-sdk" });
|
|
await expect(withAccountRotation(attempt, c as never)).resolves.toBe("answer-on-account-c");
|
|
expect(attempt).toHaveBeenCalledTimes(2);
|
|
expect(bridge.select).toHaveBeenCalledTimes(2);
|
|
expect(c.onRotate).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("does NOT rotate on a non-limit error — rethrows immediately to failover", async () => {
|
|
const bridge = installFakeBridge([account("b")]);
|
|
const attempt = vi.fn(async () => {
|
|
throw new Error("[cli-inference:sdk] empty completion (subtype=success)");
|
|
});
|
|
const c = ctx();
|
|
await expect(withAccountRotation(attempt, c as never)).rejects.toThrow("empty completion");
|
|
expect(attempt).toHaveBeenCalledTimes(1);
|
|
// Only the pool-first initial selection ran — no rotation select.
|
|
expect(bridge.select).toHaveBeenCalledTimes(1);
|
|
expect(bridge.markRateLimited).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("excludes already-tried accounts and rotates through several before succeeding", async () => {
|
|
const bridge = installFakeBridge([account("b"), account("c"), account("d")]);
|
|
let calls = 0;
|
|
const attempt = vi.fn(async () => {
|
|
calls += 1;
|
|
if (calls <= 2) throw new Error("429 too many requests");
|
|
return "answer-on-account-d";
|
|
});
|
|
await expect(withAccountRotation(attempt, ctx() as never)).resolves.toBe("answer-on-account-d");
|
|
expect(attempt).toHaveBeenCalledTimes(3);
|
|
expect(bridge.select).toHaveBeenCalledTimes(3);
|
|
// Each rotation select excludes every account already tried.
|
|
expect(bridge.select.mock.calls[1][1].exclude).toEqual(["b"]);
|
|
expect(bridge.select.mock.calls[2][1].exclude).toEqual(["b", "c"]);
|
|
});
|
|
|
|
it("falls through to provider failover (rethrows) when the pool is exhausted", async () => {
|
|
const bridge = installFakeBridge([account("b"), null]);
|
|
const attempt = vi.fn(async () => {
|
|
throw new Error("subscription rate limit reached: session limit");
|
|
});
|
|
// Pool-first start on b; b limits → select returns null → rethrow.
|
|
await expect(withAccountRotation(attempt, ctx() as never)).rejects.toThrow(
|
|
"subscription rate limit reached"
|
|
);
|
|
expect(bridge.select).toHaveBeenCalledTimes(2);
|
|
// The limited account b was marked rate-limited before the exhausted select.
|
|
expect(bridge.markRateLimited).toHaveBeenCalledWith(
|
|
"anthropic-subscription",
|
|
"b",
|
|
expect.any(Number),
|
|
expect.any(String)
|
|
);
|
|
});
|
|
|
|
it("single-account no-op: no bridge installed → single un-wrapped attempt, throw to failover", async () => {
|
|
uninstallBridge();
|
|
const attempt = vi.fn(async () => {
|
|
throw new Error("subscription rate limit reached: session limit");
|
|
});
|
|
const c = ctx();
|
|
await expect(withAccountRotation(attempt, c as never)).rejects.toThrow(
|
|
"subscription rate limit reached"
|
|
);
|
|
expect(attempt).toHaveBeenCalledTimes(1);
|
|
expect(c.onRotate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not rotate when disabled via the opt-out flag", async () => {
|
|
const bridge = installFakeBridge([account("b")]);
|
|
const attempt = vi.fn(async () => {
|
|
throw new Error("subscription rate limit reached: session limit");
|
|
});
|
|
const c = ctx({ getValue: () => "0" });
|
|
await expect(withAccountRotation(attempt, c as never)).rejects.toThrow(
|
|
"subscription rate limit reached"
|
|
);
|
|
expect(attempt).toHaveBeenCalledTimes(1);
|
|
expect(bridge.select).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("non-rotatable backend (cold CLI) is a pass-through no-op", async () => {
|
|
const bridge = installFakeBridge([account("b")]);
|
|
const attempt = vi.fn(async () => {
|
|
throw new Error("subscription rate limit reached: session limit");
|
|
});
|
|
const c = ctx({ backend: "claude" });
|
|
await expect(withAccountRotation(attempt, c as never)).rejects.toThrow(
|
|
"subscription rate limit reached"
|
|
);
|
|
expect(attempt).toHaveBeenCalledTimes(1);
|
|
expect(bridge.select).not.toHaveBeenCalled();
|
|
});
|
|
});
|