From 2a6c4c85ae49d1696d39d523a94762fe49f411f0 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Wed, 15 Jul 2026 00:58:42 +0200 Subject: [PATCH] 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 0541788a5..b53a84392 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, + } } /** @@ -183,11 +209,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'] : []) @@ -195,21 +226,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', @@ -222,29 +264,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, ) } @@ -254,5 +312,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 b7cf600f1..ada77dfea 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.') + }) +})