From 9865bd6c7f61662061071b65d18a4cc2f0328747 Mon Sep 17 00:00:00 2001 From: Season Date: Thu, 25 Jun 2026 06:40:42 +0800 Subject: [PATCH 1/8] feat(ai-openrouter): per-request native combined tools + outputSchema mode Give both OpenRouter text adapters (chat-completions and Responses) native combined mode. When chat({ outputSchema, tools, stream: true }) targets a combined-capable upstream model, the schema is wired into the same streaming request as the tools and the final-turn JSON is harvested directly, skipping the separate finalization round-trip. Capability is per resolved upstream model via the new exported OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS set, consulted by both adapters' supportsCombinedToolsAndSchema(). Membership tracks the upstream per-provider combined-mode gates (Anthropic 4.5+ mirrors ANTHROPIC_COMBINED_TOOLS_AND_SCHEMA_MODELS, Gemini 3.x, OpenAI strict json_schema era, Grok 4.x) rather than the broader catalog responseFormat flag. Closes #612. --- .../openrouter-combined-tools-and-schema.md | 7 + .../src/adapters/responses-text.ts | 46 ++++ packages/ai-openrouter/src/adapters/text.ts | 42 ++++ packages/ai-openrouter/src/index.ts | 1 + packages/ai-openrouter/src/model-meta.ts | 97 +++++++++ ...nrouter-combined-structured-output.test.ts | 200 ++++++++++++++++++ testing/e2e/src/lib/feature-support.ts | 6 + 7 files changed, 399 insertions(+) create mode 100644 .changeset/openrouter-combined-tools-and-schema.md create mode 100644 packages/ai-openrouter/tests/openrouter-combined-structured-output.test.ts diff --git a/.changeset/openrouter-combined-tools-and-schema.md b/.changeset/openrouter-combined-tools-and-schema.md new file mode 100644 index 0000000000..940ca1e88a --- /dev/null +++ b/.changeset/openrouter-combined-tools-and-schema.md @@ -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 exported `OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS` set, 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. diff --git a/packages/ai-openrouter/src/adapters/responses-text.ts b/packages/ai-openrouter/src/adapters/responses-text.ts index 842530bc27..afa9392718 100644 --- a/packages/ai-openrouter/src/adapters/responses-text.ts +++ b/packages/ai-openrouter/src/adapters/responses-text.ts @@ -12,6 +12,7 @@ import { convertFunctionToolToResponsesFormat } from '../internal/responses-tool import { isWebSearchTool } from '../tools/web-search-tool' import { isWebFetchTool } from '../tools/web-fetch-tool' import { getOpenRouterApiKeyFromEnv } from '../utils' +import { OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS } from '../model-meta' import { extractUsageCost } from './cost' import type { SDKOptions } from '@openrouter/sdk' import type { ResponsesFunctionTool } from '../internal/responses-tool-converter' @@ -1586,6 +1587,24 @@ 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 = options.outputSchema as + | (Record & { required?: Array }) + | undefined + const combinedSchema = + combinedOutputSchema && this.supportsCombinedToolsAndSchema() + ? this.makeStructuredOutputCompatible( + combinedOutputSchema, + combinedOutputSchema.required, + ) + : undefined + const built: Pick< ResponsesRequest, | 'model' @@ -1598,6 +1617,7 @@ export class OpenRouterResponsesTextAdapter< | 'tools' | 'toolChoice' | 'parallelToolCalls' + | 'text' > = { ...modelOptions, model: options.model + variantSuffix, @@ -1616,11 +1636,37 @@ 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-model: `this.model` is the bare + * canonical catalog id (the `:variant` suffix is a routing directive applied + * at request-build time and does not change combined-mode support), so the + * lookup ignores `modelOptions` and keys directly off `this.model`. Models + * not in the set fall back to the legacy finalization path. + */ + supportsCombinedToolsAndSchema(): boolean { + return OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS.has(this.model) + } + /** * Convert a list of ModelMessage to OpenRouter's `InputsUnion` array form. * Emits camelCase shapes (`callId`, `imageUrl`, `videoUrl`, `fileData`, diff --git a/packages/ai-openrouter/src/adapters/text.ts b/packages/ai-openrouter/src/adapters/text.ts index 09df05b351..4dcef658b8 100644 --- a/packages/ai-openrouter/src/adapters/text.ts +++ b/packages/ai-openrouter/src/adapters/text.ts @@ -11,6 +11,7 @@ import { makeStructuredOutputCompatible } from '../internal/schema-converter' import { convertToolsToProviderFormat } from '../tools' import { getOpenRouterApiKeyFromEnv } from '../utils' import { buildOpenRouterUsage } from '../usage' +import { OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS } from '../model-meta' import { extractUsageCost } from './cost' import type { SDKOptions } from '@openrouter/sdk' import type { @@ -1184,6 +1185,25 @@ export class OpenRouterTextAdapter< ? convertToolsToProviderFormat(options.tools) : undefined + // Native combined mode (#612): the engine populates `options.outputSchema` + // on the `chatStream` call ONLY when the adapter declared + // `supportsCombinedToolsAndSchema()` for this model. When set, attach + // `responseFormat: json_schema` alongside `tools` so the schema-constrained + // JSON rides the same streaming request and the engine harvests it from the + // final-turn text — no separate finalization round-trip. The legacy + // `structuredOutput*` methods strip `outputSchema` before calling this, so + // this branch only fires on the combined path. + const combinedOutputSchema = options.outputSchema as + | (Record & { required?: Array }) + | undefined + const combinedSchema = + combinedOutputSchema && this.supportsCombinedToolsAndSchema() + ? this.makeStructuredOutputCompatible( + combinedOutputSchema, + combinedOutputSchema.required, + ) + : undefined + // `modelOptions` is the sole wire surface: callers set provider-native // names (`temperature`, `topP`, `maxCompletionTokens`, `metadata`, etc.) // there and they flow through the spread below. Root `metadata` is @@ -1195,10 +1215,32 @@ export class OpenRouterTextAdapter< model: options.model + variantSuffix, messages, ...(tools && tools.length > 0 && { tools }), + ...(combinedSchema && { + responseFormat: { + type: 'json_schema' as const, + jsonSchema: { + name: 'structured_output', + schema: combinedSchema, + strict: true, + }, + }, + }), } return request } + /** + * Native combined tools + `outputSchema` (#612). OpenRouter routes to many + * upstream providers, so capability is per-model: `this.model` is the bare + * canonical catalog id (the `:variant` suffix is a routing directive applied + * at request-build time and does not change combined-mode support), so the + * lookup ignores `modelOptions` and keys directly off `this.model`. Models + * not in the set fall back to the legacy finalization path. + */ + supportsCombinedToolsAndSchema(): boolean { + return OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS.has(this.model) + } + /** * Convert a ModelMessage to OpenRouter's ChatMessages discriminated union * (camelCase: `toolCallId`, `toolCalls`). diff --git a/packages/ai-openrouter/src/index.ts b/packages/ai-openrouter/src/index.ts index 63ceffaed1..0098fb7bce 100644 --- a/packages/ai-openrouter/src/index.ts +++ b/packages/ai-openrouter/src/index.ts @@ -50,6 +50,7 @@ export type { OpenRouterModelInputModalitiesByName, OpenRouterChatModelToolCapabilitiesByName, } from './model-meta' +export { OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS } from './model-meta' export type { OpenRouterTextMetadata, OpenRouterImageMetadata, diff --git a/packages/ai-openrouter/src/model-meta.ts b/packages/ai-openrouter/src/model-meta.ts index fe83403acd..175edf8d20 100644 --- a/packages/ai-openrouter/src/model-meta.ts +++ b/packages/ai-openrouter/src/model-meta.ts @@ -15897,3 +15897,100 @@ export const OPENROUTER_IMAGE_MODELS = [ OPENAI_GPT_5_IMAGE_MINI.id, OPENAI_GPT_5_4_IMAGE_2.id, ] as const + +/** + * OpenRouter catalog ids whose resolved upstream model natively supports + * strict `json_schema` output **together with** `tools` in a single streaming + * request — "combined mode" (issue #612, extends #605). When `chat({ + * outputSchema, tools, stream: true })` targets one of these, the engine wires + * the schema into the regular `chatStream` request alongside `tools` and + * harvests the schema-constrained JSON from the agent loop's final-turn text, + * skipping the separate finalization round-trip. Ids **not** listed here take + * the proven legacy finalization path. + * + * Membership mirrors the per-provider upstream gates that #605 maintains — + * NOT the catalog's `responseFormat` support flag, which is too permissive + * (it is also `true` for `claude-opus-4.1`, every `gemini-2.5*`, and `gpt-3.5*`, + * all of which the upstream native adapters exclude from combined mode): + * - Anthropic: the Claude 4.5+ ids in the upstream + * `ANTHROPIC_COMBINED_TOOLS_AND_SCHEMA_MODELS` gate (opus/sonnet/haiku); + * newer ids (e.g. 4.8) land here only once that gate adds them + * - Google: Gemini 3.x only + * - OpenAI: strict-`json_schema` era (gpt-4o-2024-08-06 and later), gpt-4.1, + * gpt-5*, o-series, and gpt-oss-* — tool-capable text variants only + * - x.ai: Grok 4.x (tool-capable; excludes the multi-agent variant) + * + * Every entry must also exist in {@link OPENROUTER_CHAT_MODELS} (guarded by a + * unit test) and carry both `responseFormat` and `toolChoice` capability. + */ +export const OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS = new Set([ + // Anthropic — the Claude 4.5+ ids the upstream gate currently blesses. + // Mirrors ANTHROPIC_COMBINED_TOOLS_AND_SCHEMA_MODELS exactly (it stops at + // 4.7), so OpenRouter-routed Claude 4.8 takes the same legacy path as the + // native @tanstack/ai-anthropic adapter until upstream adds it. + 'anthropic/claude-haiku-4.5', + 'anthropic/claude-opus-4.5', + 'anthropic/claude-opus-4.6', + 'anthropic/claude-opus-4.6-fast', + 'anthropic/claude-opus-4.7', + 'anthropic/claude-opus-4.7-fast', + 'anthropic/claude-sonnet-4.5', + 'anthropic/claude-sonnet-4.6', + + // Google — Gemini 3.x family + 'google/gemini-3-flash-preview', + 'google/gemini-3.1-flash-lite', + 'google/gemini-3.1-flash-lite-preview', + 'google/gemini-3.1-pro-preview', + 'google/gemini-3.1-pro-preview-customtools', + 'google/gemini-3.5-flash', + + // OpenAI — strict-json_schema era, tool-capable text models + 'openai/gpt-4o', + 'openai/gpt-4o-2024-08-06', + 'openai/gpt-4o-2024-11-20', + 'openai/gpt-4o-mini', + 'openai/gpt-4o-mini-2024-07-18', + 'openai/gpt-4.1', + 'openai/gpt-4.1-mini', + 'openai/gpt-4.1-nano', + 'openai/gpt-5', + 'openai/gpt-5-codex', + 'openai/gpt-5-mini', + 'openai/gpt-5-nano', + 'openai/gpt-5-pro', + 'openai/gpt-5.1', + 'openai/gpt-5.1-chat', + 'openai/gpt-5.1-codex', + 'openai/gpt-5.1-codex-max', + 'openai/gpt-5.1-codex-mini', + 'openai/gpt-5.2', + 'openai/gpt-5.2-chat', + 'openai/gpt-5.2-codex', + 'openai/gpt-5.2-pro', + 'openai/gpt-5.3-chat', + 'openai/gpt-5.3-codex', + 'openai/gpt-5.4', + 'openai/gpt-5.4-mini', + 'openai/gpt-5.4-nano', + 'openai/gpt-5.4-pro', + 'openai/gpt-5.5', + 'openai/gpt-5.5-pro', + 'openai/gpt-chat-latest', + 'openai/o1', + 'openai/o3', + 'openai/o3-mini', + 'openai/o3-mini-high', + 'openai/o3-pro', + 'openai/o3-deep-research', + 'openai/o4-mini', + 'openai/o4-mini-high', + 'openai/o4-mini-deep-research', + 'openai/gpt-oss-120b', + 'openai/gpt-oss-20b', + 'openai/gpt-oss-safeguard-20b', + + // x.ai — Grok 4.x (tool-capable) + 'x-ai/grok-4.20', + 'x-ai/grok-4.3', +]) diff --git a/packages/ai-openrouter/tests/openrouter-combined-structured-output.test.ts b/packages/ai-openrouter/tests/openrouter-combined-structured-output.test.ts new file mode 100644 index 0000000000..e0f140d14a --- /dev/null +++ b/packages/ai-openrouter/tests/openrouter-combined-structured-output.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it, vi } from 'vitest' +import { createOpenRouterText } from '../src/adapters/text' +import { createOpenRouterResponsesText } from '../src/adapters/responses-text' +import { + OPENROUTER_CHAT_MODELS, + OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS, +} from '../src/model-meta' +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 = [ + { name: 'lookup_weather', description: 'Return the forecast for a location' }, +] + +// `mapOptionsToRequest` is protected; reach it directly to assert the wire +// shape without standing up a full streaming round-trip. +type RequestBuilder = { + mapOptionsToRequest: (options: Record) => Record +} + +function buildChatRequest(model: string, modelOptions?: Record) { + const adapter = createOpenRouterText( + model as 'openai/gpt-4o', + 'test-key', + ) as unknown as RequestBuilder + return adapter.mapOptionsToRequest({ + model, + messages: [{ role: 'user', content: 'hi' }], + tools, + outputSchema, + ...(modelOptions ? { modelOptions } : {}), + }) +} + +function buildResponsesRequest(model: string) { + const adapter = createOpenRouterResponsesText( + model as 'openai/gpt-4o', + 'test-key', + ) as unknown as RequestBuilder + 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) + }) + }) + + 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('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('preserves caller-supplied text.* fields when attaching the schema format', () => { + const adapter = createOpenRouterResponsesText( + 'openai/gpt-4o', + 'test-key', + ) as unknown as RequestBuilder + 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(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, + ) + } + }) + }) +}) diff --git a/testing/e2e/src/lib/feature-support.ts b/testing/e2e/src/lib/feature-support.ts index 691a0d21f7..29c2056378 100644 --- a/testing/e2e/src/lib/feature-support.ts +++ b/testing/e2e/src/lib/feature-support.ts @@ -187,11 +187,17 @@ export const matrix: Record> = { // (or per-feature override in `features.ts`) must opt into combined mode // — otherwise the engine takes the legacy finalization path, which makes // an extra request that this feature's fixture doesn't model. + // + // openrouter (#612): its default test model `openai/gpt-4o` is a member of + // OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS, so the chat adapter's + // `supportsCombinedToolsAndSchema()` returns true and the engine takes the + // native combined path — same single-request shape this fixture models. 'agentic-structured-stream': new Set([ 'openai', 'anthropic', 'gemini', 'grok', + 'openrouter', 'byteplus', ]), // Bedrock excluded: the default e2e model (openai.gpt-oss-120b) is text-only From bbc75caaae14b09b83a4672099c8d661f7c434d7 Mon Sep 17 00:00:00 2001 From: Season Date: Thu, 25 Jun 2026 08:11:36 +0800 Subject: [PATCH 2/8] Support OpenRouter combined tools and schema --- .../openrouter-combined-tools-and-schema.md | 2 +- packages/ai-openrouter/package.json | 4 ++ .../src/adapters/responses-text.ts | 26 +++++----- packages/ai-openrouter/src/adapters/text.ts | 26 +++++----- .../src/internal/combined-tools-and-schema.ts | 23 +++++++++ packages/ai-openrouter/src/model-meta.ts | 22 ++++++-- ...nrouter-combined-structured-output.test.ts | 51 ++++++++++++++++++- packages/ai-openrouter/vite.config.ts | 2 +- 8 files changed, 124 insertions(+), 32 deletions(-) create mode 100644 packages/ai-openrouter/src/internal/combined-tools-and-schema.ts diff --git a/.changeset/openrouter-combined-tools-and-schema.md b/.changeset/openrouter-combined-tools-and-schema.md index 940ca1e88a..953df9f5fc 100644 --- a/.changeset/openrouter-combined-tools-and-schema.md +++ b/.changeset/openrouter-combined-tools-and-schema.md @@ -4,4 +4,4 @@ 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 exported `OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS` set, 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. +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. diff --git a/packages/ai-openrouter/package.json b/packages/ai-openrouter/package.json index d3d4d85068..d7822a810b 100644 --- a/packages/ai-openrouter/package.json +++ b/packages/ai-openrouter/package.json @@ -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" diff --git a/packages/ai-openrouter/src/adapters/responses-text.ts b/packages/ai-openrouter/src/adapters/responses-text.ts index afa9392718..8ec8700680 100644 --- a/packages/ai-openrouter/src/adapters/responses-text.ts +++ b/packages/ai-openrouter/src/adapters/responses-text.ts @@ -7,12 +7,12 @@ 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' import { isWebFetchTool } from '../tools/web-fetch-tool' import { getOpenRouterApiKeyFromEnv } from '../utils' -import { OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS } from '../model-meta' import { extractUsageCost } from './cost' import type { SDKOptions } from '@openrouter/sdk' import type { ResponsesFunctionTool } from '../internal/responses-tool-converter' @@ -30,6 +30,7 @@ import type { } from '@tanstack/ai/adapters' import type { ContentPart, + JSONSchema, ModelMessage, StreamChunk, TextOptions, @@ -1594,11 +1595,10 @@ export class OpenRouterResponsesTextAdapter< // 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 = options.outputSchema as - | (Record & { required?: Array }) - | undefined + const combinedOutputSchema = options.outputSchema as JSONSchema | undefined const combinedSchema = - combinedOutputSchema && this.supportsCombinedToolsAndSchema() + combinedOutputSchema && + this.supportsCombinedToolsAndSchema(options.modelOptions) ? this.makeStructuredOutputCompatible( combinedOutputSchema, combinedOutputSchema.required, @@ -1657,14 +1657,16 @@ export class OpenRouterResponsesTextAdapter< /** * Native combined tools + `outputSchema` (#612). OpenRouter routes to many - * upstream providers, so capability is per-model: `this.model` is the bare - * canonical catalog id (the `:variant` suffix is a routing directive applied - * at request-build time and does not change combined-mode support), so the - * lookup ignores `modelOptions` and keys directly off `this.model`. Models - * not in the set fall back to the legacy finalization path. + * 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(): boolean { - return OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS.has(this.model) + supportsCombinedToolsAndSchema( + modelOptions?: OpenRouterResponsesTextProviderOptions, + ): boolean { + return openRouterSupportsCombinedToolsAndSchema(this.model, modelOptions) } /** diff --git a/packages/ai-openrouter/src/adapters/text.ts b/packages/ai-openrouter/src/adapters/text.ts index 4dcef658b8..d1b742dfc1 100644 --- a/packages/ai-openrouter/src/adapters/text.ts +++ b/packages/ai-openrouter/src/adapters/text.ts @@ -8,10 +8,10 @@ import { import { generateId } from '@tanstack/ai-utils' import { extractRequestOptions } from '../internal/request-options' import { makeStructuredOutputCompatible } from '../internal/schema-converter' +import { openRouterSupportsCombinedToolsAndSchema } from '../internal/combined-tools-and-schema' import { convertToolsToProviderFormat } from '../tools' import { getOpenRouterApiKeyFromEnv } from '../utils' import { buildOpenRouterUsage } from '../usage' -import { OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS } from '../model-meta' import { extractUsageCost } from './cost' import type { SDKOptions } from '@openrouter/sdk' import type { @@ -28,6 +28,7 @@ import type { } from '@tanstack/ai/adapters' import type { ContentPart, + JSONSchema, ModelMessage, StreamChunk, TextOptions, @@ -1193,11 +1194,10 @@ export class OpenRouterTextAdapter< // final-turn text — no separate finalization round-trip. The legacy // `structuredOutput*` methods strip `outputSchema` before calling this, so // this branch only fires on the combined path. - const combinedOutputSchema = options.outputSchema as - | (Record & { required?: Array }) - | undefined + const combinedOutputSchema = options.outputSchema as JSONSchema | undefined const combinedSchema = - combinedOutputSchema && this.supportsCombinedToolsAndSchema() + combinedOutputSchema && + this.supportsCombinedToolsAndSchema(options.modelOptions) ? this.makeStructuredOutputCompatible( combinedOutputSchema, combinedOutputSchema.required, @@ -1231,14 +1231,16 @@ export class OpenRouterTextAdapter< /** * Native combined tools + `outputSchema` (#612). OpenRouter routes to many - * upstream providers, so capability is per-model: `this.model` is the bare - * canonical catalog id (the `:variant` suffix is a routing directive applied - * at request-build time and does not change combined-mode support), so the - * lookup ignores `modelOptions` and keys directly off `this.model`. Models - * not in the set fall back to the legacy finalization path. + * 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(): boolean { - return OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS.has(this.model) + supportsCombinedToolsAndSchema( + modelOptions?: ResolveProviderOptions, + ): boolean { + return openRouterSupportsCombinedToolsAndSchema(this.model, modelOptions) } /** diff --git a/packages/ai-openrouter/src/internal/combined-tools-and-schema.ts b/packages/ai-openrouter/src/internal/combined-tools-and-schema.ts new file mode 100644 index 0000000000..9892a1ff22 --- /dev/null +++ b/packages/ai-openrouter/src/internal/combined-tools-and-schema.ts @@ -0,0 +1,23 @@ +import { OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS } from '../model-meta' + +type OpenRouterCombinedModelOptions = { + models?: ReadonlyArray | undefined +} + +function stripOpenRouterModelVariant(model: string): string { + const variantIndex = model.indexOf(':') + return variantIndex === -1 ? model : model.slice(0, variantIndex) +} + +export function openRouterSupportsCombinedToolsAndSchema( + model: string, + modelOptions?: OpenRouterCombinedModelOptions | undefined, +): boolean { + const candidates = [model, ...(modelOptions?.models ?? [])].map( + stripOpenRouterModelVariant, + ) + + return candidates.every((candidate) => + OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS.has(candidate), + ) +} diff --git a/packages/ai-openrouter/src/model-meta.ts b/packages/ai-openrouter/src/model-meta.ts index 175edf8d20..4c3f714f30 100644 --- a/packages/ai-openrouter/src/model-meta.ts +++ b/packages/ai-openrouter/src/model-meta.ts @@ -15920,10 +15920,20 @@ export const OPENROUTER_IMAGE_MODELS = [ * gpt-5*, o-series, and gpt-oss-* — tool-capable text variants only * - x.ai: Grok 4.x (tool-capable; excludes the multi-agent variant) * - * Every entry must also exist in {@link OPENROUTER_CHAT_MODELS} (guarded by a - * unit test) and carry both `responseFormat` and `toolChoice` capability. + * Every entry must also exist in {@link OpenRouterModelOptionsByName} and carry + * both `responseFormat` and `toolChoice` capability (guarded by the + * `satisfies` check on `OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODEL_IDS` + * below), and exist in {@link OPENROUTER_CHAT_MODELS} (guarded by a unit test). */ -export const OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS = new Set([ +type OpenRouterCombinedToolsAndSchemaModelId = { + [K in keyof OpenRouterModelOptionsByName]: 'responseFormat' extends keyof OpenRouterModelOptionsByName[K] + ? 'toolChoice' extends keyof OpenRouterModelOptionsByName[K] + ? K + : never + : never +}[keyof OpenRouterModelOptionsByName] + +const OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODEL_IDS = [ // Anthropic — the Claude 4.5+ ids the upstream gate currently blesses. // Mirrors ANTHROPIC_COMBINED_TOOLS_AND_SCHEMA_MODELS exactly (it stops at // 4.7), so OpenRouter-routed Claude 4.8 takes the same legacy path as the @@ -15993,4 +16003,8 @@ export const OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS = new Set([ // x.ai — Grok 4.x (tool-capable) 'x-ai/grok-4.20', 'x-ai/grok-4.3', -]) +] as const satisfies ReadonlyArray + +export const OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS = new Set( + OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODEL_IDS, +) diff --git a/packages/ai-openrouter/tests/openrouter-combined-structured-output.test.ts b/packages/ai-openrouter/tests/openrouter-combined-structured-output.test.ts index e0f140d14a..35062cef7e 100644 --- a/packages/ai-openrouter/tests/openrouter-combined-structured-output.test.ts +++ b/packages/ai-openrouter/tests/openrouter-combined-structured-output.test.ts @@ -34,7 +34,10 @@ type RequestBuilder = { mapOptionsToRequest: (options: Record) => Record } -function buildChatRequest(model: string, modelOptions?: Record) { +function buildChatRequest( + model: string, + modelOptions?: Record, +) { const adapter = createOpenRouterText( model as 'openai/gpt-4o', 'test-key', @@ -71,7 +74,10 @@ describe('OpenRouter combined tools + outputSchema (#612)', () => { ).supportsCombinedToolsAndSchema(), ).toBe(true) expect( - createOpenRouterText('openai/gpt-4o', 'k').supportsCombinedToolsAndSchema(), + createOpenRouterText( + 'openai/gpt-4o', + 'k', + ).supportsCombinedToolsAndSchema(), ).toBe(true) expect( createOpenRouterText( @@ -113,6 +119,21 @@ describe('OpenRouter combined tools + outputSchema (#612)', () => { ).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', () => { @@ -137,6 +158,15 @@ describe('OpenRouter combined tools + outputSchema (#612)', () => { 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() @@ -164,6 +194,23 @@ describe('OpenRouter combined tools + outputSchema (#612)', () => { expect(req.text).toBeUndefined() }) + it('omits text.format when any fallback model is unsupported', () => { + const adapter = createOpenRouterResponsesText( + 'openai/gpt-4o', + 'test-key', + ) as unknown as RequestBuilder + 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 = createOpenRouterResponsesText( 'openai/gpt-4o', diff --git a/packages/ai-openrouter/vite.config.ts b/packages/ai-openrouter/vite.config.ts index 0e7e7eaea6..c85fc6955b 100644 --- a/packages/ai-openrouter/vite.config.ts +++ b/packages/ai-openrouter/vite.config.ts @@ -29,7 +29,7 @@ const config = defineConfig({ export default mergeConfig( config, tanstackViteConfig({ - entry: ['./src/index.ts', './src/tools/index.ts'], + entry: ['./src/index.ts', './src/model-meta.ts', './src/tools/index.ts'], srcDir: './src', cjs: false, }), From f651a716c9aeb0a06f08b589729a40c150e57554 Mon Sep 17 00:00:00 2001 From: Season Date: Thu, 25 Jun 2026 08:31:39 +0800 Subject: [PATCH 3/8] Address OpenRouter review comments --- ...nrouter-combined-structured-output.test.ts | 57 +++++++++++-------- .../src/adapters/responses-text.ts | 2 +- packages/ai-openrouter/src/adapters/text.ts | 2 +- packages/ai-openrouter/vite.config.ts | 2 +- 4 files changed, 37 insertions(+), 26 deletions(-) rename packages/ai-openrouter/{tests => src/adapters}/openrouter-combined-structured-output.test.ts (85%) diff --git a/packages/ai-openrouter/tests/openrouter-combined-structured-output.test.ts b/packages/ai-openrouter/src/adapters/openrouter-combined-structured-output.test.ts similarity index 85% rename from packages/ai-openrouter/tests/openrouter-combined-structured-output.test.ts rename to packages/ai-openrouter/src/adapters/openrouter-combined-structured-output.test.ts index 35062cef7e..603952746a 100644 --- a/packages/ai-openrouter/tests/openrouter-combined-structured-output.test.ts +++ b/packages/ai-openrouter/src/adapters/openrouter-combined-structured-output.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from 'vitest' -import { createOpenRouterText } from '../src/adapters/text' -import { createOpenRouterResponsesText } from '../src/adapters/responses-text' import { OPENROUTER_CHAT_MODELS, OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS, -} from '../src/model-meta' +} 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 @@ -30,18 +30,32 @@ const tools: Array = [ // `mapOptionsToRequest` is protected; reach it directly to assert the wire // shape without standing up a full streaming round-trip. +type BuiltOpenRouterRequest = Record & { + model?: string + models?: Array + responseFormat?: unknown + text?: Record & { + format?: Record + verbosity?: string + } + tools?: Array +} + type RequestBuilder = { - mapOptionsToRequest: (options: Record) => Record + mapOptionsToRequest: (options: Record) => BuiltOpenRouterRequest +} + +function asRequestBuilder(adapter: unknown): RequestBuilder { + return adapter as RequestBuilder } function buildChatRequest( model: string, modelOptions?: Record, ) { - const adapter = createOpenRouterText( - model as 'openai/gpt-4o', - 'test-key', - ) as unknown as RequestBuilder + const adapter = asRequestBuilder( + createOpenRouterText(model as 'openai/gpt-4o', 'test-key'), + ) return adapter.mapOptionsToRequest({ model, messages: [{ role: 'user', content: 'hi' }], @@ -52,10 +66,9 @@ function buildChatRequest( } function buildResponsesRequest(model: string) { - const adapter = createOpenRouterResponsesText( - model as 'openai/gpt-4o', - 'test-key', - ) as unknown as RequestBuilder + const adapter = asRequestBuilder( + createOpenRouterResponsesText(model as 'openai/gpt-4o', 'test-key'), + ) return adapter.mapOptionsToRequest({ model, messages: [{ role: 'user', content: 'hi' }], @@ -148,7 +161,7 @@ describe('OpenRouter combined tools + outputSchema (#612)', () => { }, }) expect(req.tools).toBeDefined() - expect(req.tools.length).toBeGreaterThan(0) + expect(req.tools?.length).toBeGreaterThan(0) }) it('omits responseFormat for an unsupported model (legacy finalization path)', () => { @@ -195,10 +208,9 @@ describe('OpenRouter combined tools + outputSchema (#612)', () => { }) it('omits text.format when any fallback model is unsupported', () => { - const adapter = createOpenRouterResponsesText( - 'openai/gpt-4o', - 'test-key', - ) as unknown as RequestBuilder + const adapter = asRequestBuilder( + createOpenRouterResponsesText('openai/gpt-4o', 'test-key'), + ) const req = adapter.mapOptionsToRequest({ model: 'openai/gpt-4o', messages: [{ role: 'user', content: 'hi' }], @@ -212,10 +224,9 @@ describe('OpenRouter combined tools + outputSchema (#612)', () => { }) it('preserves caller-supplied text.* fields when attaching the schema format', () => { - const adapter = createOpenRouterResponsesText( - 'openai/gpt-4o', - 'test-key', - ) as unknown as RequestBuilder + const adapter = asRequestBuilder( + createOpenRouterResponsesText('openai/gpt-4o', 'test-key'), + ) const req = adapter.mapOptionsToRequest({ model: 'openai/gpt-4o', messages: [{ role: 'user', content: 'hi' }], @@ -225,8 +236,8 @@ describe('OpenRouter combined tools + outputSchema (#612)', () => { }) // `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({ + expect(req.text?.verbosity).toBe('low') + expect(req.text?.format).toMatchObject({ type: 'json_schema', name: 'structured_output', strict: true, diff --git a/packages/ai-openrouter/src/adapters/responses-text.ts b/packages/ai-openrouter/src/adapters/responses-text.ts index 8ec8700680..3784b56d19 100644 --- a/packages/ai-openrouter/src/adapters/responses-text.ts +++ b/packages/ai-openrouter/src/adapters/responses-text.ts @@ -1595,7 +1595,7 @@ export class OpenRouterResponsesTextAdapter< // 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 = options.outputSchema as JSONSchema | undefined + const combinedOutputSchema: JSONSchema | undefined = options.outputSchema const combinedSchema = combinedOutputSchema && this.supportsCombinedToolsAndSchema(options.modelOptions) diff --git a/packages/ai-openrouter/src/adapters/text.ts b/packages/ai-openrouter/src/adapters/text.ts index d1b742dfc1..4855bc00c1 100644 --- a/packages/ai-openrouter/src/adapters/text.ts +++ b/packages/ai-openrouter/src/adapters/text.ts @@ -1194,7 +1194,7 @@ export class OpenRouterTextAdapter< // final-turn text — no separate finalization round-trip. The legacy // `structuredOutput*` methods strip `outputSchema` before calling this, so // this branch only fires on the combined path. - const combinedOutputSchema = options.outputSchema as JSONSchema | undefined + const combinedOutputSchema: JSONSchema | undefined = options.outputSchema const combinedSchema = combinedOutputSchema && this.supportsCombinedToolsAndSchema(options.modelOptions) diff --git a/packages/ai-openrouter/vite.config.ts b/packages/ai-openrouter/vite.config.ts index c85fc6955b..fab574d16d 100644 --- a/packages/ai-openrouter/vite.config.ts +++ b/packages/ai-openrouter/vite.config.ts @@ -9,7 +9,7 @@ const config = defineConfig({ watch: false, globals: true, environment: 'node', - include: ['tests/**/*.test.ts'], + include: ['tests/**/*.test.ts', 'src/**/*.test.ts'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html', 'lcov'], From ad8e3221fb76891a3865b34597f4f0bebc2f3e45 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:33:25 +0000 Subject: [PATCH 4/8] ci: apply automated fixes --- .../adapters/openrouter-combined-structured-output.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ai-openrouter/src/adapters/openrouter-combined-structured-output.test.ts b/packages/ai-openrouter/src/adapters/openrouter-combined-structured-output.test.ts index 603952746a..0ed3d02741 100644 --- a/packages/ai-openrouter/src/adapters/openrouter-combined-structured-output.test.ts +++ b/packages/ai-openrouter/src/adapters/openrouter-combined-structured-output.test.ts @@ -42,7 +42,9 @@ type BuiltOpenRouterRequest = Record & { } type RequestBuilder = { - mapOptionsToRequest: (options: Record) => BuiltOpenRouterRequest + mapOptionsToRequest: ( + options: Record, + ) => BuiltOpenRouterRequest } function asRequestBuilder(adapter: unknown): RequestBuilder { From 5459cf9c031ed9cd51ad3538123a0934a8191554 Mon Sep 17 00:00:00 2001 From: Season Saw Date: Tue, 18 Aug 2026 10:39:38 +0800 Subject: [PATCH 5/8] fix(ai-openrouter): sync combined tools+schema gate with upstream main main removed anthropic/claude-opus-4.6-fast from the OpenRouter catalog and extended ANTHROPIC_COMBINED_TOOLS_AND_SCHEMA_MODELS through opus 4.8 and the Claude 5 ids, so the mirror list follows: drop the removed id, add opus-4.8(-fast), fable-5, and sonnet-5. --- packages/ai-openrouter/src/model-meta.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/ai-openrouter/src/model-meta.ts b/packages/ai-openrouter/src/model-meta.ts index 00641917b9..7abfbe32b0 100644 --- a/packages/ai-openrouter/src/model-meta.ts +++ b/packages/ai-openrouter/src/model-meta.ts @@ -16332,7 +16332,7 @@ export const OPENROUTER_IMAGE_MODELS = [ * all of which the upstream native adapters exclude from combined mode): * - Anthropic: the Claude 4.5+ ids in the upstream * `ANTHROPIC_COMBINED_TOOLS_AND_SCHEMA_MODELS` gate (opus/sonnet/haiku); - * newer ids (e.g. 4.8) land here only once that gate adds them + * newer ids land here only once that gate adds them * - Google: Gemini 3.x only * - OpenAI: strict-`json_schema` era (gpt-4o-2024-08-06 and later), gpt-4.1, * gpt-5*, o-series, and gpt-oss-* — tool-capable text variants only @@ -16353,17 +16353,19 @@ type OpenRouterCombinedToolsAndSchemaModelId = { const OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODEL_IDS = [ // Anthropic — the Claude 4.5+ ids the upstream gate currently blesses. - // Mirrors ANTHROPIC_COMBINED_TOOLS_AND_SCHEMA_MODELS exactly (it stops at - // 4.7), so OpenRouter-routed Claude 4.8 takes the same legacy path as the - // native @tanstack/ai-anthropic adapter until upstream adds it. + // Mirrors ANTHROPIC_COMBINED_TOOLS_AND_SCHEMA_MODELS exactly (through the + // Claude 5 ids), plus the OpenRouter-only `-fast` variants of gated models. + 'anthropic/claude-fable-5', 'anthropic/claude-haiku-4.5', 'anthropic/claude-opus-4.5', 'anthropic/claude-opus-4.6', - 'anthropic/claude-opus-4.6-fast', 'anthropic/claude-opus-4.7', 'anthropic/claude-opus-4.7-fast', + 'anthropic/claude-opus-4.8', + 'anthropic/claude-opus-4.8-fast', 'anthropic/claude-sonnet-4.5', 'anthropic/claude-sonnet-4.6', + 'anthropic/claude-sonnet-5', // Google — Gemini 3.x family 'google/gemini-3-flash-preview', From fbce3dcc3b35edd2e688b43ce3a36fd053e42018 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 19 Aug 2026 11:33:11 +0200 Subject: [PATCH 6/8] fix(ai-openrouter): address combined-mode review Sync the combined-model allowlist with current main. Document the OpenRouter tools+outputSchema path. Cover Responses harvest and :variant stripping in tests. --- docs/adapters/openrouter.md | 125 +++++ docs/advanced/middleware.md | 2 +- docs/config.json | 8 +- docs/structured-outputs/overview.md | 8 +- docs/structured-outputs/with-tools.md | 2 + ...nrouter-combined-structured-output.test.ts | 260 ---------- .../src/adapters/responses-text.ts | 18 +- packages/ai-openrouter/src/adapters/text.ts | 19 +- packages/ai-openrouter/src/model-meta.ts | 25 +- ...nrouter-combined-structured-output.test.ts | 462 ++++++++++++++++++ testing/e2e/src/lib/feature-support.ts | 8 +- 11 files changed, 629 insertions(+), 308 deletions(-) delete mode 100644 packages/ai-openrouter/src/adapters/openrouter-combined-structured-output.test.ts create mode 100644 packages/ai-openrouter/tests/openrouter-combined-structured-output.test.ts diff --git a/docs/adapters/openrouter.md b/docs/adapters/openrouter.md index 3060bff5f4..a45724574a 100644 --- a/docs/adapters/openrouter.md +++ b/docs/adapters/openrouter.md @@ -112,6 +112,131 @@ export async function POST(request: Request) { } ``` +## Tools and structured output together + +You can pass both `tools` and `outputSchema` on one `chat()` call. For some +upstream models OpenRouter can return the typed object in that same streaming +request, so the engine does not make a second finalization call. + +That happens only when **every** model that can receive the request is in +`OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS`. The set is the upstream models +that already support this combined mode: + +- Anthropic Claude 4.5 and later +- Gemini 3.x text models +- Grok 4.x (not the multi-agent variant) +- OpenAI models from the `gpt-4o-2024-08-06` strict JSON Schema era onward + (the unpinned `openai/gpt-4o-mini` alias is included; the dated pin + `openai/gpt-4o-mini-2024-07-18` is not) + +If any fallback in `modelOptions.models` is outside that set, OpenRouter keeps +the two-call path. Routing suffixes such as `:nitro` do not change the gate. + +Import the set from `@tanstack/ai-openrouter/model-meta` if you need to check a +model before you send: + +```typescript +import { OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS } from "@tanstack/ai-openrouter/model-meta"; + +OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS.has("openai/gpt-5.5"); +``` + +Chat Completions (`openRouterText`) and Responses (`openRouterResponsesText`) +both attach the schema on this path. The client does not change: `useChat({ +outputSchema })` still reads `partial` and `final`. + +Server (Chat Completions): + +```typescript +import { chat, toServerSentEventsResponse, toolDefinition } from "@tanstack/ai"; +import { openRouterText } from "@tanstack/ai-openrouter"; +import { z } from "zod"; + +const getWeather = toolDefinition({ + name: "get_weather", + description: "Get the current weather", + inputSchema: z.object({ location: z.string() }), +}).server(async ({ location }) => { + return { temperature: 72, conditions: "sunny", location }; +}); + +const AnswerSchema = z.object({ + summary: z.string(), + location: z.string(), +}); + +export async function POST(request: Request) { + const { messages } = await request.json(); + + const stream = chat({ + adapter: openRouterText("openai/gpt-5.5"), + messages, + tools: [getWeather], + outputSchema: AnswerSchema, + stream: true, + }); + + return toServerSentEventsResponse(stream); +} +``` + +Server (Responses). Same `tools` and `outputSchema` as the Chat Completions +example, with `openRouterResponsesText`: + +```typescript +import { chat, toServerSentEventsResponse, toolDefinition } from "@tanstack/ai"; +import { openRouterResponsesText } from "@tanstack/ai-openrouter"; +import { z } from "zod"; + +const getWeather = toolDefinition({ + name: "get_weather", + description: "Get the current weather", + inputSchema: z.object({ location: z.string() }), +}).server(async ({ location }) => { + return { temperature: 72, conditions: "sunny", location }; +}); + +const AnswerSchema = z.object({ + summary: z.string(), + location: z.string(), +}); + +export async function POST(request: Request) { + const { messages } = await request.json(); + + const stream = chat({ + adapter: openRouterResponsesText("openai/gpt-5.5"), + messages, + tools: [getWeather], + outputSchema: AnswerSchema, + stream: true, + }); + + return toServerSentEventsResponse(stream); +} +``` + +Client: + +```tsx +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; +import { z } from "zod"; + +const AnswerSchema = z.object({ + summary: z.string(), + location: z.string(), +}); + +const { sendMessage, partial, final } = useChat({ + connection: fetchServerSentEvents("/api/chat"), + outputSchema: AnswerSchema, +}); +``` + +See [Structured Outputs with tools](../structured-outputs/with-tools) for the +event order, and [Middleware](../advanced/middleware) for how +`structuredOutput` phase behaves on this path. + ## Environment Variables Set your API key in environment variables: diff --git a/docs/advanced/middleware.md b/docs/advanced/middleware.md index 80fe5fed46..334bb3ead2 100644 --- a/docs/advanced/middleware.md +++ b/docs/advanced/middleware.md @@ -102,7 +102,7 @@ The context's `phase` field tracks where you are in the lifecycle: | `modelStream` | While adapter streams chunks | `onChunk`, `onUsage` | | `beforeTools` | Before tool execution | `onBeforeToolCall` | | `afterTools` | After tool execution | `onAfterToolCall` | -| `structuredOutput` | During the final structured-output adapter call (when `outputSchema` is set **and** the adapter does not declare `supportsCombinedToolsAndSchema()`). Chunks from `adapter.structuredOutputStream` (or the synthesized non-streaming fallback) flow through `onChunk` with this phase, and `onUsage` fires for the final call's tokens. **Does not fire** for adapters that natively combine tools + schema in one streaming call (modern OpenAI Chat Completions, OpenAI Responses, Claude 4.5+, Gemini 3.x, Grok 4.x family — see issue #605); on that path middleware observes the run through `beforeModel` / `modelStream` as usual. | `onStructuredOutputConfig`, `onConfig`, `onChunk`, `onUsage` | +| `structuredOutput` | During the final structured-output adapter call (when `outputSchema` is set **and** the adapter does not declare `supportsCombinedToolsAndSchema()`). Chunks from `adapter.structuredOutputStream` (or the synthesized non-streaming fallback) flow through `onChunk` with this phase, and `onUsage` fires for the final call's tokens. **Does not fire** for adapters that natively combine tools + schema in one streaming call (modern OpenAI Chat Completions, OpenAI Responses, Claude 4.5+, Gemini 3.x, Grok 4.x family, and OpenRouter when every routed model is in `OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS`; see issue #605). On that path middleware observes the run through `beforeModel` / `modelStream` as usual. | `onStructuredOutputConfig`, `onConfig`, `onChunk`, `onUsage` | ## Hooks Reference diff --git a/docs/config.json b/docs/config.json index 6ab97268ed..ca435a0054 100644 --- a/docs/config.json +++ b/docs/config.json @@ -337,7 +337,7 @@ "label": "Overview", "to": "structured-outputs/overview", "addedAt": "2026-05-19", - "updatedAt": "2026-08-18" + "updatedAt": "2026-08-19" }, { "label": "One-Shot Extraction", @@ -360,7 +360,7 @@ "label": "With Tools", "to": "structured-outputs/with-tools", "addedAt": "2026-05-19", - "updatedAt": "2026-08-14" + "updatedAt": "2026-08-19" }, { "label": "Harness Agents", @@ -490,7 +490,7 @@ "label": "Middleware", "to": "advanced/middleware", "addedAt": "2026-04-15", - "updatedAt": "2026-07-21" + "updatedAt": "2026-08-19" }, { "label": "Built-in Middleware", @@ -857,7 +857,7 @@ "label": "OpenRouter Adapter", "to": "adapters/openrouter", "addedAt": "2026-04-15", - "updatedAt": "2026-08-13" + "updatedAt": "2026-08-19" }, { "label": "Perplexity Search", diff --git a/docs/structured-outputs/overview.md b/docs/structured-outputs/overview.md index 79d1ad1c3f..dba56fe729 100644 --- a/docs/structured-outputs/overview.md +++ b/docs/structured-outputs/overview.md @@ -113,10 +113,14 @@ exactly once at the end of the entire run. > - Anthropic Claude 4.5+ > - Gemini 3.x > - Grok 4.x family +> - OpenRouter, when every routed model is in +> `OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS` (see the +> [OpenRouter adapter](../adapters/openrouter.md#tools-and-structured-output-together)) > > **Adapters without native combined-mode support** (Anthropic 4.4-, Gemini -> 2.x, Grok 2/3, Groq, Ollama, OpenRouter) keep the legacy finalization -> path and the `'structuredOutput'` phase fires as before. +> 2.x, Grok 2/3, Groq, Ollama, and OpenRouter models outside that set) keep +> the legacy finalization path and the `'structuredOutput'` phase fires as +> before. ### Observing structured-output chunks diff --git a/docs/structured-outputs/with-tools.md b/docs/structured-outputs/with-tools.md index 7ac8aec506..b2ee1d7460 100644 --- a/docs/structured-outputs/with-tools.md +++ b/docs/structured-outputs/with-tools.md @@ -17,6 +17,8 @@ You want the agent to use tools to gather information, then return a structured This page covers the combined `outputSchema` + `tools` shape, including the pause/resume points (server-tool approval prompts, client-tool invocations) that can land mid-run before the structured object arrives. +On adapters that support native combined mode (modern OpenAI, Claude 4.5+, Gemini 3.x, Grok 4.x, and [OpenRouter on those same upstream models](../adapters/openrouter.md#tools-and-structured-output-together)), `chat({ tools, outputSchema, stream: true })` is one streaming request. The extra finalization call does not run. + > **Note:** If you're not yet familiar with how tools work in TanStack AI, read [Tool Architecture](../tools/tool-architecture) and [Server Tools](../tools/server-tools) first. The patterns here build on the regular agent-loop flow — `outputSchema` just adds a final terminal event. ## Non-streaming: tools first, then structured object diff --git a/packages/ai-openrouter/src/adapters/openrouter-combined-structured-output.test.ts b/packages/ai-openrouter/src/adapters/openrouter-combined-structured-output.test.ts deleted file mode 100644 index 0ed3d02741..0000000000 --- a/packages/ai-openrouter/src/adapters/openrouter-combined-structured-output.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -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 = [ - { name: 'lookup_weather', description: 'Return the forecast for a location' }, -] - -// `mapOptionsToRequest` is protected; reach it directly to assert the wire -// shape without standing up a full streaming round-trip. -type BuiltOpenRouterRequest = Record & { - model?: string - models?: Array - responseFormat?: unknown - text?: Record & { - format?: Record - verbosity?: string - } - tools?: Array -} - -type RequestBuilder = { - mapOptionsToRequest: ( - options: Record, - ) => BuiltOpenRouterRequest -} - -function asRequestBuilder(adapter: unknown): RequestBuilder { - return adapter as RequestBuilder -} - -function buildChatRequest( - model: string, - modelOptions?: Record, -) { - 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(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, - ) - } - }) - }) -}) diff --git a/packages/ai-openrouter/src/adapters/responses-text.ts b/packages/ai-openrouter/src/adapters/responses-text.ts index dd41afdbd4..930760d5c5 100644 --- a/packages/ai-openrouter/src/adapters/responses-text.ts +++ b/packages/ai-openrouter/src/adapters/responses-text.ts @@ -1607,13 +1607,8 @@ 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. + // Attach text.format json_schema only when outputSchema is set and every + // routed model is in the combined-capable set. const combinedOutputSchema: JSONSchema | undefined = options.outputSchema const combinedSchema = combinedOutputSchema && @@ -1675,12 +1670,9 @@ export class OpenRouterResponsesTextAdapter< } /** - * 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. + * Combined mode is safe only when this model and every `modelOptions.models` + * fallback are in `OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS`. + * `:variant` suffixes are routing directives and do not change the gate. */ supportsCombinedToolsAndSchema( modelOptions?: OpenRouterResponsesTextProviderOptions, diff --git a/packages/ai-openrouter/src/adapters/text.ts b/packages/ai-openrouter/src/adapters/text.ts index 4a6123cd1a..1c48849ca2 100644 --- a/packages/ai-openrouter/src/adapters/text.ts +++ b/packages/ai-openrouter/src/adapters/text.ts @@ -1191,14 +1191,8 @@ export class OpenRouterTextAdapter< ? convertToolsToProviderFormat(options.tools) : undefined - // Native combined mode (#612): the engine populates `options.outputSchema` - // on the `chatStream` call ONLY when the adapter declared - // `supportsCombinedToolsAndSchema()` for this model. When set, attach - // `responseFormat: json_schema` alongside `tools` so the schema-constrained - // JSON rides the same streaming request and the engine harvests it from the - // final-turn text — no separate finalization round-trip. The legacy - // `structuredOutput*` methods strip `outputSchema` before calling this, so - // this branch only fires on the combined path. + // Attach json_schema only when outputSchema is set and every routed model + // is in the combined-capable set. const combinedOutputSchema: JSONSchema | undefined = options.outputSchema const combinedSchema = combinedOutputSchema && @@ -1235,12 +1229,9 @@ export class OpenRouterTextAdapter< } /** - * 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. + * Combined mode is safe only when this model and every `modelOptions.models` + * fallback are in `OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS`. + * `:variant` suffixes are routing directives and do not change the gate. */ supportsCombinedToolsAndSchema( modelOptions?: ResolveProviderOptions, diff --git a/packages/ai-openrouter/src/model-meta.ts b/packages/ai-openrouter/src/model-meta.ts index 2fa6e03d5c..7255487790 100644 --- a/packages/ai-openrouter/src/model-meta.ts +++ b/packages/ai-openrouter/src/model-meta.ts @@ -19619,30 +19619,32 @@ const OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODEL_IDS = [ 'anthropic/claude-sonnet-4.6', 'anthropic/claude-sonnet-5', - // Google — Gemini 3.x family + // Google — Gemini 3.x text models only (image-only ids stay on the legacy path) 'google/gemini-3-flash-preview', 'google/gemini-3.1-flash-lite', 'google/gemini-3.1-flash-lite-preview', 'google/gemini-3.1-pro-preview', 'google/gemini-3.1-pro-preview-customtools', 'google/gemini-3.5-flash', + 'google/gemini-3.5-flash-lite', + 'google/gemini-3.6-flash', + 'google/gemini-3.7-flash', - // OpenAI — strict-json_schema era, tool-capable text models + // OpenAI — strict-json_schema era, tool-capable text models. + // Cut is gpt-4o-2024-08-06. The dated pin gpt-4o-mini-2024-07-18 predates + // that launch, so it is not listed; the unpinned gpt-4o-mini alias is. 'openai/gpt-4o', 'openai/gpt-4o-2024-08-06', 'openai/gpt-4o-2024-11-20', 'openai/gpt-4o-mini', - 'openai/gpt-4o-mini-2024-07-18', 'openai/gpt-4.1', 'openai/gpt-4.1-mini', 'openai/gpt-4.1-nano', 'openai/gpt-5', - 'openai/gpt-5-codex', 'openai/gpt-5-mini', 'openai/gpt-5-nano', 'openai/gpt-5-pro', 'openai/gpt-5.1', - 'openai/gpt-5.1-chat', 'openai/gpt-5.1-codex', 'openai/gpt-5.1-codex-max', 'openai/gpt-5.1-codex-mini', @@ -19650,7 +19652,6 @@ const OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODEL_IDS = [ 'openai/gpt-5.2-chat', 'openai/gpt-5.2-codex', 'openai/gpt-5.2-pro', - 'openai/gpt-5.3-chat', 'openai/gpt-5.3-codex', 'openai/gpt-5.4', 'openai/gpt-5.4-mini', @@ -19658,23 +19659,29 @@ const OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODEL_IDS = [ 'openai/gpt-5.4-pro', 'openai/gpt-5.5', 'openai/gpt-5.5-pro', + 'openai/gpt-5.6-luna', + 'openai/gpt-5.6-luna-pro', + 'openai/gpt-5.6-sol', + 'openai/gpt-5.6-sol-pro', + 'openai/gpt-5.6-terra', + 'openai/gpt-5.6-terra-pro', 'openai/gpt-chat-latest', 'openai/o1', 'openai/o3', 'openai/o3-mini', 'openai/o3-mini-high', 'openai/o3-pro', - 'openai/o3-deep-research', 'openai/o4-mini', 'openai/o4-mini-high', - 'openai/o4-mini-deep-research', 'openai/gpt-oss-120b', 'openai/gpt-oss-20b', 'openai/gpt-oss-safeguard-20b', - // x.ai — Grok 4.x (tool-capable) + // x.ai — Grok 4.x (tool-capable; skip grok-4.20-multi-agent) 'x-ai/grok-4.20', 'x-ai/grok-4.3', + 'x-ai/grok-4.5', + 'x-ai/grok-4.6', ] as const satisfies ReadonlyArray export const OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS = new Set( diff --git a/packages/ai-openrouter/tests/openrouter-combined-structured-output.test.ts b/packages/ai-openrouter/tests/openrouter-combined-structured-output.test.ts new file mode 100644 index 0000000000..12a72c0f42 --- /dev/null +++ b/packages/ai-openrouter/tests/openrouter-combined-structured-output.test.ts @@ -0,0 +1,462 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { EventType, chat } from '@tanstack/ai' +import { resolveDebugOption } from '@tanstack/ai/adapter-internals' +import { z } from 'zod' +import { + OPENROUTER_CHAT_MODELS, + OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS, +} from '../src/model-meta' +import { openRouterSupportsCombinedToolsAndSchema } from '../src/internal/combined-tools-and-schema' +import { createOpenRouterResponsesText } from '../src/adapters/responses-text' +import { createOpenRouterText } from '../src/adapters/text' +import type { StreamChunk, Tool } from '@tanstack/ai' + +// Mock the SDK with a constructor function, not a class. A class field named +// `chat` collides with the `chat` import when vitest hoists this mock. +let mockChatSend = vi.fn() +let mockResponsesSend = vi.fn() + +vi.mock('@openrouter/sdk', () => { + function OpenRouter(this: { + chat: { send: (...args: Array) => unknown } + beta: { responses: { send: (...args: Array) => unknown } } + }) { + this.chat = { + send: (...args: Array) => mockChatSend(...args), + } + this.beta = { + responses: { + send: (...args: Array) => mockResponsesSend(...args), + }, + } + } + return { OpenRouter } +}) + +const AnswerSchema = z.object({ answer: z.string() }) + +const tools: Array = [ + { name: 'lookup_weather', description: 'Return the forecast for a location' }, +] + +const testLogger = resolveDebugOption(false) + +function createAsyncIterable(chunks: Array): AsyncIterable { + return { + [Symbol.asyncIterator]() { + let index = 0 + return { + async next(): Promise> { + if (index < chunks.length) { + return { value: chunks[index++]!, done: false } + } + return { done: true, value: undefined } + }, + } + }, + } +} + +function setupChatStream(chunks: Array>) { + mockChatSend = vi + .fn() + .mockImplementation((params: { chatRequest?: { stream?: boolean } }) => { + if (params.chatRequest?.stream) { + return Promise.resolve(createAsyncIterable(chunks)) + } + return Promise.resolve({ choices: [{ message: { content: '' } }] }) + }) +} + +function setupResponsesStream(chunks: Array>) { + mockResponsesSend = vi + .fn() + .mockImplementation( + (params: { responsesRequest?: { stream?: boolean } }) => { + if (params.responsesRequest?.stream) { + return Promise.resolve(createAsyncIterable(chunks)) + } + return Promise.resolve({ output: [] }) + }, + ) +} + +const jsonStopChunks = [ + { + id: 'chatcmpl-1', + model: 'openai/gpt-4o', + choices: [{ delta: { content: '{"answer":"ok"}' }, finishReason: null }], + }, + { + id: 'chatcmpl-1', + model: 'openai/gpt-4o', + choices: [{ delta: {}, finishReason: 'stop' }], + usage: { promptTokens: 8, completionTokens: 4, totalTokens: 12 }, + }, +] + +const responsesJsonChunks = [ + { + type: 'response.created', + sequenceNumber: 0, + response: { model: 'openai/gpt-4o', output: [] }, + }, + { + type: 'response.output_text.delta', + sequenceNumber: 1, + itemId: 'msg_1', + outputIndex: 0, + contentIndex: 0, + delta: '{"answer":"ok"}', + }, + { + type: 'response.completed', + sequenceNumber: 2, + response: { + model: 'openai/gpt-4o', + output: [], + usage: { inputTokens: 8, outputTokens: 4, totalTokens: 12 }, + }, + }, +] + +function readCompleteObject(chunks: Array): unknown { + for (const chunk of chunks) { + if ( + chunk.type === EventType.CUSTOM && + chunk.name === 'structured-output.complete' && + chunk.value && + typeof chunk.value === 'object' && + 'object' in chunk.value + ) { + return chunk.value.object + } + } + throw new Error('missing structured-output.complete') +} + +describe('OpenRouter combined tools + outputSchema', () => { + beforeEach(() => { + mockChatSend = vi.fn() + mockResponsesSend = vi.fn() + }) + + 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) + expect( + createOpenRouterText( + 'google/gemini-3.6-flash', + 'k', + ).supportsCombinedToolsAndSchema(), + ).toBe(true) + }) + + it('returns false for upstream models the upstream gate excludes', () => { + expect( + createOpenRouterText( + 'anthropic/claude-opus-4.1', + 'k', + ).supportsCombinedToolsAndSchema(), + ).toBe(false) + expect( + createOpenRouterText( + 'openai/gpt-4o-2024-05-13', + 'k', + ).supportsCombinedToolsAndSchema(), + ).toBe(false) + expect( + createOpenRouterText( + 'openai/gpt-4o-mini-2024-07-18', + '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) + }) + + it('strips :variant suffixes on the model id and on fallback models', () => { + expect( + openRouterSupportsCombinedToolsAndSchema('openai/gpt-4o:nitro'), + ).toBe(true) + expect( + openRouterSupportsCombinedToolsAndSchema('openai/gpt-4o', { + models: ['openai/gpt-4o:nitro'], + }), + ).toBe(true) + expect( + openRouterSupportsCombinedToolsAndSchema('openai/gpt-4o', { + models: ['openai/gpt-4o-2024-05-13:nitro'], + }), + ).toBe(false) + }) + }) + + describe('chat-completions request payload', () => { + it('attaches responseFormat alongside tools on chatStream', async () => { + setupChatStream(jsonStopChunks) + const adapter = createOpenRouterText('openai/gpt-4o', 'k') + + for await (const _ of adapter.chatStream({ + logger: testLogger, + model: 'openai/gpt-4o', + messages: [{ role: 'user', content: 'hi' }], + tools, + outputSchema: { + type: 'object', + properties: { answer: { type: 'string' } }, + required: ['answer'], + }, + })) { + // drain + } + + expect(mockChatSend).toHaveBeenCalledTimes(1) + const params = mockChatSend.mock.calls[0]![0].chatRequest + expect(params.tools?.length).toBeGreaterThan(0) + expect(params.responseFormat).toEqual({ + type: 'json_schema', + jsonSchema: { + name: 'structured_output', + schema: expect.any(Object), + strict: true, + }, + }) + }) + + it('omits responseFormat for an unsupported model on the agent-loop call', async () => { + setupChatStream(jsonStopChunks) + const adapter = createOpenRouterText('anthropic/claude-opus-4.1', 'k') + + for await (const _ of chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + tools, + outputSchema: AnswerSchema, + stream: true, + })) { + // drain + } + + expect(mockChatSend.mock.calls.length).toBeGreaterThan(0) + const first = mockChatSend.mock.calls[0]![0].chatRequest + expect(first.tools).toBeDefined() + expect(first.responseFormat).toBeUndefined() + }) + + it('omits responseFormat when any fallback model is unsupported', async () => { + setupChatStream(jsonStopChunks) + const adapter = createOpenRouterText('openai/gpt-4o', 'k') + + for await (const _ of adapter.chatStream({ + logger: testLogger, + model: 'openai/gpt-4o', + messages: [{ role: 'user', content: 'hi' }], + tools, + outputSchema: { + type: 'object', + properties: { answer: { type: 'string' } }, + required: ['answer'], + }, + modelOptions: { models: ['openai/gpt-4o-2024-05-13'] }, + })) { + // drain + } + + const params = mockChatSend.mock.calls[0]![0].chatRequest + expect(params.responseFormat).toBeUndefined() + expect(params.models).toEqual(['openai/gpt-4o-2024-05-13']) + expect(params.tools).toBeDefined() + }) + }) + + describe('Responses request payload', () => { + it('attaches text.format alongside tools on chatStream', async () => { + setupResponsesStream(responsesJsonChunks) + const adapter = createOpenRouterResponsesText('openai/gpt-4o', 'k') + + for await (const _ of adapter.chatStream({ + logger: testLogger, + model: 'openai/gpt-4o', + messages: [{ role: 'user', content: 'hi' }], + tools, + outputSchema: { + type: 'object', + properties: { answer: { type: 'string' } }, + required: ['answer'], + }, + })) { + // drain + } + + expect(mockResponsesSend).toHaveBeenCalledTimes(1) + const params = mockResponsesSend.mock.calls[0]![0].responsesRequest + expect(params.tools).toBeDefined() + expect(params.text).toEqual({ + format: { + type: 'json_schema', + name: 'structured_output', + schema: expect.any(Object), + strict: true, + }, + }) + }) + + it('preserves caller-supplied text.* fields when attaching the schema format', async () => { + setupResponsesStream(responsesJsonChunks) + const adapter = createOpenRouterResponsesText('openai/gpt-4o', 'k') + + for await (const _ of adapter.chatStream({ + logger: testLogger, + model: 'openai/gpt-4o', + messages: [{ role: 'user', content: 'hi' }], + tools, + outputSchema: { + type: 'object', + properties: { answer: { type: 'string' } }, + required: ['answer'], + }, + modelOptions: { text: { verbosity: 'low' } }, + })) { + // drain + } + + const params = mockResponsesSend.mock.calls[0]![0].responsesRequest + expect(params.text?.verbosity).toBe('low') + expect(params.text?.format).toMatchObject({ + type: 'json_schema', + name: 'structured_output', + strict: true, + }) + }) + }) + + describe('engine harvest', () => { + it('parses final-turn JSON from chat({ tools, outputSchema, stream: true }) in one request', async () => { + setupChatStream(jsonStopChunks) + const adapter = createOpenRouterText('openai/gpt-4o', 'k') + const chunks: Array = [] + + for await (const chunk of chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + tools, + outputSchema: AnswerSchema, + stream: true, + })) { + chunks.push(chunk) + } + + expect(mockChatSend).toHaveBeenCalledTimes(1) + const params = mockChatSend.mock.calls[0]![0].chatRequest + expect(params.tools?.length).toBeGreaterThan(0) + expect(params.responseFormat?.type).toBe('json_schema') + + expect(readCompleteObject(chunks)).toEqual({ answer: 'ok' }) + }) + + it('parses final-turn JSON from the Responses adapter on the combined path', async () => { + setupResponsesStream(responsesJsonChunks) + const adapter = createOpenRouterResponsesText('openai/gpt-4o', 'k') + const chunks: Array = [] + + for await (const chunk of chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + tools, + outputSchema: AnswerSchema, + stream: true, + })) { + chunks.push(chunk) + } + + expect(mockResponsesSend).toHaveBeenCalledTimes(1) + const params = mockResponsesSend.mock.calls[0]![0].responsesRequest + expect(params.tools).toBeDefined() + expect(params.text?.format?.type).toBe('json_schema') + + expect(readCompleteObject(chunks)).toEqual({ answer: 'ok' }) + }) + }) + + describe('set integrity', () => { + const catalog = new Set(OPENROUTER_CHAT_MODELS) + + it('every combined-mode id exists in the OpenRouter catalog', () => { + for (const id of OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS) { + expect(catalog.has(id), `${id} is not in OPENROUTER_CHAT_MODELS`).toBe( + true, + ) + } + }) + + it('every Gemini 3.x text catalog id is in the combined set', () => { + for (const id of OPENROUTER_CHAT_MODELS) { + if (!id.startsWith('google/gemini-3')) continue + if (id.includes(':')) continue + if (id.includes('-image')) continue + expect( + OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS.has(id), + `${id} is a Gemini 3.x text catalog id missing from the combined set`, + ).toBe(true) + } + }) + + it('every Grok 4.x tool-capable catalog id is in the combined set', () => { + for (const id of OPENROUTER_CHAT_MODELS) { + if (!id.startsWith('x-ai/grok-4')) continue + if (id.includes(':')) continue + if (id.includes('multi-agent')) continue + expect( + OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS.has(id), + `${id} is a Grok 4.x catalog id missing from the combined set`, + ).toBe(true) + } + }) + }) +}) diff --git a/testing/e2e/src/lib/feature-support.ts b/testing/e2e/src/lib/feature-support.ts index d8a2e4de5b..c9d9234200 100644 --- a/testing/e2e/src/lib/feature-support.ts +++ b/testing/e2e/src/lib/feature-support.ts @@ -199,17 +199,15 @@ export const matrix: Record> = { // (or per-feature override in `features.ts`) must opt into combined mode // — otherwise the engine takes the legacy finalization path, which makes // an extra request that this feature's fixture doesn't model. - // - // openrouter (#612): its default test model `openai/gpt-4o` is a member of - // OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS, so the chat adapter's - // `supportsCombinedToolsAndSchema()` returns true and the engine takes the - // native combined path — same single-request shape this fixture models. + // openrouter and openrouter-responses both default to openai/gpt-4o, + // which is in OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS. 'agentic-structured-stream': new Set([ 'openai', 'anthropic', 'gemini', 'grok', 'openrouter', + 'openrouter-responses', 'byteplus', ]), // Bedrock excluded: the default e2e model (openai.gpt-oss-120b) is text-only From 1fbe85f5014af5cd74243bff609f0b6e11a0e9e2 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 19 Aug 2026 13:00:20 +0200 Subject: [PATCH 7/8] feat(examples): add OpenRouter combined tools and schema demo --- docs/adapters/openrouter.md | 4 + docs/structured-outputs/with-tools.md | 2 + .../ts-react-chat/src/components/Header.tsx | 13 + examples/ts-react-chat/src/routeTree.gen.ts | 43 ++ .../src/routes/api.openrouter-combined.ts | 273 ++++++++++++ .../generations.openrouter-combined.tsx | 394 ++++++++++++++++++ examples/ts-react-chat/src/routes/index.tsx | 8 + 7 files changed, 737 insertions(+) create mode 100644 examples/ts-react-chat/src/routes/api.openrouter-combined.ts create mode 100644 examples/ts-react-chat/src/routes/generations.openrouter-combined.tsx diff --git a/docs/adapters/openrouter.md b/docs/adapters/openrouter.md index a45724574a..91ffdb9dc8 100644 --- a/docs/adapters/openrouter.md +++ b/docs/adapters/openrouter.md @@ -237,6 +237,10 @@ See [Structured Outputs with tools](../structured-outputs/with-tools) for the event order, and [Middleware](../advanced/middleware) for how `structuredOutput` phase behaves on this path. +To try this in a browser, run `examples/ts-react-chat` and open +`/generations/openrouter-combined`. The page shows the tool call, the typed +object, and the adapter call counts. `structuredOutputStream` must stay at 0. + ## Environment Variables Set your API key in environment variables: diff --git a/docs/structured-outputs/with-tools.md b/docs/structured-outputs/with-tools.md index b2ee1d7460..8e64503f52 100644 --- a/docs/structured-outputs/with-tools.md +++ b/docs/structured-outputs/with-tools.md @@ -19,6 +19,8 @@ This page covers the combined `outputSchema` + `tools` shape, including the paus On adapters that support native combined mode (modern OpenAI, Claude 4.5+, Gemini 3.x, Grok 4.x, and [OpenRouter on those same upstream models](../adapters/openrouter.md#tools-and-structured-output-together)), `chat({ tools, outputSchema, stream: true })` is one streaming request. The extra finalization call does not run. +The React chat example has a live OpenRouter page at `/generations/openrouter-combined`. + > **Note:** If you're not yet familiar with how tools work in TanStack AI, read [Tool Architecture](../tools/tool-architecture) and [Server Tools](../tools/server-tools) first. The patterns here build on the regular agent-loop flow — `outputSchema` just adds a final terminal event. ## Non-streaming: tools first, then structured object diff --git a/examples/ts-react-chat/src/components/Header.tsx b/examples/ts-react-chat/src/components/Header.tsx index 8660905a3c..aba382f7a3 100644 --- a/examples/ts-react-chat/src/components/Header.tsx +++ b/examples/ts-react-chat/src/components/Header.tsx @@ -204,6 +204,19 @@ export default function Header() { Structured Output + setIsOpen(false)} + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-1" + activeProps={{ + className: + 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-1', + }} + > + + OpenRouter Combined + + setIsOpen(false)} diff --git a/examples/ts-react-chat/src/routeTree.gen.ts b/examples/ts-react-chat/src/routeTree.gen.ts index e42124b47d..bc690e9d93 100644 --- a/examples/ts-react-chat/src/routeTree.gen.ts +++ b/examples/ts-react-chat/src/routeTree.gen.ts @@ -35,6 +35,7 @@ import { Route as GenerationsStructuredOutputRouteImport } from './routes/genera import { Route as GenerationsStructuredChatRouteImport } from './routes/generations.structured-chat' import { Route as GenerationsSpeechRouteImport } from './routes/generations.speech' import { Route as GenerationsPersistentGenerationRouteImport } from './routes/generations.persistent-generation' +import { Route as GenerationsOpenrouterCombinedRouteImport } from './routes/generations.openrouter-combined' import { Route as GenerationsImageRouteImport } from './routes/generations.image' import { Route as GenerationsAudioRouteImport } from './routes/generations.audio' import { Route as ExampleRuntimeContextRouteImport } from './routes/example.runtime-context' @@ -48,6 +49,7 @@ import { Route as ApiSandboxTriageRouteImport } from './routes/api.sandbox-triag import { Route as ApiSandboxRepoReportRouteImport } from './routes/api.sandbox-repo-report' import { Route as ApiResumableRouteImport } from './routes/api.resumable' import { Route as ApiPersistentChatRouteImport } from './routes/api.persistent-chat' +import { Route as ApiOpenrouterCombinedRouteImport } from './routes/api.openrouter-combined' import { Route as ApiMcpStatusRouteImport } from './routes/api.mcp-status' import { Route as ApiMcpPoolRouteImport } from './routes/api.mcp-pool' import { Route as ApiMcpManualRouteImport } from './routes/api.mcp-manual' @@ -203,6 +205,12 @@ const GenerationsPersistentGenerationRoute = path: '/generations/persistent-generation', getParentRoute: () => rootRouteImport, } as any) +const GenerationsOpenrouterCombinedRoute = + GenerationsOpenrouterCombinedRouteImport.update({ + id: '/generations/openrouter-combined', + path: '/generations/openrouter-combined', + getParentRoute: () => rootRouteImport, + } as any) const GenerationsImageRoute = GenerationsImageRouteImport.update({ id: '/generations/image', path: '/generations/image', @@ -268,6 +276,11 @@ const ApiPersistentChatRoute = ApiPersistentChatRouteImport.update({ path: '/api/persistent-chat', getParentRoute: () => rootRouteImport, } as any) +const ApiOpenrouterCombinedRoute = ApiOpenrouterCombinedRouteImport.update({ + id: '/api/openrouter-combined', + path: '/api/openrouter-combined', + getParentRoute: () => rootRouteImport, +} as any) const ApiMcpStatusRoute = ApiMcpStatusRouteImport.update({ id: '/api/mcp-status', path: '/api/mcp-status', @@ -403,6 +416,7 @@ export interface FileRoutesByFullPath { '/api/mcp-manual': typeof ApiMcpManualRoute '/api/mcp-pool': typeof ApiMcpPoolRoute '/api/mcp-status': typeof ApiMcpStatusRoute + '/api/openrouter-combined': typeof ApiOpenrouterCombinedRoute '/api/persistent-chat': typeof ApiPersistentChatRoute '/api/resumable': typeof ApiResumableRoute '/api/sandbox-repo-report': typeof ApiSandboxRepoReportRoute @@ -416,6 +430,7 @@ export interface FileRoutesByFullPath { '/example/runtime-context': typeof ExampleRuntimeContextRoute '/generations/audio': typeof GenerationsAudioRoute '/generations/image': typeof GenerationsImageRoute + '/generations/openrouter-combined': typeof GenerationsOpenrouterCombinedRoute '/generations/persistent-generation': typeof GenerationsPersistentGenerationRoute '/generations/speech': typeof GenerationsSpeechRoute '/generations/structured-chat': typeof GenerationsStructuredChatRoute @@ -464,6 +479,7 @@ export interface FileRoutesByTo { '/api/mcp-manual': typeof ApiMcpManualRoute '/api/mcp-pool': typeof ApiMcpPoolRoute '/api/mcp-status': typeof ApiMcpStatusRoute + '/api/openrouter-combined': typeof ApiOpenrouterCombinedRoute '/api/persistent-chat': typeof ApiPersistentChatRoute '/api/resumable': typeof ApiResumableRoute '/api/sandbox-repo-report': typeof ApiSandboxRepoReportRoute @@ -477,6 +493,7 @@ export interface FileRoutesByTo { '/example/runtime-context': typeof ExampleRuntimeContextRoute '/generations/audio': typeof GenerationsAudioRoute '/generations/image': typeof GenerationsImageRoute + '/generations/openrouter-combined': typeof GenerationsOpenrouterCombinedRoute '/generations/persistent-generation': typeof GenerationsPersistentGenerationRoute '/generations/speech': typeof GenerationsSpeechRoute '/generations/structured-chat': typeof GenerationsStructuredChatRoute @@ -526,6 +543,7 @@ export interface FileRoutesById { '/api/mcp-manual': typeof ApiMcpManualRoute '/api/mcp-pool': typeof ApiMcpPoolRoute '/api/mcp-status': typeof ApiMcpStatusRoute + '/api/openrouter-combined': typeof ApiOpenrouterCombinedRoute '/api/persistent-chat': typeof ApiPersistentChatRoute '/api/resumable': typeof ApiResumableRoute '/api/sandbox-repo-report': typeof ApiSandboxRepoReportRoute @@ -539,6 +557,7 @@ export interface FileRoutesById { '/example/runtime-context': typeof ExampleRuntimeContextRoute '/generations/audio': typeof GenerationsAudioRoute '/generations/image': typeof GenerationsImageRoute + '/generations/openrouter-combined': typeof GenerationsOpenrouterCombinedRoute '/generations/persistent-generation': typeof GenerationsPersistentGenerationRoute '/generations/speech': typeof GenerationsSpeechRoute '/generations/structured-chat': typeof GenerationsStructuredChatRoute @@ -589,6 +608,7 @@ export interface FileRouteTypes { | '/api/mcp-manual' | '/api/mcp-pool' | '/api/mcp-status' + | '/api/openrouter-combined' | '/api/persistent-chat' | '/api/resumable' | '/api/sandbox-repo-report' @@ -602,6 +622,7 @@ export interface FileRouteTypes { | '/example/runtime-context' | '/generations/audio' | '/generations/image' + | '/generations/openrouter-combined' | '/generations/persistent-generation' | '/generations/speech' | '/generations/structured-chat' @@ -650,6 +671,7 @@ export interface FileRouteTypes { | '/api/mcp-manual' | '/api/mcp-pool' | '/api/mcp-status' + | '/api/openrouter-combined' | '/api/persistent-chat' | '/api/resumable' | '/api/sandbox-repo-report' @@ -663,6 +685,7 @@ export interface FileRouteTypes { | '/example/runtime-context' | '/generations/audio' | '/generations/image' + | '/generations/openrouter-combined' | '/generations/persistent-generation' | '/generations/speech' | '/generations/structured-chat' @@ -711,6 +734,7 @@ export interface FileRouteTypes { | '/api/mcp-manual' | '/api/mcp-pool' | '/api/mcp-status' + | '/api/openrouter-combined' | '/api/persistent-chat' | '/api/resumable' | '/api/sandbox-repo-report' @@ -724,6 +748,7 @@ export interface FileRouteTypes { | '/example/runtime-context' | '/generations/audio' | '/generations/image' + | '/generations/openrouter-combined' | '/generations/persistent-generation' | '/generations/speech' | '/generations/structured-chat' @@ -773,6 +798,7 @@ export interface RootRouteChildren { ApiMcpManualRoute: typeof ApiMcpManualRoute ApiMcpPoolRoute: typeof ApiMcpPoolRoute ApiMcpStatusRoute: typeof ApiMcpStatusRoute + ApiOpenrouterCombinedRoute: typeof ApiOpenrouterCombinedRoute ApiPersistentChatRoute: typeof ApiPersistentChatRoute ApiResumableRoute: typeof ApiResumableRoute ApiSandboxRepoReportRoute: typeof ApiSandboxRepoReportRoute @@ -786,6 +812,7 @@ export interface RootRouteChildren { ExampleRuntimeContextRoute: typeof ExampleRuntimeContextRoute GenerationsAudioRoute: typeof GenerationsAudioRoute GenerationsImageRoute: typeof GenerationsImageRoute + GenerationsOpenrouterCombinedRoute: typeof GenerationsOpenrouterCombinedRoute GenerationsPersistentGenerationRoute: typeof GenerationsPersistentGenerationRoute GenerationsSpeechRoute: typeof GenerationsSpeechRoute GenerationsStructuredChatRoute: typeof GenerationsStructuredChatRoute @@ -985,6 +1012,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof GenerationsPersistentGenerationRouteImport parentRoute: typeof rootRouteImport } + '/generations/openrouter-combined': { + id: '/generations/openrouter-combined' + path: '/generations/openrouter-combined' + fullPath: '/generations/openrouter-combined' + preLoaderRoute: typeof GenerationsOpenrouterCombinedRouteImport + parentRoute: typeof rootRouteImport + } '/generations/image': { id: '/generations/image' path: '/generations/image' @@ -1076,6 +1110,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiPersistentChatRouteImport parentRoute: typeof rootRouteImport } + '/api/openrouter-combined': { + id: '/api/openrouter-combined' + path: '/api/openrouter-combined' + fullPath: '/api/openrouter-combined' + preLoaderRoute: typeof ApiOpenrouterCombinedRouteImport + parentRoute: typeof rootRouteImport + } '/api/mcp-status': { id: '/api/mcp-status' path: '/api/mcp-status' @@ -1263,6 +1304,7 @@ const rootRouteChildren: RootRouteChildren = { ApiMcpManualRoute: ApiMcpManualRoute, ApiMcpPoolRoute: ApiMcpPoolRoute, ApiMcpStatusRoute: ApiMcpStatusRoute, + ApiOpenrouterCombinedRoute: ApiOpenrouterCombinedRoute, ApiPersistentChatRoute: ApiPersistentChatRoute, ApiResumableRoute: ApiResumableRoute, ApiSandboxRepoReportRoute: ApiSandboxRepoReportRoute, @@ -1276,6 +1318,7 @@ const rootRouteChildren: RootRouteChildren = { ExampleRuntimeContextRoute: ExampleRuntimeContextRoute, GenerationsAudioRoute: GenerationsAudioRoute, GenerationsImageRoute: GenerationsImageRoute, + GenerationsOpenrouterCombinedRoute: GenerationsOpenrouterCombinedRoute, GenerationsPersistentGenerationRoute: GenerationsPersistentGenerationRoute, GenerationsSpeechRoute: GenerationsSpeechRoute, GenerationsStructuredChatRoute: GenerationsStructuredChatRoute, diff --git a/examples/ts-react-chat/src/routes/api.openrouter-combined.ts b/examples/ts-react-chat/src/routes/api.openrouter-combined.ts new file mode 100644 index 0000000000..5e4c834d03 --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.openrouter-combined.ts @@ -0,0 +1,273 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + chat, + chatParamsFromRequestBody, + EventType, + toServerSentEventsResponse, + toolDefinition, +} from '@tanstack/ai' +import { + openRouterResponsesText, + openRouterText, +} from '@tanstack/ai-openrouter' +import { z } from 'zod' +import type { ChatMiddleware, StreamChunk } from '@tanstack/ai' + +/** + * Live confirmation page for OpenRouter native combined mode: + * `chat({ tools, outputSchema, stream: true })` must stay on `chatStream` + * and must not call `structuredOutputStream`. + */ + +export const CitySchema = z.object({ + city: z.string().describe('City name'), + code: z.string().describe('Short 3-letter city code from the tool'), + summary: z.string().describe('One sentence that uses the city and the code'), +}) + +export type CityResult = z.infer + +const lookupCityCode = toolDefinition({ + name: 'lookup_city_code', + description: 'Return a short 3-letter code for a city', + inputSchema: z.object({ city: z.string() }), + outputSchema: z.object({ code: z.string() }), +}).server(async ({ city }) => ({ + code: city.slice(0, 3).toUpperCase(), +})) + +const PROVIDERS = ['openrouter', 'openrouter-responses'] as const +type Provider = (typeof PROVIDERS)[number] + +const MODELS = [ + 'openai/gpt-5.5', + 'anthropic/claude-sonnet-4.6', + 'google/gemini-3.5-flash', + 'x-ai/grok-4.5', +] as const +export type CombinedModel = (typeof MODELS)[number] + +export const COMBINED_MODELS: ReadonlyArray<{ + value: CombinedModel + label: string +}> = [ + { value: 'openai/gpt-5.5', label: 'OpenAI GPT-5.5' }, + { value: 'anthropic/claude-sonnet-4.6', label: 'Claude Sonnet 4.6' }, + { value: 'google/gemini-3.5-flash', label: 'Gemini 3.5 Flash' }, + { value: 'x-ai/grok-4.5', label: 'Grok 4.5' }, +] + +function isProvider(value: unknown): value is Provider { + return value === 'openrouter' || value === 'openrouter-responses' +} + +function isModel(value: unknown): value is CombinedModel { + return typeof value === 'string' && MODELS.some((model) => model === value) +} + +export type CombinedModeStats = { + supportsCombined: boolean + chatStreamCalls: number + chatStreamWithSchema: number + structuredOutputStreamCalls: number + structuredOutputCalls: number + nativeCombined: boolean +} + +function createAdapter(provider: Provider, model: CombinedModel) { + return provider === 'openrouter-responses' + ? openRouterResponsesText(model) + : openRouterText(model) +} + +/** + * Count adapter entry points so the UI can prove native combined mode: + * schema rides on `chatStream`, and `structuredOutputStream` stays at 0. + */ +function instrumentAdapter(adapter: ReturnType): { + adapter: ReturnType + snapshot: () => CombinedModeStats +} { + const stats = { + supportsCombined: adapter.supportsCombinedToolsAndSchema() === true, + chatStreamCalls: 0, + chatStreamWithSchema: 0, + structuredOutputStreamCalls: 0, + structuredOutputCalls: 0, + } + + const origChatStream = adapter.chatStream.bind(adapter) + adapter.chatStream = (options) => { + stats.chatStreamCalls += 1 + if (options.outputSchema) { + stats.chatStreamWithSchema += 1 + } + return origChatStream(options) + } + + const origStructuredStream = adapter.structuredOutputStream?.bind(adapter) + if (origStructuredStream) { + adapter.structuredOutputStream = (options) => { + stats.structuredOutputStreamCalls += 1 + return origStructuredStream(options) + } + } + + const origStructured = adapter.structuredOutput.bind(adapter) + adapter.structuredOutput = (options) => { + stats.structuredOutputCalls += 1 + return origStructured(options) + } + + return { + adapter, + snapshot: () => ({ + ...stats, + nativeCombined: + stats.supportsCombined && + stats.structuredOutputStreamCalls === 0 && + stats.structuredOutputCalls === 0 && + stats.chatStreamWithSchema > 0, + }), + } +} + +function phaseCounterMiddleware(): { + middleware: ChatMiddleware + snapshot: () => Record +} { + const counts: Record = {} + return { + middleware: { + name: 'phase-counter', + onChunk(ctx) { + counts[ctx.phase] = (counts[ctx.phase] ?? 0) + 1 + }, + }, + snapshot: () => ({ ...counts }), + } +} + +async function* withTrailingDiagnostics( + stream: AsyncIterable, + combinedSnapshot: () => CombinedModeStats, + phaseSnapshot: () => Record, + model: string, +): AsyncIterable { + let yielded = false + for await (const chunk of stream) { + if ( + chunk.type === EventType.RUN_FINISHED || + chunk.type === EventType.RUN_ERROR + ) { + yielded = true + yield { + type: EventType.CUSTOM, + name: 'combined-mode', + value: combinedSnapshot(), + model, + timestamp: Date.now(), + } + yield { + type: EventType.CUSTOM, + name: 'phase-counts', + value: phaseSnapshot(), + model, + timestamp: Date.now(), + } + } + yield chunk + } + if (!yielded) { + yield { + type: EventType.CUSTOM, + name: 'combined-mode', + value: combinedSnapshot(), + model, + timestamp: Date.now(), + } + yield { + type: EventType.CUSTOM, + name: 'phase-counts', + value: phaseSnapshot(), + model, + timestamp: Date.now(), + } + } +} + +export const Route = createFileRoute('/api/openrouter-combined')({ + server: { + handlers: { + POST: async ({ request }) => { + if (request.signal.aborted) { + return new Response(null, { status: 499 }) + } + + const abortController = new AbortController() + const onAbort = () => abortController.abort() + request.signal.addEventListener('abort', onAbort, { once: true }) + if (request.signal.aborted) { + onAbort() + } + + let params: Awaited> + try { + params = await chatParamsFromRequestBody(await request.json()) + } catch (error) { + return new Response( + error instanceof Error ? error.message : 'Bad request', + { status: 400 }, + ) + } + + try { + const provider: Provider = isProvider(params.forwardedProps.provider) + ? params.forwardedProps.provider + : 'openrouter' + const model: CombinedModel = isModel(params.forwardedProps.model) + ? params.forwardedProps.model + : 'openai/gpt-5.5' + + const { adapter, snapshot: combinedSnapshot } = instrumentAdapter( + createAdapter(provider, model), + ) + const counter = phaseCounterMiddleware() + + const streamIterable = chat({ + adapter, + messages: params.messages, + tools: [lookupCityCode], + outputSchema: CitySchema, + stream: true, + middleware: [counter.middleware], + threadId: params.threadId, + runId: params.runId, + abortController, + }) as AsyncIterable + + const withDiagnostics = withTrailingDiagnostics( + streamIterable, + combinedSnapshot, + counter.snapshot, + adapter.model, + ) + + return toServerSentEventsResponse(withDiagnostics, { + abortController, + }) + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : 'An error occurred' + console.error('[api/openrouter-combined] Error:', error) + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } finally { + request.signal.removeEventListener('abort', onAbort) + } + }, + }, + }, +}) diff --git a/examples/ts-react-chat/src/routes/generations.openrouter-combined.tsx b/examples/ts-react-chat/src/routes/generations.openrouter-combined.tsx new file mode 100644 index 0000000000..457c15fda3 --- /dev/null +++ b/examples/ts-react-chat/src/routes/generations.openrouter-combined.tsx @@ -0,0 +1,394 @@ +import { useId, useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { EventType } from '@tanstack/ai' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { CitySchema, COMBINED_MODELS } from './api.openrouter-combined' +import type { + CityResult, + CombinedModeStats, + CombinedModel, +} from './api.openrouter-combined' +import type { StreamChunk } from '@tanstack/ai' + +const SAMPLE_PROMPT = + 'Call lookup_city_code for Paris, then return the structured object with city, code, and a one-sentence summary.' + +type Provider = 'openrouter' | 'openrouter-responses' + +function isProvider(value: string): value is Provider { + return value === 'openrouter' || value === 'openrouter-responses' +} + +function isCombinedModel(value: string): value is CombinedModel { + return COMBINED_MODELS.some((option) => option.value === value) +} + +function isPhaseCounts(value: unknown): value is Record { + if (typeof value !== 'object' || value === null) return false + return Object.values(value).every((item) => typeof item === 'number') +} + +function isCombinedStats(value: unknown): value is CombinedModeStats { + if (typeof value !== 'object' || value === null) return false + if ( + !('supportsCombined' in value) || + !('chatStreamCalls' in value) || + !('chatStreamWithSchema' in value) || + !('structuredOutputStreamCalls' in value) || + !('structuredOutputCalls' in value) || + !('nativeCombined' in value) + ) { + return false + } + return ( + typeof value.supportsCombined === 'boolean' && + typeof value.chatStreamCalls === 'number' && + typeof value.chatStreamWithSchema === 'number' && + typeof value.structuredOutputStreamCalls === 'number' && + typeof value.structuredOutputCalls === 'number' && + typeof value.nativeCombined === 'boolean' + ) +} + +function OpenRouterCombinedPage() { + const providerId = useId() + const modelId = useId() + const promptId = useId() + const statusId = useId() + const [prompt, setPrompt] = useState(SAMPLE_PROMPT) + const [provider, setProvider] = useState('openrouter') + const [model, setModel] = useState('openai/gpt-5.5') + const [error, setError] = useState(null) + const [phaseCounts, setPhaseCounts] = useState | null>( + null, + ) + const [combined, setCombined] = useState(null) + + const resetLocal = () => { + setError(null) + setPhaseCounts(null) + setCombined(null) + } + + const handleChunk = (chunk: StreamChunk) => { + if (chunk.type !== EventType.CUSTOM) return + + if (chunk.name === 'combined-mode' && isCombinedStats(chunk.value)) { + setCombined(chunk.value) + } else if (chunk.name === 'phase-counts' && isPhaseCounts(chunk.value)) { + setPhaseCounts(chunk.value) + } + } + + const chat = useChat({ + threadId: 'openrouter-combined:useChat', + outputSchema: CitySchema, + connection: fetchServerSentEvents('/api/openrouter-combined'), + forwardedProps: { provider, model }, + onChunk: handleChunk, + onError: (err) => { + setError(err.message) + }, + }) + + const toolCalls = chat.messages + .flatMap((message) => message.parts) + .filter((part) => part.type === 'tool-call') + + const result: CityResult | null = chat.final ?? null + const isLoading = chat.isLoading + const structuredOutputPhase = phaseCounts?.structuredOutput ?? 0 + const nativeCombined = + combined?.nativeCombined === true && structuredOutputPhase === 0 + + const handleGenerate = async () => { + if (!prompt.trim()) return + resetLocal() + chat.clear() + await chat.sendMessage(prompt.trim()) + } + + const handleAbort = () => { + chat.stop() + setError('Aborted') + } + + const handleReset = () => { + resetLocal() + chat.clear() + } + + return ( +
+
+

OpenRouter combined mode

+

+ This page calls chat() with + both tools and{' '} + outputSchema. Native combined + mode is working when the model calls{' '} + lookup_city_code, returns a + typed object, and{' '} + structuredOutputStream stays + at 0. +

+
+ +
+
+
+
+ + +
+
+ + +
+
+ +
+ +