@tanstack/openai-base 0.10.0 → 0.10.2

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.
Files changed (29) hide show
  1. package/README.md +2 -2
  2. package/dist/esm/adapters/chat-completions-text.d.ts +18 -7
  3. package/dist/esm/adapters/chat-completions-text.js +86 -89
  4. package/dist/esm/adapters/chat-completions-text.js.map +1 -1
  5. package/dist/esm/adapters/chat-completions-tool-converter.test.d.ts +1 -0
  6. package/dist/esm/adapters/responses-text.d.ts +9 -1
  7. package/dist/esm/adapters/responses-text.js +41 -6
  8. package/dist/esm/adapters/responses-text.js.map +1 -1
  9. package/dist/esm/adapters/responses-tool-converter.test.d.ts +1 -0
  10. package/dist/esm/index.d.ts +1 -1
  11. package/dist/esm/index.js +2 -2
  12. package/dist/esm/tools/function-tool.test.d.ts +1 -0
  13. package/dist/esm/utils/schema-converter.d.ts +14 -0
  14. package/dist/esm/utils/schema-converter.js +106 -18
  15. package/dist/esm/utils/schema-converter.js.map +1 -1
  16. package/dist/esm/utils/tool-input-normalizer.d.ts +12 -0
  17. package/dist/esm/utils/tool-input-normalizer.js +36 -0
  18. package/dist/esm/utils/tool-input-normalizer.js.map +1 -0
  19. package/dist/esm/utils/tool-input-normalizer.test.d.ts +1 -0
  20. package/package.json +3 -3
  21. package/src/adapters/chat-completions-text.ts +128 -120
  22. package/src/adapters/chat-completions-tool-converter.test.ts +64 -0
  23. package/src/adapters/responses-text.ts +81 -8
  24. package/src/adapters/responses-tool-converter.test.ts +69 -0
  25. package/src/index.ts +4 -1
  26. package/src/tools/function-tool.test.ts +62 -0
  27. package/src/utils/schema-converter.ts +165 -20
  28. package/src/utils/tool-input-normalizer.test.ts +146 -0
  29. package/src/utils/tool-input-normalizer.ts +55 -0
@@ -1,3 +1,5 @@
1
+ import type { NullWideningMap } from '@tanstack/ai-utils'
2
+
1
3
  /**
2
4
  * String `format` values accepted by OpenAI's strict Structured Outputs subset.
3
5
  * Any other format (e.g. "uri", "uri-reference", "regex") causes the API to
@@ -61,7 +63,35 @@ export function makeStructuredOutputCompatible(
61
63
  schema: Record<string, any>,
62
64
  originalRequired?: Array<string>,
63
65
  ): Record<string, any> {
64
- return stripUnsupportedFormats(coerceStrictSchema(schema, originalRequired))
66
+ return makeStructuredOutputCompatibleWithMap(schema, originalRequired).schema
67
+ }
68
+
69
+ export interface StructuredOutputCompatibility {
70
+ schema: Record<string, any>
71
+ nullWideningMap: NullWideningMap | undefined
72
+ }
73
+
74
+ interface CoercedStrictSchema extends StructuredOutputCompatibility {
75
+ hasUntrackableAnyOfWidening: boolean
76
+ }
77
+
78
+ /**
79
+ * Strict-schema conversion plus an exact map of the nullability introduced by
80
+ * that conversion. Consumers can pass provider output through
81
+ * `undoNullWidening` before validating it against the original schema.
82
+ */
83
+ export function makeStructuredOutputCompatibleWithMap(
84
+ schema: Record<string, any>,
85
+ originalRequired?: Array<string>,
86
+ ): StructuredOutputCompatibility {
87
+ const { schema: strictSchema, nullWideningMap } = coerceStrictSchema(
88
+ schema,
89
+ originalRequired,
90
+ )
91
+ return {
92
+ schema: stripUnsupportedFormats(strictSchema),
93
+ nullWideningMap,
94
+ }
65
95
  }
66
96
 
