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.
- package/dist/ai-model.d.ts +20 -22
- package/dist/ai-model.d.ts.map +1 -1
- package/dist/ai-model.js +26 -23
- package/dist/ai-model.js.map +1 -1
- package/dist/client.d.ts +5 -5
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +17 -9
- package/dist/client.js.map +1 -1
- package/dist/http.d.ts +2 -0
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +1 -0
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/interfaces.d.ts +49 -11
- package/dist/interfaces.d.ts.map +1 -1
- package/dist/interfaces.js +14 -0
- package/dist/interfaces.js.map +1 -1
- package/dist/providers/anthropic.d.ts +56 -0
- package/dist/providers/anthropic.d.ts.map +1 -0
- package/dist/providers/anthropic.js +524 -0
- package/dist/providers/anthropic.js.map +1 -0
- package/dist/providers/google.d.ts +5 -0
- package/dist/providers/google.d.ts.map +1 -1
- package/dist/providers/google.js +64 -8
- package/dist/providers/google.js.map +1 -1
- package/dist/providers/index.d.ts +1 -0
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +1 -0
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/ollama.d.ts.map +1 -1
- package/dist/providers/ollama.js +38 -11
- package/dist/providers/ollama.js.map +1 -1
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +9 -7
- package/dist/providers/openai.js.map +1 -1
- package/dist/router.d.ts +13 -33
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +33 -57
- package/dist/router.js.map +1 -1
- package/dist/stream-decoder.d.ts +29 -2
- package/dist/stream-decoder.d.ts.map +1 -1
- package/dist/stream-decoder.js +39 -11
- package/dist/stream-decoder.js.map +1 -1
- package/dist/structured-output.d.ts +107 -181
- package/dist/structured-output.d.ts.map +1 -1
- package/dist/structured-output.js +137 -192
- package/dist/structured-output.js.map +1 -1
- package/dist/zod-adapter.d.ts +44 -0
- package/dist/zod-adapter.d.ts.map +1 -0
- package/dist/zod-adapter.js +61 -0
- package/dist/zod-adapter.js.map +1 -0
- package/package.json +9 -1
- package/src/ai-model.ts +350 -0
- package/src/auditor.ts +213 -0
- package/src/client.ts +402 -0
- package/src/debug/debug-google-streaming.ts +97 -0
- package/src/debug/debug-tool-execution.ts +86 -0
- package/src/debug/test-lmstudio-tools.ts +155 -0
- package/src/demos/README.md +47 -0
- package/src/demos/basic/universal-llm-examples.ts +161 -0
- package/src/demos/mcp/astrid-memory-demo.ts +295 -0
- package/src/demos/mcp/astrid-persona-memory.ts +357 -0
- package/src/demos/mcp/mcp-mongodb-demo.ts +275 -0
- package/src/demos/mcp/simple-astrid-memory.ts +148 -0
- package/src/demos/mcp/simple-mcp-demo.ts +68 -0
- package/src/demos/mcp/working-mcp-demo.ts +62 -0
- package/src/demos/model-alias-demo.ts +0 -0
- package/src/demos/tools/RAG_MEMORY_INTEGRATION.md +267 -0
- package/src/demos/tools/astrid-memory-demo.ts +270 -0
- package/src/demos/tools/astrid-production-memory-clean.ts +785 -0
- package/src/demos/tools/astrid-production-memory.ts +558 -0
- package/src/demos/tools/basic-translation-test.ts +66 -0
- package/src/demos/tools/chromadb-similarity-tuning.ts +390 -0
- package/src/demos/tools/clean-multilingual-conversation.ts +209 -0
- package/src/demos/tools/clean-translation-test.ts +119 -0
- package/src/demos/tools/clean-universal-multilingual-test.ts +131 -0
- package/src/demos/tools/complete-rag-demo.ts +369 -0
- package/src/demos/tools/complete-tool-demo.ts +132 -0
- package/src/demos/tools/demo-tool-calling.ts +124 -0
- package/src/demos/tools/dynamic-language-switching-test.ts +251 -0
- package/src/demos/tools/hybrid-thinking-test.ts +154 -0
- package/src/demos/tools/memory-integration-test.ts +420 -0
- package/src/demos/tools/multilingual-memory-system.ts +802 -0
- package/src/demos/tools/ondemand-translation-demo.ts +655 -0
- package/src/demos/tools/production-tool-demo.ts +245 -0
- package/src/demos/tools/revolutionary-multilingual-test.ts +151 -0
- package/src/demos/tools/rigorous-language-analysis.ts +218 -0
- package/src/demos/tools/test-universal-memory-system.ts +126 -0
- package/src/demos/tools/translation-integration-guide.ts +346 -0
- package/src/demos/tools/universal-memory-system.ts +560 -0
- package/src/http.ts +247 -0
- package/src/index.ts +161 -0
- package/src/interfaces.ts +657 -0
- package/src/mcp.ts +345 -0
- package/src/providers/anthropic.ts +762 -0
- package/src/providers/google.ts +620 -0
- package/src/providers/index.ts +8 -0
- package/src/providers/ollama.ts +469 -0
- package/src/providers/openai.ts +392 -0
- package/src/router.ts +780 -0
- package/src/stream-decoder.ts +361 -0
- package/src/structured-output.ts +759 -0
- package/src/test-scripts/test-advanced-tools.ts +310 -0
- package/src/test-scripts/test-google-streaming-enhanced.ts +147 -0
- package/src/test-scripts/test-google-streaming.ts +63 -0
- package/src/test-scripts/test-google-system-prompt-comprehensive.ts +189 -0
- package/src/test-scripts/test-mcp-config.ts +28 -0
- package/src/test-scripts/test-mcp-connection.ts +29 -0
- package/src/test-scripts/test-system-message-positions.ts +163 -0
- package/src/test-scripts/test-system-prompt-improvement-demo.ts +83 -0
- package/src/test-scripts/test-tool-calling.ts +231 -0
- package/src/tests/ai-model.test.ts +1614 -0
- package/src/tests/auditor.test.ts +224 -0
- package/src/tests/http.test.ts +200 -0
- package/src/tests/interfaces.test.ts +117 -0
- package/src/tests/providers/google.test.ts +660 -0
- package/src/tests/providers/ollama.test.ts +954 -0
- package/src/tests/providers/openai.test.ts +1122 -0
- package/src/tests/router.test.ts +254 -0
- package/src/tests/stream-decoder.test.ts +179 -0
- package/src/tests/structured-output.test.ts +1450 -0
- package/src/tests/tools.test.ts +175 -0
- package/src/tools.ts +246 -0
- 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
|
+
});
|