@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.
- package/dist/esm/activities/chat/index.js +23 -17
- package/dist/esm/activities/chat/index.js.map +1 -1
- package/dist/esm/activities/chat/tools/schema-converter.d.ts +13 -0
- package/dist/esm/activities/chat/tools/schema-converter.js +61 -33
- package/dist/esm/activities/chat/tools/schema-converter.js.map +1 -1
- package/package.json +3 -2
- package/src/activities/chat/index.ts +93 -31
- package/src/activities/chat/tools/schema-converter.ts +146 -93
|
@@ -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
|
|
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
|
-
):
|
|
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
|
|
116
|
-
prop,
|
|
117
|
-
prop.required || [],
|
|
118
|
-
)
|
|
142
|
+
const nested = makeStructuredOutputCompatible(prop, prop.required || [])
|
|
119
143
|
properties[propName] = wasOptional
|
|
120
|
-
? { ...
|
|
121
|
-
:
|
|
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
|
|
150
|
+
const nestedItems = items
|
|
151
|
+
? makeStructuredOutputCompatible(items, items.required || [])
|
|
152
|
+
: undefined
|
|
153
|
+
properties[propName] = {
|
|
125
154
|
...prop,
|
|
126
|
-
items: items
|
|
127
|
-
|
|
128
|
-
: prop.items,
|
|
155
|
+
items: nestedItems ? nestedItems.schema : prop.items,
|
|
156
|
+
...(wasOptional ? { type: ['array', 'null'] } : {}),
|
|
129
157
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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
|
/**
|