Skip to content
5 changes: 5 additions & 0 deletions .changeset/openrouter-optional-tool-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-openrouter': patch
---

Keep optional tool fields optional. The Chat Completions adapter now sends function tools with `strict: false`. With `strict` omitted, OpenAI models through OpenRouter were observed to treat the schema as strict and make every optional field required, so before this fix the model could not leave one out. The Responses adapter now strips the `null` that strict mode puts in an omitted optional field before the tool input is validated, so the tool runs and sees the field as absent. A `.nullable()` field keeps its `null`. Known limit: an optional field inside a union of several object shapes is not stripped and still arrives as `null`.
26 changes: 20 additions & 6 deletions packages/ai-openrouter/src/adapters/responses-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import { generateId } from '@tanstack/ai-utils'
import { extractRequestOptions } from '../internal/request-options'
import { openRouterSupportsCombinedToolsAndSchema } from '../internal/combined-tools-and-schema'
import { makeStructuredOutputCompatible } from '../internal/schema-converter'
import { convertFunctionToolToResponsesFormat } from '../internal/responses-tool-converter'
import {
convertFunctionToolToResponsesFormat,
createToolInputNormalizer,
} from '../internal/responses-tool-converter'
import { isWebSearchTool } from '../tools/web-search-tool'
import { isWebFetchTool } from '../tools/web-fetch-tool'
import { getOpenRouterApiKeyFromEnv } from '../utils'
Expand Down Expand Up @@ -837,6 +840,10 @@ export class OpenRouterResponsesTextAdapter<
hasEmittedRunStarted: boolean
},
): AsyncIterable<AdapterYieldChunk> {
const normalizeToolInput = createToolInputNormalizer(
options.tools,
this.makeStructuredOutputCompatible.bind(this),
)
let accumulatedContent = ''
let accumulatedReasoning = ''

Expand Down Expand Up @@ -1325,7 +1332,10 @@ export class OpenRouterResponsesTextAdapter<
if (chunk.arguments) {
try {
const parsed = JSON.parse(chunk.arguments)
parsedInput = parsed && typeof parsed === 'object' ? parsed : {}
parsedInput = normalizeToolInput(
name,
parsed && typeof parsed === 'object' ? parsed : {},
)
} catch (parseError) {
options.logger.errors(
`${this.name}.processStreamChunks tool-args JSON parse failed`,
Expand Down Expand Up @@ -1400,8 +1410,10 @@ export class OpenRouterResponsesTextAdapter<
if (rawArgs) {
try {
const parsed = JSON.parse(rawArgs)
parsedInput =
parsed && typeof parsed === 'object' ? parsed : {}
parsedInput = normalizeToolInput(
name,
parsed && typeof parsed === 'object' ? parsed : {},
)
} catch (parseError) {
options.logger.errors(
`${this.name}.processStreamChunks tool-args JSON parse failed (output_item.done backfill)`,
Expand Down Expand Up @@ -1522,8 +1534,10 @@ export class OpenRouterResponsesTextAdapter<
if (rawArgs) {
try {
const parsed = JSON.parse(rawArgs)
parsedInput =
parsed && typeof parsed === 'object' ? parsed : {}
parsedInput = normalizeToolInput(
name,
parsed && typeof parsed === 'object' ? parsed : {},
)
} catch (parseError) {
options.logger.errors(
`${this.name}.processStreamChunks tool-args JSON parse failed (response.completed backfill)`,
Expand Down
87 changes: 87 additions & 0 deletions packages/ai-openrouter/src/internal/responses-tool-converter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { undoNullWidening } from '@tanstack/ai-utils'
import { makeStructuredOutputCompatible } from './schema-converter'
import type { JSONSchema, Tool } from '@tanstack/ai'
import type { NullWideningMap } from '@tanstack/ai-utils'

/**
* Responses API function tool format.
Expand Down Expand Up @@ -55,3 +57,88 @@ export function convertFunctionToolToResponsesFormat(
strict: true,
}
}

function allowsNull(schema: JSONSchema | undefined): boolean {
if (!schema || typeof schema !== 'object') return false
if (schema.type === 'null') return true
if (Array.isArray(schema.type) && schema.type.includes('null')) return true
if (Array.isArray(schema.enum) && schema.enum.includes(null)) return true
return Array.isArray(schema.anyOf) && schema.anyOf.some(allowsNull)
}

/**
* Diff the original tool schema against the strict wire schema and mark every
* position where the converter added `null`. Diffing the actual wire schema
* keeps the map aligned with a subclass-supplied `schemaConverter`. A field
* that already allowed `null` (`.nullable()`, `.nullish()`) is not marked, so
* its `null` survives.
*/
function diffNullWidening(
original: JSONSchema | undefined,
wire: JSONSchema | undefined,
): NullWideningMap | undefined {
if (!original || !wire || typeof original !== 'object') return undefined
const map: NullWideningMap = {}
if (allowsNull(wire) && !allowsNull(original)) map.widened = true

if (original.properties && wire.properties) {
const properties: Record<string, NullWideningMap> = {}
for (const key of Object.keys(wire.properties)) {
const child = diffNullWidening(
original.properties[key],
wire.properties[key],
)
if (child) properties[key] = child
}
if (Object.keys(properties).length > 0) map.properties = properties
}

if (
original.items &&
wire.items &&
!Array.isArray(original.items) &&
!Array.isArray(wire.items)
) {
const items = diffNullWidening(original.items, wire.items)
if (items) map.items = items
}

// A `.nullable()` object reaches the wire as `anyOf: [object, null]`, and
// the converter widens inside the object variant. Descend into that one
// variant. A union of several shapes is ambiguous, so it is left alone.
const nonNullVariants = (schema: JSONSchema) =>
(schema.anyOf ?? []).filter((variant) => variant.type !== 'null')
const originalVariants = nonNullVariants(original)
const wireVariants = nonNullVariants(wire)
if (originalVariants.length === 1 && wireVariants.length === 1) {
const inner = diffNullWidening(originalVariants[0], wireVariants[0])
if (inner?.properties) map.properties = inner.properties
if (inner?.items) map.items = inner.items
}

return Object.keys(map).length > 0 ? map : undefined
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Build the inverse of the strict null-widening applied to the tools of one
* request. Strict tools reach the model with every optional field promoted to
* required + nullable, so the model sends `null` for an omitted optional. The
* returned function strips exactly those synthesized nulls, so the engine
* validates the input against the original schema and `execute` sees the
* field as absent. Pass the same converter the request used.
*/
export function createToolInputNormalizer(
tools: Array<Tool> | undefined,
schemaConverter?: Parameters<typeof convertFunctionToolToResponsesFormat>[1],
): (toolName: string, input: unknown) => unknown {
const maps = new Map<string, NullWideningMap>()
for (const tool of tools ?? []) {
const map = diffNullWidening(
tool.inputSchema,
convertFunctionToolToResponsesFormat(tool, schemaConverter).parameters ??
undefined,
)
if (map) maps.set(tool.name, map)
}
return (toolName, input) => undoNullWidening(input, maps.get(toolName))
}
5 changes: 5 additions & 0 deletions packages/ai-openrouter/src/tools/function-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface FunctionTool {
name: string
description?: string
parameters: Record<string, unknown>
strict?: boolean
}
/**
* Anthropic-style prompt-cache breakpoint for the tool definition.
Expand Down Expand Up @@ -51,6 +52,10 @@ export function convertFunctionToolToAdapterFormat(tool: Tool): FunctionTool {
name: tool.name,
description: tool.description,
parameters: inputSchema,
// Sent explicitly for every model. Observed behaviour (#1542): with
// `strict` omitted, OpenAI models through OpenRouter treat the schema as
// strict, so every optional field becomes required.
strict: false,
},
// Only present when supplied — additive and non-breaking.
...(cacheControl ? { cacheControl } : {}),
Expand Down
41 changes: 41 additions & 0 deletions packages/ai-openrouter/tests/openrouter-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,47 @@ describe('OpenRouter adapter option mapping', () => {
expect(serialized).toHaveProperty('tool_choice', 'auto')
})

it('sends function tools with strict: false and the schema as authored', async () => {
setupMockSdkClient([
{
id: 'chatcmpl-strict',
model: 'openai/gpt-4o-mini',
choices: [{ delta: { content: 'ok' }, finishReason: 'stop' }],
usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
},
])
const inputSchema = {
type: 'object',
properties: {
guitar: { type: 'string' },
strings: { type: 'array', items: { type: 'string' }, minItems: 1 },
},
required: ['guitar'],
}

for await (const _ of chat({
adapter: createAdapter(),
messages: [{ role: 'user', content: 'Hello' }],
tools: [
{ name: 'recommend_guitar', description: 'Recommend', inputSchema },
],
})) {
// drain
}

const [rawParams] = mockSend.mock.calls[0]!
const serialized = ChatRequest$outboundSchema.parse(rawParams.chatRequest)
expect(serialized.tools?.[0]).toEqual({
type: 'function',
function: {
name: 'recommend_guitar',
description: 'Recommend',
parameters: inputSchema,
strict: false,
},
})
})

it('prepends mixed string + object-form systemPrompts as a role:system message and drops foreign metadata', async () => {
const streamChunks = [
{
Expand Down
131 changes: 131 additions & 0 deletions packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1123,6 +1123,137 @@ describe('OpenRouter responses adapter — stream event bridge', () => {
expect(finished.metadata?.tanstack?.finishReason).toBe('tool_calls')
})

// Strict tools reach the model with optionals widened to required +
// nullable, so an omitted optional comes back as `null`. Only those nulls are
// stripped; a genuine `.nullable()` null stays. Each row delivers the
// arguments through a different TOOL_CALL_END emit path.
const widenedArguments =
'{"guitar":"Martin D-28","color":null,"strings":{"gauges":["10","46"],"brand":null},"note":null,"case":null,"pickup":{"store":"Berlin","date":null}}'
const widenedCall = {
type: 'function_call',
id: 'item_1',
callId: 'call_abc',
name: 'recommend_guitar',
}
const widenedCallAdded = {
type: 'response.output_item.added',
sequenceNumber: 1,
outputIndex: 0,
item: { ...widenedCall, arguments: '' },
}
it.each([
{
path: 'function_call_arguments.done',
events: [
widenedCallAdded,
{
type: 'response.function_call_arguments.done',
sequenceNumber: 2,
itemId: 'item_1',
outputIndex: 0,
arguments: widenedArguments,
},
],
completedOutput: [{ type: 'function_call' }],
},
{
path: 'output_item.done backfill',
events: [
widenedCallAdded,
{
type: 'response.output_item.done',
sequenceNumber: 2,
outputIndex: 0,
item: { ...widenedCall, arguments: widenedArguments },
},
],
completedOutput: [],
},
{
path: 'response.completed backfill',
events: [],
completedOutput: [{ ...widenedCall, arguments: widenedArguments }],
},
])(
'undoes strict null-widening on the $path path',
async ({ events, completedOutput }) => {
const strictTool: Tool = {
name: 'recommend_guitar',
description: 'Recommend a guitar',
inputSchema: {
type: 'object',
properties: {
guitar: { type: 'string' },
color: { type: 'string' },
strings: {
type: 'object',
properties: {
gauges: { type: 'array', items: { type: 'string' } },
brand: { type: 'string' },
},
required: ['gauges'],
},
note: { type: ['string', 'null'] },
case: { anyOf: [{ type: 'string' }, { type: 'null' }] },
pickup: {
anyOf: [
{
type: 'object',
properties: {
store: { type: 'string' },
date: { type: 'string' },
},
required: ['store'],
},
{ type: 'null' },
],
},
},
required: ['guitar', 'note', 'pickup'],
},
}
setupMockSdkClient([
{
type: 'response.created',
sequenceNumber: 0,
response: { model: 'm', output: [] },
},
...events,
{
type: 'response.completed',
sequenceNumber: 3,
response: {
model: 'm',
output: completedOutput,
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
},
},
])

const chunks: Array<AdapterYieldChunk> = []
for await (const c of createAdapter().chatStream({
logger: testLogger,
model: 'openai/gpt-4o-mini',
messages: [{ role: 'user', content: 'Hello' }],
tools: [strictTool],
})) {
chunks.push(c)
}

const end = chunks.find((c) => c.type === 'TOOL_CALL_END')
if (end?.type !== 'TOOL_CALL_END') {
throw new Error('expected TOOL_CALL_END')
}
expect(end.input).toEqual({
guitar: 'Martin D-28',
strings: { gauges: ['10', '46'] },
note: null,
case: null,
pickup: { store: 'Berlin' },
})
},
)

it('preserves a streamed function-call name when later items omit it', async () => {
setupMockSdkClient([
{
Expand Down
Loading
Loading