@tanstack/ai 0.33.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,7 @@ import type {
2
2
  StandardJSONSchemaV1,
3
3
  StandardSchemaV1,
4
4
  } from '@standard-schema/spec'
5
+ import type { NullWideningMap } from '@tanstack/ai-utils'
5
6
  import type { JSONSchema, SchemaInput } from '../../../types'
6
7
 
7
8
  /**
@@ -82,6 +83,22 @@ export function isStandardSchema(schema: unknown): schema is StandardSchemaV1 {
82
83
  )
83
84
  }
84
85
 
86
+ /**
87
+ * Result of {@link makeStructuredOutputCompatible}: the strict-ready schema plus
88
+ * a {@link NullWideningMap} recording every position where a `null` was
89
+ * synthesized, so the response can be un-widened before validation without
90
+ * re-deriving (or guessing) which nulls were synthetic.
91
+ */
92
+ interface StructuredOutputConversion {
93
+ schema: JSONSchema
94
+ nullWidening: NullWideningMap | undefined
95
+ }
96
+
97
+ /** Drop an empty map to `undefined` so leaf/no-op subtrees don't litter it. */
98
+ function pruneMap(map: NullWideningMap): NullWideningMap | undefined {
99
+ return Object.keys(map).length > 0 ? map : undefined
100
+ }
101
+
85
102
  /**
86
103
  * Transform a JSON schema to be compatible with OpenAI's structured output requirements.
87
104
  * OpenAI requires:
@@ -89,59 +106,76 @@ export function isStandardSchema(schema: unknown): schema is StandardSchemaV1 {
89
106
  * - Optional fields should have null added to their type union
90
107
  * - additionalProperties must be false for objects
91
108
  *
109
+ * Alongside the transformed schema it returns a {@link NullWideningMap} marking
110
+ * exactly the positions where `null` was added, so `undoNullWidening` can strip
111
+ * those synthesized nulls (and only those) from the provider's response.
112
+ *
92
113
  * @param schema - JSON schema to transform
93
114
  * @param originalRequired - Original required array (to know which fields were optional)
94
- * @returns Transformed schema compatible with OpenAI structured output
115
+ * @returns Transformed schema + the null-widening map for the round trip
95
116
  */
