Spaces:
Runtime error
Runtime error
File size: 6,029 Bytes
cd8bd0a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 | /**
* tests/integration/combo-failover-e2e.test.ts
*
* End-to-end combo routing scenarios that the existing suite left uncovered:
* 1. A 3-target priority chain that walks past TWO failing targets
* (500 then 503) to succeed on the third β the existing suite only
* exercised a 2-target (single-hop) failover.
* 2. A `strategy:"auto"` combo dispatched end-to-end (request β scored
* selection β real upstream fetch β 200), closing the gap where auto was
* only exercised at the UI layer.
* 3. A per-target timeout (targetTimeoutMs) on the first target failing over
* to a healthy second target β timeout-driven failover had zero coverage.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { createChatPipelineHarness } from "./_chatPipelineHarness.ts";
const harness = await createChatPipelineHarness("combo-failover-e2e");
const {
BaseExecutor,
buildClaudeResponse,
buildGeminiResponse,
buildOpenAIResponse,
buildRequest,
combosDb,
handleChat,
resetStorage,
seedConnection,
} = harness;
test.beforeEach(async () => {
BaseExecutor.RETRY_CONFIG.delayMs = 0;
await resetStorage();
});
test.afterEach(async () => {
BaseExecutor.RETRY_CONFIG.delayMs = harness.originalRetryDelayMs;
await resetStorage();
});
test.after(async () => {
await harness.cleanup();
});
function body(model: string, content = `Route ${model}`) {
return { model, stream: false, messages: [{ role: "user", content }] };
}
test("priority combo walks a 3-target chain: 500 β 503 β success", async () => {
await seedConnection("openai", { apiKey: "sk-openai-3way" });
await seedConnection("claude", { apiKey: "sk-claude-3way" });
await seedConnection("gemini", { apiKey: "sk-gemini-3way" });
await combosDb.createCombo({
name: "router-3way",
strategy: "priority",
config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 },
models: [
"openai/gpt-4o-mini",
"claude/claude-3-5-sonnet-20241022",
"gemini/gemini-2.5-flash",
],
});
const attempts: string[] = [];
globalThis.fetch = async (url) => {
const target = String(url);
if (target.includes("/chat/completions")) {
attempts.push("openai");
return new Response(JSON.stringify({ error: { message: "primary down" } }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
if (target.includes("?beta=true")) {
attempts.push("claude");
return new Response(JSON.stringify({ error: { message: "secondary overloaded" } }), {
status: 503,
headers: { "Content-Type": "application/json" },
});
}
attempts.push("gemini");
return buildGeminiResponse("Third target answered");
};
const res = await handleChat(buildRequest({ body: body("router-3way") }));
const json = (await res.json()) as {
choices: Array<{ message: { content: string } }>;
};
assert.equal(res.status, 200, "request must succeed on the 3rd target");
assert.deepEqual(attempts, ["openai", "claude", "gemini"], "all three targets attempted in order");
assert.equal(json.choices[0].message.content, "Third target answered");
});
test("priority combo fails over when the first target exceeds its per-target timeout", async () => {
await seedConnection("openai", { apiKey: "sk-openai-timeout" });
await seedConnection("claude", { apiKey: "sk-claude-timeout" });
await combosDb.createCombo({
name: "router-timeout",
strategy: "priority",
// 80ms per-target ceiling; the first target hangs past it and is aborted.
config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0, targetTimeoutMs: 80 },
models: ["openai/gpt-4o-mini", "claude/claude-3-5-sonnet-20241022"],
});
const attempts: string[] = [];
globalThis.fetch = async (url, init: RequestInit = {}) => {
const target = String(url);
if (target.includes("/chat/completions")) {
attempts.push("openai");
// Hang until the combo's per-target timeout aborts us via the signal.
return await new Promise<Response>((_resolve, reject) => {
const signal = init.signal;
if (signal) {
signal.addEventListener("abort", () =>
reject(Object.assign(new Error("aborted by combo timeout"), { name: "AbortError" }))
);
}
});
}
attempts.push("claude");
return buildClaudeResponse("Recovered after timeout");
};
const res = await handleChat(buildRequest({ body: body("router-timeout") }));
const json = (await res.json()) as {
choices: Array<{ message: { content: string } }>;
};
assert.equal(res.status, 200, "must fail over to the second target after the first times out");
assert.deepEqual(attempts, ["openai", "claude"]);
assert.equal(json.choices[0].message.content, "Recovered after timeout");
});
test("auto combo selects and dispatches a scored candidate end-to-end", async () => {
await seedConnection("openai", { apiKey: "sk-openai-auto" });
await seedConnection("claude", { apiKey: "sk-claude-auto" });
await combosDb.createCombo({
name: "router-auto",
strategy: "auto",
config: { maxRetries: 0, retryDelayMs: 0 },
models: ["openai/gpt-4o-mini", "claude/claude-3-5-sonnet-20241022"],
});
const seen: string[] = [];
globalThis.fetch = async (url) => {
const target = String(url);
if (target.includes("?beta=true")) {
seen.push("claude");
return buildClaudeResponse("Auto chose claude");
}
seen.push("openai");
return buildOpenAIResponse("Auto chose openai");
};
const res = await handleChat(buildRequest({ body: body("router-auto") }));
const json = (await res.json()) as {
choices: Array<{ message: { content: string } }>;
};
assert.equal(res.status, 200, "auto combo must dispatch successfully");
assert.equal(seen.length, 1, "auto selects exactly one target (no needless fan-out)");
assert.match(json.choices[0].message.content, /Auto chose (openai|claude)/);
});
|