@aeye/core 0.3.8 → 0.3.9
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/README.md +103 -2
- package/aeye-core-prompts.md +171 -0
- package/aeye-core-schema.md +117 -0
- package/aeye-core-tools.md +177 -0
- package/aeye-core-types.md +178 -0
- package/aeye-core.md +88 -0
- package/dist/common.d.ts +36 -0
- package/dist/common.d.ts.map +1 -1
- package/dist/common.js +101 -0
- package/dist/common.js.map +1 -1
- package/dist/prompt.d.ts +261 -16
- package/dist/prompt.d.ts.map +1 -1
- package/dist/prompt.js +653 -297
- package/dist/prompt.js.map +1 -1
- package/dist/schema.d.ts +432 -18
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +1403 -209
- package/dist/schema.js.map +1 -1
- package/dist/tool.d.ts +107 -17
- package/dist/tool.d.ts.map +1 -1
- package/dist/tool.js +128 -9
- package/dist/tool.js.map +1 -1
- package/dist/types.d.ts +50 -5
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -2
package/dist/schema.js
CHANGED
|
@@ -1,11 +1,465 @@
|
|
|
1
1
|
import z from 'zod';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
* Default instruction used when a `FormatDescriptor` omits
|
|
4
|
+
* `jsonFallbackInstruction`. Appended after the schema text when a schema is
|
|
5
|
+
* delivered as prompt text instead of as structured output.
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_JSON_FALLBACK_INSTRUCTION = 'Return ONLY a single raw JSON object conforming to the schema above — no markdown code fences, no prose, and do NOT echo the schema itself.';
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the prompt-text fallback instruction for a descriptor, falling back
|
|
10
|
+
* to `DEFAULT_JSON_FALLBACK_INSTRUCTION` when the descriptor doesn't declare
|
|
11
|
+
* one of its own.
|
|
12
|
+
*/
|
|
13
|
+
export function getJsonFallbackInstruction(descriptor) {
|
|
14
|
+
return descriptor.jsonFallbackInstruction ?? DEFAULT_JSON_FALLBACK_INSTRUCTION;
|
|
15
|
+
}
|
|
16
|
+
const OPENAI_STRICT_FORMATS = new Set([
|
|
17
|
+
'date-time', 'time', 'date', 'duration', 'email', 'hostname', 'ipv4', 'ipv6', 'uuid',
|
|
18
|
+
]);
|
|
19
|
+
/** Lenient descriptor — no rewrites, no closure, formats pass through. Default for unsupported models. */
|
|
20
|
+
export const LENIENT = Object.freeze({
|
|
21
|
+
id: 'lenient',
|
|
22
|
+
family: 'lenient',
|
|
23
|
+
strict: false,
|
|
24
|
+
objectAllFieldsRequired: false,
|
|
25
|
+
objectClosedByDefault: false,
|
|
26
|
+
recordEncoding: 'open-record',
|
|
27
|
+
tupleEncoding: 'prefix-items',
|
|
28
|
+
allowAllOf: true,
|
|
29
|
+
allowAnyOf: true,
|
|
30
|
+
allowOneOf: true,
|
|
31
|
+
allowRootRef: true,
|
|
32
|
+
allowDefsRef: true,
|
|
33
|
+
emitPropertyOrdering: false,
|
|
34
|
+
supportedStringFormats: 'all',
|
|
35
|
+
allowPattern: true,
|
|
36
|
+
allowMultiplePatterns: true,
|
|
37
|
+
allowMinMaxLength: true,
|
|
38
|
+
allowMinMaxItems: true,
|
|
39
|
+
allowMinimumMaximum: true,
|
|
40
|
+
optionalAsNullable: false,
|
|
41
|
+
anyEncoding: 'recursive-open',
|
|
42
|
+
supportsRecursion: true,
|
|
43
|
+
});
|
|
44
|
+
/**
|
|
45
|
+
* OpenAI strict — current behavior verbatim. Records→pairs,
|
|
46
|
+
* tuples→numeric-keys, optional→nullable.
|
|
47
|
+
*
|
|
48
|
+
* No documented per-request slot limit (max strict tools / optional params /
|
|
49
|
+
* union types). The SchemaBudget treats those caps as `undefined` (no limit)
|
|
50
|
+
* for OpenAI; OpenAI's own ~5000-property and ~5-level-depth schema caps
|
|
51
|
+
* aren't enforced here either — they're rare in practice and produce a
|
|
52
|
+
* server-side rejection that the user can react to.
|
|
53
|
+
*/
|
|
54
|
+
export const OPENAI_STRICT = Object.freeze({
|
|
55
|
+
id: 'openai-strict',
|
|
56
|
+
family: 'openai',
|
|
57
|
+
strict: true,
|
|
58
|
+
objectAllFieldsRequired: true,
|
|
59
|
+
objectClosedByDefault: true,
|
|
60
|
+
recordEncoding: 'array-of-pairs',
|
|
61
|
+
tupleEncoding: 'object-numeric-keys',
|
|
62
|
+
allowAllOf: false,
|
|
63
|
+
allowAnyOf: true,
|
|
64
|
+
allowOneOf: false,
|
|
65
|
+
allowRootRef: true,
|
|
66
|
+
allowDefsRef: true,
|
|
67
|
+
emitPropertyOrdering: false,
|
|
68
|
+
supportedStringFormats: OPENAI_STRICT_FORMATS,
|
|
69
|
+
allowPattern: true,
|
|
70
|
+
allowMultiplePatterns: false,
|
|
71
|
+
allowMinMaxLength: false,
|
|
72
|
+
allowMinMaxItems: false,
|
|
73
|
+
allowMinimumMaximum: false,
|
|
74
|
+
optionalAsNullable: true,
|
|
75
|
+
anyEncoding: 'recursive-strict',
|
|
76
|
+
supportsRecursion: true,
|
|
77
|
+
});
|
|
78
|
+
/** OpenAI non-strict — alias of LENIENT but tagged with the openai family. */
|
|
79
|
+
export const OPENAI_NON_STRICT = Object.freeze({
|
|
80
|
+
...LENIENT,
|
|
81
|
+
id: 'openai-non-strict',
|
|
82
|
+
family: 'openai',
|
|
83
|
+
});
|
|
84
|
+
/**
|
|
85
|
+
* Anthropic strict — Claude 4.5+ only (Opus 4.7/4.6/4.5, Sonnet 4.6/4.5,
|
|
86
|
+
* Haiku 4.5). Per Anthropic's structured-outputs docs:
|
|
87
|
+
*
|
|
88
|
+
* - `additionalProperties` may **only** be `false` (no schema-valued open
|
|
89
|
+
* records), so `z.record(...)` falls back to the OpenAI-style
|
|
90
|
+
* array-of-pairs workaround.
|
|
91
|
+
* - Non-cyclic `$ref` / `$defs` reuse is supported and used freely — only
|
|
92
|
+
* *circular* `$defs` graphs are rejected by the API
|
|
93
|
+
* (`Circular reference detected in schema definitions: …`). The emitter
|
|
94
|
+
* sets `supportsRecursion: false` so the cycle-breaker replaces the
|
|
95
|
+
* offending back-edge with the flat "any" shape (see `anyEncoding`) and
|
|
96
|
+
* leaves shared non-cyclic `$ref`s alone. Root self-reference (`$ref: '#'`)
|
|
97
|
+
* is always cyclic by definition, so `allowRootRef` stays `false`.
|
|
98
|
+
* - Numerical (`minimum`/`maximum`/`multipleOf`) and string-length
|
|
99
|
+
* (`minLength`/`maxLength`) constraints are not supported and are dropped.
|
|
100
|
+
* - `prefixItems` and other positional tuple keywords are not in the
|
|
101
|
+
* supported list, so tuples collapse to a homogeneous `items: anyOf`.
|
|
102
|
+
* - Supported formats include `uri` (unlike OpenAI strict). We pass all
|
|
103
|
+
* formats through and let Anthropic ignore unknowns.
|
|
104
|
+
*/
|
|
105
|
+
export const ANTHROPIC_STRICT = Object.freeze({
|
|
106
|
+
id: 'anthropic-strict',
|
|
107
|
+
family: 'anthropic',
|
|
108
|
+
strict: true,
|
|
109
|
+
objectAllFieldsRequired: false,
|
|
110
|
+
objectClosedByDefault: true,
|
|
111
|
+
recordEncoding: 'array-of-pairs',
|
|
112
|
+
tupleEncoding: 'items-union',
|
|
113
|
+
allowAllOf: true,
|
|
114
|
+
allowAnyOf: true,
|
|
115
|
+
allowOneOf: false,
|
|
116
|
+
allowRootRef: false,
|
|
117
|
+
allowDefsRef: true,
|
|
118
|
+
emitPropertyOrdering: false,
|
|
119
|
+
supportedStringFormats: 'all',
|
|
120
|
+
allowPattern: true,
|
|
121
|
+
allowMultiplePatterns: false,
|
|
122
|
+
allowMinMaxLength: false,
|
|
123
|
+
allowMinMaxItems: false,
|
|
124
|
+
allowMinimumMaximum: false,
|
|
125
|
+
optionalAsNullable: false,
|
|
126
|
+
// Anthropic rejects recursive `$defs/Any` definitions, so encode `z.any()`
|
|
127
|
+
// as a flat `anyOf` over every JSON value — equivalent to TypeScript `any`.
|
|
128
|
+
anyEncoding: 'flat',
|
|
129
|
+
// Anthropic-documented per-request limits (apply across ALL strict tool
|
|
130
|
+
// schemas + JSON output schemas in one request).
|
|
131
|
+
// Source: https://platform.claude.com/docs/en/build-with-claude/structured-outputs
|
|
132
|
+
maxStrictTools: 20,
|
|
133
|
+
maxStrictOptionalParams: 24,
|
|
134
|
+
maxStrictUnionTypes: 16,
|
|
135
|
+
// Recursive `$ref` graphs are rejected by Anthropic's tool input_schema
|
|
136
|
+
// validator. The toJSONSchema emitter detects cycles and replaces the
|
|
137
|
+
// back-edge with an inline flat "any" shape (see `anyEncoding`); shared
|
|
138
|
+
// non-cyclic `$ref`s are still emitted normally.
|
|
139
|
+
supportsRecursion: false,
|
|
140
|
+
});
|
|
141
|
+
export const ANTHROPIC_NON_STRICT = Object.freeze({
|
|
142
|
+
...LENIENT,
|
|
143
|
+
id: 'anthropic-non-strict',
|
|
144
|
+
family: 'anthropic',
|
|
145
|
+
});
|
|
146
|
+
/**
|
|
147
|
+
* Google Gemini strict — per `ai.google.dev/gemini-api/docs/structured-output`:
|
|
148
|
+
*
|
|
149
|
+
* - Supported keywords: types, `properties`, `required`, `additionalProperties`,
|
|
150
|
+
* `enum`, `format` (date-time/date/time only), `minimum`, `maximum`, `items`,
|
|
151
|
+
* `prefixItems`, `minItems`, `maxItems`, `title`, `description`,
|
|
152
|
+
* `propertyOrdering`. Recursion via `$ref: "#"` (root self-ref).
|
|
153
|
+
* - Combinators (`allOf`, `anyOf`, `oneOf`) are NOT in the supported list;
|
|
154
|
+
* we conservatively flag them off so unions degrade to a representable form
|
|
155
|
+
* rather than emit something Gemini ignores.
|
|
156
|
+
* - String constraints (`minLength`, `maxLength`, `pattern` formats beyond the
|
|
157
|
+
* three documented) are not supported.
|
|
158
|
+
* - `propertyOrdering` is REQUIRED for Gemini 2.0 strict; we always emit it
|
|
159
|
+
* on object schemas under this descriptor.
|
|
160
|
+
*/
|
|
161
|
+
export const GOOGLE_STRICT = Object.freeze({
|
|
162
|
+
id: 'google-strict',
|
|
163
|
+
family: 'google',
|
|
164
|
+
strict: true,
|
|
165
|
+
objectAllFieldsRequired: false,
|
|
166
|
+
objectClosedByDefault: false,
|
|
167
|
+
recordEncoding: 'open-record',
|
|
168
|
+
tupleEncoding: 'prefix-items',
|
|
169
|
+
allowAllOf: false,
|
|
170
|
+
allowAnyOf: false,
|
|
171
|
+
allowOneOf: false,
|
|
172
|
+
allowRootRef: true,
|
|
173
|
+
allowDefsRef: false,
|
|
174
|
+
emitPropertyOrdering: true,
|
|
175
|
+
supportedStringFormats: new Set(['date-time', 'date', 'time']),
|
|
176
|
+
allowPattern: false,
|
|
177
|
+
allowMultiplePatterns: false,
|
|
178
|
+
allowMinMaxLength: false,
|
|
179
|
+
allowMinMaxItems: true,
|
|
180
|
+
allowMinimumMaximum: true,
|
|
181
|
+
optionalAsNullable: false,
|
|
182
|
+
anyEncoding: 'recursive-open',
|
|
183
|
+
// No documented per-request slot limits.
|
|
184
|
+
supportsRecursion: true,
|
|
185
|
+
// Gemini's structured-output endpoint rejects `anyOf`/`$defs` schemas
|
|
186
|
+
// (HTTP 400). When a schema can't be expressed here it's delivered as
|
|
187
|
+
// prompt text instead; this hint keeps the reply a single raw JSON object.
|
|
188
|
+
jsonFallbackInstruction: 'Return ONLY a single raw JSON object that conforms to the schema above. Do NOT wrap it in markdown code fences, do NOT add any prose before or after it, and do NOT echo the schema itself — emit only the JSON instance.',
|
|
189
|
+
});
|
|
190
|
+
export const GOOGLE_NON_STRICT = Object.freeze({
|
|
191
|
+
...LENIENT,
|
|
192
|
+
id: 'google-non-strict',
|
|
193
|
+
family: 'google',
|
|
194
|
+
jsonFallbackInstruction: 'Return ONLY a single raw JSON object that conforms to the schema above. Do NOT wrap it in markdown code fences, do NOT add any prose before or after it, and do NOT echo the schema itself — emit only the JSON instance.',
|
|
195
|
+
});
|
|
196
|
+
// ============================================================================
|
|
197
|
+
// Descriptor registry — mutable lookup tables for built-in + user-registered
|
|
198
|
+
// descriptors. `registerDescriptor` adds to both maps; `getDescriptor` /
|
|
199
|
+
// `getDescriptorById` consult them on every lookup so newly-registered
|
|
200
|
+
// descriptors are immediately addressable by family or id.
|
|
201
|
+
// ============================================================================
|
|
202
|
+
const DESCRIPTORS_BY_ID = new Map();
|
|
203
|
+
// Keyed by family name. Each family slot holds its strict and lenient variants.
|
|
204
|
+
// `'lenient'` always points to `LENIENT` for both keys.
|
|
205
|
+
const DESCRIPTORS_BY_FAMILY = new Map();
|
|
206
|
+
function indexDescriptor(d) {
|
|
207
|
+
DESCRIPTORS_BY_ID.set(d.id, d);
|
|
208
|
+
const slot = DESCRIPTORS_BY_FAMILY.get(d.family) ?? {};
|
|
209
|
+
if (d.strict)
|
|
210
|
+
slot.strict = d;
|
|
211
|
+
else
|
|
212
|
+
slot.lenient = d;
|
|
213
|
+
DESCRIPTORS_BY_FAMILY.set(d.family, slot);
|
|
214
|
+
}
|
|
215
|
+
// Seed the registry with built-ins.
|
|
216
|
+
for (const d of [
|
|
217
|
+
LENIENT,
|
|
218
|
+
OPENAI_STRICT, OPENAI_NON_STRICT,
|
|
219
|
+
ANTHROPIC_STRICT, ANTHROPIC_NON_STRICT,
|
|
220
|
+
GOOGLE_STRICT, GOOGLE_NON_STRICT,
|
|
221
|
+
]) {
|
|
222
|
+
indexDescriptor(d);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Register a custom `FormatDescriptor` so it can be looked up by `id` or
|
|
226
|
+
* by `(family, strict)`. Useful for adding support for a new provider
|
|
227
|
+
* dialect or for registering a tweaked variant of an existing family.
|
|
228
|
+
*
|
|
229
|
+
* The descriptor's `id` and `family` become the lookup keys. If a
|
|
230
|
+
* descriptor with the same id is already registered, the new one
|
|
231
|
+
* replaces it. The family slot tracks one strict and one lenient
|
|
232
|
+
* variant; registering a third variant overwrites the matching slot.
|
|
233
|
+
*
|
|
234
|
+
* Built-in descriptors (`OPENAI_STRICT`, `LENIENT`, etc.) are seeded at
|
|
235
|
+
* module load — you don't need to register them manually. They CAN be
|
|
236
|
+
* overridden by registering a same-id descriptor afterwards, but doing
|
|
237
|
+
* so is generally a sign that you should pick a different id.
|
|
238
|
+
*
|
|
239
|
+
* @example
|
|
240
|
+
* ```ts
|
|
241
|
+
* import { registerDescriptor, OPENAI_STRICT } from '@aeye/core';
|
|
242
|
+
*
|
|
243
|
+
* // A tighter OpenAI-flavored descriptor: same wire shape but with
|
|
244
|
+
* // pattern support disabled (some downstream tools choke on regex).
|
|
245
|
+
* registerDescriptor({
|
|
246
|
+
* ...OPENAI_STRICT,
|
|
247
|
+
* id: 'openai-no-regex',
|
|
248
|
+
* family: 'openai-no-regex',
|
|
249
|
+
* allowPattern: false,
|
|
250
|
+
* allowMultiplePatterns: false,
|
|
251
|
+
* });
|
|
252
|
+
*
|
|
253
|
+
* // Now resolvable:
|
|
254
|
+
* getDescriptor('openai-no-regex', true); // → the registered descriptor
|
|
255
|
+
* getDescriptorById('openai-no-regex'); // → same
|
|
256
|
+
* ```
|
|
257
|
+
*/
|
|
258
|
+
export function registerDescriptor(descriptor) {
|
|
259
|
+
indexDescriptor(descriptor);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Resolve a descriptor by family + strictness.
|
|
263
|
+
*
|
|
264
|
+
* Built-in families (`'openai'`, `'anthropic'`, `'google'`) always
|
|
265
|
+
* resolve. Custom families registered via `registerDescriptor` resolve
|
|
266
|
+
* once they're registered. Unknown families fall back to `LENIENT`
|
|
267
|
+
* (safe default — strict mode silently degrades for unrecognized models).
|
|
268
|
+
*
|
|
269
|
+
* @param family - provider family (built-in or registered)
|
|
270
|
+
* @param strict - whether the strict variant is wanted
|
|
271
|
+
*/
|
|
272
|
+
export function getDescriptor(family, strict) {
|
|
273
|
+
if (!strict) {
|
|
274
|
+
const slot = DESCRIPTORS_BY_FAMILY.get(family);
|
|
275
|
+
return slot?.lenient ?? LENIENT;
|
|
276
|
+
}
|
|
277
|
+
const slot = DESCRIPTORS_BY_FAMILY.get(family);
|
|
278
|
+
return slot?.strict ?? LENIENT;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Look up a descriptor by id. Returns LENIENT for unknown ids (safe default).
|
|
282
|
+
* Custom descriptors registered via `registerDescriptor` are reachable here
|
|
283
|
+
* by their `id`.
|
|
284
|
+
*/
|
|
285
|
+
export function getDescriptorById(id) {
|
|
286
|
+
if (!id)
|
|
287
|
+
return LENIENT;
|
|
288
|
+
return DESCRIPTORS_BY_ID.get(id) ?? LENIENT;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* True if `family` has at least one registered descriptor (strict or
|
|
292
|
+
* lenient). Used by `@aeye/ai`'s `resolveStrictFormat` fallback chain to
|
|
293
|
+
* decide whether a model id prefix or provider name is a recognized
|
|
294
|
+
* dialect.
|
|
295
|
+
*/
|
|
296
|
+
export function hasDescriptorFamily(family) {
|
|
297
|
+
if (family === undefined)
|
|
298
|
+
return false;
|
|
299
|
+
return DESCRIPTORS_BY_FAMILY.has(family);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Resolve descriptor from various input shapes (boolean, options object, or descriptor).
|
|
303
|
+
* Used by `toJSONSchema` to support its overloaded signature.
|
|
304
|
+
*/
|
|
305
|
+
export function resolveDescriptor(input) {
|
|
306
|
+
if (input === undefined)
|
|
307
|
+
return OPENAI_STRICT;
|
|
308
|
+
if (typeof input === 'boolean')
|
|
309
|
+
return input ? OPENAI_STRICT : LENIENT;
|
|
310
|
+
if ('id' in input && 'family' in input)
|
|
311
|
+
return input;
|
|
312
|
+
const opts = input;
|
|
313
|
+
return getDescriptor(opts.format ?? 'openai', opts.strict);
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Decide whether `schema` can be expressed as structured output under
|
|
317
|
+
* `descriptor`'s JSON-Schema dialect. Returns `false` when the schema uses a
|
|
318
|
+
* construct the descriptor forbids, so the caller can DROP the wire schema and
|
|
319
|
+
* deliver it as prompt text instead (see the ai-layer
|
|
320
|
+
* `applySchemaDeliveryFallback`).
|
|
321
|
+
*
|
|
322
|
+
* Walks the Zod schema in the same style as `strictify`/`convert`, honoring:
|
|
323
|
+
* - **unions** (`z.union` / `z.discriminatedUnion` → `anyOf`) when
|
|
324
|
+
* `!descriptor.allowAnyOf` (e.g. Gemini strict rejects `anyOf`);
|
|
325
|
+
* - **intersections** (`z.intersection` → `allOf`) when `!descriptor.allowAllOf`;
|
|
326
|
+
* - **recursion** (a self-referential `z.lazy` cycle → `$ref`): a cycle back to
|
|
327
|
+
* the ROOT is fine when `descriptor.allowRootRef && descriptor.supportsRecursion`
|
|
328
|
+
* (Gemini supports `$ref: '#'`); a non-root cycle needs
|
|
329
|
+
* `descriptor.allowDefsRef && descriptor.supportsRecursion`.
|
|
330
|
+
*
|
|
331
|
+
* `ZodNullable` / `ZodOptional` are transparent wrappers here (nullability is
|
|
332
|
+
* NOT treated as a union), so only genuine `z.union(...)` trips `allowAnyOf`.
|
|
333
|
+
* Non-combinator, non-recursive schemas (plain objects, arrays, primitives)
|
|
334
|
+
* are always expressible. The structure mirrors the descriptor flags so more
|
|
335
|
+
* feasibility checks are easy to add.
|
|
336
|
+
*/
|
|
337
|
+
export function canExpress(schema, descriptor) {
|
|
338
|
+
const root = schema;
|
|
339
|
+
// Nodes currently on the DFS stack — a re-encounter is a recursion cycle.
|
|
340
|
+
const inProgress = new Set();
|
|
341
|
+
// Completed nodes with their result, so shared (non-cyclic) sub-schemas
|
|
342
|
+
// aren't re-walked (guards against exponential blow-up on DAG-shaped schemas).
|
|
343
|
+
const completed = new Map();
|
|
344
|
+
const walk = (s) => {
|
|
345
|
+
if (inProgress.has(s)) {
|
|
346
|
+
// Recursion cycle detected at `s`.
|
|
347
|
+
if (s === root)
|
|
348
|
+
return descriptor.allowRootRef && descriptor.supportsRecursion;
|
|
349
|
+
return descriptor.allowDefsRef && descriptor.supportsRecursion;
|
|
350
|
+
}
|
|
351
|
+
const cached = completed.get(s);
|
|
352
|
+
if (cached !== undefined)
|
|
353
|
+
return cached;
|
|
354
|
+
inProgress.add(s);
|
|
355
|
+
const result = walkNode(s);
|
|
356
|
+
inProgress.delete(s);
|
|
357
|
+
completed.set(s, result);
|
|
358
|
+
return result;
|
|
359
|
+
};
|
|
360
|
+
const walkNode = (s) => {
|
|
361
|
+
// ---- Transparent wrappers: recurse into the inner type ----
|
|
362
|
+
if (s instanceof z.ZodOptional || s instanceof z.ZodNullable)
|
|
363
|
+
return walk(s.unwrap());
|
|
364
|
+
if (s instanceof z.ZodDefault)
|
|
365
|
+
return walk(s._zod.def.innerType);
|
|
366
|
+
if (s instanceof z.ZodPrefault)
|
|
367
|
+
return walk(s._zod.def.innerType);
|
|
368
|
+
if (s instanceof z.ZodCatch)
|
|
369
|
+
return walk(s._zod.def.innerType);
|
|
370
|
+
if (s instanceof z.ZodReadonly)
|
|
371
|
+
return walk(s._zod.def.innerType);
|
|
372
|
+
if (s instanceof z.ZodNonOptional)
|
|
373
|
+
return walk(s._zod.def.innerType);
|
|
374
|
+
if (s instanceof z.ZodLazy)
|
|
375
|
+
return walk(s._zod.def.getter());
|
|
376
|
+
// ZodCodec must be checked before ZodPipe (it's a ZodPipe subclass in v4).
|
|
377
|
+
if (s instanceof z.ZodCodec)
|
|
378
|
+
return walk(s._zod.def.in) && walk(s._zod.def.out);
|
|
379
|
+
if (s instanceof z.ZodPipe)
|
|
380
|
+
return walk(s._zod.def.in) && walk(s._zod.def.out);
|
|
381
|
+
// ---- Combinators (the ones that actually bite) ----
|
|
382
|
+
// ZodDiscriminatedUnion is a ZodUnion subclass in v4, so this covers both.
|
|
383
|
+
if (s instanceof z.ZodUnion) {
|
|
384
|
+
if (!descriptor.allowAnyOf)
|
|
385
|
+
return false;
|
|
386
|
+
return s.options.every(walk);
|
|
387
|
+
}
|
|
388
|
+
if (s instanceof z.ZodIntersection) {
|
|
389
|
+
if (!descriptor.allowAllOf)
|
|
390
|
+
return false;
|
|
391
|
+
return walk(s._zod.def.left) && walk(s._zod.def.right);
|
|
392
|
+
}
|
|
393
|
+
// ---- Containers: recurse so nested combinators/cycles are reached ----
|
|
394
|
+
if (s instanceof z.ZodObject) {
|
|
395
|
+
for (const key in s.shape) {
|
|
396
|
+
if (!walk(s.shape[key]))
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
if (s instanceof z.ZodArray)
|
|
402
|
+
return walk(s._zod.def.element);
|
|
403
|
+
if (s instanceof z.ZodTuple) {
|
|
404
|
+
for (const item of s._zod.def.items) {
|
|
405
|
+
if (!walk(item))
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
return s._zod.def.rest ? walk(s._zod.def.rest) : true;
|
|
409
|
+
}
|
|
410
|
+
if (s instanceof z.ZodRecord)
|
|
411
|
+
return walk(s._zod.def.valueType);
|
|
412
|
+
if (s instanceof z.ZodMap)
|
|
413
|
+
return walk(s._zod.def.keyType) && walk(s._zod.def.valueType);
|
|
414
|
+
if (s instanceof z.ZodSet)
|
|
415
|
+
return walk(s._zod.def.valueType);
|
|
416
|
+
// ---- Leaves / anything without forbidden substructure ----
|
|
417
|
+
return true;
|
|
418
|
+
};
|
|
419
|
+
return walk(schema);
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Module-scope cache. Per-source-schema entry survives only as long as the
|
|
423
|
+
* source schema does — the WeakMap entry is gc'd with it, so all per-format
|
|
424
|
+
* strictified clones go with it. The inner Map is keyed by descriptor.id
|
|
425
|
+
* (small bounded set) so it cannot grow unboundedly.
|
|
426
|
+
*/
|
|
427
|
+
const strictifyCache = new WeakMap();
|
|
428
|
+
/**
|
|
429
|
+
* Recursively transforms a Zod schema to a target dialect.
|
|
430
|
+
*
|
|
431
|
+
* For LENIENT, returns the input schema unchanged (no rewrites). For strict
|
|
432
|
+
* descriptors, installs preprocesses that accept the dialect's wire shape
|
|
433
|
+
* (e.g. array-of-pairs records, numeric-key tuple objects) and normalize them
|
|
434
|
+
* back to the natural Zod shape before validation.
|
|
435
|
+
*
|
|
436
|
+
* Repeated calls with the same `(schema, descriptor)` return the cached
|
|
437
|
+
* transformed schema. Different descriptors share the same outer entry but
|
|
438
|
+
* map to different inner values — bounded by the small descriptor count.
|
|
439
|
+
*
|
|
440
|
+
* @param schema - input Zod schema
|
|
441
|
+
* @param descriptor - target dialect descriptor (defaults to OPENAI_STRICT)
|
|
442
|
+
*/
|
|
443
|
+
export function strictify(schema, descriptor = OPENAI_STRICT) {
|
|
444
|
+
// LENIENT is a no-op: same reference, no cache entry needed.
|
|
445
|
+
if (descriptor.id === LENIENT.id)
|
|
446
|
+
return schema;
|
|
447
|
+
let perSchema = strictifyCache.get(schema);
|
|
448
|
+
if (!perSchema) {
|
|
449
|
+
perSchema = new Map();
|
|
450
|
+
strictifyCache.set(schema, perSchema);
|
|
451
|
+
}
|
|
452
|
+
const cached = perSchema.get(descriptor.id);
|
|
453
|
+
if (cached)
|
|
454
|
+
return cached;
|
|
455
|
+
const result = strictifyWithDescriptor(schema, descriptor);
|
|
456
|
+
perSchema.set(descriptor.id, result);
|
|
457
|
+
return result;
|
|
458
|
+
}
|
|
459
|
+
function strictifyWithDescriptor(schema, descriptor) {
|
|
460
|
+
// Per-call cycle map. Same scheme as before: cache lazy-thunks for in-flight
|
|
461
|
+
// schemas so recursive references emit a `z.lazy(() => result)` rather than
|
|
462
|
+
// recursing forever.
|
|
9
463
|
const map = new Map();
|
|
10
464
|
const transform = (s) => {
|
|
11
465
|
const cached = map.get(s);
|
|
@@ -19,7 +473,7 @@ export function strictify(schema) {
|
|
|
19
473
|
}
|
|
20
474
|
let result;
|
|
21
475
|
map.set(s, () => result);
|
|
22
|
-
result = strictifySimple(s, transform);
|
|
476
|
+
result = strictifySimple(s, transform, descriptor);
|
|
23
477
|
map.set(s, result);
|
|
24
478
|
return result;
|
|
25
479
|
};
|
|
@@ -27,10 +481,6 @@ export function strictify(schema) {
|
|
|
27
481
|
}
|
|
28
482
|
/**
|
|
29
483
|
* Transfer description and metadata from source Zod schema to target Zod schema
|
|
30
|
-
*
|
|
31
|
-
* @param target - target Zod schema
|
|
32
|
-
* @param source - source Zod schema
|
|
33
|
-
* @returns
|
|
34
484
|
*/
|
|
35
485
|
function transferMetadata(target, source) {
|
|
36
486
|
if (source.description) {
|
|
@@ -43,42 +493,21 @@ function transferMetadata(target, source) {
|
|
|
43
493
|
return target;
|
|
44
494
|
}
|
|
45
495
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* This is needed when building input schemas that contain transformed fields.
|
|
496
|
+
* Per-node strictify dispatch. Each branch consults the descriptor to decide
|
|
497
|
+
* whether to install the dialect-specific preprocess.
|
|
49
498
|
*/
|
|
50
|
-
function
|
|
51
|
-
|
|
52
|
-
return schema.def.in;
|
|
53
|
-
}
|
|
54
|
-
// For ZodPipe (which preprocess creates), check if the output is optional
|
|
55
|
-
// If so, we need to return a nullable version for the input schema
|
|
56
|
-
if (schema instanceof z.ZodPipe) {
|
|
57
|
-
const outputSchema = schema.def.out;
|
|
58
|
-
if (outputSchema instanceof z.ZodOptional) {
|
|
59
|
-
// The input from preprocess is already set up to handle null->undefined conversion
|
|
60
|
-
// So we just need to make sure the base type accepts null
|
|
61
|
-
return z.nullable(outputSchema.unwrap());
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return schema;
|
|
65
|
-
}
|
|
66
|
-
/**
|
|
67
|
-
* Recursively transforms a Zod schema to support strict mode.
|
|
68
|
-
*
|
|
69
|
-
* @param schema - input Zod schema
|
|
70
|
-
* @returns transformed Zod schema
|
|
71
|
-
*/
|
|
72
|
-
function strictifySimple(schema, transform) {
|
|
73
|
-
// Handle ZodOptional - get the inner schema and make it optional
|
|
499
|
+
function strictifySimple(schema, transform, descriptor) {
|
|
500
|
+
// Handle ZodOptional
|
|
74
501
|
if (schema instanceof z.ZodOptional) {
|
|
75
502
|
const innerSchema = schema.unwrap();
|
|
76
503
|
const transformed = transform(innerSchema);
|
|
77
|
-
//
|
|
78
|
-
//
|
|
504
|
+
// Only install the null→undefined preprocess for dialects that rewrite
|
|
505
|
+
// optional to nullable on the wire (OpenAI strict). For other dialects,
|
|
506
|
+
// optional stays optional and the preprocess is unnecessary.
|
|
507
|
+
if (!descriptor.optionalAsNullable) {
|
|
508
|
+
return transferMetadata(transformed.optional(), schema);
|
|
509
|
+
}
|
|
79
510
|
const isNullable = innerSchema instanceof z.ZodNullable;
|
|
80
|
-
// Use a preprocess to convert null to undefined only for non-nullable schemas
|
|
81
|
-
// This avoids issues with codec output validation in recursive schemas
|
|
82
511
|
return transferMetadata(z.preprocess((val) => (!isNullable && val === null) ? undefined : val, transformed.optional()), schema);
|
|
83
512
|
}
|
|
84
513
|
// Handle ZodObject
|
|
@@ -109,22 +538,40 @@ function strictifySimple(schema, transform) {
|
|
|
109
538
|
return transferMetadata(z.array(transform(schema.element)), schema);
|
|
110
539
|
}
|
|
111
540
|
// Handle ZodRecord
|
|
541
|
+
// Strict-mode JSON Schema represents records as `array of {key, value}` —
|
|
542
|
+
// OpenAI's structured outputs has no native pattern for open records, so
|
|
543
|
+
// we rewrite. Previously this used `z.codec(arrayIn, recordOut, decode)`,
|
|
544
|
+
// but z.codec proved fragile inside recursive lazy unions: the inner
|
|
545
|
+
// codec's transform doesn't always run when validation traverses a
|
|
546
|
+
// sibling branch, leaving the data in array form when later code expects
|
|
547
|
+
// record form (and vice-versa) — surfacing as "expected array, received
|
|
548
|
+
// object" union failures.
|
|
549
|
+
//
|
|
550
|
+
// `z.preprocess` is more robust here: it normalizes the input to record
|
|
551
|
+
// shape BEFORE the record schema sees it. Either array-of-pairs (from a
|
|
552
|
+
// strict-mode model) or already-a-record (when the schema is reused
|
|
553
|
+
// outside strict context) is accepted; both arrive at the record schema
|
|
554
|
+
// as a record.
|
|
555
|
+
//
|
|
556
|
+
// Only installed when the descriptor wants the array-of-pairs encoding.
|
|
112
557
|
if (schema instanceof z.ZodRecord) {
|
|
113
558
|
const keyTransformed = schema.keyType ? transform(schema.keyType) : z.string();
|
|
114
559
|
const valueTransformed = transform(schema.valueType);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
return transferMetadata(z.
|
|
119
|
-
|
|
560
|
+
if (descriptor.recordEncoding !== 'array-of-pairs') {
|
|
561
|
+
return transferMetadata(z.record(keyTransformed, valueTransformed), schema);
|
|
562
|
+
}
|
|
563
|
+
return transferMetadata(z.preprocess((val) => {
|
|
564
|
+
if (Array.isArray(val)) {
|
|
120
565
|
const record = {};
|
|
121
|
-
for (const
|
|
122
|
-
|
|
566
|
+
for (const entry of val) {
|
|
567
|
+
if (entry && typeof entry === 'object' && 'key' in entry && 'value' in entry) {
|
|
568
|
+
record[entry.key] = entry.value;
|
|
569
|
+
}
|
|
123
570
|
}
|
|
124
571
|
return record;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
}), schema);
|
|
572
|
+
}
|
|
573
|
+
return val;
|
|
574
|
+
}, z.record(keyTransformed, valueTransformed)), schema);
|
|
128
575
|
}
|
|
129
576
|
// Handle ZodUnion
|
|
130
577
|
if (schema instanceof z.ZodUnion) {
|
|
@@ -139,10 +586,41 @@ function strictifySimple(schema, transform) {
|
|
|
139
586
|
return transferMetadata(z.intersection(transform(schema.def.left), transform(schema.def.right)), schema);
|
|
140
587
|
}
|
|
141
588
|
// Handle ZodTuple
|
|
589
|
+
// Strict-mode JSON Schema represents tuples as an object with numeric
|
|
590
|
+
// string keys (`{"0": <T0>, "1": <T1>, ...}`) — OpenAI's structured
|
|
591
|
+
// outputs has no positional `prefixItems` support and would otherwise
|
|
592
|
+
// collapse a `[string, number, bool]` to an array of `(string|number|bool)`,
|
|
593
|
+
// losing the per-position type. Encoding as an object preserves it.
|
|
594
|
+
// The strictified schema accepts EITHER form: an object with "0".."n-1"
|
|
595
|
+
// keys (what a strict-mode model produces) or an array (what a callsite
|
|
596
|
+
// outside strict context would pass). Both arrive at the tuple schema as
|
|
597
|
+
// an array.
|
|
598
|
+
//
|
|
599
|
+
// Only installed when the descriptor wants the numeric-keys encoding.
|
|
142
600
|
if (schema instanceof z.ZodTuple) {
|
|
143
|
-
|
|
601
|
+
const items = schema.def.items.map(transform);
|
|
602
|
+
const rest = schema.def.rest ? transform(schema.def.rest) : undefined;
|
|
603
|
+
const tupleSchema = rest ? z.tuple(items, rest) : z.tuple(items);
|
|
604
|
+
if (descriptor.tupleEncoding !== 'object-numeric-keys') {
|
|
605
|
+
return transferMetadata(tupleSchema, schema);
|
|
606
|
+
}
|
|
607
|
+
return transferMetadata(z.preprocess((val) => {
|
|
608
|
+
if (val && typeof val === 'object' && !Array.isArray(val)) {
|
|
609
|
+
const obj = val;
|
|
610
|
+
const keys = Object.keys(obj);
|
|
611
|
+
if (keys.length > 0 && keys.every((k) => /^\d+$/.test(k))) {
|
|
612
|
+
const indices = keys.map((k) => parseInt(k, 10));
|
|
613
|
+
const len = Math.max(...indices) + 1;
|
|
614
|
+
const arr = new Array(len);
|
|
615
|
+
for (const k of keys)
|
|
616
|
+
arr[parseInt(k, 10)] = obj[k];
|
|
617
|
+
return arr;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return val;
|
|
621
|
+
}, tupleSchema), schema);
|
|
144
622
|
}
|
|
145
|
-
// Handle
|
|
623
|
+
// Handle ZodDefault
|
|
146
624
|
if (schema instanceof z.ZodDefault) {
|
|
147
625
|
return z.preprocess((val) => val === null ? undefined : val, transferMetadata(transform(schema.def.innerType).default(schema.def.defaultValue), schema));
|
|
148
626
|
}
|
|
@@ -153,15 +631,219 @@ function strictifySimple(schema, transform) {
|
|
|
153
631
|
// For all other types (primitives, etc.), return as-is
|
|
154
632
|
return schema;
|
|
155
633
|
}
|
|
634
|
+
// ============================================================================
|
|
635
|
+
// Wire → conceptual DECODE (symmetric with strictify's conceptual → wire ENCODE)
|
|
636
|
+
//
|
|
637
|
+
// `strictify` installs decode preprocesses (array-of-pairs → record,
|
|
638
|
+
// numeric-key object → tuple, null → undefined for optionals, …) that only
|
|
639
|
+
// run when Zod VALIDATES a value. A custom `parse` hook REPLACES Zod
|
|
640
|
+
// validation, so it never triggers those preprocesses — it would otherwise
|
|
641
|
+
// see the raw provider wire shape.
|
|
642
|
+
//
|
|
643
|
+
// `relaxValidation` turns a (strictified) schema into a NON-FAILING variant
|
|
644
|
+
// that KEEPS every transform but DROPS validation, so we can run it purely to
|
|
645
|
+
// execute the decode preprocesses and hand a custom parser the CONCEPTUAL
|
|
646
|
+
// value. `decodeWire` ties it together (strictify → relax → safeParse,
|
|
647
|
+
// best-effort).
|
|
648
|
+
// ============================================================================
|
|
156
649
|
/**
|
|
157
|
-
*
|
|
650
|
+
* Rebuild a `ZodObject` for the relaxed decoder.
|
|
158
651
|
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
652
|
+
* - Recurses each field through `relax` (so nested transforms still run).
|
|
653
|
+
* - `forceOptional`: when true every field becomes optional (max leniency —
|
|
654
|
+
* used everywhere EXCEPT union options). When false the field keeps its own
|
|
655
|
+
* optionality (used for union-option objects, where a required field is the
|
|
656
|
+
* discriminating signal that lets the union pick the right branch).
|
|
657
|
+
* - `loose`: when true (default) unknown keys pass straight through; when
|
|
658
|
+
* false the object STRIPS unknown keys. Intersection sides use `false` so
|
|
659
|
+
* each half only claims its own keys and the two halves merge without an
|
|
660
|
+
* "unmergable intersection" conflict (a loose side would echo the other
|
|
661
|
+
* half's raw key and collide with that half's transformed value).
|
|
662
|
+
*/
|
|
663
|
+
function relaxObjectShape(schema, relax, forceOptional, loose = true) {
|
|
664
|
+
const shape = {};
|
|
665
|
+
for (const key in schema.shape) {
|
|
666
|
+
const relaxed = relax(schema.shape[key]);
|
|
667
|
+
shape[key] = forceOptional ? relaxed.optional() : relaxed;
|
|
668
|
+
}
|
|
669
|
+
const obj = z.object(shape);
|
|
670
|
+
return loose ? obj.loose() : obj;
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Relax one side of an intersection. Object sides are rebuilt STRIP (not
|
|
674
|
+
* loose) so the two halves merge cleanly (see `relaxObjectShape`'s `loose`
|
|
675
|
+
* note); everything else relaxes normally.
|
|
676
|
+
*/
|
|
677
|
+
function relaxIntersectionSide(schema, relax) {
|
|
678
|
+
if (schema instanceof z.ZodObject) {
|
|
679
|
+
return relaxObjectShape(schema, relax, /* forceOptional */ true, /* loose */ false);
|
|
680
|
+
}
|
|
681
|
+
return relax(schema);
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Relax the options of a union (plain OR discriminated — the latter is a
|
|
685
|
+
* subclass of `ZodUnion` in Zod v4, so a single path covers both).
|
|
686
|
+
*
|
|
687
|
+
* Object options are rebuilt with their ORIGINAL requiredness preserved
|
|
688
|
+
* (`forceOptional: false`) so the union can discriminate by required-field
|
|
689
|
+
* presence and run the matching option's decode transforms. Non-object
|
|
690
|
+
* options are kept AS-IS: relaxing a leaf to `z.any()` would make it swallow
|
|
691
|
+
* every value (the first branch would always win), so the strictified option
|
|
692
|
+
* is left intact — it still discriminates by type and still carries any wire
|
|
693
|
+
* transform it needs. Validation on those non-object branches is therefore
|
|
694
|
+
* only best-effort, which is acceptable: `decodeWire` falls back to the
|
|
695
|
+
* original value if nothing matches.
|
|
696
|
+
*/
|
|
697
|
+
function relaxUnionOptions(options, relax) {
|
|
698
|
+
const relaxed = options.map((opt) => opt instanceof z.ZodObject ? relaxObjectShape(opt, relax, false) : opt);
|
|
699
|
+
return z.union(relaxed);
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Per-node dispatch for `relaxValidation`. Mirrors the node coverage of
|
|
703
|
+
* `strictifySimple` plus the extra wrappers/containers a strictified schema
|
|
704
|
+
* can contain (`ZodMap`, `ZodSet`, `ZodCatch`, `ZodReadonly`, `ZodPrefault`,
|
|
705
|
+
* `ZodNonOptional`, …). Unknown/leaf nodes bottom out at `z.any()`.
|
|
706
|
+
*/
|
|
707
|
+
function relaxNode(schema, relax) {
|
|
708
|
+
// ---- Wrappers: unwrap, relax inner, re-wrap ----
|
|
709
|
+
if (schema instanceof z.ZodOptional)
|
|
710
|
+
return relax(schema.unwrap()).optional();
|
|
711
|
+
if (schema instanceof z.ZodNullable)
|
|
712
|
+
return relax(schema.unwrap()).nullable();
|
|
713
|
+
if (schema instanceof z.ZodDefault) {
|
|
714
|
+
return relax(schema._zod.def.innerType).default(schema._zod.def.defaultValue);
|
|
715
|
+
}
|
|
716
|
+
if (schema instanceof z.ZodPrefault) {
|
|
717
|
+
return relax(schema._zod.def.innerType).prefault(schema._zod.def.defaultValue);
|
|
718
|
+
}
|
|
719
|
+
if (schema instanceof z.ZodCatch) {
|
|
720
|
+
return relax(schema._zod.def.innerType).catch(schema._zod.def.catchValue);
|
|
721
|
+
}
|
|
722
|
+
if (schema instanceof z.ZodReadonly)
|
|
723
|
+
return relax(schema._zod.def.innerType).readonly();
|
|
724
|
+
// NonOptional adds a "must be present" validation — drop it (we're relaxing).
|
|
725
|
+
if (schema instanceof z.ZodNonOptional)
|
|
726
|
+
return relax(schema._zod.def.innerType);
|
|
727
|
+
// ---- Transforms: KEEP the transform so the decode still runs ----
|
|
728
|
+
// ZodCodec must be checked before ZodPipe (it's a ZodPipe subclass in v4).
|
|
729
|
+
if (schema instanceof z.ZodCodec) {
|
|
730
|
+
return z.codec(relax(schema._zod.def.in), relax(schema._zod.def.out), { decode: schema._zod.def.transform, encode: schema._zod.def.reverseTransform });
|
|
731
|
+
}
|
|
732
|
+
// A bare transform (the `in` of a `preprocess`, the `out` of a `.transform`)
|
|
733
|
+
// carries no validation — keep it verbatim so the transform runs.
|
|
734
|
+
if (schema instanceof z.ZodTransform)
|
|
735
|
+
return schema;
|
|
736
|
+
if (schema instanceof z.ZodPipe) {
|
|
737
|
+
return z.pipe(relax(schema._zod.def.in), relax(schema._zod.def.out));
|
|
738
|
+
}
|
|
739
|
+
// ---- Containers: recurse so nested transforms are reached ----
|
|
740
|
+
if (schema instanceof z.ZodObject)
|
|
741
|
+
return relaxObjectShape(schema, relax, /* forceOptional */ true);
|
|
742
|
+
if (schema instanceof z.ZodArray)
|
|
743
|
+
return z.array(relax(schema._zod.def.element));
|
|
744
|
+
if (schema instanceof z.ZodTuple) {
|
|
745
|
+
const items = schema._zod.def.items.map(relax);
|
|
746
|
+
const rest = schema._zod.def.rest ? relax(schema._zod.def.rest) : undefined;
|
|
747
|
+
return rest ? z.tuple(items, rest) : z.tuple(items);
|
|
748
|
+
}
|
|
749
|
+
if (schema instanceof z.ZodRecord) {
|
|
750
|
+
// Accept any string key (records arrive with string-ish keys on the wire);
|
|
751
|
+
// an exhaustive/enum key schema would reject partial records.
|
|
752
|
+
return z.record(z.string(), relax(schema._zod.def.valueType));
|
|
753
|
+
}
|
|
754
|
+
if (schema instanceof z.ZodMap) {
|
|
755
|
+
return z.map(relax(schema._zod.def.keyType), relax(schema._zod.def.valueType));
|
|
756
|
+
}
|
|
757
|
+
if (schema instanceof z.ZodSet)
|
|
758
|
+
return z.set(relax(schema._zod.def.valueType));
|
|
759
|
+
// ---- Combinators ----
|
|
760
|
+
// ZodDiscriminatedUnion is a ZodUnion subclass in v4, so this one branch
|
|
761
|
+
// covers both. Discrimination survives via required-field presence.
|
|
762
|
+
if (schema instanceof z.ZodUnion)
|
|
763
|
+
return relaxUnionOptions(schema.options, relax);
|
|
764
|
+
if (schema instanceof z.ZodIntersection) {
|
|
765
|
+
return z.intersection(relaxIntersectionSide(schema._zod.def.left, relax), relaxIntersectionSide(schema._zod.def.right, relax));
|
|
766
|
+
}
|
|
767
|
+
// ---- Recursion: defer + memoize (see `relaxValidation`) ----
|
|
768
|
+
if (schema instanceof z.ZodLazy) {
|
|
769
|
+
return z.lazy(() => relax(schema._zod.def.getter()));
|
|
770
|
+
}
|
|
771
|
+
// ---- Validating leaves + anything unrecognized → accept everything ----
|
|
772
|
+
return z.any();
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Produce a NON-FAILING variant of `schema` that keeps every transform
|
|
776
|
+
* (`preprocess`, `codec`, `pipe`, `transform`) but drops validation. Parsing
|
|
777
|
+
* a wire value through the result runs the decode preprocesses installed by
|
|
778
|
+
* `strictify` (array-of-pairs → record, numeric-key object → tuple, null →
|
|
779
|
+
* undefined) and yields the conceptual value, without ever rejecting.
|
|
780
|
+
*
|
|
781
|
+
* Recursion (`z.lazy` self-reference) is handled with a per-call memo map: an
|
|
782
|
+
* in-flight node is recorded as a thunk and re-encounters resolve to
|
|
783
|
+
* `z.lazy(thunk)`, so a self-referential schema relaxes in finite time and
|
|
784
|
+
* never infinite-loops.
|
|
785
|
+
*/
|
|
786
|
+
export function relaxValidation(schema) {
|
|
787
|
+
// Per-call cycle map — same scheme as `strictifyWithDescriptor`: cache a
|
|
788
|
+
// lazy thunk for in-flight schemas so recursive references resolve to a
|
|
789
|
+
// `z.lazy(() => result)` instead of recursing forever.
|
|
790
|
+
const map = new Map();
|
|
791
|
+
const relax = (s) => {
|
|
792
|
+
const cached = map.get(s);
|
|
793
|
+
if (cached) {
|
|
794
|
+
return typeof cached === 'function' ? z.lazy(cached) : cached;
|
|
795
|
+
}
|
|
796
|
+
let result;
|
|
797
|
+
map.set(s, () => result);
|
|
798
|
+
result = relaxNode(s, relax);
|
|
799
|
+
map.set(s, result);
|
|
800
|
+
return result;
|
|
801
|
+
};
|
|
802
|
+
return relax(schema);
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Module-scope cache for relaxed decoders. Mirrors `strictifyCache`: outer
|
|
806
|
+
* WeakMap keyed by the SOURCE schema (gc'd with it), inner Map keyed by
|
|
807
|
+
* descriptor id (small bounded set).
|
|
808
|
+
*/
|
|
809
|
+
const relaxDecodeCache = new WeakMap();
|
|
810
|
+
/**
|
|
811
|
+
* DECODE a model's wire `value` back to the conceptual shape a custom parser
|
|
812
|
+
* expects — symmetric with how `strictify` ENCODEs the request for the wire.
|
|
813
|
+
*
|
|
814
|
+
* Runs the descriptor's decode transforms (via `relaxValidation(strictify(...))`)
|
|
815
|
+
* without imposing validation, so array-of-pairs records, numeric-key tuples,
|
|
816
|
+
* and null-for-optional fields are normalized before a provider-agnostic
|
|
817
|
+
* custom parser sees them. Best-effort: NEVER throws — if the relaxed decoder
|
|
818
|
+
* can't parse the value it is returned unchanged.
|
|
819
|
+
*
|
|
820
|
+
* @param schema - the PRE-strictify conceptual schema (strictify runs internally)
|
|
821
|
+
* @param value - the model's raw wire value (already `JSON.parse`d)
|
|
822
|
+
* @param descriptor - the wire dialect the request was encoded with
|
|
823
|
+
*/
|
|
824
|
+
export function decodeWire(schema, value, descriptor) {
|
|
825
|
+
let perSchema = relaxDecodeCache.get(schema);
|
|
826
|
+
if (!perSchema) {
|
|
827
|
+
perSchema = new Map();
|
|
828
|
+
relaxDecodeCache.set(schema, perSchema);
|
|
829
|
+
}
|
|
830
|
+
let decoder = perSchema.get(descriptor.id);
|
|
831
|
+
if (!decoder) {
|
|
832
|
+
decoder = relaxValidation(strictify(schema, descriptor));
|
|
833
|
+
perSchema.set(descriptor.id, decoder);
|
|
834
|
+
}
|
|
835
|
+
const result = decoder.safeParse(value);
|
|
836
|
+
return result.success ? result.data : value;
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Converts a Zod schema to JSON Schema with support for different provider dialects.
|
|
840
|
+
*
|
|
841
|
+
* Accepts either a boolean (legacy: true = OPENAI_STRICT, false = LENIENT),
|
|
842
|
+
* an options object `{ strict, format? }`, or a `FormatDescriptor` directly.
|
|
161
843
|
*
|
|
162
844
|
* @param schema - The Zod schema to convert
|
|
163
845
|
* @param options - Configuration options for the conversion
|
|
164
|
-
* @returns JSON Schema object compatible with the
|
|
846
|
+
* @returns JSON Schema object compatible with the resolved dialect
|
|
165
847
|
*
|
|
166
848
|
* @example
|
|
167
849
|
* ```typescript
|
|
@@ -171,25 +853,22 @@ function strictifySimple(schema, transform) {
|
|
|
171
853
|
* });
|
|
172
854
|
*
|
|
173
855
|
* // For OpenAI strict mode
|
|
174
|
-
* const
|
|
175
|
-
*
|
|
856
|
+
* const jsonSchema = toJSONSchema(schema, { strict: true, format: 'openai' });
|
|
857
|
+
*
|
|
858
|
+
* // For Anthropic strict mode
|
|
859
|
+
* const anthropic = toJSONSchema(schema, ANTHROPIC_STRICT);
|
|
176
860
|
* ```
|
|
177
861
|
*/
|
|
178
862
|
export function toJSONSchema(schema, options) {
|
|
179
|
-
const
|
|
180
|
-
const { strict, format = 'openai' } = resolvedOptions;
|
|
181
|
-
// Currently only OpenAI format is supported
|
|
182
|
-
if (format !== 'openai') {
|
|
183
|
-
throw new Error(`Unsupported format: ${format}. Currently only 'openai' is supported.`);
|
|
184
|
-
}
|
|
863
|
+
const descriptor = resolveDescriptor(options);
|
|
185
864
|
const context = {
|
|
186
865
|
root: schema,
|
|
187
|
-
|
|
188
|
-
format,
|
|
866
|
+
descriptor,
|
|
189
867
|
definitions: new Map(),
|
|
190
868
|
definitionSchemas: new Map(),
|
|
191
869
|
refCounter: 0,
|
|
192
870
|
path: [],
|
|
871
|
+
inProgress: new Set(),
|
|
193
872
|
};
|
|
194
873
|
const result = convert(schema, context);
|
|
195
874
|
// Add definitions if any were created
|
|
@@ -211,17 +890,7 @@ function convert(schema, context) {
|
|
|
211
890
|
// Check cache FIRST before evaluating getter to prevent infinite recursion
|
|
212
891
|
const [cachedJs, cachedId] = context.definitions.get(schema) || [];
|
|
213
892
|
if (cachedJs && cachedId) {
|
|
214
|
-
|
|
215
|
-
return { $ref: `#` };
|
|
216
|
-
}
|
|
217
|
-
if (!context.definitionSchemas.has(cachedId)) {
|
|
218
|
-
context.definitionSchemas.set(cachedId, { ...cachedJs });
|
|
219
|
-
for (const prop in cachedJs) {
|
|
220
|
-
delete cachedJs[prop];
|
|
221
|
-
}
|
|
222
|
-
cachedJs.$ref = `#/$defs/${cachedId}`;
|
|
223
|
-
}
|
|
224
|
-
return { $ref: `#/$defs/${cachedId}` };
|
|
893
|
+
return emitCachedRef(schema, cachedJs, cachedId, context);
|
|
225
894
|
}
|
|
226
895
|
const metadata = (schema instanceof z.ZodType) ? schema.meta() : null;
|
|
227
896
|
const aid = metadata?.aid;
|
|
@@ -232,17 +901,7 @@ function convert(schema, context) {
|
|
|
232
901
|
const cachedMeta = cachedSchema.meta();
|
|
233
902
|
if (cachedMeta?.aid === aid && jsId && js) {
|
|
234
903
|
// Found a cached version with the same aid - use it
|
|
235
|
-
|
|
236
|
-
return { $ref: `#` };
|
|
237
|
-
}
|
|
238
|
-
if (!context.definitionSchemas.has(jsId)) {
|
|
239
|
-
context.definitionSchemas.set(jsId, { ...js });
|
|
240
|
-
for (const prop in js) {
|
|
241
|
-
delete js[prop];
|
|
242
|
-
}
|
|
243
|
-
js.$ref = `#/$defs/${jsId}`;
|
|
244
|
-
}
|
|
245
|
-
return { $ref: `#/$defs/${jsId}` };
|
|
904
|
+
return emitCachedRef(schema, js, jsId, context);
|
|
246
905
|
}
|
|
247
906
|
}
|
|
248
907
|
}
|
|
@@ -253,17 +912,7 @@ function convert(schema, context) {
|
|
|
253
912
|
// Check cache by object identity for non-lazy schemas
|
|
254
913
|
const [js, jsId] = context.definitions.get(schema) || [];
|
|
255
914
|
if (jsId && js) {
|
|
256
|
-
|
|
257
|
-
return { $ref: `#` };
|
|
258
|
-
}
|
|
259
|
-
if (!context.definitionSchemas.has(jsId)) {
|
|
260
|
-
context.definitionSchemas.set(jsId, { ...js });
|
|
261
|
-
for (const prop in js) {
|
|
262
|
-
delete js[prop];
|
|
263
|
-
}
|
|
264
|
-
js.$ref = `#/$defs/${jsId}`;
|
|
265
|
-
}
|
|
266
|
-
return { $ref: `#/$defs/${jsId}` };
|
|
915
|
+
return emitCachedRef(schema, js, jsId, context);
|
|
267
916
|
}
|
|
268
917
|
}
|
|
269
918
|
// Capture metadata from the original schema
|
|
@@ -281,8 +930,15 @@ function convert(schema, context) {
|
|
|
281
930
|
const target = {};
|
|
282
931
|
// Before converting, register this schema to handle recursion
|
|
283
932
|
context.definitions.set(cacheKey, [target, id]);
|
|
933
|
+
context.inProgress.add(id);
|
|
284
934
|
// Convert the unwrapped schema
|
|
285
|
-
|
|
935
|
+
let result;
|
|
936
|
+
try {
|
|
937
|
+
result = convertSchema(unwrappedSchema, context);
|
|
938
|
+
}
|
|
939
|
+
finally {
|
|
940
|
+
context.inProgress.delete(id);
|
|
941
|
+
}
|
|
286
942
|
Object.assign(result, metadata);
|
|
287
943
|
delete result.aid;
|
|
288
944
|
// Promote it because user requested it or it's recursive
|
|
@@ -296,21 +952,211 @@ function convert(schema, context) {
|
|
|
296
952
|
}
|
|
297
953
|
return target;
|
|
298
954
|
}
|
|
955
|
+
/**
|
|
956
|
+
* Emit a `$ref` (or, for descriptors that don't support recursion, an inline
|
|
957
|
+
* shape-aware placeholder) when the converter re-encounters a schema it's
|
|
958
|
+
* already started or finished converting.
|
|
959
|
+
*
|
|
960
|
+
* Three cases:
|
|
961
|
+
* 1. Re-encounter while still on the conversion stack (`inProgress.has(id)`)
|
|
962
|
+
* AND the descriptor rejects recursive `$ref`s — emit a shape-aware
|
|
963
|
+
* placeholder via `buildCycleBreakerSchema()` (number stays number, array
|
|
964
|
+
* stays array, etc.) with a description naming the `$defs` entry it would
|
|
965
|
+
* have referenced. This breaks the cycle inline so providers like
|
|
966
|
+
* Anthropic don't reject the schema.
|
|
967
|
+
* 2. Re-encounter at root with `allowRootRef` — emit `{$ref: '#'}`.
|
|
968
|
+
* 3. Otherwise — emit `{$ref: '#/$defs/<id>'}`, promoting the original
|
|
969
|
+
* target to a `$defs` entry on first share.
|
|
970
|
+
*/
|
|
971
|
+
function emitCachedRef(schema, cachedJs, cachedId, context) {
|
|
972
|
+
const descriptor = context.descriptor;
|
|
973
|
+
const isCycle = context.inProgress.has(cachedId);
|
|
974
|
+
if (isCycle && !descriptor.supportsRecursion) {
|
|
975
|
+
// Replace the back-edge with a placeholder whose top-level shape matches
|
|
976
|
+
// the recursive zod schema. The original `cachedJs` target keeps filling
|
|
977
|
+
// in normally — once conversion completes the `$defs` entry exists; we
|
|
978
|
+
// just don't reference it from here, since doing so would form the cycle
|
|
979
|
+
// the provider rejects.
|
|
980
|
+
return buildCycleBreakerSchema(schema, cachedId, descriptor);
|
|
981
|
+
}
|
|
982
|
+
if (schema === context.root && descriptor.allowRootRef) {
|
|
983
|
+
return { $ref: `#` };
|
|
984
|
+
}
|
|
985
|
+
if (!context.definitionSchemas.has(cachedId)) {
|
|
986
|
+
context.definitionSchemas.set(cachedId, { ...cachedJs });
|
|
987
|
+
for (const prop in cachedJs) {
|
|
988
|
+
delete cachedJs[prop];
|
|
989
|
+
}
|
|
990
|
+
cachedJs.$ref = `#/$defs/${cachedId}`;
|
|
991
|
+
}
|
|
992
|
+
return { $ref: `#/$defs/${cachedId}` };
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Build the inline placeholder used in place of a cyclic `$ref` for
|
|
996
|
+
* descriptors with `supportsRecursion: false`. Inspects the recursive zod
|
|
997
|
+
* `schema` and emits a JSON-Schema node whose top-level shape matches it
|
|
998
|
+
* (number → `{type: 'number'}`, array → `{type: 'array', items: ...}`, union
|
|
999
|
+
* → `{anyOf: [...]}`, etc.) so the LLM still knows what JSON value to send
|
|
1000
|
+
* at the cycle position. One level of structural fidelity, then bottoms out
|
|
1001
|
+
* at the descriptor's flat "any" encoding.
|
|
1002
|
+
*/
|
|
1003
|
+
function buildCycleBreakerSchema(schema, refId, descriptor) {
|
|
1004
|
+
const placeholder = genericize(schema, descriptor, 1);
|
|
1005
|
+
return {
|
|
1006
|
+
...placeholder,
|
|
1007
|
+
description: `Would recursively reference #/$defs/${refId}`,
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Reduce a zod schema to a JSON-Schema node carrying its top-level shape
|
|
1012
|
+
* class (string / number / array / object / union / …) without nested
|
|
1013
|
+
* constraints. Used by the cycle-breaker so the placeholder at a recursive
|
|
1014
|
+
* position matches the JSON value the LLM is supposed to emit.
|
|
1015
|
+
*
|
|
1016
|
+
* `depth` controls how far structural fidelity flows into the *recursive*
|
|
1017
|
+
* containers (arrays of arrays, unions of unions). Closed-form schemas
|
|
1018
|
+
* (primitives, objects, tuples) ignore depth — they have no further
|
|
1019
|
+
* substructure to walk and are always safe to emit verbatim.
|
|
1020
|
+
* - `depth >= 1`: arrays carry their `items`, unions carry their branches,
|
|
1021
|
+
* each recursing at `depth - 1`.
|
|
1022
|
+
* - `depth === 0`: arrays collapse to bare `{type: 'array'}`, unions
|
|
1023
|
+
* collapse to the descriptor's flat any encoding. Stops the walk before
|
|
1024
|
+
* it can re-traverse the cycle that triggered the call.
|
|
1025
|
+
*
|
|
1026
|
+
* Depth is hardcoded to 1 by `buildCycleBreakerSchema`. That's enough for
|
|
1027
|
+
* "a list of objects should be a list of records" and prevents infinite
|
|
1028
|
+
* recursion on `Array<Array<Self>>` / `Union<Union<Self>>` shapes.
|
|
1029
|
+
*/
|
|
1030
|
+
function genericize(schema, descriptor, depth) {
|
|
1031
|
+
// Unwrap transparent wrappers — recurse at the same depth because the
|
|
1032
|
+
// wrapper itself doesn't add a structural level.
|
|
1033
|
+
if (schema instanceof z.ZodLazy) {
|
|
1034
|
+
return genericize(schema.def.getter(), descriptor, depth);
|
|
1035
|
+
}
|
|
1036
|
+
if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable) {
|
|
1037
|
+
return genericize(schema.unwrap(), descriptor, depth);
|
|
1038
|
+
}
|
|
1039
|
+
if (schema instanceof z.ZodDefault) {
|
|
1040
|
+
return genericize(schema.def.innerType, descriptor, depth);
|
|
1041
|
+
}
|
|
1042
|
+
if (schema instanceof z.ZodCodec) {
|
|
1043
|
+
return genericize(schema.def.in, descriptor, depth);
|
|
1044
|
+
}
|
|
1045
|
+
if (schema instanceof z.ZodPipe) {
|
|
1046
|
+
return genericize(schema.def.in, descriptor, depth);
|
|
1047
|
+
}
|
|
1048
|
+
// Primitive leaves and string-flavored types — no substructure, depth
|
|
1049
|
+
// irrelevant. The LLM gets a precise type signal.
|
|
1050
|
+
if (schema instanceof z.ZodString)
|
|
1051
|
+
return { type: 'string' };
|
|
1052
|
+
if (schema instanceof z.ZodNumber)
|
|
1053
|
+
return { type: 'number' };
|
|
1054
|
+
if (schema instanceof z.ZodBigInt)
|
|
1055
|
+
return { type: 'number' };
|
|
1056
|
+
if (schema instanceof z.ZodBoolean)
|
|
1057
|
+
return { type: 'boolean' };
|
|
1058
|
+
if (schema instanceof z.ZodNull)
|
|
1059
|
+
return { type: 'null' };
|
|
1060
|
+
if (schema instanceof z.ZodUndefined)
|
|
1061
|
+
return { type: 'null' };
|
|
1062
|
+
if (schema instanceof z.ZodDate ||
|
|
1063
|
+
schema instanceof z.ZodISODateTime ||
|
|
1064
|
+
schema instanceof z.ZodISODate ||
|
|
1065
|
+
schema instanceof z.ZodISOTime ||
|
|
1066
|
+
schema instanceof z.ZodISODuration ||
|
|
1067
|
+
schema instanceof z.ZodEmail ||
|
|
1068
|
+
schema instanceof z.ZodIPv4 ||
|
|
1069
|
+
schema instanceof z.ZodIPv6 ||
|
|
1070
|
+
schema instanceof z.ZodUUID ||
|
|
1071
|
+
schema instanceof z.ZodTemplateLiteral) {
|
|
1072
|
+
return { type: 'string' };
|
|
1073
|
+
}
|
|
1074
|
+
if (schema instanceof z.ZodLiteral) {
|
|
1075
|
+
// ZodLiteral can carry one or more values; pick the first that maps to
|
|
1076
|
+
// a JSON primitive type, else fall back to flat any.
|
|
1077
|
+
const v = Array.from(schema.values).find((x) => x === null || typeof x === 'string' || typeof x === 'number' || typeof x === 'boolean');
|
|
1078
|
+
if (v === null)
|
|
1079
|
+
return { type: 'null' };
|
|
1080
|
+
if (typeof v === 'string')
|
|
1081
|
+
return { type: 'string' };
|
|
1082
|
+
if (typeof v === 'number')
|
|
1083
|
+
return { type: 'number' };
|
|
1084
|
+
if (typeof v === 'boolean')
|
|
1085
|
+
return { type: 'boolean' };
|
|
1086
|
+
return buildAnyValueSchema(descriptor);
|
|
1087
|
+
}
|
|
1088
|
+
if (schema instanceof z.ZodEnum) {
|
|
1089
|
+
const numericValues = Object.values(schema.def.entries).filter((v) => typeof v === 'number');
|
|
1090
|
+
const values = Object.entries(schema.def.entries)
|
|
1091
|
+
.filter(([k]) => numericValues.indexOf(+k) === -1)
|
|
1092
|
+
.map(([, v]) => v);
|
|
1093
|
+
if (values.every((v) => typeof v === 'string'))
|
|
1094
|
+
return { type: 'string' };
|
|
1095
|
+
if (values.every((v) => typeof v === 'number'))
|
|
1096
|
+
return { type: 'number' };
|
|
1097
|
+
return buildAnyValueSchema(descriptor);
|
|
1098
|
+
}
|
|
1099
|
+
// Closed-form containers — no nested schema to walk, safe at any depth.
|
|
1100
|
+
if (schema instanceof z.ZodObject ||
|
|
1101
|
+
schema instanceof z.ZodRecord ||
|
|
1102
|
+
schema instanceof z.ZodIntersection) {
|
|
1103
|
+
return { type: 'object', additionalProperties: true };
|
|
1104
|
+
}
|
|
1105
|
+
if (schema instanceof z.ZodTuple) {
|
|
1106
|
+
// Heterogeneous positional shape — drop items, keep array hint.
|
|
1107
|
+
return { type: 'array' };
|
|
1108
|
+
}
|
|
1109
|
+
// Recursive containers — depth gates further structural fidelity.
|
|
1110
|
+
if (schema instanceof z.ZodArray) {
|
|
1111
|
+
if (depth <= 0)
|
|
1112
|
+
return { type: 'array' };
|
|
1113
|
+
return { type: 'array', items: genericize(schema.element, descriptor, depth - 1) };
|
|
1114
|
+
}
|
|
1115
|
+
if (schema instanceof z.ZodUnion || schema instanceof z.ZodDiscriminatedUnion) {
|
|
1116
|
+
if (depth <= 0)
|
|
1117
|
+
return buildAnyValueSchema(descriptor);
|
|
1118
|
+
const options = schema.options;
|
|
1119
|
+
const branches = options.map((opt) => genericize(opt, descriptor, depth - 1));
|
|
1120
|
+
const unique = uniqueByJSON(branches);
|
|
1121
|
+
return unique.length === 1 ? unique[0] : { anyOf: unique };
|
|
1122
|
+
}
|
|
1123
|
+
// ZodAny / ZodUnknown / ZodTransform / anything we don't recognize —
|
|
1124
|
+
// emit the descriptor's any encoding. Safer than guessing.
|
|
1125
|
+
return buildAnyValueSchema(descriptor);
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Dedupe an array of JSONSchema nodes by structural (JSON-stringified)
|
|
1129
|
+
* equality. Order-preserving: the first occurrence wins. Used by
|
|
1130
|
+
* `genericize` to collapse `union<T, T>` placeholders into a single branch.
|
|
1131
|
+
*/
|
|
1132
|
+
function uniqueByJSON(nodes) {
|
|
1133
|
+
const seen = new Set();
|
|
1134
|
+
const out = [];
|
|
1135
|
+
for (const node of nodes) {
|
|
1136
|
+
const key = JSON.stringify(node);
|
|
1137
|
+
if (seen.has(key))
|
|
1138
|
+
continue;
|
|
1139
|
+
seen.add(key);
|
|
1140
|
+
out.push(node);
|
|
1141
|
+
}
|
|
1142
|
+
return out;
|
|
1143
|
+
}
|
|
299
1144
|
/**
|
|
300
1145
|
* Main conversion function
|
|
301
1146
|
*/
|
|
302
1147
|
function convertSchema(schema, context) {
|
|
1148
|
+
const descriptor = context.descriptor;
|
|
303
1149
|
// TODO: Map, Set, File, ReadOnly, Nan, Catch, Prefault, NonOptional, Transform, Function, Promise, Custom
|
|
304
1150
|
// Handle ZodOptional
|
|
305
1151
|
if (schema instanceof z.ZodOptional) {
|
|
306
1152
|
const innerSchema = schema.unwrap();
|
|
307
1153
|
const innerJson = convert(innerSchema, context);
|
|
308
|
-
if (
|
|
309
|
-
//
|
|
310
|
-
return makeNullable(innerJson);
|
|
1154
|
+
if (descriptor.optionalAsNullable) {
|
|
1155
|
+
// OpenAI-strict-style: optional fields become nullable on the wire.
|
|
1156
|
+
return makeNullable(innerJson, descriptor);
|
|
311
1157
|
}
|
|
312
1158
|
else {
|
|
313
|
-
//
|
|
1159
|
+
// Standard JSON Schema: optional fields are simply not in `required[]`.
|
|
314
1160
|
return innerJson;
|
|
315
1161
|
}
|
|
316
1162
|
}
|
|
@@ -318,7 +1164,7 @@ function convertSchema(schema, context) {
|
|
|
318
1164
|
if (schema instanceof z.ZodNullable) {
|
|
319
1165
|
const innerSchema = schema.unwrap();
|
|
320
1166
|
const innerJson = convert(innerSchema, context);
|
|
321
|
-
return makeNullable(innerJson);
|
|
1167
|
+
return makeNullable(innerJson, descriptor);
|
|
322
1168
|
}
|
|
323
1169
|
// Handle ZodObject
|
|
324
1170
|
if (schema instanceof z.ZodObject) {
|
|
@@ -327,9 +1173,9 @@ function convertSchema(schema, context) {
|
|
|
327
1173
|
const shape = schema.shape;
|
|
328
1174
|
for (const key in shape) {
|
|
329
1175
|
const fieldSchema = shape[key];
|
|
330
|
-
const
|
|
1176
|
+
const isFieldRequired = descriptor.objectAllFieldsRequired || !isOptional(fieldSchema);
|
|
331
1177
|
properties[key] = convert(fieldSchema, context);
|
|
332
|
-
if (
|
|
1178
|
+
if (isFieldRequired) {
|
|
333
1179
|
required.push(key);
|
|
334
1180
|
}
|
|
335
1181
|
}
|
|
@@ -338,12 +1184,19 @@ function convertSchema(schema, context) {
|
|
|
338
1184
|
properties,
|
|
339
1185
|
required,
|
|
340
1186
|
};
|
|
341
|
-
if (
|
|
1187
|
+
if (descriptor.objectClosedByDefault || schema.def.catchall?._zod.def.type === "never") {
|
|
342
1188
|
result.additionalProperties = false;
|
|
343
1189
|
}
|
|
344
1190
|
else if (schema.def.catchall) {
|
|
345
1191
|
result.additionalProperties = convert(schema.def.catchall, context);
|
|
346
1192
|
}
|
|
1193
|
+
// Gemini 2.0 strict requires `propertyOrdering` so the model emits keys
|
|
1194
|
+
// in a deterministic order. Other dialects ignore this hint, so emitting
|
|
1195
|
+
// it under non-Google descriptors is harmless — but we gate it on the
|
|
1196
|
+
// descriptor flag to keep the JSON Schema minimal everywhere else.
|
|
1197
|
+
if (descriptor.emitPropertyOrdering) {
|
|
1198
|
+
result.propertyOrdering = Object.keys(properties);
|
|
1199
|
+
}
|
|
347
1200
|
return result;
|
|
348
1201
|
}
|
|
349
1202
|
// Handle ZodArray
|
|
@@ -352,19 +1205,21 @@ function convertSchema(schema, context) {
|
|
|
352
1205
|
type: 'array',
|
|
353
1206
|
items: convert(schema.element, context),
|
|
354
1207
|
};
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
1208
|
+
if (descriptor.allowMinMaxItems) {
|
|
1209
|
+
const { minimum, maximum } = schema._zod.bag;
|
|
1210
|
+
if (typeof minimum === 'number') {
|
|
1211
|
+
result.minItems = minimum;
|
|
1212
|
+
}
|
|
1213
|
+
if (typeof maximum === 'number') {
|
|
1214
|
+
result.maxItems = maximum;
|
|
1215
|
+
}
|
|
361
1216
|
}
|
|
362
1217
|
return result;
|
|
363
1218
|
}
|
|
364
|
-
// Handle ZodRecord
|
|
1219
|
+
// Handle ZodRecord
|
|
365
1220
|
if (schema instanceof z.ZodRecord) {
|
|
366
|
-
if (
|
|
367
|
-
//
|
|
1221
|
+
if (descriptor.recordEncoding === 'array-of-pairs') {
|
|
1222
|
+
// Strict-mode workaround: records become arrays of {key, value} objects.
|
|
368
1223
|
return {
|
|
369
1224
|
type: 'array',
|
|
370
1225
|
items: {
|
|
@@ -378,14 +1233,12 @@ function convertSchema(schema, context) {
|
|
|
378
1233
|
},
|
|
379
1234
|
};
|
|
380
1235
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
};
|
|
388
|
-
}
|
|
1236
|
+
// Standard: open record with propertyNames + additionalProperties.
|
|
1237
|
+
return {
|
|
1238
|
+
type: 'object',
|
|
1239
|
+
propertyNames: convert(schema.keyType, context),
|
|
1240
|
+
additionalProperties: convert(schema.valueType, context),
|
|
1241
|
+
};
|
|
389
1242
|
}
|
|
390
1243
|
// Handle ZodUnion
|
|
391
1244
|
if (schema instanceof z.ZodUnion) {
|
|
@@ -397,7 +1250,7 @@ function convertSchema(schema, context) {
|
|
|
397
1250
|
const anyOf = schema.options.map(option => convert(option, context));
|
|
398
1251
|
return { anyOf };
|
|
399
1252
|
}
|
|
400
|
-
// Handle ZodIntersection
|
|
1253
|
+
// Handle ZodIntersection
|
|
401
1254
|
if (schema instanceof z.ZodIntersection) {
|
|
402
1255
|
const left = convert(schema.def.left, context);
|
|
403
1256
|
const right = convert(schema.def.right, context);
|
|
@@ -405,32 +1258,51 @@ function convertSchema(schema, context) {
|
|
|
405
1258
|
...(left.allOf && Object.keys(left).length === 1 ? left.allOf : [left]),
|
|
406
1259
|
...(right.allOf && Object.keys(right).length === 1 ? right.allOf : [right]),
|
|
407
1260
|
];
|
|
408
|
-
//
|
|
409
|
-
|
|
1261
|
+
// OpenAI strict bans allOf in some shapes; collapse to anyOf when the
|
|
1262
|
+
// descriptor disallows it.
|
|
1263
|
+
return descriptor.allowAllOf ? { allOf } : { anyOf: allOf };
|
|
410
1264
|
}
|
|
411
1265
|
// Handle ZodTuple
|
|
412
1266
|
if (schema instanceof z.ZodTuple) {
|
|
413
|
-
// Tuples in JSON Schema can be represented as arrays with prefixItems
|
|
414
|
-
// For simplicity, we'll use an array type
|
|
415
1267
|
const items = schema.def.items.map(item => convert(item, context));
|
|
416
1268
|
const rest = schema.def.rest ? convert(schema.def.rest, context) : undefined;
|
|
1269
|
+
// Object-numeric-keys encoding: per-position type preserved as object
|
|
1270
|
+
// properties. Variadic rests can't be represented this way — fall back to
|
|
1271
|
+
// a homogeneous array of (items ∪ rest) for that rare case.
|
|
1272
|
+
if (descriptor.tupleEncoding === 'object-numeric-keys' && !rest) {
|
|
1273
|
+
const properties = {};
|
|
1274
|
+
const required = [];
|
|
1275
|
+
for (let i = 0; i < items.length; i++) {
|
|
1276
|
+
const k = String(i);
|
|
1277
|
+
properties[k] = isOptional(schema.def.items[i]) ? makeNullable(items[i], descriptor) : items[i];
|
|
1278
|
+
required.push(k);
|
|
1279
|
+
}
|
|
1280
|
+
return {
|
|
1281
|
+
type: 'object',
|
|
1282
|
+
properties,
|
|
1283
|
+
required,
|
|
1284
|
+
additionalProperties: false,
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
417
1287
|
const result = { type: 'array' };
|
|
418
|
-
if (
|
|
1288
|
+
if (descriptor.tupleEncoding === 'object-numeric-keys' && rest) {
|
|
1289
|
+
// Strict + rest fallback: every position fits one of the declared
|
|
1290
|
+
// types (positional info is lost — there's no way to express a
|
|
1291
|
+
// mixed-prefix-plus-rest array in strict-object form).
|
|
419
1292
|
items.push(rest);
|
|
420
1293
|
}
|
|
421
1294
|
// If all items are the same type, simplify to a single items schema
|
|
422
1295
|
if (items.length > 0 && items.every((item) => JSON.stringify(item) === JSON.stringify(items[0]))) {
|
|
423
1296
|
result.items = items[0];
|
|
424
1297
|
}
|
|
1298
|
+
else if (descriptor.tupleEncoding === 'prefix-items') {
|
|
1299
|
+
result.prefixItems = items;
|
|
1300
|
+
}
|
|
425
1301
|
else {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
}
|
|
429
|
-
else {
|
|
430
|
-
result.prefixItems = items;
|
|
431
|
-
}
|
|
1302
|
+
// items-union or strict-mode-with-rest fallback: collapse to anyOf
|
|
1303
|
+
result.items = { anyOf: items };
|
|
432
1304
|
}
|
|
433
|
-
if (
|
|
1305
|
+
if (descriptor.tupleEncoding === 'prefix-items' && rest) {
|
|
434
1306
|
result.additionalItems = rest;
|
|
435
1307
|
}
|
|
436
1308
|
if (!rest) {
|
|
@@ -441,12 +1313,14 @@ function convertSchema(schema, context) {
|
|
|
441
1313
|
result.minItems = minItems;
|
|
442
1314
|
result.maxItems = items.length;
|
|
443
1315
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
1316
|
+
if (descriptor.allowMinMaxItems) {
|
|
1317
|
+
const { minimum, maximum } = schema._zod.bag;
|
|
1318
|
+
if (typeof minimum === 'number') {
|
|
1319
|
+
result.minItems = minimum;
|
|
1320
|
+
}
|
|
1321
|
+
if (typeof maximum === 'number') {
|
|
1322
|
+
result.maxItems = maximum;
|
|
1323
|
+
}
|
|
450
1324
|
}
|
|
451
1325
|
return result;
|
|
452
1326
|
}
|
|
@@ -481,14 +1355,11 @@ function convertSchema(schema, context) {
|
|
|
481
1355
|
return innerJson;
|
|
482
1356
|
}
|
|
483
1357
|
// Handle ZodLazy - don't unwrap here, let convert() handle caching
|
|
484
|
-
// Just return empty schema since ZodLazy should be handled at convert() level
|
|
485
1358
|
if (schema instanceof z.ZodLazy) {
|
|
486
|
-
// This shouldn't be reached because convert() handles ZodLazy before calling convertSchema
|
|
487
1359
|
throw new Error('ZodLazy should be handled in convert(), not convertSchema()');
|
|
488
1360
|
}
|
|
489
1361
|
// Handle ZodCodec
|
|
490
1362
|
if (schema instanceof z.ZodCodec) {
|
|
491
|
-
// For codecs, use the input schema for JSON Schema generation
|
|
492
1363
|
return convert(schema.def.in, context);
|
|
493
1364
|
}
|
|
494
1365
|
// Handle ZodPipe (from preprocess)
|
|
@@ -500,63 +1371,60 @@ function convertSchema(schema, context) {
|
|
|
500
1371
|
if (schema instanceof z.ZodString) {
|
|
501
1372
|
const result = { type: 'string' };
|
|
502
1373
|
const { minimum, maximum, format, patterns, contentEncoding } = schema._zod.bag;
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
1374
|
+
// Format: emit only when allowed by the descriptor.
|
|
1375
|
+
if (typeof format === 'string') {
|
|
1376
|
+
const formats = descriptor.supportedStringFormats;
|
|
1377
|
+
if (formats === 'all' || formats.has(format)) {
|
|
506
1378
|
result.format = format;
|
|
507
1379
|
}
|
|
508
|
-
if (patterns && patterns.size > 0) {
|
|
509
|
-
result.pattern = Array.from(patterns)[0].source;
|
|
510
|
-
}
|
|
511
1380
|
}
|
|
512
|
-
|
|
1381
|
+
if (descriptor.allowMinMaxLength) {
|
|
513
1382
|
if (typeof minimum === 'number') {
|
|
514
1383
|
result.minLength = minimum;
|
|
515
1384
|
}
|
|
516
1385
|
if (typeof maximum === 'number') {
|
|
517
1386
|
result.maxLength = maximum;
|
|
518
1387
|
}
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
1388
|
+
}
|
|
1389
|
+
if (typeof contentEncoding === 'string' && descriptor.allowMinMaxLength) {
|
|
1390
|
+
// contentEncoding piggybacks on the lenient gate (it's not a strict-mode-safe field)
|
|
1391
|
+
result.contentEncoding = contentEncoding;
|
|
1392
|
+
}
|
|
1393
|
+
if (patterns && descriptor.allowPattern) {
|
|
1394
|
+
if (patterns.size === 1 || !descriptor.allowMultiplePatterns) {
|
|
1395
|
+
result.pattern = Array.from(patterns)[0].source;
|
|
524
1396
|
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
result.allOf = Array.from(patterns).map((regex) => ({
|
|
531
|
-
type: 'string',
|
|
532
|
-
pattern: regex.source,
|
|
533
|
-
}));
|
|
534
|
-
}
|
|
1397
|
+
else {
|
|
1398
|
+
result.allOf = Array.from(patterns).map((regex) => ({
|
|
1399
|
+
type: 'string',
|
|
1400
|
+
pattern: regex.source,
|
|
1401
|
+
}));
|
|
535
1402
|
}
|
|
536
1403
|
}
|
|
537
1404
|
return result;
|
|
538
1405
|
}
|
|
539
1406
|
if (schema instanceof z.ZodNumber) {
|
|
540
1407
|
const result = { type: 'number' };
|
|
541
|
-
// Add number constraints if present
|
|
542
1408
|
const { minimum, maximum, format, multipleOf, exclusiveMaximum, exclusiveMinimum } = schema._zod.bag;
|
|
543
1409
|
if (typeof format === 'string' && format.includes("int")) {
|
|
544
1410
|
result.type = 'integer';
|
|
545
1411
|
}
|
|
546
|
-
if (
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
1412
|
+
if (descriptor.allowMinimumMaximum) {
|
|
1413
|
+
if (typeof exclusiveMinimum === 'number') {
|
|
1414
|
+
result.exclusiveMinimum = exclusiveMinimum;
|
|
1415
|
+
}
|
|
1416
|
+
else if (typeof minimum === 'number') {
|
|
1417
|
+
result.minimum = minimum;
|
|
1418
|
+
}
|
|
1419
|
+
if (typeof exclusiveMaximum === 'number') {
|
|
1420
|
+
result.exclusiveMaximum = exclusiveMaximum;
|
|
1421
|
+
}
|
|
1422
|
+
else if (typeof maximum === 'number') {
|
|
1423
|
+
result.maximum = maximum;
|
|
1424
|
+
}
|
|
1425
|
+
if (typeof multipleOf === 'number') {
|
|
1426
|
+
result.multipleOf = multipleOf;
|
|
1427
|
+
}
|
|
560
1428
|
}
|
|
561
1429
|
return result;
|
|
562
1430
|
}
|
|
@@ -567,28 +1435,28 @@ function convertSchema(schema, context) {
|
|
|
567
1435
|
return { type: 'integer' };
|
|
568
1436
|
}
|
|
569
1437
|
if (schema instanceof z.ZodDate || schema instanceof z.ZodISODateTime) {
|
|
570
|
-
return
|
|
1438
|
+
return stringWithFormat('date-time', descriptor);
|
|
571
1439
|
}
|
|
572
1440
|
if (schema instanceof z.ZodISODate) {
|
|
573
|
-
return
|
|
1441
|
+
return stringWithFormat('date', descriptor);
|
|
574
1442
|
}
|
|
575
1443
|
if (schema instanceof z.ZodISOTime) {
|
|
576
|
-
return
|
|
1444
|
+
return stringWithFormat('time', descriptor);
|
|
577
1445
|
}
|
|
578
1446
|
if (schema instanceof z.ZodISODuration) {
|
|
579
|
-
return
|
|
1447
|
+
return stringWithFormat('duration', descriptor);
|
|
580
1448
|
}
|
|
581
1449
|
if (schema instanceof z.ZodEmail) {
|
|
582
|
-
return
|
|
1450
|
+
return stringWithFormat('email', descriptor);
|
|
583
1451
|
}
|
|
584
1452
|
if (schema instanceof z.ZodIPv4) {
|
|
585
|
-
return
|
|
1453
|
+
return stringWithFormat('ipv4', descriptor);
|
|
586
1454
|
}
|
|
587
1455
|
if (schema instanceof z.ZodIPv6) {
|
|
588
|
-
return
|
|
1456
|
+
return stringWithFormat('ipv6', descriptor);
|
|
589
1457
|
}
|
|
590
1458
|
if (schema instanceof z.ZodUUID) {
|
|
591
|
-
return
|
|
1459
|
+
return stringWithFormat('uuid', descriptor);
|
|
592
1460
|
}
|
|
593
1461
|
if (schema instanceof z.ZodNull) {
|
|
594
1462
|
return { type: 'null' };
|
|
@@ -599,11 +1467,27 @@ function convertSchema(schema, context) {
|
|
|
599
1467
|
if (schema instanceof z.ZodUndefined) {
|
|
600
1468
|
return { type: 'null' }; // Treat undefined as null in JSON
|
|
601
1469
|
}
|
|
602
|
-
if (schema instanceof z.ZodAny) {
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
1470
|
+
if (schema instanceof z.ZodAny || schema instanceof z.ZodUnknown) {
|
|
1471
|
+
// Bare `{}` would satisfy the meaning of "any" but OpenAI's strict
|
|
1472
|
+
// structured-output mode rejects schemas without a `type` key. For
|
|
1473
|
+
// descriptors with a recursive Any encoding, promote to a single shared
|
|
1474
|
+
// `$defs/Any` definition that covers every JSON value and return a
|
|
1475
|
+
// `$ref` — sharing keeps the output compact when `z.any()` appears many
|
|
1476
|
+
// times inside a big union. For descriptors with `flat` Any encoding
|
|
1477
|
+
// (e.g. Anthropic, which rejects recursive `$defs` graphs), inline the
|
|
1478
|
+
// permissive shape directly so the schema stays acyclic.
|
|
1479
|
+
if (descriptor.anyEncoding === 'flat') {
|
|
1480
|
+
return buildAnyValueSchema(descriptor);
|
|
1481
|
+
}
|
|
1482
|
+
const id = 'Any';
|
|
1483
|
+
if (!context.definitionSchemas.has(id)) {
|
|
1484
|
+
// Seed a placeholder BEFORE building the body so recursive
|
|
1485
|
+
// `$ref: '#/$defs/Any'` references don't infinite-loop during
|
|
1486
|
+
// construction.
|
|
1487
|
+
context.definitionSchemas.set(id, {});
|
|
1488
|
+
context.definitionSchemas.set(id, buildAnyValueSchema(descriptor));
|
|
1489
|
+
}
|
|
1490
|
+
return { $ref: `#/$defs/${id}` };
|
|
607
1491
|
}
|
|
608
1492
|
if (schema instanceof z.ZodTransform) {
|
|
609
1493
|
return {}; // Transforms are not represented in JSON Schema
|
|
@@ -613,11 +1497,75 @@ function convertSchema(schema, context) {
|
|
|
613
1497
|
return {};
|
|
614
1498
|
}
|
|
615
1499
|
/**
|
|
616
|
-
*
|
|
1500
|
+
* Builds the body of the "any value" schema — an `anyOf` covering every JSON
|
|
1501
|
+
* value (string, number, boolean, null, array, object).
|
|
1502
|
+
*
|
|
1503
|
+
* Encoding modes:
|
|
1504
|
+
* - `recursive-strict` (OpenAI strict): self-referencing `$defs/Any` with
|
|
1505
|
+
* array-of-pairs records (no open-object support in strict mode).
|
|
1506
|
+
* - `recursive-open` (Lenient / Google): self-referencing `$defs/Any` with
|
|
1507
|
+
* `additionalProperties: <self>` records.
|
|
1508
|
+
* - `flat` (Anthropic strict): non-recursive — array branch has no `items`
|
|
1509
|
+
* constraint, object branch is `additionalProperties: true`. Equivalent
|
|
1510
|
+
* to TypeScript `any`; safe under descriptors that reject cyclic `$defs`.
|
|
617
1511
|
*/
|
|
618
|
-
function
|
|
1512
|
+
function buildAnyValueSchema(descriptor) {
|
|
1513
|
+
const isFlat = descriptor.anyEncoding === 'flat';
|
|
1514
|
+
const selfRef = isFlat ? {} : { $ref: '#/$defs/Any' };
|
|
1515
|
+
const arrayBranch = isFlat
|
|
1516
|
+
? { type: 'array' }
|
|
1517
|
+
: { type: 'array', items: selfRef };
|
|
1518
|
+
const branches = [
|
|
1519
|
+
{ type: 'string' },
|
|
1520
|
+
{ type: 'number' },
|
|
1521
|
+
{ type: 'boolean' },
|
|
1522
|
+
{ type: 'null' },
|
|
1523
|
+
arrayBranch,
|
|
1524
|
+
];
|
|
1525
|
+
if (descriptor.anyEncoding === 'recursive-strict') {
|
|
1526
|
+
branches.push({
|
|
1527
|
+
type: 'array',
|
|
1528
|
+
items: {
|
|
1529
|
+
type: 'object',
|
|
1530
|
+
properties: {
|
|
1531
|
+
key: { type: 'string' },
|
|
1532
|
+
value: selfRef,
|
|
1533
|
+
},
|
|
1534
|
+
required: ['key', 'value'],
|
|
1535
|
+
additionalProperties: false,
|
|
1536
|
+
},
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
else if (descriptor.anyEncoding === 'recursive-open') {
|
|
1540
|
+
branches.push({
|
|
1541
|
+
type: 'object',
|
|
1542
|
+
additionalProperties: selfRef,
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
else {
|
|
1546
|
+
// flat
|
|
1547
|
+
branches.push({
|
|
1548
|
+
type: 'object',
|
|
1549
|
+
additionalProperties: true,
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
return { anyOf: branches };
|
|
1553
|
+
}
|
|
1554
|
+
/**
|
|
1555
|
+
* Makes a JSON Schema nullable.
|
|
1556
|
+
*
|
|
1557
|
+
* For descriptors where optional becomes nullable on the wire (OpenAI strict),
|
|
1558
|
+
* this rewrites the schema to either include `null` in `type` or wrap in
|
|
1559
|
+
* `anyOf: [..., {type: 'null'}]`. For other descriptors this is a no-op (the
|
|
1560
|
+
* caller handles optional via the `required[]` list instead).
|
|
1561
|
+
*/
|
|
1562
|
+
function makeNullable(schema, descriptor) {
|
|
1563
|
+
// Lenient/standard dialects don't rewrite — optional is expressed via
|
|
1564
|
+
// required[] omission, not via type-union with null.
|
|
1565
|
+
if (!descriptor.optionalAsNullable) {
|
|
1566
|
+
return schema;
|
|
1567
|
+
}
|
|
619
1568
|
if (schema.$ref) {
|
|
620
|
-
// If it's a reference, wrap in anyOf with null
|
|
621
1569
|
return {
|
|
622
1570
|
anyOf: [
|
|
623
1571
|
{ $ref: schema.$ref },
|
|
@@ -626,8 +1574,6 @@ function makeNullable(schema) {
|
|
|
626
1574
|
};
|
|
627
1575
|
}
|
|
628
1576
|
if (schema.type) {
|
|
629
|
-
// Check if schema has type-specific properties that require anyOf structure
|
|
630
|
-
// Arrays have 'items', objects have 'properties', etc.
|
|
631
1577
|
const hasTypeSpecificProps = schema.items !== undefined ||
|
|
632
1578
|
schema.properties !== undefined ||
|
|
633
1579
|
schema.additionalProperties !== undefined ||
|
|
@@ -636,9 +1582,7 @@ function makeNullable(schema) {
|
|
|
636
1582
|
schema.maxItems !== undefined ||
|
|
637
1583
|
schema.minProperties !== undefined ||
|
|
638
1584
|
schema.maxProperties !== undefined;
|
|
639
|
-
// If there are type-specific properties, must use anyOf to separate type schemas
|
|
640
1585
|
if (hasTypeSpecificProps) {
|
|
641
|
-
// Extract type-specific properties based on the type
|
|
642
1586
|
const { type, description, ...rest } = schema;
|
|
643
1587
|
const baseType = Array.isArray(type) ? type[0] : type;
|
|
644
1588
|
return {
|
|
@@ -649,7 +1593,6 @@ function makeNullable(schema) {
|
|
|
649
1593
|
...(description ? { description } : {}),
|
|
650
1594
|
};
|
|
651
1595
|
}
|
|
652
|
-
// For simple types without type-specific properties, can use array notation
|
|
653
1596
|
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
654
1597
|
if (!types.includes('null')) {
|
|
655
1598
|
return {
|
|
@@ -660,7 +1603,6 @@ function makeNullable(schema) {
|
|
|
660
1603
|
return schema;
|
|
661
1604
|
}
|
|
662
1605
|
if (schema.anyOf) {
|
|
663
|
-
// Already has anyOf, add null option if not present
|
|
664
1606
|
const hasNull = schema.anyOf.some((s) => s.type === 'null');
|
|
665
1607
|
if (!hasNull) {
|
|
666
1608
|
return {
|
|
@@ -670,7 +1612,6 @@ function makeNullable(schema) {
|
|
|
670
1612
|
}
|
|
671
1613
|
return schema;
|
|
672
1614
|
}
|
|
673
|
-
// For complex schemas without type, wrap in anyOf
|
|
674
1615
|
return {
|
|
675
1616
|
anyOf: [
|
|
676
1617
|
schema,
|
|
@@ -681,4 +1622,257 @@ function makeNullable(schema) {
|
|
|
681
1622
|
function isOptional(schema) {
|
|
682
1623
|
return schema._zod.optin !== undefined;
|
|
683
1624
|
}
|
|
1625
|
+
/**
|
|
1626
|
+
* Emit a `{ type: 'string', format: ... }` JSON Schema, dropping the format
|
|
1627
|
+
* when the descriptor's whitelist excludes it. The string type is always
|
|
1628
|
+
* preserved; only the format hint is gated.
|
|
1629
|
+
*/
|
|
1630
|
+
function stringWithFormat(format, descriptor) {
|
|
1631
|
+
const formats = descriptor.supportedStringFormats;
|
|
1632
|
+
if (formats === 'all' || formats.has(format)) {
|
|
1633
|
+
return { type: 'string', format };
|
|
1634
|
+
}
|
|
1635
|
+
return { type: 'string' };
|
|
1636
|
+
}
|
|
1637
|
+
const ZERO_FEATURES = Object.freeze({
|
|
1638
|
+
hasRecursion: false,
|
|
1639
|
+
optionalParameterCount: 0,
|
|
1640
|
+
unionTypeCount: 0,
|
|
1641
|
+
recordCount: 0,
|
|
1642
|
+
tupleCount: 0,
|
|
1643
|
+
});
|
|
1644
|
+
const featuresCache = new WeakMap();
|
|
1645
|
+
/**
|
|
1646
|
+
* Walk a Zod schema once and count its structural features. Result is
|
|
1647
|
+
* cached per schema in a WeakMap — second-and-subsequent calls are O(1) and
|
|
1648
|
+
* the entry is GC'd with the schema (same OOM-safe pattern as `strictify`).
|
|
1649
|
+
*/
|
|
1650
|
+
export function analyzeSchema(schema) {
|
|
1651
|
+
const cached = featuresCache.get(schema);
|
|
1652
|
+
if (cached)
|
|
1653
|
+
return cached;
|
|
1654
|
+
const visiting = new Set();
|
|
1655
|
+
const features = { ...ZERO_FEATURES };
|
|
1656
|
+
function walk(s) {
|
|
1657
|
+
if (!s)
|
|
1658
|
+
return;
|
|
1659
|
+
if (visiting.has(s))
|
|
1660
|
+
return;
|
|
1661
|
+
visiting.add(s);
|
|
1662
|
+
try {
|
|
1663
|
+
// Recursion is detected via z.lazy: peek at the inner schema (but only
|
|
1664
|
+
// once — the visiting set bounds the walk).
|
|
1665
|
+
if (s instanceof z.ZodLazy) {
|
|
1666
|
+
features.hasRecursion = true;
|
|
1667
|
+
try {
|
|
1668
|
+
walk(s.def.getter());
|
|
1669
|
+
}
|
|
1670
|
+
catch { /* lazy getter may throw at analyze time; ignore */ }
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
if (s instanceof z.ZodOptional) {
|
|
1674
|
+
features.optionalParameterCount += 1;
|
|
1675
|
+
walk(s.unwrap());
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
if (s instanceof z.ZodNullable) {
|
|
1679
|
+
features.unionTypeCount += 1; // T|null counts as a union on the wire
|
|
1680
|
+
walk(s.unwrap());
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
if (s instanceof z.ZodUnion || s instanceof z.ZodDiscriminatedUnion) {
|
|
1684
|
+
features.unionTypeCount += 1;
|
|
1685
|
+
for (const opt of s.options)
|
|
1686
|
+
walk(opt);
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
if (s instanceof z.ZodIntersection) {
|
|
1690
|
+
walk(s.def.left);
|
|
1691
|
+
walk(s.def.right);
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
if (s instanceof z.ZodObject) {
|
|
1695
|
+
for (const key in s.shape)
|
|
1696
|
+
walk(s.shape[key]);
|
|
1697
|
+
if (s.def.catchall)
|
|
1698
|
+
walk(s.def.catchall);
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
if (s instanceof z.ZodArray) {
|
|
1702
|
+
walk(s.element);
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
if (s instanceof z.ZodRecord) {
|
|
1706
|
+
features.recordCount += 1;
|
|
1707
|
+
if (s.keyType)
|
|
1708
|
+
walk(s.keyType);
|
|
1709
|
+
walk(s.valueType);
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
if (s instanceof z.ZodTuple) {
|
|
1713
|
+
features.tupleCount += 1;
|
|
1714
|
+
for (const item of s.def.items)
|
|
1715
|
+
walk(item);
|
|
1716
|
+
if (s.def.rest)
|
|
1717
|
+
walk(s.def.rest);
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
if (s instanceof z.ZodDefault) {
|
|
1721
|
+
walk(s.def.innerType);
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
if (s instanceof z.ZodCodec || s instanceof z.ZodPipe) {
|
|
1725
|
+
walk(s.def.in);
|
|
1726
|
+
walk(s.def.out);
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
// Primitives and unknown leaves contribute nothing.
|
|
1730
|
+
}
|
|
1731
|
+
finally {
|
|
1732
|
+
visiting.delete(s);
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
walk(schema);
|
|
1736
|
+
Object.freeze(features);
|
|
1737
|
+
featuresCache.set(schema, features);
|
|
1738
|
+
return features;
|
|
1739
|
+
}
|
|
1740
|
+
/**
|
|
1741
|
+
* Per-request strict-mode allocator.
|
|
1742
|
+
*
|
|
1743
|
+
* Constructed once per outgoing request with the chosen model's strictest
|
|
1744
|
+
* descriptor. Tracks remaining slots (strict tools, optional parameters,
|
|
1745
|
+
* union types) against the descriptor's documented per-request limits and
|
|
1746
|
+
* decides per-tool / per-output whether to emit strict or fall back to
|
|
1747
|
+
* LENIENT.
|
|
1748
|
+
*
|
|
1749
|
+
* Selection guarantees that any tool with `strict: true` (hard requirement)
|
|
1750
|
+
* has a model that supports the family — `allocate` therefore always grants
|
|
1751
|
+
* strict for `requested === true` (subject only to feasibility, not budget;
|
|
1752
|
+
* if `true` items overflow the budget the API call will fail, which is the
|
|
1753
|
+
* correct behavior for hard requirements).
|
|
1754
|
+
*
|
|
1755
|
+
* Numeric `strict: <priority>` items are allocated greedily after `true`
|
|
1756
|
+
* items have consumed their share of the budget. Caller is expected to
|
|
1757
|
+
* pre-sort by descending priority before iterating.
|
|
1758
|
+
*/
|
|
1759
|
+
export class SchemaBudget {
|
|
1760
|
+
descriptor;
|
|
1761
|
+
remainingTools;
|
|
1762
|
+
remainingOptionalParams;
|
|
1763
|
+
remainingUnionTypes;
|
|
1764
|
+
constructor(descriptor) {
|
|
1765
|
+
this.descriptor = descriptor;
|
|
1766
|
+
this.remainingTools = descriptor.maxStrictTools ?? Infinity;
|
|
1767
|
+
this.remainingOptionalParams = descriptor.maxStrictOptionalParams ?? Infinity;
|
|
1768
|
+
this.remainingUnionTypes = descriptor.maxStrictUnionTypes ?? Infinity;
|
|
1769
|
+
}
|
|
1770
|
+
/**
|
|
1771
|
+
* Decide the effective descriptor for a tool's parameter schema given the
|
|
1772
|
+
* dev's strict request, the schema's features, and the remaining budget.
|
|
1773
|
+
*
|
|
1774
|
+
* Returns `LENIENT` when:
|
|
1775
|
+
* - `requested === false`
|
|
1776
|
+
* - the schema uses a feature the descriptor can't represent
|
|
1777
|
+
* - the descriptor has slot limits and the budget is exhausted (only
|
|
1778
|
+
* applies to `requested` being a positive number; `true` always wins
|
|
1779
|
+
* subject to feasibility)
|
|
1780
|
+
*
|
|
1781
|
+
* Returns the strict descriptor (and decrements the budget) otherwise.
|
|
1782
|
+
*/
|
|
1783
|
+
allocateTool(schema, requested) {
|
|
1784
|
+
return this.allocate(schema, requested, /* isTool */ true);
|
|
1785
|
+
}
|
|
1786
|
+
/**
|
|
1787
|
+
* Same as `allocateTool` but for the request's structured-output schema.
|
|
1788
|
+
* The output schema doesn't consume a tool slot but it does count toward
|
|
1789
|
+
* the optional-parameter and union-type budgets — descriptors document
|
|
1790
|
+
* those limits as "across all strict schemas in one request".
|
|
1791
|
+
*/
|
|
1792
|
+
allocateOutput(schema, requested) {
|
|
1793
|
+
return this.allocate(schema, requested, /* isTool */ false);
|
|
1794
|
+
}
|
|
1795
|
+
/** Snapshot of remaining budget. Useful for telemetry / debug logs. */
|
|
1796
|
+
remaining() {
|
|
1797
|
+
return {
|
|
1798
|
+
strictTools: this.remainingTools,
|
|
1799
|
+
optionalParams: this.remainingOptionalParams,
|
|
1800
|
+
unionTypes: this.remainingUnionTypes,
|
|
1801
|
+
};
|
|
1802
|
+
}
|
|
1803
|
+
allocate(schema, requested, isTool) {
|
|
1804
|
+
// strict: false → always lenient, no budget movement.
|
|
1805
|
+
if (requested === false)
|
|
1806
|
+
return LENIENT;
|
|
1807
|
+
// Descriptor itself is lenient → nothing to allocate.
|
|
1808
|
+
if (!this.descriptor.strict)
|
|
1809
|
+
return LENIENT;
|
|
1810
|
+
const features = analyzeSchema(schema);
|
|
1811
|
+
const isHardRequired = requested === true;
|
|
1812
|
+
const isPreferredNumeric = typeof requested === 'number' && requested > 0;
|
|
1813
|
+
if (!isHardRequired && !isPreferredNumeric) {
|
|
1814
|
+
// requested is undefined or 0 — treat as no preference; default to
|
|
1815
|
+
// lenient so callers explicitly opt into strict.
|
|
1816
|
+
return LENIENT;
|
|
1817
|
+
}
|
|
1818
|
+
// Soft-priority items must fit the budget. Hard-required items skip
|
|
1819
|
+
// budget checks because selection already promised the model can take
|
|
1820
|
+
// them — if there are too many, that's a request-construction bug we
|
|
1821
|
+
// surface via API error rather than degrading silently.
|
|
1822
|
+
if (isPreferredNumeric) {
|
|
1823
|
+
if (isTool && this.remainingTools <= 0)
|
|
1824
|
+
return LENIENT;
|
|
1825
|
+
if (this.remainingOptionalParams - features.optionalParameterCount < 0)
|
|
1826
|
+
return LENIENT;
|
|
1827
|
+
if (this.remainingUnionTypes - features.unionTypeCount < 0)
|
|
1828
|
+
return LENIENT;
|
|
1829
|
+
}
|
|
1830
|
+
// Allocation succeeded — decrement.
|
|
1831
|
+
if (isTool)
|
|
1832
|
+
this.remainingTools -= 1;
|
|
1833
|
+
this.remainingOptionalParams -= features.optionalParameterCount;
|
|
1834
|
+
this.remainingUnionTypes -= features.unionTypeCount;
|
|
1835
|
+
return this.descriptor;
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
/**
|
|
1839
|
+
* Compare two descriptors and return the one with the tighter per-request
|
|
1840
|
+
* budget. Used by providers to pick a single descriptor for a SchemaBudget
|
|
1841
|
+
* shared between `convertTools` and `convertResponseFormat` — Anthropic's
|
|
1842
|
+
* documented limits apply across the whole request, so the strictest
|
|
1843
|
+
* descriptor wins.
|
|
1844
|
+
*/
|
|
1845
|
+
export function strictestOf(a, b) {
|
|
1846
|
+
// LENIENT is the loosest — pick the strict side if either is strict.
|
|
1847
|
+
if (a.strict && !b.strict)
|
|
1848
|
+
return a;
|
|
1849
|
+
if (b.strict && !a.strict)
|
|
1850
|
+
return b;
|
|
1851
|
+
if (!a.strict && !b.strict)
|
|
1852
|
+
return a;
|
|
1853
|
+
// Both strict — pick the one with the smallest documented limits.
|
|
1854
|
+
const score = (d) => (d.maxStrictTools ?? Infinity) +
|
|
1855
|
+
(d.maxStrictOptionalParams ?? Infinity) +
|
|
1856
|
+
(d.maxStrictUnionTypes ?? Infinity);
|
|
1857
|
+
return score(a) <= score(b) ? a : b;
|
|
1858
|
+
}
|
|
1859
|
+
/**
|
|
1860
|
+
* Convert a `boolean | number | undefined` strict request into a numeric
|
|
1861
|
+
* priority for sorting. Used by providers to allocate strict slots in
|
|
1862
|
+
* descending priority order.
|
|
1863
|
+
*
|
|
1864
|
+
* - `true` → `+Infinity` (hard requirement; always first in line)
|
|
1865
|
+
* - `false` → `-Infinity` (always lenient; never wants strict)
|
|
1866
|
+
* - `number > 0` → the number itself
|
|
1867
|
+
* - `number <= 0` or `undefined` → `0` (no preference)
|
|
1868
|
+
*/
|
|
1869
|
+
export function strictPriority(requested) {
|
|
1870
|
+
if (requested === true)
|
|
1871
|
+
return Infinity;
|
|
1872
|
+
if (requested === false)
|
|
1873
|
+
return -Infinity;
|
|
1874
|
+
if (typeof requested === 'number')
|
|
1875
|
+
return requested > 0 ? requested : 0;
|
|
1876
|
+
return 0;
|
|
1877
|
+
}
|
|
684
1878
|
//# sourceMappingURL=schema.js.map
|