feat: 增加服务商远端模型发现与一键添加

- 支持 OpenAI 兼容、Ollama 和阿里百炼模型目录适配

- 使用静态模型库识别能力并过滤未接入的生成模型

- 增加扁平模型列表、搜索筛选和幂等添加
This commit is contained in:
2026-07-21 19:01:33 +08:00
parent 9436cc5397
commit 53fb63802b
35 changed files with 15397 additions and 3 deletions

View File

@@ -1,21 +1,33 @@
package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.ai.dto.RemoteModelImportRequest;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.entity.ModelProvider;
import tech.easyflow.ai.service.ModelProviderService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.service.discovery.RemoteModelDiscoveryService;
import tech.easyflow.ai.service.discovery.RemoteModelImportResult;
import tech.easyflow.ai.service.discovery.RemoteModelImportService;
import tech.easyflow.ai.service.discovery.RemoteModelListResult;
import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import java.io.Serializable;
import java.math.BigInteger;
/**
* 控制层。
@@ -28,12 +40,33 @@ import java.io.Serializable;
@UsePermission(moduleName = "/api/v1/model")
public class ModelProviderController extends BaseCurdController<ModelProviderService, ModelProvider> {
private final ModelService modelService;
private final RemoteModelDiscoveryService remoteModelDiscoveryService;
private final RemoteModelImportService remoteModelImportService;
public ModelProviderController(ModelProviderService service, ModelService modelService) {
/**
* 创建模型服务商控制器。
*
* @param service 模型服务商服务
* @param modelService 模型服务
* @param remoteModelDiscoveryService 远端模型发现服务
* @param remoteModelImportService 远端模型一键添加服务
*/
public ModelProviderController(ModelProviderService service,
ModelService modelService,
RemoteModelDiscoveryService remoteModelDiscoveryService,
RemoteModelImportService remoteModelImportService) {
super(service);
this.modelService = modelService;
this.remoteModelDiscoveryService = remoteModelDiscoveryService;
this.remoteModelImportService = remoteModelImportService;
}
/**
* 删除没有子模型的服务商。
*
* @param id 服务商 ID
* @return 删除结果
*/
@Override
@PostMapping("remove")
@Transactional
@@ -45,4 +78,35 @@ public class ModelProviderController extends BaseCurdController<ModelProviderSer
}
return Result.ok(service.removeById(id));
}
}
/**
* 主动获取当前服务商的远端模型列表。
*
* @param providerId 服务商 ID
* @return 已补全能力和本地添加状态的远端模型列表
*/
@GetMapping("{providerId}/remoteModels")
@SaCheckPermission("/api/v1/model/save")
public Result<RemoteModelListResult> remoteModels(@PathVariable BigInteger providerId) {
return Result.ok(remoteModelDiscoveryService.discover(providerId));
}
/**
* 幂等添加单个远端模型。
*
* @param providerId 服务商 ID
* @param request 一键添加请求
* @return 创建或已存在结果
*/
@PostMapping("{providerId}/remoteModels/import")
@SaCheckPermission("/api/v1/model/save")
public Result<RemoteModelImportResult> importRemoteModel(
@PathVariable BigInteger providerId,
@RequestBody RemoteModelImportRequest request) {
LoginAccount account = SaTokenUtil.getLoginAccount();
Model model = new Model();
commonFiled(model, account.getId(), account.getTenantId(), account.getDeptId());
String modelId = request == null ? null : request.getModelId();
return Result.ok(remoteModelImportService.importModel(providerId, modelId, model));
}
}

View File

@@ -0,0 +1,28 @@
package tech.easyflow.ai.dto;
/**
* 远端模型一键添加请求。
*/
public class RemoteModelImportRequest {
/** 待添加的远端原始模型 ID。 */
private String modelId;
/**
* 获取待添加模型 ID。
*
* @return 远端原始模型 ID
*/
public String getModelId() {
return modelId;
}
/**
* 设置待添加模型 ID。
*
* @param modelId 远端原始模型 ID
*/
public void setModelId(String modelId) {
this.modelId = modelId;
}
}

View File

@@ -1,8 +1,12 @@
package tech.easyflow.ai.mapper;
import com.mybatisflex.core.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import tech.easyflow.ai.entity.ModelProvider;
import java.math.BigInteger;
/**
* 映射层。
*
@@ -11,4 +15,12 @@ import tech.easyflow.ai.entity.ModelProvider;
*/
public interface ModelProviderMapper extends BaseMapper<ModelProvider> {
/**
* 锁定服务商记录,用于串行化同一服务商下的幂等模型导入。
*
* @param id 服务商 ID
* @return 已锁定的服务商 ID不存在时返回 null
*/
@Select("SELECT id FROM tb_model_provider WHERE id = #{id} FOR UPDATE")
BigInteger lockById(@Param("id") BigInteger id);
}

View File

@@ -0,0 +1,312 @@
package tech.easyflow.ai.service.capability;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.entity.Model;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/**
* 从静态 {@code llm.json} 加载模型能力,并建立常量时间查询索引。
*/
@Component
public class ModelCapabilityCatalog {
/** classpath 模型能力库资源。 */
private static final String CATALOG_RESOURCE = "llm.json";
/** 按规范化完整模型 ID 建立的目录索引。 */
private final Map<String, ModelCatalogMetadata> metadataById;
/** 仅在模型短 ID 唯一时建立的目录别名索引。 */
private final Map<String, ModelCatalogMetadata> metadataByAlias;
/**
* 加载并索引静态模型能力库。
*
* @param objectMapper JSON 解析器
* @throws IllegalStateException 静态资源缺失或格式非法时抛出
*/
public ModelCapabilityCatalog(ObjectMapper objectMapper) {
Map<String, ModelCatalogMetadata> fullIdIndex = new HashMap<>();
Map<String, ModelCatalogMetadata> aliasCandidates = new HashMap<>();
Set<String> ambiguousAliases = new HashSet<>();
loadCatalog(objectMapper, fullIdIndex, aliasCandidates, ambiguousAliases);
ambiguousAliases.forEach(aliasCandidates::remove);
this.metadataById = Map.copyOf(fullIdIndex);
this.metadataByAlias = Map.copyOf(aliasCandidates);
}
/**
* 查询模型能力。
*
* @param providerType EasyFlow 供应商类型
* @param modelId 用户配置的模型 ID
* @return 命中的模型能力
*/
public Optional<ModelCapabilityResolution> find(String providerType, String modelId) {
return findMetadata(providerType, modelId).map(ModelCatalogMetadata::getCapability);
}
/**
* 查询模型目录元数据。
*
* @param providerType EasyFlow 供应商类型
* @param modelId 用户配置的模型 ID
* @return 命中的模型目录元数据
*/
public Optional<ModelCatalogMetadata> findMetadata(String providerType, String modelId) {
String normalizedId = normalize(modelId);
if (normalizedId.isEmpty()) {
return Optional.empty();
}
ModelCatalogMetadata direct = metadataById.get(normalizedId);
if (direct != null) {
return Optional.of(direct);
}
String providerPrefix = providerPrefix(providerType);
if (!providerPrefix.isEmpty() && !normalizedId.contains("/")) {
direct = metadataById.get(providerPrefix + "/" + normalizedId);
if (direct != null) {
return Optional.of(direct);
}
}
String alias = shortId(normalizedId);
return Optional.ofNullable(metadataByAlias.get(alias));
}
/**
* 读取静态 JSON 并建立完整 ID 与唯一短 ID 索引。
*
* @param objectMapper JSON 解析器
* @param fullIdIndex 完整 ID 索引
* @param aliasCandidates 短 ID 候选索引
* @param ambiguousAliases 存在冲突的短 ID
*/
private void loadCatalog(ObjectMapper objectMapper,
Map<String, ModelCatalogMetadata> fullIdIndex,
Map<String, ModelCatalogMetadata> aliasCandidates,
Set<String> ambiguousAliases) {
ClassPathResource resource = new ClassPathResource(CATALOG_RESOURCE);
try (InputStream inputStream = resource.getInputStream()) {
JsonNode root = objectMapper.readTree(inputStream);
if (root == null || !root.isObject()) {
throw new IllegalStateException("模型能力库根节点必须是 JSON 对象");
}
Iterator<Map.Entry<String, JsonNode>> fields = root.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> field = fields.next();
String normalizedId = normalize(field.getKey());
if (normalizedId.isEmpty() || !field.getValue().isObject()) {
continue;
}
ModelCatalogMetadata metadata = toMetadata(normalizedId, field.getValue());
fullIdIndex.put(normalizedId, metadata);
registerAlias(shortId(normalizedId), metadata, aliasCandidates, ambiguousAliases);
}
} catch (IOException exception) {
throw new IllegalStateException("无法加载模型能力库 " + CATALOG_RESOURCE, exception);
}
}
/**
* 将目录条目转换为展示元数据与能力信息。
*
* @param normalizedId 规范化模型 ID
* @param node 模型目录条目
* @return 模型目录元数据
*/
private ModelCatalogMetadata toMetadata(String normalizedId, JsonNode node) {
return new ModelCatalogMetadata(
normalizedId,
textValue(node, "name"),
textValue(node, "family"),
modalities(node, "input"),
modalities(node, "output"),
toCapability(normalizedId, node));
}
/**
* 将模型目录条目转换为 EasyFlow 能力结果。
*
* @param normalizedId 规范化模型 ID
* @param node 模型目录条目
* @return EasyFlow 能力结果
*/
private ModelCapabilityResolution toCapability(String normalizedId, JsonNode node) {
String modelType = resolveModelType(normalizedId);
if (!Model.MODEL_TYPES[0].equals(modelType)) {
return new ModelCapabilityResolution(
modelType,
Boolean.FALSE,
Boolean.FALSE,
Boolean.FALSE,
ModelCapabilitySource.CATALOG);
}
return new ModelCapabilityResolution(
modelType,
hasInputModality(node, "image"),
booleanValue(node, "reasoning"),
booleanValue(node, "tool_call"),
ModelCapabilitySource.CATALOG);
}
/**
* 根据模型目录 ID 识别互斥模型类型。
*
* @param normalizedId 规范化模型 ID
* @return EasyFlow 模型类型
*/
private String resolveModelType(String normalizedId) {
if (ModelCapabilityNameRules.isRerankModel(normalizedId)) {
return Model.MODEL_TYPES[2];
}
if (ModelCapabilityNameRules.isEmbeddingModel(normalizedId)) {
return Model.MODEL_TYPES[1];
}
return Model.MODEL_TYPES[0];
}
/**
* 读取布尔字段,缺失时按 false 处理。
*
* @param node 模型条目
* @param fieldName 字段名
* @return 布尔字段值
*/
private boolean booleanValue(JsonNode node, String fieldName) {
JsonNode value = node.get(fieldName);
return value != null && value.asBoolean(false);
}
/**
* 判断模型输入模态是否包含指定类型。
*
* @param node 模型条目
* @param modality 输入模态
* @return 包含指定模态返回 true
*/
private boolean hasInputModality(JsonNode node, String modality) {
JsonNode inputs = node.path("modalities").path("input");
if (!inputs.isArray()) {
return false;
}
for (JsonNode input : inputs) {
if (modality.equalsIgnoreCase(input.asText())) {
return true;
}
}
return false;
}
/**
* 读取非空文本字段。
*
* @param node 模型目录条目
* @param fieldName 字段名
* @return 去除首尾空白的文本,缺失时返回 null
*/
private String textValue(JsonNode node, String fieldName) {
JsonNode value = node.get(fieldName);
if (value == null || !value.isTextual() || value.asText().isBlank()) {
return null;
}
return value.asText().trim();
}
/**
* 读取并规范化模型模态集合。
*
* @param node 模型目录条目
* @param direction input 或 output
* @return 小写模态集合
*/
private Set<String> modalities(JsonNode node, String direction) {
JsonNode values = node.path("modalities").path(direction);
if (!values.isArray()) {
return Set.of();
}
Set<String> modalities = new HashSet<>();
values.forEach(value -> {
if (value.isTextual() && !value.asText().isBlank()) {
modalities.add(value.asText().trim().toLowerCase(Locale.ROOT));
}
});
return Set.copyOf(modalities);
}
/**
* 注册无冲突的模型短 ID。
*
* @param alias 模型短 ID
* @param capability 模型能力
* @param aliasCandidates 短 ID 候选索引
* @param ambiguousAliases 冲突短 ID 集合
*/
private void registerAlias(String alias,
ModelCatalogMetadata metadata,
Map<String, ModelCatalogMetadata> aliasCandidates,
Set<String> ambiguousAliases) {
if (alias.isEmpty() || ambiguousAliases.contains(alias)) {
return;
}
ModelCatalogMetadata previous = aliasCandidates.putIfAbsent(alias, metadata);
if (previous != null) {
aliasCandidates.remove(alias);
ambiguousAliases.add(alias);
}
}
/**
* 将 EasyFlow 供应商类型映射为 models.dev 前缀。
*
* @param providerType EasyFlow 供应商类型
* @return models.dev 供应商前缀,未知时返回空字符串
*/
private String providerPrefix(String providerType) {
return switch (normalize(providerType)) {
case "dashscope", "bailian", "aliyun" -> "alibaba";
case "gemini" -> "google";
case "kimi" -> "moonshotai";
case "zhipu" -> "zhipuai";
case "minimax" -> "minimax";
case "azure-openai", "azure_openai" -> "openai";
case "openai", "anthropic", "deepseek", "google", "xai", "mistral", "cohere" ->
normalize(providerType);
default -> "";
};
}
/**
* 规范化模型或供应商标识。
*
* @param value 原始值
* @return 小写且去除首尾空白的标识
*/
private String normalize(String value) {
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
}
/**
* 提取最后一个路径段作为模型短 ID。
*
* @param normalizedId 规范化模型 ID
* @return 模型短 ID
*/
private String shortId(String normalizedId) {
int separator = normalizedId.lastIndexOf('/');
return separator < 0 ? normalizedId : normalizedId.substring(separator + 1);
}
}

