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)/);
});