mateclaw/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java
matevip 7b12c5f0c9 feat(llm): provider health tracker + UI editor for failover priority
UI — Failover priority editor
- ProviderConfigRequest + ProviderInfoDTO carry fallbackPriority
- ModelProviderService.updateProviderConfig persists it (null = unchanged);
  toProviderInfo exposes the current value to the UI (defaults to 0)
- ProviderConfigModal advanced panel exposes a number input with hint
- ProviderCard shows a "Fallback #N" badge for chain members so the
  priority order is visible at a glance without opening the modal
- 5 new i18n keys (zh + en) — verified to resolve at runtime via i18n.global.t

Backend — Per-provider health tracker
- ProviderHealthTracker: ConcurrentHashMap-backed counters; N consecutive
  failures (default 3) push the provider into a cooldown window (default
  5 min) during which the chain walker skips it. Success resets both
  counter and cooldown atomically. Lazy expiry on lookup so dead entries
  do not accumulate.
- ProviderHealthProperties exposed under mateclaw.llm.failover.health.*
  with sane production defaults
- New FallbackEntry record (providerId + ChatModel) replaces raw
  List<ChatModel> in the chain so the walker can correlate cooldown
  state to entries; AgentGraphBuilder.buildFallbackChain returns the
  new type
- NodeStreamingChatHelper takes the tracker through a new 4-arg
  constructor and consults it before each fallback call; records
  success/failure on each chain attempt. Legacy 2/3-arg constructors
  preserved as @Deprecated wrappers (synthetic providerId means no
  health tracking on the legacy path — that path is opt-out anyway)

Tests
- ProviderHealthTrackerTest (9 tests): below/at threshold, success
  reset, cooldown expiry (via reflection on the min-clamp setter),
  disabled-tracker no-op, null-providerId safety, per-provider
  isolation, snapshot output
- NodeStreamingChatHelperFallbackChainTest updated to FallbackEntry
  field type — verifies providerId + ChatModel survive the chain
- 168 tests pass (was 159 + 9 new)

Verification
- mvn test green; vue-tsc clean; live UI confirms i18n resolution
2026-04-19 16:57:03 +08:00

