From db13abaa6373dfdc12a8e6ce8e4bda0922b7ea26 Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Sun, 26 Apr 2026 15:03:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(model):=20=E5=A2=9E=E5=BC=BA=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E9=85=8D=E7=BD=AE=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=89=8B=E5=8A=A8=E6=B7=BB=E5=8A=A0=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=8F=8A=E6=BA=90=E5=AD=97=E6=AE=B5=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=BF=9C=E7=AB=AF=E6=A8=A1=E5=9E=8B=E5=8A=A0=E8=BD=BD=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yuxi/services/model_provider_service.py | 84 ++++++-- .../server/routers/model_provider_router.py | 11 +- .../services/test_model_provider_service.py | 93 +++++++++ docs/develop-guides/roadmap.md | 2 +- web/src/apis/system_api.js | 4 + web/src/components/ModelSelectorComponent.vue | 78 ++++++- web/src/views/ModelConfigView.vue | 197 ++++++++++++++---- 7 files changed, 396 insertions(+), 73 deletions(-) diff --git a/backend/package/yuxi/services/model_provider_service.py b/backend/package/yuxi/services/model_provider_service.py index 98ebc596..cbc9b5e9 100644 --- a/backend/package/yuxi/services/model_provider_service.py +++ b/backend/package/yuxi/services/model_provider_service.py @@ -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) diff --git a/backend/server/routers/model_provider_router.py b/backend/server/routers/model_provider_router.py index 428e62dc..ca1de837 100644 --- a/backend/server/routers/model_provider_router.py +++ b/backend/server/routers/model_provider_router.py @@ -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", diff --git a/backend/test/unit/services/test_model_provider_service.py b/backend/test/unit/services/test_model_provider_service.py index 7d5cb1bc..916da31a 100644 --- a/backend/test/unit/services/test_model_provider_service.py +++ b/backend/test/unit/services/test_model_provider_service.py @@ -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"] diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index 76ead21c..e0d63b13 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -34,7 +34,7 @@ - 调整应用主导航:`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` 双层一致性校验中拦截越权写入。 --- diff --git a/web/src/apis/system_api.js b/web/src/apis/system_api.js index 1f81be4f..abe07ef9 100644 --- a/web/src/apis/system_api.js +++ b/web/src/apis/system_api.js @@ -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)}`) }, diff --git a/web/src/components/ModelSelectorComponent.vue b/web/src/components/ModelSelectorComponent.vue index 39ab24e5..48b1aa9a 100644 --- a/web/src/components/ModelSelectorComponent.vue +++ b/web/src/components/ModelSelectorComponent.vue @@ -1,5 +1,5 @@