ForcePilot/backend/test/unit/services/test_model_provider_service.py
2026-05-27 19:56:26 +08:00

319 lines
12 KiB
Python
Raw 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 os
import pytest
os.environ.setdefault("OPENAI_API_KEY", "test-key")
from yuxi.config.builtin_providers import BUILTIN_PROVIDERS
from yuxi.services.model_provider_service import (
check_credential_status,
_normalize_payload,
_normalize_remote_model,
fetch_remote_models,
)
def test_normalize_payload_accepts_enabled_chat_model():
payload = _normalize_payload(
{
"provider_id": "openrouter-local",
"display_name": "OpenRouter Local",
"base_url": "https://openrouter.ai/api/v1",
"enabled_models": [{"id": "anthropic/claude-sonnet-4.5", "type": "chat"}],
}
)
assert payload["provider_id"] == "openrouter-local"
assert payload["provider_type"] == "openai"
assert "models_endpoint" not in payload
assert "embedding_models_endpoint" not in payload
assert payload["enabled_models"][0]["display_name"] == "anthropic/claude-sonnet-4.5"
def test_normalize_payload_rejects_unknown_enabled_model_type():
with pytest.raises(ValueError, match="type 必须是"):
_normalize_payload(
{
"provider_id": "openrouter-local",
"display_name": "OpenRouter Local",
"base_url": "https://openrouter.ai/api/v1",
"enabled_models": [{"id": "unknown-model", "type": "unknown"}],
}
)
def test_normalize_payload_allows_embedding_without_dimension():
"""embedding 模型的 dimension 是可选字段,不提供也不会报错。"""
payload = _normalize_payload(
{
"provider_id": "embedding-local",
"display_name": "Embedding Local",
"base_url": "https://example.com/v1",
"capabilities": ["embedding"],
"embedding_base_url": "https://example.com/v1/embeddings",
"enabled_models": [{"id": "text-embedding", "type": "embedding"}],
}
)
assert payload["provider_id"] == "embedding-local"
assert payload["enabled_models"][0].get("dimension") is None
def test_normalize_remote_model_preserves_detailed_model_config():
model = _normalize_remote_model(
{
"id": "xiaomi/mimo-v2-omni",
"name": "Xiaomi: MiMo-V2-Omni",
"context_length": 262144,
"architecture": {
"input_modalities": ["text", "audio", "image", "video"],
"output_modalities": ["text"],
},
"top_provider": {"max_completion_tokens": 65536},
"supported_parameters": ["temperature", "tools"],
}
)
assert model["id"] == "xiaomi/mimo-v2-omni"
assert model["display_name"] == "Xiaomi: MiMo-V2-Omni"
assert model["type"] == "chat"
assert model["input_modalities"] == ["text", "audio", "image", "video"]
assert model["max_completion_tokens"] == 65536
assert model["raw_metadata"]["supported_parameters"] == ["temperature", "tools"]
def test_normalize_remote_model_uses_endpoint_model_type():
model = _normalize_remote_model({"id": "BAAI/bge-m3", "object": "model"}, "embedding")
assert model["id"] == "BAAI/bge-m3"
assert model["type"] == "embedding"
@pytest.mark.asyncio
async def test_fetch_remote_models_loads_embedding_only_when_capability_enabled(monkeypatch):
calls = []
async def fake_fetch(client, provider, headers, endpoint, model_type):
calls.append((endpoint, model_type))
return [{"id": f"{model_type}-model", "type": model_type}]
monkeypatch.setattr("yuxi.services.model_provider_service._fetch_models_from_endpoint", fake_fetch)
class Provider:
base_url = "https://example.com/v1"
api_key = None
api_key_env = None
headers_json = {}
capabilities = ["chat", "embedding", "rerank"]
models_endpoint = "/models"
embedding_models_endpoint = "/embeddings/models"
rerank_models_endpoint = None
models = await fetch_remote_models(Provider())
assert calls == [("/models", "chat"), ("/embeddings/models", "embedding")]
assert [model["type"] for model in models] == ["chat", "embedding"]
def test_normalize_payload_rejects_ollama_provider_type():
with pytest.raises(ValueError, match="provider_type 必须是"):
_normalize_payload(
{
"provider_id": "ollama-local",
"display_name": "Ollama Local",
"provider_type": "ollama",
"base_url": "http://localhost:11434",
}
)
def test_builtin_provider_templates_default_to_openai_provider_type():
assert len(BUILTIN_PROVIDERS) >= 16
provider_types = {
_normalize_payload(
{
"provider_id": provider["provider_id"],
"display_name": provider["display_name"],
"base_url": provider["base_url"],
"provider_type": provider.get("provider_type"),
}
)["provider_type"]
for provider in BUILTIN_PROVIDERS
}
assert provider_types == {"openai"}
assert all("ollama" not in provider["provider_id"] for provider in BUILTIN_PROVIDERS)
def test_builtin_siliconflow_provider_includes_default_runnable_models():
provider = next(item for item in BUILTIN_PROVIDERS if item["provider_id"] == "siliconflow-cn")
models = {model["id"]: model for model in provider["enabled_models"]}
assert provider["capabilities"] == ["chat", "embedding", "rerank"]
assert provider["embedding_base_url"] == "https://api.siliconflow.cn/v1/embeddings"
assert provider["rerank_base_url"] == "https://api.siliconflow.cn/v1/rerank"
assert models["Pro/BAAI/bge-m3"]["type"] == "embedding"
assert models["Pro/BAAI/bge-m3"]["dimension"] == 1024
assert "base_url_override" not in models["Pro/BAAI/bge-m3"]
assert models["Pro/BAAI/bge-reranker-v2-m3"]["type"] == "rerank"
assert "base_url_override" not in models["Pro/BAAI/bge-reranker-v2-m3"]
def test_builtin_dashscope_provider_includes_default_embedding_and_rerank_models():
provider = next(item for item in BUILTIN_PROVIDERS if item["provider_id"] == "alibaba")
models = {model["id"]: model for model in provider["enabled_models"]}
assert provider["capabilities"] == ["chat", "embedding", "rerank"]
assert provider["embedding_base_url"] == "https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings"
assert provider["rerank_base_url"] == "https://dashscope.aliyuncs.com/compatible-api/v1/reranks"
assert "embedding_models_endpoint" not in provider
assert "rerank_models_endpoint" not in provider
assert models["text-embedding-v4"]["type"] == "embedding"
assert models["text-embedding-v4"]["dimension"] == 2048
assert models["qwen3-rerank"]["type"] == "rerank"
def testcheck_credential_status_disabled_provider_always_ok():
"""未启用的 provider 无论凭证如何配置,状态始终为 ok。"""
class Provider:
is_enabled = False
api_key = None
api_key_env = None
assert check_credential_status(Provider()) == "ok"
def testcheck_credential_status_direct_api_key_ok():
"""直接配置了 api_key 的启用 provider 状态为 ok。"""
class Provider:
is_enabled = True
api_key = "sk-test"
api_key_env = None
assert check_credential_status(Provider()) == "ok"
def testcheck_credential_status_env_key_exists_ok(monkeypatch):
"""api_key_env 对应的环境变量存在时状态为 ok。"""
monkeypatch.setenv("TEST_API_KEY", "exists")
class Provider:
is_enabled = True
api_key = None
api_key_env = "TEST_API_KEY"
assert check_credential_status(Provider()) == "ok"
def testcheck_credential_status_env_key_missing_warning(monkeypatch):
"""api_key_env 对应的环境变量不存在时状态为 warning。"""
monkeypatch.delenv("MISSING_KEY", raising=False)
class Provider:
is_enabled = True
api_key = None
api_key_env = "MISSING_KEY"
assert check_credential_status(Provider()) == "warning"
def testcheck_credential_status_both_empty_warning():
"""api_key 和 api_key_env 都未配置时状态为 warning。"""
class Provider:
is_enabled = True
api_key = None
api_key_env = None
assert check_credential_status(Provider()) == "warning"
# ==================== 手动添加模型 / source 字段 ====================
def test_normalize_payload_default_model_source_is_remote():
"""未显式指定 source 时,规范化后默认填入 remote向后兼容旧数据。"""
payload = _normalize_payload(
{
"provider_id": "openrouter-local",
"display_name": "OpenRouter Local",
"base_url": "https://openrouter.ai/api/v1",
"enabled_models": [{"id": "anthropic/claude-sonnet-4.5", "type": "chat"}],
}
)
assert payload["enabled_models"][0]["source"] == "remote"
def test_normalize_payload_accepts_manual_source():
"""source=manual 表示管理员手动添加的模型,规范化保留该标签。"""
payload = _normalize_payload(
{
"provider_id": "custom-local",
"display_name": "Custom Local",
"base_url": "https://example.com/v1",
"capabilities": ["chat"],
"enabled_models": [
{"id": "my-chat-model", "type": "chat", "source": "manual"}
],
}
)
assert payload["enabled_models"][0]["source"] == "manual"
def test_normalize_payload_rejects_invalid_source():
"""source 仅允许 manual 或 remote其他取值视为非法。"""
with pytest.raises(ValueError, match="source 必须是"):
_normalize_payload(
{
"provider_id": "custom-local",
"display_name": "Custom Local",
"base_url": "https://example.com/v1",
"enabled_models": [
{"id": "x", "type": "chat", "source": "custom"}
],
}
)
def test_normalize_payload_rejects_model_type_not_in_capabilities():
"""provider 仅声明 chat 能力时,不允许写入 embedding 类型的模型。"""
with pytest.raises(ValueError, match="不在 provider 能力"):
_normalize_payload(
{
"provider_id": "chat-only",
"display_name": "Chat Only",
"base_url": "https://example.com/v1",
"capabilities": ["chat"],
"enabled_models": [
{"id": "rogue-embedding", "type": "embedding", "dimension": 1024}
],
}
)
def test_normalize_payload_allows_model_type_within_capabilities():
"""provider 同时声明 chat + embedding 时,两类模型均可正常写入。"""
payload = _normalize_payload(
{
"provider_id": "multi-cap",
"display_name": "Multi Cap",
"base_url": "https://example.com/v1",
"capabilities": ["chat", "embedding"],
"embedding_base_url": "https://example.com/v1/embeddings",
"embedding_models_endpoint": "/embeddings/models",
"enabled_models": [
{"id": "chat-1", "type": "chat", "source": "manual"},
{
"id": "embed-1",
"type": "embedding",
"source": "manual",
"dimension": 1024,
},
],
}
)
types = [model["type"] for model in payload["enabled_models"]]
sources = [model["source"] for model in payload["enabled_models"]]
assert types == ["chat", "embedding"]
assert sources == ["manual", "manual"]