View File

@@ -0,0 +1,75 @@
package tech.easyflow.ai.service.capability;
import java.util.Locale;
import java.util.regex.Pattern;
/**
* 未命中模型目录时使用的保守命名规则。
*/
final class ModelCapabilityNameRules {
private static final Pattern RERANK_PATTERN = Pattern.compile(
"(^|[/_.:-])rerank(?:er)?($|[/_.:-])");
private static final Pattern EMBEDDING_PATTERN = Pattern.compile(
"(^|[/_.:-])(embedding|embed)($|[/_.:-])|(^|/)bge-m3($|[/_.:-])"
+ "|(^|/)(e5|gte)(-|$)");
private static final Pattern VISION_PATTERN = Pattern.compile(
"(^|[/_.:-])(vl|vision|visual|omni)($|[/_.:-])");
private static final Pattern REASONING_PATTERN = Pattern.compile(
"reasoning|reasoner|deepseek-r1|(^|[/_.:-])r1($|[/_.:-])"
+ "|(^|[/_.:-])qwq($|[/_.:-])|(^|[/_.:-])o[134]($|[/_.:-])");
/** 禁止实例化规则工具类。 */
private ModelCapabilityNameRules() {
}
/**
* 判断模型 ID 是否明确指向重排模型。
*
* @param modelId 模型 ID
* @return 明确为重排模型返回 true
*/
static boolean isRerankModel(String modelId) {
return RERANK_PATTERN.matcher(normalize(modelId)).find();
}
/**
* 判断模型 ID 是否明确指向嵌入模型。
*
* @param modelId 模型 ID
* @return 明确为嵌入模型返回 true
*/
static boolean isEmbeddingModel(String modelId) {
return EMBEDDING_PATTERN.matcher(normalize(modelId)).find();
}
/**
* 判断模型 ID 是否明确指向视觉模型。
*
* @param modelId 模型 ID
* @return 明确支持视觉输入返回 true
*/
static boolean supportsVision(String modelId) {
return VISION_PATTERN.matcher(normalize(modelId)).find();
}
/**
* 判断模型 ID 是否明确指向推理模型。
*
* @param modelId 模型 ID
* @return 明确支持推理返回 true
*/
static boolean supportsReasoning(String modelId) {
return REASONING_PATTERN.matcher(normalize(modelId)).find();
}
/**
* 规范化待匹配模型 ID。
*
* @param modelId 原始模型 ID
* @return 规范化模型 ID
*/
private static String normalize(String modelId) {
return modelId == null ? "" : modelId.trim().toLowerCase(Locale.ROOT);
}
}

View File

@@ -0,0 +1,93 @@
package tech.easyflow.ai.service.capability;
/**
* 模型类型与对话能力识别结果。
*/
public final class ModelCapabilityResolution {
/** 模型类型。 */
private final String modelType;
/** 是否支持视觉输入,空值表示未知。 */
private final Boolean supportImage;
/** 是否支持推理,空值表示未知。 */
private final Boolean supportThinking;
/** 是否支持工具调用,空值表示未知。 */
private final Boolean supportTool;
/** 能力识别来源。 */
private final ModelCapabilitySource source;
/**
* 创建模型能力识别结果。
*
* @param modelType 模型类型
* @param supportImage 是否支持视觉输入
* @param supportThinking 是否支持推理
* @param supportTool 是否支持工具调用
* @param source 能力识别来源
*/
public ModelCapabilityResolution(String modelType,
Boolean supportImage,
Boolean supportThinking,
Boolean supportTool,
ModelCapabilitySource source) {
this.modelType = modelType;
this.supportImage = supportImage;
this.supportThinking = supportThinking;
this.supportTool = supportTool;
this.source = source;
}
/**
* 获取模型类型。
*
* @return 模型类型
*/
public String getModelType() {
return modelType;
}
/**
* 获取视觉输入能力。
*
* @return 是否支持视觉输入,空值表示未知
*/
public Boolean getSupportImage() {
return supportImage;
}
/**
* 获取推理能力。
*
* @return 是否支持推理,空值表示未知
*/
public Boolean getSupportThinking() {
return supportThinking;
}
/**
* 获取工具调用能力。
*
* @return 是否支持工具调用,空值表示未知
*/
public Boolean getSupportTool() {
return supportTool;
}
/**
* 获取能力识别来源。
*
* @return 能力识别来源
*/
public ModelCapabilitySource getSource() {
return source;
}
/**
* 判断是否获得了模型库或命名规则证据。
*
* @return 已识别返回 true
*/
public boolean isDetected() {
return source != ModelCapabilitySource.DEFAULT;
}
}

View File

@@ -0,0 +1,64 @@
package tech.easyflow.ai.service.capability;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.entity.Model;
/**
* 统一解析静态目录和保守命名规则中的模型能力。
*/
@Component
public class ModelCapabilityResolver {
/** 静态模型能力目录。 */
private final ModelCapabilityCatalog catalog;
/**
* 创建模型能力解析器。
*
* @param catalog 静态模型能力目录
*/
public ModelCapabilityResolver(ModelCapabilityCatalog catalog) {
this.catalog = catalog;
}
/**
* 解析模型类型和对话能力。
*
* @param providerType 供应商类型
* @param modelId 模型 ID
* @return 模型能力识别结果
*/
public ModelCapabilityResolution resolve(String providerType, String modelId) {
return catalog.find(providerType, modelId).orElseGet(() -> resolveByName(modelId));
}
/**
* 对未命中目录的模型执行保守命名推断。
*
* @param modelId 模型 ID
* @return 模型能力识别结果
*/
private ModelCapabilityResolution resolveByName(String modelId) {
if (ModelCapabilityNameRules.isRerankModel(modelId)) {
return new ModelCapabilityResolution(
Model.MODEL_TYPES[2], false, false, false, ModelCapabilitySource.RULE);
}
if (ModelCapabilityNameRules.isEmbeddingModel(modelId)) {
return new ModelCapabilityResolution(
Model.MODEL_TYPES[1], false, false, false, ModelCapabilitySource.RULE);
}
boolean vision = ModelCapabilityNameRules.supportsVision(modelId);
boolean reasoning = ModelCapabilityNameRules.supportsReasoning(modelId);
if (vision || reasoning) {
return new ModelCapabilityResolution(
Model.MODEL_TYPES[0],
vision ? Boolean.TRUE : null,
reasoning ? Boolean.TRUE : null,
null,
ModelCapabilitySource.RULE);
}
return new ModelCapabilityResolution(
Model.MODEL_TYPES[0], null, null, null, ModelCapabilitySource.DEFAULT);
}
}

View File

@@ -0,0 +1,13 @@
package tech.easyflow.ai.service.capability;
/**
* 模型能力识别来源。
*/
public enum ModelCapabilitySource {
/** 静态模型能力库精确命中。 */
CATALOG,
/** 根据稳定模型命名规则推断。 */
RULE,
/** 未识别模型使用的保守默认值。 */
DEFAULT
}

View File

@@ -0,0 +1,127 @@
package tech.easyflow.ai.service.capability;
import java.util.Set;
/**
* 静态模型目录中的展示元数据与能力信息。
*/
public final class ModelCatalogMetadata {
/** 规范化模型目录 ID。 */
private final String modelId;
/** 模型展示名称。 */
private final String displayName;
/** 模型家族。 */
private final String family;
/** 输入模态。 */
private final Set<String> inputModalities;
/** 输出模态。 */
private final Set<String> outputModalities;
/** EasyFlow 模型能力。 */
private final ModelCapabilityResolution capability;
/**
* 创建模型目录元数据。
*
* @param modelId 规范化模型目录 ID
* @param displayName 模型展示名称
* @param family 模型家族
* @param inputModalities 输入模态
* @param outputModalities 输出模态
* @param capability EasyFlow 模型能力
*/
public ModelCatalogMetadata(String modelId,
String displayName,
String family,
Set<String> inputModalities,
Set<String> outputModalities,
ModelCapabilityResolution capability) {
this.modelId = modelId;
this.displayName = displayName;
this.family = family;
this.inputModalities = Set.copyOf(inputModalities);
this.outputModalities = Set.copyOf(outputModalities);
this.capability = capability;
}
/**
* 获取模型展示名称。
*
* @return 模型展示名称
*/
public String getDisplayName() {
return displayName;
}
/**
* 获取模型家族。
*
* @return 模型家族
*/
public String getFamily() {
return family;
}
/**
* 获取输入模态。
*
* @return 不可变输入模态集合
*/
public Set<String> getInputModalities() {
return inputModalities;
}
/**
* 获取输出模态。
*
* @return 不可变输出模态集合
*/
public Set<String> getOutputModalities() {
return outputModalities;
}
/**
* 获取 EasyFlow 模型能力。
*
* @return 模型能力
*/
public ModelCapabilityResolution getCapability() {
return capability;
}
/**
* 判断目录条目是否为当前系统尚未接入的生成模型。
*
* @return 已知属于媒体生成模型时返回 true
*/
public boolean isUnsupportedGenerationModel() {
if (outputModalities.isEmpty()) {
return false;
}
boolean mediaOutput = outputModalities.contains("image")
|| outputModalities.contains("video")
|| outputModalities.contains("audio");
if (mediaOutput && !outputModalities.contains("text")) {
return true;
}
String normalizedFamily = family == null ? "" : family.toLowerCase();
return mediaOutput && (containsGenerationKeyword(modelId)
|| containsGenerationKeyword(normalizedFamily));
}
/**
* 判断模型标识是否明确属于媒体生成家族。
*
* @param value 规范化模型 ID 或家族
* @return 命中明确生成模型关键词返回 true
*/
private boolean containsGenerationKeyword(String value) {
return value.contains("gpt-image")
|| value.contains("dall-e")
|| value.contains("stable-diffusion")
|| value.contains("text-to-image")
|| value.contains("text-to-video")
|| value.contains("image-generation")
|| value.contains("video-generation");
}
}

View File

@@ -0,0 +1,134 @@
package tech.easyflow.ai.service.discovery;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.entity.ModelProvider;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 阿里百炼可部署基础模型目录适配器。
*/
@Component
public class AliyunRemoteModelAdapter implements RemoteModelProviderAdapter {
/** 单页最大模型数量。 */
private static final int PAGE_SIZE = 100;
/** 最大安全分页数。 */
private static final int MAX_PAGES = 10;
/** 当前未接入的百炼媒体生成及内部算法模型。 */
private static final Set<String> UNSUPPORTED_MODEL_IDS = Set.of(
"animate-anyone",
"animate-anyone-detect",
"emo",
"emo-detect",
"mock-algo-v1",
"wanx-v1-0521");
/**
* 分页获取阿里百炼基础模型 ID。
*
* @param provider 已保存的服务商配置
* @param httpClient 受控 HTTP 客户端
* @return 原始模型 ID 列表
* @throws BusinessException 响应结构不兼容时抛出
*/
@Override
public List<String> fetchModelIds(ModelProvider provider, RemoteModelHttpClient httpClient) {
List<String> modelIds = new ArrayList<>();
for (int page = 1; page <= MAX_PAGES; page++) {
JsonNode root = httpClient.getJson(provider, "/api/v1/deployments/models",
queryParameters(page));
JsonNode models = modelsNode(root);
if (models == null || !models.isArray()) {
throw new BusinessException(502, 50233, "阿里百炼模型列表响应格式不兼容");
}
models.forEach(item -> {
JsonNode value = item.get("model_name");
if (value != null && value.isTextual()) {
String modelId = value.asText();
if (!modelId.isBlank() && !UNSUPPORTED_MODEL_IDS.contains(modelId)) {
modelIds.add(modelId);
}
}
});
if (!hasNextPage(root, models.size(), page)) {
break;
}
}
return modelIds;
}
/**
* 构建单页受控查询参数。
*
* @param page 页码
* @return 查询参数
*/
private Map<String, String> queryParameters(int page) {
Map<String, String> parameters = new LinkedHashMap<>();
parameters.put("model_source", "base");
parameters.put("page_no", String.valueOf(page));
parameters.put("page_size", String.valueOf(PAGE_SIZE));
// v1.0 才会返回当前完整的可部署基础模型目录;省略时可能退回旧版模型集合。
parameters.put("version", "v1.0");
return parameters;
}
/**
* 兼容官方 output 节点及历史根节点、data 节点中的 models 数组。
*
* @param root JSON 根节点
* @return models 节点
*/
private JsonNode modelsNode(JsonNode root) {
return responsePayload(root).get("models");
}
/**
* 根据明确分页字段或当前页数量判断是否继续。
*
* @param root JSON 根节点
* @param currentSize 当前页数量
* @param page 当前页码
* @return 需要继续分页返回 true
*/
private boolean hasNextPage(JsonNode root, int currentSize, int page) {
JsonNode payload = responsePayload(root);
boolean hasMore = payload.path("has_more").asBoolean(false);
long total = payload.path("total").asLong(payload.path("total_count").asLong(-1));
int responsePage = payload.path("page_no").asInt(page);
int responsePageSize = payload.path("page_size").asInt(PAGE_SIZE);
if (hasMore) {
return true;
}
if (total >= 0) {
return (long) responsePage * responsePageSize < total;
}
return currentSize == PAGE_SIZE;
}
/**
* 获取承载模型列表与分页字段的响应节点。
*
* @param root JSON 根节点
* @return 官方 output、嵌套 output、data 或根节点
*/
private JsonNode responsePayload(JsonNode root) {
JsonNode output = root.path("output");
if (output.isObject()) {
return output;
}
JsonNode data = root.path("data");
JsonNode nestedOutput = data.path("output");
if (nestedOutput.isObject()) {
return nestedOutput;
}
return data.isObject() ? data : root;
}
}

