Files
MoneyPrinterTurbo/test/services/test_llm.py
2026-06-12 14:59:20 +08:00

506 lines
19 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.

import os
import sys
import tempfile
import types
import unittest
from pathlib import Path
from unittest.mock import patch
from pydantic import ValidationError
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from app.config import config
from app.models.schema import VideoScriptRequest
from app.services import llm
class TestScriptPromptOptions(unittest.TestCase):
def test_build_script_prompt_appends_advanced_requirements(self):
"""
高级文案要求只作为附加约束,不替换默认系统提示词。
这样普通用户不配置时仍然走稳定默认规则,高级用户也能细化风格。
"""
prompt = llm.build_script_prompt(
video_subject="咖啡",
language="zh-CN",
paragraph_number=3,
video_script_prompt="语气轻松,面向程序员",
)
self.assertIn("# Role: Video Script Generator", prompt)
self.assertIn("- video subject: 咖啡", prompt)
self.assertIn("- number of paragraphs: 3", prompt)
self.assertIn("- language: zh-CN", prompt)
self.assertIn("# Additional User Requirements:", prompt)
self.assertIn("语气轻松,面向程序员", prompt)
def test_custom_system_prompt_keeps_runtime_context(self):
"""
自定义 system prompt 会替换默认脚本规则,但视频主题、语言、段落数
仍由服务层统一追加,避免高级用户漏写必要上下文。
"""
prompt = llm.build_script_prompt(
video_subject="露营",
language="en",
paragraph_number=2,
custom_system_prompt="Only write cinematic narration.",
)
self.assertNotIn("# Role: Video Script Generator", prompt)
self.assertIn("Only write cinematic narration.", prompt)
self.assertIn("- video subject: 露营", prompt)
self.assertIn("- number of paragraphs: 2", prompt)
self.assertIn("- language: en", prompt)
def test_generate_script_sends_custom_prompt_to_llm(self):
captured = {}
def fake_generate_response(prompt):
captured["prompt"] = prompt
return "第一段。\n\n第二段。"
with patch.object(llm, "_generate_response", side_effect=fake_generate_response):
result = llm.generate_script(
video_subject="咖啡",
language="zh-CN",
paragraph_number=2,
video_script_prompt="开头更有悬念",
)
self.assertEqual(result, "第一段。\n\n第二段。")
self.assertIn("- number of paragraphs: 2", captured["prompt"])
self.assertIn("开头更有悬念", captured["prompt"])
def test_video_script_request_rejects_invalid_advanced_options(self):
"""
API 请求模型需要限制高级 prompt 参数,避免外部调用绕过 WebUI
传入异常段落数或超长提示词,导致模型成本和结果不可控。
"""
with self.assertRaises(ValidationError):
VideoScriptRequest(video_subject="咖啡", paragraph_number=0)
with self.assertRaises(ValidationError):
VideoScriptRequest(
video_subject="咖啡",
video_script_prompt="x" * (llm.MAX_SCRIPT_PROMPT_LENGTH + 1),
)
class TestLiteLLMProvider(unittest.TestCase):
def setUp(self):
self.original_app_config = dict(config.app)
def tearDown(self):
config.app.clear()
config.app.update(self.original_app_config)
def _use_litellm_provider(self, model_name="openai/gpt-4o-mini"):
config.app["llm_provider"] = "litellm"
config.app["litellm_model_name"] = model_name
def test_litellm_provider_returns_normalized_text(self):
"""
验证 LiteLLM provider 的主路径不依赖真实网络和私有 API key。
这里用 fake module 注入 `sys.modules`,直接覆盖动态 import 的
`litellm.completion()`,确保测试稳定覆盖 `_generate_response()` 里的
litellm 分支。
"""
self._use_litellm_provider()
fake_litellm = types.SimpleNamespace()
def _completion(**kwargs):
self.assertEqual(kwargs["model"], "openai/gpt-4o-mini")
self.assertEqual(
kwargs["messages"], [{"role": "user", "content": "Say hello"}]
)
self.assertTrue(kwargs["drop_params"])
message = types.SimpleNamespace(content="hello\nworld")
choice = types.SimpleNamespace(message=message)
return types.SimpleNamespace(choices=[choice])
fake_litellm.completion = _completion
with patch.dict(sys.modules, {"litellm": fake_litellm}):
result = llm._generate_response("Say hello")
self.assertEqual(result, "helloworld")
def test_litellm_provider_requires_model_name(self):
self._use_litellm_provider(model_name="")
result = llm._generate_response("test")
self.assertIn("Error:", result)
self.assertIn("model_name is not set", result)
def test_litellm_provider_handles_empty_response(self):
self._use_litellm_provider()
fake_litellm = types.SimpleNamespace(
completion=lambda **kwargs: types.SimpleNamespace(choices=[])
)
with patch.dict(sys.modules, {"litellm": fake_litellm}):
result = llm._generate_response("test")
self.assertIn("Error:", result)
self.assertIn("returned empty response", result)
def test_litellm_provider_handles_empty_message(self):
"""
某些 OpenAI-compatible 网关在内容过滤或安全拦截时会返回
HTTP 200但 `choices[0].message` 为 None。这里必须返回
可诊断的错误,而不是抛出 AttributeError。
"""
self._use_litellm_provider()
fake_litellm = types.SimpleNamespace(
completion=lambda **kwargs: types.SimpleNamespace(
choices=[types.SimpleNamespace(message=None)]
)
)
with patch.dict(sys.modules, {"litellm": fake_litellm}):
result = llm._generate_response("test")
self.assertIn("Error:", result)
self.assertIn("returned empty message", result)
def test_openai_provider_still_uses_existing_path(self):
config.app["llm_provider"] = "openai"
config.app["openai_api_key"] = ""
config.app["openai_base_url"] = "https://api.openai.com/v1"
config.app["openai_model_name"] = "gpt-4o-mini"
result = llm._generate_response("test")
self.assertIn("Error:", result)
self.assertIn("api_key is not set", result)
self.assertNotIn("litellm", result.lower())
def test_grok_provider_still_uses_existing_path(self):
config.app["llm_provider"] = "grok"
config.app["grok_api_key"] = ""
config.app["grok_base_url"] = "https://api.x.ai/v1"
config.app["grok_model_name"] = "grok-4.3"
result = llm._generate_response("test")
self.assertIn("Error:", result)
self.assertIn("api_key is not set", result)
self.assertNotIn("litellm", result.lower())
def _use_ollama_provider(self, base_url=""):
config.app["llm_provider"] = "ollama"
config.app["ollama_api_key"] = ""
config.app["ollama_base_url"] = base_url
config.app["ollama_model_name"] = "llama3"
def _assert_ollama_base_url(self, expected_base_url: str):
class FakeCompletions:
def create(self, **kwargs):
self.kwargs = kwargs
message = types.SimpleNamespace(content="hello\nollama")
choice = types.SimpleNamespace(message=message)
return types.SimpleNamespace(choices=[choice])
fake_completions = FakeCompletions()
fake_client = types.SimpleNamespace(
chat=types.SimpleNamespace(completions=fake_completions)
)
with (
patch.object(llm, "OpenAI", return_value=fake_client) as openai_client,
patch.object(llm, "ChatCompletion", types.SimpleNamespace),
):
result = llm._generate_response("Say hello")
openai_client.assert_called_once_with(
api_key="ollama",
base_url=expected_base_url,
)
self.assertEqual(
fake_completions.kwargs,
{
"model": "llama3",
"messages": [{"role": "user", "content": "Say hello"}],
},
)
self.assertEqual(result, "helloollama")
def test_ollama_default_base_url_uses_localhost_outside_container(self):
"""
普通本机运行时Ollama 默认仍然使用 localhost避免影响已有用户。
"""
self._use_ollama_provider()
with patch.object(config, "is_running_in_container", return_value=False):
self._assert_ollama_base_url("http://localhost:11434/v1")
def test_ollama_default_base_url_uses_host_gateway_inside_container(self):
"""
容器内运行时localhost 指向容器自身;默认改为 host.docker.internal
方便 Docker Desktop 用户访问宿主机上的 Ollama。
"""
self._use_ollama_provider()
with (
patch.object(config, "is_running_in_container", return_value=True),
patch.object(config, "_can_resolve_hostname", return_value=True),
):
self._assert_ollama_base_url("http://host.docker.internal:11434/v1")
def test_ollama_default_base_url_falls_back_to_container_gateway(self):
"""
原生 Linux Docker 里不一定能解析 host.docker.internal。此时使用容器
默认网关作为兜底地址,比直接返回不可解析的 hostname 更稳。
"""
self._use_ollama_provider()
with (
patch.object(config, "is_running_in_container", return_value=True),
patch.object(config, "_can_resolve_hostname", return_value=False),
patch.object(config, "get_container_default_gateway_ip", return_value="172.17.0.1"),
):
self._assert_ollama_base_url("http://172.17.0.1:11434/v1")
def test_ollama_explicit_base_url_takes_precedence(self):
"""
用户手动配置的 ollama_base_url 优先级最高,不受容器检测影响。
"""
self._use_ollama_provider(base_url="http://ollama:11434/v1")
with patch.object(config, "is_running_in_container", return_value=True):
self._assert_ollama_base_url("http://ollama:11434/v1")
def test_mimo_provider_uses_openai_compatible_client(self):
"""
MiMo 官方接口兼容 OpenAI Chat Completions 协议。这里用 fake OpenAI
client 验证 provider 会使用 MiMo 独立配置和默认 base_url不依赖
真实网络或私有 API Key。
"""
config.app["llm_provider"] = "mimo"
config.app["mimo_api_key"] = "mimo-key"
config.app["mimo_base_url"] = ""
config.app["mimo_model_name"] = ""
class FakeCompletions:
def create(self, **kwargs):
self.kwargs = kwargs
message = types.SimpleNamespace(content="hello\nmimo")
choice = types.SimpleNamespace(message=message)
return types.SimpleNamespace(choices=[choice])
fake_completions = FakeCompletions()
fake_client = types.SimpleNamespace(
chat=types.SimpleNamespace(completions=fake_completions)
)
with (
patch.object(llm, "OpenAI", return_value=fake_client) as openai_client,
patch.object(llm, "ChatCompletion", types.SimpleNamespace),
):
result = llm._generate_response("Say hello")
openai_client.assert_called_once_with(
api_key="mimo-key",
base_url="https://api.xiaomimimo.com/v1",
)
self.assertEqual(
fake_completions.kwargs,
{
"model": "mimo-v2.5-pro",
"messages": [{"role": "user", "content": "Say hello"}],
},
)
self.assertEqual(result, "hellomimo")
def test_azure_provider_uses_azure_client_directly(self):
"""
Azure OpenAI 的鉴权、endpoint 和 api-version 都由 AzureOpenAI 客户端处理。
这个测试覆盖 issue #892azure 分支必须直接调用 AzureOpenAI 创建的客户端,
不能继续落入普通 OpenAI-compatible 分支,否则会丢失 Azure 专用请求配置。
"""
config.app["llm_provider"] = "azure"
config.app["azure_api_key"] = "azure-key"
config.app["azure_base_url"] = "https://example.openai.azure.com"
config.app["azure_model_name"] = "gpt-4o-mini"
config.app["azure_api_version"] = "2024-02-15-preview"
class FakeCompletions:
def create(self, **kwargs):
self.kwargs = kwargs
message = types.SimpleNamespace(content="hello\nazure")
choice = types.SimpleNamespace(message=message)
return types.SimpleNamespace(choices=[choice])
fake_completions = FakeCompletions()
fake_client = types.SimpleNamespace(
chat=types.SimpleNamespace(completions=fake_completions)
)
with (
patch.object(llm, "AzureOpenAI", return_value=fake_client) as azure_client,
patch.object(llm, "OpenAI") as openai_client,
patch.object(llm, "ChatCompletion", types.SimpleNamespace),
):
result = llm._generate_response("Say hello")
azure_client.assert_called_once_with(
api_key="azure-key",
api_version="2024-02-15-preview",
azure_endpoint="https://example.openai.azure.com",
)
openai_client.assert_not_called()
self.assertEqual(
fake_completions.kwargs,
{
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "Say hello"}],
},
)
self.assertEqual(result, "helloazure")
def test_g4f_provider_requires_explicit_opt_in(self):
"""
g4f 存在供应链和稳定性风险,不能因为用户把 provider 写成 g4f
就默认加载第三方包并访问逆向接口,必须显式启用。
"""
config.app["llm_provider"] = "g4f"
config.app["enable_g4f"] = False
result = llm._generate_response("test")
self.assertIn("Error:", result)
self.assertIn("g4f provider is disabled", result)
def test_g4f_provider_uses_lazy_import_after_opt_in(self):
config.app["llm_provider"] = "g4f"
config.app["enable_g4f"] = True
config.app["g4f_model_name"] = "gpt-3.5-turbo"
fake_g4f = types.SimpleNamespace()
fake_g4f.ChatCompletion = types.SimpleNamespace(
create=lambda **kwargs: "hello from g4f"
)
with patch.dict(sys.modules, {"g4f": fake_g4f}):
result = llm._generate_response("test")
self.assertEqual(result, "hello from g4f")
def test_g4f_provider_reports_missing_optional_dependency(self):
config.app["llm_provider"] = "g4f"
config.app["enable_g4f"] = True
config.app["g4f_model_name"] = "gpt-3.5-turbo"
with patch.dict(sys.modules, {"g4f": None}):
result = llm._generate_response("test")
self.assertIn("Error:", result)
self.assertIn("g4f package is not installed by default", result)
class TestRuntimeEnvironmentDetection(unittest.TestCase):
def test_container_detection_ignores_plain_linux_cgroup_file(self):
"""
普通 Linux 也有 /proc/1/cgroup不能因为文件存在就判定为容器。
"""
with tempfile.TemporaryDirectory() as tmp_dir:
cgroup_path = Path(tmp_dir) / "cgroup"
cgroup_path.write_text("0::/init.scope\n", encoding="utf-8")
self.assertFalse(
config.is_running_in_container(
dockerenv_path=str(Path(tmp_dir) / "missing-dockerenv"),
containerenv_path=str(Path(tmp_dir) / "missing-containerenv"),
cgroup_path=str(cgroup_path),
)
)
def test_container_detection_accepts_dockerenv_marker(self):
with tempfile.TemporaryDirectory() as tmp_dir:
dockerenv_path = Path(tmp_dir) / ".dockerenv"
dockerenv_path.write_text("", encoding="utf-8")
self.assertTrue(
config.is_running_in_container(
dockerenv_path=str(dockerenv_path),
containerenv_path=str(Path(tmp_dir) / "missing-containerenv"),
cgroup_path=str(Path(tmp_dir) / "missing-cgroup"),
)
)
def test_container_detection_accepts_cgroup_container_marker(self):
with tempfile.TemporaryDirectory() as tmp_dir:
cgroup_path = Path(tmp_dir) / "cgroup"
cgroup_path.write_text(
"0::/system.slice/docker-abcdef.scope\n",
encoding="utf-8",
)
self.assertTrue(
config.is_running_in_container(
dockerenv_path=str(Path(tmp_dir) / "missing-dockerenv"),
containerenv_path=str(Path(tmp_dir) / "missing-containerenv"),
cgroup_path=str(cgroup_path),
)
)
def test_container_gateway_ip_decodes_default_route(self):
with tempfile.TemporaryDirectory() as tmp_dir:
route_path = Path(tmp_dir) / "route"
route_path.write_text(
"Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT\n"
"eth0\t00000000\t010011AC\t0003\t0\t0\t0\t00000000\t0\t0\t0\n",
encoding="utf-8",
)
self.assertEqual(
config.get_container_default_gateway_ip(str(route_path)),
"172.17.0.1",
)
def test_container_gateway_ip_ignores_missing_default_route(self):
with tempfile.TemporaryDirectory() as tmp_dir:
route_path = Path(tmp_dir) / "route"
route_path.write_text(
"Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT\n"
"eth0\t0011AC0A\t00000000\t0001\t0\t0\t0\t00FFFFFF\t0\t0\t0\n",
encoding="utf-8",
)
self.assertEqual(config.get_container_default_gateway_ip(str(route_path)), "")
FOUNDRY_KEY = os.environ.get("ANTHROPIC_FOUNDRY_API_KEY", "")
FOUNDRY_BASE = "https://amanrai-test-resource.services.ai.azure.com/anthropic"
FOUNDRY_MODEL = "azure_ai/claude-sonnet-4-6"
@unittest.skipUnless(FOUNDRY_KEY, "ANTHROPIC_FOUNDRY_API_KEY not set")
class TestLiteLLMLiveIntegration(unittest.TestCase):
def setUp(self):
self.original_app_config = dict(config.app)
config.app["llm_provider"] = "litellm"
config.app["litellm_model_name"] = FOUNDRY_MODEL
os.environ["AZURE_AI_API_KEY"] = FOUNDRY_KEY
os.environ["AZURE_AI_API_BASE"] = FOUNDRY_BASE
def tearDown(self):
config.app.clear()
config.app.update(self.original_app_config)
def test_live_litellm_completion(self):
result = llm._generate_response("What is 2+2? Reply with just the number.")
self.assertNotIn("Error:", result)
self.assertIn("4", result)
if __name__ == "__main__":
unittest.main()