Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion openvibe/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions openvibe/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}

Expand Down
32 changes: 28 additions & 4 deletions openvibe/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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``.

Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -323,14 +345,16 @@ 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}],
)


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"])
14 changes: 14 additions & 0 deletions openvibe/provider/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 40 additions & 0 deletions tests/test_provider.py
Original file line number Diff line number Diff line change
@@ -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 == {}