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,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 = 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
+ });