2026-04-25 23:58:01 +08:00
|
|
|
|
# ruff: noqa: E501
|
|
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import os
|
|
|
|
|
|
import re
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
from yuxi.repositories.model_provider_repository import (
|
|
|
|
|
|
create_model_provider,
|
|
|
|
|
|
delete_model_provider,
|
|
|
|
|
|
get_model_provider,
|
|
|
|
|
|
list_model_providers,
|
|
|
|
|
|
update_model_provider,
|
|
|
|
|
|
)
|
|
|
|
|
|
from yuxi.storage.postgres.models_business import ModelProvider
|
|
|
|
|
|
|
|
|
|
|
|
VALID_MODEL_TYPES = {"chat", "embedding", "rerank"}
|
2026-04-26 15:03:20 +08:00
|
|
|
|
VALID_MODEL_SOURCES = {"manual", "remote"}
|
2026-04-25 23:58:01 +08:00
|
|
|
|
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}$")
|
2026-04-26 15:03:20 +08:00
|
|
|
|
DEFAULT_MODELS_ENDPOINT = ""
|
|
|
|
|
|
DEFAULT_EMBEDDING_MODELS_ENDPOINT = ""
|
2026-04-25 23:58:01 +08:00
|
|
|
|
|
|
|
|
|
|
_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",
|
2026-04-26 15:03:20 +08:00
|
|
|
|
"models_endpoint": "https://api.openai.com/v1/models",
|
2026-04-25 23:58:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"provider_id": "anthropic",
|
|
|
|
|
|
"display_name": "Anthropic",
|
|
|
|
|
|
"base_url": "https://api.anthropic.com",
|
|
|
|
|
|
"api_key_env": "ANTHROPIC_API_KEY",
|
2026-04-26 15:03:20 +08:00
|
|
|
|
"models_endpoint": "https://api.anthropic.com/models",
|
2026-04-25 23:58:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"provider_id": "google",
|
|
|
|
|
|
"display_name": "Google Gemini",
|
|
|
|
|
|
"base_url": "https://generativelanguage.googleapis.com",
|
|
|
|
|
|
"api_key_env": "GEMINI_API_KEY",
|
2026-04-26 15:03:20 +08:00
|
|
|
|
"models_endpoint": "https://generativelanguage.googleapis.com/v1beta/models",
|
2026-04-25 23:58:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"provider_id": "ollama-cloud",
|
|
|
|
|
|
"display_name": "Ollama",
|
|
|
|
|
|
"base_url": "http://localhost:11434",
|
2026-04-26 15:03:20 +08:00
|
|
|
|
"models_endpoint": "http://localhost:11434/api/tags",
|
2026-04-25 23:58:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"provider_id": "lmstudio",
|
|
|
|
|
|
"display_name": "LM Studio",
|
|
|
|
|
|
"base_url": "http://localhost:1234/v1",
|
2026-04-26 15:03:20 +08:00
|
|
|
|
"models_endpoint": "http://localhost:1234/v1/models",
|
2026-04-25 23:58:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"provider_id": "deepseek",
|
|
|
|
|
|
"display_name": "DeepSeek",
|
|
|
|
|
|
"base_url": "https://api.deepseek.com",
|
|
|
|
|
|
"api_key_env": "DEEPSEEK_API_KEY",
|
2026-04-26 15:03:20 +08:00
|
|
|
|
"models_endpoint": "https://api.deepseek.com/models",
|
2026-04-25 23:58:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"provider_id": "alibaba",
|
|
|
|
|
|
"display_name": "DashScope",
|
|
|
|
|
|
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
|
|
|
|
"api_key_env": "DASHSCOPE_API_KEY",
|
2026-04-26 15:03:20 +08:00
|
|
|
|
"models_endpoint": "https://dashscope.aliyuncs.com/compatible-mode/v1/models",
|
2026-04-25 23:58:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"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",
|
2026-04-26 15:03:20 +08:00
|
|
|
|
"models_endpoint": "https://coding.dashscope.aliyuncs.com/v1/models",
|
2026-04-25 23:58:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"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",
|
2026-04-26 15:03:20 +08:00
|
|
|
|
"models_endpoint": "https://coding-intl.dashscope.aliyuncs.com/v1/models",
|
2026-04-25 23:58:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"provider_id": "zhipuai",
|
|
|
|
|
|
"display_name": "Zhipu (BigModel)",
|
|
|
|
|
|
"base_url": "https://open.bigmodel.cn/api/paas/v4",
|
|
|
|
|
|
"api_key_env": "ZHIPUAI_API_KEY",
|
2026-04-26 15:03:20 +08:00
|
|
|
|
"models_endpoint": "https://open.bigmodel.cn/api/paas/v4/models",
|
2026-04-25 23:58:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"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",
|
2026-04-26 15:03:20 +08:00
|
|
|
|
"models_endpoint": "https://open.bigmodel.cn/api/coding/paas/v4/models",
|
2026-04-25 23:58:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"provider_id": "zai",
|
|
|
|
|
|
"display_name": "Zhipu (Z.AI)",
|
|
|
|
|
|
"base_url": "https://api.z.ai/api/paas/v4",
|
|
|
|
|
|
"api_key_env": "ZAI_API_KEY",
|
2026-04-26 15:03:20 +08:00
|
|
|
|
"models_endpoint": "https://api.z.ai/api/paas/v4/models",
|
2026-04-25 23:58:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"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",
|
2026-04-26 15:03:20 +08:00
|
|
|
|
"models_endpoint": "https://api.z.ai/api/coding/paas/v4/models",
|
2026-04-25 23:58:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"provider_id": "moonshotai-cn",
|
|
|
|
|
|
"display_name": "Moonshot",
|
|
|
|
|
|
"base_url": "https://api.moonshot.cn/v1",
|
|
|
|
|
|
"api_key_env": "MOONSHOT_API_KEY",
|
2026-04-26 15:03:20 +08:00
|
|
|
|
"models_endpoint": "https://api.moonshot.cn/v1/models",
|
2026-04-25 23:58:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"provider_id": "moonshotai",
|
|
|
|
|
|
"display_name": "Moonshot (International)",
|
|
|
|
|
|
"base_url": "https://api.moonshot.ai/v1",
|
|
|
|
|
|
"api_key_env": "MOONSHOT_API_KEY",
|
2026-04-26 15:03:20 +08:00
|
|
|
|
"models_endpoint": "https://api.moonshot.ai/v1/models",
|
2026-04-25 23:58:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"provider_id": "minimax-cn",
|
|
|
|
|
|
"display_name": "MiniMax",
|
2026-04-26 15:03:20 +08:00
|
|
|
|
"base_url": "https://api.minimaxi.com/v1",
|
2026-04-25 23:58:01 +08:00
|
|
|
|
"api_key_env": "MINIMAX_API_KEY",
|
2026-04-26 15:03:20 +08:00
|
|
|
|
"models_endpoint": "https://api.minimaxi.com/v1/models",
|
2026-04-25 23:58:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"provider_id": "minimax",
|
|
|
|
|
|
"display_name": "MiniMax (International)",
|
2026-04-26 15:03:20 +08:00
|
|
|
|
"base_url": "https://api.minimax.io/v1",
|
2026-04-25 23:58:01 +08:00
|
|
|
|
"api_key_env": "MINIMAX_API_KEY",
|
2026-04-26 15:03:20 +08:00
|
|
|
|
"models_endpoint": "https://api.minimax.io/v1/models",
|
2026-04-25 23:58:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"provider_id": "openrouter",
|
|
|
|
|
|
"display_name": "OpenRouter",
|
|
|
|
|
|
"base_url": "https://openrouter.ai/api/v1",
|
|
|
|
|
|
"api_key_env": "OPENROUTER_API_KEY",
|
2026-04-26 15:03:20 +08:00
|
|
|
|
"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",
|
2026-04-25 23:58:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"provider_id": "modelscope",
|
|
|
|
|
|
"display_name": "ModelScope",
|
|
|
|
|
|
"base_url": "https://api-inference.modelscope.cn/v1",
|
|
|
|
|
|
"api_key_env": "MODELSCOPE_ACCESS_TOKEN",
|
2026-04-26 15:03:20 +08:00
|
|
|
|
"models_endpoint": "https://api-inference.modelscope.cn/v1/models",
|
2026-04-25 23:58:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"provider_id": "opencode",
|
|
|
|
|
|
"display_name": "OpenCode",
|
|
|
|
|
|
"base_url": "https://opencode.ai/zen/v1",
|
2026-04-26 15:03:20 +08:00
|
|
|
|
"models_endpoint": "https://opencode.ai/zen/v1/models",
|
2026-04-25 23:58:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"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"],
|
2026-04-26 15:03:20 +08:00
|
|
|
|
"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",
|
2026-04-25 23:58:01 +08:00
|
|
|
|
"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"],
|
2026-04-26 15:03:20 +08:00
|
|
|
|
"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",
|
2026-04-25 23:58:01 +08:00
|
|
|
|
"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:
|
|
|
|
|
|
return value if isinstance(value, list) else []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_dict(value: Any) -> dict:
|
|
|
|
|
|
return value if isinstance(value, dict) else {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _validate_provider_id(provider_id: str) -> None:
|
|
|
|
|
|
if not _PROVIDER_ID_RE.match(provider_id):
|
|
|
|
|
|
raise ValueError("provider_id 只能包含字母、数字、下划线和中划线,长度 2-100")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_model_item(model: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
|
"""规范化模型配置对象,校验运行所需字段。"""
|
|
|
|
|
|
model_id = str(model.get("id") or "").strip()
|
|
|
|
|
|
if not model_id:
|
|
|
|
|
|
raise ValueError("模型 id 不能为空")
|
|
|
|
|
|
|
|
|
|
|
|
model_type = str(model.get("type") or "unknown").strip()
|
|
|
|
|
|
if model_type not in VALID_MODEL_TYPES:
|
|
|
|
|
|
raise ValueError(f"启用模型 {model_id} 的 type 必须是 chat、embedding 或 rerank")
|
|
|
|
|
|
|
2026-04-26 15:03:20 +08:00
|
|
|
|
# source 区分手动添加 vs 远端拉取,用于跳过远端清单存在性的视觉警告。
|
|
|
|
|
|
source = str(model.get("source") or "remote").strip()
|
|
|
|
|
|
if source not in VALID_MODEL_SOURCES:
|
|
|
|
|
|
raise ValueError(f"模型 {model_id} 的 source 必须是 manual 或 remote")
|
|
|
|
|
|
|
2026-04-25 23:58:01 +08:00
|
|
|
|
normalized = dict(model)
|
|
|
|
|
|
normalized["id"] = model_id
|
|
|
|
|
|
normalized["type"] = model_type
|
2026-04-26 15:03:20 +08:00
|
|
|
|
normalized["source"] = source
|
2026-04-25 23:58:01 +08:00
|
|
|
|
normalized["display_name"] = str(model.get("display_name") or model.get("name") or model_id)
|
|
|
|
|
|
normalized["extra"] = _normalize_dict(model.get("extra"))
|
|
|
|
|
|
|
|
|
|
|
|
if model_type == "embedding":
|
|
|
|
|
|
dimension = normalized.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"])
|
|
|
|
|
|
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_model_list(models: Any) -> list[dict[str, Any]]:
|
|
|
|
|
|
normalized_models: list[dict[str, Any]] = []
|
|
|
|
|
|
seen_ids: set[str] = set()
|
|
|
|
|
|
for item in _normalize_list(models):
|
|
|
|
|
|
if not isinstance(item, dict):
|
|
|
|
|
|
raise ValueError("模型配置必须是对象列表")
|
|
|
|
|
|
normalized = _normalize_model_item(item)
|
|
|
|
|
|
if normalized["id"] in seen_ids:
|
|
|
|
|
|
raise ValueError(f"模型 id 重复: {normalized['id']}")
|
|
|
|
|
|
seen_ids.add(normalized["id"])
|
|
|
|
|
|
normalized_models.append(normalized)
|
|
|
|
|
|
return normalized_models
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-26 15:03:20 +08:00
|
|
|
|
def _validate_models_capabilities(enabled_models: list[dict], capabilities: set[str]) -> None:
|
|
|
|
|
|
"""校验 enabled_models 中所有模型的 type 都在 provider capabilities 范围内。"""
|
|
|
|
|
|
for model in enabled_models or []:
|
|
|
|
|
|
if model["type"] not in capabilities:
|
2026-04-26 18:51:38 +08:00
|
|
|
|
raise ValueError(f"模型 {model['id']} 的 type={model['type']} 不在 provider 能力 {sorted(capabilities)} 内")
|
2026-04-26 15:03:20 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-25 23:58:01 +08:00
|
|
|
|
def _normalize_payload(data: dict[str, Any], *, partial: bool = False) -> dict[str, Any]:
|
|
|
|
|
|
payload = dict(data)
|
|
|
|
|
|
if not partial or "provider_id" in payload:
|
|
|
|
|
|
provider_id = str(payload.get("provider_id") or "").strip()
|
|
|
|
|
|
_validate_provider_id(provider_id)
|
|
|
|
|
|
payload["provider_id"] = provider_id
|
|
|
|
|
|
|
|
|
|
|
|
if not partial or "display_name" in payload:
|
|
|
|
|
|
display_name = str(payload.get("display_name") or "").strip()
|
|
|
|
|
|
if not display_name:
|
|
|
|
|
|
raise ValueError("display_name 不能为空")
|
|
|
|
|
|
payload["display_name"] = display_name
|
|
|
|
|
|
|
|
|
|
|
|
if not partial or "base_url" in payload:
|
|
|
|
|
|
base_url = str(payload.get("base_url") or "").strip()
|
|
|
|
|
|
if not base_url:
|
|
|
|
|
|
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),
|
|
|
|
|
|
):
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
provider_type = payload.get("provider_type")
|
|
|
|
|
|
if provider_type is None and not partial:
|
|
|
|
|
|
payload["provider_type"] = "openai"
|
|
|
|
|
|
elif provider_type is not None:
|
|
|
|
|
|
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()
|
2026-04-26 15:03:20 +08:00
|
|
|
|
if embedding_endpoint and not embedding_endpoint.startswith(("http://", "https://")):
|
|
|
|
|
|
raise ValueError("embedding_models_endpoint 必须是完整的 HTTP URL")
|
2026-04-25 23:58:01 +08:00
|
|
|
|
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()
|
2026-04-26 15:03:20 +08:00
|
|
|
|
if rerank_endpoint and not rerank_endpoint.startswith(("http://", "https://")):
|
|
|
|
|
|
raise ValueError("rerank_models_endpoint 必须是完整的 HTTP URL")
|
2026-04-25 23:58:01 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-04-26 15:03:20 +08:00
|
|
|
|
# 仅当本次 payload 同时携带 capabilities 与 enabled_models 时做一致性校验,
|
|
|
|
|
|
# 防止前端把超出 provider.capabilities 的模型 type 写入。
|
|
|
|
|
|
# partial 模式下若只更新其中一项,跳过校验避免误判(DB 已有值不可见)。
|
|
|
|
|
|
if "capabilities" in payload and "enabled_models" in payload:
|
|
|
|
|
|
capabilities_set = set(payload.get("capabilities") or [])
|
|
|
|
|
|
if capabilities_set:
|
|
|
|
|
|
_validate_models_capabilities(payload.get("enabled_models"), capabilities_set)
|
|
|
|
|
|
|
2026-04-25 23:58:01 +08:00
|
|
|
|
return payload
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_api_key(provider: ModelProvider) -> str | None:
|
|
|
|
|
|
if provider.api_key:
|
|
|
|
|
|
return provider.api_key
|
|
|
|
|
|
if provider.api_key_env:
|
|
|
|
|
|
return os.getenv(provider.api_key_env)
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _check_credential_status(provider: ModelProvider) -> str:
|
|
|
|
|
|
"""检查 provider 的凭证配置状态。仅对启用的 provider 做校验。"""
|
|
|
|
|
|
if not provider.is_enabled:
|
|
|
|
|
|
return "ok"
|
|
|
|
|
|
if provider.api_key:
|
|
|
|
|
|
return "ok"
|
|
|
|
|
|
if provider.api_key_env:
|
|
|
|
|
|
return "ok" if os.getenv(provider.api_key_env) else "warning"
|
|
|
|
|
|
return "warning"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _models_url(base_url: str, endpoint: str | None = DEFAULT_MODELS_ENDPOINT) -> str:
|
|
|
|
|
|
base = base_url.rstrip("/")
|
|
|
|
|
|
if not endpoint:
|
|
|
|
|
|
return base
|
|
|
|
|
|
endpoint = endpoint.strip()
|
|
|
|
|
|
if endpoint.startswith(("http://", "https://")):
|
|
|
|
|
|
return endpoint
|
|
|
|
|
|
return f"{base}/{endpoint.lstrip('/')}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_remote_model(raw_model: dict[str, Any], model_type: str = "chat") -> dict[str, Any]:
|
|
|
|
|
|
model_id = str(raw_model.get("id") or "").strip()
|
|
|
|
|
|
if not model_id:
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
architecture = _normalize_dict(raw_model.get("architecture"))
|
|
|
|
|
|
top_provider = _normalize_dict(raw_model.get("top_provider"))
|
|
|
|
|
|
raw_type = raw_model.get("type")
|
|
|
|
|
|
normalized_type = raw_type if raw_type in VALID_MODEL_TYPES else model_type
|
|
|
|
|
|
normalized = {
|
|
|
|
|
|
"id": model_id,
|
|
|
|
|
|
"object": raw_model.get("object"),
|
|
|
|
|
|
"created": raw_model.get("created"),
|
|
|
|
|
|
"owned_by": raw_model.get("owned_by"),
|
|
|
|
|
|
"type": normalized_type,
|
|
|
|
|
|
"display_name": raw_model.get("name") or model_id,
|
|
|
|
|
|
"description": raw_model.get("description"),
|
|
|
|
|
|
"context_length": raw_model.get("context_length") or top_provider.get("context_length"),
|
|
|
|
|
|
"max_completion_tokens": top_provider.get("max_completion_tokens"),
|
|
|
|
|
|
"input_modalities": architecture.get("input_modalities") or [],
|
|
|
|
|
|
"output_modalities": architecture.get("output_modalities") or [],
|
|
|
|
|
|
"supported_parameters": raw_model.get("supported_parameters") or [],
|
|
|
|
|
|
"pricing": raw_model.get("pricing") or {},
|
|
|
|
|
|
"default_parameters": raw_model.get("default_parameters") or {},
|
|
|
|
|
|
"raw_metadata": raw_model,
|
|
|
|
|
|
"extra": {},
|
|
|
|
|
|
}
|
|
|
|
|
|
return {key: value for key, value in normalized.items() if value is not None}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_all_model_providers(db: AsyncSession) -> list[ModelProvider]:
|
|
|
|
|
|
"""获取全部独立模型供应商配置。"""
|
|
|
|
|
|
return await list_model_providers(db)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_model_provider_by_id(db: AsyncSession, provider_id: str) -> ModelProvider | None:
|
|
|
|
|
|
"""按 provider_id 获取独立模型供应商配置。"""
|
|
|
|
|
|
return await get_model_provider(db, provider_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def ensure_builtin_model_providers_in_db(db: AsyncSession) -> None:
|
|
|
|
|
|
"""确保独立模型配置模块的内置 provider 模板存在。
|
|
|
|
|
|
|
|
|
|
|
|
这里只补不存在的内置 provider,不覆盖管理员已编辑的配置。
|
|
|
|
|
|
"""
|
|
|
|
|
|
existing = await list_model_providers(db)
|
|
|
|
|
|
existing_ids = {p.provider_id: p for p in existing}
|
|
|
|
|
|
|
|
|
|
|
|
for provider_def in _DEFAULT_BUILTIN_PROVIDERS:
|
|
|
|
|
|
provider_id = provider_def["provider_id"]
|
|
|
|
|
|
existing_provider = existing_ids.get(provider_id)
|
|
|
|
|
|
if existing_provider:
|
|
|
|
|
|
if not existing_provider.enabled_models and provider_def.get("enabled_models"):
|
|
|
|
|
|
existing_provider.enabled_models = _normalize_model_list(provider_def["enabled_models"])
|
|
|
|
|
|
existing_provider.capabilities = provider_def.get("capabilities") or existing_provider.capabilities
|
|
|
|
|
|
existing_provider.updated_by = "system"
|
|
|
|
|
|
await db.flush()
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
payload = {key: value for key, value in provider_def.items() if value is not None}
|
|
|
|
|
|
payload["enabled_models"] = provider_def.get("enabled_models", [])
|
|
|
|
|
|
payload["headers_json"] = payload.get("headers_json") or {}
|
|
|
|
|
|
payload["extra_json"] = payload.get("extra_json") or {}
|
2026-04-26 18:51:38 +08:00
|
|
|
|
payload["is_enabled"] = provider_id == "siliconflow-cn"
|
2026-04-25 23:58:01 +08:00
|
|
|
|
payload["is_builtin"] = True
|
|
|
|
|
|
payload["created_by"] = "system"
|
|
|
|
|
|
payload["updated_by"] = "system"
|
|
|
|
|
|
await create_model_provider(db, _normalize_payload(payload))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def create_provider_config(db: AsyncSession, data: dict[str, Any], username: str) -> ModelProvider:
|
|
|
|
|
|
"""创建独立模型供应商配置。"""
|
|
|
|
|
|
payload = _normalize_payload(data)
|
|
|
|
|
|
if await get_model_provider(db, payload["provider_id"]):
|
|
|
|
|
|
raise ValueError(f"供应商 {payload['provider_id']} 已存在")
|
|
|
|
|
|
payload["created_by"] = username
|
|
|
|
|
|
payload["updated_by"] = username
|
|
|
|
|
|
return await create_model_provider(db, payload)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def update_provider_config(
|
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
|
provider_id: str,
|
|
|
|
|
|
data: dict[str, Any],
|
|
|
|
|
|
username: str,
|
|
|
|
|
|
) -> ModelProvider | None:
|
|
|
|
|
|
"""更新独立模型供应商配置。"""
|
|
|
|
|
|
provider = await get_model_provider(db, provider_id)
|
|
|
|
|
|
if provider is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
payload = _normalize_payload(data, partial=True)
|
2026-04-26 15:03:20 +08:00
|
|
|
|
# partial 更新时仅传 enabled_models,结合 DB 中现有 capabilities 校验
|
|
|
|
|
|
if "enabled_models" in payload and "capabilities" not in payload:
|
|
|
|
|
|
existing_caps = set(provider.capabilities or [])
|
|
|
|
|
|
if existing_caps:
|
|
|
|
|
|
_validate_models_capabilities(payload.get("enabled_models"), existing_caps)
|
2026-04-25 23:58:01 +08:00
|
|
|
|
payload["updated_by"] = username
|
|
|
|
|
|
return await update_model_provider(db, provider, payload)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def delete_provider_config(db: AsyncSession, provider_id: str) -> bool:
|
|
|
|
|
|
"""删除独立模型供应商配置。"""
|
|
|
|
|
|
provider = await get_model_provider(db, provider_id)
|
|
|
|
|
|
if provider is None:
|
|
|
|
|
|
return False
|
|
|
|
|
|
await delete_model_provider(db, provider)
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _fetch_models_from_endpoint(
|
|
|
|
|
|
client: httpx.AsyncClient,
|
|
|
|
|
|
provider: ModelProvider,
|
|
|
|
|
|
headers: dict[str, str],
|
|
|
|
|
|
endpoint: str | None,
|
|
|
|
|
|
model_type: str,
|
|
|
|
|
|
) -> list[dict[str, Any]]:
|
|
|
|
|
|
"""按单个模型类型端点拉取并规范化远端模型列表。"""
|
|
|
|
|
|
if not endpoint:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
response = await client.get(_models_url(provider.base_url, endpoint), headers=headers)
|
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
|
payload = response.json()
|
|
|
|
|
|
|
|
|
|
|
|
raw_models = payload.get("data") if isinstance(payload, dict) else payload
|
|
|
|
|
|
if not isinstance(raw_models, list):
|
|
|
|
|
|
raise ValueError(f"{endpoint} 响应必须是列表或包含 data 列表")
|
|
|
|
|
|
|
|
|
|
|
|
models = []
|
|
|
|
|
|
for raw_model in raw_models:
|
|
|
|
|
|
if isinstance(raw_model, dict):
|
|
|
|
|
|
normalized = _normalize_remote_model(raw_model, model_type)
|
|
|
|
|
|
if normalized:
|
|
|
|
|
|
models.append(normalized)
|
|
|
|
|
|
return models
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def fetch_remote_models(provider: ModelProvider) -> list[dict[str, Any]]:
|
|
|
|
|
|
"""按 provider 配置实时拉取远端模型列表,不落库。
|
|
|
|
|
|
|
|
|
|
|
|
Chat 模型默认走 /models;embedding 只有 provider 声明能力时才走
|
|
|
|
|
|
/embeddings/models;rerank 供应商没有稳定通用端点,配置了 endpoint 才拉取。
|
|
|
|
|
|
"""
|
|
|
|
|
|
headers = dict(provider.headers_json or {})
|
|
|
|
|
|
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"),
|
|
|
|
|
|
]
|
|
|
|
|
|
if "embedding" in capabilities:
|
|
|
|
|
|
endpoint_specs.append((provider.embedding_models_endpoint or DEFAULT_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:
|
|
|
|
|
|
results = await asyncio.gather(
|
|
|
|
|
|
*[
|
|
|
|
|
|
_fetch_models_from_endpoint(client, provider, headers, endpoint, model_type)
|
|
|
|
|
|
for endpoint, model_type in endpoint_specs
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
for fetched_models in results:
|
|
|
|
|
|
for model in fetched_models:
|
|
|
|
|
|
model_key = (model["id"], model["type"])
|
|
|
|
|
|
if model_key in seen_ids:
|
|
|
|
|
|
continue
|
|
|
|
|
|
seen_ids.add(model_key)
|
|
|
|
|
|
models.append(model)
|
|
|
|
|
|
return models
|