From 996b3669e6718c5c82dc2d4e11e746ddb643fcb7 Mon Sep 17 00:00:00 2001 From: Marc-oss-hub Date: Tue, 11 Aug 2026 16:52:02 +0800 Subject: [PATCH] feat: add OrcaRouter as a named provider Add OrcaRouter (https://www.orcarouter.ai) as a first-class provider, mirroring the OpenRouter entry: - provider catalogue: ProviderInfo(id="orcarouter", litellm_prefix "orcarouter", env ORCAROUTER_API_KEY) with a static starter model list - config: _PROVIDER_ENV tuple so the gateway API key is injected for litellm - llm: normalize_litellm_model() routes orcarouter/ through the openai provider with api_base https://api.orcarouter.ai/v1 (litellm has no built-in orcarouter provider); applied in stream(), count_tokens(), and model_context_limits() - api: normalize the model in the sync _run_turn litellm path - tests: provider registration, env tuple, and model-normalization coverage Co-Authored-By: Claude --- openvibe/api.py | 7 +++++- openvibe/config.py | 1 + openvibe/llm.py | 32 ++++++++++++++++++++++++---- openvibe/provider/provider.py | 14 ++++++++++++ tests/test_provider.py | 40 +++++++++++++++++++++++++++++++++++ 5 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 tests/test_provider.py diff --git a/openvibe/api.py b/openvibe/api.py index 18f765d..5fd1814 100644 --- a/openvibe/api.py +++ b/openvibe/api.py @@ -1372,14 +1372,19 @@ def _run_turn( if llm is not None: _llm_call = llm + litellm_model = _model_string(agent) + provider_kwargs: dict[str, Any] = {} else: import litellm # lazy — keeps startup fast and tests free + from openvibe.llm import normalize_litellm_model # lazy — keeps startup fast _llm_call = litellm.completion + litellm_model, provider_kwargs = normalize_litellm_model(_model_string(agent)) stream = _llm_call( - model=_model_string(agent), + model=litellm_model, messages=ll_messages, **call_kwargs, + **provider_kwargs, ) for chunk in stream: diff --git a/openvibe/config.py b/openvibe/config.py index a595cc8..323476a 100644 --- a/openvibe/config.py +++ b/openvibe/config.py @@ -283,6 +283,7 @@ def load_config(project_dir: Path | None = None) -> Config: "groq": ("GROQ_API_KEY", None, None), "mistral": ("MISTRAL_API_KEY", None, None), "openrouter": ("OPENROUTER_API_KEY", None, None), + "orcarouter": ("ORCAROUTER_API_KEY", None, None), "azure": ("AZURE_API_KEY", "AZURE_API_BASE", "AZURE_API_VERSION"), } diff --git a/openvibe/llm.py b/openvibe/llm.py index b795eb0..f062e2a 100644 --- a/openvibe/llm.py +++ b/openvibe/llm.py @@ -35,6 +35,7 @@ async def stream( from __future__ import annotations +import os from collections.abc import AsyncIterator from dataclasses import dataclass, field from typing import Any, Protocol @@ -207,6 +208,26 @@ def _to_litellm_tools(tools: list[ToolDefinition]) -> list[dict[str, Any]]: ] +ORCAROUTER_BASE_URL = "https://api.orcarouter.ai/v1" + + +def normalize_litellm_model(model: str) -> tuple[str, dict[str, str]]: + """Translate an OrcaRouter model to litellm's OpenAI-compatible routing. + + litellm has no built-in ``orcarouter`` provider, so we route through the + ``openai`` provider with an explicit ``api_base`` (and ``api_key`` when + the ``ORCAROUTER_API_KEY`` env var is set). Any other model is returned + unchanged. + """ + if not model.startswith("orcarouter/"): + return model, {} + kwargs: dict[str, str] = {"api_base": ORCAROUTER_BASE_URL} + key = os.environ.get("ORCAROUTER_API_KEY") + if key: + kwargs["api_key"] = key + return f"openai/{model.removeprefix('orcarouter/')}", kwargs + + class LiteLLMBackend: """LLM backend powered by ``litellm``. @@ -236,7 +257,8 @@ async def stream( if system: ll_messages = [{"role": "system", "content": system}] + ll_messages - call_kwargs: dict[str, Any] = {"stream": True, **kwargs} + litellm_model, provider_kwargs = normalize_litellm_model(model) + call_kwargs: dict[str, Any] = {"stream": True, **provider_kwargs, **kwargs} if tools: call_kwargs["tools"] = _to_litellm_tools(tools) if temperature is not None: @@ -250,7 +272,7 @@ async def stream( pending: dict[int, dict[str, Any]] = {} response = await litellm.acompletion( - model=model, messages=ll_messages, **call_kwargs + model=litellm_model, messages=ll_messages, **call_kwargs ) async for chunk in response: @@ -323,8 +345,9 @@ def resolve_model() -> str: def count_tokens(model: str, text: str) -> int: """Return the approximate token count for *text* under *model*.""" + litellm_model, _ = normalize_litellm_model(model) return litellm.token_counter( - model=model, + model=litellm_model, messages=[{"role": "system", "content": text}], ) @@ -332,5 +355,6 @@ def count_tokens(model: str, text: str) -> int: def model_context_limits(model: str) -> tuple[int, int]: """Return (max_input_tokens, max_output_tokens) for *model*.""" - info = litellm.get_model_info(model) + litellm_model, _ = normalize_litellm_model(model) + info = litellm.get_model_info(litellm_model) return int(info["max_input_tokens"]), int(info["max_output_tokens"]) diff --git a/openvibe/provider/provider.py b/openvibe/provider/provider.py index adae784..cdf983c 100644 --- a/openvibe/provider/provider.py +++ b/openvibe/provider/provider.py @@ -179,6 +179,20 @@ def _m(provider_id: str, model_id: str, name: str, **kwargs: object) -> ModelInf env_key="OPENROUTER_API_KEY", models=[], # populated dynamically via list_models_from_api() ), + ProviderInfo( + id="orcarouter", + name="OrcaRouter", + litellm_prefix="orcarouter", + env_key="ORCAROUTER_API_KEY", + # Static starter list; the gateway exposes 190+ namespaced model IDs. + models=[ + _m("orcarouter", "openai/gpt-5.5", "GPT-5.5"), + _m("orcarouter", "anthropic/claude-opus-4.8", "Claude Opus 4.8"), + _m("orcarouter", "z-ai/glm-5.2", "GLM-5.2"), + _m("orcarouter", "deepseek/deepseek-v4-pro", "DeepSeek V4 Pro"), + _m("orcarouter", "google/gemini-3.1-flash-lite", "Gemini 3.1 Flash Lite"), + ], + ), ProviderInfo( id="azure", name="Azure OpenAI", diff --git a/tests/test_provider.py b/tests/test_provider.py new file mode 100644 index 0000000..296aa91 --- /dev/null +++ b/tests/test_provider.py @@ -0,0 +1,40 @@ +"""Tests for the OrcaRouter provider integration.""" + +from __future__ import annotations + +from openvibe.config import _PROVIDER_ENV +from openvibe.llm import ORCAROUTER_BASE_URL, normalize_litellm_model +from openvibe.provider.provider import get_provider + + +def test_orcarouter_provider_is_registered(): + provider = get_provider("orcarouter") + assert provider is not None + assert provider.name == "OrcaRouter" + assert provider.litellm_prefix == "orcarouter" + assert provider.env_key == "ORCAROUTER_API_KEY" + # Named starter model list, mirroring the gateway's namespaced IDs. + assert any(m.id == "orcarouter/openai/gpt-5.5" for m in provider.models) + + +def test_orcarouter_env_tuple_matches_provider(): + assert _PROVIDER_ENV["orcarouter"] == ("ORCAROUTER_API_KEY", None, None) + + +def test_normalize_litellm_model_routes_orcarouter_via_openai(): + litellm_model, kwargs = normalize_litellm_model("orcarouter/openai/gpt-5.5") + assert litellm_model == "openai/openai/gpt-5.5" + assert kwargs["api_base"] == ORCAROUTER_BASE_URL + assert "api_key" not in kwargs # only injected when env var is set + + +def test_normalize_litellm_model_injects_api_key_when_set(monkeypatch): + monkeypatch.setenv("ORCAROUTER_API_KEY", "sk-orca-test") + _, kwargs = normalize_litellm_model("orcarouter/anthropic/claude-opus-4.8") + assert kwargs["api_key"] == "sk-orca-test" + + +def test_normalize_litellm_model_leaves_other_providers_unchanged(): + model, kwargs = normalize_litellm_model("openrouter/openai/gpt-4o") + assert model == "openrouter/openai/gpt-4o" + assert kwargs == {}