mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
OAuth token save now promotes the first available chat model when no usable default exists. Default-model resolution and provider availability checks require Provider.enabled=true alongside credentials, so disabled Providers no longer return stale defaults. /models/enabled drops the single-Provider hard-code so OpenAI OAuth and other enabled chat models surface in selectors. Docker exposes the 1455 PKCE callback via MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST (default 127.0.0.1) and a port mapping; deployment mode stays Host-driven.
376 lines
16 KiB
Java
376 lines
16 KiB
Java
package vip.mate.llm.service;
|
||
|
||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||
import lombok.RequiredArgsConstructor;
|
||
import org.springframework.beans.factory.annotation.Autowired;
|
||
import org.springframework.context.annotation.Lazy;
|
||
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.ModelConfigEntity;
|
||
import vip.mate.llm.repository.ModelConfigMapper;
|
||
|
||
import java.util.List;
|
||
import org.springframework.context.ApplicationEventPublisher;
|
||
|
||
/**
|
||
* 模型配置服务
|
||
*/
|
||
@Service
|
||
@RequiredArgsConstructor
|
||
public class ModelConfigService {
|
||
|
||
private final ModelConfigMapper modelConfigMapper;
|
||
private final ApplicationEventPublisher eventPublisher;
|
||
private final ModelCapabilityService modelCapabilityService;
|
||
|
||
/**
|
||
* Lazy to break circular dependency: ModelProviderService → ModelConfigService.
|
||
* Used only in getDefaultModel() to skip models whose provider is unconfigured.
|
||
*/
|
||
@Lazy
|
||
@Autowired
|
||
private ModelProviderService modelProviderService;
|
||
|
||
public List<ModelConfigEntity> listModels() {
|
||
return modelConfigMapper.selectList(new LambdaQueryWrapper<ModelConfigEntity>()
|
||
.orderByDesc(ModelConfigEntity::getIsDefault)
|
||
.orderByAsc(ModelConfigEntity::getProvider)
|
||
.orderByAsc(ModelConfigEntity::getName));
|
||
}
|
||
|
||
public List<ModelConfigEntity> listEnabledModels() {
|
||
return modelConfigMapper.selectList(new LambdaQueryWrapper<ModelConfigEntity>()
|
||
.eq(ModelConfigEntity::getEnabled, true)
|
||
// 仅 chat 类型(排除 embedding),NULL 兼容老数据
|
||
.and(w -> w.isNull(ModelConfigEntity::getModelType)
|
||
.or().eq(ModelConfigEntity::getModelType, "chat"))
|
||
.orderByAsc(ModelConfigEntity::getProvider)
|
||
.orderByDesc(ModelConfigEntity::getIsDefault)
|
||
.orderByAsc(ModelConfigEntity::getName));
|
||
}
|
||
|
||
public List<ModelConfigEntity> listModelsByProvider(String providerId) {
|
||
return modelConfigMapper.selectList(new LambdaQueryWrapper<ModelConfigEntity>()
|
||
.eq(ModelConfigEntity::getProvider, providerId)
|
||
.orderByDesc(ModelConfigEntity::getBuiltin)
|
||
.orderByAsc(ModelConfigEntity::getName));
|
||
}
|
||
|
||
/**
|
||
* 按模型类型筛选(RFC: embedding UI 配置)。
|
||
* <p>
|
||
* modelType 参数:
|
||
* <ul>
|
||
* <li>{@code "chat"} — 对话模型(默认,包括老数据 modelType IS NULL)</li>
|
||
* <li>{@code "embedding"} — 文本向量化模型</li>
|
||
* </ul>
|
||
*/
|
||
public List<ModelConfigEntity> listByType(String modelType) {
|
||
return listByType(modelType, null);
|
||
}
|
||
|
||
/**
|
||
* Optional modality filter (case-insensitive: {@code "vision" / "video" / "audio"}).
|
||
* When non-null, only enabled rows whose resolved capability set contains the
|
||
* requested modality survive — used by the multimodal sidecar settings UI to
|
||
* populate "default vision model" / "default video model" dropdowns.
|
||
*/
|
||
public List<ModelConfigEntity> listByType(String modelType, String modality) {
|
||
List<ModelConfigEntity> rows;
|
||
if ("chat".equals(modelType)) {
|
||
rows = modelConfigMapper.selectList(new LambdaQueryWrapper<ModelConfigEntity>()
|
||
.and(w -> w.isNull(ModelConfigEntity::getModelType)
|
||
.or().eq(ModelConfigEntity::getModelType, "chat"))
|
||
.orderByDesc(ModelConfigEntity::getIsDefault)
|
||
.orderByAsc(ModelConfigEntity::getName));
|
||
} else {
|
||
rows = modelConfigMapper.selectList(new LambdaQueryWrapper<ModelConfigEntity>()
|
||
.eq(ModelConfigEntity::getModelType, modelType)
|
||
.orderByDesc(ModelConfigEntity::getIsDefault)
|
||
.orderByAsc(ModelConfigEntity::getName));
|
||
}
|
||
if (modality == null || modality.isBlank()) return rows;
|
||
ModelCapabilityService.Modality required;
|
||
try {
|
||
required = ModelCapabilityService.Modality.valueOf(modality.trim().toUpperCase());
|
||
} catch (IllegalArgumentException e) {
|
||
return rows;
|
||
}
|
||
return rows.stream()
|
||
.filter(m -> Boolean.TRUE.equals(m.getEnabled()))
|
||
.filter(m -> modelCapabilityService.supports(m.getModelName(), m.getModalities(), required))
|
||
.toList();
|
||
}
|
||
|
||
/**
|
||
* 查找第一个 enabled 的 embedding 模型(WikiEmbeddingService 的 fallback 路径)
|
||
*/
|
||
public ModelConfigEntity findFirstEnabledEmbedding() {
|
||
return modelConfigMapper.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
|
||
.eq(ModelConfigEntity::getModelType, "embedding")
|
||
.eq(ModelConfigEntity::getEnabled, true)
|
||
.orderByDesc(ModelConfigEntity::getIsDefault)
|
||
.orderByAsc(ModelConfigEntity::getName)
|
||
.last("LIMIT 1"));
|
||
}
|
||
|
||
public ModelConfigEntity getModel(Long id) {
|
||
ModelConfigEntity entity = modelConfigMapper.selectById(id);
|
||
if (entity == null) {
|
||
throw new MateClawException("err.llm.model_config_not_found", "模型配置不存在: " + id);
|
||
}
|
||
return entity;
|
||
}
|
||
|
||
public ModelConfigEntity getDefaultModel() {
|
||
// Prefer the explicitly marked default chat model when its provider is configured.
|
||
ModelConfigEntity defaultMarked = modelConfigMapper.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
|
||
.eq(ModelConfigEntity::getIsDefault, true)
|
||
.and(w -> w.isNull(ModelConfigEntity::getModelType)
|
||
.or().eq(ModelConfigEntity::getModelType, "chat"))
|
||
.last("LIMIT 1"));
|
||
if (defaultMarked != null && isProviderEnabledAndConfigured(defaultMarked.getProvider())) {
|
||
return defaultMarked;
|
||
}
|
||
|
||
// Default model's provider is unavailable (or no default set) — scan all enabled
|
||
// chat models and pick the first one whose provider is actually configured.
|
||
List<ModelConfigEntity> candidates = modelConfigMapper.selectList(new LambdaQueryWrapper<ModelConfigEntity>()
|
||
.eq(ModelConfigEntity::getEnabled, true)
|
||
.and(w -> w.isNull(ModelConfigEntity::getModelType)
|
||
.or().eq(ModelConfigEntity::getModelType, "chat"))
|
||
.orderByDesc(ModelConfigEntity::getIsDefault)
|
||
.orderByAsc(ModelConfigEntity::getName));
|
||
for (ModelConfigEntity candidate : candidates) {
|
||
if (isProviderEnabledAndConfigured(candidate.getProvider())) {
|
||
return candidate;
|
||
}
|
||
}
|
||
|
||
// No configured provider found — give a clearer error than the generic one.
|
||
if (!candidates.isEmpty()) {
|
||
String unconfiguredProvider = candidates.get(0).getProvider();
|
||
throw new MateClawException("err.llm.no_configured_provider",
|
||
"所有已启用的模型 Provider 均未完成配置(默认 Provider: " + unconfiguredProvider
|
||
+ "),请在「设置 → 模型」中填写 API Key");
|
||
}
|
||
throw new MateClawException("err.llm.no_available_model", "没有可用的模型配置");
|
||
}
|
||
|
||
/**
|
||
* Checks whether a provider is fully configured (API key / credentials present).
|
||
* Delegates to ModelProviderService which is injected lazily to avoid a circular
|
||
* dependency. Falls back to {@code true} when the service is not yet available
|
||
* (e.g., during early bootstrap) so we don't accidentally block startup.
|
||
*/
|
||
private boolean isProviderEnabledAndConfigured(String providerId) {
|
||
if (modelProviderService == null || providerId == null) {
|
||
return true;
|
||
}
|
||
try {
|
||
return modelProviderService.isProviderEnabledAndConfigured(providerId);
|
||
} catch (Exception e) {
|
||
return true; // conservative: don't filter if lookup fails
|
||
}
|
||
}
|
||
|
||
public ModelConfigEntity getDefaultModelByProvider(String providerId) {
|
||
return modelConfigMapper.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
|
||
.eq(ModelConfigEntity::getProvider, providerId)
|
||
.eq(ModelConfigEntity::getIsDefault, true)
|
||
.last("LIMIT 1"));
|
||
}
|
||
|
||
public ModelConfigEntity createModel(ModelConfigEntity entity) {
|
||
validateModel(entity, null);
|
||
if (Boolean.TRUE.equals(entity.getIsDefault())) {
|
||
clearDefaultFlag();
|
||
}
|
||
if (entity.getEnabled() == null) {
|
||
entity.setEnabled(true);
|
||
}
|
||
if (entity.getBuiltin() == null) {
|
||
entity.setBuiltin(true);
|
||
}
|
||
if (entity.getIsDefault() == null) {
|
||
entity.setIsDefault(false);
|
||
}
|
||
modelConfigMapper.insert(entity);
|
||
ensureDefaultExists();
|
||
publishConfigChanged("model-created");
|
||
return entity;
|
||
}
|
||
|
||
public ModelConfigEntity updateModel(ModelConfigEntity entity) {
|
||
ModelConfigEntity existing = getModel(entity.getId());
|
||
validateModel(entity, existing.getId());
|
||
if (Boolean.TRUE.equals(entity.getIsDefault())) {
|
||
clearDefaultFlag();
|
||
}
|
||
if (existing.getIsDefault() && Boolean.FALSE.equals(entity.getEnabled())) {
|
||
throw new MateClawException("err.llm.cannot_disable_default", "默认模型不能被禁用,请先切换默认模型");
|
||
}
|
||
modelConfigMapper.updateById(entity);
|
||
ensureDefaultExists();
|
||
publishConfigChanged("model-updated");
|
||
return getModel(entity.getId());
|
||
}
|
||
|
||
public void deleteModel(Long id) {
|
||
ModelConfigEntity entity = getModel(id);
|
||
if (Boolean.TRUE.equals(entity.getIsDefault())) {
|
||
throw new MateClawException("err.llm.cannot_delete_default", "默认模型不能删除,请先切换默认模型");
|
||
}
|
||
modelConfigMapper.deleteById(id);
|
||
ensureDefaultExists();
|
||
publishConfigChanged("model-deleted");
|
||
}
|
||
|
||
public ModelConfigEntity addModelToProvider(String providerId, String modelId, String displayName, boolean builtin) {
|
||
if (!StringUtils.hasText(providerId) || !StringUtils.hasText(modelId)) {
|
||
throw new MateClawException("err.llm.provider_model_required", "Provider 和模型标识不能为空");
|
||
}
|
||
ModelConfigEntity existing = modelConfigMapper.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
|
||
.eq(ModelConfigEntity::getProvider, providerId)
|
||
.eq(ModelConfigEntity::getModelName, modelId)
|
||
.last("LIMIT 1"));
|
||
if (existing != null) {
|
||
throw new MateClawException("err.llm.model_exists", "模型已存在: " + modelId);
|
||
}
|
||
ModelConfigEntity entity = new ModelConfigEntity();
|
||
entity.setName(StringUtils.hasText(displayName) ? displayName : modelId);
|
||
entity.setProvider(providerId);
|
||
entity.setModelName(modelId);
|
||
entity.setDescription("");
|
||
entity.setTemperature(0.7);
|
||
entity.setMaxTokens(4096);
|
||
entity.setTopP(0.8);
|
||
entity.setBuiltin(builtin);
|
||
entity.setEnabled(true);
|
||
entity.setIsDefault(false);
|
||
modelConfigMapper.insert(entity);
|
||
ensureDefaultExists();
|
||
publishConfigChanged("provider-model-added");
|
||
return entity;
|
||
}
|
||
|
||
public void removeModelFromProvider(String providerId, String modelId) {
|
||
ModelConfigEntity entity = modelConfigMapper.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
|
||
.eq(ModelConfigEntity::getProvider, providerId)
|
||
.eq(ModelConfigEntity::getModelName, modelId)
|
||
.last("LIMIT 1"));
|
||
if (entity == null) {
|
||
throw new MateClawException("err.llm.model_not_found", "模型不存在: " + modelId);
|
||
}
|
||
if (Boolean.TRUE.equals(entity.getBuiltin())) {
|
||
throw new MateClawException("err.llm.builtin_readonly", "内置模型不支持删除");
|
||
}
|
||
deleteModel(entity.getId());
|
||
}
|
||
|
||
public void deleteModelsByProvider(String providerId) {
|
||
List<ModelConfigEntity> entities = listModelsByProvider(providerId);
|
||
for (ModelConfigEntity entity : entities) {
|
||
modelConfigMapper.deleteById(entity.getId());
|
||
}
|
||
ensureDefaultExists();
|
||
publishConfigChanged("provider-models-deleted");
|
||
}
|
||
|
||
public ModelConfigEntity setDefaultModel(Long id) {
|
||
ModelConfigEntity entity = getModel(id);
|
||
if (!Boolean.TRUE.equals(entity.getEnabled())) {
|
||
throw new MateClawException("err.llm.only_enabled_default", "只有启用状态的模型才能设为默认");
|
||
}
|
||
clearDefaultFlag();
|
||
entity.setIsDefault(true);
|
||
modelConfigMapper.updateById(entity);
|
||
publishConfigChanged("default-model-updated");
|
||
return entity;
|
||
}
|
||
|
||
public ModelConfigEntity setDefaultModel(String providerId, String modelName) {
|
||
ModelConfigEntity entity = modelConfigMapper.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
|
||
.eq(ModelConfigEntity::getProvider, providerId)
|
||
.eq(ModelConfigEntity::getModelName, modelName)
|
||
.last("LIMIT 1"));
|
||
if (entity == null) {
|
||
throw new MateClawException("err.llm.model_not_found", "模型不存在: " + providerId + "/" + modelName);
|
||
}
|
||
if (!Boolean.TRUE.equals(entity.getEnabled())) {
|
||
// Auto-enable when setting as default (e.g. local Ollama models)
|
||
entity.setEnabled(true);
|
||
}
|
||
clearDefaultFlag();
|
||
entity.setIsDefault(true);
|
||
modelConfigMapper.updateById(entity);
|
||
publishConfigChanged("default-model-updated");
|
||
return entity;
|
||
}
|
||
|
||
public ModelConfigEntity resolveModel(String agentModelName) {
|
||
if (StringUtils.hasText(agentModelName)) {
|
||
ModelConfigEntity entity = modelConfigMapper.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
|
||
.eq(ModelConfigEntity::getModelName, agentModelName)
|
||
.eq(ModelConfigEntity::getEnabled, true)
|
||
.last("LIMIT 1"));
|
||
if (entity != null) {
|
||
return entity;
|
||
}
|
||
}
|
||
return getDefaultModel();
|
||
}
|
||
|
||
private void validateModel(ModelConfigEntity entity, Long currentId) {
|
||
if (!StringUtils.hasText(entity.getName())) {
|
||
throw new MateClawException("err.llm.name_required", "模型名称不能为空");
|
||
}
|
||
if (!StringUtils.hasText(entity.getProvider())) {
|
||
entity.setProvider("dashscope");
|
||
}
|
||
if (!StringUtils.hasText(entity.getModelName())) {
|
||
throw new MateClawException("err.llm.id_required", "模型标识不能为空");
|
||
}
|
||
ModelConfigEntity duplicate = modelConfigMapper.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
|
||
.eq(ModelConfigEntity::getProvider, entity.getProvider())
|
||
.eq(ModelConfigEntity::getModelName, entity.getModelName())
|
||
.ne(currentId != null, ModelConfigEntity::getId, currentId)
|
||
.last("LIMIT 1"));
|
||
if (duplicate != null) {
|
||
throw new MateClawException("err.llm.id_exists", "模型标识已存在: " + entity.getProvider() + "/" + entity.getModelName());
|
||
}
|
||
}
|
||
|
||
private void clearDefaultFlag() {
|
||
List<ModelConfigEntity> defaults = modelConfigMapper.selectList(new LambdaQueryWrapper<ModelConfigEntity>()
|
||
.eq(ModelConfigEntity::getIsDefault, true));
|
||
for (ModelConfigEntity item : defaults) {
|
||
item.setIsDefault(false);
|
||
modelConfigMapper.updateById(item);
|
||
}
|
||
}
|
||
|
||
private void ensureDefaultExists() {
|
||
long defaultCount = modelConfigMapper.selectCount(new LambdaQueryWrapper<ModelConfigEntity>()
|
||
.eq(ModelConfigEntity::getIsDefault, true)
|
||
.eq(ModelConfigEntity::getEnabled, true));
|
||
if (defaultCount > 0) {
|
||
return;
|
||
}
|
||
ModelConfigEntity firstEnabled = modelConfigMapper.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
|
||
.eq(ModelConfigEntity::getEnabled, true)
|
||
.orderByAsc(ModelConfigEntity::getName)
|
||
.last("LIMIT 1"));
|
||
if (firstEnabled != null) {
|
||
firstEnabled.setIsDefault(true);
|
||
modelConfigMapper.updateById(firstEnabled);
|
||
}
|
||
}
|
||
|
||
private void publishConfigChanged(String reason) {
|
||
eventPublisher.publishEvent(new ModelConfigChangedEvent(reason));
|
||
}
|
||
}
|