diff --git a/docs/providers.md b/docs/providers.md index ff86d23a7..176b3f72d 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -123,6 +123,40 @@ appended to nanobot's generated functions. This keeps unrelated local tools such available in the same request. Responses-only server tools require an API surface that the OpenRouter provider does not currently enable. +### OrcaRouter Gateway + +[OrcaRouter](https://www.orcarouter.ai) is an OpenAI-compatible model routing gateway. Configure +the built-in `orcarouter` provider and use a model ID from OrcaRouter's catalog: + +```json +{ + "providers": { + "orcarouter": { + "apiKey": "${ORCAROUTER_API_KEY}" + } + }, + "modelPresets": { + "primary": { + "provider": "orcarouter", + "model": "orcarouter/auto", + "maxTokens": 8192, + "contextWindowTokens": 65536 + } + }, + "agents": { + "defaults": { + "modelPreset": "primary" + } + } +} +``` + +Use the model ID exactly as OrcaRouter lists it. `orcarouter/auto` routes to a +suitable upstream automatically; explicit IDs such as +`anthropic/claude-sonnet-4.6` or `openai/gpt-5` are also accepted. OrcaRouter API keys start with +`sk-orca-`. The WebUI can load the account's model catalog after the API key is saved under +**Settings → Models**. + ### Eden AI Gateway Eden AI exposes an OpenAI-compatible chat-completions endpoint at diff --git a/nanobot/channels/websocket/tests/test_websocket_channel.py b/nanobot/channels/websocket/tests/test_websocket_channel.py index ef6627b2a..6e07dba45 100644 --- a/nanobot/channels/websocket/tests/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -3132,6 +3132,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( assert providers["azure_openai"]["api_key_required"] is False # AAD auth supported; no static key required assert providers["openrouter"]["configured"] is False assert providers["openrouter"]["api_key_required"] is True + assert providers["orcarouter"]["label"] == "OrcaRouter" + assert providers["orcarouter"]["configured"] is False + assert providers["orcarouter"]["api_key_required"] is True + assert providers["orcarouter"]["default_api_base"] == "https://api.orcarouter.ai/v1" assert providers["skywork"]["label"] == "Skywork" assert providers["skywork"]["default_api_base"] == "https://api.apifree.ai/agent/v1" assert providers["ant_ling"]["label"] == "Ant Ling" diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 92c97783c..1beb36f51 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -262,6 +262,7 @@ class ProvidersConfig(Base): anthropic: ProviderConfig = Field(default_factory=ProviderConfig) openai: ProviderConfig = Field(default_factory=ProviderConfig) openrouter: ProviderConfig = Field(default_factory=ProviderConfig) + orcarouter: ProviderConfig = Field(default_factory=ProviderConfig) # OrcaRouter API gateway assemblyai: ProviderConfig = Field(default_factory=ProviderConfig) # AssemblyAI voice transcription huggingface: ProviderConfig = Field(default_factory=ProviderConfig) skywork: ProviderConfig = Field(default_factory=ProviderConfig) # Skywork / APIFree API gateway diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index a9608928e..291fa2c2c 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -199,6 +199,18 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( supports_prompt_caching=True, gateway_reasoning_style="reasoning_effort", ), + # OrcaRouter: global gateway, keys start with "sk-orca-" + ProviderSpec( + name="orcarouter", + keywords=("orcarouter",), + env_key="ORCAROUTER_API_KEY", + display_name="OrcaRouter", + backend="openai_compat", + is_gateway=True, + detect_by_key_prefix="sk-orca-", + detect_by_base_keyword="orcarouter", + default_api_base="https://api.orcarouter.ai/v1", + ), # Eden AI: OpenAI-compatible gateway. Models use the "provider/model" # naming scheme (e.g. "anthropic/claude-sonnet-4-5"); the full id is sent upstream. ProviderSpec( diff --git a/tests/providers/test_orcarouter_provider.py b/tests/providers/test_orcarouter_provider.py new file mode 100644 index 000000000..ebf9052ee --- /dev/null +++ b/tests/providers/test_orcarouter_provider.py @@ -0,0 +1,98 @@ +"""Tests for the OrcaRouter provider registration.""" + +from unittest.mock import patch + +from nanobot.config.schema import Config, ProvidersConfig +from nanobot.providers.openai_compat_provider import OpenAICompatProvider +from nanobot.providers.registry import PROVIDERS, find_by_name + + +def test_orcarouter_config_field_exists() -> None: + config = ProvidersConfig() + + assert hasattr(config, "orcarouter") + + +def test_orcarouter_provider_in_registry() -> None: + specs = {spec.name: spec for spec in PROVIDERS} + + assert "orcarouter" in specs + orcarouter = specs["orcarouter"] + assert orcarouter.backend == "openai_compat" + assert orcarouter.env_key == "ORCAROUTER_API_KEY" + assert orcarouter.display_name == "OrcaRouter" + assert orcarouter.is_gateway is True + assert orcarouter.detect_by_key_prefix == "sk-orca-" + assert orcarouter.detect_by_base_keyword == "orcarouter" + assert orcarouter.default_api_base == "https://api.orcarouter.ai/v1" + assert orcarouter.strip_model_prefix is False + + +def test_find_by_name_orcarouter() -> None: + spec = find_by_name("orcarouter") + + assert spec is not None + assert spec.name == "orcarouter" + + +def test_orcarouter_forced_provider_uses_default_api_base() -> None: + config = Config.model_validate({ + "providers": { + "orcarouter": { + "apiKey": "sk-orca-test-key", + }, + }, + "agents": { + "defaults": { + "model": "deepseek/deepseek-chat", + "provider": "orcarouter", + }, + }, + }) + + assert config.get_provider_name("deepseek/deepseek-chat") == "orcarouter" + assert config.get_api_key("deepseek/deepseek-chat") == "sk-orca-test-key" + assert config.get_api_base("deepseek/deepseek-chat") == "https://api.orcarouter.ai/v1" + + +def test_orcarouter_gateway_routes_unprefixed_models_when_configured() -> None: + config = Config.model_validate({ + "providers": { + "orcarouter": { + "apiKey": "sk-orca-test-key", + }, + }, + "agents": { + "defaults": { + "model": "orcarouter/auto", + }, + }, + }) + + assert config.get_provider_name("orcarouter/auto") == "orcarouter" + assert config.get_api_key("orcarouter/auto") == "sk-orca-test-key" + assert config.get_api_base("orcarouter/auto") == "https://api.orcarouter.ai/v1" + + +def test_orcarouter_preserves_model_api_id() -> None: + spec = find_by_name("orcarouter") + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + provider = OpenAICompatProvider( + api_key="sk-orca-test-key", + default_model="anthropic/claude-sonnet-4.6", + spec=spec, + ) + + kwargs = provider._build_kwargs( + messages=[{"role": "user", "content": "hi"}], + tools=None, + model="anthropic/claude-sonnet-4.6", + max_tokens=1024, + temperature=0.7, + reasoning_effort=None, + tool_choice=None, + ) + + assert kwargs["model"] == "anthropic/claude-sonnet-4.6" + assert kwargs["max_tokens"] == 1024 + assert "max_completion_tokens" not in kwargs diff --git a/webui/src/components/settings/shared/ModelControls.tsx b/webui/src/components/settings/shared/ModelControls.tsx index 54798c002..2704a0ba4 100644 --- a/webui/src/components/settings/shared/ModelControls.tsx +++ b/webui/src/components/settings/shared/ModelControls.tsx @@ -52,6 +52,7 @@ const DEFERRED_MODEL_LIST_PROVIDERS = new Set([ "novita", "ollama", "openrouter", + "orcarouter", "ovms", "siliconflow", "vllm", @@ -577,6 +578,7 @@ export function optionRowsWithCurrent( export const PROVIDER_ICONS: Record = { custom: Hexagon, openrouter: Sparkles, + orcarouter: Sparkles, skywork: Sparkles, aihubmix: Triangle, anthropic: Brain, diff --git a/webui/src/lib/provider-brand.ts b/webui/src/lib/provider-brand.ts index 0019bcae0..15477cace 100644 --- a/webui/src/lib/provider-brand.ts +++ b/webui/src/lib/provider-brand.ts @@ -184,6 +184,7 @@ const PROVIDER_BRANDS: Record = { ollama: brand("ollama.com", "#111827", "O"), openai: brand("openai.com", "#111827", "AI"), openrouter: brand("openrouter.ai", "#111827", "OR"), + orcarouter: brand("orcarouter.ai", "#111827", "OR"), ovms: brand("openvino.ai", "#0071C5", "OV"), qianfan: brand("cloud.baidu.com", "#2932E1", "QF"), searxng: brand("searxng.org", "#3050FF", "SX"),