mirror of
https://gitcode.com/ageerle/ruoyi-ai.git
synced 2026-11-14 23:10:41 +00:00
fix: restore admin-managed model keys and refresh cached clients
This commit is contained in:
@@ -8,7 +8,6 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo;
|
||||
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
|
||||
import org.ruoyi.common.chat.security.ChatModelCredentialPolicy;
|
||||
import org.ruoyi.common.chat.service.chat.IChatModelService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -93,10 +92,7 @@ public class ZhipuWebSearchClient {
|
||||
|
||||
private Credential credentialForConfiguredEndpoint(ChatModelVo model) {
|
||||
String finalBaseUrl = normalizeBaseUrl(model.getApiHost());
|
||||
String apiKey = ChatModelCredentialPolicy.resolveApiKeyForUse(
|
||||
ZHIPU_PROVIDER_CODE, model.getProviderCode(), model.getModelName(), finalBaseUrl,
|
||||
model.getApiKey());
|
||||
return new Credential(finalBaseUrl, apiKey);
|
||||
return new Credential(finalBaseUrl, model.getApiKey());
|
||||
}
|
||||
|
||||
private boolean isUsableApiKey(String apiKey) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.springframework.stereotype.Service;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
@@ -39,10 +40,12 @@ public class EmbeddingModelFactory {
|
||||
private final EmbeddingModelListenerProvider embeddingModelListenerProvider;
|
||||
|
||||
// 模型缓存,使用ConcurrentHashMap保证线程安全
|
||||
private final Map<ModelCacheKey, BaseEmbedModelService> modelCache = new ConcurrentHashMap<>();
|
||||
private final Map<ModelCacheKey, CachedModel> modelCache = new ConcurrentHashMap<>();
|
||||
|
||||
private record ModelCacheKey(Long id, String modelName, String providerCode) { }
|
||||
|
||||
private record CachedModel(ChatModelVo configuration, BaseEmbedModelService model) { }
|
||||
|
||||
/**
|
||||
* 创建嵌入模型实例
|
||||
* 如果模型已存在于缓存中,则直接返回;否则创建新的实例
|
||||
@@ -57,8 +60,15 @@ public class EmbeddingModelFactory {
|
||||
}
|
||||
ModelCacheKey key = new ModelCacheKey(modelConfig.getId(),
|
||||
modelConfig.getModelName(), modelConfig.getProviderCode());
|
||||
return modelCache.computeIfAbsent(key,
|
||||
ignored -> createModelInstance(modelConfig.getProviderCode(), modelConfig));
|
||||
return modelCache.compute(key, (ignored, cached) -> {
|
||||
// ChatModelVo.equals deliberately excludes the secret, so compare it separately.
|
||||
if (cached != null && modelConfig.equals(cached.configuration())
|
||||
&& Objects.equals(modelConfig.getApiKey(), cached.configuration().getApiKey())) {
|
||||
return cached;
|
||||
}
|
||||
return new CachedModel(modelConfig,
|
||||
createModelInstance(modelConfig.getProviderCode(), modelConfig));
|
||||
}).model();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.springframework.stereotype.Service;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
@@ -34,10 +35,12 @@ public class RerankModelFactory {
|
||||
/**
|
||||
* 模型缓存,使用ConcurrentHashMap保证线程安全
|
||||
*/
|
||||
private final Map<ModelCacheKey, RerankModelService> modelCache = new ConcurrentHashMap<>();
|
||||
private final Map<ModelCacheKey, CachedModel> modelCache = new ConcurrentHashMap<>();
|
||||
|
||||
private record ModelCacheKey(Long id, String modelName, String providerCode) { }
|
||||
|
||||
private record CachedModel(ChatModelVo configuration, RerankModelService model) { }
|
||||
|
||||
/**
|
||||
* 创建重排序模型实例
|
||||
* 如果模型已存在于缓存中,则直接返回;否则创建新的实例
|
||||
@@ -52,8 +55,15 @@ public class RerankModelFactory {
|
||||
}
|
||||
ModelCacheKey key = new ModelCacheKey(modelConfig.getId(),
|
||||
modelConfig.getModelName(), modelConfig.getProviderCode());
|
||||
return modelCache.computeIfAbsent(key,
|
||||
ignored -> createModelInstance(modelConfig.getProviderCode(), modelConfig));
|
||||
return modelCache.compute(key, (ignored, cached) -> {
|
||||
// ChatModelVo.equals deliberately excludes the secret, so compare it separately.
|
||||
if (cached != null && modelConfig.equals(cached.configuration())
|
||||
&& Objects.equals(modelConfig.getApiKey(), cached.configuration().getApiKey())) {
|
||||
return cached;
|
||||
}
|
||||
return new CachedModel(modelConfig,
|
||||
createModelInstance(modelConfig.getProviderCode(), modelConfig));
|
||||
}).model();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -75,7 +75,7 @@ public class AtlasAudioGenerationServiceImpl extends AbstractAudioGenerationServ
|
||||
|
||||
Request request = new Request.Builder()
|
||||
.url(AtlasMediaSupport.endpoint(model.getApiHost(), "/model/generateAudio"))
|
||||
.addHeader("Authorization", "Bearer " + model.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.addHeader("Authorization", "Bearer " + model.getApiKey())
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(RequestBody.create(payload.toString(), AtlasMediaSupport.JSON))
|
||||
.build();
|
||||
|
||||
@@ -72,7 +72,7 @@ public class OpenAiAudioGenerationServiceImpl extends AbstractAudioGenerationSer
|
||||
private byte[] postBytes(ChatModelVo model, String path, String jsonBody) throws IOException {
|
||||
Request request = new Request.Builder()
|
||||
.url(OpenAiMediaSupport.endpoint(model.getApiHost(), path))
|
||||
.addHeader("Authorization", "Bearer " + model.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.addHeader("Authorization", "Bearer " + model.getApiKey())
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(RequestBody.create(jsonBody, OpenAiMediaSupport.JSON))
|
||||
.build();
|
||||
|
||||
@@ -36,7 +36,7 @@ public interface AbstractChatService {
|
||||
default ChatModel buildChatModel(ChatModelVo chatModelVo) {
|
||||
return OpenAiChatModel.builder()
|
||||
.baseUrl(chatModelVo.getApiHost())
|
||||
.apiKey(chatModelVo.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.timeout(Duration.ofSeconds(120))
|
||||
.build();
|
||||
|
||||
@@ -4,8 +4,6 @@ import org.ruoyi.common.chat.service.chat.IChatModelService;
|
||||
import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo;
|
||||
import org.ruoyi.common.chat.entity.chat.ChatModel;
|
||||
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
|
||||
import org.ruoyi.common.chat.security.ChatModelCredentialPolicy;
|
||||
import org.ruoyi.common.chat.security.ChatModelSecretReference;
|
||||
import org.ruoyi.common.core.utils.StringUtils;
|
||||
import org.ruoyi.common.mybatis.core.page.TableDataInfo;
|
||||
import org.ruoyi.common.mybatis.core.page.PageQuery;
|
||||
@@ -131,7 +129,7 @@ public class ChatModelServiceImpl implements IChatModelService {
|
||||
@Override
|
||||
public Boolean insertByBo(ChatModelBo bo) {
|
||||
ChatModel add = toEntity(bo);
|
||||
validateEffectiveConfiguration(add);
|
||||
chatProviderService.requireEnabled(add.getProviderCode());
|
||||
boolean flag = baseMapper.insert(add) > 0;
|
||||
if (flag) {
|
||||
bo.setId(add.getId());
|
||||
@@ -149,30 +147,19 @@ public class ChatModelServiceImpl implements IChatModelService {
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Boolean updateByBo(ChatModelBo bo) {
|
||||
ChatModel update = toEntity(bo);
|
||||
validateRequestedFields(update);
|
||||
// 编辑表单不回显密钥;留空时保留数据库中的原值。
|
||||
if ("".equals(update.getApiKey())) {
|
||||
update.setApiKey(null);
|
||||
}
|
||||
ChatModel current = selectByIdForUpdate(update.getId());
|
||||
if (current == null) {
|
||||
return false;
|
||||
}
|
||||
validateEffectiveConfiguration(mergeEffectiveConfiguration(current, update));
|
||||
chatProviderService.requireEnabled(
|
||||
update.getProviderCode() == null ? current.getProviderCode() : update.getProviderCode());
|
||||
return baseMapper.updateById(update) > 0;
|
||||
}
|
||||
|
||||
private void validateRequestedFields(ChatModel requested) {
|
||||
if (requested.getApiKey() != null) {
|
||||
ChatModelSecretReference.requirePersistableReference(requested.getApiKey());
|
||||
}
|
||||
if (requested.getApiHost() != null) {
|
||||
ChatModelCredentialPolicy.requireSecureApiHost(requested.getApiHost());
|
||||
}
|
||||
}
|
||||
|
||||
private void validateEffectiveConfiguration(ChatModel entity) {
|
||||
ChatModelCredentialPolicy.requirePersistableConfiguration(
|
||||
entity.getProviderCode(), entity.getModelName(), entity.getApiHost(), entity.getApiKey());
|
||||
chatProviderService.requireEnabled(entity.getProviderCode());
|
||||
}
|
||||
|
||||
private ChatModel selectByIdForUpdate(Long id) {
|
||||
if (id == null) {
|
||||
throw new IllegalArgumentException("Model id is required for update");
|
||||
@@ -182,19 +169,6 @@ public class ChatModelServiceImpl implements IChatModelService {
|
||||
return baseMapper.selectOne(lock);
|
||||
}
|
||||
|
||||
private ChatModel mergeEffectiveConfiguration(ChatModel current, ChatModel requested) {
|
||||
ChatModel effective = new ChatModel();
|
||||
effective.setProviderCode(firstNonNull(requested.getProviderCode(), current.getProviderCode()));
|
||||
effective.setModelName(firstNonNull(requested.getModelName(), current.getModelName()));
|
||||
effective.setApiHost(firstNonNull(requested.getApiHost(), current.getApiHost()));
|
||||
effective.setApiKey(firstNonNull(requested.getApiKey(), current.getApiKey()));
|
||||
return effective;
|
||||
}
|
||||
|
||||
private static <T> T firstNonNull(T requested, T current) {
|
||||
return requested == null ? current : requested;
|
||||
}
|
||||
|
||||
private ChatModel toEntity(ChatModelBo source) {
|
||||
if (source == null) {
|
||||
throw new IllegalArgumentException("Model command is required");
|
||||
@@ -247,25 +221,12 @@ public class ChatModelServiceImpl implements IChatModelService {
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Boolean updateApiKeyByProvider(String providerCode, String apiKey) {
|
||||
if (apiKey == null) {
|
||||
throw new IllegalArgumentException("Batch model key reference is required");
|
||||
if (StringUtils.isBlank(providerCode) || StringUtils.isBlank(apiKey)) {
|
||||
throw new IllegalArgumentException("厂商编码和密钥不能为空");
|
||||
}
|
||||
String reference = ChatModelSecretReference.requirePersistableReference(apiKey);
|
||||
ChatModelCredentialPolicy.requireProviderReference(providerCode, reference);
|
||||
LambdaQueryWrapper<ChatModel> lock = Wrappers.lambdaQuery();
|
||||
lock.select(ChatModel::getId, ChatModel::getProviderCode, ChatModel::getModelName, ChatModel::getApiHost)
|
||||
.eq(ChatModel::getProviderCode, providerCode)
|
||||
.last("FOR UPDATE");
|
||||
List<ChatModel> current = baseMapper.selectList(lock);
|
||||
if (current.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
current.forEach(model -> ChatModelCredentialPolicy.requireTrustedConfiguration(
|
||||
model.getProviderCode(), model.getModelName(), model.getApiHost(), reference));
|
||||
LambdaUpdateWrapper<ChatModel> uw = Wrappers.lambdaUpdate();
|
||||
uw.set(ChatModel::getApiKey, reference)
|
||||
.eq(ChatModel::getProviderCode, providerCode)
|
||||
.in(ChatModel::getId, current.stream().map(ChatModel::getId).toList());
|
||||
uw.set(ChatModel::getApiKey, apiKey)
|
||||
.eq(ChatModel::getProviderCode, providerCode);
|
||||
return baseMapper.update(null, uw) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ public class ChatMemoryUsageExample {
|
||||
@Override
|
||||
protected StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) {
|
||||
return ClaudeStreamingChatModel.builder()
|
||||
.apiKey(chatModelVo.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ public class AtlaServiceImpl implements AbstractChatService {
|
||||
public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) {
|
||||
return OpenAiStreamingChatModel.builder()
|
||||
.baseUrl(chatModelVo.getApiHost())
|
||||
.apiKey(chatModelVo.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.listeners(List.of(new MyChatModelListener()))
|
||||
.returnThinking(chatRequest.getEnableThinking())
|
||||
|
||||
@@ -368,8 +368,7 @@ public class CozeChatServiceImpl implements AbstractChatService {
|
||||
}
|
||||
|
||||
private String requiredApiKey() {
|
||||
String apiKey = chatModelVo.resolveApiKeyForConfiguredEndpoint(
|
||||
ChatModeType.COZE.getCode());
|
||||
String apiKey = chatModelVo.getApiKey();
|
||||
if (StrUtil.isBlank(apiKey)) {
|
||||
throw new IllegalArgumentException("Coze apiKey cannot be blank");
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ public class CustomAnthropicServiceImpl implements AbstractChatService {
|
||||
String baseUrl = validateConfiguration(config);
|
||||
return AnthropicStreamingChatModel.builder()
|
||||
.baseUrl(baseUrl)
|
||||
.apiKey(config.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.apiKey(config.getApiKey())
|
||||
.modelName(config.getModelName())
|
||||
.maxTokens(DEFAULT_MAX_TOKENS)
|
||||
.timeout(DEFAULT_TIMEOUT)
|
||||
@@ -41,7 +41,7 @@ public class CustomAnthropicServiceImpl implements AbstractChatService {
|
||||
String baseUrl = validateConfiguration(config);
|
||||
return AnthropicChatModel.builder()
|
||||
.baseUrl(baseUrl)
|
||||
.apiKey(config.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.apiKey(config.getApiKey())
|
||||
.modelName(config.getModelName())
|
||||
.maxTokens(DEFAULT_MAX_TOKENS)
|
||||
.timeout(DEFAULT_TIMEOUT)
|
||||
@@ -53,8 +53,7 @@ public class CustomAnthropicServiceImpl implements AbstractChatService {
|
||||
if (!getProviderName().equals(config.getProviderCode())) {
|
||||
throw new IllegalArgumentException("模型厂商与 Anthropic 自定义适配器不匹配");
|
||||
}
|
||||
return CustomApiCredentialPolicy.requireConfiguration(
|
||||
getProviderName(), config.getModelName(), config.getApiHost(), config.getApiKey());
|
||||
return CustomApiCredentialPolicy.normalizeBaseUrl(getProviderName(), config.getApiHost());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -59,7 +59,7 @@ public class CustomApiServiceImpl implements AbstractChatService {
|
||||
}
|
||||
return OpenAiStreamingChatModel.builder()
|
||||
.baseUrl(baseUrl)
|
||||
.apiKey(chatModelVo.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.timeout(DEFAULT_TIMEOUT)
|
||||
.listeners(List.of(new MyChatModelListener()))
|
||||
@@ -79,7 +79,7 @@ public class CustomApiServiceImpl implements AbstractChatService {
|
||||
boolean thinkingEnabled = !"none".equalsIgnoreCase(reasoningEffort);
|
||||
return DoubaoStreamingChatModel.builder()
|
||||
.endpoint(baseUrl + "/chat/completions")
|
||||
.apiKey(chatModelVo.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.timeout(doubaoTimeout)
|
||||
.reasoningEffort(reasoningEffort)
|
||||
@@ -92,7 +92,7 @@ public class CustomApiServiceImpl implements AbstractChatService {
|
||||
String baseUrl = validateConfiguration(chatModelVo);
|
||||
return OpenAiChatModel.builder()
|
||||
.baseUrl(baseUrl)
|
||||
.apiKey(chatModelVo.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.timeout(DEFAULT_TIMEOUT)
|
||||
.build();
|
||||
@@ -107,7 +107,6 @@ public class CustomApiServiceImpl implements AbstractChatService {
|
||||
if (!getProviderName().equals(config.getProviderCode())) {
|
||||
throw new IllegalArgumentException("模型厂商与 OpenAI 自定义适配器不匹配");
|
||||
}
|
||||
return CustomApiCredentialPolicy.requireConfiguration(
|
||||
getProviderName(), config.getModelName(), config.getApiHost(), config.getApiKey());
|
||||
return CustomApiCredentialPolicy.normalizeBaseUrl(getProviderName(), config.getApiHost());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
package org.ruoyi.service.chat.impl.provider;
|
||||
|
||||
|
||||
import dev.langchain4j.model.chat.ChatModel;
|
||||
import dev.langchain4j.model.chat.StreamingChatModel;
|
||||
import dev.langchain4j.model.openai.OpenAiStreamingChatModel;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.ruoyi.common.chat.domain.dto.request.ChatRequest;
|
||||
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
|
||||
import org.ruoyi.common.chat.security.ChatModelCredentialPolicy;
|
||||
import org.ruoyi.enums.ChatModeType;
|
||||
import org.ruoyi.observability.ChatModelListenerProvider;
|
||||
import org.ruoyi.observability.MyChatModelListener;
|
||||
@@ -33,13 +31,12 @@ public class DeepseekServiceImpl implements AbstractChatService {
|
||||
|
||||
@Override
|
||||
public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) {
|
||||
validateConfiguration(chatModelVo);
|
||||
boolean thinkingEnabled = Boolean.TRUE.equals(chatRequest.getEnableThinking());
|
||||
boolean replayThinking = thinkingEnabled
|
||||
|| Boolean.TRUE.equals(chatRequest.getReplayThinking());
|
||||
var builder = OpenAiStreamingChatModel.builder()
|
||||
.baseUrl(chatModelVo.getApiHost())
|
||||
.apiKey(chatModelVo.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.listeners(List.of(new MyChatModelListener()))
|
||||
.customParameters(thinkingParameters(thinkingEnabled))
|
||||
@@ -54,18 +51,6 @@ public class DeepseekServiceImpl implements AbstractChatService {
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatModel buildChatModel(ChatModelVo chatModelVo) {
|
||||
validateConfiguration(chatModelVo);
|
||||
return AbstractChatService.super.buildChatModel(chatModelVo);
|
||||
}
|
||||
|
||||
private void validateConfiguration(ChatModelVo chatModelVo) {
|
||||
ChatModelCredentialPolicy.requireDeepSeekConfiguration(
|
||||
chatModelVo.getProviderCode(), chatModelVo.getModelName(), chatModelVo.getApiHost(),
|
||||
chatModelVo.getApiKey());
|
||||
}
|
||||
|
||||
static Map<String, Object> thinkingParameters(boolean enabled) {
|
||||
return Map.of("thinking", Map.of("type", enabled ? "enabled" : "disabled"));
|
||||
}
|
||||
|
||||
@@ -262,8 +262,7 @@ public class DifyChatServiceImpl implements AbstractChatService {
|
||||
}
|
||||
|
||||
private String requiredApiKey() {
|
||||
String apiKey = chatModelVo.resolveApiKeyForConfiguredEndpoint(
|
||||
ChatModeType.DIFY.getCode());
|
||||
String apiKey = chatModelVo.getApiKey();
|
||||
if (StrUtil.isBlank(apiKey)) {
|
||||
throw new IllegalArgumentException("Dify 的 API Key 不能为空");
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ public class MiMoServiceImpl implements AbstractChatService {
|
||||
public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) {
|
||||
return OpenAiStreamingChatModel.builder()
|
||||
.baseUrl(chatModelVo.getApiHost())
|
||||
.apiKey(chatModelVo.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.listeners(List.of(new MyChatModelListener()))
|
||||
.returnThinking(chatRequest.getEnableThinking())
|
||||
|
||||
@@ -45,7 +45,7 @@ public class MinimaxServiceImpl implements AbstractChatService {
|
||||
if (isAnthropicBaseUrl(baseUrl)) {
|
||||
return AnthropicStreamingChatModel.builder()
|
||||
.baseUrl(toAnthropicClientBaseUrl(baseUrl))
|
||||
.apiKey(chatModelVo.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.timeout(DEFAULT_TIMEOUT)
|
||||
.listeners(List.of(new MyChatModelListener()))
|
||||
@@ -56,7 +56,7 @@ public class MinimaxServiceImpl implements AbstractChatService {
|
||||
|
||||
OpenAiStreamingChatModel.OpenAiStreamingChatModelBuilder builder = OpenAiStreamingChatModel.builder()
|
||||
.baseUrl(baseUrl)
|
||||
.apiKey(chatModelVo.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.timeout(DEFAULT_TIMEOUT)
|
||||
.listeners(List.of(new MyChatModelListener()))
|
||||
@@ -73,14 +73,14 @@ public class MinimaxServiceImpl implements AbstractChatService {
|
||||
if (isAnthropicBaseUrl(baseUrl)) {
|
||||
return AnthropicChatModel.builder()
|
||||
.baseUrl(toAnthropicClientBaseUrl(baseUrl))
|
||||
.apiKey(chatModelVo.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.timeout(DEFAULT_TIMEOUT)
|
||||
.build();
|
||||
}
|
||||
return OpenAiChatModel.builder()
|
||||
.baseUrl(baseUrl)
|
||||
.apiKey(chatModelVo.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.timeout(DEFAULT_TIMEOUT)
|
||||
.build();
|
||||
|
||||
@@ -32,7 +32,7 @@ public class OpenAIServiceImpl implements AbstractChatService {
|
||||
public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo,ChatRequest chatRequest) {
|
||||
return OpenAiStreamingChatModel.builder()
|
||||
.baseUrl(chatModelVo.getApiHost())
|
||||
.apiKey(chatModelVo.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.timeout(Duration.ofMinutes(30))
|
||||
.listeners(List.of(new MyChatModelListener()))
|
||||
|
||||
@@ -6,7 +6,6 @@ import dev.langchain4j.model.openai.OpenAiChatModel;
|
||||
import dev.langchain4j.model.openai.OpenAiStreamingChatModel;
|
||||
import org.ruoyi.common.chat.domain.dto.request.ChatRequest;
|
||||
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
|
||||
import org.ruoyi.common.chat.security.ChatModelCredentialPolicy;
|
||||
import org.ruoyi.enums.ChatModeType;
|
||||
import org.ruoyi.observability.MyChatModelListener;
|
||||
import org.ruoyi.service.chat.AbstractChatService;
|
||||
@@ -21,10 +20,10 @@ public class PpioChatServiceImpl implements AbstractChatService {
|
||||
|
||||
@Override
|
||||
public StreamingChatModel buildStreamingChatModel(ChatModelVo config, ChatRequest request) {
|
||||
String baseUrl = validateConfiguration(config);
|
||||
String baseUrl = normalizeBaseUrl(config.getApiHost());
|
||||
return OpenAiStreamingChatModel.builder()
|
||||
.baseUrl(baseUrl)
|
||||
.apiKey(config.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.apiKey(config.getApiKey())
|
||||
.modelName(config.getModelName())
|
||||
.listeners(List.of(new MyChatModelListener()))
|
||||
.returnThinking(Boolean.TRUE.equals(request.getEnableThinking()))
|
||||
@@ -34,19 +33,22 @@ public class PpioChatServiceImpl implements AbstractChatService {
|
||||
|
||||
@Override
|
||||
public ChatModel buildChatModel(ChatModelVo config) {
|
||||
String baseUrl = validateConfiguration(config);
|
||||
String baseUrl = normalizeBaseUrl(config.getApiHost());
|
||||
return OpenAiChatModel.builder()
|
||||
.baseUrl(baseUrl)
|
||||
.apiKey(config.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.apiKey(config.getApiKey())
|
||||
.modelName(config.getModelName())
|
||||
.listeners(List.of(new MyChatModelListener()))
|
||||
.timeout(Duration.ofMinutes(3))
|
||||
.build();
|
||||
}
|
||||
|
||||
private String validateConfiguration(ChatModelVo config) {
|
||||
return ChatModelCredentialPolicy.requirePpioConfiguration(
|
||||
config.getProviderCode(), config.getModelName(), config.getApiHost(), config.getApiKey());
|
||||
private String normalizeBaseUrl(String apiHost) {
|
||||
if (apiHost == null) {
|
||||
return null;
|
||||
}
|
||||
String baseUrl = apiHost.replaceAll("/+$", "");
|
||||
return baseUrl.endsWith("/openai") ? baseUrl + "/v1" : baseUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -34,7 +34,7 @@ public class QianWenChatServiceImpl implements AbstractChatService {
|
||||
@Override
|
||||
public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo,ChatRequest chatRequest) {
|
||||
return QwenStreamingChatModel.builder()
|
||||
.apiKey(chatModelVo.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.listeners(List.of(new MyChatModelListener()))
|
||||
.build();
|
||||
@@ -43,7 +43,7 @@ public class QianWenChatServiceImpl implements AbstractChatService {
|
||||
@Override
|
||||
public ChatModel buildChatModel(ChatModelVo chatModelVo) {
|
||||
return QwenChatModel.builder()
|
||||
.apiKey(chatModelVo.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ public class ZhiPuChatServiceImpl implements AbstractChatService {
|
||||
@Override
|
||||
public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) {
|
||||
return ZhipuAiStreamingChatModel.builder()
|
||||
.apiKey(chatModelVo.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.model(chatModelVo.getModelName())
|
||||
.maxToken(MAX_TOKENS)
|
||||
.readTimeout(Duration.ofSeconds(300))
|
||||
@@ -45,7 +45,7 @@ public class ZhiPuChatServiceImpl implements AbstractChatService {
|
||||
@Override
|
||||
public ChatModel buildChatModel(ChatModelVo chatModelVo) {
|
||||
return ZhipuAiChatModel.builder()
|
||||
.apiKey(chatModelVo.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.model(chatModelVo.getModelName())
|
||||
.maxToken(MAX_TOKENS)
|
||||
.readTimeout(Duration.ofSeconds(300))
|
||||
|
||||
@@ -37,7 +37,7 @@ public class AliBaiLianBaseEmbedProvider extends OpenAiEmbeddingProvider {
|
||||
@Override
|
||||
public Response<List<Embedding>> embedAll(List<TextSegment> textSegments) {
|
||||
EmbeddingModel model = QwenEmbeddingModel.builder()
|
||||
.apiKey(chatModelVo.resolveApiKeyForConfiguredEndpoint("qianwen"))
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.dimension(chatModelVo.getModelDimension())
|
||||
.build();
|
||||
|
||||
@@ -203,7 +203,7 @@ public class AliBaiLianMultiEmbeddingProvider implements MultiModalEmbedModelSer
|
||||
Request httpRequest = new Request.Builder()
|
||||
.url(chatModelVo.getApiHost())
|
||||
.addHeader("Authorization", "Bearer "
|
||||
+ chatModelVo.resolveApiKeyForConfiguredEndpoint("qianwen"))
|
||||
+ chatModelVo.getApiKey())
|
||||
.post(body)
|
||||
.build();
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ public class OpenAiEmbeddingProvider implements BaseEmbedModelService {
|
||||
public Response<List<Embedding>> embedAll(List<TextSegment> textSegments) {
|
||||
EmbeddingModel model = OpenAiEmbeddingModel.builder()
|
||||
.baseUrl(chatModelVo.getApiHost())
|
||||
.apiKey(chatModelVo.resolveApiKeyForConfiguredEndpoint("openai"))
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.dimensions(chatModelVo.getModelDimension())
|
||||
.build();
|
||||
|
||||
@@ -39,7 +39,7 @@ public class ZhipuAiEmbeddingProvider implements BaseEmbedModelService {
|
||||
public Response<List<Embedding>> embedAll(List<TextSegment> textSegments) {
|
||||
EmbeddingModel model = ZhipuAiEmbeddingModel.builder()
|
||||
.baseUrl(chatModelVo.getApiHost())
|
||||
.apiKey(chatModelVo.resolveApiKeyForConfiguredEndpoint("zhipu"))
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.model(chatModelVo.getModelName())
|
||||
.dimensions(chatModelVo.getModelDimension())
|
||||
.build();
|
||||
|
||||
@@ -56,7 +56,7 @@ public class AtlasImageGenerationServiceImpl extends AbstractImageGenerationServ
|
||||
|
||||
Request request = new Request.Builder()
|
||||
.url(AtlasMediaSupport.endpoint(chatModelVo.getApiHost(), "/model/generateImage"))
|
||||
.addHeader("Authorization", "Bearer " + chatModelVo.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.addHeader("Authorization", "Bearer " + chatModelVo.getApiKey())
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(RequestBody.create(payload.toString(), AtlasMediaSupport.JSON))
|
||||
.build();
|
||||
@@ -173,7 +173,7 @@ public class AtlasImageGenerationServiceImpl extends AbstractImageGenerationServ
|
||||
|
||||
Request request = new Request.Builder()
|
||||
.url(AtlasMediaSupport.endpoint(chatModelVo.getApiHost(), "/model/generateImage"))
|
||||
.addHeader("Authorization", "Bearer " + chatModelVo.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.addHeader("Authorization", "Bearer " + chatModelVo.getApiKey())
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(RequestBody.create(payload.toString(), AtlasMediaSupport.JSON))
|
||||
.build();
|
||||
@@ -217,7 +217,7 @@ public class AtlasImageGenerationServiceImpl extends AbstractImageGenerationServ
|
||||
.build();
|
||||
Request request = new Request.Builder()
|
||||
.url(AtlasMediaSupport.endpoint(model.getApiHost(), "/model/uploadMedia"))
|
||||
.addHeader("Authorization", "Bearer " + model.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.addHeader("Authorization", "Bearer " + model.getApiKey())
|
||||
.post(requestBody)
|
||||
.build();
|
||||
try (Response response = okHttpClient.newCall(request).execute()) {
|
||||
|
||||
@@ -77,7 +77,7 @@ public class OpenAiImageGenerationServiceImpl extends AbstractImageGenerationSer
|
||||
private String postJson(ChatModelVo model, String path, String jsonBody) throws IOException {
|
||||
Request request = new Request.Builder()
|
||||
.url(OpenAiMediaSupport.endpoint(model.getApiHost(), path))
|
||||
.addHeader("Authorization", "Bearer " + model.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.addHeader("Authorization", "Bearer " + model.getApiKey())
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(RequestBody.create(jsonBody, OpenAiMediaSupport.JSON))
|
||||
.build();
|
||||
|
||||
@@ -60,7 +60,7 @@ public class TongYiWanxImageServiceImpl extends AbstractImageGenerationService {
|
||||
protected Object buildImageModel(ChatModelVo chatModelVo) {
|
||||
return ImageSynthesisParam.builder()
|
||||
.prompt("")
|
||||
.apiKey(chatModelVo.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.model(chatModelVo.getModelName())
|
||||
.n(IMAGE_DEFAULT_SIZE)
|
||||
.build();
|
||||
|
||||
@@ -31,7 +31,7 @@ public class AtlasPredictionService {
|
||||
Request request = new Request.Builder()
|
||||
.url(AtlasMediaSupport.endpoint(model.getApiHost(), "/model/prediction/" + predictionId))
|
||||
.addHeader("Authorization", "Bearer "
|
||||
+ model.resolveApiKeyForConfiguredEndpoint("atlas"))
|
||||
+ model.getApiKey())
|
||||
.get()
|
||||
.build();
|
||||
try (Response response = okHttpClient.newCall(request).execute()) {
|
||||
|
||||
@@ -88,7 +88,7 @@ public class AliBaiLianRerankModelService implements RerankModelService {
|
||||
Request httpRequest = new Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("Authorization", "Bearer "
|
||||
+ chatModelVo.resolveApiKeyForConfiguredEndpoint("qianwen"))
|
||||
+ chatModelVo.getApiKey())
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(body)
|
||||
.build();
|
||||
|
||||
@@ -60,7 +60,7 @@ public class SiliconFlowRerankModelService implements RerankModelService {
|
||||
Request httpRequest = new Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("Authorization", "Bearer "
|
||||
+ chatModelVo.resolveApiKeyForConfiguredEndpoint("siliconflow"))
|
||||
+ chatModelVo.getApiKey())
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(body)
|
||||
.build();
|
||||
|
||||
@@ -90,7 +90,7 @@ public class ZhiPuRerankModelService implements RerankModelService {
|
||||
|
||||
// 生成智谱认证Token
|
||||
String token = generateToken(
|
||||
chatModelVo.resolveApiKeyForConfiguredEndpoint("zhipu"));
|
||||
chatModelVo.getApiKey());
|
||||
|
||||
// 智谱重排序固定端点路径
|
||||
String url = chatModelVo.getApiHost() + "/api/paas/v4/rerank";
|
||||
|
||||
@@ -78,7 +78,7 @@ public class AtlasVideoGenerationServiceImpl extends AbstractVideoGenerationServ
|
||||
|
||||
Request request = new Request.Builder()
|
||||
.url(AtlasMediaSupport.endpoint(model.getApiHost(), "/model/generateVideo"))
|
||||
.addHeader("Authorization", "Bearer " + model.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.addHeader("Authorization", "Bearer " + model.getApiKey())
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(RequestBody.create(payload.toString(), AtlasMediaSupport.JSON))
|
||||
.build();
|
||||
|
||||
@@ -101,7 +101,7 @@ public class OpenAiVideoGenerationServiceImpl extends AbstractVideoGenerationSer
|
||||
private String postJson(ChatModelVo model, String path, String jsonBody) throws IOException {
|
||||
Request request = new Request.Builder()
|
||||
.url(OpenAiMediaSupport.endpoint(model.getApiHost(), path))
|
||||
.addHeader("Authorization", "Bearer " + model.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.addHeader("Authorization", "Bearer " + model.getApiKey())
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(RequestBody.create(jsonBody, OpenAiMediaSupport.JSON))
|
||||
.build();
|
||||
@@ -111,7 +111,7 @@ public class OpenAiVideoGenerationServiceImpl extends AbstractVideoGenerationSer
|
||||
private String getJson(ChatModelVo model, String path) throws IOException {
|
||||
Request request = new Request.Builder()
|
||||
.url(OpenAiMediaSupport.endpoint(model.getApiHost(), path))
|
||||
.addHeader("Authorization", "Bearer " + model.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.addHeader("Authorization", "Bearer " + model.getApiKey())
|
||||
.get()
|
||||
.build();
|
||||
return execute(request, "OpenAI Videos API查询失败");
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package org.ruoyi.factory;
|
||||
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
|
||||
import org.ruoyi.common.chat.service.chat.IChatModelService;
|
||||
import org.ruoyi.observability.EmbeddingModelListenerProvider;
|
||||
import org.ruoyi.service.embed.BaseEmbedModelService;
|
||||
import org.ruoyi.service.rerank.RerankModelService;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@Tag("dev")
|
||||
class ModelKeyRotationTest {
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"same", "key", "host", "disabled"})
|
||||
void embeddingCacheUsesCurrentConfiguration(String change) {
|
||||
var context = mock(ApplicationContext.class);
|
||||
var configs = mock(IChatModelService.class);
|
||||
var listeners = mock(EmbeddingModelListenerProvider.class);
|
||||
when(listeners.getEmbeddingModelListeners()).thenReturn(List.of());
|
||||
var first = mock(BaseEmbedModelService.class);
|
||||
var next = mock(BaseEmbedModelService.class);
|
||||
when(context.getBean("alibailian", BaseEmbedModelService.class)).thenReturn(first, next);
|
||||
var initial = configuration("same");
|
||||
var updated = configuration(change);
|
||||
when(configs.selectModelByName("test-model")).thenReturn(initial, updated);
|
||||
var factory = new EmbeddingModelFactory(context, configs, listeners);
|
||||
assertSame(first, factory.createModel("test-model"));
|
||||
if ("disabled".equals(change)) {
|
||||
when(configs.selectModelByName("test-model")).thenThrow(new IllegalArgumentException("disabled"));
|
||||
assertThrows(IllegalArgumentException.class, () -> factory.createModel("test-model"));
|
||||
} else if ("same".equals(change)) {
|
||||
assertSame(first, factory.createModel("test-model"));
|
||||
verify(context, times(1)).getBean("alibailian", BaseEmbedModelService.class);
|
||||
} else {
|
||||
assertSame(next, factory.createModel("test-model"));
|
||||
verify(next).configure(updated);
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"same", "key", "host", "disabled"})
|
||||
void rerankCacheUsesCurrentConfiguration(String change) {
|
||||
var context = mock(ApplicationContext.class);
|
||||
var configs = mock(IChatModelService.class);
|
||||
var first = mock(RerankModelService.class);
|
||||
var next = mock(RerankModelService.class);
|
||||
when(context.getBean("qianwenRerank", RerankModelService.class)).thenReturn(first, next);
|
||||
var initial = configuration("same");
|
||||
var updated = configuration(change);
|
||||
when(configs.selectModelByName("test-model")).thenReturn(initial, updated);
|
||||
var factory = new RerankModelFactory(context, configs);
|
||||
assertSame(first, factory.createModel("test-model"));
|
||||
if ("disabled".equals(change)) {
|
||||
when(configs.selectModelByName("test-model")).thenThrow(new IllegalArgumentException("disabled"));
|
||||
assertThrows(IllegalArgumentException.class, () -> factory.createModel("test-model"));
|
||||
} else if ("same".equals(change)) {
|
||||
assertSame(first, factory.createModel("test-model"));
|
||||
verify(context, times(1)).getBean("qianwenRerank", RerankModelService.class);
|
||||
} else {
|
||||
assertSame(next, factory.createModel("test-model"));
|
||||
verify(next).configure(updated);
|
||||
}
|
||||
}
|
||||
|
||||
private ChatModelVo configuration(String change) {
|
||||
var config = new ChatModelVo();
|
||||
config.setId(1L);
|
||||
config.setModelName("test-model");
|
||||
config.setProviderCode("qianwen");
|
||||
config.setApiKey("key".equals(change) ? "test-new-key" : "test-old-key");
|
||||
config.setApiHost("host".equals(change) ? "https://new.example/v1" : "https://old.example/v1");
|
||||
return config;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package org.ruoyi.service.chat.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.NullAndEmptySource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo;
|
||||
import org.ruoyi.common.chat.entity.chat.ChatModel;
|
||||
import org.ruoyi.mapper.chat.ChatModelMapper;
|
||||
import org.ruoyi.service.chat.IChatProviderService;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@Tag("dev")
|
||||
class ChatModelApiKeyTest {
|
||||
private final ChatModelMapper mapper = mock(ChatModelMapper.class);
|
||||
private final IChatProviderService providers = mock(IChatProviderService.class);
|
||||
private final ChatModelServiceImpl service = new ChatModelServiceImpl(mapper, providers);
|
||||
|
||||
@BeforeAll
|
||||
static void initializeTableMetadata() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"), ChatModel.class);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"qianwen", "zhipu", "openai", "deepseek", "ppio", "atlas",
|
||||
"minimax", "dify", "coze", "xiaomi", "custom_api", "custom_anthropic", "ollama"})
|
||||
void insertPersistsExactlyTheSuppliedKey(String provider) {
|
||||
var command = command(provider, " test-key/with:any-format ");
|
||||
when(mapper.insert(any(ChatModel.class))).thenReturn(1);
|
||||
assertTrue(service.insertByBo(command));
|
||||
var saved = ArgumentCaptor.forClass(ChatModel.class);
|
||||
verify(mapper).insert(saved.capture());
|
||||
assertEquals(command.getApiKey(), saved.getValue().getApiKey());
|
||||
assertEquals(command.getApiHost(), saved.getValue().getApiHost());
|
||||
verify(providers).requireEnabled(provider);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"sk-new-key", " env:LITERAL_VALUE ", "env:ATLAS_API_KEY"})
|
||||
void editReplacesAnOldReferenceWithExactlyTheSuppliedValue(String key) {
|
||||
when(mapper.selectOne(any())).thenReturn(currentModel());
|
||||
when(mapper.updateById(any(ChatModel.class))).thenReturn(1);
|
||||
assertTrue(service.updateByBo(command("qianwen", key)));
|
||||
var saved = ArgumentCaptor.forClass(ChatModel.class);
|
||||
verify(mapper).updateById(saved.capture());
|
||||
assertEquals(key, saved.getValue().getApiKey());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@NullAndEmptySource
|
||||
void editWithoutANewKeyDoesNotOverwriteTheExistingKey(String key) {
|
||||
when(mapper.selectOne(any())).thenReturn(currentModel());
|
||||
when(mapper.updateById(any(ChatModel.class))).thenReturn(1);
|
||||
assertTrue(service.updateByBo(command("qianwen", key)));
|
||||
var saved = ArgumentCaptor.forClass(ChatModel.class);
|
||||
verify(mapper).updateById(saved.capture());
|
||||
assertNull(saved.getValue().getApiKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
void batchUpdateUsesTheSuppliedKeyAndOnlyTheSelectedProvider() {
|
||||
when(mapper.update(isNull(), any(Wrapper.class))).thenReturn(2);
|
||||
String key = " raw-batch-key ";
|
||||
assertTrue(service.updateApiKeyByProvider("qianwen", key));
|
||||
var update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
|
||||
verify(mapper).update(isNull(), update.capture());
|
||||
var wrapper = update.getValue();
|
||||
assertTrue(wrapper.getSqlSet().contains("api_key"));
|
||||
assertTrue(wrapper.getSqlSegment().contains("provider_code"));
|
||||
assertTrue(wrapper.getParamNameValuePairs().containsValue(key));
|
||||
assertTrue(wrapper.getParamNameValuePairs().containsValue("qianwen"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyBatchKeyCannotEraseProviderCredentials() {
|
||||
assertThrows(IllegalArgumentException.class, () -> service.updateApiKeyByProvider("qianwen", ""));
|
||||
verifyNoInteractions(mapper);
|
||||
}
|
||||
|
||||
private ChatModelBo command(String provider, String key) {
|
||||
var command = new ChatModelBo();
|
||||
command.setId(1L);
|
||||
command.setCategory("embedding");
|
||||
command.setProviderCode(provider);
|
||||
command.setModelName("text-embedding-v3");
|
||||
command.setApiHost("http://localhost:11434/v1");
|
||||
command.setApiKey(key);
|
||||
return command;
|
||||
}
|
||||
|
||||
private ChatModel currentModel() {
|
||||
var current = new ChatModel();
|
||||
current.setId(1L);
|
||||
current.setProviderCode("qianwen");
|
||||
current.setApiKey("env:OLD_API_KEY");
|
||||
return current;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.ruoyi.service.chat.impl;
|
||||
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo;
|
||||
import org.ruoyi.common.chat.entity.chat.ChatModel;
|
||||
import org.ruoyi.mapper.chat.ChatModelMapper;
|
||||
import org.ruoyi.service.chat.IChatProviderService;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@Tag("dev")
|
||||
class ChatModelDifyConfigurationTest {
|
||||
private static final String KEY = "app-test-key";
|
||||
private final ChatModelMapper mapper = mock(ChatModelMapper.class);
|
||||
private final IChatProviderService providers = mock(IChatProviderService.class);
|
||||
private final ChatModelServiceImpl service = new ChatModelServiceImpl(mapper, providers);
|
||||
|
||||
@Test
|
||||
void insertsDifyWithTheAdminConfiguredAppKey() {
|
||||
when(mapper.insert(any(ChatModel.class))).thenReturn(1);
|
||||
assertTrue(service.insertByBo(command()));
|
||||
var saved = ArgumentCaptor.forClass(ChatModel.class);
|
||||
verify(mapper).insert(saved.capture());
|
||||
assertEquals(KEY, saved.getValue().getApiKey());
|
||||
verify(providers).requireEnabled("dify");
|
||||
}
|
||||
|
||||
@Test
|
||||
void replacesLegacyDifyPlaceholderThroughNormalModelEdit() {
|
||||
var current = new ChatModel();
|
||||
current.setId(1L);
|
||||
current.setProviderCode("dify");
|
||||
current.setModelName("dify-chat");
|
||||
current.setApiHost("https://api.dify.ai/v1");
|
||||
current.setApiKey("替换为你的DIFY_APP_API_KEY");
|
||||
when(mapper.selectOne(any())).thenReturn(current);
|
||||
when(mapper.updateById(any(ChatModel.class))).thenReturn(1);
|
||||
assertTrue(service.updateByBo(command()));
|
||||
var saved = ArgumentCaptor.forClass(ChatModel.class);
|
||||
verify(mapper).updateById(saved.capture());
|
||||
assertEquals(KEY, saved.getValue().getApiKey());
|
||||
verify(providers).requireEnabled("dify");
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsASelfHostedDifyEndpoint() {
|
||||
var command = command();
|
||||
command.setApiHost("http://localhost:8080/v1");
|
||||
when(mapper.insert(any(ChatModel.class))).thenReturn(1);
|
||||
assertTrue(service.insertByBo(command));
|
||||
var saved = ArgumentCaptor.forClass(ChatModel.class);
|
||||
verify(mapper).insert(saved.capture());
|
||||
assertEquals(command.getApiHost(), saved.getValue().getApiHost());
|
||||
assertEquals(KEY, saved.getValue().getApiKey());
|
||||
}
|
||||
|
||||
private ChatModelBo command() {
|
||||
var command = new ChatModelBo();
|
||||
command.setId(1L);
|
||||
command.setCategory("chat");
|
||||
command.setProviderCode("dify");
|
||||
command.setModelName("dify-chat");
|
||||
command.setApiHost("https://api.dify.ai/v1");
|
||||
command.setApiKey(KEY);
|
||||
return command;
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,29 @@ package org.ruoyi.service.chat.impl.provider;
|
||||
|
||||
import dev.langchain4j.model.chat.ChatModel;
|
||||
import dev.langchain4j.model.chat.StreamingChatModel;
|
||||
import dev.langchain4j.data.message.UserMessage;
|
||||
import dev.langchain4j.model.chat.response.ChatResponse;
|
||||
import dev.langchain4j.model.chat.response.StreamingChatResponseHandler;
|
||||
import io.github.imfangs.dify.client.DifyChatClient;
|
||||
import io.github.imfangs.dify.client.DifyClientFactory;
|
||||
import io.github.imfangs.dify.client.callback.ChatStreamCallback;
|
||||
import io.github.imfangs.dify.client.enums.ResponseMode;
|
||||
import io.github.imfangs.dify.client.event.MessageEvent;
|
||||
import io.github.imfangs.dify.client.event.MessageEndEvent;
|
||||
import io.github.imfangs.dify.client.model.DifyConfig;
|
||||
import io.github.imfangs.dify.client.model.chat.ChatMessageResponse;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.ruoyi.common.chat.domain.dto.request.ChatRequest;
|
||||
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
|
||||
import org.ruoyi.enums.ChatModeType;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@Tag("dev")
|
||||
class DifyChatServiceImplTest {
|
||||
@@ -35,12 +50,64 @@ class DifyChatServiceImplTest {
|
||||
assertNotNull(model);
|
||||
}
|
||||
|
||||
@Test
|
||||
void blockingCallPassesAdminConfiguredAppKeyAndCloudBaseUrlToSdk() throws Exception {
|
||||
var config = modelVo();
|
||||
var client = mock(DifyChatClient.class);
|
||||
when(client.sendChatMessage(any())).thenReturn(ChatMessageResponse.builder()
|
||||
.answer("Hello from Dify").messageId("message-1").build());
|
||||
try (var factory = mockStatic(DifyClientFactory.class)) {
|
||||
factory.when(() -> DifyClientFactory.createChatClient(any(DifyConfig.class))).thenReturn(client);
|
||||
var response = service.buildChatModel(config).doChat(request());
|
||||
assertEquals("Hello from Dify", response.aiMessage().text());
|
||||
factory.verify(() -> DifyClientFactory.createChatClient(argThat(value ->
|
||||
"https://api.dify.ai/v1".equals(value.getBaseUrl())
|
||||
&& "app-test-key".equals(value.getApiKey()))));
|
||||
verify(client).sendChatMessage(argThat(message -> message.getResponseMode() == ResponseMode.BLOCKING
|
||||
&& message.getInputs().isEmpty()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamingCallPassesAdminConfiguredAppKeyAndDeliversResponse() throws Exception {
|
||||
var config = modelVo();
|
||||
config.setApiHost("https://api.dify.ai/v1/");
|
||||
var client = mock(DifyChatClient.class);
|
||||
doAnswer(invocation -> {
|
||||
ChatStreamCallback callback = invocation.getArgument(1);
|
||||
var chunk = new MessageEvent();
|
||||
chunk.setAnswer("Hello from Dify");
|
||||
callback.onMessage(chunk);
|
||||
callback.onMessageEnd(new MessageEndEvent());
|
||||
return null;
|
||||
}).when(client).sendChatMessageStream(any(), any());
|
||||
var handler = mock(StreamingChatResponseHandler.class);
|
||||
try (var factory = mockStatic(DifyClientFactory.class)) {
|
||||
factory.when(() -> DifyClientFactory.createChatClient(any(DifyConfig.class))).thenReturn(client);
|
||||
service.buildStreamingChatModel(config, new ChatRequest()).doChat(request(), handler);
|
||||
factory.verify(() -> DifyClientFactory.createChatClient(argThat(value ->
|
||||
"https://api.dify.ai/v1".equals(value.getBaseUrl())
|
||||
&& "app-test-key".equals(value.getApiKey()))));
|
||||
verify(client).sendChatMessageStream(argThat(message -> message.getResponseMode() == ResponseMode.STREAMING), any());
|
||||
verify(handler).onPartialResponse("Hello from Dify");
|
||||
var response = ArgumentCaptor.forClass(ChatResponse.class);
|
||||
verify(handler).onCompleteResponse(response.capture());
|
||||
assertEquals("Hello from Dify", response.getValue().aiMessage().text());
|
||||
verify(handler, never()).onError(any());
|
||||
}
|
||||
}
|
||||
|
||||
private dev.langchain4j.model.chat.request.ChatRequest request() {
|
||||
return dev.langchain4j.model.chat.request.ChatRequest.builder()
|
||||
.messages(UserMessage.from("Hello")).build();
|
||||
}
|
||||
|
||||
private ChatModelVo modelVo() {
|
||||
ChatModelVo modelVo = new ChatModelVo();
|
||||
modelVo.setProviderCode(ChatModeType.DIFY.getCode());
|
||||
modelVo.setModelName("dify-chat");
|
||||
modelVo.setApiHost("https://api.dify.ai/v1");
|
||||
modelVo.setApiKey("app-test");
|
||||
modelVo.setApiKey("app-test-key");
|
||||
return modelVo;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.ruoyi.service.embed.impl;
|
||||
|
||||
import dev.langchain4j.community.model.dashscope.QwenEmbeddingModel;
|
||||
import dev.langchain4j.data.embedding.Embedding;
|
||||
import dev.langchain4j.data.segment.TextSegment;
|
||||
import dev.langchain4j.model.output.Response;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@Tag("dev")
|
||||
class QianwenAdminApiKeyTest {
|
||||
@Test
|
||||
void embeddingUsesTheAdminConfiguredKeyWithoutEnvironmentVariables() {
|
||||
var config = new ChatModelVo();
|
||||
config.setProviderCode("qianwen");
|
||||
config.setModelName("text-embedding-v3");
|
||||
config.setModelDimension(1024);
|
||||
config.setApiKey("sk-test-qianwen-key");
|
||||
var provider = new AliBaiLianBaseEmbedProvider();
|
||||
provider.configure(config);
|
||||
var builder = spy(QwenEmbeddingModel.builder());
|
||||
var client = mock(QwenEmbeddingModel.class);
|
||||
var response = Response.from(List.of(Embedding.from(new float[]{1, 0})));
|
||||
doReturn(client).when(builder).build();
|
||||
when(client.embedAll(anyList())).thenReturn(response);
|
||||
try (var factory = mockStatic(QwenEmbeddingModel.class)) {
|
||||
factory.when(QwenEmbeddingModel::builder).thenReturn(builder);
|
||||
assertSame(response, provider.embedAll(List.of(TextSegment.from("test document"))));
|
||||
verify(builder).apiKey("sk-test-qianwen-key");
|
||||
verify(builder).modelName("text-embedding-v3");
|
||||
verify(client).embedAll(anyList());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user