feat: add Qwen JSON chat client

main
chenyuan 1 month ago
parent 0c2a9a2184
commit 60a2b1dfcd

@ -322,6 +322,13 @@
<!-- <version>20.7</version>--> <!-- <version>20.7</version>-->
<!-- </dependency>--> <!-- </dependency>-->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>mockwebserver</artifactId>
<version>4.9.3</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>

@ -0,0 +1,120 @@
package com.sztzjy.linkCommerce.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.sztzjy.linkCommerce.config.ai.QwenProperties;
import com.sztzjy.linkCommerce.config.exception.handler.ServiceException;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
@Component
public class DashScopeQwenChatClient implements QwenChatClient {
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
private final QwenProperties properties;
private final ObjectMapper objectMapper;
public DashScopeQwenChatClient(QwenProperties properties) {
this(properties, new ObjectMapper());
}
DashScopeQwenChatClient(QwenProperties properties, ObjectMapper objectMapper) {
this.properties = properties;
this.objectMapper = objectMapper;
}
@Override
public String completeJson(String systemPrompt, String userPrompt) {
validateConfiguration();
Request request = new Request.Builder()
.url(endpoint())
.header("Authorization", "Bearer " + properties.getApiKey().trim())
.header("Content-Type", "application/json")
.post(RequestBody.create(requestJson(systemPrompt, userPrompt), JSON))
.build();
try (Response response = httpClient().newCall(request).execute()) {
if (!response.isSuccessful()) {
throw unavailable("Qwen request failed with HTTP " + response.code());
}
return contentFrom(response);
} catch (ServiceException e) {
throw e;
} catch (IOException | RuntimeException e) {
throw unavailable("Qwen request could not be completed");
}
}
private void validateConfiguration() {
if (isBlank(properties.getApiKey())) {
throw unavailable("Qwen API key is not configured");
}
if (isBlank(properties.getBaseUrl()) || isBlank(properties.getModel())) {
throw unavailable("Qwen client configuration is incomplete");
}
}
private String endpoint() {
return properties.getBaseUrl().trim().replaceAll("/+$", "") + "/chat/completions";
}
private OkHttpClient httpClient() {
long timeout = properties.getTimeoutMillis() > 0 ? properties.getTimeoutMillis() : 60_000L;
return new OkHttpClient.Builder()
.connectTimeout(timeout, TimeUnit.MILLISECONDS)
.readTimeout(timeout, TimeUnit.MILLISECONDS)
.writeTimeout(timeout, TimeUnit.MILLISECONDS)
.callTimeout(timeout, TimeUnit.MILLISECONDS)
.build();
}
private String requestJson(String systemPrompt, String userPrompt) {
ObjectNode request = objectMapper.createObjectNode();
request.put("model", properties.getModel().trim());
request.put("temperature", 0.2D);
request.putObject("response_format").put("type", "json_object");
ArrayNode messages = request.putArray("messages");
messages.addObject().put("role", "system").put("content", systemPrompt);
messages.addObject().put("role", "user").put("content", userPrompt);
try {
return objectMapper.writeValueAsString(request);
} catch (IOException e) {
throw unavailable("Qwen request could not be prepared");
}
}
private String contentFrom(Response response) throws IOException {
if (response.body() == null) {
throw unavailable("Qwen returned an invalid response");
}
JsonNode root;
try {
root = objectMapper.readTree(response.body().string());
} catch (IOException | RuntimeException e) {
throw unavailable("Qwen returned an invalid response");
}
JsonNode content = root.path("choices").path(0).path("message").path("content");
if (!content.isTextual() || isBlank(content.asText())) {
throw unavailable("Qwen returned an invalid response");
}
return content.asText();
}
private ServiceException unavailable(String message) {
return new ServiceException(HttpStatus.BAD_GATEWAY, message);
}
private boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
}

@ -0,0 +1,6 @@
package com.sztzjy.linkCommerce.ai;
public interface QwenChatClient {
String completeJson(String systemPrompt, String userPrompt);
}

