From f0fcbebbb5337abd4dec8af2de251c1cacc6f7bb Mon Sep 17 00:00:00 2001 From: XiaoHuo888 Date: Mon, 10 Aug 2026 11:54:46 +0800 Subject: [PATCH] feat(llm): add OrcaRouter as a named LLM provider Mirror the existing OpenRouter wiring so OrcaRouter is selectable as a provider behind the ENABLE_ORCAROUTER env toggle: - LlmProvider.orcarouter + LlmModel.orcarouter + enable_orcarouter() - LlmModel.get_orcarouter_model() maps Notte provider prefixes to the namespaced ids OrcaRouter serves - LLMEngine routes through litellm's OpenAI-compatible path with base_url https://api.orcarouter.ai/v1 and ORCAROUTER_API_KEY - structured_completion uses the OpenAI json_schema wrapper for all OrcaRouter-routed upstreams - tests: config mapping, engine routing, agent model smoke list - .env.example: ORCAROUTER_API_KEY + ENABLE_ORCAROUTER --- .env.example | 3 + .../src/notte_core/common/config.py | 69 +++++++++++ packages/notte-llm/src/notte_llm/engine.py | 36 +++++- tests/config/test_orcarouter_provider.py | 110 +++++++++++++++++ tests/llms/test_engine.py | 31 +++++ tests/llms/test_orcarouter_models.py | 112 ++++++++++++++++++ 6 files changed, 358 insertions(+), 3 deletions(-) create mode 100644 tests/config/test_orcarouter_provider.py create mode 100644 tests/llms/test_orcarouter_models.py diff --git a/.env.example b/.env.example index e2b345a8d..3f3f0c3b3 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,6 @@ GEMINI_API_KEY= OPENROUTER_API_KEY= # if you want to use openrouter as a provider, set this to true ENABLE_OPENROUTER=false +ORCAROUTER_API_KEY= +# if you want to use orcarouter as a provider, set this to true +ENABLE_ORCAROUTER=false diff --git a/packages/notte-core/src/notte_core/common/config.py b/packages/notte-core/src/notte_core/common/config.py index a5f08c749..c94f8cbc4 100644 --- a/packages/notte-core/src/notte_core/common/config.py +++ b/packages/notte-core/src/notte_core/common/config.py @@ -17,6 +17,9 @@ ScreenshotType = Literal["raw", "full", "last_action"] _enable_openrouter: bool | None = None +_enable_orcarouter: bool | None = None + +ORCAROUTER_BASE_URL = "https://api.orcarouter.ai/v1" def enable_openrouter() -> bool: @@ -27,6 +30,14 @@ def enable_openrouter() -> bool: return _enable_openrouter +def enable_orcarouter() -> bool: + global _enable_orcarouter + if _enable_orcarouter is not None: + return _enable_orcarouter + _enable_orcarouter = os.environ.get("ENABLE_ORCAROUTER", "false").lower() in ("true", "1", "yes") + return _enable_orcarouter + + class CookieDict(TypedDict, total=False): """ Cookie dictionary as returned by the session.get_cookies() method. @@ -59,6 +70,7 @@ class LlmProvider(StrEnum): gemini = "gemini" vertex_ai = "vertex_ai" openrouter = "openrouter" + orcarouter = "orcarouter" cerebras = "cerebras" groq = "groq" perplexity = "perplexity" @@ -86,6 +98,8 @@ def context_length(self) -> int: @property def apikey_name(self) -> str: + if enable_orcarouter(): + return "ORCAROUTER_API_KEY" if enable_openrouter(): return "OPENROUTER_API_KEY" match self: @@ -103,6 +117,8 @@ def apikey_name(self) -> str: return "CEREBRAS_API_KEY" case LlmProvider.openrouter: return "OPENROUTER_API_KEY" + case LlmProvider.orcarouter: + return "ORCAROUTER_API_KEY" case LlmProvider.deepseek: return "DEEPSEEK_API_KEY" case LlmProvider.ollama: @@ -144,6 +160,10 @@ class LlmModel(StrEnum): kimi2_5 = "moonshot/kimi-k2.5" grok = "xai/grok-4-1-fast-non-reasoning" minimax = "minimax/minimax-m2.5" + # Auto-routing OrcaRouter model. Prefer a fixed model (e.g. openai/gpt-4o) + # when using strict structured output, as orcarouter/auto does not + # guarantee json_schema compliance across all upstreams. + orcarouter = "orcarouter/auto" @property def provider(self) -> LlmProvider: @@ -207,6 +227,55 @@ def get_openrouter_model(model: str) -> str: return f"openrouter/{_model}" + @staticmethod + def get_orcarouter_model(model: str) -> str: + """Map a Notte model id to its OrcaRouter equivalent. + + OrcaRouter exposes the upstream ``namespace/model`` ids directly + (``google/...``, ``anthropic/...``, ``orcarouter/...``), so Notte's + internal provider prefixes are rewritten when ENABLE_ORCAROUTER=true. + """ + if model.startswith("orcarouter/"): + return model + + _model = model.removeprefix("openrouter/") + + # OrcaRouter does not serve every Notte provider family; map the + # missing ones to their closest available equivalent. + if "/gpt-oss-120b" in _model: + _model = "openai/gpt-5-mini" + if "/gemma-3-27b-it" in _model: + _model = "google/gemma-4-31b-it" + if "/deepseek-r1" in _model: + _model = "deepseek/deepseek-reasoner" + if "/claude-sonnet-4-5" in _model: + _model = "anthropic/claude-sonnet-4.5" + if "/llama-3.3-70b-instruct" in _model: + _model = "openai/gpt-4o" + if "/sonar-pro" in _model: + _model = "openai/gpt-4o" + if "/grok-4-1-fast-non-reasoning" in _model: + _model = "grok/grok-4.3" + + if "vertex_ai/" in _model: + _model = _model.replace("vertex_ai", "google") + if "gemini/" in _model: + _model = _model.replace("gemini/", "google/") + if "zai/" in _model: + _model = _model.replace("zai/", "z-ai/") + if "moonshot/" in _model: + _model = _model.replace("moonshot/", "kimi/") + if "perplexity/" in _model: + _model = _model.replace("perplexity/", "openai/") + if "cerebras/" in _model: + _model = _model.replace("cerebras/", "openai/") + if "groq/" in _model: + _model = _model.replace("groq/", "openai/") + if "together_ai/" in _model: + _model = _model.replace("together_ai/", "openai/") + + return _model + @property def context_length(self) -> int: return self.provider.context_length diff --git a/packages/notte-llm/src/notte_llm/engine.py b/packages/notte-llm/src/notte_llm/engine.py index 6b29b65c0..58afa6041 100644 --- a/packages/notte-llm/src/notte_llm/engine.py +++ b/packages/notte-llm/src/notte_llm/engine.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import re from collections.abc import Iterable from dataclasses import dataclass @@ -23,7 +24,13 @@ ContextWindowExceededError as LiteLLMContextWindowExceededError, ) from litellm.files.main import ModelResponse # pyright: ignore [reportMissingTypeStubs] -from notte_core.common.config import LlmModel, config, enable_openrouter +from notte_core.common.config import ( + ORCAROUTER_BASE_URL, + LlmModel, + config, + enable_openrouter, + enable_orcarouter, +) from notte_core.common.logging import logger from notte_core.errors.base import NotteBaseError from notte_core.errors.llm import LLmModelOverloadedError, LLMParsingError @@ -182,6 +189,11 @@ def is_openrouter_model(model: str) -> bool: return model.lower().startswith("openrouter/") +def is_orcarouter_model(model: str) -> bool: + """Check if the model is routed through OrcaRouter.""" + return model.lower().startswith("orcarouter/") or enable_orcarouter() + + def fix_schema_for_openai(schema: dict[str, Any]) -> dict[str, Any]: """ Convert a Pydantic JSON schema to OpenAI-compatible structured output format. @@ -369,12 +381,18 @@ async def structured_completion( litellm_response_format: dict[str, Any] | type[BaseModel] = dict(type="json_object") if use_strict_response_format: raw_schema = response_format.model_json_schema() + is_routed_via_openrouter = is_openrouter_model(effective_model) or enable_openrouter() + is_routed_via_orcarouter = is_orcarouter_model(effective_model) or enable_orcarouter() + # OrcaRouter exposes an OpenAI-compatible endpoint, so the OpenAI + # json_schema wrapper is used for every upstream it routes to + # (OpenAI, Anthropic, Google, DeepSeek, ...). + if is_routed_via_orcarouter: + litellm_response_format = fix_schema_for_openai(raw_schema) # For Anthropic models via OpenRouter, use non-strict json_object format # OpenRouter routes to various backends with incompatible schema support: # - Bedrock doesn't support oneOf at all # - Anthropic direct limits anyOf to 16 parameters - is_routed_via_openrouter = is_openrouter_model(effective_model) or enable_openrouter() - if is_routed_via_openrouter and is_anthropic_model(effective_model): + elif is_routed_via_openrouter and is_anthropic_model(effective_model): litellm_response_format = dict(type="json_object") use_strict_response_format = False # For OpenRouter-prefixed models, use OpenAI schema format @@ -527,6 +545,11 @@ async def single_completion( def _get_model(self, model: str | None) -> str: model = model or self.model + if enable_orcarouter(): + # litellm has no native orcarouter/ route; use its OpenAI-compatible + # path. The openai/ prefix is stripped by litellm and the full + # (namespaced) model id is forwarded to ORCAROUTER_BASE_URL. + return f"openai/{LlmModel.get_orcarouter_model(model)}" if enable_openrouter(): return LlmModel.get_openrouter_model(model) return model @@ -558,6 +581,12 @@ async def completion( model = self._get_model(model) # Apply model-specific temperature overrides temperature = LlmModel.get_temperature(model, temperature) + completion_kwargs: dict[str, Any] = {} + if enable_orcarouter(): + completion_kwargs["base_url"] = ORCAROUTER_BASE_URL + orcarouter_api_key = os.environ.get("ORCAROUTER_API_KEY") + if orcarouter_api_key: + completion_kwargs["api_key"] = orcarouter_api_key try: response = await litellm.acompletion( # pyright: ignore [reportUnknownMemberType] model, @@ -572,6 +601,7 @@ async def completion( # indefinitely. Without this, httpx has no read timeout and silent server # stalls hang the whole agent run. timeout=60, + **completion_kwargs, ) # Cast to ModelResponse since we know it's not streaming in this case return cast(ModelResponse, response) diff --git a/tests/config/test_orcarouter_provider.py b/tests/config/test_orcarouter_provider.py new file mode 100644 index 000000000..037aeb55a --- /dev/null +++ b/tests/config/test_orcarouter_provider.py @@ -0,0 +1,110 @@ +import pytest +from notte_core.common.config import LlmModel, LlmProvider + +from tests.llms.test_orcarouter_models import ORCAROUTER_MODELS + +# Mapping from OrcaRouter provider names that differ from LlmProvider enum values. +# e.g. OrcaRouter uses "google" but LlmProvider uses "gemini". +ORCAROUTER_PROVIDER_ALIASES: dict[str, LlmProvider] = { + "google": LlmProvider.gemini, + "kimi": LlmProvider.moonshot, + "grok": LlmProvider.xai, + "z-ai": LlmProvider.zai, +} + + +def _resolve_orcarouter_provider(model: str) -> LlmProvider: + """Resolve the OrcaRouter provider prefix to a LlmProvider.""" + prefix = model.split("/")[0] + if prefix in ORCAROUTER_PROVIDER_ALIASES: + return ORCAROUTER_PROVIDER_ALIASES[prefix] + # Try direct match against LlmProvider values + if prefix in list(LlmProvider): + return LlmProvider(prefix) + raise ValueError( + f"OrcaRouter provider '{prefix}' (from model '{model}') " + f"has no matching LlmProvider and no alias in ORCAROUTER_PROVIDER_ALIASES." + ) + + +class TestOrcarouterModelsHaveProvider: + """Ensure every provider in ORCAROUTER_MODELS maps to a known LlmProvider.""" + + @pytest.mark.parametrize("model", ORCAROUTER_MODELS) + def test_orcarouter_model_has_known_provider(self, model: str) -> None: + provider = _resolve_orcarouter_provider(model) + assert isinstance(provider, LlmProvider) + + +class TestGetOrcarouterModel: + """Tests for LlmModel.get_orcarouter_model() method.""" + + def test_already_orcarouter_model_unchanged(self) -> None: + model = "orcarouter/auto" + assert LlmModel.get_orcarouter_model(model) == model + + def test_openrouter_model_is_stripped(self) -> None: + result = LlmModel.get_orcarouter_model("openrouter/google/gemma-3-27b-it") + assert result == "google/gemma-4-31b-it" + + def test_gpt_oss_120b_conversion(self) -> None: + result = LlmModel.get_orcarouter_model("cerebras/gpt-oss-120b") + assert result == "openai/gpt-5-mini" + + def test_gemini_conversion(self) -> None: + result = LlmModel.get_orcarouter_model("gemini/gemini-2.5-flash") + assert result == "google/gemini-2.5-flash" + + def test_vertex_ai_conversion(self) -> None: + result = LlmModel.get_orcarouter_model("vertex_ai/gemini-2.5-flash") + assert result == "google/gemini-2.5-flash" + + def test_deepseek_conversion(self) -> None: + result = LlmModel.get_orcarouter_model("deepseek/deepseek-r1") + assert result == "deepseek/deepseek-reasoner" + + def test_claude_sonnet_conversion(self) -> None: + result = LlmModel.get_orcarouter_model("anthropic/claude-sonnet-4-5-20250929") + assert result == "anthropic/claude-sonnet-4.5" + + def test_kimi_conversion(self) -> None: + result = LlmModel.get_orcarouter_model("moonshot/kimi-k2.5") + assert result == "kimi/kimi-k2.5" + + def test_llama_conversion(self) -> None: + result = LlmModel.get_orcarouter_model("together_ai/meta-llama/llama-3.3-70b-instruct") + assert result == "openai/gpt-4o" + + def test_grok_conversion(self) -> None: + result = LlmModel.get_orcarouter_model("xai/grok-4-1-fast-non-reasoning") + assert result == "grok/grok-4.3" + + def test_openai_model_unchanged(self) -> None: + result = LlmModel.get_orcarouter_model("openai/gpt-4o") + assert result == "openai/gpt-4o" + + def test_minimax_model_unchanged(self) -> None: + result = LlmModel.get_orcarouter_model("minimax/minimax-m2.5") + assert result == "minimax/minimax-m2.5" + + +class TestLlmModelOrcarouterIntegration: + """Tests for LlmModel enum values with OrcaRouter methods.""" + + @pytest.mark.parametrize("model", list(LlmModel)) + def test_all_models_can_be_converted_to_orcarouter(self, model: LlmModel) -> None: + """All LlmModel values should map to a namespace served by OrcaRouter.""" + result = LlmModel.get_orcarouter_model(model.value) + assert result.startswith( + ( + "openai/", + "anthropic/", + "google/", + "deepseek/", + "minimax/", + "kimi/", + "grok/", + "z-ai/", + "orcarouter/", + ) + ) diff --git a/tests/llms/test_engine.py b/tests/llms/test_engine.py index 581fbff9e..ae57a5209 100644 --- a/tests/llms/test_engine.py +++ b/tests/llms/test_engine.py @@ -1,5 +1,7 @@ +import os from unittest.mock import Mock, patch +import notte_core.common.config as notte_config import pytest from litellm import Message from notte_core.errors.base import ErrorConfig @@ -43,6 +45,35 @@ async def test_completion_error(llm_engine: LLMEngine) -> None: assert "API Error" in str(exc_info.value) +@pytest.mark.asyncio +async def test_completion_with_orcarouter(llm_engine: LLMEngine) -> None: + """Completion routes through OrcaRouter when ENABLE_ORCAROUTER=true. + + The model id is prefixed with ``openai/`` so litellm uses its + OpenAI-compatible path, and the OrcaRouter base URL + API key are forwarded. + """ + messages = [ + Message(role="user", content="Hello"), + ] + model = "gemini/gemini-2.5-flash" + + mock_response = Mock() + mock_response.choices = [Mock(message=Mock(content="Hello there!"))] + + with patch.dict(os.environ, {"ENABLE_ORCAROUTER": "true", "ORCAROUTER_API_KEY": "sk-orca-test"}): + notte_config._enable_orcarouter = None # Reset cached value + with patch("litellm.acompletion", return_value=mock_response) as mock_acompletion: + response = await llm_engine.completion(messages=messages, model=model) + notte_config._enable_orcarouter = None # Reset cached value + + call_args = mock_acompletion.call_args + assert call_args.args[0] == "openai/google/gemini-2.5-flash" + assert call_args.kwargs["base_url"] == "https://api.orcarouter.ai/v1" + assert call_args.kwargs["api_key"] == "sk-orca-test" + assert response == mock_response + assert response.choices[0].message.content == "Hello there!" + + class TestStructuredContent: def test_extract_with_outer_tag(self): structure = StructuredContent(outer_tag="response") diff --git a/tests/llms/test_orcarouter_models.py b/tests/llms/test_orcarouter_models.py new file mode 100644 index 000000000..91d6d707e --- /dev/null +++ b/tests/llms/test_orcarouter_models.py @@ -0,0 +1,112 @@ +""" +Test agent single step with various OrcaRouter models. + +This test verifies that the agent can successfully complete a single step +(observe + LLM completion) with different reasoning models via OrcaRouter. +""" + +import os + +import notte_core.common.config as notte_config +import pytest +from dotenv import load_dotenv + +import notte + +# OrcaRouter models to test - models served by the OrcaRouter catalog +# Format: / - full namespaced ids, no extra prefix needed +# Update this list as new models become available +ORCAROUTER_MODELS = [ + "google/gemini-3.5-flash", + "google/gemini-2.5-flash", + "anthropic/claude-opus-4.6", + "anthropic/claude-sonnet-4.6", + "anthropic/claude-haiku-4.5", + "openai/gpt-5.2", + "openai/gpt-5-nano", + "openai/gpt-4o-mini", + "minimax/minimax-m2.5", + "kimi/kimi-k2.5", + "deepseek/deepseek-v4-flash", + "grok/grok-4.3", + "z-ai/glm-5", + "qwen/qwen3.5-flash", +] + + +def to_orcarouter_model(model: str) -> str: + """Return the full OrcaRouter model id (ids are already namespaced).""" + return model + + +def check_orcarouter_available() -> bool: + """Check if OrcaRouter API key is available. + + Note: Relies on load_dotenv() having been called at module import time. + """ + return os.getenv("ORCAROUTER_API_KEY") is not None + + +# Load .env at module import time (before pytest collection evaluates skipif) +load_dotenv() + + +@pytest.fixture(autouse=True, scope="module") +def enable_orcarouter_for_module(): + """Enable OrcaRouter mode for this test module with proper teardown.""" + original = os.environ.get("ENABLE_ORCAROUTER") + os.environ["ENABLE_ORCAROUTER"] = "true" + notte_config._enable_orcarouter = None # Reset cached value + yield + if original is None: + os.environ.pop("ENABLE_ORCAROUTER", None) + else: + os.environ["ENABLE_ORCAROUTER"] = original + notte_config._enable_orcarouter = None # Reset cached value + + +@pytest.fixture(scope="module") +def session(): + """Create a notte session for testing (module-scoped for efficiency).""" + with notte.Session(headless=True) as s: + # Navigate to a simple page first + s.execute(type="goto", url="https://example.com") + yield s + + +@pytest.mark.skipif( + not check_orcarouter_available(), + reason="ORCAROUTER_API_KEY not set", +) +@pytest.mark.parametrize("model", ORCAROUTER_MODELS) +def test_single_agent_step_with_orcarouter_model(session, model: str): + """ + Test that a single agent step works with the given OrcaRouter model. + + This test: + 1. Creates an agent with the specified reasoning model + 2. Runs the agent for just 1 step + 3. Verifies the agent successfully completed the step (no errors) + """ + # Reset to known page state before each test to avoid cross-test pollution + # (a previous agent may have navigated away from example.com) + session.execute(type="goto", url="https://example.com") + + agent = notte.Agent( + session=session, + reasoning_model=to_orcarouter_model(model), + max_steps=1, # Only run 1 step + use_vision=False, # Disable vision for models that don't support it + ) + + # Run the agent - it should complete 1 step and then stop + # (either by completing the task or hitting max_steps) + result = agent.run(task="Describe this page") + + # The agent should have run at least one step + assert result is not None + assert len(result.steps) >= 1, f"Agent did not complete any steps with model {model}" + + # The first step should have a valid action + first_step = result.steps[0] + assert first_step.action is not None, f"First step has no action with model {model}"