NarratoAI/app/services/llm/test_openai_compat_unittest.py
linyq-laien 0a5dcf5f21 功能: 新增字幕任务专用高效模型支持
新增高推理模型和高效模型的中英文国际化配置字符串
新增高效文本模型配置项,支持空值时自动回退到高推理模型
添加resolve_text_model_name工具函数用于根据偏好解析对应文本模型
更新字幕校对和翻译服务,默认使用高效模型处理批量字幕任务
优化OpenAI兼容提供商的模型参数处理逻辑,支持通过参数覆盖模型名称
更新WebUI基础设置页面,新增高效模型配置项并支持测试两个模型的连接
同步更新示例配置文件、默认配置项与相关测试用例
2026-07-19 14:53:06 +08:00

341 lines
12 KiB
Python

"""OpenAI 兼容 provider 的最小回归测试。"""
import asyncio
import unittest
from unittest.mock import patch
from app.config import config
from app.services.llm.base import TextModelProvider
from app.services.llm.exceptions import ConfigurationError
from app.services.llm.manager import LLMServiceManager
from app.services.llm.migration_adapter import LegacyLLMAdapter, VisionAnalyzerAdapter
from app.services.llm.openai_compatible_provider import (
OpenAICompatibleTextProvider,
OpenAICompatibleVisionProvider,
is_trusted_openai_compatible_base_url,
validate_openai_compatible_base_url,
)
from app.services.llm.providers import register_all_providers
from app.utils.openai_base_url_security import openai_compatible_base_url_warning
class DummyOpenAITextProvider(TextModelProvider):
@property
def provider_name(self) -> str:
return "openai"
@property
def supported_models(self) -> list[str]:
return []
async def generate_text(self, prompt: str, **kwargs) -> str:
return prompt
async def _make_api_call(self, payload: dict) -> dict:
return payload
def _reset_manager_state():
LLMServiceManager._vision_providers.clear()
LLMServiceManager._text_providers.clear()
LLMServiceManager._vision_instance_cache.clear()
LLMServiceManager._text_instance_cache.clear()
class OpenAICompatManagerTests(unittest.TestCase):
def setUp(self):
_reset_manager_state()
self._original_app = dict(config.app)
def tearDown(self):
_reset_manager_state()
config.app.clear()
config.app.update(self._original_app)
def test_register_all_providers_registers_expected_providers(self):
register_all_providers()
# 文本仅 OpenAI 兼容;视觉额外提供可选的 TwelveLabs Pegasus。
self.assertEqual({"openai"}, set(LLMServiceManager.list_text_providers()))
self.assertEqual({"openai", "twelvelabs"}, set(LLMServiceManager.list_vision_providers()))
def test_get_text_provider_uses_openai_keys(self):
LLMServiceManager.register_text_provider("openai", DummyOpenAITextProvider)
config.app["text_llm_provider"] = "openai"
config.app["text_openai_api_key"] = "new-key"
config.app["text_openai_model_name"] = "new-model"
config.app["text_openai_base_url"] = "https://new.example/v1"
provider = LLMServiceManager.get_text_provider()
self.assertIsInstance(provider, DummyOpenAITextProvider)
self.assertEqual("new-key", provider.api_key)
self.assertEqual("new-model", provider.model_name)
self.assertEqual("https://new.example/v1", provider.base_url)
class OpenAICompatVisionConcurrencyTests(unittest.IsolatedAsyncioTestCase):
async def test_analyze_images_keeps_batch_order_when_running_concurrently(self):
provider = OpenAICompatibleVisionProvider(api_key="k", model_name="m")
provider._prepare_images = lambda images: list(images)
async def fake_analyze_batch(batch, prompt, **kwargs):
delays = {"a": 0.03, "c": 0.01, "e": 0.0}
await asyncio.sleep(delays[batch[0]])
return f"batch-{batch[0]}"
provider._analyze_batch = fake_analyze_batch
result = await provider.analyze_images(
images=["a", "b", "c", "d", "e", "f"],
prompt="prompt",
batch_size=2,
max_concurrency=2,
)
self.assertEqual(["batch-a", "batch-c", "batch-e"], result)
async def test_analyze_images_respects_max_concurrency_limit(self):
provider = OpenAICompatibleVisionProvider(api_key="k", model_name="m")
provider._prepare_images = lambda images: list(images)
in_flight = 0
max_in_flight = 0
async def fake_analyze_batch(batch, prompt, **kwargs):
nonlocal in_flight, max_in_flight
in_flight += 1
max_in_flight = max(max_in_flight, in_flight)
await asyncio.sleep(0.02)
in_flight -= 1
return f"batch-{batch[0]}"
provider._analyze_batch = fake_analyze_batch
result = await provider.analyze_images(
images=["a", "b", "c", "d", "e", "f"],
prompt="prompt",
batch_size=1,
max_concurrency=2,
)
self.assertEqual(6, len(result))
self.assertEqual(2, max_in_flight)
class OpenAICompatGenerationOptionTests(unittest.TestCase):
def setUp(self):
self._original_app = dict(config.app)
def tearDown(self):
config.app.clear()
config.app.update(self._original_app)
def test_build_options_uses_generation_defaults(self):
provider = OpenAICompatibleTextProvider(api_key="k", model_name="m")
for key in (
"text_openai_temperature",
"text_openai_top_p",
"text_openai_max_tokens",
"text_openai_thinking_level",
):
config.app.pop(key, None)
options = provider._build_chat_completion_options("text")
self.assertEqual(1.0, options["temperature"])
self.assertEqual(0.95, options["top_p"])
self.assertEqual(65536, options["max_tokens"])
self.assertNotIn("extra_body", options)
def test_text_request_can_override_model_for_fast_tasks(self):
provider = OpenAICompatibleTextProvider(api_key="k", model_name="reasoning-model")
options = provider._build_text_completion_kwargs(
messages=[{"role": "user", "content": "hello"}],
temperature=0.2,
max_tokens=None,
response_format=None,
kwargs={"model": "fast-model", "thinking_level": "off"},
)
self.assertEqual("fast-model", options["model"])
self.assertNotIn("extra_body", options)
def test_build_options_uses_per_model_generation_config(self):
provider = OpenAICompatibleTextProvider(api_key="k", model_name="m")
config.app.update(
{
"text_openai_temperature": 0.3,
"text_openai_top_p": 0.8,
"text_openai_max_tokens": 2048,
"text_openai_thinking_level": "high",
}
)
options = provider._build_chat_completion_options("text")
self.assertEqual(0.3, options["temperature"])
self.assertEqual(0.8, options["top_p"])
self.assertEqual(2048, options["max_tokens"])
self.assertEqual({"reasoning_effort": "high"}, options["extra_body"])
def test_explicit_generation_options_override_config(self):
provider = OpenAICompatibleTextProvider(api_key="k", model_name="m")
config.app["text_openai_temperature"] = 0.3
options = provider._build_chat_completion_options("text", temperature=0.9, max_tokens=512)
self.assertEqual(0.9, options["temperature"])
self.assertEqual(512, options["max_tokens"])
class OpenAICompatBaseURLValidationTests(unittest.TestCase):
def setUp(self):
self._original_app = dict(config.app)
def tearDown(self):
config.app.clear()
config.app.update(self._original_app)
def test_known_providers_and_local_ollama_are_trusted(self):
trusted_urls = [
"https://api.openai.com/v1",
"https://api.siliconflow.cn/v1",
"https://openrouter.ai/api/v1",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
"https://example.openai.azure.com/openai/deployments/demo",
"http://localhost:11434/v1",
"http://127.0.0.1:11434/v1",
]
for url in trusted_urls:
with self.subTest(url=url):
self.assertTrue(is_trusted_openai_compatible_base_url(url))
def test_unrecognized_or_unsafe_base_urls_are_not_trusted(self):
untrusted_urls = [
"https://attacker.example/v1",
"http://api.openai.com/v1",
"https://user@api.openai.com/v1",
"https://127.0.0.1:9999/v1",
"not-a-url",
]
for url in untrusted_urls:
with self.subTest(url=url):
self.assertFalse(is_trusted_openai_compatible_base_url(url))
def test_build_client_allows_well_formed_custom_base_url_by_default(self):
provider = OpenAICompatibleTextProvider(
api_key="test-key",
model_name="test-model",
base_url="https://custom.example/v1",
)
with patch("app.services.llm.openai_compatible_provider.AsyncOpenAI") as async_openai:
provider._build_client()
self.assertEqual("https://custom.example/v1", async_openai.call_args.kwargs["base_url"])
def test_custom_base_url_validation_returns_normalized_url(self):
self.assertEqual(
"https://custom.example/v1",
validate_openai_compatible_base_url(" https://custom.example/v1 "),
)
def test_custom_base_url_warning_only_for_untrusted_well_formed_urls(self):
warning = openai_compatible_base_url_warning("https://custom.example/v1")
self.assertIn("custom.example", warning)
self.assertEqual("", openai_compatible_base_url_warning("https://api.openai.com/v1"))
self.assertEqual("", openai_compatible_base_url_warning(""))
def test_custom_base_url_validation_rejects_malformed_urls(self):
provider = OpenAICompatibleTextProvider(
api_key="test-key",
model_name="test-model",
base_url="https://user@custom.example/v1",
)
with self.assertRaises(ConfigurationError):
provider._build_client()
class ExplicitVisionAdapterSettingsTests(unittest.IsolatedAsyncioTestCase):
class _CapturingVisionProvider:
last_init: tuple[str, str, str | None] | None = None
last_call_kwargs: dict | None = None
def __init__(self, api_key: str, model_name: str, base_url: str | None = None):
self.api_key = api_key
self.model_name = model_name
self.base_url = base_url
ExplicitVisionAdapterSettingsTests._CapturingVisionProvider.last_init = (api_key, model_name, base_url)
async def analyze_images(self, images, prompt, batch_size=10, max_concurrency=1, **kwargs):
ExplicitVisionAdapterSettingsTests._CapturingVisionProvider.last_call_kwargs = dict(kwargs)
return [f"{self.model_name}|{self.api_key}|{self.base_url}"]
def setUp(self):
_reset_manager_state()
self._original_app = dict(config.app)
def tearDown(self):
_reset_manager_state()
config.app.clear()
config.app.update(self._original_app)
async def test_adapter_uses_explicit_settings_instead_of_global_config(self):
LLMServiceManager.register_vision_provider("openai", self._CapturingVisionProvider)
config.app["vision_openai_api_key"] = "config-key"
config.app["vision_openai_model_name"] = "config-model"
config.app["vision_openai_base_url"] = "https://config.example/v1"
adapter = VisionAnalyzerAdapter(
provider="openai",
api_key="explicit-key",
model="explicit-model",
base_url="https://explicit.example/v1",
)
result = await adapter.analyze_images(
images=["/tmp/keyframe_000001_000000100.jpg"],
prompt="描述画面",
batch_size=1,
max_concurrency=1,
)
self.assertEqual(
("explicit-key", "explicit-model", "https://explicit.example/v1"),
self._CapturingVisionProvider.last_init,
)
self.assertEqual("explicit-key", self._CapturingVisionProvider.last_call_kwargs["api_key"])
self.assertEqual("https://explicit.example/v1", self._CapturingVisionProvider.last_call_kwargs["api_base"])
self.assertEqual("explicit-model|explicit-key|https://explicit.example/v1", result[0]["response"])
class LegacyNarrationAdapterBehaviorTests(unittest.TestCase):
def test_generate_narration_returns_raw_unrecoverable_payload_without_fabrication(self):
raw_payload = "not-json-at-all ::: ???"
with patch(
"app.services.llm.migration_adapter.PromptManager.get_prompt",
return_value="prompt",
), patch(
"app.services.llm.migration_adapter._run_async_safely",
return_value=raw_payload,
):
result = LegacyLLMAdapter.generate_narration(
markdown_content="markdown",
api_key="test-key",
base_url="https://example.com/v1",
model="test-model",
)
self.assertEqual(raw_payload, result)
self.assertNotIn('"items"', result)
if __name__ == "__main__":
unittest.main()