View File

@@ -0,0 +1,45 @@
package tech.easyflow.ai.service.discovery;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.entity.ModelProvider;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Ollama 原生 {@code /api/tags} 模型目录适配器。
*/
@Component
public class OllamaRemoteModelAdapter implements RemoteModelProviderAdapter {
/**
* 获取 Ollama 本地模型 ID。
*
* @param provider 已保存的服务商配置
* @param httpClient 受控 HTTP 客户端
* @return 原始模型 ID 列表
* @throws BusinessException 响应结构不兼容时抛出
*/
@Override
public List<String> fetchModelIds(ModelProvider provider, RemoteModelHttpClient httpClient) {
JsonNode root = httpClient.getJson(provider, "/api/tags", Map.of());
JsonNode models = root.get("models");
if (models == null || !models.isArray()) {
throw new BusinessException(502, 50232, "Ollama 模型列表响应格式不兼容");
}
List<String> modelIds = new ArrayList<>();
models.forEach(item -> {
JsonNode value = item.get("name");
if (value == null || !value.isTextual() || value.asText().isBlank()) {
value = item.get("model");
}
if (value != null && value.isTextual() && !value.asText().isBlank()) {
modelIds.add(value.asText());
}
});
return modelIds;
}
}

View File

@@ -0,0 +1,83 @@
package tech.easyflow.ai.service.discovery;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.entity.ModelProvider;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* OpenAI-compatible {@code data[].id} 模型目录适配器。
*/
@Component
public class OpenAiCompatibleRemoteModelAdapter implements RemoteModelProviderAdapter {
/**
* 获取 OpenAI-compatible 模型 ID。
*
* @param provider 已保存的服务商配置
* @param httpClient 受控 HTTP 客户端
* @return 原始模型 ID 列表
* @throws BusinessException 对话路径或响应结构不兼容时抛出
*/
@Override
public List<String> fetchModelIds(ModelProvider provider, RemoteModelHttpClient httpClient) {
String modelsPath = deriveModelsPath(provider.getChatPath());
Map<String, String> query = "siliconflow".equals(normalize(provider.getProviderType()))
? Map.of("type", "text") : Map.of();
JsonNode root = httpClient.getJson(provider, modelsPath, query);
JsonNode data = root.get("data");
if (data == null || !data.isArray()) {
throw new BusinessException(502, 50231, "模型列表响应格式不兼容");
}
List<String> modelIds = new ArrayList<>();
data.forEach(item -> addText(modelIds, item.get("id")));
return modelIds;
}
/**
* 从对话路径推导同版本的 models 路径。
*
* @param chatPath 已保存的对话路径
* @return models 路径
* @throws BusinessException 路径不符合兼容协议时抛出
*/
public String deriveModelsPath(String chatPath) {
if (chatPath == null || chatPath.isBlank()) {
return "/v1/models";
}
String normalized = chatPath.trim();
String suffix = "/chat/completions";
if (!normalized.toLowerCase(Locale.ROOT).endsWith(suffix)) {
throw new BusinessException(422, 42231, "当前服务暂不支持获取模型列表");
}
String prefix = normalized.substring(0, normalized.length() - suffix.length());
return (prefix.isBlank() ? "" : prefix) + "/models";
}
/**
* 添加非空文本模型 ID。
*
* @param target 结果列表
* @param value JSON 文本节点
*/
private void addText(List<String> target, JsonNode value) {
if (value != null && value.isTextual() && !value.asText().isBlank()) {
target.add(value.asText());
}
}
/**
* 规范化供应商类型。
*
* @param value 原始供应商类型
* @return 小写供应商类型
*/
private String normalize(String value) {
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
}
}

View File

@@ -0,0 +1,126 @@
package tech.easyflow.ai.service.discovery;
import tech.easyflow.ai.service.capability.ModelCapabilitySource;
/**
* 远端模型在管理端使用的统一描述。
*/
public final class RemoteModelDescriptor {
/** 远端原始模型 ID。 */
private final String modelId;
/** 模型展示名称。 */
private final String displayName;
/** 模型家族。 */
private final String family;
/** EasyFlow 模型类型。 */
private final String modelType;
/** 是否支持视觉输入。 */
private final Boolean supportImage;
/** 是否支持推理。 */
private final Boolean supportThinking;
/** 是否支持工具调用。 */
private final Boolean supportTool;
/** 能力识别来源。 */
private final ModelCapabilitySource capabilitySource;
/** 当前租户是否已经添加。 */
private final boolean added;
/** 当前模型是否允许一键添加。 */
private final boolean addable;
/** 无法添加时的简短原因。 */
private final String unavailableReason;
/**
* 创建远端模型描述。
*
* @param modelId 远端原始模型 ID
* @param displayName 模型展示名称
* @param family 模型家族
* @param modelType EasyFlow 模型类型
* @param supportImage 是否支持视觉输入
* @param supportThinking 是否支持推理
* @param supportTool 是否支持工具调用
* @param capabilitySource 能力识别来源
* @param added 是否已经添加
* @param addable 是否允许一键添加
* @param unavailableReason 无法添加原因
*/
public RemoteModelDescriptor(String modelId,
String displayName,
String family,
String modelType,
Boolean supportImage,
Boolean supportThinking,
Boolean supportTool,
ModelCapabilitySource capabilitySource,
boolean added,
boolean addable,
String unavailableReason) {
this.modelId = modelId;
this.displayName = displayName;
this.family = family;
this.modelType = modelType;
this.supportImage = supportImage;
this.supportThinking = supportThinking;
this.supportTool = supportTool;
this.capabilitySource = capabilitySource;
this.added = added;
this.addable = addable;
this.unavailableReason = unavailableReason;
}
/** @return 远端原始模型 ID */
public String getModelId() {
return modelId;
}
/** @return 模型展示名称 */
public String getDisplayName() {
return displayName;
}
/** @return 模型家族 */
public String getFamily() {
return family;
}
/** @return EasyFlow 模型类型 */
public String getModelType() {
return modelType;
}
/** @return 是否支持视觉输入,空值表示未知 */
public Boolean getSupportImage() {
return supportImage;
}
/** @return 是否支持推理,空值表示未知 */
public Boolean getSupportThinking() {
return supportThinking;
}
/** @return 是否支持工具调用,空值表示未知 */
public Boolean getSupportTool() {
return supportTool;
}
/** @return 能力识别来源 */
public ModelCapabilitySource getCapabilitySource() {
return capabilitySource;
}
/** @return 已经添加返回 true */
public boolean isAdded() {
return added;
}
/** @return 允许一键添加返回 true */
public boolean isAddable() {
return addable;
}
/** @return 无法添加原因,可添加时为 null */
public String getUnavailableReason() {
return unavailableReason;
}
}

View File

@@ -0,0 +1,135 @@
package tech.easyflow.ai.service.discovery;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.entity.ModelProvider;
import tech.easyflow.ai.mapper.ModelMapper;
import tech.easyflow.ai.service.ModelProviderService;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 远端模型发现应用服务。
*/
@Service
public class RemoteModelDiscoveryService {
/** 单次发现最多返回的可用模型数量。 */
private static final int MAX_MODEL_COUNT = 1000;
/** 服务商服务。 */
private final ModelProviderService modelProviderService;
/** 本地模型映射器。 */
private final ModelMapper modelMapper;
/** 静态适配表。 */
private final RemoteModelProviderAdapterRegistry adapterRegistry;
/** 受控 HTTP 客户端。 */
private final RemoteModelHttpClient httpClient;
/** 模型元数据解析器。 */
private final RemoteModelMetadataResolver metadataResolver;
/**
* 创建远端模型发现服务。
*
* @param modelProviderService 服务商服务
* @param modelMapper 本地模型映射器
* @param adapterRegistry 静态适配表
* @param httpClient 受控 HTTP 客户端
* @param metadataResolver 模型元数据解析器
*/
public RemoteModelDiscoveryService(ModelProviderService modelProviderService,
ModelMapper modelMapper,
RemoteModelProviderAdapterRegistry adapterRegistry,
RemoteModelHttpClient httpClient,
RemoteModelMetadataResolver metadataResolver) {
this.modelProviderService = modelProviderService;
this.modelMapper = modelMapper;
this.adapterRegistry = adapterRegistry;
this.httpClient = httpClient;
this.metadataResolver = metadataResolver;
}
/**
* 主动获取指定服务商的远端模型列表。
*
* @param providerId 服务商 ID
* @return 已补全能力和本地添加状态的模型列表
* @throws BusinessException 服务商不存在或远端发现失败时抛出
*/
public RemoteModelListResult discover(BigInteger providerId) {
if (providerId == null) {
throw new BusinessException(400, 40031, "服务商 ID 不能为空");
}
ModelProvider provider = modelProviderService.getById(providerId);
if (provider == null) {
throw new BusinessException(404, 40431, "模型服务商不存在");
}
RemoteModelProviderAdapter adapter = adapterRegistry.get(provider.getProviderType());
List<String> fetchedIds = adapter.fetchModelIds(provider, httpClient);
LinkedHashSet<String> uniqueIds = normalizeModelIds(fetchedIds);
Set<String> addedModelIds = loadAddedModelIds(providerId);
List<String> supportedIds = uniqueIds.stream()
.filter(modelId -> !metadataResolver.isUnsupportedGenerationModel(
provider.getProviderType(), modelId))
.collect(Collectors.toList());
boolean truncated = supportedIds.size() > MAX_MODEL_COUNT;
List<RemoteModelDescriptor> descriptors = supportedIds.stream()
.limit(MAX_MODEL_COUNT)
.map(modelId -> metadataResolver.describe(
provider.getProviderType(), modelId, addedModelIds.contains(modelId)))
.sorted(Comparator.comparing(RemoteModelDescriptor::getFamily,
String.CASE_INSENSITIVE_ORDER)
.thenComparing(RemoteModelDescriptor::getDisplayName,
String.CASE_INSENSITIVE_ORDER)
.thenComparing(RemoteModelDescriptor::getModelId))
.collect(Collectors.toList());
return new RemoteModelListResult(providerId, descriptors, truncated);
}
/**
* 规范化、去重并过滤非法远端模型 ID。
*
* @param fetchedIds 适配器返回的原始列表
* @return 保持远端顺序的唯一模型 ID
*/
private LinkedHashSet<String> normalizeModelIds(List<String> fetchedIds) {
LinkedHashSet<String> uniqueIds = new LinkedHashSet<>();
if (fetchedIds == null) {
return uniqueIds;
}
for (String modelId : fetchedIds) {
if (modelId == null) {
continue;
}
String normalized = modelId.trim();
if (!normalized.isEmpty() && normalized.chars().noneMatch(Character::isISOControl)) {
uniqueIds.add(normalized);
}
}
return uniqueIds;
}
/**
* 加载当前租户在指定服务商下已添加的原始模型 ID。
*
* @param providerId 服务商 ID
* @return 已添加模型 ID 集合
*/
private Set<String> loadAddedModelIds(BigInteger providerId) {
QueryWrapper query = QueryWrapper.create().eq(Model::getProviderId, providerId);
List<Model> models = modelMapper.selectListByQuery(query);
return models.stream()
.map(Model::getModelName)
.filter(value -> value != null && !value.isBlank())
.collect(Collectors.toSet());
}
}

View File

