fix: restore admin-managed model keys and refresh cached clients

This commit is contained in:
ageerle
2026-09-21 11:08:05 +08:00
parent 91ca91571b
commit 8e3b286190
52 changed files with 675 additions and 545 deletions

View File

@@ -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) {

View File

@@ -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();
}
/**

View File

@@ -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();
}
/**

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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;
}
}

View File

@@ -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();
}

View File

@@ -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())

View File

@@ -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");
}

View File

@@ -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

View File

@@ -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());
}
}

View File

@@ -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"));
}

View File

@@ -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 不能为空");
}

View File

@@ -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())

View File

@@ -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();

View File

@@ -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()))

View File

@@ -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

View File

@@ -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();
}

View File

@@ -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))

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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()) {

View File

@@ -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();

View File

@@ -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();

View File

@@ -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()) {

View File

@@ -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();

View File

@@ -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();

View File

@@ -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";

View File

@@ -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();

View File

@@ -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查询失败");

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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());
}
}
}