From a85d0d54774802c1ede1ba0e870df412be8b597f Mon Sep 17 00:00:00 2001 From: Felix Wunderlich Date: Thu, 24 Sep 2026 00:01:27 +0200 Subject: [PATCH] feat(providers): add Opper as an OpenAI-compatible gateway Opper is an EU hosted gateway. One key reaches models from OpenAI, Anthropic, Google, Mistral, DeepSeek, Moonshot and open weight vendors at https://api.opper.ai/v3/compat. Mirrors the Requesty provider added in #159: a ProviderSpec on the generic openai_compat gateway path, the matching ProviderConfig block, and tests pinning the wiring. Two provider specific decisions, both covered by tests. Prompt caching is on, because Opper serves Anthropic models and prices cached input separately, and bare pool names start with claude so the existing trigger applies. strip_model_prefix stays off, unlike Forge: Opper ids are bare pool names, but a provider/model id such as azure/gpt-5.5 deliberately pins one route, so stripping the prefix would silently change routing. --- core/config.py | 1 + core/providers/registry.py | 17 ++++++++ tests/test_opper_provider.py | 78 ++++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 tests/test_opper_provider.py diff --git a/core/config.py b/core/config.py index 94f05f46e..d1ad421e8 100644 --- a/core/config.py +++ b/core/config.py @@ -317,6 +317,7 @@ class ProvidersConfig(_Base): openrouter: ProviderConfig = Field(default_factory=ProviderConfig) forge: ProviderConfig = Field(default_factory=ProviderConfig) requesty: ProviderConfig = Field(default_factory=ProviderConfig) + opper: ProviderConfig = Field(default_factory=ProviderConfig) bedrock: ProviderConfig = Field(default_factory=ProviderConfig) anthropic: ProviderConfig = Field(default_factory=ProviderConfig) openai: ProviderConfig = Field(default_factory=ProviderConfig) diff --git a/core/providers/registry.py b/core/providers/registry.py index c68818f74..61d084873 100644 --- a/core/providers/registry.py +++ b/core/providers/registry.py @@ -219,6 +219,23 @@ def _matches_declared_endpoint(self, api_base: str | None) -> bool: default_api_base="https://router.requesty.ai/v1", supports_prompt_caching=True, ), + ProviderSpec( + name="opper", + keywords=("opper",), + env_key="OPPER_API_KEY", + display_name="Opper", + backend="openai_compat", + is_gateway=True, + endpoint_class="gateway", + detect_by_base_keyword="opper.ai", + default_api_base="https://api.opper.ai/v3/compat", + # Opper serves Anthropic models and prices cached input separately, so + # the Anthropic cache-control markers are worth sending. + supports_prompt_caching=True, + # Ids are bare pool names such as ``claude-sonnet-4-6``, but a + # ``provider/model`` id like ``azure/gpt-5.5`` deliberately pins one + # route, so the prefix must survive rather than be stripped. + ), ProviderSpec( name="forge", keywords=("forge",), diff --git a/tests/test_opper_provider.py b/tests/test_opper_provider.py new file mode 100644 index 000000000..d9b40fb5e --- /dev/null +++ b/tests/test_opper_provider.py @@ -0,0 +1,78 @@ +"""Pin the Opper provider as a mirror of the Requesty provider. + +Opper is an EU-hosted OpenAI-compatible gateway wired on the same generic +``openai_compat`` path as OpenRouter and Requesty. These tests assert the +shared gateway wiring, pin the Opper-specific base URL and env var, and lock +the one place Opper differs from the other gateways: its ids are bare pool +names, but a ``provider/model`` id pins a single route, so the prefix must not +be stripped the way Forge's is. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +BACKEND = ROOT / "new_ui" / "backend" +if str(BACKEND) not in sys.path: + sys.path.insert(0, str(BACKEND)) + +from core.providers.registry import find_by_name # noqa: E402 + +OPPER = find_by_name("opper") +REQUESTY = find_by_name("requesty") +FORGE = find_by_name("forge") + + +# ---- registry -------------------------------------------------------------- + + +def test_opper_is_registered() -> None: + assert OPPER is not None + assert OPPER.name == "opper" + assert OPPER.display_name == "Opper" + + +def test_opper_mirrors_the_generic_gateway_wiring() -> None: + assert OPPER is not None and REQUESTY is not None + assert OPPER.backend == REQUESTY.backend == "openai_compat" + assert OPPER.is_gateway is True + assert OPPER.endpoint_class == "gateway" + assert OPPER.is_local is False + assert OPPER.is_oauth is False + + +def test_opper_provider_specific_endpoint() -> None: + assert OPPER is not None + assert OPPER.default_api_base == "https://api.opper.ai/v3/compat" + assert OPPER.env_key == "OPPER_API_KEY" + assert OPPER.detect_by_base_keyword == "opper.ai" + + +def test_opper_does_not_borrow_another_gateways_key_prefix() -> None: + # OpenRouter keys start with ``sk-or-``; Opper keys carry no such marker, + # so the prefix heuristic must stay empty. + assert OPPER is not None + assert OPPER.detect_by_key_prefix == "" + + +def test_opper_sends_anthropic_cache_control() -> None: + # Opper serves Anthropic models and prices cached input separately, so the + # cache-control markers apply, as they do for the other gateways. + assert OPPER is not None + assert OPPER.supports_prompt_caching is True + + +def test_opper_keeps_a_pinned_route_prefix() -> None: + """Unlike Forge, a ``provider/`` prefix on an Opper id is meaningful. + + Opper ids are bare pool names such as ``claude-sonnet-4-6``, where a pool + is every provider serving that model. ``azure/gpt-5.5`` pins one provider + or region instead, so stripping the prefix would silently change routing. + """ + assert OPPER is not None and FORGE is not None + assert FORGE.strip_model_prefix is True + assert OPPER.strip_model_prefix is False