diff --git a/packages/ai/src/providers/openrouter.ts b/packages/ai/src/providers/openrouter.ts index 09c042a98e8b..5ca224653290 100644 --- a/packages/ai/src/providers/openrouter.ts +++ b/packages/ai/src/providers/openrouter.ts @@ -8,6 +8,7 @@ import type { ProviderPackage } from "../provider-package.js" import { SystemOne } from "../protocols/system-one.js" import { OpenAIImages, type OpenAIImageSizing } from "../protocols/openai-images.js" import { MediaRoute } from "../route/media.js" +import { HttpTransport } from "../route/transport/index.js" import { OpenAIChat } from "../protocols/openai-chat.js" import { OpenResponses } from "../protocols/open-responses.js" import { cacheControl } from "../protocols/utils/cache.js" @@ -74,6 +75,9 @@ export interface OpenRouterOptions { export type OpenRouterProviderOptionsInput = OpenRouterOptions +export type OpenRouterResponsesProviderOptionsInput = OpenResponsesProviderOptionsInput & + Pick + export interface OpenRouterDecisionOptions { readonly [key: string]: unknown readonly provider?: OpenRouterProviderRouting @@ -85,7 +89,7 @@ export interface OpenRouterDecisionOptions { export type Config = Omit & ProviderAuthOption<"optional"> & { readonly baseURL?: string - readonly providerOptions?: OpenRouterProviderOptionsInput | OpenResponsesProviderOptionsInput + readonly providerOptions?: OpenRouterProviderOptionsInput | OpenRouterResponsesProviderOptionsInput } export type Settings = ProviderPackage.Settings & @@ -94,14 +98,18 @@ export type Settings = ProviderPackage readonly baseURL?: string } -const Options = Schema.Struct({ - usage: lenient(Schema.Union([Schema.Boolean, JsonObject])), +const SharedOptions = Schema.Struct({ models: lenient(Schema.Array(Schema.String)), provider: lenient(JsonObject), plugins: lenient(Schema.Array(JsonObject)), - web_search_options: lenient(JsonObject), debug: lenient(JsonObject), user: lenient(Schema.String), +}) + +const Options = Schema.Struct({ + ...SharedOptions.fields, + usage: lenient(Schema.Union([Schema.Boolean, JsonObject])), + web_search_options: lenient(JsonObject), reasoning: lenient(JsonObject), }) const decodeOptions = Schema.decodeUnknownSync(Options) @@ -166,12 +174,8 @@ const bodyOptions = (body: OpenAIChat.OpenAIChatBody, input: unknown, maxTokens: ...options, ...body, ...(usage !== undefined ? { usage } : {}), - ...(known.models !== undefined ? { models: known.models } : {}), - ...(known.provider !== undefined ? { provider: known.provider } : {}), - ...(known.plugins !== undefined ? { plugins: known.plugins } : {}), + ...sharedOptions(known), ...(known.web_search_options !== undefined ? { web_search_options: known.web_search_options } : {}), - ...(known.debug !== undefined ? { debug: known.debug } : {}), - ...(known.user !== undefined ? { user: known.user } : {}), ...(known.reasoning !== undefined ? { reasoning: fitReasoning(known.reasoning, maxTokens) } : {}), } } @@ -191,6 +195,32 @@ const imageSizing: OpenAIImageSizing = (request) => { } } +const sharedOptions = (known: typeof SharedOptions.Type) => ({ + ...(known.models !== undefined ? { models: known.models } : {}), + ...(known.provider !== undefined ? { provider: known.provider } : {}), + ...(known.plugins !== undefined ? { plugins: known.plugins } : {}), + ...(known.debug !== undefined ? { debug: known.debug } : {}), + ...(known.user !== undefined ? { user: known.user } : {}), +}) + +const OpenRouterResponsesBody = Schema.Struct({ + ...OpenResponses.OpenResponsesBody.fields, + ...SharedOptions.fields, +}) +type OpenRouterResponsesBody = Schema.Schema.Type + +const responsesProtocol = Protocol.make({ + id: "openrouter-responses", + body: { + schema: OpenRouterResponsesBody, + from: (request) => + OpenResponses.fromRequest(request).pipe( + Effect.map((body) => ({ ...body, ...sharedOptions(decodeOptions(request.providerOptions ?? {})) })), + ), + }, + stream: OpenResponses.protocol.stream, +}) + export const route = Route.make({ id: ADAPTER, provider: id, @@ -204,9 +234,9 @@ const responsesRoute = Route.make({ id: "openrouter-responses", provider: id, providerMetadataKey: "openrouter", - protocol: OpenResponses.protocol, + protocol: responsesProtocol, endpoint: Endpoint.path("/responses", { baseURL }), - transport: OpenResponses.httpTransport, + transport: HttpTransport.sseJson.with(), defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } }, }) @@ -247,7 +277,7 @@ export const configure = (input: Config = {}) => { model: (modelID: string | ModelID) => chatRoute.model({ id: modelID, compatibility: { supportsPromptCacheKey: true } }), responses: (modelID: string | ModelID) => - configuredResponses.model({ id: modelID }), + configuredResponses.model({ id: modelID }), image, decision, configure, @@ -261,8 +291,8 @@ export const responses = provider.responses export const model: ProviderPackage.Model = (modelID, settings) => configure(config(settings)).model(modelID) export const responsesModel: ProviderPackage.Model< - Settings, - OpenResponsesProviderOptionsInput + Settings, + OpenRouterResponsesProviderOptionsInput > = (modelID, settings) => configure(config(settings)).responses(modelID) export const imageModel = (modelID: string, settings?: Settings) => { const input = config(settings) diff --git a/packages/ai/src/providers/openrouter/responses.ts b/packages/ai/src/providers/openrouter/responses.ts index 34a9c7f2e13b..efabaea27ded 100644 --- a/packages/ai/src/providers/openrouter/responses.ts +++ b/packages/ai/src/providers/openrouter/responses.ts @@ -1,4 +1,3 @@ -import type { OpenResponsesProviderOptionsInput } from "../open-responses-options.js" import type { OpenRouter } from "../openrouter.js" export { responsesModel as model } from "../openrouter.js" -export type Settings = OpenRouter.Settings +export type Settings = OpenRouter.Settings diff --git a/packages/ai/test/provider-options/openrouter.types.ts b/packages/ai/test/provider-options/openrouter.types.ts index ce1741711dda..ad1a7375e60f 100644 --- a/packages/ai/test/provider-options/openrouter.types.ts +++ b/packages/ai/test/provider-options/openrouter.types.ts @@ -31,3 +31,25 @@ LLM.request({ // @ts-expect-error OpenRouter usage must be boolean or an option record. providerOptions: { usage: "yes" }, }) + +const responses = OpenRouter.provider.responses("openai/gpt-5.4") + +LLM.request({ + model: responses, + prompt: "Hello", + providerOptions: { + reasoningEffort: "low", + models: ["openai/gpt-5.4-mini"], + provider: { only: ["openai"], sort: "future-sort", data_collection: "future-policy" }, + plugins: [{ id: "future-plugin", enabled: true }], + debug: { echo_upstream_body: true }, + user: "user_123", + }, +}) + +LLM.request({ + model: responses, + prompt: "Hello", + // @ts-expect-error OpenRouter fallback models must be a list of model IDs. + providerOptions: { models: "openai/gpt-5.4-mini" }, +}) diff --git a/packages/ai/test/provider-package.test.ts b/packages/ai/test/provider-package.test.ts index e2b02ddcbcca..46e1877b6008 100644 --- a/packages/ai/test/provider-package.test.ts +++ b/packages/ai/test/provider-package.test.ts @@ -60,9 +60,24 @@ describe("provider package entrypoints", () => { test("maps OpenRouter, DigitalOcean and Venice Responses entrypoints onto provider-owned routes", async () => { const providers = [ - ["openrouter", "https://openrouter.ai/api/v1", await import("@opencode/ai/providers/openrouter/responses")], - ["digitalocean", "https://inference.do-ai.run/v1", await import("@opencode/ai/providers/digitalocean/responses")], - ["venice", "https://api.venice.ai/api/v1", await import("@opencode/ai/providers/venice/responses")], + [ + "openrouter", + "https://openrouter.ai/api/v1", + "openrouter-responses", + await import("@opencode/ai/providers/openrouter/responses"), + ], + [ + "digitalocean", + "https://inference.do-ai.run/v1", + "open-responses", + await import("@opencode/ai/providers/digitalocean/responses"), + ], + [ + "venice", + "https://api.venice.ai/api/v1", + "open-responses", + await import("@opencode/ai/providers/venice/responses"), + ], ] as const const settings = { apiKey: "fixture", @@ -70,11 +85,11 @@ describe("provider package entrypoints", () => { body: { future_option: true }, reasoningEffort: "low", } - for (const [provider, baseURL, module] of providers) { + for (const [provider, baseURL, protocol, module] of providers) { const selected = module.model("gpt-5.4", settings) expect(selected.provider).toBe(provider) expect(selected.route.id).toBe(`${provider}-responses`) - expect(selected.route.protocol).toBe("open-responses") + expect(selected.route.protocol).toBe(protocol) expect(selected.route.endpoint).toMatchObject({ baseURL, path: "/responses" }) expect(selected.route.defaults.headers).toEqual(settings.headers) expect(selected.route.defaults.http?.body).toEqual(settings.body) diff --git a/packages/ai/test/provider/openrouter.test.ts b/packages/ai/test/provider/openrouter.test.ts index 22eb27c4e8f8..16e1b65b2821 100644 --- a/packages/ai/test/provider/openrouter.test.ts +++ b/packages/ai/test/provider/openrouter.test.ts @@ -5,7 +5,7 @@ import { LLMClient } from "../../src/route.js" import { compileRequest } from "../../src/route/client.js" import * as OpenRouter from "../../src/providers/openrouter.js" import { it } from "../lib/effect.js" -import { fixedResponse } from "../lib/http.js" +import { dynamicResponse, fixedResponse } from "../lib/http.js" import { sseEvents } from "../lib/sse.js" describe("OpenRouter", () => { @@ -391,6 +391,46 @@ describe("OpenRouter", () => { }), ) + it.effect("sends OpenRouter routing options on the Responses route", () => + LLMClient.generate( + LLM.request({ + model: OpenRouter.configure({ + apiKey: "test-key", + providerOptions: { + models: ["openai/gpt-5.4-mini"], + provider: { only: ["openai"], allow_fallbacks: false }, + plugins: [{ id: "response-healing" }], + debug: { echo_upstream_body: true }, + user: "user_123", + reasoningEffort: "low", + }, + }).responses("openai/gpt-5.4"), + prompt: "Hello", + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.sync(() => { + expect(input.request.url).toBe("https://openrouter.ai/api/v1/responses") + expect(JSON.parse(input.text)).toMatchObject({ + model: "openai/gpt-5.4", + store: false, + reasoning: { effort: "low" }, + models: ["openai/gpt-5.4-mini"], + provider: { only: ["openai"], allow_fallbacks: false }, + plugins: [{ id: "response-healing" }], + debug: { echo_upstream_body: true }, + user: "user_123", + }) + return input.respond(sseEvents({ type: "response.completed", response: { id: "resp_1" } }), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ), + ) + it.effect("omits the prompt cache key when caching is disabled", () => Effect.gen(function* () { const prepared = yield* compileRequest(