diff --git a/app/agent/__init__.py b/app/agent/__init__.py index f186b9d4e..aeb22147e 100644 --- a/app/agent/__init__.py +++ b/app/agent/__init__.py @@ -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(), diff --git a/app/agent/llm/helper.py b/app/agent/llm/helper.py index be82527bf..273cd173e 100644 --- a/app/agent/llm/helper.py +++ b/app/agent/llm/helper.py @@ -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]: diff --git a/app/agent/llm/server_tools.py b/app/agent/llm/server_tools.py new file mode 100644 index 000000000..8d7bd1609 --- /dev/null +++ b/app/agent/llm/server_tools.py @@ -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", + ) diff --git a/app/agent/middleware/subagents.py b/app/agent/middleware/subagents.py index ee4356e2b..544ca66d6 100644 --- a/app/agent/middleware/subagents.py +++ b/app/agent/middleware/subagents.py @@ -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, ) diff --git a/app/api/endpoints/llm.py b/app/api/endpoints/llm.py index 08f8cd092..57180bbf5 100644 --- a/app/api/endpoints/llm.py +++ b/app/api/endpoints/llm.py @@ -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 diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index 9536306d6..374182672 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -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]} diff --git a/app/core/config.py b/app/core/config.py index 2fc6eeeae..956271cc2 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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 diff --git a/app/schemas/event.py b/app/schemas/event.py index 23a832192..d4eb3de91 100644 --- a/app/schemas/event.py +++ b/app/schemas/event.py @@ -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="选择来源") diff --git a/docs/cli.md b/docs/cli.md index f41150846..ebcd8c154 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -238,6 +238,10 @@ moviepilot setup --config-dir /path/to/moviepilot-config - 默认下载目录与媒体库目录 - AI Agent 可按需启用,并配置 `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 等参数 - 开机自启 diff --git a/scripts/local_setup.py b/scripts/local_setup.py index 9d5fe40fc..050c5da45 100644 --- a/scripts/local_setup.py +++ b/scripts/local_setup.py @@ -2093,6 +2093,21 @@ def _collect_agent_config( runtime_python=runtime_python, ) 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 diff --git a/tests/test_agent_llm_runtime_config.py b/tests/test_agent_llm_runtime_config.py index d3394d84c..050541079 100644 --- a/tests/test_agent_llm_runtime_config.py +++ b/tests/test_agent_llm_runtime_config.py @@ -84,3 +84,22 @@ def test_resolve_llm_runtime_config_prefers_plugin_api_protocol(monkeypatch) -> runtime_config = asyncio.run(agent._resolve_llm_runtime_config()) 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" diff --git a/tests/test_agent_tokens_events.py b/tests/test_agent_tokens_events.py index 54544f9bb..c87b0a874 100644 --- a/tests/test_agent_tokens_events.py +++ b/tests/test_agent_tokens_events.py @@ -88,6 +88,7 @@ def test_initialize_llm_uses_chain_event_selection(monkeypatch) -> None: use_proxy=True, thinking_level="xhigh", api_protocol="auto", + web_search_mode="local", ) assert agent._llm_provider_selection["selected_provider_id"] == "provider-1" diff --git a/tests/test_langchain_deepseek_compat.py b/tests/test_langchain_deepseek_compat.py index 1f892553d..c548b1761 100644 --- a/tests/test_langchain_deepseek_compat.py +++ b/tests/test_langchain_deepseek_compat.py @@ -1,5 +1,4 @@ import unittest -from unittest.mock import patch from langchain_core.messages import AIMessage, HumanMessage, ToolMessage @@ -54,12 +53,15 @@ class DeepSeekCompatPatchTest(unittest.TestCase): _FakeChatDeepSeek._get_request_payload = _ORIGINAL_GET_REQUEST_PAYLOAD if hasattr(_FakeChatDeepSeek, "_moviepilot_reasoning_content_patched"): delattr(_FakeChatDeepSeek, "_moviepilot_reasoning_content_patched") - # helper 的修补函数内部 `from langchain_deepseek import ChatDeepSeek`, - # 这里临时把该名指向假类,使修补作用到 _FakeChatDeepSeek;patch 在用例结束自动还原。 - patcher = patch("langchain_deepseek.ChatDeepSeek", _FakeChatDeepSeek) - patcher.start() - self.addCleanup(patcher.stop) - llm_module._patch_deepseek_reasoning_content_support() + llm_module._patch_interleaved_reasoning_request_support( + _FakeChatDeepSeek, + patch_marker="_moviepilot_reasoning_content_patched", + thinking_filter=lambda model_name, extra_body: ( + llm_module._is_deepseek_thinking_enabled(model_name, extra_body) + ), + normalize_deepseek_messages=True, + inject_missing_as_empty=True, + ) def test_injects_reasoning_content_for_assistant_tool_calls(self): llm = _FakeChatDeepSeek("deepseek-v4-pro") diff --git a/tests/test_llm_helper_testcall.py b/tests/test_llm_helper_testcall.py index 19afa63b7..44a1c9223 100644 --- a/tests/test_llm_helper_testcall.py +++ b/tests/test_llm_helper_testcall.py @@ -245,6 +245,7 @@ class LlmHelperTestCallTest(unittest.TestCase): user_agent=None, use_proxy=None, api_protocol=None, + web_search_mode=None, ) self.assertEqual(result["provider"], "deepseek") self.assertEqual(result["model"], "deepseek-chat") @@ -439,8 +440,8 @@ class LlmHelperTestCallTest(unittest.TestCase): {"langchain_deepseek": SimpleNamespace(ChatDeepSeek=_FakeChatDeepSeek)}, ), patch.object( llm_module, - "_patch_deepseek_reasoning_content_support", - side_effect=lambda: patch_calls.append(True), + "_patch_interleaved_reasoning_request_support", + side_effect=lambda *args, **kwargs: patch_calls.append((args, kwargs)), ): asyncio.run( llm_module.LLMHelper.get_llm( @@ -457,7 +458,8 @@ class LlmHelperTestCallTest(unittest.TestCase): calls[0].get("extra_body"), {"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("api_base"), "https://api.deepseek.com") @@ -476,8 +478,8 @@ class LlmHelperTestCallTest(unittest.TestCase): {"langchain_deepseek": SimpleNamespace(ChatDeepSeek=_FakeChatDeepSeek)}, ), patch.object( llm_module, - "_patch_deepseek_reasoning_content_support", - side_effect=lambda: patch_calls.append(True), + "_patch_interleaved_reasoning_request_support", + side_effect=lambda *args, **kwargs: patch_calls.append((args, kwargs)), ): asyncio.run( llm_module.LLMHelper.get_llm( @@ -494,10 +496,65 @@ class LlmHelperTestCallTest(unittest.TestCase): calls[0].get("extra_body"), {"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.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): calls = [] diff --git a/tests/test_llm_server_tools.py b/tests/test_llm_server_tools.py new file mode 100644 index 000000000..7aad4ea37 --- /dev/null +++ b/tests/test_llm_server_tools.py @@ -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) diff --git a/tests/test_local_setup_llm_provider_prompt.py b/tests/test_local_setup_llm_provider_prompt.py index b5f0957f3..516115fd1 100644 --- a/tests/test_local_setup_llm_provider_prompt.py +++ b/tests/test_local_setup_llm_provider_prompt.py @@ -303,7 +303,7 @@ class LocalSetupLlmProviderPromptTests(unittest.TestCase): ), patch.object( module, "_env_llm_thinking_level_default", return_value="auto" ), 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() diff --git a/tests/test_system_llm_test.py b/tests/test_system_llm_test.py index 269cc9bd2..04460ede9 100644 --- a/tests/test_system_llm_test.py +++ b/tests/test_system_llm_test.py @@ -138,6 +138,7 @@ class LlmTestEndpointTest(unittest.TestCase): user_agent="MoviePilot-Test/1.0", use_proxy=True, api_protocol="responses", + web_search_mode="local", ) self.assertTrue(resp.success) self.assertEqual(resp.data["provider"], "deepseek") @@ -190,6 +191,7 @@ class LlmTestEndpointTest(unittest.TestCase): user_agent="MoviePilot-Custom/1.0", use_proxy=False, api_protocol=None, + web_search_mode=None, ) self.assertTrue(resp.success) self.assertEqual(resp.data["provider"], "openai") @@ -233,6 +235,7 @@ class LlmTestEndpointTest(unittest.TestCase): user_agent=None, use_proxy=None, api_protocol=None, + web_search_mode=None, ) self.assertTrue(resp.success) diff --git a/tests/test_system_llm_web_search_config.py b/tests/test_system_llm_web_search_config.py new file mode 100644 index 000000000..c87b79aad --- /dev/null +++ b/tests/test_system_llm_web_search_config.py @@ -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)