@aeye/core 0.5.1 → 0.5.3
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 +60 -5
- package/dist/schema.d.ts +286 -15
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +522 -71
- 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,9 @@ 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, and NO `maxEnumValues` — a large
|
|
236
|
+
// `enum` was suspected of causing `400 INVALID_ARGUMENT` here and did not
|
|
237
|
+
// survive measurement. See `FormatDescriptor.maxEnumValues` for the numbers.
|
|
196
238
|
supportsRecursion: true,
|
|
197
239
|
// Gemini's structured-output endpoint rejects `anyOf`/`$defs` schemas
|
|
198
240
|
// (HTTP 400). When a schema can't be expressed here it's delivered as
|
|
@@ -238,7 +280,8 @@ const ANY_ENCODING_REQUIREMENTS = Object.freeze({
|
|
|
238
280
|
});
|
|
239
281
|
/**
|
|
240
282
|
* Report the ways a `FormatDescriptor` contradicts itself — settings that ask
|
|
241
|
-
* the emitter to produce a keyword the same descriptor forbids
|
|
283
|
+
* the emitter to produce a keyword the same descriptor forbids, or a limit
|
|
284
|
+
* that would quietly strip every constraint it governs. Returns one
|
|
242
285
|
* human-readable line per problem; an empty array means the descriptor is
|
|
243
286
|
* internally consistent.
|
|
244
287
|
*
|
|
@@ -275,6 +318,58 @@ export function checkDescriptorConsistency(descriptor) {
|
|
|
275
318
|
if (needs.defsRef && !descriptor.supportsRecursion) {
|
|
276
319
|
problems.push(`anyEncoding '${descriptor.anyEncoding}' emits a self-referencing \`$defs/Any\`, but supportsRecursion is false`);
|
|
277
320
|
}
|
|
321
|
+
// `maxEnumValues` is an independent scalar — there is no other field it can
|
|
322
|
+
// contradict — but a cap below 1 widens EVERY enum the dialect ever emits
|
|
323
|
+
// (including a two-member one), and a fractional cap reads as a threshold
|
|
324
|
+
// nobody intended. Both are silent: the schema stays valid, it just stops
|
|
325
|
+
// constraining anything, which is the "invisible until you read the wire"
|
|
326
|
+
// class this function exists to catch. Only reachable through
|
|
327
|
+
// `registerDescriptor`; the built-ins are asserted clean by the test suite.
|
|
328
|
+
const cap = descriptor.maxEnumValues;
|
|
329
|
+
if (cap !== undefined && (!Number.isInteger(cap) || cap < 1)) {
|
|
330
|
+
problems.push(`maxEnumValues must be a positive integer, got ${cap}`);
|
|
331
|
+
}
|
|
332
|
+
problems.push(...schemaSizeLimitProblems(descriptor));
|
|
333
|
+
return problems;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* The ways a `schemaSizeLimits` group contradicts itself or the descriptor
|
|
337
|
+
* carrying it. Split out only to keep `checkDescriptorConsistency` readable;
|
|
338
|
+
* it is part of the same contract.
|
|
339
|
+
*
|
|
340
|
+
* The failures below are all SILENT: a non-positive bound degrades every
|
|
341
|
+
* schema to lenient (strict mode simply stops happening, with no error
|
|
342
|
+
* anywhere), a threshold above the total makes the large-enum rule
|
|
343
|
+
* unreachable, and limits on a non-strict descriptor describe a rule the API
|
|
344
|
+
* does not apply — each one is a rule that reads as enforced and is not.
|
|
345
|
+
*/
|
|
346
|
+
function schemaSizeLimitProblems(descriptor) {
|
|
347
|
+
const limits = descriptor.schemaSizeLimits;
|
|
348
|
+
if (limits === undefined)
|
|
349
|
+
return [];
|
|
350
|
+
const problems = [];
|
|
351
|
+
if (!descriptor.strict) {
|
|
352
|
+
problems.push('schemaSizeLimits is declared on a non-strict descriptor, but the size rules bound Structured Outputs only');
|
|
353
|
+
}
|
|
354
|
+
// Keyed off the interface, so a bound added to `SchemaSizeLimits` is checked
|
|
355
|
+
// here without an edit — `satisfies` makes a missed key a compile error.
|
|
356
|
+
const bounds = {
|
|
357
|
+
maxObjectProperties: limits.maxObjectProperties,
|
|
358
|
+
maxNestingDepth: limits.maxNestingDepth,
|
|
359
|
+
maxTotalStringChars: limits.maxTotalStringChars,
|
|
360
|
+
maxTotalEnumValues: limits.maxTotalEnumValues,
|
|
361
|
+
largeEnumValueCount: limits.largeEnumValueCount,
|
|
362
|
+
maxLargeEnumStringChars: limits.maxLargeEnumStringChars,
|
|
363
|
+
};
|
|
364
|
+
for (const [name, value] of Object.entries(bounds)) {
|
|
365
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
366
|
+
problems.push(`schemaSizeLimits.${name} must be a positive integer, got ${value}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (limits.largeEnumValueCount >= limits.maxTotalEnumValues) {
|
|
370
|
+
problems.push(`schemaSizeLimits.largeEnumValueCount (${limits.largeEnumValueCount}) is not below maxTotalEnumValues ` +
|
|
371
|
+
`(${limits.maxTotalEnumValues}), so the large-enum character rule can never apply`);
|
|
372
|
+
}
|
|
278
373
|
return problems;
|
|
279
374
|
}
|
|
280
375
|
// ============================================================================
|
|
@@ -404,6 +499,37 @@ export function resolveDescriptor(input) {
|
|
|
404
499
|
const opts = input;
|
|
405
500
|
return getDescriptor(opts.format ?? 'openai', opts.strict);
|
|
406
501
|
}
|
|
502
|
+
/**
|
|
503
|
+
* The key an EAGER walk's cycle guard must use for `s`.
|
|
504
|
+
*
|
|
505
|
+
* Object identity alone is a broken guard, because a `z.lazy` is only required
|
|
506
|
+
* to return *a* schema — not the *same* schema object — from its getter. A
|
|
507
|
+
* codegen layer that derives zod from a live type registry rebuilds the subtree
|
|
508
|
+
* on every call (`@aeye/gin`'s `buildSchemas` does), so each re-entry into a
|
|
509
|
+
* recursive node is a FRESH object: an identity guard never fires, the walk
|
|
510
|
+
* descends forever, and because every level allocates a whole new subtree
|
|
511
|
+
* (measured ~9.4 KB/level) the process dies of heap exhaustion —
|
|
512
|
+
* `FATAL ERROR: Ineffective mark-compacts near heap limit` — rather than
|
|
513
|
+
* overflowing the stack. `convert()` already survives this by matching a cached
|
|
514
|
+
* definition on the `aid` such a layer stamps on the node (see its `ZodLazy`
|
|
515
|
+
* branch), so the eager walkers key on the SAME identity: a declared
|
|
516
|
+
* `aid`/`id` names the definition, and only an unnamed node falls back to
|
|
517
|
+
* object identity.
|
|
518
|
+
*
|
|
519
|
+
* The two IDs are read in `convert()`'s own precedence (`aid` then `id`), and
|
|
520
|
+
* carry the same meaning here that they do there: two nodes sharing one
|
|
521
|
+
* declared id ARE one definition.
|
|
522
|
+
*/
|
|
523
|
+
function walkKey(s) {
|
|
524
|
+
if (!(s instanceof z.ZodType))
|
|
525
|
+
return s;
|
|
526
|
+
const meta = s.meta();
|
|
527
|
+
const declared = meta?.aid ?? meta?.id;
|
|
528
|
+
// Prefixed so a declared id can never be mistaken for anything else in the
|
|
529
|
+
// guard's key space (the alternative member is an object, so this is belt +
|
|
530
|
+
// braces against a future string-keyed member).
|
|
531
|
+
return typeof declared === 'string' ? `id:${declared}` : s;
|
|
532
|
+
}
|
|
407
533
|
/**
|
|
408
534
|
* Decide whether `schema` can be expressed as structured output under
|
|
409
535
|
* `descriptor`'s JSON-Schema dialect. Returns `false` when the schema uses a
|
|
@@ -427,26 +553,58 @@ export function resolveDescriptor(input) {
|
|
|
427
553
|
* feasibility checks are easy to add.
|
|
428
554
|
*/
|
|
429
555
|
export function canExpress(schema, descriptor) {
|
|
430
|
-
|
|
556
|
+
return expressible(schema, descriptor, /* cycleBreakerCounts */ false);
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* `canExpress`, except that a cycle counts as EXPRESSIBLE whatever the
|
|
560
|
+
* descriptor's ref flags say, because `emitCachedRef` replaces any back-edge
|
|
561
|
+
* the descriptor cannot spell with the bounded `buildCycleBreakerSchema`
|
|
562
|
+
* placeholder. The emitted schema is therefore always *sendable*; it is only
|
|
563
|
+
* *looser* than the source at the back-edge.
|
|
564
|
+
*
|
|
565
|
+
* That distinction is the whole reason there are two predicates:
|
|
566
|
+
* - `canExpress` answers "will the model be properly CONSTRAINED by this?",
|
|
567
|
+
* which is what a structured-OUTPUT delivery decision needs — a back-edge
|
|
568
|
+
* silently widened to "any" is a good reason to deliver the schema as prompt
|
|
569
|
+
* text instead (`applySchemaDeliveryFallback`), and
|
|
570
|
+
* `ANTHROPIC_STRICT`'s `supportsRecursion: false` is exactly that case.
|
|
571
|
+
* - this one answers "will the provider REJECT this?", which is what a TOOL's
|
|
572
|
+
* strict allocation needs. A tool has no prompt-text fallback, so degrading a
|
|
573
|
+
* merely-loose schema to LENIENT would trade a real strict guarantee for
|
|
574
|
+
* nothing.
|
|
575
|
+
*
|
|
576
|
+
* The combinator half is shared and is the half that actually bites: nothing
|
|
577
|
+
* rewrites a forbidden `anyOf`/`allOf` away, so a schema carrying one is a
|
|
578
|
+
* provider 400 under a descriptor that forbids it.
|
|
579
|
+
*/
|
|
580
|
+
function sendableUnder(schema, descriptor) {
|
|
581
|
+
return expressible(schema, descriptor, /* cycleBreakerCounts */ true);
|
|
582
|
+
}
|
|
583
|
+
function expressible(schema, descriptor, cycleBreakerCounts) {
|
|
584
|
+
// Keys, not objects — a rebuilt `z.lazy` getter defeats identity (see `walkKey`).
|
|
585
|
+
const rootKey = walkKey(schema);
|
|
431
586
|
// Nodes currently on the DFS stack — a re-encounter is a recursion cycle.
|
|
432
587
|
const inProgress = new Set();
|
|
433
588
|
// Completed nodes with their result, so shared (non-cyclic) sub-schemas
|
|
434
589
|
// aren't re-walked (guards against exponential blow-up on DAG-shaped schemas).
|
|
435
590
|
const completed = new Map();
|
|
436
591
|
const walk = (s) => {
|
|
437
|
-
|
|
592
|
+
const key = walkKey(s);
|
|
593
|
+
if (inProgress.has(key)) {
|
|
438
594
|
// Recursion cycle detected at `s`.
|
|
439
|
-
if (
|
|
595
|
+
if (cycleBreakerCounts)
|
|
596
|
+
return true;
|
|
597
|
+
if (key === rootKey)
|
|
440
598
|
return descriptor.allowRootRef && descriptor.supportsRecursion;
|
|
441
599
|
return descriptor.allowDefsRef && descriptor.supportsRecursion;
|
|
442
600
|
}
|
|
443
|
-
const cached = completed.get(
|
|
601
|
+
const cached = completed.get(key);
|
|
444
602
|
if (cached !== undefined)
|
|
445
603
|
return cached;
|
|
446
|
-
inProgress.add(
|
|
604
|
+
inProgress.add(key);
|
|
447
605
|
const result = walkNode(s);
|
|
448
|
-
inProgress.delete(
|
|
449
|
-
completed.set(
|
|
606
|
+
inProgress.delete(key);
|
|
607
|
+
completed.set(key, result);
|
|
450
608
|
return result;
|
|
451
609
|
};
|
|
452
610
|
const walkNode = (s) => {
|
|
@@ -1045,9 +1203,16 @@ function convert(schema, context) {
|
|
|
1045
1203
|
// If the schema has an 'aid' or 'id' in meta, promote it to a definition.
|
|
1046
1204
|
// A cache-bypassed "any" node is never promoted — naming a schema that
|
|
1047
1205
|
// asserts nothing buys nothing, and the `$defs` entry is precisely what the
|
|
1048
|
-
// inline encoding is avoiding.
|
|
1206
|
+
// inline encoding is avoiding. A descriptor that forbids `$defs` outright
|
|
1207
|
+
// (`allowDefsRef: false` — Google strict) is never promoted to either: the id
|
|
1208
|
+
// is still computed, because the cycle-breaker's description names it, but
|
|
1209
|
+
// the definition is inlined at each use site, which is the only spelling that
|
|
1210
|
+
// dialect has for a shared node.
|
|
1049
1211
|
const id = (metadata.aid ? String(metadata.aid) : 0) || metadata.id || `__schema${context.refCounter++}`;
|
|
1050
|
-
const save = !bypassCache
|
|
1212
|
+
const save = !bypassCache
|
|
1213
|
+
&& !!(metadata.aid || metadata.id)
|
|
1214
|
+
&& context.root !== schema
|
|
1215
|
+
&& context.descriptor.allowDefsRef;
|
|
1051
1216
|
// A schema target - will hold either the converted schema or a $ref
|
|
1052
1217
|
const target = {};
|
|
1053
1218
|
// Before converting, register this schema to handle recursion
|
|
@@ -1063,7 +1228,17 @@ function convert(schema, context) {
|
|
|
1063
1228
|
finally {
|
|
1064
1229
|
context.inProgress.delete(id);
|
|
1065
1230
|
}
|
|
1231
|
+
// A description the CONVERTER produced describes the EMITTED node, not the
|
|
1232
|
+
// source schema — today that is only the `maxEnumValues` widening, whose
|
|
1233
|
+
// description IS the value list that replaced the `enum` keyword. The source's
|
|
1234
|
+
// own `.describe()` must not silently replace it, or a model handed a widened
|
|
1235
|
+
// `{type:'string'}` is told nothing about which strings are legal. Keep both,
|
|
1236
|
+
// converter note last.
|
|
1237
|
+
const emittedNote = result.description;
|
|
1066
1238
|
Object.assign(result, metadata);
|
|
1239
|
+
if (emittedNote !== undefined && metadata.description !== undefined && metadata.description !== emittedNote) {
|
|
1240
|
+
result.description = `${metadata.description} ${emittedNote}`;
|
|
1241
|
+
}
|
|
1067
1242
|
delete result.aid;
|
|
1068
1243
|
// Promote it because user requested it or it's recursive
|
|
1069
1244
|
if (save || context.definitionSchemas.has(id)) {
|
|
@@ -1083,11 +1258,21 @@ function convert(schema, context) {
|
|
|
1083
1258
|
*
|
|
1084
1259
|
* Three cases:
|
|
1085
1260
|
* 1. Re-encounter while still on the conversion stack (`inProgress.has(id)`)
|
|
1086
|
-
* AND the descriptor
|
|
1087
|
-
* placeholder via `buildCycleBreakerSchema()` (number
|
|
1088
|
-
* stays array, etc.) with a description naming the
|
|
1089
|
-
* have referenced. This breaks the cycle inline so
|
|
1090
|
-
* Anthropic don't reject the schema.
|
|
1261
|
+
* AND the descriptor cannot express the `$ref` this back-edge would need —
|
|
1262
|
+
* emit a shape-aware placeholder via `buildCycleBreakerSchema()` (number
|
|
1263
|
+
* stays number, array stays array, etc.) with a description naming the
|
|
1264
|
+
* `$defs` entry it would have referenced. This breaks the cycle inline so
|
|
1265
|
+
* providers like Anthropic don't reject the schema.
|
|
1266
|
+
*
|
|
1267
|
+
* "Cannot express" is TWO flags, not one, because a back-edge is only ever
|
|
1268
|
+
* spelled one of two ways: `{$ref: '#'}` at the root, or `{$ref:
|
|
1269
|
+
* '#/$defs/X'}` anywhere else. `!supportsRecursion` rules out both.
|
|
1270
|
+
* `!allowDefsRef` rules out the second — so a NON-ROOT cycle under a
|
|
1271
|
+
* descriptor that forbids `$defs` (Google strict is exactly that:
|
|
1272
|
+
* `supportsRecursion: true`, `allowRootRef: true`, `allowDefsRef: false`)
|
|
1273
|
+
* must take the same bounded placeholder. Checking only `supportsRecursion`
|
|
1274
|
+
* silently emitted the `$defs`/`$ref` pair the descriptor declares it cannot
|
|
1275
|
+
* send — a schema the provider answers with HTTP 400.
|
|
1091
1276
|
* 2. Re-encounter at root with `allowRootRef` — emit `{$ref: '#'}`.
|
|
1092
1277
|
* 3. Otherwise — emit `{$ref: '#/$defs/<id>'}`, promoting the original
|
|
1093
1278
|
* target to a `$defs` entry on first share.
|
|
@@ -1095,7 +1280,10 @@ function convert(schema, context) {
|
|
|
1095
1280
|
function emitCachedRef(schema, cachedJs, cachedId, context) {
|
|
1096
1281
|
const descriptor = context.descriptor;
|
|
1097
1282
|
const isCycle = context.inProgress.has(cachedId);
|
|
1098
|
-
|
|
1283
|
+
const isRoot = schema === context.root;
|
|
1284
|
+
// The spelling this back-edge would need, and whether the descriptor has it.
|
|
1285
|
+
const backEdgeExpressible = isRoot ? descriptor.allowRootRef : descriptor.allowDefsRef;
|
|
1286
|
+
if (isCycle && (!descriptor.supportsRecursion || !backEdgeExpressible)) {
|
|
1099
1287
|
// Replace the back-edge with a placeholder whose top-level shape matches
|
|
1100
1288
|
// the recursive zod schema. The original `cachedJs` target keeps filling
|
|
1101
1289
|
// in normally — once conversion completes the `$defs` entry exists; we
|
|
@@ -1103,9 +1291,30 @@ function emitCachedRef(schema, cachedJs, cachedId, context) {
|
|
|
1103
1291
|
// the provider rejects.
|
|
1104
1292
|
return buildCycleBreakerSchema(schema, cachedId, descriptor);
|
|
1105
1293
|
}
|
|
1106
|
-
if (
|
|
1294
|
+
if (isRoot && descriptor.allowRootRef) {
|
|
1107
1295
|
return { $ref: `#` };
|
|
1108
1296
|
}
|
|
1297
|
+
// A non-cyclic re-encounter under a descriptor with no `$defs`: INLINE the
|
|
1298
|
+
// already-converted shape instead of naming it. There is no third spelling in
|
|
1299
|
+
// that dialect — a shared node is either repeated or it is a `$ref` the
|
|
1300
|
+
// provider rejects. Safe and finite: `!isCycle` means this node's conversion
|
|
1301
|
+
// already completed, and any cycle BELOW it was replaced by the bounded
|
|
1302
|
+
// placeholder above, so the inlined value is a finite tree. The cost is
|
|
1303
|
+
// duplication in the emitted schema, which is what that dialect charges for
|
|
1304
|
+
// shared structure.
|
|
1305
|
+
//
|
|
1306
|
+
// On a schema the dialect can actually SEND that cost is negligible (measured
|
|
1307
|
+
// on the cases in `schema-google-defs.test.ts`: 497→435, 627→565, 280→322
|
|
1308
|
+
// bytes — inlining a leaf is often smaller than naming it). It is only large
|
|
1309
|
+
// on a heavily-shared recursive DAG — a gin `buildSchemas().Expr` renders
|
|
1310
|
+
// 44 KB with `$defs` and 1.36 MB without — and such a schema carries 1646
|
|
1311
|
+
// `anyOf`s, so `canExpress` already answers `false` for it and no provider
|
|
1312
|
+
// path reaches here. Paying size on a schema that must not be sent anyway is
|
|
1313
|
+
// the right side of the trade against emitting `$defs` the descriptor
|
|
1314
|
+
// declares it cannot send.
|
|
1315
|
+
if (!descriptor.allowDefsRef) {
|
|
1316
|
+
return { ...cachedJs };
|
|
1317
|
+
}
|
|
1109
1318
|
if (!context.definitionSchemas.has(cachedId)) {
|
|
1110
1319
|
context.definitionSchemas.set(cachedId, { ...cachedJs });
|
|
1111
1320
|
for (const prop in cachedJs) {
|
|
@@ -1126,10 +1335,14 @@ function emitCachedRef(schema, cachedJs, cachedId, context) {
|
|
|
1126
1335
|
*/
|
|
1127
1336
|
function buildCycleBreakerSchema(schema, refId, descriptor) {
|
|
1128
1337
|
const placeholder = genericize(schema, descriptor, 1);
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1338
|
+
// The description is MODEL-FACING, so it must not point at a `$defs` entry
|
|
1339
|
+
// this dialect never emits — under `allowDefsRef: false` there is no `$defs`
|
|
1340
|
+
// section at all, and telling the model to look at one is a lie about the
|
|
1341
|
+
// document it was handed. Name the recursion instead.
|
|
1342
|
+
const description = descriptor.allowDefsRef
|
|
1343
|
+
? `Would recursively reference #/$defs/${refId}`
|
|
1344
|
+
: `Recursive reference to '${refId}' — repeat this shape here`;
|
|
1345
|
+
return { ...placeholder, description };
|
|
1133
1346
|
}
|
|
1134
1347
|
/**
|
|
1135
1348
|
* Reduce a zod schema to a JSON-Schema node carrying its top-level shape
|
|
@@ -1265,6 +1478,61 @@ function uniqueByJSON(nodes) {
|
|
|
1265
1478
|
}
|
|
1266
1479
|
return out;
|
|
1267
1480
|
}
|
|
1481
|
+
/**
|
|
1482
|
+
* The values a `z.enum` actually EMITS as JSON-Schema `enum` members.
|
|
1483
|
+
*
|
|
1484
|
+
* A numeric TypeScript enum compiles to a two-way map (`{A: 0, 0: 'A'}`), so
|
|
1485
|
+
* the reverse-mapping keys have to be dropped or every value appears twice.
|
|
1486
|
+
* Shared with `analyzeSchema` deliberately: the size budget counts what goes on
|
|
1487
|
+
* the wire, and "what goes on the wire" must have ONE definition (a second
|
|
1488
|
+
* copy of this filter would drift from the emitter it is supposed to measure).
|
|
1489
|
+
*/
|
|
1490
|
+
function enumEmittedValues(schema) {
|
|
1491
|
+
const numericValues = Object.values(schema.def.entries).filter((v) => typeof v === 'number');
|
|
1492
|
+
return Object.entries(schema.def.entries)
|
|
1493
|
+
.filter(([k]) => numericValues.indexOf(+k) === -1)
|
|
1494
|
+
.map(([, v]) => v);
|
|
1495
|
+
}
|
|
1496
|
+
/**
|
|
1497
|
+
* The values a `z.literal` emits — one for the `const` form, several for the
|
|
1498
|
+
* multi-value form that emits `enum`. Types JSON Schema cannot carry
|
|
1499
|
+
* (functions, symbols, bigints, `undefined`) are dropped, matching the
|
|
1500
|
+
* emitter. Shared with `analyzeSchema` for the same reason as above.
|
|
1501
|
+
*/
|
|
1502
|
+
function literalEmittedValues(schema) {
|
|
1503
|
+
return Array.from(schema.values).filter((v) => v !== undefined && typeof v !== 'function' && typeof v !== 'symbol' && typeof v !== 'bigint');
|
|
1504
|
+
}
|
|
1505
|
+
/**
|
|
1506
|
+
* Decide whether a set of values may be emitted as a JSON-Schema `enum` under
|
|
1507
|
+
* this descriptor, per `maxEnumValues`.
|
|
1508
|
+
*
|
|
1509
|
+
* Under the cap (or with no cap declared) the caller emits its `enum` as
|
|
1510
|
+
* normal. Over it, the caller emits NO `enum` — just the widened `type` — and
|
|
1511
|
+
* attaches `note`, which lists EVERY value in prose.
|
|
1512
|
+
*
|
|
1513
|
+
* Widening rather than truncating is the deliberate choice, and the two differ
|
|
1514
|
+
* in what the model can still say. A truncated `enum` makes the values past the
|
|
1515
|
+
* cap **unreachable**: the wire schema constrains them away, so a caller whose
|
|
1516
|
+
* 96 values are all legitimately selectable loses 56 of them. Widening removes
|
|
1517
|
+
* the wire-level constraint that the dialect can't compile in the first place
|
|
1518
|
+
* and moves the full list into the `description`, so the model retains complete
|
|
1519
|
+
* information and can name any of the 96. Nothing is hidden — it is described
|
|
1520
|
+
* instead of enforced, and enforcement moves to whatever validates the value
|
|
1521
|
+
* afterwards (`Tool.parse` against the ORIGINAL zod schema still rejects a name
|
|
1522
|
+
* that was never in the enum, with a message the model can act on).
|
|
1523
|
+
*
|
|
1524
|
+
* The note is deliberately DOMAIN-NEUTRAL. This library has no idea what the
|
|
1525
|
+
* values mean; whichever caller relies on the cap (a session's known-name enum,
|
|
1526
|
+
* a country list, anything) reads its own semantics into them.
|
|
1527
|
+
*/
|
|
1528
|
+
function describeOversizeEnum(values, descriptor) {
|
|
1529
|
+
const cap = descriptor.maxEnumValues;
|
|
1530
|
+
if (cap === undefined || values.length <= cap)
|
|
1531
|
+
return undefined;
|
|
1532
|
+
// A flat comma-separated list: it is how an enum reads in prose anyway, and
|
|
1533
|
+
// the values are single tokens by construction (they were `enum` members).
|
|
1534
|
+
return `One of these ${values.length} values: ${values.map((v) => String(v)).join(', ')}`;
|
|
1535
|
+
}
|
|
1268
1536
|
/**
|
|
1269
1537
|
* Main conversion function
|
|
1270
1538
|
*/
|
|
@@ -1450,26 +1718,33 @@ function convertSchema(schema, context) {
|
|
|
1450
1718
|
}
|
|
1451
1719
|
// Handle ZodEnum
|
|
1452
1720
|
if (schema instanceof z.ZodEnum) {
|
|
1453
|
-
const
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1721
|
+
const values = enumEmittedValues(schema);
|
|
1722
|
+
// Over the dialect's cap the `enum` keyword is DROPPED and the node widens
|
|
1723
|
+
// to its plain scalar type, with every value listed in the description —
|
|
1724
|
+
// see `describeOversizeEnum` for why widening beats truncating.
|
|
1725
|
+
const oversize = describeOversizeEnum(values, descriptor);
|
|
1457
1726
|
return {
|
|
1458
1727
|
type: values.every((v) => typeof v === 'number')
|
|
1459
1728
|
? 'number'
|
|
1460
1729
|
: values.every((v) => typeof v === 'string')
|
|
1461
1730
|
? 'string'
|
|
1462
1731
|
: undefined,
|
|
1463
|
-
enum: values,
|
|
1732
|
+
...(oversize === undefined ? { enum: values } : { description: oversize }),
|
|
1464
1733
|
};
|
|
1465
1734
|
}
|
|
1466
1735
|
// Handle ZodLiteral
|
|
1467
1736
|
if (schema instanceof z.ZodLiteral) {
|
|
1468
|
-
const values =
|
|
1737
|
+
const values = literalEmittedValues(schema);
|
|
1469
1738
|
const types = Array.from(new Set(values.map(v => v === null ? 'null' : typeof v)));
|
|
1739
|
+
// A multi-value literal emits the SAME `enum` keyword as ZodEnum, so the
|
|
1740
|
+
// dialect cap applies identically — the provider sees no difference. The
|
|
1741
|
+
// single-value form emits `const`, which no cap can be exceeded by.
|
|
1742
|
+
const oversize = values.length === 1 ? undefined : describeOversizeEnum(values, descriptor);
|
|
1470
1743
|
return {
|
|
1471
1744
|
...(types.length === 1 ? { type: types[0] } : {}),
|
|
1472
|
-
...(values.length === 1
|
|
1745
|
+
...(values.length === 1
|
|
1746
|
+
? { const: values[0] }
|
|
1747
|
+
: oversize === undefined ? { enum: values } : { description: oversize }),
|
|
1473
1748
|
};
|
|
1474
1749
|
}
|
|
1475
1750
|
// Handle ZodDefault
|
|
@@ -1783,12 +2058,23 @@ const ZERO_FEATURES = Object.freeze({
|
|
|
1783
2058
|
unionTypeCount: 0,
|
|
1784
2059
|
recordCount: 0,
|
|
1785
2060
|
tupleCount: 0,
|
|
2061
|
+
enums: Object.freeze([]),
|
|
2062
|
+
objectPropertyCount: 0,
|
|
2063
|
+
maxNestingDepth: 0,
|
|
2064
|
+
stringSizeChars: 0,
|
|
1786
2065
|
});
|
|
1787
2066
|
const featuresCache = new WeakMap();
|
|
1788
2067
|
/**
|
|
1789
2068
|
* Walk a Zod schema once and count its structural features. Result is
|
|
1790
2069
|
* cached per schema in a WeakMap — second-and-subsequent calls are O(1) and
|
|
1791
2070
|
* the entry is GC'd with the schema (same OOM-safe pattern as `strictify`).
|
|
2071
|
+
*
|
|
2072
|
+
* The walk is EAGER (it has to descend to count), so its cycle guard keys on
|
|
2073
|
+
* `walkKey`, not on object identity: a `z.lazy` that rebuilds its inner schema
|
|
2074
|
+
* per call would otherwise never re-enter the same object and the walk would
|
|
2075
|
+
* never terminate. That is not hypothetical — it is a measured, reproducible
|
|
2076
|
+
* heap-death, and this function is on the hot path of EVERY tool of EVERY
|
|
2077
|
+
* request to a strict-family model (`SchemaBudget.allocate`).
|
|
1792
2078
|
*/
|
|
1793
2079
|
export function analyzeSchema(schema) {
|
|
1794
2080
|
const cached = featuresCache.get(schema);
|
|
@@ -1796,98 +2082,202 @@ export function analyzeSchema(schema) {
|
|
|
1796
2082
|
return cached;
|
|
1797
2083
|
const visiting = new Set();
|
|
1798
2084
|
const features = { ...ZERO_FEATURES };
|
|
1799
|
-
|
|
2085
|
+
// Collected mutably and frozen onto `features` at the end — `ZERO_FEATURES`
|
|
2086
|
+
// is a frozen singleton, so spreading it must not hand out its array.
|
|
2087
|
+
const enums = [];
|
|
2088
|
+
/** Total characters of the string members of an emitted enum/const value set. */
|
|
2089
|
+
const stringChars = (values) => values.reduce((n, v) => n + (typeof v === 'string' ? v.length : 0), 0);
|
|
2090
|
+
/** Record the deepest container nesting reached. `depth` is the level of `s` itself. */
|
|
2091
|
+
const noteDepth = (depth) => {
|
|
2092
|
+
if (depth > features.maxNestingDepth)
|
|
2093
|
+
features.maxNestingDepth = depth;
|
|
2094
|
+
};
|
|
2095
|
+
// `depth` counts CONTAINERS entered (an object of scalars is depth 1). Wrapper
|
|
2096
|
+
// nodes — optional, nullable, default, lazy, union arms — are transparent on
|
|
2097
|
+
// the wire and pass their own depth through, so `{a?: {b: string}}` is 2, the
|
|
2098
|
+
// same as `{a: {b: string}}`.
|
|
2099
|
+
function walk(s, depth) {
|
|
1800
2100
|
if (!s)
|
|
1801
2101
|
return;
|
|
1802
|
-
|
|
2102
|
+
const key = walkKey(s);
|
|
2103
|
+
if (visiting.has(key))
|
|
1803
2104
|
return;
|
|
1804
|
-
visiting.add(
|
|
2105
|
+
visiting.add(key);
|
|
1805
2106
|
try {
|
|
1806
2107
|
// Recursion is detected via z.lazy: peek at the inner schema (but only
|
|
1807
2108
|
// once — the visiting set bounds the walk).
|
|
1808
2109
|
if (s instanceof z.ZodLazy) {
|
|
1809
2110
|
features.hasRecursion = true;
|
|
1810
2111
|
try {
|
|
1811
|
-
walk(s.def.getter());
|
|
2112
|
+
walk(s.def.getter(), depth);
|
|
1812
2113
|
}
|
|
1813
2114
|
catch { /* lazy getter may throw at analyze time; ignore */ }
|
|
1814
2115
|
return;
|
|
1815
2116
|
}
|
|
1816
2117
|
if (s instanceof z.ZodOptional) {
|
|
1817
2118
|
features.optionalParameterCount += 1;
|
|
1818
|
-
walk(s.unwrap());
|
|
2119
|
+
walk(s.unwrap(), depth);
|
|
1819
2120
|
return;
|
|
1820
2121
|
}
|
|
1821
2122
|
if (s instanceof z.ZodNullable) {
|
|
1822
2123
|
features.unionTypeCount += 1; // T|null counts as a union on the wire
|
|
1823
|
-
walk(s.unwrap());
|
|
2124
|
+
walk(s.unwrap(), depth);
|
|
1824
2125
|
return;
|
|
1825
2126
|
}
|
|
1826
2127
|
if (s instanceof z.ZodUnion || s instanceof z.ZodDiscriminatedUnion) {
|
|
1827
2128
|
features.unionTypeCount += 1;
|
|
1828
2129
|
for (const opt of s.options)
|
|
1829
|
-
walk(opt);
|
|
2130
|
+
walk(opt, depth);
|
|
1830
2131
|
return;
|
|
1831
2132
|
}
|
|
1832
2133
|
if (s instanceof z.ZodIntersection) {
|
|
1833
|
-
walk(s.def.left);
|
|
1834
|
-
walk(s.def.right);
|
|
2134
|
+
walk(s.def.left, depth);
|
|
2135
|
+
walk(s.def.right, depth);
|
|
1835
2136
|
return;
|
|
1836
2137
|
}
|
|
1837
2138
|
if (s instanceof z.ZodObject) {
|
|
1838
|
-
|
|
1839
|
-
|
|
2139
|
+
noteDepth(depth + 1);
|
|
2140
|
+
for (const key in s.shape) {
|
|
2141
|
+
features.objectPropertyCount += 1;
|
|
2142
|
+
features.stringSizeChars += key.length;
|
|
2143
|
+
walk(s.shape[key], depth + 1);
|
|
2144
|
+
}
|
|
1840
2145
|
if (s.def.catchall)
|
|
1841
|
-
walk(s.def.catchall);
|
|
2146
|
+
walk(s.def.catchall, depth + 1);
|
|
1842
2147
|
return;
|
|
1843
2148
|
}
|
|
1844
2149
|
if (s instanceof z.ZodArray) {
|
|
1845
|
-
|
|
2150
|
+
noteDepth(depth + 1);
|
|
2151
|
+
walk(s.element, depth + 1);
|
|
1846
2152
|
return;
|
|
1847
2153
|
}
|
|
1848
2154
|
if (s instanceof z.ZodRecord) {
|
|
1849
2155
|
features.recordCount += 1;
|
|
2156
|
+
// Widest encoding wins (see `SchemaFeatures.objectPropertyCount`): the
|
|
2157
|
+
// array-of-pairs form is an array of `{key, value}` objects, so two
|
|
2158
|
+
// properties, two property names, and two levels of container.
|
|
2159
|
+
features.objectPropertyCount += 2;
|
|
2160
|
+
features.stringSizeChars += 'key'.length + 'value'.length;
|
|
2161
|
+
noteDepth(depth + 2);
|
|
1850
2162
|
if (s.keyType)
|
|
1851
|
-
walk(s.keyType);
|
|
1852
|
-
walk(s.valueType);
|
|
2163
|
+
walk(s.keyType, depth + 2);
|
|
2164
|
+
walk(s.valueType, depth + 2);
|
|
1853
2165
|
return;
|
|
1854
2166
|
}
|
|
1855
2167
|
if (s instanceof z.ZodTuple) {
|
|
1856
2168
|
features.tupleCount += 1;
|
|
2169
|
+
// The numeric-key object encoding emits one property per item, named
|
|
2170
|
+
// "0", "1", … — one character each until there are ten of them, which
|
|
2171
|
+
// is close enough for a 120,000-character budget.
|
|
2172
|
+
features.objectPropertyCount += s.def.items.length;
|
|
2173
|
+
features.stringSizeChars += s.def.items.length;
|
|
2174
|
+
noteDepth(depth + 1);
|
|
1857
2175
|
for (const item of s.def.items)
|
|
1858
|
-
walk(item);
|
|
2176
|
+
walk(item, depth + 1);
|
|
1859
2177
|
if (s.def.rest)
|
|
1860
|
-
walk(s.def.rest);
|
|
2178
|
+
walk(s.def.rest, depth + 1);
|
|
1861
2179
|
return;
|
|
1862
2180
|
}
|
|
1863
2181
|
if (s instanceof z.ZodDefault) {
|
|
1864
|
-
walk(s.def.innerType);
|
|
2182
|
+
walk(s.def.innerType, depth);
|
|
1865
2183
|
return;
|
|
1866
2184
|
}
|
|
1867
2185
|
if (s instanceof z.ZodCodec || s instanceof z.ZodPipe) {
|
|
1868
|
-
walk(s.def.in);
|
|
1869
|
-
walk(s.def.out);
|
|
2186
|
+
walk(s.def.in, depth);
|
|
2187
|
+
walk(s.def.out, depth);
|
|
2188
|
+
return;
|
|
2189
|
+
}
|
|
2190
|
+
if (s instanceof z.ZodEnum) {
|
|
2191
|
+
const values = enumEmittedValues(s);
|
|
2192
|
+
const chars = stringChars(values);
|
|
2193
|
+
enums.push({ valueCount: values.length, stringValueChars: chars });
|
|
2194
|
+
features.stringSizeChars += chars;
|
|
2195
|
+
return;
|
|
2196
|
+
}
|
|
2197
|
+
if (s instanceof z.ZodLiteral) {
|
|
2198
|
+
const values = literalEmittedValues(s);
|
|
2199
|
+
const chars = stringChars(values);
|
|
2200
|
+
// One value emits `const`, several emit `enum` — only the latter is an
|
|
2201
|
+
// enum for the purpose of a dialect's enum-value budget, but both count
|
|
2202
|
+
// toward the total string size.
|
|
2203
|
+
if (values.length > 1)
|
|
2204
|
+
enums.push({ valueCount: values.length, stringValueChars: chars });
|
|
2205
|
+
features.stringSizeChars += chars;
|
|
1870
2206
|
return;
|
|
1871
2207
|
}
|
|
1872
2208
|
// Primitives and unknown leaves contribute nothing.
|
|
1873
2209
|
}
|
|
1874
2210
|
finally {
|
|
1875
|
-
visiting.delete(
|
|
2211
|
+
visiting.delete(key);
|
|
1876
2212
|
}
|
|
1877
2213
|
}
|
|
1878
|
-
walk(schema);
|
|
2214
|
+
walk(schema, 0);
|
|
2215
|
+
features.enums = Object.freeze(enums);
|
|
1879
2216
|
Object.freeze(features);
|
|
1880
2217
|
featuresCache.set(schema, features);
|
|
1881
2218
|
return features;
|
|
1882
2219
|
}
|
|
2220
|
+
/** Shared empty result, so the common "dialect publishes no size limits" path allocates nothing. */
|
|
2221
|
+
const NO_SIZE_PROBLEMS = Object.freeze([]);
|
|
2222
|
+
/**
|
|
2223
|
+
* Report the ways ONE schema exceeds the descriptor's documented per-schema
|
|
2224
|
+
* size limits (`FormatDescriptor.schemaSizeLimits`). Empty means it fits, or
|
|
2225
|
+
* that the dialect publishes no limits.
|
|
2226
|
+
*
|
|
2227
|
+
* This is a FEASIBILITY question, not a budget one: the numbers bound a single
|
|
2228
|
+
* schema, so no other tool in the request can change the answer, and
|
|
2229
|
+
* `SchemaBudget.allocate` therefore treats a hit exactly like an
|
|
2230
|
+
* unrepresentable keyword — that item degrades to the family's non-strict
|
|
2231
|
+
* descriptor and the rest of the request is untouched.
|
|
2232
|
+
*
|
|
2233
|
+
* Exported because "why did my tool silently stop being strict?" needs an
|
|
2234
|
+
* answer a caller can print; each line names the bound and the measurement.
|
|
2235
|
+
*
|
|
2236
|
+
* @example
|
|
2237
|
+
* ```ts
|
|
2238
|
+
* checkSchemaSizeLimits(z.object({ pick: z.enum(tenThousandNames) }), OPENAI_STRICT);
|
|
2239
|
+
* // → ['enum values 10000 exceed maxTotalEnumValues 1000']
|
|
2240
|
+
* ```
|
|
2241
|
+
*/
|
|
2242
|
+
export function checkSchemaSizeLimits(schema, descriptor) {
|
|
2243
|
+
const limits = descriptor.schemaSizeLimits;
|
|
2244
|
+
if (limits === undefined)
|
|
2245
|
+
return NO_SIZE_PROBLEMS;
|
|
2246
|
+
const features = analyzeSchema(schema);
|
|
2247
|
+
const problems = [];
|
|
2248
|
+
if (features.objectPropertyCount > limits.maxObjectProperties) {
|
|
2249
|
+
problems.push(`object properties ${features.objectPropertyCount} exceed maxObjectProperties ${limits.maxObjectProperties}`);
|
|
2250
|
+
}
|
|
2251
|
+
if (features.maxNestingDepth > limits.maxNestingDepth) {
|
|
2252
|
+
problems.push(`nesting depth ${features.maxNestingDepth} exceeds maxNestingDepth ${limits.maxNestingDepth}`);
|
|
2253
|
+
}
|
|
2254
|
+
if (features.stringSizeChars > limits.maxTotalStringChars) {
|
|
2255
|
+
problems.push(`total string characters ${features.stringSizeChars} exceed maxTotalStringChars ${limits.maxTotalStringChars}`);
|
|
2256
|
+
}
|
|
2257
|
+
const totalEnumValues = features.enums.reduce((n, e) => n + e.valueCount, 0);
|
|
2258
|
+
if (totalEnumValues > limits.maxTotalEnumValues) {
|
|
2259
|
+
problems.push(`enum values ${totalEnumValues} exceed maxTotalEnumValues ${limits.maxTotalEnumValues}`);
|
|
2260
|
+
}
|
|
2261
|
+
// The large-enum character rule is per-enum and conditional on that SAME
|
|
2262
|
+
// enum's value count, which is why the walk keeps the breakdown instead of
|
|
2263
|
+
// two totals.
|
|
2264
|
+
for (const e of features.enums) {
|
|
2265
|
+
if (e.valueCount > limits.largeEnumValueCount && e.stringValueChars > limits.maxLargeEnumStringChars) {
|
|
2266
|
+
problems.push(`an enum of ${e.valueCount} values (over largeEnumValueCount ${limits.largeEnumValueCount}) spends ` +
|
|
2267
|
+
`${e.stringValueChars} characters, over maxLargeEnumStringChars ${limits.maxLargeEnumStringChars}`);
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
return problems;
|
|
2271
|
+
}
|
|
1883
2272
|
/**
|
|
1884
2273
|
* Per-request strict-mode allocator.
|
|
1885
2274
|
*
|
|
1886
2275
|
* Constructed once per outgoing request with the chosen model's strictest
|
|
1887
2276
|
* descriptor. Tracks remaining slots (strict tools, optional parameters,
|
|
1888
2277
|
* union types) against the descriptor's documented per-request limits and
|
|
1889
|
-
* decides per-tool / per-output whether to emit strict or fall back to
|
|
1890
|
-
*
|
|
2278
|
+
* decides per-tool / per-output whether to emit strict or fall back to the
|
|
2279
|
+
* SAME FAMILY's non-strict descriptor (`degraded` — never the family-blind
|
|
2280
|
+
* `LENIENT`, which would undo dialect rules that outlive strict mode).
|
|
1891
2281
|
*
|
|
1892
2282
|
* Selection guarantees that any tool with `strict: true` (hard requirement)
|
|
1893
2283
|
* has a model that supports the family — `allocate` therefore always grants
|
|
@@ -1901,11 +2291,37 @@ export function analyzeSchema(schema) {
|
|
|
1901
2291
|
*/
|
|
1902
2292
|
export class SchemaBudget {
|
|
1903
2293
|
descriptor;
|
|
2294
|
+
/**
|
|
2295
|
+
* What an item that does NOT get strict is emitted through: the descriptor's
|
|
2296
|
+
* own family, non-strict.
|
|
2297
|
+
*
|
|
2298
|
+
* It is deliberately NOT the family-blind `LENIENT`. A dialect restriction
|
|
2299
|
+
* can belong to the DIALECT rather than to strict mode — `GOOGLE_NON_STRICT`
|
|
2300
|
+
* exists for exactly one such rule, the `unconstrained` encoding of
|
|
2301
|
+
* `z.any()`, because Gemini builds a decoding grammar whenever a tool call is
|
|
2302
|
+
* forced, with no per-tool strict flag involved. Degrading to `LENIENT` put
|
|
2303
|
+
* the self-referencing `$defs/Any` that encoding was created to avoid back on
|
|
2304
|
+
* the Google wire — and it did so for every gin/query-style recursive `anyOf`
|
|
2305
|
+
* schema, i.e. the schemas that ALWAYS degrade, since `GOOGLE_STRICT` cannot
|
|
2306
|
+
* express `anyOf` at all (measured: the product's real `api_set` tool schema
|
|
2307
|
+
* allocates to `lenient` under `GOOGLE_STRICT`, and its emitted JSON carries
|
|
2308
|
+
* nine `#/$defs/Any` references).
|
|
2309
|
+
*
|
|
2310
|
+
* For the other families this is behaviour-neutral by construction:
|
|
2311
|
+
* `OPENAI_NON_STRICT` and `ANTHROPIC_NON_STRICT` are `LENIENT` with a family
|
|
2312
|
+
* tag, and a family with no registered non-strict variant resolves to
|
|
2313
|
+
* `LENIENT` (`getDescriptor`). Only the pinned `descriptor` id on the tool
|
|
2314
|
+
* changes, which round-trips through `getDescriptorById`.
|
|
2315
|
+
*/
|
|
2316
|
+
degraded;
|
|
1904
2317
|
remainingTools;
|
|
1905
2318
|
remainingOptionalParams;
|
|
1906
2319
|
remainingUnionTypes;
|
|
1907
2320
|
constructor(descriptor) {
|
|
1908
2321
|
this.descriptor = descriptor;
|
|
2322
|
+
// An already-non-strict budget degrades to itself: there is nothing looser
|
|
2323
|
+
// in its family, and `LENIENT`'s own family has no other variant.
|
|
2324
|
+
this.degraded = descriptor.strict ? getDescriptor(descriptor.family, false) : descriptor;
|
|
1909
2325
|
this.remainingTools = descriptor.maxStrictTools ?? Infinity;
|
|
1910
2326
|
this.remainingOptionalParams = descriptor.maxStrictOptionalParams ?? Infinity;
|
|
1911
2327
|
this.remainingUnionTypes = descriptor.maxStrictUnionTypes ?? Infinity;
|
|
@@ -1914,12 +2330,15 @@ export class SchemaBudget {
|
|
|
1914
2330
|
* Decide the effective descriptor for a tool's parameter schema given the
|
|
1915
2331
|
* dev's strict request, the schema's features, and the remaining budget.
|
|
1916
2332
|
*
|
|
1917
|
-
* Returns `LENIENT`
|
|
2333
|
+
* Returns the family's NON-STRICT descriptor (`LENIENT` for a family that
|
|
2334
|
+
* registers none) when:
|
|
1918
2335
|
* - `requested === false`
|
|
1919
2336
|
* - the schema uses a feature the descriptor can't represent
|
|
2337
|
+
* - the schema exceeds the dialect's per-schema size limits
|
|
2338
|
+
* (`checkSchemaSizeLimits`)
|
|
1920
2339
|
* - the descriptor has slot limits and the budget is exhausted (only
|
|
1921
2340
|
* applies to `requested` being a positive number; `true` always wins
|
|
1922
|
-
* subject to feasibility)
|
|
2341
|
+
* subject to feasibility and size)
|
|
1923
2342
|
*
|
|
1924
2343
|
* Returns the strict descriptor (and decrements the budget) otherwise.
|
|
1925
2344
|
*/
|
|
@@ -1944,31 +2363,56 @@ export class SchemaBudget {
|
|
|
1944
2363
|
};
|
|
1945
2364
|
}
|
|
1946
2365
|
allocate(schema, requested, isTool) {
|
|
1947
|
-
// strict: false → always
|
|
2366
|
+
// strict: false → always non-strict, no budget movement.
|
|
1948
2367
|
if (requested === false)
|
|
1949
|
-
return
|
|
2368
|
+
return this.degraded;
|
|
1950
2369
|
// Descriptor itself is lenient → nothing to allocate.
|
|
1951
2370
|
if (!this.descriptor.strict)
|
|
1952
|
-
return
|
|
1953
|
-
const features = analyzeSchema(schema);
|
|
2371
|
+
return this.degraded;
|
|
1954
2372
|
const isHardRequired = requested === true;
|
|
1955
2373
|
const isPreferredNumeric = typeof requested === 'number' && requested > 0;
|
|
1956
2374
|
if (!isHardRequired && !isPreferredNumeric) {
|
|
1957
2375
|
// requested is undefined or 0 — treat as no preference; default to
|
|
1958
|
-
//
|
|
1959
|
-
|
|
2376
|
+
// non-strict so callers explicitly opt into strict. Decided BEFORE any
|
|
2377
|
+
// walk of `schema`: analysing a schema whose verdict is already
|
|
2378
|
+
// non-strict is pure waste on the hot path of every tool of every request.
|
|
2379
|
+
return this.degraded;
|
|
1960
2380
|
}
|
|
2381
|
+
// FEASIBILITY, before budget — the second clause of this method's contract,
|
|
2382
|
+
// and the one that was documented but never implemented. Emitting a strict
|
|
2383
|
+
// schema carrying a combinator the descriptor forbids is a guaranteed
|
|
2384
|
+
// provider 400, not a degradation: `GOOGLE_STRICT` forbids `anyOf`, and the
|
|
2385
|
+
// recursive union a gin/query codegen layer produces is nothing but.
|
|
2386
|
+
// Degrading to the family's non-strict descriptor is the documented answer
|
|
2387
|
+
// for a TOOL (unlike a structured OUTPUT, a tool schema has no prompt-text
|
|
2388
|
+
// delivery to fall back to — `applySchemaDeliveryFallback` covers
|
|
2389
|
+
// `responseFormat` only), and it is also what keeps the API-level
|
|
2390
|
+
// `strict: true` flag off the wire, since providers set it from
|
|
2391
|
+
// `descriptor.strict`.
|
|
2392
|
+
//
|
|
2393
|
+
// `sendableUnder`, not `canExpress`: a cycle is always sendable (the
|
|
2394
|
+
// emitter's bounded placeholder), only looser — see that function's note on
|
|
2395
|
+
// why the two questions are different.
|
|
2396
|
+
if (!sendableUnder(schema, this.descriptor))
|
|
2397
|
+
return this.degraded;
|
|
2398
|
+
// SIZE, still before budget and still regardless of priority: these bounds
|
|
2399
|
+
// are per-SCHEMA (see `checkSchemaSizeLimits`), so no other item in the
|
|
2400
|
+
// request can make this one fit, and a hard `strict: true` cannot force a
|
|
2401
|
+
// schema the API will refuse to compile.
|
|
2402
|
+
if (checkSchemaSizeLimits(schema, this.descriptor).length > 0)
|
|
2403
|
+
return this.degraded;
|
|
2404
|
+
const features = analyzeSchema(schema);
|
|
1961
2405
|
// Soft-priority items must fit the budget. Hard-required items skip
|
|
1962
2406
|
// budget checks because selection already promised the model can take
|
|
1963
2407
|
// them — if there are too many, that's a request-construction bug we
|
|
1964
2408
|
// surface via API error rather than degrading silently.
|
|
1965
2409
|
if (isPreferredNumeric) {
|
|
1966
2410
|
if (isTool && this.remainingTools <= 0)
|
|
1967
|
-
return
|
|
2411
|
+
return this.degraded;
|
|
1968
2412
|
if (this.remainingOptionalParams - features.optionalParameterCount < 0)
|
|
1969
|
-
return
|
|
2413
|
+
return this.degraded;
|
|
1970
2414
|
if (this.remainingUnionTypes - features.unionTypeCount < 0)
|
|
1971
|
-
return
|
|
2415
|
+
return this.degraded;
|
|
1972
2416
|
}
|
|
1973
2417
|
// Allocation succeeded — decrement.
|
|
1974
2418
|
if (isTool)
|
|
@@ -1984,6 +2428,13 @@ export class SchemaBudget {
|
|
|
1984
2428
|
* shared between `convertTools` and `convertResponseFormat` — Anthropic's
|
|
1985
2429
|
* documented limits apply across the whole request, so the strictest
|
|
1986
2430
|
* descriptor wins.
|
|
2431
|
+
*
|
|
2432
|
+
* `schemaSizeLimits` deliberately does NOT participate. Those bounds are
|
|
2433
|
+
* per-schema, so they are evaluated against whichever descriptor this returns
|
|
2434
|
+
* rather than shared between the two — and every provider here passes two
|
|
2435
|
+
* descriptors resolved from the SAME family, so there is nothing to break the
|
|
2436
|
+
* tie on. Ranking six size bounds against three slot budgets would be an
|
|
2437
|
+
* invented ordering, not a documented one.
|
|
1987
2438
|
*/
|
|
1988
2439
|
export function strictestOf(a, b) {
|
|
1989
2440
|
// LENIENT is the loosest — pick the strict side if either is strict.
|