Skip to content

feat: Add a universal OpenAI API #251

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Aug 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions app/src/main/java/com/tinyengine/it/config/filter/WebConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@

package com.tinyengine.it.config.filter;

import com.tinyengine.it.common.converter.StreamingResponseBodyConverter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
Expand All @@ -27,6 +29,17 @@ public class WebConfig implements WebMvcConfigurer {
@Value("${cors.allowed-origins}")
private String allowedOrigins;

private final StreamingResponseBodyConverter streamingResponseBodyConverter;

public WebConfig(StreamingResponseBodyConverter streamingResponseBodyConverter) {
this.streamingResponseBodyConverter = streamingResponseBodyConverter;
}

@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
// 添加自定义的 StreamingResponseBody 转换器
converters.add(streamingResponseBodyConverter);
}
@Bean
public CorsFilter corsFilter() {
// 跨域配置地址
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Copyright (c) 2023 - present TinyEngine Authors.
* Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd.
*
* Use of this source code is governed by an MIT-style license.
*
* THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL,
* BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR
* A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS.
*
*/

package com.tinyengine.it.common.converter;


import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpOutputMessage;
import org.springframework.http.MediaType;
import org.springframework.http.converter.AbstractHttpMessageConverter;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.http.converter.HttpMessageNotWritableException;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;

import java.io.IOException;
import java.io.OutputStream;

/**
* The type StreamingResponseBodyConverter.
*
* @since 2025-08-06
*/
@Component
public class StreamingResponseBodyConverter extends AbstractHttpMessageConverter<StreamingResponseBody> {

public StreamingResponseBodyConverter() {
super(MediaType.TEXT_EVENT_STREAM);
}

@Override
protected boolean supports(Class<?> clazz) {
return StreamingResponseBody.class.isAssignableFrom(clazz);
}

@Override
protected StreamingResponseBody readInternal(Class<? extends StreamingResponseBody> clazz, HttpInputMessage inputMessage)
throws IOException, HttpMessageNotReadableException {
throw new UnsupportedOperationException("Streaming response body does not support input.");
}

@Override
protected void writeInternal(StreamingResponseBody responseBody, HttpOutputMessage outputMessage)
throws IOException, HttpMessageNotWritableException {
OutputStream outputStream = outputMessage.getBody();
responseBody.writeTo(outputStream); // 使用 StreamingResponseBody 的 writeTo 方法
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,12 @@ public enum ExceptionEnum implements IBaseError {
/**
* Cm 325 exception enum.
*/
CM325("CM325", "文件校验失败");
CM325("CM325", "文件校验失败"),

/**
* Cm 326 exception enum.
*/
CM326("CM326", "Failed to write stream data");

/**
* 错误码
Expand Down
30 changes: 30 additions & 0 deletions base/src/main/java/com/tinyengine/it/config/OpenAIConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Copyright (c) 2023 - present TinyEngine Authors.
* Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd.
*
* Use of this source code is governed by an MIT-style license.
*
* THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL,
* BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR
* A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS.
*
*/

package com.tinyengine.it.config;

import lombok.Data;
import org.springframework.context.annotation.Configuration;

/**
* The type Open AI config.
*
* @since 2025-08-06
*/
@Data
@Configuration
public class OpenAIConfig {
private String apiKey = "your-api-key";
private String baseUrl = "https://api.deepseek.com/chat/completions";
private String defaultModel = "deepseek-chat";
private int timeoutSeconds = 300;
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@
import com.tinyengine.it.common.base.Result;
import com.tinyengine.it.common.log.SystemControllerLog;
import com.tinyengine.it.model.dto.AiParam;
import com.tinyengine.it.model.dto.ChatRequest;
import com.tinyengine.it.service.app.AiChatService;

import com.tinyengine.it.service.app.v1.AiChatV1Service;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
Expand All @@ -25,11 +27,15 @@
import io.swagger.v3.oas.annotations.tags.Tag;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;

import java.util.Map;

Expand All @@ -49,6 +55,12 @@ public class AiChatController {
@Autowired
private AiChatService aiChatService;

/**
* The Ai chat v1 service.
*/
@Autowired
private AiChatV1Service aiChatV1Service;

/**
* AI api
*
Expand All @@ -65,4 +77,34 @@ public class AiChatController {
public Result<Map<String, Object>> aiChat(@RequestBody AiParam aiParam) {
return aiChatService.getAnswerFromAi(aiParam);
}

/**
* AI api v1
*
* @param request the AI param
* @return ai回答信息 result
*/
@Operation(summary = "获取ai回答信息", description = "获取ai回答信息", parameters = {
@Parameter(name = "ChatRequest", description = "入参对象")}, responses = {
@ApiResponse(responseCode = "200", description = "返回信息",
content = @Content(mediaType = "application/json", schema = @Schema())),
@ApiResponse(responseCode = "400", description = "请求失败")})
@SystemControllerLog(description = "AI api v1")
@PostMapping("/chat/completions")
public ResponseEntity<?> chat(@RequestBody ChatRequest request) {
try {
Object response = aiChatV1Service.chatCompletion(request);

if (request.isStream()) {
return ResponseEntity.ok()
.contentType(MediaType.TEXT_EVENT_STREAM)
.body((StreamingResponseBody) response);
} else {
return ResponseEntity.ok(response);
}
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(e.getMessage());
}
}
}
32 changes: 32 additions & 0 deletions base/src/main/java/com/tinyengine/it/model/dto/ChatRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Copyright (c) 2023 - present TinyEngine Authors.
* Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd.
*
* Use of this source code is governed by an MIT-style license.
*
* THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL,
* BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR
* A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS.
*
*/

package com.tinyengine.it.model.dto;

import lombok.Data;

import java.util.List;

/**
* ChatRequest dto
*
* @since 2025-08-06
*/
@Data
public class ChatRequest {
private String model;
private String apiKey;
private String baseUrl;
private List<AiMessages> messages;
private Double temperature = 0.7;
private boolean stream = false; // 流式开关
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* Copyright (c) 2023 - present TinyEngine Authors.
* Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd.
*
* Use of this source code is governed by an MIT-style license.
*
* THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL,
* BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR
* A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS.
*
*/

package com.tinyengine.it.service.app.impl.v1;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.tinyengine.it.common.exception.ExceptionEnum;
import com.tinyengine.it.common.exception.ServiceException;
import com.tinyengine.it.common.utils.JsonUtils;
import com.tinyengine.it.config.OpenAIConfig;
import com.tinyengine.it.model.dto.ChatRequest;
import com.tinyengine.it.service.app.v1.AiChatV1Service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Stream;

/**
* The type AiChat v1 service.
*
* @since 2025-08-06
*/
@Service
@Slf4j
public class AiChatV1ServiceImpl implements AiChatV1Service {
private final OpenAIConfig config = new OpenAIConfig();
private HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(config.getTimeoutSeconds()))
.build();

/**
* chatCompletion.
*
* @param request the request
* @return Object the Object
*/
@Override
public Object chatCompletion(ChatRequest request) throws Exception {
String requestBody = buildRequestBody(request);
String apiKey = request.getApiKey() != null ? request.getApiKey() : config.getApiKey();
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(request.getBaseUrl() != null ? request.getBaseUrl() : config.getBaseUrl()))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + apiKey)
.POST(HttpRequest.BodyPublishers.ofString(requestBody));

if (request.isStream()) {
requestBuilder.header("Accept", "text/event-stream");
return processStreamResponse(requestBuilder);
} else {
return processStandardResponse(requestBuilder);
}
}

private String buildRequestBody(ChatRequest request) throws JsonProcessingException {
Map<String, Object> body = new HashMap<>();
body.put("model", request.getModel() != null ? request.getModel() : config.getDefaultModel());
body.put("messages", request.getMessages());
body.put("temperature", request.getTemperature());
body.put("stream", request.isStream());

return JsonUtils.encode(body);
}

private JsonNode processStandardResponse(HttpRequest.Builder requestBuilder)
throws Exception {
HttpResponse<String> response = httpClient.send(
requestBuilder.build(), HttpResponse.BodyHandlers.ofString());
return JsonUtils.MAPPER.readTree(response.body());
}

private StreamingResponseBody processStreamResponse(HttpRequest.Builder requestBuilder) {
return outputStream -> {
try {
HttpResponse<Stream<String>> response = httpClient.send(
requestBuilder.build(), HttpResponse.BodyHandlers.ofLines());
try (Stream<String> lines = response.body()) {
lines.filter(line -> !line.isEmpty())
.forEach(line -> {
try {
if (!line.startsWith("data:")) {
line = "data: " + line;
}
if (!line.endsWith("\n\n")) {
line = line + "\n\n";
}
outputStream.write(line.getBytes(StandardCharsets.UTF_8));
outputStream.flush();
} catch (IOException e) {
throw new ServiceException(ExceptionEnum.CM326.getResultCode(),
ExceptionEnum.CM326.getResultMsg());
}
});
}
} catch (Exception e) {
try {
String errorEvent = "data: " +
JsonUtils.encode(Map.of("error", e.getMessage())) + "\n\n";
outputStream.write(errorEvent.getBytes(StandardCharsets.UTF_8));
outputStream.flush();
} catch (IOException ioException) {
throw new ServiceException(ExceptionEnum.CM326.getResultCode(), ExceptionEnum.CM326.getResultMsg());
}
} finally {
try {
outputStream.close();
} catch (IOException e) {
// 忽略关闭异常
}
}
};
}
}
Loading
Loading