feat: Enhance agent functionality with ModelProfile integration

- Added ModelProfile to the Agent record to allow for more detailed model configurations.
- Updated AgentExecutorService to utilize ModelProfile when fetching clients.
- Modified BaseAgent interface to include a default modelProfile method.
- Adjusted Client interface and implementations to support ModelProfile in conversation and streaming methods.
- Enhanced ClientService to retrieve clients based on ModelProfile.
- Updated CompletionInterceptor to work with ModelProfile instead of ModelCategory.
- Refactored AzureAiClient and other client implementations to handle ModelProfile correctly.
- Changed CachedClient to cache responses based on ModelProfile.
- Updated ModelCategory to a record type for better validation and immutability.
- Enhanced ModelProfile with validation checks for its fields.
- Updated ModelProfileService to support fetching profiles based on ModelCategory.
- Adjusted tests to accommodate changes in Agent and ModelProfile.
- Updated properties files to include category information for model profiles.
This commit is contained in:
extio-de
2025-10-29 12:07:36 +01:00
parent c5dca3ac07
commit 304924e223
20 changed files with 452 additions and 126 deletions

222
README.md
View File

@@ -47,22 +47,100 @@ Don't forget to activate a CacheManager afterwards:
Models are managed with profiles. You can maintain multiple profiles for different models and parameters such as temperature. Models are managed with profiles. You can maintain multiple profiles for different models and parameters such as temperature.
These profiles must be mapped to fixed categories with the following application properties: ### Managing Model Profiles
profile.model.s= lm-lib offers three flexible approaches for managing model profiles:
profile.model.m=
profile.model.l=
profile.model.xl=
profile.model.hot=
profile.model.cold=
The value is the name of the model profile .properties file, for example "llama3.3-70b-azure". #### 1. Predefined Categories (Recommended for Simple Cases)
Agent and completion requests will be invoked with these categories and will load the corresponding model profile. Map model profiles to predefined categories using application properties:
Profiles must be located in the classpath resources location (e.g. src/main/resources). profile.model.s=local-small-model
profile.model.m=llama3.3-70b-azure
profile.model.l=gpt-4
profile.model.xl=gpt-4-turbo
profile.model.hot=high-temperature-model
profile.model.cold=deterministic-model
Examples can be found in test resources. The value is the name of the model profile `.properties` file (without the extension), for example "llama3.3-70b-azure".
**Note:** Model profile `.properties` files must be located in the classpath resources (e.g., `src/main/resources`). Examples can be found in test resources.
Agent and completion requests use predefined constants like `ModelCategory.MEDIUM` or `ModelCategory.LARGE`, which automatically load the corresponding model profile.
```java
// Using predefined categories
final var client = clientService.getClient(ModelCategory.MEDIUM);
final var completion = client.conversation(ModelCategory.LARGE, conversation);
```
#### 2. Custom Categories (Dynamic Profile Selection)
Create custom categories at runtime for more flexible profile management:
```java
// Create a custom category pointing to a specific profile
final var customCategory = new ModelCategory("profile-name", "CUSTOM");
// Use it just like predefined categories
final var client = clientService.getClient(customCategory);
final var completion = client.conversation(customCategory, conversation);
```
The value is the name of the model profile `.properties` file (without the extension), for example "llama3.3-70b-azure".
**Note:** Model profile `.properties` files must be located in the classpath resources (e.g., `src/main/resources`). Examples can be found in test resources.
This approach is useful when you need to:
- Select profiles based on runtime conditions
- Create multiple variants of the same model with different parameters
- Implement A/B testing or model experimentation
#### 3. Programmatic ModelProfile Creation (Maximum Control)
Create `ModelProfile` instances directly in code, bypassing the properties file system entirely:
```java
// Build a ModelProfile programmatically
final var modelProfile = new ModelProfile(
"", // prompts (empty for chat completion)
"cl100k_base", // tokenEncoding
2000, // maxTokens
32768, // maxContextLength
0.7, // temperature
1.0, // topP
ModelProfile.ModelProvider.OAI_CHAT_COMPLETION,
"gpt-4", // modelName
"https://api.openai.com/v1", // url
System.getenv("OPENAI_API_KEY"), // apiKey
new BigDecimal("0.03"), // costInToken (per token)
new BigDecimal("0.06"), // costOutToken (per token)
null, // reasoningEffort
null, // reasoningSummaryDetails
"DYNAMIC" // category (short name for logging)
);
// Use the profile directly
final var client = clientService.getClient(modelProfile);
final var completion = client.conversation(modelProfile, conversation);
// Or in agents
final var agent = new Agent(
"MyAgent",
AgentType.START_CONVERSATION,
null, // modelCategory (not needed when using modelProfile)
modelProfile, // specify the profile directly
"You are a helpful assistant.",
"{{userInput}}",
new TextAgentResponseHandler("response"),
null, null, null,
AgentNext::end
);
```
This approach is ideal for:
- Runtime model selection based on complex business logic
- Testing different model configurations without changing properties files
- Integrating with external configuration systems
- Per-request model customization
### Model Profile Properties ### Model Profile Properties
@@ -70,6 +148,7 @@ Each model profile is a `.properties` file that configures how the library inter
| Property | Type | Required | Description | Example Values | | Property | Type | Required | Description | Example Values |
|----------|------|----------|-------------|----------------| |----------|------|----------|-------------|----------------|
| `category` | String | No | Short name identifier for the profile used in logging and graph visualization. If omitted, will be inferred from the ModelCategory when loading. | `M`, `L`, `CUSTOM`, `PROD` |
| `prompts` | String | Yes | Prompt template strategy name for text completions. Use empty string for chat completions or if no special formatting is needed. | `llama4`, `gpt-oss`, `no`, `` (empty) | | `prompts` | String | Yes | Prompt template strategy name for text completions. Use empty string for chat completions or if no special formatting is needed. | `llama4`, `gpt-oss`, `no`, `` (empty) |
| `tokenEncoding` | String | Yes | Token encoding scheme used by the model for counting tokens. | `cl100k_base`, `none` | | `tokenEncoding` | String | Yes | Token encoding scheme used by the model for counting tokens. | `cl100k_base`, `none` |
| `maxTokens` | Integer | Yes | Maximum number of tokens to generate in the model response. | `1500`, `2500` | | `maxTokens` | Integer | Yes | Maximum number of tokens to generate in the model response. | `1500`, `2500` |
@@ -77,9 +156,9 @@ Each model profile is a `.properties` file that configures how the library inter
| `temperature` | Double | Yes | Controls randomness in responses. Lower values (0.0-0.5) are more deterministic, higher values (0.5-1.0+) more creative. | `0.2`, `0.4`, `0.7` | | `temperature` | Double | Yes | Controls randomness in responses. Lower values (0.0-0.5) are more deterministic, higher values (0.5-1.0+) more creative. | `0.2`, `0.4`, `0.7` |
| `topP` | Double | Yes | Nucleus sampling parameter. Controls diversity via cumulative probability. Typically kept at 1.0. | `1.0`, `0.9` | | `topP` | Double | Yes | Nucleus sampling parameter. Controls diversity via cumulative probability. Typically kept at 1.0. | `1.0`, `0.9` |
| `modelProvider` | Enum | Yes | The LLM provider/client type to use. | `OAI_CHAT_COMPLETION`, `OAI_TEXT_COMPLETION`, `AZURE_AI` | | `modelProvider` | Enum | Yes | The LLM provider/client type to use. | `OAI_CHAT_COMPLETION`, `OAI_TEXT_COMPLETION`, `AZURE_AI` |
| `modelName` | String | Yes | The specific model identifier or deployment name. Can be empty for local models where it's auto-detected. | `Llama-3.3-70B-Instruct`, `gpt-4`, `` (empty for auto-detect) | | `modelName` | String | No | The specific model identifier or deployment name. Can be empty for local models where it's auto-detected. | `Llama-3.3-70B-Instruct`, `gpt-4`, `` (empty for auto-detect) |
| `url` | String | Yes | Base URL endpoint for the model API. | `https://api.openai.com/v1`, `http://localhost:5001` | | `url` | String | Yes | Base URL endpoint for the model API. | `https://api.openai.com/v1`, `http://localhost:5001` |
| `apiKey` | String | Yes | Authentication key for the API. Can use Spring property placeholders like `${apikey}`. Can be empty for local models. | `sk-...`, `${apikey}`, `` (empty) | | `apiKey` | String | No | Authentication key for the API. Can use Spring property placeholders like `${apikey}`. Can be empty for local models. | `sk-...`, `${apikey}`, `` (empty) |
| `cost1MInTokens` | Double | Yes | Cost per 1 million input tokens in your currency. Use `0` for free/local models. | `0.71`, `0`, `5.00` | | `cost1MInTokens` | Double | Yes | Cost per 1 million input tokens in your currency. Use `0` for free/local models. | `0.71`, `0`, `5.00` |
| `cost1MOutTokens` | Double | Yes | Cost per 1 million output tokens in your currency. Use `0` for free/local models. | `0.71`, `0`, `15.00` | | `cost1MOutTokens` | Double | Yes | Cost per 1 million output tokens in your currency. Use `0` for free/local models. | `0.71`, `0`, `15.00` |
| `reasoningEffort` | String | No | For reasoning models (e.g., OpenAI o1): controls depth of reasoning process. | `low`, `medium`, `high` | | `reasoningEffort` | String | No | For reasoning models (e.g., OpenAI o1): controls depth of reasoning process. | `low`, `medium`, `high` |
@@ -87,7 +166,9 @@ Each model profile is a `.properties` file that configures how the library inter
**Notes:** **Notes:**
- Properties marked "Yes" in the Required column must be present in every profile; missing required properties will cause runtime errors. - Properties marked "Yes" in the Required column must be present in every profile; missing required properties will cause runtime errors.
- The `category` field is optional in profile files. When omitted, the category short name from the `ModelCategory` used to load the profile will be used instead.
- The `prompts` property determines the prompt formatting strategy. For text completions, specify a strategy name (e.g., `llama3`, `gpt-oss`) or `no` for no formatting. For chat completions, use an empty string. - The `prompts` property determines the prompt formatting strategy. For text completions, specify a strategy name (e.g., `llama3`, `gpt-oss`) or `no` for no formatting. For chat completions, use an empty string.
- The `modelName` and `apiKey` properties are optional and can be empty for local model deployments.
- Cost properties are used for statistics tracking and have no impact on API calls. They help you monitor expenses. - Cost properties are used for statistics tracking and have no impact on API calls. They help you monitor expenses.
- Reasoning properties are only applicable to models that support separate reasoning output (like OpenAI's o1 series). - Reasoning properties are only applicable to models that support separate reasoning output (like OpenAI's o1 series).
@@ -104,6 +185,7 @@ These settings control how the model generates and presents its reasoning proces
**Azure AI Chat Completion (llama3.3-70b-azure.properties):** **Azure AI Chat Completion (llama3.3-70b-azure.properties):**
```properties ```properties
category=
prompts= prompts=
tokenEncoding=cl100k_base tokenEncoding=cl100k_base
maxTokens=1500 maxTokens=1500
@@ -120,6 +202,7 @@ cost1MOutTokens=0.71
**Local Chat Completion (local-chatcompletion.properties):** **Local Chat Completion (local-chatcompletion.properties):**
```properties ```properties
category=M
prompts= prompts=
tokenEncoding=cl100k_base tokenEncoding=cl100k_base
maxTokens=2500 maxTokens=2500
@@ -138,6 +221,7 @@ reasoningSummaryDetails=auto
**Local Text Completion (local-completion.properties):** **Local Text Completion (local-completion.properties):**
```properties ```properties
category=S
prompts=gpt-oss prompts=gpt-oss
tokenEncoding=cl100k_base tokenEncoding=cl100k_base
maxTokens=2500 maxTokens=2500
@@ -230,9 +314,44 @@ All language model requests will be cached if you add a CachedClientRepository b
# Completion Interceptors # Completion Interceptors
You can intercept every Conversation and Completion by implementing CompletionInterceptor. You can intercept every Conversation and Completion by implementing `CompletionInterceptor`.
This is for example useful for accounting purposes or to implement a circuit breaker. This is useful for accounting purposes, implementing circuit breakers, rate limiting, or logging.
The client will autowire all implementations and intercept requests accordingly.
Interceptor implementations receive the full `ModelProfile` for each request, allowing you to make decisions based on:
- The specific model being used
- Model costs and token limits
- Temperature and other parameters
- The model provider
Example:
```java
@Component
public class MyInterceptor implements CompletionInterceptor {
@Override
public Conversation before(ModelProfile modelProfile, Conversation conversation) {
// Access model details
LOGGER.info("Using model: {} at {}", modelProfile.modelName(), modelProfile.url());
// Could implement rate limiting based on model category
if ("EXPENSIVE".equals(modelProfile.category())) {
checkRateLimit();
}
return conversation;
}
@Override
public Completion after(ModelProfile modelProfile, Completion completion) {
// Track costs based on actual model used
trackCosts(modelProfile, completion.statistics());
return completion;
}
}
```
The client will autowire all `CompletionInterceptor` implementations and apply them to requests automatically.
# Grader # Grader
@@ -291,6 +410,14 @@ public class BlogPostAgent implements BaseAgent {
return ModelCategory.MEDIUM; return ModelCategory.MEDIUM;
} }
// OPTIONAL: Specific model profile (overrides modelCategory, default: null)
@Override
public ModelProfile modelProfile(final AgentContext ctx) {
// Return null to use modelCategory() instead
// Or return a specific ModelProfile for direct control
return null;
}
// OPTIONAL: System-level instruction for the LLM (default: null) // OPTIONAL: System-level instruction for the LLM (default: null)
@Override @Override
public String systemPrompt() { public String systemPrompt() {
@@ -371,6 +498,7 @@ final var blogPostAgent = new Agent(
"BlogPostAgent", // name "BlogPostAgent", // name
AgentType.START_CONVERSATION_WITH_SYSTEM_PROMPT, // agentType AgentType.START_CONVERSATION_WITH_SYSTEM_PROMPT, // agentType
ModelCategory.MEDIUM, // modelCategory ModelCategory.MEDIUM, // modelCategory
null, // modelProfile (null to use modelCategory)
"You are a concise technical blog writer.", // systemPrompt "You are a concise technical blog writer.", // systemPrompt
"Write a blog post about {{topic}} using these bullets:\n\n{{points}}", // textTemplate "Write a blog post about {{topic}} using these bullets:\n\n{{points}}", // textTemplate
new TextAgentResponseHandler("blogPost"), // responseHandler new TextAgentResponseHandler("blogPost"), // responseHandler
@@ -385,6 +513,7 @@ final var processingAgent = new Agent(
"DataProcessor", "DataProcessor",
AgentType.PROCESSING_ONLY, AgentType.PROCESSING_ONLY,
ModelCategory.MEDIUM, ModelCategory.MEDIUM,
null, // modelProfile
null, null,
null, null,
new TextAgentResponseHandler("result"), new TextAgentResponseHandler("result"),
@@ -407,6 +536,7 @@ final var analyzerAgent = new Agent(
"FeatureAnalyzer", "FeatureAnalyzer",
AgentType.START_CONVERSATION, AgentType.START_CONVERSATION,
ModelCategory.SMALL, ModelCategory.SMALL,
null, // modelProfile
null, null,
"Analyze this feature: [[feature]]", // Branches per feature "Analyze this feature: [[feature]]", // Branches per feature
new JsonAgentResponseHandler("analysis_"), new JsonAgentResponseHandler("analysis_"),
@@ -415,6 +545,37 @@ final var analyzerAgent = new Agent(
contexts -> BaseAgent.mergeContexts(List.of("analysis_score", "analysis_summary"), contexts), // Many-to-one merge contexts -> BaseAgent.mergeContexts(List.of("analysis_score", "analysis_summary"), contexts), // Many-to-one merge
ctx -> new AgentNext("ReportGenerator") ctx -> new AgentNext("ReportGenerator")
); );
// Agent using a programmatically created ModelProfile
final var customProfile = new ModelProfile(
"", // prompts
"cl100k_base", // tokenEncoding
3000, // maxTokens
64000, // maxContextLength
0.3, // temperature
1.0, // topP
ModelProfile.ModelProvider.OAI_CHAT_COMPLETION, // modelProvider
"gpt-4-turbo", // modelName
"https://api.openai.com/v1", // url
System.getenv("OPENAI_API_KEY"), // apiKey
new BigDecimal("0.01"), // costInToken
new BigDecimal("0.03"), // costOutToken
null, // reasoningEffort
null, // reasoningSummaryDetails
"GPT4T" // category
);
final var customModelAgent = new Agent(
"CustomModelAgent",
AgentType.COMPLETION,
null, // modelCategory (not needed with modelProfile)
customProfile, // modelProfile (overrides category)
"Expert technical writer",
"Summarize: {{text}}",
new TextAgentResponseHandler("summary"),
null, null, null,
AgentNext::end
);
``` ```
**Usage:** **Usage:**
@@ -621,47 +782,56 @@ This document details the public methods of the `BaseAgent` interface, providing
* **Returns:** An `AgentType` enum value. * **Returns:** An `AgentType` enum value.
* **Throws:** `UnsupportedOperationException` if not implemented. * **Throws:** `UnsupportedOperationException` if not implemented.
**3. `ModelCategory modelCategory()`** **3. `ModelCategory modelCategory(final AgentContext context)`**
* **Purpose:** Specifies the desired size/capability category of the language model to use when interacting with the LLM client. This allows for cost optimization or performance tuning by selecting models appropriate for the task. * **Purpose:** Specifies the desired size/capability category of the language model to use when interacting with the LLM client. This allows for cost optimization or performance tuning by selecting models appropriate for the task. This method is only consulted if `modelProfile()` returns `null`.
* **Usage:** Implementations can optionally override this method to customize the model category. The default implementation returns `ModelCategory.MEDIUM`. * **Usage:** Implementations can optionally override this method to customize the model category. The default implementation returns `ModelCategory.MEDIUM`.
* **Returns:** A `ModelCategory` enum value. * **Parameters:** `context`: The `AgentContext` object representing the current state of the agent flow.
* **Returns:** A `ModelCategory` value (can be predefined like `ModelCategory.MEDIUM` or a custom instance).
* **Default:** `ModelCategory.MEDIUM` * **Default:** `ModelCategory.MEDIUM`
**4. `String systemPrompt()`** **4. `ModelProfile modelProfile(final AgentContext context)`**
* **Purpose:** Directly specifies a `ModelProfile` to use for this agent, bypassing the model category system. When this method returns a non-null value, it takes precedence over `modelCategory()`. This provides maximum control over model selection, allowing runtime-specific profile creation or selection based on context.
* **Usage:** Implementations can optionally override this method to return a specific `ModelProfile` instance. This is useful for agents that need to use a specific model configuration that doesn't fit into the predefined categories, or when model selection needs to be dynamic based on the agent context.
* **Parameters:** `context`: The `AgentContext` object representing the current state of the agent flow.
* **Returns:** A `ModelProfile` instance, or `null` to use `modelCategory()` instead.
* **Default:** `null`
**5. `String systemPrompt()`**
* **Purpose:** Provides a system-level instruction that sets the context and behavior for the LLM. This prompt is prepended to user input when creating a conversation. * **Purpose:** Provides a system-level instruction that sets the context and behavior for the LLM. This prompt is prepended to user input when creating a conversation.
* **Usage:** Implementations can optionally override this method to provide a custom system prompt tailored to their specific task. If no prompt is needed, it should return `null`. * **Usage:** Implementations can optionally override this method to provide a custom system prompt tailored to their specific task. If no prompt is needed, it should return `null`.
* **Returns:** A `String` containing the system prompt. * **Returns:** A `String` containing the system prompt.
* **Default:** `null` * **Default:** `null`
**5. `String textTemplate()`** **6. `String textTemplate()`**
* **Purpose:** Defines a template string for generating input text for the LLM. The template can include placeholders (e.g., `{{key}}`) which are dynamically replaced with values from the `AgentContext`. `[[key]]` style placeholders are a convenient way to branch out / parallelize execution flows. See also the section about text template placeholders below. * **Purpose:** Defines a template string for generating input text for the LLM. The template can include placeholders (e.g., `{{key}}`) which are dynamically replaced with values from the `AgentContext`. `[[key]]` style placeholders are a convenient way to branch out / parallelize execution flows. See also the section about text template placeholders below.
* **Usage:** Implementations can optionally override this method to define a template. This is useful for constructing prompts based on data in the agent context. * **Usage:** Implementations can optionally override this method to define a template. This is useful for constructing prompts based on data in the agent context.
* **Returns:** A `String` containing the text template. * **Returns:** A `String` containing the text template.
* **Default:** `null` * **Default:** `null`
**6. `AgentResponseHandler responseHandler()`** **7. `AgentResponseHandler responseHandler()`**
* **Purpose:** Provides a mechanism for processing the raw LLM response. Different handlers can parse responses into structured data, extract specific information, or perform validation. * **Purpose:** Provides a mechanism for processing the raw LLM response. Different handlers can parse responses into structured data, extract specific information, or perform validation.
* **Usage:** Implementations can optionally override this method to return a custom `AgentResponseHandler`. The default implementation returns a `TextAgentResponseHandler` configured to handle responses under the "response" key. * **Usage:** Implementations can optionally override this method to return a custom `AgentResponseHandler`. The default implementation returns a `TextAgentResponseHandler` configured to handle responses under the "response" key.
* **Returns:** An `AgentResponseHandler` instance. * **Returns:** An `AgentResponseHandler` instance.
* **Default:** `new TextAgentResponseHandler("response")` * **Default:** `new TextAgentResponseHandler("response")`
**7. `void preProcess(final AgentContext context)`** **8. `void preProcess(final AgentContext context)`**
* **Purpose:** Allows performing actions *before* the main logic of the agent is executed. This could involve modifying the `AgentContext`, preparing data, or setting up necessary resources. * **Purpose:** Allows performing actions *before* the main logic of the agent is executed. This could involve modifying the `AgentContext`, preparing data, or setting up necessary resources.
* **Usage:** Implementations can optionally override this method to add pre-processing steps. * **Usage:** Implementations can optionally override this method to add pre-processing steps.
* **Parameters:** `context`: The `AgentContext` object representing the current state of the agent flow. * **Parameters:** `context`: The `AgentContext` object representing the current state of the agent flow.
**8. `void postProcess(final AgentContext context)`** **9. `void postProcess(final AgentContext context)`**
* **Purpose:** Allows performing actions *after* the main logic of the agent is executed. This could involve cleaning up resources, updating the `AgentContext`, or logging results. * **Purpose:** Allows performing actions *after* the main logic of the agent is executed. This could involve cleaning up resources, updating the `AgentContext`, or logging results.
* **Usage:** Implementations can optionally override this method to add post-processing steps. * **Usage:** Implementations can optionally override this method to add post-processing steps.
* **Parameters:** `context`: The `AgentContext` object representing the current state of the agent flow. * **Parameters:** `context`: The `AgentContext` object representing the current state of the agent flow.
**9. `List<AgentContext> merge(final List<AgentContext> contexts)`** **10. `List<AgentContext> merge(final List<AgentContext> contexts)`**
* **Purpose:** Combines multiple `AgentContext` objects into a collection of potentially new `AgentContext` objects. This method provides flexible context aggregation, supporting scenarios beyond simply reducing many contexts to one. It allows for one-to-one, one-to-many, many-to-one, and many-to-many merging strategies. The merge() step is executed after all branch executions (depth 1) and their postProcess() steps have been completed. * **Purpose:** Combines multiple `AgentContext` objects into a collection of potentially new `AgentContext` objects. This method provides flexible context aggregation, supporting scenarios beyond simply reducing many contexts to one. It allows for one-to-one, one-to-many, many-to-one, and many-to-many merging strategies. The merge() step is executed after all branch executions (depth 1) and their postProcess() steps have been completed.
* **Usage:** Implementations can optionally override this method to define a custom merging strategy tailored to their specific needs. The default implementation returns `null`, indicating no merging is performed. A non-null return value signifies the merged contexts. * **Usage:** Implementations can optionally override this method to define a custom merging strategy tailored to their specific needs. The default implementation returns `null`, indicating no merging is performed. A non-null return value signifies the merged contexts.
@@ -682,7 +852,7 @@ The flexibility of the `merge()` method allows developers to implement sophistic
* Filter or transform contexts during the merging process. * Filter or transform contexts during the merging process.
* Create new contexts based on relationships between existing ones. * Create new contexts based on relationships between existing ones.
**10. `AgentNext chooseNextAgent(final AgentContext context)`** **11. `AgentNext chooseNextAgent(final AgentContext context)`**
* **Purpose:** Determines which agent should be executed next in the flow based on the current `AgentContext`. * **Purpose:** Determines which agent should be executed next in the flow based on the current `AgentContext`.
* **Usage:** Implementations *must* override this method to implement the logic for selecting the next agent. It should return an `AgentNext` enum value indicating either the name of the next agent or `AgentNext.END` to signal the end of the flow. * **Usage:** Implementations *must* override this method to implement the logic for selecting the next agent. It should return an `AgentNext` enum value indicating either the name of the next agent or `AgentNext.END` to signal the end of the flow.

View File

@@ -6,10 +6,12 @@ import java.util.function.Function;
import de.extio.lmlib.agent.responsehandler.AgentResponseHandler; import de.extio.lmlib.agent.responsehandler.AgentResponseHandler;
import de.extio.lmlib.profile.ModelCategory; import de.extio.lmlib.profile.ModelCategory;
import de.extio.lmlib.profile.ModelProfile;
public record Agent(String name, public record Agent(String name,
AgentType agentType, AgentType agentType,
ModelCategory modelCategory, ModelCategory modelCategory,
ModelProfile modelProfile,
String systemPrompt, String systemPrompt,
String textTemplate, String textTemplate,
AgentResponseHandler responseHandler, AgentResponseHandler responseHandler,
@@ -44,6 +46,14 @@ public record Agent(String name,
return BaseAgent.super.modelCategory(context); return BaseAgent.super.modelCategory(context);
} }
@Override
public ModelProfile modelProfile(final AgentContext context) {
if (modelProfile != null) {
return modelProfile;
}
return BaseAgent.super.modelProfile(context);
}
@Override @Override
public void preProcess(final AgentContext context) { public void preProcess(final AgentContext context) {
if (preProcessor != null) { if (preProcessor != null) {

View File

@@ -82,7 +82,13 @@ public class AgentExecutorService implements InitializingBean, DisposableBean {
Client client = null; Client client = null;
if (agent.agentType(context) != AgentType.PROCESSING_ONLY) { if (agent.agentType(context) != AgentType.PROCESSING_ONLY) {
client = this.clientService.getClient(agent.modelCategory(context)); final var modelProfile = agent.modelProfile(context);
if (modelProfile != null) {
client = this.clientService.getClient(modelProfile);
}
else {
client = this.clientService.getClient(agent.modelCategory(context));
}
} }
context.setAgentContextUpdateConsumer(agentContextUpdateConsumer); context.setAgentContextUpdateConsumer(agentContextUpdateConsumer);

View File

@@ -6,6 +6,7 @@ import java.util.List;
import java.util.Map.Entry; import java.util.Map.Entry;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.function.Consumer;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -17,11 +18,13 @@ import com.fasterxml.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;
import de.extio.lmlib.agent.responsehandler.TextAgentResponseHandler; import de.extio.lmlib.agent.responsehandler.TextAgentResponseHandler;
import de.extio.lmlib.client.Chunk;
import de.extio.lmlib.client.Client; import de.extio.lmlib.client.Client;
import de.extio.lmlib.client.Completion; import de.extio.lmlib.client.Completion;
import de.extio.lmlib.client.Conversation; import de.extio.lmlib.client.Conversation;
import de.extio.lmlib.client.Conversation.TurnType; import de.extio.lmlib.client.Conversation.TurnType;
import de.extio.lmlib.profile.ModelCategory; import de.extio.lmlib.profile.ModelCategory;
import de.extio.lmlib.profile.ModelProfile;
public interface BaseAgent { public interface BaseAgent {
@@ -41,6 +44,10 @@ public interface BaseAgent {
return ModelCategory.MEDIUM; return ModelCategory.MEDIUM;
} }
default ModelProfile modelProfile(final AgentContext context) {
return null;
}
default String systemPrompt() { default String systemPrompt() {
return null; return null;
} }
@@ -90,7 +97,8 @@ public interface BaseAgent {
split.context().getGraph().add("○"); split.context().getGraph().add("○");
} }
else { else {
split.context().getGraph().add(this.modelCategory(split.context()).getShortName()); final var modelProfile = this.modelProfile(split.context());
split.context().getGraph().add(modelProfile != null ? modelProfile.category() : this.modelCategory(split.context()).getShortName());
} }
split.context().getGraph().add(this.name()); split.context().getGraph().add(this.name());
@@ -102,21 +110,33 @@ public interface BaseAgent {
LOGGER.debug("Conversation: {}", conversation); LOGGER.debug("Conversation: {}", conversation);
final var responseHandler = this.responseHandler(); final var responseHandler = this.responseHandler();
final var modelProfile = this.modelProfile(split.context());
Completion completion = null; Completion completion = null;
if (split.context().isStreaming()) { if (split.context().isStreaming()) {
if (responseHandler instanceof final StreamedAgentResponseHandler streamedResponseHandler) { if (responseHandler instanceof final StreamedAgentResponseHandler streamedResponseHandler) {
streamedResponseHandler.beforeStream(split.context()); streamedResponseHandler.beforeStream(split.context());
} }
completion = client.streamConversation(this.modelCategory(split.context()), conversation, chunk -> { final Consumer<Chunk> chunkConsumer = chunk -> {
if (responseHandler instanceof final StreamedAgentResponseHandler streamedResponseHandler) { if (responseHandler instanceof final StreamedAgentResponseHandler streamedResponseHandler) {
if (streamedResponseHandler.handleChunk(chunk, split.context()) && split.context().getAgentContextUpdateConsumer() != null) { if (streamedResponseHandler.handleChunk(chunk, split.context()) && split.context().getAgentContextUpdateConsumer() != null) {
split.context().getAgentContextUpdateConsumer().accept(split.context()); split.context().getAgentContextUpdateConsumer().accept(split.context());
} }
} }
}); };
if (modelProfile != null) {
completion = client.streamConversation(modelProfile, conversation, chunkConsumer);
}
else {
completion = client.streamConversation(this.modelCategory(split.context()), conversation, chunkConsumer);
}
} }
else { else {
completion = client.conversation(this.modelCategory(split.context()), conversation); if (modelProfile != null) {
completion = client.conversation(modelProfile, conversation);
}
else {
completion = client.conversation(this.modelCategory(split.context()), conversation);
}
} }
split.context().setLastCompletion(completion); split.context().setLastCompletion(completion);
split.context().getRequestStatistic().add(completion); split.context().getRequestStatistic().add(completion);

View File

@@ -3,6 +3,7 @@ package de.extio.lmlib.client;
import java.util.function.Consumer; import java.util.function.Consumer;
import de.extio.lmlib.profile.ModelCategory; import de.extio.lmlib.profile.ModelCategory;
import de.extio.lmlib.profile.ModelProfile;
import de.extio.lmlib.profile.ModelProfile.ModelProvider; import de.extio.lmlib.profile.ModelProfile.ModelProvider;
public interface Client { public interface Client {
@@ -15,7 +16,11 @@ public interface Client {
Completion conversation(ModelCategory modelCategory, Conversation conversation); Completion conversation(ModelCategory modelCategory, Conversation conversation);
Completion conversation(ModelProfile modelProfile, Conversation conversation);
Completion streamConversation(ModelCategory modelCategory, Conversation conversation, Consumer<Chunk> chunkConsumer); Completion streamConversation(ModelCategory modelCategory, Conversation conversation, Consumer<Chunk> chunkConsumer);
Completion streamConversation(ModelProfile modelProfile, Conversation conversation, Consumer<Chunk> chunkConsumer);
ModelProvider getModelProvider(); ModelProvider getModelProvider();
} }

View File

@@ -10,6 +10,7 @@ import org.springframework.stereotype.Service;
import de.extio.lmlib.client.cached.CachedClient; import de.extio.lmlib.client.cached.CachedClient;
import de.extio.lmlib.client.cached.CachedClientRepository; import de.extio.lmlib.client.cached.CachedClientRepository;
import de.extio.lmlib.profile.ModelCategory; import de.extio.lmlib.profile.ModelCategory;
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;
@@ -28,25 +29,32 @@ public class ClientService {
@Autowired(required = false) @Autowired(required = false)
private List<CompletionInterceptor> completionInterceptors; private List<CompletionInterceptor> completionInterceptors;
@Cacheable("clients")
public Client getClient(final ModelCategory category) { public Client getClient(final ModelCategory category) {
final var modelProfile = this.modelProfileService.getModelProfile(category.getModelProfile()); final var modelProfile = this.modelProfileService.getModelProfile(category.getModelProfile(), category);
return this.getClient(modelProfile);
}
@Cacheable("clients")
public Client getClient(final ModelProfile modelProfile) {
return this.clients.stream() return this.clients.stream()
.filter(client -> client.getModelProvider() == modelProfile.modelProvider()) .filter(client -> client.getModelProvider() == modelProfile.modelProvider())
.findFirst() .findFirst()
.map(client -> this.cachedClientRepository == null ? client : new CachedClient(this.cachedClientRepository, this.modelProfileService, client)) .map(client -> this.cachedClientRepository == null ? client : new CachedClient(this.cachedClientRepository, this.modelProfileService, client))
.map(client -> this.completionInterceptors == null || this.completionInterceptors.isEmpty() ? client : new InterceptingClient(client, this.completionInterceptors)) .map(client -> this.completionInterceptors == null || this.completionInterceptors.isEmpty() ? client : new InterceptingClient(client, this.modelProfileService, this.completionInterceptors))
.orElseThrow(() -> new IllegalStateException("No client found for model profile " + category)); .orElseThrow(() -> new IllegalStateException("No client found for model profile " + modelProfile.modelName()));
} }
private static class InterceptingClient implements Client { private static class InterceptingClient implements Client {
private final Client client; private final Client client;
private final ModelProfileService modelProfileService;
private final List<CompletionInterceptor> completionInterceptors; private final List<CompletionInterceptor> completionInterceptors;
public InterceptingClient(final Client client, final List<CompletionInterceptor> completionInterceptors) { public InterceptingClient(final Client client, final ModelProfileService modelProfileService, final List<CompletionInterceptor> completionInterceptors) {
this.client = client; this.client = client;
this.modelProfileService = modelProfileService;
this.completionInterceptors = completionInterceptors; this.completionInterceptors = completionInterceptors;
} }
@@ -57,26 +65,38 @@ public class ClientService {
@Override @Override
public Completion conversation(final ModelCategory modelCategory, final Conversation conversation_) { public Completion conversation(final ModelCategory modelCategory, final Conversation conversation_) {
final var modelProfile = this.modelProfileService.getModelProfile(modelCategory.getModelProfile(), modelCategory);
return this.conversation(modelProfile, conversation_);
}
@Override
public Completion streamConversation(final ModelCategory modelCategory, final Conversation conversation_, final Consumer<Chunk> chunkConsumer) {
final var modelProfile = this.modelProfileService.getModelProfile(modelCategory.getModelProfile(), modelCategory);
return this.streamConversation(modelProfile, conversation_, chunkConsumer);
}
@Override
public Completion conversation(final ModelProfile modelProfile, final Conversation conversation_) {
Conversation conversation = conversation_; Conversation conversation = conversation_;
for (final var interceptor : this.completionInterceptors) { for (final var interceptor : this.completionInterceptors) {
conversation = interceptor.before(modelCategory, conversation); conversation = interceptor.before(modelProfile, conversation);
} }
var completion = this.client.conversation(modelCategory, conversation); var completion = this.client.conversation(modelProfile, conversation);
for (final var interceptor : this.completionInterceptors) { for (final var interceptor : this.completionInterceptors) {
completion = interceptor.after(modelCategory, completion); completion = interceptor.after(modelProfile, completion);
} }
return completion; return completion;
} }
@Override @Override
public Completion streamConversation(final ModelCategory modelCategory, final Conversation conversation_, final Consumer<Chunk> chunkConsumer) { public Completion streamConversation(final ModelProfile modelProfile, final Conversation conversation_, final Consumer<Chunk> chunkConsumer) {
Conversation conversation = conversation_; Conversation conversation = conversation_;
for (final var interceptor : this.completionInterceptors) { for (final var interceptor : this.completionInterceptors) {
conversation = interceptor.before(modelCategory, conversation); conversation = interceptor.before(modelProfile, conversation);
} }
var completion = this.client.streamConversation(modelCategory, conversation, chunkConsumer); var completion = this.client.streamConversation(modelProfile, conversation, chunkConsumer);
for (final var interceptor : this.completionInterceptors) { for (final var interceptor : this.completionInterceptors) {
completion = interceptor.after(modelCategory, completion); completion = interceptor.after(modelProfile, completion);
} }
return completion; return completion;
} }

View File

@@ -1,11 +1,11 @@
package de.extio.lmlib.client; package de.extio.lmlib.client;
import de.extio.lmlib.profile.ModelCategory; import de.extio.lmlib.profile.ModelProfile;
public interface CompletionInterceptor { public interface CompletionInterceptor {
Conversation before(ModelCategory modelCategory, Conversation conversation); Conversation before(ModelProfile modelProfile, Conversation conversation);
Completion after(ModelCategory modelCategory, Completion completion); Completion after(ModelProfile modelProfile, Completion completion);
} }

View File

@@ -55,7 +55,7 @@ public final class AzureAiClient implements Client {
private static final int CONNECT_TIMEOUT = 10000; private static final int CONNECT_TIMEOUT = 10000;
private final ConcurrentMap<ModelCategory, ChatCompletionsClient> chatCompletionsClients = new ConcurrentHashMap<>(); private final ConcurrentMap<String, ChatCompletionsClient> chatCompletionsClients = new ConcurrentHashMap<>();
private final ModelProfileService modelProfileService; private final ModelProfileService modelProfileService;
@@ -93,12 +93,22 @@ public final class AzureAiClient implements Client {
@Override @Override
public Completion conversation(final ModelCategory modelCategory, final Conversation conversation) { public Completion conversation(final ModelCategory modelCategory, final Conversation conversation) {
return this.requestConversation(modelCategory, conversation, null); return this.requestConversation(modelCategory, null, conversation, null);
}
@Override
public Completion conversation(final ModelProfile modelProfile, final Conversation conversation) {
return this.requestConversation(null, modelProfile, conversation, null);
} }
@Override @Override
public Completion streamConversation(final ModelCategory modelCategory, final Conversation conversation, final Consumer<Chunk> chunkConsumer) { public Completion streamConversation(final ModelCategory modelCategory, final Conversation conversation, final Consumer<Chunk> chunkConsumer) {
return this.requestConversation(modelCategory, conversation, chunkConsumer); return this.requestConversation(modelCategory, null, conversation, chunkConsumer);
}
@Override
public Completion streamConversation(final ModelProfile modelProfile, final Conversation conversation, final Consumer<Chunk> chunkConsumer) {
return this.requestConversation(null, modelProfile, conversation, chunkConsumer);
} }
private List<ChatRequestMessage> createChats(final Conversation conversation, final ModelProfile modelProfile) { private List<ChatRequestMessage> createChats(final Conversation conversation, final ModelProfile modelProfile) {
@@ -172,31 +182,41 @@ public final class AzureAiClient implements Client {
return trimmed; return trimmed;
} }
private Completion requestConversation(ModelCategory modelCategory, final Conversation conversation, final Consumer<Chunk> chunkConsumer) { private Completion requestConversation(ModelCategory modelCategory, final ModelProfile modelProfile, final Conversation conversation, final Consumer<Chunk> chunkConsumer) {
if (modelCategory == null) { final ModelProfile profile;
modelCategory = ModelCategory.MEDIUM; if (modelProfile != null) {
} profile = modelProfile;
final var modelProfile = this.modelProfileService.getModelProfile(modelCategory.getModelProfile()); if (modelCategory == null) {
if (modelProfile == null || modelProfile.modelProvider() != ModelProvider.AZURE_AI) { modelCategory = new ModelCategory(modelProfile.modelName(), modelProfile.category());
throw new IllegalArgumentException("ModelProfile " + modelCategory.getModelProfile()); }
}
final var chat = this.createChats(conversation, modelProfile);
if (chunkConsumer == null) {
return this.requestCompletions(chat, modelCategory, modelProfile);
} }
else { else {
return this.requestStreamCompletions(chat, modelCategory, modelProfile, chunkConsumer); if (modelCategory == null) {
modelCategory = ModelCategory.MEDIUM;
}
profile = this.modelProfileService.getModelProfile(modelCategory.getModelProfile(), modelCategory);
}
if (profile == null || profile.modelProvider() != ModelProvider.AZURE_AI) {
throw new IllegalArgumentException("Invalid ModelProfile");
}
final var chat = this.createChats(conversation, profile);
if (chunkConsumer == null) {
return this.requestCompletions(chat, profile);
}
else {
return this.requestStreamCompletions(chat, profile, chunkConsumer);
} }
} }
private Completion requestCompletions(final List<ChatRequestMessage> chat, final ModelCategory modelCategory, final ModelProfile modelProfile) { private Completion requestCompletions(final List<ChatRequestMessage> chat, final ModelProfile modelProfile) {
LOGGER.debug("Requesting ChatCompletion at {}", modelProfile.modelName()); LOGGER.debug("Requesting ChatCompletion at {}", modelProfile.modelName());
final LocalDateTime start = LocalDateTime.now(); final LocalDateTime start = LocalDateTime.now();
final var options = this.createChatCompletionsOptions(chat, modelProfile); final var options = this.createChatCompletionsOptions(chat, modelProfile);
final var client = this.createHttpClient(modelCategory, modelProfile); final var client = this.createHttpClient(modelProfile);
final var response = client.complete(options); final var response = client.complete(options);
final var finishReason = this.mapFinishReason(response.getChoice().getFinishReason()); final var finishReason = this.mapFinishReason(response.getChoice().getFinishReason());
@@ -205,12 +225,12 @@ public final class AzureAiClient implements Client {
return new Completion(response.getChoice().getMessage().getContent(), null, finishReason, statistics); return new Completion(response.getChoice().getMessage().getContent(), null, finishReason, statistics);
} }
private Completion requestStreamCompletions(final List<ChatRequestMessage> chat, final ModelCategory modelCategory, final ModelProfile modelProfile, final Consumer<Chunk> chunkConsumer) { private Completion requestStreamCompletions(final List<ChatRequestMessage> chat, final ModelProfile modelProfile, final Consumer<Chunk> chunkConsumer) {
LOGGER.debug("Requesting Streaming ChatCompletion at {}", modelProfile.modelName()); LOGGER.debug("Requesting Streaming ChatCompletion at {}", modelProfile.modelName());
final LocalDateTime start = LocalDateTime.now(); final LocalDateTime start = LocalDateTime.now();
final var options = this.createChatCompletionsOptions(chat, modelProfile); final var options = this.createChatCompletionsOptions(chat, modelProfile);
final var client = this.createHttpClient(modelCategory, modelProfile); final var client = this.createHttpClient(modelProfile);
final var completionsStream = client.completeStream(options); final var completionsStream = client.completeStream(options);
final StringBuilder contentBuilder = new StringBuilder(); final StringBuilder contentBuilder = new StringBuilder();
@@ -277,8 +297,8 @@ public final class AzureAiClient implements Client {
return statistics; return statistics;
} }
private ChatCompletionsClient createHttpClient(final ModelCategory modelCategory, final ModelProfile modelProfile) { private ChatCompletionsClient createHttpClient(final ModelProfile modelProfile) {
return this.chatCompletionsClients.computeIfAbsent(modelCategory, key -> { return this.chatCompletionsClients.computeIfAbsent(modelProfile.category(), key -> {
final ExponentialBackoffOptions exponentialOptions = new ExponentialBackoffOptions() final ExponentialBackoffOptions exponentialOptions = new ExponentialBackoffOptions()
.setMaxRetries(99) .setMaxRetries(99)
.setBaseDelay(Duration.ofSeconds(1)) .setBaseDelay(Duration.ofSeconds(1))

View File

@@ -17,6 +17,7 @@ import de.extio.lmlib.client.CompletionFinishReason;
import de.extio.lmlib.client.CompletionStatistics; import de.extio.lmlib.client.CompletionStatistics;
import de.extio.lmlib.client.Conversation; import de.extio.lmlib.client.Conversation;
import de.extio.lmlib.profile.ModelCategory; import de.extio.lmlib.profile.ModelCategory;
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;
@@ -36,17 +37,31 @@ public class CachedClient implements Client {
@Override @Override
public Completion completion(final ModelCategory modelCategory, final String system, final String text) { public Completion completion(final ModelCategory modelCategory, final String system, final String text) {
return this.getCachedResponse(modelCategory, () -> this.client.completion(modelCategory, system, text), system, text); return this.getCachedResponse(modelCategory, null, () -> this.client.completion(modelCategory, system, text), system, text);
} }
@Override @Override
public Completion conversation(final ModelCategory modelCategory, final Conversation conversation) { public Completion conversation(final ModelCategory modelCategory, final Conversation conversation) {
return this.getCachedResponse(modelCategory, () -> this.client.conversation(modelCategory, conversation), conversation.getConversation().toString()); return this.getCachedResponse(modelCategory, null, () -> this.client.conversation(modelCategory, conversation), conversation.getConversation().toString());
}
@Override
public Completion conversation(final ModelProfile modelProfile, final Conversation conversation) {
return this.getCachedResponse(null, modelProfile, () -> this.client.conversation(modelProfile, conversation), conversation.getConversation().toString());
} }
@Override @Override
public Completion streamConversation(final ModelCategory modelCategory, final Conversation conversation, final Consumer<Chunk> chunkConsumer) { public Completion streamConversation(final ModelCategory modelCategory, final Conversation conversation, final Consumer<Chunk> chunkConsumer) {
final var response = this.getCachedResponse(modelCategory, () -> this.client.streamConversation(modelCategory, conversation, chunkConsumer), conversation.getConversation().toString()); final var response = this.getCachedResponse(modelCategory, null, () -> this.client.streamConversation(modelCategory, conversation, chunkConsumer), conversation.getConversation().toString());
if (response.statistics().cached()) {
chunkConsumer.accept(new Chunk(response.response(), response.reasoning()));
}
return response;
}
@Override
public Completion streamConversation(final ModelProfile modelProfile, final Conversation conversation, final Consumer<Chunk> chunkConsumer) {
final var response = this.getCachedResponse(null, modelProfile, () -> this.client.streamConversation(modelProfile, conversation, chunkConsumer), conversation.getConversation().toString());
if (response.statistics().cached()) { if (response.statistics().cached()) {
chunkConsumer.accept(new Chunk(response.response(), response.reasoning())); chunkConsumer.accept(new Chunk(response.response(), response.reasoning()));
} }
@@ -58,8 +73,8 @@ public class CachedClient implements Client {
return this.client.getModelProvider(); return this.client.getModelProvider();
} }
private Completion getCachedResponse(final ModelCategory modelCategory, final Supplier<Completion> supplier, final String... keys) { private Completion getCachedResponse(final ModelCategory modelCategory, final ModelProfile modelProfile, final Supplier<Completion> supplier, final String... keys) {
final var key = this.createCacheKey(modelCategory, keys); final var key = this.createCacheKey(modelCategory, modelProfile, keys);
final var cachedCompletion = this.cacheRepository.get(key); final var cachedCompletion = this.cacheRepository.get(key);
if (cachedCompletion != null) { if (cachedCompletion != null) {
@@ -73,23 +88,25 @@ public class CachedClient implements Client {
return completion; return completion;
} }
private String createCacheKey(ModelCategory modelCategory, final String... keys) { private String createCacheKey(ModelCategory modelCategory, ModelProfile modelProfile, final String... keys) {
if (modelCategory == null) {
modelCategory = ModelCategory.MEDIUM;
}
final var modelProfile = this.modelProfileService.getModelProfile(modelCategory.getModelProfile());
if (modelProfile == null) { if (modelProfile == null) {
throw new IllegalArgumentException("ModelProfile " + modelCategory.getModelProfile()); if (modelCategory == null) {
modelCategory = ModelCategory.MEDIUM;
}
modelProfile = this.modelProfileService.getModelProfile(modelCategory.getModelProfile(), modelCategory);
if (modelProfile == null) {
throw new IllegalArgumentException("ModelProfile " + modelCategory.getModelProfile());
}
} }
MessageDigest digest; MessageDigest digest;
try { try {
digest = MessageDigest.getInstance("SHA-256"); digest = MessageDigest.getInstance("SHA-256");
digest.update(Objects.requireNonNullElse(modelProfile.modelName(), modelCategory.name()).getBytes()); digest.update(modelProfile.modelName().getBytes());
digest.update(modelCategory != null ? modelCategory.getModelProfile().getBytes() : modelProfile.category().getBytes());
digest.update(String.valueOf(modelProfile.modelProvider()).getBytes()); digest.update(String.valueOf(modelProfile.modelProvider()).getBytes());
digest.update(String.valueOf(modelProfile.maxTokens()).getBytes()); digest.update(String.valueOf(modelProfile.maxTokens()).getBytes());
digest.update(String.valueOf(modelProfile.maxContextLength()).getBytes()); digest.update(String.valueOf(modelProfile.maxContextLength()).getBytes());
digest.update(String.valueOf("1").getBytes());
digest.update(String.valueOf(modelProfile.temperature()).getBytes()); digest.update(String.valueOf(modelProfile.temperature()).getBytes());
digest.update(String.valueOf(modelProfile.topP()).getBytes()); digest.update(String.valueOf(modelProfile.topP()).getBytes());
for (final var k : keys) { for (final var k : keys) {

View File

@@ -21,7 +21,6 @@ import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.databind.json.JsonMapper;
import de.extio.lmlib.client.Chunk;
import de.extio.lmlib.client.Chunk; import de.extio.lmlib.client.Chunk;
import de.extio.lmlib.client.Client; import de.extio.lmlib.client.Client;
import de.extio.lmlib.client.Completion; import de.extio.lmlib.client.Completion;
@@ -93,17 +92,27 @@ public abstract class AbstractCompletionClient implements Client, DisposableBean
} }
@Override @Override
public Completion streamConversation(final ModelCategory modelCategory_, final Conversation conversation, final Consumer<Chunk> chunkConsumer) { public Completion conversation(final ModelProfile modelProfile, final Conversation conversation) {
final ModelCategory modelCategory = (modelCategory_ == null) ? ModelCategory.MEDIUM : modelCategory_; return this.streamConversation(modelProfile, conversation, null);
final var modelProfile = this.modelProfileService.getModelProfile(modelCategory.getModelProfile());
if (modelProfile == null || (modelProfile.modelProvider() != ModelProvider.OAI_TEXT_COMPLETION && modelProfile.modelProvider() != ModelProvider.OAI_CHAT_COMPLETION)) {
throw new IllegalArgumentException("ModelProfile " + modelCategory.getModelProfile());
}
return this.requestCompletion(conversation, modelCategory, modelProfile, chunkConsumer);
} }
protected abstract Completion requestCompletion(final Conversation conversation, final ModelCategory modelCategory, final ModelProfile modelProfile, final Consumer<Chunk> chunkConsumer); @Override
public Completion streamConversation(final ModelCategory modelCategory_, final Conversation conversation, final Consumer<Chunk> chunkConsumer) {
final ModelCategory modelCategory = (modelCategory_ == null) ? ModelCategory.MEDIUM : modelCategory_;
final var modelProfile = this.modelProfileService.getModelProfile(modelCategory.getModelProfile(), modelCategory);
return this.streamConversation(modelProfile, conversation, chunkConsumer);
}
@Override
public Completion streamConversation(final ModelProfile modelProfile, final Conversation conversation, final Consumer<Chunk> chunkConsumer) {
if (modelProfile == null || (modelProfile.modelProvider() != ModelProvider.OAI_TEXT_COMPLETION && modelProfile.modelProvider() != ModelProvider.OAI_CHAT_COMPLETION)) {
throw new IllegalArgumentException("Invalid ModelProfile");
}
return this.requestCompletion(conversation, modelProfile, chunkConsumer);
}
protected abstract Completion requestCompletion(final Conversation conversation, final ModelProfile modelProfile, final Consumer<Chunk> chunkConsumer);
protected CompletionFinishReason mapFinishReason(final String finishReason) { protected CompletionFinishReason mapFinishReason(final String finishReason) {
return switch (finishReason) { return switch (finishReason) {

View File

@@ -40,7 +40,7 @@ public class ChatCompletionClient extends AbstractCompletionClient {
} }
@Override @Override
protected Completion requestCompletion(final Conversation conversation, final ModelCategory modelCategory, final ModelProfile modelProfile, final Consumer<Chunk> chunkConsumer) { protected Completion requestCompletion(final Conversation conversation, final ModelProfile modelProfile, final Consumer<Chunk> chunkConsumer) {
final var chat = createChats(conversation, modelProfile); final var chat = createChats(conversation, modelProfile);
final var request = new ChatCompletionRequest(); final var request = new ChatCompletionRequest();

View File

@@ -20,7 +20,6 @@ import de.extio.lmlib.client.CompletionFinishReason;
import de.extio.lmlib.client.Conversation; import de.extio.lmlib.client.Conversation;
import de.extio.lmlib.client.Conversation.Turn; 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.ModelCategory;
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 de.extio.lmlib.prompt.PromptStrategy; import de.extio.lmlib.prompt.PromptStrategy;
@@ -41,13 +40,13 @@ public class TextCompletionClient extends AbstractCompletionClient {
} }
@Override @Override
protected Completion requestCompletion(final Conversation conversation, final ModelCategory modelCategory, final ModelProfile modelProfile, final Consumer<Chunk> chunkConsumer) { protected Completion requestCompletion(final Conversation conversation, final ModelProfile modelProfile, final Consumer<Chunk> chunkConsumer) {
final var promptStrategy = this.promptStrategyFactory.getStrategy(modelProfile.prompt()); final var promptStrategy = this.promptStrategyFactory.getStrategy(modelProfile.prompt());
if (promptStrategy == null) { if (promptStrategy == null) {
throw new IllegalArgumentException("Prompt strategy not found: " + modelProfile.prompt()); throw new IllegalArgumentException("Prompt strategy not found: " + modelProfile.prompt());
} }
final var prompt = this.createPrompt(conversation, modelCategory, modelProfile, promptStrategy); final var prompt = this.createPrompt(conversation, modelProfile, promptStrategy);
final var request = new CompletionRequest(); final var request = new CompletionRequest();
request.setModel(this.modelNameSupplier.getModelName(modelProfile.url())); request.setModel(this.modelNameSupplier.getModelName(modelProfile.url()));
@@ -177,7 +176,7 @@ public class TextCompletionClient extends AbstractCompletionClient {
return new Completion(content, reasoning, finishReason, statistics); return new Completion(content, reasoning, finishReason, statistics);
} }
private String createPrompt(final Conversation conversation, final ModelCategory modelCategory, final ModelProfile modelProfile, final PromptStrategy promptStrategy) { private String createPrompt(final Conversation conversation, final ModelProfile modelProfile, final PromptStrategy promptStrategy) {
StringBuilder prompt = this.formatConversation(conversation, promptStrategy); StringBuilder prompt = this.formatConversation(conversation, promptStrategy);
List<Long> tokenized = this.tokenizer.tokenize(prompt.toString(), modelProfile); List<Long> tokenized = this.tokenizer.tokenize(prompt.toString(), modelProfile);
@@ -188,7 +187,7 @@ public class TextCompletionClient extends AbstractCompletionClient {
prompt = new StringBuilder(this.tokenizer.detokenize(tokenized, modelProfile)); prompt = new StringBuilder(this.tokenizer.detokenize(tokenized, modelProfile));
} }
LOGGER.debug("Completion request for {}. Input tokens: {}", modelCategory, tokenized.size()); LOGGER.debug("Completion request for {}. Input tokens: {}", modelProfile.category(), tokenized.size());
return prompt.toString(); return prompt.toString();
} }

View File

@@ -15,6 +15,7 @@ public class AnswerGrader {
"AnswerGrader", "AnswerGrader",
AgentType.COMPLETION, AgentType.COMPLETION,
ModelCategory.MEDIUM, ModelCategory.MEDIUM,
null,
""" """
You are a grader agent that evaluates the quality of an answer to a support question. You are a grader agent that evaluates the quality of an answer to a support question.
Please provide a response on a scale between 0 and 100, where: 100 indicates that the answer directly addresses the question, is accurate, and provides a clear and concise solution or explanation; 90-99 indicates that the answer is mostly accurate and provides a good solution or explanation, but may lack some detail or clarity; 80-89 indicates that the answer is generally accurate but may require some additional information or clarification to fully understand the solution or explanation; 75 indicates that the answer describes how the system behaves to get to the subject of the question, but may not directly answer the question; 50-74 indicates that the answer is partially accurate or relevant, but may lack key information or provide an incomplete solution or explanation; 25-49 indicates that the answer does not directly answer the question or is speculative or it contains mainly instructions for changes or mainly refers to the Enterprise Integration team without providing substantial information, but may provide some relevant information or context; 0-24 indicates that the answer is not related to the question, is inaccurate, or provides no useful information; Please provide a response on a scale between 0 and 100, where: 100 indicates that the answer directly addresses the question, is accurate, and provides a clear and concise solution or explanation; 90-99 indicates that the answer is mostly accurate and provides a good solution or explanation, but may lack some detail or clarity; 80-89 indicates that the answer is generally accurate but may require some additional information or clarification to fully understand the solution or explanation; 75 indicates that the answer describes how the system behaves to get to the subject of the question, but may not directly answer the question; 50-74 indicates that the answer is partially accurate or relevant, but may lack key information or provide an incomplete solution or explanation; 25-49 indicates that the answer does not directly answer the question or is speculative or it contains mainly instructions for changes or mainly refers to the Enterprise Integration team without providing substantial information, but may provide some relevant information or context; 0-24 indicates that the answer is not related to the question, is inaccurate, or provides no useful information;

View File

@@ -1,21 +1,21 @@
package de.extio.lmlib.profile; package de.extio.lmlib.profile;
public enum ModelCategory { public record ModelCategory(String modelProfile, String shortName) {
SMALL("profile.model.s", "S"), public static final ModelCategory SMALL = new ModelCategory("profile.model.s", "S");
MEDIUM("profile.model.m", "M"), public static final ModelCategory MEDIUM = new ModelCategory("profile.model.m", "M");
LARGE("profile.model.l", "L"), public static final ModelCategory LARGE = new ModelCategory("profile.model.l", "L");
XLARGE("profile.model.xl", "XL"), public static final ModelCategory XLARGE = new ModelCategory("profile.model.xl", "XL");
HOT("profile.model.hot", "HOT"), public static final ModelCategory HOT = new ModelCategory("profile.model.hot", "HOT");
COLD("profile.model.cold", "COLD"); public static final ModelCategory COLD = new ModelCategory("profile.model.cold", "COLD");
private final String modelProfile; public ModelCategory {
if (modelProfile == null || modelProfile.isEmpty()) {
private final String shortName; throw new IllegalArgumentException("modelProfile must not be null or empty");
}
ModelCategory(final String modelProfile, final String shortName) { if (shortName == null || shortName.isEmpty()) {
this.modelProfile = modelProfile; throw new IllegalArgumentException("shortName must not be null or empty");
this.shortName = shortName; }
} }
public String getModelProfile() { public String getModelProfile() {

View File

@@ -16,7 +16,32 @@ public record ModelProfile(
BigDecimal costInToken, BigDecimal costInToken,
BigDecimal costOutToken, BigDecimal costOutToken,
String reasoningEffort, String reasoningEffort,
String reasoningSummaryDetails) { String reasoningSummaryDetails,
String category) {
public ModelProfile {
if (maxTokens <= 0) {
throw new IllegalArgumentException("Model profile maxTokens must be greater than zero");
}
if (maxContextLength <= 0) {
throw new IllegalArgumentException("Model profile maxContextLength must be greater than zero");
}
if (temperature <= 0) {
throw new IllegalArgumentException("Model profile temperature must be greater than zero");
}
if (topP <= 0) {
throw new IllegalArgumentException("Model profile topP must be greater than zero");
}
if (modelProvider == null) {
throw new IllegalArgumentException("Model profile modelProvider is mandatory and cannot be null");
}
if (url == null || url.isBlank()) {
throw new IllegalArgumentException("Model profile url is mandatory and cannot be empty");
}
if (category == null || category.isBlank()) {
throw new IllegalArgumentException("Model profile category is mandatory and cannot be empty");
}
}
public static enum ModelProvider { public static enum ModelProvider {
AZURE_AI, OAI_TEXT_COMPLETION, OAI_CHAT_COMPLETION; AZURE_AI, OAI_TEXT_COMPLETION, OAI_CHAT_COMPLETION;

View File

@@ -33,8 +33,12 @@ public class ModelProfileService {
this.environment = environment; this.environment = environment;
} }
@Cacheable("modelProfiles")
public ModelProfile getModelProfile(final String modelProfile) { public ModelProfile getModelProfile(final String modelProfile) {
return this.getModelProfile(modelProfile, null);
}
@Cacheable("modelProfiles")
public ModelProfile getModelProfile(final String modelProfile, final ModelCategory modelCategory) {
final var modelName = this.environment.getProperty(modelProfile); final var modelName = this.environment.getProperty(modelProfile);
if (modelName == null) { if (modelName == null) {
throw new IllegalStateException("No model found for profile: " + modelProfile); throw new IllegalStateException("No model found for profile: " + modelProfile);
@@ -45,7 +49,7 @@ public class ModelProfileService {
final var promptTemplate = Optional.ofNullable(resource.getProperty("prompts")) final var promptTemplate = Optional.ofNullable(resource.getProperty("prompts"))
.map(Object::toString) .map(Object::toString)
.orElseThrow(() -> new IllegalStateException("No model name found for model: " + modelName)); .orElse("");
final var tokenEncoding = Optional.ofNullable(resource.getProperty("tokenEncoding")) final var tokenEncoding = Optional.ofNullable(resource.getProperty("tokenEncoding"))
.map(Object::toString) .map(Object::toString)
.orElse("none"); .orElse("none");
@@ -71,7 +75,7 @@ public class ModelProfileService {
.orElseThrow(() -> new IllegalStateException("No model modelProvider found for model: " + modelName)); .orElseThrow(() -> new IllegalStateException("No model modelProvider found for model: " + modelName));
final var modelNameCfg = Optional.ofNullable(resource.getProperty("modelName")) final var modelNameCfg = Optional.ofNullable(resource.getProperty("modelName"))
.map(Object::toString) .map(Object::toString)
.orElseThrow(() -> new IllegalStateException("No model modelName found for model: " + modelName)); .orElse(null);
final var url = Optional.ofNullable(resource.getProperty("url")) final var url = Optional.ofNullable(resource.getProperty("url"))
.map(Object::toString) .map(Object::toString)
.orElseThrow(() -> new IllegalStateException("No model url found for model: " + modelName)); .orElseThrow(() -> new IllegalStateException("No model url found for model: " + modelName));
@@ -91,6 +95,10 @@ public class ModelProfileService {
final var reasoningSummaryDetails = Optional.ofNullable(resource.getProperty("reasoningSummaryDetails")) final var reasoningSummaryDetails = Optional.ofNullable(resource.getProperty("reasoningSummaryDetails"))
.map(Object::toString) .map(Object::toString)
.orElse(null); .orElse(null);
final var category = Optional
.ofNullable(modelCategory)
.map(ModelCategory::getShortName)
.orElseGet(() -> (String)resource.getProperty("category"));
//To resolve api key placeholder ${apikey} in model-name.properties file //To resolve api key placeholder ${apikey} in model-name.properties file
String apiKey = null; String apiKey = null;
@@ -100,10 +108,10 @@ public class ModelProfileService {
} }
apiKey = Optional.ofNullable(this.environment.getProperty("apiKey")) apiKey = Optional.ofNullable(this.environment.getProperty("apiKey"))
.map(Object::toString) .map(Object::toString)
.orElseThrow(() -> new IllegalStateException("No model apiKey found for model: " + modelName)); .orElse(null);
} }
return new ModelProfile(promptTemplate, tokenEncoding, maxTokens, maxContextLength, temperature, topP, modelProvider, modelNameCfg, url, apiKey, costInToken, costOutToken, reasoningEffort, reasoningSummaryDetails); return new ModelProfile(promptTemplate, tokenEncoding, maxTokens, maxContextLength, temperature, topP, modelProvider, modelNameCfg, url, apiKey, costInToken, costOutToken, reasoningEffort, reasoningSummaryDetails, category);
} }
catch (final IOException e) { catch (final IOException e) {
LOGGER.error("Error while reading model profile", e); LOGGER.error("Error while reading model profile", e);
@@ -111,6 +119,10 @@ public class ModelProfileService {
} }
} }
public ModelProfile getModelProfile(final ModelCategory modelCategory) {
return this.getModelProfile(modelCategory.getModelProfile(), modelCategory);
}
public List<String> getModelProfileUrls(final ModelProvider... modelProviders) { public List<String> getModelProfileUrls(final ModelProvider... modelProviders) {
final var modelProfiles = Binder.get(this.environment) final var modelProfiles = Binder.get(this.environment)
.bind(MODEL_PROFILE_PREFIX, Bindable.mapOf(String.class, String.class)) .bind(MODEL_PROFILE_PREFIX, Bindable.mapOf(String.class, String.class))

View File

@@ -53,6 +53,7 @@ public class AgentTest {
new Agent("CodeSummarizer", new Agent("CodeSummarizer",
AgentType.START_CONVERSATION, AgentType.START_CONVERSATION,
ModelCategory.MEDIUM, ModelCategory.MEDIUM,
null,
"Generate a summary of the following Java source code.", "Generate a summary of the following Java source code.",
"The source code is:\n{{code}}", "The source code is:\n{{code}}",
new TextAgentResponseHandler("summary"), new TextAgentResponseHandler("summary"),
@@ -65,6 +66,7 @@ public class AgentTest {
new Agent("FeatureIdentifier", new Agent("FeatureIdentifier",
AgentType.CONVERSATION, AgentType.CONVERSATION,
ModelCategory.MEDIUM, ModelCategory.MEDIUM,
null,
"", "",
"Generate a list of 5 distinct functional features. In scope is business logic, not code mechanics. Provide no preamble and no explanation. Return the response in JSON format with the following field: { \"features\": [\"Feature 1\", \"Feature 2\", ... ] }", "Generate a list of 5 distinct functional features. In scope is business logic, not code mechanics. Provide no preamble and no explanation. Return the response in JSON format with the following field: { \"features\": [\"Feature 1\", \"Feature 2\", ... ] }",
new JsonAgentResponseHandler(), new JsonAgentResponseHandler(),
@@ -77,6 +79,7 @@ public class AgentTest {
new Agent("FeatureAnalyzer", new Agent("FeatureAnalyzer",
AgentType.COMPLETION, AgentType.COMPLETION,
ModelCategory.MEDIUM, ModelCategory.MEDIUM,
null,
"Analyze a feature of the following Java source code and describe the functionality and purpose of this feature. Only describe the implementation, don't suggest any possible improvements.", "Analyze a feature of the following Java source code and describe the functionality and purpose of this feature. Only describe the implementation, don't suggest any possible improvements.",
""" """
The source code is: The source code is:
@@ -92,6 +95,7 @@ public class AgentTest {
new Agent("AnswerGenerator", new Agent("AnswerGenerator",
AgentType.COMPLETION, AgentType.COMPLETION,
ModelCategory.MEDIUM, ModelCategory.MEDIUM,
null,
"Generate a clear and concise blog post for an announcement from the following code analysis.", "Generate a clear and concise blog post for an announcement from the following code analysis.",
""" """
Write a concise description of the purpose in the blog post first and then concise information for the following topics: {{topics}} Write a concise description of the purpose in the blog post first and then concise information for the following topics: {{topics}}
@@ -131,6 +135,7 @@ public class AgentTest {
new Agent("CodeSummarizer", new Agent("CodeSummarizer",
AgentType.START_CONVERSATION, AgentType.START_CONVERSATION,
ModelCategory.MEDIUM, ModelCategory.MEDIUM,
null,
"Generate a summary of the following Java source code.", "Generate a summary of the following Java source code.",
"The source code is:\n{{code}}", "The source code is:\n{{code}}",
new TextAgentResponseHandler("summary", new TextAgentResponseHandler("summary",
@@ -179,6 +184,7 @@ public class AgentTest {
new Agent("FirstLevel", new Agent("FirstLevel",
AgentType.COMPLETION, AgentType.COMPLETION,
ModelCategory.MEDIUM, ModelCategory.MEDIUM,
null,
"", "",
"Generate 5 random numbers from 1 to 10. Provide no preamble and no explanation. Return the response in JSON format with the following field: { \"numbers\": [\"Number 1\", \"Number 2\", ... ] }", "Generate 5 random numbers from 1 to 10. Provide no preamble and no explanation. Return the response in JSON format with the following field: { \"numbers\": [\"Number 1\", \"Number 2\", ... ] }",
new JsonAgentResponseHandler(), new JsonAgentResponseHandler(),
@@ -191,6 +197,7 @@ public class AgentTest {
new Agent("SecondLevel", new Agent("SecondLevel",
AgentType.COMPLETION, AgentType.COMPLETION,
ModelCategory.MEDIUM, ModelCategory.MEDIUM,
null,
"", "",
"Generate 5 random numbers from 1 to 10. The previous number was [[numbers]]. Provide no preamble and no explanation. Return the response in JSON format with the following field: { \"random\": [\"Number 1\", \"Number 2\", ... ] }", "Generate 5 random numbers from 1 to 10. The previous number was [[numbers]]. Provide no preamble and no explanation. Return the response in JSON format with the following field: { \"random\": [\"Number 1\", \"Number 2\", ... ] }",
new JsonAgentResponseHandler(), new JsonAgentResponseHandler(),
@@ -203,6 +210,7 @@ public class AgentTest {
new Agent("Calculator", new Agent("Calculator",
AgentType.COMPLETION, AgentType.COMPLETION,
ModelCategory.MEDIUM, ModelCategory.MEDIUM,
null,
"", "",
"Add these 2 numbers: {{numbers}} + [[random]] ; Provide no preamble and no explanation. Return the response in JSON format with the following field: { \"result\": \"result\" }", "Add these 2 numbers: {{numbers}} + [[random]] ; Provide no preamble and no explanation. Return the response in JSON format with the following field: { \"result\": \"result\" }",
new JsonAgentResponseHandler(), new JsonAgentResponseHandler(),
@@ -215,6 +223,7 @@ public class AgentTest {
new Agent("Grader", new Agent("Grader",
AgentType.COMPLETION, AgentType.COMPLETION,
ModelCategory.MEDIUM, ModelCategory.MEDIUM,
null,
"You are a helpful assistant.", "You are a helpful assistant.",
"If you add {{numbers}} marbles to {{random}} marbles, do you get {{result}} marbles as the result? Answer with 'yes' or 'no'", "If you add {{numbers}} marbles to {{random}} marbles, do you get {{result}} marbles as the result? Answer with 'yes' or 'no'",
new TextAgentResponseHandler("grade"), new TextAgentResponseHandler("grade"),

View File

@@ -1,3 +1,4 @@
category=
prompts= prompts=
tokenEncoding=cl100k_base tokenEncoding=cl100k_base
maxTokens=1500 maxTokens=1500

View File

@@ -1,3 +1,4 @@
category=M
prompts= prompts=
tokenEncoding=cl100k_base tokenEncoding=cl100k_base
maxTokens=2500 maxTokens=2500

View File

@@ -1,3 +1,4 @@
category=S
prompts=gpt-oss prompts=gpt-oss
tokenEncoding=cl100k_base tokenEncoding=cl100k_base
maxTokens=2500 maxTokens=2500