67
97
  /**
@@ -118,6 +148,9 @@ const TYPE_INDICATOR_KEYWORDS: ReadonlyArray<string> = [
118
148
  * 3. It contains an open object schema. OpenAI strict mode requires objects to
119
149
  * set `additionalProperties: false`, which would change the semantics of a
120
150
  * free-form map rather than merely normalizing it.
151
+ * 4. An `anyOf` variant itself needs null widening. The inverse map is
152
+ * intentionally schema-blind, so it cannot select a variant without risking
153
+ * removal of a genuine nullable value accepted by another variant.
121
154
  *
122
155
  * Conservative by design: for (1) keywords are matched as object keys, so a
123
156
  * property literally named e.g. `oneOf` also trips it. That only costs that one
@@ -128,10 +161,24 @@ export function isStrictModeCompatible(schema: unknown): boolean {
128
161
  return (
129
162
  !containsStrictUnsupportedKeyword(schema) &&
130
163
  !containsTypelessSchema(schema) &&
131
- !containsOpenObject(schema)
164
+ !containsOpenObject(schema) &&
165
+ !containsUntrackableAnyOfWidening(schema)
132
166
  )
133
167
  }
134
168
 
169
+ /**
170
+ * Reports strict conversions whose synthesized nulls cannot be represented by
171
+ * the schema-blind inverse map. Optional `anyOf` wrappers remain supported:
172
+ * only widening introduced inside one of their variants triggers fallback.
173
+ */
174
+ function containsUntrackableAnyOfWidening(schema: unknown): boolean {
175
+ if (schema === null || typeof schema !== 'object' || Array.isArray(schema)) {
176
+ return false
177
+ }
178
+ return coerceStrictSchema(schema as Record<string, any>)
179
+ .hasUntrackableAnyOfWidening
180
+ }
181
+
135
182
  /**
136
183
  * Reports object schemas that cannot be closed without changing their input
137
184
  * semantics. Objects with `properties` and no explicit
@@ -184,8 +231,10 @@ function containsStrictUnsupportedKeyword(node: unknown): boolean {
184
231
  /** A schema-position node that declares no type and so 400s strict mode. */
185
232
  function isTypelessSchema(node: unknown): boolean {
186
233
  if (node === null || typeof node !== 'object' || Array.isArray(node)) {
187
- // boolean schemas (`true`/`false`) and non-objects aren't typeless props.
188
- return false
234
+ // JSON Schema permits bare boolean nodes; malformed inputs may contain
235
+ // other primitives. OpenAI's strict subset requires a declared type, so
236
+ // preserve the containing tool by sending it in non-strict mode.
237
+ return true
189
238
  }
190
239
  return !TYPE_INDICATOR_KEYWORDS.some((key) => key in node)
191
240
  }
@@ -225,11 +274,42 @@ function containsTypelessSchema(node: unknown): boolean {
225
274
  * additionalProperties). Kept private so the public entry point can apply the
226
275
  * format-stripping pass exactly once over the fully-rewritten tree.
227
276
  */
