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,1122 @@
|
|
|
1
|
+
import { fromZod } from '../../zod-adapter.js';
|
|
2
|
+
/**
|
|
3
|
+
* OpenAI-Compatible Provider Structured Output Tests
|
|
4
|
+
*
|
|
5
|
+
* Tests the OpenAICompatibleClient's structured output support (response_format).
|
|
6
|
+
* Validates assertions:
|
|
7
|
+
* - VAL-PROVIDER-OPENAI-001: response_format json_schema Request
|
|
8
|
+
* - VAL-PROVIDER-OPENAI-005: Provider-Specific Schema Limitations
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import { OpenAICompatibleClient } from '../../providers/openai.js';
|
|
14
|
+
import type { LLMClientOptions, ChatOptions } from '../../interfaces.js';
|
|
15
|
+
import { AIModelApiType } from '../../interfaces.js';
|
|
16
|
+
import {
|
|
17
|
+
type StructuredOutputOptions,
|
|
18
|
+
parseStructured,
|
|
19
|
+
} from '../../structured-output.js';
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Helpers
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
function createClient(overrides?: Partial<LLMClientOptions>): OpenAICompatibleClient {
|
|
26
|
+
return new OpenAICompatibleClient({
|
|
27
|
+
model: 'test-model',
|
|
28
|
+
url: 'https://api.openai.com/v1',
|
|
29
|
+
apiType: AIModelApiType.OpenAI,
|
|
30
|
+
...overrides,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const OPENAI_RESPONSE = {
|
|
35
|
+
id: 'test-id',
|
|
36
|
+
object: 'chat.completion',
|
|
37
|
+
created: 1700000000,
|
|
38
|
+
model: 'test-model',
|
|
39
|
+
choices: [{
|
|
40
|
+
index: 0,
|
|
41
|
+
message: {
|
|
42
|
+
role: 'assistant',
|
|
43
|
+
content: '{"name": "Alice", "age": 30}',
|
|
44
|
+
},
|
|
45
|
+
finish_reason: 'stop',
|
|
46
|
+
}],
|
|
47
|
+
usage: {
|
|
48
|
+
prompt_tokens: 10,
|
|
49
|
+
completion_tokens: 20,
|
|
50
|
+
total_tokens: 30,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// Tests
|
|
56
|
+
// ============================================================================
|
|
57
|
+
|
|
58
|
+
describe('OpenAICompatibleClient Structured Output', () => {
|
|
59
|
+
let originalFetch: typeof globalThis.fetch;
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
originalFetch = globalThis.fetch;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
globalThis.fetch = originalFetch;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/** Capture the body sent to OpenAI's /v1/chat/completions */
|
|
70
|
+
function mockFetchAndCapture(response = OPENAI_RESPONSE) {
|
|
71
|
+
let capturedBody: Record<string, unknown> | null = null;
|
|
72
|
+
|
|
73
|
+
globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
|
|
74
|
+
if (init?.body) {
|
|
75
|
+
capturedBody = JSON.parse(init.body as string);
|
|
76
|
+
}
|
|
77
|
+
return new Response(JSON.stringify(response), {
|
|
78
|
+
status: 200,
|
|
79
|
+
headers: { 'Content-Type': 'application/json' },
|
|
80
|
+
});
|
|
81
|
+
}) as typeof fetch;
|
|
82
|
+
|
|
83
|
+
return () => capturedBody;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ========================================================================
|
|
87
|
+
// VAL-PROVIDER-OPENAI-001: response_format json_schema Request
|
|
88
|
+
// ========================================================================
|
|
89
|
+
|
|
90
|
+
describe('response_format json_schema', () => {
|
|
91
|
+
test('includes response_format with json_schema type when schema provided', async () => {
|
|
92
|
+
const getBody = mockFetchAndCapture();
|
|
93
|
+
const client = createClient();
|
|
94
|
+
|
|
95
|
+
const UserSchema = z.object({
|
|
96
|
+
name: z.string(),
|
|
97
|
+
age: z.number(),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const options: ChatOptions = {
|
|
101
|
+
schema: fromZod(UserSchema),
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
await client.chat([
|
|
105
|
+
{ role: 'user', content: 'Generate a user' },
|
|
106
|
+
], options);
|
|
107
|
+
|
|
108
|
+
const body = getBody()!;
|
|
109
|
+
expect(body['response_format']).toBeDefined();
|
|
110
|
+
expect((body['response_format'] as Record<string, unknown>)['type']).toBe('json_schema');
|
|
111
|
+
expect((body['response_format'] as Record<string, unknown>)['json_schema']).toBeDefined();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('includes strict mode when schema provided', async () => {
|
|
115
|
+
const getBody = mockFetchAndCapture();
|
|
116
|
+
const client = createClient();
|
|
117
|
+
|
|
118
|
+
const UserSchema = z.object({
|
|
119
|
+
name: z.string(),
|
|
120
|
+
age: z.number(),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const options: ChatOptions = {
|
|
124
|
+
schema: fromZod(UserSchema),
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
await client.chat([
|
|
128
|
+
{ role: 'user', content: 'Generate a user' },
|
|
129
|
+
], options);
|
|
130
|
+
|
|
131
|
+
const body = getBody()!;
|
|
132
|
+
const responseFormat = body['response_format'] as Record<string, unknown>;
|
|
133
|
+
expect(responseFormat['json_schema']).toBeDefined();
|
|
134
|
+
|
|
135
|
+
const jsonSchema = responseFormat['json_schema'] as Record<string, unknown>;
|
|
136
|
+
// strict should be true for reliable structured output
|
|
137
|
+
expect(jsonSchema['strict']).toBe(true);
|
|
138
|
+
expect(jsonSchema['name']).toBeDefined();
|
|
139
|
+
expect(jsonSchema['schema']).toBeDefined();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('converts Zod schema to JSON Schema in response_format', async () => {
|
|
143
|
+
const getBody = mockFetchAndCapture();
|
|
144
|
+
const client = createClient();
|
|
145
|
+
|
|
146
|
+
const UserSchema = z.object({
|
|
147
|
+
name: z.string(),
|
|
148
|
+
age: z.number().optional(),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const options: ChatOptions = {
|
|
152
|
+
schema: fromZod(UserSchema),
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
await client.chat([
|
|
156
|
+
{ role: 'user', content: 'Generate a user' },
|
|
157
|
+
], options);
|
|
158
|
+
|
|
159
|
+
const body = getBody()!;
|
|
160
|
+
const responseFormat = body['response_format'] as Record<string, unknown>;
|
|
161
|
+
const jsonSchema = responseFormat['json_schema'] as Record<string, unknown>;
|
|
162
|
+
const schema = jsonSchema['schema'] as Record<string, unknown>;
|
|
163
|
+
|
|
164
|
+
expect(schema['type']).toBe('object');
|
|
165
|
+
expect(schema['properties']).toBeDefined();
|
|
166
|
+
expect(schema['properties']!['name']).toEqual({ type: 'string' });
|
|
167
|
+
expect(schema['properties']!['age']).toEqual({ type: 'number' });
|
|
168
|
+
expect(schema['required']).toEqual(['name']);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('uses schema name from options when provided', async () => {
|
|
172
|
+
const getBody = mockFetchAndCapture();
|
|
173
|
+
const client = createClient();
|
|
174
|
+
|
|
175
|
+
const UserSchema = z.object({
|
|
176
|
+
name: z.string(),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const options: ChatOptions = {
|
|
180
|
+
schema: fromZod(UserSchema),
|
|
181
|
+
schemaName: 'CustomUserSchema',
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
await client.chat([
|
|
185
|
+
{ role: 'user', content: 'Generate' },
|
|
186
|
+
], options);
|
|
187
|
+
|
|
188
|
+
const body = getBody()!;
|
|
189
|
+
const responseFormat = body['response_format'] as Record<string, unknown>;
|
|
190
|
+
const jsonSchema = responseFormat['json_schema'] as Record<string, unknown>;
|
|
191
|
+
|
|
192
|
+
expect(jsonSchema['name']).toBe('CustomUserSchema');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('generates default schema name when not provided', async () => {
|
|
196
|
+
const getBody = mockFetchAndCapture();
|
|
197
|
+
const client = createClient();
|
|
198
|
+
|
|
199
|
+
const UserSchema = z.object({
|
|
200
|
+
name: z.string(),
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const options: ChatOptions = {
|
|
204
|
+
schema: fromZod(UserSchema),
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
await client.chat([
|
|
208
|
+
{ role: 'user', content: 'Generate' },
|
|
209
|
+
], options);
|
|
210
|
+
|
|
211
|
+
const body = getBody()!;
|
|
212
|
+
const responseFormat = body['response_format'] as Record<string, unknown>;
|
|
213
|
+
const jsonSchema = responseFormat['json_schema'] as Record<string, unknown>;
|
|
214
|
+
|
|
215
|
+
// Should have a name, either provided or auto-generated
|
|
216
|
+
expect(jsonSchema['name']).toBeDefined();
|
|
217
|
+
expect(typeof jsonSchema['name']).toBe('string');
|
|
218
|
+
expect((jsonSchema['name'] as string).length).toBeGreaterThan(0);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('includes schema description when provided', async () => {
|
|
222
|
+
const getBody = mockFetchAndCapture();
|
|
223
|
+
const client = createClient();
|
|
224
|
+
|
|
225
|
+
const UserSchema = z.object({
|
|
226
|
+
name: z.string(),
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const options: ChatOptions = {
|
|
230
|
+
schema: fromZod(UserSchema),
|
|
231
|
+
schemaDescription: 'A user object with name',
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
await client.chat([
|
|
235
|
+
{ role: 'user', content: 'Generate' },
|
|
236
|
+
], options);
|
|
237
|
+
|
|
238
|
+
const body = getBody()!;
|
|
239
|
+
const responseFormat = body['response_format'] as Record<string, unknown>;
|
|
240
|
+
const jsonSchema = responseFormat['json_schema'] as Record<string, unknown>;
|
|
241
|
+
|
|
242
|
+
expect(jsonSchema['description']).toBe('A user object with name');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('accepts raw JSON Schema instead of Zod schema', async () => {
|
|
246
|
+
// Create mock with custom response for jsonSchema test
|
|
247
|
+
let capturedBody: Record<string, unknown> | null = null;
|
|
248
|
+
|
|
249
|
+
globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
|
|
250
|
+
if (init?.body) {
|
|
251
|
+
capturedBody = JSON.parse(init.body as string);
|
|
252
|
+
}
|
|
253
|
+
return new Response(JSON.stringify({
|
|
254
|
+
id: 'test-id',
|
|
255
|
+
object: 'chat.completion',
|
|
256
|
+
created: 1700000000,
|
|
257
|
+
model: 'test-model',
|
|
258
|
+
choices: [{
|
|
259
|
+
index: 0,
|
|
260
|
+
message: {
|
|
261
|
+
role: 'assistant',
|
|
262
|
+
content: '{"id": "123", "count": 5}',
|
|
263
|
+
},
|
|
264
|
+
finish_reason: 'stop',
|
|
265
|
+
}],
|
|
266
|
+
usage: {
|
|
267
|
+
prompt_tokens: 10,
|
|
268
|
+
completion_tokens: 20,
|
|
269
|
+
total_tokens: 30,
|
|
270
|
+
},
|
|
271
|
+
}), {
|
|
272
|
+
status: 200,
|
|
273
|
+
headers: { 'Content-Type': 'application/json' },
|
|
274
|
+
});
|
|
275
|
+
}) as typeof fetch;
|
|
276
|
+
|
|
277
|
+
const client = createClient();
|
|
278
|
+
|
|
279
|
+
const jsonSchema = {
|
|
280
|
+
type: 'object' as const,
|
|
281
|
+
properties: {
|
|
282
|
+
id: { type: 'string' as const },
|
|
283
|
+
count: { type: 'number' as const },
|
|
284
|
+
},
|
|
285
|
+
required: ['id'],
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const options: ChatOptions = {
|
|
289
|
+
jsonSchema,
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const response = await client.chat([
|
|
293
|
+
{ role: 'user', content: 'Generate' },
|
|
294
|
+
], options);
|
|
295
|
+
|
|
296
|
+
const body = capturedBody!;
|
|
297
|
+
const responseFormat = body['response_format'] as Record<string, unknown>;
|
|
298
|
+
expect(responseFormat).toBeDefined();
|
|
299
|
+
expect(responseFormat['type']).toBe('json_schema');
|
|
300
|
+
|
|
301
|
+
const jsonSchemaReq = responseFormat['json_schema'] as Record<string, unknown>;
|
|
302
|
+
expect(jsonSchemaReq['schema']).toBeDefined();
|
|
303
|
+
// The schema property contains the normalized JSON Schema
|
|
304
|
+
const schema = jsonSchemaReq['schema'] as Record<string, unknown>;
|
|
305
|
+
expect(schema.properties).toBeDefined();
|
|
306
|
+
// Verify response processed successfully (z.unknown() always passes)
|
|
307
|
+
expect(response.message.content).toContain('id');
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// ========================================================================
|
|
312
|
+
// Response Validation
|
|
313
|
+
// ========================================================================
|
|
314
|
+
|
|
315
|
+
describe('response validation', () => {
|
|
316
|
+
test('validates response JSON against schema', async () => {
|
|
317
|
+
mockFetchAndCapture({
|
|
318
|
+
...OPENAI_RESPONSE,
|
|
319
|
+
choices: [{
|
|
320
|
+
index: 0,
|
|
321
|
+
message: {
|
|
322
|
+
role: 'assistant',
|
|
323
|
+
content: '{"name": "Bob", "age": 25}',
|
|
324
|
+
},
|
|
325
|
+
finish_reason: 'stop',
|
|
326
|
+
}],
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const client = createClient();
|
|
330
|
+
|
|
331
|
+
const UserSchema = z.object({
|
|
332
|
+
name: z.string(),
|
|
333
|
+
age: z.number(),
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const options: ChatOptions = {
|
|
337
|
+
schema: fromZod(UserSchema),
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const response = await client.chat([
|
|
341
|
+
{ role: 'user', content: 'Generate a user' },
|
|
342
|
+
], options);
|
|
343
|
+
|
|
344
|
+
// If we got here without error, validation passed
|
|
345
|
+
expect(response.message.content).toBe('{"name": "Bob", "age": 25}');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('provider does NOT validate response (validation is centralized in Router)', async () => {
|
|
349
|
+
mockFetchAndCapture({
|
|
350
|
+
...OPENAI_RESPONSE,
|
|
351
|
+
choices: [{
|
|
352
|
+
index: 0,
|
|
353
|
+
message: {
|
|
354
|
+
role: 'assistant',
|
|
355
|
+
content: 'not valid json at all',
|
|
356
|
+
},
|
|
357
|
+
finish_reason: 'stop',
|
|
358
|
+
}],
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const client = createClient();
|
|
362
|
+
|
|
363
|
+
const UserSchema = z.object({
|
|
364
|
+
name: z.string(),
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
const options: ChatOptions = {
|
|
368
|
+
schema: fromZod(UserSchema),
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
// Provider should NOT throw — validation is done at Router level
|
|
372
|
+
const response = await client.chat([
|
|
373
|
+
{ role: 'user', content: 'Generate' },
|
|
374
|
+
], options);
|
|
375
|
+
expect(response.message.content).toBe('not valid json at all');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test('provider returns raw response even on schema mismatch (Router validates)', async () => {
|
|
379
|
+
mockFetchAndCapture({
|
|
380
|
+
...OPENAI_RESPONSE,
|
|
381
|
+
choices: [{
|
|
382
|
+
index: 0,
|
|
383
|
+
message: {
|
|
384
|
+
role: 'assistant',
|
|
385
|
+
content: '{"name": "Bob", "age": "not a number"}',
|
|
386
|
+
},
|
|
387
|
+
finish_reason: 'stop',
|
|
388
|
+
}],
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
const client = createClient();
|
|
392
|
+
|
|
393
|
+
const UserSchema = z.object({
|
|
394
|
+
name: z.string(),
|
|
395
|
+
age: z.number(),
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const options: ChatOptions = {
|
|
399
|
+
schema: fromZod(UserSchema),
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
// Provider should NOT throw — validation is done at Router level
|
|
403
|
+
const response = await client.chat([
|
|
404
|
+
{ role: 'user', content: 'Generate' },
|
|
405
|
+
], options);
|
|
406
|
+
expect(response.message.content).toBe('{"name": "Bob", "age": "not a number"}');
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test('includes raw output in response when schema provided', async () => {
|
|
410
|
+
const rawOutput = '{"name": 123}';
|
|
411
|
+
mockFetchAndCapture({
|
|
412
|
+
...OPENAI_RESPONSE,
|
|
413
|
+
choices: [{
|
|
414
|
+
index: 0,
|
|
415
|
+
message: {
|
|
416
|
+
role: 'assistant',
|
|
417
|
+
content: rawOutput,
|
|
418
|
+
},
|
|
419
|
+
finish_reason: 'stop',
|
|
420
|
+
}],
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const client = createClient();
|
|
424
|
+
|
|
425
|
+
const UserSchema = z.object({
|
|
426
|
+
name: z.string(),
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const options: ChatOptions = {
|
|
430
|
+
schema: fromZod(UserSchema),
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
// Provider returns raw response — Router handles validation
|
|
434
|
+
const response = await client.chat([
|
|
435
|
+
{ role: 'user', content: 'Generate' },
|
|
436
|
+
], options);
|
|
437
|
+
expect(response.message.content).toBe(rawOutput);
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// ========================================================================
|
|
442
|
+
// VAL-PROVIDER-OPENAI-005: Provider-Specific Schema Limitations (json_object mode)
|
|
443
|
+
// ========================================================================
|
|
444
|
+
|
|
445
|
+
describe('json_object mode (backward compatibility)', () => {
|
|
446
|
+
test('supports response_format type json_object for legacy providers', async () => {
|
|
447
|
+
const getBody = mockFetchAndCapture();
|
|
448
|
+
const client = createClient();
|
|
449
|
+
|
|
450
|
+
const options: ChatOptions = {
|
|
451
|
+
responseFormat: { type: 'json_object' },
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
await client.chat([
|
|
455
|
+
{ role: 'user', content: 'Generate JSON' },
|
|
456
|
+
], options);
|
|
457
|
+
|
|
458
|
+
const body = getBody()!;
|
|
459
|
+
expect(body['response_format']).toBeDefined();
|
|
460
|
+
expect((body['response_format'] as Record<string, unknown>)['type']).toBe('json_object');
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test('json_object mode does not include schema in request', async () => {
|
|
464
|
+
const getBody = mockFetchAndCapture();
|
|
465
|
+
const client = createClient();
|
|
466
|
+
|
|
467
|
+
const options: ChatOptions = {
|
|
468
|
+
responseFormat: { type: 'json_object' },
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
await client.chat([
|
|
472
|
+
{ role: 'user', content: 'Generate JSON' },
|
|
473
|
+
], options);
|
|
474
|
+
|
|
475
|
+
const body = getBody()!;
|
|
476
|
+
const responseFormat = body['response_format'] as Record<string, unknown>;
|
|
477
|
+
|
|
478
|
+
expect(responseFormat['type']).toBe('json_object');
|
|
479
|
+
expect(responseFormat['json_schema']).toBeUndefined();
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test('can provide schema alongside json_object for validation', async () => {
|
|
483
|
+
mockFetchAndCapture({
|
|
484
|
+
...OPENAI_RESPONSE,
|
|
485
|
+
choices: [{
|
|
486
|
+
index: 0,
|
|
487
|
+
message: {
|
|
488
|
+
role: 'assistant',
|
|
489
|
+
content: '{"name": "Alice", "age": 30}',
|
|
490
|
+
},
|
|
491
|
+
finish_reason: 'stop',
|
|
492
|
+
}],
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
const client = createClient();
|
|
496
|
+
|
|
497
|
+
const UserSchema = z.object({
|
|
498
|
+
name: z.string(),
|
|
499
|
+
age: z.number(),
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// Provider that only supports json_object (like older OpenAI)
|
|
503
|
+
const options: ChatOptions = {
|
|
504
|
+
schema: fromZod(UserSchema),
|
|
505
|
+
responseFormat: { type: 'json_object' },
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const response = await client.chat([
|
|
509
|
+
{ role: 'user', content: 'Generate a user' },
|
|
510
|
+
], options);
|
|
511
|
+
|
|
512
|
+
// Validation should still work
|
|
513
|
+
expect(response.message.content).toBe('{"name": "Alice", "age": 30}');
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// ========================================================================
|
|
518
|
+
// Error Handling
|
|
519
|
+
// ========================================================================
|
|
520
|
+
|
|
521
|
+
describe('error handling', () => {
|
|
522
|
+
test('provider returns raw response for null content (Router validates)', async () => {
|
|
523
|
+
mockFetchAndCapture({
|
|
524
|
+
...OPENAI_RESPONSE,
|
|
525
|
+
choices: [{
|
|
526
|
+
index: 0,
|
|
527
|
+
message: {
|
|
528
|
+
role: 'assistant',
|
|
529
|
+
content: null,
|
|
530
|
+
},
|
|
531
|
+
finish_reason: 'stop',
|
|
532
|
+
}],
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
const client = createClient();
|
|
536
|
+
|
|
537
|
+
const UserSchema = z.object({
|
|
538
|
+
name: z.string(),
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
const options: ChatOptions = {
|
|
542
|
+
schema: fromZod(UserSchema),
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
// Provider should NOT throw — returns raw response
|
|
546
|
+
const response = await client.chat([
|
|
547
|
+
{ role: 'user', content: 'Generate' },
|
|
548
|
+
], options);
|
|
549
|
+
expect(response.message.content).toBe('');
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test('provider returns raw response for empty content (Router validates)', async () => {
|
|
553
|
+
mockFetchAndCapture({
|
|
554
|
+
...OPENAI_RESPONSE,
|
|
555
|
+
choices: [{
|
|
556
|
+
index: 0,
|
|
557
|
+
message: {
|
|
558
|
+
role: 'assistant',
|
|
559
|
+
content: '',
|
|
560
|
+
},
|
|
561
|
+
finish_reason: 'stop',
|
|
562
|
+
}],
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
const client = createClient();
|
|
566
|
+
|
|
567
|
+
const UserSchema = z.object({
|
|
568
|
+
name: z.string(),
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
const options: ChatOptions = {
|
|
572
|
+
schema: fromZod(UserSchema),
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
// Provider should NOT throw — returns raw response
|
|
576
|
+
const response = await client.chat([
|
|
577
|
+
{ role: 'user', content: 'Generate' },
|
|
578
|
+
], options);
|
|
579
|
+
expect(response.message.content).toBe('');
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test('provider returns raw response for schema mismatch (Router validates)', async () => {
|
|
583
|
+
mockFetchAndCapture({
|
|
584
|
+
...OPENAI_RESPONSE,
|
|
585
|
+
choices: [{
|
|
586
|
+
index: 0,
|
|
587
|
+
message: {
|
|
588
|
+
role: 'assistant',
|
|
589
|
+
content: '{"name": "test"}', // Missing 'age'
|
|
590
|
+
},
|
|
591
|
+
finish_reason: 'stop',
|
|
592
|
+
}],
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
const client = createClient();
|
|
596
|
+
|
|
597
|
+
const UserSchema = z.object({
|
|
598
|
+
name: z.string(),
|
|
599
|
+
age: z.number(), // required
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
const options: ChatOptions = {
|
|
603
|
+
schema: fromZod(UserSchema),
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
// Provider should NOT throw — returns raw response
|
|
607
|
+
const response = await client.chat([
|
|
608
|
+
{ role: 'user', content: 'Generate' },
|
|
609
|
+
], options);
|
|
610
|
+
expect(response.message.content).toBe('{"name": "test"}');
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
// ========================================================================
|
|
615
|
+
// No schema option (regular chat)
|
|
616
|
+
// ========================================================================
|
|
617
|
+
|
|
618
|
+
describe('regular chat without schema', () => {
|
|
619
|
+
test('does not include response_format when no schema provided', async () => {
|
|
620
|
+
const getBody = mockFetchAndCapture();
|
|
621
|
+
const client = createClient();
|
|
622
|
+
|
|
623
|
+
await client.chat([
|
|
624
|
+
{ role: 'user', content: 'Hello' },
|
|
625
|
+
]);
|
|
626
|
+
|
|
627
|
+
const body = getBody()!;
|
|
628
|
+
expect(body['response_format']).toBeUndefined();
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
test('chat without schema returns raw response', async () => {
|
|
632
|
+
mockFetchAndCapture(OPENAI_RESPONSE);
|
|
633
|
+
const client = createClient();
|
|
634
|
+
|
|
635
|
+
const response = await client.chat([
|
|
636
|
+
{ role: 'user', content: 'Hello' },
|
|
637
|
+
]);
|
|
638
|
+
|
|
639
|
+
expect(response.message.content).toBe('{"name": "Alice", "age": 30}');
|
|
640
|
+
expect(response.message.role).toBe('assistant');
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// ========================================================================
|
|
645
|
+
// Schema with Tools (allowed together)
|
|
646
|
+
// ========================================================================
|
|
647
|
+
|
|
648
|
+
describe('schema with tools', () => {
|
|
649
|
+
test('sends both response_format and tools in the request', async () => {
|
|
650
|
+
const getBody = mockFetchAndCapture();
|
|
651
|
+
const client = createClient();
|
|
652
|
+
|
|
653
|
+
const UserSchema = z.object({
|
|
654
|
+
name: z.string(),
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
const options: ChatOptions = {
|
|
658
|
+
schema: fromZod(UserSchema),
|
|
659
|
+
tools: [{
|
|
660
|
+
type: 'function',
|
|
661
|
+
function: {
|
|
662
|
+
name: 'test_tool',
|
|
663
|
+
description: 'A test tool',
|
|
664
|
+
parameters: {
|
|
665
|
+
type: 'object',
|
|
666
|
+
properties: {},
|
|
667
|
+
},
|
|
668
|
+
},
|
|
669
|
+
}],
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
await client.chat([
|
|
673
|
+
{ role: 'user', content: 'Test' },
|
|
674
|
+
], options);
|
|
675
|
+
|
|
676
|
+
const body = getBody();
|
|
677
|
+
expect(body).not.toBeNull();
|
|
678
|
+
|
|
679
|
+
// Both response_format and tools should be present
|
|
680
|
+
expect(body!.response_format).toBeDefined();
|
|
681
|
+
expect(body!.tools).toBeDefined();
|
|
682
|
+
expect((body!.tools as unknown[]).length).toBe(1);
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
// ========================================================================
|
|
687
|
+
// VAL-PROVIDER-OPENAI-003: Vision with Structured Output
|
|
688
|
+
// ========================================================================
|
|
689
|
+
|
|
690
|
+
describe('vision with structured output', () => {
|
|
691
|
+
test('includes image_url content parts with response_format in request', async () => {
|
|
692
|
+
const getBody = mockFetchAndCapture({
|
|
693
|
+
...OPENAI_RESPONSE,
|
|
694
|
+
choices: [{
|
|
695
|
+
index: 0,
|
|
696
|
+
message: {
|
|
697
|
+
role: 'assistant',
|
|
698
|
+
content: '{"description": "A colorful image with flowers", "objects": ["flower", "vase", "table"]}',
|
|
699
|
+
},
|
|
700
|
+
finish_reason: 'stop',
|
|
701
|
+
}],
|
|
702
|
+
});
|
|
703
|
+
const client = createClient();
|
|
704
|
+
|
|
705
|
+
const DescriptionSchema = z.object({
|
|
706
|
+
description: z.string(),
|
|
707
|
+
objects: z.array(z.string()),
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
const messages = [{
|
|
711
|
+
role: 'user' as const,
|
|
712
|
+
content: [
|
|
713
|
+
{ type: 'text' as const, text: 'Describe this image' },
|
|
714
|
+
{ type: 'image_url' as const, image_url: { url: 'data:image/jpeg;base64,IMGDATA' } },
|
|
715
|
+
] as const,
|
|
716
|
+
}];
|
|
717
|
+
|
|
718
|
+
const options: ChatOptions = {
|
|
719
|
+
schema: fromZod(DescriptionSchema),
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
await client.chat(messages, options);
|
|
723
|
+
|
|
724
|
+
const body = getBody()!;
|
|
725
|
+
|
|
726
|
+
// Should have response_format with structured output
|
|
727
|
+
expect(body['response_format']).toBeDefined();
|
|
728
|
+
const responseFormat = body['response_format'] as Record<string, unknown>;
|
|
729
|
+
expect(responseFormat['type']).toBe('json_schema');
|
|
730
|
+
|
|
731
|
+
// Should preserve image_url content parts
|
|
732
|
+
const sentMessages = body['messages'] as Array<Record<string, unknown>>;
|
|
733
|
+
expect(sentMessages).toHaveLength(1);
|
|
734
|
+
expect(sentMessages[0]!['role']).toBe('user');
|
|
735
|
+
|
|
736
|
+
const content = sentMessages[0]!['content'] as Array<Record<string, unknown>>;
|
|
737
|
+
expect(content).toHaveLength(2);
|
|
738
|
+
|
|
739
|
+
// Text part
|
|
740
|
+
expect(content[0]!['type']).toBe('text');
|
|
741
|
+
expect(content[0]!['text']).toBe('Describe this image');
|
|
742
|
+
|
|
743
|
+
// Image part
|
|
744
|
+
expect(content[1]!['type']).toBe('image_url');
|
|
745
|
+
expect(content[1]!['image_url']).toEqual({ url: 'data:image/jpeg;base64,IMGDATA' });
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
test('handles multiple images with structured output', async () => {
|
|
749
|
+
const getBody = mockFetchAndCapture({
|
|
750
|
+
...OPENAI_RESPONSE,
|
|
751
|
+
choices: [{
|
|
752
|
+
index: 0,
|
|
753
|
+
message: {
|
|
754
|
+
role: 'assistant',
|
|
755
|
+
content: '{"comparison": "The images show different scenes"}',
|
|
756
|
+
},
|
|
757
|
+
finish_reason: 'stop',
|
|
758
|
+
}],
|
|
759
|
+
});
|
|
760
|
+
const client = createClient();
|
|
761
|
+
|
|
762
|
+
const ComparisonSchema = z.object({
|
|
763
|
+
comparison: z.string(),
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
const messages = [{
|
|
767
|
+
role: 'user' as const,
|
|
768
|
+
content: [
|
|
769
|
+
{ type: 'text' as const, text: 'Compare these images' },
|
|
770
|
+
{ type: 'image_url' as const, image_url: { url: 'data:image/png;base64,IMG1' } },
|
|
771
|
+
{ type: 'image_url' as const, image_url: { url: 'data:image/png;base64,IMG2' } },
|
|
772
|
+
] as const,
|
|
773
|
+
}];
|
|
774
|
+
|
|
775
|
+
const options: ChatOptions = {
|
|
776
|
+
schema: fromZod(ComparisonSchema),
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
await client.chat(messages, options);
|
|
780
|
+
|
|
781
|
+
const body = getBody()!;
|
|
782
|
+
|
|
783
|
+
// Should have response_format with structured output
|
|
784
|
+
expect(body['response_format']).toBeDefined();
|
|
785
|
+
|
|
786
|
+
// Should preserve all image_url content parts
|
|
787
|
+
const sentMessages = body['messages'] as Array<Record<string, unknown>>;
|
|
788
|
+
const content = sentMessages[0]!['content'] as Array<Record<string, unknown>>;
|
|
789
|
+
|
|
790
|
+
expect(content).toHaveLength(3);
|
|
791
|
+
expect(content[0]!['type']).toBe('text');
|
|
792
|
+
expect(content[1]!['type']).toBe('image_url');
|
|
793
|
+
expect(content[1]!['image_url']).toEqual({ url: 'data:image/png;base64,IMG1' });
|
|
794
|
+
expect(content[2]!['type']).toBe('image_url');
|
|
795
|
+
expect(content[2]!['image_url']).toEqual({ url: 'data:image/png;base64,IMG2' });
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
test('validates structured output response with vision', async () => {
|
|
799
|
+
mockFetchAndCapture({
|
|
800
|
+
...OPENAI_RESPONSE,
|
|
801
|
+
choices: [{
|
|
802
|
+
index: 0,
|
|
803
|
+
message: {
|
|
804
|
+
role: 'assistant',
|
|
805
|
+
content: '{"description": "A sunset over mountains", "colors": ["orange", "purple", "blue"]}',
|
|
806
|
+
},
|
|
807
|
+
finish_reason: 'stop',
|
|
808
|
+
}],
|
|
809
|
+
});
|
|
810
|
+
const client = createClient();
|
|
811
|
+
|
|
812
|
+
const ImageAnalysisSchema = z.object({
|
|
813
|
+
description: z.string(),
|
|
814
|
+
colors: z.array(z.string()),
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
const messages = [{
|
|
818
|
+
role: 'user' as const,
|
|
819
|
+
content: [
|
|
820
|
+
{ type: 'text' as const, text: 'Analyze this image' },
|
|
821
|
+
{ type: 'image_url' as const, image_url: { url: 'data:image/jpeg;base64,SUNSET' } },
|
|
822
|
+
] as const,
|
|
823
|
+
}];
|
|
824
|
+
|
|
825
|
+
const options: ChatOptions = {
|
|
826
|
+
schema: fromZod(ImageAnalysisSchema),
|
|
827
|
+
};
|
|
828
|
+
|
|
829
|
+
const response = await client.chat(messages, options);
|
|
830
|
+
|
|
831
|
+
// Should validate and return successfully
|
|
832
|
+
expect(response.message.content).toBe('{"description": "A sunset over mountains", "colors": ["orange", "purple", "blue"]}');
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
test('supports http image URLs with structured output', async () => {
|
|
836
|
+
const getBody = mockFetchAndCapture({
|
|
837
|
+
...OPENAI_RESPONSE,
|
|
838
|
+
choices: [{
|
|
839
|
+
index: 0,
|
|
840
|
+
message: {
|
|
841
|
+
role: 'assistant',
|
|
842
|
+
content: '{"description": "An image from the web"}',
|
|
843
|
+
},
|
|
844
|
+
finish_reason: 'stop',
|
|
845
|
+
}],
|
|
846
|
+
});
|
|
847
|
+
const client = createClient();
|
|
848
|
+
|
|
849
|
+
const DescriptionSchema = z.object({
|
|
850
|
+
description: z.string(),
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
const messages = [{
|
|
854
|
+
role: 'user' as const,
|
|
855
|
+
content: [
|
|
856
|
+
{ type: 'text' as const, text: 'Describe this image' },
|
|
857
|
+
{ type: 'image_url' as const, image_url: { url: 'https://example.com/image.jpg' } },
|
|
858
|
+
] as const,
|
|
859
|
+
}];
|
|
860
|
+
|
|
861
|
+
const options: ChatOptions = {
|
|
862
|
+
schema: fromZod(DescriptionSchema),
|
|
863
|
+
};
|
|
864
|
+
|
|
865
|
+
await client.chat(messages, options);
|
|
866
|
+
|
|
867
|
+
const body = getBody()!;
|
|
868
|
+
const sentMessages = body['messages'] as Array<Record<string, unknown>>;
|
|
869
|
+
const content = sentMessages[0]!['content'] as Array<Record<string, unknown>>;
|
|
870
|
+
|
|
871
|
+
// OpenAI accepts HTTP URLs directly (unlike Ollama which needs base64)
|
|
872
|
+
expect(content[1]!['type']).toBe('image_url');
|
|
873
|
+
expect(content[1]!['image_url']).toEqual({ url: 'https://example.com/image.jpg' });
|
|
874
|
+
|
|
875
|
+
// And response_format should still be set
|
|
876
|
+
expect(body['response_format']).toBeDefined();
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
test('supports image_url with detail parameter and structured output', async () => {
|
|
880
|
+
const getBody = mockFetchAndCapture({
|
|
881
|
+
...OPENAI_RESPONSE,
|
|
882
|
+
choices: [{
|
|
883
|
+
index: 0,
|
|
884
|
+
message: {
|
|
885
|
+
role: 'assistant',
|
|
886
|
+
content: '{"objects": ["car", "tree", "building"]}',
|
|
887
|
+
},
|
|
888
|
+
finish_reason: 'stop',
|
|
889
|
+
}],
|
|
890
|
+
});
|
|
891
|
+
const client = createClient();
|
|
892
|
+
|
|
893
|
+
const DescriptionSchema = z.object({
|
|
894
|
+
objects: z.array(z.string()),
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
const messages = [{
|
|
898
|
+
role: 'user' as const,
|
|
899
|
+
content: [
|
|
900
|
+
{ type: 'text' as const, text: 'List objects in this image' },
|
|
901
|
+
{ type: 'image_url' as const, image_url: { url: 'data:image/jpeg;base64,IMG', detail: 'high' } },
|
|
902
|
+
] as const,
|
|
903
|
+
}];
|
|
904
|
+
|
|
905
|
+
const options: ChatOptions = {
|
|
906
|
+
schema: fromZod(DescriptionSchema),
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
await client.chat(messages, options);
|
|
910
|
+
|
|
911
|
+
const body = getBody()!;
|
|
912
|
+
const sentMessages = body['messages'] as Array<Record<string, unknown>>;
|
|
913
|
+
const content = sentMessages[0]!['content'] as Array<Record<string, unknown>>;
|
|
914
|
+
|
|
915
|
+
// Should preserve detail parameter
|
|
916
|
+
expect(content[1]!['image_url']).toEqual({ url: 'data:image/jpeg;base64,IMG', detail: 'high' });
|
|
917
|
+
expect(body['response_format']).toBeDefined();
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
test('returns validated object on successful vision + structured output', async () => {
|
|
921
|
+
mockFetchAndCapture({
|
|
922
|
+
...OPENAI_RESPONSE,
|
|
923
|
+
choices: [{
|
|
924
|
+
index: 0,
|
|
925
|
+
message: {
|
|
926
|
+
role: 'assistant',
|
|
927
|
+
content: '{"count": 3, "items": ["cat", "dog", "bird"]}',
|
|
928
|
+
},
|
|
929
|
+
finish_reason: 'stop',
|
|
930
|
+
}],
|
|
931
|
+
});
|
|
932
|
+
const client = createClient();
|
|
933
|
+
|
|
934
|
+
const VisionSchema = z.object({
|
|
935
|
+
count: z.number(),
|
|
936
|
+
items: z.array(z.string()),
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
const messages = [{
|
|
940
|
+
role: 'user' as const,
|
|
941
|
+
content: [
|
|
942
|
+
{ type: 'text' as const, text: 'Count items' },
|
|
943
|
+
{ type: 'image_url' as const, image_url: { url: 'data:image/png;base64,IMG' } },
|
|
944
|
+
] as const,
|
|
945
|
+
}];
|
|
946
|
+
|
|
947
|
+
const options: ChatOptions = {
|
|
948
|
+
schema: fromZod(VisionSchema),
|
|
949
|
+
};
|
|
950
|
+
|
|
951
|
+
// Should not throw - response passes validation
|
|
952
|
+
const result = await client.chat(messages, options);
|
|
953
|
+
expect(result.message.content).toBe('{"count": 3, "items": ["cat", "dog", "bird"]}');
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
test('throws StructuredOutputError on invalid vision response', async () => {
|
|
957
|
+
mockFetchAndCapture({
|
|
958
|
+
...OPENAI_RESPONSE,
|
|
959
|
+
choices: [{
|
|
960
|
+
index: 0,
|
|
961
|
+
message: {
|
|
962
|
+
role: 'assistant',
|
|
963
|
+
content: '{"count": "not a number"}', // Invalid - count should be number
|
|
964
|
+
},
|
|
965
|
+
finish_reason: 'stop',
|
|
966
|
+
}],
|
|
967
|
+
});
|
|
968
|
+
const client = createClient();
|
|
969
|
+
|
|
970
|
+
const VisionSchema = z.object({
|
|
971
|
+
count: z.number(),
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
const messages = [{
|
|
975
|
+
role: 'user' as const,
|
|
976
|
+
content: [
|
|
977
|
+
{ type: 'text' as const, text: 'Count items' },
|
|
978
|
+
{ type: 'image_url' as const, image_url: { url: 'data:image/png;base64,IMG' } },
|
|
979
|
+
] as const,
|
|
980
|
+
}];
|
|
981
|
+
|
|
982
|
+
const options: ChatOptions = {
|
|
983
|
+
schema: fromZod(VisionSchema),
|
|
984
|
+
};
|
|
985
|
+
|
|
986
|
+
// Provider should NOT throw — validation is done at Router level
|
|
987
|
+
const result = await client.chat(messages, options);
|
|
988
|
+
expect(result.message.content).toBe('{"count": "not a number"}');
|
|
989
|
+
});
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
// ========================================================================
|
|
993
|
+
// Complex Schemas
|
|
994
|
+
// ========================================================================
|
|
995
|
+
|
|
996
|
+
describe('complex schemas', () => {
|
|
997
|
+
test('handles nested object schemas', async () => {
|
|
998
|
+
const getBody = mockFetchAndCapture({
|
|
999
|
+
...OPENAI_RESPONSE,
|
|
1000
|
+
choices: [{
|
|
1001
|
+
index: 0,
|
|
1002
|
+
message: {
|
|
1003
|
+
role: 'assistant',
|
|
1004
|
+
content: '{"name": "Alice", "address": {"street": "123 Main St", "city": "NYC"}}',
|
|
1005
|
+
},
|
|
1006
|
+
finish_reason: 'stop',
|
|
1007
|
+
}],
|
|
1008
|
+
});
|
|
1009
|
+
const client = createClient();
|
|
1010
|
+
|
|
1011
|
+
const AddressSchema = z.object({
|
|
1012
|
+
street: z.string(),
|
|
1013
|
+
city: z.string(),
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
const UserSchema = z.object({
|
|
1017
|
+
name: z.string(),
|
|
1018
|
+
address: AddressSchema,
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
const options: ChatOptions = {
|
|
1022
|
+
schema: fromZod(UserSchema),
|
|
1023
|
+
};
|
|
1024
|
+
|
|
1025
|
+
const response = await client.chat([
|
|
1026
|
+
{ role: 'user', content: 'Generate' },
|
|
1027
|
+
], options);
|
|
1028
|
+
|
|
1029
|
+
const body = getBody()!;
|
|
1030
|
+
const responseFormat = body['response_format'] as Record<string, unknown>;
|
|
1031
|
+
const jsonSchema = responseFormat['json_schema'] as Record<string, unknown>;
|
|
1032
|
+
const schema = jsonSchema['schema'] as Record<string, unknown>;
|
|
1033
|
+
|
|
1034
|
+
expect(schema['type']).toBe('object');
|
|
1035
|
+
const addressSchema = schema['properties']!['address'] as Record<string, unknown>;
|
|
1036
|
+
expect(addressSchema['type']).toBe('object');
|
|
1037
|
+
expect(addressSchema['properties']).toBeDefined();
|
|
1038
|
+
// Verify response validated successfully
|
|
1039
|
+
expect(response.message.content).toContain('Alice');
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
test('handles array schemas', async () => {
|
|
1043
|
+
const getBody = mockFetchAndCapture({
|
|
1044
|
+
...OPENAI_RESPONSE,
|
|
1045
|
+
choices: [{
|
|
1046
|
+
index: 0,
|
|
1047
|
+
message: {
|
|
1048
|
+
role: 'assistant',
|
|
1049
|
+
content: '{"users": [{"name": "Alice", "email": "alice@example.com"}]}',
|
|
1050
|
+
},
|
|
1051
|
+
finish_reason: 'stop',
|
|
1052
|
+
}],
|
|
1053
|
+
});
|
|
1054
|
+
const client = createClient();
|
|
1055
|
+
|
|
1056
|
+
const UserListSchema = z.object({
|
|
1057
|
+
users: z.array(z.object({
|
|
1058
|
+
name: z.string(),
|
|
1059
|
+
email: z.string().email(),
|
|
1060
|
+
})),
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
const options: ChatOptions = {
|
|
1064
|
+
schema: fromZod(UserListSchema),
|
|
1065
|
+
};
|
|
1066
|
+
|
|
1067
|
+
const response = await client.chat([
|
|
1068
|
+
{ role: 'user', content: 'Generate' },
|
|
1069
|
+
], options);
|
|
1070
|
+
|
|
1071
|
+
const body = getBody()!;
|
|
1072
|
+
const responseFormat = body['response_format'] as Record<string, unknown>;
|
|
1073
|
+
const jsonSchema = responseFormat['json_schema'] as Record<string, unknown>;
|
|
1074
|
+
const schema = jsonSchema['schema'] as Record<string, unknown>;
|
|
1075
|
+
|
|
1076
|
+
expect(schema['type']).toBe('object');
|
|
1077
|
+
const usersSchema = schema['properties']!['users'] as Record<string, unknown>;
|
|
1078
|
+
expect(usersSchema['type']).toBe('array');
|
|
1079
|
+
expect(usersSchema['items']).toBeDefined();
|
|
1080
|
+
// Verify response validated successfully
|
|
1081
|
+
expect(response.message.content).toContain('Alice');
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
test('handles enum schemas', async () => {
|
|
1085
|
+
const getBody = mockFetchAndCapture({
|
|
1086
|
+
...OPENAI_RESPONSE,
|
|
1087
|
+
choices: [{
|
|
1088
|
+
index: 0,
|
|
1089
|
+
message: {
|
|
1090
|
+
role: 'assistant',
|
|
1091
|
+
content: '{"status": "active"}',
|
|
1092
|
+
},
|
|
1093
|
+
finish_reason: 'stop',
|
|
1094
|
+
}],
|
|
1095
|
+
});
|
|
1096
|
+
const client = createClient();
|
|
1097
|
+
|
|
1098
|
+
const StatusSchema = z.object({
|
|
1099
|
+
status: z.enum(['active', 'inactive', 'pending']),
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
const options: ChatOptions = {
|
|
1103
|
+
schema: fromZod(StatusSchema),
|
|
1104
|
+
};
|
|
1105
|
+
|
|
1106
|
+
const response = await client.chat([
|
|
1107
|
+
{ role: 'user', content: 'Generate' },
|
|
1108
|
+
], options);
|
|
1109
|
+
|
|
1110
|
+
const body = getBody()!;
|
|
1111
|
+
const responseFormat = body['response_format'] as Record<string, unknown>;
|
|
1112
|
+
const jsonSchema = responseFormat['json_schema'] as Record<string, unknown>;
|
|
1113
|
+
const schema = jsonSchema['schema'] as Record<string, unknown>;
|
|
1114
|
+
|
|
1115
|
+
const statusSchema = schema['properties']!['status'] as Record<string, unknown>;
|
|
1116
|
+
expect(statusSchema['type']).toBe('string');
|
|
1117
|
+
expect(statusSchema['enum']).toEqual(['active', 'inactive', 'pending']);
|
|
1118
|
+
// Verify response validated successfully
|
|
1119
|
+
expect(response.message.content).toContain('active');
|
|
1120
|
+
});
|
|
1121
|
+
});
|
|
1122
|
+
});
|