diff --git a/apps/models_provider/base_model_provider.py b/apps/models_provider/base_model_provider.py index c8562f58aaa..a808810a754 100644 --- a/apps/models_provider/base_model_provider.py +++ b/apps/models_provider/base_model_provider.py @@ -1,5 +1,10 @@ # coding=utf-8 +import json +import time + +import requests + from abc import ABC, abstractmethod from enum import Enum from functools import reduce @@ -41,6 +46,52 @@ def to_dict(self): } +# 动态模型列表缓存:(provider, api_key, api_base) -> (ModelInfoManage, monotonic 时间戳) +_dynamic_model_manage_cache = {} +_DYNAMIC_MODEL_TTL_SECONDS = 5 * 60 +_DYNAMIC_MODEL_CACHE_MAX_SIZE = 512 +# 查询失败的 (provider, api_key, api_base, credential 指纹) 短期跳过 +_dynamic_model_failed_cache = {} +_DYNAMIC_MODEL_FAILED_TTL_SECONDS = 10 * 60 +_DYNAMIC_MODEL_FAILED_CACHE_MAX_SIZE = 512 + + +def _evict_cache(cache: dict, max_size: int): + """简单的容量上限保护:超出时淘汰最早写入的条目。""" + while len(cache) > max_size: + oldest = next(iter(cache)) + cache.pop(oldest, None) + + +def _model_type_name(model_type): + """ModelTypeConst 枚举名或字符串,统一作为 impl_map 的 key。""" + return model_type.name if isinstance(model_type, ModelTypeConst) else str(model_type) + + +def classify_dynamic_model_id(model_id: str): + """按模型 id 关键词推断 MaxKB 模型类型,无法识别时按 LLM 处理。""" + model_id = str(model_id).lower() + if "embed" in model_id: + return ModelTypeConst.EMBEDDING + if "rerank" in model_id: + return ModelTypeConst.RERANKER + if any(k in model_id for k in ("whisper", "asr", "transcribe", "paraformer")): + return ModelTypeConst.STT + if any(k in model_id for k in ("tts", "speech", "voice", "cosyvoice", "sambert")): + return ModelTypeConst.TTS + if any( + k in model_id for k in ("dall-e", "flux", "stable-diffusion", "sdxl", "wanx", "wan2", "z-image", "qwen-image") + ): + return ModelTypeConst.TTI + if any(k in model_id for k in ("t2v", "i2v", "video", "sora")): + return ( + ModelTypeConst.TTV + if ("t2v" in model_id or "video" in model_id or "sora" in model_id) + else ModelTypeConst.ITV + ) + return ModelTypeConst.LLM + + class IModelProvider(ABC): @abstractmethod def get_model_info_manage(self): @@ -53,11 +104,188 @@ def get_model_provide_info(self): def get_model_type_list(self): return self.get_model_info_manage().get_model_type_list() - def get_model_list(self, model_type): + def get_model_list(self, model_type, api_key=None, api_base=None, credential=None): if model_type is None: raise AppApiException(500, _("Model type cannot be empty")) + if api_key or credential: + manage = self._get_dynamic_manage_cached(api_key, api_base, credential) + if manage is not None: + return manage.get_model_list_by_model_type(model_type) return self.get_model_info_manage().get_model_list_by_model_type(model_type) + # ---------- 动态模型列表框架 ---------- + # 约定:供应商未提供 api_key 时使用内置 model_info_list; + # 提供了 api_key 时由 get_dynamic_model_info_list 实时查询官方列表, + # 默认实现按 OpenAI 兼容协议 GET {api_base}/models,供应商可按官方 API 覆写。 + + def get_dynamic_model_info_list(self, api_key, api_base, credential=None): + """返回实时查询得到的 ModelInfo 列表;查询失败或为空时返回 None(回退内置)。""" + items = self.fetch_dynamic_model_items(api_key, api_base, credential) + if not items: + return None + builtin_manage = self.get_model_info_manage() + builtin_by_name = {} + impl_map = {} + for info in builtin_manage.model_list: + builtin_by_name.setdefault(info.name, info) + impl_map.setdefault(info.model_type, (info.model_credential, info.model_class)) + info_list = [] + for item in items: + model_id = item.get("id") if isinstance(item, dict) else item + if not model_id: + continue + # 内置列表中已有的模型:类型与实现直接沿用原有逻辑,不依赖关键词猜型。 + builtin = builtin_by_name.get(model_id) + if builtin is not None: + info_list.append( + ModelInfo(model_id, builtin.desc, builtin.model_type, builtin.model_credential, builtin.model_class) + ) + continue + if isinstance(item, dict): + model_type = self.classify_dynamic_item(item) + else: + model_type = self.classify_dynamic_model(model_id) + impl = impl_map.get(_model_type_name(model_type)) + if impl is None: + # 视觉输入的对话模型(IMAGE 类型)在无视觉实现时按 LLM 兜底;其余未支持类型直接丢弃。 + if isinstance(model_type, ModelTypeConst) and model_type is ModelTypeConst.IMAGE: + impl = impl_map.get("LLM") + if impl is not None: + model_type = ModelTypeConst.LLM + if impl is None: + continue + info_list.append(ModelInfo(model_id, _(""), model_type, impl[0], impl[1])) + return info_list or None + + def classify_dynamic_item(self, item): + """优先使用 /models 返回的结构化元数据(input/output_modalities 等)判型。""" + model_id = str(item.get("id") or "") + input_modalities = {str(m).lower() for m in (item.get("input_modalities") or [])} + output_modalities = {str(m).lower() for m in (item.get("output_modalities") or [])} + if "image" in output_modalities: + return ModelTypeConst.TTI + if "audio" in output_modalities: + return ModelTypeConst.TTS + if "audio" in input_modalities: + return ModelTypeConst.STT + if "image" in input_modalities: + return ModelTypeConst.IMAGE + return self.classify_dynamic_model(model_id) + + def fetch_dynamic_model_items(self, api_key, api_base, credential=None): + """OpenAI 兼容协议实时获取模型条目(保留官方元数据,供判型使用)。""" + api_base = ( + (api_base or self._credential_api_base(credential) or self.get_dynamic_default_api_base() or "") + .strip() + .rstrip("/") + ) + if not api_base: + return [] + if not api_base.lower().startswith(("http://", "https://")): + # 仅允许 http/https,防止用户填入 file:/ftp: 等协议被服务端请求。 + return [] + candidates = [api_base + "/models"] + if "/v1" not in api_base and "/compatible-mode/" not in api_base: + candidates.insert(0, api_base + "/v1/models") + for url in candidates: + try: + response = requests.get(url, headers={"Authorization": f"Bearer {api_key}"}, timeout=15) + if response.status_code != 200: + continue + data = response.json() + items = data.get("data") if isinstance(data, dict) else data + if isinstance(items, dict): + items = items.get("models") or [] + result = [] + seen = set() + for item in items or []: + model_id = item.get("id") if isinstance(item, dict) else item + if model_id and model_id not in seen: + seen.add(model_id) + result.append(item if isinstance(item, dict) else {"id": item}) + if result: + return result + except Exception: + continue + return [] + + def fetch_dynamic_model_ids(self, api_key, api_base, credential=None): + """OpenAI 兼容协议实时获取模型 id 列表。""" + return [item.get("id") for item in self.fetch_dynamic_model_items(api_key, api_base, credential)] + + @staticmethod + def classify_dynamic_model(model_id): + return classify_dynamic_model_id(model_id) + + # 供应商可覆盖:未在表单填写 API 地址时的默认列表查询地址。 + dynamic_default_api_base = None + + def get_dynamic_default_api_base(self): + """从内置凭证表单的 api_base 默认值推断该供应商的默认 API 地址。""" + cached = getattr(self, "_dynamic_default_api_base", None) + if cached is not None: + return cached + if self.dynamic_default_api_base: + self._dynamic_default_api_base = self.dynamic_default_api_base + return self.dynamic_default_api_base + default = None + for info in self.get_model_info_manage().model_list: + try: + default_data = info.model_credential.get_default_form_data() + except Exception: + continue + value = str(default_data.get("api_base") or "").strip() + if value: + default = value + break + self._dynamic_default_api_base = default + return default + + @staticmethod + def _credential_api_base(credential): + """从完整凭证里找 API 地址字段(各供应商字段名不同:api_base/base_url 等)。""" + if not isinstance(credential, dict): + return None + for key in ("api_base", "base_url", "api_domain", "domain"): + value = str(credential.get(key) or "").strip() + if value: + return value + return None + + @staticmethod + def _credential_cache_fingerprint(credential): + """完整凭证的缓存指纹:仅取字符串值参与排序,避免大对象。""" + if not isinstance(credential, dict): + return "" + return json.dumps( + {k: str(v) for k, v in credential.items() if isinstance(v, (str, int, float))}, sort_keys=True + ) + + def _get_dynamic_manage_cached(self, api_key, api_base, credential=None): + provider = self.get_model_provide_info().provider + now = time.monotonic() + cache_key = (provider, api_key, api_base, self._credential_cache_fingerprint(credential)) + cached, cached_at = _dynamic_model_manage_cache.get(cache_key, (None, 0.0)) + if cached is not None and now - cached_at < _DYNAMIC_MODEL_TTL_SECONDS: + return cached + failed_at = _dynamic_model_failed_cache.get(cache_key, 0.0) + if now - failed_at < _DYNAMIC_MODEL_FAILED_TTL_SECONDS: + return None + info_list = self.get_dynamic_model_info_list(api_key, api_base, credential) + if not info_list: + _dynamic_model_failed_cache[cache_key] = now + _evict_cache(_dynamic_model_failed_cache, _DYNAMIC_MODEL_FAILED_CACHE_MAX_SIZE) + return None + builder = ModelInfoManage.builder() + for info in info_list: + builder.append_model_info(info) + for default in self.get_model_info_manage().default_model_list: + builder.append_default_model_info(default) + manage = builder.build() + _dynamic_model_manage_cache[cache_key] = (manage, now) + _evict_cache(_dynamic_model_manage_cache, _DYNAMIC_MODEL_CACHE_MAX_SIZE) + return manage + def get_model_credential(self, model_type, model_name): model_info = self.get_model_info_manage().get_model_info(model_type, model_name) model_credential = model_info.model_credential @@ -209,7 +437,7 @@ def __init__( ): self.name = name self.desc = desc - self.model_type = model_type.name + self.model_type = model_type.name if isinstance(model_type, ModelTypeConst) else str(model_type) self.model_credential = model_credential self.model_class = model_class if keywords is not None: diff --git a/apps/models_provider/impl/aliyun_bai_lian_model_provider/aliyun_bai_lian_model_provider.py b/apps/models_provider/impl/aliyun_bai_lian_model_provider/aliyun_bai_lian_model_provider.py index 053f636b312..fb554ff1712 100644 --- a/apps/models_provider/impl/aliyun_bai_lian_model_provider/aliyun_bai_lian_model_provider.py +++ b/apps/models_provider/impl/aliyun_bai_lian_model_provider/aliyun_bai_lian_model_provider.py @@ -9,6 +9,7 @@ import os + from common.utils.common import get_file_content from models_provider.base_model_provider import ( ModelProvideInfo, @@ -230,10 +231,106 @@ ) +def _type_impl_map(): + """各 MaxKB 模型类型对应的凭证实例与实现类(取自内置映射,非按模型名单写死)。""" + return { + ModelTypeConst.LLM: ( + aliyun_bai_lian_llm_model_credential, + BaiLianChatModel, + _("Alibaba Cloud Bailian LLM model"), + ), + ModelTypeConst.EMBEDDING: ( + aliyun_bai_lian_embedding_model_credential, + AliyunBaiLianEmbedding, + _("Alibaba Cloud Bailian embedding model"), + ), + ModelTypeConst.STT: ( + aliyun_bai_lian_stt_model_credential, + AliyunBaiLianSpeechToText, + _("Alibaba Cloud Bailian speech-to-text model"), + ), + ModelTypeConst.TTS: ( + aliyun_bai_lian_tts_model_credential, + AliyunBaiLianTextToSpeech, + _("Alibaba Cloud Bailian TTS model"), + ), + ModelTypeConst.IMAGE: (qwenvl_model_credential, QwenVLChatModel, _("Alibaba Cloud Bailian vision model")), + ModelTypeConst.TTI: ( + qwentti_model_credential, + QwenTextToImageModel, + _("Alibaba Cloud Bailian image generation model"), + ), + ModelTypeConst.RERANKER: ( + aliyun_bai_lian_model_credential, + AliyunBaiLianReranker, + _("Alibaba Cloud Bailian rerank model"), + ), + ModelTypeConst.TTV: ( + aliyun_bai_lian_ttv_model_credential, + GenerationVideoModel, + _("Alibaba Cloud Bailian text-to-video model"), + ), + ModelTypeConst.ITV: ( + aliyun_bai_lian_itv_model_credential, + GenerationVideoModel, + _("Alibaba Cloud Bailian image-to-video model"), + ), + } + + class AliyunBaiLianModelProvider(IModelProvider): + @staticmethod + def _query_base_for(api_base: str): + """把凭证里的 api_base 转成 /models 列表接口可用的 base 地址。""" + api_base = (api_base or "").strip() + if not api_base: + return "https://dashscope.aliyuncs.com/api/v1" + base = api_base.rstrip("/") + # OpenAI 兼容地址(.../compatible-mode/v1)回到原生 /api/v1 + if "/compatible-mode/" in base: + return base.split("/compatible-mode/")[0] + "/api/v1" + return base + def get_model_info_manage(self): + # 未提供 api_key 时返回内置常用模型;提供了 key 的实时查询见 get_model_list。 return model_info_manage + def get_dynamic_model_info_list(self, api_key, api_base, credential=None): + """DashScope 专有实现:按 capabilities/inference_metadata 精确映射模型类型。""" + # 模型列表始终走官方 DashScope 接口;compatible-mode 地址归一化回原生 /api/v1。 + items = [] + for base in dict.fromkeys([self._query_base_for(api_base), "https://dashscope.aliyuncs.com/api/v1"]): + try: + items = self.get_base_model_list(api_key, base) + except Exception: + items = [] + if items: + break + if not items: + return None + builtin_by_name = {info.name: info for info in self.get_model_info_manage().model_list} + info_list = [] + seen = set() + for item in items: + model_id = item.get("model") or item.get("model_id") or item.get("id") or item.get("name") + if not model_id or model_id in seen: + continue + seen.add(model_id) + # 内置列表已有的模型直接沿用原有类型与实现,其余按官方 capabilities 判型。 + builtin = builtin_by_name.get(model_id) + if builtin is not None: + info_list.append( + ModelInfo(model_id, builtin.desc, builtin.model_type, builtin.model_credential, builtin.model_class) + ) + continue + model_type = self._model_type_from_item(item) + impl = _type_impl_map().get(model_type) + if impl is None: + continue + credential_obj, model_class, desc = impl + info_list.append(ModelInfo(model_id, desc, model_type, credential_obj, model_class)) + return info_list or None + def get_model_provide_info(self): return ModelProvideInfo( provider="aliyun_bai_lian_model_provider", @@ -250,3 +347,108 @@ def get_model_provide_info(self): ) ), ) + + @staticmethod + def get_base_model_list(api_key: str, api_base: str = None): + """调用阿里云百炼(DashScope)官方 /models 列表接口实时获取可用模型。 + + 兼容两种返回结构: + - DashScope:{"output": {"models": [...]}} + - 网关/OpenAI 风格:{"data": [...]}(如 ai.fit2cloud.cn 网关) + 传入 api_base 时会作为 SDK 的 base_address,否则使用 SDK 默认的 + https://dashscope.aliyuncs.com/api/v1。 + """ + import dashscope + + models = [] + seen = set() + page_no = 1 + page_size = 100 + while True: + kwargs = {"page": page_no, "page_size": page_size, "api_key": api_key} + if api_base: + kwargs["base_address"] = api_base + response = dashscope.Models.list(**kwargs) + if getattr(response, "status_code", None) != 200: + if models: + return models + return [] + output = response.output or {} + if isinstance(output, dict): + page_models = output.get("models") or output.get("data") or [] + elif isinstance(output, list): + page_models = output + else: + page_models = [] + if not page_models: + break + new_count = 0 + for item in page_models: + model_id = item.get("model") or item.get("id") or item.get("model_id") or item.get("name") + if not model_id or model_id in seen: + continue + seen.add(model_id) + models.append(item) + new_count += 1 + if len(page_models) < page_size or new_count == 0: + break + page_no += 1 + return models + + @staticmethod + def _model_type_from_item(item): + """根据官方 /models 接口返回的 capabilities 与 inference_metadata 判别 MaxKB 模型类型,缺省按 LLM 处理。""" + caps = {str(c).lower() for c in (item.get("capabilities") or [])} + meta = item.get("inference_metadata") or {} + request_modality = meta.get("request_modality") or [] + response_modality = meta.get("response_modality") or [] + if not isinstance(request_modality, (list, tuple)): + request_modality = [request_modality] + if not isinstance(response_modality, (list, tuple)): + response_modality = [response_modality] + req = {str(x).lower() for x in request_modality} + model_id = str(item.get("model") or item.get("model_id") or item.get("id") or "").lower() + + # 官方 capabilities 是权威判别字段,优先级最高 + if "tts" in caps: + return ModelTypeConst.TTS + if caps & {"asr", "realtime-asr", "realtime-audio-translate"}: + return ModelTypeConst.STT + if "ig" in caps: + return ModelTypeConst.TTI + if "vg" in caps: + return ModelTypeConst.ITV if "image" in req else ModelTypeConst.TTV + if "me" in caps: + return ModelTypeConst.EMBEDDING + if "tr" in caps: + return ModelTypeConst.RERANKER if "rerank" in model_id else ModelTypeConst.EMBEDDING + if "vu" in caps and not (caps & {"tg", "reasoning"}): + return ModelTypeConst.IMAGE + if caps & {"tg", "reasoning"}: + return ModelTypeConst.LLM + if caps: + return ModelTypeConst.LLM + + # 网关/OpenAI 风格返回 type 字段(无 capabilities),按其映射类型 + item_type = str(item.get("type") or "").lower() + type_map = { + "embedding": ModelTypeConst.EMBEDDING, + "text": ModelTypeConst.LLM, + "image": ModelTypeConst.IMAGE, + "audio": ModelTypeConst.STT, + "tts": ModelTypeConst.TTS, + "rerank": ModelTypeConst.RERANKER, + "video": ModelTypeConst.TTV, + } + if item_type in type_map: + return type_map[item_type] + + # 兜底:仅当 capabilities 缺失时按输入/输出模态推断 + resp = {str(x).lower() for x in response_modality} + if any(k in resp for k in ("video",)): + return ModelTypeConst.ITV if "image" in req else ModelTypeConst.TTV + if "image" in resp: + return ModelTypeConst.TTI + if "audio" in req and "text" in resp: + return ModelTypeConst.STT + return ModelTypeConst.LLM diff --git a/apps/models_provider/impl/anthropic_model_provider/anthropic_model_provider.py b/apps/models_provider/impl/anthropic_model_provider/anthropic_model_provider.py index a3830de90a5..497b0769c96 100644 --- a/apps/models_provider/impl/anthropic_model_provider/anthropic_model_provider.py +++ b/apps/models_provider/impl/anthropic_model_provider/anthropic_model_provider.py @@ -9,6 +9,8 @@ import os +import requests + from common.utils.common import get_file_content from models_provider.base_model_provider import ( IModelProvider, @@ -50,6 +52,28 @@ class AnthropicModelProvider(IModelProvider): + # Anthropic 凭证表单无 api_base 字段,实时查询使用官方默认地址。 + dynamic_default_api_base = "https://api.anthropic.com/v1" + + def fetch_dynamic_model_items(self, api_key, api_base, credential=None): + """Anthropic 专有协议:GET {base}/v1/models,鉴权用 x-api-key 头。""" + api_base = (api_base or self.get_dynamic_default_api_base() or "").strip().rstrip("/") + if not api_base: + return [] + url = api_base + ("/models" if api_base.endswith("/v1") else "/v1/models") + try: + response = requests.get( + url, + headers={"x-api-key": api_key, "anthropic-version": "2023-06-01"}, + timeout=15, + ) + if response.status_code != 200: + return [] + data = response.json().get("data") or [] + return [item for item in data if item.get("id")] + except Exception: + return [] + def get_model_info_manage(self): return model_info_manage diff --git a/apps/models_provider/impl/aws_bedrock_model_provider/aws_bedrock_model_provider.py b/apps/models_provider/impl/aws_bedrock_model_provider/aws_bedrock_model_provider.py index 4bc956e816a..d3b38a4e781 100644 --- a/apps/models_provider/impl/aws_bedrock_model_provider/aws_bedrock_model_provider.py +++ b/apps/models_provider/impl/aws_bedrock_model_provider/aws_bedrock_model_provider.py @@ -4,6 +4,7 @@ import os from common.utils.common import get_file_content from models_provider.base_model_provider import ( + _model_type_name, IModelProvider, ModelProvideInfo, ModelInfo, @@ -218,6 +219,49 @@ def _initialize_model_info(): class BedrockModelProvider(IModelProvider): + def get_dynamic_model_info_list(self, api_key, api_base, credential=None): + """Bedrock 专有实现:boto3 list_foundation_models,凭证含 Access Key/Secret/Region。""" + cred = credential if isinstance(credential, dict) else {} + access_key_id = cred.get("access_key_id") + secret_access_key = api_key or cred.get("secret_access_key") + region_name = cred.get("region_name") + if not (access_key_id and secret_access_key and region_name): + return None + try: + import boto3 + + client = boto3.client( + "bedrock", + aws_access_key_id=access_key_id, + aws_secret_access_key=secret_access_key, + region_name=region_name, + ) + summaries = client.list_foundation_models().get("modelSummaries") or [] + except Exception: + return None + builtin_manage = self.get_model_info_manage() + impl_map = {} + for info in builtin_manage.model_list: + impl_map.setdefault(info.model_type, (info.model_credential, info.model_class)) + builtin_by_name = {info.name: info for info in builtin_manage.model_list} + info_list = [] + for item in summaries: + model_id = item.get("modelId") or item.get("modelArn") + if not model_id: + continue + builtin = builtin_by_name.get(model_id) + if builtin is not None: + info_list.append( + ModelInfo(model_id, builtin.desc, builtin.model_type, builtin.model_credential, builtin.model_class) + ) + continue + model_type = self.classify_dynamic_model(model_id) + impl = impl_map.get(_model_type_name(model_type)) + if impl is None: + continue + info_list.append(ModelInfo(model_id, _(""), model_type, impl[0], impl[1])) + return info_list or None + def __init__(self): self._model_info_manage = _initialize_model_info() diff --git a/apps/models_provider/impl/gemini_model_provider/gemini_model_provider.py b/apps/models_provider/impl/gemini_model_provider/gemini_model_provider.py index 8dfb30d377e..b3597f5fac9 100644 --- a/apps/models_provider/impl/gemini_model_provider/gemini_model_provider.py +++ b/apps/models_provider/impl/gemini_model_provider/gemini_model_provider.py @@ -9,8 +9,11 @@ import os +import requests + from common.utils.common import get_file_content from models_provider.base_model_provider import ( + _model_type_name, IModelProvider, ModelProvideInfo, ModelInfo, @@ -140,6 +143,92 @@ class GeminiModelProvider(IModelProvider): + def get_dynamic_model_info_list(self, api_key, api_base, credential=None): + """Gemini 原生 /v1beta/models 返回 supportedGenerationMethods,可精确判型;失败时回退框架默认。""" + base = (api_base or self.get_dynamic_default_api_base() or "").strip().rstrip("/") + if not base: + return None + try: + response = requests.get( + base + "/v1beta/models", + headers={"x-goog-api-key": api_key}, + timeout=15, + ) + if response.status_code != 200: + return None + items = response.json().get("models") or [] + if not items: + return None + builtin_manage = self.get_model_info_manage() + builtin_by_name = {} + impl_map = {} + for info in builtin_manage.model_list: + builtin_by_name.setdefault(info.name, info) + impl_map.setdefault(info.model_type, (info.model_credential, info.model_class)) + info_list = [] + for item in items: + name = str(item.get("name") or "").removeprefix("models/") + if not name: + continue + builtin = builtin_by_name.get(name) + if builtin is not None: + info_list.append( + ModelInfo(name, builtin.desc, builtin.model_type, builtin.model_credential, builtin.model_class) + ) + continue + methods = {str(m).lower() for m in (item.get("supportedGenerationMethods") or [])} + if not methods: + # 官方接口未给出可用能力时跳过,避免误判成 LLM。 + continue + if "embedcontent" in methods: + model_type = ModelTypeConst.EMBEDDING + elif any(m in methods for m in ("generatecontent", "counttokens", "generateanswer")): + model_type = ModelTypeConst.LLM + elif "generatevideos" in methods: + model_type = ModelTypeConst.TTV + else: + model_type = self.classify_dynamic_model(name) + impl = impl_map.get(_model_type_name(model_type)) + if impl is None: + continue + info_list.append(ModelInfo(name, _(""), model_type, impl[0], impl[1])) + return info_list or None + except Exception: + return None + + def fetch_dynamic_model_ids(self, api_key, api_base, credential=None): + """Gemini OpenAI 兼容端点:GET {base}/v1beta/openai/models,Bearer 鉴权。""" + api_base = (api_base or self.get_dynamic_default_api_base() or "").strip().rstrip("/") + if not api_base: + return [] + url = api_base + "/v1beta/openai/models" + try: + response = requests.get(url, headers={"Authorization": f"Bearer {api_key}"}, timeout=15) + if response.status_code != 200: + return [] + data = response.json().get("data") or [] + return [item.get("id") for item in data if item.get("id")] + except Exception: + return [] + + def get_dynamic_default_api_base(self): + """Gemini 凭证表单的地址字段是 base_url,默认值推断需特殊处理。""" + cached = getattr(self, "_dynamic_default_api_base", None) + if cached is not None: + return cached + default = None + for info in self.get_model_info_manage().model_list: + try: + default_data = info.model_credential.get_default_form_data() + except Exception: + continue + value = str(default_data.get("base_url") or default_data.get("api_base") or "").strip() + if value: + default = value + break + self._dynamic_default_api_base = default + return default + def get_model_info_manage(self): return model_info_manage diff --git a/apps/models_provider/impl/openai_model_provider/openai_model_provider.py b/apps/models_provider/impl/openai_model_provider/openai_model_provider.py index 5a326b0c39d..c8cfb08a271 100644 --- a/apps/models_provider/impl/openai_model_provider/openai_model_provider.py +++ b/apps/models_provider/impl/openai_model_provider/openai_model_provider.py @@ -195,6 +195,8 @@ class OpenAIModelProvider(IModelProvider): + dynamic_default_api_base = "https://api.openai.com/v1" + def get_model_info_manage(self): return model_info_manage diff --git a/apps/models_provider/tests/test_all_providers_dynamic_list.py b/apps/models_provider/tests/test_all_providers_dynamic_list.py new file mode 100644 index 00000000000..b0f5dd65e62 --- /dev/null +++ b/apps/models_provider/tests/test_all_providers_dynamic_list.py @@ -0,0 +1,198 @@ +# coding=utf-8 +"""全供应商动态模型列表一致性测试。 + +验证约定: +- 提供了 api_key 时,动态列表来自实时查询(mock 官方接口); +- 模型类型不是按关键词猜出来的唯一来源:凡是内置 model_info_list 里已有的模型, + 类型与实现必须沿用原有逻辑(builtin-name-first); +- 每个供应商按其内置的模型类型集合逐一校验。 +""" + +import importlib +from unittest.mock import Mock, patch + +from django.test import SimpleTestCase + +from models_provider.constants.model_provider_constants import ModelProvideConstants + +API_KEY = "test-key" + + +def _builtin_catalog(provider): + """返回 {model_type: [name, ...]},数据完全取自供应商原有内置逻辑。""" + catalog = {} + for info in provider.get_model_info_manage().model_list: + catalog.setdefault(info.model_type, []) + if info.name not in catalog[info.model_type]: + catalog[info.model_type].append(info.name) + return catalog + + +def _dynamic_names(provider, model_type, api_base=None): + return sorted(m["name"] for m in provider.get_model_list(model_type, api_key=API_KEY, api_base=api_base)) + + +class AllProvidersDynamicModelListTest(SimpleTestCase): + def _load_provider(self, name): + try: + return ModelProvideConstants[name] + except Exception: + self.skipTest(f"provider {name} unavailable in this environment") + + def test_openai_compatible_providers_use_original_types(self): + """默认 OpenAI 兼容协议的供应商:mock /models 返回内置模型名,动态列表类型须与内置一致。""" + special = { + "aliyun_bai_lian_model_provider", + "model_gemini_provider", + "model_anthropic_provider", + "model_aws_bedrock_provider", + } + tested = [] + for name in ModelProvideConstants: + if name in special: + continue + provider = self._load_provider(name) + catalog = _builtin_catalog(provider) + all_names = list(dict.fromkeys(n for names in catalog.values() for n in names)) + if not all_names: + continue + api_base = provider.get_dynamic_default_api_base() or "https://dynamic-test.example.com/v1" + calls = [] + + def fake_get(url, headers=None, timeout=None, **kwargs): + calls.append(url) + resp = Mock() + resp.status_code = 200 + resp.json.return_value = {"object": "list", "data": [{"id": n} for n in all_names]} + return resp + + with patch("models_provider.base_model_provider.requests.get", side_effect=fake_get): + for model_type, names in catalog.items(): + # 跨类型重名模型不参与断言(动态列表只归属一次)。 + duplicated = {n for n in names if sum(n in ns for ns in catalog.values()) > 1} + expected = sorted(n for n in names if n not in duplicated) + if not expected: + continue + actual = _dynamic_names(provider, model_type, api_base) + # 内置模型必须出现在动态列表中(新模型可按关键词归入同类型,允许超集)。 + self.assertTrue( + set(expected).issubset(actual), + f"{name} / {model_type}: builtin models missing from dynamic list: {expected} vs {actual}", + ) + self.assertTrue(calls, f"{name}: dynamic fetch never issued a request") + tested.append(name) + self.assertGreaterEqual(len(tested), 10) + + def test_openai_compatible_metadata_modalities_classification(self): + """官方 /models 返回 input/output_modalities 时按元数据判型(如 DeepSeek 新返回结构)。""" + provider = self._load_provider("model_deepseek_provider") + items = [ + {"id": "deepseek-flash", "input_modalities": ["text", "image"], "output_modalities": ["text"]}, + {"id": "deepseek-v4-pro", "input_modalities": ["text"], "output_modalities": ["text"]}, + {"id": "deepseek-image", "input_modalities": ["text"], "output_modalities": ["image"]}, + ] + + def fake_get(url, headers=None, timeout=None, **kwargs): + resp = Mock() + resp.status_code = 200 + resp.json.return_value = {"object": "list", "data": items} + return resp + + with patch("models_provider.base_model_provider.requests.get", side_effect=fake_get): + llm = _dynamic_names(provider, "LLM") + # 带 image 输入的多模态对话模型在本供应商无 IMAGE 实现时归入 LLM;纯绘图模型归 TTI 且不混入 LLM。 + self.assertIn("deepseek-flash", llm) + self.assertIn("deepseek-v4-pro", llm) + self.assertNotIn("deepseek-image", llm) + + def test_anthropic_uses_x_api_key_header(self): + provider = self._load_provider("model_anthropic_provider") + catalog = _builtin_catalog(provider) + all_names = list(dict.fromkeys(n for names in catalog.values() for n in names)) + headers_seen = [] + + def fake_get(url, headers=None, timeout=None, **kwargs): + headers_seen.append(headers or {}) + resp = Mock() + resp.status_code = 200 + resp.json.return_value = {"data": [{"id": n} for n in all_names]} + return resp + + with patch("models_provider.base_model_provider.requests.get", side_effect=fake_get): + for model_type, names in catalog.items(): + duplicated = {n for n in names if sum(n in ns for ns in catalog.values()) > 1} + expected = sorted(n for n in names if n not in duplicated) + if not expected: + continue + actual = _dynamic_names(provider, model_type) + self.assertTrue(set(expected).issubset(actual), f"anthropic / {model_type}") + self.assertTrue(headers_seen and headers_seen[0].get("x-api-key") == API_KEY) + + def test_gemini_native_models_endpoint_keeps_builtin_types(self): + provider = self._load_provider("model_gemini_provider") + catalog = _builtin_catalog(provider) + all_names = list(dict.fromkeys(n for names in catalog.values() for n in names)) + + def fake_get(url, headers=None, timeout=None, **kwargs): + resp = Mock() + if url.endswith("/v1beta/models") and "/openai/" not in url: + resp.status_code = 200 + resp.json.return_value = { + "models": [{"name": f"models/{n}"} for n in all_names], + } + else: + resp.status_code = 200 + resp.json.return_value = {"data": [{"id": n} for n in all_names]} + return resp + + with patch("models_provider.base_model_provider.requests.get", side_effect=fake_get): + for model_type, names in catalog.items(): + duplicated = {n for n in names if sum(n in ns for ns in catalog.values()) > 1} + expected = sorted(n for n in names if n not in duplicated) + if not expected: + continue + actual = _dynamic_names(provider, model_type) + self.assertTrue(set(expected).issubset(actual), f"gemini / {model_type}") + + def test_aliyun_dashscope_list_keeps_builtin_types(self): + provider = self._load_provider("aliyun_bai_lian_model_provider") + catalog = _builtin_catalog(provider) + all_names = list(dict.fromkeys(n for names in catalog.values() for n in names)) + dashscope = importlib.import_module("dashscope") + + response = Mock() + response.status_code = 200 + response.output = {"models": [{"model": n} for n in all_names]} + + with patch.object(dashscope.Models, "list", return_value=response): + for model_type, names in catalog.items(): + duplicated = {n for n in names if sum(n in ns for ns in catalog.values()) > 1} + expected = sorted(n for n in names if n not in duplicated) + if not expected: + continue + actual = _dynamic_names(provider, model_type) + self.assertTrue(set(expected).issubset(actual), f"aliyun / {model_type}") + + def test_bedrock_list_foundation_models_keeps_builtin_types(self): + provider = self._load_provider("model_aws_bedrock_provider") + catalog = _builtin_catalog(provider) + all_names = list(dict.fromkeys(n for names in catalog.values() for n in names)) + boto3 = importlib.import_module("boto3") + + client = Mock() + client.list_foundation_models.return_value = {"modelSummaries": [{"modelId": n} for n in all_names]} + credential = { + "access_key_id": "AKIA_TEST", + "secret_access_key": "SECRET_TEST", + "region_name": "us-east-1", + } + + with patch.object(boto3, "client", return_value=client): + for model_type, names in catalog.items(): + duplicated = {n for n in names if sum(n in ns for ns in catalog.values()) > 1} + expected = sorted(n for n in names if n not in duplicated) + if not expected: + continue + models = provider.get_model_list(model_type, api_key="SECRET_TEST", credential=credential) + actual = sorted(m["name"] for m in models) + self.assertTrue(set(expected).issubset(actual), f"bedrock / {model_type}") diff --git a/apps/models_provider/tests/test_minimax.py b/apps/models_provider/tests/test_minimax.py new file mode 100644 index 00000000000..ce3660d84c8 --- /dev/null +++ b/apps/models_provider/tests/test_minimax.py @@ -0,0 +1,214 @@ +# coding=utf-8 +import json +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import httpx +from langchain_core.messages import HumanMessage + + +class MiniMaxProviderTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + from django.conf import settings + + if not settings.configured: + settings.configure(USE_I18N=False) + # Isolate application services; provider lookup, forms and SDK transport stay real. + dependencies = { + "common.utils.common": SimpleNamespace( + encryption=Mock(), get_file_content=Mock(), _remove_empty_lines=Mock() + ), + "maxkb.conf": SimpleNamespace(PROJECT_DIR=str(Path(__file__).resolve().parents[3])), + } + services = patch.dict("sys.modules", dependencies) + services.start() + cls.addClassCleanup(services.stop) + from models_provider.impl.minimax_model_provider.minimax_model_provider import MiniMaxModelProvider + + cls.provider = MiniMaxModelProvider() + + def capture(self, name, base=None, params=None, stream=False, content="Hello"): + captured = [] + + def respond(request): + body = json.loads(request.content) + captured.append((str(request.url), body)) + if body.get("stream"): + chunk = { + "id": "test", + "object": "chat.completion.chunk", + "created": 0, + "model": name, + "choices": [{"index": 0, "delta": {"content": "OK"}, "finish_reason": None}], + } + return httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + text=f"data: {json.dumps(chunk)}\n\ndata: [DONE]\n\n", + ) + return httpx.Response( + 200, + json={ + "id": "test", + "object": "chat.completion", + "created": 0, + "model": name, + "choices": [ + {"index": 0, "message": {"role": "assistant", "content": "OK"}, "finish_reason": "stop"} + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }, + ) + + credential = self.provider.get_model_credential("LLM", name) + options = credential.get_model_params_setting_form(name).get_default_form_data() + options.update(params or {}) + with httpx.Client(transport=httpx.MockTransport(respond)) as client: + model = self.provider.get_model( + "LLM", + name, + {"api_key": "test-key", "api_base": base}, + http_client=client, + max_retries=0, + **options, + ) + messages = [HumanMessage(content=content)] + result = ( + "".join(chunk.content for chunk in model.stream(messages)) if stream else model.invoke(messages).content + ) + self.assertEqual(result, "OK") + self.assertEqual(len(captured), 1) + return captured[0] + + def test_dynamic_model_list_uses_official_models_endpoint(self): + from models_provider.base_model_provider import ModelTypeConst + + calls = [] + + def fake_get(url, headers=None, timeout=None): + calls.append((url, headers)) + resp = Mock() + resp.status_code = 200 + resp.json.return_value = { + "object": "list", + "data": [ + {"id": "MiniMax-M2", "object": "model"}, + {"id": "speech-02-hd", "object": "model"}, + ], + } + return resp + + with patch("models_provider.base_model_provider.requests.get", side_effect=fake_get): + models = self.provider.get_model_list("LLM", api_key="secret-key") + + self.assertEqual(calls, [("https://api.minimaxi.com/v1/models", {"Authorization": "Bearer secret-key"})]) + self.assertEqual([m["name"] for m in models], ["MiniMax-M2"]) + + with patch("models_provider.base_model_provider.requests.get", side_effect=fake_get): + tts = self.provider.get_model_list("TTS", api_key="secret-key") + self.assertEqual([m["name"] for m in tts], ["speech-02-hd"]) + self.assertEqual(tts[0]["model_type"], ModelTypeConst.TTS.name) + + def test_dynamic_model_list_falls_back_without_or_with_bad_key(self): + with patch("models_provider.base_model_provider.requests.get", return_value=Mock(status_code=401)): + fallback = self.provider.get_model_list("LLM", api_key="bad-key") + builtin = self.provider.get_model_list("LLM") + self.assertEqual([m["name"] for m in fallback], [m["name"] for m in builtin]) + self.assertTrue(len(builtin) > 0) + + def test_current_catalog_and_default(self): + self.assertEqual( + [model["name"] for model in self.provider.get_model_list("LLM")], + ["MiniMax-M3.1-Flash", "MiniMax-M3"], + ) + manager = self.provider.get_model_info_manage() + self.assertEqual(manager.default_model_dict["LLM"].name, "MiniMax-M3.1-Flash") + + def test_default_and_regional_requests(self): + for name in ("MiniMax-M3.1-Flash", "MiniMax-M3"): + for base in (None, "https://api.minimaxi.com/v1", "https://api.minimax.io/v1"): + for stream in (False, True): + with self.subTest(name=name, base=base, stream=stream): + url, body = self.capture(name, base, stream=stream) + self.assertEqual(url, (base or "https://api.minimaxi.com/v1") + "/chat/completions") + self.assertEqual(body["model"], name) + self.assertEqual(body["max_completion_tokens"], 8192) + self.assertEqual(body["temperature"], 1.0) + self.assertTrue(body["reasoning_split"]) + # Omission uses adaptive thinking and the API's default effort. + for key in ("thinking", "reasoning_effort", "service_tier"): + self.assertNotIn(key, body) + + def test_saved_and_custom_names_survive_default_adapter_lookup(self): + for name in ( + "MiniMax-M2.7", + "MiniMax-M2.7-highspeed", + "MiniMax-M2.5", + "MiniMax-M2.5-highspeed", + "saved-custom-model", + ): + with self.subTest(name=name): + url, body = self.capture(name, "https://proxy.example/v1", {"max_tokens": 321}) + self.assertEqual(url, "https://proxy.example/v1/chat/completions") + self.assertEqual(body["model"], name) + self.assertEqual(body["max_completion_tokens"], 321) + + def test_flash_output_limit_is_model_specific(self): + for name, maximum in ( + ("MiniMax-M3.1-Flash", 524288), + ("MiniMax-M3", 192000), + ("MiniMax-M2.7", 192000), + ("saved-custom-model", 192000), + ): + with self.subTest(name=name): + form = self.provider.get_model_credential("LLM", name).get_model_params_setting_form(name) + self.assertEqual(form.max_tokens.attrs["max"], maximum) + self.assertEqual(form.get_default_form_data()["max_tokens"], 8192) + self.assertEqual( + next(field for field in form.to_form_list() if field["field"] == "max_tokens")["attrs"]["max"], + maximum, + ) + _, body = self.capture("MiniMax-M3.1-Flash", params={"max_tokens": 524288}) + self.assertEqual(body["max_completion_tokens"], 524288) + + def test_explicit_thinking_effort_and_multimodal_content(self): + content = [ + {"type": "text", "text": "Describe this image"}, + {"type": "image_url", "image_url": {"url": "https://example.com/image.png"}}, + ] + for effort in ("low", "medium", "high", "xhigh", "max"): + with self.subTest(effort=effort): + _, body = self.capture( + "MiniMax-M3.1-Flash", + params={ + "reasoning_effort": effort, + "extra_body": {"thinking": {"type": "adaptive"}}, + }, + content=content, + ) + self.assertEqual(body["reasoning_effort"], effort) + self.assertEqual(body["thinking"], {"type": "adaptive"}) + self.assertEqual(body["messages"][0]["content"], content) + self.assertTrue(body["reasoning_split"]) + self.assertNotIn("service_tier", body) + _, body = self.capture("MiniMax-M3", params={"extra_body": {"thinking": {"type": "disabled"}}}) + self.assertEqual(body["thinking"], {"type": "disabled"}) + + def test_nontext_catalogs_are_preserved(self): + expected = { + "TTS": ["speech-2.8-hd", "speech-2.8-turbo"], + "TTI": ["image-01"], + "TTV": ["MiniMax-H3", "MiniMax-H3-Max", "MiniMax-Hailuo-2.3"], + "ITV": ["MiniMax-H3", "MiniMax-H3-Max", "MiniMax-Hailuo-2.3"], + } + for model_type, names in expected.items(): + with self.subTest(model_type=model_type): + self.assertEqual([model["name"] for model in self.provider.get_model_list(model_type)], names) + self.assertEqual(self.provider.get_model_info_manage().default_model_dict[model_type].name, names[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/models_provider/views/provide.py b/apps/models_provider/views/provide.py index 2f41a54755d..90751a131f6 100644 --- a/apps/models_provider/views/provide.py +++ b/apps/models_provider/views/provide.py @@ -1,9 +1,8 @@ # coding=utf-8 + from common import result from common.auth import TokenAuth -from common.auth.authentication import has_permissions -from common.auth.constants.permission_constants import PermissionConstants from django.utils.translation import gettext_lazy as _ from drf_spectacular.utils import extend_schema from models_provider.api.provide import ProvideApi @@ -73,11 +72,49 @@ class ModelList(APIView): responses=ProvideApi.ModelList.get_response(), tags=[_("Model")], # type: ignore ) + @staticmethod + def _get_model_list(provider, model_type, api_key, api_base, credential, search): + model_list = ModelProvideConstants[provider].get_model_list( + model_type, + api_key=api_key, + api_base=api_base, + credential=credential, + ) + if search: + search = str(search).strip().lower() + model_list = [ + model + for model in model_list + if search in str(model.get("name", "")).lower() or search in str(model.get("desc", "")).lower() + ] + return model_list + def get(self, request: Request): - provider = request.query_params.get("provider") - model_type = request.query_params.get("model_type") + """内置列表查询;带凭证的查询必须走 POST,避免 api_key 出现在 URL/访问日志中。""" + return result.success( + self._get_model_list( + request.query_params.get("provider"), + request.query_params.get("model_type"), + None, + None, + None, + request.query_params.get("search"), + ) + ) - return result.success(ModelProvideConstants[provider].get_model_list(model_type)) + def post(self, request: Request): + """携带凭证的查询走 POST,避免 api_key 出现在 URL/访问日志中。""" + data = request.data or {} + return result.success( + self._get_model_list( + data.get("provider"), + data.get("model_type"), + data.get("api_key"), + data.get("api_base"), + data.get("credential"), + data.get("search"), + ) + ) class ModelParamsForm(APIView): authentication_classes = [TokenAuth] diff --git a/ui/src/api/admin/model-provider.ts b/ui/src/api/admin/model-provider.ts index 1dd3b460074..70d1a072c84 100644 --- a/ui/src/api/admin/model-provider.ts +++ b/ui/src/api/admin/model-provider.ts @@ -1,4 +1,4 @@ -import { get } from './core/request' +import { get, post } from './core/request' import type { BaseModelOption, DynamicFormField, ModelProviderItem, ModelTypeOption } from '@/api/types' const prefix = '/provider' @@ -23,9 +23,19 @@ const getModelTypeList = (provider: string) => { return get(`${prefix}/model_type_list`, { provider }) } -/** 获取供应商指定类型下的基础模型。 */ -const getBaseModelList = (provider: string, modelType: string) => { - return get(`${prefix}/model_list`, { model_type: modelType, provider }) +/** 获取供应商指定类型下的基础模型,可携带 search 远程筛选;提供凭证时后端实时查询官方模型列表。 */ +const getBaseModelList = (provider: string, modelType: string, search?: string, credential?: Record) => { + // 携带凭证时走 POST body,避免 api_key 泄露到 URL/访问日志。 + if (credential && Object.keys(credential).length > 0) { + return post, BaseModelOption[]>(`${prefix}/model_list`, { + model_type: modelType, + provider, + search, + ...credential, + credential, + }) + } + return get(`${prefix}/model_list`, { model_type: modelType, provider, search }) } export default { getBaseModelList, getBaseModelParamsForm, getModelCreateForm, getModelTypeList, getProviderList } diff --git a/ui/src/components/mk-dynamics-form/index.vue b/ui/src/components/mk-dynamics-form/index.vue index 9568a41d0ba..676717d4cce 100644 --- a/ui/src/components/mk-dynamics-form/index.vue +++ b/ui/src/components/mk-dynamics-form/index.vue @@ -352,5 +352,6 @@ defineExpose({ initDefaultData: initializeFieldDefault, render, ruleFormRef, val @change-label="changeFieldLabel(field, $event)" /> + diff --git a/ui/src/views/model/create-model/CreateModelDrawer.vue b/ui/src/views/model/create-model/CreateModelDrawer.vue index bed148f6a71..e411a025519 100644 --- a/ui/src/views/model/create-model/CreateModelDrawer.vue +++ b/ui/src/views/model/create-model/CreateModelDrawer.vue @@ -8,7 +8,7 @@ import ProviderApi from '@/api/admin/model-provider' import { MODEL_TYPE_LABELS } from '@/constants/model' import { MkDynamicsForm, type DynamicFormValue, type FormField } from '@/components/mk-dynamics-form' import { useStore } from '@/stores' -import { MsgSuccess } from '@/utils/message' +import { MsgSuccess, MsgWarning } from '@/utils/message' import ModelAdvancedSettings from '@/views/model/advanced-settings-table/AdvancedSettingsTable.vue' defineOptions({ name: 'CreateModelDrawer' }) @@ -80,8 +80,31 @@ function loadModelProviders() { const dynamicsFormRef = ref>() const dynamicsLoading = ref(false) const baseModelOptions = ref([]) +const baseModelLoading = ref(false) const credentialFields = ref([]) const modelParamsForm = ref([]) +function fetchBaseModelOptions() { + if (!modelForm.provider || !modelForm.model_type) return + baseModelLoading.value = true + const loadBuiltin = () => + ProviderApi.getBaseModelList(modelForm.provider, modelForm.model_type).then((options) => { + baseModelOptions.value = options + MsgWarning('未获取到官方模型列表,已展示内置常用模型') + }) + ProviderApi.getBaseModelList(modelForm.provider, modelForm.model_type, undefined, modelForm.credential as Record) + .then((options) => { + if (options?.length) { + baseModelOptions.value = options + } else { + return loadBuiltin() + } + }) + .catch(() => loadBuiltin()) + .finally(() => { + baseModelLoading.value = false + }) +} + function handleModelTypeChange() { modelForm.model_name = '' modelForm.credential = {} @@ -91,10 +114,12 @@ function handleModelTypeChange() { void dynamicsFormRef.value?.render([], modelFormData.value) if (!modelForm.model_type) return + // 选择模型类型后立即展示 API URL / API Key 等凭证表单(取该类型默认凭证)。 dynamicsLoading.value = true - ProviderApi.getBaseModelList(modelForm.provider, modelForm.model_type) - .then((options) => { - baseModelOptions.value = options + ProviderApi.getModelCreateForm(modelForm.provider, modelForm.model_type, '') + .then((fields) => { + credentialFields.value = fields + return dynamicsFormRef.value?.render(fields, modelFormData.value) }) .finally(() => { dynamicsLoading.value = false @@ -102,7 +127,7 @@ function handleModelTypeChange() { } function handleBaseModelChange() { - modelForm.credential = {} + // 切换基础模型时保留已填写的凭证参数(同供应商/类型下字段一致,直接沿用)。 credentialFields.value = [] modelParamsForm.value = [] void dynamicsFormRef.value?.render([], modelFormData.value) @@ -273,6 +298,15 @@ defineExpose({ open }) + + +