返回 MoneyPrinterTurbo
test_llm.py
根目录 / test / services / test_llm.py
1 import os
2 import sys
3 import tempfile
4 import types
5 import unittest
6 from pathlib import Path
7 from unittest.mock import patch
8
9 from pydantic import ValidationError
10
11 sys.path.insert(0, str(Path(__file__).parent.parent.parent))
12
13 from app.config import config
14 from app.models.schema import VideoScriptRequest, VideoSocialMetadataRequest
15 from app.services import llm
16
17 RUN_INTEGRATION_TESTS = os.environ.get("MPT_RUN_INTEGRATION_TESTS", "").lower() in {
18 "1",
19 "true",
20 "yes",
21 }
22
23
24 class TestScriptPromptOptions(unittest.TestCase):
25 def test_normalize_text_response_removes_think_blocks(self):
26 """
27 reasoning 模型可能返回 `<think>...</think>`。脚本生成链路必须只保留
28 最终正文,避免思考过程进入字幕和配音。
29 """
30 result = llm._normalize_text_response(
31 "<think>\nI should reason here.\n</think>\n测试成功",
32 "minimax",
33 )
34
35 self.assertEqual(result, "测试成功")
36
37 def test_normalize_text_response_rejects_think_only_response(self):
38 """
39 如果模型只返回思考块而没有最终答案,应视为空内容,触发重试或明确错误。
40 """
41 with self.assertRaises(ValueError):
42 llm._normalize_text_response("<think>hidden reasoning</think>", "minimax")
43
44 def test_normalize_text_response_removes_unclosed_think_block(self):
45 """
46 某些网关可能因为截断只返回未闭合的 `<think>`。这种内容同样不能
47 进入最终脚本;如果清理后没有正文,就应该按空响应处理。
48 """
49 with self.assertRaises(ValueError):
50 llm._normalize_text_response("<think>hidden reasoning", "minimax")
51
52 def test_build_script_prompt_appends_advanced_requirements(self):
53 """
54 高级文案要求只作为附加约束,不替换默认系统提示词。
55 这样普通用户不配置时仍然走稳定默认规则,高级用户也能细化风格。
56 """
57 prompt = llm.build_script_prompt(
58 video_subject="咖啡",
59 language="zh-CN",
60 paragraph_number=3,
61 video_script_prompt="语气轻松,面向程序员",
62 )
63
64 self.assertIn("# Role: Video Script Generator", prompt)
65 self.assertIn("- video subject: 咖啡", prompt)
66 self.assertIn("- number of paragraphs: 3", prompt)
67 self.assertIn("- language: zh-CN", prompt)
68 self.assertIn("# Additional User Requirements:", prompt)
69 self.assertIn("语气轻松,面向程序员", prompt)
70
71 def test_custom_system_prompt_keeps_runtime_context(self):
72 """
73 自定义 system prompt 会替换默认脚本规则,但视频主题、语言、段落数
74 仍由服务层统一追加,避免高级用户漏写必要上下文。
75 """
76 prompt = llm.build_script_prompt(
77 video_subject="露营",
78 language="en",
79 paragraph_number=2,
80 custom_system_prompt="Only write cinematic narration.",
81 )
82
83 self.assertNotIn("# Role: Video Script Generator", prompt)
84 self.assertIn("Only write cinematic narration.", prompt)
85 self.assertIn("- video subject: 露营", prompt)
86 self.assertIn("- number of paragraphs: 2", prompt)
87 self.assertIn("- language: en", prompt)
88
89 def test_generate_script_sends_custom_prompt_to_llm(self):
90 captured = {}
91
92 def fake_generate_response(prompt):
93 captured["prompt"] = prompt
94 return "第一段。\n\n第二段。"
95
96 with patch.object(llm, "_generate_response", side_effect=fake_generate_response):
97 result = llm.generate_script(
98 video_subject="咖啡",
99 language="zh-CN",
100 paragraph_number=2,
101 video_script_prompt="开头更有悬念",
102 )
103
104 self.assertEqual(result, "第一段。\n\n第二段。")
105 self.assertIn("- number of paragraphs: 2", captured["prompt"])
106 self.assertIn("开头更有悬念", captured["prompt"])
107
108 def test_generate_terms_can_request_script_ordered_keywords(self):
109 """
110 按文案顺序匹配素材依赖 LLM 返回有序关键词。这里不调用真实模型,
111 只验证服务层会把“按脚本叙事顺序输出”的约束写入 prompt,避免
112 后续素材下载虽然顺序化,但关键词仍然是全局无序主题词。
113 """
114 captured = {}
115
116 def fake_generate_response(prompt):
117 captured["prompt"] = prompt
118 return '["opening city", "middle office", "final sunset"]'
119
120 with patch.object(llm, "_generate_response", side_effect=fake_generate_response):
121 result = llm.generate_terms(
122 video_subject="startup story",
123 video_script="First city. Then office. Finally sunset.",
124 amount=3,
125 match_script_order=True,
126 )
127
128 self.assertEqual(result, ["opening city", "middle office", "final sunset"])
129 self.assertIn("chronological stock-video search terms", captured["prompt"])
130 self.assertIn("same order as the script narration", captured["prompt"])
131
132 def test_video_script_request_rejects_invalid_advanced_options(self):
133 """
134 API 请求模型需要限制高级 prompt 参数,避免外部调用绕过 WebUI
135 传入异常段落数或超长提示词,导致模型成本和结果不可控。
136 """
137 with self.assertRaises(ValidationError):
138 VideoScriptRequest(video_subject="咖啡", paragraph_number=0)
139
140 with self.assertRaises(ValidationError):
141 VideoScriptRequest(
142 video_subject="咖啡",
143 video_script_prompt="x" * (llm.MAX_SCRIPT_PROMPT_LENGTH + 1),
144 )
145
146
147 class TestLiteLLMProvider(unittest.TestCase):
148 def setUp(self):
149 self.original_app_config = dict(config.app)
150
151 def tearDown(self):
152 config.app.clear()
153 config.app.update(self.original_app_config)
154
155 def _use_litellm_provider(self, model_name="openai/gpt-4o-mini"):
156 config.app["llm_provider"] = "litellm"
157 config.app["litellm_model_name"] = model_name
158
159 def test_litellm_provider_returns_normalized_text(self):
160 """
161 验证 LiteLLM provider 的主路径不依赖真实网络和私有 API key。
162
163 这里用 fake module 注入 `sys.modules`,直接覆盖动态 import 的
164 `litellm.completion()`,确保测试稳定覆盖 `_generate_response()` 里的
165 litellm 分支。
166 """
167 self._use_litellm_provider()
168
169 fake_litellm = types.SimpleNamespace()
170
171 def _completion(**kwargs):
172 self.assertEqual(kwargs["model"], "openai/gpt-4o-mini")
173 self.assertEqual(
174 kwargs["messages"], [{"role": "user", "content": "Say hello"}]
175 )
176 self.assertTrue(kwargs["drop_params"])
177 message = types.SimpleNamespace(content="hello\nworld")
178 choice = types.SimpleNamespace(message=message)
179 return types.SimpleNamespace(choices=[choice])
180
181 fake_litellm.completion = _completion
182
183 with patch.dict(sys.modules, {"litellm": fake_litellm}):
184 result = llm._generate_response("Say hello")
185
186 self.assertEqual(result, "helloworld")
187
188 def test_litellm_provider_requires_model_name(self):
189 self._use_litellm_provider(model_name="")
190
191 result = llm._generate_response("test")
192
193 self.assertIn("Error:", result)
194 self.assertIn("model_name is not set", result)
195
196 def test_litellm_provider_handles_empty_response(self):
197 self._use_litellm_provider()
198
199 fake_litellm = types.SimpleNamespace(
200 completion=lambda **kwargs: types.SimpleNamespace(choices=[])
201 )
202
203 with patch.dict(sys.modules, {"litellm": fake_litellm}):
204 result = llm._generate_response("test")
205
206 self.assertIn("Error:", result)
207 self.assertIn("returned empty response", result)
208
209 def test_litellm_provider_handles_empty_message(self):
210 """
211 某些 OpenAI-compatible 网关在内容过滤或安全拦截时会返回
212 HTTP 200,但 `choices[0].message` 为 None。这里必须返回
213 可诊断的错误,而不是抛出 AttributeError。
214 """
215 self._use_litellm_provider()
216
217 fake_litellm = types.SimpleNamespace(
218 completion=lambda **kwargs: types.SimpleNamespace(
219 choices=[types.SimpleNamespace(message=None)]
220 )
221 )
222
223 with patch.dict(sys.modules, {"litellm": fake_litellm}):
224 result = llm._generate_response("test")
225
226 self.assertIn("Error:", result)
227 self.assertIn("returned empty message", result)
228
229 def test_sanitize_error_message_redacts_url_credentials_and_query_tokens(self):
230 message = (
231 "request failed for "
232 "https://myuser:mypassword@proxy.example.com/v1/chat"
233 "?api_key=secret-key&token=secret-token&safe=value"
234 )
235
236 result = llm._sanitize_error_message(message)
237
238 self.assertIn("https://***:***@proxy.example.com", result)
239 self.assertIn("api_key=***", result)
240 self.assertIn("token=***", result)
241 self.assertIn("safe=value", result)
242 self.assertNotIn("myuser", result)
243 self.assertNotIn("mypassword", result)
244 self.assertNotIn("secret-key", result)
245 self.assertNotIn("secret-token", result)
246
247 def test_openai_provider_error_redacts_embedded_base_url_credentials(self):
248 """
249 自定义 OpenAI-compatible base_url 可能包含代理网关的 user:pass。
250 SDK 抛错时常会把 URL 带回异常信息,这里验证最终返回给 WebUI/API 的
251 `Error:` 文案不会泄露这些凭据。
252 """
253 config.app["llm_provider"] = "groq"
254 config.app["groq_api_key"] = "groq-key"
255 config.app["groq_model_name"] = "llama-3.3-70b-versatile"
256 config.app["groq_base_url"] = "https://myuser:mypassword@proxy.example.com/openai/v1"
257
258 class FakeCompletions:
259 def create(self, **kwargs):
260 raise RuntimeError(
261 "connection failed: "
262 "https://myuser:mypassword@proxy.example.com/openai/v1"
263 "?access_token=secret-token"
264 )
265
266 fake_client = types.SimpleNamespace(
267 chat=types.SimpleNamespace(completions=FakeCompletions())
268 )
269
270 with patch.object(llm, "OpenAI", return_value=fake_client):
271 result = llm._generate_response("test")
272
273 self.assertIn("Error:", result)
274 self.assertIn("https://***:***@proxy.example.com", result)
275 self.assertIn("access_token=***", result)
276 self.assertNotIn("myuser", result)
277 self.assertNotIn("mypassword", result)
278 self.assertNotIn("secret-token", result)
279
280 def test_openai_provider_still_uses_existing_path(self):
281 config.app["llm_provider"] = "openai"
282 config.app["openai_api_key"] = ""
283 config.app["openai_base_url"] = "https://api.openai.com/v1"
284 config.app["openai_model_name"] = "gpt-4o-mini"
285
286 result = llm._generate_response("test")
287
288 self.assertIn("Error:", result)
289 self.assertIn("api_key is not set", result)
290 self.assertNotIn("litellm", result.lower())
291
292 def _use_qwen_provider(self):
293 config.app["llm_provider"] = "qwen"
294 config.app["qwen_api_key"] = "qwen-key"
295 config.app["qwen_model_name"] = "qwen-max"
296
297 def _patch_dashscope_generation(self, response):
298 class FakeGenerationResponse(dict):
299 pass
300
301 fake_response = FakeGenerationResponse(response)
302 fake_response.status_code = response.get("status_code", 200)
303 fake_dashscope = types.SimpleNamespace(
304 api_key="",
305 Generation=types.SimpleNamespace(call=lambda **kwargs: fake_response),
306 )
307 fake_dashscope_response = types.SimpleNamespace(
308 GenerationResponse=FakeGenerationResponse
309 )
310
311 return patch.dict(
312 sys.modules,
313 {
314 "dashscope": fake_dashscope,
315 "dashscope.api_entities": types.SimpleNamespace(),
316 "dashscope.api_entities.dashscope_response": fake_dashscope_response,
317 },
318 )
319
320 def test_qwen_provider_reads_chat_choices_content(self):
321 """
322 DashScope chat 模式会把文本放在 `output.choices[0].message.content`。
323 这里覆盖 issue #966 报告的 `output.text is None` 场景,避免再次触发
324 `'NoneType' object has no attribute 'replace'`。
325 """
326 self._use_qwen_provider()
327 response = {
328 "output": {
329 "text": None,
330 "choices": [{"message": {"content": "你好\n世界"}}],
331 }
332 }
333
334 with self._patch_dashscope_generation(response):
335 result = llm._generate_response("Say hello")
336
337 self.assertEqual(result, "你好世界")
338
339 def test_qwen_provider_falls_back_to_output_text(self):
340 """保留旧 DashScope completion 响应结构的兼容路径。"""
341 self._use_qwen_provider()
342 response = {"output": {"text": "旧格式\n响应"}}
343
344 with self._patch_dashscope_generation(response):
345 result = llm._generate_response("Say hello")
346
347 self.assertEqual(result, "旧格式响应")
348
349 def test_qwen_provider_reports_empty_text(self):
350 """Qwen 空响应应返回可诊断错误,而不是底层 AttributeError。"""
351 self._use_qwen_provider()
352 response = {"output": {"text": None, "choices": [{"message": {"content": None}}]}}
353
354 with self._patch_dashscope_generation(response):
355 result = llm._generate_response("Say hello")
356
357 self.assertIn("Error:", result)
358 self.assertIn("returned empty text content", result)
359 self.assertNotIn("NoneType", result)
360
361 def test_qwen_provider_reports_empty_choices(self):
362 """Qwen chat 响应 choices 为空时应返回明确错误。"""
363 self._use_qwen_provider()
364 response = {"output": {"text": None, "choices": []}}
365
366 with self._patch_dashscope_generation(response):
367 result = llm._generate_response("Say hello")
368
369 self.assertIn("Error:", result)
370 self.assertIn("returned empty choices", result)
371 self.assertNotIn("NoneType", result)
372
373 def test_aihubmix_provider_uses_openai_compatible_client(self):
374 """
375 AIHubMix 是 OpenAI-compatible 网关。这里用 fake OpenAI client
376 验证独立 provider 会使用合作方默认地址和推荐模型,避免真实网络
377 或私有 API Key 影响测试稳定性。
378 """
379 config.app["llm_provider"] = "aihubmix"
380 config.app["aihubmix_api_key"] = "aihubmix-key"
381 config.app["aihubmix_base_url"] = ""
382 config.app["aihubmix_model_name"] = ""
383
384 class FakeCompletions:
385 def create(self, **kwargs):
386 self.kwargs = kwargs
387 message = types.SimpleNamespace(content="hello\naihubmix")
388 choice = types.SimpleNamespace(message=message)
389 return types.SimpleNamespace(choices=[choice])
390
391 fake_completions = FakeCompletions()
392 fake_client = types.SimpleNamespace(
393 chat=types.SimpleNamespace(completions=fake_completions)
394 )
395
396 with (
397 patch.object(llm, "OpenAI", return_value=fake_client) as openai_client,
398 patch.object(llm, "ChatCompletion", types.SimpleNamespace),
399 ):
400 result = llm._generate_response("Say hello")
401
402 openai_client.assert_called_once_with(
403 api_key="aihubmix-key",
404 base_url="https://aihubmix.com/v1",
405 )
406 self.assertEqual(
407 fake_completions.kwargs,
408 {
409 "model": "gpt-5.4-mini",
410 "messages": [{"role": "user", "content": "Say hello"}],
411 },
412 )
413 self.assertEqual(result, "helloaihubmix")
414
415 def test_aimlapi_provider_uses_openai_compatible_client(self):
416 config.app["llm_provider"] = "aimlapi"
417 config.app["aimlapi_api_key"] = "aimlapi-key"
418 config.app["aimlapi_base_url"] = ""
419 config.app["aimlapi_model_name"] = ""
420
421 class FakeCompletions:
422 def create(self, **kwargs):
423 self.kwargs = kwargs
424 message = types.SimpleNamespace(content="hello\naimlapi")
425 choice = types.SimpleNamespace(message=message)
426 return types.SimpleNamespace(choices=[choice])
427
428 fake_completions = FakeCompletions()
429 fake_client = types.SimpleNamespace(
430 chat=types.SimpleNamespace(completions=fake_completions)
431 )
432
433 with (
434 patch.object(llm, "OpenAI", return_value=fake_client) as openai_client,
435 patch.object(llm, "ChatCompletion", types.SimpleNamespace),
436 ):
437 result = llm._generate_response("Say hello")
438
439 openai_client.assert_called_once_with(
440 api_key="aimlapi-key",
441 base_url="https://api.aimlapi.com/v1",
442 )
443 self.assertEqual(
444 fake_completions.kwargs,
445 {
446 "model": "openai/gpt-4o-mini",
447 "messages": [{"role": "user", "content": "Say hello"}],
448 },
449 )
450 self.assertEqual(result, "helloaimlapi")
451
452 def test_evolink_provider_uses_openai_compatible_client(self):
453 """
454 EvoLink exposes OpenAI-compatible Chat Completions at direct.evolink.ai.
455 The provider should keep its own default endpoint and model instead of
456 requiring users to overload the generic OpenAI settings.
457 """
458 config.app["llm_provider"] = "evolink"
459 config.app["evolink_api_key"] = "evolink-key"
460 config.app["evolink_base_url"] = ""
461 config.app["evolink_model_name"] = ""
462
463 class FakeCompletions:
464 def create(self, **kwargs):
465 self.kwargs = kwargs
466 message = types.SimpleNamespace(content="hello\nevolink")
467 choice = types.SimpleNamespace(message=message)
468 return types.SimpleNamespace(choices=[choice])
469
470 fake_completions = FakeCompletions()
471 fake_client = types.SimpleNamespace(
472 chat=types.SimpleNamespace(completions=fake_completions)
473 )
474
475 with (
476 patch.object(llm, "OpenAI", return_value=fake_client) as openai_client,
477 patch.object(llm, "ChatCompletion", types.SimpleNamespace),
478 ):
479 result = llm._generate_response("Say hello")
480
481 openai_client.assert_called_once_with(
482 api_key="evolink-key",
483 base_url="https://direct.evolink.ai/v1",
484 )
485 self.assertEqual(
486 fake_completions.kwargs,
487 {
488 "model": "gpt-5.5",
489 "messages": [{"role": "user", "content": "Say hello"}],
490 },
491 )
492 self.assertEqual(result, "helloevolink")
493
494 def test_volcengine_provider_uses_openai_compatible_client(self):
495 """
496 VolcEngine Ark 暴露 OpenAI-compatible Chat Completions。
497 这里用 fake OpenAI client 覆盖 provider 默认地址和默认模型,
498 避免真实网络或私有 API key 影响测试稳定性。
499 """
500 config.app["llm_provider"] = "volcengine"
501 config.app["volcengine_api_key"] = "volcengine-key"
502 config.app["volcengine_base_url"] = ""
503 config.app["volcengine_model_name"] = ""
504
505 class FakeCompletions:
506 def create(self, **kwargs):
507 self.kwargs = kwargs
508 message = types.SimpleNamespace(content="hello\nvolcengine")
509 choice = types.SimpleNamespace(message=message)
510 return types.SimpleNamespace(choices=[choice])
511
512 fake_completions = FakeCompletions()
513 fake_client = types.SimpleNamespace(
514 chat=types.SimpleNamespace(completions=fake_completions)
515 )
516
517 with (
518 patch.object(llm, "OpenAI", return_value=fake_client) as openai_client,
519 patch.object(llm, "ChatCompletion", types.SimpleNamespace),
520 ):
521 result = llm._generate_response("Say hello")
522
523 openai_client.assert_called_once_with(
524 api_key="volcengine-key",
525 base_url="https://ark.cn-beijing.volces.com/api/v3",
526 )
527 self.assertEqual(
528 fake_completions.kwargs,
529 {
530 "model": "doubao-seed-2-1-turbo-260628",
531 "messages": [{"role": "user", "content": "Say hello"}],
532 },
533 )
534 self.assertEqual(result, "hellovolcengine")
535
536 def test_grok_provider_still_uses_existing_path(self):
537 config.app["llm_provider"] = "grok"
538 config.app["grok_api_key"] = ""
539 config.app["grok_base_url"] = "https://api.x.ai/v1"
540 config.app["grok_model_name"] = "grok-4.3"
541
542 result = llm._generate_response("test")
543
544 self.assertIn("Error:", result)
545 self.assertIn("api_key is not set", result)
546 self.assertNotIn("litellm", result.lower())
547
548 def test_groq_provider_requires_api_key(self):
549 config.app["llm_provider"] = "groq"
550 config.app["groq_api_key"] = ""
551 config.app["groq_base_url"] = "https://api.groq.com/openai/v1"
552 config.app["groq_model_name"] = "llama-3.3-70b-versatile"
553
554 result = llm._generate_response("test")
555
556 self.assertIn("Error:", result)
557 self.assertIn("api_key is not set", result)
558 self.assertNotIn("litellm", result.lower())
559
560 def test_groq_provider_uses_default_base_url(self):
561 config.app["llm_provider"] = "groq"
562 config.app["groq_api_key"] = "groq-test-key"
563 config.app["groq_base_url"] = ""
564 config.app["groq_model_name"] = "llama-3.3-70b-versatile"
565
566 fake_response = types.SimpleNamespace(
567 choices=[
568 types.SimpleNamespace(
569 message=types.SimpleNamespace(content="hello\ngroq")
570 )
571 ]
572 )
573 fake_client = types.SimpleNamespace(
574 chat=types.SimpleNamespace(
575 completions=types.SimpleNamespace(create=lambda **kwargs: fake_response)
576 )
577 )
578
579 with (
580 patch.object(llm, "OpenAI", return_value=fake_client) as openai_client,
581 patch.object(llm, "ChatCompletion", types.SimpleNamespace),
582 ):
583 result = llm._generate_response("Say hello")
584
585 openai_client.assert_called_once_with(
586 api_key="groq-test-key",
587 base_url="https://api.groq.com/openai/v1",
588 )
589 self.assertEqual(result, "hellogroq")
590
591 def _use_ollama_provider(self, base_url=""):
592 config.app["llm_provider"] = "ollama"
593 config.app["ollama_api_key"] = ""
594 config.app["ollama_base_url"] = base_url
595 config.app["ollama_model_name"] = "llama3"
596
597 def _assert_ollama_base_url(self, expected_base_url: str):
598 class FakeCompletions:
599 def create(self, **kwargs):
600 self.kwargs = kwargs
601 message = types.SimpleNamespace(content="hello\nollama")
602 choice = types.SimpleNamespace(message=message)
603 return types.SimpleNamespace(choices=[choice])
604
605 fake_completions = FakeCompletions()
606 fake_client = types.SimpleNamespace(
607 chat=types.SimpleNamespace(completions=fake_completions)
608 )
609
610 with (
611 patch.object(llm, "OpenAI", return_value=fake_client) as openai_client,
612 patch.object(llm, "ChatCompletion", types.SimpleNamespace),
613 ):
614 result = llm._generate_response("Say hello")
615
616 openai_client.assert_called_once_with(
617 api_key="ollama",
618 base_url=expected_base_url,
619 )
620 self.assertEqual(
621 fake_completions.kwargs,
622 {
623 "model": "llama3",
624 "messages": [{"role": "user", "content": "Say hello"}],
625 },
626 )
627 self.assertEqual(result, "helloollama")
628
629 def test_ollama_default_base_url_uses_localhost_outside_container(self):
630 """
631 普通本机运行时,Ollama 默认仍然使用 localhost,避免影响已有用户。
632 """
633 self._use_ollama_provider()
634
635 with patch.object(config, "is_running_in_container", return_value=False):
636 self._assert_ollama_base_url("http://localhost:11434/v1")
637
638 def test_ollama_default_base_url_uses_host_gateway_inside_container(self):
639 """
640 容器内运行时,localhost 指向容器自身;默认改为 host.docker.internal,
641 方便 Docker Desktop 用户访问宿主机上的 Ollama。
642 """
643 self._use_ollama_provider()
644
645 with (
646 patch.object(config, "is_running_in_container", return_value=True),
647 patch.object(config, "_can_resolve_hostname", return_value=True),
648 ):
649 self._assert_ollama_base_url("http://host.docker.internal:11434/v1")
650
651 def test_ollama_default_base_url_falls_back_to_container_gateway(self):
652 """
653 原生 Linux Docker 里不一定能解析 host.docker.internal。此时使用容器
654 默认网关作为兜底地址,比直接返回不可解析的 hostname 更稳。
655 """
656 self._use_ollama_provider()
657
658 with (
659 patch.object(config, "is_running_in_container", return_value=True),
660 patch.object(config, "_can_resolve_hostname", return_value=False),
661 patch.object(config, "get_container_default_gateway_ip", return_value="172.17.0.1"),
662 ):
663 self._assert_ollama_base_url("http://172.17.0.1:11434/v1")
664
665 def test_ollama_explicit_base_url_takes_precedence(self):
666 """
667 用户手动配置的 ollama_base_url 优先级最高,不受容器检测影响。
668 """
669 self._use_ollama_provider(base_url="http://ollama:11434/v1")
670
671 with patch.object(config, "is_running_in_container", return_value=True):
672 self._assert_ollama_base_url("http://ollama:11434/v1")
673
674 def test_mimo_provider_uses_openai_compatible_client(self):
675 """
676 MiMo 官方接口兼容 OpenAI Chat Completions 协议。这里用 fake OpenAI
677 client 验证 provider 会使用 MiMo 独立配置和默认 base_url,不依赖
678 真实网络或私有 API Key。
679 """
680 config.app["llm_provider"] = "mimo"
681 config.app["mimo_api_key"] = "mimo-key"
682 config.app["mimo_base_url"] = ""
683 config.app["mimo_model_name"] = ""
684
685 class FakeCompletions:
686 def create(self, **kwargs):
687 self.kwargs = kwargs
688 message = types.SimpleNamespace(content="hello\nmimo")
689 choice = types.SimpleNamespace(message=message)
690 return types.SimpleNamespace(choices=[choice])
691
692 fake_completions = FakeCompletions()
693 fake_client = types.SimpleNamespace(
694 chat=types.SimpleNamespace(completions=fake_completions)
695 )
696
697 with (
698 patch.object(llm, "OpenAI", return_value=fake_client) as openai_client,
699 patch.object(llm, "ChatCompletion", types.SimpleNamespace),
700 ):
701 result = llm._generate_response("Say hello")
702
703 openai_client.assert_called_once_with(
704 api_key="mimo-key",
705 base_url="https://api.xiaomimimo.com/v1",
706 )
707 self.assertEqual(
708 fake_completions.kwargs,
709 {
710 "model": "mimo-v2.5-pro",
711 "messages": [{"role": "user", "content": "Say hello"}],
712 },
713 )
714 self.assertEqual(result, "hellomimo")
715
716 def test_azure_provider_uses_azure_client_directly(self):
717 """
718 Azure OpenAI 的鉴权、endpoint 和 api-version 都由 AzureOpenAI 客户端处理。
719 这个测试覆盖 issue #892:azure 分支必须直接调用 AzureOpenAI 创建的客户端,
720 不能继续落入普通 OpenAI-compatible 分支,否则会丢失 Azure 专用请求配置。
721 """
722 config.app["llm_provider"] = "azure"
723 config.app["azure_api_key"] = "azure-key"
724 config.app["azure_base_url"] = "https://example.openai.azure.com"
725 config.app["azure_model_name"] = "gpt-4o-mini"
726 config.app["azure_api_version"] = "2024-02-15-preview"
727
728 class FakeCompletions:
729 def create(self, **kwargs):
730 self.kwargs = kwargs
731 message = types.SimpleNamespace(content="hello\nazure")
732 choice = types.SimpleNamespace(message=message)
733 return types.SimpleNamespace(choices=[choice])
734
735 fake_completions = FakeCompletions()
736 fake_client = types.SimpleNamespace(
737 chat=types.SimpleNamespace(completions=fake_completions)
738 )
739
740 with (
741 patch.object(llm, "AzureOpenAI", return_value=fake_client) as azure_client,
742 patch.object(llm, "OpenAI") as openai_client,
743 patch.object(llm, "ChatCompletion", types.SimpleNamespace),
744 ):
745 result = llm._generate_response("Say hello")
746
747 azure_client.assert_called_once_with(
748 api_key="azure-key",
749 api_version="2024-02-15-preview",
750 azure_endpoint="https://example.openai.azure.com",
751 )
752 openai_client.assert_not_called()
753 self.assertEqual(
754 fake_completions.kwargs,
755 {
756 "model": "gpt-4o-mini",
757 "messages": [{"role": "user", "content": "Say hello"}],
758 },
759 )
760 self.assertEqual(result, "helloazure")
761
762 def test_g4f_provider_requires_explicit_opt_in(self):
763 """
764 g4f 存在供应链和稳定性风险,不能因为用户把 provider 写成 g4f
765 就默认加载第三方包并访问逆向接口,必须显式启用。
766 """
767 config.app["llm_provider"] = "g4f"
768 config.app["enable_g4f"] = False
769
770 result = llm._generate_response("test")
771
772 self.assertIn("Error:", result)
773 self.assertIn("g4f provider is disabled", result)
774
775 def test_g4f_provider_uses_lazy_import_after_opt_in(self):
776 config.app["llm_provider"] = "g4f"
777 config.app["enable_g4f"] = True
778 config.app["g4f_model_name"] = "gpt-3.5-turbo"
779
780 fake_g4f = types.SimpleNamespace()
781 fake_g4f.ChatCompletion = types.SimpleNamespace(
782 create=lambda **kwargs: "hello from g4f"
783 )
784
785 with patch.dict(sys.modules, {"g4f": fake_g4f}):
786 result = llm._generate_response("test")
787
788 self.assertEqual(result, "hello from g4f")
789
790 def test_g4f_provider_reports_missing_optional_dependency(self):
791 config.app["llm_provider"] = "g4f"
792 config.app["enable_g4f"] = True
793 config.app["g4f_model_name"] = "gpt-3.5-turbo"
794
795 with patch.dict(sys.modules, {"g4f": None}):
796 result = llm._generate_response("test")
797
798 self.assertIn("Error:", result)
799 self.assertIn("g4f package is not installed by default", result)
800
801
802 class TestRuntimeEnvironmentDetection(unittest.TestCase):
803 def test_container_detection_ignores_plain_linux_cgroup_file(self):
804 """
805 普通 Linux 也有 /proc/1/cgroup,不能因为文件存在就判定为容器。
806 """
807 with tempfile.TemporaryDirectory() as tmp_dir:
808 cgroup_path = Path(tmp_dir) / "cgroup"
809 cgroup_path.write_text("0::/init.scope\n", encoding="utf-8")
810
811 self.assertFalse(
812 config.is_running_in_container(
813 dockerenv_path=str(Path(tmp_dir) / "missing-dockerenv"),
814 containerenv_path=str(Path(tmp_dir) / "missing-containerenv"),
815 cgroup_path=str(cgroup_path),
816 )
817 )
818
819 def test_container_detection_accepts_dockerenv_marker(self):
820 with tempfile.TemporaryDirectory() as tmp_dir:
821 dockerenv_path = Path(tmp_dir) / ".dockerenv"
822 dockerenv_path.write_text("", encoding="utf-8")
823
824 self.assertTrue(
825 config.is_running_in_container(
826 dockerenv_path=str(dockerenv_path),
827 containerenv_path=str(Path(tmp_dir) / "missing-containerenv"),
828 cgroup_path=str(Path(tmp_dir) / "missing-cgroup"),
829 )
830 )
831
832 def test_container_detection_accepts_cgroup_container_marker(self):
833 with tempfile.TemporaryDirectory() as tmp_dir:
834 cgroup_path = Path(tmp_dir) / "cgroup"
835 cgroup_path.write_text(
836 "0::/system.slice/docker-abcdef.scope\n",
837 encoding="utf-8",
838 )
839
840 self.assertTrue(
841 config.is_running_in_container(
842 dockerenv_path=str(Path(tmp_dir) / "missing-dockerenv"),
843 containerenv_path=str(Path(tmp_dir) / "missing-containerenv"),
844 cgroup_path=str(cgroup_path),
845 )
846 )
847
848 def test_container_gateway_ip_decodes_default_route(self):
849 with tempfile.TemporaryDirectory() as tmp_dir:
850 route_path = Path(tmp_dir) / "route"
851 route_path.write_text(
852 "Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT\n"
853 "eth0\t00000000\t010011AC\t0003\t0\t0\t0\t00000000\t0\t0\t0\n",
854 encoding="utf-8",
855 )
856
857 self.assertEqual(
858 config.get_container_default_gateway_ip(str(route_path)),
859 "172.17.0.1",
860 )
861
862 def test_container_gateway_ip_ignores_missing_default_route(self):
863 with tempfile.TemporaryDirectory() as tmp_dir:
864 route_path = Path(tmp_dir) / "route"
865 route_path.write_text(
866 "Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT\n"
867 "eth0\t0011AC0A\t00000000\t0001\t0\t0\t0\t00FFFFFF\t0\t0\t0\n",
868 encoding="utf-8",
869 )
870
871 self.assertEqual(config.get_container_default_gateway_ip(str(route_path)), "")
872
873
874 class TestSocialMetadata(unittest.TestCase):
875 """通用短视频发布文案元数据生成。"""
876
877 def test_build_prompt_auto_language_uses_source_language(self):
878 """
879 language 默认 auto 时,不应该固定成某个国家或语种,而是让模型
880 跟随视频主题和脚本的语言,扩大 API 适用范围。
881 """
882 prompt = llm.build_social_metadata_prompt(
883 video_subject="上海一日游",
884 video_script="今天带你快速看完上海经典路线。",
885 language="auto",
886 platform="tiktok",
887 )
888
889 self.assertIn("TikTok", prompt)
890 self.assertIn("Use the same language as the video subject and script", prompt)
891 self.assertIn("上海一日游", prompt)
892 self.assertIn("array of exactly 5 strings", prompt)
893
894 def test_build_prompt_accepts_explicit_language(self):
895 prompt = llm.build_social_metadata_prompt(
896 video_subject="Coffee tips",
897 language="en-US",
898 platform="youtube_shorts",
899 )
900
901 self.assertIn("YouTube Shorts", prompt)
902 self.assertIn('Write "title" and "caption" in this language: en-US', prompt)
903 self.assertIn("array of exactly 3 strings", prompt)
904
905 def test_unknown_platform_falls_back_to_tiktok(self):
906 prompt = llm.build_social_metadata_prompt(
907 video_subject="x",
908 platform="unsupported-platform",
909 )
910
911 self.assertIn("TikTok", prompt)
912
913 def test_normalize_hashtags_from_string_dedupes_and_clamps(self):
914 tags = llm._normalize_hashtags("#fyp fyp, trending #Trending viral", count=2)
915
916 self.assertEqual(tags, ["#fyp", "#trending"])
917
918 def test_normalize_hashtags_from_list_keeps_unicode_letters(self):
919 tags = llm._normalize_hashtags(
920 ["上海 旅行", "#việt nam", " ", "@bad!chars"], count=5
921 )
922
923 self.assertEqual(tags, ["#上海旅行", "#việtnam", "#badchars"])
924
925 def test_parse_social_metadata_recovers_embedded_json(self):
926 raw = 'Sure: {"title":"T","caption":"C","hashtags":["#x"]} thanks'
927 result = llm._parse_social_metadata(raw, "tiktok")
928
929 self.assertEqual(result["title"], "T")
930 self.assertEqual(result["caption"], "C")
931 self.assertEqual(result["hashtags"], ["#x"])
932
933 def test_parse_social_metadata_requires_title_or_caption(self):
934 with self.assertRaises(ValueError):
935 llm._parse_social_metadata('{"hashtags":["#x"]}', "tiktok")
936
937 def test_generate_social_metadata_uses_llm_response(self):
938 payload = (
939 '{"title":"上海一日游","caption":"收藏这条路线,下次直接出发!",'
940 '"hashtags":["#上海","#旅行","#shorts"]}'
941 )
942 with patch.object(llm, "_generate_response", return_value=payload):
943 result = llm.generate_social_metadata(
944 video_subject="上海一日游",
945 video_script="今天带你快速看完上海经典路线。",
946 language="zh-CN",
947 platform="tiktok",
948 )
949
950 self.assertEqual(result["title"], "上海一日游")
951 self.assertEqual(result["caption"], "收藏这条路线,下次直接出发!")
952 self.assertEqual(result["hashtags"], ["#上海", "#旅行", "#shorts"])
953
954 def test_generate_social_metadata_falls_back_to_generic_hashtags(self):
955 with patch.object(
956 llm, "_generate_response", return_value="Error: api_key is not set"
957 ):
958 result = llm.generate_social_metadata(
959 video_subject="Coffee tips",
960 video_script="Save these three coffee tips.",
961 platform="instagram_reels",
962 )
963
964 self.assertEqual(result["title"], "Coffee tips")
965 self.assertEqual(result["caption"], "Save these three coffee tips.")
966 self.assertEqual(len(result["hashtags"]), 8)
967 self.assertEqual(result["hashtags"][0], "#shorts")
968
969 def test_request_model_defaults_to_auto_language_tiktok(self):
970 body = VideoSocialMetadataRequest(video_subject="Test")
971
972 self.assertEqual(body.language, "auto")
973 self.assertEqual(body.platform, "tiktok")
974
975 def test_request_model_rejects_oversized_social_metadata_fields(self):
976 """
977 外部 API 不能接受无限长的脚本和语言参数,否则会直接放大 LLM
978 token 成本。schema 层先拦截,服务层再做内部调用兜底。
979 """
980 with self.assertRaises(ValidationError):
981 VideoSocialMetadataRequest(video_subject="x" * 501)
982
983 with self.assertRaises(ValidationError):
984 VideoSocialMetadataRequest(video_subject="x", video_script="x" * 8001)
985
986 with self.assertRaises(ValidationError):
987 VideoSocialMetadataRequest(video_subject="x", language="x" * 65)
988
989 def test_build_prompt_clamps_direct_service_inputs(self):
990 prompt = llm.build_social_metadata_prompt(
991 video_subject="x" * 600,
992 video_script="y" * 9000,
993 language="en",
994 )
995
996 self.assertIn("x" * llm.MAX_SOCIAL_SUBJECT_LENGTH, prompt)
997 self.assertNotIn("x" * (llm.MAX_SOCIAL_SUBJECT_LENGTH + 1), prompt)
998 self.assertIn("y" * llm.MAX_SOCIAL_SCRIPT_LENGTH, prompt)
999 self.assertNotIn("y" * (llm.MAX_SOCIAL_SCRIPT_LENGTH + 1), prompt)
1000
1001 def test_social_metadata_endpoint_response_shape(self):
1002 from fastapi.testclient import TestClient
1003
1004 from app.asgi import app
1005
1006 request_body = {
1007 "video_subject": "Tokyo coffee shops",
1008 "video_script": "Three quiet coffee shops for your next Tokyo morning.",
1009 "language": "en",
1010 "platform": "youtube_shorts",
1011 }
1012 llm_response = (
1013 '{"title":"3 Quiet Tokyo Coffee Shops",'
1014 '"caption":"Save these spots for your next Tokyo morning.",'
1015 '"hashtags":["#Tokyo","#Coffee","#Shorts"]}'
1016 )
1017
1018 with patch.object(llm, "_generate_response", return_value=llm_response):
1019 response = TestClient(app).post(
1020 "/api/v1/social-metadata",
1021 json=request_body,
1022 )
1023
1024 self.assertEqual(response.status_code, 200)
1025 self.assertEqual(
1026 response.json(),
1027 {
1028 "status": 200,
1029 "message": "success",
1030 "data": {
1031 "title": "3 Quiet Tokyo Coffee Shops",
1032 "caption": "Save these spots for your next Tokyo morning.",
1033 "hashtags": ["#Tokyo", "#Coffee", "#Shorts"],
1034 },
1035 },
1036 )
1037
1038
1039 FOUNDRY_KEY = os.environ.get("ANTHROPIC_FOUNDRY_API_KEY", "")
1040 FOUNDRY_BASE = "https://amanrai-test-resource.services.ai.azure.com/anthropic"
1041 FOUNDRY_MODEL = "azure_ai/claude-sonnet-4-6"
1042
1043
1044 @unittest.skipUnless(
1045 RUN_INTEGRATION_TESTS and FOUNDRY_KEY,
1046 "MPT_RUN_INTEGRATION_TESTS and ANTHROPIC_FOUNDRY_API_KEY not set",
1047 )
1048 class TestLiteLLMLiveIntegration(unittest.TestCase):
1049 def setUp(self):
1050 self.original_app_config = dict(config.app)
1051 config.app["llm_provider"] = "litellm"
1052 config.app["litellm_model_name"] = FOUNDRY_MODEL
1053 os.environ["AZURE_AI_API_KEY"] = FOUNDRY_KEY
1054 os.environ["AZURE_AI_API_BASE"] = FOUNDRY_BASE
1055
1056 def tearDown(self):
1057 config.app.clear()
1058 config.app.update(self.original_app_config)
1059
1060 def test_live_litellm_completion(self):
1061 result = llm._generate_response("What is 2+2? Reply with just the number.")
1062
1063 self.assertNotIn("Error:", result)
1064 self.assertIn("4", result)
1065
1066
1067 if __name__ == "__main__":
1068 unittest.main()
1069
1069 lines PYTHON