@aeye/core 0.5.2 → 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/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). The SchemaBudget treats those caps as `undefined` (no limit)
50
- * for OpenAI; OpenAI's own ~5000-property and ~5-level-depth schema caps
51
- * aren't enforced here either — they're rare in practice and produce a
52
- * server-side rejection that the user can react to.
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` fails that
167
- * compile with `400 INVALID_ARGUMENT` — the same request succeeds under
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, 100% reproducible.
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. Returns one
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
  // ============================================================================
@@ -1133,7 +1228,17 @@ function convert(schema, context) {
1133
1228
  finally {
1134
1229
  context.inProgress.delete(id);
1135
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;
1136
1238
  Object.assign(result, metadata);
1239
+ if (emittedNote !== undefined && metadata.description !== undefined && metadata.description !== emittedNote) {
1240
+ result.description = `${metadata.description} ${emittedNote}`;
1241
+ }
1137
1242
  delete result.aid;
1138
1243
  // Promote it because user requested it or it's recursive
1139
1244
  if (save || context.definitionSchemas.has(id)) {
@@ -1373,6 +1478,61 @@ function uniqueByJSON(nodes) {
1373
1478
  }
1374
1479
  return out;
1375
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
+ }
1376
1536
  /**
1377
1537
  * Main conversion function
1378
1538
  */
@@ -1558,26 +1718,33 @@ function convertSchema(schema, context) {
1558
1718
  }
1559
1719
  // Handle ZodEnum
1560
1720
  if (schema instanceof z.ZodEnum) {
1561
- const numericValues = Object.values(schema.def.entries).filter((v) => typeof v === "number");
1562
- const values = Object.entries(schema.def.entries)
1563
- .filter(([k, _]) => numericValues.indexOf(+k) === -1)
1564
- .map(([_, v]) => v);
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);
1565
1726
  return {
1566
1727
  type: values.every((v) => typeof v === 'number')
1567
1728
  ? 'number'
1568
1729
  : values.every((v) => typeof v === 'string')
1569
1730
  ? 'string'
1570
1731
  : undefined,
1571
- enum: values,
1732
+ ...(oversize === undefined ? { enum: values } : { description: oversize }),
1572
1733
  };
1573
1734
  }
1574
1735
  // Handle ZodLiteral
1575
1736
  if (schema instanceof z.ZodLiteral) {
1576
- const values = Array.from(schema.values).filter(v => v !== undefined && typeof v !== 'function' && typeof v !== 'symbol' && typeof v !== 'bigint');
1737
+ const values = literalEmittedValues(schema);
1577
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);
1578
1743
  return {
1579
1744
  ...(types.length === 1 ? { type: types[0] } : {}),
1580
- ...(values.length === 1 ? { const: values[0] } : { enum: values }),
1745
+ ...(values.length === 1
1746
+ ? { const: values[0] }
1747
+ : oversize === undefined ? { enum: values } : { description: oversize }),
1581
1748
  };
1582
1749
  }
1583
1750
  // Handle ZodDefault
@@ -1891,6 +2058,10 @@ const ZERO_FEATURES = Object.freeze({
1891
2058
  unionTypeCount: 0,
1892
2059
  recordCount: 0,
1893
2060
  tupleCount: 0,
2061
+ enums: Object.freeze([]),
2062
+ objectPropertyCount: 0,
2063
+ maxNestingDepth: 0,
2064
+ stringSizeChars: 0,
1894
2065
  });
1895
2066
  const featuresCache = new WeakMap();
1896
2067
  /**
@@ -1911,7 +2082,21 @@ export function analyzeSchema(schema) {
1911
2082
  return cached;
1912
2083
  const visiting = new Set();
1913
2084
  const features = { ...ZERO_FEATURES };
1914
- function walk(s) {
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) {
1915
2100
  if (!s)
1916
2101
  return;
1917
2102
  const key = walkKey(s);
@@ -1924,65 +2109,100 @@ export function analyzeSchema(schema) {
1924
2109
  if (s instanceof z.ZodLazy) {
1925
2110
  features.hasRecursion = true;
1926
2111
  try {
1927
- walk(s.def.getter());
2112
+ walk(s.def.getter(), depth);
1928
2113
  }
1929
2114
  catch { /* lazy getter may throw at analyze time; ignore */ }
1930
2115
  return;
1931
2116
  }
1932
2117
  if (s instanceof z.ZodOptional) {
1933
2118
  features.optionalParameterCount += 1;
1934
- walk(s.unwrap());
2119
+ walk(s.unwrap(), depth);
1935
2120
  return;
1936
2121
  }
1937
2122
  if (s instanceof z.ZodNullable) {
1938
2123
  features.unionTypeCount += 1; // T|null counts as a union on the wire
1939
- walk(s.unwrap());
2124
+ walk(s.unwrap(), depth);
1940
2125
  return;
1941
2126
  }
1942
2127
  if (s instanceof z.ZodUnion || s instanceof z.ZodDiscriminatedUnion) {
1943
2128
  features.unionTypeCount += 1;
1944
2129
  for (const opt of s.options)
1945
- walk(opt);
2130
+ walk(opt, depth);
1946
2131
  return;
1947
2132
  }
1948
2133
  if (s instanceof z.ZodIntersection) {
1949
- walk(s.def.left);
1950
- walk(s.def.right);
2134
+ walk(s.def.left, depth);
2135
+ walk(s.def.right, depth);
1951
2136
  return;
1952
2137
  }
1953
2138
  if (s instanceof z.ZodObject) {
1954
- for (const key in s.shape)
1955
- walk(s.shape[key]);
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
+ }
1956
2145
  if (s.def.catchall)
1957
- walk(s.def.catchall);
2146
+ walk(s.def.catchall, depth + 1);
1958
2147
  return;
1959
2148
  }
1960
2149
  if (s instanceof z.ZodArray) {
1961
- walk(s.element);
2150
+ noteDepth(depth + 1);
2151
+ walk(s.element, depth + 1);
1962
2152
  return;
1963
2153
  }
1964
2154
  if (s instanceof z.ZodRecord) {
1965
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);
1966
2162
  if (s.keyType)
1967
- walk(s.keyType);
1968
- walk(s.valueType);
2163
+ walk(s.keyType, depth + 2);
2164
+ walk(s.valueType, depth + 2);
1969
2165
  return;
1970
2166
  }
1971
2167
  if (s instanceof z.ZodTuple) {
1972
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);
1973
2175
  for (const item of s.def.items)
1974
- walk(item);
2176
+ walk(item, depth + 1);
1975
2177
  if (s.def.rest)
1976
- walk(s.def.rest);
2178
+ walk(s.def.rest, depth + 1);
1977
2179
  return;
1978
2180
  }
1979
2181
  if (s instanceof z.ZodDefault) {
1980
- walk(s.def.innerType);
2182
+ walk(s.def.innerType, depth);
1981
2183
  return;
1982
2184
  }
1983
2185
  if (s instanceof z.ZodCodec || s instanceof z.ZodPipe) {
1984
- walk(s.def.in);
1985
- 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;
1986
2206
  return;
1987
2207
  }
1988
2208
  // Primitives and unknown leaves contribute nothing.
@@ -1991,19 +2211,73 @@ export function analyzeSchema(schema) {
1991
2211
  visiting.delete(key);
1992
2212
  }
1993
2213
  }
1994
- walk(schema);
2214
+ walk(schema, 0);
2215
+ features.enums = Object.freeze(enums);
1995
2216
  Object.freeze(features);
1996
2217
  featuresCache.set(schema, features);
1997
2218
  return features;
1998
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
+ }
1999
2272
  /**
2000
2273
  * Per-request strict-mode allocator.
2001
2274
  *
2002
2275
  * Constructed once per outgoing request with the chosen model's strictest
2003
2276
  * descriptor. Tracks remaining slots (strict tools, optional parameters,
2004
2277
  * 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
- * LENIENT.
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).
2007
2281
  *
2008
2282
  * Selection guarantees that any tool with `strict: true` (hard requirement)
2009
2283
  * has a model that supports the family — `allocate` therefore always grants
@@ -2017,11 +2291,37 @@ export function analyzeSchema(schema) {
2017
2291
  */
2018
2292
  export class SchemaBudget {
2019
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;
2020
2317
  remainingTools;
2021
2318
  remainingOptionalParams;
2022
2319
  remainingUnionTypes;
2023
2320
  constructor(descriptor) {
2024
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;
2025
2325
  this.remainingTools = descriptor.maxStrictTools ?? Infinity;
2026
2326
  this.remainingOptionalParams = descriptor.maxStrictOptionalParams ?? Infinity;
2027
2327
  this.remainingUnionTypes = descriptor.maxStrictUnionTypes ?? Infinity;
@@ -2030,12 +2330,15 @@ export class SchemaBudget {
2030
2330
  * Decide the effective descriptor for a tool's parameter schema given the
2031
2331
  * dev's strict request, the schema's features, and the remaining budget.
2032
2332
  *
2033
- * Returns `LENIENT` when:
2333
+ * Returns the family's NON-STRICT descriptor (`LENIENT` for a family that
2334
+ * registers none) when:
2034
2335
  * - `requested === false`
2035
2336
  * - the schema uses a feature the descriptor can't represent
2337
+ * - the schema exceeds the dialect's per-schema size limits
2338
+ * (`checkSchemaSizeLimits`)
2036
2339
  * - the descriptor has slot limits and the budget is exhausted (only
2037
2340
  * applies to `requested` being a positive number; `true` always wins
2038
- * subject to feasibility)
2341
+ * subject to feasibility and size)
2039
2342
  *
2040
2343
  * Returns the strict descriptor (and decrements the budget) otherwise.
2041
2344
  */
@@ -2060,37 +2363,44 @@ export class SchemaBudget {
2060
2363
  };
2061
2364
  }
2062
2365
  allocate(schema, requested, isTool) {
2063
- // strict: false → always lenient, no budget movement.
2366
+ // strict: false → always non-strict, no budget movement.
2064
2367
  if (requested === false)
2065
- return LENIENT;
2368
+ return this.degraded;
2066
2369
  // Descriptor itself is lenient → nothing to allocate.
2067
2370
  if (!this.descriptor.strict)
2068
- return LENIENT;
2371
+ return this.degraded;
2069
2372
  const isHardRequired = requested === true;
2070
2373
  const isPreferredNumeric = typeof requested === 'number' && requested > 0;
2071
2374
  if (!isHardRequired && !isPreferredNumeric) {
2072
2375
  // requested is undefined or 0 — treat as no preference; default to
2073
- // lenient so callers explicitly opt into strict. Decided BEFORE any walk
2074
- // of `schema`: analysing a schema whose verdict is already LENIENT is
2075
- // pure waste on the hot path of every tool of every request.
2076
- return LENIENT;
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;
2077
2380
  }
2078
2381
  // FEASIBILITY, before budget — the second clause of this method's contract,
2079
2382
  // and the one that was documented but never implemented. Emitting a strict
2080
2383
  // schema carrying a combinator the descriptor forbids is a guaranteed
2081
2384
  // provider 400, not a degradation: `GOOGLE_STRICT` forbids `anyOf`, and the
2082
2385
  // recursive union a gin/query codegen layer produces is nothing but.
2083
- // Degrading to LENIENT is the documented answer for a TOOL (unlike a
2084
- // structured OUTPUT, a tool schema has no prompt-text delivery to fall back
2085
- // to — `applySchemaDeliveryFallback` covers `responseFormat` only), and it
2086
- // is also what keeps the API-level `strict: true` flag off the wire, since
2087
- // providers set it from `descriptor.strict`.
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`.
2088
2392
  //
