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,660 +1,841 @@
1
- import { fromZod } from '../../zod-adapter.js';
2
- /**
3
- * Google/Gemini Provider Structured Output Tests
4
- *
5
- * Tests the GoogleClient's structured output support (responseMimeType + responseSchema).
6
- * Validates assertions:
7
- * - VAL-PROVIDER-GOOGLE-001: responseMimeType and responseSchema
8
- * - VAL-PROVIDER-GOOGLE-002: Google AI Studio Integration (smoke test)
9
- * - VAL-PROVIDER-GOOGLE-003: Vision with Inline Data
10
- * - VAL-PROVIDER-GOOGLE-004: Gemini 3.x thoughtSignature Preservation
11
- * - VAL-PROVIDER-GOOGLE-006: Schema Conversion for Gemini
12
- */
13
-
14
- import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test';
15
- import { z } from 'zod';
16
- import { GoogleClient } from '../../providers/google.js';
17
- import type { LLMClientOptions, ChatOptions, LLMChatMessage } from '../../interfaces.js';
18
- import { AIModelApiType } from '../../interfaces.js';
19
-
20
- // ============================================================================
21
- // Helpers
22
- // ============================================================================
23
-
24
- function createClient(overrides?: Partial<LLMClientOptions>): GoogleClient {
25
- return new GoogleClient({
26
- model: 'gemini-1.5-flash',
27
- apiKey: 'test-api-key',
28
- apiType: AIModelApiType.Google,
29
- ...overrides,
30
- });
31
- }
32
-
33
- const GOOGLE_RESPONSE = {
34
- candidates: [{
35
- content: {
36
- parts: [{ text: '{"name": "Alice", "age": 30}' }],
37
- role: 'model',
38
- },
39
- finishReason: 'STOP',
40
- index: 0,
41
- }],
42
- usageMetadata: {
43
- promptTokenCount: 10,
44
- candidatesTokenCount: 20,
45
- totalTokenCount: 30,
46
- },
47
- };
48
-
49
- // ============================================================================
50
- // Tests
51
- // ============================================================================
52
-
53
- describe('GoogleClient Structured Output', () => {
54
- let originalFetch: typeof globalThis.fetch;
55
-
56
- beforeEach(() => {
57
- originalFetch = globalThis.fetch;
58
- });
59
-
60
- afterEach(() => {
61
- globalThis.fetch = originalFetch;
62
- });
63
-
64
- /** Capture the body sent to Google's generateContent API */
65
- function mockFetchAndCapture(response = GOOGLE_RESPONSE) {
66
- let capturedBody: Record<string, unknown> | null = null;
67
-
68
- globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
69
- if (init?.body) {
70
- capturedBody = JSON.parse(init.body as string);
71
- }
72
- return new Response(JSON.stringify(response), {
73
- status: 200,
74
- headers: { 'Content-Type': 'application/json' },
75
- });
76
- }) as typeof fetch;
77
-
78
- return () => capturedBody;
79
- }
80
-
81
- // ========================================================================
82
- // VAL-PROVIDER-GOOGLE-001: responseMimeType and responseSchema
83
- // ========================================================================
84
-
85
- describe('responseMimeType and responseSchema', () => {
86
- test('includes responseMimeType: application/json in generationConfig when schema provided', async () => {
87
- const getBody = mockFetchAndCapture();
88
- const client = createClient();
89
-
90
- const UserSchema = z.object({
91
- name: z.string(),
92
- age: z.number(),
93
- });
94
-
95
- const options: ChatOptions = {
96
- schema: fromZod(UserSchema),
97
- };
98
-
99
- await client.chat([
100
- { role: 'user', content: 'Generate a user' },
101
- ], options);
102
-
103
- const body = getBody()!;
104
- expect(body['generationConfig']).toBeDefined();
105
- const genConfig = body['generationConfig'] as Record<string, unknown>;
106
- expect(genConfig['responseMimeType']).toBe('application/json');
107
- });
108
-
109
- test('includes responseSchema with converted schema in generationConfig', async () => {
110
- const getBody = mockFetchAndCapture();
111
- const client = createClient();
112
-
113
- const UserSchema = z.object({
114
- name: z.string(),
115
- age: z.number().optional(),
116
- });
117
-
118
- const options: ChatOptions = {
119
- schema: fromZod(UserSchema),
120
- };
121
-
122
- await client.chat([
123
- { role: 'user', content: 'Generate a user' },
124
- ], options);
125
-
126
- const body = getBody()!;
127
- const genConfig = body['generationConfig'] as Record<string, unknown>;
128
- expect(genConfig['responseSchema']).toBeDefined();
129
-
130
- const schema = genConfig['responseSchema'] as Record<string, unknown>;
131
- expect(schema['type']).toBe('object');
132
- expect(schema['properties']).toBeDefined();
133
- expect(schema['properties']!['name']).toEqual({ type: 'string' });
134
- expect(schema['properties']!['age']).toEqual({ type: 'number' });
135
- // Google's schema should have required array
136
- expect(schema['required']).toEqual(['name']);
137
- });
138
-
139
- test('strips unsupported features from schema (pattern, minLength, etc.)', async () => {
140
- const getBody = mockFetchAndCapture({
141
- candidates: [{
142
- content: {
143
- parts: [{ text: '{"name": "Alice", "email": "alice@example.com", "age": 30}' }],
144
- role: 'model',
145
- },
146
- finishReason: 'STOP',
147
- index: 0,
148
- }],
149
- });
150
- const client = createClient();
151
-
152
- const UserSchema = z.object({
153
- name: z.string().min(1).max(100),
154
- email: z.string().regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/),
155
- age: z.number().min(0).max(150),
156
- });
157
-
158
- const options: ChatOptions = {
159
- schema: fromZod(UserSchema),
160
- };
161
-
162
- await client.chat([
163
- { role: 'user', content: 'Generate a user' },
164
- ], options);
165
-
166
- const body = getBody()!;
167
- const genConfig = body['generationConfig'] as Record<string, unknown>;
168
- const schema = genConfig['responseSchema'] as Record<string, unknown>;
169
- const props = schema['properties'] as Record<string, unknown>;
170
-
171
- // Google doesn't support pattern, minLength, maxLength, min, max
172
- expect(props['name']).toEqual({ type: 'string' });
173
- expect(props['email']).toEqual({ type: 'string' });
174
- expect(props['age']).toEqual({ type: 'number' });
175
- });
176
-
177
- test('supports raw jsonSchema option', async () => {
178
- const getBody = mockFetchAndCapture();
179
- const client = createClient();
180
-
181
- const options: ChatOptions = {
182
- jsonSchema: {
183
- type: 'object',
184
- properties: {
185
- id: { type: 'string' },
186
- count: { type: 'number' },
187
- },
188
- required: ['id'],
189
- },
190
- };
191
-
192
- await client.chat([
193
- { role: 'user', content: 'Generate data' },
194
- ], options);
195
-
196
- const body = getBody()!;
197
- const genConfig = body['generationConfig'] as Record<string, unknown>;
198
- expect(genConfig['responseMimeType']).toBe('application/json');
199
- expect(genConfig['responseSchema']).toBeDefined();
200
- });
201
-
202
- test('validates response against schema on success', async () => {
203
- const validResponse = {
204
- candidates: [{
205
- content: {
206
- parts: [{ text: '{"name": "Bob", "age": 25}' }],
207
- role: 'model',
208
- },
209
- index: 0,
210
- }],
211
- };
212
-
213
- mockFetchAndCapture(validResponse);
214
- const client = createClient();
215
-
216
- const UserSchema = z.object({
217
- name: z.string(),
218
- age: z.number(),
219
- });
220
-
221
- // Should not throw - valid response
222
- const result = await client.chat([
223
- { role: 'user', content: 'Generate user' },
224
- ], { schema: fromZod(UserSchema) });
225
-
226
- expect(result.message.content).toBe('{"name": "Bob", "age": 25}');
227
- });
228
-
229
- test('provider returns raw response on invalid JSON (Router validates)', async () => {
230
- const invalidJsonResponse = {
231
- candidates: [{
232
- content: {
233
- parts: [{ text: 'not valid json' }],
234
- role: 'model',
235
- },
236
- index: 0,
237
- }],
238
- };
239
-
240
- mockFetchAndCapture(invalidJsonResponse);
241
- const client = createClient();
242
-
243
- const UserSchema = z.object({
244
- name: z.string(),
245
- });
246
-
247
- // Provider should NOT throw — validation is done at Router level
248
- const result = await client.chat([
249
- { role: 'user', content: 'Generate user' },
250
- ], { schema: fromZod(UserSchema) });
251
- expect(result.message.content).toBe('not valid json');
252
- });
253
-
254
- test('provider returns raw response on schema mismatch (Router validates)', async () => {
255
- const invalidSchemaResponse = {
256
- candidates: [{
257
- content: {
258
- parts: [{ text: '{"name": "Bob", "age": "not a number"}' }],
259
- role: 'model',
260
- },
261
- index: 0,
262
- }],
263
- };
264
-
265
- mockFetchAndCapture(invalidSchemaResponse);
266
- const client = createClient();
267
-
268
- const UserSchema = z.object({
269
- name: z.string(),
270
- age: z.number(),
271
- });
272
-
273
- // Provider should NOT throw — validation is done at Router level
274
- const result = await client.chat([
275
- { role: 'user', content: 'Generate user' },
276
- ], { schema: fromZod(UserSchema) });
277
- expect(result.message.content).toBe('{"name": "Bob", "age": "not a number"}');
278
- });
279
-
280
- test('sends both schema and tools in the request', async () => {
281
- const getBody = mockFetchAndCapture({
282
- candidates: [{
283
- content: {
284
- parts: [{ text: '{"name": "Alice"}' }],
285
- role: 'model',
286
- },
287
- index: 0,
288
- }],
289
- });
290
- const client = createClient();
291
-
292
- const UserSchema = z.object({ name: z.string() });
293
- const options: ChatOptions = {
294
- schema: fromZod(UserSchema),
295
- tools: [{
296
- type: 'function',
297
- function: { name: 'test', description: 'test', parameters: { type: 'object' } },
298
- }],
299
- };
300
-
301
- const result = await client.chat([
302
- { role: 'user', content: 'Test' },
303
- ], options);
304
-
305
- const body = getBody();
306
- expect(body).not.toBeNull();
307
- // Both generationConfig.responseSchema and tools should be present
308
- const genConfig = (body as Record<string, unknown>).generationConfig as Record<string, unknown>;
309
- expect(genConfig.responseSchema).toBeDefined();
310
- expect((body as Record<string, unknown>).tools).toBeDefined();
311
- expect(result.message.content).toBe('{"name": "Alice"}');
312
- });
313
- });
314
-
315
- // ========================================================================
316
- // VAL-PROVIDER-GOOGLE-003: Vision with Inline Data
317
- // ========================================================================
318
-
319
- describe('vision with structured output', () => {
320
- test('converts data URLs to inlineData with mimeType', async () => {
321
- const getBody = mockFetchAndCapture({
322
- candidates: [{
323
- content: {
324
- parts: [{ text: '{"description": "A colorful image"}' }],
325
- role: 'model',
326
- },
327
- index: 0,
328
- }],
329
- });
330
- const client = createClient();
331
-
332
- const messages: LLMChatMessage[] = [{
333
- role: 'user',
334
- content: [
335
- { type: 'text', text: 'Describe this image' },
336
- { type: 'image_url', image_url: { url: 'data:image/png;base64,ABC123' } },
337
- ],
338
- }];
339
-
340
- const options: ChatOptions = {
341
- schema: fromZod(z.object({ description: z.string() })),
342
- };
343
-
344
- await client.chat(messages, options);
345
-
346
- const body = getBody()!;
347
- const contents = body['contents'] as Array<Record<string, unknown>>;
348
- expect(contents).toHaveLength(1);
349
- expect(contents[0]!['role']).toBe('user');
350
-
351
- const parts = contents[0]!['parts'] as Array<Record<string, unknown>>;
352
- expect(parts).toHaveLength(2);
353
-
354
- // First part: text
355
- expect(parts[0]!['text']).toBe('Describe this image');
356
-
357
- // Second part: inlineData with mimeType and data
358
- expect(parts[1]!['inlineData']).toBeDefined();
359
- const inlineData = parts[1]!['inlineData'] as Record<string, unknown>;
360
- expect(inlineData['mimeType']).toBe('image/png');
361
- expect(inlineData['data']).toBe('ABC123');
362
-
363
- // Also check that generationConfig is set
364
- const genConfig = body['generationConfig'] as Record<string, unknown>;
365
- expect(genConfig['responseMimeType']).toBe('application/json');
366
- });
367
-
368
- test('handles multiple images with structured output', async () => {
369
- const getBody = mockFetchAndCapture({
370
- candidates: [{
371
- content: {
372
- parts: [{ text: '{"difference": "They are different images"}' }],
373
- role: 'model',
374
- },
375
- index: 0,
376
- }],
377
- });
378
- const client = createClient();
379
-
380
- const messages: LLMChatMessage[] = [{
381
- role: 'user',
382
- content: [
383
- { type: 'text', text: 'Compare these' },
384
- { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,IMG1' } },
385
- { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,IMG2' } },
386
- ],
387
- }];
388
-
389
- const options: ChatOptions = {
390
- schema: fromZod(z.object({
391
- difference: z.string(),
392
- })),
393
- };
394
-
395
- await client.chat(messages, options);
396
-
397
- const body = getBody()!;
398
- const contents = body['contents'] as Array<Record<string, unknown>>;
399
- const parts = contents[0]!['parts'] as Array<Record<string, unknown>>;
400
-
401
- // Text + 2 images
402
- expect(parts).toHaveLength(3);
403
- expect(parts[0]!['text']).toBe('Compare these');
404
- expect(parts[1]!['inlineData']!['data']).toBe('IMG1');
405
- expect(parts[2]!['inlineData']!['data']).toBe('IMG2');
406
- });
407
- });
408
-
409
- // ========================================================================
410
- // VAL-PROVIDER-GOOGLE-004: Gemini 3.x thoughtSignature Preservation
411
- // ========================================================================
412
-
413
- describe('thoughtSignature preservation', () => {
414
- test('preserves thoughtSignature in assistant message tool_calls', async () => {
415
- mockFetchAndCapture();
416
- const client = createClient();
417
-
418
- // First, send a message with a tool call that has thoughtSignature
419
- // This simulates a multi-turn conversation with Gemini 3.x
420
- const messages: LLMChatMessage[] = [
421
- { role: 'user', content: 'What is the weather?' },
422
- {
423
- role: 'assistant',
424
- content: '',
425
- tool_calls: [{
426
- id: 'call-123',
427
- type: 'function',
428
- function: { name: 'get_weather', arguments: '{"location": "NYC"}' },
429
- thoughtSignature: 'encrypted-thought-data-here',
430
- }],
431
- },
432
- {
433
- role: 'tool',
434
- tool_call_id: 'call-123',
435
- content: '{"temp": 72, "condition": "sunny"}',
436
- },
437
- ];
438
-
439
- await client.chat(messages);
440
-
441
- // The tool call with thoughtSignature should still be preserved
442
- // This is handled in convertFunctionCallToToolCall
443
- // Google's format expects thoughtSignature on the functionCall part
444
- });
445
-
446
- test('converts tool calls with thoughtSignature to Google format', async () => {
447
- const getBody = mockFetchAndCapture();
448
- const client = createClient();
449
-
450
- const messages: LLMChatMessage[] = [
451
- { role: 'user', content: 'Test' },
452
- {
453
- role: 'assistant',
454
- content: '',
455
- tool_calls: [{
456
- id: 'call-123',
457
- type: 'function',
458
- function: { name: 'test_func', arguments: '{"arg": "value"}' },
459
- thoughtSignature: 'sig-123',
460
- }],
461
- },
462
- ];
463
-
464
- await client.chat(messages);
465
-
466
- const body = getBody()!;
467
- const contents = body['contents'] as Array<Record<string, unknown>>;
468
-
469
- // Find the model message
470
- const modelContent = contents.find(c => c['role'] === 'model');
471
- expect(modelContent).toBeDefined();
472
-
473
- const parts = (modelContent!['parts'] as Array<Record<string, unknown>>).filter(
474
- (p: Record<string, unknown>) => p['functionCall']
475
- );
476
- expect(parts).toHaveLength(1);
477
-
478
- const functionCall = parts[0]!['functionCall'] as Record<string, unknown>;
479
- expect(functionCall['name']).toBe('test_func');
480
- expect(functionCall['args']).toEqual({ arg: 'value' });
481
-
482
- // Check that thoughtSignature is echoed from the tool call
483
- expect(parts[0]!['thoughtSignature']).toBe('sig-123');
484
- });
485
-
486
- test('echoes thoughtSignature in response tool calls', async () => {
487
- const responseWithToolCall = {
488
- candidates: [{
489
- content: {
490
- parts: [{
491
- functionCall: {
492
- name: 'get_weather',
493
- args: { location: 'NYC' },
494
- },
495
- thoughtSignature: 'response-sig-123',
496
- }],
497
- role: 'model',
498
- },
499
- index: 0,
500
- }],
501
- };
502
-
503
- mockFetchAndCapture(responseWithToolCall);
504
- const client = createClient();
505
-
506
- const result = await client.chat([
507
- { role: 'user', content: 'Weather?' },
508
- ]);
509
-
510
- expect(result.message.tool_calls).toBeDefined();
511
- expect(result.message.tool_calls).toHaveLength(1);
512
- expect(result.message.tool_calls![0]!.function.name).toBe('get_weather');
513
- expect(result.message.tool_calls![0]!.thoughtSignature).toBe('response-sig-123');
514
- });
515
- });
516
-
517
- // ========================================================================
518
- // Additional Features: System Instructions, Tools
519
- // ========================================================================
520
-
521
- describe('structured output with system instructions', () => {
522
- test('includes system instruction with structured output', async () => {
523
- const getBody = mockFetchAndCapture({
524
- candidates: [{
525
- content: {
526
- parts: [{ text: '{"result": "test output"}' }],
527
- role: 'model',
528
- },
529
- index: 0,
530
- }],
531
- });
532
- const client = createClient();
533
-
534
- const options: ChatOptions = {
535
- schema: fromZod(z.object({ result: z.string() })),
536
- };
537
-
538
- await client.chat([
539
- { role: 'system', content: 'Always respond in JSON format.' },
540
- { role: 'user', content: 'Generate data' },
541
- ], options);
542
-
543
- const body = getBody()!;
544
- expect(body['systemInstruction']).toBeDefined();
545
- const sysInst = body['systemInstruction'] as Record<string, unknown>;
546
- expect(sysInst['parts']).toEqual([{ text: 'Always respond in JSON format.' }]);
547
-
548
- // Also verify structured output is still set
549
- const genConfig = body['generationConfig'] as Record<string, unknown>;
550
- expect(genConfig['responseMimeType']).toBe('application/json');
551
- });
552
- });
553
-
554
- describe('structured output with parameters', () => {
555
- test('includes temperature and maxTokens alongside responseMimeType', async () => {
556
- const getBody = mockFetchAndCapture();
557
- const client = createClient();
558
-
559
- const options: ChatOptions = {
560
- schema: fromZod(z.object({ name: z.string() })),
561
- temperature: 0.7,
562
- maxTokens: 100,
563
- };
564
-
565
- await client.chat([
566
- { role: 'user', content: 'Generate' },
567
- ], options);
568
-
569
- const body = getBody()!;
570
- const genConfig = body['generationConfig'] as Record<string, unknown>;
571
-
572
- expect(genConfig['responseMimeType']).toBe('application/json');
573
- expect(genConfig['responseSchema']).toBeDefined();
574
- expect(genConfig['temperature']).toBe(0.7);
575
- expect(genConfig['maxOutputTokens']).toBe(100);
576
- });
577
- });
578
-
579
- // ========================================================================
580
- // Vertex AI Support
581
- // ========================================================================
582
-
583
- describe('Vertex AI structured output', () => {
584
- test('builds correct URL for Vertex AI', async () => {
585
- let capturedUrl = '';
586
-
587
- globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
588
- capturedUrl = typeof input === 'string' ? input : input.toString();
589
- return new Response(JSON.stringify({
590
- candidates: [{
591
- content: {
592
- parts: [{ text: '{"text": "test"}' }],
593
- role: 'model',
594
- },
595
- index: 0,
596
- }],
597
- }), {
598
- status: 200,
599
- headers: { 'Content-Type': 'application/json' },
600
- });
601
- }) as typeof fetch;
602
-
603
- const client = new GoogleClient({
604
- model: 'gemini-1.5-pro',
605
- apiType: AIModelApiType.Vertex,
606
- region: 'us-central1',
607
- apiKey: 'vertex-token',
608
- });
609
-
610
- const options: ChatOptions = {
611
- schema: fromZod(z.object({ text: z.string() })),
612
- };
613
-
614
- await client.chat([{ role: 'user', content: 'Test' }], options);
615
-
616
- // Vertex AI URL format
617
- expect(capturedUrl).toContain('aiplatform.googleapis.com');
618
- expect(capturedUrl).toContain('us-central1');
619
- expect(capturedUrl).toContain(':generateContent');
620
- });
621
-
622
- test('uses Authorization header for Vertex AI', async () => {
623
- let capturedHeaders: Record<string, string> = {};
624
-
625
- globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
626
- capturedHeaders = init?.headers as Record<string, string> || {};
627
- return new Response(JSON.stringify({
628
- candidates: [{
629
- content: {
630
- parts: [{ text: '{"text": "test"}' }],
631
- role: 'model',
632
- },
633
- index: 0,
634
- }],
635
- }), {
636
- status: 200,
637
- headers: { 'Content-Type': 'application/json' },
638
- });
639
- }) as typeof fetch;
640
-
641
- const client = new GoogleClient({
642
- model: 'gemini-1.5-pro',
643
- apiType: AIModelApiType.Vertex,
644
- region: 'us-central1',
645
- apiKey: 'vertex-token',
646
- });
647
-
648
- const options: ChatOptions = {
649
- schema: fromZod(z.object({ text: z.string() })),
650
- };
651
-
652
- await client.chat([{ role: 'user', content: 'Test' }], options);
653
-
654
- // Vertex uses Bearer token, not query param
655
- expect(capturedHeaders['Authorization']).toBe('Bearer vertex-token');
656
- // URL should NOT have ?key= in it
657
- expect(capturedHeaders['Content-Type']).toBe('application/json');
658
- });
659
- });
660
- });
1
+ import { fromZod } from '../../zod-adapter.js';
2
+ /**
3
+ * Google/Gemini Provider Structured Output Tests
4
+ *
5
+ * Tests the GoogleClient's structured output support (responseMimeType + responseSchema).
6
+ * Validates assertions:
7
+ * - VAL-PROVIDER-GOOGLE-001: responseMimeType and responseSchema
8
+ * - VAL-PROVIDER-GOOGLE-002: Google AI Studio Integration (smoke test)
9
+ * - VAL-PROVIDER-GOOGLE-003: Vision with Inline Data
10
+ * - VAL-PROVIDER-GOOGLE-004: Gemini 3.x thoughtSignature Preservation
11
+ * - VAL-PROVIDER-GOOGLE-006: Schema Conversion for Gemini
12
+ */
13
+
14
+ import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test';
15
+ import { z } from 'zod';
16
+ import { GoogleClient } from '../../providers/google.js';
17
+ import type { LLMClientOptions, ChatOptions, LLMChatMessage } from '../../interfaces.js';
18
+ import { AIModelApiType } from '../../interfaces.js';
19
+
20
+ // ============================================================================
21
+ // Helpers
22
+ // ============================================================================
23
+
24
+ function createClient(overrides?: Partial<LLMClientOptions>): GoogleClient {
25
+ return new GoogleClient({
26
+ model: 'gemini-1.5-flash',
27
+ apiKey: 'test-api-key',
28
+ apiType: AIModelApiType.Google,
29
+ ...overrides,
30
+ });
31
+ }
32
+
33
+ const GOOGLE_RESPONSE = {
34
+ candidates: [{
35
+ content: {
36
+ parts: [{ text: '{"name": "Alice", "age": 30}' }],
37
+ role: 'model',
38
+ },
39
+ finishReason: 'STOP',
40
+ index: 0,
41
+ }],
42
+ usageMetadata: {
43
+ promptTokenCount: 10,
44
+ candidatesTokenCount: 20,
45
+ totalTokenCount: 30,
46
+ },
47
+ };
48
+
49
+ // ============================================================================
50
+ // Tests
51
+ // ============================================================================
52
+
53
+ describe('GoogleClient Structured Output', () => {
54
+ let originalFetch: typeof globalThis.fetch;
55
+
56
+ beforeEach(() => {
57
+ originalFetch = globalThis.fetch;
58
+ });
59
+
60
+ afterEach(() => {
61
+ globalThis.fetch = originalFetch;
62
+ });
63
+
64
+ /** Capture the body sent to Google's generateContent API */
65
+ function mockFetchAndCapture(response: unknown = GOOGLE_RESPONSE, status = 200) {
66
+ let capturedBody: Record<string, unknown> | null = null;
67
+
68
+ globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
69
+ if (init?.body) {
70
+ capturedBody = JSON.parse(init.body as string);
71
+ }
72
+ return new Response(JSON.stringify(response), {
73
+ status,
74
+ headers: { 'Content-Type': 'application/json' },
75
+ });
76
+ }) as typeof fetch;
77
+
78
+ return () => capturedBody;
79
+ }
80
+
81
+ // ========================================================================
82
+ // VAL-PROVIDER-GOOGLE-001: responseMimeType and responseSchema
83
+ // ========================================================================
84
+
85
+ describe('responseMimeType and responseSchema', () => {
86
+ test('includes responseMimeType: application/json in generationConfig when schema provided', async () => {
87
+ const getBody = mockFetchAndCapture();
88
+ const client = createClient();
89
+
90
+ const UserSchema = z.object({
91
+ name: z.string(),
92
+ age: z.number(),
93
+ });
94
+
95
+ const options: ChatOptions = {
96
+ schema: fromZod(UserSchema),
97
+ };
98
+
99
+ await client.chat([
100
+ { role: 'user', content: 'Generate a user' },
101
+ ], options);
102
+
103
+ const body = getBody()!;
104
+ expect(body['generationConfig']).toBeDefined();
105
+ const genConfig = body['generationConfig'] as Record<string, unknown>;
106
+ expect(genConfig['responseMimeType']).toBe('application/json');
107
+ });
108
+
109
+ test('includes responseSchema with converted schema in generationConfig', async () => {
110
+ const getBody = mockFetchAndCapture();
111
+ const client = createClient();
112
+
113
+ const UserSchema = z.object({
114
+ name: z.string(),
115
+ age: z.number().optional(),
116
+ });
117
+
118
+ const options: ChatOptions = {
119
+ schema: fromZod(UserSchema),
120
+ };
121
+
122
+ await client.chat([
123
+ { role: 'user', content: 'Generate a user' },
124
+ ], options);
125
+
126
+ const body = getBody()!;
127
+ const genConfig = body['generationConfig'] as Record<string, unknown>;
128
+ expect(genConfig['responseSchema']).toBeDefined();
129
+
130
+ const schema = genConfig['responseSchema'] as Record<string, unknown>;
131
+ expect(schema['type']).toBe('object');
132
+ expect(schema['properties']).toBeDefined();
133
+ expect(schema['properties']!['name']).toEqual({ type: 'string' });
134
+ expect(schema['properties']!['age']).toEqual({ type: 'number' });
135
+ // Google's schema should have required array
136
+ expect(schema['required']).toEqual(['name']);
137
+ });
138
+
139
+ test('strips unsupported features from schema (pattern, minLength, etc.)', async () => {
140
+ const getBody = mockFetchAndCapture({
141
+ candidates: [{
142
+ content: {
143
+ parts: [{ text: '{"name": "Alice", "email": "alice@example.com", "age": 30}' }],
144
+ role: 'model',
145
+ },
146
+ finishReason: 'STOP',
147
+ index: 0,
148
+ }],
149
+ });
150
+ const client = createClient();
151
+
152
+ const UserSchema = z.object({
153
+ name: z.string().min(1).max(100),
154
+ email: z.string().regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/),
155
+ age: z.number().min(0).max(150),
156
+ });
157
+
158
+ const options: ChatOptions = {
159
+ schema: fromZod(UserSchema),
160
+ };
161
+
162
+ await client.chat([
163
+ { role: 'user', content: 'Generate a user' },
164
+ ], options);
165
+
166
+ const body = getBody()!;
167
+ const genConfig = body['generationConfig'] as Record<string, unknown>;
168
+ const schema = genConfig['responseSchema'] as Record<string, unknown>;
169
+ const props = schema['properties'] as Record<string, unknown>;
170
+
171
+ // Google doesn't support pattern, minLength, maxLength, min, max
172
+ expect(props['name']).toEqual({ type: 'string' });
173
+ expect(props['email']).toEqual({ type: 'string' });
174
+ expect(props['age']).toEqual({ type: 'number' });
175
+ });
176
+
177
+ test('supports raw jsonSchema option', async () => {
178
+ const getBody = mockFetchAndCapture();
179
+ const client = createClient();
180
+
181
+ const options: ChatOptions = {
182
+ jsonSchema: {
183
+ type: 'object',
184
+ properties: {
185
+ id: { type: 'string' },
186
+ count: { type: 'number' },
187
+ },
188
+ required: ['id'],
189
+ },
190
+ };
191
+
192
+ await client.chat([
193
+ { role: 'user', content: 'Generate data' },
194
+ ], options);
195
+
196
+ const body = getBody()!;
197
+ const genConfig = body['generationConfig'] as Record<string, unknown>;
198
+ expect(genConfig['responseMimeType']).toBe('application/json');
199
+ expect(genConfig['responseSchema']).toBeDefined();
200
+ });
201
+
202
+ test('validates response against schema on success', async () => {
203
+ const validResponse = {
204
+ candidates: [{
205
+ content: {
206
+ parts: [{ text: '{"name": "Bob", "age": 25}' }],
207
+ role: 'model',
208
+ },
209
+ index: 0,
210
+ }],
211
+ };
212
+
213
+ mockFetchAndCapture(validResponse);
214
+ const client = createClient();
215
+
216
+ const UserSchema = z.object({
217
+ name: z.string(),
218
+ age: z.number(),
219
+ });
220
+
221
+ // Should not throw - valid response
222
+ const result = await client.chat([
223
+ { role: 'user', content: 'Generate user' },
224
+ ], { schema: fromZod(UserSchema) });
225
+
226
+ expect(result.message.content).toBe('{"name": "Bob", "age": 25}');
227
+ });
228
+
229
+ test('provider returns raw response on invalid JSON (Router validates)', async () => {
230
+ const invalidJsonResponse = {
231
+ candidates: [{
232
+ content: {
233
+ parts: [{ text: 'not valid json' }],
234
+ role: 'model',
235
+ },
236
+ index: 0,
237
+ }],
238
+ };
239
+
240
+ mockFetchAndCapture(invalidJsonResponse);
241
+ const client = createClient();
242
+
243
+ const UserSchema = z.object({
244
+ name: z.string(),
245
+ });
246
+
247
+ // Provider should NOT throw — validation is done at Router level
248
+ const result = await client.chat([
249
+ { role: 'user', content: 'Generate user' },
250
+ ], { schema: fromZod(UserSchema) });
251
+ expect(result.message.content).toBe('not valid json');
252
+ });
253
+
254
+ test('provider returns raw response on schema mismatch (Router validates)', async () => {
255
+ const invalidSchemaResponse = {
256
+ candidates: [{
257
+ content: {
258
+ parts: [{ text: '{"name": "Bob", "age": "not a number"}' }],
259
+ role: 'model',
260
+ },
261
+ index: 0,
262
+ }],
263
+ };
264
+
265
+ mockFetchAndCapture(invalidSchemaResponse);
266
+ const client = createClient();
267
+
268
+ const UserSchema = z.object({
269
+ name: z.string(),
270
+ age: z.number(),
271
+ });
272
+
273
+ // Provider should NOT throw — validation is done at Router level
274
+ const result = await client.chat([
275
+ { role: 'user', content: 'Generate user' },
276
+ ], { schema: fromZod(UserSchema) });
277
+ expect(result.message.content).toBe('{"name": "Bob", "age": "not a number"}');
278
+ });
279
+
280
+ test('sends both schema and tools in the request', async () => {
281
+ const getBody = mockFetchAndCapture({
282
+ candidates: [{
283
+ content: {
284
+ parts: [{ text: '{"name": "Alice"}' }],
285
+ role: 'model',
286
+ },
287
+ index: 0,
288
+ }],
289
+ });
290
+ const client = createClient();
291
+
292
+ const UserSchema = z.object({ name: z.string() });
293
+ const options: ChatOptions = {
294
+ schema: fromZod(UserSchema),
295
+ tools: [{
296
+ type: 'function',
297
+ function: { name: 'test', description: 'test', parameters: { type: 'object' } },
298
+ }],
299
+ };
300
+
301
+ const result = await client.chat([
302
+ { role: 'user', content: 'Test' },
303
+ ], options);
304
+
305
+ const body = getBody();
306
+ expect(body).not.toBeNull();
307
+ // Both generationConfig.responseSchema and tools should be present
308
+ const genConfig = (body as Record<string, unknown>).generationConfig as Record<string, unknown>;
309
+ expect(genConfig.responseSchema).toBeDefined();
310
+ expect((body as Record<string, unknown>).tools).toBeDefined();
311
+ expect(result.message.content).toBe('{"name": "Alice"}');
312
+ });
313
+ });
314
+
315
+ // ========================================================================
316
+ // VAL-PROVIDER-GOOGLE-003: Vision with Inline Data
317
+ // ========================================================================
318
+
319
+ describe('vision with structured output', () => {
320
+ test('converts data URLs to inlineData with mimeType', async () => {
321
+ const getBody = mockFetchAndCapture({
322
+ candidates: [{
323
+ content: {
324
+ parts: [{ text: '{"description": "A colorful image"}' }],
325
+ role: 'model',
326
+ },
327
+ index: 0,
328
+ }],
329
+ });
330
+ const client = createClient();
331
+
332
+ const messages: LLMChatMessage[] = [{
333
+ role: 'user',
334
+ content: [
335
+ { type: 'text', text: 'Describe this image' },
336
+ { type: 'image_url', image_url: { url: 'data:image/png;base64,ABC123' } },
337
+ ],
338
+ }];
339
+
340
+ const options: ChatOptions = {
341
+ schema: fromZod(z.object({ description: z.string() })),
342
+ };
343
+
344
+ await client.chat(messages, options);
345
+
346
+ const body = getBody()!;
347
+ const contents = body['contents'] as Array<Record<string, unknown>>;
348
+ expect(contents).toHaveLength(1);
349
+ expect(contents[0]!['role']).toBe('user');
350
+
351
+ const parts = contents[0]!['parts'] as Array<Record<string, unknown>>;
352
+ expect(parts).toHaveLength(2);
353
+
354
+ // First part: text
355
+ expect(parts[0]!['text']).toBe('Describe this image');
356
+
357
+ // Second part: inlineData with mimeType and data
358
+ expect(parts[1]!['inlineData']).toBeDefined();
359
+ const inlineData = parts[1]!['inlineData'] as Record<string, unknown>;
360
+ expect(inlineData['mimeType']).toBe('image/png');
361
+ expect(inlineData['data']).toBe('ABC123');
362
+
363
+ // Also check that generationConfig is set
364
+ const genConfig = body['generationConfig'] as Record<string, unknown>;
365
+ expect(genConfig['responseMimeType']).toBe('application/json');
366
+ });
367
+
368
+ test('handles multiple images with structured output', async () => {
369
+ const getBody = mockFetchAndCapture({
370
+ candidates: [{
371
+ content: {
372
+ parts: [{ text: '{"difference": "They are different images"}' }],
373
+ role: 'model',
374
+ },
375
+ index: 0,
376
+ }],
377
+ });
378
+ const client = createClient();
379
+
380
+ const messages: LLMChatMessage[] = [{
381
+ role: 'user',
382
+ content: [
383
+ { type: 'text', text: 'Compare these' },
384
+ { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,IMG1' } },
385
+ { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,IMG2' } },
386
+ ],
387
+ }];
388
+
389
+ const options: ChatOptions = {
390
+ schema: fromZod(z.object({
391
+ difference: z.string(),
392
+ })),
393
+ };
394
+
395
+ await client.chat(messages, options);
396
+
397
+ const body = getBody()!;
398
+ const contents = body['contents'] as Array<Record<string, unknown>>;
399
+ const parts = contents[0]!['parts'] as Array<Record<string, unknown>>;
400
+
401
+ // Text + 2 images
402
+ expect(parts).toHaveLength(3);
403
+ expect(parts[0]!['text']).toBe('Compare these');
404
+ expect(parts[1]!['inlineData']!['data']).toBe('IMG1');
405
+ expect(parts[2]!['inlineData']!['data']).toBe('IMG2');
406
+ });
407
+ });
408
+
409
+ // ========================================================================
410
+ // Unified thinking flag -> thinkingConfig
411
+ // ========================================================================
412
+
413
+ describe('thinking flag', () => {
414
+ test('maps thinking:true (gemini-2.5) to thinkingBudget -1 (dynamic) + includeThoughts', async () => {
415
+ const getBody = mockFetchAndCapture();
416
+ const client = createClient({ thinking: true }); // default model gemini-1.5-flash -> budget path
417
+ await client.chat([{ role: 'user', content: 'hi' }]);
418
+ const genConfig = getBody()!['generationConfig'] as Record<string, unknown>;
419
+ expect(genConfig['thinkingConfig']).toEqual({ thinkingBudget: -1, includeThoughts: true });
420
+ });
421
+
422
+ test('maps a level (gemini-2.5) to the budget map + includeThoughts', async () => {
423
+ const getBody = mockFetchAndCapture();
424
+ const client = createClient({ model: 'gemini-2.5-flash', thinking: 'high' });
425
+ await client.chat([{ role: 'user', content: 'hi' }]);
426
+ const genConfig = getBody()!['generationConfig'] as Record<string, unknown>;
427
+ expect(genConfig['thinkingConfig']).toEqual({ thinkingBudget: 24576, includeThoughts: true });
428
+ });
429
+
430
+ test('maps a level (gemini-3) to thinkingLevel + includeThoughts', async () => {
431
+ const getBody = mockFetchAndCapture();
432
+ const client = createClient({ model: 'gemini-3.5-flash', thinking: 'high' });
433
+ await client.chat([{ role: 'user', content: 'hi' }]);
434
+ const genConfig = getBody()!['generationConfig'] as Record<string, unknown>;
435
+ expect(genConfig['thinkingConfig']).toEqual({ thinkingLevel: 'HIGH', includeThoughts: true });
436
+ });
437
+
438
+ test('maps thinking:false (gemini-3) to thinkingLevel MINIMAL', async () => {
439
+ const getBody = mockFetchAndCapture();
440
+ const client = createClient({ model: 'gemini-3.5-flash' });
441
+ await client.chat([{ role: 'user', content: 'hi' }], { thinking: false });
442
+ const genConfig = getBody()!['generationConfig'] as Record<string, unknown>;
443
+ expect(genConfig['thinkingConfig']).toEqual({ thinkingLevel: 'MINIMAL' });
444
+ });
445
+
446
+ test('maps thinking:false to thinkingConfig.thinkingBudget 0 (disabled)', async () => {
447
+ const getBody = mockFetchAndCapture();
448
+ const client = createClient();
449
+ await client.chat([{ role: 'user', content: 'hi' }], { thinking: false });
450
+ const genConfig = getBody()!['generationConfig'] as Record<string, unknown>;
451
+ expect(genConfig['thinkingConfig']).toEqual({ thinkingBudget: 0 });
452
+ });
453
+
454
+ test('omits thinkingConfig when thinking is not set', async () => {
455
+ const getBody = mockFetchAndCapture();
456
+ const client = createClient();
457
+ await client.chat([{ role: 'user', content: 'hi' }]);
458
+ const genConfig = getBody()!['generationConfig'] as Record<string, unknown>;
459
+ expect(genConfig['thinkingConfig']).toBeUndefined();
460
+ });
461
+
462
+ test('per-call thinking overrides client config', async () => {
463
+ const getBody = mockFetchAndCapture();
464
+ const client = createClient({ thinking: true });
465
+ await client.chat([{ role: 'user', content: 'hi' }], { thinking: false });
466
+ const genConfig = getBody()!['generationConfig'] as Record<string, unknown>;
467
+ expect(genConfig['thinkingConfig']).toEqual({ thinkingBudget: 0 });
468
+ });
469
+ });
470
+
471
+ // ========================================================================
472
+ // Deep Research (interactions API)
473
+ // ========================================================================
474
+
475
+ describe('deep research', () => {
476
+ test('serializes tools as {type}, posts background:true, extracts report from steps', async () => {
477
+ const getBody = mockFetchAndCapture({
478
+ id: 'interaction-1',
479
+ status: 'completed',
480
+ steps: [{ type: 'answer', content: [{ text: 'final report text' }] }],
481
+ });
482
+ const client = createClient();
483
+
484
+ const r = await client.deepResearch('research X', { tools: ['google_search', 'url_context'] });
485
+
486
+ const body = getBody()!;
487
+ expect(body['background']).toBe(true);
488
+ expect(body['tools']).toEqual([{ type: 'google_search' }, { type: 'url_context' }]);
489
+ expect(r.status).toBe('completed');
490
+ expect(r.report).toBe('final report text'); // assembled from steps[].content[].text
491
+ });
492
+ });
493
+
494
+ // ========================================================================
495
+ // VAL-PROVIDER-GOOGLE-004: Gemini 3.x thoughtSignature Preservation
496
+ // ========================================================================
497
+
498
+ describe('thoughtSignature preservation', () => {
499
+ test('preserves thoughtSignature in assistant message tool_calls', async () => {
500
+ mockFetchAndCapture();
501
+ const client = createClient();
502
+
503
+ // First, send a message with a tool call that has thoughtSignature
504
+ // This simulates a multi-turn conversation with Gemini 3.x
505
+ const messages: LLMChatMessage[] = [
506
+ { role: 'user', content: 'What is the weather?' },
507
+ {
508
+ role: 'assistant',
509
+ content: '',
510
+ tool_calls: [{
511
+ id: 'call-123',
512
+ type: 'function',
513
+ function: { name: 'get_weather', arguments: '{"location": "NYC"}' },
514
+ thoughtSignature: 'encrypted-thought-data-here',
515
+ }],
516
+ },
517
+ {
518
+ role: 'tool',
519
+ tool_call_id: 'call-123',
520
+ content: '{"temp": 72, "condition": "sunny"}',
521
+ },
522
+ ];
523
+
524
+ await client.chat(messages);
525
+
526
+ // The tool call with thoughtSignature should still be preserved
527
+ // This is handled in convertFunctionCallToToolCall
528
+ // Google's format expects thoughtSignature on the functionCall part
529
+ });
530
+
531
+ test('converts tool calls with thoughtSignature to Google format', async () => {
532
+ const getBody = mockFetchAndCapture();
533
+ const client = createClient();
534
+
535
+ const messages: LLMChatMessage[] = [
536
+ { role: 'user', content: 'Test' },
537
+ {
538
+ role: 'assistant',
539
+ content: '',
540
+ tool_calls: [{
541
+ id: 'call-123',
542
+ type: 'function',
543
+ function: { name: 'test_func', arguments: '{"arg": "value"}' },
544
+ thoughtSignature: 'sig-123',
545
+ }],
546
+ },
547
+ ];
548
+
549
+ await client.chat(messages);
550
+
551
+ const body = getBody()!;
552
+ const contents = body['contents'] as Array<Record<string, unknown>>;
553
+
554
+ // Find the model message
555
+ const modelContent = contents.find(c => c['role'] === 'model');
556
+ expect(modelContent).toBeDefined();
557
+
558
+ const parts = (modelContent!['parts'] as Array<Record<string, unknown>>).filter(
559
+ (p: Record<string, unknown>) => p['functionCall']
560
+ );
561
+ expect(parts).toHaveLength(1);
562
+
563
+ const functionCall = parts[0]!['functionCall'] as Record<string, unknown>;
564
+ expect(functionCall['name']).toBe('test_func');
565
+ expect(functionCall['args']).toEqual({ arg: 'value' });
566
+
567
+ // Check that thoughtSignature is echoed from the tool call
568
+ expect(parts[0]!['thoughtSignature']).toBe('sig-123');
569
+ });
570
+
571
+ test('echoes thoughtSignature in response tool calls', async () => {
572
+ const responseWithToolCall = {
573
+ candidates: [{
574
+ content: {
575
+ parts: [{
576
+ functionCall: {
577
+ name: 'get_weather',
578
+ args: { location: 'NYC' },
579
+ },
580
+ thoughtSignature: 'response-sig-123',
581
+ }],
582
+ role: 'model',
583
+ },
584
+ index: 0,
585
+ }],
586
+ };
587
+
588
+ mockFetchAndCapture(responseWithToolCall);
589
+ const client = createClient();
590
+
591
+ const result = await client.chat([
592
+ { role: 'user', content: 'Weather?' },
593
+ ]);
594
+
595
+ expect(result.message.tool_calls).toBeDefined();
596
+ expect(result.message.tool_calls).toHaveLength(1);
597
+ expect(result.message.tool_calls![0]!.function.name).toBe('get_weather');
598
+ expect(result.message.tool_calls![0]!.thoughtSignature).toBe('response-sig-123');
599
+ });
600
+ });
601
+
602
+ // ========================================================================
603
+ // Additional Features: System Instructions, Tools
604
+ // ========================================================================
605
+
606
+ describe('structured output with system instructions', () => {
607
+ test('includes system instruction with structured output', async () => {
608
+ const getBody = mockFetchAndCapture({
609
+ candidates: [{
610
+ content: {
611
+ parts: [{ text: '{"result": "test output"}' }],
612
+ role: 'model',
613
+ },
614
+ index: 0,
615
+ }],
616
+ });
617
+ const client = createClient();
618
+
619
+ const options: ChatOptions = {
620
+ schema: fromZod(z.object({ result: z.string() })),
621
+ };
622
+
623
+ await client.chat([
624
+ { role: 'system', content: 'Always respond in JSON format.' },
625
+ { role: 'user', content: 'Generate data' },
626
+ ], options);
627
+
628
+ const body = getBody()!;
629
+ expect(body['systemInstruction']).toBeDefined();
630
+ const sysInst = body['systemInstruction'] as Record<string, unknown>;
631
+ expect(sysInst['parts']).toEqual([{ text: 'Always respond in JSON format.' }]);
632
+
633
+ // Also verify structured output is still set
634
+ const genConfig = body['generationConfig'] as Record<string, unknown>;
635
+ expect(genConfig['responseMimeType']).toBe('application/json');
636
+ });
637
+ });
638
+
639
+ describe('structured output with parameters', () => {
640
+ test('includes temperature and maxTokens alongside responseMimeType', async () => {
641
+ const getBody = mockFetchAndCapture();
642
+ const client = createClient();
643
+
644
+ const options: ChatOptions = {
645
+ schema: fromZod(z.object({ name: z.string() })),
646
+ temperature: 0.7,
647
+ maxTokens: 100,
648
+ };
649
+
650
+ await client.chat([
651
+ { role: 'user', content: 'Generate' },
652
+ ], options);
653
+
654
+ const body = getBody()!;
655
+ const genConfig = body['generationConfig'] as Record<string, unknown>;
656
+
657
+ expect(genConfig['responseMimeType']).toBe('application/json');
658
+ expect(genConfig['responseSchema']).toBeDefined();
659
+ expect(genConfig['temperature']).toBe(0.7);
660
+ expect(genConfig['maxOutputTokens']).toBe(100);
661
+ });
662
+ });
663
+
664
+ // ========================================================================
665
+ // Vertex AI Support
666
+ // ========================================================================
667
+
668
+ describe('Vertex AI structured output', () => {
669
+ test('builds correct URL for Vertex AI', async () => {
670
+ let capturedUrl = '';
671
+
672
+ globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
673
+ capturedUrl = typeof input === 'string' ? input : input.toString();
674
+ return new Response(JSON.stringify({
675
+ candidates: [{
676
+ content: {
677
+ parts: [{ text: '{"text": "test"}' }],
678
+ role: 'model',
679
+ },
680
+ index: 0,
681
+ }],
682
+ }), {
683
+ status: 200,
684
+ headers: { 'Content-Type': 'application/json' },
685
+ });
686
+ }) as typeof fetch;
687
+
688
+ const client = new GoogleClient({
689
+ model: 'gemini-1.5-pro',
690
+ apiType: AIModelApiType.Vertex,
691
+ region: 'us-central1',
692
+ apiKey: 'vertex-token',
693
+ });
694
+
695
+ const options: ChatOptions = {
696
+ schema: fromZod(z.object({ text: z.string() })),
697
+ };
698
+
699
+ await client.chat([{ role: 'user', content: 'Test' }], options);
700
+
701
+ // Vertex AI URL format
702
+ expect(capturedUrl).toContain('aiplatform.googleapis.com');
703
+ expect(capturedUrl).toContain('us-central1');
704
+ expect(capturedUrl).toContain(':generateContent');
705
+ });
706
+
707
+ test('uses Authorization header for Vertex AI', async () => {
708
+ let capturedHeaders: Record<string, string> = {};
709
+
710
+ globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
711
+ capturedHeaders = init?.headers as Record<string, string> || {};
712
+ return new Response(JSON.stringify({
713
+ candidates: [{
714
+ content: {
715
+ parts: [{ text: '{"text": "test"}' }],
716
+ role: 'model',
717
+ },
718
+ index: 0,
719
+ }],
720
+ }), {
721
+ status: 200,
722
+ headers: { 'Content-Type': 'application/json' },
723
+ });
724
+ }) as typeof fetch;
725
+
726
+ const client = new GoogleClient({
727
+ model: 'gemini-1.5-pro',
728
+ apiType: AIModelApiType.Vertex,
729
+ region: 'us-central1',
730
+ apiKey: 'vertex-token',
731
+ });
732
+
733
+ const options: ChatOptions = {
734
+ schema: fromZod(z.object({ text: z.string() })),
735
+ };
736
+
737
+ await client.chat([{ role: 'user', content: 'Test' }], options);
738
+
739
+ // Vertex uses Bearer token, not query param
740
+ expect(capturedHeaders['Authorization']).toBe('Bearer vertex-token');
741
+ // URL should NOT have ?key= in it
742
+ expect(capturedHeaders['Content-Type']).toBe('application/json');
743
+ });
744
+ });
745
+
746
+ // ========================================================================
747
+ // Edge cases
748
+ // ========================================================================
749
+
750
+ describe('edge cases', () => {
751
+ test('handles missing or empty response content gracefully', async () => {
752
+ mockFetchAndCapture({
753
+ ...GOOGLE_RESPONSE,
754
+ candidates: [{
755
+ content: {
756
+ parts: [],
757
+ role: 'model',
758
+ },
759
+ finishReason: 'STOP',
760
+ index: 0,
761
+ }],
762
+ });
763
+ const client = createClient();
764
+
765
+ const response = await client.chat([{ role: 'user', content: 'Test' }]);
766
+
767
+ expect(response.message.content).toBe('');
768
+ expect(response.message.tool_calls).toBeUndefined();
769
+ });
770
+
771
+ test('normalizes missing function call names and arguments in responses', async () => {
772
+ mockFetchAndCapture({
773
+ ...GOOGLE_RESPONSE,
774
+ candidates: [{
775
+ content: {
776
+ parts: [{
777
+ functionCall: {
778
+ name: 'get_weather',
779
+ },
780
+ }, {
781
+ functionCall: {},
782
+ }],
783
+ role: 'model',
784
+ },
785
+ finishReason: 'STOP',
786
+ index: 0,
787
+ }],
788
+ });
789
+ const client = createClient();
790
+
791
+ const response = await client.chat([{ role: 'user', content: 'Use tools' }]);
792
+
793
+ expect(response.message.tool_calls).toHaveLength(2);
794
+ expect(response.message.tool_calls![0]!.id).toStartWith('call_');
795
+ expect(response.message.tool_calls![0]!.function.name).toBe('get_weather');
796
+ expect(response.message.tool_calls![0]!.function.arguments).toBe('{}');
797
+ expect(response.message.tool_calls![1]!.function.name).toBe('');
798
+ expect(response.message.tool_calls![1]!.function.arguments).toBe('{}');
799
+ });
800
+
801
+ test('converts empty historical tool call arguments to Google args objects', async () => {
802
+ const getBody = mockFetchAndCapture();
803
+ const client = createClient();
804
+
805
+ await client.chat([
806
+ { role: 'user', content: 'Test' },
807
+ {
808
+ role: 'assistant',
809
+ content: '',
810
+ tool_calls: [{
811
+ id: 'call-123',
812
+ type: 'function',
813
+ function: { name: 'get_weather', arguments: '' },
814
+ }],
815
+ },
816
+ ]);
817
+
818
+ const body = getBody()!;
819
+ const contents = body['contents'] as Array<Record<string, unknown>>;
820
+ const modelContent = contents.find(c => c['role'] === 'model');
821
+ const parts = modelContent!['parts'] as Array<Record<string, unknown>>;
822
+ const functionCall = parts[0]!['functionCall'] as Record<string, unknown>;
823
+
824
+ expect(functionCall['name']).toBe('get_weather');
825
+ expect(functionCall['args']).toEqual({});
826
+ });
827
+
828
+ test('surfaces HTTP server errors', async () => {
829
+ mockFetchAndCapture({
830
+ error: {
831
+ message: 'Internal server error',
832
+ code: 500,
833
+ },
834
+ }, 500);
835
+ const client = createClient();
836
+
837
+ await expect(client.chat([{ role: 'user', content: 'Hello' }]))
838
+ .rejects.toThrow('Internal server error');
839
+ });
840
+ });
841
+ });