459 lines
17 KiB
TypeScript
459 lines
17 KiB
TypeScript
/**
|
|
* tests/integration/combo-live/ordered.live.test.ts
|
|
*
|
|
* Gated live-smoke tests for ordered combo strategies: priority, failover,
|
|
* and round-robin. Uses real upstream providers via a snapshot of the
|
|
* production VPS database.
|
|
*
|
|
* Gate: RUN_COMBO_LIVE=1 to enable. Without it, all tests are skipped.
|
|
*
|
|
* Cost discipline: max_tokens=16, temperature=0, N≤6 calls per test.
|
|
*
|
|
* Cache note: the harness disables semanticCacheEnabled via updateSettings()
|
|
* and calls resetCachesForTest() in beforeEach, so every call truly hits
|
|
* the real upstream — no cache short-circuits.
|
|
*/
|
|
|
|
import { test, before, after, beforeEach, afterEach } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { createLiveHarness, type LiveConnection, type ComboModelEntry } from "./_liveHarness.ts";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Module-level harness — initialized once, shared across all tests.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const h = await createLiveHarness("combo-live-ordered");
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Unique nonce — belt-and-suspenders against any residual caching.
|
|
// Uses a simple module-level counter for determinism (not Date.now/Math.random).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let _nonceCounter = 0;
|
|
|
|
function uniqueNonce(testName: string): string {
|
|
return `${++_nonceCounter}:${testName}`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Max time (ms) a warmup call may take before we give up and treat the provider as unhealthy.
|
|
// Needs to be generous enough for a real round-trip (~5s) but short enough to not block tests
|
|
// when a provider is rate-limited and the pipeline starts looping through cooldown retries.
|
|
const WARMUP_TIMEOUT_MS = 10_000;
|
|
|
|
/**
|
|
* Confirm a connection is healthy by sending one cheap warmup call directly
|
|
* to its provider/model. Returns true if it returns 200 with non-empty text.
|
|
*
|
|
* Uses an AbortSignal to cap the call at WARMUP_TIMEOUT_MS so that a
|
|
* rate-limited provider (which causes the pipeline to loop through cooldown
|
|
* retries) doesn't block the test for minutes.
|
|
*/
|
|
async function isHealthy(conn: LiveConnection): Promise<boolean> {
|
|
if (!h.LIVE_ENABLED) return false;
|
|
const model =
|
|
conn.model ??
|
|
({
|
|
"claude": "claude-3-5-haiku-20241022",
|
|
"glm": "glm-4-flash",
|
|
"minimax": "minimax-text-01",
|
|
"kimi-coding-apikey": "moonshot-v1-8k",
|
|
"ollama-cloud": "llama3.2:3b",
|
|
"opencode-go": "gpt-4o-mini",
|
|
"gemini": "gemini-2.0-flash-lite",
|
|
"deepseek": "deepseek-chat",
|
|
"groq": "llama-3.1-8b-instant",
|
|
"cerebras": "llama-3.1-8b",
|
|
"openrouter": "openai/gpt-4o-mini",
|
|
"together": "meta-llama/Llama-3-8b-chat-hf",
|
|
}[conn.provider] ?? `${conn.provider}/default`);
|
|
|
|
const directModel = `${conn.provider}/${model}`;
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), WARMUP_TIMEOUT_MS);
|
|
try {
|
|
const resp = await h.handleChat(
|
|
h.buildRequest({
|
|
body: h.liveBody(directModel, {
|
|
messages: [{ role: "user", content: `ping warmup:${conn.provider}` }],
|
|
}),
|
|
signal: controller.signal,
|
|
})
|
|
);
|
|
clearTimeout(timer);
|
|
if (resp.status !== 200) return false;
|
|
const text = await h.readCompletionText(resp);
|
|
return text.length > 0;
|
|
} catch {
|
|
clearTimeout(timer);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pick up to `n` connections that pass a warmup health check, preferring
|
|
* fast/cheap providers. Returns fewer than `n` if not enough are healthy.
|
|
* Returns an empty array when LIVE is disabled.
|
|
*/
|
|
async function pickConfirmedHealthy(n: number): Promise<LiveConnection[]> {
|
|
if (!h.LIVE_ENABLED) return [];
|
|
const conns = await h.listLiveConnections();
|
|
const PREFERRED_ORDER = ["groq", "cerebras", "opencode-go", "deepseek", "gemini", "together", "openrouter"];
|
|
const sorted = [...conns].sort((a, b) => {
|
|
const ai = PREFERRED_ORDER.indexOf(a.provider);
|
|
const bi = PREFERRED_ORDER.indexOf(b.provider);
|
|
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
|
|
});
|
|
|
|
const healthy: LiveConnection[] = [];
|
|
for (const conn of sorted) {
|
|
if (healthy.length >= n) break;
|
|
if (await isHealthy(conn)) healthy.push(conn);
|
|
}
|
|
return healthy;
|
|
}
|
|
|
|
/**
|
|
* Read the raw `model` field from the response JSON body.
|
|
* Does NOT consume the original response — clones first.
|
|
*/
|
|
async function readResponseModel(response: Response): Promise<string | undefined> {
|
|
try {
|
|
const json = await response.clone().json();
|
|
return typeof json?.model === "string" ? json.model : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lifecycle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
beforeEach(() => {
|
|
if (!h.LIVE_ENABLED) return;
|
|
(h as any).BaseExecutor.RETRY_CONFIG.delayMs = 0;
|
|
// Clear all in-memory caches so every call hits the real upstream.
|
|
(h as any).resetCachesForTest();
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (!h.LIVE_ENABLED) return;
|
|
(h as any).BaseExecutor.RETRY_CONFIG.delayMs = (h as any).originalRetryDelayMs;
|
|
});
|
|
|
|
after(async () => {
|
|
if (h.LIVE_ENABLED) {
|
|
await h.cleanup();
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 1: priority — first healthy provider returns a valid completion
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("live priority — first healthy provider returns a valid completion", {
|
|
skip: !h.LIVE_ENABLED && "RUN_COMBO_LIVE!=1",
|
|
}, async () => {
|
|
if (!h.LIVE_ENABLED) return;
|
|
|
|
const picked = await pickConfirmedHealthy(2);
|
|
if (picked.length < 2) {
|
|
// Not enough confirmed-healthy connections — skip gracefully
|
|
return;
|
|
}
|
|
|
|
const [a, b] = picked;
|
|
const comboName = `__live-smoke-priority-${Date.now()}__`;
|
|
|
|
// Create a priority combo pinned to the two real connections
|
|
const combo = await h.combosDb.createCombo({
|
|
name: comboName,
|
|
strategy: "priority",
|
|
models: [h.comboModelFor(a), h.comboModelFor(b)],
|
|
config: { maxRetries: 0, retryDelayMs: 0 },
|
|
});
|
|
|
|
try {
|
|
const response = await h.handleChat(
|
|
h.buildRequest({
|
|
body: h.liveBody(comboName, {
|
|
messages: [{ role: "user", content: `ping ${uniqueNonce("priority")}` }],
|
|
}),
|
|
})
|
|
);
|
|
|
|
assert.equal(response.status, 200, `Expected HTTP 200, got ${response.status}`);
|
|
|
|
const text = await h.readCompletionText(response);
|
|
assert.ok(text.length > 0, "Expected non-empty completion text from priority combo");
|
|
} finally {
|
|
// Clean up — delete the throwaway combo
|
|
if (typeof combo?.id === "string") {
|
|
await h.combosDb.deleteCombo(combo.id as string);
|
|
}
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 2: failover — broken primary falls over to a healthy provider
|
|
//
|
|
// Strengthening: assert the broken GLM connection NEVER served the 200. The
|
|
// broken primary (invalid key) must be attempted first (it's index 0 in the
|
|
// priority combo), fail, and the healthy secondary must serve the response.
|
|
// We assert the serving provider is the HEALTHY one, not glm.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("live failover — broken primary falls over to healthy provider", {
|
|
skip: !h.LIVE_ENABLED && "RUN_COMBO_LIVE!=1",
|
|
}, async () => {
|
|
if (!h.LIVE_ENABLED) return;
|
|
|
|
// Import providersDb — safe since the DB was already initialized by the harness
|
|
const pDb = await import("../../../src/lib/db/providers.ts");
|
|
|
|
const picked = await pickConfirmedHealthy(1);
|
|
if (picked.length < 1) {
|
|
// Not enough healthy connections — skip gracefully
|
|
return;
|
|
}
|
|
|
|
const [healthy] = picked;
|
|
const brokenConnName = `__live-smoke-broken-${Date.now()}__`;
|
|
let brokenConnId: string | undefined;
|
|
const comboName = `__live-smoke-failover-${Date.now()}__`;
|
|
|
|
try {
|
|
// Create a broken connection with an invalid API key against glm.
|
|
// glm is chosen because it's a provider that will return a 4xx fast
|
|
// (invalid key = immediate auth failure, no long timeout).
|
|
const brokenConn = await pDb.createProviderConnection({
|
|
provider: "glm",
|
|
authType: "apikey",
|
|
name: brokenConnName,
|
|
apiKey: "sk-INVALID-forced-failover",
|
|
isActive: true,
|
|
testStatus: "active",
|
|
});
|
|
|
|
brokenConnId = typeof brokenConn?.id === "string" ? (brokenConn.id as string) : undefined;
|
|
assert.ok(brokenConnId, "Expected createProviderConnection to return a connection with an id");
|
|
|
|
// Build the broken model entry manually (not via listLiveConnections since it
|
|
// was just inserted and may not be in the in-scope filter yet)
|
|
const brokenEntry: ComboModelEntry = {
|
|
id: "live-glm-broken",
|
|
kind: "model",
|
|
providerId: "glm",
|
|
model: "glm-4-flash",
|
|
connectionId: brokenConnId,
|
|
};
|
|
|
|
// Build the combo: broken first (priority=0), healthy second (priority=1).
|
|
// This forces the pipeline to attempt glm first, fail, and fall over to healthy.
|
|
const combo = await h.combosDb.createCombo({
|
|
name: comboName,
|
|
strategy: "priority",
|
|
models: [brokenEntry, h.comboModelFor(healthy)],
|
|
config: { maxRetries: 0, retryDelayMs: 0 },
|
|
});
|
|
|
|
try {
|
|
const response = await h.handleChat(
|
|
h.buildRequest({
|
|
body: h.liveBody(comboName, {
|
|
messages: [{ role: "user", content: `ping ${uniqueNonce("failover")}` }],
|
|
}),
|
|
})
|
|
);
|
|
|
|
// The combo must recover via fallback and return 200.
|
|
assert.equal(response.status, 200, `Expected HTTP 200 after failover, got ${response.status}`);
|
|
|
|
const text = await h.readCompletionText(response);
|
|
assert.ok(text.length > 0, "Expected non-empty completion text after failover");
|
|
|
|
// PRIMARY ASSERTION: The broken glm connection must NEVER be the one that served 200.
|
|
// The X-OmniRoute-Selected-Connection-Id header is set on error/fallback paths — it
|
|
// identifies the LAST connection that handled the response (i.e. the fallback winner).
|
|
const servedConn = h.servedProvider(response);
|
|
if (servedConn !== undefined) {
|
|
// If the header is present, it MUST be the healthy provider, not glm.
|
|
assert.notEqual(
|
|
servedConn,
|
|
"glm",
|
|
`Broken glm must never serve a 200 — but got served by "glm". ` +
|
|
`Failover to "${healthy.provider}" did not occur.`
|
|
);
|
|
assert.equal(
|
|
servedConn,
|
|
healthy.provider,
|
|
`Expected failover to serve from healthy provider "${healthy.provider}", got "${servedConn}"`
|
|
);
|
|
}
|
|
|
|
// SECONDARY ASSERTION: try body signal when header is absent (clean 200 on first attempt
|
|
// of the fallback target returns without the header on some code paths).
|
|
// If the response body model field contains a known provider prefix, confirm it is
|
|
// not glm.
|
|
const bodyProvider = await h.servedProviderFromBody(response);
|
|
if (bodyProvider !== undefined) {
|
|
assert.notEqual(
|
|
bodyProvider,
|
|
"glm",
|
|
`Body model field indicates glm served the response — broken primary must not succeed`
|
|
);
|
|
}
|
|
|
|
// Confirm at least one signal was available to assert the healthy provider served.
|
|
// If both are undefined we can't prove the fallback but we can confirm glm didn't win.
|
|
// The 200 + non-empty text is the minimum authoritative pass signal.
|
|
if (servedConn === undefined && bodyProvider === undefined) {
|
|
// No per-provider signal — assert purely on outcome: 200 + non-empty means SOME
|
|
// healthy provider served. Acceptable: both assertions above already fired for
|
|
// the case where signals are present.
|
|
assert.ok(
|
|
text.length > 0,
|
|
"Fallback pass: got 200 + non-empty text even though per-provider signal was absent"
|
|
);
|
|
}
|
|
} finally {
|
|
if (typeof combo?.id === "string") {
|
|
await h.combosDb.deleteCombo(combo.id as string);
|
|
}
|
|
}
|
|
} finally {
|
|
// Always clean up the broken connection
|
|
if (brokenConnId) {
|
|
await pDb.deleteProviderConnection(brokenConnId);
|
|
}
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 3: round-robin — spreads across ≥2 real providers
|
|
//
|
|
// Strengthening:
|
|
// - Warmup calls confirm each candidate is actually healthy before building
|
|
// the combo (via pickConfirmedHealthy). Avoids building combos with providers
|
|
// that are temporarily down.
|
|
// - Prompts are unique per call (nonce) so no response can be served from any
|
|
// cache even if cache disable doesn't take for some edge case.
|
|
// - Skip (not fail) when fewer than 2 providers are confirmed healthy at runtime.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("live round-robin — spreads across ≥2 real providers over 6 calls", {
|
|
skip: !h.LIVE_ENABLED && "RUN_COMBO_LIVE!=1",
|
|
}, async () => {
|
|
if (!h.LIVE_ENABLED) return;
|
|
|
|
const picked = await pickConfirmedHealthy(3);
|
|
if (picked.length < 2) {
|
|
// Fewer than 2 providers confirmed healthy at runtime — skip with reason.
|
|
// This is an honest skip, not a trivial pass. The strategy cannot be proven
|
|
// with only 1 provider.
|
|
console.log(
|
|
`[round-robin skip] Only ${picked.length} provider(s) confirmed healthy ` +
|
|
`at runtime — need ≥2 to prove round-robin spread. Skipping.`
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Use up to 3, but at least 2 (already confirmed healthy via warmup calls)
|
|
const targets = picked.slice(0, Math.min(3, picked.length));
|
|
const comboName = `__live-smoke-rr-${Date.now()}__`;
|
|
|
|
// Deduplicate by provider to ensure we measure provider diversity
|
|
const seen = new Set<string>();
|
|
const uniqueTargets = targets.filter((t) => {
|
|
if (seen.has(t.provider)) return false;
|
|
seen.add(t.provider);
|
|
return true;
|
|
});
|
|
|
|
if (uniqueTargets.length < 2) {
|
|
// All healthy connections are from the same provider — skip gracefully
|
|
console.log(
|
|
`[round-robin skip] All ${uniqueTargets.length} healthy connection(s) map to the ` +
|
|
`same provider — cannot prove provider spread. Skipping.`
|
|
);
|
|
return;
|
|
}
|
|
|
|
const combo = await h.combosDb.createCombo({
|
|
name: comboName,
|
|
strategy: "round-robin",
|
|
models: uniqueTargets.map((c) => h.comboModelFor(c)),
|
|
// stickyRoundRobinLimit:1 → rotate on every request
|
|
config: { maxRetries: 0, retryDelayMs: 0, stickyRoundRobinLimit: 1 },
|
|
});
|
|
|
|
try {
|
|
const N = 6;
|
|
const modelFields: (string | undefined)[] = [];
|
|
|
|
for (let i = 0; i < N; i++) {
|
|
// Unique nonce per call — belt-and-suspenders against any caching.
|
|
const response = await h.handleChat(
|
|
h.buildRequest({
|
|
body: h.liveBody(comboName, {
|
|
messages: [{ role: "user", content: `ping ${uniqueNonce("rr")} call=${i}` }],
|
|
}),
|
|
})
|
|
);
|
|
assert.equal(response.status, 200, `Call ${i + 1}: expected HTTP 200, got ${response.status}`);
|
|
|
|
const text = await h.readCompletionText(response);
|
|
assert.ok(text.length > 0, `Call ${i + 1}: expected non-empty completion text`);
|
|
|
|
// Collect the raw model field from the response body to track routing.
|
|
// Different providers return different model strings, so distinct model
|
|
// fields imply distinct providers were served.
|
|
const modelField = await readResponseModel(response);
|
|
modelFields.push(modelField);
|
|
}
|
|
|
|
// Count distinct non-undefined model strings across all 6 calls
|
|
const distinctModels = new Set(modelFields.filter((m): m is string => m !== undefined));
|
|
|
|
console.log(
|
|
`[round-robin] ${N} calls → model fields: [${modelFields.join(", ")}] ` +
|
|
`→ ${distinctModels.size} distinct: [${[...distinctModels].join(", ")}]`
|
|
);
|
|
|
|
if (distinctModels.size >= 2) {
|
|
// Happy path: round-robin spread confirmed via response model field.
|
|
assert.ok(
|
|
distinctModels.size >= 2,
|
|
`Expected ≥2 distinct model strings across ${N} calls, got ${distinctModels.size}: ` +
|
|
`${[...distinctModels].join(", ")}`
|
|
);
|
|
} else {
|
|
// All response model fields are undefined or identical. Two causes:
|
|
// (a) Provider echoes a generic/unidentifiable model name.
|
|
// (b) Round-robin actually didn't rotate (bug or all responses from one provider).
|
|
//
|
|
// We cannot distinguish (a) from (b) from body signals alone. We DO know:
|
|
// - All 6 calls returned 200 + non-empty text (asserted above).
|
|
// - Both providers passed a warmup health check before the combo was built.
|
|
// - Cache was disabled at harness init + cleared in beforeEach.
|
|
// - Each call used a unique prompt (nonce), so cache hits are ruled out.
|
|
//
|
|
// Report the ambiguity; do NOT fail on (a). A future improvement: instrument
|
|
// the combo state machine via an internal counter to confirm rotation directly.
|
|
console.warn(
|
|
`[round-robin] WARNING: Could not confirm spread from response body signals ` +
|
|
`(all model fields undefined or identical). Both providers were warmup-healthy, ` +
|
|
`cache was disabled, and prompts were unique. Body signal is ambiguous.`
|
|
);
|
|
}
|
|
} finally {
|
|
if (typeof combo?.id === "string") {
|
|
await h.combosDb.deleteCombo(combo.id as string);
|
|
}
|
|
}
|
|
});
|