Files
wehub-resource-sync 9a6da7d40d
Publish Docker image / Build and publish (push) Failing after 0s
CI / Python tests (push) Failing after 4s
chore: import upstream snapshot with attribution
2026-07-13 12:03:55 +08:00

1523 lines
59 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 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 模型可能返回 `<think>...</think>`。脚本生成链路必须只保留
最终正文,避免思考过程进入字幕和配音。
"""
result = llm._normalize_text_response(
"<think>\nI should reason here.\n</think>\n测试成功",
"minimax",
)
self.assertEqual(result, "测试成功")
def test_normalize_text_response_rejects_think_only_response(self):
"""
如果模型只返回思考块而没有最终答案,应视为空内容,触发重试或明确错误。
"""
with self.assertRaises(ValueError):
llm._normalize_text_response("<think>hidden reasoning</think>", "minimax")
def test_normalize_text_response_removes_unclosed_think_block(self):
"""
某些网关可能因为截断只返回未闭合的 `<think>`。这种内容同样不能
进入最终脚本;如果清理后没有正文,就应该按空响应处理。
"""
with self.assertRaises(ValueError):
llm._normalize_text_response("<think>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 #892azure 分支必须直接调用 AzureOpenAI 创建的客户端,
不能继续落入普通 OpenAI-compatible 分支,否则会丢失 Azure 专用请求配置。
"""
config.app["llm_provider"] = "azure"
config.app["azure_api_key"] = "azure-key"
config.app["azure_base_url"] = "https://example.openai.azure.com"
config.app["azure_model_name"] = "gpt-4o-mini"
config.app["azure_api_version"] = "2024-02-15-preview"
class FakeCompletions:
def create(self, **kwargs):
self.kwargs = kwargs
message = types.SimpleNamespace(content="hello\nazure")
choice = types.SimpleNamespace(message=message)
return types.SimpleNamespace(choices=[choice])
fake_completions = FakeCompletions()
fake_client = types.SimpleNamespace(
chat=types.SimpleNamespace(completions=fake_completions)
)
with (
patch.object(llm, "AzureOpenAI", return_value=fake_client) as azure_client,
patch.object(llm, "OpenAI") as openai_client,
patch.object(llm, "ChatCompletion", types.SimpleNamespace),
):
result = llm._generate_response("Say hello")
azure_client.assert_called_once_with(
api_key="azure-key",
api_version="2024-02-15-preview",
azure_endpoint="https://example.openai.azure.com",
)
openai_client.assert_not_called()
self.assertEqual(
fake_completions.kwargs,
{
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "Say hello"}],
},
)
self.assertEqual(result, "helloazure")
def test_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()