feat(model): 增强模型配置功能,支持手动添加模型及源字段,优化远端模型加载逻辑
This commit is contained in:
parent
129f8860c9
commit
db13abaa63
@ -17,10 +17,11 @@ from yuxi.repositories.model_provider_repository import (
|
||||
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 = "/models"
|
||||
DEFAULT_EMBEDDING_MODELS_ENDPOINT = "/embeddings/models"
|
||||
DEFAULT_MODELS_ENDPOINT = ""
|
||||
DEFAULT_EMBEDDING_MODELS_ENDPOINT = ""
|
||||
|
||||
_DEFAULT_BUILTIN_PROVIDERS: list[dict[str, Any]] = [
|
||||
{
|
||||
@ -28,117 +29,140 @@ _DEFAULT_BUILTIN_PROVIDERS: list[dict[str, Any]] = [
|
||||
"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/anthropic",
|
||||
"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/anthropic",
|
||||
"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",
|
||||
@ -148,9 +172,9 @@ _DEFAULT_BUILTIN_PROVIDERS: list[dict[str, Any]] = [
|
||||
"rerank_base_url": "https://api.siliconflow.cn/v1/rerank",
|
||||
"api_key_env": "SILICONFLOW_API_KEY",
|
||||
"capabilities": ["chat", "embedding", "rerank"],
|
||||
"models_endpoint": "/models?sub_type=chat",
|
||||
"embedding_models_endpoint": "/models?sub_type=embedding",
|
||||
"rerank_models_endpoint": "/models?sub_type=reranker",
|
||||
"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"},
|
||||
@ -195,9 +219,9 @@ _DEFAULT_BUILTIN_PROVIDERS: list[dict[str, Any]] = [
|
||||
"rerank_base_url": "https://api.siliconflow.com/v1/rerank",
|
||||
"api_key_env": "SILICONFLOW_API_KEY",
|
||||
"capabilities": ["chat", "embedding", "rerank"],
|
||||
"models_endpoint": "/models?sub_type=chat",
|
||||
"embedding_models_endpoint": "/models?sub_type=embedding",
|
||||
"rerank_models_endpoint": "/models?sub_type=reranker",
|
||||
"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"},
|
||||
@ -260,9 +284,15 @@ def _normalize_model_item(model: dict[str, Any]) -> dict[str, Any]:
|
||||
if model_type not in VALID_MODEL_TYPES:
|
||||
raise ValueError(f"启用模型 {model_id} 的 type 必须是 chat、embedding 或 rerank")
|
||||
|
||||
# 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")
|
||||
|
||||
normalized = dict(model)
|
||||
normalized["id"] = model_id
|
||||
normalized["type"] = model_type
|
||||
normalized["source"] = source
|
||||
normalized["display_name"] = str(model.get("display_name") or model.get("name") or model_id)
|
||||
normalized["extra"] = _normalize_dict(model.get("extra"))
|
||||
|
||||
@ -290,6 +320,15 @@ def _normalize_model_list(models: Any) -> list[dict[str, Any]]:
|
||||
return normalized_models
|
||||
|
||||
|
||||
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:
|
||||
raise ValueError(
|
||||
f"模型 {model['id']} 的 type={model['type']} 不在 provider 能力 {sorted(capabilities)} 内"
|
||||
)
|
||||
|
||||
|
||||
def _normalize_payload(data: dict[str, Any], *, partial: bool = False) -> dict[str, Any]:
|
||||
payload = dict(data)
|
||||
if not partial or "provider_id" in payload:
|
||||
@ -339,8 +378,8 @@ def _normalize_payload(data: dict[str, Any], *, partial: bool = False) -> dict[s
|
||||
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 not embedding_endpoint:
|
||||
raise ValueError("embedding provider 必须配置 embedding_models_endpoint")
|
||||
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()
|
||||
@ -348,8 +387,8 @@ def _normalize_payload(data: dict[str, Any], *, partial: bool = False) -> dict[s
|
||||
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 not rerank_endpoint:
|
||||
raise ValueError("rerank provider 必须配置 rerank_models_endpoint")
|
||||
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:
|
||||
@ -377,6 +416,14 @@ def _normalize_payload(data: dict[str, Any], *, partial: bool = False) -> dict[s
|
||||
elif not partial:
|
||||
payload["is_builtin"] = False
|
||||
|
||||
# 仅当本次 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)
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
@ -470,10 +517,6 @@ async def ensure_builtin_model_providers_in_db(db: AsyncSession) -> None:
|
||||
|
||||
payload = {key: value for key, value in provider_def.items() if value is not None}
|
||||
payload["enabled_models"] = provider_def.get("enabled_models", [])
|
||||
payload["models_endpoint"] = payload.get("models_endpoint") or DEFAULT_MODELS_ENDPOINT
|
||||
payload["embedding_models_endpoint"] = (
|
||||
payload.get("embedding_models_endpoint") or DEFAULT_EMBEDDING_MODELS_ENDPOINT
|
||||
)
|
||||
payload["headers_json"] = payload.get("headers_json") or {}
|
||||
payload["extra_json"] = payload.get("extra_json") or {}
|
||||
payload["is_enabled"] = False
|
||||
@ -504,6 +547,11 @@ async def update_provider_config(
|
||||
if provider is None:
|
||||
return None
|
||||
payload = _normalize_payload(data, partial=True)
|
||||
# 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)
|
||||
payload["updated_by"] = username
|
||||
return await update_model_provider(db, provider, payload)
|
||||
|
||||
|
||||
@ -180,12 +180,21 @@ async def get_remote_models(
|
||||
detail = e.response.text
|
||||
if e.response.status_code == 401:
|
||||
raise HTTPException(status_code=502, detail="远端 API 认证失败,请检查 API Key 配置")
|
||||
raise HTTPException(status_code=e.response.status_code, detail=f"远端 /models 请求失败: {detail}")
|
||||
raise HTTPException(status_code=e.response.status_code, detail=f"Models 请求失败: {detail}")
|
||||
except Exception as e:
|
||||
logger.error(f"拉取远端模型失败 {provider_id}: {e}")
|
||||
raise HTTPException(status_code=400, detail=f"拉取远端模型失败: {e}")
|
||||
|
||||
|
||||
@model_providers.post("/models/cache/refresh")
|
||||
async def refresh_model_cache(
|
||||
current_user: User = Depends(get_admin_user),
|
||||
):
|
||||
"""强制刷新模型缓存,从数据库重新加载所有供应商配置到 Redis。"""
|
||||
await _refresh_model_cache()
|
||||
return {"success": True, "message": "缓存已刷新", "model_count": len(model_cache.get_all_specs())}
|
||||
|
||||
|
||||
@model_providers.get("/models/v2")
|
||||
async def get_v2_models(
|
||||
model_type: str = "chat",
|
||||
|
||||
@ -194,3 +194,96 @@ def test_check_credential_status_both_empty_warning():
|
||||
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"]
|
||||
|
||||
@ -34,7 +34,7 @@
|
||||
<!-- 0.6.2 的内容请放在这里 -->
|
||||
- 调整应用主导航:`AppLayout` 从默认窄栏升级为默认展开的侧边栏,保留折叠态图标导航;侧边栏样式收敛为 14px 文本 + 18px 图标的标准紧凑密度,并统一导航项、任务中心、GitHub、用户信息的图标与文字对齐。折叠态改为仅通过显式按钮展开,避免空白区域误触发。
|
||||
- 合并智能体对话导航:移除 `AgentChatComponent` 内部聊天侧边栏,将新建对话入口和对话历史移动到 `AppLayout` 主侧边栏,并通过共享线程 store 统一管理历史列表、当前线程、重命名、删除、置顶和分页加载。
|
||||
- 新增独立模型配置模块:增加 `model_providers` 表、独立管理接口和”模型配置”页面,支持 provider 基础信息、可配置模型列表端点、远端候选模型、`enabled_models` 的早期配置验证;启动时会补齐内置 provider 模板,`provider_type` 暂统一默认为 `openai`,该模块暂不接入现有运行时模型选择逻辑。远端模型加载默认使用 `/models` 获取 chat/通用模型,provider 声明 `embedding` 能力时使用 `/embeddings/models` 获取 embedding 候选,rerank 模型列表端点按供应商文档显式配置后加载;修复路由请求模型未接收 `embedding_base_url`/`rerank_base_url` 导致前端已填写仍被后端校验拦截的问题。
|
||||
- 新增独立模型配置模块:增加 `model_providers` 表、独立管理接口和”模型配置”页面,支持 provider 基础信息、可配置模型列表端点、远端候选模型、`enabled_models` 的早期配置验证;启动时会补齐内置 provider 模板,`provider_type` 暂统一默认为 `openai`,该模块暂不接入现有运行时模型选择逻辑。远端模型加载默认使用 `/models` 获取 chat/通用模型,provider 声明 `embedding` 能力时使用 `/embeddings/models` 获取 embedding 候选,rerank 模型列表端点按供应商文档显式配置后加载;修复路由请求模型未接收 `embedding_base_url`/`rerank_base_url` 导致前端已填写仍被后端校验拦截的问题。补充手动添加模型能力:`enabled_models[i]` 新增可选 `source: "manual"|"remote"` 字段(默认 `remote`),管理员可通过”+ 手动添加”入口录入远端清单未覆盖的模型(典型:自部署 embedding/rerank),手动模型在前端跳过”远端不存在”的 stale 警告并显示「手动」标签;type 选项受 `provider.capabilities` 约束,后端在 `_normalize_payload` 与 `update_provider_config` 双层一致性校验中拦截越权写入。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -165,6 +165,10 @@ export const modelProviderApi = {
|
||||
return apiAdminGet('/api/system/model-providers/models/cache-status')
|
||||
},
|
||||
|
||||
refreshModelCache: async () => {
|
||||
return apiAdminPost('/api/system/model-providers/models/cache/refresh')
|
||||
},
|
||||
|
||||
getModelStatusBySpec: async (spec) => {
|
||||
return apiAdminGet(`/api/system/model-providers/models/status?spec=${encodeURIComponent(spec)}`)
|
||||
},
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<a-dropdown trigger="click">
|
||||
<a-dropdown trigger="click" @open-change="handleOpenChange">
|
||||
<div class="model-select" :class="modelSelectClasses" @click.prevent>
|
||||
<div class="model-select-content">
|
||||
<div class="model-info">
|
||||
@ -16,6 +16,19 @@
|
||||
>
|
||||
{{ modelStatusIcon }}
|
||||
</span>
|
||||
<a-tooltip title="刷新缓存">
|
||||
<a-button
|
||||
type="text"
|
||||
:loading="state.refreshingCache"
|
||||
@click.stop="refreshCache"
|
||||
:disabled="state.refreshingCache"
|
||||
class="cache-refresh-button"
|
||||
>
|
||||
<template #icon>
|
||||
<RefreshCw :size="13" :class="{ 'spin': state.refreshingCache }" />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-button
|
||||
:size="buttonSize"
|
||||
type="text"
|
||||
@ -66,9 +79,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, onMounted } from 'vue'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { modelProviderApi } from '@/apis/system_api'
|
||||
import { RefreshCw } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps({
|
||||
model_spec: {
|
||||
@ -89,11 +103,14 @@ const props = defineProps({
|
||||
const configStore = useConfigStore()
|
||||
const emit = defineEmits(['select-model'])
|
||||
|
||||
// v2 模型数据
|
||||
// v2 模型数据:每次展开下拉时实时从后端拉取
|
||||
const v2Models = ref({})
|
||||
const loadingV2Models = ref(false)
|
||||
|
||||
// 加载 v2 模型列表
|
||||
onMounted(async () => {
|
||||
// 拉取 v2 模型列表
|
||||
const fetchV2Models = async () => {
|
||||
if (loadingV2Models.value) return
|
||||
loadingV2Models.value = true
|
||||
try {
|
||||
const response = await modelProviderApi.getV2Models('chat')
|
||||
if (response.success) {
|
||||
@ -101,13 +118,36 @@ onMounted(async () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load v2 models:', error)
|
||||
} finally {
|
||||
loadingV2Models.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 下拉展开时触发实时刷新(仅在打开瞬间触发,关闭时忽略)
|
||||
const handleOpenChange = (open) => {
|
||||
if (open) fetchV2Models()
|
||||
}
|
||||
|
||||
// 强制刷新缓存
|
||||
const refreshCache = async () => {
|
||||
if (state.refreshingCache) return
|
||||
state.refreshingCache = true
|
||||
try {
|
||||
await modelProviderApi.refreshModelCache()
|
||||
// 刷新后重新拉取模型列表
|
||||
await fetchV2Models()
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh cache:', error)
|
||||
} finally {
|
||||
state.refreshingCache = false
|
||||
}
|
||||
}
|
||||
|
||||
// 状态管理
|
||||
const state = reactive({
|
||||
currentModelStatus: null, // 当前模型状态
|
||||
checkingStatus: false // 是否正在检查状态
|
||||
checkingStatus: false, // 是否正在检查状态
|
||||
refreshingCache: false // 是否正在刷新缓存
|
||||
})
|
||||
|
||||
// 从configStore中获取所需数据
|
||||
@ -291,10 +331,34 @@ const handleSelectV2Model = (spec) => {
|
||||
font-size: @status-check-button-font-size;
|
||||
padding: @status-check-button-padding;
|
||||
}
|
||||
|
||||
// 缓存刷新按钮
|
||||
.cache-refresh-button {
|
||||
font-size: @status-check-button-font-size;
|
||||
padding: @status-check-button-padding;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 24px;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新图标旋转动画
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Provider 标签样式
|
||||
.provider-tag {
|
||||
margin-left: 6px;
|
||||
|
||||
@ -46,10 +46,12 @@ const providerForm = reactive({
|
||||
|
||||
// Model form state
|
||||
const showModelModal = ref(false)
|
||||
const isCreating = ref(false) // true=手动添加新模型,false=编辑已有模型
|
||||
const editingModel = reactive({
|
||||
id: '',
|
||||
display_name: '',
|
||||
type: 'chat',
|
||||
source: 'remote', // 'manual'|'remote':区分手动添加,控制 stale 视觉警告跳过
|
||||
protocol_override: null,
|
||||
base_url_override: null,
|
||||
context_length: null,
|
||||
@ -92,12 +94,17 @@ const filteredProviders = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const providerStats = computed(() => ({
|
||||
total: providers.value.length,
|
||||
enabled: providers.value.filter((p) => p.is_enabled).length,
|
||||
warning: providers.value.filter((p) => p.is_enabled && p.credential_status === 'warning').length,
|
||||
models: providers.value.reduce((sum, p) => sum + (p.enabled_models?.length || 0), 0)
|
||||
}))
|
||||
const providerStats = computed(() => {
|
||||
let enabled = 0, warning = 0, models = 0
|
||||
for (const p of providers.value) {
|
||||
if (p.is_enabled) {
|
||||
enabled++
|
||||
if (p.credential_status === 'warning') warning++
|
||||
}
|
||||
models += p.enabled_models?.length || 0
|
||||
}
|
||||
return { total: providers.value.length, enabled, warning, models }
|
||||
})
|
||||
|
||||
// ============ Helpers ============
|
||||
const getProviderIcon = (provider) => {
|
||||
@ -155,11 +162,18 @@ const getInputModalities = (model) => {
|
||||
return []
|
||||
}
|
||||
|
||||
const remoteIdsMap = computed(() => {
|
||||
const map = {}
|
||||
for (const [providerId, models] of Object.entries(remoteModelsMap.value)) {
|
||||
map[providerId] = new Set(models.map((m) => m.id))
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const isModelStale = (model, providerId) => {
|
||||
// Only check stale if remote models have been loaded
|
||||
if (model.source === 'manual') return false
|
||||
if (!remoteModelsLoaded.value[providerId]) return false
|
||||
const remoteIds = new Set((remoteModelsMap.value[providerId] || []).map((m) => m.id))
|
||||
return model.enabled && !remoteIds.has(model.id)
|
||||
return model.enabled && !remoteIdsMap.value[providerId]?.has(model.id)
|
||||
}
|
||||
|
||||
// Remote models filtered by search query per provider
|
||||
@ -193,6 +207,14 @@ const remoteModelTypeOptions = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
// Model Config Modal 的 type 下拉选项:基于 provider.capabilities 限定
|
||||
// 旧数据 capabilities 为空时回退到全集,保持现状
|
||||
const editingModelTypeOptions = computed(() => {
|
||||
const caps = currentProviderForModels.value?.capabilities
|
||||
const types = Array.isArray(caps) && caps.length ? caps : ['chat', 'embedding', 'rerank']
|
||||
return types.map((c) => ({ value: c, label: c }))
|
||||
})
|
||||
|
||||
const parseJsonObject = (text, label) => {
|
||||
try {
|
||||
const parsed = JSON.parse(text || '{}')
|
||||
@ -353,7 +375,8 @@ const openModelsModal = (provider) => {
|
||||
if (!remoteModelsLoaded.value[provider.provider_id]) {
|
||||
remoteModelsMap.value[provider.provider_id] = []
|
||||
}
|
||||
remoteModelSearch.value[provider.provider_id] = remoteModelSearch.value[provider.provider_id] || ''
|
||||
remoteModelSearch.value[provider.provider_id] =
|
||||
remoteModelSearch.value[provider.provider_id] || ''
|
||||
remoteModelTypeFilter.value[provider.provider_id] = 'all'
|
||||
showModelsModal.value = true
|
||||
}
|
||||
@ -381,6 +404,7 @@ const normalizeModel = (model = {}) => ({
|
||||
id: model.id || '',
|
||||
display_name: model.display_name || model.name || model.id || '',
|
||||
type: model.type && model.type !== 'unknown' ? model.type : 'chat',
|
||||
source: model.source || 'remote',
|
||||
protocol_override: model.protocol_override || null,
|
||||
base_url_override: model.base_url_override || null,
|
||||
context_length: model.context_length || null,
|
||||
@ -401,6 +425,7 @@ const addModelFromRemote = async (providerId, remoteModel) => {
|
||||
}
|
||||
|
||||
const newModel = normalizeModel(remoteModel)
|
||||
newModel.source = 'remote' // 远端拉取的模型显式标注,避免后续被误判为旧数据
|
||||
newModel.enabled = true
|
||||
const newEnabledModels = [...enabledModels, newModel]
|
||||
|
||||
@ -418,7 +443,30 @@ const addModelFromRemote = async (providerId, remoteModel) => {
|
||||
}
|
||||
|
||||
const openModelConfigModal = (model) => {
|
||||
Object.assign(editingModel, { ...model })
|
||||
Object.assign(editingModel, normalizeModel(model))
|
||||
isCreating.value = false
|
||||
showModelModal.value = true
|
||||
}
|
||||
|
||||
// 手动添加模型:弹出与编辑共用的 Model Config Modal,但 id 字段可编辑、type 选项受 provider 能力约束
|
||||
const openCreateModal = (provider) => {
|
||||
if (!provider) return
|
||||
const types = provider.capabilities?.length ? provider.capabilities : ['chat']
|
||||
const defaultType = types[0]
|
||||
Object.assign(editingModel, {
|
||||
id: '',
|
||||
display_name: '',
|
||||
type: defaultType,
|
||||
source: 'manual',
|
||||
protocol_override: null,
|
||||
base_url_override: null,
|
||||
context_length: null,
|
||||
dimension: null,
|
||||
batch_size: null,
|
||||
supported_parameters: [],
|
||||
extra: {}
|
||||
})
|
||||
isCreating.value = true
|
||||
showModelModal.value = true
|
||||
}
|
||||
|
||||
@ -431,15 +479,31 @@ const saveModelConfig = async () => {
|
||||
)
|
||||
if (!provider) return
|
||||
|
||||
const enabledModels = (provider.enabled_models || []).map((m) =>
|
||||
m.id === editingModel.id ? { ...editingModel } : m
|
||||
)
|
||||
let enabledModels
|
||||
if (isCreating.value) {
|
||||
const newId = (editingModel.id || '').trim()
|
||||
if (!newId) {
|
||||
message.error('请填写模型 ID')
|
||||
return
|
||||
}
|
||||
if ((provider.enabled_models || []).some((m) => m.id === newId)) {
|
||||
message.error('模型 ID 已存在')
|
||||
return
|
||||
}
|
||||
const newModel = { ...editingModel, id: newId, source: 'manual', enabled: true }
|
||||
enabledModels = [...(provider.enabled_models || []), newModel]
|
||||
} else {
|
||||
enabledModels = (provider.enabled_models || []).map((m) =>
|
||||
m.id === editingModel.id ? { ...editingModel } : m
|
||||
)
|
||||
}
|
||||
|
||||
await modelProviderApi.updateProvider(currentProviderForModels.value.provider_id, {
|
||||
enabled_models: enabledModels
|
||||
})
|
||||
message.success('模型配置已保存')
|
||||
message.success(isCreating.value ? '模型已添加' : '模型配置已保存')
|
||||
showModelModal.value = false
|
||||
isCreating.value = false
|
||||
await loadProviders()
|
||||
// Refresh current provider reference
|
||||
currentProviderForModels.value = providers.value.find(
|
||||
@ -778,9 +842,30 @@ onMounted(loadProviders)
|
||||
<div v-if="currentProviderForModels" class="models-modal-content">
|
||||
<!-- Enabled Models Section -->
|
||||
<div class="models-section">
|
||||
<h4 class="models-section-title">
|
||||
已启用模型 ({{ currentProviderForModels.enabled_models?.length || 0 }})
|
||||
</h4>
|
||||
<div class="enabled-header">
|
||||
<h4 class="models-section-title">
|
||||
已启用模型 ({{ currentProviderForModels.enabled_models?.length || 0 }})
|
||||
</h4>
|
||||
<div class="actions">
|
||||
<a-button
|
||||
size="small"
|
||||
type="primary"
|
||||
class="lucide-icon-btn"
|
||||
:loading="remoteLoading"
|
||||
@click="fetchRemoteModels(currentProviderForModels.provider_id)"
|
||||
>
|
||||
获取远程模型
|
||||
</a-button>
|
||||
<a-button
|
||||
size="small"
|
||||
class="lucide-icon-btn"
|
||||
@click="openCreateModal(currentProviderForModels)"
|
||||
>
|
||||
<Plus :size="14" />
|
||||
<span>手动添加</span>
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="models-table" v-if="currentProviderForModels.enabled_models?.length">
|
||||
<div class="table-head">
|
||||
<span class="col-name">模型</span>
|
||||
@ -801,6 +886,12 @@ onMounted(loadProviders)
|
||||
</div>
|
||||
<span class="col-type">
|
||||
<span class="type-tag" :class="model.type">{{ model.type }}</span>
|
||||
<span
|
||||
v-if="model.source === 'manual'"
|
||||
class="type-tag manual"
|
||||
title="管理员手动添加"
|
||||
>手动</span
|
||||
>
|
||||
</span>
|
||||
<span class="col-context">{{ formatContextLength(model.context_length) }}</span>
|
||||
<span class="col-dim">
|
||||
@ -827,7 +918,7 @@ onMounted(loadProviders)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<a-empty v-else :image="false" description="暂无已启用模型" />
|
||||
<a-empty v-else description="暂无已启用模型" />
|
||||
</div>
|
||||
|
||||
<!-- Remote Models Section -->
|
||||
@ -864,10 +955,7 @@ onMounted(loadProviders)
|
||||
<span class="type-tag" :class="remoteModel.type || 'chat'">
|
||||
{{ remoteModel.type || 'chat' }}
|
||||
</span>
|
||||
<template
|
||||
v-for="mod in (getInputModalities(remoteModel) || [])"
|
||||
:key="mod"
|
||||
>
|
||||
<template v-for="mod in getInputModalities(remoteModel) || []" :key="mod">
|
||||
<span class="modality-tag">{{ mod }}</span>
|
||||
</template>
|
||||
</div>
|
||||
@ -902,15 +990,6 @@ onMounted(loadProviders)
|
||||
</div>
|
||||
</div>
|
||||
<div class="remote-fetch-actions">
|
||||
<a-button
|
||||
type="primary"
|
||||
class="lucide-icon-btn"
|
||||
:loading="remoteLoading"
|
||||
@click="fetchRemoteModels(currentProviderForModels.provider_id)"
|
||||
>
|
||||
<RefreshCw :size="14" v-if="!remoteLoading" />
|
||||
获取远程模型
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -919,13 +998,19 @@ onMounted(loadProviders)
|
||||
<!-- Model Config Modal -->
|
||||
<a-modal
|
||||
v-model:open="showModelModal"
|
||||
title="模型配置"
|
||||
:title="isCreating ? '手动添加模型' : '模型配置'"
|
||||
:width="520"
|
||||
:confirm-loading="saving"
|
||||
@ok="saveModelConfig"
|
||||
>
|
||||
<div class="modal-form">
|
||||
<div class="model-id-display">
|
||||
<div v-if="isCreating" class="form-row">
|
||||
<label class="form-label">
|
||||
<span>模型 ID <span class="required-mark">*</span></span>
|
||||
<a-input v-model:value="editingModel.id" placeholder="例如 BAAI/bge-m3" allow-clear />
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="model-id-display">
|
||||
<span class="info-label">模型 ID</span>
|
||||
<code>{{ editingModel.id }}</code>
|
||||
</div>
|
||||
@ -937,11 +1022,11 @@ onMounted(loadProviders)
|
||||
</label>
|
||||
<label class="form-label">
|
||||
<span>模型类型</span>
|
||||
<a-select v-model:value="editingModel.type">
|
||||
<a-select-option value="chat">chat</a-select-option>
|
||||
<a-select-option value="embedding">embedding</a-select-option>
|
||||
<a-select-option value="rerank">rerank</a-select-option>
|
||||
</a-select>
|
||||
<a-select
|
||||
v-model:value="editingModel.type"
|
||||
:options="editingModelTypeOptions"
|
||||
:disabled="editingModelTypeOptions.length === 1"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@ -1386,6 +1471,10 @@ onMounted(loadProviders)
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
|
||||
& + & {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
&.chat {
|
||||
background: var(--color-info-50);
|
||||
color: var(--color-info-700);
|
||||
@ -1400,6 +1489,11 @@ onMounted(loadProviders)
|
||||
background: var(--color-warning-50);
|
||||
color: var(--color-warning-900);
|
||||
}
|
||||
|
||||
&.manual {
|
||||
background: var(--gray-200);
|
||||
color: var(--gray-700);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Status Badges ============
|
||||
@ -1449,6 +1543,24 @@ onMounted(loadProviders)
|
||||
border-top: 1px solid var(--gray-100);
|
||||
}
|
||||
|
||||
.enabled-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.models-section-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.remote-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -1541,13 +1653,6 @@ onMounted(loadProviders)
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.remote-fetch-actions {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--gray-100);
|
||||
}
|
||||
|
||||
// ============ Modal Form ============
|
||||
.modal-form {
|
||||
display: flex;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user