universal-llm-client 4.5.0 → 4.5.1

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 (174) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +2 -0
  3. package/dist/ai-model.d.ts +0 -1
  4. package/dist/ai-model.js +0 -1
  5. package/dist/auditor.d.ts +0 -1
  6. package/dist/auditor.js +0 -1
  7. package/dist/client.d.ts +0 -1
  8. package/dist/client.js +0 -1
  9. package/dist/gemma-channel.d.ts +0 -1
  10. package/dist/gemma-channel.js +0 -1
  11. package/dist/gemma-diffusion.d.ts +0 -1
  12. package/dist/gemma-diffusion.js +0 -1
  13. package/dist/http.d.ts +0 -1
  14. package/dist/http.js +0 -1
  15. package/dist/index.d.ts +0 -1
  16. package/dist/index.js +0 -1
  17. package/dist/interfaces.d.ts +0 -1
  18. package/dist/interfaces.js +0 -1
  19. package/dist/mcp.d.ts +0 -1
  20. package/dist/mcp.js +0 -1
  21. package/dist/providers/anthropic.d.ts +0 -1
  22. package/dist/providers/anthropic.js +0 -1
  23. package/dist/providers/google.d.ts +0 -1
  24. package/dist/providers/google.js +0 -1
  25. package/dist/providers/index.d.ts +0 -1
  26. package/dist/providers/index.js +0 -1
  27. package/dist/providers/ollama.d.ts +0 -1
  28. package/dist/providers/ollama.js +0 -1
  29. package/dist/providers/openai.d.ts +2 -1
  30. package/dist/providers/openai.js +303 -74
  31. package/dist/router.d.ts +0 -1
  32. package/dist/router.js +0 -1
  33. package/dist/stream-decoder.d.ts +0 -1
  34. package/dist/stream-decoder.js +0 -1
  35. package/dist/structured-output.d.ts +0 -1
  36. package/dist/structured-output.js +0 -1
  37. package/dist/thinking.d.ts +0 -1
  38. package/dist/thinking.js +0 -1
  39. package/dist/tools.d.ts +0 -1
  40. package/dist/tools.js +0 -1
  41. package/dist/zod-adapter.d.ts +0 -1
  42. package/dist/zod-adapter.js +0 -1
  43. package/package.json +1 -2
  44. package/dist/ai-model.d.ts.map +0 -1
  45. package/dist/ai-model.js.map +0 -1
  46. package/dist/auditor.d.ts.map +0 -1
  47. package/dist/auditor.js.map +0 -1
  48. package/dist/client.d.ts.map +0 -1
  49. package/dist/client.js.map +0 -1
  50. package/dist/gemma-channel.d.ts.map +0 -1
  51. package/dist/gemma-channel.js.map +0 -1
  52. package/dist/gemma-diffusion.d.ts.map +0 -1
  53. package/dist/gemma-diffusion.js.map +0 -1
  54. package/dist/http.d.ts.map +0 -1
  55. package/dist/http.js.map +0 -1
  56. package/dist/index.d.ts.map +0 -1
  57. package/dist/index.js.map +0 -1
  58. package/dist/interfaces.d.ts.map +0 -1
  59. package/dist/interfaces.js.map +0 -1
  60. package/dist/mcp.d.ts.map +0 -1
  61. package/dist/mcp.js.map +0 -1
  62. package/dist/providers/anthropic.d.ts.map +0 -1
  63. package/dist/providers/anthropic.js.map +0 -1
  64. package/dist/providers/google.d.ts.map +0 -1
  65. package/dist/providers/google.js.map +0 -1
  66. package/dist/providers/index.d.ts.map +0 -1
  67. package/dist/providers/index.js.map +0 -1
  68. package/dist/providers/ollama.d.ts.map +0 -1
  69. package/dist/providers/ollama.js.map +0 -1
  70. package/dist/providers/openai.d.ts.map +0 -1
  71. package/dist/providers/openai.js.map +0 -1
  72. package/dist/router.d.ts.map +0 -1
  73. package/dist/router.js.map +0 -1
  74. package/dist/stream-decoder.d.ts.map +0 -1
  75. package/dist/stream-decoder.js.map +0 -1
  76. package/dist/structured-output.d.ts.map +0 -1
  77. package/dist/structured-output.js.map +0 -1
  78. package/dist/thinking.d.ts.map +0 -1
  79. package/dist/thinking.js.map +0 -1
  80. package/dist/tools.d.ts.map +0 -1
  81. package/dist/tools.js.map +0 -1
  82. package/dist/zod-adapter.d.ts.map +0 -1
  83. package/dist/zod-adapter.js.map +0 -1
  84. package/src/ai-model.ts +0 -400
  85. package/src/auditor.ts +0 -213
  86. package/src/client.ts +0 -402
  87. package/src/debug/debug-google-streaming.ts +0 -97
  88. package/src/debug/debug-tool-execution.ts +0 -86
  89. package/src/debug/test-lmstudio-tools.ts +0 -155
  90. package/src/demos/README.md +0 -47
  91. package/src/demos/basic/universal-llm-examples.ts +0 -161
  92. package/src/demos/diffusion-gemma/.env +0 -29
  93. package/src/demos/diffusion-gemma/.env.example +0 -27
  94. package/src/demos/diffusion-gemma/CLAUDE.md +0 -95
  95. package/src/demos/diffusion-gemma/README.md +0 -59
  96. package/src/demos/diffusion-gemma/canvas.ts +0 -1606
  97. package/src/demos/diffusion-gemma/docker-compose.yml +0 -29
  98. package/src/demos/diffusion-gemma/probe-stream.ts +0 -51
  99. package/src/demos/diffusion-gemma/probe-tools.ts +0 -55
  100. package/src/demos/diffusion-gemma/server.ts +0 -1205
  101. package/src/demos/diffusion-gemma/start-vllm.sh +0 -98
  102. package/src/demos/mcp/astrid-memory-demo.ts +0 -295
  103. package/src/demos/mcp/astrid-persona-memory.ts +0 -357
  104. package/src/demos/mcp/mcp-mongodb-demo.ts +0 -275
  105. package/src/demos/mcp/simple-astrid-memory.ts +0 -148
  106. package/src/demos/mcp/simple-mcp-demo.ts +0 -68
  107. package/src/demos/mcp/working-mcp-demo.ts +0 -62
  108. package/src/demos/model-alias-demo.ts +0 -0
  109. package/src/demos/tools/RAG_MEMORY_INTEGRATION.md +0 -267
  110. package/src/demos/tools/astrid-memory-demo.ts +0 -270
  111. package/src/demos/tools/astrid-production-memory-clean.ts +0 -785
  112. package/src/demos/tools/astrid-production-memory.ts +0 -558
  113. package/src/demos/tools/basic-translation-test.ts +0 -66
  114. package/src/demos/tools/chromadb-similarity-tuning.ts +0 -390
  115. package/src/demos/tools/clean-multilingual-conversation.ts +0 -209
  116. package/src/demos/tools/clean-translation-test.ts +0 -119
  117. package/src/demos/tools/clean-universal-multilingual-test.ts +0 -131
  118. package/src/demos/tools/complete-rag-demo.ts +0 -369
  119. package/src/demos/tools/complete-tool-demo.ts +0 -132
  120. package/src/demos/tools/demo-tool-calling.ts +0 -124
  121. package/src/demos/tools/dynamic-language-switching-test.ts +0 -251
  122. package/src/demos/tools/hybrid-thinking-test.ts +0 -154
  123. package/src/demos/tools/memory-integration-test.ts +0 -420
  124. package/src/demos/tools/multilingual-memory-system.ts +0 -802
  125. package/src/demos/tools/ondemand-translation-demo.ts +0 -655
  126. package/src/demos/tools/production-tool-demo.ts +0 -245
  127. package/src/demos/tools/revolutionary-multilingual-test.ts +0 -151
  128. package/src/demos/tools/rigorous-language-analysis.ts +0 -218
  129. package/src/demos/tools/test-universal-memory-system.ts +0 -126
  130. package/src/demos/tools/translation-integration-guide.ts +0 -346
  131. package/src/demos/tools/universal-memory-system.ts +0 -560
  132. package/src/gemma-channel.ts +0 -47
  133. package/src/gemma-diffusion.ts +0 -167
  134. package/src/http.ts +0 -261
  135. package/src/index.ts +0 -180
  136. package/src/interfaces.ts +0 -843
  137. package/src/mcp.ts +0 -345
  138. package/src/providers/anthropic.ts +0 -796
  139. package/src/providers/google.ts +0 -840
  140. package/src/providers/index.ts +0 -8
  141. package/src/providers/ollama.ts +0 -503
  142. package/src/providers/openai.ts +0 -587
  143. package/src/router.ts +0 -785
  144. package/src/stream-decoder.ts +0 -535
  145. package/src/structured-output.ts +0 -759
  146. package/src/test-scripts/test-advanced-tools.ts +0 -310
  147. package/src/test-scripts/test-google-deep-research.ts +0 -33
  148. package/src/test-scripts/test-google-streaming-enhanced.ts +0 -147
  149. package/src/test-scripts/test-google-streaming.ts +0 -63
  150. package/src/test-scripts/test-google-system-prompt-comprehensive.ts +0 -189
  151. package/src/test-scripts/test-google-thinking.ts +0 -46
  152. package/src/test-scripts/test-mcp-config.ts +0 -28
  153. package/src/test-scripts/test-mcp-connection.ts +0 -29
  154. package/src/test-scripts/test-system-message-positions.ts +0 -163
  155. package/src/test-scripts/test-system-prompt-improvement-demo.ts +0 -83
  156. package/src/test-scripts/test-tool-calling.ts +0 -231
  157. package/src/test-scripts/test-vllm-qwen36.ts +0 -256
  158. package/src/tests/ai-model.test.ts +0 -1614
  159. package/src/tests/auditor.test.ts +0 -224
  160. package/src/tests/gemma-diffusion.test.ts +0 -115
  161. package/src/tests/http.test.ts +0 -200
  162. package/src/tests/interfaces.test.ts +0 -117
  163. package/src/tests/providers/anthropic.test.ts +0 -118
  164. package/src/tests/providers/google.test.ts +0 -841
  165. package/src/tests/providers/ollama.test.ts +0 -1034
  166. package/src/tests/providers/openai.test.ts +0 -1511
  167. package/src/tests/router.test.ts +0 -254
  168. package/src/tests/stream-decoder.test.ts +0 -263
  169. package/src/tests/structured-output.test.ts +0 -1450
  170. package/src/tests/thinking.test.ts +0 -65
  171. package/src/tests/tools.test.ts +0 -175
  172. package/src/thinking.ts +0 -73
  173. package/src/tools.ts +0 -246
  174. package/src/zod-adapter.ts +0 -72
@@ -1,841 +0,0 @@
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
- });