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,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]
|
||||
Reference in New Issue
Block a user