@aeye/core 0.5.2 → 0.5.4
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 +1 -0
- package/aeye-core-schema.md +57 -4
- package/dist/schema.d.ts +281 -14
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +378 -55
- package/dist/schema.js.map +1 -1
- package/package.json +1 -1
package/dist/schema.js
CHANGED
|
@@ -46,10 +46,28 @@ export const LENIENT = Object.freeze({
|
|
|
46
46
|
* tuples→numeric-keys, optional→nullable.
|
|
47
47
|
*
|
|
48
48
|
* No documented per-request slot limit (max strict tools / optional params /
|
|
49
|
-
* union types)
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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.
|
|
53
71
|
*/
|
|
54
72
|
export const OPENAI_STRICT = Object.freeze({
|
|
55
73
|
id: 'openai-strict',
|
|
@@ -74,6 +92,17 @@ export const OPENAI_STRICT = Object.freeze({
|
|
|
74
92
|
optionalAsNullable: true,
|
|
75
93
|
anyEncoding: 'recursive-strict',
|
|
76
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
|
+
}),
|
|
77
106
|
});
|
|
78
107
|
/** OpenAI non-strict — alias of LENIENT but tagged with the openai family. */
|
|
79
108
|
export const OPENAI_NON_STRICT = Object.freeze({
|
|
@@ -163,10 +192,21 @@ export const ANTHROPIC_NON_STRICT = Object.freeze({
|
|
|
163
192
|
* this descriptor used to declare was a schema it says it cannot emit.
|
|
164
193
|
* Gemini compiles the tool schemas into a decoding grammar whenever the
|
|
165
194
|
* caller forces a tool call (`toolChoice: 'required'` → Google's function
|
|
166
|
-
* calling mode `ANY`), and a self-referencing `$defs/Any`
|
|
167
|
-
* compile with `400 INVALID_ARGUMENT` — the same request
|
|
195
|
+
* calling mode `ANY`), and a self-referencing `$defs/Any` failed that
|
|
196
|
+
* compile with `400 INVALID_ARGUMENT` — the same request succeeded under
|
|
168
197
|
* `toolChoice: 'auto'`, where no grammar is built. Reported against
|
|
169
|
-
* `google/gemini-3-flash-preview` via OpenRouter
|
|
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.
|
|
170
210
|
*/
|
|
171
211
|
export const GOOGLE_STRICT = Object.freeze({
|
|
172
212
|
id: 'google-strict',
|
|
@@ -192,7 +232,11 @@ export const GOOGLE_STRICT = Object.freeze({
|
|
|
192
232
|
// The ONLY encoding expressible under `allowAnyOf: false` +
|
|
193
233
|
// `allowDefsRef: false` — see the descriptor doc above.
|
|
194
234
|
anyEncoding: 'unconstrained',
|
|
195
|
-
// No documented per-request slot limits.
|
|
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,
|
|
196
240
|
supportsRecursion: true,
|
|
197
241
|
// Gemini's structured-output endpoint rejects `anyOf`/`$defs` schemas
|
|
198
242
|
// (HTTP 400). When a schema can't be expressed here it's delivered as
|
|
@@ -220,6 +264,10 @@ export const GOOGLE_NON_STRICT = Object.freeze({
|
|
|
220
264
|
id: 'google-non-strict',
|
|
221
265
|
family: 'google',
|
|
222
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,
|
|
223
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.',
|
|
224
272
|
});
|
|
225
273
|
// ============================================================================
|
|
@@ -238,7 +286,8 @@ const ANY_ENCODING_REQUIREMENTS = Object.freeze({
|
|
|
238
286
|
});
|
|
239
287
|
/**
|
|
240
288
|
* Report the ways a `FormatDescriptor` contradicts itself — settings that ask
|
|
241
|
-
* the emitter to produce a keyword the same descriptor forbids
|
|
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
|
|
242
291
|
* human-readable line per problem; an empty array means the descriptor is
|
|
243
292
|
* internally consistent.
|
|
244
293
|
*
|
|
@@ -275,6 +324,58 @@ export function checkDescriptorConsistency(descriptor) {
|
|
|
275
324
|
if (needs.defsRef && !descriptor.supportsRecursion) {
|
|
276
325
|
problems.push(`anyEncoding '${descriptor.anyEncoding}' emits a self-referencing \`$defs/Any\`, but supportsRecursion is false`);
|
|
277
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
|
+
}
|
|
278
379
|
return problems;
|
|
279
380
|
}
|
|
280
381
|
// ============================================================================
|
|
@@ -1133,7 +1234,17 @@ function convert(schema, context) {
|
|
|
1133
1234
|
finally {
|
|
1134
1235
|
context.inProgress.delete(id);
|
|
1135
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;
|
|
1136
1244
|
Object.assign(result, metadata);
|
|
1245
|
+
if (emittedNote !== undefined && metadata.description !== undefined && metadata.description !== emittedNote) {
|
|
1246
|
+
result.description = `${metadata.description} ${emittedNote}`;
|
|
1247
|
+
}
|
|
1137
1248
|
delete result.aid;
|
|
1138
1249
|
// Promote it because user requested it or it's recursive
|
|
1139
1250
|
if (save || context.definitionSchemas.has(id)) {
|
|
@@ -1373,6 +1484,61 @@ function uniqueByJSON(nodes) {
|
|
|
1373
1484
|
}
|
|
1374
1485
|
return out;
|
|
1375
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
|
+
}
|
|
1376
1542
|
/**
|
|
1377
1543
|
* Main conversion function
|
|
1378
1544
|
*/
|
|
@@ -1558,26 +1724,33 @@ function convertSchema(schema, context) {
|
|
|
1558
1724
|
}
|
|
1559
1725
|
// Handle ZodEnum
|
|
1560
1726
|
if (schema instanceof z.ZodEnum) {
|
|
1561
|
-
const
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
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);
|
|
1565
1732
|
return {
|
|
1566
1733
|
type: values.every((v) => typeof v === 'number')
|
|
1567
1734
|
? 'number'
|
|
1568
1735
|
: values.every((v) => typeof v === 'string')
|
|
1569
1736
|
? 'string'
|
|
1570
1737
|
: undefined,
|
|
1571
|
-
enum: values,
|
|
1738
|
+
...(oversize === undefined ? { enum: values } : { description: oversize }),
|
|
1572
1739
|
};
|
|
1573
1740
|
}
|
|
1574
1741
|
// Handle ZodLiteral
|
|
1575
1742
|
if (schema instanceof z.ZodLiteral) {
|
|
1576
|
-
const values =
|
|
1743
|
+
const values = literalEmittedValues(schema);
|
|
1577
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);
|
|
1578
1749
|
return {
|
|
1579
1750
|
...(types.length === 1 ? { type: types[0] } : {}),
|
|
1580
|
-
...(values.length === 1
|
|
1751
|
+
...(values.length === 1
|
|
1752
|
+
? { const: values[0] }
|
|
1753
|
+
: oversize === undefined ? { enum: values } : { description: oversize }),
|
|
1581
1754
|
};
|
|
1582
1755
|
}
|
|
1583
1756
|
// Handle ZodDefault
|
|
@@ -1891,6 +2064,10 @@ const ZERO_FEATURES = Object.freeze({
|
|
|
1891
2064
|
unionTypeCount: 0,
|
|
1892
2065
|
recordCount: 0,
|
|
1893
2066
|
tupleCount: 0,
|
|
2067
|
+
enums: Object.freeze([]),
|
|
2068
|
+
objectPropertyCount: 0,
|
|
2069
|
+
maxNestingDepth: 0,
|
|
2070
|
+
stringSizeChars: 0,
|
|
1894
2071
|
});
|
|
1895
2072
|
const featuresCache = new WeakMap();
|
|
1896
2073
|
/**
|
|
@@ -1911,7 +2088,21 @@ export function analyzeSchema(schema) {
|
|
|
1911
2088
|
return cached;
|
|
1912
2089
|
const visiting = new Set();
|
|
1913
2090
|
const features = { ...ZERO_FEATURES };
|
|
1914
|
-
|
|
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) {
|
|
1915
2106
|
if (!s)
|
|
1916
2107
|
return;
|
|
1917
2108
|
const key = walkKey(s);
|
|
@@ -1924,65 +2115,100 @@ export function analyzeSchema(schema) {
|
|
|
1924
2115
|
if (s instanceof z.ZodLazy) {
|
|
1925
2116
|
features.hasRecursion = true;
|
|
1926
2117
|
try {
|
|
1927
|
-
walk(s.def.getter());
|
|
2118
|
+
walk(s.def.getter(), depth);
|
|
1928
2119
|
}
|
|
1929
2120
|
catch { /* lazy getter may throw at analyze time; ignore */ }
|
|
1930
2121
|
return;
|
|
1931
2122
|
}
|
|
1932
2123
|
if (s instanceof z.ZodOptional) {
|
|
1933
2124
|
features.optionalParameterCount += 1;
|
|
1934
|
-
walk(s.unwrap());
|
|
2125
|
+
walk(s.unwrap(), depth);
|
|
1935
2126
|
return;
|
|
1936
2127
|
}
|
|
1937
2128
|
if (s instanceof z.ZodNullable) {
|
|
1938
2129
|
features.unionTypeCount += 1; // T|null counts as a union on the wire
|
|
1939
|
-
walk(s.unwrap());
|
|
2130
|
+
walk(s.unwrap(), depth);
|
|
1940
2131
|
return;
|
|
1941
2132
|
}
|
|
1942
2133
|
if (s instanceof z.ZodUnion || s instanceof z.ZodDiscriminatedUnion) {
|
|
1943
2134
|
features.unionTypeCount += 1;
|
|
1944
2135
|
for (const opt of s.options)
|
|
1945
|
-
walk(opt);
|
|
2136
|
+
walk(opt, depth);
|
|
1946
2137
|
return;
|
|
1947
2138
|
}
|
|
1948
2139
|
if (s instanceof z.ZodIntersection) {
|
|
1949
|
-
walk(s.def.left);
|
|
1950
|
-
walk(s.def.right);
|
|
2140
|
+
walk(s.def.left, depth);
|
|
2141
|
+
walk(s.def.right, depth);
|
|
1951
2142
|
return;
|
|
1952
2143
|
}
|
|
1953
2144
|
if (s instanceof z.ZodObject) {
|
|
1954
|
-
|
|
1955
|
-
|
|
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
|
+
}
|
|
1956
2151
|
if (s.def.catchall)
|
|
1957
|
-
walk(s.def.catchall);
|
|
2152
|
+
walk(s.def.catchall, depth + 1);
|
|
1958
2153
|
return;
|
|
1959
2154
|
}
|
|
1960
2155
|
if (s instanceof z.ZodArray) {
|
|
1961
|
-
|
|
2156
|
+
noteDepth(depth + 1);
|
|
2157
|
+
walk(s.element, depth + 1);
|
|
1962
2158
|
return;
|
|
1963
2159
|
}
|
|
1964
2160
|
if (s instanceof z.ZodRecord) {
|
|
1965
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);
|
|
1966
2168
|
if (s.keyType)
|
|
1967
|
-
walk(s.keyType);
|
|
1968
|
-
walk(s.valueType);
|
|
2169
|
+
walk(s.keyType, depth + 2);
|
|
2170
|
+
walk(s.valueType, depth + 2);
|
|
1969
2171
|
return;
|
|
1970
2172
|
}
|
|
1971
2173
|
if (s instanceof z.ZodTuple) {
|
|
1972
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);
|
|
1973
2181
|
for (const item of s.def.items)
|
|
1974
|
-
walk(item);
|
|
2182
|
+
walk(item, depth + 1);
|
|
1975
2183
|
if (s.def.rest)
|
|
1976
|
-
walk(s.def.rest);
|
|
2184
|
+
walk(s.def.rest, depth + 1);
|
|
1977
2185
|
return;
|
|
1978
2186
|
}
|
|
1979
2187
|
if (s instanceof z.ZodDefault) {
|
|
1980
|
-
walk(s.def.innerType);
|
|
2188
|
+
walk(s.def.innerType, depth);
|
|
1981
2189
|
return;
|
|
1982
2190
|
}
|
|
1983
2191
|
if (s instanceof z.ZodCodec || s instanceof z.ZodPipe) {
|
|
1984
|
-
walk(s.def.in);
|
|
1985
|
-
walk(s.def.out);
|
|
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;
|
|
1986
2212
|
return;
|
|
1987
2213
|
}
|
|
1988
2214
|
// Primitives and unknown leaves contribute nothing.
|
|
@@ -1991,19 +2217,73 @@ export function analyzeSchema(schema) {
|
|
|
1991
2217
|
visiting.delete(key);
|
|
1992
2218
|
}
|
|
1993
2219
|
}
|
|
1994
|
-
walk(schema);
|
|
2220
|
+
walk(schema, 0);
|
|
2221
|
+
features.enums = Object.freeze(enums);
|
|
1995
2222
|
Object.freeze(features);
|
|
1996
2223
|
featuresCache.set(schema, features);
|
|
1997
2224
|
return features;
|
|
1998
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
|
+
}
|
|
1999
2278
|
/**
|
|
2000
2279
|
* Per-request strict-mode allocator.
|
|
2001
2280
|
*
|
|
2002
2281
|
* Constructed once per outgoing request with the chosen model's strictest
|
|
2003
2282
|
* descriptor. Tracks remaining slots (strict tools, optional parameters,
|
|
2004
2283
|
* union types) against the descriptor's documented per-request limits and
|
|
2005
|
-
* decides per-tool / per-output whether to emit strict or fall back to
|
|
2006
|
-
*
|
|
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).
|
|
2007
2287
|
*
|
|
2008
2288
|
* Selection guarantees that any tool with `strict: true` (hard requirement)
|
|
2009
2289
|
* has a model that supports the family — `allocate` therefore always grants
|
|
@@ -2017,11 +2297,37 @@ export function analyzeSchema(schema) {
|
|
|
2017
2297
|
*/
|
|
2018
2298
|
export class SchemaBudget {
|
|
2019
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;
|
|
2020
2323
|
remainingTools;
|
|
2021
2324
|
remainingOptionalParams;
|
|
2022
2325
|
remainingUnionTypes;
|
|
2023
2326
|
constructor(descriptor) {
|
|
2024
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;
|
|
2025
2331
|
this.remainingTools = descriptor.maxStrictTools ?? Infinity;
|
|
2026
2332
|
this.remainingOptionalParams = descriptor.maxStrictOptionalParams ?? Infinity;
|
|
2027
2333
|
this.remainingUnionTypes = descriptor.maxStrictUnionTypes ?? Infinity;
|
|
@@ -2030,12 +2336,15 @@ export class SchemaBudget {
|
|
|
2030
2336
|
* Decide the effective descriptor for a tool's parameter schema given the
|
|
2031
2337
|
* dev's strict request, the schema's features, and the remaining budget.
|
|
2032
2338
|
*
|
|
2033
|
-
* Returns `LENIENT`
|
|
2339
|
+
* Returns the family's NON-STRICT descriptor (`LENIENT` for a family that
|
|
2340
|
+
* registers none) when:
|
|
2034
2341
|
* - `requested === false`
|
|
2035
2342
|
* - the schema uses a feature the descriptor can't represent
|
|
2343
|
+
* - the schema exceeds the dialect's per-schema size limits
|
|
2344
|
+
* (`checkSchemaSizeLimits`)
|
|
2036
2345
|
* - the descriptor has slot limits and the budget is exhausted (only
|
|
2037
2346
|
* applies to `requested` being a positive number; `true` always wins
|
|
2038
|
-
* subject to feasibility)
|
|
2347
|
+
* subject to feasibility and size)
|
|
2039
2348
|
*
|
|
2040
2349
|
* Returns the strict descriptor (and decrements the budget) otherwise.
|
|
2041
2350
|
*/
|
|
@@ -2060,37 +2369,44 @@ export class SchemaBudget {
|
|
|
2060
2369
|
};
|
|
2061
2370
|
}
|
|
2062
2371
|
allocate(schema, requested, isTool) {
|
|
2063
|
-
// strict: false → always
|
|
2372
|
+
// strict: false → always non-strict, no budget movement.
|
|
2064
2373
|
if (requested === false)
|
|
2065
|
-
return
|
|
2374
|
+
return this.degraded;
|
|
2066
2375
|
// Descriptor itself is lenient → nothing to allocate.
|
|
2067
2376
|
if (!this.descriptor.strict)
|
|
2068
|
-
return
|
|
2377
|
+
return this.degraded;
|
|
2069
2378
|
const isHardRequired = requested === true;
|
|
2070
2379
|
const isPreferredNumeric = typeof requested === 'number' && requested > 0;
|
|
2071
2380
|
if (!isHardRequired && !isPreferredNumeric) {
|
|
2072
2381
|
// requested is undefined or 0 — treat as no preference; default to
|
|
2073
|
-
//
|
|
2074
|
-
// of `schema`: analysing a schema whose verdict is already
|
|
2075
|
-
// pure waste on the hot path of every tool of every request.
|
|
2076
|
-
return
|
|
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;
|
|
2077
2386
|
}
|
|
2078
2387
|
// FEASIBILITY, before budget — the second clause of this method's contract,
|
|
2079
2388
|
// and the one that was documented but never implemented. Emitting a strict
|
|
2080
2389
|
// schema carrying a combinator the descriptor forbids is a guaranteed
|
|
2081
2390
|
// provider 400, not a degradation: `GOOGLE_STRICT` forbids `anyOf`, and the
|
|
2082
2391
|
// recursive union a gin/query codegen layer produces is nothing but.
|
|
2083
|
-
// Degrading to
|
|
2084
|
-
// structured OUTPUT, a tool schema has no prompt-text
|
|
2085
|
-
// to — `applySchemaDeliveryFallback` covers
|
|
2086
|
-
// is also what keeps the API-level
|
|
2087
|
-
// providers set it from
|
|
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`.
|
|
2088
2398
|
//
|
|
2089
2399
|
// `sendableUnder`, not `canExpress`: a cycle is always sendable (the
|
|
2090
2400
|
// emitter's bounded placeholder), only looser — see that function's note on
|
|
2091
2401
|
// why the two questions are different.
|
|
2092
2402
|
if (!sendableUnder(schema, this.descriptor))
|
|
2093
|
-
return
|
|
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;
|
|
2094
2410
|
const features = analyzeSchema(schema);
|
|
2095
2411
|
// Soft-priority items must fit the budget. Hard-required items skip
|
|
2096
2412
|
// budget checks because selection already promised the model can take
|
|
@@ -2098,11 +2414,11 @@ export class SchemaBudget {
|
|
|
2098
2414
|
// surface via API error rather than degrading silently.
|
|
2099
2415
|
if (isPreferredNumeric) {
|
|
2100
2416
|
if (isTool && this.remainingTools <= 0)
|
|
2101
|
-
return
|
|
2417
|
+
return this.degraded;
|
|
2102
2418
|
if (this.remainingOptionalParams - features.optionalParameterCount < 0)
|
|
2103
|
-
return
|
|
2419
|
+
return this.degraded;
|
|
2104
2420
|
if (this.remainingUnionTypes - features.unionTypeCount < 0)
|
|
2105
|
-
return
|
|
2421
|
+
return this.degraded;
|
|
2106
2422
|
}
|
|
2107
2423
|
// Allocation succeeded — decrement.
|
|
2108
2424
|
if (isTool)
|
|
@@ -2118,6 +2434,13 @@ export class SchemaBudget {
|
|
|
2118
2434
|
* shared between `convertTools` and `convertResponseFormat` — Anthropic's
|
|
2119
2435
|
* documented limits apply across the whole request, so the strictest
|
|
2120
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.
|
|
2121
2444
|
*/
|
|
2122
2445
|
export function strictestOf(a, b) {
|
|
2123
2446
|
// LENIENT is the loosest — pick the strict side if either is strict.
|