feat: 添加LLM模型连通性测试功能

- 在 `AiModelServiceImpl` 中添加 `testLlmConnectivity` 方法,支持不同提供商的连通性测试
- 在 `AiModelController` 中添加 `/llm-connectivity-test` API 端点,用于测试 LLM 模型连通性
- 更新 `AiModelService` 接口以包含新的 `testLlmConnectivity` 方法
- 添加相关单元测试以验证连通性测试功能的正确性
dev_na
chenhao 2026-04-22 09:40:15 +08:00
parent 940cc8a939
commit 6a08fb1a3b
12 changed files with 702 additions and 18 deletions

View File

@ -1,8 +1,7 @@
package com.imeeting.controller.biz;
import com.imeeting.dto.biz.AiModelDTO;
import com.imeeting.dto.biz.AiLocalProfileVO;
import com.imeeting.dto.biz.AiModelDTO;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.service.biz.AiModelService;
import com.unisbase.common.ApiResponse;
@ -114,6 +113,20 @@ public class AiModelController {
return ApiResponse.ok(aiModelService.testLocalConnectivity(dto.getBaseUrl(), dto.getApiKey()));
}
@Operation(summary = "测试LLM模型连通性")
@PostMapping("/llm-connectivity-test")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> testLlmConnectivity(@RequestBody AiModelDTO dto) {
if ("custom".equalsIgnoreCase(dto.getProvider()) && (dto.getBaseUrl() == null || dto.getBaseUrl().isBlank())) {
return ApiResponse.error("Base URL不能为空");
}
if (dto.getModelCode() == null || dto.getModelCode().isBlank()) {
return ApiResponse.error("模型名称不能为空");
}
aiModelService.testLlmConnectivity(dto);
return ApiResponse.ok(Boolean.TRUE);
}
@Operation(summary = "获取默认AI模型")
@GetMapping("/default")
@PreAuthorize("isAuthenticated()")

View File

@ -0,0 +1,24 @@
package com.imeeting.dto.android.legacy;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.List;
@Data
public class LegacyScreenSaverCatalogResponse {
@JsonProperty("refresh_interval_sec")
private Integer refreshIntervalSec;
@JsonProperty("play_mode")
private String playMode;
@JsonProperty("source_scope")
private String sourceScope;
@JsonProperty("display_duration_sec")
private Integer displayDurationSec;
private List<LegacyScreenSaverItemResponse> items;
}

View File

@ -13,6 +13,7 @@ public class AiModelDTO {
private String baseUrl;
private String apiPath;
private String apiKey;
private String testMessage;
private String modelCode;
private String wsUrl;
private BigDecimal temperature;

View File

@ -0,0 +1,12 @@
package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "屏保用户播放设置请求")
public class ScreenSaverUserSettingsDTO {
@Schema(description = "统一屏保展示时长(秒)", example = "15")
private Integer displayDurationSec;
}

View File

@ -0,0 +1,15 @@
package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "屏保用户播放设置响应")
public class ScreenSaverUserSettingsVO {
@Schema(description = "用户 ID")
private Long userId;
@Schema(description = "统一屏保展示时长(秒)")
private Integer displayDurationSec;
}

View File

