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,1450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for structured-output.ts — Core structured output types
|
|
3
|
+
*
|
|
4
|
+
* Validates assertions:
|
|
5
|
+
* - VAL-SCHEMA-001: Zod Schema Input with Type Inference
|
|
6
|
+
* - VAL-SCHEMA-003: Raw JSON Schema Input
|
|
7
|
+
* - VAL-SCHEMA-005: Validation Error with Raw Output
|
|
8
|
+
* - VAL-SCHEMA-007: tryParseStructured Returns Result Object
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect } from 'bun:test';
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
import {
|
|
13
|
+
StructuredOutputError,
|
|
14
|
+
type StructuredOutputOptions,
|
|
15
|
+
type StructuredOutputResult,
|
|
16
|
+
type StructuredOutputSuccess,
|
|
17
|
+
type StructuredOutputFailure,
|
|
18
|
+
type JSONSchema,
|
|
19
|
+
isStructuredOutputSuccess,
|
|
20
|
+
isStructuredOutputFailure,
|
|
21
|
+
// Schema conversion functions
|
|
22
|
+
normalizeJsonSchema,
|
|
23
|
+
convertToProviderSchema,
|
|
24
|
+
stripUnsupportedFeatures,
|
|
25
|
+
// Validation functions
|
|
26
|
+
parseStructured,
|
|
27
|
+
tryParseStructured,
|
|
28
|
+
validateStructuredOutput,
|
|
29
|
+
stripJsonFences,
|
|
30
|
+
} from '../structured-output.js';
|
|
31
|
+
import { fromZod } from '../zod-adapter.js';
|
|
32
|
+
|
|
33
|
+
// ============================================================================
|
|
34
|
+
// Test Schemas
|
|
35
|
+
// ============================================================================
|
|
36
|
+
|
|
37
|
+
const UserSchema = z.object({
|
|
38
|
+
name: z.string(),
|
|
39
|
+
age: z.number(),
|
|
40
|
+
email: z.string().email().optional(),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
type User = z.infer<typeof UserSchema>;
|
|
44
|
+
|
|
45
|
+
// ============================================================================
|
|
46
|
+
// StructuredOutputError Tests
|
|
47
|
+
// ============================================================================
|
|
48
|
+
|
|
49
|
+
describe('StructuredOutputError', () => {
|
|
50
|
+
it('extends Error class', () => {
|
|
51
|
+
const error = new StructuredOutputError('Validation failed', {
|
|
52
|
+
rawOutput: '{"name": 123}',
|
|
53
|
+
});
|
|
54
|
+
expect(error instanceof Error).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('has rawOutput property', () => {
|
|
58
|
+
const error = new StructuredOutputError('Validation failed', {
|
|
59
|
+
rawOutput: '{"invalid": "json"}',
|
|
60
|
+
});
|
|
61
|
+
expect(error.rawOutput).toBe('{"invalid": "json"}');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('has cause property for Zod validation errors', () => {
|
|
65
|
+
const zodError = new z.ZodError([
|
|
66
|
+
{ code: 'invalid_type', expected: 'string', path: ['name'], message: 'Invalid input: expected string, received number' },
|
|
67
|
+
]);
|
|
68
|
+
const error = new StructuredOutputError('Validation failed', {
|
|
69
|
+
rawOutput: '{"name": 123}',
|
|
70
|
+
cause: zodError,
|
|
71
|
+
});
|
|
72
|
+
expect(error.cause).toBe(zodError);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('has optional cause property', () => {
|
|
76
|
+
const error = new StructuredOutputError('Validation failed', {
|
|
77
|
+
rawOutput: 'not json at all',
|
|
78
|
+
});
|
|
79
|
+
expect(error.cause).toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('captures stack trace', () => {
|
|
83
|
+
const error = new StructuredOutputError('Validation failed', {
|
|
84
|
+
rawOutput: '{}',
|
|
85
|
+
});
|
|
86
|
+
expect(error.stack).toBeDefined();
|
|
87
|
+
// Stack trace should contain the error message and show where it was thrown
|
|
88
|
+
expect(error.stack).toContain('Validation failed');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('message includes raw output preview', () => {
|
|
92
|
+
const error = new StructuredOutputError('Validation failed', {
|
|
93
|
+
rawOutput: '{"long": "output that should be truncated in preview"}',
|
|
94
|
+
});
|
|
95
|
+
expect(error.message).toContain('Validation failed');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ============================================================================
|
|
100
|
+
// StructuredOutputOptions Tests
|
|
101
|
+
// ============================================================================
|
|
102
|
+
|
|
103
|
+
describe('StructuredOutputOptions', () => {
|
|
104
|
+
it('accepts Zod schema config', () => {
|
|
105
|
+
const config = fromZod(UserSchema);
|
|
106
|
+
const options: StructuredOutputOptions<User> = {
|
|
107
|
+
schemaConfig: config,
|
|
108
|
+
};
|
|
109
|
+
expect(options.schemaConfig).toBe(config);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('accepts raw JSON Schema', () => {
|
|
113
|
+
const jsonSchema: JSONSchema = {
|
|
114
|
+
type: 'object',
|
|
115
|
+
properties: {
|
|
116
|
+
name: { type: 'string' },
|
|
117
|
+
age: { type: 'number' },
|
|
118
|
+
},
|
|
119
|
+
required: ['name', 'age'],
|
|
120
|
+
};
|
|
121
|
+
const options: StructuredOutputOptions<User> = {
|
|
122
|
+
jsonSchema,
|
|
123
|
+
};
|
|
124
|
+
expect(options.jsonSchema).toBe(jsonSchema);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('accepts optional name for LLM guidance', () => {
|
|
128
|
+
const options: StructuredOutputOptions<User> = {
|
|
129
|
+
schemaConfig: fromZod(UserSchema, { name: 'User' }),
|
|
130
|
+
};
|
|
131
|
+
expect(options.schemaConfig?.name).toBe('User');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('accepts optional description for LLM guidance', () => {
|
|
135
|
+
const options: StructuredOutputOptions<User> = {
|
|
136
|
+
schemaConfig: fromZod(UserSchema, { name: 'User', description: 'A user object with name and age' }),
|
|
137
|
+
};
|
|
138
|
+
expect(options.schemaConfig?.description).toBe('A user object with name and age');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('accepts both schema and name/description', () => {
|
|
142
|
+
const config = fromZod(UserSchema);
|
|
143
|
+
const options: StructuredOutputOptions<User> = {
|
|
144
|
+
schemaConfig: config,
|
|
145
|
+
name: 'User',
|
|
146
|
+
description: 'User schema',
|
|
147
|
+
};
|
|
148
|
+
expect(options.schemaConfig).toBe(config);
|
|
149
|
+
expect(options.name).toBe('User');
|
|
150
|
+
expect(options.description).toBe('User schema');
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ============================================================================
|
|
155
|
+
// StructuredOutputResult Tests
|
|
156
|
+
// ============================================================================
|
|
157
|
+
|
|
158
|
+
describe('StructuredOutputResult', () => {
|
|
159
|
+
describe('Success case', () => {
|
|
160
|
+
it('has ok: true and value property', () => {
|
|
161
|
+
const result: StructuredOutputSuccess<User> = {
|
|
162
|
+
ok: true,
|
|
163
|
+
value: { name: 'John', age: 30 },
|
|
164
|
+
};
|
|
165
|
+
expect(result.ok).toBe(true);
|
|
166
|
+
expect(result.value).toEqual({ name: 'John', age: 30 });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('narrows type correctly', () => {
|
|
170
|
+
const result: StructuredOutputResult<User> = {
|
|
171
|
+
ok: true,
|
|
172
|
+
value: { name: 'Jane', age: 25 },
|
|
173
|
+
};
|
|
174
|
+
if (result.ok) {
|
|
175
|
+
// TypeScript should infer result.value is User
|
|
176
|
+
const name: string = result.value.name;
|
|
177
|
+
expect(name).toBe('Jane');
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('Failure case', () => {
|
|
183
|
+
it('has ok: false, error, and rawOutput properties', () => {
|
|
184
|
+
const zodError = new z.ZodError([
|
|
185
|
+
{ code: 'invalid_type', expected: 'string', path: ['name'], message: 'Invalid input: expected string, received number' },
|
|
186
|
+
]);
|
|
187
|
+
const result: StructuredOutputFailure<User> = {
|
|
188
|
+
ok: false,
|
|
189
|
+
error: new StructuredOutputError('Validation failed', {
|
|
190
|
+
rawOutput: '{"name": 123}',
|
|
191
|
+
cause: zodError,
|
|
192
|
+
}),
|
|
193
|
+
rawOutput: '{"name": 123}',
|
|
194
|
+
};
|
|
195
|
+
expect(result.ok).toBe(false);
|
|
196
|
+
expect(result.error).toBeInstanceOf(StructuredOutputError);
|
|
197
|
+
expect(result.rawOutput).toBe('{"name": 123}');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('narrows type correctly', () => {
|
|
201
|
+
const result: StructuredOutputResult<User> = {
|
|
202
|
+
ok: false,
|
|
203
|
+
error: new StructuredOutputError('Invalid JSON', {
|
|
204
|
+
rawOutput: 'not json',
|
|
205
|
+
}),
|
|
206
|
+
rawOutput: 'not json',
|
|
207
|
+
};
|
|
208
|
+
if (!result.ok) {
|
|
209
|
+
// TypeScript should infer result.error and result.rawOutput
|
|
210
|
+
const errorMessage: string = result.error.message;
|
|
211
|
+
const raw: string = result.rawOutput;
|
|
212
|
+
expect(errorMessage).toContain('Invalid JSON');
|
|
213
|
+
expect(raw).toBe('not json');
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// ============================================================================
|
|
220
|
+
// Type Guard Tests
|
|
221
|
+
// ============================================================================
|
|
222
|
+
|
|
223
|
+
describe('isStructuredOutputSuccess', () => {
|
|
224
|
+
it('returns true for success result', () => {
|
|
225
|
+
const result: StructuredOutputResult<User> = {
|
|
226
|
+
ok: true,
|
|
227
|
+
value: { name: 'Alice', age: 28 },
|
|
228
|
+
};
|
|
229
|
+
expect(isStructuredOutputSuccess(result)).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('returns false for failure result', () => {
|
|
233
|
+
const result: StructuredOutputResult<User> = {
|
|
234
|
+
ok: false,
|
|
235
|
+
error: new StructuredOutputError('Failed', {
|
|
236
|
+
rawOutput: '{}',
|
|
237
|
+
}),
|
|
238
|
+
rawOutput: '{}',
|
|
239
|
+
};
|
|
240
|
+
expect(isStructuredOutputSuccess(result)).toBe(false);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// ============================================================================
|
|
245
|
+
// Type Inference Tests
|
|
246
|
+
// ============================================================================
|
|
247
|
+
|
|
248
|
+
describe('Type Inference (VAL-SCHEMA-001)', () => {
|
|
249
|
+
it('infers type from Zod schema correctly', () => {
|
|
250
|
+
// This test ensures TypeScript infers the correct type
|
|
251
|
+
const options: StructuredOutputOptions<User> = {
|
|
252
|
+
schemaConfig: fromZod(UserSchema),
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// TypeScript compilation is the test - if type is wrong, this won't compile
|
|
256
|
+
type InferredType = z.infer<typeof UserSchema>;
|
|
257
|
+
const _typeCheck: InferredType = { name: 'Test', age: 0 };
|
|
258
|
+
|
|
259
|
+
expect(options.schemaConfig).toBeDefined();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('structured output result type narrows correctly', () => {
|
|
263
|
+
// Test that TypeScript correctly narrows the union type
|
|
264
|
+
const successResult: StructuredOutputResult<User> = {
|
|
265
|
+
ok: true,
|
|
266
|
+
value: { name: 'Bob', age: 35 },
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const failureResult: StructuredOutputResult<User> = {
|
|
270
|
+
ok: false,
|
|
271
|
+
error: new StructuredOutputError('Error', { rawOutput: '{}' }),
|
|
272
|
+
rawOutput: '{}',
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// TypeScript narrow check - these should compile
|
|
276
|
+
if (successResult.ok) {
|
|
277
|
+
const name: string = successResult.value.name;
|
|
278
|
+
expect(name).toBe('Bob');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (!failureResult.ok) {
|
|
282
|
+
const raw: string = failureResult.rawOutput;
|
|
283
|
+
expect(raw).toBe('{}');
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ============================================================================
|
|
289
|
+
// JSON Schema Input Tests (VAL-SCHEMA-003)
|
|
290
|
+
// ============================================================================
|
|
291
|
+
|
|
292
|
+
describe('Raw JSON Schema Input', () => {
|
|
293
|
+
it('accepts JSONSchema object without Zod', () => {
|
|
294
|
+
const jsonSchema: JSONSchema = {
|
|
295
|
+
type: 'object',
|
|
296
|
+
properties: {
|
|
297
|
+
id: { type: 'string' },
|
|
298
|
+
count: { type: 'integer' },
|
|
299
|
+
},
|
|
300
|
+
required: ['id'],
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const options: StructuredOutputOptions<{ id: string; count?: number }> = {
|
|
304
|
+
jsonSchema,
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
expect(options.jsonSchema).toEqual(jsonSchema);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('allows both schema and jsonSchema to be specified (schema takes precedence)', () => {
|
|
311
|
+
const jsonSchema: JSONSchema = {
|
|
312
|
+
type: 'object',
|
|
313
|
+
properties: {
|
|
314
|
+
value: { type: 'number' },
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const options: StructuredOutputOptions<{ value: number }> = {
|
|
319
|
+
schemaConfig: fromZod(z.object({ value: z.number() })),
|
|
320
|
+
jsonSchema,
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
// Both can be set, implementation will use schema for validation
|
|
324
|
+
expect(options.schemaConfig).toBeDefined();
|
|
325
|
+
expect(options.jsonSchema).toBeDefined();
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// ============================================================================
|
|
330
|
+
// Schema Conversion Tests (VAL-SCHEMA-002)
|
|
331
|
+
// ============================================================================
|
|
332
|
+
|
|
333
|
+
describe('Schema Conversion (VAL-SCHEMA-002)', () => {
|
|
334
|
+
describe('zodToJsonSchema', () => {
|
|
335
|
+
it('converts Zod string schema to JSON Schema', () => {
|
|
336
|
+
const stringSchema = z.string();
|
|
337
|
+
const jsonSchema = fromZod(stringSchema).jsonSchema;
|
|
338
|
+
|
|
339
|
+
expect(jsonSchema.type).toBe('string');
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('converts Zod number schema to JSON Schema', () => {
|
|
343
|
+
const numberSchema = z.number();
|
|
344
|
+
const jsonSchema = fromZod(numberSchema).jsonSchema;
|
|
345
|
+
|
|
346
|
+
expect(jsonSchema.type).toBe('number');
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('converts Zod boolean schema to JSON Schema', () => {
|
|
350
|
+
const boolSchema = z.boolean();
|
|
351
|
+
const jsonSchema = fromZod(boolSchema).jsonSchema;
|
|
352
|
+
|
|
353
|
+
expect(jsonSchema.type).toBe('boolean');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('converts Zod object schema with required properties', () => {
|
|
357
|
+
const objectSchema = z.object({
|
|
358
|
+
name: z.string(),
|
|
359
|
+
age: z.number(),
|
|
360
|
+
});
|
|
361
|
+
const jsonSchema = fromZod(objectSchema).jsonSchema;
|
|
362
|
+
|
|
363
|
+
expect(jsonSchema.type).toBe('object');
|
|
364
|
+
expect(jsonSchema.properties).toBeDefined();
|
|
365
|
+
expect(jsonSchema.properties?.['name']).toEqual({ type: 'string' });
|
|
366
|
+
expect(jsonSchema.properties?.['age']).toEqual({ type: 'number' });
|
|
367
|
+
expect(jsonSchema.required).toEqual(['name', 'age']);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('converts Zod object schema with optional properties', () => {
|
|
371
|
+
const objectSchema = z.object({
|
|
372
|
+
name: z.string(),
|
|
373
|
+
email: z.string().optional(),
|
|
374
|
+
});
|
|
375
|
+
const jsonSchema = fromZod(objectSchema).jsonSchema;
|
|
376
|
+
|
|
377
|
+
expect(jsonSchema.type).toBe('object');
|
|
378
|
+
expect(jsonSchema.required).toEqual(['name']);
|
|
379
|
+
expect(jsonSchema.properties?.['name']).toEqual({ type: 'string' });
|
|
380
|
+
expect(jsonSchema.properties?.['email']).toEqual({ type: 'string' });
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('converts nested Zod object schema', () => {
|
|
384
|
+
const nestedAddressSchema = z.object({
|
|
385
|
+
city: z.string(),
|
|
386
|
+
country: z.string(),
|
|
387
|
+
});
|
|
388
|
+
const personSchema = z.object({
|
|
389
|
+
name: z.string(),
|
|
390
|
+
address: nestedAddressSchema,
|
|
391
|
+
});
|
|
392
|
+
const jsonSchema = fromZod(personSchema).jsonSchema;
|
|
393
|
+
|
|
394
|
+
expect(jsonSchema.type).toBe('object');
|
|
395
|
+
// zod-to-json-schema adds additionalProperties: false by default
|
|
396
|
+
const addressProp = jsonSchema.properties?.['address'] as JSONSchema;
|
|
397
|
+
expect(addressProp.type).toBe('object');
|
|
398
|
+
expect(addressProp.properties?.['city']).toEqual({ type: 'string' });
|
|
399
|
+
expect(addressProp.properties?.['country']).toEqual({ type: 'string' });
|
|
400
|
+
expect(addressProp.required).toEqual(['city', 'country']);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
describe('normalizeJsonSchema', () => {
|
|
405
|
+
it('passes through JSON Schema without modification when not needed', () => {
|
|
406
|
+
const input: JSONSchema = {
|
|
407
|
+
type: 'object',
|
|
408
|
+
properties: {
|
|
409
|
+
name: { type: 'string' },
|
|
410
|
+
count: { type: 'number' },
|
|
411
|
+
},
|
|
412
|
+
required: ['name'],
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const result = normalizeJsonSchema(input);
|
|
416
|
+
|
|
417
|
+
expect(result.type).toBe('object');
|
|
418
|
+
expect(result.properties).toEqual(input.properties);
|
|
419
|
+
expect(result.required).toEqual(['name']);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('preserves description in JSON Schema', () => {
|
|
423
|
+
const input: JSONSchema = {
|
|
424
|
+
type: 'object',
|
|
425
|
+
properties: {
|
|
426
|
+
name: { type: 'string', description: 'User name' },
|
|
427
|
+
},
|
|
428
|
+
description: 'A user object',
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
const result = normalizeJsonSchema(input);
|
|
432
|
+
|
|
433
|
+
expect(result.description).toBe('A user object');
|
|
434
|
+
expect(result.properties?.['name']?.description).toBe('User name');
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// ============================================================================
|
|
440
|
+
// Schema Name/Description Tests (VAL-SCHEMA-004)
|
|
441
|
+
// ============================================================================
|
|
442
|
+
|
|
443
|
+
describe('Schema Name and Description (VAL-SCHEMA-004)', () => {
|
|
444
|
+
describe('convertToProviderSchema', () => {
|
|
445
|
+
it('includes schema name in OpenAI-compatible format', () => {
|
|
446
|
+
const schema = z.object({ name: z.string() });
|
|
447
|
+
const options: StructuredOutputOptions<{ name: string }> = {
|
|
448
|
+
schemaConfig: fromZod(schema, { name: 'User', description: 'A user object' }),
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const result = convertToProviderSchema('openai', options);
|
|
452
|
+
|
|
453
|
+
expect(result.name).toBe('User');
|
|
454
|
+
expect(result.description).toBe('A user object');
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('works without name/description (optional)', () => {
|
|
458
|
+
const schema = z.object({ name: z.string() });
|
|
459
|
+
const options: StructuredOutputOptions<{ name: string }> = {
|
|
460
|
+
schemaConfig: fromZod(schema),
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const result = convertToProviderSchema('openai', options);
|
|
464
|
+
|
|
465
|
+
expect(result.schema).toBeDefined();
|
|
466
|
+
expect(result.schema.type).toBe('object');
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('generates default name if not provided for providers that require it', () => {
|
|
470
|
+
const schema = z.object({ value: z.number() });
|
|
471
|
+
const options: StructuredOutputOptions<{ value: number }> = {
|
|
472
|
+
schemaConfig: fromZod(schema),
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const result = convertToProviderSchema('openai', options);
|
|
476
|
+
|
|
477
|
+
// Should have a name, either provided or auto-generated
|
|
478
|
+
expect(result.name).toBeDefined();
|
|
479
|
+
expect(typeof result.name).toBe('string');
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// ============================================================================
|
|
485
|
+
// Enum Schema Tests (VAL-SCHEMA-008)
|
|
486
|
+
// ============================================================================
|
|
487
|
+
|
|
488
|
+
describe('Schema with Enums (VAL-SCHEMA-008)', () => {
|
|
489
|
+
it('converts Zod enum to JSON Schema with enum constraint', () => {
|
|
490
|
+
const statusSchema = z.enum(['active', 'inactive', 'pending']);
|
|
491
|
+
const jsonSchema = fromZod(statusSchema).jsonSchema;
|
|
492
|
+
|
|
493
|
+
expect(jsonSchema.type).toBe('string');
|
|
494
|
+
expect(jsonSchema.enum).toEqual(['active', 'inactive', 'pending']);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('converts Zod native enum to JSON Schema', () => {
|
|
498
|
+
enum Color {
|
|
499
|
+
Red = 'red',
|
|
500
|
+
Green = 'green',
|
|
501
|
+
Blue = 'blue',
|
|
502
|
+
}
|
|
503
|
+
const colorSchema = z.nativeEnum(Color);
|
|
504
|
+
const jsonSchema = fromZod(colorSchema).jsonSchema;
|
|
505
|
+
|
|
506
|
+
expect(jsonSchema.type).toBe('string');
|
|
507
|
+
expect(jsonSchema.enum).toContain('red');
|
|
508
|
+
expect(jsonSchema.enum).toContain('green');
|
|
509
|
+
expect(jsonSchema.enum).toContain('blue');
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('converts object with enum property', () => {
|
|
513
|
+
const userSchema = z.object({
|
|
514
|
+
name: z.string(),
|
|
515
|
+
status: z.enum(['active', 'inactive']),
|
|
516
|
+
});
|
|
517
|
+
const jsonSchema = fromZod(userSchema).jsonSchema;
|
|
518
|
+
|
|
519
|
+
expect(jsonSchema.type).toBe('object');
|
|
520
|
+
expect(jsonSchema.properties?.['status']).toEqual({
|
|
521
|
+
type: 'string',
|
|
522
|
+
enum: ['active', 'inactive'],
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it('validates enum values correctly', () => {
|
|
527
|
+
const schema = z.enum(['a', 'b', 'c']);
|
|
528
|
+
const jsonSchema = fromZod(schema).jsonSchema;
|
|
529
|
+
|
|
530
|
+
expect(jsonSchema.enum).toEqual(['a', 'b', 'c']);
|
|
531
|
+
expect(jsonSchema.type).toBe('string');
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
// ============================================================================
|
|
536
|
+
// Nested Object Schema Tests (VAL-SCHEMA-009)
|
|
537
|
+
// ============================================================================
|
|
538
|
+
|
|
539
|
+
describe('Nested Object Schema (VAL-SCHEMA-009)', () => {
|
|
540
|
+
it('converts deeply nested object schema', () => {
|
|
541
|
+
const addressSchema = z.object({
|
|
542
|
+
street: z.string(),
|
|
543
|
+
city: z.string(),
|
|
544
|
+
country: z.string(),
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
const userSchema = z.object({
|
|
548
|
+
name: z.string(),
|
|
549
|
+
address: addressSchema,
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
const companySchema = z.object({
|
|
553
|
+
name: z.string(),
|
|
554
|
+
users: z.array(userSchema),
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const jsonSchema = fromZod(companySchema).jsonSchema;
|
|
558
|
+
|
|
559
|
+
expect(jsonSchema.type).toBe('object');
|
|
560
|
+
// Check the structure exists
|
|
561
|
+
const usersSchema = jsonSchema.properties?.['users'] as JSONSchema;
|
|
562
|
+
expect(usersSchema.type).toBe('array');
|
|
563
|
+
|
|
564
|
+
const itemsSchema = usersSchema.items as JSONSchema;
|
|
565
|
+
expect(itemsSchema.type).toBe('object');
|
|
566
|
+
expect(itemsSchema.properties?.['name']).toEqual({ type: 'string' });
|
|
567
|
+
|
|
568
|
+
// Navigate deep into the schema
|
|
569
|
+
const addressProp = itemsSchema.properties?.['address'] as JSONSchema;
|
|
570
|
+
expect(addressProp.type).toBe('object');
|
|
571
|
+
expect(addressProp.properties?.['street']).toEqual({ type: 'string' });
|
|
572
|
+
expect(addressProp.properties?.['city']).toEqual({ type: 'string' });
|
|
573
|
+
expect(addressProp.properties?.['country']).toEqual({ type: 'string' });
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it('handles three levels of nesting', () => {
|
|
577
|
+
const citySchema = z.object({
|
|
578
|
+
name: z.string(),
|
|
579
|
+
population: z.number(),
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
const addressSchema = z.object({
|
|
583
|
+
street: z.string(),
|
|
584
|
+
city: citySchema,
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
const personSchema = z.object({
|
|
588
|
+
name: z.string(),
|
|
589
|
+
address: addressSchema,
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
const jsonSchema = fromZod(personSchema).jsonSchema;
|
|
593
|
+
|
|
594
|
+
// Navigate deep into the schema
|
|
595
|
+
const addressProps = jsonSchema.properties?.['address'];
|
|
596
|
+
const cityProps = (addressProps as JSONSchema)?.properties?.['city'];
|
|
597
|
+
const citySchemaProps = (cityProps as JSONSchema)?.properties;
|
|
598
|
+
|
|
599
|
+
expect(citySchemaProps?.['name']).toEqual({ type: 'string' });
|
|
600
|
+
expect(citySchemaProps?.['population']).toEqual({ type: 'number' });
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it('handles optional nested objects', () => {
|
|
604
|
+
const schema = z.object({
|
|
605
|
+
name: z.string(),
|
|
606
|
+
metadata: z.object({
|
|
607
|
+
created: z.string(),
|
|
608
|
+
}).optional(),
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
const jsonSchema = fromZod(schema).jsonSchema;
|
|
612
|
+
|
|
613
|
+
expect(jsonSchema.required).toEqual(['name']);
|
|
614
|
+
expect(jsonSchema.properties?.['metadata']?.type).toBe('object');
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
// ============================================================================
|
|
619
|
+
// Array Schema Tests (VAL-SCHEMA-010)
|
|
620
|
+
// ============================================================================
|
|
621
|
+
|
|
622
|
+
describe('Array Schema (VAL-SCHEMA-010)', () => {
|
|
623
|
+
it('converts Zod array of strings to JSON Schema', () => {
|
|
624
|
+
const schema = z.array(z.string());
|
|
625
|
+
const jsonSchema = fromZod(schema).jsonSchema;
|
|
626
|
+
|
|
627
|
+
expect(jsonSchema.type).toBe('array');
|
|
628
|
+
expect(jsonSchema.items).toEqual({ type: 'string' });
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('converts Zod array of numbers to JSON Schema', () => {
|
|
632
|
+
const schema = z.array(z.number());
|
|
633
|
+
const jsonSchema = fromZod(schema).jsonSchema;
|
|
634
|
+
|
|
635
|
+
expect(jsonSchema.type).toBe('array');
|
|
636
|
+
expect(jsonSchema.items).toEqual({ type: 'number' });
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it('converts Zod array of objects to JSON Schema', () => {
|
|
640
|
+
const schema = z.array(z.object({
|
|
641
|
+
id: z.string(),
|
|
642
|
+
name: z.string(),
|
|
643
|
+
}));
|
|
644
|
+
const jsonSchema = fromZod(schema).jsonSchema;
|
|
645
|
+
|
|
646
|
+
expect(jsonSchema.type).toBe('array');
|
|
647
|
+
// zod-to-json-schema adds additionalProperties: false by default
|
|
648
|
+
const items = jsonSchema.items as JSONSchema;
|
|
649
|
+
expect(items.type).toBe('object');
|
|
650
|
+
expect(items.properties?.['id']).toEqual({ type: 'string' });
|
|
651
|
+
expect(items.properties?.['name']).toEqual({ type: 'string' });
|
|
652
|
+
expect(items.required).toEqual(['id', 'name']);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it('converts nested arrays', () => {
|
|
656
|
+
const schema = z.array(z.array(z.string()));
|
|
657
|
+
const jsonSchema = fromZod(schema).jsonSchema;
|
|
658
|
+
|
|
659
|
+
expect(jsonSchema.type).toBe('array');
|
|
660
|
+
expect(jsonSchema.items).toEqual({
|
|
661
|
+
type: 'array',
|
|
662
|
+
items: { type: 'string' },
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it('handles array with min/max constraints', () => {
|
|
667
|
+
const schema = z.array(z.string()).min(1).max(10);
|
|
668
|
+
const jsonSchema = fromZod(schema).jsonSchema;
|
|
669
|
+
|
|
670
|
+
expect(jsonSchema.type).toBe('array');
|
|
671
|
+
expect(jsonSchema.minItems).toBe(1);
|
|
672
|
+
expect(jsonSchema.maxItems).toBe(10);
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
// ============================================================================
|
|
677
|
+
// Primitives Schema Tests
|
|
678
|
+
// ============================================================================
|
|
679
|
+
|
|
680
|
+
describe('Primitive Schemas', () => {
|
|
681
|
+
it('converts Zod null schema', () => {
|
|
682
|
+
const schema = z.null();
|
|
683
|
+
const jsonSchema = fromZod(schema).jsonSchema;
|
|
684
|
+
|
|
685
|
+
expect(jsonSchema.type).toBe('null');
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it('converts Zod literal schema', () => {
|
|
689
|
+
const schema = z.literal('fixed');
|
|
690
|
+
const jsonSchema = fromZod(schema).jsonSchema;
|
|
691
|
+
|
|
692
|
+
expect(jsonSchema.const).toBe('fixed');
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it('converts Zod union schema', () => {
|
|
696
|
+
const schema = z.union([z.string(), z.number()]);
|
|
697
|
+
const jsonSchema = fromZod(schema).jsonSchema;
|
|
698
|
+
// Zod 4 native z.toJSONSchema uses anyOf for unions
|
|
699
|
+
expect(jsonSchema.anyOf).toEqual([
|
|
700
|
+
{ type: 'string' },
|
|
701
|
+
{ type: 'number' },
|
|
702
|
+
]);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it('converts Zod record schema', () => {
|
|
706
|
+
const schema = z.record(z.string(), z.number());
|
|
707
|
+
const jsonSchema = fromZod(schema).jsonSchema;
|
|
708
|
+
|
|
709
|
+
expect(jsonSchema.type).toBe('object');
|
|
710
|
+
expect(jsonSchema.additionalProperties).toEqual({ type: 'number' });
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it('converts Zod tuple schema', () => {
|
|
714
|
+
const schema = z.tuple([z.string(), z.number()]);
|
|
715
|
+
const jsonSchema = fromZod(schema).jsonSchema;
|
|
716
|
+
|
|
717
|
+
expect(jsonSchema.type).toBe('array');
|
|
718
|
+
// Zod 4 native z.toJSONSchema uses items array for tuples
|
|
719
|
+
expect(jsonSchema.items).toEqual([
|
|
720
|
+
{ type: 'string' },
|
|
721
|
+
{ type: 'number' },
|
|
722
|
+
]);
|
|
723
|
+
});
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
// ============================================================================
|
|
727
|
+
// Google-Specific Schema Transformation Tests (VAL-PROVIDER-GOOGLE-006)
|
|
728
|
+
// ============================================================================
|
|
729
|
+
|
|
730
|
+
describe('Google Schema Transformation (VAL-PROVIDER-GOOGLE-006)', () => {
|
|
731
|
+
describe('stripUnsupportedFeatures', () => {
|
|
732
|
+
it('removes pattern property (not supported by Gemini)', () => {
|
|
733
|
+
const input: JSONSchema = {
|
|
734
|
+
type: 'string',
|
|
735
|
+
pattern: '^[a-zA-Z]+$',
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
const result = stripUnsupportedFeatures(input, 'google');
|
|
739
|
+
|
|
740
|
+
expect(result.pattern).toBeUndefined();
|
|
741
|
+
expect(result.type).toBe('string');
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
it('removes minLength/maxLength for strings', () => {
|
|
745
|
+
const input: JSONSchema = {
|
|
746
|
+
type: 'string',
|
|
747
|
+
minLength: 1,
|
|
748
|
+
maxLength: 100,
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
const result = stripUnsupportedFeatures(input, 'google');
|
|
752
|
+
|
|
753
|
+
expect(result.minLength).toBeUndefined();
|
|
754
|
+
expect(result.maxLength).toBeUndefined();
|
|
755
|
+
expect(result.type).toBe('string');
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it('keeps minLength/maxLength for other providers', () => {
|
|
759
|
+
const input: JSONSchema = {
|
|
760
|
+
type: 'string',
|
|
761
|
+
minLength: 1,
|
|
762
|
+
maxLength: 100,
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
const result = stripUnsupportedFeatures(input, 'openai');
|
|
766
|
+
|
|
767
|
+
expect(result.minLength).toBe(1);
|
|
768
|
+
expect(result.maxLength).toBe(100);
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it('removes minimum/maximum for numbers', () => {
|
|
772
|
+
const input: JSONSchema = {
|
|
773
|
+
type: 'number',
|
|
774
|
+
minimum: 0,
|
|
775
|
+
maximum: 100,
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
const result = stripUnsupportedFeatures(input, 'google');
|
|
779
|
+
|
|
780
|
+
expect(result.minimum).toBeUndefined();
|
|
781
|
+
expect(result.maximum).toBeUndefined();
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it('removes exclusiveMinimum/exclusiveMaximum', () => {
|
|
785
|
+
const input: JSONSchema = {
|
|
786
|
+
type: 'number',
|
|
787
|
+
exclusiveMinimum: 0,
|
|
788
|
+
exclusiveMaximum: 100,
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
const result = stripUnsupportedFeatures(input, 'google');
|
|
792
|
+
|
|
793
|
+
expect(result.exclusiveMinimum).toBeUndefined();
|
|
794
|
+
expect(result.exclusiveMaximum).toBeUndefined();
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it('removes unsupported features recursively in nested objects', () => {
|
|
798
|
+
const input: JSONSchema = {
|
|
799
|
+
type: 'object',
|
|
800
|
+
properties: {
|
|
801
|
+
name: {
|
|
802
|
+
type: 'string',
|
|
803
|
+
pattern: '^[a-z]+$',
|
|
804
|
+
},
|
|
805
|
+
age: {
|
|
806
|
+
type: 'number',
|
|
807
|
+
minimum: 0,
|
|
808
|
+
},
|
|
809
|
+
},
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
const result = stripUnsupportedFeatures(input, 'google');
|
|
813
|
+
|
|
814
|
+
expect(result.properties?.['name']?.pattern).toBeUndefined();
|
|
815
|
+
expect(result.properties?.['age']?.minimum).toBeUndefined();
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
it('removes unsupported features in array items', () => {
|
|
819
|
+
const input: JSONSchema = {
|
|
820
|
+
type: 'array',
|
|
821
|
+
items: {
|
|
822
|
+
type: 'object',
|
|
823
|
+
properties: {
|
|
824
|
+
code: {
|
|
825
|
+
type: 'string',
|
|
826
|
+
pattern: '^[A-Z]{3}$',
|
|
827
|
+
},
|
|
828
|
+
},
|
|
829
|
+
},
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
const result = stripUnsupportedFeatures(input, 'google');
|
|
833
|
+
|
|
834
|
+
const items = result.items as JSONSchema;
|
|
835
|
+
expect(items.properties?.['code']?.pattern).toBeUndefined();
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
it('preserves required array', () => {
|
|
839
|
+
const input: JSONSchema = {
|
|
840
|
+
type: 'object',
|
|
841
|
+
properties: {
|
|
842
|
+
name: { type: 'string' },
|
|
843
|
+
age: { type: 'number' },
|
|
844
|
+
},
|
|
845
|
+
required: ['name', 'age'],
|
|
846
|
+
};
|
|
847
|
+
|
|
848
|
+
const result = stripUnsupportedFeatures(input, 'google');
|
|
849
|
+
|
|
850
|
+
expect(result.required).toEqual(['name', 'age']);
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
it('preserves enum array', () => {
|
|
854
|
+
const input: JSONSchema = {
|
|
855
|
+
type: 'string',
|
|
856
|
+
enum: ['active', 'inactive', 'pending'],
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
const result = stripUnsupportedFeatures(input, 'google');
|
|
860
|
+
|
|
861
|
+
expect(result.enum).toEqual(['active', 'inactive', 'pending']);
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
it('removes additionalProperties for Google (not supported)', () => {
|
|
865
|
+
const input: JSONSchema = {
|
|
866
|
+
type: 'object',
|
|
867
|
+
properties: {
|
|
868
|
+
name: { type: 'string' },
|
|
869
|
+
},
|
|
870
|
+
additionalProperties: false,
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
const result = stripUnsupportedFeatures(input, 'google');
|
|
874
|
+
|
|
875
|
+
expect(result.additionalProperties).toBeUndefined();
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
it('removes additionalProperties as object schema for Google', () => {
|
|
879
|
+
const input: JSONSchema = {
|
|
880
|
+
type: 'object',
|
|
881
|
+
properties: {
|
|
882
|
+
name: { type: 'string' },
|
|
883
|
+
},
|
|
884
|
+
additionalProperties: { type: 'string' },
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
const result = stripUnsupportedFeatures(input, 'google');
|
|
888
|
+
|
|
889
|
+
expect(result.additionalProperties).toBeUndefined();
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
it('keeps additionalProperties for other providers', () => {
|
|
893
|
+
const input: JSONSchema = {
|
|
894
|
+
type: 'object',
|
|
895
|
+
properties: {
|
|
896
|
+
name: { type: 'string' },
|
|
897
|
+
},
|
|
898
|
+
additionalProperties: false,
|
|
899
|
+
};
|
|
900
|
+
|
|
901
|
+
const result = stripUnsupportedFeatures(input, 'openai');
|
|
902
|
+
|
|
903
|
+
expect(result.additionalProperties).toBe(false);
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
it('preserves description', () => {
|
|
907
|
+
const input: JSONSchema = {
|
|
908
|
+
type: 'object',
|
|
909
|
+
description: 'User object',
|
|
910
|
+
properties: {
|
|
911
|
+
name: {
|
|
912
|
+
type: 'string',
|
|
913
|
+
description: 'User name',
|
|
914
|
+
},
|
|
915
|
+
},
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
const result = stripUnsupportedFeatures(input, 'google');
|
|
919
|
+
|
|
920
|
+
expect(result.description).toBe('User object');
|
|
921
|
+
expect(result.properties?.['name']?.description).toBe('User name');
|
|
922
|
+
});
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
describe('convertToProviderSchema for Google', () => {
|
|
926
|
+
it('applies Google-specific transformations', () => {
|
|
927
|
+
const schema = z.object({
|
|
928
|
+
email: z.string().regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/),
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
const options: StructuredOutputOptions<{ email: string }> = {
|
|
932
|
+
schemaConfig: fromZod(schema),
|
|
933
|
+
};
|
|
934
|
+
|
|
935
|
+
const result = convertToProviderSchema('google', options);
|
|
936
|
+
|
|
937
|
+
// Google doesn't support pattern, should be stripped
|
|
938
|
+
const emailProp = result.schema.properties?.['email'] as JSONSchema;
|
|
939
|
+
expect(emailProp.pattern).toBeUndefined();
|
|
940
|
+
expect(emailProp.type).toBe('string');
|
|
941
|
+
});
|
|
942
|
+
});
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
// ============================================================================
|
|
946
|
+
// Validation Logic Tests (VAL-SCHEMA-005, VAL-SCHEMA-006, VAL-SCHEMA-007)
|
|
947
|
+
// ============================================================================
|
|
948
|
+
|
|
949
|
+
describe('Validation Logic (VAL-SCHEMA-005)', () => {
|
|
950
|
+
describe('parseStructured', () => {
|
|
951
|
+
it('returns parsed object when JSON is valid and matches schema', () => {
|
|
952
|
+
const schema = z.object({
|
|
953
|
+
name: z.string(),
|
|
954
|
+
age: z.number(),
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
const rawOutput = '{"name": "Alice", "age": 30}';
|
|
958
|
+
const result = parseStructured(fromZod(schema), rawOutput);
|
|
959
|
+
|
|
960
|
+
expect(result).toEqual({ name: 'Alice', age: 30 });
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
it('returns parsed object for complex nested schema', () => {
|
|
964
|
+
const addressSchema = z.object({
|
|
965
|
+
street: z.string(),
|
|
966
|
+
city: z.string(),
|
|
967
|
+
});
|
|
968
|
+
const personSchema = z.object({
|
|
969
|
+
name: z.string(),
|
|
970
|
+
address: addressSchema,
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
const rawOutput = '{"name": "Bob", "address": {"street": "123 Main", "city": "NYC"}}';
|
|
974
|
+
const result = parseStructured(fromZod(personSchema), rawOutput);
|
|
975
|
+
|
|
976
|
+
expect(result).toEqual({
|
|
977
|
+
name: 'Bob',
|
|
978
|
+
address: { street: '123 Main', city: 'NYC' },
|
|
979
|
+
});
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
it('throws StructuredOutputError when JSON fails schema validation', () => {
|
|
983
|
+
const schema = z.object({
|
|
984
|
+
name: z.string(),
|
|
985
|
+
age: z.number(),
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
// Missing 'age' field
|
|
989
|
+
const rawOutput = '{"name": "Alice"}';
|
|
990
|
+
|
|
991
|
+
expect(() => parseStructured(fromZod(schema), rawOutput)).toThrow(StructuredOutputError);
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
it('throws StructuredOutputError with rawOutput when schema validation fails', () => {
|
|
995
|
+
const schema = z.object({
|
|
996
|
+
name: z.string(),
|
|
997
|
+
age: z.number(),
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
// 'age' is string instead of number
|
|
1001
|
+
const rawOutput = '{"name": "Alice", "age": "thirty"}';
|
|
1002
|
+
|
|
1003
|
+
try {
|
|
1004
|
+
parseStructured(fromZod(schema), rawOutput);
|
|
1005
|
+
expect(true).toBe(false); // Should not reach here
|
|
1006
|
+
} catch (error) {
|
|
1007
|
+
expect(error).toBeInstanceOf(StructuredOutputError);
|
|
1008
|
+
if (error instanceof StructuredOutputError) {
|
|
1009
|
+
expect(error.rawOutput).toBe(rawOutput);
|
|
1010
|
+
expect(error.cause).toBeInstanceOf(z.ZodError);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
it('throws StructuredOutputError with ZodError as cause on validation failure', () => {
|
|
1016
|
+
const schema = z.object({
|
|
1017
|
+
email: z.string().email(),
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
const rawOutput = '{"email": "not-an-email"}';
|
|
1021
|
+
|
|
1022
|
+
try {
|
|
1023
|
+
parseStructured(fromZod(schema), rawOutput);
|
|
1024
|
+
expect(true).toBe(false); // Should not reach here
|
|
1025
|
+
} catch (error) {
|
|
1026
|
+
expect(error).toBeInstanceOf(StructuredOutputError);
|
|
1027
|
+
if (error instanceof StructuredOutputError) {
|
|
1028
|
+
expect(error.cause).toBeInstanceOf(z.ZodError);
|
|
1029
|
+
const zodError = error.cause as z.ZodError;
|
|
1030
|
+
expect(zodError.issues.length).toBeGreaterThan(0);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
describe('Markdown Fence Tolerance (VAL-SCHEMA-008)', () => {
|
|
1037
|
+
// Some providers/models wrap structured output in ```json ... ```
|
|
1038
|
+
// fences even when a JSON schema is supplied via the `format`
|
|
1039
|
+
// field (observed with Gemma variants via Ollama). parseStructured
|
|
1040
|
+
// must tolerate this so the first such response doesn't fail the
|
|
1041
|
+
// whole scoring run.
|
|
1042
|
+
|
|
1043
|
+
const schema = z.object({
|
|
1044
|
+
score: z.number(),
|
|
1045
|
+
reasoning: z.string(),
|
|
1046
|
+
correct: z.boolean(),
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
it('parses JSON wrapped in ```json fences', () => {
|
|
1050
|
+
const rawOutput = '```json\n{"score": 0.9, "reasoning": "matches", "correct": true}\n```';
|
|
1051
|
+
const result = parseStructured(fromZod(schema), rawOutput);
|
|
1052
|
+
expect(result).toEqual({ score: 0.9, reasoning: 'matches', correct: true });
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
it('parses JSON wrapped in bare ``` fences (no language tag)', () => {
|
|
1056
|
+
const rawOutput = '```\n{"score": 0.5, "reasoning": "partial", "correct": false}\n```';
|
|
1057
|
+
const result = parseStructured(fromZod(schema), rawOutput);
|
|
1058
|
+
expect(result).toEqual({ score: 0.5, reasoning: 'partial', correct: false });
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
it('parses JSON wrapped in ```JSON (uppercase language tag)', () => {
|
|
1062
|
+
const rawOutput = '```JSON\n{"score": 1.0, "reasoning": "exact", "correct": true}\n```';
|
|
1063
|
+
const result = parseStructured(fromZod(schema), rawOutput);
|
|
1064
|
+
expect(result).toEqual({ score: 1.0, reasoning: 'exact', correct: true });
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
it('parses JSON with inline fences on a single line', () => {
|
|
1068
|
+
const rawOutput = '```json{"score": 0.0, "reasoning": "wrong", "correct": false}```';
|
|
1069
|
+
const result = parseStructured(fromZod(schema), rawOutput);
|
|
1070
|
+
expect(result).toEqual({ score: 0.0, reasoning: 'wrong', correct: false });
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
it('parses JSON with leading/trailing whitespace around fences', () => {
|
|
1074
|
+
const rawOutput = ' \n```json\n{"score": 0.7, "reasoning": "ok", "correct": true}\n```\n ';
|
|
1075
|
+
const result = parseStructured(fromZod(schema), rawOutput);
|
|
1076
|
+
expect(result).toEqual({ score: 0.7, reasoning: 'ok', correct: true });
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
it('falls back to first fenced block when prose precedes the JSON', () => {
|
|
1080
|
+
const rawOutput = "Here's the evaluation:\n```json\n{\"score\": 0.3, \"reasoning\": \"weak\", \"correct\": false}\n```\nHope that helps!";
|
|
1081
|
+
const result = parseStructured(fromZod(schema), rawOutput);
|
|
1082
|
+
expect(result).toEqual({ score: 0.3, reasoning: 'weak', correct: false });
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
it('does not disturb well-formed JSON without fences (fast path)', () => {
|
|
1086
|
+
const rawOutput = '{"score": 0.8, "reasoning": "good", "correct": true}';
|
|
1087
|
+
const result = parseStructured(fromZod(schema), rawOutput);
|
|
1088
|
+
expect(result).toEqual({ score: 0.8, reasoning: 'good', correct: true });
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
it('preserves JSON string values that contain literal backticks', () => {
|
|
1092
|
+
// The reasoning field contains the sequence ``` inside a string
|
|
1093
|
+
// literal but there is no fenced block. parseStructured should
|
|
1094
|
+
// take the fast path and not touch the value.
|
|
1095
|
+
const rawOutput = '{"score": 0.5, "reasoning": "the model emitted ``` once", "correct": false}';
|
|
1096
|
+
const result = parseStructured(fromZod(schema), rawOutput);
|
|
1097
|
+
expect(result.reasoning).toBe('the model emitted ``` once');
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
it('throws with the original rawOutput when fence stripping does not yield valid JSON', () => {
|
|
1101
|
+
const rawOutput = '```json\nthis is not json at all\n```';
|
|
1102
|
+
try {
|
|
1103
|
+
parseStructured(fromZod(schema), rawOutput);
|
|
1104
|
+
expect(true).toBe(false);
|
|
1105
|
+
} catch (error) {
|
|
1106
|
+
expect(error).toBeInstanceOf(StructuredOutputError);
|
|
1107
|
+
if (error instanceof StructuredOutputError) {
|
|
1108
|
+
// The error should report the original raw output so
|
|
1109
|
+
// users debugging can see exactly what the model sent.
|
|
1110
|
+
expect(error.rawOutput).toBe(rawOutput);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
describe('stripJsonFences helper', () => {
|
|
1117
|
+
it('returns input unchanged when no fences are present', () => {
|
|
1118
|
+
expect(stripJsonFences('{"a": 1}')).toBe('{"a": 1}');
|
|
1119
|
+
expect(stripJsonFences('plain text')).toBe('plain text');
|
|
1120
|
+
expect(stripJsonFences('')).toBe('');
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
it('strips ```json fences', () => {
|
|
1124
|
+
expect(stripJsonFences('```json\n{"a": 1}\n```')).toBe('{"a": 1}');
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
it('strips bare ``` fences', () => {
|
|
1128
|
+
expect(stripJsonFences('```\n{"a": 1}\n```')).toBe('{"a": 1}');
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
it('is idempotent', () => {
|
|
1132
|
+
const once = stripJsonFences('```json\n{"a": 1}\n```');
|
|
1133
|
+
const twice = stripJsonFences(once);
|
|
1134
|
+
expect(twice).toBe(once);
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
it('returns input unchanged if a stray ``` has no closing fence', () => {
|
|
1138
|
+
// No matched pair — leave the input alone so the JSON parser
|
|
1139
|
+
// sees the real output and produces a meaningful error.
|
|
1140
|
+
const input = '{"a": 1, "note": "stray ``` here"}';
|
|
1141
|
+
expect(stripJsonFences(input)).toBe(input);
|
|
1142
|
+
});
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
describe('Malformed JSON Handling (VAL-SCHEMA-006)', () => {
|
|
1146
|
+
it('throws StructuredOutputError on malformed JSON', () => {
|
|
1147
|
+
const schema = z.object({ name: z.string() });
|
|
1148
|
+
const rawOutput = 'not valid json at all';
|
|
1149
|
+
|
|
1150
|
+
expect(() => parseStructured(fromZod(schema), rawOutput)).toThrow(StructuredOutputError);
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
it('includes raw output in error for malformed JSON', () => {
|
|
1154
|
+
const schema = z.object({ name: z.string() });
|
|
1155
|
+
const rawOutput = '{"name": "incomplete"';
|
|
1156
|
+
|
|
1157
|
+
try {
|
|
1158
|
+
parseStructured(fromZod(schema), rawOutput);
|
|
1159
|
+
expect(true).toBe(false); // Should not reach here
|
|
1160
|
+
} catch (error) {
|
|
1161
|
+
expect(error).toBeInstanceOf(StructuredOutputError);
|
|
1162
|
+
if (error instanceof StructuredOutputError) {
|
|
1163
|
+
expect(error.rawOutput).toBe(rawOutput);
|
|
1164
|
+
expect(error.cause).toBeDefined();
|
|
1165
|
+
expect(error.cause).toBeInstanceOf(SyntaxError);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
it('handles completely empty output', () => {
|
|
1171
|
+
const schema = z.object({ name: z.string() });
|
|
1172
|
+
const rawOutput = '';
|
|
1173
|
+
|
|
1174
|
+
try {
|
|
1175
|
+
parseStructured(fromZod(schema), rawOutput);
|
|
1176
|
+
expect(true).toBe(false); // Should not reach here
|
|
1177
|
+
} catch (error) {
|
|
1178
|
+
expect(error).toBeInstanceOf(StructuredOutputError);
|
|
1179
|
+
if (error instanceof StructuredOutputError) {
|
|
1180
|
+
expect(error.rawOutput).toBe('');
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
it('handles null JSON value', () => {
|
|
1186
|
+
const schema = z.object({ name: z.string() });
|
|
1187
|
+
const rawOutput = 'null';
|
|
1188
|
+
|
|
1189
|
+
expect(() => parseStructured(fromZod(schema), rawOutput)).toThrow(StructuredOutputError);
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
it('handles array when object expected', () => {
|
|
1193
|
+
const schema = z.object({ name: z.string() });
|
|
1194
|
+
const rawOutput = '["not", "an", "object"]';
|
|
1195
|
+
|
|
1196
|
+
try {
|
|
1197
|
+
parseStructured(fromZod(schema), rawOutput);
|
|
1198
|
+
expect(true).toBe(false); // Should not reach here
|
|
1199
|
+
} catch (error) {
|
|
1200
|
+
expect(error).toBeInstanceOf(StructuredOutputError);
|
|
1201
|
+
if (error instanceof StructuredOutputError) {
|
|
1202
|
+
expect(error.rawOutput).toBe(rawOutput);
|
|
1203
|
+
expect(error.cause).toBeInstanceOf(z.ZodError);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
});
|
|
1207
|
+
});
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
describe('tryParseStructured (VAL-SCHEMA-007)', () => {
|
|
1211
|
+
describe('Success Path', () => {
|
|
1212
|
+
it('returns { ok: true, value } on success', () => {
|
|
1213
|
+
const schema = z.object({
|
|
1214
|
+
name: z.string(),
|
|
1215
|
+
age: z.number(),
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
const rawOutput = '{"name": "Alice", "age": 30}';
|
|
1219
|
+
const result = tryParseStructured(fromZod(schema), rawOutput);
|
|
1220
|
+
|
|
1221
|
+
expect(result.ok).toBe(true);
|
|
1222
|
+
if (result.ok) {
|
|
1223
|
+
expect(result.value).toEqual({ name: 'Alice', age: 30 });
|
|
1224
|
+
}
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
it('never throws on success', () => {
|
|
1228
|
+
const schema = z.object({ value: z.number() });
|
|
1229
|
+
const rawOutput = '{"value": 42}';
|
|
1230
|
+
|
|
1231
|
+
// Should not throw, should return result object
|
|
1232
|
+
expect(() => tryParseStructured(fromZod(schema), rawOutput)).not.toThrow();
|
|
1233
|
+
|
|
1234
|
+
const result = tryParseStructured(fromZod(schema), rawOutput);
|
|
1235
|
+
expect(result.ok).toBe(true);
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
it('handles optional fields correctly', () => {
|
|
1239
|
+
const schema = z.object({
|
|
1240
|
+
name: z.string(),
|
|
1241
|
+
email: z.string().email().optional(),
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
const rawOutput = '{"name": "Bob"}';
|
|
1245
|
+
const result = tryParseStructured(fromZod(schema), rawOutput);
|
|
1246
|
+
|
|
1247
|
+
expect(result.ok).toBe(true);
|
|
1248
|
+
if (result.ok) {
|
|
1249
|
+
expect(result.value).toEqual({ name: 'Bob' });
|
|
1250
|
+
}
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
it('validates nested objects', () => {
|
|
1254
|
+
const schema = z.object({
|
|
1255
|
+
user: z.object({
|
|
1256
|
+
name: z.string(),
|
|
1257
|
+
address: z.object({
|
|
1258
|
+
city: z.string(),
|
|
1259
|
+
}),
|
|
1260
|
+
}),
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
const rawOutput = '{"user": {"name": "Carol", "address": {"city": "NYC"}}}';
|
|
1264
|
+
const result = tryParseStructured(fromZod(schema), rawOutput);
|
|
1265
|
+
|
|
1266
|
+
expect(result.ok).toBe(true);
|
|
1267
|
+
if (result.ok) {
|
|
1268
|
+
expect(result.value).toEqual({
|
|
1269
|
+
user: { name: 'Carol', address: { city: 'NYC' } },
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
it('validates arrays', () => {
|
|
1275
|
+
const schema = z.object({
|
|
1276
|
+
items: z.array(z.string()),
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
const rawOutput = '{"items": ["a", "b", "c"]}';
|
|
1280
|
+
const result = tryParseStructured(fromZod(schema), rawOutput);
|
|
1281
|
+
|
|
1282
|
+
expect(result.ok).toBe(true);
|
|
1283
|
+
if (result.ok) {
|
|
1284
|
+
expect(result.value).toEqual({ items: ['a', 'b', 'c'] });
|
|
1285
|
+
}
|
|
1286
|
+
});
|
|
1287
|
+
|
|
1288
|
+
it('validates enums', () => {
|
|
1289
|
+
const schema = z.object({
|
|
1290
|
+
status: z.enum(['active', 'inactive']),
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
const rawOutput = '{"status": "active"}';
|
|
1294
|
+
const result = tryParseStructured(fromZod(schema), rawOutput);
|
|
1295
|
+
|
|
1296
|
+
expect(result.ok).toBe(true);
|
|
1297
|
+
if (result.ok) {
|
|
1298
|
+
expect(result.value).toEqual({ status: 'active' });
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
describe('Failure Path', () => {
|
|
1304
|
+
it('returns { ok: false, error, rawOutput } on validation failure', () => {
|
|
1305
|
+
const schema = z.object({
|
|
1306
|
+
name: z.string(),
|
|
1307
|
+
age: z.number(),
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
// Missing 'age' field
|
|
1311
|
+
const rawOutput = '{"name": "Alice"}';
|
|
1312
|
+
const result = tryParseStructured(fromZod(schema), rawOutput);
|
|
1313
|
+
|
|
1314
|
+
expect(result.ok).toBe(false);
|
|
1315
|
+
if (!result.ok) {
|
|
1316
|
+
expect(result.error).toBeInstanceOf(StructuredOutputError);
|
|
1317
|
+
expect(result.rawOutput).toBe(rawOutput);
|
|
1318
|
+
}
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
it('returns { ok: false, ... } on malformed JSON', () => {
|
|
1322
|
+
const schema = z.object({ name: z.string() });
|
|
1323
|
+
const rawOutput = 'not json';
|
|
1324
|
+
const result = tryParseStructured(fromZod(schema), rawOutput);
|
|
1325
|
+
|
|
1326
|
+
expect(result.ok).toBe(false);
|
|
1327
|
+
if (!result.ok) {
|
|
1328
|
+
expect(result.error).toBeInstanceOf(StructuredOutputError);
|
|
1329
|
+
expect(result.rawOutput).toBe(rawOutput);
|
|
1330
|
+
}
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
it('never throws, always returns result object', () => {
|
|
1334
|
+
const schema = z.object({ name: z.string() });
|
|
1335
|
+
|
|
1336
|
+
// Test with invalid JSON
|
|
1337
|
+
expect(() => tryParseStructured(fromZod(schema), 'invalid')).not.toThrow();
|
|
1338
|
+
|
|
1339
|
+
// Test with schema validation failure
|
|
1340
|
+
expect(() => tryParseStructured(fromZod(schema), '{"age": 30}')).not.toThrow();
|
|
1341
|
+
|
|
1342
|
+
// Test with type mismatch
|
|
1343
|
+
expect(() => tryParseStructured(fromZod(schema), '{"name": 123}')).not.toThrow();
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
it('includes ZodError as cause in failure result', () => {
|
|
1347
|
+
const schema = z.object({
|
|
1348
|
+
email: z.string().email(),
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
const rawOutput = '{"email": "not-an-email"}';
|
|
1352
|
+
const result = tryParseStructured(fromZod(schema), rawOutput);
|
|
1353
|
+
|
|
1354
|
+
expect(result.ok).toBe(false);
|
|
1355
|
+
if (!result.ok) {
|
|
1356
|
+
expect(result.error.cause).toBeInstanceOf(z.ZodError);
|
|
1357
|
+
}
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
it('includes SyntaxError as cause for malformed JSON', () => {
|
|
1361
|
+
const schema = z.object({ name: z.string() });
|
|
1362
|
+
const rawOutput = '{"incomplete": ';
|
|
1363
|
+
const result = tryParseStructured(fromZod(schema), rawOutput);
|
|
1364
|
+
|
|
1365
|
+
expect(result.ok).toBe(false);
|
|
1366
|
+
if (!result.ok) {
|
|
1367
|
+
expect(result.error.cause).toBeInstanceOf(SyntaxError);
|
|
1368
|
+
}
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
it('handles empty string input', () => {
|
|
1372
|
+
const schema = z.object({ name: z.string() });
|
|
1373
|
+
const rawOutput = '';
|
|
1374
|
+
const result = tryParseStructured(fromZod(schema), rawOutput);
|
|
1375
|
+
|
|
1376
|
+
expect(result.ok).toBe(false);
|
|
1377
|
+
if (!result.ok) {
|
|
1378
|
+
expect(result.rawOutput).toBe('');
|
|
1379
|
+
}
|
|
1380
|
+
});
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
describe('Type Narrowing', () => {
|
|
1384
|
+
it('narrows type correctly with ok check', () => {
|
|
1385
|
+
const schema = z.object({ name: z.string() });
|
|
1386
|
+
const result = tryParseStructured(fromZod(schema), '{"name": "test"}');
|
|
1387
|
+
|
|
1388
|
+
// TypeScript should narrow the type correctly
|
|
1389
|
+
if (result.ok) {
|
|
1390
|
+
// result.value is User type
|
|
1391
|
+
const name: string = result.value.name;
|
|
1392
|
+
expect(name).toBe('test');
|
|
1393
|
+
} else {
|
|
1394
|
+
// This branch should not execute
|
|
1395
|
+
expect(true).toBe(false);
|
|
1396
|
+
}
|
|
1397
|
+
});
|
|
1398
|
+
|
|
1399
|
+
it('isStructuredOutputFailure works correctly', () => {
|
|
1400
|
+
const schema = z.object({ name: z.string() });
|
|
1401
|
+
const successResult = tryParseStructured(fromZod(schema), '{"name": "test"}');
|
|
1402
|
+
const failureResult = tryParseStructured(fromZod(schema), 'invalid json');
|
|
1403
|
+
|
|
1404
|
+
expect(isStructuredOutputSuccess(successResult)).toBe(true);
|
|
1405
|
+
expect(isStructuredOutputFailure(successResult)).toBe(false);
|
|
1406
|
+
expect(isStructuredOutputSuccess(failureResult)).toBe(false);
|
|
1407
|
+
expect(isStructuredOutputFailure(failureResult)).toBe(true);
|
|
1408
|
+
});
|
|
1409
|
+
});
|
|
1410
|
+
});
|
|
1411
|
+
|
|
1412
|
+
describe('validateStructuredOutput', () => {
|
|
1413
|
+
it('validates output against Zod schema', () => {
|
|
1414
|
+
const schema = z.object({
|
|
1415
|
+
name: z.string(),
|
|
1416
|
+
count: z.number(),
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
const data = { name: 'test', count: 5 };
|
|
1420
|
+
const result = validateStructuredOutput(fromZod(schema), data);
|
|
1421
|
+
|
|
1422
|
+
expect(result).toEqual(data);
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
it('throws StructuredOutputError on validation failure', () => {
|
|
1426
|
+
const schema = z.object({
|
|
1427
|
+
name: z.string(),
|
|
1428
|
+
count: z.number(),
|
|
1429
|
+
});
|
|
1430
|
+
|
|
1431
|
+
const data = { name: 'test', count: 'not a number' };
|
|
1432
|
+
|
|
1433
|
+
expect(() => validateStructuredOutput(fromZod(schema), data)).toThrow(StructuredOutputError);
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
it('includes raw output in error for failed validation', () => {
|
|
1437
|
+
const schema = z.object({ name: z.string() });
|
|
1438
|
+
const rawOutput = '{"name": 123}';
|
|
1439
|
+
|
|
1440
|
+
try {
|
|
1441
|
+
validateStructuredOutput(fromZod(schema), JSON.parse(rawOutput), rawOutput);
|
|
1442
|
+
expect(true).toBe(false); // Should not reach here
|
|
1443
|
+
} catch (error) {
|
|
1444
|
+
expect(error).toBeInstanceOf(StructuredOutputError);
|
|
1445
|
+
if (error instanceof StructuredOutputError) {
|
|
1446
|
+
expect(error.rawOutput).toBe(rawOutput);
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
});
|
|
1450
|
+
});
|