Spaces:
Runtime error
Runtime error
| import test from "node:test"; | |
| import assert from "node:assert/strict"; | |
| import { createChatPipelineHarness } from "./_chatPipelineHarness.ts"; | |
| const harness = await createChatPipelineHarness("combo-routing"); | |
| const callLogs = await import("../../src/lib/usage/callLogs.ts"); | |
| const { | |
| BaseExecutor, | |
| buildClaudeResponse, | |
| buildGeminiResponse, | |
| buildOpenAIResponse, | |
| buildRequest, | |
| combosDb, | |
| handleChat, | |
| modelComboMappingsDb, | |
| resetStorage, | |
| seedConnection, | |
| toPlainHeaders, | |
| waitFor, | |
| } = 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 buildOpenAIChatBody(model, content = `Route ${model}`) { | |
| return { | |
| model, | |
| stream: false, | |
| messages: [{ role: "user", content }], | |
| }; | |
| } | |
| test("combo routes requests by exact combo name", async () => { | |
| await seedConnection("openai", { apiKey: "sk-openai-combo-exact" }); | |
| await combosDb.createCombo({ | |
| name: "router-priority", | |
| strategy: "priority", | |
| models: ["openai/gpt-4o-mini"], | |
| }); | |
| const fetchCalls = []; | |
| globalThis.fetch = async (url, init = {}) => { | |
| fetchCalls.push({ | |
| url: String(url), | |
| headers: toPlainHeaders(init.headers), | |
| }); | |
| return buildOpenAIResponse("Exact combo route"); | |
| }; | |
| const response = await handleChat( | |
| buildRequest({ | |
| body: buildOpenAIChatBody("router-priority"), | |
| }) | |
| ); | |
| const json = (await response.json()) as any; | |
| assert.equal(response.status, 200); | |
| assert.equal(fetchCalls.length, 1); | |
| assert.match(fetchCalls[0].url, /\/chat\/completions$/); | |
| assert.equal(fetchCalls[0].headers.authorization, "Bearer sk-openai-combo-exact"); | |
| assert.equal(json.choices[0].message.content, "Exact combo route"); | |
| }); | |
| test("round-robin combo cycles through three providers", async () => { | |
| await seedConnection("openai", { apiKey: "sk-openai-rr" }); | |
| await seedConnection("claude", { apiKey: "sk-claude-rr" }); | |
| await seedConnection("gemini", { apiKey: "sk-gemini-rr" }); | |
| await combosDb.createCombo({ | |
| name: "router-rr", | |
| strategy: "round-robin", | |
| config: { maxRetries: 0, retryDelayMs: 0 }, | |
| models: ["openai/gpt-4o-mini", "claude/claude-3-5-sonnet-20241022", "gemini/gemini-2.5-flash"], | |
| }); | |
| const seenProviders = []; | |
| globalThis.fetch = async (url) => { | |
| const target = String(url); | |
| if (target.includes("/chat/completions")) { | |
| seenProviders.push("openai"); | |
| return buildOpenAIResponse("OpenAI round-robin"); | |
| } | |
| if (target.includes("?beta=true")) { | |
| seenProviders.push("claude"); | |
| return buildClaudeResponse("Claude round-robin"); | |
| } | |
| seenProviders.push("gemini"); | |
| return buildGeminiResponse("Gemini round-robin"); | |
| }; | |
| // `stickyRoundRobinLimit` defaults to 3 (src/lib/db/settings.ts), so a combo using the | |
| // round-robin strategy serves 3 consecutive requests per target before advancing to the | |
| // next one — batched rotation, not one-request-per-target. Nine requests therefore cover | |
| // a full cycle: openai x3 -> claude x3 -> gemini x3. | |
| const responses = []; | |
| for (let i = 0; i < 9; i++) { | |
| responses.push(await handleChat(buildRequest({ body: buildOpenAIChatBody("router-rr") }))); | |
| } | |
| for (const response of responses) { | |
| assert.equal(response.status, 200); | |
| } | |
| assert.deepEqual(seenProviders, [ | |
| "openai", | |
| "openai", | |
| "openai", | |
| "claude", | |
| "claude", | |
| "claude", | |
| "gemini", | |
| "gemini", | |
| "gemini", | |
| ]); | |
| }); | |
| test("priority combo sticks to the primary model while healthy", async () => { | |
| await seedConnection("openai", { apiKey: "sk-openai-priority" }); | |
| await seedConnection("claude", { apiKey: "sk-claude-priority" }); | |
| await combosDb.createCombo({ | |
| name: "router-priority-healthy", | |
| strategy: "priority", | |
| models: ["openai/gpt-4o-mini", "claude/claude-3-5-sonnet-20241022"], | |
| }); | |
| const seenTargets = []; | |
| globalThis.fetch = async (url) => { | |
| seenTargets.push(String(url)); | |
| return buildOpenAIResponse("Primary stayed active"); | |
| }; | |
| const first = await handleChat( | |
| buildRequest({ | |
| body: buildOpenAIChatBody("router-priority-healthy", "Route priority first"), | |
| }) | |
| ); | |
| const second = await handleChat( | |
| buildRequest({ | |
| body: buildOpenAIChatBody("router-priority-healthy", "Route priority second"), | |
| }) | |
| ); | |
| assert.equal(first.status, 200); | |
| assert.equal(second.status, 200); | |
| assert.equal(seenTargets.length, 2); | |
| assert.ok(seenTargets.every((target) => target.includes("/chat/completions"))); | |
| }); | |
| test("priority combo falls back to the secondary model when the first one fails", async () => { | |
| await seedConnection("openai", { apiKey: "sk-openai-fallback" }); | |
| await seedConnection("claude", { apiKey: "sk-claude-fallback" }); | |
| await combosDb.createCombo({ | |
| name: "router-fallback", | |
| strategy: "priority", | |
| config: { maxRetries: 0, retryDelayMs: 0 }, | |
| models: ["openai/gpt-4o-mini", "claude/claude-3-5-sonnet-20241022"], | |
| }); | |
| const attempts = []; | |
| globalThis.fetch = async (url) => { | |
| const target = String(url); | |
| attempts.push(target); | |
| if (attempts.length === 1) { | |
| return new Response(JSON.stringify({ error: { message: "primary down" } }), { | |
| status: 503, | |
| headers: { "Content-Type": "application/json" }, | |
| }); | |
| } | |
| return buildClaudeResponse("Fallback answered"); | |
| }; | |
| const response = await handleChat(buildRequest({ body: buildOpenAIChatBody("router-fallback") })); | |
| const json = (await response.json()) as any; | |
| assert.equal(response.status, 200); | |
| assert.equal(attempts.length, 2); | |
| assert.match(attempts[0], /\/chat\/completions$/); | |
| assert.match(attempts[1], /\?beta=true$/); | |
| assert.equal(json.choices[0].message.content, "Fallback answered"); | |
| }); | |
| test("priority combo can repeat the same provider/model with different fixed accounts", async () => { | |
| const firstConn = await seedConnection("openai", { | |
| name: "openai-fixed-1", | |
| apiKey: "sk-openai-fixed-1", | |
| }); | |
| const secondConn = await seedConnection("openai", { | |
| name: "openai-fixed-2", | |
| apiKey: "sk-openai-fixed-2", | |
| }); | |
| assert.notEqual(firstConn.id, secondConn.id); | |
| await combosDb.createCombo({ | |
| name: "router-fixed-accounts", | |
| strategy: "priority", | |
| config: { maxRetries: 0, retryDelayMs: 0 }, | |
| models: [ | |
| { | |
| id: "step-openai-primary", | |
| kind: "model", | |
| providerId: "openai", | |
| model: "gpt-4o-mini", | |
| connectionId: firstConn.id, | |
| }, | |
| { | |
| id: "step-openai-secondary", | |
| kind: "model", | |
| providerId: "openai", | |
| model: "gpt-4o-mini", | |
| connectionId: secondConn.id, | |
| }, | |
| ], | |
| }); | |
| const authHeaders = []; | |
| let firstAttemptHeader = null; | |
| globalThis.fetch = async (_url, init = {}) => { | |
| const headers = toPlainHeaders(init.headers); | |
| authHeaders.push(headers.authorization); | |
| if (!firstAttemptHeader) { | |
| firstAttemptHeader = headers.authorization; | |
| return new Response(JSON.stringify({ error: { message: "first account down" } }), { | |
| status: 503, | |
| headers: { "Content-Type": "application/json" }, | |
| }); | |
| } | |
| return buildOpenAIResponse("Second fixed account answered"); | |
| }; | |
| const response = await handleChat( | |
| buildRequest({ | |
| body: buildOpenAIChatBody("router-fixed-accounts"), | |
| }) | |
| ); | |
| const json = (await response.json()) as any; | |
| assert.equal(response.status, 200); | |
| assert.equal(authHeaders.length, 2); | |
| assert.notEqual(authHeaders[0], authHeaders[1]); | |
| assert.deepEqual( | |
| new Set(authHeaders), | |
| new Set(["Bearer sk-openai-fixed-1", "Bearer sk-openai-fixed-2"]) | |
| ); | |
| assert.equal(json.choices[0].message.content, "Second fixed account answered"); | |
| const comboLogs = await waitFor(async () => { | |
| const logs = await callLogs.getCallLogs({ combo: true, limit: 10 }); | |
| return logs.length >= 2 ? logs : null; | |
| }); | |
| assert.ok(comboLogs, "expected combo call logs to be persisted"); | |
| const targetLogs = comboLogs | |
| .filter((entry) => entry.comboName === "router-fixed-accounts") | |
| .sort((left, right) => (left.timestamp || "").localeCompare(right.timestamp || "")); | |
| assert.equal(targetLogs.length, 2); | |
| assert.deepEqual( | |
| targetLogs.map((entry) => ({ | |
| comboStepId: entry.comboStepId, | |
| comboExecutionKey: entry.comboExecutionKey, | |
| status: entry.status, | |
| })), | |
| [ | |
| { | |
| comboStepId: "step-openai-primary", | |
| comboExecutionKey: "step-openai-primary", | |
| status: 503, | |
| }, | |
| { | |
| comboStepId: "step-openai-secondary", | |
| comboExecutionKey: "step-openai-secondary", | |
| status: 200, | |
| }, | |
| ] | |
| ); | |
| }); | |
| test("model combo mappings route explicit model ids through the configured combo", async () => { | |
| await seedConnection("openai", { apiKey: "sk-openai-mapped" }); | |
| const combo = await combosDb.createCombo({ | |
| name: "mapped-router", | |
| strategy: "priority", | |
| models: ["openai/gpt-4o-mini"], | |
| }); | |
| await modelComboMappingsDb.createModelComboMapping({ | |
| pattern: "tenant/mapped-model", | |
| comboId: combo.id, | |
| priority: 100, | |
| }); | |
| const fetchCalls = []; | |
| globalThis.fetch = async (url, init = {}) => { | |
| fetchCalls.push({ | |
| url: String(url), | |
| headers: toPlainHeaders(init.headers), | |
| }); | |
| return buildOpenAIResponse("Mapped combo route"); | |
| }; | |
| const response = await handleChat( | |
| buildRequest({ | |
| body: buildOpenAIChatBody("tenant/mapped-model"), | |
| }) | |
| ); | |
| const json = (await response.json()) as any; | |
| assert.equal(response.status, 200); | |
| assert.equal(fetchCalls.length, 1); | |
| assert.equal(fetchCalls[0].headers.authorization, "Bearer sk-openai-mapped"); | |
| assert.equal(json.choices[0].message.content, "Mapped combo route"); | |
| }); | |
| test("wildcard model combo mappings resolve arbitrary matching models", async () => { | |
| await seedConnection("gemini", { apiKey: "sk-gemini-wild" }); | |
| const combo = await combosDb.createCombo({ | |
| name: "wild-router", | |
| strategy: "priority", | |
| models: ["gemini/gemini-2.5-flash"], | |
| }); | |
| await modelComboMappingsDb.createModelComboMapping({ | |
| pattern: "tenant/*", | |
| comboId: combo.id, | |
| priority: 10, | |
| }); | |
| const fetchCalls = []; | |
| globalThis.fetch = async (url, init = {}) => { | |
| fetchCalls.push({ | |
| url: String(url), | |
| headers: toPlainHeaders(init.headers), | |
| }); | |
| return buildGeminiResponse("Wildcard combo route"); | |
| }; | |
| const response = await handleChat( | |
| buildRequest({ | |
| body: buildOpenAIChatBody("tenant/any-model-name"), | |
| }) | |
| ); | |
| const json = (await response.json()) as any; | |
| assert.equal(response.status, 200); | |
| assert.equal(fetchCalls.length, 1); | |
| assert.match(fetchCalls[0].url, /generateContent$/); | |
| assert.equal(fetchCalls[0].headers["x-goog-api-key"], "sk-gemini-wild"); | |
| assert.equal(json.choices[0].message.content, "Wildcard combo route"); | |
| }); | |
| test("unmapped custom model requests fail after combo resolution falls through", async () => { | |
| const response = await handleChat( | |
| buildRequest({ | |
| body: buildOpenAIChatBody("tenant/unmapped-model"), | |
| }) | |
| ); | |
| const json = (await response.json()) as any; | |
| // Upstream port decolua/9router#336: 400 → 404 so combo routing can fall through | |
| // to the next target when a provider has zero usable credentials. | |
| assert.equal(response.status, 404); | |
| assert.match(json.error.message, /No active credentials for provider: tenant/); | |
| }); | |
| test("strategy updates take effect for later requests on the same combo name", async () => { | |
| await seedConnection("openai", { apiKey: "sk-openai-update" }); | |
| await seedConnection("claude", { apiKey: "sk-claude-update" }); | |
| const combo = await combosDb.createCombo({ | |
| name: "router-dynamic", | |
| strategy: "priority", | |
| models: ["openai/gpt-4o-mini", "claude/claude-3-5-sonnet-20241022"], | |
| }); | |
| const seenProviders = []; | |
| globalThis.fetch = async (url) => { | |
| const target = String(url); | |
| if (target.includes("?beta=true")) { | |
| seenProviders.push("claude"); | |
| return buildClaudeResponse("Claude after update"); | |
| } | |
| seenProviders.push("openai"); | |
| return buildOpenAIResponse("OpenAI before update"); | |
| }; | |
| const initial = await handleChat( | |
| buildRequest({ | |
| body: buildOpenAIChatBody("router-dynamic", "Route dynamic initial"), | |
| }) | |
| ); | |
| await combosDb.updateCombo((combo as any).id, { | |
| strategy: "round-robin", | |
| config: { maxRetries: 0, retryDelayMs: 0 }, | |
| }); | |
| // After the strategy flips to round-robin the new behavior must take effect. With the | |
| // default stickyRoundRobinLimit of 3 the round-robin batch serves openai three times | |
| // before advancing, so the 4th post-update request rotates to claude — a provider the | |
| // previous "priority" strategy would never have reached. That rotation is the proof the | |
| // updated strategy is live. | |
| const postUpdate = []; | |
| for (let i = 0; i < 4; i++) { | |
| postUpdate.push( | |
| await handleChat( | |
| buildRequest({ body: buildOpenAIChatBody("router-dynamic", `Route dynamic ${i}`) }) | |
| ) | |
| ); | |
| } | |
| assert.equal(initial.status, 200); | |
| for (const response of postUpdate) { | |
| assert.equal(response.status, 200); | |
| } | |
| assert.deepEqual(seenProviders, ["openai", "openai", "openai", "openai", "claude"]); | |
| }); | |