diff --git a/packages/core/src/plugin/provider/index.ts b/packages/core/src/plugin/provider/index.ts index fd02d322a1f9..ddfb220b14e1 100644 --- a/packages/core/src/plugin/provider/index.ts +++ b/packages/core/src/plugin/provider/index.ts @@ -22,6 +22,7 @@ import { OpenAIPlugin } from "./openai" import { OpenAICompatiblePlugin } from "./openai-compatible" import { OpencodePlugin } from "./opencode" import { OpenRouterPlugin } from "./openrouter" +import { OrcaRouterPlugin } from "./orcarouter" import { PerplexityPlugin } from "./perplexity" import { SapAICorePlugin } from "./sap-ai-core" import { TogetherAIPlugin } from "./togetherai" @@ -56,6 +57,7 @@ export const ProviderPlugins = [ OpenAICompatiblePlugin, OpenAIPlugin, OpenRouterPlugin, + OrcaRouterPlugin, PerplexityPlugin, SapAICorePlugin, TogetherAIPlugin, diff --git a/packages/core/src/plugin/provider/orcarouter.ts b/packages/core/src/plugin/provider/orcarouter.ts new file mode 100644 index 000000000000..92767f74993f --- /dev/null +++ b/packages/core/src/plugin/provider/orcarouter.ts @@ -0,0 +1,16 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const OrcaRouterPlugin = PluginV2.define({ + id: PluginV2.ID.make("orcarouter"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.make("orcarouter")) return + evt.provider.options.headers["HTTP-Referer"] ??= "https://opencode.ai/" + evt.provider.options.headers["X-Title"] ??= "opencode" + }), + } + }), +}) diff --git a/packages/core/test/plugin/provider-orcarouter.test.ts b/packages/core/test/plugin/provider-orcarouter.test.ts new file mode 100644 index 000000000000..cd34cd569a4d --- /dev/null +++ b/packages/core/test/plugin/provider-orcarouter.test.ts @@ -0,0 +1,102 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" +import { OrcaRouterPlugin } from "@opencode-ai/core/plugin/provider/orcarouter" +import { expectPluginRegistered, it, provider } from "./provider-helper" + +describe("OrcaRouterPlugin", () => { + it.effect("is registered so legacy referer headers can be applied", () => + Effect.sync(() => + expectPluginRegistered( + ProviderPlugins.map((item) => item.id), + "orcarouter", + ), + ), + ) + + it.effect("applies legacy referer headers only to orcarouter", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OrcaRouterPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("orcarouter", { + options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, + }), + cancel: false, + }, + ) + const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("openrouter"), cancel: false }) + expect(result.provider.options.headers).toEqual({ + Existing: "value", + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + }) + expect(ignored.provider.options.headers).toEqual({}) + }), + ) + + it.effect("uses the exact OrcaRouter header casing and set", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OrcaRouterPlugin) + const result = yield* plugin.trigger("provider.update", {}, { provider: provider("orcarouter"), cancel: false }) + + expect(result.provider.options.headers).toEqual({ + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + }) + expect(result.provider.options.headers).not.toHaveProperty("http-referer") + expect(result.provider.options.headers).not.toHaveProperty("x-title") + expect(result.provider.options.headers).not.toHaveProperty("X-Source") + }), + ) + + it.effect("lets configured OrcaRouter headers override defaults", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OrcaRouterPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("orcarouter", { + options: { + headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, + body: {}, + aisdk: { provider: {}, request: {} }, + }, + }), + cancel: false, + }, + ) + + expect(result.provider.options.headers).toEqual({ + "HTTP-Referer": "https://example.com/", + "X-Title": "custom-title", + }) + }), + ) + + it.effect("guards headers to the exact orcarouter provider id", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OrcaRouterPlugin) + const ignored = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("custom-orcarouter", { + endpoint: { type: "aisdk", package: "orcarouter" }, + }), + cancel: false, + }, + ) + + expect(ignored.provider.options.headers).toEqual({}) + }), + ) +}) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 063e2800d167..57bc391ef34c 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -427,6 +427,16 @@ function custom(dep: CustomDep): Record { }, }, }), + orcarouter: () => + Effect.succeed({ + autoload: false, + options: { + headers: { + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + }, + }, + }), nvidia: (provider) => Effect.succeed({ autoload: provider.source === "config", diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 33efa0359156..1c107ef3b0af 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -1724,6 +1724,76 @@ OpenCode Zen is a list of tested and verified models provided by the OpenCode te --- +### OrcaRouter + +[OrcaRouter](https://www.orcarouter.ai) is an OpenAI-compatible router that aggregates 150+ models from OpenAI, Anthropic, Google, xAI, DeepSeek and others behind a single API key, and exposes a virtual `orcarouter/auto` router that picks an upstream per request based on a configurable strategy (`cheapest` / `balanced` / `quality` / `adaptive` / `gated_adaptive`). The adaptive strategy is a LinUCB contextual bandit that learns from your own traffic — same endpoint, but a 50-token summary may be routed to a cheap model while a 30K-token refactor goes to a premium one, without any client-side dispatch logic. Routing strategies, model pools, and reward weights are admin-tunable from [the routing console](https://www.orcarouter.ai/console/routing). + +1. Head over to the [OrcaRouter dashboard](https://www.orcarouter.ai/console), click **Create API Key**, and copy the key. + +2. Run the `/connect` command and search for OrcaRouter. + + ```txt + /connect + ``` + +3. Enter the API key for the provider. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Many OrcaRouter models are preloaded by default, run the `/models` command to select the one you want. The smart-routing entry is `orcarouter/auto`. + + ```txt + /models + ``` + + You can also add additional models through your opencode config. + + ```json title="opencode.json" {6} + { + "$schema": "https://opencode.ai/config.json", + "provider": { + "orcarouter": { + "models": { + "somecoolnewmodel": {} + } + } + } + } + ``` + +5. You can also pass OrcaRouter's `extra_body` routing preferences (fallback model list, route mode) per-model. See the [routing docs](https://docs.orcarouter.ai) for the full schema. + + ```json title="opencode.json" + { + "$schema": "https://opencode.ai/config.json", + "provider": { + "orcarouter": { + "models": { + "openai/gpt-5": { + "options": { + "extra_body": { + "models": ["openai/gpt-4o-mini", "openai/gpt-4o"], + "route": "fallback" + } + } + } + } + } + } + } + ``` + + :::note + A few OrcaRouter models (e.g. `anthropic/claude-opus-4.7` and other reasoning-only models) reject `temperature` upstream. If you see a 400 from those models, drop `temperature` from your config. + ::: + +--- + ### LLM Gateway 1. Head over to the [LLM Gateway dashboard](https://llmgateway.io/dashboard), click **Create API Key**, and copy the key.