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