From 173a5e88f5abca4a281589091926c3c5d6d32519 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Wed, 15 Jul 2026 00:58:42 +0200 Subject: [PATCH 1/6] fix: normalize strict optional tool inputs --- .changeset/calm-tools-return.md | 5 + .../src/adapters/chat-completions-text.ts | 13 +- .../src/adapters/responses-text.ts | 19 +- .../openai-base/src/utils/schema-converter.ts | 79 ++++++- .../src/utils/tool-input-normalizer.ts | 47 ++++ .../tests/chat-completions-text.test.ts | 63 +++++ .../openai-base/tests/responses-text.test.ts | 81 +++++++ .../tests/schema-converter.test.ts | 66 ++++++ .../tests/tool-input-normalizer.test.ts | 46 ++++ testing/e2e/src/routeTree.gen.ts | 22 ++ .../api.openai-strict-tool-null-wire.ts | 217 ++++++++++++++++++ .../openai-strict-tool-null-wire.spec.ts | 57 +++++ 12 files changed, 698 insertions(+), 17 deletions(-) create mode 100644 .changeset/calm-tools-return.md create mode 100644 packages/openai-base/src/utils/tool-input-normalizer.ts create mode 100644 packages/openai-base/tests/tool-input-normalizer.test.ts create mode 100644 testing/e2e/src/routes/api.openai-strict-tool-null-wire.ts create mode 100644 testing/e2e/tests/openai-strict-tool-null-wire.spec.ts 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/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/utils/schema-converter.ts b/packages/openai-base/src/utils/schema-converter.ts index 540308be1..c26ec06e3 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,31 @@ 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 +} + +/** + * 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, + } } /** @@ -225,11 +251,16 @@ 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 coerceStrictSchema( schema: Record, originalRequired?: Array, -): Record { +): StructuredOutputCompatibility { const result = { ...schema } + const nullWideningMap: NullWideningMap = {} const required = originalRequired ?? (Array.isArray(result['required']) ? result['required'] : []) @@ -237,21 +268,32 @@ 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 || []) + const nested = coerceStrictSchema(prop, prop.required || []) + prop = nested.schema + childMap = nested.nullWideningMap } else if (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, } + childMap = nested.nullWideningMap + ? { items: nested.nullWideningMap } + : undefined } else if (prop.anyOf) { - prop = coerceStrictSchema(prop, prop.required || []) + const nested = coerceStrictSchema(prop, prop.required || []) + prop = nested.schema + childMap = nested.nullWideningMap } else if (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', @@ -264,29 +306,45 @@ function coerceStrictSchema( // For anyOf, add a null variant if not already present if (!prop.anyOf.some((v: any) => v.type === 'null')) { prop = { ...prop, anyOf: [...prop.anyOf, { type: 'null' }] } + widenedHere = true } } else if (prop.type && !Array.isArray(prop.type)) { prop = { ...prop, type: [prop.type, 'null'] } + widenedHere = true } else if (Array.isArray(prop.type) && !prop.type.includes('null')) { prop = { ...prop, type: [...prop.type, 'null'] } + widenedHere = true } } 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 + } } if (result.anyOf && Array.isArray(result.anyOf)) { - result.anyOf = result.anyOf.map((variant) => - coerceStrictSchema(variant, variant.required || []), + result.anyOf = result.anyOf.map( + (variant) => coerceStrictSchema(variant, variant.required || []).schema, ) } @@ -296,5 +354,8 @@ function coerceStrictSchema( ) } - return result + return { + schema: result, + nullWideningMap: pruneMap(nullWideningMap), + } } 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..a0e28993d 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,71 @@ describe('makeStructuredOutputCompatible', () => { expect(result.properties.nickname.type).toEqual(['string', 'null']) }) + 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', diff --git a/packages/openai-base/tests/tool-input-normalizer.test.ts b/packages/openai-base/tests/tool-input-normalizer.test.ts new file mode 100644 index 000000000..2d85c4464 --- /dev/null +++ b/packages/openai-base/tests/tool-input-normalizer.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { createToolInputNormalizer } from '../src/utils/tool-input-normalizer' +import type { Tool } from '@tanstack/ai' + +describe('createToolInputNormalizer', () => { + 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) + }) +}) 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..346119a79 --- /dev/null +++ b/testing/e2e/src/routes/api.openai-strict-tool-null-wire.ts @@ -0,0 +1,217 @@ +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({ + 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({ + 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..794a60604 --- /dev/null +++ b/testing/e2e/tests/openai-strict-tool-null-wire.spec.ts @@ -0,0 +1,57 @@ +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 }> + } + > + } + }> + } + 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: ['question', 'options', 'nullableNote'], + properties: { + 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.') + }) +}) From d3c7c70013e99e87634b2b41a71e28810fcb11c4 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Thu, 16 Jul 2026 21:40:17 +0200 Subject: [PATCH 2/6] fix: avoid ambiguous anyOf null normalization --- .../openai-base/src/utils/schema-converter.ts | 41 ++++++++++++-- .../tests/schema-converter.test.ts | 31 +++++++++++ .../tool-converter-strict-fallback.test.ts | 55 +++++++++++++++++++ .../tests/tool-input-normalizer.test.ts | 37 +++++++++++++ 4 files changed, 160 insertions(+), 4 deletions(-) diff --git a/packages/openai-base/src/utils/schema-converter.ts b/packages/openai-base/src/utils/schema-converter.ts index c26ec06e3..751d46776 100644 --- a/packages/openai-base/src/utils/schema-converter.ts +++ b/packages/openai-base/src/utils/schema-converter.ts @@ -71,6 +71,10 @@ interface StructuredOutputCompatibility { 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 @@ -144,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 @@ -154,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 @@ -258,9 +279,10 @@ function pruneMap(map: NullWideningMap): NullWideningMap | undefined { function coerceStrictSchema( schema: Record, originalRequired?: Array, -): StructuredOutputCompatibility { +): CoercedStrictSchema { const result = { ...schema } const nullWideningMap: NullWideningMap = {} + let hasUntrackableAnyOfWidening = false const required = originalRequired ?? (Array.isArray(result['required']) ? result['required'] : []) @@ -281,6 +303,7 @@ function coerceStrictSchema( const nested = coerceStrictSchema(prop, prop.required || []) prop = nested.schema childMap = nested.nullWideningMap + hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening } else if (prop.type === 'array' && prop.items) { const nested = coerceStrictSchema(prop.items, prop.items.required || []) prop = { @@ -290,10 +313,12 @@ function coerceStrictSchema( childMap = nested.nullWideningMap ? { items: nested.nullWideningMap } : undefined + hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening } else if (prop.anyOf) { const nested = coerceStrictSchema(prop, prop.required || []) prop = nested.schema childMap = nested.nullWideningMap + hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening } else if (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', @@ -340,11 +365,18 @@ function coerceStrictSchema( if (nested.nullWideningMap) { nullWideningMap.items = nested.nullWideningMap } + hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening } if (result.anyOf && Array.isArray(result.anyOf)) { - result.anyOf = result.anyOf.map( - (variant) => coerceStrictSchema(variant, variant.required || []).schema, + 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, ) } @@ -357,5 +389,6 @@ function coerceStrictSchema( return { schema: result, nullWideningMap: pruneMap(nullWideningMap), + hasUntrackableAnyOfWidening, } } diff --git a/packages/openai-base/tests/schema-converter.test.ts b/packages/openai-base/tests/schema-converter.test.ts index a0e28993d..1666e1ef9 100644 --- a/packages/openai-base/tests/schema-converter.test.ts +++ b/packages/openai-base/tests/schema-converter.test.ts @@ -434,6 +434,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..98b3a3d9c 100644 --- a/packages/openai-base/tests/tool-converter-strict-fallback.test.ts +++ b/packages/openai-base/tests/tool-converter-strict-fallback.test.ts @@ -55,6 +55,37 @@ const freeFormMapTool: Tool = { }, } +const anyOfWithOptionalVariantTool: 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'], + }, +} + describe('responses tool converter — strict fallback', () => { it('uses strict:true for strict-subset schemas', () => { const out = convertFunctionToolToResponsesFormat(strictSafeTool) @@ -82,6 +113,16 @@ describe('responses tool converter — strict fallback', () => { (out.parameters as any).properties.labels.additionalProperties, ).toEqual({ type: 'string' }) }) + + it('falls back to strict:false when an anyOf variant needs null widening', () => { + const out = convertFunctionToolToResponsesFormat( + anyOfWithOptionalVariantTool, + ) + expect(out.strict).toBe(false) + expect( + (out.parameters as any).properties.value.anyOf[0].required, + ).toEqual(['kind']) + }) }) describe('chat-completions tool converter — strict fallback', () => { @@ -102,6 +143,13 @@ describe('chat-completions tool converter — strict fallback', () => { const out = convertFunctionToolToChatCompletionsFormat(freeFormMapTool) expect(out.function.strict).toBe(false) }) + + it('falls back to strict:false when an anyOf variant needs null widening', () => { + const out = convertFunctionToolToChatCompletionsFormat( + anyOfWithOptionalVariantTool, + ) + expect(out.function.strict).toBe(false) + }) }) // This is the converter the provider tool-dispatcher (`convertToolsToProviderFormat`) @@ -131,4 +179,11 @@ describe('function-tool adapter converter — strict fallback', () => { const out = convertFunctionToolToAdapterFormat(freeFormMapTool) expect(out.strict).toBe(false) }) + + it('falls back to strict:false when an anyOf variant needs null widening', () => { + const out = convertFunctionToolToAdapterFormat( + anyOfWithOptionalVariantTool, + ) + expect(out.strict).toBe(false) + }) }) diff --git a/packages/openai-base/tests/tool-input-normalizer.test.ts b/packages/openai-base/tests/tool-input-normalizer.test.ts index 2d85c4464..3f0ce45fb 100644 --- a/packages/openai-base/tests/tool-input-normalizer.test.ts +++ b/packages/openai-base/tests/tool-input-normalizer.test.ts @@ -43,4 +43,41 @@ describe('createToolInputNormalizer', () => { 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) + }) }) From 59525c45d3433274f68bf506123daaf3b12ec486 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Fri, 17 Jul 2026 07:19:38 +0200 Subject: [PATCH 3/6] fix: widen optional literal schemas --- .../openai-base/src/utils/schema-converter.ts | 12 ++++++++ .../tests/schema-converter.test.ts | 29 +++++++++++++++++++ .../tests/tool-input-normalizer.test.ts | 17 +++++++++++ .../api.openai-strict-tool-null-wire.ts | 2 ++ .../openai-strict-tool-null-wire.spec.ts | 7 ++++- 5 files changed, 66 insertions(+), 1 deletion(-) diff --git a/packages/openai-base/src/utils/schema-converter.ts b/packages/openai-base/src/utils/schema-converter.ts index 751d46776..251276396 100644 --- a/packages/openai-base/src/utils/schema-converter.ts +++ b/packages/openai-base/src/utils/schema-converter.ts @@ -327,6 +327,18 @@ function coerceStrictSchema( // Step 2: Apply null-widening for optional properties (after recursion) if (wasOptional) { + // `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 ('const' in prop && prop.const !== null) { + const { const: constValue, ...withoutConst } = prop + prop = { ...withoutConst, enum: [constValue, null] } + widenedHere = true + } else if (Array.isArray(prop.enum) && !prop.enum.includes(null)) { + prop = { ...prop, enum: [...prop.enum, null] } + widenedHere = true + } + if (prop.anyOf) { // For anyOf, add a null variant if not already present if (!prop.anyOf.some((v: any) => v.type === 'null')) { diff --git a/packages/openai-base/tests/schema-converter.test.ts b/packages/openai-base/tests/schema-converter.test.ts index 1666e1ef9..478bcfc4c 100644 --- a/packages/openai-base/tests/schema-converter.test.ts +++ b/packages/openai-base/tests/schema-converter.test.ts @@ -48,6 +48,35 @@ 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', diff --git a/packages/openai-base/tests/tool-input-normalizer.test.ts b/packages/openai-base/tests/tool-input-normalizer.test.ts index 3f0ce45fb..25453f9c0 100644 --- a/packages/openai-base/tests/tool-input-normalizer.test.ts +++ b/packages/openai-base/tests/tool-input-normalizer.test.ts @@ -3,6 +3,23 @@ import { createToolInputNormalizer } from '../src/utils/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', 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 index 346119a79..8f517e991 100644 --- a/testing/e2e/src/routes/api.openai-strict-tool-null-wire.ts +++ b/testing/e2e/src/routes/api.openai-strict-tool-null-wire.ts @@ -27,6 +27,7 @@ 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, @@ -175,6 +176,7 @@ export const Route = createFileRoute('/api/openai-strict-tool-null-wire')({ 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(), diff --git a/testing/e2e/tests/openai-strict-tool-null-wire.spec.ts b/testing/e2e/tests/openai-strict-tool-null-wire.spec.ts index 794a60604..164ce1f08 100644 --- a/testing/e2e/tests/openai-strict-tool-null-wire.spec.ts +++ b/testing/e2e/tests/openai-strict-tool-null-wire.spec.ts @@ -21,6 +21,7 @@ test.describe('openai strict tool optional fields', () => { { type?: string | Array anyOf?: Array<{ type: string }> + enum?: Array } > } @@ -38,8 +39,12 @@ test.describe('openai strict tool optional fields', () => { expect(tool).toMatchObject({ strict: true, parameters: { - required: ['question', 'options', 'nullableNote'], + required: ['mode', 'question', 'options', 'nullableNote'], properties: { + mode: { + type: ['string', 'null'], + enum: ['canary', null], + }, options: { type: ['array', 'null'] }, nullableNote: { anyOf: [{ type: 'string' }, { type: 'null' }], From d45e03d902e8f2ee59dabe9b55dbdd2f6cb259a7 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Fri, 17 Jul 2026 10:59:28 +0200 Subject: [PATCH 4/6] test: colocate strict tool regressions --- .../chat-completions-tool-converter.test.ts | 44 ++++++++++++++++ .../adapters/responses-tool-converter.test.ts | 52 +++++++++++++++++++ .../src/tools/function-tool.test.ts | 42 +++++++++++++++ .../utils}/tool-input-normalizer.test.ts | 2 +- .../tool-converter-strict-fallback.test.ts | 52 ------------------- 5 files changed, 139 insertions(+), 53 deletions(-) create mode 100644 packages/openai-base/src/adapters/chat-completions-tool-converter.test.ts create mode 100644 packages/openai-base/src/adapters/responses-tool-converter.test.ts create mode 100644 packages/openai-base/src/tools/function-tool.test.ts rename packages/openai-base/{tests => src/utils}/tool-input-normalizer.test.ts (97%) 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..3848d7aeb --- /dev/null +++ b/packages/openai-base/src/adapters/chat-completions-tool-converter.test.ts @@ -0,0 +1,44 @@ +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) + }) +}) + +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-tool-converter.test.ts b/packages/openai-base/src/adapters/responses-tool-converter.test.ts new file mode 100644 index 000000000..1d6aa1548 --- /dev/null +++ b/packages/openai-base/src/adapters/responses-tool-converter.test.ts @@ -0,0 +1,52 @@ +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'] }, + ], + }, + }, + }) + }) +}) + +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..32c86b4ae --- /dev/null +++ b/packages/openai-base/src/tools/function-tool.test.ts @@ -0,0 +1,42 @@ +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) + }) +}) + +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/tests/tool-input-normalizer.test.ts b/packages/openai-base/src/utils/tool-input-normalizer.test.ts similarity index 97% rename from packages/openai-base/tests/tool-input-normalizer.test.ts rename to packages/openai-base/src/utils/tool-input-normalizer.test.ts index 25453f9c0..359a8c92f 100644 --- a/packages/openai-base/tests/tool-input-normalizer.test.ts +++ b/packages/openai-base/src/utils/tool-input-normalizer.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { createToolInputNormalizer } from '../src/utils/tool-input-normalizer' +import { createToolInputNormalizer } from './tool-input-normalizer' import type { Tool } from '@tanstack/ai' describe('createToolInputNormalizer', () => { 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 98b3a3d9c..9ea886c21 100644 --- a/packages/openai-base/tests/tool-converter-strict-fallback.test.ts +++ b/packages/openai-base/tests/tool-converter-strict-fallback.test.ts @@ -55,37 +55,6 @@ const freeFormMapTool: Tool = { }, } -const anyOfWithOptionalVariantTool: 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'], - }, -} - describe('responses tool converter — strict fallback', () => { it('uses strict:true for strict-subset schemas', () => { const out = convertFunctionToolToResponsesFormat(strictSafeTool) @@ -114,15 +83,6 @@ describe('responses tool converter — strict fallback', () => { ).toEqual({ type: 'string' }) }) - it('falls back to strict:false when an anyOf variant needs null widening', () => { - const out = convertFunctionToolToResponsesFormat( - anyOfWithOptionalVariantTool, - ) - expect(out.strict).toBe(false) - expect( - (out.parameters as any).properties.value.anyOf[0].required, - ).toEqual(['kind']) - }) }) describe('chat-completions tool converter — strict fallback', () => { @@ -144,12 +104,6 @@ describe('chat-completions tool converter — strict fallback', () => { expect(out.function.strict).toBe(false) }) - it('falls back to strict:false when an anyOf variant needs null widening', () => { - const out = convertFunctionToolToChatCompletionsFormat( - anyOfWithOptionalVariantTool, - ) - expect(out.function.strict).toBe(false) - }) }) // This is the converter the provider tool-dispatcher (`convertToolsToProviderFormat`) @@ -180,10 +134,4 @@ describe('function-tool adapter converter — strict fallback', () => { expect(out.strict).toBe(false) }) - it('falls back to strict:false when an anyOf variant needs null widening', () => { - const out = convertFunctionToolToAdapterFormat( - anyOfWithOptionalVariantTool, - ) - expect(out.strict).toBe(false) - }) }) From 482581a6b0442b81a626c1df2acec685ed487e45 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Fri, 17 Jul 2026 13:40:44 +0200 Subject: [PATCH 5/6] fix: fall back for boolean OpenAI schema nodes --- .../chat-completions-tool-converter.test.ts | 20 +++++++++++++++++++ .../adapters/responses-tool-converter.test.ts | 20 +++++++++++++++++++ .../src/tools/function-tool.test.ts | 20 +++++++++++++++++++ .../openai-base/src/utils/schema-converter.ts | 6 +++++- 4 files changed, 65 insertions(+), 1 deletion(-) 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 index 3848d7aeb..92ad2f869 100644 --- a/packages/openai-base/src/adapters/chat-completions-tool-converter.test.ts +++ b/packages/openai-base/src/adapters/chat-completions-tool-converter.test.ts @@ -10,8 +10,28 @@ describe('chat-completions tool converter', () => { 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', diff --git a/packages/openai-base/src/adapters/responses-tool-converter.test.ts b/packages/openai-base/src/adapters/responses-tool-converter.test.ts index 1d6aa1548..f08d8dcff 100644 --- a/packages/openai-base/src/adapters/responses-tool-converter.test.ts +++ b/packages/openai-base/src/adapters/responses-tool-converter.test.ts @@ -18,8 +18,28 @@ describe('responses tool converter', () => { }, }) }) + + 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', diff --git a/packages/openai-base/src/tools/function-tool.test.ts b/packages/openai-base/src/tools/function-tool.test.ts index 32c86b4ae..e2c5d7661 100644 --- a/packages/openai-base/src/tools/function-tool.test.ts +++ b/packages/openai-base/src/tools/function-tool.test.ts @@ -8,8 +8,28 @@ describe('function-tool adapter converter', () => { 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', diff --git a/packages/openai-base/src/utils/schema-converter.ts b/packages/openai-base/src/utils/schema-converter.ts index 251276396..cd88fc73f 100644 --- a/packages/openai-base/src/utils/schema-converter.ts +++ b/packages/openai-base/src/utils/schema-converter.ts @@ -230,8 +230,12 @@ function containsStrictUnsupportedKeyword(node: unknown): boolean { /** A schema-position node that declares no type and so 400s strict mode. */ function isTypelessSchema(node: unknown): boolean { + // JSON Schema also permits bare `true` / `false` schema nodes, but OpenAI's + // strict subset requires one of its supported declared types. Preserve + // boolean schemas by sending the containing tool in non-strict mode. + if (typeof node === 'boolean') return true if (node === null || typeof node !== 'object' || Array.isArray(node)) { - // boolean schemas (`true`/`false`) and non-objects aren't typeless props. + // Non-object values other than boolean schemas aren't schema nodes. return false } return !TYPE_INDICATOR_KEYWORDS.some((key) => key in node) From 27e165544c61e072de2c5e3922d6830adc4d01b1 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Fri, 17 Jul 2026 13:49:05 +0200 Subject: [PATCH 6/6] fix: preserve nullable OpenAI union inputs --- .../openai-base/src/utils/schema-converter.ts | 83 ++++++++++++++----- .../src/utils/tool-input-normalizer.test.ts | 19 +++++ 2 files changed, 80 insertions(+), 22 deletions(-) diff --git a/packages/openai-base/src/utils/schema-converter.ts b/packages/openai-base/src/utils/schema-converter.ts index cd88fc73f..9afa9c8fc 100644 --- a/packages/openai-base/src/utils/schema-converter.ts +++ b/packages/openai-base/src/utils/schema-converter.ts @@ -230,13 +230,11 @@ function containsStrictUnsupportedKeyword(node: unknown): boolean { /** A schema-position node that declares no type and so 400s strict mode. */ function isTypelessSchema(node: unknown): boolean { - // JSON Schema also permits bare `true` / `false` schema nodes, but OpenAI's - // strict subset requires one of its supported declared types. Preserve - // boolean schemas by sending the containing tool in non-strict mode. - if (typeof node === 'boolean') return true if (node === null || typeof node !== 'object' || Array.isArray(node)) { - // Non-object values other than boolean schemas aren't schema nodes. - 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) } @@ -280,6 +278,31 @@ 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, @@ -303,12 +326,16 @@ function coerceStrictSchema( let widenedHere = false // Step 1: Recurse into nested structures - if (prop.type === 'object' && prop.properties) { + 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 (prop.type === 'array' && prop.items) { + } else if ( + isSchemaObject(prop) && + prop.type === 'array' && + prop.items + ) { const nested = coerceStrictSchema(prop.items, prop.items.required || []) prop = { ...prop, @@ -318,12 +345,12 @@ function coerceStrictSchema( ? { items: nested.nullWideningMap } : undefined hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening - } else if (prop.anyOf) { + } else if (isSchemaObject(prop) && prop.anyOf) { const nested = coerceStrictSchema(prop, prop.required || []) prop = nested.schema childMap = nested.nullWideningMap hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening - } else if (prop.oneOf) { + } 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', ) @@ -331,31 +358,43 @@ function coerceStrictSchema( // Step 2: Apply null-widening for optional properties (after recursion) if (wasOptional) { + 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 ('const' in prop && prop.const !== null) { + if (isSchemaObject(prop) && 'const' in prop && prop.const !== null) { const { const: constValue, ...withoutConst } = prop prop = { ...withoutConst, enum: [constValue, null] } - widenedHere = true - } else if (Array.isArray(prop.enum) && !prop.enum.includes(null)) { + } else if ( + isSchemaObject(prop) && + Array.isArray(prop.enum) && + !prop.enum.includes(null) + ) { prop = { ...prop, enum: [...prop.enum, null] } - widenedHere = true } - if (prop.anyOf) { - // For anyOf, add a null variant if not already present - if (!prop.anyOf.some((v: any) => v.type === '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' }] } - widenedHere = true } - } else if (prop.type && !Array.isArray(prop.type)) { + } else if ( + isSchemaObject(prop) && + prop.type && + !Array.isArray(prop.type) + ) { prop = { ...prop, type: [prop.type, 'null'] } - widenedHere = true - } 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 = true } + + widenedHere = !originallyAcceptedNull && acceptsNull(prop) } properties[propName] = prop diff --git a/packages/openai-base/src/utils/tool-input-normalizer.test.ts b/packages/openai-base/src/utils/tool-input-normalizer.test.ts index 359a8c92f..646a18df8 100644 --- a/packages/openai-base/src/utils/tool-input-normalizer.test.ts +++ b/packages/openai-base/src/utils/tool-input-normalizer.test.ts @@ -97,4 +97,23 @@ describe('createToolInputNormalizer', () => { 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) + }) })