From d8580caf01e0777c756e5ebe52f4cf242d998f70 Mon Sep 17 00:00:00 2001 From: XiaoHuo888 Date: Wed, 12 Aug 2026 08:05:12 +0800 Subject: [PATCH 1/4] Add OrcaRouter as a named LLM provider Registers ChatOrcaRouter (provider='orcarouter'), an OpenAI-compatible BaseChatModel mirroring the existing ChatOpenRouter wiring, so the model gateway is usable as a first-class provider: - browser_use/llm/orcarouter/chat.py + serializer.py - Registered in browser_use/llm/__init__.py and browser_use/__init__.py - Token-cost guard: never attribute upstream prices to the gateway - .env.example ORCAROUTER_API_KEY entry - tests/ci/test_orcarouter.py and examples/models/orcarouter.py Co-Authored-By: Claude --- .env.example | 1 + browser_use/__init__.py | 3 + browser_use/llm/__init__.py | 3 + browser_use/llm/orcarouter/chat.py | 205 +++++++++++++++++++++++ browser_use/llm/orcarouter/serializer.py | 26 +++ browser_use/tokens/service.py | 3 + examples/models/orcarouter.py | 33 ++++ tests/ci/test_orcarouter.py | 64 +++++++ 8 files changed, 338 insertions(+) create mode 100644 browser_use/llm/orcarouter/chat.py create mode 100644 browser_use/llm/orcarouter/serializer.py create mode 100644 examples/models/orcarouter.py create mode 100644 tests/ci/test_orcarouter.py 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/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..c3d91c9d6 --- /dev/null +++ b/browser_use/llm/orcarouter/chat.py @@ -0,0 +1,205 @@ +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_client_params(self) -> dict[str, Any]: + """Prepare client parameters dictionary.""" + # Define base client params + base_params = { + 'api_key': self.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, + 'top_p': self.top_p, + 'seed': self.seed, + } + + # 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 {}), + ) + + usage = self._get_usage(response) + return ChatInvokeCompletion( + completion=response.choices[0].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 {}), + ) + + if response.choices[0].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(response.choices[0].message.content) + + return ChatInvokeCompletion( + completion=parsed, + usage=usage, + ) + + 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..6314b2a9e --- /dev/null +++ b/examples/models/orcarouter.py @@ -0,0 +1,33 @@ +""" +Simple try of the agent with OrcaRouter. + +@dev You need to add ORCAROUTER_API_KEY to your environment variables. +""" + +import asyncio +import os + +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', + base_url='https://api.orcarouter.ai/v1', + api_key=os.getenv('ORCAROUTER_API_KEY'), +) +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..1dcaa9e92 --- /dev/null +++ b/tests/ci/test_orcarouter.py @@ -0,0 +1,64 @@ +import pytest + +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 From 82a673b3fa3e99d557882b3222a83aa8fe1e19be Mon Sep 17 00:00:00 2001 From: XiaoHuo888-hue Date: Wed, 19 Aug 2026 16:29:10 +0800 Subject: [PATCH 2/4] fix(orcarouter): address review feedback on ChatOrcaRouter - Do not pass top_p/seed into the AsyncOpenAI constructor; they are request-level params and already forwarded to chat.completions.create, so setting them raised TypeError on client init (matches ChatOpenAI) - Guard empty response.choices in both the text and structured paths and raise a descriptive ModelProviderError instead of an IndexError - Re-raise ModelProviderError before the generic handler so the locally chosen status_code (500) is preserved instead of being re-wrapped --- browser_use/llm/orcarouter/chat.py | 42 ++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/browser_use/llm/orcarouter/chat.py b/browser_use/llm/orcarouter/chat.py index c3d91c9d6..66523aba7 100644 --- a/browser_use/llm/orcarouter/chat.py +++ b/browser_use/llm/orcarouter/chat.py @@ -65,8 +65,6 @@ class ChatOrcaRouter(BaseChatModel): 'default_headers': self.default_headers, 'default_query': self.default_query, '_strict_response_validation': self._strict_response_validation, - 'top_p': self.top_p, - 'seed': self.seed, } # Create client_params dict with non-None values @@ -147,9 +145,24 @@ class ChatOrcaRouter(BaseChatModel): **(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=response.choices[0].message.content or '', + completion=choice.message.content or '', usage=usage, ) @@ -177,7 +190,22 @@ class ChatOrcaRouter(BaseChatModel): **(self.extra_body or {}), ) - if response.choices[0].message.content is None: + 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, @@ -185,13 +213,17 @@ class ChatOrcaRouter(BaseChatModel): ) usage = self._get_usage(response) - parsed = output_format.model_validate_json(response.choices[0].message.content) + 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 From df2701e524819dbde580b0010e9f18be9a8b6922 Mon Sep 17 00:00:00 2001 From: Growth Radar QA Date: Sun, 30 Aug 2026 22:57:42 -0700 Subject: [PATCH 3/4] fix(browser): preserve default Chrome arg order --- browser_use/browser/profile.py | 3 ++- tests/ci/test_profile_args.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 tests/ci/test_profile_args.py 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/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 From be76e6cceef4c50e23106aa7be1ddc9362ccb755 Mon Sep 17 00:00:00 2001 From: Saurav Panda Date: Sat, 29 Aug 2026 15:39:29 -0700 Subject: [PATCH 4/4] fix(orcarouter): resolve the API key explicitly instead of inheriting OPENAI_API_KEY AsyncOpenAI falls back to OPENAI_API_KEY when api_key is unset, so ChatOrcaRouter(model=...) with no key authenticated its requests to api.orcarouter.ai with the user's OpenAI credentials. The shipped example hit this path whenever ORCAROUTER_API_KEY was unset, and .env.example documented a variable that nothing read. Resolve api_key from the constructor then ORCAROUTER_API_KEY, and raise ModelProviderError(401) when neither is set, matching ChatMistral. --- browser_use/llm/orcarouter/chat.py | 11 ++++++++++- examples/models/orcarouter.py | 7 +------ tests/ci/test_orcarouter.py | 23 +++++++++++++++++++++++ 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/browser_use/llm/orcarouter/chat.py b/browser_use/llm/orcarouter/chat.py index 66523aba7..e76ae4b52 100644 --- a/browser_use/llm/orcarouter/chat.py +++ b/browser_use/llm/orcarouter/chat.py @@ -1,3 +1,4 @@ +import os from collections.abc import Mapping from dataclasses import dataclass from typing import Any, TypeVar, overload @@ -54,11 +55,19 @@ class ChatOrcaRouter(BaseChatModel): 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.api_key, + 'api_key': self._get_api_key(), 'base_url': self.base_url, 'timeout': self.timeout, 'max_retries': self.max_retries, diff --git a/examples/models/orcarouter.py b/examples/models/orcarouter.py index 6314b2a9e..9fd9d28d6 100644 --- a/examples/models/orcarouter.py +++ b/examples/models/orcarouter.py @@ -5,7 +5,6 @@ Simple try of the agent with OrcaRouter. """ import asyncio -import os from dotenv import load_dotenv @@ -14,11 +13,7 @@ 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', - base_url='https://api.orcarouter.ai/v1', - api_key=os.getenv('ORCAROUTER_API_KEY'), -) +llm = ChatOrcaRouter(model='orcarouter/auto') agent = Agent( task='Find the number of stars of the browser-use repo', llm=llm, diff --git a/tests/ci/test_orcarouter.py b/tests/ci/test_orcarouter.py index 1dcaa9e92..15bb2c756 100644 --- a/tests/ci/test_orcarouter.py +++ b/tests/ci/test_orcarouter.py @@ -1,5 +1,6 @@ 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 @@ -62,3 +63,25 @@ async def test_registered_orcarouter_llm_never_matches_upstream_pricing( 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)