diff --git a/.changeset/openrouter-optional-tool-fields.md b/.changeset/openrouter-optional-tool-fields.md new file mode 100644 index 0000000000..d2b99083f9 --- /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`. 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/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..dba12880ec 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,10 @@ export function convertFunctionToolToAdapterFormat(tool: Tool): FunctionTool { name: tool.name, description: tool.description, parameters: inputSchema, + // 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. ...(cacheControl ? { cacheControl } : {}), diff --git a/packages/ai-openrouter/tests/openrouter-adapter.test.ts b/packages/ai-openrouter/tests/openrouter-adapter.test.ts index 9ad0edf71b..c6bfae0dd1 100644 --- a/packages/ai-openrouter/tests/openrouter-adapter.test.ts +++ b/packages/ai-openrouter/tests/openrouter-adapter.test.ts @@ -179,6 +179,47 @@ describe('OpenRouter adapter option mapping', () => { expect(serialized).toHaveProperty('tool_choice', 'auto') }) + it('sends function tools with strict: false and the schema as authored', async () => { + 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..fccbb743e4 100644 --- a/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts +++ b/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts @@ -1123,6 +1123,137 @@ describe('OpenRouter responses adapter — stream event bridge', () => { expect(finished.metadata?.tanstack?.finishReason).toBe('tool_calls') }) + // 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, + }, + ], + completedOutput: [{ type: 'function_call' }], + }, + { + path: 'output_item.done backfill', + events: [ + widenedCallAdded, + { + type: 'response.output_item.done', + sequenceNumber: 2, + outputIndex: 0, + item: { ...widenedCall, arguments: widenedArguments }, + }, + ], + 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'], + }, + note: { type: ['string', 'null'] }, + case: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + pickup: { + anyOf: [ + { + type: 'object', + properties: { + store: { type: 'string' }, + date: { type: 'string' }, + }, + required: ['store'], + }, + { type: 'null' }, + ], + }, + }, + required: ['guitar', 'note', 'pickup'], + }, + } + setupMockSdkClient([ + { + type: 'response.created', + sequenceNumber: 0, + response: { model: 'm', output: [] }, + }, + ...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 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/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 0aa05a57ef..341f71f1cc 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -103,6 +103,7 @@ import { Route as ApiOpenrouterJsonObjectWireRouteImport } from './routes/api.op import { Route as ApiOpenrouterReasoningWireRouteImport } from './routes/api.openrouter-reasoning-wire' import { Route as ApiOpenrouterRetryCodesRouteImport } from './routes/api.openrouter-retry-codes' import { Route as ApiOpenrouterStreamOptionsWireRouteImport } from './routes/api.openrouter-stream-options-wire' +import { Route as ApiOpenrouterStrictToolOptionalsRouteImport } from './routes/api.openrouter-strict-tool-optionals' import { Route as ApiOpenrouterWebToolsWireRouteImport } from './routes/api.openrouter-web-tools-wire' import { Route as ApiOtelMediaRouteImport } from './routes/api.otel-media' import { Route as ApiOtelTranscriptionRouteImport } from './routes/api.otel-transcription' @@ -624,6 +625,12 @@ const ApiOpenrouterStreamOptionsWireRoute = path: '/api/openrouter-stream-options-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 ApiOpenrouterWebToolsWireRoute = ApiOpenrouterWebToolsWireRouteImport.update({ id: '/api/openrouter-web-tools-wire', @@ -876,6 +883,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 @@ -1002,6 +1010,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 @@ -1129,6 +1138,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 @@ -1257,6 +1267,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' @@ -1383,6 +1394,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' @@ -1509,6 +1521,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' @@ -1636,6 +1649,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 @@ -2324,6 +2338,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiOpenrouterStreamOptionsWireRouteImport 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-web-tools-wire': { id: '/api/openrouter-web-tools-wire' path: '/api/openrouter-web-tools-wire' @@ -2690,6 +2711,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..6d93e238d9 --- /dev/null +++ b/testing/e2e/src/routes/api.openrouter-strict-tool-optionals.ts @@ -0,0 +1,233 @@ +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`. 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.') + }) +})