mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-03 06:29:55 +08:00
feat(agent): expand LLM provider and wizard support
This commit is contained in:
215
tests/test_llm_provider_registry.py
Normal file
215
tests/test_llm_provider_registry.py
Normal file
@@ -0,0 +1,215 @@
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
|
||||
def _stub_module(name: str, **attrs):
|
||||
module = sys.modules.get(name)
|
||||
if module is None:
|
||||
module = ModuleType(name)
|
||||
sys.modules[name] = module
|
||||
for key, value in attrs.items():
|
||||
setattr(module, key, value)
|
||||
return module
|
||||
|
||||
|
||||
class _DummyLogger:
|
||||
def __getattr__(self, _name):
|
||||
return lambda *args, **kwargs: None
|
||||
|
||||
|
||||
class _DummySystemConfigOper:
|
||||
def get(self, _key):
|
||||
return {}
|
||||
|
||||
async def async_set(self, _key, _value):
|
||||
return None
|
||||
|
||||
|
||||
for _module_name in ("aiofiles", "jwt"):
|
||||
_stub_module(_module_name)
|
||||
|
||||
_stub_module(
|
||||
"app.core.config",
|
||||
settings=SimpleNamespace(
|
||||
TEMP_PATH="/tmp",
|
||||
PROXY_HOST=None,
|
||||
LLM_MAX_CONTEXT_TOKENS=64,
|
||||
),
|
||||
)
|
||||
_stub_module("app.db.systemconfig_oper", SystemConfigOper=_DummySystemConfigOper)
|
||||
_stub_module("app.log", logger=_DummyLogger())
|
||||
_stub_module("app.schemas.types", SystemConfigKey=SimpleNamespace(AIAgentConfig="agent"))
|
||||
|
||||
provider_path = Path(__file__).resolve().parents[1] / "app" / "agent" / "llm" / "provider.py"
|
||||
spec = importlib.util.spec_from_file_location("test_llm_provider_module", provider_path)
|
||||
provider_module = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
sys.modules[spec.name] = provider_module
|
||||
spec.loader.exec_module(provider_module)
|
||||
|
||||
LLMProviderError = provider_module.LLMProviderError
|
||||
LLMProviderManager = provider_module.LLMProviderManager
|
||||
|
||||
|
||||
class LlmProviderRegistryTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
LLMProviderManager._instances.clear()
|
||||
|
||||
def tearDown(self):
|
||||
LLMProviderManager._instances.clear()
|
||||
|
||||
def test_dynamic_provider_is_exposed_from_models_dev_cache(self):
|
||||
manager = LLMProviderManager()
|
||||
manager._models_dev_data = {
|
||||
"frogbot": {
|
||||
"id": "frogbot",
|
||||
"name": "FrogBot",
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"env": ["FROGBOT_API_KEY"],
|
||||
"api": "https://app.frogbot.ai/api/v1",
|
||||
"models": {},
|
||||
}
|
||||
}
|
||||
|
||||
provider = manager.get_provider("frogbot")
|
||||
|
||||
self.assertEqual(provider.id, "frogbot")
|
||||
self.assertEqual(provider.runtime, "openai_compatible")
|
||||
self.assertEqual(provider.default_base_url, "https://app.frogbot.ai/api/v1")
|
||||
self.assertFalse(provider.requires_base_url)
|
||||
self.assertTrue(provider.base_url_editable)
|
||||
self.assertEqual(provider.model_list_strategy, "models_dev_only")
|
||||
|
||||
def test_dynamic_provider_override_normalizes_chat_endpoint_base_url(self):
|
||||
manager = LLMProviderManager()
|
||||
manager._models_dev_data = {
|
||||
"bailing": {
|
||||
"id": "bailing",
|
||||
"name": "Bailing",
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"env": ["BAILING_API_TOKEN"],
|
||||
"api": "https://api.tbox.cn/api/llm/v1/chat/completions",
|
||||
"models": {},
|
||||
}
|
||||
}
|
||||
|
||||
provider = manager.get_provider("bailing")
|
||||
|
||||
self.assertEqual(provider.default_base_url, "https://api.tbox.cn/api/llm/v1")
|
||||
self.assertEqual(provider.api_key_label, "API Token")
|
||||
|
||||
def test_dynamic_provider_skips_alias_only_models_dev_ids(self):
|
||||
manager = LLMProviderManager()
|
||||
manager._models_dev_data = {
|
||||
"moonshotai": {
|
||||
"id": "moonshotai",
|
||||
"name": "Moonshot AI Intl",
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"env": ["MOONSHOT_API_KEY"],
|
||||
"api": "https://api.moonshot.ai/v1",
|
||||
"models": {},
|
||||
}
|
||||
}
|
||||
|
||||
with self.assertRaises(LLMProviderError):
|
||||
manager.get_provider("moonshotai")
|
||||
|
||||
def test_dynamic_provider_skips_incompatible_models_dev_provider(self):
|
||||
manager = LLMProviderManager()
|
||||
manager._models_dev_data = {
|
||||
"azure": {
|
||||
"id": "azure",
|
||||
"name": "Azure",
|
||||
"npm": "@ai-sdk/azure",
|
||||
"env": ["AZURE_API_KEY"],
|
||||
"models": {},
|
||||
}
|
||||
}
|
||||
|
||||
with self.assertRaises(LLMProviderError):
|
||||
manager.get_provider("azure")
|
||||
|
||||
def test_dynamic_provider_without_known_base_url_requires_manual_input(self):
|
||||
manager = LLMProviderManager()
|
||||
manager._models_dev_data = {
|
||||
"custom-anthropic": {
|
||||
"id": "custom-anthropic",
|
||||
"name": "Custom Anthropic",
|
||||
"npm": "@ai-sdk/anthropic",
|
||||
"env": ["CUSTOM_ANTHROPIC_KEY"],
|
||||
"models": {},
|
||||
}
|
||||
}
|
||||
|
||||
provider = manager.get_provider("custom-anthropic")
|
||||
|
||||
self.assertEqual(provider.runtime, "anthropic_compatible")
|
||||
self.assertTrue(provider.requires_base_url)
|
||||
self.assertTrue(provider.base_url_editable)
|
||||
self.assertEqual(provider.model_list_strategy, "anthropic_compatible")
|
||||
|
||||
def test_list_providers_async_loads_models_dev_before_serializing(self):
|
||||
manager = LLMProviderManager()
|
||||
payload = {
|
||||
"frogbot": {
|
||||
"id": "frogbot",
|
||||
"name": "FrogBot",
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"env": ["FROGBOT_API_KEY"],
|
||||
"api": "https://app.frogbot.ai/api/v1",
|
||||
"models": {},
|
||||
}
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
manager,
|
||||
"get_models_dev_data",
|
||||
AsyncMock(side_effect=lambda force_refresh=False: manager.__dict__.update({"_models_dev_data": payload}) or payload),
|
||||
) as fetch_mock:
|
||||
providers = asyncio.run(manager.list_providers_async())
|
||||
|
||||
fetch_mock.assert_awaited_once_with(force_refresh=False)
|
||||
self.assertIn("frogbot", {item["id"] for item in providers})
|
||||
|
||||
def test_list_models_uses_dynamic_provider_after_refresh(self):
|
||||
manager = LLMProviderManager()
|
||||
payload = {
|
||||
"frogbot": {
|
||||
"id": "frogbot",
|
||||
"name": "FrogBot",
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"env": ["FROGBOT_API_KEY"],
|
||||
"api": "https://app.frogbot.ai/api/v1",
|
||||
"models": {
|
||||
"frog-1": {
|
||||
"name": "Frog 1",
|
||||
"limit": {"context": 131072},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async def _load_models_dev(force_refresh: bool = False):
|
||||
manager._models_dev_data = payload
|
||||
return payload
|
||||
|
||||
with patch.object(manager, "get_models_dev_data", AsyncMock(side_effect=_load_models_dev)):
|
||||
models = asyncio.run(
|
||||
manager.list_models(
|
||||
provider_id="frogbot",
|
||||
api_key="sk-test",
|
||||
force_refresh=True,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual([item["id"] for item in models], ["frog-1"])
|
||||
self.assertEqual(models[0]["source"], "models.dev")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
189
tests/test_local_setup_llm_provider_prompt.py
Normal file
189
tests/test_local_setup_llm_provider_prompt.py
Normal file
@@ -0,0 +1,189 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import unittest
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "local_setup.py"
|
||||
|
||||
|
||||
def load_local_setup_module():
|
||||
module_name = f"moviepilot_local_setup_llm_{uuid.uuid4().hex}"
|
||||
spec = importlib.util.spec_from_file_location(module_name, MODULE_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class LocalSetupLlmProviderPromptTests(unittest.TestCase):
|
||||
def test_collect_agent_config_prefers_loaded_provider_directory(self):
|
||||
module = load_local_setup_module()
|
||||
|
||||
provider_definitions = [
|
||||
{
|
||||
"id": "frogbot",
|
||||
"name": "FrogBot",
|
||||
"default_base_url": "https://app.frogbot.ai/api/v1",
|
||||
"api_key_label": "API Key",
|
||||
}
|
||||
]
|
||||
models = [
|
||||
{"id": "frog-1", "name": "Frog 1", "context_tokens_k": 128},
|
||||
{"id": "frog-2", "name": "Frog 2"},
|
||||
]
|
||||
|
||||
with patch.object(module, "print_step"), patch.object(
|
||||
module, "_prompt_yes_no", side_effect=[True, False, True]
|
||||
), patch.object(
|
||||
module, "_load_llm_provider_definitions", return_value=provider_definitions
|
||||
), patch.object(
|
||||
module, "_prompt_provider_choice", return_value="frogbot"
|
||||
) as provider_prompt, patch.object(
|
||||
module, "_prompt_text", side_effect=["https://override.example.com/v1"]
|
||||
), patch.object(
|
||||
module, "_prompt_secret_text", return_value="sk-frog"
|
||||
), patch.object(
|
||||
module, "_load_llm_models", return_value=models
|
||||
) as load_models, patch.object(
|
||||
module, "_prompt_model_choice", return_value="frog-2"
|
||||
) as model_prompt, patch.object(
|
||||
module, "read_env_value", return_value=None
|
||||
), patch.object(
|
||||
module, "_env_default", side_effect=lambda key, default="": default
|
||||
), patch.object(
|
||||
module, "_env_bool", side_effect=lambda key, default: default
|
||||
), patch.object(
|
||||
module, "_env_llm_thinking_level_default", return_value="auto"
|
||||
), patch.object(
|
||||
module, "_prompt_choice", return_value="auto"
|
||||
):
|
||||
config = module._collect_agent_config(runtime_python=Path("/tmp/runtime-python"))
|
||||
|
||||
provider_prompt.assert_called_once()
|
||||
load_models.assert_called_once_with(
|
||||
provider="frogbot",
|
||||
api_key="sk-frog",
|
||||
base_url="https://override.example.com/v1",
|
||||
runtime_python=Path("/tmp/runtime-python"),
|
||||
)
|
||||
model_prompt.assert_called_once_with(models, default="")
|
||||
self.assertEqual(config["LLM_PROVIDER"], "frogbot")
|
||||
self.assertEqual(config["LLM_MODEL"], "frog-2")
|
||||
self.assertEqual(config["LLM_API_KEY"], "sk-frog")
|
||||
self.assertEqual(config["LLM_BASE_URL"], "https://override.example.com/v1")
|
||||
|
||||
def test_collect_agent_config_falls_back_to_common_provider_choices(self):
|
||||
module = load_local_setup_module()
|
||||
|
||||
with patch.object(module, "print_step"), patch.object(
|
||||
module, "_prompt_yes_no", side_effect=[True, False, True]
|
||||
), patch.object(
|
||||
module, "_load_llm_provider_definitions", return_value=[]
|
||||
), patch.object(
|
||||
module, "_prompt_provider_choice", return_value="anthropic"
|
||||
), patch.object(
|
||||
module, "_prompt_text", side_effect=["https://api.anthropic.com/v1"]
|
||||
), patch.object(
|
||||
module, "_prompt_secret_text", return_value="sk-anthropic"
|
||||
), patch.object(
|
||||
module, "_load_llm_models", return_value=[]
|
||||
), patch.object(
|
||||
module, "_prompt_model_choice", return_value="claude-sonnet-4-0"
|
||||
), patch.object(
|
||||
module, "read_env_value", return_value=None
|
||||
), patch.object(
|
||||
module, "_env_default", side_effect=lambda key, default="": default
|
||||
), patch.object(
|
||||
module, "_env_bool", side_effect=lambda key, default: default
|
||||
), patch.object(
|
||||
module, "_env_llm_thinking_level_default", return_value="off"
|
||||
), patch.object(
|
||||
module, "_prompt_choice", return_value="off"
|
||||
):
|
||||
config = module._collect_agent_config()
|
||||
|
||||
self.assertEqual(config["LLM_PROVIDER"], "anthropic")
|
||||
self.assertEqual(config["LLM_MODEL"], "claude-sonnet-4-0")
|
||||
self.assertEqual(config["LLM_BASE_URL"], "https://api.anthropic.com/v1")
|
||||
|
||||
def test_prompt_model_choice_accepts_index_selection(self):
|
||||
module = load_local_setup_module()
|
||||
|
||||
with patch.object(module, "_print_llm_models") as print_models, patch(
|
||||
"builtins.input", return_value="2"
|
||||
):
|
||||
model = module._prompt_model_choice(
|
||||
[
|
||||
{"id": "model-a", "name": "Model A"},
|
||||
{"id": "model-b", "name": "Model B"},
|
||||
],
|
||||
default="model-a",
|
||||
)
|
||||
|
||||
print_models.assert_called_once()
|
||||
self.assertEqual(model, "model-b")
|
||||
|
||||
def test_prompt_model_choice_falls_back_to_text_input_when_empty(self):
|
||||
module = load_local_setup_module()
|
||||
|
||||
with patch.object(module, "_prompt_text", return_value="custom-model") as prompt_text:
|
||||
model = module._prompt_model_choice([], default="")
|
||||
|
||||
prompt_text.assert_called_once_with("LLM 模型名称", default="")
|
||||
self.assertEqual(model, "custom-model")
|
||||
|
||||
def test_load_llm_provider_definitions_inner_uses_direct_provider_module_loader(self):
|
||||
module = load_local_setup_module()
|
||||
|
||||
class _FakeManager:
|
||||
async def list_providers_async(self, force_refresh: bool = False):
|
||||
return [{"id": "frogbot", "name": "FrogBot"}]
|
||||
|
||||
class _FakeProviderModule:
|
||||
@staticmethod
|
||||
def LLMProviderManager():
|
||||
return _FakeManager()
|
||||
|
||||
fake_provider_module = _FakeProviderModule()
|
||||
|
||||
with patch.object(
|
||||
module,
|
||||
"_load_llm_provider_module",
|
||||
return_value=fake_provider_module,
|
||||
) as loader:
|
||||
providers = module._load_llm_provider_definitions_inner()
|
||||
|
||||
loader.assert_called_once_with()
|
||||
self.assertEqual(providers, [{"id": "frogbot", "name": "FrogBot"}])
|
||||
|
||||
def test_llm_provider_choice_map_skips_oauth_only_provider(self):
|
||||
module = load_local_setup_module()
|
||||
|
||||
choices = module._llm_provider_choice_map(
|
||||
[
|
||||
{"id": "chatgpt", "name": "ChatGPT", "supports_api_key": True},
|
||||
{"id": "github-copilot", "name": "GitHub Copilot", "supports_api_key": False},
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(choices, {"chatgpt": "ChatGPT"})
|
||||
|
||||
def test_prompt_provider_choice_accepts_custom_provider_id(self):
|
||||
module = load_local_setup_module()
|
||||
|
||||
with patch("builtins.input", return_value="my-provider_01"), patch("builtins.print"):
|
||||
provider = module._prompt_provider_choice(
|
||||
"选择 LLM 提供商",
|
||||
{"deepseek": "DeepSeek", "google": "Google"},
|
||||
default="deepseek",
|
||||
)
|
||||
|
||||
self.assertEqual(provider, "my-provider_01")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user