-
Notifications
You must be signed in to change notification settings - Fork 184
[NOT-780] feat(llm): add OrcaRouter as a named LLM provider #893
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Missing ORCAROUTER_API_KEY causes OpenAI API key leakage to third-party endpoint ENABLE_ORCAROUTER=true without ORCAROUTER_API_KEY leaks OPENAI_API_KEY to api.orcarouter.ai via LiteLLM fallback. Require ORCAROUTER_API_KEY before setting base_url, or explicitly pass api_key to prevent LiteLLM provider fallback. AI prompt |
||
| 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) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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/", | ||
| ) | ||
| ) |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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 | ||||||||||||||||||||||||||
|
Comment on lines
+63
to
+67
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Reset If Proposed fix 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
+ try:
+ with patch("litellm.acompletion", return_value=mock_response) as mock_acompletion:
+ response = await llm_engine.completion(messages=messages, model=model)
+ finally:
+ notte_config._enable_orcarouter = None # Reset cached value📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| 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") | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Do not advertise
orcarouter/autofor an OpenRouter-only configuration.When
ENABLE_OPENROUTER=trueandENABLE_ORCAROUTER=false, Line 103 makesLlmProvider.orcarouter.apikey_namereturnOPENROUTER_API_KEY.LlmModel.valid()then includes this model.LLMEngine._get_model()sends it asopenrouter/orcarouter/autoinstead of usingORCAROUTER_BASE_URL.Exclude
LlmModel.orcarouterunless OrcaRouter routing is enabled, or route explicitorcarouter/models through OrcaRouter independent of the global toggle.🤖 Prompt for AI Agents