96
117
  function makeStructuredOutputCompatible(
97
118
  schema: JSONSchema,
98
119
  originalRequired: Array<string> = [],
99
- ): JSONSchema {
120
+ ): StructuredOutputConversion {
100
121
  const result: JSONSchema = { ...schema }
122
+ const map: NullWideningMap = {}
101
123
 
102
124
  // Handle object types
103
125
  if (result.type === 'object' && result.properties) {
104
126
  const properties: Record<string, JSONSchema> = { ...result.properties }
105
127
  const allPropertyNames = Object.keys(properties)
128
+ const propertyMaps: Record<string, NullWideningMap> = {}
106
129
 
107
130
  // Transform each property
108
131
  for (const propName of allPropertyNames) {
109
132
  const prop = properties[propName]
110
133
  if (!prop) continue
111
134
  const wasOptional = !originalRequired.includes(propName)
135
+ // `null` synthesized AT this property (the field itself can come back null).
136
+ let widenedHere = false
137
+ // Map describing widened positions INSIDE this property.
138
+ let childMap: NullWideningMap | undefined
112
139
 
113
140
  // Recursively transform nested objects/arrays
114
141
  if (prop.type === 'object' && prop.properties) {
115
- const transformed = makeStructuredOutputCompatible(
116
- prop,
117
- prop.required || [],
118
- )
142
+ const nested = makeStructuredOutputCompatible(prop, prop.required || [])
119
143
  properties[propName] = wasOptional
120
- ? { ...transformed, type: ['object', 'null'] }
121
- : transformed
144
+ ? { ...nested.schema, type: ['object', 'null'] }
145
+ : nested.schema
146
+ widenedHere = wasOptional
147
+ childMap = nested.nullWidening
122
148
  } else if (prop.type === 'array' && prop.items) {
123
149
  const items = Array.isArray(prop.items) ? prop.items[0] : prop.items
124
- const transformed: JSONSchema = {
150
+ const nestedItems = items
151
+ ? makeStructuredOutputCompatible(items, items.required || [])
152
+ : undefined
153
+ properties[propName] = {
125
154
  ...prop,
126
- items: items
127
- ? makeStructuredOutputCompatible(items, items.required || [])
128
- : prop.items,
155
+ items: nestedItems ? nestedItems.schema : prop.items,
156
+ ...(wasOptional ? { type: ['array', 'null'] } : {}),
129
157
  }
130
- properties[propName] = wasOptional
131
- ? { ...transformed, type: ['array', 'null'] }
132
- : transformed
158
+ widenedHere = wasOptional
159
+ childMap = nestedItems?.nullWidening
160
+ ? { items: nestedItems.nullWidening }
161
+ : undefined
133
162
  } else if (wasOptional) {
134
- // Make optional fields nullable by adding null to the type
163
+ // Make optional fields nullable by adding null to the type. Mark
164
+ // `widenedHere` only where we actually add `null`; a field already
165
+ // typed nullable (`.nullish()`) is left as-is and keeps its null.
135
166
  if (prop.type && !Array.isArray(prop.type)) {
136
- properties[propName] = {
137
- ...prop,
138
- type: [prop.type, 'null'],
139
- }
167
+ properties[propName] = { ...prop, type: [prop.type, 'null'] }
168
+ widenedHere = true
140
169
  } else if (Array.isArray(prop.type) && !prop.type.includes('null')) {
141
- properties[propName] = {
142
- ...prop,
143
- type: [...prop.type, 'null'],
144
- }
170
+ properties[propName] = { ...prop, type: [...prop.type, 'null'] }
171
+ widenedHere = true
172
+ }
173
+ }
174
+
175
+ if (widenedHere || childMap) {
176
+ propertyMaps[propName] = {
177
+ ...(childMap ?? {}),
178
+ ...(widenedHere ? { widened: true } : {}),
145
179
  }
146
180
  }
147
181
  }
@@ -151,17 +185,23 @@ function makeStructuredOutputCompatible(
151
185
  result.required = allPropertyNames
152
186
  // additionalProperties must be false
153
187
  result.additionalProperties = false
188
+ if (Object.keys(propertyMaps).length > 0) map.properties = propertyMaps
154
189
  }
155
190
 
156
191
  // Handle array types with object items
157
192
  if (result.type === 'array' && result.items) {
158
193
  const items = Array.isArray(result.items) ? result.items[0] : result.items
159
194
  if (items) {
160
- result.items = makeStructuredOutputCompatible(items, items.required || [])
195
+ const nestedItems = makeStructuredOutputCompatible(
196
+ items,
197
+ items.required || [],
198
+ )
199
+ result.items = nestedItems.schema
200
+ if (nestedItems.nullWidening) map.items = nestedItems.nullWidening
161
201
  }
162
202
  }
163
203
 
164
- return result
204
+ return { schema: result, nullWidening: pruneMap(map) }
165
205
  }
166
206
 
167
207
  /**
@@ -179,6 +219,48 @@ export interface ConvertSchemaOptions {
179
219
  forStructuredOutput?: boolean
180
220
  }
181
221
 
222
+ /**
223
+ * Normalize any supported schema input to a typed, UN-widened `JSONSchema` —
224
+ * the shared first half of conversion, before any structured-output widening.
225
+ *
226
+ * - Standard JSON Schemas are rebuilt structurally (dropping `$schema`, which
227
+ * LLM providers ignore) and given the explicit `type`/`properties`/`required`
228
+ * defaults object shapes need downstream.
229
+ * - Plain `JSONSchema` inputs are rebuilt into the typed view; non-object inputs
230
+ * are surfaced untouched (they can't be widened).
231
+ * - Standard Schema validators lacking a `~standard.jsonSchema` converter throw
232
+ * with actionable guidance, rather than shipping `{ '~standard': … }` to the
233
+ * provider and producing an opaque downstream error.
234
+ */
235
+ function toTypedJsonSchema(schema: SchemaInput): JSONSchema | undefined {
236
+ if (isStandardJSONSchema(schema)) {
237
+ const jsonSchema = schema['~standard'].jsonSchema.input({
238
+ target: 'draft-07',
239
+ })
240
+ const result: JSONSchema = toJsonSchema(jsonSchema)
241
+ if ('properties' in result && !result.type) result.type = 'object'
242
+ if (result.type === 'object' && !('properties' in result)) {
243
+ result.properties = {}
244
+ }
245
+ if (result.type === 'object' && !('required' in result)) {
246
+ result.required = []
247
+ }
248
+ return result
249
+ }
250
+
251
+ if (isStandardSchema(schema)) {
252
+ throw new Error(
253
+ 'Schema is a Standard Schema validator but does not expose a JSON Schema ' +
254
+ 'converter on `~standard.jsonSchema`. Use Zod v4.2+, ArkType v2.1.28+, ' +
255
+ 'or wrap a Valibot schema with `toStandardJsonSchema()` from ' +
256
+ '`@valibot/to-json-schema` before passing it as `outputSchema`.',
257
+ )
258
+ }
259
+
260
+ if (typeof schema !== 'object') return schema
261
+ return toJsonSchema(schema)
262
+ }
263
+
182
264
  /**
183
265
  * Converts a Standard JSON Schema compliant schema or plain JSONSchema to JSON Schema format
184
266
  * compatible with LLM providers.
@@ -247,77 +329,48 @@ export function convertSchemaToJsonSchema(
247
329
 
248
330
  const { forStructuredOutput = false } = options
249
331
 
250
- // If it's a Standard JSON Schema compliant schema, use the standard interface
251
- if (isStandardJSONSchema(schema)) {
252
- const jsonSchema = schema['~standard'].jsonSchema.input({
253
- target: 'draft-07',
254
- })
255
-
256
- // Rebuild structurally so the typed JSONSchema view is acquired without
257
- // a `Record<string, unknown> as JSONSchema` cast; `toJsonSchema()` also
258
- // drops the `$schema` key which LLM providers don't need.
259
- let result: JSONSchema = toJsonSchema(jsonSchema)
260
-
261
- // Ensure object schemas always have type: "object"
262
- // If it has properties (even empty), it should be an object type
263
- if ('properties' in result && !result.type) {
264
- result.type = 'object'
265
- }
266
-
267
- // Ensure properties exists for object types (even if empty)
268
- if (result.type === 'object' && !('properties' in result)) {
269
- result.properties = {}
270
- }
271
-
272
- // Ensure required exists for object types (even if empty array)
273
- if (result.type === 'object' && !('required' in result)) {
274
- result.required = []
275
- }
276
-
277
- // Apply structured output transformation if requested
278
- if (forStructuredOutput) {
279
- result = makeStructuredOutputCompatible(result, result.required || [])
280
- }
281
-
282
- return result
283
- }
284
-
285
- // Detect Standard Schema validators (Zod, ArkType, Valibot, …) that don't
286
- // expose a `~standard.jsonSchema` converter. These would otherwise fall
287
- // through to the JSONSchema pass-through below and ship `{ '~standard': … }`
288
- // straight to the LLM provider, producing an opaque downstream error. Fail
289
- // fast with actionable guidance instead.
290
- if (isStandardSchema(schema)) {
291
- throw new Error(
292
- 'Schema is a Standard Schema validator but does not expose a JSON Schema ' +
293
- 'converter on `~standard.jsonSchema`. Use Zod v4.2+, ArkType v2.1.28+, ' +
294
- 'or wrap a Valibot schema with `toStandardJsonSchema()` from ' +
295
- '`@valibot/to-json-schema` before passing it as `outputSchema`.',
296
- )
297
- }
298
-
299
- // If it's not a Standard JSON Schema, assume it's already a JSONSchema and pass through
300
- // Still apply structured output transformation if requested
301
-
302
- // At this branch, `schema` is the plain `JSONSchema` arm of `SchemaInput`
303
- // (the two `~standard` arms were handled above). When no transformation
304
- // is requested we pass the schema through by reference to preserve
305
- // identity for callers that compare via `===`.
306
- if (typeof schema !== 'object') {
307
- // The SchemaInput union is object-shaped on every arm; if we ever hit a
308
- // non-object here, propagate it untouched and let the downstream
309
- // provider error loudly rather than silently widen.
332
+ // Plain-JSONSchema passthrough: with no widening requested, return the schema
333
+ // by reference so callers comparing via `===` keep identity. Only the widening
334
+ // path needs the rebuilt, normalized view from `toTypedJsonSchema`.
335
+ if (
336
+ !forStructuredOutput &&
337
+ !isStandardJSONSchema(schema) &&
338
+ !isStandardSchema(schema)
339
+ ) {
310
340
  return schema
311
341
  }
312
342
 
313
- if (forStructuredOutput) {
314
- // Build a typed view structurally so we don't need a SchemaInput→JSONSchema
315
- // cast on the transformation path.
316
- const typedView = toJsonSchema(schema)
317
- return makeStructuredOutputCompatible(typedView, typedView.required || [])
318
- }
343
+ const base = toTypedJsonSchema(schema)
344
+ // Non-object inputs can't be widened; surface them untouched.
345
+ if (!base || typeof base !== 'object') return base
346
+ if (!forStructuredOutput) return base
347
+ return makeStructuredOutputCompatible(base, base.required || []).schema
348
+ }
319
349
 
320
- return schema
350
+ /**
351
+ * Convert a schema for structured output AND capture the {@link NullWideningMap}
352
+ * recording every `null` the strict-mode widening synthesized. The map lets the
353
+ * caller undo that widening on the provider's response (via `undoNullWidening`)
354
+ * before validating against the original schema — optional fields read back as
355
+ * absent while genuine `.nullable()` nulls survive. The map is `undefined` when
356
+ * the schema isn't a widenable object or when no field needed widening.
357
+ */
358
+ export function convertSchemaForStructuredOutput(
359
+ schema: SchemaInput | undefined,
360
+ ): {
361
+ jsonSchema: JSONSchema | undefined
362
+ nullWideningMap: NullWideningMap | undefined
363
+ } {
364
+ if (!schema) return { jsonSchema: undefined, nullWideningMap: undefined }
365
+ const base = toTypedJsonSchema(schema)
366
+ if (!base || typeof base !== 'object') {
367
+ return { jsonSchema: base, nullWideningMap: undefined }
368
+ }
369
+ const { schema: jsonSchema, nullWidening } = makeStructuredOutputCompatible(
370
+ base,
371
+ base.required || [],
372
+ )
373
+ return { jsonSchema, nullWideningMap: nullWidening }
321
374
  }
322
375
 
323
376
  /**