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,1122 @@
1
+ import { fromZod } from '../../zod-adapter.js';
2
+ /**
3
+ * OpenAI-Compatible Provider Structured Output Tests
4
+ *
5
+ * Tests the OpenAICompatibleClient's structured output support (response_format).
6
+ * Validates assertions:
7
+ * - VAL-PROVIDER-OPENAI-001: response_format json_schema Request
8
+ * - VAL-PROVIDER-OPENAI-005: Provider-Specific Schema Limitations
9
+ */
10
+
11
+ import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test';
12
+ import { z } from 'zod';
13
+ import { OpenAICompatibleClient } from '../../providers/openai.js';
14
+ import type { LLMClientOptions, ChatOptions } from '../../interfaces.js';
15
+ import { AIModelApiType } from '../../interfaces.js';
16
+ import {
17
+ type StructuredOutputOptions,
18
+ parseStructured,
19
+ } from '../../structured-output.js';
20
+
21
+ // ============================================================================
22
+ // Helpers
23
+ // ============================================================================
24
+
25
+ function createClient(overrides?: Partial<LLMClientOptions>): OpenAICompatibleClient {
26
+ return new OpenAICompatibleClient({
27
+ model: 'test-model',
28
+ url: 'https://api.openai.com/v1',
29
+ apiType: AIModelApiType.OpenAI,
30
+ ...overrides,
31
+ });
32
+ }
33
+
34
+ const OPENAI_RESPONSE = {
35
+ id: 'test-id',
36
+ object: 'chat.completion',
37
+ created: 1700000000,
38
+ model: 'test-model',
39
+ choices: [{
40
+ index: 0,
41
+ message: {
42
+ role: 'assistant',
43
+ content: '{"name": "Alice", "age": 30}',
44
+ },
45
+ finish_reason: 'stop',
46
+ }],
47
+ usage: {
48
+ prompt_tokens: 10,
49
+ completion_tokens: 20,
50
+ total_tokens: 30,
51
+ },
52
+ };
53
+
54
+ // ============================================================================
55
+ // Tests
56
+ // ============================================================================
57
+
58
+ describe('OpenAICompatibleClient Structured Output', () => {
59
+ let originalFetch: typeof globalThis.fetch;
60
+
61
+ beforeEach(() => {
62
+ originalFetch = globalThis.fetch;
63
+ });
64
+
65
+ afterEach(() => {
66
+ globalThis.fetch = originalFetch;
67
+ });
68
+
69
+ /** Capture the body sent to OpenAI's /v1/chat/completions */
70
+ function mockFetchAndCapture(response = OPENAI_RESPONSE) {
71
+ let capturedBody: Record<string, unknown> | null = null;
72
+
73
+ globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
74
+ if (init?.body) {
75
+ capturedBody = JSON.parse(init.body as string);
76
+ }
77
+ return new Response(JSON.stringify(response), {
78
+ status: 200,
79
+ headers: { 'Content-Type': 'application/json' },
80
+ });
81
+ }) as typeof fetch;
82
+
83
+ return () => capturedBody;
84
+ }
85
+
86
+ // ========================================================================
87
+ // VAL-PROVIDER-OPENAI-001: response_format json_schema Request
88
+ // ========================================================================
89
+
90
+ describe('response_format json_schema', () => {
91
+ test('includes response_format with json_schema type when schema provided', async () => {
92
+ const getBody = mockFetchAndCapture();
93
+ const client = createClient();
94
+
95
+ const UserSchema = z.object({
96
+ name: z.string(),
97
+ age: z.number(),
98
+ });
99
+
100
+ const options: ChatOptions = {
101
+ schema: fromZod(UserSchema),
102
+ };
103
+
104
+ await client.chat([
105
+ { role: 'user', content: 'Generate a user' },
106
+ ], options);
107
+
108
+ const body = getBody()!;
109
+ expect(body['response_format']).toBeDefined();
110
+ expect((body['response_format'] as Record<string, unknown>)['type']).toBe('json_schema');
111
+ expect((body['response_format'] as Record<string, unknown>)['json_schema']).toBeDefined();
112
+ });
113
+
114
+ test('includes strict mode when schema provided', async () => {
115
+ const getBody = mockFetchAndCapture();
116
+ const client = createClient();
117
+
118
+ const UserSchema = z.object({
119
+ name: z.string(),
120
+ age: z.number(),
121
+ });
122
+
123
+ const options: ChatOptions = {
124
+ schema: fromZod(UserSchema),
125
+ };
126
+
127
+ await client.chat([
128
+ { role: 'user', content: 'Generate a user' },
129
+ ], options);
130
+
131
+ const body = getBody()!;
132
+ const responseFormat = body['response_format'] as Record<string, unknown>;
133
+ expect(responseFormat['json_schema']).toBeDefined();
134
+
135
+ const jsonSchema = responseFormat['json_schema'] as Record<string, unknown>;
136
+ // strict should be true for reliable structured output
137
+ expect(jsonSchema['strict']).toBe(true);
138
+ expect(jsonSchema['name']).toBeDefined();
139
+ expect(jsonSchema['schema']).toBeDefined();
140
+ });
141
+
142
+ test('converts Zod schema to JSON Schema in response_format', async () => {
143
+ const getBody = mockFetchAndCapture();
144
+ const client = createClient();
145
+
146
+ const UserSchema = z.object({
147
+ name: z.string(),
148
+ age: z.number().optional(),
149
+ });
150
+
151
+ const options: ChatOptions = {
152
+ schema: fromZod(UserSchema),
153
+ };
154
+
155
+ await client.chat([
156
+ { role: 'user', content: 'Generate a user' },
157
+ ], options);
158
+
159
+ const body = getBody()!;
160
+ const responseFormat = body['response_format'] as Record<string, unknown>;
161
+ const jsonSchema = responseFormat['json_schema'] as Record<string, unknown>;
162
+ const schema = jsonSchema['schema'] as Record<string, unknown>;
163
+
164
+ expect(schema['type']).toBe('object');
165
+ expect(schema['properties']).toBeDefined();
166
+ expect(schema['properties']!['name']).toEqual({ type: 'string' });
167
+ expect(schema['properties']!['age']).toEqual({ type: 'number' });
168
+ expect(schema['required']).toEqual(['name']);
169
+ });
170
+
171
+ test('uses schema name from options when provided', async () => {
172
+ const getBody = mockFetchAndCapture();
173
+ const client = createClient();
174
+
175
+ const UserSchema = z.object({
176
+ name: z.string(),
177
+ });
178
+
179
+ const options: ChatOptions = {
180
+ schema: fromZod(UserSchema),
181
+ schemaName: 'CustomUserSchema',
182
+ };
183
+
184
+ await client.chat([
185
+ { role: 'user', content: 'Generate' },
186
+ ], options);
187
+
188
+ const body = getBody()!;
189
+ const responseFormat = body['response_format'] as Record<string, unknown>;
190
+ const jsonSchema = responseFormat['json_schema'] as Record<string, unknown>;
191
+
192
+ expect(jsonSchema['name']).toBe('CustomUserSchema');
193
+ });
194
+
195
+ test('generates default schema name when not provided', async () => {
196
+ const getBody = mockFetchAndCapture();
197
+ const client = createClient();
198
+
199
+ const UserSchema = z.object({
200
+ name: z.string(),
201
+ });
202
+
203
+ const options: ChatOptions = {
204
+ schema: fromZod(UserSchema),
205
+ };
206
+
207
+ await client.chat([
208
+ { role: 'user', content: 'Generate' },
209
+ ], options);
210
+
211
+ const body = getBody()!;
212
+ const responseFormat = body['response_format'] as Record<string, unknown>;
213
+ const jsonSchema = responseFormat['json_schema'] as Record<string, unknown>;
214
+
215
+ // Should have a name, either provided or auto-generated
216
+ expect(jsonSchema['name']).toBeDefined();
217
+ expect(typeof jsonSchema['name']).toBe('string');
218
+ expect((jsonSchema['name'] as string).length).toBeGreaterThan(0);
219
+ });
220
+
221
+ test('includes schema description when provided', async () => {
222
+ const getBody = mockFetchAndCapture();
223
+ const client = createClient();
224
+
225
+ const UserSchema = z.object({
226
+ name: z.string(),
227
+ });
228
+
229
+ const options: ChatOptions = {
230
+ schema: fromZod(UserSchema),
231
+ schemaDescription: 'A user object with name',
232
+ };
233
+
234
+ await client.chat([
235
+ { role: 'user', content: 'Generate' },
236
+ ], options);
237
+
238
+ const body = getBody()!;
239
+ const responseFormat = body['response_format'] as Record<string, unknown>;
240
+ const jsonSchema = responseFormat['json_schema'] as Record<string, unknown>;
241
+
242
+ expect(jsonSchema['description']).toBe('A user object with name');
243
+ });
244
+
245
+ test('accepts raw JSON Schema instead of Zod schema', async () => {
246
+ // Create mock with custom response for jsonSchema test
247
+ let capturedBody: Record<string, unknown> | null = null;
248
+
249
+ globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
250
+ if (init?.body) {
251
+ capturedBody = JSON.parse(init.body as string);
252
+ }
253
+ return new Response(JSON.stringify({
254
+ id: 'test-id',
255
+ object: 'chat.completion',
256
+ created: 1700000000,
257
+ model: 'test-model',
258
+ choices: [{
259
+ index: 0,
260
+ message: {
261
+ role: 'assistant',
262
+ content: '{"id": "123", "count": 5}',
263
+ },
264
+ finish_reason: 'stop',
265
+ }],
266
+ usage: {
267
+ prompt_tokens: 10,
268
+ completion_tokens: 20,
269
+ total_tokens: 30,
270
+ },
271
+ }), {
272
+ status: 200,
273
+ headers: { 'Content-Type': 'application/json' },
274
+ });
275
+ }) as typeof fetch;
276
+
277
+ const client = createClient();
278
+
279
+ const jsonSchema = {
280
+ type: 'object' as const,
281
+ properties: {
282
+ id: { type: 'string' as const },
283
+ count: { type: 'number' as const },
284
+ },
285
+ required: ['id'],
286
+ };
287
+
288
+ const options: ChatOptions = {
289
+ jsonSchema,
290
+ };
291
+
292
+ const response = await client.chat([
293
+ { role: 'user', content: 'Generate' },
294
+ ], options);
295
+
296
+ const body = capturedBody!;
297
+ const responseFormat = body['response_format'] as Record<string, unknown>;
298
+ expect(responseFormat).toBeDefined();
299
+ expect(responseFormat['type']).toBe('json_schema');
300
+
301
+ const jsonSchemaReq = responseFormat['json_schema'] as Record<string, unknown>;
302
+ expect(jsonSchemaReq['schema']).toBeDefined();
303
+ // The schema property contains the normalized JSON Schema
304
+ const schema = jsonSchemaReq['schema'] as Record<string, unknown>;
305
+ expect(schema.properties).toBeDefined();
306
+ // Verify response processed successfully (z.unknown() always passes)
307
+ expect(response.message.content).toContain('id');
308
+ });
309
+ });
310
+
311
+ // ========================================================================
312
+ // Response Validation
313
+ // ========================================================================
314
+
315
+ describe('response validation', () => {
316
+ test('validates response JSON against schema', async () => {
317
+ mockFetchAndCapture({
318
+ ...OPENAI_RESPONSE,
319
+ choices: [{
320
+ index: 0,
321
+ message: {
322
+ role: 'assistant',
323
+ content: '{"name": "Bob", "age": 25}',
324
+ },
325
+ finish_reason: 'stop',
326
+ }],
327
+ });
328
+
329
+ const client = createClient();
330
+
331
+ const UserSchema = z.object({
332
+ name: z.string(),
333
+ age: z.number(),
334
+ });
335
+
336
+ const options: ChatOptions = {
337
+ schema: fromZod(UserSchema),
338
+ };
339
+
340
+ const response = await client.chat([
341
+ { role: 'user', content: 'Generate a user' },
342
+ ], options);
343
+
344
+ // If we got here without error, validation passed
345
+ expect(response.message.content).toBe('{"name": "Bob", "age": 25}');
346
+ });
347
+
348
+ test('provider does NOT validate response (validation is centralized in Router)', async () => {
349
+ mockFetchAndCapture({
350
+ ...OPENAI_RESPONSE,
351
+ choices: [{
352
+ index: 0,
353
+ message: {
354
+ role: 'assistant',
355
+ content: 'not valid json at all',
356
+ },
357
+ finish_reason: 'stop',
358
+ }],
359
+ });
360
+
361
+ const client = createClient();
362
+
363
+ const UserSchema = z.object({
364
+ name: z.string(),
365
+ });
366
+
367
+ const options: ChatOptions = {
368
+ schema: fromZod(UserSchema),
369
+ };
370
+
371
+ // Provider should NOT throw — validation is done at Router level
372
+ const response = await client.chat([
373
+ { role: 'user', content: 'Generate' },
374
+ ], options);
375
+ expect(response.message.content).toBe('not valid json at all');
376
+ });
377
+
378
+ test('provider returns raw response even on schema mismatch (Router validates)', async () => {
379
+ mockFetchAndCapture({
380
+ ...OPENAI_RESPONSE,
381
+ choices: [{
382
+ index: 0,
383
+ message: {
384
+ role: 'assistant',
385
+ content: '{"name": "Bob", "age": "not a number"}',
386
+ },
387
+ finish_reason: 'stop',
388
+ }],
389
+ });
390
+
391
+ const client = createClient();
392
+
393
+ const UserSchema = z.object({
394
+ name: z.string(),
395
+ age: z.number(),
396
+ });
397
+
398
+ const options: ChatOptions = {
399
+ schema: fromZod(UserSchema),
400
+ };
401
+
402
+ // Provider should NOT throw — validation is done at Router level
403
+ const response = await client.chat([
404
+ { role: 'user', content: 'Generate' },
405
+ ], options);
406
+ expect(response.message.content).toBe('{"name": "Bob", "age": "not a number"}');
407
+ });
408
+
409
+ test('includes raw output in response when schema provided', async () => {
410
+ const rawOutput = '{"name": 123}';
411
+ mockFetchAndCapture({
412
+ ...OPENAI_RESPONSE,
413
+ choices: [{
414
+ index: 0,
415
+ message: {
416
+ role: 'assistant',
417
+ content: rawOutput,
418
+ },
419
+ finish_reason: 'stop',
420
+ }],
421
+ });
422
+
423
+ const client = createClient();
424
+
425
+ const UserSchema = z.object({
426
+ name: z.string(),
427
+ });
428
+
429
+ const options: ChatOptions = {
430
+ schema: fromZod(UserSchema),
431
+ };
432
+
433
+ // Provider returns raw response — Router handles validation
434
+ const response = await client.chat([
435
+ { role: 'user', content: 'Generate' },
436
+ ], options);
437
+ expect(response.message.content).toBe(rawOutput);
438
+ });
439
+ });
440
+
441
+ // ========================================================================
442
+ // VAL-PROVIDER-OPENAI-005: Provider-Specific Schema Limitations (json_object mode)
443
+ // ========================================================================
444
+
445
+ describe('json_object mode (backward compatibility)', () => {
446
+ test('supports response_format type json_object for legacy providers', async () => {
447
+ const getBody = mockFetchAndCapture();
448
+ const client = createClient();
449
+
450
+ const options: ChatOptions = {
451
+ responseFormat: { type: 'json_object' },
452
+ };
453
+
454
+ await client.chat([
455
+ { role: 'user', content: 'Generate JSON' },
456
+ ], options);
457
+
458
+ const body = getBody()!;
459
+ expect(body['response_format']).toBeDefined();
460
+ expect((body['response_format'] as Record<string, unknown>)['type']).toBe('json_object');
461
+ });
462
+
463
+ test('json_object mode does not include schema in request', async () => {
464
+ const getBody = mockFetchAndCapture();
465
+ const client = createClient();
466
+
467
+ const options: ChatOptions = {
468
+ responseFormat: { type: 'json_object' },
469
+ };
470
+
471
+ await client.chat([
472
+ { role: 'user', content: 'Generate JSON' },
473
+ ], options);
474
+
475
+ const body = getBody()!;
476
+ const responseFormat = body['response_format'] as Record<string, unknown>;
477
+
478
+ expect(responseFormat['type']).toBe('json_object');
479
+ expect(responseFormat['json_schema']).toBeUndefined();
480
+ });
481
+
482
+ test('can provide schema alongside json_object for validation', async () => {
483
+ mockFetchAndCapture({
484
+ ...OPENAI_RESPONSE,
485
+ choices: [{
486
+ index: 0,
487
+ message: {
488
+ role: 'assistant',
489
+ content: '{"name": "Alice", "age": 30}',
490
+ },
491
+ finish_reason: 'stop',
492
+ }],
493
+ });
494
+
495
+ const client = createClient();
496
+
497
+ const UserSchema = z.object({
498
+ name: z.string(),
499
+ age: z.number(),
500
+ });
501
+
502
+ // Provider that only supports json_object (like older OpenAI)
503
+ const options: ChatOptions = {
504
+ schema: fromZod(UserSchema),
505
+ responseFormat: { type: 'json_object' },
506
+ };
507
+
508
+ const response = await client.chat([
509
+ { role: 'user', content: 'Generate a user' },
510
+ ], options);
511
+
512
+ // Validation should still work
513
+ expect(response.message.content).toBe('{"name": "Alice", "age": 30}');
514
+ });
515
+ });
516
+
517
+ // ========================================================================
518
+ // Error Handling
519
+ // ========================================================================
520
+
521
+ describe('error handling', () => {
522
+ test('provider returns raw response for null content (Router validates)', async () => {
523
+ mockFetchAndCapture({
524
+ ...OPENAI_RESPONSE,
525
+ choices: [{
526
+ index: 0,
527
+ message: {
528
+ role: 'assistant',
529
+ content: null,
530
+ },
531
+ finish_reason: 'stop',
532
+ }],
533
+ });
534
+
535
+ const client = createClient();
536
+
537
+ const UserSchema = z.object({
538
+ name: z.string(),
539
+ });
540
+
541
+ const options: ChatOptions = {
542
+ schema: fromZod(UserSchema),
543
+ };
544
+
545
+ // Provider should NOT throw — returns raw response
546
+ const response = await client.chat([
547
+ { role: 'user', content: 'Generate' },
548
+ ], options);
549
+ expect(response.message.content).toBe('');
550
+ });
551
+
552
+ test('provider returns raw response for empty content (Router validates)', async () => {
553
+ mockFetchAndCapture({
554
+ ...OPENAI_RESPONSE,
555
+ choices: [{
556
+ index: 0,
557
+ message: {
558
+ role: 'assistant',
559
+ content: '',
560
+ },
561
+ finish_reason: 'stop',
562
+ }],
563
+ });
564
+
565
+ const client = createClient();
566
+
567
+ const UserSchema = z.object({
568
+ name: z.string(),
569
+ });
570
+
571
+ const options: ChatOptions = {
572
+ schema: fromZod(UserSchema),
573
+ };
574
+
575
+ // Provider should NOT throw — returns raw response
576
+ const response = await client.chat([
577
+ { role: 'user', content: 'Generate' },
578
+ ], options);
579
+ expect(response.message.content).toBe('');
580
+ });
581
+
582
+ test('provider returns raw response for schema mismatch (Router validates)', async () => {
583
+ mockFetchAndCapture({
584
+ ...OPENAI_RESPONSE,
585
+ choices: [{
586
+ index: 0,
587
+ message: {
588
+ role: 'assistant',
589
+ content: '{"name": "test"}', // Missing 'age'
590
+ },
591
+ finish_reason: 'stop',
592
+ }],
593
+ });
594
+
595
+ const client = createClient();
596
+
597
+ const UserSchema = z.object({
598
+ name: z.string(),
599
+ age: z.number(), // required
600
+ });
601
+
602
+ const options: ChatOptions = {
603
+ schema: fromZod(UserSchema),
604
+ };
605
+
606
+ // Provider should NOT throw — returns raw response
607
+ const response = await client.chat([
608
+ { role: 'user', content: 'Generate' },
609
+ ], options);
610
+ expect(response.message.content).toBe('{"name": "test"}');
611
+ });
612
+ });
613
+
614
+ // ========================================================================
615
+ // No schema option (regular chat)
616
+ // ========================================================================
617
+
618
+ describe('regular chat without schema', () => {
619
+ test('does not include response_format when no schema provided', async () => {
620
+ const getBody = mockFetchAndCapture();
621
+ const client = createClient();
622
+
623
+ await client.chat([
624
+ { role: 'user', content: 'Hello' },
625
+ ]);
626
+
627
+ const body = getBody()!;
628
+ expect(body['response_format']).toBeUndefined();
629
+ });
630
+
631
+ test('chat without schema returns raw response', async () => {
632
+ mockFetchAndCapture(OPENAI_RESPONSE);
633
+ const client = createClient();
634
+
635
+ const response = await client.chat([
636
+ { role: 'user', content: 'Hello' },
637
+ ]);
638
+
639
+ expect(response.message.content).toBe('{"name": "Alice", "age": 30}');
640
+ expect(response.message.role).toBe('assistant');
641
+ });
642
+ });
643
+
644
+ // ========================================================================
645
+ // Schema with Tools (allowed together)
646
+ // ========================================================================
647
+
648
+ describe('schema with tools', () => {
649
+ test('sends both response_format and tools in the request', async () => {
650
+ const getBody = mockFetchAndCapture();
651
+ const client = createClient();
652
+
653
+ const UserSchema = z.object({
654
+ name: z.string(),
655
+ });
656
+
657
+ const options: ChatOptions = {
658
+ schema: fromZod(UserSchema),
659
+ tools: [{
660
+ type: 'function',
661
+ function: {
662
+ name: 'test_tool',
663
+ description: 'A test tool',
664
+ parameters: {
665
+ type: 'object',
666
+ properties: {},
667
+ },
668
+ },
669
+ }],
670
+ };
671
+
672
+ await client.chat([
673
+ { role: 'user', content: 'Test' },
674
+ ], options);
675
+
676
+ const body = getBody();
677
+ expect(body).not.toBeNull();
678
+
679
+ // Both response_format and tools should be present
680
+ expect(body!.response_format).toBeDefined();
681
+ expect(body!.tools).toBeDefined();
682
+ expect((body!.tools as unknown[]).length).toBe(1);
683
+ });
684
+ });
685
+
686
+ // ========================================================================
687
+ // VAL-PROVIDER-OPENAI-003: Vision with Structured Output
688
+ // ========================================================================
689
+
690
+ describe('vision with structured output', () => {
691
+ test('includes image_url content parts with response_format in request', async () => {
692
+ const getBody = mockFetchAndCapture({
693
+ ...OPENAI_RESPONSE,
694
+ choices: [{
695
+ index: 0,
696
+ message: {
697
+ role: 'assistant',
698
+ content: '{"description": "A colorful image with flowers", "objects": ["flower", "vase", "table"]}',
699
+ },
700
+ finish_reason: 'stop',
701
+ }],
702
+ });
703
+ const client = createClient();
704
+
705
+ const DescriptionSchema = z.object({
706
+ description: z.string(),
707
+ objects: z.array(z.string()),
708
+ });
709
+
710
+ const messages = [{
711
+ role: 'user' as const,
712
+ content: [
713
+ { type: 'text' as const, text: 'Describe this image' },
714
+ { type: 'image_url' as const, image_url: { url: 'data:image/jpeg;base64,IMGDATA' } },
715
+ ] as const,
716
+ }];
717
+
718
+ const options: ChatOptions = {
719
+ schema: fromZod(DescriptionSchema),
720
+ };
721
+
722
+ await client.chat(messages, options);
723
+
724
+ const body = getBody()!;
725
+
726
+ // Should have response_format with structured output
727
+ expect(body['response_format']).toBeDefined();
728
+ const responseFormat = body['response_format'] as Record<string, unknown>;
729
+ expect(responseFormat['type']).toBe('json_schema');
730
+
731
+ // Should preserve image_url content parts
732
+ const sentMessages = body['messages'] as Array<Record<string, unknown>>;
733
+ expect(sentMessages).toHaveLength(1);
734
+ expect(sentMessages[0]!['role']).toBe('user');
735
+
736
+ const content = sentMessages[0]!['content'] as Array<Record<string, unknown>>;
737
+ expect(content).toHaveLength(2);
738
+
739
+ // Text part
740
+ expect(content[0]!['type']).toBe('text');
741
+ expect(content[0]!['text']).toBe('Describe this image');
742
+
743
+ // Image part
744
+ expect(content[1]!['type']).toBe('image_url');
745
+ expect(content[1]!['image_url']).toEqual({ url: 'data:image/jpeg;base64,IMGDATA' });
746
+ });
747
+
748
+ test('handles multiple images with structured output', async () => {
749
+ const getBody = mockFetchAndCapture({
750
+ ...OPENAI_RESPONSE,
751
+ choices: [{
752
+ index: 0,
753
+ message: {
754
+ role: 'assistant',
755
+ content: '{"comparison": "The images show different scenes"}',
756
+ },
757
+ finish_reason: 'stop',
758
+ }],
759
+ });
760
+ const client = createClient();
761
+
762
+ const ComparisonSchema = z.object({
763
+ comparison: z.string(),
764
+ });
765
+
766
+ const messages = [{
767
+ role: 'user' as const,
768
+ content: [
769
+ { type: 'text' as const, text: 'Compare these images' },
770
+ { type: 'image_url' as const, image_url: { url: 'data:image/png;base64,IMG1' } },
771
+ { type: 'image_url' as const, image_url: { url: 'data:image/png;base64,IMG2' } },
772
+ ] as const,
773
+ }];
774
+
775
+ const options: ChatOptions = {
776
+ schema: fromZod(ComparisonSchema),
777
+ };
778
+
779
+ await client.chat(messages, options);
780
+
781
+ const body = getBody()!;
782
+
783
+ // Should have response_format with structured output
784
+ expect(body['response_format']).toBeDefined();
785
+
786
+ // Should preserve all image_url content parts
787
+ const sentMessages = body['messages'] as Array<Record<string, unknown>>;
788
+ const content = sentMessages[0]!['content'] as Array<Record<string, unknown>>;
789
+
790
+ expect(content).toHaveLength(3);
791
+ expect(content[0]!['type']).toBe('text');
792
+ expect(content[1]!['type']).toBe('image_url');
793
+ expect(content[1]!['image_url']).toEqual({ url: 'data:image/png;base64,IMG1' });
794
+ expect(content[2]!['type']).toBe('image_url');
795
+ expect(content[2]!['image_url']).toEqual({ url: 'data:image/png;base64,IMG2' });
796
+ });
797
+
798
+ test('validates structured output response with vision', async () => {
799
+ mockFetchAndCapture({
800
+ ...OPENAI_RESPONSE,
801
+ choices: [{
802
+ index: 0,
803
+ message: {
804
+ role: 'assistant',
805
+ content: '{"description": "A sunset over mountains", "colors": ["orange", "purple", "blue"]}',
806
+ },
807
+ finish_reason: 'stop',
808
+ }],
809
+ });
810
+ const client = createClient();
811
+
812
+ const ImageAnalysisSchema = z.object({
813
+ description: z.string(),
814
+ colors: z.array(z.string()),
815
+ });
816
+
817
+ const messages = [{
818
+ role: 'user' as const,
819
+ content: [
820
+ { type: 'text' as const, text: 'Analyze this image' },
821
+ { type: 'image_url' as const, image_url: { url: 'data:image/jpeg;base64,SUNSET' } },
822
+ ] as const,
823
+ }];
824
+
825
+ const options: ChatOptions = {
826
+ schema: fromZod(ImageAnalysisSchema),
827
+ };
828
+
829
+ const response = await client.chat(messages, options);
830
+
831
+ // Should validate and return successfully
832
+ expect(response.message.content).toBe('{"description": "A sunset over mountains", "colors": ["orange", "purple", "blue"]}');
833
+ });
834
+
835
+ test('supports http image URLs with structured output', async () => {
836
+ const getBody = mockFetchAndCapture({
837
+ ...OPENAI_RESPONSE,
838
+ choices: [{
839
+ index: 0,
840
+ message: {
841
+ role: 'assistant',
842
+ content: '{"description": "An image from the web"}',
843
+ },
844
+ finish_reason: 'stop',
845
+ }],
846
+ });
847
+ const client = createClient();
848
+
849
+ const DescriptionSchema = z.object({
850
+ description: z.string(),
851
+ });
852
+
853
+ const messages = [{
854
+ role: 'user' as const,
855
+ content: [
856
+ { type: 'text' as const, text: 'Describe this image' },
857
+ { type: 'image_url' as const, image_url: { url: 'https://example.com/image.jpg' } },
858
+ ] as const,
859
+ }];
860
+
861
+ const options: ChatOptions = {
862
+ schema: fromZod(DescriptionSchema),
863
+ };
864
+
865
+ await client.chat(messages, options);
866
+
867
+ const body = getBody()!;
868
+ const sentMessages = body['messages'] as Array<Record<string, unknown>>;
869
+ const content = sentMessages[0]!['content'] as Array<Record<string, unknown>>;
870
+
871
+ // OpenAI accepts HTTP URLs directly (unlike Ollama which needs base64)
872
+ expect(content[1]!['type']).toBe('image_url');
873
+ expect(content[1]!['image_url']).toEqual({ url: 'https://example.com/image.jpg' });
874
+
875
+ // And response_format should still be set
876
+ expect(body['response_format']).toBeDefined();
877
+ });
878
+
879
+ test('supports image_url with detail parameter and structured output', async () => {
880
+ const getBody = mockFetchAndCapture({
881
+ ...OPENAI_RESPONSE,
882
+ choices: [{
883
+ index: 0,
884
+ message: {
885
+ role: 'assistant',
886
+ content: '{"objects": ["car", "tree", "building"]}',
887
+ },
888
+ finish_reason: 'stop',
889
+ }],
890
+ });
891
+ const client = createClient();
892
+
893
+ const DescriptionSchema = z.object({
894
+ objects: z.array(z.string()),
895
+ });
896
+
897
+ const messages = [{
898
+ role: 'user' as const,
899
+ content: [
900
+ { type: 'text' as const, text: 'List objects in this image' },
901
+ { type: 'image_url' as const, image_url: { url: 'data:image/jpeg;base64,IMG', detail: 'high' } },
902
+ ] as const,
903
+ }];
904
+
905
+ const options: ChatOptions = {
906
+ schema: fromZod(DescriptionSchema),
907
+ };
908
+
909
+ await client.chat(messages, options);
910
+
911
+ const body = getBody()!;
912
+ const sentMessages = body['messages'] as Array<Record<string, unknown>>;
913
+ const content = sentMessages[0]!['content'] as Array<Record<string, unknown>>;
914
+
915
+ // Should preserve detail parameter
916
+ expect(content[1]!['image_url']).toEqual({ url: 'data:image/jpeg;base64,IMG', detail: 'high' });
917
+ expect(body['response_format']).toBeDefined();
918
+ });
919
+
920
+ test('returns validated object on successful vision + structured output', async () => {
921
+ mockFetchAndCapture({
922
+ ...OPENAI_RESPONSE,
923
+ choices: [{
924
+ index: 0,
925
+ message: {
926
+ role: 'assistant',
927
+ content: '{"count": 3, "items": ["cat", "dog", "bird"]}',
928
+ },
929
+ finish_reason: 'stop',
930
+ }],
931
+ });
932
+ const client = createClient();
933
+
934
+ const VisionSchema = z.object({
935
+ count: z.number(),
936
+ items: z.array(z.string()),
937
+ });
938
+
939
+ const messages = [{
940
+ role: 'user' as const,
941
+ content: [
942
+ { type: 'text' as const, text: 'Count items' },
943
+ { type: 'image_url' as const, image_url: { url: 'data:image/png;base64,IMG' } },
944
+ ] as const,
945
+ }];
946
+
947
+ const options: ChatOptions = {
948
+ schema: fromZod(VisionSchema),
949
+ };
950
+
951
+ // Should not throw - response passes validation
952
+ const result = await client.chat(messages, options);
953
+ expect(result.message.content).toBe('{"count": 3, "items": ["cat", "dog", "bird"]}');
954
+ });
955
+
956
+ test('throws StructuredOutputError on invalid vision response', async () => {
957
+ mockFetchAndCapture({
958
+ ...OPENAI_RESPONSE,
959
+ choices: [{
960
+ index: 0,
961
+ message: {
962
+ role: 'assistant',
963
+ content: '{"count": "not a number"}', // Invalid - count should be number
964
+ },
965
+ finish_reason: 'stop',
966
+ }],
967
+ });
968
+ const client = createClient();
969
+
970
+ const VisionSchema = z.object({
971
+ count: z.number(),
972
+ });
973
+
974
+ const messages = [{
975
+ role: 'user' as const,
976
+ content: [
977
+ { type: 'text' as const, text: 'Count items' },
978
+ { type: 'image_url' as const, image_url: { url: 'data:image/png;base64,IMG' } },
979
+ ] as const,
980
+ }];
981
+
982
+ const options: ChatOptions = {
983
+ schema: fromZod(VisionSchema),
984
+ };
985
+
986
+ // Provider should NOT throw — validation is done at Router level
987
+ const result = await client.chat(messages, options);
988
+ expect(result.message.content).toBe('{"count": "not a number"}');
989
+ });
990
+ });
991
+
992
+ // ========================================================================
993
+ // Complex Schemas
994
+ // ========================================================================
995
+
996
+ describe('complex schemas', () => {
997
+ test('handles nested object schemas', async () => {
998
+ const getBody = mockFetchAndCapture({
999
+ ...OPENAI_RESPONSE,
1000
+ choices: [{
1001
+ index: 0,
1002
+ message: {
1003
+ role: 'assistant',
1004
+ content: '{"name": "Alice", "address": {"street": "123 Main St", "city": "NYC"}}',
1005
+ },
1006
+ finish_reason: 'stop',
1007
+ }],
1008
+ });
1009
+ const client = createClient();
1010
+
1011
+ const AddressSchema = z.object({
1012
+ street: z.string(),
1013
+ city: z.string(),
1014
+ });
1015
+
1016
+ const UserSchema = z.object({
1017
+ name: z.string(),
1018
+ address: AddressSchema,
1019
+ });
1020
+
1021
+ const options: ChatOptions = {
1022
+ schema: fromZod(UserSchema),
1023
+ };
1024
+
1025
+ const response = await client.chat([
1026
+ { role: 'user', content: 'Generate' },
1027
+ ], options);
1028
+
1029
+ const body = getBody()!;
1030
+ const responseFormat = body['response_format'] as Record<string, unknown>;
1031
+ const jsonSchema = responseFormat['json_schema'] as Record<string, unknown>;
1032
+ const schema = jsonSchema['schema'] as Record<string, unknown>;
1033
+
1034
+ expect(schema['type']).toBe('object');
1035
+ const addressSchema = schema['properties']!['address'] as Record<string, unknown>;
1036
+ expect(addressSchema['type']).toBe('object');
1037
+ expect(addressSchema['properties']).toBeDefined();
1038
+ // Verify response validated successfully
1039
+ expect(response.message.content).toContain('Alice');
1040
+ });
1041
+
1042
+ test('handles array schemas', async () => {
1043
+ const getBody = mockFetchAndCapture({
1044
+ ...OPENAI_RESPONSE,
1045
+ choices: [{
1046
+ index: 0,
1047
+ message: {
1048
+ role: 'assistant',
1049
+ content: '{"users": [{"name": "Alice", "email": "alice@example.com"}]}',
1050
+ },
1051
+ finish_reason: 'stop',
1052
+ }],
1053
+ });
1054
+ const client = createClient();
1055
+
1056
+ const UserListSchema = z.object({
1057
+ users: z.array(z.object({
1058
+ name: z.string(),
1059
+ email: z.string().email(),
1060
+ })),
1061
+ });
1062
+
1063
+ const options: ChatOptions = {
1064
+ schema: fromZod(UserListSchema),
1065
+ };
1066
+
1067
+ const response = await client.chat([
1068
+ { role: 'user', content: 'Generate' },
1069
+ ], options);
1070
+
1071
+ const body = getBody()!;
1072
+ const responseFormat = body['response_format'] as Record<string, unknown>;
1073
+ const jsonSchema = responseFormat['json_schema'] as Record<string, unknown>;
1074
+ const schema = jsonSchema['schema'] as Record<string, unknown>;
1075
+
1076
+ expect(schema['type']).toBe('object');
1077
+ const usersSchema = schema['properties']!['users'] as Record<string, unknown>;
1078
+ expect(usersSchema['type']).toBe('array');
1079
+ expect(usersSchema['items']).toBeDefined();
1080
+ // Verify response validated successfully
1081
+ expect(response.message.content).toContain('Alice');
1082
+ });
1083
+
1084
+ test('handles enum schemas', async () => {
1085
+ const getBody = mockFetchAndCapture({
1086
+ ...OPENAI_RESPONSE,
1087
+ choices: [{
1088
+ index: 0,
1089
+ message: {
1090
+ role: 'assistant',
1091
+ content: '{"status": "active"}',
1092
+ },
1093
+ finish_reason: 'stop',
1094
+ }],
1095
+ });
1096
+ const client = createClient();
1097
+
1098
+ const StatusSchema = z.object({
1099
+ status: z.enum(['active', 'inactive', 'pending']),
1100
+ });
1101
+
1102
+ const options: ChatOptions = {
1103
+ schema: fromZod(StatusSchema),
1104
+ };
1105
+
1106
+ const response = await client.chat([
1107
+ { role: 'user', content: 'Generate' },
1108
+ ], options);
1109
+
1110
+ const body = getBody()!;
1111
+ const responseFormat = body['response_format'] as Record<string, unknown>;
1112
+ const jsonSchema = responseFormat['json_schema'] as Record<string, unknown>;
1113
+ const schema = jsonSchema['schema'] as Record<string, unknown>;
1114
+
1115
+ const statusSchema = schema['properties']!['status'] as Record<string, unknown>;
1116
+ expect(statusSchema['type']).toBe('string');
1117
+ expect(statusSchema['enum']).toEqual(['active', 'inactive', 'pending']);
1118
+ // Verify response validated successfully
1119
+ expect(response.message.content).toContain('active');
1120
+ });
1121
+ });
1122
+ });