Files
autoclip/backend/tests/test_model_catalog.py
T

270 lines
11 KiB
Python

"""云端模型目录:内置名单、过滤、合并,以及有密钥时实时拉取。"""
import asyncio
import pytest
from backend.core import model_catalog
@pytest.fixture(autouse=True)
def _clear_cache():
model_catalog.clear_model_list_cache()
yield
model_catalog.clear_model_list_cache()
def test_curated_catalog_includes_current_flagships():
dashscope = model_catalog.curated_models("dashscope")
openai = model_catalog.curated_models("openai")
gemini = model_catalog.curated_models("gemini")
deepseek = model_catalog.curated_models("deepseek")
seed = model_catalog.curated_models("seed")
kimi = model_catalog.curated_models("kimi")
assert dashscope[0] == "qwen3.8-max"
assert "qwen-plus" in dashscope
assert "qwen3.8-flash" in dashscope
assert "qwen-turbo" not in dashscope
assert "qwen-long" not in dashscope
assert "gpt-5" in openai
assert "gpt-5.6-terra" in openai
assert "gpt-4o" not in openai
assert "gpt-4o-mini" not in openai
assert "gemini-3.8-flash" in gemini
assert "gemini-2.5-flash" in gemini
assert "gemini-1.5-pro" not in gemini
assert "deepseek-flash" in deepseek
assert "deepseek-v4-pro" in deepseek
assert "deepseek-chat" not in deepseek
assert "doubao-seed-2-1-lite-260915" in seed
assert "doubao-seed-2-1-pro-260915" in seed
assert "kimi-k3" in kimi
assert "siliconflow" not in model_catalog.CURATED_MODELS
assert model_catalog.default_model_for("dashscope") == "qwen-plus"
assert model_catalog.default_model_for("gemini") == "gemini-3.8-flash"
assert model_catalog.default_model_for("deepseek") == "deepseek-flash"
assert model_catalog.default_model_for("seed") == "doubao-seed-2-1-lite-260915"
def test_is_chat_model_drops_embeddings_and_media():
assert model_catalog.is_chat_model("qwen-plus", "dashscope")
assert not model_catalog.is_chat_model("text-embedding-v4", "dashscope")
assert not model_catalog.is_chat_model("qwen-audio-3.0-tts-plus", "dashscope")
assert model_catalog.is_chat_model("gpt-5-mini", "openai", official=True)
assert not model_catalog.is_chat_model("whisper-1", "openai", official=True)
assert not model_catalog.is_chat_model("dall-e-3", "openai", official=True)
# 自建兼容接口:自定义名字要留下来
assert model_catalog.is_chat_model("glm-4-flash", "openai", official=False)
assert not model_catalog.is_chat_model("bge-m3-embedding", "openai", official=False)
assert model_catalog.is_chat_model("gemini-3.8-flash", "gemini")
assert model_catalog.is_chat_model("gemini-2.5-flash", "gemini")
assert not model_catalog.is_chat_model("imagen-4.0-generate", "gemini")
assert not model_catalog.is_chat_model("gemini-3.8-live", "gemini")
assert not model_catalog.is_chat_model("gemini-3.1-flash-image", "gemini")
assert model_catalog.is_chat_model("doubao-seed-2-1-lite-260915", "seed")
assert not model_catalog.is_chat_model("doubao-seedream-5-0-260128", "seed")
assert not model_catalog.is_chat_model("doubao-seedance-2-0", "seed")
def test_merge_keeps_curated_first_and_drops_dated_snapshots():
merged = model_catalog.merge_models(
["qwen-plus", "qwen3.8-max"],
["qwen3.8-max", "qwen3.8-max-2026-09-01", "qwen3.9-preview", "text-embedding-v4"],
)
assert merged[:2] == ["qwen-plus", "qwen3.8-max"]
assert "qwen3.9-preview" in merged
assert "qwen3.8-max-2026-09-01" not in merged
def test_should_fetch_live_only_with_key_or_custom_openai_url():
assert not model_catalog.should_fetch_live("dashscope", "", "")
assert model_catalog.should_fetch_live("dashscope", "sk-test-key-123", "")
assert not model_catalog.should_fetch_live("openai", "", "")
assert not model_catalog.should_fetch_live("openai", "", "https://api.openai.com/v1")
assert model_catalog.should_fetch_live("openai", "", "https://api.deepseek.com/v1")
assert not model_catalog.should_fetch_live("ollama", "sk-x", "")
class _FakeResponse:
def __init__(self, payload, status=200):
self._payload = payload
self.status_code = status
def raise_for_status(self):
if self.status_code >= 400:
raise RuntimeError(f"HTTP {self.status_code}")
def json(self):
return self._payload
class _FakeAsyncClient:
calls = []
routes = {}
def __init__(self, *args, **kwargs):
self.kwargs = kwargs
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
async def get(self, url, headers=None, params=None):
_FakeAsyncClient.calls.append({"url": url, "headers": headers or {}, "params": params or {}, "trust_env": self.kwargs.get("trust_env")})
if url not in _FakeAsyncClient.routes:
raise RuntimeError(f"unexpected url {url}")
payload, status = _FakeAsyncClient.routes[url]
return _FakeResponse(payload, status)
def test_list_available_models_without_key_stays_on_catalog():
result = asyncio.run(model_catalog.list_available_models("dashscope"))
assert result.source == "catalog"
assert result.reachable is False
assert "qwen3.8-max" in result.models
assert result.catalog["openai"][0].startswith("gpt-")
def test_list_available_models_merges_live_dashscope(monkeypatch):
_FakeAsyncClient.calls = []
_FakeAsyncClient.routes = {
"https://dashscope.aliyuncs.com/compatible-mode/v1/models": (
{"data": [
{"id": "qwen3.8-max"},
{"id": "qwen3.9-preview"},
{"id": "text-embedding-v4"},
{"id": "qwen-plus-2026-01-01"},
]},
200,
)
}
import httpx
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
result = asyncio.run(model_catalog.list_available_models("dashscope", api_key="sk-test-key-123456"))
assert result.source == "live"
assert result.reachable is True
assert result.models[0] == "qwen3.8-max"
assert "qwen-plus" in result.models
assert "qwen3.9-preview" in result.models
assert "text-embedding-v4" not in result.models
assert "qwen-plus-2026-01-01" not in result.models
assert _FakeAsyncClient.calls[0]["headers"]["Authorization"].startswith("Bearer sk-")
def test_list_available_models_gemini_pagination(monkeypatch):
import httpx
class _PagedClient(_FakeAsyncClient):
async def get(self, url, headers=None, params=None):
_FakeAsyncClient.calls.append({"url": url, "params": params or {}})
if params and params.get("pageToken") == "p2":
return _FakeResponse({
"models": [
{"name": "models/gemini-2.5-flash", "supportedGenerationMethods": ["generateContent"]},
{"name": "models/imagen-4.0", "supportedGenerationMethods": ["generateContent"]},
]
})
return _FakeResponse({
"models": [
{"name": "models/gemini-3-pro", "supportedGenerationMethods": ["generateContent"]},
{"name": "models/embedding-001", "supportedGenerationMethods": ["embedContent"]},
],
"nextPageToken": "p2",
})
_FakeAsyncClient.calls = []
monkeypatch.setattr(httpx, "AsyncClient", _PagedClient)
result = asyncio.run(model_catalog.list_available_models("gemini", api_key="AIza-test-key-123456"))
assert result.source == "live"
assert "gemini-3-pro" in result.models
assert "gemini-2.5-flash" in result.models
assert "embedding-001" not in result.models
assert "imagen-4.0" not in result.models
assert len(_FakeAsyncClient.calls) == 2
def test_list_available_models_live_failure_falls_back(monkeypatch):
import httpx
class _Boom(_FakeAsyncClient):
async def get(self, url, headers=None, params=None):
raise RuntimeError("timeout")
monkeypatch.setattr(httpx, "AsyncClient", _Boom)
result = asyncio.run(model_catalog.list_available_models("openai", api_key="sk-test-key-123456"))
assert result.source == "catalog"
assert result.reachable is False
assert result.error
assert "gpt-5" in result.models
def test_result_payload_shape_for_settings_page():
result = model_catalog.ModelListResult(
provider="dashscope",
source="live",
reachable=True,
default_model="qwen-plus",
models=["qwen-plus", "qwen3.8-max"],
catalog={"dashscope": ["qwen-plus"]},
)
payload = result.as_dict()
assert payload["source"] == "live"
assert payload["models"] == ["qwen-plus", "qwen3.8-max"]
assert payload["catalog"]["dashscope"] == ["qwen-plus"]
assert "error" not in payload
def test_provider_catalog_infos_follow_curated_list():
from backend.core.llm_providers import ProviderType, _catalog_model_infos
names = [m.name for m in _catalog_model_infos(ProviderType.DASHSCOPE)]
assert names == model_catalog.curated_models("dashscope")
openai_names = [m.name for m in _catalog_model_infos(ProviderType.OPENAI)]
assert "gpt-5" in openai_names
def test_infistar_has_no_builtin_list_and_fetches_gateway_models(monkeypatch):
# 没 key:不拿通义的型号冒充
empty = asyncio.run(model_catalog.list_available_models("infistar"))
assert empty.models == [] and empty.default_model == "" and empty.source == "catalog"
_FakeAsyncClient.calls = []
_FakeAsyncClient.routes = {
"https://infistar.cc/v1/models": (
{"data": [{"id": "claude-sonnet-5-5"}, {"id": "deepseek-v4-pro"}, {"id": "gemini-3.8-flash"},
{"id": "text-embedding-3-large"}, {"id": "sora-2"}]},
200,
)
}
import httpx
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
live = asyncio.run(model_catalog.list_available_models("infistar", api_key="sk-infistar-key-123", refresh=True))
assert live.source == "live"
# 网关里各家型号都保留,只去掉嵌入 / 视频
assert live.models == ["claude-sonnet-5-5", "deepseek-v4-pro", "gemini-3.8-flash"]
def test_api88_has_no_builtin_list_and_fetches_gateway_models(monkeypatch):
# 没 key:不拿通义的型号冒充
empty = asyncio.run(model_catalog.list_available_models("api88"))
assert empty.models == [] and empty.default_model == "" and empty.source == "catalog"
_FakeAsyncClient.calls = []
_FakeAsyncClient.routes = {
"https://88api.ai/v1/models": (
{"data": [{"id": "claude-sonnet-5-5"}, {"id": "deepseek-v4-pro"}, {"id": "gemini-3.8-flash"},
{"id": "text-embedding-3-large"}, {"id": "sora-2"}]},
200,
)
}
import httpx
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
live = asyncio.run(model_catalog.list_available_models("api88", api_key="sk-api88-key-123", refresh=True))
assert live.source == "live"
# 网关里各家型号都保留,只去掉嵌入 / 视频
assert live.models == ["claude-sonnet-5-5", "deepseek-v4-pro", "gemini-3.8-flash"]