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