TanStack AI version
0.63.0
Framework/Library version
@tanstack/ai-openrouter: 0.20.1 | @openrouter/sdk: 0.13.20 | zod: 4
Describe the bug and the steps to reproduce it
An .optional() tool field does not work with either OpenRouter text adapter when the model is an OpenAI model.
1. createOpenRouterText (Chat Completions): the model cannot omit an optional field.
The adapter sends the tool schema as written and sets no strict (function-tool.ts). OpenRouter serves OpenAI models through the upstream Responses API. debug: { echo_upstream_body: true } shows the upstream body with input and flat tools, still without strict. OpenAI's function calling guide says: "If you omit strict … Responses requests will attempt to normalize your schema into strict mode … Chat Completions requests remain non-strict by default." So every optional field becomes required, and the model has to fill it. With minItems: 1 it makes up a value.
// OPENROUTER_API_KEY=... node live.mjs
const tool = (strict) => ({
type: 'function',
function: {
name: 'recommend_guitar',
description: 'Recommend a guitar. Pass strings only if the user asks about strings.',
parameters: {
type: 'object',
properties: {
guitar: { type: 'string' },
strings: {
type: 'object',
properties: { gauges: { type: 'array', items: { type: 'string' }, minItems: 1 } },
required: ['gauges'],
},
},
required: ['guitar'],
},
...(strict === undefined ? {} : { strict }),
},
})
for (const strict of [undefined, false]) {
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: { authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, 'content-type': 'application/json' },
body: JSON.stringify({
model: 'openai/gpt-5.5',
messages: [{ role: 'user', content: 'Recommend an acoustic guitar. Do not pass strings.' }],
tools: [tool(strict)],
tool_choice: 'required',
}),
}).then((r) => r.json())
console.log(`strict=${strict}`, res.choices[0].message.tool_calls[0].function.arguments)
}
strict=undefined {"guitar":"Yamaha FG800 …","strings":{"gauges":[""]}}
strict=false {"guitar":"Yamaha FG800"}
Five runs each on openai/gpt-5.5: strings was present 5/5 without strict and 0/5 with strict: false. It is the same through Azure (provider: { only: ['azure'] }) and on openai/gpt-4.1. With no strict, the model also cannot send an enum value outside the list. anthropic/claude-sonnet-4.5 on Bedrock omits the field either way, and google/gemini-2.5-flash accepts strict: false.
createOpenRouterText sends the same schema as live.mjs without strict:
import { chat, toolDefinition } from '@tanstack/ai'
import { createOpenRouterText } from '@tanstack/ai-openrouter'
import { HTTPClient } from '@openrouter/sdk'
import { z } from 'zod'
const httpClient = new HTTPClient({
fetcher: async (req) => {
console.log((await req.json()).tools[0].function) // no `strict`
return new Response('{"error":{"code":400,"message":"stop"}}', { status: 400 })
},
})
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(),
}),
})
const adapter = createOpenRouterText('openai/gpt-5.5', 'sk-or-test', { httpClient })
for await (const _ of chat({ adapter, messages: [{ role: 'user', content: 'Hello' }], tools: [recommendGuitar] })) {}
2. createOpenRouterResponsesText: the tool never runs when the model omits an optional field.
The Responses adapter makes every tool strict. It widens optional fields to required + nullable, so the model sends null for an omitted optional. The adapter emits that null unchanged in TOOL_CALL_END.input (1, 2, 3). The engine checks it against the original schema, and .optional() rejects null. @tanstack/openai-base fixed the same problem in #939 with createToolInputNormalizer, and ai-mistral did in #956. The OpenRouter adapters do not use openai-base, so they did not get that fix.
This repro needs no API key. The fetcher is stubbed:
import { chat, maxIterations, toolDefinition } from '@tanstack/ai'
import { createOpenRouterResponsesText } from '@tanstack/ai-openrouter'
import { HTTPClient } from '@openrouter/sdk'
import { z } from 'zod'
// The model left out `strings`. The strict wire schema made it required + nullable.
const args = JSON.stringify({ guitar: 'Martin D-28', strings: null })
const item = { id: 'fc_1', call_id: 'call_1', type: 'function_call', name: 'recommend_guitar', arguments: args, status: 'completed' }
const events = [
{ type: 'response.created', sequence_number: 0, response: { id: 'resp_1', object: 'response', model: 'openai/gpt-5.5', status: 'in_progress', output: [] } },
{ type: 'response.output_item.added', sequence_number: 1, output_index: 0, item: { ...item, arguments: '' } },
{ type: 'response.function_call_arguments.done', sequence_number: 2, item_id: 'fc_1', output_index: 0, arguments: args },
{ type: 'response.completed', sequence_number: 3, response: { id: 'resp_1', object: 'response', model: 'openai/gpt-5.5', status: 'completed', output: [item] } },
]
const sse = events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join('') + 'data: [DONE]\n\n'
const httpClient = new HTTPClient({
fetcher: async () => new Response(sse, { 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) => {
console.log('execute ran with', input) // never printed
return { ok: true }
})
const adapter = createOpenRouterResponsesText('openai/gpt-5.5', 'sk-or-test', { httpClient })
for await (const chunk of chat({
adapter,
messages: [{ role: 'user', content: 'Recommend a guitar' }],
tools: [recommendGuitar],
agentLoopStrategy: maxIterations(1),
})) {
if (chunk.type === 'TOOL_CALL_RESULT') console.log(chunk.content)
}
{"error":"Input validation failed for tool recommend_guitar: Validation failed: Invalid input: expected object, received null"}
Expected behavior
Both adapters follow the contract in docs/tools/tools.md: "an omitted .optional() tool field is absent when your tool runs. A .nullable() field keeps null."
createOpenRouterText sends strict: false. That matches the schema it sends as written, and it is the Chat Completions default.
createOpenRouterResponsesText removes the nulls its strict conversion added before it emits TOOL_CALL_END, the same way openai-base does.
Your Minimal, Reproducible Example - (Sandbox Highly Recommended)
Inline above. The Responses repro needs no API key. The Chat Completions repro needs an OpenRouter key because the behavior comes from the upstream model.
Do you intend to try to help solve this bug with your own PR?
Yes, I am also opening a PR that solves the problem along side this issue
Terms & Code of Conduct
TanStack AI version
0.63.0
Framework/Library version
@tanstack/ai-openrouter: 0.20.1 | @openrouter/sdk: 0.13.20 | zod: 4
Describe the bug and the steps to reproduce it
An
.optional()tool field does not work with either OpenRouter text adapter when the model is an OpenAI model.1.
createOpenRouterText(Chat Completions): the model cannot omit an optional field.The adapter sends the tool schema as written and sets no
strict(function-tool.ts). OpenRouter serves OpenAI models through the upstream Responses API.debug: { echo_upstream_body: true }shows the upstream body withinputand flattools, still withoutstrict. OpenAI's function calling guide says: "If you omit strict … Responses requests will attempt to normalize your schema into strict mode … Chat Completions requests remain non-strict by default." So every optional field becomes required, and the model has to fill it. WithminItems: 1it makes up a value.Five runs each on
openai/gpt-5.5:stringswas present 5/5 withoutstrictand 0/5 withstrict: false. It is the same through Azure (provider: { only: ['azure'] }) and onopenai/gpt-4.1. With nostrict, the model also cannot send an enum value outside the list.anthropic/claude-sonnet-4.5on Bedrock omits the field either way, andgoogle/gemini-2.5-flashacceptsstrict: false.createOpenRouterTextsends the same schema aslive.mjswithoutstrict:2.
createOpenRouterResponsesText: the tool never runs when the model omits an optional field.The Responses adapter makes every tool strict. It widens optional fields to required + nullable, so the model sends
nullfor an omitted optional. The adapter emits thatnullunchanged inTOOL_CALL_END.input(1, 2, 3). The engine checks it against the original schema, and.optional()rejectsnull.@tanstack/openai-basefixed the same problem in #939 withcreateToolInputNormalizer, andai-mistraldid in #956. The OpenRouter adapters do not useopenai-base, so they did not get that fix.This repro needs no API key. The fetcher is stubbed:
Expected behavior
Both adapters follow the contract in
docs/tools/tools.md: "an omitted.optional()tool field is absent when your tool runs. A.nullable()field keepsnull."createOpenRouterTextsendsstrict: false. That matches the schema it sends as written, and it is the Chat Completions default.createOpenRouterResponsesTextremoves thenulls its strict conversion added before it emitsTOOL_CALL_END, the same wayopenai-basedoes.Your Minimal, Reproducible Example - (Sandbox Highly Recommended)
Inline above. The Responses repro needs no API key. The Chat Completions repro needs an OpenRouter key because the behavior comes from the upstream model.
Do you intend to try to help solve this bug with your own PR?
Yes, I am also opening a PR that solves the problem along side this issue
Terms & Code of Conduct