@aeye/core 0.5.4 → 0.7.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 +790 -724
- package/aeye-core-prompts.md +179 -178
- package/aeye-core-schema.md +43 -16
- package/aeye-core-tools.md +179 -177
- package/aeye-core-types.md +1 -0
- 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 +134 -15
- package/dist/prompt.d.ts.map +1 -1
- package/dist/prompt.js +220 -164
- 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/check-bag.d.ts +28 -0
- package/dist/schema/check-bag.d.ts.map +1 -0
- package/dist/schema/check-bag.js +129 -0
- package/dist/schema/check-bag.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 +692 -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 +38 -4
- package/dist/tool.d.ts.map +1 -1
- package/dist/tool.js +52 -8
- 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 +2 -2
- 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
package/dist/schema.js
DELETED
|
@@ -1,2478 +0,0 @@
|
|
|
1
|
-
import z from 'zod';
|
|
2
|
-
/**
|
|
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` for OpenAI.
|
|
50
|
-
*
|
|
51
|
-
* OpenAI DOES document per-SCHEMA size limits for Structured Outputs, and they
|
|
52
|
-
* are declared below (`schemaSizeLimits`). Verified against
|
|
53
|
-
* developers.openai.com/api/docs/guides/structured-outputs on 2026-08-23,
|
|
54
|
-
* section "Supported schemas": *"A schema may have up to 5000 object
|
|
55
|
-
* properties total, with up to 10 levels of nesting"*, *"total string length of
|
|
56
|
-
* all property names, definition names, enum values, and const values cannot
|
|
57
|
-
* exceed 120,000 characters"*, *"A schema may have up to 1000 enum values
|
|
58
|
-
* across all enum properties"*, and *"For a single enum property with string
|
|
59
|
-
* values, the total string length of all enum values cannot exceed 15,000
|
|
60
|
-
* characters when there are more than 250 enum values"*. Every one of those
|
|
61
|
-
* numbers was raised in July 2025 (100→5000 properties, 15,000→120,000
|
|
62
|
-
* characters, 500→1000 enum values, 7,500→15,000 for the >250-value enum) —
|
|
63
|
-
* the pre-raise figures are still widely quoted secondhand, so they are named
|
|
64
|
-
* here to keep a future reader from "correcting" these downward.
|
|
65
|
-
*
|
|
66
|
-
* Scope is Structured Outputs, i.e. a tool carrying `strict: true` or a
|
|
67
|
-
* `json_schema` response format — the function-calling guide defines strict
|
|
68
|
-
* mode as "leveraging our structured outputs feature", and non-strict function
|
|
69
|
-
* calling is best-effort with no compiled decoder. That is exactly why
|
|
70
|
-
* degrading the over-size item to `OPENAI_NON_STRICT` is a complete answer.
|
|
71
|
-
*/
|
|
72
|
-
export const OPENAI_STRICT = Object.freeze({
|
|
73
|
-
id: 'openai-strict',
|
|
74
|
-
family: 'openai',
|
|
75
|
-
strict: true,
|
|
76
|
-
objectAllFieldsRequired: true,
|
|
77
|
-
objectClosedByDefault: true,
|
|
78
|
-
recordEncoding: 'array-of-pairs',
|
|
79
|
-
tupleEncoding: 'object-numeric-keys',
|
|
80
|
-
allowAllOf: false,
|
|
81
|
-
allowAnyOf: true,
|
|
82
|
-
allowOneOf: false,
|
|
83
|
-
allowRootRef: true,
|
|
84
|
-
allowDefsRef: true,
|
|
85
|
-
emitPropertyOrdering: false,
|
|
86
|
-
supportedStringFormats: OPENAI_STRICT_FORMATS,
|
|
87
|
-
allowPattern: true,
|
|
88
|
-
allowMultiplePatterns: false,
|
|
89
|
-
allowMinMaxLength: false,
|
|
90
|
-
allowMinMaxItems: false,
|
|
91
|
-
allowMinimumMaximum: false,
|
|
92
|
-
optionalAsNullable: true,
|
|
93
|
-
anyEncoding: 'recursive-strict',
|
|
94
|
-
supportsRecursion: true,
|
|
95
|
-
// Every number is quoted from OpenAI's own "Supported schemas" section — see
|
|
96
|
-
// the descriptor doc above for the exact sentences and the date they were
|
|
97
|
-
// read. Frozen because `Object.freeze` on the descriptor is shallow.
|
|
98
|
-
schemaSizeLimits: Object.freeze({
|
|
99
|
-
maxObjectProperties: 5000,
|
|
100
|
-
maxNestingDepth: 10,
|
|
101
|
-
maxTotalStringChars: 120_000,
|
|
102
|
-
maxTotalEnumValues: 1000,
|
|
103
|
-
largeEnumValueCount: 250,
|
|
104
|
-
maxLargeEnumStringChars: 15_000,
|
|
105
|
-
}),
|
|
106
|
-
});
|
|
107
|
-
/** OpenAI non-strict — alias of LENIENT but tagged with the openai family. */
|
|
108
|
-
export const OPENAI_NON_STRICT = Object.freeze({
|
|
109
|
-
...LENIENT,
|
|
110
|
-
id: 'openai-non-strict',
|
|
111
|
-
family: 'openai',
|
|
112
|
-
});
|
|
113
|
-
/**
|
|
114
|
-
* Anthropic strict — Claude 4.5+ only (Opus 4.7/4.6/4.5, Sonnet 4.6/4.5,
|
|
115
|
-
* Haiku 4.5). Per Anthropic's structured-outputs docs:
|
|
116
|
-
*
|
|
117
|
-
* - `additionalProperties` may **only** be `false` (no schema-valued open
|
|
118
|
-
* records), so `z.record(...)` falls back to the OpenAI-style
|
|
119
|
-
* array-of-pairs workaround.
|
|
120
|
-
* - Non-cyclic `$ref` / `$defs` reuse is supported and used freely — only
|
|
121
|
-
* *circular* `$defs` graphs are rejected by the API
|
|
122
|
-
* (`Circular reference detected in schema definitions: …`). The emitter
|
|
123
|
-
* sets `supportsRecursion: false` so the cycle-breaker replaces the
|
|
124
|
-
* offending back-edge with the flat "any" shape (see `anyEncoding`) and
|
|
125
|
-
* leaves shared non-cyclic `$ref`s alone. Root self-reference (`$ref: '#'`)
|
|
126
|
-
* is always cyclic by definition, so `allowRootRef` stays `false`.
|
|
127
|
-
* - Numerical (`minimum`/`maximum`/`multipleOf`) and string-length
|
|
128
|
-
* (`minLength`/`maxLength`) constraints are not supported and are dropped.
|
|
129
|
-
* - `prefixItems` and other positional tuple keywords are not in the
|
|
130
|
-
* supported list, so tuples collapse to a homogeneous `items: anyOf`.
|
|
131
|
-
* - Supported formats include `uri` (unlike OpenAI strict). We pass all
|
|
132
|
-
* formats through and let Anthropic ignore unknowns.
|
|
133
|
-
*/
|
|
134
|
-
export const ANTHROPIC_STRICT = Object.freeze({
|
|
135
|
-
id: 'anthropic-strict',
|
|
136
|
-
family: 'anthropic',
|
|
137
|
-
strict: true,
|
|
138
|
-
objectAllFieldsRequired: false,
|
|
139
|
-
objectClosedByDefault: true,
|
|
140
|
-
recordEncoding: 'array-of-pairs',
|
|
141
|
-
tupleEncoding: 'items-union',
|
|
142
|
-
allowAllOf: true,
|
|
143
|
-
allowAnyOf: true,
|
|
144
|
-
allowOneOf: false,
|
|
145
|
-
allowRootRef: false,
|
|
146
|
-
allowDefsRef: true,
|
|
147
|
-
emitPropertyOrdering: false,
|
|
148
|
-
supportedStringFormats: 'all',
|
|
149
|
-
allowPattern: true,
|
|
150
|
-
allowMultiplePatterns: false,
|
|
151
|
-
allowMinMaxLength: false,
|
|
152
|
-
allowMinMaxItems: false,
|
|
153
|
-
allowMinimumMaximum: false,
|
|
154
|
-
optionalAsNullable: false,
|
|
155
|
-
// Anthropic rejects recursive `$defs/Any` definitions, so encode `z.any()`
|
|
156
|
-
// as a flat `anyOf` over every JSON value — equivalent to TypeScript `any`.
|
|
157
|
-
anyEncoding: 'flat',
|
|
158
|
-
// Anthropic-documented per-request limits (apply across ALL strict tool
|
|
159
|
-
// schemas + JSON output schemas in one request).
|
|
160
|
-
// Source: https://platform.claude.com/docs/en/build-with-claude/structured-outputs
|
|
161
|
-
maxStrictTools: 20,
|
|
162
|
-
maxStrictOptionalParams: 24,
|
|
163
|
-
maxStrictUnionTypes: 16,
|
|
164
|
-
// Recursive `$ref` graphs are rejected by Anthropic's tool input_schema
|
|
165
|
-
// validator. The toJSONSchema emitter detects cycles and replaces the
|
|
166
|
-
// back-edge with an inline flat "any" shape (see `anyEncoding`); shared
|
|
167
|
-
// non-cyclic `$ref`s are still emitted normally.
|
|
168
|
-
supportsRecursion: false,
|
|
169
|
-
});
|
|
170
|
-
export const ANTHROPIC_NON_STRICT = Object.freeze({
|
|
171
|
-
...LENIENT,
|
|
172
|
-
id: 'anthropic-non-strict',
|
|
173
|
-
family: 'anthropic',
|
|
174
|
-
});
|
|
175
|
-
/**
|
|
176
|
-
* Google Gemini strict — per `ai.google.dev/gemini-api/docs/structured-output`:
|
|
177
|
-
*
|
|
178
|
-
* - Supported keywords: types, `properties`, `required`, `additionalProperties`,
|
|
179
|
-
* `enum`, `format` (date-time/date/time only), `minimum`, `maximum`, `items`,
|
|
180
|
-
* `prefixItems`, `minItems`, `maxItems`, `title`, `description`,
|
|
181
|
-
* `propertyOrdering`. Recursion via `$ref: "#"` (root self-ref).
|
|
182
|
-
* - Combinators (`allOf`, `anyOf`, `oneOf`) are NOT in the supported list;
|
|
183
|
-
* we conservatively flag them off so unions degrade to a representable form
|
|
184
|
-
* rather than emit something Gemini ignores.
|
|
185
|
-
* - String constraints (`minLength`, `maxLength`, `pattern` formats beyond the
|
|
186
|
-
* three documented) are not supported.
|
|
187
|
-
* - `propertyOrdering` is REQUIRED for Gemini 2.0 strict; we always emit it
|
|
188
|
-
* on object schemas under this descriptor.
|
|
189
|
-
* - `z.any()` / `z.unknown()` uses the `unconstrained` encoding. Every other
|
|
190
|
-
* "any" encoding needs `anyOf` and/or a named `$defs/Any` — both of which
|
|
191
|
-
* this descriptor forbids two lines below — so the recursive `$defs/Any`
|
|
192
|
-
* this descriptor used to declare was a schema it says it cannot emit.
|
|
193
|
-
* Gemini compiles the tool schemas into a decoding grammar whenever the
|
|
194
|
-
* caller forces a tool call (`toolChoice: 'required'` → Google's function
|
|
195
|
-
* calling mode `ANY`), and a self-referencing `$defs/Any` failed that
|
|
196
|
-
* compile with `400 INVALID_ARGUMENT` — the same request succeeded under
|
|
197
|
-
* `toolChoice: 'auto'`, where no grammar is built. Reported against
|
|
198
|
-
* `google/gemini-3-flash-preview` via OpenRouter as 100% reproducible.
|
|
199
|
-
*
|
|
200
|
-
* **That 400 no longer reproduces (re-measured 2026-08-23)** and the
|
|
201
|
-
* encoding stays anyway. Same model, same route, upstream pinned to Google
|
|
202
|
-
* AI Studio with fallbacks off, `tool_choice` forced to one named tool: the
|
|
203
|
-
* hand-built recursive `$defs/Any` tool, and the product's real 32 KB gin
|
|
204
|
-
* `api_set` schema carrying nine `#/$defs/Any` references, both answered
|
|
205
|
-
* HTTP 200 with a valid tool call. So the upstream either fixed or now
|
|
206
|
-
* sanitizes it. The descriptor still declares what this dialect's own
|
|
207
|
-
* documented keyword list supports; depending on a provider's current
|
|
208
|
-
* leniency for a shape we say it cannot express is how the first version of
|
|
209
|
-
* this descriptor shipped a guaranteed 400 in the first place.
|
|
210
|
-
*/
|
|
211
|
-
export const GOOGLE_STRICT = Object.freeze({
|
|
212
|
-
id: 'google-strict',
|
|
213
|
-
family: 'google',
|
|
214
|
-
strict: true,
|
|
215
|
-
objectAllFieldsRequired: false,
|
|
216
|
-
objectClosedByDefault: false,
|
|
217
|
-
recordEncoding: 'open-record',
|
|
218
|
-
tupleEncoding: 'prefix-items',
|
|
219
|
-
allowAllOf: false,
|
|
220
|
-
allowAnyOf: false,
|
|
221
|
-
allowOneOf: false,
|
|
222
|
-
allowRootRef: true,
|
|
223
|
-
allowDefsRef: false,
|
|
224
|
-
emitPropertyOrdering: true,
|
|
225
|
-
supportedStringFormats: new Set(['date-time', 'date', 'time']),
|
|
226
|
-
allowPattern: false,
|
|
227
|
-
allowMultiplePatterns: false,
|
|
228
|
-
allowMinMaxLength: false,
|
|
229
|
-
allowMinMaxItems: true,
|
|
230
|
-
allowMinimumMaximum: true,
|
|
231
|
-
optionalAsNullable: false,
|
|
232
|
-
// The ONLY encoding expressible under `allowAnyOf: false` +
|
|
233
|
-
// `allowDefsRef: false` — see the descriptor doc above.
|
|
234
|
-
anyEncoding: 'unconstrained',
|
|
235
|
-
// No documented per-request slot limits. `maxEnumValues: 40` IS a measured
|
|
236
|
-
// response to a real `400 INVALID_ARGUMENT` on this exact product's live
|
|
237
|
-
// fn-catalog enum — see `FormatDescriptor.maxEnumValues` for the numbers,
|
|
238
|
-
// the bisection, and why 40 is conservative rather than the measured edge.
|
|
239
|
-
maxEnumValues: 40,
|
|
240
|
-
supportsRecursion: true,
|
|
241
|
-
// Gemini's structured-output endpoint rejects `anyOf`/`$defs` schemas
|
|
242
|
-
// (HTTP 400). When a schema can't be expressed here it's delivered as
|
|
243
|
-
// prompt text instead; this hint keeps the reply a single raw JSON object.
|
|
244
|
-
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.',
|
|
245
|
-
});
|
|
246
|
-
/**
|
|
247
|
-
* Google Gemini non-strict — LENIENT's permissive rules EXCEPT for the "any"
|
|
248
|
-
* encoding.
|
|
249
|
-
*
|
|
250
|
-
* Gemini rejects a self-referencing `$defs/Any` when it has to build a
|
|
251
|
-
* decoding grammar, and it builds one whenever a tool call is forced —
|
|
252
|
-
* independent of any per-tool `strict` flag, which Google's function-calling
|
|
253
|
-
* API doesn't even have. So the `unconstrained` encoding is a property of the
|
|
254
|
-
* DIALECT, not of strict mode, and belongs on both Google descriptors.
|
|
255
|
-
*
|
|
256
|
-
* Everything else stays LENIENT deliberately: the restrictions on
|
|
257
|
-
* `GOOGLE_STRICT` come from the documented structured-output keyword list,
|
|
258
|
-
* which is a grammar-mode concern, and tightening them here would push
|
|
259
|
-
* ordinary Gemini schemas onto the prompt-text delivery fallback for no
|
|
260
|
-
* measured reason.
|
|
261
|
-
*/
|
|
262
|
-
export const GOOGLE_NON_STRICT = Object.freeze({
|
|
263
|
-
...LENIENT,
|
|
264
|
-
id: 'google-non-strict',
|
|
265
|
-
family: 'google',
|
|
266
|
-
anyEncoding: 'unconstrained',
|
|
267
|
-
// Same measured enum-cardinality limit as GOOGLE_STRICT — it fires whatever
|
|
268
|
-
// the strictness (see FormatDescriptor.maxEnumValues), so a tool degraded
|
|
269
|
-
// to this descriptor still needs it.
|
|
270
|
-
maxEnumValues: 40,
|
|
271
|
-
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.',
|
|
272
|
-
});
|
|
273
|
-
// ============================================================================
|
|
274
|
-
// Descriptor self-consistency
|
|
275
|
-
// ============================================================================
|
|
276
|
-
/**
|
|
277
|
-
* JSON-Schema keywords each `anyEncoding` has to emit. Single source of truth
|
|
278
|
-
* for both `buildAnyValueSchema` (which produces the shape) and
|
|
279
|
-
* `checkDescriptorConsistency` (which checks the descriptor is allowed to).
|
|
280
|
-
*/
|
|
281
|
-
const ANY_ENCODING_REQUIREMENTS = Object.freeze({
|
|
282
|
-
'recursive-strict': { anyOf: true, defsRef: true },
|
|
283
|
-
'recursive-open': { anyOf: true, defsRef: true },
|
|
284
|
-
'flat': { anyOf: true, defsRef: false },
|
|
285
|
-
'unconstrained': { anyOf: false, defsRef: false },
|
|
286
|
-
});
|
|
287
|
-
/**
|
|
288
|
-
* Report the ways a `FormatDescriptor` contradicts itself — settings that ask
|
|
289
|
-
* the emitter to produce a keyword the same descriptor forbids, or a limit
|
|
290
|
-
* that would quietly strip every constraint it governs. Returns one
|
|
291
|
-
* human-readable line per problem; an empty array means the descriptor is
|
|
292
|
-
* internally consistent.
|
|
293
|
-
*
|
|
294
|
-
* This exists because a contradiction here is invisible until a provider
|
|
295
|
-
* answers HTTP 400: `GOOGLE_STRICT` shipped with `allowAnyOf: false` +
|
|
296
|
-
* `allowDefsRef: false` AND `anyEncoding: 'recursive-open'` (which needs
|
|
297
|
-
* both), so every `z.any()` in a Gemini tool schema emitted exactly the
|
|
298
|
-
* `anyOf` + `$defs/Any` shape the descriptor declared unrepresentable.
|
|
299
|
-
* Nothing checked, so nothing caught it.
|
|
300
|
-
*
|
|
301
|
-
* Called by `registerDescriptor` (which warns) and asserted over every
|
|
302
|
-
* built-in by the test suite.
|
|
303
|
-
*
|
|
304
|
-
* @example
|
|
305
|
-
* ```ts
|
|
306
|
-
* checkDescriptorConsistency(GOOGLE_STRICT); // → []
|
|
307
|
-
* checkDescriptorConsistency({ ...GOOGLE_STRICT, anyEncoding: 'flat' });
|
|
308
|
-
* // → ["anyEncoding 'flat' emits `anyOf`, but allowAnyOf is false"]
|
|
309
|
-
* ```
|
|
310
|
-
*/
|
|
311
|
-
export function checkDescriptorConsistency(descriptor) {
|
|
312
|
-
const problems = [];
|
|
313
|
-
const needs = ANY_ENCODING_REQUIREMENTS[descriptor.anyEncoding];
|
|
314
|
-
if (needs === undefined) {
|
|
315
|
-
problems.push(`anyEncoding '${descriptor.anyEncoding}' is not a known encoding`);
|
|
316
|
-
return problems;
|
|
317
|
-
}
|
|
318
|
-
if (needs.anyOf && !descriptor.allowAnyOf) {
|
|
319
|
-
problems.push(`anyEncoding '${descriptor.anyEncoding}' emits \`anyOf\`, but allowAnyOf is false`);
|
|
320
|
-
}
|
|
321
|
-
if (needs.defsRef && !descriptor.allowDefsRef) {
|
|
322
|
-
problems.push(`anyEncoding '${descriptor.anyEncoding}' emits a self-referencing \`$defs/Any\`, but allowDefsRef is false`);
|
|
323
|
-
}
|
|
324
|
-
if (needs.defsRef && !descriptor.supportsRecursion) {
|
|
325
|
-
problems.push(`anyEncoding '${descriptor.anyEncoding}' emits a self-referencing \`$defs/Any\`, but supportsRecursion is false`);
|
|
326
|
-
}
|
|
327
|
-
// `maxEnumValues` is an independent scalar — there is no other field it can
|
|
328
|
-
// contradict — but a cap below 1 widens EVERY enum the dialect ever emits
|
|
329
|
-
// (including a two-member one), and a fractional cap reads as a threshold
|
|
330
|
-
// nobody intended. Both are silent: the schema stays valid, it just stops
|
|
331
|
-
// constraining anything, which is the "invisible until you read the wire"
|
|
332
|
-
// class this function exists to catch. Only reachable through
|
|
333
|
-
// `registerDescriptor`; the built-ins are asserted clean by the test suite.
|
|
334
|
-
const cap = descriptor.maxEnumValues;
|
|
335
|
-
if (cap !== undefined && (!Number.isInteger(cap) || cap < 1)) {
|
|
336
|
-
problems.push(`maxEnumValues must be a positive integer, got ${cap}`);
|
|
337
|
-
}
|
|
338
|
-
problems.push(...schemaSizeLimitProblems(descriptor));
|
|
339
|
-
return problems;
|
|
340
|
-
}
|
|
341
|
-
/**
|
|
342
|
-
* The ways a `schemaSizeLimits` group contradicts itself or the descriptor
|
|
343
|
-
* carrying it. Split out only to keep `checkDescriptorConsistency` readable;
|
|
344
|
-
* it is part of the same contract.
|
|
345
|
-
*
|
|
346
|
-
* The failures below are all SILENT: a non-positive bound degrades every
|
|
347
|
-
* schema to lenient (strict mode simply stops happening, with no error
|
|
348
|
-
* anywhere), a threshold above the total makes the large-enum rule
|
|
349
|
-
* unreachable, and limits on a non-strict descriptor describe a rule the API
|
|
350
|
-
* does not apply — each one is a rule that reads as enforced and is not.
|
|
351
|
-
*/
|
|
352
|
-
function schemaSizeLimitProblems(descriptor) {
|
|
353
|
-
const limits = descriptor.schemaSizeLimits;
|
|
354
|
-
if (limits === undefined)
|
|
355
|
-
return [];
|
|
356
|
-
const problems = [];
|
|
357
|
-
if (!descriptor.strict) {
|
|
358
|
-
problems.push('schemaSizeLimits is declared on a non-strict descriptor, but the size rules bound Structured Outputs only');
|
|
359
|
-
}
|
|
360
|
-
// Keyed off the interface, so a bound added to `SchemaSizeLimits` is checked
|
|
361
|
-
// here without an edit — `satisfies` makes a missed key a compile error.
|
|
362
|
-
const bounds = {
|
|
363
|
-
maxObjectProperties: limits.maxObjectProperties,
|
|
364
|
-
maxNestingDepth: limits.maxNestingDepth,
|
|
365
|
-
maxTotalStringChars: limits.maxTotalStringChars,
|
|
366
|
-
maxTotalEnumValues: limits.maxTotalEnumValues,
|
|
367
|
-
largeEnumValueCount: limits.largeEnumValueCount,
|
|
368
|
-
maxLargeEnumStringChars: limits.maxLargeEnumStringChars,
|
|
369
|
-
};
|
|
370
|
-
for (const [name, value] of Object.entries(bounds)) {
|
|
371
|
-
if (!Number.isInteger(value) || value < 1) {
|
|
372
|
-
problems.push(`schemaSizeLimits.${name} must be a positive integer, got ${value}`);
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
if (limits.largeEnumValueCount >= limits.maxTotalEnumValues) {
|
|
376
|
-
problems.push(`schemaSizeLimits.largeEnumValueCount (${limits.largeEnumValueCount}) is not below maxTotalEnumValues ` +
|
|
377
|
-
`(${limits.maxTotalEnumValues}), so the large-enum character rule can never apply`);
|
|
378
|
-
}
|
|
379
|
-
return problems;
|
|
380
|
-
}
|
|
381
|
-
// ============================================================================
|
|
382
|
-
// Descriptor registry — mutable lookup tables for built-in + user-registered
|
|
383
|
-
// descriptors. `registerDescriptor` adds to both maps; `getDescriptor` /
|
|
384
|
-
// `getDescriptorById` consult them on every lookup so newly-registered
|
|
385
|
-
// descriptors are immediately addressable by family or id.
|
|
386
|
-
// ============================================================================
|
|
387
|
-
const DESCRIPTORS_BY_ID = new Map();
|
|
388
|
-
// Keyed by family name. Each family slot holds its strict and lenient variants.
|
|
389
|
-
// `'lenient'` always points to `LENIENT` for both keys.
|
|
390
|
-
const DESCRIPTORS_BY_FAMILY = new Map();
|
|
391
|
-
function indexDescriptor(d) {
|
|
392
|
-
DESCRIPTORS_BY_ID.set(d.id, d);
|
|
393
|
-
const slot = DESCRIPTORS_BY_FAMILY.get(d.family) ?? {};
|
|
394
|
-
if (d.strict)
|
|
395
|
-
slot.strict = d;
|
|
396
|
-
else
|
|
397
|
-
slot.lenient = d;
|
|
398
|
-
DESCRIPTORS_BY_FAMILY.set(d.family, slot);
|
|
399
|
-
}
|
|
400
|
-
// Seed the registry with built-ins.
|
|
401
|
-
for (const d of [
|
|
402
|
-
LENIENT,
|
|
403
|
-
OPENAI_STRICT, OPENAI_NON_STRICT,
|
|
404
|
-
ANTHROPIC_STRICT, ANTHROPIC_NON_STRICT,
|
|
405
|
-
GOOGLE_STRICT, GOOGLE_NON_STRICT,
|
|
406
|
-
]) {
|
|
407
|
-
indexDescriptor(d);
|
|
408
|
-
}
|
|
409
|
-
/**
|
|
410
|
-
* Register a custom `FormatDescriptor` so it can be looked up by `id` or
|
|
411
|
-
* by `(family, strict)`. Useful for adding support for a new provider
|
|
412
|
-
* dialect or for registering a tweaked variant of an existing family.
|
|
413
|
-
*
|
|
414
|
-
* The descriptor's `id` and `family` become the lookup keys. If a
|
|
415
|
-
* descriptor with the same id is already registered, the new one
|
|
416
|
-
* replaces it. The family slot tracks one strict and one lenient
|
|
417
|
-
* variant; registering a third variant overwrites the matching slot.
|
|
418
|
-
*
|
|
419
|
-
* Built-in descriptors (`OPENAI_STRICT`, `LENIENT`, etc.) are seeded at
|
|
420
|
-
* module load — you don't need to register them manually. They CAN be
|
|
421
|
-
* overridden by registering a same-id descriptor afterwards, but doing
|
|
422
|
-
* so is generally a sign that you should pick a different id.
|
|
423
|
-
*
|
|
424
|
-
* @example
|
|
425
|
-
* ```ts
|
|
426
|
-
* import { registerDescriptor, OPENAI_STRICT } from '@aeye/core';
|
|
427
|
-
*
|
|
428
|
-
* // A tighter OpenAI-flavored descriptor: same wire shape but with
|
|
429
|
-
* // pattern support disabled (some downstream tools choke on regex).
|
|
430
|
-
* registerDescriptor({
|
|
431
|
-
* ...OPENAI_STRICT,
|
|
432
|
-
* id: 'openai-no-regex',
|
|
433
|
-
* family: 'openai-no-regex',
|
|
434
|
-
* allowPattern: false,
|
|
435
|
-
* allowMultiplePatterns: false,
|
|
436
|
-
* });
|
|
437
|
-
*
|
|
438
|
-
* // Now resolvable:
|
|
439
|
-
* getDescriptor('openai-no-regex', true); // → the registered descriptor
|
|
440
|
-
* getDescriptorById('openai-no-regex'); // → same
|
|
441
|
-
* ```
|
|
442
|
-
*
|
|
443
|
-
* A descriptor that contradicts itself (see `checkDescriptorConsistency`) is
|
|
444
|
-
* still registered — the caller may know something we don't — but each
|
|
445
|
-
* problem is warned once at registration, because the alternative is finding
|
|
446
|
-
* out from a provider's HTTP 400.
|
|
447
|
-
*/
|
|
448
|
-
export function registerDescriptor(descriptor) {
|
|
449
|
-
for (const problem of checkDescriptorConsistency(descriptor)) {
|
|
450
|
-
console.warn(`FormatDescriptor '${descriptor.id}' is inconsistent: ${problem}`);
|
|
451
|
-
}
|
|
452
|
-
indexDescriptor(descriptor);
|
|
453
|
-
}
|
|
454
|
-
/**
|
|
455
|
-
* Resolve a descriptor by family + strictness.
|
|
456
|
-
*
|
|
457
|
-
* Built-in families (`'openai'`, `'anthropic'`, `'google'`) always
|
|
458
|
-
* resolve. Custom families registered via `registerDescriptor` resolve
|
|
459
|
-
* once they're registered. Unknown families fall back to `LENIENT`
|
|
460
|
-
* (safe default — strict mode silently degrades for unrecognized models).
|
|
461
|
-
*
|
|
462
|
-
* @param family - provider family (built-in or registered)
|
|
463
|
-
* @param strict - whether the strict variant is wanted
|
|
464
|
-
*/
|
|
465
|
-
export function getDescriptor(family, strict) {
|
|
466
|
-
if (!strict) {
|
|
467
|
-
const slot = DESCRIPTORS_BY_FAMILY.get(family);
|
|
468
|
-
return slot?.lenient ?? LENIENT;
|
|
469
|
-
}
|
|
470
|
-
const slot = DESCRIPTORS_BY_FAMILY.get(family);
|
|
471
|
-
return slot?.strict ?? LENIENT;
|
|
472
|
-
}
|
|
473
|
-
/**
|
|
474
|
-
* Look up a descriptor by id. Returns LENIENT for unknown ids (safe default).
|
|
475
|
-
* Custom descriptors registered via `registerDescriptor` are reachable here
|
|
476
|
-
* by their `id`.
|
|
477
|
-
*/
|
|
478
|
-
export function getDescriptorById(id) {
|
|
479
|
-
if (!id)
|
|
480
|
-
return LENIENT;
|
|
481
|
-
return DESCRIPTORS_BY_ID.get(id) ?? LENIENT;
|
|
482
|
-
}
|
|
483
|
-
/**
|
|
484
|
-
* True if `family` has at least one registered descriptor (strict or
|
|
485
|
-
* lenient). Used by `@aeye/ai`'s `resolveStrictFormat` fallback chain to
|
|
486
|
-
* decide whether a model id prefix or provider name is a recognized
|
|
487
|
-
* dialect.
|
|
488
|
-
*/
|
|
489
|
-
export function hasDescriptorFamily(family) {
|
|
490
|
-
if (family === undefined)
|
|
491
|
-
return false;
|
|
492
|
-
return DESCRIPTORS_BY_FAMILY.has(family);
|
|
493
|
-
}
|
|
494
|
-
/**
|
|
495
|
-
* Resolve descriptor from various input shapes (boolean, options object, or descriptor).
|
|
496
|
-
* Used by `toJSONSchema` to support its overloaded signature.
|
|
497
|
-
*/
|
|
498
|
-
export function resolveDescriptor(input) {
|
|
499
|
-
if (input === undefined)
|
|
500
|
-
return OPENAI_STRICT;
|
|
501
|
-
if (typeof input === 'boolean')
|
|
502
|
-
return input ? OPENAI_STRICT : LENIENT;
|
|
503
|
-
if ('id' in input && 'family' in input)
|
|
504
|
-
return input;
|
|
505
|
-
const opts = input;
|
|
506
|
-
return getDescriptor(opts.format ?? 'openai', opts.strict);
|
|
507
|
-
}
|
|
508
|
-
/**
|
|
509
|
-
* The key an EAGER walk's cycle guard must use for `s`.
|
|
510
|
-
*
|
|
511
|
-
* Object identity alone is a broken guard, because a `z.lazy` is only required
|
|
512
|
-
* to return *a* schema — not the *same* schema object — from its getter. A
|
|
513
|
-
* codegen layer that derives zod from a live type registry rebuilds the subtree
|
|
514
|
-
* on every call (`@aeye/gin`'s `buildSchemas` does), so each re-entry into a
|
|
515
|
-
* recursive node is a FRESH object: an identity guard never fires, the walk
|
|
516
|
-
* descends forever, and because every level allocates a whole new subtree
|
|
517
|
-
* (measured ~9.4 KB/level) the process dies of heap exhaustion —
|
|
518
|
-
* `FATAL ERROR: Ineffective mark-compacts near heap limit` — rather than
|
|
519
|
-
* overflowing the stack. `convert()` already survives this by matching a cached
|
|
520
|
-
* definition on the `aid` such a layer stamps on the node (see its `ZodLazy`
|
|
521
|
-
* branch), so the eager walkers key on the SAME identity: a declared
|
|
522
|
-
* `aid`/`id` names the definition, and only an unnamed node falls back to
|
|
523
|
-
* object identity.
|
|
524
|
-
*
|
|
525
|
-
* The two IDs are read in `convert()`'s own precedence (`aid` then `id`), and
|
|
526
|
-
* carry the same meaning here that they do there: two nodes sharing one
|
|
527
|
-
* declared id ARE one definition.
|
|
528
|
-
*/
|
|
529
|
-
function walkKey(s) {
|
|
530
|
-
if (!(s instanceof z.ZodType))
|
|
531
|
-
return s;
|
|
532
|
-
const meta = s.meta();
|
|
533
|
-
const declared = meta?.aid ?? meta?.id;
|
|
534
|
-
// Prefixed so a declared id can never be mistaken for anything else in the
|
|
535
|
-
// guard's key space (the alternative member is an object, so this is belt +
|
|
536
|
-
// braces against a future string-keyed member).
|
|
537
|
-
return typeof declared === 'string' ? `id:${declared}` : s;
|
|
538
|
-
}
|
|
539
|
-
/**
|
|
540
|
-
* Decide whether `schema` can be expressed as structured output under
|
|
541
|
-
* `descriptor`'s JSON-Schema dialect. Returns `false` when the schema uses a
|
|
542
|
-
* construct the descriptor forbids, so the caller can DROP the wire schema and
|
|
543
|
-
* deliver it as prompt text instead (see the ai-layer
|
|
544
|
-
* `applySchemaDeliveryFallback`).
|
|
545
|
-
*
|
|
546
|
-
* Walks the Zod schema in the same style as `strictify`/`convert`, honoring:
|
|
547
|
-
* - **unions** (`z.union` / `z.discriminatedUnion` → `anyOf`) when
|
|
548
|
-
* `!descriptor.allowAnyOf` (e.g. Gemini strict rejects `anyOf`);
|
|
549
|
-
* - **intersections** (`z.intersection` → `allOf`) when `!descriptor.allowAllOf`;
|
|
550
|
-
* - **recursion** (a self-referential `z.lazy` cycle → `$ref`): a cycle back to
|
|
551
|
-
* the ROOT is fine when `descriptor.allowRootRef && descriptor.supportsRecursion`
|
|
552
|
-
* (Gemini supports `$ref: '#'`); a non-root cycle needs
|
|
553
|
-
* `descriptor.allowDefsRef && descriptor.supportsRecursion`.
|
|
554
|
-
*
|
|
555
|
-
* `ZodNullable` / `ZodOptional` are transparent wrappers here (nullability is
|
|
556
|
-
* NOT treated as a union), so only genuine `z.union(...)` trips `allowAnyOf`.
|
|
557
|
-
* Non-combinator, non-recursive schemas (plain objects, arrays, primitives)
|
|
558
|
-
* are always expressible. The structure mirrors the descriptor flags so more
|
|
559
|
-
* feasibility checks are easy to add.
|
|
560
|
-
*/
|
|
561
|
-
export function canExpress(schema, descriptor) {
|
|
562
|
-
return expressible(schema, descriptor, /* cycleBreakerCounts */ false);
|
|
563
|
-
}
|
|
564
|
-
/**
|
|
565
|
-
* `canExpress`, except that a cycle counts as EXPRESSIBLE whatever the
|
|
566
|
-
* descriptor's ref flags say, because `emitCachedRef` replaces any back-edge
|
|
567
|
-
* the descriptor cannot spell with the bounded `buildCycleBreakerSchema`
|
|
568
|
-
* placeholder. The emitted schema is therefore always *sendable*; it is only
|
|
569
|
-
* *looser* than the source at the back-edge.
|
|
570
|
-
*
|
|
571
|
-
* That distinction is the whole reason there are two predicates:
|
|
572
|
-
* - `canExpress` answers "will the model be properly CONSTRAINED by this?",
|
|
573
|
-
* which is what a structured-OUTPUT delivery decision needs — a back-edge
|
|
574
|
-
* silently widened to "any" is a good reason to deliver the schema as prompt
|
|
575
|
-
* text instead (`applySchemaDeliveryFallback`), and
|
|
576
|
-
* `ANTHROPIC_STRICT`'s `supportsRecursion: false` is exactly that case.
|
|
577
|
-
* - this one answers "will the provider REJECT this?", which is what a TOOL's
|
|
578
|
-
* strict allocation needs. A tool has no prompt-text fallback, so degrading a
|
|
579
|
-
* merely-loose schema to LENIENT would trade a real strict guarantee for
|
|
580
|
-
* nothing.
|
|
581
|
-
*
|
|
582
|
-
* The combinator half is shared and is the half that actually bites: nothing
|
|
583
|
-
* rewrites a forbidden `anyOf`/`allOf` away, so a schema carrying one is a
|
|
584
|
-
* provider 400 under a descriptor that forbids it.
|
|
585
|
-
*/
|
|
586
|
-
function sendableUnder(schema, descriptor) {
|
|
587
|
-
return expressible(schema, descriptor, /* cycleBreakerCounts */ true);
|
|
588
|
-
}
|
|
589
|
-
function expressible(schema, descriptor, cycleBreakerCounts) {
|
|
590
|
-
// Keys, not objects — a rebuilt `z.lazy` getter defeats identity (see `walkKey`).
|
|
591
|
-
const rootKey = walkKey(schema);
|
|
592
|
-
// Nodes currently on the DFS stack — a re-encounter is a recursion cycle.
|
|
593
|
-
const inProgress = new Set();
|
|
594
|
-
// Completed nodes with their result, so shared (non-cyclic) sub-schemas
|
|
595
|
-
// aren't re-walked (guards against exponential blow-up on DAG-shaped schemas).
|
|
596
|
-
const completed = new Map();
|
|
597
|
-
const walk = (s) => {
|
|
598
|
-
const key = walkKey(s);
|
|
599
|
-
if (inProgress.has(key)) {
|
|
600
|
-
// Recursion cycle detected at `s`.
|
|
601
|
-
if (cycleBreakerCounts)
|
|
602
|
-
return true;
|
|
603
|
-
if (key === rootKey)
|
|
604
|
-
return descriptor.allowRootRef && descriptor.supportsRecursion;
|
|
605
|
-
return descriptor.allowDefsRef && descriptor.supportsRecursion;
|
|
606
|
-
}
|
|
607
|
-
const cached = completed.get(key);
|
|
608
|
-
if (cached !== undefined)
|
|
609
|
-
return cached;
|
|
610
|
-
inProgress.add(key);
|
|
611
|
-
const result = walkNode(s);
|
|
612
|
-
inProgress.delete(key);
|
|
613
|
-
completed.set(key, result);
|
|
614
|
-
return result;
|
|
615
|
-
};
|
|
616
|
-
const walkNode = (s) => {
|
|
617
|
-
// ---- Transparent wrappers: recurse into the inner type ----
|
|
618
|
-
if (s instanceof z.ZodOptional || s instanceof z.ZodNullable)
|
|
619
|
-
return walk(s.unwrap());
|
|
620
|
-
if (s instanceof z.ZodDefault)
|
|
621
|
-
return walk(s._zod.def.innerType);
|
|
622
|
-
if (s instanceof z.ZodPrefault)
|
|
623
|
-
return walk(s._zod.def.innerType);
|
|
624
|
-
if (s instanceof z.ZodCatch)
|
|
625
|
-
return walk(s._zod.def.innerType);
|
|
626
|
-
if (s instanceof z.ZodReadonly)
|
|
627
|
-
return walk(s._zod.def.innerType);
|
|
628
|
-
if (s instanceof z.ZodNonOptional)
|
|
629
|
-
return walk(s._zod.def.innerType);
|
|
630
|
-
if (s instanceof z.ZodLazy)
|
|
631
|
-
return walk(s._zod.def.getter());
|
|
632
|
-
// ZodCodec must be checked before ZodPipe (it's a ZodPipe subclass in v4).
|
|
633
|
-
if (s instanceof z.ZodCodec)
|
|
634
|
-
return walk(s._zod.def.in) && walk(s._zod.def.out);
|
|
635
|
-
if (s instanceof z.ZodPipe)
|
|
636
|
-
return walk(s._zod.def.in) && walk(s._zod.def.out);
|
|
637
|
-
// ---- Combinators (the ones that actually bite) ----
|
|
638
|
-
// ZodDiscriminatedUnion is a ZodUnion subclass in v4, so this covers both.
|
|
639
|
-
if (s instanceof z.ZodUnion) {
|
|
640
|
-
if (!descriptor.allowAnyOf)
|
|
641
|
-
return false;
|
|
642
|
-
return s.options.every(walk);
|
|
643
|
-
}
|
|
644
|
-
if (s instanceof z.ZodIntersection) {
|
|
645
|
-
if (!descriptor.allowAllOf)
|
|
646
|
-
return false;
|
|
647
|
-
return walk(s._zod.def.left) && walk(s._zod.def.right);
|
|
648
|
-
}
|
|
649
|
-
// ---- Containers: recurse so nested combinators/cycles are reached ----
|
|
650
|
-
if (s instanceof z.ZodObject) {
|
|
651
|
-
for (const key in s.shape) {
|
|
652
|
-
if (!walk(s.shape[key]))
|
|
653
|
-
return false;
|
|
654
|
-
}
|
|
655
|
-
return true;
|
|
656
|
-
}
|
|
657
|
-
if (s instanceof z.ZodArray)
|
|
658
|
-
return walk(s._zod.def.element);
|
|
659
|
-
if (s instanceof z.ZodTuple) {
|
|
660
|
-
for (const item of s._zod.def.items) {
|
|
661
|
-
if (!walk(item))
|
|
662
|
-
return false;
|
|
663
|
-
}
|
|
664
|
-
return s._zod.def.rest ? walk(s._zod.def.rest) : true;
|
|
665
|
-
}
|
|
666
|
-
if (s instanceof z.ZodRecord)
|
|
667
|
-
return walk(s._zod.def.valueType);
|
|
668
|
-
if (s instanceof z.ZodMap)
|
|
669
|
-
return walk(s._zod.def.keyType) && walk(s._zod.def.valueType);
|
|
670
|
-
if (s instanceof z.ZodSet)
|
|
671
|
-
return walk(s._zod.def.valueType);
|
|
672
|
-
// ---- Leaves / anything without forbidden substructure ----
|
|
673
|
-
return true;
|
|
674
|
-
};
|
|
675
|
-
return walk(schema);
|
|
676
|
-
}
|
|
677
|
-
/**
|
|
678
|
-
* Module-scope cache. Per-source-schema entry survives only as long as the
|
|
679
|
-
* source schema does — the WeakMap entry is gc'd with it, so all per-format
|
|
680
|
-
* strictified clones go with it. The inner Map is keyed by descriptor.id
|
|
681
|
-
* (small bounded set) so it cannot grow unboundedly.
|
|
682
|
-
*/
|
|
683
|
-
const strictifyCache = new WeakMap();
|
|
684
|
-
/**
|
|
685
|
-
* Recursively transforms a Zod schema to a target dialect.
|
|
686
|
-
*
|
|
687
|
-
* For LENIENT, returns the input schema unchanged (no rewrites). For strict
|
|
688
|
-
* descriptors, installs preprocesses that accept the dialect's wire shape
|
|
689
|
-
* (e.g. array-of-pairs records, numeric-key tuple objects) and normalize them
|
|
690
|
-
* back to the natural Zod shape before validation.
|
|
691
|
-
*
|
|
692
|
-
* Repeated calls with the same `(schema, descriptor)` return the cached
|
|
693
|
-
* transformed schema. Different descriptors share the same outer entry but
|
|
694
|
-
* map to different inner values — bounded by the small descriptor count.
|
|
695
|
-
*
|
|
696
|
-
* @param schema - input Zod schema
|
|
697
|
-
* @param descriptor - target dialect descriptor (defaults to OPENAI_STRICT)
|
|
698
|
-
*/
|
|
699
|
-
export function strictify(schema, descriptor = OPENAI_STRICT) {
|
|
700
|
-
// LENIENT is a no-op: same reference, no cache entry needed.
|
|
701
|
-
if (descriptor.id === LENIENT.id)
|
|
702
|
-
return schema;
|
|
703
|
-
let perSchema = strictifyCache.get(schema);
|
|
704
|
-
if (!perSchema) {
|
|
705
|
-
perSchema = new Map();
|
|
706
|
-
strictifyCache.set(schema, perSchema);
|
|
707
|
-
}
|
|
708
|
-
const cached = perSchema.get(descriptor.id);
|
|
709
|
-
if (cached)
|
|
710
|
-
return cached;
|
|
711
|
-
const result = strictifyWithDescriptor(schema, descriptor);
|
|
712
|
-
perSchema.set(descriptor.id, result);
|
|
713
|
-
return result;
|
|
714
|
-
}
|
|
715
|
-
function strictifyWithDescriptor(schema, descriptor) {
|
|
716
|
-
// Per-call cycle map. Same scheme as before: cache lazy-thunks for in-flight
|
|
717
|
-
// schemas so recursive references emit a `z.lazy(() => result)` rather than
|
|
718
|
-
// recursing forever.
|
|
719
|
-
const map = new Map();
|
|
720
|
-
const transform = (s) => {
|
|
721
|
-
const cached = map.get(s);
|
|
722
|
-
if (cached) {
|
|
723
|
-
if (typeof cached === 'function') {
|
|
724
|
-
return z.lazy(cached);
|
|
725
|
-
}
|
|
726
|
-
else {
|
|
727
|
-
return cached;
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
let result;
|
|
731
|
-
map.set(s, () => result);
|
|
732
|
-
result = strictifySimple(s, transform, descriptor);
|
|
733
|
-
map.set(s, result);
|
|
734
|
-
return result;
|
|
735
|
-
};
|
|
736
|
-
return transform(schema);
|
|
737
|
-
}
|
|
738
|
-
/**
|
|
739
|
-
* Transfer description and metadata from source Zod schema to target Zod schema
|
|
740
|
-
*/
|
|
741
|
-
function transferMetadata(target, source) {
|
|
742
|
-
if (source.description) {
|
|
743
|
-
target = target.describe(source.description);
|
|
744
|
-
}
|
|
745
|
-
const meta = source.meta();
|
|
746
|
-
if (meta) {
|
|
747
|
-
target = target.meta(meta);
|
|
748
|
-
}
|
|
749
|
-
return target;
|
|
750
|
-
}
|
|
751
|
-
/**
|
|
752
|
-
* Per-node strictify dispatch. Each branch consults the descriptor to decide
|
|
753
|
-
* whether to install the dialect-specific preprocess.
|
|
754
|
-
*/
|
|
755
|
-
function strictifySimple(schema, transform, descriptor) {
|
|
756
|
-
// Handle ZodOptional
|
|
757
|
-
if (schema instanceof z.ZodOptional) {
|
|
758
|
-
const innerSchema = schema.unwrap();
|
|
759
|
-
const transformed = transform(innerSchema);
|
|
760
|
-
// Only install the null→undefined preprocess for dialects that rewrite
|
|
761
|
-
// optional to nullable on the wire (OpenAI strict). For other dialects,
|
|
762
|
-
// optional stays optional and the preprocess is unnecessary.
|
|
763
|
-
if (!descriptor.optionalAsNullable) {
|
|
764
|
-
return transferMetadata(transformed.optional(), schema);
|
|
765
|
-
}
|
|
766
|
-
const isNullable = innerSchema instanceof z.ZodNullable;
|
|
767
|
-
return transferMetadata(z.preprocess((val) => (!isNullable && val === null) ? undefined : val, transformed.optional()), schema);
|
|
768
|
-
}
|
|
769
|
-
// Handle ZodObject
|
|
770
|
-
if (schema instanceof z.ZodObject) {
|
|
771
|
-
const transformedShape = {};
|
|
772
|
-
for (const key in schema.shape) {
|
|
773
|
-
transformedShape[key] = transform(schema.shape[key]);
|
|
774
|
-
}
|
|
775
|
-
// Carry the catchall through. `z.object(shape)` alone DROPS it, so every
|
|
776
|
-
// `.catchall(...)` / `z.looseObject(...)` / `z.strictObject(...)` schema
|
|
777
|
-
// silently lost its open (or explicitly closed) tail the moment a strict
|
|
778
|
-
// dialect was selected — while `toJSONSchema` kept emitting that same
|
|
779
|
-
// catchall as `additionalProperties` and `analyzeSchema` kept walking it.
|
|
780
|
-
// strictify and the emitter were describing different schemas, and the
|
|
781
|
-
// mismatch surfaced only as keys stripped at validation time.
|
|
782
|
-
//
|
|
783
|
-
// Dialects that close objects (`objectClosedByDefault`: OpenAI/Anthropic
|
|
784
|
-
// strict) still emit `additionalProperties: false` regardless, so no wire
|
|
785
|
-
// shape changes for them — this restores the open tail exactly where the
|
|
786
|
-
// dialect permits one (Google).
|
|
787
|
-
const catchall = schema.def.catchall;
|
|
788
|
-
return transferMetadata(catchall
|
|
789
|
-
? z.object(transformedShape).catchall(transform(catchall))
|
|
790
|
-
: z.object(transformedShape), schema);
|
|
791
|
-
}
|
|
792
|
-
// Handle ZodCodec
|
|
793
|
-
if (schema instanceof z.ZodCodec) {
|
|
794
|
-
return transferMetadata(z.codec(transform(schema.def.in), transform(schema.def.out), {
|
|
795
|
-
decode: schema.def.transform,
|
|
796
|
-
encode: schema.def.reverseTransform,
|
|
797
|
-
}), schema);
|
|
798
|
-
}
|
|
799
|
-
// Handle ZodPipe
|
|
800
|
-
if (schema instanceof z.ZodPipe) {
|
|
801
|
-
return transferMetadata(z.pipe(transform(schema.def.in), transform(schema.def.out)), schema);
|
|
802
|
-
}
|
|
803
|
-
// Handle ZodNullable
|
|
804
|
-
if (schema instanceof z.ZodNullable) {
|
|
805
|
-
return transferMetadata(transform(schema.unwrap()).nullable(), schema);
|
|
806
|
-
}
|
|
807
|
-
// Handle ZodArray
|
|
808
|
-
if (schema instanceof z.ZodArray) {
|
|
809
|
-
return transferMetadata(z.array(transform(schema.element)), schema);
|
|
810
|
-
}
|
|
811
|
-
// Handle ZodRecord
|
|
812
|
-
// Strict-mode JSON Schema represents records as `array of {key, value}` —
|
|
813
|
-
// OpenAI's structured outputs has no native pattern for open records, so
|
|
814
|
-
// we rewrite. Previously this used `z.codec(arrayIn, recordOut, decode)`,
|
|
815
|
-
// but z.codec proved fragile inside recursive lazy unions: the inner
|
|
816
|
-
// codec's transform doesn't always run when validation traverses a
|
|
817
|
-
// sibling branch, leaving the data in array form when later code expects
|
|
818
|
-
// record form (and vice-versa) — surfacing as "expected array, received
|
|
819
|
-
// object" union failures.
|
|
820
|
-
//
|
|
821
|
-
// `z.preprocess` is more robust here: it normalizes the input to record
|
|
822
|
-
// shape BEFORE the record schema sees it. Either array-of-pairs (from a
|
|
823
|
-
// strict-mode model) or already-a-record (when the schema is reused
|
|
824
|
-
// outside strict context) is accepted; both arrive at the record schema
|
|
825
|
-
// as a record.
|
|
826
|
-
//
|
|
827
|
-
// Only installed when the descriptor wants the array-of-pairs encoding.
|
|
828
|
-
if (schema instanceof z.ZodRecord) {
|
|
829
|
-
const keyTransformed = schema.keyType ? transform(schema.keyType) : z.string();
|
|
830
|
-
const valueTransformed = transform(schema.valueType);
|
|
831
|
-
if (descriptor.recordEncoding !== 'array-of-pairs') {
|
|
832
|
-
return transferMetadata(z.record(keyTransformed, valueTransformed), schema);
|
|
833
|
-
}
|
|
834
|
-
return transferMetadata(z.preprocess((val) => {
|
|
835
|
-
if (Array.isArray(val)) {
|
|
836
|
-
const record = {};
|
|
837
|
-
for (const entry of val) {
|
|
838
|
-
if (entry && typeof entry === 'object' && 'key' in entry && 'value' in entry) {
|
|
839
|
-
record[entry.key] = entry.value;
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
return record;
|
|
843
|
-
}
|
|
844
|
-
return val;
|
|
845
|
-
}, z.record(keyTransformed, valueTransformed)), schema);
|
|
846
|
-
}
|
|
847
|
-
// Handle ZodUnion
|
|
848
|
-
if (schema instanceof z.ZodUnion) {
|
|
849
|
-
return transferMetadata(z.union(schema.options.map(transform)), schema);
|
|
850
|
-
}
|
|
851
|
-
// Handle ZodDiscriminatedUnion
|
|
852
|
-
if (schema instanceof z.ZodDiscriminatedUnion) {
|
|
853
|
-
return transferMetadata(z.discriminatedUnion(schema.def.discriminator, schema.options.map(transform)), schema);
|
|
854
|
-
}
|
|
855
|
-
// Handle ZodIntersection
|
|
856
|
-
if (schema instanceof z.ZodIntersection) {
|
|
857
|
-
return transferMetadata(z.intersection(transform(schema.def.left), transform(schema.def.right)), schema);
|
|
858
|
-
}
|
|
859
|
-
// Handle ZodTuple
|
|
860
|
-
// Strict-mode JSON Schema represents tuples as an object with numeric
|
|
861
|
-
// string keys (`{"0": <T0>, "1": <T1>, ...}`) — OpenAI's structured
|
|
862
|
-
// outputs has no positional `prefixItems` support and would otherwise
|
|
863
|
-
// collapse a `[string, number, bool]` to an array of `(string|number|bool)`,
|
|
864
|
-
// losing the per-position type. Encoding as an object preserves it.
|
|
865
|
-
// The strictified schema accepts EITHER form: an object with "0".."n-1"
|
|
866
|
-
// keys (what a strict-mode model produces) or an array (what a callsite
|
|
867
|
-
// outside strict context would pass). Both arrive at the tuple schema as
|
|
868
|
-
// an array.
|
|
869
|
-
//
|
|
870
|
-
// Only installed when the descriptor wants the numeric-keys encoding.
|
|
871
|
-
if (schema instanceof z.ZodTuple) {
|
|
872
|
-
const items = schema.def.items.map(transform);
|
|
873
|
-
const rest = schema.def.rest ? transform(schema.def.rest) : undefined;
|
|
874
|
-
const tupleSchema = rest ? z.tuple(items, rest) : z.tuple(items);
|
|
875
|
-
if (descriptor.tupleEncoding !== 'object-numeric-keys') {
|
|
876
|
-
return transferMetadata(tupleSchema, schema);
|
|
877
|
-
}
|
|
878
|
-
return transferMetadata(z.preprocess((val) => {
|
|
879
|
-
if (val && typeof val === 'object' && !Array.isArray(val)) {
|
|
880
|
-
const obj = val;
|
|
881
|
-
const keys = Object.keys(obj);
|
|
882
|
-
if (keys.length > 0 && keys.every((k) => /^\d+$/.test(k))) {
|
|
883
|
-
const indices = keys.map((k) => parseInt(k, 10));
|
|
884
|
-
const len = Math.max(...indices) + 1;
|
|
885
|
-
const arr = new Array(len);
|
|
886
|
-
for (const k of keys)
|
|
887
|
-
arr[parseInt(k, 10)] = obj[k];
|
|
888
|
-
return arr;
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
return val;
|
|
892
|
-
}, tupleSchema), schema);
|
|
893
|
-
}
|
|
894
|
-
// Handle ZodDefault
|
|
895
|
-
if (schema instanceof z.ZodDefault) {
|
|
896
|
-
return z.preprocess((val) => val === null ? undefined : val, transferMetadata(transform(schema.def.innerType).default(schema.def.defaultValue), schema));
|
|
897
|
-
}
|
|
898
|
-
// Handle ZodLazy
|
|
899
|
-
if (schema instanceof z.ZodLazy) {
|
|
900
|
-
return transferMetadata(z.lazy(() => transform(schema.def.getter())), schema);
|
|
901
|
-
}
|
|
902
|
-
// For all other types (primitives, etc.), return as-is
|
|
903
|
-
return schema;
|
|
904
|
-
}
|
|
905
|
-
// ============================================================================
|
|
906
|
-
// Wire → conceptual DECODE (symmetric with strictify's conceptual → wire ENCODE)
|
|
907
|
-
//
|
|
908
|
-
// `strictify` installs decode preprocesses (array-of-pairs → record,
|
|
909
|
-
// numeric-key object → tuple, null → undefined for optionals, …) that only
|
|
910
|
-
// run when Zod VALIDATES a value. A custom `parse` hook REPLACES Zod
|
|
911
|
-
// validation, so it never triggers those preprocesses — it would otherwise
|
|
912
|
-
// see the raw provider wire shape.
|
|
913
|
-
//
|
|
914
|
-
// `relaxValidation` turns a (strictified) schema into a NON-FAILING variant
|
|
915
|
-
// that KEEPS every transform but DROPS validation, so we can run it purely to
|
|
916
|
-
// execute the decode preprocesses and hand a custom parser the CONCEPTUAL
|
|
917
|
-
// value. `decodeWire` ties it together (strictify → relax → safeParse,
|
|
918
|
-
// best-effort).
|
|
919
|
-
// ============================================================================
|
|
920
|
-
/**
|
|
921
|
-
* Rebuild a `ZodObject` for the relaxed decoder.
|
|
922
|
-
*
|
|
923
|
-
* - Recurses each field through `relax` (so nested transforms still run).
|
|
924
|
-
* - `forceOptional`: when true every field becomes optional (max leniency —
|
|
925
|
-
* used everywhere EXCEPT union options). When false the field keeps its own
|
|
926
|
-
* optionality (used for union-option objects, where a required field is the
|
|
927
|
-
* discriminating signal that lets the union pick the right branch).
|
|
928
|
-
* - `loose`: when true (default) unknown keys pass straight through; when
|
|
929
|
-
* false the object STRIPS unknown keys. Intersection sides use `false` so
|
|
930
|
-
* each half only claims its own keys and the two halves merge without an
|
|
931
|
-
* "unmergable intersection" conflict (a loose side would echo the other
|
|
932
|
-
* half's raw key and collide with that half's transformed value).
|
|
933
|
-
*/
|
|
934
|
-
function relaxObjectShape(schema, relax, forceOptional, loose = true) {
|
|
935
|
-
const shape = {};
|
|
936
|
-
for (const key in schema.shape) {
|
|
937
|
-
const relaxed = relax(schema.shape[key]);
|
|
938
|
-
shape[key] = forceOptional ? relaxed.optional() : relaxed;
|
|
939
|
-
}
|
|
940
|
-
const obj = z.object(shape);
|
|
941
|
-
return loose ? obj.loose() : obj;
|
|
942
|
-
}
|
|
943
|
-
/**
|
|
944
|
-
* Relax one side of an intersection. Object sides are rebuilt STRIP (not
|
|
945
|
-
* loose) so the two halves merge cleanly (see `relaxObjectShape`'s `loose`
|
|
946
|
-
* note); everything else relaxes normally.
|
|
947
|
-
*/
|
|
948
|
-
function relaxIntersectionSide(schema, relax) {
|
|
949
|
-
if (schema instanceof z.ZodObject) {
|
|
950
|
-
return relaxObjectShape(schema, relax, /* forceOptional */ true, /* loose */ false);
|
|
951
|
-
}
|
|
952
|
-
return relax(schema);
|
|
953
|
-
}
|
|
954
|
-
/**
|
|
955
|
-
* Relax the options of a union (plain OR discriminated — the latter is a
|
|
956
|
-
* subclass of `ZodUnion` in Zod v4, so a single path covers both).
|
|
957
|
-
*
|
|
958
|
-
* Object options are rebuilt with their ORIGINAL requiredness preserved
|
|
959
|
-
* (`forceOptional: false`) so the union can discriminate by required-field
|
|
960
|
-
* presence and run the matching option's decode transforms. Non-object
|
|
961
|
-
* options are kept AS-IS: relaxing a leaf to `z.any()` would make it swallow
|
|
962
|
-
* every value (the first branch would always win), so the strictified option
|
|
963
|
-
* is left intact — it still discriminates by type and still carries any wire
|
|
964
|
-
* transform it needs. Validation on those non-object branches is therefore
|
|
965
|
-
* only best-effort, which is acceptable: `decodeWire` falls back to the
|
|
966
|
-
* original value if nothing matches.
|
|
967
|
-
*/
|
|
968
|
-
function relaxUnionOptions(options, relax) {
|
|
969
|
-
const relaxed = options.map((opt) => opt instanceof z.ZodObject ? relaxObjectShape(opt, relax, false) : opt);
|
|
970
|
-
return z.union(relaxed);
|
|
971
|
-
}
|
|
972
|
-
/**
|
|
973
|
-
* Per-node dispatch for `relaxValidation`. Mirrors the node coverage of
|
|
974
|
-
* `strictifySimple` plus the extra wrappers/containers a strictified schema
|
|
975
|
-
* can contain (`ZodMap`, `ZodSet`, `ZodCatch`, `ZodReadonly`, `ZodPrefault`,
|
|
976
|
-
* `ZodNonOptional`, …). Unknown/leaf nodes bottom out at `z.any()`.
|
|
977
|
-
*/
|
|
978
|
-
function relaxNode(schema, relax) {
|
|
979
|
-
// ---- Wrappers: unwrap, relax inner, re-wrap ----
|
|
980
|
-
if (schema instanceof z.ZodOptional)
|
|
981
|
-
return relax(schema.unwrap()).optional();
|
|
982
|
-
if (schema instanceof z.ZodNullable)
|
|
983
|
-
return relax(schema.unwrap()).nullable();
|
|
984
|
-
if (schema instanceof z.ZodDefault) {
|
|
985
|
-
return relax(schema._zod.def.innerType).default(schema._zod.def.defaultValue);
|
|
986
|
-
}
|
|
987
|
-
if (schema instanceof z.ZodPrefault) {
|
|
988
|
-
return relax(schema._zod.def.innerType).prefault(schema._zod.def.defaultValue);
|
|
989
|
-
}
|
|
990
|
-
if (schema instanceof z.ZodCatch) {
|
|
991
|
-
return relax(schema._zod.def.innerType).catch(schema._zod.def.catchValue);
|
|
992
|
-
}
|
|
993
|
-
if (schema instanceof z.ZodReadonly)
|
|
994
|
-
return relax(schema._zod.def.innerType).readonly();
|
|
995
|
-
// NonOptional adds a "must be present" validation — drop it (we're relaxing).
|
|
996
|
-
if (schema instanceof z.ZodNonOptional)
|
|
997
|
-
return relax(schema._zod.def.innerType);
|
|
998
|
-
// ---- Transforms: KEEP the transform so the decode still runs ----
|
|
999
|
-
// ZodCodec must be checked before ZodPipe (it's a ZodPipe subclass in v4).
|
|
1000
|
-
if (schema instanceof z.ZodCodec) {
|
|
1001
|
-
return z.codec(relax(schema._zod.def.in), relax(schema._zod.def.out), { decode: schema._zod.def.transform, encode: schema._zod.def.reverseTransform });
|
|
1002
|
-
}
|
|
1003
|
-
// A bare transform (the `in` of a `preprocess`, the `out` of a `.transform`)
|
|
1004
|
-
// carries no validation — keep it verbatim so the transform runs.
|
|
1005
|
-
if (schema instanceof z.ZodTransform)
|
|
1006
|
-
return schema;
|
|
1007
|
-
if (schema instanceof z.ZodPipe) {
|
|
1008
|
-
return z.pipe(relax(schema._zod.def.in), relax(schema._zod.def.out));
|
|
1009
|
-
}
|
|
1010
|
-
// ---- Containers: recurse so nested transforms are reached ----
|
|
1011
|
-
if (schema instanceof z.ZodObject)
|
|
1012
|
-
return relaxObjectShape(schema, relax, /* forceOptional */ true);
|
|
1013
|
-
if (schema instanceof z.ZodArray)
|
|
1014
|
-
return z.array(relax(schema._zod.def.element));
|
|
1015
|
-
if (schema instanceof z.ZodTuple) {
|
|
1016
|
-
const items = schema._zod.def.items.map(relax);
|
|
1017
|
-
const rest = schema._zod.def.rest ? relax(schema._zod.def.rest) : undefined;
|
|
1018
|
-
return rest ? z.tuple(items, rest) : z.tuple(items);
|
|
1019
|
-
}
|
|
1020
|
-
if (schema instanceof z.ZodRecord) {
|
|
1021
|
-
// Accept any string key (records arrive with string-ish keys on the wire);
|
|
1022
|
-
// an exhaustive/enum key schema would reject partial records.
|
|
1023
|
-
return z.record(z.string(), relax(schema._zod.def.valueType));
|
|
1024
|
-
}
|
|
1025
|
-
if (schema instanceof z.ZodMap) {
|
|
1026
|
-
return z.map(relax(schema._zod.def.keyType), relax(schema._zod.def.valueType));
|
|
1027
|
-
}
|
|
1028
|
-
if (schema instanceof z.ZodSet)
|
|
1029
|
-
return z.set(relax(schema._zod.def.valueType));
|
|
1030
|
-
// ---- Combinators ----
|
|
1031
|
-
// ZodDiscriminatedUnion is a ZodUnion subclass in v4, so this one branch
|
|
1032
|
-
// covers both. Discrimination survives via required-field presence.
|
|
1033
|
-
if (schema instanceof z.ZodUnion)
|
|
1034
|
-
return relaxUnionOptions(schema.options, relax);
|
|
1035
|
-
if (schema instanceof z.ZodIntersection) {
|
|
1036
|
-
return z.intersection(relaxIntersectionSide(schema._zod.def.left, relax), relaxIntersectionSide(schema._zod.def.right, relax));
|
|
1037
|
-
}
|
|
1038
|
-
// ---- Recursion: defer + memoize (see `relaxValidation`) ----
|
|
1039
|
-
if (schema instanceof z.ZodLazy) {
|
|
1040
|
-
return z.lazy(() => relax(schema._zod.def.getter()));
|
|
1041
|
-
}
|
|
1042
|
-
// ---- Validating leaves + anything unrecognized → accept everything ----
|
|
1043
|
-
return z.any();
|
|
1044
|
-
}
|
|
1045
|
-
/**
|
|
1046
|
-
* Produce a NON-FAILING variant of `schema` that keeps every transform
|
|
1047
|
-
* (`preprocess`, `codec`, `pipe`, `transform`) but drops validation. Parsing
|
|
1048
|
-
* a wire value through the result runs the decode preprocesses installed by
|
|
1049
|
-
* `strictify` (array-of-pairs → record, numeric-key object → tuple, null →
|
|
1050
|
-
* undefined) and yields the conceptual value, without ever rejecting.
|
|
1051
|
-
*
|
|
1052
|
-
* Recursion (`z.lazy` self-reference) is handled with a per-call memo map: an
|
|
1053
|
-
* in-flight node is recorded as a thunk and re-encounters resolve to
|
|
1054
|
-
* `z.lazy(thunk)`, so a self-referential schema relaxes in finite time and
|
|
1055
|
-
* never infinite-loops.
|
|
1056
|
-
*/
|
|
1057
|
-
export function relaxValidation(schema) {
|
|
1058
|
-
// Per-call cycle map — same scheme as `strictifyWithDescriptor`: cache a
|
|
1059
|
-
// lazy thunk for in-flight schemas so recursive references resolve to a
|
|
1060
|
-
// `z.lazy(() => result)` instead of recursing forever.
|
|
1061
|
-
const map = new Map();
|
|
1062
|
-
const relax = (s) => {
|
|
1063
|
-
const cached = map.get(s);
|
|
1064
|
-
if (cached) {
|
|
1065
|
-
return typeof cached === 'function' ? z.lazy(cached) : cached;
|
|
1066
|
-
}
|
|
1067
|
-
let result;
|
|
1068
|
-
map.set(s, () => result);
|
|
1069
|
-
result = relaxNode(s, relax);
|
|
1070
|
-
map.set(s, result);
|
|
1071
|
-
return result;
|
|
1072
|
-
};
|
|
1073
|
-
return relax(schema);
|
|
1074
|
-
}
|
|
1075
|
-
/**
|
|
1076
|
-
* Module-scope cache for relaxed decoders. Mirrors `strictifyCache`: outer
|
|
1077
|
-
* WeakMap keyed by the SOURCE schema (gc'd with it), inner Map keyed by
|
|
1078
|
-
* descriptor id (small bounded set).
|
|
1079
|
-
*/
|
|
1080
|
-
const relaxDecodeCache = new WeakMap();
|
|
1081
|
-
/**
|
|
1082
|
-
* DECODE a model's wire `value` back to the conceptual shape a custom parser
|
|
1083
|
-
* expects — symmetric with how `strictify` ENCODEs the request for the wire.
|
|
1084
|
-
*
|
|
1085
|
-
* Runs the descriptor's decode transforms (via `relaxValidation(strictify(...))`)
|
|
1086
|
-
* without imposing validation, so array-of-pairs records, numeric-key tuples,
|
|
1087
|
-
* and null-for-optional fields are normalized before a provider-agnostic
|
|
1088
|
-
* custom parser sees them. Best-effort: NEVER throws — if the relaxed decoder
|
|
1089
|
-
* can't parse the value it is returned unchanged.
|
|
1090
|
-
*
|
|
1091
|
-
* @param schema - the PRE-strictify conceptual schema (strictify runs internally)
|
|
1092
|
-
* @param value - the model's raw wire value (already `JSON.parse`d)
|
|
1093
|
-
* @param descriptor - the wire dialect the request was encoded with
|
|
1094
|
-
*/
|
|
1095
|
-
export function decodeWire(schema, value, descriptor) {
|
|
1096
|
-
let perSchema = relaxDecodeCache.get(schema);
|
|
1097
|
-
if (!perSchema) {
|
|
1098
|
-
perSchema = new Map();
|
|
1099
|
-
relaxDecodeCache.set(schema, perSchema);
|
|
1100
|
-
}
|
|
1101
|
-
let decoder = perSchema.get(descriptor.id);
|
|
1102
|
-
if (!decoder) {
|
|
1103
|
-
decoder = relaxValidation(strictify(schema, descriptor));
|
|
1104
|
-
perSchema.set(descriptor.id, decoder);
|
|
1105
|
-
}
|
|
1106
|
-
const result = decoder.safeParse(value);
|
|
1107
|
-
return result.success ? result.data : value;
|
|
1108
|
-
}
|
|
1109
|
-
/**
|
|
1110
|
-
* Converts a Zod schema to JSON Schema with support for different provider dialects.
|
|
1111
|
-
*
|
|
1112
|
-
* Accepts either a boolean (legacy: true = OPENAI_STRICT, false = LENIENT),
|
|
1113
|
-
* an options object `{ strict, format? }`, or a `FormatDescriptor` directly.
|
|
1114
|
-
*
|
|
1115
|
-
* @param schema - The Zod schema to convert
|
|
1116
|
-
* @param options - Configuration options for the conversion
|
|
1117
|
-
* @returns JSON Schema object compatible with the resolved dialect
|
|
1118
|
-
*
|
|
1119
|
-
* @example
|
|
1120
|
-
* ```typescript
|
|
1121
|
-
* const schema = z.object({
|
|
1122
|
-
* name: z.string(),
|
|
1123
|
-
* age: z.number().optional(),
|
|
1124
|
-
* });
|
|
1125
|
-
*
|
|
1126
|
-
* // For OpenAI strict mode
|
|
1127
|
-
* const jsonSchema = toJSONSchema(schema, { strict: true, format: 'openai' });
|
|
1128
|
-
*
|
|
1129
|
-
* // For Anthropic strict mode
|
|
1130
|
-
* const anthropic = toJSONSchema(schema, ANTHROPIC_STRICT);
|
|
1131
|
-
* ```
|
|
1132
|
-
*/
|
|
1133
|
-
export function toJSONSchema(schema, options) {
|
|
1134
|
-
const descriptor = resolveDescriptor(options);
|
|
1135
|
-
const context = {
|
|
1136
|
-
root: schema,
|
|
1137
|
-
descriptor,
|
|
1138
|
-
definitions: new Map(),
|
|
1139
|
-
definitionSchemas: new Map(),
|
|
1140
|
-
refCounter: 0,
|
|
1141
|
-
path: [],
|
|
1142
|
-
inProgress: new Set(),
|
|
1143
|
-
};
|
|
1144
|
-
const result = convert(schema, context);
|
|
1145
|
-
// Add definitions if any were created
|
|
1146
|
-
if (context.definitionSchemas.size > 0) {
|
|
1147
|
-
result.$defs = Object.fromEntries(context.definitionSchemas);
|
|
1148
|
-
}
|
|
1149
|
-
return result;
|
|
1150
|
-
}
|
|
1151
|
-
/**
|
|
1152
|
-
* Main recursive conversion function
|
|
1153
|
-
*/
|
|
1154
|
-
function convert(schema, context) {
|
|
1155
|
-
// For lazy schemas, we need to check by aid first to handle .describe()/.optional() wrappers
|
|
1156
|
-
// that create new objects but share the same lazy getter
|
|
1157
|
-
let cacheKey = schema;
|
|
1158
|
-
let unwrappedSchema = schema;
|
|
1159
|
-
// `z.any()` / `z.unknown()` under an INLINE any-encoding (`flat`,
|
|
1160
|
-
// `unconstrained`) must never travel through the definition cache: a second
|
|
1161
|
-
// use site of the SAME schema instance — common when a codegen layer hands
|
|
1162
|
-
// out one shared "any" node — would otherwise be emitted as
|
|
1163
|
-
// `$ref: '#/$defs/__schemaN'`, resurrecting the `$defs` entry the inline
|
|
1164
|
-
// encoding exists to avoid. Bypassing the cache re-inlines the (tiny,
|
|
1165
|
-
// keyword-free) shape at each site instead.
|
|
1166
|
-
const bypassCache = !usesSharedAnyDefinition(context.descriptor)
|
|
1167
|
-
&& (schema instanceof z.ZodAny || schema instanceof z.ZodUnknown);
|
|
1168
|
-
// Check if this is a lazy schema and extract metadata early
|
|
1169
|
-
if (bypassCache) {
|
|
1170
|
-
// fall through to conversion with no cache read
|
|
1171
|
-
}
|
|
1172
|
-
else if (schema instanceof z.ZodLazy) {
|
|
1173
|
-
// Check cache FIRST before evaluating getter to prevent infinite recursion
|
|
1174
|
-
const [cachedJs, cachedId] = context.definitions.get(schema) || [];
|
|
1175
|
-
if (cachedJs && cachedId) {
|
|
1176
|
-
return emitCachedRef(schema, cachedJs, cachedId, context);
|
|
1177
|
-
}
|
|
1178
|
-
const metadata = (schema instanceof z.ZodType) ? schema.meta() : null;
|
|
1179
|
-
const aid = metadata?.aid;
|
|
1180
|
-
// If there's an aid, check if we've already seen this aid
|
|
1181
|
-
if (aid) {
|
|
1182
|
-
for (const [cachedSchema, [js, jsId]] of context.definitions.entries()) {
|
|
1183
|
-
if (cachedSchema instanceof z.ZodType) {
|
|
1184
|
-
const cachedMeta = cachedSchema.meta();
|
|
1185
|
-
if (cachedMeta?.aid === aid && jsId && js) {
|
|
1186
|
-
// Found a cached version with the same aid - use it
|
|
1187
|
-
return emitCachedRef(schema, js, jsId, context);
|
|
1188
|
-
}
|
|
1189
|
-
}
|
|
1190
|
-
}
|
|
1191
|
-
}
|
|
1192
|
-
unwrappedSchema = schema.def.getter();
|
|
1193
|
-
}
|
|
1194
|
-
else {
|
|
1195
|
-
// Check cache by object identity for non-lazy schemas
|
|
1196
|
-
const [js, jsId] = context.definitions.get(schema) || [];
|
|
1197
|
-
if (jsId && js) {
|
|
1198
|
-
return emitCachedRef(schema, js, jsId, context);
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
|
-
// Capture metadata from the original schema
|
|
1202
|
-
const metadata = {};
|
|
1203
|
-
if (schema instanceof z.ZodType) {
|
|
1204
|
-
Object.assign(metadata, schema.meta() || {});
|
|
1205
|
-
if (!metadata.description && schema.description) {
|
|
1206
|
-
metadata.description = schema.description;
|
|
1207
|
-
}
|
|
1208
|
-
}
|
|
1209
|
-
// If the schema has an 'aid' or 'id' in meta, promote it to a definition.
|
|
1210
|
-
// A cache-bypassed "any" node is never promoted — naming a schema that
|
|
1211
|
-
// asserts nothing buys nothing, and the `$defs` entry is precisely what the
|
|
1212
|
-
// inline encoding is avoiding. A descriptor that forbids `$defs` outright
|
|
1213
|
-
// (`allowDefsRef: false` — Google strict) is never promoted to either: the id
|
|
1214
|
-
// is still computed, because the cycle-breaker's description names it, but
|
|
1215
|
-
// the definition is inlined at each use site, which is the only spelling that
|
|
1216
|
-
// dialect has for a shared node.
|
|
1217
|
-
const id = (metadata.aid ? String(metadata.aid) : 0) || metadata.id || `__schema${context.refCounter++}`;
|
|
1218
|
-
const save = !bypassCache
|
|
1219
|
-
&& !!(metadata.aid || metadata.id)
|
|
1220
|
-
&& context.root !== schema
|
|
1221
|
-
&& context.descriptor.allowDefsRef;
|
|
1222
|
-
// A schema target - will hold either the converted schema or a $ref
|
|
1223
|
-
const target = {};
|
|
1224
|
-
// Before converting, register this schema to handle recursion
|
|
1225
|
-
if (!bypassCache) {
|
|
1226
|
-
context.definitions.set(cacheKey, [target, id]);
|
|
1227
|
-
}
|
|
1228
|
-
context.inProgress.add(id);
|
|
1229
|
-
// Convert the unwrapped schema
|
|
1230
|
-
let result;
|
|
1231
|
-
try {
|
|
1232
|
-
result = convertSchema(unwrappedSchema, context);
|
|
1233
|
-
}
|
|
1234
|
-
finally {
|
|
1235
|
-
context.inProgress.delete(id);
|
|
1236
|
-
}
|
|
1237
|
-
// A description the CONVERTER produced describes the EMITTED node, not the
|
|
1238
|
-
// source schema — today that is only the `maxEnumValues` widening, whose
|
|
1239
|
-
// description IS the value list that replaced the `enum` keyword. The source's
|
|
1240
|
-
// own `.describe()` must not silently replace it, or a model handed a widened
|
|
1241
|
-
// `{type:'string'}` is told nothing about which strings are legal. Keep both,
|
|
1242
|
-
// converter note last.
|
|
1243
|
-
const emittedNote = result.description;
|
|
1244
|
-
Object.assign(result, metadata);
|
|
1245
|
-
if (emittedNote !== undefined && metadata.description !== undefined && metadata.description !== emittedNote) {
|
|
1246
|
-
result.description = `${metadata.description} ${emittedNote}`;
|
|
1247
|
-
}
|
|
1248
|
-
delete result.aid;
|
|
1249
|
-
// Promote it because user requested it or it's recursive
|
|
1250
|
-
if (save || context.definitionSchemas.has(id)) {
|
|
1251
|
-
context.definitionSchemas.set(id, result);
|
|
1252
|
-
target.$ref = `#/$defs/${id}`;
|
|
1253
|
-
}
|
|
1254
|
-
else {
|
|
1255
|
-
// Inline schema
|
|
1256
|
-
Object.assign(target, result);
|
|
1257
|
-
}
|
|
1258
|
-
return target;
|
|
1259
|
-
}
|
|
1260
|
-
/**
|
|
1261
|
-
* Emit a `$ref` (or, for descriptors that don't support recursion, an inline
|
|
1262
|
-
* shape-aware placeholder) when the converter re-encounters a schema it's
|
|
1263
|
-
* already started or finished converting.
|
|
1264
|
-
*
|
|
1265
|
-
* Three cases:
|
|
1266
|
-
* 1. Re-encounter while still on the conversion stack (`inProgress.has(id)`)
|
|
1267
|
-
* AND the descriptor cannot express the `$ref` this back-edge would need —
|
|
1268
|
-
* emit a shape-aware placeholder via `buildCycleBreakerSchema()` (number
|
|
1269
|
-
* stays number, array stays array, etc.) with a description naming the
|
|
1270
|
-
* `$defs` entry it would have referenced. This breaks the cycle inline so
|
|
1271
|
-
* providers like Anthropic don't reject the schema.
|
|
1272
|
-
*
|
|
1273
|
-
* "Cannot express" is TWO flags, not one, because a back-edge is only ever
|
|
1274
|
-
* spelled one of two ways: `{$ref: '#'}` at the root, or `{$ref:
|
|
1275
|
-
* '#/$defs/X'}` anywhere else. `!supportsRecursion` rules out both.
|
|
1276
|
-
* `!allowDefsRef` rules out the second — so a NON-ROOT cycle under a
|
|
1277
|
-
* descriptor that forbids `$defs` (Google strict is exactly that:
|
|
1278
|
-
* `supportsRecursion: true`, `allowRootRef: true`, `allowDefsRef: false`)
|
|
1279
|
-
* must take the same bounded placeholder. Checking only `supportsRecursion`
|
|
1280
|
-
* silently emitted the `$defs`/`$ref` pair the descriptor declares it cannot
|
|
1281
|
-
* send — a schema the provider answers with HTTP 400.
|
|
1282
|
-
* 2. Re-encounter at root with `allowRootRef` — emit `{$ref: '#'}`.
|
|
1283
|
-
* 3. Otherwise — emit `{$ref: '#/$defs/<id>'}`, promoting the original
|
|
1284
|
-
* target to a `$defs` entry on first share.
|
|
1285
|
-
*/
|
|
1286
|
-
function emitCachedRef(schema, cachedJs, cachedId, context) {
|
|
1287
|
-
const descriptor = context.descriptor;
|
|
1288
|
-
const isCycle = context.inProgress.has(cachedId);
|
|
1289
|
-
const isRoot = schema === context.root;
|
|
1290
|
-
// The spelling this back-edge would need, and whether the descriptor has it.
|
|
1291
|
-
const backEdgeExpressible = isRoot ? descriptor.allowRootRef : descriptor.allowDefsRef;
|
|
1292
|
-
if (isCycle && (!descriptor.supportsRecursion || !backEdgeExpressible)) {
|
|
1293
|
-
// Replace the back-edge with a placeholder whose top-level shape matches
|
|
1294
|
-
// the recursive zod schema. The original `cachedJs` target keeps filling
|
|
1295
|
-
// in normally — once conversion completes the `$defs` entry exists; we
|
|
1296
|
-
// just don't reference it from here, since doing so would form the cycle
|
|
1297
|
-
// the provider rejects.
|
|
1298
|
-
return buildCycleBreakerSchema(schema, cachedId, descriptor);
|
|
1299
|
-
}
|
|
1300
|
-
if (isRoot && descriptor.allowRootRef) {
|
|
1301
|
-
return { $ref: `#` };
|
|
1302
|
-
}
|
|
1303
|
-
// A non-cyclic re-encounter under a descriptor with no `$defs`: INLINE the
|
|
1304
|
-
// already-converted shape instead of naming it. There is no third spelling in
|
|
1305
|
-
// that dialect — a shared node is either repeated or it is a `$ref` the
|
|
1306
|
-
// provider rejects. Safe and finite: `!isCycle` means this node's conversion
|
|
1307
|
-
// already completed, and any cycle BELOW it was replaced by the bounded
|
|
1308
|
-
// placeholder above, so the inlined value is a finite tree. The cost is
|
|
1309
|
-
// duplication in the emitted schema, which is what that dialect charges for
|
|
1310
|
-
// shared structure.
|
|
1311
|
-
//
|
|
1312
|
-
// On a schema the dialect can actually SEND that cost is negligible (measured
|
|
1313
|
-
// on the cases in `schema-google-defs.test.ts`: 497→435, 627→565, 280→322
|
|
1314
|
-
// bytes — inlining a leaf is often smaller than naming it). It is only large
|
|
1315
|
-
// on a heavily-shared recursive DAG — a gin `buildSchemas().Expr` renders
|
|
1316
|
-
// 44 KB with `$defs` and 1.36 MB without — and such a schema carries 1646
|
|
1317
|
-
// `anyOf`s, so `canExpress` already answers `false` for it and no provider
|
|
1318
|
-
// path reaches here. Paying size on a schema that must not be sent anyway is
|
|
1319
|
-
// the right side of the trade against emitting `$defs` the descriptor
|
|
1320
|
-
// declares it cannot send.
|
|
1321
|
-
if (!descriptor.allowDefsRef) {
|
|
1322
|
-
return { ...cachedJs };
|
|
1323
|
-
}
|
|
1324
|
-
if (!context.definitionSchemas.has(cachedId)) {
|
|
1325
|
-
context.definitionSchemas.set(cachedId, { ...cachedJs });
|
|
1326
|
-
for (const prop in cachedJs) {
|
|
1327
|
-
delete cachedJs[prop];
|
|
1328
|
-
}
|
|
1329
|
-
cachedJs.$ref = `#/$defs/${cachedId}`;
|
|
1330
|
-
}
|
|
1331
|
-
return { $ref: `#/$defs/${cachedId}` };
|
|
1332
|
-
}
|
|
1333
|
-
/**
|
|
1334
|
-
* Build the inline placeholder used in place of a cyclic `$ref` for
|
|
1335
|
-
* descriptors with `supportsRecursion: false`. Inspects the recursive zod
|
|
1336
|
-
* `schema` and emits a JSON-Schema node whose top-level shape matches it
|
|
1337
|
-
* (number → `{type: 'number'}`, array → `{type: 'array', items: ...}`, union
|
|
1338
|
-
* → `{anyOf: [...]}`, etc.) so the LLM still knows what JSON value to send
|
|
1339
|
-
* at the cycle position. One level of structural fidelity, then bottoms out
|
|
1340
|
-
* at the descriptor's flat "any" encoding.
|
|
1341
|
-
*/
|
|
1342
|
-
function buildCycleBreakerSchema(schema, refId, descriptor) {
|
|
1343
|
-
const placeholder = genericize(schema, descriptor, 1);
|
|
1344
|
-
// The description is MODEL-FACING, so it must not point at a `$defs` entry
|
|
1345
|
-
// this dialect never emits — under `allowDefsRef: false` there is no `$defs`
|
|
1346
|
-
// section at all, and telling the model to look at one is a lie about the
|
|
1347
|
-
// document it was handed. Name the recursion instead.
|
|
1348
|
-
const description = descriptor.allowDefsRef
|
|
1349
|
-
? `Would recursively reference #/$defs/${refId}`
|
|
1350
|
-
: `Recursive reference to '${refId}' — repeat this shape here`;
|
|
1351
|
-
return { ...placeholder, description };
|
|
1352
|
-
}
|
|
1353
|
-
/**
|
|
1354
|
-
* Reduce a zod schema to a JSON-Schema node carrying its top-level shape
|
|
1355
|
-
* class (string / number / array / object / union / …) without nested
|
|
1356
|
-
* constraints. Used by the cycle-breaker so the placeholder at a recursive
|
|
1357
|
-
* position matches the JSON value the LLM is supposed to emit.
|
|
1358
|
-
*
|
|
1359
|
-
* `depth` controls how far structural fidelity flows into the *recursive*
|
|
1360
|
-
* containers (arrays of arrays, unions of unions). Closed-form schemas
|
|
1361
|
-
* (primitives, objects, tuples) ignore depth — they have no further
|
|
1362
|
-
* substructure to walk and are always safe to emit verbatim.
|
|
1363
|
-
* - `depth >= 1`: arrays carry their `items`, unions carry their branches,
|
|
1364
|
-
* each recursing at `depth - 1`.
|
|
1365
|
-
* - `depth === 0`: arrays collapse to bare `{type: 'array'}`, unions
|
|
1366
|
-
* collapse to the descriptor's flat any encoding. Stops the walk before
|
|
1367
|
-
* it can re-traverse the cycle that triggered the call.
|
|
1368
|
-
*
|
|
1369
|
-
* Depth is hardcoded to 1 by `buildCycleBreakerSchema`. That's enough for
|
|
1370
|
-
* "a list of objects should be a list of records" and prevents infinite
|
|
1371
|
-
* recursion on `Array<Array<Self>>` / `Union<Union<Self>>` shapes.
|
|
1372
|
-
*/
|
|
1373
|
-
function genericize(schema, descriptor, depth) {
|
|
1374
|
-
// Unwrap transparent wrappers — recurse at the same depth because the
|
|
1375
|
-
// wrapper itself doesn't add a structural level.
|
|
1376
|
-
if (schema instanceof z.ZodLazy) {
|
|
1377
|
-
return genericize(schema.def.getter(), descriptor, depth);
|
|
1378
|
-
}
|
|
1379
|
-
if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable) {
|
|
1380
|
-
return genericize(schema.unwrap(), descriptor, depth);
|
|
1381
|
-
}
|
|
1382
|
-
if (schema instanceof z.ZodDefault) {
|
|
1383
|
-
return genericize(schema.def.innerType, descriptor, depth);
|
|
1384
|
-
}
|
|
1385
|
-
if (schema instanceof z.ZodCodec) {
|
|
1386
|
-
return genericize(schema.def.in, descriptor, depth);
|
|
1387
|
-
}
|
|
1388
|
-
if (schema instanceof z.ZodPipe) {
|
|
1389
|
-
return genericize(schema.def.in, descriptor, depth);
|
|
1390
|
-
}
|
|
1391
|
-
// Primitive leaves and string-flavored types — no substructure, depth
|
|
1392
|
-
// irrelevant. The LLM gets a precise type signal.
|
|
1393
|
-
if (schema instanceof z.ZodString)
|
|
1394
|
-
return { type: 'string' };
|
|
1395
|
-
if (schema instanceof z.ZodNumber)
|
|
1396
|
-
return { type: 'number' };
|
|
1397
|
-
if (schema instanceof z.ZodBigInt)
|
|
1398
|
-
return { type: 'number' };
|
|
1399
|
-
if (schema instanceof z.ZodBoolean)
|
|
1400
|
-
return { type: 'boolean' };
|
|
1401
|
-
if (schema instanceof z.ZodNull)
|
|
1402
|
-
return { type: 'null' };
|
|
1403
|
-
if (schema instanceof z.ZodUndefined)
|
|
1404
|
-
return { type: 'null' };
|
|
1405
|
-
if (schema instanceof z.ZodDate ||
|
|
1406
|
-
schema instanceof z.ZodISODateTime ||
|
|
1407
|
-
schema instanceof z.ZodISODate ||
|
|
1408
|
-
schema instanceof z.ZodISOTime ||
|
|
1409
|
-
schema instanceof z.ZodISODuration ||
|
|
1410
|
-
schema instanceof z.ZodEmail ||
|
|
1411
|
-
schema instanceof z.ZodIPv4 ||
|
|
1412
|
-
schema instanceof z.ZodIPv6 ||
|
|
1413
|
-
schema instanceof z.ZodUUID ||
|
|
1414
|
-
schema instanceof z.ZodTemplateLiteral) {
|
|
1415
|
-
return { type: 'string' };
|
|
1416
|
-
}
|
|
1417
|
-
if (schema instanceof z.ZodLiteral) {
|
|
1418
|
-
// ZodLiteral can carry one or more values; pick the first that maps to
|
|
1419
|
-
// a JSON primitive type, else fall back to flat any.
|
|
1420
|
-
const v = Array.from(schema.values).find((x) => x === null || typeof x === 'string' || typeof x === 'number' || typeof x === 'boolean');
|
|
1421
|
-
if (v === null)
|
|
1422
|
-
return { type: 'null' };
|
|
1423
|
-
if (typeof v === 'string')
|
|
1424
|
-
return { type: 'string' };
|
|
1425
|
-
if (typeof v === 'number')
|
|
1426
|
-
return { type: 'number' };
|
|
1427
|
-
if (typeof v === 'boolean')
|
|
1428
|
-
return { type: 'boolean' };
|
|
1429
|
-
return buildAnyValueSchema(descriptor);
|
|
1430
|
-
}
|
|
1431
|
-
if (schema instanceof z.ZodEnum) {
|
|
1432
|
-
const numericValues = Object.values(schema.def.entries).filter((v) => typeof v === 'number');
|
|
1433
|
-
const values = Object.entries(schema.def.entries)
|
|
1434
|
-
.filter(([k]) => numericValues.indexOf(+k) === -1)
|
|
1435
|
-
.map(([, v]) => v);
|
|
1436
|
-
if (values.every((v) => typeof v === 'string'))
|
|
1437
|
-
return { type: 'string' };
|
|
1438
|
-
if (values.every((v) => typeof v === 'number'))
|
|
1439
|
-
return { type: 'number' };
|
|
1440
|
-
return buildAnyValueSchema(descriptor);
|
|
1441
|
-
}
|
|
1442
|
-
// Closed-form containers — no nested schema to walk, safe at any depth.
|
|
1443
|
-
if (schema instanceof z.ZodObject ||
|
|
1444
|
-
schema instanceof z.ZodRecord ||
|
|
1445
|
-
schema instanceof z.ZodIntersection) {
|
|
1446
|
-
return { type: 'object', additionalProperties: true };
|
|
1447
|
-
}
|
|
1448
|
-
if (schema instanceof z.ZodTuple) {
|
|
1449
|
-
// Heterogeneous positional shape — drop items, keep array hint.
|
|
1450
|
-
return { type: 'array' };
|
|
1451
|
-
}
|
|
1452
|
-
// Recursive containers — depth gates further structural fidelity.
|
|
1453
|
-
if (schema instanceof z.ZodArray) {
|
|
1454
|
-
if (depth <= 0)
|
|
1455
|
-
return { type: 'array' };
|
|
1456
|
-
return { type: 'array', items: genericize(schema.element, descriptor, depth - 1) };
|
|
1457
|
-
}
|
|
1458
|
-
if (schema instanceof z.ZodUnion || schema instanceof z.ZodDiscriminatedUnion) {
|
|
1459
|
-
if (depth <= 0)
|
|
1460
|
-
return buildAnyValueSchema(descriptor);
|
|
1461
|
-
const options = schema.options;
|
|
1462
|
-
const branches = options.map((opt) => genericize(opt, descriptor, depth - 1));
|
|
1463
|
-
const unique = uniqueByJSON(branches);
|
|
1464
|
-
return unique.length === 1 ? unique[0] : { anyOf: unique };
|
|
1465
|
-
}
|
|
1466
|
-
// ZodAny / ZodUnknown / ZodTransform / anything we don't recognize —
|
|
1467
|
-
// emit the descriptor's any encoding. Safer than guessing.
|
|
1468
|
-
return buildAnyValueSchema(descriptor);
|
|
1469
|
-
}
|
|
1470
|
-
/**
|
|
1471
|
-
* Dedupe an array of JSONSchema nodes by structural (JSON-stringified)
|
|
1472
|
-
* equality. Order-preserving: the first occurrence wins. Used by
|
|
1473
|
-
* `genericize` to collapse `union<T, T>` placeholders into a single branch.
|
|
1474
|
-
*/
|
|
1475
|
-
function uniqueByJSON(nodes) {
|
|
1476
|
-
const seen = new Set();
|
|
1477
|
-
const out = [];
|
|
1478
|
-
for (const node of nodes) {
|
|
1479
|
-
const key = JSON.stringify(node);
|
|
1480
|
-
if (seen.has(key))
|
|
1481
|
-
continue;
|
|
1482
|
-
seen.add(key);
|
|
1483
|
-
out.push(node);
|
|
1484
|
-
}
|
|
1485
|
-
return out;
|
|
1486
|
-
}
|
|
1487
|
-
/**
|
|
1488
|
-
* The values a `z.enum` actually EMITS as JSON-Schema `enum` members.
|
|
1489
|
-
*
|
|
1490
|
-
* A numeric TypeScript enum compiles to a two-way map (`{A: 0, 0: 'A'}`), so
|
|
1491
|
-
* the reverse-mapping keys have to be dropped or every value appears twice.
|
|
1492
|
-
* Shared with `analyzeSchema` deliberately: the size budget counts what goes on
|
|
1493
|
-
* the wire, and "what goes on the wire" must have ONE definition (a second
|
|
1494
|
-
* copy of this filter would drift from the emitter it is supposed to measure).
|
|
1495
|
-
*/
|
|
1496
|
-
function enumEmittedValues(schema) {
|
|
1497
|
-
const numericValues = Object.values(schema.def.entries).filter((v) => typeof v === 'number');
|
|
1498
|
-
return Object.entries(schema.def.entries)
|
|
1499
|
-
.filter(([k]) => numericValues.indexOf(+k) === -1)
|
|
1500
|
-
.map(([, v]) => v);
|
|
1501
|
-
}
|
|
1502
|
-
/**
|
|
1503
|
-
* The values a `z.literal` emits — one for the `const` form, several for the
|
|
1504
|
-
* multi-value form that emits `enum`. Types JSON Schema cannot carry
|
|
1505
|
-
* (functions, symbols, bigints, `undefined`) are dropped, matching the
|
|
1506
|
-
* emitter. Shared with `analyzeSchema` for the same reason as above.
|
|
1507
|
-
*/
|
|
1508
|
-
function literalEmittedValues(schema) {
|
|
1509
|
-
return Array.from(schema.values).filter((v) => v !== undefined && typeof v !== 'function' && typeof v !== 'symbol' && typeof v !== 'bigint');
|
|
1510
|
-
}
|
|
1511
|
-
/**
|
|
1512
|
-
* Decide whether a set of values may be emitted as a JSON-Schema `enum` under
|
|
1513
|
-
* this descriptor, per `maxEnumValues`.
|
|
1514
|
-
*
|
|
1515
|
-
* Under the cap (or with no cap declared) the caller emits its `enum` as
|
|
1516
|
-
* normal. Over it, the caller emits NO `enum` — just the widened `type` — and
|
|
1517
|
-
* attaches `note`, which lists EVERY value in prose.
|
|
1518
|
-
*
|
|
1519
|
-
* Widening rather than truncating is the deliberate choice, and the two differ
|
|
1520
|
-
* in what the model can still say. A truncated `enum` makes the values past the
|
|
1521
|
-
* cap **unreachable**: the wire schema constrains them away, so a caller whose
|
|
1522
|
-
* 96 values are all legitimately selectable loses 56 of them. Widening removes
|
|
1523
|
-
* the wire-level constraint that the dialect can't compile in the first place
|
|
1524
|
-
* and moves the full list into the `description`, so the model retains complete
|
|
1525
|
-
* information and can name any of the 96. Nothing is hidden — it is described
|
|
1526
|
-
* instead of enforced, and enforcement moves to whatever validates the value
|
|
1527
|
-
* afterwards (`Tool.parse` against the ORIGINAL zod schema still rejects a name
|
|
1528
|
-
* that was never in the enum, with a message the model can act on).
|
|
1529
|
-
*
|
|
1530
|
-
* The note is deliberately DOMAIN-NEUTRAL. This library has no idea what the
|
|
1531
|
-
* values mean; whichever caller relies on the cap (a session's known-name enum,
|
|
1532
|
-
* a country list, anything) reads its own semantics into them.
|
|
1533
|
-
*/
|
|
1534
|
-
function describeOversizeEnum(values, descriptor) {
|
|
1535
|
-
const cap = descriptor.maxEnumValues;
|
|
1536
|
-
if (cap === undefined || values.length <= cap)
|
|
1537
|
-
return undefined;
|
|
1538
|
-
// A flat comma-separated list: it is how an enum reads in prose anyway, and
|
|
1539
|
-
// the values are single tokens by construction (they were `enum` members).
|
|
1540
|
-
return `One of these ${values.length} values: ${values.map((v) => String(v)).join(', ')}`;
|
|
1541
|
-
}
|
|
1542
|
-
/**
|
|
1543
|
-
* Main conversion function
|
|
1544
|
-
*/
|
|
1545
|
-
function convertSchema(schema, context) {
|
|
1546
|
-
const descriptor = context.descriptor;
|
|
1547
|
-
// TODO: Map, Set, File, ReadOnly, Nan, Catch, Prefault, NonOptional, Transform, Function, Promise, Custom
|
|
1548
|
-
// Handle ZodOptional
|
|
1549
|
-
if (schema instanceof z.ZodOptional) {
|
|
1550
|
-
const innerSchema = schema.unwrap();
|
|
1551
|
-
const innerJson = convert(innerSchema, context);
|
|
1552
|
-
if (descriptor.optionalAsNullable) {
|
|
1553
|
-
// OpenAI-strict-style: optional fields become nullable on the wire.
|
|
1554
|
-
return makeNullable(innerJson, descriptor);
|
|
1555
|
-
}
|
|
1556
|
-
else {
|
|
1557
|
-
// Standard JSON Schema: optional fields are simply not in `required[]`.
|
|
1558
|
-
return innerJson;
|
|
1559
|
-
}
|
|
1560
|
-
}
|
|
1561
|
-
// Handle ZodNullable
|
|
1562
|
-
if (schema instanceof z.ZodNullable) {
|
|
1563
|
-
const innerSchema = schema.unwrap();
|
|
1564
|
-
const innerJson = convert(innerSchema, context);
|
|
1565
|
-
return makeNullable(innerJson, descriptor);
|
|
1566
|
-
}
|
|
1567
|
-
// Handle ZodObject
|
|
1568
|
-
if (schema instanceof z.ZodObject) {
|
|
1569
|
-
const properties = {};
|
|
1570
|
-
const required = [];
|
|
1571
|
-
const shape = schema.shape;
|
|
1572
|
-
for (const key in shape) {
|
|
1573
|
-
const fieldSchema = shape[key];
|
|
1574
|
-
const isFieldRequired = descriptor.objectAllFieldsRequired || !isOptional(fieldSchema);
|
|
1575
|
-
properties[key] = convert(fieldSchema, context);
|
|
1576
|
-
if (isFieldRequired) {
|
|
1577
|
-
required.push(key);
|
|
1578
|
-
}
|
|
1579
|
-
}
|
|
1580
|
-
const result = {
|
|
1581
|
-
type: 'object',
|
|
1582
|
-
properties,
|
|
1583
|
-
required,
|
|
1584
|
-
};
|
|
1585
|
-
if (descriptor.objectClosedByDefault || schema.def.catchall?._zod.def.type === "never") {
|
|
1586
|
-
result.additionalProperties = false;
|
|
1587
|
-
}
|
|
1588
|
-
else if (schema.def.catchall) {
|
|
1589
|
-
result.additionalProperties = convert(schema.def.catchall, context);
|
|
1590
|
-
}
|
|
1591
|
-
// Gemini 2.0 strict requires `propertyOrdering` so the model emits keys
|
|
1592
|
-
// in a deterministic order. Other dialects ignore this hint, so emitting
|
|
1593
|
-
// it under non-Google descriptors is harmless — but we gate it on the
|
|
1594
|
-
// descriptor flag to keep the JSON Schema minimal everywhere else.
|
|
1595
|
-
if (descriptor.emitPropertyOrdering) {
|
|
1596
|
-
result.propertyOrdering = Object.keys(properties);
|
|
1597
|
-
}
|
|
1598
|
-
return result;
|
|
1599
|
-
}
|
|
1600
|
-
// Handle ZodArray
|
|
1601
|
-
if (schema instanceof z.ZodArray) {
|
|
1602
|
-
const result = {
|
|
1603
|
-
type: 'array',
|
|
1604
|
-
items: convert(schema.element, context),
|
|
1605
|
-
};
|
|
1606
|
-
if (descriptor.allowMinMaxItems) {
|
|
1607
|
-
const { minimum, maximum } = schema._zod.bag;
|
|
1608
|
-
if (typeof minimum === 'number') {
|
|
1609
|
-
result.minItems = minimum;
|
|
1610
|
-
}
|
|
1611
|
-
if (typeof maximum === 'number') {
|
|
1612
|
-
result.maxItems = maximum;
|
|
1613
|
-
}
|
|
1614
|
-
}
|
|
1615
|
-
return result;
|
|
1616
|
-
}
|
|
1617
|
-
// Handle ZodRecord
|
|
1618
|
-
if (schema instanceof z.ZodRecord) {
|
|
1619
|
-
if (descriptor.recordEncoding === 'array-of-pairs') {
|
|
1620
|
-
// Strict-mode workaround: records become arrays of {key, value} objects.
|
|
1621
|
-
return {
|
|
1622
|
-
type: 'array',
|
|
1623
|
-
items: {
|
|
1624
|
-
type: 'object',
|
|
1625
|
-
properties: {
|
|
1626
|
-
key: convert(schema.keyType, context),
|
|
1627
|
-
value: convert(schema.valueType, context),
|
|
1628
|
-
},
|
|
1629
|
-
required: ['key', 'value'],
|
|
1630
|
-
additionalProperties: false,
|
|
1631
|
-
},
|
|
1632
|
-
};
|
|
1633
|
-
}
|
|
1634
|
-
// Standard: open record with propertyNames + additionalProperties.
|
|
1635
|
-
return {
|
|
1636
|
-
type: 'object',
|
|
1637
|
-
propertyNames: convert(schema.keyType, context),
|
|
1638
|
-
additionalProperties: convert(schema.valueType, context),
|
|
1639
|
-
};
|
|
1640
|
-
}
|
|
1641
|
-
// Handle ZodUnion
|
|
1642
|
-
if (schema instanceof z.ZodUnion) {
|
|
1643
|
-
const anyOf = schema.options.map(option => convert(option, context));
|
|
1644
|
-
return { anyOf };
|
|
1645
|
-
}
|
|
1646
|
-
// Handle ZodDiscriminatedUnion
|
|
1647
|
-
if (schema instanceof z.ZodDiscriminatedUnion) {
|
|
1648
|
-
const anyOf = schema.options.map(option => convert(option, context));
|
|
1649
|
-
return { anyOf };
|
|
1650
|
-
}
|
|
1651
|
-
// Handle ZodIntersection
|
|
1652
|
-
if (schema instanceof z.ZodIntersection) {
|
|
1653
|
-
const left = convert(schema.def.left, context);
|
|
1654
|
-
const right = convert(schema.def.right, context);
|
|
1655
|
-
const allOf = [
|
|
1656
|
-
...(left.allOf && Object.keys(left).length === 1 ? left.allOf : [left]),
|
|
1657
|
-
...(right.allOf && Object.keys(right).length === 1 ? right.allOf : [right]),
|
|
1658
|
-
];
|
|
1659
|
-
// OpenAI strict bans allOf in some shapes; collapse to anyOf when the
|
|
1660
|
-
// descriptor disallows it.
|
|
1661
|
-
return descriptor.allowAllOf ? { allOf } : { anyOf: allOf };
|
|
1662
|
-
}
|
|
1663
|
-
// Handle ZodTuple
|
|
1664
|
-
if (schema instanceof z.ZodTuple) {
|
|
1665
|
-
const items = schema.def.items.map(item => convert(item, context));
|
|
1666
|
-
const rest = schema.def.rest ? convert(schema.def.rest, context) : undefined;
|
|
1667
|
-
// Object-numeric-keys encoding: per-position type preserved as object
|
|
1668
|
-
// properties. Variadic rests can't be represented this way — fall back to
|
|
1669
|
-
// a homogeneous array of (items ∪ rest) for that rare case.
|
|
1670
|
-
if (descriptor.tupleEncoding === 'object-numeric-keys' && !rest) {
|
|
1671
|
-
const properties = {};
|
|
1672
|
-
const required = [];
|
|
1673
|
-
for (let i = 0; i < items.length; i++) {
|
|
1674
|
-
const k = String(i);
|
|
1675
|
-
properties[k] = isOptional(schema.def.items[i]) ? makeNullable(items[i], descriptor) : items[i];
|
|
1676
|
-
required.push(k);
|
|
1677
|
-
}
|
|
1678
|
-
return {
|
|
1679
|
-
type: 'object',
|
|
1680
|
-
properties,
|
|
1681
|
-
required,
|
|
1682
|
-
additionalProperties: false,
|
|
1683
|
-
};
|
|
1684
|
-
}
|
|
1685
|
-
const result = { type: 'array' };
|
|
1686
|
-
if (descriptor.tupleEncoding === 'object-numeric-keys' && rest) {
|
|
1687
|
-
// Strict + rest fallback: every position fits one of the declared
|
|
1688
|
-
// types (positional info is lost — there's no way to express a
|
|
1689
|
-
// mixed-prefix-plus-rest array in strict-object form).
|
|
1690
|
-
items.push(rest);
|
|
1691
|
-
}
|
|
1692
|
-
// If all items are the same type, simplify to a single items schema
|
|
1693
|
-
if (items.length > 0 && items.every((item) => JSON.stringify(item) === JSON.stringify(items[0]))) {
|
|
1694
|
-
result.items = items[0];
|
|
1695
|
-
}
|
|
1696
|
-
else if (descriptor.tupleEncoding === 'prefix-items') {
|
|
1697
|
-
result.prefixItems = items;
|
|
1698
|
-
}
|
|
1699
|
-
else {
|
|
1700
|
-
// items-union or strict-mode-with-rest fallback: collapse to anyOf
|
|
1701
|
-
result.items = { anyOf: items };
|
|
1702
|
-
}
|
|
1703
|
-
if (descriptor.tupleEncoding === 'prefix-items' && rest) {
|
|
1704
|
-
result.additionalItems = rest;
|
|
1705
|
-
}
|
|
1706
|
-
if (!rest) {
|
|
1707
|
-
let minItems = items.length;
|
|
1708
|
-
while (minItems > 0 && isOptional(schema.def.items[minItems - 1])) {
|
|
1709
|
-
minItems--;
|
|
1710
|
-
}
|
|
1711
|
-
result.minItems = minItems;
|
|
1712
|
-
result.maxItems = items.length;
|
|
1713
|
-
}
|
|
1714
|
-
if (descriptor.allowMinMaxItems) {
|
|
1715
|
-
const { minimum, maximum } = schema._zod.bag;
|
|
1716
|
-
if (typeof minimum === 'number') {
|
|
1717
|
-
result.minItems = minimum;
|
|
1718
|
-
}
|
|
1719
|
-
if (typeof maximum === 'number') {
|
|
1720
|
-
result.maxItems = maximum;
|
|
1721
|
-
}
|
|
1722
|
-
}
|
|
1723
|
-
return result;
|
|
1724
|
-
}
|
|
1725
|
-
// Handle ZodEnum
|
|
1726
|
-
if (schema instanceof z.ZodEnum) {
|
|
1727
|
-
const values = enumEmittedValues(schema);
|
|
1728
|
-
// Over the dialect's cap the `enum` keyword is DROPPED and the node widens
|
|
1729
|
-
// to its plain scalar type, with every value listed in the description —
|
|
1730
|
-
// see `describeOversizeEnum` for why widening beats truncating.
|
|
1731
|
-
const oversize = describeOversizeEnum(values, descriptor);
|
|
1732
|
-
return {
|
|
1733
|
-
type: values.every((v) => typeof v === 'number')
|
|
1734
|
-
? 'number'
|
|
1735
|
-
: values.every((v) => typeof v === 'string')
|
|
1736
|
-
? 'string'
|
|
1737
|
-
: undefined,
|
|
1738
|
-
...(oversize === undefined ? { enum: values } : { description: oversize }),
|
|
1739
|
-
};
|
|
1740
|
-
}
|
|
1741
|
-
// Handle ZodLiteral
|
|
1742
|
-
if (schema instanceof z.ZodLiteral) {
|
|
1743
|
-
const values = literalEmittedValues(schema);
|
|
1744
|
-
const types = Array.from(new Set(values.map(v => v === null ? 'null' : typeof v)));
|
|
1745
|
-
// A multi-value literal emits the SAME `enum` keyword as ZodEnum, so the
|
|
1746
|
-
// dialect cap applies identically — the provider sees no difference. The
|
|
1747
|
-
// single-value form emits `const`, which no cap can be exceeded by.
|
|
1748
|
-
const oversize = values.length === 1 ? undefined : describeOversizeEnum(values, descriptor);
|
|
1749
|
-
return {
|
|
1750
|
-
...(types.length === 1 ? { type: types[0] } : {}),
|
|
1751
|
-
...(values.length === 1
|
|
1752
|
-
? { const: values[0] }
|
|
1753
|
-
: oversize === undefined ? { enum: values } : { description: oversize }),
|
|
1754
|
-
};
|
|
1755
|
-
}
|
|
1756
|
-
// Handle ZodDefault
|
|
1757
|
-
if (schema instanceof z.ZodDefault) {
|
|
1758
|
-
const innerJson = convert(schema.def.innerType, context);
|
|
1759
|
-
innerJson.default = JSON.parse(JSON.stringify(schema.def.defaultValue));
|
|
1760
|
-
return innerJson;
|
|
1761
|
-
}
|
|
1762
|
-
// Handle ZodLazy - don't unwrap here, let convert() handle caching
|
|
1763
|
-
if (schema instanceof z.ZodLazy) {
|
|
1764
|
-
throw new Error('ZodLazy should be handled in convert(), not convertSchema()');
|
|
1765
|
-
}
|
|
1766
|
-
// Handle ZodCodec
|
|
1767
|
-
if (schema instanceof z.ZodCodec) {
|
|
1768
|
-
return convert(schema.def.in, context);
|
|
1769
|
-
}
|
|
1770
|
-
// Handle ZodPipe (from preprocess)
|
|
1771
|
-
if (schema instanceof z.ZodPipe) {
|
|
1772
|
-
const innerType = schema.def.in._zod.def.type === "transform" ? schema.def.out : schema.def.in;
|
|
1773
|
-
return convert(innerType, context);
|
|
1774
|
-
}
|
|
1775
|
-
// Handle primitive types
|
|
1776
|
-
if (schema instanceof z.ZodString) {
|
|
1777
|
-
const result = { type: 'string' };
|
|
1778
|
-
const { minimum, maximum, format, patterns, contentEncoding } = schema._zod.bag;
|
|
1779
|
-
// Format: emit only when allowed by the descriptor.
|
|
1780
|
-
if (typeof format === 'string') {
|
|
1781
|
-
const formats = descriptor.supportedStringFormats;
|
|
1782
|
-
if (formats === 'all' || formats.has(format)) {
|
|
1783
|
-
result.format = format;
|
|
1784
|
-
}
|
|
1785
|
-
}
|
|
1786
|
-
if (descriptor.allowMinMaxLength) {
|
|
1787
|
-
if (typeof minimum === 'number') {
|
|
1788
|
-
result.minLength = minimum;
|
|
1789
|
-
}
|
|
1790
|
-
if (typeof maximum === 'number') {
|
|
1791
|
-
result.maxLength = maximum;
|
|
1792
|
-
}
|
|
1793
|
-
}
|
|
1794
|
-
if (typeof contentEncoding === 'string' && descriptor.allowMinMaxLength) {
|
|
1795
|
-
// contentEncoding piggybacks on the lenient gate (it's not a strict-mode-safe field)
|
|
1796
|
-
result.contentEncoding = contentEncoding;
|
|
1797
|
-
}
|
|
1798
|
-
if (patterns && descriptor.allowPattern) {
|
|
1799
|
-
if (patterns.size === 1 || !descriptor.allowMultiplePatterns) {
|
|
1800
|
-
result.pattern = Array.from(patterns)[0].source;
|
|
1801
|
-
}
|
|
1802
|
-
else {
|
|
1803
|
-
result.allOf = Array.from(patterns).map((regex) => ({
|
|
1804
|
-
type: 'string',
|
|
1805
|
-
pattern: regex.source,
|
|
1806
|
-
}));
|
|
1807
|
-
}
|
|
1808
|
-
}
|
|
1809
|
-
return result;
|
|
1810
|
-
}
|
|
1811
|
-
if (schema instanceof z.ZodNumber) {
|
|
1812
|
-
const result = { type: 'number' };
|
|
1813
|
-
const { minimum, maximum, format, multipleOf, exclusiveMaximum, exclusiveMinimum } = schema._zod.bag;
|
|
1814
|
-
if (typeof format === 'string' && format.includes("int")) {
|
|
1815
|
-
result.type = 'integer';
|
|
1816
|
-
}
|
|
1817
|
-
if (descriptor.allowMinimumMaximum) {
|
|
1818
|
-
if (typeof exclusiveMinimum === 'number') {
|
|
1819
|
-
result.exclusiveMinimum = exclusiveMinimum;
|
|
1820
|
-
}
|
|
1821
|
-
else if (typeof minimum === 'number') {
|
|
1822
|
-
result.minimum = minimum;
|
|
1823
|
-
}
|
|
1824
|
-
if (typeof exclusiveMaximum === 'number') {
|
|
1825
|
-
result.exclusiveMaximum = exclusiveMaximum;
|
|
1826
|
-
}
|
|
1827
|
-
else if (typeof maximum === 'number') {
|
|
1828
|
-
result.maximum = maximum;
|
|
1829
|
-
}
|
|
1830
|
-
if (typeof multipleOf === 'number') {
|
|
1831
|
-
result.multipleOf = multipleOf;
|
|
1832
|
-
}
|
|
1833
|
-
}
|
|
1834
|
-
return result;
|
|
1835
|
-
}
|
|
1836
|
-
if (schema instanceof z.ZodBoolean || schema instanceof z.ZodSuccess) {
|
|
1837
|
-
return { type: 'boolean' };
|
|
1838
|
-
}
|
|
1839
|
-
if (schema instanceof z.ZodBigInt) {
|
|
1840
|
-
return { type: 'integer' };
|
|
1841
|
-
}
|
|
1842
|
-
if (schema instanceof z.ZodDate || schema instanceof z.ZodISODateTime) {
|
|
1843
|
-
return stringWithFormat('date-time', descriptor);
|
|
1844
|
-
}
|
|
1845
|
-
if (schema instanceof z.ZodISODate) {
|
|
1846
|
-
return stringWithFormat('date', descriptor);
|
|
1847
|
-
}
|
|
1848
|
-
if (schema instanceof z.ZodISOTime) {
|
|
1849
|
-
return stringWithFormat('time', descriptor);
|
|
1850
|
-
}
|
|
1851
|
-
if (schema instanceof z.ZodISODuration) {
|
|
1852
|
-
return stringWithFormat('duration', descriptor);
|
|
1853
|
-
}
|
|
1854
|
-
if (schema instanceof z.ZodEmail) {
|
|
1855
|
-
return stringWithFormat('email', descriptor);
|
|
1856
|
-
}
|
|
1857
|
-
if (schema instanceof z.ZodIPv4) {
|
|
1858
|
-
return stringWithFormat('ipv4', descriptor);
|
|
1859
|
-
}
|
|
1860
|
-
if (schema instanceof z.ZodIPv6) {
|
|
1861
|
-
return stringWithFormat('ipv6', descriptor);
|
|
1862
|
-
}
|
|
1863
|
-
if (schema instanceof z.ZodUUID) {
|
|
1864
|
-
return stringWithFormat('uuid', descriptor);
|
|
1865
|
-
}
|
|
1866
|
-
if (schema instanceof z.ZodNull) {
|
|
1867
|
-
return { type: 'null' };
|
|
1868
|
-
}
|
|
1869
|
-
if (schema instanceof z.ZodTemplateLiteral) {
|
|
1870
|
-
return { type: 'string', pattern: schema._zod.pattern?.source };
|
|
1871
|
-
}
|
|
1872
|
-
if (schema instanceof z.ZodUndefined) {
|
|
1873
|
-
return { type: 'null' }; // Treat undefined as null in JSON
|
|
1874
|
-
}
|
|
1875
|
-
if (schema instanceof z.ZodAny || schema instanceof z.ZodUnknown) {
|
|
1876
|
-
// Bare `{}` would satisfy the meaning of "any" but OpenAI's strict
|
|
1877
|
-
// structured-output mode rejects schemas without a `type` key. For
|
|
1878
|
-
// descriptors with a recursive Any encoding, promote to a single shared
|
|
1879
|
-
// `$defs/Any` definition that covers every JSON value and return a
|
|
1880
|
-
// `$ref` — sharing keeps the output compact when `z.any()` appears many
|
|
1881
|
-
// times inside a big union. For descriptors with `flat` Any encoding
|
|
1882
|
-
// (e.g. Anthropic, which rejects recursive `$defs` graphs) or `unconstrained`
|
|
1883
|
-
// (Google, which rejects both `anyOf` and named `$defs`), inline the
|
|
1884
|
-
// permissive shape directly so the schema stays acyclic and `$defs`-free.
|
|
1885
|
-
if (!usesSharedAnyDefinition(descriptor)) {
|
|
1886
|
-
return buildAnyValueSchema(descriptor);
|
|
1887
|
-
}
|
|
1888
|
-
const id = 'Any';
|
|
1889
|
-
if (!context.definitionSchemas.has(id)) {
|
|
1890
|
-
// Seed a placeholder BEFORE building the body so recursive
|
|
1891
|
-
// `$ref: '#/$defs/Any'` references don't infinite-loop during
|
|
1892
|
-
// construction.
|
|
1893
|
-
context.definitionSchemas.set(id, {});
|
|
1894
|
-
context.definitionSchemas.set(id, buildAnyValueSchema(descriptor));
|
|
1895
|
-
}
|
|
1896
|
-
return { $ref: `#/$defs/${id}` };
|
|
1897
|
-
}
|
|
1898
|
-
if (schema instanceof z.ZodTransform) {
|
|
1899
|
-
return {}; // Transforms are not represented in JSON Schema
|
|
1900
|
-
}
|
|
1901
|
-
// Fallback for unknown types
|
|
1902
|
-
console.warn(`Unknown Zod schema type: ${schema.constructor.name}`);
|
|
1903
|
-
return {};
|
|
1904
|
-
}
|
|
1905
|
-
/**
|
|
1906
|
-
* True when the descriptor's "any" encoding is a self-referencing definition,
|
|
1907
|
-
* i.e. it has to be hoisted into `$defs/Any` and referenced by `$ref`. The
|
|
1908
|
-
* non-recursive encodings (`flat`, `unconstrained`) are inlined at each use
|
|
1909
|
-
* site instead, so no `$defs` entry is ever created for them.
|
|
1910
|
-
*/
|
|
1911
|
-
function usesSharedAnyDefinition(descriptor) {
|
|
1912
|
-
return descriptor.anyEncoding === 'recursive-strict'
|
|
1913
|
-
|| descriptor.anyEncoding === 'recursive-open';
|
|
1914
|
-
}
|
|
1915
|
-
/**
|
|
1916
|
-
* Builds the body of the "any value" schema — for most dialects an `anyOf`
|
|
1917
|
-
* covering every JSON value (string, number, boolean, null, array, object).
|
|
1918
|
-
*
|
|
1919
|
-
* Encoding modes:
|
|
1920
|
-
* - `recursive-strict` (OpenAI strict): self-referencing `$defs/Any` with
|
|
1921
|
-
* array-of-pairs records (no open-object support in strict mode).
|
|
1922
|
-
* - `recursive-open` (Lenient): self-referencing `$defs/Any` with
|
|
1923
|
-
* `additionalProperties: <self>` records.
|
|
1924
|
-
* - `flat` (Anthropic strict): non-recursive — array branch has no `items`
|
|
1925
|
-
* constraint, object branch is `additionalProperties: true`. Equivalent
|
|
1926
|
-
* to TypeScript `any`; safe under descriptors that reject cyclic `$defs`.
|
|
1927
|
-
* - `unconstrained` (Google): the empty schema `{}`. A schema with no
|
|
1928
|
-
* assertion keywords validates every instance, so this says "any JSON
|
|
1929
|
-
* value" using zero keywords — the only option for a dialect that forbids
|
|
1930
|
-
* `anyOf` AND named `$defs`/`$ref`. The caller re-attaches `description`
|
|
1931
|
-
* from the source schema's metadata, so the model still gets the prose.
|
|
1932
|
-
*/
|
|
1933
|
-
function buildAnyValueSchema(descriptor) {
|
|
1934
|
-
if (descriptor.anyEncoding === 'unconstrained') {
|
|
1935
|
-
return {};
|
|
1936
|
-
}
|
|
1937
|
-
const isFlat = descriptor.anyEncoding === 'flat';
|
|
1938
|
-
const selfRef = isFlat ? {} : { $ref: '#/$defs/Any' };
|
|
1939
|
-
const arrayBranch = isFlat
|
|
1940
|
-
? { type: 'array' }
|
|
1941
|
-
: { type: 'array', items: selfRef };
|
|
1942
|
-
const branches = [
|
|
1943
|
-
{ type: 'string' },
|
|
1944
|
-
{ type: 'number' },
|
|
1945
|
-
{ type: 'boolean' },
|
|
1946
|
-
{ type: 'null' },
|
|
1947
|
-
arrayBranch,
|
|
1948
|
-
];
|
|
1949
|
-
if (descriptor.anyEncoding === 'recursive-strict') {
|
|
1950
|
-
branches.push({
|
|
1951
|
-
type: 'array',
|
|
1952
|
-
items: {
|
|
1953
|
-
type: 'object',
|
|
1954
|
-
properties: {
|
|
1955
|
-
key: { type: 'string' },
|
|
1956
|
-
value: selfRef,
|
|
1957
|
-
},
|
|
1958
|
-
required: ['key', 'value'],
|
|
1959
|
-
additionalProperties: false,
|
|
1960
|
-
},
|
|
1961
|
-
});
|
|
1962
|
-
}
|
|
1963
|
-
else if (descriptor.anyEncoding === 'recursive-open') {
|
|
1964
|
-
branches.push({
|
|
1965
|
-
type: 'object',
|
|
1966
|
-
additionalProperties: selfRef,
|
|
1967
|
-
});
|
|
1968
|
-
}
|
|
1969
|
-
else {
|
|
1970
|
-
// flat
|
|
1971
|
-
branches.push({
|
|
1972
|
-
type: 'object',
|
|
1973
|
-
additionalProperties: true,
|
|
1974
|
-
});
|
|
1975
|
-
}
|
|
1976
|
-
return { anyOf: branches };
|
|
1977
|
-
}
|
|
1978
|
-
/**
|
|
1979
|
-
* Makes a JSON Schema nullable.
|
|
1980
|
-
*
|
|
1981
|
-
* For descriptors where optional becomes nullable on the wire (OpenAI strict),
|
|
1982
|
-
* this rewrites the schema to either include `null` in `type` or wrap in
|
|
1983
|
-
* `anyOf: [..., {type: 'null'}]`. For other descriptors this is a no-op (the
|
|
1984
|
-
* caller handles optional via the `required[]` list instead).
|
|
1985
|
-
*/
|
|
1986
|
-
function makeNullable(schema, descriptor) {
|
|
1987
|
-
// Lenient/standard dialects don't rewrite — optional is expressed via
|
|
1988
|
-
// required[] omission, not via type-union with null.
|
|
1989
|
-
if (!descriptor.optionalAsNullable) {
|
|
1990
|
-
return schema;
|
|
1991
|
-
}
|
|
1992
|
-
if (schema.$ref) {
|
|
1993
|
-
return {
|
|
1994
|
-
anyOf: [
|
|
1995
|
-
{ $ref: schema.$ref },
|
|
1996
|
-
{ type: 'null' },
|
|
1997
|
-
],
|
|
1998
|
-
};
|
|
1999
|
-
}
|
|
2000
|
-
if (schema.type) {
|
|
2001
|
-
const hasTypeSpecificProps = schema.items !== undefined ||
|
|
2002
|
-
schema.properties !== undefined ||
|
|
2003
|
-
schema.additionalProperties !== undefined ||
|
|
2004
|
-
schema.patternProperties !== undefined ||
|
|
2005
|
-
schema.minItems !== undefined ||
|
|
2006
|
-
schema.maxItems !== undefined ||
|
|
2007
|
-
schema.minProperties !== undefined ||
|
|
2008
|
-
schema.maxProperties !== undefined;
|
|
2009
|
-
if (hasTypeSpecificProps) {
|
|
2010
|
-
const { type, description, ...rest } = schema;
|
|
2011
|
-
const baseType = Array.isArray(type) ? type[0] : type;
|
|
2012
|
-
return {
|
|
2013
|
-
anyOf: [
|
|
2014
|
-
{ type: baseType, ...rest },
|
|
2015
|
-
{ type: 'null' },
|
|
2016
|
-
],
|
|
2017
|
-
...(description ? { description } : {}),
|
|
2018
|
-
};
|
|
2019
|
-
}
|
|
2020
|
-
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
2021
|
-
if (!types.includes('null')) {
|
|
2022
|
-
return {
|
|
2023
|
-
...schema,
|
|
2024
|
-
type: [...types, 'null'],
|
|
2025
|
-
};
|
|
2026
|
-
}
|
|
2027
|
-
return schema;
|
|
2028
|
-
}
|
|
2029
|
-
if (schema.anyOf) {
|
|
2030
|
-
const hasNull = schema.anyOf.some((s) => s.type === 'null');
|
|
2031
|
-
if (!hasNull) {
|
|
2032
|
-
return {
|
|
2033
|
-
...schema,
|
|
2034
|
-
anyOf: [...schema.anyOf, { type: 'null' }],
|
|
2035
|
-
};
|
|
2036
|
-
}
|
|
2037
|
-
return schema;
|
|
2038
|
-
}
|
|
2039
|
-
return {
|
|
2040
|
-
anyOf: [
|
|
2041
|
-
schema,
|
|
2042
|
-
{ type: 'null' },
|
|
2043
|
-
],
|
|
2044
|
-
};
|
|
2045
|
-
}
|
|
2046
|
-
function isOptional(schema) {
|
|
2047
|
-
return schema._zod.optin !== undefined;
|
|
2048
|
-
}
|
|
2049
|
-
/**
|
|
2050
|
-
* Emit a `{ type: 'string', format: ... }` JSON Schema, dropping the format
|
|
2051
|
-
* when the descriptor's whitelist excludes it. The string type is always
|
|
2052
|
-
* preserved; only the format hint is gated.
|
|
2053
|
-
*/
|
|
2054
|
-
function stringWithFormat(format, descriptor) {
|
|
2055
|
-
const formats = descriptor.supportedStringFormats;
|
|
2056
|
-
if (formats === 'all' || formats.has(format)) {
|
|
2057
|
-
return { type: 'string', format };
|
|
2058
|
-
}
|
|
2059
|
-
return { type: 'string' };
|
|
2060
|
-
}
|
|
2061
|
-
const ZERO_FEATURES = Object.freeze({
|
|
2062
|
-
hasRecursion: false,
|
|
2063
|
-
optionalParameterCount: 0,
|
|
2064
|
-
unionTypeCount: 0,
|
|
2065
|
-
recordCount: 0,
|
|
2066
|
-
tupleCount: 0,
|
|
2067
|
-
enums: Object.freeze([]),
|
|
2068
|
-
objectPropertyCount: 0,
|
|
2069
|
-
maxNestingDepth: 0,
|
|
2070
|
-
stringSizeChars: 0,
|
|
2071
|
-
});
|
|
2072
|
-
const featuresCache = new WeakMap();
|
|
2073
|
-
/**
|
|
2074
|
-
* Walk a Zod schema once and count its structural features. Result is
|
|
2075
|
-
* cached per schema in a WeakMap — second-and-subsequent calls are O(1) and
|
|
2076
|
-
* the entry is GC'd with the schema (same OOM-safe pattern as `strictify`).
|
|
2077
|
-
*
|
|
2078
|
-
* The walk is EAGER (it has to descend to count), so its cycle guard keys on
|
|
2079
|
-
* `walkKey`, not on object identity: a `z.lazy` that rebuilds its inner schema
|
|
2080
|
-
* per call would otherwise never re-enter the same object and the walk would
|
|
2081
|
-
* never terminate. That is not hypothetical — it is a measured, reproducible
|
|
2082
|
-
* heap-death, and this function is on the hot path of EVERY tool of EVERY
|
|
2083
|
-
* request to a strict-family model (`SchemaBudget.allocate`).
|
|
2084
|
-
*/
|
|
2085
|
-
export function analyzeSchema(schema) {
|
|
2086
|
-
const cached = featuresCache.get(schema);
|
|
2087
|
-
if (cached)
|
|
2088
|
-
return cached;
|
|
2089
|
-
const visiting = new Set();
|
|
2090
|
-
const features = { ...ZERO_FEATURES };
|
|
2091
|
-
// Collected mutably and frozen onto `features` at the end — `ZERO_FEATURES`
|
|
2092
|
-
// is a frozen singleton, so spreading it must not hand out its array.
|
|
2093
|
-
const enums = [];
|
|
2094
|
-
/** Total characters of the string members of an emitted enum/const value set. */
|
|
2095
|
-
const stringChars = (values) => values.reduce((n, v) => n + (typeof v === 'string' ? v.length : 0), 0);
|
|
2096
|
-
/** Record the deepest container nesting reached. `depth` is the level of `s` itself. */
|
|
2097
|
-
const noteDepth = (depth) => {
|
|
2098
|
-
if (depth > features.maxNestingDepth)
|
|
2099
|
-
features.maxNestingDepth = depth;
|
|
2100
|
-
};
|
|
2101
|
-
// `depth` counts CONTAINERS entered (an object of scalars is depth 1). Wrapper
|
|
2102
|
-
// nodes — optional, nullable, default, lazy, union arms — are transparent on
|
|
2103
|
-
// the wire and pass their own depth through, so `{a?: {b: string}}` is 2, the
|
|
2104
|
-
// same as `{a: {b: string}}`.
|
|
2105
|
-
function walk(s, depth) {
|
|
2106
|
-
if (!s)
|
|
2107
|
-
return;
|
|
2108
|
-
const key = walkKey(s);
|
|
2109
|
-
if (visiting.has(key))
|
|
2110
|
-
return;
|
|
2111
|
-
visiting.add(key);
|
|
2112
|
-
try {
|
|
2113
|
-
// Recursion is detected via z.lazy: peek at the inner schema (but only
|
|
2114
|
-
// once — the visiting set bounds the walk).
|
|
2115
|
-
if (s instanceof z.ZodLazy) {
|
|
2116
|
-
features.hasRecursion = true;
|
|
2117
|
-
try {
|
|
2118
|
-
walk(s.def.getter(), depth);
|
|
2119
|
-
}
|
|
2120
|
-
catch { /* lazy getter may throw at analyze time; ignore */ }
|
|
2121
|
-
return;
|
|
2122
|
-
}
|
|
2123
|
-
if (s instanceof z.ZodOptional) {
|
|
2124
|
-
features.optionalParameterCount += 1;
|
|
2125
|
-
walk(s.unwrap(), depth);
|
|
2126
|
-
return;
|
|
2127
|
-
}
|
|
2128
|
-
if (s instanceof z.ZodNullable) {
|
|
2129
|
-
features.unionTypeCount += 1; // T|null counts as a union on the wire
|
|
2130
|
-
walk(s.unwrap(), depth);
|
|
2131
|
-
return;
|
|
2132
|
-
}
|
|
2133
|
-
if (s instanceof z.ZodUnion || s instanceof z.ZodDiscriminatedUnion) {
|
|
2134
|
-
features.unionTypeCount += 1;
|
|
2135
|
-
for (const opt of s.options)
|
|
2136
|
-
walk(opt, depth);
|
|
2137
|
-
return;
|
|
2138
|
-
}
|
|
2139
|
-
if (s instanceof z.ZodIntersection) {
|
|
2140
|
-
walk(s.def.left, depth);
|
|
2141
|
-
walk(s.def.right, depth);
|
|
2142
|
-
return;
|
|
2143
|
-
}
|
|
2144
|
-
if (s instanceof z.ZodObject) {
|
|
2145
|
-
noteDepth(depth + 1);
|
|
2146
|
-
for (const key in s.shape) {
|
|
2147
|
-
features.objectPropertyCount += 1;
|
|
2148
|
-
features.stringSizeChars += key.length;
|
|
2149
|
-
walk(s.shape[key], depth + 1);
|
|
2150
|
-
}
|
|
2151
|
-
if (s.def.catchall)
|
|
2152
|
-
walk(s.def.catchall, depth + 1);
|
|
2153
|
-
return;
|
|
2154
|
-
}
|
|
2155
|
-
if (s instanceof z.ZodArray) {
|
|
2156
|
-
noteDepth(depth + 1);
|
|
2157
|
-
walk(s.element, depth + 1);
|
|
2158
|
-
return;
|
|
2159
|
-
}
|
|
2160
|
-
if (s instanceof z.ZodRecord) {
|
|
2161
|
-
features.recordCount += 1;
|
|
2162
|
-
// Widest encoding wins (see `SchemaFeatures.objectPropertyCount`): the
|
|
2163
|
-
// array-of-pairs form is an array of `{key, value}` objects, so two
|
|
2164
|
-
// properties, two property names, and two levels of container.
|
|
2165
|
-
features.objectPropertyCount += 2;
|
|
2166
|
-
features.stringSizeChars += 'key'.length + 'value'.length;
|
|
2167
|
-
noteDepth(depth + 2);
|
|
2168
|
-
if (s.keyType)
|
|
2169
|
-
walk(s.keyType, depth + 2);
|
|
2170
|
-
walk(s.valueType, depth + 2);
|
|
2171
|
-
return;
|
|
2172
|
-
}
|
|
2173
|
-
if (s instanceof z.ZodTuple) {
|
|
2174
|
-
features.tupleCount += 1;
|
|
2175
|
-
// The numeric-key object encoding emits one property per item, named
|
|
2176
|
-
// "0", "1", … — one character each until there are ten of them, which
|
|
2177
|
-
// is close enough for a 120,000-character budget.
|
|
2178
|
-
features.objectPropertyCount += s.def.items.length;
|
|
2179
|
-
features.stringSizeChars += s.def.items.length;
|
|
2180
|
-
noteDepth(depth + 1);
|
|
2181
|
-
for (const item of s.def.items)
|
|
2182
|
-
walk(item, depth + 1);
|
|
2183
|
-
if (s.def.rest)
|
|
2184
|
-
walk(s.def.rest, depth + 1);
|
|
2185
|
-
return;
|
|
2186
|
-
}
|
|
2187
|
-
if (s instanceof z.ZodDefault) {
|
|
2188
|
-
walk(s.def.innerType, depth);
|
|
2189
|
-
return;
|
|
2190
|
-
}
|
|
2191
|
-
if (s instanceof z.ZodCodec || s instanceof z.ZodPipe) {
|
|
2192
|
-
walk(s.def.in, depth);
|
|
2193
|
-
walk(s.def.out, depth);
|
|
2194
|
-
return;
|
|
2195
|
-
}
|
|
2196
|
-
if (s instanceof z.ZodEnum) {
|
|
2197
|
-
const values = enumEmittedValues(s);
|
|
2198
|
-
const chars = stringChars(values);
|
|
2199
|
-
enums.push({ valueCount: values.length, stringValueChars: chars });
|
|
2200
|
-
features.stringSizeChars += chars;
|
|
2201
|
-
return;
|
|
2202
|
-
}
|
|
2203
|
-
if (s instanceof z.ZodLiteral) {
|
|
2204
|
-
const values = literalEmittedValues(s);
|
|
2205
|
-
const chars = stringChars(values);
|
|
2206
|
-
// One value emits `const`, several emit `enum` — only the latter is an
|
|
2207
|
-
// enum for the purpose of a dialect's enum-value budget, but both count
|
|
2208
|
-
// toward the total string size.
|
|
2209
|
-
if (values.length > 1)
|
|
2210
|
-
enums.push({ valueCount: values.length, stringValueChars: chars });
|
|
2211
|
-
features.stringSizeChars += chars;
|
|
2212
|
-
return;
|
|
2213
|
-
}
|
|
2214
|
-
// Primitives and unknown leaves contribute nothing.
|
|
2215
|
-
}
|
|
2216
|
-
finally {
|
|
2217
|
-
visiting.delete(key);
|
|
2218
|
-
}
|
|
2219
|
-
}
|
|
2220
|
-
walk(schema, 0);
|
|
2221
|
-
features.enums = Object.freeze(enums);
|
|
2222
|
-
Object.freeze(features);
|
|
2223
|
-
featuresCache.set(schema, features);
|
|
2224
|
-
return features;
|
|
2225
|
-
}
|
|
2226
|
-
/** Shared empty result, so the common "dialect publishes no size limits" path allocates nothing. */
|
|
2227
|
-
const NO_SIZE_PROBLEMS = Object.freeze([]);
|
|
2228
|
-
/**
|
|
2229
|
-
* Report the ways ONE schema exceeds the descriptor's documented per-schema
|
|
2230
|
-
* size limits (`FormatDescriptor.schemaSizeLimits`). Empty means it fits, or
|
|
2231
|
-
* that the dialect publishes no limits.
|
|
2232
|
-
*
|
|
2233
|
-
* This is a FEASIBILITY question, not a budget one: the numbers bound a single
|
|
2234
|
-
* schema, so no other tool in the request can change the answer, and
|
|
2235
|
-
* `SchemaBudget.allocate` therefore treats a hit exactly like an
|
|
2236
|
-
* unrepresentable keyword — that item degrades to the family's non-strict
|
|
2237
|
-
* descriptor and the rest of the request is untouched.
|
|
2238
|
-
*
|
|
2239
|
-
* Exported because "why did my tool silently stop being strict?" needs an
|
|
2240
|
-
* answer a caller can print; each line names the bound and the measurement.
|
|
2241
|
-
*
|
|
2242
|
-
* @example
|
|
2243
|
-
* ```ts
|
|
2244
|
-
* checkSchemaSizeLimits(z.object({ pick: z.enum(tenThousandNames) }), OPENAI_STRICT);
|
|
2245
|
-
* // → ['enum values 10000 exceed maxTotalEnumValues 1000']
|
|
2246
|
-
* ```
|
|
2247
|
-
*/
|
|
2248
|
-
export function checkSchemaSizeLimits(schema, descriptor) {
|
|
2249
|
-
const limits = descriptor.schemaSizeLimits;
|
|
2250
|
-
if (limits === undefined)
|
|
2251
|
-
return NO_SIZE_PROBLEMS;
|
|
2252
|
-
const features = analyzeSchema(schema);
|
|
2253
|
-
const problems = [];
|
|
2254
|
-
if (features.objectPropertyCount > limits.maxObjectProperties) {
|
|
2255
|
-
problems.push(`object properties ${features.objectPropertyCount} exceed maxObjectProperties ${limits.maxObjectProperties}`);
|
|
2256
|
-
}
|
|
2257
|
-
if (features.maxNestingDepth > limits.maxNestingDepth) {
|
|
2258
|
-
problems.push(`nesting depth ${features.maxNestingDepth} exceeds maxNestingDepth ${limits.maxNestingDepth}`);
|
|
2259
|
-
}
|
|
2260
|
-
if (features.stringSizeChars > limits.maxTotalStringChars) {
|
|
2261
|
-
problems.push(`total string characters ${features.stringSizeChars} exceed maxTotalStringChars ${limits.maxTotalStringChars}`);
|
|
2262
|
-
}
|
|
2263
|
-
const totalEnumValues = features.enums.reduce((n, e) => n + e.valueCount, 0);
|
|
2264
|
-
if (totalEnumValues > limits.maxTotalEnumValues) {
|
|
2265
|
-
problems.push(`enum values ${totalEnumValues} exceed maxTotalEnumValues ${limits.maxTotalEnumValues}`);
|
|
2266
|
-
}
|
|
2267
|
-
// The large-enum character rule is per-enum and conditional on that SAME
|
|
2268
|
-
// enum's value count, which is why the walk keeps the breakdown instead of
|
|
2269
|
-
// two totals.
|
|
2270
|
-
for (const e of features.enums) {
|
|
2271
|
-
if (e.valueCount > limits.largeEnumValueCount && e.stringValueChars > limits.maxLargeEnumStringChars) {
|
|
2272
|
-
problems.push(`an enum of ${e.valueCount} values (over largeEnumValueCount ${limits.largeEnumValueCount}) spends ` +
|
|
2273
|
-
`${e.stringValueChars} characters, over maxLargeEnumStringChars ${limits.maxLargeEnumStringChars}`);
|
|
2274
|
-
}
|
|
2275
|
-
}
|
|
2276
|
-
return problems;
|
|
2277
|
-
}
|
|
2278
|
-
/**
|
|
2279
|
-
* Per-request strict-mode allocator.
|
|
2280
|
-
*
|
|
2281
|
-
* Constructed once per outgoing request with the chosen model's strictest
|
|
2282
|
-
* descriptor. Tracks remaining slots (strict tools, optional parameters,
|
|
2283
|
-
* union types) against the descriptor's documented per-request limits and
|
|
2284
|
-
* decides per-tool / per-output whether to emit strict or fall back to the
|
|
2285
|
-
* SAME FAMILY's non-strict descriptor (`degraded` — never the family-blind
|
|
2286
|
-
* `LENIENT`, which would undo dialect rules that outlive strict mode).
|
|
2287
|
-
*
|
|
2288
|
-
* Selection guarantees that any tool with `strict: true` (hard requirement)
|
|
2289
|
-
* has a model that supports the family — `allocate` therefore always grants
|
|
2290
|
-
* strict for `requested === true` (subject only to feasibility, not budget;
|
|
2291
|
-
* if `true` items overflow the budget the API call will fail, which is the
|
|
2292
|
-
* correct behavior for hard requirements).
|
|
2293
|
-
*
|
|
2294
|
-
* Numeric `strict: <priority>` items are allocated greedily after `true`
|
|
2295
|
-
* items have consumed their share of the budget. Caller is expected to
|
|
2296
|
-
* pre-sort by descending priority before iterating.
|
|
2297
|
-
*/
|
|
2298
|
-
export class SchemaBudget {
|
|
2299
|
-
descriptor;
|
|
2300
|
-
/**
|
|
2301
|
-
* What an item that does NOT get strict is emitted through: the descriptor's
|
|
2302
|
-
* own family, non-strict.
|
|
2303
|
-
*
|
|
2304
|
-
* It is deliberately NOT the family-blind `LENIENT`. A dialect restriction
|
|
2305
|
-
* can belong to the DIALECT rather than to strict mode — `GOOGLE_NON_STRICT`
|
|
2306
|
-
* exists for exactly one such rule, the `unconstrained` encoding of
|
|
2307
|
-
* `z.any()`, because Gemini builds a decoding grammar whenever a tool call is
|
|
2308
|
-
* forced, with no per-tool strict flag involved. Degrading to `LENIENT` put
|
|
2309
|
-
* the self-referencing `$defs/Any` that encoding was created to avoid back on
|
|
2310
|
-
* the Google wire — and it did so for every gin/query-style recursive `anyOf`
|
|
2311
|
-
* schema, i.e. the schemas that ALWAYS degrade, since `GOOGLE_STRICT` cannot
|
|
2312
|
-
* express `anyOf` at all (measured: the product's real `api_set` tool schema
|
|
2313
|
-
* allocates to `lenient` under `GOOGLE_STRICT`, and its emitted JSON carries
|
|
2314
|
-
* nine `#/$defs/Any` references).
|
|
2315
|
-
*
|
|
2316
|
-
* For the other families this is behaviour-neutral by construction:
|
|
2317
|
-
* `OPENAI_NON_STRICT` and `ANTHROPIC_NON_STRICT` are `LENIENT` with a family
|
|
2318
|
-
* tag, and a family with no registered non-strict variant resolves to
|
|
2319
|
-
* `LENIENT` (`getDescriptor`). Only the pinned `descriptor` id on the tool
|
|
2320
|
-
* changes, which round-trips through `getDescriptorById`.
|
|
2321
|
-
*/
|
|
2322
|
-
degraded;
|
|
2323
|
-
remainingTools;
|
|
2324
|
-
remainingOptionalParams;
|
|
2325
|
-
remainingUnionTypes;
|
|
2326
|
-
constructor(descriptor) {
|
|
2327
|
-
this.descriptor = descriptor;
|
|
2328
|
-
// An already-non-strict budget degrades to itself: there is nothing looser
|
|
2329
|
-
// in its family, and `LENIENT`'s own family has no other variant.
|
|
2330
|
-
this.degraded = descriptor.strict ? getDescriptor(descriptor.family, false) : descriptor;
|
|
2331
|
-
this.remainingTools = descriptor.maxStrictTools ?? Infinity;
|
|
2332
|
-
this.remainingOptionalParams = descriptor.maxStrictOptionalParams ?? Infinity;
|
|
2333
|
-
this.remainingUnionTypes = descriptor.maxStrictUnionTypes ?? Infinity;
|
|
2334
|
-
}
|
|
2335
|
-
/**
|
|
2336
|
-
* Decide the effective descriptor for a tool's parameter schema given the
|
|
2337
|
-
* dev's strict request, the schema's features, and the remaining budget.
|
|
2338
|
-
*
|
|
2339
|
-
* Returns the family's NON-STRICT descriptor (`LENIENT` for a family that
|
|
2340
|
-
* registers none) when:
|
|
2341
|
-
* - `requested === false`
|
|
2342
|
-
* - the schema uses a feature the descriptor can't represent
|
|
2343
|
-
* - the schema exceeds the dialect's per-schema size limits
|
|
2344
|
-
* (`checkSchemaSizeLimits`)
|
|
2345
|
-
* - the descriptor has slot limits and the budget is exhausted (only
|
|
2346
|
-
* applies to `requested` being a positive number; `true` always wins
|
|
2347
|
-
* subject to feasibility and size)
|
|
2348
|
-
*
|
|
2349
|
-
* Returns the strict descriptor (and decrements the budget) otherwise.
|
|
2350
|
-
*/
|
|
2351
|
-
allocateTool(schema, requested) {
|
|
2352
|
-
return this.allocate(schema, requested, /* isTool */ true);
|
|
2353
|
-
}
|
|
2354
|
-
/**
|
|
2355
|
-
* Same as `allocateTool` but for the request's structured-output schema.
|
|
2356
|
-
* The output schema doesn't consume a tool slot but it does count toward
|
|
2357
|
-
* the optional-parameter and union-type budgets — descriptors document
|
|
2358
|
-
* those limits as "across all strict schemas in one request".
|
|
2359
|
-
*/
|
|
2360
|
-
allocateOutput(schema, requested) {
|
|
2361
|
-
return this.allocate(schema, requested, /* isTool */ false);
|
|
2362
|
-
}
|
|
2363
|
-
/** Snapshot of remaining budget. Useful for telemetry / debug logs. */
|
|
2364
|
-
remaining() {
|
|
2365
|
-
return {
|
|
2366
|
-
strictTools: this.remainingTools,
|
|
2367
|
-
optionalParams: this.remainingOptionalParams,
|
|
2368
|
-
unionTypes: this.remainingUnionTypes,
|
|
2369
|
-
};
|
|
2370
|
-
}
|
|
2371
|
-
allocate(schema, requested, isTool) {
|
|
2372
|
-
// strict: false → always non-strict, no budget movement.
|
|
2373
|
-
if (requested === false)
|
|
2374
|
-
return this.degraded;
|
|
2375
|
-
// Descriptor itself is lenient → nothing to allocate.
|
|
2376
|
-
if (!this.descriptor.strict)
|
|
2377
|
-
return this.degraded;
|
|
2378
|
-
const isHardRequired = requested === true;
|
|
2379
|
-
const isPreferredNumeric = typeof requested === 'number' && requested > 0;
|
|
2380
|
-
if (!isHardRequired && !isPreferredNumeric) {
|
|
2381
|
-
// requested is undefined or 0 — treat as no preference; default to
|
|
2382
|
-
// non-strict so callers explicitly opt into strict. Decided BEFORE any
|
|
2383
|
-
// walk of `schema`: analysing a schema whose verdict is already
|
|
2384
|
-
// non-strict is pure waste on the hot path of every tool of every request.
|
|
2385
|
-
return this.degraded;
|
|
2386
|
-
}
|
|
2387
|
-
// FEASIBILITY, before budget — the second clause of this method's contract,
|
|
2388
|
-
// and the one that was documented but never implemented. Emitting a strict
|
|
2389
|
-
// schema carrying a combinator the descriptor forbids is a guaranteed
|
|
2390
|
-
// provider 400, not a degradation: `GOOGLE_STRICT` forbids `anyOf`, and the
|
|
2391
|
-
// recursive union a gin/query codegen layer produces is nothing but.
|
|
2392
|
-
// Degrading to the family's non-strict descriptor is the documented answer
|
|
2393
|
-
// for a TOOL (unlike a structured OUTPUT, a tool schema has no prompt-text
|
|
2394
|
-
// delivery to fall back to — `applySchemaDeliveryFallback` covers
|
|
2395
|
-
// `responseFormat` only), and it is also what keeps the API-level
|
|
2396
|
-
// `strict: true` flag off the wire, since providers set it from
|
|
2397
|
-
// `descriptor.strict`.
|
|
2398
|
-
//
|
|
2399
|
-
// `sendableUnder`, not `canExpress`: a cycle is always sendable (the
|
|
2400
|
-
// emitter's bounded placeholder), only looser — see that function's note on
|
|
2401
|
-
// why the two questions are different.
|
|
2402
|
-
if (!sendableUnder(schema, this.descriptor))
|
|
2403
|
-
return this.degraded;
|
|
2404
|
-
// SIZE, still before budget and still regardless of priority: these bounds
|
|
2405
|
-
// are per-SCHEMA (see `checkSchemaSizeLimits`), so no other item in the
|
|
2406
|
-
// request can make this one fit, and a hard `strict: true` cannot force a
|
|
2407
|
-
// schema the API will refuse to compile.
|
|
2408
|
-
if (checkSchemaSizeLimits(schema, this.descriptor).length > 0)
|
|
2409
|
-
return this.degraded;
|
|
2410
|
-
const features = analyzeSchema(schema);
|
|
2411
|
-
// Soft-priority items must fit the budget. Hard-required items skip
|
|
2412
|
-
// budget checks because selection already promised the model can take
|
|
2413
|
-
// them — if there are too many, that's a request-construction bug we
|
|
2414
|
-
// surface via API error rather than degrading silently.
|
|
2415
|
-
if (isPreferredNumeric) {
|
|
2416
|
-
if (isTool && this.remainingTools <= 0)
|
|
2417
|
-
return this.degraded;
|
|
2418
|
-
if (this.remainingOptionalParams - features.optionalParameterCount < 0)
|
|
2419
|
-
return this.degraded;
|
|
2420
|
-
if (this.remainingUnionTypes - features.unionTypeCount < 0)
|
|
2421
|
-
return this.degraded;
|
|
2422
|
-
}
|
|
2423
|
-
// Allocation succeeded — decrement.
|
|
2424
|
-
if (isTool)
|
|
2425
|
-
this.remainingTools -= 1;
|
|
2426
|
-
this.remainingOptionalParams -= features.optionalParameterCount;
|
|
2427
|
-
this.remainingUnionTypes -= features.unionTypeCount;
|
|
2428
|
-
return this.descriptor;
|
|
2429
|
-
}
|
|
2430
|
-
}
|
|
2431
|
-
/**
|
|
2432
|
-
* Compare two descriptors and return the one with the tighter per-request
|
|
2433
|
-
* budget. Used by providers to pick a single descriptor for a SchemaBudget
|
|
2434
|
-
* shared between `convertTools` and `convertResponseFormat` — Anthropic's
|
|
2435
|
-
* documented limits apply across the whole request, so the strictest
|
|
2436
|
-
* descriptor wins.
|
|
2437
|
-
*
|
|
2438
|
-
* `schemaSizeLimits` deliberately does NOT participate. Those bounds are
|
|
2439
|
-
* per-schema, so they are evaluated against whichever descriptor this returns
|
|
2440
|
-
* rather than shared between the two — and every provider here passes two
|
|
2441
|
-
* descriptors resolved from the SAME family, so there is nothing to break the
|
|
2442
|
-
* tie on. Ranking six size bounds against three slot budgets would be an
|
|
2443
|
-
* invented ordering, not a documented one.
|
|
2444
|
-
*/
|
|
2445
|
-
export function strictestOf(a, b) {
|
|
2446
|
-
// LENIENT is the loosest — pick the strict side if either is strict.
|
|
2447
|
-
if (a.strict && !b.strict)
|
|
2448
|
-
return a;
|
|
2449
|
-
if (b.strict && !a.strict)
|
|
2450
|
-
return b;
|
|
2451
|
-
if (!a.strict && !b.strict)
|
|
2452
|
-
return a;
|
|
2453
|
-
// Both strict — pick the one with the smallest documented limits.
|
|
2454
|
-
const score = (d) => (d.maxStrictTools ?? Infinity) +
|
|
2455
|
-
(d.maxStrictOptionalParams ?? Infinity) +
|
|
2456
|
-
(d.maxStrictUnionTypes ?? Infinity);
|
|
2457
|
-
return score(a) <= score(b) ? a : b;
|
|
2458
|
-
}
|
|
2459
|
-
/**
|
|
2460
|
-
* Convert a `boolean | number | undefined` strict request into a numeric
|
|
2461
|
-
* priority for sorting. Used by providers to allocate strict slots in
|
|
2462
|
-
* descending priority order.
|
|
2463
|
-
*
|
|
2464
|
-
* - `true` → `+Infinity` (hard requirement; always first in line)
|
|
2465
|
-
* - `false` → `-Infinity` (always lenient; never wants strict)
|
|
2466
|
-
* - `number > 0` → the number itself
|
|
2467
|
-
* - `number <= 0` or `undefined` → `0` (no preference)
|
|
2468
|
-
*/
|
|
2469
|
-
export function strictPriority(requested) {
|
|
2470
|
-
if (requested === true)
|
|
2471
|
-
return Infinity;
|
|
2472
|
-
if (requested === false)
|
|
2473
|
-
return -Infinity;
|
|
2474
|
-
if (typeof requested === 'number')
|
|
2475
|
-
return requested > 0 ? requested : 0;
|
|
2476
|
-
return 0;
|
|
2477
|
-
}
|
|
2478
|
-
//# sourceMappingURL=schema.js.map
|