337 lines
14 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.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import vip.mate.exception.MateClawException;
import vip.mate.llm.event.ModelConfigChangedEvent;
import vip.mate.llm.model.*;
import vip.mate.llm.repository.ModelProviderMapper;
import org.springframework.ai.chat.model.ChatModel;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class ModelProviderService {
private final ModelProviderMapper modelProviderMapper;
private final ModelConfigService modelConfigService;
private final ApplicationEventPublisher eventPublisher;
private final ObjectMapper objectMapper = new ObjectMapper();
/** Plugin-registered ChatModel instances: providerId -> ChatModel */
private final Map<String, ChatModel> pluginChatModels = new ConcurrentHashMap<>();
/**
* Register a ChatModel from a plugin.
*/
public void registerPluginChatModel(String providerId, ChatModel chatModel) {
pluginChatModels.put(providerId, chatModel);
}
/**
* Unregister a plugin ChatModel.
*/
public void unregisterPluginChatModel(String providerId) {
pluginChatModels.remove(providerId);
}
/**
* Get a plugin-registered ChatModel.
*
* @return the ChatModel, or null if not registered by a plugin
*/
public ChatModel getPluginChatModel(String providerId) {
return pluginChatModels.get(providerId);
}
public List<ProviderInfoDTO> listProviders() {
List<ModelProviderEntity> providers = modelProviderMapper.selectList(new LambdaQueryWrapper<ModelProviderEntity>()
.orderByDesc(ModelProviderEntity::getIsLocal)
.orderByAsc(ModelProviderEntity::getIsCustom)
.orderByAsc(ModelProviderEntity::getName));
Map<String, List<ModelConfigEntity>> modelsByProvider = modelConfigService.listModels().stream()
.collect(Collectors.groupingBy(ModelConfigEntity::getProvider));
return providers.stream().map(provider -> toProviderInfo(provider, modelsByProvider.get(provider.getProviderId()))).toList();
}
public ProviderInfoDTO updateProviderConfig(String providerId, ProviderConfigRequest request) {
ModelProviderEntity provider = getProvider(providerId);
if (StringUtils.hasText(request.getApiKey())) {
provider.setApiKey(request.getApiKey().trim());
}
provider.setBaseUrl(request.getBaseUrl());
provider.setChatModel(ModelProtocol.resolveChatModel(request.getProtocol(), request.getChatModel()));
provider.setGenerateKwargs(writeJson(request.getGenerateKwargs()));
// only update fallback priority when the caller explicitly
// sends a value. null leaves it untouched (existing chain unchanged).
if (request.getFallbackPriority() != null) {
int p = Math.max(0, request.getFallbackPriority());
provider.setFallbackPriority(p);
}
modelProviderMapper.updateById(provider);
tryAutoActivateModel(providerId, provider);
eventPublisher.publishEvent(new ModelConfigChangedEvent("provider-config-updated"));
return toProviderInfo(provider, modelConfigService.listModelsByProvider(providerId));
}
public ProviderInfoDTO createCustomProvider(CreateCustomProviderRequest request) {
if (!StringUtils.hasText(request.getId()) || !StringUtils.hasText(request.getName())) {
throw new MateClawException("err.llm.provider_fields_required", "Provider id 和名称不能为空");
}
if (modelProviderMapper.selectById(request.getId()) != null) {
throw new MateClawException("err.llm.provider_exists", "Provider 已存在: " + request.getId());
}
ModelProviderEntity provider = new ModelProviderEntity();
provider.setProviderId(request.getId());
provider.setName(request.getName());
provider.setApiKeyPrefix(request.getApiKeyPrefix());
provider.setChatModel(ModelProtocol.resolveChatModel(request.getProtocol(), request.getChatModel()));
provider.setBaseUrl(request.getDefaultBaseUrl());
provider.setGenerateKwargs("{}");
provider.setIsCustom(true);
provider.setIsLocal(false);
provider.setSupportModelDiscovery(false);
provider.setSupportConnectionCheck(false);
provider.setFreezeUrl(false);
provider.setRequireApiKey(true);
modelProviderMapper.insert(provider);
if (request.getModels() != null) {
for (ModelInfoDTO model : request.getModels()) {
modelConfigService.addModelToProvider(request.getId(), model.getId(), model.getName(), false);
}
}
eventPublisher.publishEvent(new ModelConfigChangedEvent("provider-created"));
return toProviderInfo(provider, modelConfigService.listModelsByProvider(request.getId()));
}
public void deleteCustomProvider(String providerId) {
ModelProviderEntity provider = getProvider(providerId);
if (!Boolean.TRUE.equals(provider.getIsCustom())) {
throw new MateClawException("err.llm.provider_builtin_readonly", "内置 Provider 不支持删除");
}
modelConfigService.deleteModelsByProvider(providerId);
modelProviderMapper.deleteById(providerId);
eventPublisher.publishEvent(new ModelConfigChangedEvent("provider-deleted"));
}
public ProviderInfoDTO addModel(String providerId, AddProviderModelRequest request) {
getProvider(providerId);
// Defense-in-depth: the manual "Add model" form must apply the same
// protocol-level safety as auto-discovery — otherwise users can freely
// type an unknown model id (e.g. "qwen3.6-plus") that DashScope native
// rejects at runtime with the opaque "[InvalidParameter] url error".
String modelId = request.getId();
if (modelId != null && !modelId.isBlank()) {
ModelDiscoveryService.assertModelIdAcceptable(providerId, this.getProvider(providerId), modelId);
}
modelConfigService.addModelToProvider(providerId, modelId, request.getName(), false);
return toProviderInfo(getProvider(providerId), modelConfigService.listModelsByProvider(providerId));
}
public ProviderInfoDTO removeModel(String providerId, String modelId) {
getProvider(providerId);
modelConfigService.removeModelFromProvider(providerId, modelId);
return toProviderInfo(getProvider(providerId), modelConfigService.listModelsByProvider(providerId));
}
public ModelProviderEntity getProviderConfig(String providerId) {
return getProvider(providerId);
}
/**
* ordered list of providers that participate in the multi-model
* failover chain. Filters by {@code fallback_priority > 0} and sorts
* ascending, so priority 1 is tried first after the primary model
* exhausts retries. An empty list disables fallover entirely.
*/
public List<ModelProviderEntity> listFallbackChain() {
com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<ModelProviderEntity> qw =
new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<>();
qw.gt("fallback_priority", 0);
qw.orderByAsc("fallback_priority");
return modelProviderMapper.selectList(qw);
}
public boolean isProviderConfigured(String providerId) {
return isProviderConfigured(getProvider(providerId));
}
public boolean isProviderAvailable(String providerId) {
ModelProviderEntity provider = getProvider(providerId);
return isProviderConfigured(provider) && hasModels(providerId);
}
public String getProviderUnavailableReason(String providerId) {
ModelProviderEntity provider = getProvider(providerId);
if (!isProviderConfigured(provider)) {
if (Boolean.TRUE.equals(provider.getRequireApiKey())) {
return "Provider 未配置有效的 API Key";
}
if (Boolean.TRUE.equals(provider.getIsCustom()) || !Boolean.TRUE.equals(provider.getIsLocal())) {
return "Provider 未配置 Base URL";
}
return "Provider 未完成配置";
}
if (!hasModels(providerId)) {
return "Provider 下没有可用模型";
}
return null;
}
private void tryAutoActivateModel(String providerId, ModelProviderEntity provider) {
if (!isProviderConfigured(provider)) {
return;
}
List<ModelConfigEntity> providerModels = modelConfigService.listModelsByProvider(providerId);
if (providerModels.isEmpty()) {
return;
}
boolean shouldAutoActivate = false;
try {
ModelConfigEntity currentDefault = modelConfigService.getDefaultModel();
ModelProviderEntity defaultProvider = modelProviderMapper.selectById(currentDefault.getProvider());
if (!isProviderConfigured(defaultProvider)) {
shouldAutoActivate = true;
}
} catch (MateClawException e) {
shouldAutoActivate = true;
}
if (shouldAutoActivate) {
ModelConfigEntity firstModel = providerModels.get(0);
modelConfigService.setDefaultModel(providerId, firstModel.getModelName());
}
}
private ModelProviderEntity getProvider(String providerId) {
ModelProviderEntity provider = modelProviderMapper.selectById(providerId);
if (provider == null) {
throw new MateClawException("err.llm.provider_not_found", "Provider 不存在: " + providerId);
}
return provider;
}
private ProviderInfoDTO toProviderInfo(ModelProviderEntity provider, List<ModelConfigEntity> models) {
ProviderInfoDTO dto = new ProviderInfoDTO();
dto.setId(provider.getProviderId());
dto.setName(provider.getName());
dto.setProtocol(ModelProtocol.fromChatModel(provider.getChatModel()).getId());
dto.setApiKeyPrefix(provider.getApiKeyPrefix());
dto.setChatModel(provider.getChatModel());
dto.setIsCustom(Boolean.TRUE.equals(provider.getIsCustom()));
dto.setIsLocal(Boolean.TRUE.equals(provider.getIsLocal()));
dto.setSupportModelDiscovery(Boolean.TRUE.equals(provider.getSupportModelDiscovery()));
dto.setSupportConnectionCheck(Boolean.TRUE.equals(provider.getSupportConnectionCheck()));
dto.setFreezeUrl(Boolean.TRUE.equals(provider.getFreezeUrl()));
dto.setRequireApiKey(Boolean.TRUE.equals(provider.getRequireApiKey()));
boolean configured = isProviderConfigured(provider);
boolean available = configured && models != null && !models.isEmpty();
dto.setConfigured(configured);
dto.setAvailable(available);
dto.setApiKey(maskApiKey(provider.getApiKey()));
dto.setBaseUrl(provider.getBaseUrl());
dto.setGenerateKwargs(readJson(provider.getGenerateKwargs()));
dto.setAuthType(provider.getAuthType() != null ? provider.getAuthType() : "api_key");
dto.setOauthConnected(StringUtils.hasText(provider.getOauthAccessToken()));
dto.setOauthExpiresAt(provider.getOauthExpiresAt());
dto.setFallbackPriority(provider.getFallbackPriority() != null ? provider.getFallbackPriority() : 0);
List<ModelInfoDTO> builtinModels = new ArrayList<>();
List<ModelInfoDTO> extraModels = new ArrayList<>();
if (models != null) {
for (ModelConfigEntity model : models) {
ModelInfoDTO info = new ModelInfoDTO(model.getModelName(), model.getName());
if (Boolean.TRUE.equals(model.getBuiltin())) {
builtinModels.add(info);
} else {
extraModels.add(info);
}
}
}
dto.setModels(builtinModels);
dto.setExtraModels(extraModels);
return dto;
}
private boolean hasModels(String providerId) {
return !modelConfigService.listModelsByProvider(providerId).isEmpty();
}
private boolean isProviderConfigured(ModelProviderEntity provider) {
if (provider == null) {
return false;
}
if (Boolean.TRUE.equals(provider.getIsLocal())) {
return true;
}
// OAuth 认证的 provider检查 OAuth token 是否存在
if ("oauth".equals(provider.getAuthType())) {
return StringUtils.hasText(provider.getOauthAccessToken());
}
boolean hasBaseUrl = StringUtils.hasText(provider.getBaseUrl());
boolean hasApiKey = hasUsableApiKey(provider.getApiKey());
if (Boolean.TRUE.equals(provider.getIsCustom())) {
return hasBaseUrl && (!Boolean.TRUE.equals(provider.getRequireApiKey()) || hasApiKey);
}
if (Boolean.FALSE.equals(provider.getRequireApiKey())) {
return hasBaseUrl;
}
return hasApiKey;
}
public boolean hasUsableApiKey(String apiKey) {
if (!StringUtils.hasText(apiKey)) {
return false;
}
String normalized = apiKey.trim();
return !normalized.contains("*")
&& !"your-dashscope-api-key-here".equalsIgnoreCase(normalized)
&& !"your-api-key-here".equalsIgnoreCase(normalized);
}
public Map<String, Object> readProviderGenerateKwargs(ModelProviderEntity provider) {
return readJson(provider != null ? provider.getGenerateKwargs() : null);
}
private String maskApiKey(String apiKey) {
if (!StringUtils.hasText(apiKey)) {
return "";
}
if (apiKey.length() <= 8) {
return "********";
}
return apiKey.substring(0, 4) + "********" + apiKey.substring(apiKey.length() - 4);
}
private Map<String, Object> readJson(String value) {
if (!StringUtils.hasText(value)) {
return new LinkedHashMap<>();
}
try {
return objectMapper.readValue(value, new TypeReference<>() {});
} catch (Exception e) {
return new LinkedHashMap<>();
}
}
private String writeJson(Map<String, Object> value) {
try {
return objectMapper.writeValueAsString(value == null ? Collections.emptyMap() : value);
} catch (Exception e) {
return "{}";
}
}
}