diff --git a/README.md b/README.md index a1cff71b..99d330f4 100644 --- a/README.md +++ b/README.md @@ -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 💪 diff --git a/README_ZH.md b/README_ZH.md index 4c80d303..034fc5ff 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -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) + ## 🤝 参与贡献 我们热烈欢迎社区贡献!无论您是资深开发者还是初学者,都可以为项目贡献力量 💪 diff --git a/docs/dify-cloud.md b/docs/dify-cloud.md new file mode 100644 index 00000000..dbf25ca0 --- /dev/null +++ b/docs/dify-cloud.md @@ -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 中验证应用可以回答。 | diff --git a/docs/model-configuration.md b/docs/model-configuration.md new file mode 100644 index 00000000..5dfab510 --- /dev/null +++ b/docs/model-configuration.md @@ -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 应对应同一个服务账号。模型是否可用、支持哪些能力以及额度限制,以服务商实际响应为准。 diff --git a/docs/script/sql/ruoyi-ai.sql b/docs/script/sql/ruoyi-ai.sql index 87caaf57..b2d256f9 100644 --- a/docs/script/sql/ruoyi-ai.sql +++ b/docs/script/sql/ruoyi-ai.sql @@ -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); diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/bo/chat/ChatModelBo.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/bo/chat/ChatModelBo.java index 41d65809..8ac4579f 100644 --- a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/bo/chat/ChatModelBo.java +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/bo/chat/ChatModelBo.java @@ -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; diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/bo/chat/ModelBatchKeyBo.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/bo/chat/ModelBatchKeyBo.java index a2f1ba79..d1782e9a 100644 --- a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/bo/chat/ModelBatchKeyBo.java +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/bo/chat/ModelBatchKeyBo.java @@ -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; diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/vo/chat/ChatModelVo.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/vo/chat/ChatModelVo.java index 3b890ca1..cf7f9ce5 100644 --- a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/vo/chat/ChatModelVo.java +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/vo/chat/ChatModelVo.java @@ -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); - } - /** * 备注 */ diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/security/ChatModelCredentialPolicy.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/security/ChatModelCredentialPolicy.java deleted file mode 100644 index 4a462cd9..00000000 --- a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/security/ChatModelCredentialPolicy.java +++ /dev/null @@ -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 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 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 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 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); - } -} diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/security/ChatModelSecretReference.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/security/ChatModelSecretReference.java deleted file mode 100644 index ba7b16ef..00000000 --- a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/security/ChatModelSecretReference.java +++ /dev/null @@ -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 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 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; - } -} diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/security/CustomApiCredentialPolicy.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/security/CustomApiCredentialPolicy.java index e283eba2..2e4c66fc 100644 --- a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/security/CustomApiCredentialPolicy.java +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/security/CustomApiCredentialPolicy.java @@ -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 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"; diff --git a/ruoyi-common/ruoyi-common-chat/src/test/java/org/ruoyi/common/chat/security/AtlasCredentialPolicyTest.java b/ruoyi-common/ruoyi-common-chat/src/test/java/org/ruoyi/common/chat/security/AtlasCredentialPolicyTest.java deleted file mode 100644 index 561fb7c7..00000000 --- a/ruoyi-common/ruoyi-common-chat/src/test/java/org/ruoyi/common/chat/security/AtlasCredentialPolicyTest.java +++ /dev/null @@ -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)); - } -} diff --git a/ruoyi-common/ruoyi-common-chat/src/test/java/org/ruoyi/common/chat/security/DifyApiKeyConfigurationTest.java b/ruoyi-common/ruoyi-common-chat/src/test/java/org/ruoyi/common/chat/security/DifyApiKeyConfigurationTest.java new file mode 100644 index 00000000..db5e6e82 --- /dev/null +++ b/ruoyi-common/ruoyi-common-chat/src/test/java/org/ruoyi/common/chat/security/DifyApiKeyConfigurationTest.java @@ -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()); + } + } +} diff --git a/ruoyi-common/ruoyi-common-chat/src/test/java/org/ruoyi/common/chat/security/ModelApiKeyValidationTest.java b/ruoyi-common/ruoyi-common-chat/src/test/java/org/ruoyi/common/chat/security/ModelApiKeyValidationTest.java new file mode 100644 index 00000000..d16a702c --- /dev/null +++ b/ruoyi-common/ruoyi-common-chat/src/test/java/org/ruoyi/common/chat/security/ModelApiKeyValidationTest.java @@ -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")); + } +} diff --git a/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/node/googleSearch/ZhipuWebSearchClient.java b/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/node/googleSearch/ZhipuWebSearchClient.java index 5fbc9b66..37326e63 100644 --- a/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/node/googleSearch/ZhipuWebSearchClient.java +++ b/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/node/googleSearch/ZhipuWebSearchClient.java @@ -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) { diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/EmbeddingModelFactory.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/EmbeddingModelFactory.java index 0fbc9abe..ecb08b91 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/EmbeddingModelFactory.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/EmbeddingModelFactory.java @@ -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 modelCache = new ConcurrentHashMap<>(); + private final Map 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(); } /** diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/RerankModelFactory.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/RerankModelFactory.java index e8be2feb..74bb9f0d 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/RerankModelFactory.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/RerankModelFactory.java @@ -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 modelCache = new ConcurrentHashMap<>(); + private final Map 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(); } /** diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/provider/AtlasAudioGenerationServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/provider/AtlasAudioGenerationServiceImpl.java index 90bc5318..bab5ca4e 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/provider/AtlasAudioGenerationServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/provider/AtlasAudioGenerationServiceImpl.java @@ -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(); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/provider/OpenAiAudioGenerationServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/provider/OpenAiAudioGenerationServiceImpl.java index f440df76..cc406fa9 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/provider/OpenAiAudioGenerationServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/provider/OpenAiAudioGenerationServiceImpl.java @@ -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(); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/AbstractChatService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/AbstractChatService.java index 512d4eaa..7048e2e8 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/AbstractChatService.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/AbstractChatService.java @@ -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(); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatModelServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatModelServiceImpl.java index 727e954b..d5142167 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatModelServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatModelServiceImpl.java @@ -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 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 lock = Wrappers.lambdaQuery(); - lock.select(ChatModel::getId, ChatModel::getProviderCode, ChatModel::getModelName, ChatModel::getApiHost) - .eq(ChatModel::getProviderCode, providerCode) - .last("FOR UPDATE"); - List current = baseMapper.selectList(lock); - if (current.isEmpty()) { - return false; - } - current.forEach(model -> ChatModelCredentialPolicy.requireTrustedConfiguration( - model.getProviderCode(), model.getModelName(), model.getApiHost(), reference)); LambdaUpdateWrapper 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; } } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/ChatMemoryUsageExample.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/ChatMemoryUsageExample.java index adf9c9df..ca719572 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/ChatMemoryUsageExample.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/ChatMemoryUsageExample.java @@ -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(); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/AtlaServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/AtlaServiceImpl.java index 930ef259..7d4a62f0 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/AtlaServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/AtlaServiceImpl.java @@ -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()) diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/CozeChatServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/CozeChatServiceImpl.java index 398ebb23..cc7b0b94 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/CozeChatServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/CozeChatServiceImpl.java @@ -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"); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/CustomAnthropicServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/CustomAnthropicServiceImpl.java index 14bf4417..8868bdc5 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/CustomAnthropicServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/CustomAnthropicServiceImpl.java @@ -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 diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/CustomApiServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/CustomApiServiceImpl.java index 89ca786d..9fb6da00 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/CustomApiServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/CustomApiServiceImpl.java @@ -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()); } } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/DeepseekServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/DeepseekServiceImpl.java index 1e4354a0..17bfcc38 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/DeepseekServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/DeepseekServiceImpl.java @@ -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 thinkingParameters(boolean enabled) { return Map.of("thinking", Map.of("type", enabled ? "enabled" : "disabled")); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/DifyChatServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/DifyChatServiceImpl.java index fa8d07a5..0944cfb7 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/DifyChatServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/DifyChatServiceImpl.java @@ -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 不能为空"); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/MiMoServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/MiMoServiceImpl.java index 109c93a3..85b8a099 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/MiMoServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/MiMoServiceImpl.java @@ -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()) diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/MinimaxServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/MinimaxServiceImpl.java index f0de88a5..5012e47a 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/MinimaxServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/MinimaxServiceImpl.java @@ -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(); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OpenAIServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OpenAIServiceImpl.java index e4efb099..0b06614e 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OpenAIServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OpenAIServiceImpl.java @@ -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())) diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/PpioChatServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/PpioChatServiceImpl.java index 43994c7e..b6d28f8c 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/PpioChatServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/PpioChatServiceImpl.java @@ -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 diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/QianWenChatServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/QianWenChatServiceImpl.java index 38ca5da2..2490dd2e 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/QianWenChatServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/QianWenChatServiceImpl.java @@ -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(); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/ZhiPuChatServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/ZhiPuChatServiceImpl.java index f309cca1..f65b5f32 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/ZhiPuChatServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/ZhiPuChatServiceImpl.java @@ -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)) diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/AliBaiLianBaseEmbedProvider.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/AliBaiLianBaseEmbedProvider.java index fcab3781..68292d5e 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/AliBaiLianBaseEmbedProvider.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/AliBaiLianBaseEmbedProvider.java @@ -37,7 +37,7 @@ public class AliBaiLianBaseEmbedProvider extends OpenAiEmbeddingProvider { @Override public Response> embedAll(List textSegments) { EmbeddingModel model = QwenEmbeddingModel.builder() - .apiKey(chatModelVo.resolveApiKeyForConfiguredEndpoint("qianwen")) + .apiKey(chatModelVo.getApiKey()) .modelName(chatModelVo.getModelName()) .dimension(chatModelVo.getModelDimension()) .build(); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/AliBaiLianMultiEmbeddingProvider.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/AliBaiLianMultiEmbeddingProvider.java index 8793049f..83751e40 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/AliBaiLianMultiEmbeddingProvider.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/AliBaiLianMultiEmbeddingProvider.java @@ -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(); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/OpenAiEmbeddingProvider.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/OpenAiEmbeddingProvider.java index 17b25d18..ea7f1d99 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/OpenAiEmbeddingProvider.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/OpenAiEmbeddingProvider.java @@ -37,7 +37,7 @@ public class OpenAiEmbeddingProvider implements BaseEmbedModelService { public Response> embedAll(List textSegments) { EmbeddingModel model = OpenAiEmbeddingModel.builder() .baseUrl(chatModelVo.getApiHost()) - .apiKey(chatModelVo.resolveApiKeyForConfiguredEndpoint("openai")) + .apiKey(chatModelVo.getApiKey()) .modelName(chatModelVo.getModelName()) .dimensions(chatModelVo.getModelDimension()) .build(); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/ZhipuAiEmbeddingProvider.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/ZhipuAiEmbeddingProvider.java index 45c09cb5..275c0b82 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/ZhipuAiEmbeddingProvider.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/embed/impl/ZhipuAiEmbeddingProvider.java @@ -39,7 +39,7 @@ public class ZhipuAiEmbeddingProvider implements BaseEmbedModelService { public Response> embedAll(List textSegments) { EmbeddingModel model = ZhipuAiEmbeddingModel.builder() .baseUrl(chatModelVo.getApiHost()) - .apiKey(chatModelVo.resolveApiKeyForConfiguredEndpoint("zhipu")) + .apiKey(chatModelVo.getApiKey()) .model(chatModelVo.getModelName()) .dimensions(chatModelVo.getModelDimension()) .build(); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/AtlasImageGenerationServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/AtlasImageGenerationServiceImpl.java index 9d7f789d..1d7a478e 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/AtlasImageGenerationServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/AtlasImageGenerationServiceImpl.java @@ -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()) { diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/OpenAiImageGenerationServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/OpenAiImageGenerationServiceImpl.java index 732c53d8..8b2287e2 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/OpenAiImageGenerationServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/OpenAiImageGenerationServiceImpl.java @@ -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(); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/TongYiWanxImageServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/TongYiWanxImageServiceImpl.java index b4c4c128..c188cae7 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/TongYiWanxImageServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/TongYiWanxImageServiceImpl.java @@ -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(); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasPredictionService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasPredictionService.java index d688979a..ef720466 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasPredictionService.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasPredictionService.java @@ -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()) { diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/AliBaiLianRerankModelService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/AliBaiLianRerankModelService.java index 2bcc32b2..3c97d056 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/AliBaiLianRerankModelService.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/AliBaiLianRerankModelService.java @@ -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(); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/SiliconFlowRerankModelService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/SiliconFlowRerankModelService.java index 549af250..dd44987c 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/SiliconFlowRerankModelService.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/SiliconFlowRerankModelService.java @@ -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(); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/ZhiPuRerankModelService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/ZhiPuRerankModelService.java index bfa393c4..c0b9d8f5 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/ZhiPuRerankModelService.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/rerank/impl/ZhiPuRerankModelService.java @@ -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"; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/provider/AtlasVideoGenerationServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/provider/AtlasVideoGenerationServiceImpl.java index 991a5140..e7832dad 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/provider/AtlasVideoGenerationServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/provider/AtlasVideoGenerationServiceImpl.java @@ -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(); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/provider/OpenAiVideoGenerationServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/provider/OpenAiVideoGenerationServiceImpl.java index 262708e4..35ae6222 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/provider/OpenAiVideoGenerationServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/provider/OpenAiVideoGenerationServiceImpl.java @@ -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查询失败"); diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/factory/ModelKeyRotationTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/factory/ModelKeyRotationTest.java new file mode 100644 index 00000000..45413dde --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/factory/ModelKeyRotationTest.java @@ -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; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/ChatModelApiKeyTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/ChatModelApiKeyTest.java new file mode 100644 index 00000000..2fb15c2b --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/ChatModelApiKeyTest.java @@ -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; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/ChatModelDifyConfigurationTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/ChatModelDifyConfigurationTest.java new file mode 100644 index 00000000..b9af75ae --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/ChatModelDifyConfigurationTest.java @@ -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; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/provider/DifyChatServiceImplTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/provider/DifyChatServiceImplTest.java index 04d2403b..3a1464bd 100644 --- a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/provider/DifyChatServiceImplTest.java +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/provider/DifyChatServiceImplTest.java @@ -2,14 +2,29 @@ package org.ruoyi.service.chat.impl.provider; import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.StreamingChatModel; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.chat.response.StreamingChatResponseHandler; +import io.github.imfangs.dify.client.DifyChatClient; +import io.github.imfangs.dify.client.DifyClientFactory; +import io.github.imfangs.dify.client.callback.ChatStreamCallback; +import io.github.imfangs.dify.client.enums.ResponseMode; +import io.github.imfangs.dify.client.event.MessageEvent; +import io.github.imfangs.dify.client.event.MessageEndEvent; +import io.github.imfangs.dify.client.model.DifyConfig; +import io.github.imfangs.dify.client.model.chat.ChatMessageResponse; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.ruoyi.common.chat.domain.dto.request.ChatRequest; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; import org.ruoyi.enums.ChatModeType; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.*; @Tag("dev") class DifyChatServiceImplTest { @@ -35,12 +50,64 @@ class DifyChatServiceImplTest { assertNotNull(model); } + @Test + void blockingCallPassesAdminConfiguredAppKeyAndCloudBaseUrlToSdk() throws Exception { + var config = modelVo(); + var client = mock(DifyChatClient.class); + when(client.sendChatMessage(any())).thenReturn(ChatMessageResponse.builder() + .answer("Hello from Dify").messageId("message-1").build()); + try (var factory = mockStatic(DifyClientFactory.class)) { + factory.when(() -> DifyClientFactory.createChatClient(any(DifyConfig.class))).thenReturn(client); + var response = service.buildChatModel(config).doChat(request()); + assertEquals("Hello from Dify", response.aiMessage().text()); + factory.verify(() -> DifyClientFactory.createChatClient(argThat(value -> + "https://api.dify.ai/v1".equals(value.getBaseUrl()) + && "app-test-key".equals(value.getApiKey())))); + verify(client).sendChatMessage(argThat(message -> message.getResponseMode() == ResponseMode.BLOCKING + && message.getInputs().isEmpty())); + } + } + + @Test + void streamingCallPassesAdminConfiguredAppKeyAndDeliversResponse() throws Exception { + var config = modelVo(); + config.setApiHost("https://api.dify.ai/v1/"); + var client = mock(DifyChatClient.class); + doAnswer(invocation -> { + ChatStreamCallback callback = invocation.getArgument(1); + var chunk = new MessageEvent(); + chunk.setAnswer("Hello from Dify"); + callback.onMessage(chunk); + callback.onMessageEnd(new MessageEndEvent()); + return null; + }).when(client).sendChatMessageStream(any(), any()); + var handler = mock(StreamingChatResponseHandler.class); + try (var factory = mockStatic(DifyClientFactory.class)) { + factory.when(() -> DifyClientFactory.createChatClient(any(DifyConfig.class))).thenReturn(client); + service.buildStreamingChatModel(config, new ChatRequest()).doChat(request(), handler); + factory.verify(() -> DifyClientFactory.createChatClient(argThat(value -> + "https://api.dify.ai/v1".equals(value.getBaseUrl()) + && "app-test-key".equals(value.getApiKey())))); + verify(client).sendChatMessageStream(argThat(message -> message.getResponseMode() == ResponseMode.STREAMING), any()); + verify(handler).onPartialResponse("Hello from Dify"); + var response = ArgumentCaptor.forClass(ChatResponse.class); + verify(handler).onCompleteResponse(response.capture()); + assertEquals("Hello from Dify", response.getValue().aiMessage().text()); + verify(handler, never()).onError(any()); + } + } + + private dev.langchain4j.model.chat.request.ChatRequest request() { + return dev.langchain4j.model.chat.request.ChatRequest.builder() + .messages(UserMessage.from("Hello")).build(); + } + private ChatModelVo modelVo() { ChatModelVo modelVo = new ChatModelVo(); modelVo.setProviderCode(ChatModeType.DIFY.getCode()); modelVo.setModelName("dify-chat"); modelVo.setApiHost("https://api.dify.ai/v1"); - modelVo.setApiKey("app-test"); + modelVo.setApiKey("app-test-key"); return modelVo; } } diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/embed/impl/QianwenAdminApiKeyTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/embed/impl/QianwenAdminApiKeyTest.java new file mode 100644 index 00000000..cca29f77 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/embed/impl/QianwenAdminApiKeyTest.java @@ -0,0 +1,41 @@ +package org.ruoyi.service.embed.impl; + +import dev.langchain4j.community.model.dashscope.QwenEmbeddingModel; +import dev.langchain4j.data.embedding.Embedding; +import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.model.output.Response; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.*; + +@Tag("dev") +class QianwenAdminApiKeyTest { + @Test + void embeddingUsesTheAdminConfiguredKeyWithoutEnvironmentVariables() { + var config = new ChatModelVo(); + config.setProviderCode("qianwen"); + config.setModelName("text-embedding-v3"); + config.setModelDimension(1024); + config.setApiKey("sk-test-qianwen-key"); + var provider = new AliBaiLianBaseEmbedProvider(); + provider.configure(config); + var builder = spy(QwenEmbeddingModel.builder()); + var client = mock(QwenEmbeddingModel.class); + var response = Response.from(List.of(Embedding.from(new float[]{1, 0}))); + doReturn(client).when(builder).build(); + when(client.embedAll(anyList())).thenReturn(response); + try (var factory = mockStatic(QwenEmbeddingModel.class)) { + factory.when(QwenEmbeddingModel::builder).thenReturn(builder); + assertSame(response, provider.embedAll(List.of(TextSegment.from("test document")))); + verify(builder).apiKey("sk-test-qianwen-key"); + verify(builder).modelName("text-embedding-v3"); + verify(client).embedAll(anyList()); + } + } +}