From 94894aee85c379713f0042d7983f8a20966c5849 Mon Sep 17 00:00:00 2001 From: extio-de Date: Fri, 27 Jun 2025 16:36:46 +0200 Subject: [PATCH] Merged with tp lmlib --- README.md | 16 +-- .../lmlib/client/WebClientConfiguration.java | 2 +- .../lmlib/client/azureai/AzureAiClient.java | 130 +++++++++++++++--- .../AzureAiClientAutoConfiguration.java | 20 ++- .../java/de/extio/lmlib/agent/AgentTest.java | 2 + .../client/azureai/AzureAiClientTest.java | 4 +- .../lmlib/client/cached/CachedClientTest.java | 106 ++++++++++++++ .../cached/TestCachedClientRepository.java | 27 ++++ .../TextCompletionClientTest.java | 1 + .../de/extio/lmlib/token/TokenizerTest.java | 4 +- 10 files changed, 279 insertions(+), 33 deletions(-) mode change 100644 => 100755 src/main/java/de/extio/lmlib/client/azureai/AzureAiClientAutoConfiguration.java create mode 100755 src/test/java/de/extio/lmlib/client/cached/CachedClientTest.java create mode 100755 src/test/java/de/extio/lmlib/client/cached/TestCachedClientRepository.java diff --git a/README.md b/README.md index a17e778..077834e 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,13 @@ The program is a comprehensive conversational AI system that provides natural la The program consists of several packages that work together to achieve this goal. These packages include: -1. **Caching layer (com.transporeon.exchange.lmlib.client.cached)**: Improves performance by reducing requests to the underlying client through caching and retrieving completion responses. -2. **Natural Language Processing (com.transporeon.exchange.lmlib.client)**: Provides conversation management and text completion functionality, generating completions, processing conversations, and managing models. -3. **Azure AI Integration (com.transporeon.exchange.lmlib.client.azureai)**: Integrates with Azure AI services, managing model profiles and handling conversation completions. -4. **Text Completion (com.transporeon.exchange.lmlib.client.textcompletion)**: Provides text completion functionality using AI models, including model selection, prompt generation, and response handling. -5. **Text Tokenization (com.transporeon.exchange.lmlib.token)**: Tokenizes text into numerical tokens and detokenizes numerical tokens back into human-readable text. -6. **Conversation Prompts (com.transporeon.exchange.lmlib.prompt)**: Manages conversation prompts, creating new prompts, continuing conversations, and removing end-of-text markers. -7. **Agent Executor (com.transporeon.exchange.lmlib.agent)**: Executes agent graphs concurrently, handles agent responses, and tracks request statistics. +1. **Caching layer (.lmlib.client.cached)**: Improves performance by reducing requests to the underlying client through caching and retrieving completion responses. +2. **Natural Language Processing (.lmlib.client)**: Provides conversation management and text completion functionality, generating completions, processing conversations, and managing models. +3. **Azure AI Integration (.lmlib.client.azureai)**: Integrates with Azure AI services, managing model profiles and handling conversation completions. +4. **Text Completion (.lmlib.client.textcompletion)**: Provides text completion functionality using AI models, including model selection, prompt generation, and response handling. +5. **Text Tokenization (.lmlib.token)**: Tokenizes text into numerical tokens and detokenizes numerical tokens back into human-readable text. +6. **Conversation Prompts (.lmlib.prompt)**: Manages conversation prompts, creating new prompts, continuing conversations, and removing end-of-text markers. +7. **Agent Executor (.lmlib.agent)**: Executes agent graphs concurrently, handles agent responses, and tracks request statistics. The program's key features include: @@ -88,7 +88,7 @@ If you use jtokkit tokenizer: jtokkit -It is required to select the right prompt template. See package com.transporeon.exchange.lmlib.prompt for available prompt templates or implement your own PromptStrategy (must be a bean in the spring context). +It is required to select the right prompt template. See package .lmlib.prompt for available prompt templates or implement your own PromptStrategy (must be a bean in the spring context). Set the following property in your model profile: diff --git a/src/main/java/de/extio/lmlib/client/WebClientConfiguration.java b/src/main/java/de/extio/lmlib/client/WebClientConfiguration.java index 47796cc..84835d5 100755 --- a/src/main/java/de/extio/lmlib/client/WebClientConfiguration.java +++ b/src/main/java/de/extio/lmlib/client/WebClientConfiguration.java @@ -19,7 +19,7 @@ import reactor.netty.http.client.HttpClient; @Configuration public class WebClientConfiguration { - private static final int TIMEOUT = 900000; + private static final int TIMEOUT = 300000; private static final int CONNECT_TIMEOUT = 10000; diff --git a/src/main/java/de/extio/lmlib/client/azureai/AzureAiClient.java b/src/main/java/de/extio/lmlib/client/azureai/AzureAiClient.java index fa62d07..35ee50e 100755 --- a/src/main/java/de/extio/lmlib/client/azureai/AzureAiClient.java +++ b/src/main/java/de/extio/lmlib/client/azureai/AzureAiClient.java @@ -1,6 +1,8 @@ package de.extio.lmlib.client.azureai; import java.math.BigDecimal; +import java.net.InetSocketAddress; +import java.net.Proxy; import java.time.Duration; import java.time.LocalDateTime; import java.util.ArrayDeque; @@ -26,6 +28,8 @@ import com.azure.ai.inference.models.ChatRequestSystemMessage; import com.azure.ai.inference.models.ChatRequestUserMessage; import com.azure.ai.inference.models.CompletionsFinishReason; import com.azure.core.credential.AzureKeyCredential; +import com.azure.core.http.HttpClient; +import com.azure.core.http.okhttp.OkHttpAsyncHttpClientBuilder; import com.azure.core.http.policy.ExponentialBackoffOptions; import com.azure.core.http.policy.RetryOptions; @@ -35,23 +39,49 @@ import de.extio.lmlib.client.Conversation; import de.extio.lmlib.client.Conversation.Turn; import de.extio.lmlib.profile.ModelCategory; import de.extio.lmlib.profile.ModelProfile; -import de.extio.lmlib.profile.ModelProfileService; import de.extio.lmlib.profile.ModelProfile.ModelProvider; +import de.extio.lmlib.profile.ModelProfileService; import de.extio.lmlib.token.Tokenizer; +import okhttp3.Authenticator; +import okhttp3.Challenge; +import okhttp3.Credentials; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.Route; + public final class AzureAiClient implements Client { private static final Logger LOGGER = LoggerFactory.getLogger(AzureAiClient.class); + private static final int TIMEOUT = 300000; + private static final int CONNECT_TIMEOUT = 10000; + private final ConcurrentMap chatCompletionsClients = new ConcurrentHashMap<>(); private final ModelProfileService modelProfileService; private final Tokenizer tokenizer; - AzureAiClient(final ModelProfileService modelProfileService, final Tokenizer tokenizer) { + private final boolean proxyEnabled; + + private final String proxyHost; + + private final int proxyPort; + + private final String proxyUser; + + private final String proxyPassword; + + AzureAiClient(final ModelProfileService modelProfileService, final Tokenizer tokenizer, final boolean proxyEnabled, final String proxyHost, final int proxyPort, final String proxyUser, final String proxyPassword) { this.modelProfileService = modelProfileService; this.tokenizer = tokenizer; + this.proxyEnabled = proxyEnabled; + this.proxyHost = proxyHost; + this.proxyPort = proxyPort; + this.proxyUser = proxyUser; + this.proxyPassword = proxyPassword; } @Override @@ -98,7 +128,7 @@ public final class AzureAiClient implements Client { answers.add(answer); } - // TODO: Reactivate when multiple (split) prompts have been implemented again + // TODO: Reactivate when multiple (split) prompts have been implemented again in #addChatRequestMessage // if (answers.size() > 1) { // this.summarize(answers, modelProfile, promptStrategy, statistics); // } @@ -178,7 +208,8 @@ public final class AzureAiClient implements Client { tokenized = tokenized.subList(0, tokenized.size() - (tokens.get() - maxLength)); prompt = this.tokenizer.detokenize(tokenized, modelProfile); trimmed = true; - } + } + chat.add(messageFactory.apply(prompt)); return trimmed; @@ -211,21 +242,8 @@ public final class AzureAiClient implements Client { private ChatCompletions requestCompletions(final List chat, final ModelCategory modelCategory, final ModelProfile modelProfile) { LOGGER.debug("Requesting ChatCompletion at {}", modelProfile.modelName()); - final var client = this.chatCompletionsClients.computeIfAbsent(modelCategory, key -> { - final ExponentialBackoffOptions exponentialOptions = new ExponentialBackoffOptions() - .setMaxRetries(99) - .setBaseDelay(Duration.ofSeconds(1)) - .setMaxDelay(Duration.ofSeconds(10)); - final RetryOptions retryOptions = new RetryOptions(exponentialOptions); - - final ChatCompletionsClient chatCompletionsClient = new ChatCompletionsClientBuilder() - .credential(new AzureKeyCredential(modelProfile.apiKey())) - .endpoint(modelProfile.url()) - .retryOptions(retryOptions) - .buildClient(); - return chatCompletionsClient; - }); - + final var client = this.createHttpClient(modelCategory, modelProfile); + final var options = new ChatCompletionsOptions(chat); options.setModel(modelProfile.modelName()); options.setMaxTokens(modelProfile.maxTokens()); @@ -235,6 +253,80 @@ public final class AzureAiClient implements Client { return client.complete(options); } + private ChatCompletionsClient createHttpClient(final ModelCategory modelCategory, final ModelProfile modelProfile) { + return this.chatCompletionsClients.computeIfAbsent(modelCategory, key -> { + final ExponentialBackoffOptions exponentialOptions = new ExponentialBackoffOptions() + .setMaxRetries(99) + .setBaseDelay(Duration.ofSeconds(1)) + .setMaxDelay(Duration.ofSeconds(10)); + + final RetryOptions retryOptions = new RetryOptions(exponentialOptions) + .setShouldRetryCondition(httpResponse -> { + if (httpResponse != null) { + if (httpResponse.getThrowable() != null) { + return true; + } + if (httpResponse.getResponse() != null) { + return this.shouldRetryError(httpResponse.getResponse().getStatusCode()); + } + } + return false; + }); + + final OkHttpClient.Builder nativeOkHttpClientBuilder = new OkHttpClient.Builder(); + + if (this.proxyEnabled) { + LOGGER.info("Proxy is enabled. Configuring proxy settings for host: {} port: {} user: {}", this.proxyHost, this.proxyPort, this.proxyUser); + + final Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(this.proxyHost, this.proxyPort)); + nativeOkHttpClientBuilder.proxy(proxy); + + if (this.proxyUser != null && this.proxyPassword != null) { + final Authenticator proxyAuthenticator = new Authenticator() { + @Override + public Request authenticate(final Route route, final Response response) { + for (final Challenge challenge : response.challenges()) { + if (challenge.scheme().equalsIgnoreCase("OkHttp-Preemptive")) { + final String credential = Credentials.basic(proxyUser, proxyPassword); + return response.request().newBuilder() + .header("Proxy-Authorization", credential) + .build(); + } + } + return null; + } + }; + nativeOkHttpClientBuilder.proxyAuthenticator(proxyAuthenticator); + } else { + LOGGER.warn("Proxy user or password is not set. Skipping proxy authentication."); + } + } else { + LOGGER.info("Proxy is not enabled. Skipping proxy configuration."); + } + + final OkHttpClient nativeOkHttpClient = nativeOkHttpClientBuilder.build(); + final OkHttpAsyncHttpClientBuilder okHttpClientBuilder = new OkHttpAsyncHttpClientBuilder(nativeOkHttpClient); + final HttpClient okHttpClient = okHttpClientBuilder + .connectionTimeout(Duration.ofMillis(CONNECT_TIMEOUT)) + .readTimeout(Duration.ofMillis(TIMEOUT)) + .responseTimeout(Duration.ofMillis(TIMEOUT)) + .writeTimeout(Duration.ofMillis(TIMEOUT)) + .build(); + + final ChatCompletionsClient chatCompletionsClient = new ChatCompletionsClientBuilder() + .credential(new AzureKeyCredential(modelProfile.apiKey())) + .endpoint(modelProfile.url()) + .retryOptions(retryOptions) + .httpClient(okHttpClient) + .buildClient(); + return chatCompletionsClient; + }); + } + + private boolean shouldRetryError(final int statusCode) { + return statusCode == 408 || statusCode == 429 || (statusCode >= 500 && statusCode < 600); + } + static class CompletionStatistics { int requests; diff --git a/src/main/java/de/extio/lmlib/client/azureai/AzureAiClientAutoConfiguration.java b/src/main/java/de/extio/lmlib/client/azureai/AzureAiClientAutoConfiguration.java old mode 100644 new mode 100755 index 737856a..6399c8f --- a/src/main/java/de/extio/lmlib/client/azureai/AzureAiClientAutoConfiguration.java +++ b/src/main/java/de/extio/lmlib/client/azureai/AzureAiClientAutoConfiguration.java @@ -1,5 +1,6 @@ package de.extio.lmlib.client.azureai; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.context.annotation.Bean; @@ -8,12 +9,27 @@ import de.extio.lmlib.profile.ModelProfileService; import de.extio.lmlib.token.Tokenizer; @AutoConfiguration -@ConditionalOnClass(name = "com.azure.ai.inference.ChatCompletionsClient") +@ConditionalOnClass(name = {"com.azure.ai.inference.ChatCompletionsClient", "okhttp3.OkHttpClient" }) public class AzureAiClientAutoConfiguration { + @Value("${extproxy.enabled:false}") + private boolean proxyEnabled; + + @Value("${extproxy.host:}") + private String proxyHost; + + @Value("${extproxy.port:}") + private int proxyPort; + + @Value("${extproxy.username:}") + private String proxyUser; + + @Value("${extproxy.password:}") + private String proxyPassword; + @Bean AzureAiClient azureAiClient(final ModelProfileService modelProfileService, final Tokenizer tokenizer) { - return new AzureAiClient(modelProfileService, tokenizer); + return new AzureAiClient(modelProfileService, tokenizer, this.proxyEnabled, this.proxyHost, this.proxyPort, this.proxyUser, this.proxyPassword); } } diff --git a/src/test/java/de/extio/lmlib/agent/AgentTest.java b/src/test/java/de/extio/lmlib/agent/AgentTest.java index d603c65..0afcafa 100644 --- a/src/test/java/de/extio/lmlib/agent/AgentTest.java +++ b/src/test/java/de/extio/lmlib/agent/AgentTest.java @@ -9,6 +9,7 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -28,6 +29,7 @@ import io.netty.util.internal.ThreadLocalRandom; /** * These tests have been verified with Llama 3.1 8B and Gemma2 27B models */ +@Disabled("This test requires a running Llama server or an Azure subscription (setup azure key in model profile)") @SpringBootTest(webEnvironment = WebEnvironment.NONE) @SpringBootConfiguration @ComponentScan(basePackages = "de.extio.lmlib") diff --git a/src/test/java/de/extio/lmlib/client/azureai/AzureAiClientTest.java b/src/test/java/de/extio/lmlib/client/azureai/AzureAiClientTest.java index 9eeae03..ccb54c4 100755 --- a/src/test/java/de/extio/lmlib/client/azureai/AzureAiClientTest.java +++ b/src/test/java/de/extio/lmlib/client/azureai/AzureAiClientTest.java @@ -16,7 +16,7 @@ import de.extio.lmlib.client.Conversation.Turn; import de.extio.lmlib.client.Conversation.TurnType; import de.extio.lmlib.profile.ModelCategory; -@Disabled +@Disabled("This test requires an Azure subscription (setup azure key in model profile)") @SpringBootTest(webEnvironment = WebEnvironment.NONE) @SpringBootConfiguration @ComponentScan(basePackages = "de.extio.lmlib") @@ -35,7 +35,7 @@ public class AzureAiClientTest { "You are a clever story teller", "Continue this story:", "Once upon a time, there was a spaceship travelling to a black hole. The crew were cats!"); - LOGGER.info(completion.response()); + LOGGER.info(completion.toString()); } @Test diff --git a/src/test/java/de/extio/lmlib/client/cached/CachedClientTest.java b/src/test/java/de/extio/lmlib/client/cached/CachedClientTest.java new file mode 100755 index 0000000..06d8b3f --- /dev/null +++ b/src/test/java/de/extio/lmlib/client/cached/CachedClientTest.java @@ -0,0 +1,106 @@ +package de.extio.lmlib.client.cached; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.PropertySource; + +import de.extio.lmlib.client.ClientService; +import de.extio.lmlib.client.Conversation; +import de.extio.lmlib.client.Conversation.Turn; +import de.extio.lmlib.client.Conversation.TurnType; +import de.extio.lmlib.profile.ModelCategory; + +@Disabled("This test requires a running Llama server or an Azure subscription (setup azure key in model profile)") +@SpringBootTest(webEnvironment = WebEnvironment.NONE) +@SpringBootConfiguration +@ComponentScan(basePackages = "de.extio.lmlib") +@PropertySource("classpath:/application-test.properties") +public class CachedClientTest { + + private static final Logger LOGGER = LoggerFactory.getLogger(CachedClientTest.class); + + @Autowired + private TestCachedClientRepository cachedClientRepository; + + @Autowired + private ClientService clientService; + + @AfterEach + void afterEach() { + this.cachedClientRepository.reset(); + } + + @Test + void cacheCompletion() throws Exception { + var completion = this.clientService.getClient(ModelCategory.MEDIUM).completion( + ModelCategory.MEDIUM, + "You are a funny person", + "Tell me a cat joke", + ""); + LOGGER.info(completion.toString()); + assertFalse(completion.cached()); + assertEquals(1, completion.requests()); + final var firstResponse = completion.response(); + assertFalse(firstResponse.isBlank()); + + completion = this.clientService.getClient(ModelCategory.MEDIUM).completion( + ModelCategory.MEDIUM, + "You are a funny person", + "Tell me a cat joke", + ""); + LOGGER.info(completion.toString()); + assertTrue(completion.cached()); + assertEquals(0, completion.requests()); + assertEquals(firstResponse, completion.response()); + + completion = this.clientService.getClient(ModelCategory.MEDIUM).completion( + ModelCategory.MEDIUM, + "You are a funny person", + "Tell me a dog joke", + ""); + LOGGER.info(completion.toString()); + assertFalse(completion.cached()); + assertEquals(1, completion.requests()); + assertNotEquals(firstResponse, completion.response()); + } + + @Test + void cacheChat() throws Exception { + final var conversation = Conversation.create("You are an assistant who stutters", "Hi there, I would like to know the climate in Munich."); + + var completion = this.clientService.getClient(ModelCategory.MEDIUM).conversation(ModelCategory.MEDIUM, conversation); + LOGGER.info(completion.toString()); + assertFalse(completion.cached()); + assertEquals(1, completion.requests()); + final var firstResponse = completion.response(); + assertFalse(firstResponse.isBlank()); + + completion = this.clientService.getClient(ModelCategory.MEDIUM).conversation(ModelCategory.MEDIUM, conversation); + LOGGER.info(completion.toString()); + assertTrue(completion.cached()); + assertEquals(0, completion.requests()); + assertEquals(firstResponse, completion.response()); + + conversation.addTurn(new Turn(TurnType.ASSISTANT, + "T-t-the climate in B-b-berlin, um, it's, uh, quite, c-c-cool, actually. B-b-berlin has a, um, temperate climate, with, uh, four distinct s-s-seasons. S-s-spring is, um, pretty mild, with, uh, temperatures ranging from, uh, 10 to 20 degrees Celsius. S-s-summer is, um, warm, with, uh, highs around 25 degrees Celsius. F-f-fall is, um, cool, with, uh, temperatures ranging from 5 to 15 degrees Celsius. And, um, w-w-winter is, uh, quite, c-c-cold, with, uh, temperatures ranging from -2 to 5 degrees Celsius.")); + conversation.addTurn(new Turn(TurnType.USER, "Great! How is the weather in summer?")); + completion = this.clientService.getClient(ModelCategory.MEDIUM).conversation(ModelCategory.MEDIUM, conversation); + LOGGER.info(completion.toString()); + assertFalse(completion.cached()); + assertEquals(1, completion.requests()); + assertNotEquals(firstResponse, completion.response()); + } +} diff --git a/src/test/java/de/extio/lmlib/client/cached/TestCachedClientRepository.java b/src/test/java/de/extio/lmlib/client/cached/TestCachedClientRepository.java new file mode 100755 index 0000000..24d1524 --- /dev/null +++ b/src/test/java/de/extio/lmlib/client/cached/TestCachedClientRepository.java @@ -0,0 +1,27 @@ +package de.extio.lmlib.client.cached; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import org.springframework.stereotype.Component; + +@Component +class TestCachedClientRepository implements CachedClientRepository { + + private final ConcurrentMap cache = new ConcurrentHashMap<>(); + + @Override + public CachedCompletion get(final String key) { + return this.cache.get(key); + } + + @Override + public void put(final String key, final CachedCompletion completion) { + this.cache.put(key, completion); + } + + void reset() { + this.cache.clear(); + } + +} diff --git a/src/test/java/de/extio/lmlib/client/textcompletion/TextCompletionClientTest.java b/src/test/java/de/extio/lmlib/client/textcompletion/TextCompletionClientTest.java index 284e92a..6268301 100755 --- a/src/test/java/de/extio/lmlib/client/textcompletion/TextCompletionClientTest.java +++ b/src/test/java/de/extio/lmlib/client/textcompletion/TextCompletionClientTest.java @@ -23,6 +23,7 @@ import de.extio.lmlib.client.Conversation.Turn; import de.extio.lmlib.client.Conversation.TurnType; import de.extio.lmlib.profile.ModelCategory; +@Disabled("This test requires a running Llama server") @SpringBootTest(webEnvironment = WebEnvironment.NONE) @SpringBootConfiguration @ComponentScan(basePackages = "de.extio.lmlib") diff --git a/src/test/java/de/extio/lmlib/token/TokenizerTest.java b/src/test/java/de/extio/lmlib/token/TokenizerTest.java index 080fadd..9f03191 100644 --- a/src/test/java/de/extio/lmlib/token/TokenizerTest.java +++ b/src/test/java/de/extio/lmlib/token/TokenizerTest.java @@ -2,6 +2,7 @@ package de.extio.lmlib.token; import static org.junit.jupiter.api.Assertions.assertEquals; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; @@ -42,8 +43,9 @@ public class TokenizerTest { } /** - * Tested with a Llama 3.1 8B model. The test will likely fail if you load a different model. + * Tested with a Llama 3.1 8B model. The test will likely fail if you load a different model. */ + @Disabled("Requires llama.cpp model profile") @Test void llamaServer() throws Exception { final var tokenizer = new LlamaServerTokenizer(this.webClientBuilder);