@@ -0,0 +1,369 @@
package tech.easyflow.ai.service.discovery;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.entity.ModelProvider;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.io.IOException;
import java.io.InputStream;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.URI;
import java.net.URLEncoder;
import java.net.UnknownHostException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Comparator;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.zip.GZIPInputStream;
/**
* 使用服务端已保存配置执行受控的远端模型目录请求。
*/
@Component
public class RemoteModelHttpClient {
/** 日志记录器。 */
private static final Logger log = LoggerFactory.getLogger(RemoteModelHttpClient.class);
/** 最大响应体大小。 */
private static final int MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
/** 单次远端请求超时。 */
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(15);
/** 允许访问本机或内网地址的服务商类型。 */
private static final Set<String> PRIVATE_ENDPOINT_PROVIDER_TYPES = Set.of(
"ollama", "self-hosted", "self_hosted", "selfhost");
/** 始终禁止访问的元数据主机。 */
private static final Set<String> BLOCKED_HOSTS = Set.of(
"metadata.google.internal", "metadata.google.internal.", "100.100.100.200");
/** HTTP 客户端。 */
private final HttpClient httpClient;
/** JSON 解析器。 */
private final ObjectMapper objectMapper;
/**
* 创建受控远端模型 HTTP 客户端。
*
* @param objectMapper JSON 解析器
*/
public RemoteModelHttpClient(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(8))
.followRedirects(HttpClient.Redirect.NEVER)
.version(HttpClient.Version.HTTP_1_1)
.build();
}
/**
* 请求并解析模型目录 JSON。
*
* @param provider 已保存的模型服务商
* @param requestPath 静态适配器确定的请求路径
* @param queryParameters 受控查询参数
* @return JSON 根节点
* @throws BusinessException URL、网络、状态码、响应大小或 JSON 格式不合法时抛出
*/
public JsonNode getJson(ModelProvider provider,
String requestPath,
Map<String, String> queryParameters) {
URI uri = buildUri(provider, requestPath, queryParameters);
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(uri)
.timeout(REQUEST_TIMEOUT)
.header("Accept", "application/json")
.header("Accept-Encoding", "gzip")
.header("User-Agent", "EasyFlow-RemoteModelDiscovery/1.0")
.GET();
String apiKey = provider.getApiKey();
if (apiKey != null && !apiKey.isBlank()) {
String trimmedKey = apiKey.trim();
if (trimmedKey.indexOf('\r') >= 0 || trimmedKey.indexOf('\n') >= 0) {
throw new BusinessException(422, 42211, "API 密钥格式不正确");
}
requestBuilder.header("Authorization", "Bearer " + trimmedKey);
}
long startedAt = System.nanoTime();
try {
HttpResponse<InputStream> response = httpClient.send(
requestBuilder.build(), HttpResponse.BodyHandlers.ofInputStream());
log.info("远端模型目录请求完成 providerId={}, providerType={}, host={}, status={}, elapsedMs={}",
provider.getId(), provider.getProviderType(), uri.getHost(), response.statusCode(),
Duration.ofNanos(System.nanoTime() - startedAt).toMillis());
try (InputStream rawBody = response.body()) {
validateStatus(response.statusCode());
try (InputStream body = decodeResponseBody(response, rawBody)) {
byte[] payload = body.readNBytes(MAX_RESPONSE_BYTES + 1);
if (payload.length > MAX_RESPONSE_BYTES) {
throw new BusinessException(502, 50213, "远端模型列表响应过大");
}
JsonNode root;
try {
root = objectMapper.readTree(payload);
} catch (com.fasterxml.jackson.core.JsonProcessingException exception) {
throw new BusinessException(502, 50214,
"远端模型列表响应不是有效 JSON", exception);
}
if (root == null) {
throw new BusinessException(502, 50215, "远端模型列表响应为空");
}
return root;
}
}
} catch (BusinessException exception) {
throw exception;
} catch (java.net.http.HttpTimeoutException exception) {
log.error("远端模型目录请求超时 providerId={}, providerType={}, host={}",
provider.getId(), provider.getProviderType(), uri.getHost(), exception);
throw new BusinessException(504, 50411, "获取模型列表超时,请检查 API 地址后重试", exception);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
log.error("远端模型目录请求被中断 providerId={}, providerType={}, host={}",
provider.getId(), provider.getProviderType(), uri.getHost(), exception);
throw new BusinessException(503, 50311, "获取模型列表被中断,请稍后重试", exception);
} catch (IOException | IllegalArgumentException exception) {
log.error("远端模型目录请求失败 providerId={}, providerType={}, host={}",
provider.getId(), provider.getProviderType(), uri.getHost(), exception);
throw new BusinessException(502, 50211, "无法获取模型列表,请检查 API 地址和密钥", exception);
}
}
/**
* 根据响应编码解压响应体,降低大型模型目录的网络传输开销。
*
* @param response HTTP 响应
* @param rawBody 原始响应流
* @return 可直接读取的响应流
* @throws IOException gzip 响应无法解压时抛出
*/
private InputStream decodeResponseBody(HttpResponse<InputStream> response,
InputStream rawBody) throws IOException {
String contentEncoding = response.headers()
.firstValue("Content-Encoding")
.orElse("")
.trim();
if ("gzip".equalsIgnoreCase(contentEncoding)) {
return new GZIPInputStream(rawBody);
}
return rawBody;
}
/**
* 合并服务商 Endpoint、静态路径和受控查询参数。
*
* @param provider 已保存的模型服务商
* @param requestPath 静态请求路径
* @param queryParameters 受控查询参数
* @return 已完成安全校验的请求 URI
* @throws BusinessException URL 或目标地址不安全时抛出
*/
public URI buildUri(ModelProvider provider,
String requestPath,
Map<String, String> queryParameters) {
if (provider == null || provider.getEndpoint() == null || provider.getEndpoint().isBlank()) {
throw new BusinessException(422, 42212, "请先配置并保存 API 地址");
}
if (requestPath == null || requestPath.isBlank()
|| requestPath.contains("?") || requestPath.contains("#")) {
throw new BusinessException(422, 42213, "当前服务暂不支持获取模型列表");
}
try {
URI endpoint = URI.create(provider.getEndpoint().trim());
validateEndpoint(provider, endpoint);
String combinedPath = combinePaths(endpoint.getRawPath(), requestPath);
String query = buildQuery(queryParameters);
return new URI(endpoint.getScheme(), null, endpoint.getHost(), endpoint.getPort(),
combinedPath, query, null);
} catch (BusinessException exception) {
throw exception;
} catch (Exception exception) {
throw new BusinessException(422, 42214, "API 地址格式不正确", exception);
}
}
/**
* 校验 Endpoint 协议、主机与解析后的地址范围。
*
* @param provider 模型服务商
* @param endpoint Endpoint URI
*/
private void validateEndpoint(ModelProvider provider, URI endpoint) {
String scheme = endpoint.getScheme() == null
? "" : endpoint.getScheme().toLowerCase(Locale.ROOT);
if (!("http".equals(scheme) || "https".equals(scheme))
|| endpoint.getHost() == null
|| endpoint.getUserInfo() != null
|| endpoint.getQuery() != null
|| endpoint.getFragment() != null) {
throw new BusinessException(422, 42214, "API 地址格式不正确");
}
String host = endpoint.getHost().toLowerCase(Locale.ROOT);
if (BLOCKED_HOSTS.contains(host)) {
throw new BusinessException(422, 42215, "API 地址指向受限网络目标");
}
boolean privateEndpointAllowed = PRIVATE_ENDPOINT_PROVIDER_TYPES.contains(
normalize(provider.getProviderType()));
try {
for (InetAddress address : InetAddress.getAllByName(host)) {
if (isAlwaysBlocked(address)
|| (!privateEndpointAllowed && isPrivateOrLoopback(address))) {
throw new BusinessException(422, 42215, "API 地址指向受限网络目标");
}
}
} catch (UnknownHostException exception) {
throw new BusinessException(502, 50212, "API 地址无法解析", exception);
}
}
/**
* 判断地址是否在任何服务商下都禁止访问。
*
* @param address 已解析地址
* @return 禁止访问返回 true
*/
private boolean isAlwaysBlocked(InetAddress address) {
return address.isAnyLocalAddress()
|| address.isLinkLocalAddress()
|| address.isMulticastAddress()
|| "100.100.100.200".equals(address.getHostAddress());
}
/**
* 判断地址是否为内网或本机地址。
*
* @param address 已解析地址
* @return 内网或本机地址返回 true
*/
private boolean isPrivateOrLoopback(InetAddress address) {
return address.isLoopbackAddress()
|| address.isSiteLocalAddress()
|| isUniqueLocalIpv6(address);
}
/**
* 判断地址是否位于 IPv6 唯一本地地址段 fc00::/7。
*
* @param address 已解析地址
* @return 位于 fc00::/7 返回 true
*/
private boolean isUniqueLocalIpv6(InetAddress address) {
if (!(address instanceof Inet6Address)) {
return false;
}
return (address.getAddress()[0] & 0xFE) == 0xFC;
}
/**
* 合并 Endpoint 路径与模型目录路径。
*
* @param basePath Endpoint 自带路径
* @param requestPath 目录请求路径
* @return 规范化请求路径
*/
private String combinePaths(String basePath, String requestPath) {
String normalizedBase = normalizePath(basePath);
String normalizedRequest = normalizePath(requestPath);
if ("/".equals(normalizedBase)) {
return normalizedRequest;
}
if (normalizedRequest.equals(normalizedBase)
|| normalizedRequest.startsWith(normalizedBase + "/")) {
return normalizedRequest;
}
return normalizePath(normalizedBase + "/" + normalizedRequest.substring(1));
}
/**
* 规范化 URL 路径中的首尾与重复斜杠。
*
* @param path 原始路径
* @return 以单斜杠开头的路径
*/
private String normalizePath(String path) {
if (path == null || path.isBlank() || "/".equals(path.trim())) {
return "/";
}
String normalized = path.trim();
if (!normalized.startsWith("/")) {
normalized = "/" + normalized;
}
normalized = normalized.replaceAll("/{2,}", "/");
return normalized.length() > 1 && normalized.endsWith("/")
? normalized.substring(0, normalized.length() - 1) : normalized;
}
/**
* 构建顺序稳定的查询字符串。
*
* @param parameters 查询参数
* @return 查询字符串,无参数时返回 null
*/
private String buildQuery(Map<String, String> parameters) {
if (parameters == null || parameters.isEmpty()) {
return null;
}
return parameters.entrySet().stream()
.filter(entry -> entry.getKey() != null && entry.getValue() != null)
.sorted(Comparator.comparing(Map.Entry::getKey))
.map(entry -> encode(entry.getKey()) + "=" + encode(entry.getValue()))
.collect(Collectors.joining("&"));
}
/**
* 对单个查询参数执行 UTF-8 编码。
*
* @param value 参数值
* @return 编码结果
*/
private String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20");
}
/**
* 将远端 HTTP 状态映射为可恢复的业务错误。
*
* @param statusCode 远端 HTTP 状态码
* @throws BusinessException 非 2xx 状态时抛出
*/
private void validateStatus(int statusCode) {
if (statusCode >= 200 && statusCode < 300) {
return;
}
switch (statusCode) {
case 401 -> throw new BusinessException(422, 42221, "API 密钥无效,请检查服务商配置");
case 403 -> throw new BusinessException(422, 42222, "当前 API 密钥无权获取模型列表");
case 404, 405 -> throw new BusinessException(422, 42223, "当前服务暂不支持获取模型列表");
case 429 -> throw new BusinessException(429, 42911, "请求过于频繁,请稍后重试");
default -> {
if (statusCode >= 500) {
throw new BusinessException(502, 50221,
"模型服务暂时不可用HTTP " + statusCode + "");
}
throw new BusinessException(422, 42224,
"获取模型列表失败HTTP " + statusCode + "");
}
}
}
/**
* 规范化供应商类型。
*
* @param value 原始供应商类型
* @return 小写无首尾空白的供应商类型
*/
private String normalize(String value) {
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
}
}

View File

@@ -0,0 +1,56 @@
package tech.easyflow.ai.service.discovery;
import java.math.BigInteger;
/**
* 远端模型一键添加结果。
*/
public final class RemoteModelImportResult {
/** 远端原始模型 ID。 */
private final String modelId;
/** 本地模型 ID。 */
private final BigInteger localModelId;
/** 最终模型类型。 */
private final String modelType;
/** 添加结果状态。 */
private final RemoteModelImportStatus status;
/**
* 创建一键添加结果。
*
* @param modelId 远端原始模型 ID
* @param localModelId 本地模型 ID
* @param modelType 最终模型类型
* @param status 添加结果状态
*/
public RemoteModelImportResult(String modelId,
BigInteger localModelId,
String modelType,
RemoteModelImportStatus status) {
this.modelId = modelId;
this.localModelId = localModelId;
this.modelType = modelType;
this.status = status;
}
/** @return 远端原始模型 ID */
public String getModelId() {
return modelId;
}
/** @return 本地模型 ID */
public BigInteger getLocalModelId() {
return localModelId;
}
/** @return 最终模型类型 */
public String getModelType() {
return modelType;
}
/** @return 添加结果状态 */
public RemoteModelImportStatus getStatus() {
return status;
}
}

View File

