From 4ec4f274bde6f8718193dbb887056b0e8d21218a Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 29 Sep 2026 23:29:39 -0500 Subject: [PATCH] feat(ai): route OpenRouter models through native APIs --- packages/ai/README.md | 10 +- packages/ai/src/cache-policy.ts | 4 +- packages/ai/src/protocols/meta-responses.ts | 2 +- packages/ai/src/protocols/open-responses.ts | 28 +-- packages/ai/src/protocols/openai-responses.ts | 2 +- packages/ai/src/protocols/utils/openrouter.ts | 154 ++++++++++++++ packages/ai/src/protocols/xai-responses.ts | 2 +- packages/ai/src/providers/openrouter.ts | 199 ++++++++++++++---- ...er-claude-opus-4-7-drives-a-tool-loop.json | 54 ----- ...router-gpt-4o-mini-drives-a-tool-loop.json | 53 ----- ...openrouter-gpt-5-5-drives-a-tool-loop.json | 54 ----- .../openrouter-streams-text.json | 28 --- .../openrouter-streams-tool-call.json | 28 --- .../openrouter-claude-opus-4-7-tool-loop.json | 57 +++++ .../openrouter-reasoning-tool-loop.json | 10 +- .../recordings/openrouter-reasoning.json | 11 +- .../openrouter-gpt-4o-mini-text.json | 37 ++++ .../openrouter-gpt-4o-mini-tool-call.json | 38 ++++ .../openrouter-gpt-4o-mini-tool-loop.json | 56 +++++ .../openrouter-gpt-5-5-tool-loop.json | 57 +++++ .../openrouter-grok-4-3-tool-loop.json | 56 +++++ .../openrouter-muse-spark-1-3-tool-loop.json | 52 +++++ .../ai/test/provider/golden.recorded.test.ts | 16 +- .../openai-chat-reasoning.recorded.test.ts | 2 +- .../openrouter-native.recorded.test.ts | 36 ++++ packages/ai/test/provider/openrouter.test.ts | 175 +++++++++------ 26 files changed, 877 insertions(+), 344 deletions(-) create mode 100644 packages/ai/src/protocols/utils/openrouter.ts delete mode 100644 packages/ai/test/fixtures/recordings/openai-compatible-chat/openrouter-claude-opus-4-7-drives-a-tool-loop.json delete mode 100644 packages/ai/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-4o-mini-drives-a-tool-loop.json delete mode 100644 packages/ai/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-5-5-drives-a-tool-loop.json delete mode 100644 packages/ai/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-text.json delete mode 100644 packages/ai/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-tool-call.json create mode 100644 packages/ai/test/fixtures/recordings/openrouter-messages/openrouter-claude-opus-4-7-tool-loop.json create mode 100644 packages/ai/test/fixtures/recordings/openrouter-responses/openrouter-gpt-4o-mini-text.json create mode 100644 packages/ai/test/fixtures/recordings/openrouter-responses/openrouter-gpt-4o-mini-tool-call.json create mode 100644 packages/ai/test/fixtures/recordings/openrouter-responses/openrouter-gpt-4o-mini-tool-loop.json create mode 100644 packages/ai/test/fixtures/recordings/openrouter-responses/openrouter-gpt-5-5-tool-loop.json create mode 100644 packages/ai/test/fixtures/recordings/openrouter-responses/openrouter-grok-4-3-tool-loop.json create mode 100644 packages/ai/test/fixtures/recordings/openrouter-responses/openrouter-muse-spark-1-3-tool-loop.json create mode 100644 packages/ai/test/provider/openrouter-native.recorded.test.ts diff --git a/packages/ai/README.md b/packages/ai/README.md index 081fe3e48c6..1bcc9ffeb46 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -1203,12 +1203,20 @@ LLM.request({ | ----------------------- | ------------------------------------------------------------------------- | | Anthropic Messages | emits up to 4 `cache_control` markers (4-breakpoint cap enforced) | | Bedrock Converse | emits up to 4 `cachePoint` blocks (4-breakpoint cap enforced) | -| OpenRouter | emits up to 4 `cache_control` markers | +| OpenRouter | Anthropic/Qwen emit explicit markers; other families use upstream caching | | OpenAI Chat / Responses | no-op (implicit caching above 1024 tokens) | | Gemini | no-op (implicit caching on 2.5+; explicit `CachedContent` is out-of-band) | Normalized cache usage is read back into `response.usage.cacheReadInputTokens` and `cacheWriteInputTokens` across every provider. +OpenRouter selects its wire API by model family: `openai/*`, `x-ai/*`, and `meta/*` use Responses; `anthropic/*` uses +Anthropic Messages; other families use Chat Completions. Leading-`~` model aliases follow the same routing. Explicit +`openrouter.chat(id)`, `.responses(id)`, and `.messages(id)` selectors are available for API-specific integrations. +Qwen retains Chat's explicit system/conversation cache markers, while Claude uses native Messages cache blocks. +OpenRouter Responses is stateless (`store: false`); every request includes full history, not `previous_response_id`. +Meta currently accepts only automatic tool selection on both its Chat and Responses upstreams; forced/disabled tool +choices can be rejected by the provider. + ## Providers Provider facades configure endpoint/auth/deployment details first, then expose model selectors that take only a model or deployment id. The selected model carries the executable route value used at runtime. diff --git a/packages/ai/src/cache-policy.ts b/packages/ai/src/cache-policy.ts index ff111e77df9..dac1a91fa4f 100644 --- a/packages/ai/src/cache-policy.ts +++ b/packages/ai/src/cache-policy.ts @@ -49,6 +49,7 @@ const RESPECTS_INLINE_HINTS = new Set([ "zai-coding-messages", "bedrock-converse", "openrouter", + "openrouter-messages", ]) // OpenRouter upstreams other than Anthropic and Alibaba Qwen cache without breakpoints. Gemini uses only the last @@ -168,7 +169,8 @@ const countHints = (request: LLMRequest) => export const applyCachePolicy = (request: LLMRequest): LLMRequest => { if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request const policy = - request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto") + (request.model.route.id === "openrouter" || request.model.route.id === "openrouter-messages") && + (request.cache === undefined || request.cache === "auto") ? openRouterPolicy(request.model.id) : resolve(request.cache) if (!policy.tools && !policy.system && !policy.messages) return request diff --git a/packages/ai/src/protocols/meta-responses.ts b/packages/ai/src/protocols/meta-responses.ts index 79827fa3178..32e42b23b47 100644 --- a/packages/ai/src/protocols/meta-responses.ts +++ b/packages/ai/src/protocols/meta-responses.ts @@ -44,7 +44,7 @@ const ImageItem = Schema.Struct({ error: Schema.optional(Schema.Unknown), }) -const Body = Schema.Struct({ +export const Body = Schema.Struct({ ...OpenResponses.coreFields, input: Schema.Array(Schema.Union([OpenResponses.InputItem, ImageItem])), tools: optionalArray(Schema.Union([OpenResponses.Tool, NativeTool])), diff --git a/packages/ai/src/protocols/open-responses.ts b/packages/ai/src/protocols/open-responses.ts index 129cc1441d0..e2210f9a626 100644 --- a/packages/ai/src/protocols/open-responses.ts +++ b/packages/ai/src/protocols/open-responses.ts @@ -298,18 +298,22 @@ const OpenResponsesBody = Schema.Struct({ }) export type OpenResponsesBody = Schema.Schema.Type -export const OpenResponsesUsage = Schema.Struct({ - input_tokens: Schema.optional(Schema.Number), - input_tokens_details: optionalNull( - Schema.Struct({ - cached_tokens: Schema.optional(Schema.Number), - cache_write_tokens: Schema.optional(Schema.Number), - }), - ), - output_tokens: Schema.optional(Schema.Number), - output_tokens_details: optionalNull(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) })), - total_tokens: Schema.optional(Schema.Number), -}) +// Retain gateway billing extensions in provider metadata without changing normalized token accounting. +export const OpenResponsesUsage = Schema.StructWithRest( + Schema.Struct({ + input_tokens: Schema.optional(Schema.Number), + input_tokens_details: optionalNull( + Schema.Struct({ + cached_tokens: Schema.optional(Schema.Number), + cache_write_tokens: Schema.optional(Schema.Number), + }), + ), + output_tokens: Schema.optional(Schema.Number), + output_tokens_details: optionalNull(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) })), + total_tokens: Schema.optional(Schema.Number), + }), + [Schema.Record(Schema.String, Schema.Unknown)], +) type OpenResponsesUsage = Schema.Schema.Type // The spec requires `id` on every output item, but some gateways drop it from diff --git a/packages/ai/src/protocols/openai-responses.ts b/packages/ai/src/protocols/openai-responses.ts index e36496954b9..babb2fc70d2 100644 --- a/packages/ai/src/protocols/openai-responses.ts +++ b/packages/ai/src/protocols/openai-responses.ts @@ -124,7 +124,7 @@ const OpenAIResponsesCoreFields = { ), } -const OpenAIResponsesBody = Schema.Struct({ +export const OpenAIResponsesBody = Schema.Struct({ ...OpenAIResponsesCoreFields, stream: Schema.Literal(true), }) diff --git a/packages/ai/src/protocols/utils/openrouter.ts b/packages/ai/src/protocols/utils/openrouter.ts new file mode 100644 index 00000000000..b95de8dfb80 --- /dev/null +++ b/packages/ai/src/protocols/utils/openrouter.ts @@ -0,0 +1,154 @@ +export * as OpenRouterWire from "./openrouter.js" + +import { Option, Schema } from "effect" +import { LLMRequest, Message, type ContentPart, type ReasoningPart } from "../../schema/index.js" +import type { OpenResponses } from "../open-responses.js" +import { isRecord, ProviderShared } from "../shared.js" + +const ReplayDetail = Schema.Struct({ + type: Schema.String, + text: Schema.optional(Schema.String), + signature: Schema.optional(Schema.NullOr(Schema.String)), + data: Schema.optional(Schema.String), + encrypted: Schema.optional(Schema.String), +}) +const decodeReplayDetail = Schema.decodeUnknownOption(ReplayDetail) + +export function nativeRequest(request: LLMRequest, format: "responses" | "messages") { + const options = request.providerOptions ?? {} + const reasoning = isRecord(options.reasoning) + ? fitReasoning(options.reasoning, request.generation?.maxTokens) + : undefined + const disabled = reasoning?.enabled === false || reasoning?.effort === "none" + return LLMRequest.update(request, { + providerOptions: + format === "responses" + ? { + ...options, + reasoningEffort: + options.reasoningEffort ?? (typeof reasoning?.effort === "string" ? reasoning.effort : undefined), + } + : { + ...options, + effort: + options.effort ?? (typeof reasoning?.effort === "string" && !disabled ? reasoning.effort : undefined), + thinking: + options.thinking ?? + (() => { + if (disabled) return { type: "disabled" } + if (typeof reasoning?.max_tokens === "number") + return { type: "enabled", budget_tokens: reasoning.max_tokens } + if (reasoning?.enabled === true || typeof reasoning?.effort === "string") + return { type: "adaptive", ...(reasoning.exclude === true ? { display: "omitted" } : {}) } + })(), + }, + messages: request.messages.map((message) => { + if ( + !message.content.some( + (part) => part.type === "reasoning" && part.providerMetadata?.openrouter?.reasoningDetails !== undefined, + ) + ) + return message + return Message.make({ + ...message, + // Chat stores signatures in reasoning_details; native APIs require individual signed blocks/items. + content: message.content.flatMap((part) => { + if (part.type !== "reasoning") return [part] + const details = part.providerMetadata?.openrouter?.reasoningDetails + if (!Array.isArray(details)) return [part] + const replay = details.flatMap((detail) => Option.toArray(decodeReplayDetail(detail))) + if (format === "responses") { + const encrypted = replay.find( + (detail) => detail.type === "reasoning.encrypted" || detail.type === "encrypted", + ) + return [ + { + ...part, + providerMetadata: { + ...part.providerMetadata, + openrouter: { + ...part.providerMetadata?.openrouter, + reasoningEncryptedContent: + part.providerMetadata?.openrouter?.reasoningEncryptedContent ?? + encrypted?.data ?? + encrypted?.encrypted, + }, + }, + }, + ] + } + const blocks = replay.flatMap((detail) => { + if (detail.type === "reasoning.text" && detail.signature) + return [ + { + ...part, + text: detail.text ?? part.text, + providerMetadata: { + ...part.providerMetadata, + openrouter: { ...part.providerMetadata?.openrouter, signature: detail.signature }, + }, + }, + ] + if (detail.type === "reasoning.encrypted" && detail.data) + return [ + { + ...part, + text: "", + providerMetadata: { + ...part.providerMetadata, + openrouter: { ...part.providerMetadata?.openrouter, redactedData: detail.data }, + }, + }, + ] + return [] + }) + return blocks.length > 0 ? blocks : [part] + }), + }) + }), + }) +} + +export function responsesOptions>( + request: LLMRequest, + body: Body, +) { + const { usage: _, ...options } = bodyOptions(request.providerOptions, request.generation?.maxTokens) + return { + ...options, + ...body, + store: false as const, + // Native lowering may freeze effort at the history baseline; keep it instead of the current effort. + ...(options.reasoning || body.reasoning ? { reasoning: { ...options.reasoning, ...body.reasoning } } : {}), + } +} + +export const bodyOptions = (input: unknown, maxTokens: number | undefined) => { + const openrouter = isRecord(input) ? input : {} + const { usage, models, provider, plugins, web_search_options, debug, user, reasoning, promptCacheKey, ...options } = + openrouter + return { + ...options, + ...(usage === undefined || usage === true + ? { usage: { include: true } } + : usage === false + ? { usage: { include: false } } + : isRecord(usage) + ? { usage } + : {}), + ...(Array.isArray(models) ? { models } : {}), + ...(isRecord(provider) ? { provider } : {}), + ...(Array.isArray(plugins) ? { plugins } : {}), + ...(isRecord(web_search_options) ? { web_search_options } : {}), + ...(isRecord(debug) ? { debug } : {}), + ...(typeof user === "string" ? { user } : {}), + ...(isRecord(reasoning) ? { reasoning: fitReasoning(reasoning, maxTokens) } : {}), + } +} + +// Anthropic and Alibaba require the thinking budget to remain below the output limit. +function fitReasoning(reasoning: Record, maxTokens: number | undefined) { + return typeof reasoning.max_tokens === "number" + ? { ...reasoning, max_tokens: ProviderShared.fitThinkingBudget(reasoning.max_tokens, maxTokens, 1_024) } + : reasoning +} diff --git a/packages/ai/src/protocols/xai-responses.ts b/packages/ai/src/protocols/xai-responses.ts index bb19ebd843a..68ea661f31b 100644 --- a/packages/ai/src/protocols/xai-responses.ts +++ b/packages/ai/src/protocols/xai-responses.ts @@ -31,7 +31,7 @@ const XAIResponsesHostedToolItem = Schema.Union([ ), ]) -const XAIResponsesBody = Schema.Struct({ +export const XAIResponsesBody = Schema.Struct({ ...OpenResponses.coreFields, input: Schema.Array(Schema.Union([OpenResponses.InputItem, XAIResponsesHostedToolItem])), stream: Schema.Literal(true), diff --git a/packages/ai/src/providers/openrouter.ts b/packages/ai/src/providers/openrouter.ts index 3a09c0826e5..dd8c4186a99 100644 --- a/packages/ai/src/providers/openrouter.ts +++ b/packages/ai/src/providers/openrouter.ts @@ -7,8 +7,13 @@ import { HttpOptions, ProviderID, type CacheHint, type ModelID, type OpenString import type { ProviderPackage } from "../provider-package.js" import { SystemOne } from "../experimental/system-one.js" import { OpenAIChat } from "../protocols/openai-chat.js" +import { OpenAIResponses } from "../protocols/openai-responses.js" +import { XAIResponses } from "../protocols/xai-responses.js" +import { MetaResponses } from "../protocols/meta-responses.js" +import { AnthropicMessages } from "../protocols/anthropic-messages.js" +import { Framing } from "../route/framing.js" import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js" -import { isRecord, ProviderShared } from "../protocols/shared.js" +import { OpenRouterWire } from "../protocols/utils/openrouter.js" export const id = ProviderID.make("openrouter") const baseURL = "https://openrouter.ai/api/v1" @@ -95,6 +100,37 @@ const OpenRouterBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields ]) export type OpenRouterBody = Schema.Schema.Type +const ResponsesReasoning = Schema.StructWithRest( + Schema.Struct({ + enabled: Schema.optional(Schema.Boolean), + exclude: Schema.optional(Schema.Boolean), + effort: Schema.optional(Schema.String), + max_tokens: Schema.optional(Schema.Number), + summary: Schema.optional(Schema.String), + }), + [Schema.Record(Schema.String, Schema.Unknown)], +) +const responsesFields = { + store: Schema.Literal(false), + reasoning: Schema.optional(ResponsesReasoning), +} +const OpenRouterResponsesBody = Schema.StructWithRest( + Schema.Struct({ ...OpenAIResponses.OpenAIResponsesBody.fields, ...responsesFields }), + [Schema.Record(Schema.String, Schema.Unknown)], +) +const OpenRouterXAIResponsesBody = Schema.StructWithRest( + Schema.Struct({ ...XAIResponses.XAIResponsesBody.fields, ...responsesFields }), + [Schema.Record(Schema.String, Schema.Unknown)], +) +const OpenRouterMetaResponsesBody = Schema.StructWithRest( + Schema.Struct({ ...MetaResponses.Body.fields, ...responsesFields }), + [Schema.Record(Schema.String, Schema.Unknown)], +) +const OpenRouterMessagesBody = Schema.StructWithRest(AnthropicMessages.AnthropicMessagesBody, [ + Schema.Record(Schema.String, Schema.Unknown), +]) +type OpenRouterMessagesBody = Schema.Schema.Type + export const protocol = Protocol.make({ id: "openrouter-chat", body: { @@ -123,7 +159,7 @@ export const protocol = Protocol.make({ return { ...body, messages, - ...bodyOptions(request.providerOptions, request.generation?.maxTokens), + ...OpenRouterWire.bodyOptions(request.providerOptions, request.generation?.maxTokens), } as OpenRouterBody }), ), @@ -131,6 +167,58 @@ export const protocol = Protocol.make({ stream: OpenAIChat.protocol.stream, }) +const responsesProtocol = Protocol.make({ + ...OpenAIResponses.protocol, + id: "openrouter-responses", + body: { + schema: OpenRouterResponsesBody, + from: (request) => + OpenAIResponses.protocol.body + .from(OpenRouterWire.nativeRequest(request, "responses")) + .pipe(Effect.map((body) => OpenRouterWire.responsesOptions(request, body))), + }, +}) +const xaiProtocol = Protocol.make({ + ...XAIResponses.protocol, + id: "openrouter-xai-responses", + body: { + schema: OpenRouterXAIResponsesBody, + from: (request) => + XAIResponses.protocol.body + .from(OpenRouterWire.nativeRequest(request, "responses")) + .pipe(Effect.map((body) => OpenRouterWire.responsesOptions(request, body))), + }, +}) +const metaProtocol = Protocol.make({ + ...MetaResponses.protocol, + id: "openrouter-meta-responses", + body: { + schema: OpenRouterMetaResponsesBody, + from: (request) => + MetaResponses.protocol.body + .from(OpenRouterWire.nativeRequest(request, "responses")) + .pipe(Effect.map((body) => OpenRouterWire.responsesOptions(request, body))), + }, +}) +const messagesProtocol = Protocol.make({ + ...AnthropicMessages.protocol, + id: "openrouter-messages", + body: { + schema: OpenRouterMessagesBody, + from: (request) => + AnthropicMessages.protocol.body.from(OpenRouterWire.nativeRequest(request, "messages")).pipe( + Effect.map((body) => { + const { + reasoning: _, + usage: _usage, + ...options + } = OpenRouterWire.bodyOptions(request.providerOptions, body.max_tokens) + return { ...options, ...body } + }), + ), + }, +}) + const cacheControl = () => { const breakpoints = newBreakpoints(4) return (cache: CacheHint | undefined) => { @@ -143,36 +231,6 @@ const cacheControl = () => { } } -// OpenRouter forwards `reasoning.max_tokens` as the upstream thinking budget. Upstreams such as Anthropic and Alibaba -// reject one that is not below the output limit; 1,024 is Anthropic's minimum budget. -const fitReasoning = (reasoning: Record, maxTokens: number | undefined) => - typeof reasoning.max_tokens === "number" - ? { ...reasoning, max_tokens: ProviderShared.fitThinkingBudget(reasoning.max_tokens, maxTokens, 1_024) } - : reasoning - -const bodyOptions = (input: unknown, maxTokens: number | undefined) => { - const openrouter = isRecord(input) ? input : {} - const { usage, models, provider, plugins, web_search_options, debug, user, reasoning, promptCacheKey, ...options } = - openrouter - return { - ...options, - ...(usage === undefined || usage === true - ? { usage: { include: true } } - : usage === false - ? { usage: { include: false } } - : isRecord(usage) - ? { usage } - : {}), - ...(Array.isArray(models) ? { models } : {}), - ...(isRecord(provider) ? { provider } : {}), - ...(Array.isArray(plugins) ? { plugins } : {}), - ...(isRecord(web_search_options) ? { web_search_options } : {}), - ...(isRecord(debug) ? { debug } : {}), - ...(typeof user === "string" ? { user } : {}), - ...(isRecord(reasoning) ? { reasoning: fitReasoning(reasoning, maxTokens) } : {}), - } -} - export const route = Route.make({ id: ADAPTER, provider: id, @@ -182,19 +240,70 @@ export const route = Route.make({ framing: OpenAIChat.framing, }) -export const routes = [route] +const responsesRoute = Route.make({ + id: "openrouter-responses", + provider: id, + providerMetadataKey: "openrouter", + protocol: responsesProtocol, + endpoint: Endpoint.path("/responses", { baseURL }), + framing: Framing.sse, + defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } }, +}) +const xaiRoute = Route.make({ + id: "openrouter-xai-responses", + provider: id, + providerMetadataKey: "openrouter", + protocol: xaiProtocol, + endpoint: Endpoint.path("/responses", { baseURL }), + framing: Framing.sse, + defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } }, +}) +const metaRoute = Route.make({ + id: "openrouter-meta-responses", + provider: id, + providerMetadataKey: "openrouter", + protocol: metaProtocol, + endpoint: Endpoint.path("/responses", { baseURL }), + framing: Framing.sse, + defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } }, +}) +const messagesRoute = Route.make({ + id: "openrouter-messages", + provider: id, + providerMetadataKey: "openrouter", + protocol: messagesProtocol, + endpoint: Endpoint.path("/messages", { baseURL }), + transport: AnthropicMessages.transport(), +}) -const configuredRoute = (input: LanguageModelOptions) => { +export const routes = [route, responsesRoute, xaiRoute, metaRoute, messagesRoute] + +const routeOptions = (input: LanguageModelOptions) => { const { apiKey: _, auth: _auth, baseURL: endpoint, ...rest } = input - return route.with({ + return { ...rest, endpoint: { baseURL: endpoint ?? baseURL }, auth: AuthOptions.bearer(input, "OPENROUTER_API_KEY"), - }) + } } export const configure = (input: LanguageModelOptions = {}) => { - const route = configuredRoute(input) + const options = routeOptions(input) + const chatRoute = route.with(options) + const openaiResponses = responsesRoute.with(options) + const xaiResponses = xaiRoute.with(options) + const metaResponses = metaRoute.with(options) + const anthropicMessages = messagesRoute.with(options) + const chat = (modelID: string | ModelID) => + chatRoute.model({ id: modelID, compatibility: { supportsPromptCacheKey: true } }) + const responses = (modelID: string | ModelID) => { + const model = String(modelID).replace(/^~/, "") + return ( + model.startsWith("x-ai/") ? xaiResponses : model.startsWith("meta/") ? metaResponses : openaiResponses + ).model({ id: modelID }) + } + const messages = (modelID: string | ModelID) => + anthropicMessages.model({ id: modelID }) const evaluation = (modelID: string | ModelID) => SystemOne.model({ id: modelID, @@ -207,14 +316,24 @@ export const configure = (input: LanguageModelOptions = {}) => { }) return { id, - model: (modelID: string | ModelID) => - route.model({ id: modelID, compatibility: { supportsPromptCacheKey: true } }), + model: (modelID: string | ModelID) => { + const model = String(modelID).replace(/^~/, "") + if (model.startsWith("anthropic/")) return messages(modelID) + if (["openai/", "x-ai/", "meta/"].some((prefix) => model.startsWith(prefix))) return responses(modelID) + return chat(modelID) + }, + chat, + responses, + messages, experimental: { evaluation }, configure, } } export const provider = configure() +export const chat = provider.chat +export const responses = provider.responses +export const messages = provider.messages export const experimental = provider.experimental export const model: ProviderPackage.Definition["model"] = ( modelID, @@ -227,3 +346,5 @@ export const model: ProviderPackage.Definition + Effect.gen(function* () { + const events = yield* runWeatherToolLoop( + goldenWeatherToolLoopRequest({ + model: OpenRouter.configure({ + apiKey: process.env.OPENROUTER_API_KEY ?? "fixture", + providerOptions: { reasoning: { effort: "minimal" } }, + }).model("meta/muse-spark-1.3"), + maxTokens: 512, + }), + ) + + expectWeatherToolLoop(events) + expect(LLMResponse.text({ events: events.slice(events.findIndex(LLMEvent.is.stepFinish) + 1) }).trim()).toMatch( + /^Paris is sunny\.?$/, + ) + }), + 60_000, +) diff --git a/packages/ai/test/provider/openrouter.test.ts b/packages/ai/test/provider/openrouter.test.ts index a4b446b17e7..096c2d452c9 100644 --- a/packages/ai/test/provider/openrouter.test.ts +++ b/packages/ai/test/provider/openrouter.test.ts @@ -9,27 +9,29 @@ import { fixedResponse } from "../lib/http.js" import { sseEvents } from "../lib/sse.js" describe("OpenRouter", () => { - it.effect("prepares OpenRouter models through the OpenAI-compatible Chat route", () => - Effect.gen(function* () { - const model = OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini") + it.effect("routes OpenAI, xAI, and Meta models through their native Responses protocols", () => + Effect.forEach( + [ + ["openai/gpt-4o-mini", "openrouter-responses"], + ["x-ai/grok-4.3", "openrouter-xai-responses"], + ["meta/muse-spark-1.3", "openrouter-meta-responses"], + ] as const, + ([id, route]) => + Effect.gen(function* () { + const model = OpenRouter.configure({ apiKey: "test-key" }).model(id) + const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." })) - expect(model).toMatchObject({ - id: "openai/gpt-4o-mini", - provider: "openrouter", - route: { id: "openrouter" }, - }) - expect(model.route.endpoint.baseURL).toBe("https://openrouter.ai/api/v1") - - const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." })) - - expect(prepared.route).toBe("openrouter") - expect(prepared.body).toMatchObject({ - model: "openai/gpt-4o-mini", - messages: [{ role: "user", content: "Say hello." }], - stream: true, - usage: { include: true }, - }) - }), + expect(model.route.endpoint.baseURL).toBe("https://openrouter.ai/api/v1") + expect(prepared.route).toBe(route) + expect(prepared.body).toMatchObject({ + model: id, + input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }], + stream: true, + store: false, + include: ["reasoning.encrypted_content"], + }) + }), + ), ) it.effect("places default cache breakpoints on tools, system boundaries, and the conversation tail", () => @@ -51,23 +53,90 @@ describe("OpenRouter", () => { ) expect(prepared.body.tools?.map((tool) => tool.cache_control)).toEqual([undefined, { type: "ephemeral" }]) + expect(prepared.route).toBe("openrouter-messages") + expect(prepared.body.system).toMatchObject([ + { text: "Base agent", cache_control: { type: "ephemeral" } }, + { text: "Model details" }, + { text: "Project instructions", cache_control: { type: "ephemeral" } }, + ]) expect(prepared.body.messages).toMatchObject([ - { - role: "system", - content: [ - { text: "Base agent", cache_control: { type: "ephemeral" } }, - { text: "Model details" }, - { text: "Project instructions", cache_control: { type: "ephemeral" } }, - ], - }, { role: "user", content: [{ text: "Hello", cache_control: { type: "ephemeral" } }] }, ]) - expect(prepared.body.messages[0]?.content).not.toContainEqual( + expect(prepared.body.system).not.toContainEqual( expect.objectContaining({ text: "Model details", cache_control: expect.anything() }), ) }), ) + it.effect("preserves gateway routing and lowers native effort updates without changing the baseline", () => + Effect.gen(function* () { + const openrouter = OpenRouter.configure({ + apiKey: "test-key", + providerOptions: { provider: { allow_fallbacks: false }, reasoning: { effort: "low" } }, + }) + const messages = [Message.effort({ previous: "high", effort: "low" }), Message.user("Hello")] + const response = yield* compileRequest(LLM.request({ model: openrouter.model("openai/gpt-6-luna"), messages })) + const message = yield* compileRequest( + LLM.request({ model: openrouter.model("anthropic/claude-fable-5.1"), messages }), + ) + + expect(response.body).toMatchObject({ + provider: { allow_fallbacks: false }, + reasoning: { effort: "high" }, + input: [{ type: "configuration_update", reasoning: { effort: "low" } }, { role: "user" }], + }) + expect(message.body).toMatchObject({ + provider: { allow_fallbacks: false }, + output_config: { effort: "high" }, + thinking: { type: "adaptive", block_binding: { prefix_mismatch_behavior: "drop_block" } }, + messages: [{ role: "system", content: [], output_config: { effort: "low" } }, { role: "user" }], + }) + }), + ) + + it.effect("replays saved Chat reasoning details through the native APIs", () => + Effect.gen(function* () { + const openrouter = OpenRouter.configure({ apiKey: "test-key" }) + const message = yield* compileRequest( + LLM.request({ + model: openrouter.model("anthropic/claude-sonnet-4.6"), + messages: [ + Message.assistant({ + type: "reasoning", + text: "AB", + providerMetadata: { + openrouter: { + reasoningDetails: [ + { type: "reasoning.text", text: "A", signature: "first" }, + { type: "reasoning.text", text: "B", signature: "second" }, + ], + }, + }, + }), + ], + }), + ) + const response = yield* compileRequest( + LLM.request({ + model: openrouter.model("meta/muse-spark-1.3"), + messages: [ + Message.assistant({ + type: "reasoning", + text: "Summary", + providerMetadata: { openrouter: { reasoningDetails: [{ type: "reasoning.encrypted", data: "opaque" }] } }, + }), + ], + }), + ) + + expect(message.body.messages[0]?.content).toMatchObject([ + { type: "thinking", thinking: "A", signature: "first" }, + { type: "thinking", thinking: "B", signature: "second" }, + ]) + expect(response.body.input[0]).toMatchObject({ type: "reasoning", encrypted_content: "opaque" }) + }), + ) + it.effect("places default cache breakpoints on OpenRouter latest-model aliases", () => Effect.gen(function* () { const prepared = yield* compileRequest( @@ -80,8 +149,8 @@ describe("OpenRouter", () => { ) expect(prepared.body.tools?.[0]?.cache_control).toEqual({ type: "ephemeral" }) + expect(prepared.body.system).toMatchObject([{ text: "Base agent", cache_control: { type: "ephemeral" } }]) expect(prepared.body.messages).toMatchObject([ - { role: "system", content: [{ text: "Base agent", cache_control: { type: "ephemeral" } }] }, { role: "user", content: [{ text: "Hello", cache_control: { type: "ephemeral" } }] }, ]) }), @@ -143,10 +212,7 @@ describe("OpenRouter", () => { bodies.forEach((body) => { expect(body.tools?.[0]?.cache_control).toBeUndefined() - expect(body.messages).toMatchObject([ - { role: "system", content: "Base agent" }, - { role: "user", content: "Hello" }, - ]) + expect(JSON.stringify(body)).not.toContain("cache_control") }) }), ) @@ -168,14 +234,11 @@ describe("OpenRouter", () => { expect(prepared.body).toMatchObject({ tools: [{ cache_control: { type: "ephemeral" } }], + system: [ + { text: "Base agent", cache_control: { type: "ephemeral", ttl: "1h" } }, + { text: "Project instructions", cache_control: { type: "ephemeral" } }, + ], messages: [ - { - role: "system", - content: [ - { text: "Base agent", cache_control: { type: "ephemeral", ttl: "1h" } }, - { text: "Project instructions", cache_control: { type: "ephemeral" } }, - ], - }, { role: "user", content: [{ text: "Hello", cache_control: { type: "ephemeral" } }], @@ -189,7 +252,7 @@ describe("OpenRouter", () => { Effect.gen(function* () { const prepared = yield* compileRequest( LLM.request({ - model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), + model: OpenRouter.configure({ apiKey: "test-key" }).chat("anthropic/claude-sonnet-4.6"), cache: "none", messages: [ Message.user("Call the tool"), @@ -238,13 +301,7 @@ describe("OpenRouter", () => { }), ) - const system = prepared.body.messages[0] - expect(system?.role).toBe("system") - expect( - system && Array.isArray(system.content) - ? system.content.filter((part) => "cache_control" in part && part.cache_control !== undefined) - : [], - ).toHaveLength(4) + expect(prepared.body.system?.filter((part) => part.cache_control !== undefined)).toHaveLength(4) }), ) @@ -252,7 +309,7 @@ describe("OpenRouter", () => { Effect.gen(function* () { const prepared = yield* compileRequest( LLM.request({ - model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), + model: OpenRouter.configure({ apiKey: "test-key" }).chat("anthropic/claude-sonnet-4.6"), cache: { messages: "latest-assistant" }, messages: [Message.user("Think"), Message.assistant([{ type: "reasoning", text: "Reasoning" }])], }), @@ -270,7 +327,7 @@ describe("OpenRouter", () => { Effect.gen(function* () { const prepared = yield* compileRequest( LLM.request({ - model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), + model: OpenRouter.configure({ apiKey: "test-key" }).chat("anthropic/claude-sonnet-4.6"), system: [ { type: "text", text: "Base agent" }, { type: "text", text: "Project instructions" }, @@ -302,7 +359,7 @@ describe("OpenRouter", () => { model: OpenRouter.configure({ apiKey: "test-key", providerOptions: { usage: false }, - }).model("openai/gpt-4o-mini"), + }).chat("openai/gpt-4o-mini"), cache: "none", prompt: "Hello", }), @@ -350,7 +407,7 @@ describe("OpenRouter", () => { user: "user_123", future_option: { enabled: true }, }, - }).model("anthropic/claude-3.7-sonnet:thinking"), + }).chat("anthropic/claude-3.7-sonnet:thinking"), prompt: "Think briefly.", promptCacheKey: "session_123", }), @@ -425,7 +482,7 @@ describe("OpenRouter", () => { it.effect("preserves the upstream provider finish reason", () => Effect.gen(function* () { - const model = OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6") + const model = OpenRouter.configure({ apiKey: "test-key" }).chat("anthropic/claude-sonnet-4.6") const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe( Effect.provide( fixedResponse( @@ -442,7 +499,7 @@ describe("OpenRouter", () => { it.effect("fails on a mid-stream provider error", () => Effect.gen(function* () { - const model = OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini") + const model = OpenRouter.configure({ apiKey: "test-key" }).chat("openai/gpt-4o-mini") const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe( Effect.provide( fixedResponse( @@ -469,7 +526,7 @@ describe("OpenRouter", () => { ] const prepared = yield* compileRequest( LLM.request({ - model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), + model: OpenRouter.configure({ apiKey: "test-key" }).chat("anthropic/claude-sonnet-4.6"), cache: "none", messages: [ Message.assistant([ @@ -504,7 +561,7 @@ describe("OpenRouter", () => { ] const prepared = yield* compileRequest( LLM.request({ - model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), + model: OpenRouter.configure({ apiKey: "test-key" }).chat("anthropic/claude-sonnet-4.6"), cache: "none", messages: [ Message.assistant({ @@ -545,7 +602,7 @@ describe("OpenRouter", () => { ] const prepared = yield* compileRequest( LLM.request({ - model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), + model: OpenRouter.configure({ apiKey: "test-key" }).chat("anthropic/claude-sonnet-4.6"), cache: "none", messages: [ Message.assistant({ @@ -574,7 +631,7 @@ describe("OpenRouter", () => { Effect.gen(function* () { const prepared = yield* compileRequest( LLM.request({ - model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), + model: OpenRouter.configure({ apiKey: "test-key" }).chat("anthropic/claude-sonnet-4.6"), cache: "none", messages: [Message.assistant({ type: "reasoning", text: "Thinking" })], }),