Files
2026-07-13 13:39:12 +08:00

700 lines
24 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import net from "node:net";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { MockUpstreamServer, buildCompletion, buildError } from "./helpers/mockUpstreamServer.ts";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-system-failover-"));
const DASHBOARD_PORT = await getFreePort();
const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "system-failover-secret-123456";
process.env.REQUIRE_API_KEY = "false";
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const combosDb = await import("../../src/lib/db/combos.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
const accountFallback = await import("../../open-sse/services/accountFallback.ts");
function resetConnectionCooldowns() {
accountFallback.clearAllModelLockouts();
const db = core.getDbInstance() as any;
db.prepare(
`UPDATE provider_connections
SET rate_limited_until = NULL,
test_status = 'active',
backoff_level = 0,
last_error = NULL,
last_error_type = NULL,
last_error_source = NULL,
error_code = NULL,
last_error_at = NULL
WHERE rate_limited_until IS NOT NULL
OR test_status != 'active'`
).run();
db.pragma("wal_checkpoint(TRUNCATE)");
}
function getFreePort() {
return new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close();
reject(new Error("Failed to allocate a free port"));
return;
}
const { port } = address;
server.close((closeError) => {
if (closeError) reject(closeError);
else resolve(port);
});
});
});
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function seedProvider(label: string, apiKey: string, baseUrl: string) {
const providerId = `openai-compatible-sys-${label}`;
await providersDb.createProviderNode({
id: providerId,
type: "openai-compatible",
name: `System ${label}`,
prefix: label,
apiType: "chat",
baseUrl,
});
await providersDb.createProviderConnection({
provider: providerId,
authType: "apikey",
name: `conn-${label}`,
apiKey,
isActive: true,
testStatus: "active",
providerSpecificData: { baseUrl, apiType: "chat" },
});
return { providerId, model: `${label}/test-model`, apiKey };
}
function createServerProcess(dataDir: string, port: number) {
const stdoutLines: string[] = [];
const stderrLines: string[] = [];
let exitInfo: { code: number | null; signal: NodeJS.Signals | null } | null = null;
const child = spawn(process.execPath, ["scripts/dev/run-next-playwright.mjs", "dev"], {
cwd: REPO_ROOT,
env: {
...process.env,
DATA_DIR: dataDir,
PORT: String(port),
DASHBOARD_PORT: String(port),
API_PORT: String(port),
HOST: "127.0.0.1",
REQUIRE_API_KEY: "false",
API_KEY_SECRET: process.env.API_KEY_SECRET || "system-failover-secret-123456",
DISABLE_SQLITE_AUTO_BACKUP: "true",
INITIAL_PASSWORD: "",
NEXT_TELEMETRY_DISABLED: "1",
OMNIROUTE_DISABLE_BACKGROUND_SERVICES: "true",
OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK: "true",
OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK: "true",
OMNIROUTE_HIDE_HEALTHCHECK_LOGS: "true",
OMNIROUTE_E2E_BOOTSTRAP_MODE: "open",
},
stdio: ["ignore", "pipe", "pipe"],
});
child.once("exit", (code, signal) => {
exitInfo = { code, signal };
});
child.stdout.on("data", (chunk) => {
const lines = String(chunk).split(/\r?\n/).filter(Boolean);
stdoutLines.push(...lines);
if (stdoutLines.length > 200) stdoutLines.splice(0, stdoutLines.length - 200);
});
child.stderr.on("data", (chunk) => {
const lines = String(chunk).split(/\r?\n/).filter(Boolean);
stderrLines.push(...lines);
if (stderrLines.length > 200) stderrLines.splice(0, stderrLines.length - 200);
});
return {
child,
stdoutLines,
stderrLines,
baseUrl: `http://127.0.0.1:${port}`,
get exitInfo() {
return exitInfo;
},
};
}
async function waitForServer(
baseUrl: string,
logs: {
stdoutLines: string[];
stderrLines: string[];
exitInfo?: { code: number | null; signal: NodeJS.Signals | null } | null;
}
) {
const startedAt = Date.now();
let lastError = "";
while (Date.now() - startedAt < 120_000) {
if (logs.exitInfo) {
throw new Error(
[
`OmniRoute exited before it became ready (code=${logs.exitInfo.code}, signal=${logs.exitInfo.signal})`,
"--- stdout ---",
...logs.stdoutLines.slice(-40),
"--- stderr ---",
...logs.stderrLines.slice(-40),
].join("\n")
);
}
try {
const response = await fetch(`${baseUrl}/api/monitoring/health`, {
signal: AbortSignal.timeout(5_000),
});
if (response.ok) return;
lastError = `HTTP ${response.status}`;
} catch (error: any) {
lastError = error instanceof Error ? error.message : String(error);
}
await sleep(500);
}
throw new Error(
[
`Timed out waiting for OmniRoute to start: ${lastError}`,
"--- stdout ---",
...logs.stdoutLines.slice(-40),
"--- stderr ---",
...logs.stderrLines.slice(-40),
].join("\n")
);
}
async function stopProcess(child: ReturnType<typeof spawn>) {
if (child.killed) return;
child.kill("SIGTERM");
const exited = await Promise.race([
new Promise<boolean>((resolve) => child.once("exit", () => resolve(true))),
sleep(5_000).then(() => false),
]);
if (!exited && !child.killed) {
child.kill("SIGKILL");
await new Promise<void>((resolve) => child.once("exit", () => resolve()));
}
}
async function postChat(
baseUrl: string,
model: string,
content: string,
extraHeaders?: Record<string, string>
) {
const response = await fetch(`${baseUrl}/api/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", ...extraHeaders },
body: JSON.stringify({
model,
stream: false,
messages: [{ role: "user", content }],
}),
signal: AbortSignal.timeout(30_000),
});
const text = await response.text();
const json = text ? JSON.parse(text) : {};
return { response, json };
}
async function resetBreakers(url: string) {
await fetch(`${url}/api/resilience/reset`, {
method: "POST",
signal: AbortSignal.timeout(5_000),
});
}
const serverA = new MockUpstreamServer();
const serverB = new MockUpstreamServer();
let app:
| {
child: ReturnType<typeof spawn>;
stdoutLines: string[];
stderrLines: string[];
baseUrl: string;
}
| undefined;
const TOKEN_A = "sk-sys-a";
const TOKEN_B = "sk-sys-b";
const TOKEN_A2 = "sk-sys-a2";
const TOKEN_B2 = "sk-sys-b2";
test.before(async () => {
const baseUrlA = await serverA.start();
const baseUrlB = await serverB.start();
serverA.configureToken(TOKEN_A, {
defaultResponse: buildCompletion("server A ok", { model: "sys-a/test-model" }),
});
serverA.configureToken(TOKEN_A2, {
defaultResponse: buildCompletion("server A2 ok", { model: "sys-a2/test-model" }),
});
serverB.configureToken(TOKEN_B, {
defaultResponse: buildCompletion("server B ok", { model: "sys-b/test-model" }),
});
serverB.configureToken(TOKEN_B2, {
defaultResponse: buildCompletion("server B2 ok", { model: "sys-b2/test-model" }),
});
const provA = await seedProvider("sys-a", TOKEN_A, baseUrlA);
const provB = await seedProvider("sys-b", TOKEN_B, baseUrlB);
const provA2 = await seedProvider("sys-a2", TOKEN_A2, baseUrlA);
const provB2 = await seedProvider("sys-b2", TOKEN_B2, baseUrlB);
await combosDb.createCombo({
name: "sys-priority",
strategy: "priority",
config: { maxRetries: 0, retryDelayMs: 0 },
models: [provA.model, provB.model],
});
await combosDb.createCombo({
name: "sys-priority-v2",
strategy: "priority",
config: { maxRetries: 0, retryDelayMs: 0 },
models: [provA2.model, provB2.model],
});
await combosDb.createCombo({
name: "sys-priority-fobr",
strategy: "priority",
config: { maxRetries: 0, retryDelayMs: 0, failoverBeforeRetry: true },
models: [provA.model, provB.model],
});
await combosDb.createCombo({
name: "sys-priority-setretry",
strategy: "priority",
config: {
maxRetries: 0,
retryDelayMs: 0,
failoverBeforeRetry: true,
maxSetRetries: 1,
setRetryDelayMs: 500,
},
models: [provA.model, provB.model],
});
await combosDb.createCombo({
name: "sys-same-server",
strategy: "priority",
config: { maxRetries: 0, retryDelayMs: 0 },
models: [provA.model, provA2.model],
});
await combosDb.createCombo({
name: "sys-same-server-fobr",
strategy: "priority",
config: { maxRetries: 0, retryDelayMs: 0, failoverBeforeRetry: true },
models: [provA.model, provA2.model],
});
await combosDb.createCombo({
name: "sys-single-provider",
strategy: "priority",
config: { maxRetries: 0, retryDelayMs: 0 },
models: ["sys-a/modelA", "sys-a/modelB"],
});
await combosDb.createCombo({
name: "sys-single-provider-fobr",
strategy: "priority",
config: { maxRetries: 0, retryDelayMs: 0, failoverBeforeRetry: true },
models: ["sys-a/modelA", "sys-a/modelB"],
});
await settingsDb.updateSettings({
resilienceSettings: {
requestQueue: {
autoEnableApiKeyProviders: true,
requestsPerMinute: 120,
minTimeBetweenRequestsMs: 0,
concurrentRequests: 4,
maxWaitMs: 2_000,
},
connectionCooldown: {
oauth: { baseCooldownMs: 500, useUpstreamRetryHints: true, maxBackoffSteps: 3 },
apikey: { baseCooldownMs: 200, useUpstreamRetryHints: false, maxBackoffSteps: 0 },
},
providerBreaker: {
oauth: { failureThreshold: 3, resetTimeoutMs: 2_000 },
apikey: { failureThreshold: 2, resetTimeoutMs: 1_500 },
},
waitForCooldown: {
enabled: false,
maxRetries: 0,
maxRetryWaitSec: 0,
},
},
requestRetry: 0,
maxRetryIntervalSec: 0,
requireLogin: false,
setupComplete: true,
});
core.closeDbInstance();
app = createServerProcess(TEST_DATA_DIR, DASHBOARD_PORT);
await waitForServer(app.baseUrl, app);
const warmup = await postChat(app.baseUrl, "sys-b/test-model", "warm up");
assert.equal(warmup.response.status, 200, JSON.stringify(warmup.json));
serverB.resetState(TOKEN_B);
});
test.after(async () => {
if (app) await stopProcess(app.child);
await serverA.stop();
await serverB.stop();
core.closeDbInstance();
await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true });
});
test("primary healthy: request routes to Server A only", async () => {
assert.ok(app);
serverA.resetState(TOKEN_A);
serverB.resetState(TOKEN_B);
const result = await postChat(app.baseUrl, "sys-priority", "healthy primary");
assert.equal(result.response.status, 200, JSON.stringify(result.json));
assert.equal(result.json.choices[0].message.content, "server A ok");
assert.equal(result.json.model, "sys-a/test-model");
assert.equal(serverA.getState(TOKEN_A).hits, 1);
assert.equal(serverB.getState(TOKEN_B).hits, 0);
});
test("500 Internal Server Error: combo falls back to Server B", async () => {
assert.ok(app);
serverA.resetState(TOKEN_A, [buildError(500, "Internal Server Error")]);
serverB.resetState(TOKEN_B);
const result = await postChat(app.baseUrl, "sys-priority", "500 fallback");
assert.equal(result.response.status, 200, JSON.stringify(result.json));
assert.equal(result.json.choices[0].message.content, "server B ok");
assert.equal(result.json.model, "sys-b/test-model");
assert.equal(serverA.getState(TOKEN_A).hits, 1);
assert.equal(serverB.getState(TOKEN_B).hits, 1);
});
test("503 Service Unavailable: combo falls back to Server B", async () => {
assert.ok(app);
await resetBreakers(app.baseUrl);
resetConnectionCooldowns();
serverA.resetState(TOKEN_A, [buildError(503, "Service Unavailable")]);
serverB.resetState(TOKEN_B);
const result = await postChat(app.baseUrl, "sys-priority", "503 fallback");
assert.equal(result.response.status, 200, JSON.stringify(result.json));
assert.equal(result.json.choices[0].message.content, "server B ok");
assert.equal(result.json.model, "sys-b/test-model");
assert.equal(serverA.getState(TOKEN_A).hits, 1);
assert.equal(serverB.getState(TOKEN_B).hits, 1);
});
test("both servers fail (500): request returns a 5xx error to the client", async () => {
assert.ok(app);
await resetBreakers(app.baseUrl);
resetConnectionCooldowns();
serverA.resetState(TOKEN_A, [buildError(500, "A is down")]);
serverB.resetState(TOKEN_B, [buildError(500, "B is down")]);
const result = await postChat(app.baseUrl, "sys-priority", "both down");
assert.ok(result.response.status >= 500, `expected 5xx, got ${result.response.status}`);
});
test("combo fallback to Server B survives sequential 503 failures from Server A", async () => {
assert.ok(app);
await resetBreakers(app.baseUrl);
resetConnectionCooldowns();
serverA.resetState(TOKEN_A, [buildError(503, "transient blip"), buildError(503, "second blip")]);
serverB.resetState(TOKEN_B);
const first = await postChat(app.baseUrl, "sys-priority", "seq 503 attempt 1");
assert.equal(first.response.status, 200, JSON.stringify(first.json));
assert.equal(first.json.choices[0].message.content, "server B ok");
assert.equal(first.json.model, "sys-b/test-model");
// Wait for the 200ms apikey cooldown to expire so the second request also
// goes through the full A→B fallback path rather than skipping A entirely.
await sleep(250);
const second = await postChat(app.baseUrl, "sys-priority", "seq 503 attempt 2");
assert.equal(second.response.status, 200, JSON.stringify(second.json));
assert.equal(second.json.choices[0].message.content, "server B ok");
assert.equal(second.json.model, "sys-b/test-model");
assert.equal(serverA.getState(TOKEN_A).hits, 2);
assert.equal(serverB.getState(TOKEN_B).hits, 2);
});
test("429 with Retry-After and wait-for-cooldown: primary retries then falls back to B", async () => {
assert.ok(app);
await resetBreakers(app.baseUrl);
resetConnectionCooldowns();
serverA.resetState(TOKEN_A, [
buildError(429, "rate limited, retry after 1s", { "Retry-After": "1" }),
]);
serverB.resetState(TOKEN_B);
const patchRes = await fetch(`${app.baseUrl}/api/resilience`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
connectionCooldown: {
apikey: { useUpstreamRetryHints: true, baseCooldownMs: 200 },
},
waitForCooldown: { enabled: true, maxRetries: 1, maxRetryWaitSec: 2 },
}),
signal: AbortSignal.timeout(10_000),
});
});
test("failoverBeforeRetry enabled: upstream error triggers immediate failover to next target", async () => {
assert.ok(app);
await resetBreakers(app.baseUrl);
resetConnectionCooldowns();
serverA.resetState(TOKEN_A, [buildError(429, "rate limited")]);
serverB.resetState(TOKEN_B);
const result = await postChat(app.baseUrl, "sys-priority-fobr", "test failover before retry");
assert.equal(result.response.status, 200, JSON.stringify(result.json));
assert.equal(result.json.choices[0].message.content, "server B ok");
assert.equal(result.json.model, "sys-b/test-model");
// With failoverBeforeRetry=true, A should be hit exactly ONCE (no intra-URL retry)
assert.equal(serverA.getState(TOKEN_A).hits, 1);
assert.equal(serverB.getState(TOKEN_B).hits, 1);
});
test("failoverBeforeRetry disabled: 429 triggers executor intra-URL retry, succeeds on retry", async () => {
assert.ok(app);
// Full resilience reset so A isn't blocked by residual breaker/cooldown state
const patchRes = await fetch(`${app.baseUrl}/api/resilience`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
connectionCooldown: {
apikey: { useUpstreamRetryHints: false, baseCooldownMs: 0, maxBackoffSteps: 0 },
oauth: { useUpstreamRetryHints: false, baseCooldownMs: 0, maxBackoffSteps: 0 },
},
waitForCooldown: { enabled: false, maxRetries: 0, maxRetryWaitSec: 0 },
}),
signal: AbortSignal.timeout(10_000),
});
assert.equal(patchRes.status, 200);
await resetBreakers(app.baseUrl);
resetConnectionCooldowns();
// Wait for any residual cooldowns to expire
await sleep(300);
// One 429, then the default (200) on retry
serverA.resetState(TOKEN_A, [buildError(429, "rate limited")]);
serverB.resetState(TOKEN_B);
const result = await postChat(app.baseUrl, "sys-priority", "test failover before retry disabled");
assert.equal(result.response.status, 200, JSON.stringify(result.json));
// With maxRetries=0 the combo does not retry A — it fails over to B immediately.
assert.equal(result.json.model, "sys-b/test-model");
// With failoverBeforeRetry=false, A should be hit TWICE (initial + 1 intra-URL retry).
// This contrasts with failoverBeforeRetry=true where A is hit exactly ONCE.
assert.equal(serverA.getState(TOKEN_A).hits, 2);
});
test("maxSetRetries: both A and B fail first pass, A 429 again, B 200 on retry", async () => {
assert.ok(app);
// Reset to a clean resilience slate: disable cooldowns, disable waitForCooldown,
// reset breakers, and clear connection rate_limited_until from previous tests.
const patchRes = await fetch(`${app.baseUrl}/api/resilience`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
connectionCooldown: {
apikey: { useUpstreamRetryHints: false, baseCooldownMs: 0, maxBackoffSteps: 0 },
oauth: { useUpstreamRetryHints: false, baseCooldownMs: 0, maxBackoffSteps: 0 },
},
waitForCooldown: { enabled: false, maxRetries: 0, maxRetryWaitSec: 0 },
}),
signal: AbortSignal.timeout(10_000),
});
assert.equal(patchRes.status, 200);
await resetBreakers(app.baseUrl);
resetConnectionCooldowns();
// Set try 0: A 429, B 500 — both fail
// Set try 1: A 429, B 200 — B succeeds
serverA.resetState(TOKEN_A, [buildError(429, "rate limited"), buildError(429, "rate limited")]);
serverB.resetState(TOKEN_B, [
buildError(500, "server error"),
buildCompletion("server B ok on retry", { model: "sys-b/test-model" }),
]);
const result = await postChat(app.baseUrl, "sys-priority-setretry", "test max set retries", {
"x-internal-test": "combo-health-check",
});
// Set try 0: A 429, B 500 → both fail
// Set try 1: A 429, B 200 → B succeeds
assert.equal(result.response.status, 200, JSON.stringify(result.json));
assert.equal(result.json.choices[0].message.content, "server B ok on retry");
assert.equal(result.json.model, "sys-b/test-model");
assert.equal(serverA.getState(TOKEN_A).hits, 2);
assert.equal(serverB.getState(TOKEN_B).hits, 2);
// Restore defaults so other tests are not affected
await fetch(`${app.baseUrl}/api/resilience`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
connectionCooldown: {
apikey: { useUpstreamRetryHints: false, baseCooldownMs: 200, maxBackoffSteps: 0 },
oauth: { useUpstreamRetryHints: true, baseCooldownMs: 500, maxBackoffSteps: 3 },
},
}),
signal: AbortSignal.timeout(10_000),
});
});
test("same server failoverBeforeRetry disabled: first model 429 retried before trying second", async () => {
assert.ok(app);
// Full resilience reset — previous test restored defaults and may have left A cooldown
const patchRes = await fetch(`${app.baseUrl}/api/resilience`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
connectionCooldown: {
apikey: { useUpstreamRetryHints: false, baseCooldownMs: 0, maxBackoffSteps: 0 },
oauth: { useUpstreamRetryHints: false, baseCooldownMs: 0, maxBackoffSteps: 0 },
},
waitForCooldown: { enabled: false, maxRetries: 0, maxRetryWaitSec: 0 },
}),
signal: AbortSignal.timeout(10_000),
});
assert.equal(patchRes.status, 200);
await sleep(300);
await resetBreakers(app.baseUrl);
resetConnectionCooldowns();
serverA.resetState(TOKEN_A, [buildError(429, "rate limited")]);
serverA.resetState(TOKEN_A2);
const result = await postChat(
app.baseUrl,
"sys-same-server",
"test same server failover disabled"
);
assert.equal(result.response.status, 200, JSON.stringify(result.json));
// With maxRetries=0 the combo fails over to A2 rather than retrying A.
assert.equal(result.json.model, "sys-a2/test-model");
assert.equal(serverA.getState(TOKEN_A).hits, 2);
});
test("same server failoverBeforeRetry enabled: first model 429 skipped to second immediately", async () => {
assert.ok(app);
await sleep(300);
await resetBreakers(app.baseUrl);
resetConnectionCooldowns();
serverA.resetState(TOKEN_A, [buildError(429, "rate limited")]);
serverA.resetState(TOKEN_A2);
const result = await postChat(
app.baseUrl,
"sys-same-server-fobr",
"test same server failover enabled"
);
assert.equal(result.response.status, 200, JSON.stringify(result.json));
assert.equal(result.json.model, "sys-a2/test-model");
assert.equal(serverA.getState(TOKEN_A).hits, 1);
assert.equal(serverA.getState(TOKEN_A2).hits, 1);
});
test("single provider, modelA 500: combo fails over to modelB", async () => {
assert.ok(app);
await resetBreakers(app.baseUrl);
resetConnectionCooldowns();
serverA.resetState(TOKEN_A, [buildError(500, "model A error")]);
const result = await postChat(app.baseUrl, "sys-single-provider", "test modelA 500");
assert.equal(result.response.status, 200, JSON.stringify(result.json));
assert.equal(result.json.model, "sys-a/test-model");
assert.equal(serverA.getState(TOKEN_A).hits, 2);
});
test("single provider, modelA 503: combo fails over to modelB", async () => {
assert.ok(app);
await resetBreakers(app.baseUrl);
resetConnectionCooldowns();
serverA.resetState(TOKEN_A, [buildError(503, "Service Unavailable")]);
const result = await postChat(app.baseUrl, "sys-single-provider", "test modelA 503");
assert.equal(result.response.status, 200, JSON.stringify(result.json));
assert.equal(result.json.model, "sys-a/test-model");
assert.equal(serverA.getState(TOKEN_A).hits, 2);
});
test("single provider, modelA 429 with fobr: immediate failover to modelB", async () => {
assert.ok(app);
await resetBreakers(app.baseUrl);
resetConnectionCooldowns();
serverA.resetState(TOKEN_A, [buildError(429, "rate limited")]);
const result = await postChat(app.baseUrl, "sys-single-provider-fobr", "test fobr");
assert.equal(result.response.status, 200, JSON.stringify(result.json));
assert.equal(result.json.model, "sys-a/test-model");
assert.equal(serverA.getState(TOKEN_A).hits, 2);
});
test("single provider, modelA 500 with fobr: modelA retry", async () => {
assert.ok(app);
await resetBreakers(app.baseUrl);
resetConnectionCooldowns();
serverA.resetState(TOKEN_A, [buildError(500, "Oops!")]);
const result = await postChat(app.baseUrl, "sys-single-provider-fobr", "test fobr");
assert.equal(result.response.status, 200, JSON.stringify(result.json));
assert.equal(result.json.model, "sys-a/test-model");
assert.equal(serverA.getState(TOKEN_A).hits, 2);
});
test("single provider, both models fail: request returns 5xx to client", async () => {
assert.ok(app);
await resetBreakers(app.baseUrl);
resetConnectionCooldowns();
serverA.resetState(TOKEN_A, [buildError(500, "modelA down"), buildError(500, "modelB down")]);
const result = await postChat(app.baseUrl, "sys-single-provider", "both down");
assert.ok(result.response.status >= 500, `expected 5xx, got ${result.response.status}`);
assert.equal(serverA.getState(TOKEN_A).hits, 2);
});