ai-openrouter: malformed Chat Completions tool arguments execute as an empty object
Maintainers usually reply within 1 day
Assessment
This issue has not been assessed yet.
Description
TanStack AI version
@tanstack/ai: 0.66.0@tanstack/ai-openrouter: 0.21.2
Also seen on 0.64.1 / 0.20.3. The adapter code is unchanged on main at 377262c0b4e5f59f8fd467a831b9341cc705077e.
Framework/Library version
No UI framework. Node.js 24.18.0, Zod 4.6.5.
Describe the bug and the steps to reproduce it
openRouterText can execute a tool after its streamed arguments fail JSON parsing. On a parse failure the adapter sets TOOL_CALL_END.input to {}, which replaces the original arguments before the core parses them.
#1602 fixed the same fallback in @tanstack/openai-base (#1601). ai-openrouter keeps its own copy of the Chat Completions stream handling, so the fix did not carry over.
OpenRouter produces this input in normal operation. When a model hits its output limit inside a tool call, OpenRouter ends the stream with finish_reason: "tool_calls" (native_finish_reason: "max_output_tokens"), then sends the usage chunk and [DONE]. The arguments are cut off, for example {"sql":"SELECT g.brand, g.model, SUM(s.quantity) AS units.
Steps to reproduce
- Define a tool with an optional field:
inputSchema: z.object({ sql: z.string().optional() }). - Call
chat()withopenRouterText('openai/gpt-4.1-mini'), the tool,toolChoiceforcing it, andmaxCompletionTokens: 12.
A controlled SSE response that sends {"sql": "SELECT 1" with finish_reason: "tool_calls" reproduces it without a provider.
Actual behavior
The handler runs once with {}. The next model request contains a successful tool result. With a required field, the model gets Invalid input: expected string, received undefined instead of a JSON parse error, and the raw arguments are lost.
Expected behavior
Return a tool-result error for malformed JSON without calling the tool, as docs/tools/server-tools.md describes and as openai-base does since #1602.
Cause
Both the finish_reason path and the end-of-body drain set parsedInput = {} after a parse failure. completeToolCall then serializes that object over the original argument string, so the core's malformed-argument check never sees the invalid JSON.
Your Minimal, Reproducible Example - (Sandbox Highly Recommended)
Self-contained script, no API key. A local SSE server replays the OpenRouter stream described above.
npm init -y && npm pkg set type=module && npm i @tanstack/[email protected] @tanstack/[email protected] [email protected]
node index.mjs
Output:
TOOL_CALL_END input: {}
tool executed with: [{}]
tool result sent to the model: tool ran
index.mjs
// openRouterText runs a tool with {} when its streamed arguments are not valid JSON.
// OpenRouter sends this when a model hits its output limit inside a tool call:
// truncated arguments with finish_reason "tool_calls" (native_finish_reason "max_output_tokens").
// A local SSE server replays that stream, so no API key is needed.
import http from 'node:http'
import { chat, toolDefinition } from '@tanstack/ai'
import { createOpenRouterText } from '@tanstack/ai-openrouter'
import { z } from 'zod'
const chunk = (choice, extra = {}) =>
`data: ${JSON.stringify({
id: 'gen-1',
object: 'chat.completion.chunk',
created: 1,
model: 'openai/gpt-4.1-mini',
choices: [{ index: 0, delta: {}, finish_reason: null, ...choice }],
...extra,
})}\n\n`
const truncatedToolCall = [
chunk({ delta: { role: 'assistant', content: '' } }),
chunk({
delta: {
tool_calls: [
{
index: 0,
id: 'call_1',
type: 'function',
function: { name: 'run_query', arguments: '{"sql":"SELECT brand, model, SUM(units' },
},
],
},
}),
chunk({ finish_reason: 'tool_calls', native_finish_reason: 'max_output_tokens' }),
chunk(
{ finish_reason: 'tool_calls', native_finish_reason: 'max_output_tokens' },
{ usage: { prompt_tokens: 10, completion_tokens: 12, total_tokens: 22 } },
),
'data: [DONE]\n\n',
]
const followUp = [
chunk({ delta: { role: 'assistant', content: 'Done.' } }),
chunk({ finish_reason: 'stop' }, { usage: { prompt_tokens: 20, completion_tokens: 2, total_tokens: 22 } }),
'data: [DONE]\n\n',
]
const providerRequests = []
const server = http.createServer((req, res) => {
let body = ''
req.on('data', (d) => (body += d))
req.on('end', () => {
providerRequests.push(JSON.parse(body))
res.writeHead(200, { 'content-type': 'text/event-stream' })
for (const c of providerRequests.length === 1 ? truncatedToolCall : followUp) res.write(c)
res.end()
})
})
await new Promise((resolve) => server.listen(0, resolve))
const executedInputs = []
const runQuery = toolDefinition({
name: 'run_query',
description: 'Run a SQL query',
inputSchema: z.object({ sql: z.string().optional() }),
}).server((input) => {
executedInputs.push(input)
return 'tool ran'
})
const adapter = createOpenRouterText('openai/gpt-4.1-mini', 'sk-dummy', {
serverURL: `http://127.0.0.1:${server.address().port}/api/v1`,
})
for await (const event of chat({
adapter,
tools: [runQuery],
messages: [{ role: 'user', content: 'Top 20 guitars by revenue in 2025?' }],
})) {
if (event.type === 'TOOL_CALL_END') console.log('TOOL_CALL_END input:', JSON.stringify(event.input))
}
server.close()
const toolMessage = providerRequests[1]?.messages.find((m) => m.role === 'tool')
console.log('tool executed with:', JSON.stringify(executedInputs))
console.log('tool result sent to the model:', toolMessage?.content)
console.log(
executedInputs.length > 0
? '\nBUG: the tool ran with {} although its arguments were malformed JSON.'
: '\nOK: the tool did not run; the model got a tool error.',
)
Screenshots or Videos (Optional)
n/a
Do you intend to try to help solve this bug with your own PR?
Yes, I think I know how to fix it and will discuss it in the comments of 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.2k
- Forks
- 361
- Avg merge
- 1d 18h
- Merged PRs (30d)
- 211
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
-
summarize() sends temperature 0.3 to models that reject sampling parameters (e.g. claude-sonnet-5-5)Possibly taken @jherr claimed this today. Openhas-pr waiting-on: maintainer
TanStack/ai#1703 · 1 comment · 1 assignee ·
Maintainers usually reply within 1 day
-
Text adapters fail chat()'s adapter constraint under exactOptionalPropertyTypes (inputModalities)Possibly taken @tombeckenham claimed this today. Openwaiting-on: maintainer
TanStack/ai#1702 · 1 assignee ·
Maintainers usually reply within 1 day
-
StreamProcessor discards metadata from REASONING_MESSAGE_STARTPossibly taken @tombeckenham claimed this 1 day ago. Openwaiting-on: maintainer
TanStack/ai#1667 · 1 assignee ·
Maintainers usually reply within 1 day
-
Ollama chat adapter does not forward the request abort signal to the ollama SDKPossibly taken @tombeckenham claimed this 3 days ago. Openwaiting-on: maintainer
TanStack/ai#1644 · 1 assignee ·
Maintainers usually reply within 1 day
-
Async persistence restored before attach() never rejoins an in-flight runPossibly taken @tombeckenham claimed this 4 days ago. Openhas-pr waiting-on: maintainer
TanStack/ai#1639 · 1 assignee ·
Maintainers usually reply within 1 day
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
NousResearch/hermes-agent#136483 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 86/100
Maintainers usually reply within 1 day
-
factory-active factory-automatic task-bug-reproduction-success task-identify-harness-labels-done task-identify-issue-type-done
Difficulty 2/5 1-3 hours Newbie friendliness 62/100
vercel/ai#22796 · 2 comments ·
Maintainers usually reply within 1 day
-
[Bug]: Web chat input doesn't regain focus after a reply finishesPossibly taken @GaijinSystems claimed this today. Open
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
zeroclaw-labs/zeroclaw#11658 ·
Maintainers usually reply within 2 days
-
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
babylonlabs-io/babylon-toolkit#2711 ·
Maintainers usually reply within 1 day