@@ -0,0 +1,143 @@
package tech.easyflow.ai.service.discovery;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.entity.ModelProvider;
import tech.easyflow.ai.mapper.ModelMapper;
import tech.easyflow.ai.mapper.ModelProviderMapper;
import tech.easyflow.ai.service.ModelProviderService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
/**
* 远端模型一键添加应用服务。
*/
@Service
public class RemoteModelImportService {
/** 数据库允许的最大模型 ID 长度。 */
private static final int MAX_MODEL_ID_LENGTH = 255;
/** 服务商服务。 */
private final ModelProviderService modelProviderService;
/** 服务商映射器。 */
private final ModelProviderMapper modelProviderMapper;
/** 模型服务。 */
private final ModelService modelService;
/** 模型映射器。 */
private final ModelMapper modelMapper;
/** 模型元数据解析器。 */
private final RemoteModelMetadataResolver metadataResolver;
/**
* 创建远端模型一键添加服务。
*
* @param modelProviderService 服务商服务
* @param modelProviderMapper 服务商映射器
* @param modelService 模型服务
* @param modelMapper 模型映射器
* @param metadataResolver 模型元数据解析器
*/
public RemoteModelImportService(ModelProviderService modelProviderService,
ModelProviderMapper modelProviderMapper,
ModelService modelService,
ModelMapper modelMapper,
RemoteModelMetadataResolver metadataResolver) {
this.modelProviderService = modelProviderService;
this.modelProviderMapper = modelProviderMapper;
this.modelService = modelService;
this.modelMapper = modelMapper;
this.metadataResolver = metadataResolver;
}
/**
* 幂等添加单个远端模型。
*
* @param providerId 服务商 ID
* @param rawModelId 远端原始模型 ID
* @param auditModel 已由控制层填充租户和部门字段的模型种子
* @return 创建或已存在结果
* @throws BusinessException 参数、服务商或保存结果不合法时抛出
*/
@Transactional(rollbackFor = Exception.class)
public RemoteModelImportResult importModel(BigInteger providerId,
String rawModelId,
Model auditModel) {
if (providerId == null) {
throw new BusinessException(400, 40031, "服务商 ID 不能为空");
}
String modelId = validateModelId(rawModelId);
ModelProvider provider = modelProviderService.getById(providerId);
if (provider == null) {
throw new BusinessException(404, 40431, "模型服务商不存在");
}
// 对同一服务商的一键添加串行化,配合唯一键避免并发重复插入。
if (modelProviderMapper.lockById(providerId) == null) {
throw new BusinessException(404, 40431, "模型服务商不存在");
}
Model existing = findExisting(providerId, modelId);
if (existing != null) {
return toResult(existing, RemoteModelImportStatus.ALREADY_EXISTS);
}
Model model = auditModel == null ? new Model() : auditModel;
model.setProviderId(providerId);
metadataResolver.configureNewModel(model, provider.getProviderType(), modelId);
modelService.validateForSaveOrUpdate(model, true);
if (!modelService.save(model)) {
throw new BusinessException(500, 50031, "添加模型失败,请稍后重试");
}
return toResult(model, RemoteModelImportStatus.CREATED);
}
/**
* 校验并规范化模型 ID。
*
* @param rawModelId 原始模型 ID
* @return 去除首尾空白的模型 ID
*/
private String validateModelId(String rawModelId) {
if (rawModelId == null || rawModelId.trim().isEmpty()) {
throw new BusinessException(400, 40032, "模型 ID 不能为空");
}
String modelId = rawModelId.trim();
if (modelId.codePointCount(0, modelId.length()) > MAX_MODEL_ID_LENGTH) {
throw new BusinessException(422, 42232, "模型 ID 不能超过 255 个字符");
}
if (modelId.chars().anyMatch(Character::isISOControl)) {
throw new BusinessException(422, 42233, "模型 ID 包含非法控制字符");
}
return modelId;
}
/**
* 查询当前租户下已存在的相同模型。
*
* @param providerId 服务商 ID
* @param modelId 原始模型 ID
* @return 已存在模型,不存在时返回 null
*/
private Model findExisting(BigInteger providerId, String modelId) {
QueryWrapper query = QueryWrapper.create()
.eq(Model::getProviderId, providerId)
.eq(Model::getModelName, modelId);
return modelMapper.selectOneByQuery(query);
}
/**
* 构建添加接口结果。
*
* @param model 本地模型
* @param status 添加状态
* @return 添加接口结果
*/
private RemoteModelImportResult toResult(Model model, RemoteModelImportStatus status) {
return new RemoteModelImportResult(
model.getModelName(), model.getId(), model.getModelType(), status);
}
}

View File

@@ -0,0 +1,11 @@
package tech.easyflow.ai.service.discovery;
/**
* 远端模型一键添加结果状态。
*/
public enum RemoteModelImportStatus {
/** 已创建新的本地模型。 */
CREATED,
/** 相同模型已经存在。 */
ALREADY_EXISTS
}

View File

@@ -0,0 +1,47 @@
package tech.easyflow.ai.service.discovery;
import java.math.BigInteger;
import java.util.List;
/**
* 单个服务商的远端模型发现结果。
*/
public final class RemoteModelListResult {
/** 服务商 ID。 */
private final BigInteger providerId;
/** 统一模型列表。 */
private final List<RemoteModelDescriptor> models;
/** 远端结果是否超过服务端安全上限。 */
private final boolean truncated;
/**
* 创建远端模型发现结果。
*
* @param providerId 服务商 ID
* @param models 统一模型列表
* @param truncated 是否因数量上限而截断
*/
public RemoteModelListResult(BigInteger providerId,
List<RemoteModelDescriptor> models,
boolean truncated) {
this.providerId = providerId;
this.models = List.copyOf(models);
this.truncated = truncated;
}
/** @return 服务商 ID */
public BigInteger getProviderId() {
return providerId;
}
/** @return 不可变远端模型列表 */
public List<RemoteModelDescriptor> getModels() {
return models;
}
/** @return 结果被截断返回 true */
public boolean isTruncated() {
return truncated;
}
}

View File

@@ -0,0 +1,127 @@
package tech.easyflow.ai.service.discovery;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.service.capability.ModelCapabilityCatalog;
import tech.easyflow.ai.service.capability.ModelCapabilityResolution;
import tech.easyflow.ai.service.capability.ModelCapabilityResolver;
import tech.easyflow.ai.service.capability.ModelCatalogMetadata;
import java.util.Optional;
/**
* 使用静态模型目录和统一能力规则补全远端模型信息。
*/
@Component
public class RemoteModelMetadataResolver {
/** 数据库允许的最大模型 ID 长度。 */
private static final int MAX_MODEL_ID_LENGTH = 255;
/** 数据库允许的最大模型标题长度。 */
private static final int MAX_TITLE_LENGTH = 128;
/** 未命中目录时使用的默认家族。 */
private static final String DEFAULT_FAMILY = "其他模型";
/** 静态模型目录。 */
private final ModelCapabilityCatalog catalog;
/** 统一模型能力解析器。 */
private final ModelCapabilityResolver capabilityResolver;
/**
* 创建远端模型元数据解析器。
*
* @param catalog 静态模型目录
* @param capabilityResolver 统一模型能力解析器
*/
public RemoteModelMetadataResolver(ModelCapabilityCatalog catalog,
ModelCapabilityResolver capabilityResolver) {
this.catalog = catalog;
this.capabilityResolver = capabilityResolver;
}
/**
* 构建管理端使用的远端模型描述。
*
* @param providerType 供应商类型
* @param modelId 远端原始模型 ID
* @param added 是否已经添加
* @return 统一模型描述
*/
public RemoteModelDescriptor describe(String providerType, String modelId, boolean added) {
Optional<ModelCatalogMetadata> metadata = catalog.findMetadata(providerType, modelId);
ModelCapabilityResolution capability = capabilityResolver.resolve(providerType, modelId);
boolean addable = modelId.codePointCount(0, modelId.length()) <= MAX_MODEL_ID_LENGTH;
return new RemoteModelDescriptor(
modelId,
metadata.map(ModelCatalogMetadata::getDisplayName)
.filter(value -> !value.isBlank()).orElse(modelId),
metadata.map(ModelCatalogMetadata::getFamily)
.filter(value -> !value.isBlank()).orElse(DEFAULT_FAMILY),
capability.getModelType(),
capability.getSupportImage(),
capability.getSupportThinking(),
capability.getSupportTool(),
capability.getSource(),
added,
addable,
addable ? null : "模型 ID 超过 255 个字符");
}
/**
* 使用目录元数据和能力识别结果配置待新增模型。
*
* @param target 待新增模型
* @param providerType 供应商类型
* @param modelId 远端原始模型 ID
*/
public void configureNewModel(Model target, String providerType, String modelId) {
Optional<ModelCatalogMetadata> metadata = catalog.findMetadata(providerType, modelId);
ModelCapabilityResolution capability = capabilityResolver.resolve(providerType, modelId);
String displayName = metadata.map(ModelCatalogMetadata::getDisplayName)
.filter(value -> !value.isBlank()).orElse(modelId);
String family = metadata.map(ModelCatalogMetadata::getFamily)
.filter(value -> !value.isBlank()).orElse(DEFAULT_FAMILY);
target.setModelName(modelId);
target.setTitle(limitCodePoints(displayName, MAX_TITLE_LENGTH));
target.setGroupName(family);
target.setModelType(capability.getModelType());
target.setSupportImage(capability.getSupportImage());
target.setSupportThinking(capability.getSupportThinking());
target.setSupportTool(capability.getSupportTool());
target.setSupportToolMessage(capability.getSupportTool());
target.setSupportImageB64Only(Boolean.FALSE);
target.setSupportVideo(Boolean.FALSE);
target.setSupportAudio(Boolean.FALSE);
target.setSupportFree(Boolean.FALSE);
target.setPublishEnabled(Boolean.FALSE);
}
/**
* 判断目录中的模型是否为尚未接入的生成模型。
*
* @param providerType 供应商类型
* @param modelId 远端原始模型 ID
* @return 已知仅生成图片、视频或音频时返回 true
*/
public boolean isUnsupportedGenerationModel(String providerType, String modelId) {
return catalog.findMetadata(providerType, modelId)
.map(ModelCatalogMetadata::isUnsupportedGenerationModel)
.orElse(false);
}
/**
* 按 Unicode 码点安全截断文本。
*
* @param value 原始文本
* @param maxCodePoints 最大码点数
* @return 截断后的文本
*/
private String limitCodePoints(String value, int maxCodePoints) {
if (value.codePointCount(0, value.length()) <= maxCodePoints) {
return value;
}
int endIndex = value.offsetByCodePoints(0, maxCodePoints);
return value.substring(0, endIndex);
}
}

View File

@@ -0,0 +1,20 @@
package tech.easyflow.ai.service.discovery;
import tech.easyflow.ai.entity.ModelProvider;
import java.util.List;
/**
* 单类远端服务商模型目录协议适配器。
*/
public interface RemoteModelProviderAdapter {
/**
* 获取远端原始模型 ID。
*
* @param provider 已保存的服务商配置
* @param httpClient 受控 HTTP 客户端
* @return 远端原始模型 ID 列表
*/
List<String> fetchModelIds(ModelProvider provider, RemoteModelHttpClient httpClient);
}

View File

@@ -0,0 +1,66 @@
package tech.easyflow.ai.service.discovery;
import org.springframework.stereotype.Component;
import java.util.Locale;
import java.util.Map;
/**
* 远端模型目录的简洁静态服务商适配表。
*/
@Component
public class RemoteModelProviderAdapterRegistry {
/** 通用 OpenAI-compatible 适配器。 */
private final RemoteModelProviderAdapter defaultAdapter;
/** 按规范化供应商类型维护的静态适配表。 */
private final Map<String, RemoteModelProviderAdapter> adapters;
/**
* 创建静态服务商适配表。
*
* @param openAiCompatibleAdapter OpenAI-compatible 适配器
* @param ollamaAdapter Ollama 适配器
* @param aliyunAdapter 阿里百炼适配器
*/
public RemoteModelProviderAdapterRegistry(
OpenAiCompatibleRemoteModelAdapter openAiCompatibleAdapter,
OllamaRemoteModelAdapter ollamaAdapter,
AliyunRemoteModelAdapter aliyunAdapter) {
this.defaultAdapter = openAiCompatibleAdapter;
this.adapters = Map.ofEntries(
Map.entry("openai", openAiCompatibleAdapter),
Map.entry("deepseek", openAiCompatibleAdapter),
Map.entry("zhipu", openAiCompatibleAdapter),
Map.entry("minimax", openAiCompatibleAdapter),
Map.entry("kimi", openAiCompatibleAdapter),
Map.entry("siliconflow", openAiCompatibleAdapter),
Map.entry("self-hosted", openAiCompatibleAdapter),
Map.entry("self_hosted", openAiCompatibleAdapter),
Map.entry("selfhost", openAiCompatibleAdapter),
Map.entry("ollama", ollamaAdapter),
Map.entry("aliyun", aliyunAdapter),
Map.entry("dashscope", aliyunAdapter),
Map.entry("bailian", aliyunAdapter));
}
/**
* 根据供应商类型获取适配器。
*
* @param providerType 供应商类型
* @return 专用适配器,未知类型使用通用兼容适配器
*/
public RemoteModelProviderAdapter get(String providerType) {
return adapters.getOrDefault(normalize(providerType), defaultAdapter);
}
/**
* 规范化供应商类型。
*
* @param value 原始供应商类型
* @return 小写供应商类型
*/
private String normalize(String value) {
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
}
}

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 models.dev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,15 @@
# llm.json 数据来源
- 上游地址:<https://models.dev/models.json>
- 上游仓库:<https://github.com/anomalyco/models.dev>
- 下载日期2026-07-21
- 上游条目数259
- 上游文件 SHA-256`22f0e8bd69d5addebc2e762419082c828d121f4e25a9c247db7017ef545aa6ff`
- 本地补充7 个 BAAI 模型条目,元数据来自 BAAI 官方 Hugging Face 页面
本地补充条目包括 `bge-m3``bge-reranker-v2-m3``bge-reranker-v2-gemma`
`bge-reranker-v2-minicpm-layerwise``bge-reranker-v2.5-gemma2-lightweight`
`bge-reranker-large``bge-reranker-base`
更新上游快照时,需要保留上述本地补充条目。
各模型权重许可证以 `llm.json` 条目和对应模型卡为准。

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,80 @@
package tech.easyflow.ai.service.capability;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import tech.easyflow.ai.entity.Model;
/**
* 静态模型能力库与命名规则解析测试。
*/
public class ModelCapabilityResolverTest {
/** 待测试能力解析器。 */
private ModelCapabilityResolver resolver;
/**
* 加载真实静态模型目录。
*/
@Before
public void setUp() {
resolver = new ModelCapabilityResolver(new ModelCapabilityCatalog(new ObjectMapper()));
}
/**
* 验证 DashScope 模型短 ID 能命中 Alibaba 目录条目。
*/
@Test
public void shouldResolveCatalogCapabilitiesByProviderAlias() {
ModelCapabilityResolution result = resolver.resolve("dashscope", "qwen3.7-plus");
Assert.assertEquals(ModelCapabilitySource.CATALOG, result.getSource());
Assert.assertEquals(Model.MODEL_TYPES[0], result.getModelType());
Assert.assertEquals(Boolean.TRUE, result.getSupportImage());
Assert.assertEquals(Boolean.TRUE, result.getSupportThinking());
Assert.assertEquals(Boolean.TRUE, result.getSupportTool());
}
/**
* 验证本地补充的 BAAI 嵌入与重排模型类型互斥。
*/
@Test
public void shouldResolveBaaiEmbeddingAndRerankModels() {
ModelCapabilityResolution embedding = resolver.resolve(null, "BAAI/bge-m3");
ModelCapabilityResolution rerank = resolver.resolve(null, "bge-reranker-v2-m3");
Assert.assertEquals(Model.MODEL_TYPES[1], embedding.getModelType());
Assert.assertEquals(Model.MODEL_TYPES[2], rerank.getModelType());
Assert.assertEquals(Boolean.FALSE, embedding.getSupportTool());
Assert.assertEquals(Boolean.FALSE, rerank.getSupportImage());
}
/**
* 验证自定义部署名称仍能通过严格关键词识别视觉模型。
*/
@Test
public void shouldInferVisionForCustomDeploymentName() {
ModelCapabilityResolution result = resolver.resolve(
"gpustack", "team-a/qwen2.5-vl-7b-instruct-awq");
Assert.assertEquals(ModelCapabilitySource.RULE, result.getSource());
Assert.assertEquals(Model.MODEL_TYPES[0], result.getModelType());
Assert.assertEquals(Boolean.TRUE, result.getSupportImage());
Assert.assertNull(result.getSupportTool());
}
/**
* 验证无法识别的自定义模型保留未知能力,不误判为不支持工具。
*/
@Test
public void shouldKeepCapabilitiesUnknownForCustomModel() {
ModelCapabilityResolution result = resolver.resolve("custom", "team-production-model");
Assert.assertEquals(ModelCapabilitySource.DEFAULT, result.getSource());
Assert.assertEquals(Model.MODEL_TYPES[0], result.getModelType());
Assert.assertNull(result.getSupportImage());
Assert.assertNull(result.getSupportThinking());
Assert.assertNull(result.getSupportTool());
}
}

