universal-llm-client 4.2.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +142 -103
  2. package/LICENSE +21 -21
  3. package/README.md +640 -591
  4. package/dist/ai-model.d.ts +12 -1
  5. package/dist/ai-model.d.ts.map +1 -1
  6. package/dist/ai-model.js +36 -1
  7. package/dist/ai-model.js.map +1 -1
  8. package/dist/gemma-channel.d.ts +14 -0
  9. package/dist/gemma-channel.d.ts.map +1 -0
  10. package/dist/gemma-channel.js +38 -0
  11. package/dist/gemma-channel.js.map +1 -0
  12. package/dist/gemma-diffusion.d.ts +49 -0
  13. package/dist/gemma-diffusion.d.ts.map +1 -0
  14. package/dist/gemma-diffusion.js +147 -0
  15. package/dist/gemma-diffusion.js.map +1 -0
  16. package/dist/http.d.ts +4 -0
  17. package/dist/http.d.ts.map +1 -1
  18. package/dist/http.js +14 -1
  19. package/dist/http.js.map +1 -1
  20. package/dist/index.d.ts +2 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +4 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/interfaces.d.ts +183 -7
  25. package/dist/interfaces.d.ts.map +1 -1
  26. package/dist/interfaces.js.map +1 -1
  27. package/dist/providers/anthropic.d.ts.map +1 -1
  28. package/dist/providers/anthropic.js +28 -3
  29. package/dist/providers/anthropic.js.map +1 -1
  30. package/dist/providers/google.d.ts +22 -1
  31. package/dist/providers/google.d.ts.map +1 -1
  32. package/dist/providers/google.js +225 -13
  33. package/dist/providers/google.js.map +1 -1
  34. package/dist/providers/ollama.d.ts +2 -0
  35. package/dist/providers/ollama.d.ts.map +1 -1
  36. package/dist/providers/ollama.js +59 -30
  37. package/dist/providers/ollama.js.map +1 -1
  38. package/dist/providers/openai.d.ts +14 -0
  39. package/dist/providers/openai.d.ts.map +1 -1
  40. package/dist/providers/openai.js +200 -22
  41. package/dist/providers/openai.js.map +1 -1
  42. package/dist/router.d.ts +2 -0
  43. package/dist/router.d.ts.map +1 -1
  44. package/dist/router.js +4 -0
  45. package/dist/router.js.map +1 -1
  46. package/dist/stream-decoder.d.ts +12 -0
  47. package/dist/stream-decoder.d.ts.map +1 -1
  48. package/dist/stream-decoder.js +182 -5
  49. package/dist/stream-decoder.js.map +1 -1
  50. package/dist/thinking.d.ts +36 -0
  51. package/dist/thinking.d.ts.map +1 -0
  52. package/dist/thinking.js +52 -0
  53. package/dist/thinking.js.map +1 -0
  54. package/package.json +118 -116
  55. package/src/ai-model.ts +400 -350
  56. package/src/auditor.ts +213 -213
  57. package/src/client.ts +402 -402
  58. package/src/debug/debug-google-streaming.ts +1 -1
  59. package/src/demos/basic/universal-llm-examples.ts +3 -3
  60. package/src/demos/diffusion-gemma/.env +29 -0
  61. package/src/demos/diffusion-gemma/.env.example +27 -0
  62. package/src/demos/diffusion-gemma/CLAUDE.md +95 -0
  63. package/src/demos/diffusion-gemma/README.md +59 -0
  64. package/src/demos/diffusion-gemma/canvas.ts +1606 -0
  65. package/src/demos/diffusion-gemma/docker-compose.yml +29 -0
  66. package/src/demos/diffusion-gemma/probe-stream.ts +51 -0
  67. package/src/demos/diffusion-gemma/probe-tools.ts +55 -0
  68. package/src/demos/diffusion-gemma/server.ts +1205 -0
  69. package/src/demos/diffusion-gemma/start-vllm.sh +98 -0
  70. package/src/gemma-channel.ts +47 -0
  71. package/src/gemma-diffusion.ts +167 -0
  72. package/src/http.ts +261 -247
  73. package/src/index.ts +180 -161
  74. package/src/interfaces.ts +843 -657
  75. package/src/mcp.ts +345 -345
  76. package/src/providers/anthropic.ts +796 -762
  77. package/src/providers/google.ts +840 -620
  78. package/src/providers/index.ts +8 -8
  79. package/src/providers/ollama.ts +503 -469
  80. package/src/providers/openai.ts +587 -392
  81. package/src/router.ts +785 -780
  82. package/src/stream-decoder.ts +535 -361
  83. package/src/structured-output.ts +759 -759
  84. package/src/test-scripts/test-google-deep-research.ts +33 -0
  85. package/src/test-scripts/test-google-streaming-enhanced.ts +147 -147
  86. package/src/test-scripts/test-google-streaming.ts +1 -1
  87. package/src/test-scripts/test-google-system-prompt-comprehensive.ts +189 -189
  88. package/src/test-scripts/test-google-thinking.ts +46 -0
  89. package/src/test-scripts/test-system-message-positions.ts +163 -163
  90. package/src/test-scripts/test-system-prompt-improvement-demo.ts +83 -83
  91. package/src/test-scripts/test-vllm-qwen36.ts +256 -0
  92. package/src/tests/ai-model.test.ts +1614 -1614
  93. package/src/tests/auditor.test.ts +224 -224
  94. package/src/tests/gemma-diffusion.test.ts +115 -0
  95. package/src/tests/http.test.ts +200 -200
  96. package/src/tests/interfaces.test.ts +117 -117
  97. package/src/tests/providers/anthropic.test.ts +118 -0
  98. package/src/tests/providers/google.test.ts +841 -660
  99. package/src/tests/providers/ollama.test.ts +1034 -954
  100. package/src/tests/providers/openai.test.ts +1511 -1122
  101. package/src/tests/router.test.ts +254 -254
  102. package/src/tests/stream-decoder.test.ts +263 -179
  103. package/src/tests/structured-output.test.ts +1450 -1450
  104. package/src/tests/thinking.test.ts +65 -0
  105. package/src/tests/tools.test.ts +175 -175
  106. package/src/thinking.ts +73 -0
  107. package/src/tools.ts +246 -246
  108. package/src/zod-adapter.ts +72 -72
