import json import os import sys import tempfile import tomllib 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.llm_provider import ( DEFAULT_LLM_PROVIDER_ID, LLM_PROVIDER_REGISTRY, LLM_PROVIDERS, get_llm_provider, normalize_provider_override, ) from app.models.schema import VideoScriptRequest, VideoSocialMetadataRequest from app.services import llm RUN_INTEGRATION_TESTS = os.environ.get("MPT_RUN_INTEGRATION_TESTS", "").lower() in { "1", "true", "yes", } class TestScriptPromptOptions(unittest.TestCase): def test_normalize_text_response_removes_think_blocks(self): """ reasoning 模型可能返回 `...`。脚本生成链路必须只保留 最终正文,避免思考过程进入字幕和配音。 """ result = llm._normalize_text_response( "\nI should reason here.\n\n测试成功", "minimax", ) self.assertEqual(result, "测试成功") def test_normalize_text_response_rejects_think_only_response(self): """ 如果模型只返回思考块而没有最终答案,应视为空内容,触发重试或明确错误。 """ with self.assertRaises(ValueError): llm._normalize_text_response("hidden reasoning", "minimax") def test_normalize_text_response_removes_unclosed_think_block(self): """ 某些网关可能因为截断只返回未闭合的 ``。这种内容同样不能 进入最终脚本;如果清理后没有正文,就应该按空响应处理。 """ with self.assertRaises(ValueError): llm._normalize_text_response("hidden reasoning", "minimax") 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_generate_terms_can_request_script_ordered_keywords(self): """ 按文案顺序匹配素材依赖 LLM 返回有序关键词。这里不调用真实模型, 只验证服务层会把“按脚本叙事顺序输出”的约束写入 prompt,避免 后续素材下载虽然顺序化,但关键词仍然是全局无序主题词。 """ captured = {} def fake_generate_response(prompt): captured["prompt"] = prompt return '["opening city", "middle office", "final sunset"]' with patch.object( llm, "_generate_response", side_effect=fake_generate_response ): result = llm.generate_terms( video_subject="startup story", video_script="First city. Then office. Finally sunset.", amount=3, match_script_order=True, ) self.assertEqual(result, ["opening city", "middle office", "final sunset"]) self.assertIn("chronological stock-video search terms", captured["prompt"]) self.assertIn("same order as the script narration", 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 TestLLMConnection(unittest.TestCase): def test_connection_sends_one_minimal_request(self): """连接测试只发送一次固定最小请求,不触发脚本生成重试。""" with ( patch.object(llm, "_generate_response", return_value="OK") as generate, patch.object(llm, "perf_counter", side_effect=[10.0, 10.25]), ): result = llm.test_connection() generate.assert_called_once_with(prompt="Reply with exactly: OK") self.assertEqual(result, (True, "", 0.25)) def test_connection_returns_provider_error(self): """Provider 返回错误时应保留可诊断信息,并报告本次请求耗时。""" with ( patch.object( llm, "_generate_response", return_value="Error: invalid API key", ), patch.object(llm, "perf_counter", side_effect=[20.0, 20.5]), ): result = llm.test_connection() self.assertEqual(result, (False, "invalid API key", 0.5)) def test_connection_rejects_empty_response(self): """极端情况下的空响应应显示明确错误,而不是误报连接成功。""" with ( patch.object(llm, "_generate_response", return_value=""), patch.object(llm, "perf_counter", side_effect=[30.0, 31.0]), ): result = llm.test_connection() self.assertEqual(result, (False, "LLM returned an empty response", 1.0)) 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 test_current_default_model_names(self): """WebUI 与服务层必须共享同一组默认模型,避免展示值和请求值漂移。""" self.assertEqual(get_llm_provider("openai").default_model, "gpt-5.5") self.assertEqual(get_llm_provider("aimlapi").default_model, "openai/gpt-5-5") self.assertEqual(get_llm_provider("deepseek").default_model, "deepseek-v4-pro") self.assertEqual( get_llm_provider("modelscope").default_model, "ZhipuAI/GLM-5.2" ) self.assertEqual( get_llm_provider("gemini").default_model, "gemini-3.1-pro-preview" ) pollinations = get_llm_provider("pollinations") self.assertEqual(pollinations.default_model, "openai-fast") self.assertEqual( pollinations.default_base_url, "https://gen.pollinations.ai/v1", ) self.assertTrue(pollinations.requires_api_key) self.assertEqual(pollinations.adapter, "openai_compatible") def test_provider_defaults_are_not_persisted_as_user_overrides(self): """默认值只用于运行和展示,只有不同值才应写入用户配置。""" self.assertEqual( normalize_provider_override("gpt-5.5", "gpt-5.5"), "", ) self.assertEqual( normalize_provider_override(" gpt-5.5 ", "gpt-5.5"), "", ) self.assertEqual( normalize_provider_override("gpt-5.6-custom", "gpt-5.5"), "gpt-5.6-custom", ) def test_provider_registry_has_unique_stable_ids(self): """Registry 是 Provider 列表的唯一数据源,ID 必须唯一且默认项存在。""" provider_ids = [provider.provider_id for provider in LLM_PROVIDER_REGISTRY] self.assertEqual(len(provider_ids), len(set(provider_ids))) self.assertEqual(len(provider_ids), len(LLM_PROVIDERS)) self.assertIn(DEFAULT_LLM_PROVIDER_ID, LLM_PROVIDERS) def test_provider_registry_preserves_product_group_order(self): """下拉顺序按推荐、原厂、聚合平台、本地部署和其它服务排列。""" self.assertEqual( [provider.provider_id for provider in LLM_PROVIDER_REGISTRY], [ "moonshot", "openai", "gemini", "deepseek", "qwen", "azure", "volcengine", "grok", "minimax", "mimo", "cloudflare", "modelscope", "aihubmix", "aimlapi", "evolink", "ollama", "oneapi", "litellm", "groq", "pollinations", ], ) self.assertEqual( get_llm_provider("gemini").default_label, "Google Gemini", ) self.assertEqual( get_llm_provider("azure").default_label, "Microsoft Azure OpenAI", ) def test_provider_registry_uses_conventional_locale_and_config_keys(self): """统一命名规则可避免 WebUI 为每个 Provider 增加硬编码映射。""" for provider in LLM_PROVIDER_REGISTRY: self.assertEqual( provider.label_key, f"llm_provider_label.{provider.provider_id}", ) self.assertEqual( provider.tips_key, f"llm_provider_tips.{provider.provider_id}", ) self.assertEqual( provider.config_key("api_key"), f"{provider.provider_id}_api_key", ) def test_registry_replaces_deprecated_provider_models(self): """历史默认模型应自动迁移,避免升级后继续使用已移除的接入语义。""" cloudflare = get_llm_provider("cloudflare") gemini = get_llm_provider("gemini") self.assertEqual( cloudflare.resolve_model_name("@cf/meta/llama-3.1-8b-instruct"), "openai/gpt-4.1-mini", ) self.assertEqual( gemini.resolve_model_name("gemini-pro"), "gemini-3.1-pro-preview", ) self.assertEqual( cloudflare.resolve_model_name("anthropic/claude-sonnet-4-5"), "anthropic/claude-sonnet-4-5", ) pollinations = get_llm_provider("pollinations") self.assertEqual( pollinations.resolve_model_name("default"), "openai-fast", ) self.assertEqual( pollinations.resolve_base_url("https://text.pollinations.ai/openai"), "https://gen.pollinations.ai/v1", ) self.assertEqual( pollinations.resolve_base_url("https://example.com/v1"), "https://example.com/v1", ) def test_provider_tip_templates_accept_registry_defaults(self): """所有语言的 Provider 提示模板都必须能安全注入 Registry 默认值。""" i18n_dir = Path(__file__).parent.parent.parent / "webui" / "i18n" for locale_file in i18n_dir.glob("*.json"): translations = json.loads(locale_file.read_text(encoding="utf-8"))[ "Translation" ] for provider in LLM_PROVIDER_REGISTRY: tips = translations.get(provider.tips_key, "") if not tips: continue rendered = tips.format( api_key_url=provider.api_key_url, default_model=provider.default_model, default_base_url=provider.default_base_url, docker_hint="", **{ f"default_{field.config_suffix}": field.default_value for field in provider.extra_fields }, ) self.assertNotIn("{default_model}", rendered) self.assertNotIn("{default_base_url}", rendered) def test_primary_provider_tips_use_consistent_structure(self): """中英文配置说明统一展示 API Key、Base URL 和模型名称。""" i18n_dir = Path(__file__).parent.parent.parent / "webui" / "i18n" for language in ("zh", "en"): translations = json.loads( (i18n_dir / f"{language}.json").read_text(encoding="utf-8") )["Translation"] for provider in LLM_PROVIDER_REGISTRY: tips = translations[provider.tips_key] self.assertTrue(tips.startswith("##### "), provider.provider_id) self.assertIn("**API Key**", tips, provider.provider_id) self.assertIn("**Base Url**", tips, provider.provider_id) self.assertIn("**Model Name**", tips, provider.provider_id) zh_kimi_tips = json.loads((i18n_dir / "zh.json").read_text(encoding="utf-8"))[ "Translation" ]["llm_provider_tips.moonshot"] self.assertIn("推荐理由:", zh_kimi_tips) self.assertIn("视频创作链路匹配", zh_kimi_tips) def test_required_api_key_providers_have_clickable_entry_points(self): """需要密钥的 Provider 必须提供统一申请入口,避免 WebUI 只给出文字。""" i18n_dir = Path(__file__).parent.parent.parent / "webui" / "i18n" locale_translations = { locale_file.stem: json.loads(locale_file.read_text(encoding="utf-8"))[ "Translation" ] for locale_file in i18n_dir.glob("*.json") } for provider in LLM_PROVIDER_REGISTRY: if provider.requires_api_key: self.assertTrue(provider.api_key_url, provider.provider_id) self.assertTrue( provider.api_key_url.startswith("https://"), provider.provider_id, ) for language, translations in locale_translations.items(): tips_template = translations.get(provider.tips_key, "") if not tips_template: continue tips = tips_template.format( api_key_url=provider.api_key_url, default_model=provider.default_model, default_base_url=provider.default_base_url, docker_hint="", **{ f"default_{field.config_suffix}": field.default_value for field in provider.extra_fields }, ) api_key_line = next( line for line in tips.splitlines() if "**API Key**" in line ) self.assertIn("](", api_key_line, provider.provider_id) self.assertIn( f"]({provider.api_key_url})", api_key_line, f"{language}: {provider.provider_id}", ) def test_example_config_does_not_duplicate_registry_defaults(self): """示例配置只保存用户覆盖值,默认模型和地址由 Registry 唯一维护。""" config_path = Path(__file__).parent.parent.parent / "config.example.toml" app_config = tomllib.loads(config_path.read_text(encoding="utf-8"))["app"] for provider in LLM_PROVIDER_REGISTRY: if provider.default_model: self.assertEqual( app_config.get(provider.config_key("model_name"), ""), "", provider.provider_id, ) if provider.default_base_url: self.assertEqual( app_config.get(provider.config_key("base_url"), ""), "", provider.provider_id, ) for field in provider.extra_fields: if field.default_value: self.assertEqual( app_config.get(provider.config_key(field.config_suffix), ""), "", provider.provider_id, ) def test_removed_ernie_provider_is_unsupported(self): """移除 ERNIE 后,遗留配置应返回明确错误,不再发起旧 OAuth 请求。""" config.app["llm_provider"] = "ernie" with patch.object(llm, "OpenAI") as openai_client: result = llm._generate_response("test") openai_client.assert_not_called() self.assertIn("unsupported llm provider", result) def test_pollinations_requires_api_key_before_request(self): """新统一 API 要求鉴权,缺少 Key 时不得发送匿名生成请求。""" config.app.update( { "llm_provider": "pollinations", "pollinations_api_key": "", "pollinations_base_url": "", "pollinations_model_name": "", } ) with patch.object(llm, "OpenAI") as openai_client: result = llm._generate_response("test") openai_client.assert_not_called() self.assertIn("api_key is not set", result) def test_pollinations_uses_unified_openai_compatible_api(self): """历史地址和模型名应自动迁移,并通过统一 Chat Completions API 调用。""" config.app.update( { "llm_provider": "pollinations", "pollinations_api_key": "pollinations-test-key", "pollinations_base_url": "https://text.pollinations.ai/openai/", "pollinations_model_name": "default", } ) class FakeCompletions: def create(self, **kwargs): self.kwargs = kwargs message = types.SimpleNamespace(content="hello\npollinations") 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="pollinations-test-key", base_url="https://gen.pollinations.ai/v1", ) self.assertEqual( fake_completions.kwargs, { "model": "openai-fast", "messages": [{"role": "user", "content": "Say hello"}], }, ) self.assertEqual(result, "hellopollinations") def test_gemini_uses_google_genai_client(self): """Gemini 适配器应通过新版 SDK 的统一 Client 发起内容生成请求。""" config.app.update( { "llm_provider": "gemini", "gemini_api_key": "gemini-test-key", "gemini_base_url": "", "gemini_model_name": "gemini-test-model", } ) captured = {} class FakeModels: def generate_content(self, **kwargs): captured.update(kwargs) return types.SimpleNamespace(text="hello\ngemini") class FakeClient: def __init__(self, **kwargs): captured["client_kwargs"] = kwargs self.models = FakeModels() def __enter__(self): return self def __exit__(self, exc_type, exc_value, traceback): captured["closed"] = True with patch("google.genai.Client", FakeClient): result = llm._generate_response("Say hello") self.assertEqual(result, "hellogemini") self.assertEqual( captured["client_kwargs"], {"api_key": "gemini-test-key", "http_options": None}, ) self.assertEqual(captured["model"], "gemini-test-model") self.assertEqual(captured["contents"], "Say hello") self.assertEqual(captured["config"].max_output_tokens, 2048) self.assertTrue(captured["closed"]) def test_cloudflare_requires_account_id_before_request(self): """Cloudflare 缺少 Account ID 时应在本地失败,不发送无效请求。""" config.app.update( { "llm_provider": "cloudflare", "cloudflare_api_key": "test-token", "cloudflare_account_id": "", "cloudflare_model_name": "", } ) with patch.object(llm, "OpenAI") as openai_client: result = llm._generate_response("test") openai_client.assert_not_called() self.assertIn("account_id is not set", result) def test_cloudflare_uses_ai_gateway_openai_endpoint(self): """Cloudflare Provider 必须走 AI Gateway,不再调用 Workers AI 接口。""" config.app.update( { "llm_provider": "cloudflare", "cloudflare_api_key": "cloudflare-token", "cloudflare_account_id": "account-123", "cloudflare_gateway_id": "", "cloudflare_model_name": "", } ) fake_response = types.SimpleNamespace( choices=[ types.SimpleNamespace( message=types.SimpleNamespace(content="gateway\nresponse") ) ] ) class FakeCompletions: def create(self, **kwargs): self.kwargs = kwargs return fake_response 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="cloudflare-token", base_url=( "https://api.cloudflare.com/client/v4/accounts/account-123/ai/v1" ), default_headers={"cf-aig-gateway-id": "default"}, ) self.assertEqual( fake_completions.kwargs, { "model": "openai/gpt-4.1-mini", "messages": [{"role": "user", "content": "Say hello"}], }, ) self.assertEqual(result, "gatewayresponse") 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_uses_registry_default_model(self): self._use_litellm_provider(model_name="") fake_litellm = types.SimpleNamespace() def _completion(**kwargs): self.assertEqual(kwargs["model"], "openai/gpt-4o-mini") message = types.SimpleNamespace(content="default model") 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("test") self.assertEqual(result, "default model") 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_sanitize_error_message_redacts_url_credentials_and_query_tokens(self): message = ( "request failed for " "https://myuser:mypassword@proxy.example.com/v1/chat" "?api_key=secret-key&token=secret-token&safe=value" ) result = llm._sanitize_error_message(message) self.assertIn("https://***:***@proxy.example.com", result) self.assertIn("api_key=***", result) self.assertIn("token=***", result) self.assertIn("safe=value", result) self.assertNotIn("myuser", result) self.assertNotIn("mypassword", result) self.assertNotIn("secret-key", result) self.assertNotIn("secret-token", result) def test_openai_provider_error_redacts_embedded_base_url_credentials(self): """ 自定义 OpenAI-compatible base_url 可能包含代理网关的 user:pass。 SDK 抛错时常会把 URL 带回异常信息,这里验证最终返回给 WebUI/API 的 `Error:` 文案不会泄露这些凭据。 """ config.app["llm_provider"] = "groq" config.app["groq_api_key"] = "groq-key" config.app["groq_model_name"] = "llama-3.3-70b-versatile" config.app["groq_base_url"] = ( "https://myuser:mypassword@proxy.example.com/openai/v1" ) class FakeCompletions: def create(self, **kwargs): raise RuntimeError( "connection failed: " "https://myuser:mypassword@proxy.example.com/openai/v1" "?access_token=secret-token" ) fake_client = types.SimpleNamespace( chat=types.SimpleNamespace(completions=FakeCompletions()) ) with patch.object(llm, "OpenAI", return_value=fake_client): result = llm._generate_response("test") self.assertIn("Error:", result) self.assertIn("https://***:***@proxy.example.com", result) self.assertIn("access_token=***", result) self.assertNotIn("myuser", result) self.assertNotIn("mypassword", result) self.assertNotIn("secret-token", 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 _use_qwen_provider(self): config.app["llm_provider"] = "qwen" config.app["qwen_api_key"] = "qwen-key" config.app["qwen_model_name"] = "qwen-max" def _patch_dashscope_generation(self, response): class FakeGenerationResponse(dict): pass fake_response = FakeGenerationResponse(response) fake_response.status_code = response.get("status_code", 200) fake_dashscope = types.SimpleNamespace( api_key="", Generation=types.SimpleNamespace(call=lambda **kwargs: fake_response), ) fake_dashscope_response = types.SimpleNamespace( GenerationResponse=FakeGenerationResponse ) return patch.dict( sys.modules, { "dashscope": fake_dashscope, "dashscope.api_entities": types.SimpleNamespace(), "dashscope.api_entities.dashscope_response": fake_dashscope_response, }, ) def test_qwen_provider_reads_chat_choices_content(self): """ DashScope chat 模式会把文本放在 `output.choices[0].message.content`。 这里覆盖 issue #966 报告的 `output.text is None` 场景,避免再次触发 `'NoneType' object has no attribute 'replace'`。 """ self._use_qwen_provider() response = { "output": { "text": None, "choices": [{"message": {"content": "你好\n世界"}}], } } with self._patch_dashscope_generation(response): result = llm._generate_response("Say hello") self.assertEqual(result, "你好世界") def test_qwen_provider_falls_back_to_output_text(self): """保留旧 DashScope completion 响应结构的兼容路径。""" self._use_qwen_provider() response = {"output": {"text": "旧格式\n响应"}} with self._patch_dashscope_generation(response): result = llm._generate_response("Say hello") self.assertEqual(result, "旧格式响应") def test_qwen_provider_reports_empty_text(self): """Qwen 空响应应返回可诊断错误,而不是底层 AttributeError。""" self._use_qwen_provider() response = { "output": {"text": None, "choices": [{"message": {"content": None}}]} } with self._patch_dashscope_generation(response): result = llm._generate_response("Say hello") self.assertIn("Error:", result) self.assertIn("returned empty text content", result) self.assertNotIn("NoneType", result) def test_qwen_provider_reports_empty_choices(self): """Qwen chat 响应 choices 为空时应返回明确错误。""" self._use_qwen_provider() response = {"output": {"text": None, "choices": []}} with self._patch_dashscope_generation(response): result = llm._generate_response("Say hello") self.assertIn("Error:", result) self.assertIn("returned empty choices", result) self.assertNotIn("NoneType", result) def test_aihubmix_provider_uses_openai_compatible_client(self): """ AIHubMix 是 OpenAI-compatible 网关。这里用 fake OpenAI client 验证独立 Provider 会使用 Registry 中的默认地址和模型,避免真实网络 或私有 API Key 影响测试稳定性。 """ config.app["llm_provider"] = "aihubmix" config.app["aihubmix_api_key"] = "aihubmix-key" config.app["aihubmix_base_url"] = "" config.app["aihubmix_model_name"] = "" class FakeCompletions: def create(self, **kwargs): self.kwargs = kwargs message = types.SimpleNamespace(content="hello\naihubmix") 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="aihubmix-key", base_url="https://aihubmix.com/v1", ) self.assertEqual( fake_completions.kwargs, { "model": "gpt-5.4-mini", "messages": [{"role": "user", "content": "Say hello"}], }, ) self.assertEqual(result, "helloaihubmix") def test_aimlapi_provider_uses_openai_compatible_client(self): config.app["llm_provider"] = "aimlapi" config.app["aimlapi_api_key"] = "aimlapi-key" config.app["aimlapi_base_url"] = "" config.app["aimlapi_model_name"] = "" class FakeCompletions: def create(self, **kwargs): self.kwargs = kwargs message = types.SimpleNamespace(content="hello\naimlapi") 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="aimlapi-key", base_url="https://api.aimlapi.com/v1", ) self.assertEqual( fake_completions.kwargs, { "model": "openai/gpt-5-5", "messages": [{"role": "user", "content": "Say hello"}], }, ) self.assertEqual(result, "helloaimlapi") def test_evolink_provider_uses_openai_compatible_client(self): """ EvoLink exposes OpenAI-compatible Chat Completions at direct.evolink.ai. The provider should keep its own default endpoint and model instead of requiring users to overload the generic OpenAI settings. """ config.app["llm_provider"] = "evolink" config.app["evolink_api_key"] = "evolink-key" config.app["evolink_base_url"] = "" config.app["evolink_model_name"] = "" class FakeCompletions: def create(self, **kwargs): self.kwargs = kwargs message = types.SimpleNamespace(content="hello\nevolink") 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="evolink-key", base_url="https://direct.evolink.ai/v1", ) self.assertEqual( fake_completions.kwargs, { "model": "gpt-5.5", "messages": [{"role": "user", "content": "Say hello"}], }, ) self.assertEqual(result, "helloevolink") def test_volcengine_provider_uses_openai_compatible_client(self): """ VolcEngine Ark 暴露 OpenAI-compatible Chat Completions。 这里用 fake OpenAI client 覆盖 provider 默认地址和默认模型, 避免真实网络或私有 API key 影响测试稳定性。 """ config.app["llm_provider"] = "volcengine" config.app["volcengine_api_key"] = "volcengine-key" config.app["volcengine_base_url"] = "" config.app["volcengine_model_name"] = "" class FakeCompletions: def create(self, **kwargs): self.kwargs = kwargs message = types.SimpleNamespace(content="hello\nvolcengine") 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="volcengine-key", base_url="https://ark.cn-beijing.volces.com/api/v3", ) self.assertEqual( fake_completions.kwargs, { "model": "doubao-seed-2-1-turbo-260628", "messages": [{"role": "user", "content": "Say hello"}], }, ) self.assertEqual(result, "hellovolcengine") 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 test_groq_provider_requires_api_key(self): config.app["llm_provider"] = "groq" config.app["groq_api_key"] = "" config.app["groq_base_url"] = "https://api.groq.com/openai/v1" config.app["groq_model_name"] = "llama-3.3-70b-versatile" result = llm._generate_response("test") self.assertIn("Error:", result) self.assertIn("api_key is not set", result) self.assertNotIn("litellm", result.lower()) def test_groq_provider_uses_default_base_url(self): config.app["llm_provider"] = "groq" config.app["groq_api_key"] = "groq-test-key" config.app["groq_base_url"] = "" config.app["groq_model_name"] = "llama-3.3-70b-versatile" fake_response = types.SimpleNamespace( choices=[ types.SimpleNamespace( message=types.SimpleNamespace(content="hello\ngroq") ) ] ) fake_client = types.SimpleNamespace( chat=types.SimpleNamespace( completions=types.SimpleNamespace(create=lambda **kwargs: fake_response) ) ) 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="groq-test-key", base_url="https://api.groq.com/openai/v1", ) self.assertEqual(result, "hellogroq") 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_unsupported_provider_returns_clear_error(self): config.app["llm_provider"] = "g" + "4f" result = llm._generate_response("test") self.assertIn("Error:", result) self.assertIn("unsupported llm provider", 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)), "" ) class TestSocialMetadata(unittest.TestCase): """通用短视频发布文案元数据生成。""" def test_build_prompt_auto_language_uses_source_language(self): """ language 默认 auto 时,不应该固定成某个国家或语种,而是让模型 跟随视频主题和脚本的语言,扩大 API 适用范围。 """ prompt = llm.build_social_metadata_prompt( video_subject="上海一日游", video_script="今天带你快速看完上海经典路线。", language="auto", platform="tiktok", ) self.assertIn("TikTok", prompt) self.assertIn("Use the same language as the video subject and script", prompt) self.assertIn("上海一日游", prompt) self.assertIn("array of exactly 5 strings", prompt) def test_build_prompt_accepts_explicit_language(self): prompt = llm.build_social_metadata_prompt( video_subject="Coffee tips", language="en-US", platform="youtube_shorts", ) self.assertIn("YouTube Shorts", prompt) self.assertIn('Write "title" and "caption" in this language: en-US', prompt) self.assertIn("array of exactly 3 strings", prompt) def test_unknown_platform_falls_back_to_tiktok(self): prompt = llm.build_social_metadata_prompt( video_subject="x", platform="unsupported-platform", ) self.assertIn("TikTok", prompt) def test_normalize_hashtags_from_string_dedupes_and_clamps(self): tags = llm._normalize_hashtags("#fyp fyp, trending #Trending viral", count=2) self.assertEqual(tags, ["#fyp", "#trending"]) def test_normalize_hashtags_from_list_keeps_unicode_letters(self): tags = llm._normalize_hashtags( ["上海 旅行", "#việt nam", " ", "@bad!chars"], count=5 ) self.assertEqual(tags, ["#上海旅行", "#việtnam", "#badchars"]) def test_parse_social_metadata_recovers_embedded_json(self): raw = 'Sure: {"title":"T","caption":"C","hashtags":["#x"]} thanks' result = llm._parse_social_metadata(raw, "tiktok") self.assertEqual(result["title"], "T") self.assertEqual(result["caption"], "C") self.assertEqual(result["hashtags"], ["#x"]) def test_parse_social_metadata_requires_title_or_caption(self): with self.assertRaises(ValueError): llm._parse_social_metadata('{"hashtags":["#x"]}', "tiktok") def test_generate_social_metadata_uses_llm_response(self): payload = ( '{"title":"上海一日游","caption":"收藏这条路线,下次直接出发!",' '"hashtags":["#上海","#旅行","#shorts"]}' ) with patch.object(llm, "_generate_response", return_value=payload): result = llm.generate_social_metadata( video_subject="上海一日游", video_script="今天带你快速看完上海经典路线。", language="zh-CN", platform="tiktok", ) self.assertEqual(result["title"], "上海一日游") self.assertEqual(result["caption"], "收藏这条路线,下次直接出发!") self.assertEqual(result["hashtags"], ["#上海", "#旅行", "#shorts"]) def test_generate_social_metadata_falls_back_to_generic_hashtags(self): with patch.object( llm, "_generate_response", return_value="Error: api_key is not set" ): result = llm.generate_social_metadata( video_subject="Coffee tips", video_script="Save these three coffee tips.", platform="instagram_reels", ) self.assertEqual(result["title"], "Coffee tips") self.assertEqual(result["caption"], "Save these three coffee tips.") self.assertEqual(len(result["hashtags"]), 8) self.assertEqual(result["hashtags"][0], "#shorts") def test_request_model_defaults_to_auto_language_tiktok(self): body = VideoSocialMetadataRequest(video_subject="Test") self.assertEqual(body.language, "auto") self.assertEqual(body.platform, "tiktok") def test_request_model_rejects_oversized_social_metadata_fields(self): """ 外部 API 不能接受无限长的脚本和语言参数,否则会直接放大 LLM token 成本。schema 层先拦截,服务层再做内部调用兜底。 """ with self.assertRaises(ValidationError): VideoSocialMetadataRequest(video_subject="x" * 501) with self.assertRaises(ValidationError): VideoSocialMetadataRequest(video_subject="x", video_script="x" * 8001) with self.assertRaises(ValidationError): VideoSocialMetadataRequest(video_subject="x", language="x" * 65) def test_build_prompt_clamps_direct_service_inputs(self): prompt = llm.build_social_metadata_prompt( video_subject="x" * 600, video_script="y" * 9000, language="en", ) self.assertIn("x" * llm.MAX_SOCIAL_SUBJECT_LENGTH, prompt) self.assertNotIn("x" * (llm.MAX_SOCIAL_SUBJECT_LENGTH + 1), prompt) self.assertIn("y" * llm.MAX_SOCIAL_SCRIPT_LENGTH, prompt) self.assertNotIn("y" * (llm.MAX_SOCIAL_SCRIPT_LENGTH + 1), prompt) def test_social_metadata_endpoint_response_shape(self): from fastapi.testclient import TestClient from app.asgi import app request_body = { "video_subject": "Tokyo coffee shops", "video_script": "Three quiet coffee shops for your next Tokyo morning.", "language": "en", "platform": "youtube_shorts", } llm_response = ( '{"title":"3 Quiet Tokyo Coffee Shops",' '"caption":"Save these spots for your next Tokyo morning.",' '"hashtags":["#Tokyo","#Coffee","#Shorts"]}' ) with patch.object(llm, "_generate_response", return_value=llm_response): response = TestClient(app).post( "/api/v1/social-metadata", json=request_body, ) self.assertEqual(response.status_code, 200) self.assertEqual( response.json(), { "status": 200, "message": "success", "data": { "title": "3 Quiet Tokyo Coffee Shops", "caption": "Save these spots for your next Tokyo morning.", "hashtags": ["#Tokyo", "#Coffee", "#Shorts"], }, }, ) 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( RUN_INTEGRATION_TESTS and FOUNDRY_KEY, "MPT_RUN_INTEGRATION_TESTS and 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()