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

@@ -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
View File

@@ -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>

View File

@@ -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);
} }
} }

View File

@@ -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());
} }

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 java.util.List;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.*;
public final class Model { public final class Model {

View File

@@ -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)

View File

@@ -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("");
}); });
} }

View File

@@ -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 {

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.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();

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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");

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();
} }

View File

@@ -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)

View File

@@ -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

View File

@@ -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());

View File

@@ -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));

View File

@@ -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",

View File

@@ -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