View File

@@ -0,0 +1,120 @@
package tech.easyflow.ai.service.discovery;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.ai.entity.ModelProvider;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import java.util.zip.GZIPOutputStream;
/**
* 远端模型目录 URL 合并与网络目标限制测试。
*/
public class RemoteModelHttpClientTest {
/**
* 验证 Endpoint 已含版本路径时不会重复拼接。
*/
@Test
public void shouldJoinEndpointAndPathWithoutDuplicatingPrefix() {
ModelProvider provider = provider("self-hosted", "http://127.0.0.1:8000/v1");
URI uri = new RemoteModelHttpClient(new ObjectMapper()).buildUri(
provider, "/v1/models", Map.of("type", "text"));
Assert.assertEquals("http://127.0.0.1:8000/v1/models?type=text", uri.toString());
}
/**
* 验证云服务商类型不能访问本机地址。
*/
@Test(expected = BusinessException.class)
public void shouldRejectPrivateTargetForCloudProvider() {
ModelProvider provider = provider("openai", "http://127.0.0.1:8000");
new RemoteModelHttpClient(new ObjectMapper()).buildUri(provider, "/v1/models", Map.of());
}
/**
* 验证自部署类型也不能访问链路本地元数据地址。
*/
@Test(expected = BusinessException.class)
public void shouldRejectMetadataTargetForSelfHostedProvider() {
ModelProvider provider = provider("self-hosted", "http://169.254.169.254");
new RemoteModelHttpClient(new ObjectMapper()).buildUri(provider, "/v1/models", Map.of());
}
/**
* 验证客户端会请求并正确解压 gzip 模型目录响应。
*
* @throws Exception 本地测试服务启动或请求失败时抛出
*/
@Test
public void shouldRequestAndDecodeGzipResponse() throws Exception {
AtomicReference<String> acceptEncoding = new AtomicReference<>();
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/v1/models", exchange -> {
acceptEncoding.set(exchange.getRequestHeaders().getFirst("Accept-Encoding"));
byte[] payload = gzip("{\"data\":[{\"id\":\"test-model\"}]}");
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.getResponseHeaders().add("Content-Encoding", "gzip");
exchange.sendResponseHeaders(200, payload.length);
try (var responseBody = exchange.getResponseBody()) {
responseBody.write(payload);
}
});
server.start();
try {
ModelProvider provider = provider("self-hosted",
"http://127.0.0.1:" + server.getAddress().getPort());
var result = new RemoteModelHttpClient(new ObjectMapper())
.getJson(provider, "/v1/models", Map.of());
Assert.assertEquals("test-model", result.path("data").path(0).path("id").asText());
Assert.assertEquals("gzip", acceptEncoding.get());
} finally {
server.stop(0);
}
}
/**
* 压缩测试响应内容。
*
* @param content 原始响应内容
* @return gzip 压缩后的字节
* @throws IOException 压缩失败时抛出
*/
private byte[] gzip(String content) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (GZIPOutputStream gzip = new GZIPOutputStream(output)) {
gzip.write(content.getBytes(StandardCharsets.UTF_8));
}
return output.toByteArray();
}
/**
* 创建测试服务商。
*
* @param providerType 服务商类型
* @param endpoint API 地址
* @return 测试服务商
*/
private ModelProvider provider(String providerType, String endpoint) {
ModelProvider provider = new ModelProvider();
provider.setProviderType(providerType);
provider.setEndpoint(endpoint);
return provider;
}
}

View File

@@ -0,0 +1,148 @@
package tech.easyflow.ai.service.discovery;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.entity.ModelProvider;
import tech.easyflow.ai.mapper.ModelMapper;
import tech.easyflow.ai.mapper.ModelProviderMapper;
import tech.easyflow.ai.service.ModelProviderService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.service.capability.ModelCapabilityCatalog;
import tech.easyflow.ai.service.capability.ModelCapabilityResolver;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.lang.reflect.Proxy;
import java.math.BigInteger;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 远端模型幂等导入服务测试。
*/
public class RemoteModelImportServiceTest {
/** 测试服务商 ID。 */
private static final BigInteger PROVIDER_ID = BigInteger.valueOf(100);
/** 测试服务商。 */
private ModelProvider provider;
/** 预置的已存在模型。 */
private Model existingModel;
/** 保存调用次数。 */
private final AtomicInteger saveCount = new AtomicInteger();
/** 待测试服务。 */
private RemoteModelImportService importService;
/**
* 初始化测试夹具。
*/
@Before
public void setUp() {
provider = new ModelProvider();
provider.setId(PROVIDER_ID);
provider.setProviderType("openai");
existingModel = null;
saveCount.set(0);
ModelProviderService providerService = proxy(ModelProviderService.class,
(method, arguments) -> "getById".equals(method) ? provider : defaultValue(method));
ModelProviderMapper providerMapper = proxy(ModelProviderMapper.class,
(method, arguments) -> "lockById".equals(method) ? PROVIDER_ID : defaultValue(method));
ModelMapper modelMapper = proxy(ModelMapper.class,
(method, arguments) -> "selectOneByQuery".equals(method)
? existingModel : defaultValue(method));
ModelService modelService = proxy(ModelService.class, (method, arguments) -> {
if ("save".equals(method)) {
Model target = (Model) arguments[0];
target.setId(BigInteger.valueOf(201));
saveCount.incrementAndGet();
return true;
}
return defaultValue(method);
});
ModelCapabilityCatalog catalog = new ModelCapabilityCatalog(new ObjectMapper());
RemoteModelMetadataResolver metadataResolver = new RemoteModelMetadataResolver(
catalog, new ModelCapabilityResolver(catalog));
importService = new RemoteModelImportService(
providerService, providerMapper, modelService, modelMapper, metadataResolver);
}
/**
* 验证命中已有模型时返回幂等结果且不重复保存。
*/
@Test
public void shouldReturnAlreadyExistsWithoutSaving() {
Model existing = new Model();
existing.setId(BigInteger.valueOf(200));
existing.setModelName("gpt-5");
existing.setModelType(Model.MODEL_TYPES[0]);
existingModel = existing;
RemoteModelImportResult result = importService.importModel(
PROVIDER_ID, "gpt-5", new Model());
Assert.assertEquals(RemoteModelImportStatus.ALREADY_EXISTS, result.getStatus());
Assert.assertEquals(existing.getId(), result.getLocalModelId());
Assert.assertEquals(0, saveCount.get());
}
/**
* 验证新模型完成默认值补全和保存。
*/
@Test
public void shouldCreateModelWhenNotExists() {
RemoteModelImportResult result = importService.importModel(
PROVIDER_ID, " gpt-5 ", new Model());
Assert.assertEquals(RemoteModelImportStatus.CREATED, result.getStatus());
Assert.assertEquals(BigInteger.valueOf(201), result.getLocalModelId());
Assert.assertEquals(1, saveCount.get());
}
/**
* 创建按方法名返回结果的 JDK 动态接口替身。
*
* @param type 接口类型
* @param handler 方法处理器
* @param <T> 接口类型
* @return 接口替身
*/
private <T> T proxy(Class<T> type, TestInvocationHandler handler) {
Object value = Proxy.newProxyInstance(
type.getClassLoader(),
new Class<?>[]{type},
(proxy, method, arguments) -> handler.invoke(method.getName(), arguments));
return type.cast(value);
}
/**
* 返回方法返回类型的基础默认值。
*
* @param methodName 方法名
* @return 默认值
*/
private Object defaultValue(String methodName) {
if ("count".equals(methodName)) {
return 0L;
}
return null;
}
/**
* 测试接口方法处理器。
*/
@FunctionalInterface
private interface TestInvocationHandler {
/**
* 处理接口方法调用。
*
* @param method 方法名
* @param arguments 方法参数
* @return 方法结果
*/
Object invoke(String method, Object[] arguments);
}
}

View File

@@ -0,0 +1,66 @@
package tech.easyflow.ai.service.discovery;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.service.capability.ModelCapabilityCatalog;
import tech.easyflow.ai.service.capability.ModelCapabilityResolver;
/**
* 远端模型目录元数据和默认值补全测试。
*/
public class RemoteModelMetadataResolverTest {
/** 待测试元数据解析器。 */
private RemoteModelMetadataResolver resolver;
/**
* 加载真实静态模型目录。
*/
@Before
public void setUp() {
ModelCapabilityCatalog catalog = new ModelCapabilityCatalog(new ObjectMapper());
resolver = new RemoteModelMetadataResolver(catalog, new ModelCapabilityResolver(catalog));
}
/**
* 验证 BAAI 模型使用目录名称、家族和向量类型。
*/
@Test
public void shouldEnrichBaaiEmbeddingModel() {
RemoteModelDescriptor result = resolver.describe(null, "BAAI/bge-m3", false);
Assert.assertEquals("BGE-M3", result.getDisplayName());
Assert.assertEquals("bge", result.getFamily());
Assert.assertEquals(Model.MODEL_TYPES[1], result.getModelType());
Assert.assertTrue(result.isAddable());
}
/**
* 验证未知模型使用保守对话默认值。
*/
@Test
public void shouldConfigureUnknownModelConservatively() {
Model model = new Model();
resolver.configureNewModel(model, "self-hosted", "team/custom-model");
Assert.assertEquals("team/custom-model", model.getTitle());
Assert.assertEquals("其他模型", model.getGroupName());
Assert.assertEquals(Model.MODEL_TYPES[0], model.getModelType());
Assert.assertNull(model.getSupportTool());
Assert.assertEquals(Boolean.FALSE, model.getSupportVideo());
}
/**
* 验证已知图片生成模型不会进入当前可添加范围。
*/
@Test
public void shouldIdentifyUnsupportedImageGenerationModel() {
Assert.assertTrue(resolver.isUnsupportedGenerationModel("openai", "gpt-image-1"));
Assert.assertTrue(resolver.isUnsupportedGenerationModel("openai", "gpt-image-1.5"));
Assert.assertFalse(resolver.isUnsupportedGenerationModel("openai", "gpt-5"));
}
}

View File

