From 9bcd1d05426e003d5b329e8df20be3e67755ee8b Mon Sep 17 00:00:00 2001 From: Stan Date: Thu, 3 Sep 2026 05:16:41 +0500 Subject: [PATCH 1/3] feat(aimlapi): add aimlapi.com as a named LLM provider Users can already reach aimlapi.com through ChatOpenAI(base_url=...), but that route has no env var, no discoverability, and - the part that actually costs users money - no entry in _get_pricing_model_name(), so a gateway-routed openai/gpt-4o-mini is priced against upstream OpenAI's rate card in the token cost display. Registering it as a named provider is the only way to get that carve-out, mirroring what OrcaRouter did. The provider is a near-verbatim sibling of browser_use/llm/orcarouter, with two deliberate differences: - Unset model params are omitted from the request instead of being sent as explicit nulls. The gateway validates optional fields strictly and answers `"top_p": null` with a 400; the OpenAI SDK serialises a None argument exactly that way, so the copied shape failed on every call made with default settings. - Attribution headers (HTTP-Referer / X-Title naming browser-use, plus the two X-AIMLAPI-* channel headers) are merged into default_headers and scoped to the api.aimlapi.com origin, so they cannot ride a request to a proxy configured through base_url. Caller-supplied headers win on a key clash, and the shared constant is never mutated. ChatOpenRouter already sets HTTP-Referer, so this is an existing mechanism rather than new machinery. Model ids used in the example, the docs and the tests were checked against the live catalog (ids and aliases) rather than copied from another gateway's list. --- .env.example | 1 + browser_use/__init__.py | 3 + browser_use/llm/__init__.py | 3 + browser_use/llm/aimlapi/chat.py | 280 ++++++++++++++++++++++++ browser_use/llm/aimlapi/serializer.py | 26 +++ browser_use/tokens/service.py | 3 + examples/models/aimlapi.py | 30 +++ skills/open-source/references/models.md | 14 ++ tests/ci/test_aimlapi.py | 140 ++++++++++++ 9 files changed, 500 insertions(+) create mode 100644 browser_use/llm/aimlapi/chat.py create mode 100644 browser_use/llm/aimlapi/serializer.py create mode 100644 examples/models/aimlapi.py create mode 100644 tests/ci/test_aimlapi.py diff --git a/.env.example b/.env.example index 04621556a0..76547eedc2 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,7 @@ BROWSER_USE_API_KEY=your_bu_api_key_here # GROK_API_KEY= # NOVITA_API_KEY= # ORCAROUTER_API_KEY= +# AIMLAPI_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 6fa2a790d6..9658866bb1 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 @@ -107,6 +108,7 @@ def _patched_del(self): 'ChatOllama': ('browser_use.llm.ollama.chat', 'ChatOllama'), 'ChatOpenRouter': ('browser_use.llm.openrouter.chat', 'ChatOpenRouter'), 'ChatOrcaRouter': ('browser_use.llm.orcarouter.chat', 'ChatOrcaRouter'), + 'ChatAIMLAPI': ('browser_use.llm.aimlapi.chat', 'ChatAIMLAPI'), 'ChatVercel': ('browser_use.llm.vercel.chat', 'ChatVercel'), # LLM models module 'models': ('browser_use.llm.models', None), @@ -165,6 +167,7 @@ def __getattr__(name: str): 'ChatOllama', 'ChatOpenRouter', 'ChatOrcaRouter', + 'ChatAIMLAPI', 'ChatVercel', 'Tools', 'Controller', diff --git a/browser_use/llm/__init__.py b/browser_use/llm/__init__.py index 5bba93b1bb..6919ecba1b 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 @@ -95,6 +96,7 @@ 'ChatOpenAI': ('browser_use.llm.openai.chat', 'ChatOpenAI'), 'ChatOpenRouter': ('browser_use.llm.openrouter.chat', 'ChatOpenRouter'), 'ChatOrcaRouter': ('browser_use.llm.orcarouter.chat', 'ChatOrcaRouter'), + 'ChatAIMLAPI': ('browser_use.llm.aimlapi.chat', 'ChatAIMLAPI'), 'ChatVercel': ('browser_use.llm.vercel.chat', 'ChatVercel'), } @@ -159,6 +161,7 @@ def __getattr__(name: str): 'ChatOllama', 'ChatOpenRouter', 'ChatOrcaRouter', + 'ChatAIMLAPI', 'ChatVercel', 'ChatCerebras', ] diff --git a/browser_use/llm/aimlapi/chat.py b/browser_use/llm/aimlapi/chat.py new file mode 100644 index 0000000000..99b5f03e0d --- /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_browseruse', + } +) + + +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..16b9f7e382 100644 --- a/skills/open-source/references/models.md +++ b/skills/open-source/references/models.md @@ -18,6 +18,7 @@ Browser Use natively supports 15+ LLM providers. Most providers accept any model | Cerebras | `ChatCerebras` | `CEREBRAS_API_KEY` | | Ollama | `ChatOllama` | — | | OpenRouter | `ChatOpenRouter` | `OPENROUTER_API_KEY` | +| aimlapi.com | `ChatAIMLAPI` | `AIMLAPI_API_KEY` | | Vercel AI Gateway | `ChatVercel` | `AI_GATEWAY_API_KEY` | | OCI (Oracle) | `ChatOCIRaw` | OCI config file | | LiteLLM | `ChatLiteLLM` | Provider-specific | @@ -45,6 +46,7 @@ Based on our [benchmark of real-world browser tasks](https://browser-use.com/pos - [Cerebras](#cerebras) - [Ollama (Local)](#ollama-local) - [OpenRouter](#openrouter) +- [aimlapi.com](#aimlapicom) - [Vercel AI Gateway](#vercel-ai-gateway) - [OCI (Oracle)](#oci-oracle) - [LiteLLM (100+ Providers)](#litellm-100-providers) @@ -207,6 +209,18 @@ llm = ChatOpenRouter(model="anthropic/claude-sonnet-4-6") **Env:** `OPENROUTER_API_KEY` | [Available models](https://openrouter.ai/models) +## 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) + ## Vercel AI Gateway Proxy to multiple providers with automatic fallback: 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 From fc12daeb20fd7aec120b6ab03750c9835dc2e0bc Mon Sep 17 00:00:00 2001 From: Stan Date: Thu, 3 Sep 2026 05:17:44 +0500 Subject: [PATCH 2/3] =?UTF-8?q?chore(aimlapi):=20fork-only=20placement=20?= =?UTF-8?q?=E2=80=94=20do=20not=20send=20upstream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves aimlapi.com to the front of the hand-ordered provider lists: the Quick Reference table, the table of contents and the provider sections in skills/open-source/references/models.md, the commented key list in .env.example, and the Chat* entries in the two lazy-import registries. This is partnership placement, not a functional change, and it is deliberately isolated in one commit so it can be dropped before any upstream PR. Nothing here alters behaviour; the TYPE_CHECKING import blocks are left alone because ruff/isort orders them. No "Recommended" badge is claimed. The repo has that concept, but it is bound to accuracy figures in "Recommendations by Use Case" that we have no benchmark for, and AGENTS.md reserves it: "always default to and recommend the model `ChatBrowserUse`". --- .env.example | 2 +- browser_use/__init__.py | 4 ++-- browser_use/llm/__init__.py | 4 ++-- skills/open-source/references/models.md | 28 ++++++++++++------------- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.env.example b/.env.example index 76547eedc2..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= @@ -40,7 +41,6 @@ BROWSER_USE_API_KEY=your_bu_api_key_here # GROK_API_KEY= # NOVITA_API_KEY= # ORCAROUTER_API_KEY= -# AIMLAPI_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 9658866bb1..814efe68b2 100644 --- a/browser_use/__init__.py +++ b/browser_use/__init__.py @@ -98,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'), @@ -108,7 +109,6 @@ def _patched_del(self): 'ChatOllama': ('browser_use.llm.ollama.chat', 'ChatOllama'), 'ChatOpenRouter': ('browser_use.llm.openrouter.chat', 'ChatOpenRouter'), 'ChatOrcaRouter': ('browser_use.llm.orcarouter.chat', 'ChatOrcaRouter'), - 'ChatAIMLAPI': ('browser_use.llm.aimlapi.chat', 'ChatAIMLAPI'), 'ChatVercel': ('browser_use.llm.vercel.chat', 'ChatVercel'), # LLM models module 'models': ('browser_use.llm.models', None), @@ -151,6 +151,7 @@ def __getattr__(name: str): 'ActionModel', 'AgentHistoryList', # Chat models + 'ChatAIMLAPI', 'ChatOpenAI', 'ChatGoogle', 'ChatAnthropic', @@ -167,7 +168,6 @@ def __getattr__(name: str): 'ChatOllama', 'ChatOpenRouter', 'ChatOrcaRouter', - 'ChatAIMLAPI', 'ChatVercel', 'Tools', 'Controller', diff --git a/browser_use/llm/__init__.py b/browser_use/llm/__init__.py index 6919ecba1b..848b8255a7 100644 --- a/browser_use/llm/__init__.py +++ b/browser_use/llm/__init__.py @@ -81,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'), @@ -96,7 +97,6 @@ 'ChatOpenAI': ('browser_use.llm.openai.chat', 'ChatOpenAI'), 'ChatOpenRouter': ('browser_use.llm.openrouter.chat', 'ChatOpenRouter'), 'ChatOrcaRouter': ('browser_use.llm.orcarouter.chat', 'ChatOrcaRouter'), - 'ChatAIMLAPI': ('browser_use.llm.aimlapi.chat', 'ChatAIMLAPI'), 'ChatVercel': ('browser_use.llm.vercel.chat', 'ChatVercel'), } @@ -146,6 +146,7 @@ def __getattr__(name: str): 'ContentRefusal', 'ContentImage', # Chat models + 'ChatAIMLAPI', 'BaseChatModel', 'ChatOpenAI', 'ChatBrowserUse', @@ -161,7 +162,6 @@ def __getattr__(name: str): 'ChatOllama', 'ChatOpenRouter', 'ChatOrcaRouter', - 'ChatAIMLAPI', 'ChatVercel', 'ChatCerebras', ] diff --git a/skills/open-source/references/models.md b/skills/open-source/references/models.md index 16b9f7e382..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` | @@ -18,7 +19,6 @@ Browser Use natively supports 15+ LLM providers. Most providers accept any model | Cerebras | `ChatCerebras` | `CEREBRAS_API_KEY` | | Ollama | `ChatOllama` | — | | OpenRouter | `ChatOpenRouter` | `OPENROUTER_API_KEY` | -| aimlapi.com | `ChatAIMLAPI` | `AIMLAPI_API_KEY` | | Vercel AI Gateway | `ChatVercel` | `AI_GATEWAY_API_KEY` | | OCI (Oracle) | `ChatOCIRaw` | OCI config file | | LiteLLM | `ChatLiteLLM` | Provider-specific | @@ -34,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) @@ -46,7 +47,6 @@ Based on our [benchmark of real-world browser tasks](https://browser-use.com/pos - [Cerebras](#cerebras) - [Ollama (Local)](#ollama-local) - [OpenRouter](#openrouter) -- [aimlapi.com](#aimlapicom) - [Vercel AI Gateway](#vercel-ai-gateway) - [OCI (Oracle)](#oci-oracle) - [LiteLLM (100+ Providers)](#litellm-100-providers) @@ -54,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. @@ -209,18 +221,6 @@ llm = ChatOpenRouter(model="anthropic/claude-sonnet-4-6") **Env:** `OPENROUTER_API_KEY` | [Available models](https://openrouter.ai/models) -## 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) - ## Vercel AI Gateway Proxy to multiple providers with automatic fallback: From d113e91c64ce1df6558d54f57b784a537a39f6d7 Mon Sep 17 00:00:00 2001 From: Stan Date: Thu, 3 Sep 2026 18:09:22 +0500 Subject: [PATCH 3/3] fix(aimlapi): use the registered partner id The placeholder part_browseruse was a readable stand-in chosen before the partner was registered. Registration mints the id server-side, so the real value is part_DtfcGF9FcEYD50B1yIkFL8a6. A wrong or unknown partner id is accepted with a 200 and silently not attributed, so this would not have surfaced at runtime. --- browser_use/llm/aimlapi/chat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/browser_use/llm/aimlapi/chat.py b/browser_use/llm/aimlapi/chat.py index 99b5f03e0d..92312c86be 100644 --- a/browser_use/llm/aimlapi/chat.py +++ b/browser_use/llm/aimlapi/chat.py @@ -33,7 +33,7 @@ 'HTTP-Referer': 'https://github.com/browser-use/browser-use', 'X-Title': 'Browser Use', 'X-AIMLAPI-Source': 'agent/browser-use', - 'X-AIMLAPI-Partner-ID': 'part_browseruse', + 'X-AIMLAPI-Partner-ID': 'part_DtfcGF9FcEYD50B1yIkFL8a6', } )