feat: 添加LLM模型连通性测试功能
- 在 `AiModelServiceImpl` 中添加 `testLlmConnectivity` 方法,支持不同提供商的连通性测试 - 在 `AiModelController` 中添加 `/llm-connectivity-test` API 端点,用于测试 LLM 模型连通性 - 更新 `AiModelService` 接口以包含新的 `testLlmConnectivity` 方法 - 添加相关单元测试以验证连通性测试功能的正确性dev_na
parent
940cc8a939
commit
6a08fb1a3b
|
|
@ -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()")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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> {
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
测试连接
|
||||
|
|
|
|||
Loading…
Reference in New Issue