feat: 增加服务商远端模型发现与一键添加
- 支持 OpenAI 兼容、Ollama 和阿里百炼模型目录适配 - 使用静态模型库识别能力并过滤未接入的生成模型 - 增加扁平模型列表、搜索筛选和幂等添加
This commit is contained in:
@@ -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;
|
||||
|
||||
import com.mybatisflex.core.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import tech.easyflow.ai.entity.ModelProvider;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
/**
|
||||
* 映射层。
|
||||
*
|
||||
@@ -11,4 +15,12 @@ import tech.easyflow.ai.entity.ModelProvider;
|
||||
*/
|
||||
public interface ModelProviderMapper extends BaseMapper<ModelProvider> {
|
||||
|
||||
/**
|
||||
* 锁定服务商记录,用于串行化同一服务商下的幂等模型导入。
|
||||
*
|
||||
* @param id 服务商 ID
|
||||
* @return 已锁定的服务商 ID,不存在时返回 null
|
||||
*/
|
||||
@Select("SELECT id FROM tb_model_provider WHERE id = #{id} FOR UPDATE")
|
||||
BigInteger lockById(@Param("id") BigInteger id);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user