mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08:00
refactor(api): LLM 提供商管理端点收敛为通用 manage 接口,端点层零特色
- schemas 新增 LlmProviderAction 公共动作词汇表
- LLMProviderManager 新增 provider_manage 统一入口,默认值填充、
API Key 豁免判断、密钥脱敏与错误归因改写全部下沉封闭,
不再硬编码 chatgpt/github-copilot 等产品名
- endpoint 收敛为 POST /llm/manage(ManageRequest 透传);
OAuth 回跳地址由端点按具名回调路由统一构造后注入动作参数,
/provider-auth/callback/{provider_id} 因浏览器回跳协议约束保留具名路由
- 新增 14 项 provider_manage 契约守护测试,
替换原针对端点函数的两个旧测试文件
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
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")
|
||||
Reference in New Issue
Block a user