@ -0,0 +1,46 @@
package com.sztzjy.linkCommerce.config.ai;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "ai.qwen")
public class QwenProperties {
private String baseUrl;
private String apiKey;
private String model;
private long timeoutMillis;
public String getBaseUrl() {
return baseUrl;
}
public void setBaseUrl(String baseUrl) {
this.baseUrl = baseUrl;
}
public String getApiKey() {
return apiKey;
}
public void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
public String getModel() {
return model;
}
public void setModel(String model) {
this.model = model;
}
public long getTimeoutMillis() {
return timeoutMillis;
}
public void setTimeoutMillis(long timeoutMillis) {
this.timeoutMillis = timeoutMillis;
}
}

@ -100,6 +100,13 @@ swagger:
contactAddress: www.sztzjy.com contactAddress: www.sztzjy.com
version: @project.version@ version: @project.version@
ai:
qwen:
base-url: https://dashscope.aliyuncs.com/compatible-mode/v1
api-key: ${DASHSCOPE_API_KEY:}
model: qwen-plus
timeout-millis: 60000
ssl: ssl:
# 支持https # 支持https
enabled: true enabled: true

@ -0,0 +1,94 @@
package com.sztzjy.linkCommerce.ai;
import com.sztzjy.linkCommerce.config.ai.QwenProperties;
import com.sztzjy.linkCommerce.config.exception.handler.ServiceException;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class DashScopeQwenChatClientTest {
private final MockWebServer server = new MockWebServer();
@AfterEach
void tearDown() throws Exception {
server.shutdown();
}
@Test
void sendsCompatibleJsonChatRequestAndReturnsMessageContent() throws Exception {
server.start();
server.enqueue(new MockResponse().setResponseCode(200)
.setBody("{\"choices\":[{\"message\":{\"content\":\"{\\\"score\\\":86}\"}}]}"));
String content = client("test-key").completeJson("system instructions", "student answer");
RecordedRequest request = server.takeRequest();
assertEquals("POST", request.getMethod());
assertEquals("/compatible-mode/v1/chat/completions", request.getPath());
assertEquals("Bearer test-key", request.getHeader("Authorization"));
assertTrue(request.getBody().readUtf8().contains("\"response_format\":{\"type\":\"json_object\"}"));
assertEquals("{\"score\":86}", content);
}
@Test
void rejectsBlankApiKeyWithoutIncludingPromptsInMessage() {
ServiceException exception = assertThrows(ServiceException.class,
() -> client(" ").completeJson("confidential system prompt", "confidential user prompt"));
assertEquals(HttpStatus.BAD_GATEWAY, exception.getCode());
assertFalse(exception.getMessage().contains("confidential"));
}
@Test
void rejectsNonSuccessResponseWithoutLeakingPromptOrKey() throws Exception {
server.start();
server.enqueue(new MockResponse().setResponseCode(401).setBody("provider details"));
ServiceException exception = assertThrows(ServiceException.class,
() -> client("secret-key").completeJson("private system prompt", "private user prompt"));
assertEquals(HttpStatus.BAD_GATEWAY, exception.getCode());
assertFalse(exception.getMessage().contains("private"));
assertFalse(exception.getMessage().contains("secret-key"));
}
@Test
void rejectsResponseWithoutMessageContent() throws Exception {
server.start();
server.enqueue(new MockResponse().setResponseCode(200).setBody("{\"choices\":[{\"message\":{}}]}"));
ServiceException exception = assertThrows(ServiceException.class,
() -> client("test-key").completeJson("system prompt", "user prompt"));
assertEquals(HttpStatus.BAD_GATEWAY, exception.getCode());
}
@Test
void rejectsMalformedResponse() throws Exception {
server.start();
server.enqueue(new MockResponse().setResponseCode(200).setBody("not-json"));
ServiceException exception = assertThrows(ServiceException.class,
() -> client("test-key").completeJson("system prompt", "user prompt"));
assertEquals(HttpStatus.BAD_GATEWAY, exception.getCode());
}
private DashScopeQwenChatClient client(String apiKey) {
QwenProperties properties = new QwenProperties();
properties.setBaseUrl(server.url("/compatible-mode/v1").toString().replaceAll("/$", ""));
properties.setApiKey(apiKey);
properties.setModel("qwen-test");
properties.setTimeoutMillis(3_000L);
return new DashScopeQwenChatClient(properties);
}
}
Loading…
Cancel
Save