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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
69 changes: 69 additions & 0 deletions packages/notte-core/src/notte_core/common/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
Expand Down Expand Up @@ -59,6 +70,7 @@ class LlmProvider(StrEnum):
gemini = "gemini"
vertex_ai = "vertex_ai"
openrouter = "openrouter"
orcarouter = "orcarouter"
cerebras = "cerebras"
groq = "groq"
perplexity = "perplexity"
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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"
Comment on lines +163 to +166

Copy link
Copy Markdown
Contributor

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/auto for an OpenRouter-only configuration.

When ENABLE_OPENROUTER=true and ENABLE_ORCAROUTER=false, Line 103 makes LlmProvider.orcarouter.apikey_name return OPENROUTER_API_KEY. LlmModel.valid() then includes this model. LLMEngine._get_model() sends it as openrouter/orcarouter/auto instead of using ORCAROUTER_BASE_URL.

Exclude LlmModel.orcarouter unless OrcaRouter routing is enabled, or route explicit orcarouter/ models through OrcaRouter independent of the global toggle.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/notte-core/src/notte_core/common/config.py` around lines 163 - 166,
The default LlmModel.orcarouter configuration must not be advertised when only
OpenRouter is enabled, because it is incorrectly routed as
openrouter/orcarouter/auto. Update the model availability or routing logic
around LlmModel.orcarouter and LLMEngine._get_model so it is included only when
ENABLE_ORCAROUTER is enabled, or explicitly route orcarouter/ models through
ORCAROUTER_BASE_URL regardless of the global toggle.


@property
def provider(self) -> LlmProvider:
Expand Down Expand Up @@ -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
Expand Down
36 changes: 33 additions & 3 deletions packages/notte-llm/src/notte_llm/engine.py
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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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
Check if this security scanner issue is valid. If so, understand the root cause and fix it. If appropriate, update or add tests. Keep the change focused and preserve intended behavior.

<file name="packages/notte-llm/src/notte_llm/engine.py">
<violation number="1" location="packages/notte-llm/src/notte_llm/engine.py:586">
<priority>P1</priority>
<title>Missing ORCAROUTER_API_KEY causes OpenAI API key leakage to third-party endpoint</title>
<evidence>When `ENABLE_ORCAROUTER=true` is set but `ORCAROUTER_API_KEY` is missing, the code unconditionally configures `base_url=https://api.orcarouter.ai/v1` while only conditionally adding `api_key` to `completion_kwargs`. Because `_get_model()` prefixes the model with `openai/` for LiteLLM compatibility, LiteLLM's OpenAI provider falls back to the `OPENAI_API_KEY` environment variable when no explicit `api_key` is passed. This causes the user's OpenAI API key to be transmitted to the OrcaRouter endpoint without explicit consent.</evidence>
<recommendation>Validate that `ORCAROUTER_API_KEY` is present before setting `base_url`. Raise a clear `ValueError` or configuration error when `ENABLE_ORCAROUTER=true` but the required API key is missing. For example:
```python
if enable_orcarouter():
    orcarouter_api_key = os.environ.get('ORCAROUTER_API_KEY')
    if not orcarouter_api_key:
        raise ValueError('ORCAROUTER_API_KEY must be set when ENABLE_ORCAROUTER=true')
    completion_kwargs['base_url'] = ORCAROUTER_BASE_URL
    completion_kwargs['api_key'] = orcarouter_api_key
```</recommendation>
</violation>
</file>

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,
Expand All @@ -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)
Expand Down
110 changes: 110 additions & 0 deletions tests/config/test_orcarouter_provider.py
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/",
)
)
31 changes: 31 additions & 0 deletions tests/llms/test_engine.py
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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reset _enable_orcarouter in a finally block.

If llm_engine.completion() or a later assertion fails, Line 67 does not run. patch.dict restores the environment, but the cached value remains True. Later tests can then route through OrcaRouter unexpectedly.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
with patch.dict(os.environ, {"ENABLE_ORCAROUTER": "true", "ORCAROUTER_API_KEY": "sk-orca-test"}):
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
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/llms/test_engine.py` around lines 63 - 67, Wrap the completion call and
any related assertions in a try/finally within the ENABLE_ORCAROUTER environment
patch, and reset notte_config._enable_orcarouter to None in the finally block.
Remove the existing trailing reset so cleanup occurs even when the test fails.


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")
Expand Down
Loading