277
+ function pruneMap(map: NullWideningMap): NullWideningMap | undefined {
278
+ return Object.keys(map).length > 0 ? map : undefined
279
+ }
280
+
281
+ function isSchemaObject(schema: unknown): schema is Record<string, any> {
282
+ return typeof schema === 'object' && schema !== null && !Array.isArray(schema)
283
+ }
284
+
285
+ /** Whether every active JSON Schema constraint at this node admits null. */
286
+ function acceptsNull(schema: unknown): boolean {
287
+ if (schema === true) return true
288
+ if (!isSchemaObject(schema)) return false
289
+
290
+ if ('const' in schema && schema.const !== null) return false
291
+ if (Array.isArray(schema.enum) && !schema.enum.includes(null)) return false
292
+
293
+ if (typeof schema.type === 'string' && schema.type !== 'null') return false
294
+ if (Array.isArray(schema.type) && !schema.type.includes('null')) return false
295
+
296
+ if (
297
+ Array.isArray(schema.anyOf) &&
298
+ !schema.anyOf.some((variant: unknown) => acceptsNull(variant))
299
+ ) {
300
+ return false
301
+ }
302
+
303
+ return true
304
+ }
305
+
228
306
  function coerceStrictSchema(
229
307
  schema: Record<string, any>,
230
308
  originalRequired?: Array<string>,
231
- ): Record<string, any> {
309
+ ): CoercedStrictSchema {
232
310
  const result = { ...schema }
311
+ const nullWideningMap: NullWideningMap = {}
312
+ let hasUntrackableAnyOfWidening = false
233
313
  const required =
234
314
  originalRequired ??
235
315
  (Array.isArray(result['required']) ? result['required'] : [])
@@ -237,22 +317,36 @@ function coerceStrictSchema(
237
317
  if (result.type === 'object' && result.properties) {
238
318
  const properties = { ...result.properties }
239
319
  const allPropertyNames = Object.keys(properties)
320
+ const propertyMaps: Record<string, NullWideningMap> = {}
240
321
 
241
322
  for (const propName of allPropertyNames) {
242
323
  let prop = properties[propName]
243
324
  const wasOptional = !required.includes(propName)
325
+ let childMap: NullWideningMap | undefined
326
+ let widenedHere = false
244
327
 
245
328
  // Step 1: Recurse into nested structures
246
- if (prop.type === 'object' && prop.properties) {
247
- prop = coerceStrictSchema(prop, prop.required || [])
248
- } else if (prop.type === 'array' && prop.items) {
329
+ if (isSchemaObject(prop) && prop.type === 'object' && prop.properties) {
330
+ const nested = coerceStrictSchema(prop, prop.required || [])
331
+ prop = nested.schema
332
+ childMap = nested.nullWideningMap
333
+ hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening
334
+ } else if (isSchemaObject(prop) && prop.type === 'array' && prop.items) {
335
+ const nested = coerceStrictSchema(prop.items, prop.items.required || [])
249
336
  prop = {
250
337
  ...prop,
251
- items: coerceStrictSchema(prop.items, prop.items.required || []),
338
+ items: nested.schema,
252
339
  }
253
- } else if (prop.anyOf) {
254
- prop = coerceStrictSchema(prop, prop.required || [])
255
- } else if (prop.oneOf) {
340
+ childMap = nested.nullWideningMap
341
+ ? { items: nested.nullWideningMap }
342
+ : undefined
343
+ hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening
344
+ } else if (isSchemaObject(prop) && prop.anyOf) {
345
+ const nested = coerceStrictSchema(prop, prop.required || [])
346
+ prop = nested.schema
347
+ childMap = nested.nullWideningMap
348
+ hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening
349
+ } else if (isSchemaObject(prop) && prop.oneOf) {
256
350
  throw new Error(
257
351
  'oneOf is not supported in OpenAI structured output schemas. Check the supported outputs here: https://platform.openai.com/docs/guides/structured-outputs#supported-types',
258
352
  )
@@ -260,34 +354,81 @@ function coerceStrictSchema(
260
354
 
261
355
  // Step 2: Apply null-widening for optional properties (after recursion)
262
356
  if (wasOptional) {
263
- if (prop.anyOf) {
264
- // For anyOf, add a null variant if not already present
265
- if (!prop.anyOf.some((v: any) => v.type === 'null')) {
357
+ const originallyAcceptedNull = acceptsNull(prop)
358
+
359
+ // `type: [..., 'null']` alone does not make null valid when an enum or
360
+ // const still excludes it; strict decoding would be forced to emit the
361
+ // original literal instead of the synthetic omission marker.
362
+ if (isSchemaObject(prop) && 'const' in prop && prop.const !== null) {
363
+ const { const: constValue, ...withoutConst } = prop
364
+ prop = { ...withoutConst, enum: [constValue, null] }
365
+ } else if (
366
+ isSchemaObject(prop) &&
367
+ Array.isArray(prop.enum) &&
368
+ !prop.enum.includes(null)
369
+ ) {
370
+ prop = { ...prop, enum: [...prop.enum, null] }
371
+ }
372
+
373
+ if (isSchemaObject(prop) && prop.anyOf) {
374
+ // A genuine null branch can use type, enum, or const. Only add a
375
+ // provider omission marker when the original union rejected null.
376
+ if (!acceptsNull(prop)) {
266
377
  prop = { ...prop, anyOf: [...prop.anyOf, { type: 'null' }] }
267
378
  }
268
- } else if (prop.type && !Array.isArray(prop.type)) {
379
+ } else if (
380
+ isSchemaObject(prop) &&
381
+ prop.type &&
382
+ !Array.isArray(prop.type)
383
+ ) {
269
384
  prop = { ...prop, type: [prop.type, 'null'] }
270
- } else if (Array.isArray(prop.type) && !prop.type.includes('null')) {
385
+ } else if (
386
+ isSchemaObject(prop) &&
387
+ Array.isArray(prop.type) &&
388
+ !prop.type.includes('null')
389
+ ) {
271
390
  prop = { ...prop, type: [...prop.type, 'null'] }
272
391
  }
392
+
393
+ widenedHere = !originallyAcceptedNull && acceptsNull(prop)
273
394
  }
274
395
 
275
396
  properties[propName] = prop
397
+ if (childMap || widenedHere) {
398
+ propertyMaps[propName] = {
399
+ ...(childMap ?? {}),
400
+ ...(widenedHere ? { widened: true } : {}),
401
+ }
402
+ }
276
403
  }
277
404
 
278
405
  result.properties = properties
279
406
  result.required = allPropertyNames
280
407
  result.additionalProperties = false
408
+ if (Object.keys(propertyMaps).length > 0) {
409
+ nullWideningMap.properties = propertyMaps
410
+ }
281
411
  }
282
412
 
283
413
  if (result.type === 'array' && result.items) {
284
- result.items = coerceStrictSchema(result.items, result.items.required || [])
414
+ const nested = coerceStrictSchema(result.items, result.items.required || [])
415
+ result.items = nested.schema
416
+ if (nested.nullWideningMap) {
417
+ nullWideningMap.items = nested.nullWideningMap
418
+ }
419
+ hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening
285
420
  }
286
421
 
287
422
  if (result.anyOf && Array.isArray(result.anyOf)) {
288
- result.anyOf = result.anyOf.map((variant) =>
423
+ const variants = result.anyOf.map((variant) =>
289
424
  coerceStrictSchema(variant, variant.required || []),
290
425
  )
426
+ result.anyOf = variants.map((variant) => variant.schema)
427
+ hasUntrackableAnyOfWidening ||= variants.some(
428
+ (variant) =>
429
+ variant.nullWideningMap !== undefined ||
430
+ variant.hasUntrackableAnyOfWidening,
431
+ )
291
432
  }
292
433
 
293
434
  if (result.oneOf) {
@@ -296,5 +437,9 @@ function coerceStrictSchema(
296
437
  )
297
438
  }
298
439
 
299
- return result
440
+ return {
441
+ schema: result,
442
+ nullWideningMap: pruneMap(nullWideningMap),
443
+ hasUntrackableAnyOfWidening,
444
+ }
300
445
  }
@@ -0,0 +1,146 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createToolInputNormalizer } from './tool-input-normalizer'
3
+ import type { Tool } from '@tanstack/ai'
4
+
5
+ describe('createToolInputNormalizer', () => {
6
+ it('removes a null synthesized for an optional enum', () => {
7
+ const tool: Tool = {
8
+ name: 'optional_enum',
9
+ description: 'Uses an optional literal value',
10
+ inputSchema: {
11
+ type: 'object',
12
+ properties: {
13
+ value: { type: 'string', enum: ['canary'] },
14
+ },
15
+ required: [],
16
+ },
17
+ }
18
+ const normalize = createToolInputNormalizer([tool])
19
+
20
+ expect(normalize(tool.name, { value: null })).toEqual({})
21
+ })
22
+
23
+ it('leaves non-strict tool inputs unchanged', () => {
24
+ const tool: Tool = {
25
+ name: 'non_strict',
26
+ description: 'Uses a schema outside the strict subset',
27
+ inputSchema: {
28
+ type: 'object',
29
+ properties: { optional: { type: 'string' } },
30
+ oneOf: [{ required: ['optional'] }, { required: [] }],
31
+ },
32
+ }
33
+ const normalize = createToolInputNormalizer([tool])
34
+ const input = { optional: null }
35
+
36
+ expect(normalize(tool.name, input)).toBe(input)
37
+ })
38
+
39
+ it('does not guess when public tool names are ambiguous', () => {
40
+ const tools: Array<Tool> = [
41
+ {
42
+ name: 'duplicate',
43
+ description: 'First tool',
44
+ inputSchema: {
45
+ type: 'object',
46
+ properties: { firstOptional: { type: 'string' } },
47
+ },
48
+ },
49
+ {
50
+ name: 'duplicate',
51
+ description: 'Second tool',
52
+ inputSchema: {
53
+ type: 'object',
54
+ properties: { secondOptional: { type: 'string' } },
55
+ },
56
+ },
57
+ ]
58
+ const normalize = createToolInputNormalizer(tools)
59
+ const input = { firstOptional: null, secondOptional: null }
60
+
61
+ expect(normalize('duplicate', input)).toBe(input)
62
+ })
63
+
64
+ it('leaves anyOf inputs unchanged when variant widening is ambiguous', () => {
65
+ const tool: Tool = {
66
+ name: 'union',
67
+ description: 'Uses variant-specific nullability',
68
+ inputSchema: {
69
+ type: 'object',
70
+ properties: {
71
+ value: {
72
+ anyOf: [
73
+ {
74
+ type: 'object',
75
+ properties: {
76
+ kind: { const: 'optional' },
77
+ note: { type: 'string' },
78
+ },
79
+ required: ['kind'],
80
+ },
81
+ {
82
+ type: 'object',
83
+ properties: {
84
+ kind: { const: 'nullable' },
85
+ note: { type: ['string', 'null'] },
86
+ },
87
+ required: ['kind', 'note'],
88
+ },
89
+ ],
90
+ },
91
+ },
92
+ required: ['value'],
93
+ },
94
+ }
95
+ const normalize = createToolInputNormalizer([tool])
96
+ const input = { value: { kind: 'nullable', note: null } }
97
+
98
+ expect(normalize(tool.name, input)).toBe(input)
99
+ })
100
+
101
+ it.each([
102
+ ['const', { anyOf: [{ type: 'string' }, { const: null }] }],
103
+ ['enum', { anyOf: [{ type: 'string' }, { enum: [null] }] }],
104
+ ])('preserves genuine null accepted by an anyOf %s branch', (_, value) => {
105
+ const tool: Tool = {
106
+ name: 'nullable_union',
107
+ description: 'Uses an optional union that genuinely accepts null',
108
+ inputSchema: {
109
+ type: 'object',
110
+ properties: { value },
111
+ required: [],
112
+ },
113
+ }
114
+ const normalize = createToolInputNormalizer([tool])
115
+ const input = { value: null }
116
+
117
+ expect(normalize(tool.name, input)).toBe(input)
118
+ })
119
+
120
+ it('builds the inverse map from the supplied converter', () => {
121
+ const tool: Tool = {
122
+ name: 'ask',
123
+ description: 'Ask',
124
+ inputSchema: {
125
+ type: 'object',
126
+ properties: {
127
+ note: { type: 'string' },
128
+ },
129
+ required: ['note'],
130
+ },
131
+ }
132
+
133
+ const defaultNormalize = createToolInputNormalizer([tool])
134
+ expect(defaultNormalize(tool.name, { note: null })).toEqual({ note: null })
135
+
136
+ const normalize = createToolInputNormalizer([tool], () => ({
137
+ schema: {
138
+ type: 'object',
139
+ properties: { note: { type: ['string', 'null'] } },
140
+ required: ['note'],
141
+ },
142
+ nullWideningMap: { properties: { note: { widened: true } } },
143
+ }))
144
+ expect(normalize(tool.name, { note: null })).toEqual({})
145
+ })
146
+ })
@@ -0,0 +1,55 @@
1
+ import { undoNullWidening } from '@tanstack/ai-utils'
2
+ import {
3
+ isStrictModeCompatible,
4
+ makeStructuredOutputCompatibleWithMap,
5
+ } from './schema-converter'
6
+ import type { JSONSchema, Tool } from '@tanstack/ai'
7
+ import type { NullWideningMap } from '@tanstack/ai-utils'
8
+ import type { StructuredOutputCompatibility } from './schema-converter'
9
+
10
+ type ToolInputNormalizer = (toolName: string, input: unknown) => unknown
11
+
12
+ type SchemaConverterWithMap = (
13
+ schema: Record<string, any>,
14
+ originalRequired?: Array<string>,
15
+ ) => StructuredOutputCompatibility
16
+
17
+ /**
18
+ * Build the inverse transform for the strict tool schemas sent in one request.
19
+ * Pass the same converter the request used so subclass schema tweaks stay
20
+ * aligned with undo. Non-strict tools are excluded because they were not
21
+ * null-widened on the wire.
22
+ */
23
+ export function createToolInputNormalizer(
24
+ tools: Array<Tool> | undefined,
25
+ convertSchema: SchemaConverterWithMap = makeStructuredOutputCompatibleWithMap,
26
+ ): ToolInputNormalizer {
27
+ const maps = new Map<string, NullWideningMap>()
28
+ const seenNames = new Set<string>()
29
+ const ambiguousNames = new Set<string>()
30
+
31
+ for (const tool of tools ?? []) {
32
+ if (ambiguousNames.has(tool.name)) continue
33
+ if (seenNames.has(tool.name)) {
34
+ maps.delete(tool.name)
35
+ ambiguousNames.add(tool.name)
36
+ continue
37
+ }
38
+ seenNames.add(tool.name)
39
+
40
+ const inputSchema = (tool.inputSchema ?? {
41
+ type: 'object',
42
+ properties: {},
43
+ required: [],
44
+ }) as JSONSchema
45
+ if (!isStrictModeCompatible(inputSchema)) continue
46
+
47
+ const { nullWideningMap } = convertSchema(
48
+ inputSchema,
49
+ inputSchema.required || [],
50
+ )
51
+ if (nullWideningMap) maps.set(tool.name, nullWideningMap)
52
+ }
53
+
54
+ return (toolName, input) => undoNullWidening(input, maps.get(toolName))
55
+ }