universal-llm-client 4.2.0 → 4.5.0
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/CHANGELOG.md +142 -103
- package/LICENSE +21 -21
- package/README.md +640 -591
- package/dist/ai-model.d.ts +12 -1
- package/dist/ai-model.d.ts.map +1 -1
- package/dist/ai-model.js +36 -1
- package/dist/ai-model.js.map +1 -1
- package/dist/gemma-channel.d.ts +14 -0
- package/dist/gemma-channel.d.ts.map +1 -0
- package/dist/gemma-channel.js +38 -0
- package/dist/gemma-channel.js.map +1 -0
- package/dist/gemma-diffusion.d.ts +49 -0
- package/dist/gemma-diffusion.d.ts.map +1 -0
- package/dist/gemma-diffusion.js +147 -0
- package/dist/gemma-diffusion.js.map +1 -0
- package/dist/http.d.ts +4 -0
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +14 -1
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/interfaces.d.ts +183 -7
- package/dist/interfaces.d.ts.map +1 -1
- package/dist/interfaces.js.map +1 -1
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +28 -3
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/google.d.ts +22 -1
- package/dist/providers/google.d.ts.map +1 -1
- package/dist/providers/google.js +225 -13
- package/dist/providers/google.js.map +1 -1
- package/dist/providers/ollama.d.ts +2 -0
- package/dist/providers/ollama.d.ts.map +1 -1
- package/dist/providers/ollama.js +59 -30
- package/dist/providers/ollama.js.map +1 -1
- package/dist/providers/openai.d.ts +14 -0
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +200 -22
- package/dist/providers/openai.js.map +1 -1
- package/dist/router.d.ts +2 -0
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +4 -0
- package/dist/router.js.map +1 -1
- package/dist/stream-decoder.d.ts +12 -0
- package/dist/stream-decoder.d.ts.map +1 -1
- package/dist/stream-decoder.js +182 -5
- package/dist/stream-decoder.js.map +1 -1
- package/dist/thinking.d.ts +36 -0
- package/dist/thinking.d.ts.map +1 -0
- package/dist/thinking.js +52 -0
- package/dist/thinking.js.map +1 -0
- package/package.json +118 -116
- package/src/ai-model.ts +400 -350
- package/src/auditor.ts +213 -213
- package/src/client.ts +402 -402
- package/src/debug/debug-google-streaming.ts +1 -1
- package/src/demos/basic/universal-llm-examples.ts +3 -3
- package/src/demos/diffusion-gemma/.env +29 -0
- package/src/demos/diffusion-gemma/.env.example +27 -0
- package/src/demos/diffusion-gemma/CLAUDE.md +95 -0
- package/src/demos/diffusion-gemma/README.md +59 -0
- package/src/demos/diffusion-gemma/canvas.ts +1606 -0
- package/src/demos/diffusion-gemma/docker-compose.yml +29 -0
- package/src/demos/diffusion-gemma/probe-stream.ts +51 -0
- package/src/demos/diffusion-gemma/probe-tools.ts +55 -0
- package/src/demos/diffusion-gemma/server.ts +1205 -0
- package/src/demos/diffusion-gemma/start-vllm.sh +98 -0
- package/src/gemma-channel.ts +47 -0
- package/src/gemma-diffusion.ts +167 -0
- package/src/http.ts +261 -247
- package/src/index.ts +180 -161
- package/src/interfaces.ts +843 -657
- package/src/mcp.ts +345 -345
- package/src/providers/anthropic.ts +796 -762
- package/src/providers/google.ts +840 -620
- package/src/providers/index.ts +8 -8
- package/src/providers/ollama.ts +503 -469
- package/src/providers/openai.ts +587 -392
- package/src/router.ts +785 -780
- package/src/stream-decoder.ts +535 -361
- package/src/structured-output.ts +759 -759
- package/src/test-scripts/test-google-deep-research.ts +33 -0
- package/src/test-scripts/test-google-streaming-enhanced.ts +147 -147
- package/src/test-scripts/test-google-streaming.ts +1 -1
- package/src/test-scripts/test-google-system-prompt-comprehensive.ts +189 -189
- package/src/test-scripts/test-google-thinking.ts +46 -0
- package/src/test-scripts/test-system-message-positions.ts +163 -163
- package/src/test-scripts/test-system-prompt-improvement-demo.ts +83 -83
- package/src/test-scripts/test-vllm-qwen36.ts +256 -0
- package/src/tests/ai-model.test.ts +1614 -1614
- package/src/tests/auditor.test.ts +224 -224
- package/src/tests/gemma-diffusion.test.ts +115 -0
- package/src/tests/http.test.ts +200 -200
- package/src/tests/interfaces.test.ts +117 -117
- package/src/tests/providers/anthropic.test.ts +118 -0
- package/src/tests/providers/google.test.ts +841 -660
- package/src/tests/providers/ollama.test.ts +1034 -954
- package/src/tests/providers/openai.test.ts +1511 -1122
- package/src/tests/router.test.ts +254 -254
- package/src/tests/stream-decoder.test.ts +263 -179
- package/src/tests/structured-output.test.ts +1450 -1450
- package/src/tests/thinking.test.ts +65 -0
- package/src/tests/tools.test.ts +175 -175
- package/src/thinking.ts +73 -0
- package/src/tools.ts +246 -246
- package/src/zod-adapter.ts +72 -72
package/src/tests/router.test.ts
CHANGED
|
@@ -1,254 +1,254 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for router.ts — Failover Engine
|
|
3
|
-
*/
|
|
4
|
-
import { describe, it, expect, mock, beforeEach } from 'bun:test';
|
|
5
|
-
import { Router, type ProviderEntry } from '../router.js';
|
|
6
|
-
import { BaseLLMClient } from '../client.js';
|
|
7
|
-
import { BufferedAuditor } from '../auditor.js';
|
|
8
|
-
import type {
|
|
9
|
-
LLMChatMessage,
|
|
10
|
-
LLMChatResponse,
|
|
11
|
-
ChatOptions,
|
|
12
|
-
DecodedEvent,
|
|
13
|
-
} from '../interfaces.js';
|
|
14
|
-
|
|
15
|
-
// ============================================================================
|
|
16
|
-
// Mock Client
|
|
17
|
-
// ============================================================================
|
|
18
|
-
|
|
19
|
-
class MockClient extends BaseLLMClient {
|
|
20
|
-
public chatFn: (messages: LLMChatMessage[]) => Promise<LLMChatResponse>;
|
|
21
|
-
public embedFn: (text: string) => Promise<number[]>;
|
|
22
|
-
public modelsFn: () => Promise<string[]>;
|
|
23
|
-
|
|
24
|
-
constructor(id: string, opts?: {
|
|
25
|
-
chatFn?: (messages: LLMChatMessage[]) => Promise<LLMChatResponse>;
|
|
26
|
-
embedFn?: (text: string) => Promise<number[]>;
|
|
27
|
-
}) {
|
|
28
|
-
super({
|
|
29
|
-
model: `mock-${id}`,
|
|
30
|
-
url: `http://mock-${id}`,
|
|
31
|
-
apiType: 'openai' as never,
|
|
32
|
-
});
|
|
33
|
-
this.chatFn = opts?.chatFn ?? (async () => ({
|
|
34
|
-
message: { role: 'assistant' as const, content: `Response from ${id}` },
|
|
35
|
-
provider: id,
|
|
36
|
-
}));
|
|
37
|
-
this.embedFn = opts?.embedFn ?? (async () => [1, 2, 3]);
|
|
38
|
-
this.modelsFn = async () => [`mock-${id}`];
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
async chat(messages: LLMChatMessage[]): Promise<LLMChatResponse> {
|
|
42
|
-
return this.chatFn(messages);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
async *chatStream(): AsyncGenerator<DecodedEvent, LLMChatResponse | void, unknown> {
|
|
46
|
-
yield { type: 'text', content: 'streamed' };
|
|
47
|
-
return { message: { role: 'assistant', content: 'streamed' }, provider: 'mock' };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async getModels(): Promise<string[]> {
|
|
51
|
-
return this.modelsFn();
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
async embed(text: string): Promise<number[]> {
|
|
55
|
-
return this.embedFn(text);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// ============================================================================
|
|
60
|
-
// Tests
|
|
61
|
-
// ============================================================================
|
|
62
|
-
|
|
63
|
-
describe('Router', () => {
|
|
64
|
-
let router: Router;
|
|
65
|
-
let auditor: BufferedAuditor;
|
|
66
|
-
|
|
67
|
-
beforeEach(() => {
|
|
68
|
-
auditor = new BufferedAuditor();
|
|
69
|
-
router = new Router({ auditor, retriesPerProvider: 1, maxFailures: 2, cooldownMs: 100 });
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
describe('provider management', () => {
|
|
73
|
-
it('adds providers and sorts by priority', () => {
|
|
74
|
-
const clientA = new MockClient('a');
|
|
75
|
-
const clientB = new MockClient('b');
|
|
76
|
-
|
|
77
|
-
router.addProvider({ id: 'a', client: clientA, priority: 2 });
|
|
78
|
-
router.addProvider({ id: 'b', client: clientB, priority: 1 });
|
|
79
|
-
|
|
80
|
-
const status = router.getStatus();
|
|
81
|
-
expect(status).toHaveLength(2);
|
|
82
|
-
// b has lower priority number = tried first
|
|
83
|
-
expect(status[0]!.id).toBe('b');
|
|
84
|
-
expect(status[1]!.id).toBe('a');
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it('removes providers', () => {
|
|
88
|
-
router.addProvider({ id: 'a', client: new MockClient('a'), priority: 0 });
|
|
89
|
-
router.addProvider({ id: 'b', client: new MockClient('b'), priority: 1 });
|
|
90
|
-
router.removeProvider('a');
|
|
91
|
-
|
|
92
|
-
expect(router.getStatus()).toHaveLength(1);
|
|
93
|
-
expect(router.getStatus()[0]!.id).toBe('b');
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
describe('execution with failover', () => {
|
|
98
|
-
it('uses the highest-priority provider', async () => {
|
|
99
|
-
const clientA = new MockClient('a');
|
|
100
|
-
const clientB = new MockClient('b');
|
|
101
|
-
|
|
102
|
-
router.addProvider({ id: 'a', client: clientA, priority: 0 });
|
|
103
|
-
router.addProvider({ id: 'b', client: clientB, priority: 1 });
|
|
104
|
-
|
|
105
|
-
const result = await router.chat([{ role: 'user', content: 'test' }]);
|
|
106
|
-
expect(result.provider).toBe('a');
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it('fails over to next provider on error', async () => {
|
|
110
|
-
const clientA = new MockClient('a', {
|
|
111
|
-
chatFn: async () => { throw new Error('A failed'); },
|
|
112
|
-
});
|
|
113
|
-
const clientB = new MockClient('b');
|
|
114
|
-
|
|
115
|
-
router.addProvider({ id: 'a', client: clientA, priority: 0 });
|
|
116
|
-
router.addProvider({ id: 'b', client: clientB, priority: 1 });
|
|
117
|
-
|
|
118
|
-
const result = await router.chat([{ role: 'user', content: 'test' }]);
|
|
119
|
-
expect(result.provider).toBe('b');
|
|
120
|
-
|
|
121
|
-
// Check audit events
|
|
122
|
-
const events = auditor.getEvents();
|
|
123
|
-
const failoverEvents = events.filter(e => e.type === 'failover');
|
|
124
|
-
expect(failoverEvents.length).toBeGreaterThan(0);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it('retries within a provider before failover', async () => {
|
|
128
|
-
let attempts = 0;
|
|
129
|
-
const clientA = new MockClient('a', {
|
|
130
|
-
chatFn: async () => {
|
|
131
|
-
attempts++;
|
|
132
|
-
if (attempts === 1) throw new Error('Transient failure');
|
|
133
|
-
return {
|
|
134
|
-
message: { role: 'assistant' as const, content: 'recovered' },
|
|
135
|
-
provider: 'a',
|
|
136
|
-
};
|
|
137
|
-
},
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
router.addProvider({ id: 'a', client: clientA, priority: 0 });
|
|
141
|
-
const result = await router.chat([{ role: 'user', content: 'test' }]);
|
|
142
|
-
|
|
143
|
-
expect(result.provider).toBe('a');
|
|
144
|
-
expect(attempts).toBe(2); // first attempt + 1 retry
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it('throws when all providers fail', async () => {
|
|
148
|
-
const clientA = new MockClient('a', {
|
|
149
|
-
chatFn: async () => { throw new Error('A failed'); },
|
|
150
|
-
});
|
|
151
|
-
const clientB = new MockClient('b', {
|
|
152
|
-
chatFn: async () => { throw new Error('B failed'); },
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
router.addProvider({ id: 'a', client: clientA, priority: 0 });
|
|
156
|
-
router.addProvider({ id: 'b', client: clientB, priority: 1 });
|
|
157
|
-
|
|
158
|
-
expect(router.chat([{ role: 'user', content: 'test' }])).rejects.toThrow();
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it('throws when no providers configured', async () => {
|
|
162
|
-
expect(router.chat([{ role: 'user', content: 'test' }])).rejects.toThrow(
|
|
163
|
-
'No available LLM providers'
|
|
164
|
-
);
|
|
165
|
-
});
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
describe('health tracking', () => {
|
|
169
|
-
it('marks unhealthy after max failures', async () => {
|
|
170
|
-
const failing = new MockClient('fail', {
|
|
171
|
-
chatFn: async () => { throw new Error('always fails'); },
|
|
172
|
-
});
|
|
173
|
-
const backup = new MockClient('backup');
|
|
174
|
-
|
|
175
|
-
router.addProvider({ id: 'fail', client: failing, priority: 0 });
|
|
176
|
-
router.addProvider({ id: 'backup', client: backup, priority: 1 });
|
|
177
|
-
|
|
178
|
-
// Each chat call records 1 failure for 'fail', maxFailures is 2
|
|
179
|
-
await router.chat([{ role: 'user', content: 'test 1' }]);
|
|
180
|
-
await router.chat([{ role: 'user', content: 'test 2' }]);
|
|
181
|
-
|
|
182
|
-
const status = router.getStatus();
|
|
183
|
-
const failStatus = status.find(s => s.id === 'fail');
|
|
184
|
-
expect(failStatus!.healthy).toBe(false);
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it('recovers after cooldown expires', async () => {
|
|
188
|
-
let callCount = 0;
|
|
189
|
-
const failing = new MockClient('fail', {
|
|
190
|
-
chatFn: async () => {
|
|
191
|
-
callCount++;
|
|
192
|
-
if (callCount <= 4) throw new Error('failing');
|
|
193
|
-
return {
|
|
194
|
-
message: { role: 'assistant' as const, content: 'recovered' },
|
|
195
|
-
provider: 'fail',
|
|
196
|
-
};
|
|
197
|
-
},
|
|
198
|
-
});
|
|
199
|
-
const backup = new MockClient('backup');
|
|
200
|
-
|
|
201
|
-
router.addProvider({ id: 'fail', client: failing, priority: 0 });
|
|
202
|
-
router.addProvider({ id: 'backup', client: backup, priority: 1 });
|
|
203
|
-
|
|
204
|
-
// First call: fail → backup
|
|
205
|
-
await router.chat([{ role: 'user', content: '1' }]);
|
|
206
|
-
|
|
207
|
-
// Wait for cooldown (100ms in test config)
|
|
208
|
-
await new Promise(r => setTimeout(r, 150));
|
|
209
|
-
|
|
210
|
-
// After cooldown, fail should be tried again
|
|
211
|
-
const status = router.getStatus();
|
|
212
|
-
const failStatus = status.find(s => s.id === 'fail');
|
|
213
|
-
expect(failStatus!.healthy).toBe(true);
|
|
214
|
-
});
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
describe('tool registration', () => {
|
|
218
|
-
it('broadcasts tool registration to all providers', () => {
|
|
219
|
-
const clientA = new MockClient('a');
|
|
220
|
-
const clientB = new MockClient('b');
|
|
221
|
-
|
|
222
|
-
router.addProvider({ id: 'a', client: clientA, priority: 0 });
|
|
223
|
-
router.addProvider({ id: 'b', client: clientB, priority: 1 });
|
|
224
|
-
|
|
225
|
-
router.registerTool(
|
|
226
|
-
'test_tool',
|
|
227
|
-
'A test tool',
|
|
228
|
-
{ type: 'object', properties: {} },
|
|
229
|
-
async () => 'result',
|
|
230
|
-
);
|
|
231
|
-
|
|
232
|
-
// Both clients should have the tool registered
|
|
233
|
-
const defsA = clientA.getToolDefinitions();
|
|
234
|
-
const defsB = clientB.getToolDefinitions();
|
|
235
|
-
expect(defsA).toHaveLength(1);
|
|
236
|
-
expect(defsB).toHaveLength(1);
|
|
237
|
-
expect(defsA[0]!.function.name).toBe('test_tool');
|
|
238
|
-
});
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
describe('model aggregation', () => {
|
|
242
|
-
it('aggregates models from all providers', async () => {
|
|
243
|
-
const clientA = new MockClient('a');
|
|
244
|
-
const clientB = new MockClient('b');
|
|
245
|
-
|
|
246
|
-
router.addProvider({ id: 'a', client: clientA, priority: 0 });
|
|
247
|
-
router.addProvider({ id: 'b', client: clientB, priority: 1 });
|
|
248
|
-
|
|
249
|
-
const models = await router.getModels();
|
|
250
|
-
expect(models).toContain('mock-a');
|
|
251
|
-
expect(models).toContain('mock-b');
|
|
252
|
-
});
|
|
253
|
-
});
|
|
254
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* Tests for router.ts — Failover Engine
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, mock, beforeEach } from 'bun:test';
|
|
5
|
+
import { Router, type ProviderEntry } from '../router.js';
|
|
6
|
+
import { BaseLLMClient } from '../client.js';
|
|
7
|
+
import { BufferedAuditor } from '../auditor.js';
|
|
8
|
+
import type {
|
|
9
|
+
LLMChatMessage,
|
|
10
|
+
LLMChatResponse,
|
|
11
|
+
ChatOptions,
|
|
12
|
+
DecodedEvent,
|
|
13
|
+
} from '../interfaces.js';
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Mock Client
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
class MockClient extends BaseLLMClient {
|
|
20
|
+
public chatFn: (messages: LLMChatMessage[]) => Promise<LLMChatResponse>;
|
|
21
|
+
public embedFn: (text: string) => Promise<number[]>;
|
|
22
|
+
public modelsFn: () => Promise<string[]>;
|
|
23
|
+
|
|
24
|
+
constructor(id: string, opts?: {
|
|
25
|
+
chatFn?: (messages: LLMChatMessage[]) => Promise<LLMChatResponse>;
|
|
26
|
+
embedFn?: (text: string) => Promise<number[]>;
|
|
27
|
+
}) {
|
|
28
|
+
super({
|
|
29
|
+
model: `mock-${id}`,
|
|
30
|
+
url: `http://mock-${id}`,
|
|
31
|
+
apiType: 'openai' as never,
|
|
32
|
+
});
|
|
33
|
+
this.chatFn = opts?.chatFn ?? (async () => ({
|
|
34
|
+
message: { role: 'assistant' as const, content: `Response from ${id}` },
|
|
35
|
+
provider: id,
|
|
36
|
+
}));
|
|
37
|
+
this.embedFn = opts?.embedFn ?? (async () => [1, 2, 3]);
|
|
38
|
+
this.modelsFn = async () => [`mock-${id}`];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async chat(messages: LLMChatMessage[]): Promise<LLMChatResponse> {
|
|
42
|
+
return this.chatFn(messages);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async *chatStream(): AsyncGenerator<DecodedEvent, LLMChatResponse | void, unknown> {
|
|
46
|
+
yield { type: 'text', content: 'streamed' };
|
|
47
|
+
return { message: { role: 'assistant', content: 'streamed' }, provider: 'mock' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async getModels(): Promise<string[]> {
|
|
51
|
+
return this.modelsFn();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async embed(text: string): Promise<number[]> {
|
|
55
|
+
return this.embedFn(text);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ============================================================================
|
|
60
|
+
// Tests
|
|
61
|
+
// ============================================================================
|
|
62
|
+
|
|
63
|
+
describe('Router', () => {
|
|
64
|
+
let router: Router;
|
|
65
|
+
let auditor: BufferedAuditor;
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
auditor = new BufferedAuditor();
|
|
69
|
+
router = new Router({ auditor, retriesPerProvider: 1, maxFailures: 2, cooldownMs: 100 });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('provider management', () => {
|
|
73
|
+
it('adds providers and sorts by priority', () => {
|
|
74
|
+
const clientA = new MockClient('a');
|
|
75
|
+
const clientB = new MockClient('b');
|
|
76
|
+
|
|
77
|
+
router.addProvider({ id: 'a', client: clientA, priority: 2 });
|
|
78
|
+
router.addProvider({ id: 'b', client: clientB, priority: 1 });
|
|
79
|
+
|
|
80
|
+
const status = router.getStatus();
|
|
81
|
+
expect(status).toHaveLength(2);
|
|
82
|
+
// b has lower priority number = tried first
|
|
83
|
+
expect(status[0]!.id).toBe('b');
|
|
84
|
+
expect(status[1]!.id).toBe('a');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('removes providers', () => {
|
|
88
|
+
router.addProvider({ id: 'a', client: new MockClient('a'), priority: 0 });
|
|
89
|
+
router.addProvider({ id: 'b', client: new MockClient('b'), priority: 1 });
|
|
90
|
+
router.removeProvider('a');
|
|
91
|
+
|
|
92
|
+
expect(router.getStatus()).toHaveLength(1);
|
|
93
|
+
expect(router.getStatus()[0]!.id).toBe('b');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('execution with failover', () => {
|
|
98
|
+
it('uses the highest-priority provider', async () => {
|
|
99
|
+
const clientA = new MockClient('a');
|
|
100
|
+
const clientB = new MockClient('b');
|
|
101
|
+
|
|
102
|
+
router.addProvider({ id: 'a', client: clientA, priority: 0 });
|
|
103
|
+
router.addProvider({ id: 'b', client: clientB, priority: 1 });
|
|
104
|
+
|
|
105
|
+
const result = await router.chat([{ role: 'user', content: 'test' }]);
|
|
106
|
+
expect(result.provider).toBe('a');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('fails over to next provider on error', async () => {
|
|
110
|
+
const clientA = new MockClient('a', {
|
|
111
|
+
chatFn: async () => { throw new Error('A failed'); },
|
|
112
|
+
});
|
|
113
|
+
const clientB = new MockClient('b');
|
|
114
|
+
|
|
115
|
+
router.addProvider({ id: 'a', client: clientA, priority: 0 });
|
|
116
|
+
router.addProvider({ id: 'b', client: clientB, priority: 1 });
|
|
117
|
+
|
|
118
|
+
const result = await router.chat([{ role: 'user', content: 'test' }]);
|
|
119
|
+
expect(result.provider).toBe('b');
|
|
120
|
+
|
|
121
|
+
// Check audit events
|
|
122
|
+
const events = auditor.getEvents();
|
|
123
|
+
const failoverEvents = events.filter(e => e.type === 'failover');
|
|
124
|
+
expect(failoverEvents.length).toBeGreaterThan(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('retries within a provider before failover', async () => {
|
|
128
|
+
let attempts = 0;
|
|
129
|
+
const clientA = new MockClient('a', {
|
|
130
|
+
chatFn: async () => {
|
|
131
|
+
attempts++;
|
|
132
|
+
if (attempts === 1) throw new Error('Transient failure');
|
|
133
|
+
return {
|
|
134
|
+
message: { role: 'assistant' as const, content: 'recovered' },
|
|
135
|
+
provider: 'a',
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
router.addProvider({ id: 'a', client: clientA, priority: 0 });
|
|
141
|
+
const result = await router.chat([{ role: 'user', content: 'test' }]);
|
|
142
|
+
|
|
143
|
+
expect(result.provider).toBe('a');
|
|
144
|
+
expect(attempts).toBe(2); // first attempt + 1 retry
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('throws when all providers fail', async () => {
|
|
148
|
+
const clientA = new MockClient('a', {
|
|
149
|
+
chatFn: async () => { throw new Error('A failed'); },
|
|
150
|
+
});
|
|
151
|
+
const clientB = new MockClient('b', {
|
|
152
|
+
chatFn: async () => { throw new Error('B failed'); },
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
router.addProvider({ id: 'a', client: clientA, priority: 0 });
|
|
156
|
+
router.addProvider({ id: 'b', client: clientB, priority: 1 });
|
|
157
|
+
|
|
158
|
+
expect(router.chat([{ role: 'user', content: 'test' }])).rejects.toThrow();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('throws when no providers configured', async () => {
|
|
162
|
+
expect(router.chat([{ role: 'user', content: 'test' }])).rejects.toThrow(
|
|
163
|
+
'No available LLM providers'
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('health tracking', () => {
|
|
169
|
+
it('marks unhealthy after max failures', async () => {
|
|
170
|
+
const failing = new MockClient('fail', {
|
|
171
|
+
chatFn: async () => { throw new Error('always fails'); },
|
|
172
|
+
});
|
|
173
|
+
const backup = new MockClient('backup');
|
|
174
|
+
|
|
175
|
+
router.addProvider({ id: 'fail', client: failing, priority: 0 });
|
|
176
|
+
router.addProvider({ id: 'backup', client: backup, priority: 1 });
|
|
177
|
+
|
|
178
|
+
// Each chat call records 1 failure for 'fail', maxFailures is 2
|
|
179
|
+
await router.chat([{ role: 'user', content: 'test 1' }]);
|
|
180
|
+
await router.chat([{ role: 'user', content: 'test 2' }]);
|
|
181
|
+
|
|
182
|
+
const status = router.getStatus();
|
|
183
|
+
const failStatus = status.find(s => s.id === 'fail');
|
|
184
|
+
expect(failStatus!.healthy).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('recovers after cooldown expires', async () => {
|
|
188
|
+
let callCount = 0;
|
|
189
|
+
const failing = new MockClient('fail', {
|
|
190
|
+
chatFn: async () => {
|
|
191
|
+
callCount++;
|
|
192
|
+
if (callCount <= 4) throw new Error('failing');
|
|
193
|
+
return {
|
|
194
|
+
message: { role: 'assistant' as const, content: 'recovered' },
|
|
195
|
+
provider: 'fail',
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
const backup = new MockClient('backup');
|
|
200
|
+
|
|
201
|
+
router.addProvider({ id: 'fail', client: failing, priority: 0 });
|
|
202
|
+
router.addProvider({ id: 'backup', client: backup, priority: 1 });
|
|
203
|
+
|
|
204
|
+
// First call: fail → backup
|
|
205
|
+
await router.chat([{ role: 'user', content: '1' }]);
|
|
206
|
+
|
|
207
|
+
// Wait for cooldown (100ms in test config)
|
|
208
|
+
await new Promise(r => setTimeout(r, 150));
|
|
209
|
+
|
|
210
|
+
// After cooldown, fail should be tried again
|
|
211
|
+
const status = router.getStatus();
|
|
212
|
+
const failStatus = status.find(s => s.id === 'fail');
|
|
213
|
+
expect(failStatus!.healthy).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('tool registration', () => {
|
|
218
|
+
it('broadcasts tool registration to all providers', () => {
|
|
219
|
+
const clientA = new MockClient('a');
|
|
220
|
+
const clientB = new MockClient('b');
|
|
221
|
+
|
|
222
|
+
router.addProvider({ id: 'a', client: clientA, priority: 0 });
|
|
223
|
+
router.addProvider({ id: 'b', client: clientB, priority: 1 });
|
|
224
|
+
|
|
225
|
+
router.registerTool(
|
|
226
|
+
'test_tool',
|
|
227
|
+
'A test tool',
|
|
228
|
+
{ type: 'object', properties: {} },
|
|
229
|
+
async () => 'result',
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
// Both clients should have the tool registered
|
|
233
|
+
const defsA = clientA.getToolDefinitions();
|
|
234
|
+
const defsB = clientB.getToolDefinitions();
|
|
235
|
+
expect(defsA).toHaveLength(1);
|
|
236
|
+
expect(defsB).toHaveLength(1);
|
|
237
|
+
expect(defsA[0]!.function.name).toBe('test_tool');
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('model aggregation', () => {
|
|
242
|
+
it('aggregates models from all providers', async () => {
|
|
243
|
+
const clientA = new MockClient('a');
|
|
244
|
+
const clientB = new MockClient('b');
|
|
245
|
+
|
|
246
|
+
router.addProvider({ id: 'a', client: clientA, priority: 0 });
|
|
247
|
+
router.addProvider({ id: 'b', client: clientB, priority: 1 });
|
|
248
|
+
|
|
249
|
+
const models = await router.getModels();
|
|
250
|
+
expect(models).toContain('mock-a');
|
|
251
|
+
expect(models).toContain('mock-b');
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
});
|