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

OpenRouter adapters: optional tool fields cannot be omitted (Chat Completions) or fail validation (Responses)

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

Maintainers usually reply within 1 day

@tombeckenham is already working on this.

Since Sep 28, 2026.

Assessment

This issue has not been assessed yet.

Description

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

  • createOpenRouterText sends strict: false. That matches the schema it sends as written, and it is the Chat Completions default.
  • createOpenRouterResponsesText removes the nulls its strict conversion added before it emits TOOL_CALL_END, the same way openai-base does.
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.
Dominant language
TypeScript
Stars
3.1k
Forks
340
Avg merge
2d 10h
Merged PRs (30d)
175

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.