307 lines
13 KiB
Python
307 lines
13 KiB
Python
"""根据配置创建 Browser Use 可用的 LLM 实例;主 Key 余额不足时自动切兜底 Key(见 llm_keys)。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import asyncio
|
||
from dataclasses import dataclass, field
|
||
from typing import Any
|
||
|
||
from backend.browser import llm_keys
|
||
from backend.browser import diagnostics
|
||
from backend.browser import structured_output
|
||
from backend.config import MODELS_BY_PROVIDER, settings
|
||
from backend.db import database as db
|
||
from backend.prospecting import usage as model_usage
|
||
|
||
|
||
def _setting(key: str, env_key: str, default: str = "") -> str:
|
||
return db.get_setting(key) or getattr(settings, key, "") or os.getenv(env_key, default)
|
||
|
||
|
||
def get_llm_config() -> dict:
|
||
provider = _setting("llm_provider", "LLM_PROVIDER", settings.llm_provider)
|
||
model = _setting("llm_model", "LLM_MODEL", settings.llm_model)
|
||
base_url = _setting("llm_base_url", "LLM_BASE_URL", settings.llm_base_url)
|
||
|
||
if provider == "qwen":
|
||
primary = _setting("dashscope_api_key", "DASHSCOPE_API_KEY", settings.dashscope_api_key)
|
||
if not base_url or "deepseek" in base_url:
|
||
base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||
else:
|
||
provider = "deepseek"
|
||
primary = _setting("llm_api_key", "DEEPSEEK_API_KEY", settings.deepseek_api_key)
|
||
if not primary:
|
||
primary = _setting("deepseek_api_key", "DEEPSEEK_API_KEY", settings.deepseek_api_key)
|
||
if not base_url:
|
||
base_url = "https://api.deepseek.com/v1"
|
||
|
||
fallback = llm_keys.fallback_key(provider)
|
||
if fallback == primary:
|
||
fallback = ""
|
||
api_key, using_fallback = llm_keys.choose(provider, primary)
|
||
|
||
return {
|
||
"provider": provider,
|
||
"model": model,
|
||
# 本次应使用的 Key(主 Key 已标记耗尽且有兜底时就是兜底)
|
||
"api_key": api_key,
|
||
"primary_api_key": primary,
|
||
"fallback_api_key": fallback,
|
||
"using_fallback": using_fallback,
|
||
"base_url": base_url,
|
||
"available_models": MODELS_BY_PROVIDER.get(provider, MODELS_BY_PROVIDER["deepseek"]),
|
||
}
|
||
|
||
|
||
def _build_model(cfg: dict, api_key: str):
|
||
if cfg["provider"] == "qwen":
|
||
from browser_use import ChatOpenAI
|
||
|
||
return ObservedChatModel(ChatOpenAI(model=cfg["model"], api_key=api_key, base_url=cfg["base_url"], temperature=0.1), cfg["model"])
|
||
|
||
if cfg["model"] in {"deepseek-flash", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp", "deepseek-v4-pro"}:
|
||
return DeepSeekBrowserChat(model=cfg["model"], api_key=api_key, base_url=cfg["base_url"])
|
||
|
||
from browser_use import ChatDeepSeek
|
||
|
||
return ObservedChatModel(ChatDeepSeek(model=cfg["model"], api_key=api_key, base_url=cfg["base_url"], temperature=0.1), cfg["model"])
|
||
|
||
|
||
@dataclass
|
||
class ObservedChatModel:
|
||
"""Observe legacy adapters without changing their model or request options.
|
||
|
||
Their internal failed validation may not expose provider usage. Those calls
|
||
remain explicitly unknown instead of being reported as free requests.
|
||
"""
|
||
wrapped: Any = field(repr=False)
|
||
model: str
|
||
_verified_api_keys: bool = False
|
||
|
||
@property
|
||
def provider(self) -> str:
|
||
return str(self.wrapped.provider)
|
||
|
||
@property
|
||
def name(self) -> str:
|
||
return str(self.wrapped.name)
|
||
|
||
@property
|
||
def model_name(self) -> str:
|
||
return self.model
|
||
|
||
async def ainvoke(self, messages: list, output_format: Any = None, **kwargs: Any):
|
||
call = model_usage.start_call(self.model)
|
||
call_status, status_code = "connection_error", None
|
||
try:
|
||
result = await self.wrapped.ainvoke(messages, output_format, **kwargs)
|
||
model_usage.record_response(call, getattr(result, "usage", None), model=self.model,
|
||
finish_reason=getattr(result, "stop_reason", None))
|
||
call_status = "succeeded"
|
||
return result
|
||
except asyncio.CancelledError:
|
||
call_status = "cancelled"
|
||
raise
|
||
except Exception as error:
|
||
status_code = getattr(error, "status_code", None)
|
||
if type(status_code) is int:
|
||
call_status = "http_error"
|
||
elif type(error).__name__ == "ModelOutputTruncatedError":
|
||
call_status = "truncated"
|
||
raise
|
||
finally:
|
||
model_usage.finish_call(call, call_status, http_status=status_code)
|
||
|
||
|
||
@dataclass
|
||
class DeepSeekBrowserChat:
|
||
"""Browser-use 0.13.10 adapter for current DeepSeek models.
|
||
|
||
Preserve images, disabled thinking and usage accounting while returning the
|
||
complete step through the provider's forced named-function-call protocol.
|
||
Free text JSON mode does not enforce the required browser action envelope.
|
||
"""
|
||
|
||
model: str
|
||
api_key: str = field(repr=False)
|
||
base_url: str = "https://api.deepseek.com/v1"
|
||
timeout: float = 75.0
|
||
max_tokens: int = 8192
|
||
_verified_api_keys: bool = False
|
||
|
||
@property
|
||
def provider(self) -> str:
|
||
return "deepseek"
|
||
|
||
@property
|
||
def name(self) -> str:
|
||
return self.model
|
||
|
||
@property
|
||
def model_name(self) -> str:
|
||
return self.model
|
||
|
||
async def ainvoke(self, messages: list, output_format: Any = None, **kwargs: Any):
|
||
from openai import APIConnectionError, APIStatusError, AsyncOpenAI, RateLimitError
|
||
from pydantic import ValidationError
|
||
|
||
from browser_use.llm.exceptions import ModelOutputTruncatedError, ModelProviderError, ModelRateLimitError
|
||
from browser_use.llm.openai.serializer import OpenAIMessageSerializer
|
||
from browser_use.llm.views import ChatInvokeCompletion, ChatInvokeUsage
|
||
|
||
prepared = OpenAIMessageSerializer.serialize_messages(messages)
|
||
params: dict[str, Any] = {
|
||
"model": self.model,
|
||
"temperature": 0.1,
|
||
"max_tokens": self.max_tokens,
|
||
"extra_body": {"thinking": {"type": "disabled"}},
|
||
}
|
||
if output_format is not None:
|
||
function, instruction = structured_output.request_contract(output_format)
|
||
# Resolve the framework's raw-JSON examples without duplicating the
|
||
# schema or changing the original conversation's terminal role.
|
||
prepared = structured_output.attach_instruction(prepared, instruction)
|
||
params["tools"] = [function]
|
||
params["tool_choice"] = {"type": "function", "function": {"name": structured_output.FUNCTION_NAME}}
|
||
call = model_usage.start_call(self.model)
|
||
call_status = "connection_error"
|
||
status_code = None
|
||
try:
|
||
if output_format is not None:
|
||
diagnostics.record_request_contract(call, function)
|
||
async with AsyncOpenAI(
|
||
api_key=self.api_key, base_url=self.base_url, timeout=self.timeout, max_retries=0,
|
||
) as client:
|
||
response = await client.chat.completions.create(messages=prepared, **params)
|
||
# Record what the provider actually billed before JSON/schema
|
||
# validation can reject the action and hide its consumed tokens.
|
||
choice = response.choices[0] if response.choices else None
|
||
model_usage.record_response(call, response.usage, model=getattr(response, "model", None) or self.model,
|
||
finish_reason=getattr(choice, "finish_reason", None))
|
||
call_status = "invalid_response"
|
||
if not response.choices:
|
||
raise ModelProviderError("DeepSeek 未返回有效选择。", status_code=502, model=self.model)
|
||
choice = response.choices[0]
|
||
if choice.finish_reason == "length":
|
||
call_status = "truncated"
|
||
raise ModelOutputTruncatedError("DeepSeek 输出超过单步限制,未执行截断动作。", model=self.model)
|
||
if choice.finish_reason not in {"stop", "tool_calls"}:
|
||
raise ModelProviderError("DeepSeek 本次输出未正常完成,未执行动作。", status_code=422, model=self.model)
|
||
if output_format is not None:
|
||
completion = structured_output.validated_tool_result(choice.message, output_format)
|
||
else:
|
||
completion = choice.message.content
|
||
if not isinstance(completion, str) or not completion.strip():
|
||
raise ModelProviderError("DeepSeek 未返回动作内容。", status_code=502, model=self.model)
|
||
usage = None
|
||
if response.usage is not None:
|
||
counters = model_usage.normalize_usage(response.usage)
|
||
usage = ChatInvokeUsage(
|
||
prompt_tokens=response.usage.prompt_tokens,
|
||
prompt_cached_tokens=counters["cache_hit_tokens"],
|
||
prompt_cache_creation_tokens=None,
|
||
prompt_image_tokens=None,
|
||
completion_tokens=response.usage.completion_tokens,
|
||
total_tokens=response.usage.total_tokens,
|
||
)
|
||
call_status = "succeeded"
|
||
diagnostics.validated_response(getattr(call, "phase", "browser"))
|
||
return ChatInvokeCompletion(completion=completion, usage=usage, stop_reason=choice.finish_reason)
|
||
except RateLimitError as error:
|
||
call_status, status_code = "http_error", 429
|
||
raise ModelRateLimitError("DeepSeek 请求限流。", model=self.model) from error
|
||
except APIStatusError as error:
|
||
call_status, status_code = "http_error", error.status_code
|
||
raise ModelProviderError(
|
||
f"Error code: {error.status_code} - DeepSeek 请求失败,请检查 Key、余额和服务状态。",
|
||
status_code=error.status_code, model=self.model,
|
||
) from error
|
||
except APIConnectionError as error:
|
||
call_status = "connection_error"
|
||
raise ModelProviderError("无法连接 DeepSeek,本次请求未自动重试。", model=self.model) from error
|
||
except ValidationError as error:
|
||
call_status = "schema_error"
|
||
diagnostic = diagnostics.schema_error(call, error, output_format)
|
||
raise ModelProviderError("DeepSeek 返回的动作不符合任务结构,未执行。" + diagnostics.correction_hint(diagnostic), status_code=422, model=self.model) from None
|
||
except structured_output.ResponseContractError as error:
|
||
call_status = "schema_error"
|
||
diagnostic = diagnostics.response_contract_error(call, error.reason)
|
||
raise ModelProviderError("DeepSeek 返回的动作不符合任务结构,未执行。" + diagnostics.correction_hint(diagnostic), status_code=422, model=self.model) from None
|
||
except asyncio.CancelledError:
|
||
call_status = "cancelled"
|
||
raise
|
||
finally:
|
||
if call_status != "schema_error":
|
||
diagnostics.validated_response(getattr(call, "phase", "browser"))
|
||
model_usage.finish_call(call, call_status, http_status=status_code)
|
||
|
||
|
||
@dataclass
|
||
class FallbackChatModel:
|
||
"""对 browser-use Agent 表现为普通 BaseChatModel:主 Key 402 时当场切到兜底 Key 重发这一次请求。
|
||
|
||
Agent 构造时拿到的是这个包装对象,所以任务跑到一半余额耗尽也不会失败,只会多一次调用。
|
||
"""
|
||
|
||
primary: Any
|
||
fallback: Any
|
||
model: str
|
||
_verified_api_keys: bool = False
|
||
_active: Any = field(init=False, default=None, repr=False)
|
||
|
||
def __post_init__(self) -> None:
|
||
self._active = self.fallback if llm_keys.primary_exhausted() else self.primary
|
||
|
||
@property
|
||
def provider(self) -> str:
|
||
return str(self._active.provider)
|
||
|
||
@property
|
||
def name(self) -> str:
|
||
return str(self._active.name)
|
||
|
||
@property
|
||
def model_name(self) -> str:
|
||
return self.model
|
||
|
||
@property
|
||
def using_fallback(self) -> bool:
|
||
return self._active is self.fallback
|
||
|
||
async def ainvoke(self, messages: list, output_format: Any = None, **kwargs: Any):
|
||
try:
|
||
result = await self._active.ainvoke(messages, output_format, **kwargs)
|
||
except Exception as error: # noqa: BLE001 browser-use 把所有供应商错误都包成 ModelProviderError
|
||
if self._active is self.primary and llm_keys.is_balance_error(error):
|
||
llm_keys.mark_primary_exhausted(str(error))
|
||
self._active = self.fallback
|
||
return await self._active.ainvoke(messages, output_format, **kwargs)
|
||
raise
|
||
if self._active is self.primary:
|
||
llm_keys.clear_primary_exhausted()
|
||
return result
|
||
|
||
|
||
def create_llm():
|
||
cfg = get_llm_config()
|
||
if not cfg["api_key"]:
|
||
raise RuntimeError(
|
||
"未配置 API Key。DeepSeek 请在 .env 设置 DEEPSEEK_API_KEY,或在设置页保存。"
|
||
)
|
||
if cfg["primary_api_key"] and cfg["fallback_api_key"]:
|
||
return FallbackChatModel(
|
||
primary=_build_model(cfg, cfg["primary_api_key"]),
|
||
fallback=_build_model(cfg, cfg["fallback_api_key"]),
|
||
model=cfg["model"],
|
||
)
|
||
return _build_model(cfg, cfg["api_key"])
|
||
|
||
|
||
def use_vision_for_model(model: str, provider: str) -> bool:
|
||
if provider == "qwen":
|
||
return "vl" in model.lower()
|
||
if provider == "deepseek":
|
||
return model.lower() in {"deepseek-flash", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp"}
|
||
return False
|