发布 v1.10 #5
@@ -1,21 +1,33 @@
|
|||||||
package tech.easyflow.admin.controller.ai;
|
package tech.easyflow.admin.controller.ai;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
import com.mybatisflex.core.query.QueryWrapper;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
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.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import tech.easyflow.ai.dto.RemoteModelImportRequest;
|
||||||
import tech.easyflow.ai.entity.Model;
|
import tech.easyflow.ai.entity.Model;
|
||||||
import tech.easyflow.ai.entity.ModelProvider;
|
import tech.easyflow.ai.entity.ModelProvider;
|
||||||
import tech.easyflow.ai.service.ModelProviderService;
|
import tech.easyflow.ai.service.ModelProviderService;
|
||||||
import tech.easyflow.ai.service.ModelService;
|
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.annotation.UsePermission;
|
||||||
import tech.easyflow.common.domain.Result;
|
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.controller.BaseCurdController;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
|
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 控制层。
|
* 控制层。
|
||||||
@@ -28,12 +40,33 @@ import java.io.Serializable;
|
|||||||
@UsePermission(moduleName = "/api/v1/model")
|
@UsePermission(moduleName = "/api/v1/model")
|
||||||
public class ModelProviderController extends BaseCurdController<ModelProviderService, ModelProvider> {
|
public class ModelProviderController extends BaseCurdController<ModelProviderService, ModelProvider> {
|
||||||
private final ModelService modelService;
|
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);
|
super(service);
|
||||||
this.modelService = modelService;
|
this.modelService = modelService;
|
||||||
|
this.remoteModelDiscoveryService = remoteModelDiscoveryService;
|
||||||
|
this.remoteModelImportService = remoteModelImportService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除没有子模型的服务商。
|
||||||
|
*
|
||||||
|
* @param id 服务商 ID
|
||||||
|
* @return 删除结果
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
@PostMapping("remove")
|
@PostMapping("remove")
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -45,4 +78,35 @@ public class ModelProviderController extends BaseCurdController<ModelProviderSer
|
|||||||
}
|
}
|
||||||
return Result.ok(service.removeById(id));
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
package tech.easyflow.ai.mapper;
|
package tech.easyflow.ai.mapper;
|
||||||
|
|
||||||
import com.mybatisflex.core.BaseMapper;
|
import com.mybatisflex.core.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
import tech.easyflow.ai.entity.ModelProvider;
|
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> {
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package tech.easyflow.ai.service.capability;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模型能力识别来源。
|
||||||
|
*/
|
||||||
|
public enum ModelCapabilitySource {
|
||||||
|
/** 静态模型能力库精确命中。 */
|
||||||
|
CATALOG,
|
||||||
|
/** 根据稳定模型命名规则推断。 */
|
||||||
|
RULE,
|
||||||
|
/** 未识别模型使用的保守默认值。 */
|
||||||
|
DEFAULT
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package tech.easyflow.ai.service.discovery;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 远端模型一键添加结果状态。
|
||||||
|
*/
|
||||||
|
public enum RemoteModelImportStatus {
|
||||||
|
/** 已创建新的本地模型。 */
|
||||||
|
CREATED,
|
||||||
|
/** 相同模型已经存在。 */
|
||||||
|
ALREADY_EXISTS
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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` 条目和对应模型卡为准。
|
||||||
11810
easyflow-modules/easyflow-module-ai/src/main/resources/llm.json
Normal file
11810
easyflow-modules/easyflow-module-ai/src/main/resources/llm.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -37,6 +37,47 @@ export async function verifyModelConfig(id: string) {
|
|||||||
return api.get('/api/v1/model/verifyLlmConfig', { params: { id } });
|
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 =
|
export type ModelVerificationStageStatus =
|
||||||
| 'FAILED'
|
| 'FAILED'
|
||||||
| 'PARTIAL'
|
| 'PARTIAL'
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ import { computed, onMounted, ref } from 'vue';
|
|||||||
|
|
||||||
import { $t } from '@easyflow/locales';
|
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 {
|
import {
|
||||||
ElButton,
|
ElButton,
|
||||||
ElEmpty,
|
ElEmpty,
|
||||||
@@ -13,6 +20,7 @@ import {
|
|||||||
ElMessage,
|
ElMessage,
|
||||||
ElMessageBox,
|
ElMessageBox,
|
||||||
ElTag,
|
ElTag,
|
||||||
|
ElTooltip,
|
||||||
} from 'element-plus';
|
} from 'element-plus';
|
||||||
|
|
||||||
import { getLlmProviderList } from '#/api/ai/llm.js';
|
import { getLlmProviderList } from '#/api/ai/llm.js';
|
||||||
@@ -31,6 +39,7 @@ import {
|
|||||||
} from '#/views/ai/model/modelUtils/providerDraft';
|
} from '#/views/ai/model/modelUtils/providerDraft';
|
||||||
import ModelVerifyConfig from '#/views/ai/model/ModelVerifyConfig.vue';
|
import ModelVerifyConfig from '#/views/ai/model/ModelVerifyConfig.vue';
|
||||||
import ModelViewItemOperation from '#/views/ai/model/ModelViewItemOperation.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';
|
import UnifiedGatewayWorkspace from '#/views/ai/model/UnifiedGatewayWorkspace.vue';
|
||||||
|
|
||||||
type ModelWorkspaceView = 'active' | 'gateway' | 'provider';
|
type ModelWorkspaceView = 'active' | 'gateway' | 'provider';
|
||||||
@@ -61,6 +70,7 @@ const llmVerifyConfigRef = ref();
|
|||||||
const addLlmRef = ref();
|
const addLlmRef = ref();
|
||||||
const activeWorkspaceRef = ref();
|
const activeWorkspaceRef = ref();
|
||||||
const unifiedGatewayWorkspaceRef = ref();
|
const unifiedGatewayWorkspaceRef = ref();
|
||||||
|
const remoteModelDialogRef = ref();
|
||||||
|
|
||||||
const selectedProvider = computed(() =>
|
const selectedProvider = computed(() =>
|
||||||
providers.value.find((item) => item.id === selectedProviderId.value),
|
providers.value.find((item) => item.id === selectedProviderId.value),
|
||||||
@@ -119,6 +129,19 @@ const isProviderDirty = computed(() =>
|
|||||||
isProviderDraftDirty(selectedProvider.value, providerDraft.value),
|
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(() =>
|
const currentProviderMetrics = computed(() =>
|
||||||
getProviderConfigMetrics(
|
getProviderConfigMetrics(
|
||||||
{
|
{
|
||||||
@@ -405,6 +428,24 @@ const handleAddLlm = (modelType = activeModelType.value) => {
|
|||||||
addLlmRef.value.openAddDialog(targetModelType);
|
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) => {
|
const handleDeleteLlm = (id: string) => {
|
||||||
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
|
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
|
||||||
confirmButtonText: $t('message.ok'),
|
confirmButtonText: $t('message.ok'),
|
||||||
@@ -722,6 +763,21 @@ onMounted(() => {
|
|||||||
<p>按模型能力分组管理已配置模型。</p>
|
<p>按模型能力分组管理已配置模型。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="provider-card__head-actions">
|
<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
|
<ElButton
|
||||||
type="primary"
|
type="primary"
|
||||||
:icon="Plus"
|
:icon="Plus"
|
||||||
@@ -825,6 +881,11 @@ onMounted(() => {
|
|||||||
@reload="handleModelDataReload"
|
@reload="handleModelDataReload"
|
||||||
/>
|
/>
|
||||||
<ModelVerifyConfig ref="llmVerifyConfigRef" />
|
<ModelVerifyConfig ref="llmVerifyConfigRef" />
|
||||||
|
<RemoteModelDialog
|
||||||
|
ref="remoteModelDialogRef"
|
||||||
|
@imported="handleRemoteModelImported"
|
||||||
|
@manual-add="handleRemoteManualAdd"
|
||||||
|
/>
|
||||||
</ListPageShell>
|
</ListPageShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
494
easyflow-ui-admin/app/src/views/ai/model/RemoteModelDialog.vue
Normal file
494
easyflow-ui-admin/app/src/views/ai/model/RemoteModelDialog.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user