OpenRouter adapters: optional tool fields cannot be omitted (Chat Completions) or fail validation (Responses)
Maintainers usually reply within 1 day
@tombeckenham is already working on this.
Since Sep 28, 2026.
Assessment
This issue has not been assessed yet.
Description
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."
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
- I agree to follow this project's Code of Conduct
- I understand that if my bug cannot be reliable reproduced in a debuggable environment, it will probably not be fixed and this issue may even be closed.
- Dominant language
- TypeScript
- Stars
- 3.1k
- Forks
- 340
- Avg merge
- 2d 10h
- Merged PRs (30d)
- 175
Getting set up
- No Dockerfile or Docker Compose file
- Has a pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from TanStack/ai
-
update elevenlabsPossibly taken @tombeckenham claimed this 1 day ago. Open
Difficulty 4/5 3-5 days Newbie friendliness 25/100
Maintainers usually reply within 1 day
-
onAfterToolCall failure records a second, contradictory result for a successful server toolPossibly taken @AlemTuzlak claimed this 1 day ago. Openhas-pr waiting-on: maintainer
TanStack/ai#1558 · 1 assignee ·
Maintainers usually reply within 1 day
-
SSE and NDJSON response streams drain unread sources without backpressurePossibly taken @tombeckenham claimed this 1 day ago. Openhas-pr waiting-on: maintainer
TanStack/ai#1556 · 1 assignee ·
Maintainers usually reply within 1 day
-
Solid useChat drops earlier turns after a reactive request option changesPossibly taken @AlemTuzlak claimed this 1 day ago. Openhas-pr waiting-on: maintainer
TanStack/ai#1552 · 1 assignee ·
Maintainers usually reply within 1 day
-
Tool calls from one model step run one at a time, but the docs say they run in parallelPossibly taken @tombeckenham claimed this 1 day ago. Openwaiting-on: maintainer
TanStack/ai#1547 · 1 reaction · 1 assignee ·
Maintainers usually reply within 1 day
Similar issues
-
needs:triage
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
Maintainers usually reply within 1 day
-
ai-discovered
Difficulty 2/5 1-3 hours Newbie friendliness 83/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
jessepollak/home#1627 ·
Maintainers usually reply within 1 day
-
agent-canvas bug llm priority:low ready-for-dev
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
OpenHands/OpenHands#17806 · 3 comments ·
Maintainers usually reply within 1 day
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
radius-project/ai-extensions#923 ·
Maintainers usually reply within 1 day