ai-openrouter: malformed Chat Completions tool arguments execute as an empty object
メンテナーはふだん 1 日以内に返信
評価
この issue はまだ評価されていません。
説明
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.
- 主要言語
- TypeScript
- スター
- 3.2k
- フォーク
- 361
- 平均マージ
- 1日 15時間
- マージ済み PR(30日)
- 210
環境構築
- Dockerfile・Docker Compose ファイルなし
- プルリクエストのテンプレートあり
- コントリビューションガイドを読む
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
TanStack/ai のほかの issue
-
summarize() sends temperature 0.3 to models that reject sampling parameters (e.g. claude-sonnet-5-5)対応中かも @jherr が 1 日前に担当しました。 オープンhas-pr waiting-on: maintainer
TanStack/ai#1703 · コメント 1 件 · 担当者 1 名 ·
メンテナーはふだん 1 日以内に返信
-
StreamProcessor discards metadata from REASONING_MESSAGE_START対応中かも @tombeckenham が 3 日前に担当しました。 オープンwaiting-on: maintainer
メンテナーはふだん 1 日以内に返信
-
Async persistence restored before attach() never rejoins an in-flight run対応中かも @tombeckenham が 5 日前に担当しました。 オープンhas-pr waiting-on: maintainer
メンテナーはふだん 1 日以内に返信
-
`ai-opencode`: `RUN_FINISHED` never arrives when an adapter teardown step does not settle対応中かも @tombeckenham が 5 日前に担当しました。 オープンwaiting-on: maintainer
TanStack/ai#1638 · コメント 1 件 · 担当者 1 名 ·
メンテナーはふだん 1 日以内に返信
-
ai-mcp: support publishing MCP list changes to subscribed clients対応中かも @jherr が 5 日前に担当しました。 オープンwaiting-on: maintainer
難易度 5/5 1週間以上 初心者へのやさしさ 28/100
メンテナーはふだん 1 日以内に返信
似ている issue
-
area:tui enhancement platform:macos
難易度 2/5 1〜3時間 初心者へのやさしさ 66/100
anthropics/claude-code#101490 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 66/100
netlify/netlify-mcp#75 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
atlassian-labs/compiled#1960 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 75/100
pnp/cli-microsoft365#7639 ·
メンテナーはふだん 2 日以内に返信
-
nextjs-tracking
難易度 2/5 1〜3時間 初心者へのやさしさ 74/100
cloudflare/vinext#3887 ·
メンテナーはふだん 1 日以内に返信