Files
MoviePilot/tests/test_llm_provider_manage.py
T

311 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
LLM 提供商通用管理契约(provider_manage)守护测试
验证通用模式的三条核心性质:
1. 动作词汇表由 schemas 契约层统一定义,未支持动作返回统一错误结构
2. 动作标识兼容枚举与原始字符串,统一返回 {"success", "message", "data"}
3. 端点层零提供商特色:ManageRequest 原样透传,默认值填充/校验/脱敏封闭在 Manager 内
"""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from app import schemas
from app.agent.llm.helper import LLMHelper
from app.agent.llm.provider import LLMProviderManager
from app.runtime.config import settings
from app.schemas.types import LlmProviderAction
@pytest.fixture
def manager():
return LLMProviderManager()
def test_provider_manage_rejects_unknown_action(manager):
"""动作词汇表之外的请求返回统一错误结构。"""
result = asyncio.run(manager.provider_manage("openai", "not_an_action"))
assert result["success"] is False
assert "不支持" in result["message"]
assert "data" in result
def test_provider_manage_accepts_enum_and_string_action(manager, monkeypatch):
"""动作标识兼容枚举对象与原始字符串,两种形式等价。"""
clear_mock = AsyncMock()
monkeypatch.setattr(manager, "clear_auth", clear_mock)
for action in (LlmProviderAction.DISCONNECT, "disconnect"):
result = asyncio.run(manager.provider_manage("openai", action))
assert result["success"] is True
assert result["data"] is None
assert clear_mock.await_count == 2
def test_provider_manage_test_requires_ai_agent_enabled(manager):
"""智能助手未启用时测试动作返回提示。"""
with patch.object(settings, "AI_AGENT_ENABLE", False):
result = asyncio.run(manager.provider_manage("deepseek", "test"))
assert result["success"] is False
assert result["message"] == "请先启用智能助手"
def test_provider_manage_test_requires_model(manager):
"""未配置模型时测试动作返回提示。"""
with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object(
settings, "LLM_API_KEY", "sk-test"
), patch.object(settings, "LLM_MODEL", ""):
result = asyncio.run(manager.provider_manage("deepseek", "test"))
assert result["success"] is False
assert result["message"] == "请先配置 LLM 模型"
def test_provider_manage_test_requires_api_key(manager, monkeypatch):
"""无 OAuth 授权方式且无已保存凭据的提供商必须配置 API Key。"""
monkeypatch.setattr(manager, "get_saved_auth", lambda provider_id: None)
with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object(
settings, "LLM_API_KEY", None
), patch.object(settings, "LLM_MODEL", "deepseek-chat"):
result = asyncio.run(manager.provider_manage("deepseek", "test"))
assert result["success"] is False
assert result["message"] == "请先配置 LLM API Key"
assert result["data"]["model"] == "deepseek-chat"
def test_provider_manage_test_exempts_oauth_providers_from_api_key(manager, monkeypatch):
"""支持 OAuth 授权的提供商无需 API Key,端点与 Manager 均不硬编码提供商名。"""
monkeypatch.setattr(manager, "get_saved_auth", lambda provider_id: None)
monkeypatch.setattr(
manager, "get_provider", lambda provider_id: SimpleNamespace(oauth_methods=("browser_oauth",))
)
test_mock = AsyncMock(return_value={"provider": "chatgpt", "model": "gpt-4o", "reply_preview": "OK"})
with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object(
settings, "LLM_API_KEY", None
), patch.object(LLMHelper, "test_current_settings", test_mock):
result = asyncio.run(
manager.provider_manage("chatgpt", "test", model="gpt-4o")
)
assert result["success"] is True
test_mock.assert_awaited_once()
def test_provider_manage_test_returns_reply_preview(manager, monkeypatch):
"""测试成功时返回模型响应预览,显式参数优先于已保存配置。"""
monkeypatch.setattr(manager, "get_saved_auth", lambda provider_id: None)
test_mock = AsyncMock(
return_value={"provider": "openai", "model": "gpt-4.1-mini", "duration_ms": 123, "reply_preview": "OK"}
)
with patch.object(settings, "AI_AGENT_ENABLE", False), patch.object(
LLMHelper, "test_current_settings", test_mock
):
result = asyncio.run(
manager.provider_manage(
"openai",
"test",
enabled=True,
model="gpt-4.1-mini",
thinking_level="high",
api_key="sk-live",
base_url="https://example.com/v1",
use_proxy=False,
)
)
test_mock.assert_awaited_once_with(
provider="openai",
model="gpt-4.1-mini",
thinking_level="high",
api_key="sk-live",
base_url="https://example.com/v1",
base_url_preset=None,
user_agent=None,
use_proxy=False,
api_protocol=None,
web_search_mode=None,
)
assert result["success"] is True
assert result["data"]["reply_preview"] == "OK"
def test_provider_manage_test_rejects_empty_reply(manager, monkeypatch):
"""模型响应为空时返回失败但保留结果详情。"""
monkeypatch.setattr(manager, "get_saved_auth", lambda provider_id: None)
with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object(
LLMHelper,
"test_current_settings",
AsyncMock(return_value={"provider": "deepseek", "model": "deepseek-chat", "duration_ms": 12}),
):
result = asyncio.run(
manager.provider_manage("deepseek", "test", model="deepseek-chat", api_key="sk-test")
)
assert result["success"] is False
assert result["message"] == "模型响应为空"
assert result["data"]["duration_ms"] == 12
def test_provider_manage_test_maps_timeout_error(manager, monkeypatch):
"""调用超时返回统一的超时提示。"""
monkeypatch.setattr(manager, "get_saved_auth", lambda provider_id: None)
with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object(
LLMHelper,
"test_current_settings",
AsyncMock(side_effect=TimeoutError("request timed out")),
):
result = asyncio.run(
manager.provider_manage("deepseek", "test", model="deepseek-chat", api_key="sk-test")
)
assert result["success"] is False
assert result["message"] == "LLM 调用超时"
def test_provider_manage_test_sanitizes_error_message(manager, monkeypatch):
"""错误信息中的密钥与授权头必须脱敏。"""
monkeypatch.setattr(manager, "get_saved_auth", lambda provider_id: None)
raw_error = (
"request failed api_key=sk-secret "
"Authorization: Bearer sk-secret "
"base error sk-secret"
)
with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object(
LLMHelper, "test_current_settings", AsyncMock(side_effect=RuntimeError(raw_error))
):
result = asyncio.run(
manager.provider_manage("deepseek", "test", model="deepseek-chat", api_key="sk-secret")
)
assert result["success"] is False
assert "sk-secret" not in result["message"]
assert "Authorization: Bearer" not in result["message"]
assert "***" in result["message"]
def test_provider_manage_test_maps_internal_error_to_base_url_hint(manager, monkeypatch):
"""SDK 内部响应解析错误应改写为可定位的基础地址提示。"""
monkeypatch.setattr(manager, "get_saved_auth", lambda provider_id: None)
with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object(
LLMHelper,
"test_current_settings",
AsyncMock(side_effect=RuntimeError("'str' object has no attribute 'model_dump'")),
):
result = asyncio.run(
manager.provider_manage("openai", "test", model="gpt-4o-mini", api_key="sk-test")
)
assert result["success"] is False
assert "基础地址" in result["message"]
assert "API Base URL" in result["message"]
assert "model_dump" not in result["message"]
def test_provider_manage_list_models_sanitizes_base_url_hint(manager):
"""模型列表查询遇到 SDK 内部错误时同样给出基础地址提示。"""
with patch.object(
LLMHelper,
"get_models",
AsyncMock(side_effect=AttributeError("'str' object has no attribute '_set_private_attributes'")),
):
result = asyncio.run(
manager.provider_manage("openai", "list_models", api_key="sk-test", base_url="https://example.com")
)
assert result["success"] is False
assert "基础地址" in result["message"]
assert "API Base URL" in result["message"]
assert "_set_private_attributes" not in result["message"]
def test_provider_manage_list_models_returns_catalog_with_auth_status(manager):
"""模型目录查询成功时附带授权状态摘要。"""
models = [{"id": "gpt-4o"}]
with patch.object(LLMHelper, "get_models", AsyncMock(return_value=models)), patch.object(
manager, "get_auth_status", lambda provider_id: {"connected": False}
):
result = asyncio.run(manager.provider_manage("openai", "list_models"))
assert result["success"] is True
assert result["data"]["provider"] == "openai"
assert result["data"]["models"] == models
assert result["data"]["auth_status"] == {"connected": False}
def test_llm_manage_endpoint_passes_through_manage_request(monkeypatch):
"""端点仅透传 ManageRequest,并按具名回调路由注入 OAuth 回跳地址。"""
from app.api.endpoints import llm as llm_endpoint
captured = {}
async def fake_manage(self, provider, action, **params):
captured["provider"] = provider
captured["action"] = action
captured["params"] = params
return {"success": True, "message": "", "data": {"ok": True}}
monkeypatch.setattr(LLMProviderManager, "provider_manage", fake_manage)
request = SimpleNamespace(
url_for=lambda name, **kwargs: f"https://host/api/v1/llm/provider-auth/callback/{kwargs['provider_id']}"
)
payload = schemas.ManageRequest(target="chatgpt", action="start_auth", params={"method": "browser_oauth"})
resp = asyncio.run(llm_endpoint.manage_provider(request, payload, _="token"))
assert resp.success is True
assert resp.data == {"ok": True}
assert captured["provider"] == "chatgpt"
assert captured["action"] == "start_auth"
assert captured["params"]["method"] == "browser_oauth"
assert captured["params"]["callback_url"].endswith("/callback/chatgpt")
def test_llm_manage_endpoint_accepts_empty_target(monkeypatch):
"""目录类查询动作 target 可为空,不得因 url_for 空路径参数报 500。
回归守护:前端加载提供商目录时 target 为空字符串,
回调地址构造必须跳过空 target。
"""
from app.api.endpoints import llm as llm_endpoint
captured = {}
async def fake_manage(self, provider, action, **params):
captured["provider"] = provider
captured["params"] = params
return {"success": True, "message": "", "data": []}
def fail_url_for(name, **kwargs):
raise AssertionError("空 target 不应构造回调地址")
monkeypatch.setattr(LLMProviderManager, "provider_manage", fake_manage)
request = SimpleNamespace(url_for=fail_url_for)
payload = schemas.ManageRequest(target="", action="list_providers")
resp = asyncio.run(llm_endpoint.manage_provider(request, payload, _="token"))
assert resp.success is True
assert captured["provider"] == ""
assert "callback_url" not in captured["params"]
def test_llm_manage_endpoint_response_model_accepts_list_data():
"""目录查询动作 data 为列表,响应模型须同时覆盖列表与映射形态。
回归守护:list_providers 返回 list[dict]
若响应模型声明为 Dict[str, Any] 会在序列化校验时直接 500;
同时受响应模型守护测试约束,不得使用 Any/JsonData 弱类型。
"""
from typing import Any, Dict, List, Union
from app.api.endpoints import llm as llm_endpoint
route = next(
r for r in llm_endpoint.router.routes if getattr(r, "path", "").endswith("/manage")
)
assert route.response_model is schemas.Response[
Union[List[Dict[str, Any]], Dict[str, Any]]
]
list_resp = schemas.Response[Union[List[Dict[str, Any]], Dict[str, Any]]](
success=True, message="", data=[{"id": "openai", "name": "OpenAI 兼容"}]
)
assert list_resp.data == [{"id": "openai", "name": "OpenAI 兼容"}]
dict_resp = schemas.Response[Union[List[Dict[str, Any]], Dict[str, Any]]](
success=True, message="", data={"models": ["gpt-4o"]}
)
assert dict_resp.data == {"models": ["gpt-4o"]}