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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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