@@ -1,1122 +1,1511 @@
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
- });
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, LLMChatMessage } from '../../interfaces.js';
15
+ import { AIModelApiType } from '../../interfaces.js';
16
+ import {
17
+ type StructuredOutputOptions,
18
+ parseStructured,
19
+ } from '../../structured-output.js';
20
+ import type { DecodedEvent } from '../../stream-decoder.js';
21
+
22
+ // ============================================================================
23
+ // Helpers
24
+ // ============================================================================
25
+
26
+ function createClient(overrides?: Partial<LLMClientOptions>): OpenAICompatibleClient {
27
+ return new OpenAICompatibleClient({
28
+ model: 'test-model',
29
+ url: 'https://api.openai.com/v1',
30
+ apiType: AIModelApiType.OpenAI,
31
+ ...overrides,
32
+ });
33
+ }
34
+
35
+ const OPENAI_RESPONSE = {
36
+ id: 'test-id',
37
+ object: 'chat.completion',
38
+ created: 1700000000,
39
+ model: 'test-model',
40
+ choices: [{
41
+ index: 0,
42
+ message: {
43
+ role: 'assistant',
44
+ content: '{"name": "Alice", "age": 30}',
45
+ },
46
+ finish_reason: 'stop',
47
+ }],
48
+ usage: {
49
+ prompt_tokens: 10,
50
+ completion_tokens: 20,
51
+ total_tokens: 30,
52
+ },
53
+ };
54
+
55
+ // ============================================================================
56
+ // Tests
57
+ // ============================================================================
58
+
59
+ describe('OpenAICompatibleClient Structured Output', () => {
60
+ let originalFetch: typeof globalThis.fetch;
61
+
62
+ beforeEach(() => {
63
+ originalFetch = globalThis.fetch;
64
+ });
65
+
66
+ afterEach(() => {
67
+ globalThis.fetch = originalFetch;
68
+ });
69
+
70
+ /** Capture the body sent to OpenAI's /v1/chat/completions */
71
+ function mockFetchAndCapture(response: unknown = OPENAI_RESPONSE, status = 200) {
72
+ let capturedBody: Record<string, unknown> | null = null;
73
+
74
+ globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
75
+ if (init?.body) {
76
+ capturedBody = JSON.parse(init.body as string);
77
+ }
78
+ return new Response(JSON.stringify(response), {
79
+ status,
80
+ headers: { 'Content-Type': 'application/json' },
81
+ });
82
+ }) as typeof fetch;
83
+
84
+ return () => capturedBody;
85
+ }
86
+
87
+ // ========================================================================
88
+ // VAL-PROVIDER-OPENAI-001: response_format json_schema Request
89
+ // ========================================================================
90
+
91
+ describe('response_format json_schema', () => {
92
+ test('includes response_format with json_schema type when schema provided', async () => {
93
+ const getBody = mockFetchAndCapture();
94
+ const client = createClient();
95
+
96
+ const UserSchema = z.object({
97
+ name: z.string(),
98
+ age: z.number(),
99
+ });
100
+
101
+ const options: ChatOptions = {
102
+ schema: fromZod(UserSchema),
103
+ };
104
+
105
+ await client.chat([
106
+ { role: 'user', content: 'Generate a user' },
107
+ ], options);
108
+
109
+ const body = getBody()!;
110
+ expect(body['response_format']).toBeDefined();
111
+ expect((body['response_format'] as Record<string, unknown>)['type']).toBe('json_schema');
112
+ expect((body['response_format'] as Record<string, unknown>)['json_schema']).toBeDefined();
113
+ });
114
+
115
+ test('includes strict mode when schema provided', async () => {
116
+ const getBody = mockFetchAndCapture();
117
+ const client = createClient();
118
+
119
+ const UserSchema = z.object({
120
+ name: z.string(),
121
+ age: z.number(),
122
+ });
123
+
124
+ const options: ChatOptions = {
125
+ schema: fromZod(UserSchema),
126
+ };
127
+
128
+ await client.chat([
129
+ { role: 'user', content: 'Generate a user' },
130
+ ], options);
131
+
132
+ const body = getBody()!;
133
+ const responseFormat = body['response_format'] as Record<string, unknown>;
134
+ expect(responseFormat['json_schema']).toBeDefined();
135
+
136
+ const jsonSchema = responseFormat['json_schema'] as Record<string, unknown>;
137
+ // strict should be true for reliable structured output
138
+ expect(jsonSchema['strict']).toBe(true);
139
+ expect(jsonSchema['name']).toBeDefined();
140
+ expect(jsonSchema['schema']).toBeDefined();
141
+ });
142
+
143
+ test('converts Zod schema to JSON Schema in response_format', async () => {
144
+ const getBody = mockFetchAndCapture();
145
+ const client = createClient();
146
+
147
+ const UserSchema = z.object({
148
+ name: z.string(),
149
+ age: z.number().optional(),
150
+ });
151
+
152
+ const options: ChatOptions = {
153
+ schema: fromZod(UserSchema),
154
+ };
155
+
156
+ await client.chat([
157
+ { role: 'user', content: 'Generate a user' },
158
+ ], options);
159
+
160
+ const body = getBody()!;
161
+ const responseFormat = body['response_format'] as Record<string, unknown>;
162
+ const jsonSchema = responseFormat['json_schema'] as Record<string, unknown>;
163
+ const schema = jsonSchema['schema'] as Record<string, unknown>;
164
+
165
+ expect(schema['type']).toBe('object');
166
+ expect(schema['properties']).toBeDefined();
167
+ expect(schema['properties']!['name']).toEqual({ type: 'string' });
168
+ expect(schema['properties']!['age']).toEqual({ type: 'number' });
169
+ expect(schema['required']).toEqual(['name']);
170
+ });
171
+
172
+ test('uses schema name from options when provided', async () => {
173
+ const getBody = mockFetchAndCapture();
174
+ const client = createClient();
175
+
176
+ const UserSchema = z.object({
177
+ name: z.string(),
178
+ });
179
+
180
+ const options: ChatOptions = {
181
+ schema: fromZod(UserSchema),
182
+ schemaName: 'CustomUserSchema',
183
+ };
184
+
185
+ await client.chat([
186
+ { role: 'user', content: 'Generate' },
187
+ ], options);
188
+
189
+ const body = getBody()!;
190
+ const responseFormat = body['response_format'] as Record<string, unknown>;
191
+ const jsonSchema = responseFormat['json_schema'] as Record<string, unknown>;
192
+
193
+ expect(jsonSchema['name']).toBe('CustomUserSchema');
194
+ });
195
+
196
+ test('generates default schema name when not provided', async () => {
197
+ const getBody = mockFetchAndCapture();
198
+ const client = createClient();
199
+
200
+ const UserSchema = z.object({
201
+ name: z.string(),
202
+ });
203
+
204
+ const options: ChatOptions = {
205
+ schema: fromZod(UserSchema),
206
+ };
207
+
208
+ await client.chat([
209
+ { role: 'user', content: 'Generate' },
210
+ ], options);
211
+
212
+ const body = getBody()!;
213
+ const responseFormat = body['response_format'] as Record<string, unknown>;
214
+ const jsonSchema = responseFormat['json_schema'] as Record<string, unknown>;
215
+
216
+ // Should have a name, either provided or auto-generated
217
+ expect(jsonSchema['name']).toBeDefined();
218
+ expect(typeof jsonSchema['name']).toBe('string');
219
+ expect((jsonSchema['name'] as string).length).toBeGreaterThan(0);
220
+ });
221
+
222
+ test('includes schema description when provided', async () => {
223
+ const getBody = mockFetchAndCapture();
224
+ const client = createClient();
225
+
226
+ const UserSchema = z.object({
227
+ name: z.string(),
228
+ });
229
+
230
+ const options: ChatOptions = {
231
+ schema: fromZod(UserSchema),
232
+ schemaDescription: 'A user object with name',
233
+ };
234
+
235
+ await client.chat([
236
+ { role: 'user', content: 'Generate' },
237
+ ], options);
238
+
239
+ const body = getBody()!;
240
+ const responseFormat = body['response_format'] as Record<string, unknown>;
241
+ const jsonSchema = responseFormat['json_schema'] as Record<string, unknown>;
242
+
243
+ expect(jsonSchema['description']).toBe('A user object with name');
244
+ });
245
+
246
+ test('accepts raw JSON Schema instead of Zod schema', async () => {
247
+ // Create mock with custom response for jsonSchema test
248
+ let capturedBody: Record<string, unknown> | null = null;
249
+
250
+ globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
251
+ if (init?.body) {
252
+ capturedBody = JSON.parse(init.body as string);
253
+ }
254
+ return new Response(JSON.stringify({
255
+ id: 'test-id',
256
+ object: 'chat.completion',
257
+ created: 1700000000,
258
+ model: 'test-model',
259
+ choices: [{
260
+ index: 0,
261
+ message: {
262
+ role: 'assistant',
263
+ content: '{"id": "123", "count": 5}',
264
+ },
265
+ finish_reason: 'stop',
266
+ }],
267
+ usage: {
268
+ prompt_tokens: 10,
269
+ completion_tokens: 20,
270
+ total_tokens: 30,
271
+ },
272
+ }), {
273
+ status: 200,
274
+ headers: { 'Content-Type': 'application/json' },
275
+ });
276
+ }) as typeof fetch;
277
+
278
+ const client = createClient();
279
+
280
+ const jsonSchema = {
281
+ type: 'object' as const,
282
+ properties: {
283
+ id: { type: 'string' as const },
284
+ count: { type: 'number' as const },
285
+ },
286
+ required: ['id'],
287
+ };
288
+
289
+ const options: ChatOptions = {
290
+ jsonSchema,
291
+ };
292
+
293
+ const response = await client.chat([
294
+ { role: 'user', content: 'Generate' },
295
+ ], options);
296
+
297
+ const body = capturedBody!;
298
+ const responseFormat = body['response_format'] as Record<string, unknown>;
299
+ expect(responseFormat).toBeDefined();
300
+ expect(responseFormat['type']).toBe('json_schema');
301
+
302
+ const jsonSchemaReq = responseFormat['json_schema'] as Record<string, unknown>;
303
+ expect(jsonSchemaReq['schema']).toBeDefined();
304
+ // The schema property contains the normalized JSON Schema
305
+ const schema = jsonSchemaReq['schema'] as Record<string, unknown>;
306
+ expect(schema.properties).toBeDefined();
307
+ // Verify response processed successfully (z.unknown() always passes)
308
+ expect(response.message.content).toContain('id');
309
+ });
310
+ });
311
+
312
+ // ========================================================================
313
+ // Response Validation
314
+ // ========================================================================
315
+
316
+ describe('response validation', () => {
317
+ test('validates response JSON against schema', async () => {
318
+ mockFetchAndCapture({
319
+ ...OPENAI_RESPONSE,
320
+ choices: [{
321
+ index: 0,
322
+ message: {
323
+ role: 'assistant',
324
+ content: '{"name": "Bob", "age": 25}',
325
+ },
326
+ finish_reason: 'stop',
327
+ }],
328
+ });
329
+
330
+ const client = createClient();
331
+
332
+ const UserSchema = z.object({
333
+ name: z.string(),
334
+ age: z.number(),
335
+ });
336
+
337
+ const options: ChatOptions = {
338
+ schema: fromZod(UserSchema),
339
+ };
340
+
341
+ const response = await client.chat([
342
+ { role: 'user', content: 'Generate a user' },
343
+ ], options);
344
+
345
+ // If we got here without error, validation passed
346
+ expect(response.message.content).toBe('{"name": "Bob", "age": 25}');
347
+ });
348
+
349
+ // ===================================================================
350
+ // Reasoning models (vLLM --reasoning-parser, DeepSeek-R1, etc.)
351
+ // ===================================================================
352
+
353
+ test('exposes vLLM `reasoning_content` as result.reasoning, keeping content clean', async () => {
354
+ mockFetchAndCapture({
355
+ ...OPENAI_RESPONSE,
356
+ choices: [{
357
+ index: 0,
358
+ message: {
359
+ role: 'assistant',
360
+ content: 'The answer is 14.',
361
+ reasoning_content: 'All but 9 run away => 9 remain; 9 + 5 = 14.',
362
+ },
363
+ finish_reason: 'stop',
364
+ }],
365
+ });
366
+
367
+ const client = createClient();
368
+ const response = await client.chat([{ role: 'user', content: 'sheep riddle' }]);
369
+
370
+ expect(response.reasoning).toBe('All but 9 run away => 9 remain; 9 + 5 = 14.');
371
+ expect(response.message.content).toBe('The answer is 14.');
372
+ });
373
+
374
+ test('exposes gateway `reasoning` field as result.reasoning', async () => {
375
+ mockFetchAndCapture({
376
+ ...OPENAI_RESPONSE,
377
+ choices: [{
378
+ index: 0,
379
+ message: {
380
+ role: 'assistant',
381
+ content: 'Done.',
382
+ reasoning: 'Some chain of thought.',
383
+ },
384
+ finish_reason: 'stop',
385
+ }],
386
+ });
387
+
388
+ const client = createClient();
389
+ const response = await client.chat([{ role: 'user', content: 'hi' }]);
390
+
391
+ expect(response.reasoning).toBe('Some chain of thought.');
392
+ expect(response.message.content).toBe('Done.');
393
+ });
394
+
395
+ test('leaves reasoning undefined when no reasoning field is present', async () => {
396
+ mockFetchAndCapture({
397
+ ...OPENAI_RESPONSE,
398
+ choices: [{
399
+ index: 0,
400
+ message: { role: 'assistant', content: 'Plain answer.' },
401
+ finish_reason: 'stop',
402
+ }],
403
+ });
404
+
405
+ const client = createClient();
406
+ const response = await client.chat([{ role: 'user', content: 'hi' }]);
407
+
408
+ expect(response.reasoning).toBeUndefined();
409
+ expect(response.message.content).toBe('Plain answer.');
410
+ });
411
+
412
+ // ===================================================================
413
+ // Unified thinking flag -> chat_template_kwargs.enable_thinking (vLLM)
414
+ // ===================================================================
415
+
416
+ const VLLM_URL = 'http://localhost:8000/v1'; // non-official endpoint
417
+
418
+ test('translates per-call thinking:false to chat_template_kwargs.enable_thinking (vLLM)', async () => {
419
+ const getBody = mockFetchAndCapture();
420
+ const client = createClient({ url: VLLM_URL });
421
+
422
+ await client.chat([{ role: 'user', content: 'hi' }], { thinking: false });
423
+
424
+ const ctk = getBody()!['chat_template_kwargs'] as Record<string, unknown> | undefined;
425
+ expect(ctk).toBeDefined();
426
+ expect(ctk!['enable_thinking']).toBe(false);
427
+ });
428
+
429
+ test('translates client-level thinking:true to chat_template_kwargs.enable_thinking (vLLM)', async () => {
430
+ const getBody = mockFetchAndCapture();
431
+ const client = createClient({ thinking: true, url: VLLM_URL });
432
+
433
+ await client.chat([{ role: 'user', content: 'hi' }]);
434
+
435
+ const ctk = getBody()!['chat_template_kwargs'] as Record<string, unknown> | undefined;
436
+ expect(ctk!['enable_thinking']).toBe(true);
437
+ });
438
+
439
+ test('does NOT send chat_template_kwargs to official OpenAI for non-reasoning models', async () => {
440
+ const getBody = mockFetchAndCapture();
441
+ const client = createClient({ thinking: true }); // default url = api.openai.com, model = test-model
442
+
443
+ await client.chat([{ role: 'user', content: 'hi' }]);
444
+
445
+ expect(getBody()!['chat_template_kwargs']).toBeUndefined();
446
+ });
447
+
448
+ test('omits chat_template_kwargs when thinking is not set', async () => {
449
+ const getBody = mockFetchAndCapture();
450
+ const client = createClient();
451
+
452
+ await client.chat([{ role: 'user', content: 'hi' }]);
453
+
454
+ expect(getBody()!['chat_template_kwargs']).toBeUndefined();
455
+ });
456
+
457
+ test('per-call thinking overrides client-level thinking (vLLM)', async () => {
458
+ const getBody = mockFetchAndCapture();
459
+ const client = createClient({ thinking: true, url: VLLM_URL });
460
+
461
+ await client.chat([{ role: 'user', content: 'hi' }], { thinking: false });
462
+
463
+ const ctk = getBody()!['chat_template_kwargs'] as Record<string, unknown>;
464
+ expect(ctk['enable_thinking']).toBe(false);
465
+ });
466
+
467
+ test('OpenAI reasoning model maps a level to reasoning_effort (not chat_template_kwargs)', async () => {
468
+ const getBody = mockFetchAndCapture();
469
+ const client = createClient({ model: 'gpt-5', thinking: 'high' });
470
+
471
+ await client.chat([{ role: 'user', content: 'hi' }]);
472
+
473
+ const body = getBody()!;
474
+ expect(body['reasoning_effort']).toBe('high');
475
+ expect(body['chat_template_kwargs']).toBeUndefined();
476
+ });
477
+
478
+ test('OpenAI reasoning model maps thinking:true to reasoning_effort medium', async () => {
479
+ const getBody = mockFetchAndCapture();
480
+ const client = createClient({ model: 'o3', thinking: true });
481
+
482
+ await client.chat([{ role: 'user', content: 'hi' }]);
483
+
484
+ expect(getBody()!['reasoning_effort']).toBe('medium');
485
+ });
486
+
487
+ test('OpenAI reasoning model maps thinking:false to reasoning_effort minimal', async () => {
488
+ const getBody = mockFetchAndCapture();
489
+ const client = createClient({ model: 'gpt-5-mini', thinking: false });
490
+
491
+ await client.chat([{ role: 'user', content: 'hi' }]);
492
+
493
+ expect(getBody()!['reasoning_effort']).toBe('minimal');
494
+ });
495
+
496
+ // ===================================================================
497
+ // Usage stats (timing / throughput)
498
+ // ===================================================================
499
+
500
+ test('populates durationMs in usage (client-measured wall-clock)', async () => {
501
+ mockFetchAndCapture();
502
+ const client = createClient();
503
+
504
+ const response = await client.chat([{ role: 'user', content: 'hi' }]);
505
+
506
+ expect(typeof response.usage?.durationMs).toBe('number');
507
+ expect(response.usage!.durationMs!).toBeGreaterThanOrEqual(0);
508
+ });
509
+
510
+ test('provider does NOT validate response (validation is centralized in Router)', async () => {
511
+ mockFetchAndCapture({
512
+ ...OPENAI_RESPONSE,
513
+ choices: [{
514
+ index: 0,
515
+ message: {
516
+ role: 'assistant',
517
+ content: 'not valid json at all',
518
+ },
519
+ finish_reason: 'stop',
520
+ }],
521
+ });
522
+
523
+ const client = createClient();
524
+
525
+ const UserSchema = z.object({
526
+ name: z.string(),
527
+ });
528
+
529
+ const options: ChatOptions = {
530
+ schema: fromZod(UserSchema),
531
+ };
532
+
533
+ // Provider should NOT throw — validation is done at Router level
534
+ const response = await client.chat([
535
+ { role: 'user', content: 'Generate' },
536
+ ], options);
537
+ expect(response.message.content).toBe('not valid json at all');
538
+ });
539
+
540
+ test('provider returns raw response even on schema mismatch (Router validates)', async () => {
541
+ mockFetchAndCapture({
542
+ ...OPENAI_RESPONSE,
543
+ choices: [{
544
+ index: 0,
545
+ message: {
546
+ role: 'assistant',
547
+ content: '{"name": "Bob", "age": "not a number"}',
548
+ },
549
+ finish_reason: 'stop',
550
+ }],
551
+ });
552
+
553
+ const client = createClient();
554
+
555
+ const UserSchema = z.object({
556
+ name: z.string(),
557
+ age: z.number(),
558
+ });
559
+
560
+ const options: ChatOptions = {
561
+ schema: fromZod(UserSchema),
562
+ };
563
+
564
+ // Provider should NOT throw — validation is done at Router level
565
+ const response = await client.chat([
566
+ { role: 'user', content: 'Generate' },
567
+ ], options);
568
+ expect(response.message.content).toBe('{"name": "Bob", "age": "not a number"}');
569
+ });
570
+
571
+ test('includes raw output in response when schema provided', async () => {
572
+ const rawOutput = '{"name": 123}';
573
+ mockFetchAndCapture({
574
+ ...OPENAI_RESPONSE,
575
+ choices: [{
576
+ index: 0,
577
+ message: {
578
+ role: 'assistant',
579
+ content: rawOutput,
580
+ },
581
+ finish_reason: 'stop',
582
+ }],
583
+ });
584
+
585
+ const client = createClient();
586
+
587
+ const UserSchema = z.object({
588
+ name: z.string(),
589
+ });
590
+
591
+ const options: ChatOptions = {
592
+ schema: fromZod(UserSchema),
593
+ };
594
+
595
+ // Provider returns raw response — Router handles validation
596
+ const response = await client.chat([
597
+ { role: 'user', content: 'Generate' },
598
+ ], options);
599
+ expect(response.message.content).toBe(rawOutput);
600
+ });
601
+ });
602
+
603
+ // ========================================================================
604
+ // Transport flexibility: apiBasePath / queryParams / auth headers
605
+ // ========================================================================
606
+
607
+ describe('transport flexibility', () => {
608
+ function captureRequest(response: unknown = OPENAI_RESPONSE) {
609
+ const cap = { url: '', headers: {} as Record<string, string> };
610
+ globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
611
+ cap.url = String(input);
612
+ cap.headers = (init?.headers as Record<string, string>) ?? {};
613
+ return new Response(JSON.stringify(response), {
614
+ status: 200,
615
+ headers: { 'Content-Type': 'application/json' },
616
+ });
617
+ }) as typeof fetch;
618
+ return cap;
619
+ }
620
+ const hi: LLMChatMessage[] = [{ role: 'user', content: 'hi' }];
621
+
622
+ test('appends /v1 by default', async () => {
623
+ const cap = captureRequest();
624
+ await createClient({ url: 'https://host.example' }).chat(hi);
625
+ expect(cap.url).toBe('https://host.example/v1/chat/completions');
626
+ });
627
+
628
+ test('apiBasePath "" disables the /v1 append', async () => {
629
+ const cap = captureRequest();
630
+ await createClient({ url: 'https://h/openai/deployments/d', apiBasePath: '' }).chat(hi);
631
+ expect(cap.url).toBe('https://h/openai/deployments/d/chat/completions');
632
+ });
633
+
634
+ test('apiBasePath normalizes extra leading slashes (//v1 -> /v1)', async () => {
635
+ const cap = captureRequest();
636
+ await createClient({ url: 'https://host.example', apiBasePath: '//v1' }).chat(hi);
637
+ expect(cap.url).toBe('https://host.example/v1/chat/completions');
638
+ });
639
+
640
+ test('queryParams are appended to the URL', async () => {
641
+ const cap = captureRequest();
642
+ await createClient({ url: 'https://host.example', queryParams: { 'api-version': '2024-10-21' } }).chat(hi);
643
+ expect(cap.url).toContain('?api-version=2024-10-21');
644
+ });
645
+
646
+ test('preserves a query string already on the base URL (path before query)', async () => {
647
+ const cap = captureRequest();
648
+ await createClient({ url: 'https://h/v1?foo=1', apiBasePath: '' }).chat(hi);
649
+ expect(cap.url).toBe('https://h/v1/chat/completions?foo=1');
650
+ });
651
+
652
+ test('authHeader + authPrefix produce a custom auth header (no Bearer)', async () => {
653
+ const cap = captureRequest();
654
+ await createClient({ apiKey: 'secret', authHeader: 'api-key', authPrefix: '' }).chat(hi);
655
+ expect(cap.headers['api-key']).toBe('secret');
656
+ expect(cap.headers['Authorization']).toBeUndefined();
657
+ });
658
+
659
+ test('extraHeaders are merged into the request', async () => {
660
+ const cap = captureRequest();
661
+ await createClient({ apiKey: 'k', extraHeaders: { 'x-custom': 'v' } }).chat(hi);
662
+ expect(cap.headers['x-custom']).toBe('v');
663
+ });
664
+ });
665
+
666
+ // ========================================================================
667
+ // VAL-PROVIDER-OPENAI-005: Provider-Specific Schema Limitations (json_object mode)
668
+ // ========================================================================
669
+
670
+ describe('json_object mode (backward compatibility)', () => {
671
+ test('supports response_format type json_object for legacy providers', async () => {
672
+ const getBody = mockFetchAndCapture();
673
+ const client = createClient();
674
+
675
+ const options: ChatOptions = {
676
+ responseFormat: { type: 'json_object' },
677
+ };
678
+
679
+ await client.chat([
680
+ { role: 'user', content: 'Generate JSON' },
681
+ ], options);
682
+
683
+ const body = getBody()!;
684
+ expect(body['response_format']).toBeDefined();
685
+ expect((body['response_format'] as Record<string, unknown>)['type']).toBe('json_object');
686
+ });
687
+
688
+ test('json_object mode does not include schema in request', async () => {
689
+ const getBody = mockFetchAndCapture();
690
+ const client = createClient();
691
+
692
+ const options: ChatOptions = {
693
+ responseFormat: { type: 'json_object' },
694
+ };
695
+
696
+ await client.chat([
697
+ { role: 'user', content: 'Generate JSON' },
698
+ ], options);
699
+
700
+ const body = getBody()!;
701
+ const responseFormat = body['response_format'] as Record<string, unknown>;
702
+
703
+ expect(responseFormat['type']).toBe('json_object');
704
+ expect(responseFormat['json_schema']).toBeUndefined();
705
+ });
706
+
707
+ test('can provide schema alongside json_object for validation', async () => {
708
+ mockFetchAndCapture({
709
+ ...OPENAI_RESPONSE,
710
+ choices: [{
711
+ index: 0,
712
+ message: {
713
+ role: 'assistant',
714
+ content: '{"name": "Alice", "age": 30}',
715
+ },
716
+ finish_reason: 'stop',
717
+ }],
718
+ });
719
+
720
+ const client = createClient();
721
+
722
+ const UserSchema = z.object({
723
+ name: z.string(),
724
+ age: z.number(),
725
+ });
726
+
727
+ // Provider that only supports json_object (like older OpenAI)
728
+ const options: ChatOptions = {
729
+ schema: fromZod(UserSchema),
730
+ responseFormat: { type: 'json_object' },
731
+ };
732
+
733
+ const response = await client.chat([
734
+ { role: 'user', content: 'Generate a user' },
735
+ ], options);
736
+
737
+ // Validation should still work
738
+ expect(response.message.content).toBe('{"name": "Alice", "age": 30}');
739
+ });
740
+ });
741
+
742
+ // ========================================================================
743
+ // Error Handling
744
+ // ========================================================================
745
+
746
+ describe('error handling', () => {
747
+ test('provider returns raw response for null content (Router validates)', async () => {
748
+ mockFetchAndCapture({
749
+ ...OPENAI_RESPONSE,
750
+ choices: [{
751
+ index: 0,
752
+ message: {
753
+ role: 'assistant',
754
+ content: null,
755
+ },
756
+ finish_reason: 'stop',
757
+ }],
758
+ });
759
+
760
+ const client = createClient();
761
+
762
+ const UserSchema = z.object({
763
+ name: z.string(),
764
+ });
765
+
766
+ const options: ChatOptions = {
767
+ schema: fromZod(UserSchema),
768
+ };
769
+
770
+ // Provider should NOT throw returns raw response
771
+ const response = await client.chat([
772
+ { role: 'user', content: 'Generate' },
773
+ ], options);
774
+ expect(response.message.content).toBe('');
775
+ });
776
+
777
+ test('provider returns raw response for empty content (Router validates)', async () => {
778
+ mockFetchAndCapture({
779
+ ...OPENAI_RESPONSE,
780
+ choices: [{
781
+ index: 0,
782
+ message: {
783
+ role: 'assistant',
784
+ content: '',
785
+ },
786
+ finish_reason: 'stop',
787
+ }],
788
+ });
789
+
790
+ const client = createClient();
791
+
792
+ const UserSchema = z.object({
793
+ name: z.string(),
794
+ });
795
+
796
+ const options: ChatOptions = {
797
+ schema: fromZod(UserSchema),
798
+ };
799
+
800
+ // Provider should NOT throw — returns raw response
801
+ const response = await client.chat([
802
+ { role: 'user', content: 'Generate' },
803
+ ], options);
804
+ expect(response.message.content).toBe('');
805
+ });
806
+
807
+ test('provider returns raw response for schema mismatch (Router validates)', async () => {
808
+ mockFetchAndCapture({
809
+ ...OPENAI_RESPONSE,
810
+ choices: [{
811
+ index: 0,
812
+ message: {
813
+ role: 'assistant',
814
+ content: '{"name": "test"}', // Missing 'age'
815
+ },
816
+ finish_reason: 'stop',
817
+ }],
818
+ });
819
+
820
+ const client = createClient();
821
+
822
+ const UserSchema = z.object({
823
+ name: z.string(),
824
+ age: z.number(), // required
825
+ });
826
+
827
+ const options: ChatOptions = {
828
+ schema: fromZod(UserSchema),
829
+ };
830
+
831
+ // Provider should NOT throw — returns raw response
832
+ const response = await client.chat([
833
+ { role: 'user', content: 'Generate' },
834
+ ], options);
835
+ expect(response.message.content).toBe('{"name": "test"}');
836
+ });
837
+ });
838
+
839
+ // ========================================================================
840
+ // No schema option (regular chat)
841
+ // ========================================================================
842
+
843
+ describe('regular chat without schema', () => {
844
+ test('does not include response_format when no schema provided', async () => {
845
+ const getBody = mockFetchAndCapture();
846
+ const client = createClient();
847
+
848
+ await client.chat([
849
+ { role: 'user', content: 'Hello' },
850
+ ]);
851
+
852
+ const body = getBody()!;
853
+ expect(body['response_format']).toBeUndefined();
854
+ });
855
+
856
+ test('chat without schema returns raw response', async () => {
857
+ mockFetchAndCapture(OPENAI_RESPONSE);
858
+ const client = createClient();
859
+
860
+ const response = await client.chat([
861
+ { role: 'user', content: 'Hello' },
862
+ ]);
863
+
864
+ expect(response.message.content).toBe('{"name": "Alice", "age": 30}');
865
+ expect(response.message.role).toBe('assistant');
866
+ });
867
+ });
868
+
869
+ // ========================================================================
870
+ // Schema with Tools (allowed together)
871
+ // ========================================================================
872
+
873
+ describe('schema with tools', () => {
874
+ test('sends both response_format and tools in the request', async () => {
875
+ const getBody = mockFetchAndCapture();
876
+ const client = createClient();
877
+
878
+ const UserSchema = z.object({
879
+ name: z.string(),
880
+ });
881
+
882
+ const options: ChatOptions = {
883
+ schema: fromZod(UserSchema),
884
+ tools: [{
885
+ type: 'function',
886
+ function: {
887
+ name: 'test_tool',
888
+ description: 'A test tool',
889
+ parameters: {
890
+ type: 'object',
891
+ properties: {},
892
+ },
893
+ },
894
+ }],
895
+ };
896
+
897
+ await client.chat([
898
+ { role: 'user', content: 'Test' },
899
+ ], options);
900
+
901
+ const body = getBody();
902
+ expect(body).not.toBeNull();
903
+
904
+ // Both response_format and tools should be present
905
+ expect(body!.response_format).toBeDefined();
906
+ expect(body!.tools).toBeDefined();
907
+ expect((body!.tools as unknown[]).length).toBe(1);
908
+ });
909
+ });
910
+
911
+ // ========================================================================
912
+ // VAL-PROVIDER-OPENAI-003: Vision with Structured Output
913
+ // ========================================================================
914
+
915
+ describe('vision with structured output', () => {
916
+ test('includes image_url content parts with response_format in request', async () => {
917
+ const getBody = mockFetchAndCapture({
918
+ ...OPENAI_RESPONSE,
919
+ choices: [{
920
+ index: 0,
921
+ message: {
922
+ role: 'assistant',
923
+ content: '{"description": "A colorful image with flowers", "objects": ["flower", "vase", "table"]}',
924
+ },
925
+ finish_reason: 'stop',
926
+ }],
927
+ });
928
+ const client = createClient();
929
+
930
+ const DescriptionSchema = z.object({
931
+ description: z.string(),
932
+ objects: z.array(z.string()),
933
+ });
934
+
935
+ const messages = [{
936
+ role: 'user' as const,
937
+ content: [
938
+ { type: 'text' as const, text: 'Describe this image' },
939
+ { type: 'image_url' as const, image_url: { url: 'data:image/jpeg;base64,IMGDATA' } },
940
+ ] as const,
941
+ }];
942
+
943
+ const options: ChatOptions = {
944
+ schema: fromZod(DescriptionSchema),
945
+ };
946
+
947
+ await client.chat(messages, options);
948
+
949
+ const body = getBody()!;
950
+
951
+ // Should have response_format with structured output
952
+ expect(body['response_format']).toBeDefined();
953
+ const responseFormat = body['response_format'] as Record<string, unknown>;
954
+ expect(responseFormat['type']).toBe('json_schema');
955
+
956
+ // Should preserve image_url content parts
957
+ const sentMessages = body['messages'] as Array<Record<string, unknown>>;
958
+ expect(sentMessages).toHaveLength(1);
959
+ expect(sentMessages[0]!['role']).toBe('user');
960
+
961
+ const content = sentMessages[0]!['content'] as Array<Record<string, unknown>>;
962
+ expect(content).toHaveLength(2);
963
+
964
+ // Text part
965
+ expect(content[0]!['type']).toBe('text');
966
+ expect(content[0]!['text']).toBe('Describe this image');
967
+
968
+ // Image part
969
+ expect(content[1]!['type']).toBe('image_url');
970
+ expect(content[1]!['image_url']).toEqual({ url: 'data:image/jpeg;base64,IMGDATA' });
971
+ });
972
+
973
+ test('handles multiple images with structured output', async () => {
974
+ const getBody = mockFetchAndCapture({
975
+ ...OPENAI_RESPONSE,
976
+ choices: [{
977
+ index: 0,
978
+ message: {
979
+ role: 'assistant',
980
+ content: '{"comparison": "The images show different scenes"}',
981
+ },
982
+ finish_reason: 'stop',
983
+ }],
984
+ });
985
+ const client = createClient();
986
+
987
+ const ComparisonSchema = z.object({
988
+ comparison: z.string(),
989
+ });
990
+
991
+ const messages = [{
992
+ role: 'user' as const,
993
+ content: [
994
+ { type: 'text' as const, text: 'Compare these images' },
995
+ { type: 'image_url' as const, image_url: { url: 'data:image/png;base64,IMG1' } },
996
+ { type: 'image_url' as const, image_url: { url: 'data:image/png;base64,IMG2' } },
997
+ ] as const,
998
+ }];
999
+
1000
+ const options: ChatOptions = {
1001
+ schema: fromZod(ComparisonSchema),
1002
+ };
1003
+
1004
+ await client.chat(messages, options);
1005
+
1006
+ const body = getBody()!;
1007
+
1008
+ // Should have response_format with structured output
1009
+ expect(body['response_format']).toBeDefined();
1010
+
1011
+ // Should preserve all image_url content parts
1012
+ const sentMessages = body['messages'] as Array<Record<string, unknown>>;
1013
+ const content = sentMessages[0]!['content'] as Array<Record<string, unknown>>;
1014
+
1015
+ expect(content).toHaveLength(3);
1016
+ expect(content[0]!['type']).toBe('text');
1017
+ expect(content[1]!['type']).toBe('image_url');
1018
+ expect(content[1]!['image_url']).toEqual({ url: 'data:image/png;base64,IMG1' });
1019
+ expect(content[2]!['type']).toBe('image_url');
1020
+ expect(content[2]!['image_url']).toEqual({ url: 'data:image/png;base64,IMG2' });
1021
+ });
1022
+
1023
+ test('validates structured output response with vision', async () => {
1024
+ mockFetchAndCapture({
1025
+ ...OPENAI_RESPONSE,
1026
+ choices: [{
1027
+ index: 0,
1028
+ message: {
1029
+ role: 'assistant',
1030
+ content: '{"description": "A sunset over mountains", "colors": ["orange", "purple", "blue"]}',
1031
+ },
1032
+ finish_reason: 'stop',
1033
+ }],
1034
+ });
1035
+ const client = createClient();
1036
+
1037
+ const ImageAnalysisSchema = z.object({
1038
+ description: z.string(),
1039
+ colors: z.array(z.string()),
1040
+ });
1041
+
1042
+ const messages = [{
1043
+ role: 'user' as const,
1044
+ content: [
1045
+ { type: 'text' as const, text: 'Analyze this image' },
1046
+ { type: 'image_url' as const, image_url: { url: 'data:image/jpeg;base64,SUNSET' } },
1047
+ ] as const,
1048
+ }];
1049
+
1050
+ const options: ChatOptions = {
1051
+ schema: fromZod(ImageAnalysisSchema),
1052
+ };
1053
+
1054
+ const response = await client.chat(messages, options);
1055
+
1056
+ // Should validate and return successfully
1057
+ expect(response.message.content).toBe('{"description": "A sunset over mountains", "colors": ["orange", "purple", "blue"]}');
1058
+ });
1059
+
1060
+ test('supports http image URLs with structured output', async () => {
1061
+ const getBody = mockFetchAndCapture({
1062
+ ...OPENAI_RESPONSE,
1063
+ choices: [{
1064
+ index: 0,
1065
+ message: {
1066
+ role: 'assistant',
1067
+ content: '{"description": "An image from the web"}',
1068
+ },
1069
+ finish_reason: 'stop',
1070
+ }],
1071
+ });
1072
+ const client = createClient();
1073
+
1074
+ const DescriptionSchema = z.object({
1075
+ description: z.string(),
1076
+ });
1077
+
1078
+ const messages = [{
1079
+ role: 'user' as const,
1080
+ content: [
1081
+ { type: 'text' as const, text: 'Describe this image' },
1082
+ { type: 'image_url' as const, image_url: { url: 'https://example.com/image.jpg' } },
1083
+ ] as const,
1084
+ }];
1085
+
1086
+ const options: ChatOptions = {
1087
+ schema: fromZod(DescriptionSchema),
1088
+ };
1089
+
1090
+ await client.chat(messages, options);
1091
+
1092
+ const body = getBody()!;
1093
+ const sentMessages = body['messages'] as Array<Record<string, unknown>>;
1094
+ const content = sentMessages[0]!['content'] as Array<Record<string, unknown>>;
1095
+
1096
+ // OpenAI accepts HTTP URLs directly (unlike Ollama which needs base64)
1097
+ expect(content[1]!['type']).toBe('image_url');
1098
+ expect(content[1]!['image_url']).toEqual({ url: 'https://example.com/image.jpg' });
1099
+
1100
+ // And response_format should still be set
1101
+ expect(body['response_format']).toBeDefined();
1102
+ });
1103
+
1104
+ test('supports image_url with detail parameter and structured output', async () => {
1105
+ const getBody = mockFetchAndCapture({
1106
+ ...OPENAI_RESPONSE,
1107
+ choices: [{
1108
+ index: 0,
1109
+ message: {
1110
+ role: 'assistant',
1111
+ content: '{"objects": ["car", "tree", "building"]}',
1112
+ },
1113
+ finish_reason: 'stop',
1114
+ }],
1115
+ });
1116
+ const client = createClient();
1117
+
1118
+ const DescriptionSchema = z.object({
1119
+ objects: z.array(z.string()),
1120
+ });
1121
+
1122
+ const messages = [{
1123
+ role: 'user' as const,
1124
+ content: [
1125
+ { type: 'text' as const, text: 'List objects in this image' },
1126
+ { type: 'image_url' as const, image_url: { url: 'data:image/jpeg;base64,IMG', detail: 'high' } },
1127
+ ] as const,
1128
+ }];
1129
+
1130
+ const options: ChatOptions = {
1131
+ schema: fromZod(DescriptionSchema),
1132
+ };
1133
+
1134
+ await client.chat(messages, options);
1135
+
1136
+ const body = getBody()!;
1137
+ const sentMessages = body['messages'] as Array<Record<string, unknown>>;
1138
+ const content = sentMessages[0]!['content'] as Array<Record<string, unknown>>;
1139
+
1140
+ // Should preserve detail parameter
1141
+ expect(content[1]!['image_url']).toEqual({ url: 'data:image/jpeg;base64,IMG', detail: 'high' });
1142
+ expect(body['response_format']).toBeDefined();
1143
+ });
1144
+
1145
+ test('returns validated object on successful vision + structured output', async () => {
1146
+ mockFetchAndCapture({
1147
+ ...OPENAI_RESPONSE,
1148
+ choices: [{
1149
+ index: 0,
1150
+ message: {
1151
+ role: 'assistant',
1152
+ content: '{"count": 3, "items": ["cat", "dog", "bird"]}',
1153
+ },
1154
+ finish_reason: 'stop',
1155
+ }],
1156
+ });
1157
+ const client = createClient();
1158
+
1159
+ const VisionSchema = z.object({
1160
+ count: z.number(),
1161
+ items: z.array(z.string()),
1162
+ });
1163
+
1164
+ const messages = [{
1165
+ role: 'user' as const,
1166
+ content: [
1167
+ { type: 'text' as const, text: 'Count items' },
1168
+ { type: 'image_url' as const, image_url: { url: 'data:image/png;base64,IMG' } },
1169
+ ] as const,
1170
+ }];
1171
+
1172
+ const options: ChatOptions = {
1173
+ schema: fromZod(VisionSchema),
1174
+ };
1175
+
1176
+ // Should not throw - response passes validation
1177
+ const result = await client.chat(messages, options);
1178
+ expect(result.message.content).toBe('{"count": 3, "items": ["cat", "dog", "bird"]}');
1179
+ });
1180
+
1181
+ test('throws StructuredOutputError on invalid vision response', async () => {
1182
+ mockFetchAndCapture({
1183
+ ...OPENAI_RESPONSE,
1184
+ choices: [{
1185
+ index: 0,
1186
+ message: {
1187
+ role: 'assistant',
1188
+ content: '{"count": "not a number"}', // Invalid - count should be number
1189
+ },
1190
+ finish_reason: 'stop',
1191
+ }],
1192
+ });
1193
+ const client = createClient();
1194
+
1195
+ const VisionSchema = z.object({
1196
+ count: z.number(),
1197
+ });
1198
+
1199
+ const messages = [{
1200
+ role: 'user' as const,
1201
+ content: [
1202
+ { type: 'text' as const, text: 'Count items' },
1203
+ { type: 'image_url' as const, image_url: { url: 'data:image/png;base64,IMG' } },
1204
+ ] as const,
1205
+ }];
1206
+
1207
+ const options: ChatOptions = {
1208
+ schema: fromZod(VisionSchema),
1209
+ };
1210
+
1211
+ // Provider should NOT throw — validation is done at Router level
1212
+ const result = await client.chat(messages, options);
1213
+ expect(result.message.content).toBe('{"count": "not a number"}');
1214
+ });
1215
+ });
1216
+
1217
+ // ========================================================================
1218
+ // Complex Schemas
1219
+ // ========================================================================
1220
+
1221
+ describe('complex schemas', () => {
1222
+ test('handles nested object schemas', async () => {
1223
+ const getBody = mockFetchAndCapture({
1224
+ ...OPENAI_RESPONSE,
1225
+ choices: [{
1226
+ index: 0,
1227
+ message: {
1228
+ role: 'assistant',
1229
+ content: '{"name": "Alice", "address": {"street": "123 Main St", "city": "NYC"}}',
1230
+ },
1231
+ finish_reason: 'stop',
1232
+ }],
1233
+ });
1234
+ const client = createClient();
1235
+
1236
+ const AddressSchema = z.object({
1237
+ street: z.string(),
1238
+ city: z.string(),
1239
+ });
1240
+
1241
+ const UserSchema = z.object({
1242
+ name: z.string(),
1243
+ address: AddressSchema,
1244
+ });
1245
+
1246
+ const options: ChatOptions = {
1247
+ schema: fromZod(UserSchema),
1248
+ };
1249
+
1250
+ const response = await client.chat([
1251
+ { role: 'user', content: 'Generate' },
1252
+ ], options);
1253
+
1254
+ const body = getBody()!;
1255
+ const responseFormat = body['response_format'] as Record<string, unknown>;
1256
+ const jsonSchema = responseFormat['json_schema'] as Record<string, unknown>;
1257
+ const schema = jsonSchema['schema'] as Record<string, unknown>;
1258
+
1259
+ expect(schema['type']).toBe('object');
1260
+ const addressSchema = schema['properties']!['address'] as Record<string, unknown>;
1261
+ expect(addressSchema['type']).toBe('object');
1262
+ expect(addressSchema['properties']).toBeDefined();
1263
+ // Verify response validated successfully
1264
+ expect(response.message.content).toContain('Alice');
1265
+ });
1266
+
1267
+ test('handles array schemas', async () => {
1268
+ const getBody = mockFetchAndCapture({
1269
+ ...OPENAI_RESPONSE,
1270
+ choices: [{
1271
+ index: 0,
1272
+ message: {
1273
+ role: 'assistant',
1274
+ content: '{"users": [{"name": "Alice", "email": "alice@example.com"}]}',
1275
+ },
1276
+ finish_reason: 'stop',
1277
+ }],
1278
+ });
1279
+ const client = createClient();
1280
+
1281
+ const UserListSchema = z.object({
1282
+ users: z.array(z.object({
1283
+ name: z.string(),
1284
+ email: z.string().email(),
1285
+ })),
1286
+ });
1287
+
1288
+ const options: ChatOptions = {
1289
+ schema: fromZod(UserListSchema),
1290
+ };
1291
+
1292
+ const response = await client.chat([
1293
+ { role: 'user', content: 'Generate' },
1294
+ ], options);
1295
+
1296
+ const body = getBody()!;
1297
+ const responseFormat = body['response_format'] as Record<string, unknown>;
1298
+ const jsonSchema = responseFormat['json_schema'] as Record<string, unknown>;
1299
+ const schema = jsonSchema['schema'] as Record<string, unknown>;
1300
+
1301
+ expect(schema['type']).toBe('object');
1302
+ const usersSchema = schema['properties']!['users'] as Record<string, unknown>;
1303
+ expect(usersSchema['type']).toBe('array');
1304
+ expect(usersSchema['items']).toBeDefined();
1305
+ // Verify response validated successfully
1306
+ expect(response.message.content).toContain('Alice');
1307
+ });
1308
+
1309
+ test('handles enum schemas', async () => {
1310
+ const getBody = mockFetchAndCapture({
1311
+ ...OPENAI_RESPONSE,
1312
+ choices: [{
1313
+ index: 0,
1314
+ message: {
1315
+ role: 'assistant',
1316
+ content: '{"status": "active"}',
1317
+ },
1318
+ finish_reason: 'stop',
1319
+ }],
1320
+ });
1321
+ const client = createClient();
1322
+
1323
+ const StatusSchema = z.object({
1324
+ status: z.enum(['active', 'inactive', 'pending']),
1325
+ });
1326
+
1327
+ const options: ChatOptions = {
1328
+ schema: fromZod(StatusSchema),
1329
+ };
1330
+
1331
+ const response = await client.chat([
1332
+ { role: 'user', content: 'Generate' },
1333
+ ], options);
1334
+
1335
+ const body = getBody()!;
1336
+ const responseFormat = body['response_format'] as Record<string, unknown>;
1337
+ const jsonSchema = responseFormat['json_schema'] as Record<string, unknown>;
1338
+ const schema = jsonSchema['schema'] as Record<string, unknown>;
1339
+
1340
+ const statusSchema = schema['properties']!['status'] as Record<string, unknown>;
1341
+ expect(statusSchema['type']).toBe('string');
1342
+ expect(statusSchema['enum']).toEqual(['active', 'inactive', 'pending']);
1343
+ // Verify response validated successfully
1344
+ expect(response.message.content).toContain('active');
1345
+ });
1346
+ });
1347
+
1348
+ // ========================================================================
1349
+ // Edge cases
1350
+ // ========================================================================
1351
+
1352
+ describe('edge cases', () => {
1353
+ test('returns malformed structured output without provider-side validation', async () => {
1354
+ mockFetchAndCapture({
1355
+ ...OPENAI_RESPONSE,
1356
+ choices: [{
1357
+ index: 0,
1358
+ message: {
1359
+ role: 'assistant',
1360
+ content: '{"name": "Alice", "age": 30',
1361
+ },
1362
+ finish_reason: 'stop',
1363
+ }],
1364
+ });
1365
+ const client = createClient();
1366
+
1367
+ const response = await client.chat([{ role: 'user', content: 'Generate' }], {
1368
+ schema: fromZod(z.object({ name: z.string(), age: z.number() })),
1369
+ });
1370
+
1371
+ expect(response.message.content).toBe('{"name": "Alice", "age": 30');
1372
+ });
1373
+
1374
+ test('generates missing tool call IDs and normalizes empty arguments', async () => {
1375
+ mockFetchAndCapture({
1376
+ ...OPENAI_RESPONSE,
1377
+ choices: [{
1378
+ index: 0,
1379
+ message: {
1380
+ role: 'assistant',
1381
+ content: null,
1382
+ tool_calls: [{
1383
+ type: 'function',
1384
+ function: {
1385
+ name: 'get_weather',
1386
+ arguments: '',
1387
+ },
1388
+ }, {
1389
+ id: '',
1390
+ type: 'function',
1391
+ function: {
1392
+ name: 'get_time',
1393
+ },
1394
+ }, {
1395
+ id: '',
1396
+ type: 'function',
1397
+ function: {
1398
+ name: 'get_location',
1399
+ arguments: " \n\t",
1400
+ },
1401
+ }],
1402
+ },
1403
+ finish_reason: 'tool_calls',
1404
+ }],
1405
+ });
1406
+ const client = createClient();
1407
+
1408
+ const response = await client.chat([{ role: 'user', content: 'Use a tool' }]);
1409
+
1410
+ expect(response.message.tool_calls).toHaveLength(3);
1411
+ expect(response.message.tool_calls![0]!.id).toStartWith('call_');
1412
+ expect(response.message.tool_calls![0]!.function.arguments).toBe('{}');
1413
+ expect(response.message.tool_calls![1]!.id).toStartWith('call_');
1414
+ expect(response.message.tool_calls![1]!.function.arguments).toBe('{}');
1415
+ expect(response.message.tool_calls![2]!.id).toStartWith('call_');
1416
+ expect(response.message.tool_calls![2]!.function.arguments).toBe('{}');
1417
+ });
1418
+
1419
+ test('normalizes blank streamed tool call arguments', async () => {
1420
+ const chunks = [{
1421
+ choices: [{
1422
+ delta: {
1423
+ tool_calls: [{
1424
+ index: 0,
1425
+ id: 'call_blank',
1426
+ type: 'function',
1427
+ function: {
1428
+ name: 'get_weather',
1429
+ arguments: " \n",
1430
+ },
1431
+ }],
1432
+ },
1433
+ }],
1434
+ }, {
1435
+ choices: [{
1436
+ delta: {},
1437
+ finish_reason: 'tool_calls',
1438
+ }],
1439
+ }];
1440
+
1441
+ globalThis.fetch = mock(async () => new Response(
1442
+ chunks.map(chunk => `data: ${JSON.stringify(chunk)}\n\n`).join(''),
1443
+ {
1444
+ status: 200,
1445
+ headers: { 'Content-Type': 'text/event-stream' },
1446
+ },
1447
+ )) as typeof fetch;
1448
+ const client = createClient();
1449
+
1450
+ const events: DecodedEvent[] = [];
1451
+ const stream = client.chatStream([{ role: 'user', content: 'Use a tool' }]);
1452
+ let finalResult: Awaited<ReturnType<typeof client.chat>> | undefined;
1453
+ while (true) {
1454
+ const next = await stream.next();
1455
+ if (next.done) {
1456
+ finalResult = next.value || undefined;
1457
+ break;
1458
+ }
1459
+ events.push(next.value);
1460
+ }
1461
+
1462
+ const toolEvent = events.find(event => event.type === 'tool_call');
1463
+ expect(toolEvent?.type).toBe('tool_call');
1464
+ if (toolEvent?.type !== 'tool_call') {
1465
+ throw new Error('Expected a tool_call stream event');
1466
+ }
1467
+ expect(toolEvent.calls[0]!.function.arguments).toBe('{}');
1468
+ expect(finalResult?.message.tool_calls![0]!.function.arguments).toBe('{}');
1469
+ });
1470
+
1471
+ test('passes through non-empty malformed tool call arguments', async () => {
1472
+ mockFetchAndCapture({
1473
+ ...OPENAI_RESPONSE,
1474
+ choices: [{
1475
+ index: 0,
1476
+ message: {
1477
+ role: 'assistant',
1478
+ content: null,
1479
+ tool_calls: [{
1480
+ id: 'call_123',
1481
+ type: 'function',
1482
+ function: {
1483
+ name: 'get_weather',
1484
+ arguments: '{"location": "Boston"',
1485
+ },
1486
+ }],
1487
+ },
1488
+ finish_reason: 'tool_calls',
1489
+ }],
1490
+ });
1491
+ const client = createClient();
1492
+
1493
+ const response = await client.chat([{ role: 'user', content: 'Use a tool' }]);
1494
+
1495
+ expect(response.message.tool_calls![0]!.function.arguments).toBe('{"location": "Boston"');
1496
+ });
1497
+
1498
+ test('surfaces HTTP rate limit errors', async () => {
1499
+ mockFetchAndCapture({
1500
+ error: {
1501
+ message: 'Rate limit exceeded',
1502
+ type: 'rate_limit_error',
1503
+ },
1504
+ }, 429);
1505
+ const client = createClient();
1506
+
1507
+ await expect(client.chat([{ role: 'user', content: 'Hello' }]))
1508
+ .rejects.toThrow('Rate limit exceeded');
1509
+ });
1510
+ });
1511
+ });