mateclaw/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java

777 lines
35 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package vip.mate.llm.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClient;
import vip.mate.exception.MateClawException;
import vip.mate.llm.model.*;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class ModelDiscoveryService {
private final ModelProviderService modelProviderService;
private final ModelConfigService modelConfigService;
private final ObjectMapper objectMapper;
private static final Duration TIMEOUT = Duration.ofSeconds(10);
// Virtual-thread executor for parallel model probing (lightweight, short-lived)
private static final ExecutorService PROBE_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor();
// Probe concurrency cap — reduced from 5 to 3 because higher parallelism
// triggered 429 Throttling.RateQuota on DashScope during bulk refresh
private static final int MAX_PROBE_CONCURRENCY = 3;
// Per-model probe timeout (short; we only need to know "yes/no usable")
private static final long PROBE_TIMEOUT_SECONDS = 12;
/**
* Explicit deny list: model ids listed by DashScope compatible-mode that are known
* to fail on the native protocol. Updated as we observe new failures.
* Note: the DASHSCOPE_NATIVE_UNSUPPORTED_PATTERN below also catches the whole
* dot-versioned family; this set makes individual blocked names searchable/auditable.
*/
private static final Set<String> DASHSCOPE_NATIVE_DENY = Set.of(
"qwen3.5-max",
"qwen3.5-plus",
"qwen3.6-plus",
"qwen3.6-max"
);
/**
* Pattern matching DashScope model ids that use a dot-versioned family (e.g.
* "qwen3.5-max", "qwen3.6-plus"). These are only offered on compatible-mode
* and consistently fail on the native endpoint with
* "[InvalidParameter] url error". Block them regardless of exact name.
*/
private static final java.util.regex.Pattern DASHSCOPE_NATIVE_UNSUPPORTED_PATTERN =
java.util.regex.Pattern.compile("^qwen\\d+\\.\\d+.*", java.util.regex.Pattern.CASE_INSENSITIVE);
/**
* Parameter-size suffixes used by DashScope open-source base models (e.g.
* {@code qwen3-0.6b}, {@code qwen3-8b}, {@code qwen3-32b}, {@code qwen3-30b-a3b}).
* These are catalog entries, not DashScope-hosted chat endpoints, and the native
* protocol returns {@code InvalidParameter: parameter.enable_thinking ...} for
* any attempt to invoke them. Pre-filter them out of discovery.
*/
private static final java.util.regex.Pattern DASHSCOPE_OPEN_SOURCE_SIZE_PATTERN =
java.util.regex.Pattern.compile("^qwen\\d+-\\d+(?:\\.\\d+)?b(?:-.*)?$", java.util.regex.Pattern.CASE_INSENSITIVE);
/**
* Prefixes that identify non-chat modalities (image generation, vision understanding,
* TTS, ASR, omni/multimodal bases, realtime speech, live translation, OCR, speech-to-
* speech, voice-clone). They are catalog-visible but have different endpoints than
* the native chat-generation API, so probing them via chat always fails with
* "url error". Blocking them up-front cuts discovery time and log noise dramatically.
*/
private static final Set<String> DASHSCOPE_NON_CHAT_PREFIXES = Set.of(
// Vision understanding / OCR
"qwen-vl-",
"qwen3-vl-",
// Image generation / edit
"qwen-image-",
"qwen3-image-",
// TTS / ASR / speech-to-speech / voice
"qwen-tts-",
"qwen3-tts-",
"qwen-asr-",
"qwen3-asr-",
"qwen-s2s-",
"qwen3-s2s-",
// Omni multimodal bases
"qwen-omni-",
"qwen3-omni-",
// Audio understanding
"qwen-audio-",
// Live translation
"qwen-livetranslate-",
"qwen3-livetranslate-"
);
/**
* Allow-list prefixes for DashScope models that are known to work on the native
* chat protocol. An empty set means "no prefix filter" (we still apply DENY).
* Extend conservatively as new families are verified.
*/
private static final Set<String> DASHSCOPE_NATIVE_ALLOW_PREFIXES = Set.of(
"qwen-", // qwen-max / qwen-plus / qwen-turbo / qwen-coder-* / qwen-long
"qwen2-", // qwen2 series
"qwen3-", // qwen3-max / qwen3-plus / qwen3-coder / qwen3-235b-*
"deepseek-", // deepseek-v3.x / deepseek-r1*
"baichuan",
"yi-",
"llama"
);
// ==================== 模型发现 ====================
public DiscoverResult discoverModels(String providerId) {
ModelProviderEntity provider = modelProviderService.getProviderConfig(providerId);
if (!Boolean.TRUE.equals(provider.getSupportModelDiscovery())) {
throw new MateClawException("err.llm.discovery_not_supported", "该供应商不支持模型发现: " + providerId);
}
ModelProtocol protocol = ModelProtocol.fromChatModel(provider.getChatModel());
List<ModelInfoDTO> discovered = fetchRemoteModels(provider, protocol);
// Layer 2: Protocol-aware allow/deny filtering. The listing endpoint
// (compatible-mode /v1/models for DashScope) often returns models that
// the native SDK does not accept — filter them out before the user sees
// them.
discovered = applyProtocolFilter(discovered, protocol, providerId);
// Layer 3: Probe each remaining model with a real runtime-protocol call.
// This catches any model the allow-list let through but the provider
// actually rejects at request time. Failed probes are kept in the list
// but marked probeOk=false so the UI can show a warning badge.
discovered = probeInParallel(discovered, provider, protocol);
// De-dupe against already-configured models for the "new" bucket
Set<String> existingIds = modelConfigService.listModelsByProvider(providerId).stream()
.map(ModelConfigEntity::getModelName)
.collect(Collectors.toSet());
// Only propose models that passed the probe (or were not probed) as "new"
List<ModelInfoDTO> newModels = discovered.stream()
.filter(m -> !existingIds.contains(m.getId()))
.filter(m -> !Boolean.FALSE.equals(m.getProbeOk()))
.toList();
return new DiscoverResult(discovered, newModels, discovered.size(), newModels.size());
}
/**
* Apply protocol-aware allow/deny filtering to the raw discovery list.
* <p>
* Currently only DashScope is filtered: the compatible-mode listing includes
* many models the native SDK rejects. Other providers pass through unchanged.
*/
private List<ModelInfoDTO> applyProtocolFilter(List<ModelInfoDTO> discovered,
ModelProtocol protocol,
String providerId) {
if (protocol != ModelProtocol.DASHSCOPE_NATIVE) {
return discovered;
}
int before = discovered.size();
List<ModelInfoDTO> filtered = discovered.stream()
.filter(m -> isDashScopeModelIdAcceptable(m.getId()))
.toList();
if (filtered.size() < before) {
log.info("[ModelDiscovery] Filtered {} -> {} DashScope models for provider={} (allow/deny rules)",
before, filtered.size(), providerId);
}
return filtered;
}
/**
* Return true if a DashScope model id is allowed on the native chat protocol.
* Rejection rules (in order):
* 1. Explicit DENY set (e.g. qwen3.5-max)
* 2. Dot-version family pattern (qwen3.5-*, qwen3.6-*, ...)
* 3. Non-chat modality prefix (vl, image, tts, asr, omni, audio, s2s, ocr, livetranslate)
* 4. Open-source parameter-size suffix (qwen3-8b, qwen3-32b, qwen3-30b-a3b, qwen3-0.6b ...)
* 5. Must start with a known ALLOW prefix (qwen-, qwen2-, qwen3-, deepseek-, ...)
*/
private static boolean isDashScopeModelIdAcceptable(String modelId) {
if (modelId == null || modelId.isBlank()) return false;
String lower = modelId.toLowerCase();
if (DASHSCOPE_NATIVE_DENY.contains(lower)) return false;
if (DASHSCOPE_NATIVE_UNSUPPORTED_PATTERN.matcher(lower).matches()) return false;
if (DASHSCOPE_NON_CHAT_PREFIXES.stream().anyMatch(lower::startsWith)) return false;
if (DASHSCOPE_OPEN_SOURCE_SIZE_PATTERN.matcher(lower).matches()) return false;
if (DASHSCOPE_NATIVE_ALLOW_PREFIXES.isEmpty()) return true;
return DASHSCOPE_NATIVE_ALLOW_PREFIXES.stream().anyMatch(lower::startsWith);
}
/**
* Defensive guard for code paths that persist a model id without going through
* discovery (e.g. the manual "Add model" form). Throws a MateClawException with
* a user-friendly message if the id is known to be unusable under the provider's
* runtime protocol.
*/
public static void assertModelIdAcceptable(String providerId, ModelProviderEntity provider, String modelId) {
if (provider == null) return;
ModelProtocol protocol = ModelProtocol.fromChatModel(provider.getChatModel());
if (protocol == ModelProtocol.DASHSCOPE_NATIVE && !isDashScopeModelIdAcceptable(modelId)) {
throw new MateClawException(
"err.llm.model_not_supported",
"Model id '" + modelId + "' is not supported on DashScope native protocol. " +
"Dot-versioned families (e.g. qwen3.5-*, qwen3.6-*) are only available via compatible-mode. " +
"Use an allowed id such as qwen-max / qwen-plus / qwen3-max."
);
}
}
/**
* Probe each discovered model in parallel (bounded concurrency) using the same
* protocol the runtime will use. Populates {@code probeOk}/{@code probeError}
* on each DTO; does not remove failed entries so the UI can surface the reason.
*/
private List<ModelInfoDTO> probeInParallel(List<ModelInfoDTO> discovered,
ModelProviderEntity provider,
ModelProtocol protocol) {
if (discovered.isEmpty()) return discovered;
// OpenAI ChatGPT has no model-level test, skip probe for it
if (protocol == ModelProtocol.OPENAI_CHATGPT) return discovered;
Semaphore sem = new Semaphore(MAX_PROBE_CONCURRENCY);
List<CompletableFuture<Void>> futures = new ArrayList<>(discovered.size());
for (ModelInfoDTO dto : discovered) {
futures.add(CompletableFuture.runAsync(() -> {
try { sem.acquire(); }
catch (InterruptedException ie) { Thread.currentThread().interrupt(); return; }
try {
sendTestPrompt(provider, protocol, dto.getId());
dto.setProbeOk(true);
} catch (Exception e) {
dto.setProbeOk(false);
dto.setProbeError(shortError(e));
// DEBUG level — per-model probe failures are an expected part of bulk
// discovery (DashScope lists many deprecated/restricted models). The
// aggregate "Probe results: X passed, Y failed" summary below is
// sufficient for normal operations. Enable DEBUG for ModelDiscovery
// if you need to inspect individual reasons.
log.debug("[ModelDiscovery] Probe failed for model={}: {}", dto.getId(), dto.getProbeError());
} finally {
sem.release();
}
}, PROBE_EXECUTOR));
}
try {
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.get(PROBE_TIMEOUT_SECONDS * Math.max(1, discovered.size() / MAX_PROBE_CONCURRENCY + 1),
TimeUnit.SECONDS);
} catch (TimeoutException te) {
log.warn("[ModelDiscovery] Probe batch timeout; {} models may be marked unknown",
futures.stream().filter(f -> !f.isDone()).count());
} catch (Exception e) {
log.warn("[ModelDiscovery] Probe batch wait failed: {}", e.getMessage());
}
long passed = discovered.stream().filter(m -> Boolean.TRUE.equals(m.getProbeOk())).count();
long failed = discovered.stream().filter(m -> Boolean.FALSE.equals(m.getProbeOk())).count();
log.info("[ModelDiscovery] Probe results: {} passed, {} failed, {} unknown (of {})",
passed, failed, discovered.size() - passed - failed, discovered.size());
return discovered;
}
private String shortError(Exception e) {
String msg = extractErrorMessage(e);
if (msg == null) return "unknown error";
// Clip to ~120 chars so the UI tooltip stays usable
return msg.length() > 120 ? msg.substring(0, 120) + "..." : msg;
}
// ==================== 连接测试 ====================
public TestResult testConnection(String providerId) {
ModelProviderEntity provider = modelProviderService.getProviderConfig(providerId);
ModelProtocol protocol = ModelProtocol.fromChatModel(provider.getChatModel());
long start = System.currentTimeMillis();
try {
if (Boolean.TRUE.equals(provider.getSupportModelDiscovery())) {
// 支持模型发现的 provider调用模型列表 API 验证连接
fetchRemoteModels(provider, protocol);
long latency = System.currentTimeMillis() - start;
return TestResult.ok(latency, "连接成功");
} else {
// 不支持模型发现(如智谱):用第一个已配置模型发送测试请求
List<ModelConfigEntity> models = modelConfigService.listModelsByProvider(providerId);
if (models.isEmpty()) {
throw new MateClawException("err.llm.no_model_for_test", "该供应商没有已配置的模型,无法测试连接");
}
String testModelId = models.get(0).getModelName();
String response = sendTestPrompt(provider, protocol, testModelId);
long latency = System.currentTimeMillis() - start;
return TestResult.ok(latency, response);
}
} catch (Exception e) {
long latency = System.currentTimeMillis() - start;
return TestResult.fail(latency, extractErrorMessage(e));
}
}
// ==================== 单模型测试 ====================
public TestResult testModel(String providerId, String modelId) {
ModelProviderEntity provider = modelProviderService.getProviderConfig(providerId);
ModelProtocol protocol = ModelProtocol.fromChatModel(provider.getChatModel());
long start = System.currentTimeMillis();
try {
String response = sendTestPrompt(provider, protocol, modelId);
long latency = System.currentTimeMillis() - start;
return TestResult.ok(latency, response);
} catch (Exception e) {
long latency = System.currentTimeMillis() - start;
return TestResult.fail(latency, extractErrorMessage(e));
}
}
// ==================== 批量添加发现的模型 ====================
public int batchAddModels(String providerId, List<String> modelIds) {
ModelProviderEntity provider = modelProviderService.getProviderConfig(providerId);
ModelProtocol protocol = ModelProtocol.fromChatModel(provider.getChatModel());
Set<String> existingIds = modelConfigService.listModelsByProvider(providerId).stream()
.map(ModelConfigEntity::getModelName)
.collect(Collectors.toSet());
int added = 0;
int skipped = 0;
for (String modelId : modelIds) {
if (modelId == null || modelId.isBlank()) continue;
if (existingIds.contains(modelId)) continue;
// Defense-in-depth: never add a DashScope model that fails the protocol-aware check
if (protocol == ModelProtocol.DASHSCOPE_NATIVE && !isDashScopeModelIdAcceptable(modelId)) {
log.warn("[ModelDiscovery] Refusing to add {} — blocked by DashScope native protocol filter", modelId);
skipped++;
continue;
}
modelConfigService.addModelToProvider(providerId, modelId, modelId, false);
added++;
}
if (skipped > 0) {
log.info("[ModelDiscovery] batchAddModels: added={}, skipped(deny)={}", added, skipped);
}
return added;
}
// ==================== 协议分派:模型列表 ====================
private List<ModelInfoDTO> fetchRemoteModels(ModelProviderEntity provider, ModelProtocol protocol) {
return switch (protocol) {
case OPENAI_COMPATIBLE -> fetchOpenAiCompatibleModels(provider);
case DASHSCOPE_NATIVE -> fetchDashScopeModels(provider);
case GEMINI_NATIVE -> fetchGeminiModels(provider);
case ANTHROPIC_MESSAGES -> fetchAnthropicModels(provider);
case OPENAI_CHATGPT -> throw new MateClawException("err.llm.chatgpt_no_discovery", "ChatGPT OAuth provider 不支持模型发现");
};
}
private List<ModelInfoDTO> fetchOpenAiCompatibleModels(ModelProviderEntity provider) {
String baseUrl = normalizeBaseUrl(provider.getBaseUrl());
if (!StringUtils.hasText(baseUrl)) {
throw new MateClawException("err.llm.base_url_missing", "Base URL 未配置");
}
String apiKey = provider.getApiKey();
RestClient client = RestClient.builder()
.baseUrl(baseUrl)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.build();
RestClient.RequestHeadersSpec<?> spec = client.get().uri("/v1/models");
if (modelProviderService.hasUsableApiKey(apiKey)) {
spec = spec.header(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey.trim());
}
// 添加自定义 headers从 generateKwargs 中读取)
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
applyCustomHeaders(spec, kwargs);
String body = spec.retrieve().body(String.class);
return parseOpenAiModelsResponse(body);
}
private List<ModelInfoDTO> fetchDashScopeModels(ModelProviderEntity provider) {
String apiKey = provider.getApiKey();
if (!modelProviderService.hasUsableApiKey(apiKey)) {
throw new MateClawException("err.llm.dashscope_key_missing", "DashScope API Key 未配置");
}
// DashScope 兼容模式暴露了 OpenAI 兼容的 /v1/models 端点
RestClient client = RestClient.builder()
.baseUrl("https://dashscope.aliyuncs.com/compatible-mode")
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey.trim())
.build();
String body = client.get().uri("/v1/models").retrieve().body(String.class);
return parseOpenAiModelsResponse(body);
}
private List<ModelInfoDTO> fetchGeminiModels(ModelProviderEntity provider) {
String apiKey = provider.getApiKey();
if (!modelProviderService.hasUsableApiKey(apiKey)) {
throw new MateClawException("err.llm.gemini_key_missing", "Gemini API Key 未配置");
}
RestClient client = RestClient.builder()
.baseUrl("https://generativelanguage.googleapis.com")
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.build();
String body = client.get()
.uri("/v1beta/models?key={key}", apiKey.trim())
.retrieve()
.body(String.class);
return parseGeminiModelsResponse(body);
}
private List<ModelInfoDTO> fetchAnthropicModels(ModelProviderEntity provider) {
String apiKey = provider.getApiKey();
if (!modelProviderService.hasUsableApiKey(apiKey)) {
throw new MateClawException("err.llm.anthropic_key_missing", "Anthropic API Key 未配置");
}
String baseUrl = StringUtils.hasText(provider.getBaseUrl())
? normalizeBaseUrl(provider.getBaseUrl())
: "https://api.anthropic.com";
RestClient client = RestClient.builder()
.baseUrl(baseUrl)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader("x-api-key", apiKey.trim())
.defaultHeader("anthropic-version", "2023-06-01")
.build();
String body = client.get().uri("/v1/models").retrieve().body(String.class);
return parseAnthropicModelsResponse(body);
}
// ==================== 协议分派:单模型测试 ====================
private String sendTestPrompt(ModelProviderEntity provider, ModelProtocol protocol, String modelId) {
return switch (protocol) {
case OPENAI_COMPATIBLE -> sendOpenAiTestPrompt(provider, modelId);
case DASHSCOPE_NATIVE -> sendDashScopeTestPrompt(provider, modelId);
case GEMINI_NATIVE -> sendGeminiTestPrompt(provider, modelId);
case ANTHROPIC_MESSAGES -> sendAnthropicTestPrompt(provider, modelId);
case OPENAI_CHATGPT -> throw new MateClawException("err.llm.chatgpt_no_test", "ChatGPT OAuth provider 不支持模型测试");
};
}
private String sendOpenAiTestPrompt(ModelProviderEntity provider, String modelId) {
String baseUrl = normalizeBaseUrl(provider.getBaseUrl());
if (!StringUtils.hasText(baseUrl)) {
throw new MateClawException("err.llm.base_url_missing", "Base URL 未配置");
}
Map<String, Object> requestBody = Map.of(
"model", modelId,
"messages", List.of(Map.of("role", "user", "content", "请回复:连接正常")),
"max_tokens", 10,
"temperature", 0
);
// 从 generateKwargs 读取 completionsPath智谱等用 /chat/completions 而非 /v1/chat/completions
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
String completionsPath = resolveCompletionsPath(baseUrl, kwargs);
RestClient.RequestHeadersSpec<?> spec = RestClient.builder()
.baseUrl(baseUrl)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build()
.post()
.uri(completionsPath)
.body(requestBody);
if (modelProviderService.hasUsableApiKey(provider.getApiKey())) {
spec = spec.header(HttpHeaders.AUTHORIZATION, "Bearer " + provider.getApiKey().trim());
}
applyCustomHeaders(spec, kwargs);
String body = spec.retrieve().body(String.class);
return extractOpenAiChatContent(body);
}
/**
* Test a DashScope model using the **native** endpoint
* ({@code /api/v1/services/aigc/text-generation/generation}).
* <p>
* This matches the protocol Spring AI Alibaba's {@code DashScopeChatModel} uses
* at runtime. Using compatible-mode for testing (as the previous implementation
* did) was the root cause of "test passed but chat fails" — compatible-mode
* accepts a broader set of model names than the native API does.
*/
private String sendDashScopeTestPrompt(ModelProviderEntity provider, String modelId) {
String apiKey = provider.getApiKey();
if (!modelProviderService.hasUsableApiKey(apiKey)) {
throw new MateClawException("err.llm.dashscope_key_missing", "DashScope API Key 未配置");
}
// DashScope native request shape: input.messages + parameters
Map<String, Object> requestBody = Map.of(
"model", modelId,
"input", Map.of(
"messages", List.of(Map.of("role", "user", "content", "ping"))
),
"parameters", Map.of(
"max_tokens", 1,
"temperature", 0,
"result_format", "message"
)
);
String body = RestClient.builder()
.baseUrl("https://dashscope.aliyuncs.com")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey.trim())
.build()
.post()
.uri("/api/v1/services/aigc/text-generation/generation")
.body(requestBody)
.retrieve()
.body(String.class);
return extractDashScopeNativeContent(body);
}
/**
* Extract content from DashScope native response:
* {@code { "output": { "choices": [ { "message": { "content": "..." } } ] } } }
* Falls back to the raw body preview if the shape differs.
*/
private String extractDashScopeNativeContent(String body) {
try {
JsonNode root = objectMapper.readTree(body);
JsonNode choices = root.path("output").path("choices");
if (choices.isArray() && choices.size() > 0) {
String content = choices.get(0).path("message").path("content").asText("");
if (!content.isBlank()) return content;
}
// Older shape: output.text
String legacyText = root.path("output").path("text").asText("");
if (!legacyText.isBlank()) return legacyText;
} catch (Exception ignored) {}
return body == null ? "" : (body.length() > 200 ? body.substring(0, 200) : body);
}
private String sendGeminiTestPrompt(ModelProviderEntity provider, String modelId) {
String apiKey = provider.getApiKey();
if (!modelProviderService.hasUsableApiKey(apiKey)) {
throw new MateClawException("err.llm.gemini_key_missing", "Gemini API Key 未配置");
}
Map<String, Object> requestBody = Map.of(
"contents", List.of(Map.of(
"parts", List.of(Map.of("text", "请回复:连接正常"))
)),
"generationConfig", Map.of("maxOutputTokens", 10, "temperature", 0)
);
String body = RestClient.builder()
.baseUrl("https://generativelanguage.googleapis.com")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build()
.post()
.uri("/v1beta/models/{model}:generateContent?key={key}", modelId, apiKey.trim())
.body(requestBody)
.retrieve()
.body(String.class);
return extractGeminiContent(body);
}
private String sendAnthropicTestPrompt(ModelProviderEntity provider, String modelId) {
String apiKey = provider.getApiKey();
if (!modelProviderService.hasUsableApiKey(apiKey)) {
throw new MateClawException("err.llm.anthropic_key_missing", "Anthropic API Key 未配置");
}
String baseUrl = StringUtils.hasText(provider.getBaseUrl())
? normalizeBaseUrl(provider.getBaseUrl())
: "https://api.anthropic.com";
Map<String, Object> requestBody = Map.of(
"model", modelId,
"messages", List.of(Map.of("role", "user", "content", "请回复:连接正常")),
"max_tokens", 10
);
String body = RestClient.builder()
.baseUrl(baseUrl)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader("x-api-key", apiKey.trim())
.defaultHeader("anthropic-version", "2023-06-01")
.build()
.post()
.uri("/v1/messages")
.body(requestBody)
.retrieve()
.body(String.class);
return extractAnthropicContent(body);
}
// ==================== JSON 解析 ====================
private List<ModelInfoDTO> parseOpenAiModelsResponse(String body) {
try {
JsonNode root = objectMapper.readTree(body);
JsonNode data = root.path("data");
if (!data.isArray()) {
return Collections.emptyList();
}
List<ModelInfoDTO> models = new ArrayList<>();
for (JsonNode node : data) {
String id = node.path("id").asText("");
if (StringUtils.hasText(id)) {
models.add(new ModelInfoDTO(id, id));
}
}
return models;
} catch (Exception e) {
log.warn("解析 OpenAI 模型列表失败: {}", e.getMessage());
return Collections.emptyList();
}
}
private List<ModelInfoDTO> parseGeminiModelsResponse(String body) {
try {
JsonNode root = objectMapper.readTree(body);
JsonNode models = root.path("models");
if (!models.isArray()) {
return Collections.emptyList();
}
List<ModelInfoDTO> result = new ArrayList<>();
for (JsonNode node : models) {
String name = node.path("name").asText("");
String displayName = node.path("displayName").asText(name);
// Gemini 返回 "models/gemini-1.5-pro" 格式,去掉 "models/" 前缀
if (name.startsWith("models/")) {
name = name.substring(7);
}
if (StringUtils.hasText(name)) {
result.add(new ModelInfoDTO(name, displayName));
}
}
return result;
} catch (Exception e) {
log.warn("解析 Gemini 模型列表失败: {}", e.getMessage());
return Collections.emptyList();
}
}
private List<ModelInfoDTO> parseAnthropicModelsResponse(String body) {
try {
JsonNode root = objectMapper.readTree(body);
JsonNode data = root.path("data");
if (!data.isArray()) {
return Collections.emptyList();
}
List<ModelInfoDTO> models = new ArrayList<>();
for (JsonNode node : data) {
String id = node.path("id").asText("");
String displayName = node.path("display_name").asText(id);
if (StringUtils.hasText(id)) {
models.add(new ModelInfoDTO(id, displayName));
}
}
return models;
} catch (Exception e) {
log.warn("解析 Anthropic 模型列表失败: {}", e.getMessage());
return Collections.emptyList();
}
}
private String extractOpenAiChatContent(String body) {
try {
JsonNode root = objectMapper.readTree(body);
return root.path("choices").path(0).path("message").path("content").asText("连接正常");
} catch (Exception e) {
return "连接正常(响应解析异常)";
}
}
private String extractGeminiContent(String body) {
try {
JsonNode root = objectMapper.readTree(body);
return root.path("candidates").path(0).path("content").path("parts").path(0).path("text").asText("连接正常");
} catch (Exception e) {
return "连接正常(响应解析异常)";
}
}
private String extractAnthropicContent(String body) {
try {
JsonNode root = objectMapper.readTree(body);
return root.path("content").path(0).path("text").asText("连接正常");
} catch (Exception e) {
return "连接正常(响应解析异常)";
}
}
// ==================== 工具方法 ====================
/**
* 从 generateKwargs 中解析 completionsPath处理 baseUrl 与路径前缀的重叠。
* 例如baseUrl 以 /v4 结尾completionsPath 为 /chat/completions → 最终 /chat/completions
* baseUrl 以 /v1 结尾completionsPath 为 /v1/chat/completions → 最终 /chat/completions
*/
private String resolveCompletionsPath(String baseUrl, Map<String, Object> kwargs) {
String path = "/v1/chat/completions";
if (kwargs != null) {
Object raw = kwargs.get("completionsPath");
if (raw instanceof String value && StringUtils.hasText(value)) {
path = value.trim();
if (!path.startsWith("/")) {
path = "/" + path;
}
}
}
// 避免路径重叠:如果 baseUrl 以 /v1 结尾且 path 以 /v1/ 开头,去掉重复
if (baseUrl != null && baseUrl.endsWith("/v1") && path.startsWith("/v1/")) {
path = path.substring(3);
}
return path;
}
private String normalizeBaseUrl(String baseUrl) {
if (!StringUtils.hasText(baseUrl)) {
return null;
}
String normalized = baseUrl.trim();
if (normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1);
}
if (normalized.endsWith("/v1")) {
normalized = normalized.substring(0, normalized.length() - 3);
}
return normalized;
}
@SuppressWarnings("unchecked")
private void applyCustomHeaders(RestClient.RequestHeadersSpec<?> spec, Map<String, Object> kwargs) {
if (kwargs == null) {
return;
}
Object customHeaders = kwargs.get("customHeaders");
if (customHeaders instanceof Map) {
((Map<String, Object>) customHeaders).forEach((key, value) -> {
if (value != null) {
spec.header(key, value.toString());
}
});
}
}
private String extractErrorMessage(Exception e) {
String msg = e.getMessage();
if (msg == null || msg.isBlank()) {
return "未知错误: " + e.getClass().getSimpleName();
}
// 截取合理长度
if (msg.length() > 200) {
msg = msg.substring(0, 200) + "...";
}
return msg;
}
}