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", ];