ai-openrouter: malformed Chat Completions tool arguments execute as an empty object
I maintainer di solito rispondono entro 1 giorno
Valutazione
Questa issue non è ancora stata valutata.
Descrizione
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.
- Lingua principale
- TypeScript
- Stelle
- 3.2k
- Fork
- 361
- Merge medio
- 1g 18h
- PR unite (30g)
- 211
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
-
summarize() sends temperature 0.3 to models that reject sampling parameters (e.g. claude-sonnet-5-5)Forse già presa @jherr l’ha presa oggi. Apertahas-pr waiting-on: maintainer
TanStack/ai#1703 · 1 commento · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
-
Text adapters fail chat()'s adapter constraint under exactOptionalPropertyTypes (inputModalities)Forse già presa @tombeckenham l’ha presa oggi. Apertawaiting-on: maintainer
TanStack/ai#1702 · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
-
StreamProcessor discards metadata from REASONING_MESSAGE_STARTForse già presa @tombeckenham l’ha presa 2 giorni fa. Apertawaiting-on: maintainer
TanStack/ai#1667 · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
-
Ollama chat adapter does not forward the request abort signal to the ollama SDKForse già presa @tombeckenham l’ha presa 3 giorni fa. Apertawaiting-on: maintainer
TanStack/ai#1644 · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
-
Async persistence restored before attach() never rejoins an in-flight runForse già presa @tombeckenham l’ha presa 4 giorni fa. Apertahas-pr waiting-on: maintainer
TanStack/ai#1639 · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
Issue simili
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
JoviDeCroock/pracht#432 ·
I maintainer di solito rispondono entro 1 giorno
-
Add: CNN en Espanol SDApertaapproved check:passed streams:add
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 75/100
I maintainer di solito rispondono entro 1 giorno
-
Hardware attribute name "app Connection Support" has inconsistent casingForse già presa Una pull request collegata a questa issue è aperta o già unita. Aperta
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 88/100
walletbeat/walletbeat#1628 ·
I maintainer di solito rispondono entro 1 giorno
-
bug go
Difficoltà 2/5 1-3 ore Idoneità per principianti 82/100
genkit-ai/genkit#6761 · 1 commento ·
I maintainer di solito rispondono entro 2 giorni
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
NousResearch/hermes-agent#136483 ·
I maintainer di solito rispondono entro 1 giorno