Commit 7c397bbfae3e768b26e3ee962c5aa7064e8d7c96
1 parent
c1bddc86
feat(ai1): 同步 XXL-AI Cloud 增量,优化请求 Header、技能注册和首页图表
- 前后端请求 Header 统一为 Map 对象,保留顺序、逐值 ENV 解析及会话占位符,支持重复校验和空对象清空 - 升级 spring-ai-agent-utils 至 0.13.0,固定技能注册顺序,明确同名技能首次注册行为 - 避免占位符解析和供应商 HTTP 探测异常回显配置、密钥原文 - 首页图表关闭动画,支持容器尺寸变化、keepalive 恢复及卸载释放 - 补充模型、Header 和技能执行回归测试,统一 AI1 单测代码风格 验证:jdk25、sb4 各 210 项测试通过;前端 ESLint、类型检查、构建及浏览器回归通过
Showing
23 changed files
with
546 additions
and
120 deletions
yudao-module-ai1-springai/pom.xml
| @@ -22,7 +22,7 @@ | @@ -22,7 +22,7 @@ | ||
| 22 | <properties> | 22 | <properties> |
| 23 | <!-- 以下依赖不在 spring-ai-bom 中,需显式声明版本 --> | 23 | <!-- 以下依赖不在 spring-ai-bom 中,需显式声明版本 --> |
| 24 | <mcp-sdk.version>2.0.1</mcp-sdk.version> | 24 | <mcp-sdk.version>2.0.1</mcp-sdk.version> |
| 25 | - <spring-ai-agent-utils.version>0.12.0</spring-ai-agent-utils.version> | 25 | + <spring-ai-agent-utils.version>0.13.0</spring-ai-agent-utils.version> |
| 26 | <milvus-sdk.version>3.0.10</milvus-sdk.version> | 26 | <milvus-sdk.version>3.0.10</milvus-sdk.version> |
| 27 | </properties> | 27 | </properties> |
| 28 | 28 |
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderRespVO.java
| @@ -3,6 +3,8 @@ package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider; | @@ -3,6 +3,8 @@ package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider; | ||
| 3 | import io.swagger.v3.oas.annotations.media.Schema; | 3 | import io.swagger.v3.oas.annotations.media.Schema; |
| 4 | import lombok.Data; | 4 | import lombok.Data; |
| 5 | 5 | ||
| 6 | +import java.util.Map; | ||
| 7 | + | ||
| 6 | import java.time.LocalDateTime; | 8 | import java.time.LocalDateTime; |
| 7 | 9 | ||
| 8 | @Schema(description = "管理后台 - AI1 供应商 Response VO") | 10 | @Schema(description = "管理后台 - AI1 供应商 Response VO") |
| @@ -21,8 +23,8 @@ public class Ai1ProviderRespVO { | @@ -21,8 +23,8 @@ public class Ai1ProviderRespVO { | ||
| 21 | @Schema(description = "API 密钥,脱敏展示", example = "sk-****abcd") | 23 | @Schema(description = "API 密钥,脱敏展示", example = "sk-****abcd") |
| 22 | private String apiKey; | 24 | private String apiKey; |
| 23 | 25 | ||
| 24 | - @Schema(description = "请求附属 Header,JSON 数组", example = "[{\"key\":\"X-Session\",\"value\":\"{session}\"}]") | ||
| 25 | - private String headers; | 26 | + @Schema(description = "请求 Header,JSON 对象", example = "{\"X-Session\":\"{session}\"}") |
| 27 | + private Map<String, String> headers; | ||
| 26 | 28 | ||
| 27 | @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") | 29 | @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") |
| 28 | private Integer status; | 30 | private Integer status; |
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderSaveReqVO.java
| 1 | package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider; | 1 | package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider; |
| 2 | 2 | ||
| 3 | -import cn.hutool.core.util.StrUtil; | ||
| 4 | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; | 3 | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; |
| 5 | import cn.iocoder.yudao.framework.common.validation.InEnum; | 4 | import cn.iocoder.yudao.framework.common.validation.InEnum; |
| 6 | import cn.iocoder.yudao.module.ai1.util.Ai1Utils; | 5 | import cn.iocoder.yudao.module.ai1.util.Ai1Utils; |
| @@ -12,6 +11,8 @@ import jakarta.validation.constraints.NotNull; | @@ -12,6 +11,8 @@ import jakarta.validation.constraints.NotNull; | ||
| 12 | import jakarta.validation.constraints.Size; | 11 | import jakarta.validation.constraints.Size; |
| 13 | import lombok.Data; | 12 | import lombok.Data; |
| 14 | 13 | ||
| 14 | +import java.util.Map; | ||
| 15 | + | ||
| 15 | @Schema(description = "管理后台 - AI1 供应商新增/修改 Request VO") | 16 | @Schema(description = "管理后台 - AI1 供应商新增/修改 Request VO") |
| 16 | @Data | 17 | @Data |
| 17 | public class Ai1ProviderSaveReqVO { | 18 | public class Ai1ProviderSaveReqVO { |
| @@ -33,9 +34,8 @@ public class Ai1ProviderSaveReqVO { | @@ -33,9 +34,8 @@ public class Ai1ProviderSaveReqVO { | ||
| 33 | @Size(max = 200, message = "API 密钥长度不能超过 200 个字符") | 34 | @Size(max = 200, message = "API 密钥长度不能超过 200 个字符") |
| 34 | private String apiKey; | 35 | private String apiKey; |
| 35 | 36 | ||
| 36 | - @Schema(description = "请求附属 Header,JSON 数组", example = "[{\"key\":\"X-Session\",\"value\":\"{session}\"}]") | ||
| 37 | - @Size(max = 2000, message = "请求附属 Header 长度不能超过 2000 个字符") | ||
| 38 | - private String headers; | 37 | + @Schema(description = "请求 Header,JSON 对象", example = "{\"X-Session\":\"{session}\"}") |
| 38 | + private Map<String, String> headers; | ||
| 39 | 39 | ||
| 40 | @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") | 40 | @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") |
| 41 | @NotNull(message = "状态不能为空") | 41 | @NotNull(message = "状态不能为空") |
| @@ -46,10 +46,10 @@ public class Ai1ProviderSaveReqVO { | @@ -46,10 +46,10 @@ public class Ai1ProviderSaveReqVO { | ||
| 46 | @Size(max = 255, message = "备注长度不能超过 255 个字符") | 46 | @Size(max = 255, message = "备注长度不能超过 255 个字符") |
| 47 | private String remark; | 47 | private String remark; |
| 48 | 48 | ||
| 49 | - @AssertTrue(message = "请求附属 Header 格式不正确,应为 JSON 数组:[{\"key\":\"...\",\"value\":\"...\"}]") | 49 | + @AssertTrue(message = "请求 Header 名称不能为空或重复,值必须为字符串,JSON 长度不能超过 2000 个字符") |
| 50 | @JsonIgnore | 50 | @JsonIgnore |
| 51 | public boolean isHeadersValid() { | 51 | public boolean isHeadersValid() { |
| 52 | - return StrUtil.isBlank(headers) || Ai1Utils.parseHeaders(headers) != null; | 52 | + return Ai1Utils.isHeadersValid(headers); |
| 53 | } | 53 | } |
| 54 | 54 | ||
| 55 | } | 55 | } |
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/model/Ai1ProviderDO.java
| @@ -4,9 +4,13 @@ import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; | @@ -4,9 +4,13 @@ import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; | ||
| 4 | import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; | 4 | import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; |
| 5 | import com.baomidou.mybatisplus.annotation.KeySequence; | 5 | import com.baomidou.mybatisplus.annotation.KeySequence; |
| 6 | import com.baomidou.mybatisplus.annotation.TableId; | 6 | import com.baomidou.mybatisplus.annotation.TableId; |
| 7 | +import com.baomidou.mybatisplus.annotation.TableField; | ||
| 8 | +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; | ||
| 7 | import com.baomidou.mybatisplus.annotation.TableName; | 9 | import com.baomidou.mybatisplus.annotation.TableName; |
| 8 | import lombok.*; | 10 | import lombok.*; |
| 9 | 11 | ||
| 12 | +import java.util.Map; | ||
| 13 | + | ||
| 10 | /** | 14 | /** |
| 11 | * AI1 供应商 DO | 15 | * AI1 供应商 DO |
| 12 | * | 16 | * |
| @@ -14,7 +18,7 @@ import lombok.*; | @@ -14,7 +18,7 @@ import lombok.*; | ||
| 14 | * | 18 | * |
| 15 | * @author 芋道源码 | 19 | * @author 芋道源码 |
| 16 | */ | 20 | */ |
| 17 | -@TableName("ai1_provider") | 21 | +@TableName(value = "ai1_provider", autoResultMap = true) |
| 18 | @KeySequence("ai1_provider_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 | 22 | @KeySequence("ai1_provider_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 |
| 19 | @Data | 23 | @Data |
| 20 | @EqualsAndHashCode(callSuper = true) | 24 | @EqualsAndHashCode(callSuper = true) |
| @@ -42,11 +46,12 @@ public class Ai1ProviderDO extends TenantBaseDO { | @@ -42,11 +46,12 @@ public class Ai1ProviderDO extends TenantBaseDO { | ||
| 42 | */ | 46 | */ |
| 43 | private String apiKey; | 47 | private String apiKey; |
| 44 | /** | 48 | /** |
| 45 | - * 请求附属 Header | 49 | + * 请求 Header |
| 46 | * | 50 | * |
| 47 | - * JSON 数组,格式为 [{"key":"...","value":"..."}];value 可包含 {session} 占位符,请求时替换为会话编号 | 51 | + * JSON 对象,格式为 {"X-Session":"{session}"};value 可包含 {session} 占位符,请求时替换为会话编号 |
| 48 | */ | 52 | */ |
| 49 | - private String headers; | 53 | + @TableField(typeHandler = JacksonTypeHandler.class) |
| 54 | + private Map<String, String> headers; | ||
| 50 | /** | 55 | /** |
| 51 | * 状态 | 56 | * 状态 |
| 52 | * | 57 | * |
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/Ai1ErrorCodeConstants.java
| @@ -16,6 +16,7 @@ public interface Ai1ErrorCodeConstants { | @@ -16,6 +16,7 @@ public interface Ai1ErrorCodeConstants { | ||
| 16 | ErrorCode PROVIDER_REMOTE_MODEL_LOAD_FAIL = new ErrorCode(1_041_000_004, "模型拉取失败:请检查供应商地址与 API 密钥"); | 16 | ErrorCode PROVIDER_REMOTE_MODEL_LOAD_FAIL = new ErrorCode(1_041_000_004, "模型拉取失败:请检查供应商地址与 API 密钥"); |
| 17 | ErrorCode PROVIDER_REMOTE_MODEL_EMPTY = new ErrorCode(1_041_000_005, "远程未返回可用模型"); | 17 | ErrorCode PROVIDER_REMOTE_MODEL_EMPTY = new ErrorCode(1_041_000_005, "远程未返回可用模型"); |
| 18 | ErrorCode CONFIG_PLACEHOLDER_NOT_RESOLVED = new ErrorCode(1_041_000_006, "配置({})无法解析,请检查环境变量或配置项"); | 18 | ErrorCode CONFIG_PLACEHOLDER_NOT_RESOLVED = new ErrorCode(1_041_000_006, "配置({})无法解析,请检查环境变量或配置项"); |
| 19 | + ErrorCode PROVIDER_HEADERS_INVALID = new ErrorCode(1_041_000_007, "请求 Header 格式不正确或名称重复,请检查供应商配置"); | ||
| 19 | 20 | ||
| 20 | // ========== 模型 1-041-001-000 ========== | 21 | // ========== 模型 1-041-001-000 ========== |
| 21 | ErrorCode MODEL_NOT_EXISTS = new ErrorCode(1_041_001_000, "模型不存在"); | 22 | ErrorCode MODEL_NOT_EXISTS = new ErrorCode(1_041_001_000, "模型不存在"); |
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmChatTool.java
| @@ -51,7 +51,7 @@ public class Ai1LlmChatTool { | @@ -51,7 +51,7 @@ public class Ai1LlmChatTool { | ||
| 51 | * @param content 当前用户消息 | 51 | * @param content 当前用户消息 |
| 52 | * @param tools 工具(ToolCallback 或 @Tool 对象),可为空 | 52 | * @param tools 工具(ToolCallback 或 @Tool 对象),可为空 |
| 53 | * @param advisors 会话 Advisor,可为空 | 53 | * @param advisors 会话 Advisor,可为空 |
| 54 | - * @param sessionId 会话编号,用于替换附属 Header 中的 {session} 占位符 | 54 | + * @param sessionId 会话编号,用于替换请求 Header 中的 {session} 占位符 |
| 55 | * @param onThinking 思考过程增量回调,可为空 | 55 | * @param onThinking 思考过程增量回调,可为空 |
| 56 | * @param onContent 回复内容增量回调,可为空 | 56 | * @param onContent 回复内容增量回调,可为空 |
| 57 | * @return 完整回复(内容 + 思考过程) | 57 | * @return 完整回复(内容 + 思考过程) |
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmModelFactory.java
| 1 | package cn.iocoder.yudao.module.ai1.harness.llm; | 1 | package cn.iocoder.yudao.module.ai1.harness.llm; |
| 2 | 2 | ||
| 3 | -import cn.hutool.core.collection.CollUtil; | ||
| 4 | import cn.hutool.core.util.StrUtil; | 3 | import cn.hutool.core.util.StrUtil; |
| 5 | import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; | 4 | import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; |
| 6 | import com.google.common.cache.Cache; | 5 | import com.google.common.cache.Cache; |
| @@ -21,7 +20,6 @@ import org.springframework.util.MultiValueMap; | @@ -21,7 +20,6 @@ import org.springframework.util.MultiValueMap; | ||
| 21 | import java.net.URI; | 20 | import java.net.URI; |
| 22 | import java.time.Duration; | 21 | import java.time.Duration; |
| 23 | import java.util.LinkedHashMap; | 22 | import java.util.LinkedHashMap; |
| 24 | -import java.util.List; | ||
| 25 | import java.util.Map; | 23 | import java.util.Map; |
| 26 | import java.util.Objects; | 24 | import java.util.Objects; |
| 27 | import java.util.function.Predicate; | 25 | import java.util.function.Predicate; |
| @@ -43,7 +41,7 @@ import java.util.function.Predicate; | @@ -43,7 +41,7 @@ import java.util.function.Predicate; | ||
| 43 | public class Ai1LlmModelFactory { | 41 | public class Ai1LlmModelFactory { |
| 44 | 42 | ||
| 45 | /** | 43 | /** |
| 46 | - * 附属 Header 会话占位符:构建模型时替换为当前会话编号 | 44 | + * 请求 Header 会话占位符:构建模型时替换为当前会话编号 |
| 47 | */ | 45 | */ |
| 48 | public static final String SESSION_PLACEHOLDER = "{session}"; | 46 | public static final String SESSION_PLACEHOLDER = "{session}"; |
| 49 | /** | 47 | /** |
| @@ -76,7 +74,7 @@ public class Ai1LlmModelFactory { | @@ -76,7 +74,7 @@ public class Ai1LlmModelFactory { | ||
| 76 | * 获取(或构建)对话模型,并包装为 ChatClient | 74 | * 获取(或构建)对话模型,并包装为 ChatClient |
| 77 | * | 75 | * |
| 78 | * @param model 模型运行时快照 | 76 | * @param model 模型运行时快照 |
| 79 | - * @param sessionId 会话编号,用于替换附属 Header 中的 {session} 占位符,可为空 | 77 | + * @param sessionId 会话编号,用于替换请求 Header 中的 {session} 占位符,可为空 |
| 80 | * @return ChatClient | 78 | * @return ChatClient |
| 81 | */ | 79 | */ |
| 82 | public ChatClient buildChatClient(Ai1ModelRespBO model, Long sessionId) { | 80 | public ChatClient buildChatClient(Ai1ModelRespBO model, Long sessionId) { |
| @@ -132,7 +130,7 @@ public class Ai1LlmModelFactory { | @@ -132,7 +130,7 @@ public class Ai1LlmModelFactory { | ||
| 132 | /** | 130 | /** |
| 133 | * 获取(或构建)对话模型 | 131 | * 获取(或构建)对话模型 |
| 134 | * | 132 | * |
| 135 | - * 仅当附属 Header 使用 {session} 占位符(需按会话隔离)时,才把会话编号纳入缓存 key; | 133 | + * 仅当请求 Header 使用 {session} 占位符(需按会话隔离)时,才把会话编号纳入缓存 key; |
| 136 | * 否则同一模型跨会话复用,避免每个会话都构建一个模型实例 | 134 | * 否则同一模型跨会话复用,避免每个会话都构建一个模型实例 |
| 137 | */ | 135 | */ |
| 138 | private OpenAiChatModel getOrCreateChatModel(Ai1ModelRespBO model, Long sessionId) { | 136 | private OpenAiChatModel getOrCreateChatModel(Ai1ModelRespBO model, Long sessionId) { |
| @@ -205,23 +203,23 @@ public class Ai1LlmModelFactory { | @@ -205,23 +203,23 @@ public class Ai1LlmModelFactory { | ||
| 205 | } | 203 | } |
| 206 | 204 | ||
| 207 | /** | 205 | /** |
| 208 | - * 附属 Header 是否使用 {session} 占位符 | 206 | + * 请求 Header 是否使用 {session} 占位符 |
| 209 | */ | 207 | */ |
| 210 | - private static boolean usesSessionHeader(List<Map<String, String>> headers) { | ||
| 211 | - return CollUtil.findOne(headers, header -> StrUtil.contains(header.get("value"), SESSION_PLACEHOLDER)) != null; | 208 | + private static boolean usesSessionHeader(Map<String, String> headers) { |
| 209 | + return headers != null && headers.values().stream().anyMatch(value -> StrUtil.contains(value, SESSION_PLACEHOLDER)); | ||
| 212 | } | 210 | } |
| 213 | 211 | ||
| 214 | /** | 212 | /** |
| 215 | * 构建请求 Header:{session} 占位符替换为会话编号;会话编号为空时,跳过带占位符的 Header | 213 | * 构建请求 Header:{session} 占位符替换为会话编号;会话编号为空时,跳过带占位符的 Header |
| 216 | */ | 214 | */ |
| 217 | - private static Map<String, String> buildHeaders(List<Map<String, String>> headers, Long sessionId) { | 215 | + private static Map<String, String> buildHeaders(Map<String, String> headers, Long sessionId) { |
| 218 | Map<String, String> result = new LinkedHashMap<>(); | 216 | Map<String, String> result = new LinkedHashMap<>(); |
| 219 | - if (CollUtil.isEmpty(headers)) { | 217 | + if (headers == null || headers.isEmpty()) { |
| 220 | return result; | 218 | return result; |
| 221 | } | 219 | } |
| 222 | - for (Map<String, String> header : headers) { | ||
| 223 | - String key = header.get("key"); | ||
| 224 | - String value = header.get("value"); | 220 | + for (Map.Entry<String, String> header : headers.entrySet()) { |
| 221 | + String key = header.getKey(); | ||
| 222 | + String value = header.getValue(); | ||
| 225 | if (StrUtil.isBlank(key)) { | 223 | if (StrUtil.isBlank(key)) { |
| 226 | continue; | 224 | continue; |
| 227 | } | 225 | } |
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/model/Ai1ProviderTool.java
| @@ -58,10 +58,10 @@ public class Ai1ProviderTool { | @@ -58,10 +58,10 @@ public class Ai1ProviderTool { | ||
| 58 | * | 58 | * |
| 59 | * @param baseUrl 接口地址 | 59 | * @param baseUrl 接口地址 |
| 60 | * @param apiKey API 密钥 | 60 | * @param apiKey API 密钥 |
| 61 | - * @param headers 请求附属 Header | 61 | + * @param headers 请求 Header |
| 62 | * @return 测试结果 | 62 | * @return 测试结果 |
| 63 | */ | 63 | */ |
| 64 | - public Ai1ProviderConnectRespVO testConnect(String baseUrl, String apiKey, List<Map<String, String>> headers) { | 64 | + public Ai1ProviderConnectRespVO testConnect(String baseUrl, String apiKey, Map<String, String> headers) { |
| 65 | long startTime = System.currentTimeMillis(); | 65 | long startTime = System.currentTimeMillis(); |
| 66 | String url = normalizeBaseUrl(baseUrl); | 66 | String url = normalizeBaseUrl(baseUrl); |
| 67 | 67 | ||
| @@ -102,10 +102,10 @@ public class Ai1ProviderTool { | @@ -102,10 +102,10 @@ public class Ai1ProviderTool { | ||
| 102 | * | 102 | * |
| 103 | * @param baseUrl 接口地址 | 103 | * @param baseUrl 接口地址 |
| 104 | * @param apiKey API 密钥 | 104 | * @param apiKey API 密钥 |
| 105 | - * @param headers 请求附属 Header | 105 | + * @param headers 请求 Header |
| 106 | * @return 模型标识列表 | 106 | * @return 模型标识列表 |
| 107 | */ | 107 | */ |
| 108 | - public List<String> listModels(String baseUrl, String apiKey, List<Map<String, String>> headers) { | 108 | + public List<String> listModels(String baseUrl, String apiKey, Map<String, String> headers) { |
| 109 | // 1. 请求 GET /models:非 HTTP 200 时,提示检查配置 | 109 | // 1. 请求 GET /models:非 HTTP 200 时,提示检查配置 |
| 110 | String url = normalizeBaseUrl(baseUrl) + "/models"; | 110 | String url = normalizeBaseUrl(baseUrl) + "/models"; |
| 111 | HttpOutcome outcome = executeRequest(Method.GET, url, apiKey, headers, null); | 111 | HttpOutcome outcome = executeRequest(Method.GET, url, apiKey, headers, null); |
| @@ -153,12 +153,12 @@ public class Ai1ProviderTool { | @@ -153,12 +153,12 @@ public class Ai1ProviderTool { | ||
| 153 | * @param method 请求方法 | 153 | * @param method 请求方法 |
| 154 | * @param url 请求地址 | 154 | * @param url 请求地址 |
| 155 | * @param apiKey API 密钥 | 155 | * @param apiKey API 密钥 |
| 156 | - * @param headers 请求附属 Header | 156 | + * @param headers 请求 Header |
| 157 | * @param body JSON 请求体,可为空 | 157 | * @param body JSON 请求体,可为空 |
| 158 | * @return 请求结果 | 158 | * @return 请求结果 |
| 159 | */ | 159 | */ |
| 160 | private static HttpOutcome executeRequest(Method method, String url, String apiKey, | 160 | private static HttpOutcome executeRequest(Method method, String url, String apiKey, |
| 161 | - List<Map<String, String>> headers, String body) { | 161 | + Map<String, String> headers, String body) { |
| 162 | try { | 162 | try { |
| 163 | HttpRequest request = buildOpenAiRequest(method, url, apiKey, headers); | 163 | HttpRequest request = buildOpenAiRequest(method, url, apiKey, headers); |
| 164 | if (body != null) { | 164 | if (body != null) { |
| @@ -168,30 +168,32 @@ public class Ai1ProviderTool { | @@ -168,30 +168,32 @@ public class Ai1ProviderTool { | ||
| 168 | return new HttpOutcome(response.getStatus(), null, response.body()); | 168 | return new HttpOutcome(response.getStatus(), null, response.body()); |
| 169 | } | 169 | } |
| 170 | } catch (Exception e) { | 170 | } catch (Exception e) { |
| 171 | - return new HttpOutcome(0, e.getMessage(), null); | 171 | + // HTTP 客户端异常可能包含密钥或 Header 原文,不能直接回传。 |
| 172 | + return new HttpOutcome(0, "请求发送失败,请检查服务地址、密钥及 Header(" | ||
| 173 | + + e.getClass().getSimpleName() + ")", null); | ||
| 172 | } | 174 | } |
| 173 | } | 175 | } |
| 174 | 176 | ||
| 175 | /** | 177 | /** |
| 176 | - * 构建 OpenAI 兼容接口的 HTTP 请求:携带 API 密钥与静态附属 Header,包含 {session} 占位符的 Header 跳过 | 178 | + * 构建 OpenAI 兼容接口的 HTTP 请求:携带 API 密钥与静态 Header,包含 {session} 占位符的 Header 跳过 |
| 177 | * | 179 | * |
| 178 | * @param method 请求方法 | 180 | * @param method 请求方法 |
| 179 | * @param url 请求地址 | 181 | * @param url 请求地址 |
| 180 | * @param apiKey API 密钥 | 182 | * @param apiKey API 密钥 |
| 181 | - * @param headers 请求附属 Header | 183 | + * @param headers 请求 Header |
| 182 | * @return HTTP 请求 | 184 | * @return HTTP 请求 |
| 183 | */ | 185 | */ |
| 184 | - private static HttpRequest buildOpenAiRequest(Method method, String url, String apiKey, List<Map<String, String>> headers) { | 186 | + private static HttpRequest buildOpenAiRequest(Method method, String url, String apiKey, Map<String, String> headers) { |
| 185 | HttpRequest request = HttpRequest.of(url).method(method) | 187 | HttpRequest request = HttpRequest.of(url).method(method) |
| 186 | .setConnectionTimeout(CONNECT_TIMEOUT).setReadTimeout(READ_TIMEOUT) | 188 | .setConnectionTimeout(CONNECT_TIMEOUT).setReadTimeout(READ_TIMEOUT) |
| 187 | .header(Header.ACCEPT, ContentType.JSON.getValue()); | 189 | .header(Header.ACCEPT, ContentType.JSON.getValue()); |
| 188 | if (StrUtil.isNotBlank(apiKey)) { | 190 | if (StrUtil.isNotBlank(apiKey)) { |
| 189 | request.bearerAuth(apiKey); | 191 | request.bearerAuth(apiKey); |
| 190 | } | 192 | } |
| 191 | - if (CollUtil.isNotEmpty(headers)) { | ||
| 192 | - for (Map<String, String> header : headers) { | ||
| 193 | - String key = header.get("key"); | ||
| 194 | - String value = header.get("value"); | 193 | + if (MapUtil.isNotEmpty(headers)) { |
| 194 | + for (Map.Entry<String, String> header : headers.entrySet()) { | ||
| 195 | + String key = header.getKey(); | ||
| 196 | + String value = header.getValue(); | ||
| 195 | if (StrUtil.isBlank(key) || StrUtil.contains(value, Ai1LlmModelFactory.SESSION_PLACEHOLDER)) { | 197 | if (StrUtil.isBlank(key) || StrUtil.contains(value, Ai1LlmModelFactory.SESSION_PLACEHOLDER)) { |
| 196 | continue; | 198 | continue; |
| 197 | } | 199 | } |
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionStreamTool.java
| @@ -304,7 +304,7 @@ public class Ai1SessionStreamTool implements SmartLifecycle { | @@ -304,7 +304,7 @@ public class Ai1SessionStreamTool implements SmartLifecycle { | ||
| 304 | // 1.2 生成所需的业务字段 | 304 | // 1.2 生成所需的业务字段 |
| 305 | fields.put(FIELD_MESSAGE_ID, String.valueOf(messageId)); | 305 | fields.put(FIELD_MESSAGE_ID, String.valueOf(messageId)); |
| 306 | fields.put(FIELD_AGENT_ID, String.valueOf(agentId)); | 306 | fields.put(FIELD_AGENT_ID, String.valueOf(agentId)); |
| 307 | - // 生成时,附属 Header 中的 {session} 占位符替换为此处的会话编号 | 307 | + // 生成时,请求 Header 中的 {session} 占位符替换为此处的会话编号 |
| 308 | fields.put(FIELD_SESSION_ID, String.valueOf(sessionId)); | 308 | fields.put(FIELD_SESSION_ID, String.valueOf(sessionId)); |
| 309 | fields.put(FIELD_CONTENT, content); | 309 | fields.put(FIELD_CONTENT, content); |
| 310 | 310 |
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillToolFactory.java
| @@ -157,16 +157,18 @@ public class Ai1SkillToolFactory { | @@ -157,16 +157,18 @@ public class Ai1SkillToolFactory { | ||
| 157 | // 2.1 清空目录后,逐个物化开启的 SKILL | 157 | // 2.1 清空目录后,逐个物化开启的 SKILL |
| 158 | FileUtil.del(root); | 158 | FileUtil.del(root); |
| 159 | FileUtil.mkdir(root.toFile()); | 159 | FileUtil.mkdir(root.toFile()); |
| 160 | - boolean hasSkill = false; | ||
| 161 | - for (Ai1SkillDO skill : skills) { | 160 | + List<String> skillDirectories = new ArrayList<>(); |
| 161 | + // 固定注册顺序;同名 frontmatter 按 agent-utils 的首次注册规则处理。 | ||
| 162 | + for (Ai1SkillDO skill : skills.stream().sorted(Comparator.comparing(Ai1SkillDO::getId)).toList()) { | ||
| 162 | if (CommonStatusEnum.isEnable(skill.getStatus()) | 163 | if (CommonStatusEnum.isEnable(skill.getStatus()) |
| 163 | && materializeSkill(root.resolve(sanitize(skill.getName())), skill.getId())) { | 164 | && materializeSkill(root.resolve(sanitize(skill.getName())), skill.getId())) { |
| 164 | - hasSkill = true; | 165 | + skillDirectories.add(root.resolve(sanitize(skill.getName())).toString()); |
| 165 | } | 166 | } |
| 166 | } | 167 | } |
| 167 | // 2.2 写入指纹标记,构建技能工具并缓存快照 | 168 | // 2.2 写入指纹标记,构建技能工具并缓存快照 |
| 168 | FileUtil.writeUtf8String(fingerprint, root.resolve(FILE_NAME_FINGERPRINT).toFile()); | 169 | FileUtil.writeUtf8String(fingerprint, root.resolve(FILE_NAME_FINGERPRINT).toFile()); |
| 169 | - ToolCallback skillsTool = hasSkill ? SkillsTool.builder().addSkillsDirectory(root.toString()).build() : null; | 170 | + ToolCallback skillsTool = skillDirectories.isEmpty() ? null |
| 171 | + : SkillsTool.builder().addSkillsDirectories(skillDirectories).build(); | ||
| 170 | SkillSnapshot snapshot = new SkillSnapshot(fingerprint, root, skillsTool); | 172 | SkillSnapshot snapshot = new SkillSnapshot(fingerprint, root, skillsTool); |
| 171 | snapshotCache.put(agent.getId(), snapshot); | 173 | snapshotCache.put(agent.getId(), snapshot); |
| 172 | log.info("[sync][Agent({}) SKILL 物化完成,目录({})]", agent.getId(), root); | 174 | log.info("[sync][Agent({}) SKILL 物化完成,目录({})]", agent.getId(), root); |
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelServiceImpl.java
| @@ -29,7 +29,6 @@ import java.util.*; | @@ -29,7 +29,6 @@ import java.util.*; | ||
| 29 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | 29 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 30 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; | 30 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; |
| 31 | import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; | 31 | import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; |
| 32 | -import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.parseHeaders; | ||
| 33 | import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.resolveSpringPlaceholders; | 32 | import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.resolveSpringPlaceholders; |
| 34 | 33 | ||
| 35 | /** | 34 | /** |
| @@ -176,7 +175,7 @@ public class Ai1ModelServiceImpl implements Ai1ModelService { | @@ -176,7 +175,7 @@ public class Ai1ModelServiceImpl implements Ai1ModelService { | ||
| 176 | 175 | ||
| 177 | // 2. 拉取远程模型:调用前解析 ${ENV} 占位符,库中的原文不改 | 176 | // 2. 拉取远程模型:调用前解析 ${ENV} 占位符,库中的原文不改 |
| 178 | return providerTool.listModels(resolveSpringPlaceholders(provider.getBaseUrl()), | 177 | return providerTool.listModels(resolveSpringPlaceholders(provider.getBaseUrl()), |
| 179 | - resolveSpringPlaceholders(provider.getApiKey()), parseHeaders(resolveSpringPlaceholders(provider.getHeaders()))); | 178 | + resolveSpringPlaceholders(provider.getApiKey()), resolveSpringPlaceholders(provider.getHeaders())); |
| 180 | } | 179 | } |
| 181 | 180 | ||
| 182 | @Override | 181 | @Override |
| @@ -200,7 +199,7 @@ public class Ai1ModelServiceImpl implements Ai1ModelService { | @@ -200,7 +199,7 @@ public class Ai1ModelServiceImpl implements Ai1ModelService { | ||
| 200 | .setModelId(model.getId()).setModel(model.getModel()).setModelType(model.getType()) | 199 | .setModelId(model.getId()).setModel(model.getModel()).setModelType(model.getType()) |
| 201 | .setBaseUrl(resolveSpringPlaceholders(provider.getBaseUrl())) | 200 | .setBaseUrl(resolveSpringPlaceholders(provider.getBaseUrl())) |
| 202 | .setApiKey(resolveSpringPlaceholders(provider.getApiKey())) | 201 | .setApiKey(resolveSpringPlaceholders(provider.getApiKey())) |
| 203 | - .setHeaders(parseHeaders(resolveSpringPlaceholders(provider.getHeaders()))); | 202 | + .setHeaders(resolveSpringPlaceholders(provider.getHeaders())); |
| 204 | } | 203 | } |
| 205 | 204 | ||
| 206 | @Override | 205 | @Override |
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderServiceImpl.java
| @@ -23,7 +23,7 @@ import java.util.List; | @@ -23,7 +23,7 @@ import java.util.List; | ||
| 23 | 23 | ||
| 24 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | 24 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 25 | import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; | 25 | import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; |
| 26 | -import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.parseHeaders; | 26 | +import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.normalizeHeaders; |
| 27 | import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.resolveSpringPlaceholders; | 27 | import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.resolveSpringPlaceholders; |
| 28 | 28 | ||
| 29 | /** | 29 | /** |
| @@ -51,6 +51,7 @@ public class Ai1ProviderServiceImpl implements Ai1ProviderService { | @@ -51,6 +51,7 @@ public class Ai1ProviderServiceImpl implements Ai1ProviderService { | ||
| 51 | @Override | 51 | @Override |
| 52 | public Long createProvider(Ai1ProviderSaveReqVO createReqVO) { | 52 | public Long createProvider(Ai1ProviderSaveReqVO createReqVO) { |
| 53 | Ai1ProviderDO provider = BeanUtils.toBean(createReqVO, Ai1ProviderDO.class); | 53 | Ai1ProviderDO provider = BeanUtils.toBean(createReqVO, Ai1ProviderDO.class); |
| 54 | + provider.setHeaders(normalizeHeaders(createReqVO.getHeaders())); | ||
| 54 | providerMapper.insert(provider); | 55 | providerMapper.insert(provider); |
| 55 | return provider.getId(); | 56 | return provider.getId(); |
| 56 | } | 57 | } |
| @@ -62,6 +63,7 @@ public class Ai1ProviderServiceImpl implements Ai1ProviderService { | @@ -62,6 +63,7 @@ public class Ai1ProviderServiceImpl implements Ai1ProviderService { | ||
| 62 | 63 | ||
| 63 | // 2. 更新:API 密钥为空时置为 null,由 updateById 跳过该字段,保持原密钥 | 64 | // 2. 更新:API 密钥为空时置为 null,由 updateById 跳过该字段,保持原密钥 |
| 64 | Ai1ProviderDO updateObj = BeanUtils.toBean(updateReqVO, Ai1ProviderDO.class); | 65 | Ai1ProviderDO updateObj = BeanUtils.toBean(updateReqVO, Ai1ProviderDO.class); |
| 66 | + updateObj.setHeaders(normalizeHeaders(updateReqVO.getHeaders())); | ||
| 65 | if (StrUtil.isBlank(updateObj.getApiKey())) { | 67 | if (StrUtil.isBlank(updateObj.getApiKey())) { |
| 66 | updateObj.setApiKey(null); | 68 | updateObj.setApiKey(null); |
| 67 | } | 69 | } |
| @@ -132,7 +134,7 @@ public class Ai1ProviderServiceImpl implements Ai1ProviderService { | @@ -132,7 +134,7 @@ public class Ai1ProviderServiceImpl implements Ai1ProviderService { | ||
| 132 | 134 | ||
| 133 | // 2. 连通测试:调用前解析 ${ENV} 占位符,库中的原文不改 | 135 | // 2. 连通测试:调用前解析 ${ENV} 占位符,库中的原文不改 |
| 134 | return providerTool.testConnect(resolveSpringPlaceholders(provider.getBaseUrl()), | 136 | return providerTool.testConnect(resolveSpringPlaceholders(provider.getBaseUrl()), |
| 135 | - resolveSpringPlaceholders(provider.getApiKey()), parseHeaders(resolveSpringPlaceholders(provider.getHeaders()))); | 137 | + resolveSpringPlaceholders(provider.getApiKey()), resolveSpringPlaceholders(provider.getHeaders())); |
| 136 | } | 138 | } |
| 137 | 139 | ||
| 138 | } | 140 | } |
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/bo/Ai1ModelRespBO.java
| @@ -4,7 +4,6 @@ import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; | @@ -4,7 +4,6 @@ import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; | ||
| 4 | import lombok.Data; | 4 | import lombok.Data; |
| 5 | import lombok.experimental.Accessors; | 5 | import lombok.experimental.Accessors; |
| 6 | 6 | ||
| 7 | -import java.util.List; | ||
| 8 | import java.util.Map; | 7 | import java.util.Map; |
| 9 | 8 | ||
| 10 | /** | 9 | /** |
| @@ -46,8 +45,8 @@ public class Ai1ModelRespBO { | @@ -46,8 +45,8 @@ public class Ai1ModelRespBO { | ||
| 46 | */ | 45 | */ |
| 47 | private String apiKey; | 46 | private String apiKey; |
| 48 | /** | 47 | /** |
| 49 | - * 请求附属 Header,每项包含 key、value;value 可包含 {session} 占位符 | 48 | + * 请求 Header,value 可包含 {session} 占位符 |
| 50 | */ | 49 | */ |
| 51 | - private List<Map<String, String>> headers; | 50 | + private Map<String, String> headers; |
| 52 | 51 | ||
| 53 | } | 52 | } |
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/util/Ai1Utils.java
| 1 | package cn.iocoder.yudao.module.ai1.util; | 1 | package cn.iocoder.yudao.module.ai1.util; |
| 2 | 2 | ||
| 3 | -import cn.hutool.core.map.MapUtil; | ||
| 4 | -import cn.hutool.core.util.ClassUtil; | ||
| 5 | import cn.hutool.core.util.StrUtil; | 3 | import cn.hutool.core.util.StrUtil; |
| 6 | import cn.hutool.extra.spring.SpringUtil; | 4 | import cn.hutool.extra.spring.SpringUtil; |
| 7 | import cn.iocoder.yudao.framework.common.util.json.JsonUtils; | 5 | import cn.iocoder.yudao.framework.common.util.json.JsonUtils; |
| 8 | -import com.fasterxml.jackson.core.type.TypeReference; | 6 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; |
| 9 | import org.springframework.core.env.Environment; | 7 | import org.springframework.core.env.Environment; |
| 10 | 8 | ||
| 11 | -import java.util.ArrayList; | ||
| 12 | -import java.util.LinkedHashMap; | ||
| 13 | -import java.util.List; | ||
| 14 | -import java.util.Map; | 9 | +import java.util.*; |
| 15 | 10 | ||
| 16 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | 11 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 17 | import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.CONFIG_PLACEHOLDER_NOT_RESOLVED; | 12 | import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.CONFIG_PLACEHOLDER_NOT_RESOLVED; |
| 13 | +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.PROVIDER_HEADERS_INVALID; | ||
| 18 | 14 | ||
| 19 | /** | 15 | /** |
| 20 | * AI1 工具类 | 16 | * AI1 工具类 |
| @@ -38,7 +34,7 @@ public class Ai1Utils { | @@ -38,7 +34,7 @@ public class Ai1Utils { | ||
| 38 | try { | 34 | try { |
| 39 | return SpringUtil.getBean(Environment.class).resolveRequiredPlaceholders(value); | 35 | return SpringUtil.getBean(Environment.class).resolveRequiredPlaceholders(value); |
| 40 | } catch (IllegalArgumentException ex) { | 36 | } catch (IllegalArgumentException ex) { |
| 41 | - throw exception(CONFIG_PLACEHOLDER_NOT_RESOLVED, value); | 37 | + throw exception(CONFIG_PLACEHOLDER_NOT_RESOLVED, "动态配置项"); |
| 42 | } | 38 | } |
| 43 | } | 39 | } |
| 44 | 40 | ||
| @@ -58,36 +54,37 @@ public class Ai1Utils { | @@ -58,36 +54,37 @@ public class Ai1Utils { | ||
| 58 | } | 54 | } |
| 59 | 55 | ||
| 60 | /** | 56 | /** |
| 61 | - * 解析供应商的请求附属 Header:JSON 数组,格式为 [{"key":"...","value":"..."}] | ||
| 62 | - * | ||
| 63 | - * @param headersJson JSON 字符串 | ||
| 64 | - * @return Header 列表,每项包含 key、value;为空或格式错误时返回 null | 57 | + * 规范化请求 Header,保留输入顺序;不解析环境占位符 |
| 65 | */ | 58 | */ |
| 66 | - public static List<Map<String, String>> parseHeaders(String headersJson) { | ||
| 67 | - if (StrUtil.isBlank(headersJson)) { | ||
| 68 | - return null; | ||
| 69 | - } | ||
| 70 | - List<Map<String, Object>> items = JsonUtils.parseObjectQuietly(headersJson, new TypeReference<List<Map<String, Object>>>() {}); | ||
| 71 | - if (items == null) { | 59 | + public static Map<String, String> normalizeHeaders(Map<String, String> values) { |
| 60 | + if (values == null) { | ||
| 72 | return null; | 61 | return null; |
| 73 | } | 62 | } |
| 74 | - List<Map<String, String>> headers = new ArrayList<>(items.size()); | ||
| 75 | - for (Map<String, Object> item : items) { | ||
| 76 | - // 每项必须是对象,且 key 为标量 | ||
| 77 | - if (item == null || !isSimpleValue(item.get("key"))) { | ||
| 78 | - return null; | 63 | + Map<String, String> headers = new LinkedHashMap<>(); |
| 64 | + Set<String> names = new HashSet<>(); | ||
| 65 | + values.forEach((key, value) -> { | ||
| 66 | + if (StrUtil.isBlank(key) || value == null) { | ||
| 67 | + throw exception(PROVIDER_HEADERS_INVALID); | ||
| 68 | + } | ||
| 69 | + String name = key.trim(); | ||
| 70 | + if (!names.add(name.toLowerCase(Locale.ROOT))) { | ||
| 71 | + throw exception(PROVIDER_HEADERS_INVALID); | ||
| 79 | } | 72 | } |
| 80 | - headers.add(MapUtil.builder("key", MapUtil.getStr(item, "key")) | ||
| 81 | - .put("value", isSimpleValue(item.get("value")) ? MapUtil.getStr(item, "value") : "").build()); | 73 | + headers.put(name, value); |
| 74 | + }); | ||
| 75 | + if (JsonUtils.toJsonString(headers).length() > 2000) { | ||
| 76 | + throw exception(PROVIDER_HEADERS_INVALID); | ||
| 82 | } | 77 | } |
| 83 | return headers; | 78 | return headers; |
| 84 | } | 79 | } |
| 85 | 80 | ||
| 86 | - /** | ||
| 87 | - * 是否为 JSON 标量值:字符串、数字、布尔 | ||
| 88 | - */ | ||
| 89 | - private static boolean isSimpleValue(Object value) { | ||
| 90 | - return value != null && ClassUtil.isSimpleValueType(value.getClass()); | 81 | + public static boolean isHeadersValid(Map<String, String> headers) { |
| 82 | + try { | ||
| 83 | + normalizeHeaders(headers); | ||
| 84 | + return true; | ||
| 85 | + } catch (ServiceException ex) { | ||
| 86 | + return false; | ||
| 87 | + } | ||
| 91 | } | 88 | } |
| 92 | 89 | ||
| 93 | } | 90 | } |
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmModelFactoryTest.java
| @@ -7,13 +7,18 @@ import com.sun.net.httpserver.HttpServer; | @@ -7,13 +7,18 @@ import com.sun.net.httpserver.HttpServer; | ||
| 7 | import org.junit.jupiter.api.AfterEach; | 7 | import org.junit.jupiter.api.AfterEach; |
| 8 | import org.junit.jupiter.api.BeforeEach; | 8 | import org.junit.jupiter.api.BeforeEach; |
| 9 | import org.junit.jupiter.api.Test; | 9 | import org.junit.jupiter.api.Test; |
| 10 | +import org.junit.jupiter.api.io.TempDir; | ||
| 10 | import org.junit.jupiter.params.ParameterizedTest; | 11 | import org.junit.jupiter.params.ParameterizedTest; |
| 11 | import org.junit.jupiter.params.provider.ValueSource; | 12 | import org.junit.jupiter.params.provider.ValueSource; |
| 13 | +import org.springaicommunity.agent.tools.SkillsTool; | ||
| 12 | import org.springframework.ai.embedding.EmbeddingModel; | 14 | import org.springframework.ai.embedding.EmbeddingModel; |
| 15 | +import org.springframework.ai.tool.ToolCallback; | ||
| 13 | import org.springframework.test.util.ReflectionTestUtils; | 16 | import org.springframework.test.util.ReflectionTestUtils; |
| 14 | 17 | ||
| 15 | import java.net.InetSocketAddress; | 18 | import java.net.InetSocketAddress; |
| 16 | import java.nio.charset.StandardCharsets; | 19 | import java.nio.charset.StandardCharsets; |
| 20 | +import java.nio.file.Files; | ||
| 21 | +import java.nio.file.Path; | ||
| 17 | import java.util.ArrayList; | 22 | import java.util.ArrayList; |
| 18 | import java.util.List; | 23 | import java.util.List; |
| 19 | import java.util.Map; | 24 | import java.util.Map; |
| @@ -22,7 +27,7 @@ import java.util.concurrent.CopyOnWriteArrayList; | @@ -22,7 +27,7 @@ import java.util.concurrent.CopyOnWriteArrayList; | ||
| 22 | import static org.junit.jupiter.api.Assertions.*; | 27 | import static org.junit.jupiter.api.Assertions.*; |
| 23 | 28 | ||
| 24 | /** | 29 | /** |
| 25 | - * 模型调用回归:通过本地 HTTP 端点验证真实请求、流式分片和模型缓存。 | 30 | + * {@link Ai1LlmModelFactory} 的单元测试 |
| 26 | */ | 31 | */ |
| 27 | public class Ai1LlmModelFactoryTest { | 32 | public class Ai1LlmModelFactoryTest { |
| 28 | 33 | ||
| @@ -30,9 +35,11 @@ public class Ai1LlmModelFactoryTest { | @@ -30,9 +35,11 @@ public class Ai1LlmModelFactoryTest { | ||
| 30 | private final List<Request> requests = new CopyOnWriteArrayList<>(); | 35 | private final List<Request> requests = new CopyOnWriteArrayList<>(); |
| 31 | private HttpServer server; | 36 | private HttpServer server; |
| 32 | private String baseUrl; | 37 | private String baseUrl; |
| 38 | + private String requestedTool; | ||
| 33 | 39 | ||
| 34 | @BeforeEach | 40 | @BeforeEach |
| 35 | public void before() throws Exception { | 41 | public void before() throws Exception { |
| 42 | + // mock HTTP 服务 | ||
| 36 | server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); | 43 | server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); |
| 37 | server.createContext("/", exchange -> { | 44 | server.createContext("/", exchange -> { |
| 38 | requests.add(new Request(exchange.getRequestURI().getPath(), | 45 | requests.add(new Request(exchange.getRequestURI().getPath(), |
| @@ -41,7 +48,8 @@ public class Ai1LlmModelFactoryTest { | @@ -41,7 +48,8 @@ public class Ai1LlmModelFactoryTest { | ||
| 41 | exchange.getRequestHeaders().getFirst("X-Provider"), | 48 | exchange.getRequestHeaders().getFirst("X-Provider"), |
| 42 | JsonUtils.parseTree(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)))); | 49 | JsonUtils.parseTree(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)))); |
| 43 | boolean embedding = exchange.getRequestURI().getPath().endsWith("/embeddings"); | 50 | boolean embedding = exchange.getRequestURI().getPath().endsWith("/embeddings"); |
| 44 | - String response = embedding | 51 | + boolean callTool = requestedTool != null && requests.size() == 1; |
| 52 | + String response = callTool ? toolCallSse(requestedTool) : embedding | ||
| 45 | ? "{\"object\":\"list\",\"data\":[{\"object\":\"embedding\",\"index\":0,\"embedding\":[0.1,0.2]}],\"model\":\"test-model\",\"usage\":{\"prompt_tokens\":1,\"total_tokens\":1}}" | 53 | ? "{\"object\":\"list\",\"data\":[{\"object\":\"embedding\",\"index\":0,\"embedding\":[0.1,0.2]}],\"model\":\"test-model\",\"usage\":{\"prompt_tokens\":1,\"total_tokens\":1}}" |
| 46 | : sse("{\"role\":\"assistant\",\"reasoning_content\":\"想\"}") | 54 | : sse("{\"role\":\"assistant\",\"reasoning_content\":\"想\"}") |
| 47 | + sse("{\"reasoning_content\":\"想\"}") | 55 | + sse("{\"reasoning_content\":\"想\"}") |
| @@ -67,15 +75,18 @@ public class Ai1LlmModelFactoryTest { | @@ -67,15 +75,18 @@ public class Ai1LlmModelFactoryTest { | ||
| 67 | @ParameterizedTest | 75 | @ParameterizedTest |
| 68 | @ValueSource(strings = {"", "/v1/", "/proxy/v1"}) | 76 | @ValueSource(strings = {"", "/v1/", "/proxy/v1"}) |
| 69 | public void testChat_endpointHeadersAndReasoningDeltas(String prefix) { | 77 | public void testChat_endpointHeadersAndReasoningDeltas(String prefix) { |
| 78 | + // 准备参数 | ||
| 70 | Ai1ModelRespBO model = model(baseUrl + prefix); | 79 | Ai1ModelRespBO model = model(baseUrl + prefix); |
| 71 | Ai1LlmChatTool chatTool = new Ai1LlmChatTool(); | 80 | Ai1LlmChatTool chatTool = new Ai1LlmChatTool(); |
| 72 | ReflectionTestUtils.setField(chatTool, "llmModelFactory", factory); | 81 | ReflectionTestUtils.setField(chatTool, "llmModelFactory", factory); |
| 73 | List<String> thinking = new ArrayList<>(); | 82 | List<String> thinking = new ArrayList<>(); |
| 74 | List<String> content = new ArrayList<>(); | 83 | List<String> content = new ArrayList<>(); |
| 75 | 84 | ||
| 85 | + // 调用 | ||
| 76 | Ai1LlmChatTool.ChatText result = chatTool.chat(model, "系统指令", List.<String[]>of(new String[]{"user", "历史"}), | 86 | Ai1LlmChatTool.ChatText result = chatTool.chat(model, "系统指令", List.<String[]>of(new String[]{"user", "历史"}), |
| 77 | "当前消息", List.of(), List.of(), 42L, thinking::add, content::add); | 87 | "当前消息", List.of(), List.of(), 42L, thinking::add, content::add); |
| 78 | 88 | ||
| 89 | + // 断言 | ||
| 79 | assertEquals("你好", result.getContent()); | 90 | assertEquals("你好", result.getContent()); |
| 80 | assertEquals("想想想到", result.getThinking()); | 91 | assertEquals("想想想到", result.getThinking()); |
| 81 | assertEquals(List.of("想", "想", "想到"), thinking); | 92 | assertEquals(List.of("想", "想", "想到"), thinking); |
| @@ -94,10 +105,14 @@ public class Ai1LlmModelFactoryTest { | @@ -94,10 +105,14 @@ public class Ai1LlmModelFactoryTest { | ||
| 94 | 105 | ||
| 95 | @Test | 106 | @Test |
| 96 | public void testEmbedding_endpointAndHeaders() { | 107 | public void testEmbedding_endpointAndHeaders() { |
| 108 | + // 准备参数 | ||
| 97 | Ai1ModelRespBO model = model(baseUrl + "/proxy/v1/"); | 109 | Ai1ModelRespBO model = model(baseUrl + "/proxy/v1/"); |
| 98 | EmbeddingModel embedding = factory.getOrCreateEmbeddingModel(model); | 110 | EmbeddingModel embedding = factory.getOrCreateEmbeddingModel(model); |
| 99 | 111 | ||
| 100 | - assertArrayEquals(new float[]{0.1F, 0.2F}, embedding.embed("文档内容")); | 112 | + // 调用 |
| 113 | + float[] result = embedding.embed("文档内容"); | ||
| 114 | + // 断言 | ||
| 115 | + assertArrayEquals(new float[]{0.1F, 0.2F}, result); | ||
| 101 | Request request = requests.get(0); | 116 | Request request = requests.get(0); |
| 102 | assertEquals("/proxy/v1/embeddings", request.path()); | 117 | assertEquals("/proxy/v1/embeddings", request.path()); |
| 103 | assertEquals("Bearer test-key", request.authorization()); | 118 | assertEquals("Bearer test-key", request.authorization()); |
| @@ -108,35 +123,79 @@ public class Ai1LlmModelFactoryTest { | @@ -108,35 +123,79 @@ public class Ai1LlmModelFactoryTest { | ||
| 108 | } | 123 | } |
| 109 | 124 | ||
| 110 | @Test | 125 | @Test |
| 126 | + public void testChat_skillCallback(@TempDir Path root) throws Exception { | ||
| 127 | + // 准备参数 | ||
| 128 | + Files.writeString(root.resolve("SKILL.md"), "---\nname: probe\ndescription: test\n---\nunique-skill-content\n"); | ||
| 129 | + ToolCallback skill = SkillsTool.builder().addSkillsDirectory(root.toString()).build(); | ||
| 130 | + requestedTool = skill.getToolDefinition().name(); | ||
| 131 | + Ai1LlmChatTool chat = new Ai1LlmChatTool(); | ||
| 132 | + ReflectionTestUtils.setField(chat, "llmModelFactory", factory); | ||
| 133 | + | ||
| 134 | + // 调用 | ||
| 135 | + Ai1LlmChatTool.ChatText result = chat.chat(model(baseUrl), "system", List.of(), "use skill", | ||
| 136 | + List.of(skill), List.of(), 42L, null, null); | ||
| 137 | + // 断言 | ||
| 138 | + assertEquals("你好", result.getContent()); | ||
| 139 | + assertEquals(2, requests.size()); | ||
| 140 | + assertEquals(requestedTool, requests.get(0).body().get("tools").get(0).get("function").get("name").asText()); | ||
| 141 | + boolean hasToolResult = false; | ||
| 142 | + for (JsonNode message : requests.get(1).body().get("messages")) { | ||
| 143 | + if ("tool".equals(message.get("role").asText())) { | ||
| 144 | + hasToolResult = true; | ||
| 145 | + assertTrue(message.get("content").asText().contains("unique-skill-content")); | ||
| 146 | + } | ||
| 147 | + } | ||
| 148 | + assertTrue(hasToolResult); | ||
| 149 | + assertEquals("42", requests.get(1).session()); | ||
| 150 | + } | ||
| 151 | + | ||
| 152 | + @Test | ||
| 111 | public void testCache_sessionIsolationAndInvalidation() { | 153 | public void testCache_sessionIsolationAndInvalidation() { |
| 154 | + // 准备参数 | ||
| 112 | Ai1ModelRespBO model = model(baseUrl); | 155 | Ai1ModelRespBO model = model(baseUrl); |
| 156 | + | ||
| 157 | + // 调用 | ||
| 113 | Object chat = chatModel(model, 1L); | 158 | Object chat = chatModel(model, 1L); |
| 159 | + // 断言 | ||
| 114 | assertSame(chat, chatModel(model, 1L)); | 160 | assertSame(chat, chatModel(model, 1L)); |
| 115 | assertNotSame(chat, chatModel(model, 2L)); | 161 | assertNotSame(chat, chatModel(model, 2L)); |
| 116 | EmbeddingModel embedding = factory.getOrCreateEmbeddingModel(model); | 162 | EmbeddingModel embedding = factory.getOrCreateEmbeddingModel(model); |
| 117 | assertSame(embedding, factory.getOrCreateEmbeddingModel(model)); | 163 | assertSame(embedding, factory.getOrCreateEmbeddingModel(model)); |
| 118 | 164 | ||
| 165 | + // 调用 | ||
| 119 | factory.evictByModelId(model.getModelId()); | 166 | factory.evictByModelId(model.getModelId()); |
| 167 | + // 断言 | ||
| 120 | assertNotSame(chat, chatModel(model, 1L)); | 168 | assertNotSame(chat, chatModel(model, 1L)); |
| 121 | assertNotSame(embedding, factory.getOrCreateEmbeddingModel(model)); | 169 | assertNotSame(embedding, factory.getOrCreateEmbeddingModel(model)); |
| 122 | - model.setHeaders(List.of(Map.of("key", "X-Provider", "value", "provider"))); | 170 | + |
| 171 | + // 准备参数 | ||
| 172 | + model.setHeaders(Map.of("X-Provider", "provider")); | ||
| 173 | + // 调用 | ||
| 123 | chat = chatModel(model, 1L); | 174 | chat = chatModel(model, 1L); |
| 175 | + // 断言 | ||
| 124 | assertSame(chat, chatModel(model, 2L)); | 176 | assertSame(chat, chatModel(model, 2L)); |
| 177 | + | ||
| 178 | + // 调用 | ||
| 125 | model.setApiKey("changed-key"); | 179 | model.setApiKey("changed-key"); |
| 180 | + // 断言 | ||
| 126 | assertNotSame(chat, chatModel(model, 1L)); | 181 | assertNotSame(chat, chatModel(model, 1L)); |
| 182 | + | ||
| 183 | + // 调用 | ||
| 127 | chat = chatModel(model, 1L); | 184 | chat = chatModel(model, 1L); |
| 128 | factory.evictByProviderId(model.getProviderId()); | 185 | factory.evictByProviderId(model.getProviderId()); |
| 186 | + // 断言 | ||
| 129 | assertNotSame(chat, chatModel(model, 1L)); | 187 | assertNotSame(chat, chatModel(model, 1L)); |
| 130 | } | 188 | } |
| 131 | 189 | ||
| 190 | + // ========== 工具方法 ========== | ||
| 191 | + | ||
| 132 | private Object chatModel(Ai1ModelRespBO model, Long sessionId) { | 192 | private Object chatModel(Ai1ModelRespBO model, Long sessionId) { |
| 133 | return ReflectionTestUtils.invokeMethod(factory, "getOrCreateChatModel", model, sessionId); | 193 | return ReflectionTestUtils.invokeMethod(factory, "getOrCreateChatModel", model, sessionId); |
| 134 | } | 194 | } |
| 135 | 195 | ||
| 136 | private static Ai1ModelRespBO model(String url) { | 196 | private static Ai1ModelRespBO model(String url) { |
| 137 | return new Ai1ModelRespBO().setProviderId(1L).setModelId(2L).setBaseUrl(url).setApiKey("test-key") | 197 | return new Ai1ModelRespBO().setProviderId(1L).setModelId(2L).setBaseUrl(url).setApiKey("test-key") |
| 138 | - .setModel("test-model").setHeaders(List.of(Map.of("key", "X-Session", "value", "{session}"), | ||
| 139 | - Map.of("key", "X-Provider", "value", "provider"))); | 198 | + .setModel("test-model").setHeaders(Map.of("X-Session", "{session}", "X-Provider", "provider")); |
| 140 | } | 199 | } |
| 141 | 200 | ||
| 142 | private static String sse(String delta) { | 201 | private static String sse(String delta) { |
| @@ -144,6 +203,16 @@ public class Ai1LlmModelFactoryTest { | @@ -144,6 +203,16 @@ public class Ai1LlmModelFactoryTest { | ||
| 144 | + "\"choices\":[{\"index\":0,\"delta\":" + delta + ",\"finish_reason\":null}]}\n\n"; | 203 | + "\"choices\":[{\"index\":0,\"delta\":" + delta + ",\"finish_reason\":null}]}\n\n"; |
| 145 | } | 204 | } |
| 146 | 205 | ||
| 206 | + private static String toolCallSse(String name) { | ||
| 207 | + String delta = JsonUtils.toJsonString(Map.of("role", "assistant", "tool_calls", List.of(Map.of( | ||
| 208 | + "index", 0, "id", "call-probe", "type", "function", "function", Map.of( | ||
| 209 | + "name", name, "arguments", "{\"command\":\"probe\"}"))))); | ||
| 210 | + return sse(delta) | ||
| 211 | + + "data: {\"id\":\"test\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"test-model\"," | ||
| 212 | + + "\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n" | ||
| 213 | + + "data: [DONE]\n\n"; | ||
| 214 | + } | ||
| 215 | + | ||
| 147 | private record Request(String path, String authorization, String session, String provider, JsonNode body) { | 216 | private record Request(String path, String authorization, String session, String provider, JsonNode body) { |
| 148 | } | 217 | } |
| 149 | } | 218 | } |
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpRuntimeCompatibilityTest.java
| @@ -16,13 +16,14 @@ import java.util.concurrent.CopyOnWriteArrayList; | @@ -16,13 +16,14 @@ import java.util.concurrent.CopyOnWriteArrayList; | ||
| 16 | import static org.junit.jupiter.api.Assertions.*; | 16 | import static org.junit.jupiter.api.Assertions.*; |
| 17 | 17 | ||
| 18 | /** | 18 | /** |
| 19 | - * 验证 MCP SDK 的真实 HTTP 握手及工具调用。 | 19 | + * {@link Ai1McpClientTool} 的运行时测试 |
| 20 | */ | 20 | */ |
| 21 | public class Ai1McpRuntimeCompatibilityTest { | 21 | public class Ai1McpRuntimeCompatibilityTest { |
| 22 | 22 | ||
| 23 | @Test | 23 | @Test |
| 24 | @Timeout(15) | 24 | @Timeout(15) |
| 25 | public void testStreamableHttp_initializeListAndCall() throws Exception { | 25 | public void testStreamableHttp_initializeListAndCall() throws Exception { |
| 26 | + // mock HTTP 服务 | ||
| 26 | List<String> methods = new CopyOnWriteArrayList<>(); | 27 | List<String> methods = new CopyOnWriteArrayList<>(); |
| 27 | List<String> paths = new CopyOnWriteArrayList<>(); | 28 | List<String> paths = new CopyOnWriteArrayList<>(); |
| 28 | List<String> headers = new CopyOnWriteArrayList<>(); | 29 | List<String> headers = new CopyOnWriteArrayList<>(); |
| @@ -63,13 +64,16 @@ public class Ai1McpRuntimeCompatibilityTest { | @@ -63,13 +64,16 @@ public class Ai1McpRuntimeCompatibilityTest { | ||
| 63 | server.start(); | 64 | server.start(); |
| 64 | Ai1McpClientTool client = new Ai1McpClientTool(); | 65 | Ai1McpClientTool client = new Ai1McpClientTool(); |
| 65 | try { | 66 | try { |
| 67 | + // 准备参数 | ||
| 66 | Ai1McpDO mcp = new Ai1McpDO().setId(1L).setName("probe").setTransport("http") | 68 | Ai1McpDO mcp = new Ai1McpDO().setId(1L).setName("probe").setTransport("http") |
| 67 | .setUrl("http://127.0.0.1:" + server.getAddress().getPort() + "/proxy/mcp?key=test") | 69 | .setUrl("http://127.0.0.1:" + server.getAddress().getPort() + "/proxy/mcp?key=test") |
| 68 | .setHeaders(Map.of("X-Probe", "compatibility")); | 70 | .setHeaders(Map.of("X-Probe", "compatibility")); |
| 69 | 71 | ||
| 72 | + // 调用,并断言工具发现和调用 | ||
| 70 | assertEquals(1, client.listTools(mcp).size()); | 73 | assertEquals(1, client.listTools(mcp).size()); |
| 71 | assertEquals("echo", client.listTools(mcp).get(0).name()); | 74 | assertEquals("echo", client.listTools(mcp).get(0).name()); |
| 72 | assertEquals("hello", client.callTool(mcp, "echo", Map.of("value", "hello"))); | 75 | assertEquals("hello", client.callTool(mcp, "echo", Map.of("value", "hello"))); |
| 76 | + // 断言 HTTP 请求 | ||
| 73 | assertTrue(methods.containsAll(List.of("initialize", "notifications/initialized", "tools/list", "tools/call"))); | 77 | assertTrue(methods.containsAll(List.of("initialize", "notifications/initialized", "tools/list", "tools/call"))); |
| 74 | assertTrue(paths.stream().allMatch("/proxy/mcp?key=test"::equals)); | 78 | assertTrue(paths.stream().allMatch("/proxy/mcp?key=test"::equals)); |
| 75 | assertTrue(headers.stream().allMatch("compatibility"::equals)); | 79 | assertTrue(headers.stream().allMatch("compatibility"::equals)); |
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/model/Ai1ProviderToolTest.java
| 1 | package cn.iocoder.yudao.module.ai1.harness.model; | 1 | package cn.iocoder.yudao.module.ai1.harness.model; |
| 2 | 2 | ||
| 3 | +import cn.hutool.extra.spring.SpringUtil; | ||
| 3 | import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderConnectRespVO; | 4 | import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderConnectRespVO; |
| 5 | +import cn.iocoder.yudao.module.ai1.util.Ai1Utils; | ||
| 6 | +import com.sun.net.httpserver.HttpServer; | ||
| 4 | import org.junit.jupiter.api.Test; | 7 | import org.junit.jupiter.api.Test; |
| 8 | +import org.mockito.MockedStatic; | ||
| 9 | +import org.springframework.core.env.Environment; | ||
| 10 | +import org.springframework.mock.env.MockEnvironment; | ||
| 5 | 11 | ||
| 12 | +import java.net.InetSocketAddress; | ||
| 13 | +import java.nio.charset.StandardCharsets; | ||
| 6 | import java.util.List; | 14 | import java.util.List; |
| 15 | +import java.util.Map; | ||
| 16 | +import java.util.concurrent.CopyOnWriteArrayList; | ||
| 7 | 17 | ||
| 8 | import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; | 18 | import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; |
| 9 | import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.PROVIDER_REMOTE_MODEL_LOAD_FAIL; | 19 | import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.PROVIDER_REMOTE_MODEL_LOAD_FAIL; |
| 10 | import static org.junit.jupiter.api.Assertions.*; | 20 | import static org.junit.jupiter.api.Assertions.*; |
| 21 | +import static org.mockito.Mockito.mockStatic; | ||
| 11 | 22 | ||
| 12 | /** | 23 | /** |
| 13 | * {@link Ai1ProviderTool} 的单元测试 | 24 | * {@link Ai1ProviderTool} 的单元测试 |
| @@ -39,6 +50,68 @@ public class Ai1ProviderToolTest { | @@ -39,6 +50,68 @@ public class Ai1ProviderToolTest { | ||
| 39 | } | 50 | } |
| 40 | 51 | ||
| 41 | @Test | 52 | @Test |
| 53 | + public void testTestConnectAndListModels_staticHeaders() throws Exception { | ||
| 54 | + // mock HTTP 服务 | ||
| 55 | + List<Map<String, String>> received = new CopyOnWriteArrayList<>(); | ||
| 56 | + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); | ||
| 57 | + server.createContext("/proxy/v1/models", exchange -> { | ||
| 58 | + received.add(Map.of("auth", exchange.getRequestHeaders().getFirst("Authorization"), | ||
| 59 | + "test", exchange.getRequestHeaders().getFirst("X-Test"), | ||
| 60 | + "session", String.valueOf(exchange.getRequestHeaders().getFirst("X-Session")))); | ||
| 61 | + byte[] body = "{\"data\":[{\"id\":\"test-model\"}]}".getBytes(StandardCharsets.UTF_8); | ||
| 62 | + exchange.sendResponseHeaders(200, body.length); | ||
| 63 | + try (var output = exchange.getResponseBody()) { | ||
| 64 | + output.write(body); | ||
| 65 | + } | ||
| 66 | + }); | ||
| 67 | + server.start(); | ||
| 68 | + try { | ||
| 69 | + // 准备参数 | ||
| 70 | + String url = "http://127.0.0.1:" + server.getAddress().getPort() + "/proxy/v1/"; | ||
| 71 | + Map<String, String> headers = Map.of("X-Test", "a\"b\\c", "X-Session", "{session}"); | ||
| 72 | + // 调用 | ||
| 73 | + Ai1ProviderConnectRespVO result = providerTool.testConnect(url, "synthetic-key", headers); | ||
| 74 | + List<String> models = providerTool.listModels(url, "synthetic-key", headers); | ||
| 75 | + // 断言 | ||
| 76 | + assertTrue(result.getConnectable()); | ||
| 77 | + assertEquals(List.of("test-model"), models); | ||
| 78 | + assertEquals(2, received.size()); | ||
| 79 | + received.forEach(request -> assertEquals(Map.of("auth", "Bearer synthetic-key", "test", "a\"b\\c", "session", "null"), request)); | ||
| 80 | + } finally { | ||
| 81 | + server.stop(0); | ||
| 82 | + } | ||
| 83 | + } | ||
| 84 | + | ||
| 85 | + @Test | ||
| 86 | + public void testTestConnect_invalidConfiguration() throws Exception { | ||
| 87 | + // 准备参数 | ||
| 88 | + String secret = "synthetic-private-token\nprivate-suffix"; | ||
| 89 | + // mock HTTP 服务 | ||
| 90 | + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); | ||
| 91 | + server.start(); | ||
| 92 | + try (MockedStatic<SpringUtil> spring = mockStatic(SpringUtil.class)) { | ||
| 93 | + // mock 环境变量 | ||
| 94 | + spring.when(() -> SpringUtil.getBean(Environment.class)) | ||
| 95 | + .thenReturn(new MockEnvironment().withProperty("HTTP_HEADER_TOKEN", secret)); | ||
| 96 | + // 准备参数 | ||
| 97 | + String url = "http://127.0.0.1:" + server.getAddress().getPort() + "/v1"; | ||
| 98 | + Map<String, String> headers = Ai1Utils.resolveSpringPlaceholders(Map.of("X-Token", "${HTTP_HEADER_TOKEN}")); | ||
| 99 | + // 调用 | ||
| 100 | + List<Ai1ProviderConnectRespVO> results = List.of( | ||
| 101 | + providerTool.testConnect(url, null, headers), providerTool.testConnect(url, secret, null)); | ||
| 102 | + // 断言 | ||
| 103 | + for (Ai1ProviderConnectRespVO result : results) { | ||
| 104 | + assertFalse(result.getConnectable()); | ||
| 105 | + assertEquals(0, result.getHttpCode()); | ||
| 106 | + assertFalse(result.getMessage().contains("synthetic-private-token")); | ||
| 107 | + assertFalse(result.getMessage().contains("private-suffix")); | ||
| 108 | + } | ||
| 109 | + } finally { | ||
| 110 | + server.stop(0); | ||
| 111 | + } | ||
| 112 | + } | ||
| 113 | + | ||
| 114 | + @Test | ||
| 42 | public void testParseModels() { | 115 | public void testParseModels() { |
| 43 | // 调用,并断言过滤无效模型 | 116 | // 调用,并断言过滤无效模型 |
| 44 | assertEquals(List.of("gpt-4o", "qwen3"), Ai1ProviderTool.parseModels( | 117 | assertEquals(List.of("gpt-4o", "qwen3"), Ai1ProviderTool.parseModels( |
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionStreamToolTest.java
| @@ -4,20 +4,20 @@ import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; | @@ -4,20 +4,20 @@ import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; | ||
| 4 | import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; | 4 | import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; |
| 5 | import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; | 5 | import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; |
| 6 | import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO; | 6 | import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO; |
| 7 | -import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum; | ||
| 8 | -import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum; | ||
| 9 | import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; | 7 | import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; |
| 8 | +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum; | ||
| 9 | +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum; | ||
| 10 | import cn.iocoder.yudao.module.ai1.framework.ai.config.YudaoAi1Properties; | 10 | import cn.iocoder.yudao.module.ai1.framework.ai.config.YudaoAi1Properties; |
| 11 | +import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmChatTool; | ||
| 12 | +import cn.iocoder.yudao.module.ai1.harness.mcp.Ai1McpToolFactory; | ||
| 13 | +import cn.iocoder.yudao.module.ai1.harness.rag.Ai1RagTool; | ||
| 11 | import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillToolFactory; | 14 | import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillToolFactory; |
| 12 | import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; | 15 | import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; |
| 13 | -import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService; | ||
| 14 | -import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionMessageService; | ||
| 15 | import cn.iocoder.yudao.module.ai1.service.knowledge.Ai1KnowledgeBaseService; | 16 | import cn.iocoder.yudao.module.ai1.service.knowledge.Ai1KnowledgeBaseService; |
| 16 | import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; | 17 | import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; |
| 17 | import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; | 18 | import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; |
| 18 | -import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmChatTool; | ||
| 19 | -import cn.iocoder.yudao.module.ai1.harness.mcp.Ai1McpToolFactory; | ||
| 20 | -import cn.iocoder.yudao.module.ai1.harness.rag.Ai1RagTool; | 19 | +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionMessageService; |
| 20 | +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService; | ||
| 21 | import org.junit.jupiter.api.BeforeEach; | 21 | import org.junit.jupiter.api.BeforeEach; |
| 22 | import org.junit.jupiter.api.Test; | 22 | import org.junit.jupiter.api.Test; |
| 23 | import org.junit.jupiter.params.ParameterizedTest; | 23 | import org.junit.jupiter.params.ParameterizedTest; |
| @@ -26,23 +26,23 @@ import org.mockito.ArgumentCaptor; | @@ -26,23 +26,23 @@ import org.mockito.ArgumentCaptor; | ||
| 26 | import org.mockito.InjectMocks; | 26 | import org.mockito.InjectMocks; |
| 27 | import org.mockito.Mock; | 27 | import org.mockito.Mock; |
| 28 | import org.mockito.Spy; | 28 | import org.mockito.Spy; |
| 29 | -import org.redisson.api.RStream; | ||
| 30 | import org.redisson.api.RLock; | 29 | import org.redisson.api.RLock; |
| 30 | +import org.redisson.api.RStream; | ||
| 31 | import org.redisson.api.RedissonClient; | 31 | import org.redisson.api.RedissonClient; |
| 32 | import org.redisson.api.stream.StreamAddArgs; | 32 | import org.redisson.api.stream.StreamAddArgs; |
| 33 | import org.redisson.api.stream.StreamMessageId; | 33 | import org.redisson.api.stream.StreamMessageId; |
| 34 | import org.redisson.api.stream.StreamRangeArgs; | 34 | import org.redisson.api.stream.StreamRangeArgs; |
| 35 | import org.redisson.api.stream.StreamReadArgs; | 35 | import org.redisson.api.stream.StreamReadArgs; |
| 36 | +import org.redisson.client.codec.Codec; | ||
| 36 | import org.springframework.test.util.ReflectionTestUtils; | 37 | import org.springframework.test.util.ReflectionTestUtils; |
| 37 | import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; | 38 | import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; |
| 38 | -import org.redisson.client.codec.Codec; | ||
| 39 | 39 | ||
| 40 | import java.io.IOException; | 40 | import java.io.IOException; |
| 41 | import java.util.ArrayList; | 41 | import java.util.ArrayList; |
| 42 | import java.util.HashMap; | 42 | import java.util.HashMap; |
| 43 | +import java.util.LinkedHashMap; | ||
| 43 | import java.util.List; | 44 | import java.util.List; |
| 44 | import java.util.Map; | 45 | import java.util.Map; |
| 45 | -import java.util.LinkedHashMap; | ||
| 46 | import java.util.concurrent.atomic.AtomicReference; | 46 | import java.util.concurrent.atomic.AtomicReference; |
| 47 | import java.util.function.Consumer; | 47 | import java.util.function.Consumer; |
| 48 | 48 | ||
| @@ -111,14 +111,17 @@ public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest { | @@ -111,14 +111,17 @@ public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest { | ||
| 111 | 111 | ||
| 112 | @Test | 112 | @Test |
| 113 | public void testStreamResult_disconnectedMustNotComplete() throws Exception { | 113 | public void testStreamResult_disconnectedMustNotComplete() throws Exception { |
| 114 | - SseEmitter emitter = mock( | ||
| 115 | - SseEmitter.class); | 114 | + // 准备参数 |
| 115 | + SseEmitter emitter = mock(SseEmitter.class); | ||
| 116 | + // mock 连接断开 | ||
| 116 | doThrow(new IOException("client disconnected")).when(emitter).send( | 117 | doThrow(new IOException("client disconnected")).when(emitter).send( |
| 117 | any(SseEmitter.SseEventBuilder.class)); | 118 | any(SseEmitter.SseEventBuilder.class)); |
| 118 | 119 | ||
| 120 | + // 调用 | ||
| 119 | Boolean shouldComplete = ReflectionTestUtils.invokeMethod( | 121 | Boolean shouldComplete = ReflectionTestUtils.invokeMethod( |
| 120 | sessionStreamTool, "streamResult", emitter, MESSAGE_ID, null, TENANT_ID); | 122 | sessionStreamTool, "streamResult", emitter, MESSAGE_ID, null, TENANT_ID); |
| 121 | 123 | ||
| 124 | + // 断言 | ||
| 122 | assertEquals(Boolean.FALSE, shouldComplete); | 125 | assertEquals(Boolean.FALSE, shouldComplete); |
| 123 | verifyNoInteractions(resultStream); | 126 | verifyNoInteractions(resultStream); |
| 124 | verify(emitter, never()).complete(); | 127 | verify(emitter, never()).complete(); |
| @@ -126,27 +129,33 @@ public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest { | @@ -126,27 +129,33 @@ public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest { | ||
| 126 | 129 | ||
| 127 | @Test | 130 | @Test |
| 128 | public void testStreamResult_terminalMustComplete() { | 131 | public void testStreamResult_terminalMustComplete() { |
| 129 | - SseEmitter emitter = mock( | ||
| 130 | - SseEmitter.class); | 132 | + // 准备参数 |
| 133 | + SseEmitter emitter = mock(SseEmitter.class); | ||
| 134 | + // mock 结果流 | ||
| 131 | when(resultStream.read(any(StreamReadArgs.class))) | 135 | when(resultStream.read(any(StreamReadArgs.class))) |
| 132 | .thenReturn(Map.of(RECORD_ID, Map.of("type", "done", "data", ""))); | 136 | .thenReturn(Map.of(RECORD_ID, Map.of("type", "done", "data", ""))); |
| 133 | 137 | ||
| 138 | + // 调用 | ||
| 134 | Boolean shouldComplete = ReflectionTestUtils.invokeMethod( | 139 | Boolean shouldComplete = ReflectionTestUtils.invokeMethod( |
| 135 | sessionStreamTool, "streamResult", emitter, MESSAGE_ID, null, TENANT_ID); | 140 | sessionStreamTool, "streamResult", emitter, MESSAGE_ID, null, TENANT_ID); |
| 136 | 141 | ||
| 142 | + // 断言 | ||
| 137 | assertEquals(Boolean.TRUE, shouldComplete); | 143 | assertEquals(Boolean.TRUE, shouldComplete); |
| 138 | } | 144 | } |
| 139 | 145 | ||
| 140 | @Test | 146 | @Test |
| 141 | public void testSendErrorAndComplete_disconnectedMustNotComplete() throws Exception { | 147 | public void testSendErrorAndComplete_disconnectedMustNotComplete() throws Exception { |
| 142 | - SseEmitter emitter = mock( | ||
| 143 | - SseEmitter.class); | 148 | + // 准备参数 |
| 149 | + SseEmitter emitter = mock(SseEmitter.class); | ||
| 150 | + // mock 连接断开 | ||
| 144 | doThrow(new IOException("client disconnected")).when(emitter).send( | 151 | doThrow(new IOException("client disconnected")).when(emitter).send( |
| 145 | any(SseEmitter.SseEventBuilder.class)); | 152 | any(SseEmitter.SseEventBuilder.class)); |
| 146 | 153 | ||
| 154 | + // 调用 | ||
| 147 | ReflectionTestUtils.invokeMethod( | 155 | ReflectionTestUtils.invokeMethod( |
| 148 | Ai1SessionStreamTool.class, "sendErrorAndComplete", emitter, "error"); | 156 | Ai1SessionStreamTool.class, "sendErrorAndComplete", emitter, "error"); |
| 149 | 157 | ||
| 158 | + // 断言 | ||
| 150 | verify(emitter, never()).complete(); | 159 | verify(emitter, never()).complete(); |
| 151 | } | 160 | } |
| 152 | 161 |
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillRuntimeCompatibilityTest.java
| @@ -10,29 +10,87 @@ import org.springframework.ai.tool.ToolCallback; | @@ -10,29 +10,87 @@ import org.springframework.ai.tool.ToolCallback; | ||
| 10 | import java.nio.file.Files; | 10 | import java.nio.file.Files; |
| 11 | import java.nio.file.Path; | 11 | import java.nio.file.Path; |
| 12 | import java.util.Arrays; | 12 | import java.util.Arrays; |
| 13 | +import java.util.List; | ||
| 13 | import java.util.Map; | 14 | import java.util.Map; |
| 14 | import java.util.function.Function; | 15 | import java.util.function.Function; |
| 15 | import java.util.stream.Collectors; | 16 | import java.util.stream.Collectors; |
| 16 | 17 | ||
| 17 | import static org.junit.jupiter.api.Assertions.*; | 18 | import static org.junit.jupiter.api.Assertions.*; |
| 18 | 19 | ||
| 19 | -/** | ||
| 20 | - * 验证 AI1 使用的 agent-utils 工具的注册、JSON 入参和实际执行。 | ||
| 21 | - */ | ||
| 22 | public class Ai1SkillRuntimeCompatibilityTest { | 20 | public class Ai1SkillRuntimeCompatibilityTest { |
| 23 | 21 | ||
| 24 | @TempDir | 22 | @TempDir |
| 25 | private Path root; | 23 | private Path root; |
| 26 | 24 | ||
| 27 | @Test | 25 | @Test |
| 28 | - public void testSkillAndExecutionTools() throws Exception { | 26 | + public void testSkillsTool_duplicateName() throws Exception { |
| 27 | + // 准备参数 | ||
| 28 | + Path first = Files.createDirectory(root.resolve("first")); | ||
| 29 | + Path second = Files.createDirectory(root.resolve("second")); | ||
| 30 | + Files.writeString(first.resolve("SKILL.md"), "---\nname: same\ndescription: first\n---\nfirst-content\n"); | ||
| 31 | + Files.writeString(second.resolve("SKILL.md"), "---\nname: same\ndescription: second\n---\nsecond-content\n"); | ||
| 32 | + ToolCallback callback = SkillsTool.builder().addSkillsDirectories(List.of(first.toString(), second.toString())).build(); | ||
| 33 | + | ||
| 34 | + // 调用 | ||
| 35 | + String result = callback.call("{\"command\":\"same\"}"); | ||
| 36 | + // 断言 | ||
| 37 | + assertTrue(result.contains("first-content")); | ||
| 38 | + assertTrue(result.contains(first.toString())); | ||
| 39 | + assertFalse(result.contains("second-content")); | ||
| 40 | + assertFalse(callback.getToolDefinition().description().contains("<description>second</description>")); | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + @Test | ||
| 44 | + public void testFileTools_pathConfinement() throws Exception { | ||
| 45 | + // 准备参数 | ||
| 46 | + Path nested = Files.createDirectory(root.resolve("nested")); | ||
| 47 | + Files.writeString(nested.resolve("marker.txt"), "inside-marker"); | ||
| 48 | + Path outside = Files.createTempDirectory(root.getParent(), "ai1-outside-"); | ||
| 49 | + try { | ||
| 50 | + Files.writeString(outside.resolve("private.txt"), "outside-marker"); | ||
| 51 | + Files.createSymbolicLink(root.resolve("escape"), outside); | ||
| 52 | + Map<String, ToolCallback> callbacks = callbacks(); | ||
| 53 | + | ||
| 54 | + // 调用,并断言目录内搜索 | ||
| 55 | + for (String path : List.of("nested", nested.toString())) { | ||
| 56 | + assertTrue(call(callbacks, "Glob", Map.of("pattern", "*.txt", "path", path)).contains("marker.txt")); | ||
| 57 | + assertTrue(call(callbacks, "Grep", Map.of("pattern", "inside-marker", "path", path)).contains("marker.txt")); | ||
| 58 | + assertTrue(call(callbacks, "ListDirectory", Map.of("path", path)).contains("marker.txt")); | ||
| 59 | + } | ||
| 60 | + // 调用,并断言目录外搜索 | ||
| 61 | + for (String path : List.of("../" + outside.getFileName(), outside.toString(), "escape")) { | ||
| 62 | + assertFalse(call(callbacks, "Glob", Map.of("pattern", "*.txt", "path", path)).contains("private.txt")); | ||
| 63 | + assertFalse(call(callbacks, "Grep", Map.of("pattern", "outside-marker", "path", path)).contains("private.txt")); | ||
| 64 | + assertFalse(call(callbacks, "ListDirectory", Map.of("path", path)).contains("private.txt")); | ||
| 65 | + } | ||
| 66 | + // 调用,并断言目录外文件读写 | ||
| 67 | + Path privateFile = outside.resolve("private.txt"); | ||
| 68 | + assertFalse(call(callbacks, "Read", Map.of("filePath", privateFile.toString())).contains("outside-marker")); | ||
| 69 | + call(callbacks, "Write", Map.of("filePath", privateFile.toString(), "content", "changed")); | ||
| 70 | + call(callbacks, "Edit", Map.of("filePath", root.resolve("escape/private.txt").toString(), | ||
| 71 | + "old_string", "outside-marker", "new_string", "changed")); | ||
| 72 | + // 断言 | ||
| 73 | + assertEquals("outside-marker", Files.readString(privateFile)); | ||
| 74 | + } finally { | ||
| 75 | + Files.deleteIfExists(root.resolve("escape")); | ||
| 76 | + Files.deleteIfExists(outside.resolve("private.txt")); | ||
| 77 | + Files.deleteIfExists(outside); | ||
| 78 | + } | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | + @Test | ||
| 82 | + public void testTools_execute() throws Exception { | ||
| 83 | + // 准备参数 | ||
| 29 | Path skill = Files.createDirectory(root.resolve("probe")); | 84 | Path skill = Files.createDirectory(root.resolve("probe")); |
| 30 | Files.writeString(skill.resolve("SKILL.md"), "---\nname: probe\ndescription: 兼容性测试\n---\n执行测试脚本\n"); | 85 | Files.writeString(skill.resolve("SKILL.md"), "---\nname: probe\ndescription: 兼容性测试\n---\n执行测试脚本\n"); |
| 31 | Files.writeString(root.resolve("probe.txt"), "compatibility-ok\n"); | 86 | Files.writeString(root.resolve("probe.txt"), "compatibility-ok\n"); |
| 32 | ToolCallback skillCallback = SkillsTool.builder().addSkillsDirectory(root.toString()).build(); | 87 | ToolCallback skillCallback = SkillsTool.builder().addSkillsDirectory(root.toString()).build(); |
| 88 | + | ||
| 89 | + // 调用,并断言技能读取 | ||
| 33 | assertTrue(skillCallback.getToolDefinition().inputSchema().contains("command")); | 90 | assertTrue(skillCallback.getToolDefinition().inputSchema().contains("command")); |
| 34 | assertTrue(skillCallback.call("{\"command\":\"probe\"}").contains("执行测试脚本")); | 91 | assertTrue(skillCallback.call("{\"command\":\"probe\"}").contains("执行测试脚本")); |
| 35 | 92 | ||
| 93 | + // 准备执行工具 | ||
| 36 | Map<String, ToolCallback> callbacks = Arrays.stream(ToolCallbacks.from( | 94 | Map<String, ToolCallback> callbacks = Arrays.stream(ToolCallbacks.from( |
| 37 | ShellTools.builder().workingDirectory(root).build(), | 95 | ShellTools.builder().workingDirectory(root).build(), |
| 38 | FileSystemTools.builder().allowedDirectory(root).build(), | 96 | FileSystemTools.builder().allowedDirectory(root).build(), |
| @@ -41,21 +99,27 @@ public class Ai1SkillRuntimeCompatibilityTest { | @@ -41,21 +99,27 @@ public class Ai1SkillRuntimeCompatibilityTest { | ||
| 41 | ListDirectoryTool.builder().workingDirectory(root).allowedDirectory(root).build())) | 99 | ListDirectoryTool.builder().workingDirectory(root).allowedDirectory(root).build())) |
| 42 | .collect(Collectors.toMap(callback -> callback.getToolDefinition().name(), Function.identity())); | 100 | .collect(Collectors.toMap(callback -> callback.getToolDefinition().name(), Function.identity())); |
| 43 | 101 | ||
| 102 | + // 调用,并断言命令执行和文件搜索 | ||
| 44 | assertTrue(call(callbacks, "Bash", Map.of("command", "pwd", "timeout", 5000)).contains(root.toRealPath().toString())); | 103 | assertTrue(call(callbacks, "Bash", Map.of("command", "pwd", "timeout", 5000)).contains(root.toRealPath().toString())); |
| 45 | assertTrue(call(callbacks, "Read", Map.of("filePath", root.resolve("probe.txt").toString())).contains("compatibility-ok")); | 104 | assertTrue(call(callbacks, "Read", Map.of("filePath", root.resolve("probe.txt").toString())).contains("compatibility-ok")); |
| 46 | assertTrue(call(callbacks, "Glob", Map.of("pattern", "*.txt")).contains("probe.txt")); | 105 | assertTrue(call(callbacks, "Glob", Map.of("pattern", "*.txt")).contains("probe.txt")); |
| 47 | assertTrue(call(callbacks, "Grep", Map.of("pattern", "compatibility-ok")).contains("probe.txt")); | 106 | assertTrue(call(callbacks, "Grep", Map.of("pattern", "compatibility-ok")).contains("probe.txt")); |
| 48 | assertTrue(call(callbacks, "ListDirectory", Map.of()).contains("probe.txt")); | 107 | assertTrue(call(callbacks, "ListDirectory", Map.of()).contains("probe.txt")); |
| 49 | 108 | ||
| 109 | + // 调用 | ||
| 50 | call(callbacks, "Write", Map.of("filePath", root.resolve("written.txt").toString(), "content", "before")); | 110 | call(callbacks, "Write", Map.of("filePath", root.resolve("written.txt").toString(), "content", "before")); |
| 111 | + // 断言 | ||
| 51 | assertEquals("before", Files.readString(root.resolve("written.txt"))); | 112 | assertEquals("before", Files.readString(root.resolve("written.txt"))); |
| 113 | + // 调用 | ||
| 52 | call(callbacks, "Edit", Map.of("filePath", root.resolve("written.txt").toString(), "old_string", "before", "new_string", "after")); | 114 | call(callbacks, "Edit", Map.of("filePath", root.resolve("written.txt").toString(), "old_string", "before", "new_string", "after")); |
| 115 | + // 断言 | ||
| 53 | assertEquals("after", Files.readString(root.resolve("written.txt"))); | 116 | assertEquals("after", Files.readString(root.resolve("written.txt"))); |
| 54 | 117 | ||
| 55 | - // 保留 0.12.0 的目录约束,不能因迁移而放宽文件和搜索工具访问范围。 | 118 | + // 准备目录外文件 |
| 56 | Path outside = Files.createTempFile(root.getParent(), "ai1-outside-", ".txt"); | 119 | Path outside = Files.createTempFile(root.getParent(), "ai1-outside-", ".txt"); |
| 57 | try { | 120 | try { |
| 58 | Files.writeString(outside, "private-marker"); | 121 | Files.writeString(outside, "private-marker"); |
| 122 | + // 调用,并断言目录外访问 | ||
| 59 | assertFalse(call(callbacks, "Read", Map.of("filePath", outside.toString())).contains("private-marker")); | 123 | assertFalse(call(callbacks, "Read", Map.of("filePath", outside.toString())).contains("private-marker")); |
| 60 | assertFalse(call(callbacks, "Glob", Map.of("pattern", "*.txt", "path", root.getParent().toString())).contains(outside.getFileName().toString())); | 124 | assertFalse(call(callbacks, "Glob", Map.of("pattern", "*.txt", "path", root.getParent().toString())).contains(outside.getFileName().toString())); |
| 61 | } finally { | 125 | } finally { |
| @@ -63,6 +127,17 @@ public class Ai1SkillRuntimeCompatibilityTest { | @@ -63,6 +127,17 @@ public class Ai1SkillRuntimeCompatibilityTest { | ||
| 63 | } | 127 | } |
| 64 | } | 128 | } |
| 65 | 129 | ||
| 130 | + // ========== 工具方法 ========== | ||
| 131 | + | ||
| 132 | + private Map<String, ToolCallback> callbacks() { | ||
| 133 | + return Arrays.stream(ToolCallbacks.from( | ||
| 134 | + FileSystemTools.builder().allowedDirectory(root).build(), | ||
| 135 | + GlobTool.builder().workingDirectory(root).allowedDirectory(root).build(), | ||
| 136 | + GrepTool.builder().workingDirectory(root).allowedDirectory(root).build(), | ||
| 137 | + ListDirectoryTool.builder().workingDirectory(root).allowedDirectory(root).build())) | ||
| 138 | + .collect(Collectors.toMap(callback -> callback.getToolDefinition().name(), Function.identity())); | ||
| 139 | + } | ||
| 140 | + | ||
| 66 | private static String call(Map<String, ToolCallback> callbacks, String name, Map<String, Object> arguments) { | 141 | private static String call(Map<String, ToolCallback> callbacks, String name, Map<String, Object> arguments) { |
| 67 | ToolCallback callback = callbacks.get(name); | 142 | ToolCallback callback = callbacks.get(name); |
| 68 | assertNotNull(callback, name); | 143 | assertNotNull(callback, name); |
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillToolFactoryTest.java
| @@ -160,6 +160,37 @@ public class Ai1SkillToolFactoryTest extends BaseMockitoUnitTest { | @@ -160,6 +160,37 @@ public class Ai1SkillToolFactoryTest extends BaseMockitoUnitTest { | ||
| 160 | verify(skillFileService, times(2)).getSkillFileListBySkillId(SKILL_ID); | 160 | verify(skillFileService, times(2)).getSkillFileListBySkillId(SKILL_ID); |
| 161 | } | 161 | } |
| 162 | 162 | ||
| 163 | + @Test | ||
| 164 | + public void testBuildTools_duplicateFrontmatter() { | ||
| 165 | + // 准备参数 | ||
| 166 | + Ai1SkillDO first = new Ai1SkillDO().setId(10L).setName("first").setStatus(CommonStatusEnum.ENABLE.getStatus()); | ||
| 167 | + Ai1SkillDO second = new Ai1SkillDO().setId(20L).setName("second").setStatus(CommonStatusEnum.ENABLE.getStatus()); | ||
| 168 | + Ai1AgentDO agent = new Ai1AgentDO().setId(AGENT_ID).setSkillIds(List.of(20L, 10L)); | ||
| 169 | + // mock skillService 和 skillFileService 的方法 | ||
| 170 | + when(skillService.getSkillList(agent.getSkillIds())).thenReturn(List.of(second, first)); | ||
| 171 | + for (Ai1SkillDO skill : List.of(first, second)) { | ||
| 172 | + String content = "---\nname: same\ndescription: " + skill.getName() + "\n---\n" + skill.getName() + "-content\n"; | ||
| 173 | + when(skillFileService.getSkillFileListBySkillId(skill.getId())).thenReturn(List.of( | ||
| 174 | + Ai1SkillFileDO.builder().id(skill.getId()).skillId(skill.getId()).parentId(Ai1SkillFileDO.PARENT_ID_ROOT) | ||
| 175 | + .name(Ai1SkillFileDO.NAME_SKILL).type(Ai1SkillFileTypeEnum.FILE.getType()).content(content).build())); | ||
| 176 | + } | ||
| 177 | + | ||
| 178 | + // 调用 | ||
| 179 | + ToolCallback tool = (ToolCallback) skillToolFactory.buildTools(agent).get(0); | ||
| 180 | + String result = tool.call("{\"command\":\"same\"}"); | ||
| 181 | + // 断言 | ||
| 182 | + assertTrue(result.contains("first-content")); | ||
| 183 | + assertTrue(result.contains(agentRoot().resolve("first").toString())); | ||
| 184 | + assertFalse(result.contains("second-content")); | ||
| 185 | + | ||
| 186 | + // 调用 | ||
| 187 | + skillToolFactory.evict(AGENT_ID); | ||
| 188 | + ToolCallback rebuiltTool = (ToolCallback) skillToolFactory.buildTools(agent).get(0); | ||
| 189 | + String rebuiltResult = rebuiltTool.call("{\"command\":\"same\"}"); | ||
| 190 | + // 断言 | ||
| 191 | + assertEquals(result, rebuiltResult); | ||
| 192 | + } | ||
| 193 | + | ||
| 163 | // ========== 测试数据 ========== | 194 | // ========== 测试数据 ========== |
| 164 | 195 | ||
| 165 | private Path agentRoot() { | 196 | private Path agentRoot() { |
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelServiceImplTest.java
| @@ -25,6 +25,7 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; | @@ -25,6 +25,7 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; | ||
| 25 | import java.util.Arrays; | 25 | import java.util.Arrays; |
| 26 | import java.util.Collections; | 26 | import java.util.Collections; |
| 27 | import java.util.List; | 27 | import java.util.List; |
| 28 | +import java.util.Map; | ||
| 28 | import java.util.function.Consumer; | 29 | import java.util.function.Consumer; |
| 29 | 30 | ||
| 30 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; | 31 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; |
| @@ -333,7 +334,7 @@ public class Ai1ModelServiceImplTest extends BaseDbUnitTest { | @@ -333,7 +334,7 @@ public class Ai1ModelServiceImplTest extends BaseDbUnitTest { | ||
| 333 | public void testGetModelRespBO_success() { | 334 | public void testGetModelRespBO_success() { |
| 334 | // mock 方法 | 335 | // mock 方法 |
| 335 | Ai1ProviderDO provider = randomPojo(Ai1ProviderDO.class, o -> o.setStatus(CommonStatusEnum.ENABLE.getStatus()) | 336 | Ai1ProviderDO provider = randomPojo(Ai1ProviderDO.class, o -> o.setStatus(CommonStatusEnum.ENABLE.getStatus()) |
| 336 | - .setHeaders("[{\"key\":\"X-App\",\"value\":\"yudao\"}]")); | 337 | + .setHeaders(Map.of("X-App", "yudao"))); |
| 337 | when(providerService.validateProviderExists(provider.getId())).thenReturn(provider); | 338 | when(providerService.validateProviderExists(provider.getId())).thenReturn(provider); |
| 338 | // mock 数据 | 339 | // mock 数据 |
| 339 | Ai1ModelDO model = randomModelDO(o -> o.setProviderId(provider.getId())); | 340 | Ai1ModelDO model = randomModelDO(o -> o.setProviderId(provider.getId())); |
| @@ -345,7 +346,7 @@ public class Ai1ModelServiceImplTest extends BaseDbUnitTest { | @@ -345,7 +346,7 @@ public class Ai1ModelServiceImplTest extends BaseDbUnitTest { | ||
| 345 | assertEquals(provider.getBaseUrl(), respBO.getBaseUrl()); | 346 | assertEquals(provider.getBaseUrl(), respBO.getBaseUrl()); |
| 346 | assertEquals(model.getModel(), respBO.getModel()); | 347 | assertEquals(model.getModel(), respBO.getModel()); |
| 347 | assertEquals(model.getType(), respBO.getModelType()); | 348 | assertEquals(model.getType(), respBO.getModelType()); |
| 348 | - assertEquals(Collections.singletonList("X-App"), convertList(respBO.getHeaders(), header -> header.get("key"))); | 349 | + assertEquals(Map.of("X-App", "yudao"), respBO.getHeaders()); |
| 349 | } | 350 | } |
| 350 | 351 | ||
| 351 | // ========== 随机对象 ========== | 352 | // ========== 随机对象 ========== |
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderServiceImplTest.java
| @@ -14,17 +14,17 @@ import org.junit.jupiter.api.Test; | @@ -14,17 +14,17 @@ import org.junit.jupiter.api.Test; | ||
| 14 | import org.springframework.context.annotation.Import; | 14 | import org.springframework.context.annotation.Import; |
| 15 | import org.springframework.test.context.bean.override.mockito.MockitoBean; | 15 | import org.springframework.test.context.bean.override.mockito.MockitoBean; |
| 16 | 16 | ||
| 17 | -import java.util.Collections; | 17 | +import java.util.LinkedHashMap; |
| 18 | +import java.util.List; | ||
| 19 | +import java.util.Map; | ||
| 18 | import java.util.function.Consumer; | 20 | import java.util.function.Consumer; |
| 19 | 21 | ||
| 20 | -import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; | ||
| 21 | import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; | 22 | import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; |
| 22 | import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; | 23 | import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; |
| 23 | import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; | 24 | import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; |
| 24 | import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; | 25 | import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; |
| 25 | import static org.junit.jupiter.api.Assertions.*; | 26 | import static org.junit.jupiter.api.Assertions.*; |
| 26 | import static org.mockito.ArgumentMatchers.anyList; | 27 | import static org.mockito.ArgumentMatchers.anyList; |
| 27 | -import static org.mockito.ArgumentMatchers.argThat; | ||
| 28 | import static org.mockito.ArgumentMatchers.eq; | 28 | import static org.mockito.ArgumentMatchers.eq; |
| 29 | import static org.mockito.Mockito.*; | 29 | import static org.mockito.Mockito.*; |
| 30 | 30 | ||
| @@ -52,7 +52,7 @@ public class Ai1ProviderServiceImplTest extends BaseDbUnitTest { | @@ -52,7 +52,7 @@ public class Ai1ProviderServiceImplTest extends BaseDbUnitTest { | ||
| 52 | @Test | 52 | @Test |
| 53 | public void testCreateProvider_success() { | 53 | public void testCreateProvider_success() { |
| 54 | // 准备参数 | 54 | // 准备参数 |
| 55 | - Ai1ProviderSaveReqVO reqVO = randomProviderSaveReqVO(o -> o.setHeaders("[{\"key\":\"X-Session\",\"value\":\"{session}\"}]")); | 55 | + Ai1ProviderSaveReqVO reqVO = randomProviderSaveReqVO(o -> o.setHeaders(Map.of("X-Session", "{session}"))); |
| 56 | 56 | ||
| 57 | // 调用 | 57 | // 调用 |
| 58 | Long id = providerService.createProvider(reqVO); | 58 | Long id = providerService.createProvider(reqVO); |
| @@ -117,12 +117,12 @@ public class Ai1ProviderServiceImplTest extends BaseDbUnitTest { | @@ -117,12 +117,12 @@ public class Ai1ProviderServiceImplTest extends BaseDbUnitTest { | ||
| 117 | @Test | 117 | @Test |
| 118 | public void testTestProviderConnect_success() { | 118 | public void testTestProviderConnect_success() { |
| 119 | // mock 数据 | 119 | // mock 数据 |
| 120 | - Ai1ProviderDO dbProvider = randomProviderDO(o -> o.setHeaders("[{\"key\":\"X-App\",\"value\":\"yudao\"}]")); | 120 | + Ai1ProviderDO dbProvider = randomProviderDO(o -> o.setHeaders(Map.of("X-App", "yudao"))); |
| 121 | providerMapper.insert(dbProvider); | 121 | providerMapper.insert(dbProvider); |
| 122 | // mock 方法 | 122 | // mock 方法 |
| 123 | Ai1ProviderConnectRespVO result = randomPojo(Ai1ProviderConnectRespVO.class); | 123 | Ai1ProviderConnectRespVO result = randomPojo(Ai1ProviderConnectRespVO.class); |
| 124 | when(providerTool.testConnect(eq(dbProvider.getBaseUrl()), eq(dbProvider.getApiKey()), | 124 | when(providerTool.testConnect(eq(dbProvider.getBaseUrl()), eq(dbProvider.getApiKey()), |
| 125 | - argThat(headers -> Collections.singletonList("X-App").equals(convertList(headers, header -> header.get("key")))))) | 125 | + eq(Map.of("X-App", "yudao")))) |
| 126 | .thenReturn(result); | 126 | .thenReturn(result); |
| 127 | 127 | ||
| 128 | // 调用 | 128 | // 调用 |
| @@ -131,6 +131,44 @@ public class Ai1ProviderServiceImplTest extends BaseDbUnitTest { | @@ -131,6 +131,44 @@ public class Ai1ProviderServiceImplTest extends BaseDbUnitTest { | ||
| 131 | assertSame(result, connectResult); | 131 | assertSame(result, connectResult); |
| 132 | } | 132 | } |
| 133 | 133 | ||
| 134 | + @Test | ||
| 135 | + public void testCreateAndUpdateProvider_headers() { | ||
| 136 | + // 准备参数 | ||
| 137 | + Map<String, String> headers = new LinkedHashMap<>(); | ||
| 138 | + headers.put(" X-Z ", "${HEADER_TOKEN}"); | ||
| 139 | + headers.put("X-A", "{session}"); | ||
| 140 | + Ai1ProviderSaveReqVO req = randomProviderSaveReqVO(o -> o.setHeaders(headers)); | ||
| 141 | + | ||
| 142 | + // 调用 | ||
| 143 | + Long id = providerService.createProvider(req); | ||
| 144 | + // 断言 | ||
| 145 | + Ai1ProviderDO created = providerMapper.selectById(id); | ||
| 146 | + assertEquals(List.of("X-Z", "X-A"), List.copyOf(created.getHeaders().keySet())); | ||
| 147 | + assertEquals("${HEADER_TOKEN}", created.getHeaders().get("X-Z")); | ||
| 148 | + assertEquals("{session}", created.getHeaders().get("X-A")); | ||
| 149 | + | ||
| 150 | + // 准备参数 | ||
| 151 | + req.setId(id).setHeaders(null); | ||
| 152 | + // 调用 | ||
| 153 | + providerService.updateProvider(req); | ||
| 154 | + // 断言 | ||
| 155 | + assertEquals(created.getHeaders(), providerMapper.selectById(id).getHeaders()); | ||
| 156 | + | ||
| 157 | + // 准备参数 | ||
| 158 | + req.setHeaders(Map.of("X-New", "new")); | ||
| 159 | + // 调用 | ||
| 160 | + providerService.updateProvider(req); | ||
| 161 | + // 断言 | ||
| 162 | + assertEquals(req.getHeaders(), providerMapper.selectById(id).getHeaders()); | ||
| 163 | + | ||
| 164 | + // 准备参数 | ||
| 165 | + req.setHeaders(Map.of()); | ||
| 166 | + // 调用 | ||
| 167 | + providerService.updateProvider(req); | ||
| 168 | + // 断言 | ||
| 169 | + assertEquals(Map.of(), providerMapper.selectById(id).getHeaders()); | ||
| 170 | + } | ||
| 171 | + | ||
| 134 | // ========== 随机对象 ========== | 172 | // ========== 随机对象 ========== |
| 135 | 173 | ||
| 136 | @SafeVarargs | 174 | @SafeVarargs |
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/util/Ai1UtilsTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.ai1.util; | ||
| 2 | + | ||
| 3 | +import cn.hutool.extra.spring.SpringUtil; | ||
| 4 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; | ||
| 5 | +import org.junit.jupiter.api.Test; | ||
| 6 | +import org.mockito.MockedStatic; | ||
| 7 | +import org.springframework.core.env.Environment; | ||
| 8 | +import org.springframework.mock.env.MockEnvironment; | ||
| 9 | + | ||
| 10 | +import java.util.LinkedHashMap; | ||
| 11 | +import java.util.List; | ||
| 12 | +import java.util.Map; | ||
| 13 | + | ||
| 14 | +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; | ||
| 15 | +import static org.junit.jupiter.api.Assertions.*; | ||
| 16 | +import static org.mockito.Mockito.mockStatic; | ||
| 17 | + | ||
| 18 | +public class Ai1UtilsTest { | ||
| 19 | + | ||
| 20 | + @Test | ||
| 21 | + public void testNormalizeHeaders_orderAndValues() { | ||
| 22 | + // 准备参数 | ||
| 23 | + Map<String, String> input = new LinkedHashMap<>(); | ||
| 24 | + input.put(" X-Z ", "${TEST}"); | ||
| 25 | + input.put("X-A", "{session}"); | ||
| 26 | + | ||
| 27 | + // 调用 | ||
| 28 | + Map<String, String> normalized = Ai1Utils.normalizeHeaders(input); | ||
| 29 | + | ||
| 30 | + // 断言 | ||
| 31 | + assertInstanceOf(LinkedHashMap.class, normalized); | ||
| 32 | + assertEquals(List.of("X-Z", "X-A"), List.copyOf(normalized.keySet())); | ||
| 33 | + assertEquals("${TEST}", normalized.get("X-Z")); | ||
| 34 | + assertEquals("{session}", normalized.get("X-A")); | ||
| 35 | + assertTrue(input.containsKey(" X-Z ")); | ||
| 36 | + assertNotSame(input, normalized); | ||
| 37 | + } | ||
| 38 | + | ||
| 39 | + @Test | ||
| 40 | + public void testNormalizeHeaders_invalid() { | ||
| 41 | + // 准备参数 | ||
| 42 | + Map<String, String> duplicate = new LinkedHashMap<>(); | ||
| 43 | + duplicate.put("X-Test", "static"); | ||
| 44 | + duplicate.put(" x-test ", "{session}"); | ||
| 45 | + Map<String, String> nullValue = new LinkedHashMap<>(); | ||
| 46 | + nullValue.put("X-Test", null); | ||
| 47 | + Map<String, String> nullKey = new LinkedHashMap<>(); | ||
| 48 | + nullKey.put(null, "value"); | ||
| 49 | + | ||
| 50 | + // 调用,并断言异常 | ||
| 51 | + for (Map<String, String> input : List.of(duplicate, nullValue, nullKey, Map.of(" ", "value"))) { | ||
| 52 | + assertFalse(Ai1Utils.isHeadersValid(input)); | ||
| 53 | + ServiceException ex = assertThrows(ServiceException.class, () -> Ai1Utils.normalizeHeaders(input)); | ||
| 54 | + assertEquals(PROVIDER_HEADERS_INVALID.getCode(), ex.getCode()); | ||
| 55 | + assertFalse(ex.getMessage().contains("static")); | ||
| 56 | + } | ||
| 57 | + } | ||
| 58 | + | ||
| 59 | + @Test | ||
| 60 | + public void testNormalizeHeaders_nullAndEmpty() { | ||
| 61 | + // 调用,并断言 | ||
| 62 | + assertNull(Ai1Utils.normalizeHeaders(null)); | ||
| 63 | + assertEquals(Map.of(), Ai1Utils.normalizeHeaders(Map.of())); | ||
| 64 | + assertEquals(Map.of("X-Test", ""), Ai1Utils.normalizeHeaders(Map.of("X-Test", ""))); | ||
| 65 | + assertNull(Ai1Utils.resolveSpringPlaceholders((Map<String, String>) null)); | ||
| 66 | + } | ||
| 67 | + | ||
| 68 | + @Test | ||
| 69 | + public void testIsHeadersValid_jsonLength() { | ||
| 70 | + // 调用,并断言 | ||
| 71 | + assertTrue(Ai1Utils.isHeadersValid(Map.of("X", "a".repeat(1992)))); | ||
| 72 | + assertFalse(Ai1Utils.isHeadersValid(Map.of("X", "a".repeat(1993)))); | ||
| 73 | + assertFalse(Ai1Utils.isHeadersValid(Map.of("X", "\"".repeat(1000)))); | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + @Test | ||
| 77 | + public void testResolveSpringPlaceholders_unresolved() { | ||
| 78 | + // 准备参数 | ||
| 79 | + try (MockedStatic<SpringUtil> spring = mockStatic(SpringUtil.class)) { | ||
| 80 | + spring.when(() -> SpringUtil.getBean(Environment.class)).thenReturn(new MockEnvironment()); | ||
| 81 | + // 调用,并断言异常 | ||
| 82 | + for (String value : List.of("literal-private-api-key-${MISSING}", "https://private-token@host/${MISSING}")) { | ||
| 83 | + ServiceException ex = assertThrows(ServiceException.class, () -> Ai1Utils.resolveSpringPlaceholders(value)); | ||
| 84 | + assertEquals(CONFIG_PLACEHOLDER_NOT_RESOLVED.getCode(), ex.getCode()); | ||
| 85 | + assertFalse(ex.getMessage().contains("private")); | ||
| 86 | + assertFalse(ex.getMessage().contains("Header")); | ||
| 87 | + assertFalse(ex.getMessage().contains(value)); | ||
| 88 | + } | ||
| 89 | + ServiceException env = assertThrows(ServiceException.class, () -> | ||
| 90 | + Ai1Utils.resolveSpringPlaceholders(Map.of("MCP_TOKEN", "private-env-${MISSING}"))); | ||
| 91 | + assertFalse(env.getMessage().contains("Header")); | ||
| 92 | + assertFalse(env.getMessage().contains("private-env")); | ||
| 93 | + } | ||
| 94 | + } | ||
| 95 | + | ||
| 96 | + @Test | ||
| 97 | + public void testResolveSpringPlaceholders_map() { | ||
| 98 | + // 准备参数 | ||
| 99 | + try (MockedStatic<SpringUtil> spring = mockStatic(SpringUtil.class)) { | ||
| 100 | + spring.when(() -> SpringUtil.getBean(Environment.class)) | ||
| 101 | + .thenReturn(new MockEnvironment().withProperty("HEADER_TEST", "a\"b\\c")); | ||
| 102 | + Map<String, String> input = new LinkedHashMap<>(); | ||
| 103 | + input.put("X-Z", "${HEADER_TEST}-{session}"); | ||
| 104 | + input.put("X-A", "unchanged"); | ||
| 105 | + | ||
| 106 | + // 调用 | ||
| 107 | + Map<String, String> resolved = Ai1Utils.resolveSpringPlaceholders(input); | ||
| 108 | + | ||
| 109 | + // 断言 | ||
| 110 | + assertEquals("a\"b\\c-{session}", resolved.get("X-Z")); | ||
| 111 | + assertEquals(List.of("X-Z", "X-A"), List.copyOf(resolved.keySet())); | ||
| 112 | + assertEquals("${HEADER_TEST}-{session}", input.get("X-Z")); | ||
| 113 | + ServiceException ex = assertThrows(ServiceException.class, () -> | ||
| 114 | + Ai1Utils.resolveSpringPlaceholders(Map.of("X-Test", "literal-secret-${MISSING_HEADER_TEST}"))); | ||
| 115 | + assertEquals(CONFIG_PLACEHOLDER_NOT_RESOLVED.getCode(), ex.getCode()); | ||
| 116 | + assertFalse(ex.getMessage().contains("literal-secret")); | ||
| 117 | + } | ||
| 118 | + } | ||
| 119 | +} |