ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
# Browser Use LLMs
|
||||
|
||||
We officially support the following LLMs:
|
||||
|
||||
- OpenAI
|
||||
- Anthropic
|
||||
- Google
|
||||
- Groq
|
||||
- Ollama
|
||||
- DeepSeek
|
||||
|
||||
## Migrating from LangChain
|
||||
|
||||
Because of how we implemented the LLMs, we can technically support anything. If you want to use a LangChain model, you can use the `ChatLangchain` (NOT OFFICIALLY SUPPORTED) class.
|
||||
|
||||
You can find all the details in the [LangChain example](examples/models/langchain/example.py). We suggest you grab that code and use it as a reference.
|
||||
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
We have switched all of our code from langchain to openai.types.chat.chat_completion_message_param.
|
||||
|
||||
For easier transition we have
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
# Lightweight imports that are commonly used
|
||||
from browser_use.llm.base import BaseChatModel
|
||||
from browser_use.llm.messages import (
|
||||
AssistantMessage,
|
||||
BaseMessage,
|
||||
SystemMessage,
|
||||
UserMessage,
|
||||
)
|
||||
from browser_use.llm.messages import (
|
||||
ContentPartImageParam as ContentImage,
|
||||
)
|
||||
from browser_use.llm.messages import (
|
||||
ContentPartRefusalParam as ContentRefusal,
|
||||
)
|
||||
from browser_use.llm.messages import (
|
||||
ContentPartTextParam as ContentText,
|
||||
)
|
||||
|
||||
# Type stubs for lazy imports
|
||||
if TYPE_CHECKING:
|
||||
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
|
||||
from browser_use.llm.azure.chat import ChatAzureOpenAI
|
||||
from browser_use.llm.deepseek.chat import ChatDeepSeek
|
||||
from browser_use.llm.google.chat import ChatGoogle
|
||||
from browser_use.llm.groq.chat import ChatGroq
|
||||
from browser_use.llm.ollama.chat import ChatOllama
|
||||
from browser_use.llm.openai.chat import ChatOpenAI
|
||||
from browser_use.llm.openrouter.chat import ChatOpenRouter
|
||||
|
||||
# Type stubs for model instances - enables IDE autocomplete
|
||||
openai_gpt_4o: ChatOpenAI
|
||||
openai_gpt_4o_mini: ChatOpenAI
|
||||
openai_gpt_4_1_mini: ChatOpenAI
|
||||
openai_o1: ChatOpenAI
|
||||
openai_o1_mini: ChatOpenAI
|
||||
openai_o1_pro: ChatOpenAI
|
||||
openai_o3: ChatOpenAI
|
||||
openai_o3_mini: ChatOpenAI
|
||||
openai_o3_pro: ChatOpenAI
|
||||
openai_o4_mini: ChatOpenAI
|
||||
openai_gpt_5: ChatOpenAI
|
||||
openai_gpt_5_mini: ChatOpenAI
|
||||
openai_gpt_5_nano: ChatOpenAI
|
||||
|
||||
azure_gpt_4o: ChatAzureOpenAI
|
||||
azure_gpt_4o_mini: ChatAzureOpenAI
|
||||
azure_gpt_4_1_mini: ChatAzureOpenAI
|
||||
azure_o1: ChatAzureOpenAI
|
||||
azure_o1_mini: ChatAzureOpenAI
|
||||
azure_o1_pro: ChatAzureOpenAI
|
||||
azure_o3: ChatAzureOpenAI
|
||||
azure_o3_mini: ChatAzureOpenAI
|
||||
azure_o3_pro: ChatAzureOpenAI
|
||||
azure_gpt_5: ChatAzureOpenAI
|
||||
azure_gpt_5_mini: ChatAzureOpenAI
|
||||
|
||||
google_gemini_2_0_flash: ChatGoogle
|
||||
google_gemini_2_0_pro: ChatGoogle
|
||||
google_gemini_2_5_pro: ChatGoogle
|
||||
google_gemini_2_5_flash: ChatGoogle
|
||||
google_gemini_2_5_flash_lite: ChatGoogle
|
||||
|
||||
# Models are imported on-demand via __getattr__
|
||||
|
||||
# Lazy imports mapping for heavy chat models
|
||||
_LAZY_IMPORTS = {
|
||||
'ChatAnthropic': ('browser_use.llm.anthropic.chat', 'ChatAnthropic'),
|
||||
'ChatAnthropicBedrock': ('browser_use.llm.aws.chat_anthropic', 'ChatAnthropicBedrock'),
|
||||
'ChatAWSBedrock': ('browser_use.llm.aws.chat_bedrock', 'ChatAWSBedrock'),
|
||||
'ChatAzureOpenAI': ('browser_use.llm.azure.chat', 'ChatAzureOpenAI'),
|
||||
'ChatDeepSeek': ('browser_use.llm.deepseek.chat', 'ChatDeepSeek'),
|
||||
'ChatGoogle': ('browser_use.llm.google.chat', 'ChatGoogle'),
|
||||
'ChatGroq': ('browser_use.llm.groq.chat', 'ChatGroq'),
|
||||
'ChatOllama': ('browser_use.llm.ollama.chat', 'ChatOllama'),
|
||||
'ChatOpenAI': ('browser_use.llm.openai.chat', 'ChatOpenAI'),
|
||||
'ChatOpenRouter': ('browser_use.llm.openrouter.chat', 'ChatOpenRouter'),
|
||||
}
|
||||
|
||||
# Cache for model instances - only created when accessed
|
||||
_model_cache: dict[str, 'BaseChatModel'] = {}
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy import mechanism for heavy chat model imports and model instances."""
|
||||
if name in _LAZY_IMPORTS:
|
||||
module_path, attr_name = _LAZY_IMPORTS[name]
|
||||
try:
|
||||
from importlib import import_module
|
||||
|
||||
module = import_module(module_path)
|
||||
attr = getattr(module, attr_name)
|
||||
return attr
|
||||
except ImportError as e:
|
||||
raise ImportError(f'Failed to import {name} from {module_path}: {e}') from e
|
||||
|
||||
# Check cache first for model instances
|
||||
if name in _model_cache:
|
||||
return _model_cache[name]
|
||||
|
||||
# Try to get model instances from models module on-demand
|
||||
try:
|
||||
from browser_use.llm.models import __getattr__ as models_getattr
|
||||
|
||||
attr = models_getattr(name)
|
||||
# Cache in our clean cache dict
|
||||
_model_cache[name] = attr
|
||||
return attr
|
||||
except (AttributeError, ImportError):
|
||||
pass
|
||||
|
||||
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Message types -> for easier transition from langchain
|
||||
'BaseMessage',
|
||||
'UserMessage',
|
||||
'SystemMessage',
|
||||
'AssistantMessage',
|
||||
# Content parts with better names
|
||||
'ContentText',
|
||||
'ContentRefusal',
|
||||
'ContentImage',
|
||||
# Chat models
|
||||
'BaseChatModel',
|
||||
'ChatOpenAI',
|
||||
'ChatDeepSeek',
|
||||
'ChatGoogle',
|
||||
'ChatAnthropic',
|
||||
'ChatAnthropicBedrock',
|
||||
'ChatAWSBedrock',
|
||||
'ChatGroq',
|
||||
'ChatAzureOpenAI',
|
||||
'ChatOllama',
|
||||
'ChatOpenRouter',
|
||||
]
|
||||
@@ -0,0 +1,236 @@
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, TypeVar, overload
|
||||
|
||||
import httpx
|
||||
from anthropic import (
|
||||
NOT_GIVEN,
|
||||
APIConnectionError,
|
||||
APIStatusError,
|
||||
AsyncAnthropic,
|
||||
NotGiven,
|
||||
RateLimitError,
|
||||
)
|
||||
from anthropic.types import CacheControlEphemeralParam, Message, ToolParam
|
||||
from anthropic.types.model_param import ModelParam
|
||||
from anthropic.types.text_block import TextBlock
|
||||
from anthropic.types.tool_choice_tool_param import ToolChoiceToolParam
|
||||
from httpx import Timeout
|
||||
from pydantic import BaseModel
|
||||
|
||||
from browser_use.llm.anthropic.serializer import AnthropicMessageSerializer
|
||||
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)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatAnthropic(BaseChatModel):
|
||||
"""
|
||||
A wrapper around Anthropic's chat model.
|
||||
"""
|
||||
|
||||
# Model configuration
|
||||
model: str | ModelParam
|
||||
max_tokens: int = 8192
|
||||
temperature: float | None = None
|
||||
top_p: float | None = None
|
||||
seed: int | None = None
|
||||
|
||||
# Client initialization parameters
|
||||
api_key: str | None = None
|
||||
auth_token: str | None = None
|
||||
base_url: str | httpx.URL | None = None
|
||||
timeout: float | Timeout | None | NotGiven = NotGiven()
|
||||
max_retries: int = 10
|
||||
default_headers: Mapping[str, str] | None = None
|
||||
default_query: Mapping[str, object] | None = None
|
||||
|
||||
# Static
|
||||
@property
|
||||
def provider(self) -> str:
|
||||
return 'anthropic'
|
||||
|
||||
def _get_client_params(self) -> dict[str, Any]:
|
||||
"""Prepare client parameters dictionary."""
|
||||
# Define base client params
|
||||
base_params = {
|
||||
'api_key': self.api_key,
|
||||
'auth_token': self.auth_token,
|
||||
'base_url': self.base_url,
|
||||
'timeout': self.timeout,
|
||||
'max_retries': self.max_retries,
|
||||
'default_headers': self.default_headers,
|
||||
'default_query': self.default_query,
|
||||
}
|
||||
|
||||
# Create client_params dict with non-None values and non-NotGiven values
|
||||
client_params = {}
|
||||
for k, v in base_params.items():
|
||||
if v is not None and v is not NotGiven():
|
||||
client_params[k] = v
|
||||
|
||||
return client_params
|
||||
|
||||
def _get_client_params_for_invoke(self):
|
||||
"""Prepare client parameters dictionary for invoke."""
|
||||
|
||||
client_params = {}
|
||||
|
||||
if self.temperature is not None:
|
||||
client_params['temperature'] = self.temperature
|
||||
|
||||
if self.max_tokens is not None:
|
||||
client_params['max_tokens'] = self.max_tokens
|
||||
|
||||
if self.top_p is not None:
|
||||
client_params['top_p'] = self.top_p
|
||||
|
||||
if self.seed is not None:
|
||||
client_params['seed'] = self.seed
|
||||
|
||||
return client_params
|
||||
|
||||
def get_client(self) -> AsyncAnthropic:
|
||||
"""
|
||||
Returns an AsyncAnthropic client.
|
||||
|
||||
Returns:
|
||||
AsyncAnthropic: An instance of the AsyncAnthropic client.
|
||||
"""
|
||||
client_params = self._get_client_params()
|
||||
return AsyncAnthropic(**client_params)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return str(self.model)
|
||||
|
||||
def _get_usage(self, response: Message) -> ChatInvokeUsage | None:
|
||||
usage = ChatInvokeUsage(
|
||||
prompt_tokens=response.usage.input_tokens
|
||||
+ (
|
||||
response.usage.cache_read_input_tokens or 0
|
||||
), # Total tokens in Anthropic are a bit fucked, you have to add cached tokens to the prompt tokens
|
||||
completion_tokens=response.usage.output_tokens,
|
||||
total_tokens=response.usage.input_tokens + response.usage.output_tokens,
|
||||
prompt_cached_tokens=response.usage.cache_read_input_tokens,
|
||||
prompt_cache_creation_tokens=response.usage.cache_creation_input_tokens,
|
||||
prompt_image_tokens=None,
|
||||
)
|
||||
return usage
|
||||
|
||||
@overload
|
||||
async def ainvoke(self, messages: list[BaseMessage], output_format: None = None) -> ChatInvokeCompletion[str]: ...
|
||||
|
||||
@overload
|
||||
async def ainvoke(self, messages: list[BaseMessage], output_format: type[T]) -> ChatInvokeCompletion[T]: ...
|
||||
|
||||
async def ainvoke(
|
||||
self, messages: list[BaseMessage], output_format: type[T] | None = None
|
||||
) -> ChatInvokeCompletion[T] | ChatInvokeCompletion[str]:
|
||||
anthropic_messages, system_prompt = AnthropicMessageSerializer.serialize_messages(messages)
|
||||
|
||||
try:
|
||||
if output_format is None:
|
||||
# Normal completion without structured output
|
||||
response = await self.get_client().messages.create(
|
||||
model=self.model,
|
||||
messages=anthropic_messages,
|
||||
system=system_prompt or NOT_GIVEN,
|
||||
**self._get_client_params_for_invoke(),
|
||||
)
|
||||
|
||||
# Ensure we have a valid Message object before accessing attributes
|
||||
if not isinstance(response, Message):
|
||||
raise ModelProviderError(
|
||||
message=f'Unexpected response type from Anthropic API: {type(response).__name__}. Response: {str(response)[:200]}',
|
||||
status_code=502,
|
||||
model=self.name,
|
||||
)
|
||||
|
||||
usage = self._get_usage(response)
|
||||
|
||||
# Extract text from the first content block
|
||||
first_content = response.content[0]
|
||||
if isinstance(first_content, TextBlock):
|
||||
response_text = first_content.text
|
||||
else:
|
||||
# If it's not a text block, convert to string
|
||||
response_text = str(first_content)
|
||||
|
||||
return ChatInvokeCompletion(
|
||||
completion=response_text,
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
else:
|
||||
# Use tool calling for structured output
|
||||
# Create a tool that represents the output format
|
||||
tool_name = output_format.__name__
|
||||
schema = SchemaOptimizer.create_optimized_json_schema(output_format)
|
||||
|
||||
# Remove title from schema if present (Anthropic doesn't like it in parameters)
|
||||
if 'title' in schema:
|
||||
del schema['title']
|
||||
|
||||
tool = ToolParam(
|
||||
name=tool_name,
|
||||
description=f'Extract information in the format of {tool_name}',
|
||||
input_schema=schema,
|
||||
cache_control=CacheControlEphemeralParam(type='ephemeral'),
|
||||
)
|
||||
|
||||
# Force the model to use this tool
|
||||
tool_choice = ToolChoiceToolParam(type='tool', name=tool_name)
|
||||
|
||||
response = await self.get_client().messages.create(
|
||||
model=self.model,
|
||||
messages=anthropic_messages,
|
||||
tools=[tool],
|
||||
system=system_prompt or NOT_GIVEN,
|
||||
tool_choice=tool_choice,
|
||||
**self._get_client_params_for_invoke(),
|
||||
)
|
||||
|
||||
# Ensure we have a valid Message object before accessing attributes
|
||||
if not isinstance(response, Message):
|
||||
raise ModelProviderError(
|
||||
message=f'Unexpected response type from Anthropic API: {type(response).__name__}. Response: {str(response)[:200]}',
|
||||
status_code=502,
|
||||
model=self.name,
|
||||
)
|
||||
|
||||
usage = self._get_usage(response)
|
||||
|
||||
# Extract the tool use block
|
||||
for content_block in response.content:
|
||||
if hasattr(content_block, 'type') and content_block.type == 'tool_use':
|
||||
# Parse the tool input as the structured output
|
||||
try:
|
||||
return ChatInvokeCompletion(completion=output_format.model_validate(content_block.input), usage=usage)
|
||||
except Exception as e:
|
||||
# If validation fails, try to parse it as JSON first
|
||||
if isinstance(content_block.input, str):
|
||||
data = json.loads(content_block.input)
|
||||
return ChatInvokeCompletion(
|
||||
completion=output_format.model_validate(data),
|
||||
usage=usage,
|
||||
)
|
||||
raise e
|
||||
|
||||
# If no tool use block found, raise an error
|
||||
raise ValueError('Expected tool use in response but none found')
|
||||
|
||||
except APIConnectionError as e:
|
||||
raise ModelProviderError(message=e.message, model=self.name) from e
|
||||
except RateLimitError as e:
|
||||
raise ModelRateLimitError(message=e.message, 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
|
||||
@@ -0,0 +1,312 @@
|
||||
import json
|
||||
from typing import overload
|
||||
|
||||
from anthropic.types import (
|
||||
Base64ImageSourceParam,
|
||||
CacheControlEphemeralParam,
|
||||
ImageBlockParam,
|
||||
MessageParam,
|
||||
TextBlockParam,
|
||||
ToolUseBlockParam,
|
||||
URLImageSourceParam,
|
||||
)
|
||||
|
||||
from browser_use.llm.messages import (
|
||||
AssistantMessage,
|
||||
BaseMessage,
|
||||
ContentPartImageParam,
|
||||
ContentPartTextParam,
|
||||
SupportedImageMediaType,
|
||||
SystemMessage,
|
||||
UserMessage,
|
||||
)
|
||||
|
||||
NonSystemMessage = UserMessage | AssistantMessage
|
||||
|
||||
|
||||
class AnthropicMessageSerializer:
|
||||
"""Serializer for converting between custom message types and Anthropic message param types."""
|
||||
|
||||
@staticmethod
|
||||
def _is_base64_image(url: str) -> bool:
|
||||
"""Check if the URL is a base64 encoded image."""
|
||||
return url.startswith('data:image/')
|
||||
|
||||
@staticmethod
|
||||
def _parse_base64_url(url: str) -> tuple[SupportedImageMediaType, str]:
|
||||
"""Parse a base64 data URL to extract media type and data."""
|
||||
# Format: data:image/jpeg;base64,<data>
|
||||
if not url.startswith('data:'):
|
||||
raise ValueError(f'Invalid base64 URL: {url}')
|
||||
|
||||
header, data = url.split(',', 1)
|
||||
media_type = header.split(';')[0].replace('data:', '')
|
||||
|
||||
# Ensure it's a supported media type
|
||||
supported_types = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||
if media_type not in supported_types:
|
||||
# Default to png if not recognized
|
||||
media_type = 'image/png'
|
||||
|
||||
return media_type, data # type: ignore
|
||||
|
||||
@staticmethod
|
||||
def _serialize_cache_control(use_cache: bool) -> CacheControlEphemeralParam | None:
|
||||
"""Serialize cache control."""
|
||||
if use_cache:
|
||||
return CacheControlEphemeralParam(type='ephemeral')
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _serialize_content_part_text(part: ContentPartTextParam, use_cache: bool) -> TextBlockParam:
|
||||
"""Convert a text content part to Anthropic's TextBlockParam."""
|
||||
return TextBlockParam(
|
||||
text=part.text, type='text', cache_control=AnthropicMessageSerializer._serialize_cache_control(use_cache)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _serialize_content_part_image(part: ContentPartImageParam) -> ImageBlockParam:
|
||||
"""Convert an image content part to Anthropic's ImageBlockParam."""
|
||||
url = part.image_url.url
|
||||
|
||||
if AnthropicMessageSerializer._is_base64_image(url):
|
||||
# Handle base64 encoded images
|
||||
media_type, data = AnthropicMessageSerializer._parse_base64_url(url)
|
||||
return ImageBlockParam(
|
||||
source=Base64ImageSourceParam(
|
||||
data=data,
|
||||
media_type=media_type,
|
||||
type='base64',
|
||||
),
|
||||
type='image',
|
||||
)
|
||||
else:
|
||||
# Handle URL images
|
||||
return ImageBlockParam(source=URLImageSourceParam(url=url, type='url'), type='image')
|
||||
|
||||
@staticmethod
|
||||
def _serialize_content_to_str(
|
||||
content: str | list[ContentPartTextParam], use_cache: bool = False
|
||||
) -> list[TextBlockParam] | str:
|
||||
"""Serialize content to a string."""
|
||||
cache_control = AnthropicMessageSerializer._serialize_cache_control(use_cache)
|
||||
|
||||
if isinstance(content, str):
|
||||
if cache_control:
|
||||
return [TextBlockParam(text=content, type='text', cache_control=cache_control)]
|
||||
else:
|
||||
return content
|
||||
|
||||
serialized_blocks: list[TextBlockParam] = []
|
||||
for part in content:
|
||||
if part.type == 'text':
|
||||
serialized_blocks.append(AnthropicMessageSerializer._serialize_content_part_text(part, use_cache))
|
||||
|
||||
return serialized_blocks
|
||||
|
||||
@staticmethod
|
||||
def _serialize_content(
|
||||
content: str | list[ContentPartTextParam | ContentPartImageParam],
|
||||
use_cache: bool = False,
|
||||
) -> str | list[TextBlockParam | ImageBlockParam]:
|
||||
"""Serialize content to Anthropic format."""
|
||||
if isinstance(content, str):
|
||||
if use_cache:
|
||||
return [TextBlockParam(text=content, type='text', cache_control=CacheControlEphemeralParam(type='ephemeral'))]
|
||||
else:
|
||||
return content
|
||||
|
||||
serialized_blocks: list[TextBlockParam | ImageBlockParam] = []
|
||||
for part in content:
|
||||
if part.type == 'text':
|
||||
serialized_blocks.append(AnthropicMessageSerializer._serialize_content_part_text(part, use_cache))
|
||||
elif part.type == 'image_url':
|
||||
serialized_blocks.append(AnthropicMessageSerializer._serialize_content_part_image(part))
|
||||
|
||||
return serialized_blocks
|
||||
|
||||
@staticmethod
|
||||
def _serialize_tool_calls_to_content(tool_calls, use_cache: bool = False) -> list[ToolUseBlockParam]:
|
||||
"""Convert tool calls to Anthropic's ToolUseBlockParam format."""
|
||||
blocks: list[ToolUseBlockParam] = []
|
||||
for tool_call in tool_calls:
|
||||
# Parse the arguments JSON string to object
|
||||
|
||||
try:
|
||||
input_obj = json.loads(tool_call.function.arguments)
|
||||
except json.JSONDecodeError:
|
||||
# If arguments aren't valid JSON, use as string
|
||||
input_obj = {'arguments': tool_call.function.arguments}
|
||||
|
||||
blocks.append(
|
||||
ToolUseBlockParam(
|
||||
id=tool_call.id,
|
||||
input=input_obj,
|
||||
name=tool_call.function.name,
|
||||
type='tool_use',
|
||||
cache_control=AnthropicMessageSerializer._serialize_cache_control(use_cache),
|
||||
)
|
||||
)
|
||||
return blocks
|
||||
|
||||
# region - Serialize overloads
|
||||
@overload
|
||||
@staticmethod
|
||||
def serialize(message: UserMessage) -> MessageParam: ...
|
||||
|
||||
@overload
|
||||
@staticmethod
|
||||
def serialize(message: SystemMessage) -> SystemMessage: ...
|
||||
|
||||
@overload
|
||||
@staticmethod
|
||||
def serialize(message: AssistantMessage) -> MessageParam: ...
|
||||
|
||||
@staticmethod
|
||||
def serialize(message: BaseMessage) -> MessageParam | SystemMessage:
|
||||
"""Serialize a custom message to an Anthropic MessageParam.
|
||||
|
||||
Note: Anthropic doesn't have a 'system' role. System messages should be
|
||||
handled separately as the system parameter in the API call, not as a message.
|
||||
If a SystemMessage is passed here, it will be converted to a user message.
|
||||
"""
|
||||
if isinstance(message, UserMessage):
|
||||
content = AnthropicMessageSerializer._serialize_content(message.content, use_cache=message.cache)
|
||||
return MessageParam(role='user', content=content)
|
||||
|
||||
elif isinstance(message, SystemMessage):
|
||||
# Anthropic doesn't have system messages in the messages array
|
||||
# System prompts are passed separately. Convert to user message.
|
||||
return message
|
||||
|
||||
elif isinstance(message, AssistantMessage):
|
||||
# Handle content and tool calls
|
||||
blocks: list[TextBlockParam | ToolUseBlockParam] = []
|
||||
|
||||
# Add content blocks if present
|
||||
if message.content is not None:
|
||||
if isinstance(message.content, str):
|
||||
blocks.append(
|
||||
TextBlockParam(
|
||||
text=message.content,
|
||||
type='text',
|
||||
cache_control=AnthropicMessageSerializer._serialize_cache_control(message.cache),
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Process content parts (text and refusal)
|
||||
for part in message.content:
|
||||
if part.type == 'text':
|
||||
blocks.append(AnthropicMessageSerializer._serialize_content_part_text(part, use_cache=message.cache))
|
||||
# # Note: Anthropic doesn't have a specific refusal block type,
|
||||
# # so we convert refusals to text blocks
|
||||
# elif part.type == 'refusal':
|
||||
# blocks.append(TextBlockParam(text=f'[Refusal] {part.refusal}', type='text'))
|
||||
|
||||
# Add tool use blocks if present
|
||||
if message.tool_calls:
|
||||
tool_blocks = AnthropicMessageSerializer._serialize_tool_calls_to_content(
|
||||
message.tool_calls, use_cache=message.cache
|
||||
)
|
||||
blocks.extend(tool_blocks)
|
||||
|
||||
# If no content or tool calls, add empty text block
|
||||
# (Anthropic requires at least one content block)
|
||||
if not blocks:
|
||||
blocks.append(
|
||||
TextBlockParam(
|
||||
text='', type='text', cache_control=AnthropicMessageSerializer._serialize_cache_control(message.cache)
|
||||
)
|
||||
)
|
||||
|
||||
# If caching is enabled or we have multiple blocks, return blocks as-is
|
||||
# Otherwise, simplify single text blocks to plain string
|
||||
if message.cache or len(blocks) > 1:
|
||||
content = blocks
|
||||
else:
|
||||
# Only simplify when no caching and single block
|
||||
single_block = blocks[0]
|
||||
if single_block['type'] == 'text' and not single_block.get('cache_control'):
|
||||
content = single_block['text']
|
||||
else:
|
||||
content = blocks
|
||||
|
||||
return MessageParam(
|
||||
role='assistant',
|
||||
content=content,
|
||||
)
|
||||
|
||||
else:
|
||||
raise ValueError(f'Unknown message type: {type(message)}')
|
||||
|
||||
@staticmethod
|
||||
def _clean_cache_messages(messages: list[NonSystemMessage]) -> list[NonSystemMessage]:
|
||||
"""Clean cache settings so only the last cache=True message remains cached.
|
||||
|
||||
Because of how Claude caching works, only the last cache message matters.
|
||||
This method automatically removes cache=True from all messages except the last one.
|
||||
|
||||
Args:
|
||||
messages: List of non-system messages to clean
|
||||
|
||||
Returns:
|
||||
List of messages with cleaned cache settings
|
||||
"""
|
||||
if not messages:
|
||||
return messages
|
||||
|
||||
# Create a copy to avoid modifying the original
|
||||
cleaned_messages = [msg.model_copy(deep=True) for msg in messages]
|
||||
|
||||
# Find the last message with cache=True
|
||||
last_cache_index = -1
|
||||
for i in range(len(cleaned_messages) - 1, -1, -1):
|
||||
if cleaned_messages[i].cache:
|
||||
last_cache_index = i
|
||||
break
|
||||
|
||||
# If we found a cached message, disable cache for all others
|
||||
if last_cache_index != -1:
|
||||
for i, msg in enumerate(cleaned_messages):
|
||||
if i != last_cache_index and msg.cache:
|
||||
# Set cache to False for all messages except the last cached one
|
||||
msg.cache = False
|
||||
|
||||
return cleaned_messages
|
||||
|
||||
@staticmethod
|
||||
def serialize_messages(messages: list[BaseMessage]) -> tuple[list[MessageParam], list[TextBlockParam] | str | None]:
|
||||
"""Serialize a list of messages, extracting any system message.
|
||||
|
||||
Returns:
|
||||
A tuple of (messages, system_message) where system_message is extracted
|
||||
from any SystemMessage in the list.
|
||||
"""
|
||||
messages = [m.model_copy(deep=True) for m in messages]
|
||||
|
||||
# Separate system messages from normal messages
|
||||
normal_messages: list[NonSystemMessage] = []
|
||||
system_message: SystemMessage | None = None
|
||||
|
||||
for message in messages:
|
||||
if isinstance(message, SystemMessage):
|
||||
system_message = message
|
||||
else:
|
||||
normal_messages.append(message)
|
||||
|
||||
# Clean cache messages so only the last cache=True message remains cached
|
||||
normal_messages = AnthropicMessageSerializer._clean_cache_messages(normal_messages)
|
||||
|
||||
# Serialize normal messages
|
||||
serialized_messages: list[MessageParam] = []
|
||||
for message in normal_messages:
|
||||
serialized_messages.append(AnthropicMessageSerializer.serialize(message))
|
||||
|
||||
# Serialize system message
|
||||
serialized_system_message: list[TextBlockParam] | str | None = None
|
||||
if system_message:
|
||||
serialized_system_message = AnthropicMessageSerializer._serialize_content_to_str(
|
||||
system_message.content, use_cache=system_message.cache
|
||||
)
|
||||
|
||||
return serialized_messages, serialized_system_message
|
||||
@@ -0,0 +1,36 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
# Type stubs for lazy imports
|
||||
if TYPE_CHECKING:
|
||||
from browser_use.llm.aws.chat_anthropic import ChatAnthropicBedrock
|
||||
from browser_use.llm.aws.chat_bedrock import ChatAWSBedrock
|
||||
|
||||
# Lazy imports mapping for AWS chat models
|
||||
_LAZY_IMPORTS = {
|
||||
'ChatAnthropicBedrock': ('browser_use.llm.aws.chat_anthropic', 'ChatAnthropicBedrock'),
|
||||
'ChatAWSBedrock': ('browser_use.llm.aws.chat_bedrock', 'ChatAWSBedrock'),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy import mechanism for AWS chat models."""
|
||||
if name in _LAZY_IMPORTS:
|
||||
module_path, attr_name = _LAZY_IMPORTS[name]
|
||||
try:
|
||||
from importlib import import_module
|
||||
|
||||
module = import_module(module_path)
|
||||
attr = getattr(module, attr_name)
|
||||
# Cache the imported attribute in the module's globals
|
||||
globals()[name] = attr
|
||||
return attr
|
||||
except ImportError as e:
|
||||
raise ImportError(f'Failed to import {name} from {module_path}: {e}') from e
|
||||
|
||||
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
||||
|
||||
|
||||
__all__ = [
|
||||
'ChatAWSBedrock',
|
||||
'ChatAnthropicBedrock',
|
||||
]
|
||||
@@ -0,0 +1,242 @@
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, TypeVar, overload
|
||||
|
||||
from anthropic import (
|
||||
NOT_GIVEN,
|
||||
APIConnectionError,
|
||||
APIStatusError,
|
||||
AsyncAnthropicBedrock,
|
||||
RateLimitError,
|
||||
)
|
||||
from anthropic.types import CacheControlEphemeralParam, Message, ToolParam
|
||||
from anthropic.types.text_block import TextBlock
|
||||
from anthropic.types.tool_choice_tool_param import ToolChoiceToolParam
|
||||
from pydantic import BaseModel
|
||||
|
||||
from browser_use.llm.anthropic.serializer import AnthropicMessageSerializer
|
||||
from browser_use.llm.aws.chat_bedrock import ChatAWSBedrock
|
||||
from browser_use.llm.exceptions import ModelProviderError, ModelRateLimitError
|
||||
from browser_use.llm.messages import BaseMessage
|
||||
from browser_use.llm.views import ChatInvokeCompletion, ChatInvokeUsage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from boto3.session import Session # pyright: ignore
|
||||
|
||||
|
||||
T = TypeVar('T', bound=BaseModel)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatAnthropicBedrock(ChatAWSBedrock):
|
||||
"""
|
||||
AWS Bedrock Anthropic Claude chat model.
|
||||
|
||||
This is a convenience class that provides Claude-specific defaults
|
||||
for the AWS Bedrock service. It inherits all functionality from
|
||||
ChatAWSBedrock but sets Anthropic Claude as the default model.
|
||||
"""
|
||||
|
||||
# Anthropic Claude specific defaults
|
||||
model: str = 'anthropic.claude-3-5-sonnet-20240620-v1:0'
|
||||
max_tokens: int = 8192
|
||||
temperature: float | None = None
|
||||
top_p: float | None = None
|
||||
top_k: int | None = None
|
||||
stop_sequences: list[str] | None = None
|
||||
seed: int | None = None
|
||||
|
||||
# AWS credentials and configuration
|
||||
aws_access_key: str | None = None
|
||||
aws_secret_key: str | None = None
|
||||
aws_session_token: str | None = None
|
||||
aws_region: str | None = None
|
||||
session: 'Session | None' = None
|
||||
|
||||
# Client initialization parameters
|
||||
max_retries: int = 10
|
||||
default_headers: Mapping[str, str] | None = None
|
||||
default_query: Mapping[str, object] | None = None
|
||||
|
||||
@property
|
||||
def provider(self) -> str:
|
||||
return 'anthropic_bedrock'
|
||||
|
||||
def _get_client_params(self) -> dict[str, Any]:
|
||||
"""Prepare client parameters dictionary for Bedrock."""
|
||||
client_params: dict[str, Any] = {}
|
||||
|
||||
if self.session:
|
||||
credentials = self.session.get_credentials()
|
||||
client_params.update(
|
||||
{
|
||||
'aws_access_key': credentials.access_key,
|
||||
'aws_secret_key': credentials.secret_key,
|
||||
'aws_session_token': credentials.token,
|
||||
'aws_region': self.session.region_name,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Use individual credentials
|
||||
if self.aws_access_key:
|
||||
client_params['aws_access_key'] = self.aws_access_key
|
||||
if self.aws_secret_key:
|
||||
client_params['aws_secret_key'] = self.aws_secret_key
|
||||
if self.aws_region:
|
||||
client_params['aws_region'] = self.aws_region
|
||||
if self.aws_session_token:
|
||||
client_params['aws_session_token'] = self.aws_session_token
|
||||
|
||||
# Add optional parameters
|
||||
if self.max_retries:
|
||||
client_params['max_retries'] = self.max_retries
|
||||
if self.default_headers:
|
||||
client_params['default_headers'] = self.default_headers
|
||||
if self.default_query:
|
||||
client_params['default_query'] = self.default_query
|
||||
|
||||
return client_params
|
||||
|
||||
def _get_client_params_for_invoke(self) -> dict[str, Any]:
|
||||
"""Prepare client parameters dictionary for invoke."""
|
||||
client_params = {}
|
||||
|
||||
if self.temperature is not None:
|
||||
client_params['temperature'] = self.temperature
|
||||
if self.max_tokens is not None:
|
||||
client_params['max_tokens'] = self.max_tokens
|
||||
if self.top_p is not None:
|
||||
client_params['top_p'] = self.top_p
|
||||
if self.top_k is not None:
|
||||
client_params['top_k'] = self.top_k
|
||||
if self.seed is not None:
|
||||
client_params['seed'] = self.seed
|
||||
if self.stop_sequences is not None:
|
||||
client_params['stop_sequences'] = self.stop_sequences
|
||||
|
||||
return client_params
|
||||
|
||||
def get_client(self) -> AsyncAnthropicBedrock:
|
||||
"""
|
||||
Returns an AsyncAnthropicBedrock client.
|
||||
|
||||
Returns:
|
||||
AsyncAnthropicBedrock: An instance of the AsyncAnthropicBedrock client.
|
||||
"""
|
||||
client_params = self._get_client_params()
|
||||
return AsyncAnthropicBedrock(**client_params)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return str(self.model)
|
||||
|
||||
def _get_usage(self, response: Message) -> ChatInvokeUsage | None:
|
||||
"""Extract usage information from the response."""
|
||||
usage = ChatInvokeUsage(
|
||||
prompt_tokens=response.usage.input_tokens
|
||||
+ (
|
||||
response.usage.cache_read_input_tokens or 0
|
||||
), # Total tokens in Anthropic are a bit fucked, you have to add cached tokens to the prompt tokens
|
||||
completion_tokens=response.usage.output_tokens,
|
||||
total_tokens=response.usage.input_tokens + response.usage.output_tokens,
|
||||
prompt_cached_tokens=response.usage.cache_read_input_tokens,
|
||||
prompt_cache_creation_tokens=response.usage.cache_creation_input_tokens,
|
||||
prompt_image_tokens=None,
|
||||
)
|
||||
return usage
|
||||
|
||||
@overload
|
||||
async def ainvoke(self, messages: list[BaseMessage], output_format: None = None) -> ChatInvokeCompletion[str]: ...
|
||||
|
||||
@overload
|
||||
async def ainvoke(self, messages: list[BaseMessage], output_format: type[T]) -> ChatInvokeCompletion[T]: ...
|
||||
|
||||
async def ainvoke(
|
||||
self, messages: list[BaseMessage], output_format: type[T] | None = None
|
||||
) -> ChatInvokeCompletion[T] | ChatInvokeCompletion[str]:
|
||||
anthropic_messages, system_prompt = AnthropicMessageSerializer.serialize_messages(messages)
|
||||
|
||||
try:
|
||||
if output_format is None:
|
||||
# Normal completion without structured output
|
||||
response = await self.get_client().messages.create(
|
||||
model=self.model,
|
||||
messages=anthropic_messages,
|
||||
system=system_prompt or NOT_GIVEN,
|
||||
**self._get_client_params_for_invoke(),
|
||||
)
|
||||
|
||||
usage = self._get_usage(response)
|
||||
|
||||
# Extract text from the first content block
|
||||
first_content = response.content[0]
|
||||
if isinstance(first_content, TextBlock):
|
||||
response_text = first_content.text
|
||||
else:
|
||||
# If it's not a text block, convert to string
|
||||
response_text = str(first_content)
|
||||
|
||||
return ChatInvokeCompletion(
|
||||
completion=response_text,
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
else:
|
||||
# Use tool calling for structured output
|
||||
# Create a tool that represents the output format
|
||||
tool_name = output_format.__name__
|
||||
schema = output_format.model_json_schema()
|
||||
|
||||
# Remove title from schema if present (Anthropic doesn't like it in parameters)
|
||||
if 'title' in schema:
|
||||
del schema['title']
|
||||
|
||||
tool = ToolParam(
|
||||
name=tool_name,
|
||||
description=f'Extract information in the format of {tool_name}',
|
||||
input_schema=schema,
|
||||
cache_control=CacheControlEphemeralParam(type='ephemeral'),
|
||||
)
|
||||
|
||||
# Force the model to use this tool
|
||||
tool_choice = ToolChoiceToolParam(type='tool', name=tool_name)
|
||||
|
||||
response = await self.get_client().messages.create(
|
||||
model=self.model,
|
||||
messages=anthropic_messages,
|
||||
tools=[tool],
|
||||
system=system_prompt or NOT_GIVEN,
|
||||
tool_choice=tool_choice,
|
||||
**self._get_client_params_for_invoke(),
|
||||
)
|
||||
|
||||
usage = self._get_usage(response)
|
||||
|
||||
# Extract the tool use block
|
||||
for content_block in response.content:
|
||||
if hasattr(content_block, 'type') and content_block.type == 'tool_use':
|
||||
# Parse the tool input as the structured output
|
||||
try:
|
||||
return ChatInvokeCompletion(completion=output_format.model_validate(content_block.input), usage=usage)
|
||||
except Exception as e:
|
||||
# If validation fails, try to parse it as JSON first
|
||||
if isinstance(content_block.input, str):
|
||||
data = json.loads(content_block.input)
|
||||
return ChatInvokeCompletion(
|
||||
completion=output_format.model_validate(data),
|
||||
usage=usage,
|
||||
)
|
||||
raise e
|
||||
|
||||
# If no tool use block found, raise an error
|
||||
raise ValueError('Expected tool use in response but none found')
|
||||
|
||||
except APIConnectionError as e:
|
||||
raise ModelProviderError(message=e.message, model=self.name) from e
|
||||
except RateLimitError as e:
|
||||
raise ModelRateLimitError(message=e.message, 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
|
||||
@@ -0,0 +1,289 @@
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from os import getenv
|
||||
from typing import TYPE_CHECKING, Any, TypeVar, overload
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from browser_use.llm.aws.serializer import AWSBedrockMessageSerializer
|
||||
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.views import ChatInvokeCompletion, ChatInvokeUsage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from boto3 import client as AwsClient # type: ignore
|
||||
from boto3.session import Session # type: ignore
|
||||
|
||||
T = TypeVar('T', bound=BaseModel)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatAWSBedrock(BaseChatModel):
|
||||
"""
|
||||
AWS Bedrock chat model supporting multiple providers (Anthropic, Meta, etc.).
|
||||
|
||||
This class provides access to various models via AWS Bedrock,
|
||||
supporting both text generation and structured output via tool calling.
|
||||
|
||||
To use this model, you need to either:
|
||||
1. Set the following environment variables:
|
||||
- AWS_ACCESS_KEY_ID
|
||||
- AWS_SECRET_ACCESS_KEY
|
||||
- AWS_SESSION_TOKEN (only required when using temporary credentials)
|
||||
- AWS_REGION
|
||||
2. Or provide a boto3 Session object
|
||||
3. Or use AWS SSO authentication
|
||||
"""
|
||||
|
||||
# Model configuration
|
||||
model: str = 'anthropic.claude-3-5-sonnet-20240620-v1:0'
|
||||
max_tokens: int | None = 4096
|
||||
temperature: float | None = None
|
||||
top_p: float | None = None
|
||||
seed: int | None = None
|
||||
stop_sequences: list[str] | None = None
|
||||
|
||||
# AWS credentials and configuration
|
||||
aws_access_key_id: str | None = None
|
||||
aws_secret_access_key: str | None = None
|
||||
aws_session_token: str | None = None
|
||||
aws_region: str | None = None
|
||||
aws_sso_auth: bool = False
|
||||
session: 'Session | None' = None
|
||||
|
||||
# Request parameters
|
||||
request_params: dict[str, Any] | None = None
|
||||
|
||||
# Static
|
||||
@property
|
||||
def provider(self) -> str:
|
||||
return 'aws_bedrock'
|
||||
|
||||
def _get_client(self) -> 'AwsClient': # type: ignore
|
||||
"""Get the AWS Bedrock client."""
|
||||
try:
|
||||
from boto3 import client as AwsClient # type: ignore
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
'`boto3` not installed. Please install using `pip install browser-use[aws] or pip install browser-use[all]`'
|
||||
)
|
||||
|
||||
if self.session:
|
||||
return self.session.client('bedrock-runtime')
|
||||
|
||||
# Get credentials from environment or instance parameters
|
||||
access_key = self.aws_access_key_id or getenv('AWS_ACCESS_KEY_ID')
|
||||
secret_key = self.aws_secret_access_key or getenv('AWS_SECRET_ACCESS_KEY')
|
||||
session_token = self.aws_session_token or getenv('AWS_SESSION_TOKEN')
|
||||
region = self.aws_region or getenv('AWS_REGION') or getenv('AWS_DEFAULT_REGION')
|
||||
|
||||
if self.aws_sso_auth:
|
||||
return AwsClient(service_name='bedrock-runtime', region_name=region)
|
||||
else:
|
||||
if not access_key or not secret_key:
|
||||
raise ModelProviderError(
|
||||
message='AWS credentials not found. Please set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables (and AWS_SESSION_TOKEN if using temporary credentials) or provide a boto3 session.',
|
||||
model=self.name,
|
||||
)
|
||||
|
||||
return AwsClient(
|
||||
service_name='bedrock-runtime',
|
||||
region_name=region,
|
||||
aws_access_key_id=access_key,
|
||||
aws_secret_access_key=secret_key,
|
||||
aws_session_token=session_token,
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return str(self.model)
|
||||
|
||||
def _get_inference_config(self) -> dict[str, Any]:
|
||||
"""Get the inference configuration for the request."""
|
||||
config = {}
|
||||
if self.max_tokens is not None:
|
||||
config['maxTokens'] = self.max_tokens
|
||||
if self.temperature is not None:
|
||||
config['temperature'] = self.temperature
|
||||
if self.top_p is not None:
|
||||
config['topP'] = self.top_p
|
||||
if self.stop_sequences is not None:
|
||||
config['stopSequences'] = self.stop_sequences
|
||||
if self.seed is not None:
|
||||
config['seed'] = self.seed
|
||||
return config
|
||||
|
||||
def _format_tools_for_request(self, output_format: type[BaseModel]) -> list[dict[str, Any]]:
|
||||
"""Format a Pydantic model as a tool for structured output."""
|
||||
schema = output_format.model_json_schema()
|
||||
|
||||
# Convert Pydantic schema to Bedrock tool format
|
||||
properties = {}
|
||||
required = []
|
||||
|
||||
for prop_name, prop_info in schema.get('properties', {}).items():
|
||||
properties[prop_name] = {
|
||||
'type': prop_info.get('type', 'string'),
|
||||
'description': prop_info.get('description', ''),
|
||||
}
|
||||
|
||||
# Add required fields
|
||||
required = schema.get('required', [])
|
||||
|
||||
return [
|
||||
{
|
||||
'toolSpec': {
|
||||
'name': f'extract_{output_format.__name__.lower()}',
|
||||
'description': f'Extract information in the format of {output_format.__name__}',
|
||||
'inputSchema': {'json': {'type': 'object', 'properties': properties, 'required': required}},
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
def _get_usage(self, response: dict[str, Any]) -> ChatInvokeUsage | None:
|
||||
"""Extract usage information from the response."""
|
||||
if 'usage' not in response:
|
||||
return None
|
||||
|
||||
usage_data = response['usage']
|
||||
return ChatInvokeUsage(
|
||||
prompt_tokens=usage_data.get('inputTokens', 0),
|
||||
completion_tokens=usage_data.get('outputTokens', 0),
|
||||
total_tokens=usage_data.get('totalTokens', 0),
|
||||
prompt_cached_tokens=None, # Bedrock doesn't provide this
|
||||
prompt_cache_creation_tokens=None,
|
||||
prompt_image_tokens=None,
|
||||
)
|
||||
|
||||
@overload
|
||||
async def ainvoke(self, messages: list[BaseMessage], output_format: None = None) -> ChatInvokeCompletion[str]: ...
|
||||
|
||||
@overload
|
||||
async def ainvoke(self, messages: list[BaseMessage], output_format: type[T]) -> ChatInvokeCompletion[T]: ...
|
||||
|
||||
async def ainvoke(
|
||||
self, messages: list[BaseMessage], output_format: type[T] | None = None
|
||||
) -> ChatInvokeCompletion[T] | ChatInvokeCompletion[str]:
|
||||
"""
|
||||
Invoke the AWS Bedrock model with the given messages.
|
||||
|
||||
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
|
||||
"""
|
||||
try:
|
||||
from botocore.exceptions import ClientError # type: ignore
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
'`boto3` not installed. Please install using `pip install browser-use[aws] or pip install browser-use[all]`'
|
||||
)
|
||||
|
||||
bedrock_messages, system_message = AWSBedrockMessageSerializer.serialize_messages(messages)
|
||||
|
||||
try:
|
||||
# Prepare the request body
|
||||
body: dict[str, Any] = {}
|
||||
|
||||
if system_message:
|
||||
body['system'] = system_message
|
||||
|
||||
inference_config = self._get_inference_config()
|
||||
if inference_config:
|
||||
body['inferenceConfig'] = inference_config
|
||||
|
||||
# Handle structured output via tool calling
|
||||
if output_format is not None:
|
||||
tools = self._format_tools_for_request(output_format)
|
||||
body['toolConfig'] = {'tools': tools}
|
||||
|
||||
# Add any additional request parameters
|
||||
if self.request_params:
|
||||
body.update(self.request_params)
|
||||
|
||||
# Filter out None values
|
||||
body = {k: v for k, v in body.items() if v is not None}
|
||||
|
||||
# Make the API call
|
||||
client = self._get_client()
|
||||
response = client.converse(modelId=self.model, messages=bedrock_messages, **body)
|
||||
|
||||
usage = self._get_usage(response)
|
||||
|
||||
# Extract the response content
|
||||
if 'output' in response and 'message' in response['output']:
|
||||
message = response['output']['message']
|
||||
content = message.get('content', [])
|
||||
|
||||
if output_format is None:
|
||||
# Return text response
|
||||
text_content = []
|
||||
for item in content:
|
||||
if 'text' in item:
|
||||
text_content.append(item['text'])
|
||||
|
||||
response_text = '\n'.join(text_content) if text_content else ''
|
||||
return ChatInvokeCompletion(
|
||||
completion=response_text,
|
||||
usage=usage,
|
||||
)
|
||||
else:
|
||||
# Handle structured output from tool calls
|
||||
for item in content:
|
||||
if 'toolUse' in item:
|
||||
tool_use = item['toolUse']
|
||||
tool_input = tool_use.get('input', {})
|
||||
|
||||
try:
|
||||
# Validate and return the structured output
|
||||
return ChatInvokeCompletion(
|
||||
completion=output_format.model_validate(tool_input),
|
||||
usage=usage,
|
||||
)
|
||||
except Exception as e:
|
||||
# If validation fails, try to parse as JSON first
|
||||
if isinstance(tool_input, str):
|
||||
try:
|
||||
data = json.loads(tool_input)
|
||||
return ChatInvokeCompletion(
|
||||
completion=output_format.model_validate(data),
|
||||
usage=usage,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
raise ModelProviderError(
|
||||
message=f'Failed to validate structured output: {str(e)}',
|
||||
model=self.name,
|
||||
) from e
|
||||
|
||||
# If no tool use found but output_format was requested
|
||||
raise ModelProviderError(
|
||||
message='Expected structured output but no tool use found in response',
|
||||
model=self.name,
|
||||
)
|
||||
|
||||
# If no valid content found
|
||||
if output_format is None:
|
||||
return ChatInvokeCompletion(
|
||||
completion='',
|
||||
usage=usage,
|
||||
)
|
||||
else:
|
||||
raise ModelProviderError(
|
||||
message='No valid content found in response',
|
||||
model=self.name,
|
||||
)
|
||||
|
||||
except ClientError as e:
|
||||
error_code = e.response.get('Error', {}).get('Code', 'Unknown')
|
||||
error_message = e.response.get('Error', {}).get('Message', str(e))
|
||||
|
||||
if error_code in ['ThrottlingException', 'TooManyRequestsException']:
|
||||
raise ModelRateLimitError(message=error_message, model=self.name) from e
|
||||
else:
|
||||
raise ModelProviderError(message=error_message, model=self.name) from e
|
||||
except Exception as e:
|
||||
raise ModelProviderError(message=str(e), model=self.name) from e
|
||||
@@ -0,0 +1,257 @@
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from typing import Any, overload
|
||||
|
||||
from browser_use.llm.messages import (
|
||||
AssistantMessage,
|
||||
BaseMessage,
|
||||
ContentPartImageParam,
|
||||
ContentPartRefusalParam,
|
||||
ContentPartTextParam,
|
||||
SystemMessage,
|
||||
ToolCall,
|
||||
UserMessage,
|
||||
)
|
||||
|
||||
|
||||
class AWSBedrockMessageSerializer:
|
||||
"""Serializer for converting between custom message types and AWS Bedrock message format."""
|
||||
|
||||
@staticmethod
|
||||
def _is_base64_image(url: str) -> bool:
|
||||
"""Check if the URL is a base64 encoded image."""
|
||||
return url.startswith('data:image/')
|
||||
|
||||
@staticmethod
|
||||
def _is_url_image(url: str) -> bool:
|
||||
"""Check if the URL is a regular HTTP/HTTPS image URL."""
|
||||
return url.startswith(('http://', 'https://')) and any(
|
||||
url.lower().endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp']
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_base64_url(url: str) -> tuple[str, bytes]:
|
||||
"""Parse a base64 data URL to extract format and raw bytes."""
|
||||
# Format: data:image/jpeg;base64,<data>
|
||||
if not url.startswith('data:'):
|
||||
raise ValueError(f'Invalid base64 URL: {url}')
|
||||
|
||||
header, data = url.split(',', 1)
|
||||
|
||||
# Extract format from mime type
|
||||
mime_match = re.search(r'image/(\w+)', header)
|
||||
if mime_match:
|
||||
format_name = mime_match.group(1).lower()
|
||||
# Map common formats
|
||||
format_mapping = {'jpg': 'jpeg', 'jpeg': 'jpeg', 'png': 'png', 'gif': 'gif', 'webp': 'webp'}
|
||||
image_format = format_mapping.get(format_name, 'jpeg')
|
||||
else:
|
||||
image_format = 'jpeg' # Default format
|
||||
|
||||
# Decode base64 data
|
||||
try:
|
||||
image_bytes = base64.b64decode(data)
|
||||
except Exception as e:
|
||||
raise ValueError(f'Failed to decode base64 image data: {e}')
|
||||
|
||||
return image_format, image_bytes
|
||||
|
||||
@staticmethod
|
||||
def _download_and_convert_image(url: str) -> tuple[str, bytes]:
|
||||
"""Download an image from URL and convert to base64 bytes."""
|
||||
try:
|
||||
import httpx
|
||||
except ImportError:
|
||||
raise ImportError('httpx not available. Please install it to use URL images with AWS Bedrock.')
|
||||
|
||||
try:
|
||||
response = httpx.get(url, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
# Detect format from content type or URL
|
||||
content_type = response.headers.get('content-type', '').lower()
|
||||
if 'jpeg' in content_type or url.lower().endswith(('.jpg', '.jpeg')):
|
||||
image_format = 'jpeg'
|
||||
elif 'png' in content_type or url.lower().endswith('.png'):
|
||||
image_format = 'png'
|
||||
elif 'gif' in content_type or url.lower().endswith('.gif'):
|
||||
image_format = 'gif'
|
||||
elif 'webp' in content_type or url.lower().endswith('.webp'):
|
||||
image_format = 'webp'
|
||||
else:
|
||||
image_format = 'jpeg' # Default format
|
||||
|
||||
return image_format, response.content
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f'Failed to download image from {url}: {e}')
|
||||
|
||||
@staticmethod
|
||||
def _serialize_content_part_text(part: ContentPartTextParam) -> dict[str, Any]:
|
||||
"""Convert a text content part to AWS Bedrock format."""
|
||||
return {'text': part.text}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_content_part_image(part: ContentPartImageParam) -> dict[str, Any]:
|
||||
"""Convert an image content part to AWS Bedrock format."""
|
||||
url = part.image_url.url
|
||||
|
||||
if AWSBedrockMessageSerializer._is_base64_image(url):
|
||||
# Handle base64 encoded images
|
||||
image_format, image_bytes = AWSBedrockMessageSerializer._parse_base64_url(url)
|
||||
elif AWSBedrockMessageSerializer._is_url_image(url):
|
||||
# Download and convert URL images
|
||||
image_format, image_bytes = AWSBedrockMessageSerializer._download_and_convert_image(url)
|
||||
else:
|
||||
raise ValueError(f'Unsupported image URL format: {url}')
|
||||
|
||||
return {
|
||||
'image': {
|
||||
'format': image_format,
|
||||
'source': {
|
||||
'bytes': image_bytes,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_user_content(
|
||||
content: str | list[ContentPartTextParam | ContentPartImageParam],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Serialize content for user messages."""
|
||||
if isinstance(content, str):
|
||||
return [{'text': content}]
|
||||
|
||||
content_blocks: list[dict[str, Any]] = []
|
||||
for part in content:
|
||||
if part.type == 'text':
|
||||
content_blocks.append(AWSBedrockMessageSerializer._serialize_content_part_text(part))
|
||||
elif part.type == 'image_url':
|
||||
content_blocks.append(AWSBedrockMessageSerializer._serialize_content_part_image(part))
|
||||
|
||||
return content_blocks
|
||||
|
||||
@staticmethod
|
||||
def _serialize_system_content(
|
||||
content: str | list[ContentPartTextParam],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Serialize content for system messages."""
|
||||
if isinstance(content, str):
|
||||
return [{'text': content}]
|
||||
|
||||
content_blocks: list[dict[str, Any]] = []
|
||||
for part in content:
|
||||
if part.type == 'text':
|
||||
content_blocks.append(AWSBedrockMessageSerializer._serialize_content_part_text(part))
|
||||
|
||||
return content_blocks
|
||||
|
||||
@staticmethod
|
||||
def _serialize_assistant_content(
|
||||
content: str | list[ContentPartTextParam | ContentPartRefusalParam] | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Serialize content for assistant messages."""
|
||||
if content is None:
|
||||
return []
|
||||
if isinstance(content, str):
|
||||
return [{'text': content}]
|
||||
|
||||
content_blocks: list[dict[str, Any]] = []
|
||||
for part in content:
|
||||
if part.type == 'text':
|
||||
content_blocks.append(AWSBedrockMessageSerializer._serialize_content_part_text(part))
|
||||
# Skip refusal content parts - AWS Bedrock doesn't need them
|
||||
|
||||
return content_blocks
|
||||
|
||||
@staticmethod
|
||||
def _serialize_tool_call(tool_call: ToolCall) -> dict[str, Any]:
|
||||
"""Convert a tool call to AWS Bedrock format."""
|
||||
try:
|
||||
arguments = json.loads(tool_call.function.arguments)
|
||||
except json.JSONDecodeError:
|
||||
# If arguments aren't valid JSON, wrap them
|
||||
arguments = {'arguments': tool_call.function.arguments}
|
||||
|
||||
return {
|
||||
'toolUse': {
|
||||
'toolUseId': tool_call.id,
|
||||
'name': tool_call.function.name,
|
||||
'input': arguments,
|
||||
}
|
||||
}
|
||||
|
||||
# region - Serialize overloads
|
||||
@overload
|
||||
@staticmethod
|
||||
def serialize(message: UserMessage) -> dict[str, Any]: ...
|
||||
|
||||
@overload
|
||||
@staticmethod
|
||||
def serialize(message: SystemMessage) -> SystemMessage: ...
|
||||
|
||||
@overload
|
||||
@staticmethod
|
||||
def serialize(message: AssistantMessage) -> dict[str, Any]: ...
|
||||
|
||||
@staticmethod
|
||||
def serialize(message: BaseMessage) -> dict[str, Any] | SystemMessage:
|
||||
"""Serialize a custom message to AWS Bedrock format."""
|
||||
|
||||
if isinstance(message, UserMessage):
|
||||
return {
|
||||
'role': 'user',
|
||||
'content': AWSBedrockMessageSerializer._serialize_user_content(message.content),
|
||||
}
|
||||
|
||||
elif isinstance(message, SystemMessage):
|
||||
# System messages are handled separately in AWS Bedrock
|
||||
return message
|
||||
|
||||
elif isinstance(message, AssistantMessage):
|
||||
content_blocks: list[dict[str, Any]] = []
|
||||
|
||||
# Add content blocks if present
|
||||
if message.content is not None:
|
||||
content_blocks.extend(AWSBedrockMessageSerializer._serialize_assistant_content(message.content))
|
||||
|
||||
# Add tool use blocks if present
|
||||
if message.tool_calls:
|
||||
for tool_call in message.tool_calls:
|
||||
content_blocks.append(AWSBedrockMessageSerializer._serialize_tool_call(tool_call))
|
||||
|
||||
# AWS Bedrock requires at least one content block
|
||||
if not content_blocks:
|
||||
content_blocks = [{'text': ''}]
|
||||
|
||||
return {
|
||||
'role': 'assistant',
|
||||
'content': content_blocks,
|
||||
}
|
||||
|
||||
else:
|
||||
raise ValueError(f'Unknown message type: {type(message)}')
|
||||
|
||||
@staticmethod
|
||||
def serialize_messages(messages: list[BaseMessage]) -> tuple[list[dict[str, Any]], list[dict[str, Any]] | None]:
|
||||
"""
|
||||
Serialize a list of messages, extracting any system message.
|
||||
|
||||
Returns:
|
||||
Tuple of (bedrock_messages, system_message) where system_message is extracted
|
||||
from any SystemMessage in the list.
|
||||
"""
|
||||
bedrock_messages: list[dict[str, Any]] = []
|
||||
system_message: list[dict[str, Any]] | None = None
|
||||
|
||||
for message in messages:
|
||||
if isinstance(message, SystemMessage):
|
||||
# Extract system message content
|
||||
system_message = AWSBedrockMessageSerializer._serialize_system_content(message.content)
|
||||
else:
|
||||
# Serialize and add to regular messages
|
||||
serialized = AWSBedrockMessageSerializer.serialize(message)
|
||||
bedrock_messages.append(serialized)
|
||||
|
||||
return bedrock_messages, system_message
|
||||
@@ -0,0 +1,91 @@
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from openai import AsyncAzureOpenAI as AsyncAzureOpenAIClient
|
||||
from openai.types.shared import ChatModel
|
||||
|
||||
from browser_use.llm.openai.like import ChatOpenAILike
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatAzureOpenAI(ChatOpenAILike):
|
||||
"""
|
||||
A class for to interact with any provider using the OpenAI API schema.
|
||||
|
||||
Args:
|
||||
model (str): The name of the OpenAI model to use. Defaults to "not-provided".
|
||||
api_key (Optional[str]): The API key to use. Defaults to "not-provided".
|
||||
"""
|
||||
|
||||
# Model configuration
|
||||
model: str | ChatModel
|
||||
|
||||
# Client initialization parameters
|
||||
api_key: str | None = None
|
||||
api_version: str | None = '2024-12-01-preview'
|
||||
azure_endpoint: str | None = None
|
||||
azure_deployment: str | None = None
|
||||
base_url: str | None = None
|
||||
azure_ad_token: str | None = None
|
||||
azure_ad_token_provider: Any | None = None
|
||||
|
||||
default_headers: dict[str, str] | None = None
|
||||
default_query: dict[str, Any] | None = None
|
||||
|
||||
client: AsyncAzureOpenAIClient | None = None
|
||||
|
||||
@property
|
||||
def provider(self) -> str:
|
||||
return 'azure'
|
||||
|
||||
def _get_client_params(self) -> dict[str, Any]:
|
||||
_client_params: dict[str, Any] = {}
|
||||
|
||||
self.api_key = self.api_key or os.getenv('AZURE_OPENAI_API_KEY')
|
||||
self.azure_endpoint = self.azure_endpoint or os.getenv('AZURE_OPENAI_ENDPOINT')
|
||||
self.azure_deployment = self.azure_deployment or os.getenv('AZURE_OPENAI_DEPLOYMENT')
|
||||
params_mapping = {
|
||||
'api_key': self.api_key,
|
||||
'api_version': self.api_version,
|
||||
'organization': self.organization,
|
||||
'azure_endpoint': self.azure_endpoint,
|
||||
'azure_deployment': self.azure_deployment,
|
||||
'base_url': self.base_url,
|
||||
'azure_ad_token': self.azure_ad_token,
|
||||
'azure_ad_token_provider': self.azure_ad_token_provider,
|
||||
'http_client': self.http_client,
|
||||
}
|
||||
if self.default_headers is not None:
|
||||
_client_params['default_headers'] = self.default_headers
|
||||
if self.default_query is not None:
|
||||
_client_params['default_query'] = self.default_query
|
||||
|
||||
_client_params.update({k: v for k, v in params_mapping.items() if v is not None})
|
||||
|
||||
return _client_params
|
||||
|
||||
def get_client(self) -> AsyncAzureOpenAIClient:
|
||||
"""
|
||||
Returns an asynchronous OpenAI client.
|
||||
|
||||
Returns:
|
||||
AsyncAzureOpenAIClient: An instance of the asynchronous OpenAI client.
|
||||
"""
|
||||
if self.client:
|
||||
return self.client
|
||||
|
||||
_client_params: dict[str, Any] = self._get_client_params()
|
||||
|
||||
if self.http_client:
|
||||
_client_params['http_client'] = self.http_client
|
||||
else:
|
||||
# Create a new async HTTP client with custom limits
|
||||
_client_params['http_client'] = httpx.AsyncClient(
|
||||
limits=httpx.Limits(max_connections=20, max_keepalive_connections=6)
|
||||
)
|
||||
|
||||
self.client = AsyncAzureOpenAIClient(**_client_params)
|
||||
|
||||
return self.client
|
||||
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
We have switched all of our code from langchain to openai.types.chat.chat_completion_message_param.
|
||||
|
||||
For easier transition we have
|
||||
"""
|
||||
|
||||
from typing import Any, Protocol, TypeVar, overload, runtime_checkable
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from browser_use.llm.messages import BaseMessage
|
||||
from browser_use.llm.views import ChatInvokeCompletion
|
||||
|
||||
T = TypeVar('T', bound=BaseModel)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class BaseChatModel(Protocol):
|
||||
_verified_api_keys: bool = False
|
||||
|
||||
model: str
|
||||
|
||||
@property
|
||||
def provider(self) -> str: ...
|
||||
|
||||
@property
|
||||
def name(self) -> str: ...
|
||||
|
||||
@property
|
||||
def model_name(self) -> str:
|
||||
# for legacy support
|
||||
return self.model
|
||||
|
||||
@overload
|
||||
async def ainvoke(self, messages: list[BaseMessage], output_format: None = None) -> ChatInvokeCompletion[str]: ...
|
||||
|
||||
@overload
|
||||
async def ainvoke(self, messages: list[BaseMessage], output_format: type[T]) -> ChatInvokeCompletion[T]: ...
|
||||
|
||||
async def ainvoke(
|
||||
self, messages: list[BaseMessage], output_format: type[T] | None = None
|
||||
) -> ChatInvokeCompletion[T] | ChatInvokeCompletion[str]: ...
|
||||
|
||||
@classmethod
|
||||
def __get_pydantic_core_schema__(
|
||||
cls,
|
||||
source_type: type,
|
||||
handler: Any,
|
||||
) -> Any:
|
||||
"""
|
||||
Allow this Protocol to be used in Pydantic models -> very useful to typesafe the agent settings for example.
|
||||
Returns a schema that allows any object (since this is a Protocol).
|
||||
"""
|
||||
from pydantic_core import core_schema
|
||||
|
||||
# Return a schema that accepts any object for Protocol types
|
||||
return core_schema.any_schema()
|
||||
@@ -0,0 +1,212 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, TypeVar, overload
|
||||
|
||||
import httpx
|
||||
from openai import (
|
||||
APIConnectionError,
|
||||
APIError,
|
||||
APIStatusError,
|
||||
APITimeoutError,
|
||||
AsyncOpenAI,
|
||||
RateLimitError,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
from browser_use.llm.base import BaseChatModel
|
||||
from browser_use.llm.deepseek.serializer import DeepSeekMessageSerializer
|
||||
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
|
||||
|
||||
T = TypeVar('T', bound=BaseModel)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatDeepSeek(BaseChatModel):
|
||||
"""DeepSeek /chat/completions wrapper (OpenAI-compatible)."""
|
||||
|
||||
model: str = 'deepseek-chat'
|
||||
|
||||
# Generation parameters
|
||||
max_tokens: int | None = None
|
||||
temperature: float | None = None
|
||||
top_p: float | None = None
|
||||
seed: int | None = None
|
||||
|
||||
# Connection parameters
|
||||
api_key: str | None = None
|
||||
base_url: str | httpx.URL | None = 'https://api.deepseek.com/v1'
|
||||
timeout: float | httpx.Timeout | None = None
|
||||
client_params: dict[str, Any] | None = None
|
||||
|
||||
@property
|
||||
def provider(self) -> str:
|
||||
return 'deepseek'
|
||||
|
||||
def _client(self) -> AsyncOpenAI:
|
||||
return AsyncOpenAI(
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url,
|
||||
timeout=self.timeout,
|
||||
**(self.client_params or {}),
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.model
|
||||
|
||||
@overload
|
||||
async def ainvoke(
|
||||
self,
|
||||
messages: list[BaseMessage],
|
||||
output_format: None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
stop: list[str] | None = None,
|
||||
) -> ChatInvokeCompletion[str]: ...
|
||||
|
||||
@overload
|
||||
async def ainvoke(
|
||||
self,
|
||||
messages: list[BaseMessage],
|
||||
output_format: type[T],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
stop: list[str] | None = None,
|
||||
) -> ChatInvokeCompletion[T]: ...
|
||||
|
||||
async def ainvoke(
|
||||
self,
|
||||
messages: list[BaseMessage],
|
||||
output_format: type[T] | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
stop: list[str] | None = None,
|
||||
) -> ChatInvokeCompletion[T] | ChatInvokeCompletion[str]:
|
||||
"""
|
||||
DeepSeek ainvoke supports:
|
||||
1. Regular text/multi-turn conversation
|
||||
2. Function Calling
|
||||
3. JSON Output (response_format)
|
||||
4. Conversation prefix continuation (beta, prefix, stop)
|
||||
"""
|
||||
client = self._client()
|
||||
ds_messages = DeepSeekMessageSerializer.serialize_messages(messages)
|
||||
common: dict[str, Any] = {}
|
||||
|
||||
if self.temperature is not None:
|
||||
common['temperature'] = self.temperature
|
||||
if self.max_tokens is not None:
|
||||
common['max_tokens'] = self.max_tokens
|
||||
if self.top_p is not None:
|
||||
common['top_p'] = self.top_p
|
||||
if self.seed is not None:
|
||||
common['seed'] = self.seed
|
||||
|
||||
# Beta conversation prefix continuation (see official documentation)
|
||||
if self.base_url and str(self.base_url).endswith('/beta'):
|
||||
# The last assistant message must have prefix
|
||||
if ds_messages and isinstance(ds_messages[-1], dict) and ds_messages[-1].get('role') == 'assistant':
|
||||
ds_messages[-1]['prefix'] = True
|
||||
if stop:
|
||||
common['stop'] = stop
|
||||
|
||||
# ① Regular multi-turn conversation/text output
|
||||
if output_format is None and not tools:
|
||||
try:
|
||||
resp = await client.chat.completions.create( # type: ignore
|
||||
model=self.model,
|
||||
messages=ds_messages, # type: ignore
|
||||
**common,
|
||||
)
|
||||
return ChatInvokeCompletion(
|
||||
completion=resp.choices[0].message.content or '',
|
||||
usage=None,
|
||||
)
|
||||
except RateLimitError as e:
|
||||
raise ModelRateLimitError(str(e), model=self.name) from e
|
||||
except (APIError, APIConnectionError, APITimeoutError, APIStatusError) as e:
|
||||
raise ModelProviderError(str(e), model=self.name) from e
|
||||
except Exception as e:
|
||||
raise ModelProviderError(str(e), model=self.name) from e
|
||||
|
||||
# ② Function Calling path (with tools or output_format)
|
||||
if tools or (output_format is not None and hasattr(output_format, 'model_json_schema')):
|
||||
try:
|
||||
call_tools = tools
|
||||
tool_choice = None
|
||||
if output_format is not None and hasattr(output_format, 'model_json_schema'):
|
||||
tool_name = output_format.__name__
|
||||
schema = SchemaOptimizer.create_optimized_json_schema(output_format)
|
||||
schema.pop('title', None)
|
||||
call_tools = [
|
||||
{
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': tool_name,
|
||||
'description': f'Return a JSON object of type {tool_name}',
|
||||
'parameters': schema,
|
||||
},
|
||||
}
|
||||
]
|
||||
tool_choice = {'type': 'function', 'function': {'name': tool_name}}
|
||||
resp = await client.chat.completions.create( # type: ignore
|
||||
model=self.model,
|
||||
messages=ds_messages, # type: ignore
|
||||
tools=call_tools, # type: ignore
|
||||
tool_choice=tool_choice, # type: ignore
|
||||
**common,
|
||||
)
|
||||
msg = resp.choices[0].message
|
||||
if not msg.tool_calls:
|
||||
raise ValueError('Expected tool_calls in response but got none')
|
||||
raw_args = msg.tool_calls[0].function.arguments
|
||||
if isinstance(raw_args, str):
|
||||
parsed = json.loads(raw_args)
|
||||
else:
|
||||
parsed = raw_args
|
||||
# --------- Fix: only use model_validate when output_format is not None ----------
|
||||
if output_format is not None:
|
||||
return ChatInvokeCompletion(
|
||||
completion=output_format.model_validate(parsed),
|
||||
usage=None,
|
||||
)
|
||||
else:
|
||||
# If no output_format, return dict directly
|
||||
return ChatInvokeCompletion(
|
||||
completion=parsed,
|
||||
usage=None,
|
||||
)
|
||||
except RateLimitError as e:
|
||||
raise ModelRateLimitError(str(e), model=self.name) from e
|
||||
except (APIError, APIConnectionError, APITimeoutError, APIStatusError) as e:
|
||||
raise ModelProviderError(str(e), model=self.name) from e
|
||||
except Exception as e:
|
||||
raise ModelProviderError(str(e), model=self.name) from e
|
||||
|
||||
# ③ JSON Output path (official response_format)
|
||||
if output_format is not None and hasattr(output_format, 'model_json_schema'):
|
||||
try:
|
||||
resp = await client.chat.completions.create( # type: ignore
|
||||
model=self.model,
|
||||
messages=ds_messages, # type: ignore
|
||||
response_format={'type': 'json_object'},
|
||||
**common,
|
||||
)
|
||||
content = resp.choices[0].message.content
|
||||
if not content:
|
||||
raise ModelProviderError('Empty JSON content in DeepSeek response', model=self.name)
|
||||
parsed = output_format.model_validate_json(content)
|
||||
return ChatInvokeCompletion(
|
||||
completion=parsed,
|
||||
usage=None,
|
||||
)
|
||||
except RateLimitError as e:
|
||||
raise ModelRateLimitError(str(e), model=self.name) from e
|
||||
except (APIError, APIConnectionError, APITimeoutError, APIStatusError) as e:
|
||||
raise ModelProviderError(str(e), model=self.name) from e
|
||||
except Exception as e:
|
||||
raise ModelProviderError(str(e), model=self.name) from e
|
||||
|
||||
raise ModelProviderError('No valid ainvoke execution path for DeepSeek LLM', model=self.name)
|
||||
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, overload
|
||||
|
||||
from browser_use.llm.messages import (
|
||||
AssistantMessage,
|
||||
BaseMessage,
|
||||
ContentPartImageParam,
|
||||
ContentPartTextParam,
|
||||
SystemMessage,
|
||||
ToolCall,
|
||||
UserMessage,
|
||||
)
|
||||
|
||||
MessageDict = dict[str, Any]
|
||||
|
||||
|
||||
class DeepSeekMessageSerializer:
|
||||
"""Serializer for converting browser-use messages to DeepSeek messages."""
|
||||
|
||||
# -------- content 处理 --------------------------------------------------
|
||||
@staticmethod
|
||||
def _serialize_text_part(part: ContentPartTextParam) -> str:
|
||||
return part.text
|
||||
|
||||
@staticmethod
|
||||
def _serialize_image_part(part: ContentPartImageParam) -> dict[str, Any]:
|
||||
url = part.image_url.url
|
||||
if url.startswith('data:'):
|
||||
return {'type': 'image_url', 'image_url': {'url': url}}
|
||||
return {'type': 'image_url', 'image_url': {'url': url}}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_content(content: Any) -> str | list[dict[str, Any]]:
|
||||
if content is None:
|
||||
return ''
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
serialized: list[dict[str, Any]] = []
|
||||
for part in content:
|
||||
if part.type == 'text':
|
||||
serialized.append({'type': 'text', 'text': DeepSeekMessageSerializer._serialize_text_part(part)})
|
||||
elif part.type == 'image_url':
|
||||
serialized.append(DeepSeekMessageSerializer._serialize_image_part(part))
|
||||
elif part.type == 'refusal':
|
||||
serialized.append({'type': 'text', 'text': f'[Refusal] {part.refusal}'})
|
||||
return serialized
|
||||
|
||||
# -------- Tool-call 处理 -------------------------------------------------
|
||||
@staticmethod
|
||||
def _serialize_tool_calls(tool_calls: list[ToolCall]) -> list[dict[str, Any]]:
|
||||
deepseek_tool_calls: list[dict[str, Any]] = []
|
||||
for tc in tool_calls:
|
||||
try:
|
||||
arguments = json.loads(tc.function.arguments)
|
||||
except json.JSONDecodeError:
|
||||
arguments = {'arguments': tc.function.arguments}
|
||||
deepseek_tool_calls.append(
|
||||
{
|
||||
'id': tc.id,
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': tc.function.name,
|
||||
'arguments': arguments,
|
||||
},
|
||||
}
|
||||
)
|
||||
return deepseek_tool_calls
|
||||
|
||||
# -------- 单条消息序列化 -------------------------------------------------
|
||||
@overload
|
||||
@staticmethod
|
||||
def serialize(message: UserMessage) -> MessageDict: ...
|
||||
|
||||
@overload
|
||||
@staticmethod
|
||||
def serialize(message: SystemMessage) -> MessageDict: ...
|
||||
|
||||
@overload
|
||||
@staticmethod
|
||||
def serialize(message: AssistantMessage) -> MessageDict: ...
|
||||
|
||||
@staticmethod
|
||||
def serialize(message: BaseMessage) -> MessageDict:
|
||||
if isinstance(message, UserMessage):
|
||||
return {
|
||||
'role': 'user',
|
||||
'content': DeepSeekMessageSerializer._serialize_content(message.content),
|
||||
}
|
||||
if isinstance(message, SystemMessage):
|
||||
return {
|
||||
'role': 'system',
|
||||
'content': DeepSeekMessageSerializer._serialize_content(message.content),
|
||||
}
|
||||
if isinstance(message, AssistantMessage):
|
||||
msg: MessageDict = {
|
||||
'role': 'assistant',
|
||||
'content': DeepSeekMessageSerializer._serialize_content(message.content),
|
||||
}
|
||||
if message.tool_calls:
|
||||
msg['tool_calls'] = DeepSeekMessageSerializer._serialize_tool_calls(message.tool_calls)
|
||||
return msg
|
||||
raise ValueError(f'Unknown message type: {type(message)}')
|
||||
|
||||
# -------- 列表序列化 -----------------------------------------------------
|
||||
@staticmethod
|
||||
def serialize_messages(messages: list[BaseMessage]) -> list[MessageDict]:
|
||||
return [DeepSeekMessageSerializer.serialize(m) for m in messages]
|
||||
@@ -0,0 +1,27 @@
|
||||
class ModelError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ModelProviderError(ModelError):
|
||||
"""Exception raised when a model provider returns an error."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
status_code: int = 502,
|
||||
model: str | None = None,
|
||||
):
|
||||
super().__init__(message, status_code)
|
||||
self.model = model
|
||||
|
||||
|
||||
class ModelRateLimitError(ModelProviderError):
|
||||
"""Exception raised when a model provider returns a rate limit error."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
status_code: int = 429,
|
||||
model: str | None = None,
|
||||
):
|
||||
super().__init__(message, status_code, model)
|
||||
@@ -0,0 +1,3 @@
|
||||
from browser_use.llm.google.chat import ChatGoogle
|
||||
|
||||
__all__ = ['ChatGoogle']
|
||||
@@ -0,0 +1,506 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal, TypeVar, overload
|
||||
|
||||
from google import genai
|
||||
from google.auth.credentials import Credentials
|
||||
from google.genai import types
|
||||
from google.genai.types import MediaModality
|
||||
from pydantic import BaseModel
|
||||
|
||||
from browser_use.llm.base import BaseChatModel
|
||||
from browser_use.llm.exceptions import ModelProviderError
|
||||
from browser_use.llm.google.serializer import GoogleMessageSerializer
|
||||
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)
|
||||
|
||||
|
||||
VerifiedGeminiModels = Literal[
|
||||
'gemini-2.0-flash',
|
||||
'gemini-2.0-flash-exp',
|
||||
'gemini-2.0-flash-lite-preview-02-05',
|
||||
'Gemini-2.0-exp',
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-flash-lite',
|
||||
'gemini-2.5-pro',
|
||||
'gemma-3-27b-it',
|
||||
'gemma-3-4b',
|
||||
'gemma-3-12b',
|
||||
'gemma-3n-e2b',
|
||||
'gemma-3n-e4b',
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatGoogle(BaseChatModel):
|
||||
"""
|
||||
A wrapper around Google's Gemini chat model using the genai client.
|
||||
|
||||
This class accepts all genai.Client parameters while adding model,
|
||||
temperature, and config parameters for the LLM interface.
|
||||
|
||||
Args:
|
||||
model: The Gemini model to use
|
||||
temperature: Temperature for response generation
|
||||
config: Additional configuration parameters to pass to generate_content
|
||||
(e.g., tools, safety_settings, etc.).
|
||||
api_key: Google API key
|
||||
vertexai: Whether to use Vertex AI
|
||||
credentials: Google credentials object
|
||||
project: Google Cloud project ID
|
||||
location: Google Cloud location
|
||||
http_options: HTTP options for the client
|
||||
include_system_in_user: If True, system messages are included in the first user message
|
||||
supports_structured_output: If True, uses native JSON mode; if False, uses prompt-based fallback
|
||||
|
||||
Example:
|
||||
from google.genai import types
|
||||
|
||||
llm = ChatGoogle(
|
||||
model='gemini-2.0-flash-exp',
|
||||
config={
|
||||
'tools': [types.Tool(code_execution=types.ToolCodeExecution())]
|
||||
}
|
||||
)
|
||||
"""
|
||||
|
||||
# Model configuration
|
||||
model: VerifiedGeminiModels | str
|
||||
temperature: float | None = 0.2
|
||||
top_p: float | None = None
|
||||
seed: int | None = None
|
||||
thinking_budget: int | None = None
|
||||
max_output_tokens: int | None = 4096
|
||||
config: types.GenerateContentConfigDict | None = None
|
||||
include_system_in_user: bool = False
|
||||
supports_structured_output: bool = True # New flag
|
||||
|
||||
# Client initialization parameters
|
||||
api_key: str | None = None
|
||||
vertexai: bool | None = None
|
||||
credentials: Credentials | None = None
|
||||
project: str | None = None
|
||||
location: str | None = None
|
||||
http_options: types.HttpOptions | types.HttpOptionsDict | None = None
|
||||
|
||||
# Static
|
||||
@property
|
||||
def provider(self) -> str:
|
||||
return 'google'
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
"""Get logger for this chat instance"""
|
||||
return logging.getLogger(f'browser_use.llm.google.{self.model}')
|
||||
|
||||
def _get_client_params(self) -> dict[str, Any]:
|
||||
"""Prepare client parameters dictionary."""
|
||||
# Define base client params
|
||||
base_params = {
|
||||
'api_key': self.api_key,
|
||||
'vertexai': self.vertexai,
|
||||
'credentials': self.credentials,
|
||||
'project': self.project,
|
||||
'location': self.location,
|
||||
'http_options': self.http_options,
|
||||
}
|
||||
|
||||
# Create client_params dict with non-None values
|
||||
client_params = {k: v for k, v in base_params.items() if v is not None}
|
||||
|
||||
return client_params
|
||||
|
||||
def get_client(self) -> genai.Client:
|
||||
"""
|
||||
Returns a genai.Client instance.
|
||||
|
||||
Returns:
|
||||
genai.Client: An instance of the Google genai client.
|
||||
"""
|
||||
client_params = self._get_client_params()
|
||||
return genai.Client(**client_params)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return str(self.model)
|
||||
|
||||
def _get_usage(self, response: types.GenerateContentResponse) -> ChatInvokeUsage | None:
|
||||
usage: ChatInvokeUsage | None = None
|
||||
|
||||
if response.usage_metadata is not None:
|
||||
image_tokens = 0
|
||||
if response.usage_metadata.prompt_tokens_details is not None:
|
||||
image_tokens = sum(
|
||||
detail.token_count or 0
|
||||
for detail in response.usage_metadata.prompt_tokens_details
|
||||
if detail.modality == MediaModality.IMAGE
|
||||
)
|
||||
|
||||
usage = ChatInvokeUsage(
|
||||
prompt_tokens=response.usage_metadata.prompt_token_count or 0,
|
||||
completion_tokens=(response.usage_metadata.candidates_token_count or 0)
|
||||
+ (response.usage_metadata.thoughts_token_count or 0),
|
||||
total_tokens=response.usage_metadata.total_token_count or 0,
|
||||
prompt_cached_tokens=response.usage_metadata.cached_content_token_count,
|
||||
prompt_cache_creation_tokens=None,
|
||||
prompt_image_tokens=image_tokens,
|
||||
)
|
||||
|
||||
return usage
|
||||
|
||||
@overload
|
||||
async def ainvoke(self, messages: list[BaseMessage], output_format: None = None) -> ChatInvokeCompletion[str]: ...
|
||||
|
||||
@overload
|
||||
async def ainvoke(self, messages: list[BaseMessage], output_format: type[T]) -> ChatInvokeCompletion[T]: ...
|
||||
|
||||
async def ainvoke(
|
||||
self, messages: list[BaseMessage], output_format: type[T] | None = None
|
||||
) -> ChatInvokeCompletion[T] | ChatInvokeCompletion[str]:
|
||||
"""
|
||||
Invoke the model with the given messages.
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
# Serialize messages to Google format with the include_system_in_user flag
|
||||
contents, system_instruction = GoogleMessageSerializer.serialize_messages(
|
||||
messages, include_system_in_user=self.include_system_in_user
|
||||
)
|
||||
|
||||
# Build config dictionary starting with user-provided config
|
||||
config: types.GenerateContentConfigDict = {}
|
||||
if self.config:
|
||||
config = self.config.copy()
|
||||
|
||||
# Apply model-specific configuration (these can override config)
|
||||
if self.temperature is not None:
|
||||
config['temperature'] = self.temperature
|
||||
|
||||
# Add system instruction if present
|
||||
if system_instruction:
|
||||
config['system_instruction'] = system_instruction
|
||||
|
||||
if self.top_p is not None:
|
||||
config['top_p'] = self.top_p
|
||||
|
||||
if self.seed is not None:
|
||||
config['seed'] = self.seed
|
||||
|
||||
if self.thinking_budget is None and 'gemini-2.5-flash' in self.model:
|
||||
self.thinking_budget = 0
|
||||
|
||||
if self.thinking_budget is not None:
|
||||
thinking_config_dict: types.ThinkingConfigDict = {'thinking_budget': self.thinking_budget}
|
||||
config['thinking_config'] = thinking_config_dict
|
||||
|
||||
if self.max_output_tokens is not None:
|
||||
config['max_output_tokens'] = self.max_output_tokens
|
||||
|
||||
async def _make_api_call():
|
||||
start_time = time.time()
|
||||
self.logger.debug(f'🚀 Starting API call to {self.model}')
|
||||
|
||||
try:
|
||||
if output_format is None:
|
||||
# Return string response
|
||||
self.logger.debug('📄 Requesting text response')
|
||||
|
||||
response = await self.get_client().aio.models.generate_content(
|
||||
model=self.model,
|
||||
contents=contents, # type: ignore
|
||||
config=config,
|
||||
)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
self.logger.debug(f'✅ Got text response in {elapsed:.2f}s')
|
||||
|
||||
# Handle case where response.text might be None
|
||||
text = response.text or ''
|
||||
if not text:
|
||||
self.logger.warning('⚠️ Empty text response received')
|
||||
|
||||
usage = self._get_usage(response)
|
||||
|
||||
return ChatInvokeCompletion(
|
||||
completion=text,
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
else:
|
||||
# Handle structured output
|
||||
if self.supports_structured_output:
|
||||
# Use native JSON mode
|
||||
self.logger.debug(f'🔧 Requesting structured output for {output_format.__name__}')
|
||||
config['response_mime_type'] = 'application/json'
|
||||
# Convert Pydantic model to Gemini-compatible schema
|
||||
optimized_schema = SchemaOptimizer.create_optimized_json_schema(output_format)
|
||||
|
||||
gemini_schema = self._fix_gemini_schema(optimized_schema)
|
||||
config['response_schema'] = gemini_schema
|
||||
|
||||
response = await self.get_client().aio.models.generate_content(
|
||||
model=self.model,
|
||||
contents=contents,
|
||||
config=config,
|
||||
)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
self.logger.debug(f'✅ Got structured response in {elapsed:.2f}s')
|
||||
|
||||
usage = self._get_usage(response)
|
||||
|
||||
# Handle case where response.parsed might be None
|
||||
if response.parsed is None:
|
||||
self.logger.debug('📝 Parsing JSON from text response')
|
||||
# When using response_schema, Gemini returns JSON as text
|
||||
if response.text:
|
||||
try:
|
||||
# Handle JSON wrapped in markdown code blocks (common Gemini behavior)
|
||||
text = response.text.strip()
|
||||
if text.startswith('```json') and text.endswith('```'):
|
||||
text = text[7:-3].strip()
|
||||
self.logger.debug('🔧 Stripped ```json``` wrapper from response')
|
||||
elif text.startswith('```') and text.endswith('```'):
|
||||
text = text[3:-3].strip()
|
||||
self.logger.debug('🔧 Stripped ``` wrapper from response')
|
||||
|
||||
# Parse the JSON text and validate with the Pydantic model
|
||||
parsed_data = json.loads(text)
|
||||
return ChatInvokeCompletion(
|
||||
completion=output_format.model_validate(parsed_data),
|
||||
usage=usage,
|
||||
)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
self.logger.error(f'❌ Failed to parse JSON response: {str(e)}')
|
||||
self.logger.debug(f'Raw response text: {response.text[:200]}...')
|
||||
raise ModelProviderError(
|
||||
message=f'Failed to parse or validate response {response}: {str(e)}',
|
||||
status_code=500,
|
||||
model=self.model,
|
||||
) from e
|
||||
else:
|
||||
self.logger.error('❌ No response text received')
|
||||
raise ModelProviderError(
|
||||
message=f'No response from model {response}',
|
||||
status_code=500,
|
||||
model=self.model,
|
||||
)
|
||||
|
||||
# Ensure we return the correct type
|
||||
if isinstance(response.parsed, output_format):
|
||||
return ChatInvokeCompletion(
|
||||
completion=response.parsed,
|
||||
usage=usage,
|
||||
)
|
||||
else:
|
||||
# If it's not the expected type, try to validate it
|
||||
return ChatInvokeCompletion(
|
||||
completion=output_format.model_validate(response.parsed),
|
||||
usage=usage,
|
||||
)
|
||||
else:
|
||||
# Fallback: Request JSON in the prompt for models without native JSON mode
|
||||
self.logger.debug(f'🔄 Using fallback JSON mode for {output_format.__name__}')
|
||||
# Create a copy of messages to modify
|
||||
modified_messages = [m.model_copy(deep=True) for m in messages]
|
||||
|
||||
# Add JSON instruction to the last message
|
||||
if modified_messages and isinstance(modified_messages[-1].content, str):
|
||||
json_instruction = f'\n\nPlease respond with a valid JSON object that matches this schema: {SchemaOptimizer.create_optimized_json_schema(output_format)}'
|
||||
modified_messages[-1].content += json_instruction
|
||||
|
||||
# Re-serialize with modified messages
|
||||
fallback_contents, fallback_system = GoogleMessageSerializer.serialize_messages(
|
||||
modified_messages, include_system_in_user=self.include_system_in_user
|
||||
)
|
||||
|
||||
# Update config with fallback system instruction if present
|
||||
fallback_config = config.copy()
|
||||
if fallback_system:
|
||||
fallback_config['system_instruction'] = fallback_system
|
||||
|
||||
response = await self.get_client().aio.models.generate_content(
|
||||
model=self.model,
|
||||
contents=fallback_contents, # type: ignore
|
||||
config=fallback_config,
|
||||
)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
self.logger.debug(f'✅ Got fallback response in {elapsed:.2f}s')
|
||||
|
||||
usage = self._get_usage(response)
|
||||
|
||||
# Try to extract JSON from the text response
|
||||
if response.text:
|
||||
try:
|
||||
# Try to find JSON in the response
|
||||
text = response.text.strip()
|
||||
|
||||
# Common patterns: JSON wrapped in markdown code blocks
|
||||
if text.startswith('```json') and text.endswith('```'):
|
||||
text = text[7:-3].strip()
|
||||
elif text.startswith('```') and text.endswith('```'):
|
||||
text = text[3:-3].strip()
|
||||
|
||||
# Parse and validate
|
||||
parsed_data = json.loads(text)
|
||||
return ChatInvokeCompletion(
|
||||
completion=output_format.model_validate(parsed_data),
|
||||
usage=usage,
|
||||
)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
self.logger.error(f'❌ Failed to parse fallback JSON: {str(e)}')
|
||||
self.logger.debug(f'Raw response text: {response.text[:200]}...')
|
||||
raise ModelProviderError(
|
||||
message=f'Model does not support JSON mode and failed to parse JSON from text response: {str(e)}',
|
||||
status_code=500,
|
||||
model=self.model,
|
||||
) from e
|
||||
else:
|
||||
self.logger.error('❌ No response text in fallback mode')
|
||||
raise ModelProviderError(
|
||||
message='No response from model',
|
||||
status_code=500,
|
||||
model=self.model,
|
||||
)
|
||||
except Exception as e:
|
||||
elapsed = time.time() - start_time
|
||||
self.logger.error(f'💥 API call failed after {elapsed:.2f}s: {type(e).__name__}: {e}')
|
||||
# Re-raise the exception
|
||||
raise
|
||||
|
||||
try:
|
||||
# Let Google client handle retries internally with proper connection management
|
||||
self.logger.debug(f'🔄 Making API call to {self.model} (using built-in retry)')
|
||||
return await _make_api_call()
|
||||
|
||||
except Exception as e:
|
||||
# Handle specific Google API errors with enhanced diagnostics
|
||||
error_message = str(e)
|
||||
status_code: int | None = None
|
||||
|
||||
# Enhanced timeout error handling
|
||||
if 'timeout' in error_message.lower() or 'cancelled' in error_message.lower():
|
||||
if isinstance(e, asyncio.CancelledError) or 'CancelledError' in str(type(e)):
|
||||
enhanced_message = 'Gemini API request was cancelled (likely timeout). '
|
||||
enhanced_message += 'This suggests the API is taking too long to respond. '
|
||||
enhanced_message += (
|
||||
'Consider: 1) Reducing input size, 2) Using a different model, 3) Checking network connectivity.'
|
||||
)
|
||||
error_message = enhanced_message
|
||||
status_code = 504 # Gateway timeout
|
||||
self.logger.error(f'🕐 Timeout diagnosis: Model: {self.model}')
|
||||
else:
|
||||
status_code = 408 # Request timeout
|
||||
# Check if this is a rate limit error
|
||||
elif any(
|
||||
indicator in error_message.lower()
|
||||
for indicator in ['rate limit', 'resource exhausted', 'quota exceeded', 'too many requests', '429']
|
||||
):
|
||||
status_code = 429
|
||||
elif any(
|
||||
indicator in error_message.lower()
|
||||
for indicator in ['service unavailable', 'internal server error', 'bad gateway', '503', '502', '500']
|
||||
):
|
||||
status_code = 503
|
||||
|
||||
# Try to extract status code if available
|
||||
if hasattr(e, 'response'):
|
||||
response_obj = getattr(e, 'response', None)
|
||||
if response_obj and hasattr(response_obj, 'status_code'):
|
||||
status_code = getattr(response_obj, 'status_code', None)
|
||||
|
||||
raise ModelProviderError(
|
||||
message=error_message,
|
||||
status_code=status_code or 502, # Use default if None
|
||||
model=self.name,
|
||||
) from e
|
||||
|
||||
def _fix_gemini_schema(self, schema: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Convert a Pydantic model to a Gemini-compatible schema.
|
||||
|
||||
This function removes unsupported properties like 'additionalProperties' and resolves
|
||||
$ref references that Gemini doesn't support.
|
||||
"""
|
||||
|
||||
# Handle $defs and $ref resolution
|
||||
if '$defs' in schema:
|
||||
defs = schema.pop('$defs')
|
||||
|
||||
def resolve_refs(obj: Any) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
if '$ref' in obj:
|
||||
ref = obj.pop('$ref')
|
||||
ref_name = ref.split('/')[-1]
|
||||
if ref_name in defs:
|
||||
# Replace the reference with the actual definition
|
||||
resolved = defs[ref_name].copy()
|
||||
# Merge any additional properties from the reference
|
||||
for key, value in obj.items():
|
||||
if key != '$ref':
|
||||
resolved[key] = value
|
||||
return resolve_refs(resolved)
|
||||
return obj
|
||||
else:
|
||||
# Recursively process all dictionary values
|
||||
return {k: resolve_refs(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [resolve_refs(item) for item in obj]
|
||||
return obj
|
||||
|
||||
schema = resolve_refs(schema)
|
||||
|
||||
# Remove unsupported properties
|
||||
def clean_schema(obj: Any) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
# Remove unsupported properties
|
||||
cleaned = {}
|
||||
for key, value in obj.items():
|
||||
if key not in ['additionalProperties', 'title', 'default']:
|
||||
cleaned_value = clean_schema(value)
|
||||
# Handle empty object properties - Gemini doesn't allow empty OBJECT types
|
||||
if (
|
||||
key == 'properties'
|
||||
and isinstance(cleaned_value, dict)
|
||||
and len(cleaned_value) == 0
|
||||
and isinstance(obj.get('type', ''), str)
|
||||
and obj.get('type', '').upper() == 'OBJECT'
|
||||
):
|
||||
# Convert empty object to have at least one property
|
||||
cleaned['properties'] = {'_placeholder': {'type': 'string'}}
|
||||
else:
|
||||
cleaned[key] = cleaned_value
|
||||
|
||||
# If this is an object type with empty properties, add a placeholder
|
||||
if (
|
||||
isinstance(cleaned.get('type', ''), str)
|
||||
and cleaned.get('type', '').upper() == 'OBJECT'
|
||||
and 'properties' in cleaned
|
||||
and isinstance(cleaned['properties'], dict)
|
||||
and len(cleaned['properties']) == 0
|
||||
):
|
||||
cleaned['properties'] = {'_placeholder': {'type': 'string'}}
|
||||
|
||||
# Also remove 'title' from the required list if it exists
|
||||
if 'required' in cleaned and isinstance(cleaned.get('required'), list):
|
||||
cleaned['required'] = [p for p in cleaned['required'] if p != 'title']
|
||||
|
||||
return cleaned
|
||||
elif isinstance(obj, list):
|
||||
return [clean_schema(item) for item in obj]
|
||||
return obj
|
||||
|
||||
return clean_schema(schema)
|
||||
@@ -0,0 +1,120 @@
|
||||
import base64
|
||||
|
||||
from google.genai.types import Content, ContentListUnion, Part
|
||||
|
||||
from browser_use.llm.messages import (
|
||||
AssistantMessage,
|
||||
BaseMessage,
|
||||
SystemMessage,
|
||||
UserMessage,
|
||||
)
|
||||
|
||||
|
||||
class GoogleMessageSerializer:
|
||||
"""Serializer for converting messages to Google Gemini format."""
|
||||
|
||||
@staticmethod
|
||||
def serialize_messages(
|
||||
messages: list[BaseMessage], include_system_in_user: bool = False
|
||||
) -> tuple[ContentListUnion, str | None]:
|
||||
"""
|
||||
Convert a list of BaseMessages to Google format, extracting system message.
|
||||
|
||||
Google handles system instructions separately from the conversation, so we need to:
|
||||
1. Extract any system messages and return them separately as a string (or include in first user message if flag is set)
|
||||
2. Convert the remaining messages to Content objects
|
||||
|
||||
Args:
|
||||
messages: List of messages to convert
|
||||
include_system_in_user: If True, system/developer messages are prepended to the first user message
|
||||
|
||||
Returns:
|
||||
A tuple of (formatted_messages, system_message) where:
|
||||
- formatted_messages: List of Content objects for the conversation
|
||||
- system_message: System instruction string or None
|
||||
"""
|
||||
|
||||
messages = [m.model_copy(deep=True) for m in messages]
|
||||
|
||||
formatted_messages: ContentListUnion = []
|
||||
system_message: str | None = None
|
||||
system_parts: list[str] = []
|
||||
|
||||
for i, message in enumerate(messages):
|
||||
role = message.role if hasattr(message, 'role') else None
|
||||
|
||||
# Handle system/developer messages
|
||||
if isinstance(message, SystemMessage) or role in ['system', 'developer']:
|
||||
# Extract system message content as string
|
||||
if isinstance(message.content, str):
|
||||
if include_system_in_user:
|
||||
system_parts.append(message.content)
|
||||
else:
|
||||
system_message = message.content
|
||||
elif message.content is not None:
|
||||
# Handle Iterable of content parts
|
||||
parts = []
|
||||
for part in message.content:
|
||||
if part.type == 'text':
|
||||
parts.append(part.text)
|
||||
combined_text = '\n'.join(parts)
|
||||
if include_system_in_user:
|
||||
system_parts.append(combined_text)
|
||||
else:
|
||||
system_message = combined_text
|
||||
continue
|
||||
|
||||
# Determine the role for non-system messages
|
||||
if isinstance(message, UserMessage):
|
||||
role = 'user'
|
||||
elif isinstance(message, AssistantMessage):
|
||||
role = 'model'
|
||||
else:
|
||||
# Default to user for any unknown message types
|
||||
role = 'user'
|
||||
|
||||
# Initialize message parts
|
||||
message_parts: list[Part] = []
|
||||
|
||||
# If this is the first user message and we have system parts, prepend them
|
||||
if include_system_in_user and system_parts and role == 'user' and not formatted_messages:
|
||||
system_text = '\n\n'.join(system_parts)
|
||||
if isinstance(message.content, str):
|
||||
message_parts.append(Part.from_text(text=f'{system_text}\n\n{message.content}'))
|
||||
else:
|
||||
# Add system text as the first part
|
||||
message_parts.append(Part.from_text(text=system_text))
|
||||
system_parts = [] # Clear after using
|
||||
else:
|
||||
# Extract content and create parts normally
|
||||
if isinstance(message.content, str):
|
||||
# Regular text content
|
||||
message_parts = [Part.from_text(text=message.content)]
|
||||
elif message.content is not None:
|
||||
# Handle Iterable of content parts
|
||||
for part in message.content:
|
||||
if part.type == 'text':
|
||||
message_parts.append(Part.from_text(text=part.text))
|
||||
elif part.type == 'refusal':
|
||||
message_parts.append(Part.from_text(text=f'[Refusal] {part.refusal}'))
|
||||
elif part.type == 'image_url':
|
||||
# Handle images
|
||||
url = part.image_url.url
|
||||
|
||||
# Format: data:image/png;base64,<data>
|
||||
header, data = url.split(',', 1)
|
||||
# Decode base64 to bytes
|
||||
image_bytes = base64.b64decode(data)
|
||||
|
||||
# Add image part
|
||||
image_part = Part.from_bytes(data=image_bytes, mime_type='image/png')
|
||||
|
||||
message_parts.append(image_part)
|
||||
|
||||
# Create the Content object
|
||||
if message_parts:
|
||||
final_message = Content(role=role, parts=message_parts)
|
||||
# for some reason, the type checker is not able to infer the type of formatted_messages
|
||||
formatted_messages.append(final_message) # type: ignore
|
||||
|
||||
return formatted_messages, system_message
|
||||
@@ -0,0 +1,229 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, TypeVar, overload
|
||||
|
||||
from groq import (
|
||||
APIError,
|
||||
APIResponseValidationError,
|
||||
APIStatusError,
|
||||
AsyncGroq,
|
||||
NotGiven,
|
||||
RateLimitError,
|
||||
Timeout,
|
||||
)
|
||||
from groq.types.chat import ChatCompletion, ChatCompletionToolChoiceOptionParam, ChatCompletionToolParam
|
||||
from groq.types.chat.completion_create_params import (
|
||||
ResponseFormatResponseFormatJsonSchema,
|
||||
ResponseFormatResponseFormatJsonSchemaJsonSchema,
|
||||
)
|
||||
from httpx import URL
|
||||
from pydantic import BaseModel
|
||||
|
||||
from browser_use.llm.base import BaseChatModel, ChatInvokeCompletion
|
||||
from browser_use.llm.exceptions import ModelProviderError, ModelRateLimitError
|
||||
from browser_use.llm.groq.parser import try_parse_groq_failed_generation
|
||||
from browser_use.llm.groq.serializer import GroqMessageSerializer
|
||||
from browser_use.llm.messages import BaseMessage
|
||||
from browser_use.llm.schema import SchemaOptimizer
|
||||
from browser_use.llm.views import ChatInvokeUsage
|
||||
|
||||
GroqVerifiedModels = Literal[
|
||||
'meta-llama/llama-4-maverick-17b-128e-instruct',
|
||||
'meta-llama/llama-4-scout-17b-16e-instruct',
|
||||
'qwen/qwen3-32b',
|
||||
'moonshotai/kimi-k2-instruct',
|
||||
'openai/gpt-oss-20b',
|
||||
'openai/gpt-oss-120b',
|
||||
]
|
||||
|
||||
JsonSchemaModels = [
|
||||
'meta-llama/llama-4-maverick-17b-128e-instruct',
|
||||
'meta-llama/llama-4-scout-17b-16e-instruct',
|
||||
'openai/gpt-oss-20b',
|
||||
'openai/gpt-oss-120b',
|
||||
]
|
||||
|
||||
ToolCallingModels = [
|
||||
'moonshotai/kimi-k2-instruct',
|
||||
]
|
||||
|
||||
T = TypeVar('T', bound=BaseModel)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatGroq(BaseChatModel):
|
||||
"""
|
||||
A wrapper around AsyncGroq that implements the BaseLLM protocol.
|
||||
"""
|
||||
|
||||
# Model configuration
|
||||
model: GroqVerifiedModels | str
|
||||
|
||||
# Model params
|
||||
temperature: float | None = None
|
||||
service_tier: Literal['auto', 'on_demand', 'flex'] | None = None
|
||||
top_p: float | None = None
|
||||
seed: int | None = None
|
||||
|
||||
# Client initialization parameters
|
||||
api_key: str | None = None
|
||||
base_url: str | URL | None = None
|
||||
timeout: float | Timeout | NotGiven | None = None
|
||||
max_retries: int = 10 # Increase default retries for automation reliability
|
||||
|
||||
def get_client(self) -> AsyncGroq:
|
||||
return AsyncGroq(api_key=self.api_key, base_url=self.base_url, timeout=self.timeout, max_retries=self.max_retries)
|
||||
|
||||
@property
|
||||
def provider(self) -> str:
|
||||
return 'groq'
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return str(self.model)
|
||||
|
||||
def _get_usage(self, response: ChatCompletion) -> ChatInvokeUsage | None:
|
||||
usage = (
|
||||
ChatInvokeUsage(
|
||||
prompt_tokens=response.usage.prompt_tokens,
|
||||
completion_tokens=response.usage.completion_tokens,
|
||||
total_tokens=response.usage.total_tokens,
|
||||
prompt_cached_tokens=None, # Groq doesn't support cached tokens
|
||||
prompt_cache_creation_tokens=None,
|
||||
prompt_image_tokens=None,
|
||||
)
|
||||
if response.usage is not None
|
||||
else None
|
||||
)
|
||||
return usage
|
||||
|
||||
@overload
|
||||
async def ainvoke(self, messages: list[BaseMessage], output_format: None = None) -> ChatInvokeCompletion[str]: ...
|
||||
|
||||
@overload
|
||||
async def ainvoke(self, messages: list[BaseMessage], output_format: type[T]) -> ChatInvokeCompletion[T]: ...
|
||||
|
||||
async def ainvoke(
|
||||
self, messages: list[BaseMessage], output_format: type[T] | None = None
|
||||
) -> ChatInvokeCompletion[T] | ChatInvokeCompletion[str]:
|
||||
groq_messages = GroqMessageSerializer.serialize_messages(messages)
|
||||
|
||||
try:
|
||||
if output_format is None:
|
||||
return await self._invoke_regular_completion(groq_messages)
|
||||
else:
|
||||
return await self._invoke_structured_output(groq_messages, output_format)
|
||||
|
||||
except RateLimitError as e:
|
||||
raise ModelRateLimitError(message=e.response.text, status_code=e.response.status_code, model=self.name) from e
|
||||
|
||||
except APIResponseValidationError as e:
|
||||
raise ModelProviderError(message=e.response.text, status_code=e.response.status_code, model=self.name) from e
|
||||
|
||||
except APIStatusError as e:
|
||||
if output_format is None:
|
||||
raise ModelProviderError(message=e.response.text, status_code=e.response.status_code, model=self.name) from e
|
||||
else:
|
||||
try:
|
||||
logger.debug(f'Groq failed generation: {e.response.text}; fallback to manual parsing')
|
||||
|
||||
parsed_response = try_parse_groq_failed_generation(e, output_format)
|
||||
|
||||
logger.debug('Manual error parsing successful ✅')
|
||||
|
||||
return ChatInvokeCompletion(
|
||||
completion=parsed_response,
|
||||
usage=None, # because this is a hacky way to get the outputs
|
||||
# TODO: @groq needs to fix their parsers and validators
|
||||
)
|
||||
except Exception as _:
|
||||
raise ModelProviderError(message=str(e), status_code=e.response.status_code, model=self.name) from e
|
||||
|
||||
except APIError as e:
|
||||
raise ModelProviderError(message=e.message, model=self.name) from e
|
||||
except Exception as e:
|
||||
raise ModelProviderError(message=str(e), model=self.name) from e
|
||||
|
||||
async def _invoke_regular_completion(self, groq_messages) -> ChatInvokeCompletion[str]:
|
||||
"""Handle regular completion without structured output."""
|
||||
chat_completion = await self.get_client().chat.completions.create(
|
||||
messages=groq_messages,
|
||||
model=self.model,
|
||||
service_tier=self.service_tier,
|
||||
temperature=self.temperature,
|
||||
top_p=self.top_p,
|
||||
seed=self.seed,
|
||||
)
|
||||
usage = self._get_usage(chat_completion)
|
||||
return ChatInvokeCompletion(
|
||||
completion=chat_completion.choices[0].message.content or '',
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
async def _invoke_structured_output(self, groq_messages, output_format: type[T]) -> ChatInvokeCompletion[T]:
|
||||
"""Handle structured output using either tool calling or JSON schema."""
|
||||
schema = SchemaOptimizer.create_optimized_json_schema(output_format)
|
||||
|
||||
if self.model in ToolCallingModels:
|
||||
response = await self._invoke_with_tool_calling(groq_messages, output_format, schema)
|
||||
else:
|
||||
response = await self._invoke_with_json_schema(groq_messages, output_format, schema)
|
||||
|
||||
if not response.choices[0].message.content:
|
||||
raise ModelProviderError(
|
||||
message='No content in response',
|
||||
status_code=500,
|
||||
model=self.name,
|
||||
)
|
||||
|
||||
parsed_response = output_format.model_validate_json(response.choices[0].message.content)
|
||||
usage = self._get_usage(response)
|
||||
|
||||
return ChatInvokeCompletion(
|
||||
completion=parsed_response,
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
async def _invoke_with_tool_calling(self, groq_messages, output_format: type[T], schema) -> ChatCompletion:
|
||||
"""Handle structured output using tool calling."""
|
||||
tool = ChatCompletionToolParam(
|
||||
function={
|
||||
'name': output_format.__name__,
|
||||
'description': f'Extract information in the format of {output_format.__name__}',
|
||||
'parameters': schema,
|
||||
},
|
||||
type='function',
|
||||
)
|
||||
tool_choice: ChatCompletionToolChoiceOptionParam = 'required'
|
||||
|
||||
return await self.get_client().chat.completions.create(
|
||||
model=self.model,
|
||||
messages=groq_messages,
|
||||
temperature=self.temperature,
|
||||
top_p=self.top_p,
|
||||
seed=self.seed,
|
||||
tools=[tool],
|
||||
tool_choice=tool_choice,
|
||||
service_tier=self.service_tier,
|
||||
)
|
||||
|
||||
async def _invoke_with_json_schema(self, groq_messages, output_format: type[T], schema) -> ChatCompletion:
|
||||
"""Handle structured output using JSON schema."""
|
||||
return await self.get_client().chat.completions.create(
|
||||
model=self.model,
|
||||
messages=groq_messages,
|
||||
temperature=self.temperature,
|
||||
top_p=self.top_p,
|
||||
seed=self.seed,
|
||||
response_format=ResponseFormatResponseFormatJsonSchema(
|
||||
json_schema=ResponseFormatResponseFormatJsonSchemaJsonSchema(
|
||||
name=output_format.__name__,
|
||||
description='Model output schema',
|
||||
schema=schema,
|
||||
),
|
||||
type='json_schema',
|
||||
),
|
||||
service_tier=self.service_tier,
|
||||
)
|
||||
@@ -0,0 +1,158 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import TypeVar
|
||||
|
||||
from groq import APIStatusError
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar('T', bound=BaseModel)
|
||||
|
||||
|
||||
class ParseFailedGenerationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def try_parse_groq_failed_generation(
|
||||
error: APIStatusError,
|
||||
output_format: type[T],
|
||||
) -> T:
|
||||
"""Extract JSON from model output, handling both plain JSON and code-block-wrapped JSON."""
|
||||
try:
|
||||
content = error.body['error']['failed_generation'] # type: ignore
|
||||
|
||||
# If content is wrapped in code blocks, extract just the JSON part
|
||||
if '```' in content:
|
||||
# Find the JSON content between code blocks
|
||||
content = content.split('```')[1]
|
||||
# Remove language identifier if present (e.g., 'json\n')
|
||||
if '\n' in content:
|
||||
content = content.split('\n', 1)[1]
|
||||
|
||||
# remove html-like tags before the first { and after the last }
|
||||
# This handles cases like <|header_start|>assistant<|header_end|> and <function=AgentOutput>
|
||||
# Only remove content before { if content doesn't already start with {
|
||||
if not content.strip().startswith('{'):
|
||||
content = re.sub(r'^.*?(?=\{)', '', content, flags=re.DOTALL)
|
||||
|
||||
# Remove common HTML-like tags and patterns at the end, but be more conservative
|
||||
# Look for patterns like </function>, <|header_start|>, etc. after the JSON
|
||||
content = re.sub(r'\}(\s*<[^>]*>.*?$)', '}', content, flags=re.DOTALL)
|
||||
content = re.sub(r'\}(\s*<\|[^|]*\|>.*?$)', '}', content, flags=re.DOTALL)
|
||||
|
||||
# Handle extra characters after the JSON, including stray braces
|
||||
# Find the position of the last } that would close the main JSON object
|
||||
content = content.strip()
|
||||
|
||||
if content.endswith('}'):
|
||||
# Try to parse and see if we get valid JSON
|
||||
try:
|
||||
json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
# If parsing fails, try to find the correct end of the JSON
|
||||
# by counting braces and removing anything after the balanced JSON
|
||||
brace_count = 0
|
||||
last_valid_pos = -1
|
||||
for i, char in enumerate(content):
|
||||
if char == '{':
|
||||
brace_count += 1
|
||||
elif char == '}':
|
||||
brace_count -= 1
|
||||
if brace_count == 0:
|
||||
last_valid_pos = i + 1
|
||||
break
|
||||
|
||||
if last_valid_pos > 0:
|
||||
content = content[:last_valid_pos]
|
||||
|
||||
# Fix control characters in JSON strings before parsing
|
||||
# This handles cases where literal control characters appear in JSON values
|
||||
content = _fix_control_characters_in_json(content)
|
||||
|
||||
# Parse the cleaned content
|
||||
result_dict = json.loads(content)
|
||||
|
||||
# some models occasionally respond with a list containing one dict: https://github.com/browser-use/browser-use/issues/1458
|
||||
if isinstance(result_dict, list) and len(result_dict) == 1 and isinstance(result_dict[0], dict):
|
||||
result_dict = result_dict[0]
|
||||
|
||||
logger.debug(f'Successfully parsed model output: {result_dict}')
|
||||
return output_format.model_validate(result_dict)
|
||||
|
||||
except KeyError as e:
|
||||
raise ParseFailedGenerationError(e) from e
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f'Failed to parse model output: {content} {str(e)}')
|
||||
raise ValueError(f'Could not parse response. {str(e)}')
|
||||
|
||||
except Exception as e:
|
||||
raise ParseFailedGenerationError(error.response.text) from e
|
||||
|
||||
|
||||
def _fix_control_characters_in_json(content: str) -> str:
|
||||
"""Fix control characters in JSON string values to make them valid JSON."""
|
||||
try:
|
||||
# First try to parse as-is to see if it's already valid
|
||||
json.loads(content)
|
||||
return content
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# More sophisticated approach: only escape control characters inside string values
|
||||
# while preserving JSON structure formatting
|
||||
|
||||
result = []
|
||||
i = 0
|
||||
in_string = False
|
||||
escaped = False
|
||||
|
||||
while i < len(content):
|
||||
char = content[i]
|
||||
|
||||
if not in_string:
|
||||
# Outside of string - check if we're entering a string
|
||||
if char == '"':
|
||||
in_string = True
|
||||
result.append(char)
|
||||
else:
|
||||
# Inside string - handle escaping and control characters
|
||||
if escaped:
|
||||
# Previous character was backslash, so this character is escaped
|
||||
result.append(char)
|
||||
escaped = False
|
||||
elif char == '\\':
|
||||
# This is an escape character
|
||||
result.append(char)
|
||||
escaped = True
|
||||
elif char == '"':
|
||||
# End of string
|
||||
result.append(char)
|
||||
in_string = False
|
||||
elif char == '\n':
|
||||
# Literal newline inside string - escape it
|
||||
result.append('\\n')
|
||||
elif char == '\r':
|
||||
# Literal carriage return inside string - escape it
|
||||
result.append('\\r')
|
||||
elif char == '\t':
|
||||
# Literal tab inside string - escape it
|
||||
result.append('\\t')
|
||||
elif char == '\b':
|
||||
# Literal backspace inside string - escape it
|
||||
result.append('\\b')
|
||||
elif char == '\f':
|
||||
# Literal form feed inside string - escape it
|
||||
result.append('\\f')
|
||||
elif ord(char) < 32:
|
||||
# Other control characters inside string - convert to unicode escape
|
||||
result.append(f'\\u{ord(char):04x}')
|
||||
else:
|
||||
# Normal character inside string
|
||||
result.append(char)
|
||||
|
||||
i += 1
|
||||
|
||||
return ''.join(result)
|
||||
@@ -0,0 +1,159 @@
|
||||
from typing import overload
|
||||
|
||||
from groq.types.chat import (
|
||||
ChatCompletionAssistantMessageParam,
|
||||
ChatCompletionContentPartImageParam,
|
||||
ChatCompletionContentPartTextParam,
|
||||
ChatCompletionMessageParam,
|
||||
ChatCompletionMessageToolCallParam,
|
||||
ChatCompletionSystemMessageParam,
|
||||
ChatCompletionUserMessageParam,
|
||||
)
|
||||
from groq.types.chat.chat_completion_content_part_image_param import ImageURL
|
||||
from groq.types.chat.chat_completion_message_tool_call_param import Function
|
||||
|
||||
from browser_use.llm.messages import (
|
||||
AssistantMessage,
|
||||
BaseMessage,
|
||||
ContentPartImageParam,
|
||||
ContentPartRefusalParam,
|
||||
ContentPartTextParam,
|
||||
SystemMessage,
|
||||
ToolCall,
|
||||
UserMessage,
|
||||
)
|
||||
|
||||
|
||||
class GroqMessageSerializer:
|
||||
"""Serializer for converting between custom message types and OpenAI message param types."""
|
||||
|
||||
@staticmethod
|
||||
def _serialize_content_part_text(part: ContentPartTextParam) -> ChatCompletionContentPartTextParam:
|
||||
return ChatCompletionContentPartTextParam(text=part.text, type='text')
|
||||
|
||||
@staticmethod
|
||||
def _serialize_content_part_image(part: ContentPartImageParam) -> ChatCompletionContentPartImageParam:
|
||||
return ChatCompletionContentPartImageParam(
|
||||
image_url=ImageURL(url=part.image_url.url, detail=part.image_url.detail),
|
||||
type='image_url',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _serialize_user_content(
|
||||
content: str | list[ContentPartTextParam | ContentPartImageParam],
|
||||
) -> str | list[ChatCompletionContentPartTextParam | ChatCompletionContentPartImageParam]:
|
||||
"""Serialize content for user messages (text and images allowed)."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
|
||||
serialized_parts: list[ChatCompletionContentPartTextParam | ChatCompletionContentPartImageParam] = []
|
||||
for part in content:
|
||||
if part.type == 'text':
|
||||
serialized_parts.append(GroqMessageSerializer._serialize_content_part_text(part))
|
||||
elif part.type == 'image_url':
|
||||
serialized_parts.append(GroqMessageSerializer._serialize_content_part_image(part))
|
||||
return serialized_parts
|
||||
|
||||
@staticmethod
|
||||
def _serialize_system_content(
|
||||
content: str | list[ContentPartTextParam],
|
||||
) -> str:
|
||||
"""Serialize content for system messages (text only)."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
|
||||
serialized_parts: list[str] = []
|
||||
for part in content:
|
||||
if part.type == 'text':
|
||||
serialized_parts.append(GroqMessageSerializer._serialize_content_part_text(part)['text'])
|
||||
|
||||
return '\n'.join(serialized_parts)
|
||||
|
||||
@staticmethod
|
||||
def _serialize_assistant_content(
|
||||
content: str | list[ContentPartTextParam | ContentPartRefusalParam] | None,
|
||||
) -> str | None:
|
||||
"""Serialize content for assistant messages (text and refusal allowed)."""
|
||||
if content is None:
|
||||
return None
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
|
||||
serialized_parts: list[str] = []
|
||||
for part in content:
|
||||
if part.type == 'text':
|
||||
serialized_parts.append(GroqMessageSerializer._serialize_content_part_text(part)['text'])
|
||||
|
||||
return '\n'.join(serialized_parts)
|
||||
|
||||
@staticmethod
|
||||
def _serialize_tool_call(tool_call: ToolCall) -> ChatCompletionMessageToolCallParam:
|
||||
return ChatCompletionMessageToolCallParam(
|
||||
id=tool_call.id,
|
||||
function=Function(name=tool_call.function.name, arguments=tool_call.function.arguments),
|
||||
type='function',
|
||||
)
|
||||
|
||||
# endregion
|
||||
|
||||
# region - Serialize overloads
|
||||
@overload
|
||||
@staticmethod
|
||||
def serialize(message: UserMessage) -> ChatCompletionUserMessageParam: ...
|
||||
|
||||
@overload
|
||||
@staticmethod
|
||||
def serialize(message: SystemMessage) -> ChatCompletionSystemMessageParam: ...
|
||||
|
||||
@overload
|
||||
@staticmethod
|
||||
def serialize(message: AssistantMessage) -> ChatCompletionAssistantMessageParam: ...
|
||||
|
||||
@staticmethod
|
||||
def serialize(message: BaseMessage) -> ChatCompletionMessageParam:
|
||||
"""Serialize a custom message to an OpenAI message param."""
|
||||
|
||||
if isinstance(message, UserMessage):
|
||||
user_result: ChatCompletionUserMessageParam = {
|
||||
'role': 'user',
|
||||
'content': GroqMessageSerializer._serialize_user_content(message.content),
|
||||
}
|
||||
if message.name is not None:
|
||||
user_result['name'] = message.name
|
||||
return user_result
|
||||
|
||||
elif isinstance(message, SystemMessage):
|
||||
system_result: ChatCompletionSystemMessageParam = {
|
||||
'role': 'system',
|
||||
'content': GroqMessageSerializer._serialize_system_content(message.content),
|
||||
}
|
||||
if message.name is not None:
|
||||
system_result['name'] = message.name
|
||||
return system_result
|
||||
|
||||
elif isinstance(message, AssistantMessage):
|
||||
# Handle content serialization
|
||||
content = None
|
||||
if message.content is not None:
|
||||
content = GroqMessageSerializer._serialize_assistant_content(message.content)
|
||||
|
||||
assistant_result: ChatCompletionAssistantMessageParam = {'role': 'assistant'}
|
||||
|
||||
# Only add content if it's not None
|
||||
if content is not None:
|
||||
assistant_result['content'] = content
|
||||
|
||||
if message.name is not None:
|
||||
assistant_result['name'] = message.name
|
||||
|
||||
if message.tool_calls:
|
||||
assistant_result['tool_calls'] = [GroqMessageSerializer._serialize_tool_call(tc) for tc in message.tool_calls]
|
||||
|
||||
return assistant_result
|
||||
|
||||
else:
|
||||
raise ValueError(f'Unknown message type: {type(message)}')
|
||||
|
||||
@staticmethod
|
||||
def serialize_messages(messages: list[BaseMessage]) -> list[ChatCompletionMessageParam]:
|
||||
return [GroqMessageSerializer.serialize(m) for m in messages]
|
||||
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
This implementation is based on the OpenAI types, while removing all the parts that are not needed for Browser Use.
|
||||
"""
|
||||
|
||||
# region - Content parts
|
||||
from typing import Literal, Union
|
||||
|
||||
from openai import BaseModel
|
||||
|
||||
|
||||
def _truncate(text: str, max_length: int = 50) -> str:
|
||||
"""Truncate text to max_length characters, adding ellipsis if truncated."""
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
return text[: max_length - 3] + '...'
|
||||
|
||||
|
||||
def _format_image_url(url: str, max_length: int = 50) -> str:
|
||||
"""Format image URL for display, truncating if necessary."""
|
||||
if url.startswith('data:'):
|
||||
# Base64 image
|
||||
media_type = url.split(';')[0].split(':')[1] if ';' in url else 'image'
|
||||
return f'<base64 {media_type}>'
|
||||
else:
|
||||
# Regular URL
|
||||
return _truncate(url, max_length)
|
||||
|
||||
|
||||
class ContentPartTextParam(BaseModel):
|
||||
text: str
|
||||
type: Literal['text'] = 'text'
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'Text: {_truncate(self.text)}'
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'ContentPartTextParam(text={_truncate(self.text)})'
|
||||
|
||||
|
||||
class ContentPartRefusalParam(BaseModel):
|
||||
refusal: str
|
||||
type: Literal['refusal'] = 'refusal'
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'Refusal: {_truncate(self.refusal)}'
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'ContentPartRefusalParam(refusal={_truncate(repr(self.refusal), 50)})'
|
||||
|
||||
|
||||
SupportedImageMediaType = Literal['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||
|
||||
|
||||
class ImageURL(BaseModel):
|
||||
url: str
|
||||
"""Either a URL of the image or the base64 encoded image data."""
|
||||
detail: Literal['auto', 'low', 'high'] = 'auto'
|
||||
"""Specifies the detail level of the image.
|
||||
|
||||
Learn more in the
|
||||
[Vision guide](https://platform.openai.com/docs/guides/vision#low-or-high-fidelity-image-understanding).
|
||||
"""
|
||||
# needed for Anthropic
|
||||
media_type: SupportedImageMediaType = 'image/png'
|
||||
|
||||
def __str__(self) -> str:
|
||||
url_display = _format_image_url(self.url)
|
||||
return f'🖼️ Image[{self.media_type}, detail={self.detail}]: {url_display}'
|
||||
|
||||
def __repr__(self) -> str:
|
||||
url_repr = _format_image_url(self.url, 30)
|
||||
return f'ImageURL(url={repr(url_repr)}, detail={repr(self.detail)}, media_type={repr(self.media_type)})'
|
||||
|
||||
|
||||
class ContentPartImageParam(BaseModel):
|
||||
image_url: ImageURL
|
||||
type: Literal['image_url'] = 'image_url'
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.image_url)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'ContentPartImageParam(image_url={repr(self.image_url)})'
|
||||
|
||||
|
||||
class Function(BaseModel):
|
||||
arguments: str
|
||||
"""
|
||||
The arguments to call the function with, as generated by the model in JSON
|
||||
format. Note that the model does not always generate valid JSON, and may
|
||||
hallucinate parameters not defined by your function schema. Validate the
|
||||
arguments in your code before calling your function.
|
||||
"""
|
||||
name: str
|
||||
"""The name of the function to call."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
args_preview = _truncate(self.arguments, 80)
|
||||
return f'{self.name}({args_preview})'
|
||||
|
||||
def __repr__(self) -> str:
|
||||
args_repr = _truncate(repr(self.arguments), 50)
|
||||
return f'Function(name={repr(self.name)}, arguments={args_repr})'
|
||||
|
||||
|
||||
class ToolCall(BaseModel):
|
||||
id: str
|
||||
"""The ID of the tool call."""
|
||||
function: Function
|
||||
"""The function that the model called."""
|
||||
type: Literal['function'] = 'function'
|
||||
"""The type of the tool. Currently, only `function` is supported."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'ToolCall[{self.id}]: {self.function}'
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'ToolCall(id={repr(self.id)}, function={repr(self.function)})'
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region - Message types
|
||||
class _MessageBase(BaseModel):
|
||||
"""Base class for all message types"""
|
||||
|
||||
role: Literal['user', 'system', 'assistant']
|
||||
|
||||
cache: bool = False
|
||||
"""Whether to cache this message. This is only applicable when using Anthropic models.
|
||||
"""
|
||||
|
||||
|
||||
class UserMessage(_MessageBase):
|
||||
role: Literal['user'] = 'user'
|
||||
"""The role of the messages author, in this case `user`."""
|
||||
|
||||
content: str | list[ContentPartTextParam | ContentPartImageParam]
|
||||
"""The contents of the user message."""
|
||||
|
||||
name: str | None = None
|
||||
"""An optional name for the participant.
|
||||
|
||||
Provides the model information to differentiate between participants of the same
|
||||
role.
|
||||
"""
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""
|
||||
Automatically parse the text inside content, whether it's a string or a list of content parts.
|
||||
"""
|
||||
if isinstance(self.content, str):
|
||||
return self.content
|
||||
elif isinstance(self.content, list):
|
||||
return '\n'.join([part.text for part in self.content if part.type == 'text'])
|
||||
else:
|
||||
return ''
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'UserMessage(content={self.text})'
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'UserMessage(content={repr(self.text)})'
|
||||
|
||||
|
||||
class SystemMessage(_MessageBase):
|
||||
role: Literal['system'] = 'system'
|
||||
"""The role of the messages author, in this case `system`."""
|
||||
|
||||
content: str | list[ContentPartTextParam]
|
||||
"""The contents of the system message."""
|
||||
|
||||
name: str | None = None
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""
|
||||
Automatically parse the text inside content, whether it's a string or a list of content parts.
|
||||
"""
|
||||
if isinstance(self.content, str):
|
||||
return self.content
|
||||
elif isinstance(self.content, list):
|
||||
return '\n'.join([part.text for part in self.content if part.type == 'text'])
|
||||
else:
|
||||
return ''
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'SystemMessage(content={self.text})'
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'SystemMessage(content={repr(self.text)})'
|
||||
|
||||
|
||||
class AssistantMessage(_MessageBase):
|
||||
role: Literal['assistant'] = 'assistant'
|
||||
"""The role of the messages author, in this case `assistant`."""
|
||||
|
||||
content: str | list[ContentPartTextParam | ContentPartRefusalParam] | None
|
||||
"""The contents of the assistant message."""
|
||||
|
||||
name: str | None = None
|
||||
|
||||
refusal: str | None = None
|
||||
"""The refusal message by the assistant."""
|
||||
|
||||
tool_calls: list[ToolCall] = []
|
||||
"""The tool calls generated by the model, such as function calls."""
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""
|
||||
Automatically parse the text inside content, whether it's a string or a list of content parts.
|
||||
"""
|
||||
if isinstance(self.content, str):
|
||||
return self.content
|
||||
elif isinstance(self.content, list):
|
||||
text = ''
|
||||
for part in self.content:
|
||||
if part.type == 'text':
|
||||
text += part.text
|
||||
elif part.type == 'refusal':
|
||||
text += f'[Refusal] {part.refusal}'
|
||||
return text
|
||||
else:
|
||||
return ''
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'AssistantMessage(content={self.text})'
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'AssistantMessage(content={repr(self.text)})'
|
||||
|
||||
|
||||
BaseMessage = Union[UserMessage, SystemMessage, AssistantMessage]
|
||||
|
||||
# endregion
|
||||
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
Convenient access to LLM models.
|
||||
|
||||
Usage:
|
||||
from browser_use import llm
|
||||
|
||||
# Simple model access
|
||||
model = llm.azure_gpt_4_1_mini
|
||||
model = llm.openai_gpt_4o
|
||||
model = llm.google_gemini_2_5_pro
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from browser_use.llm.azure.chat import ChatAzureOpenAI
|
||||
from browser_use.llm.google.chat import ChatGoogle
|
||||
from browser_use.llm.openai.chat import ChatOpenAI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from browser_use.llm.base import BaseChatModel
|
||||
|
||||
# Type stubs for IDE autocomplete
|
||||
openai_gpt_4o: 'BaseChatModel'
|
||||
openai_gpt_4o_mini: 'BaseChatModel'
|
||||
openai_gpt_4_1_mini: 'BaseChatModel'
|
||||
openai_o1: 'BaseChatModel'
|
||||
openai_o1_mini: 'BaseChatModel'
|
||||
openai_o1_pro: 'BaseChatModel'
|
||||
openai_o3: 'BaseChatModel'
|
||||
openai_o3_mini: 'BaseChatModel'
|
||||
openai_o3_pro: 'BaseChatModel'
|
||||
openai_o4_mini: 'BaseChatModel'
|
||||
openai_gpt_5: 'BaseChatModel'
|
||||
openai_gpt_5_mini: 'BaseChatModel'
|
||||
openai_gpt_5_nano: 'BaseChatModel'
|
||||
|
||||
azure_gpt_4o: 'BaseChatModel'
|
||||
azure_gpt_4o_mini: 'BaseChatModel'
|
||||
azure_gpt_4_1_mini: 'BaseChatModel'
|
||||
azure_o1: 'BaseChatModel'
|
||||
azure_o1_mini: 'BaseChatModel'
|
||||
azure_o1_pro: 'BaseChatModel'
|
||||
azure_o3: 'BaseChatModel'
|
||||
azure_o3_mini: 'BaseChatModel'
|
||||
azure_o3_pro: 'BaseChatModel'
|
||||
azure_gpt_5: 'BaseChatModel'
|
||||
azure_gpt_5_mini: 'BaseChatModel'
|
||||
|
||||
google_gemini_2_0_flash: 'BaseChatModel'
|
||||
google_gemini_2_0_pro: 'BaseChatModel'
|
||||
google_gemini_2_5_pro: 'BaseChatModel'
|
||||
google_gemini_2_5_flash: 'BaseChatModel'
|
||||
google_gemini_2_5_flash_lite: 'BaseChatModel'
|
||||
|
||||
|
||||
def get_llm_by_name(model_name: str):
|
||||
"""
|
||||
Factory function to create LLM instances from string names with API keys from environment.
|
||||
|
||||
Args:
|
||||
model_name: String name like 'azure_gpt_4_1_mini', 'openai_gpt_4o', etc.
|
||||
|
||||
Returns:
|
||||
LLM instance with API keys from environment variables
|
||||
|
||||
Raises:
|
||||
ValueError: If model_name is not recognized
|
||||
"""
|
||||
if not model_name:
|
||||
raise ValueError('Model name cannot be empty')
|
||||
|
||||
# Parse model name
|
||||
parts = model_name.split('_', 1)
|
||||
if len(parts) < 2:
|
||||
raise ValueError(f"Invalid model name format: '{model_name}'. Expected format: 'provider_model_name'")
|
||||
|
||||
provider = parts[0]
|
||||
model_part = parts[1]
|
||||
|
||||
# Convert underscores back to dots/dashes for actual model names
|
||||
if 'gpt_4_1_mini' in model_part:
|
||||
model = model_part.replace('gpt_4_1_mini', 'gpt-4.1-mini')
|
||||
elif 'gpt_4o_mini' in model_part:
|
||||
model = model_part.replace('gpt_4o_mini', 'gpt-4o-mini')
|
||||
elif 'gpt_4o' in model_part:
|
||||
model = model_part.replace('gpt_4o', 'gpt-4o')
|
||||
elif 'gemini_2_0' in model_part:
|
||||
model = model_part.replace('gemini_2_0', 'gemini-2.0').replace('_', '-')
|
||||
elif 'gemini_2_5' in model_part:
|
||||
model = model_part.replace('gemini_2_5', 'gemini-2.5').replace('_', '-')
|
||||
else:
|
||||
model = model_part.replace('_', '-')
|
||||
|
||||
# OpenAI Models
|
||||
if provider == 'openai':
|
||||
api_key = os.getenv('OPENAI_API_KEY')
|
||||
return ChatOpenAI(model=model, api_key=api_key)
|
||||
|
||||
# Azure OpenAI Models
|
||||
elif provider == 'azure':
|
||||
api_key = os.getenv('AZURE_OPENAI_KEY') or os.getenv('AZURE_OPENAI_API_KEY')
|
||||
azure_endpoint = os.getenv('AZURE_OPENAI_ENDPOINT')
|
||||
return ChatAzureOpenAI(model=model, api_key=api_key, azure_endpoint=azure_endpoint)
|
||||
|
||||
# Google Models
|
||||
elif provider == 'google':
|
||||
api_key = os.getenv('GOOGLE_API_KEY')
|
||||
return ChatGoogle(model=model, api_key=api_key)
|
||||
|
||||
else:
|
||||
available_providers = ['openai', 'azure', 'google']
|
||||
raise ValueError(f"Unknown provider: '{provider}'. Available providers: {', '.join(available_providers)}")
|
||||
|
||||
|
||||
# Pre-configured model instances (lazy loaded via __getattr__)
|
||||
def __getattr__(name: str) -> 'BaseChatModel':
|
||||
"""Create model instances on demand with API keys from environment."""
|
||||
# Handle chat classes first
|
||||
if name == 'ChatOpenAI':
|
||||
return ChatOpenAI # type: ignore
|
||||
elif name == 'ChatAzureOpenAI':
|
||||
return ChatAzureOpenAI # type: ignore
|
||||
elif name == 'ChatGoogle':
|
||||
return ChatGoogle # type: ignore
|
||||
|
||||
# Handle model instances - these are the main use case
|
||||
try:
|
||||
return get_llm_by_name(name)
|
||||
except ValueError:
|
||||
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
||||
|
||||
|
||||
__all__ = [
|
||||
'ChatOpenAI',
|
||||
'ChatAzureOpenAI',
|
||||
'ChatGoogle',
|
||||
'get_llm_by_name',
|
||||
# OpenAI instances - created on demand
|
||||
'openai_gpt_4o',
|
||||
'openai_gpt_4o_mini',
|
||||
'openai_gpt_4_1_mini',
|
||||
'openai_o1',
|
||||
'openai_o1_mini',
|
||||
'openai_o1_pro',
|
||||
'openai_o3',
|
||||
'openai_o3_mini',
|
||||
'openai_o3_pro',
|
||||
'openai_o4_mini',
|
||||
'openai_gpt_5',
|
||||
'openai_gpt_5_mini',
|
||||
'openai_gpt_5_nano',
|
||||
# Azure instances - created on demand
|
||||
'azure_gpt_4o',
|
||||
'azure_gpt_4o_mini',
|
||||
'azure_gpt_4_1_mini',
|
||||
'azure_o1',
|
||||
'azure_o1_mini',
|
||||
'azure_o1_pro',
|
||||
'azure_o3',
|
||||
'azure_o3_mini',
|
||||
'azure_o3_pro',
|
||||
'azure_gpt_5',
|
||||
'azure_gpt_5_mini',
|
||||
# Google instances - created on demand
|
||||
'google_gemini_2_0_flash',
|
||||
'google_gemini_2_0_pro',
|
||||
'google_gemini_2_5_pro',
|
||||
'google_gemini_2_5_flash',
|
||||
'google_gemini_2_5_flash_lite',
|
||||
]
|
||||
@@ -0,0 +1,97 @@
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, TypeVar, overload
|
||||
|
||||
import httpx
|
||||
from ollama import AsyncClient as OllamaAsyncClient
|
||||
from ollama import Options
|
||||
from pydantic import BaseModel
|
||||
|
||||
from browser_use.llm.base import BaseChatModel
|
||||
from browser_use.llm.exceptions import ModelProviderError
|
||||
from browser_use.llm.messages import BaseMessage
|
||||
from browser_use.llm.ollama.serializer import OllamaMessageSerializer
|
||||
from browser_use.llm.views import ChatInvokeCompletion
|
||||
|
||||
T = TypeVar('T', bound=BaseModel)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatOllama(BaseChatModel):
|
||||
"""
|
||||
A wrapper around Ollama's chat model.
|
||||
"""
|
||||
|
||||
model: str
|
||||
|
||||
# # Model params
|
||||
# TODO (matic): Why is this commented out?
|
||||
# temperature: float | None = None
|
||||
|
||||
# Client initialization parameters
|
||||
host: str | None = None
|
||||
timeout: float | httpx.Timeout | None = None
|
||||
client_params: dict[str, Any] | None = None
|
||||
ollama_options: Mapping[str, Any] | Options | None = None
|
||||
|
||||
# Static
|
||||
@property
|
||||
def provider(self) -> str:
|
||||
return 'ollama'
|
||||
|
||||
def _get_client_params(self) -> dict[str, Any]:
|
||||
"""Prepare client parameters dictionary."""
|
||||
return {
|
||||
'host': self.host,
|
||||
'timeout': self.timeout,
|
||||
'client_params': self.client_params,
|
||||
}
|
||||
|
||||
def get_client(self) -> OllamaAsyncClient:
|
||||
"""
|
||||
Returns an OllamaAsyncClient client.
|
||||
"""
|
||||
return OllamaAsyncClient(host=self.host, timeout=self.timeout, **self.client_params or {})
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.model
|
||||
|
||||
@overload
|
||||
async def ainvoke(self, messages: list[BaseMessage], output_format: None = None) -> ChatInvokeCompletion[str]: ...
|
||||
|
||||
@overload
|
||||
async def ainvoke(self, messages: list[BaseMessage], output_format: type[T]) -> ChatInvokeCompletion[T]: ...
|
||||
|
||||
async def ainvoke(
|
||||
self, messages: list[BaseMessage], output_format: type[T] | None = None
|
||||
) -> ChatInvokeCompletion[T] | ChatInvokeCompletion[str]:
|
||||
ollama_messages = OllamaMessageSerializer.serialize_messages(messages)
|
||||
|
||||
try:
|
||||
if output_format is None:
|
||||
response = await self.get_client().chat(
|
||||
model=self.model,
|
||||
messages=ollama_messages,
|
||||
options=self.ollama_options,
|
||||
)
|
||||
|
||||
return ChatInvokeCompletion(completion=response.message.content or '', usage=None)
|
||||
else:
|
||||
schema = output_format.model_json_schema()
|
||||
|
||||
response = await self.get_client().chat(
|
||||
model=self.model,
|
||||
messages=ollama_messages,
|
||||
format=schema,
|
||||
options=self.ollama_options,
|
||||
)
|
||||
|
||||
completion = response.message.content or ''
|
||||
if output_format is not None:
|
||||
completion = output_format.model_validate_json(completion)
|
||||
|
||||
return ChatInvokeCompletion(completion=completion, usage=None)
|
||||
|
||||
except Exception as e:
|
||||
raise ModelProviderError(message=str(e), model=self.name) from e
|
||||
@@ -0,0 +1,143 @@
|
||||
import base64
|
||||
import json
|
||||
from typing import Any, overload
|
||||
|
||||
from ollama._types import Image, Message
|
||||
|
||||
from browser_use.llm.messages import (
|
||||
AssistantMessage,
|
||||
BaseMessage,
|
||||
SystemMessage,
|
||||
ToolCall,
|
||||
UserMessage,
|
||||
)
|
||||
|
||||
|
||||
class OllamaMessageSerializer:
|
||||
"""Serializer for converting between custom message types and Ollama message types."""
|
||||
|
||||
@staticmethod
|
||||
def _extract_text_content(content: Any) -> str:
|
||||
"""Extract text content from message content, ignoring images."""
|
||||
if content is None:
|
||||
return ''
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
|
||||
text_parts: list[str] = []
|
||||
for part in content:
|
||||
if hasattr(part, 'type'):
|
||||
if part.type == 'text':
|
||||
text_parts.append(part.text)
|
||||
elif part.type == 'refusal':
|
||||
text_parts.append(f'[Refusal] {part.refusal}')
|
||||
# Skip image parts as they're handled separately
|
||||
|
||||
return '\n'.join(text_parts)
|
||||
|
||||
@staticmethod
|
||||
def _extract_images(content: Any) -> list[Image]:
|
||||
"""Extract images from message content."""
|
||||
if content is None or isinstance(content, str):
|
||||
return []
|
||||
|
||||
images: list[Image] = []
|
||||
for part in content:
|
||||
if hasattr(part, 'type') and part.type == 'image_url':
|
||||
url = part.image_url.url
|
||||
if url.startswith('data:'):
|
||||
# Handle base64 encoded images
|
||||
# Format: data:image/png;base64,<data>
|
||||
_, data = url.split(',', 1)
|
||||
# Decode base64 to bytes
|
||||
image_bytes = base64.b64decode(data)
|
||||
images.append(Image(value=image_bytes))
|
||||
else:
|
||||
# Handle URL images (Ollama will download them)
|
||||
images.append(Image(value=url))
|
||||
|
||||
return images
|
||||
|
||||
@staticmethod
|
||||
def _serialize_tool_calls(tool_calls: list[ToolCall]) -> list[Message.ToolCall]:
|
||||
"""Convert browser-use ToolCalls to Ollama ToolCalls."""
|
||||
ollama_tool_calls: list[Message.ToolCall] = []
|
||||
|
||||
for tool_call in tool_calls:
|
||||
# Parse arguments from JSON string to dict for Ollama
|
||||
try:
|
||||
arguments_dict = json.loads(tool_call.function.arguments)
|
||||
except json.JSONDecodeError:
|
||||
# If parsing fails, wrap in a dict
|
||||
arguments_dict = {'arguments': tool_call.function.arguments}
|
||||
|
||||
ollama_tool_call = Message.ToolCall(
|
||||
function=Message.ToolCall.Function(name=tool_call.function.name, arguments=arguments_dict)
|
||||
)
|
||||
ollama_tool_calls.append(ollama_tool_call)
|
||||
|
||||
return ollama_tool_calls
|
||||
|
||||
# region - Serialize overloads
|
||||
@overload
|
||||
@staticmethod
|
||||
def serialize(message: UserMessage) -> Message: ...
|
||||
|
||||
@overload
|
||||
@staticmethod
|
||||
def serialize(message: SystemMessage) -> Message: ...
|
||||
|
||||
@overload
|
||||
@staticmethod
|
||||
def serialize(message: AssistantMessage) -> Message: ...
|
||||
|
||||
@staticmethod
|
||||
def serialize(message: BaseMessage) -> Message:
|
||||
"""Serialize a custom message to an Ollama Message."""
|
||||
|
||||
if isinstance(message, UserMessage):
|
||||
text_content = OllamaMessageSerializer._extract_text_content(message.content)
|
||||
images = OllamaMessageSerializer._extract_images(message.content)
|
||||
|
||||
ollama_message = Message(
|
||||
role='user',
|
||||
content=text_content if text_content else None,
|
||||
)
|
||||
|
||||
if images:
|
||||
ollama_message.images = images
|
||||
|
||||
return ollama_message
|
||||
|
||||
elif isinstance(message, SystemMessage):
|
||||
text_content = OllamaMessageSerializer._extract_text_content(message.content)
|
||||
|
||||
return Message(
|
||||
role='system',
|
||||
content=text_content if text_content else None,
|
||||
)
|
||||
|
||||
elif isinstance(message, AssistantMessage):
|
||||
# Handle content
|
||||
text_content = None
|
||||
if message.content is not None:
|
||||
text_content = OllamaMessageSerializer._extract_text_content(message.content)
|
||||
|
||||
ollama_message = Message(
|
||||
role='assistant',
|
||||
content=text_content if text_content else None,
|
||||
)
|
||||
|
||||
# Handle tool calls
|
||||
if message.tool_calls:
|
||||
ollama_message.tool_calls = OllamaMessageSerializer._serialize_tool_calls(message.tool_calls)
|
||||
|
||||
return ollama_message
|
||||
|
||||
else:
|
||||
raise ValueError(f'Unknown message type: {type(message)}')
|
||||
|
||||
@staticmethod
|
||||
def serialize_messages(messages: list[BaseMessage]) -> list[Message]:
|
||||
"""Serialize a list of browser_use messages to Ollama Messages."""
|
||||
return [OllamaMessageSerializer.serialize(m) for m in messages]
|
||||
@@ -0,0 +1,273 @@
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal, TypeVar, overload
|
||||
|
||||
import httpx
|
||||
from openai import APIConnectionError, APIStatusError, AsyncOpenAI, RateLimitError
|
||||
from openai.types.chat import ChatCompletionContentPartTextParam
|
||||
from openai.types.chat.chat_completion import ChatCompletion
|
||||
from openai.types.shared.chat_model import ChatModel
|
||||
from openai.types.shared_params.reasoning_effort import ReasoningEffort
|
||||
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
|
||||
from browser_use.llm.messages import BaseMessage
|
||||
from browser_use.llm.openai.serializer import OpenAIMessageSerializer
|
||||
from browser_use.llm.schema import SchemaOptimizer
|
||||
from browser_use.llm.views import ChatInvokeCompletion, ChatInvokeUsage
|
||||
|
||||
T = TypeVar('T', bound=BaseModel)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatOpenAI(BaseChatModel):
|
||||
"""
|
||||
A wrapper around AsyncOpenAI that implements the BaseLLM protocol.
|
||||
|
||||
This class accepts all AsyncOpenAI parameters while adding model
|
||||
and temperature parameters for the LLM interface (if temperature it not `None`).
|
||||
"""
|
||||
|
||||
# Model configuration
|
||||
model: ChatModel | str
|
||||
|
||||
# Model params
|
||||
temperature: float | None = 0.2
|
||||
frequency_penalty: float | None = 0.3 # this avoids infinite generation of \t for models like 4.1-mini
|
||||
reasoning_effort: ReasoningEffort = 'low'
|
||||
seed: int | None = None
|
||||
service_tier: Literal['auto', 'default', 'flex', 'priority', 'scale'] | None = None
|
||||
top_p: float | None = None
|
||||
add_schema_to_system_prompt: bool = False # Add JSON schema to system prompt instead of using response_format
|
||||
|
||||
# Client initialization parameters
|
||||
api_key: str | None = None
|
||||
organization: str | None = None
|
||||
project: str | None = None
|
||||
base_url: str | httpx.URL | None = None
|
||||
websocket_base_url: str | httpx.URL | None = None
|
||||
timeout: float | httpx.Timeout | None = None
|
||||
max_retries: int = 5 # Increase default retries for automation reliability
|
||||
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
|
||||
max_completion_tokens: int | None = 4096
|
||||
reasoning_models: list[ChatModel | str] | None = field(
|
||||
default_factory=lambda: [
|
||||
'o4-mini',
|
||||
'o3',
|
||||
'o3-mini',
|
||||
'o1',
|
||||
'o1-pro',
|
||||
'o3-pro',
|
||||
'gpt-5',
|
||||
'gpt-5-mini',
|
||||
'gpt-5-nano',
|
||||
]
|
||||
)
|
||||
|
||||
# Static
|
||||
@property
|
||||
def provider(self) -> str:
|
||||
return 'openai'
|
||||
|
||||
def _get_client_params(self) -> dict[str, Any]:
|
||||
"""Prepare client parameters dictionary."""
|
||||
# Define base client params
|
||||
base_params = {
|
||||
'api_key': self.api_key,
|
||||
'organization': self.organization,
|
||||
'project': self.project,
|
||||
'base_url': self.base_url,
|
||||
'websocket_base_url': self.websocket_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.
|
||||
|
||||
Returns:
|
||||
AsyncOpenAI: An instance of the AsyncOpenAI client.
|
||||
"""
|
||||
client_params = self._get_client_params()
|
||||
return AsyncOpenAI(**client_params)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return str(self.model)
|
||||
|
||||
def _get_usage(self, response: ChatCompletion) -> ChatInvokeUsage | None:
|
||||
if response.usage is not None:
|
||||
completion_tokens = response.usage.completion_tokens
|
||||
completion_token_details = response.usage.completion_tokens_details
|
||||
if completion_token_details is not None:
|
||||
reasoning_tokens = completion_token_details.reasoning_tokens
|
||||
if reasoning_tokens is not None:
|
||||
completion_tokens += reasoning_tokens
|
||||
|
||||
usage = ChatInvokeUsage(
|
||||
prompt_tokens=response.usage.prompt_tokens,
|
||||
prompt_cached_tokens=response.usage.prompt_tokens_details.cached_tokens
|
||||
if response.usage.prompt_tokens_details is not None
|
||||
else None,
|
||||
prompt_cache_creation_tokens=None,
|
||||
prompt_image_tokens=None,
|
||||
# Completion
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=response.usage.total_tokens,
|
||||
)
|
||||
else:
|
||||
usage = None
|
||||
|
||||
return usage
|
||||
|
||||
@overload
|
||||
async def ainvoke(self, messages: list[BaseMessage], output_format: None = None) -> ChatInvokeCompletion[str]: ...
|
||||
|
||||
@overload
|
||||
async def ainvoke(self, messages: list[BaseMessage], output_format: type[T]) -> ChatInvokeCompletion[T]: ...
|
||||
|
||||
async def ainvoke(
|
||||
self, messages: list[BaseMessage], output_format: type[T] | None = None
|
||||
) -> ChatInvokeCompletion[T] | ChatInvokeCompletion[str]:
|
||||
"""
|
||||
Invoke the model with the given messages.
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
openai_messages = OpenAIMessageSerializer.serialize_messages(messages)
|
||||
|
||||
try:
|
||||
model_params: dict[str, Any] = {}
|
||||
|
||||
if self.temperature is not None:
|
||||
model_params['temperature'] = self.temperature
|
||||
|
||||
if self.frequency_penalty is not None:
|
||||
model_params['frequency_penalty'] = self.frequency_penalty
|
||||
|
||||
if self.max_completion_tokens is not None:
|
||||
model_params['max_completion_tokens'] = self.max_completion_tokens
|
||||
|
||||
if self.top_p is not None:
|
||||
model_params['top_p'] = self.top_p
|
||||
|
||||
if self.seed is not None:
|
||||
model_params['seed'] = self.seed
|
||||
|
||||
if self.service_tier is not None:
|
||||
model_params['service_tier'] = self.service_tier
|
||||
|
||||
if self.reasoning_models and any(str(m).lower() in str(self.model).lower() for m in self.reasoning_models):
|
||||
model_params['reasoning_effort'] = self.reasoning_effort
|
||||
del model_params['temperature']
|
||||
del model_params['frequency_penalty']
|
||||
|
||||
if output_format is None:
|
||||
# Return string response
|
||||
response = await self.get_client().chat.completions.create(
|
||||
model=self.model,
|
||||
messages=openai_messages,
|
||||
**model_params,
|
||||
)
|
||||
|
||||
usage = self._get_usage(response)
|
||||
return ChatInvokeCompletion(
|
||||
completion=response.choices[0].message.content or '',
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
else:
|
||||
response_format: JSONSchema = {
|
||||
'name': 'agent_output',
|
||||
'strict': True,
|
||||
'schema': SchemaOptimizer.create_optimized_json_schema(output_format),
|
||||
}
|
||||
|
||||
# Add JSON schema to system prompt if requested
|
||||
if self.add_schema_to_system_prompt and openai_messages and openai_messages[0]['role'] == 'system':
|
||||
schema_text = f'\n<json_schema>\n{response_format}\n</json_schema>'
|
||||
if isinstance(openai_messages[0]['content'], str):
|
||||
openai_messages[0]['content'] += schema_text
|
||||
elif isinstance(openai_messages[0]['content'], Iterable):
|
||||
openai_messages[0]['content'] = list(openai_messages[0]['content']) + [
|
||||
ChatCompletionContentPartTextParam(text=schema_text, type='text')
|
||||
]
|
||||
|
||||
# Return structured response
|
||||
response = await self.get_client().chat.completions.create(
|
||||
model=self.model,
|
||||
messages=openai_messages,
|
||||
response_format=ResponseFormatJSONSchema(json_schema=response_format, type='json_schema'),
|
||||
**model_params,
|
||||
)
|
||||
|
||||
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:
|
||||
error_message = e.response.json().get('error', {})
|
||||
error_message = (
|
||||
error_message.get('message', 'Unknown model error') if isinstance(error_message, dict) else error_message
|
||||
)
|
||||
raise ModelProviderError(
|
||||
message=error_message,
|
||||
status_code=e.response.status_code,
|
||||
model=self.name,
|
||||
) from e
|
||||
|
||||
except APIConnectionError as e:
|
||||
raise ModelProviderError(message=str(e), model=self.name) from e
|
||||
|
||||
except APIStatusError as e:
|
||||
try:
|
||||
error_message = e.response.json().get('error', {})
|
||||
except Exception:
|
||||
error_message = e.response.text
|
||||
error_message = (
|
||||
error_message.get('message', 'Unknown model error') if isinstance(error_message, dict) else error_message
|
||||
)
|
||||
raise ModelProviderError(
|
||||
message=error_message,
|
||||
status_code=e.response.status_code,
|
||||
model=self.name,
|
||||
) from e
|
||||
|
||||
except Exception as e:
|
||||
raise ModelProviderError(message=str(e), model=self.name) from e
|
||||
@@ -0,0 +1,15 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from browser_use.llm.openai.chat import ChatOpenAI
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatOpenAILike(ChatOpenAI):
|
||||
"""
|
||||
A class for to interact with any provider using the OpenAI API schema.
|
||||
|
||||
Args:
|
||||
model (str): The name of the OpenAI model to use.
|
||||
"""
|
||||
|
||||
model: str
|
||||
@@ -0,0 +1,165 @@
|
||||
from typing import overload
|
||||
|
||||
from openai.types.chat import (
|
||||
ChatCompletionAssistantMessageParam,
|
||||
ChatCompletionContentPartImageParam,
|
||||
ChatCompletionContentPartRefusalParam,
|
||||
ChatCompletionContentPartTextParam,
|
||||
ChatCompletionMessageFunctionToolCallParam,
|
||||
ChatCompletionMessageParam,
|
||||
ChatCompletionSystemMessageParam,
|
||||
ChatCompletionUserMessageParam,
|
||||
)
|
||||
from openai.types.chat.chat_completion_content_part_image_param import ImageURL
|
||||
from openai.types.chat.chat_completion_message_function_tool_call_param import Function
|
||||
|
||||
from browser_use.llm.messages import (
|
||||
AssistantMessage,
|
||||
BaseMessage,
|
||||
ContentPartImageParam,
|
||||
ContentPartRefusalParam,
|
||||
ContentPartTextParam,
|
||||
SystemMessage,
|
||||
ToolCall,
|
||||
UserMessage,
|
||||
)
|
||||
|
||||
|
||||
class OpenAIMessageSerializer:
|
||||
"""Serializer for converting between custom message types and OpenAI message param types."""
|
||||
|
||||
@staticmethod
|
||||
def _serialize_content_part_text(part: ContentPartTextParam) -> ChatCompletionContentPartTextParam:
|
||||
return ChatCompletionContentPartTextParam(text=part.text, type='text')
|
||||
|
||||
@staticmethod
|
||||
def _serialize_content_part_image(part: ContentPartImageParam) -> ChatCompletionContentPartImageParam:
|
||||
return ChatCompletionContentPartImageParam(
|
||||
image_url=ImageURL(url=part.image_url.url, detail=part.image_url.detail),
|
||||
type='image_url',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _serialize_content_part_refusal(part: ContentPartRefusalParam) -> ChatCompletionContentPartRefusalParam:
|
||||
return ChatCompletionContentPartRefusalParam(refusal=part.refusal, type='refusal')
|
||||
|
||||
@staticmethod
|
||||
def _serialize_user_content(
|
||||
content: str | list[ContentPartTextParam | ContentPartImageParam],
|
||||
) -> str | list[ChatCompletionContentPartTextParam | ChatCompletionContentPartImageParam]:
|
||||
"""Serialize content for user messages (text and images allowed)."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
|
||||
serialized_parts: list[ChatCompletionContentPartTextParam | ChatCompletionContentPartImageParam] = []
|
||||
for part in content:
|
||||
if part.type == 'text':
|
||||
serialized_parts.append(OpenAIMessageSerializer._serialize_content_part_text(part))
|
||||
elif part.type == 'image_url':
|
||||
serialized_parts.append(OpenAIMessageSerializer._serialize_content_part_image(part))
|
||||
return serialized_parts
|
||||
|
||||
@staticmethod
|
||||
def _serialize_system_content(
|
||||
content: str | list[ContentPartTextParam],
|
||||
) -> str | list[ChatCompletionContentPartTextParam]:
|
||||
"""Serialize content for system messages (text only)."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
|
||||
serialized_parts: list[ChatCompletionContentPartTextParam] = []
|
||||
for part in content:
|
||||
if part.type == 'text':
|
||||
serialized_parts.append(OpenAIMessageSerializer._serialize_content_part_text(part))
|
||||
return serialized_parts
|
||||
|
||||
@staticmethod
|
||||
def _serialize_assistant_content(
|
||||
content: str | list[ContentPartTextParam | ContentPartRefusalParam] | None,
|
||||
) -> str | list[ChatCompletionContentPartTextParam | ChatCompletionContentPartRefusalParam] | None:
|
||||
"""Serialize content for assistant messages (text and refusal allowed)."""
|
||||
if content is None:
|
||||
return None
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
|
||||
serialized_parts: list[ChatCompletionContentPartTextParam | ChatCompletionContentPartRefusalParam] = []
|
||||
for part in content:
|
||||
if part.type == 'text':
|
||||
serialized_parts.append(OpenAIMessageSerializer._serialize_content_part_text(part))
|
||||
elif part.type == 'refusal':
|
||||
serialized_parts.append(OpenAIMessageSerializer._serialize_content_part_refusal(part))
|
||||
return serialized_parts
|
||||
|
||||
@staticmethod
|
||||
def _serialize_tool_call(tool_call: ToolCall) -> ChatCompletionMessageFunctionToolCallParam:
|
||||
return ChatCompletionMessageFunctionToolCallParam(
|
||||
id=tool_call.id,
|
||||
function=Function(name=tool_call.function.name, arguments=tool_call.function.arguments),
|
||||
type='function',
|
||||
)
|
||||
|
||||
# endregion
|
||||
|
||||
# region - Serialize overloads
|
||||
@overload
|
||||
@staticmethod
|
||||
def serialize(message: UserMessage) -> ChatCompletionUserMessageParam: ...
|
||||
|
||||
@overload
|
||||
@staticmethod
|
||||
def serialize(message: SystemMessage) -> ChatCompletionSystemMessageParam: ...
|
||||
|
||||
@overload
|
||||
@staticmethod
|
||||
def serialize(message: AssistantMessage) -> ChatCompletionAssistantMessageParam: ...
|
||||
|
||||
@staticmethod
|
||||
def serialize(message: BaseMessage) -> ChatCompletionMessageParam:
|
||||
"""Serialize a custom message to an OpenAI message param."""
|
||||
|
||||
if isinstance(message, UserMessage):
|
||||
user_result: ChatCompletionUserMessageParam = {
|
||||
'role': 'user',
|
||||
'content': OpenAIMessageSerializer._serialize_user_content(message.content),
|
||||
}
|
||||
if message.name is not None:
|
||||
user_result['name'] = message.name
|
||||
return user_result
|
||||
|
||||
elif isinstance(message, SystemMessage):
|
||||
system_result: ChatCompletionSystemMessageParam = {
|
||||
'role': 'system',
|
||||
'content': OpenAIMessageSerializer._serialize_system_content(message.content),
|
||||
}
|
||||
if message.name is not None:
|
||||
system_result['name'] = message.name
|
||||
return system_result
|
||||
|
||||
elif isinstance(message, AssistantMessage):
|
||||
# Handle content serialization
|
||||
content = None
|
||||
if message.content is not None:
|
||||
content = OpenAIMessageSerializer._serialize_assistant_content(message.content)
|
||||
|
||||
assistant_result: ChatCompletionAssistantMessageParam = {'role': 'assistant'}
|
||||
|
||||
# Only add content if it's not None
|
||||
if content is not None:
|
||||
assistant_result['content'] = content
|
||||
|
||||
if message.name is not None:
|
||||
assistant_result['name'] = message.name
|
||||
if message.refusal is not None:
|
||||
assistant_result['refusal'] = message.refusal
|
||||
if message.tool_calls:
|
||||
assistant_result['tool_calls'] = [OpenAIMessageSerializer._serialize_tool_call(tc) for tc in message.tool_calls]
|
||||
|
||||
return assistant_result
|
||||
|
||||
else:
|
||||
raise ValueError(f'Unknown message type: {type(message)}')
|
||||
|
||||
@staticmethod
|
||||
def serialize_messages(messages: list[BaseMessage]) -> list[ChatCompletionMessageParam]:
|
||||
return [OpenAIMessageSerializer.serialize(m) for m in messages]
|
||||
@@ -0,0 +1,208 @@
|
||||
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.openrouter.serializer import OpenRouterMessageSerializer
|
||||
from browser_use.llm.schema import SchemaOptimizer
|
||||
from browser_use.llm.views import ChatInvokeCompletion, ChatInvokeUsage
|
||||
|
||||
T = TypeVar('T', bound=BaseModel)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatOpenRouter(BaseChatModel):
|
||||
"""
|
||||
A wrapper around OpenRouter's chat API, which provides access to various LLM models
|
||||
through a unified OpenAI-compatible interface.
|
||||
|
||||
This class implements the BaseChatModel protocol for OpenRouter'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
|
||||
http_referer: str | None = None # OpenRouter specific parameter for tracking
|
||||
base_url: str | httpx.URL = 'https://openrouter.ai/api/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
|
||||
|
||||
# Static
|
||||
@property
|
||||
def provider(self) -> str:
|
||||
return 'openrouter'
|
||||
|
||||
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 OpenRouter.
|
||||
|
||||
Returns:
|
||||
AsyncOpenAI: An instance of the AsyncOpenAI client with OpenRouter 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 OpenRouter 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) -> ChatInvokeCompletion[str]: ...
|
||||
|
||||
@overload
|
||||
async def ainvoke(self, messages: list[BaseMessage], output_format: type[T]) -> ChatInvokeCompletion[T]: ...
|
||||
|
||||
async def ainvoke(
|
||||
self, messages: list[BaseMessage], output_format: type[T] | None = None
|
||||
) -> ChatInvokeCompletion[T] | ChatInvokeCompletion[str]:
|
||||
"""
|
||||
Invoke the model with the given messages through OpenRouter.
|
||||
|
||||
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
|
||||
"""
|
||||
openrouter_messages = OpenRouterMessageSerializer.serialize_messages(messages)
|
||||
|
||||
# Set up extra headers for OpenRouter
|
||||
extra_headers = {}
|
||||
if self.http_referer:
|
||||
extra_headers['HTTP-Referer'] = self.http_referer
|
||||
|
||||
try:
|
||||
if output_format is None:
|
||||
# Return string response
|
||||
response = await self.get_client().chat.completions.create(
|
||||
model=self.model,
|
||||
messages=openrouter_messages,
|
||||
temperature=self.temperature,
|
||||
top_p=self.top_p,
|
||||
seed=self.seed,
|
||||
extra_headers=extra_headers,
|
||||
)
|
||||
|
||||
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=openrouter_messages,
|
||||
temperature=self.temperature,
|
||||
top_p=self.top_p,
|
||||
seed=self.seed,
|
||||
response_format=ResponseFormatJSONSchema(
|
||||
json_schema=response_format_schema,
|
||||
type='json_schema',
|
||||
),
|
||||
extra_headers=extra_headers,
|
||||
)
|
||||
|
||||
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
|
||||
@@ -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 OpenRouterMessageSerializer:
|
||||
"""
|
||||
Serializer for converting between custom message types and OpenRouter message formats.
|
||||
|
||||
OpenRouter uses the 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 OpenRouter-compatible messages.
|
||||
|
||||
Args:
|
||||
messages: List of browser_use messages
|
||||
|
||||
Returns:
|
||||
List of OpenRouter-compatible messages (identical to OpenAI format)
|
||||
"""
|
||||
# OpenRouter uses the same message format as OpenAI
|
||||
return OpenAIMessageSerializer.serialize_messages(messages)
|
||||
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
Utilities for creating optimized Pydantic schemas for LLM usage.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SchemaOptimizer:
|
||||
@staticmethod
|
||||
def create_optimized_json_schema(model: type[BaseModel]) -> dict[str, Any]:
|
||||
"""
|
||||
Create the most optimized schema by flattening all $ref/$defs while preserving
|
||||
FULL descriptions and ALL action definitions. Also ensures OpenAI strict mode compatibility.
|
||||
|
||||
Args:
|
||||
model: The Pydantic model to optimize
|
||||
|
||||
Returns:
|
||||
Optimized schema with all $refs resolved and strict mode compatibility
|
||||
"""
|
||||
# Generate original schema
|
||||
original_schema = model.model_json_schema()
|
||||
|
||||
# Extract $defs for reference resolution, then flatten everything
|
||||
defs_lookup = original_schema.get('$defs', {})
|
||||
|
||||
def optimize_schema(
|
||||
obj: Any,
|
||||
defs_lookup: dict[str, Any] | None = None,
|
||||
*,
|
||||
in_properties: bool = False, # NEW: track context
|
||||
) -> Any:
|
||||
"""Apply all optimization techniques including flattening all $ref/$defs"""
|
||||
if isinstance(obj, dict):
|
||||
optimized: dict[str, Any] = {}
|
||||
flattened_ref: dict[str, Any] | None = None
|
||||
|
||||
# Skip unnecessary fields AND $defs (we'll inline everything)
|
||||
skip_fields = ['additionalProperties', '$defs']
|
||||
|
||||
for key, value in obj.items():
|
||||
if key in skip_fields:
|
||||
continue
|
||||
|
||||
# Skip metadata "title" unless we're iterating inside an actual `properties` map
|
||||
if key == 'title' and not in_properties:
|
||||
continue
|
||||
|
||||
# Preserve FULL descriptions without truncation
|
||||
elif key == 'description':
|
||||
optimized[key] = value
|
||||
|
||||
# Handle type field
|
||||
elif key == 'type':
|
||||
optimized[key] = value
|
||||
|
||||
# FLATTEN: Resolve $ref by inlining the actual definition
|
||||
elif key == '$ref' and defs_lookup:
|
||||
ref_path = value.split('/')[-1] # Get the definition name from "#/$defs/SomeName"
|
||||
if ref_path in defs_lookup:
|
||||
# Get the referenced definition and flatten it
|
||||
referenced_def = defs_lookup[ref_path]
|
||||
flattened_ref = optimize_schema(referenced_def, defs_lookup)
|
||||
|
||||
# Keep all anyOf structures (action unions) and resolve any $refs within
|
||||
elif key == 'anyOf' and isinstance(value, list):
|
||||
optimized[key] = [optimize_schema(item, defs_lookup) for item in value]
|
||||
|
||||
# Recursively optimize nested structures
|
||||
elif key in ['properties', 'items']:
|
||||
optimized[key] = optimize_schema(
|
||||
value,
|
||||
defs_lookup,
|
||||
in_properties=(key == 'properties'),
|
||||
)
|
||||
|
||||
# Keep essential validation fields
|
||||
elif key in ['type', 'required', 'minimum', 'maximum', 'minItems', 'maxItems', 'pattern', 'default']:
|
||||
optimized[key] = value if not isinstance(value, (dict, list)) else optimize_schema(value, defs_lookup)
|
||||
|
||||
# Recursively process all other fields
|
||||
else:
|
||||
optimized[key] = optimize_schema(value, defs_lookup) if isinstance(value, (dict, list)) else value
|
||||
|
||||
# If we have a flattened reference, merge it with the optimized properties
|
||||
if flattened_ref is not None and isinstance(flattened_ref, dict):
|
||||
# Start with the flattened reference as the base
|
||||
result = flattened_ref.copy()
|
||||
|
||||
# Merge in any sibling properties that were processed
|
||||
for key, value in optimized.items():
|
||||
# Preserve descriptions from the original object if they exist
|
||||
if key == 'description' and 'description' not in result:
|
||||
result[key] = value
|
||||
elif key != 'description': # Don't overwrite description from flattened ref
|
||||
result[key] = value
|
||||
|
||||
return result
|
||||
else:
|
||||
# No $ref, just return the optimized object
|
||||
# CRITICAL: Add additionalProperties: false to ALL objects for OpenAI strict mode
|
||||
if optimized.get('type') == 'object':
|
||||
optimized['additionalProperties'] = False
|
||||
|
||||
return optimized
|
||||
|
||||
elif isinstance(obj, list):
|
||||
return [optimize_schema(item, defs_lookup, in_properties=in_properties) for item in obj]
|
||||
return obj
|
||||
|
||||
# Create optimized schema with flattening
|
||||
optimized_result = optimize_schema(original_schema, defs_lookup)
|
||||
|
||||
# Ensure we have a dictionary (should always be the case for schema root)
|
||||
if not isinstance(optimized_result, dict):
|
||||
raise ValueError('Optimized schema result is not a dictionary')
|
||||
|
||||
optimized_schema: dict[str, Any] = optimized_result
|
||||
|
||||
# Additional pass to ensure ALL objects have additionalProperties: false
|
||||
def ensure_additional_properties_false(obj: Any) -> None:
|
||||
"""Ensure all objects have additionalProperties: false"""
|
||||
if isinstance(obj, dict):
|
||||
# If it's an object type, ensure additionalProperties is false
|
||||
if obj.get('type') == 'object':
|
||||
obj['additionalProperties'] = False
|
||||
|
||||
# Recursively apply to all values
|
||||
for value in obj.values():
|
||||
if isinstance(value, (dict, list)):
|
||||
ensure_additional_properties_false(value)
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
if isinstance(item, (dict, list)):
|
||||
ensure_additional_properties_false(item)
|
||||
|
||||
ensure_additional_properties_false(optimized_schema)
|
||||
SchemaOptimizer._make_strict_compatible(optimized_schema)
|
||||
|
||||
return optimized_schema
|
||||
|
||||
@staticmethod
|
||||
def _make_strict_compatible(schema: dict[str, Any] | list[Any]) -> None:
|
||||
"""Ensure all properties are required for OpenAI strict mode"""
|
||||
if isinstance(schema, dict):
|
||||
# First recursively apply to nested objects
|
||||
for key, value in schema.items():
|
||||
if isinstance(value, (dict, list)) and key != 'required':
|
||||
SchemaOptimizer._make_strict_compatible(value)
|
||||
|
||||
# Then update required for this level
|
||||
if 'properties' in schema and 'type' in schema and schema['type'] == 'object':
|
||||
# Add all properties to required array
|
||||
all_props = list(schema['properties'].keys())
|
||||
schema['required'] = all_props # Set all properties as required
|
||||
|
||||
elif isinstance(schema, list):
|
||||
for item in schema:
|
||||
SchemaOptimizer._make_strict_compatible(item)
|
||||
@@ -0,0 +1,290 @@
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from browser_use.agent.service import Agent
|
||||
from browser_use.llm.anthropic.chat import ChatAnthropic
|
||||
from browser_use.llm.anthropic.serializer import AnthropicMessageSerializer, NonSystemMessage
|
||||
from browser_use.llm.messages import (
|
||||
AssistantMessage,
|
||||
BaseMessage,
|
||||
ContentPartImageParam,
|
||||
ContentPartTextParam,
|
||||
Function,
|
||||
ImageURL,
|
||||
SystemMessage,
|
||||
ToolCall,
|
||||
UserMessage,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TestAnthropicCache:
|
||||
"""Comprehensive test for Anthropic cache serialization."""
|
||||
|
||||
def test_cache_basic_functionality(self):
|
||||
"""Test basic cache functionality for all message types."""
|
||||
# Test cache with different message types
|
||||
messages: list[BaseMessage] = [
|
||||
SystemMessage(content='System message!', cache=True),
|
||||
UserMessage(content='User message!', cache=True),
|
||||
AssistantMessage(content='Assistant message!', cache=False),
|
||||
]
|
||||
|
||||
anthropic_messages, system_message = AnthropicMessageSerializer.serialize_messages(messages)
|
||||
|
||||
assert len(anthropic_messages) == 2
|
||||
assert isinstance(system_message, list)
|
||||
assert isinstance(anthropic_messages[0]['content'], list)
|
||||
assert isinstance(anthropic_messages[1]['content'], str)
|
||||
|
||||
# Test cache with assistant message
|
||||
agent_messages: list[BaseMessage] = [
|
||||
SystemMessage(content='System message!'),
|
||||
UserMessage(content='User message!'),
|
||||
AssistantMessage(content='Assistant message!', cache=True),
|
||||
]
|
||||
|
||||
anthropic_messages, system_message = AnthropicMessageSerializer.serialize_messages(agent_messages)
|
||||
|
||||
assert isinstance(system_message, str)
|
||||
assert isinstance(anthropic_messages[0]['content'], str)
|
||||
assert isinstance(anthropic_messages[1]['content'], list)
|
||||
|
||||
def test_cache_with_tool_calls(self):
|
||||
"""Test cache functionality with tool calls."""
|
||||
tool_call = ToolCall(id='test_id', function=Function(name='test_function', arguments='{"arg": "value"}'))
|
||||
|
||||
# Assistant with tool calls and cache
|
||||
assistant_with_tools = AssistantMessage(content='Assistant with tools', tool_calls=[tool_call], cache=True)
|
||||
messages, _ = AnthropicMessageSerializer.serialize_messages([assistant_with_tools])
|
||||
|
||||
assert len(messages) == 1
|
||||
assert isinstance(messages[0]['content'], list)
|
||||
# Should have both text and tool_use blocks
|
||||
assert len(messages[0]['content']) >= 2
|
||||
|
||||
def test_cache_with_images(self):
|
||||
"""Test cache functionality with image content."""
|
||||
user_with_image = UserMessage(
|
||||
content=[
|
||||
ContentPartTextParam(text='Here is an image:', type='text'),
|
||||
ContentPartImageParam(image_url=ImageURL(url='https://example.com/image.jpg'), type='image_url'),
|
||||
],
|
||||
cache=True,
|
||||
)
|
||||
|
||||
messages, _ = AnthropicMessageSerializer.serialize_messages([user_with_image])
|
||||
|
||||
assert len(messages) == 1
|
||||
assert isinstance(messages[0]['content'], list)
|
||||
assert len(messages[0]['content']) == 2
|
||||
|
||||
def test_cache_with_base64_images(self):
|
||||
"""Test cache functionality with base64 images."""
|
||||
base64_url = 'data:image/jpeg;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
|
||||
|
||||
user_with_base64 = UserMessage(
|
||||
content=[
|
||||
ContentPartTextParam(text='Base64 image:', type='text'),
|
||||
ContentPartImageParam(image_url=ImageURL(url=base64_url), type='image_url'),
|
||||
],
|
||||
cache=True,
|
||||
)
|
||||
|
||||
messages, _ = AnthropicMessageSerializer.serialize_messages([user_with_base64])
|
||||
|
||||
assert len(messages) == 1
|
||||
assert isinstance(messages[0]['content'], list)
|
||||
|
||||
def test_cache_content_types(self):
|
||||
"""Test different content types with cache."""
|
||||
# String content with cache should become list
|
||||
user_string_cached = UserMessage(content='String message', cache=True)
|
||||
messages, _ = AnthropicMessageSerializer.serialize_messages([user_string_cached])
|
||||
assert isinstance(messages[0]['content'], list)
|
||||
|
||||
# String content without cache should remain string
|
||||
user_string_no_cache = UserMessage(content='String message', cache=False)
|
||||
messages, _ = AnthropicMessageSerializer.serialize_messages([user_string_no_cache])
|
||||
assert isinstance(messages[0]['content'], str)
|
||||
|
||||
# List content maintains list format regardless of cache
|
||||
user_list_cached = UserMessage(content=[ContentPartTextParam(text='List message', type='text')], cache=True)
|
||||
messages, _ = AnthropicMessageSerializer.serialize_messages([user_list_cached])
|
||||
assert isinstance(messages[0]['content'], list)
|
||||
|
||||
user_list_no_cache = UserMessage(content=[ContentPartTextParam(text='List message', type='text')], cache=False)
|
||||
messages, _ = AnthropicMessageSerializer.serialize_messages([user_list_no_cache])
|
||||
assert isinstance(messages[0]['content'], list)
|
||||
|
||||
def test_assistant_cache_empty_content(self):
|
||||
"""Test AssistantMessage with empty content and cache."""
|
||||
# With cache
|
||||
assistant_empty_cached = AssistantMessage(content=None, cache=True)
|
||||
messages, _ = AnthropicMessageSerializer.serialize_messages([assistant_empty_cached])
|
||||
|
||||
assert len(messages) == 1
|
||||
assert isinstance(messages[0]['content'], list)
|
||||
|
||||
# Without cache
|
||||
assistant_empty_no_cache = AssistantMessage(content=None, cache=False)
|
||||
messages, _ = AnthropicMessageSerializer.serialize_messages([assistant_empty_no_cache])
|
||||
|
||||
assert len(messages) == 1
|
||||
assert isinstance(messages[0]['content'], str)
|
||||
|
||||
def test_mixed_cache_scenarios(self):
|
||||
"""Test various combinations of cached and non-cached messages."""
|
||||
messages_list: list[BaseMessage] = [
|
||||
SystemMessage(content='System with cache', cache=True),
|
||||
UserMessage(content='User with cache', cache=True),
|
||||
AssistantMessage(content='Assistant without cache', cache=False),
|
||||
UserMessage(content='User without cache', cache=False),
|
||||
AssistantMessage(content='Assistant with cache', cache=True),
|
||||
]
|
||||
|
||||
serialized_messages, system_message = AnthropicMessageSerializer.serialize_messages(messages_list)
|
||||
|
||||
# Check system message is cached (becomes list)
|
||||
assert isinstance(system_message, list)
|
||||
|
||||
# Check serialized messages
|
||||
assert len(serialized_messages) == 4
|
||||
|
||||
# User with cache should be list
|
||||
assert isinstance(serialized_messages[0]['content'], list)
|
||||
|
||||
# Assistant without cache should be string
|
||||
assert isinstance(serialized_messages[1]['content'], str)
|
||||
|
||||
# User without cache should be string
|
||||
assert isinstance(serialized_messages[2]['content'], str)
|
||||
|
||||
# Assistant with cache should be list
|
||||
assert isinstance(serialized_messages[3]['content'], list)
|
||||
|
||||
def test_system_message_cache_behavior(self):
|
||||
"""Test SystemMessage specific cache behavior."""
|
||||
# With cache
|
||||
system_cached = SystemMessage(content='System message with cache', cache=True)
|
||||
result = AnthropicMessageSerializer.serialize(system_cached)
|
||||
assert isinstance(result, SystemMessage)
|
||||
|
||||
# Test serialization to string format
|
||||
serialized_content = AnthropicMessageSerializer._serialize_content_to_str(result.content, use_cache=True)
|
||||
assert isinstance(serialized_content, list)
|
||||
|
||||
# Without cache
|
||||
system_no_cache = SystemMessage(content='System message without cache', cache=False)
|
||||
result = AnthropicMessageSerializer.serialize(system_no_cache)
|
||||
assert isinstance(result, SystemMessage)
|
||||
|
||||
serialized_content = AnthropicMessageSerializer._serialize_content_to_str(result.content, use_cache=False)
|
||||
assert isinstance(serialized_content, str)
|
||||
|
||||
def test_agent_messages_integration(self):
|
||||
"""Test integration with actual agent messages."""
|
||||
agent = Agent(task='Hello, world!', llm=ChatAnthropic(''))
|
||||
|
||||
messages = agent.message_manager.get_messages()
|
||||
anthropic_messages, system_message = AnthropicMessageSerializer.serialize_messages(messages)
|
||||
|
||||
# System message should be properly handled
|
||||
assert system_message is not None
|
||||
|
||||
def test_cache_cleaning_last_message_only(self):
|
||||
"""Test that only the last cache=True message remains cached."""
|
||||
# Create multiple messages with cache=True
|
||||
messages_list: list[BaseMessage] = [
|
||||
UserMessage(content='First user message', cache=True),
|
||||
AssistantMessage(content='First assistant message', cache=True),
|
||||
UserMessage(content='Second user message', cache=True),
|
||||
AssistantMessage(content='Second assistant message', cache=False),
|
||||
UserMessage(content='Third user message', cache=True), # This should be the only one cached
|
||||
]
|
||||
|
||||
# Test the cleaning method directly (only accepts non-system messages)
|
||||
normal_messages = cast(list[NonSystemMessage], [msg for msg in messages_list if not isinstance(msg, SystemMessage)])
|
||||
cleaned_messages = AnthropicMessageSerializer._clean_cache_messages(normal_messages)
|
||||
|
||||
# Verify only the last cache=True message remains cached
|
||||
assert not cleaned_messages[0].cache # First user message should be uncached
|
||||
assert not cleaned_messages[1].cache # First assistant message should be uncached
|
||||
assert not cleaned_messages[2].cache # Second user message should be uncached
|
||||
assert not cleaned_messages[3].cache # Second assistant message was already uncached
|
||||
assert cleaned_messages[4].cache # Third user message should remain cached
|
||||
|
||||
# Test through serialize_messages
|
||||
serialized_messages, system_message = AnthropicMessageSerializer.serialize_messages(messages_list)
|
||||
|
||||
# Count how many messages have list content (indicating caching)
|
||||
cached_content_count = sum(1 for msg in serialized_messages if isinstance(msg['content'], list))
|
||||
|
||||
# Only one message should have cached content
|
||||
assert cached_content_count == 1
|
||||
|
||||
# The last message should be the cached one
|
||||
assert isinstance(serialized_messages[-1]['content'], list)
|
||||
|
||||
def test_cache_cleaning_with_system_message(self):
|
||||
"""Test that system messages are not affected by cache cleaning logic."""
|
||||
messages_list: list[BaseMessage] = [
|
||||
SystemMessage(content='System message', cache=True), # System messages are handled separately
|
||||
UserMessage(content='First user message', cache=True),
|
||||
AssistantMessage(content='Assistant message', cache=True), # This should be the only normal message cached
|
||||
]
|
||||
|
||||
# Test through serialize_messages to see the full integration
|
||||
serialized_messages, system_message = AnthropicMessageSerializer.serialize_messages(messages_list)
|
||||
|
||||
# System message should be cached
|
||||
assert isinstance(system_message, list)
|
||||
|
||||
# Only one normal message should have cached content (the last one)
|
||||
cached_content_count = sum(1 for msg in serialized_messages if isinstance(msg['content'], list))
|
||||
assert cached_content_count == 1
|
||||
|
||||
# The last message should be the cached one
|
||||
assert isinstance(serialized_messages[-1]['content'], list)
|
||||
|
||||
def test_cache_cleaning_no_cached_messages(self):
|
||||
"""Test that messages without cache=True are not affected."""
|
||||
normal_messages_list = [
|
||||
UserMessage(content='User message 1', cache=False),
|
||||
AssistantMessage(content='Assistant message 1', cache=False),
|
||||
UserMessage(content='User message 2', cache=False),
|
||||
]
|
||||
|
||||
cleaned_messages = AnthropicMessageSerializer._clean_cache_messages(normal_messages_list)
|
||||
|
||||
# All messages should remain uncached
|
||||
for msg in cleaned_messages:
|
||||
assert not msg.cache
|
||||
|
||||
def test_max_4_cache_blocks(self):
|
||||
"""Test that the max number of cache blocks is 4."""
|
||||
agent = Agent(task='Hello, world!', llm=ChatAnthropic(''))
|
||||
messages = agent.message_manager.get_messages()
|
||||
anthropic_messages, system_message = AnthropicMessageSerializer.serialize_messages(messages)
|
||||
|
||||
logger.info(anthropic_messages)
|
||||
logger.info(system_message)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
test_instance = TestAnthropicCache()
|
||||
test_instance.test_cache_basic_functionality()
|
||||
test_instance.test_cache_with_tool_calls()
|
||||
test_instance.test_cache_with_images()
|
||||
test_instance.test_cache_with_base64_images()
|
||||
test_instance.test_cache_content_types()
|
||||
test_instance.test_assistant_cache_empty_content()
|
||||
test_instance.test_mixed_cache_scenarios()
|
||||
test_instance.test_system_message_cache_behavior()
|
||||
test_instance.test_agent_messages_integration()
|
||||
test_instance.test_cache_cleaning_last_message_only()
|
||||
test_instance.test_cache_cleaning_with_system_message()
|
||||
test_instance.test_cache_cleaning_no_cached_messages()
|
||||
test_instance.test_max_4_cache_blocks()
|
||||
print('All cache tests passed!')
|
||||
@@ -0,0 +1,248 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from browser_use.llm import ChatAnthropic, ChatGoogle, ChatGroq, ChatOpenAI, ChatOpenRouter
|
||||
from browser_use.llm.messages import ContentPartTextParam
|
||||
|
||||
|
||||
class CapitalResponse(BaseModel):
|
||||
"""Structured response for capital question"""
|
||||
|
||||
country: str
|
||||
capital: str
|
||||
|
||||
|
||||
class TestChatModels:
|
||||
from browser_use.llm.messages import (
|
||||
AssistantMessage,
|
||||
BaseMessage,
|
||||
SystemMessage,
|
||||
UserMessage,
|
||||
)
|
||||
|
||||
"""Test suite for all chat model implementations"""
|
||||
|
||||
# Test Constants
|
||||
SYSTEM_MESSAGE = SystemMessage(content=[ContentPartTextParam(text='You are a helpful assistant.', type='text')])
|
||||
FRANCE_QUESTION = UserMessage(content='What is the capital of France? Answer in one word.')
|
||||
FRANCE_ANSWER = AssistantMessage(content='Paris')
|
||||
GERMANY_QUESTION = UserMessage(content='What is the capital of Germany? Answer in one word.')
|
||||
|
||||
# Expected values
|
||||
EXPECTED_GERMANY_CAPITAL = 'berlin'
|
||||
EXPECTED_FRANCE_COUNTRY = 'france'
|
||||
EXPECTED_FRANCE_CAPITAL = 'paris'
|
||||
|
||||
# Test messages for conversation
|
||||
CONVERSATION_MESSAGES: list[BaseMessage] = [
|
||||
SYSTEM_MESSAGE,
|
||||
FRANCE_QUESTION,
|
||||
FRANCE_ANSWER,
|
||||
GERMANY_QUESTION,
|
||||
]
|
||||
|
||||
# Test messages for structured output
|
||||
STRUCTURED_MESSAGES: list[BaseMessage] = [UserMessage(content='What is the capital of France?')]
|
||||
|
||||
# OpenAI Tests
|
||||
@pytest.fixture
|
||||
def openrouter_chat(self):
|
||||
"""Provides an initialized ChatOpenRouter client for tests."""
|
||||
if not os.getenv('OPENROUTER_API_KEY'):
|
||||
pytest.skip('OPENROUTER_API_KEY not set')
|
||||
return ChatOpenRouter(model='openai/gpt-4o-mini', api_key=os.getenv('OPENROUTER_API_KEY'), temperature=0)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_ainvoke_normal(self):
|
||||
"""Test normal text response from OpenAI"""
|
||||
# Skip if no API key
|
||||
if not os.getenv('OPENAI_API_KEY'):
|
||||
pytest.skip('OPENAI_API_KEY not set')
|
||||
|
||||
chat = ChatOpenAI(model='gpt-4o-mini', temperature=0)
|
||||
response = await chat.ainvoke(self.CONVERSATION_MESSAGES)
|
||||
|
||||
completion = response.completion
|
||||
|
||||
assert isinstance(completion, str)
|
||||
assert self.EXPECTED_GERMANY_CAPITAL in completion.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_ainvoke_structured(self):
|
||||
"""Test structured output from OpenAI"""
|
||||
# Skip if no API key
|
||||
if not os.getenv('OPENAI_API_KEY'):
|
||||
pytest.skip('OPENAI_API_KEY not set')
|
||||
|
||||
chat = ChatOpenAI(model='gpt-4o-mini', temperature=0)
|
||||
response = await chat.ainvoke(self.STRUCTURED_MESSAGES, output_format=CapitalResponse)
|
||||
completion = response.completion
|
||||
|
||||
assert isinstance(completion, CapitalResponse)
|
||||
assert completion.country.lower() == self.EXPECTED_FRANCE_COUNTRY
|
||||
assert completion.capital.lower() == self.EXPECTED_FRANCE_CAPITAL
|
||||
|
||||
# Anthropic Tests
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_ainvoke_normal(self):
|
||||
"""Test normal text response from Anthropic"""
|
||||
# Skip if no API key
|
||||
if not os.getenv('ANTHROPIC_API_KEY'):
|
||||
pytest.skip('ANTHROPIC_API_KEY not set')
|
||||
|
||||
chat = ChatAnthropic(model='claude-3-5-haiku-latest', max_tokens=100, temperature=0)
|
||||
response = await chat.ainvoke(self.CONVERSATION_MESSAGES)
|
||||
completion = response.completion
|
||||
|
||||
assert isinstance(completion, str)
|
||||
assert self.EXPECTED_GERMANY_CAPITAL in completion.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_ainvoke_structured(self):
|
||||
"""Test structured output from Anthropic"""
|
||||
# Skip if no API key
|
||||
if not os.getenv('ANTHROPIC_API_KEY'):
|
||||
pytest.skip('ANTHROPIC_API_KEY not set')
|
||||
|
||||
chat = ChatAnthropic(model='claude-3-5-haiku-latest', max_tokens=100, temperature=0)
|
||||
response = await chat.ainvoke(self.STRUCTURED_MESSAGES, output_format=CapitalResponse)
|
||||
completion = response.completion
|
||||
|
||||
assert isinstance(completion, CapitalResponse)
|
||||
assert completion.country.lower() == self.EXPECTED_FRANCE_COUNTRY
|
||||
assert completion.capital.lower() == self.EXPECTED_FRANCE_CAPITAL
|
||||
|
||||
# Google Gemini Tests
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_ainvoke_normal(self):
|
||||
"""Test normal text response from Google Gemini"""
|
||||
# Skip if no API key
|
||||
if not os.getenv('GOOGLE_API_KEY'):
|
||||
pytest.skip('GOOGLE_API_KEY not set')
|
||||
|
||||
chat = ChatGoogle(model='gemini-2.0-flash', api_key=os.getenv('GOOGLE_API_KEY'), temperature=0)
|
||||
response = await chat.ainvoke(self.CONVERSATION_MESSAGES)
|
||||
completion = response.completion
|
||||
|
||||
assert isinstance(completion, str)
|
||||
assert self.EXPECTED_GERMANY_CAPITAL in completion.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_ainvoke_structured(self):
|
||||
"""Test structured output from Google Gemini"""
|
||||
# Skip if no API key
|
||||
if not os.getenv('GOOGLE_API_KEY'):
|
||||
pytest.skip('GOOGLE_API_KEY not set')
|
||||
|
||||
chat = ChatGoogle(model='gemini-2.0-flash', api_key=os.getenv('GOOGLE_API_KEY'), temperature=0)
|
||||
response = await chat.ainvoke(self.STRUCTURED_MESSAGES, output_format=CapitalResponse)
|
||||
completion = response.completion
|
||||
|
||||
assert isinstance(completion, CapitalResponse)
|
||||
assert completion.country.lower() == self.EXPECTED_FRANCE_COUNTRY
|
||||
assert completion.capital.lower() == self.EXPECTED_FRANCE_CAPITAL
|
||||
|
||||
# Google Gemini with Vertex AI Tests
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_vertex_ainvoke_normal(self):
|
||||
"""Test normal text response from Google Gemini via Vertex AI"""
|
||||
# Skip if no project ID
|
||||
if not os.getenv('GOOGLE_CLOUD_PROJECT'):
|
||||
pytest.skip('GOOGLE_CLOUD_PROJECT not set')
|
||||
|
||||
chat = ChatGoogle(
|
||||
model='gemini-2.0-flash',
|
||||
vertexai=True,
|
||||
project=os.getenv('GOOGLE_CLOUD_PROJECT'),
|
||||
location='us-central1',
|
||||
temperature=0,
|
||||
)
|
||||
response = await chat.ainvoke(self.CONVERSATION_MESSAGES)
|
||||
completion = response.completion
|
||||
|
||||
assert isinstance(completion, str)
|
||||
assert self.EXPECTED_GERMANY_CAPITAL in completion.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_vertex_ainvoke_structured(self):
|
||||
"""Test structured output from Google Gemini via Vertex AI"""
|
||||
# Skip if no project ID
|
||||
if not os.getenv('GOOGLE_CLOUD_PROJECT'):
|
||||
pytest.skip('GOOGLE_CLOUD_PROJECT not set')
|
||||
|
||||
chat = ChatGoogle(
|
||||
model='gemini-2.0-flash',
|
||||
vertexai=True,
|
||||
project=os.getenv('GOOGLE_CLOUD_PROJECT'),
|
||||
location='us-central1',
|
||||
temperature=0,
|
||||
)
|
||||
response = await chat.ainvoke(self.STRUCTURED_MESSAGES, output_format=CapitalResponse)
|
||||
completion = response.completion
|
||||
|
||||
assert isinstance(completion, CapitalResponse)
|
||||
assert completion.country.lower() == self.EXPECTED_FRANCE_COUNTRY
|
||||
assert completion.capital.lower() == self.EXPECTED_FRANCE_CAPITAL
|
||||
|
||||
# Groq Tests
|
||||
@pytest.mark.asyncio
|
||||
async def test_groq_ainvoke_normal(self):
|
||||
"""Test normal text response from Groq"""
|
||||
# Skip if no API key
|
||||
if not os.getenv('GROQ_API_KEY'):
|
||||
pytest.skip('GROQ_API_KEY not set')
|
||||
|
||||
chat = ChatGroq(model='meta-llama/llama-4-maverick-17b-128e-instruct', temperature=0)
|
||||
response = await chat.ainvoke(self.CONVERSATION_MESSAGES)
|
||||
completion = response.completion
|
||||
|
||||
assert isinstance(completion, str)
|
||||
assert self.EXPECTED_GERMANY_CAPITAL in completion.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_groq_ainvoke_structured(self):
|
||||
"""Test structured output from Groq"""
|
||||
# Skip if no API key
|
||||
if not os.getenv('GROQ_API_KEY'):
|
||||
pytest.skip('GROQ_API_KEY not set')
|
||||
|
||||
chat = ChatGroq(model='meta-llama/llama-4-maverick-17b-128e-instruct', temperature=0)
|
||||
response = await chat.ainvoke(self.STRUCTURED_MESSAGES, output_format=CapitalResponse)
|
||||
|
||||
completion = response.completion
|
||||
|
||||
assert isinstance(completion, CapitalResponse)
|
||||
assert completion.country.lower() == self.EXPECTED_FRANCE_COUNTRY
|
||||
assert completion.capital.lower() == self.EXPECTED_FRANCE_CAPITAL
|
||||
|
||||
# OpenRouter Tests
|
||||
@pytest.mark.asyncio
|
||||
async def test_openrouter_ainvoke_normal(self):
|
||||
"""Test normal text response from OpenRouter"""
|
||||
# Skip if no API key
|
||||
if not os.getenv('OPENROUTER_API_KEY'):
|
||||
pytest.skip('OPENROUTER_API_KEY not set')
|
||||
|
||||
chat = ChatOpenRouter(model='openai/gpt-4o-mini', api_key=os.getenv('OPENROUTER_API_KEY'), temperature=0)
|
||||
response = await chat.ainvoke(self.CONVERSATION_MESSAGES)
|
||||
completion = response.completion
|
||||
|
||||
assert isinstance(completion, str)
|
||||
assert self.EXPECTED_GERMANY_CAPITAL in completion.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openrouter_ainvoke_structured(self):
|
||||
"""Test structured output from OpenRouter"""
|
||||
# Skip if no API key
|
||||
if not os.getenv('OPENROUTER_API_KEY'):
|
||||
pytest.skip('OPENROUTER_API_KEY not set')
|
||||
|
||||
chat = ChatOpenRouter(model='openai/gpt-4o-mini', api_key=os.getenv('OPENROUTER_API_KEY'), temperature=0)
|
||||
response = await chat.ainvoke(self.STRUCTURED_MESSAGES, output_format=CapitalResponse)
|
||||
completion = response.completion
|
||||
|
||||
assert isinstance(completion, CapitalResponse)
|
||||
assert completion.country.lower() == self.EXPECTED_FRANCE_COUNTRY
|
||||
assert completion.capital.lower() == self.EXPECTED_FRANCE_CAPITAL
|
||||
@@ -0,0 +1,91 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import random
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from browser_use.llm.google.chat import ChatGoogle
|
||||
from browser_use.llm.google.serializer import GoogleMessageSerializer
|
||||
from browser_use.llm.messages import (
|
||||
BaseMessage,
|
||||
ContentPartImageParam,
|
||||
ContentPartTextParam,
|
||||
ImageURL,
|
||||
SystemMessage,
|
||||
UserMessage,
|
||||
)
|
||||
|
||||
|
||||
def create_random_text_image(text: str = 'hello world', width: int = 4000, height: int = 4000) -> str:
|
||||
# Create image with random background color
|
||||
bg_color = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
|
||||
image = Image.new('RGB', (width, height), bg_color)
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
# Try to use a default font, fallback to default if not available
|
||||
try:
|
||||
font = ImageFont.truetype('arial.ttf', 24)
|
||||
except Exception:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Calculate text position to center it
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
x = (width - text_width) // 2
|
||||
y = (height - text_height) // 2
|
||||
|
||||
# Draw text with contrasting color
|
||||
text_color = (255 - bg_color[0], 255 - bg_color[1], 255 - bg_color[2])
|
||||
draw.text((x, y), text, fill=text_color, font=font)
|
||||
|
||||
# Convert to base64
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format='PNG')
|
||||
img_data = base64.b64encode(buffer.getvalue()).decode()
|
||||
|
||||
return f'data:image/png;base64,{img_data}'
|
||||
|
||||
|
||||
async def test_gemini_image_vision():
|
||||
"""Test Gemini's ability to see and describe images."""
|
||||
|
||||
# Create the LLM
|
||||
llm = ChatGoogle(model='gemini-2.0-flash-exp')
|
||||
|
||||
# Create a random image with text
|
||||
image_data_url = create_random_text_image('Hello Gemini! Can you see this text?')
|
||||
|
||||
# Create messages with image
|
||||
messages: list[BaseMessage] = [
|
||||
SystemMessage(content='You are a helpful assistant that can see and describe images.'),
|
||||
UserMessage(
|
||||
content=[
|
||||
ContentPartTextParam(text='What do you see in this image? Please describe the text and any visual elements.'),
|
||||
ContentPartImageParam(image_url=ImageURL(url=image_data_url)),
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
# Serialize messages for Google format
|
||||
serializer = GoogleMessageSerializer()
|
||||
formatted_messages, system_message = serializer.serialize_messages(messages)
|
||||
|
||||
print('Testing Gemini image vision...')
|
||||
print(f'System message: {system_message}')
|
||||
|
||||
# Make the API call
|
||||
try:
|
||||
response = await llm.ainvoke(messages)
|
||||
print('\n=== Gemini Response ===')
|
||||
print(response.completion)
|
||||
print(response.usage)
|
||||
print('=======================')
|
||||
except Exception as e:
|
||||
print(f'Error calling Gemini: {e}')
|
||||
print(f'Error type: {type(e)}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(test_gemini_image_vision())
|
||||
@@ -0,0 +1,51 @@
|
||||
import asyncio
|
||||
|
||||
from browser_use.llm import ContentText
|
||||
from browser_use.llm.groq.chat import ChatGroq
|
||||
from browser_use.llm.messages import SystemMessage, UserMessage
|
||||
|
||||
llm = ChatGroq(
|
||||
model='meta-llama/llama-4-maverick-17b-128e-instruct',
|
||||
temperature=0.5,
|
||||
)
|
||||
# llm = ChatOpenAI(model='gpt-4.1-mini')
|
||||
|
||||
|
||||
async def main():
|
||||
from pydantic import BaseModel
|
||||
|
||||
from browser_use.tokens.service import TokenCost
|
||||
|
||||
tk = TokenCost().register_llm(llm)
|
||||
|
||||
class Output(BaseModel):
|
||||
reasoning: str
|
||||
answer: str
|
||||
|
||||
message = [
|
||||
SystemMessage(content='You are a helpful assistant that can answer questions and help with tasks.'),
|
||||
UserMessage(
|
||||
content=[
|
||||
ContentText(
|
||||
text=r"Why is the sky blue? write exactly this into reasoning make sure to output ' with exactly like in the input : "
|
||||
),
|
||||
ContentText(
|
||||
text="""
|
||||
The user's request is to find the lowest priced women's plus size one piece swimsuit in color black with a customer rating of at least 5 on Kohls.com. I am currently on the homepage of Kohls. The page has a search bar and various category links. To begin, I need to navigate to the women's section and search for swimsuits. I will start by clicking on the 'Women' category link."""
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
for i in range(10):
|
||||
print('-' * 50)
|
||||
print(f'start loop {i}')
|
||||
response = await llm.ainvoke(message, output_format=Output)
|
||||
completion = response.completion
|
||||
print(f'start reasoning: {completion.reasoning}')
|
||||
print(f'answer: {completion.answer}')
|
||||
print('-' * 50)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,188 @@
|
||||
import logging
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from browser_use.agent.prompts import AgentMessagePrompt
|
||||
from browser_use.agent.service import Agent
|
||||
from browser_use.browser.views import BrowserStateSummary, TabInfo
|
||||
from browser_use.dom.views import DOMSelectorMap, EnhancedDOMTreeNode, NodeType, SerializedDOMState, SimplifiedNode
|
||||
from browser_use.filesystem.file_system import FileSystem
|
||||
from browser_use.llm.anthropic.chat import ChatAnthropic
|
||||
from browser_use.llm.azure.chat import ChatAzureOpenAI
|
||||
from browser_use.llm.base import BaseChatModel
|
||||
from browser_use.llm.google.chat import ChatGoogle
|
||||
from browser_use.llm.groq.chat import ChatGroq
|
||||
from browser_use.llm.openai.chat import ChatOpenAI
|
||||
|
||||
# Set logging level to INFO for this module
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
def create_mock_state_message(temp_dir: str):
|
||||
"""Create a mock state message with a single clickable element."""
|
||||
|
||||
# Create a mock DOM element with a single clickable button
|
||||
mock_button = EnhancedDOMTreeNode(
|
||||
node_id=1,
|
||||
backend_node_id=1,
|
||||
node_type=NodeType.ELEMENT_NODE,
|
||||
node_name='button',
|
||||
node_value='Click Me',
|
||||
attributes={'id': 'test-button'},
|
||||
is_scrollable=False,
|
||||
is_visible=True,
|
||||
absolute_position=None,
|
||||
session_id=None,
|
||||
target_id='ABCD1234ABCD1234ABCD1234ABCD1234ABCD1234',
|
||||
frame_id=None,
|
||||
content_document=None,
|
||||
shadow_root_type=None,
|
||||
shadow_roots=None,
|
||||
parent_node=None,
|
||||
children_nodes=None,
|
||||
ax_node=None,
|
||||
snapshot_node=None,
|
||||
)
|
||||
|
||||
# Create selector map
|
||||
selector_map: DOMSelectorMap = {1: mock_button}
|
||||
|
||||
# Create mock tab info with proper target_id
|
||||
mock_tab = TabInfo(
|
||||
target_id='ABCD1234ABCD1234ABCD1234ABCD1234ABCD1234',
|
||||
url='https://example.com',
|
||||
title='Test Page',
|
||||
)
|
||||
|
||||
dom_state = SerializedDOMState(
|
||||
_root=SimplifiedNode(
|
||||
original_node=mock_button,
|
||||
children=[],
|
||||
should_display=True,
|
||||
interactive_index=1,
|
||||
),
|
||||
selector_map=selector_map,
|
||||
)
|
||||
|
||||
# Create mock browser state with required selector_map
|
||||
mock_browser_state = BrowserStateSummary(
|
||||
dom_state=dom_state, # Using the actual DOM element
|
||||
url='https://example.com',
|
||||
title='Test Page',
|
||||
tabs=[mock_tab],
|
||||
screenshot='', # Empty screenshot
|
||||
pixels_above=0,
|
||||
pixels_below=0,
|
||||
)
|
||||
|
||||
# Create file system using the provided temp directory
|
||||
mock_file_system = FileSystem(temp_dir)
|
||||
|
||||
# Create the agent message prompt
|
||||
agent_prompt = AgentMessagePrompt(
|
||||
browser_state_summary=mock_browser_state,
|
||||
file_system=mock_file_system, # Now using actual FileSystem instance
|
||||
agent_history_description='', # Empty history
|
||||
read_state_description='', # Empty read state
|
||||
task='Click the button on the page',
|
||||
include_attributes=['id'],
|
||||
step_info=None,
|
||||
page_filtered_actions=None,
|
||||
max_clickable_elements_length=40000,
|
||||
sensitive_data=None,
|
||||
)
|
||||
|
||||
# Override the clickable_elements_to_string method to return our simple element
|
||||
dom_state.llm_representation = lambda include_attributes=None: '[1]<button id="test-button">Click Me</button>'
|
||||
|
||||
# Get the formatted message
|
||||
message = agent_prompt.get_user_message(use_vision=False)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
# Pytest parameterized version
|
||||
@pytest.mark.parametrize(
|
||||
'llm_class,model_name',
|
||||
[
|
||||
(ChatGroq, 'meta-llama/llama-4-maverick-17b-128e-instruct'),
|
||||
(ChatGoogle, 'gemini-2.0-flash-exp'),
|
||||
(ChatOpenAI, 'gpt-4.1-mini'),
|
||||
(ChatAnthropic, 'claude-3-5-sonnet-latest'),
|
||||
(ChatAzureOpenAI, 'gpt-4.1-mini'),
|
||||
],
|
||||
)
|
||||
async def test_single_step_parametrized(llm_class, model_name):
|
||||
"""Test single step with different LLM providers using pytest parametrize."""
|
||||
llm = llm_class(model=model_name)
|
||||
|
||||
agent = Agent(task='Click the button on the page', llm=llm)
|
||||
|
||||
# Create temporary directory that will stay alive during the test
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Create mock state message
|
||||
mock_message = create_mock_state_message(temp_dir)
|
||||
|
||||
agent.message_manager._set_message_with_type(mock_message, 'state')
|
||||
|
||||
messages = agent.message_manager.get_messages()
|
||||
|
||||
# Test with simple question
|
||||
response = await llm.ainvoke(messages, agent.AgentOutput)
|
||||
|
||||
# Basic assertions to ensure response is valid
|
||||
assert response.completion is not None
|
||||
assert response.usage is not None
|
||||
assert response.usage.total_tokens > 0
|
||||
|
||||
|
||||
async def test_single_step():
|
||||
"""Original test function that tests all models in a loop."""
|
||||
# Create a list of models to test
|
||||
models: list[BaseChatModel] = [
|
||||
ChatGroq(model='meta-llama/llama-4-maverick-17b-128e-instruct'),
|
||||
ChatGoogle(model='gemini-2.0-flash-exp'),
|
||||
ChatOpenAI(model='gpt-4.1'),
|
||||
ChatAnthropic(model='claude-3-5-sonnet-latest'), # Using haiku for cost efficiency
|
||||
ChatAzureOpenAI(model='gpt-4o-mini'),
|
||||
]
|
||||
|
||||
for llm in models:
|
||||
print(f'\n{"=" * 60}')
|
||||
print(f'Testing with model: {llm.provider} - {llm.model}')
|
||||
print(f'{"=" * 60}\n')
|
||||
|
||||
agent = Agent(task='Click the button on the page', llm=llm)
|
||||
|
||||
# Create temporary directory that will stay alive during the test
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Create mock state message
|
||||
mock_message = create_mock_state_message(temp_dir)
|
||||
|
||||
# Print the mock message content to see what it looks like
|
||||
print('Mock state message:')
|
||||
print(mock_message.content)
|
||||
print('\n' + '=' * 50 + '\n')
|
||||
|
||||
agent.message_manager._set_message_with_type(mock_message, 'state')
|
||||
|
||||
messages = agent.message_manager.get_messages()
|
||||
|
||||
# Test with simple question
|
||||
try:
|
||||
response = await llm.ainvoke(messages, agent.AgentOutput)
|
||||
logger.info(f'Response from {llm.provider}: {response.completion}')
|
||||
logger.info(f'Actions: {str(response.completion.action)}')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error with {llm.provider}: {type(e).__name__}: {str(e)}')
|
||||
|
||||
print(f'\n{"=" * 60}\n')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import asyncio
|
||||
|
||||
asyncio.run(test_single_step())
|
||||
@@ -0,0 +1,45 @@
|
||||
from typing import Generic, TypeVar, Union
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
T = TypeVar('T', bound=Union[BaseModel, str])
|
||||
|
||||
|
||||
class ChatInvokeUsage(BaseModel):
|
||||
"""
|
||||
Usage information for a chat model invocation.
|
||||
"""
|
||||
|
||||
prompt_tokens: int
|
||||
"""The number of tokens in the prompt (this includes the cached tokens as well. When calculating the cost, subtract the cached tokens from the prompt tokens)"""
|
||||
|
||||
prompt_cached_tokens: int | None
|
||||
"""The number of cached tokens."""
|
||||
|
||||
prompt_cache_creation_tokens: int | None
|
||||
"""Anthropic only: The number of tokens used to create the cache."""
|
||||
|
||||
prompt_image_tokens: int | None
|
||||
"""Google only: The number of tokens in the image (prompt tokens is the text tokens + image tokens in that case)"""
|
||||
|
||||
completion_tokens: int
|
||||
"""The number of tokens in the completion."""
|
||||
|
||||
total_tokens: int
|
||||
"""The total number of tokens in the response."""
|
||||
|
||||
|
||||
class ChatInvokeCompletion(BaseModel, Generic[T]):
|
||||
"""
|
||||
Response from a chat model invocation.
|
||||
"""
|
||||
|
||||
completion: T
|
||||
"""The completion of the response."""
|
||||
|
||||
# Thinking stuff
|
||||
thinking: str | None = None
|
||||
redacted_thinking: str | None = None
|
||||
|
||||
usage: ChatInvokeUsage | None
|
||||
"""The usage of the response."""
|
||||
Reference in New Issue
Block a user