Files
MoviePilot/tests/test_llm_server_tools.py
2026-08-05 19:19:26 +08:00

306 lines
8.8 KiB
Python

"""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)