Version 2.0

Java 25, Spring Boot 4, Use RestClient with JDK client backend
This commit is contained in:
extio-de
2025-12-19 17:58:56 +01:00
parent 4c0c30974e
commit 4ebb8640c7
31 changed files with 392 additions and 263 deletions

View File

@@ -12,8 +12,8 @@ import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
import de.extio.lmlib.agent.responsehandler.AgentResponseHandler;
import de.extio.lmlib.agent.responsehandler.StreamedAgentResponseHandler;
@@ -239,7 +239,7 @@ public interface BaseAgent {
try {
text = text.replace(k, objectMapper.writeValueAsString(entry.getValue()));
}
catch (final JsonProcessingException e) {
catch (final JacksonException e) {
LOGGER.warn("Cannot write json", e);
}
}

View File

@@ -10,11 +10,11 @@ import java.util.Map.Entry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.fasterxml.jackson.core.json.JsonReadFeature;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import tools.jackson.core.json.JsonReadFeature;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
import de.extio.lmlib.agent.AgentContext;
import de.extio.lmlib.client.Completion;
@@ -52,7 +52,7 @@ public class JsonAgentResponseHandler implements AgentResponseHandler {
.enable(JsonReadFeature.ALLOW_LEADING_DECIMAL_POINT_FOR_NUMBERS)
.enable(JsonReadFeature.ALLOW_LEADING_PLUS_SIGN_FOR_NUMBERS)
.enable(JsonReadFeature.ALLOW_TRAILING_DECIMAL_POINT_FOR_NUMBERS)
.enable(JsonReadFeature.ALLOW_UNQUOTED_FIELD_NAMES)
.enable(JsonReadFeature.ALLOW_UNQUOTED_PROPERTY_NAMES)
.build();
}
@@ -94,7 +94,7 @@ public class JsonAgentResponseHandler implements AgentResponseHandler {
private void parseEntry(final LinkedHashMap<String, List<String>> items, final Entry<String, JsonNode> entry) {
if (entry.getValue().isArray()) {
entry.getValue().elements().forEachRemaining(nestedArrayValue -> {
entry.getValue().values().forEach(nestedArrayValue -> {
if (nestedArrayValue.properties().isEmpty()) {
items.computeIfAbsent(this.prefixFields + entry.getKey(), k -> new ArrayList<>()).add(nestedArrayValue.asText());
}

View File

@@ -0,0 +1,139 @@
package de.extio.lmlib.client;
import java.net.Authenticator;
import java.net.InetSocketAddress;
import java.net.PasswordAuthentication;
import java.net.ProxySelector;
import java.net.http.HttpClient;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.time.Duration;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.util.backoff.BackOff;
import org.springframework.util.backoff.BackOffExecution;
import org.springframework.util.backoff.ExponentialBackOff;
import org.springframework.web.client.RestClient;
@Configuration
public class RestClientConfiguration {
private static final Logger LOGGER = LoggerFactory.getLogger(RestClientConfiguration.class);
private static final int TIMEOUT = 300000;
private static final int CONNECT_TIMEOUT = 10000;
@Bean
@Qualifier("lmLibRestClientBuilder")
RestClient.Builder lmLibRestClientBuilder(
@Value("${lmlib.client.proxy.enabled:false}") final boolean proxyEnabled,
@Value("${lmlib.client.proxy.host:}") final String proxyHost,
@Value("${lmlib.client.proxy.port:0}") final int proxyPort,
@Value("${lmlib.client.proxy.user:}") final String proxyUser,
@Value("${lmlib.client.proxy.password:}") final String proxyPassword,
@Value("${lmlib.client.tls.verification.disabled:false}") final boolean tlsVerificationDisabled,
@Value("${lmlib.client.retry.max-attempts:5}") final int maxAttempts,
@Value("${lmlib.client.retry.backoff-interval-min:125}") final long backoffIntervalMin,
@Value("${lmlib.client.retry.backoff-interval-max:2500}") final long backoffIntervalMax) {
final HttpClient.Builder httpClientBuilder = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(CONNECT_TIMEOUT));
if (proxyEnabled && proxyHost != null && !proxyHost.isBlank()) {
httpClientBuilder.proxy(ProxySelector.of(new InetSocketAddress(proxyHost, proxyPort)));
if (proxyUser != null && !proxyUser.isBlank()) {
httpClientBuilder.authenticator(new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(proxyUser, proxyPassword.toCharArray());
}
});
}
}
if (tlsVerificationDisabled) {
try {
final SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, new TrustManager[] { new X509TrustManager() {
@Override
public void checkClientTrusted(final X509Certificate[] chain, final String authType) {
}
@Override
public void checkServerTrusted(final X509Certificate[] chain, final String authType) {
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
} }, new SecureRandom());
httpClientBuilder.sslContext(sslContext);
}
catch (NoSuchAlgorithmException | KeyManagementException e) {
throw new RuntimeException("Failed to disable TLS verification", e);
}
}
final JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClientBuilder.build());
requestFactory.setReadTimeout(Duration.ofMillis(TIMEOUT));
return RestClient.builder()
.requestFactory(requestFactory)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.requestInterceptor(createRetryInterceptor(maxAttempts, backoffIntervalMin, backoffIntervalMax));
}
private ClientHttpRequestInterceptor createRetryInterceptor(final int maxAttempts, final long backoffIntervalMin, final long backoffIntervalMax) {
return (request, body, execution) -> {
final BackOff backOff = new ExponentialBackOff();
((ExponentialBackOff) backOff).setInitialInterval(backoffIntervalMin);
((ExponentialBackOff) backOff).setMaxInterval(backoffIntervalMax);
((ExponentialBackOff) backOff).setMaxElapsedTime(maxAttempts * backoffIntervalMax);
((ExponentialBackOff) backOff).setMultiplier(2.0);
((ExponentialBackOff) backOff).setJitter(backoffIntervalMin / 2);
final BackOffExecution backOffExecution = backOff.start();
while (true) {
try {
return execution.execute(request, body);
}
catch (final java.io.IOException e) {
final long waitTime = backOffExecution.nextBackOff();
if (waitTime == BackOffExecution.STOP) {
throw e;
}
LOGGER.warn("Request failed, retrying in {} ms: {} {}", waitTime, e.getClass().getSimpleName(), e.getMessage() != null ? e.getMessage() : "");
try {
Thread.sleep(waitTime);
}
catch (final InterruptedException ie) {
Thread.currentThread().interrupt();
throw e;
}
}
}
};
}
}

View File

@@ -1,40 +0,0 @@
package de.extio.lmlib.client;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import reactor.netty.http.client.HttpClient;
@Configuration
public class WebClientConfiguration {
private static final int TIMEOUT = 300000;
private static final int CONNECT_TIMEOUT = 10000;
@Bean
@Qualifier("lmLibWebClientBuilder")
WebClient.Builder lmLibWebClientBuilder() {
final HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, CONNECT_TIMEOUT)
.responseTimeout(Duration.ofMillis(TIMEOUT))
.doOnConnected(conn -> conn.addHandlerLast(new ReadTimeoutHandler(TIMEOUT, TimeUnit.MILLISECONDS)).addHandlerLast(new WriteTimeoutHandler(TIMEOUT, TimeUnit.MILLISECONDS)));
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE);
}
}

View File

@@ -2,7 +2,7 @@ package de.extio.lmlib.client.oai;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.*;
public final class Model {

View File

@@ -5,28 +5,27 @@ import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpMethod;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.client.RestClient;
class ModelNameLoader {
private static final Logger LOGGER = LoggerFactory.getLogger(ModelNameLoader.class);
private final WebClient webClient;
private final RestClient restClient;
public ModelNameLoader(final WebClient webClient) {
this.webClient = webClient;
public ModelNameLoader(final RestClient restClient) {
this.restClient = restClient;
}
public Optional<String> loadModelName() {
try {
LOGGER.info("Querying model name");
final var modelName = this.webClient
final var modelName = this.restClient
.method(HttpMethod.GET)
.uri(uriBuilder -> uriBuilder.path("/v1/models").build())
.retrieve()
.bodyToMono(ModelsResponse.class)
.block()
.body(ModelsResponse.class)
.getData()
.stream()
.map(Model::getId)

View File

@@ -6,7 +6,7 @@ import java.util.concurrent.ConcurrentHashMap;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.client.RestClient;
import de.extio.lmlib.profile.ModelProfile;
import de.extio.lmlib.profile.ModelProfile.ModelProvider;
@@ -15,8 +15,8 @@ import de.extio.lmlib.profile.ModelProfile.ModelProvider;
public class ModelNameSupplier {
@Autowired
@Qualifier("lmLibWebClientBuilder")
private WebClient.Builder webClientBuilder;
@Qualifier("lmLibRestClientBuilder")
private RestClient.Builder restClientBuilder;
private final Map<String, String> resolvedModelNames = new ConcurrentHashMap<>();
@@ -31,8 +31,8 @@ public class ModelNameSupplier {
final var cacheKey = modelProfile.category() + "|" + modelProfile.url();
return this.resolvedModelNames.computeIfAbsent(cacheKey, key -> {
final var webClient = this.webClientBuilder.baseUrl(modelProfile.url()).build();
final var loader = new ModelNameLoader(webClient);
final var restClient = this.restClientBuilder.baseUrl(modelProfile.url()).build();
final var loader = new ModelNameLoader(restClient);
return loader.loadModelName().orElse("");
});
}

View File

@@ -2,7 +2,7 @@ package de.extio.lmlib.client.oai;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.*;
public final class ModelsResponse {

View File

@@ -13,13 +13,13 @@ import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.client.RestClient;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.json.JsonReadFeature;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import tools.jackson.core.json.JsonReadFeature;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
import de.extio.lmlib.client.Chunk;
import de.extio.lmlib.client.Client;
@@ -32,15 +32,14 @@ import de.extio.lmlib.profile.ModelProfile;
import de.extio.lmlib.profile.ModelProfile.ModelProvider;
import de.extio.lmlib.profile.ModelProfileService;
import de.extio.lmlib.token.Tokenizer;
import reactor.util.retry.Retry;
public abstract class AbstractCompletionClient implements Client, DisposableBean {
private static final Logger LOGGER = LoggerFactory.getLogger(AbstractCompletionClient.class);
@Autowired
@Qualifier("lmLibWebClientBuilder")
protected WebClient.Builder webClientBuilder;
@Qualifier("lmLibRestClientBuilder")
protected RestClient.Builder restClientBuilder;
@Autowired
protected ModelNameSupplier modelNameSupplier;
@@ -65,7 +64,8 @@ public abstract class AbstractCompletionClient implements Client, DisposableBean
.configure(DeserializationFeature.FAIL_ON_MISSING_EXTERNAL_TYPE_ID_PROPERTY, false)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.configure(DeserializationFeature.FAIL_ON_UNRESOLVED_OBJECT_IDS, false)
.serializationInclusion(JsonInclude.Include.NON_NULL)
.changeDefaultPropertyInclusion(incl -> incl.withValueInclusion(JsonInclude.Include.NON_NULL))
.changeDefaultPropertyInclusion(incl -> incl.withContentInclusion(JsonInclude.Include.NON_NULL))
.enable(JsonReadFeature.ALLOW_BACKSLASH_ESCAPING_ANY_CHARACTER)
.enable(JsonReadFeature.ALLOW_JAVA_COMMENTS)
.enable(JsonReadFeature.ALLOW_MISSING_VALUES)
@@ -77,7 +77,7 @@ public abstract class AbstractCompletionClient implements Client, DisposableBean
.enable(JsonReadFeature.ALLOW_LEADING_DECIMAL_POINT_FOR_NUMBERS)
.enable(JsonReadFeature.ALLOW_LEADING_PLUS_SIGN_FOR_NUMBERS)
.enable(JsonReadFeature.ALLOW_TRAILING_DECIMAL_POINT_FOR_NUMBERS)
.enable(JsonReadFeature.ALLOW_UNQUOTED_FIELD_NAMES)
.enable(JsonReadFeature.ALLOW_UNQUOTED_PROPERTY_NAMES)
.build();
}
@@ -122,16 +122,6 @@ public abstract class AbstractCompletionClient implements Client, DisposableBean
};
}
protected Retry createRetrySpec() {
return Retry.backoff(99, Duration.ofSeconds(1))
.maxBackoff(Duration.ofSeconds(10))
.jitter(0.25d)
.filter(throwable -> {
return throwable instanceof java.io.IOException || throwable instanceof java.net.ConnectException || throwable instanceof java.net.http.HttpTimeoutException;
})
.doAfterRetry(rs -> LOGGER.warn("Retrying failed request: " + rs.failure().getClass().getName() + " " + rs.failure().getMessage()));
}
protected void configureStreamOptions(final boolean enableStreaming, final Consumer<StreamOptions> streamOptionsConsumer) {
if (enableStreaming) {
final var streamOptions = new StreamOptions();

View File

@@ -1,6 +1,6 @@
package de.extio.lmlib.client.oai.completion;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.*;
public abstract class AbstractCompletionResponse {

View File

@@ -1,6 +1,6 @@
package de.extio.lmlib.client.oai.completion;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.*;
public class StreamOptions {

View File

@@ -1,6 +1,6 @@
package de.extio.lmlib.client.oai.completion;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.*;
public final class Timings {

View File

@@ -1,6 +1,6 @@
package de.extio.lmlib.client.oai.completion;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.*;
public final class Usage {

View File

@@ -1,6 +1,6 @@
package de.extio.lmlib.client.oai.completion.chat;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.*;
class ChatChoice {

View File

@@ -1,5 +1,8 @@
package de.extio.lmlib.client.oai.completion.chat;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.ArrayDeque;
import java.util.ArrayList;
@@ -12,8 +15,9 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import com.fasterxml.jackson.core.JsonProcessingException;
import tools.jackson.core.JacksonException;
import de.extio.lmlib.client.Chunk;
import de.extio.lmlib.client.Completion;
@@ -22,7 +26,6 @@ import de.extio.lmlib.client.Conversation.Turn;
import de.extio.lmlib.client.oai.completion.AbstractCompletionClient;
import de.extio.lmlib.profile.ModelProfile;
import de.extio.lmlib.profile.ModelProfile.ModelProvider;
import reactor.core.publisher.Mono;
@Component
public class ChatCompletionClient extends AbstractCompletionClient {
@@ -80,92 +83,99 @@ public class ChatCompletionClient extends AbstractCompletionClient {
try {
requestBody = this.objectMapper.writeValueAsString(request);
}
catch (final JsonProcessingException e) {
catch (final JacksonException e) {
throw new IllegalStateException("Cannot serialize request body", e);
}
LOGGER.debug("Requesting chat completion at {}", modelProfile.url());
final LocalDateTime start = LocalDateTime.now();
final var webClient = this.webClientBuilder.baseUrl(modelProfile.url()).build();
final var restClient = this.restClientBuilder.baseUrl(modelProfile.url()).build();
var requestSpec = webClient
var requestSpec = restClient
.method(HttpMethod.POST)
.uri(uriBuilder -> uriBuilder.path("/v1/chat/completions").build())
.uri("/v1/chat/completions")
.header("Content-Type", "application/json");
if (modelProfile.apiKey() != null && !modelProfile.apiKey().isBlank()) {
requestSpec = requestSpec.header("Authorization", "Bearer " + modelProfile.apiKey());
}
final var response = requestSpec
.bodyValue(requestBody)
.exchangeToMono(clientResponse -> {
if (clientResponse.statusCode().isError()) {
return clientResponse.bodyToMono(String.class)
.flatMap(errorBody -> Mono.error(new IllegalStateException("Error response from server: " + clientResponse.statusCode() + " - " + errorBody)));
.body(requestBody)
.exchange((clientRequest, clientResponse) -> {
if (clientResponse.getStatusCode().isError()) {
throw new IllegalStateException("Error response from server: " + clientResponse.getStatusCode());
}
if (chunkConsumer == null) {
return clientResponse.bodyToMono(ChatCompletionResponse.class);
return this.objectMapper.readValue(clientResponse.getBody(), ChatCompletionResponse.class);
}
else {
final var chatCompletionResponse = new ChatCompletionResponse();
chatCompletionResponse.setChoices(List.of(new ChatChoice()));
final StringBuilder contentStringBuilder = new StringBuilder();
final StringBuilder reasoningStringBuilder = new StringBuilder();
return clientResponse.bodyToFlux(String.class)
.doOnNext(buffer -> {
try {
if (!"[DONE]".equals(buffer)) {
final var streamResponse = this.objectMapper.readValue(buffer, ChatCompletionResponse.class);
if (streamResponse.getId() != null && !streamResponse.getId().isEmpty()) {
chatCompletionResponse.setId(streamResponse.getId());
try (BufferedReader reader = new BufferedReader(new InputStreamReader(clientResponse.getBody(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.startsWith("data: ")) {
line = line.substring(6).trim();
}
if (line.isEmpty()) {
continue;
}
if ("[DONE]".equals(line)) {
break;
}
try {
final var streamResponse = this.objectMapper.readValue(line, ChatCompletionResponse.class);
if (streamResponse.getId() != null && !streamResponse.getId().isEmpty()) {
chatCompletionResponse.setId(streamResponse.getId());
}
if (streamResponse.getObject() != null && !streamResponse.getObject().isEmpty()) {
chatCompletionResponse.setObject(streamResponse.getObject());
}
if (streamResponse.getCreated() != null) {
chatCompletionResponse.setCreated(streamResponse.getCreated());
}
if (streamResponse.getUsage() != null) {
chatCompletionResponse.setUsage(streamResponse.getUsage());
}
if (streamResponse.getTimings() != null) {
chatCompletionResponse.setTimings(streamResponse.getTimings());
}
if (streamResponse.getChoices() != null && !streamResponse.getChoices().isEmpty()) {
if (streamResponse.getChoices().getFirst().getFinishReason() != null) {
chatCompletionResponse.getChoices().getFirst().setFinishReason(streamResponse.getChoices().getFirst().getFinishReason());
}
if (streamResponse.getChoices().getFirst().getMessage() != null) {
String content = null;
String reasoning = null;
if (streamResponse.getChoices().getFirst().getMessage().getContent() != null && !streamResponse.getChoices().getFirst().getMessage().getContent().isEmpty()) {
content = streamResponse.getChoices().getFirst().getMessage().getContent();
contentStringBuilder.append(content);
}
if (streamResponse.getObject() != null && !streamResponse.getObject().isEmpty()) {
chatCompletionResponse.setObject(streamResponse.getObject());
if (streamResponse.getChoices().getFirst().getMessage().getReasoningContent() != null && !streamResponse.getChoices().getFirst().getMessage().getReasoningContent().isEmpty()) {
reasoning = streamResponse.getChoices().getFirst().getMessage().getReasoningContent();
reasoningStringBuilder.append(reasoning);
}
if (streamResponse.getCreated() != null) {
chatCompletionResponse.setCreated(streamResponse.getCreated());
}
if (streamResponse.getUsage() != null) {
chatCompletionResponse.setUsage(streamResponse.getUsage());
}
if (streamResponse.getTimings() != null) {
chatCompletionResponse.setTimings(streamResponse.getTimings());
}
if (streamResponse.getChoices() != null && !streamResponse.getChoices().isEmpty()) {
if (streamResponse.getChoices().getFirst().getFinishReason() != null) {
chatCompletionResponse.getChoices().getFirst().setFinishReason(streamResponse.getChoices().getFirst().getFinishReason());
}
if (streamResponse.getChoices().getFirst().getMessage() != null) {
String content = null;
String reasoning = null;
if (streamResponse.getChoices().getFirst().getMessage().getContent() != null && !streamResponse.getChoices().getFirst().getMessage().getContent().isEmpty()) {
content = streamResponse.getChoices().getFirst().getMessage().getContent();
contentStringBuilder.append(content);
}
if (streamResponse.getChoices().getFirst().getMessage().getReasoningContent() != null && !streamResponse.getChoices().getFirst().getMessage().getReasoningContent().isEmpty()) {
reasoning = streamResponse.getChoices().getFirst().getMessage().getReasoningContent();
reasoningStringBuilder.append(reasoning);
}
if (content != null || reasoning != null) {
chunkConsumer.accept(new Chunk(content, reasoning));
}
}
if (content != null || reasoning != null) {
chunkConsumer.accept(new Chunk(content, reasoning));
}
}
}
catch (final JsonProcessingException e) {
LOGGER.warn("Streamed chunk is not parseable", e);
}
})
.then(Mono.<ChatCompletionResponse> fromSupplier(() -> {
chatCompletionResponse.getChoices().getFirst().setMessage(new ChatMessage("assistant", contentStringBuilder.toString(), reasoningStringBuilder.toString()));
return chatCompletionResponse;
}));
}
catch (final JacksonException e) {
LOGGER.warn("Streamed chunk is not parseable: {}", line, e);
}
}
}
chatCompletionResponse.getChoices().getFirst().setMessage(new ChatMessage("assistant", contentStringBuilder.toString(), reasoningStringBuilder.toString()));
return chatCompletionResponse;
}
})
.retryWhen(this.createRetrySpec())
.block();
});
if (response.getChoices() == null || response.getChoices().isEmpty() || response.getChoices().getFirst().getMessage() == null) {
throw new IllegalStateException("No parseable response content from server");

View File

@@ -2,7 +2,7 @@ package de.extio.lmlib.client.oai.completion.chat;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.*;
import de.extio.lmlib.client.oai.completion.AbstractCompletionRequest;

View File

@@ -2,7 +2,7 @@ package de.extio.lmlib.client.oai.completion.chat;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.*;
import de.extio.lmlib.client.oai.completion.AbstractCompletionResponse;

View File

@@ -1,6 +1,6 @@
package de.extio.lmlib.client.oai.completion.chat;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.*;
final class ChatMessage {

View File

@@ -1,6 +1,6 @@
package de.extio.lmlib.client.oai.completion.text;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.*;
final class Choice {

View File

@@ -1,6 +1,6 @@
package de.extio.lmlib.client.oai.completion.text;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.*;
import de.extio.lmlib.client.oai.completion.AbstractCompletionRequest;

View File

@@ -2,7 +2,7 @@ package de.extio.lmlib.client.oai.completion.text;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.*;
import de.extio.lmlib.client.oai.completion.AbstractCompletionResponse;

View File

@@ -1,5 +1,8 @@
package de.extio.lmlib.client.oai.completion.text;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.ArrayDeque;
import java.util.Deque;
@@ -11,8 +14,9 @@ import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import com.fasterxml.jackson.core.JsonProcessingException;
import tools.jackson.core.JacksonException;
import de.extio.lmlib.client.Chunk;
import de.extio.lmlib.client.Completion;
@@ -24,7 +28,6 @@ import de.extio.lmlib.profile.ModelProfile;
import de.extio.lmlib.profile.ModelProfile.ModelProvider;
import de.extio.lmlib.prompt.PromptStrategy;
import de.extio.lmlib.prompt.PromptStrategyFactory;
import reactor.core.publisher.Mono;
@Component
public class TextCompletionClient extends AbstractCompletionClient {
@@ -65,88 +68,94 @@ public class TextCompletionClient extends AbstractCompletionClient {
try {
requestBody = this.objectMapper.writeValueAsString(request);
}
catch (final JsonProcessingException e) {
catch (final JacksonException e) {
throw new IllegalStateException("Cannot serialize request body", e);
}
LOGGER.debug("Requesting completion at {}", modelProfile.url());
final LocalDateTime start = LocalDateTime.now();
final var webClient = this.webClientBuilder.baseUrl(modelProfile.url()).build();
var requestSpec = webClient
final var restClient = this.restClientBuilder.baseUrl(modelProfile.url()).build();
var requestSpec = restClient
.method(HttpMethod.POST)
.uri(uriBuilder -> uriBuilder.path("/v1/completions").build())
.uri("/v1/completions")
.header("Content-Type", "application/json");
if (modelProfile.apiKey() != null && !modelProfile.apiKey().isBlank()) {
requestSpec = requestSpec.header("Authorization", "Bearer " + modelProfile.apiKey());
}
final var response = requestSpec
.bodyValue(requestBody)
.exchangeToMono(clientResponse -> {
if (clientResponse.statusCode().isError()) {
return clientResponse.bodyToMono(String.class)
.flatMap(errorBody -> Mono.error(new IllegalStateException("Error response from server: " + clientResponse.statusCode() + " - " + errorBody)));
.body(requestBody)
.exchange((clientRequest, clientResponse) -> {
if (clientResponse.getStatusCode().isError()) {
throw new IllegalStateException("Error response from server: " + clientResponse.getStatusCode());
}
if (chunkConsumer == null) {
return clientResponse.bodyToMono(CompletionResponse.class);
return this.objectMapper.readValue(clientResponse.getBody(), CompletionResponse.class);
}
else {
final var CompletionResponse = new CompletionResponse();
CompletionResponse.setChoices(List.of(new Choice()));
final var completionResponse = new CompletionResponse();
completionResponse.setChoices(List.of(new Choice()));
final StringBuilder contentStringBuilder = new StringBuilder();
return clientResponse.bodyToFlux(String.class)
.doOnNext(buffer -> {
try {
if (!"[DONE]".equals(buffer)) {
final var streamResponse = this.objectMapper.readValue(buffer, CompletionResponse.class);
if (streamResponse.getId() != null && !streamResponse.getId().isEmpty()) {
CompletionResponse.setId(streamResponse.getId());
}
if (streamResponse.getObject() != null && !streamResponse.getObject().isEmpty()) {
CompletionResponse.setObject(streamResponse.getObject());
}
if (streamResponse.getCreated() != null) {
CompletionResponse.setCreated(streamResponse.getCreated());
}
if (streamResponse.isStoppedEos() != null) {
CompletionResponse.setStoppedEos(streamResponse.isStoppedEos());
}
if (streamResponse.getContent() != null && !streamResponse.getContent().isEmpty()) {
contentStringBuilder.append(streamResponse.getContent());
}
if (streamResponse.getUsage() != null) {
CompletionResponse.setUsage(streamResponse.getUsage());
}
if (streamResponse.getTimings() != null) {
CompletionResponse.setTimings(streamResponse.getTimings());
}
if (streamResponse.getChoices() != null && !streamResponse.getChoices().isEmpty()) {
if (streamResponse.getChoices().getFirst().getFinishReason() != null) {
CompletionResponse.getChoices().getFirst().setFinishReason(streamResponse.getChoices().getFirst().getFinishReason());
}
if (streamResponse.getChoices().getFirst().getScore() != null) {
CompletionResponse.getChoices().getFirst().setScore(streamResponse.getChoices().getFirst().getScore());
}
if (streamResponse.getChoices().getFirst().getText() != null && !streamResponse.getChoices().getFirst().getText().isEmpty()) {
contentStringBuilder.append(streamResponse.getChoices().getFirst().getText());
chunkConsumer.accept(new Chunk(streamResponse.getChoices().getFirst().getText(), null));
}
}
try (BufferedReader reader = new BufferedReader(new InputStreamReader(clientResponse.getBody(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.startsWith("data: ")) {
line = line.substring(6).trim();
}
if (line.isEmpty()) {
continue;
}
if ("[DONE]".equals(line)) {
break;
}
try {
final var streamResponse = this.objectMapper.readValue(line, CompletionResponse.class);
if (streamResponse.getId() != null && !streamResponse.getId().isEmpty()) {
completionResponse.setId(streamResponse.getId());
}
if (streamResponse.getObject() != null && !streamResponse.getObject().isEmpty()) {
completionResponse.setObject(streamResponse.getObject());
}
if (streamResponse.getCreated() != null) {
completionResponse.setCreated(streamResponse.getCreated());
}
if (streamResponse.isStoppedEos() != null) {
completionResponse.setStoppedEos(streamResponse.isStoppedEos());
}
if (streamResponse.getContent() != null && !streamResponse.getContent().isEmpty()) {
contentStringBuilder.append(streamResponse.getContent());
}
if (streamResponse.getUsage() != null) {
completionResponse.setUsage(streamResponse.getUsage());
}
if (streamResponse.getTimings() != null) {
completionResponse.setTimings(streamResponse.getTimings());
}
if (streamResponse.getChoices() != null && !streamResponse.getChoices().isEmpty()) {
if (streamResponse.getChoices().getFirst().getFinishReason() != null) {
completionResponse.getChoices().getFirst().setFinishReason(streamResponse.getChoices().getFirst().getFinishReason());
}
if (streamResponse.getChoices().getFirst().getScore() != null) {
completionResponse.getChoices().getFirst().setScore(streamResponse.getChoices().getFirst().getScore());
}
if (streamResponse.getChoices().getFirst().getText() != null && !streamResponse.getChoices().getFirst().getText().isEmpty()) {
contentStringBuilder.append(streamResponse.getChoices().getFirst().getText());
chunkConsumer.accept(new Chunk(streamResponse.getChoices().getFirst().getText(), null));
}
}
catch (final JsonProcessingException e) {
LOGGER.warn("Streamed chunk is not parseable", e);
}
})
.then(Mono.<CompletionResponse> fromSupplier(() -> {
CompletionResponse.getChoices().getFirst().setText(contentStringBuilder.toString());
return CompletionResponse;
}));
}
catch (final JacksonException e) {
LOGGER.warn("Streamed chunk is not parseable: {}", line, e);
}
}
}
completionResponse.getChoices().getFirst().setText(contentStringBuilder.toString());
return completionResponse;
}
})
.retryWhen(this.createRetrySpec())
.block();
});
String content = null;
String reasoning = null;

View File

@@ -6,36 +6,31 @@ import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpMethod;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClient.Builder;
import org.springframework.web.client.RestClient;
import de.extio.lmlib.profile.ModelProfile;
import reactor.util.retry.Retry;
final class LlamaServerTokenizer implements Tokenizer {
private static final Logger LOGGER = LoggerFactory.getLogger(LlamaServerTokenizer.class);
private final WebClient.Builder webClientBuilder;
private final RestClient.Builder restClientBuilder;
public LlamaServerTokenizer(final Builder webClientBuilder) {
this.webClientBuilder = webClientBuilder;
public LlamaServerTokenizer(final RestClient.Builder restClientBuilder) {
this.restClientBuilder = restClientBuilder;
}
@Override
public List<Long> tokenize(final String txt, final ModelProfile modelProfile) {
LOGGER.debug("Requesting tokenizing");
final var request = new TokenizeRequest(txt);
final var webClient = this.webClientBuilder.baseUrl(modelProfile.url()).build();
final var response = webClient
final var restClient = this.restClientBuilder.baseUrl(modelProfile.url()).build();
final var response = restClient
.method(HttpMethod.POST)
.uri(uriBuilder -> uriBuilder.path("/tokenize").build())
.bodyValue(request)
.uri("/tokenize")
.body(request)
.retrieve()
.bodyToMono(TokenizeResponse.class)
.retryWhen(Retry.backoff(99, Duration.ofSeconds(1)).maxBackoff(Duration.ofSeconds(10)).jitter(0.25d).doAfterRetry(rs -> LOGGER.warn("Request failed " + rs.failure().getMessage())))
.block();
.body(TokenizeResponse.class);
return response.tokens();
}
@@ -48,15 +43,13 @@ final class LlamaServerTokenizer implements Tokenizer {
public String detokenize(final List<Long> tokens, final ModelProfile modelProfile) {
LOGGER.debug("Requesting detokenizing");
final var request = new DetokenizeRequest(tokens);
final var webClient = this.webClientBuilder.baseUrl(modelProfile.url()).build();
final var response = webClient
final var restClient = this.restClientBuilder.baseUrl(modelProfile.url()).build();
final var response = restClient
.method(HttpMethod.POST)
.uri(uriBuilder -> uriBuilder.path("/detokenize").build())
.bodyValue(request)
.uri("/detokenize")
.body(request)
.retrieve()
.bodyToMono(DetokenizeResponse.class)
.retryWhen(Retry.backoff(99, Duration.ofSeconds(1)).maxBackoff(Duration.ofSeconds(10)).jitter(0.25d).doAfterRetry(rs -> LOGGER.warn("Request failed " + rs.failure().getMessage())))
.block();
.body(DetokenizeResponse.class);
return response.content();
}

View File

@@ -7,7 +7,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.client.RestClient;
@AutoConfiguration
public class TokenizerAutoConfiguration {
@@ -15,8 +15,8 @@ public class TokenizerAutoConfiguration {
@Bean
@ConditionalOnProperty(name = "tokenizer.strategy", havingValue = "llamaServer")
@ConditionalOnMissingBean(type = "de.extio.lmlib.token.Tokenizer")
Tokenizer llamaServerTokenizer(@Qualifier("lmLibWebClientBuilder") final WebClient.Builder webClientBuilder) {
return new LlamaServerTokenizer(webClientBuilder);
Tokenizer llamaServerTokenizer(@Qualifier("lmLibRestClientBuilder") final RestClient.Builder restClientBuilder) {
return new LlamaServerTokenizer(restClientBuilder);
}
@Configuration(proxyBeanMethods = false)