2089
2393
  // `sendableUnder`, not `canExpress`: a cycle is always sendable (the
2090
2394
  // emitter's bounded placeholder), only looser — see that function's note on
2091
2395
  // why the two questions are different.
2092
2396
  if (!sendableUnder(schema, this.descriptor))
2093
- return LENIENT;
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;
2094
2404
  const features = analyzeSchema(schema);
2095
2405
  // Soft-priority items must fit the budget. Hard-required items skip
2096
2406
  // budget checks because selection already promised the model can take
@@ -2098,11 +2408,11 @@ export class SchemaBudget {
2098
2408
  // surface via API error rather than degrading silently.
2099
2409
  if (isPreferredNumeric) {
2100
2410
  if (isTool && this.remainingTools <= 0)
2101
- return LENIENT;
2411
+ return this.degraded;
2102
2412
  if (this.remainingOptionalParams - features.optionalParameterCount < 0)
2103
- return LENIENT;
2413
+ return this.degraded;
2104
2414
  if (this.remainingUnionTypes - features.unionTypeCount < 0)
2105
- return LENIENT;
2415
+ return this.degraded;
2106
2416
  }
2107
2417
  // Allocation succeeded — decrement.
2108
2418
  if (isTool)
@@ -2118,6 +2428,13 @@ export class SchemaBudget {
2118
2428
  * shared between `convertTools` and `convertResponseFormat` — Anthropic's
2119
2429
  * documented limits apply across the whole request, so the strictest
2120
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.
2121
2438
  */
2122
2439
  export function strictestOf(a, b) {
2123
2440
  // LENIENT is the loosest — pick the strict side if either is strict.