4cd2d4af2b
Test Browser Use CLI Install / uv pip install (ubuntu-latest) (push) Failing after 1s
Test Browser Use CLI Install / uvx browser-use from local wheel (push) Failing after 1s
Test Browser Use CLI Install / uvx browser-use[cli] from PyPI (push) Failing after 1s
package / pip-install-on-macos-latest-py-3.11 (push) Has been skipped
package / pip-install-on-macos-latest-py-3.13 (push) Has been skipped
package / pip-install-on-ubuntu-latest-py-3.11 (push) Has been skipped
package / pip-install-on-windows-latest-py-3.13 (push) Has been skipped
cloud_evals / trigger_cloud_eval_image_build (push) Failing after 1s
docker / build_publish_image (push) Failing after 1s
Test Browser Use CLI Install / browser-use skill sync (push) Failing after 1s
lint / code-style (push) Failing after 0s
lint / type-checker (push) Failing after 1s
package / pip-build (push) Failing after 1s
lint / syntax-errors (push) Failing after 3s
package / pip-install-on-ubuntu-latest-py-3.13 (push) Has been skipped
package / pip-install-on-windows-latest-py-3.11 (push) Has been skipped
test / ${{ matrix.test_filename }} (push) Has been skipped
test / evaluate-tasks (push) Has been skipped
test / setup-chromium (push) Failing after 2s
test / find_tests (push) Failing after 2s
Test Browser Use CLI Install / uv pip install (windows-latest) (push) Has been cancelled
Test Browser Use CLI Install / uv pip install (macos-latest) (push) Has been cancelled
446 lines
15 KiB
Python
446 lines
15 KiB
Python
"""
|
|
OCI Raw API chat model integration for browser-use.
|
|
|
|
This module provides direct integration with Oracle Cloud Infrastructure's
|
|
Generative AI service using raw API calls without Langchain dependencies.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
from dataclasses import dataclass
|
|
from typing import Any, TypeVar, overload
|
|
|
|
import oci
|
|
from oci.generative_ai_inference import GenerativeAiInferenceClient
|
|
from oci.generative_ai_inference.models import (
|
|
BaseChatRequest,
|
|
ChatDetails,
|
|
CohereChatRequest,
|
|
GenericChatRequest,
|
|
OnDemandServingMode,
|
|
)
|
|
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.schema import SchemaOptimizer
|
|
from browser_use.llm.views import ChatInvokeCompletion, ChatInvokeUsage
|
|
|
|
from .serializer import OCIRawMessageSerializer
|
|
|
|
T = TypeVar('T', bound=BaseModel)
|
|
|
|
|
|
@dataclass
|
|
class ChatOCIRaw(BaseChatModel):
|
|
"""
|
|
A direct OCI Raw API integration for browser-use that bypasses Langchain.
|
|
|
|
This class provides a browser-use compatible interface for OCI GenAI models
|
|
using direct API calls to Oracle Cloud Infrastructure.
|
|
|
|
Args:
|
|
model_id: The OCI GenAI model OCID
|
|
service_endpoint: The OCI service endpoint URL
|
|
compartment_id: The OCI compartment OCID
|
|
provider: The model provider (e.g., "meta", "cohere", "xai")
|
|
temperature: Temperature for response generation (0.0-2.0) - supported by all providers
|
|
max_tokens: Maximum tokens in response - supported by all providers
|
|
frequency_penalty: Frequency penalty for response generation - supported by Meta and Cohere only
|
|
presence_penalty: Presence penalty for response generation - supported by Meta only
|
|
top_p: Top-p sampling parameter - supported by all providers
|
|
top_k: Top-k sampling parameter - supported by Cohere and xAI only
|
|
auth_type: Authentication type (e.g., "API_KEY")
|
|
auth_profile: Authentication profile name
|
|
timeout: Request timeout in seconds
|
|
"""
|
|
|
|
# Model configuration
|
|
model_id: str
|
|
service_endpoint: str
|
|
compartment_id: str
|
|
provider: str = 'meta'
|
|
|
|
# Model parameters
|
|
temperature: float | None = 1.0
|
|
max_tokens: int | None = 600
|
|
frequency_penalty: float | None = 0.0
|
|
presence_penalty: float | None = 0.0
|
|
top_p: float | None = 0.75
|
|
top_k: int | None = 0 # Used by Cohere models
|
|
|
|
# Authentication
|
|
auth_type: str = 'API_KEY'
|
|
auth_profile: str = 'DEFAULT'
|
|
|
|
# Client configuration
|
|
timeout: float = 60.0
|
|
|
|
# Static properties
|
|
@property
|
|
def provider_name(self) -> str:
|
|
return 'oci-raw'
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
# Return a shorter name for telemetry (max 100 chars)
|
|
if len(self.model_id) > 90:
|
|
# Extract the model name from the OCID
|
|
parts = self.model_id.split('.')
|
|
if len(parts) >= 4:
|
|
return f'oci-{self.provider}-{parts[3]}' # e.g., "oci-meta-us-chicago-1"
|
|
else:
|
|
return f'oci-{self.provider}-model'
|
|
return self.model_id
|
|
|
|
@property
|
|
def model(self) -> str:
|
|
return self.model_id
|
|
|
|
@property
|
|
def model_name(self) -> str:
|
|
# Override for telemetry - return shorter name (max 100 chars)
|
|
if len(self.model_id) > 90:
|
|
# Extract the model name from the OCID
|
|
parts = self.model_id.split('.')
|
|
if len(parts) >= 4:
|
|
return f'oci-{self.provider}-{parts[3]}' # e.g., "oci-meta-us-chicago-1"
|
|
else:
|
|
return f'oci-{self.provider}-model'
|
|
return self.model_id
|
|
|
|
def _uses_cohere_format(self) -> bool:
|
|
"""Check if the provider uses Cohere chat request format."""
|
|
return self.provider.lower() == 'cohere'
|
|
|
|
def _get_supported_parameters(self) -> dict[str, bool]:
|
|
"""Get which parameters are supported by the current provider."""
|
|
provider = self.provider.lower()
|
|
if provider == 'meta':
|
|
return {
|
|
'temperature': True,
|
|
'max_tokens': True,
|
|
'frequency_penalty': True,
|
|
'presence_penalty': True,
|
|
'top_p': True,
|
|
'top_k': False,
|
|
}
|
|
elif provider == 'cohere':
|
|
return {
|
|
'temperature': True,
|
|
'max_tokens': True,
|
|
'frequency_penalty': True,
|
|
'presence_penalty': False,
|
|
'top_p': True,
|
|
'top_k': True,
|
|
}
|
|
elif provider == 'xai':
|
|
return {
|
|
'temperature': True,
|
|
'max_tokens': True,
|
|
'frequency_penalty': False,
|
|
'presence_penalty': False,
|
|
'top_p': True,
|
|
'top_k': True,
|
|
}
|
|
else:
|
|
# Default: assume all parameters are supported
|
|
return {
|
|
'temperature': True,
|
|
'max_tokens': True,
|
|
'frequency_penalty': True,
|
|
'presence_penalty': True,
|
|
'top_p': True,
|
|
'top_k': True,
|
|
}
|
|
|
|
def _get_oci_client(self) -> GenerativeAiInferenceClient:
|
|
"""Get the OCI GenerativeAiInferenceClient following your working example."""
|
|
if not hasattr(self, '_client'):
|
|
# Configure OCI client based on auth_type (following your working example)
|
|
if self.auth_type == 'API_KEY':
|
|
config = oci.config.from_file('~/.oci/config', self.auth_profile)
|
|
self._client = GenerativeAiInferenceClient(
|
|
config=config,
|
|
service_endpoint=self.service_endpoint,
|
|
retry_strategy=oci.retry.NoneRetryStrategy(),
|
|
timeout=(10, 240), # Following your working example
|
|
)
|
|
elif self.auth_type == 'INSTANCE_PRINCIPAL':
|
|
config = {}
|
|
signer = oci.auth.signers.InstancePrincipalsSecurityTokenSigner()
|
|
self._client = GenerativeAiInferenceClient(
|
|
config=config,
|
|
signer=signer,
|
|
service_endpoint=self.service_endpoint,
|
|
retry_strategy=oci.retry.NoneRetryStrategy(),
|
|
timeout=(10, 240),
|
|
)
|
|
elif self.auth_type == 'RESOURCE_PRINCIPAL':
|
|
config = {}
|
|
signer = oci.auth.signers.get_resource_principals_signer()
|
|
self._client = GenerativeAiInferenceClient(
|
|
config=config,
|
|
signer=signer,
|
|
service_endpoint=self.service_endpoint,
|
|
retry_strategy=oci.retry.NoneRetryStrategy(),
|
|
timeout=(10, 240),
|
|
)
|
|
else:
|
|
# Fallback to API_KEY
|
|
config = oci.config.from_file('~/.oci/config', self.auth_profile)
|
|
self._client = GenerativeAiInferenceClient(
|
|
config=config,
|
|
service_endpoint=self.service_endpoint,
|
|
retry_strategy=oci.retry.NoneRetryStrategy(),
|
|
timeout=(10, 240),
|
|
)
|
|
|
|
return self._client
|
|
|
|
def _extract_usage(self, response) -> ChatInvokeUsage | None:
|
|
"""Extract usage information from OCI response."""
|
|
try:
|
|
# The response is the direct OCI response object, not a dict
|
|
if hasattr(response, 'data') and hasattr(response.data, 'chat_response'):
|
|
chat_response = response.data.chat_response
|
|
if hasattr(chat_response, 'usage'):
|
|
usage = chat_response.usage
|
|
return ChatInvokeUsage(
|
|
prompt_tokens=getattr(usage, 'prompt_tokens', 0),
|
|
prompt_cached_tokens=None,
|
|
prompt_cache_creation_tokens=None,
|
|
prompt_image_tokens=None,
|
|
completion_tokens=getattr(usage, 'completion_tokens', 0),
|
|
total_tokens=getattr(usage, 'total_tokens', 0),
|
|
)
|
|
return None
|
|
except Exception:
|
|
return None
|
|
|
|
def _extract_content(self, response) -> str:
|
|
"""Extract text content from OCI response."""
|
|
try:
|
|
# The response is the direct OCI response object, not a dict
|
|
if not hasattr(response, 'data'):
|
|
raise ModelProviderError(message='Invalid response format: no data attribute', status_code=500, model=self.name)
|
|
|
|
chat_response = response.data.chat_response
|
|
|
|
# Handle different response types based on provider
|
|
if hasattr(chat_response, 'text'):
|
|
# Cohere response format - has direct text attribute
|
|
return chat_response.text or ''
|
|
elif hasattr(chat_response, 'choices') and chat_response.choices:
|
|
# Generic response format - has choices array (Meta, xAI)
|
|
choice = chat_response.choices[0]
|
|
message = choice.message
|
|
content_parts = message.content
|
|
|
|
# Extract text from content parts
|
|
text_parts = []
|
|
for part in content_parts:
|
|
if hasattr(part, 'text'):
|
|
text_parts.append(part.text)
|
|
|
|
return '\n'.join(text_parts) if text_parts else ''
|
|
else:
|
|
raise ModelProviderError(
|
|
message=f'Unsupported response format: {type(chat_response).__name__}', status_code=500, model=self.name
|
|
)
|
|
|
|
except Exception as e:
|
|
raise ModelProviderError(
|
|
message=f'Failed to extract content from response: {str(e)}', status_code=500, model=self.name
|
|
) from e
|
|
|
|
async def _make_request(self, messages: list[BaseMessage]):
|
|
"""Make async request to OCI API using proper OCI SDK models."""
|
|
|
|
# Create chat request based on provider type
|
|
if self._uses_cohere_format():
|
|
# Cohere models use CohereChatRequest with single message string
|
|
message_text = OCIRawMessageSerializer.serialize_messages_for_cohere(messages)
|
|
|
|
chat_request = CohereChatRequest()
|
|
chat_request.message = message_text
|
|
chat_request.max_tokens = self.max_tokens
|
|
chat_request.temperature = self.temperature
|
|
chat_request.frequency_penalty = self.frequency_penalty
|
|
chat_request.top_p = self.top_p
|
|
chat_request.top_k = self.top_k
|
|
else:
|
|
# Meta, xAI and other models use GenericChatRequest with messages array
|
|
oci_messages = OCIRawMessageSerializer.serialize_messages(messages)
|
|
|
|
chat_request = GenericChatRequest()
|
|
chat_request.api_format = BaseChatRequest.API_FORMAT_GENERIC
|
|
chat_request.messages = oci_messages
|
|
chat_request.max_tokens = self.max_tokens
|
|
chat_request.temperature = self.temperature
|
|
chat_request.top_p = self.top_p
|
|
|
|
# Provider-specific parameters
|
|
if self.provider.lower() == 'meta':
|
|
# Meta models support frequency_penalty and presence_penalty
|
|
chat_request.frequency_penalty = self.frequency_penalty
|
|
chat_request.presence_penalty = self.presence_penalty
|
|
elif self.provider.lower() == 'xai':
|
|
# xAI models support top_k but not frequency_penalty or presence_penalty
|
|
chat_request.top_k = self.top_k
|
|
else:
|
|
# Default: include all parameters for unknown providers
|
|
chat_request.frequency_penalty = self.frequency_penalty
|
|
chat_request.presence_penalty = self.presence_penalty
|
|
|
|
# Create serving mode
|
|
serving_mode = OnDemandServingMode(model_id=self.model_id)
|
|
|
|
# Create chat details
|
|
chat_details = ChatDetails()
|
|
chat_details.serving_mode = serving_mode
|
|
chat_details.chat_request = chat_request
|
|
chat_details.compartment_id = self.compartment_id
|
|
|
|
# Make the request in a thread to avoid blocking
|
|
def _sync_request():
|
|
try:
|
|
client = self._get_oci_client()
|
|
response = client.chat(chat_details)
|
|
return response # Return the raw response object
|
|
except Exception as e:
|
|
# Handle OCI-specific exceptions
|
|
status_code = getattr(e, 'status', 500)
|
|
if status_code == 429:
|
|
raise ModelRateLimitError(message=f'Rate limit exceeded: {str(e)}', model=self.name) from e
|
|
else:
|
|
raise ModelProviderError(message=str(e), status_code=status_code, model=self.name) from e
|
|
|
|
# Run in thread pool to make it async
|
|
loop = asyncio.get_event_loop()
|
|
return await loop.run_in_executor(None, _sync_request)
|
|
|
|
@overload
|
|
async def ainvoke(
|
|
self, messages: list[BaseMessage], output_format: None = None, **kwargs: Any
|
|
) -> ChatInvokeCompletion[str]: ...
|
|
|
|
@overload
|
|
async def ainvoke(self, messages: list[BaseMessage], output_format: type[T], **kwargs: Any) -> ChatInvokeCompletion[T]: ...
|
|
|
|
async def ainvoke(
|
|
self, messages: list[BaseMessage], output_format: type[T] | None = None, **kwargs: Any
|
|
) -> ChatInvokeCompletion[T] | ChatInvokeCompletion[str]:
|
|
"""
|
|
Invoke the OCI GenAI model with the given messages using raw API.
|
|
|
|
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:
|
|
if output_format is None:
|
|
# Return string response
|
|
response = await self._make_request(messages)
|
|
content = self._extract_content(response)
|
|
usage = self._extract_usage(response)
|
|
|
|
return ChatInvokeCompletion(
|
|
completion=content,
|
|
usage=usage,
|
|
)
|
|
else:
|
|
# For structured output, add JSON schema instructions
|
|
optimized_schema = SchemaOptimizer.create_optimized_json_schema(output_format)
|
|
|
|
# Add JSON schema instruction to messages
|
|
system_instruction = f"""
|
|
You must respond with ONLY a valid JSON object that matches this exact schema:
|
|
{json.dumps(optimized_schema, indent=2)}
|
|
|
|
IMPORTANT:
|
|
- Your response must be ONLY the JSON object, no additional text
|
|
- The JSON must be valid and parseable
|
|
- All required fields must be present
|
|
- No extra fields are allowed
|
|
- Use proper JSON syntax with double quotes
|
|
"""
|
|
|
|
# Clone messages and add system instruction
|
|
modified_messages = messages.copy()
|
|
|
|
# Add or modify system message
|
|
from browser_use.llm.messages import SystemMessage
|
|
|
|
if modified_messages and hasattr(modified_messages[0], 'role') and modified_messages[0].role == 'system':
|
|
# Modify existing system message
|
|
existing_content = modified_messages[0].content
|
|
if isinstance(existing_content, str):
|
|
modified_messages[0].content = existing_content + '\n\n' + system_instruction
|
|
else:
|
|
# Handle list content
|
|
modified_messages[0].content = str(existing_content) + '\n\n' + system_instruction
|
|
else:
|
|
# Insert new system message at the beginning
|
|
modified_messages.insert(0, SystemMessage(content=system_instruction))
|
|
|
|
response = await self._make_request(modified_messages)
|
|
response_text = self._extract_content(response)
|
|
|
|
# Clean and parse the JSON response
|
|
try:
|
|
# Clean the response text
|
|
cleaned_text = response_text.strip()
|
|
|
|
# Remove markdown code blocks if present
|
|
if cleaned_text.startswith('```json'):
|
|
cleaned_text = cleaned_text[7:]
|
|
if cleaned_text.startswith('```'):
|
|
cleaned_text = cleaned_text[3:]
|
|
if cleaned_text.endswith('```'):
|
|
cleaned_text = cleaned_text[:-3]
|
|
|
|
cleaned_text = cleaned_text.strip()
|
|
|
|
# Try to find JSON object in the response
|
|
if not cleaned_text.startswith('{'):
|
|
start_idx = cleaned_text.find('{')
|
|
end_idx = cleaned_text.rfind('}')
|
|
if start_idx != -1 and end_idx != -1 and end_idx > start_idx:
|
|
cleaned_text = cleaned_text[start_idx : end_idx + 1]
|
|
|
|
# Parse the JSON
|
|
parsed_data = json.loads(cleaned_text)
|
|
parsed = output_format.model_validate(parsed_data)
|
|
|
|
usage = self._extract_usage(response)
|
|
return ChatInvokeCompletion(
|
|
completion=parsed,
|
|
usage=usage,
|
|
)
|
|
|
|
except (json.JSONDecodeError, ValueError) as e:
|
|
raise ModelProviderError(
|
|
message=f'Failed to parse structured output: {str(e)}. Response was: {response_text[:200]}...',
|
|
status_code=500,
|
|
model=self.name,
|
|
) from e
|
|
|
|
except ModelRateLimitError:
|
|
# Re-raise rate limit errors as-is
|
|
raise
|
|
except ModelProviderError:
|
|
# Re-raise provider errors as-is
|
|
raise
|
|
except Exception as e:
|
|
# Handle any other exceptions
|
|
raise ModelProviderError(
|
|
message=f'Unexpected error: {str(e)}',
|
|
status_code=500,
|
|
model=self.name,
|
|
) from e
|