refactor: 增强模型提供程序管理和 UI 组件
- 在 PostgresManager 中添加了用于嵌入和重新排序基本 URL 的新列。 - 更新了模型提供程序数据结构,以包含新的端点。 - 重构了模型提供程序服务和路由器中的凭据状态检查。 - 引入了一个可重用的组件,用于跨组件进行模型状态检查。 - 通过调整页面内边距的 CSS 变量,提高了 UI 的响应速度。 - 重构了模型选择器组件,以利用新的模型状态组件。 - 更新了模型配置视图,以处理响应式编辑模型状态。 - 清理了未使用的代码,并改进了模型状态检查中的错误处理。
This commit is contained in:
parent
2920cbeec3
commit
2e0b4a8358
205
backend/package/yuxi/config/builtin_providers.py
Normal file
205
backend/package/yuxi/config/builtin_providers.py
Normal file
@ -0,0 +1,205 @@
|
||||
"""内置模型供应商定义。"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
BUILTIN_PROVIDERS: list[dict[str, Any]] = [
|
||||
{
|
||||
"provider_id": "openai",
|
||||
"display_name": "OpenAI",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"api_key_env": "OPENAI_API_KEY",
|
||||
"models_endpoint": "https://api.openai.com/v1/models",
|
||||
},
|
||||
# {
|
||||
# "provider_id": "anthropic",
|
||||
# "display_name": "Anthropic",
|
||||
# "base_url": "https://api.anthropic.com",
|
||||
# "api_key_env": "ANTHROPIC_API_KEY",
|
||||
# "models_endpoint": "https://api.anthropic.com/models",
|
||||
# },
|
||||
# {
|
||||
# "provider_id": "google",
|
||||
# "display_name": "Google Gemini",
|
||||
# "base_url": "https://generativelanguage.googleapis.com",
|
||||
# "api_key_env": "GEMINI_API_KEY",
|
||||
# "models_endpoint": "https://generativelanguage.googleapis.com/v1beta/models",
|
||||
# },
|
||||
# {
|
||||
# "provider_id": "ollama-cloud",
|
||||
# "display_name": "Ollama",
|
||||
# "base_url": "http://localhost:11434",
|
||||
# "models_endpoint": "http://localhost:11434/api/tags",
|
||||
# },
|
||||
# {
|
||||
# "provider_id": "lmstudio",
|
||||
# "display_name": "LM Studio",
|
||||
# "base_url": "http://localhost:1234/v1",
|
||||
# "models_endpoint": "http://localhost:1234/v1/models",
|
||||
# },
|
||||
{
|
||||
"provider_id": "deepseek",
|
||||
"display_name": "DeepSeek",
|
||||
"base_url": "https://api.deepseek.com",
|
||||
"api_key_env": "DEEPSEEK_API_KEY",
|
||||
"models_endpoint": "https://api.deepseek.com/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "alibaba",
|
||||
"display_name": "DashScope",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"api_key_env": "DASHSCOPE_API_KEY",
|
||||
"models_endpoint": "https://dashscope.aliyuncs.com/compatible-mode/v1/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "alibaba-coding-plan-cn",
|
||||
"display_name": "Aliyun Coding Plan",
|
||||
"base_url": "https://coding.dashscope.aliyuncs.com/v1",
|
||||
"api_key_env": "DASHSCOPE_API_KEY",
|
||||
"models_endpoint": "https://coding.dashscope.aliyuncs.com/v1/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "alibaba-coding-plan",
|
||||
"display_name": "Aliyun Coding Plan (International)",
|
||||
"base_url": "https://coding-intl.dashscope.aliyuncs.com/v1",
|
||||
"api_key_env": "DASHSCOPE_API_KEY",
|
||||
"models_endpoint": "https://coding-intl.dashscope.aliyuncs.com/v1/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "zhipuai",
|
||||
"display_name": "Zhipu (BigModel)",
|
||||
"base_url": "https://open.bigmodel.cn/api/paas/v4",
|
||||
"api_key_env": "ZHIPUAI_API_KEY",
|
||||
"models_endpoint": "https://open.bigmodel.cn/api/paas/v4/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "zhipuai-coding-plan",
|
||||
"display_name": "Zhipu Coding Plan (BigModel)",
|
||||
"base_url": "https://open.bigmodel.cn/api/coding/paas/v4",
|
||||
"api_key_env": "ZHIPUAI_API_KEY",
|
||||
"models_endpoint": "https://open.bigmodel.cn/api/coding/paas/v4/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "zai",
|
||||
"display_name": "Zhipu (Z.AI)",
|
||||
"base_url": "https://api.z.ai/api/paas/v4",
|
||||
"api_key_env": "ZAI_API_KEY",
|
||||
"models_endpoint": "https://api.z.ai/api/paas/v4/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "zai-coding-plan",
|
||||
"display_name": "Zhipu Coding Plan (Z.AI)",
|
||||
"base_url": "https://api.z.ai/api/coding/paas/v4",
|
||||
"api_key_env": "ZAI_API_KEY",
|
||||
"models_endpoint": "https://api.z.ai/api/coding/paas/v4/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "moonshotai-cn",
|
||||
"display_name": "Moonshot",
|
||||
"base_url": "https://api.moonshot.cn/v1",
|
||||
"api_key_env": "MOONSHOT_API_KEY",
|
||||
"models_endpoint": "https://api.moonshot.cn/v1/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "moonshotai",
|
||||
"display_name": "Moonshot (International)",
|
||||
"base_url": "https://api.moonshot.ai/v1",
|
||||
"api_key_env": "MOONSHOT_API_KEY",
|
||||
"models_endpoint": "https://api.moonshot.ai/v1/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "minimax-cn",
|
||||
"display_name": "MiniMax",
|
||||
"base_url": "https://api.minimaxi.com/v1",
|
||||
"api_key_env": "MINIMAX_API_KEY",
|
||||
"models_endpoint": "https://api.minimaxi.com/v1/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "minimax",
|
||||
"display_name": "MiniMax (International)",
|
||||
"base_url": "https://api.minimax.io/v1",
|
||||
"api_key_env": "MINIMAX_API_KEY",
|
||||
"models_endpoint": "https://api.minimax.io/v1/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "openrouter",
|
||||
"display_name": "OpenRouter",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"api_key_env": "OPENROUTER_API_KEY",
|
||||
"capabilities": ["chat", "embedding"],
|
||||
"embedding_base_url": "https://openrouter.ai/api/v1/embeddings",
|
||||
"models_endpoint": "https://openrouter.ai/api/v1/models",
|
||||
"embedding_models_endpoint": "https://openrouter.ai/api/v1/embeddings/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "modelscope",
|
||||
"display_name": "ModelScope",
|
||||
"base_url": "https://api-inference.modelscope.cn/v1",
|
||||
"api_key_env": "MODELSCOPE_ACCESS_TOKEN",
|
||||
"models_endpoint": "https://api-inference.modelscope.cn/v1/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "opencode",
|
||||
"display_name": "OpenCode",
|
||||
"base_url": "https://opencode.ai/zen/v1",
|
||||
"models_endpoint": "https://opencode.ai/zen/v1/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "siliconflow-cn",
|
||||
"display_name": "SiliconFlow",
|
||||
"base_url": "https://api.siliconflow.cn/v1",
|
||||
"embedding_base_url": "https://api.siliconflow.cn/v1/embeddings",
|
||||
"rerank_base_url": "https://api.siliconflow.cn/v1/rerank",
|
||||
"api_key_env": "SILICONFLOW_API_KEY",
|
||||
"capabilities": ["chat", "embedding", "rerank"],
|
||||
"models_endpoint": "https://api.siliconflow.cn/v1/models?sub_type=chat",
|
||||
"embedding_models_endpoint": "https://api.siliconflow.cn/v1/models?sub_type=embedding",
|
||||
"rerank_models_endpoint": "https://api.siliconflow.cn/v1/models?sub_type=reranker",
|
||||
"enabled_models": [
|
||||
{"id": "Pro/deepseek-ai/DeepSeek-V3.2", "type": "chat", "display_name": "Pro/deepseek-ai/DeepSeek-V3.2"},
|
||||
{"id": "Pro/MiniMaxAI/MiniMax-M2.5", "type": "chat", "display_name": "Pro/MiniMaxAI/MiniMax-M2.5"},
|
||||
{
|
||||
"id": "Pro/BAAI/bge-m3",
|
||||
"type": "embedding",
|
||||
"display_name": "Pro/BAAI/bge-m3",
|
||||
"dimension": 1024,
|
||||
"batch_size": 40,
|
||||
},
|
||||
{
|
||||
"id": "BAAI/bge-m3",
|
||||
"type": "embedding",
|
||||
"display_name": "BAAI/bge-m3",
|
||||
"dimension": 1024,
|
||||
"batch_size": 40,
|
||||
},
|
||||
{
|
||||
"id": "Qwen/Qwen3-Embedding-0.6B",
|
||||
"type": "embedding",
|
||||
"display_name": "Qwen/Qwen3-Embedding-0.6B",
|
||||
"dimension": 1024,
|
||||
"batch_size": 40,
|
||||
},
|
||||
{
|
||||
"id": "Pro/BAAI/bge-reranker-v2-m3",
|
||||
"type": "rerank",
|
||||
"display_name": "Pro/BAAI/bge-reranker-v2-m3",
|
||||
},
|
||||
{
|
||||
"id": "BAAI/bge-reranker-v2-m3",
|
||||
"type": "rerank",
|
||||
"display_name": "BAAI/bge-reranker-v2-m3",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"provider_id": "siliconflow",
|
||||
"display_name": "SiliconFlow (International)",
|
||||
"base_url": "https://api.siliconflow.com/v1",
|
||||
"embedding_base_url": "https://api.siliconflow.com/v1/embeddings",
|
||||
"rerank_base_url": "https://api.siliconflow.com/v1/rerank",
|
||||
"api_key_env": "SILICONFLOW_GLOBAL_API_KEY",
|
||||
"capabilities": ["chat", "embedding", "rerank"],
|
||||
"models_endpoint": "https://api.siliconflow.com/v1/models?sub_type=chat",
|
||||
"embedding_models_endpoint": "https://api.siliconflow.com/v1/models?sub_type=embedding",
|
||||
"rerank_models_endpoint": "https://api.siliconflow.com/v1/models?sub_type=reranker",
|
||||
},
|
||||
]
|
||||
@ -85,6 +85,9 @@ class ModelCache:
|
||||
"""基于 Redis 的模型缓存,所有写入均走 Redis,保证跨进程一致。
|
||||
|
||||
查询接口使用本地内存缓存(带 TTL),避免热路径反复反序列化 JSON。
|
||||
|
||||
注意:本类使用同步 Redis 客户端(redis-py),因为 LangGraph 在初始化模型时
|
||||
通过同步路径调用 get_model_info()。后续可统一升级为异步客户端(redis.asyncio)。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
@ -170,7 +173,7 @@ class ModelCache:
|
||||
所有修改操作(启动初始化、CRUD)都通过此方法写入,
|
||||
保证 Redis 中的数据始终是最新的。
|
||||
"""
|
||||
from yuxi.services.model_provider_service import _resolve_api_key
|
||||
from yuxi.services.model_provider_service import resolve_api_key
|
||||
|
||||
new_cache: dict[str, ModelInfo] = {}
|
||||
|
||||
@ -178,7 +181,7 @@ class ModelCache:
|
||||
if not provider.is_enabled:
|
||||
continue
|
||||
|
||||
api_key = _resolve_api_key(provider)
|
||||
api_key = resolve_api_key(provider)
|
||||
|
||||
for model in provider.enabled_models or []:
|
||||
model_type = model.get("type", "chat")
|
||||
@ -228,3 +231,51 @@ class ModelCache:
|
||||
|
||||
# 全局单例
|
||||
model_cache = ModelCache()
|
||||
|
||||
|
||||
def resolve_model_spec(spec: str) -> ModelInfo:
|
||||
"""统一入口:根据 spec 自动识别 V1/V2 格式并返回 ModelInfo。
|
||||
|
||||
V2 格式(优先): provider_id:model_id(冒号分隔),从 model_cache 查找
|
||||
V1 格式: provider/model_name(斜杠分隔),从 config.model_names 查找
|
||||
|
||||
Raises:
|
||||
ValueError: spec 格式无效或模型未找到
|
||||
"""
|
||||
if not spec:
|
||||
raise ValueError("spec 不能为空")
|
||||
|
||||
# V2: 优先查找缓存
|
||||
if ":" in spec:
|
||||
info = model_cache.get_model_info(spec)
|
||||
if info:
|
||||
return info
|
||||
raise ValueError(f"未找到 V2 模型: {spec}")
|
||||
|
||||
# V1: 从 config 查找(兼容旧版)
|
||||
if "/" in spec:
|
||||
from yuxi import config
|
||||
|
||||
provider, model_name = spec.split("/", 1)
|
||||
model_info = config.model_names.get(provider)
|
||||
if not model_info:
|
||||
raise ValueError(f"未找到 V1 模型提供者: {provider}")
|
||||
|
||||
import os
|
||||
|
||||
from yuxi.utils import get_docker_safe_url
|
||||
|
||||
api_key = os.getenv(model_info.env) or model_info.env
|
||||
return ModelInfo(
|
||||
provider_id=provider,
|
||||
model_id=model_name,
|
||||
model_type="chat",
|
||||
display_name=f"{provider}/{model_name}",
|
||||
api_key=api_key,
|
||||
base_url=get_docker_safe_url(model_info.base_url),
|
||||
provider_type="openai",
|
||||
)
|
||||
|
||||
raise ValueError(
|
||||
f"无效的模型 spec: '{spec}'。V1 格式要求 'provider/model_name',V2 格式要求 'provider_id:model_id'"
|
||||
)
|
||||
|
||||
@ -14,251 +14,13 @@ from yuxi.repositories.model_provider_repository import (
|
||||
list_model_providers,
|
||||
update_model_provider,
|
||||
)
|
||||
from yuxi.config.builtin_providers import BUILTIN_PROVIDERS
|
||||
from yuxi.storage.postgres.models_business import ModelProvider
|
||||
|
||||
VALID_MODEL_TYPES = {"chat", "embedding", "rerank"}
|
||||
VALID_MODEL_SOURCES = {"manual", "remote"}
|
||||
VALID_PROVIDER_TYPES = {"openai", "anthropic", "gemini", "ollama", "openrouter", "lmstudio"}
|
||||
_PROVIDER_ID_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{1,99}$")
|
||||
DEFAULT_MODELS_ENDPOINT = ""
|
||||
DEFAULT_EMBEDDING_MODELS_ENDPOINT = ""
|
||||
|
||||
_DEFAULT_BUILTIN_PROVIDERS: list[dict[str, Any]] = [
|
||||
{
|
||||
"provider_id": "openai",
|
||||
"display_name": "OpenAI",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"api_key_env": "OPENAI_API_KEY",
|
||||
"models_endpoint": "https://api.openai.com/v1/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "anthropic",
|
||||
"display_name": "Anthropic",
|
||||
"base_url": "https://api.anthropic.com",
|
||||
"api_key_env": "ANTHROPIC_API_KEY",
|
||||
"models_endpoint": "https://api.anthropic.com/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "google",
|
||||
"display_name": "Google Gemini",
|
||||
"base_url": "https://generativelanguage.googleapis.com",
|
||||
"api_key_env": "GEMINI_API_KEY",
|
||||
"models_endpoint": "https://generativelanguage.googleapis.com/v1beta/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "ollama-cloud",
|
||||
"display_name": "Ollama",
|
||||
"base_url": "http://localhost:11434",
|
||||
"models_endpoint": "http://localhost:11434/api/tags",
|
||||
},
|
||||
{
|
||||
"provider_id": "lmstudio",
|
||||
"display_name": "LM Studio",
|
||||
"base_url": "http://localhost:1234/v1",
|
||||
"models_endpoint": "http://localhost:1234/v1/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "deepseek",
|
||||
"display_name": "DeepSeek",
|
||||
"base_url": "https://api.deepseek.com",
|
||||
"api_key_env": "DEEPSEEK_API_KEY",
|
||||
"models_endpoint": "https://api.deepseek.com/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "alibaba",
|
||||
"display_name": "DashScope",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"api_key_env": "DASHSCOPE_API_KEY",
|
||||
"models_endpoint": "https://dashscope.aliyuncs.com/compatible-mode/v1/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "alibaba-coding-plan-cn",
|
||||
"display_name": "Aliyun Coding Plan",
|
||||
"base_url": "https://coding.dashscope.aliyuncs.com/v1",
|
||||
"api_key_env": "DASHSCOPE_API_KEY",
|
||||
"models_endpoint": "https://coding.dashscope.aliyuncs.com/v1/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "alibaba-coding-plan",
|
||||
"display_name": "Aliyun Coding Plan (International)",
|
||||
"base_url": "https://coding-intl.dashscope.aliyuncs.com/v1",
|
||||
"api_key_env": "DASHSCOPE_API_KEY",
|
||||
"models_endpoint": "https://coding-intl.dashscope.aliyuncs.com/v1/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "zhipuai",
|
||||
"display_name": "Zhipu (BigModel)",
|
||||
"base_url": "https://open.bigmodel.cn/api/paas/v4",
|
||||
"api_key_env": "ZHIPUAI_API_KEY",
|
||||
"models_endpoint": "https://open.bigmodel.cn/api/paas/v4/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "zhipuai-coding-plan",
|
||||
"display_name": "Zhipu Coding Plan (BigModel)",
|
||||
"base_url": "https://open.bigmodel.cn/api/coding/paas/v4",
|
||||
"api_key_env": "ZHIPUAI_API_KEY",
|
||||
"models_endpoint": "https://open.bigmodel.cn/api/coding/paas/v4/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "zai",
|
||||
"display_name": "Zhipu (Z.AI)",
|
||||
"base_url": "https://api.z.ai/api/paas/v4",
|
||||
"api_key_env": "ZAI_API_KEY",
|
||||
"models_endpoint": "https://api.z.ai/api/paas/v4/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "zai-coding-plan",
|
||||
"display_name": "Zhipu Coding Plan (Z.AI)",
|
||||
"base_url": "https://api.z.ai/api/coding/paas/v4",
|
||||
"api_key_env": "ZAI_API_KEY",
|
||||
"models_endpoint": "https://api.z.ai/api/coding/paas/v4/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "moonshotai-cn",
|
||||
"display_name": "Moonshot",
|
||||
"base_url": "https://api.moonshot.cn/v1",
|
||||
"api_key_env": "MOONSHOT_API_KEY",
|
||||
"models_endpoint": "https://api.moonshot.cn/v1/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "moonshotai",
|
||||
"display_name": "Moonshot (International)",
|
||||
"base_url": "https://api.moonshot.ai/v1",
|
||||
"api_key_env": "MOONSHOT_API_KEY",
|
||||
"models_endpoint": "https://api.moonshot.ai/v1/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "minimax-cn",
|
||||
"display_name": "MiniMax",
|
||||
"base_url": "https://api.minimaxi.com/v1",
|
||||
"api_key_env": "MINIMAX_API_KEY",
|
||||
"models_endpoint": "https://api.minimaxi.com/v1/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "minimax",
|
||||
"display_name": "MiniMax (International)",
|
||||
"base_url": "https://api.minimax.io/v1",
|
||||
"api_key_env": "MINIMAX_API_KEY",
|
||||
"models_endpoint": "https://api.minimax.io/v1/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "openrouter",
|
||||
"display_name": "OpenRouter",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"api_key_env": "OPENROUTER_API_KEY",
|
||||
"capabilities": ["chat", "embedding"],
|
||||
"embedding_base_url": "https://openrouter.ai/api/v1/embeddings",
|
||||
"models_endpoint": "https://openrouter.ai/api/v1/models",
|
||||
"embedding_models_endpoint": "https://openrouter.ai/api/v1/embeddings/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "modelscope",
|
||||
"display_name": "ModelScope",
|
||||
"base_url": "https://api-inference.modelscope.cn/v1",
|
||||
"api_key_env": "MODELSCOPE_ACCESS_TOKEN",
|
||||
"models_endpoint": "https://api-inference.modelscope.cn/v1/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "opencode",
|
||||
"display_name": "OpenCode",
|
||||
"base_url": "https://opencode.ai/zen/v1",
|
||||
"models_endpoint": "https://opencode.ai/zen/v1/models",
|
||||
},
|
||||
{
|
||||
"provider_id": "siliconflow-cn",
|
||||
"display_name": "SiliconFlow",
|
||||
"base_url": "https://api.siliconflow.cn/v1",
|
||||
"embedding_base_url": "https://api.siliconflow.cn/v1/embeddings",
|
||||
"rerank_base_url": "https://api.siliconflow.cn/v1/rerank",
|
||||
"api_key_env": "SILICONFLOW_API_KEY",
|
||||
"capabilities": ["chat", "embedding", "rerank"],
|
||||
"models_endpoint": "https://api.siliconflow.cn/v1/models?sub_type=chat",
|
||||
"embedding_models_endpoint": "https://api.siliconflow.cn/v1/models?sub_type=embedding",
|
||||
"rerank_models_endpoint": "https://api.siliconflow.cn/v1/models?sub_type=reranker",
|
||||
"enabled_models": [
|
||||
{"id": "Pro/deepseek-ai/DeepSeek-V3.2", "type": "chat", "display_name": "Pro/deepseek-ai/DeepSeek-V3.2"},
|
||||
{"id": "Pro/MiniMaxAI/MiniMax-M2.5", "type": "chat", "display_name": "Pro/MiniMaxAI/MiniMax-M2.5"},
|
||||
{
|
||||
"id": "Pro/BAAI/bge-m3",
|
||||
"type": "embedding",
|
||||
"display_name": "Pro/BAAI/bge-m3",
|
||||
"dimension": 1024,
|
||||
"batch_size": 40,
|
||||
},
|
||||
{
|
||||
"id": "BAAI/bge-m3",
|
||||
"type": "embedding",
|
||||
"display_name": "BAAI/bge-m3",
|
||||
"dimension": 1024,
|
||||
"batch_size": 40,
|
||||
},
|
||||
{
|
||||
"id": "Qwen/Qwen3-Embedding-0.6B",
|
||||
"type": "embedding",
|
||||
"display_name": "Qwen/Qwen3-Embedding-0.6B",
|
||||
"dimension": 1024,
|
||||
"batch_size": 40,
|
||||
},
|
||||
{
|
||||
"id": "Pro/BAAI/bge-reranker-v2-m3",
|
||||
"type": "rerank",
|
||||
"display_name": "Pro/BAAI/bge-reranker-v2-m3",
|
||||
},
|
||||
{
|
||||
"id": "BAAI/bge-reranker-v2-m3",
|
||||
"type": "rerank",
|
||||
"display_name": "BAAI/bge-reranker-v2-m3",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"provider_id": "siliconflow",
|
||||
"display_name": "SiliconFlow (International)",
|
||||
"base_url": "https://api.siliconflow.com/v1",
|
||||
"embedding_base_url": "https://api.siliconflow.com/v1/embeddings",
|
||||
"rerank_base_url": "https://api.siliconflow.com/v1/rerank",
|
||||
"api_key_env": "SILICONFLOW_API_KEY",
|
||||
"capabilities": ["chat", "embedding", "rerank"],
|
||||
"models_endpoint": "https://api.siliconflow.com/v1/models?sub_type=chat",
|
||||
"embedding_models_endpoint": "https://api.siliconflow.com/v1/models?sub_type=embedding",
|
||||
"rerank_models_endpoint": "https://api.siliconflow.com/v1/models?sub_type=reranker",
|
||||
"enabled_models": [
|
||||
{"id": "deepseek-ai/DeepSeek-V3.2", "type": "chat", "display_name": "deepseek-ai/DeepSeek-V3.2"},
|
||||
{"id": "MiniMaxAI/MiniMax-M2.5", "type": "chat", "display_name": "MiniMaxAI/MiniMax-M2.5"},
|
||||
{
|
||||
"id": "Pro/BAAI/bge-m3",
|
||||
"type": "embedding",
|
||||
"display_name": "Pro/BAAI/bge-m3",
|
||||
"dimension": 1024,
|
||||
"batch_size": 40,
|
||||
},
|
||||
{
|
||||
"id": "BAAI/bge-m3",
|
||||
"type": "embedding",
|
||||
"display_name": "BAAI/bge-m3",
|
||||
"dimension": 1024,
|
||||
"batch_size": 40,
|
||||
},
|
||||
{
|
||||
"id": "Qwen/Qwen3-Embedding-0.6B",
|
||||
"type": "embedding",
|
||||
"display_name": "Qwen/Qwen3-Embedding-0.6B",
|
||||
"dimension": 1024,
|
||||
"batch_size": 40,
|
||||
},
|
||||
{
|
||||
"id": "Pro/BAAI/bge-reranker-v2-m3",
|
||||
"type": "rerank",
|
||||
"display_name": "Pro/BAAI/bge-reranker-v2-m3",
|
||||
},
|
||||
{
|
||||
"id": "BAAI/bge-reranker-v2-m3",
|
||||
"type": "rerank",
|
||||
"display_name": "BAAI/bge-reranker-v2-m3",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _normalize_list(value: Any) -> list:
|
||||
@ -297,11 +59,12 @@ def _normalize_model_item(model: dict[str, Any]) -> dict[str, Any]:
|
||||
normalized["extra"] = _normalize_dict(model.get("extra"))
|
||||
|
||||
if model_type == "embedding":
|
||||
dimension = normalized.get("dimension")
|
||||
dimension = model.get("dimension")
|
||||
if dimension not in (None, ""):
|
||||
normalized["dimension"] = int(dimension)
|
||||
if normalized.get("batch_size") not in (None, ""):
|
||||
normalized["batch_size"] = int(normalized["batch_size"])
|
||||
batch_size = model.get("batch_size")
|
||||
if batch_size not in (None, ""):
|
||||
normalized["batch_size"] = int(batch_size)
|
||||
|
||||
return normalized
|
||||
|
||||
@ -346,16 +109,14 @@ def _normalize_payload(data: dict[str, Any], *, partial: bool = False) -> dict[s
|
||||
raise ValueError("base_url 不能为空")
|
||||
payload["base_url"] = base_url
|
||||
|
||||
for endpoint_field, default_endpoint in (
|
||||
("models_endpoint", DEFAULT_MODELS_ENDPOINT),
|
||||
("embedding_models_endpoint", DEFAULT_EMBEDDING_MODELS_ENDPOINT),
|
||||
("rerank_models_endpoint", None),
|
||||
for endpoint_field in (
|
||||
"models_endpoint",
|
||||
"embedding_models_endpoint",
|
||||
"rerank_models_endpoint",
|
||||
):
|
||||
if endpoint_field in payload:
|
||||
endpoint = str(payload.get(endpoint_field) or "").strip()
|
||||
payload[endpoint_field] = endpoint or default_endpoint
|
||||
elif not partial and default_endpoint is not None:
|
||||
payload[endpoint_field] = default_endpoint
|
||||
payload[endpoint_field] = endpoint
|
||||
|
||||
provider_type = payload.get("provider_type")
|
||||
if provider_type is None and not partial:
|
||||
@ -364,55 +125,29 @@ def _normalize_payload(data: dict[str, Any], *, partial: bool = False) -> dict[s
|
||||
if provider_type not in VALID_PROVIDER_TYPES:
|
||||
raise ValueError(f"provider_type 必须是 {', '.join(sorted(VALID_PROVIDER_TYPES))} 之一")
|
||||
|
||||
if "capabilities" in payload:
|
||||
payload["capabilities"] = _normalize_list(payload.get("capabilities"))
|
||||
elif not partial:
|
||||
payload["capabilities"] = []
|
||||
|
||||
capabilities = set(payload.get("capabilities") or [])
|
||||
if "embedding" in capabilities:
|
||||
embedding_base_url = str(payload.get("embedding_base_url") or "").strip()
|
||||
if not embedding_base_url:
|
||||
raise ValueError("embedding provider 必须配置 embedding_base_url")
|
||||
payload["embedding_base_url"] = embedding_base_url
|
||||
embedding_endpoint = str(payload.get("embedding_models_endpoint") or "").strip()
|
||||
if embedding_endpoint and not embedding_endpoint.startswith(("http://", "https://")):
|
||||
raise ValueError("embedding_models_endpoint 必须是完整的 HTTP URL")
|
||||
payload["embedding_models_endpoint"] = embedding_endpoint
|
||||
if "rerank" in capabilities:
|
||||
rerank_base_url = str(payload.get("rerank_base_url") or "").strip()
|
||||
if not rerank_base_url:
|
||||
raise ValueError("rerank provider 必须配置 rerank_base_url")
|
||||
payload["rerank_base_url"] = rerank_base_url
|
||||
rerank_endpoint = str(payload.get("rerank_models_endpoint") or "").strip()
|
||||
if rerank_endpoint and not rerank_endpoint.startswith(("http://", "https://")):
|
||||
raise ValueError("rerank_models_endpoint 必须是完整的 HTTP URL")
|
||||
payload["rerank_models_endpoint"] = rerank_endpoint
|
||||
|
||||
if "enabled_models" in payload:
|
||||
payload["enabled_models"] = _normalize_model_list(payload.get("enabled_models"))
|
||||
elif not partial:
|
||||
payload["enabled_models"] = []
|
||||
|
||||
if "headers_json" in payload:
|
||||
payload["headers_json"] = _normalize_dict(payload.get("headers_json"))
|
||||
elif not partial:
|
||||
payload["headers_json"] = {}
|
||||
|
||||
if "extra_json" in payload:
|
||||
payload["extra_json"] = _normalize_dict(payload.get("extra_json"))
|
||||
elif not partial:
|
||||
payload["extra_json"] = {}
|
||||
|
||||
if "is_enabled" in payload:
|
||||
payload["is_enabled"] = bool(payload["is_enabled"])
|
||||
elif not partial:
|
||||
payload["is_enabled"] = True
|
||||
|
||||
if "is_builtin" in payload:
|
||||
payload["is_builtin"] = bool(payload["is_builtin"])
|
||||
elif not partial:
|
||||
payload["is_builtin"] = False
|
||||
# 声明式字段默认值:partial 模式下仅规范化传入值,非 partial 补全默认值
|
||||
_FIELD_DEFAULTS: dict[str, Any] = {
|
||||
"capabilities": [],
|
||||
"enabled_models": [],
|
||||
"headers_json": {},
|
||||
"extra_json": {},
|
||||
"is_enabled": True,
|
||||
"is_builtin": False,
|
||||
}
|
||||
_FIELD_NORMALIZERS = {
|
||||
"capabilities": _normalize_list,
|
||||
"enabled_models": _normalize_model_list,
|
||||
"headers_json": _normalize_dict,
|
||||
"extra_json": _normalize_dict,
|
||||
"is_enabled": bool,
|
||||
"is_builtin": bool,
|
||||
}
|
||||
for field, default in _FIELD_DEFAULTS.items():
|
||||
if field in payload:
|
||||
normalizer = _FIELD_NORMALIZERS.get(field)
|
||||
payload[field] = normalizer(payload[field]) if normalizer else payload[field]
|
||||
elif not partial:
|
||||
payload[field] = default
|
||||
|
||||
# 仅当本次 payload 同时携带 capabilities 与 enabled_models 时做一致性校验,
|
||||
# 防止前端把超出 provider.capabilities 的模型 type 写入。
|
||||
@ -425,7 +160,8 @@ def _normalize_payload(data: dict[str, Any], *, partial: bool = False) -> dict[s
|
||||
return payload
|
||||
|
||||
|
||||
def _resolve_api_key(provider: ModelProvider) -> str | None:
|
||||
def resolve_api_key(provider: ModelProvider) -> str | None:
|
||||
"""解析 provider 的 API Key,优先直接配置,其次从环境变量读取。"""
|
||||
if provider.api_key:
|
||||
return provider.api_key
|
||||
if provider.api_key_env:
|
||||
@ -433,7 +169,7 @@ def _resolve_api_key(provider: ModelProvider) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _check_credential_status(provider: ModelProvider) -> str:
|
||||
def check_credential_status(provider: ModelProvider) -> str:
|
||||
"""检查 provider 的凭证配置状态。仅对启用的 provider 做校验。"""
|
||||
if not provider.is_enabled:
|
||||
return "ok"
|
||||
@ -444,7 +180,7 @@ def _check_credential_status(provider: ModelProvider) -> str:
|
||||
return "warning"
|
||||
|
||||
|
||||
def _models_url(base_url: str, endpoint: str | None = DEFAULT_MODELS_ENDPOINT) -> str:
|
||||
def _models_url(base_url: str, endpoint: str | None = None) -> str:
|
||||
base = base_url.rstrip("/")
|
||||
if not endpoint:
|
||||
return base
|
||||
@ -502,7 +238,7 @@ async def ensure_builtin_model_providers_in_db(db: AsyncSession) -> None:
|
||||
existing = await list_model_providers(db)
|
||||
existing_ids = {p.provider_id: p for p in existing}
|
||||
|
||||
for provider_def in _DEFAULT_BUILTIN_PROVIDERS:
|
||||
for provider_def in BUILTIN_PROVIDERS:
|
||||
provider_id = provider_def["provider_id"]
|
||||
existing_provider = existing_ids.get(provider_id)
|
||||
if existing_provider:
|
||||
@ -598,22 +334,22 @@ async def fetch_remote_models(provider: ModelProvider) -> list[dict[str, Any]]:
|
||||
/embeddings/models;rerank 供应商没有稳定通用端点,配置了 endpoint 才拉取。
|
||||
"""
|
||||
headers = dict(provider.headers_json or {})
|
||||
api_key = _resolve_api_key(provider)
|
||||
api_key = resolve_api_key(provider)
|
||||
if api_key:
|
||||
headers.setdefault("Authorization", f"Bearer {api_key}")
|
||||
|
||||
capabilities = set(provider.capabilities or [])
|
||||
endpoint_specs = [
|
||||
(provider.models_endpoint or DEFAULT_MODELS_ENDPOINT, "chat"),
|
||||
(provider.models_endpoint, "chat"),
|
||||
]
|
||||
if "embedding" in capabilities:
|
||||
endpoint_specs.append((provider.embedding_models_endpoint or DEFAULT_EMBEDDING_MODELS_ENDPOINT, "embedding"))
|
||||
endpoint_specs.append((provider.embedding_models_endpoint, "embedding"))
|
||||
if "rerank" in capabilities and provider.rerank_models_endpoint:
|
||||
endpoint_specs.append((provider.rerank_models_endpoint, "rerank"))
|
||||
|
||||
seen_ids: set[tuple[str, str]] = set()
|
||||
models: list[dict[str, Any]] = []
|
||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||
async with httpx.AsyncClient(timeout=40.0) as client:
|
||||
results = await asyncio.gather(
|
||||
*[
|
||||
_fetch_models_from_endpoint(client, provider, headers, endpoint, model_type)
|
||||
@ -628,3 +364,50 @@ async def fetch_remote_models(provider: ModelProvider) -> list[dict[str, Any]]:
|
||||
seen_ids.add(model_key)
|
||||
models.append(model)
|
||||
return models
|
||||
|
||||
|
||||
async def test_model_status_by_spec(spec: str) -> dict:
|
||||
"""根据 full spec 测试模型连接状态(自动识别 Chat/Embedding)。
|
||||
|
||||
V2 spec 格式: provider_id:model_id(冒号分隔)
|
||||
V1 spec 格式: provider/model_name(斜杠分隔)
|
||||
"""
|
||||
from yuxi.services.model_cache import model_cache
|
||||
|
||||
# V2: 从缓存识别模型类型并分派
|
||||
if ":" in spec:
|
||||
info = model_cache.get_model_info(spec)
|
||||
if info:
|
||||
if info.model_type == "embedding":
|
||||
from yuxi.models.embed import select_embedding_model_v2
|
||||
|
||||
model = select_embedding_model_v2(spec)
|
||||
success, message = await model.test_connection()
|
||||
return {
|
||||
"spec": spec,
|
||||
"status": "available" if success else "unavailable",
|
||||
"message": "连接正常" if success else message,
|
||||
"model_type": "embedding",
|
||||
}
|
||||
# chat 或其他类型走 chat 测试
|
||||
from yuxi.models.chat import select_model_v2
|
||||
|
||||
model = select_model_v2(spec)
|
||||
test_messages = [{"role": "user", "content": "Say 1"}]
|
||||
response = await model.call(test_messages, stream=False)
|
||||
if response and response.content:
|
||||
return {"spec": spec, "status": "available", "message": "连接正常", "model_type": "chat"}
|
||||
return {"spec": spec, "status": "unavailable", "message": "响应无效", "model_type": "chat"}
|
||||
|
||||
# V1 兼容:尝试旧的模型选择逻辑
|
||||
from yuxi.models.chat import select_model
|
||||
|
||||
try:
|
||||
model = select_model(model_spec=spec)
|
||||
test_messages = [{"role": "user", "content": "Say 1"}]
|
||||
response = await model.call(test_messages, stream=False)
|
||||
if response and response.content:
|
||||
return {"spec": spec, "status": "available", "message": "连接正常"}
|
||||
return {"spec": spec, "status": "unavailable", "message": "响应无效"}
|
||||
except Exception as e:
|
||||
return {"spec": spec, "status": "error", "message": str(e)}
|
||||
|
||||
@ -202,6 +202,8 @@ class PostgresManager(metaclass=SingletonMeta):
|
||||
provider_type VARCHAR(32) NOT NULL DEFAULT 'openai',
|
||||
default_protocol VARCHAR(64),
|
||||
base_url VARCHAR(500) NOT NULL,
|
||||
embedding_base_url VARCHAR(500),
|
||||
rerank_base_url VARCHAR(500),
|
||||
models_endpoint VARCHAR(200),
|
||||
embedding_models_endpoint VARCHAR(200),
|
||||
rerank_models_endpoint VARCHAR(200),
|
||||
@ -242,29 +244,6 @@ class PostgresManager(metaclass=SingletonMeta):
|
||||
"CREATE INDEX IF NOT EXISTS ix_conversations_is_pinned ON conversations(is_pinned)",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ix_model_providers_provider_id ON model_providers(provider_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_model_providers_is_enabled ON model_providers(is_enabled)",
|
||||
"ALTER TABLE IF EXISTS model_providers ALTER COLUMN provider_type SET DEFAULT 'openai'",
|
||||
"ALTER TABLE IF EXISTS model_providers ADD COLUMN IF NOT EXISTS models_endpoint VARCHAR(200)",
|
||||
"ALTER TABLE IF EXISTS model_providers ADD COLUMN IF NOT EXISTS embedding_models_endpoint VARCHAR(200)",
|
||||
"ALTER TABLE IF EXISTS model_providers ADD COLUMN IF NOT EXISTS rerank_models_endpoint VARCHAR(200)",
|
||||
"ALTER TABLE IF EXISTS model_providers ADD COLUMN IF NOT EXISTS embedding_base_url VARCHAR(500)",
|
||||
"ALTER TABLE IF EXISTS model_providers ADD COLUMN IF NOT EXISTS rerank_base_url VARCHAR(500)",
|
||||
"ALTER TABLE IF EXISTS model_providers ALTER COLUMN models_endpoint SET DEFAULT '/models'",
|
||||
(
|
||||
"ALTER TABLE IF EXISTS model_providers "
|
||||
"ALTER COLUMN embedding_models_endpoint SET DEFAULT '/embeddings/models'"
|
||||
),
|
||||
(
|
||||
"UPDATE model_providers SET models_endpoint = '/models' "
|
||||
"WHERE models_endpoint IS NULL OR models_endpoint = ''"
|
||||
),
|
||||
(
|
||||
"UPDATE model_providers SET embedding_models_endpoint = '/embeddings/models' "
|
||||
"WHERE embedding_models_endpoint IS NULL "
|
||||
"OR embedding_models_endpoint = '' "
|
||||
"OR embedding_models_endpoint = '/embedding/models'"
|
||||
),
|
||||
"ALTER TABLE IF EXISTS model_providers DROP COLUMN IF EXISTS model_cache",
|
||||
"ALTER TABLE IF EXISTS model_providers DROP COLUMN IF EXISTS sync_source",
|
||||
]
|
||||
async with self.async_engine.begin() as conn:
|
||||
for stmt in stmts:
|
||||
|
||||
@ -568,8 +568,8 @@ class ModelProvider(Base):
|
||||
"base_url": self.base_url,
|
||||
"embedding_base_url": self.embedding_base_url,
|
||||
"rerank_base_url": self.rerank_base_url,
|
||||
"models_endpoint": self.models_endpoint or "/models",
|
||||
"embedding_models_endpoint": self.embedding_models_endpoint or "/embeddings/models",
|
||||
"models_endpoint": self.models_endpoint,
|
||||
"embedding_models_endpoint": self.embedding_models_endpoint,
|
||||
"rerank_models_endpoint": self.rerank_models_endpoint,
|
||||
"api_key_env": self.api_key_env,
|
||||
"api_key": self.api_key,
|
||||
|
||||
@ -9,12 +9,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from server.utils.auth_middleware import get_admin_user, get_db
|
||||
from yuxi.services.model_provider_service import (
|
||||
_check_credential_status,
|
||||
check_credential_status,
|
||||
create_provider_config,
|
||||
delete_provider_config,
|
||||
fetch_remote_models,
|
||||
get_all_model_providers,
|
||||
get_model_provider_by_id,
|
||||
test_model_status_by_spec,
|
||||
update_provider_config,
|
||||
)
|
||||
from yuxi.storage.postgres.models_business import User
|
||||
@ -68,7 +69,7 @@ async def list_providers(
|
||||
data = []
|
||||
for p in providers:
|
||||
d = p.to_dict()
|
||||
d["credential_status"] = _check_credential_status(p)
|
||||
d["credential_status"] = check_credential_status(p)
|
||||
data.append(d)
|
||||
return {"success": True, "data": data}
|
||||
|
||||
@ -106,7 +107,7 @@ async def get_provider(
|
||||
if provider is None:
|
||||
raise HTTPException(status_code=404, detail=f"供应商 {provider_id} 不存在")
|
||||
data = provider.to_dict()
|
||||
data["credential_status"] = _check_credential_status(provider)
|
||||
data["credential_status"] = check_credential_status(provider)
|
||||
return {"success": True, "data": data}
|
||||
|
||||
|
||||
@ -234,23 +235,10 @@ async def get_model_status_by_spec(
|
||||
spec: str,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
):
|
||||
"""根据 full spec 检查模型状态(自动识别 V1/V2、Chat/Embedding)。
|
||||
|
||||
V2 spec 格式: provider_id:model_id(冒号分隔)
|
||||
优先从缓存获取模型类型,根据类型分派到对应的测试函数。
|
||||
"""
|
||||
from yuxi.models.chat import test_chat_model_status_by_spec
|
||||
|
||||
# 尝试从缓存获取模型类型,区分 Chat 和 Embedding
|
||||
if ":" in spec:
|
||||
from yuxi.services.model_cache import model_cache
|
||||
|
||||
info = model_cache.get_model_info(spec)
|
||||
if info and info.model_type == "embedding":
|
||||
from yuxi.models.embed import test_embedding_model_status_by_spec
|
||||
|
||||
result = await test_embedding_model_status_by_spec(spec)
|
||||
return {"success": True, "data": result}
|
||||
|
||||
result = await test_chat_model_status_by_spec(spec)
|
||||
return {"success": True, "data": result}
|
||||
"""根据 full spec 检查模型状态(自动识别 V1/V2、Chat/Embedding)。"""
|
||||
try:
|
||||
result = await test_model_status_by_spec(spec)
|
||||
return {"success": True, "data": result}
|
||||
except Exception as e:
|
||||
logger.error(f"测试模型状态失败 {spec}: {e}")
|
||||
return {"success": False, "data": {"spec": spec, "status": "error", "message": str(e)}}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
@ -59,8 +60,6 @@ async def lifespan(app: FastAPI):
|
||||
raise
|
||||
|
||||
# 初始化知识库管理器
|
||||
import os
|
||||
|
||||
if os.environ.get("LITE_MODE", "").lower() in ("true", "1"):
|
||||
logger.info("LITE_MODE enabled, skipping knowledge base initialization")
|
||||
else:
|
||||
|
||||
@ -4,11 +4,9 @@ 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 (
|
||||
_DEFAULT_BUILTIN_PROVIDERS,
|
||||
DEFAULT_EMBEDDING_MODELS_ENDPOINT,
|
||||
DEFAULT_MODELS_ENDPOINT,
|
||||
_check_credential_status,
|
||||
check_credential_status,
|
||||
_normalize_payload,
|
||||
_normalize_remote_model,
|
||||
fetch_remote_models,
|
||||
@ -27,8 +25,8 @@ def test_normalize_payload_accepts_enabled_chat_model():
|
||||
|
||||
assert payload["provider_id"] == "openrouter-local"
|
||||
assert payload["provider_type"] == "openai"
|
||||
assert payload["models_endpoint"] == DEFAULT_MODELS_ENDPOINT
|
||||
assert payload["embedding_models_endpoint"] == DEFAULT_EMBEDDING_MODELS_ENDPOINT
|
||||
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"
|
||||
|
||||
|
||||
@ -44,16 +42,20 @@ def test_normalize_payload_rejects_unknown_enabled_model_type():
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_payload_requires_embedding_dimension():
|
||||
with pytest.raises(ValueError, match="dimension"):
|
||||
_normalize_payload(
|
||||
{
|
||||
"provider_id": "embedding-local",
|
||||
"display_name": "Embedding Local",
|
||||
"base_url": "https://example.com/v1",
|
||||
"enabled_models": [{"id": "text-embedding", "type": "embedding"}],
|
||||
}
|
||||
)
|
||||
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():
|
||||
@ -113,7 +115,7 @@ async def test_fetch_remote_models_loads_embedding_only_when_capability_enabled(
|
||||
|
||||
|
||||
def test_builtin_provider_templates_default_to_openai_provider_type():
|
||||
assert len(_DEFAULT_BUILTIN_PROVIDERS) >= 20
|
||||
assert len(BUILTIN_PROVIDERS) >= 16
|
||||
provider_types = {
|
||||
_normalize_payload(
|
||||
{
|
||||
@ -123,13 +125,13 @@ def test_builtin_provider_templates_default_to_openai_provider_type():
|
||||
"provider_type": provider.get("provider_type"),
|
||||
}
|
||||
)["provider_type"]
|
||||
for provider in _DEFAULT_BUILTIN_PROVIDERS
|
||||
for provider in BUILTIN_PROVIDERS
|
||||
}
|
||||
assert provider_types == {"openai"}
|
||||
|
||||
|
||||
def test_builtin_siliconflow_provider_includes_default_runnable_models():
|
||||
provider = next(item for item in _DEFAULT_BUILTIN_PROVIDERS if item["provider_id"] == "siliconflow-cn")
|
||||
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"]
|
||||
@ -142,27 +144,27 @@ def test_builtin_siliconflow_provider_includes_default_runnable_models():
|
||||
assert "base_url_override" not in models["Pro/BAAI/bge-reranker-v2-m3"]
|
||||
|
||||
|
||||
def test_check_credential_status_disabled_provider_always_ok():
|
||||
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"
|
||||
assert check_credential_status(Provider()) == "ok"
|
||||
|
||||
|
||||
def test_check_credential_status_direct_api_key_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"
|
||||
assert check_credential_status(Provider()) == "ok"
|
||||
|
||||
|
||||
def test_check_credential_status_env_key_exists_ok(monkeypatch):
|
||||
def testcheck_credential_status_env_key_exists_ok(monkeypatch):
|
||||
"""api_key_env 对应的环境变量存在时状态为 ok。"""
|
||||
monkeypatch.setenv("TEST_API_KEY", "exists")
|
||||
|
||||
@ -171,10 +173,10 @@ def test_check_credential_status_env_key_exists_ok(monkeypatch):
|
||||
api_key = None
|
||||
api_key_env = "TEST_API_KEY"
|
||||
|
||||
assert _check_credential_status(Provider()) == "ok"
|
||||
assert check_credential_status(Provider()) == "ok"
|
||||
|
||||
|
||||
def test_check_credential_status_env_key_missing_warning(monkeypatch):
|
||||
def testcheck_credential_status_env_key_missing_warning(monkeypatch):
|
||||
"""api_key_env 对应的环境变量不存在时状态为 warning。"""
|
||||
monkeypatch.delenv("MISSING_KEY", raising=False)
|
||||
|
||||
@ -183,17 +185,17 @@ def test_check_credential_status_env_key_missing_warning(monkeypatch):
|
||||
api_key = None
|
||||
api_key_env = "MISSING_KEY"
|
||||
|
||||
assert _check_credential_status(Provider()) == "warning"
|
||||
assert check_credential_status(Provider()) == "warning"
|
||||
|
||||
|
||||
def test_check_credential_status_both_empty_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"
|
||||
assert check_credential_status(Provider()) == "warning"
|
||||
|
||||
|
||||
# ==================== 手动添加模型 / source 字段 ====================
|
||||
|
||||
@ -142,7 +142,7 @@
|
||||
--min-width: 400px;
|
||||
|
||||
/* Page Padding - 响应式页面内边距 */
|
||||
--page-padding: 20px;
|
||||
--page-padding: 22px;
|
||||
|
||||
/* Ant Design 兼容变量 */
|
||||
--color-bg-container: var(--main-0);
|
||||
@ -189,18 +189,18 @@
|
||||
/* Page Padding 响应式断点 */
|
||||
@media (min-width: 1440px) {
|
||||
:root {
|
||||
--page-padding: 24px;
|
||||
--page-padding: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1199px) {
|
||||
:root {
|
||||
--page-padding: 16px;
|
||||
--page-padding: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
:root {
|
||||
--page-padding: 12px;
|
||||
--page-padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -69,6 +69,7 @@ import { useConfigStore } from '@/stores/config'
|
||||
import { embeddingApi } from '@/apis/knowledge_api'
|
||||
import { modelProviderApi } from '@/apis/system_api'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useModelStatus } from '@/composables/useModelStatus'
|
||||
|
||||
const configStore = useConfigStore()
|
||||
|
||||
@ -99,9 +100,9 @@ const props = defineProps({
|
||||
const emit = defineEmits(['update:value', 'change'])
|
||||
|
||||
const v2Models = ref({})
|
||||
const v1ModelStatuses = reactive({})
|
||||
const { statusMap: v2ModelStatuses, getStatusIcon: getV2StatusIcon, getStatusClass: getV2StatusClass, getStatusTooltip: getV2StatusTooltip, checkV2Status, checkV2Statuses } = useModelStatus()
|
||||
const state = reactive({
|
||||
v1ModelStatuses: {},
|
||||
v2ModelStatuses: {},
|
||||
checkingStatus: false
|
||||
})
|
||||
|
||||
@ -140,20 +141,7 @@ const checkV2ModelStatuses = async () => {
|
||||
state.checkingStatus = true
|
||||
try {
|
||||
for (const providerData of Object.values(v2Models.value)) {
|
||||
for (const model of providerData.models || []) {
|
||||
try {
|
||||
const response = await modelProviderApi.getModelStatusBySpec(model.spec)
|
||||
if (response.data) {
|
||||
state.v2ModelStatuses[model.spec] = response.data
|
||||
}
|
||||
} catch {
|
||||
state.v2ModelStatuses[model.spec] = {
|
||||
spec: model.spec,
|
||||
status: 'error',
|
||||
message: '检查失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
await checkV2Statuses(providerData.models || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查 V2 模型状态失败:', error)
|
||||
@ -166,7 +154,7 @@ const checkV1ModelStatuses = async () => {
|
||||
try {
|
||||
const response = await embeddingApi.getAllModelsStatus()
|
||||
if (response.status.models) {
|
||||
state.v1ModelStatuses = response.status.models
|
||||
Object.assign(v1ModelStatuses, response.status.models)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查 V1 模型状态失败:', error)
|
||||
@ -176,7 +164,7 @@ const checkV1ModelStatuses = async () => {
|
||||
|
||||
// V1 模型状态辅助函数
|
||||
const getV1StatusIcon = (modelId) => {
|
||||
const status = state.v1ModelStatuses[modelId]
|
||||
const status = v1ModelStatuses[modelId]
|
||||
if (!status) return '○'
|
||||
if (status.status === 'available') return '✓'
|
||||
if (status.status === 'unavailable') return '✗'
|
||||
@ -185,35 +173,12 @@ const getV1StatusIcon = (modelId) => {
|
||||
}
|
||||
|
||||
const getV1StatusClass = (modelId) => {
|
||||
const status = state.v1ModelStatuses[modelId]
|
||||
const status = v1ModelStatuses[modelId]
|
||||
return status?.status || ''
|
||||
}
|
||||
|
||||
const getV1StatusTooltip = (modelId) => {
|
||||
const status = state.v1ModelStatuses[modelId]
|
||||
if (!status) return '状态未知'
|
||||
let statusText =
|
||||
{ available: '可用', unavailable: '不可用', error: '错误' }[status.status] || '未知'
|
||||
return `${statusText}: ${status.message || '无详细信息'}`
|
||||
}
|
||||
|
||||
// V2 模型状态辅助函数
|
||||
const getV2StatusIcon = (spec) => {
|
||||
const status = state.v2ModelStatuses[spec]
|
||||
if (!status) return '○'
|
||||
if (status.status === 'available') return '✓'
|
||||
if (status.status === 'unavailable') return '✗'
|
||||
if (status.status === 'error') return '⚠'
|
||||
return '○'
|
||||
}
|
||||
|
||||
const getV2StatusClass = (spec) => {
|
||||
const status = state.v2ModelStatuses[spec]
|
||||
return status?.status || ''
|
||||
}
|
||||
|
||||
const getV2StatusTooltip = (spec) => {
|
||||
const status = state.v2ModelStatuses[spec]
|
||||
const status = v1ModelStatuses[modelId]
|
||||
if (!status) return '状态未知'
|
||||
let statusText =
|
||||
{ available: '可用', unavailable: '不可用', error: '错误' }[status.status] || '未知'
|
||||
|
||||
@ -64,6 +64,7 @@
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { modelProviderApi } from '@/apis/system_api'
|
||||
import { RefreshCw } from 'lucide-vue-next'
|
||||
import { useModelStatus } from '@/composables/useModelStatus'
|
||||
|
||||
const props = defineProps({
|
||||
model_spec: {
|
||||
@ -124,10 +125,11 @@ const refreshCache = async () => {
|
||||
}
|
||||
|
||||
// 状态管理
|
||||
const { statusMap: modelStatusMap, getStatusIcon, getStatusTooltip } = useModelStatus()
|
||||
const state = reactive({
|
||||
currentModelStatus: null, // 当前模型状态
|
||||
checkingStatus: false, // 是否正在检查状态
|
||||
refreshingCache: false // 是否正在刷新缓存
|
||||
currentModelStatus: null,
|
||||
checkingStatus: false,
|
||||
refreshingCache: false
|
||||
})
|
||||
|
||||
const resolvedSize = computed(() => props.size || 'small')
|
||||
@ -143,8 +145,6 @@ const buttonSize = computed(() => {
|
||||
|
||||
const displayModelText = computed(() => props.model_spec || props.placeholder)
|
||||
|
||||
// 当前模型状态
|
||||
|
||||
// 检查当前模型状态
|
||||
const checkCurrentModelStatus = async () => {
|
||||
const spec = props.model_spec
|
||||
@ -175,7 +175,6 @@ const modelStatusIcon = computed(() => {
|
||||
return '○'
|
||||
})
|
||||
|
||||
// 获取当前模型状态提示文本
|
||||
const getCurrentModelStatusTooltip = () => {
|
||||
const status = state.currentModelStatus
|
||||
if (!status) return '状态未知'
|
||||
|
||||
48
web/src/composables/useModelStatus.js
Normal file
48
web/src/composables/useModelStatus.js
Normal file
@ -0,0 +1,48 @@
|
||||
import { reactive } from 'vue'
|
||||
import { modelProviderApi } from '@/apis/system_api'
|
||||
|
||||
/**
|
||||
* 模型状态检查 composable,供 Chat/Embedding/Rerank 模型选择器共用。
|
||||
*/
|
||||
export function useModelStatus() {
|
||||
const statusMap = reactive({})
|
||||
|
||||
const getStatusIcon = (key) => {
|
||||
const status = statusMap[key]
|
||||
if (!status) return '○'
|
||||
if (status.status === 'available') return '✓'
|
||||
if (status.status === 'unavailable') return '✗'
|
||||
if (status.status === 'error') return '⚠'
|
||||
return '○'
|
||||
}
|
||||
|
||||
const getStatusClass = (key) => {
|
||||
return statusMap[key]?.status || ''
|
||||
}
|
||||
|
||||
const getStatusTooltip = (key) => {
|
||||
const status = statusMap[key]
|
||||
if (!status) return '状态未知'
|
||||
const text = { available: '可用', unavailable: '不可用', error: '错误' }[status.status] || '未知'
|
||||
return `${text}: ${status.message || '无详细信息'}`
|
||||
}
|
||||
|
||||
const checkV2Status = async (spec) => {
|
||||
try {
|
||||
const response = await modelProviderApi.getModelStatusBySpec(spec)
|
||||
if (response.data) {
|
||||
statusMap[spec] = response.data
|
||||
}
|
||||
} catch {
|
||||
statusMap[spec] = { spec, status: 'error', message: '检查失败' }
|
||||
}
|
||||
}
|
||||
|
||||
const checkV2Statuses = async (models) => {
|
||||
for (const model of models || []) {
|
||||
await checkV2Status(model.spec)
|
||||
}
|
||||
}
|
||||
|
||||
return { statusMap, getStatusIcon, getStatusClass, getStatusTooltip, checkV2Status, checkV2Statuses }
|
||||
}
|
||||
@ -29,4 +29,4 @@ export const modelIcons = {
|
||||
zhipu: `${ICON_BASE}/zhipu-color.svg`,
|
||||
zhipuai: `${ICON_BASE}/zhipu-color.svg`,
|
||||
'zhipuai-coding-plan': `${ICON_BASE}/zhipu-color.svg`
|
||||
} // ESLint-disable-line
|
||||
} // eslint-disable-line
|
||||
|
||||
@ -47,11 +47,11 @@ const providerForm = reactive({
|
||||
// Model form state
|
||||
const showModelModal = ref(false)
|
||||
const isCreating = ref(false) // true=手动添加新模型,false=编辑已有模型
|
||||
const editingModel = reactive({
|
||||
const editingModel = ref({
|
||||
id: '',
|
||||
display_name: '',
|
||||
type: 'chat',
|
||||
source: 'remote', // 'manual'|'remote':区分手动添加,控制 stale 视觉警告跳过
|
||||
source: 'remote',
|
||||
protocol_override: null,
|
||||
base_url_override: null,
|
||||
context_length: null,
|
||||
@ -445,7 +445,7 @@ const addModelFromRemote = async (providerId, remoteModel) => {
|
||||
}
|
||||
|
||||
const openModelConfigModal = (model) => {
|
||||
Object.assign(editingModel, normalizeModel(model))
|
||||
Object.assign(editingModel.value, normalizeModel(model))
|
||||
isCreating.value = false
|
||||
showModelModal.value = true
|
||||
}
|
||||
@ -455,7 +455,7 @@ const openCreateModal = (provider) => {
|
||||
if (!provider) return
|
||||
const types = provider.capabilities?.length ? provider.capabilities : ['chat']
|
||||
const defaultType = types[0]
|
||||
Object.assign(editingModel, {
|
||||
Object.assign(editingModel.value, {
|
||||
id: '',
|
||||
display_name: '',
|
||||
type: defaultType,
|
||||
@ -483,7 +483,7 @@ const saveModelConfig = async () => {
|
||||
|
||||
let enabledModels
|
||||
if (isCreating.value) {
|
||||
const newId = (editingModel.id || '').trim()
|
||||
const newId = (editingModel.value.id || '').trim()
|
||||
if (!newId) {
|
||||
message.error('请填写模型 ID')
|
||||
return
|
||||
@ -492,11 +492,11 @@ const saveModelConfig = async () => {
|
||||
message.error('模型 ID 已存在')
|
||||
return
|
||||
}
|
||||
const newModel = { ...editingModel, id: newId, source: 'manual', enabled: true }
|
||||
const newModel = { ...editingModel.value, id: newId, source: 'manual', enabled: true }
|
||||
enabledModels = [...(provider.enabled_models || []), newModel]
|
||||
} else {
|
||||
enabledModels = (provider.enabled_models || []).map((m) =>
|
||||
m.id === editingModel.id ? { ...editingModel } : m
|
||||
m.id === editingModel.value.id ? { ...editingModel.value } : m
|
||||
)
|
||||
}
|
||||
|
||||
@ -554,7 +554,6 @@ onMounted(loadProviders)
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<p class="eyebrow">Model Registry</p>
|
||||
<h1>模型配置</h1>
|
||||
</div>
|
||||
<div class="summary-strip">
|
||||
@ -1075,26 +1074,17 @@ onMounted(loadProviders)
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 28px 32px 18px;
|
||||
padding: 28px var(--page-padding) 18px;
|
||||
border-bottom: 1px solid var(--gray-100);
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-size: 24px;
|
||||
font-weight: 720;
|
||||
line-height: 34px;
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 4px;
|
||||
color: var(--main-700);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.summary-strip {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@ -1122,8 +1112,7 @@ onMounted(loadProviders)
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 32px;
|
||||
border-bottom: 1px solid var(--gray-100);
|
||||
padding: 16px var(--page-padding) 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
@ -1154,7 +1143,7 @@ onMounted(loadProviders)
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 24px 32px;
|
||||
padding: 16px var(--page-padding);
|
||||
}
|
||||
|
||||
// ============ Provider Card ============
|
||||
|
||||
Loading…
Reference in New Issue
Block a user