Version 2.0
Java 25, Spring Boot 4, Use RestClient with JDK client backend
This commit is contained in:
23
README.md
23
README.md
@@ -16,6 +16,11 @@ The libraries's key features include:
|
|||||||
- Modular architecture for easy extension and customization
|
- Modular architecture for easy extension and customization
|
||||||
- Spring integration for dependency injection and configuration
|
- Spring integration for dependency injection and configuration
|
||||||
|
|
||||||
|
# Requirements
|
||||||
|
|
||||||
|
- **Java 25** or higher
|
||||||
|
- **Spring Boot 4.0** or higher
|
||||||
|
|
||||||
# Setup
|
# Setup
|
||||||
|
|
||||||
To keep this library lean and compatible with different dependency trees, all dependencies must be provided by the application.
|
To keep this library lean and compatible with different dependency trees, all dependencies must be provided by the application.
|
||||||
@@ -24,9 +29,25 @@ To keep this library lean and compatible with different dependency trees, all de
|
|||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
<artifactId>spring-boot-starter-restclient</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The library can be configured using the following application properties:
|
||||||
|
|
||||||
|
| Property | Type | Default | Description |
|
||||||
|
|----------|------|---------|-------------|
|
||||||
|
| `lmlib.client.proxy.enabled` | Boolean | `false` | Enable HTTP proxy for LLM requests. |
|
||||||
|
| `lmlib.client.proxy.host` | String | | HTTP proxy host. |
|
||||||
|
| `lmlib.client.proxy.port` | Integer | `0` | HTTP proxy port. |
|
||||||
|
| `lmlib.client.proxy.user` | String | | HTTP proxy username (optional). |
|
||||||
|
| `lmlib.client.proxy.password` | String | | HTTP proxy password (optional). |
|
||||||
|
| `lmlib.client.tls.verification.disabled` | Boolean | `false` | Disable TLS certificate verification (not recommended for production). |
|
||||||
|
| `lmlib.client.retry.max-attempts` | Integer | `5` | Maximum number of retry attempts for failed requests. |
|
||||||
|
| `lmlib.client.retry.backoff-interval-min` | Long | `200` | Minimum backoff interval in milliseconds between retry attempts. |
|
||||||
|
| `lmlib.client.retry.backoff-interval-max` | Long | `2500` | Maximum backoff interval in milliseconds between retry attempts. |
|
||||||
|
|
||||||
## Optional dependencies
|
## Optional dependencies
|
||||||
|
|
||||||
Some services benefit from Spring cache framework, e.g. the model profile service:
|
Some services benefit from Spring cache framework, e.g. the model profile service:
|
||||||
|
|||||||
12
pom.xml
12
pom.xml
@@ -4,13 +4,13 @@
|
|||||||
<artifactId>lm-lib</artifactId>
|
<artifactId>lm-lib</artifactId>
|
||||||
<groupId>de.extio</groupId>
|
<groupId>de.extio</groupId>
|
||||||
<name>lm-lib</name>
|
<name>lm-lib</name>
|
||||||
<version>1.10.0</version>
|
<version>2.0</version>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<java.version>21</java.version>
|
<java.version>25</java.version>
|
||||||
<maven.compiler.source>21</maven.compiler.source>
|
<maven.compiler.source>25</maven.compiler.source>
|
||||||
<maven.compiler.target>21</maven.compiler.target>
|
<maven.compiler.target>25</maven.compiler.target>
|
||||||
<spring-boot.version>3.5.6</spring-boot.version>
|
<spring-boot.version>4.0.1</spring-boot.version>
|
||||||
<jtokkit.version>1.1.0</jtokkit.version>
|
<jtokkit.version>1.1.0</jtokkit.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
<artifactId>spring-boot-starter-restclient</artifactId>
|
||||||
<scope>provided</scope>
|
<scope>provided</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import java.util.stream.Collectors;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import tools.jackson.core.JacksonException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
import de.extio.lmlib.agent.responsehandler.AgentResponseHandler;
|
import de.extio.lmlib.agent.responsehandler.AgentResponseHandler;
|
||||||
import de.extio.lmlib.agent.responsehandler.StreamedAgentResponseHandler;
|
import de.extio.lmlib.agent.responsehandler.StreamedAgentResponseHandler;
|
||||||
@@ -239,7 +239,7 @@ public interface BaseAgent {
|
|||||||
try {
|
try {
|
||||||
text = text.replace(k, objectMapper.writeValueAsString(entry.getValue()));
|
text = text.replace(k, objectMapper.writeValueAsString(entry.getValue()));
|
||||||
}
|
}
|
||||||
catch (final JsonProcessingException e) {
|
catch (final JacksonException e) {
|
||||||
LOGGER.warn("Cannot write json", e);
|
LOGGER.warn("Cannot write json", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ import java.util.Map.Entry;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature;
|
import tools.jackson.core.json.JsonReadFeature;
|
||||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
import tools.jackson.databind.DeserializationFeature;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import tools.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import tools.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
import de.extio.lmlib.agent.AgentContext;
|
import de.extio.lmlib.agent.AgentContext;
|
||||||
import de.extio.lmlib.client.Completion;
|
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_DECIMAL_POINT_FOR_NUMBERS)
|
||||||
.enable(JsonReadFeature.ALLOW_LEADING_PLUS_SIGN_FOR_NUMBERS)
|
.enable(JsonReadFeature.ALLOW_LEADING_PLUS_SIGN_FOR_NUMBERS)
|
||||||
.enable(JsonReadFeature.ALLOW_TRAILING_DECIMAL_POINT_FOR_NUMBERS)
|
.enable(JsonReadFeature.ALLOW_TRAILING_DECIMAL_POINT_FOR_NUMBERS)
|
||||||
.enable(JsonReadFeature.ALLOW_UNQUOTED_FIELD_NAMES)
|
.enable(JsonReadFeature.ALLOW_UNQUOTED_PROPERTY_NAMES)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ public class JsonAgentResponseHandler implements AgentResponseHandler {
|
|||||||
|
|
||||||
private void parseEntry(final LinkedHashMap<String, List<String>> items, final Entry<String, JsonNode> entry) {
|
private void parseEntry(final LinkedHashMap<String, List<String>> items, final Entry<String, JsonNode> entry) {
|
||||||
if (entry.getValue().isArray()) {
|
if (entry.getValue().isArray()) {
|
||||||
entry.getValue().elements().forEachRemaining(nestedArrayValue -> {
|
entry.getValue().values().forEach(nestedArrayValue -> {
|
||||||
if (nestedArrayValue.properties().isEmpty()) {
|
if (nestedArrayValue.properties().isEmpty()) {
|
||||||
items.computeIfAbsent(this.prefixFields + entry.getKey(), k -> new ArrayList<>()).add(nestedArrayValue.asText());
|
items.computeIfAbsent(this.prefixFields + entry.getKey(), k -> new ArrayList<>()).add(nestedArrayValue.asText());
|
||||||
}
|
}
|
||||||
|
|||||||
139
src/main/java/de/extio/lmlib/client/RestClientConfiguration.java
Normal file
139
src/main/java/de/extio/lmlib/client/RestClientConfiguration.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ package de.extio.lmlib.client.oai;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.*;
|
||||||
|
|
||||||
public final class Model {
|
public final class Model {
|
||||||
|
|
||||||
|
|||||||
@@ -5,28 +5,27 @@ import java.util.Optional;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.web.reactive.function.client.WebClient;
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
class ModelNameLoader {
|
class ModelNameLoader {
|
||||||
|
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger(ModelNameLoader.class);
|
private static final Logger LOGGER = LoggerFactory.getLogger(ModelNameLoader.class);
|
||||||
|
|
||||||
private final WebClient webClient;
|
private final RestClient restClient;
|
||||||
|
|
||||||
public ModelNameLoader(final WebClient webClient) {
|
public ModelNameLoader(final RestClient restClient) {
|
||||||
this.webClient = webClient;
|
this.restClient = restClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<String> loadModelName() {
|
public Optional<String> loadModelName() {
|
||||||
try {
|
try {
|
||||||
LOGGER.info("Querying model name");
|
LOGGER.info("Querying model name");
|
||||||
|
|
||||||
final var modelName = this.webClient
|
final var modelName = this.restClient
|
||||||
.method(HttpMethod.GET)
|
.method(HttpMethod.GET)
|
||||||
.uri(uriBuilder -> uriBuilder.path("/v1/models").build())
|
.uri(uriBuilder -> uriBuilder.path("/v1/models").build())
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.bodyToMono(ModelsResponse.class)
|
.body(ModelsResponse.class)
|
||||||
.block()
|
|
||||||
.getData()
|
.getData()
|
||||||
.stream()
|
.stream()
|
||||||
.map(Model::getId)
|
.map(Model::getId)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.stereotype.Component;
|
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;
|
||||||
import de.extio.lmlib.profile.ModelProfile.ModelProvider;
|
import de.extio.lmlib.profile.ModelProfile.ModelProvider;
|
||||||
@@ -15,8 +15,8 @@ import de.extio.lmlib.profile.ModelProfile.ModelProvider;
|
|||||||
public class ModelNameSupplier {
|
public class ModelNameSupplier {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
@Qualifier("lmLibWebClientBuilder")
|
@Qualifier("lmLibRestClientBuilder")
|
||||||
private WebClient.Builder webClientBuilder;
|
private RestClient.Builder restClientBuilder;
|
||||||
|
|
||||||
private final Map<String, String> resolvedModelNames = new ConcurrentHashMap<>();
|
private final Map<String, String> resolvedModelNames = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
@@ -31,8 +31,8 @@ public class ModelNameSupplier {
|
|||||||
|
|
||||||
final var cacheKey = modelProfile.category() + "|" + modelProfile.url();
|
final var cacheKey = modelProfile.category() + "|" + modelProfile.url();
|
||||||
return this.resolvedModelNames.computeIfAbsent(cacheKey, key -> {
|
return this.resolvedModelNames.computeIfAbsent(cacheKey, key -> {
|
||||||
final var webClient = this.webClientBuilder.baseUrl(modelProfile.url()).build();
|
final var restClient = this.restClientBuilder.baseUrl(modelProfile.url()).build();
|
||||||
final var loader = new ModelNameLoader(webClient);
|
final var loader = new ModelNameLoader(restClient);
|
||||||
return loader.loadModelName().orElse("");
|
return loader.loadModelName().orElse("");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package de.extio.lmlib.client.oai;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.*;
|
||||||
|
|
||||||
public final class ModelsResponse {
|
public final class ModelsResponse {
|
||||||
|
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ import org.springframework.beans.factory.DisposableBean;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
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.annotation.JsonInclude;
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature;
|
import tools.jackson.core.json.JsonReadFeature;
|
||||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
import tools.jackson.databind.DeserializationFeature;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import tools.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
import de.extio.lmlib.client.Chunk;
|
import de.extio.lmlib.client.Chunk;
|
||||||
import de.extio.lmlib.client.Client;
|
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.ModelProfile.ModelProvider;
|
||||||
import de.extio.lmlib.profile.ModelProfileService;
|
import de.extio.lmlib.profile.ModelProfileService;
|
||||||
import de.extio.lmlib.token.Tokenizer;
|
import de.extio.lmlib.token.Tokenizer;
|
||||||
import reactor.util.retry.Retry;
|
|
||||||
|
|
||||||
public abstract class AbstractCompletionClient implements Client, DisposableBean {
|
public abstract class AbstractCompletionClient implements Client, DisposableBean {
|
||||||
|
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger(AbstractCompletionClient.class);
|
private static final Logger LOGGER = LoggerFactory.getLogger(AbstractCompletionClient.class);
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
@Qualifier("lmLibWebClientBuilder")
|
@Qualifier("lmLibRestClientBuilder")
|
||||||
protected WebClient.Builder webClientBuilder;
|
protected RestClient.Builder restClientBuilder;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
protected ModelNameSupplier modelNameSupplier;
|
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_MISSING_EXTERNAL_TYPE_ID_PROPERTY, false)
|
||||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||||
.configure(DeserializationFeature.FAIL_ON_UNRESOLVED_OBJECT_IDS, 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_BACKSLASH_ESCAPING_ANY_CHARACTER)
|
||||||
.enable(JsonReadFeature.ALLOW_JAVA_COMMENTS)
|
.enable(JsonReadFeature.ALLOW_JAVA_COMMENTS)
|
||||||
.enable(JsonReadFeature.ALLOW_MISSING_VALUES)
|
.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_DECIMAL_POINT_FOR_NUMBERS)
|
||||||
.enable(JsonReadFeature.ALLOW_LEADING_PLUS_SIGN_FOR_NUMBERS)
|
.enable(JsonReadFeature.ALLOW_LEADING_PLUS_SIGN_FOR_NUMBERS)
|
||||||
.enable(JsonReadFeature.ALLOW_TRAILING_DECIMAL_POINT_FOR_NUMBERS)
|
.enable(JsonReadFeature.ALLOW_TRAILING_DECIMAL_POINT_FOR_NUMBERS)
|
||||||
.enable(JsonReadFeature.ALLOW_UNQUOTED_FIELD_NAMES)
|
.enable(JsonReadFeature.ALLOW_UNQUOTED_PROPERTY_NAMES)
|
||||||
.build();
|
.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) {
|
protected void configureStreamOptions(final boolean enableStreaming, final Consumer<StreamOptions> streamOptionsConsumer) {
|
||||||
if (enableStreaming) {
|
if (enableStreaming) {
|
||||||
final var streamOptions = new StreamOptions();
|
final var streamOptions = new StreamOptions();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.extio.lmlib.client.oai.completion;
|
package de.extio.lmlib.client.oai.completion;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.*;
|
||||||
|
|
||||||
public abstract class AbstractCompletionResponse {
|
public abstract class AbstractCompletionResponse {
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.extio.lmlib.client.oai.completion;
|
package de.extio.lmlib.client.oai.completion;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.*;
|
||||||
|
|
||||||
public class StreamOptions {
|
public class StreamOptions {
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.extio.lmlib.client.oai.completion;
|
package de.extio.lmlib.client.oai.completion;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.*;
|
||||||
|
|
||||||
public final class Timings {
|
public final class Timings {
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.extio.lmlib.client.oai.completion;
|
package de.extio.lmlib.client.oai.completion;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.*;
|
||||||
|
|
||||||
public final class Usage {
|
public final class Usage {
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.extio.lmlib.client.oai.completion.chat;
|
package de.extio.lmlib.client.oai.completion.chat;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.*;
|
||||||
|
|
||||||
class ChatChoice {
|
class ChatChoice {
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package de.extio.lmlib.client.oai.completion.chat;
|
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.time.LocalDateTime;
|
||||||
import java.util.ArrayDeque;
|
import java.util.ArrayDeque;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -12,8 +15,9 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.stereotype.Component;
|
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.Chunk;
|
||||||
import de.extio.lmlib.client.Completion;
|
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.client.oai.completion.AbstractCompletionClient;
|
||||||
import de.extio.lmlib.profile.ModelProfile;
|
import de.extio.lmlib.profile.ModelProfile;
|
||||||
import de.extio.lmlib.profile.ModelProfile.ModelProvider;
|
import de.extio.lmlib.profile.ModelProfile.ModelProvider;
|
||||||
import reactor.core.publisher.Mono;
|
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class ChatCompletionClient extends AbstractCompletionClient {
|
public class ChatCompletionClient extends AbstractCompletionClient {
|
||||||
@@ -80,42 +83,53 @@ public class ChatCompletionClient extends AbstractCompletionClient {
|
|||||||
try {
|
try {
|
||||||
requestBody = this.objectMapper.writeValueAsString(request);
|
requestBody = this.objectMapper.writeValueAsString(request);
|
||||||
}
|
}
|
||||||
catch (final JsonProcessingException e) {
|
catch (final JacksonException e) {
|
||||||
throw new IllegalStateException("Cannot serialize request body", e);
|
throw new IllegalStateException("Cannot serialize request body", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
LOGGER.debug("Requesting chat completion at {}", modelProfile.url());
|
LOGGER.debug("Requesting chat completion at {}", modelProfile.url());
|
||||||
final LocalDateTime start = LocalDateTime.now();
|
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)
|
.method(HttpMethod.POST)
|
||||||
.uri(uriBuilder -> uriBuilder.path("/v1/chat/completions").build())
|
.uri("/v1/chat/completions")
|
||||||
.header("Content-Type", "application/json");
|
.header("Content-Type", "application/json");
|
||||||
if (modelProfile.apiKey() != null && !modelProfile.apiKey().isBlank()) {
|
if (modelProfile.apiKey() != null && !modelProfile.apiKey().isBlank()) {
|
||||||
requestSpec = requestSpec.header("Authorization", "Bearer " + modelProfile.apiKey());
|
requestSpec = requestSpec.header("Authorization", "Bearer " + modelProfile.apiKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
final var response = requestSpec
|
final var response = requestSpec
|
||||||
.bodyValue(requestBody)
|
.body(requestBody)
|
||||||
.exchangeToMono(clientResponse -> {
|
.exchange((clientRequest, clientResponse) -> {
|
||||||
if (clientResponse.statusCode().isError()) {
|
if (clientResponse.getStatusCode().isError()) {
|
||||||
return clientResponse.bodyToMono(String.class)
|
throw new IllegalStateException("Error response from server: " + clientResponse.getStatusCode());
|
||||||
.flatMap(errorBody -> Mono.error(new IllegalStateException("Error response from server: " + clientResponse.statusCode() + " - " + errorBody)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chunkConsumer == null) {
|
if (chunkConsumer == null) {
|
||||||
return clientResponse.bodyToMono(ChatCompletionResponse.class);
|
return this.objectMapper.readValue(clientResponse.getBody(), ChatCompletionResponse.class);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
final var chatCompletionResponse = new ChatCompletionResponse();
|
final var chatCompletionResponse = new ChatCompletionResponse();
|
||||||
chatCompletionResponse.setChoices(List.of(new ChatChoice()));
|
chatCompletionResponse.setChoices(List.of(new ChatChoice()));
|
||||||
final StringBuilder contentStringBuilder = new StringBuilder();
|
final StringBuilder contentStringBuilder = new StringBuilder();
|
||||||
final StringBuilder reasoningStringBuilder = new StringBuilder();
|
final StringBuilder reasoningStringBuilder = new StringBuilder();
|
||||||
return clientResponse.bodyToFlux(String.class)
|
|
||||||
.doOnNext(buffer -> {
|
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 {
|
try {
|
||||||
if (!"[DONE]".equals(buffer)) {
|
final var streamResponse = this.objectMapper.readValue(line, ChatCompletionResponse.class);
|
||||||
final var streamResponse = this.objectMapper.readValue(buffer, ChatCompletionResponse.class);
|
|
||||||
|
|
||||||
if (streamResponse.getId() != null && !streamResponse.getId().isEmpty()) {
|
if (streamResponse.getId() != null && !streamResponse.getId().isEmpty()) {
|
||||||
chatCompletionResponse.setId(streamResponse.getId());
|
chatCompletionResponse.setId(streamResponse.getId());
|
||||||
@@ -153,19 +167,15 @@ public class ChatCompletionClient extends AbstractCompletionClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (final JacksonException e) {
|
||||||
|
LOGGER.warn("Streamed chunk is not parseable: {}", line, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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()));
|
chatCompletionResponse.getChoices().getFirst().setMessage(new ChatMessage("assistant", contentStringBuilder.toString(), reasoningStringBuilder.toString()));
|
||||||
return chatCompletionResponse;
|
return chatCompletionResponse;
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
.retryWhen(this.createRetrySpec())
|
|
||||||
.block();
|
|
||||||
|
|
||||||
if (response.getChoices() == null || response.getChoices().isEmpty() || response.getChoices().getFirst().getMessage() == null) {
|
if (response.getChoices() == null || response.getChoices().isEmpty() || response.getChoices().getFirst().getMessage() == null) {
|
||||||
throw new IllegalStateException("No parseable response content from server");
|
throw new IllegalStateException("No parseable response content from server");
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package de.extio.lmlib.client.oai.completion.chat;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.*;
|
||||||
|
|
||||||
import de.extio.lmlib.client.oai.completion.AbstractCompletionRequest;
|
import de.extio.lmlib.client.oai.completion.AbstractCompletionRequest;
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package de.extio.lmlib.client.oai.completion.chat;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.*;
|
||||||
|
|
||||||
import de.extio.lmlib.client.oai.completion.AbstractCompletionResponse;
|
import de.extio.lmlib.client.oai.completion.AbstractCompletionResponse;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.extio.lmlib.client.oai.completion.chat;
|
package de.extio.lmlib.client.oai.completion.chat;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.*;
|
||||||
|
|
||||||
final class ChatMessage {
|
final class ChatMessage {
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.extio.lmlib.client.oai.completion.text;
|
package de.extio.lmlib.client.oai.completion.text;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.*;
|
||||||
|
|
||||||
final class Choice {
|
final class Choice {
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.extio.lmlib.client.oai.completion.text;
|
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;
|
import de.extio.lmlib.client.oai.completion.AbstractCompletionRequest;
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package de.extio.lmlib.client.oai.completion.text;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.*;
|
||||||
|
|
||||||
import de.extio.lmlib.client.oai.completion.AbstractCompletionResponse;
|
import de.extio.lmlib.client.oai.completion.AbstractCompletionResponse;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package de.extio.lmlib.client.oai.completion.text;
|
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.time.LocalDateTime;
|
||||||
import java.util.ArrayDeque;
|
import java.util.ArrayDeque;
|
||||||
import java.util.Deque;
|
import java.util.Deque;
|
||||||
@@ -11,8 +14,9 @@ import org.slf4j.LoggerFactory;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.stereotype.Component;
|
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.Chunk;
|
||||||
import de.extio.lmlib.client.Completion;
|
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.profile.ModelProfile.ModelProvider;
|
||||||
import de.extio.lmlib.prompt.PromptStrategy;
|
import de.extio.lmlib.prompt.PromptStrategy;
|
||||||
import de.extio.lmlib.prompt.PromptStrategyFactory;
|
import de.extio.lmlib.prompt.PromptStrategyFactory;
|
||||||
import reactor.core.publisher.Mono;
|
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class TextCompletionClient extends AbstractCompletionClient {
|
public class TextCompletionClient extends AbstractCompletionClient {
|
||||||
@@ -65,68 +68,78 @@ public class TextCompletionClient extends AbstractCompletionClient {
|
|||||||
try {
|
try {
|
||||||
requestBody = this.objectMapper.writeValueAsString(request);
|
requestBody = this.objectMapper.writeValueAsString(request);
|
||||||
}
|
}
|
||||||
catch (final JsonProcessingException e) {
|
catch (final JacksonException e) {
|
||||||
throw new IllegalStateException("Cannot serialize request body", e);
|
throw new IllegalStateException("Cannot serialize request body", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
LOGGER.debug("Requesting completion at {}", modelProfile.url());
|
LOGGER.debug("Requesting completion at {}", modelProfile.url());
|
||||||
final LocalDateTime start = LocalDateTime.now();
|
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)
|
.method(HttpMethod.POST)
|
||||||
.uri(uriBuilder -> uriBuilder.path("/v1/completions").build())
|
.uri("/v1/completions")
|
||||||
.header("Content-Type", "application/json");
|
.header("Content-Type", "application/json");
|
||||||
if (modelProfile.apiKey() != null && !modelProfile.apiKey().isBlank()) {
|
if (modelProfile.apiKey() != null && !modelProfile.apiKey().isBlank()) {
|
||||||
requestSpec = requestSpec.header("Authorization", "Bearer " + modelProfile.apiKey());
|
requestSpec = requestSpec.header("Authorization", "Bearer " + modelProfile.apiKey());
|
||||||
}
|
}
|
||||||
final var response = requestSpec
|
final var response = requestSpec
|
||||||
.bodyValue(requestBody)
|
.body(requestBody)
|
||||||
.exchangeToMono(clientResponse -> {
|
.exchange((clientRequest, clientResponse) -> {
|
||||||
if (clientResponse.statusCode().isError()) {
|
if (clientResponse.getStatusCode().isError()) {
|
||||||
return clientResponse.bodyToMono(String.class)
|
throw new IllegalStateException("Error response from server: " + clientResponse.getStatusCode());
|
||||||
.flatMap(errorBody -> Mono.error(new IllegalStateException("Error response from server: " + clientResponse.statusCode() + " - " + errorBody)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chunkConsumer == null) {
|
if (chunkConsumer == null) {
|
||||||
return clientResponse.bodyToMono(CompletionResponse.class);
|
return this.objectMapper.readValue(clientResponse.getBody(), CompletionResponse.class);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
final var CompletionResponse = new CompletionResponse();
|
final var completionResponse = new CompletionResponse();
|
||||||
CompletionResponse.setChoices(List.of(new Choice()));
|
completionResponse.setChoices(List.of(new Choice()));
|
||||||
final StringBuilder contentStringBuilder = new StringBuilder();
|
final StringBuilder contentStringBuilder = new StringBuilder();
|
||||||
return clientResponse.bodyToFlux(String.class)
|
|
||||||
.doOnNext(buffer -> {
|
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 {
|
try {
|
||||||
if (!"[DONE]".equals(buffer)) {
|
final var streamResponse = this.objectMapper.readValue(line, CompletionResponse.class);
|
||||||
final var streamResponse = this.objectMapper.readValue(buffer, CompletionResponse.class);
|
|
||||||
|
|
||||||
if (streamResponse.getId() != null && !streamResponse.getId().isEmpty()) {
|
if (streamResponse.getId() != null && !streamResponse.getId().isEmpty()) {
|
||||||
CompletionResponse.setId(streamResponse.getId());
|
completionResponse.setId(streamResponse.getId());
|
||||||
}
|
}
|
||||||
if (streamResponse.getObject() != null && !streamResponse.getObject().isEmpty()) {
|
if (streamResponse.getObject() != null && !streamResponse.getObject().isEmpty()) {
|
||||||
CompletionResponse.setObject(streamResponse.getObject());
|
completionResponse.setObject(streamResponse.getObject());
|
||||||
}
|
}
|
||||||
if (streamResponse.getCreated() != null) {
|
if (streamResponse.getCreated() != null) {
|
||||||
CompletionResponse.setCreated(streamResponse.getCreated());
|
completionResponse.setCreated(streamResponse.getCreated());
|
||||||
}
|
}
|
||||||
if (streamResponse.isStoppedEos() != null) {
|
if (streamResponse.isStoppedEos() != null) {
|
||||||
CompletionResponse.setStoppedEos(streamResponse.isStoppedEos());
|
completionResponse.setStoppedEos(streamResponse.isStoppedEos());
|
||||||
}
|
}
|
||||||
if (streamResponse.getContent() != null && !streamResponse.getContent().isEmpty()) {
|
if (streamResponse.getContent() != null && !streamResponse.getContent().isEmpty()) {
|
||||||
contentStringBuilder.append(streamResponse.getContent());
|
contentStringBuilder.append(streamResponse.getContent());
|
||||||
}
|
}
|
||||||
if (streamResponse.getUsage() != null) {
|
if (streamResponse.getUsage() != null) {
|
||||||
CompletionResponse.setUsage(streamResponse.getUsage());
|
completionResponse.setUsage(streamResponse.getUsage());
|
||||||
}
|
}
|
||||||
if (streamResponse.getTimings() != null) {
|
if (streamResponse.getTimings() != null) {
|
||||||
CompletionResponse.setTimings(streamResponse.getTimings());
|
completionResponse.setTimings(streamResponse.getTimings());
|
||||||
}
|
}
|
||||||
if (streamResponse.getChoices() != null && !streamResponse.getChoices().isEmpty()) {
|
if (streamResponse.getChoices() != null && !streamResponse.getChoices().isEmpty()) {
|
||||||
if (streamResponse.getChoices().getFirst().getFinishReason() != null) {
|
if (streamResponse.getChoices().getFirst().getFinishReason() != null) {
|
||||||
CompletionResponse.getChoices().getFirst().setFinishReason(streamResponse.getChoices().getFirst().getFinishReason());
|
completionResponse.getChoices().getFirst().setFinishReason(streamResponse.getChoices().getFirst().getFinishReason());
|
||||||
}
|
}
|
||||||
if (streamResponse.getChoices().getFirst().getScore() != null) {
|
if (streamResponse.getChoices().getFirst().getScore() != null) {
|
||||||
CompletionResponse.getChoices().getFirst().setScore(streamResponse.getChoices().getFirst().getScore());
|
completionResponse.getChoices().getFirst().setScore(streamResponse.getChoices().getFirst().getScore());
|
||||||
}
|
}
|
||||||
if (streamResponse.getChoices().getFirst().getText() != null && !streamResponse.getChoices().getFirst().getText().isEmpty()) {
|
if (streamResponse.getChoices().getFirst().getText() != null && !streamResponse.getChoices().getFirst().getText().isEmpty()) {
|
||||||
contentStringBuilder.append(streamResponse.getChoices().getFirst().getText());
|
contentStringBuilder.append(streamResponse.getChoices().getFirst().getText());
|
||||||
@@ -134,19 +147,15 @@ public class TextCompletionClient extends AbstractCompletionClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (final JacksonException e) {
|
||||||
|
LOGGER.warn("Streamed chunk is not parseable: {}", line, e);
|
||||||
}
|
}
|
||||||
catch (final JsonProcessingException e) {
|
|
||||||
LOGGER.warn("Streamed chunk is not parseable", e);
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.then(Mono.<CompletionResponse> fromSupplier(() -> {
|
|
||||||
CompletionResponse.getChoices().getFirst().setText(contentStringBuilder.toString());
|
|
||||||
return CompletionResponse;
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
})
|
completionResponse.getChoices().getFirst().setText(contentStringBuilder.toString());
|
||||||
.retryWhen(this.createRetrySpec())
|
return completionResponse;
|
||||||
.block();
|
}
|
||||||
|
});
|
||||||
|
|
||||||
String content = null;
|
String content = null;
|
||||||
String reasoning = null;
|
String reasoning = null;
|
||||||
|
|||||||
@@ -6,36 +6,31 @@ import java.util.List;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.web.reactive.function.client.WebClient;
|
import org.springframework.web.client.RestClient;
|
||||||
import org.springframework.web.reactive.function.client.WebClient.Builder;
|
|
||||||
|
|
||||||
import de.extio.lmlib.profile.ModelProfile;
|
import de.extio.lmlib.profile.ModelProfile;
|
||||||
|
|
||||||
import reactor.util.retry.Retry;
|
|
||||||
|
|
||||||
final class LlamaServerTokenizer implements Tokenizer {
|
final class LlamaServerTokenizer implements Tokenizer {
|
||||||
|
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger(LlamaServerTokenizer.class);
|
private static final Logger LOGGER = LoggerFactory.getLogger(LlamaServerTokenizer.class);
|
||||||
|
|
||||||
private final WebClient.Builder webClientBuilder;
|
private final RestClient.Builder restClientBuilder;
|
||||||
|
|
||||||
public LlamaServerTokenizer(final Builder webClientBuilder) {
|
public LlamaServerTokenizer(final RestClient.Builder restClientBuilder) {
|
||||||
this.webClientBuilder = webClientBuilder;
|
this.restClientBuilder = restClientBuilder;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Long> tokenize(final String txt, final ModelProfile modelProfile) {
|
public List<Long> tokenize(final String txt, final ModelProfile modelProfile) {
|
||||||
LOGGER.debug("Requesting tokenizing");
|
LOGGER.debug("Requesting tokenizing");
|
||||||
final var request = new TokenizeRequest(txt);
|
final var request = new TokenizeRequest(txt);
|
||||||
final var webClient = this.webClientBuilder.baseUrl(modelProfile.url()).build();
|
final var restClient = this.restClientBuilder.baseUrl(modelProfile.url()).build();
|
||||||
final var response = webClient
|
final var response = restClient
|
||||||
.method(HttpMethod.POST)
|
.method(HttpMethod.POST)
|
||||||
.uri(uriBuilder -> uriBuilder.path("/tokenize").build())
|
.uri("/tokenize")
|
||||||
.bodyValue(request)
|
.body(request)
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.bodyToMono(TokenizeResponse.class)
|
.body(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();
|
|
||||||
return response.tokens();
|
return response.tokens();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,15 +43,13 @@ final class LlamaServerTokenizer implements Tokenizer {
|
|||||||
public String detokenize(final List<Long> tokens, final ModelProfile modelProfile) {
|
public String detokenize(final List<Long> tokens, final ModelProfile modelProfile) {
|
||||||
LOGGER.debug("Requesting detokenizing");
|
LOGGER.debug("Requesting detokenizing");
|
||||||
final var request = new DetokenizeRequest(tokens);
|
final var request = new DetokenizeRequest(tokens);
|
||||||
final var webClient = this.webClientBuilder.baseUrl(modelProfile.url()).build();
|
final var restClient = this.restClientBuilder.baseUrl(modelProfile.url()).build();
|
||||||
final var response = webClient
|
final var response = restClient
|
||||||
.method(HttpMethod.POST)
|
.method(HttpMethod.POST)
|
||||||
.uri(uriBuilder -> uriBuilder.path("/detokenize").build())
|
.uri("/detokenize")
|
||||||
.bodyValue(request)
|
.body(request)
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.bodyToMono(DetokenizeResponse.class)
|
.body(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();
|
|
||||||
return response.content();
|
return response.content();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
|
|||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.web.reactive.function.client.WebClient;
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
@AutoConfiguration
|
@AutoConfiguration
|
||||||
public class TokenizerAutoConfiguration {
|
public class TokenizerAutoConfiguration {
|
||||||
@@ -15,8 +15,8 @@ public class TokenizerAutoConfiguration {
|
|||||||
@Bean
|
@Bean
|
||||||
@ConditionalOnProperty(name = "tokenizer.strategy", havingValue = "llamaServer")
|
@ConditionalOnProperty(name = "tokenizer.strategy", havingValue = "llamaServer")
|
||||||
@ConditionalOnMissingBean(type = "de.extio.lmlib.token.Tokenizer")
|
@ConditionalOnMissingBean(type = "de.extio.lmlib.token.Tokenizer")
|
||||||
Tokenizer llamaServerTokenizer(@Qualifier("lmLibWebClientBuilder") final WebClient.Builder webClientBuilder) {
|
Tokenizer llamaServerTokenizer(@Qualifier("lmLibRestClientBuilder") final RestClient.Builder restClientBuilder) {
|
||||||
return new LlamaServerTokenizer(webClientBuilder);
|
return new LlamaServerTokenizer(restClientBuilder);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Configuration(proxyBeanMethods = false)
|
@Configuration(proxyBeanMethods = false)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import java.nio.file.Files;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Disabled;
|
import org.junit.jupiter.api.Disabled;
|
||||||
@@ -26,7 +27,6 @@ import de.extio.lmlib.agent.responsehandler.TextAgentResponseHandler;
|
|||||||
import de.extio.lmlib.client.ClientService;
|
import de.extio.lmlib.client.ClientService;
|
||||||
import de.extio.lmlib.grader.Grader;
|
import de.extio.lmlib.grader.Grader;
|
||||||
import de.extio.lmlib.profile.ModelCategory;
|
import de.extio.lmlib.profile.ModelCategory;
|
||||||
import io.netty.util.internal.ThreadLocalRandom;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* These tests have been verified with Llama 3.1 8B, Gemma2 27B, Gemma3 27B and GPT-OSS models
|
* These tests have been verified with Llama 3.1 8B, Gemma2 27B, Gemma3 27B and GPT-OSS models
|
||||||
|
|||||||
@@ -107,8 +107,13 @@ public class CachedClientTest {
|
|||||||
|
|
||||||
final var sb = new java.lang.StringBuilder();
|
final var sb = new java.lang.StringBuilder();
|
||||||
var completion = this.clientService.getClient(ModelCategory.MEDIUM).streamConversation(ModelCategory.MEDIUM, conversation, chunk -> {
|
var completion = this.clientService.getClient(ModelCategory.MEDIUM).streamConversation(ModelCategory.MEDIUM, conversation, chunk -> {
|
||||||
sb.append(chunk);
|
if (chunk.reasoningContent() != null) {
|
||||||
System.out.print(chunk);
|
System.out.print(chunk.reasoningContent());
|
||||||
|
}
|
||||||
|
if (chunk.content() != null) {
|
||||||
|
sb.append(chunk.content());
|
||||||
|
System.out.print(chunk.content());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
assertFalse(completion.statistics().cached());
|
assertFalse(completion.statistics().cached());
|
||||||
assertEquals(1, completion.statistics().requests());
|
assertEquals(1, completion.statistics().requests());
|
||||||
@@ -120,8 +125,13 @@ public class CachedClientTest {
|
|||||||
|
|
||||||
final var sb2 = new java.lang.StringBuilder();
|
final var sb2 = new java.lang.StringBuilder();
|
||||||
completion = this.clientService.getClient(ModelCategory.MEDIUM).streamConversation(ModelCategory.MEDIUM, conversation, chunk -> {
|
completion = this.clientService.getClient(ModelCategory.MEDIUM).streamConversation(ModelCategory.MEDIUM, conversation, chunk -> {
|
||||||
sb2.append(chunk);
|
if (chunk.reasoningContent() != null) {
|
||||||
System.out.print(chunk);
|
System.out.print(chunk.reasoningContent());
|
||||||
|
}
|
||||||
|
if (chunk.content() != null) {
|
||||||
|
sb2.append(chunk.content());
|
||||||
|
System.out.print(chunk.content());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
assertTrue(completion.statistics().cached());
|
assertTrue(completion.statistics().cached());
|
||||||
assertEquals(sb.toString(), sb2.toString());
|
assertEquals(sb.toString(), sb2.toString());
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import org.springframework.boot.test.context.SpringBootTest;
|
|||||||
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
|
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
|
||||||
import org.springframework.context.annotation.ComponentScan;
|
import org.springframework.context.annotation.ComponentScan;
|
||||||
import org.springframework.context.annotation.PropertySource;
|
import org.springframework.context.annotation.PropertySource;
|
||||||
import org.springframework.web.reactive.function.client.WebClient;
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
import de.extio.lmlib.profile.ModelCategory;
|
import de.extio.lmlib.profile.ModelCategory;
|
||||||
import de.extio.lmlib.profile.ModelProfileService;
|
import de.extio.lmlib.profile.ModelProfileService;
|
||||||
@@ -28,8 +28,8 @@ public class TokenizerTest {
|
|||||||
private ModelProfileService modelProfileService;
|
private ModelProfileService modelProfileService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
@Qualifier("lmLibWebClientBuilder")
|
@Qualifier("lmLibRestClientBuilder")
|
||||||
private WebClient.Builder webClientBuilder;
|
private RestClient.Builder restClientBuilder;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void jTokkit() throws Exception {
|
void jTokkit() throws Exception {
|
||||||
@@ -48,7 +48,7 @@ public class TokenizerTest {
|
|||||||
@Disabled("Requires llama.cpp model profile")
|
@Disabled("Requires llama.cpp model profile")
|
||||||
@Test
|
@Test
|
||||||
void llamaServer() throws Exception {
|
void llamaServer() throws Exception {
|
||||||
final var tokenizer = new LlamaServerTokenizer(this.webClientBuilder);
|
final var tokenizer = new LlamaServerTokenizer(this.restClientBuilder);
|
||||||
final var profile = this.modelProfileService.getModelProfile(ModelCategory.MEDIUM.getModelProfile());
|
final var profile = this.modelProfileService.getModelProfile(ModelCategory.MEDIUM.getModelProfile());
|
||||||
|
|
||||||
assertEquals(21, tokenizer.count(TEXT, profile));
|
assertEquals(21, tokenizer.count(TEXT, profile));
|
||||||
|
|||||||
@@ -215,8 +215,6 @@ public class TextUtilsTest {
|
|||||||
"'This is a test with special characters', 'test{2}', false",
|
"'This is a test with special characters', 'test{2}', false",
|
||||||
"'This is a test with special characters', 'test[abc]', false",
|
"'This is a test with special characters', 'test[abc]', false",
|
||||||
|
|
||||||
"'bean:exchangeSenderProducer?method=sendToNamedConnection(${exchange}, \\'HttpsOutbound\\')', 'exchangeSenderProducer', true",
|
|
||||||
|
|
||||||
"'Service.process(${param}, [1,2,3])', 'Service', true",
|
"'Service.process(${param}, [1,2,3])', 'Service', true",
|
||||||
"'Service.process(${param}, [1,2,3])', 'process', true",
|
"'Service.process(${param}, [1,2,3])', 'process', true",
|
||||||
"'Service.process(${param}, [1,2,3])', 'param', true",
|
"'Service.process(${param}, [1,2,3])', 'param', true",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
category=TEXT
|
category=TEXT
|
||||||
prompts=gemma
|
prompts=gpt-oss
|
||||||
tokenEncoding=cl100k_base
|
tokenEncoding=cl100k_base
|
||||||
maxTokens=2500
|
maxTokens=2500
|
||||||
maxContextLength=16000
|
maxContextLength=16000
|
||||||
|
|||||||
Reference in New Issue
Block a user