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,954 @@
1
+ import { fromZod } from '../../zod-adapter.js';
2
+ /**
3
+ * Ollama Provider Unit Tests
4
+ *
5
+ * Tests the OllamaClient's message conversion logic, specifically
6
+ * the multimodal/vision path, tool call argument handling, and
7
+ * structured output via format parameter.
8
+ *
9
+ * Validates assertions:
10
+ * - VAL-PROVIDER-OLLAMA-001: format Parameter with JSON Schema
11
+ * - VAL-PROVIDER-OLLAMA-003: Vision with Base64 Extraction
12
+ * - VAL-PROVIDER-OLLAMA-004: format "json" vs Schema
13
+ */
14
+
15
+ import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test';
16
+ import { z } from 'zod';
17
+ import { OllamaClient } from '../../providers/ollama.js';
18
+ import type { LLMClientOptions, LLMChatMessage, ChatOptions } from '../../interfaces.js';
19
+ import { AIModelApiType } from '../../interfaces.js';
20
+
21
+ // ============================================================================
22
+ // Helpers
23
+ // ============================================================================
24
+
25
+ function createClient(overrides?: Partial<LLMClientOptions>): OllamaClient {
26
+ return new OllamaClient({
27
+ model: 'test-model',
28
+ url: 'http://localhost:11434',
29
+ apiType: AIModelApiType.Ollama,
30
+ ...overrides,
31
+ });
32
+ }
33
+
34
+ const OLLAMA_RESPONSE = {
35
+ model: 'test-model',
36
+ created_at: '2026-01-01T00:00:00Z',
37
+ message: { role: 'assistant', content: 'test response' },
38
+ done: true,
39
+ prompt_eval_count: 10,
40
+ eval_count: 5,
41
+ };
42
+
43
+ // ============================================================================
44
+ // Tests
45
+ // ============================================================================
46
+
47
+ describe('OllamaClient', () => {
48
+ let originalFetch: typeof globalThis.fetch;
49
+
50
+ beforeEach(() => {
51
+ originalFetch = globalThis.fetch;
52
+ });
53
+
54
+ afterEach(() => {
55
+ globalThis.fetch = originalFetch;
56
+ });
57
+
58
+ /** Capture the body sent to Ollama's /api/chat */
59
+ function mockFetchAndCapture(response = OLLAMA_RESPONSE) {
60
+ let capturedBody: Record<string, unknown> | null = null;
61
+
62
+ globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
63
+ if (init?.body) {
64
+ capturedBody = JSON.parse(init.body as string);
65
+ }
66
+ return new Response(JSON.stringify(response), {
67
+ status: 200,
68
+ headers: { 'Content-Type': 'application/json' },
69
+ });
70
+ }) as typeof fetch;
71
+
72
+ return () => capturedBody;
73
+ }
74
+
75
+ // ========================================================================
76
+ // Text-only messages
77
+ // ========================================================================
78
+
79
+ describe('text-only messages', () => {
80
+ test('passes string content through', async () => {
81
+ const getBody = mockFetchAndCapture();
82
+ const client = createClient();
83
+
84
+ await client.chat([
85
+ { role: 'user', content: 'Hello' },
86
+ ]);
87
+
88
+ const body = getBody()!;
89
+ const messages = body['messages'] as Record<string, unknown>[];
90
+ expect(messages).toHaveLength(1);
91
+ expect(messages[0]!['role']).toBe('user');
92
+ expect(messages[0]!['content']).toBe('Hello');
93
+ expect(messages[0]!['images']).toBeUndefined();
94
+ });
95
+
96
+ test('handles empty string content', async () => {
97
+ const getBody = mockFetchAndCapture();
98
+ const client = createClient();
99
+
100
+ await client.chat([
101
+ { role: 'assistant', content: '' },
102
+ ]);
103
+
104
+ const body = getBody()!;
105
+ const messages = body['messages'] as Record<string, unknown>[];
106
+ expect(messages[0]!['content']).toBe('');
107
+ });
108
+ });
109
+
110
+ // ========================================================================
111
+ // Multimodal / Vision messages
112
+ // ========================================================================
113
+
114
+ describe('multimodal messages', () => {
115
+ test('extracts base64 from data URLs', async () => {
116
+ const getBody = mockFetchAndCapture();
117
+ const client = createClient();
118
+
119
+ const messages: LLMChatMessage[] = [{
120
+ role: 'user',
121
+ content: [
122
+ { type: 'text', text: 'What is this?' },
123
+ { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,AAAA1234' } },
124
+ ],
125
+ }];
126
+
127
+ await client.chat(messages);
128
+
129
+ const body = getBody()!;
130
+ const sent = body['messages'] as Record<string, unknown>[];
131
+ expect(sent[0]!['content']).toBe('What is this?');
132
+ expect(sent[0]!['images']).toEqual(['AAAA1234']);
133
+ });
134
+
135
+ test('handles raw base64 strings', async () => {
136
+ const getBody = mockFetchAndCapture();
137
+ const client = createClient();
138
+
139
+ const messages: LLMChatMessage[] = [{
140
+ role: 'user',
141
+ content: [
142
+ { type: 'text', text: 'Describe' },
143
+ { type: 'image_url', image_url: { url: 'iVBORw0KGgo=' } },
144
+ ],
145
+ }];
146
+
147
+ await client.chat(messages);
148
+
149
+ const body = getBody()!;
150
+ const sent = body['messages'] as Record<string, unknown>[];
151
+ expect(sent[0]!['images']).toEqual(['iVBORw0KGgo=']);
152
+ });
153
+
154
+ test('skips http URL images without crashing', async () => {
155
+ const getBody = mockFetchAndCapture();
156
+ const client = createClient();
157
+
158
+ const messages: LLMChatMessage[] = [{
159
+ role: 'user',
160
+ content: [
161
+ { type: 'text', text: 'What is this?' },
162
+ { type: 'image_url', image_url: { url: 'https://example.com/photo.jpg' } },
163
+ ],
164
+ }];
165
+
166
+ await client.chat(messages);
167
+
168
+ const body = getBody()!;
169
+ const sent = body['messages'] as Record<string, unknown>[];
170
+ // HTTP URLs are skipped — no images array
171
+ expect(sent[0]!['images']).toBeUndefined();
172
+ expect(sent[0]!['content']).toBe('What is this?');
173
+ });
174
+
175
+ test('handles multiple images', async () => {
176
+ const getBody = mockFetchAndCapture();
177
+ const client = createClient();
178
+
179
+ const messages: LLMChatMessage[] = [{
180
+ role: 'user',
181
+ content: [
182
+ { type: 'text', text: 'Compare these' },
183
+ { type: 'image_url', image_url: { url: 'data:image/png;base64,IMG1' } },
184
+ { type: 'image_url', image_url: { url: 'data:image/png;base64,IMG2' } },
185
+ ],
186
+ }];
187
+
188
+ await client.chat(messages);
189
+
190
+ const body = getBody()!;
191
+ const sent = body['messages'] as Record<string, unknown>[];
192
+ expect(sent[0]!['images']).toEqual(['IMG1', 'IMG2']);
193
+ });
194
+
195
+ test('merges multiple text parts with newline', async () => {
196
+ const getBody = mockFetchAndCapture();
197
+ const client = createClient();
198
+
199
+ const messages: LLMChatMessage[] = [{
200
+ role: 'user',
201
+ content: [
202
+ { type: 'text', text: 'First part' },
203
+ { type: 'text', text: 'Second part' },
204
+ ],
205
+ }];
206
+
207
+ await client.chat(messages);
208
+
209
+ const body = getBody()!;
210
+ const sent = body['messages'] as Record<string, unknown>[];
211
+ expect(sent[0]!['content']).toBe('First part\nSecond part');
212
+ });
213
+ });
214
+
215
+ // ========================================================================
216
+ // Tool call argument handling
217
+ // ========================================================================
218
+
219
+ describe('tool call arguments in messages', () => {
220
+ test('deserializes JSON string arguments to objects', async () => {
221
+ const getBody = mockFetchAndCapture();
222
+ const client = createClient();
223
+
224
+ const messages: LLMChatMessage[] = [{
225
+ role: 'assistant',
226
+ content: '',
227
+ tool_calls: [{
228
+ id: 'call_1',
229
+ type: 'function',
230
+ function: {
231
+ name: 'get_weather',
232
+ arguments: JSON.stringify({ city: 'Tokyo' }),
233
+ },
234
+ }],
235
+ }];
236
+
237
+ await client.chat(messages);
238
+
239
+ const body = getBody()!;
240
+ const sent = body['messages'] as Record<string, unknown>[];
241
+ const toolCalls = sent[0]!['tool_calls'] as Array<{ function: { arguments: unknown } }>;
242
+ // Ollama expects arguments as objects, not strings
243
+ expect(toolCalls[0]!.function.arguments).toEqual({ city: 'Tokyo' });
244
+ });
245
+
246
+ test('passes through non-JSON arguments as-is', async () => {
247
+ const getBody = mockFetchAndCapture();
248
+ const client = createClient();
249
+
250
+ const messages: LLMChatMessage[] = [{
251
+ role: 'assistant',
252
+ content: '',
253
+ tool_calls: [{
254
+ id: 'call_1',
255
+ type: 'function',
256
+ function: {
257
+ name: 'test_tool',
258
+ arguments: 'not-valid-json',
259
+ },
260
+ }],
261
+ }];
262
+
263
+ await client.chat(messages);
264
+
265
+ const body = getBody()!;
266
+ const sent = body['messages'] as Record<string, unknown>[];
267
+ const toolCalls = sent[0]!['tool_calls'] as Array<{ function: { arguments: unknown } }>;
268
+ expect(toolCalls[0]!.function.arguments).toBe('not-valid-json');
269
+ });
270
+ });
271
+
272
+ // ========================================================================
273
+ // Options mapping
274
+ // ========================================================================
275
+
276
+ describe('options mapping', () => {
277
+ test('maps temperature and maxTokens to Ollama format', async () => {
278
+ const getBody = mockFetchAndCapture();
279
+ const client = createClient();
280
+
281
+ await client.chat(
282
+ [{ role: 'user', content: 'Hi' }],
283
+ { temperature: 0.7, maxTokens: 100 },
284
+ );
285
+
286
+ const body = getBody()!;
287
+ const options = body['options'] as Record<string, unknown>;
288
+ expect(options['temperature']).toBe(0.7);
289
+ expect(options['num_predict']).toBe(100);
290
+ });
291
+
292
+ test('enables thinking mode when configured', async () => {
293
+ const getBody = mockFetchAndCapture();
294
+ const client = createClient({ thinking: true });
295
+
296
+ await client.chat([{ role: 'user', content: 'Think about this' }]);
297
+
298
+ const body = getBody()!;
299
+ expect(body['think']).toBe(true);
300
+ });
301
+ });
302
+
303
+ // ========================================================================
304
+ // Response normalization
305
+ // ========================================================================
306
+
307
+ describe('response handling', () => {
308
+ test('normalizes response with usage info', async () => {
309
+ mockFetchAndCapture({
310
+ ...OLLAMA_RESPONSE,
311
+ prompt_eval_count: 42,
312
+ eval_count: 18,
313
+ });
314
+ const client = createClient();
315
+
316
+ const result = await client.chat([{ role: 'user', content: 'Hi' }]);
317
+
318
+ expect(result.message.role).toBe('assistant');
319
+ expect(result.message.content).toBe('test response');
320
+ expect(result.usage).toEqual({
321
+ inputTokens: 42,
322
+ outputTokens: 18,
323
+ totalTokens: 60,
324
+ });
325
+ expect(result.provider).toBe('ollama');
326
+ });
327
+
328
+ test('generates IDs for tool calls missing them', async () => {
329
+ mockFetchAndCapture({
330
+ ...OLLAMA_RESPONSE,
331
+ message: {
332
+ role: 'assistant',
333
+ content: '',
334
+ tool_calls: [{
335
+ id: '',
336
+ type: 'function',
337
+ function: { name: 'test', arguments: '{}' },
338
+ }],
339
+ },
340
+ });
341
+ const client = createClient();
342
+
343
+ const result = await client.chat([{ role: 'user', content: 'Hi' }]);
344
+
345
+ expect(result.message.tool_calls).toHaveLength(1);
346
+ expect(result.message.tool_calls![0]!.id).toBeTruthy();
347
+ expect(result.message.tool_calls![0]!.id.length).toBeGreaterThan(0);
348
+ });
349
+
350
+ test('prefers live deployment context from /api/ps over trained maximum', async () => {
351
+ globalThis.fetch = mock(async (input: string | URL | Request) => {
352
+ const url = typeof input === 'string'
353
+ ? input
354
+ : input instanceof URL
355
+ ? input.toString()
356
+ : input.url;
357
+
358
+ if (url.endsWith('/api/show')) {
359
+ return new Response(JSON.stringify({
360
+ model_info: {
361
+ 'general.architecture': 'gemma4',
362
+ 'gemma4.context_length': 262144,
363
+ },
364
+ capabilities: ['completion', 'vision', 'tools', 'thinking'],
365
+ }), {
366
+ status: 200,
367
+ headers: { 'Content-Type': 'application/json' },
368
+ });
369
+ }
370
+
371
+ if (url.endsWith('/api/ps')) {
372
+ return new Response(JSON.stringify({
373
+ models: [{ name: 'test-model', context_length: 32768 }],
374
+ }), {
375
+ status: 200,
376
+ headers: { 'Content-Type': 'application/json' },
377
+ });
378
+ }
379
+
380
+ return new Response(JSON.stringify(OLLAMA_RESPONSE), {
381
+ status: 200,
382
+ headers: { 'Content-Type': 'application/json' },
383
+ });
384
+ }) as typeof fetch;
385
+
386
+ const client = createClient();
387
+ const info = await client.getModelInfo();
388
+
389
+ expect(info.contextLength).toBe(32768);
390
+ expect(info.architecture).toBe('gemma4');
391
+ expect(info.capabilities).toEqual(['completion', 'vision', 'tools', 'thinking']);
392
+ });
393
+ });
394
+
395
+ // ========================================================================
396
+ // VAL-PROVIDER-OLLAMA-001: format Parameter with JSON Schema
397
+ // ========================================================================
398
+
399
+ describe('structured output format parameter', () => {
400
+ test('includes format with JSON Schema when schema provided', async () => {
401
+ const getBody = mockFetchAndCapture({
402
+ ...OLLAMA_RESPONSE,
403
+ message: { role: 'assistant', content: '{"name": "Test", "age": 30}' },
404
+ });
405
+ const client = createClient();
406
+
407
+ const UserSchema = z.object({
408
+ name: z.string(),
409
+ age: z.number(),
410
+ });
411
+
412
+ const options: ChatOptions = {
413
+ schema: fromZod(UserSchema),
414
+ };
415
+
416
+ await client.chat([
417
+ { role: 'user', content: 'Generate a user' },
418
+ ], options);
419
+
420
+ const body = getBody()!;
421
+ expect(body['format']).toBeDefined();
422
+ // Ollama format is an object with the schema
423
+ const format = body['format'] as Record<string, unknown>;
424
+ expect(format['type']).toBe('object');
425
+ expect(format['properties']).toBeDefined();
426
+ });
427
+
428
+ test('converts Zod schema to JSON Schema in format', async () => {
429
+ const getBody = mockFetchAndCapture({
430
+ ...OLLAMA_RESPONSE,
431
+ message: { role: 'assistant', content: '{"name": "Test", "age": 25}' },
432
+ });
433
+ const client = createClient();
434
+
435
+ const UserSchema = z.object({
436
+ name: z.string(),
437
+ age: z.number().optional(),
438
+ });
439
+
440
+ const options: ChatOptions = {
441
+ schema: fromZod(UserSchema),
442
+ };
443
+
444
+ await client.chat([
445
+ { role: 'user', content: 'Generate a user' },
446
+ ], options);
447
+
448
+ const body = getBody()!;
449
+ const format = body['format'] as Record<string, unknown>;
450
+
451
+ expect(format['type']).toBe('object');
452
+ expect(format['properties']).toBeDefined();
453
+ const properties = format['properties'] as Record<string, unknown>;
454
+ expect(properties['name']).toEqual({ type: 'string' });
455
+ expect(properties['age']).toEqual({ type: 'number' });
456
+ // Required should only include non-optional fields
457
+ expect(format['required']).toEqual(['name']);
458
+ });
459
+
460
+ test('accepts raw JSON Schema in format', async () => {
461
+ const getBody = mockFetchAndCapture({
462
+ ...OLLAMA_RESPONSE,
463
+ message: { role: 'assistant', content: '{"id": "123", "count": 5}' },
464
+ });
465
+ const client = createClient();
466
+
467
+ const jsonSchema = {
468
+ type: 'object' as const,
469
+ properties: {
470
+ id: { type: 'string' as const },
471
+ count: { type: 'number' as const },
472
+ },
473
+ required: ['id'],
474
+ };
475
+
476
+ const options: ChatOptions = {
477
+ jsonSchema,
478
+ };
479
+
480
+ await client.chat([
481
+ { role: 'user', content: 'Generate' },
482
+ ], options);
483
+
484
+ const body = getBody()!;
485
+ const format = body['format'] as Record<string, unknown>;
486
+
487
+ expect(format['type']).toBe('object');
488
+ expect(format['properties']).toBeDefined();
489
+ const properties = format['properties'] as Record<string, unknown>;
490
+ expect(properties['id']).toEqual({ type: 'string' });
491
+ expect(properties['count']).toEqual({ type: 'number' });
492
+ });
493
+
494
+ test('handles nested object schemas', async () => {
495
+ const getBody = mockFetchAndCapture({
496
+ ...OLLAMA_RESPONSE,
497
+ message: { role: 'assistant', content: '{"name": "Alice", "address": {"street": "123 Main St", "city": "NYC"}}' },
498
+ });
499
+ const client = createClient();
500
+
501
+ const AddressSchema = z.object({
502
+ street: z.string(),
503
+ city: z.string(),
504
+ });
505
+
506
+ const UserSchema = z.object({
507
+ name: z.string(),
508
+ address: AddressSchema,
509
+ });
510
+
511
+ const options: ChatOptions = {
512
+ schema: fromZod(UserSchema),
513
+ };
514
+
515
+ await client.chat([
516
+ { role: 'user', content: 'Generate' },
517
+ ], options);
518
+
519
+ const body = getBody()!;
520
+ const format = body['format'] as Record<string, unknown>;
521
+
522
+ expect(format['type']).toBe('object');
523
+ const properties = format['properties'] as Record<string, unknown>;
524
+ const addressSchema = properties['address'] as Record<string, unknown>;
525
+ expect(addressSchema['type']).toBe('object');
526
+ expect(addressSchema['properties']).toBeDefined();
527
+ });
528
+
529
+ test('handles array schemas', async () => {
530
+ const getBody = mockFetchAndCapture({
531
+ ...OLLAMA_RESPONSE,
532
+ message: { role: 'assistant', content: '{"users": [{"name": "Alice", "email": "alice@example.com"}]}' },
533
+ });
534
+ const client = createClient();
535
+
536
+ const UserListSchema = z.object({
537
+ users: z.array(z.object({
538
+ name: z.string(),
539
+ email: z.string(),
540
+ })),
541
+ });
542
+
543
+ const options: ChatOptions = {
544
+ schema: fromZod(UserListSchema),
545
+ };
546
+
547
+ await client.chat([
548
+ { role: 'user', content: 'Generate' },
549
+ ], options);
550
+
551
+ const body = getBody()!;
552
+ const format = body['format'] as Record<string, unknown>;
553
+
554
+ expect(format['type']).toBe('object');
555
+ const properties = format['properties'] as Record<string, unknown>;
556
+ const usersSchema = properties['users'] as Record<string, unknown>;
557
+ expect(usersSchema['type']).toBe('array');
558
+ expect(usersSchema['items']).toBeDefined();
559
+ });
560
+
561
+ test('handles enum schemas', async () => {
562
+ const getBody = mockFetchAndCapture({
563
+ ...OLLAMA_RESPONSE,
564
+ message: { role: 'assistant', content: '{"status": "active"}' },
565
+ });
566
+ const client = createClient();
567
+
568
+ const StatusSchema = z.object({
569
+ status: z.enum(['active', 'inactive', 'pending']),
570
+ });
571
+
572
+ const options: ChatOptions = {
573
+ schema: fromZod(StatusSchema),
574
+ };
575
+
576
+ await client.chat([
577
+ { role: 'user', content: 'Generate' },
578
+ ], options);
579
+
580
+ const body = getBody()!;
581
+ const format = body['format'] as Record<string, unknown>;
582
+
583
+ const properties = format['properties'] as Record<string, unknown>;
584
+ const statusSchema = properties['status'] as Record<string, unknown>;
585
+ expect(statusSchema['type']).toBe('string');
586
+ expect(statusSchema['enum']).toEqual(['active', 'inactive', 'pending']);
587
+ });
588
+ });
589
+
590
+ // ========================================================================
591
+ // VAL-PROVIDER-OLLAMA-004: format "json" vs Schema
592
+ // ========================================================================
593
+
594
+ describe('format json (simple mode)', () => {
595
+ test('supports format: "json" string for simple JSON mode', async () => {
596
+ const getBody = mockFetchAndCapture({
597
+ ...OLLAMA_RESPONSE,
598
+ message: { role: 'assistant', content: '{"key": "value"}' },
599
+ });
600
+ const client = createClient();
601
+
602
+ const options: ChatOptions = {
603
+ responseFormat: { type: 'json_object' },
604
+ };
605
+
606
+ await client.chat([
607
+ { role: 'user', content: 'Generate JSON' },
608
+ ], options);
609
+
610
+ const body = getBody()!;
611
+ // Ollama uses format: "json" for simple JSON mode
612
+ expect(body['format']).toBe('json');
613
+ });
614
+
615
+ test('json mode does not include schema in request', async () => {
616
+ const getBody = mockFetchAndCapture({
617
+ ...OLLAMA_RESPONSE,
618
+ message: { role: 'assistant', content: '{"key": "value"}' },
619
+ });
620
+ const client = createClient();
621
+
622
+ const options: ChatOptions = {
623
+ responseFormat: { type: 'json_object' },
624
+ };
625
+
626
+ await client.chat([
627
+ { role: 'user', content: 'Generate JSON' },
628
+ ], options);
629
+
630
+ const body = getBody()!;
631
+ // format should be a string, not an object with schema
632
+ expect(typeof body['format']).toBe('string');
633
+ expect(body['format']).toBe('json');
634
+ });
635
+ });
636
+
637
+ // ========================================================================
638
+ // Response Validation
639
+ // ========================================================================
640
+
641
+ describe('structured response validation', () => {
642
+ test('validates response JSON against schema', async () => {
643
+ mockFetchAndCapture({
644
+ ...OLLAMA_RESPONSE,
645
+ message: {
646
+ role: 'assistant',
647
+ content: '{"name": "Bob", "age": 25}',
648
+ },
649
+ });
650
+
651
+ const client = createClient();
652
+
653
+ const UserSchema = z.object({
654
+ name: z.string(),
655
+ age: z.number(),
656
+ });
657
+
658
+ const options: ChatOptions = {
659
+ schema: fromZod(UserSchema),
660
+ };
661
+
662
+ const response = await client.chat([
663
+ { role: 'user', content: 'Generate a user' },
664
+ ], options);
665
+
666
+ // If we got here without error, validation passed
667
+ expect(response.message.content).toBe('{"name": "Bob", "age": 25}');
668
+ });
669
+
670
+ test('provider returns raw response on invalid JSON (Router validates)', async () => {
671
+ mockFetchAndCapture({
672
+ ...OLLAMA_RESPONSE,
673
+ message: {
674
+ role: 'assistant',
675
+ content: 'not valid json at all',
676
+ },
677
+ });
678
+
679
+ const client = createClient();
680
+
681
+ const UserSchema = z.object({
682
+ name: z.string(),
683
+ });
684
+
685
+ const options: ChatOptions = {
686
+ schema: fromZod(UserSchema),
687
+ };
688
+
689
+ // Provider should NOT throw — validation is done at Router level
690
+ const response = await client.chat([
691
+ { role: 'user', content: 'Generate' },
692
+ ], options);
693
+ expect(response.message.content).toBe('not valid json at all');
694
+ });
695
+
696
+ test('provider returns raw response on schema mismatch (Router validates)', async () => {
697
+ mockFetchAndCapture({
698
+ ...OLLAMA_RESPONSE,
699
+ message: {
700
+ role: 'assistant',
701
+ content: '{"name": "Bob", "age": "not a number"}',
702
+ },
703
+ });
704
+
705
+ const client = createClient();
706
+
707
+ const UserSchema = z.object({
708
+ name: z.string(),
709
+ age: z.number(),
710
+ });
711
+
712
+ const options: ChatOptions = {
713
+ schema: fromZod(UserSchema),
714
+ };
715
+
716
+ // Provider should NOT throw — validation is done at Router level
717
+ const response = await client.chat([
718
+ { role: 'user', content: 'Generate' },
719
+ ], options);
720
+ expect(response.message.content).toBe('{"name": "Bob", "age": "not a number"}');
721
+ });
722
+
723
+ test('includes raw output in response when schema provided', async () => {
724
+ const rawOutput = '{"name": 123}';
725
+ mockFetchAndCapture({
726
+ ...OLLAMA_RESPONSE,
727
+ message: {
728
+ role: 'assistant',
729
+ content: rawOutput,
730
+ },
731
+ });
732
+
733
+ const client = createClient();
734
+
735
+ const UserSchema = z.object({
736
+ name: z.string(),
737
+ });
738
+
739
+ const options: ChatOptions = {
740
+ schema: fromZod(UserSchema),
741
+ };
742
+
743
+ // Provider returns raw response — Router handles validation
744
+ const response = await client.chat([
745
+ { role: 'user', content: 'Generate' },
746
+ ], options);
747
+ expect(response.message.content).toBe(rawOutput);
748
+ });
749
+
750
+ test('provider returns raw response for null content (Router validates)', async () => {
751
+ mockFetchAndCapture({
752
+ ...OLLAMA_RESPONSE,
753
+ message: {
754
+ role: 'assistant',
755
+ content: null as unknown as string,
756
+ },
757
+ });
758
+
759
+ const client = createClient();
760
+
761
+ const UserSchema = z.object({
762
+ name: z.string(),
763
+ });
764
+
765
+ const options: ChatOptions = {
766
+ schema: fromZod(UserSchema),
767
+ };
768
+
769
+ // Provider should NOT throw — returns raw response
770
+ const response = await client.chat([
771
+ { role: 'user', content: 'Generate' },
772
+ ], options);
773
+ expect(response.message.content).toBe('');
774
+ });
775
+
776
+ test('provider returns raw response for empty content (Router validates)', async () => {
777
+ mockFetchAndCapture({
778
+ ...OLLAMA_RESPONSE,
779
+ message: {
780
+ role: 'assistant',
781
+ content: '',
782
+ },
783
+ });
784
+
785
+ const client = createClient();
786
+
787
+ const UserSchema = z.object({
788
+ name: z.string(),
789
+ });
790
+
791
+ const options: ChatOptions = {
792
+ schema: fromZod(UserSchema),
793
+ };
794
+
795
+ // Provider should NOT throw — returns raw response
796
+ const response = await client.chat([
797
+ { role: 'user', content: 'Generate' },
798
+ ], options);
799
+ expect(response.message.content).toBe('');
800
+ });
801
+ });
802
+
803
+ // ========================================================================
804
+ // VAL-PROVIDER-OLLAMA-003: Vision with Base64 Extraction + format
805
+ // ========================================================================
806
+
807
+ describe('vision with structured output', () => {
808
+ test('includes both format and images array in request', async () => {
809
+ const getBody = mockFetchAndCapture({
810
+ ...OLLAMA_RESPONSE,
811
+ message: {
812
+ role: 'assistant',
813
+ content: '{"objects": ["cat", "keyboard"], "scene": "office"}',
814
+ },
815
+ });
816
+ const client = createClient();
817
+
818
+ const DescriptionSchema = z.object({
819
+ objects: z.array(z.string()),
820
+ scene: z.string(),
821
+ });
822
+
823
+ const messages: LLMChatMessage[] = [{
824
+ role: 'user',
825
+ content: [
826
+ { type: 'text', text: 'Describe this image' },
827
+ { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,IMGDATA' } },
828
+ ],
829
+ }];
830
+
831
+ const options: ChatOptions = {
832
+ schema: fromZod(DescriptionSchema),
833
+ };
834
+
835
+ await client.chat(messages, options);
836
+
837
+ const body = getBody()!;
838
+ const sent = body['messages'] as Record<string, unknown>[];
839
+
840
+ // Should have format with schema
841
+ expect(body['format']).toBeDefined();
842
+ const format = body['format'] as Record<string, unknown>;
843
+ expect(format['type']).toBe('object');
844
+
845
+ // Should have images extracted from data URL
846
+ expect(sent[0]!['images']).toEqual(['IMGDATA']);
847
+ });
848
+
849
+ test('validates structured output response with vision', async () => {
850
+ mockFetchAndCapture({
851
+ ...OLLAMA_RESPONSE,
852
+ message: {
853
+ role: 'assistant',
854
+ content: '{"objects": ["cat", "keyboard"], "scene": "office"}',
855
+ },
856
+ });
857
+
858
+ const client = createClient();
859
+
860
+ const DescriptionSchema = z.object({
861
+ objects: z.array(z.string()),
862
+ scene: z.string(),
863
+ });
864
+
865
+ const messages: LLMChatMessage[] = [{
866
+ role: 'user',
867
+ content: [
868
+ { type: 'text', text: 'Describe' },
869
+ { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,ABC' } },
870
+ ],
871
+ }];
872
+
873
+ const options: ChatOptions = {
874
+ schema: fromZod(DescriptionSchema),
875
+ };
876
+
877
+ const response = await client.chat(messages, options);
878
+
879
+ expect(response.message.content).toBe('{"objects": ["cat", "keyboard"], "scene": "office"}');
880
+ });
881
+ });
882
+
883
+ // ========================================================================
884
+ // No schema option (regular chat)
885
+ // ========================================================================
886
+
887
+ describe('regular chat without schema', () => {
888
+ test('does not include format when no schema provided', async () => {
889
+ const getBody = mockFetchAndCapture();
890
+ const client = createClient();
891
+
892
+ await client.chat([
893
+ { role: 'user', content: 'Hello' },
894
+ ]);
895
+
896
+ const body = getBody()!;
897
+ expect(body['format']).toBeUndefined();
898
+ });
899
+
900
+ test('chat without schema returns raw response', async () => {
901
+ mockFetchAndCapture();
902
+ const client = createClient();
903
+
904
+ const response = await client.chat([
905
+ { role: 'user', content: 'Hello' },
906
+ ]);
907
+
908
+ expect(response.message.content).toBe('test response');
909
+ expect(response.message.role).toBe('assistant');
910
+ });
911
+ });
912
+
913
+ // ========================================================================
914
+ // Schema with Tools (allowed together)
915
+ // ========================================================================
916
+
917
+ describe('schema with tools', () => {
918
+ test('sends both format and tools in the request', async () => {
919
+ const getBody = mockFetchAndCapture();
920
+ const client = createClient();
921
+
922
+ const UserSchema = z.object({
923
+ name: z.string(),
924
+ });
925
+
926
+ const options: ChatOptions = {
927
+ schema: fromZod(UserSchema),
928
+ tools: [{
929
+ type: 'function',
930
+ function: {
931
+ name: 'test_tool',
932
+ description: 'A test tool',
933
+ parameters: {
934
+ type: 'object',
935
+ properties: {},
936
+ },
937
+ },
938
+ }],
939
+ };
940
+
941
+ await client.chat([
942
+ { role: 'user', content: 'Test' },
943
+ ], options);
944
+
945
+ const body = getBody();
946
+ expect(body).not.toBeNull();
947
+
948
+ // Both format and tools should be present
949
+ expect(body!.format).toBeDefined();
950
+ expect(body!.tools).toBeDefined();
951
+ expect((body!.tools as unknown[]).length).toBe(1);
952
+ });
953
+ });
954
+ });