mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 08:57:09 +08:00
feat: 支持模型服务端联网搜索
This commit is contained in:
+25
-3
@@ -730,6 +730,7 @@ class MoviePilotAgent:
|
|||||||
use_proxy=settings.LLM_USE_PROXY,
|
use_proxy=settings.LLM_USE_PROXY,
|
||||||
thinking_level=settings.LLM_THINKING_LEVEL,
|
thinking_level=settings.LLM_THINKING_LEVEL,
|
||||||
api_protocol=settings.LLM_API_PROTOCOL,
|
api_protocol=settings.LLM_API_PROTOCOL,
|
||||||
|
web_search_mode=settings.LLM_WEB_SEARCH_MODE,
|
||||||
)
|
)
|
||||||
selected_event = await eventmanager.async_send_event(
|
selected_event = await eventmanager.async_send_event(
|
||||||
ChainEventType.AgentLLMProvider,
|
ChainEventType.AgentLLMProvider,
|
||||||
@@ -773,6 +774,9 @@ class MoviePilotAgent:
|
|||||||
api_protocol = self._clean_optional_text(
|
api_protocol = self._clean_optional_text(
|
||||||
self._get_event_value(resolved_data, "api_protocol")
|
self._get_event_value(resolved_data, "api_protocol")
|
||||||
) or settings.LLM_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(
|
selected_provider_id = self._clean_optional_text(
|
||||||
self._get_event_value(resolved_data, "selected_provider_id")
|
self._get_event_value(resolved_data, "selected_provider_id")
|
||||||
)
|
)
|
||||||
@@ -799,6 +803,7 @@ class MoviePilotAgent:
|
|||||||
"use_proxy": bool(use_proxy),
|
"use_proxy": bool(use_proxy),
|
||||||
"thinking_level": thinking_level,
|
"thinking_level": thinking_level,
|
||||||
"api_protocol": api_protocol,
|
"api_protocol": api_protocol,
|
||||||
|
"web_search_mode": web_search_mode,
|
||||||
}
|
}
|
||||||
return self._llm_runtime_config
|
return self._llm_runtime_config
|
||||||
|
|
||||||
@@ -1006,6 +1011,13 @@ class MoviePilotAgent:
|
|||||||
allow_message_tools=self.allow_message_tools,
|
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:
|
def _refresh_tool_context(self, values: Dict[str, object]) -> None:
|
||||||
"""
|
"""
|
||||||
刷新本轮工具共享上下文。
|
刷新本轮工具共享上下文。
|
||||||
@@ -1035,6 +1047,7 @@ class MoviePilotAgent:
|
|||||||
bool(runtime_config.get("use_proxy")),
|
bool(runtime_config.get("use_proxy")),
|
||||||
runtime_config.get("thinking_level"),
|
runtime_config.get("thinking_level"),
|
||||||
runtime_config.get("api_protocol"),
|
runtime_config.get("api_protocol"),
|
||||||
|
runtime_config.get("web_search_mode"),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _agent_bundle_signature(self, streaming: bool) -> tuple[Any, ...]:
|
async def _agent_bundle_signature(self, streaming: bool) -> tuple[Any, ...]:
|
||||||
@@ -1165,6 +1178,8 @@ class MoviePilotAgent:
|
|||||||
# LLM 模型(用于 agent 执行)
|
# LLM 模型(用于 agent 执行)
|
||||||
agent_model = await self._initialize_llm(streaming=streaming)
|
agent_model = await self._initialize_llm(streaming=streaming)
|
||||||
self._sync_model_profile(agent_model)
|
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,避免与用户流式回复复用同一实例。
|
# 为内部模型调用准备非流式 LLM,避免与用户流式回复复用同一实例。
|
||||||
non_streaming_model = (
|
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())
|
tools.extend(await self._initialize_mcp_tools())
|
||||||
skills_middleware = SkillsMiddleware(
|
skills_middleware = SkillsMiddleware(
|
||||||
sources=[str(agent_runtime_manager.skills_dir)],
|
sources=[str(agent_runtime_manager.skills_dir)],
|
||||||
@@ -1192,11 +1210,15 @@ class MoviePilotAgent:
|
|||||||
activity_log_tools = list(
|
activity_log_tools = list(
|
||||||
getattr(activity_log_middleware, "tools", []) or []
|
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_tools.extend(await self._initialize_subagent_mcp_tools())
|
||||||
subagent_middlewares, subagent_task_tools = create_subagent_middlewares(
|
subagent_middlewares, subagent_task_tools = create_subagent_middlewares(
|
||||||
model=non_streaming_model,
|
model=non_streaming_model,
|
||||||
tools=subagent_tools,
|
tools=subagent_tools,
|
||||||
|
server_tools=server_tools,
|
||||||
stream_handler=self.stream_handler,
|
stream_handler=self.stream_handler,
|
||||||
)
|
)
|
||||||
max_tools = settings.LLM_MAX_TOOLS
|
max_tools = settings.LLM_MAX_TOOLS
|
||||||
@@ -1271,7 +1293,7 @@ class MoviePilotAgent:
|
|||||||
|
|
||||||
agent = create_agent(
|
agent = create_agent(
|
||||||
model=agent_model,
|
model=agent_model,
|
||||||
tools=[*tools, *skill_tools, *activity_log_tools],
|
tools=[*tools, *skill_tools, *activity_log_tools, *server_tools],
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
middleware=middlewares,
|
middleware=middlewares,
|
||||||
checkpointer=InMemorySaver(),
|
checkpointer=InMemorySaver(),
|
||||||
|
|||||||
+182
-91
@@ -5,13 +5,16 @@ import inspect
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from functools import wraps
|
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 langchain_core.messages import AIMessage, AIMessageChunk
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.agent.llm.server_tools import ServerToolResolution
|
||||||
|
|
||||||
|
|
||||||
class LLMTestError(RuntimeError):
|
class LLMTestError(RuntimeError):
|
||||||
"""LLM 测试调用异常,附带请求耗时。"""
|
"""LLM 测试调用异常,附带请求耗时。"""
|
||||||
@@ -224,74 +227,76 @@ def _is_deepseek_thinking_enabled(model_name: str | None, extra_body: Any) -> bo
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _patch_deepseek_reasoning_content_support():
|
def _patch_interleaved_reasoning_request_support(
|
||||||
"""
|
model_cls: Any,
|
||||||
修补 langchain-deepseek 在 tool-call 场景下遗漏 reasoning_content 回传的问题。
|
*,
|
||||||
|
patch_marker: str,
|
||||||
DeepSeek thinking mode 要求:若 assistant 历史消息包含 tool_calls,
|
thinking_filter: Any = None,
|
||||||
后续请求中必须带回该条消息的顶层 reasoning_content。
|
normalize_deepseek_messages: bool = False,
|
||||||
某些 langchain-deepseek 版本虽然能从响应中拿到 reasoning_content,
|
inject_missing_as_empty: bool = False,
|
||||||
但不会在重放消息历史时写回请求载荷,导致 400。
|
) -> None:
|
||||||
"""
|
"""为兼容模型统一补回工具调用历史中的 reasoning_content。"""
|
||||||
try:
|
if getattr(model_cls, patch_marker, False):
|
||||||
from langchain_deepseek import ChatDeepSeek
|
|
||||||
except Exception as err:
|
|
||||||
logger.debug(f"跳过 langchain-deepseek reasoning_content 修补:{err}")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if getattr(ChatDeepSeek, "_moviepilot_reasoning_content_patched", False):
|
original_get_request_payload = getattr(model_cls, "_get_request_payload", None)
|
||||||
return
|
|
||||||
|
|
||||||
original_get_request_payload = getattr(ChatDeepSeek, "_get_request_payload", None)
|
|
||||||
if not callable(original_get_request_payload):
|
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
|
return
|
||||||
|
|
||||||
@wraps(original_get_request_payload)
|
@wraps(original_get_request_payload)
|
||||||
def _patched_get_request_payload(self, input_, *, stop=None, **kwargs):
|
def _patched_get_request_payload(self, input_, *, stop=None, **kwargs):
|
||||||
payload = original_get_request_payload(self, input_, stop=stop, **kwargs)
|
payload = original_get_request_payload(self, input_, stop=stop, **kwargs)
|
||||||
|
if "messages" not in payload:
|
||||||
|
return payload
|
||||||
|
|
||||||
|
extra_body = getattr(self, "extra_body", None)
|
||||||
|
if extra_body is None:
|
||||||
extra_body = (getattr(self, "model_kwargs", None) or {}).get("extra_body")
|
extra_body = (getattr(self, "model_kwargs", None) or {}).get("extra_body")
|
||||||
if not _is_deepseek_thinking_enabled(
|
if thinking_filter is not None and not thinking_filter(
|
||||||
getattr(self, "model_name", None) or getattr(self, "model", None),
|
getattr(self, "model_name", None) or getattr(self, "model", None),
|
||||||
extra_body,
|
extra_body,
|
||||||
):
|
):
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
# 从原始 LangChain 消息中取回 reasoning_content。上游 payload 构造器
|
|
||||||
# 不会自动透传这个 DeepSeek 扩展字段。
|
|
||||||
messages = self._convert_input(input_).to_messages()
|
messages = self._convert_input(input_).to_messages()
|
||||||
|
for index, payload_message in enumerate(payload["messages"]):
|
||||||
for i, message in enumerate(payload["messages"]):
|
if normalize_deepseek_messages:
|
||||||
if message["role"] == "tool" and isinstance(message["content"], list):
|
if payload_message.get("role") == "tool" and isinstance(
|
||||||
message["content"] = json.dumps(message["content"])
|
payload_message.get("content"), list
|
||||||
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)
|
|
||||||
):
|
):
|
||||||
message["reasoning_content"] = messages[i].additional_kwargs.get(
|
payload_message["content"] = json.dumps(payload_message["content"])
|
||||||
"reasoning_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
|
return payload
|
||||||
|
|
||||||
ChatDeepSeek._get_request_payload = _patched_get_request_payload
|
model_cls._get_request_payload = _patched_get_request_payload
|
||||||
ChatDeepSeek._moviepilot_reasoning_content_patched = True
|
setattr(model_cls, patch_marker, True)
|
||||||
logger.debug("已修补 langchain-deepseek thinking tool-call 的 reasoning_content 回传兼容性")
|
|
||||||
|
|
||||||
|
|
||||||
def _patch_openai_interleaved_reasoning_content_support():
|
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
|
_openai_base._moviepilot_reasoning_response_patched = True
|
||||||
|
|
||||||
if getattr(ChatOpenAI, "_moviepilot_interleaved_reasoning_patched", False):
|
_patch_interleaved_reasoning_request_support(
|
||||||
return
|
ChatOpenAI,
|
||||||
|
patch_marker="_moviepilot_interleaved_reasoning_patched",
|
||||||
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
|
|
||||||
logger.debug("已修补 langchain-openai interleaved reasoning_content 回传兼容性")
|
logger.debug("已修补 langchain-openai interleaved reasoning_content 回传兼容性")
|
||||||
|
|
||||||
|
|
||||||
@@ -931,6 +904,36 @@ class LLMHelper:
|
|||||||
profile["moviepilot_provider_id"] = runtime_metadata["provider_id"]
|
profile["moviepilot_provider_id"] = runtime_metadata["provider_id"]
|
||||||
profile["moviepilot_base_url"] = runtime_metadata["base_url"]
|
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
|
@classmethod
|
||||||
def _resolve_thinking_level(
|
def _resolve_thinking_level(
|
||||||
cls,
|
cls,
|
||||||
@@ -979,6 +982,7 @@ class LLMHelper:
|
|||||||
temperature: Optional[float] = None,
|
temperature: Optional[float] = None,
|
||||||
use_proxy: bool | None = None,
|
use_proxy: bool | None = None,
|
||||||
api_protocol: str | None = None,
|
api_protocol: str | None = None,
|
||||||
|
web_search_mode: str | None = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
获取LLM实例
|
获取LLM实例
|
||||||
@@ -999,6 +1003,9 @@ class LLMHelper:
|
|||||||
(auto/chat_completions/responses)。未显式传入时使用配置项 LLM_API_PROTOCOL。
|
(auto/chat_completions/responses)。未显式传入时使用配置项 LLM_API_PROTOCOL。
|
||||||
仅对 OpenAI 兼容运行时生效;``responses`` 强制走 Responses API,
|
仅对 OpenAI 兼容运行时生效;``responses`` 强制走 Responses API,
|
||||||
``chat_completions`` 强制走 Chat Completions,``auto`` 保持原有自动判断。
|
``chat_completions`` 强制走 Chat Completions,``auto`` 保持原有自动判断。
|
||||||
|
:param web_search_mode: 联网搜索模式
|
||||||
|
(local/builtin/auto/disabled)。未显式传入时使用配置项
|
||||||
|
``LLM_WEB_SEARCH_MODE``。
|
||||||
:return: LLM实例
|
:return: LLM实例
|
||||||
"""
|
"""
|
||||||
provider_name = str(provider if provider is not None else settings.LLM_PROVIDER).lower()
|
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,
|
user_agent=user_agent_value,
|
||||||
)
|
)
|
||||||
model_name = runtime.get("model_id") or model_name
|
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(
|
default_headers = cls._build_openai_default_headers(
|
||||||
runtime.get("default_headers"),
|
runtime.get("default_headers"),
|
||||||
user_agent=user_agent_value,
|
user_agent=user_agent_value,
|
||||||
@@ -1050,7 +1091,7 @@ class LLMHelper:
|
|||||||
provider=provider_name,
|
provider=provider_name,
|
||||||
model=model_name,
|
model=model_name,
|
||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
api_protocol=api_protocol,
|
api_protocol=effective_api_protocol,
|
||||||
)
|
)
|
||||||
llm_proxy = _resolve_llm_proxy(use_proxy)
|
llm_proxy = _resolve_llm_proxy(use_proxy)
|
||||||
|
|
||||||
@@ -1072,10 +1113,22 @@ class LLMHelper:
|
|||||||
client_args=_build_google_client_args(llm_proxy),
|
client_args=_build_google_client_args(llm_proxy),
|
||||||
**thinking_kwargs,
|
**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
|
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 = ChatDeepSeek(
|
||||||
model=model_name,
|
model=model_name,
|
||||||
api_key=runtime["api_key"],
|
api_key=runtime["api_key"],
|
||||||
@@ -1154,6 +1207,7 @@ class LLMHelper:
|
|||||||
),
|
),
|
||||||
default_headers=default_headers,
|
default_headers=default_headers,
|
||||||
use_responses_api=use_responses_api,
|
use_responses_api=use_responses_api,
|
||||||
|
output_version=("responses/v1" if use_responses_api else None),
|
||||||
**thinking_kwargs,
|
**thinking_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1181,6 +1235,7 @@ class LLMHelper:
|
|||||||
}
|
}
|
||||||
|
|
||||||
cls._attach_runtime_metadata(model, runtime)
|
cls._attach_runtime_metadata(model, runtime)
|
||||||
|
cls._attach_server_tool_metadata(model, server_tool_resolution)
|
||||||
return model
|
return model
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -1241,12 +1296,14 @@ class LLMHelper:
|
|||||||
temperature: Optional[float] = None,
|
temperature: Optional[float] = None,
|
||||||
use_proxy: bool | None = None,
|
use_proxy: bool | None = None,
|
||||||
api_protocol: str | None = None,
|
api_protocol: str | None = None,
|
||||||
|
web_search_mode: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
使用当前配置或显式传入的临时配置执行一次最小 LLM 调用。
|
使用当前配置或显式传入的临时配置执行一次最小 LLM 调用。
|
||||||
|
|
||||||
:param temperature: LLM 温度参数。未显式传入时沿用已保存配置。
|
:param temperature: LLM 温度参数。未显式传入时沿用已保存配置。
|
||||||
:param api_protocol: OpenAI 兼容接口 API 协议,未显式传入时沿用已保存配置。
|
:param api_protocol: OpenAI 兼容接口 API 协议,未显式传入时沿用已保存配置。
|
||||||
|
:param web_search_mode: 联网搜索模式,未显式传入时沿用已保存配置。
|
||||||
"""
|
"""
|
||||||
provider_name = provider if provider is not None else settings.LLM_PROVIDER
|
provider_name = provider if provider is not None else settings.LLM_PROVIDER
|
||||||
model_name = model if model is not None else settings.LLM_MODEL
|
model_name = model if model is not None else settings.LLM_MODEL
|
||||||
@@ -1262,6 +1319,7 @@ class LLMHelper:
|
|||||||
"user_agent": user_agent,
|
"user_agent": user_agent,
|
||||||
"use_proxy": use_proxy,
|
"use_proxy": use_proxy,
|
||||||
"api_protocol": api_protocol,
|
"api_protocol": api_protocol,
|
||||||
|
"web_search_mode": web_search_mode,
|
||||||
}
|
}
|
||||||
if temperature is not None:
|
if temperature is not None:
|
||||||
llm_kwargs["temperature"] = temperature
|
llm_kwargs["temperature"] = temperature
|
||||||
@@ -1310,7 +1368,7 @@ class LLMHelper:
|
|||||||
try:
|
try:
|
||||||
from app.agent.llm.provider import LLMProviderManager
|
from app.agent.llm.provider import LLMProviderManager
|
||||||
|
|
||||||
return await LLMProviderManager().list_models(
|
models = await LLMProviderManager().list_models(
|
||||||
provider_id=provider,
|
provider_id=provider,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
@@ -1319,16 +1377,25 @@ class LLMHelper:
|
|||||||
use_proxy=use_proxy,
|
use_proxy=use_proxy,
|
||||||
force_refresh=force_refresh,
|
force_refresh=force_refresh,
|
||||||
)
|
)
|
||||||
|
return self._attach_server_tool_capabilities(
|
||||||
|
provider,
|
||||||
|
models,
|
||||||
|
base_url=base_url,
|
||||||
|
)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.debug(f"LLM provider 目录不可用,回退旧模型列表逻辑: {err}")
|
logger.debug(f"LLM provider 目录不可用,回退旧模型列表逻辑: {err}")
|
||||||
if provider == "google":
|
if provider == "google":
|
||||||
return [
|
return self._attach_server_tool_capabilities(
|
||||||
|
provider,
|
||||||
|
[
|
||||||
{"id": model_id, "name": model_id}
|
{"id": model_id, "name": model_id}
|
||||||
for model_id in await self._get_google_models(
|
for model_id in await self._get_google_models(
|
||||||
api_key or "",
|
api_key or "",
|
||||||
use_proxy=use_proxy,
|
use_proxy=use_proxy,
|
||||||
)
|
)
|
||||||
]
|
],
|
||||||
|
base_url=base_url,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
from app.agent.llm.provider import LLMProviderManager
|
from app.agent.llm.provider import LLMProviderManager
|
||||||
|
|
||||||
@@ -1342,7 +1409,9 @@ class LLMHelper:
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
model_list_base_url = base_url
|
model_list_base_url = base_url
|
||||||
return [
|
return self._attach_server_tool_capabilities(
|
||||||
|
provider,
|
||||||
|
[
|
||||||
{"id": model_id, "name": model_id}
|
{"id": model_id, "name": model_id}
|
||||||
for model_id in await self._get_openai_compatible_models(
|
for model_id in await self._get_openai_compatible_models(
|
||||||
provider,
|
provider,
|
||||||
@@ -1351,7 +1420,29 @@ class LLMHelper:
|
|||||||
user_agent=user_agent,
|
user_agent=user_agent,
|
||||||
use_proxy=use_proxy,
|
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
|
@staticmethod
|
||||||
async def _get_google_models(api_key: str, use_proxy: bool | None = None) -> List[str]:
|
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,
|
model: BaseChatModel,
|
||||||
profiles: tuple[_SubAgentProfile, ...],
|
profiles: tuple[_SubAgentProfile, ...],
|
||||||
tools: list[BaseTool],
|
tools: list[BaseTool],
|
||||||
|
server_tools: Optional[list[dict[str, Any]]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""初始化子代理执行器。"""
|
"""初始化子代理执行器。"""
|
||||||
self._model = model
|
self._model = model
|
||||||
self._profiles = {profile.name: profile for profile in profiles}
|
self._profiles = {profile.name: profile for profile in profiles}
|
||||||
self._tools = tools
|
self._tools = tools
|
||||||
|
self._server_tools = server_tools or []
|
||||||
self._agents = {}
|
self._agents = {}
|
||||||
self._default_agent_name = "general-purpose"
|
self._default_agent_name = "general-purpose"
|
||||||
|
|
||||||
@@ -404,7 +406,7 @@ class _SubAgentAgentProvider:
|
|||||||
)
|
)
|
||||||
agent = create_agent(
|
agent = create_agent(
|
||||||
model=self._model,
|
model=self._model,
|
||||||
tools=subagent_tools,
|
tools=[*subagent_tools, *self._server_tools],
|
||||||
system_prompt=profile.prompt,
|
system_prompt=profile.prompt,
|
||||||
name=profile.name,
|
name=profile.name,
|
||||||
)
|
)
|
||||||
@@ -462,16 +464,19 @@ class MoviePilotSubAgentMiddleware(AgentMiddleware):
|
|||||||
model: BaseChatModel,
|
model: BaseChatModel,
|
||||||
profiles: tuple[_SubAgentProfile, ...],
|
profiles: tuple[_SubAgentProfile, ...],
|
||||||
tools: list[BaseTool],
|
tools: list[BaseTool],
|
||||||
|
server_tools: Optional[list[dict[str, Any]]] = None,
|
||||||
system_prompt: str = SUBAGENT_PARENT_PROMPT,
|
system_prompt: str = SUBAGENT_PARENT_PROMPT,
|
||||||
task_description: str = SUBAGENT_TASK_DESCRIPTION,
|
task_description: str = SUBAGENT_TASK_DESCRIPTION,
|
||||||
stream_handler: Any = None,
|
stream_handler: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""初始化同步子代理中间件。"""
|
||||||
self.system_prompt = system_prompt
|
self.system_prompt = system_prompt
|
||||||
self.stream_handler = stream_handler
|
self.stream_handler = stream_handler
|
||||||
self._provider = _SubAgentAgentProvider(
|
self._provider = _SubAgentAgentProvider(
|
||||||
model=model,
|
model=model,
|
||||||
profiles=profiles,
|
profiles=profiles,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
|
server_tools=server_tools,
|
||||||
)
|
)
|
||||||
self.tools = [
|
self.tools = [
|
||||||
StructuredTool.from_function(
|
StructuredTool.from_function(
|
||||||
@@ -549,6 +554,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
|
|||||||
model: BaseChatModel,
|
model: BaseChatModel,
|
||||||
profiles: tuple[_SubAgentProfile, ...],
|
profiles: tuple[_SubAgentProfile, ...],
|
||||||
tools: list[BaseTool],
|
tools: list[BaseTool],
|
||||||
|
server_tools: Optional[list[dict[str, Any]]] = None,
|
||||||
task_description: str = SUBAGENT_CONTROL_DESCRIPTION,
|
task_description: str = SUBAGENT_CONTROL_DESCRIPTION,
|
||||||
stream_handler: Any = None,
|
stream_handler: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -558,6 +564,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
|
|||||||
model=model,
|
model=model,
|
||||||
profiles=profiles,
|
profiles=profiles,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
|
server_tools=server_tools,
|
||||||
)
|
)
|
||||||
self._semaphore = asyncio.Semaphore(SUBAGENT_MAX_CONCURRENT_TASKS)
|
self._semaphore = asyncio.Semaphore(SUBAGENT_MAX_CONCURRENT_TASKS)
|
||||||
self._tasks: dict[str, _SubAgentRuntimeTask] = {}
|
self._tasks: dict[str, _SubAgentRuntimeTask] = {}
|
||||||
@@ -1111,6 +1118,7 @@ def create_subagent_middlewares(
|
|||||||
*,
|
*,
|
||||||
model: BaseChatModel,
|
model: BaseChatModel,
|
||||||
tools: list[BaseTool],
|
tools: list[BaseTool],
|
||||||
|
server_tools: Optional[list[dict[str, Any]]] = None,
|
||||||
stream_handler: Any = None,
|
stream_handler: Any = None,
|
||||||
) -> tuple[list[AgentMiddleware], list[BaseTool]]:
|
) -> tuple[list[AgentMiddleware], list[BaseTool]]:
|
||||||
"""创建子代理中间件列表和任务工具列表。"""
|
"""创建子代理中间件列表和任务工具列表。"""
|
||||||
@@ -1120,12 +1128,14 @@ def create_subagent_middlewares(
|
|||||||
model=model,
|
model=model,
|
||||||
profiles=profiles,
|
profiles=profiles,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
|
server_tools=server_tools or [],
|
||||||
stream_handler=stream_handler,
|
stream_handler=stream_handler,
|
||||||
)
|
)
|
||||||
control_middleware = SubAgentTaskControlMiddleware(
|
control_middleware = SubAgentTaskControlMiddleware(
|
||||||
model=model,
|
model=model,
|
||||||
profiles=profiles,
|
profiles=profiles,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
|
server_tools=server_tools or [],
|
||||||
stream_handler=stream_handler,
|
stream_handler=stream_handler,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class LlmTestRequest(BaseModel):
|
|||||||
temperature: Optional[float] = None
|
temperature: Optional[float] = None
|
||||||
use_proxy: Optional[bool] = None
|
use_proxy: Optional[bool] = None
|
||||||
api_protocol: Optional[str] = None
|
api_protocol: Optional[str] = None
|
||||||
|
web_search_mode: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class LlmProviderAuthStartRequest(BaseModel):
|
class LlmProviderAuthStartRequest(BaseModel):
|
||||||
@@ -271,6 +272,7 @@ async def llm_test(
|
|||||||
user_agent=settings.LLM_USER_AGENT,
|
user_agent=settings.LLM_USER_AGENT,
|
||||||
use_proxy=settings.LLM_USE_PROXY,
|
use_proxy=settings.LLM_USE_PROXY,
|
||||||
api_protocol=settings.LLM_API_PROTOCOL,
|
api_protocol=settings.LLM_API_PROTOCOL,
|
||||||
|
web_search_mode=settings.LLM_WEB_SEARCH_MODE,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not payload.provider:
|
if not payload.provider:
|
||||||
@@ -305,6 +307,7 @@ async def llm_test(
|
|||||||
"user_agent": payload.user_agent,
|
"user_agent": payload.user_agent,
|
||||||
"use_proxy": payload.use_proxy,
|
"use_proxy": payload.use_proxy,
|
||||||
"api_protocol": payload.api_protocol,
|
"api_protocol": payload.api_protocol,
|
||||||
|
"web_search_mode": payload.web_search_mode,
|
||||||
}
|
}
|
||||||
if payload.temperature is not None:
|
if payload.temperature is not None:
|
||||||
test_kwargs["temperature"] = payload.temperature
|
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]:
|
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)
|
result = settings.update_settings(env=env)
|
||||||
# 统计成功和失败的结果
|
# 统计成功和失败的结果
|
||||||
success_updates = {k: v for k, v in result.items() if v[0]}
|
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"
|
LLM_THINKING_LEVEL: Optional[str] = "off"
|
||||||
# OpenAI兼容接口API协议:auto(自动)/ chat_completions / responses
|
# OpenAI兼容接口API协议:auto(自动)/ chat_completions / responses
|
||||||
LLM_API_PROTOCOL: str = "auto"
|
LLM_API_PROTOCOL: str = "auto"
|
||||||
|
# 联网搜索模式:local(本地)/ builtin(模型服务端)/ auto(自动)/ disabled(关闭)
|
||||||
|
LLM_WEB_SEARCH_MODE: str = "local"
|
||||||
# LLM是否支持图片输入,开启后消息图片会按多模态输入发送给模型
|
# LLM是否支持图片输入,开启后消息图片会按多模态输入发送给模型
|
||||||
LLM_SUPPORT_IMAGE_INPUT: bool = True
|
LLM_SUPPORT_IMAGE_INPUT: bool = True
|
||||||
# 是否启用音频输入,开启后用户语音会先转写为文本再进入 Agent
|
# 是否启用音频输入,开启后用户语音会先转写为文本再进入 Agent
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ class AgentLLMProviderEventData(ChainEventData):
|
|||||||
use_proxy: Optional[bool] = Field(default=None, description="是否使用系统代理")
|
use_proxy: Optional[bool] = Field(default=None, description="是否使用系统代理")
|
||||||
thinking_level: Optional[str] = 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")
|
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_id: Optional[str] = Field(default=None, description="插件侧供应商ID")
|
||||||
selected_provider_name: Optional[str] = Field(default=None, description="插件侧供应商名称")
|
selected_provider_name: Optional[str] = Field(default=None, description="插件侧供应商名称")
|
||||||
source: Optional[str] = Field(default=None, description="选择来源")
|
source: Optional[str] = Field(default=None, description="选择来源")
|
||||||
|
|||||||
@@ -238,6 +238,10 @@ moviepilot setup --config-dir /path/to/moviepilot-config
|
|||||||
- 默认下载目录与媒体库目录
|
- 默认下载目录与媒体库目录
|
||||||
- AI Agent
|
- AI Agent
|
||||||
可按需启用,并配置 `LLM_PROVIDER`、`LLM_MODEL`、`LLM_API_KEY`、`LLM_BASE_URL`
|
可按需启用,并配置 `LLM_PROVIDER`、`LLM_MODEL`、`LLM_API_KEY`、`LLM_BASE_URL`
|
||||||
|
与 `LLM_WEB_SEARCH_MODE`。联网搜索支持 MoviePilot 本地搜索、模型服务端搜索、
|
||||||
|
服务端优先自动回退与完全关闭;服务端模式仅在当前模型目录声明支持时生效。
|
||||||
|
当前可识别 OpenAI、Anthropic Claude、Google Gemini、xAI Grok 与 DeepSeek
|
||||||
|
官方端点已公布的服务端联网搜索能力,第三方兼容端点不会被自动误判。
|
||||||
- 用户站点认证
|
- 用户站点认证
|
||||||
可按需选择认证站点,并按站点要求填写用户名、UID、Passkey 等参数
|
可按需选择认证站点,并按站点要求填写用户名、UID、Passkey 等参数
|
||||||
- 开机自启
|
- 开机自启
|
||||||
|
|||||||
@@ -2093,6 +2093,21 @@ def _collect_agent_config(
|
|||||||
runtime_python=runtime_python,
|
runtime_python=runtime_python,
|
||||||
)
|
)
|
||||||
config["LLM_MODEL"] = _prompt_model_choice(models, default=current_model)
|
config["LLM_MODEL"] = _prompt_model_choice(models, default=current_model)
|
||||||
|
config["LLM_WEB_SEARCH_MODE"] = _prompt_choice(
|
||||||
|
"LLM 联网搜索模式",
|
||||||
|
choices={
|
||||||
|
"local": "MoviePilot 本地搜索",
|
||||||
|
"builtin": "模型服务端搜索",
|
||||||
|
"auto": "自动(服务端优先,不支持时回退本地)",
|
||||||
|
"disabled": "关闭联网搜索",
|
||||||
|
},
|
||||||
|
default=(
|
||||||
|
_env_default("LLM_WEB_SEARCH_MODE", "local")
|
||||||
|
if _env_default("LLM_WEB_SEARCH_MODE", "local")
|
||||||
|
in {"local", "builtin", "auto", "disabled"}
|
||||||
|
else "local"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|||||||
@@ -84,3 +84,22 @@ def test_resolve_llm_runtime_config_prefers_plugin_api_protocol(monkeypatch) ->
|
|||||||
runtime_config = asyncio.run(agent._resolve_llm_runtime_config())
|
runtime_config = asyncio.run(agent._resolve_llm_runtime_config())
|
||||||
|
|
||||||
assert runtime_config["api_protocol"] == "chat_completions"
|
assert runtime_config["api_protocol"] == "chat_completions"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_llm_runtime_config_prefers_plugin_web_search_mode(monkeypatch) -> None:
|
||||||
|
"""插件显式覆盖联网搜索模式时应优先使用插件值。"""
|
||||||
|
monkeypatch.setattr(settings, "LLM_WEB_SEARCH_MODE", "local")
|
||||||
|
agent = MoviePilotAgent(session_id="web-search-plugin", user_id="user-1")
|
||||||
|
|
||||||
|
async def override_web_search_mode(_event_type, event_data):
|
||||||
|
"""模拟插件覆盖联网搜索模式。"""
|
||||||
|
event_data.web_search_mode = "builtin"
|
||||||
|
return SimpleNamespace(event_data=event_data)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.agent.eventmanager.async_send_event",
|
||||||
|
new=AsyncMock(side_effect=override_web_search_mode),
|
||||||
|
):
|
||||||
|
runtime_config = asyncio.run(agent._resolve_llm_runtime_config())
|
||||||
|
|
||||||
|
assert runtime_config["web_search_mode"] == "builtin"
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ def test_initialize_llm_uses_chain_event_selection(monkeypatch) -> None:
|
|||||||
use_proxy=True,
|
use_proxy=True,
|
||||||
thinking_level="xhigh",
|
thinking_level="xhigh",
|
||||||
api_protocol="auto",
|
api_protocol="auto",
|
||||||
|
web_search_mode="local",
|
||||||
)
|
)
|
||||||
assert agent._llm_provider_selection["selected_provider_id"] == "provider-1"
|
assert agent._llm_provider_selection["selected_provider_id"] == "provider-1"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||||
|
|
||||||
@@ -54,12 +53,15 @@ class DeepSeekCompatPatchTest(unittest.TestCase):
|
|||||||
_FakeChatDeepSeek._get_request_payload = _ORIGINAL_GET_REQUEST_PAYLOAD
|
_FakeChatDeepSeek._get_request_payload = _ORIGINAL_GET_REQUEST_PAYLOAD
|
||||||
if hasattr(_FakeChatDeepSeek, "_moviepilot_reasoning_content_patched"):
|
if hasattr(_FakeChatDeepSeek, "_moviepilot_reasoning_content_patched"):
|
||||||
delattr(_FakeChatDeepSeek, "_moviepilot_reasoning_content_patched")
|
delattr(_FakeChatDeepSeek, "_moviepilot_reasoning_content_patched")
|
||||||
# helper 的修补函数内部 `from langchain_deepseek import ChatDeepSeek`,
|
llm_module._patch_interleaved_reasoning_request_support(
|
||||||
# 这里临时把该名指向假类,使修补作用到 _FakeChatDeepSeek;patch 在用例结束自动还原。
|
_FakeChatDeepSeek,
|
||||||
patcher = patch("langchain_deepseek.ChatDeepSeek", _FakeChatDeepSeek)
|
patch_marker="_moviepilot_reasoning_content_patched",
|
||||||
patcher.start()
|
thinking_filter=lambda model_name, extra_body: (
|
||||||
self.addCleanup(patcher.stop)
|
llm_module._is_deepseek_thinking_enabled(model_name, extra_body)
|
||||||
llm_module._patch_deepseek_reasoning_content_support()
|
),
|
||||||
|
normalize_deepseek_messages=True,
|
||||||
|
inject_missing_as_empty=True,
|
||||||
|
)
|
||||||
|
|
||||||
def test_injects_reasoning_content_for_assistant_tool_calls(self):
|
def test_injects_reasoning_content_for_assistant_tool_calls(self):
|
||||||
llm = _FakeChatDeepSeek("deepseek-v4-pro")
|
llm = _FakeChatDeepSeek("deepseek-v4-pro")
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ class LlmHelperTestCallTest(unittest.TestCase):
|
|||||||
user_agent=None,
|
user_agent=None,
|
||||||
use_proxy=None,
|
use_proxy=None,
|
||||||
api_protocol=None,
|
api_protocol=None,
|
||||||
|
web_search_mode=None,
|
||||||
)
|
)
|
||||||
self.assertEqual(result["provider"], "deepseek")
|
self.assertEqual(result["provider"], "deepseek")
|
||||||
self.assertEqual(result["model"], "deepseek-chat")
|
self.assertEqual(result["model"], "deepseek-chat")
|
||||||
@@ -439,8 +440,8 @@ class LlmHelperTestCallTest(unittest.TestCase):
|
|||||||
{"langchain_deepseek": SimpleNamespace(ChatDeepSeek=_FakeChatDeepSeek)},
|
{"langchain_deepseek": SimpleNamespace(ChatDeepSeek=_FakeChatDeepSeek)},
|
||||||
), patch.object(
|
), patch.object(
|
||||||
llm_module,
|
llm_module,
|
||||||
"_patch_deepseek_reasoning_content_support",
|
"_patch_interleaved_reasoning_request_support",
|
||||||
side_effect=lambda: patch_calls.append(True),
|
side_effect=lambda *args, **kwargs: patch_calls.append((args, kwargs)),
|
||||||
):
|
):
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
llm_module.LLMHelper.get_llm(
|
llm_module.LLMHelper.get_llm(
|
||||||
@@ -457,7 +458,8 @@ class LlmHelperTestCallTest(unittest.TestCase):
|
|||||||
calls[0].get("extra_body"),
|
calls[0].get("extra_body"),
|
||||||
{"thinking": {"type": "enabled"}},
|
{"thinking": {"type": "enabled"}},
|
||||||
)
|
)
|
||||||
self.assertEqual(patch_calls, [True])
|
self.assertEqual(patch_calls[0][0][0], _FakeChatDeepSeek)
|
||||||
|
self.assertTrue(patch_calls[0][1]["normalize_deepseek_messages"])
|
||||||
self.assertEqual(calls[0].get("reasoning_effort"), "max")
|
self.assertEqual(calls[0].get("reasoning_effort"), "max")
|
||||||
self.assertEqual(calls[0].get("api_base"), "https://api.deepseek.com")
|
self.assertEqual(calls[0].get("api_base"), "https://api.deepseek.com")
|
||||||
|
|
||||||
@@ -476,8 +478,8 @@ class LlmHelperTestCallTest(unittest.TestCase):
|
|||||||
{"langchain_deepseek": SimpleNamespace(ChatDeepSeek=_FakeChatDeepSeek)},
|
{"langchain_deepseek": SimpleNamespace(ChatDeepSeek=_FakeChatDeepSeek)},
|
||||||
), patch.object(
|
), patch.object(
|
||||||
llm_module,
|
llm_module,
|
||||||
"_patch_deepseek_reasoning_content_support",
|
"_patch_interleaved_reasoning_request_support",
|
||||||
side_effect=lambda: patch_calls.append(True),
|
side_effect=lambda *args, **kwargs: patch_calls.append((args, kwargs)),
|
||||||
):
|
):
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
llm_module.LLMHelper.get_llm(
|
llm_module.LLMHelper.get_llm(
|
||||||
@@ -494,10 +496,65 @@ class LlmHelperTestCallTest(unittest.TestCase):
|
|||||||
calls[0].get("extra_body"),
|
calls[0].get("extra_body"),
|
||||||
{"thinking": {"type": "disabled"}},
|
{"thinking": {"type": "disabled"}},
|
||||||
)
|
)
|
||||||
self.assertEqual(patch_calls, [True])
|
self.assertEqual(patch_calls[0][0][0], _FakeChatDeepSeek)
|
||||||
|
self.assertTrue(patch_calls[0][1]["normalize_deepseek_messages"])
|
||||||
self.assertIsNone(calls[0].get("reasoning_effort"))
|
self.assertIsNone(calls[0].get("reasoning_effort"))
|
||||||
self.assertEqual(calls[0].get("api_base"), "https://proxy.example.com")
|
self.assertEqual(calls[0].get("api_base"), "https://proxy.example.com")
|
||||||
|
|
||||||
|
def test_get_llm_uses_common_responses_adapter_for_deepseek_web_search(self):
|
||||||
|
"""DeepSeek 服务端搜索应走通用 ChatOpenAI Responses 适配器。"""
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class _FakeChatOpenAI:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
calls.append(kwargs)
|
||||||
|
self.model = kwargs["model"]
|
||||||
|
self.profile = None
|
||||||
|
|
||||||
|
openai_module = ModuleType("langchain_openai")
|
||||||
|
openai_module.ChatOpenAI = _FakeChatOpenAI
|
||||||
|
|
||||||
|
with patch.dict(sys.modules, {"langchain_openai": openai_module}), patch.object(
|
||||||
|
llm_module,
|
||||||
|
"_patch_openai_responses_instructions_support",
|
||||||
|
):
|
||||||
|
model = asyncio.run(
|
||||||
|
llm_module.LLMHelper.get_llm(
|
||||||
|
provider="deepseek",
|
||||||
|
model="deepseek-v4-flash",
|
||||||
|
thinking_level="off",
|
||||||
|
api_key="sk-test",
|
||||||
|
base_url="https://api.deepseek.com",
|
||||||
|
api_protocol="auto",
|
||||||
|
web_search_mode="builtin",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(calls), 1)
|
||||||
|
self.assertEqual(calls[0]["base_url"], "https://api.deepseek.com")
|
||||||
|
self.assertTrue(calls[0]["use_responses_api"])
|
||||||
|
self.assertEqual(calls[0]["output_version"], "responses/v1")
|
||||||
|
self.assertEqual(
|
||||||
|
llm_module.LLMHelper.get_server_tools(model),
|
||||||
|
[{"type": "web_search"}],
|
||||||
|
)
|
||||||
|
self.assertFalse(llm_module.LLMHelper.should_use_local_web_search(model))
|
||||||
|
|
||||||
|
def test_get_llm_rejects_unsupported_builtin_web_search(self):
|
||||||
|
"""强制服务端搜索不可用时应在构造模型前显式失败。"""
|
||||||
|
with self.assertRaisesRegex(ValueError, "不支持服务端联网搜索"):
|
||||||
|
asyncio.run(
|
||||||
|
llm_module.LLMHelper.get_llm(
|
||||||
|
provider="deepseek",
|
||||||
|
model="deepseek-chat",
|
||||||
|
thinking_level="off",
|
||||||
|
api_key="sk-test",
|
||||||
|
base_url="https://api.deepseek.com",
|
||||||
|
api_protocol="auto",
|
||||||
|
web_search_mode="builtin",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def test_get_llm_uses_openai_reasoning_effort_none_for_off(self):
|
def test_get_llm_uses_openai_reasoning_effort_none_for_off(self):
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
"""LLM 服务端工具能力解析测试。"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.agent.llm import LLMHelper
|
||||||
|
from app.agent.llm.provider import LLMProviderManager
|
||||||
|
from app.agent.llm.server_tools import (
|
||||||
|
ServerToolRegistry,
|
||||||
|
ServerToolUnavailableError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_deepseek_v4_flash_exposes_builtin_web_search() -> None:
|
||||||
|
"""DeepSeek V4 Flash 应声明 Responses 服务端联网搜索能力。"""
|
||||||
|
capabilities = ServerToolRegistry.list_capabilities(
|
||||||
|
provider="deepseek",
|
||||||
|
model="deepseek-v4-flash",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert capabilities == [
|
||||||
|
{
|
||||||
|
"id": "web_search",
|
||||||
|
"required_api_protocol": "responses",
|
||||||
|
"client_adapter": "openai_responses",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
(
|
||||||
|
"provider",
|
||||||
|
"model",
|
||||||
|
"base_url",
|
||||||
|
"expected_tool",
|
||||||
|
"required_api_protocol",
|
||||||
|
"client_adapter",
|
||||||
|
),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"chatgpt",
|
||||||
|
"gpt-5.6-sol",
|
||||||
|
"https://api.openai.com/v1",
|
||||||
|
{"type": "web_search"},
|
||||||
|
"responses",
|
||||||
|
"openai_responses",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"openai",
|
||||||
|
"gpt-4.1-mini",
|
||||||
|
"https://api.openai.com/v1",
|
||||||
|
{"type": "web_search"},
|
||||||
|
"responses",
|
||||||
|
"openai_responses",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"anthropic",
|
||||||
|
"claude-opus-5",
|
||||||
|
"https://api.anthropic.com/v1",
|
||||||
|
{"type": "web_search_20250305", "name": "web_search"},
|
||||||
|
"native",
|
||||||
|
"anthropic_native",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"google",
|
||||||
|
"models/gemini-3.6-flash-preview",
|
||||||
|
None,
|
||||||
|
{"google_search": {}},
|
||||||
|
"native",
|
||||||
|
"google_native",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"xai",
|
||||||
|
"grok-4.5",
|
||||||
|
"https://api.x.ai/v1",
|
||||||
|
{"type": "web_search"},
|
||||||
|
"responses",
|
||||||
|
"openai_responses",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_official_provider_models_expose_builtin_web_search(
|
||||||
|
provider: str,
|
||||||
|
model: str,
|
||||||
|
base_url: str | None,
|
||||||
|
expected_tool: dict,
|
||||||
|
required_api_protocol: str,
|
||||||
|
client_adapter: str,
|
||||||
|
) -> None:
|
||||||
|
"""官方文档声明支持的模型应返回各自原生服务端搜索工具。"""
|
||||||
|
resolution = ServerToolRegistry.resolve_web_search(
|
||||||
|
provider=provider,
|
||||||
|
model=model,
|
||||||
|
mode="builtin",
|
||||||
|
api_protocol="auto",
|
||||||
|
base_url=base_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolution.server_tools == (expected_tool,)
|
||||||
|
assert resolution.required_api_protocol == required_api_protocol
|
||||||
|
assert resolution.client_adapter == client_adapter
|
||||||
|
assert resolution.use_local_web_search is False
|
||||||
|
assert resolution.available is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_builtin_web_search_selects_responses_adapter() -> None:
|
||||||
|
"""服务端搜索应切换到通用 Responses 适配器并关闭本地搜索。"""
|
||||||
|
resolution = ServerToolRegistry.resolve_web_search(
|
||||||
|
provider="deepseek",
|
||||||
|
model="deepseek-v4-flash",
|
||||||
|
mode="builtin",
|
||||||
|
api_protocol="auto",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolution.server_tools == ({"type": "web_search"},)
|
||||||
|
assert resolution.client_adapter == "openai_responses"
|
||||||
|
assert resolution.required_api_protocol == "responses"
|
||||||
|
assert resolution.use_local_web_search is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_web_search_falls_back_to_local_for_unsupported_model() -> None:
|
||||||
|
"""自动模式在模型不支持服务端搜索时应保留本地搜索。"""
|
||||||
|
resolution = ServerToolRegistry.resolve_web_search(
|
||||||
|
provider="deepseek",
|
||||||
|
model="deepseek-chat",
|
||||||
|
mode="auto",
|
||||||
|
api_protocol="auto",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolution.server_tools == ()
|
||||||
|
assert resolution.use_local_web_search is True
|
||||||
|
assert resolution.reason == "builtin_web_search_unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_web_search_respects_chat_completions_selection() -> None:
|
||||||
|
"""显式 Chat Completions 下自动模式应回退本地搜索。"""
|
||||||
|
resolution = ServerToolRegistry.resolve_web_search(
|
||||||
|
provider="deepseek",
|
||||||
|
model="deepseek-v4-flash",
|
||||||
|
mode="auto",
|
||||||
|
api_protocol="chat_completions",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolution.server_tools == ()
|
||||||
|
assert resolution.use_local_web_search is True
|
||||||
|
assert resolution.available is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_native_web_search_ignores_openai_chat_completions_selection() -> None:
|
||||||
|
"""原生 Gemini 服务端搜索不应被 OpenAI 协议选项误伤回退。"""
|
||||||
|
resolution = ServerToolRegistry.resolve_web_search(
|
||||||
|
provider="google",
|
||||||
|
model="gemini-3.6-flash-preview",
|
||||||
|
mode="auto",
|
||||||
|
api_protocol="chat_completions",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolution.server_tools == ({"google_search": {}},)
|
||||||
|
assert resolution.use_local_web_search is False
|
||||||
|
assert resolution.available is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_builtin_web_search_does_not_silently_fall_back() -> None:
|
||||||
|
"""强制服务端模式在模型不支持时不应静默启用本地搜索。"""
|
||||||
|
resolution = ServerToolRegistry.resolve_web_search(
|
||||||
|
provider="deepseek",
|
||||||
|
model="deepseek-v4-pro",
|
||||||
|
mode="builtin",
|
||||||
|
api_protocol="auto",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolution.server_tools == ()
|
||||||
|
assert resolution.use_local_web_search is False
|
||||||
|
assert resolution.available is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_deepseek_builtin_web_search_is_limited_to_official_endpoint() -> None:
|
||||||
|
"""自定义 DeepSeek 兼容端点不应被误判为官方托管搜索。"""
|
||||||
|
resolution = ServerToolRegistry.resolve_web_search(
|
||||||
|
provider="deepseek",
|
||||||
|
model="deepseek-v4-flash",
|
||||||
|
mode="auto",
|
||||||
|
api_protocol="auto",
|
||||||
|
base_url="https://deepseek-proxy.example.com/v1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolution.server_tools == ()
|
||||||
|
assert resolution.use_local_web_search is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("provider", "model", "base_url"),
|
||||||
|
[
|
||||||
|
("openai", "gpt-5.6-sol", "https://openai-proxy.example.com/v1"),
|
||||||
|
("anthropic", "claude-opus-5", "https://anthropic-proxy.example.com/v1"),
|
||||||
|
("xai", "grok-4.5", "https://xai-proxy.example.com/v1"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_provider_web_search_is_limited_to_official_endpoints(
|
||||||
|
provider: str,
|
||||||
|
model: str,
|
||||||
|
base_url: str,
|
||||||
|
) -> None:
|
||||||
|
"""第三方兼容端点不应被误判为厂商官方托管搜索。"""
|
||||||
|
resolution = ServerToolRegistry.resolve_web_search(
|
||||||
|
provider=provider,
|
||||||
|
model=model,
|
||||||
|
mode="auto",
|
||||||
|
api_protocol="auto",
|
||||||
|
base_url=base_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolution.server_tools == ()
|
||||||
|
assert resolution.use_local_web_search is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("provider", "model", "runtime_name", "base_url", "expected_tool"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"chatgpt",
|
||||||
|
"gpt-5.6-sol",
|
||||||
|
"openai_compatible",
|
||||||
|
"https://api.openai.com/v1",
|
||||||
|
{"type": "web_search"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"anthropic",
|
||||||
|
"claude-opus-5",
|
||||||
|
"anthropic_compatible",
|
||||||
|
"https://api.anthropic.com/v1",
|
||||||
|
{"type": "web_search_20250305", "name": "web_search"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"google",
|
||||||
|
"gemini-3.6-flash-preview",
|
||||||
|
"google",
|
||||||
|
None,
|
||||||
|
{"google_search": {}},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"xai",
|
||||||
|
"grok-4.5",
|
||||||
|
"openai_compatible",
|
||||||
|
"https://api.x.ai/v1",
|
||||||
|
{"type": "web_search"},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_llm_helper_binds_each_native_server_search_tool_offline(
|
||||||
|
provider: str,
|
||||||
|
model: str,
|
||||||
|
runtime_name: str,
|
||||||
|
base_url: str | None,
|
||||||
|
expected_tool: dict,
|
||||||
|
) -> None:
|
||||||
|
"""LLM Helper 应能离线构造并绑定各厂商的原生搜索工具。"""
|
||||||
|
runtime = {
|
||||||
|
"provider_id": provider,
|
||||||
|
"runtime": runtime_name,
|
||||||
|
"model_id": model,
|
||||||
|
"api_key": "test-key",
|
||||||
|
"base_url": base_url,
|
||||||
|
"default_headers": None,
|
||||||
|
"use_responses_api": None,
|
||||||
|
"model_record": None,
|
||||||
|
"model_metadata": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
LLMProviderManager,
|
||||||
|
"resolve_runtime",
|
||||||
|
new=AsyncMock(return_value=runtime),
|
||||||
|
):
|
||||||
|
llm = asyncio.run(
|
||||||
|
LLMHelper.get_llm(
|
||||||
|
provider=provider,
|
||||||
|
model=model,
|
||||||
|
api_key="test-key",
|
||||||
|
base_url=base_url,
|
||||||
|
web_search_mode="builtin",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
tools = LLMHelper.get_server_tools(llm)
|
||||||
|
assert tools == [expected_tool]
|
||||||
|
assert llm.bind_tools(tools) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_unavailable_server_tool_error_guides_user_to_safe_modes() -> None:
|
||||||
|
"""服务端搜索不可用时应明确告知用户可选的回退模式。"""
|
||||||
|
error = ServerToolUnavailableError(
|
||||||
|
provider="deepseek",
|
||||||
|
model="deepseek-chat",
|
||||||
|
tool_id="web_search",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert error.provider == "deepseek"
|
||||||
|
assert error.model == "deepseek-chat"
|
||||||
|
assert error.tool_id == "web_search"
|
||||||
|
assert "不支持服务端联网搜索" in str(error)
|
||||||
|
assert "自动" in str(error)
|
||||||
|
assert "MoviePilot 本地搜索" in str(error)
|
||||||
@@ -303,7 +303,7 @@ class LocalSetupLlmProviderPromptTests(unittest.TestCase):
|
|||||||
), patch.object(
|
), patch.object(
|
||||||
module, "_env_llm_thinking_level_default", return_value="auto"
|
module, "_env_llm_thinking_level_default", return_value="auto"
|
||||||
), patch.object(
|
), patch.object(
|
||||||
module, "_prompt_choice", side_effect=["auto", "minimax-cn-coding"]
|
module, "_prompt_choice", side_effect=["auto", "minimax-cn-coding", "local"]
|
||||||
):
|
):
|
||||||
config = module._collect_agent_config()
|
config = module._collect_agent_config()
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ class LlmTestEndpointTest(unittest.TestCase):
|
|||||||
user_agent="MoviePilot-Test/1.0",
|
user_agent="MoviePilot-Test/1.0",
|
||||||
use_proxy=True,
|
use_proxy=True,
|
||||||
api_protocol="responses",
|
api_protocol="responses",
|
||||||
|
web_search_mode="local",
|
||||||
)
|
)
|
||||||
self.assertTrue(resp.success)
|
self.assertTrue(resp.success)
|
||||||
self.assertEqual(resp.data["provider"], "deepseek")
|
self.assertEqual(resp.data["provider"], "deepseek")
|
||||||
@@ -190,6 +191,7 @@ class LlmTestEndpointTest(unittest.TestCase):
|
|||||||
user_agent="MoviePilot-Custom/1.0",
|
user_agent="MoviePilot-Custom/1.0",
|
||||||
use_proxy=False,
|
use_proxy=False,
|
||||||
api_protocol=None,
|
api_protocol=None,
|
||||||
|
web_search_mode=None,
|
||||||
)
|
)
|
||||||
self.assertTrue(resp.success)
|
self.assertTrue(resp.success)
|
||||||
self.assertEqual(resp.data["provider"], "openai")
|
self.assertEqual(resp.data["provider"], "openai")
|
||||||
@@ -233,6 +235,7 @@ class LlmTestEndpointTest(unittest.TestCase):
|
|||||||
user_agent=None,
|
user_agent=None,
|
||||||
use_proxy=None,
|
use_proxy=None,
|
||||||
api_protocol=None,
|
api_protocol=None,
|
||||||
|
web_search_mode=None,
|
||||||
)
|
)
|
||||||
self.assertTrue(resp.success)
|
self.assertTrue(resp.success)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""系统 LLM 服务端联网搜索配置测试。"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from app.api.endpoints import system as system_endpoint
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_env_rejects_unsupported_builtin_web_search() -> None:
|
||||||
|
"""强制不可用的服务端搜索时应拒绝保存且不部分写入配置。"""
|
||||||
|
env = {
|
||||||
|
"LLM_PROVIDER": "deepseek",
|
||||||
|
"LLM_MODEL": "deepseek-chat",
|
||||||
|
"LLM_BASE_URL": "https://api.deepseek.com",
|
||||||
|
"LLM_WEB_SEARCH_MODE": "builtin",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(type(system_endpoint.settings), "update_settings") as update_settings:
|
||||||
|
response = asyncio.run(system_endpoint.set_env_setting(env=env, _=object()))
|
||||||
|
|
||||||
|
assert response.success is False
|
||||||
|
assert "不支持服务端联网搜索" in response.message
|
||||||
|
update_settings.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_env_accepts_supported_deepseek_builtin_web_search() -> None:
|
||||||
|
"""DeepSeek V4 Flash 官方端点应允许保存强制服务端搜索。"""
|
||||||
|
env = {
|
||||||
|
"LLM_PROVIDER": "deepseek",
|
||||||
|
"LLM_MODEL": "deepseek-v4-flash",
|
||||||
|
"LLM_BASE_URL": "https://api.deepseek.com",
|
||||||
|
"LLM_WEB_SEARCH_MODE": "builtin",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
type(system_endpoint.settings),
|
||||||
|
"update_settings",
|
||||||
|
return_value={key: (True, None) for key in env},
|
||||||
|
) as update_settings, patch.object(
|
||||||
|
system_endpoint.eventmanager,
|
||||||
|
"async_send_event",
|
||||||
|
):
|
||||||
|
response = asyncio.run(system_endpoint.set_env_setting(env=env, _=object()))
|
||||||
|
|
||||||
|
assert response.success is True
|
||||||
|
update_settings.assert_called_once_with(env=env)
|
||||||
Reference in New Issue
Block a user