diff --git a/.github/pr-assets/4823-opper-provider-catalog.png b/.github/pr-assets/4823-opper-provider-catalog.png
new file mode 100644
index 00000000000..374c7b39247
Binary files /dev/null and b/.github/pr-assets/4823-opper-provider-catalog.png differ
diff --git a/.github/pr-assets/4823-opper-provider-note.png b/.github/pr-assets/4823-opper-provider-note.png
new file mode 100644
index 00000000000..a37239de6e4
Binary files /dev/null and b/.github/pr-assets/4823-opper-provider-note.png differ
diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md
index 94c006e724f..24974092f88 100644
--- a/docs-site/src/content/docs/guides/providers.md
+++ b/docs-site/src/content/docs/guides/providers.md
@@ -395,7 +395,7 @@ selectors, then retry. Signing in from a machine with no existing `kiro-cli` ses
## 3. API-key catalog
-opencodex ships 79 built-in presets: 67 key-based, eight OAuth, three local, and one default
+opencodex ships 94 built-in presets: 78 key-based, twelve OAuth, three local, and one default
ChatGPT-forward preset. The dashboard's **Add provider** picker opens a key provider's dashboard,
validates the key, and stores it; validation is provider-specific. Notable entries:
@@ -428,6 +428,15 @@ token group allows (`gpt-5.5` and `gpt-5.1-codex` are seeded). Register at
pins the row near the top of the Add provider picker and marks it as a sponsor, and nothing else about
routing or defaults changes.
+**Opper** is the EU-hosted AI gateway from Opper AI (Stockholm): one key (created at
+[platform.opper.ai](https://platform.opper.ai)) and one OpenAI-compatible endpoint in front of 700+
+models from 30+ providers. Bare model ids such as `claude-sonnet-4-6` or `gpt-5.5` are *pools*: Opper
+picks the provider and region per request, so the seeded ids stay valid as routes come and go. A
+`vendor/model` id (`anthropic/claude-sonnet-4-6`, `aws/claude-sonnet-4-6-eu`) pins one route instead.
+The catalogue is discovered live from `/v3/compat/models` with your key; the public list, including
+region-pinned EU routes, is at [opper.ai/models](https://opper.ai/models). Opper is also a
+[models.dev](https://models.dev) provider (`opper`), which uses the same bare pool ids.
+
| Provider | Base URL |
| --- | --- |
| **OpenAI (API key)** | `https://api.openai.com/v1` |
@@ -475,6 +484,7 @@ routing or defaults changes.
| Xiaomi MiMo | `https://api.xiaomimimo.com/anthropic` |
| Xiaomi MiMo (OpenAI Chat) | `https://api.xiaomimimo.com/v1` |
| Kilo | `https://api.kilo.ai/api/gateway` |
+| Opper | `https://api.opper.ai/v3/compat` |
| GitLab Duo | `https://cloud.gitlab.com/ai/v1/proxy/openai/v1` |
| Cloudflare AI Gateway | `https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic` |
| …and more | opencode zen, Vercel AI Gateway, Venice, NanoGPT, Synthetic, Qianfan, Alibaba, Parallel, ZenMux, LiteLLM |
diff --git a/gui/public/provider-icons/opper.svg b/gui/public/provider-icons/opper.svg
new file mode 100644
index 00000000000..45500826c31
--- /dev/null
+++ b/gui/public/provider-icons/opper.svg
@@ -0,0 +1,3 @@
+
diff --git a/gui/src/provider-icons.ts b/gui/src/provider-icons.ts
index 26a0f42fddb..58ce0b2882a 100644
--- a/gui/src/provider-icons.ts
+++ b/gui/src/provider-icons.ts
@@ -51,6 +51,7 @@ const PROVIDER_ICON_ALIASES: Record = {
"opencode-go": "opencode.svg",
"opencode-zen": "opencode.svg",
openrouter: "openrouter-color.svg",
+ opper: "opper.svg",
qianfan: "qianfan-color.svg",
/*
* Qoder Global and Qoder CN are one brand on two operators (BRIGHT ZENITH
@@ -143,6 +144,7 @@ const PROVIDER_DISPLAY_NAMES: Record = {
"github-copilot": "GitHub Copilot",
"gitlab-duo": "GitLab Duo",
openrouter: "OpenRouter",
+ opper: "Opper",
"opencode-go": "OpenCode Go",
"opencode-free": "OpenCode Free",
"opencode-zen": "OpenCode Zen",
@@ -230,6 +232,7 @@ const MASKED_PROVIDER_ICONS: ReadonlySet = new Set([
"nous.svg",
"novita.svg",
"packycode.svg",
+ "opper.svg",
"siliconflow.svg",
"synthetic.svg",
"zenmux.svg",
diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json
index 2106a9614be..864a10679d1 100644
--- a/scripts/test-layout/layout.json
+++ b/scripts/test-layout/layout.json
@@ -1037,6 +1037,7 @@
"opencode-zen-deepseek-reasoning.test.ts": "providers",
"opencode-zen-rate-limit.test.ts": "providers",
"openrouter-provider-routing.test.ts": "providers",
+ "opper-provider.test.ts": "providers",
"optional-shutdown-hooks.test.ts": "lib",
"orcarouter-provider.test.ts": "providers",
"outbound-body-guard.test.ts": "server",
diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts
index 17916596155..5d905365d4b 100644
--- a/src/providers/registry/entries-extended.ts
+++ b/src/providers/registry/entries-extended.ts
@@ -93,6 +93,10 @@ import {
DIGITALOCEAN_CHAT_COMPLETION_MODELS,
SCALEWAY_SERVERLESS_CHAT_MODELS,
SCALEWAY_MODEL_INPUT_MODALITIES,
+ OPPER_MODELS,
+ OPPER_MODEL_CONTEXT_WINDOWS,
+ OPPER_MODEL_MAX_OUTPUT_TOKENS,
+ OPPER_MODEL_INPUT_MODALITIES,
} from "./model-seeds";
export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [
@@ -935,6 +939,30 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [
noJsonSchemaModels: [...DEEPSEEK_GATEWAY_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS],
},
{ id: "vercel-ai-gateway", label: "Vercel AI Gateway", baseUrl: "https://ai-gateway.vercel.sh/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://vercel.com/dashboard" },
+ {
+ // Opper: EU-hosted AI gateway (Opper AI AB, Stockholm). One OpenAI-compatible endpoint and one
+ // key in front of 30+ upstream providers. Seeded ids are Opper *pools* (bare names such as
+ // `claude-sonnet-4-6`): the gateway chooses the provider/region per request, and a
+ // `vendor/model` id (`anthropic/claude-sonnet-4-6`, `aws/claude-sonnet-4-6-eu`) pins one route.
+ // Live-verified 2026-09-08: GET /v3/compat/models answers 401 without a key, so the default
+ // discovery URL doubles as key validation. Windows, output caps and modalities live in
+ // model-seeds.ts (smallest value / shared modality across each pool's members); live discovery
+ // owns which models exist.
+ id: "opper",
+ label: "Opper",
+ adapter: "openai-chat",
+ baseUrl: "https://api.opper.ai/v3/compat",
+ authKind: "key",
+ dashboardUrl: "https://platform.opper.ai",
+ liveModels: true,
+ preserveCustomDestination: true,
+ defaultModel: "claude-sonnet-4-6",
+ models: OPPER_MODELS,
+ modelContextWindows: OPPER_MODEL_CONTEXT_WINDOWS,
+ modelMaxOutputTokens: OPPER_MODEL_MAX_OUTPUT_TOKENS,
+ modelInputModalities: OPPER_MODEL_INPUT_MODALITIES,
+ note: "EU-hosted AI gateway: one OpenAI-compatible endpoint and one key in front of 30+ providers. Bare model ids are pools (claude-sonnet-4-6, gpt-5.5) and Opper picks the route per request; vendor/model ids (anthropic/claude-sonnet-4-6) pin one provider. The catalogue is discovered live from /v3/compat/models with your key; the public list is at opper.ai/models. Token rates are the model providers' rates with no markup; Opper charges a 3% fee when you buy credits.",
+ },
{
id: "opencode-free",
label: "OpenCode Free",
diff --git a/src/providers/registry/model-seeds.ts b/src/providers/registry/model-seeds.ts
index 94e34b803a5..a0490a69466 100644
--- a/src/providers/registry/model-seeds.ts
+++ b/src/providers/registry/model-seeds.ts
@@ -910,3 +910,46 @@ export const CLINE_PASS_TEXT_ONLY_MODELS = CLINE_PASS_MODALITY_KNOWN_MODELS.filt
export const CLINE_PASS_MODEL_INPUT_MODALITIES: Record = Object.fromEntries(
CLINE_PASS_MODALITY_KNOWN_MODELS.map(id => [id, CLINE_PASS_IMAGE_MODELS.has(id) ? ["text", "image"] : ["text"]]),
);
+
+// Opper seed: bare *pool* names. A pool is every provider Opper serves that model through; Opper
+// picks the route per request. Each name is the `.model` of a `pooled: true` entry in the public
+// catalogue (https://api.opper.ai/v3/models?limit=2000, checked 2026-09-14); `vendor/model` ids
+// (anthropic/claude-sonnet-4-6) pin one route and stay valid, they are just not seeded.
+export const OPPER_MODELS = [
+ "claude-sonnet-4-6",
+ "claude-opus-5",
+ "gpt-5.5",
+ "gpt-5.4-mini",
+ "gemini-3.8-flash",
+ "deepseek-v4-pro",
+ "kimi-k3",
+ "mistral-large-2512",
+];
+// Smallest value across each pool's members in the public catalogue, capped at the lab model's own
+// limit (kimi-k3 output); live discovery owns which models exist.
+export const OPPER_MODEL_CONTEXT_WINDOWS: Record = {
+ "claude-sonnet-4-6": 1_000_000,
+ "claude-opus-5": 1_000_000,
+ "gpt-5.5": 1_050_000,
+ "gpt-5.4-mini": 400_000,
+ "gemini-3.8-flash": 1_048_576,
+ "deepseek-v4-pro": 1_000_000,
+ "kimi-k3": 1_048_576,
+ "mistral-large-2512": 256_000,
+};
+export const OPPER_MODEL_MAX_OUTPUT_TOKENS: Record = {
+ "claude-sonnet-4-6": 64_000,
+ "claude-opus-5": 128_000,
+ "gpt-5.5": 128_000,
+ "gpt-5.4-mini": 128_000,
+ "gemini-3.8-flash": 65_536,
+ "deepseek-v4-pro": 65_536,
+ "kimi-k3": 131_072,
+ "mistral-large-2512": 8_192,
+};
+// Pools whose members do not all accept image input (deepseek-v4-pro: no member does; kimi-k3: the
+// sference route is text-only), so the shared modality set is text.
+export const OPPER_TEXT_ONLY_MODELS = ["deepseek-v4-pro", "kimi-k3"];
+export const OPPER_MODEL_INPUT_MODALITIES: Record = Object.fromEntries(
+ OPPER_MODELS.map(id => [id, OPPER_TEXT_ONLY_MODELS.includes(id) ? ["text"] : ["text", "image"]]),
+);
diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json
index f5c49f9959c..2035b84e890 100644
--- a/tests/fixtures/test-layout-expected.json
+++ b/tests/fixtures/test-layout-expected.json
@@ -863,6 +863,7 @@
"opencode-zen-deepseek-reasoning.test.ts": "providers",
"opencode-zen-rate-limit.test.ts": "providers",
"openrouter-provider-routing.test.ts": "providers",
+ "opper-provider.test.ts": "providers",
"optional-shutdown-hooks.test.ts": "lib",
"orcarouter-provider.test.ts": "providers",
"outbound-body-guard.test.ts": "server",
diff --git a/tests/providers/opper-provider.test.ts b/tests/providers/opper-provider.test.ts
new file mode 100644
index 00000000000..bba3884eae9
--- /dev/null
+++ b/tests/providers/opper-provider.test.ts
@@ -0,0 +1,106 @@
+import { describe, expect, test } from "bun:test";
+import { KEY_LOGIN_PROVIDERS } from "../../src/oauth/key-providers";
+import { deriveProviderPresets, providerConfigSeed } from "../../src/providers/derive";
+import { resolveProviderModelDiscoveryUrl } from "../../src/providers/model-discovery";
+import { PROVIDER_REGISTRY } from "../../src/providers/registry";
+import { routeModel } from "../../src/router";
+import type { OcxConfig } from "../../src/types";
+import { en } from "../../gui/src/i18n/en";
+import { interpolate, type TFn } from "../../gui/src/i18n/shared";
+import { formatProviderDisplayName, providerIconPaint, providerIconSrc } from "../../gui/src/provider-icons";
+
+const englishT: TFn = (key, vars) => interpolate(en[key], vars);
+const OPPER_BASE_URL = "https://api.opper.ai/v3/compat";
+
+function registryEntry() {
+ const entry = PROVIDER_REGISTRY.find(row => row.id === "opper");
+ if (!entry) throw new Error("missing opper registry entry");
+ return entry;
+}
+
+describe("Opper gateway provider", () => {
+ test("is a fixed OpenAI-compatible API-key gateway seeded with bare pool ids", () => {
+ const entry = registryEntry();
+ expect(entry).toMatchObject({
+ label: "Opper",
+ adapter: "openai-chat",
+ baseUrl: OPPER_BASE_URL,
+ authKind: "key",
+ liveModels: true,
+ preserveCustomDestination: true,
+ defaultModel: "claude-sonnet-4-6",
+ });
+ expect(entry.allowBaseUrlOverride).toBeUndefined();
+ expect(entry.models).toContain(entry.defaultModel!);
+ for (const id of entry.models ?? []) {
+ // Pool names carry no vendor prefix; Opper resolves the route per request.
+ expect(id).toMatch(/^[a-z0-9.-]+$/);
+ expect(entry.modelContextWindows?.[id]).toBeGreaterThan(0);
+ expect(entry.modelMaxOutputTokens?.[id]).toBeGreaterThan(0);
+ expect(entry.modelMaxOutputTokens?.[id]).toBeLessThanOrEqual(entry.modelContextWindows?.[id] ?? 0);
+ expect(entry.modelInputModalities?.[id]?.[0]).toBe("text");
+ }
+ // Only pools whose every member takes images advertise the image modality.
+ expect(entry.modelInputModalities?.["claude-sonnet-4-6"]).toEqual(["text", "image"]);
+ expect(entry.modelInputModalities?.["deepseek-v4-pro"]).toEqual(["text"]);
+ expect(entry.modelInputModalities?.["kimi-k3"]).toEqual(["text"]);
+ });
+
+ test("derives the key-login preset and config seed from the registry row", () => {
+ const entry = registryEntry();
+ expect(KEY_LOGIN_PROVIDERS.opper).toMatchObject({
+ label: "Opper",
+ adapter: "openai-chat",
+ baseUrl: OPPER_BASE_URL,
+ defaultModel: "claude-sonnet-4-6",
+ });
+ expect(deriveProviderPresets().find(row => row.id === "opper")).toMatchObject({ auth: "key" });
+ const seed = providerConfigSeed(entry);
+ expect(seed).toMatchObject({
+ adapter: "openai-chat",
+ baseUrl: OPPER_BASE_URL,
+ liveModels: true,
+ models: entry.models,
+ modelContextWindows: entry.modelContextWindows,
+ });
+ expect(seed).not.toHaveProperty("preserveCustomDestination");
+ expect(seed).not.toHaveProperty("note");
+ });
+
+ test("validates keys against the authenticated compat model list", () => {
+ // /v3/compat/models answers 401 without a key, so the default discovery URL is a real
+ // key check and the preset must not opt into `apiKeyValidation: "unknown"`.
+ expect(registryEntry().apiKeyValidation).toBeUndefined();
+ expect(resolveProviderModelDiscoveryUrl(
+ "opper",
+ { adapter: "openai-chat", baseUrl: OPPER_BASE_URL, authMode: "key" },
+ OPPER_BASE_URL,
+ `${OPPER_BASE_URL}/models`,
+ )).toBe("https://api.opper.ai/v3/compat/models");
+ });
+
+ test("routes the provider prefix and forwards pool and pinned model ids unchanged", () => {
+ const config: OcxConfig = {
+ port: 10100,
+ defaultProvider: "opper",
+ providers: {
+ opper: { ...providerConfigSeed(registryEntry()), apiKey: "test-key" },
+ },
+ };
+ // Bare pool id: Opper picks the provider.
+ const pooled = routeModel(config, "opper/claude-sonnet-4-6");
+ expect(pooled.modelId).toBe("claude-sonnet-4-6");
+ expect(pooled.provider.baseUrl).toBe(OPPER_BASE_URL);
+ expect(pooled.provider.adapter).toBe("openai-chat");
+ // Vendor-prefixed id pins one route; the extra slash must survive the provider split.
+ const pinned = routeModel(config, "opper/anthropic/claude-sonnet-4-6");
+ expect(pinned.modelId).toBe("anthropic/claude-sonnet-4-6");
+ expect(pinned.provider.baseUrl).toBe(OPPER_BASE_URL);
+ });
+
+ test("ships a masked single-ink mark and a brand display name in the dashboard", () => {
+ expect(providerIconSrc("opper")).toBe("/provider-icons/opper.svg");
+ expect(providerIconPaint(providerIconSrc("opper"))).toBe("mask");
+ expect(formatProviderDisplayName("opper", englishT)).toBe("Opper");
+ });
+});
diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts
index 0582bd5072c..2b3d54b8e84 100644
--- a/tests/providers/provider-registry-parity.test.ts
+++ b/tests/providers/provider-registry-parity.test.ts
@@ -38,7 +38,7 @@ const EXPECTED_KEY_PROVIDER_IDS = [
"deepseek", "cerebras", "chutes", "deepinfra", "hyperbolic", "nscale", "vultr", "baseten", "commandcode", "sambanova", "nebius", "digitalocean", "scaleway", "featherless", "novita", "together", "fireworks", "firepass", "moonshot",
"huggingface", "nvidia", "venice", "zai", "zhipu-bigmodel", "zhipu-bigmodel-coding", "zhipu-bigmodel-responses", "nanogpt", "synthetic", "siliconflow", "qwen-cloud", "tencent-coding-plan",
"volcengine", "volcengine-coding-plan", "volcengine-agent-plan", "qianfan", "alibaba", "alibaba-token-plan", "alibaba-token-plan-intl", "parallel", "zenmux", "litellm", "ollama-cloud", "mistral",
- "minimax", "minimax-cn", "kimi-code", "opencode-zen", "vercel-ai-gateway",
+ "minimax", "minimax-cn", "kimi-code", "opencode-zen", "vercel-ai-gateway", "opper",
"opencode-free", "xiaomi", "xiaomi-mimo", "kilo", "mimo-free", "mimo", "cloudflare-ai-gateway", "cloudflare-workers-ai", "gitlab-duo",
"qoder", "qoder-cn", "codebuddy", "codebuddy-cn",
];