@ -0,0 +1,44 @@
package com.imeeting.entity.biz;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Schema(description = "屏保用户播放设置实体")
@TableName("biz_screen_saver_user_settings")
public class ScreenSaverUserSettings {
@TableId(value = "id", type = IdType.AUTO)
@Schema(description = "设置 ID")
private Long id;
@TableField(fill = FieldFill.INSERT)
@Schema(description = "租户 ID")
private Long tenantId;
@Schema(description = "用户 ID")
private Long userId;
@Schema(description = "统一屏保展示时长(秒)")
private Integer displayDurationSec;
@TableLogic(value = "0", delval = "1")
@Schema(description = "逻辑删除标记")
private Integer isDeleted;
@TableField(fill = FieldFill.INSERT)
@Schema(description = "创建时间")
private LocalDateTime createdAt;
@TableField(fill = FieldFill.INSERT_UPDATE)
@Schema(description = "更新时间")
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,9 @@
package com.imeeting.mapper.biz;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imeeting.entity.biz.ScreenSaverUserSettings;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ScreenSaverUserSettingsMapper extends BaseMapper<ScreenSaverUserSettings> {
}

View File

@ -14,6 +14,7 @@ public interface AiModelService {
PageResult<List<AiModelVO>> pageModels(Integer current, Integer size, String name, String type, Long tenantId);
List<String> fetchRemoteModels(String provider, String baseUrl, String apiKey);
AiLocalProfileVO testLocalConnectivity(String baseUrl, String apiKey);
void testLlmConnectivity(AiModelDTO dto);
AiModelVO getDefaultModel(String type, Long tenantId);
AiModelVO getModelById(Long id, String type);
boolean removeModelById(Long id, String type);

View File

@ -31,6 +31,7 @@ import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@ -43,6 +44,20 @@ public class AiModelServiceImpl implements AiModelService {
private static final String TYPE_ASR = "ASR";
private static final String TYPE_LLM = "LLM";
private static final String DEFAULT_LLM_API_PATH = "/v1/chat/completions";
private static final String DEFAULT_ANTHROPIC_API_PATH = "/messages";
private static final String CONNECTIVITY_TEST_SYSTEM_PROMPT = """
You are an LLM connectivity test assistant.
You must return exactly one JSON object and nothing else.
Do not return markdown, code fences, explanations, or any extra text.
The JSON schema is fixed:
{"status":"success","message":"LLM connectivity test passed"}
Rules:
1. status must be success
2. message must be LLM connectivity test passed
3. no extra fields are allowed
""";
private static final String DEFAULT_LLM_TEST_MESSAGE = "Please reply with the fixed JSON payload.";
private final ObjectMapper objectMapper;
private final AsrModelMapper asrModelMapper;
@ -50,6 +65,7 @@ public class AiModelServiceImpl implements AiModelService {
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(300))
.version(HttpClient.Version.HTTP_1_1)
.build();
@Override
@ -161,14 +177,7 @@ public class AiModelServiceImpl implements AiModelService {
.timeout(Duration.ofSeconds(10))
.GET();
if ("anthropic".equals(providerKey)) {
if (apiKey != null && !apiKey.isBlank()) {
requestBuilder.header("x-api-key", apiKey);
}
requestBuilder.header("anthropic-version", "2023-06-01");
} else if (!"gemini".equals(providerKey) && apiKey != null && !apiKey.isBlank()) {
requestBuilder.header("Authorization", "Bearer " + apiKey);
}
applyProviderAuthHeaders(requestBuilder, providerKey, apiKey);
HttpResponse<String> response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
@ -227,6 +236,138 @@ public class AiModelServiceImpl implements AiModelService {
return fetchLocalProfile(baseUrl, apiKey);
}
@Override
public void testLlmConnectivity(AiModelDTO dto) {
String providerKey = normalizeProvider(dto.getProvider());
if ("anthropic".equals(providerKey)) {
testAnthropicConnectivity(dto, providerKey);
return;
}
if ("gemini".equals(providerKey) || "google".equals(providerKey)) {
testGeminiConnectivity(dto, providerKey);
return;
}
testOpenAiCompatibleConnectivity(dto, providerKey);
}
private void testOpenAiCompatibleConnectivity(AiModelDTO dto, String providerKey) {
String resolvedBaseUrl = resolveBaseUrl(providerKey, dto.getBaseUrl());
String targetUrl = appendPath(resolvedBaseUrl,
dto.getApiPath() == null || dto.getApiPath().isBlank() ? DEFAULT_LLM_API_PATH : dto.getApiPath());
Map<String, Object> body = new LinkedHashMap<>();
body.put("model", dto.getModelCode().trim());
body.put("stream", false);
body.put("max_tokens", 32);
if (dto.getTemperature() != null) {
body.put("temperature", dto.getTemperature());
}
if (dto.getTopP() != null) {
body.put("top_p", dto.getTopP());
}
body.put("response_format", Map.of("type", "json_object"));
body.put("messages", buildConnectivityTestMessages(dto.getTestMessage()));
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(targetUrl))
.timeout(Duration.ofSeconds(20))
.header("Content-Type", "application/json; charset=UTF-8")
.header("Accept", "application/json");
applyProviderAuthHeaders(requestBuilder, providerKey, dto.getApiKey());
executeConnectivityRequest(targetUrl, dto.getModelCode(), requestBuilder, body);
}
private void testAnthropicConnectivity(AiModelDTO dto, String providerKey) {
String resolvedBaseUrl = resolveBaseUrl(providerKey, dto.getBaseUrl());
String targetUrl = appendPath(resolvedBaseUrl,
dto.getApiPath() == null || dto.getApiPath().isBlank() ? DEFAULT_ANTHROPIC_API_PATH : dto.getApiPath());
Map<String, Object> body = new LinkedHashMap<>();
body.put("model", dto.getModelCode().trim());
body.put("system", CONNECTIVITY_TEST_SYSTEM_PROMPT);
body.put("max_tokens", 32);
body.put("messages", List.of(
Map.of("role", "user", "content", resolveTestMessage(dto.getTestMessage()))
));
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(targetUrl))
.timeout(Duration.ofSeconds(20))
.header("Content-Type", "application/json; charset=UTF-8")
.header("Accept", "application/json");
applyProviderAuthHeaders(requestBuilder, providerKey, dto.getApiKey());
executeConnectivityRequest(targetUrl, dto.getModelCode(), requestBuilder, body);
}
private void testGeminiConnectivity(AiModelDTO dto, String providerKey) {
String resolvedBaseUrl = resolveBaseUrl(providerKey, dto.getBaseUrl());
String apiPath = dto.getApiPath();
if (apiPath == null || apiPath.isBlank()) {
apiPath = "/models/" + dto.getModelCode().trim() + ":generateContent";
}
String targetUrl = appendPath(resolvedBaseUrl, apiPath);
if (dto.getApiKey() != null && !dto.getApiKey().isBlank()) {
targetUrl = appendQueryParam(targetUrl, "key", dto.getApiKey().trim());
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("systemInstruction", Map.of(
"parts", List.of(Map.of("text", CONNECTIVITY_TEST_SYSTEM_PROMPT))
));
body.put("contents", List.of(
Map.of(
"role", "user",
"parts", List.of(Map.of("text", resolveTestMessage(dto.getTestMessage())))
)
));
if (dto.getTemperature() != null || dto.getTopP() != null) {
Map<String, Object> generationConfig = new LinkedHashMap<>();
if (dto.getTemperature() != null) {
generationConfig.put("temperature", dto.getTemperature());
}
if (dto.getTopP() != null) {
generationConfig.put("topP", dto.getTopP());
}
body.put("generationConfig", generationConfig);
}
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(targetUrl))
.timeout(Duration.ofSeconds(20))
.header("Content-Type", "application/json; charset=UTF-8")
.header("Accept", "application/json");
executeConnectivityRequest(targetUrl, dto.getModelCode(), requestBuilder, body);
}
private void executeConnectivityRequest(String targetUrl, String modelCode, HttpRequest.Builder requestBuilder, Object body) {
try {
String requestBody = objectMapper.writeValueAsString(body);
log.info("Testing LLM connectivity, url={}, model={}", targetUrl, modelCode);
HttpRequest request = requestBuilder
.POST(HttpRequest.BodyPublishers.ofString(requestBody, StandardCharsets.UTF_8))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new RuntimeException("HTTP " + response.statusCode() + ", body=" + response.body());
}
JsonNode root = objectMapper.readTree(response.body());
String responseContent = readConnectivityResponseContent(root);
String resultMessage = extractConnectivityResultMessage(responseContent);
if (resultMessage == null || resultMessage.isBlank()) {
throw new RuntimeException("Unexpected empty connectivity response, body=" + response.body());
}
} catch (Exception e) {
String detail = describeException(e);
log.error("LLM connectivity test failed, url={}, detail={}", targetUrl, detail, e);
throw new RuntimeException("LLM连通性测试失败 url=" + targetUrl + ", detail=" + detail, e);
}
}
private AiLocalProfileVO fetchLocalProfile(String baseUrl, String apiKey) {
String targetUrl = appendPath(baseUrl, "api/v1/system/profile");
try {
@ -297,10 +438,10 @@ public class AiModelServiceImpl implements AiModelService {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new RuntimeException("本地模型配置保存失败: HTTP " + response.statusCode());
throw new RuntimeException("闂佸搫鐗滈崜娆忥耿閺夋嚦鐔煎灳瀹曞洠鍋撻悜鑺ョ厐鐎广儱娲ㄩ弸鍌毲庨崶銊х畵闁宦板妽瀵板嫭娼忛銉? HTTP " + response.statusCode());
}
} catch (Exception e) {
throw new RuntimeException("本地模型配置保存失败: " + e.getMessage(), e);
throw new RuntimeException("闂佸搫鐗滈崜娆忥耿閺夋嚦鐔煎灳瀹曞洠鍋撻悜鑺ョ厐鐎广儱娲ㄩ弸鍌毲庨崶銊х畵闁宦板妽瀵板嫭娼忛銉? " + e.getMessage(), e);
}
}
@ -335,7 +476,227 @@ public class AiModelServiceImpl implements AiModelService {
}
private String appendPath(String baseUrl, String path) {
return baseUrl.endsWith("/") ? baseUrl + path : baseUrl + "/" + path;
if (baseUrl == null || baseUrl.isBlank()) {
throw new RuntimeException("baseUrl is required");
}
String trimmedBaseUrl = baseUrl.trim();
if (path == null || path.isBlank()) {
return trimmedBaseUrl;
}
String trimmedPath = path.trim();
if (trimmedPath.startsWith("http://") || trimmedPath.startsWith("https://")) {
return trimmedPath;
}
String normalizedBaseUrl = trimmedBaseUrl.endsWith("/")
? trimmedBaseUrl.substring(0, trimmedBaseUrl.length() - 1)
: trimmedBaseUrl;
if (!trimmedPath.startsWith("/")) {
return normalizedBaseUrl + "/" + trimmedPath;
}
URI baseUri = URI.create(normalizedBaseUrl + "/");
String basePath = baseUri.getPath();
if (basePath != null && !basePath.isBlank() && !"/".equals(basePath)) {
String normalizedBasePath = basePath.endsWith("/") ? basePath.substring(0, basePath.length() - 1) : basePath;
if (trimmedPath.startsWith(normalizedBasePath + "/")) {
return baseUri.resolve(trimmedPath).toString();
}
}
return normalizedBaseUrl + trimmedPath;
}
private String appendQueryParam(String url, String name, String value) {
if (value == null || value.isBlank()) {
return url;
}
String delimiter = url.contains("?") ? "&" : "?";
return url + delimiter + name + "=" + URLEncoder.encode(value, StandardCharsets.UTF_8);
}
private void applyProviderAuthHeaders(HttpRequest.Builder requestBuilder, String providerKey, String apiKey) {
if ("anthropic".equals(providerKey)) {
if (apiKey != null && !apiKey.isBlank()) {
requestBuilder.header("x-api-key", apiKey.trim());
}
requestBuilder.header("anthropic-version", "2023-06-01");
return;
}
if ("gemini".equals(providerKey) || "google".equals(providerKey)) {
return;
}
if (apiKey != null && !apiKey.isBlank()) {
requestBuilder.header("Authorization", buildAuthorization(apiKey));
}
}
private String buildAuthorization(String apiKey) {
String trimmedApiKey = apiKey == null ? "" : apiKey.trim();
if (trimmedApiKey.isEmpty()) {
return trimmedApiKey;
}
return trimmedApiKey.startsWith("Bearer ") ? trimmedApiKey : "Bearer " + trimmedApiKey;
}
private String resolveTestMessage(String testMessage) {
if (testMessage == null || testMessage.isBlank()) {
return DEFAULT_LLM_TEST_MESSAGE;
}
return testMessage.trim();
}
private List<Map<String, String>> buildConnectivityTestMessages(String testMessage) {
return List.of(
Map.of("role", "system", "content", CONNECTIVITY_TEST_SYSTEM_PROMPT),
Map.of("role", "user", "content", resolveTestMessage(testMessage))
);
}
private String readConnectivityResponseContent(JsonNode root) {
String anthropicText = root.path("content").path(0).path("text").asText(null);
if (anthropicText != null && !anthropicText.isBlank()) {
return anthropicText;
}
String chatCompletionContent = root.path("choices").path(0).path("message").path("content").asText(null);
if (chatCompletionContent != null && !chatCompletionContent.isBlank()) {
return chatCompletionContent;
}
String textCompletionContent = root.path("choices").path(0).path("text").asText(null);
if (textCompletionContent != null && !textCompletionContent.isBlank()) {
return textCompletionContent;
}
String responsesApiContent = root.path("output_text").asText(null);
if (responsesApiContent != null && !responsesApiContent.isBlank()) {
return responsesApiContent;
}
JsonNode candidatesNode = root.path("candidates");
if (candidatesNode.isArray()) {
for (JsonNode candidate : candidatesNode) {
JsonNode partsNode = candidate.path("content").path("parts");
if (!partsNode.isArray()) {
continue;
}
for (JsonNode part : partsNode) {
String text = part.path("text").asText(null);
if (text != null && !text.isBlank()) {
return text;
}
}
}
}
JsonNode outputNode = root.path("output");
if (outputNode.isArray()) {
for (JsonNode item : outputNode) {
JsonNode contentArray = item.path("content");
if (!contentArray.isArray()) {
continue;
}
for (JsonNode contentItem : contentArray) {
String text = contentItem.path("text").asText(null);
if (text != null && !text.isBlank()) {
return text;
}
}
}
}
return null;
}
private String extractConnectivityResultMessage(String responseContent) {
if (responseContent == null || responseContent.isBlank()) {
return null;
}
String trimmed = responseContent.trim();
JsonNode parsed = tryParseConnectivityPayload(trimmed);
if (parsed != null) {
String status = parsed.path("status").asText("");
String message = parsed.path("message").asText("");
if ("success".equalsIgnoreCase(status) && !message.isBlank()) {
return message;
}
}
return trimmed;
}
private JsonNode tryParseConnectivityPayload(String text) {
try {
return objectMapper.readTree(text);
} catch (Exception ignored) {
}
String jsonObject = extractFirstJsonObject(text);
if (jsonObject == null) {
return null;
}
try {
return objectMapper.readTree(jsonObject);
} catch (Exception ignored) {
return null;
}
}
private String extractFirstJsonObject(String text) {
if (text == null || text.isBlank()) {
return null;
}
int start = text.indexOf('{');
if (start < 0) {
return null;
}
int depth = 0;
boolean inString = false;
boolean escaped = false;
for (int i = start; i < text.length(); i++) {
char ch = text.charAt(i);
if (escaped) {
escaped = false;
continue;
}
if (ch == '\\') {
escaped = true;
continue;
}
if (ch == '"') {
inString = !inString;
continue;
}
if (inString) {
continue;
}
if (ch == '{') {
depth++;
} else if (ch == '}') {
depth--;
if (depth == 0) {
return text.substring(start, i + 1);
}
}
}
return null;
}
private String describeException(Throwable throwable) {
if (throwable == null) {
return "unknown";
}
String message = throwable.getMessage();
if (message != null && !message.isBlank()) {
return throwable.getClass().getSimpleName() + ": " + message;
}
Throwable cause = throwable.getCause();
if (cause != null && cause != throwable) {
String causeMessage = describeException(cause);
if (causeMessage != null && !causeMessage.isBlank()) {
return throwable.getClass().getSimpleName() + " <- " + causeMessage;
}
}
return throwable.getClass().getName();
}
private String sanitizeModelName(String rawName) {
@ -357,19 +718,19 @@ public class AiModelServiceImpl implements AiModelService {
private void validateModel(AiModelDTO dto) {
if (dto == null) {
throw new RuntimeException("模型配置不能为空");
throw new RuntimeException("妯″瀷閰嶇疆涓嶈兘涓虹┖");
}
if ("custom".equals(normalizeProvider(dto.getProvider()))) {
if (dto.getApiKey() == null || dto.getApiKey().isBlank()) {
throw new RuntimeException("本地模型必须填写API Key");
throw new RuntimeException("鏈湴妯″瀷蹇呴』濉啓API Key");
}
if (TYPE_ASR.equals(normalizeType(dto.getModelType()))) {
Map<String, Object> mediaConfig = dto.getMediaConfig() == null ? Collections.emptyMap() : dto.getMediaConfig();
if (readConfigString(mediaConfig.get("speakerModel")) == null) {
throw new RuntimeException("本地ASR模型必须选择声纹模型");
throw new RuntimeException("Local ASR model requires a speaker model");
}
if (mediaConfig.get("svThreshold") == null) {
throw new RuntimeException("本地ASR模型必须填写声纹阈值");
throw new RuntimeException("Local ASR model requires svThreshold");
}
}
}

View File

@ -0,0 +1,162 @@
package com.imeeting.service.biz.impl;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.dto.biz.AiModelDTO;
import com.imeeting.mapper.biz.AsrModelMapper;
import com.imeeting.mapper.biz.LlmModelMapper;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.io.OutputStream;
import java.lang.reflect.Field;
import java.net.InetSocketAddress;
import java.net.http.HttpClient;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
class AiModelServiceImplTest {
private final ObjectMapper objectMapper = new ObjectMapper();
private HttpServer server;
@AfterEach
void tearDown() {
if (server != null) {
server.stop(0);
}
}
@Test
void testLlmConnectivityShouldCallBaseUrlAndApiPathWithAuthorizationAndMessage() throws Exception {
AtomicReference<String> requestPath = new AtomicReference<>();
AtomicReference<String> authorization = new AtomicReference<>();
AtomicReference<String> body = new AtomicReference<>();
server = HttpServer.create(new InetSocketAddress(0), 0);
server.createContext("/gateway/v1/chat/completions", exchange -> {
captureRequest(exchange, requestPath, authorization, body);
writeJson(exchange, 200, "{\"choices\":[{\"message\":{\"content\":\"{\\\"status\\\":\\\"success\\\",\\\"message\\\":\\\"LLM connectivity test passed\\\"}\"}}]}");
});
server.start();
AiModelServiceImpl service = new AiModelServiceImpl(
objectMapper,
mock(AsrModelMapper.class),
mock(LlmModelMapper.class)
);
AiModelDTO dto = new AiModelDTO();
dto.setBaseUrl("http://127.0.0.1:" + server.getAddress().getPort() + "/gateway");
dto.setApiPath("/v1/chat/completions");
dto.setApiKey("test-key");
dto.setModelCode("gpt-test");
dto.setTestMessage("璇峰洖澶嶏細杩炴帴姝e父");
service.testLlmConnectivity(dto);
assertEquals("/gateway/v1/chat/completions", requestPath.get());
assertEquals("Bearer test-key", authorization.get());
JsonNode requestJson = objectMapper.readTree(body.get());
assertEquals("gpt-test", requestJson.path("model").asText());
assertEquals(
"{\"status\":\"success\",\"message\":\"LLM connectivity test passed\"}",
requestJson.path("messages").path(0).path("content").asText().lines()
.filter(line -> line.trim().startsWith("{"))
.findFirst()
.orElse("")
.trim()
);
assertEquals("璇峰洖澶嶏細杩炴帴姝e父", requestJson.path("messages").path(1).path("content").asText());
}
@Test
void testLlmConnectivityShouldAvoidDuplicatingSharedPathPrefix() throws Exception {
AtomicReference<String> requestPath = new AtomicReference<>();
server = HttpServer.create(new InetSocketAddress(0), 0);
server.createContext("/v1/chat/completions", exchange -> {
captureRequest(exchange, requestPath, new AtomicReference<>(), new AtomicReference<>());
writeJson(exchange, 200, "{\"choices\":[{\"message\":{\"content\":\"{\\\"status\\\":\\\"success\\\",\\\"message\\\":\\\"LLM connectivity test passed\\\"}\"}}]}");
});
server.start();
AiModelServiceImpl service = new AiModelServiceImpl(
objectMapper,
mock(AsrModelMapper.class),
mock(LlmModelMapper.class)
);
AiModelDTO dto = new AiModelDTO();
dto.setBaseUrl("http://127.0.0.1:" + server.getAddress().getPort() + "/v1");
dto.setApiPath("/v1/chat/completions");
dto.setModelCode("gpt-test");
service.testLlmConnectivity(dto);
assertEquals("/v1/chat/completions", requestPath.get());
}
@Test
void testLlmConnectivityShouldAcceptPlainTextResponseWhenModelIgnoresJsonFormat() throws Exception {
server = HttpServer.create(new InetSocketAddress(0), 0);
server.createContext("/v1/chat/completions", exchange -> {
captureRequest(exchange, new AtomicReference<>(), new AtomicReference<>(), new AtomicReference<>());
writeJson(exchange, 200, "{\"choices\":[{\"message\":{\"content\":\"thought\\n* Input: \\\"请回复LLM 连通性测试成功。\\\"\"}}]}");
});
server.start();
AiModelServiceImpl service = new AiModelServiceImpl(
objectMapper,
mock(AsrModelMapper.class),
mock(LlmModelMapper.class)
);
AiModelDTO dto = new AiModelDTO();
dto.setBaseUrl("http://127.0.0.1:" + server.getAddress().getPort());
dto.setApiPath("/v1/chat/completions");
dto.setModelCode("gpt-test");
service.testLlmConnectivity(dto);
}
@Test
void testLlmConnectivityShouldUseHttp11ClientForCompatibility() throws Exception {
AiModelServiceImpl service = new AiModelServiceImpl(
objectMapper,
mock(AsrModelMapper.class),
mock(LlmModelMapper.class)
);
Field httpClientField = AiModelServiceImpl.class.getDeclaredField("httpClient");
httpClientField.setAccessible(true);
HttpClient httpClient = (HttpClient) httpClientField.get(service);
assertEquals(HttpClient.Version.HTTP_1_1, httpClient.version());
}
private void captureRequest(HttpExchange exchange,
AtomicReference<String> requestPath,
AtomicReference<String> authorization,
AtomicReference<String> body) throws IOException {
requestPath.set(exchange.getRequestURI().getPath());
authorization.set(exchange.getRequestHeaders().getFirst("Authorization"));
body.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
}
private void writeJson(HttpExchange exchange, int status, String responseBody) throws IOException {
byte[] bytes = responseBody.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(status, bytes.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(bytes);
}
}
}

View File

@ -37,6 +37,7 @@ export interface AiModelDTO {
baseUrl?: string;
apiPath?: string;
apiKey?: string;
testMessage?: string;
modelCode?: string;
wsUrl?: string;
temperature?: number;
@ -100,6 +101,22 @@ export const testLocalModelConnectivity = (data: { baseUrl: string; apiKey: stri
);
};
export const testLlmModelConnectivity = (data: {
provider?: string;
baseUrl: string;
apiPath?: string;
apiKey?: string;
modelCode: string;
temperature?: number;
topP?: number;
testMessage: string;
}) => {
return http.post<{ code: string; data: boolean; msg: string }>(
"/api/biz/aimodel/llm-connectivity-test",
data
);
};
export const getAiModelDefault = (type: 'ASR' | 'LLM') => {
return http.get<{ code: string; data: AiModelVO; msg: string }>(
"/api/biz/aimodel/default",

View File

@ -19,6 +19,7 @@ import {
getAiModelPage,
getRemoteModelList,
saveAiModel,
testLlmModelConnectivity,
testLocalModelConnectivity,
updateAiModel,
} from "../../api/business/aimodel";
@ -40,6 +41,8 @@ const PROVIDER_BASE_URL_MAP: Record<string, string> = {
groq: "https://api.groq.com/openai/v1",
};
const DEFAULT_LLM_TEST_MESSAGE = "请回复LLM 连通性测试成功。";
const AiModels: React.FC = () => {
const { message } = App.useApp();
const [form] = Form.useForm();
@ -277,6 +280,28 @@ const AiModels: React.FC = () => {
};
const handleTestConnectivity = async () => {
if (activeType === "LLM") {
const values = await form.validateFields(["provider", "baseUrl", "apiPath", "modelCode"]);
const extraValues = form.getFieldsValue(["apiKey", "temperature", "topP"]);
setConnectivityLoading(true);
try {
await testLlmModelConnectivity({
provider: values.provider,
baseUrl: values.baseUrl,
apiPath: values.apiPath,
apiKey: extraValues.apiKey,
modelCode: values.modelCode,
temperature: extraValues.temperature,
topP: extraValues.topP,
testMessage: DEFAULT_LLM_TEST_MESSAGE,
});
message.success("LLM 连通性测试成功");
} finally {
setConnectivityLoading(false);
}
return;
}
const values = await form.validateFields(["provider", "baseUrl", "apiKey"]);
if (String(values.provider || "").toLowerCase() !== "custom") {
message.warning("仅本地模型支持连通性测试");
@ -486,7 +511,7 @@ const AiModels: React.FC = () => {
<Input.Password />
</Form.Item>
{isLocalProvider && (
{(activeType === "LLM" || isLocalProvider) && (
<Form.Item label="连通性测试">
<Button icon={<WifiOutlined />} loading={connectivityLoading} onClick={handleTestConnectivity}>