@aeye/core 0.5.4 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -4
- package/aeye-core-schema.md +11 -7
- package/dist/common.d.ts +30 -5
- package/dist/common.d.ts.map +1 -1
- package/dist/common.js +134 -129
- package/dist/common.js.map +1 -1
- package/dist/delivery.d.ts +39 -0
- package/dist/delivery.d.ts.map +1 -0
- package/dist/delivery.js +127 -0
- package/dist/delivery.js.map +1 -0
- package/dist/errors.d.ts +60 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +84 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/prompt.d.ts +101 -9
- package/dist/prompt.d.ts.map +1 -1
- package/dist/prompt.js +163 -141
- package/dist/prompt.js.map +1 -1
- package/dist/schema/analyze.d.ts +94 -0
- package/dist/schema/analyze.d.ts.map +1 -0
- package/dist/schema/analyze.js +221 -0
- package/dist/schema/analyze.js.map +1 -0
- package/dist/schema/any-value.d.ts +32 -0
- package/dist/schema/any-value.d.ts.map +1 -0
- package/dist/schema/any-value.js +77 -0
- package/dist/schema/any-value.js.map +1 -0
- package/dist/schema/budget.d.ts +114 -0
- package/dist/schema/budget.d.ts.map +1 -0
- package/dist/schema/budget.js +223 -0
- package/dist/schema/budget.js.map +1 -0
- package/dist/schema/consistency.d.ts +27 -0
- package/dist/schema/consistency.d.ts.map +1 -0
- package/dist/schema/consistency.js +110 -0
- package/dist/schema/consistency.js.map +1 -0
- package/dist/schema/cycle-breaker.d.ts +14 -0
- package/dist/schema/cycle-breaker.d.ts.map +1 -0
- package/dist/schema/cycle-breaker.js +157 -0
- package/dist/schema/cycle-breaker.js.map +1 -0
- package/dist/schema/decode.d.ts +31 -0
- package/dist/schema/decode.d.ts.map +1 -0
- package/dist/schema/decode.js +207 -0
- package/dist/schema/decode.js.map +1 -0
- package/dist/schema/descriptor.d.ts +300 -0
- package/dist/schema/descriptor.d.ts.map +1 -0
- package/dist/schema/descriptor.js +15 -0
- package/dist/schema/descriptor.js.map +1 -0
- package/dist/schema/dialects/anthropic.d.ts +34 -0
- package/dist/schema/dialects/anthropic.d.ts.map +1 -0
- package/dist/schema/dialects/anthropic.js +77 -0
- package/dist/schema/dialects/anthropic.js.map +1 -0
- package/dist/schema/dialects/google.d.ts +47 -0
- package/dist/schema/dialects/google.d.ts.map +1 -0
- package/dist/schema/dialects/google.js +91 -0
- package/dist/schema/dialects/google.js.map +1 -0
- package/dist/schema/dialects/lenient.d.ts +4 -0
- package/dist/schema/dialects/lenient.d.ts.map +1 -0
- package/dist/schema/dialects/lenient.js +26 -0
- package/dist/schema/dialects/lenient.js.map +1 -0
- package/dist/schema/dialects/openai.d.ts +40 -0
- package/dist/schema/dialects/openai.d.ts.map +1 -0
- package/dist/schema/dialects/openai.js +81 -0
- package/dist/schema/dialects/openai.js.map +1 -0
- package/dist/schema/dialects/xai.d.ts +23 -0
- package/dist/schema/dialects/xai.d.ts.map +1 -0
- package/dist/schema/dialects/xai.js +53 -0
- package/dist/schema/dialects/xai.js.map +1 -0
- package/dist/schema/enum-values.d.ts +44 -0
- package/dist/schema/enum-values.d.ts.map +1 -0
- package/dist/schema/enum-values.js +56 -0
- package/dist/schema/enum-values.js.map +1 -0
- package/dist/schema/expressible.d.ts +59 -0
- package/dist/schema/expressible.d.ts.map +1 -0
- package/dist/schema/expressible.js +161 -0
- package/dist/schema/expressible.js.map +1 -0
- package/dist/schema/index.d.ts +20 -0
- package/dist/schema/index.d.ts.map +1 -0
- package/dist/schema/index.js +20 -0
- package/dist/schema/index.js.map +1 -0
- package/dist/schema/json-schema.d.ts +64 -0
- package/dist/schema/json-schema.d.ts.map +1 -0
- package/dist/schema/json-schema.js +2 -0
- package/dist/schema/json-schema.js.map +1 -0
- package/dist/schema/registry.d.ts +73 -0
- package/dist/schema/registry.d.ts.map +1 -0
- package/dist/schema/registry.js +135 -0
- package/dist/schema/registry.js.map +1 -0
- package/dist/schema/strictify.d.ts +19 -0
- package/dist/schema/strictify.d.ts.map +1 -0
- package/dist/schema/strictify.js +237 -0
- package/dist/schema/strictify.js.map +1 -0
- package/dist/schema/to-json-schema.d.ts +29 -0
- package/dist/schema/to-json-schema.d.ts.map +1 -0
- package/dist/schema/to-json-schema.js +691 -0
- package/dist/schema/to-json-schema.js.map +1 -0
- package/dist/schema/walk-key.d.ts +24 -0
- package/dist/schema/walk-key.d.ts.map +1 -0
- package/dist/schema/walk-key.js +33 -0
- package/dist/schema/walk-key.js.map +1 -0
- package/dist/to.js +1 -1
- package/dist/to.js.map +1 -1
- package/dist/tool.d.ts +32 -3
- package/dist/tool.d.ts.map +1 -1
- package/dist/tool.js +41 -0
- package/dist/tool.js.map +1 -1
- package/dist/types.d.ts +265 -8
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +4 -1
- package/dist/types.js.map +1 -1
- package/package.json +72 -72
- package/dist/schema.d.ts +0 -840
- package/dist/schema.d.ts.map +0 -1
- package/dist/schema.js +0 -2478
- package/dist/schema.js.map +0 -1
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
import z from 'zod';
|
|
2
|
+
import { resolveDescriptor } from './registry';
|
|
3
|
+
import { buildAnyValueSchema, usesSharedAnyDefinition } from './any-value';
|
|
4
|
+
import { buildCycleBreakerSchema } from './cycle-breaker';
|
|
5
|
+
import { describeOversizeEnum, enumEmittedValues, literalEmittedValues } from './enum-values';
|
|
6
|
+
/**
|
|
7
|
+
* Converts a Zod schema to JSON Schema with support for different provider dialects.
|
|
8
|
+
*
|
|
9
|
+
* Accepts either a boolean (legacy: true = OPENAI_STRICT, false = LENIENT),
|
|
10
|
+
* an options object `{ strict, format? }`, or a `FormatDescriptor` directly.
|
|
11
|
+
*
|
|
12
|
+
* @param schema - The Zod schema to convert
|
|
13
|
+
* @param options - Configuration options for the conversion
|
|
14
|
+
* @returns JSON Schema object compatible with the resolved dialect
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```typescript
|
|
18
|
+
* const schema = z.object({
|
|
19
|
+
* name: z.string(),
|
|
20
|
+
* age: z.number().optional(),
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* // For OpenAI strict mode
|
|
24
|
+
* const jsonSchema = toJSONSchema(schema, { strict: true, format: 'openai' });
|
|
25
|
+
*
|
|
26
|
+
* // For Anthropic strict mode
|
|
27
|
+
* const anthropic = toJSONSchema(schema, ANTHROPIC_STRICT);
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export function toJSONSchema(schema, options) {
|
|
31
|
+
const descriptor = resolveDescriptor(options);
|
|
32
|
+
const context = {
|
|
33
|
+
root: schema,
|
|
34
|
+
descriptor,
|
|
35
|
+
definitions: new Map(),
|
|
36
|
+
definitionSchemas: new Map(),
|
|
37
|
+
refCounter: 0,
|
|
38
|
+
path: [],
|
|
39
|
+
inProgress: new Set(),
|
|
40
|
+
};
|
|
41
|
+
const result = convert(schema, context);
|
|
42
|
+
// Add definitions if any were created
|
|
43
|
+
if (context.definitionSchemas.size > 0) {
|
|
44
|
+
result.$defs = Object.fromEntries(context.definitionSchemas);
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Main recursive conversion function
|
|
50
|
+
*/
|
|
51
|
+
function convert(schema, context) {
|
|
52
|
+
// For lazy schemas, we need to check by aid first to handle .describe()/.optional() wrappers
|
|
53
|
+
// that create new objects but share the same lazy getter
|
|
54
|
+
let cacheKey = schema;
|
|
55
|
+
let unwrappedSchema = schema;
|
|
56
|
+
// `z.any()` / `z.unknown()` under an INLINE any-encoding (`flat`,
|
|
57
|
+
// `unconstrained`) must never travel through the definition cache: a second
|
|
58
|
+
// use site of the SAME schema instance — common when a codegen layer hands
|
|
59
|
+
// out one shared "any" node — would otherwise be emitted as
|
|
60
|
+
// `$ref: '#/$defs/__schemaN'`, resurrecting the `$defs` entry the inline
|
|
61
|
+
// encoding exists to avoid. Bypassing the cache re-inlines the (tiny,
|
|
62
|
+
// keyword-free) shape at each site instead.
|
|
63
|
+
const bypassCache = !usesSharedAnyDefinition(context.descriptor)
|
|
64
|
+
&& (schema instanceof z.ZodAny || schema instanceof z.ZodUnknown);
|
|
65
|
+
// Check if this is a lazy schema and extract metadata early
|
|
66
|
+
if (bypassCache) {
|
|
67
|
+
// fall through to conversion with no cache read
|
|
68
|
+
}
|
|
69
|
+
else if (schema instanceof z.ZodLazy) {
|
|
70
|
+
// Check cache FIRST before evaluating getter to prevent infinite recursion
|
|
71
|
+
const [cachedJs, cachedId] = context.definitions.get(schema) || [];
|
|
72
|
+
if (cachedJs && cachedId) {
|
|
73
|
+
return emitCachedRef(schema, cachedJs, cachedId, context);
|
|
74
|
+
}
|
|
75
|
+
const metadata = (schema instanceof z.ZodType) ? schema.meta() : null;
|
|
76
|
+
const aid = metadata?.aid;
|
|
77
|
+
// If there's an aid, check if we've already seen this aid
|
|
78
|
+
if (aid) {
|
|
79
|
+
for (const [cachedSchema, [js, jsId]] of context.definitions.entries()) {
|
|
80
|
+
if (cachedSchema instanceof z.ZodType) {
|
|
81
|
+
const cachedMeta = cachedSchema.meta();
|
|
82
|
+
if (cachedMeta?.aid === aid && jsId && js) {
|
|
83
|
+
// Found a cached version with the same aid - use it
|
|
84
|
+
return emitCachedRef(schema, js, jsId, context);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
unwrappedSchema = schema.def.getter();
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
// Check cache by object identity for non-lazy schemas
|
|
93
|
+
const [js, jsId] = context.definitions.get(schema) || [];
|
|
94
|
+
if (jsId && js) {
|
|
95
|
+
return emitCachedRef(schema, js, jsId, context);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Capture metadata from the original schema
|
|
99
|
+
const metadata = {};
|
|
100
|
+
if (schema instanceof z.ZodType) {
|
|
101
|
+
Object.assign(metadata, schema.meta() || {});
|
|
102
|
+
if (!metadata.description && schema.description) {
|
|
103
|
+
metadata.description = schema.description;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// If the schema has an 'aid' or 'id' in meta, promote it to a definition.
|
|
107
|
+
// A cache-bypassed "any" node is never promoted — naming a schema that
|
|
108
|
+
// asserts nothing buys nothing, and the `$defs` entry is precisely what the
|
|
109
|
+
// inline encoding is avoiding. A descriptor that forbids `$defs` outright
|
|
110
|
+
// (`allowDefsRef: false` — Google strict) is never promoted to either: the id
|
|
111
|
+
// is still computed, because the cycle-breaker's description names it, but
|
|
112
|
+
// the definition is inlined at each use site, which is the only spelling that
|
|
113
|
+
// dialect has for a shared node.
|
|
114
|
+
const id = (metadata.aid ? String(metadata.aid) : 0) || metadata.id || `__schema${context.refCounter++}`;
|
|
115
|
+
const save = !bypassCache
|
|
116
|
+
&& !!(metadata.aid || metadata.id)
|
|
117
|
+
&& context.root !== schema
|
|
118
|
+
&& context.descriptor.allowDefsRef;
|
|
119
|
+
// A schema target - will hold either the converted schema or a $ref
|
|
120
|
+
const target = {};
|
|
121
|
+
// Before converting, register this schema to handle recursion
|
|
122
|
+
if (!bypassCache) {
|
|
123
|
+
context.definitions.set(cacheKey, [target, id]);
|
|
124
|
+
}
|
|
125
|
+
context.inProgress.add(id);
|
|
126
|
+
// Convert the unwrapped schema
|
|
127
|
+
let result;
|
|
128
|
+
try {
|
|
129
|
+
result = convertSchema(unwrappedSchema, context);
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
context.inProgress.delete(id);
|
|
133
|
+
}
|
|
134
|
+
// A description the CONVERTER produced describes the EMITTED node, not the
|
|
135
|
+
// source schema — today that is only the `maxEnumValues` widening, whose
|
|
136
|
+
// description IS the value list that replaced the `enum` keyword. The source's
|
|
137
|
+
// own `.describe()` must not silently replace it, or a model handed a widened
|
|
138
|
+
// `{type:'string'}` is told nothing about which strings are legal. Keep both,
|
|
139
|
+
// converter note last.
|
|
140
|
+
const emittedNote = result.description;
|
|
141
|
+
Object.assign(result, metadata);
|
|
142
|
+
if (emittedNote !== undefined && metadata.description !== undefined && metadata.description !== emittedNote) {
|
|
143
|
+
result.description = `${metadata.description} ${emittedNote}`;
|
|
144
|
+
}
|
|
145
|
+
delete result.aid;
|
|
146
|
+
// Promote it because user requested it or it's recursive
|
|
147
|
+
if (save || context.definitionSchemas.has(id)) {
|
|
148
|
+
context.definitionSchemas.set(id, result);
|
|
149
|
+
target.$ref = `#/$defs/${id}`;
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
// Inline schema
|
|
153
|
+
Object.assign(target, result);
|
|
154
|
+
}
|
|
155
|
+
return target;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Emit a `$ref` (or, for descriptors that don't support recursion, an inline
|
|
159
|
+
* shape-aware placeholder) when the converter re-encounters a schema it's
|
|
160
|
+
* already started or finished converting.
|
|
161
|
+
*
|
|
162
|
+
* Three cases:
|
|
163
|
+
* 1. Re-encounter while still on the conversion stack (`inProgress.has(id)`)
|
|
164
|
+
* AND the descriptor cannot express the `$ref` this back-edge would need —
|
|
165
|
+
* emit a shape-aware placeholder via `buildCycleBreakerSchema()` (number
|
|
166
|
+
* stays number, array stays array, etc.) with a description naming the
|
|
167
|
+
* `$defs` entry it would have referenced. This breaks the cycle inline so
|
|
168
|
+
* providers like Anthropic don't reject the schema.
|
|
169
|
+
*
|
|
170
|
+
* "Cannot express" is TWO flags, not one, because a back-edge is only ever
|
|
171
|
+
* spelled one of two ways: `{$ref: '#'}` at the root, or `{$ref:
|
|
172
|
+
* '#/$defs/X'}` anywhere else. `!supportsRecursion` rules out both.
|
|
173
|
+
* `!allowDefsRef` rules out the second — so a NON-ROOT cycle under a
|
|
174
|
+
* descriptor that forbids `$defs` (Google strict is exactly that:
|
|
175
|
+
* `supportsRecursion: true`, `allowRootRef: true`, `allowDefsRef: false`)
|
|
176
|
+
* must take the same bounded placeholder. Checking only `supportsRecursion`
|
|
177
|
+
* silently emitted the `$defs`/`$ref` pair the descriptor declares it cannot
|
|
178
|
+
* send — a schema the provider answers with HTTP 400.
|
|
179
|
+
* 2. Re-encounter at root with `allowRootRef` — emit `{$ref: '#'}`.
|
|
180
|
+
* 3. Otherwise — emit `{$ref: '#/$defs/<id>'}`, promoting the original
|
|
181
|
+
* target to a `$defs` entry on first share.
|
|
182
|
+
*/
|
|
183
|
+
function emitCachedRef(schema, cachedJs, cachedId, context) {
|
|
184
|
+
const descriptor = context.descriptor;
|
|
185
|
+
const isCycle = context.inProgress.has(cachedId);
|
|
186
|
+
const isRoot = schema === context.root;
|
|
187
|
+
// The spelling this back-edge would need, and whether the descriptor has it.
|
|
188
|
+
const backEdgeExpressible = isRoot ? descriptor.allowRootRef : descriptor.allowDefsRef;
|
|
189
|
+
if (isCycle && (!descriptor.supportsRecursion || !backEdgeExpressible)) {
|
|
190
|
+
// Replace the back-edge with a placeholder whose top-level shape matches
|
|
191
|
+
// the recursive zod schema. The original `cachedJs` target keeps filling
|
|
192
|
+
// in normally — once conversion completes the `$defs` entry exists; we
|
|
193
|
+
// just don't reference it from here, since doing so would form the cycle
|
|
194
|
+
// the provider rejects.
|
|
195
|
+
return buildCycleBreakerSchema(schema, cachedId, descriptor);
|
|
196
|
+
}
|
|
197
|
+
if (isRoot && descriptor.allowRootRef) {
|
|
198
|
+
return { $ref: `#` };
|
|
199
|
+
}
|
|
200
|
+
// A non-cyclic re-encounter under a descriptor with no `$defs`: INLINE the
|
|
201
|
+
// already-converted shape instead of naming it. There is no third spelling in
|
|
202
|
+
// that dialect — a shared node is either repeated or it is a `$ref` the
|
|
203
|
+
// provider rejects. Safe and finite: `!isCycle` means this node's conversion
|
|
204
|
+
// already completed, and any cycle BELOW it was replaced by the bounded
|
|
205
|
+
// placeholder above, so the inlined value is a finite tree. The cost is
|
|
206
|
+
// duplication in the emitted schema, which is what that dialect charges for
|
|
207
|
+
// shared structure.
|
|
208
|
+
//
|
|
209
|
+
// On a schema the dialect can actually SEND that cost is negligible (measured
|
|
210
|
+
// on the cases in `schema-google-defs.test.ts`: 497→435, 627→565, 280→322
|
|
211
|
+
// bytes — inlining a leaf is often smaller than naming it). It is only large
|
|
212
|
+
// on a heavily-shared recursive DAG — a gin `buildSchemas().Expr` renders
|
|
213
|
+
// 44 KB with `$defs` and 1.36 MB without — and such a schema carries 1646
|
|
214
|
+
// `anyOf`s, so `canExpress` already answers `false` for it and no provider
|
|
215
|
+
// path reaches here. Paying size on a schema that must not be sent anyway is
|
|
216
|
+
// the right side of the trade against emitting `$defs` the descriptor
|
|
217
|
+
// declares it cannot send.
|
|
218
|
+
if (!descriptor.allowDefsRef) {
|
|
219
|
+
return { ...cachedJs };
|
|
220
|
+
}
|
|
221
|
+
if (!context.definitionSchemas.has(cachedId)) {
|
|
222
|
+
context.definitionSchemas.set(cachedId, { ...cachedJs });
|
|
223
|
+
for (const prop in cachedJs) {
|
|
224
|
+
delete cachedJs[prop];
|
|
225
|
+
}
|
|
226
|
+
cachedJs.$ref = `#/$defs/${cachedId}`;
|
|
227
|
+
}
|
|
228
|
+
return { $ref: `#/$defs/${cachedId}` };
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Main conversion function
|
|
232
|
+
*/
|
|
233
|
+
function convertSchema(schema, context) {
|
|
234
|
+
const descriptor = context.descriptor;
|
|
235
|
+
// TODO: Map, Set, File, ReadOnly, Nan, Catch, Prefault, NonOptional, Transform, Function, Promise, Custom
|
|
236
|
+
// Handle ZodOptional
|
|
237
|
+
if (schema instanceof z.ZodOptional) {
|
|
238
|
+
const innerSchema = schema.unwrap();
|
|
239
|
+
const innerJson = convert(innerSchema, context);
|
|
240
|
+
if (descriptor.optionalAsNullable) {
|
|
241
|
+
// OpenAI-strict-style: optional fields become nullable on the wire.
|
|
242
|
+
return makeNullable(innerJson, descriptor);
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
// Standard JSON Schema: optional fields are simply not in `required[]`.
|
|
246
|
+
return innerJson;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// Handle ZodNullable
|
|
250
|
+
if (schema instanceof z.ZodNullable) {
|
|
251
|
+
const innerSchema = schema.unwrap();
|
|
252
|
+
const innerJson = convert(innerSchema, context);
|
|
253
|
+
return makeNullable(innerJson, descriptor);
|
|
254
|
+
}
|
|
255
|
+
// Handle ZodObject
|
|
256
|
+
if (schema instanceof z.ZodObject) {
|
|
257
|
+
const properties = {};
|
|
258
|
+
const required = [];
|
|
259
|
+
const shape = schema.shape;
|
|
260
|
+
for (const key in shape) {
|
|
261
|
+
const fieldSchema = shape[key];
|
|
262
|
+
const isFieldRequired = descriptor.objectAllFieldsRequired || !isOptional(fieldSchema);
|
|
263
|
+
properties[key] = convert(fieldSchema, context);
|
|
264
|
+
if (isFieldRequired) {
|
|
265
|
+
required.push(key);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const result = {
|
|
269
|
+
type: 'object',
|
|
270
|
+
properties,
|
|
271
|
+
required,
|
|
272
|
+
};
|
|
273
|
+
if (descriptor.objectClosedByDefault || schema.def.catchall?._zod.def.type === "never") {
|
|
274
|
+
result.additionalProperties = false;
|
|
275
|
+
}
|
|
276
|
+
else if (schema.def.catchall) {
|
|
277
|
+
result.additionalProperties = convert(schema.def.catchall, context);
|
|
278
|
+
}
|
|
279
|
+
// Gemini 2.0 strict requires `propertyOrdering` so the model emits keys
|
|
280
|
+
// in a deterministic order. Other dialects ignore this hint, so emitting
|
|
281
|
+
// it under non-Google descriptors is harmless — but we gate it on the
|
|
282
|
+
// descriptor flag to keep the JSON Schema minimal everywhere else.
|
|
283
|
+
if (descriptor.emitPropertyOrdering) {
|
|
284
|
+
result.propertyOrdering = Object.keys(properties);
|
|
285
|
+
}
|
|
286
|
+
return result;
|
|
287
|
+
}
|
|
288
|
+
// Handle ZodArray
|
|
289
|
+
if (schema instanceof z.ZodArray) {
|
|
290
|
+
const result = {
|
|
291
|
+
type: 'array',
|
|
292
|
+
items: convert(schema.element, context),
|
|
293
|
+
};
|
|
294
|
+
if (descriptor.allowMinMaxItems) {
|
|
295
|
+
const { minimum, maximum } = schema._zod.bag;
|
|
296
|
+
if (typeof minimum === 'number') {
|
|
297
|
+
result.minItems = minimum;
|
|
298
|
+
}
|
|
299
|
+
if (typeof maximum === 'number') {
|
|
300
|
+
result.maxItems = maximum;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return result;
|
|
304
|
+
}
|
|
305
|
+
// Handle ZodRecord
|
|
306
|
+
if (schema instanceof z.ZodRecord) {
|
|
307
|
+
if (descriptor.recordEncoding === 'array-of-pairs') {
|
|
308
|
+
// Strict-mode workaround: records become arrays of {key, value} objects.
|
|
309
|
+
return {
|
|
310
|
+
type: 'array',
|
|
311
|
+
items: {
|
|
312
|
+
type: 'object',
|
|
313
|
+
properties: {
|
|
314
|
+
key: convert(schema.keyType, context),
|
|
315
|
+
value: convert(schema.valueType, context),
|
|
316
|
+
},
|
|
317
|
+
required: ['key', 'value'],
|
|
318
|
+
additionalProperties: false,
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
// Standard: open record. `propertyNames` only when the key says more than
|
|
323
|
+
// "a string": several grammar-compiling hosts reject the keyword outright
|
|
324
|
+
// (`Unimplemented keys: ["propertyNames"]`, DeepInfra and Cloudflare via
|
|
325
|
+
// OpenRouter, 2026-11-04), and for a plain string key it constrains nothing.
|
|
326
|
+
const propertyNames = convert(schema.keyType, context);
|
|
327
|
+
const additionalProperties = convert(schema.valueType, context);
|
|
328
|
+
return Object.keys(propertyNames).some((key) => key !== 'type' && key !== 'description')
|
|
329
|
+
? { type: 'object', propertyNames, additionalProperties }
|
|
330
|
+
: { type: 'object', additionalProperties };
|
|
331
|
+
}
|
|
332
|
+
// Handle ZodUnion
|
|
333
|
+
if (schema instanceof z.ZodUnion) {
|
|
334
|
+
const anyOf = schema.options.map(option => convert(option, context));
|
|
335
|
+
return { anyOf };
|
|
336
|
+
}
|
|
337
|
+
// Handle ZodDiscriminatedUnion
|
|
338
|
+
if (schema instanceof z.ZodDiscriminatedUnion) {
|
|
339
|
+
const anyOf = schema.options.map(option => convert(option, context));
|
|
340
|
+
return { anyOf };
|
|
341
|
+
}
|
|
342
|
+
// Handle ZodIntersection
|
|
343
|
+
if (schema instanceof z.ZodIntersection) {
|
|
344
|
+
const left = convert(schema.def.left, context);
|
|
345
|
+
const right = convert(schema.def.right, context);
|
|
346
|
+
const allOf = [
|
|
347
|
+
...(left.allOf && Object.keys(left).length === 1 ? left.allOf : [left]),
|
|
348
|
+
...(right.allOf && Object.keys(right).length === 1 ? right.allOf : [right]),
|
|
349
|
+
];
|
|
350
|
+
// OpenAI strict bans allOf in some shapes; collapse to anyOf when the
|
|
351
|
+
// descriptor disallows it.
|
|
352
|
+
return descriptor.allowAllOf ? { allOf } : { anyOf: allOf };
|
|
353
|
+
}
|
|
354
|
+
// Handle ZodTuple
|
|
355
|
+
if (schema instanceof z.ZodTuple) {
|
|
356
|
+
const items = schema.def.items.map(item => convert(item, context));
|
|
357
|
+
const rest = schema.def.rest ? convert(schema.def.rest, context) : undefined;
|
|
358
|
+
// Object-numeric-keys encoding: per-position type preserved as object
|
|
359
|
+
// properties. Variadic rests can't be represented this way — fall back to
|
|
360
|
+
// a homogeneous array of (items ∪ rest) for that rare case.
|
|
361
|
+
if (descriptor.tupleEncoding === 'object-numeric-keys' && !rest) {
|
|
362
|
+
const properties = {};
|
|
363
|
+
const required = [];
|
|
364
|
+
for (let i = 0; i < items.length; i++) {
|
|
365
|
+
const k = String(i);
|
|
366
|
+
properties[k] = isOptional(schema.def.items[i]) ? makeNullable(items[i], descriptor) : items[i];
|
|
367
|
+
required.push(k);
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
type: 'object',
|
|
371
|
+
properties,
|
|
372
|
+
required,
|
|
373
|
+
additionalProperties: false,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
const result = { type: 'array' };
|
|
377
|
+
if (descriptor.tupleEncoding === 'object-numeric-keys' && rest) {
|
|
378
|
+
// Strict + rest fallback: every position fits one of the declared
|
|
379
|
+
// types (positional info is lost — there's no way to express a
|
|
380
|
+
// mixed-prefix-plus-rest array in strict-object form).
|
|
381
|
+
items.push(rest);
|
|
382
|
+
}
|
|
383
|
+
// If all items are the same type, simplify to a single items schema
|
|
384
|
+
if (items.length > 0 && items.every((item) => JSON.stringify(item) === JSON.stringify(items[0]))) {
|
|
385
|
+
result.items = items[0];
|
|
386
|
+
}
|
|
387
|
+
else if (descriptor.tupleEncoding === 'prefix-items') {
|
|
388
|
+
result.prefixItems = items;
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
// items-union or strict-mode-with-rest fallback: collapse to anyOf
|
|
392
|
+
result.items = { anyOf: items };
|
|
393
|
+
}
|
|
394
|
+
if (descriptor.tupleEncoding === 'prefix-items' && rest) {
|
|
395
|
+
result.additionalItems = rest;
|
|
396
|
+
}
|
|
397
|
+
if (!rest) {
|
|
398
|
+
let minItems = items.length;
|
|
399
|
+
while (minItems > 0 && isOptional(schema.def.items[minItems - 1])) {
|
|
400
|
+
minItems--;
|
|
401
|
+
}
|
|
402
|
+
result.minItems = minItems;
|
|
403
|
+
result.maxItems = items.length;
|
|
404
|
+
}
|
|
405
|
+
if (descriptor.allowMinMaxItems) {
|
|
406
|
+
const { minimum, maximum } = schema._zod.bag;
|
|
407
|
+
if (typeof minimum === 'number') {
|
|
408
|
+
result.minItems = minimum;
|
|
409
|
+
}
|
|
410
|
+
if (typeof maximum === 'number') {
|
|
411
|
+
result.maxItems = maximum;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return result;
|
|
415
|
+
}
|
|
416
|
+
// Handle ZodEnum
|
|
417
|
+
if (schema instanceof z.ZodEnum) {
|
|
418
|
+
const values = enumEmittedValues(schema);
|
|
419
|
+
// Over the dialect's cap the `enum` keyword is DROPPED and the node widens
|
|
420
|
+
// to its plain scalar type, with every value listed in the description —
|
|
421
|
+
// see `describeOversizeEnum` for why widening beats truncating.
|
|
422
|
+
const oversize = describeOversizeEnum(values, descriptor);
|
|
423
|
+
return {
|
|
424
|
+
type: values.every((v) => typeof v === 'number')
|
|
425
|
+
? 'number'
|
|
426
|
+
: values.every((v) => typeof v === 'string')
|
|
427
|
+
? 'string'
|
|
428
|
+
: undefined,
|
|
429
|
+
...(oversize === undefined ? { enum: values } : { description: oversize }),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
// Handle ZodLiteral
|
|
433
|
+
if (schema instanceof z.ZodLiteral) {
|
|
434
|
+
const values = literalEmittedValues(schema);
|
|
435
|
+
const types = Array.from(new Set(values.map(v => v === null ? 'null' : typeof v)));
|
|
436
|
+
// A multi-value literal emits the SAME `enum` keyword as ZodEnum, so the
|
|
437
|
+
// dialect cap applies identically — the provider sees no difference. The
|
|
438
|
+
// single-value form emits `const`, which no cap can be exceeded by.
|
|
439
|
+
const oversize = values.length === 1 ? undefined : describeOversizeEnum(values, descriptor);
|
|
440
|
+
return {
|
|
441
|
+
...(types.length === 1 ? { type: types[0] } : {}),
|
|
442
|
+
...(values.length === 1
|
|
443
|
+
? { const: values[0] }
|
|
444
|
+
: oversize === undefined ? { enum: values } : { description: oversize }),
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
// Handle ZodDefault
|
|
448
|
+
if (schema instanceof z.ZodDefault) {
|
|
449
|
+
const innerJson = convert(schema.def.innerType, context);
|
|
450
|
+
innerJson.default = JSON.parse(JSON.stringify(schema.def.defaultValue));
|
|
451
|
+
return innerJson;
|
|
452
|
+
}
|
|
453
|
+
// Handle ZodLazy - don't unwrap here, let convert() handle caching
|
|
454
|
+
if (schema instanceof z.ZodLazy) {
|
|
455
|
+
throw new Error('ZodLazy should be handled in convert(), not convertSchema()');
|
|
456
|
+
}
|
|
457
|
+
// Handle ZodCodec
|
|
458
|
+
if (schema instanceof z.ZodCodec) {
|
|
459
|
+
return convert(schema.def.in, context);
|
|
460
|
+
}
|
|
461
|
+
// Handle ZodPipe (from preprocess)
|
|
462
|
+
if (schema instanceof z.ZodPipe) {
|
|
463
|
+
const innerType = schema.def.in._zod.def.type === "transform" ? schema.def.out : schema.def.in;
|
|
464
|
+
return convert(innerType, context);
|
|
465
|
+
}
|
|
466
|
+
// Handle primitive types
|
|
467
|
+
if (schema instanceof z.ZodString) {
|
|
468
|
+
const result = { type: 'string' };
|
|
469
|
+
const { minimum, maximum, format, patterns, contentEncoding } = schema._zod.bag;
|
|
470
|
+
// Format: emit only when allowed by the descriptor.
|
|
471
|
+
if (typeof format === 'string' && emitsFormat(format, descriptor)) {
|
|
472
|
+
result.format = format;
|
|
473
|
+
}
|
|
474
|
+
if (descriptor.allowMinMaxLength) {
|
|
475
|
+
if (typeof minimum === 'number') {
|
|
476
|
+
result.minLength = minimum;
|
|
477
|
+
}
|
|
478
|
+
if (typeof maximum === 'number') {
|
|
479
|
+
result.maxLength = maximum;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (typeof contentEncoding === 'string' && descriptor.allowMinMaxLength) {
|
|
483
|
+
// contentEncoding piggybacks on the lenient gate (it's not a strict-mode-safe field)
|
|
484
|
+
result.contentEncoding = contentEncoding;
|
|
485
|
+
}
|
|
486
|
+
if (patterns && descriptor.allowPattern) {
|
|
487
|
+
if (patterns.size === 1 || !descriptor.allowMultiplePatterns) {
|
|
488
|
+
result.pattern = Array.from(patterns)[0].source;
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
result.allOf = Array.from(patterns).map((regex) => ({
|
|
492
|
+
type: 'string',
|
|
493
|
+
pattern: regex.source,
|
|
494
|
+
}));
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return result;
|
|
498
|
+
}
|
|
499
|
+
if (schema instanceof z.ZodNumber) {
|
|
500
|
+
const result = { type: 'number' };
|
|
501
|
+
const { minimum, maximum, format, multipleOf, exclusiveMaximum, exclusiveMinimum } = schema._zod.bag;
|
|
502
|
+
if (typeof format === 'string' && format.includes("int")) {
|
|
503
|
+
result.type = 'integer';
|
|
504
|
+
}
|
|
505
|
+
if (descriptor.allowMinimumMaximum) {
|
|
506
|
+
if (typeof exclusiveMinimum === 'number') {
|
|
507
|
+
result.exclusiveMinimum = exclusiveMinimum;
|
|
508
|
+
}
|
|
509
|
+
else if (typeof minimum === 'number') {
|
|
510
|
+
result.minimum = minimum;
|
|
511
|
+
}
|
|
512
|
+
if (typeof exclusiveMaximum === 'number') {
|
|
513
|
+
result.exclusiveMaximum = exclusiveMaximum;
|
|
514
|
+
}
|
|
515
|
+
else if (typeof maximum === 'number') {
|
|
516
|
+
result.maximum = maximum;
|
|
517
|
+
}
|
|
518
|
+
if (typeof multipleOf === 'number') {
|
|
519
|
+
result.multipleOf = multipleOf;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return result;
|
|
523
|
+
}
|
|
524
|
+
if (schema instanceof z.ZodBoolean || schema instanceof z.ZodSuccess) {
|
|
525
|
+
return { type: 'boolean' };
|
|
526
|
+
}
|
|
527
|
+
if (schema instanceof z.ZodBigInt) {
|
|
528
|
+
return { type: 'integer' };
|
|
529
|
+
}
|
|
530
|
+
if (schema instanceof z.ZodDate || schema instanceof z.ZodISODateTime) {
|
|
531
|
+
return stringWithFormat('date-time', descriptor);
|
|
532
|
+
}
|
|
533
|
+
if (schema instanceof z.ZodISODate) {
|
|
534
|
+
return stringWithFormat('date', descriptor);
|
|
535
|
+
}
|
|
536
|
+
if (schema instanceof z.ZodISOTime) {
|
|
537
|
+
return stringWithFormat('time', descriptor);
|
|
538
|
+
}
|
|
539
|
+
if (schema instanceof z.ZodISODuration) {
|
|
540
|
+
return stringWithFormat('duration', descriptor);
|
|
541
|
+
}
|
|
542
|
+
if (schema instanceof z.ZodEmail) {
|
|
543
|
+
return stringWithFormat('email', descriptor);
|
|
544
|
+
}
|
|
545
|
+
if (schema instanceof z.ZodIPv4) {
|
|
546
|
+
return stringWithFormat('ipv4', descriptor);
|
|
547
|
+
}
|
|
548
|
+
if (schema instanceof z.ZodIPv6) {
|
|
549
|
+
return stringWithFormat('ipv6', descriptor);
|
|
550
|
+
}
|
|
551
|
+
if (schema instanceof z.ZodUUID) {
|
|
552
|
+
return stringWithFormat('uuid', descriptor);
|
|
553
|
+
}
|
|
554
|
+
if (schema instanceof z.ZodNull) {
|
|
555
|
+
return { type: 'null' };
|
|
556
|
+
}
|
|
557
|
+
if (schema instanceof z.ZodTemplateLiteral) {
|
|
558
|
+
return { type: 'string', pattern: schema._zod.pattern?.source };
|
|
559
|
+
}
|
|
560
|
+
if (schema instanceof z.ZodUndefined) {
|
|
561
|
+
return { type: 'null' }; // Treat undefined as null in JSON
|
|
562
|
+
}
|
|
563
|
+
if (schema instanceof z.ZodAny || schema instanceof z.ZodUnknown) {
|
|
564
|
+
// Bare `{}` would satisfy the meaning of "any" but OpenAI's strict
|
|
565
|
+
// structured-output mode rejects schemas without a `type` key. For
|
|
566
|
+
// descriptors with a recursive Any encoding, promote to a single shared
|
|
567
|
+
// `$defs/Any` definition that covers every JSON value and return a
|
|
568
|
+
// `$ref` — sharing keeps the output compact when `z.any()` appears many
|
|
569
|
+
// times inside a big union. For descriptors with `flat` Any encoding
|
|
570
|
+
// (e.g. Anthropic, which rejects recursive `$defs` graphs) or `unconstrained`
|
|
571
|
+
// (Google, which rejects both `anyOf` and named `$defs`), inline the
|
|
572
|
+
// permissive shape directly so the schema stays acyclic and `$defs`-free.
|
|
573
|
+
if (!usesSharedAnyDefinition(descriptor)) {
|
|
574
|
+
return buildAnyValueSchema(descriptor);
|
|
575
|
+
}
|
|
576
|
+
const id = 'Any';
|
|
577
|
+
if (!context.definitionSchemas.has(id)) {
|
|
578
|
+
// Seed a placeholder BEFORE building the body so recursive
|
|
579
|
+
// `$ref: '#/$defs/Any'` references don't infinite-loop during
|
|
580
|
+
// construction.
|
|
581
|
+
context.definitionSchemas.set(id, {});
|
|
582
|
+
context.definitionSchemas.set(id, buildAnyValueSchema(descriptor));
|
|
583
|
+
}
|
|
584
|
+
return { $ref: `#/$defs/${id}` };
|
|
585
|
+
}
|
|
586
|
+
if (schema instanceof z.ZodTransform) {
|
|
587
|
+
return {}; // Transforms are not represented in JSON Schema
|
|
588
|
+
}
|
|
589
|
+
// Fallback for unknown types
|
|
590
|
+
console.warn(`Unknown Zod schema type: ${schema.constructor.name}`);
|
|
591
|
+
return {};
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Makes a JSON Schema nullable: `z.nullable()` under every dialect, and
|
|
595
|
+
* `z.optional()` under one that spells optional as nullable (OpenAI strict).
|
|
596
|
+
*
|
|
597
|
+
* The null joins as an `anyOf` branch wherever the dialect has `anyOf`. A
|
|
598
|
+
* `["T", "null"]` type array is the spelling of last resort: o3's strict
|
|
599
|
+
* function validator rejects it ("schema must have a 'type' key", measured
|
|
600
|
+
* 2026-11-05) where the `anyOf` form passes.
|
|
601
|
+
*/
|
|
602
|
+
function makeNullable(schema, descriptor) {
|
|
603
|
+
// The empty schema already admits null
|
|
604
|
+
if (Object.keys(schema).length === 0) {
|
|
605
|
+
return schema;
|
|
606
|
+
}
|
|
607
|
+
if (schema.$ref) {
|
|
608
|
+
return {
|
|
609
|
+
anyOf: [
|
|
610
|
+
{ $ref: schema.$ref },
|
|
611
|
+
{ type: 'null' },
|
|
612
|
+
],
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
if (schema.type) {
|
|
616
|
+
const hasTypeSpecificProps = schema.items !== undefined ||
|
|
617
|
+
schema.properties !== undefined ||
|
|
618
|
+
schema.additionalProperties !== undefined ||
|
|
619
|
+
schema.patternProperties !== undefined ||
|
|
620
|
+
schema.minItems !== undefined ||
|
|
621
|
+
schema.maxItems !== undefined ||
|
|
622
|
+
schema.minProperties !== undefined ||
|
|
623
|
+
schema.maxProperties !== undefined;
|
|
624
|
+
if (hasTypeSpecificProps || (descriptor.allowAnyOf && !Array.isArray(schema.type) && schema.type !== 'null')) {
|
|
625
|
+
const { type, description, ...rest } = schema;
|
|
626
|
+
const baseType = Array.isArray(type) ? type[0] : type;
|
|
627
|
+
return {
|
|
628
|
+
anyOf: [
|
|
629
|
+
{ type: baseType, ...rest },
|
|
630
|
+
{ type: 'null' },
|
|
631
|
+
],
|
|
632
|
+
...(description ? { description } : {}),
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
636
|
+
if (!types.includes('null')) {
|
|
637
|
+
return {
|
|
638
|
+
...schema,
|
|
639
|
+
type: [...types, 'null'],
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
return schema;
|
|
643
|
+
}
|
|
644
|
+
if (schema.anyOf) {
|
|
645
|
+
const hasNull = schema.anyOf.some((s) => s.type === 'null');
|
|
646
|
+
if (!hasNull) {
|
|
647
|
+
return {
|
|
648
|
+
...schema,
|
|
649
|
+
anyOf: [...schema.anyOf, { type: 'null' }],
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
return schema;
|
|
653
|
+
}
|
|
654
|
+
return {
|
|
655
|
+
anyOf: [
|
|
656
|
+
schema,
|
|
657
|
+
{ type: 'null' },
|
|
658
|
+
],
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
function isOptional(schema) {
|
|
662
|
+
return schema._zod.optin !== undefined;
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* The `format` values JSON Schema (2020-12) defines, which is what `'all'`
|
|
666
|
+
* passes through. Zod's own check names (`regex` for `.regex()`, `cuid`,
|
|
667
|
+
* `starts_with`, …) share the field but are not formats: a grammar-compiling
|
|
668
|
+
* host rejects them (`Unknown format: regex`, DeepInfra via OpenRouter,
|
|
669
|
+
* 2026-11-04), so they are never emitted.
|
|
670
|
+
*/
|
|
671
|
+
const JSON_SCHEMA_FORMATS = new Set([
|
|
672
|
+
'date-time', 'date', 'time', 'duration', 'email', 'idn-email', 'hostname', 'idn-hostname',
|
|
673
|
+
'ipv4', 'ipv6', 'uri', 'uri-reference', 'iri', 'iri-reference', 'uuid', 'uri-template',
|
|
674
|
+
'json-pointer', 'relative-json-pointer',
|
|
675
|
+
]);
|
|
676
|
+
/**
|
|
677
|
+
* Whether `format` may be emitted under the descriptor's whitelist.
|
|
678
|
+
*/
|
|
679
|
+
function emitsFormat(format, descriptor) {
|
|
680
|
+
const formats = descriptor.supportedStringFormats;
|
|
681
|
+
return formats === 'all' ? JSON_SCHEMA_FORMATS.has(format) : formats.has(format);
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Emit a `{ type: 'string', format: ... }` JSON Schema, dropping the format
|
|
685
|
+
* when the descriptor's whitelist excludes it. The string type is always
|
|
686
|
+
* preserved; only the format hint is gated.
|
|
687
|
+
*/
|
|
688
|
+
function stringWithFormat(format, descriptor) {
|
|
689
|
+
return emitsFormat(format, descriptor) ? { type: 'string', format } : { type: 'string' };
|
|
690
|
+
}
|
|
691
|
+
//# sourceMappingURL=to-json-schema.js.map
|