Skip to content

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

Description

@PatrM

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

has-prAn open PR references this issuewaiting-on: maintainerThe ball is in the maintainers’ court

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions