@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.
- package/README.md +2 -2
- package/dist/esm/adapters/chat-completions-text.d.ts +18 -7
- package/dist/esm/adapters/chat-completions-text.js +86 -89
- package/dist/esm/adapters/chat-completions-text.js.map +1 -1
- package/dist/esm/adapters/chat-completions-tool-converter.test.d.ts +1 -0
- package/dist/esm/adapters/responses-text.d.ts +9 -1
- package/dist/esm/adapters/responses-text.js +41 -6
- package/dist/esm/adapters/responses-text.js.map +1 -1
- package/dist/esm/adapters/responses-tool-converter.test.d.ts +1 -0
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.js +2 -2
- package/dist/esm/tools/function-tool.test.d.ts +1 -0
- package/dist/esm/utils/schema-converter.d.ts +14 -0
- package/dist/esm/utils/schema-converter.js +106 -18
- package/dist/esm/utils/schema-converter.js.map +1 -1
- package/dist/esm/utils/tool-input-normalizer.d.ts +12 -0
- package/dist/esm/utils/tool-input-normalizer.js +36 -0
- package/dist/esm/utils/tool-input-normalizer.js.map +1 -0
- package/dist/esm/utils/tool-input-normalizer.test.d.ts +1 -0
- package/package.json +3 -3
- package/src/adapters/chat-completions-text.ts +128 -120
- package/src/adapters/chat-completions-tool-converter.test.ts +64 -0
- package/src/adapters/responses-text.ts +81 -8
- package/src/adapters/responses-tool-converter.test.ts +69 -0
- package/src/index.ts +4 -1
- package/src/tools/function-tool.test.ts +62 -0
- package/src/utils/schema-converter.ts +165 -20
- package/src/utils/tool-input-normalizer.test.ts +146 -0
- 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
|
|
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
|
-
//
|
|
188
|
-
|
|
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
|
-
):
|
|
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
|
-
|
|
248
|
-
|
|
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:
|
|
338
|
+
items: nested.schema,
|
|
252
339
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|