wraptc 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/wraptc +4 -4
- package/package.json +2 -2
- package/src/cli/__tests__/cli.test.ts +337 -0
- package/src/cli/index.ts +149 -0
- package/src/core/__tests__/fixtures/configs/project-config.json +14 -0
- package/src/core/__tests__/fixtures/configs/system-config.json +14 -0
- package/src/core/__tests__/fixtures/configs/user-config.json +15 -0
- package/src/core/__tests__/integration/integration.test.ts +241 -0
- package/src/core/__tests__/integration/mock-coder-adapter.test.ts +243 -0
- package/src/core/__tests__/test-utils.ts +136 -0
- package/src/core/__tests__/unit/adapters/runner.test.ts +302 -0
- package/src/core/__tests__/unit/basic-test.test.ts +44 -0
- package/src/core/__tests__/unit/basic.test.ts +12 -0
- package/src/core/__tests__/unit/config.test.ts +244 -0
- package/src/core/__tests__/unit/error-patterns.test.ts +181 -0
- package/src/core/__tests__/unit/memory-monitor.test.ts +354 -0
- package/src/core/__tests__/unit/plugin/registry.test.ts +356 -0
- package/src/core/__tests__/unit/providers/codex.test.ts +173 -0
- package/src/core/__tests__/unit/providers/configurable.test.ts +429 -0
- package/src/core/__tests__/unit/providers/gemini.test.ts +251 -0
- package/src/core/__tests__/unit/providers/opencode.test.ts +258 -0
- package/src/core/__tests__/unit/providers/qwen-code.test.ts +195 -0
- package/src/core/__tests__/unit/providers/simple-codex.test.ts +18 -0
- package/src/core/__tests__/unit/router.test.ts +967 -0
- package/src/core/__tests__/unit/state.test.ts +1079 -0
- package/src/core/__tests__/unit/unified/capabilities.test.ts +186 -0
- package/src/core/__tests__/unit/wrap-terminalcoder.test.ts +32 -0
- package/src/core/adapters/builtin/codex.ts +35 -0
- package/src/core/adapters/builtin/gemini.ts +34 -0
- package/src/core/adapters/builtin/index.ts +31 -0
- package/src/core/adapters/builtin/mock-coder.ts +148 -0
- package/src/core/adapters/builtin/qwen.ts +34 -0
- package/src/core/adapters/define.ts +48 -0
- package/src/core/adapters/index.ts +43 -0
- package/src/core/adapters/loader.ts +143 -0
- package/src/core/adapters/provider-bridge.ts +190 -0
- package/src/core/adapters/runner.ts +437 -0
- package/src/core/adapters/types.ts +172 -0
- package/src/core/config.ts +290 -0
- package/src/core/define-provider.ts +212 -0
- package/src/core/error-patterns.ts +147 -0
- package/src/core/index.ts +130 -0
- package/src/core/memory-monitor.ts +171 -0
- package/src/core/plugin/builtin.ts +87 -0
- package/src/core/plugin/index.ts +34 -0
- package/src/core/plugin/registry.ts +350 -0
- package/src/core/plugin/types.ts +209 -0
- package/src/core/provider-factory.ts +397 -0
- package/src/core/provider-loader.ts +171 -0
- package/src/core/providers/codex.ts +56 -0
- package/src/core/providers/configurable.ts +637 -0
- package/src/core/providers/custom.ts +261 -0
- package/src/core/providers/gemini.ts +41 -0
- package/src/core/providers/index.ts +383 -0
- package/src/core/providers/opencode.ts +168 -0
- package/src/core/providers/qwen-code.ts +41 -0
- package/src/core/router.ts +370 -0
- package/src/core/state.ts +258 -0
- package/src/core/types.ts +206 -0
- package/src/core/unified/capabilities.ts +184 -0
- package/src/core/unified/errors.ts +141 -0
- package/src/core/unified/index.ts +29 -0
- package/src/core/unified/output.ts +189 -0
- package/src/core/wrap-terminalcoder.ts +245 -0
- package/src/mcp/__tests__/server.test.ts +295 -0
- package/src/mcp/server.ts +284 -0
- package/src/test-fixtures/mock-coder.sh +194 -0
- package/dist/cli/index.js +0 -16501
- package/dist/core/index.js +0 -7531
- package/dist/mcp/server.js +0 -14568
- package/dist/wraptc-1.0.2.tgz +0 -0
|
@@ -0,0 +1,967 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import type { Provider } from "../../providers/index";
|
|
3
|
+
import { Router } from "../../router";
|
|
4
|
+
import type { StateManager } from "../../state";
|
|
5
|
+
import type { CodingRequest, ProviderErrorContext } from "../../types";
|
|
6
|
+
import type { Config } from "../../types";
|
|
7
|
+
|
|
8
|
+
// Mock Provider for testing
|
|
9
|
+
class MockProvider implements Provider {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly displayName: string;
|
|
12
|
+
readonly supportsStreaming: boolean;
|
|
13
|
+
readonly prefersJson: boolean;
|
|
14
|
+
readonly capabilities?: string[];
|
|
15
|
+
|
|
16
|
+
private shouldFail = false;
|
|
17
|
+
private shouldStream = false;
|
|
18
|
+
private failWith: Error | null = null;
|
|
19
|
+
|
|
20
|
+
constructor(id: string, displayName: string, supportsStreaming = false) {
|
|
21
|
+
this.id = id;
|
|
22
|
+
this.displayName = displayName;
|
|
23
|
+
this.supportsStreaming = supportsStreaming;
|
|
24
|
+
this.prefersJson = true;
|
|
25
|
+
this.capabilities = ["generate", "edit", "explain", "test"];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
setShouldFail(fail: boolean, error?: Error) {
|
|
29
|
+
this.shouldFail = fail;
|
|
30
|
+
this.failWith = error || new Error("Mock failure");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
setShouldStream(stream: boolean) {
|
|
34
|
+
this.shouldStream = stream;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async runOnce(req: CodingRequest, opts: any): Promise<{ text: string; usage?: any }> {
|
|
38
|
+
if (this.shouldFail && this.failWith) {
|
|
39
|
+
throw this.failWith;
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
text: `Response from ${this.id}`,
|
|
43
|
+
usage: { inputTokens: 10, outputTokens: 20 },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async *runStream(req: CodingRequest, opts: any): AsyncGenerator<any> {
|
|
48
|
+
if (this.shouldFail && this.failWith) {
|
|
49
|
+
throw this.failWith;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
yield { type: "start", provider: this.id, requestId: "req-123" };
|
|
53
|
+
yield { type: "delta", text: `Stream from ${this.id}` };
|
|
54
|
+
yield { type: "complete", provider: this.id, text: `Complete response from ${this.id}` };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
classifyError(error: ProviderErrorContext): any {
|
|
58
|
+
const stderr = error.stderr?.toLowerCase() || "";
|
|
59
|
+
|
|
60
|
+
if (stderr.includes("rate limit")) return "RATE_LIMIT";
|
|
61
|
+
if (stderr.includes("out of credits")) return "OUT_OF_CREDITS";
|
|
62
|
+
if (stderr.includes("bad request")) return "BAD_REQUEST";
|
|
63
|
+
if (stderr.includes("internal error")) return "INTERNAL";
|
|
64
|
+
|
|
65
|
+
return "TRANSIENT";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
getInfo() {
|
|
69
|
+
return {
|
|
70
|
+
id: this.id,
|
|
71
|
+
displayName: this.displayName,
|
|
72
|
+
supportsStreaming: this.supportsStreaming,
|
|
73
|
+
prefersJson: this.prefersJson,
|
|
74
|
+
capabilities: this.capabilities,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("Router", () => {
|
|
80
|
+
let router: Router;
|
|
81
|
+
let mockProviders: Map<string, MockProvider>;
|
|
82
|
+
let mockStateManager: StateManager;
|
|
83
|
+
let mockConfig: Config;
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
mockProviders = new Map();
|
|
87
|
+
|
|
88
|
+
mockStateManager = {
|
|
89
|
+
getProviderState: mock(async (providerId: string) => ({
|
|
90
|
+
requestsToday: 0,
|
|
91
|
+
lastErrors: [],
|
|
92
|
+
outOfCreditsUntil: undefined,
|
|
93
|
+
})),
|
|
94
|
+
recordSuccess: mock(async () => {}),
|
|
95
|
+
recordError: mock(async () => {}),
|
|
96
|
+
markOutOfCredits: mock(async () => {}),
|
|
97
|
+
initialize: mock(async () => {}),
|
|
98
|
+
resetProvider: mock(async () => {}),
|
|
99
|
+
resetAll: mock(async () => {}),
|
|
100
|
+
getState: mock(() => ({ version: "1.0.0", providers: {} })),
|
|
101
|
+
getStatePath: mock(() => "/test/state.json"),
|
|
102
|
+
save: mock(async () => {}),
|
|
103
|
+
} as any;
|
|
104
|
+
|
|
105
|
+
mockConfig = {
|
|
106
|
+
routing: {
|
|
107
|
+
defaultOrder: ["provider1", "provider2", "provider3"],
|
|
108
|
+
perModeOverride: {
|
|
109
|
+
test: ["provider2", "provider1"],
|
|
110
|
+
explain: ["provider3", "provider2"],
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
providers: {
|
|
114
|
+
provider1: {
|
|
115
|
+
binary: "binary1",
|
|
116
|
+
args: [],
|
|
117
|
+
jsonMode: "flag",
|
|
118
|
+
jsonFlag: "--json",
|
|
119
|
+
streamingMode: "line",
|
|
120
|
+
capabilities: ["generate", "edit", "explain", "test"],
|
|
121
|
+
},
|
|
122
|
+
provider2: {
|
|
123
|
+
binary: "binary2",
|
|
124
|
+
args: [],
|
|
125
|
+
jsonMode: "flag",
|
|
126
|
+
jsonFlag: "--json",
|
|
127
|
+
streamingMode: "line",
|
|
128
|
+
capabilities: ["generate", "edit", "explain", "test"],
|
|
129
|
+
},
|
|
130
|
+
provider3: {
|
|
131
|
+
binary: "binary3",
|
|
132
|
+
args: [],
|
|
133
|
+
jsonMode: "flag",
|
|
134
|
+
jsonFlag: "--json",
|
|
135
|
+
streamingMode: "line",
|
|
136
|
+
capabilities: ["generate", "edit", "explain", "test"],
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
credits: {
|
|
140
|
+
providers: {
|
|
141
|
+
provider1: { dailyRequestLimit: 100, resetHourUtc: 0 },
|
|
142
|
+
provider2: { dailyRequestLimit: 200, resetHourUtc: 0 },
|
|
143
|
+
provider3: { plan: "pro" },
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// Set up mock providers
|
|
149
|
+
const provider1 = new MockProvider("provider1", "Provider 1", true);
|
|
150
|
+
const provider2 = new MockProvider("provider2", "Provider 2", true);
|
|
151
|
+
const provider3 = new MockProvider("provider3", "Provider 3", false);
|
|
152
|
+
|
|
153
|
+
mockProviders.set("provider1", provider1);
|
|
154
|
+
mockProviders.set("provider2", provider2);
|
|
155
|
+
mockProviders.set("provider3", provider3);
|
|
156
|
+
|
|
157
|
+
router = new Router(mockProviders as any, {
|
|
158
|
+
config: mockConfig,
|
|
159
|
+
stateManager: mockStateManager,
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("route", () => {
|
|
164
|
+
test("should route to first available provider on success", async () => {
|
|
165
|
+
const req: CodingRequest = {
|
|
166
|
+
prompt: "Test prompt",
|
|
167
|
+
mode: "generate",
|
|
168
|
+
stream: false,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const response = await router.route(req);
|
|
172
|
+
|
|
173
|
+
expect(response.provider).toBe("provider1");
|
|
174
|
+
expect(response.text).toBe("Response from provider1");
|
|
175
|
+
expect(response.usage).toEqual({ inputTokens: 10, outputTokens: 20 });
|
|
176
|
+
expect(mockStateManager.recordSuccess).toHaveBeenCalledWith("provider1", 20);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("should use explicit provider when specified", async () => {
|
|
180
|
+
const req: CodingRequest = {
|
|
181
|
+
prompt: "Test prompt",
|
|
182
|
+
mode: "generate",
|
|
183
|
+
stream: false,
|
|
184
|
+
provider: "provider2",
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const response = await router.route(req);
|
|
188
|
+
|
|
189
|
+
expect(response.provider).toBe("provider2");
|
|
190
|
+
expect(mockStateManager.recordSuccess).toHaveBeenCalledWith("provider2", 20);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("should skip to next provider when first fails with TRANSIENT error", async () => {
|
|
194
|
+
const provider1 = mockProviders.get("provider1")!;
|
|
195
|
+
provider1.setShouldFail(true, new Error("Transient error"));
|
|
196
|
+
|
|
197
|
+
const req: CodingRequest = {
|
|
198
|
+
prompt: "Test prompt",
|
|
199
|
+
mode: "generate",
|
|
200
|
+
stream: false,
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const response = await router.route(req);
|
|
204
|
+
|
|
205
|
+
expect(response.provider).toBe("provider2"); // Should skip provider1 and use provider2
|
|
206
|
+
expect(mockStateManager.recordError).toHaveBeenCalledWith(
|
|
207
|
+
"provider1",
|
|
208
|
+
"TRANSIENT",
|
|
209
|
+
"Transient error",
|
|
210
|
+
);
|
|
211
|
+
expect(mockStateManager.recordSuccess).toHaveBeenCalledWith("provider2", 20);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("should mark provider as out of credits on OUT_OF_CREDITS error", async () => {
|
|
215
|
+
const provider1 = mockProviders.get("provider1")!;
|
|
216
|
+
provider1.setShouldFail(true, new Error("Out of credits"));
|
|
217
|
+
|
|
218
|
+
const req: CodingRequest = {
|
|
219
|
+
prompt: "Test prompt",
|
|
220
|
+
mode: "generate",
|
|
221
|
+
stream: false,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const response = await router.route(req);
|
|
225
|
+
|
|
226
|
+
expect(response.provider).toBe("provider2");
|
|
227
|
+
expect(mockStateManager.recordError).toHaveBeenCalledWith(
|
|
228
|
+
"provider1",
|
|
229
|
+
"OUT_OF_CREDITS",
|
|
230
|
+
"Out of credits",
|
|
231
|
+
);
|
|
232
|
+
expect(mockStateManager.markOutOfCredits).toHaveBeenCalledWith("provider1", expect.any(Date));
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("should mark provider as out of credits on RATE_LIMIT error", async () => {
|
|
236
|
+
const provider1 = mockProviders.get("provider1")!;
|
|
237
|
+
provider1.setShouldFail(true, new Error("Rate limit exceeded"));
|
|
238
|
+
|
|
239
|
+
const req: CodingRequest = {
|
|
240
|
+
prompt: "Test prompt",
|
|
241
|
+
mode: "generate",
|
|
242
|
+
stream: false,
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const response = await router.route(req);
|
|
246
|
+
|
|
247
|
+
expect(response.provider).toBe("provider2");
|
|
248
|
+
expect(mockStateManager.recordError).toHaveBeenCalledWith(
|
|
249
|
+
"provider1",
|
|
250
|
+
"RATE_LIMIT",
|
|
251
|
+
"Rate limit exceeded",
|
|
252
|
+
);
|
|
253
|
+
expect(mockStateManager.markOutOfCredits).toHaveBeenCalledWith("provider1", expect.any(Date));
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("should throw immediately on BAD_REQUEST error", async () => {
|
|
257
|
+
const provider1 = mockProviders.get("provider1")!;
|
|
258
|
+
provider1.setShouldFail(true, new Error("Bad request"));
|
|
259
|
+
|
|
260
|
+
const req: CodingRequest = {
|
|
261
|
+
prompt: "Test prompt",
|
|
262
|
+
mode: "generate",
|
|
263
|
+
stream: false,
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
await expect(router.route(req)).rejects.toThrow("Bad request to provider1");
|
|
267
|
+
expect(mockStateManager.recordError).toHaveBeenCalledWith(
|
|
268
|
+
"provider1",
|
|
269
|
+
"BAD_REQUEST",
|
|
270
|
+
"Bad request",
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("should check daily request limits", async () => {
|
|
275
|
+
// Mock state manager to return high request count
|
|
276
|
+
mockStateManager.getProviderState = mock(async (providerId: string) => ({
|
|
277
|
+
requestsToday: providerId === "provider1" ? 100 : 0, // provider1 at limit
|
|
278
|
+
lastErrors: [],
|
|
279
|
+
outOfCreditsUntil: undefined,
|
|
280
|
+
}));
|
|
281
|
+
|
|
282
|
+
const req: CodingRequest = {
|
|
283
|
+
prompt: "Test prompt",
|
|
284
|
+
mode: "generate",
|
|
285
|
+
stream: false,
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const response = await router.route(req);
|
|
289
|
+
|
|
290
|
+
expect(response.provider).toBe("provider2"); // Should skip provider1 due to limit
|
|
291
|
+
expect(mockStateManager.markOutOfCredits).toHaveBeenCalledWith("provider1", expect.any(Date));
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("should skip providers marked as out of credits", async () => {
|
|
295
|
+
// Mock state manager to return out of credits for provider1
|
|
296
|
+
mockStateManager.getProviderState = mock(async (providerId: string) => ({
|
|
297
|
+
requestsToday: 0,
|
|
298
|
+
lastErrors: [],
|
|
299
|
+
outOfCreditsUntil:
|
|
300
|
+
providerId === "provider1" ? new Date(Date.now() + 3600000).toISOString() : undefined,
|
|
301
|
+
}));
|
|
302
|
+
|
|
303
|
+
const req: CodingRequest = {
|
|
304
|
+
prompt: "Test prompt",
|
|
305
|
+
mode: "generate",
|
|
306
|
+
stream: false,
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const response = await router.route(req);
|
|
310
|
+
|
|
311
|
+
expect(response.provider).toBe("provider2"); // Should skip provider1
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("should throw error when no providers available", async () => {
|
|
315
|
+
// Create a new router with all providers out of credits
|
|
316
|
+
const outOfCreditsStateManager = {
|
|
317
|
+
...mockStateManager,
|
|
318
|
+
getProviderState: mock(async (providerId: string) => ({
|
|
319
|
+
requestsToday: 0,
|
|
320
|
+
lastErrors: [],
|
|
321
|
+
outOfCreditsUntil: new Date(Date.now() + 3600000).toISOString(),
|
|
322
|
+
})),
|
|
323
|
+
} as any;
|
|
324
|
+
|
|
325
|
+
const failingRouter = new Router(mockProviders as any, {
|
|
326
|
+
config: mockConfig,
|
|
327
|
+
stateManager: outOfCreditsStateManager,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const req: CodingRequest = {
|
|
331
|
+
prompt: "Test prompt",
|
|
332
|
+
mode: "generate",
|
|
333
|
+
stream: false,
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
await expect(failingRouter.route(req)).rejects.toThrow(/All \d+ providers failed/);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("should use per-mode override routing", async () => {
|
|
340
|
+
const req: CodingRequest = {
|
|
341
|
+
prompt: "Test prompt",
|
|
342
|
+
mode: "test",
|
|
343
|
+
stream: false,
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const response = await router.route(req);
|
|
347
|
+
|
|
348
|
+
expect(response.provider).toBe("provider2"); // test mode uses ["provider2", "provider1"]
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("should include timing metadata in response", async () => {
|
|
352
|
+
const req: CodingRequest = {
|
|
353
|
+
prompt: "Test prompt",
|
|
354
|
+
mode: "generate",
|
|
355
|
+
stream: false,
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const response = await router.route(req);
|
|
359
|
+
|
|
360
|
+
expect(response.meta?.elapsedMs).toBeGreaterThanOrEqual(0);
|
|
361
|
+
expect(typeof response.meta?.elapsedMs).toBe("number");
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("should handle missing provider gracefully", async () => {
|
|
365
|
+
const req: CodingRequest = {
|
|
366
|
+
prompt: "Test prompt",
|
|
367
|
+
mode: "generate",
|
|
368
|
+
stream: false,
|
|
369
|
+
provider: "nonexistent",
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
await expect(router.route(req)).rejects.toThrow("Provider nonexistent not found");
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
describe("routeStream", () => {
|
|
377
|
+
test("should route streaming request to first available provider", async () => {
|
|
378
|
+
const req: CodingRequest = {
|
|
379
|
+
prompt: "Test prompt",
|
|
380
|
+
mode: "generate",
|
|
381
|
+
stream: true,
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const events: any[] = [];
|
|
385
|
+
for await (const event of router.routeStream(req)) {
|
|
386
|
+
events.push(event);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
expect(events).toHaveLength(1);
|
|
390
|
+
expect(events[0].provider).toBe("provider1");
|
|
391
|
+
expect(events[0].text).toBe("Complete response from provider1");
|
|
392
|
+
// Mock runStream doesn't include usage data, so tokens will be 0
|
|
393
|
+
expect(mockStateManager.recordSuccess).toHaveBeenCalledWith("provider1", 0);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("should handle streaming provider failure and fallback", async () => {
|
|
397
|
+
const provider1 = mockProviders.get("provider1")!;
|
|
398
|
+
provider1.setShouldFail(true, new Error("Stream failed"));
|
|
399
|
+
|
|
400
|
+
const req: CodingRequest = {
|
|
401
|
+
prompt: "Test prompt",
|
|
402
|
+
mode: "generate",
|
|
403
|
+
stream: true,
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const events: any[] = [];
|
|
407
|
+
for await (const event of router.routeStream(req)) {
|
|
408
|
+
events.push(event);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
expect(events).toHaveLength(1);
|
|
412
|
+
expect(events[0].provider).toBe("provider2"); // Should fallback to provider2
|
|
413
|
+
expect(mockStateManager.recordError).toHaveBeenCalledWith(
|
|
414
|
+
"provider1",
|
|
415
|
+
"TRANSIENT",
|
|
416
|
+
"Stream failed",
|
|
417
|
+
);
|
|
418
|
+
// Mock runStream doesn't include usage data, so tokens will be 0
|
|
419
|
+
expect(mockStateManager.recordSuccess).toHaveBeenCalledWith("provider2", 0);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("should include timing metadata in streaming responses", async () => {
|
|
423
|
+
const req: CodingRequest = {
|
|
424
|
+
prompt: "Test prompt",
|
|
425
|
+
mode: "generate",
|
|
426
|
+
stream: true,
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const events: any[] = [];
|
|
430
|
+
for await (const event of router.routeStream(req)) {
|
|
431
|
+
events.push(event);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
expect(events[0].meta?.elapsedMs).toBeGreaterThanOrEqual(0);
|
|
435
|
+
expect(typeof events[0].meta?.elapsedMs).toBe("number");
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("should handle streaming with intermediate events", async () => {
|
|
439
|
+
// Override the mock provider to emit multiple streaming events
|
|
440
|
+
const provider1 = mockProviders.get("provider1")!;
|
|
441
|
+
provider1.runStream = async function* (req: CodingRequest, opts: any) {
|
|
442
|
+
yield { type: "start", provider: this.id, requestId: "req-123" };
|
|
443
|
+
yield { type: "delta", text: `First chunk from ${this.id}` };
|
|
444
|
+
yield { type: "delta", text: `Second chunk from ${this.id}` };
|
|
445
|
+
yield { type: "complete", provider: this.id, text: `Final response from ${this.id}` };
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const req: CodingRequest = {
|
|
449
|
+
prompt: "Test prompt",
|
|
450
|
+
mode: "generate",
|
|
451
|
+
stream: true,
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
const events: any[] = [];
|
|
455
|
+
for await (const event of router.routeStream(req)) {
|
|
456
|
+
events.push(event);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
expect(events).toHaveLength(1);
|
|
460
|
+
expect(events[0].provider).toBe("provider1");
|
|
461
|
+
expect(events[0].text).toBe("Final response from provider1");
|
|
462
|
+
// No usage data in custom runStream, so tokens will be 0
|
|
463
|
+
expect(mockStateManager.recordSuccess).toHaveBeenCalledWith("provider1", 0);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("should handle streaming error events", async () => {
|
|
467
|
+
// Override the mock provider to emit error events
|
|
468
|
+
const provider1 = mockProviders.get("provider1")!;
|
|
469
|
+
provider1.runStream = async function* (req: CodingRequest, opts: any) {
|
|
470
|
+
yield { type: "start", provider: this.id, requestId: "req-123" };
|
|
471
|
+
yield { type: "delta", text: "First chunk" };
|
|
472
|
+
yield {
|
|
473
|
+
type: "error",
|
|
474
|
+
provider: this.id,
|
|
475
|
+
code: "TRANSIENT",
|
|
476
|
+
message: "Temporary error",
|
|
477
|
+
};
|
|
478
|
+
yield { type: "complete", provider: this.id, text: "Final response" };
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const req: CodingRequest = {
|
|
482
|
+
prompt: "Test prompt",
|
|
483
|
+
mode: "generate",
|
|
484
|
+
stream: true,
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const events: any[] = [];
|
|
488
|
+
for await (const event of router.routeStream(req)) {
|
|
489
|
+
events.push(event);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
expect(events).toHaveLength(1);
|
|
493
|
+
expect(events[0].provider).toBe("provider1");
|
|
494
|
+
expect(events[0].text).toBe("Final response");
|
|
495
|
+
// No usage data in custom runStream, so tokens will be 0
|
|
496
|
+
expect(mockStateManager.recordSuccess).toHaveBeenCalledWith("provider1", 0);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test("should handle streaming provider failure with fallback to next provider", async () => {
|
|
500
|
+
// Make provider1 fail during streaming
|
|
501
|
+
const provider1 = mockProviders.get("provider1")!;
|
|
502
|
+
provider1.runStream = async function* (req: CodingRequest, opts: any) {
|
|
503
|
+
yield { type: "start", provider: this.id, requestId: "req-123" };
|
|
504
|
+
yield { type: "delta", text: "First chunk" };
|
|
505
|
+
throw new Error("Streaming error");
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const req: CodingRequest = {
|
|
509
|
+
prompt: "Test prompt",
|
|
510
|
+
mode: "generate",
|
|
511
|
+
stream: true,
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
const events: any[] = [];
|
|
515
|
+
for await (const event of router.routeStream(req)) {
|
|
516
|
+
events.push(event);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Should fallback to provider2 (which uses default mock with no usage)
|
|
520
|
+
expect(events).toHaveLength(1);
|
|
521
|
+
expect(events[0].provider).toBe("provider2");
|
|
522
|
+
expect(mockStateManager.recordError).toHaveBeenCalledWith(
|
|
523
|
+
"provider1",
|
|
524
|
+
"TRANSIENT",
|
|
525
|
+
"Streaming error",
|
|
526
|
+
);
|
|
527
|
+
// Mock runStream doesn't include usage data, so tokens will be 0
|
|
528
|
+
expect(mockStateManager.recordSuccess).toHaveBeenCalledWith("provider2", 0);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("should handle streaming with different streaming modes", async () => {
|
|
532
|
+
// Test with line mode
|
|
533
|
+
const provider1 = mockProviders.get("provider1")!;
|
|
534
|
+
provider1.runStream = async function* (req: CodingRequest, opts: any) {
|
|
535
|
+
yield { type: "start", provider: this.id, requestId: "req-123" };
|
|
536
|
+
yield { type: "chunk", text: "Line 1\n" };
|
|
537
|
+
yield { type: "chunk", text: "Line 2\n" };
|
|
538
|
+
yield { type: "chunk", text: "Line 3\n" };
|
|
539
|
+
yield { type: "complete", provider: this.id, text: "Line 1\nLine 2\nLine 3\n" };
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
const req: CodingRequest = {
|
|
543
|
+
prompt: "Test prompt",
|
|
544
|
+
mode: "generate",
|
|
545
|
+
stream: true,
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
const events: any[] = [];
|
|
549
|
+
for await (const event of router.routeStream(req)) {
|
|
550
|
+
events.push(event);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
expect(events).toHaveLength(1);
|
|
554
|
+
expect(events[0].provider).toBe("provider1");
|
|
555
|
+
expect(events[0].text).toBe("Line 1\nLine 2\nLine 3\n");
|
|
556
|
+
// No usage data in custom runStream, so tokens will be 0
|
|
557
|
+
expect(mockStateManager.recordSuccess).toHaveBeenCalledWith("provider1", 0);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test("should handle streaming with usage data", async () => {
|
|
561
|
+
// Override the mock provider to include usage data
|
|
562
|
+
const provider1 = mockProviders.get("provider1")!;
|
|
563
|
+
provider1.runStream = async function* (req: CodingRequest, opts: any) {
|
|
564
|
+
yield { type: "start", provider: this.id, requestId: "req-123" };
|
|
565
|
+
yield { type: "delta", text: "Response chunk" };
|
|
566
|
+
yield {
|
|
567
|
+
type: "complete",
|
|
568
|
+
provider: this.id,
|
|
569
|
+
text: "Complete response",
|
|
570
|
+
usage: { inputTokens: 15, outputTokens: 25 },
|
|
571
|
+
};
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
const req: CodingRequest = {
|
|
575
|
+
prompt: "Test prompt",
|
|
576
|
+
mode: "generate",
|
|
577
|
+
stream: true,
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
const events: any[] = [];
|
|
581
|
+
for await (const event of router.routeStream(req)) {
|
|
582
|
+
events.push(event);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
expect(events).toHaveLength(1);
|
|
586
|
+
expect(events[0].provider).toBe("provider1");
|
|
587
|
+
expect(events[0].text).toBe("Complete response");
|
|
588
|
+
expect(events[0].usage).toEqual({ inputTokens: 15, outputTokens: 25 });
|
|
589
|
+
expect(mockStateManager.recordSuccess).toHaveBeenCalledWith("provider1", 25);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test("should handle streaming with explicit provider", async () => {
|
|
593
|
+
const req: CodingRequest = {
|
|
594
|
+
prompt: "Test prompt",
|
|
595
|
+
mode: "generate",
|
|
596
|
+
stream: true,
|
|
597
|
+
provider: "provider2",
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
const events: any[] = [];
|
|
601
|
+
for await (const event of router.routeStream(req)) {
|
|
602
|
+
events.push(event);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
expect(events).toHaveLength(1);
|
|
606
|
+
expect(events[0].provider).toBe("provider2");
|
|
607
|
+
expect(events[0].text).toBe("Complete response from provider2");
|
|
608
|
+
// Mock runStream doesn't include usage data, so tokens will be 0
|
|
609
|
+
expect(mockStateManager.recordSuccess).toHaveBeenCalledWith("provider2", 0);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test("should handle streaming with per-mode override", async () => {
|
|
613
|
+
const req: CodingRequest = {
|
|
614
|
+
prompt: "Test prompt",
|
|
615
|
+
mode: "test", // Uses ["provider2", "provider1"] override
|
|
616
|
+
stream: true,
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
const events: any[] = [];
|
|
620
|
+
for await (const event of router.routeStream(req)) {
|
|
621
|
+
events.push(event);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
expect(events).toHaveLength(1);
|
|
625
|
+
expect(events[0].provider).toBe("provider2"); // Should use provider2 first due to override
|
|
626
|
+
expect(events[0].text).toBe("Complete response from provider2");
|
|
627
|
+
// Mock runStream doesn't include usage data, so tokens will be 0
|
|
628
|
+
expect(mockStateManager.recordSuccess).toHaveBeenCalledWith("provider2", 0);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
test("should handle streaming with out of credits provider", async () => {
|
|
632
|
+
// Mock state manager to return out of credits for provider1
|
|
633
|
+
mockStateManager.getProviderState = mock(async (providerId: string) => ({
|
|
634
|
+
requestsToday: 0,
|
|
635
|
+
lastErrors: [],
|
|
636
|
+
outOfCreditsUntil:
|
|
637
|
+
providerId === "provider1" ? new Date(Date.now() + 3600000).toISOString() : undefined,
|
|
638
|
+
}));
|
|
639
|
+
|
|
640
|
+
const req: CodingRequest = {
|
|
641
|
+
prompt: "Test prompt",
|
|
642
|
+
mode: "generate",
|
|
643
|
+
stream: true,
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
const events: any[] = [];
|
|
647
|
+
for await (const event of router.routeStream(req)) {
|
|
648
|
+
events.push(event);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
expect(events).toHaveLength(1);
|
|
652
|
+
expect(events[0].provider).toBe("provider2"); // Should skip provider1
|
|
653
|
+
expect(events[0].text).toBe("Complete response from provider2");
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
test("should handle streaming with daily request limit exceeded", async () => {
|
|
657
|
+
// Mock state manager to return high request count for provider1
|
|
658
|
+
mockStateManager.getProviderState = mock(async (providerId: string) => ({
|
|
659
|
+
requestsToday: providerId === "provider1" ? 100 : 0, // provider1 at limit
|
|
660
|
+
lastErrors: [],
|
|
661
|
+
outOfCreditsUntil: undefined,
|
|
662
|
+
}));
|
|
663
|
+
|
|
664
|
+
const req: CodingRequest = {
|
|
665
|
+
prompt: "Test prompt",
|
|
666
|
+
mode: "generate",
|
|
667
|
+
stream: true,
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
const events: any[] = [];
|
|
671
|
+
for await (const event of router.routeStream(req)) {
|
|
672
|
+
events.push(event);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
expect(events).toHaveLength(1);
|
|
676
|
+
expect(events[0].provider).toBe("provider2"); // Should skip provider1 due to limit
|
|
677
|
+
expect(mockStateManager.markOutOfCredits).toHaveBeenCalledWith("provider1", expect.any(Date));
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
test("should handle streaming with all providers failing", async () => {
|
|
681
|
+
// Make all providers fail
|
|
682
|
+
const provider1 = mockProviders.get("provider1")!;
|
|
683
|
+
const provider2 = mockProviders.get("provider2")!;
|
|
684
|
+
const provider3 = mockProviders.get("provider3")!;
|
|
685
|
+
|
|
686
|
+
provider1.setShouldFail(true, new Error("Provider1 failed"));
|
|
687
|
+
provider2.setShouldFail(true, new Error("Provider2 failed"));
|
|
688
|
+
provider3.setShouldFail(true, new Error("Provider3 failed"));
|
|
689
|
+
|
|
690
|
+
const req: CodingRequest = {
|
|
691
|
+
prompt: "Test prompt",
|
|
692
|
+
mode: "generate",
|
|
693
|
+
stream: true,
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
// Collect all events from the async iterator
|
|
697
|
+
const collectEvents = async () => {
|
|
698
|
+
const events: any[] = [];
|
|
699
|
+
for await (const event of router.routeStream(req)) {
|
|
700
|
+
events.push(event);
|
|
701
|
+
}
|
|
702
|
+
return events;
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
await expect(collectEvents()).rejects.toThrow(/All \d+ providers failed/);
|
|
706
|
+
|
|
707
|
+
expect(mockStateManager.recordError).toHaveBeenCalledWith(
|
|
708
|
+
"provider1",
|
|
709
|
+
"TRANSIENT",
|
|
710
|
+
"Provider1 failed",
|
|
711
|
+
);
|
|
712
|
+
expect(mockStateManager.recordError).toHaveBeenCalledWith(
|
|
713
|
+
"provider2",
|
|
714
|
+
"TRANSIENT",
|
|
715
|
+
"Provider2 failed",
|
|
716
|
+
);
|
|
717
|
+
expect(mockStateManager.recordError).toHaveBeenCalledWith(
|
|
718
|
+
"provider3",
|
|
719
|
+
"TRANSIENT",
|
|
720
|
+
"Provider3 failed",
|
|
721
|
+
);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
test("should handle streaming with bad request error", async () => {
|
|
725
|
+
const provider1 = mockProviders.get("provider1")!;
|
|
726
|
+
provider1.setShouldFail(true, new Error("Bad request"));
|
|
727
|
+
|
|
728
|
+
const req: CodingRequest = {
|
|
729
|
+
prompt: "Test prompt",
|
|
730
|
+
mode: "generate",
|
|
731
|
+
stream: true,
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
// Collect all events from the async iterator
|
|
735
|
+
const collectEvents = async () => {
|
|
736
|
+
const events: any[] = [];
|
|
737
|
+
for await (const event of router.routeStream(req)) {
|
|
738
|
+
events.push(event);
|
|
739
|
+
}
|
|
740
|
+
return events;
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
await expect(collectEvents()).rejects.toThrow("Bad request to provider1");
|
|
744
|
+
|
|
745
|
+
expect(mockStateManager.recordError).toHaveBeenCalledWith(
|
|
746
|
+
"provider1",
|
|
747
|
+
"BAD_REQUEST",
|
|
748
|
+
"Bad request",
|
|
749
|
+
);
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
test("should handle streaming with rate limit error", async () => {
|
|
753
|
+
const provider1 = mockProviders.get("provider1")!;
|
|
754
|
+
provider1.setShouldFail(true, new Error("Rate limit exceeded"));
|
|
755
|
+
|
|
756
|
+
const req: CodingRequest = {
|
|
757
|
+
prompt: "Test prompt",
|
|
758
|
+
mode: "generate",
|
|
759
|
+
stream: true,
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
const events: any[] = [];
|
|
763
|
+
for await (const event of router.routeStream(req)) {
|
|
764
|
+
events.push(event);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
expect(events).toHaveLength(1);
|
|
768
|
+
expect(events[0].provider).toBe("provider2"); // Should fallback to provider2
|
|
769
|
+
expect(mockStateManager.recordError).toHaveBeenCalledWith(
|
|
770
|
+
"provider1",
|
|
771
|
+
"RATE_LIMIT",
|
|
772
|
+
"Rate limit exceeded",
|
|
773
|
+
);
|
|
774
|
+
expect(mockStateManager.markOutOfCredits).toHaveBeenCalledWith("provider1", expect.any(Date));
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
test("should handle streaming with bad request error", async () => {
|
|
778
|
+
const provider1 = mockProviders.get("provider1")!;
|
|
779
|
+
provider1.setShouldFail(true, new Error("Bad request"));
|
|
780
|
+
|
|
781
|
+
const req: CodingRequest = {
|
|
782
|
+
prompt: "Test prompt",
|
|
783
|
+
mode: "generate",
|
|
784
|
+
stream: true,
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
// Collect all events from the async iterator
|
|
788
|
+
const collectEvents = async () => {
|
|
789
|
+
const events: any[] = [];
|
|
790
|
+
for await (const event of router.routeStream(req)) {
|
|
791
|
+
events.push(event);
|
|
792
|
+
}
|
|
793
|
+
return events;
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
await expect(collectEvents()).rejects.toThrow("Bad request to provider1");
|
|
797
|
+
|
|
798
|
+
expect(mockStateManager.recordError).toHaveBeenCalledWith(
|
|
799
|
+
"provider1",
|
|
800
|
+
"BAD_REQUEST",
|
|
801
|
+
"Bad request",
|
|
802
|
+
);
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
test("should handle streaming with out of credits error", async () => {
|
|
806
|
+
const provider1 = mockProviders.get("provider1")!;
|
|
807
|
+
provider1.setShouldFail(true, new Error("Out of credits"));
|
|
808
|
+
|
|
809
|
+
const req: CodingRequest = {
|
|
810
|
+
prompt: "Test prompt",
|
|
811
|
+
mode: "generate",
|
|
812
|
+
stream: true,
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
const events: any[] = [];
|
|
816
|
+
for await (const event of router.routeStream(req)) {
|
|
817
|
+
events.push(event);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
expect(events).toHaveLength(1);
|
|
821
|
+
expect(events[0].provider).toBe("provider2"); // Should fallback to provider2
|
|
822
|
+
expect(mockStateManager.recordError).toHaveBeenCalledWith(
|
|
823
|
+
"provider1",
|
|
824
|
+
"OUT_OF_CREDITS",
|
|
825
|
+
"Out of credits",
|
|
826
|
+
);
|
|
827
|
+
expect(mockStateManager.markOutOfCredits).toHaveBeenCalledWith("provider1", expect.any(Date));
|
|
828
|
+
});
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
describe("getProviderInfo", () => {
|
|
832
|
+
test("should return information for all providers", async () => {
|
|
833
|
+
const info = await (router as any).getProviderInfo();
|
|
834
|
+
|
|
835
|
+
expect(info).toHaveLength(3);
|
|
836
|
+
expect(info.map((i: any) => i.id)).toEqual(["provider1", "provider2", "provider3"]);
|
|
837
|
+
|
|
838
|
+
const provider1Info = info.find((i: any) => i.id === "provider1");
|
|
839
|
+
expect(provider1Info?.id).toBe("provider1");
|
|
840
|
+
expect(provider1Info?.displayName).toBe("Provider 1");
|
|
841
|
+
expect(provider1Info?.requestsToday).toBe(0);
|
|
842
|
+
expect(provider1Info?.outOfCreditsUntil).toBeUndefined();
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
test("should include outOfCreditsUntil in provider info", async () => {
|
|
846
|
+
// Mock state manager to return out of credits for provider1
|
|
847
|
+
mockStateManager.getProviderState = mock(async (providerId: string) => ({
|
|
848
|
+
requestsToday: 0,
|
|
849
|
+
lastErrors: [],
|
|
850
|
+
outOfCreditsUntil:
|
|
851
|
+
providerId === "provider1" ? new Date(Date.now() + 3600000).toISOString() : undefined,
|
|
852
|
+
}));
|
|
853
|
+
|
|
854
|
+
const info = await (router as any).getProviderInfo();
|
|
855
|
+
|
|
856
|
+
const provider1Info = info.find((i: any) => i.id === "provider1");
|
|
857
|
+
expect(provider1Info?.outOfCreditsUntil).toBeInstanceOf(Date);
|
|
858
|
+
});
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
describe("Error Classification", () => {
|
|
862
|
+
test("should handle different error types correctly", async () => {
|
|
863
|
+
const testCases = [
|
|
864
|
+
{ error: "Rate limit exceeded", expected: "RATE_LIMIT" },
|
|
865
|
+
{ error: "Out of credits", expected: "OUT_OF_CREDITS" },
|
|
866
|
+
{ error: "Bad request", expected: "BAD_REQUEST" },
|
|
867
|
+
{ error: "Internal server error", expected: "TRANSIENT" },
|
|
868
|
+
{ error: "Unknown error", expected: "TRANSIENT" },
|
|
869
|
+
];
|
|
870
|
+
|
|
871
|
+
for (const { error, expected } of testCases) {
|
|
872
|
+
// Clear previous calls
|
|
873
|
+
(mockStateManager.recordError as any).mockClear();
|
|
874
|
+
|
|
875
|
+
const provider1 = mockProviders.get("provider1")!;
|
|
876
|
+
provider1.setShouldFail(true, new Error(error));
|
|
877
|
+
|
|
878
|
+
const req: CodingRequest = {
|
|
879
|
+
prompt: "Test prompt",
|
|
880
|
+
mode: "generate",
|
|
881
|
+
stream: false,
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
try {
|
|
885
|
+
await router.route(req);
|
|
886
|
+
} catch {
|
|
887
|
+
// Expected to fail
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
expect(mockStateManager.recordError).toHaveBeenCalledWith("provider1", expected, error);
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
describe("Configuration Integration", () => {
|
|
896
|
+
test("should respect daily request limits", async () => {
|
|
897
|
+
// Mock state manager to return high request count for provider1
|
|
898
|
+
mockStateManager.getProviderState = mock(async (providerId: string) => ({
|
|
899
|
+
requestsToday: providerId === "provider1" ? 100 : 0,
|
|
900
|
+
lastErrors: [],
|
|
901
|
+
outOfCreditsUntil: undefined,
|
|
902
|
+
}));
|
|
903
|
+
|
|
904
|
+
const req: CodingRequest = {
|
|
905
|
+
prompt: "Test prompt",
|
|
906
|
+
mode: "generate",
|
|
907
|
+
stream: false,
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
await router.route(req);
|
|
911
|
+
|
|
912
|
+
expect(mockStateManager.markOutOfCredits).toHaveBeenCalledWith("provider1", expect.any(Date));
|
|
913
|
+
});
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
describe("Edge Cases", () => {
|
|
917
|
+
test("should handle request with null undefined mode", async () => {
|
|
918
|
+
const req: CodingRequest = {
|
|
919
|
+
prompt: "Test prompt",
|
|
920
|
+
mode: undefined as any,
|
|
921
|
+
stream: false,
|
|
922
|
+
};
|
|
923
|
+
|
|
924
|
+
const response = await router.route(req);
|
|
925
|
+
|
|
926
|
+
expect(response.provider).toBe("provider1"); // Should use default order
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
test("should handle multiple concurrent requests", async () => {
|
|
930
|
+
const req: CodingRequest = {
|
|
931
|
+
prompt: "Test prompt",
|
|
932
|
+
mode: "generate",
|
|
933
|
+
stream: false,
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
const promises = Array(5)
|
|
937
|
+
.fill(null)
|
|
938
|
+
.map(() => router.route(req));
|
|
939
|
+
const responses = await Promise.all(promises);
|
|
940
|
+
|
|
941
|
+
expect(responses).toHaveLength(5);
|
|
942
|
+
responses.forEach((response) => {
|
|
943
|
+
expect(response).toBeDefined();
|
|
944
|
+
expect(response.provider).toBe("provider1");
|
|
945
|
+
});
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
test("should handle request with AbortSignal", async () => {
|
|
949
|
+
const abortController = new AbortController();
|
|
950
|
+
|
|
951
|
+
const req: CodingRequest = {
|
|
952
|
+
prompt: "Test prompt",
|
|
953
|
+
mode: "generate",
|
|
954
|
+
stream: false,
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
const routePromise = router.route(req, { signal: abortController.signal });
|
|
958
|
+
|
|
959
|
+
// Abort immediately
|
|
960
|
+
abortController.abort();
|
|
961
|
+
|
|
962
|
+
// Should not reject since the mock doesn't handle abort signals
|
|
963
|
+
const response = await routePromise;
|
|
964
|
+
expect(response.provider).toBe("provider1");
|
|
965
|
+
});
|
|
966
|
+
});
|
|
967
|
+
});
|