From d3422c1c4d1fdf795b4333cb4789c733738e0db3 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 5 Jun 2026 02:50:32 -0300 Subject: [PATCH] test(combo): guard same-provider cascade is short-circuited by connection cooldown (#3200) (#3194) Integrated into release/v3.8.11 --- .../unit/combo-same-provider-cascade.test.ts | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 tests/unit/combo-same-provider-cascade.test.ts diff --git a/tests/unit/combo-same-provider-cascade.test.ts b/tests/unit/combo-same-provider-cascade.test.ts new file mode 100644 index 0000000000..29263d3780 --- /dev/null +++ b/tests/unit/combo-same-provider-cascade.test.ts @@ -0,0 +1,116 @@ +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-3-5-sonnet-20241022", + ], + }); + + 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; + // 404 → per-model lockout (NOT whole-connection cooldown), so the openai + // connection stays usable and the next same-provider model is attempted. + // This is the cascade #3200 describes; #3145's consecutive-failure tracking + // is what cuts it short (5xx is already handled by connection cooldown). + return new Response(JSON.stringify({ error: { message: "model 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"); +});