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

@@ -209,6 +209,9 @@ Want to learn more about installation, deployment, configuration, and secondary
**👉 [Complete Documentation](https://doc.ruoyiai.chat/)**
- [Model and API Key configuration in ruoyi-admin (Chinese)](docs/model-configuration.md)
- [Dify Cloud setup for local development (Chinese)](docs/dify-cloud.md)
## 🤝 Contributing
We warmly welcome community contributions! Whether you are a seasoned developer or just getting started, you can contribute to the project 💪

View File

@@ -205,6 +205,9 @@ docker-compose up -d --build
**👉 [完整使用文档](https://doc.ruoyiai.chat/)**
- [在 ruoyi-admin 中配置模型和 API Key](docs/model-configuration.md)
- [本地开发接入 Dify 官方云服务](docs/dify-cloud.md)
## 🤝 参与贡献
我们热烈欢迎社区贡献!无论您是资深开发者还是初学者,都可以为项目贡献力量 💪

50
docs/dify-cloud.md Normal file
View File

@@ -0,0 +1,50 @@
# Dify 官方云服务配置
本指南适用于本地运行 RuoYi-AI Java 后端、连接 Dify 官方云服务的场景。
## 准备 Dify 应用
在 Dify 中创建并发布聊天应用,先在 Dify 自己的调试页面确认可以正常回答。首次接入可以使用 Chatbot(聊天助手)。在该应用的 API 访问页面创建应用 API Key。
应用 Key 对应具体的 Dify 应用;知识库 Key 不能用于聊天。Dify 官方云服务的 API 基础地址为 `https://api.dify.ai/v1`。参见 [Dify API 入门](https://docs.dify.ai/en/api-reference/guides/get-started) 和 [聊天应用 API](https://docs.dify.ai/en/api-reference/guides/chat)。
## 在后台配置 Key
API Key 统一在 **ruoyi-admin → 对话管理 → 模型管理** 中直接填写并保存,无需配置后端环境变量。
## 模型管理填写示例
先确认厂商管理中的 `Dify` 已启用,再在模型管理中修改 Dify 模型:
| 字段 | 填写内容 |
| --- | --- |
| 模型分类 | 聊天(`chat`) |
| 模型供应商 | Dify(编码 `dify`) |
| 模型名称 | `dify-chat` |
| 模型描述 | Dify 聊天助手 |
| 请求地址 | `https://api.dify.ai/v1` |
| 密钥 | 从 Dify 应用复制的真实 API Key,如 `app-...` |
| 是否显示 | 是 |
直接填写真实应用 Key,不需要 `env:` 或 `Bearer ` 前缀。编辑时 Key 不回显;留空保留原 Key,填写新值会替换原值。保存后新的调用读取最新配置,无需重启后端。
请求地址填写到 `/v1`,不要追加 `/chat-messages`,也不要填写 Dify 应用的网页分享链接。模型名称是本系统的选择标识,可以取一个不重复的应用名称;实际调用哪个 Dify 应用由应用 Key 决定。
已有数据库直接在模型管理中按上述内容保存即可,无需重新导入全量 SQL。保存后在聊天页面选择该模型,发送一条简单消息验证。
## 适用范围
- 官方云服务使用 `https://api.dify.ai/v1`;自建 Dify 填写后端可以访问的服务 API 地址。
- 不同模型可以填写不同的应用 Key,分别连接对应的 Dify 应用。
- 当前适配器使用聊天消息接口,普通 Workflow 应用不能通过此模型配置调用。
- 当前请求的自定义 `inputs` 为空。接入的聊天应用应能在不提供额外必填输入变量的情况下运行。
- 当前聊天适配器没有实现 Dify 的 Human Input 表单交互和文件上传流程;首次验证请使用简单文本聊天应用。
## 常见情况
| 现象 | 检查方法 |
| --- | --- |
| 后台无法保存 | 检查厂商是否启用,以及模型分类、名称等必填项是否齐全。 |
| 编辑时密钥框为空 | 密钥不会回显;留空保存会保留原 Key。 |
| Dify 返回鉴权失败 | 在模型管理中填写当前应用的有效 API Key,检查复制时是否带入空格或 `Bearer ` 前缀。 |
| Dify 返回应用类型或输入参数错误 | 检查是否使用聊天应用,以及应用是否要求额外输入变量;先在 Dify 中验证应用可以回答。 |

View File

@@ -0,0 +1,33 @@
# 模型与 API Key 配置
模型的 API Key 统一在 **ruoyi-admin → 对话管理 → 模型管理** 中配置。
1. 在“厂商管理”中选择并启用对应厂商,填写服务 API 地址。
2. 在“模型管理”中新建或编辑模型,选择厂商、分类,填写服务商提供的模型 ID。
3. 在“密钥”框直接粘贴真实 API Key,保存模型。
4. 在 `ruoyi-web` 中选择模型使用;向量和重排模型则在对应知识库配置中选择。
需要 API Key 的厂商适配器使用该模型保存的 Key,不要求固定的 Key 前缀或环境变量名称。自定义 OpenAI、Anthropic 服务的地址与 Key 也在模型管理中配置,无需设置配对环境变量。无需鉴权的本地模型可以不填 Key。
## 修改与批量更新
- 编辑模型时 Key 不回显;留空保存会保留原 Key,输入新值会替换原 Key。
- 按厂商批量更新接口会将提交的 Key 保存到该厂商的模型中。
- 保存后新的模型调用会读取最新配置,无需重启后端。正在进行的请求继续使用开始时的配置。
- 模型列表、聊天用户端和导出文件不返回 Key。
## 已有模型
如果模型中原来保存的是 `env:ATLAS_API_KEY` 等文本,请在后台将其替换为真实 Key。密钥字段按填写内容直接使用,不解析 `env:` 引用。无需重新导入数据库。
## 示例
| 场景 | 厂商编码 | 密钥框填写内容 |
| --- | --- | --- |
| 千问聊天、向量或重排 | `qianwen` | 阿里云百炼的 API Key |
| DeepSeek | `deepseek` | DeepSeek 控制台创建的 API Key |
| Atlas 多媒体 | `atlas` | Atlas 控制台创建的 API Key |
| Dify 聊天应用 | `dify` | 对应应用的 `app-...` Key |
| 自定义 OpenAI / Anthropic | `custom_api` / `custom_anthropic` | 对应服务的 API Key |
填写的地址、模型 ID 和 Key 应对应同一个服务账号。模型是否可用、支持哪些能力以及额度限制,以服务商实际响应为准。

View File

@@ -115,9 +115,9 @@ CREATE TABLE `chat_model` (
-- ----------------------------
-- Records of chat_model
-- ----------------------------
INSERT INTO `chat_model` VALUES (2000585866022060033, 'chat', 'deepseek-v4-flash', 'deepseek', 'DeepSeek V4 Flash', NULL, 'Y', 'https://api.deepseek.com', 'env:DEEPSEEK_API_KEY', 103, 1, '2025-12-15 23:16:54', 1, '2026-08-30 00:00:00', '简单快速编程任务', 0);
INSERT INTO `chat_model` VALUES (2090000000000000001, 'chat', 'deepseek-v4-pro', 'deepseek', 'DeepSeek V4 Pro', NULL, 'Y', 'https://api.deepseek.com', 'env:DEEPSEEK_API_KEY', 103, 1, '2026-08-30 00:00:00', 1, '2026-08-30 00:00:00', '复杂编程、规划与审查', 0);
INSERT INTO `chat_model` VALUES (2090000000000000002, 'chat', 'deepseek-v4-flash-vision-exp', 'deepseek', 'DeepSeek V4 Flash Vision (experimental)', NULL, 'Y', 'https://api.deepseek.com', 'env:DEEPSEEK_API_KEY', 103, 1, '2026-08-30 00:00:00', 1, '2026-08-30 00:00:00', '带图编程任务的视觉证据提取', 0);
INSERT INTO `chat_model` VALUES (2000585866022060033, 'chat', 'deepseek-v4-flash', 'deepseek', 'DeepSeek V4 Flash', NULL, 'Y', 'https://api.deepseek.com', '', 103, 1, '2025-12-15 23:16:54', 1, '2026-08-30 00:00:00', '简单快速编程任务', 0);
INSERT INTO `chat_model` VALUES (2090000000000000001, 'chat', 'deepseek-v4-pro', 'deepseek', 'DeepSeek V4 Pro', NULL, 'Y', 'https://api.deepseek.com', '', 103, 1, '2026-08-30 00:00:00', 1, '2026-08-30 00:00:00', '复杂编程、规划与审查', 0);
INSERT INTO `chat_model` VALUES (2090000000000000002, 'chat', 'deepseek-v4-flash-vision-exp', 'deepseek', 'DeepSeek V4 Flash Vision (experimental)', NULL, 'Y', 'https://api.deepseek.com', '', 103, 1, '2026-08-30 00:00:00', 1, '2026-08-30 00:00:00', '带图编程任务的视觉证据提取', 0);
INSERT INTO `chat_model` VALUES (2007528268536287233, 'vector', 'embedding-3', 'zhipu', 'embedding-3', 2048, 'N', 'https://open.bigmodel.cn', 'sk_xx', 103, 1, '2026-01-04 03:03:32', 1, '2026-03-15 19:18:51', '向量模型', 0);
INSERT INTO `chat_model` VALUES (2045071617578237953, 'rerank', 'rerank', 'zhipu', 'rerank', NULL, 'N', 'https://open.bigmodel.cn', 'sk_xx', 103, 1, '2026-04-17 17:27:24', 1, '2026-04-20 15:21:48', '重排序模型', 0);
INSERT INTO `chat_model` VALUES (2000585866022060003, 'chat', 'deepseek-ai/deepseek-v4-flash', 'atlas', 'deepseek-v4-flash', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2025-12-15 23:16:54', 1, '2026-03-15 19:18:48', '对话模型', 0);
@@ -126,7 +126,7 @@ INSERT INTO `chat_model` VALUES (2060622000000000001, 'image', 'openai/gpt-image
INSERT INTO `chat_model` VALUES (2060622000000000002, 'image', 'openai/gpt-image-2/edit', 'atlas', 'GPT-IMAGE-2 图生图编辑', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-06-22 14:45:54', 1, '2026-06-22 14:45:54', 'Atlas Cloud 图片模型 - 图生图/编辑,支持基于参考图修改风格、纹理等', 0);
INSERT INTO `chat_model` VALUES (2060622000000000003, 'video', 'bytedance/seedance-2.0/image-to-video', 'atlas', 'Seedance 2.0 图生视频(字节跳动)', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-06-22 17:24:07', 1, '2026-06-22 17:24:07', 'Atlas Cloud 视频模型 - Seedance 2.0 图生视频,接收参考图+提示词生成视频', 0);
INSERT INTO `chat_model` VALUES (2060622000000000004, 'video', 'bytedance/seedance-2.0/reference-to-video', 'atlas', 'Seedance 2.0 多参考图生视频(字节跳动)', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-06-22 20:24:44', 1, '2026-06-22 20:24:44', 'Atlas Cloud 视频模型 - Seedance 2.0 多参考图生视频,接收多张参考图+带@imageN标记的提示词生成视频', 0);
INSERT INTO `chat_model` VALUES (2070700000000000002, 'chat', 'dify-chat', 'dify', 'Dify Chat App', NULL, 'Y', 'https://api.dify.ai/v1', '替换为你的DIFY_APP_API_KEY', 103, 1, '2026-07-14 11:03:44', 1, '2026-07-14 11:03:44', 'Dify 聊天应用;api_key 为 Dify App API Key,model_name 可按应用名修改', 0);
INSERT INTO `chat_model` VALUES (2070700000000000002, 'chat', 'dify-chat', 'dify', 'Dify Chat App', NULL, 'Y', 'https://api.dify.ai/v1', '', 103, 1, '2026-07-14 11:03:44', 1, '2026-07-14 11:03:44', 'Dify 聊天应用;真实 App API Key 在后台模型管理中填写,model_name 可按应用名修改', 0);
INSERT INTO `chat_model` VALUES (2070700000000000004, 'chat', '替换为你的COZE_BOT_ID', 'coze', 'Coze Bot', NULL, 'Y', 'https://api.coze.cn', '替换为你的COZE_PAT', 103, 1, '2026-07-14 11:03:38', 1, '2026-07-14 11:03:38', 'Coze 聊天 Bot;model_name 为 Coze Bot ID,api_key 为 PAT 或 OAuth access token', 0);
INSERT INTO `chat_model` VALUES (2070700000000000010, 'audio', 'bytedance/seed-audio-1.0', 'atlas', 'Seed Audio 1.0 语音生成(字节跳动)', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-07-16 18:00:00', 1, '2026-07-16 18:00:00', 'Atlas Cloud 语音模型 - 支持多角色对白配音,references 指定 speaker 音色,text 中用 @audioN 引用', 0);

View File

@@ -4,13 +4,10 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import io.github.linpeilie.annotations.AutoMapper;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import org.ruoyi.common.chat.entity.chat.ChatModel;
import org.ruoyi.common.chat.security.ChatModelCredentialPolicy;
import org.ruoyi.common.chat.security.ChatModelSecretReference;
import org.ruoyi.common.core.validate.AddGroup;
import org.ruoyi.common.core.validate.EditGroup;
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
@@ -68,19 +65,12 @@ public class ChatModelBo extends BaseEntity {
/**
* 请求地址
*/
@NotBlank(message = "请求地址不能为空", groups = { AddGroup.class, EditGroup.class })
@Pattern(regexp = ChatModelCredentialPolicy.HTTPS_API_HOST_REGEXP,
message = "请求地址必须是无凭据、查询参数和片段的 HTTPS 地址",
groups = { AddGroup.class, EditGroup.class })
private String apiHost;
/**
* 密钥
*/
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Pattern(regexp = ChatModelSecretReference.ENV_REFERENCE_REGEXP,
message = "密钥必须使用受信任的环境变量引用",
groups = { AddGroup.class, EditGroup.class })
@ToString.Exclude
@EqualsAndHashCode.Exclude
private String apiKey;

View File

@@ -2,11 +2,9 @@ package org.ruoyi.common.chat.domain.bo.chat;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import org.ruoyi.common.chat.security.ChatModelSecretReference;
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
/**
@@ -30,8 +28,6 @@ public class ModelBatchKeyBo extends BaseEntity {
*/
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@NotBlank(message = "密钥不能为空")
@Pattern(regexp = ChatModelSecretReference.ENV_REFERENCE_REGEXP,
message = "密钥必须使用受信任的环境变量引用")
@ToString.Exclude
@EqualsAndHashCode.Exclude
private String apiKey;

View File

@@ -9,7 +9,6 @@ import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import org.ruoyi.common.chat.entity.chat.ChatModel;
import org.ruoyi.common.chat.security.ChatModelCredentialPolicy;
import java.io.Serial;
import java.io.Serializable;
@@ -86,21 +85,6 @@ public class ChatModelVo implements Serializable {
@EqualsAndHashCode.Exclude
private String apiKey;
/** Returns the stored configuration reference without touching the process environment. */
public String getApiKey() {
return apiKey;
}
/**
* Resolves the reference only after binding it to both the consuming provider and this row's
* effective provider, model and endpoint. Non-allowlisted, misrouted, or legacy rows fail
* closed before the environment is read.
*/
public String resolveApiKeyForConfiguredEndpoint(String consumingProviderCode) {
return ChatModelCredentialPolicy.resolveApiKeyForUse(
consumingProviderCode, providerCode, modelName, apiHost, apiKey);
}
/**
* 备注
*/

View File

@@ -1,215 +0,0 @@
package org.ruoyi.common.chat.security;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
/** Security policy binding model credential references to trusted provider endpoints. */
public final class ChatModelCredentialPolicy {
public static final String HTTPS_API_HOST_REGEXP =
"^https://(?![^/?#]*@)[^\\s/?#]+(?::[0-9]{1,5})?(?:/[^\\s?#]*)?$";
public static final String DEEPSEEK_PROVIDER = "deepseek";
public static final String DEEPSEEK_API_HOST = "https://api.deepseek.com";
public static final String PPIO_PROVIDER = "ppio";
public static final String PPIO_API_HOST = "https://api.ppio.com/openai/v1";
public static final String ATLAS_PROVIDER = "atlas";
private static final Set<String> ATLAS_API_HOSTS = Set.of(
"https://api.atlascloud.ai", "https://api.atlascloud.ai/",
"https://api.atlascloud.ai/v1", "https://api.atlascloud.ai/v1/",
"https://api.atlascloud.ai/api/v1", "https://api.atlascloud.ai/api/v1/"
);
private static final Set<String> PPIO_API_HOSTS = Set.of(
PPIO_API_HOST, PPIO_API_HOST + "/",
"https://api.ppio.com/openai", "https://api.ppio.com/openai/"
);
private static final Set<String> DEEPSEEK_MODELS = Set.of(
"deepseek-v4-flash",
"deepseek-v4-pro",
"deepseek-v4-flash-vision-exp"
);
private ChatModelCredentialPolicy() {
}
/**
* Resolves a provider credential only after the effective provider/model/endpoint/reference
* tuple passes the same allowlist used at the persistence boundary.
*/
public static String resolveApiKeyForUse(String consumingProviderCode,
String providerCode,
String modelName,
String apiHost,
String apiKey) {
return resolveApiKeyForUse(consumingProviderCode, providerCode, modelName, apiHost,
apiKey, System::getenv);
}
static String resolveApiKeyForUse(String consumingProviderCode,
String providerCode,
String modelName,
String apiHost,
String apiKey,
Function<String, String> environment) {
if (!Objects.equals(consumingProviderCode, providerCode)) {
throw new IllegalArgumentException(
"Credential consumer does not match the configured provider");
}
if (CustomApiCredentialPolicy.isCustomProvider(providerCode)) {
CustomApiCredentialPolicy.requireConfiguration(providerCode, modelName, apiHost, apiKey, environment);
} else {
requireTrustedConfiguration(providerCode, modelName, apiHost, apiKey);
}
return ChatModelSecretReference.resolveAllowlistedReference(apiKey,
Objects.requireNonNull(environment, "environment"));
}
/** Validates the effective row which will remain after an insert or partial update. */
public static void requirePersistableConfiguration(String providerCode,
String modelName,
String apiHost,
String apiKey) {
requireSecureApiHost(apiHost);
if (ATLAS_PROVIDER.equals(providerCode)) {
requireAtlasConfiguration(providerCode, modelName, apiHost, apiKey);
return;
}
if (CustomApiCredentialPolicy.isCustomProvider(providerCode)) {
CustomApiCredentialPolicy.requireConfiguration(providerCode, modelName, apiHost, apiKey);
return;
}
if (PPIO_PROVIDER.equals(providerCode)) {
requirePpioConfiguration(providerCode, modelName, apiHost, apiKey);
return;
}
if (apiKey != null) {
ChatModelSecretReference.requirePersistableReference(apiKey);
if (!isDeepSeekConfiguration(providerCode, modelName)) {
throw new IllegalArgumentException(
"Model API key reference is not allowed for this provider");
}
}
if (isDeepSeekConfiguration(providerCode, modelName)) {
requireDeepSeekConfiguration(providerCode, modelName, apiHost, apiKey);
}
}
/** Re-validates the configuration immediately before a DeepSeek provider client is built. */
public static void requireDeepSeekConfiguration(String providerCode,
String modelName,
String apiHost,
String apiKey) {
if (!DEEPSEEK_PROVIDER.equals(providerCode)) {
throw new IllegalArgumentException("DeepSeek model provider is not trusted");
}
if (!DEEPSEEK_MODELS.contains(modelName)) {
throw new IllegalArgumentException("DeepSeek model is not allowlisted");
}
requireSecureApiHost(apiHost);
if (!DEEPSEEK_API_HOST.equals(apiHost)) {
throw new IllegalArgumentException("DeepSeek API host is not allowlisted");
}
if (apiKey == null) {
throw new IllegalArgumentException("DeepSeek API key reference is required");
}
requireProviderReference(providerCode, apiKey);
}
/** Binds each provider to its own environment variable, including batch credential updates. */
public static void requireProviderReference(String providerCode, String apiKey) {
if (CustomApiCredentialPolicy.isCustomProvider(providerCode)) {
CustomApiCredentialPolicy.requireReference(providerCode, apiKey);
return;
}
String expectedReference;
if (DEEPSEEK_PROVIDER.equals(providerCode)) {
expectedReference = "env:DEEPSEEK_API_KEY";
} else if (PPIO_PROVIDER.equals(providerCode)) {
expectedReference = "env:PPIO_API_KEY";
} else if (ATLAS_PROVIDER.equals(providerCode)) {
expectedReference = "env:ATLAS_API_KEY";
} else {
throw new IllegalArgumentException("Model credential provider is not allowlisted");
}
ChatModelSecretReference.requirePersistableReference(apiKey);
if (!expectedReference.equals(apiKey)) {
throw new IllegalArgumentException("Model API key reference does not match the provider");
}
}
/** Validates credential use without allowing another adapter to consume a provider's key. */
public static void requireTrustedConfiguration(String providerCode, String modelName,
String apiHost, String apiKey) {
if (CustomApiCredentialPolicy.isCustomProvider(providerCode)) {
CustomApiCredentialPolicy.requireConfiguration(providerCode, modelName, apiHost, apiKey);
} else if (PPIO_PROVIDER.equals(providerCode)) {
requirePpioConfiguration(providerCode, modelName, apiHost, apiKey);
} else if (ATLAS_PROVIDER.equals(providerCode)) {
requireAtlasConfiguration(providerCode, modelName, apiHost, apiKey);
} else {
requireDeepSeekConfiguration(providerCode, modelName, apiHost, apiKey);
}
}
/** Atlas credentials may only be sent to its official media API origin. */
public static void requireAtlasConfiguration(String providerCode, String modelName,
String apiHost, String apiKey) {
if (!ATLAS_PROVIDER.equals(providerCode)) {
throw new IllegalArgumentException("Atlas model provider is not trusted");
}
if (modelName == null || !modelName.matches("[A-Za-z0-9][A-Za-z0-9._/-]{0,254}")) {
throw new IllegalArgumentException("Atlas model ID is invalid");
}
requireSecureApiHost(apiHost);
if (!ATLAS_API_HOSTS.contains(apiHost)) {
throw new IllegalArgumentException("Atlas API host is not allowlisted");
}
requireProviderReference(providerCode, apiKey);
}
/** Validates PPIO and returns the base URL expected by the OpenAI Chat Completions client. */
public static String requirePpioConfiguration(String providerCode, String modelName,
String apiHost, String apiKey) {
if (!PPIO_PROVIDER.equals(providerCode)) {
throw new IllegalArgumentException("PPIO model provider is not trusted");
}
// PPIO hosts models from multiple vendors; use the full model ID from its catalog.
if (modelName == null || !modelName.matches("[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}")) {
throw new IllegalArgumentException("PPIO model ID is invalid");
}
requireSecureApiHost(apiHost);
if (!PPIO_API_HOSTS.contains(apiHost)) {
throw new IllegalArgumentException("PPIO API host is not allowlisted");
}
requireProviderReference(providerCode, apiKey);
return PPIO_API_HOST;
}
public static String requireSecureApiHost(String apiHost) {
if (apiHost == null || apiHost.isBlank()) {
throw new IllegalArgumentException("Model API host must be an absolute HTTPS URI");
}
final URI uri;
try {
uri = new URI(apiHost);
} catch (URISyntaxException invalid) {
throw new IllegalArgumentException("Model API host must be an absolute HTTPS URI");
}
if (!uri.isAbsolute() || !"https".equals(uri.getScheme()) || uri.getHost() == null
|| uri.getUserInfo() != null || uri.getRawQuery() != null || uri.getRawFragment() != null
|| uri.getPort() > 65535) {
throw new IllegalArgumentException(
"Model API host must be HTTPS without userinfo, query, or fragment");
}
return apiHost;
}
public static boolean isDeepSeekConfiguration(String providerCode, String modelName) {
return DEEPSEEK_PROVIDER.equals(providerCode) || DEEPSEEK_MODELS.contains(modelName);
}
}

View File

@@ -1,71 +0,0 @@
package org.ruoyi.common.chat.security;
import java.util.Objects;
import java.util.function.Function;
import java.util.regex.Pattern;
/** Resolves an explicit {@code env:NAME} model credential without exposing it through DTO JSON. */
public final class ChatModelSecretReference {
public static final String ENV_REFERENCE_REGEXP =
"^env:(?:(?:DEEPSEEK|PPIO|ATLAS)_API_KEY|" + CustomApiCredentialPolicy.REFERENCE_PATTERN + ")$";
private static final String ENV_PREFIX = "env:";
private static final Pattern ENV_REFERENCE = Pattern.compile(ENV_REFERENCE_REGEXP);
private ChatModelSecretReference() {
}
/**
* Package-private final resolution step. Callers must first bind provider, model and endpoint
* through {@link ChatModelCredentialPolicy}; exposing a context-free resolver would recreate a
* credential-confusion channel.
*/
static String resolveAllowlistedReference(String configuredValue,
Function<String, String> environment) {
String reference = requirePersistableReference(configuredValue);
if (reference == null) {
return null;
}
String variable = reference.substring(ENV_PREFIX.length());
String resolved = Objects.requireNonNull(environment, "environment").apply(variable);
if (resolved == null || resolved.isBlank()) {
throw new IllegalStateException(
"Required model API key environment variable is not configured: " + variable);
}
return resolved;
}
/**
* Validates the only credential representation that may cross a persistence boundary.
* A {@code null} value means that an update did not request a credential change.
*/
public static String requirePersistableReference(String configuredValue) {
if (configuredValue == null) {
return null;
}
if (!ENV_REFERENCE.matcher(configuredValue).matches()) {
throw new IllegalArgumentException(
"Invalid model API key reference; reference is not allowlisted");
}
return configuredValue;
}
/**
* Narrow compatibility hook for an in-place legacy migration. It is deliberately package
* private and requires an audit callback which never receives the credential value. Normal
* provider resolution and all persistence paths must use the strict methods above.
*/
static String resolveForLegacyMigration(String configuredValue,
Function<String, String> environment,
Runnable legacyPlaintextAudit) {
if (configuredValue == null || configuredValue.startsWith(ENV_PREFIX)) {
return resolveAllowlistedReference(configuredValue, environment);
}
if (configuredValue.isBlank()) {
throw new IllegalArgumentException("Legacy model API key cannot be blank");
}
Objects.requireNonNull(legacyPlaintextAudit, "legacyPlaintextAudit").run();
return configuredValue;
}
}

View File

@@ -1,16 +1,12 @@
package org.ruoyi.common.chat.security;
import java.net.URI;
import java.util.function.Function;
import java.util.regex.Pattern;
/** Binds configurable OpenAI/Anthropic endpoints to dedicated deployment credentials. */
/** Normalizes the OpenAI/Anthropic endpoint configured in model management. */
public final class CustomApiCredentialPolicy {
public static final String OPENAI_PROVIDER = "custom_api";
public static final String ANTHROPIC_PROVIDER = "custom_anthropic";
public static final String REFERENCE_PATTERN =
"CUSTOM_(?:OPENAI|ANTHROPIC)(?:_[A-Z][A-Z0-9_]{0,31})?_API_KEY";
private CustomApiCredentialPolicy() {
}
@@ -19,51 +15,19 @@ public final class CustomApiCredentialPolicy {
return OPENAI_PROVIDER.equals(providerCode) || ANTHROPIC_PROVIDER.equals(providerCode);
}
public static void requireReference(String providerCode, String reference) {
String protocol;
if (OPENAI_PROVIDER.equals(providerCode)) {
protocol = "OPENAI";
} else if (ANTHROPIC_PROVIDER.equals(providerCode)) {
protocol = "ANTHROPIC";
} else {
throw new IllegalArgumentException("不支持的自定义厂商协议");
}
String pattern = "env:CUSTOM_" + protocol + "(?:_[A-Z][A-Z0-9_]{0,31})?_API_KEY";
if (reference == null || !Pattern.matches(pattern, reference)) {
throw new IllegalArgumentException("自定义厂商密钥必须使用与协议匹配的环境变量引用");
}
}
public static String requireConfiguration(String providerCode, String modelName,
String apiHost, String reference) {
return requireConfiguration(providerCode, modelName, apiHost, reference, System::getenv);
}
static String requireConfiguration(String providerCode, String modelName, String apiHost,
String reference, Function<String, String> environment) {
requireReference(providerCode, reference);
if (modelName == null || modelName.isBlank()) {
throw new IllegalArgumentException("自定义厂商的模型名称不能为空");
}
String baseUrl = normalizeBaseUrl(providerCode, apiHost);
String keyVariable = reference.substring("env:".length());
String hostVariable = keyVariable.substring(0, keyVariable.length() - "_API_KEY".length()) + "_BASE_URL";
String configuredHost = environment.apply(hostVariable);
if (configuredHost == null || configuredHost.isBlank()) {
throw new IllegalArgumentException("请先在后端配置自定义厂商地址环境变量:" + hostVariable);
}
if (!baseUrl.equals(normalizeBaseUrl(providerCode, configuredHost))) {
throw new IllegalArgumentException("模型请求地址与该密钥绑定的自定义厂商地址不一致");
}
return baseUrl;
}
/** Accepts a base URL or the selected protocol's complete chat endpoint. */
public static String normalizeBaseUrl(String providerCode, String apiHost) {
if (!isCustomProvider(providerCode)) {
throw new IllegalArgumentException("不支持的自定义厂商协议");
}
ChatModelCredentialPolicy.requireSecureApiHost(apiHost);
if (apiHost == null || apiHost.isBlank()) {
throw new IllegalArgumentException("请填写自定义厂商的请求地址");
}
URI uri = URI.create(apiHost);
if (uri.getHost() == null || !("http".equalsIgnoreCase(uri.getScheme())
|| "https".equalsIgnoreCase(uri.getScheme()))) {
throw new IllegalArgumentException("请求地址必须是有效的 HTTP 或 HTTPS 地址");
}
String normalized = apiHost.replaceAll("/+$", "");
String suffix = ANTHROPIC_PROVIDER.equals(providerCode) ? "/messages" : "/chat/completions";
String otherSuffix = ANTHROPIC_PROVIDER.equals(providerCode) ? "/chat/completions" : "/messages";

View File

@@ -1,50 +0,0 @@
package org.ruoyi.common.chat.security;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@Tag("dev")
class AtlasCredentialPolicyTest {
private static final String MODEL = "bytedance/seed-audio-1.0";
private static final String HOST = "https://api.atlascloud.ai/v1";
private static final String REF = "env:ATLAS_API_KEY";
@Test
void acceptsOfficialEndpointFormsAtSaveAndUse() {
for (String suffix : new String[]{"", "/", "/v1", "/v1/", "/api/v1", "/api/v1/"}) {
String host = "https://api.atlascloud.ai" + suffix;
assertDoesNotThrow(() -> ChatModelCredentialPolicy.requirePersistableConfiguration("atlas", MODEL, host, REF));
assertEquals("test-secret", ChatModelCredentialPolicy.resolveApiKeyForUse("atlas", "atlas", MODEL, host, REF,
name -> "ATLAS_API_KEY".equals(name) ? "test-secret" : null));
}
}
@Test
void rejectsUntrustedEndpointsBeforeReadingEnvironment() {
for (String host : new String[]{"http://api.atlascloud.ai/v1", "https://evil.example/v1",
"https://api.atlascloud.ai.evil.example/v1", "https://api.atlascloud.ai:8443/v1",
"https://user@api.atlascloud.ai/v1", "https://api.atlascloud.ai/v1?x=1", "https://api.atlascloud.ai/other"}) {
assertThrows(IllegalArgumentException.class,
() -> ChatModelCredentialPolicy.requirePersistableConfiguration("atlas", MODEL, host, REF));
assertThrows(IllegalArgumentException.class,
() -> ChatModelCredentialPolicy.resolveApiKeyForUse("atlas", "atlas", MODEL, host, REF,
name -> { fail("Must validate endpoint before resolving secret"); return null; }));
}
}
@Test
void rejectsWrongProviderReferenceAndMissingSecret() {
assertThrows(IllegalArgumentException.class, () -> ChatModelCredentialPolicy.resolveApiKeyForUse(
"openai", "atlas", MODEL, HOST, REF, name -> "secret"));
for (String key : new String[]{"env:PPIO_API_KEY", "plaintext", "", null}) {
assertThrows(IllegalArgumentException.class,
() -> ChatModelCredentialPolicy.requirePersistableConfiguration("atlas", MODEL, HOST, key));
}
assertThrows(IllegalStateException.class, () -> ChatModelCredentialPolicy.resolveApiKeyForUse(
"atlas", "atlas", MODEL, HOST, REF, name -> null));
assertThrows(IllegalArgumentException.class,
() -> ChatModelCredentialPolicy.requirePersistableConfiguration("atlas", "bad model", HOST, REF));
}
}

View File

@@ -0,0 +1,40 @@
package org.ruoyi.common.chat.security;
import jakarta.validation.Validation;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo;
import org.ruoyi.common.chat.domain.bo.chat.ModelBatchKeyBo;
import org.ruoyi.common.core.validate.AddGroup;
import static org.junit.jupiter.api.Assertions.*;
@Tag("dev")
class DifyApiKeyConfigurationTest {
@Test
void acceptsAnAppKeyForCloudAndSelfHostedDify() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
for (String host : new String[]{"https://api.dify.ai/v1", "http://localhost:8080/v1"}) {
var model = new ChatModelBo();
model.setCategory("chat");
model.setProviderCode("dify");
model.setModelName("dify-chat");
model.setApiHost(host);
model.setApiKey("app-test-key");
assertTrue(factory.getValidator().validate(model, AddGroup.class).isEmpty());
}
}
}
@Test
void batchUpdatesStillRequireAProviderAndANonblankKey() {
var batch = new ModelBatchKeyBo();
batch.setProviderCode("dify");
batch.setApiKey("");
try (var factory = Validation.buildDefaultValidatorFactory()) {
assertFalse(factory.getValidator().validate(batch).isEmpty());
batch.setApiKey("app-test-key");
assertTrue(factory.getValidator().validate(batch).isEmpty());
}
}
}

View File

@@ -0,0 +1,73 @@
package org.ruoyi.common.chat.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.Validation;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo;
import org.ruoyi.common.chat.domain.bo.chat.ModelBatchKeyBo;
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
import org.ruoyi.common.core.validate.AddGroup;
import org.ruoyi.common.core.validate.EditGroup;
import static org.junit.jupiter.api.Assertions.*;
@Tag("dev")
class ModelApiKeyValidationTest {
@ParameterizedTest
@ValueSource(strings = {"qianwen", "zhipu", "openai", "deepseek", "ppio", "atlas",
"minimax", "dify", "coze", "xiaomi", "custom_api", "custom_anthropic"})
void acceptsAdminConfiguredKeysForEveryProvider(String provider) {
var model = new ChatModelBo();
model.setId(1L);
model.setCategory("chat");
model.setProviderCode(provider);
model.setModelName("configured-model");
model.setApiHost("http://localhost:11434/v1");
model.setApiKey("test-key-" + provider);
var batch = new ModelBatchKeyBo();
batch.setProviderCode(provider);
batch.setApiKey(model.getApiKey());
try (var factory = Validation.buildDefaultValidatorFactory()) {
var validator = factory.getValidator();
assertTrue(validator.validate(model, AddGroup.class, EditGroup.class).isEmpty());
assertTrue(validator.validate(batch).isEmpty());
}
}
@Test
void acceptsKeylessLocalModelsAndDoesNotRequireHiddenHostField() {
var model = new ChatModelBo();
model.setCategory("chat");
model.setProviderCode("ollama");
model.setModelName("local-model");
try (var factory = Validation.buildDefaultValidatorFactory()) {
assertTrue(factory.getValidator().validate(model, AddGroup.class).isEmpty());
}
}
@Test
void keyRemainsWritableButIsNotSerializedOrIncludedInToString() throws Exception {
var mapper = new ObjectMapper();
var model = mapper.readValue("{\"providerCode\":\"qianwen\",\"apiKey\":\"test-secret\"}", ChatModelBo.class);
assertEquals("test-secret", model.getApiKey());
var view = new ChatModelVo();
view.setApiKey(model.getApiKey());
assertFalse(mapper.writeValueAsString(model).contains("test-secret"));
assertFalse(mapper.writeValueAsString(view).contains("apiKey"));
assertFalse(model.toString().contains("test-secret"));
assertFalse(view.toString().contains("test-secret"));
}
@Test
void customEndpointsNeedNoDeploymentVariablesAndSupportLocalServices() {
assertEquals("http://localhost:11434/v1", CustomApiCredentialPolicy.normalizeBaseUrl(
"custom_api", "http://localhost:11434/v1/chat/completions"));
assertEquals("https://example.com/v1", CustomApiCredentialPolicy.normalizeBaseUrl(
"custom_anthropic", "https://example.com/v1/messages"));
assertThrows(IllegalArgumentException.class, () -> CustomApiCredentialPolicy.normalizeBaseUrl(
"custom_anthropic", "https://example.com/v1/chat/completions"));
}
}

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

Some files were not shown because too many files have changed in this diff Show More