diff --git a/.env.example b/.env.example index 1ec212851..04621556a 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,7 @@ BROWSER_USE_API_KEY=your_bu_api_key_here # DEEPSEEK_API_KEY= # GROK_API_KEY= # NOVITA_API_KEY= +# ORCAROUTER_API_KEY= # AWS Bedrock Configuration (for AWS Bedrock models) # Requires: pip install browser-use[aws] diff --git a/browser_use/__init__.py b/browser_use/__init__.py index edd5e9f7b..6fa2a790d 100644 --- a/browser_use/__init__.py +++ b/browser_use/__init__.py @@ -67,6 +67,7 @@ if TYPE_CHECKING: from browser_use.llm.ollama.chat import ChatOllama from browser_use.llm.openai.chat import ChatOpenAI from browser_use.llm.openrouter.chat import ChatOpenRouter + from browser_use.llm.orcarouter.chat import ChatOrcaRouter from browser_use.llm.vercel.chat import ChatVercel from browser_use.sandbox import sandbox from browser_use.tools.service import Controller, Tools @@ -105,6 +106,7 @@ _LAZY_IMPORTS = { 'ChatOCIRaw': ('browser_use.llm.oci_raw.chat', 'ChatOCIRaw'), 'ChatOllama': ('browser_use.llm.ollama.chat', 'ChatOllama'), 'ChatOpenRouter': ('browser_use.llm.openrouter.chat', 'ChatOpenRouter'), + 'ChatOrcaRouter': ('browser_use.llm.orcarouter.chat', 'ChatOrcaRouter'), 'ChatVercel': ('browser_use.llm.vercel.chat', 'ChatVercel'), # LLM models module 'models': ('browser_use.llm.models', None), @@ -162,6 +164,7 @@ __all__ = [ 'ChatOCIRaw', 'ChatOllama', 'ChatOpenRouter', + 'ChatOrcaRouter', 'ChatVercel', 'Tools', 'Controller', diff --git a/browser_use/browser/profile.py b/browser_use/browser/profile.py index 1e0e82fb3..c5f4b737a 100644 --- a/browser_use/browser/profile.py +++ b/browser_use/browser/profile.py @@ -907,7 +907,8 @@ class BrowserProfile(BrowserConnectArgs, BrowserLaunchPersistentContextArgs, Bro """Get the list of all Chrome CLI launch args for this profile (compiled from defaults, user-provided, and system-specific).""" if isinstance(self.ignore_default_args, list): - default_args = set(CHROME_DEFAULT_ARGS) - set(self.ignore_default_args) + ignored_default_args = set(self.ignore_default_args) + default_args = [arg for arg in CHROME_DEFAULT_ARGS if arg not in ignored_default_args] elif self.ignore_default_args is True: default_args = [] elif not self.ignore_default_args: diff --git a/browser_use/llm/__init__.py b/browser_use/llm/__init__.py index d6d8464c9..5bba93b1b 100644 --- a/browser_use/llm/__init__.py +++ b/browser_use/llm/__init__.py @@ -40,6 +40,7 @@ if TYPE_CHECKING: from browser_use.llm.ollama.chat import ChatOllama from browser_use.llm.openai.chat import ChatOpenAI from browser_use.llm.openrouter.chat import ChatOpenRouter + from browser_use.llm.orcarouter.chat import ChatOrcaRouter from browser_use.llm.vercel.chat import ChatVercel # Type stubs for model instances - enables IDE autocomplete @@ -93,6 +94,7 @@ _LAZY_IMPORTS = { 'ChatOllama': ('browser_use.llm.ollama.chat', 'ChatOllama'), 'ChatOpenAI': ('browser_use.llm.openai.chat', 'ChatOpenAI'), 'ChatOpenRouter': ('browser_use.llm.openrouter.chat', 'ChatOpenRouter'), + 'ChatOrcaRouter': ('browser_use.llm.orcarouter.chat', 'ChatOrcaRouter'), 'ChatVercel': ('browser_use.llm.vercel.chat', 'ChatVercel'), } @@ -156,6 +158,7 @@ __all__ = [ 'ChatOCIRaw', 'ChatOllama', 'ChatOpenRouter', + 'ChatOrcaRouter', 'ChatVercel', 'ChatCerebras', ] diff --git a/browser_use/llm/orcarouter/chat.py b/browser_use/llm/orcarouter/chat.py new file mode 100644 index 000000000..e76ae4b52 --- /dev/null +++ b/browser_use/llm/orcarouter/chat.py @@ -0,0 +1,246 @@ +import os +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, TypeVar, overload + +import httpx +from openai import APIConnectionError, APIStatusError, AsyncOpenAI, RateLimitError +from openai.types.chat.chat_completion import ChatCompletion +from openai.types.shared_params.response_format_json_schema import ( + JSONSchema, + ResponseFormatJSONSchema, +) +from pydantic import BaseModel + +from browser_use.llm.base import BaseChatModel +from browser_use.llm.exceptions import ModelProviderError, ModelRateLimitError +from browser_use.llm.messages import BaseMessage +from browser_use.llm.orcarouter.serializer import OrcaRouterMessageSerializer +from browser_use.llm.schema import SchemaOptimizer +from browser_use.llm.views import ChatInvokeCompletion, ChatInvokeUsage + +T = TypeVar('T', bound=BaseModel) + + +@dataclass +class ChatOrcaRouter(BaseChatModel): + """ + A wrapper around OrcaRouter's OpenAI-compatible chat API, which routes to 190+ LLM models + through a single unified gateway. + + This class implements the BaseChatModel protocol for OrcaRouter's API. + """ + + # Model configuration + model: str + + # Model params + temperature: float | None = None + top_p: float | None = None + seed: int | None = None + + # Client initialization parameters + api_key: str | None = None + base_url: str | httpx.URL = 'https://api.orcarouter.ai/v1' + timeout: float | httpx.Timeout | None = None + max_retries: int = 10 + default_headers: Mapping[str, str] | None = None + default_query: Mapping[str, object] | None = None + http_client: httpx.AsyncClient | None = None + _strict_response_validation: bool = False + extra_body: dict[str, Any] | None = None + + # Static + @property + def provider(self) -> str: + return 'orcarouter' + + def _get_api_key(self) -> str: + # AsyncOpenAI falls back to OPENAI_API_KEY when api_key is unset, which would send an + # unrelated provider's key to the OrcaRouter endpoint. + key = self.api_key or os.getenv('ORCAROUTER_API_KEY') + if not key: + raise ModelProviderError('Missing OrcaRouter API key', status_code=401, model=self.name) + return key + + def _get_client_params(self) -> dict[str, Any]: + """Prepare client parameters dictionary.""" + # Define base client params + base_params = { + 'api_key': self._get_api_key(), + 'base_url': self.base_url, + 'timeout': self.timeout, + 'max_retries': self.max_retries, + 'default_headers': self.default_headers, + 'default_query': self.default_query, + '_strict_response_validation': self._strict_response_validation, + } + + # Create client_params dict with non-None values + client_params = {k: v for k, v in base_params.items() if v is not None} + + # Add http_client if provided + if self.http_client is not None: + client_params['http_client'] = self.http_client + + return client_params + + def get_client(self) -> AsyncOpenAI: + """ + Returns an AsyncOpenAI client configured for OrcaRouter. + + Returns: + AsyncOpenAI: An instance of the AsyncOpenAI client with OrcaRouter base URL. + """ + if not hasattr(self, '_client'): + client_params = self._get_client_params() + self._client = AsyncOpenAI(**client_params) + return self._client + + @property + def name(self) -> str: + return str(self.model) + + def _get_usage(self, response: ChatCompletion) -> ChatInvokeUsage | None: + """Extract usage information from the OrcaRouter response.""" + if response.usage is None: + return None + + prompt_details = getattr(response.usage, 'prompt_tokens_details', None) + cached_tokens = prompt_details.cached_tokens if prompt_details else None + + return ChatInvokeUsage( + prompt_tokens=response.usage.prompt_tokens, + prompt_cached_tokens=cached_tokens, + prompt_cache_creation_tokens=None, + prompt_image_tokens=None, + # Completion + completion_tokens=response.usage.completion_tokens, + total_tokens=response.usage.total_tokens, + ) + + @overload + async def ainvoke( + self, messages: list[BaseMessage], output_format: None = None, **kwargs: Any + ) -> ChatInvokeCompletion[str]: ... + + @overload + async def ainvoke(self, messages: list[BaseMessage], output_format: type[T], **kwargs: Any) -> ChatInvokeCompletion[T]: ... + + async def ainvoke( + self, messages: list[BaseMessage], output_format: type[T] | None = None, **kwargs: Any + ) -> ChatInvokeCompletion[T] | ChatInvokeCompletion[str]: + """ + Invoke the model with the given messages through OrcaRouter. + + Args: + messages: List of chat messages + output_format: Optional Pydantic model class for structured output + + Returns: + Either a string response or an instance of output_format + """ + orcarouter_messages = OrcaRouterMessageSerializer.serialize_messages(messages) + + try: + if output_format is None: + # Return string response + response = await self.get_client().chat.completions.create( + model=self.model, + messages=orcarouter_messages, + temperature=self.temperature, + top_p=self.top_p, + seed=self.seed, + **(self.extra_body or {}), + ) + + choice = response.choices[0] if response.choices else None + if choice is None: + base_url = str(self.base_url) if self.base_url is not None else None + hint = f' (base_url={base_url})' if base_url is not None else '' + raise ModelProviderError( + message=( + 'Invalid OrcaRouter chat completion response: missing or empty `choices`.' + ' If you are using a proxy via `base_url`, ensure it implements the OpenAI' + ' `/v1/chat/completions` schema and returns `choices` as a non-empty list.' + f'{hint}' + ), + status_code=502, + model=self.name, + ) + + usage = self._get_usage(response) + return ChatInvokeCompletion( + completion=choice.message.content or '', + usage=usage, + ) + + else: + # Create a JSON schema for structured output + schema = SchemaOptimizer.create_optimized_json_schema(output_format) + + response_format_schema: JSONSchema = { + 'name': 'agent_output', + 'strict': True, + 'schema': schema, + } + + # Return structured response + response = await self.get_client().chat.completions.create( + model=self.model, + messages=orcarouter_messages, + temperature=self.temperature, + top_p=self.top_p, + seed=self.seed, + response_format=ResponseFormatJSONSchema( + json_schema=response_format_schema, + type='json_schema', + ), + **(self.extra_body or {}), + ) + + choice = response.choices[0] if response.choices else None + if choice is None: + base_url = str(self.base_url) if self.base_url is not None else None + hint = f' (base_url={base_url})' if base_url is not None else '' + raise ModelProviderError( + message=( + 'Invalid OrcaRouter chat completion response: missing or empty `choices`.' + ' If you are using a proxy via `base_url`, ensure it implements the OpenAI' + ' `/v1/chat/completions` schema and returns `choices` as a non-empty list.' + f'{hint}' + ), + status_code=502, + model=self.name, + ) + + if choice.message.content is None: + raise ModelProviderError( + message='Failed to parse structured output from model response', + status_code=500, + model=self.name, + ) + usage = self._get_usage(response) + + parsed = output_format.model_validate_json(choice.message.content) + + return ChatInvokeCompletion( + completion=parsed, + usage=usage, + ) + + except ModelProviderError: + # Preserve status_code and message from validation errors + raise + + except RateLimitError as e: + raise ModelRateLimitError(message=e.message, model=self.name) from e + + except APIConnectionError as e: + raise ModelProviderError(message=str(e), model=self.name) from e + + except APIStatusError as e: + raise ModelProviderError(message=e.message, status_code=e.status_code, model=self.name) from e + + except Exception as e: + raise ModelProviderError(message=str(e), model=self.name) from e diff --git a/browser_use/llm/orcarouter/serializer.py b/browser_use/llm/orcarouter/serializer.py new file mode 100644 index 000000000..8659e8037 --- /dev/null +++ b/browser_use/llm/orcarouter/serializer.py @@ -0,0 +1,26 @@ +from openai.types.chat import ChatCompletionMessageParam + +from browser_use.llm.messages import BaseMessage +from browser_use.llm.openai.serializer import OpenAIMessageSerializer + + +class OrcaRouterMessageSerializer: + """ + Serializer for converting between custom message types and OrcaRouter message formats. + + OrcaRouter exposes an OpenAI-compatible API, so we can reuse the OpenAI serializer. + """ + + @staticmethod + def serialize_messages(messages: list[BaseMessage]) -> list[ChatCompletionMessageParam]: + """ + Serialize a list of browser_use messages to OrcaRouter-compatible messages. + + Args: + messages: List of browser_use messages + + Returns: + List of OrcaRouter-compatible messages (identical to OpenAI format) + """ + # OrcaRouter uses the same message format as OpenAI + return OpenAIMessageSerializer.serialize_messages(messages) diff --git a/browser_use/tokens/service.py b/browser_use/tokens/service.py index 37d9181a6..6395a3837 100644 --- a/browser_use/tokens/service.py +++ b/browser_use/tokens/service.py @@ -405,6 +405,9 @@ class TokenCost: if llm.provider == 'openrouter' or base_url == 'https://openrouter.ai/api/v1': if not is_openrouter_pricing_model(model): return f'openrouter/{model}' + # OrcaRouter is a gateway with its own pricing; never attribute upstream prices to it. + if llm.provider == 'orcarouter' or base_url == 'https://api.orcarouter.ai/v1': + return f'orcarouter/{model}' return model diff --git a/examples/models/orcarouter.py b/examples/models/orcarouter.py new file mode 100644 index 000000000..9fd9d28d6 --- /dev/null +++ b/examples/models/orcarouter.py @@ -0,0 +1,28 @@ +""" +Simple try of the agent with OrcaRouter. + +@dev You need to add ORCAROUTER_API_KEY to your environment variables. +""" + +import asyncio + +from dotenv import load_dotenv + +from browser_use import Agent, ChatOrcaRouter + +load_dotenv() + +# OrcaRouter is an OpenAI-compatible model gateway routing to 190+ models via one endpoint. +llm = ChatOrcaRouter(model='orcarouter/auto') +agent = Agent( + task='Find the number of stars of the browser-use repo', + llm=llm, + use_vision=False, +) + + +async def main(): + await agent.run(max_steps=10) + + +asyncio.run(main()) diff --git a/tests/ci/test_orcarouter.py b/tests/ci/test_orcarouter.py new file mode 100644 index 000000000..15bb2c756 --- /dev/null +++ b/tests/ci/test_orcarouter.py @@ -0,0 +1,87 @@ +import pytest + +from browser_use.llm.exceptions import ModelProviderError +from browser_use.llm.messages import ContentPartTextParam, SystemMessage, UserMessage +from browser_use.llm.orcarouter.chat import ChatOrcaRouter +from browser_use.llm.orcarouter.serializer import OrcaRouterMessageSerializer +from browser_use.llm.views import ChatInvokeUsage +from browser_use.tokens.service import TokenCost + + +def test_orcarouter_serializer_uses_openai_format() -> None: + """OrcaRouter speaks the OpenAI wire format, so the serializer must match OpenAI's.""" + messages = [ + SystemMessage(content=[ContentPartTextParam(text='You are a helpful assistant.', type='text')]), + UserMessage(content='What is the capital of France? Answer in one word.'), + ] + + serialized = OrcaRouterMessageSerializer.serialize_messages(messages) + + assert serialized == [ + {'role': 'system', 'content': [{'type': 'text', 'text': 'You are a helpful assistant.'}]}, + {'role': 'user', 'content': 'What is the capital of France? Answer in one word.'}, + ] + + +def test_orcarouter_chat_defaults() -> None: + """ChatOrcaRouter must expose the OrcaRouter provider and default gateway base URL.""" + chat = ChatOrcaRouter(model='orcarouter/auto', api_key='test-key') + + assert chat.provider == 'orcarouter' + assert str(chat.base_url) == 'https://api.orcarouter.ai/v1' + assert chat.name == 'orcarouter/auto' + + +async def test_registered_orcarouter_llm_never_matches_upstream_pricing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """OrcaRouter is a gateway; upstream model pricing must not be attributed to it.""" + seen_model_names = [] + + async def fake_openrouter_pricing(model_name: str): + seen_model_names.append(model_name) + return None + + monkeypatch.setattr('browser_use.tokens.service.get_openrouter_model_pricing', fake_openrouter_pricing) + + token_cost = TokenCost(include_cost=True) + token_cost._initialized = True + token_cost._pricing_data = {} + token_cost.register_llm(ChatOrcaRouter(model='openai/gpt-4o-mini', api_key='test-key')) + + cost = await token_cost.calculate_cost( + 'openai/gpt-4o-mini', + ChatInvokeUsage( + prompt_tokens=10, + prompt_cached_tokens=None, + prompt_cache_creation_tokens=None, + prompt_image_tokens=None, + completion_tokens=5, + total_tokens=15, + ), + ) + + assert seen_model_names == ['orcarouter/openai/gpt-4o-mini'] + assert cost is None + + +def test_orcarouter_reads_api_key_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + """ORCAROUTER_API_KEY is the documented env var, so it must actually be read.""" + monkeypatch.setenv('ORCAROUTER_API_KEY', 'orca-key') + monkeypatch.setenv('OPENAI_API_KEY', 'sk-unrelated-openai-key') + + client = ChatOrcaRouter(model='orcarouter/auto').get_client() + + assert client.api_key == 'orca-key' + + +def test_orcarouter_never_falls_back_to_the_openai_key(monkeypatch: pytest.MonkeyPatch) -> None: + """An unset OrcaRouter key must fail loudly, not ship OPENAI_API_KEY to the gateway.""" + monkeypatch.delenv('ORCAROUTER_API_KEY', raising=False) + monkeypatch.setenv('OPENAI_API_KEY', 'sk-unrelated-openai-key') + + with pytest.raises(ModelProviderError) as exc_info: + ChatOrcaRouter(model='orcarouter/auto').get_client() + + assert exc_info.value.status_code == 401 + assert 'sk-unrelated-openai-key' not in str(exc_info.value) diff --git a/tests/ci/test_profile_args.py b/tests/ci/test_profile_args.py new file mode 100644 index 000000000..592995ecd --- /dev/null +++ b/tests/ci/test_profile_args.py @@ -0,0 +1,32 @@ +from browser_use.browser.profile import CHROME_DEFAULT_ARGS, BrowserProfile + + +def test_get_args_keeps_default_order_when_ignoring_default_args(tmp_path): + profile = BrowserProfile( + user_data_dir=tmp_path, + ignore_default_args=['--disable-popup-blocking', '--no-default-browser-check'], + enable_default_extensions=False, + ) + + args = profile.get_args() + + expected_defaults = [ + arg for arg in CHROME_DEFAULT_ARGS if arg not in {'--disable-popup-blocking', '--no-default-browser-check'} + ] + actual_defaults = [arg for arg in args if arg in CHROME_DEFAULT_ARGS] + + assert actual_defaults == expected_defaults + assert '--disable-popup-blocking' not in args + assert '--no-default-browser-check' not in args + + +def test_get_args_keeps_default_order_with_default_ignored_args(tmp_path): + profile = BrowserProfile(user_data_dir=tmp_path, enable_default_extensions=False) + + args = profile.get_args() + + ignored_default_args = profile.ignore_default_args if isinstance(profile.ignore_default_args, list) else [] + expected_defaults = [arg for arg in CHROME_DEFAULT_ARGS if arg not in ignored_default_args] + actual_defaults = [arg for arg in args if arg in CHROME_DEFAULT_ARGS] + + assert actual_defaults == expected_defaults