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.
Files changed (108) hide show
  1. package/CHANGELOG.md +142 -103
  2. package/LICENSE +21 -21
  3. package/README.md +640 -591
  4. package/dist/ai-model.d.ts +12 -1
  5. package/dist/ai-model.d.ts.map +1 -1
  6. package/dist/ai-model.js +36 -1
  7. package/dist/ai-model.js.map +1 -1
  8. package/dist/gemma-channel.d.ts +14 -0
  9. package/dist/gemma-channel.d.ts.map +1 -0
  10. package/dist/gemma-channel.js +38 -0
  11. package/dist/gemma-channel.js.map +1 -0
  12. package/dist/gemma-diffusion.d.ts +49 -0
  13. package/dist/gemma-diffusion.d.ts.map +1 -0
  14. package/dist/gemma-diffusion.js +147 -0
  15. package/dist/gemma-diffusion.js.map +1 -0
  16. package/dist/http.d.ts +4 -0
  17. package/dist/http.d.ts.map +1 -1
  18. package/dist/http.js +14 -1
  19. package/dist/http.js.map +1 -1
  20. package/dist/index.d.ts +2 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +4 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/interfaces.d.ts +183 -7
  25. package/dist/interfaces.d.ts.map +1 -1
  26. package/dist/interfaces.js.map +1 -1
  27. package/dist/providers/anthropic.d.ts.map +1 -1
  28. package/dist/providers/anthropic.js +28 -3
  29. package/dist/providers/anthropic.js.map +1 -1
  30. package/dist/providers/google.d.ts +22 -1
  31. package/dist/providers/google.d.ts.map +1 -1
  32. package/dist/providers/google.js +225 -13
  33. package/dist/providers/google.js.map +1 -1
  34. package/dist/providers/ollama.d.ts +2 -0
  35. package/dist/providers/ollama.d.ts.map +1 -1
  36. package/dist/providers/ollama.js +59 -30
  37. package/dist/providers/ollama.js.map +1 -1
  38. package/dist/providers/openai.d.ts +14 -0
  39. package/dist/providers/openai.d.ts.map +1 -1
  40. package/dist/providers/openai.js +200 -22
  41. package/dist/providers/openai.js.map +1 -1
  42. package/dist/router.d.ts +2 -0
  43. package/dist/router.d.ts.map +1 -1
  44. package/dist/router.js +4 -0
  45. package/dist/router.js.map +1 -1
  46. package/dist/stream-decoder.d.ts +12 -0
  47. package/dist/stream-decoder.d.ts.map +1 -1
  48. package/dist/stream-decoder.js +182 -5
  49. package/dist/stream-decoder.js.map +1 -1
  50. package/dist/thinking.d.ts +36 -0
  51. package/dist/thinking.d.ts.map +1 -0
  52. package/dist/thinking.js +52 -0
  53. package/dist/thinking.js.map +1 -0
  54. package/package.json +118 -116
  55. package/src/ai-model.ts +400 -350
  56. package/src/auditor.ts +213 -213
  57. package/src/client.ts +402 -402
  58. package/src/debug/debug-google-streaming.ts +1 -1
  59. package/src/demos/basic/universal-llm-examples.ts +3 -3
  60. package/src/demos/diffusion-gemma/.env +29 -0
  61. package/src/demos/diffusion-gemma/.env.example +27 -0
  62. package/src/demos/diffusion-gemma/CLAUDE.md +95 -0
  63. package/src/demos/diffusion-gemma/README.md +59 -0
  64. package/src/demos/diffusion-gemma/canvas.ts +1606 -0
  65. package/src/demos/diffusion-gemma/docker-compose.yml +29 -0
  66. package/src/demos/diffusion-gemma/probe-stream.ts +51 -0
  67. package/src/demos/diffusion-gemma/probe-tools.ts +55 -0
  68. package/src/demos/diffusion-gemma/server.ts +1205 -0
  69. package/src/demos/diffusion-gemma/start-vllm.sh +98 -0
  70. package/src/gemma-channel.ts +47 -0
  71. package/src/gemma-diffusion.ts +167 -0
  72. package/src/http.ts +261 -247
  73. package/src/index.ts +180 -161
  74. package/src/interfaces.ts +843 -657
  75. package/src/mcp.ts +345 -345
  76. package/src/providers/anthropic.ts +796 -762
  77. package/src/providers/google.ts +840 -620
  78. package/src/providers/index.ts +8 -8
  79. package/src/providers/ollama.ts +503 -469
  80. package/src/providers/openai.ts +587 -392
  81. package/src/router.ts +785 -780
  82. package/src/stream-decoder.ts +535 -361
  83. package/src/structured-output.ts +759 -759
  84. package/src/test-scripts/test-google-deep-research.ts +33 -0
  85. package/src/test-scripts/test-google-streaming-enhanced.ts +147 -147
  86. package/src/test-scripts/test-google-streaming.ts +1 -1
  87. package/src/test-scripts/test-google-system-prompt-comprehensive.ts +189 -189
  88. package/src/test-scripts/test-google-thinking.ts +46 -0
  89. package/src/test-scripts/test-system-message-positions.ts +163 -163
  90. package/src/test-scripts/test-system-prompt-improvement-demo.ts +83 -83
  91. package/src/test-scripts/test-vllm-qwen36.ts +256 -0
  92. package/src/tests/ai-model.test.ts +1614 -1614
  93. package/src/tests/auditor.test.ts +224 -224
  94. package/src/tests/gemma-diffusion.test.ts +115 -0
  95. package/src/tests/http.test.ts +200 -200
  96. package/src/tests/interfaces.test.ts +117 -117
  97. package/src/tests/providers/anthropic.test.ts +118 -0
  98. package/src/tests/providers/google.test.ts +841 -660
  99. package/src/tests/providers/ollama.test.ts +1034 -954
  100. package/src/tests/providers/openai.test.ts +1511 -1122
  101. package/src/tests/router.test.ts +254 -254
  102. package/src/tests/stream-decoder.test.ts +263 -179
  103. package/src/tests/structured-output.test.ts +1450 -1450
  104. package/src/tests/thinking.test.ts +65 -0
  105. package/src/tests/tools.test.ts +175 -175
  106. package/src/thinking.ts +73 -0
  107. package/src/tools.ts +246 -246
  108. package/src/zod-adapter.ts +72 -72
@@ -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
+ });