mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-10 16:04:37 +08:00
306 lines
8.8 KiB
Python
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)
|