OmniRoute/tests/unit/combo-same-provider-cascade.test.ts
Diego Rodrigues de Sa e Souza 8f431d9004
fix(sse): scope model-not-found 404 to the model, not the connection (#15633, #15671) (#15723)
* fix(sse): scope model-not-found 404 to the model, not the connection (#15633, #15671)

* test(combo): split the #3200 cascade guard from the #15633 model-scoped 404 contract (#15633)

The #3200 guard relied on a 'model not found' 404 cooling the whole connection.
Since #15633 that wording only locks the model, so the guard now uses an
endpoint-level 404 (still a connection cooldown, still exactly one dispatch) and a
new companion test pins the model-404 contract: each same-provider model is
dispatched at most once and the combo falls back to the next provider.
2026-10-08 01:03:00 -03:00

108 lines
4.1 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import { normalizeHeaders } from "../../open-sse/utils/headers.ts";
import { createChatPipelineHarness } from "../integration/_chatPipelineHarness.ts";
/**
* Guard for the same-provider cascade (issue #3200): when a combo has SEVERAL
* targets from the same provider and that provider fails, the combo must NOT
* cascade through every same-provider target before falling back.
*
* Finding (TDD investigation of PRs #3145 / #3169, 2026-06): the EXISTING
* connection-cooldown already prevents this. After the FIRST same-provider
* failure (404 here, also 5xx) the connection is marked unavailable, so every
* remaining same-provider target is pre-screened out before dispatch — the
* provider is hit exactly ONCE, then the combo falls back to a different
* provider. This is stronger than PR #3145's provider-level counter (which would
* try the provider twice before short-circuiting) and does not need the parallel
* cooldown cache PR #3169 proposed. This test locks that behavior in so a future
* change can't silently regress the cascade guard.
*/
const harness = await createChatPipelineHarness("combo-same-provider-cascade");
const {
buildClaudeResponse,
buildRequest,
combosDb,
handleChat,
resetStorage,
seedConnection,
settingsDb,
} = harness;
test.beforeEach(async () => {
await resetStorage();
});
test.afterEach(async () => {
await resetStorage();
});
test.after(async () => {
await harness.cleanup();
});
test("combo hits a failing provider only once before falling back across same-provider targets (#3200)", async () => {
await seedConnection("openai", { apiKey: "sk-openai-cascade" });
await seedConnection("claude", { apiKey: "sk-claude-cascade" });
await settingsDb.updateSettings({ requestRetry: 0, maxRetryIntervalSec: 0 });
// Three openai targets followed by one claude target. All openai models route
// to the single seeded openai connection (same provider string).
await combosDb.createCombo({
name: "same-provider-cascade-combo",
strategy: "priority",
config: { maxRetries: 0, retryDelayMs: 0 },
models: ["openai/o3-mini", "openai/o1-mini", "openai/gpt-4.1-mini", "claude/claude-sonnet-4.6"],
});
let openaiCalls = 0;
let claudeCalls = 0;
globalThis.fetch = async (_url, init = {}) => {
const headers = normalizeHeaders(init.headers);
const authHeader = headers.authorization ?? headers.Authorization;
const apiKeyHeader = headers["x-api-key"] ?? headers["X-Api-Key"];
if (authHeader === "Bearer sk-openai-cascade") {
openaiCalls += 1;
// Endpoint-level 404 (no model wording) → whole-connection cooldown, so the
// remaining same-provider targets are pre-screened out (#3200). A model-scoped
// `model not found` 404 only locks that model since #15633 — that cascade is
// guarded separately in combo-same-provider-model-404-15633.test.ts.
return new Response(JSON.stringify({ error: { message: "404 page not found" } }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
if (apiKeyHeader === "sk-claude-cascade" || authHeader === "Bearer sk-claude-cascade") {
claudeCalls += 1;
return buildClaudeResponse("claude handled the fallback");
}
throw new Error(`unexpected upstream headers: ${JSON.stringify(headers)}`);
};
const response = await handleChat(
buildRequest({
body: {
model: "same-provider-cascade-combo",
stream: false,
messages: [{ role: "user", content: "cascade request" }],
},
})
);
const body = (await response.json()) as any;
assert.equal(response.status, 200);
assert.equal(body.choices[0].message.content, "claude handled the fallback");
// The connection cools down on the first failure, so the 2nd and 3rd openai
// targets are pre-screened out — the provider is dispatched to exactly once.
assert.equal(
openaiCalls,
1,
`expected the failing provider to be hit once then short-circuited by connection cooldown, got ${openaiCalls} calls`
);
assert.equal(claudeCalls, 1, "claude must serve the request after the cascade is cut short");
});