diff --git a/.changeset/openrouter-combined-tools-and-schema.md b/.changeset/openrouter-combined-tools-and-schema.md new file mode 100644 index 000000000..953df9f5f --- /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 `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 ace72e814..016d9b76b 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/openrouter-combined-structured-output.test.ts b/packages/ai-openrouter/src/adapters/openrouter-combined-structured-output.test.ts new file mode 100644 index 000000000..603952746 --- /dev/null +++ b/packages/ai-openrouter/src/adapters/openrouter-combined-structured-output.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it, vi } from 'vitest' +import { + OPENROUTER_CHAT_MODELS, + OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS, +} from '../model-meta' +import { createOpenRouterResponsesText } from './responses-text' +import { createOpenRouterText } from './text' +import type { Tool } from '@tanstack/ai' + +// The adapter constructor instantiates `new OpenRouter(config)`. Mock the SDK +// so construction succeeds; these tests only exercise request building +// (`mapOptionsToRequest`) and the capability gate, never an SDK call. +vi.mock('@openrouter/sdk', () => ({ + OpenRouter: class { + chat = { send: () => undefined } + beta = { responses: { send: () => undefined } } + }, +})) + +// JSON Schema as the engine hands it to the adapter on the combined path. +const outputSchema = { + type: 'object', + properties: { answer: { type: 'string' } }, + required: ['answer'], +} + +const tools: Array = [ + { 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 7659366eb..34dc76300 100644 --- a/packages/ai-openrouter/src/adapters/responses-text.ts +++ b/packages/ai-openrouter/src/adapters/responses-text.ts @@ -7,6 +7,7 @@ import { } from '@tanstack/ai/adapter-internals' import { generateId } from '@tanstack/ai-utils' import { extractRequestOptions } from '../internal/request-options' +import { openRouterSupportsCombinedToolsAndSchema } from '../internal/combined-tools-and-schema' import { makeStructuredOutputCompatible } from '../internal/schema-converter' import { convertFunctionToolToResponsesFormat } from '../internal/responses-tool-converter' import { isWebSearchTool } from '../tools/web-search-tool' @@ -29,6 +30,7 @@ import type { } from '@tanstack/ai/adapters' import type { ContentPart, + JSONSchema, ModelMessage, StreamChunk, TextOptions, @@ -1566,6 +1568,23 @@ export class OpenRouterResponsesTextAdapter< ) : undefined + // Native combined mode (#612): the engine populates `options.outputSchema` + // on the `chatStream` call ONLY when this adapter declared + // `supportsCombinedToolsAndSchema()` for the model. When set, attach the + // schema via `text.format: json_schema` alongside `tools` so it rides the + // same streaming request and the engine harvests it from the final-turn + // text. The legacy `structuredOutput*` methods strip `outputSchema` before + // calling this, so the branch only fires on the combined path. + const combinedOutputSchema: JSONSchema | undefined = options.outputSchema + const combinedSchema = + combinedOutputSchema && + this.supportsCombinedToolsAndSchema(options.modelOptions) + ? this.makeStructuredOutputCompatible( + combinedOutputSchema, + combinedOutputSchema.required, + ) + : undefined + const built: Pick< ResponsesRequest, | 'model' @@ -1578,6 +1597,7 @@ export class OpenRouterResponsesTextAdapter< | 'tools' | 'toolChoice' | 'parallelToolCalls' + | 'text' > = { ...modelOptions, model: options.model + variantSuffix, @@ -1596,11 +1616,39 @@ export class OpenRouterResponsesTextAdapter< tools.length > 0 && { tools, }), + ...(combinedSchema && { + // Merge onto any caller-supplied `text` (spread above via + // `...modelOptions`) so sibling fields like `text.verbosity` survive; + // only `text.format` is overridden by the combined-mode schema. + text: { + ...modelOptions.text, + format: { + type: 'json_schema' as const, + name: 'structured_output', + schema: combinedSchema, + strict: true, + }, + }, + }), } return built } + /** + * Native combined tools + `outputSchema` (#612). OpenRouter routes to many + * upstream providers, so capability is per-request: `modelOptions.models` + * can add fallback routes, and native combined mode is safe only when every + * possible routed model supports it. `:variant` suffixes are routing + * directives and do not change combined-mode support. Models not in the set + * fall back to the legacy finalization path. + */ + supportsCombinedToolsAndSchema( + modelOptions?: OpenRouterResponsesTextProviderOptions, + ): boolean { + return openRouterSupportsCombinedToolsAndSchema(this.model, modelOptions) + } + /** * Convert a list of ModelMessage to OpenRouter's `InputsUnion` array form. * Emits camelCase shapes (`callId`, `imageUrl`, `videoUrl`, `fileData`, diff --git a/packages/ai-openrouter/src/adapters/text.ts b/packages/ai-openrouter/src/adapters/text.ts index 09df05b35..4855bc00c 100644 --- a/packages/ai-openrouter/src/adapters/text.ts +++ b/packages/ai-openrouter/src/adapters/text.ts @@ -8,6 +8,7 @@ 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' @@ -27,6 +28,7 @@ import type { } from '@tanstack/ai/adapters' import type { ContentPart, + JSONSchema, ModelMessage, StreamChunk, TextOptions, @@ -1184,6 +1186,24 @@ 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: JSONSchema | undefined = options.outputSchema + const combinedSchema = + combinedOutputSchema && + this.supportsCombinedToolsAndSchema(options.modelOptions) + ? 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,34 @@ 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-request: `modelOptions.models` + * can add fallback routes, and native combined mode is safe only when every + * possible routed model supports it. `:variant` suffixes are routing + * directives and do not change combined-mode support. Models not in the set + * fall back to the legacy finalization path. + */ + supportsCombinedToolsAndSchema( + modelOptions?: ResolveProviderOptions, + ): boolean { + return openRouterSupportsCombinedToolsAndSchema(this.model, modelOptions) + } + /** * 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/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 39ffbc9e1..44b0b89a7 100644 --- a/packages/ai-openrouter/src/model-meta.ts +++ b/packages/ai-openrouter/src/model-meta.ts @@ -15623,3 +15623,114 @@ 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 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). + */ +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 + // 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', +] 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/vite.config.ts b/packages/ai-openrouter/vite.config.ts index 0e7e7eaea..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'], @@ -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, }), diff --git a/testing/e2e/src/lib/feature-support.ts b/testing/e2e/src/lib/feature-support.ts index 01738f1db..b466ca7dc 100644 --- a/testing/e2e/src/lib/feature-support.ts +++ b/testing/e2e/src/lib/feature-support.ts @@ -171,11 +171,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', ]), // Bedrock excluded: the default e2e model (openai.gpt-oss-120b) is text-only // (input: ['text'], no vision) — image input isn't supported, so the