diff --git a/backend/package/yuxi/config/builtin_providers.py b/backend/package/yuxi/config/builtin_providers.py new file mode 100644 index 00000000..6aaef2f8 --- /dev/null +++ b/backend/package/yuxi/config/builtin_providers.py @@ -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", + }, +] diff --git a/backend/package/yuxi/services/model_cache.py b/backend/package/yuxi/services/model_cache.py index 3bb4d231..eca2020b 100644 --- a/backend/package/yuxi/services/model_cache.py +++ b/backend/package/yuxi/services/model_cache.py @@ -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'" + ) diff --git a/backend/package/yuxi/services/model_provider_service.py b/backend/package/yuxi/services/model_provider_service.py index bdfc276b..0e77a150 100644 --- a/backend/package/yuxi/services/model_provider_service.py +++ b/backend/package/yuxi/services/model_provider_service.py @@ -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)} diff --git a/backend/package/yuxi/storage/postgres/manager.py b/backend/package/yuxi/storage/postgres/manager.py index 87e83ad1..36e853f5 100644 --- a/backend/package/yuxi/storage/postgres/manager.py +++ b/backend/package/yuxi/storage/postgres/manager.py @@ -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: diff --git a/backend/package/yuxi/storage/postgres/models_business.py b/backend/package/yuxi/storage/postgres/models_business.py index de014ff7..ef329965 100644 --- a/backend/package/yuxi/storage/postgres/models_business.py +++ b/backend/package/yuxi/storage/postgres/models_business.py @@ -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, diff --git a/backend/server/routers/model_provider_router.py b/backend/server/routers/model_provider_router.py index 5f4a5eee..14ada198 100644 --- a/backend/server/routers/model_provider_router.py +++ b/backend/server/routers/model_provider_router.py @@ -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)}} diff --git a/backend/server/utils/lifespan.py b/backend/server/utils/lifespan.py index ce137b61..10380f25 100644 --- a/backend/server/utils/lifespan.py +++ b/backend/server/utils/lifespan.py @@ -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: diff --git a/backend/test/unit/services/test_model_provider_service.py b/backend/test/unit/services/test_model_provider_service.py index 916da31a..dbbb8148 100644 --- a/backend/test/unit/services/test_model_provider_service.py +++ b/backend/test/unit/services/test_model_provider_service.py @@ -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 字段 ==================== diff --git a/web/src/assets/css/base.css b/web/src/assets/css/base.css index 594b6fbe..deb48728 100644 --- a/web/src/assets/css/base.css +++ b/web/src/assets/css/base.css @@ -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; } } diff --git a/web/src/components/EmbeddingModelSelector.vue b/web/src/components/EmbeddingModelSelector.vue index 886991ec..6305ca1e 100644 --- a/web/src/components/EmbeddingModelSelector.vue +++ b/web/src/components/EmbeddingModelSelector.vue @@ -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] || '未知' diff --git a/web/src/components/ModelSelectorComponent.vue b/web/src/components/ModelSelectorComponent.vue index f0dbd892..925381a9 100644 --- a/web/src/components/ModelSelectorComponent.vue +++ b/web/src/components/ModelSelectorComponent.vue @@ -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 '状态未知' diff --git a/web/src/composables/useModelStatus.js b/web/src/composables/useModelStatus.js new file mode 100644 index 00000000..5392a94f --- /dev/null +++ b/web/src/composables/useModelStatus.js @@ -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 } +} \ No newline at end of file diff --git a/web/src/utils/modelIcon.js b/web/src/utils/modelIcon.js index 2c055c50..3f923ede 100644 --- a/web/src/utils/modelIcon.js +++ b/web/src/utils/modelIcon.js @@ -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 diff --git a/web/src/views/ModelConfigView.vue b/web/src/views/ModelConfigView.vue index 011c3b63..ea3f3a62 100644 --- a/web/src/views/ModelConfigView.vue +++ b/web/src/views/ModelConfigView.vue @@ -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)
Model Registry