universal-llm-client 4.0.0 → 4.2.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 (127) hide show
  1. package/dist/ai-model.d.ts +20 -22
  2. package/dist/ai-model.d.ts.map +1 -1
  3. package/dist/ai-model.js +26 -23
  4. package/dist/ai-model.js.map +1 -1
  5. package/dist/client.d.ts +5 -5
  6. package/dist/client.d.ts.map +1 -1
  7. package/dist/client.js +17 -9
  8. package/dist/client.js.map +1 -1
  9. package/dist/http.d.ts +2 -0
  10. package/dist/http.d.ts.map +1 -1
  11. package/dist/http.js +1 -0
  12. package/dist/http.js.map +1 -1
  13. package/dist/index.d.ts +3 -3
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +4 -4
  16. package/dist/index.js.map +1 -1
  17. package/dist/interfaces.d.ts +49 -11
  18. package/dist/interfaces.d.ts.map +1 -1
  19. package/dist/interfaces.js +14 -0
  20. package/dist/interfaces.js.map +1 -1
  21. package/dist/providers/anthropic.d.ts +56 -0
  22. package/dist/providers/anthropic.d.ts.map +1 -0
  23. package/dist/providers/anthropic.js +524 -0
  24. package/dist/providers/anthropic.js.map +1 -0
  25. package/dist/providers/google.d.ts +5 -0
  26. package/dist/providers/google.d.ts.map +1 -1
  27. package/dist/providers/google.js +64 -8
  28. package/dist/providers/google.js.map +1 -1
  29. package/dist/providers/index.d.ts +1 -0
  30. package/dist/providers/index.d.ts.map +1 -1
  31. package/dist/providers/index.js +1 -0
  32. package/dist/providers/index.js.map +1 -1
  33. package/dist/providers/ollama.d.ts.map +1 -1
  34. package/dist/providers/ollama.js +38 -11
  35. package/dist/providers/ollama.js.map +1 -1
  36. package/dist/providers/openai.d.ts.map +1 -1
  37. package/dist/providers/openai.js +9 -7
  38. package/dist/providers/openai.js.map +1 -1
  39. package/dist/router.d.ts +13 -33
  40. package/dist/router.d.ts.map +1 -1
  41. package/dist/router.js +33 -57
  42. package/dist/router.js.map +1 -1
  43. package/dist/stream-decoder.d.ts +29 -2
  44. package/dist/stream-decoder.d.ts.map +1 -1
  45. package/dist/stream-decoder.js +39 -11
  46. package/dist/stream-decoder.js.map +1 -1
  47. package/dist/structured-output.d.ts +107 -181
  48. package/dist/structured-output.d.ts.map +1 -1
  49. package/dist/structured-output.js +137 -192
  50. package/dist/structured-output.js.map +1 -1
  51. package/dist/zod-adapter.d.ts +44 -0
  52. package/dist/zod-adapter.d.ts.map +1 -0
  53. package/dist/zod-adapter.js +61 -0
  54. package/dist/zod-adapter.js.map +1 -0
  55. package/package.json +9 -1
  56. package/src/ai-model.ts +350 -0
  57. package/src/auditor.ts +213 -0
  58. package/src/client.ts +402 -0
  59. package/src/debug/debug-google-streaming.ts +97 -0
  60. package/src/debug/debug-tool-execution.ts +86 -0
  61. package/src/debug/test-lmstudio-tools.ts +155 -0
  62. package/src/demos/README.md +47 -0
  63. package/src/demos/basic/universal-llm-examples.ts +161 -0
  64. package/src/demos/mcp/astrid-memory-demo.ts +295 -0
  65. package/src/demos/mcp/astrid-persona-memory.ts +357 -0
  66. package/src/demos/mcp/mcp-mongodb-demo.ts +275 -0
  67. package/src/demos/mcp/simple-astrid-memory.ts +148 -0
  68. package/src/demos/mcp/simple-mcp-demo.ts +68 -0
  69. package/src/demos/mcp/working-mcp-demo.ts +62 -0
  70. package/src/demos/model-alias-demo.ts +0 -0
  71. package/src/demos/tools/RAG_MEMORY_INTEGRATION.md +267 -0
  72. package/src/demos/tools/astrid-memory-demo.ts +270 -0
  73. package/src/demos/tools/astrid-production-memory-clean.ts +785 -0
  74. package/src/demos/tools/astrid-production-memory.ts +558 -0
  75. package/src/demos/tools/basic-translation-test.ts +66 -0
  76. package/src/demos/tools/chromadb-similarity-tuning.ts +390 -0
  77. package/src/demos/tools/clean-multilingual-conversation.ts +209 -0
  78. package/src/demos/tools/clean-translation-test.ts +119 -0
  79. package/src/demos/tools/clean-universal-multilingual-test.ts +131 -0
  80. package/src/demos/tools/complete-rag-demo.ts +369 -0
  81. package/src/demos/tools/complete-tool-demo.ts +132 -0
  82. package/src/demos/tools/demo-tool-calling.ts +124 -0
  83. package/src/demos/tools/dynamic-language-switching-test.ts +251 -0
  84. package/src/demos/tools/hybrid-thinking-test.ts +154 -0
  85. package/src/demos/tools/memory-integration-test.ts +420 -0
  86. package/src/demos/tools/multilingual-memory-system.ts +802 -0
  87. package/src/demos/tools/ondemand-translation-demo.ts +655 -0
  88. package/src/demos/tools/production-tool-demo.ts +245 -0
  89. package/src/demos/tools/revolutionary-multilingual-test.ts +151 -0
  90. package/src/demos/tools/rigorous-language-analysis.ts +218 -0
  91. package/src/demos/tools/test-universal-memory-system.ts +126 -0
  92. package/src/demos/tools/translation-integration-guide.ts +346 -0
  93. package/src/demos/tools/universal-memory-system.ts +560 -0
  94. package/src/http.ts +247 -0
  95. package/src/index.ts +161 -0
  96. package/src/interfaces.ts +657 -0
  97. package/src/mcp.ts +345 -0
  98. package/src/providers/anthropic.ts +762 -0
  99. package/src/providers/google.ts +620 -0
  100. package/src/providers/index.ts +8 -0
  101. package/src/providers/ollama.ts +469 -0
  102. package/src/providers/openai.ts +392 -0
  103. package/src/router.ts +780 -0
  104. package/src/stream-decoder.ts +361 -0
  105. package/src/structured-output.ts +759 -0
  106. package/src/test-scripts/test-advanced-tools.ts +310 -0
  107. package/src/test-scripts/test-google-streaming-enhanced.ts +147 -0
  108. package/src/test-scripts/test-google-streaming.ts +63 -0
  109. package/src/test-scripts/test-google-system-prompt-comprehensive.ts +189 -0
  110. package/src/test-scripts/test-mcp-config.ts +28 -0
  111. package/src/test-scripts/test-mcp-connection.ts +29 -0
  112. package/src/test-scripts/test-system-message-positions.ts +163 -0
  113. package/src/test-scripts/test-system-prompt-improvement-demo.ts +83 -0
  114. package/src/test-scripts/test-tool-calling.ts +231 -0
  115. package/src/tests/ai-model.test.ts +1614 -0
  116. package/src/tests/auditor.test.ts +224 -0
  117. package/src/tests/http.test.ts +200 -0
  118. package/src/tests/interfaces.test.ts +117 -0
  119. package/src/tests/providers/google.test.ts +660 -0
  120. package/src/tests/providers/ollama.test.ts +954 -0
  121. package/src/tests/providers/openai.test.ts +1122 -0
  122. package/src/tests/router.test.ts +254 -0
  123. package/src/tests/stream-decoder.test.ts +179 -0
  124. package/src/tests/structured-output.test.ts +1450 -0
  125. package/src/tests/tools.test.ts +175 -0
  126. package/src/tools.ts +246 -0
  127. package/src/zod-adapter.ts +72 -0
