feat: add Qwen JSON chat client
parent
0c2a9a2184
commit
60a2b1dfcd
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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…
Reference in New Issue