diff --git a/app/agent/llm/catalog.py b/app/agent/llm/catalog.py index f049b7b5a..50da380d7 100644 --- a/app/agent/llm/catalog.py +++ b/app/agent/llm/catalog.py @@ -423,6 +423,14 @@ class _ProviderCatalog: api_key_hint="填写 Runcrate API Key,可在 https://www.runcrate.ai/dashboard/api-keys?utm_source=MoviePilot&utm_medium=directory&utm_campaign=gpu_pricing 获取。", description="Runcrate GPU 云的 OpenAI-compatible 推理端点,提供 DeepSeek、GLM、Qwen、Kimi 与 gpt-oss 等模型,用 GitHub 账号即可免费使用开源模型。", ), + openai_provider( + provider_id="obscurify-ai", + name="Obscurify.ai", + default_base_url="https://obscurify.ai/v1", + sort_order=59, + api_key_hint="填写 Obscurify.ai API Key(sk-obs1_ 开头),在 https://obscurify.ai/?utm_source=moviepilot 创建账户后,于 Account 页面点击 Show API Key 获取。", + description="Obscurify.ai 注重隐私的 OpenAI-compatible 聚合模型平台:账户为随机 16 位账号,无需邮箱,服务端不保存提示词与回复。", + ), ProviderSpec( id="github-copilot", name="GitHub Copilot", diff --git a/tests/test_llm_provider_obscurify_ai.py b/tests/test_llm_provider_obscurify_ai.py new file mode 100644 index 000000000..6bdd260dd --- /dev/null +++ b/tests/test_llm_provider_obscurify_ai.py @@ -0,0 +1,60 @@ +"""Obscurify.ai 预设通过共享 OpenAI 兼容通道发现模型的边界回归。""" + +from unittest.mock import AsyncMock + +import httpx +import pytest + +from app.agent.llm.provider import LLMProviderManager +from app.foundation.singleton import Singleton + + +@pytest.fixture +def manager(monkeypatch): + """隔离管理器单例及目录缓存,防止测试影响其他 provider。""" + key = (LLMProviderManager, (), frozenset()) + monkeypatch.setattr(Singleton, "_instances", {k: v for k, v in Singleton._instances.items() if k != key}) + instance = LLMProviderManager() + monkeypatch.setattr(instance, "get_models_dev_data", AsyncMock(return_value={})) + return instance + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "base_url, expected_url", + [ + (None, "https://obscurify.ai/v1/models"), + ("https://relay.example/v1", "https://relay.example/v1/models"), + ], +) +async def test_obscurify_ai_discovers_authenticated_models(manager, monkeypatch, base_url, expected_url): + """预设及覆盖地址都传递密钥,保留带厂商前缀的完整模型 ID,不推断缺失的上下文元数据。""" + requests = [] + + def respond(request): + requests.append(request) + return httpx.Response( + 200, + json={ + "object": "list", + "data": [ + {"id": "deepseek/deepseek-v4-flash", "object": "model", "created": 0}, + {"id": "z-ai/glm-5.3", "object": "model", "created": 0}, + ], + }, + ) + + monkeypatch.setattr( + manager, + "_build_sdk_http_client", + lambda use_proxy: httpx.AsyncClient(transport=httpx.MockTransport(respond)), + ) + + models = await manager.list_models("obscurify-ai", api_key="test-obscurify-key", base_url=base_url) + + assert len(requests) == 1 + assert requests[0].method == "GET" + assert str(requests[0].url) == expected_url + assert requests[0].headers["Authorization"] == "Bearer test-obscurify-key" + assert [model["id"] for model in models] == ["deepseek/deepseek-v4-flash", "z-ai/glm-5.3"] + assert all(model["source"] == "provider" and model["context_tokens"] is None for model in models)