Skip to content

Commit 0fd760c

Browse files
authored
feat: Add a universal OpenAI API (#251)
1 parent aaf776b commit 0fd760c

File tree

8 files changed

+343
-1
lines changed

8 files changed

+343
-1
lines changed

app/src/main/java/com/tinyengine/it/config/filter/WebConfig.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@
1111

1212
package com.tinyengine.it.config.filter;
1313

14+
import com.tinyengine.it.common.converter.StreamingResponseBodyConverter;
1415
import org.springframework.beans.factory.annotation.Value;
1516
import org.springframework.context.annotation.Bean;
1617
import org.springframework.context.annotation.Configuration;
18+
import org.springframework.http.converter.HttpMessageConverter;
1719
import org.springframework.web.cors.CorsConfiguration;
1820
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
1921
import org.springframework.web.filter.CorsFilter;
@@ -27,6 +29,17 @@ public class WebConfig implements WebMvcConfigurer {
2729
@Value("${cors.allowed-origins}")
2830
private String allowedOrigins;
2931

32+
private final StreamingResponseBodyConverter streamingResponseBodyConverter;
33+
34+
public WebConfig(StreamingResponseBodyConverter streamingResponseBodyConverter) {
35+
this.streamingResponseBodyConverter = streamingResponseBodyConverter;
36+
}
37+
38+
@Override
39+
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
40+
// 添加自定义的 StreamingResponseBody 转换器
41+
converters.add(streamingResponseBodyConverter);
42+
}
3043
@Bean
3144
public CorsFilter corsFilter() {
3245
// 跨域配置地址
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* Copyright (c) 2023 - present TinyEngine Authors.
3+
* Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd.
4+
*
5+
* Use of this source code is governed by an MIT-style license.
6+
*
7+
* THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL,
8+
* BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR
9+
* A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS.
10+
*
11+
*/
12+
13+
package com.tinyengine.it.common.converter;
14+
15+
16+
import org.springframework.http.HttpInputMessage;
17+
import org.springframework.http.HttpOutputMessage;
18+
import org.springframework.http.MediaType;
19+
import org.springframework.http.converter.AbstractHttpMessageConverter;
20+
import org.springframework.http.converter.HttpMessageNotReadableException;
21+
import org.springframework.http.converter.HttpMessageNotWritableException;
22+
import org.springframework.stereotype.Component;
23+
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
24+
25+
import java.io.IOException;
26+
import java.io.OutputStream;
27+
28+
/**
29+
* The type StreamingResponseBodyConverter.
30+
*
31+
* @since 2025-08-06
32+
*/
33+
@Component
34+
public class StreamingResponseBodyConverter extends AbstractHttpMessageConverter<StreamingResponseBody> {
35+
36+
public StreamingResponseBodyConverter() {
37+
super(MediaType.TEXT_EVENT_STREAM);
38+
}
39+
40+
@Override
41+
protected boolean supports(Class<?> clazz) {
42+
return StreamingResponseBody.class.isAssignableFrom(clazz);
43+
}
44+
45+
@Override
46+
protected StreamingResponseBody readInternal(Class<? extends StreamingResponseBody> clazz, HttpInputMessage inputMessage)
47+
throws IOException, HttpMessageNotReadableException {
48+
throw new UnsupportedOperationException("Streaming response body does not support input.");
49+
}
50+
51+
@Override
52+
protected void writeInternal(StreamingResponseBody responseBody, HttpOutputMessage outputMessage)
53+
throws IOException, HttpMessageNotWritableException {
54+
OutputStream outputStream = outputMessage.getBody();
55+
responseBody.writeTo(outputStream); // 使用 StreamingResponseBody 的 writeTo 方法
56+
}
57+
}

base/src/main/java/com/tinyengine/it/common/exception/ExceptionEnum.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,12 @@ public enum ExceptionEnum implements IBaseError {
250250
/**
251251
* Cm 325 exception enum.
252252
*/
253-
CM325("CM325", "文件校验失败");
253+
CM325("CM325", "文件校验失败"),
254+
255+
/**
256+
* Cm 326 exception enum.
257+
*/
258+
CM326("CM326", "Failed to write stream data");
254259

255260
/**
256261
* 错误码
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* Copyright (c) 2023 - present TinyEngine Authors.
3+
* Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd.
4+
*
5+
* Use of this source code is governed by an MIT-style license.
6+
*
7+
* THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL,
8+
* BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR
9+
* A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS.
10+
*
11+
*/
12+
13+
package com.tinyengine.it.config;
14+
15+
import lombok.Data;
16+
import org.springframework.context.annotation.Configuration;
17+
18+
/**
19+
* The type Open AI config.
20+
*
21+
* @since 2025-08-06
22+
*/
23+
@Data
24+
@Configuration
25+
public class OpenAIConfig {
26+
private String apiKey = "your-api-key";
27+
private String baseUrl = "https://api.deepseek.com/chat/completions";
28+
private String defaultModel = "deepseek-chat";
29+
private int timeoutSeconds = 300;
30+
}

base/src/main/java/com/tinyengine/it/controller/AiChatController.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
import com.tinyengine.it.common.base.Result;
1616
import com.tinyengine.it.common.log.SystemControllerLog;
1717
import com.tinyengine.it.model.dto.AiParam;
18+
import com.tinyengine.it.model.dto.ChatRequest;
1819
import com.tinyengine.it.service.app.AiChatService;
1920

21+
import com.tinyengine.it.service.app.v1.AiChatV1Service;
2022
import io.swagger.v3.oas.annotations.Operation;
2123
import io.swagger.v3.oas.annotations.Parameter;
2224
import io.swagger.v3.oas.annotations.media.Content;
@@ -25,11 +27,15 @@
2527
import io.swagger.v3.oas.annotations.tags.Tag;
2628

2729
import org.springframework.beans.factory.annotation.Autowired;
30+
import org.springframework.http.HttpStatus;
31+
import org.springframework.http.MediaType;
32+
import org.springframework.http.ResponseEntity;
2833
import org.springframework.validation.annotation.Validated;
2934
import org.springframework.web.bind.annotation.PostMapping;
3035
import org.springframework.web.bind.annotation.RequestBody;
3136
import org.springframework.web.bind.annotation.RequestMapping;
3237
import org.springframework.web.bind.annotation.RestController;
38+
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
3339

3440
import java.util.Map;
3541

@@ -49,6 +55,12 @@ public class AiChatController {
4955
@Autowired
5056
private AiChatService aiChatService;
5157

58+
/**
59+
* The Ai chat v1 service.
60+
*/
61+
@Autowired
62+
private AiChatV1Service aiChatV1Service;
63+
5264
/**
5365
* AI api
5466
*
@@ -65,4 +77,34 @@ public class AiChatController {
6577
public Result<Map<String, Object>> aiChat(@RequestBody AiParam aiParam) {
6678
return aiChatService.getAnswerFromAi(aiParam);
6779
}
80+
81+
/**
82+
* AI api v1
83+
*
84+
* @param request the AI param
85+
* @return ai回答信息 result
86+
*/
87+
@Operation(summary = "获取ai回答信息", description = "获取ai回答信息", parameters = {
88+
@Parameter(name = "ChatRequest", description = "入参对象")}, responses = {
89+
@ApiResponse(responseCode = "200", description = "返回信息",
90+
content = @Content(mediaType = "application/json", schema = @Schema())),
91+
@ApiResponse(responseCode = "400", description = "请求失败")})
92+
@SystemControllerLog(description = "AI api v1")
93+
@PostMapping("/chat/completions")
94+
public ResponseEntity<?> chat(@RequestBody ChatRequest request) {
95+
try {
96+
Object response = aiChatV1Service.chatCompletion(request);
97+
98+
if (request.isStream()) {
99+
return ResponseEntity.ok()
100+
.contentType(MediaType.TEXT_EVENT_STREAM)
101+
.body((StreamingResponseBody) response);
102+
} else {
103+
return ResponseEntity.ok(response);
104+
}
105+
} catch (Exception e) {
106+
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
107+
.body(e.getMessage());
108+
}
109+
}
68110
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Copyright (c) 2023 - present TinyEngine Authors.
3+
* Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd.
4+
*
5+
* Use of this source code is governed by an MIT-style license.
6+
*
7+
* THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL,
8+
* BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR
9+
* A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS.
10+
*
11+
*/
12+
13+
package com.tinyengine.it.model.dto;
14+
15+
import lombok.Data;
16+
17+
import java.util.List;
18+
19+
/**
20+
* ChatRequest dto
21+
*
22+
* @since 2025-08-06
23+
*/
24+
@Data
25+
public class ChatRequest {
26+
private String model;
27+
private String apiKey;
28+
private String baseUrl;
29+
private List<AiMessages> messages;
30+
private Double temperature = 0.7;
31+
private boolean stream = false; // 流式开关
32+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* Copyright (c) 2023 - present TinyEngine Authors.
3+
* Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd.
4+
*
5+
* Use of this source code is governed by an MIT-style license.
6+
*
7+
* THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL,
8+
* BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR
9+
* A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS.
10+
*
11+
*/
12+
13+
package com.tinyengine.it.service.app.impl.v1;
14+
15+
import com.fasterxml.jackson.core.JsonProcessingException;
16+
import com.fasterxml.jackson.databind.JsonNode;
17+
import com.tinyengine.it.common.exception.ExceptionEnum;
18+
import com.tinyengine.it.common.exception.ServiceException;
19+
import com.tinyengine.it.common.utils.JsonUtils;
20+
import com.tinyengine.it.config.OpenAIConfig;
21+
import com.tinyengine.it.model.dto.ChatRequest;
22+
import com.tinyengine.it.service.app.v1.AiChatV1Service;
23+
import lombok.extern.slf4j.Slf4j;
24+
import org.springframework.stereotype.Service;
25+
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
26+
27+
import java.io.IOException;
28+
import java.net.URI;
29+
import java.net.http.HttpClient;
30+
import java.net.http.HttpRequest;
31+
import java.net.http.HttpResponse;
32+
import java.nio.charset.StandardCharsets;
33+
import java.time.Duration;
34+
import java.util.HashMap;
35+
import java.util.Map;
36+
import java.util.stream.Stream;
37+
38+
/**
39+
* The type AiChat v1 service.
40+
*
41+
* @since 2025-08-06
42+
*/
43+
@Service
44+
@Slf4j
45+
public class AiChatV1ServiceImpl implements AiChatV1Service {
46+
private final OpenAIConfig config = new OpenAIConfig();
47+
private HttpClient httpClient = HttpClient.newBuilder()
48+
.connectTimeout(Duration.ofSeconds(config.getTimeoutSeconds()))
49+
.build();
50+
51+
/**
52+
* chatCompletion.
53+
*
54+
* @param request the request
55+
* @return Object the Object
56+
*/
57+
@Override
58+
public Object chatCompletion(ChatRequest request) throws Exception {
59+
String requestBody = buildRequestBody(request);
60+
String apiKey = request.getApiKey() != null ? request.getApiKey() : config.getApiKey();
61+
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
62+
.uri(URI.create(request.getBaseUrl() != null ? request.getBaseUrl() : config.getBaseUrl()))
63+
.header("Content-Type", "application/json")
64+
.header("Authorization", "Bearer " + apiKey)
65+
.POST(HttpRequest.BodyPublishers.ofString(requestBody));
66+
67+
if (request.isStream()) {
68+
requestBuilder.header("Accept", "text/event-stream");
69+
return processStreamResponse(requestBuilder);
70+
} else {
71+
return processStandardResponse(requestBuilder);
72+
}
73+
}
74+
75+
private String buildRequestBody(ChatRequest request) throws JsonProcessingException {
76+
Map<String, Object> body = new HashMap<>();
77+
body.put("model", request.getModel() != null ? request.getModel() : config.getDefaultModel());
78+
body.put("messages", request.getMessages());
79+
body.put("temperature", request.getTemperature());
80+
body.put("stream", request.isStream());
81+
82+
return JsonUtils.encode(body);
83+
}
84+
85+
private JsonNode processStandardResponse(HttpRequest.Builder requestBuilder)
86+
throws Exception {
87+
HttpResponse<String> response = httpClient.send(
88+
requestBuilder.build(), HttpResponse.BodyHandlers.ofString());
89+
return JsonUtils.MAPPER.readTree(response.body());
90+
}
91+
92+
private StreamingResponseBody processStreamResponse(HttpRequest.Builder requestBuilder) {
93+
return outputStream -> {
94+
try {
95+
HttpResponse<Stream<String>> response = httpClient.send(
96+
requestBuilder.build(), HttpResponse.BodyHandlers.ofLines());
97+
try (Stream<String> lines = response.body()) {
98+
lines.filter(line -> !line.isEmpty())
99+
.forEach(line -> {
100+
try {
101+
if (!line.startsWith("data:")) {
102+
line = "data: " + line;
103+
}
104+
if (!line.endsWith("\n\n")) {
105+
line = line + "\n\n";
106+
}
107+
outputStream.write(line.getBytes(StandardCharsets.UTF_8));
108+
outputStream.flush();
109+
} catch (IOException e) {
110+
throw new ServiceException(ExceptionEnum.CM326.getResultCode(),
111+
ExceptionEnum.CM326.getResultMsg());
112+
}
113+
});
114+
}
115+
} catch (Exception e) {
116+
try {
117+
String errorEvent = "data: " +
118+
JsonUtils.encode(Map.of("error", e.getMessage())) + "\n\n";
119+
outputStream.write(errorEvent.getBytes(StandardCharsets.UTF_8));
120+
outputStream.flush();
121+
} catch (IOException ioException) {
122+
throw new ServiceException(ExceptionEnum.CM326.getResultCode(), ExceptionEnum.CM326.getResultMsg());
123+
}
124+
} finally {
125+
try {
126+
outputStream.close();
127+
} catch (IOException e) {
128+
// 忽略关闭异常
129+
}
130+
}
131+
};
132+
}
133+
}

0 commit comments

Comments
 (0)