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
+19
View File
@@ -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"
+1
View File
@@ -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"
+9 -7
View File
@@ -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`
# 这里临时把该名指向假类,使修补作用到 _FakeChatDeepSeekpatch 在用例结束自动还原。
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")
+63 -6
View File
@@ -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 = []
+305
View File
@@ -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(
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()
+3
View File
@@ -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)
@@ -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)