feat: 支持模型服务端联网搜索

This commit is contained in:
jxxghp
2026-08-05 19:19:26 +08:00
parent 48f4bd5f18
commit a23ac6c56d
18 changed files with 999 additions and 124 deletions
+25 -3
View File
@@ -730,6 +730,7 @@ class MoviePilotAgent:
use_proxy=settings.LLM_USE_PROXY,
thinking_level=settings.LLM_THINKING_LEVEL,
api_protocol=settings.LLM_API_PROTOCOL,
web_search_mode=settings.LLM_WEB_SEARCH_MODE,
)
selected_event = await eventmanager.async_send_event(
ChainEventType.AgentLLMProvider,
@@ -773,6 +774,9 @@ class MoviePilotAgent:
api_protocol = self._clean_optional_text(
self._get_event_value(resolved_data, "api_protocol")
) or settings.LLM_API_PROTOCOL
web_search_mode = self._clean_optional_text(
self._get_event_value(resolved_data, "web_search_mode")
) or settings.LLM_WEB_SEARCH_MODE
selected_provider_id = self._clean_optional_text(
self._get_event_value(resolved_data, "selected_provider_id")
)
@@ -799,6 +803,7 @@ class MoviePilotAgent:
"use_proxy": bool(use_proxy),
"thinking_level": thinking_level,
"api_protocol": api_protocol,
"web_search_mode": web_search_mode,
}
return self._llm_runtime_config
@@ -1006,6 +1011,13 @@ class MoviePilotAgent:
allow_message_tools=self.allow_message_tools,
)
@staticmethod
def _filter_local_web_search_tools(tools: List, enabled: bool) -> List:
"""按联网搜索策略保留或移除本地 search_web 工具。"""
if enabled:
return tools
return [tool for tool in tools if getattr(tool, "name", None) != "search_web"]
def _refresh_tool_context(self, values: Dict[str, object]) -> None:
"""
刷新本轮工具共享上下文。
@@ -1035,6 +1047,7 @@ class MoviePilotAgent:
bool(runtime_config.get("use_proxy")),
runtime_config.get("thinking_level"),
runtime_config.get("api_protocol"),
runtime_config.get("web_search_mode"),
)
async def _agent_bundle_signature(self, streaming: bool) -> tuple[Any, ...]:
@@ -1165,6 +1178,8 @@ class MoviePilotAgent:
# LLM 模型(用于 agent 执行)
agent_model = await self._initialize_llm(streaming=streaming)
self._sync_model_profile(agent_model)
server_tools = LLMHelper.get_server_tools(agent_model)
use_local_web_search = LLMHelper.should_use_local_web_search(agent_model)
# 为内部模型调用准备非流式 LLM,避免与用户流式回复复用同一实例。
non_streaming_model = (
@@ -1174,7 +1189,10 @@ class MoviePilotAgent:
)
# 工具列表
tools = self._initialize_tools()
tools = self._filter_local_web_search_tools(
self._initialize_tools(),
enabled=use_local_web_search,
)
tools.extend(await self._initialize_mcp_tools())
skills_middleware = SkillsMiddleware(
sources=[str(agent_runtime_manager.skills_dir)],
@@ -1192,11 +1210,15 @@ class MoviePilotAgent:
activity_log_tools = list(
getattr(activity_log_middleware, "tools", []) or []
)
subagent_tools = self._initialize_subagent_tools()
subagent_tools = self._filter_local_web_search_tools(
self._initialize_subagent_tools(),
enabled=use_local_web_search,
)
subagent_tools.extend(await self._initialize_subagent_mcp_tools())
subagent_middlewares, subagent_task_tools = create_subagent_middlewares(
model=non_streaming_model,
tools=subagent_tools,
server_tools=server_tools,
stream_handler=self.stream_handler,
)
max_tools = settings.LLM_MAX_TOOLS
@@ -1271,7 +1293,7 @@ class MoviePilotAgent:
agent = create_agent(
model=agent_model,
tools=[*tools, *skill_tools, *activity_log_tools],
tools=[*tools, *skill_tools, *activity_log_tools, *server_tools],
system_prompt=system_prompt,
middleware=middlewares,
checkpointer=InMemorySaver(),
+197 -106
View File
@@ -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]:
+249
View File
@@ -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",
)
+11 -1
View File
@@ -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,
)
+3
View File
@@ -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
+45
View File
@@ -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]}
+2
View File
@@ -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
+1
View File
@@ -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="选择来源")