From a7999f641bfe80e01ffe35d7a131edc4fd1904e8 Mon Sep 17 00:00:00 2001 From: Season Date: Thu, 25 Jun 2026 06:40:42 +0800 Subject: [PATCH 1/3] 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 000000000..940ca1e88 --- /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 7659366eb..e7c6d3ba9 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' @@ -1566,6 +1567,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' @@ -1578,6 +1597,7 @@ export class OpenRouterResponsesTextAdapter< | 'tools' | 'toolChoice' | 'parallelToolCalls' + | 'text' > = { ...modelOptions, model: options.model + variantSuffix, @@ -1596,11 +1616,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 09df05b35..4dcef658b 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 e55f9a9c7..e2b7c7b5d 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 42122cd2c..8d6b44f40 100644 --- a/packages/ai-openrouter/src/model-meta.ts +++ b/packages/ai-openrouter/src/model-meta.ts @@ -15644,3 +15644,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 000000000..e0f140d14 --- /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 5e722de63..6ea6aeead 100644 --- a/testing/e2e/src/lib/feature-support.ts +++ b/testing/e2e/src/lib/feature-support.ts @@ -140,11 +140,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', ]), 'multimodal-image': new Set([ 'openai', From 608f0c7c6c930838f172e37028411d6ed92bf071 Mon Sep 17 00:00:00 2001 From: Season Date: Thu, 25 Jun 2026 08:11:36 +0800 Subject: [PATCH 2/3] 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 940ca1e88..953df9f5f 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 30739f2c4..6b941751b 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 e7c6d3ba9..842bf82cd 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, @@ -1574,11 +1575,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, @@ -1637,14 +1637,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 4dcef658b..d1b742dfc 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 000000000..9892a1ff2 --- /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 8d6b44f40..04523948b 100644 --- a/packages/ai-openrouter/src/model-meta.ts +++ b/packages/ai-openrouter/src/model-meta.ts @@ -15667,10 +15667,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 @@ -15740,4 +15750,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 e0f140d14..35062cef7 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 0e7e7eaea..c85fc6955 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 0ae4ab6b744e582d6c5f112f24acf50ad5df6406 Mon Sep 17 00:00:00 2001 From: Season Date: Thu, 25 Jun 2026 08:31:39 +0800 Subject: [PATCH 3/3] 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 35062cef7..603952746 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 842bf82cd..34dc76300 100644 --- a/packages/ai-openrouter/src/adapters/responses-text.ts +++ b/packages/ai-openrouter/src/adapters/responses-text.ts @@ -1575,7 +1575,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 d1b742dfc..4855bc00c 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 c85fc6955..fab574d16 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'],