@@ -0,0 +1,187 @@
package tech.easyflow.ai.service.discovery;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import tech.easyflow.ai.entity.ModelProvider;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;
import java.util.Map;
/**
* 三类远端模型目录适配器测试。
*/
public class RemoteModelProviderAdapterTest {
/** JSON 解析器。 */
private ObjectMapper objectMapper;
/** 受控 HTTP 客户端替身。 */
private StubRemoteModelHttpClient httpClient;
/** 服务商配置。 */
private ModelProvider provider;
/**
* 初始化测试夹具。
*/
@Before
public void setUp() {
objectMapper = new ObjectMapper();
httpClient = new StubRemoteModelHttpClient(objectMapper);
provider = new ModelProvider();
provider.setChatPath("/v1/chat/completions");
}
/**
* 验证 OpenAI-compatible 路径推导和 data.id 解析。
*
* @throws Exception JSON 夹具解析失败时抛出
*/
@Test
public void shouldParseOpenAiCompatibleModels() throws Exception {
provider.setProviderType("openai");
httpClient.addResponse("{\"data\":[{\"id\":\"gpt-5\"}]}");
List<String> result = new OpenAiCompatibleRemoteModelAdapter()
.fetchModelIds(provider, httpClient);
Assert.assertEquals(List.of("gpt-5"), result);
Assert.assertEquals("/v1/models", httpClient.lastPath);
Assert.assertEquals(Map.of(), httpClient.lastQuery);
}
/**
* 验证 Ollama 优先读取 name 并兼容 model 字段。
*
* @throws Exception JSON 夹具解析失败时抛出
*/
@Test
public void shouldParseOllamaModels() throws Exception {
httpClient.addResponse(
"{\"models\":[{\"name\":\"qwen3:8b\"},{\"model\":\"bge-m3:latest\"}]}");
List<String> result = new OllamaRemoteModelAdapter().fetchModelIds(provider, httpClient);
Assert.assertEquals(List.of("qwen3:8b", "bge-m3:latest"), result);
}
/**
* 验证阿里百炼模型名称和分页字段解析。
*
* @throws Exception JSON 夹具解析失败时抛出
*/
@Test
public void shouldParseAliyunModels() throws Exception {
httpClient.addResponse(
"{\"request_id\":\"request-1\",\"output\":{"
+ "\"page_no\":1,\"page_size\":100,\"total\":1,"
+ "\"models\":[{\"model_name\":\"qwen-plus\"}]}}");
List<String> result = new AliyunRemoteModelAdapter().fetchModelIds(provider, httpClient);
Assert.assertEquals(List.of("qwen-plus"), result);
Assert.assertEquals("/api/v1/deployments/models", httpClient.lastPath);
Assert.assertEquals(Map.of(
"model_source", "base",
"page_no", "1",
"page_size", "100",
"version", "v1.0"), httpClient.lastQuery);
}
/**
* 验证阿里百炼历史根级响应仍可解析。
*
* @throws Exception JSON 夹具解析失败时抛出
*/
@Test
public void shouldKeepAliyunLegacyResponseCompatibility() throws Exception {
httpClient.addResponse(
"{\"models\":[{\"model_name\":\"qwen-turbo\"}],\"total_count\":1}");
List<String> result = new AliyunRemoteModelAdapter().fetchModelIds(provider, httpClient);
Assert.assertEquals(List.of("qwen-turbo"), result);
}
/**
* 验证百炼媒体生成和内部算法模型不会被误标为对话模型。
*
* @throws Exception JSON 夹具解析失败时抛出
*/
@Test
public void shouldExcludeUnsupportedAliyunModels() throws Exception {
httpClient.addResponse(
"{\"output\":{\"page_no\":1,\"page_size\":100,\"total\":7,"
+ "\"models\":["
+ "{\"model_name\":\"animate-anyone\"},"
+ "{\"model_name\":\"animate-anyone-detect\"},"
+ "{\"model_name\":\"emo\"},"
+ "{\"model_name\":\"emo-detect\"},"
+ "{\"model_name\":\"mock-algo-v1\"},"
+ "{\"model_name\":\"wanx-v1-0521\"},"
+ "{\"model_name\":\"qwen-plus\"}]}}"
);
List<String> result = new AliyunRemoteModelAdapter().fetchModelIds(provider, httpClient);
Assert.assertEquals(List.of("qwen-plus"), result);
}
/**
* 以队列响应替代真实网络请求的轻量测试客户端。
*/
private static final class StubRemoteModelHttpClient extends RemoteModelHttpClient {
/** JSON 解析器。 */
private final ObjectMapper objectMapper;
/** 待返回响应队列。 */
private final Deque<String> responses = new ArrayDeque<>();
/** 最近请求路径。 */
private String lastPath;
/** 最近查询参数。 */
private Map<String, String> lastQuery;
/**
* 创建测试客户端。
*
* @param objectMapper JSON 解析器
*/
private StubRemoteModelHttpClient(ObjectMapper objectMapper) {
super(objectMapper);
this.objectMapper = objectMapper;
}
/**
* 添加下一次请求返回的 JSON。
*
* @param response JSON 文本
*/
private void addResponse(String response) {
responses.addLast(response);
}
/**
* 返回预置 JSON 并记录请求参数。
*
* @param provider 服务商配置
* @param requestPath 请求路径
* @param queryParameters 查询参数
* @return 预置 JSON 根节点
*/
@Override
public com.fasterxml.jackson.databind.JsonNode getJson(
ModelProvider provider,
String requestPath,
Map<String, String> queryParameters) {
lastPath = requestPath;
lastQuery = Map.copyOf(queryParameters);
try {
return objectMapper.readTree(responses.removeFirst());
} catch (Exception exception) {
throw new AssertionError("测试 JSON 解析失败", exception);
}
}
}
}

View File

@@ -0,0 +1,9 @@
-- 扩展模型 ID 长度,并以原始大小写稳定区分同一租户、服务商下的模型。
-- 若存量存在完全相同的重复记录,唯一索引创建会明确失败,需人工确认后清理。
ALTER TABLE `tb_model`
MODIFY COLUMN `model_name` varchar(255)
CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '大模型名称';
ALTER TABLE `tb_model`
ADD UNIQUE INDEX `uni_model_provider_name`
(`tenant_id`, `provider_id`, `model_name`) USING BTREE;

View File

@@ -37,6 +37,47 @@ export async function verifyModelConfig(id: string) {
return api.get('/api/v1/model/verifyLlmConfig', { params: { id } });
}
export type ModelCapabilitySource = 'CATALOG' | 'DEFAULT' | 'RULE';
export interface RemoteModelDescriptor {
addable: boolean;
added: boolean;
capabilitySource: ModelCapabilitySource;
displayName: string;
family: string;
modelId: string;
modelType: 'chatModel' | 'embeddingModel' | 'rerankModel';
supportImage?: boolean | null;
supportThinking?: boolean | null;
supportTool?: boolean | null;
unavailableReason?: null | string;
}
export interface RemoteModelListData {
models: RemoteModelDescriptor[];
providerId: string;
truncated: boolean;
}
export type RemoteModelImportStatus = 'ALREADY_EXISTS' | 'CREATED';
export interface RemoteModelImportData {
localModelId: string;
modelId: string;
modelType: RemoteModelDescriptor['modelType'];
status: RemoteModelImportStatus;
}
export async function getRemoteModels(providerId: string) {
return api.get(`/api/v1/modelProvider/${providerId}/remoteModels`);
}
export async function importRemoteModel(providerId: string, modelId: string) {
return api.post(`/api/v1/modelProvider/${providerId}/remoteModels/import`, {
modelId,
});
}
export type ModelVerificationStageStatus =
| 'FAILED'
| 'PARTIAL'

View File

