listCatalog() {
return listProvidersInternal(false);
}
/**
* Enabled providers that are actually usable, reduced to id + display name.
*
* Feeds the agent's preferred-provider picker, which workspace members may
* edit. They cannot read the full provider list (it carries connection
* settings), so this projection is what makes the choices visible without
* widening that exposure.
*/
public List listProviderOptions() {
return listProviders().stream()
.filter(p -> Boolean.TRUE.equals(p.getConfigured()))
.map(p -> new ProviderOptionDTO(p.getId(), p.getName()))
.toList();
}
private List listProvidersInternal(boolean enabledOnly) {
LambdaQueryWrapper qw = new LambdaQueryWrapper<>();
if (enabledOnly) {
qw.eq(ModelProviderEntity::getEnabled, true);
}
qw.orderByDesc(ModelProviderEntity::getIsLocal)
.orderByAsc(ModelProviderEntity::getIsCustom)
.orderByAsc(ModelProviderEntity::getName);
List providers = modelProviderMapper.selectList(qw);
Map> modelsByProvider = modelConfigService.listModels().stream()
.collect(Collectors.groupingBy(ModelConfigEntity::getProvider));
// RFC-073: batch the runtime snapshots once so each toProviderInfo call is O(1)
// instead of N pool/tracker round-trips per render.
LivenessContext liveness = livenessContext();
return providers.stream()
.map(provider -> toProviderInfo(provider, modelsByProvider.get(provider.getProviderId()), liveness))
.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()));
if (request.getRequireApiKey() != null) {
provider.setRequireApiKey(request.getRequireApiKey());
}
// RFC-009 P3.5: 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 和名称不能为空");
}
// Issue #39: even though the UI now validates, defend the API directly —
// see PROVIDER_ID_PATTERN above for why this matters.
if (!PROVIDER_ID_PATTERN.matcher(request.getId()).matches()) {
throw new MateClawException("err.llm.provider_id_invalid",
"Provider id 仅允许字母/数字及 . _ -(不允许斜杠或空格),首字符必须是字母或数字,长度 1-64: " + request.getId());
}
ModelProviderEntity existing = modelProviderMapper.selectById(request.getId());
if (existing != null && canRestoreCustomProvider(existing)) {
return restoreCustomProvider(existing, request);
}
if (existing != null) {
throw new MateClawException("err.llm.provider_exists", "Provider 已存在: " + request.getId());
}
ModelProtocol protocol = ModelProtocol.resolve(request.getProtocol(), request.getChatModel());
ModelProviderEntity provider = new ModelProviderEntity();
provider.setProviderId(request.getId());
provider.setName(request.getName());
provider.setApiKeyPrefix(request.getApiKeyPrefix());
provider.setChatModel(protocol.getChatModelClass());
provider.setBaseUrl(request.getDefaultBaseUrl());
provider.setGenerateKwargs("{}");
provider.setIsCustom(true);
provider.setIsLocal(false);
// RFC-074: custom providers are user-created, so opt them in by default
// — the user just made the row, no need to make them flip a second toggle.
provider.setEnabled(true);
// Default model discovery on for protocols whose discovery works from a
// self-configured baseUrl+apiKey (OpenAI-compatible, DashScope, Gemini,
// Anthropic). Previously hard-coded false, which left self-hosted
// OpenAI-compatible endpoints (vLLM/Xinference/LocalAI/…) unable to
// surface the "discover models" button at all. OAuth protocols stay off.
provider.setSupportModelDiscovery(protocol.supportsSelfConfiguredDiscovery());
provider.setSupportConnectionCheck(false);
provider.setFreezeUrl(false);
provider.setRequireApiKey(request.getRequireApiKey() == null || Boolean.TRUE.equals(request.getRequireApiKey()));
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()));
}
private boolean canRestoreCustomProvider(ModelProviderEntity provider) {
return Boolean.TRUE.equals(provider.getIsCustom()) && !Boolean.TRUE.equals(provider.getEnabled());
}
private ProviderInfoDTO restoreCustomProvider(ModelProviderEntity provider, CreateCustomProviderRequest request) {
ModelProtocol protocol = ModelProtocol.resolve(request.getProtocol(), request.getChatModel());
provider.setName(request.getName());
provider.setApiKeyPrefix(request.getApiKeyPrefix());
provider.setChatModel(protocol.getChatModelClass());
provider.setBaseUrl(request.getDefaultBaseUrl());
provider.setIsCustom(true);
provider.setIsLocal(false);
provider.setEnabled(true);
provider.setSupportModelDiscovery(protocol.supportsSelfConfiguredDiscovery());
provider.setRequireApiKey(request.getRequireApiKey() == null || Boolean.TRUE.equals(request.getRequireApiKey()));
modelProviderMapper.updateById(provider);
if (request.getModels() != null) {
for (ModelInfoDTO model : request.getModels()) {
modelConfigService.addModelToProvider(request.getId(), model.getId(), model.getName(), false);
}
}
tryAutoActivateModel(request.getId(), provider);
eventPublisher.publishEvent(new ModelConfigChangedEvent("provider-enabled"));
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));
}
/**
* Set (or clear, with a null / non-positive value) the per-model input
* window. Applies to built-in models too — the shipped catalog cannot know
* every vendor's window, so operators need to correct it without editing
* the database by hand.
*/
public ProviderInfoDTO updateModelContextWindow(String providerId, String modelId, Integer maxInputTokens) {
getProvider(providerId);
modelConfigService.updateModelContextWindow(providerId, modelId, maxInputTokens);
return toProviderInfo(getProvider(providerId), modelConfigService.listModelsByProvider(providerId));
}
/**
* Fill the three window fields the model-management UI reads. Uses the
* probe-free resolution path so listing providers never issues a request.
*/
private void applyContextWindow(ModelInfoDTO info, ModelProviderEntity provider, ModelConfigEntity model) {
Integer configured = (model.getMaxInputTokens() != null && model.getMaxInputTokens() > 0)
? model.getMaxInputTokens() : null;
info.setMaxInputTokens(configured);
if (configured != null) {
info.setEffectiveMaxInputTokens(configured);
info.setMaxInputTokensSource("configured");
return;
}
Integer resolved = contextWindowResolver.resolveWithoutProbing(provider, model);
if (resolved != null) {
info.setEffectiveMaxInputTokens(resolved);
info.setMaxInputTokensSource("catalog");
return;
}
info.setEffectiveMaxInputTokens(conversationWindowProperties.getDefaultMaxInputTokens());
info.setMaxInputTokensSource("default");
}
public ModelProviderEntity getProviderConfig(String providerId) {
return getProvider(providerId);
}
/**
* RFC-009: 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 listFallbackChain() {
com.baomidou.mybatisplus.core.conditions.query.QueryWrapper 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 isProviderEnabledAndConfigured(provider) && hasModels(providerId);
}
public String getProviderUnavailableReason(String providerId) {
ModelProviderEntity provider = getProvider(providerId);
if (!Boolean.TRUE.equals(provider.getEnabled())) {
return "Provider 未启用";
}
if (!isProviderConfigured(provider)) {
// Issue #81: emit a precise reason based on which row-level fields are
// missing, rather than the previous protocol-blind heuristic. The new
// frontend reads suggestedActionHintKey/Args; this string remains for
// logs and legacy callers.
ProviderRequirements.Required req = ProviderRequirements.of(provider);
boolean hasBaseUrl = StringUtils.hasText(provider.getBaseUrl());
boolean hasApiKey = hasUsableApiKey(provider.getApiKey());
if (req.needsBaseUrl() && !hasBaseUrl && req.needsApiKey() && !hasApiKey) {
return "Provider 未配置 Base URL 和 API Key";
}
if (req.needsBaseUrl() && !hasBaseUrl) {
return "Provider 未配置 Base URL";
}
if (req.needsApiKey() && !hasApiKey) {
return "Provider 未配置 API Key";
}
return "Provider 未完成配置";
}
if (!hasModels(providerId)) {
return "Provider 下没有可用模型";
}
return null;
}
/**
* RFC-074: flip the {@code enabled} flag for a provider. Enabling republishes
* {@link ModelConfigChangedEvent} so {@code ProviderInitProbe} re-probes the
* fresh row; disabling that owns the current default model auto-promotes
* a replacement so chat doesn't break on the next request.
*
* @return an {@link EnableResult} describing whether the default model was
* switched and to what — the frontend uses it to fire a toast.
*/
public EnableResult setEnabled(String providerId, boolean enabled) {
ModelProviderEntity provider = getProvider(providerId);
boolean current = Boolean.TRUE.equals(provider.getEnabled());
if (current == enabled) {
return EnableResult.unchanged();
}
provider.setEnabled(enabled);
modelProviderMapper.updateById(provider);
EnableResult result = enabled
? EnableResult.unchanged()
: pickReplacementDefaultIfNeeded(providerId);
eventPublisher.publishEvent(new ModelConfigChangedEvent(
enabled ? "provider-enabled" : "provider-disabled"));
return result;
}
/**
* If the current default model belongs to {@code disabledProviderId}, find
* the first enabled + configured provider that has at least one model and
* promote its first model to default. Returns {@link EnableResult#unchanged()}
* when no swap was needed (or no replacement exists — in that case the
* default stays broken and the empty-state UI will catch it).
*/
private EnableResult pickReplacementDefaultIfNeeded(String disabledProviderId) {
ModelConfigEntity currentDefault;
try {
currentDefault = modelConfigService.getDefaultModel();
} catch (MateClawException e) {
// No default at all → nothing to switch.
return EnableResult.unchanged();
}
if (!disabledProviderId.equals(currentDefault.getProvider())) {
return EnableResult.unchanged();
}
// Walk enabled providers in DB order, take the first one with a model.
List candidates = modelProviderMapper.selectList(
new LambdaQueryWrapper()
.eq(ModelProviderEntity::getEnabled, true)
.ne(ModelProviderEntity::getProviderId, disabledProviderId)
.orderByDesc(ModelProviderEntity::getIsLocal)
.orderByAsc(ModelProviderEntity::getName));
for (ModelProviderEntity candidate : candidates) {
if (!isProviderConfigured(candidate)) continue;
List models = modelConfigService.listModelsByProvider(candidate.getProviderId());
if (models.isEmpty()) continue;
ModelConfigEntity first = models.get(0);
modelConfigService.setDefaultModel(candidate.getProviderId(), first.getModelName());
return EnableResult.switched(candidate.getProviderId(), first.getModelName());
}
return EnableResult.unchanged();
}
private void tryAutoActivateModel(String providerId, ModelProviderEntity provider) {
if (!isProviderEnabledAndConfigured(provider)) {
return;
}
List providerModels = modelConfigService.listModelsByProvider(providerId);
if (providerModels.isEmpty()) {
return;
}
boolean shouldAutoActivate = false;
try {
ModelConfigEntity currentDefault = modelConfigService.getDefaultModel();
ModelProviderEntity defaultProvider = modelProviderMapper.selectById(currentDefault.getProvider());
if (!isProviderEnabledAndConfigured(defaultProvider)) {
shouldAutoActivate = true;
}
} catch (MateClawException e) {
shouldAutoActivate = true;
}
if (shouldAutoActivate) {
ModelConfigEntity firstModel = providerModels.get(0);
modelConfigService.setDefaultModel(providerId, firstModel.getModelName());
}
}
/**
* OAuth/device-code completion updates credentials outside the normal provider
* config endpoint. Reuse the same default-model promotion logic so a freshly
* connected OAuth provider is immediately selectable by chat.
*/
public void activateFirstModelIfDefaultUnavailable(String providerId) {
ModelProviderEntity provider = getProvider(providerId);
tryAutoActivateModel(providerId, provider);
}
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 models) {
return toProviderInfo(provider, models, livenessContext());
}
private ProviderInfoDTO toProviderInfo(ModelProviderEntity provider,
List models,
LivenessContext liveness) {
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);
// RFC-073: `available` retains its boolean meaning ("usable right now") but is now
// gated on Liveness.LIVE rather than just configuration completeness, so the chat
// path and the dropdown stop disagreeing about local providers.
Liveness providerLiveness = computeLiveness(provider, configured, liveness);
boolean available = providerLiveness == Liveness.LIVE && models != null && !models.isEmpty();
dto.setConfigured(configured);
dto.setAvailable(available);
dto.setLiveness(providerLiveness);
dto.setEnabled(Boolean.TRUE.equals(provider.getEnabled()));
applyLivenessDetails(dto, provider.getProviderId(), providerLiveness, liveness);
dto.setApiKey(maskApiKey(provider.getApiKey()));
dto.setBaseUrl(provider.getBaseUrl());
dto.setGenerateKwargs(readJson(provider.getGenerateKwargs()));
dto.setAuthType(provider.getAuthType() != null ? provider.getAuthType() : "api_key");
if (CLAUDE_CODE_PROVIDER_ID.equals(provider.getProviderId())) {
// Claude Code OAuth credentials live on disk (RFC-062), not in the
// mate_model_provider row. Bypass the column lookup and ask the
// service directly. Falls back to false if the bean isn't present
// (e.g. minimal test contexts).
ClaudeCodeOAuthService svc = claudeCodeOAuthServiceProvider.getIfAvailable();
if (svc != null) {
ClaudeCodeOAuthService.OAuthStatus status = svc.getStatus();
dto.setOauthConnected(status.connected() && !status.expired());
dto.setOauthExpiresAt(status.expiresAtMs() > 0L ? status.expiresAtMs() : null);
} else {
dto.setOauthConnected(false);
dto.setOauthExpiresAt(null);
}
} else {
dto.setOauthConnected(StringUtils.hasText(provider.getOauthAccessToken()));
dto.setOauthExpiresAt(provider.getOauthExpiresAt());
}
dto.setFallbackPriority(provider.getFallbackPriority() != null ? provider.getFallbackPriority() : 0);
List builtinModels = new ArrayList<>();
List extraModels = new ArrayList<>();
if (models != null) {
for (ModelConfigEntity model : models) {
// RFC-049 PR-1-UI: ModelInfoDTO(id, name) derives supportsReasoningEffort
// from id via ModelFamily — no extra wiring needed here.
ModelInfoDTO info = new ModelInfoDTO(model.getModelName(), model.getName());
applyContextWindow(info, provider, model);
if (Boolean.TRUE.equals(model.getBuiltin())) {
builtinModels.add(info);
} else {
extraModels.add(info);
}
}
}
dto.setModels(builtinModels);
dto.setExtraModels(extraModels);
applySuggestedAction(dto, provider, providerLiveness);
return dto;
}
/**
* Issue #81: derive the chat-popup recovery hint from row + liveness, so the
* frontend can render a precise "next step" instead of a generic
* "model unavailable" toast. Six fields populated:
* - authStatus: CONFIGURED / MISSING / NOT_REQUIRED / OAUTH_PENDING
* - baseUrlComplete: null when not applicable, true/false otherwise
* - missingFields: comma-joined ("apiKey", "baseUrl") for required-field UX
* - suggestedAction: machine-readable next-step key (frontend switches on this)
* - suggestedActionHintKey + suggestedActionHintArgs: i18n key/args, no raw text
*/
private void applySuggestedAction(ProviderInfoDTO dto, ModelProviderEntity provider, Liveness liveness) {
ProviderRequirements.Required req = ProviderRequirements.of(provider);
boolean hasBaseUrl = StringUtils.hasText(provider.getBaseUrl());
boolean hasApiKey = hasUsableApiKey(provider.getApiKey());
boolean hasModels = (dto.getModels() != null && !dto.getModels().isEmpty())
|| (dto.getExtraModels() != null && !dto.getExtraModels().isEmpty());
// 1. authStatus
if ("oauth".equals(provider.getAuthType())) {
dto.setAuthStatus(Boolean.TRUE.equals(dto.getOauthConnected()) ? "CONFIGURED" : "OAUTH_PENDING");
} else if (req.needsApiKey()) {
dto.setAuthStatus(hasApiKey ? "CONFIGURED" : "MISSING");
} else {
dto.setAuthStatus("NOT_REQUIRED");
}
// 2. baseUrlComplete: null when this provider doesn't need a base URL.
dto.setBaseUrlComplete(req.needsBaseUrl() ? hasBaseUrl : null);
// 3. missingFields
java.util.List missing = new ArrayList<>();
if (req.needsApiKey() && !hasApiKey) missing.add("apiKey");
if (req.needsBaseUrl() && !hasBaseUrl) missing.add("baseUrl");
dto.setMissingFields(String.join(",", missing));
// 4. suggestedAction
String action;
if (liveness == Liveness.UNCONFIGURED) {
if ("oauth".equals(provider.getAuthType())) {
action = "start_oauth";
} else if (missing.size() == 1 && missing.get(0).equals("baseUrl")) {
action = "fill_base_url";
} else if (missing.size() == 1 && missing.get(0).equals("apiKey")) {
action = "fill_api_key";
} else {
action = "configure_required_fields";
}
} else if (liveness == Liveness.REMOVED) {
action = "reprobe";
} else if (liveness == Liveness.COOLDOWN) {
action = "wait_cooldown";
} else if (liveness == Liveness.UNPROBED) {
action = "reprobe";
} else if (liveness == Liveness.LIVE && !hasModels) {
action = Boolean.TRUE.equals(provider.getSupportModelDiscovery())
? "pull_model"
: "configure_required_fields";
} else {
action = "none";
}
dto.setSuggestedAction(action);
// 5. hint key + args (NOT raw text). Frontend renders via t(key, args).
// Only emit hint when it actually applies to the action; suppress for
// REMOVED / COOLDOWN / UNPROBED to keep the popup clean.
if ("fill_base_url".equals(action) || "configure_required_fields".equals(action)) {
dto.setSuggestedActionHintKey(req.hintKey());
dto.setSuggestedActionHintArgs(req.hintArgs() == null ? new java.util.LinkedHashMap<>()
: new java.util.LinkedHashMap<>(req.hintArgs()));
} else {
dto.setSuggestedActionHintKey(null);
dto.setSuggestedActionHintArgs(new java.util.LinkedHashMap<>());
}
}
private boolean hasModels(String providerId) {
return !modelConfigService.listModelsByProvider(providerId).isEmpty();
}
private boolean isProviderConfigured(ModelProviderEntity provider) {
if (provider == null) {
return false;
}
// OAuth providers store credentials elsewhere (DB column or disk for
// Claude Code). Resolve them via the OAuth service rather than the
// base-URL / api-key columns.
if ("oauth".equals(provider.getAuthType())) {
if (CLAUDE_CODE_PROVIDER_ID.equals(provider.getProviderId())) {
ClaudeCodeOAuthService svc = claudeCodeOAuthServiceProvider.getIfAvailable();
return svc != null && svc.isLoggedIn();
}
return StringUtils.hasText(provider.getOauthAccessToken());
}
// Issue #81: decide required fields from the provider row, not from the
// protocol enum. Every OpenAI-compatible provider (cloud or local) shares
// OPENAI_COMPATIBLE, so a protocol-keyed table cannot tell OpenAI cloud
// (needs api_key, no base url) apart from llama.cpp local (no api_key,
// needs base url). Without this, isLocal=true short-circuited to true
// for llama.cpp regardless of an empty Base URL, hiding the real cause
// behind a confusing REMOVED state.
ProviderRequirements.Required req = ProviderRequirements.of(provider);
if (req.needsApiKey() && !hasUsableApiKey(provider.getApiKey())) {
return false;
}
if (req.needsBaseUrl() && !StringUtils.hasText(provider.getBaseUrl())) {
return false;
}
return true;
}
public boolean hasUsableApiKey(String apiKey) {
if (!StringUtils.hasText(apiKey)) {
return false;
}
String normalized = apiKey.trim();
// Reject masked display values (the UI sends "********" when the user
// didn't re-type the key) and known placeholder sentinels — without this
// check, the chat / embedding fallback chain happily forwards the
// placeholder to the LLM endpoint, which then returns a 401 at request
// time. "configure-in-admin-ui" is the application.yml default that
// keeps DashScopeChatAutoConfiguration happy at startup when no env var
// is set; "your-*-api-key-here" are legacy sentinels from earlier
// .env.example / application.yml versions.
return !normalized.contains("*")
&& !"configure-in-admin-ui".equalsIgnoreCase(normalized)
&& !"your-dashscope-api-key-here".equalsIgnoreCase(normalized)
&& !"your-api-key-here".equalsIgnoreCase(normalized);
}
public boolean isProviderEnabledAndConfigured(String providerId) {
return isProviderEnabledAndConfigured(getProvider(providerId));
}
private boolean isProviderEnabledAndConfigured(ModelProviderEntity provider) {
return provider != null
&& Boolean.TRUE.equals(provider.getEnabled())
&& isProviderConfigured(provider);
}
public Map 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 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 value) {
try {
return objectMapper.writeValueAsString(value == null ? Collections.emptyMap() : value);
} catch (Exception e) {
return "{}";
}
}
// ============================================================
// RFC-073: Liveness computation
// ============================================================
/** Take one snapshot per render-batch so {@link #toProviderInfo} stays O(1) per provider. */
private LivenessContext livenessContext() {
return new LivenessContext(providerPool.snapshot(), providerHealthTracker.snapshot(),
providerInitProbeProvider.getIfAvailable());
}
private Liveness computeLiveness(ModelProviderEntity provider, boolean configured, LivenessContext ctx) {
if (!configured) return Liveness.UNCONFIGURED;
String id = provider.getProviderId();
// Probe absent in test contexts → fail-open to LIVE so test fixtures don't trip on UNPROBED.
if (ctx.initProbe() != null && !ctx.initProbe().hasBeenProbed(id)) {
return Liveness.UNPROBED;
}
AvailableProviderPool.RemovalReason reason = ctx.poolSnapshot().get(id);
boolean inPool = ctx.poolSnapshot().containsKey(id) && reason == null;
if (!inPool) return Liveness.REMOVED;
ProviderHealthTracker.ProviderHealthSnapshot health = ctx.healthSnapshot().get(id);
if (health != null && health.cooldownRemainingMs() > 0) return Liveness.COOLDOWN;
return Liveness.LIVE;
}
private void applyLivenessDetails(ProviderInfoDTO dto, String providerId,
Liveness liveness, LivenessContext ctx) {
switch (liveness) {
case REMOVED -> {
AvailableProviderPool.RemovalReason reason = ctx.poolSnapshot().get(providerId);
if (reason != null) {
dto.setUnavailableReason(reason.message());
dto.setLastProbedAtMs(reason.removedAtMs());
}
}
case COOLDOWN -> {
ProviderHealthTracker.ProviderHealthSnapshot health = ctx.healthSnapshot().get(providerId);
if (health != null) {
dto.setCooldownRemainingMs(health.cooldownRemainingMs());
}
dto.setUnavailableReason("provider in cooldown after consecutive failures");
}
default -> { /* LIVE / UNPROBED / UNCONFIGURED — no extra fields */ }
}
}
/** Per-render snapshot of pool / cooldown / probe-completion state. */
private record LivenessContext(
Map poolSnapshot,
Map healthSnapshot,
ProviderInitProbe initProbe) {}
}