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 #892:azure 分支必须直接调用 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()