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,1614 @@
|
|
|
1
|
+
import { fromZod } from '../zod-adapter.js';
|
|
2
|
+
/**
|
|
3
|
+
* Tests for ai-model.ts — Universal Client (AIModel)
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { AIModel, type AIModelConfig, AIModelApiType, BufferedAuditor, StructuredOutputError } from '../index.js';
|
|
8
|
+
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// Helpers
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
function createTestConfig(overrides: Partial<AIModelConfig> = {}): AIModelConfig {
|
|
14
|
+
return {
|
|
15
|
+
model: 'test-model',
|
|
16
|
+
providers: [
|
|
17
|
+
{ type: AIModelApiType.Ollama, url: 'http://localhost:11434' },
|
|
18
|
+
],
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// Tests
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
describe('AIModel', () => {
|
|
28
|
+
const originalFetch = globalThis.fetch;
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
globalThis.fetch = originalFetch;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('constructor', () => {
|
|
35
|
+
it('creates with single provider', () => {
|
|
36
|
+
const model = new AIModel(createTestConfig());
|
|
37
|
+
expect(model.model).toBe('test-model');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('creates with multiple providers', () => {
|
|
41
|
+
const model = new AIModel(createTestConfig({
|
|
42
|
+
providers: [
|
|
43
|
+
{ type: 'ollama', url: 'http://localhost:11434' },
|
|
44
|
+
{ type: 'openai', apiKey: 'sk-test' },
|
|
45
|
+
],
|
|
46
|
+
}));
|
|
47
|
+
expect(model.model).toBe('test-model');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('creates with all supported provider types', () => {
|
|
51
|
+
const model = new AIModel(createTestConfig({
|
|
52
|
+
providers: [
|
|
53
|
+
{ type: 'ollama' },
|
|
54
|
+
{ type: 'openai', apiKey: 'sk-test' },
|
|
55
|
+
{ type: 'google', apiKey: 'test-key' },
|
|
56
|
+
{ type: 'vertex', apiKey: 'token', region: 'us-east1' },
|
|
57
|
+
{ type: 'llamacpp', url: 'http://localhost:8080' },
|
|
58
|
+
],
|
|
59
|
+
}));
|
|
60
|
+
expect(model.model).toBe('test-model');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('throws for unknown provider type', () => {
|
|
64
|
+
expect(() => new AIModel(createTestConfig({
|
|
65
|
+
providers: [{ type: 'unknown' as never }],
|
|
66
|
+
}))).toThrow('Unknown provider type');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('model management', () => {
|
|
71
|
+
it('returns model name', () => {
|
|
72
|
+
const model = new AIModel(createTestConfig());
|
|
73
|
+
expect(model.model).toBe('test-model');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('switches model at runtime', () => {
|
|
77
|
+
const model = new AIModel(createTestConfig());
|
|
78
|
+
model.setModel('new-model');
|
|
79
|
+
expect(model.model).toBe('new-model');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('chat (with fetch mock)', () => {
|
|
84
|
+
it('chat returns a response from Ollama provider', async () => {
|
|
85
|
+
globalThis.fetch = mock(async () =>
|
|
86
|
+
new Response(JSON.stringify({
|
|
87
|
+
model: 'test-model',
|
|
88
|
+
created_at: new Date().toISOString(),
|
|
89
|
+
message: { role: 'assistant', content: 'Hello from Ollama' },
|
|
90
|
+
done: true,
|
|
91
|
+
prompt_eval_count: 10,
|
|
92
|
+
eval_count: 5,
|
|
93
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
94
|
+
) as typeof fetch;
|
|
95
|
+
|
|
96
|
+
const model = new AIModel(createTestConfig());
|
|
97
|
+
const response = await model.chat([
|
|
98
|
+
{ role: 'user', content: 'Hello' },
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
expect(response.message.role).toBe('assistant');
|
|
102
|
+
expect(response.message.content).toBe('Hello from Ollama');
|
|
103
|
+
expect(response.provider).toBe('ollama');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('chat returns a response from OpenAI provider', async () => {
|
|
107
|
+
globalThis.fetch = mock(async () =>
|
|
108
|
+
new Response(JSON.stringify({
|
|
109
|
+
id: 'chatcmpl-test',
|
|
110
|
+
object: 'chat.completion',
|
|
111
|
+
created: Date.now(),
|
|
112
|
+
model: 'test-model',
|
|
113
|
+
choices: [{
|
|
114
|
+
index: 0,
|
|
115
|
+
message: { role: 'assistant', content: 'Hello from OpenAI' },
|
|
116
|
+
finish_reason: 'stop',
|
|
117
|
+
}],
|
|
118
|
+
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
|
119
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
120
|
+
) as typeof fetch;
|
|
121
|
+
|
|
122
|
+
const model = new AIModel(createTestConfig({
|
|
123
|
+
providers: [{ type: 'openai', apiKey: 'sk-test' }],
|
|
124
|
+
}));
|
|
125
|
+
|
|
126
|
+
const response = await model.chat([
|
|
127
|
+
{ role: 'user', content: 'Hello' },
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
expect(response.message.content).toBe('Hello from OpenAI');
|
|
131
|
+
expect(response.provider).toBe('openai');
|
|
132
|
+
expect(response.usage?.totalTokens).toBe(15);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('chat returns a response from Google provider', async () => {
|
|
136
|
+
globalThis.fetch = mock(async () =>
|
|
137
|
+
new Response(JSON.stringify({
|
|
138
|
+
candidates: [{
|
|
139
|
+
content: {
|
|
140
|
+
parts: [{ text: 'Hello from Google' }],
|
|
141
|
+
role: 'model',
|
|
142
|
+
},
|
|
143
|
+
finishReason: 'STOP',
|
|
144
|
+
index: 0,
|
|
145
|
+
}],
|
|
146
|
+
usageMetadata: {
|
|
147
|
+
promptTokenCount: 10,
|
|
148
|
+
candidatesTokenCount: 5,
|
|
149
|
+
totalTokenCount: 15,
|
|
150
|
+
},
|
|
151
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
152
|
+
) as typeof fetch;
|
|
153
|
+
|
|
154
|
+
const model = new AIModel(createTestConfig({
|
|
155
|
+
providers: [{ type: 'google', apiKey: 'test-key' }],
|
|
156
|
+
}));
|
|
157
|
+
|
|
158
|
+
const response = await model.chat([
|
|
159
|
+
{ role: 'user', content: 'Hello' },
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
expect(response.message.content).toBe('Hello from Google');
|
|
163
|
+
expect(response.provider).toBe('google');
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('failover', () => {
|
|
168
|
+
it('fails over from unhealthy provider to healthy one', async () => {
|
|
169
|
+
let callCount = 0;
|
|
170
|
+
globalThis.fetch = mock(async (url: string | URL | Request) => {
|
|
171
|
+
callCount++;
|
|
172
|
+
const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
173
|
+
if (urlStr.includes('11434')) {
|
|
174
|
+
return new Response('Server Error', { status: 500 });
|
|
175
|
+
}
|
|
176
|
+
return new Response(JSON.stringify({
|
|
177
|
+
id: 'test',
|
|
178
|
+
object: 'chat.completion',
|
|
179
|
+
created: Date.now(),
|
|
180
|
+
model: 'test-model',
|
|
181
|
+
choices: [{
|
|
182
|
+
index: 0,
|
|
183
|
+
message: { role: 'assistant', content: 'From backup' },
|
|
184
|
+
finish_reason: 'stop',
|
|
185
|
+
}],
|
|
186
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
187
|
+
}) as typeof fetch;
|
|
188
|
+
|
|
189
|
+
const model = new AIModel(createTestConfig({
|
|
190
|
+
retries: 0, // No retries for faster test
|
|
191
|
+
providers: [
|
|
192
|
+
{ type: 'ollama', url: 'http://localhost:11434' },
|
|
193
|
+
{ type: 'openai', url: 'http://localhost:8080', apiKey: 'test' },
|
|
194
|
+
],
|
|
195
|
+
}));
|
|
196
|
+
|
|
197
|
+
const response = await model.chat([
|
|
198
|
+
{ role: 'user', content: 'Hello' },
|
|
199
|
+
]);
|
|
200
|
+
|
|
201
|
+
expect(response.message.content).toBe('From backup');
|
|
202
|
+
expect(response.provider).toBe('openai');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('tool registration', () => {
|
|
207
|
+
it('registers a tool', () => {
|
|
208
|
+
const model = new AIModel(createTestConfig());
|
|
209
|
+
model.registerTool(
|
|
210
|
+
'test_tool',
|
|
211
|
+
'A test tool',
|
|
212
|
+
{ type: 'object', properties: {} },
|
|
213
|
+
async () => 'result',
|
|
214
|
+
);
|
|
215
|
+
// If no error, registration succeeded
|
|
216
|
+
expect(true).toBe(true);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('registers multiple tools', () => {
|
|
220
|
+
const model = new AIModel(createTestConfig());
|
|
221
|
+
model.registerTools([
|
|
222
|
+
{
|
|
223
|
+
name: 'tool_a',
|
|
224
|
+
description: 'Tool A',
|
|
225
|
+
parameters: { type: 'object' },
|
|
226
|
+
handler: async () => 'a',
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
name: 'tool_b',
|
|
230
|
+
description: 'Tool B',
|
|
231
|
+
parameters: { type: 'object' },
|
|
232
|
+
handler: async () => 'b',
|
|
233
|
+
},
|
|
234
|
+
]);
|
|
235
|
+
expect(true).toBe(true);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe('provider status', () => {
|
|
240
|
+
it('returns provider status', () => {
|
|
241
|
+
const model = new AIModel(createTestConfig({
|
|
242
|
+
providers: [
|
|
243
|
+
{ type: 'ollama' },
|
|
244
|
+
{ type: 'openai', apiKey: 'sk-test' },
|
|
245
|
+
],
|
|
246
|
+
}));
|
|
247
|
+
|
|
248
|
+
const status = model.getProviderStatus();
|
|
249
|
+
expect(status).toHaveLength(2);
|
|
250
|
+
expect(status[0]!.healthy).toBe(true);
|
|
251
|
+
expect(status[1]!.healthy).toBe(true);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe('observability', () => {
|
|
256
|
+
it('records events through auditor', async () => {
|
|
257
|
+
const auditor = new BufferedAuditor();
|
|
258
|
+
globalThis.fetch = mock(async () =>
|
|
259
|
+
new Response(JSON.stringify({
|
|
260
|
+
model: 'test', created_at: '', done: true,
|
|
261
|
+
message: { role: 'assistant', content: 'ok' },
|
|
262
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
263
|
+
) as typeof fetch;
|
|
264
|
+
|
|
265
|
+
const model = new AIModel(createTestConfig({ auditor }));
|
|
266
|
+
await model.chat([{ role: 'user', content: 'test' }]);
|
|
267
|
+
|
|
268
|
+
const events = auditor.getEvents();
|
|
269
|
+
expect(events.length).toBeGreaterThan(0);
|
|
270
|
+
|
|
271
|
+
const types = events.map(e => e.type);
|
|
272
|
+
expect(types).toContain('request');
|
|
273
|
+
expect(types).toContain('response');
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe('lifecycle', () => {
|
|
278
|
+
it('dispose flushes auditor', async () => {
|
|
279
|
+
let flushed = false;
|
|
280
|
+
const auditor = new BufferedAuditor({
|
|
281
|
+
onFlush: async () => { flushed = true; },
|
|
282
|
+
});
|
|
283
|
+
auditor.record({ timestamp: Date.now(), type: 'request' });
|
|
284
|
+
|
|
285
|
+
const model = new AIModel(createTestConfig({ auditor }));
|
|
286
|
+
await model.dispose();
|
|
287
|
+
|
|
288
|
+
expect(flushed).toBe(true);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// ========================================================================
|
|
293
|
+
// Structured Output Tests (VAL-API-001, VAL-API-002, VAL-API-003, VAL-API-006, VAL-API-007)
|
|
294
|
+
// ========================================================================
|
|
295
|
+
|
|
296
|
+
describe('generateStructured', () => {
|
|
297
|
+
const UserSchema = z.object({
|
|
298
|
+
name: z.string(),
|
|
299
|
+
age: z.number(),
|
|
300
|
+
email: z.string().email().optional(),
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
type User = z.infer<typeof UserSchema>;
|
|
304
|
+
|
|
305
|
+
it('returns typed object matching schema (VAL-API-001)', async () => {
|
|
306
|
+
globalThis.fetch = mock(async () =>
|
|
307
|
+
new Response(JSON.stringify({
|
|
308
|
+
model: 'test-model',
|
|
309
|
+
created_at: new Date().toISOString(),
|
|
310
|
+
message: { role: 'assistant', content: '{"name": "Alice", "age": 30}' },
|
|
311
|
+
done: true,
|
|
312
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
313
|
+
) as typeof fetch;
|
|
314
|
+
|
|
315
|
+
const model = new AIModel(createTestConfig());
|
|
316
|
+
const result = await model.generateStructured(fromZod(UserSchema), [
|
|
317
|
+
{ role: 'user', content: 'Generate a user' },
|
|
318
|
+
]);
|
|
319
|
+
|
|
320
|
+
expect(result.name).toBe('Alice');
|
|
321
|
+
expect(result.age).toBe(30);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('returns typed object with optional fields', async () => {
|
|
325
|
+
globalThis.fetch = mock(async () =>
|
|
326
|
+
new Response(JSON.stringify({
|
|
327
|
+
model: 'test-model',
|
|
328
|
+
created_at: new Date().toISOString(),
|
|
329
|
+
message: { role: 'assistant', content: '{"name": "Bob", "age": 25, "email": "bob@example.com"}' },
|
|
330
|
+
done: true,
|
|
331
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
332
|
+
) as typeof fetch;
|
|
333
|
+
|
|
334
|
+
const model = new AIModel(createTestConfig());
|
|
335
|
+
const result = await model.generateStructured(fromZod(UserSchema), [
|
|
336
|
+
{ role: 'user', content: 'Generate a user with email' },
|
|
337
|
+
]);
|
|
338
|
+
|
|
339
|
+
expect(result.name).toBe('Bob');
|
|
340
|
+
expect(result.age).toBe(25);
|
|
341
|
+
expect(result.email).toBe('bob@example.com');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('passes options (temperature, maxTokens) to provider (VAL-API-002)', async () => {
|
|
345
|
+
let capturedBody: Record<string, unknown> | undefined;
|
|
346
|
+
globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
|
|
347
|
+
if (init?.body) {
|
|
348
|
+
capturedBody = JSON.parse(init.body as string);
|
|
349
|
+
}
|
|
350
|
+
return new Response(JSON.stringify({
|
|
351
|
+
model: 'test-model',
|
|
352
|
+
created_at: new Date().toISOString(),
|
|
353
|
+
message: { role: 'assistant', content: '{"name": "Test", "age": 20}' },
|
|
354
|
+
done: true,
|
|
355
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
356
|
+
}) as typeof fetch;
|
|
357
|
+
|
|
358
|
+
const model = new AIModel(createTestConfig());
|
|
359
|
+
await model.generateStructured(fromZod(UserSchema), [
|
|
360
|
+
{ role: 'user', content: 'Generate a user' },
|
|
361
|
+
], { temperature: 0.5, maxTokens: 100 });
|
|
362
|
+
|
|
363
|
+
// Ollama uses 'options' object for temperature and max_tokens
|
|
364
|
+
expect(capturedBody?.options).toBeDefined();
|
|
365
|
+
expect((capturedBody?.options as Record<string, unknown>)?.temperature).toBe(0.5);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('failover works across providers with same structured output request (VAL-API-003)', async () => {
|
|
369
|
+
let firstProviderCalled = false;
|
|
370
|
+
globalThis.fetch = mock(async (url: string | URL | Request) => {
|
|
371
|
+
const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
372
|
+
// First provider (Ollama) fails
|
|
373
|
+
if (urlStr.includes('11434') && !firstProviderCalled) {
|
|
374
|
+
firstProviderCalled = true;
|
|
375
|
+
return new Response('Server Error', { status: 500 });
|
|
376
|
+
}
|
|
377
|
+
// Second provider (OpenAI-compatible) succeeds
|
|
378
|
+
return new Response(JSON.stringify({
|
|
379
|
+
id: 'chatcmpl-test',
|
|
380
|
+
object: 'chat.completion',
|
|
381
|
+
created: Date.now(),
|
|
382
|
+
model: 'test-model',
|
|
383
|
+
choices: [{
|
|
384
|
+
index: 0,
|
|
385
|
+
message: { role: 'assistant', content: '{"name": "Failover", "age": 40}' },
|
|
386
|
+
finish_reason: 'stop',
|
|
387
|
+
}],
|
|
388
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
389
|
+
}) as typeof fetch;
|
|
390
|
+
|
|
391
|
+
const model = new AIModel(createTestConfig({
|
|
392
|
+
retries: 0,
|
|
393
|
+
providers: [
|
|
394
|
+
{ type: 'ollama', url: 'http://localhost:11434' },
|
|
395
|
+
{ type: 'openai', url: 'http://localhost:8080/v1', apiKey: 'test' },
|
|
396
|
+
],
|
|
397
|
+
}));
|
|
398
|
+
|
|
399
|
+
const result = await model.generateStructured(fromZod(UserSchema), [
|
|
400
|
+
{ role: 'user', content: 'Generate a user' },
|
|
401
|
+
]);
|
|
402
|
+
|
|
403
|
+
expect(result.name).toBe('Failover');
|
|
404
|
+
expect(result.age).toBe(40);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('throws StructuredOutputError on validation failure (VAL-API-001)', async () => {
|
|
408
|
+
globalThis.fetch = mock(async () =>
|
|
409
|
+
new Response(JSON.stringify({
|
|
410
|
+
model: 'test-model',
|
|
411
|
+
created_at: new Date().toISOString(),
|
|
412
|
+
message: { role: 'assistant', content: '{"name": "Alice", "age": "not a number"}' },
|
|
413
|
+
done: true,
|
|
414
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
415
|
+
) as typeof fetch;
|
|
416
|
+
|
|
417
|
+
const model = new AIModel(createTestConfig());
|
|
418
|
+
|
|
419
|
+
expect(
|
|
420
|
+
model.generateStructured(fromZod(UserSchema), [{ role: 'user', content: 'Generate a user' }])
|
|
421
|
+
).rejects.toThrow(StructuredOutputError);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('throws StructuredOutputError on invalid JSON response', async () => {
|
|
425
|
+
globalThis.fetch = mock(async () =>
|
|
426
|
+
new Response(JSON.stringify({
|
|
427
|
+
model: 'test-model',
|
|
428
|
+
created_at: new Date().toISOString(),
|
|
429
|
+
message: { role: 'assistant', content: 'not valid json' },
|
|
430
|
+
done: true,
|
|
431
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
432
|
+
) as typeof fetch;
|
|
433
|
+
|
|
434
|
+
const model = new AIModel(createTestConfig());
|
|
435
|
+
|
|
436
|
+
expect(
|
|
437
|
+
model.generateStructured(fromZod(UserSchema), [{ role: 'user', content: 'Generate a user' }])
|
|
438
|
+
).rejects.toThrow(StructuredOutputError);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('accepts raw JSON Schema instead of Zod', async () => {
|
|
442
|
+
globalThis.fetch = mock(async () =>
|
|
443
|
+
new Response(JSON.stringify({
|
|
444
|
+
model: 'test-model',
|
|
445
|
+
created_at: new Date().toISOString(),
|
|
446
|
+
message: { role: 'assistant', content: '{"name": "Schema", "age": 50}' },
|
|
447
|
+
done: true,
|
|
448
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
449
|
+
) as typeof fetch;
|
|
450
|
+
|
|
451
|
+
const model = new AIModel(createTestConfig());
|
|
452
|
+
const result = await model.generateStructured(fromZod(UserSchema),
|
|
453
|
+
[{ role: 'user', content: 'Generate a user' }],
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
expect(result.name).toBe('Schema');
|
|
457
|
+
expect(result.age).toBe(50);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('works with nested object schemas', async () => {
|
|
461
|
+
const NestedSchema = z.object({
|
|
462
|
+
user: z.object({
|
|
463
|
+
name: z.string(),
|
|
464
|
+
address: z.object({
|
|
465
|
+
city: z.string(),
|
|
466
|
+
country: z.string(),
|
|
467
|
+
}),
|
|
468
|
+
}),
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
globalThis.fetch = mock(async () =>
|
|
472
|
+
new Response(JSON.stringify({
|
|
473
|
+
model: 'test-model',
|
|
474
|
+
created_at: new Date().toISOString(),
|
|
475
|
+
message: {
|
|
476
|
+
role: 'assistant',
|
|
477
|
+
content: '{"user": {"name": "Nested", "address": {"city": "NYC", "country": "USA"}}}',
|
|
478
|
+
},
|
|
479
|
+
done: true,
|
|
480
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
481
|
+
) as typeof fetch;
|
|
482
|
+
|
|
483
|
+
const model = new AIModel(createTestConfig());
|
|
484
|
+
const result = await model.generateStructured(fromZod(NestedSchema), [
|
|
485
|
+
{ role: 'user', content: 'Generate a nested user' },
|
|
486
|
+
]);
|
|
487
|
+
|
|
488
|
+
expect(result.user.name).toBe('Nested');
|
|
489
|
+
expect(result.user.address.city).toBe('NYC');
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('works with array schemas', async () => {
|
|
493
|
+
const ArraySchema = z.object({
|
|
494
|
+
items: z.array(z.string()),
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
globalThis.fetch = mock(async () =>
|
|
498
|
+
new Response(JSON.stringify({
|
|
499
|
+
model: 'test-model',
|
|
500
|
+
created_at: new Date().toISOString(),
|
|
501
|
+
message: { role: 'assistant', content: '{"items": ["a", "b", "c"]}' },
|
|
502
|
+
done: true,
|
|
503
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
504
|
+
) as typeof fetch;
|
|
505
|
+
|
|
506
|
+
const model = new AIModel(createTestConfig());
|
|
507
|
+
const result = await model.generateStructured(fromZod(ArraySchema), [
|
|
508
|
+
{ role: 'user', content: 'Generate items' },
|
|
509
|
+
]);
|
|
510
|
+
|
|
511
|
+
expect(result.items).toEqual(['a', 'b', 'c']);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it('works with enum schemas', async () => {
|
|
515
|
+
const EnumSchema = z.object({
|
|
516
|
+
status: z.enum(['active', 'inactive', 'pending']),
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
globalThis.fetch = mock(async () =>
|
|
520
|
+
new Response(JSON.stringify({
|
|
521
|
+
model: 'test-model',
|
|
522
|
+
created_at: new Date().toISOString(),
|
|
523
|
+
message: { role: 'assistant', content: '{"status": "active"}' },
|
|
524
|
+
done: true,
|
|
525
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
526
|
+
) as typeof fetch;
|
|
527
|
+
|
|
528
|
+
const model = new AIModel(createTestConfig());
|
|
529
|
+
const result = await model.generateStructured(fromZod(EnumSchema), [
|
|
530
|
+
{ role: 'user', content: 'Generate status' },
|
|
531
|
+
]);
|
|
532
|
+
|
|
533
|
+
expect(result.status).toBe('active');
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
describe('tryParseStructured', () => {
|
|
538
|
+
const UserSchema = z.object({
|
|
539
|
+
name: z.string(),
|
|
540
|
+
age: z.number(),
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
type User = z.infer<typeof UserSchema>;
|
|
544
|
+
|
|
545
|
+
it('returns { ok: true, value } on success (VAL-API-006)', async () => {
|
|
546
|
+
globalThis.fetch = mock(async () =>
|
|
547
|
+
new Response(JSON.stringify({
|
|
548
|
+
model: 'test-model',
|
|
549
|
+
created_at: new Date().toISOString(),
|
|
550
|
+
message: { role: 'assistant', content: '{"name": "Alice", "age": 30}' },
|
|
551
|
+
done: true,
|
|
552
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
553
|
+
) as typeof fetch;
|
|
554
|
+
|
|
555
|
+
const model = new AIModel(createTestConfig());
|
|
556
|
+
const result = await model.tryParseStructured(fromZod(UserSchema), [
|
|
557
|
+
{ role: 'user', content: 'Generate a user' },
|
|
558
|
+
]);
|
|
559
|
+
|
|
560
|
+
expect(result.ok).toBe(true);
|
|
561
|
+
if (result.ok) {
|
|
562
|
+
expect(result.value.name).toBe('Alice');
|
|
563
|
+
expect(result.value.age).toBe(30);
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it('returns { ok: false, error, rawOutput } on validation failure (VAL-API-007)', async () => {
|
|
568
|
+
globalThis.fetch = mock(async () =>
|
|
569
|
+
new Response(JSON.stringify({
|
|
570
|
+
model: 'test-model',
|
|
571
|
+
created_at: new Date().toISOString(),
|
|
572
|
+
message: { role: 'assistant', content: '{"name": "Alice", "age": "wrong type"}' },
|
|
573
|
+
done: true,
|
|
574
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
575
|
+
) as typeof fetch;
|
|
576
|
+
|
|
577
|
+
const model = new AIModel(createTestConfig());
|
|
578
|
+
const result = await model.tryParseStructured(fromZod(UserSchema), [
|
|
579
|
+
{ role: 'user', content: 'Generate a user' },
|
|
580
|
+
]);
|
|
581
|
+
|
|
582
|
+
expect(result.ok).toBe(false);
|
|
583
|
+
if (!result.ok) {
|
|
584
|
+
expect(result.error).toBeInstanceOf(StructuredOutputError);
|
|
585
|
+
expect(result.rawOutput).toBe('{"name": "Alice", "age": "wrong type"}');
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('returns { ok: false, ... } on malformed JSON (VAL-API-007)', async () => {
|
|
590
|
+
globalThis.fetch = mock(async () =>
|
|
591
|
+
new Response(JSON.stringify({
|
|
592
|
+
model: 'test-model',
|
|
593
|
+
created_at: new Date().toISOString(),
|
|
594
|
+
message: { role: 'assistant', content: 'not valid json at all' },
|
|
595
|
+
done: true,
|
|
596
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
597
|
+
) as typeof fetch;
|
|
598
|
+
|
|
599
|
+
const model = new AIModel(createTestConfig());
|
|
600
|
+
const result = await model.tryParseStructured(fromZod(UserSchema), [
|
|
601
|
+
{ role: 'user', content: 'Generate a user' },
|
|
602
|
+
]);
|
|
603
|
+
|
|
604
|
+
expect(result.ok).toBe(false);
|
|
605
|
+
if (!result.ok) {
|
|
606
|
+
expect(result.error).toBeInstanceOf(StructuredOutputError);
|
|
607
|
+
expect(result.rawOutput).toBe('not valid json at all');
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it('never throws, always returns result object (VAL-API-007)', async () => {
|
|
612
|
+
// Test with valid response
|
|
613
|
+
globalThis.fetch = mock(async () =>
|
|
614
|
+
new Response(JSON.stringify({
|
|
615
|
+
model: 'test-model',
|
|
616
|
+
created_at: new Date().toISOString(),
|
|
617
|
+
message: { role: 'assistant', content: '{"name": "Test", "age": 25}' },
|
|
618
|
+
done: true,
|
|
619
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
620
|
+
) as typeof fetch;
|
|
621
|
+
|
|
622
|
+
const model = new AIModel(createTestConfig());
|
|
623
|
+
|
|
624
|
+
// Should not throw on success
|
|
625
|
+
const result1 = await model.tryParseStructured(fromZod(UserSchema), [
|
|
626
|
+
{ role: 'user', content: 'Test' },
|
|
627
|
+
]);
|
|
628
|
+
expect(result1.ok).toBe(true);
|
|
629
|
+
|
|
630
|
+
// Now test with invalid response - need to re-mock
|
|
631
|
+
globalThis.fetch = mock(async () =>
|
|
632
|
+
new Response(JSON.stringify({
|
|
633
|
+
model: 'test-model',
|
|
634
|
+
created_at: new Date().toISOString(),
|
|
635
|
+
message: { role: 'assistant', content: 'invalid' },
|
|
636
|
+
done: true,
|
|
637
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
638
|
+
) as typeof fetch;
|
|
639
|
+
|
|
640
|
+
// Should not throw on failure
|
|
641
|
+
const result2 = await model.tryParseStructured(fromZod(UserSchema), [
|
|
642
|
+
{ role: 'user', content: 'Test' },
|
|
643
|
+
]);
|
|
644
|
+
expect(result2.ok).toBe(false);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it('includes raw output in failure result for debugging', async () => {
|
|
648
|
+
globalThis.fetch = mock(async () =>
|
|
649
|
+
new Response(JSON.stringify({
|
|
650
|
+
model: 'test-model',
|
|
651
|
+
created_at: new Date().toISOString(),
|
|
652
|
+
message: { role: 'assistant', content: '{"unexpected": "structure"}' },
|
|
653
|
+
done: true,
|
|
654
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
655
|
+
) as typeof fetch;
|
|
656
|
+
|
|
657
|
+
const model = new AIModel(createTestConfig());
|
|
658
|
+
const result = await model.tryParseStructured(fromZod(UserSchema), [
|
|
659
|
+
{ role: 'user', content: 'Generate a user' },
|
|
660
|
+
]);
|
|
661
|
+
|
|
662
|
+
expect(result.ok).toBe(false);
|
|
663
|
+
if (!result.ok) {
|
|
664
|
+
expect(result.rawOutput).toBe('{"unexpected": "structure"}');
|
|
665
|
+
expect(result.error.message).toContain('Validation failed');
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it('handles options (temperature, maxTokens)', async () => {
|
|
670
|
+
let capturedBody: Record<string, unknown> | undefined;
|
|
671
|
+
globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
|
|
672
|
+
if (init?.body) {
|
|
673
|
+
capturedBody = JSON.parse(init.body as string);
|
|
674
|
+
}
|
|
675
|
+
return new Response(JSON.stringify({
|
|
676
|
+
model: 'test-model',
|
|
677
|
+
created_at: new Date().toISOString(),
|
|
678
|
+
message: { role: 'assistant', content: '{"name": "Test", "age": 20}' },
|
|
679
|
+
done: true,
|
|
680
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
681
|
+
}) as typeof fetch;
|
|
682
|
+
|
|
683
|
+
const model = new AIModel(createTestConfig());
|
|
684
|
+
const result = await model.tryParseStructured(fromZod(UserSchema), [
|
|
685
|
+
{ role: 'user', content: 'Generate a user' },
|
|
686
|
+
], { temperature: 0.3, maxTokens: 50 });
|
|
687
|
+
|
|
688
|
+
expect(result.ok).toBe(true);
|
|
689
|
+
// Verify options were passed to provider
|
|
690
|
+
expect(capturedBody?.options).toBeDefined();
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
it('works with failover across providers', async () => {
|
|
694
|
+
let firstProviderCalled = false;
|
|
695
|
+
globalThis.fetch = mock(async (url: string | URL | Request) => {
|
|
696
|
+
const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
697
|
+
// First provider (Ollama) fails
|
|
698
|
+
if (urlStr.includes('11434') && !firstProviderCalled) {
|
|
699
|
+
firstProviderCalled = true;
|
|
700
|
+
return new Response('Server Error', { status: 500 });
|
|
701
|
+
}
|
|
702
|
+
// Second provider succeeds
|
|
703
|
+
return new Response(JSON.stringify({
|
|
704
|
+
id: 'chatcmpl-test',
|
|
705
|
+
object: 'chat.completion',
|
|
706
|
+
created: Date.now(),
|
|
707
|
+
model: 'test-model',
|
|
708
|
+
choices: [{
|
|
709
|
+
index: 0,
|
|
710
|
+
message: { role: 'assistant', content: '{"name": "Failover", "age": 99}' },
|
|
711
|
+
finish_reason: 'stop',
|
|
712
|
+
}],
|
|
713
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
714
|
+
}) as typeof fetch;
|
|
715
|
+
|
|
716
|
+
const model = new AIModel(createTestConfig({
|
|
717
|
+
retries: 0,
|
|
718
|
+
providers: [
|
|
719
|
+
{ type: 'ollama', url: 'http://localhost:11434' },
|
|
720
|
+
{ type: 'openai', url: 'http://localhost:8080/v1', apiKey: 'test' },
|
|
721
|
+
],
|
|
722
|
+
}));
|
|
723
|
+
|
|
724
|
+
const result = await model.tryParseStructured(fromZod(UserSchema), [
|
|
725
|
+
{ role: 'user', content: 'Generate a user' },
|
|
726
|
+
]);
|
|
727
|
+
|
|
728
|
+
expect(result.ok).toBe(true);
|
|
729
|
+
if (result.ok) {
|
|
730
|
+
expect(result.value.name).toBe('Failover');
|
|
731
|
+
expect(result.value.age).toBe(99);
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
// ========================================================================
|
|
737
|
+
// Chat with output parameter Tests (VAL-API-004, VAL-API-005)
|
|
738
|
+
// ========================================================================
|
|
739
|
+
|
|
740
|
+
describe('chat with output parameter', () => {
|
|
741
|
+
const UserSchema = z.object({
|
|
742
|
+
name: z.string(),
|
|
743
|
+
age: z.number(),
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
it('returns response with structured property when output is provided (VAL-API-004)', async () => {
|
|
747
|
+
globalThis.fetch = mock(async () =>
|
|
748
|
+
new Response(JSON.stringify({
|
|
749
|
+
model: 'test-model',
|
|
750
|
+
created_at: new Date().toISOString(),
|
|
751
|
+
message: { role: 'assistant', content: '{"name": "Alice", "age": 30}' },
|
|
752
|
+
done: true,
|
|
753
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
754
|
+
) as typeof fetch;
|
|
755
|
+
|
|
756
|
+
const model = new AIModel(createTestConfig());
|
|
757
|
+
const response = await model.chat([
|
|
758
|
+
{ role: 'user', content: 'Generate a user' },
|
|
759
|
+
], {
|
|
760
|
+
output: { schema: fromZod(UserSchema) },
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
// Response should have both message.content and structured property
|
|
764
|
+
expect(response.message.content).toBe('{"name": "Alice", "age": 30}');
|
|
765
|
+
expect(response.structured).toBeDefined();
|
|
766
|
+
expect(response.structured?.name).toBe('Alice');
|
|
767
|
+
expect(response.structured?.age).toBe(30);
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
it('returns structured property with type inference from schema', async () => {
|
|
771
|
+
globalThis.fetch = mock(async () =>
|
|
772
|
+
new Response(JSON.stringify({
|
|
773
|
+
model: 'test-model',
|
|
774
|
+
created_at: new Date().toISOString(),
|
|
775
|
+
message: { role: 'assistant', content: '{"name": "Bob", "age": 25}' },
|
|
776
|
+
done: true,
|
|
777
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
778
|
+
) as typeof fetch;
|
|
779
|
+
|
|
780
|
+
const model = new AIModel(createTestConfig());
|
|
781
|
+
const response = await model.chat([
|
|
782
|
+
{ role: 'user', content: 'Generate a user' },
|
|
783
|
+
], {
|
|
784
|
+
output: { schema: fromZod(UserSchema) },
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
// Type check: response.structured should be typed correctly
|
|
788
|
+
if (response.structured) {
|
|
789
|
+
const name: string = response.structured.name;
|
|
790
|
+
const age: number = response.structured.age;
|
|
791
|
+
expect(name).toBe('Bob');
|
|
792
|
+
expect(age).toBe(25);
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
it('output parameter with name and description', async () => {
|
|
797
|
+
globalThis.fetch = mock(async () =>
|
|
798
|
+
new Response(JSON.stringify({
|
|
799
|
+
model: 'test-model',
|
|
800
|
+
created_at: new Date().toISOString(),
|
|
801
|
+
message: { role: 'assistant', content: '{"name": "Charlie", "age": 40}' },
|
|
802
|
+
done: true,
|
|
803
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
804
|
+
) as typeof fetch;
|
|
805
|
+
|
|
806
|
+
const model = new AIModel(createTestConfig());
|
|
807
|
+
const response = await model.chat([
|
|
808
|
+
{ role: 'user', content: 'Generate a user' },
|
|
809
|
+
], {
|
|
810
|
+
output: {
|
|
811
|
+
schema: fromZod(UserSchema),
|
|
812
|
+
name: 'User',
|
|
813
|
+
description: 'A user object',
|
|
814
|
+
},
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
expect(response.structured?.name).toBe('Charlie');
|
|
818
|
+
expect(response.structured?.age).toBe(40);
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
it('throws StructuredOutputError when response fails validation', async () => {
|
|
822
|
+
globalThis.fetch = mock(async () =>
|
|
823
|
+
new Response(JSON.stringify({
|
|
824
|
+
model: 'test-model',
|
|
825
|
+
created_at: new Date().toISOString(),
|
|
826
|
+
message: { role: 'assistant', content: '{"name": "Alice", "age": "not a number"}' },
|
|
827
|
+
done: true,
|
|
828
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
829
|
+
) as typeof fetch;
|
|
830
|
+
|
|
831
|
+
const model = new AIModel(createTestConfig());
|
|
832
|
+
|
|
833
|
+
expect(
|
|
834
|
+
model.chat([{ role: 'user', content: 'Generate a user' }], {
|
|
835
|
+
output: { schema: fromZod(UserSchema) },
|
|
836
|
+
})
|
|
837
|
+
).rejects.toThrow(StructuredOutputError);
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
it('structured is undefined when output is not provided', async () => {
|
|
841
|
+
globalThis.fetch = mock(async () =>
|
|
842
|
+
new Response(JSON.stringify({
|
|
843
|
+
model: 'test-model',
|
|
844
|
+
created_at: new Date().toISOString(),
|
|
845
|
+
message: { role: 'assistant', content: 'Hello world' },
|
|
846
|
+
done: true,
|
|
847
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
848
|
+
) as typeof fetch;
|
|
849
|
+
|
|
850
|
+
const model = new AIModel(createTestConfig());
|
|
851
|
+
const response = await model.chat([
|
|
852
|
+
{ role: 'user', content: 'Hello' },
|
|
853
|
+
]);
|
|
854
|
+
|
|
855
|
+
expect(response.structured).toBeUndefined();
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it('works with all providers (OpenAI)', async () => {
|
|
859
|
+
globalThis.fetch = mock(async () =>
|
|
860
|
+
new Response(JSON.stringify({
|
|
861
|
+
id: 'chatcmpl-test',
|
|
862
|
+
object: 'chat.completion',
|
|
863
|
+
created: Date.now(),
|
|
864
|
+
model: 'test-model',
|
|
865
|
+
choices: [{
|
|
866
|
+
index: 0,
|
|
867
|
+
message: { role: 'assistant', content: '{"name": "OpenAI", "age": 35}' },
|
|
868
|
+
finish_reason: 'stop',
|
|
869
|
+
}],
|
|
870
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
871
|
+
) as typeof fetch;
|
|
872
|
+
|
|
873
|
+
const model = new AIModel(createTestConfig({
|
|
874
|
+
providers: [{ type: 'openai', apiKey: 'sk-test' }],
|
|
875
|
+
}));
|
|
876
|
+
|
|
877
|
+
const response = await model.chat(
|
|
878
|
+
[{ role: 'user', content: 'Generate a user' }],
|
|
879
|
+
{ output: { schema: fromZod(UserSchema) } },
|
|
880
|
+
);
|
|
881
|
+
|
|
882
|
+
expect(response.structured?.name).toBe('OpenAI');
|
|
883
|
+
expect(response.structured?.age).toBe(35);
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
it('works with all providers (Google)', async () => {
|
|
887
|
+
globalThis.fetch = mock(async () =>
|
|
888
|
+
new Response(JSON.stringify({
|
|
889
|
+
candidates: [{
|
|
890
|
+
content: {
|
|
891
|
+
parts: [{ text: '{"name": "Google", "age": 20}' }],
|
|
892
|
+
role: 'model',
|
|
893
|
+
},
|
|
894
|
+
finishReason: 'STOP',
|
|
895
|
+
index: 0,
|
|
896
|
+
}],
|
|
897
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
898
|
+
) as typeof fetch;
|
|
899
|
+
|
|
900
|
+
const model = new AIModel(createTestConfig({
|
|
901
|
+
providers: [{ type: 'google', apiKey: 'test-key' }],
|
|
902
|
+
}));
|
|
903
|
+
|
|
904
|
+
const response = await model.chat(
|
|
905
|
+
[{ role: 'user', content: 'Generate a user' }],
|
|
906
|
+
{ output: { schema: fromZod(UserSchema) } },
|
|
907
|
+
);
|
|
908
|
+
|
|
909
|
+
expect(response.structured?.name).toBe('Google');
|
|
910
|
+
expect(response.structured?.age).toBe(20);
|
|
911
|
+
});
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
describe('output and tools combined usage (VAL-API-005)', () => {
|
|
915
|
+
const TestSchema = z.object({
|
|
916
|
+
result: z.string(),
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
it('allows both output and tools in the same request', async () => {
|
|
920
|
+
globalThis.fetch = mock(async () =>
|
|
921
|
+
new Response(JSON.stringify({
|
|
922
|
+
model: 'test-model',
|
|
923
|
+
created_at: new Date().toISOString(),
|
|
924
|
+
message: { role: 'assistant', content: '{"result": "structured response"}' },
|
|
925
|
+
done: true,
|
|
926
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
927
|
+
) as typeof fetch;
|
|
928
|
+
|
|
929
|
+
const model = new AIModel(createTestConfig());
|
|
930
|
+
const response = await model.chat([{ role: 'user', content: 'test' }], {
|
|
931
|
+
output: { schema: fromZod(TestSchema) },
|
|
932
|
+
tools: [{ type: 'function', function: { name: 'test', description: 'test', parameters: { type: 'object' } } }],
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
// When the model returns content (not tool calls), structured output should be validated
|
|
936
|
+
expect(response.structured?.result).toBe('structured response');
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
it('skips validation when response contains tool calls', async () => {
|
|
940
|
+
globalThis.fetch = mock(async () =>
|
|
941
|
+
new Response(JSON.stringify({
|
|
942
|
+
model: 'test-model',
|
|
943
|
+
created_at: new Date().toISOString(),
|
|
944
|
+
message: {
|
|
945
|
+
role: 'assistant',
|
|
946
|
+
content: '',
|
|
947
|
+
tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'test', arguments: '{}' } }],
|
|
948
|
+
},
|
|
949
|
+
done: true,
|
|
950
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
951
|
+
) as typeof fetch;
|
|
952
|
+
|
|
953
|
+
const model = new AIModel(createTestConfig());
|
|
954
|
+
const response = await model.chat([{ role: 'user', content: 'test' }], {
|
|
955
|
+
output: { schema: fromZod(TestSchema) },
|
|
956
|
+
tools: [{ type: 'function', function: { name: 'test', description: 'test', parameters: { type: 'object' } } }],
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
// Response should contain tool calls without throwing validation errors
|
|
960
|
+
expect(response.message.tool_calls).toBeDefined();
|
|
961
|
+
expect(response.message.tool_calls!.length).toBe(1);
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
it('works with output only (no tools)', async () => {
|
|
965
|
+
globalThis.fetch = mock(async () =>
|
|
966
|
+
new Response(JSON.stringify({
|
|
967
|
+
model: 'test-model',
|
|
968
|
+
created_at: new Date().toISOString(),
|
|
969
|
+
message: { role: 'assistant', content: '{"result": "success"}' },
|
|
970
|
+
done: true,
|
|
971
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
972
|
+
) as typeof fetch;
|
|
973
|
+
|
|
974
|
+
const model = new AIModel(createTestConfig());
|
|
975
|
+
const response = await model.chat([{ role: 'user', content: 'test' }], {
|
|
976
|
+
output: { schema: fromZod(TestSchema) },
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
expect(response.structured?.result).toBe('success');
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
it('works with tools only (no output)', async () => {
|
|
983
|
+
globalThis.fetch = mock(async () =>
|
|
984
|
+
new Response(JSON.stringify({
|
|
985
|
+
model: 'test-model',
|
|
986
|
+
created_at: new Date().toISOString(),
|
|
987
|
+
message: { role: 'assistant', content: 'Using tool...' },
|
|
988
|
+
done: true,
|
|
989
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
990
|
+
) as typeof fetch;
|
|
991
|
+
|
|
992
|
+
const model = new AIModel(createTestConfig());
|
|
993
|
+
const response = await model.chat([{ role: 'user', content: 'test' }], {
|
|
994
|
+
tools: [{ type: 'function', function: { name: 'test', description: 'test', parameters: { type: 'object' } } }],
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
expect(response.message.content).toBe('Using tool...');
|
|
998
|
+
expect(response.structured).toBeUndefined();
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
it('allows output with empty tools array (no tools)', async () => {
|
|
1002
|
+
globalThis.fetch = mock(async () =>
|
|
1003
|
+
new Response(JSON.stringify({
|
|
1004
|
+
model: 'test-model',
|
|
1005
|
+
created_at: new Date().toISOString(),
|
|
1006
|
+
message: { role: 'assistant', content: '{"result": "success"}' },
|
|
1007
|
+
done: true,
|
|
1008
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
1009
|
+
) as typeof fetch;
|
|
1010
|
+
|
|
1011
|
+
const model = new AIModel(createTestConfig());
|
|
1012
|
+
|
|
1013
|
+
// Empty tools array should be allowed with output (no actual tools)
|
|
1014
|
+
const response = await model.chat([{ role: 'user', content: 'test' }], {
|
|
1015
|
+
output: { schema: fromZod(TestSchema) },
|
|
1016
|
+
tools: [],
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
expect(response.structured?.result).toBe('success');
|
|
1020
|
+
});
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
describe('generateStructuredStream', () => {
|
|
1024
|
+
const UserSchema = z.object({
|
|
1025
|
+
name: z.string(),
|
|
1026
|
+
age: z.number(),
|
|
1027
|
+
email: z.string().optional(),
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
it('yields partial validated objects during streaming (VAL-API-008)', async () => {
|
|
1031
|
+
// Mock streaming response that yields chunks
|
|
1032
|
+
const chunks = [
|
|
1033
|
+
'data: {"choices":[{"delta":{"content":"{\\"name\\":"}}]}\n\n',
|
|
1034
|
+
'data: {"choices":[{"delta":{"content":"\\"Alice\\""}}]}\n\n',
|
|
1035
|
+
'data: {"choices":[{"delta":{"content":", \\"age\\":30"}}]}\n\n',
|
|
1036
|
+
'data: {"choices":[{"delta":{"content":"}"}}]}\n\n',
|
|
1037
|
+
'data: [DONE]\n\n',
|
|
1038
|
+
];
|
|
1039
|
+
|
|
1040
|
+
let chunkIndex = 0;
|
|
1041
|
+
globalThis.fetch = mock(async () => {
|
|
1042
|
+
return new Response(chunks.join(''), {
|
|
1043
|
+
status: 200,
|
|
1044
|
+
headers: { 'Content-Type': 'text/event-stream' },
|
|
1045
|
+
});
|
|
1046
|
+
}) as typeof fetch;
|
|
1047
|
+
|
|
1048
|
+
const model = new AIModel(createTestConfig({
|
|
1049
|
+
providers: [{ type: 'openai', apiKey: 'sk-test' }],
|
|
1050
|
+
}));
|
|
1051
|
+
|
|
1052
|
+
const partials: unknown[] = [];
|
|
1053
|
+
const stream = model.generateStructuredStream(fromZod(UserSchema), [
|
|
1054
|
+
{ role: 'user', content: 'Generate a user' },
|
|
1055
|
+
]);
|
|
1056
|
+
|
|
1057
|
+
for await (const partial of stream) {
|
|
1058
|
+
partials.push(partial);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// Should have yielded partial objects
|
|
1062
|
+
expect(partials.length).toBeGreaterThan(0);
|
|
1063
|
+
|
|
1064
|
+
// Final partial should match schema
|
|
1065
|
+
const lastPartial = partials[partials.length - 1];
|
|
1066
|
+
expect(lastPartial).toHaveProperty('name');
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
it('returns complete validated object as generator return value (VAL-API-008)', async () => {
|
|
1070
|
+
// Mock streaming response - produce valid JSON: {"name": "Bob", "age": 25}
|
|
1071
|
+
const chunks = [
|
|
1072
|
+
'data: {"choices":[{"delta":{"content":"{\\"name\\":"}}]}\n\n',
|
|
1073
|
+
'data: {"choices":[{"delta":{"content":" \\"Bob\\""}}]}\n\n',
|
|
1074
|
+
'data: {"choices":[{"delta":{"content":", \\"age\\": 25"}}]}\n\n',
|
|
1075
|
+
'data: {"choices":[{"delta":{"content":"}"}}]}\n\n',
|
|
1076
|
+
'data: [DONE]\n\n',
|
|
1077
|
+
];
|
|
1078
|
+
|
|
1079
|
+
globalThis.fetch = mock(async () => {
|
|
1080
|
+
return new Response(chunks.join(''), {
|
|
1081
|
+
status: 200,
|
|
1082
|
+
headers: { 'Content-Type': 'text/event-stream' },
|
|
1083
|
+
});
|
|
1084
|
+
}) as typeof fetch;
|
|
1085
|
+
|
|
1086
|
+
const model = new AIModel(createTestConfig({
|
|
1087
|
+
providers: [{ type: 'openai', apiKey: 'sk-test' }],
|
|
1088
|
+
}));
|
|
1089
|
+
|
|
1090
|
+
const stream = model.generateStructuredStream(fromZod(UserSchema), [
|
|
1091
|
+
{ role: 'user', content: 'Generate a user' },
|
|
1092
|
+
]);
|
|
1093
|
+
|
|
1094
|
+
// Consume the stream
|
|
1095
|
+
const partials: unknown[] = [];
|
|
1096
|
+
for await (const partial of stream) {
|
|
1097
|
+
partials.push(partial);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// Verify we got partials
|
|
1101
|
+
expect(partials.length).toBeGreaterThan(0);
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
it('handles validation errors mid-stream gracefully', async () => {
|
|
1105
|
+
// Mock streaming response with invalid data mid-stream that becomes valid
|
|
1106
|
+
const chunks = [
|
|
1107
|
+
'data: {"choices":[{"delta":{"content":"{\\"name\\":"}}]}\n\n',
|
|
1108
|
+
'data: {"choices":[{"delta":{"content":"\\"test\\""}}]}\n\n',
|
|
1109
|
+
'data: {"choices":[{"delta":{"content":", \\"age\\": \\"invalid\\""}}]}\n\n', // age should be number
|
|
1110
|
+
'data: {"choices":[{"delta":{"content":\\"}"}}]}\n\n',
|
|
1111
|
+
'data: [DONE]\n\n',
|
|
1112
|
+
];
|
|
1113
|
+
|
|
1114
|
+
globalThis.fetch = mock(async () => {
|
|
1115
|
+
return new Response(chunks.join(''), {
|
|
1116
|
+
status: 200,
|
|
1117
|
+
headers: { 'Content-Type': 'text/event-stream' },
|
|
1118
|
+
});
|
|
1119
|
+
}) as typeof fetch;
|
|
1120
|
+
|
|
1121
|
+
const model = new AIModel(createTestConfig({
|
|
1122
|
+
providers: [{ type: 'openai', apiKey: 'sk-test' }],
|
|
1123
|
+
}));
|
|
1124
|
+
|
|
1125
|
+
const stream = model.generateStructuredStream(fromZod(UserSchema), [
|
|
1126
|
+
{ role: 'user', content: 'Generate a user' },
|
|
1127
|
+
]);
|
|
1128
|
+
|
|
1129
|
+
// Consume the stream - should either yield valid partials or throw at the end
|
|
1130
|
+
const partials: unknown[] = [];
|
|
1131
|
+
try {
|
|
1132
|
+
for await (const partial of stream) {
|
|
1133
|
+
partials.push(partial);
|
|
1134
|
+
}
|
|
1135
|
+
// If no error was thrown, check that we handled it gracefully
|
|
1136
|
+
expect(partials.length).toBeGreaterThanOrEqual(0);
|
|
1137
|
+
} catch (error) {
|
|
1138
|
+
// If error is thrown, it should be StructuredOutputError
|
|
1139
|
+
expect(error).toBeInstanceOf(StructuredOutputError);
|
|
1140
|
+
}
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
it('works with Ollama provider (VAL-PROVIDER-OLLAMA-005)', async () => {
|
|
1144
|
+
// Mock NDJSON streaming response from Ollama
|
|
1145
|
+
const chunks = [
|
|
1146
|
+
JSON.stringify({ model: 'test', message: { content: '{"name":' }, done: false }) + '\n',
|
|
1147
|
+
JSON.stringify({ model: 'test', message: { content: ' "Ollama",' }, done: false }) + '\n',
|
|
1148
|
+
JSON.stringify({ model: 'test', message: { content: ' "age": 35' }, done: false }) + '\n',
|
|
1149
|
+
JSON.stringify({ model: 'test', message: { content: '}' }, done: true }) + '\n',
|
|
1150
|
+
];
|
|
1151
|
+
|
|
1152
|
+
globalThis.fetch = mock(async () => {
|
|
1153
|
+
return new Response(chunks.join(''), {
|
|
1154
|
+
status: 200,
|
|
1155
|
+
headers: { 'Content-Type': 'application/x-ndjson' },
|
|
1156
|
+
});
|
|
1157
|
+
}) as typeof fetch;
|
|
1158
|
+
|
|
1159
|
+
const model = new AIModel(createTestConfig());
|
|
1160
|
+
|
|
1161
|
+
const stream = model.generateStructuredStream(fromZod(UserSchema), [
|
|
1162
|
+
{ role: 'user', content: 'Generate a user' },
|
|
1163
|
+
]);
|
|
1164
|
+
|
|
1165
|
+
const partials: unknown[] = [];
|
|
1166
|
+
for await (const partial of stream) {
|
|
1167
|
+
partials.push(partial);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// Should have yielded partials
|
|
1171
|
+
expect(partials.length).toBeGreaterThan(0);
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
it('works with Google provider (VAL-PROVIDER-GOOGLE-005)', async () => {
|
|
1175
|
+
// Mock SSE streaming response from Google
|
|
1176
|
+
const chunks = [
|
|
1177
|
+
'data: {"candidates":[{"content":{"parts":[{"text":"{"}]}}]}\n\n',
|
|
1178
|
+
'data: {"candidates":[{"content":{"parts":[{"text":"\\"name\\": \\"Google\\""}]}}]}\n\n',
|
|
1179
|
+
'data: {"candidates":[{"content":{"parts":[{"text":", \\"age\\": 42"}]}}]}\n\n',
|
|
1180
|
+
'data: {"candidates":[{"content":{"parts":[{"text":"}"}]}}]}\n\n',
|
|
1181
|
+
'data: [DONE]\n\n',
|
|
1182
|
+
];
|
|
1183
|
+
|
|
1184
|
+
globalThis.fetch = mock(async () => {
|
|
1185
|
+
return new Response(chunks.join(''), {
|
|
1186
|
+
status: 200,
|
|
1187
|
+
headers: { 'Content-Type': 'text/event-stream' },
|
|
1188
|
+
});
|
|
1189
|
+
}) as typeof fetch;
|
|
1190
|
+
|
|
1191
|
+
const model = new AIModel(createTestConfig({
|
|
1192
|
+
providers: [{ type: 'google', apiKey: 'test-key' }],
|
|
1193
|
+
}));
|
|
1194
|
+
|
|
1195
|
+
const stream = model.generateStructuredStream(fromZod(UserSchema), [
|
|
1196
|
+
{ role: 'user', content: 'Generate a user' },
|
|
1197
|
+
]);
|
|
1198
|
+
|
|
1199
|
+
const partials: unknown[] = [];
|
|
1200
|
+
for await (const partial of stream) {
|
|
1201
|
+
partials.push(partial);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// Should have yielded partials
|
|
1205
|
+
expect(partials.length).toBeGreaterThan(0);
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
it('accepts ChatOptions (temperature, maxTokens)', async () => {
|
|
1209
|
+
// Mock streaming response
|
|
1210
|
+
const chunks = [
|
|
1211
|
+
'data: {"choices":[{"delta":{"content":"{\\"name\\": \\"Test\\""}}]}\n\n',
|
|
1212
|
+
'data: {"choices":[{"delta":{"content":", \\"age\\": 1}"}}]}\n\n',
|
|
1213
|
+
'data: [DONE]\n\n',
|
|
1214
|
+
];
|
|
1215
|
+
|
|
1216
|
+
globalThis.fetch = mock(async () => {
|
|
1217
|
+
return new Response(chunks.join(''), {
|
|
1218
|
+
status: 200,
|
|
1219
|
+
headers: { 'Content-Type': 'text/event-stream' },
|
|
1220
|
+
});
|
|
1221
|
+
}) as typeof fetch;
|
|
1222
|
+
|
|
1223
|
+
const model = new AIModel(createTestConfig({
|
|
1224
|
+
providers: [{ type: 'openai', apiKey: 'sk-test' }],
|
|
1225
|
+
}));
|
|
1226
|
+
|
|
1227
|
+
const stream = model.generateStructuredStream(fromZod(UserSchema), [
|
|
1228
|
+
{ role: 'user', content: 'Generate a user' },
|
|
1229
|
+
], { temperature: 0.5, maxTokens: 100 });
|
|
1230
|
+
|
|
1231
|
+
// Consume the stream
|
|
1232
|
+
const partials: unknown[] = [];
|
|
1233
|
+
for await (const partial of stream) {
|
|
1234
|
+
partials.push(partial);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
expect(partials.length).toBeGreaterThan(0);
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
it('throws StructuredOutputError on final validation failure', async () => {
|
|
1241
|
+
// Mock streaming response that ends with invalid JSON
|
|
1242
|
+
const chunks = [
|
|
1243
|
+
'data: {"choices":[{"delta":{"content":"{\\"name\\":"}}]}\n\n',
|
|
1244
|
+
'data: {"choices":[{"delta":{"content":"\\"test\\""}}]}\n\n',
|
|
1245
|
+
'data: {"choices":[{"delta":{"content":"}"}}]}\n\n', // Missing age (required by schema)
|
|
1246
|
+
'data: [DONE]\n\n',
|
|
1247
|
+
];
|
|
1248
|
+
|
|
1249
|
+
globalThis.fetch = mock(async () => {
|
|
1250
|
+
return new Response(chunks.join(''), {
|
|
1251
|
+
status: 200,
|
|
1252
|
+
headers: { 'Content-Type': 'text/event-stream' },
|
|
1253
|
+
});
|
|
1254
|
+
}) as typeof fetch;
|
|
1255
|
+
|
|
1256
|
+
const model = new AIModel(createTestConfig({
|
|
1257
|
+
providers: [{ type: 'openai', apiKey: 'sk-test' }],
|
|
1258
|
+
}));
|
|
1259
|
+
|
|
1260
|
+
const StrictSchema = z.object({
|
|
1261
|
+
name: z.string(),
|
|
1262
|
+
age: z.number(), // Required
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
const stream = model.generateStructuredStream(fromZod(StrictSchema), [
|
|
1266
|
+
{ role: 'user', content: 'Generate a user' },
|
|
1267
|
+
]);
|
|
1268
|
+
|
|
1269
|
+
// Consume the stream - expect StructuredOutputError at the end
|
|
1270
|
+
let errorCaught: Error | null = null;
|
|
1271
|
+
try {
|
|
1272
|
+
for await (const _ of stream) {
|
|
1273
|
+
// Consume partials
|
|
1274
|
+
}
|
|
1275
|
+
} catch (error) {
|
|
1276
|
+
errorCaught = error as Error;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// Should either gracefully handle partial objects or throw validation error
|
|
1280
|
+
// Both behaviors are acceptable
|
|
1281
|
+
if (errorCaught) {
|
|
1282
|
+
expect(errorCaught).toBeInstanceOf(StructuredOutputError);
|
|
1283
|
+
}
|
|
1284
|
+
});
|
|
1285
|
+
});
|
|
1286
|
+
});
|
|
1287
|
+
|
|
1288
|
+
// ============================================================================
|
|
1289
|
+
// Structured Output Auditor Tests (VAL-CROSS-003)
|
|
1290
|
+
// ============================================================================
|
|
1291
|
+
|
|
1292
|
+
describe('structured output auditor events', () => {
|
|
1293
|
+
const TestSchema = z.object({
|
|
1294
|
+
name: z.string(),
|
|
1295
|
+
value: z.number(),
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
const originalFetch = globalThis.fetch;
|
|
1299
|
+
|
|
1300
|
+
afterEach(() => {
|
|
1301
|
+
globalThis.fetch = originalFetch;
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
describe('generateStructured auditor events', () => {
|
|
1305
|
+
it('emits structured_request and structured_response events on success', async () => {
|
|
1306
|
+
const auditor = new BufferedAuditor();
|
|
1307
|
+
globalThis.fetch = mock(async () =>
|
|
1308
|
+
new Response(JSON.stringify({
|
|
1309
|
+
model: 'test-model',
|
|
1310
|
+
created_at: new Date().toISOString(),
|
|
1311
|
+
message: { role: 'assistant', content: '{"name": "Alice", "value": 42}' },
|
|
1312
|
+
done: true,
|
|
1313
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
1314
|
+
) as typeof fetch;
|
|
1315
|
+
|
|
1316
|
+
const model = new AIModel(createTestConfig({ auditor }));
|
|
1317
|
+
await model.generateStructured(fromZod(TestSchema), [
|
|
1318
|
+
{ role: 'user', content: 'Generate data' },
|
|
1319
|
+
]);
|
|
1320
|
+
|
|
1321
|
+
const events = auditor.getEvents();
|
|
1322
|
+
const types = events.map(e => e.type);
|
|
1323
|
+
|
|
1324
|
+
// Should emit structured_request and structured_response
|
|
1325
|
+
expect(types).toContain('structured_request');
|
|
1326
|
+
expect(types).toContain('structured_response');
|
|
1327
|
+
|
|
1328
|
+
// Check structured_request event details
|
|
1329
|
+
const requestEvent = events.find(e => e.type === 'structured_request');
|
|
1330
|
+
expect(requestEvent?.schemaName).toBe('response');
|
|
1331
|
+
|
|
1332
|
+
// Check structured_response event details
|
|
1333
|
+
const responseEvent = events.find(e => e.type === 'structured_response');
|
|
1334
|
+
expect(responseEvent?.schemaName).toBe('response');
|
|
1335
|
+
expect(responseEvent?.duration).toBeDefined();
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
it('emits structured_validation_error on validation failure', async () => {
|
|
1339
|
+
const auditor = new BufferedAuditor();
|
|
1340
|
+
globalThis.fetch = mock(async () =>
|
|
1341
|
+
new Response(JSON.stringify({
|
|
1342
|
+
model: 'test-model',
|
|
1343
|
+
created_at: new Date().toISOString(),
|
|
1344
|
+
message: { role: 'assistant', content: '{"name": "Alice", "value": "not a number"}' },
|
|
1345
|
+
done: true,
|
|
1346
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
1347
|
+
) as typeof fetch;
|
|
1348
|
+
|
|
1349
|
+
const model = new AIModel(createTestConfig({ auditor }));
|
|
1350
|
+
|
|
1351
|
+
try {
|
|
1352
|
+
await model.generateStructured(fromZod(TestSchema), [
|
|
1353
|
+
{ role: 'user', content: 'Generate data' },
|
|
1354
|
+
]);
|
|
1355
|
+
} catch {
|
|
1356
|
+
// Expected to throw StructuredOutputError
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
const events = auditor.getEvents();
|
|
1360
|
+
const types = events.map(e => e.type);
|
|
1361
|
+
|
|
1362
|
+
// Should emit structured_request and structured_validation_error
|
|
1363
|
+
expect(types).toContain('structured_request');
|
|
1364
|
+
expect(types).toContain('structured_validation_error');
|
|
1365
|
+
|
|
1366
|
+
// Check structured_validation_error event details
|
|
1367
|
+
const validationError = events.find(e => e.type === 'structured_validation_error');
|
|
1368
|
+
expect(validationError?.schemaName).toBe('response');
|
|
1369
|
+
expect(validationError?.error).toBeDefined();
|
|
1370
|
+
expect(validationError?.rawOutput).toBeDefined();
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1373
|
+
it('uses custom schema name when provided', async () => {
|
|
1374
|
+
const auditor = new BufferedAuditor();
|
|
1375
|
+
globalThis.fetch = mock(async () =>
|
|
1376
|
+
new Response(JSON.stringify({
|
|
1377
|
+
model: 'test-model',
|
|
1378
|
+
created_at: new Date().toISOString(),
|
|
1379
|
+
message: { role: 'assistant', content: '{"name": "Test", "value": 1}' },
|
|
1380
|
+
done: true,
|
|
1381
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
1382
|
+
) as typeof fetch;
|
|
1383
|
+
|
|
1384
|
+
const model = new AIModel(createTestConfig({ auditor }));
|
|
1385
|
+
await model.generateStructured(fromZod(TestSchema), [
|
|
1386
|
+
{ role: 'user', content: 'Generate data' },
|
|
1387
|
+
], { schemaName: 'CustomSchema' });
|
|
1388
|
+
|
|
1389
|
+
const events = auditor.getEvents();
|
|
1390
|
+
|
|
1391
|
+
// Check structured_request event has custom schema name
|
|
1392
|
+
const requestEvent = events.find(e => e.type === 'structured_request');
|
|
1393
|
+
expect(requestEvent?.schemaName).toBe('CustomSchema');
|
|
1394
|
+
|
|
1395
|
+
// Check structured_response event has custom schema name
|
|
1396
|
+
const responseEvent = events.find(e => e.type === 'structured_response');
|
|
1397
|
+
expect(responseEvent?.schemaName).toBe('CustomSchema');
|
|
1398
|
+
});
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
describe('chat output parameter auditor events', () => {
|
|
1402
|
+
it('emits structured_request and structured_response with output parameter', async () => {
|
|
1403
|
+
const auditor = new BufferedAuditor();
|
|
1404
|
+
globalThis.fetch = mock(async () =>
|
|
1405
|
+
new Response(JSON.stringify({
|
|
1406
|
+
model: 'test-model',
|
|
1407
|
+
created_at: new Date().toISOString(),
|
|
1408
|
+
message: { role: 'assistant', content: '{"name": "Bob", "value": 100}' },
|
|
1409
|
+
done: true,
|
|
1410
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
1411
|
+
) as typeof fetch;
|
|
1412
|
+
|
|
1413
|
+
const model = new AIModel(createTestConfig({ auditor }));
|
|
1414
|
+
const response = await model.chat([
|
|
1415
|
+
{ role: 'user', content: 'Generate data' },
|
|
1416
|
+
], { output: { schema: fromZod(TestSchema), name: 'TestData' } });
|
|
1417
|
+
|
|
1418
|
+
const events = auditor.getEvents();
|
|
1419
|
+
const types = events.map(e => e.type);
|
|
1420
|
+
|
|
1421
|
+
// Should emit structured_request and structured_response
|
|
1422
|
+
expect(types).toContain('structured_request');
|
|
1423
|
+
expect(types).toContain('structured_response');
|
|
1424
|
+
|
|
1425
|
+
// Verify structured property is populated
|
|
1426
|
+
expect(response.structured?.name).toBe('Bob');
|
|
1427
|
+
expect(response.structured?.value).toBe(100);
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
it('emits structured_validation_error when output validation fails', async () => {
|
|
1431
|
+
const auditor = new BufferedAuditor();
|
|
1432
|
+
globalThis.fetch = mock(async () =>
|
|
1433
|
+
new Response(JSON.stringify({
|
|
1434
|
+
model: 'test-model',
|
|
1435
|
+
created_at: new Date().toISOString(),
|
|
1436
|
+
message: { role: 'assistant', content: '{"invalid": "data"}' },
|
|
1437
|
+
done: true,
|
|
1438
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
1439
|
+
) as typeof fetch;
|
|
1440
|
+
|
|
1441
|
+
const model = new AIModel(createTestConfig({ auditor }));
|
|
1442
|
+
|
|
1443
|
+
try {
|
|
1444
|
+
await model.chat([
|
|
1445
|
+
{ role: 'user', content: 'Generate data' },
|
|
1446
|
+
], { output: { schema: fromZod(TestSchema) } });
|
|
1447
|
+
} catch {
|
|
1448
|
+
// Expected to throw StructuredOutputError
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
const events = auditor.getEvents();
|
|
1452
|
+
const types = events.map(e => e.type);
|
|
1453
|
+
|
|
1454
|
+
expect(types).toContain('structured_request');
|
|
1455
|
+
expect(types).toContain('structured_validation_error');
|
|
1456
|
+
|
|
1457
|
+
const validationError = events.find(e => e.type === 'structured_validation_error');
|
|
1458
|
+
expect(validationError?.rawOutput).toBe('{"invalid": "data"}');
|
|
1459
|
+
});
|
|
1460
|
+
});
|
|
1461
|
+
|
|
1462
|
+
describe('generateStructuredStream auditor events', () => {
|
|
1463
|
+
it('emits structured_request on stream start and structured_response on completion', async () => {
|
|
1464
|
+
const auditor = new BufferedAuditor();
|
|
1465
|
+
const chunks = [
|
|
1466
|
+
'data: {"choices":[{"delta":{"content":"{\\"name\\":"}}]}\n\n',
|
|
1467
|
+
'data: {"choices":[{"delta":{"content":" \\"Stream\\""}}]}\n\n',
|
|
1468
|
+
'data: {"choices":[{"delta":{"content":", \\"value\\": 99"}}]}\n\n',
|
|
1469
|
+
'data: {"choices":[{"delta":{"content":"}"}}]}\n\n',
|
|
1470
|
+
'data: [DONE]\n\n',
|
|
1471
|
+
];
|
|
1472
|
+
|
|
1473
|
+
globalThis.fetch = mock(async () =>
|
|
1474
|
+
new Response(chunks.join(''), {
|
|
1475
|
+
status: 200,
|
|
1476
|
+
headers: { 'Content-Type': 'text/event-stream' },
|
|
1477
|
+
})
|
|
1478
|
+
) as typeof fetch;
|
|
1479
|
+
|
|
1480
|
+
const model = new AIModel(createTestConfig({
|
|
1481
|
+
auditor,
|
|
1482
|
+
providers: [{ type: 'openai', apiKey: 'sk-test' }],
|
|
1483
|
+
}));
|
|
1484
|
+
|
|
1485
|
+
const stream = model.generateStructuredStream(fromZod(TestSchema), [
|
|
1486
|
+
{ role: 'user', content: 'Generate data' },
|
|
1487
|
+
]);
|
|
1488
|
+
|
|
1489
|
+
// Consume the stream
|
|
1490
|
+
for await (const _ of stream) {
|
|
1491
|
+
// Just consume the partials
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
const events = auditor.getEvents();
|
|
1495
|
+
const types = events.map(e => e.type);
|
|
1496
|
+
|
|
1497
|
+
// Should emit structured_request at start and structured_response at end
|
|
1498
|
+
expect(types).toContain('structured_request');
|
|
1499
|
+
expect(types).toContain('structured_response');
|
|
1500
|
+
|
|
1501
|
+
// Check order: request before response
|
|
1502
|
+
const requestIdx = types.indexOf('structured_request');
|
|
1503
|
+
const responseIdx = types.indexOf('structured_response');
|
|
1504
|
+
expect(requestIdx).toBeLessThan(responseIdx);
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
it('emits structured_validation_error when stream validation fails', async () => {
|
|
1508
|
+
const auditor = new BufferedAuditor();
|
|
1509
|
+
// Invalid JSON that doesn't match schema
|
|
1510
|
+
const chunks = [
|
|
1511
|
+
'data: {"choices":[{"delta":{"content":"{\\"name\\":"}}]}\n\n',
|
|
1512
|
+
'data: {"choices":[{"delta":{"content":"\\"invalid\\""}}]}\n\n',
|
|
1513
|
+
'data: {"choices":[{"delta":{"content":"}"}}]}\n\n', // Missing required 'value' field
|
|
1514
|
+
'data: [DONE]\n\n',
|
|
1515
|
+
];
|
|
1516
|
+
|
|
1517
|
+
globalThis.fetch = mock(async () =>
|
|
1518
|
+
new Response(chunks.join(''), {
|
|
1519
|
+
status: 200,
|
|
1520
|
+
headers: { 'Content-Type': 'text/event-stream' },
|
|
1521
|
+
})
|
|
1522
|
+
) as typeof fetch;
|
|
1523
|
+
|
|
1524
|
+
const model = new AIModel(createTestConfig({
|
|
1525
|
+
auditor,
|
|
1526
|
+
providers: [{ type: 'openai', apiKey: 'sk-test' }],
|
|
1527
|
+
}));
|
|
1528
|
+
|
|
1529
|
+
const StrictSchema = z.object({
|
|
1530
|
+
name: z.string(),
|
|
1531
|
+
value: z.number(),
|
|
1532
|
+
});
|
|
1533
|
+
|
|
1534
|
+
try {
|
|
1535
|
+
const stream = model.generateStructuredStream(fromZod(StrictSchema), [
|
|
1536
|
+
{ role: 'user', content: 'Generate data' },
|
|
1537
|
+
]);
|
|
1538
|
+
for await (const _ of stream) {
|
|
1539
|
+
// Consume stream
|
|
1540
|
+
}
|
|
1541
|
+
} catch {
|
|
1542
|
+
// Expected to throw
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
const events = auditor.getEvents();
|
|
1546
|
+
const types = events.map(e => e.type);
|
|
1547
|
+
|
|
1548
|
+
// Should emit structured_request and structured_validation_error
|
|
1549
|
+
expect(types).toContain('structured_request');
|
|
1550
|
+
expect(types).toContain('structured_validation_error');
|
|
1551
|
+
});
|
|
1552
|
+
});
|
|
1553
|
+
|
|
1554
|
+
describe('existing chat/stream auditor events still work', () => {
|
|
1555
|
+
it('chat without structured output still emits request/response events', async () => {
|
|
1556
|
+
const auditor = new BufferedAuditor();
|
|
1557
|
+
globalThis.fetch = mock(async () =>
|
|
1558
|
+
new Response(JSON.stringify({
|
|
1559
|
+
model: 'test-model',
|
|
1560
|
+
created_at: new Date().toISOString(),
|
|
1561
|
+
message: { role: 'assistant', content: 'Hello world' },
|
|
1562
|
+
done: true,
|
|
1563
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
1564
|
+
) as typeof fetch;
|
|
1565
|
+
|
|
1566
|
+
const model = new AIModel(createTestConfig({ auditor }));
|
|
1567
|
+
await model.chat([{ role: 'user', content: 'Hello' }]);
|
|
1568
|
+
|
|
1569
|
+
const events = auditor.getEvents();
|
|
1570
|
+
const types = events.map(e => e.type);
|
|
1571
|
+
|
|
1572
|
+
// Should emit standard request/response events (not structured events)
|
|
1573
|
+
expect(types).toContain('request');
|
|
1574
|
+
expect(types).toContain('response');
|
|
1575
|
+
expect(types).not.toContain('structured_request');
|
|
1576
|
+
expect(types).not.toContain('structured_response');
|
|
1577
|
+
});
|
|
1578
|
+
|
|
1579
|
+
it('chatStream emits stream_start and stream_end events', async () => {
|
|
1580
|
+
const auditor = new BufferedAuditor();
|
|
1581
|
+
const chunks = [
|
|
1582
|
+
'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n',
|
|
1583
|
+
'data: {"choices":[{"delta":{"content":" world"}}]}\n\n',
|
|
1584
|
+
'data: [DONE]\n\n',
|
|
1585
|
+
];
|
|
1586
|
+
|
|
1587
|
+
globalThis.fetch = mock(async () =>
|
|
1588
|
+
new Response(chunks.join(''), {
|
|
1589
|
+
status: 200,
|
|
1590
|
+
headers: { 'Content-Type': 'text/event-stream' },
|
|
1591
|
+
})
|
|
1592
|
+
) as typeof fetch;
|
|
1593
|
+
|
|
1594
|
+
const model = new AIModel(createTestConfig({
|
|
1595
|
+
auditor,
|
|
1596
|
+
providers: [{ type: 'openai', apiKey: 'sk-test' }],
|
|
1597
|
+
}));
|
|
1598
|
+
|
|
1599
|
+
const stream = model.chatStream([{ role: 'user', content: 'Hello' }]);
|
|
1600
|
+
for await (const _ of stream) {
|
|
1601
|
+
// Consume stream
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
const events = auditor.getEvents();
|
|
1605
|
+
const types = events.map(e => e.type);
|
|
1606
|
+
|
|
1607
|
+
// Should emit stream_start and stream_end, not structured events
|
|
1608
|
+
expect(types).toContain('stream_start');
|
|
1609
|
+
expect(types).toContain('stream_end');
|
|
1610
|
+
expect(types).not.toContain('structured_request');
|
|
1611
|
+
expect(types).not.toContain('structured_response');
|
|
1612
|
+
});
|
|
1613
|
+
});
|
|
1614
|
+
});
|