@@ -3,7 +3,14 @@ import { computed, onMounted, ref } from 'vue';
import { $t } from '@easyflow/locales';
import { Delete, Edit, Plus, Select, Setting } from '@element-plus/icons-vue';
import {
Delete,
Edit,
Plus,
Refresh,
Select,
Setting,
} from '@element-plus/icons-vue';
import {
ElButton,
ElEmpty,
@@ -13,6 +20,7 @@ import {
ElMessage,
ElMessageBox,
ElTag,
ElTooltip,
} from 'element-plus';
import { getLlmProviderList } from '#/api/ai/llm.js';
@@ -31,6 +39,7 @@ import {
} from '#/views/ai/model/modelUtils/providerDraft';
import ModelVerifyConfig from '#/views/ai/model/ModelVerifyConfig.vue';
import ModelViewItemOperation from '#/views/ai/model/ModelViewItemOperation.vue';
import RemoteModelDialog from '#/views/ai/model/RemoteModelDialog.vue';
import UnifiedGatewayWorkspace from '#/views/ai/model/UnifiedGatewayWorkspace.vue';
type ModelWorkspaceView = 'active' | 'gateway' | 'provider';
@@ -61,6 +70,7 @@ const llmVerifyConfigRef = ref();
const addLlmRef = ref();
const activeWorkspaceRef = ref();
const unifiedGatewayWorkspaceRef = ref();
const remoteModelDialogRef = ref();
const selectedProvider = computed(() =>
providers.value.find((item) => item.id === selectedProviderId.value),
@@ -119,6 +129,19 @@ const isProviderDirty = computed(() =>
isProviderDraftDirty(selectedProvider.value, providerDraft.value),
);
const remoteModelsDisabledReason = computed(() => {
if (!selectedProvider.value) {
return '请先选择模型服务商';
}
if (isProviderDirty.value) {
return '请先保存服务商配置';
}
if (!selectedProvider.value.endpoint?.trim()) {
return '请先配置并保存 API 地址';
}
return '';
});
const currentProviderMetrics = computed(() =>
getProviderConfigMetrics(
{
@@ -405,6 +428,24 @@ const handleAddLlm = (modelType = activeModelType.value) => {
addLlmRef.value.openAddDialog(targetModelType);
};
const openRemoteModelDialog = () => {
if (remoteModelsDisabledReason.value) {
return;
}
remoteModelDialogRef.value?.openDialog?.(
selectedProviderId.value,
selectedProvider.value?.providerName || '',
);
};
const handleRemoteManualAdd = () => {
handleAddLlm(actionModelType.value);
};
const handleRemoteModelImported = async () => {
await loadProviderDetail(selectedProviderId.value, { keepDraft: true });
};
const handleDeleteLlm = (id: string) => {
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('message.ok'),
@@ -722,6 +763,21 @@ onMounted(() => {
<p>按模型能力分组管理已配置模型</p>
</div>
<div class="provider-card__head-actions">
<ElTooltip
:disabled="!remoteModelsDisabledReason"
:content="remoteModelsDisabledReason"
placement="top"
>
<span>
<ElButton
:icon="Refresh"
:disabled="Boolean(remoteModelsDisabledReason)"
@click="openRemoteModelDialog"
>
获取模型列表
</ElButton>
</span>
</ElTooltip>
<ElButton
type="primary"
:icon="Plus"
@@ -825,6 +881,11 @@ onMounted(() => {
@reload="handleModelDataReload"
/>
<ModelVerifyConfig ref="llmVerifyConfigRef" />
<RemoteModelDialog
ref="remoteModelDialogRef"
@imported="handleRemoteModelImported"
@manual-add="handleRemoteManualAdd"
/>
</ListPageShell>
</template>

View File

@@ -0,0 +1,192 @@
import { flushPromises, mount } from '@vue/test-utils';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import RemoteModelDialog from './RemoteModelDialog.vue';
const apiMocks = vi.hoisted(() => ({
getRemoteModels: vi.fn(),
importRemoteModel: vi.fn(),
}));
vi.mock('#/api/ai/llm', () => apiMocks);
vi.mock('@easyflow/common-ui', async () => {
const { defineComponent, h } = await import('vue');
return {
EasyFlowFormModal: defineComponent({
props: {
open: Boolean,
title: { default: '', type: String },
},
emits: ['update:open'],
setup(props, { slots }) {
return () =>
props.open
? h('section', [h('h2', props.title), slots.default?.()])
: null;
},
}),
};
});
const mountDialog = () =>
mount(RemoteModelDialog, {
global: {
stubs: {
Transition: false,
},
},
});
describe('remote model dialog', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMocks.getRemoteModels.mockResolvedValue({
data: {
models: [
{
addable: true,
added: false,
capabilitySource: 'CATALOG',
displayName: 'BGE-M3',
family: 'bge',
modelId: 'BAAI/bge-m3',
modelType: 'embeddingModel',
supportImage: false,
supportThinking: false,
supportTool: false,
},
],
providerId: '100',
truncated: false,
},
errorCode: 0,
});
apiMocks.importRemoteModel.mockResolvedValue({
data: { localModelId: '200', status: 'CREATED' },
errorCode: 0,
});
});
it('loads remote models and keeps the dialog open after one-click import', async () => {
const wrapper = mountDialog();
wrapper.vm.openDialog('100', '测试服务商');
await flushPromises();
expect(apiMocks.getRemoteModels).toHaveBeenCalledWith('100');
expect(wrapper.text()).toContain('BAAI/bge-m3');
expect(wrapper.text()).not.toContain('BGE-M3');
await wrapper.get('button[aria-label="添加 BAAI/bge-m3"]').trigger('click');
await flushPromises();
expect(apiMocks.importRemoteModel).toHaveBeenCalledWith(
'100',
'BAAI/bge-m3',
);
expect(wrapper.text()).toContain('已添加');
expect(wrapper.emitted('imported')).toHaveLength(1);
expect(wrapper.get('h2').text()).toBe('测试服务商 模型');
});
it('shows a retryable error and preserves manual add', async () => {
apiMocks.getRemoteModels.mockRejectedValueOnce(
new Error('当前服务暂不支持获取模型列表'),
);
const wrapper = mountDialog();
wrapper.vm.openDialog('100', '测试服务商');
await flushPromises();
expect(wrapper.text()).toContain('获取失败');
expect(wrapper.text()).toContain('当前服务暂不支持获取模型列表');
const manualAdd = wrapper
.findAll('button')
.find((button) => button.text().trim() === '手动新增');
await manualAdd?.trigger('click');
expect(wrapper.emitted('manualAdd')).toHaveLength(1);
});
it('uses one scroll container and keeps semantic capability colors', async () => {
apiMocks.getRemoteModels.mockResolvedValueOnce({
data: {
models: [
{
addable: true,
added: false,
capabilitySource: 'CATALOG',
displayName: 'DeepSeek V4 Pro',
family: 'deepseek-thinking',
modelId: 'deepseek-v4-pro',
modelType: 'chatModel',
supportImage: false,
supportThinking: true,
supportTool: true,
},
],
providerId: '100',
truncated: false,
},
errorCode: 0,
});
const wrapper = mountDialog();
wrapper.vm.openDialog('100', '测试服务商');
await flushPromises();
expect(wrapper.find('.el-vl__wrapper').exists()).toBe(false);
expect(wrapper.find('.el-tag--info').text()).toBe('推理');
expect(wrapper.find('.el-tag--warning').text()).toBe('工具');
});
it('renders a flat list sorted by model id', async () => {
apiMocks.getRemoteModels.mockResolvedValueOnce({
data: {
models: [
{
addable: true,
added: false,
capabilitySource: 'CATALOG',
displayName: 'Zeta Model',
family: 'zeta-family',
modelId: 'zeta/model',
modelType: 'chatModel',
supportImage: false,
supportThinking: false,
supportTool: false,
},
{
addable: true,
added: false,
capabilitySource: 'CATALOG',
displayName: 'Alpha Model',
family: 'alpha-family',
modelId: 'alpha/model',
modelType: 'chatModel',
supportImage: false,
supportThinking: false,
supportTool: false,
},
],
providerId: '100',
truncated: false,
},
errorCode: 0,
});
const wrapper = mountDialog();
wrapper.vm.openDialog('100', '测试服务商');
await flushPromises();
expect(wrapper.find('.remote-model-dialog__group').exists()).toBe(false);
expect(
wrapper
.findAll('.remote-model-row__identity strong')
.map((item) => item.text()),
).toEqual(['alpha/model', 'zeta/model']);
expect(wrapper.text()).not.toContain('alpha-family');
expect(wrapper.text()).not.toContain('zeta-family');
});
});

View File

@@ -0,0 +1,494 @@
<script setup lang="ts">
import type { RemoteModelDescriptor, RemoteModelListData } from '#/api/ai/llm';
import { computed, onBeforeUnmount, ref } from 'vue';
import { EasyFlowFormModal } from '@easyflow/common-ui';
import { Check, Plus, Refresh, Search } from '@element-plus/icons-vue';
import {
ElButton,
ElEmpty,
ElIcon,
ElInput,
ElMessage,
ElSegmented,
ElSkeleton,
ElTag,
} from 'element-plus';
import { getRemoteModels, importRemoteModel } from '#/api/ai/llm';
type ModelTypeFilter =
| 'allModel'
| 'chatModel'
| 'embeddingModel'
| 'rerankModel';
const emit = defineEmits<{
imported: [];
manualAdd: [];
}>();
const visible = ref(false);
const providerId = ref('');
const providerName = ref('');
const models = ref<RemoteModelDescriptor[]>([]);
const loading = ref(false);
const errorMessage = ref('');
const truncated = ref(false);
const searchText = ref('');
const debouncedSearch = ref('');
const activeType = ref<ModelTypeFilter>('allModel');
const importingIds = ref(new Set<string>());
let searchTimer: ReturnType<typeof setTimeout> | undefined;
let requestSequence = 0;
const typeOptions = [
{ label: '全部', value: 'allModel' },
{ label: '对话', value: 'chatModel' },
{ label: '向量', value: 'embeddingModel' },
{ label: '重排', value: 'rerankModel' },
];
const typeLabelMap: Record<RemoteModelDescriptor['modelType'], string> = {
chatModel: '对话',
embeddingModel: '向量',
rerankModel: '重排',
};
const getTypeLabel = (modelType: RemoteModelDescriptor['modelType']) =>
typeLabelMap[modelType] || '对话';
const filteredModels = computed(() => {
const keyword = debouncedSearch.value.trim().toLowerCase();
return models.value
.filter((model) => {
const typeMatched =
activeType.value === 'allModel' || model.modelType === activeType.value;
if (!typeMatched || !keyword) {
return typeMatched;
}
return model.modelId.toLowerCase().includes(keyword);
})
.sort((left, right) =>
left.modelId.localeCompare(right.modelId, undefined, {
sensitivity: 'base',
}),
);
});
const hasFilters = computed(
() => Boolean(searchText.value.trim()) || activeType.value !== 'allModel',
);
const emptyDescription = computed(() =>
hasFilters.value ? '没有匹配的模型' : '远端没有返回可添加的模型',
);
const dialogTitle = computed(() =>
providerName.value ? `${providerName.value} 模型` : '获取模型列表',
);
const handleSearchInput = () => {
if (searchTimer) {
clearTimeout(searchTimer);
}
searchTimer = setTimeout(() => {
debouncedSearch.value = searchText.value;
}, 160);
};
const clearFilters = () => {
searchText.value = '';
debouncedSearch.value = '';
activeType.value = 'allModel';
};
const requestModels = async () => {
if (!providerId.value || loading.value) {
return;
}
const sequence = ++requestSequence;
loading.value = true;
errorMessage.value = '';
try {
const response = await getRemoteModels(providerId.value);
if (sequence !== requestSequence) {
return;
}
if (response?.errorCode !== 0) {
throw new Error(response?.message || '获取模型列表失败');
}
const data = (response.data || {}) as RemoteModelListData;
models.value = Array.isArray(data.models) ? data.models : [];
truncated.value = Boolean(data.truncated);
} catch (error: any) {
if (sequence === requestSequence) {
models.value = [];
truncated.value = false;
errorMessage.value =
error?.response?.data?.message || error?.message || '获取模型列表失败';
}
} finally {
if (sequence === requestSequence) {
loading.value = false;
}
}
};
const handleImport = async (model: RemoteModelDescriptor) => {
if (model.added || !model.addable || importingIds.value.has(model.modelId)) {
return;
}
importingIds.value = new Set(importingIds.value).add(model.modelId);
try {
const response = await importRemoteModel(providerId.value, model.modelId);
if (response?.errorCode !== 0) {
throw new Error(response?.message || '添加模型失败');
}
models.value = models.value.map((item) =>
item.modelId === model.modelId ? { ...item, added: true } : item,
);
ElMessage.success(
response.data?.status === 'ALREADY_EXISTS' ? '模型已添加' : '添加成功',
);
emit('imported');
} catch (error: any) {
ElMessage.error(
error?.response?.data?.message || error?.message || '添加模型失败',
);
} finally {
const nextIds = new Set(importingIds.value);
nextIds.delete(model.modelId);
importingIds.value = nextIds;
}
};
const handleManualAdd = () => {
visible.value = false;
emit('manualAdd');
};
const handleOpenChange = (open: boolean) => {
visible.value = open;
if (!open) {
requestSequence += 1;
}
};
const openDialog = (nextProviderId: string, nextProviderName = '') => {
providerId.value = nextProviderId;
providerName.value = nextProviderName;
models.value = [];
importingIds.value = new Set();
errorMessage.value = '';
truncated.value = false;
clearFilters();
visible.value = true;
void requestModels();
};
onBeforeUnmount(() => {
if (searchTimer) {
clearTimeout(searchTimer);
}
requestSequence += 1;
});
defineExpose({ openDialog });
</script>
<template>
<EasyFlowFormModal
:open="visible"
:title="dialogTitle"
width="800px"
:show-footer="false"
@update:open="handleOpenChange"
>
<div class="remote-model-dialog">
<div class="remote-model-dialog__toolbar">
<ElInput
v-model="searchText"
clearable
:disabled="loading || Boolean(errorMessage)"
placeholder="搜索模型 ID"
aria-label="搜索远端模型"
@input="handleSearchInput"
@clear="handleSearchInput"
>
<template #prefix>
<ElIcon><Search /></ElIcon>
</template>
</ElInput>
<ElButton
:icon="Refresh"
:loading="loading"
:disabled="loading"
aria-label="刷新远端模型列表"
@click="requestModels"
>
刷新
</ElButton>
</div>
<div class="remote-model-dialog__filters">
<ElSegmented
v-model="activeType"
:options="typeOptions"
aria-label="按模型类型筛选"
/>
<span
v-if="!loading && !errorMessage"
class="remote-model-dialog__count"
>
{{ filteredModels.length }} 个模型
</span>
</div>
<div
v-if="loading"
class="remote-model-dialog__loading"
aria-live="polite"
>
<ElSkeleton v-for="item in 5" :key="item" animated :rows="1" />
</div>
<div v-else-if="errorMessage" class="remote-model-dialog__state">
<strong>获取失败</strong>
<p>{{ errorMessage }}</p>
<div class="remote-model-dialog__state-actions">
<ElButton type="primary" :icon="Refresh" @click="requestModels">
重新获取
</ElButton>
<ElButton @click="handleManualAdd">手动新增</ElButton>
</div>
</div>
<div
v-else-if="filteredModels.length === 0"
class="remote-model-dialog__state"
>
<ElEmpty :description="emptyDescription">
<ElButton v-if="hasFilters" @click="clearFilters">清空筛选</ElButton>
<ElButton v-else :icon="Refresh" @click="requestModels">
重新获取
</ElButton>
</ElEmpty>
</div>
<div v-else class="remote-model-dialog__list">
<div
v-for="model in filteredModels"
:key="model.modelId"
class="remote-model-row"
>
<div class="remote-model-row__identity">
<strong :title="model.modelId">
{{ model.modelId }}
</strong>
</div>
<div class="remote-model-row__abilities">
<ElTag size="small" effect="plain">
{{ getTypeLabel(model.modelType) }}
</ElTag>
<ElTag
v-if="model.supportImage"
size="small"
effect="plain"
type="success"
>
视觉
</ElTag>
<ElTag
v-if="model.supportThinking"
size="small"
effect="plain"
type="info"
>
推理
</ElTag>
<ElTag
v-if="model.supportTool"
size="small"
effect="plain"
type="warning"
>
工具
</ElTag>
</div>
<div class="remote-model-row__action">
<span v-if="model.added" class="remote-model-row__added">
<ElIcon><Check /></ElIcon>
已添加
</span>
<ElButton
v-else
circle
text
:icon="Plus"
:loading="importingIds.has(model.modelId)"
:disabled="!model.addable || importingIds.has(model.modelId)"
:title="model.unavailableReason || '添加模型'"
:aria-label="`添加 ${model.modelId}`"
@click="handleImport(model)"
/>
</div>
</div>
</div>
<p v-if="truncated" class="remote-model-dialog__notice">
模型数量较多当前显示前 1000
</p>
</div>
</EasyFlowFormModal>
</template>
<style scoped>
.remote-model-dialog {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
}
.remote-model-dialog__toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.remote-model-dialog__filters {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
}
.remote-model-dialog__count,
.remote-model-dialog__notice {
font-size: 12px;
color: hsl(var(--text-muted));
}
.remote-model-dialog__loading {
display: flex;
flex-direction: column;
gap: 16px;
padding-top: 16px;
}
.remote-model-dialog__state {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
align-items: center;
justify-content: center;
min-height: 280px;
text-align: center;
}
.remote-model-dialog__state strong {
color: hsl(var(--text-strong));
}
.remote-model-dialog__state p {
max-width: 560px;
margin: 0;
font-size: 13px;
line-height: 1.6;
color: hsl(var(--text-muted));
}
.remote-model-dialog__state-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.remote-model-dialog__list {
overflow: hidden;
}
.remote-model-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(160px, auto) 64px;
gap: 16px;
align-items: center;
min-height: 64px;
padding: 0 12px;
border-bottom: 1px solid hsl(var(--divider-faint) / 42%);
transition: background-color 140ms ease;
}
.remote-model-row:hover {
background: hsl(var(--surface-contrast-soft) / 28%);
}
.remote-model-row__identity {
min-width: 0;
}
.remote-model-row__identity strong {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 600;
color: hsl(var(--text-strong));
}
.remote-model-row__abilities {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
}
.remote-model-row__action {
display: flex;
justify-content: flex-end;
}
.remote-model-row__added {
display: inline-flex;
gap: 4px;
align-items: center;
font-size: 12px;
color: hsl(var(--success));
}
.remote-model-dialog__notice {
margin: -8px 0 0;
}
@media (max-width: 768px) {
.remote-model-dialog {
min-height: 0;
}
.remote-model-dialog__toolbar {
grid-template-columns: minmax(0, 1fr);
}
.remote-model-dialog__filters {
align-items: flex-start;
flex-direction: column;
gap: 8px;
}
.remote-model-row {
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.remote-model-row__abilities {
display: none;
}
}
</style>