diff --git a/.env.example b/.env.example index 04621556a0..2f67373d76 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,7 @@ BROWSER_USE_API_KEY=your_bu_api_key_here # Model Configuration (optional - use if you want to use other LLM providers) # Default LLM model to use +# AIMLAPI_API_KEY= # OPENAI_API_KEY=your_openai_api_key_here # ANTHROPIC_API_KEY=your_anthropic_api_key_here # AZURE_OPENAI_API_KEY= diff --git a/browser_use/__init__.py b/browser_use/__init__.py index 6fa2a790d6..814efe68b2 100644 --- a/browser_use/__init__.py +++ b/browser_use/__init__.py @@ -52,6 +52,7 @@ def _patched_del(self): from browser_use.browser import BrowserSession as Browser from browser_use.dom.service import DomService from browser_use.llm import models + from browser_use.llm.aimlapi.chat import ChatAIMLAPI from browser_use.llm.anthropic.chat import ChatAnthropic from browser_use.llm.aws.chat_anthropic import ChatAnthropicBedrock from browser_use.llm.aws.chat_bedrock import ChatAWSBedrock @@ -97,6 +98,7 @@ def _patched_del(self): 'ChatAnthropicBedrock': ('browser_use.llm.aws.chat_anthropic', 'ChatAnthropicBedrock'), 'ChatAWSBedrock': ('browser_use.llm.aws.chat_bedrock', 'ChatAWSBedrock'), 'ChatBrowserUse': ('browser_use.llm.browser_use.chat', 'ChatBrowserUse'), + 'ChatAIMLAPI': ('browser_use.llm.aimlapi.chat', 'ChatAIMLAPI'), 'ChatCerebras': ('browser_use.llm.cerebras.chat', 'ChatCerebras'), 'ChatDeepSeek': ('browser_use.llm.deepseek.chat', 'ChatDeepSeek'), 'ChatGroq': ('browser_use.llm.groq.chat', 'ChatGroq'), @@ -149,6 +151,7 @@ def __getattr__(name: str): 'ActionModel', 'AgentHistoryList', # Chat models + 'ChatAIMLAPI', 'ChatOpenAI', 'ChatGoogle', 'ChatAnthropic', diff --git a/browser_use/llm/__init__.py b/browser_use/llm/__init__.py index 5bba93b1bb..848b8255a7 100644 --- a/browser_use/llm/__init__.py +++ b/browser_use/llm/__init__.py @@ -26,6 +26,7 @@ # Type stubs for lazy imports if TYPE_CHECKING: + from browser_use.llm.aimlapi.chat import ChatAIMLAPI from browser_use.llm.anthropic.chat import ChatAnthropic from browser_use.llm.aws.chat_anthropic import ChatAnthropicBedrock from browser_use.llm.aws.chat_bedrock import ChatAWSBedrock @@ -80,6 +81,7 @@ # Lazy imports mapping for heavy chat models _LAZY_IMPORTS = { + 'ChatAIMLAPI': ('browser_use.llm.aimlapi.chat', 'ChatAIMLAPI'), 'ChatAnthropic': ('browser_use.llm.anthropic.chat', 'ChatAnthropic'), 'ChatAnthropicBedrock': ('browser_use.llm.aws.chat_anthropic', 'ChatAnthropicBedrock'), 'ChatAWSBedrock': ('browser_use.llm.aws.chat_bedrock', 'ChatAWSBedrock'), @@ -144,6 +146,7 @@ def __getattr__(name: str): 'ContentRefusal', 'ContentImage', # Chat models + 'ChatAIMLAPI', 'BaseChatModel', 'ChatOpenAI', 'ChatBrowserUse', diff --git a/browser_use/llm/aimlapi/chat.py b/browser_use/llm/aimlapi/chat.py new file mode 100644 index 0000000000..92312c86be --- /dev/null +++ b/browser_use/llm/aimlapi/chat.py @@ -0,0 +1,280 @@ +import os +from collections.abc import Mapping +from dataclasses import dataclass +from types import MappingProxyType +from typing import Any, TypeVar, overload +from urllib.parse import urlsplit + +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.aimlapi.serializer import AIMLAPIMessageSerializer +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.schema import SchemaOptimizer +from browser_use.llm.views import ChatInvokeCompletion, ChatInvokeUsage + +T = TypeVar('T', bound=BaseModel) + +AIMLAPI_BASE_URL = 'https://api.aimlapi.com/v1' + +# Identifies Browser Use as the calling application. HTTP-Referer / X-Title follow the +# same convention ChatOpenRouter already uses and name the *host* project, not the +# gateway. Immutable on purpose: _get_attribution_headers() copies it per request. +_ATTRIBUTION_HEADERS: Mapping[str, str] = MappingProxyType( + { + 'HTTP-Referer': 'https://github.com/browser-use/browser-use', + 'X-Title': 'Browser Use', + 'X-AIMLAPI-Source': 'agent/browser-use', + 'X-AIMLAPI-Partner-ID': 'part_DtfcGF9FcEYD50B1yIkFL8a6', + } +) + + +def _is_aimlapi_origin(base_url: str | httpx.URL) -> bool: + """Whether base_url points at aimlapi.com itself rather than a user-supplied proxy.""" + parts = urlsplit(str(base_url)) + return parts.scheme == 'https' and parts.hostname == 'api.aimlapi.com' + + +@dataclass +class ChatAIMLAPI(BaseChatModel): + """ + A wrapper around the aimlapi.com OpenAI-compatible chat API, which routes to 350+ chat + models from OpenAI, Anthropic, Google, DeepSeek, Qwen, xAI and others behind one endpoint. + + This class implements the BaseChatModel protocol for the aimlapi.com 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 = AIMLAPI_BASE_URL + 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 'aimlapi' + + 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 aimlapi.com endpoint. + key = self.api_key or os.getenv('AIMLAPI_API_KEY') + if not key: + raise ModelProviderError('Missing aimlapi.com API key', status_code=401, model=self.name) + return key + + def _get_attribution_headers(self) -> dict[str, str]: + """Attribution headers, scoped to the aimlapi.com origin. + + Returns a fresh dict so the module-level constant can never be mutated, and returns + nothing when base_url was pointed elsewhere - attribution must not ride a request to + someone else's API, including a proxy that merely fronts this one. + """ + if not _is_aimlapi_origin(self.base_url): + return {} + return dict(_ATTRIBUTION_HEADERS) + + def _get_request_params(self) -> dict[str, Any]: + """Model params for a completion request, with unset ones omitted. + + The gateway validates optional fields strictly and rejects an explicit + `"top_p": null` / `"seed": null` with a 400, which is what the OpenAI SDK puts on + the wire for a `None` argument. Leaving the key out entirely is the portable form. + """ + params = {'temperature': self.temperature, 'top_p': self.top_p, 'seed': self.seed} + return {k: v for k, v in params.items() if v is not None} + + def _get_client_params(self) -> dict[str, Any]: + """Prepare client parameters dictionary.""" + # Merge rather than assign: a caller who set their own default_headers keeps them. + default_headers = {**self._get_attribution_headers(), **(self.default_headers or {})} + + # 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': default_headers or None, + '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 aimlapi.com. + + Returns: + AsyncOpenAI: An instance of the AsyncOpenAI client with the aimlapi.com 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 aimlapi.com 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, + ) + + def _get_first_choice(self, response: ChatCompletion): + """Return the first choice, or raise with a hint about proxied base URLs.""" + choice = response.choices[0] if response.choices else None + if choice is not None: + return choice + + 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 aimlapi.com 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, + ) + + @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 aimlapi.com. + + 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 + """ + aimlapi_messages = AIMLAPIMessageSerializer.serialize_messages(messages) + + try: + if output_format is None: + # Return string response + response = await self.get_client().chat.completions.create( + model=self.model, + messages=aimlapi_messages, + **self._get_request_params(), + **(self.extra_body or {}), + ) + + choice = self._get_first_choice(response) + 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=aimlapi_messages, + **self._get_request_params(), + response_format=ResponseFormatJSONSchema( + json_schema=response_format_schema, + type='json_schema', + ), + **(self.extra_body or {}), + ) + + choice = self._get_first_choice(response) + + 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/aimlapi/serializer.py b/browser_use/llm/aimlapi/serializer.py new file mode 100644 index 0000000000..860a4ef06c --- /dev/null +++ b/browser_use/llm/aimlapi/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 AIMLAPIMessageSerializer: + """ + Serializer for converting between custom message types and aimlapi.com message formats. + + aimlapi.com 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 aimlapi.com-compatible messages. + + Args: + messages: List of browser_use messages + + Returns: + List of aimlapi.com-compatible messages (identical to OpenAI format) + """ + # aimlapi.com 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 6395a3837a..67a7613d65 100644 --- a/browser_use/tokens/service.py +++ b/browser_use/tokens/service.py @@ -408,6 +408,9 @@ def _get_pricing_model_name(self, llm: BaseChatModel) -> str: # 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}' + # aimlapi.com is a gateway with its own pricing; never attribute upstream prices to it. + if llm.provider == 'aimlapi' or base_url == 'https://api.aimlapi.com/v1': + return f'aimlapi/{model}' return model diff --git a/examples/models/aimlapi.py b/examples/models/aimlapi.py new file mode 100644 index 0000000000..fbf4c1739b --- /dev/null +++ b/examples/models/aimlapi.py @@ -0,0 +1,30 @@ +""" +Simple try of the agent with aimlapi.com. + +@dev You need to add AIMLAPI_API_KEY to your environment variables. +""" + +import asyncio + +from dotenv import load_dotenv + +from browser_use import Agent, ChatAIMLAPI + +load_dotenv() + +# aimlapi.com is an OpenAI-compatible gateway routing to 350+ chat models via one endpoint. +# Pick any id from https://api.aimlapi.com/v1/models that reports the `structured_output` +# capability - browser-use drives the agent through JSON-schema structured output. +llm = ChatAIMLAPI(model='anthropic/claude-sonnet-4.6') +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/skills/open-source/references/models.md b/skills/open-source/references/models.md index 497d6fbbb2..6447d2b8e8 100644 --- a/skills/open-source/references/models.md +++ b/skills/open-source/references/models.md @@ -6,6 +6,7 @@ Browser Use natively supports 15+ LLM providers. Most providers accept any model | Provider | Class | Env Variable | |----------|-------|--------------| +| aimlapi.com | `ChatAIMLAPI` | `AIMLAPI_API_KEY` | | Browser Use Cloud | `ChatBrowserUse` | `BROWSER_USE_API_KEY` | | OpenAI | `ChatOpenAI` | `OPENAI_API_KEY` | | Anthropic | `ChatAnthropic` | `ANTHROPIC_API_KEY` | @@ -33,6 +34,7 @@ Based on our [benchmark of real-world browser tasks](https://browser-use.com/pos - **Fast + capable**: `gemini-3-1-pro` — 59.3% accuracy ## Table of Contents +- [aimlapi.com](#aimlapicom) - [Browser Use Cloud (Recommended)](#browser-use-cloud) - [OpenAI](#openai) - [Anthropic](#anthropic) @@ -52,6 +54,18 @@ Based on our [benchmark of real-world browser tasks](https://browser-use.com/pos --- +## aimlapi.com + +Access 350+ chat models from OpenAI, Anthropic, Google, DeepSeek, Qwen and xAI through a single OpenAI-compatible API. + +```python +from browser_use import Agent, ChatAIMLAPI + +llm = ChatAIMLAPI(model="anthropic/claude-sonnet-4.6") +``` + +**Env:** `AIMLAPI_API_KEY` | [Available models](https://api.aimlapi.com/v1/models) + ## Browser Use Cloud Optimized for browser automation — highest accuracy, fastest speed, lowest token cost. diff --git a/tests/ci/test_aimlapi.py b/tests/ci/test_aimlapi.py new file mode 100644 index 0000000000..dacca5dcf4 --- /dev/null +++ b/tests/ci/test_aimlapi.py @@ -0,0 +1,140 @@ +import re + +import pytest + +from browser_use.llm.aimlapi.chat import _ATTRIBUTION_HEADERS, ChatAIMLAPI +from browser_use.llm.aimlapi.serializer import AIMLAPIMessageSerializer +from browser_use.llm.exceptions import ModelProviderError +from browser_use.llm.messages import ContentPartTextParam, SystemMessage, UserMessage +from browser_use.llm.views import ChatInvokeUsage +from browser_use.tokens.service import TokenCost + +# Mirrors the gateway-side contract; a malformed id is dropped silently at runtime. +PARTNER_ID_PATTERN = re.compile(r'^part_[A-Za-z0-9]{1,64}$') + + +def test_aimlapi_serializer_uses_openai_format() -> None: + """aimlapi.com 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 = AIMLAPIMessageSerializer.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_aimlapi_chat_defaults() -> None: + """ChatAIMLAPI must expose the aimlapi provider and default gateway base URL.""" + chat = ChatAIMLAPI(model='anthropic/claude-sonnet-4.6', api_key='test-key') + + assert chat.provider == 'aimlapi' + assert str(chat.base_url) == 'https://api.aimlapi.com/v1' + assert chat.name == 'anthropic/claude-sonnet-4.6' + + +async def test_registered_aimlapi_llm_never_matches_upstream_pricing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """aimlapi.com 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(ChatAIMLAPI(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 == ['aimlapi/openai/gpt-4o-mini'] + assert cost is None + + +def test_aimlapi_reads_api_key_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + """AIMLAPI_API_KEY is the documented env var, so it must actually be read.""" + monkeypatch.setenv('AIMLAPI_API_KEY', 'aimlapi-key') + monkeypatch.setenv('OPENAI_API_KEY', 'sk-unrelated-openai-key') + + client = ChatAIMLAPI(model='anthropic/claude-sonnet-4.6').get_client() + + assert client.api_key == 'aimlapi-key' + + +def test_aimlapi_never_falls_back_to_the_openai_key(monkeypatch: pytest.MonkeyPatch) -> None: + """An unset aimlapi.com key must fail loudly, not ship OPENAI_API_KEY to the gateway.""" + monkeypatch.delenv('AIMLAPI_API_KEY', raising=False) + monkeypatch.setenv('OPENAI_API_KEY', 'sk-unrelated-openai-key') + + with pytest.raises(ModelProviderError) as exc_info: + ChatAIMLAPI(model='anthropic/claude-sonnet-4.6').get_client() + + assert exc_info.value.status_code == 401 + assert 'sk-unrelated-openai-key' not in str(exc_info.value) + + +def test_aimlapi_omits_unset_model_params() -> None: + """The gateway 400s on an explicit `"top_p": null`, so unset params must not be sent.""" + assert ChatAIMLAPI(model='openai/gpt-4o-mini', api_key='test-key')._get_request_params() == {} + + chat = ChatAIMLAPI(model='openai/gpt-4o-mini', api_key='test-key', temperature=0.0, seed=7) + + assert chat._get_request_params() == {'temperature': 0.0, 'seed': 7} + + +def test_aimlapi_partner_id_header_is_well_formed() -> None: + """A partner id that does not match the gateway pattern is dropped without any error.""" + assert PARTNER_ID_PATTERN.match(_ATTRIBUTION_HEADERS['X-AIMLAPI-Partner-ID']) + + +def test_aimlapi_sends_attribution_headers_naming_the_host_project() -> None: + """HTTP-Referer / X-Title identify browser-use, the calling app - not the gateway.""" + headers = ChatAIMLAPI(model='openai/gpt-4o-mini', api_key='test-key')._get_client_params()['default_headers'] + + assert headers['X-AIMLAPI-Source'] == 'agent/browser-use' + assert headers['HTTP-Referer'] == 'https://github.com/browser-use/browser-use' + assert headers['X-Title'] == 'Browser Use' + + +def test_aimlapi_user_headers_win_and_the_shared_constant_is_never_mutated() -> None: + """Merging, not assigning: caller headers survive and the module constant stays clean.""" + chat = ChatAIMLAPI( + model='openai/gpt-4o-mini', + api_key='test-key', + default_headers={'X-Title': 'My App', 'X-Custom': 'kept'}, + ) + + headers = chat._get_client_params()['default_headers'] + + assert headers['X-Title'] == 'My App' + assert headers['X-Custom'] == 'kept' + assert headers['X-AIMLAPI-Partner-ID'] == _ATTRIBUTION_HEADERS['X-AIMLAPI-Partner-ID'] + # The shared constant must be untouched for every other instance. + assert dict(_ATTRIBUTION_HEADERS)['X-Title'] == 'Browser Use' + assert 'X-Custom' not in _ATTRIBUTION_HEADERS + + +def test_aimlapi_attribution_never_rides_a_request_to_another_host() -> None: + """Pointing base_url at a proxy or another vendor must not leak attribution headers.""" + chat = ChatAIMLAPI(model='openai/gpt-4o-mini', api_key='test-key', base_url='https://example.com/v1') + + assert chat._get_client_params().get('default_headers') is None