Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

ai-openrouter: malformed Chat Completions tool arguments execute as an empty object

Open
#1,689 0 comments 0 reactions 1 assignee View on GitHub

Maintainers usually reply within 1 day

@tombeckenham is already working on this.

Since Oct 9, 2026.

  • #1690 by @PatrM — open

Assessment

This issue has not been assessed yet.

Description

has-pr waiting-on: maintainer
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

  1. Define a tool with an optional field: inputSchema: z.object({ sql: z.string().optional() }).
  2. Call chat() with openRouterText('openai/gpt-4.1-mini'), the tool, toolChoice forcing it, and maxCompletionTokens: 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

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from TanStack/ai

All issues in TanStack/ai

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.