mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 16:07:01 +08:00
feat: 支持模型服务端联网搜索
This commit is contained in:
+25
-3
@@ -730,6 +730,7 @@ class MoviePilotAgent:
|
||||
use_proxy=settings.LLM_USE_PROXY,
|
||||
thinking_level=settings.LLM_THINKING_LEVEL,
|
||||
api_protocol=settings.LLM_API_PROTOCOL,
|
||||
web_search_mode=settings.LLM_WEB_SEARCH_MODE,
|
||||
)
|
||||
selected_event = await eventmanager.async_send_event(
|
||||
ChainEventType.AgentLLMProvider,
|
||||
@@ -773,6 +774,9 @@ class MoviePilotAgent:
|
||||
api_protocol = self._clean_optional_text(
|
||||
self._get_event_value(resolved_data, "api_protocol")
|
||||
) or settings.LLM_API_PROTOCOL
|
||||
web_search_mode = self._clean_optional_text(
|
||||
self._get_event_value(resolved_data, "web_search_mode")
|
||||
) or settings.LLM_WEB_SEARCH_MODE
|
||||
selected_provider_id = self._clean_optional_text(
|
||||
self._get_event_value(resolved_data, "selected_provider_id")
|
||||
)
|
||||
@@ -799,6 +803,7 @@ class MoviePilotAgent:
|
||||
"use_proxy": bool(use_proxy),
|
||||
"thinking_level": thinking_level,
|
||||
"api_protocol": api_protocol,
|
||||
"web_search_mode": web_search_mode,
|
||||
}
|
||||
return self._llm_runtime_config
|
||||
|
||||
@@ -1006,6 +1011,13 @@ class MoviePilotAgent:
|
||||
allow_message_tools=self.allow_message_tools,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _filter_local_web_search_tools(tools: List, enabled: bool) -> List:
|
||||
"""按联网搜索策略保留或移除本地 search_web 工具。"""
|
||||
if enabled:
|
||||
return tools
|
||||
return [tool for tool in tools if getattr(tool, "name", None) != "search_web"]
|
||||
|
||||
def _refresh_tool_context(self, values: Dict[str, object]) -> None:
|
||||
"""
|
||||
刷新本轮工具共享上下文。
|
||||
@@ -1035,6 +1047,7 @@ class MoviePilotAgent:
|
||||
bool(runtime_config.get("use_proxy")),
|
||||
runtime_config.get("thinking_level"),
|
||||
runtime_config.get("api_protocol"),
|
||||
runtime_config.get("web_search_mode"),
|
||||
)
|
||||
|
||||
async def _agent_bundle_signature(self, streaming: bool) -> tuple[Any, ...]:
|
||||
@@ -1165,6 +1178,8 @@ class MoviePilotAgent:
|
||||
# LLM 模型(用于 agent 执行)
|
||||
agent_model = await self._initialize_llm(streaming=streaming)
|
||||
self._sync_model_profile(agent_model)
|
||||
server_tools = LLMHelper.get_server_tools(agent_model)
|
||||
use_local_web_search = LLMHelper.should_use_local_web_search(agent_model)
|
||||
|
||||
# 为内部模型调用准备非流式 LLM,避免与用户流式回复复用同一实例。
|
||||
non_streaming_model = (
|
||||
@@ -1174,7 +1189,10 @@ class MoviePilotAgent:
|
||||
)
|
||||
|
||||
# 工具列表
|
||||
tools = self._initialize_tools()
|
||||
tools = self._filter_local_web_search_tools(
|
||||
self._initialize_tools(),
|
||||
enabled=use_local_web_search,
|
||||
)
|
||||
tools.extend(await self._initialize_mcp_tools())
|
||||
skills_middleware = SkillsMiddleware(
|
||||
sources=[str(agent_runtime_manager.skills_dir)],
|
||||
@@ -1192,11 +1210,15 @@ class MoviePilotAgent:
|
||||
activity_log_tools = list(
|
||||
getattr(activity_log_middleware, "tools", []) or []
|
||||
)
|
||||
subagent_tools = self._initialize_subagent_tools()
|
||||
subagent_tools = self._filter_local_web_search_tools(
|
||||
self._initialize_subagent_tools(),
|
||||
enabled=use_local_web_search,
|
||||
)
|
||||
subagent_tools.extend(await self._initialize_subagent_mcp_tools())
|
||||
subagent_middlewares, subagent_task_tools = create_subagent_middlewares(
|
||||
model=non_streaming_model,
|
||||
tools=subagent_tools,
|
||||
server_tools=server_tools,
|
||||
stream_handler=self.stream_handler,
|
||||
)
|
||||
max_tools = settings.LLM_MAX_TOOLS
|
||||
@@ -1271,7 +1293,7 @@ class MoviePilotAgent:
|
||||
|
||||
agent = create_agent(
|
||||
model=agent_model,
|
||||
tools=[*tools, *skill_tools, *activity_log_tools],
|
||||
tools=[*tools, *skill_tools, *activity_log_tools, *server_tools],
|
||||
system_prompt=system_prompt,
|
||||
middleware=middlewares,
|
||||
checkpointer=InMemorySaver(),
|
||||
|
||||
+197
-106
@@ -5,13 +5,16 @@ import inspect
|
||||
import json
|
||||
import time
|
||||
from functools import wraps
|
||||
from typing import Any, List, Optional
|
||||
from typing import TYPE_CHECKING, Any, List, Optional
|
||||
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk
|
||||
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.agent.llm.server_tools import ServerToolResolution
|
||||
|
||||
|
||||
class LLMTestError(RuntimeError):
|
||||
"""LLM 测试调用异常,附带请求耗时。"""
|
||||
@@ -224,74 +227,76 @@ def _is_deepseek_thinking_enabled(model_name: str | None, extra_body: Any) -> bo
|
||||
return False
|
||||
|
||||
|
||||
def _patch_deepseek_reasoning_content_support():
|
||||
"""
|
||||
修补 langchain-deepseek 在 tool-call 场景下遗漏 reasoning_content 回传的问题。
|
||||
|
||||
DeepSeek thinking mode 要求:若 assistant 历史消息包含 tool_calls,
|
||||
后续请求中必须带回该条消息的顶层 reasoning_content。
|
||||
某些 langchain-deepseek 版本虽然能从响应中拿到 reasoning_content,
|
||||
但不会在重放消息历史时写回请求载荷,导致 400。
|
||||
"""
|
||||
try:
|
||||
from langchain_deepseek import ChatDeepSeek
|
||||
except Exception as err:
|
||||
logger.debug(f"跳过 langchain-deepseek reasoning_content 修补:{err}")
|
||||
def _patch_interleaved_reasoning_request_support(
|
||||
model_cls: Any,
|
||||
*,
|
||||
patch_marker: str,
|
||||
thinking_filter: Any = None,
|
||||
normalize_deepseek_messages: bool = False,
|
||||
inject_missing_as_empty: bool = False,
|
||||
) -> None:
|
||||
"""为兼容模型统一补回工具调用历史中的 reasoning_content。"""
|
||||
if getattr(model_cls, patch_marker, False):
|
||||
return
|
||||
|
||||
if getattr(ChatDeepSeek, "_moviepilot_reasoning_content_patched", False):
|
||||
return
|
||||
|
||||
original_get_request_payload = getattr(ChatDeepSeek, "_get_request_payload", None)
|
||||
original_get_request_payload = getattr(model_cls, "_get_request_payload", None)
|
||||
if not callable(original_get_request_payload):
|
||||
logger.warning("langchain-deepseek 缺少 _get_request_payload,无法修补 reasoning_content")
|
||||
logger.warning(
|
||||
f"{model_cls.__name__} 缺少 _get_request_payload,无法修补 reasoning_content"
|
||||
)
|
||||
return
|
||||
|
||||
@wraps(original_get_request_payload)
|
||||
def _patched_get_request_payload(self, input_, *, stop=None, **kwargs):
|
||||
payload = original_get_request_payload(self, input_, stop=stop, **kwargs)
|
||||
if "messages" not in payload:
|
||||
return payload
|
||||
|
||||
extra_body = (getattr(self, "model_kwargs", None) or {}).get("extra_body")
|
||||
if not _is_deepseek_thinking_enabled(
|
||||
extra_body = getattr(self, "extra_body", None)
|
||||
if extra_body is None:
|
||||
extra_body = (getattr(self, "model_kwargs", None) or {}).get("extra_body")
|
||||
if thinking_filter is not None and not thinking_filter(
|
||||
getattr(self, "model_name", None) or getattr(self, "model", None),
|
||||
extra_body,
|
||||
):
|
||||
return payload
|
||||
|
||||
# 从原始 LangChain 消息中取回 reasoning_content。上游 payload 构造器
|
||||
# 不会自动透传这个 DeepSeek 扩展字段。
|
||||
messages = self._convert_input(input_).to_messages()
|
||||
|
||||
for i, message in enumerate(payload["messages"]):
|
||||
if message["role"] == "tool" and isinstance(message["content"], list):
|
||||
message["content"] = json.dumps(message["content"])
|
||||
elif message["role"] == "assistant":
|
||||
if isinstance(message["content"], list):
|
||||
# DeepSeek API 要求 assistant content 为字符串;工具场景下
|
||||
# LangChain 可能保留为内容块列表,这里只拼回可见文本块。
|
||||
text_parts = [
|
||||
block.get("text", "")
|
||||
for block in message["content"]
|
||||
if isinstance(block, dict) and block.get("type") == "text"
|
||||
]
|
||||
message["content"] = "".join(text_parts) if text_parts else ""
|
||||
|
||||
# DeepSeek thinking mode 要求历史 assistant 消息携带
|
||||
# reasoning_content,即便本地只保存到了 additional_kwargs。
|
||||
if (
|
||||
"reasoning_content" not in message
|
||||
and i < len(messages)
|
||||
and isinstance(messages[i], AIMessage)
|
||||
for index, payload_message in enumerate(payload["messages"]):
|
||||
if normalize_deepseek_messages:
|
||||
if payload_message.get("role") == "tool" and isinstance(
|
||||
payload_message.get("content"), list
|
||||
):
|
||||
message["reasoning_content"] = messages[i].additional_kwargs.get(
|
||||
"reasoning_content", ""
|
||||
payload_message["content"] = json.dumps(payload_message["content"])
|
||||
elif payload_message.get("role") == "assistant" and isinstance(
|
||||
payload_message.get("content"), list
|
||||
):
|
||||
payload_message["content"] = "".join(
|
||||
block.get("text", "")
|
||||
for block in payload_message["content"]
|
||||
if isinstance(block, dict) and block.get("type") == "text"
|
||||
)
|
||||
|
||||
if (
|
||||
payload_message.get("role") != "assistant"
|
||||
or index >= len(messages)
|
||||
or not isinstance(messages[index], AIMessage)
|
||||
or "reasoning_content" in payload_message
|
||||
):
|
||||
continue
|
||||
|
||||
reasoning_content = messages[index].additional_kwargs.get(
|
||||
"reasoning_content"
|
||||
)
|
||||
if reasoning_content is not None:
|
||||
payload_message["reasoning_content"] = reasoning_content
|
||||
elif inject_missing_as_empty:
|
||||
payload_message["reasoning_content"] = ""
|
||||
|
||||
return payload
|
||||
|
||||
ChatDeepSeek._get_request_payload = _patched_get_request_payload
|
||||
ChatDeepSeek._moviepilot_reasoning_content_patched = True
|
||||
logger.debug("已修补 langchain-deepseek thinking tool-call 的 reasoning_content 回传兼容性")
|
||||
model_cls._get_request_payload = _patched_get_request_payload
|
||||
setattr(model_cls, patch_marker, True)
|
||||
|
||||
|
||||
def _patch_openai_interleaved_reasoning_content_support():
|
||||
@@ -352,42 +357,10 @@ def _patch_openai_interleaved_reasoning_content_support():
|
||||
|
||||
_openai_base._moviepilot_reasoning_response_patched = True
|
||||
|
||||
if getattr(ChatOpenAI, "_moviepilot_interleaved_reasoning_patched", False):
|
||||
return
|
||||
|
||||
original_get_request_payload = getattr(ChatOpenAI, "_get_request_payload", None)
|
||||
if not callable(original_get_request_payload):
|
||||
logger.warning("langchain-openai 缺少 _get_request_payload,无法修补 reasoning_content")
|
||||
return
|
||||
|
||||
@wraps(original_get_request_payload)
|
||||
def _patched_get_request_payload(self, input_, *, stop=None, **kwargs):
|
||||
payload = original_get_request_payload(self, input_, stop=stop, **kwargs)
|
||||
if "messages" not in payload:
|
||||
return payload
|
||||
|
||||
messages = self._convert_input(input_).to_messages()
|
||||
for index, payload_message in enumerate(payload["messages"]):
|
||||
if (
|
||||
payload_message.get("role") != "assistant"
|
||||
or index >= len(messages)
|
||||
or not isinstance(messages[index], AIMessage)
|
||||
or "reasoning_content" in payload_message
|
||||
):
|
||||
continue
|
||||
|
||||
reasoning_content = messages[index].additional_kwargs.get(
|
||||
"reasoning_content"
|
||||
)
|
||||
if reasoning_content is not None:
|
||||
# 只回传模型真实返回过的思考字段。普通模型没有该字段时,
|
||||
# payload 保持原样,不额外塞未知参数。
|
||||
payload_message["reasoning_content"] = reasoning_content
|
||||
|
||||
return payload
|
||||
|
||||
ChatOpenAI._get_request_payload = _patched_get_request_payload
|
||||
ChatOpenAI._moviepilot_interleaved_reasoning_patched = True
|
||||
_patch_interleaved_reasoning_request_support(
|
||||
ChatOpenAI,
|
||||
patch_marker="_moviepilot_interleaved_reasoning_patched",
|
||||
)
|
||||
logger.debug("已修补 langchain-openai interleaved reasoning_content 回传兼容性")
|
||||
|
||||
|
||||
@@ -931,6 +904,36 @@ class LLMHelper:
|
||||
profile["moviepilot_provider_id"] = runtime_metadata["provider_id"]
|
||||
profile["moviepilot_base_url"] = runtime_metadata["base_url"]
|
||||
|
||||
@staticmethod
|
||||
def _attach_server_tool_metadata(
|
||||
model: Any,
|
||||
resolution: "ServerToolResolution",
|
||||
) -> None:
|
||||
"""把服务端工具解析结果挂到模型实例,供 Agent 组装工具列表。"""
|
||||
metadata = {
|
||||
"mode": resolution.mode,
|
||||
"use_local_web_search": resolution.use_local_web_search,
|
||||
"server_tools": [dict(tool) for tool in resolution.server_tools],
|
||||
"available": resolution.available,
|
||||
"reason": resolution.reason,
|
||||
}
|
||||
try:
|
||||
setattr(model, "_moviepilot_server_tool_metadata", metadata)
|
||||
except Exception:
|
||||
object.__setattr__(model, "_moviepilot_server_tool_metadata", metadata)
|
||||
|
||||
@staticmethod
|
||||
def get_server_tools(model: Any) -> list[dict[str, Any]]:
|
||||
"""读取模型已解析的服务端工具定义。"""
|
||||
metadata = getattr(model, "_moviepilot_server_tool_metadata", {}) or {}
|
||||
return [dict(tool) for tool in metadata.get("server_tools", [])]
|
||||
|
||||
@staticmethod
|
||||
def should_use_local_web_search(model: Any) -> bool:
|
||||
"""判断当前模型是否应保留 MoviePilot 本地联网搜索工具。"""
|
||||
metadata = getattr(model, "_moviepilot_server_tool_metadata", {}) or {}
|
||||
return bool(metadata.get("use_local_web_search", True))
|
||||
|
||||
@classmethod
|
||||
def _resolve_thinking_level(
|
||||
cls,
|
||||
@@ -979,6 +982,7 @@ class LLMHelper:
|
||||
temperature: Optional[float] = None,
|
||||
use_proxy: bool | None = None,
|
||||
api_protocol: str | None = None,
|
||||
web_search_mode: str | None = None,
|
||||
):
|
||||
"""
|
||||
获取LLM实例
|
||||
@@ -999,6 +1003,9 @@ class LLMHelper:
|
||||
(auto/chat_completions/responses)。未显式传入时使用配置项 LLM_API_PROTOCOL。
|
||||
仅对 OpenAI 兼容运行时生效;``responses`` 强制走 Responses API,
|
||||
``chat_completions`` 强制走 Chat Completions,``auto`` 保持原有自动判断。
|
||||
:param web_search_mode: 联网搜索模式
|
||||
(local/builtin/auto/disabled)。未显式传入时使用配置项
|
||||
``LLM_WEB_SEARCH_MODE``。
|
||||
:return: LLM实例
|
||||
"""
|
||||
provider_name = str(provider if provider is not None else settings.LLM_PROVIDER).lower()
|
||||
@@ -1037,6 +1044,40 @@ class LLMHelper:
|
||||
user_agent=user_agent_value,
|
||||
)
|
||||
model_name = runtime.get("model_id") or model_name
|
||||
from app.agent.llm.server_tools import (
|
||||
ServerToolRegistry,
|
||||
ServerToolUnavailableError,
|
||||
)
|
||||
|
||||
server_tool_resolution = ServerToolRegistry.resolve_web_search(
|
||||
provider=provider_name,
|
||||
model=model_name,
|
||||
mode=(
|
||||
web_search_mode
|
||||
if web_search_mode is not None
|
||||
else getattr(settings, "LLM_WEB_SEARCH_MODE", "local")
|
||||
),
|
||||
api_protocol=(
|
||||
api_protocol
|
||||
if api_protocol is not None
|
||||
else settings.LLM_API_PROTOCOL
|
||||
),
|
||||
base_url=runtime.get("base_url"),
|
||||
)
|
||||
if (
|
||||
server_tool_resolution.mode == "builtin"
|
||||
and not server_tool_resolution.available
|
||||
):
|
||||
raise ServerToolUnavailableError(
|
||||
provider=provider_name,
|
||||
model=str(model_name or ""),
|
||||
tool_id="web_search",
|
||||
)
|
||||
effective_api_protocol = (
|
||||
server_tool_resolution.required_api_protocol
|
||||
if server_tool_resolution.required_api_protocol == "responses"
|
||||
else api_protocol
|
||||
)
|
||||
default_headers = cls._build_openai_default_headers(
|
||||
runtime.get("default_headers"),
|
||||
user_agent=user_agent_value,
|
||||
@@ -1050,7 +1091,7 @@ class LLMHelper:
|
||||
provider=provider_name,
|
||||
model=model_name,
|
||||
runtime=runtime,
|
||||
api_protocol=api_protocol,
|
||||
api_protocol=effective_api_protocol,
|
||||
)
|
||||
llm_proxy = _resolve_llm_proxy(use_proxy)
|
||||
|
||||
@@ -1072,10 +1113,22 @@ class LLMHelper:
|
||||
client_args=_build_google_client_args(llm_proxy),
|
||||
**thinking_kwargs,
|
||||
)
|
||||
elif runtime["runtime"] == "deepseek":
|
||||
elif (
|
||||
runtime["runtime"] == "deepseek"
|
||||
and server_tool_resolution.client_adapter != "openai_responses"
|
||||
and use_responses_api is not True
|
||||
):
|
||||
from langchain_deepseek import ChatDeepSeek
|
||||
|
||||
_patch_deepseek_reasoning_content_support()
|
||||
_patch_interleaved_reasoning_request_support(
|
||||
ChatDeepSeek,
|
||||
patch_marker="_moviepilot_reasoning_content_patched",
|
||||
thinking_filter=lambda model_name, extra_body: (
|
||||
_is_deepseek_thinking_enabled(model_name, extra_body)
|
||||
),
|
||||
normalize_deepseek_messages=True,
|
||||
inject_missing_as_empty=True,
|
||||
)
|
||||
model = ChatDeepSeek(
|
||||
model=model_name,
|
||||
api_key=runtime["api_key"],
|
||||
@@ -1154,6 +1207,7 @@ class LLMHelper:
|
||||
),
|
||||
default_headers=default_headers,
|
||||
use_responses_api=use_responses_api,
|
||||
output_version=("responses/v1" if use_responses_api else None),
|
||||
**thinking_kwargs,
|
||||
)
|
||||
|
||||
@@ -1181,6 +1235,7 @@ class LLMHelper:
|
||||
}
|
||||
|
||||
cls._attach_runtime_metadata(model, runtime)
|
||||
cls._attach_server_tool_metadata(model, server_tool_resolution)
|
||||
return model
|
||||
|
||||
@staticmethod
|
||||
@@ -1241,12 +1296,14 @@ class LLMHelper:
|
||||
temperature: Optional[float] = None,
|
||||
use_proxy: bool | None = None,
|
||||
api_protocol: str | None = None,
|
||||
web_search_mode: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
使用当前配置或显式传入的临时配置执行一次最小 LLM 调用。
|
||||
|
||||
:param temperature: LLM 温度参数。未显式传入时沿用已保存配置。
|
||||
:param api_protocol: OpenAI 兼容接口 API 协议,未显式传入时沿用已保存配置。
|
||||
:param web_search_mode: 联网搜索模式,未显式传入时沿用已保存配置。
|
||||
"""
|
||||
provider_name = provider if provider is not None else settings.LLM_PROVIDER
|
||||
model_name = model if model is not None else settings.LLM_MODEL
|
||||
@@ -1262,6 +1319,7 @@ class LLMHelper:
|
||||
"user_agent": user_agent,
|
||||
"use_proxy": use_proxy,
|
||||
"api_protocol": api_protocol,
|
||||
"web_search_mode": web_search_mode,
|
||||
}
|
||||
if temperature is not None:
|
||||
llm_kwargs["temperature"] = temperature
|
||||
@@ -1310,7 +1368,7 @@ class LLMHelper:
|
||||
try:
|
||||
from app.agent.llm.provider import LLMProviderManager
|
||||
|
||||
return await LLMProviderManager().list_models(
|
||||
models = await LLMProviderManager().list_models(
|
||||
provider_id=provider,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
@@ -1319,16 +1377,25 @@ class LLMHelper:
|
||||
use_proxy=use_proxy,
|
||||
force_refresh=force_refresh,
|
||||
)
|
||||
return self._attach_server_tool_capabilities(
|
||||
provider,
|
||||
models,
|
||||
base_url=base_url,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.debug(f"LLM provider 目录不可用,回退旧模型列表逻辑: {err}")
|
||||
if provider == "google":
|
||||
return [
|
||||
{"id": model_id, "name": model_id}
|
||||
for model_id in await self._get_google_models(
|
||||
api_key or "",
|
||||
use_proxy=use_proxy,
|
||||
)
|
||||
]
|
||||
return self._attach_server_tool_capabilities(
|
||||
provider,
|
||||
[
|
||||
{"id": model_id, "name": model_id}
|
||||
for model_id in await self._get_google_models(
|
||||
api_key or "",
|
||||
use_proxy=use_proxy,
|
||||
)
|
||||
],
|
||||
base_url=base_url,
|
||||
)
|
||||
try:
|
||||
from app.agent.llm.provider import LLMProviderManager
|
||||
|
||||
@@ -1342,16 +1409,40 @@ class LLMHelper:
|
||||
)
|
||||
except Exception:
|
||||
model_list_base_url = base_url
|
||||
return [
|
||||
{"id": model_id, "name": model_id}
|
||||
for model_id in await self._get_openai_compatible_models(
|
||||
provider,
|
||||
api_key or "",
|
||||
model_list_base_url,
|
||||
user_agent=user_agent,
|
||||
use_proxy=use_proxy,
|
||||
)
|
||||
]
|
||||
return self._attach_server_tool_capabilities(
|
||||
provider,
|
||||
[
|
||||
{"id": model_id, "name": model_id}
|
||||
for model_id in await self._get_openai_compatible_models(
|
||||
provider,
|
||||
api_key or "",
|
||||
model_list_base_url,
|
||||
user_agent=user_agent,
|
||||
use_proxy=use_proxy,
|
||||
)
|
||||
],
|
||||
base_url=base_url,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _attach_server_tool_capabilities(
|
||||
provider: str,
|
||||
models: List[dict[str, Any]],
|
||||
base_url: Optional[str] = None,
|
||||
) -> List[dict[str, Any]]:
|
||||
"""为模型目录附加通用服务端工具能力元数据。"""
|
||||
from app.agent.llm.server_tools import ServerToolRegistry
|
||||
|
||||
result = []
|
||||
for item in models:
|
||||
model_item = dict(item)
|
||||
model_item["server_tools"] = ServerToolRegistry.list_capabilities(
|
||||
provider=provider,
|
||||
model=str(model_item.get("id") or ""),
|
||||
base_url=base_url,
|
||||
)
|
||||
result.append(model_item)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def _get_google_models(api_key: str, use_proxy: bool | None = None) -> List[str]:
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""LLM 服务端工具能力注册与解析。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from fnmatch import fnmatch
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
WEB_SEARCH_MODES = frozenset({"local", "builtin", "auto", "disabled"})
|
||||
|
||||
|
||||
class ServerToolUnavailableError(ValueError):
|
||||
"""表示用户强制选择了当前模型不可用的服务端工具。"""
|
||||
|
||||
def __init__(self, *, provider: str, model: str, tool_id: str) -> None:
|
||||
"""初始化服务端工具不可用异常。"""
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
self.tool_id = tool_id
|
||||
super().__init__(
|
||||
f"当前模型 {provider}/{model} 或接口地址不支持服务端联网搜索,"
|
||||
"请改用“自动”或“MoviePilot 本地搜索”"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServerToolCapability:
|
||||
"""描述一个模型可用的服务端工具能力。"""
|
||||
|
||||
tool_id: str
|
||||
provider_ids: tuple[str, ...]
|
||||
model_patterns: tuple[str, ...]
|
||||
required_api_protocol: str
|
||||
client_adapter: str
|
||||
tool_definition: dict[str, Any]
|
||||
base_url_patterns: tuple[str, ...] = ()
|
||||
match_without_base_url: bool = True
|
||||
|
||||
def matches(self, provider: str, model: str, base_url: Optional[str] = None) -> bool:
|
||||
"""判断给定 provider/model 是否匹配当前能力。"""
|
||||
normalized_provider = str(provider or "").strip().lower()
|
||||
normalized_model = str(model or "").strip().lower().removeprefix("models/")
|
||||
normalized_base_url = str(base_url or "").strip().lower()
|
||||
return (
|
||||
normalized_provider in self.provider_ids
|
||||
and any(fnmatch(normalized_model, pattern) for pattern in self.model_patterns)
|
||||
and (
|
||||
(not normalized_base_url and self.match_without_base_url)
|
||||
or not self.base_url_patterns
|
||||
or any(
|
||||
pattern in normalized_base_url
|
||||
for pattern in self.base_url_patterns
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def serialize(self) -> dict[str, Any]:
|
||||
"""返回供 API 与前端使用的能力元数据。"""
|
||||
return {
|
||||
"id": self.tool_id,
|
||||
"required_api_protocol": self.required_api_protocol,
|
||||
"client_adapter": self.client_adapter,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServerToolResolution:
|
||||
"""记录本次联网搜索模式解析后的执行策略。"""
|
||||
|
||||
mode: str
|
||||
use_local_web_search: bool
|
||||
server_tools: tuple[dict[str, Any], ...] = ()
|
||||
client_adapter: Optional[str] = None
|
||||
required_api_protocol: Optional[str] = None
|
||||
available: bool = False
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
class ServerToolRegistry:
|
||||
"""集中注册模型服务端工具,并解析通用执行策略。"""
|
||||
|
||||
_CAPABILITIES = (
|
||||
ServerToolCapability(
|
||||
tool_id="web_search",
|
||||
provider_ids=("chatgpt",),
|
||||
model_patterns=("gpt-5*", "gpt-4.1*", "o4-mini*"),
|
||||
base_url_patterns=("api.openai.com",),
|
||||
required_api_protocol="responses",
|
||||
client_adapter="openai_responses",
|
||||
tool_definition={"type": "web_search"},
|
||||
),
|
||||
ServerToolCapability(
|
||||
tool_id="web_search",
|
||||
provider_ids=("openai",),
|
||||
model_patterns=("gpt-5*", "gpt-4.1*", "o4-mini*"),
|
||||
base_url_patterns=("api.openai.com",),
|
||||
required_api_protocol="responses",
|
||||
client_adapter="openai_responses",
|
||||
tool_definition={"type": "web_search"},
|
||||
match_without_base_url=False,
|
||||
),
|
||||
ServerToolCapability(
|
||||
tool_id="web_search",
|
||||
provider_ids=("anthropic",),
|
||||
model_patterns=(
|
||||
"claude-opus-4*",
|
||||
"claude-sonnet-4*",
|
||||
"claude-haiku-4*",
|
||||
"claude-opus-5*",
|
||||
"claude-sonnet-5*",
|
||||
"claude-haiku-5*",
|
||||
"claude-fable-5*",
|
||||
"claude-mythos-5*",
|
||||
),
|
||||
base_url_patterns=("api.anthropic.com",),
|
||||
required_api_protocol="native",
|
||||
client_adapter="anthropic_native",
|
||||
tool_definition={
|
||||
"type": "web_search_20250305",
|
||||
"name": "web_search",
|
||||
},
|
||||
),
|
||||
ServerToolCapability(
|
||||
tool_id="web_search",
|
||||
provider_ids=("google",),
|
||||
model_patterns=("gemini-3*", "gemini-2.5*", "gemini-2.0-flash*"),
|
||||
required_api_protocol="native",
|
||||
client_adapter="google_native",
|
||||
tool_definition={"google_search": {}},
|
||||
),
|
||||
ServerToolCapability(
|
||||
tool_id="web_search",
|
||||
provider_ids=("xai",),
|
||||
model_patterns=("grok-4.5*",),
|
||||
base_url_patterns=("api.x.ai",),
|
||||
required_api_protocol="responses",
|
||||
client_adapter="openai_responses",
|
||||
tool_definition={"type": "web_search"},
|
||||
),
|
||||
ServerToolCapability(
|
||||
tool_id="web_search",
|
||||
provider_ids=("deepseek",),
|
||||
model_patterns=("deepseek-v4-flash",),
|
||||
base_url_patterns=("api.deepseek.com",),
|
||||
required_api_protocol="responses",
|
||||
client_adapter="openai_responses",
|
||||
tool_definition={"type": "web_search"},
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def normalize_web_search_mode(cls, mode: Optional[str]) -> str:
|
||||
"""规范化联网搜索模式,未知值回退为本地搜索。"""
|
||||
normalized = str(mode or "local").strip().lower()
|
||||
return normalized if normalized in WEB_SEARCH_MODES else "local"
|
||||
|
||||
@classmethod
|
||||
def get_capability(
|
||||
cls,
|
||||
*,
|
||||
provider: str,
|
||||
model: str,
|
||||
base_url: Optional[str] = None,
|
||||
tool_id: str,
|
||||
) -> Optional[ServerToolCapability]:
|
||||
"""查找指定模型的服务端工具能力。"""
|
||||
return next(
|
||||
(
|
||||
capability
|
||||
for capability in cls._CAPABILITIES
|
||||
if capability.tool_id == tool_id
|
||||
and capability.matches(provider, model, base_url)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def list_capabilities(
|
||||
cls,
|
||||
*,
|
||||
provider: str,
|
||||
model: str,
|
||||
base_url: Optional[str] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""列出指定模型可用的服务端工具能力。"""
|
||||
return [
|
||||
capability.serialize()
|
||||
for capability in cls._CAPABILITIES
|
||||
if capability.matches(provider, model, base_url)
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def resolve_web_search(
|
||||
cls,
|
||||
*,
|
||||
provider: str,
|
||||
model: str,
|
||||
mode: Optional[str],
|
||||
api_protocol: Optional[str],
|
||||
base_url: Optional[str] = None,
|
||||
) -> ServerToolResolution:
|
||||
"""解析联网搜索应使用本地工具还是模型服务端工具。"""
|
||||
normalized_mode = cls.normalize_web_search_mode(mode)
|
||||
normalized_protocol = str(api_protocol or "auto").strip().lower()
|
||||
capability = cls.get_capability(
|
||||
provider=provider,
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
tool_id="web_search",
|
||||
)
|
||||
|
||||
if normalized_mode == "disabled":
|
||||
return ServerToolResolution(
|
||||
mode=normalized_mode,
|
||||
use_local_web_search=False,
|
||||
reason="web_search_disabled",
|
||||
)
|
||||
if normalized_mode == "local":
|
||||
return ServerToolResolution(
|
||||
mode=normalized_mode,
|
||||
use_local_web_search=True,
|
||||
reason="local_web_search_selected",
|
||||
)
|
||||
if capability is None:
|
||||
return ServerToolResolution(
|
||||
mode=normalized_mode,
|
||||
use_local_web_search=normalized_mode == "auto",
|
||||
reason="builtin_web_search_unavailable",
|
||||
)
|
||||
if (
|
||||
normalized_mode == "auto"
|
||||
and normalized_protocol == "chat_completions"
|
||||
and capability.required_api_protocol == "responses"
|
||||
):
|
||||
return ServerToolResolution(
|
||||
mode=normalized_mode,
|
||||
use_local_web_search=True,
|
||||
available=True,
|
||||
reason="chat_completions_uses_local_fallback",
|
||||
)
|
||||
|
||||
return ServerToolResolution(
|
||||
mode=normalized_mode,
|
||||
use_local_web_search=False,
|
||||
server_tools=(dict(capability.tool_definition),),
|
||||
client_adapter=capability.client_adapter,
|
||||
required_api_protocol=capability.required_api_protocol,
|
||||
available=True,
|
||||
reason="builtin_web_search_selected",
|
||||
)
|
||||
@@ -377,11 +377,13 @@ class _SubAgentAgentProvider:
|
||||
model: BaseChatModel,
|
||||
profiles: tuple[_SubAgentProfile, ...],
|
||||
tools: list[BaseTool],
|
||||
server_tools: Optional[list[dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
"""初始化子代理执行器。"""
|
||||
self._model = model
|
||||
self._profiles = {profile.name: profile for profile in profiles}
|
||||
self._tools = tools
|
||||
self._server_tools = server_tools or []
|
||||
self._agents = {}
|
||||
self._default_agent_name = "general-purpose"
|
||||
|
||||
@@ -404,7 +406,7 @@ class _SubAgentAgentProvider:
|
||||
)
|
||||
agent = create_agent(
|
||||
model=self._model,
|
||||
tools=subagent_tools,
|
||||
tools=[*subagent_tools, *self._server_tools],
|
||||
system_prompt=profile.prompt,
|
||||
name=profile.name,
|
||||
)
|
||||
@@ -462,16 +464,19 @@ class MoviePilotSubAgentMiddleware(AgentMiddleware):
|
||||
model: BaseChatModel,
|
||||
profiles: tuple[_SubAgentProfile, ...],
|
||||
tools: list[BaseTool],
|
||||
server_tools: Optional[list[dict[str, Any]]] = None,
|
||||
system_prompt: str = SUBAGENT_PARENT_PROMPT,
|
||||
task_description: str = SUBAGENT_TASK_DESCRIPTION,
|
||||
stream_handler: Any = None,
|
||||
) -> None:
|
||||
"""初始化同步子代理中间件。"""
|
||||
self.system_prompt = system_prompt
|
||||
self.stream_handler = stream_handler
|
||||
self._provider = _SubAgentAgentProvider(
|
||||
model=model,
|
||||
profiles=profiles,
|
||||
tools=tools,
|
||||
server_tools=server_tools,
|
||||
)
|
||||
self.tools = [
|
||||
StructuredTool.from_function(
|
||||
@@ -549,6 +554,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
|
||||
model: BaseChatModel,
|
||||
profiles: tuple[_SubAgentProfile, ...],
|
||||
tools: list[BaseTool],
|
||||
server_tools: Optional[list[dict[str, Any]]] = None,
|
||||
task_description: str = SUBAGENT_CONTROL_DESCRIPTION,
|
||||
stream_handler: Any = None,
|
||||
) -> None:
|
||||
@@ -558,6 +564,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
|
||||
model=model,
|
||||
profiles=profiles,
|
||||
tools=tools,
|
||||
server_tools=server_tools,
|
||||
)
|
||||
self._semaphore = asyncio.Semaphore(SUBAGENT_MAX_CONCURRENT_TASKS)
|
||||
self._tasks: dict[str, _SubAgentRuntimeTask] = {}
|
||||
@@ -1111,6 +1118,7 @@ def create_subagent_middlewares(
|
||||
*,
|
||||
model: BaseChatModel,
|
||||
tools: list[BaseTool],
|
||||
server_tools: Optional[list[dict[str, Any]]] = None,
|
||||
stream_handler: Any = None,
|
||||
) -> tuple[list[AgentMiddleware], list[BaseTool]]:
|
||||
"""创建子代理中间件列表和任务工具列表。"""
|
||||
@@ -1120,12 +1128,14 @@ def create_subagent_middlewares(
|
||||
model=model,
|
||||
profiles=profiles,
|
||||
tools=tools,
|
||||
server_tools=server_tools or [],
|
||||
stream_handler=stream_handler,
|
||||
)
|
||||
control_middleware = SubAgentTaskControlMiddleware(
|
||||
model=model,
|
||||
profiles=profiles,
|
||||
tools=tools,
|
||||
server_tools=server_tools or [],
|
||||
stream_handler=stream_handler,
|
||||
)
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ class LlmTestRequest(BaseModel):
|
||||
temperature: Optional[float] = None
|
||||
use_proxy: Optional[bool] = None
|
||||
api_protocol: Optional[str] = None
|
||||
web_search_mode: Optional[str] = None
|
||||
|
||||
|
||||
class LlmProviderAuthStartRequest(BaseModel):
|
||||
@@ -271,6 +272,7 @@ async def llm_test(
|
||||
user_agent=settings.LLM_USER_AGENT,
|
||||
use_proxy=settings.LLM_USE_PROXY,
|
||||
api_protocol=settings.LLM_API_PROTOCOL,
|
||||
web_search_mode=settings.LLM_WEB_SEARCH_MODE,
|
||||
)
|
||||
|
||||
if not payload.provider:
|
||||
@@ -305,6 +307,7 @@ async def llm_test(
|
||||
"user_agent": payload.user_agent,
|
||||
"use_proxy": payload.use_proxy,
|
||||
"api_protocol": payload.api_protocol,
|
||||
"web_search_mode": payload.web_search_mode,
|
||||
}
|
||||
if payload.temperature is not None:
|
||||
test_kwargs["temperature"] = payload.temperature
|
||||
|
||||
@@ -79,6 +79,47 @@ _PLUGIN_MARKET_REPO_PATTERN = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def _validate_llm_server_tool_config(env: dict) -> Optional[str]:
|
||||
"""校验强制服务端联网搜索配置,返回用户可读错误信息。"""
|
||||
from app.agent.llm.server_tools import (
|
||||
ServerToolRegistry,
|
||||
ServerToolUnavailableError,
|
||||
)
|
||||
|
||||
mode = ServerToolRegistry.normalize_web_search_mode(
|
||||
env.get(
|
||||
"LLM_WEB_SEARCH_MODE",
|
||||
getattr(settings, "LLM_WEB_SEARCH_MODE", "local"),
|
||||
)
|
||||
)
|
||||
if mode != "builtin":
|
||||
return None
|
||||
|
||||
provider = str(
|
||||
env.get("LLM_PROVIDER", getattr(settings, "LLM_PROVIDER", "")) or ""
|
||||
).strip()
|
||||
model = str(
|
||||
env.get("LLM_MODEL", getattr(settings, "LLM_MODEL", "")) or ""
|
||||
).strip()
|
||||
base_url = env.get("LLM_BASE_URL", getattr(settings, "LLM_BASE_URL", None))
|
||||
capability = ServerToolRegistry.get_capability(
|
||||
provider=provider,
|
||||
model=model,
|
||||
base_url=str(base_url or "").strip() or None,
|
||||
tool_id="web_search",
|
||||
)
|
||||
if capability:
|
||||
return None
|
||||
|
||||
return str(
|
||||
ServerToolUnavailableError(
|
||||
provider=provider,
|
||||
model=model,
|
||||
tool_id="web_search",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _normalize_plugin_market_repo_url(repo_url: str) -> Optional[str]:
|
||||
"""
|
||||
规范化插件仓库地址,便于跨来源合并去重。
|
||||
@@ -763,6 +804,10 @@ async def set_env_setting(
|
||||
"""
|
||||
更新系统环境变量(仅管理员)
|
||||
"""
|
||||
validation_error = _validate_llm_server_tool_config(env)
|
||||
if validation_error:
|
||||
return schemas.Response(success=False, message=validation_error)
|
||||
|
||||
result = settings.update_settings(env=env)
|
||||
# 统计成功和失败的结果
|
||||
success_updates = {k: v for k, v in result.items() if v[0]}
|
||||
|
||||
@@ -571,6 +571,8 @@ class ConfigModel(BaseModel):
|
||||
LLM_THINKING_LEVEL: Optional[str] = "off"
|
||||
# OpenAI兼容接口API协议:auto(自动)/ chat_completions / responses
|
||||
LLM_API_PROTOCOL: str = "auto"
|
||||
# 联网搜索模式:local(本地)/ builtin(模型服务端)/ auto(自动)/ disabled(关闭)
|
||||
LLM_WEB_SEARCH_MODE: str = "local"
|
||||
# LLM是否支持图片输入,开启后消息图片会按多模态输入发送给模型
|
||||
LLM_SUPPORT_IMAGE_INPUT: bool = True
|
||||
# 是否启用音频输入,开启后用户语音会先转写为文本再进入 Agent
|
||||
|
||||
@@ -95,6 +95,7 @@ class AgentLLMProviderEventData(ChainEventData):
|
||||
use_proxy: Optional[bool] = Field(default=None, description="是否使用系统代理")
|
||||
thinking_level: Optional[str] = Field(default=None, description="思考模式级别")
|
||||
api_protocol: Optional[str] = Field(default=None, description="OpenAI兼容接口API协议:auto/chat_completions/responses")
|
||||
web_search_mode: Optional[str] = Field(default=None, description="联网搜索模式:local/builtin/auto/disabled")
|
||||
selected_provider_id: Optional[str] = Field(default=None, description="插件侧供应商ID")
|
||||
selected_provider_name: Optional[str] = Field(default=None, description="插件侧供应商名称")
|
||||
source: Optional[str] = Field(default=None, description="选择来源")
|
||||
|
||||
Reference in New Issue
Block a user