diff --git a/data-agent-frontend/src/services/modelConfig.ts b/data-agent-frontend/src/services/modelConfig.ts index 769b495d0..1521583a1 100644 --- a/data-agent-frontend/src/services/modelConfig.ts +++ b/data-agent-frontend/src/services/modelConfig.ts @@ -34,6 +34,7 @@ export interface ModelConfig { proxyPort?: number; proxyUsername?: string; proxyPassword?: string; + chatApiProtocol?: string; // Chat模型接口协议: CHAT_COMPLETIONS(默认)或 RESPONSES } export interface ModelCheckReady { diff --git a/data-agent-frontend/src/views/ModelConfig.vue b/data-agent-frontend/src/views/ModelConfig.vue index c187b3df1..8920a9d5f 100644 --- a/data-agent-frontend/src/views/ModelConfig.vue +++ b/data-agent-frontend/src/views/ModelConfig.vue @@ -230,12 +230,30 @@ + + + + +
+ 部分模型服务商仅支持 Responses API 格式,此时请选择 Responses API +
+
+ + @@ -374,6 +392,7 @@ proxyPort: 7890, // 给个常用的默认端口 proxyUsername: '', proxyPassword: '', + chatApiProtocol: 'CHAT_COMPLETIONS', }); // 提供商与API地址的映射 @@ -493,13 +512,18 @@ completionsPath: '', embeddingsPath: '', isActive: false, + chatApiProtocol: 'CHAT_COMPLETIONS', }; dialogVisible.value = true; }; const handleEdit = (config: ModelConfig) => { isEditMode.value = true; - formData.value = { ...config }; + // 历史数据的 chatApiProtocol 可能为空(该字段为后续版本新增),回填默认协议避免下拉框空白 + formData.value = { + ...config, + chatApiProtocol: config.chatApiProtocol || 'CHAT_COMPLETIONS', + }; dialogVisible.value = true; }; diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/converter/ModelConfigConverter.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/converter/ModelConfigConverter.java index d07ffbb67..2591b041c 100644 --- a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/converter/ModelConfigConverter.java +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/converter/ModelConfigConverter.java @@ -17,8 +17,10 @@ import com.alibaba.cloud.ai.dataagent.dto.ModelConfigDTO; import com.alibaba.cloud.ai.dataagent.entity.ModelConfig; +import com.alibaba.cloud.ai.dataagent.enums.ChatApiProtocol; import com.alibaba.cloud.ai.dataagent.enums.ModelType; import org.springframework.util.Assert; +import org.springframework.util.StringUtils; import java.time.LocalDateTime; @@ -48,6 +50,7 @@ public static ModelConfigDTO toDTO(ModelConfig entity) { .proxyPort(entity.getProxyPort()) .proxyUsername(entity.getProxyUsername()) .proxyPassword(entity.getProxyPassword()) + .chatApiProtocol(entity.getChatApiProtocol()) .build(); } @@ -74,6 +77,10 @@ public static ModelConfig toEntity(ModelConfigDTO dto) { entity.setProxyPort(dto.getProxyPort()); entity.setProxyUsername(dto.getProxyUsername()); entity.setProxyPassword(dto.getProxyPassword()); + // chatApiProtocol 为空时回填默认协议:INSERT 显式写入 NULL 会绕过数据库列的 DEFAULT 值, + // 统一在转换层兜底,保证新增数据与列默认值口径一致 + entity.setChatApiProtocol(StringUtils.hasText(dto.getChatApiProtocol()) ? dto.getChatApiProtocol() + : ChatApiProtocol.CHAT_COMPLETIONS.getCode()); // 默认值处理 entity.setIsActive(false); entity.setIsDeleted(0); diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/dto/ModelConfigDTO.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/dto/ModelConfigDTO.java index 9a5b13dc4..6ec55adda 100644 --- a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/dto/ModelConfigDTO.java +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/dto/ModelConfigDTO.java @@ -69,4 +69,7 @@ public class ModelConfigDTO { private String proxyPassword; + // Chat 模型接口协议:CHAT_COMPLETIONS(默认)或 RESPONSES,仅 modelType=CHAT 时生效 + private String chatApiProtocol; + } diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/entity/ModelConfig.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/entity/ModelConfig.java index 0773e7229..5994cf235 100644 --- a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/entity/ModelConfig.java +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/entity/ModelConfig.java @@ -70,4 +70,7 @@ public class ModelConfig { private String proxyPassword; + // Chat 模型接口协议:CHAT_COMPLETIONS(默认)或 RESPONSES,仅 modelType=CHAT 时生效 + private String chatApiProtocol; + } diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/enums/ChatApiProtocol.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/enums/ChatApiProtocol.java new file mode 100644 index 000000000..e5890828b --- /dev/null +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/enums/ChatApiProtocol.java @@ -0,0 +1,56 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.alibaba.cloud.ai.dataagent.enums; + +import lombok.Getter; + +/** + * Chat 模型接口协议枚举。 CHAT_COMPLETIONS:标准 OpenAI Chat Completions 格式(默认,/v1/chat/completions); + * RESPONSES:OpenAI Responses API 格式(/v1/responses),用于仅支持该协议的模型服务商。 + */ +@Getter +public enum ChatApiProtocol { + + /** + * 标准 Chat Completions 协议(默认) + */ + CHAT_COMPLETIONS("CHAT_COMPLETIONS"), + + /** + * OpenAI Responses API 协议 + */ + RESPONSES("RESPONSES"); + + private final String code; + + ChatApiProtocol(String code) { + this.code = code; + } + + /** + * 根据代码解析枚举(忽略大小写)。 非法值直接抛异常拦截在保存入口:若不拦截,拼写错误的协议值会在 DynamicModelFactory 静默落入默认的 Chat + * Completions 分支,用户配置悄悄失效且难以排查。 + */ + public static ChatApiProtocol fromCode(String code) { + for (ChatApiProtocol protocol : values()) { + if (protocol.getCode().equalsIgnoreCase(code)) { + return protocol; + } + } + throw new IllegalArgumentException("不支持的 Chat 接口协议: " + code + ",仅支持 CHAT_COMPLETIONS 或 RESPONSES"); + } + +} diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/mapper/ModelConfigMapper.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/mapper/ModelConfigMapper.java index 0ef0cace4..5252dfe7c 100644 --- a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/mapper/ModelConfigMapper.java +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/mapper/ModelConfigMapper.java @@ -1,129 +1,130 @@ -/* - * Copyright 2024-2026 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.alibaba.cloud.ai.dataagent.mapper; - -import com.alibaba.cloud.ai.dataagent.entity.ModelConfig; -import org.apache.ibatis.annotations.*; - -import java.util.List; - -@Mapper -public interface ModelConfigMapper { - - @Select(""" - SELECT id, provider, base_url, api_key, model_name, temperature, is_active, max_tokens, - model_type, completions_path, embeddings_path, created_time, updated_time, is_deleted, - proxy_enabled, proxy_host, proxy_port, proxy_username, proxy_password - FROM model_config WHERE is_deleted = 0 ORDER BY created_time DESC - """) - List findAll(); - - @Select(""" - SELECT id, provider, base_url, api_key, model_name, temperature, is_active, max_tokens, - model_type, completions_path, embeddings_path, created_time, updated_time, is_deleted, - proxy_enabled, proxy_host, proxy_port, proxy_username, proxy_password - FROM model_config WHERE id = #{id} AND is_deleted = 0 - """) - ModelConfig findById(Integer id); - - @Select(""" - SELECT id, provider, base_url, api_key, model_name, temperature, is_active, max_tokens, - model_type, completions_path, embeddings_path, created_time, updated_time, is_deleted, - proxy_enabled, proxy_host, proxy_port, proxy_username, proxy_password - FROM model_config WHERE model_type = #{modelType} AND is_active = 1 AND is_deleted = 0 LIMIT 1 - """) - ModelConfig selectActiveByType(@Param("modelType") String modelType); - - @Update("UPDATE model_config SET is_active = 0 WHERE model_type = #{modelType} AND id != #{currentId} AND is_deleted = 0") - void deactivateOthers(@Param("modelType") String modelType, @Param("currentId") Integer currentId); - - @Select(""" - - """) - List findByConditions(@Param("provider") String provider, @Param("keyword") String keyword, - @Param("isActive") Boolean isActive, @Param("maxTokens") Integer maxTokens, - @Param("modelType") String modelType); - - @Insert(""" - INSERT INTO model_config (provider, base_url, api_key, model_name, temperature, is_active, max_tokens, - model_type, completions_path, embeddings_path, created_time, updated_time, is_deleted, - proxy_enabled, proxy_host, proxy_port, proxy_username, proxy_password) - VALUES (#{provider}, #{baseUrl}, #{apiKey}, #{modelName}, #{temperature}, #{isActive}, #{maxTokens}, - #{modelType}, #{completionsPath}, #{embeddingsPath}, NOW(), NOW(), 0, - #{proxyEnabled}, #{proxyHost}, #{proxyPort}, #{proxyUsername}, #{proxyPassword}) - """) - @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id") - int insert(ModelConfig modelConfig); - - @Update(""" - - """) - int updateById(ModelConfig modelConfig); - - @Update(""" - UPDATE model_config SET is_deleted = 1 WHERE id = #{id} - """) - int deleteById(Integer id); - -} +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.alibaba.cloud.ai.dataagent.mapper; + +import com.alibaba.cloud.ai.dataagent.entity.ModelConfig; +import org.apache.ibatis.annotations.*; + +import java.util.List; + +@Mapper +public interface ModelConfigMapper { + + @Select(""" + SELECT id, provider, base_url, api_key, model_name, temperature, is_active, max_tokens, + model_type, completions_path, embeddings_path, created_time, updated_time, is_deleted, + proxy_enabled, proxy_host, proxy_port, proxy_username, proxy_password, chat_api_protocol + FROM model_config WHERE is_deleted = 0 ORDER BY created_time DESC + """) + List findAll(); + + @Select(""" + SELECT id, provider, base_url, api_key, model_name, temperature, is_active, max_tokens, + model_type, completions_path, embeddings_path, created_time, updated_time, is_deleted, + proxy_enabled, proxy_host, proxy_port, proxy_username, proxy_password, chat_api_protocol + FROM model_config WHERE id = #{id} AND is_deleted = 0 + """) + ModelConfig findById(Integer id); + + @Select(""" + SELECT id, provider, base_url, api_key, model_name, temperature, is_active, max_tokens, + model_type, completions_path, embeddings_path, created_time, updated_time, is_deleted, + proxy_enabled, proxy_host, proxy_port, proxy_username, proxy_password, chat_api_protocol + FROM model_config WHERE model_type = #{modelType} AND is_active = 1 AND is_deleted = 0 LIMIT 1 + """) + ModelConfig selectActiveByType(@Param("modelType") String modelType); + + @Update("UPDATE model_config SET is_active = 0 WHERE model_type = #{modelType} AND id != #{currentId} AND is_deleted = 0") + void deactivateOthers(@Param("modelType") String modelType, @Param("currentId") Integer currentId); + + @Select(""" + + """) + List findByConditions(@Param("provider") String provider, @Param("keyword") String keyword, + @Param("isActive") Boolean isActive, @Param("maxTokens") Integer maxTokens, + @Param("modelType") String modelType); + + @Insert(""" + INSERT INTO model_config (provider, base_url, api_key, model_name, temperature, is_active, max_tokens, + model_type, completions_path, embeddings_path, created_time, updated_time, is_deleted, + proxy_enabled, proxy_host, proxy_port, proxy_username, proxy_password, chat_api_protocol) + VALUES (#{provider}, #{baseUrl}, #{apiKey}, #{modelName}, #{temperature}, #{isActive}, #{maxTokens}, + #{modelType}, #{completionsPath}, #{embeddingsPath}, NOW(), NOW(), 0, + #{proxyEnabled}, #{proxyHost}, #{proxyPort}, #{proxyUsername}, #{proxyPassword}, #{chatApiProtocol}) + """) + @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id") + int insert(ModelConfig modelConfig); + + @Update(""" + + """) + int updateById(ModelConfig modelConfig); + + @Update(""" + UPDATE model_config SET is_deleted = 1 WHERE id = #{id} + """) + int deleteById(Integer id); + +} diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/DynamicModelFactory.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/DynamicModelFactory.java index d57a1458d..139f054f7 100644 --- a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/DynamicModelFactory.java +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/DynamicModelFactory.java @@ -16,6 +16,9 @@ package com.alibaba.cloud.ai.dataagent.service.aimodelconfig; import com.alibaba.cloud.ai.dataagent.dto.ModelConfigDTO; +import com.alibaba.cloud.ai.dataagent.enums.ChatApiProtocol; +import com.alibaba.cloud.ai.dataagent.service.aimodelconfig.responses.ResponsesApi; +import com.alibaba.cloud.ai.dataagent.service.aimodelconfig.responses.ResponsesApiChatModel; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.hc.client5.http.auth.AuthScope; @@ -49,15 +52,51 @@ public class DynamicModelFactory { /** - * 统一使用 OpenAiChatModel,通过 baseUrl 实现多厂商兼容 + * 根据配置的接口协议创建 ChatModel。 RESPONSES 协议走自研 ResponsesApiChatModel; CHAT_COMPLETIONS(默认)走 + * OpenAiChatModel。 两者均实现 ChatModel 接口,对上层完全透明。 */ public ChatModel createChatModel(ModelConfigDTO config) { - log.info("Creating NEW ChatModel instance. Provider: {}, Model: {}, BaseUrl: {}", config.getProvider(), - config.getModelName(), config.getBaseUrl()); + log.info("Creating NEW ChatModel instance. Provider: {}, Model: {}, BaseUrl: {}, Protocol: {}", + config.getProvider(), config.getModelName(), config.getBaseUrl(), config.getChatApiProtocol()); // 1. 验证参数 checkBasic(config); + // 按接口协议分支:RESPONSES 走自研适配层,其余保持现状走 OpenAiChatModel + if (ChatApiProtocol.RESPONSES.name().equalsIgnoreCase(config.getChatApiProtocol())) { + return createResponsesApiChatModel(config); + } + + // 默认:Chat Completions 协议(现有逻辑,零变更) + return createCompletionsChatModel(config); + } + + /** + * 创建基于 Responses API 的 ChatModel。 复用现有的 proxy RestClient/WebClient 构建体系,代理能力天然继承。 + * completionsPath 在 RESPONSES 协议下复用为自定义 responses 路径(默认 /v1/responses)。 + */ + private ChatModel createResponsesApiChatModel(ModelConfigDTO config) { + String apiKey = StringUtils.hasText(config.getApiKey()) ? config.getApiKey() : ""; + // completionsPath 复用为 Responses API 路径,默认 /v1/responses + String responsesPath = StringUtils.hasText(config.getCompletionsPath()) ? config.getCompletionsPath() + : "/v1/responses"; + + ResponsesApi responsesApi = new ResponsesApi(config.getBaseUrl(), apiKey, responsesPath, + getProxiedRestClientBuilder(config), getProxiedWebClientBuilder(config)); + + OpenAiChatOptions chatOptions = OpenAiChatOptions.builder() + .model(config.getModelName()) + .temperature(config.getTemperature()) + .maxTokens(config.getMaxTokens()) + .build(); + + return new ResponsesApiChatModel(responsesApi, chatOptions); + } + + /** + * 创建基于 Chat Completions 协议的 ChatModel(原有逻辑) + */ + private ChatModel createCompletionsChatModel(ModelConfigDTO config) { // 2. 构建 OpenAiApi (核心通讯对象) String apiKey = StringUtils.hasText(config.getApiKey()) ? config.getApiKey() : ""; OpenAiApi.Builder apiBuilder = OpenAiApi.builder() diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/ModelConfigDataServiceImpl.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/ModelConfigDataServiceImpl.java index bac38186e..244c827ef 100644 --- a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/ModelConfigDataServiceImpl.java +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/ModelConfigDataServiceImpl.java @@ -15,6 +15,7 @@ */ package com.alibaba.cloud.ai.dataagent.service.aimodelconfig; +import com.alibaba.cloud.ai.dataagent.enums.ChatApiProtocol; import com.alibaba.cloud.ai.dataagent.enums.ModelType; import com.alibaba.cloud.ai.dataagent.converter.ModelConfigConverter; import com.alibaba.cloud.ai.dataagent.dto.ModelConfigDTO; @@ -24,6 +25,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; import java.time.LocalDateTime; import java.util.List; @@ -81,6 +83,12 @@ private void clean(ModelConfigDTO dto) { if (dto.getEmbeddingsPath() != null) { dto.setEmbeddingsPath(dto.getEmbeddingsPath().trim()); } + // 校验并归一化接口协议:前端下拉框虽限制了取值,但 API 直调可能传任意字符串, + // 非法值若不在保存入口拦截,会在 DynamicModelFactory 静默落入默认协议分支导致配置悄悄失效。 + // fromCode 忽略大小写,归一为枚举标准值后存库,保证库内口径统一 + if (StringUtils.hasText(dto.getChatApiProtocol())) { + dto.setChatApiProtocol(ChatApiProtocol.fromCode(dto.getChatApiProtocol().trim()).getCode()); + } } /** @@ -124,6 +132,7 @@ private static void mergeDtoToEntity(ModelConfigDTO dto, ModelConfig oldEntity) oldEntity.setProxyPort(dto.getProxyPort()); oldEntity.setProxyUsername(dto.getProxyUsername()); oldEntity.setProxyPassword(dto.getProxyPassword()); + oldEntity.setChatApiProtocol(dto.getChatApiProtocol()); // 只有当前端传来的 Key 不包含 "****" 时,才说明用户真的改了 Key,否则保持原样 if (dto.getApiKey() != null && !dto.getApiKey().contains("****")) { diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/responses/ResponsesApi.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/responses/ResponsesApi.java new file mode 100644 index 000000000..85fa531bc --- /dev/null +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/responses/ResponsesApi.java @@ -0,0 +1,309 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.alibaba.cloud.ai.dataagent.service.aimodelconfig.responses; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.MediaType; +import org.springframework.http.codec.ServerSentEvent; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Flux; + +import java.util.List; +import java.util.Map; + +/** + * OpenAI Responses API 低层 HTTP 客户端。 负责构建请求、解析非流式响应与流式 SSE 事件。 协议 DTO 以内嵌 record/class + * 形式定义,覆盖 DataAgent 实际用到的字段子集。 + *

+ * 不传 store、previous_response_id、tools 等字段,DataAgent 不使用这些特性。 + */ +@Slf4j +public class ResponsesApi { + + private final RestClient restClient; + + private final WebClient webClient; + + private final String responsesPath; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + public ResponsesApi(String baseUrl, String apiKey, String responsesPath, RestClient.Builder restClientBuilder, + WebClient.Builder webClientBuilder) { + this.responsesPath = StringUtils.hasText(responsesPath) ? responsesPath : "/v1/responses"; + + // 构建同步 RestClient(用于阻塞调用)。 + // 必须挂载 DEFAULT_RESPONSE_ERROR_HANDLER:它把 4xx 映射为 NonTransientAiException、 + // 5xx 映射为 TransientAiException,而上层 DEFAULT_RETRY_TEMPLATE 只对 TransientAiException + // 重试—— + // 若不挂载,HTTP 错误抛出的是 RestClientResponseException,重试模板会直接放行,重试形同虚设 + this.restClient = restClientBuilder.baseUrl(baseUrl) + .defaultHeader("Authorization", "Bearer " + apiKey) + .defaultHeader("Content-Type", "application/json") + .defaultStatusHandler(RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER) + .build(); + + // 构建异步 WebClient(用于 SSE 流式调用) + this.webClient = webClientBuilder.baseUrl(baseUrl) + .defaultHeader("Authorization", "Bearer " + apiKey) + .defaultHeader("Content-Type", "application/json") + .build(); + } + + // ======================== 同步调用 ======================== + + /** + * 非流式调用 Responses API,返回完整响应。 + * @param request 请求体(stream 字段会被忽略/覆盖为 false) + * @return 完整的 Responses 响应 + */ + public ResponsesResponse call(ResponsesRequest request) { + // 确保非流式 + ResponsesRequest syncRequest = request.withStream(false); + + return restClient.post().uri(this.responsesPath).body(syncRequest).retrieve().body(ResponsesResponse.class); + } + + // ======================== 流式调用 ======================== + + /** + * 流式调用 Responses API,返回 SSE 事件流。 解析 Responses API 特有的事件类型(response.output_text.delta、 + * response.completed 等)。 对于未知事件类型一律忽略,保证向前兼容。 + * @param request 请求体(stream 字段会被覆盖为 true) + * @return SSE 事件流,每个事件已解析为 StreamEvent + */ + public Flux stream(ResponsesRequest request) { + // 确保流式 + ResponsesRequest streamRequest = request.withStream(true); + + return webClient.post() + .uri(this.responsesPath) + .bodyValue(streamRequest) + .accept(MediaType.TEXT_EVENT_STREAM) + .retrieve() + .bodyToFlux(new ParameterizedTypeReference>() { + }) + .filter(sse -> StringUtils.hasText(sse.data())) + .mapNotNull(this::parseStreamEvent); + } + + /** + * 解析单个 SSE 事件的 data 负载。 按事件 type 分发:delta / completed / incomplete / failed / error。 + * 未知事件类型静默忽略(返回 null 被 mapNotNull 过滤掉)。 包私有可见性用于单元测试直接覆盖各事件分支。 + */ + StreamEvent parseStreamEvent(ServerSentEvent sse) { + String data = sse.data(); + if (!StringUtils.hasText(data)) { + return null; + } + try { + Map eventMap = OBJECT_MAPPER.readValue(data, Map.class); + String type = (String) eventMap.get("type"); + if (type == null) { + return null; + } + + return switch (type) { + // 文本增量:产出一个 delta 文本块 + case "response.output_text.delta" -> { + String delta = (String) eventMap.get("delta"); + yield new StreamEvent(StreamEvent.Type.DELTA, delta, null, null); + } + // 响应完成:携带完整响应(含 usage) + case "response.completed" -> { + ResponsesResponse response = OBJECT_MAPPER.convertValue(eventMap.get("response"), + ResponsesResponse.class); + yield new StreamEvent(StreamEvent.Type.COMPLETED, null, response, null); + } + // 响应被截断(如达到 max_output_tokens) + case "response.incomplete" -> { + ResponsesResponse response = OBJECT_MAPPER.convertValue(eventMap.get("response"), + ResponsesResponse.class); + yield new StreamEvent(StreamEvent.Type.INCOMPLETE, null, response, null); + } + // 响应失败 + case "response.failed" -> { + ResponsesResponse response = OBJECT_MAPPER.convertValue(eventMap.get("response"), + ResponsesResponse.class); + String errorMsg = extractErrorMessage(response); + yield new StreamEvent(StreamEvent.Type.ERROR, null, response, errorMsg); + } + // 全局错误事件 + case "error" -> { + String errorMsg = extractErrorFromMap(eventMap); + yield new StreamEvent(StreamEvent.Type.ERROR, null, null, errorMsg); + } + // 其余事件(created、in_progress、output_item.added、output_text.done 等)一律忽略 + default -> null; + }; + } + catch (JsonProcessingException | IllegalArgumentException e) { + // readValue 抛 JsonProcessingException;convertValue 结构不匹配时抛 + // IllegalArgumentException。 + // 两者都按"单条事件损坏"处理:记录日志后跳过,不让一条畸形事件中断整个 SSE 流 + log.warn("解析 Responses API SSE 事件失败,跳过: {}", abbreviate(data), e); + return null; + } + } + + /** 日志输出时保留的 SSE 负载最大长度 */ + private static final int LOG_PAYLOAD_MAX_LENGTH = 500; + + /** + * 截断过长的 SSE 负载用于日志输出。 事件体可能携带完整模型输出,全量打印会造成日志膨胀并把对话内容写进日志, 截断后保留的前缀足够定位事件类型与结构问题。 + */ + private static String abbreviate(String data) { + if (data == null || data.length() <= LOG_PAYLOAD_MAX_LENGTH) { + return data; + } + return data.substring(0, LOG_PAYLOAD_MAX_LENGTH) + "...(已截断,总长度 " + data.length() + ")"; + } + + /** + * 从 ResponsesResponse 的 error 字段提取错误消息 + */ + private String extractErrorMessage(ResponsesResponse response) { + if (response != null && response.error() != null) { + return response.error().message(); + } + return "Responses API 调用失败(未知原因)"; + } + + /** + * 从原始事件 map 的 error 字段提取错误消息 + */ + @SuppressWarnings("unchecked") + private String extractErrorFromMap(Map eventMap) { + Object error = eventMap.get("error"); + if (error instanceof Map errorMap) { + Object message = errorMap.get("message"); + if (message instanceof String msg) { + return msg; + } + } + return "Responses API 返回错误事件"; + } + + // ======================== 协议 DTO ======================== + + /** + * Responses API 请求体。 只包含 DataAgent 实际需要的字段,不传 tools/store/previous_response_id 等。 + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record ResponsesRequest(String model, String instructions, List input, Double temperature, + @JsonProperty("max_output_tokens") Integer maxOutputTokens, Boolean stream) { + + /** + * 返回仅覆盖 stream 标志的副本。 call/stream 入口统一经此方法强制流式开关, 避免在多处手工重建 + * record——后续给请求体加字段时只需改这一处,防止字段漏拷 + */ + public ResponsesRequest withStream(boolean stream) { + return new ResponsesRequest(model, instructions, input, temperature, maxOutputTokens, stream); + } + } + + /** + * 输入消息项:对应 Responses API 的 input 数组元素。 DataAgent 只使用 user 角色的文本消息。 + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record InputItem(String role, List content) { + + /** + * 构造 user 消息 + */ + public static InputItem userMessage(String text) { + return new InputItem("user", List.of(new ContentPart("input_text", text))); + } + } + + /** + * 内容块:对应 input/output 中的 content 数组元素 + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ContentPart(String type, String text) { + } + + /** + * Responses API 完整响应体 + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record ResponsesResponse(String id, String model, String status, List output, + ResponsesUsage usage, @JsonProperty("incomplete_details") IncompleteDetails incompleteDetails, + ResponsesError error) { + } + + /** + * 输出项:对应 output 数组元素。 type 为 "message" 时包含 content 数组;DataAgent 只关注 output_text + * 类型的文本内容。 + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record OutputItem(String type, String role, List content) { + } + + /** + * token 用量统计 + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record ResponsesUsage(@JsonProperty("input_tokens") Integer inputTokens, + @JsonProperty("output_tokens") Integer outputTokens, @JsonProperty("total_tokens") Integer totalTokens) { + } + + /** + * 截断详情(status=incomplete 时附带) + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record IncompleteDetails(String reason) { + } + + /** + * 错误信息 + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record ResponsesError(String type, String message, String code) { + } + + /** + * 流式 SSE 事件的统一封装 + */ + public record StreamEvent(Type type, String delta, ResponsesResponse response, String errorMessage) { + + public enum Type { + + /** 文本增量 */ + DELTA, + /** 响应完成(携带 usage) */ + COMPLETED, + /** 响应被截断 */ + INCOMPLETE, + /** 错误 */ + ERROR + + } + } + +} diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/responses/ResponsesApiChatModel.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/responses/ResponsesApiChatModel.java new file mode 100644 index 000000000..1a6471711 --- /dev/null +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/responses/ResponsesApiChatModel.java @@ -0,0 +1,296 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.alibaba.cloud.ai.dataagent.service.aimodelconfig.responses; + +import com.alibaba.cloud.ai.dataagent.service.aimodelconfig.responses.ResponsesApi.ContentPart; +import com.alibaba.cloud.ai.dataagent.service.aimodelconfig.responses.ResponsesApi.InputItem; +import com.alibaba.cloud.ai.dataagent.service.aimodelconfig.responses.ResponsesApi.OutputItem; +import com.alibaba.cloud.ai.dataagent.service.aimodelconfig.responses.ResponsesApi.ResponsesRequest; +import com.alibaba.cloud.ai.dataagent.service.aimodelconfig.responses.ResponsesApi.ResponsesResponse; +import com.alibaba.cloud.ai.dataagent.service.aimodelconfig.responses.ResponsesApi.ResponsesUsage; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.MessageType; +import org.springframework.ai.chat.metadata.ChatGenerationMetadata; +import org.springframework.ai.chat.metadata.ChatResponseMetadata; +import org.springframework.ai.chat.metadata.DefaultUsage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.util.StringUtils; +import reactor.core.publisher.Flux; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * 基于 OpenAI Responses API 的 ChatModel 实现。 将 Spring AI 的 Prompt 转换为 Responses API 请求, 将 + * Responses API 的响应转换回 Spring AI 的 ChatResponse。 + *

+ * 设计要点:实现 ChatModel 接口后,对上层 AiModelRegistry / LlmService / 全部 16 个图节点完全透明,无需任何改动。 + */ +@Slf4j +public class ResponsesApiChatModel implements ChatModel { + + private final ResponsesApi responsesApi; + + private final OpenAiChatOptions defaultOptions; + + public ResponsesApiChatModel(ResponsesApi responsesApi, OpenAiChatOptions defaultOptions) { + this.responsesApi = responsesApi; + this.defaultOptions = defaultOptions; + } + + // ======================== 非流式调用 ======================== + + @Override + public ChatResponse call(Prompt prompt) { + ResponsesRequest request = buildRequest(prompt, false); + // 与 OpenAiChatModel 的容错行为对齐:同步调用使用统一重试模板, + // 网络抖动或服务端瞬时错误时自动重试,避免两种协议容错能力不一致 + ResponsesResponse response = RetryUtils.DEFAULT_RETRY_TEMPLATE.execute(ctx -> responsesApi.call(request)); + return convertToChatResponse(response); + } + + // ======================== 流式调用 ======================== + + @Override + public Flux stream(Prompt prompt) { + ResponsesRequest request = buildRequest(prompt, true); + + // 标记本次流是否收到过文本增量事件:个别兼容实现可能不推送 output_text.delta, + // 文本只出现在 completed 事件的完整 output 中,此时终包需降级携带全文,否则文本整体丢失。 + // 每次订阅独立创建(defer),避免同一 Flux 被多次订阅时状态串扰 + return Flux.defer(() -> { + AtomicBoolean deltaReceived = new AtomicBoolean(false); + + // 将 SSE 事件转为 ChatResponse 流,与 OpenAiChatModel 的流式行为对齐: + // - delta 事件 → 包含文本块的 ChatResponse(逐 token 推送) + // - completed 事件 → 终包:空文本 + usage 元数据(Langfuse token 统计依赖此包) + // - incomplete 事件 → 终包:finishReason=LENGTH + // - error 事件 → Flux.error 向上传播,由节点重试机制接管 + return responsesApi.stream(request).concatMap(event -> switch (event.type()) { + case DELTA -> { + // 文本增量:构建只含文本的 ChatResponse;delta 为 null 时按空串处理,避免 NPE + deltaReceived.set(true); + String delta = event.delta() != null ? event.delta() : ""; + Generation generation = new Generation(new AssistantMessage(delta)); + yield Flux.just(new ChatResponse(List.of(generation))); + } + case COMPLETED -> { + // 完成事件:构建携带 usage 元数据的终包 + yield Flux.just(buildCompletedResponse(event.response(), "STOP", deltaReceived.get())); + } + case INCOMPLETE -> { + // 截断事件:finishReason 为 LENGTH + yield Flux.just(buildCompletedResponse(event.response(), "LENGTH", deltaReceived.get())); + } + case ERROR -> { + // 错误事件:转为异常向上传播 + yield Flux.error(new RuntimeException("Responses API 错误: " + event.errorMessage())); + } + }); + }); + } + + @Override + public ChatOptions getDefaultOptions() { + return this.defaultOptions; + } + + // ======================== 请求构建 ======================== + + /** + * 将 Spring AI Prompt 转换为 Responses API 请求。 映射规则:SystemMessage → instructions + * 字段,UserMessage → input 数组。 + */ + private ResponsesRequest buildRequest(Prompt prompt, boolean stream) { + String instructions = null; + List inputItems = new ArrayList<>(); + + // 从 Prompt 中提取消息,分类映射到 Responses API 字段 + for (Message message : prompt.getInstructions()) { + if (message.getMessageType() == MessageType.SYSTEM) { + // SystemMessage 映射为 instructions(Responses API 推荐的系统提示词方式)。 + // 多条系统消息按出现顺序拼接:instructions 只有一个字段,直接赋值覆盖会静默丢失前文 + instructions = mergeInstructions(instructions, message.getText()); + } + else if (message.getMessageType() == MessageType.USER) { + // UserMessage 映射为 input 数组项 + inputItems.add(InputItem.userMessage(message.getText())); + } + // AssistantMessage 和其他类型在 DataAgent 当前链路中不会出现,忽略 + } + + // 合并运行时 options 与默认 options,运行时优先。 + // 使用 ChatOptions 接口取值而非 instanceof 具体实现类: + // ChatClient 可能传入 DefaultChatOptions 等任意实现,按接口读取保证运行时参数不被静默忽略 + String model = defaultOptions.getModel(); + Double temperature = defaultOptions.getTemperature(); + Integer maxTokens = defaultOptions.getMaxTokens(); + + ChatOptions runtimeOptions = prompt.getOptions(); + if (runtimeOptions != null) { + if (runtimeOptions.getModel() != null) { + model = runtimeOptions.getModel(); + } + if (runtimeOptions.getTemperature() != null) { + temperature = runtimeOptions.getTemperature(); + } + if (runtimeOptions.getMaxTokens() != null) { + maxTokens = runtimeOptions.getMaxTokens(); + } + } + + return new ResponsesRequest(model, instructions, inputItems.isEmpty() ? null : inputItems, temperature, + maxTokens, stream); + } + + /** + * 合并多条 SystemMessage 为单个 instructions。 Responses API 只有一个 instructions 字段, + * 上游若注入多条系统消息,这里用空行拼接保证语义完整、顺序不变。 + */ + private String mergeInstructions(String existing, String text) { + if (!StringUtils.hasText(text)) { + return existing; + } + return existing == null ? text : existing + "\n\n" + text; + } + + // ======================== 响应转换 ======================== + + /** + * 将 Responses API 完整响应转换为 ChatResponse(非流式场景)。 从 output 数组中提取 type=message 的文本内容,拼接为 + * AssistantMessage。 + */ + private ChatResponse convertToChatResponse(ResponsesResponse response) { + String text = extractOutputText(response); + String finishReason = mapFinishReason(response); + + Generation generation = new Generation(new AssistantMessage(text), + ChatGenerationMetadata.builder().finishReason(finishReason).build()); + + ChatResponseMetadata metadata = buildResponseMetadata(response); + return new ChatResponse(List.of(generation), metadata); + } + + /** + * 构建流式完成/截断事件的终包 ChatResponse。 携带 usage 元数据,与 OpenAiChatModel streamUsage(true) + * 的末包行为对齐。 + *

+ * 关键约束:正常路径下终包文本必须为空串。completed/incomplete 事件的 response 中携带完整 output 全文, 而上层 FluxUtil + * 会对流中每个 chunk 的文本做 append 聚合——若此处再返回全文, 聚合结果会出现"全部 delta + 整段全文"的重复,破坏 SQL/JSON 解析。 + * 例外:若整个流从未收到 delta 事件(个别兼容实现不推送增量),说明文本只存在于终包的完整 output 中, 此时降级在终包携带全文,否则文本会整体丢失。 + * @param deltaReceived 本次流是否已收到过文本增量事件,决定终包是否需要降级携带全文 + */ + private ChatResponse buildCompletedResponse(ResponsesResponse response, String finishReason, + boolean deltaReceived) { + // 正常路径终包为空文本(见上方约束);从未收到 delta 且完整 output 中有文本时降级携带全文 + String text = ""; + if (!deltaReceived) { + String fullText = extractOutputText(response); + if (StringUtils.hasText(fullText)) { + log.warn("Responses API 流式响应未收到任何文本增量事件,终包降级携带完整文本(长度 {})", fullText.length()); + text = fullText; + } + } + + Generation generation = new Generation(new AssistantMessage(text), + ChatGenerationMetadata.builder().finishReason(finishReason).build()); + + ChatResponseMetadata metadata = buildResponseMetadata(response); + return new ChatResponse(List.of(generation), metadata); + } + + /** + * 从 ResponsesResponse 的 output 数组中提取所有文本内容并拼接。 只处理 type=message 且 content 中 + * type=output_text 的内容块。 + */ + private String extractOutputText(ResponsesResponse response) { + if (response == null || response.output() == null) { + return ""; + } + + StringBuilder sb = new StringBuilder(); + for (OutputItem outputItem : response.output()) { + // 只处理 message 类型的输出项 + if ("message".equals(outputItem.type()) && outputItem.content() != null) { + for (ContentPart part : outputItem.content()) { + // 只提取 output_text 类型的文本内容 + if ("output_text".equals(part.type()) && part.text() != null) { + sb.append(part.text()); + } + } + } + } + return sb.toString(); + } + + /** + * 根据 Responses API 的 status 映射 finishReason。 completed → STOP;incomplete + + * max_output_tokens → LENGTH;failed → ERROR + */ + private String mapFinishReason(ResponsesResponse response) { + if (response == null || response.status() == null) { + return "STOP"; + } + + return switch (response.status()) { + case "completed" -> "STOP"; + case "incomplete" -> { + // 检查截断原因 + if (response.incompleteDetails() != null + && "max_output_tokens".equals(response.incompleteDetails().reason())) { + yield "LENGTH"; + } + yield "STOP"; + } + case "failed" -> "ERROR"; + default -> "STOP"; + }; + } + + /** + * 构建包含 usage 统计的响应元数据。 将 Responses API 的 input_tokens/output_tokens 映射为 Spring AI 的 + * promptTokens/completionTokens。 + */ + private ChatResponseMetadata buildResponseMetadata(ResponsesResponse response) { + ChatResponseMetadata.Builder builder = ChatResponseMetadata.builder(); + if (response != null) { + if (response.id() != null) { + builder.id(response.id()); + } + if (response.model() != null) { + builder.model(response.model()); + } + // usage 映射:input_tokens → promptTokens,output_tokens → completionTokens + if (response.usage() != null) { + ResponsesUsage u = response.usage(); + builder.usage(new DefaultUsage(u.inputTokens() != null ? u.inputTokens() : 0, + u.outputTokens() != null ? u.outputTokens() : 0, + u.totalTokens() != null ? u.totalTokens() : 0)); + } + } + return builder.build(); + } + +} diff --git a/data-agent-management/src/main/resources/sql/h2/schema-h2.sql b/data-agent-management/src/main/resources/sql/h2/schema-h2.sql index 5eadd0907..611c30435 100644 --- a/data-agent-management/src/main/resources/sql/h2/schema-h2.sql +++ b/data-agent-management/src/main/resources/sql/h2/schema-h2.sql @@ -265,5 +265,6 @@ CREATE TABLE IF NOT EXISTS `model_config` ( `proxy_port` int(11) DEFAULT NULL COMMENT '代理端口', `proxy_username` varchar(255) DEFAULT NULL COMMENT '代理用户名(可选)', `proxy_password` varchar(255) DEFAULT NULL COMMENT '代理密码(可选)', + `chat_api_protocol` varchar(32) DEFAULT 'CHAT_COMPLETIONS' COMMENT 'Chat模型接口协议:CHAT_COMPLETIONS(默认Chat Completions格式)、RESPONSES(OpenAI Responses API格式)。仅model_type=CHAT时生效', PRIMARY KEY (`id`) ) ENGINE=InnoDB; diff --git a/data-agent-management/src/main/resources/sql/schema.sql b/data-agent-management/src/main/resources/sql/schema.sql index 160bcedf0..7478e01e8 100644 --- a/data-agent-management/src/main/resources/sql/schema.sql +++ b/data-agent-management/src/main/resources/sql/schema.sql @@ -266,5 +266,6 @@ CREATE TABLE IF NOT EXISTS `model_config` ( `proxy_port` int(11) DEFAULT NULL COMMENT '代理端口', `proxy_username` varchar(255) DEFAULT NULL COMMENT '代理用户名(可选)', `proxy_password` varchar(255) DEFAULT NULL COMMENT '代理密码(可选)', + `chat_api_protocol` varchar(32) DEFAULT 'CHAT_COMPLETIONS' COMMENT 'Chat模型接口协议:CHAT_COMPLETIONS(默认Chat Completions格式)、RESPONSES(OpenAI Responses API格式)。仅model_type=CHAT时生效', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/converter/ModelConfigConverterTest.java b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/converter/ModelConfigConverterTest.java index 2b0907e57..aa4259de1 100644 --- a/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/converter/ModelConfigConverterTest.java +++ b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/converter/ModelConfigConverterTest.java @@ -131,4 +131,36 @@ void toEntity_embeddingModelType_mapsCorrectly() { assertEquals(ModelType.EMBEDDING, entity.getModelType()); } + // ======================== chatApiProtocol 默认值兜底 ======================== + + private ModelConfigDTO buildChatDto(String chatApiProtocol) { + return ModelConfigDTO.builder() + .provider("openai") + .baseUrl("https://api.example.com") + .apiKey("sk-test") + .modelName("test-model") + .modelType("CHAT") + .chatApiProtocol(chatApiProtocol) + .build(); + } + + @Test + void toEntity_backfillsDefaultProtocolWhenNull() { + // chatApiProtocol 为空时必须回填默认协议:INSERT 显式写入 NULL 会绕过数据库列默认值 + ModelConfig entity = ModelConfigConverter.toEntity(buildChatDto(null)); + assertEquals("CHAT_COMPLETIONS", entity.getChatApiProtocol()); + } + + @Test + void toEntity_backfillsDefaultProtocolWhenBlank() { + ModelConfig entity = ModelConfigConverter.toEntity(buildChatDto(" ")); + assertEquals("CHAT_COMPLETIONS", entity.getChatApiProtocol()); + } + + @Test + void toEntity_keepsExplicitProtocol() { + ModelConfig entity = ModelConfigConverter.toEntity(buildChatDto("RESPONSES")); + assertEquals("RESPONSES", entity.getChatApiProtocol()); + } + } diff --git a/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/enums/ChatApiProtocolTest.java b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/enums/ChatApiProtocolTest.java new file mode 100644 index 000000000..5a092ef90 --- /dev/null +++ b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/enums/ChatApiProtocolTest.java @@ -0,0 +1,49 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.alibaba.cloud.ai.dataagent.enums; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * ChatApiProtocol 协议解析单元测试。 重点回归:非法值必须抛异常拦截在保存入口, 不能静默落入默认协议分支导致用户配置悄悄失效。 + */ +class ChatApiProtocolTest { + + @Test + void fromCode_resolvesStandardValues() { + assertEquals(ChatApiProtocol.CHAT_COMPLETIONS, ChatApiProtocol.fromCode("CHAT_COMPLETIONS")); + assertEquals(ChatApiProtocol.RESPONSES, ChatApiProtocol.fromCode("RESPONSES")); + } + + @Test + void fromCode_ignoresCase() { + // 与 DynamicModelFactory 的 equalsIgnoreCase 容忍口径一致 + assertEquals(ChatApiProtocol.RESPONSES, ChatApiProtocol.fromCode("responses")); + assertEquals(ChatApiProtocol.CHAT_COMPLETIONS, ChatApiProtocol.fromCode("chat_completions")); + } + + @Test + void fromCode_rejectsUnknownValue() { + // 拼写错误(如漏掉 S)必须报错,而不是静默回退默认协议 + assertThrows(IllegalArgumentException.class, () -> ChatApiProtocol.fromCode("RESPONSE")); + assertThrows(IllegalArgumentException.class, () -> ChatApiProtocol.fromCode("")); + assertThrows(IllegalArgumentException.class, () -> ChatApiProtocol.fromCode(null)); + } + +} diff --git a/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/responses/ResponsesApiChatModelTest.java b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/responses/ResponsesApiChatModelTest.java new file mode 100644 index 000000000..00d8e3490 --- /dev/null +++ b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/responses/ResponsesApiChatModelTest.java @@ -0,0 +1,266 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.alibaba.cloud.ai.dataagent.service.aimodelconfig.responses; + +import com.alibaba.cloud.ai.dataagent.service.aimodelconfig.responses.ResponsesApi.ContentPart; +import com.alibaba.cloud.ai.dataagent.service.aimodelconfig.responses.ResponsesApi.IncompleteDetails; +import com.alibaba.cloud.ai.dataagent.service.aimodelconfig.responses.ResponsesApi.OutputItem; +import com.alibaba.cloud.ai.dataagent.service.aimodelconfig.responses.ResponsesApi.ResponsesRequest; +import com.alibaba.cloud.ai.dataagent.service.aimodelconfig.responses.ResponsesApi.ResponsesResponse; +import com.alibaba.cloud.ai.dataagent.service.aimodelconfig.responses.ResponsesApi.ResponsesUsage; +import com.alibaba.cloud.ai.dataagent.service.aimodelconfig.responses.ResponsesApi.StreamEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.openai.OpenAiChatOptions; +import reactor.core.publisher.Flux; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * ResponsesApiChatModel 协议转换单元测试。 覆盖 Prompt → Responses 请求映射、非流式响应转换、 + * 流式事件转换(重点回归:终包必须为空文本,避免上层聚合时全文重复)。 + */ +class ResponsesApiChatModelTest { + + private ResponsesApi responsesApi; + + private ResponsesApiChatModel chatModel; + + @BeforeEach + void setUp() { + responsesApi = mock(ResponsesApi.class); + OpenAiChatOptions defaultOptions = OpenAiChatOptions.builder() + .model("default-model") + .temperature(0.1) + .maxTokens(1000) + .build(); + chatModel = new ResponsesApiChatModel(responsesApi, defaultOptions); + } + + /** + * 构造一个携带文本输出和 usage 的完整响应 + */ + private ResponsesResponse completedResponse(String text) { + OutputItem item = new OutputItem("message", "assistant", List.of(new ContentPart("output_text", text))); + return new ResponsesResponse("resp_1", "gpt-test", "completed", List.of(item), new ResponsesUsage(10, 5, 15), + null, null); + } + + // ======================== 请求映射 ======================== + + @Test + void call_mapsSystemToInstructionsAndUserToInput() { + when(responsesApi.call(any())).thenReturn(completedResponse("ok")); + + Prompt prompt = new Prompt(List.of(new SystemMessage("you are helpful"), new UserMessage("hi"))); + chatModel.call(prompt); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ResponsesRequest.class); + verify(responsesApi).call(captor.capture()); + ResponsesRequest request = captor.getValue(); + + assertEquals("you are helpful", request.instructions()); + assertEquals(1, request.input().size()); + assertEquals("user", request.input().get(0).role()); + assertEquals("hi", request.input().get(0).content().get(0).text()); + // 未指定运行时 options 时使用默认配置 + assertEquals("default-model", request.model()); + assertEquals(0.1, request.temperature()); + assertEquals(1000, request.maxOutputTokens()); + } + + @Test + void call_runtimeOptionsOverrideDefaults_viaChatOptionsInterface() { + when(responsesApi.call(any())).thenReturn(completedResponse("ok")); + + // 使用通用 ChatOptions 实现(非 OpenAiChatOptions),验证按接口取值不会被静默忽略 + ChatOptions runtimeOptions = ChatOptions.builder() + .model("runtime-model") + .temperature(0.9) + .maxTokens(123) + .build(); + Prompt prompt = new Prompt(List.of(new UserMessage("hi")), runtimeOptions); + chatModel.call(prompt); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ResponsesRequest.class); + verify(responsesApi).call(captor.capture()); + ResponsesRequest request = captor.getValue(); + + assertEquals("runtime-model", request.model()); + assertEquals(0.9, request.temperature()); + assertEquals(123, request.maxOutputTokens()); + } + + @Test + void call_multipleSystemMessages_concatenatedIntoInstructions() { + when(responsesApi.call(any())).thenReturn(completedResponse("ok")); + + // 多条系统消息必须按顺序拼接为单个 instructions,直接覆盖会静默丢失前文 + Prompt prompt = new Prompt( + List.of(new SystemMessage("first rule"), new SystemMessage("second rule"), new UserMessage("hi"))); + chatModel.call(prompt); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ResponsesRequest.class); + verify(responsesApi).call(captor.capture()); + assertEquals("first rule\n\nsecond rule", captor.getValue().instructions()); + } + + // ======================== 非流式响应转换 ======================== + + @Test + void call_extractsTextAndUsage() { + when(responsesApi.call(any())).thenReturn(completedResponse("hello world")); + + ChatResponse response = chatModel.call(new Prompt(List.of(new UserMessage("hi")))); + + assertEquals("hello world", response.getResult().getOutput().getText()); + assertEquals("STOP", response.getResult().getMetadata().getFinishReason()); + assertEquals(10, response.getMetadata().getUsage().getPromptTokens()); + assertEquals(5, response.getMetadata().getUsage().getCompletionTokens()); + } + + @Test + void call_incompleteWithMaxTokens_mapsToLengthFinishReason() { + OutputItem item = new OutputItem("message", "assistant", List.of(new ContentPart("output_text", "partial"))); + ResponsesResponse incomplete = new ResponsesResponse("resp_2", "gpt-test", "incomplete", List.of(item), null, + new IncompleteDetails("max_output_tokens"), null); + when(responsesApi.call(any())).thenReturn(incomplete); + + ChatResponse response = chatModel.call(new Prompt(List.of(new UserMessage("hi")))); + + assertEquals("partial", response.getResult().getOutput().getText()); + assertEquals("LENGTH", response.getResult().getMetadata().getFinishReason()); + } + + // ======================== 流式响应转换 ======================== + + @Test + void stream_terminalChunkMustBeEmptyText_toAvoidDuplication() { + // completed 事件的 response 中携带完整全文;上层 FluxUtil 会对每个 chunk 的文本做 append 聚合, + // 因此终包文本必须为空串,否则聚合结果为"全部 delta + 整段全文"的重复(P0 回归用例) + when(responsesApi.stream(any())).thenReturn(Flux.just(new StreamEvent(StreamEvent.Type.DELTA, "Hello", null, null), + new StreamEvent(StreamEvent.Type.DELTA, " world", null, null), + new StreamEvent(StreamEvent.Type.COMPLETED, null, completedResponse("Hello world"), null))); + + List chunks = chatModel.stream(new Prompt(List.of(new UserMessage("hi")))).collectList().block(); + + assertNotNull(chunks); + assertEquals(3, chunks.size()); + assertEquals("Hello", chunks.get(0).getResult().getOutput().getText()); + assertEquals(" world", chunks.get(1).getResult().getOutput().getText()); + // 终包:空文本 + finishReason + usage + assertEquals("", chunks.get(2).getResult().getOutput().getText()); + assertEquals("STOP", chunks.get(2).getResult().getMetadata().getFinishReason()); + assertEquals(10, chunks.get(2).getMetadata().getUsage().getPromptTokens()); + + // 模拟上层聚合行为:全部 chunk 文本拼接后应恰好等于一遍全文 + StringBuilder aggregated = new StringBuilder(); + chunks.forEach(c -> aggregated.append(c.getResult().getOutput().getText())); + assertEquals("Hello world", aggregated.toString()); + } + + @Test + void stream_incompleteEvent_mapsToLengthTerminal() { + ResponsesResponse incomplete = new ResponsesResponse("resp_3", "gpt-test", "incomplete", null, null, + new IncompleteDetails("max_output_tokens"), null); + when(responsesApi.stream(any())) + .thenReturn(Flux.just(new StreamEvent(StreamEvent.Type.DELTA, "part", null, null), + new StreamEvent(StreamEvent.Type.INCOMPLETE, null, incomplete, null))); + + List chunks = chatModel.stream(new Prompt(List.of(new UserMessage("hi")))).collectList().block(); + + assertNotNull(chunks); + assertEquals(2, chunks.size()); + assertEquals("", chunks.get(1).getResult().getOutput().getText()); + assertEquals("LENGTH", chunks.get(1).getResult().getMetadata().getFinishReason()); + } + + @Test + void stream_nullDelta_treatedAsEmptyText() { + // delta 字段缺失时按空串处理,不应抛 NPE + when(responsesApi.stream(any())) + .thenReturn(Flux.just(new StreamEvent(StreamEvent.Type.DELTA, null, null, null))); + + List chunks = chatModel.stream(new Prompt(List.of(new UserMessage("hi")))).collectList().block(); + + assertNotNull(chunks); + assertEquals("", chunks.get(0).getResult().getOutput().getText()); + } + + @Test + void stream_noDeltaReceived_terminalCarriesFullText() { + // 个别兼容实现不推送 output_text.delta,文本只出现在 completed 事件的完整 output 中, + // 此时终包必须降级携带全文,否则文本整体丢失 + when(responsesApi.stream(any())).thenReturn( + Flux.just(new StreamEvent(StreamEvent.Type.COMPLETED, null, completedResponse("full text only"), null))); + + List chunks = chatModel.stream(new Prompt(List.of(new UserMessage("hi")))).collectList().block(); + + assertNotNull(chunks); + assertEquals(1, chunks.size()); + assertEquals("full text only", chunks.get(0).getResult().getOutput().getText()); + assertEquals("STOP", chunks.get(0).getResult().getMetadata().getFinishReason()); + } + + @Test + void stream_withDelta_terminalStaysEmptyEvenIfOutputHasText() { + // 已收到 delta 时终包必须保持空文本(防聚合重复),不能因 output 中有全文而降级 + when(responsesApi.stream(any())) + .thenReturn(Flux.just(new StreamEvent(StreamEvent.Type.DELTA, "Hello world", null, null), + new StreamEvent(StreamEvent.Type.COMPLETED, null, completedResponse("Hello world"), null))); + + List chunks = chatModel.stream(new Prompt(List.of(new UserMessage("hi")))).collectList().block(); + + assertNotNull(chunks); + assertEquals(2, chunks.size()); + assertEquals("", chunks.get(1).getResult().getOutput().getText()); + } + + @Test + void stream_errorEvent_propagatesAsFluxError() { + when(responsesApi.stream(any())) + .thenReturn(Flux.just(new StreamEvent(StreamEvent.Type.ERROR, null, null, "boom"))); + + Flux flux = chatModel.stream(new Prompt(List.of(new UserMessage("hi")))); + + RuntimeException ex = assertThrows(RuntimeException.class, flux::blockLast); + assertTrue(ex.getMessage().contains("boom")); + } + + @Test + void stream_requestHasStreamFlagTrue() { + when(responsesApi.stream(any())).thenReturn(Flux.empty()); + + chatModel.stream(new Prompt(List.of(new UserMessage("hi")))).collectList().block(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ResponsesRequest.class); + verify(responsesApi).stream(captor.capture()); + assertTrue(captor.getValue().stream()); + } + +} diff --git a/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/responses/ResponsesApiTest.java b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/responses/ResponsesApiTest.java new file mode 100644 index 000000000..f0b643944 --- /dev/null +++ b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/responses/ResponsesApiTest.java @@ -0,0 +1,125 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.alibaba.cloud.ai.dataagent.service.aimodelconfig.responses; + +import com.alibaba.cloud.ai.dataagent.service.aimodelconfig.responses.ResponsesApi.StreamEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.codec.ServerSentEvent; +import org.springframework.web.client.RestClient; +import org.springframework.web.reactive.function.client.WebClient; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * ResponsesApi SSE 事件解析单元测试。 覆盖 delta / completed / incomplete / failed / error + * 各事件分支,以及畸形事件的容错行为。 + */ +class ResponsesApiTest { + + private ResponsesApi responsesApi; + + @BeforeEach + void setUp() { + // 仅测试解析逻辑,不发起真实 HTTP 请求,baseUrl 等参数使用占位值 + responsesApi = new ResponsesApi("http://localhost", "test-key", null, RestClient.builder(), + WebClient.builder()); + } + + private StreamEvent parse(String data) { + return responsesApi.parseStreamEvent(ServerSentEvent.builder(data).build()); + } + + @Test + void parseDeltaEvent_returnsDeltaText() { + StreamEvent event = parse("{\"type\":\"response.output_text.delta\",\"delta\":\"Hello\"}"); + assertNotNull(event); + assertEquals(StreamEvent.Type.DELTA, event.type()); + assertEquals("Hello", event.delta()); + } + + @Test + void parseCompletedEvent_returnsResponseWithUsage() { + String data = """ + {"type":"response.completed","response":{"id":"resp_1","model":"gpt-test","status":"completed", + "output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hi"}]}], + "usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15}}} + """; + StreamEvent event = parse(data); + assertNotNull(event); + assertEquals(StreamEvent.Type.COMPLETED, event.type()); + assertEquals("resp_1", event.response().id()); + assertEquals(10, event.response().usage().inputTokens()); + assertEquals(5, event.response().usage().outputTokens()); + } + + @Test + void parseIncompleteEvent_returnsIncompleteType() { + String data = """ + {"type":"response.incomplete","response":{"id":"resp_2","status":"incomplete", + "incomplete_details":{"reason":"max_output_tokens"}}} + """; + StreamEvent event = parse(data); + assertNotNull(event); + assertEquals(StreamEvent.Type.INCOMPLETE, event.type()); + assertEquals("max_output_tokens", event.response().incompleteDetails().reason()); + } + + @Test + void parseFailedEvent_returnsErrorWithMessage() { + String data = """ + {"type":"response.failed","response":{"id":"resp_3","status":"failed", + "error":{"type":"server_error","message":"boom","code":"500"}}} + """; + StreamEvent event = parse(data); + assertNotNull(event); + assertEquals(StreamEvent.Type.ERROR, event.type()); + assertEquals("boom", event.errorMessage()); + } + + @Test + void parseErrorEvent_returnsErrorWithMessage() { + StreamEvent event = parse("{\"type\":\"error\",\"error\":{\"message\":\"rate limited\"}}"); + assertNotNull(event); + assertEquals(StreamEvent.Type.ERROR, event.type()); + assertEquals("rate limited", event.errorMessage()); + } + + @Test + void parseUnknownEventType_returnsNull() { + // 未知事件类型(如 response.created)应静默忽略,保证向前兼容 + assertNull(parse("{\"type\":\"response.created\",\"response\":{\"id\":\"resp_4\"}}")); + } + + @Test + void parseMalformedJson_returnsNull() { + // 畸形 JSON 不应抛异常中断流,应按单条事件损坏跳过 + assertNull(parse("{not valid json")); + } + + @Test + void parseCompletedEventWithWrongStructure_returnsNull() { + // response 字段结构不匹配时 convertValue 抛 IllegalArgumentException, + // 必须被捕获并跳过,不能让一条畸形事件中断整个 SSE 流(回归用例) + assertNull(parse("{\"type\":\"response.completed\",\"response\":\"not-an-object\"}")); + } + + @Test + void parseEventWithoutType_returnsNull() { + assertNull(parse("{\"delta\":\"orphan\"}")); + } + +}