Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 44 additions & 14 deletions packages/ai/src/providers/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -74,6 +75,9 @@ export interface OpenRouterOptions {

export type OpenRouterProviderOptionsInput = OpenRouterOptions

export type OpenRouterResponsesProviderOptionsInput = OpenResponsesProviderOptionsInput &
Pick<OpenRouterOptions, "debug" | "models" | "plugins" | "provider" | "user">

export interface OpenRouterDecisionOptions {
readonly [key: string]: unknown
readonly provider?: OpenRouterProviderRouting
Expand All @@ -85,7 +89,7 @@ export interface OpenRouterDecisionOptions {
export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: OpenRouterProviderOptionsInput | OpenResponsesProviderOptionsInput
readonly providerOptions?: OpenRouterProviderOptionsInput | OpenRouterResponsesProviderOptionsInput
}

export type Settings<Options = OpenRouterProviderOptionsInput> = ProviderPackage.Settings &
Expand All @@ -94,14 +98,18 @@ export type Settings<Options = OpenRouterProviderOptionsInput> = 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)
Expand Down Expand Up @@ -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) } : {}),
}
}
Expand All @@ -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<typeof OpenRouterResponsesBody>

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,
Expand All @@ -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<OpenRouterResponsesBody>(),
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
})

Expand Down Expand Up @@ -247,7 +277,7 @@ export const configure = (input: Config = {}) => {
model: (modelID: string | ModelID) =>
chatRoute.model<OpenRouterProviderOptionsInput>({ id: modelID, compatibility: { supportsPromptCacheKey: true } }),
responses: (modelID: string | ModelID) =>
configuredResponses.model<OpenResponsesProviderOptionsInput>({ id: modelID }),
configuredResponses.model<OpenRouterResponsesProviderOptionsInput>({ id: modelID }),
image,
decision,
configure,
Expand All @@ -261,8 +291,8 @@ export const responses = provider.responses
export const model: ProviderPackage.Model<Settings, OpenRouterProviderOptionsInput> = (modelID, settings) =>
configure(config(settings)).model(modelID)
export const responsesModel: ProviderPackage.Model<
Settings<OpenResponsesProviderOptionsInput>,
OpenResponsesProviderOptionsInput
Settings<OpenRouterResponsesProviderOptionsInput>,
OpenRouterResponsesProviderOptionsInput
> = (modelID, settings) => configure(config(settings)).responses(modelID)
export const imageModel = (modelID: string, settings?: Settings) => {
const input = config(settings)
Expand Down
3 changes: 1 addition & 2 deletions packages/ai/src/providers/openrouter/responses.ts
Original file line number Diff line number Diff line change
@@ -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<OpenResponsesProviderOptionsInput>
export type Settings = OpenRouter.Settings<OpenRouter.OpenRouterResponsesProviderOptionsInput>
22 changes: 22 additions & 0 deletions packages/ai/test/provider-options/openrouter.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
})
25 changes: 20 additions & 5 deletions packages/ai/test/provider-package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,21 +60,36 @@ 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",
headers: { "x-test": "fixture" },
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)
Expand Down
42 changes: 41 additions & 1 deletion packages/ai/test/provider/openrouter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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(
Expand Down
Loading