Skip to content
Open
7 changes: 7 additions & 0 deletions .changeset/openrouter-combined-tools-and-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/ai-openrouter': minor
---

Add native combined tools + `outputSchema` mode to both OpenRouter text adapters (chat-completions and Responses). When the resolved upstream model supports emitting a schema-constrained final answer alongside tool calls in a single pass, `chat({ outputSchema, tools, stream: true })` now wires the JSON Schema into the same streaming request as the tools and harvests the final-turn JSON, skipping the separate finalization round-trip.

Because OpenRouter is a routing layer, capability is keyed per resolved upstream model via the new `OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS` set, exported from `@tanstack/ai-openrouter/model-meta`, which both adapters consult from `supportsCombinedToolsAndSchema()`. The set is derived from each upstream provider's combined-mode gate (Anthropic 4.5+, Gemini 3.x, OpenAI's strict `json_schema` era, Grok 4.x) rather than the broader catalog `responseFormat` flag, so models that advertise structured output but predate native combined mode stay on the legacy finalization path.
4 changes: 4 additions & 0 deletions packages/ai-openrouter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
"types": "./dist/esm/index.d.ts",
"import": "./dist/esm/index.js"
},
"./model-meta": {
"types": "./dist/esm/model-meta.d.ts",
"import": "./dist/esm/model-meta.js"
},
"./tools": {
"types": "./dist/esm/tools/index.d.ts",
"import": "./dist/esm/tools/index.js"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import { describe, expect, it, vi } from 'vitest'
import {
OPENROUTER_CHAT_MODELS,
OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS,
} from '../model-meta'
import { createOpenRouterResponsesText } from './responses-text'
import { createOpenRouterText } from './text'
import type { Tool } from '@tanstack/ai'

// The adapter constructor instantiates `new OpenRouter(config)`. Mock the SDK
// so construction succeeds; these tests only exercise request building
// (`mapOptionsToRequest`) and the capability gate, never an SDK call.
vi.mock('@openrouter/sdk', () => ({
OpenRouter: class {
chat = { send: () => undefined }
beta = { responses: { send: () => undefined } }
},
}))

// JSON Schema as the engine hands it to the adapter on the combined path.
const outputSchema = {
type: 'object',
properties: { answer: { type: 'string' } },
required: ['answer'],
}

const tools: Array<Tool> = [
{ name: 'lookup_weather', description: 'Return the forecast for a location' },
]
Comment on lines +21 to +29

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Tracked file:"
git ls-files | rg 'packages/ai-openrouter/src/adapters/openrouter-combined-structured-output\.(test\.)?ts$' || true

echo
echo "Relevant test file excerpt:"
if [ -f packages/ai-openrouter/src/adapters/openrouter-combined-structured-output.test.ts ]; then
  wc -l packages/ai-openrouter/src/adapters/openrouter-combined-structured-output.test.ts
  sed -n '1,180p' packages/ai-openrouter/src/adapters/openrouter-combined-structured-output.test.ts | cat -n
fi

echo
echo "Search for toolDefinition imports/usages in ai-openrouter fixtures/tests:"
rg -n "toolDefinition|Tool\\<|outputSchema|structuredOutput" packages/ai-openrouter/src -g '*.ts' -g '*.tsx' || true

echo
echo "Find toolDefinition implementation:"
rg -n "function toolDefinition|const toolDefinition|toolDefinition\\(" packages -g "*.ts" -g "*.tsx" || true

Repository: TanStack/ai

Length of output: 27493


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "toolDefinition implementation excerpt:"
sed -n '240,390p' packages/ai/src/activities/chat/tools/tool-definition.ts | cat -n

echo
echo "Tool type excerpt:"
sed -n '520,620p' packages/ai/src/types.ts | cat -n

echo
echo "Search for openrouter tool fixtures using toolDefinition():180p packages/ai-openrouter/tests openrouter tests and fixtures:"
rg -n "toolDefinition\\(|\\.server\\(|\\.client\\(" packages/ai-openrouter -g '*.ts' -g '*.tsx' || true

echo
echo "Package config / dependency availability hints:"
for f in package.json packages/ai/package.json packages/ai-openrouter/package.json; do
  if [ -f "$f" ]; then echo "--- $f"; sed -n '1,220p' "$f" | rg -n '"name"|"dependencies"|"devDependencies"|"peerDependencies"|zod|typescript|packageManager|scripts|exports|types|main' || true; fi
done

Repository: TanStack/ai

Length of output: 13248


Use Zod and toolDefinition() for test fixtures.

Lines 21-29 use a raw schema and a handwritten Tool; define the schema with Zod and create the tool with toolDefinition(). If the tool uses toolDefinition(), implement both .server() and .client() sides for isomorphic execution.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/ai-openrouter/src/adapters/openrouter-combined-structured-output.test.ts`
around lines 21 - 29, Replace the raw outputSchema fixture with an equivalent
Zod schema, and replace the handwritten tools entry with a
toolDefinition()-based fixture. Configure both the .server() and .client() sides
of that tool so it supports isomorphic execution while preserving the existing
lookup_weather name, description, and behavior.

Source: Coding guidelines


// `mapOptionsToRequest` is protected; reach it directly to assert the wire
// shape without standing up a full streaming round-trip.
type BuiltOpenRouterRequest = Record<string, unknown> & {
model?: string
models?: Array<string>
responseFormat?: unknown
text?: Record<string, unknown> & {
format?: Record<string, unknown>
verbosity?: string
}
tools?: Array<unknown>
}

type RequestBuilder = {
mapOptionsToRequest: (options: Record<string, unknown>) => BuiltOpenRouterRequest
}

function asRequestBuilder(adapter: unknown): RequestBuilder {
return adapter as RequestBuilder
}

function buildChatRequest(
model: string,
modelOptions?: Record<string, unknown>,
) {
const adapter = asRequestBuilder(
createOpenRouterText(model as 'openai/gpt-4o', 'test-key'),
)
return adapter.mapOptionsToRequest({
model,
messages: [{ role: 'user', content: 'hi' }],
tools,
outputSchema,
...(modelOptions ? { modelOptions } : {}),
})
}

function buildResponsesRequest(model: string) {
const adapter = asRequestBuilder(
createOpenRouterResponsesText(model as 'openai/gpt-4o', 'test-key'),
)
return adapter.mapOptionsToRequest({
model,
messages: [{ role: 'user', content: 'hi' }],
tools,
outputSchema,
})
}

describe('OpenRouter combined tools + outputSchema (#612)', () => {
describe('supportsCombinedToolsAndSchema gate', () => {
it('returns true for combined-capable upstream models', () => {
expect(
createOpenRouterText(
'anthropic/claude-sonnet-4.5',
'k',
).supportsCombinedToolsAndSchema(),
).toBe(true)
expect(
createOpenRouterText(
'openai/gpt-4o',
'k',
).supportsCombinedToolsAndSchema(),
).toBe(true)
expect(
createOpenRouterText(
'x-ai/grok-4.3',
'k',
).supportsCombinedToolsAndSchema(),
).toBe(true)
})

it('returns false for upstream models the upstream gate excludes', () => {
// claude-opus-4.1 predates Anthropic combined mode (4.5+); gpt-4o-2024-05-13
// predates strict json_schema — both have `responseFormat` in the catalog
// but are deliberately excluded.
expect(
createOpenRouterText(
'anthropic/claude-opus-4.1',
'k',
).supportsCombinedToolsAndSchema(),
).toBe(false)
expect(
createOpenRouterText(
'openai/gpt-4o-2024-05-13',
'k',
).supportsCombinedToolsAndSchema(),
).toBe(false)
})

it('mirrors the gate on the Responses adapter', () => {
expect(
createOpenRouterResponsesText(
'openai/gpt-4o',
'k',
).supportsCombinedToolsAndSchema(),
).toBe(true)
expect(
createOpenRouterResponsesText(
'openai/gpt-4o-2024-05-13',
'k',
).supportsCombinedToolsAndSchema(),
).toBe(false)
})

it('requires every OpenRouter fallback model to support combined mode', () => {
const adapter = createOpenRouterText('openai/gpt-4o', 'k')

expect(
adapter.supportsCombinedToolsAndSchema({
models: ['anthropic/claude-sonnet-4.5'],
}),
).toBe(true)
expect(
adapter.supportsCombinedToolsAndSchema({
models: ['openai/gpt-4o-2024-05-13'],
}),
).toBe(false)
})
})

describe('chat-completions request payload', () => {
it('attaches responseFormat alongside tools on the combined path', () => {
const req = buildChatRequest('openai/gpt-4o')
expect(req.responseFormat).toEqual({
type: 'json_schema',
jsonSchema: {
name: 'structured_output',
schema: expect.any(Object),
strict: true,
},
})
expect(req.tools).toBeDefined()
expect(req.tools?.length).toBeGreaterThan(0)
})

it('omits responseFormat for an unsupported model (legacy finalization path)', () => {
const req = buildChatRequest('anthropic/claude-opus-4.1')
expect(req.responseFormat).toBeUndefined()
// tools still flow — only the schema attachment is gated.
expect(req.tools).toBeDefined()
})

it('omits responseFormat when any fallback model is unsupported', () => {
const req = buildChatRequest('openai/gpt-4o', {
models: ['openai/gpt-4o-2024-05-13'],
})
expect(req.responseFormat).toBeUndefined()
expect(req.models).toEqual(['openai/gpt-4o-2024-05-13'])
expect(req.tools).toBeDefined()
})

it('keys capability off the bare model id, ignoring the :variant suffix', () => {
const req = buildChatRequest('openai/gpt-4o', { variant: 'nitro' })
expect(req.responseFormat).toBeDefined()
// variant rides the model id, not the wire body.
expect(req.model).toBe('openai/gpt-4o:nitro')
})
})

describe('Responses request payload', () => {
it('attaches text.format alongside tools on the combined path', () => {
const req = buildResponsesRequest('openai/gpt-4o')
expect(req.text).toEqual({
format: {
type: 'json_schema',
name: 'structured_output',
schema: expect.any(Object),
strict: true,
},
})
expect(req.tools).toBeDefined()
})

it('omits text.format for an unsupported model', () => {
const req = buildResponsesRequest('openai/gpt-4o-2024-05-13')
expect(req.text).toBeUndefined()
})

it('omits text.format when any fallback model is unsupported', () => {
const adapter = asRequestBuilder(
createOpenRouterResponsesText('openai/gpt-4o', 'test-key'),
)
const req = adapter.mapOptionsToRequest({
model: 'openai/gpt-4o',
messages: [{ role: 'user', content: 'hi' }],
tools,
outputSchema,
modelOptions: { models: ['openai/gpt-4o-2024-05-13'] },
})
expect(req.text).toBeUndefined()
expect(req.models).toEqual(['openai/gpt-4o-2024-05-13'])
expect(req.tools).toBeDefined()
})

it('preserves caller-supplied text.* fields when attaching the schema format', () => {
const adapter = asRequestBuilder(
createOpenRouterResponsesText('openai/gpt-4o', 'test-key'),
)
const req = adapter.mapOptionsToRequest({
model: 'openai/gpt-4o',
messages: [{ role: 'user', content: 'hi' }],
tools,
outputSchema,
modelOptions: { text: { verbosity: 'low' } },
})
// `text.format` carries the combined-mode schema; the caller's
// `text.verbosity` rides alongside it rather than being clobbered.
expect(req.text?.verbosity).toBe('low')
expect(req.text?.format).toMatchObject({
type: 'json_schema',
name: 'structured_output',
strict: true,
})
})
})

describe('set integrity', () => {
it('every combined-mode id exists in the OpenRouter catalog', () => {
const catalog = new Set<string>(OPENROUTER_CHAT_MODELS)
for (const id of OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS) {
expect(catalog.has(id), `${id} is not in OPENROUTER_CHAT_MODELS`).toBe(
true,
)
}
})
})
})
48 changes: 48 additions & 0 deletions packages/ai-openrouter/src/adapters/responses-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from '@tanstack/ai/adapter-internals'
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 { isWebSearchTool } from '../tools/web-search-tool'
Expand All @@ -29,6 +30,7 @@ import type {
} from '@tanstack/ai/adapters'
import type {
ContentPart,
JSONSchema,
ModelMessage,
StreamChunk,
TextOptions,
Expand Down Expand Up @@ -1586,6 +1588,23 @@ export class OpenRouterResponsesTextAdapter<
)
: undefined

// Native combined mode (#612): the engine populates `options.outputSchema`
// on the `chatStream` call ONLY when this adapter declared
// `supportsCombinedToolsAndSchema()` for the model. When set, attach the
// schema via `text.format: json_schema` alongside `tools` so it rides the
// same streaming request and the engine harvests it from the final-turn
// text. The legacy `structuredOutput*` methods strip `outputSchema` before
// calling this, so the branch only fires on the combined path.
const combinedOutputSchema: JSONSchema | undefined = options.outputSchema
const combinedSchema =
combinedOutputSchema &&
this.supportsCombinedToolsAndSchema(options.modelOptions)
? this.makeStructuredOutputCompatible(
combinedOutputSchema,
combinedOutputSchema.required,
)
: undefined

const built: Pick<
ResponsesRequest,
| 'model'
Expand All @@ -1598,6 +1617,7 @@ export class OpenRouterResponsesTextAdapter<
| 'tools'
| 'toolChoice'
| 'parallelToolCalls'
| 'text'
> = {
...modelOptions,
model: options.model + variantSuffix,
Expand All @@ -1616,11 +1636,39 @@ export class OpenRouterResponsesTextAdapter<
tools.length > 0 && {
tools,
}),
...(combinedSchema && {
// Merge onto any caller-supplied `text` (spread above via
// `...modelOptions`) so sibling fields like `text.verbosity` survive;
// only `text.format` is overridden by the combined-mode schema.
text: {
...modelOptions.text,
format: {
type: 'json_schema' as const,
name: 'structured_output',
schema: combinedSchema,
strict: true,
},
},
}),
}

return built
}

/**
* Native combined tools + `outputSchema` (#612). OpenRouter routes to many
* upstream providers, so capability is per-request: `modelOptions.models`
* can add fallback routes, and native combined mode is safe only when every
* possible routed model supports it. `:variant` suffixes are routing
* directives and do not change combined-mode support. Models not in the set
* fall back to the legacy finalization path.
*/
supportsCombinedToolsAndSchema(
modelOptions?: OpenRouterResponsesTextProviderOptions,
): boolean {
return openRouterSupportsCombinedToolsAndSchema(this.model, modelOptions)
}

/**
* Convert a list of ModelMessage to OpenRouter's `InputsUnion` array form.
* Emits camelCase shapes (`callId`, `imageUrl`, `videoUrl`, `fileData`,
Expand Down
Loading