@@ -0,0 +1,1614 @@
1
+ import { fromZod } from '../zod-adapter.js';
2
+ /**
3
+ * Tests for ai-model.ts — Universal Client (AIModel)
4
+ */
5
+ import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
6
+ import { z } from 'zod';
7
+ import { AIModel, type AIModelConfig, AIModelApiType, BufferedAuditor, StructuredOutputError } from '../index.js';
8
+
9
+ // ============================================================================
10
+ // Helpers
11
+ // ============================================================================
12
+
13
+ function createTestConfig(overrides: Partial<AIModelConfig> = {}): AIModelConfig {
14
+ return {
15
+ model: 'test-model',
16
+ providers: [
17
+ { type: AIModelApiType.Ollama, url: 'http://localhost:11434' },
18
+ ],
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ // ============================================================================
24
+ // Tests
25
+ // ============================================================================
26
+
27
+ describe('AIModel', () => {
28
+ const originalFetch = globalThis.fetch;
29
+
30
+ afterEach(() => {
31
+ globalThis.fetch = originalFetch;
32
+ });
33
+
34
+ describe('constructor', () => {
35
+ it('creates with single provider', () => {
36
+ const model = new AIModel(createTestConfig());
37
+ expect(model.model).toBe('test-model');
38
+ });
39
+
40
+ it('creates with multiple providers', () => {
41
+ const model = new AIModel(createTestConfig({
42
+ providers: [
43
+ { type: 'ollama', url: 'http://localhost:11434' },
44
+ { type: 'openai', apiKey: 'sk-test' },
45
+ ],
46
+ }));
47
+ expect(model.model).toBe('test-model');
48
+ });
49
+
50
+ it('creates with all supported provider types', () => {
51
+ const model = new AIModel(createTestConfig({
52
+ providers: [
53
+ { type: 'ollama' },
54
+ { type: 'openai', apiKey: 'sk-test' },
55
+ { type: 'google', apiKey: 'test-key' },
56
+ { type: 'vertex', apiKey: 'token', region: 'us-east1' },
57
+ { type: 'llamacpp', url: 'http://localhost:8080' },
58
+ ],
59
+ }));
60
+ expect(model.model).toBe('test-model');
61
+ });
62
+
63
+ it('throws for unknown provider type', () => {
64
+ expect(() => new AIModel(createTestConfig({
65
+ providers: [{ type: 'unknown' as never }],
66
+ }))).toThrow('Unknown provider type');
67
+ });
68
+ });
69
+
70
+ describe('model management', () => {
71
+ it('returns model name', () => {
72
+ const model = new AIModel(createTestConfig());
73
+ expect(model.model).toBe('test-model');
74
+ });
75
+
76
+ it('switches model at runtime', () => {
77
+ const model = new AIModel(createTestConfig());
78
+ model.setModel('new-model');
79
+ expect(model.model).toBe('new-model');
80
+ });
81
+ });
82
+
83
+ describe('chat (with fetch mock)', () => {
84
+ it('chat returns a response from Ollama provider', async () => {
85
+ globalThis.fetch = mock(async () =>
86
+ new Response(JSON.stringify({
87
+ model: 'test-model',
88
+ created_at: new Date().toISOString(),
89
+ message: { role: 'assistant', content: 'Hello from Ollama' },
90
+ done: true,
91
+ prompt_eval_count: 10,
92
+ eval_count: 5,
93
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
94
+ ) as typeof fetch;
95
+
96
+ const model = new AIModel(createTestConfig());
97
+ const response = await model.chat([
98
+ { role: 'user', content: 'Hello' },
99
+ ]);
100
+
101
+ expect(response.message.role).toBe('assistant');
102
+ expect(response.message.content).toBe('Hello from Ollama');
103
+ expect(response.provider).toBe('ollama');
104
+ });
105
+
106
+ it('chat returns a response from OpenAI provider', async () => {
107
+ globalThis.fetch = mock(async () =>
108
+ new Response(JSON.stringify({
109
+ id: 'chatcmpl-test',
110
+ object: 'chat.completion',
111
+ created: Date.now(),
112
+ model: 'test-model',
113
+ choices: [{
114
+ index: 0,
115
+ message: { role: 'assistant', content: 'Hello from OpenAI' },
116
+ finish_reason: 'stop',
117
+ }],
118
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
119
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
120
+ ) as typeof fetch;
121
+
122
+ const model = new AIModel(createTestConfig({
123
+ providers: [{ type: 'openai', apiKey: 'sk-test' }],
124
+ }));
125
+
126
+ const response = await model.chat([
127
+ { role: 'user', content: 'Hello' },
128
+ ]);
129
+
130
+ expect(response.message.content).toBe('Hello from OpenAI');
131
+ expect(response.provider).toBe('openai');
132
+ expect(response.usage?.totalTokens).toBe(15);
133
+ });
134
+
135
+ it('chat returns a response from Google provider', async () => {
136
+ globalThis.fetch = mock(async () =>
137
+ new Response(JSON.stringify({
138
+ candidates: [{
139
+ content: {
140
+ parts: [{ text: 'Hello from Google' }],
141
+ role: 'model',
142
+ },
143
+ finishReason: 'STOP',
144
+ index: 0,
145
+ }],
146
+ usageMetadata: {
147
+ promptTokenCount: 10,
148
+ candidatesTokenCount: 5,
149
+ totalTokenCount: 15,
150
+ },
151
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
152
+ ) as typeof fetch;
153
+
154
+ const model = new AIModel(createTestConfig({
155
+ providers: [{ type: 'google', apiKey: 'test-key' }],
156
+ }));
157
+
158
+ const response = await model.chat([
159
+ { role: 'user', content: 'Hello' },
160
+ ]);
161
+
162
+ expect(response.message.content).toBe('Hello from Google');
163
+ expect(response.provider).toBe('google');
164
+ });
165
+ });
166
+
167
+ describe('failover', () => {
168
+ it('fails over from unhealthy provider to healthy one', async () => {
169
+ let callCount = 0;
170
+ globalThis.fetch = mock(async (url: string | URL | Request) => {
171
+ callCount++;
172
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
173
+ if (urlStr.includes('11434')) {
174
+ return new Response('Server Error', { status: 500 });
175
+ }
176
+ return new Response(JSON.stringify({
177
+ id: 'test',
178
+ object: 'chat.completion',
179
+ created: Date.now(),
180
+ model: 'test-model',
181
+ choices: [{
182
+ index: 0,
183
+ message: { role: 'assistant', content: 'From backup' },
184
+ finish_reason: 'stop',
185
+ }],
186
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } });
187
+ }) as typeof fetch;
188
+
189
+ const model = new AIModel(createTestConfig({
190
+ retries: 0, // No retries for faster test
191
+ providers: [
192
+ { type: 'ollama', url: 'http://localhost:11434' },
193
+ { type: 'openai', url: 'http://localhost:8080', apiKey: 'test' },
194
+ ],
195
+ }));
196
+
197
+ const response = await model.chat([
198
+ { role: 'user', content: 'Hello' },
199
+ ]);
200
+
201
+ expect(response.message.content).toBe('From backup');
202
+ expect(response.provider).toBe('openai');
203
+ });
204
+ });
205
+
206
+ describe('tool registration', () => {
207
+ it('registers a tool', () => {
208
+ const model = new AIModel(createTestConfig());
209
+ model.registerTool(
210
+ 'test_tool',
211
+ 'A test tool',
212
+ { type: 'object', properties: {} },
213
+ async () => 'result',
214
+ );
215
+ // If no error, registration succeeded
216
+ expect(true).toBe(true);
217
+ });
218
+
219
+ it('registers multiple tools', () => {
220
+ const model = new AIModel(createTestConfig());
221
+ model.registerTools([
222
+ {
223
+ name: 'tool_a',
224
+ description: 'Tool A',
225
+ parameters: { type: 'object' },
226
+ handler: async () => 'a',
227
+ },
228
+ {
229
+ name: 'tool_b',
230
+ description: 'Tool B',
231
+ parameters: { type: 'object' },
232
+ handler: async () => 'b',
233
+ },
234
+ ]);
235
+ expect(true).toBe(true);
236
+ });
237
+ });
238
+
239
+ describe('provider status', () => {
240
+ it('returns provider status', () => {
241
+ const model = new AIModel(createTestConfig({
242
+ providers: [
243
+ { type: 'ollama' },
244
+ { type: 'openai', apiKey: 'sk-test' },
245
+ ],
246
+ }));
247
+
248
+ const status = model.getProviderStatus();
249
+ expect(status).toHaveLength(2);
250
+ expect(status[0]!.healthy).toBe(true);
251
+ expect(status[1]!.healthy).toBe(true);
252
+ });
253
+ });
254
+
255
+ describe('observability', () => {
256
+ it('records events through auditor', async () => {
257
+ const auditor = new BufferedAuditor();
258
+ globalThis.fetch = mock(async () =>
259
+ new Response(JSON.stringify({
260
+ model: 'test', created_at: '', done: true,
261
+ message: { role: 'assistant', content: 'ok' },
262
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
263
+ ) as typeof fetch;
264
+
265
+ const model = new AIModel(createTestConfig({ auditor }));
266
+ await model.chat([{ role: 'user', content: 'test' }]);
267
+
268
+ const events = auditor.getEvents();
269
+ expect(events.length).toBeGreaterThan(0);
270
+
271
+ const types = events.map(e => e.type);
272
+ expect(types).toContain('request');
273
+ expect(types).toContain('response');
274
+ });
275
+ });
276
+
277
+ describe('lifecycle', () => {
278
+ it('dispose flushes auditor', async () => {
279
+ let flushed = false;
280
+ const auditor = new BufferedAuditor({
281
+ onFlush: async () => { flushed = true; },
282
+ });
283
+ auditor.record({ timestamp: Date.now(), type: 'request' });
284
+
285
+ const model = new AIModel(createTestConfig({ auditor }));
286
+ await model.dispose();
287
+
288
+ expect(flushed).toBe(true);
289
+ });
290
+ });
291
+
292
+ // ========================================================================
293
+ // Structured Output Tests (VAL-API-001, VAL-API-002, VAL-API-003, VAL-API-006, VAL-API-007)
294
+ // ========================================================================
295
+
296
+ describe('generateStructured', () => {
297
+ const UserSchema = z.object({
298
+ name: z.string(),
299
+ age: z.number(),
300
+ email: z.string().email().optional(),
301
+ });
302
+
303
+ type User = z.infer<typeof UserSchema>;
304
+
305
+ it('returns typed object matching schema (VAL-API-001)', async () => {
306
+ globalThis.fetch = mock(async () =>
307
+ new Response(JSON.stringify({
308
+ model: 'test-model',
309
+ created_at: new Date().toISOString(),
310
+ message: { role: 'assistant', content: '{"name": "Alice", "age": 30}' },
311
+ done: true,
312
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
313
+ ) as typeof fetch;
314
+
315
+ const model = new AIModel(createTestConfig());
316
+ const result = await model.generateStructured(fromZod(UserSchema), [
317
+ { role: 'user', content: 'Generate a user' },
318
+ ]);
319
+
320
+ expect(result.name).toBe('Alice');
321
+ expect(result.age).toBe(30);
322
+ });
323
+
324
+ it('returns typed object with optional fields', async () => {
325
+ globalThis.fetch = mock(async () =>
326
+ new Response(JSON.stringify({
327
+ model: 'test-model',
328
+ created_at: new Date().toISOString(),
329
+ message: { role: 'assistant', content: '{"name": "Bob", "age": 25, "email": "bob@example.com"}' },
330
+ done: true,
331
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
332
+ ) as typeof fetch;
333
+
334
+ const model = new AIModel(createTestConfig());
335
+ const result = await model.generateStructured(fromZod(UserSchema), [
336
+ { role: 'user', content: 'Generate a user with email' },
337
+ ]);
338
+
339
+ expect(result.name).toBe('Bob');
340
+ expect(result.age).toBe(25);
341
+ expect(result.email).toBe('bob@example.com');
342
+ });
343
+
344
+ it('passes options (temperature, maxTokens) to provider (VAL-API-002)', async () => {
345
+ let capturedBody: Record<string, unknown> | undefined;
346
+ globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
347
+ if (init?.body) {
348
+ capturedBody = JSON.parse(init.body as string);
349
+ }
350
+ return new Response(JSON.stringify({
351
+ model: 'test-model',
352
+ created_at: new Date().toISOString(),
353
+ message: { role: 'assistant', content: '{"name": "Test", "age": 20}' },
354
+ done: true,
355
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } });
356
+ }) as typeof fetch;
357
+
358
+ const model = new AIModel(createTestConfig());
359
+ await model.generateStructured(fromZod(UserSchema), [
360
+ { role: 'user', content: 'Generate a user' },
361
+ ], { temperature: 0.5, maxTokens: 100 });
362
+
363
+ // Ollama uses 'options' object for temperature and max_tokens
364
+ expect(capturedBody?.options).toBeDefined();
365
+ expect((capturedBody?.options as Record<string, unknown>)?.temperature).toBe(0.5);
366
+ });
367
+
368
+ it('failover works across providers with same structured output request (VAL-API-003)', async () => {
369
+ let firstProviderCalled = false;
370
+ globalThis.fetch = mock(async (url: string | URL | Request) => {
371
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
372
+ // First provider (Ollama) fails
373
+ if (urlStr.includes('11434') && !firstProviderCalled) {
374
+ firstProviderCalled = true;
375
+ return new Response('Server Error', { status: 500 });
376
+ }
377
+ // Second provider (OpenAI-compatible) succeeds
378
+ return new Response(JSON.stringify({
379
+ id: 'chatcmpl-test',
380
+ object: 'chat.completion',
381
+ created: Date.now(),
382
+ model: 'test-model',
383
+ choices: [{
384
+ index: 0,
385
+ message: { role: 'assistant', content: '{"name": "Failover", "age": 40}' },
386
+ finish_reason: 'stop',
387
+ }],
388
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } });
389
+ }) as typeof fetch;
390
+
391
+ const model = new AIModel(createTestConfig({
392
+ retries: 0,
393
+ providers: [
394
+ { type: 'ollama', url: 'http://localhost:11434' },
395
+ { type: 'openai', url: 'http://localhost:8080/v1', apiKey: 'test' },
396
+ ],
397
+ }));
398
+
399
+ const result = await model.generateStructured(fromZod(UserSchema), [
400
+ { role: 'user', content: 'Generate a user' },
401
+ ]);
402
+
403
+ expect(result.name).toBe('Failover');
404
+ expect(result.age).toBe(40);
405
+ });
406
+
407
+ it('throws StructuredOutputError on validation failure (VAL-API-001)', async () => {
408
+ globalThis.fetch = mock(async () =>
409
+ new Response(JSON.stringify({
410
+ model: 'test-model',
411
+ created_at: new Date().toISOString(),
412
+ message: { role: 'assistant', content: '{"name": "Alice", "age": "not a number"}' },
413
+ done: true,
414
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
415
+ ) as typeof fetch;
416
+
417
+ const model = new AIModel(createTestConfig());
418
+
419
+ expect(
420
+ model.generateStructured(fromZod(UserSchema), [{ role: 'user', content: 'Generate a user' }])
421
+ ).rejects.toThrow(StructuredOutputError);
422
+ });
423
+
424
+ it('throws StructuredOutputError on invalid JSON response', async () => {
425
+ globalThis.fetch = mock(async () =>
426
+ new Response(JSON.stringify({
427
+ model: 'test-model',
428
+ created_at: new Date().toISOString(),
429
+ message: { role: 'assistant', content: 'not valid json' },
430
+ done: true,
431
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
432
+ ) as typeof fetch;
433
+
434
+ const model = new AIModel(createTestConfig());
435
+
436
+ expect(
437
+ model.generateStructured(fromZod(UserSchema), [{ role: 'user', content: 'Generate a user' }])
438
+ ).rejects.toThrow(StructuredOutputError);
439
+ });
440
+
441
+ it('accepts raw JSON Schema instead of Zod', async () => {
442
+ globalThis.fetch = mock(async () =>
443
+ new Response(JSON.stringify({
444
+ model: 'test-model',
445
+ created_at: new Date().toISOString(),
446
+ message: { role: 'assistant', content: '{"name": "Schema", "age": 50}' },
447
+ done: true,
448
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
449
+ ) as typeof fetch;
450
+
451
+ const model = new AIModel(createTestConfig());
452
+ const result = await model.generateStructured(fromZod(UserSchema),
453
+ [{ role: 'user', content: 'Generate a user' }],
454
+ );
455
+
456
+ expect(result.name).toBe('Schema');
457
+ expect(result.age).toBe(50);
458
+ });
459
+
460
+ it('works with nested object schemas', async () => {
461
+ const NestedSchema = z.object({
462
+ user: z.object({
463
+ name: z.string(),
464
+ address: z.object({
465
+ city: z.string(),
466
+ country: z.string(),
467
+ }),
468
+ }),
469
+ });
470
+
471
+ globalThis.fetch = mock(async () =>
472
+ new Response(JSON.stringify({
473
+ model: 'test-model',
474
+ created_at: new Date().toISOString(),
475
+ message: {
476
+ role: 'assistant',
477
+ content: '{"user": {"name": "Nested", "address": {"city": "NYC", "country": "USA"}}}',
478
+ },
479
+ done: true,
480
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
481
+ ) as typeof fetch;
482
+
483
+ const model = new AIModel(createTestConfig());
484
+ const result = await model.generateStructured(fromZod(NestedSchema), [
485
+ { role: 'user', content: 'Generate a nested user' },
486
+ ]);
487
+
488
+ expect(result.user.name).toBe('Nested');
489
+ expect(result.user.address.city).toBe('NYC');
490
+ });
491
+
492
+ it('works with array schemas', async () => {
493
+ const ArraySchema = z.object({
494
+ items: z.array(z.string()),
495
+ });
496
+
497
+ globalThis.fetch = mock(async () =>
498
+ new Response(JSON.stringify({
499
+ model: 'test-model',
500
+ created_at: new Date().toISOString(),
501
+ message: { role: 'assistant', content: '{"items": ["a", "b", "c"]}' },
502
+ done: true,
503
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
504
+ ) as typeof fetch;
505
+
506
+ const model = new AIModel(createTestConfig());
507
+ const result = await model.generateStructured(fromZod(ArraySchema), [
508
+ { role: 'user', content: 'Generate items' },
509
+ ]);
510
+
511
+ expect(result.items).toEqual(['a', 'b', 'c']);
512
+ });
513
+
514
+ it('works with enum schemas', async () => {
515
+ const EnumSchema = z.object({
516
+ status: z.enum(['active', 'inactive', 'pending']),
517
+ });
518
+
519
+ globalThis.fetch = mock(async () =>
520
+ new Response(JSON.stringify({
521
+ model: 'test-model',
522
+ created_at: new Date().toISOString(),
523
+ message: { role: 'assistant', content: '{"status": "active"}' },
524
+ done: true,
525
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
526
+ ) as typeof fetch;
527
+
528
+ const model = new AIModel(createTestConfig());
529
+ const result = await model.generateStructured(fromZod(EnumSchema), [
530
+ { role: 'user', content: 'Generate status' },
531
+ ]);
532
+
533
+ expect(result.status).toBe('active');
534
+ });
535
+ });
536
+
537
+ describe('tryParseStructured', () => {
538
+ const UserSchema = z.object({
539
+ name: z.string(),
540
+ age: z.number(),
541
+ });
542
+
543
+ type User = z.infer<typeof UserSchema>;
544
+
545
+ it('returns { ok: true, value } on success (VAL-API-006)', async () => {
546
+ globalThis.fetch = mock(async () =>
547
+ new Response(JSON.stringify({
548
+ model: 'test-model',
549
+ created_at: new Date().toISOString(),
550
+ message: { role: 'assistant', content: '{"name": "Alice", "age": 30}' },
551
+ done: true,
552
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
553
+ ) as typeof fetch;
554
+
555
+ const model = new AIModel(createTestConfig());
556
+ const result = await model.tryParseStructured(fromZod(UserSchema), [
557
+ { role: 'user', content: 'Generate a user' },
558
+ ]);
559
+
560
+ expect(result.ok).toBe(true);
561
+ if (result.ok) {
562
+ expect(result.value.name).toBe('Alice');
563
+ expect(result.value.age).toBe(30);
564
+ }
565
+ });
566
+
567
+ it('returns { ok: false, error, rawOutput } on validation failure (VAL-API-007)', async () => {
568
+ globalThis.fetch = mock(async () =>
569
+ new Response(JSON.stringify({
570
+ model: 'test-model',
571
+ created_at: new Date().toISOString(),
572
+ message: { role: 'assistant', content: '{"name": "Alice", "age": "wrong type"}' },
573
+ done: true,
574
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
575
+ ) as typeof fetch;
576
+
577
+ const model = new AIModel(createTestConfig());
578
+ const result = await model.tryParseStructured(fromZod(UserSchema), [
579
+ { role: 'user', content: 'Generate a user' },
580
+ ]);
581
+
582
+ expect(result.ok).toBe(false);
583
+ if (!result.ok) {
584
+ expect(result.error).toBeInstanceOf(StructuredOutputError);
585
+ expect(result.rawOutput).toBe('{"name": "Alice", "age": "wrong type"}');
586
+ }
587
+ });
588
+
589
+ it('returns { ok: false, ... } on malformed JSON (VAL-API-007)', async () => {
590
+ globalThis.fetch = mock(async () =>
591
+ new Response(JSON.stringify({
592
+ model: 'test-model',
593
+ created_at: new Date().toISOString(),
594
+ message: { role: 'assistant', content: 'not valid json at all' },
595
+ done: true,
596
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
597
+ ) as typeof fetch;
598
+
599
+ const model = new AIModel(createTestConfig());
600
+ const result = await model.tryParseStructured(fromZod(UserSchema), [
601
+ { role: 'user', content: 'Generate a user' },
602
+ ]);
603
+
604
+ expect(result.ok).toBe(false);
605
+ if (!result.ok) {
606
+ expect(result.error).toBeInstanceOf(StructuredOutputError);
607
+ expect(result.rawOutput).toBe('not valid json at all');
608
+ }
609
+ });
610
+
611
+ it('never throws, always returns result object (VAL-API-007)', async () => {
612
+ // Test with valid response
613
+ globalThis.fetch = mock(async () =>
614
+ new Response(JSON.stringify({
615
+ model: 'test-model',
616
+ created_at: new Date().toISOString(),
617
+ message: { role: 'assistant', content: '{"name": "Test", "age": 25}' },
618
+ done: true,
619
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
620
+ ) as typeof fetch;
621
+
622
+ const model = new AIModel(createTestConfig());
623
+
624
+ // Should not throw on success
625
+ const result1 = await model.tryParseStructured(fromZod(UserSchema), [
626
+ { role: 'user', content: 'Test' },
627
+ ]);
628
+ expect(result1.ok).toBe(true);
629
+
630
+ // Now test with invalid response - need to re-mock
631
+ globalThis.fetch = mock(async () =>
632
+ new Response(JSON.stringify({
633
+ model: 'test-model',
634
+ created_at: new Date().toISOString(),
635
+ message: { role: 'assistant', content: 'invalid' },
636
+ done: true,
637
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
638
+ ) as typeof fetch;
639
+
640
+ // Should not throw on failure
641
+ const result2 = await model.tryParseStructured(fromZod(UserSchema), [
642
+ { role: 'user', content: 'Test' },
643
+ ]);
644
+ expect(result2.ok).toBe(false);
645
+ });
646
+
647
+ it('includes raw output in failure result for debugging', async () => {
648
+ globalThis.fetch = mock(async () =>
649
+ new Response(JSON.stringify({
650
+ model: 'test-model',
651
+ created_at: new Date().toISOString(),
652
+ message: { role: 'assistant', content: '{"unexpected": "structure"}' },
653
+ done: true,
654
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
655
+ ) as typeof fetch;
656
+
657
+ const model = new AIModel(createTestConfig());
658
+ const result = await model.tryParseStructured(fromZod(UserSchema), [
659
+ { role: 'user', content: 'Generate a user' },
660
+ ]);
661
+
662
+ expect(result.ok).toBe(false);
663
+ if (!result.ok) {
664
+ expect(result.rawOutput).toBe('{"unexpected": "structure"}');
665
+ expect(result.error.message).toContain('Validation failed');
666
+ }
667
+ });
668
+
669
+ it('handles options (temperature, maxTokens)', async () => {
670
+ let capturedBody: Record<string, unknown> | undefined;
671
+ globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
672
+ if (init?.body) {
673
+ capturedBody = JSON.parse(init.body as string);
674
+ }
675
+ return new Response(JSON.stringify({
676
+ model: 'test-model',
677
+ created_at: new Date().toISOString(),
678
+ message: { role: 'assistant', content: '{"name": "Test", "age": 20}' },
679
+ done: true,
680
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } });
681
+ }) as typeof fetch;
682
+
683
+ const model = new AIModel(createTestConfig());
684
+ const result = await model.tryParseStructured(fromZod(UserSchema), [
685
+ { role: 'user', content: 'Generate a user' },
686
+ ], { temperature: 0.3, maxTokens: 50 });
687
+
688
+ expect(result.ok).toBe(true);
689
+ // Verify options were passed to provider
690
+ expect(capturedBody?.options).toBeDefined();
691
+ });
692
+
693
+ it('works with failover across providers', async () => {
694
+ let firstProviderCalled = false;
695
+ globalThis.fetch = mock(async (url: string | URL | Request) => {
696
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
697
+ // First provider (Ollama) fails
698
+ if (urlStr.includes('11434') && !firstProviderCalled) {
699
+ firstProviderCalled = true;
700
+ return new Response('Server Error', { status: 500 });
701
+ }
702
+ // Second provider succeeds
703
+ return new Response(JSON.stringify({
704
+ id: 'chatcmpl-test',
705
+ object: 'chat.completion',
706
+ created: Date.now(),
707
+ model: 'test-model',
708
+ choices: [{
709
+ index: 0,
710
+ message: { role: 'assistant', content: '{"name": "Failover", "age": 99}' },
711
+ finish_reason: 'stop',
712
+ }],
713
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } });
714
+ }) as typeof fetch;
715
+
716
+ const model = new AIModel(createTestConfig({
717
+ retries: 0,
718
+ providers: [
719
+ { type: 'ollama', url: 'http://localhost:11434' },
720
+ { type: 'openai', url: 'http://localhost:8080/v1', apiKey: 'test' },
721
+ ],
722
+ }));
723
+
724
+ const result = await model.tryParseStructured(fromZod(UserSchema), [
725
+ { role: 'user', content: 'Generate a user' },
726
+ ]);
727
+
728
+ expect(result.ok).toBe(true);
729
+ if (result.ok) {
730
+ expect(result.value.name).toBe('Failover');
731
+ expect(result.value.age).toBe(99);
732
+ }
733
+ });
734
+ });
735
+
736
+ // ========================================================================
737
+ // Chat with output parameter Tests (VAL-API-004, VAL-API-005)
738
+ // ========================================================================
739
+
740
+ describe('chat with output parameter', () => {
741
+ const UserSchema = z.object({
742
+ name: z.string(),
743
+ age: z.number(),
744
+ });
745
+
746
+ it('returns response with structured property when output is provided (VAL-API-004)', async () => {
747
+ globalThis.fetch = mock(async () =>
748
+ new Response(JSON.stringify({
749
+ model: 'test-model',
750
+ created_at: new Date().toISOString(),
751
+ message: { role: 'assistant', content: '{"name": "Alice", "age": 30}' },
752
+ done: true,
753
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
754
+ ) as typeof fetch;
755
+
756
+ const model = new AIModel(createTestConfig());
757
+ const response = await model.chat([
758
+ { role: 'user', content: 'Generate a user' },
759
+ ], {
760
+ output: { schema: fromZod(UserSchema) },
761
+ });
762
+
763
+ // Response should have both message.content and structured property
764
+ expect(response.message.content).toBe('{"name": "Alice", "age": 30}');
765
+ expect(response.structured).toBeDefined();
766
+ expect(response.structured?.name).toBe('Alice');
767
+ expect(response.structured?.age).toBe(30);
768
+ });
769
+
770
+ it('returns structured property with type inference from schema', async () => {
771
+ globalThis.fetch = mock(async () =>
772
+ new Response(JSON.stringify({
773
+ model: 'test-model',
774
+ created_at: new Date().toISOString(),
775
+ message: { role: 'assistant', content: '{"name": "Bob", "age": 25}' },
776
+ done: true,
777
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
778
+ ) as typeof fetch;
779
+
780
+ const model = new AIModel(createTestConfig());
781
+ const response = await model.chat([
782
+ { role: 'user', content: 'Generate a user' },
783
+ ], {
784
+ output: { schema: fromZod(UserSchema) },
785
+ });
786
+
787
+ // Type check: response.structured should be typed correctly
788
+ if (response.structured) {
789
+ const name: string = response.structured.name;
790
+ const age: number = response.structured.age;
791
+ expect(name).toBe('Bob');
792
+ expect(age).toBe(25);
793
+ }
794
+ });
795
+
796
+ it('output parameter with name and description', async () => {
797
+ globalThis.fetch = mock(async () =>
798
+ new Response(JSON.stringify({
799
+ model: 'test-model',
800
+ created_at: new Date().toISOString(),
801
+ message: { role: 'assistant', content: '{"name": "Charlie", "age": 40}' },
802
+ done: true,
803
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
804
+ ) as typeof fetch;
805
+
806
+ const model = new AIModel(createTestConfig());
807
+ const response = await model.chat([
808
+ { role: 'user', content: 'Generate a user' },
809
+ ], {
810
+ output: {
811
+ schema: fromZod(UserSchema),
812
+ name: 'User',
813
+ description: 'A user object',
814
+ },
815
+ });
816
+
817
+ expect(response.structured?.name).toBe('Charlie');
818
+ expect(response.structured?.age).toBe(40);
819
+ });
820
+
821
+ it('throws StructuredOutputError when response fails validation', async () => {
822
+ globalThis.fetch = mock(async () =>
823
+ new Response(JSON.stringify({
824
+ model: 'test-model',
825
+ created_at: new Date().toISOString(),
826
+ message: { role: 'assistant', content: '{"name": "Alice", "age": "not a number"}' },
827
+ done: true,
828
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
829
+ ) as typeof fetch;
830
+
831
+ const model = new AIModel(createTestConfig());
832
+
833
+ expect(
834
+ model.chat([{ role: 'user', content: 'Generate a user' }], {
835
+ output: { schema: fromZod(UserSchema) },
836
+ })
837
+ ).rejects.toThrow(StructuredOutputError);
838
+ });
839
+
840
+ it('structured is undefined when output is not provided', async () => {
841
+ globalThis.fetch = mock(async () =>
842
+ new Response(JSON.stringify({
843
+ model: 'test-model',
844
+ created_at: new Date().toISOString(),
845
+ message: { role: 'assistant', content: 'Hello world' },
846
+ done: true,
847
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
848
+ ) as typeof fetch;
849
+
850
+ const model = new AIModel(createTestConfig());
851
+ const response = await model.chat([
852
+ { role: 'user', content: 'Hello' },
853
+ ]);
854
+
855
+ expect(response.structured).toBeUndefined();
856
+ });
857
+
858
+ it('works with all providers (OpenAI)', async () => {
859
+ globalThis.fetch = mock(async () =>
860
+ new Response(JSON.stringify({
861
+ id: 'chatcmpl-test',
862
+ object: 'chat.completion',
863
+ created: Date.now(),
864
+ model: 'test-model',
865
+ choices: [{
866
+ index: 0,
867
+ message: { role: 'assistant', content: '{"name": "OpenAI", "age": 35}' },
868
+ finish_reason: 'stop',
869
+ }],
870
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
871
+ ) as typeof fetch;
872
+
873
+ const model = new AIModel(createTestConfig({
874
+ providers: [{ type: 'openai', apiKey: 'sk-test' }],
875
+ }));
876
+
877
+ const response = await model.chat(
878
+ [{ role: 'user', content: 'Generate a user' }],
879
+ { output: { schema: fromZod(UserSchema) } },
880
+ );
881
+
882
+ expect(response.structured?.name).toBe('OpenAI');
883
+ expect(response.structured?.age).toBe(35);
884
+ });
885
+
886
+ it('works with all providers (Google)', async () => {
887
+ globalThis.fetch = mock(async () =>
888
+ new Response(JSON.stringify({
889
+ candidates: [{
890
+ content: {
891
+ parts: [{ text: '{"name": "Google", "age": 20}' }],
892
+ role: 'model',
893
+ },
894
+ finishReason: 'STOP',
895
+ index: 0,
896
+ }],
897
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
898
+ ) as typeof fetch;
899
+
900
+ const model = new AIModel(createTestConfig({
901
+ providers: [{ type: 'google', apiKey: 'test-key' }],
902
+ }));
903
+
904
+ const response = await model.chat(
905
+ [{ role: 'user', content: 'Generate a user' }],
906
+ { output: { schema: fromZod(UserSchema) } },
907
+ );
908
+
909
+ expect(response.structured?.name).toBe('Google');
910
+ expect(response.structured?.age).toBe(20);
911
+ });
912
+ });
913
+
914
+ describe('output and tools combined usage (VAL-API-005)', () => {
915
+ const TestSchema = z.object({
916
+ result: z.string(),
917
+ });
918
+
919
+ it('allows both output and tools in the same request', async () => {
920
+ globalThis.fetch = mock(async () =>
921
+ new Response(JSON.stringify({
922
+ model: 'test-model',
923
+ created_at: new Date().toISOString(),
924
+ message: { role: 'assistant', content: '{"result": "structured response"}' },
925
+ done: true,
926
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
927
+ ) as typeof fetch;
928
+
929
+ const model = new AIModel(createTestConfig());
930
+ const response = await model.chat([{ role: 'user', content: 'test' }], {
931
+ output: { schema: fromZod(TestSchema) },
932
+ tools: [{ type: 'function', function: { name: 'test', description: 'test', parameters: { type: 'object' } } }],
933
+ });
934
+
935
+ // When the model returns content (not tool calls), structured output should be validated
936
+ expect(response.structured?.result).toBe('structured response');
937
+ });
938
+
939
+ it('skips validation when response contains tool calls', async () => {
940
+ globalThis.fetch = mock(async () =>
941
+ new Response(JSON.stringify({
942
+ model: 'test-model',
943
+ created_at: new Date().toISOString(),
944
+ message: {
945
+ role: 'assistant',
946
+ content: '',
947
+ tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'test', arguments: '{}' } }],
948
+ },
949
+ done: true,
950
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
951
+ ) as typeof fetch;
952
+
953
+ const model = new AIModel(createTestConfig());
954
+ const response = await model.chat([{ role: 'user', content: 'test' }], {
955
+ output: { schema: fromZod(TestSchema) },
956
+ tools: [{ type: 'function', function: { name: 'test', description: 'test', parameters: { type: 'object' } } }],
957
+ });
958
+
959
+ // Response should contain tool calls without throwing validation errors
960
+ expect(response.message.tool_calls).toBeDefined();
961
+ expect(response.message.tool_calls!.length).toBe(1);
962
+ });
963
+
964
+ it('works with output only (no tools)', async () => {
965
+ globalThis.fetch = mock(async () =>
966
+ new Response(JSON.stringify({
967
+ model: 'test-model',
968
+ created_at: new Date().toISOString(),
969
+ message: { role: 'assistant', content: '{"result": "success"}' },
970
+ done: true,
971
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
972
+ ) as typeof fetch;
973
+
974
+ const model = new AIModel(createTestConfig());
975
+ const response = await model.chat([{ role: 'user', content: 'test' }], {
976
+ output: { schema: fromZod(TestSchema) },
977
+ });
978
+
979
+ expect(response.structured?.result).toBe('success');
980
+ });
981
+
982
+ it('works with tools only (no output)', async () => {
983
+ globalThis.fetch = mock(async () =>
984
+ new Response(JSON.stringify({
985
+ model: 'test-model',
986
+ created_at: new Date().toISOString(),
987
+ message: { role: 'assistant', content: 'Using tool...' },
988
+ done: true,
989
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
990
+ ) as typeof fetch;
991
+
992
+ const model = new AIModel(createTestConfig());
993
+ const response = await model.chat([{ role: 'user', content: 'test' }], {
994
+ tools: [{ type: 'function', function: { name: 'test', description: 'test', parameters: { type: 'object' } } }],
995
+ });
996
+
997
+ expect(response.message.content).toBe('Using tool...');
998
+ expect(response.structured).toBeUndefined();
999
+ });
1000
+
1001
+ it('allows output with empty tools array (no tools)', async () => {
1002
+ globalThis.fetch = mock(async () =>
1003
+ new Response(JSON.stringify({
1004
+ model: 'test-model',
1005
+ created_at: new Date().toISOString(),
1006
+ message: { role: 'assistant', content: '{"result": "success"}' },
1007
+ done: true,
1008
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
1009
+ ) as typeof fetch;
1010
+
1011
+ const model = new AIModel(createTestConfig());
1012
+
1013
+ // Empty tools array should be allowed with output (no actual tools)
1014
+ const response = await model.chat([{ role: 'user', content: 'test' }], {
1015
+ output: { schema: fromZod(TestSchema) },
1016
+ tools: [],
1017
+ });
1018
+
1019
+ expect(response.structured?.result).toBe('success');
1020
+ });
1021
+ });
1022
+
1023
+ describe('generateStructuredStream', () => {
1024
+ const UserSchema = z.object({
1025
+ name: z.string(),
1026
+ age: z.number(),
1027
+ email: z.string().optional(),
1028
+ });
1029
+
1030
+ it('yields partial validated objects during streaming (VAL-API-008)', async () => {
1031
+ // Mock streaming response that yields chunks
1032
+ const chunks = [
1033
+ 'data: {"choices":[{"delta":{"content":"{\\"name\\":"}}]}\n\n',
1034
+ 'data: {"choices":[{"delta":{"content":"\\"Alice\\""}}]}\n\n',
1035
+ 'data: {"choices":[{"delta":{"content":", \\"age\\":30"}}]}\n\n',
1036
+ 'data: {"choices":[{"delta":{"content":"}"}}]}\n\n',
1037
+ 'data: [DONE]\n\n',
1038
+ ];
1039
+
1040
+ let chunkIndex = 0;
1041
+ globalThis.fetch = mock(async () => {
1042
+ return new Response(chunks.join(''), {
1043
+ status: 200,
1044
+ headers: { 'Content-Type': 'text/event-stream' },
1045
+ });
1046
+ }) as typeof fetch;
1047
+
1048
+ const model = new AIModel(createTestConfig({
1049
+ providers: [{ type: 'openai', apiKey: 'sk-test' }],
1050
+ }));
1051
+
1052
+ const partials: unknown[] = [];
1053
+ const stream = model.generateStructuredStream(fromZod(UserSchema), [
1054
+ { role: 'user', content: 'Generate a user' },
1055
+ ]);
1056
+
1057
+ for await (const partial of stream) {
1058
+ partials.push(partial);
1059
+ }
1060
+
1061
+ // Should have yielded partial objects
1062
+ expect(partials.length).toBeGreaterThan(0);
1063
+
1064
+ // Final partial should match schema
1065
+ const lastPartial = partials[partials.length - 1];
1066
+ expect(lastPartial).toHaveProperty('name');
1067
+ });
1068
+
1069
+ it('returns complete validated object as generator return value (VAL-API-008)', async () => {
1070
+ // Mock streaming response - produce valid JSON: {"name": "Bob", "age": 25}
1071
+ const chunks = [
1072
+ 'data: {"choices":[{"delta":{"content":"{\\"name\\":"}}]}\n\n',
1073
+ 'data: {"choices":[{"delta":{"content":" \\"Bob\\""}}]}\n\n',
1074
+ 'data: {"choices":[{"delta":{"content":", \\"age\\": 25"}}]}\n\n',
1075
+ 'data: {"choices":[{"delta":{"content":"}"}}]}\n\n',
1076
+ 'data: [DONE]\n\n',
1077
+ ];
1078
+
1079
+ globalThis.fetch = mock(async () => {
1080
+ return new Response(chunks.join(''), {
1081
+ status: 200,
1082
+ headers: { 'Content-Type': 'text/event-stream' },
1083
+ });
1084
+ }) as typeof fetch;
1085
+
1086
+ const model = new AIModel(createTestConfig({
1087
+ providers: [{ type: 'openai', apiKey: 'sk-test' }],
1088
+ }));
1089
+
1090
+ const stream = model.generateStructuredStream(fromZod(UserSchema), [
1091
+ { role: 'user', content: 'Generate a user' },
1092
+ ]);
1093
+
1094
+ // Consume the stream
1095
+ const partials: unknown[] = [];
1096
+ for await (const partial of stream) {
1097
+ partials.push(partial);
1098
+ }
1099
+
1100
+ // Verify we got partials
1101
+ expect(partials.length).toBeGreaterThan(0);
1102
+ });
1103
+
1104
+ it('handles validation errors mid-stream gracefully', async () => {
1105
+ // Mock streaming response with invalid data mid-stream that becomes valid
1106
+ const chunks = [
1107
+ 'data: {"choices":[{"delta":{"content":"{\\"name\\":"}}]}\n\n',
1108
+ 'data: {"choices":[{"delta":{"content":"\\"test\\""}}]}\n\n',
1109
+ 'data: {"choices":[{"delta":{"content":", \\"age\\": \\"invalid\\""}}]}\n\n', // age should be number
1110
+ 'data: {"choices":[{"delta":{"content":\\"}"}}]}\n\n',
1111
+ 'data: [DONE]\n\n',
1112
+ ];
1113
+
1114
+ globalThis.fetch = mock(async () => {
1115
+ return new Response(chunks.join(''), {
1116
+ status: 200,
1117
+ headers: { 'Content-Type': 'text/event-stream' },
1118
+ });
1119
+ }) as typeof fetch;
1120
+
1121
+ const model = new AIModel(createTestConfig({
1122
+ providers: [{ type: 'openai', apiKey: 'sk-test' }],
1123
+ }));
1124
+
1125
+ const stream = model.generateStructuredStream(fromZod(UserSchema), [
1126
+ { role: 'user', content: 'Generate a user' },
1127
+ ]);
1128
+
1129
+ // Consume the stream - should either yield valid partials or throw at the end
1130
+ const partials: unknown[] = [];
1131
+ try {
1132
+ for await (const partial of stream) {
1133
+ partials.push(partial);
1134
+ }
1135
+ // If no error was thrown, check that we handled it gracefully
1136
+ expect(partials.length).toBeGreaterThanOrEqual(0);
1137
+ } catch (error) {
1138
+ // If error is thrown, it should be StructuredOutputError
1139
+ expect(error).toBeInstanceOf(StructuredOutputError);
1140
+ }
1141
+ });
1142
+
1143
+ it('works with Ollama provider (VAL-PROVIDER-OLLAMA-005)', async () => {
1144
+ // Mock NDJSON streaming response from Ollama
1145
+ const chunks = [
1146
+ JSON.stringify({ model: 'test', message: { content: '{"name":' }, done: false }) + '\n',
1147
+ JSON.stringify({ model: 'test', message: { content: ' "Ollama",' }, done: false }) + '\n',
1148
+ JSON.stringify({ model: 'test', message: { content: ' "age": 35' }, done: false }) + '\n',
1149
+ JSON.stringify({ model: 'test', message: { content: '}' }, done: true }) + '\n',
1150
+ ];
1151
+
1152
+ globalThis.fetch = mock(async () => {
1153
+ return new Response(chunks.join(''), {
1154
+ status: 200,
1155
+ headers: { 'Content-Type': 'application/x-ndjson' },
1156
+ });
1157
+ }) as typeof fetch;
1158
+
1159
+ const model = new AIModel(createTestConfig());
1160
+
1161
+ const stream = model.generateStructuredStream(fromZod(UserSchema), [
1162
+ { role: 'user', content: 'Generate a user' },
1163
+ ]);
1164
+
1165
+ const partials: unknown[] = [];
1166
+ for await (const partial of stream) {
1167
+ partials.push(partial);
1168
+ }
1169
+
1170
+ // Should have yielded partials
1171
+ expect(partials.length).toBeGreaterThan(0);
1172
+ });
1173
+
1174
+ it('works with Google provider (VAL-PROVIDER-GOOGLE-005)', async () => {
1175
+ // Mock SSE streaming response from Google
1176
+ const chunks = [
1177
+ 'data: {"candidates":[{"content":{"parts":[{"text":"{"}]}}]}\n\n',
1178
+ 'data: {"candidates":[{"content":{"parts":[{"text":"\\"name\\": \\"Google\\""}]}}]}\n\n',
1179
+ 'data: {"candidates":[{"content":{"parts":[{"text":", \\"age\\": 42"}]}}]}\n\n',
1180
+ 'data: {"candidates":[{"content":{"parts":[{"text":"}"}]}}]}\n\n',
1181
+ 'data: [DONE]\n\n',
1182
+ ];
1183
+
1184
+ globalThis.fetch = mock(async () => {
1185
+ return new Response(chunks.join(''), {
1186
+ status: 200,
1187
+ headers: { 'Content-Type': 'text/event-stream' },
1188
+ });
1189
+ }) as typeof fetch;
1190
+
1191
+ const model = new AIModel(createTestConfig({
1192
+ providers: [{ type: 'google', apiKey: 'test-key' }],
1193
+ }));
1194
+
1195
+ const stream = model.generateStructuredStream(fromZod(UserSchema), [
1196
+ { role: 'user', content: 'Generate a user' },
1197
+ ]);
1198
+
1199
+ const partials: unknown[] = [];
1200
+ for await (const partial of stream) {
1201
+ partials.push(partial);
1202
+ }
1203
+
1204
+ // Should have yielded partials
1205
+ expect(partials.length).toBeGreaterThan(0);
1206
+ });
1207
+
1208
+ it('accepts ChatOptions (temperature, maxTokens)', async () => {
1209
+ // Mock streaming response
1210
+ const chunks = [
1211
+ 'data: {"choices":[{"delta":{"content":"{\\"name\\": \\"Test\\""}}]}\n\n',
1212
+ 'data: {"choices":[{"delta":{"content":", \\"age\\": 1}"}}]}\n\n',
1213
+ 'data: [DONE]\n\n',
1214
+ ];
1215
+
1216
+ globalThis.fetch = mock(async () => {
1217
+ return new Response(chunks.join(''), {
1218
+ status: 200,
1219
+ headers: { 'Content-Type': 'text/event-stream' },
1220
+ });
1221
+ }) as typeof fetch;
1222
+
1223
+ const model = new AIModel(createTestConfig({
1224
+ providers: [{ type: 'openai', apiKey: 'sk-test' }],
1225
+ }));
1226
+
1227
+ const stream = model.generateStructuredStream(fromZod(UserSchema), [
1228
+ { role: 'user', content: 'Generate a user' },
1229
+ ], { temperature: 0.5, maxTokens: 100 });
1230
+
1231
+ // Consume the stream
1232
+ const partials: unknown[] = [];
1233
+ for await (const partial of stream) {
1234
+ partials.push(partial);
1235
+ }
1236
+
1237
+ expect(partials.length).toBeGreaterThan(0);
1238
+ });
1239
+
1240
+ it('throws StructuredOutputError on final validation failure', async () => {
1241
+ // Mock streaming response that ends with invalid JSON
1242
+ const chunks = [
1243
+ 'data: {"choices":[{"delta":{"content":"{\\"name\\":"}}]}\n\n',
1244
+ 'data: {"choices":[{"delta":{"content":"\\"test\\""}}]}\n\n',
1245
+ 'data: {"choices":[{"delta":{"content":"}"}}]}\n\n', // Missing age (required by schema)
1246
+ 'data: [DONE]\n\n',
1247
+ ];
1248
+
1249
+ globalThis.fetch = mock(async () => {
1250
+ return new Response(chunks.join(''), {
1251
+ status: 200,
1252
+ headers: { 'Content-Type': 'text/event-stream' },
1253
+ });
1254
+ }) as typeof fetch;
1255
+
1256
+ const model = new AIModel(createTestConfig({
1257
+ providers: [{ type: 'openai', apiKey: 'sk-test' }],
1258
+ }));
1259
+
1260
+ const StrictSchema = z.object({
1261
+ name: z.string(),
1262
+ age: z.number(), // Required
1263
+ });
1264
+
1265
+ const stream = model.generateStructuredStream(fromZod(StrictSchema), [
1266
+ { role: 'user', content: 'Generate a user' },
1267
+ ]);
1268
+
1269
+ // Consume the stream - expect StructuredOutputError at the end
1270
+ let errorCaught: Error | null = null;
1271
+ try {
1272
+ for await (const _ of stream) {
1273
+ // Consume partials
1274
+ }
1275
+ } catch (error) {
1276
+ errorCaught = error as Error;
1277
+ }
1278
+
1279
+ // Should either gracefully handle partial objects or throw validation error
1280
+ // Both behaviors are acceptable
1281
+ if (errorCaught) {
1282
+ expect(errorCaught).toBeInstanceOf(StructuredOutputError);
1283
+ }
1284
+ });
1285
+ });
1286
+ });
1287
+
1288
+ // ============================================================================
1289
+ // Structured Output Auditor Tests (VAL-CROSS-003)
1290
+ // ============================================================================
1291
+
1292
+ describe('structured output auditor events', () => {
1293
+ const TestSchema = z.object({
1294
+ name: z.string(),
1295
+ value: z.number(),
1296
+ });
1297
+
1298
+ const originalFetch = globalThis.fetch;
1299
+
1300
+ afterEach(() => {
1301
+ globalThis.fetch = originalFetch;
1302
+ });
1303
+
1304
+ describe('generateStructured auditor events', () => {
1305
+ it('emits structured_request and structured_response events on success', async () => {
1306
+ const auditor = new BufferedAuditor();
1307
+ globalThis.fetch = mock(async () =>
1308
+ new Response(JSON.stringify({
1309
+ model: 'test-model',
1310
+ created_at: new Date().toISOString(),
1311
+ message: { role: 'assistant', content: '{"name": "Alice", "value": 42}' },
1312
+ done: true,
1313
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
1314
+ ) as typeof fetch;
1315
+
1316
+ const model = new AIModel(createTestConfig({ auditor }));
1317
+ await model.generateStructured(fromZod(TestSchema), [
1318
+ { role: 'user', content: 'Generate data' },
1319
+ ]);
1320
+
1321
+ const events = auditor.getEvents();
1322
+ const types = events.map(e => e.type);
1323
+
1324
+ // Should emit structured_request and structured_response
1325
+ expect(types).toContain('structured_request');
1326
+ expect(types).toContain('structured_response');
1327
+
1328
+ // Check structured_request event details
1329
+ const requestEvent = events.find(e => e.type === 'structured_request');
1330
+ expect(requestEvent?.schemaName).toBe('response');
1331
+
1332
+ // Check structured_response event details
1333
+ const responseEvent = events.find(e => e.type === 'structured_response');
1334
+ expect(responseEvent?.schemaName).toBe('response');
1335
+ expect(responseEvent?.duration).toBeDefined();
1336
+ });
1337
+
1338
+ it('emits structured_validation_error on validation failure', async () => {
1339
+ const auditor = new BufferedAuditor();
1340
+ globalThis.fetch = mock(async () =>
1341
+ new Response(JSON.stringify({
1342
+ model: 'test-model',
1343
+ created_at: new Date().toISOString(),
1344
+ message: { role: 'assistant', content: '{"name": "Alice", "value": "not a number"}' },
1345
+ done: true,
1346
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
1347
+ ) as typeof fetch;
1348
+
1349
+ const model = new AIModel(createTestConfig({ auditor }));
1350
+
1351
+ try {
1352
+ await model.generateStructured(fromZod(TestSchema), [
1353
+ { role: 'user', content: 'Generate data' },
1354
+ ]);
1355
+ } catch {
1356
+ // Expected to throw StructuredOutputError
1357
+ }
1358
+
1359
+ const events = auditor.getEvents();
1360
+ const types = events.map(e => e.type);
1361
+
1362
+ // Should emit structured_request and structured_validation_error
1363
+ expect(types).toContain('structured_request');
1364
+ expect(types).toContain('structured_validation_error');
1365
+
1366
+ // Check structured_validation_error event details
1367
+ const validationError = events.find(e => e.type === 'structured_validation_error');
1368
+ expect(validationError?.schemaName).toBe('response');
1369
+ expect(validationError?.error).toBeDefined();
1370
+ expect(validationError?.rawOutput).toBeDefined();
1371
+ });
1372
+
1373
+ it('uses custom schema name when provided', async () => {
1374
+ const auditor = new BufferedAuditor();
1375
+ globalThis.fetch = mock(async () =>
1376
+ new Response(JSON.stringify({
1377
+ model: 'test-model',
1378
+ created_at: new Date().toISOString(),
1379
+ message: { role: 'assistant', content: '{"name": "Test", "value": 1}' },
1380
+ done: true,
1381
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
1382
+ ) as typeof fetch;
1383
+
1384
+ const model = new AIModel(createTestConfig({ auditor }));
1385
+ await model.generateStructured(fromZod(TestSchema), [
1386
+ { role: 'user', content: 'Generate data' },
1387
+ ], { schemaName: 'CustomSchema' });
1388
+
1389
+ const events = auditor.getEvents();
1390
+
1391
+ // Check structured_request event has custom schema name
1392
+ const requestEvent = events.find(e => e.type === 'structured_request');
1393
+ expect(requestEvent?.schemaName).toBe('CustomSchema');
1394
+
1395
+ // Check structured_response event has custom schema name
1396
+ const responseEvent = events.find(e => e.type === 'structured_response');
1397
+ expect(responseEvent?.schemaName).toBe('CustomSchema');
1398
+ });
1399
+ });
1400
+
1401
+ describe('chat output parameter auditor events', () => {
1402
+ it('emits structured_request and structured_response with output parameter', async () => {
1403
+ const auditor = new BufferedAuditor();
1404
+ globalThis.fetch = mock(async () =>
1405
+ new Response(JSON.stringify({
1406
+ model: 'test-model',
1407
+ created_at: new Date().toISOString(),
1408
+ message: { role: 'assistant', content: '{"name": "Bob", "value": 100}' },
1409
+ done: true,
1410
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
1411
+ ) as typeof fetch;
1412
+
1413
+ const model = new AIModel(createTestConfig({ auditor }));
1414
+ const response = await model.chat([
1415
+ { role: 'user', content: 'Generate data' },
1416
+ ], { output: { schema: fromZod(TestSchema), name: 'TestData' } });
1417
+
1418
+ const events = auditor.getEvents();
1419
+ const types = events.map(e => e.type);
1420
+
1421
+ // Should emit structured_request and structured_response
1422
+ expect(types).toContain('structured_request');
1423
+ expect(types).toContain('structured_response');
1424
+
1425
+ // Verify structured property is populated
1426
+ expect(response.structured?.name).toBe('Bob');
1427
+ expect(response.structured?.value).toBe(100);
1428
+ });
1429
+
1430
+ it('emits structured_validation_error when output validation fails', async () => {
1431
+ const auditor = new BufferedAuditor();
1432
+ globalThis.fetch = mock(async () =>
1433
+ new Response(JSON.stringify({
1434
+ model: 'test-model',
1435
+ created_at: new Date().toISOString(),
1436
+ message: { role: 'assistant', content: '{"invalid": "data"}' },
1437
+ done: true,
1438
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
1439
+ ) as typeof fetch;
1440
+
1441
+ const model = new AIModel(createTestConfig({ auditor }));
1442
+
1443
+ try {
1444
+ await model.chat([
1445
+ { role: 'user', content: 'Generate data' },
1446
+ ], { output: { schema: fromZod(TestSchema) } });
1447
+ } catch {
1448
+ // Expected to throw StructuredOutputError
1449
+ }
1450
+
1451
+ const events = auditor.getEvents();
1452
+ const types = events.map(e => e.type);
1453
+
1454
+ expect(types).toContain('structured_request');
1455
+ expect(types).toContain('structured_validation_error');
1456
+
1457
+ const validationError = events.find(e => e.type === 'structured_validation_error');
1458
+ expect(validationError?.rawOutput).toBe('{"invalid": "data"}');
1459
+ });
1460
+ });
1461
+
1462
+ describe('generateStructuredStream auditor events', () => {
1463
+ it('emits structured_request on stream start and structured_response on completion', async () => {
1464
+ const auditor = new BufferedAuditor();
1465
+ const chunks = [
1466
+ 'data: {"choices":[{"delta":{"content":"{\\"name\\":"}}]}\n\n',
1467
+ 'data: {"choices":[{"delta":{"content":" \\"Stream\\""}}]}\n\n',
1468
+ 'data: {"choices":[{"delta":{"content":", \\"value\\": 99"}}]}\n\n',
1469
+ 'data: {"choices":[{"delta":{"content":"}"}}]}\n\n',
1470
+ 'data: [DONE]\n\n',
1471
+ ];
1472
+
1473
+ globalThis.fetch = mock(async () =>
1474
+ new Response(chunks.join(''), {
1475
+ status: 200,
1476
+ headers: { 'Content-Type': 'text/event-stream' },
1477
+ })
1478
+ ) as typeof fetch;
1479
+
1480
+ const model = new AIModel(createTestConfig({
1481
+ auditor,
1482
+ providers: [{ type: 'openai', apiKey: 'sk-test' }],
1483
+ }));
1484
+
1485
+ const stream = model.generateStructuredStream(fromZod(TestSchema), [
1486
+ { role: 'user', content: 'Generate data' },
1487
+ ]);
1488
+
1489
+ // Consume the stream
1490
+ for await (const _ of stream) {
1491
+ // Just consume the partials
1492
+ }
1493
+
1494
+ const events = auditor.getEvents();
1495
+ const types = events.map(e => e.type);
1496
+
1497
+ // Should emit structured_request at start and structured_response at end
1498
+ expect(types).toContain('structured_request');
1499
+ expect(types).toContain('structured_response');
1500
+
1501
+ // Check order: request before response
1502
+ const requestIdx = types.indexOf('structured_request');
1503
+ const responseIdx = types.indexOf('structured_response');
1504
+ expect(requestIdx).toBeLessThan(responseIdx);
1505
+ });
1506
+
1507
+ it('emits structured_validation_error when stream validation fails', async () => {
1508
+ const auditor = new BufferedAuditor();
1509
+ // Invalid JSON that doesn't match schema
1510
+ const chunks = [
1511
+ 'data: {"choices":[{"delta":{"content":"{\\"name\\":"}}]}\n\n',
1512
+ 'data: {"choices":[{"delta":{"content":"\\"invalid\\""}}]}\n\n',
1513
+ 'data: {"choices":[{"delta":{"content":"}"}}]}\n\n', // Missing required 'value' field
1514
+ 'data: [DONE]\n\n',
1515
+ ];
1516
+
1517
+ globalThis.fetch = mock(async () =>
1518
+ new Response(chunks.join(''), {
1519
+ status: 200,
1520
+ headers: { 'Content-Type': 'text/event-stream' },
1521
+ })
1522
+ ) as typeof fetch;
1523
+
1524
+ const model = new AIModel(createTestConfig({
1525
+ auditor,
1526
+ providers: [{ type: 'openai', apiKey: 'sk-test' }],
1527
+ }));
1528
+
1529
+ const StrictSchema = z.object({
1530
+ name: z.string(),
1531
+ value: z.number(),
1532
+ });
1533
+
1534
+ try {
1535
+ const stream = model.generateStructuredStream(fromZod(StrictSchema), [
1536
+ { role: 'user', content: 'Generate data' },
1537
+ ]);
1538
+ for await (const _ of stream) {
1539
+ // Consume stream
1540
+ }
1541
+ } catch {
1542
+ // Expected to throw
1543
+ }
1544
+
1545
+ const events = auditor.getEvents();
1546
+ const types = events.map(e => e.type);
1547
+
1548
+ // Should emit structured_request and structured_validation_error
1549
+ expect(types).toContain('structured_request');
1550
+ expect(types).toContain('structured_validation_error');
1551
+ });
1552
+ });
1553
+
1554
+ describe('existing chat/stream auditor events still work', () => {
1555
+ it('chat without structured output still emits request/response events', async () => {
1556
+ const auditor = new BufferedAuditor();
1557
+ globalThis.fetch = mock(async () =>
1558
+ new Response(JSON.stringify({
1559
+ model: 'test-model',
1560
+ created_at: new Date().toISOString(),
1561
+ message: { role: 'assistant', content: 'Hello world' },
1562
+ done: true,
1563
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
1564
+ ) as typeof fetch;
1565
+
1566
+ const model = new AIModel(createTestConfig({ auditor }));
1567
+ await model.chat([{ role: 'user', content: 'Hello' }]);
1568
+
1569
+ const events = auditor.getEvents();
1570
+ const types = events.map(e => e.type);
1571
+
1572
+ // Should emit standard request/response events (not structured events)
1573
+ expect(types).toContain('request');
1574
+ expect(types).toContain('response');
1575
+ expect(types).not.toContain('structured_request');
1576
+ expect(types).not.toContain('structured_response');
1577
+ });
1578
+
1579
+ it('chatStream emits stream_start and stream_end events', async () => {
1580
+ const auditor = new BufferedAuditor();
1581
+ const chunks = [
1582
+ 'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n',
1583
+ 'data: {"choices":[{"delta":{"content":" world"}}]}\n\n',
1584
+ 'data: [DONE]\n\n',
1585
+ ];
1586
+
1587
+ globalThis.fetch = mock(async () =>
1588
+ new Response(chunks.join(''), {
1589
+ status: 200,
1590
+ headers: { 'Content-Type': 'text/event-stream' },
1591
+ })
1592
+ ) as typeof fetch;
1593
+
1594
+ const model = new AIModel(createTestConfig({
1595
+ auditor,
1596
+ providers: [{ type: 'openai', apiKey: 'sk-test' }],
1597
+ }));
1598
+
1599
+ const stream = model.chatStream([{ role: 'user', content: 'Hello' }]);
1600
+ for await (const _ of stream) {
1601
+ // Consume stream
1602
+ }
1603
+
1604
+ const events = auditor.getEvents();
1605
+ const types = events.map(e => e.type);
1606
+
1607
+ // Should emit stream_start and stream_end, not structured events
1608
+ expect(types).toContain('stream_start');
1609
+ expect(types).toContain('stream_end');
1610
+ expect(types).not.toContain('structured_request');
1611
+ expect(types).not.toContain('structured_response');
1612
+ });
1613
+ });
1614
+ });