@aeye/core 0.3.7 → 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/dist/schema.js CHANGED
@@ -1,11 +1,465 @@
1
1
  import z from 'zod';
2
2
  /**
3
- * Recursively transforms a Zod schema to support strict mode.
4
- *
5
- * @param schema - input Zod schema
6
- * @returns transformed Zod schema
7
- */
8
- export function strictify(schema) {
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
- * Extracts the input schema from a transformed schema (codec or preprocess).
47
- * For preprocess with optional, we need the nullable version for strict mode input.
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 getInputSchema(schema) {
51
- if (schema instanceof z.ZodCodec) {
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
- // Check if the inner schema is nullable
78
- // If it's nullable, don't convert null to undefined
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
- // For the input schema (array of {key, value}), use the input side of any codecs
116
- const key = getInputSchema(keyTransformed);
117
- const value = getInputSchema(valueTransformed);
118
- return transferMetadata(z.codec(z.array(z.object({ key, value })), z.record(keyTransformed, valueTransformed), {
119
- decode: (arr) => {
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 { key, value } of arr) {
122
- record[key] = value;
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
- encode: (rec) => Object.entries(rec).map(([key, value]) => ({ key, value })),
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
- return transferMetadata(z.tuple(schema.def.items.map(transform)), schema);
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 ZodEffects (transforms, refines, etc.)
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
- * Converts a Zod schema to JSON Schema V2 with support for different provider formats.
650
+ * Rebuild a `ZodObject` for the relaxed decoder.
158
651
  *
159
- * This is a custom implementation that recursively inspects Zod schemas and converts them
160
- * to JSON Schema following the pattern of strictify/strictifySimple.
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 specified format
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 strictSchema = strictify(schema);
175
- * const jsonSchema = toJSONSchemaV2(strictSchema, { strict: true, format: 'openai' });
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 resolvedOptions = typeof options === 'boolean' ? { strict: options } : options;
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
- strict,
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
- if (schema === context.root) {
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
- if (schema === context.root) {
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
- if (schema === context.root) {
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
- const result = convertSchema(unwrappedSchema, context);
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 (context.strict) {
309
- // In strict mode, optional fields become nullable
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
- // In non-strict mode, optional fields are just not required
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 isRequired = context.strict || !isOptional(fieldSchema);
1176
+ const isFieldRequired = descriptor.objectAllFieldsRequired || !isOptional(fieldSchema);
331
1177
  properties[key] = convert(fieldSchema, context);
332
- if (isRequired) {
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 (context.strict || schema.def.catchall?._zod.def.type === "never") {
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
- const { minimum, maximum } = schema._zod.bag;
356
- if (typeof minimum === 'number') {
357
- result.minItems = minimum;
358
- }
359
- if (typeof maximum === 'number') {
360
- result.maxItems = maximum;
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 - convert to array of {key, value} pairs in strict mode
1219
+ // Handle ZodRecord
365
1220
  if (schema instanceof z.ZodRecord) {
366
- if (context.strict) {
367
- // In strict mode, records become arrays of {key, value} objects
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
- else {
382
- // In non-strict mode, use additionalProperties
383
- return {
384
- type: 'object',
385
- propertyNames: convert(schema.keyType, context),
386
- additionalProperties: convert(schema.valueType, context),
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 - not supported in OpenAI strict mode, convert to anyOf
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
- // TODO merge types to support allOf directly for strict mode
409
- return context.strict ? { anyOf: allOf } : { allOf };
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 (context.strict && rest) {
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
- if (context.strict) {
427
- result.items = { anyOf: items };
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 (!context.strict && rest) {
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
- const { minimum, maximum } = schema._zod.bag;
445
- if (typeof minimum === 'number') {
446
- result.minItems = minimum;
447
- }
448
- if (typeof maximum === 'number') {
449
- result.maxItems = maximum;
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
- if (context.strict) {
504
- const strictFormats = ['date-time', 'time', 'date', 'duration', 'email', 'hostname', 'ipv4', 'ipv6', 'uuid'];
505
- if (typeof format === 'string' && strictFormats.includes(format)) {
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
- else {
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
- if (typeof format === 'string') {
520
- result.format = format;
521
- }
522
- if (typeof contentEncoding === 'string') {
523
- result.contentEncoding = contentEncoding;
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
- if (patterns) {
526
- if (patterns.size === 1) {
527
- result.pattern = Array.from(patterns)[0].source;
528
- }
529
- else {
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 (typeof exclusiveMinimum === 'number') {
547
- result.exclusiveMinimum = exclusiveMinimum;
548
- }
549
- else if (typeof minimum === 'number') {
550
- result.minimum = minimum;
551
- }
552
- if (typeof exclusiveMaximum === 'number') {
553
- result.exclusiveMaximum = exclusiveMaximum;
554
- }
555
- else if (typeof maximum === 'number') {
556
- result.maximum = maximum;
557
- }
558
- if (typeof multipleOf === 'number') {
559
- result.multipleOf = multipleOf;
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 { type: 'string', format: 'date-time' };
1438
+ return stringWithFormat('date-time', descriptor);
571
1439
  }
572
1440
  if (schema instanceof z.ZodISODate) {
573
- return { type: 'string', format: 'date' };
1441
+ return stringWithFormat('date', descriptor);
574
1442
  }
575
1443
  if (schema instanceof z.ZodISOTime) {
576
- return { type: 'string', format: 'time' };
1444
+ return stringWithFormat('time', descriptor);
577
1445
  }
578
1446
  if (schema instanceof z.ZodISODuration) {
579
- return { type: 'string', format: 'duration' };
1447
+ return stringWithFormat('duration', descriptor);
580
1448
  }
581
1449
  if (schema instanceof z.ZodEmail) {
582
- return { type: 'string', format: 'email' };
1450
+ return stringWithFormat('email', descriptor);
583
1451
  }
584
1452
  if (schema instanceof z.ZodIPv4) {
585
- return { type: 'string', format: 'ipv4' };
1453
+ return stringWithFormat('ipv4', descriptor);
586
1454
  }
587
1455
  if (schema instanceof z.ZodIPv6) {
588
- return { type: 'string', format: 'ipv6' };
1456
+ return stringWithFormat('ipv6', descriptor);
589
1457
  }
590
1458
  if (schema instanceof z.ZodUUID) {
591
- return { type: 'string', format: 'uuid' };
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
- return {}; // Any type has no constraints
604
- }
605
- if (schema instanceof z.ZodUnknown) {
606
- return {}; // Unknown type has no constraints
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
- * Makes a JSON Schema nullable by adding null to the type
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 makeNullable(schema) {
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