From 9f9091eefbd7ccaf212f5eaeaf8f9ddbd05a1535 Mon Sep 17 00:00:00 2001 From: Patrick M Date: Mon, 28 Sep 2026 13:28:07 +0200 Subject: [PATCH 1/3] fix(ai-openrouter): keep optional tool fields optional The Chat Completions adapter sent function tools without `strict`. OpenRouter serves OpenAI models through the upstream Responses API, where an omitted `strict` normalizes the schema to strict mode, so the model had to fill every optional field. Send `strict: false`, matching the schema the adapter sends as authored. The Responses adapter sends strict, null-widened tool schemas but emitted the model's `null` for an omitted optional unchanged, so the tool's `.optional()` validation failed and `execute` never ran. Strip exactly the nulls the strict conversion added before emitting TOOL_CALL_END. Fixes #1542 --- .changeset/openrouter-optional-tool-fields.md | 5 + .../src/adapters/responses-text.ts | 26 ++- .../src/internal/responses-tool-converter.ts | 87 +++++++++ .../ai-openrouter/src/tools/function-tool.ts | 6 + .../tests/openrouter-adapter.test.ts | 44 +++++ .../openrouter-responses-adapter.test.ts | 177 ++++++++++++++++++ 6 files changed, 339 insertions(+), 6 deletions(-) create mode 100644 .changeset/openrouter-optional-tool-fields.md diff --git a/.changeset/openrouter-optional-tool-fields.md b/.changeset/openrouter-optional-tool-fields.md new file mode 100644 index 0000000000..51b52c7ca6 --- /dev/null +++ b/.changeset/openrouter-optional-tool-fields.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-openrouter': patch +--- + +Keep optional tool fields optional. The Chat Completions adapter now sends function tools with `strict: false`. OpenRouter serves OpenAI models through the upstream Responses API, where an omitted `strict` forces every optional field, so before this fix the model could not leave one out. The Responses adapter now strips the `null` that strict mode puts in an omitted optional field before the tool input is validated, so the tool runs and sees the field as absent. A `.nullable()` field keeps its `null`. diff --git a/packages/ai-openrouter/src/adapters/responses-text.ts b/packages/ai-openrouter/src/adapters/responses-text.ts index 378bfc576b..186e1b4c40 100644 --- a/packages/ai-openrouter/src/adapters/responses-text.ts +++ b/packages/ai-openrouter/src/adapters/responses-text.ts @@ -14,7 +14,10 @@ import { generateId } from '@tanstack/ai-utils' import { extractRequestOptions } from '../internal/request-options' import { openRouterSupportsCombinedToolsAndSchema } from '../internal/combined-tools-and-schema' import { makeStructuredOutputCompatible } from '../internal/schema-converter' -import { convertFunctionToolToResponsesFormat } from '../internal/responses-tool-converter' +import { + convertFunctionToolToResponsesFormat, + createToolInputNormalizer, +} from '../internal/responses-tool-converter' import { isWebSearchTool } from '../tools/web-search-tool' import { isWebFetchTool } from '../tools/web-fetch-tool' import { getOpenRouterApiKeyFromEnv } from '../utils' @@ -837,6 +840,10 @@ export class OpenRouterResponsesTextAdapter< hasEmittedRunStarted: boolean }, ): AsyncIterable { + const normalizeToolInput = createToolInputNormalizer( + options.tools, + this.makeStructuredOutputCompatible.bind(this), + ) let accumulatedContent = '' let accumulatedReasoning = '' @@ -1325,7 +1332,10 @@ export class OpenRouterResponsesTextAdapter< 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`, @@ -1400,8 +1410,10 @@ export class OpenRouterResponsesTextAdapter< 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)`, @@ -1522,8 +1534,10 @@ export class OpenRouterResponsesTextAdapter< 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/ai-openrouter/src/internal/responses-tool-converter.ts b/packages/ai-openrouter/src/internal/responses-tool-converter.ts index 5df88fa419..f4f94e7c81 100644 --- a/packages/ai-openrouter/src/internal/responses-tool-converter.ts +++ b/packages/ai-openrouter/src/internal/responses-tool-converter.ts @@ -1,5 +1,7 @@ +import { undoNullWidening } from '@tanstack/ai-utils' import { makeStructuredOutputCompatible } from './schema-converter' import type { JSONSchema, Tool } from '@tanstack/ai' +import type { NullWideningMap } from '@tanstack/ai-utils' /** * Responses API function tool format. @@ -55,3 +57,88 @@ export function convertFunctionToolToResponsesFormat( strict: true, } } + +function allowsNull(schema: JSONSchema | undefined): boolean { + if (!schema || typeof schema !== 'object') return false + if (schema.type === 'null') return true + if (Array.isArray(schema.type) && schema.type.includes('null')) return true + if (Array.isArray(schema.enum) && schema.enum.includes(null)) return true + return Array.isArray(schema.anyOf) && schema.anyOf.some(allowsNull) +} + +/** + * Diff the original tool schema against the strict wire schema and mark every + * position where the converter added `null`. Diffing the actual wire schema + * keeps the map aligned with a subclass-supplied `schemaConverter`. A field + * that already allowed `null` (`.nullable()`, `.nullish()`) is not marked, so + * its `null` survives. + */ +function diffNullWidening( + original: JSONSchema | undefined, + wire: JSONSchema | undefined, +): NullWideningMap | undefined { + if (!original || !wire || typeof original !== 'object') return undefined + const map: NullWideningMap = {} + if (allowsNull(wire) && !allowsNull(original)) map.widened = true + + if (original.properties && wire.properties) { + const properties: Record = {} + for (const key of Object.keys(wire.properties)) { + const child = diffNullWidening( + original.properties[key], + wire.properties[key], + ) + if (child) properties[key] = child + } + if (Object.keys(properties).length > 0) map.properties = properties + } + + if ( + original.items && + wire.items && + !Array.isArray(original.items) && + !Array.isArray(wire.items) + ) { + const items = diffNullWidening(original.items, wire.items) + if (items) map.items = items + } + + // A `.nullable()` object reaches the wire as `anyOf: [object, null]`, and + // the converter widens inside the object variant. Descend into that one + // variant. A union of several shapes is ambiguous, so it is left alone. + const nonNullVariants = (schema: JSONSchema) => + (schema.anyOf ?? []).filter((variant) => variant.type !== 'null') + const originalVariants = nonNullVariants(original) + const wireVariants = nonNullVariants(wire) + if (originalVariants.length === 1 && wireVariants.length === 1) { + const inner = diffNullWidening(originalVariants[0], wireVariants[0]) + if (inner?.properties) map.properties = inner.properties + if (inner?.items) map.items = inner.items + } + + return Object.keys(map).length > 0 ? map : undefined +} + +/** + * Build the inverse of the strict null-widening applied to the tools of one + * request. Strict tools reach the model with every optional field promoted to + * required + nullable, so the model sends `null` for an omitted optional. The + * returned function strips exactly those synthesized nulls, so the engine + * validates the input against the original schema and `execute` sees the + * field as absent. Pass the same converter the request used. + */ +export function createToolInputNormalizer( + tools: Array | undefined, + schemaConverter?: Parameters[1], +): (toolName: string, input: unknown) => unknown { + const maps = new Map() + for (const tool of tools ?? []) { + const map = diffNullWidening( + tool.inputSchema, + convertFunctionToolToResponsesFormat(tool, schemaConverter).parameters ?? + undefined, + ) + if (map) maps.set(tool.name, map) + } + return (toolName, input) => undoNullWidening(input, maps.get(toolName)) +} diff --git a/packages/ai-openrouter/src/tools/function-tool.ts b/packages/ai-openrouter/src/tools/function-tool.ts index 43732ecfff..75ed0ebe58 100644 --- a/packages/ai-openrouter/src/tools/function-tool.ts +++ b/packages/ai-openrouter/src/tools/function-tool.ts @@ -7,6 +7,7 @@ export interface FunctionTool { name: string description?: string parameters: Record + strict?: boolean } /** * Anthropic-style prompt-cache breakpoint for the tool definition. @@ -51,6 +52,11 @@ export function convertFunctionToolToAdapterFormat(tool: Tool): FunctionTool { name: tool.name, description: tool.description, parameters: inputSchema, + // The schema is sent as authored, so say so. OpenRouter serves OpenAI + // models through the upstream Responses API, where an omitted `strict` + // makes the provider normalize the schema to strict mode: every optional + // field becomes required and the model can no longer omit it. + strict: false, }, // Only present when supplied — additive and non-breaking. ...(cacheControl ? { cacheControl } : {}), diff --git a/packages/ai-openrouter/tests/openrouter-adapter.test.ts b/packages/ai-openrouter/tests/openrouter-adapter.test.ts index 9ad0edf71b..6a886076ac 100644 --- a/packages/ai-openrouter/tests/openrouter-adapter.test.ts +++ b/packages/ai-openrouter/tests/openrouter-adapter.test.ts @@ -179,6 +179,50 @@ describe('OpenRouter adapter option mapping', () => { expect(serialized).toHaveProperty('tool_choice', 'auto') }) + it('sends function tools with strict: false and the schema as authored', async () => { + // OpenRouter serves OpenAI models through the upstream Responses API. An + // omitted `strict` there makes the provider force every optional field, + // so the adapter has to send `strict: false` explicitly. + setupMockSdkClient([ + { + id: 'chatcmpl-strict', + model: 'openai/gpt-4o-mini', + choices: [{ delta: { content: 'ok' }, finishReason: 'stop' }], + usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 }, + }, + ]) + const inputSchema = { + type: 'object', + properties: { + guitar: { type: 'string' }, + strings: { type: 'array', items: { type: 'string' }, minItems: 1 }, + }, + required: ['guitar'], + } + + for await (const _ of chat({ + adapter: createAdapter(), + messages: [{ role: 'user', content: 'Hello' }], + tools: [ + { name: 'recommend_guitar', description: 'Recommend', inputSchema }, + ], + })) { + // drain + } + + const [rawParams] = mockSend.mock.calls[0]! + const serialized = ChatRequest$outboundSchema.parse(rawParams.chatRequest) + expect(serialized.tools?.[0]).toEqual({ + type: 'function', + function: { + name: 'recommend_guitar', + description: 'Recommend', + parameters: inputSchema, + strict: false, + }, + }) + }) + it('prepends mixed string + object-form systemPrompts as a role:system message and drops foreign metadata', async () => { const streamChunks = [ { diff --git a/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts b/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts index e865a8142e..6554b8232a 100644 --- a/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts +++ b/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts @@ -1123,6 +1123,183 @@ describe('OpenRouter responses adapter — stream event bridge', () => { expect(finished.metadata?.tanstack?.finishReason).toBe('tool_calls') }) + it('undoes strict null-widening before emitting the completed tool input', async () => { + // Strict tools reach the model with optionals widened to required + + // nullable, so an omitted optional comes back as `null`. Only those nulls + // are stripped; a genuine `.nullable()` null stays. + const strictTool: Tool = { + name: 'recommend_guitar', + description: 'Recommend a guitar', + inputSchema: { + type: 'object', + properties: { + guitar: { type: 'string' }, + strings: { + type: 'object', + properties: { + gauges: { type: 'array', items: { type: 'string' }, minItems: 1 }, + brand: { type: 'string' }, + }, + required: ['gauges'], + }, + note: { type: ['string', 'null'] }, + case: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + }, + required: ['guitar', 'note'], + }, + } + const argumentsJson = + '{"guitar":"Martin D-28","strings":null,"note":null,"case":null}' + setupMockSdkClient([ + { + type: 'response.created', + sequenceNumber: 0, + response: { model: 'm', output: [] }, + }, + { + type: 'response.output_item.added', + sequenceNumber: 1, + outputIndex: 0, + item: { + type: 'function_call', + id: 'item_1', + callId: 'call_abc', + name: 'recommend_guitar', + arguments: '', + }, + }, + { + type: 'response.function_call_arguments.delta', + sequenceNumber: 2, + itemId: 'item_1', + outputIndex: 0, + delta: argumentsJson, + }, + { + type: 'response.function_call_arguments.done', + sequenceNumber: 3, + itemId: 'item_1', + outputIndex: 0, + arguments: argumentsJson, + }, + { + type: 'response.completed', + sequenceNumber: 4, + response: { + model: 'm', + output: [{ type: 'function_call' }], + usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + }, + }, + ]) + + const chunks: Array = [] + for await (const c of createAdapter().chatStream({ + logger: testLogger, + model: 'openai/gpt-4o-mini', + messages: [{ role: 'user', content: 'Hello' }], + tools: [strictTool], + })) { + chunks.push(c) + } + + const end = chunks.find((c) => c.type === 'TOOL_CALL_END') + if (end?.type !== 'TOOL_CALL_END') { + throw new Error('expected TOOL_CALL_END') + } + expect(end.input).toEqual({ guitar: 'Martin D-28', note: null, case: null }) + }) + + it('undoes null-widening inside nested optional and nullable objects', async () => { + const strictTool: Tool = { + name: 'recommend_guitar', + description: 'Recommend a guitar', + inputSchema: { + type: 'object', + properties: { + strings: { + type: 'object', + properties: { + gauges: { type: 'array', items: { type: 'string' } }, + brand: { type: 'string' }, + }, + required: ['gauges'], + }, + pickup: { + anyOf: [ + { + type: 'object', + properties: { + store: { type: 'string' }, + date: { type: 'string' }, + }, + required: ['store'], + }, + { type: 'null' }, + ], + }, + }, + required: ['pickup'], + }, + } + const argumentsJson = + '{"strings":{"gauges":["10","46"],"brand":null},"pickup":{"store":"Berlin","date":null}}' + setupMockSdkClient([ + { + type: 'response.created', + sequenceNumber: 0, + response: { model: 'm', output: [] }, + }, + { + type: 'response.output_item.added', + sequenceNumber: 1, + outputIndex: 0, + item: { + type: 'function_call', + id: 'item_1', + callId: 'call_abc', + name: 'recommend_guitar', + arguments: '', + }, + }, + { + type: 'response.function_call_arguments.done', + sequenceNumber: 2, + itemId: 'item_1', + outputIndex: 0, + arguments: argumentsJson, + }, + { + type: 'response.completed', + sequenceNumber: 3, + response: { + model: 'm', + output: [{ type: 'function_call' }], + usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + }, + }, + ]) + + const chunks: Array = [] + for await (const c of createAdapter().chatStream({ + logger: testLogger, + model: 'openai/gpt-4o-mini', + messages: [{ role: 'user', content: 'Hello' }], + tools: [strictTool], + })) { + chunks.push(c) + } + + const end = chunks.find((c) => c.type === 'TOOL_CALL_END') + if (end?.type !== 'TOOL_CALL_END') { + throw new Error('expected TOOL_CALL_END') + } + expect(end.input).toEqual({ + strings: { gauges: ['10', '46'] }, + pickup: { store: 'Berlin' }, + }) + }) + it('preserves a streamed function-call name when later items omit it', async () => { setupMockSdkClient([ { From ceb57cf61ca2bec71dd125fb9dc49ddf3c865d93 Mon Sep 17 00:00:00 2001 From: Patrick M Date: Mon, 28 Sep 2026 13:28:07 +0200 Subject: [PATCH 2/3] test(e2e): cover optional tool fields on both OpenRouter adapters Refs #1542 --- testing/e2e/src/routeTree.gen.ts | 22 ++ .../api.openrouter-strict-tool-optionals.ts | 234 ++++++++++++++++++ .../openrouter-strict-tool-optionals.spec.ts | 55 ++++ 3 files changed, 311 insertions(+) create mode 100644 testing/e2e/src/routes/api.openrouter-strict-tool-optionals.ts create mode 100644 testing/e2e/tests/openrouter-strict-tool-optionals.spec.ts diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 013e1601a1..e29acc1ceb 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -66,6 +66,7 @@ import { Route as ApiOtelUsageRouteImport } from './routes/api.otel-usage' import { Route as ApiOtelTranscriptionRouteImport } from './routes/api.otel-transcription' import { Route as ApiOtelMediaRouteImport } from './routes/api.otel-media' import { Route as ApiOpenrouterWebToolsWireRouteImport } from './routes/api.openrouter-web-tools-wire' +import { Route as ApiOpenrouterStrictToolOptionalsRouteImport } from './routes/api.openrouter-strict-tool-optionals' import { Route as ApiOpenrouterStreamOptionsWireRouteImport } from './routes/api.openrouter-stream-options-wire' import { Route as ApiOpenrouterRetryCodesRouteImport } from './routes/api.openrouter-retry-codes' import { Route as ApiOpenrouterReasoningWireRouteImport } from './routes/api.openrouter-reasoning-wire' @@ -428,6 +429,12 @@ const ApiOpenrouterWebToolsWireRoute = path: '/api/openrouter-web-tools-wire', getParentRoute: () => rootRouteImport, } as any) +const ApiOpenrouterStrictToolOptionalsRoute = + ApiOpenrouterStrictToolOptionalsRouteImport.update({ + id: '/api/openrouter-strict-tool-optionals', + path: '/api/openrouter-strict-tool-optionals', + getParentRoute: () => rootRouteImport, + } as any) const ApiOpenrouterStreamOptionsWireRoute = ApiOpenrouterStreamOptionsWireRouteImport.update({ id: '/api/openrouter-stream-options-wire', @@ -868,6 +875,7 @@ export interface FileRoutesByFullPath { '/api/openrouter-reasoning-wire': typeof ApiOpenrouterReasoningWireRoute '/api/openrouter-retry-codes': typeof ApiOpenrouterRetryCodesRoute '/api/openrouter-stream-options-wire': typeof ApiOpenrouterStreamOptionsWireRoute + '/api/openrouter-strict-tool-optionals': typeof ApiOpenrouterStrictToolOptionalsRoute '/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute '/api/otel-media': typeof ApiOtelMediaRoute '/api/otel-transcription': typeof ApiOtelTranscriptionRoute @@ -993,6 +1001,7 @@ export interface FileRoutesByTo { '/api/openrouter-reasoning-wire': typeof ApiOpenrouterReasoningWireRoute '/api/openrouter-retry-codes': typeof ApiOpenrouterRetryCodesRoute '/api/openrouter-stream-options-wire': typeof ApiOpenrouterStreamOptionsWireRoute + '/api/openrouter-strict-tool-optionals': typeof ApiOpenrouterStrictToolOptionalsRoute '/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute '/api/otel-media': typeof ApiOtelMediaRoute '/api/otel-transcription': typeof ApiOtelTranscriptionRoute @@ -1119,6 +1128,7 @@ export interface FileRoutesById { '/api/openrouter-reasoning-wire': typeof ApiOpenrouterReasoningWireRoute '/api/openrouter-retry-codes': typeof ApiOpenrouterRetryCodesRoute '/api/openrouter-stream-options-wire': typeof ApiOpenrouterStreamOptionsWireRoute + '/api/openrouter-strict-tool-optionals': typeof ApiOpenrouterStrictToolOptionalsRoute '/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute '/api/otel-media': typeof ApiOtelMediaRoute '/api/otel-transcription': typeof ApiOtelTranscriptionRoute @@ -1246,6 +1256,7 @@ export interface FileRouteTypes { | '/api/openrouter-reasoning-wire' | '/api/openrouter-retry-codes' | '/api/openrouter-stream-options-wire' + | '/api/openrouter-strict-tool-optionals' | '/api/openrouter-web-tools-wire' | '/api/otel-media' | '/api/otel-transcription' @@ -1371,6 +1382,7 @@ export interface FileRouteTypes { | '/api/openrouter-reasoning-wire' | '/api/openrouter-retry-codes' | '/api/openrouter-stream-options-wire' + | '/api/openrouter-strict-tool-optionals' | '/api/openrouter-web-tools-wire' | '/api/otel-media' | '/api/otel-transcription' @@ -1496,6 +1508,7 @@ export interface FileRouteTypes { | '/api/openrouter-reasoning-wire' | '/api/openrouter-retry-codes' | '/api/openrouter-stream-options-wire' + | '/api/openrouter-strict-tool-optionals' | '/api/openrouter-web-tools-wire' | '/api/otel-media' | '/api/otel-transcription' @@ -1622,6 +1635,7 @@ export interface RootRouteChildren { ApiOpenrouterReasoningWireRoute: typeof ApiOpenrouterReasoningWireRoute ApiOpenrouterRetryCodesRoute: typeof ApiOpenrouterRetryCodesRoute ApiOpenrouterStreamOptionsWireRoute: typeof ApiOpenrouterStreamOptionsWireRoute + ApiOpenrouterStrictToolOptionalsRoute: typeof ApiOpenrouterStrictToolOptionalsRoute ApiOpenrouterWebToolsWireRoute: typeof ApiOpenrouterWebToolsWireRoute ApiOtelMediaRoute: typeof ApiOtelMediaRoute ApiOtelTranscriptionRoute: typeof ApiOtelTranscriptionRoute @@ -2051,6 +2065,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiOpenrouterWebToolsWireRouteImport parentRoute: typeof rootRouteImport } + '/api/openrouter-strict-tool-optionals': { + id: '/api/openrouter-strict-tool-optionals' + path: '/api/openrouter-strict-tool-optionals' + fullPath: '/api/openrouter-strict-tool-optionals' + preLoaderRoute: typeof ApiOpenrouterStrictToolOptionalsRouteImport + parentRoute: typeof rootRouteImport + } '/api/openrouter-stream-options-wire': { id: '/api/openrouter-stream-options-wire' path: '/api/openrouter-stream-options-wire' @@ -2668,6 +2689,7 @@ const rootRouteChildren: RootRouteChildren = { ApiOpenrouterReasoningWireRoute: ApiOpenrouterReasoningWireRoute, ApiOpenrouterRetryCodesRoute: ApiOpenrouterRetryCodesRoute, ApiOpenrouterStreamOptionsWireRoute: ApiOpenrouterStreamOptionsWireRoute, + ApiOpenrouterStrictToolOptionalsRoute: ApiOpenrouterStrictToolOptionalsRoute, ApiOpenrouterWebToolsWireRoute: ApiOpenrouterWebToolsWireRoute, ApiOtelMediaRoute: ApiOtelMediaRoute, ApiOtelTranscriptionRoute: ApiOtelTranscriptionRoute, diff --git a/testing/e2e/src/routes/api.openrouter-strict-tool-optionals.ts b/testing/e2e/src/routes/api.openrouter-strict-tool-optionals.ts new file mode 100644 index 0000000000..1ad9f8309c --- /dev/null +++ b/testing/e2e/src/routes/api.openrouter-strict-tool-optionals.ts @@ -0,0 +1,234 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + chat, + createChatOptions, + maxIterations, + toolDefinition, +} from '@tanstack/ai' +import { + createOpenRouterResponsesText, + createOpenRouterText, +} from '@tanstack/ai-openrouter' +import { HTTPClient } from '@openrouter/sdk' +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 makeChatTextStream(): ReadableStream { + return makeEventStream([ + { + id: 'chatcmpl-strict-optionals', + object: 'chat.completion.chunk', + created: 0, + model: 'openai/gpt-5.2', + choices: [ + { + index: 0, + delta: { content: 'Tool executed.' }, + finish_reason: 'stop', + }, + ], + }, + ]) +} + +// The strict wire schema made `strings` required + nullable, so the model +// sends `null` for the omitted optional. +function makeResponsesToolCallStream(): ReadableStream { + const responseId = 'resp_strict_optionals' + const itemId = 'call_strict_optionals' + const args = JSON.stringify({ guitar: 'Martin D-28', strings: null }) + const item = { + id: itemId, + call_id: itemId, + type: 'function_call', + name: 'recommend_guitar', + arguments: args, + status: 'completed', + } + return makeEventStream([ + { + type: 'response.created', + sequence_number: 0, + response: { + id: responseId, + object: 'response', + model: 'openai/gpt-5.2', + status: 'in_progress', + output: [], + }, + }, + { + type: 'response.output_item.added', + sequence_number: 1, + output_index: 0, + item: { ...item, arguments: '', status: 'in_progress' }, + }, + { + type: 'response.function_call_arguments.done', + sequence_number: 2, + item_id: itemId, + output_index: 0, + arguments: args, + }, + { + type: 'response.completed', + sequence_number: 3, + response: { + id: responseId, + object: 'response', + model: 'openai/gpt-5.2', + status: 'completed', + output: [item], + usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 }, + }, + }, + ]) +} + +function makeResponsesTextStream(): ReadableStream { + const responseId = 'resp_strict_optionals_text' + const itemId = 'msg_strict_optionals_text' + return makeEventStream([ + { + type: 'response.created', + sequence_number: 0, + response: { + id: responseId, + object: 'response', + model: 'openai/gpt-5.2', + status: 'in_progress', + output: [], + }, + }, + { + type: 'response.output_text.delta', + sequence_number: 1, + item_id: itemId, + output_index: 0, + content_index: 0, + delta: 'Tool executed.', + }, + { + type: 'response.completed', + sequence_number: 2, + response: { + id: responseId, + object: 'response', + model: 'openai/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 }, + }, + }, + ]) +} + +/** + * Drives both OpenRouter text adapters with a tool that has an optional + * field. Chat Completions must send `strict: false`: OpenRouter serves OpenAI + * models through the upstream Responses API, where an omitted `strict` forces + * every optional field. The Responses adapter sends a strict, null-widened + * schema and must strip the synthesized `null` before the tool runs. + */ +export const Route = createFileRoute('/api/openrouter-strict-tool-optionals')({ + server: { + handlers: { + POST: async ({ request }) => { + const api = new URL(request.url).searchParams.get('api') + let requestCount = 0 + let firstRequestBody: unknown + let executedInput: unknown + + const httpClient = new HTTPClient({ + fetcher: async (input, init) => { + requestCount++ + const req = + input instanceof Request ? input : new Request(input, init) + if (requestCount === 1) { + firstRequestBody = JSON.parse(await req.text()) + } + const body = + api === 'responses' + ? requestCount === 1 + ? makeResponsesToolCallStream() + : makeResponsesTextStream() + : makeChatTextStream() + return new Response(body, { + headers: { 'Content-Type': 'text/event-stream' }, + }) + }, + }) + + const recommendGuitar = toolDefinition({ + name: 'recommend_guitar', + description: 'Recommend a guitar', + inputSchema: z.object({ + guitar: z.string(), + strings: z + .object({ gauges: z.array(z.string()).min(1) }) + .optional(), + }), + }).server((input) => { + executedInput = input + return { accepted: true } + }) + + const config = { + serverURL: 'http://openrouter.test/api/v1', + httpClient, + } + const adapter = + api === 'responses' + ? createOpenRouterResponsesText('openai/gpt-5.2', DUMMY_KEY, config) + : createOpenRouterText('openai/gpt-5.2', DUMMY_KEY, config) + const text: Array = [] + + try { + for await (const chunk of chat({ + ...createChatOptions({ adapter }), + messages: [{ role: 'user', content: 'Hello' }], + tools: [recommendGuitar], + 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/openrouter-strict-tool-optionals.spec.ts b/testing/e2e/tests/openrouter-strict-tool-optionals.spec.ts new file mode 100644 index 0000000000..22bcfb1f48 --- /dev/null +++ b/testing/e2e/tests/openrouter-strict-tool-optionals.spec.ts @@ -0,0 +1,55 @@ +import { expect, test } from './fixtures' + +type RouteResult = { + ok: boolean + error?: string + requestCount: number + firstRequestBody: { tools: Array> } + executedInput: Record + text: string +} + +test.describe('openrouter — optional tool fields', () => { + test('chat completions sends strict: false with the schema as authored', async ({ + request, + }) => { + const response = await request.post( + '/api/openrouter-strict-tool-optionals?api=chat', + ) + expect(response.ok()).toBe(true) + const result = (await response.json()) as RouteResult + if (!result.ok) throw new Error(`Route failed: ${result.error}`) + + expect(result.firstRequestBody.tools[0]).toMatchObject({ + type: 'function', + function: { + name: 'recommend_guitar', + strict: false, + parameters: { required: ['guitar'] }, + }, + }) + }) + + test('responses undoes provider-added nullability before the tool runs', async ({ + request, + }) => { + const response = await request.post( + '/api/openrouter-strict-tool-optionals?api=responses', + ) + expect(response.ok()).toBe(true) + const result = (await response.json()) as RouteResult + if (!result.ok) throw new Error(`Route failed: ${result.error}`) + + expect(result.firstRequestBody.tools[0]).toMatchObject({ + name: 'recommend_guitar', + strict: true, + parameters: { + required: ['guitar', 'strings'], + properties: { strings: { type: ['object', 'null'] } }, + }, + }) + expect(result.executedInput).toEqual({ guitar: 'Martin D-28' }) + expect(result.requestCount).toBe(2) + expect(result.text).toBe('Tool executed.') + }) +}) From df4fff5388dd49157f483f710478195e440b26e2 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:07:51 +1100 Subject: [PATCH 3/3] test(ai-openrouter): cover all three Responses emit paths, soften routing claim Merge the two null-widening unit tests into one table-driven test that delivers the arguments through function_call_arguments.done, the output_item.done backfill, and the response.completed backfill. State the upstream strict behaviour as observed (#1542) in one place, and note the multi-shape union limit in the changeset. Refs #1542 --- .changeset/openrouter-optional-tool-fields.md | 2 +- .../ai-openrouter/src/tools/function-tool.ts | 7 +- .../tests/openrouter-adapter.test.ts | 3 - .../openrouter-responses-adapter.test.ts | 286 ++++++++---------- .../api.openrouter-strict-tool-optionals.ts | 7 +- 5 files changed, 127 insertions(+), 178 deletions(-) diff --git a/.changeset/openrouter-optional-tool-fields.md b/.changeset/openrouter-optional-tool-fields.md index 51b52c7ca6..d2b99083f9 100644 --- a/.changeset/openrouter-optional-tool-fields.md +++ b/.changeset/openrouter-optional-tool-fields.md @@ -2,4 +2,4 @@ '@tanstack/ai-openrouter': patch --- -Keep optional tool fields optional. The Chat Completions adapter now sends function tools with `strict: false`. OpenRouter serves OpenAI models through the upstream Responses API, where an omitted `strict` forces every optional field, so before this fix the model could not leave one out. The Responses adapter now strips the `null` that strict mode puts in an omitted optional field before the tool input is validated, so the tool runs and sees the field as absent. A `.nullable()` field keeps its `null`. +Keep optional tool fields optional. The Chat Completions adapter now sends function tools with `strict: false`. With `strict` omitted, OpenAI models through OpenRouter were observed to treat the schema as strict and make every optional field required, so before this fix the model could not leave one out. The Responses adapter now strips the `null` that strict mode puts in an omitted optional field before the tool input is validated, so the tool runs and sees the field as absent. A `.nullable()` field keeps its `null`. Known limit: an optional field inside a union of several object shapes is not stripped and still arrives as `null`. diff --git a/packages/ai-openrouter/src/tools/function-tool.ts b/packages/ai-openrouter/src/tools/function-tool.ts index 75ed0ebe58..dba12880ec 100644 --- a/packages/ai-openrouter/src/tools/function-tool.ts +++ b/packages/ai-openrouter/src/tools/function-tool.ts @@ -52,10 +52,9 @@ export function convertFunctionToolToAdapterFormat(tool: Tool): FunctionTool { name: tool.name, description: tool.description, parameters: inputSchema, - // The schema is sent as authored, so say so. OpenRouter serves OpenAI - // models through the upstream Responses API, where an omitted `strict` - // makes the provider normalize the schema to strict mode: every optional - // field becomes required and the model can no longer omit it. + // Sent explicitly for every model. Observed behaviour (#1542): with + // `strict` omitted, OpenAI models through OpenRouter treat the schema as + // strict, so every optional field becomes required. strict: false, }, // Only present when supplied — additive and non-breaking. diff --git a/packages/ai-openrouter/tests/openrouter-adapter.test.ts b/packages/ai-openrouter/tests/openrouter-adapter.test.ts index 6a886076ac..c6bfae0dd1 100644 --- a/packages/ai-openrouter/tests/openrouter-adapter.test.ts +++ b/packages/ai-openrouter/tests/openrouter-adapter.test.ts @@ -180,9 +180,6 @@ describe('OpenRouter adapter option mapping', () => { }) it('sends function tools with strict: false and the schema as authored', async () => { - // OpenRouter serves OpenAI models through the upstream Responses API. An - // omitted `strict` there makes the provider force every optional field, - // so the adapter has to send `strict: false` explicitly. setupMockSdkClient([ { id: 'chatcmpl-strict', diff --git a/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts b/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts index 6554b8232a..fccbb743e4 100644 --- a/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts +++ b/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts @@ -1123,182 +1123,136 @@ describe('OpenRouter responses adapter — stream event bridge', () => { expect(finished.metadata?.tanstack?.finishReason).toBe('tool_calls') }) - it('undoes strict null-widening before emitting the completed tool input', async () => { - // Strict tools reach the model with optionals widened to required + - // nullable, so an omitted optional comes back as `null`. Only those nulls - // are stripped; a genuine `.nullable()` null stays. - const strictTool: Tool = { - name: 'recommend_guitar', - description: 'Recommend a guitar', - inputSchema: { - type: 'object', - properties: { - guitar: { type: 'string' }, - strings: { - type: 'object', - properties: { - gauges: { type: 'array', items: { type: 'string' }, minItems: 1 }, - brand: { type: 'string' }, - }, - required: ['gauges'], - }, - note: { type: ['string', 'null'] }, - case: { anyOf: [{ type: 'string' }, { type: 'null' }] }, - }, - required: ['guitar', 'note'], - }, - } - const argumentsJson = - '{"guitar":"Martin D-28","strings":null,"note":null,"case":null}' - setupMockSdkClient([ - { - type: 'response.created', - sequenceNumber: 0, - response: { model: 'm', output: [] }, - }, - { - type: 'response.output_item.added', - sequenceNumber: 1, - outputIndex: 0, - item: { - type: 'function_call', - id: 'item_1', - callId: 'call_abc', - name: 'recommend_guitar', - arguments: '', + // Strict tools reach the model with optionals widened to required + + // nullable, so an omitted optional comes back as `null`. Only those nulls are + // stripped; a genuine `.nullable()` null stays. Each row delivers the + // arguments through a different TOOL_CALL_END emit path. + const widenedArguments = + '{"guitar":"Martin D-28","color":null,"strings":{"gauges":["10","46"],"brand":null},"note":null,"case":null,"pickup":{"store":"Berlin","date":null}}' + const widenedCall = { + type: 'function_call', + id: 'item_1', + callId: 'call_abc', + name: 'recommend_guitar', + } + const widenedCallAdded = { + type: 'response.output_item.added', + sequenceNumber: 1, + outputIndex: 0, + item: { ...widenedCall, arguments: '' }, + } + it.each([ + { + path: 'function_call_arguments.done', + events: [ + widenedCallAdded, + { + type: 'response.function_call_arguments.done', + sequenceNumber: 2, + itemId: 'item_1', + outputIndex: 0, + arguments: widenedArguments, }, - }, - { - type: 'response.function_call_arguments.delta', - sequenceNumber: 2, - itemId: 'item_1', - outputIndex: 0, - delta: argumentsJson, - }, - { - type: 'response.function_call_arguments.done', - sequenceNumber: 3, - itemId: 'item_1', - outputIndex: 0, - arguments: argumentsJson, - }, - { - type: 'response.completed', - sequenceNumber: 4, - response: { - model: 'm', - output: [{ type: 'function_call' }], - usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + ], + completedOutput: [{ type: 'function_call' }], + }, + { + path: 'output_item.done backfill', + events: [ + widenedCallAdded, + { + type: 'response.output_item.done', + sequenceNumber: 2, + outputIndex: 0, + item: { ...widenedCall, arguments: widenedArguments }, }, - }, - ]) - - const chunks: Array = [] - for await (const c of createAdapter().chatStream({ - logger: testLogger, - model: 'openai/gpt-4o-mini', - messages: [{ role: 'user', content: 'Hello' }], - tools: [strictTool], - })) { - chunks.push(c) - } - - const end = chunks.find((c) => c.type === 'TOOL_CALL_END') - if (end?.type !== 'TOOL_CALL_END') { - throw new Error('expected TOOL_CALL_END') - } - expect(end.input).toEqual({ guitar: 'Martin D-28', note: null, case: null }) - }) - - it('undoes null-widening inside nested optional and nullable objects', async () => { - const strictTool: Tool = { - name: 'recommend_guitar', - description: 'Recommend a guitar', - inputSchema: { - type: 'object', - properties: { - strings: { - type: 'object', - properties: { - gauges: { type: 'array', items: { type: 'string' } }, - brand: { type: 'string' }, + ], + completedOutput: [], + }, + { + path: 'response.completed backfill', + events: [], + completedOutput: [{ ...widenedCall, arguments: widenedArguments }], + }, + ])( + 'undoes strict null-widening on the $path path', + async ({ events, completedOutput }) => { + const strictTool: Tool = { + name: 'recommend_guitar', + description: 'Recommend a guitar', + inputSchema: { + type: 'object', + properties: { + guitar: { type: 'string' }, + color: { type: 'string' }, + strings: { + type: 'object', + properties: { + gauges: { type: 'array', items: { type: 'string' } }, + brand: { type: 'string' }, + }, + required: ['gauges'], }, - required: ['gauges'], - }, - pickup: { - anyOf: [ - { - type: 'object', - properties: { - store: { type: 'string' }, - date: { type: 'string' }, + note: { type: ['string', 'null'] }, + case: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + pickup: { + anyOf: [ + { + type: 'object', + properties: { + store: { type: 'string' }, + date: { type: 'string' }, + }, + required: ['store'], }, - required: ['store'], - }, - { type: 'null' }, - ], + { type: 'null' }, + ], + }, }, + required: ['guitar', 'note', 'pickup'], }, - required: ['pickup'], - }, - } - const argumentsJson = - '{"strings":{"gauges":["10","46"],"brand":null},"pickup":{"store":"Berlin","date":null}}' - setupMockSdkClient([ - { - type: 'response.created', - sequenceNumber: 0, - response: { model: 'm', output: [] }, - }, - { - type: 'response.output_item.added', - sequenceNumber: 1, - outputIndex: 0, - item: { - type: 'function_call', - id: 'item_1', - callId: 'call_abc', - name: 'recommend_guitar', - arguments: '', + } + setupMockSdkClient([ + { + type: 'response.created', + sequenceNumber: 0, + response: { model: 'm', output: [] }, }, - }, - { - type: 'response.function_call_arguments.done', - sequenceNumber: 2, - itemId: 'item_1', - outputIndex: 0, - arguments: argumentsJson, - }, - { - type: 'response.completed', - sequenceNumber: 3, - response: { - model: 'm', - output: [{ type: 'function_call' }], - usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + ...events, + { + type: 'response.completed', + sequenceNumber: 3, + response: { + model: 'm', + output: completedOutput, + usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + }, }, - }, - ]) + ]) - const chunks: Array = [] - for await (const c of createAdapter().chatStream({ - logger: testLogger, - model: 'openai/gpt-4o-mini', - messages: [{ role: 'user', content: 'Hello' }], - tools: [strictTool], - })) { - chunks.push(c) - } + const chunks: Array = [] + for await (const c of createAdapter().chatStream({ + logger: testLogger, + model: 'openai/gpt-4o-mini', + messages: [{ role: 'user', content: 'Hello' }], + tools: [strictTool], + })) { + chunks.push(c) + } - const end = chunks.find((c) => c.type === 'TOOL_CALL_END') - if (end?.type !== 'TOOL_CALL_END') { - throw new Error('expected TOOL_CALL_END') - } - expect(end.input).toEqual({ - strings: { gauges: ['10', '46'] }, - pickup: { store: 'Berlin' }, - }) - }) + const end = chunks.find((c) => c.type === 'TOOL_CALL_END') + if (end?.type !== 'TOOL_CALL_END') { + throw new Error('expected TOOL_CALL_END') + } + expect(end.input).toEqual({ + guitar: 'Martin D-28', + strings: { gauges: ['10', '46'] }, + note: null, + case: null, + pickup: { store: 'Berlin' }, + }) + }, + ) it('preserves a streamed function-call name when later items omit it', async () => { setupMockSdkClient([ diff --git a/testing/e2e/src/routes/api.openrouter-strict-tool-optionals.ts b/testing/e2e/src/routes/api.openrouter-strict-tool-optionals.ts index 1ad9f8309c..6d93e238d9 100644 --- a/testing/e2e/src/routes/api.openrouter-strict-tool-optionals.ts +++ b/testing/e2e/src/routes/api.openrouter-strict-tool-optionals.ts @@ -147,10 +147,9 @@ function makeResponsesTextStream(): ReadableStream { /** * Drives both OpenRouter text adapters with a tool that has an optional - * field. Chat Completions must send `strict: false`: OpenRouter serves OpenAI - * models through the upstream Responses API, where an omitted `strict` forces - * every optional field. The Responses adapter sends a strict, null-widened - * schema and must strip the synthesized `null` before the tool runs. + * field. Chat Completions must send `strict: false`. The Responses adapter + * sends a strict, null-widened schema and must strip the synthesized `null` + * before the tool runs. */ export const Route = createFileRoute('/api/openrouter-strict-tool-optionals')({ server: {