OpenRouter adapters: optional tool fields cannot be omitted (Chat Completions) or fail validation (Responses)
I maintainer di solito rispondono entro 1 giorno
@tombeckenham ci sta già lavorando.
Dal 28/9/2026.
Valutazione
Questa issue non è ancora stata valutata.
Descrizione
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.
- Lingua principale
- TypeScript
- Stelle
- 3.1k
- Fork
- 340
- Merge medio
- 2g 10h
- PR unite (30g)
- 169
Preparare l'ambiente
- Nessun Dockerfile né file Docker Compose
- Ha un modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di TanStack/ai
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 4/5 3-5 giorni Idoneità per principianti 38/100
I maintainer di solito rispondono entro 1 giorno
-
withPersistence re-appends stored tool results on an interrupt resume (snapshot ids never match stored id-less tool messages)Forse già presa @tombeckenham l’ha presa oggi. Apertawaiting-on: maintainer
TanStack/ai#1580 · 1 commento · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
-
signin with openaiApertahas-pr waiting-on: maintainer
Difficoltà 5/5 Più di una settimana Idoneità per principianti 45/100
TanStack/ai#1572 · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
-
update elevenlabsForse già presa @tombeckenham l’ha presa 2 giorni fa. Apertahas-pr waiting-on: maintainer
Difficoltà 4/5 3-5 giorni Idoneità per principianti 25/100
TanStack/ai#1564 · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
Issue simili
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 88/100
callstackincubator/rozenite#518 ·
I maintainer di solito rispondono entro 1 giorno
-
Area/Workflow Priority/Blocker Type/Bug
Difficoltà 2/5 1-3 ore Idoneità per principianti 76/100
wso2/product-integrator#2622 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 68/100
-
area:bash bug has repro platform:macos
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
anthropics/claude-code#98644 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
allure-framework/allure-js#1603 ·
I maintainer di solito rispondono entro 1 giorno