diff --git a/.changeset/calm-tools-return.md b/.changeset/calm-tools-return.md new file mode 100644 index 000000000..5cf6eff84 --- /dev/null +++ b/.changeset/calm-tools-return.md @@ -0,0 +1,5 @@ +--- +'@tanstack/openai-base': patch +--- + +Undo strict-mode null widening before validating and executing OpenAI-compatible tool calls. diff --git a/packages/openai-base/src/adapters/chat-completions-text.ts b/packages/openai-base/src/adapters/chat-completions-text.ts index 72ea19ca7..d9c54ffa5 100644 --- a/packages/openai-base/src/adapters/chat-completions-text.ts +++ b/packages/openai-base/src/adapters/chat-completions-text.ts @@ -7,6 +7,7 @@ import { import { generateId } from '@tanstack/ai-utils' import { extractRequestOptions } from '../utils/request-options' import { makeStructuredOutputCompatible } from '../utils/schema-converter' +import { createToolInputNormalizer } from '../utils/tool-input-normalizer' import { buildChatCompletionsUsage } from '../usage' import { convertToolsToChatCompletionsFormat } from './chat-completions-tool-converter' import type OpenAI from 'openai' @@ -620,6 +621,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< hasEmittedRunStarted: boolean }, ): AsyncIterable { + const normalizeToolInput = createToolInputNormalizer(options.tools) let accumulatedContent = '' let hasEmittedTextMessageStart = false let lastModel: string | undefined @@ -888,8 +890,10 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< if (toolCall.arguments) { try { const parsed: unknown = JSON.parse(toolCall.arguments) - parsedInput = - parsed && typeof parsed === 'object' ? parsed : {} + parsedInput = normalizeToolInput( + toolCall.name, + parsed && typeof parsed === 'object' ? parsed : {}, + ) } catch (parseError) { options.logger.errors( `${this.name}.processStreamChunks tool-args JSON parse failed`, @@ -960,7 +964,10 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< if (toolCall.arguments) { try { const parsed: unknown = JSON.parse(toolCall.arguments) - parsedInput = parsed && typeof parsed === 'object' ? parsed : {} + parsedInput = normalizeToolInput( + toolCall.name, + parsed && typeof parsed === 'object' ? parsed : {}, + ) } catch (parseError) { // Mirror the finish_reason path's logger call — a truncated // stream emitting malformed tool-call JSON would otherwise diff --git a/packages/openai-base/src/adapters/chat-completions-tool-converter.test.ts b/packages/openai-base/src/adapters/chat-completions-tool-converter.test.ts new file mode 100644 index 000000000..92ad2f869 --- /dev/null +++ b/packages/openai-base/src/adapters/chat-completions-tool-converter.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { convertFunctionToolToChatCompletionsFormat } from './chat-completions-tool-converter' +import type { Tool } from '@tanstack/ai' + +describe('chat-completions tool converter', () => { + it('falls back from strict mode when an anyOf variant needs null widening', () => { + const out = convertFunctionToolToChatCompletionsFormat( + anyOfOptionalVariantTool, + ) + + expect(out.function.strict).toBe(false) + }) + + it('falls back from strict mode for boolean schema nodes', () => { + const out = convertFunctionToolToChatCompletionsFormat(booleanSchemaTool) + + expect(out.function.strict).toBe(false) + expect(out.function.parameters).toEqual(booleanSchemaTool.inputSchema) + }) +}) + +const booleanSchemaInput = { + type: 'object', + properties: {}, + required: [], +} +Reflect.set(booleanSchemaInput.properties, 'acceptAnything', true) + +const booleanSchemaTool = { + name: 'accept_anything', + description: 'Accept any value', + inputSchema: booleanSchemaInput, +} satisfies Tool + +const anyOfOptionalVariantTool: Tool = { + name: 'store_variant', + description: 'Store a union variant', + inputSchema: { + type: 'object', + properties: { + value: { + anyOf: [ + { + type: 'object', + properties: { + kind: { const: 'optional' }, + note: { type: 'string' }, + }, + required: ['kind'], + }, + { + type: 'object', + properties: { + kind: { const: 'nullable' }, + note: { type: ['string', 'null'] }, + }, + required: ['kind', 'note'], + }, + ], + }, + }, + required: ['value'], + }, +} diff --git a/packages/openai-base/src/adapters/responses-text.ts b/packages/openai-base/src/adapters/responses-text.ts index d48018209..039e385a3 100644 --- a/packages/openai-base/src/adapters/responses-text.ts +++ b/packages/openai-base/src/adapters/responses-text.ts @@ -7,6 +7,7 @@ import { import { generateId } from '@tanstack/ai-utils' import { extractRequestOptions } from '../utils/request-options' import { makeStructuredOutputCompatible } from '../utils/schema-converter' +import { createToolInputNormalizer } from '../utils/tool-input-normalizer' import { buildResponsesUsage } from '../usage' import { convertToolsToResponsesFormat } from './responses-tool-converter' import type OpenAI from 'openai' @@ -777,6 +778,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< hasEmittedRunStarted: boolean }, ): AsyncIterable { + const normalizeToolInput = createToolInputNormalizer(options.tools) let accumulatedContent = '' let accumulatedReasoning = '' @@ -1287,7 +1289,10 @@ export abstract class OpenAIBaseResponsesTextAdapter< if (chunk.arguments) { try { const parsed = JSON.parse(chunk.arguments) - parsedInput = parsed && typeof parsed === 'object' ? parsed : {} + parsedInput = normalizeToolInput( + name, + parsed && typeof parsed === 'object' ? parsed : {}, + ) } catch (parseError) { options.logger.errors( `${this.name}.processStreamChunks tool-args JSON parse failed`, @@ -1361,8 +1366,10 @@ export abstract class OpenAIBaseResponsesTextAdapter< if (rawArgs) { try { const parsed = JSON.parse(rawArgs) - parsedInput = - parsed && typeof parsed === 'object' ? parsed : {} + parsedInput = normalizeToolInput( + name, + parsed && typeof parsed === 'object' ? parsed : {}, + ) } catch (parseError) { options.logger.errors( `${this.name}.processStreamChunks tool-args JSON parse failed (output_item.done backfill)`, @@ -1438,8 +1445,10 @@ export abstract class OpenAIBaseResponsesTextAdapter< if (rawArgs) { try { const parsed = JSON.parse(rawArgs) - parsedInput = - parsed && typeof parsed === 'object' ? parsed : {} + parsedInput = normalizeToolInput( + name, + parsed && typeof parsed === 'object' ? parsed : {}, + ) } catch (parseError) { options.logger.errors( `${this.name}.processStreamChunks tool-args JSON parse failed (response.completed backfill)`, diff --git a/packages/openai-base/src/adapters/responses-tool-converter.test.ts b/packages/openai-base/src/adapters/responses-tool-converter.test.ts new file mode 100644 index 000000000..f08d8dcff --- /dev/null +++ b/packages/openai-base/src/adapters/responses-tool-converter.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { convertFunctionToolToResponsesFormat } from './responses-tool-converter' +import type { Tool } from '@tanstack/ai' + +describe('responses tool converter', () => { + it('falls back from strict mode when an anyOf variant needs null widening', () => { + const out = convertFunctionToolToResponsesFormat(anyOfOptionalVariantTool) + + expect(out.strict).toBe(false) + expect(out.parameters).toMatchObject({ + properties: { + value: { + anyOf: [ + { required: ['kind'] }, + { required: ['kind', 'note'] }, + ], + }, + }, + }) + }) + + it('falls back from strict mode for boolean schema nodes', () => { + const out = convertFunctionToolToResponsesFormat(booleanSchemaTool) + + expect(out.strict).toBe(false) + expect(out.parameters).toEqual(booleanSchemaTool.inputSchema) + }) +}) + +const booleanSchemaInput = { + type: 'object', + properties: {}, + required: [], +} +Reflect.set(booleanSchemaInput.properties, 'acceptAnything', true) + +const booleanSchemaTool = { + name: 'accept_anything', + description: 'Accept any value', + inputSchema: booleanSchemaInput, +} satisfies Tool + +const anyOfOptionalVariantTool: Tool = { + name: 'store_variant', + description: 'Store a union variant', + inputSchema: { + type: 'object', + properties: { + value: { + anyOf: [ + { + type: 'object', + properties: { + kind: { const: 'optional' }, + note: { type: 'string' }, + }, + required: ['kind'], + }, + { + type: 'object', + properties: { + kind: { const: 'nullable' }, + note: { type: ['string', 'null'] }, + }, + required: ['kind', 'note'], + }, + ], + }, + }, + required: ['value'], + }, +} diff --git a/packages/openai-base/src/tools/function-tool.test.ts b/packages/openai-base/src/tools/function-tool.test.ts new file mode 100644 index 000000000..e2c5d7661 --- /dev/null +++ b/packages/openai-base/src/tools/function-tool.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { convertFunctionToolToAdapterFormat } from './function-tool' +import type { Tool } from '@tanstack/ai' + +describe('function-tool adapter converter', () => { + it('falls back from strict mode when an anyOf variant needs null widening', () => { + const out = convertFunctionToolToAdapterFormat(anyOfOptionalVariantTool) + + expect(out.strict).toBe(false) + }) + + it('falls back from strict mode for boolean schema nodes', () => { + const out = convertFunctionToolToAdapterFormat(booleanSchemaTool) + + expect(out.strict).toBe(false) + expect(out.parameters).toEqual(booleanSchemaTool.inputSchema) + }) +}) + +const booleanSchemaInput = { + type: 'object', + properties: {}, + required: [], +} +Reflect.set(booleanSchemaInput.properties, 'acceptAnything', true) + +const booleanSchemaTool = { + name: 'accept_anything', + description: 'Accept any value', + inputSchema: booleanSchemaInput, +} satisfies Tool + +const anyOfOptionalVariantTool: Tool = { + name: 'store_variant', + description: 'Store a union variant', + inputSchema: { + type: 'object', + properties: { + value: { + anyOf: [ + { + type: 'object', + properties: { + kind: { const: 'optional' }, + note: { type: 'string' }, + }, + required: ['kind'], + }, + { + type: 'object', + properties: { + kind: { const: 'nullable' }, + note: { type: ['string', 'null'] }, + }, + required: ['kind', 'note'], + }, + ], + }, + }, + required: ['value'], + }, +} diff --git a/packages/openai-base/src/utils/schema-converter.ts b/packages/openai-base/src/utils/schema-converter.ts index 540308be1..9afa9c8fc 100644 --- a/packages/openai-base/src/utils/schema-converter.ts +++ b/packages/openai-base/src/utils/schema-converter.ts @@ -1,3 +1,5 @@ +import type { NullWideningMap } from '@tanstack/ai-utils' + /** * String `format` values accepted by OpenAI's strict Structured Outputs subset. * Any other format (e.g. "uri", "uri-reference", "regex") causes the API to @@ -61,7 +63,35 @@ export function makeStructuredOutputCompatible( schema: Record, originalRequired?: Array, ): Record { - return stripUnsupportedFormats(coerceStrictSchema(schema, originalRequired)) + return makeStructuredOutputCompatibleWithMap(schema, originalRequired).schema +} + +interface StructuredOutputCompatibility { + schema: Record + nullWideningMap: NullWideningMap | undefined +} + +interface CoercedStrictSchema extends StructuredOutputCompatibility { + hasUntrackableAnyOfWidening: boolean +} + +/** + * Strict-schema conversion plus an exact map of the nullability introduced by + * that conversion. Consumers can pass provider output through + * `undoNullWidening` before validating it against the original schema. + */ +export function makeStructuredOutputCompatibleWithMap( + schema: Record, + originalRequired?: Array, +): StructuredOutputCompatibility { + const { schema: strictSchema, nullWideningMap } = coerceStrictSchema( + schema, + originalRequired, + ) + return { + schema: stripUnsupportedFormats(strictSchema), + nullWideningMap, + } } /** @@ -118,6 +148,9 @@ const TYPE_INDICATOR_KEYWORDS: ReadonlyArray = [ * 3. It contains an open object schema. OpenAI strict mode requires objects to * set `additionalProperties: false`, which would change the semantics of a * free-form map rather than merely normalizing it. + * 4. An `anyOf` variant itself needs null widening. The inverse map is + * intentionally schema-blind, so it cannot select a variant without risking + * removal of a genuine nullable value accepted by another variant. * * Conservative by design: for (1) keywords are matched as object keys, so a * property literally named e.g. `oneOf` also trips it. That only costs that one @@ -128,10 +161,24 @@ export function isStrictModeCompatible(schema: unknown): boolean { return ( !containsStrictUnsupportedKeyword(schema) && !containsTypelessSchema(schema) && - !containsOpenObject(schema) + !containsOpenObject(schema) && + !containsUntrackableAnyOfWidening(schema) ) } +/** + * Reports strict conversions whose synthesized nulls cannot be represented by + * the schema-blind inverse map. Optional `anyOf` wrappers remain supported: + * only widening introduced inside one of their variants triggers fallback. + */ +function containsUntrackableAnyOfWidening(schema: unknown): boolean { + if (schema === null || typeof schema !== 'object' || Array.isArray(schema)) { + return false + } + return coerceStrictSchema(schema as Record) + .hasUntrackableAnyOfWidening +} + /** * Reports object schemas that cannot be closed without changing their input * semantics. Objects with `properties` and no explicit @@ -184,8 +231,10 @@ function containsStrictUnsupportedKeyword(node: unknown): boolean { /** A schema-position node that declares no type and so 400s strict mode. */ function isTypelessSchema(node: unknown): boolean { if (node === null || typeof node !== 'object' || Array.isArray(node)) { - // boolean schemas (`true`/`false`) and non-objects aren't typeless props. - return false + // JSON Schema permits bare boolean nodes; malformed inputs may contain + // other primitives. OpenAI's strict subset requires a declared type, so + // preserve the containing tool by sending it in non-strict mode. + return true } return !TYPE_INDICATOR_KEYWORDS.some((key) => key in node) } @@ -225,11 +274,42 @@ function containsTypelessSchema(node: unknown): boolean { * additionalProperties). Kept private so the public entry point can apply the * format-stripping pass exactly once over the fully-rewritten tree. */ +function pruneMap(map: NullWideningMap): NullWideningMap | undefined { + return Object.keys(map).length > 0 ? map : undefined +} + +function isSchemaObject(schema: unknown): schema is Record { + return typeof schema === 'object' && schema !== null && !Array.isArray(schema) +} + +/** Whether every active JSON Schema constraint at this node admits null. */ +function acceptsNull(schema: unknown): boolean { + if (schema === true) return true + if (!isSchemaObject(schema)) return false + + if ('const' in schema && schema.const !== null) return false + if (Array.isArray(schema.enum) && !schema.enum.includes(null)) return false + + if (typeof schema.type === 'string' && schema.type !== 'null') return false + if (Array.isArray(schema.type) && !schema.type.includes('null')) return false + + if ( + Array.isArray(schema.anyOf) && + !schema.anyOf.some((variant: unknown) => acceptsNull(variant)) + ) { + return false + } + + return true +} + function coerceStrictSchema( schema: Record, originalRequired?: Array, -): Record { +): CoercedStrictSchema { const result = { ...schema } + const nullWideningMap: NullWideningMap = {} + let hasUntrackableAnyOfWidening = false const required = originalRequired ?? (Array.isArray(result['required']) ? result['required'] : []) @@ -237,22 +317,40 @@ function coerceStrictSchema( if (result.type === 'object' && result.properties) { const properties = { ...result.properties } const allPropertyNames = Object.keys(properties) + const propertyMaps: Record = {} for (const propName of allPropertyNames) { let prop = properties[propName] const wasOptional = !required.includes(propName) + let childMap: NullWideningMap | undefined + let widenedHere = false // Step 1: Recurse into nested structures - if (prop.type === 'object' && prop.properties) { - prop = coerceStrictSchema(prop, prop.required || []) - } else if (prop.type === 'array' && prop.items) { + if (isSchemaObject(prop) && prop.type === 'object' && prop.properties) { + const nested = coerceStrictSchema(prop, prop.required || []) + prop = nested.schema + childMap = nested.nullWideningMap + hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening + } else if ( + isSchemaObject(prop) && + prop.type === 'array' && + prop.items + ) { + const nested = coerceStrictSchema(prop.items, prop.items.required || []) prop = { ...prop, - items: coerceStrictSchema(prop.items, prop.items.required || []), + items: nested.schema, } - } else if (prop.anyOf) { - prop = coerceStrictSchema(prop, prop.required || []) - } else if (prop.oneOf) { + childMap = nested.nullWideningMap + ? { items: nested.nullWideningMap } + : undefined + hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening + } else if (isSchemaObject(prop) && prop.anyOf) { + const nested = coerceStrictSchema(prop, prop.required || []) + prop = nested.schema + childMap = nested.nullWideningMap + hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening + } else if (isSchemaObject(prop) && prop.oneOf) { throw new Error( 'oneOf is not supported in OpenAI structured output schemas. Check the supported outputs here: https://platform.openai.com/docs/guides/structured-outputs#supported-types', ) @@ -260,34 +358,81 @@ function coerceStrictSchema( // Step 2: Apply null-widening for optional properties (after recursion) if (wasOptional) { - if (prop.anyOf) { - // For anyOf, add a null variant if not already present - if (!prop.anyOf.some((v: any) => v.type === 'null')) { + const originallyAcceptedNull = acceptsNull(prop) + + // `type: [..., 'null']` alone does not make null valid when an enum or + // const still excludes it; strict decoding would be forced to emit the + // original literal instead of the synthetic omission marker. + if (isSchemaObject(prop) && 'const' in prop && prop.const !== null) { + const { const: constValue, ...withoutConst } = prop + prop = { ...withoutConst, enum: [constValue, null] } + } else if ( + isSchemaObject(prop) && + Array.isArray(prop.enum) && + !prop.enum.includes(null) + ) { + prop = { ...prop, enum: [...prop.enum, null] } + } + + if (isSchemaObject(prop) && prop.anyOf) { + // A genuine null branch can use type, enum, or const. Only add a + // provider omission marker when the original union rejected null. + if (!acceptsNull(prop)) { prop = { ...prop, anyOf: [...prop.anyOf, { type: 'null' }] } } - } else if (prop.type && !Array.isArray(prop.type)) { + } else if ( + isSchemaObject(prop) && + prop.type && + !Array.isArray(prop.type) + ) { prop = { ...prop, type: [prop.type, 'null'] } - } else if (Array.isArray(prop.type) && !prop.type.includes('null')) { + } else if ( + isSchemaObject(prop) && + Array.isArray(prop.type) && + !prop.type.includes('null') + ) { prop = { ...prop, type: [...prop.type, 'null'] } } + + widenedHere = !originallyAcceptedNull && acceptsNull(prop) } properties[propName] = prop + if (childMap || widenedHere) { + propertyMaps[propName] = { + ...(childMap ?? {}), + ...(widenedHere ? { widened: true } : {}), + } + } } result.properties = properties result.required = allPropertyNames result.additionalProperties = false + if (Object.keys(propertyMaps).length > 0) { + nullWideningMap.properties = propertyMaps + } } if (result.type === 'array' && result.items) { - result.items = coerceStrictSchema(result.items, result.items.required || []) + const nested = coerceStrictSchema(result.items, result.items.required || []) + result.items = nested.schema + if (nested.nullWideningMap) { + nullWideningMap.items = nested.nullWideningMap + } + hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening } if (result.anyOf && Array.isArray(result.anyOf)) { - result.anyOf = result.anyOf.map((variant) => + const variants = result.anyOf.map((variant) => coerceStrictSchema(variant, variant.required || []), ) + result.anyOf = variants.map((variant) => variant.schema) + hasUntrackableAnyOfWidening ||= variants.some( + (variant) => + variant.nullWideningMap !== undefined || + variant.hasUntrackableAnyOfWidening, + ) } if (result.oneOf) { @@ -296,5 +441,9 @@ function coerceStrictSchema( ) } - return result + return { + schema: result, + nullWideningMap: pruneMap(nullWideningMap), + hasUntrackableAnyOfWidening, + } } diff --git a/packages/openai-base/src/utils/tool-input-normalizer.test.ts b/packages/openai-base/src/utils/tool-input-normalizer.test.ts new file mode 100644 index 000000000..646a18df8 --- /dev/null +++ b/packages/openai-base/src/utils/tool-input-normalizer.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import { createToolInputNormalizer } from './tool-input-normalizer' +import type { Tool } from '@tanstack/ai' + +describe('createToolInputNormalizer', () => { + it('removes a null synthesized for an optional enum', () => { + const tool: Tool = { + name: 'optional_enum', + description: 'Uses an optional literal value', + inputSchema: { + type: 'object', + properties: { + value: { type: 'string', enum: ['canary'] }, + }, + required: [], + }, + } + const normalize = createToolInputNormalizer([tool]) + + expect(normalize(tool.name, { value: null })).toEqual({}) + }) + + it('leaves non-strict tool inputs unchanged', () => { + const tool: Tool = { + name: 'non_strict', + description: 'Uses a schema outside the strict subset', + inputSchema: { + type: 'object', + properties: { optional: { type: 'string' } }, + oneOf: [{ required: ['optional'] }, { required: [] }], + }, + } + const normalize = createToolInputNormalizer([tool]) + const input = { optional: null } + + expect(normalize(tool.name, input)).toBe(input) + }) + + it('does not guess when public tool names are ambiguous', () => { + const tools: Array = [ + { + name: 'duplicate', + description: 'First tool', + inputSchema: { + type: 'object', + properties: { firstOptional: { type: 'string' } }, + }, + }, + { + name: 'duplicate', + description: 'Second tool', + inputSchema: { + type: 'object', + properties: { secondOptional: { type: 'string' } }, + }, + }, + ] + const normalize = createToolInputNormalizer(tools) + const input = { firstOptional: null, secondOptional: null } + + expect(normalize('duplicate', input)).toBe(input) + }) + + it('leaves anyOf inputs unchanged when variant widening is ambiguous', () => { + const tool: Tool = { + name: 'union', + description: 'Uses variant-specific nullability', + inputSchema: { + type: 'object', + properties: { + value: { + anyOf: [ + { + type: 'object', + properties: { + kind: { const: 'optional' }, + note: { type: 'string' }, + }, + required: ['kind'], + }, + { + type: 'object', + properties: { + kind: { const: 'nullable' }, + note: { type: ['string', 'null'] }, + }, + required: ['kind', 'note'], + }, + ], + }, + }, + required: ['value'], + }, + } + const normalize = createToolInputNormalizer([tool]) + const input = { value: { kind: 'nullable', note: null } } + + expect(normalize(tool.name, input)).toBe(input) + }) + + it.each([ + ['const', { anyOf: [{ type: 'string' }, { const: null }] }], + ['enum', { anyOf: [{ type: 'string' }, { enum: [null] }] }], + ])('preserves genuine null accepted by an anyOf %s branch', (_, value) => { + const tool: Tool = { + name: 'nullable_union', + description: 'Uses an optional union that genuinely accepts null', + inputSchema: { + type: 'object', + properties: { value }, + required: [], + }, + } + const normalize = createToolInputNormalizer([tool]) + const input = { value: null } + + expect(normalize(tool.name, input)).toBe(input) + }) +}) diff --git a/packages/openai-base/src/utils/tool-input-normalizer.ts b/packages/openai-base/src/utils/tool-input-normalizer.ts new file mode 100644 index 000000000..3aac9b965 --- /dev/null +++ b/packages/openai-base/src/utils/tool-input-normalizer.ts @@ -0,0 +1,47 @@ +import { undoNullWidening } from '@tanstack/ai-utils' +import { + isStrictModeCompatible, + makeStructuredOutputCompatibleWithMap, +} from './schema-converter' +import type { JSONSchema, Tool } from '@tanstack/ai' +import type { NullWideningMap } from '@tanstack/ai-utils' + +type ToolInputNormalizer = (toolName: string, input: unknown) => unknown + +/** + * Build the inverse transform for the strict tool schemas sent in one request. + * Non-strict tools are intentionally excluded because their schemas were not + * null-widened on the wire. + */ +export function createToolInputNormalizer( + tools: Array | undefined, +): ToolInputNormalizer { + const maps = new Map() + const seenNames = new Set() + const ambiguousNames = new Set() + + for (const tool of tools ?? []) { + if (ambiguousNames.has(tool.name)) continue + if (seenNames.has(tool.name)) { + maps.delete(tool.name) + ambiguousNames.add(tool.name) + continue + } + seenNames.add(tool.name) + + const inputSchema = (tool.inputSchema ?? { + type: 'object', + properties: {}, + required: [], + }) as JSONSchema + if (!isStrictModeCompatible(inputSchema)) continue + + const { nullWideningMap } = makeStructuredOutputCompatibleWithMap( + inputSchema, + inputSchema.required || [], + ) + if (nullWideningMap) maps.set(tool.name, nullWideningMap) + } + + return (toolName, input) => undoNullWidening(input, maps.get(toolName)) +} diff --git a/packages/openai-base/tests/chat-completions-text.test.ts b/packages/openai-base/tests/chat-completions-text.test.ts index e92e34f1d..07ecc5eff 100644 --- a/packages/openai-base/tests/chat-completions-text.test.ts +++ b/packages/openai-base/tests/chat-completions-text.test.ts @@ -451,6 +451,69 @@ describe('OpenAIBaseChatCompletionsTextAdapter', () => { }) describe('tool call events', () => { + it('undoes strict null-widening before emitting the completed tool input', async () => { + const strictTool: Tool = { + name: 'ask_user', + description: 'Ask a question', + inputSchema: { + type: 'object', + properties: { + question: { type: 'string' }, + options: { type: 'array', items: { type: 'string' } }, + nullableNote: { type: ['string', 'null'] }, + }, + required: ['question', 'nullableNote'], + }, + } + setupMockSdkClient([ + { + id: 'chatcmpl-null-input', + model: 'test-model', + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: 'call-null-input', + type: 'function', + function: { + name: 'ask_user', + arguments: + '{"question":"Which one?","options":null,"nullableNote":null}', + }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-null-input', + model: 'test-model', + choices: [{ delta: {}, finish_reason: 'tool_calls' }], + }, + ]) + const adapter = new TestChatCompletionsAdapter(testConfig, 'test-model') + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Ask me' }], + tools: [strictTool], + })) { + chunks.push(chunk) + } + + expect( + chunks.find((chunk) => chunk.type === 'TOOL_CALL_END'), + ).toMatchObject({ + input: { question: 'Which one?', nullableNote: null }, + }) + }) + it('emits TOOL_CALL_START -> TOOL_CALL_ARGS -> TOOL_CALL_END', async () => { const streamChunks = [ { diff --git a/packages/openai-base/tests/responses-text.test.ts b/packages/openai-base/tests/responses-text.test.ts index 884954267..0bf097f7d 100644 --- a/packages/openai-base/tests/responses-text.test.ts +++ b/packages/openai-base/tests/responses-text.test.ts @@ -670,6 +670,87 @@ describe('OpenAIBaseResponsesTextAdapter', () => { }) describe('tool call events', () => { + it('undoes strict null-widening before emitting the completed tool input', async () => { + const strictTool: Tool = { + name: 'ask_user', + description: 'Ask a question', + inputSchema: { + type: 'object', + properties: { + question: { type: 'string' }, + options: { type: 'array', items: { type: 'string' } }, + nullableNote: { type: ['string', 'null'] }, + }, + required: ['question', 'nullableNote'], + }, + } + const argumentsJson = + '{"question":"Which one?","options":null,"nullableNote":null}' + setupMockResponsesClient([ + { + type: 'response.created', + response: { + id: 'resp-null-input', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.output_item.added', + output_index: 0, + item: { + type: 'function_call', + id: 'call-null-input', + name: 'ask_user', + }, + }, + { + type: 'response.function_call_arguments.delta', + item_id: 'call-null-input', + delta: argumentsJson, + }, + { + type: 'response.function_call_arguments.done', + item_id: 'call-null-input', + arguments: argumentsJson, + }, + { + type: 'response.completed', + response: { + id: 'resp-null-input', + model: 'test-model', + status: 'completed', + output: [ + { + type: 'function_call', + id: 'call-null-input', + name: 'ask_user', + arguments: argumentsJson, + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, + ]) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Ask me' }], + tools: [strictTool], + })) { + chunks.push(chunk) + } + + expect( + chunks.find((chunk) => chunk.type === 'TOOL_CALL_END'), + ).toMatchObject({ + input: { question: 'Which one?', nullableNote: null }, + }) + }) + it('emits TOOL_CALL_START -> TOOL_CALL_ARGS -> TOOL_CALL_END', async () => { const streamChunks = [ { diff --git a/packages/openai-base/tests/schema-converter.test.ts b/packages/openai-base/tests/schema-converter.test.ts index 8915a8560..478bcfc4c 100644 --- a/packages/openai-base/tests/schema-converter.test.ts +++ b/packages/openai-base/tests/schema-converter.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { isStrictModeCompatible, makeStructuredOutputCompatible, + makeStructuredOutputCompatibleWithMap, } from '../src/utils/schema-converter' describe('makeStructuredOutputCompatible', () => { @@ -47,6 +48,100 @@ describe('makeStructuredOutputCompatible', () => { expect(result.properties.nickname.type).toEqual(['string', 'null']) }) + it('widens optional enum and const constraints to accept null', () => { + const schema = { + type: 'object', + properties: { + mode: { type: 'string', enum: ['canary'] }, + status: { type: 'string', const: 'ready' }, + }, + required: [], + } + + const { schema: result, nullWideningMap } = + makeStructuredOutputCompatibleWithMap(schema, schema.required) + + expect(result.properties.mode).toEqual({ + type: ['string', 'null'], + enum: ['canary', null], + }) + expect(result.properties.status).toEqual({ + type: ['string', 'null'], + enum: ['ready', null], + }) + expect(nullWideningMap).toEqual({ + properties: { + mode: { widened: true }, + status: { widened: true }, + }, + }) + }) + + it('records only nullability introduced by strict conversion', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + nickname: { type: 'string' }, + note: { type: ['string', 'null'] }, + }, + required: ['name', 'note'], + } + + const { nullWideningMap } = makeStructuredOutputCompatibleWithMap( + schema, + schema.required, + ) + + expect(nullWideningMap).toEqual({ + properties: { nickname: { widened: true } }, + }) + }) + + it('records null widening through nested objects and array items', () => { + const schema = { + type: 'object', + properties: { + profile: { + type: 'object', + properties: { + nickname: { type: 'string' }, + entries: { + type: 'array', + items: { + type: 'object', + properties: { label: { type: 'string' } }, + required: [], + }, + }, + }, + required: ['entries'], + }, + }, + required: ['profile'], + } + + const { nullWideningMap } = makeStructuredOutputCompatibleWithMap( + schema, + schema.required, + ) + + expect(nullWideningMap).toEqual({ + properties: { + profile: { + properties: { + nickname: { widened: true }, + entries: { + items: { + properties: { label: { widened: true } }, + }, + }, + }, + }, + }, + }) + }) + it('should handle anyOf (union types) by transforming each variant', () => { const schema = { type: 'object', @@ -368,6 +463,37 @@ describe('isStrictModeCompatible', () => { ).toBe(true) }) + it('returns false when an anyOf variant needs optional-field widening', () => { + expect( + isStrictModeCompatible({ + type: 'object', + properties: { + value: { + anyOf: [ + { + type: 'object', + properties: { + kind: { const: 'optional' }, + note: { type: 'string' }, + }, + required: ['kind'], + }, + { + type: 'object', + properties: { + kind: { const: 'nullable' }, + note: { type: ['string', 'null'] }, + }, + required: ['kind', 'note'], + }, + ], + }, + }, + required: ['value'], + }), + ).toBe(false) + }) + it.each(['oneOf', 'allOf', 'not'])( 'returns false when a combinator keyword (%s) appears anywhere', (keyword) => { diff --git a/packages/openai-base/tests/tool-converter-strict-fallback.test.ts b/packages/openai-base/tests/tool-converter-strict-fallback.test.ts index c2dc26ee1..9ea886c21 100644 --- a/packages/openai-base/tests/tool-converter-strict-fallback.test.ts +++ b/packages/openai-base/tests/tool-converter-strict-fallback.test.ts @@ -82,6 +82,7 @@ describe('responses tool converter — strict fallback', () => { (out.parameters as any).properties.labels.additionalProperties, ).toEqual({ type: 'string' }) }) + }) describe('chat-completions tool converter — strict fallback', () => { @@ -102,6 +103,7 @@ describe('chat-completions tool converter — strict fallback', () => { const out = convertFunctionToolToChatCompletionsFormat(freeFormMapTool) expect(out.function.strict).toBe(false) }) + }) // This is the converter the provider tool-dispatcher (`convertToolsToProviderFormat`) @@ -131,4 +133,5 @@ describe('function-tool adapter converter — strict fallback', () => { const out = convertFunctionToolToAdapterFormat(freeFormMapTool) expect(out.strict).toBe(false) }) + }) diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 14ffbf330..495604361 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -32,6 +32,7 @@ import { Route as ApiOtelMediaRouteImport } from './routes/api.otel-media' import { Route as ApiOpenrouterWebToolsWireRouteImport } from './routes/api.openrouter-web-tools-wire' import { Route as ApiOpenrouterCostRouteImport } from './routes/api.openrouter-cost' import { Route as ApiOpenaiUsageDetailsRouteImport } from './routes/api.openai-usage-details' +import { Route as ApiOpenaiStrictToolNullWireRouteImport } from './routes/api.openai-strict-tool-null-wire' import { Route as ApiOpenaiShellSkillsWireRouteImport } from './routes/api.openai-shell-skills-wire' import { Route as ApiMultimodalToolResultWireRouteImport } from './routes/api.multimodal-tool-result-wire' import { Route as ApiMiddlewareTestRouteImport } from './routes/api.middleware-test' @@ -175,6 +176,12 @@ const ApiOpenaiUsageDetailsRoute = ApiOpenaiUsageDetailsRouteImport.update({ path: '/api/openai-usage-details', getParentRoute: () => rootRouteImport, } as any) +const ApiOpenaiStrictToolNullWireRoute = + ApiOpenaiStrictToolNullWireRouteImport.update({ + id: '/api/openai-strict-tool-null-wire', + path: '/api/openai-strict-tool-null-wire', + getParentRoute: () => rootRouteImport, + } as any) const ApiOpenaiShellSkillsWireRoute = ApiOpenaiShellSkillsWireRouteImport.update({ id: '/api/openai-shell-skills-wire', @@ -336,6 +343,7 @@ export interface FileRoutesByFullPath { '/api/middleware-test': typeof ApiMiddlewareTestRoute '/api/multimodal-tool-result-wire': typeof ApiMultimodalToolResultWireRoute '/api/openai-shell-skills-wire': typeof ApiOpenaiShellSkillsWireRoute + '/api/openai-strict-tool-null-wire': typeof ApiOpenaiStrictToolNullWireRoute '/api/openai-usage-details': typeof ApiOpenaiUsageDetailsRoute '/api/openrouter-cost': typeof ApiOpenrouterCostRoute '/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute @@ -386,6 +394,7 @@ export interface FileRoutesByTo { '/api/middleware-test': typeof ApiMiddlewareTestRoute '/api/multimodal-tool-result-wire': typeof ApiMultimodalToolResultWireRoute '/api/openai-shell-skills-wire': typeof ApiOpenaiShellSkillsWireRoute + '/api/openai-strict-tool-null-wire': typeof ApiOpenaiStrictToolNullWireRoute '/api/openai-usage-details': typeof ApiOpenaiUsageDetailsRoute '/api/openrouter-cost': typeof ApiOpenrouterCostRoute '/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute @@ -437,6 +446,7 @@ export interface FileRoutesById { '/api/middleware-test': typeof ApiMiddlewareTestRoute '/api/multimodal-tool-result-wire': typeof ApiMultimodalToolResultWireRoute '/api/openai-shell-skills-wire': typeof ApiOpenaiShellSkillsWireRoute + '/api/openai-strict-tool-null-wire': typeof ApiOpenaiStrictToolNullWireRoute '/api/openai-usage-details': typeof ApiOpenaiUsageDetailsRoute '/api/openrouter-cost': typeof ApiOpenrouterCostRoute '/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute @@ -489,6 +499,7 @@ export interface FileRouteTypes { | '/api/middleware-test' | '/api/multimodal-tool-result-wire' | '/api/openai-shell-skills-wire' + | '/api/openai-strict-tool-null-wire' | '/api/openai-usage-details' | '/api/openrouter-cost' | '/api/openrouter-web-tools-wire' @@ -539,6 +550,7 @@ export interface FileRouteTypes { | '/api/middleware-test' | '/api/multimodal-tool-result-wire' | '/api/openai-shell-skills-wire' + | '/api/openai-strict-tool-null-wire' | '/api/openai-usage-details' | '/api/openrouter-cost' | '/api/openrouter-web-tools-wire' @@ -589,6 +601,7 @@ export interface FileRouteTypes { | '/api/middleware-test' | '/api/multimodal-tool-result-wire' | '/api/openai-shell-skills-wire' + | '/api/openai-strict-tool-null-wire' | '/api/openai-usage-details' | '/api/openrouter-cost' | '/api/openrouter-web-tools-wire' @@ -640,6 +653,7 @@ export interface RootRouteChildren { ApiMiddlewareTestRoute: typeof ApiMiddlewareTestRoute ApiMultimodalToolResultWireRoute: typeof ApiMultimodalToolResultWireRoute ApiOpenaiShellSkillsWireRoute: typeof ApiOpenaiShellSkillsWireRoute + ApiOpenaiStrictToolNullWireRoute: typeof ApiOpenaiStrictToolNullWireRoute ApiOpenaiUsageDetailsRoute: typeof ApiOpenaiUsageDetailsRoute ApiOpenrouterCostRoute: typeof ApiOpenrouterCostRoute ApiOpenrouterWebToolsWireRoute: typeof ApiOpenrouterWebToolsWireRoute @@ -817,6 +831,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiOpenaiUsageDetailsRouteImport parentRoute: typeof rootRouteImport } + '/api/openai-strict-tool-null-wire': { + id: '/api/openai-strict-tool-null-wire' + path: '/api/openai-strict-tool-null-wire' + fullPath: '/api/openai-strict-tool-null-wire' + preLoaderRoute: typeof ApiOpenaiStrictToolNullWireRouteImport + parentRoute: typeof rootRouteImport + } '/api/openai-shell-skills-wire': { id: '/api/openai-shell-skills-wire' path: '/api/openai-shell-skills-wire' @@ -1085,6 +1106,7 @@ const rootRouteChildren: RootRouteChildren = { ApiMiddlewareTestRoute: ApiMiddlewareTestRoute, ApiMultimodalToolResultWireRoute: ApiMultimodalToolResultWireRoute, ApiOpenaiShellSkillsWireRoute: ApiOpenaiShellSkillsWireRoute, + ApiOpenaiStrictToolNullWireRoute: ApiOpenaiStrictToolNullWireRoute, ApiOpenaiUsageDetailsRoute: ApiOpenaiUsageDetailsRoute, ApiOpenrouterCostRoute: ApiOpenrouterCostRoute, ApiOpenrouterWebToolsWireRoute: ApiOpenrouterWebToolsWireRoute, diff --git a/testing/e2e/src/routes/api.openai-strict-tool-null-wire.ts b/testing/e2e/src/routes/api.openai-strict-tool-null-wire.ts new file mode 100644 index 000000000..8f517e991 --- /dev/null +++ b/testing/e2e/src/routes/api.openai-strict-tool-null-wire.ts @@ -0,0 +1,219 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + chat, + createChatOptions, + maxIterations, + toolDefinition, +} from '@tanstack/ai' +import { createOpenaiChat } from '@tanstack/ai-openai' +import { z } from 'zod' + +const DUMMY_KEY = 'sk-e2e-test-dummy-key' + +function makeEventStream(events: Array): ReadableStream { + const encoder = new TextEncoder() + return new ReadableStream({ + start(controller) { + for (const event of events) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)) + } + controller.enqueue(encoder.encode('data: [DONE]\n\n')) + controller.close() + }, + }) +} + +function makeToolCallStream(): ReadableStream { + const responseId = 'resp_strict_tool_null' + const itemId = 'call_strict_tool_null' + const args = JSON.stringify({ + mode: null, + question: 'Which option?', + options: null, + nullableNote: null, + }) + return makeEventStream([ + { + type: 'response.created', + response: { + id: responseId, + object: 'response', + model: 'gpt-5.2', + status: 'in_progress', + output: [], + }, + }, + { + type: 'response.output_item.added', + response_id: responseId, + output_index: 0, + item: { + id: itemId, + call_id: itemId, + type: 'function_call', + name: 'ask_user', + arguments: '', + status: 'in_progress', + }, + }, + { + type: 'response.function_call_arguments.delta', + response_id: responseId, + item_id: itemId, + output_index: 0, + delta: args, + }, + { + type: 'response.function_call_arguments.done', + response_id: responseId, + item_id: itemId, + output_index: 0, + arguments: args, + }, + { + type: 'response.output_item.done', + response_id: responseId, + output_index: 0, + item: { + id: itemId, + call_id: itemId, + type: 'function_call', + name: 'ask_user', + arguments: args, + status: 'completed', + }, + }, + { + type: 'response.completed', + response: { + id: responseId, + object: 'response', + model: 'gpt-5.2', + status: 'completed', + output: [ + { + id: itemId, + call_id: itemId, + type: 'function_call', + name: 'ask_user', + arguments: args, + status: 'completed', + }, + ], + usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 }, + }, + }, + ]) +} + +function makeTextStream(): ReadableStream { + const responseId = 'resp_strict_tool_text' + const itemId = 'msg_strict_tool_text' + return makeEventStream([ + { + type: 'response.created', + response: { + id: responseId, + object: 'response', + model: 'gpt-5.2', + status: 'in_progress', + output: [], + }, + }, + { + type: 'response.output_text.delta', + response_id: responseId, + item_id: itemId, + output_index: 0, + content_index: 0, + delta: 'Tool executed.', + }, + { + type: 'response.completed', + response: { + id: responseId, + object: 'response', + model: 'gpt-5.2', + status: 'completed', + output: [ + { + id: itemId, + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'Tool executed.' }], + }, + ], + usage: { input_tokens: 8, output_tokens: 2, total_tokens: 10 }, + }, + }, + ]) +} + +export const Route = createFileRoute('/api/openai-strict-tool-null-wire')({ + server: { + handlers: { + POST: async () => { + let requestCount = 0 + let firstRequestBody: unknown + let executedInput: unknown + + const mockFetch: typeof fetch = async (input, init) => { + requestCount++ + const request = + input instanceof Request ? input : new Request(input, init) + if (requestCount === 1) { + firstRequestBody = JSON.parse(await request.text()) + } + + return new Response( + requestCount === 1 ? makeToolCallStream() : makeTextStream(), + { headers: { 'Content-Type': 'text/event-stream' } }, + ) + } + + const askUser = toolDefinition({ + name: 'ask_user', + description: 'Ask the user to choose an option', + inputSchema: z.object({ + mode: z.enum(['canary']).optional(), + question: z.string(), + options: z.array(z.string()).optional(), + nullableNote: z.string().nullable(), + }), + }).server((input) => { + executedInput = input + return { accepted: true } + }) + const adapter = createOpenaiChat('gpt-5.2', DUMMY_KEY, { + fetch: mockFetch, + }) + const text: Array = [] + + try { + for await (const chunk of chat({ + ...createChatOptions({ adapter }), + messages: [{ role: 'user', content: 'Ask me a question' }], + tools: [askUser], + agentLoopStrategy: maxIterations(3), + })) { + if (chunk.type === 'TEXT_MESSAGE_CONTENT') text.push(chunk.delta) + } + } catch (error) { + return Response.json({ + ok: false, + error: error instanceof Error ? error.message : String(error), + }) + } + + return Response.json({ + ok: true, + requestCount, + firstRequestBody, + executedInput, + text: text.join(''), + }) + }, + }, + }, +}) diff --git a/testing/e2e/tests/openai-strict-tool-null-wire.spec.ts b/testing/e2e/tests/openai-strict-tool-null-wire.spec.ts new file mode 100644 index 000000000..164ce1f08 --- /dev/null +++ b/testing/e2e/tests/openai-strict-tool-null-wire.spec.ts @@ -0,0 +1,62 @@ +import { expect, test } from './fixtures' + +test.describe('openai strict tool optional fields', () => { + test('undoes provider-added nullability before tool validation and execution', async ({ + request, + }) => { + const response = await request.post('/api/openai-strict-tool-null-wire') + expect(response.ok()).toBe(true) + const result = (await response.json()) as { + ok: boolean + error?: string + requestCount: number + firstRequestBody: { + tools: Array<{ + name: string + strict: boolean + parameters: { + required: Array + properties: Record< + string, + { + type?: string | Array + anyOf?: Array<{ type: string }> + enum?: Array + } + > + } + }> + } + executedInput: Record + text: string + } + + if (!result.ok) throw new Error(`Route failed: ${result.error}`) + + const tool = result.firstRequestBody.tools.find( + ({ name }) => name === 'ask_user', + ) + expect(tool).toMatchObject({ + strict: true, + parameters: { + required: ['mode', 'question', 'options', 'nullableNote'], + properties: { + mode: { + type: ['string', 'null'], + enum: ['canary', null], + }, + options: { type: ['array', 'null'] }, + nullableNote: { + anyOf: [{ type: 'string' }, { type: 'null' }], + }, + }, + }, + }) + expect(result.executedInput).toEqual({ + question: 'Which option?', + nullableNote: null, + }) + expect(result.requestCount).toBe(2) + expect(result.text).toBe('Tool executed.') + }) +})