mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
feat: 支持模型服务端联网搜索
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user