feat: Enhance chat completion with tool calling capabilities
- Added support for tools in ChatCompletionRequest, including tool choice and parallel tool calls. - Introduced ChatCompletionTool and ChatCompletionFunction classes to define tool specifications. - Updated ChatMessage to include tool calls and tool call IDs. - Created ChatToolCall and ChatToolFunction classes for managing tool call details. - Modified TextCompletionClient to support tool calling. - Updated Grader and Grader2 classes to handle new tool-related logic. - Enhanced Agent and AgentTest classes to incorporate tool calling in agent workflows. - Added tests for tool calling functionality in CachedClientTest and CompletionClientTest.
This commit is contained in:
113
README.md
113
README.md
@@ -20,6 +20,7 @@ The current built-in client set supports both chat completions and legacy text c
|
|||||||
## Key Capabilities
|
## Key Capabilities
|
||||||
|
|
||||||
- OpenAI-compatible chat and text completions
|
- OpenAI-compatible chat and text completions
|
||||||
|
- Chat-completion tool calling with tool definitions, returned tool calls, and tool-result follow-up rounds
|
||||||
- Streaming and non-streaming APIs
|
- Streaming and non-streaming APIs
|
||||||
- Separate reasoning capture for models that expose reasoning output
|
- Separate reasoning capture for models that expose reasoning output
|
||||||
- Model profile loading from properties or direct programmatic profile creation
|
- Model profile loading from properties or direct programmatic profile creation
|
||||||
@@ -170,7 +171,7 @@ Each value refers to a profile file on the classpath without the `.properties` s
|
|||||||
|
|
||||||
```java
|
```java
|
||||||
final var client = clientService.getClient(ModelCategory.MEDIUM);
|
final var client = clientService.getClient(ModelCategory.MEDIUM);
|
||||||
final var completion = client.conversation(ModelCategory.LARGE, conversation, false);
|
final var completion = client.conversation(ModelCategory.LARGE, conversation, null, false);
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Custom Categories
|
### 2. Custom Categories
|
||||||
@@ -181,7 +182,7 @@ Create categories dynamically when predefined ones are too restrictive:
|
|||||||
final var customCategory = new ModelCategory("profile-name", "CUSTOM");
|
final var customCategory = new ModelCategory("profile-name", "CUSTOM");
|
||||||
|
|
||||||
final var client = clientService.getClient(customCategory);
|
final var client = clientService.getClient(customCategory);
|
||||||
final var completion = client.conversation(customCategory, conversation, false);
|
final var completion = client.conversation(customCategory, conversation, null, false);
|
||||||
```
|
```
|
||||||
|
|
||||||
This is useful for runtime routing, model experiments, or per-tenant profile selection.
|
This is useful for runtime routing, model experiments, or per-tenant profile selection.
|
||||||
@@ -212,7 +213,7 @@ final var modelProfile = new ModelProfile(
|
|||||||
);
|
);
|
||||||
|
|
||||||
final var client = clientService.getClient(modelProfile);
|
final var client = clientService.getClient(modelProfile);
|
||||||
final var completion = client.conversation(modelProfile, conversation, false);
|
final var completion = client.conversation(modelProfile, conversation, null, false);
|
||||||
```
|
```
|
||||||
|
|
||||||
Agents can also use a specific profile directly:
|
Agents can also use a specific profile directly:
|
||||||
@@ -225,6 +226,7 @@ final var agent = new Agent(
|
|||||||
modelProfile,
|
modelProfile,
|
||||||
"You are a helpful assistant.",
|
"You are a helpful assistant.",
|
||||||
"{{userInput}}",
|
"{{userInput}}",
|
||||||
|
null,
|
||||||
new TextAgentResponseHandler("response"),
|
new TextAgentResponseHandler("response"),
|
||||||
null, null, null,
|
null, null, null,
|
||||||
AgentNext::end
|
AgentNext::end
|
||||||
@@ -317,6 +319,8 @@ modelProvider=OAI_TEXT_COMPLETION
|
|||||||
|
|
||||||
Chat completions are the preferred default. They are simpler to use because the library only needs to send the conversation. Text completions are still useful when you need explicit prompt formatting or must work with instruct models that depend on raw prompt structure.
|
Chat completions are the preferred default. They are simpler to use because the library only needs to send the conversation. Text completions are still useful when you need explicit prompt formatting or must work with instruct models that depend on raw prompt structure.
|
||||||
|
|
||||||
|
Tool calling is currently supported only for chat completion models. `TextCompletionClient` does not support tools, and `supportsToolCalling()` can be used on `Client`, `ClientService`, and `BaseAgent` to detect availability before enabling tool-aware flows.
|
||||||
|
|
||||||
These built-in providers are OpenAI-compatible today, but that is an implementation detail of the current client set, not a hard architectural limit of the library. Additional provider integrations can be added in the library itself, and applications can plug in their own client implementations where needed.
|
These built-in providers are OpenAI-compatible today, but that is an implementation detail of the current client set, not a hard architectural limit of the library. Additional provider integrations can be added in the library itself, and applications can plug in their own client implementations where needed.
|
||||||
|
|
||||||
For text completions, select a prompt strategy with the `prompts` property. lm-lib ships built-in strategies for common model families including Llama, GPT-OSS, Gemma, Mistral, Phi, Qwen, Vicuna, ChatML, Alpaca, and a no-formatting strategy.
|
For text completions, select a prompt strategy with the `prompts` property. lm-lib ships built-in strategies for common model families including Llama, GPT-OSS, Gemma, Mistral, Phi, Qwen, Vicuna, ChatML, Alpaca, and a no-formatting strategy.
|
||||||
@@ -343,9 +347,61 @@ Typical integration flow:
|
|||||||
|
|
||||||
```java
|
```java
|
||||||
final var client = clientService.getClient(ModelCategory.MEDIUM);
|
final var client = clientService.getClient(ModelCategory.MEDIUM);
|
||||||
final var completion = client.conversation(ModelCategory.MEDIUM, conversation, false);
|
final var completion = client.conversation(ModelCategory.MEDIUM, conversation, null, false);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Tool Calling
|
||||||
|
|
||||||
|
Tool calling lets chat completion models return one or more structured tool invocations instead of a final natural-language answer. The library exposes this in both the direct client API and the agent runtime.
|
||||||
|
|
||||||
|
The main tool-calling types are:
|
||||||
|
|
||||||
|
- `ToolDefinition`: describes a callable tool and its input schema
|
||||||
|
- `ToolParameters`: helper for building nested parameter maps and JSON outputs
|
||||||
|
- `ToolCallData`: request-side tool configuration such as available tools and tool choice mode
|
||||||
|
- `ToolCall`: a tool call returned by the model
|
||||||
|
|
||||||
|
Client-side flow:
|
||||||
|
|
||||||
|
```java
|
||||||
|
final var weatherTool = new ToolDefinition(
|
||||||
|
"get_weather",
|
||||||
|
"Gets the current weather for a given location.",
|
||||||
|
ToolParameters.create(Map.of("location", "City and country, for example Berlin, Germany")),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
final var toolCallData = ToolCallData.required(List.of(weatherTool));
|
||||||
|
final var completion = client.conversation(ModelCategory.MEDIUM, conversation, toolCallData, false);
|
||||||
|
|
||||||
|
if (completion.finishReason() == CompletionFinishReason.TOOL_CALLS) {
|
||||||
|
final var toolCall = completion.toolCalls().getFirst();
|
||||||
|
|
||||||
|
conversation.addTurn(new Conversation.Turn(Conversation.TurnType.ASSISTANT, "", completion.toolCalls(), null));
|
||||||
|
conversation.addTurn(new Conversation.Turn(
|
||||||
|
Conversation.TurnType.TOOL,
|
||||||
|
ToolParameters.create().add("forecast", "Berlin is rainy and 21C").json(),
|
||||||
|
null,
|
||||||
|
toolCall.id()
|
||||||
|
));
|
||||||
|
|
||||||
|
final var finalCompletion = client.conversation(
|
||||||
|
ModelCategory.MEDIUM,
|
||||||
|
conversation,
|
||||||
|
ToolCallData.auto(List.of(weatherTool)),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ToolCallData` helpers:
|
||||||
|
|
||||||
|
- `ToolCallData.auto(...)`: model may decide whether to call a tool
|
||||||
|
- `ToolCallData.required(...)`: model must choose one of the provided tools
|
||||||
|
- `ToolCallData.force(...)`: model must call a specific named tool
|
||||||
|
|
||||||
|
The returned `Completion` now exposes `toolCalls()` in addition to `response()`, `reasoning()`, `finishReason()`, and `statistics()`.
|
||||||
|
|
||||||
## Completions and Streaming
|
## Completions and Streaming
|
||||||
|
|
||||||
Both streaming and non-streaming execution paths are supported.
|
Both streaming and non-streaming execution paths are supported.
|
||||||
@@ -353,7 +409,9 @@ Both streaming and non-streaming execution paths are supported.
|
|||||||
- Non-streaming calls return a final `Completion` object when the request finishes.
|
- Non-streaming calls return a final `Completion` object when the request finishes.
|
||||||
- Streaming calls accept a `Consumer<Chunk>` and still return the final `Completion` after the stream completes.
|
- Streaming calls accept a `Consumer<Chunk>` and still return the final `Completion` after the stream completes.
|
||||||
- Each `Chunk` exposes both `content()` and `reasoningContent()`.
|
- Each `Chunk` exposes both `content()` and `reasoningContent()`.
|
||||||
- Final `Completion` objects expose `response()`, `reasoning()`, `finishReason()`, and `statistics()`.
|
- Final `Completion` objects expose `response()`, `reasoning()`, `finishReason()`, `statistics()`, and `toolCalls()`.
|
||||||
|
|
||||||
|
If a chat completion decides to call a tool, `finishReason()` is `CompletionFinishReason.TOOL_CALLS` and `toolCalls()` contains the requested function calls. Streaming chat completions also aggregate tool calls into the final `Completion`.
|
||||||
|
|
||||||
This lets you stream visible output while still capturing final structured accounting data.
|
This lets you stream visible output while still capturing final structured accounting data.
|
||||||
|
|
||||||
@@ -364,12 +422,12 @@ If a `CachedClientRepository` bean is available, lm-lib will wrap clients with a
|
|||||||
Direct client calls support a `skipCache` flag:
|
Direct client calls support a `skipCache` flag:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
final var cached = client.conversation(ModelCategory.MEDIUM, conversation, false);
|
final var cached = client.conversation(ModelCategory.MEDIUM, conversation, null, false);
|
||||||
final var fresh = client.conversation(ModelCategory.MEDIUM, conversation, true);
|
final var fresh = client.conversation(ModelCategory.MEDIUM, conversation, null, true);
|
||||||
|
|
||||||
final var streamed = client.streamConversation(ModelCategory.MEDIUM, conversation, chunk -> {
|
final var streamed = client.streamConversation(ModelCategory.MEDIUM, conversation, chunk -> {
|
||||||
System.out.print(chunk.content());
|
System.out.print(chunk.content());
|
||||||
}, true);
|
}, null, true);
|
||||||
```
|
```
|
||||||
|
|
||||||
`skipCache=true` bypasses reading a cached response for that request but still stores the fresh result afterwards.
|
`skipCache=true` bypasses reading a cached response for that request but still stores the fresh result afterwards.
|
||||||
@@ -588,6 +646,7 @@ final var blogPostAgent = new Agent(
|
|||||||
null,
|
null,
|
||||||
"You are a concise technical blog writer.",
|
"You are a concise technical blog writer.",
|
||||||
"Write a blog post about {{topic}} using these bullets:\n\n{{points}}",
|
"Write a blog post about {{topic}} using these bullets:\n\n{{points}}",
|
||||||
|
null,
|
||||||
new TextAgentResponseHandler("blogPost"),
|
new TextAgentResponseHandler("blogPost"),
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
@@ -606,12 +665,50 @@ final var blogPostAgent = new Agent(
|
|||||||
| `modelProfile(context)` | Supplies a specific model profile and overrides `modelCategory()`. | `null` |
|
| `modelProfile(context)` | Supplies a specific model profile and overrides `modelCategory()`. | `null` |
|
||||||
| `systemPrompt()` | Optional system instruction. | `null` |
|
| `systemPrompt()` | Optional system instruction. | `null` |
|
||||||
| `textTemplate()` | Optional user prompt template. | `null` |
|
| `textTemplate()` | Optional user prompt template. | `null` |
|
||||||
|
| `toolDefinitions(context)` | Optional tool definitions for tool-capable chat models. | `null` |
|
||||||
|
| `toolCallData(context)` | Optional tool request configuration. Defaults to `ToolCallData.auto(toolDefinitions)`. | `null` |
|
||||||
|
| `supportsToolCalling(context, clientService)` | Reports whether the resolved client can handle tool calls. | derived from model/provider |
|
||||||
| `responseHandler(context)` | Parses the model response. | `new TextAgentResponseHandler("response")` |
|
| `responseHandler(context)` | Parses the model response. | `new TextAgentResponseHandler("response")` |
|
||||||
| `preProcess(context)` | Runs before completion. | no-op |
|
| `preProcess(context)` | Runs before completion. | no-op |
|
||||||
| `postProcess(context)` | Runs after completion. | no-op |
|
| `postProcess(context)` | Runs after completion. | no-op |
|
||||||
| `merge(contexts)` | Merges split contexts after branching. | `null` |
|
| `merge(contexts)` | Merges split contexts after branching. | `null` |
|
||||||
| `chooseNextAgent(context)` | Selects the next agent or ends the flow. Required. | none |
|
| `chooseNextAgent(context)` | Selects the next agent or ends the flow. Required. | none |
|
||||||
|
|
||||||
|
### Agent Tool Calling
|
||||||
|
|
||||||
|
Agents can declare tool definitions directly. For simple in-agent tool execution loops, wrap a normal response handler with `ToolCallingAgentResponseHandler`.
|
||||||
|
|
||||||
|
```java
|
||||||
|
final var weatherAgent = new Agent(
|
||||||
|
"WeatherAgent",
|
||||||
|
AgentType.START_CONVERSATION,
|
||||||
|
ModelCategory.MEDIUM,
|
||||||
|
null,
|
||||||
|
"Use the provided tool when the user asks for weather information.",
|
||||||
|
"What is the weather in Berlin, Germany? Use the tool.",
|
||||||
|
List.of(weatherTool),
|
||||||
|
new ToolCallingAgentResponseHandler(
|
||||||
|
new TextAgentResponseHandler("answer"),
|
||||||
|
(completion, context, toolCallResults) -> {
|
||||||
|
if (completion.toolCalls().isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
toolCallResults.add(
|
||||||
|
completion.toolCalls().getFirst(),
|
||||||
|
ToolParameters.create().add("forecast", "Berlin is rainy and 21C")
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
AgentNext::end
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
If you do not want the current agent to resolve tool calls itself, the conversation handoff now preserves assistant tool-call turns so the next `CONVERSATION` or `PROCESSING_ONLY` agent can inspect them in `preProcess()` and decide how to continue.
|
||||||
|
|
||||||
### Agent Types
|
### Agent Types
|
||||||
|
|
||||||
| Type | Behavior | Typical Use |
|
| Type | Behavior | Typical Use |
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import java.util.function.Consumer;
|
|||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
|
|
||||||
import de.extio.lmlib.agent.responsehandler.AgentResponseHandler;
|
import de.extio.lmlib.agent.responsehandler.AgentResponseHandler;
|
||||||
|
import de.extio.lmlib.client.ToolDefinition;
|
||||||
import de.extio.lmlib.profile.ModelCategory;
|
import de.extio.lmlib.profile.ModelCategory;
|
||||||
import de.extio.lmlib.profile.ModelProfile;
|
import de.extio.lmlib.profile.ModelProfile;
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ public record Agent(String name,
|
|||||||
ModelProfile modelProfile,
|
ModelProfile modelProfile,
|
||||||
String systemPrompt,
|
String systemPrompt,
|
||||||
String textTemplate,
|
String textTemplate,
|
||||||
|
List<ToolDefinition> toolDefinitions,
|
||||||
AgentResponseHandler responseHandler,
|
AgentResponseHandler responseHandler,
|
||||||
Consumer<AgentContext> preProcessor,
|
Consumer<AgentContext> preProcessor,
|
||||||
Consumer<AgentContext> postProcessor,
|
Consumer<AgentContext> postProcessor,
|
||||||
@@ -53,6 +55,14 @@ public record Agent(String name,
|
|||||||
}
|
}
|
||||||
return BaseAgent.super.modelProfile(context);
|
return BaseAgent.super.modelProfile(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ToolDefinition> toolDefinitions(final AgentContext context) {
|
||||||
|
if (toolDefinitions != null) {
|
||||||
|
return toolDefinitions;
|
||||||
|
}
|
||||||
|
return BaseAgent.super.toolDefinitions(context);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AgentResponseHandler responseHandler(final AgentContext context) {
|
public AgentResponseHandler responseHandler(final AgentContext context) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import java.util.stream.Collectors;
|
|||||||
|
|
||||||
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.ToolCallData;
|
||||||
|
|
||||||
public final class AgentContext {
|
public final class AgentContext {
|
||||||
|
|
||||||
@@ -47,6 +48,8 @@ public final class AgentContext {
|
|||||||
private volatile AgentNext nextAgent;
|
private volatile AgentNext nextAgent;
|
||||||
|
|
||||||
private volatile Completion lastCompletion;
|
private volatile Completion lastCompletion;
|
||||||
|
|
||||||
|
private volatile ToolCallData toolCallData;
|
||||||
|
|
||||||
private Consumer<AgentContext> agentContextUpdateConsumer;
|
private Consumer<AgentContext> agentContextUpdateConsumer;
|
||||||
|
|
||||||
@@ -79,6 +82,7 @@ public final class AgentContext {
|
|||||||
this.nextAgent = other.nextAgent;
|
this.nextAgent = other.nextAgent;
|
||||||
this.requestStatistic = other.requestStatistic;
|
this.requestStatistic = other.requestStatistic;
|
||||||
this.lastCompletion = other.lastCompletion;
|
this.lastCompletion = other.lastCompletion;
|
||||||
|
this.toolCallData = other.toolCallData;
|
||||||
this.agentContextUpdateConsumer = other.agentContextUpdateConsumer;
|
this.agentContextUpdateConsumer = other.agentContextUpdateConsumer;
|
||||||
this.errorType = other.errorType;
|
this.errorType = other.errorType;
|
||||||
this.errorException = other.errorException;
|
this.errorException = other.errorException;
|
||||||
@@ -196,6 +200,14 @@ public final class AgentContext {
|
|||||||
void setLastCompletion(final Completion lastCompletion) {
|
void setLastCompletion(final Completion lastCompletion) {
|
||||||
this.lastCompletion = lastCompletion;
|
this.lastCompletion = lastCompletion;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ToolCallData getToolCallData() {
|
||||||
|
return this.toolCallData;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setToolCallData(final ToolCallData toolCallData) {
|
||||||
|
this.toolCallData = toolCallData;
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isError() {
|
public boolean isError() {
|
||||||
return this.errorType != null;
|
return this.errorType != null;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import java.util.ArrayList;
|
|||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map.Entry;
|
import java.util.Map.Entry;
|
||||||
|
import java.util.Objects;
|
||||||
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.function.Consumer;
|
||||||
@@ -18,14 +19,19 @@ import tools.jackson.databind.ObjectMapper;
|
|||||||
import de.extio.lmlib.agent.responsehandler.AgentResponseHandler;
|
import de.extio.lmlib.agent.responsehandler.AgentResponseHandler;
|
||||||
import de.extio.lmlib.agent.responsehandler.StreamedAgentResponseHandler;
|
import de.extio.lmlib.agent.responsehandler.StreamedAgentResponseHandler;
|
||||||
import de.extio.lmlib.agent.responsehandler.TextAgentResponseHandler;
|
import de.extio.lmlib.agent.responsehandler.TextAgentResponseHandler;
|
||||||
|
import de.extio.lmlib.agent.responsehandler.ToolCallingAgentResponseHandler;
|
||||||
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.ClientService;
|
||||||
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.ToolCallData;
|
||||||
|
import de.extio.lmlib.client.ToolDefinition;
|
||||||
import de.extio.lmlib.client.Conversation.Turn;
|
import de.extio.lmlib.client.Conversation.Turn;
|
||||||
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;
|
import de.extio.lmlib.profile.ModelProfile;
|
||||||
|
import de.extio.lmlib.profile.ModelProfile.ModelProvider;
|
||||||
|
|
||||||
public interface BaseAgent {
|
public interface BaseAgent {
|
||||||
|
|
||||||
@@ -56,6 +62,38 @@ public interface BaseAgent {
|
|||||||
default String textTemplate() {
|
default String textTemplate() {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
default List<ToolDefinition> toolDefinitions(final AgentContext context) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
default ToolCallData toolCallData(final AgentContext context) {
|
||||||
|
final var toolDefinitions = this.toolDefinitions(context);
|
||||||
|
if (toolDefinitions == null || toolDefinitions.isEmpty()) {
|
||||||
|
final var rememberedToolCallData = context.getToolCallData();
|
||||||
|
if (rememberedToolCallData == null || !rememberedToolCallData.hasTools()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ToolCallData.auto(rememberedToolCallData.tools());
|
||||||
|
}
|
||||||
|
return ToolCallData.auto(toolDefinitions);
|
||||||
|
}
|
||||||
|
|
||||||
|
default boolean supportsToolCalling(final AgentContext context) {
|
||||||
|
final var modelProfile = this.modelProfile(context);
|
||||||
|
return modelProfile != null && modelProfile.modelProvider() == ModelProvider.OAI_CHAT_COMPLETION;
|
||||||
|
}
|
||||||
|
|
||||||
|
default boolean supportsToolCalling(final AgentContext context, final ClientService clientService) {
|
||||||
|
if (clientService == null) {
|
||||||
|
return this.supportsToolCalling(context);
|
||||||
|
}
|
||||||
|
final var modelProfile = this.modelProfile(context);
|
||||||
|
if (modelProfile != null) {
|
||||||
|
return clientService.supportsToolCalling(modelProfile);
|
||||||
|
}
|
||||||
|
return clientService.supportsToolCalling(this.modelCategory(context));
|
||||||
|
}
|
||||||
|
|
||||||
default AgentResponseHandler responseHandler(final AgentContext context) {
|
default AgentResponseHandler responseHandler(final AgentContext context) {
|
||||||
return new TextAgentResponseHandler("response");
|
return new TextAgentResponseHandler("response");
|
||||||
@@ -115,13 +153,18 @@ public interface BaseAgent {
|
|||||||
split.context().getGraph().add(this.name());
|
split.context().getGraph().add(this.name());
|
||||||
|
|
||||||
if (!skipCompletionInSplit) {
|
if (!skipCompletionInSplit) {
|
||||||
|
final var completionClient = Objects.requireNonNull(client);
|
||||||
final boolean skipCache = split.context().isSkipCache() || split.context().isAlwaysSkipCache();
|
final boolean skipCache = split.context().isSkipCache() || split.context().isAlwaysSkipCache();
|
||||||
split.context().setSkipCache(false);
|
split.context().setSkipCache(false);
|
||||||
final var conversation = this.setupConversation(split);
|
final var conversation = this.setupConversation(split);
|
||||||
|
final var toolCallData = completionClient.supportsToolCalling() ? this.toolCallData(split.context()) : null;
|
||||||
|
this.rememberToolCallData(split.context(), toolCallData);
|
||||||
|
|
||||||
boolean parseable = false;
|
boolean parseable = false;
|
||||||
Exception parseException = null;
|
Exception parseException = null;
|
||||||
for (int i = 0; i < 2; i++) {
|
int parseAttempts = 0;
|
||||||
|
int toolCallRounds = 0;
|
||||||
|
while (parseAttempts < 2) {
|
||||||
LOGGER.debug("Conversation: {}", conversation);
|
LOGGER.debug("Conversation: {}", conversation);
|
||||||
|
|
||||||
final var modelProfile = this.modelProfile(split.context());
|
final var modelProfile = this.modelProfile(split.context());
|
||||||
@@ -141,33 +184,46 @@ public interface BaseAgent {
|
|||||||
};
|
};
|
||||||
if (modelProfile != null) {
|
if (modelProfile != null) {
|
||||||
requestStart = System.currentTimeMillis();
|
requestStart = System.currentTimeMillis();
|
||||||
completion = client.streamConversation(modelProfile, conversation, chunkConsumer, skipCache);
|
completion = completionClient.streamConversation(modelProfile, conversation, chunkConsumer, toolCallData, skipCache);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
requestStart = System.currentTimeMillis();
|
requestStart = System.currentTimeMillis();
|
||||||
completion = client.streamConversation(this.modelCategory(split.context()), conversation, chunkConsumer, skipCache);
|
completion = completionClient.streamConversation(this.modelCategory(split.context()), conversation, chunkConsumer, toolCallData, skipCache);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
if (modelProfile != null) {
|
if (modelProfile != null) {
|
||||||
requestStart = System.currentTimeMillis();
|
requestStart = System.currentTimeMillis();
|
||||||
completion = client.conversation(modelProfile, conversation, skipCache);
|
completion = completionClient.conversation(modelProfile, conversation, toolCallData, skipCache);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
requestStart = System.currentTimeMillis();
|
requestStart = System.currentTimeMillis();
|
||||||
completion = client.conversation(this.modelCategory(split.context()), conversation, skipCache);
|
completion = completionClient.conversation(this.modelCategory(split.context()), conversation, toolCallData, skipCache);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final var durationMs = System.currentTimeMillis() - requestStart;
|
final var durationMs = System.currentTimeMillis() - requestStart;
|
||||||
split.context().getGraph().add("(" + durationMs + " ms)");
|
split.context().getGraph().add("(" + durationMs + " ms)");
|
||||||
split.context().setLastCompletion(completion);
|
split.context().setLastCompletion(completion);
|
||||||
split.context().getRequestStatistic().add(completion);
|
split.context().getRequestStatistic().add(completion);
|
||||||
|
if (!completion.toolCalls().isEmpty() && responseHandler instanceof final ToolCallingAgentResponseHandler toolCallingAgentResponseHandler) {
|
||||||
|
if (toolCallRounds >= 4) {
|
||||||
|
parseException = new IllegalStateException("Too many tool call rounds");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
final var toolCallResults = new ToolCallingAgentResponseHandler.ToolCallResults();
|
||||||
|
if (toolCallingAgentResponseHandler.handleToolCalls(completion, split.context(), toolCallResults)) {
|
||||||
|
toolCallRounds++;
|
||||||
|
this.appendToolCallResults(conversation, completion, toolCallResults.results());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!responseHandler.handle(completion, split.context())) {
|
if (!responseHandler.handle(completion, split.context())) {
|
||||||
split.context().getGraph().add("⚠");
|
split.context().getGraph().add("⚠");
|
||||||
LOGGER.warn("{} Cannot parse response: {}", this.name(), completion.response());
|
LOGGER.warn("{} Cannot parse response: {}", this.name(), completion.response());
|
||||||
split.context().setSkipCache(true);
|
split.context().setSkipCache(true);
|
||||||
|
parseAttempts++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +235,7 @@ public interface BaseAgent {
|
|||||||
split.context().getGraph().add("⚠");
|
split.context().getGraph().add("⚠");
|
||||||
LOGGER.warn("{} Cannot parse response: {}", this.name(), completion.response(), e);
|
LOGGER.warn("{} Cannot parse response: {}", this.name(), completion.response(), e);
|
||||||
split.context().setSkipCache(true);
|
split.context().setSkipCache(true);
|
||||||
|
parseAttempts++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,6 +305,13 @@ public interface BaseAgent {
|
|||||||
}
|
}
|
||||||
return List.of(first);
|
return List.of(first);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void appendToolCallResults(final Conversation conversation, final Completion completion, final List<ToolCallingAgentResponseHandler.ToolCallResult> toolCallResults) {
|
||||||
|
conversation.addTurn(new Conversation.Turn(TurnType.ASSISTANT, completion.response() == null ? "" : completion.response(), completion.toolCalls(), null));
|
||||||
|
for (final var toolCallResult : toolCallResults) {
|
||||||
|
conversation.addTurn(new Conversation.Turn(TurnType.TOOL, toolCallResult.output(), null, toolCallResult.toolCallId()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private List<Split> applyTemplate(final AgentContext context) {
|
private List<Split> applyTemplate(final AgentContext context) {
|
||||||
String text = this.textTemplate() != null ? this.textTemplate() : "";
|
String text = this.textTemplate() != null ? this.textTemplate() : "";
|
||||||
@@ -358,6 +422,35 @@ public interface BaseAgent {
|
|||||||
}
|
}
|
||||||
return conversation;
|
return conversation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void appendLastCompletionToConversation(final AgentContext context, final boolean skipCompletion) {
|
||||||
|
if (skipCompletion || context.getConversation() == null || context.getLastCompletion() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final var currentAgent = context.getAgents().get(context.getCurrentAgentName());
|
||||||
|
if (currentAgent == null || currentAgent.agentType(context) == AgentType.PROCESSING_ONLY) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (context.getConversation().getConversation().getLast().type() == TurnType.ASSISTANT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final var completion = context.getLastCompletion();
|
||||||
|
if ((completion.response() == null || completion.response().isEmpty()) && completion.toolCalls().isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.getConversation().addTurn(new Conversation.Turn(TurnType.ASSISTANT, completion.response() == null ? "" : completion.response(), completion.toolCalls(), null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void rememberToolCallData(final AgentContext context, final ToolCallData toolCallData) {
|
||||||
|
if (toolCallData == null || !toolCallData.hasTools()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.setToolCallData(toolCallData);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clearRememberedToolCallData(final AgentContext context) {
|
||||||
|
context.setToolCallData(null);
|
||||||
|
}
|
||||||
|
|
||||||
private void nextAgent(final AgentContext context, final boolean skipCompletion) {
|
private void nextAgent(final AgentContext context, final boolean skipCompletion) {
|
||||||
context.setNextAgent(this.chooseNextAgent(context));
|
context.setNextAgent(this.chooseNextAgent(context));
|
||||||
@@ -388,11 +481,13 @@ public interface BaseAgent {
|
|||||||
case final BaseAgent a when a.agentType(context) == AgentType.COMPLETION -> {
|
case final BaseAgent a when a.agentType(context) == AgentType.COMPLETION -> {
|
||||||
context.getGraph().add("→");
|
context.getGraph().add("→");
|
||||||
context.setConversation(null);
|
context.setConversation(null);
|
||||||
|
this.clearRememberedToolCallData(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
case final BaseAgent a when a.agentType(context) == AgentType.START_CONVERSATION || a.agentType(context) == AgentType.START_CONVERSATION_WITH_SYSTEM_PROMPT -> {
|
case final BaseAgent a when a.agentType(context) == AgentType.START_CONVERSATION || a.agentType(context) == AgentType.START_CONVERSATION_WITH_SYSTEM_PROMPT -> {
|
||||||
context.getGraph().add("🗨");
|
context.getGraph().add("🗨");
|
||||||
context.setConversation(null);
|
context.setConversation(null);
|
||||||
|
this.clearRememberedToolCallData(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
case final BaseAgent a when a.agentType(context) == AgentType.CONVERSATION || a.agentType(context) == AgentType.CONVERSATION_WITH_SYSTEM_PROMPT -> {
|
case final BaseAgent a when a.agentType(context) == AgentType.CONVERSATION || a.agentType(context) == AgentType.CONVERSATION_WITH_SYSTEM_PROMPT -> {
|
||||||
@@ -402,15 +497,12 @@ public interface BaseAgent {
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
context.getGraph().add("↴");
|
context.getGraph().add("↴");
|
||||||
final var currentAgent = context.getAgents().get(context.getCurrentAgentName());
|
this.appendLastCompletionToConversation(context, skipCompletion);
|
||||||
if (! skipCompletion && currentAgent.agentType(context) != AgentType.PROCESSING_ONLY && context.getConversation().getConversation().getLast().type() != TurnType.ASSISTANT) {
|
|
||||||
context.getConversation().addTurn(new Conversation.Turn(TurnType.ASSISTANT, context.getLastCompletion().response()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case final BaseAgent a when a.agentType(context) == AgentType.PROCESSING_ONLY -> {
|
case final BaseAgent a when a.agentType(context) == AgentType.PROCESSING_ONLY -> {
|
||||||
|
this.appendLastCompletionToConversation(context, skipCompletion);
|
||||||
}
|
}
|
||||||
|
|
||||||
default -> {
|
default -> {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package de.extio.lmlib.agent.responsehandler;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import de.extio.lmlib.agent.AgentContext;
|
||||||
|
import de.extio.lmlib.client.Completion;
|
||||||
|
import de.extio.lmlib.client.ToolCall;
|
||||||
|
import de.extio.lmlib.client.ToolParameters;
|
||||||
|
|
||||||
|
public class ToolCallingAgentResponseHandler implements AgentResponseHandler {
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface ToolCallHandler {
|
||||||
|
|
||||||
|
boolean handle(Completion completion, AgentContext context, ToolCallResults toolCallResults);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ToolCallResult(String toolCallId, String output) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class ToolCallResults {
|
||||||
|
|
||||||
|
private final List<ToolCallResult> toolCallResults = new ArrayList<>();
|
||||||
|
|
||||||
|
public ToolCallResults add(final ToolCall toolCall, final String output) {
|
||||||
|
return this.add(toolCall.id(), output);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ToolCallResults add(final ToolCall toolCall, final ToolParameters output) {
|
||||||
|
return this.add(toolCall.id(), output.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public ToolCallResults add(final String toolCallId, final String output) {
|
||||||
|
this.toolCallResults.add(new ToolCallResult(toolCallId, output));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ToolCallResult> results() {
|
||||||
|
return List.copyOf(this.toolCallResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private final AgentResponseHandler delegate;
|
||||||
|
|
||||||
|
private final ToolCallHandler toolCallHandler;
|
||||||
|
|
||||||
|
public ToolCallingAgentResponseHandler(final AgentResponseHandler delegate, final ToolCallHandler toolCallHandler) {
|
||||||
|
this.delegate = delegate;
|
||||||
|
this.toolCallHandler = toolCallHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean handle(final Completion completion, final AgentContext context) {
|
||||||
|
return this.delegate == null || this.delegate.handle(completion, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean handleToolCalls(final Completion completion, final AgentContext context, final ToolCallResults toolCallResults) {
|
||||||
|
return this.toolCallHandler.handle(completion, context, toolCallResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -8,13 +8,15 @@ import de.extio.lmlib.profile.ModelProfile.ModelProvider;
|
|||||||
|
|
||||||
public interface Client {
|
public interface Client {
|
||||||
|
|
||||||
Completion conversation(ModelCategory modelCategory, Conversation conversation, boolean skipCache);
|
Completion conversation(ModelCategory modelCategory, Conversation conversation, ToolCallData toolCallData, boolean skipCache);
|
||||||
|
|
||||||
Completion conversation(ModelProfile modelProfile, Conversation conversation, boolean skipCache);
|
|
||||||
|
|
||||||
Completion streamConversation(ModelCategory modelCategory, Conversation conversation, Consumer<Chunk> chunkConsumer, boolean skipCache);
|
Completion conversation(ModelProfile modelProfile, Conversation conversation, ToolCallData toolCallData, boolean skipCache);
|
||||||
|
|
||||||
|
Completion streamConversation(ModelCategory modelCategory, Conversation conversation, Consumer<Chunk> chunkConsumer, ToolCallData toolCallData, boolean skipCache);
|
||||||
|
|
||||||
Completion streamConversation(ModelProfile modelProfile, Conversation conversation, Consumer<Chunk> chunkConsumer, boolean skipCache);
|
Completion streamConversation(ModelProfile modelProfile, Conversation conversation, Consumer<Chunk> chunkConsumer, ToolCallData toolCallData, boolean skipCache);
|
||||||
|
|
||||||
ModelProvider getModelProvider();
|
ModelProvider getModelProvider();
|
||||||
|
|
||||||
|
boolean supportsToolCalling();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ public class ClientService {
|
|||||||
final var modelProfile = this.modelProfileService.getModelProfile(category.getModelProfile(), category);
|
final var modelProfile = this.modelProfileService.getModelProfile(category.getModelProfile(), category);
|
||||||
return this.getClient(modelProfile);
|
return this.getClient(modelProfile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean supportsToolCalling(final ModelCategory category) {
|
||||||
|
return this.getClient(category).supportsToolCalling();
|
||||||
|
}
|
||||||
|
|
||||||
@Cacheable("clients")
|
@Cacheable("clients")
|
||||||
public Client getClient(final ModelProfile modelProfile) {
|
public Client getClient(final ModelProfile modelProfile) {
|
||||||
@@ -41,6 +45,10 @@ public class ClientService {
|
|||||||
.map(client -> this.completionInterceptors == null || this.completionInterceptors.isEmpty() ? client : new InterceptingClient(client, this.modelProfileService, 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 " + modelProfile.modelName()));
|
.orElseThrow(() -> new IllegalStateException("No client found for model profile " + modelProfile.modelName()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean supportsToolCalling(final ModelProfile modelProfile) {
|
||||||
|
return this.getClient(modelProfile).supportsToolCalling();
|
||||||
|
}
|
||||||
|
|
||||||
private static class InterceptingClient implements Client {
|
private static class InterceptingClient implements Client {
|
||||||
|
|
||||||
@@ -57,24 +65,24 @@ public class ClientService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Completion conversation(final ModelCategory modelCategory, final Conversation conversation_, final boolean skipCache) {
|
public Completion conversation(final ModelCategory modelCategory, final Conversation conversation_, final ToolCallData toolCallData, final boolean skipCache) {
|
||||||
final var modelProfile = this.modelProfileService.getModelProfile(modelCategory.getModelProfile(), modelCategory);
|
final var modelProfile = this.modelProfileService.getModelProfile(modelCategory.getModelProfile(), modelCategory);
|
||||||
return this.conversation(modelProfile, conversation_, skipCache);
|
return this.conversation(modelProfile, conversation_, toolCallData, skipCache);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Completion streamConversation(final ModelCategory modelCategory, final Conversation conversation_, final Consumer<Chunk> chunkConsumer, final boolean skipCache) {
|
public Completion streamConversation(final ModelCategory modelCategory, final Conversation conversation_, final Consumer<Chunk> chunkConsumer, final ToolCallData toolCallData, final boolean skipCache) {
|
||||||
final var modelProfile = this.modelProfileService.getModelProfile(modelCategory.getModelProfile(), modelCategory);
|
final var modelProfile = this.modelProfileService.getModelProfile(modelCategory.getModelProfile(), modelCategory);
|
||||||
return this.streamConversation(modelProfile, conversation_, chunkConsumer, skipCache);
|
return this.streamConversation(modelProfile, conversation_, chunkConsumer, toolCallData, skipCache);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Completion conversation(final ModelProfile modelProfile, final Conversation conversation_, final boolean skipCache) {
|
public Completion conversation(final ModelProfile modelProfile, final Conversation conversation_, final ToolCallData toolCallData, final boolean skipCache) {
|
||||||
Conversation conversation = conversation_;
|
Conversation conversation = conversation_;
|
||||||
for (final var interceptor : this.completionInterceptors) {
|
for (final var interceptor : this.completionInterceptors) {
|
||||||
conversation = interceptor.before(modelProfile, conversation);
|
conversation = interceptor.before(modelProfile, conversation);
|
||||||
}
|
}
|
||||||
var completion = this.client.conversation(modelProfile, conversation, skipCache);
|
var completion = this.client.conversation(modelProfile, conversation, toolCallData, skipCache);
|
||||||
for (final var interceptor : this.completionInterceptors) {
|
for (final var interceptor : this.completionInterceptors) {
|
||||||
completion = interceptor.after(modelProfile, conversation, completion);
|
completion = interceptor.after(modelProfile, conversation, completion);
|
||||||
}
|
}
|
||||||
@@ -82,12 +90,12 @@ public class ClientService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Completion streamConversation(final ModelProfile modelProfile, final Conversation conversation_, final Consumer<Chunk> chunkConsumer, final boolean skipCache) {
|
public Completion streamConversation(final ModelProfile modelProfile, final Conversation conversation_, final Consumer<Chunk> chunkConsumer, final ToolCallData toolCallData, final boolean skipCache) {
|
||||||
Conversation conversation = conversation_;
|
Conversation conversation = conversation_;
|
||||||
for (final var interceptor : this.completionInterceptors) {
|
for (final var interceptor : this.completionInterceptors) {
|
||||||
conversation = interceptor.before(modelProfile, conversation);
|
conversation = interceptor.before(modelProfile, conversation);
|
||||||
}
|
}
|
||||||
var completion = this.client.streamConversation(modelProfile, conversation, chunkConsumer, skipCache);
|
var completion = this.client.streamConversation(modelProfile, conversation, chunkConsumer, toolCallData, skipCache);
|
||||||
for (final var interceptor : this.completionInterceptors) {
|
for (final var interceptor : this.completionInterceptors) {
|
||||||
completion = interceptor.after(modelProfile, conversation, completion);
|
completion = interceptor.after(modelProfile, conversation, completion);
|
||||||
}
|
}
|
||||||
@@ -98,6 +106,11 @@ public class ClientService {
|
|||||||
public ModelProvider getModelProvider() {
|
public ModelProvider getModelProvider() {
|
||||||
return this.client.getModelProvider();
|
return this.client.getModelProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supportsToolCalling() {
|
||||||
|
return this.client.supportsToolCalling();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
package de.extio.lmlib.client;
|
package de.extio.lmlib.client;
|
||||||
|
|
||||||
public final record Completion(String response, String reasoning, CompletionFinishReason finishReason, CompletionStatistics statistics) {
|
import java.util.List;
|
||||||
|
|
||||||
|
public final record Completion(String response, String reasoning, CompletionFinishReason finishReason, CompletionStatistics statistics, List<ToolCall> toolCalls) {
|
||||||
|
|
||||||
|
public Completion {
|
||||||
|
toolCalls = toolCalls == null ? List.of() : List.copyOf(toolCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Completion(final String response, final String reasoning, final CompletionFinishReason finishReason, final CompletionStatistics statistics) {
|
||||||
|
this(response, reasoning, finishReason, statistics, List.of());
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ public enum CompletionFinishReason {
|
|||||||
/**
|
/**
|
||||||
* Completion was stopped because the content was filtered.
|
* Completion was stopped because the content was filtered.
|
||||||
*/
|
*/
|
||||||
CONTENT_FILTERED
|
CONTENT_FILTERED,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Completion stopped because the model emitted one or more tool calls.
|
||||||
|
*/
|
||||||
|
TOOL_CALLS
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -80,10 +80,18 @@ public final class Conversation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public enum TurnType {
|
public enum TurnType {
|
||||||
SYSTEM, USER, ASSISTANT
|
SYSTEM, USER, ASSISTANT, TOOL
|
||||||
}
|
}
|
||||||
|
|
||||||
public record Turn(TurnType type, String text) {
|
public record Turn(TurnType type, String text, List<ToolCall> toolCalls, String toolCallId) {
|
||||||
|
|
||||||
|
public Turn {
|
||||||
|
toolCalls = toolCalls == null ? List.of() : List.copyOf(toolCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Turn(final TurnType type, final String text) {
|
||||||
|
this(type, text, List.of(), null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
5
src/main/java/de/extio/lmlib/client/ToolCall.java
Normal file
5
src/main/java/de/extio/lmlib/client/ToolCall.java
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
package de.extio.lmlib.client;
|
||||||
|
|
||||||
|
public record ToolCall(String id, String type, String name, String arguments) {
|
||||||
|
|
||||||
|
}
|
||||||
34
src/main/java/de/extio/lmlib/client/ToolCallData.java
Normal file
34
src/main/java/de/extio/lmlib/client/ToolCallData.java
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package de.extio.lmlib.client;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record ToolCallData(List<ToolDefinition> tools, String toolChoice, String forcedToolName, Boolean parallelToolCalls) {
|
||||||
|
|
||||||
|
public static final String TOOL_CHOICE_AUTO = "auto";
|
||||||
|
|
||||||
|
public static final String TOOL_CHOICE_REQUIRED = "required";
|
||||||
|
|
||||||
|
public static final String TOOL_CHOICE_NONE = "none";
|
||||||
|
|
||||||
|
public ToolCallData {
|
||||||
|
tools = tools == null ? List.of() : List.copyOf(tools);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ToolCallData auto(final Collection<ToolDefinition> tools) {
|
||||||
|
return new ToolCallData(List.copyOf(tools), TOOL_CHOICE_AUTO, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ToolCallData required(final Collection<ToolDefinition> tools) {
|
||||||
|
return new ToolCallData(List.copyOf(tools), TOOL_CHOICE_REQUIRED, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ToolCallData force(final Collection<ToolDefinition> tools, final String forcedToolName) {
|
||||||
|
return new ToolCallData(List.copyOf(tools), null, forcedToolName, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasTools() {
|
||||||
|
return !this.tools.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
26
src/main/java/de/extio/lmlib/client/ToolDefinition.java
Normal file
26
src/main/java/de/extio/lmlib/client/ToolDefinition.java
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package de.extio.lmlib.client;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public record ToolDefinition(String name, String description, Map<String, Object> parameters, Boolean strict) {
|
||||||
|
|
||||||
|
public ToolDefinition {
|
||||||
|
if (name == null || name.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Tool name cannot be null or blank");
|
||||||
|
}
|
||||||
|
parameters = parameters == null ? Map.of() : Map.copyOf(parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ToolDefinition(final String name, final String description, final Map<String, Object> parameters) {
|
||||||
|
this(name, description, parameters, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ToolDefinition(final String name, final String description, final ToolParameters parameters, final Boolean strict) {
|
||||||
|
this(name, description, parameters != null ? parameters.map() : null, strict);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ToolDefinition(final String name, final String description, final ToolParameters parameters) {
|
||||||
|
this(name, description, parameters != null ? parameters.map() : null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
83
src/main/java/de/extio/lmlib/client/ToolParameters.java
Normal file
83
src/main/java/de/extio/lmlib/client/ToolParameters.java
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package de.extio.lmlib.client;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
public final class ToolParameters {
|
||||||
|
|
||||||
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
private final LinkedHashMap<String, Object> values = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
private ToolParameters() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ToolParameters create() {
|
||||||
|
return new ToolParameters();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ToolParameters create(final Map<String, String> properties) {
|
||||||
|
final var schemaProperties = ToolParameters.create();
|
||||||
|
for (final var entry : properties.entrySet()) {
|
||||||
|
schemaProperties.add(entry.getKey(), ToolParameters.create()
|
||||||
|
.add("type", "string")
|
||||||
|
.add("description", entry.getValue()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ToolParameters()
|
||||||
|
.add("type", "object")
|
||||||
|
.add("properties", schemaProperties)
|
||||||
|
.add("required", List.copyOf(properties.keySet()))
|
||||||
|
.add("additionalProperties", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ToolParameters add(final String key, final Object value) {
|
||||||
|
this.values.put(key, this.normalize(value));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> map() {
|
||||||
|
return Map.copyOf(this.values);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String json() {
|
||||||
|
try {
|
||||||
|
return OBJECT_MAPPER.writeValueAsString(this.values);
|
||||||
|
}
|
||||||
|
catch (final Exception e) {
|
||||||
|
throw new IllegalStateException("Cannot serialize tool parameters", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object normalize(final Object value) {
|
||||||
|
return switch (value) {
|
||||||
|
case null -> null;
|
||||||
|
case final ToolParameters toolParameters -> toolParameters.map();
|
||||||
|
case final Map<?, ?> map -> this.normalizeMap(map);
|
||||||
|
case final Collection<?> collection -> this.normalizeCollection(collection);
|
||||||
|
default -> value;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> normalizeMap(final Map<?, ?> map) {
|
||||||
|
final var normalized = new LinkedHashMap<String, Object>();
|
||||||
|
for (final var entry : map.entrySet()) {
|
||||||
|
normalized.put(String.valueOf(entry.getKey()), this.normalize(entry.getValue()));
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Object> normalizeCollection(final Collection<?> collection) {
|
||||||
|
final var normalized = new ArrayList<Object>(collection.size());
|
||||||
|
for (final var item : collection) {
|
||||||
|
normalized.add(this.normalize(item));
|
||||||
|
}
|
||||||
|
return List.copyOf(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -9,18 +9,22 @@ import java.util.HexFormat;
|
|||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
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;
|
||||||
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.client.ToolCallData;
|
||||||
import de.extio.lmlib.profile.ModelCategory;
|
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.profile.ModelProfileService;
|
import de.extio.lmlib.profile.ModelProfileService;
|
||||||
|
|
||||||
public class CachedClient implements Client {
|
public class CachedClient implements Client {
|
||||||
|
|
||||||
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
private final CachedClientRepository cacheRepository;
|
private final CachedClientRepository cacheRepository;
|
||||||
|
|
||||||
@@ -35,18 +39,18 @@ public class CachedClient implements Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Completion conversation(final ModelCategory modelCategory, final Conversation conversation, final boolean skipCache) {
|
public Completion conversation(final ModelCategory modelCategory, final Conversation conversation, final ToolCallData toolCallData, final boolean skipCache) {
|
||||||
return this.getCachedResponse(modelCategory, null, skipCache, () -> this.client.conversation(modelCategory, conversation, skipCache), conversation.getConversation().toString());
|
return this.getCachedResponse(modelCategory, null, skipCache, () -> this.client.conversation(modelCategory, conversation, toolCallData, skipCache), conversation.getConversation().toString(), this.serializeToolCallData(toolCallData));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Completion conversation(final ModelProfile modelProfile, final Conversation conversation, final boolean skipCache) {
|
public Completion conversation(final ModelProfile modelProfile, final Conversation conversation, final ToolCallData toolCallData, final boolean skipCache) {
|
||||||
return this.getCachedResponse(null, modelProfile, skipCache, () -> this.client.conversation(modelProfile, conversation, skipCache), conversation.getConversation().toString());
|
return this.getCachedResponse(null, modelProfile, skipCache, () -> this.client.conversation(modelProfile, conversation, toolCallData, skipCache), conversation.getConversation().toString(), this.serializeToolCallData(toolCallData));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Completion streamConversation(final ModelCategory modelCategory, final Conversation conversation, final Consumer<Chunk> chunkConsumer, final boolean skipCache) {
|
public Completion streamConversation(final ModelCategory modelCategory, final Conversation conversation, final Consumer<Chunk> chunkConsumer, final ToolCallData toolCallData, final boolean skipCache) {
|
||||||
final var response = this.getCachedResponse(modelCategory, null, skipCache, () -> this.client.streamConversation(modelCategory, conversation, chunkConsumer, skipCache), conversation.getConversation().toString());
|
final var response = this.getCachedResponse(modelCategory, null, skipCache, () -> this.client.streamConversation(modelCategory, conversation, chunkConsumer, toolCallData, skipCache), conversation.getConversation().toString(), this.serializeToolCallData(toolCallData));
|
||||||
if (response.statistics().cached()) {
|
if (response.statistics().cached()) {
|
||||||
chunkConsumer.accept(new Chunk(response.response(), response.reasoning()));
|
chunkConsumer.accept(new Chunk(response.response(), response.reasoning()));
|
||||||
}
|
}
|
||||||
@@ -54,8 +58,8 @@ public class CachedClient implements Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Completion streamConversation(final ModelProfile modelProfile, final Conversation conversation, final Consumer<Chunk> chunkConsumer, final boolean skipCache) {
|
public Completion streamConversation(final ModelProfile modelProfile, final Conversation conversation, final Consumer<Chunk> chunkConsumer, final ToolCallData toolCallData, final boolean skipCache) {
|
||||||
final var response = this.getCachedResponse(null, modelProfile, skipCache, () -> this.client.streamConversation(modelProfile, conversation, chunkConsumer, skipCache), conversation.getConversation().toString());
|
final var response = this.getCachedResponse(null, modelProfile, skipCache, () -> this.client.streamConversation(modelProfile, conversation, chunkConsumer, toolCallData, skipCache), conversation.getConversation().toString(), this.serializeToolCallData(toolCallData));
|
||||||
if (response.statistics().cached()) {
|
if (response.statistics().cached()) {
|
||||||
chunkConsumer.accept(new Chunk(response.response(), response.reasoning()));
|
chunkConsumer.accept(new Chunk(response.response(), response.reasoning()));
|
||||||
}
|
}
|
||||||
@@ -66,21 +70,38 @@ public class CachedClient implements Client {
|
|||||||
public ModelProvider getModelProvider() {
|
public ModelProvider getModelProvider() {
|
||||||
return this.client.getModelProvider();
|
return this.client.getModelProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supportsToolCalling() {
|
||||||
|
return this.client.supportsToolCalling();
|
||||||
|
}
|
||||||
|
|
||||||
private Completion getCachedResponse(final ModelCategory modelCategory, final ModelProfile modelProfile, final boolean skipCache, final Supplier<Completion> supplier, final String... keys) {
|
private Completion getCachedResponse(final ModelCategory modelCategory, final ModelProfile modelProfile, final boolean skipCache, final Supplier<Completion> supplier, final String... keys) {
|
||||||
final var key = this.createCacheKey(modelCategory, modelProfile, keys);
|
final var key = this.createCacheKey(modelCategory, modelProfile, keys);
|
||||||
|
|
||||||
final var cachedCompletion = skipCache ? null : this.cacheRepository.get(key);
|
final var cachedCompletion = skipCache ? null : this.cacheRepository.get(key);
|
||||||
if (cachedCompletion != null) {
|
if (cachedCompletion != null) {
|
||||||
return new Completion(cachedCompletion.response(), cachedCompletion.reasoning(), CompletionFinishReason.DONE, new CompletionStatistics(0, Duration.ZERO, cachedCompletion.inTokens(), cachedCompletion.cachedInTokens(), cachedCompletion.outTokens(), cachedCompletion.reasoningOutTokens(), BigDecimal.ZERO, true));
|
return new Completion(cachedCompletion.response(), cachedCompletion.reasoning(), cachedCompletion.finishReason(), new CompletionStatistics(0, Duration.ZERO, cachedCompletion.inTokens(), cachedCompletion.cachedInTokens(), cachedCompletion.outTokens(), cachedCompletion.reasoningOutTokens(), BigDecimal.ZERO, true), cachedCompletion.toolCalls());
|
||||||
}
|
}
|
||||||
|
|
||||||
final var completion = supplier.get();
|
final var completion = supplier.get();
|
||||||
if (completion != null) {
|
if (completion != null) {
|
||||||
this.cacheRepository.put(key, new CachedCompletion(completion.response(), completion.reasoning(), completion.finishReason(), completion.statistics().inTokens(), completion.statistics().cachedInTokens(), completion.statistics().outTokens(), completion.statistics().reasoningOutTokens(), OffsetDateTime.now()));
|
this.cacheRepository.put(key, new CachedCompletion(completion.response(), completion.reasoning(), completion.finishReason(), completion.toolCalls(), completion.statistics().inTokens(), completion.statistics().cachedInTokens(), completion.statistics().outTokens(), completion.statistics().reasoningOutTokens(), OffsetDateTime.now()));
|
||||||
}
|
}
|
||||||
return completion;
|
return completion;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String serializeToolCallData(final ToolCallData toolCallData) {
|
||||||
|
if (toolCallData == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return OBJECT_MAPPER.writeValueAsString(toolCallData);
|
||||||
|
}
|
||||||
|
catch (final Exception e) {
|
||||||
|
return toolCallData.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private String createCacheKey(ModelCategory modelCategory, ModelProfile modelProfile, final String... keys) {
|
private String createCacheKey(ModelCategory modelCategory, ModelProfile modelProfile, final String... keys) {
|
||||||
if (modelProfile == null) {
|
if (modelProfile == null) {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package de.extio.lmlib.client.cached;
|
package de.extio.lmlib.client.cached;
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
import de.extio.lmlib.client.CompletionFinishReason;
|
import de.extio.lmlib.client.CompletionFinishReason;
|
||||||
|
import de.extio.lmlib.client.ToolCall;
|
||||||
|
|
||||||
public record CachedCompletion(String response, String reasoning, CompletionFinishReason finishReason, long inTokens, long cachedInTokens, long outTokens, long reasoningOutTokens, OffsetDateTime created) {
|
public record CachedCompletion(String response, String reasoning, CompletionFinishReason finishReason, List<ToolCall> toolCalls, long inTokens, long cachedInTokens, long outTokens, long reasoningOutTokens, OffsetDateTime created) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import de.extio.lmlib.client.Client;
|
|||||||
import de.extio.lmlib.client.Completion;
|
import de.extio.lmlib.client.Completion;
|
||||||
import de.extio.lmlib.client.CompletionFinishReason;
|
import de.extio.lmlib.client.CompletionFinishReason;
|
||||||
import de.extio.lmlib.client.Conversation;
|
import de.extio.lmlib.client.Conversation;
|
||||||
|
import de.extio.lmlib.client.ToolCallData;
|
||||||
import de.extio.lmlib.client.oai.ModelNameSupplier;
|
import de.extio.lmlib.client.oai.ModelNameSupplier;
|
||||||
import de.extio.lmlib.profile.ModelCategory;
|
import de.extio.lmlib.profile.ModelCategory;
|
||||||
import de.extio.lmlib.profile.ModelProfile;
|
import de.extio.lmlib.profile.ModelProfile;
|
||||||
@@ -82,37 +83,38 @@ public abstract class AbstractCompletionClient implements Client, DisposableBean
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Completion conversation(final ModelCategory modelCategory, final Conversation conversation, final boolean skipCache) {
|
public Completion conversation(final ModelCategory modelCategory, final Conversation conversation, final ToolCallData toolCallData, final boolean skipCache) {
|
||||||
return this.streamConversation(modelCategory, conversation, null, skipCache);
|
return this.streamConversation(modelCategory, conversation, null, toolCallData, skipCache);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Completion conversation(final ModelProfile modelProfile, final Conversation conversation, final boolean skipCache) {
|
public Completion conversation(final ModelProfile modelProfile, final Conversation conversation, final ToolCallData toolCallData, final boolean skipCache) {
|
||||||
return this.streamConversation(modelProfile, conversation, null, skipCache);
|
return this.streamConversation(modelProfile, conversation, null, toolCallData, skipCache);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Completion streamConversation(final ModelCategory modelCategory_, final Conversation conversation, final Consumer<Chunk> chunkConsumer, final boolean skipCache) {
|
public Completion streamConversation(final ModelCategory modelCategory_, final Conversation conversation, final Consumer<Chunk> chunkConsumer, final ToolCallData toolCallData, final boolean skipCache) {
|
||||||
final ModelCategory modelCategory = (modelCategory_ == null) ? ModelCategory.MEDIUM : modelCategory_;
|
final ModelCategory modelCategory = (modelCategory_ == null) ? ModelCategory.MEDIUM : modelCategory_;
|
||||||
final var modelProfile = this.modelProfileService.getModelProfile(modelCategory.getModelProfile(), modelCategory);
|
final var modelProfile = this.modelProfileService.getModelProfile(modelCategory.getModelProfile(), modelCategory);
|
||||||
return this.streamConversation(modelProfile, conversation, chunkConsumer, skipCache);
|
return this.streamConversation(modelProfile, conversation, chunkConsumer, toolCallData, skipCache);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Completion streamConversation(final ModelProfile modelProfile, final Conversation conversation, final Consumer<Chunk> chunkConsumer, final boolean skipCache) {
|
public Completion streamConversation(final ModelProfile modelProfile, final Conversation conversation, final Consumer<Chunk> chunkConsumer, final ToolCallData toolCallData, final boolean skipCache) {
|
||||||
if (modelProfile == null || (modelProfile.modelProvider() != ModelProvider.OAI_TEXT_COMPLETION && modelProfile.modelProvider() != ModelProvider.OAI_CHAT_COMPLETION)) {
|
if (modelProfile == null || (modelProfile.modelProvider() != ModelProvider.OAI_TEXT_COMPLETION && modelProfile.modelProvider() != ModelProvider.OAI_CHAT_COMPLETION)) {
|
||||||
throw new IllegalArgumentException("Invalid ModelProfile");
|
throw new IllegalArgumentException("Invalid ModelProfile");
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.requestCompletion(conversation, modelProfile, chunkConsumer);
|
return this.requestCompletion(conversation, modelProfile, chunkConsumer, toolCallData);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected abstract Completion requestCompletion(final Conversation conversation, final ModelProfile modelProfile, final Consumer<Chunk> chunkConsumer);
|
protected abstract Completion requestCompletion(final Conversation conversation, final ModelProfile modelProfile, final Consumer<Chunk> chunkConsumer, final ToolCallData toolCallData);
|
||||||
|
|
||||||
protected CompletionFinishReason mapFinishReason(final String finishReason) {
|
protected CompletionFinishReason mapFinishReason(final String finishReason) {
|
||||||
return switch (finishReason) {
|
return switch (finishReason) {
|
||||||
case FinishReasons.FINISH_REASON_CONTENT_FILTER -> CompletionFinishReason.CONTENT_FILTERED;
|
case FinishReasons.FINISH_REASON_CONTENT_FILTER -> CompletionFinishReason.CONTENT_FILTERED;
|
||||||
case FinishReasons.FINISH_REASON_LENGTH -> CompletionFinishReason.TOKEN_LIMIT_REACHED;
|
case FinishReasons.FINISH_REASON_LENGTH -> CompletionFinishReason.TOKEN_LIMIT_REACHED;
|
||||||
|
case FinishReasons.FINISH_REASON_TOOL_CALLS -> CompletionFinishReason.TOOL_CALLS;
|
||||||
default -> CompletionFinishReason.DONE;
|
default -> CompletionFinishReason.DONE;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,5 +7,7 @@ public final class FinishReasons {
|
|||||||
public static final String FINISH_REASON_LENGTH = "length";
|
public static final String FINISH_REASON_LENGTH = "length";
|
||||||
|
|
||||||
public static final String FINISH_REASON_CONTENT_FILTER = "content_filter";
|
public static final String FINISH_REASON_CONTENT_FILTER = "content_filter";
|
||||||
|
|
||||||
|
public static final String FINISH_REASON_TOOL_CALLS = "tool_calls";
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,23 +4,24 @@ import java.io.BufferedReader;
|
|||||||
import java.io.InputStreamReader;
|
import java.io.InputStreamReader;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayDeque;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Deque;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.web.client.RestClient;
|
|
||||||
|
|
||||||
import tools.jackson.core.JacksonException;
|
import tools.jackson.core.JacksonException;
|
||||||
|
|
||||||
import de.extio.lmlib.client.Chunk;
|
import de.extio.lmlib.client.Chunk;
|
||||||
import de.extio.lmlib.client.Completion;
|
import de.extio.lmlib.client.Completion;
|
||||||
import de.extio.lmlib.client.Conversation;
|
import de.extio.lmlib.client.Conversation;
|
||||||
|
import de.extio.lmlib.client.ToolCall;
|
||||||
|
import de.extio.lmlib.client.ToolCallData;
|
||||||
|
import de.extio.lmlib.client.ToolDefinition;
|
||||||
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.ModelProfile;
|
import de.extio.lmlib.profile.ModelProfile;
|
||||||
@@ -34,13 +35,18 @@ public class ChatCompletionClient extends AbstractCompletionClient {
|
|||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supportsToolCalling() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ModelProvider getModelProvider() {
|
public ModelProvider getModelProvider() {
|
||||||
return ModelProvider.OAI_CHAT_COMPLETION;
|
return ModelProvider.OAI_CHAT_COMPLETION;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected Completion requestCompletion(final Conversation conversation, final ModelProfile modelProfile, final Consumer<Chunk> chunkConsumer) {
|
protected Completion requestCompletion(final Conversation conversation, final ModelProfile modelProfile, final Consumer<Chunk> chunkConsumer, final ToolCallData toolCallData) {
|
||||||
final var chat = createChats(conversation, modelProfile);
|
final var chat = createChats(conversation, modelProfile);
|
||||||
|
|
||||||
final var request = new ChatCompletionRequest();
|
final var request = new ChatCompletionRequest();
|
||||||
@@ -76,6 +82,11 @@ public class ChatCompletionClient extends AbstractCompletionClient {
|
|||||||
throw new IllegalArgumentException("Invalid reasoning summary details in the model profile: " + modelProfile.reasoningSummaryDetails(), e);
|
throw new IllegalArgumentException("Invalid reasoning summary details in the model profile: " + modelProfile.reasoningSummaryDetails(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (toolCallData != null && toolCallData.hasTools()) {
|
||||||
|
request.setTools(this.createTools(toolCallData.tools()));
|
||||||
|
request.setToolChoice(this.createToolChoice(toolCallData));
|
||||||
|
request.setParallelToolCalls(toolCallData.parallelToolCalls());
|
||||||
|
}
|
||||||
|
|
||||||
String requestBody;
|
String requestBody;
|
||||||
try {
|
try {
|
||||||
@@ -112,6 +123,7 @@ public class ChatCompletionClient extends AbstractCompletionClient {
|
|||||||
chatCompletionResponse.setChoices(List.of(new ChatChoice()));
|
chatCompletionResponse.setChoices(List.of(new ChatChoice()));
|
||||||
final StringBuilder contentStringBuilder = new StringBuilder();
|
final StringBuilder contentStringBuilder = new StringBuilder();
|
||||||
final StringBuilder reasoningStringBuilder = new StringBuilder();
|
final StringBuilder reasoningStringBuilder = new StringBuilder();
|
||||||
|
final List<ChatToolCall> toolCalls = new ArrayList<>();
|
||||||
|
|
||||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(clientResponse.getBody(), StandardCharsets.UTF_8))) {
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(clientResponse.getBody(), StandardCharsets.UTF_8))) {
|
||||||
String line;
|
String line;
|
||||||
@@ -162,6 +174,9 @@ public class ChatCompletionClient extends AbstractCompletionClient {
|
|||||||
if (content != null || reasoning != null) {
|
if (content != null || reasoning != null) {
|
||||||
chunkConsumer.accept(new Chunk(content, reasoning));
|
chunkConsumer.accept(new Chunk(content, reasoning));
|
||||||
}
|
}
|
||||||
|
if (streamResponse.getChoices().getFirst().getMessage().getToolCalls() != null && !streamResponse.getChoices().getFirst().getMessage().getToolCalls().isEmpty()) {
|
||||||
|
this.mergeToolCalls(toolCalls, streamResponse.getChoices().getFirst().getMessage().getToolCalls());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,6 +186,7 @@ public class ChatCompletionClient extends AbstractCompletionClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
chatCompletionResponse.getChoices().getFirst().setMessage(new ChatMessage("assistant", contentStringBuilder.toString(), reasoningStringBuilder.toString()));
|
chatCompletionResponse.getChoices().getFirst().setMessage(new ChatMessage("assistant", contentStringBuilder.toString(), reasoningStringBuilder.toString()));
|
||||||
|
chatCompletionResponse.getChoices().getFirst().getMessage().setToolCalls(List.copyOf(toolCalls));
|
||||||
return chatCompletionResponse;
|
return chatCompletionResponse;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -181,65 +197,118 @@ public class ChatCompletionClient extends AbstractCompletionClient {
|
|||||||
final var choice = response.getChoices().getFirst();
|
final var choice = response.getChoices().getFirst();
|
||||||
final var content = response.getChoices().getFirst().getMessage().getContent();
|
final var content = response.getChoices().getFirst().getMessage().getContent();
|
||||||
final var reasoning = response.getChoices().getFirst().getMessage().getReasoningContent();
|
final var reasoning = response.getChoices().getFirst().getMessage().getReasoningContent();
|
||||||
|
final var toolCalls = this.toToolCalls(response.getChoices().getFirst().getMessage().getToolCalls());
|
||||||
|
if ((content == null || content.isBlank()) && toolCalls.isEmpty()) {
|
||||||
|
throw new IllegalStateException("No parseable response content from server");
|
||||||
|
}
|
||||||
final var finishReason = this.mapFinishReason(choice.getFinishReason());
|
final var finishReason = this.mapFinishReason(choice.getFinishReason());
|
||||||
final var statistics = createCompletionStatistics(modelProfile, start, response.getUsage(), response.getTimings(), null, content, reasoning);
|
final var statistics = createCompletionStatistics(modelProfile, start, response.getUsage(), response.getTimings(), null, content, reasoning);
|
||||||
return new Completion(content, reasoning, finishReason, statistics);
|
return new Completion(content, reasoning, finishReason, statistics, toolCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ChatCompletionRequest.ChatCompletionTool> createTools(final List<ToolDefinition> toolDefinitions) {
|
||||||
|
return toolDefinitions.stream().map(toolDefinition -> {
|
||||||
|
final var function = new ChatCompletionRequest.ChatCompletionFunction();
|
||||||
|
function.setName(toolDefinition.name());
|
||||||
|
function.setDescription(toolDefinition.description());
|
||||||
|
function.setParameters(toolDefinition.parameters());
|
||||||
|
function.setStrict(toolDefinition.strict());
|
||||||
|
|
||||||
|
final var tool = new ChatCompletionRequest.ChatCompletionTool();
|
||||||
|
tool.setType("function");
|
||||||
|
tool.setFunction(function);
|
||||||
|
return tool;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object createToolChoice(final ToolCallData toolCallData) {
|
||||||
|
if (toolCallData == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (toolCallData.forcedToolName() != null && !toolCallData.forcedToolName().isBlank()) {
|
||||||
|
final var function = new ChatCompletionRequest.ChatCompletionNamedToolChoiceFunction();
|
||||||
|
function.setName(toolCallData.forcedToolName());
|
||||||
|
|
||||||
|
final var namedToolChoice = new ChatCompletionRequest.ChatCompletionNamedToolChoice();
|
||||||
|
namedToolChoice.setType("function");
|
||||||
|
namedToolChoice.setFunction(function);
|
||||||
|
return namedToolChoice;
|
||||||
|
}
|
||||||
|
return toolCallData.toolChoice();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void mergeToolCalls(final List<ChatToolCall> currentToolCalls, final List<ChatToolCall> deltaToolCalls) {
|
||||||
|
for (final var deltaToolCall : deltaToolCalls) {
|
||||||
|
final int index = deltaToolCall.getIndex() != null ? deltaToolCall.getIndex() : currentToolCalls.size();
|
||||||
|
while (currentToolCalls.size() <= index) {
|
||||||
|
currentToolCalls.add(new ChatToolCall());
|
||||||
|
}
|
||||||
|
final var currentToolCall = currentToolCalls.get(index);
|
||||||
|
if (deltaToolCall.getId() != null && !deltaToolCall.getId().isBlank()) {
|
||||||
|
currentToolCall.setId(deltaToolCall.getId());
|
||||||
|
}
|
||||||
|
if (deltaToolCall.getType() != null && !deltaToolCall.getType().isBlank()) {
|
||||||
|
currentToolCall.setType(deltaToolCall.getType());
|
||||||
|
}
|
||||||
|
if (deltaToolCall.getFunction() != null) {
|
||||||
|
if (currentToolCall.getFunction() == null) {
|
||||||
|
currentToolCall.setFunction(new ChatToolFunction());
|
||||||
|
}
|
||||||
|
if (deltaToolCall.getFunction().getName() != null && !deltaToolCall.getFunction().getName().isBlank()) {
|
||||||
|
currentToolCall.getFunction().setName(deltaToolCall.getFunction().getName());
|
||||||
|
}
|
||||||
|
if (deltaToolCall.getFunction().getArguments() != null && !deltaToolCall.getFunction().getArguments().isEmpty()) {
|
||||||
|
final var existingArguments = currentToolCall.getFunction().getArguments();
|
||||||
|
currentToolCall.getFunction().setArguments((existingArguments == null ? "" : existingArguments) + deltaToolCall.getFunction().getArguments());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ToolCall> toToolCalls(final List<ChatToolCall> chatToolCalls) {
|
||||||
|
if (chatToolCalls == null || chatToolCalls.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return chatToolCalls.stream()
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.map(toolCall -> new ToolCall(toolCall.getId(), toolCall.getType(), toolCall.getFunction() != null ? toolCall.getFunction().getName() : null, toolCall.getFunction() != null ? toolCall.getFunction().getArguments() : null))
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<ChatMessage> createChats(final Conversation conversation, final ModelProfile modelProfile) {
|
private List<ChatMessage> createChats(final Conversation conversation, final ModelProfile modelProfile) {
|
||||||
final List<ChatMessage> chat = new ArrayList<>();
|
final List<ChatMessage> chat = new ArrayList<>();
|
||||||
|
|
||||||
final Deque<Turn> turns = new ArrayDeque<>(conversation.getConversation());
|
|
||||||
Turn systemTurn = null;
|
|
||||||
Turn userTurn = null;
|
|
||||||
Turn assistantTurn = null;
|
|
||||||
boolean first = true;
|
|
||||||
|
|
||||||
final AtomicInteger tokens = new AtomicInteger();
|
final AtomicInteger tokens = new AtomicInteger();
|
||||||
|
|
||||||
while (!turns.isEmpty()) {
|
for (final Turn turn : conversation.getConversation()) {
|
||||||
final Turn turn = turns.pop();
|
|
||||||
switch (turn.type()) {
|
switch (turn.type()) {
|
||||||
case SYSTEM:
|
case SYSTEM:
|
||||||
systemTurn = turn;
|
chat.add(this.createChatMessage("system", turn));
|
||||||
break;
|
break;
|
||||||
case USER:
|
case USER:
|
||||||
userTurn = turn;
|
if (this.addChatRequestMessage("user", turn, tokens, chat, modelProfile)) {
|
||||||
|
return chat;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case ASSISTANT:
|
case ASSISTANT:
|
||||||
assistantTurn = turn;
|
if (this.addChatRequestMessage("assistant", turn, tokens, chat, modelProfile)) {
|
||||||
|
return chat;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case TOOL:
|
||||||
|
if (this.addChatRequestMessage("tool", turn, tokens, chat, modelProfile)) {
|
||||||
|
return chat;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new IllegalArgumentException("Unknown turn type: " + turn.type());
|
throw new IllegalArgumentException("Unknown turn type: " + turn.type());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (first && userTurn != null) {
|
|
||||||
if (systemTurn != null) {
|
|
||||||
chat.add(new ChatMessage("system", systemTurn.text(), null));
|
|
||||||
}
|
|
||||||
if (this.addChatRequestMessage("user", userTurn.text(), tokens, chat, modelProfile)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
userTurn = null;
|
|
||||||
first = false;
|
|
||||||
}
|
|
||||||
else if (!first && assistantTurn != null && userTurn != null) {
|
|
||||||
if (this.addChatRequestMessage("assistant", assistantTurn.text(), tokens, chat, modelProfile)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (this.addChatRequestMessage("user", userTurn.text(), tokens, chat, modelProfile)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
userTurn = null;
|
|
||||||
assistantTurn = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return chat;
|
return chat;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean addChatRequestMessage(final String role, final String text, final AtomicInteger tokens, final List<ChatMessage> chat, final ModelProfile modelProfile) {
|
private boolean addChatRequestMessage(final String role, final Turn turn, final AtomicInteger tokens, final List<ChatMessage> chat, final ModelProfile modelProfile) {
|
||||||
var prompt = text;
|
var prompt = turn.text() != null ? turn.text() : "";
|
||||||
var trimmed = false;
|
var trimmed = false;
|
||||||
|
|
||||||
final var textTokens = this.tokenizer.count(prompt, modelProfile);
|
final var textTokens = this.tokenizer.count(prompt, modelProfile);
|
||||||
@@ -252,9 +321,30 @@ public class ChatCompletionClient extends AbstractCompletionClient {
|
|||||||
trimmed = true;
|
trimmed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
chat.add(new ChatMessage(role, prompt, null));
|
final var message = this.createChatMessage(role, turn);
|
||||||
|
message.setContent(prompt);
|
||||||
|
chat.add(message);
|
||||||
|
|
||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ChatMessage createChatMessage(final String role, final Turn turn) {
|
||||||
|
final var message = new ChatMessage(role, turn.text() != null ? turn.text() : "", null);
|
||||||
|
if (!turn.toolCalls().isEmpty()) {
|
||||||
|
message.setToolCalls(turn.toolCalls().stream().map(toolCall -> {
|
||||||
|
final var function = new ChatToolFunction();
|
||||||
|
function.setName(toolCall.name());
|
||||||
|
function.setArguments(toolCall.arguments());
|
||||||
|
|
||||||
|
final var chatToolCall = new ChatToolCall();
|
||||||
|
chatToolCall.setId(toolCall.id());
|
||||||
|
chatToolCall.setType(toolCall.type());
|
||||||
|
chatToolCall.setFunction(function);
|
||||||
|
return chatToolCall;
|
||||||
|
}).toList());
|
||||||
|
}
|
||||||
|
message.setToolCallId(turn.toolCallId());
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.extio.lmlib.client.oai.completion.chat;
|
package de.extio.lmlib.client.oai.completion.chat;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.*;
|
import com.fasterxml.jackson.annotation.*;
|
||||||
|
|
||||||
@@ -13,6 +14,12 @@ final class ChatCompletionRequest extends AbstractCompletionRequest {
|
|||||||
private Integer maxCompletionTokens;
|
private Integer maxCompletionTokens;
|
||||||
|
|
||||||
private ChatCompletionsRequestReasoning reasoning;
|
private ChatCompletionsRequestReasoning reasoning;
|
||||||
|
|
||||||
|
private List<ChatCompletionTool> tools;
|
||||||
|
|
||||||
|
private Object toolChoice;
|
||||||
|
|
||||||
|
private Boolean parallelToolCalls;
|
||||||
|
|
||||||
@JsonProperty("messages")
|
@JsonProperty("messages")
|
||||||
public List<ChatMessage> getMessages() {
|
public List<ChatMessage> getMessages() {
|
||||||
@@ -41,6 +48,33 @@ final class ChatCompletionRequest extends AbstractCompletionRequest {
|
|||||||
this.reasoning = reasoning;
|
this.reasoning = reasoning;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonProperty("tools")
|
||||||
|
public List<ChatCompletionTool> getTools() {
|
||||||
|
return this.tools;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTools(final List<ChatCompletionTool> tools) {
|
||||||
|
this.tools = tools;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonProperty("tool_choice")
|
||||||
|
public Object getToolChoice() {
|
||||||
|
return this.toolChoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setToolChoice(final Object toolChoice) {
|
||||||
|
this.toolChoice = toolChoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonProperty("parallel_tool_calls")
|
||||||
|
public Boolean getParallelToolCalls() {
|
||||||
|
return this.parallelToolCalls;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setParallelToolCalls(final Boolean parallelToolCalls) {
|
||||||
|
this.parallelToolCalls = parallelToolCalls;
|
||||||
|
}
|
||||||
|
|
||||||
final static class ChatCompletionsRequestReasoning {
|
final static class ChatCompletionsRequestReasoning {
|
||||||
|
|
||||||
private ChatCompletionsRequestReasoningEffort effort;
|
private ChatCompletionsRequestReasoningEffort effort;
|
||||||
@@ -75,4 +109,119 @@ final class ChatCompletionRequest extends AbstractCompletionRequest {
|
|||||||
auto, concise, detailed
|
auto, concise, detailed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static final class ChatCompletionTool {
|
||||||
|
|
||||||
|
private String type;
|
||||||
|
|
||||||
|
private ChatCompletionFunction function;
|
||||||
|
|
||||||
|
@JsonProperty("type")
|
||||||
|
public String getType() {
|
||||||
|
return this.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setType(final String type) {
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonProperty("function")
|
||||||
|
public ChatCompletionFunction getFunction() {
|
||||||
|
return this.function;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFunction(final ChatCompletionFunction function) {
|
||||||
|
this.function = function;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class ChatCompletionFunction {
|
||||||
|
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
private Map<String, Object> parameters;
|
||||||
|
|
||||||
|
private Boolean strict;
|
||||||
|
|
||||||
|
@JsonProperty("name")
|
||||||
|
public String getName() {
|
||||||
|
return this.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(final String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonProperty("description")
|
||||||
|
public String getDescription() {
|
||||||
|
return this.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescription(final String description) {
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonProperty("parameters")
|
||||||
|
public Map<String, Object> getParameters() {
|
||||||
|
return this.parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setParameters(final Map<String, Object> parameters) {
|
||||||
|
this.parameters = parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonProperty("strict")
|
||||||
|
public Boolean getStrict() {
|
||||||
|
return this.strict;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStrict(final Boolean strict) {
|
||||||
|
this.strict = strict;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class ChatCompletionNamedToolChoice {
|
||||||
|
|
||||||
|
private String type;
|
||||||
|
|
||||||
|
private ChatCompletionNamedToolChoiceFunction function;
|
||||||
|
|
||||||
|
@JsonProperty("type")
|
||||||
|
public String getType() {
|
||||||
|
return this.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setType(final String type) {
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonProperty("function")
|
||||||
|
public ChatCompletionNamedToolChoiceFunction getFunction() {
|
||||||
|
return this.function;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFunction(final ChatCompletionNamedToolChoiceFunction function) {
|
||||||
|
this.function = function;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class ChatCompletionNamedToolChoiceFunction {
|
||||||
|
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@JsonProperty("name")
|
||||||
|
public String getName() {
|
||||||
|
return this.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(final String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package de.extio.lmlib.client.oai.completion.chat;
|
package de.extio.lmlib.client.oai.completion.chat;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.*;
|
import com.fasterxml.jackson.annotation.*;
|
||||||
|
|
||||||
final class ChatMessage {
|
final class ChatMessage {
|
||||||
@@ -9,6 +11,10 @@ final class ChatMessage {
|
|||||||
private String content;
|
private String content;
|
||||||
|
|
||||||
private String reasoningContent;
|
private String reasoningContent;
|
||||||
|
|
||||||
|
private List<ChatToolCall> toolCalls;
|
||||||
|
|
||||||
|
private String toolCallId;
|
||||||
|
|
||||||
public ChatMessage() {
|
public ChatMessage() {
|
||||||
}
|
}
|
||||||
@@ -45,4 +51,22 @@ final class ChatMessage {
|
|||||||
public void setReasoningContent(final String reasoningContent) {
|
public void setReasoningContent(final String reasoningContent) {
|
||||||
this.reasoningContent = reasoningContent;
|
this.reasoningContent = reasoningContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonProperty("tool_calls")
|
||||||
|
public List<ChatToolCall> getToolCalls() {
|
||||||
|
return this.toolCalls;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setToolCalls(final List<ChatToolCall> toolCalls) {
|
||||||
|
this.toolCalls = toolCalls;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonProperty("tool_call_id")
|
||||||
|
public String getToolCallId() {
|
||||||
|
return this.toolCallId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setToolCallId(final String toolCallId) {
|
||||||
|
this.toolCallId = toolCallId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package de.extio.lmlib.client.oai.completion.chat;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
|
final class ChatToolCall {
|
||||||
|
|
||||||
|
private Integer index;
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
private String type;
|
||||||
|
|
||||||
|
private ChatToolFunction function;
|
||||||
|
|
||||||
|
@JsonProperty("index")
|
||||||
|
public Integer getIndex() {
|
||||||
|
return this.index;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIndex(final Integer index) {
|
||||||
|
this.index = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonProperty("id")
|
||||||
|
public String getId() {
|
||||||
|
return this.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(final String id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonProperty("type")
|
||||||
|
public String getType() {
|
||||||
|
return this.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setType(final String type) {
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonProperty("function")
|
||||||
|
public ChatToolFunction getFunction() {
|
||||||
|
return this.function;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFunction(final ChatToolFunction function) {
|
||||||
|
this.function = function;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package de.extio.lmlib.client.oai.completion.chat;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
|
final class ChatToolFunction {
|
||||||
|
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
private String arguments;
|
||||||
|
|
||||||
|
@JsonProperty("name")
|
||||||
|
public String getName() {
|
||||||
|
return this.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(final String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonProperty("arguments")
|
||||||
|
public String getArguments() {
|
||||||
|
return this.arguments;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setArguments(final String arguments) {
|
||||||
|
this.arguments = arguments;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -13,7 +13,6 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.web.client.RestClient;
|
|
||||||
|
|
||||||
import tools.jackson.core.JacksonException;
|
import tools.jackson.core.JacksonException;
|
||||||
|
|
||||||
@@ -21,6 +20,7 @@ import de.extio.lmlib.client.Chunk;
|
|||||||
import de.extio.lmlib.client.Completion;
|
import de.extio.lmlib.client.Completion;
|
||||||
import de.extio.lmlib.client.CompletionFinishReason;
|
import de.extio.lmlib.client.CompletionFinishReason;
|
||||||
import de.extio.lmlib.client.Conversation;
|
import de.extio.lmlib.client.Conversation;
|
||||||
|
import de.extio.lmlib.client.ToolCallData;
|
||||||
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.ModelProfile;
|
import de.extio.lmlib.profile.ModelProfile;
|
||||||
@@ -41,7 +41,12 @@ public class TextCompletionClient extends AbstractCompletionClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected Completion requestCompletion(final Conversation conversation, final ModelProfile modelProfile, final Consumer<Chunk> chunkConsumer) {
|
public boolean supportsToolCalling() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Completion requestCompletion(final Conversation conversation, final ModelProfile modelProfile, final Consumer<Chunk> chunkConsumer, final ToolCallData toolCallData) {
|
||||||
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());
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ public class Grader {
|
|||||||
Only provide the response as a single word and no preamble and no explanation.""",
|
Only provide the response as a single word and no preamble and no explanation.""",
|
||||||
"Here is the text:\n" + text + "\n\n" +
|
"Here is the text:\n" + text + "\n\n" +
|
||||||
"Here is the user question: " + question),
|
"Here is the user question: " + question),
|
||||||
|
null,
|
||||||
true);
|
true);
|
||||||
LOGGER.info(completion.response());
|
LOGGER.info(completion.response());
|
||||||
score += Boolean.parseBoolean(completion.response().strip()) ? 1 : -1;
|
score += Boolean.parseBoolean(completion.response().strip()) ? 1 : -1;
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ public class Grader2 {
|
|||||||
- 'false': The text does not provide a truthful answer.
|
- 'false': The text does not provide a truthful answer.
|
||||||
Only provide the response as a single word. Do not include any preamble or explanation.
|
Only provide the response as a single word. Do not include any preamble or explanation.
|
||||||
""".formatted(question, text)),
|
""".formatted(question, text)),
|
||||||
|
null,
|
||||||
true);
|
true);
|
||||||
LOGGER.info(completion.response());
|
LOGGER.info(completion.response());
|
||||||
score += Boolean.parseBoolean(completion.response().strip()) ? 1 : -1;
|
score += Boolean.parseBoolean(completion.response().strip()) ? 1 : -1;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ public class AnswerGrader {
|
|||||||
The question is: {{questions}}
|
The question is: {{questions}}
|
||||||
The answer is: {{answer}}
|
The answer is: {{answer}}
|
||||||
""",
|
""",
|
||||||
|
null,
|
||||||
new JsonAgentResponseHandler(),
|
new JsonAgentResponseHandler(),
|
||||||
context -> {
|
context -> {
|
||||||
context.setSkipCache(true);
|
context.setSkipCache(true);
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ public class AnswerGrader2 {
|
|||||||
### Output Format
|
### Output Format
|
||||||
{{qaOutputFormat}}
|
{{qaOutputFormat}}
|
||||||
""",
|
""",
|
||||||
|
null,
|
||||||
new JsonAgentResponseHandler(),
|
new JsonAgentResponseHandler(),
|
||||||
context -> {
|
context -> {
|
||||||
final Boolean outputDescription = context.getValue("qaOutputDescription", Boolean.class);
|
final Boolean outputDescription = context.getValue("qaOutputDescription", Boolean.class);
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ public class AnswerGraderBinary {
|
|||||||
Output Format:
|
Output Format:
|
||||||
{{qaOutputFormat}}
|
{{qaOutputFormat}}
|
||||||
""",
|
""",
|
||||||
|
null,
|
||||||
new JsonAgentResponseHandler(),
|
new JsonAgentResponseHandler(),
|
||||||
context -> {
|
context -> {
|
||||||
final Boolean outputDescription = context.getValue("qaOutputDescription", Boolean.class);
|
final Boolean outputDescription = context.getValue("qaOutputDescription", Boolean.class);
|
||||||
|
|||||||
@@ -6,21 +6,32 @@ import static org.junit.jupiter.api.Assertions.assertNull;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import de.extio.lmlib.agent.responsehandler.AgentResponseHandler;
|
import de.extio.lmlib.agent.responsehandler.AgentResponseHandler;
|
||||||
|
import de.extio.lmlib.agent.responsehandler.TextAgentResponseHandler;
|
||||||
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.CompletionFinishReason;
|
||||||
import de.extio.lmlib.client.CompletionStatistics;
|
import de.extio.lmlib.client.CompletionStatistics;
|
||||||
|
import de.extio.lmlib.client.Conversation;
|
||||||
|
import de.extio.lmlib.client.ToolCall;
|
||||||
|
import de.extio.lmlib.client.ToolCallData;
|
||||||
|
import de.extio.lmlib.client.ToolDefinition;
|
||||||
|
import de.extio.lmlib.client.ToolParameters;
|
||||||
import de.extio.lmlib.profile.ModelCategory;
|
import de.extio.lmlib.profile.ModelCategory;
|
||||||
|
|
||||||
class AgentErrorHandlingTest {
|
class AgentErrorHandlingTest {
|
||||||
@@ -28,7 +39,7 @@ class AgentErrorHandlingTest {
|
|||||||
@Test
|
@Test
|
||||||
void parsingErrorsKeepTheirTypeWithoutAnExceptionWhenTheHandlerReturnsFalse() {
|
void parsingErrorsKeepTheirTypeWithoutAnExceptionWhenTheHandlerReturnsFalse() {
|
||||||
final Client client = mock(Client.class);
|
final Client client = mock(Client.class);
|
||||||
when(client.conversation(any(ModelCategory.class), any(), anyBoolean())).thenReturn(new Completion("not json", null, null, new CompletionStatistics(1, Duration.ofMillis(1), 1, 0, 1, 0, BigDecimal.ZERO, false)));
|
when(client.conversation(any(ModelCategory.class), any(), any(ToolCallData.class), anyBoolean())).thenReturn(new Completion("not json", null, null, new CompletionStatistics(1, Duration.ofMillis(1), 1, 0, 1, 0, BigDecimal.ZERO, false)));
|
||||||
|
|
||||||
final AgentResponseHandler responseHandler = (completion, context) -> false;
|
final AgentResponseHandler responseHandler = (completion, context) -> false;
|
||||||
final var agent = new Agent("ParseAgent",
|
final var agent = new Agent("ParseAgent",
|
||||||
@@ -37,6 +48,7 @@ class AgentErrorHandlingTest {
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
"prompt",
|
"prompt",
|
||||||
|
null,
|
||||||
responseHandler,
|
responseHandler,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
@@ -63,6 +75,7 @@ class AgentErrorHandlingTest {
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
context -> {
|
context -> {
|
||||||
throw exception;
|
throw exception;
|
||||||
},
|
},
|
||||||
@@ -80,4 +93,109 @@ class AgentErrorHandlingTest {
|
|||||||
assertEquals("boom", result.getErrorException().getMessage());
|
assertEquals("boom", result.getErrorException().getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toolCallDataIsRetainedForFollowUpConversationRequests() {
|
||||||
|
final var client = mock(Client.class);
|
||||||
|
when(client.supportsToolCalling()).thenReturn(true);
|
||||||
|
final var requestCounter = new AtomicInteger();
|
||||||
|
final var firstRequestToolCallData = new AtomicReference<ToolCallData>();
|
||||||
|
final var secondRequestToolCallData = new AtomicReference<ToolCallData>();
|
||||||
|
final var weatherTool = new ToolDefinition(
|
||||||
|
"get_weather",
|
||||||
|
"Gets the current weather for a given location.",
|
||||||
|
ToolParameters.create(Map.of("location", "")),
|
||||||
|
true);
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
final var toolCallData = invocation.getArgument(2, ToolCallData.class);
|
||||||
|
final var currentRequest = requestCounter.incrementAndGet();
|
||||||
|
if (currentRequest == 1) {
|
||||||
|
firstRequestToolCallData.set(toolCallData);
|
||||||
|
return new Completion(
|
||||||
|
"",
|
||||||
|
null,
|
||||||
|
CompletionFinishReason.TOOL_CALLS,
|
||||||
|
new CompletionStatistics(1, Duration.ofMillis(1), 1, 0, 1, 0, BigDecimal.ZERO, false),
|
||||||
|
List.of(new ToolCall("call_1", "function", "get_weather", "{\"location\":\"Berlin, Germany\"}")));
|
||||||
|
}
|
||||||
|
secondRequestToolCallData.set(toolCallData);
|
||||||
|
return new Completion("Berlin is rainy and 21C", null, CompletionFinishReason.DONE, new CompletionStatistics(1, Duration.ofMillis(1), 1, 0, 1, 0, BigDecimal.ZERO, false));
|
||||||
|
}).when(client).conversation(any(ModelCategory.class), any(), any(ToolCallData.class), anyBoolean());
|
||||||
|
|
||||||
|
final BaseAgent toolCaller = new BaseAgent() {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "ToolCaller";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AgentType agentType(final AgentContext context) {
|
||||||
|
return AgentType.START_CONVERSATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ModelCategory modelCategory(final AgentContext context) {
|
||||||
|
return ModelCategory.MEDIUM;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String systemPrompt() {
|
||||||
|
return "You must call the weather tool.";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String textTemplate() {
|
||||||
|
return "What is the weather in Berlin, Germany?";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ToolDefinition> toolDefinitions(final AgentContext context) {
|
||||||
|
return List.of(weatherTool);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ToolCallData toolCallData(final AgentContext context) {
|
||||||
|
return ToolCallData.required(List.of(weatherTool));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AgentResponseHandler responseHandler(final AgentContext context) {
|
||||||
|
return (completion, agentContext) -> true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AgentNext chooseNextAgent(final AgentContext context) {
|
||||||
|
return new AgentNext("ToolResultResponder", null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
final var toolResultResponder = new Agent("ToolResultResponder",
|
||||||
|
AgentType.CONVERSATION,
|
||||||
|
ModelCategory.MEDIUM,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"Answer using the tool result already present in the conversation.",
|
||||||
|
null,
|
||||||
|
new TextAgentResponseHandler("answer"),
|
||||||
|
context -> {
|
||||||
|
final var assistantTurn = context.getConversation().getConversation().getLast();
|
||||||
|
context.getConversation().addTurn(new Conversation.Turn(Conversation.TurnType.TOOL, ToolParameters.create().add("forecast", "Berlin is rainy and 21C").json(), null, assistantTurn.toolCalls().getFirst().id()));
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
AgentNext::end);
|
||||||
|
final var context = new AgentContext(Map.of(
|
||||||
|
toolCaller.name(), toolCaller,
|
||||||
|
toolResultResponder.name(), toolResultResponder));
|
||||||
|
|
||||||
|
try (ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor()) {
|
||||||
|
final var firstResult = toolCaller.execute(client, executorService, context).getFirst();
|
||||||
|
final var secondResult = toolResultResponder.execute(client, executorService, firstResult).getFirst();
|
||||||
|
|
||||||
|
assertEquals(2, requestCounter.get());
|
||||||
|
assertEquals(ToolCallData.required(List.of(weatherTool)), firstRequestToolCallData.get());
|
||||||
|
assertEquals(ToolCallData.auto(List.of(weatherTool)), secondRequestToolCallData.get());
|
||||||
|
assertEquals("Berlin is rainy and 21C", secondResult.getStringValue("answer"));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -24,9 +24,15 @@ import org.springframework.context.annotation.PropertySource;
|
|||||||
|
|
||||||
import de.extio.lmlib.agent.responsehandler.JsonAgentResponseHandler;
|
import de.extio.lmlib.agent.responsehandler.JsonAgentResponseHandler;
|
||||||
import de.extio.lmlib.agent.responsehandler.TextAgentResponseHandler;
|
import de.extio.lmlib.agent.responsehandler.TextAgentResponseHandler;
|
||||||
|
import de.extio.lmlib.agent.responsehandler.ToolCallingAgentResponseHandler;
|
||||||
import de.extio.lmlib.client.ClientService;
|
import de.extio.lmlib.client.ClientService;
|
||||||
import de.extio.lmlib.grader.Grader;
|
import de.extio.lmlib.client.Conversation;
|
||||||
|
import de.extio.lmlib.client.ToolCallData;
|
||||||
|
import de.extio.lmlib.client.ToolDefinition;
|
||||||
|
import de.extio.lmlib.client.ToolParameters;
|
||||||
|
import de.extio.lmlib.grader.Grader2;
|
||||||
import de.extio.lmlib.profile.ModelCategory;
|
import de.extio.lmlib.profile.ModelCategory;
|
||||||
|
import de.extio.lmlib.profile.ModelProfileService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* These tests have been verified with Llama 3.1 8B, Gemma2 27B, Gemma3 27B and GPT-OSS models
|
* These tests have been verified with Llama 3.1 8B, Gemma2 27B, Gemma3 27B and GPT-OSS models
|
||||||
@@ -45,6 +51,9 @@ public class AgentTest {
|
|||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private ClientService clientService;
|
private ClientService clientService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ModelProfileService modelProfileService;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void agenticFlow() throws IOException {
|
void agenticFlow() throws IOException {
|
||||||
@@ -56,6 +65,7 @@ public class AgentTest {
|
|||||||
null,
|
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}}",
|
||||||
|
null,
|
||||||
new TextAgentResponseHandler("summary"),
|
new TextAgentResponseHandler("summary"),
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
@@ -69,6 +79,7 @@ public class AgentTest {
|
|||||||
null,
|
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\", ... ] }",
|
||||||
|
null,
|
||||||
new JsonAgentResponseHandler(),
|
new JsonAgentResponseHandler(),
|
||||||
null,
|
null,
|
||||||
context -> context.getStringValues("features").forEach(feature -> LOGGER.info(feature)),
|
context -> context.getStringValues("features").forEach(feature -> LOGGER.info(feature)),
|
||||||
@@ -85,6 +96,7 @@ public class AgentTest {
|
|||||||
The source code is:
|
The source code is:
|
||||||
{{code}}
|
{{code}}
|
||||||
The feature is: [[features]]""",
|
The feature is: [[features]]""",
|
||||||
|
null,
|
||||||
new TextAgentResponseHandler("featureDescription"),
|
new TextAgentResponseHandler("featureDescription"),
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
@@ -102,6 +114,7 @@ public class AgentTest {
|
|||||||
The code analysis:
|
The code analysis:
|
||||||
{{summary}}
|
{{summary}}
|
||||||
{{featureDescription}}""",
|
{{featureDescription}}""",
|
||||||
|
null,
|
||||||
new TextAgentResponseHandler("blogPost"),
|
new TextAgentResponseHandler("blogPost"),
|
||||||
context -> {
|
context -> {
|
||||||
context.setStringValues("topics", List.of(String.join(", ", context.getStringValues("features").stream().toArray(size -> new String[size]))));
|
context.setStringValues("topics", List.of(String.join(", ", context.getStringValues("features").stream().toArray(size -> new String[size]))));
|
||||||
@@ -123,7 +136,7 @@ public class AgentTest {
|
|||||||
assertEquals(1, resultContext.getContext().get("blogPost").size());
|
assertEquals(1, resultContext.getContext().get("blogPost").size());
|
||||||
assertEquals(8, resultContext.getRequestStatistic().getRequests().get());
|
assertEquals(8, resultContext.getRequestStatistic().getRequests().get());
|
||||||
for (final var feature : resultContext.getContext().get("features")) {
|
for (final var feature : resultContext.getContext().get("features")) {
|
||||||
assertTrue(Grader.assessScoreBinary("Does the following blog post mention the feature " + feature, resultContext.getStringValue("blogPost"), this.clientService));
|
assertTrue(Grader2.assessScoreBinary("Does the following blog post mention the feature " + feature, resultContext.getStringValue("blogPost"), ModelCategory.MEDIUM, this.modelProfileService, this.clientService));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +151,7 @@ public class AgentTest {
|
|||||||
null,
|
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}}",
|
||||||
|
null,
|
||||||
new TextAgentResponseHandler("summary",
|
new TextAgentResponseHandler("summary",
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
@@ -176,6 +190,146 @@ public class AgentTest {
|
|||||||
LOGGER.info("### final result");
|
LOGGER.info("### final result");
|
||||||
LOGGER.info(resultContexts.getFirst().toString());
|
LOGGER.info(resultContexts.getFirst().toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toolCallingAgenticFlow() {
|
||||||
|
final var agents = Map.of(
|
||||||
|
"ToolCaller",
|
||||||
|
new Agent("ToolCaller",
|
||||||
|
AgentType.START_CONVERSATION,
|
||||||
|
ModelCategory.MEDIUM,
|
||||||
|
null,
|
||||||
|
"Use the provided tool when the user asks for weather information. After receiving tool results, respond using the forecast from the tool output.",
|
||||||
|
"What is the weather in Berlin, Germany? Use the tool.",
|
||||||
|
List.of(new ToolDefinition(
|
||||||
|
"get_weather",
|
||||||
|
"Gets the current weather for a given location.",
|
||||||
|
ToolParameters.create(Map.of("location", "City and country, for example Berlin, Germany")),
|
||||||
|
true)),
|
||||||
|
new ToolCallingAgentResponseHandler(
|
||||||
|
new TextAgentResponseHandler("answer"),
|
||||||
|
(completion, context, toolCallResults) -> {
|
||||||
|
if (completion.toolCalls().isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
context.setStringValue("toolName", completion.toolCalls().getFirst().name());
|
||||||
|
context.setStringValue("toolArguments", completion.toolCalls().getFirst().arguments());
|
||||||
|
toolCallResults.add(completion.toolCalls().getFirst(), ToolParameters.create().add("forecast", "Berlin is rainy and 21C"));
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
AgentNext::end));
|
||||||
|
|
||||||
|
final var context = new AgentContext(agents);
|
||||||
|
assertTrue(agents.get("ToolCaller").supportsToolCalling(context, this.clientService));
|
||||||
|
|
||||||
|
final var resultContexts = this.agentExecutor.walk(agents.get("ToolCaller"), context);
|
||||||
|
final var resultContext = resultContexts.getFirst();
|
||||||
|
|
||||||
|
assertEquals(1, resultContexts.size());
|
||||||
|
assertEquals("get_weather", resultContext.getStringValue("toolName"));
|
||||||
|
assertTrue(resultContext.getStringValue("toolArguments").contains("Berlin"));
|
||||||
|
assertTrue(Grader2.assessScoreBinary("Does the text say that Berlin is rainy and 21C?", resultContext.getStringValue("answer"), ModelCategory.MEDIUM, this.modelProfileService, this.clientService));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toolCallsCanBeHandledByNextAgentConversation() {
|
||||||
|
final var weatherTool = new ToolDefinition(
|
||||||
|
"get_weather",
|
||||||
|
"Gets the current weather for a given location.",
|
||||||
|
ToolParameters.create(Map.of("location", "City and country, for example Berlin, Germany")),
|
||||||
|
true);
|
||||||
|
final BaseAgent toolCaller = new BaseAgent() {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "ToolCaller";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AgentType agentType(final AgentContext context) {
|
||||||
|
return AgentType.START_CONVERSATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ModelCategory modelCategory(final AgentContext context) {
|
||||||
|
return ModelCategory.MEDIUM;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String systemPrompt() {
|
||||||
|
return "You must call the provided tool for weather questions. Do not answer before the tool has been called.";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String textTemplate() {
|
||||||
|
return "What is the weather in Berlin, Germany? Use the tool.";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ToolDefinition> toolDefinitions(final AgentContext context) {
|
||||||
|
return List.of(weatherTool);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ToolCallData toolCallData(final AgentContext context) {
|
||||||
|
return ToolCallData.required(List.of(weatherTool));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supportsToolCalling(final AgentContext context) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AgentNext chooseNextAgent(final AgentContext context) {
|
||||||
|
return new AgentNext("ToolResultResponder", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public de.extio.lmlib.agent.responsehandler.AgentResponseHandler responseHandler(final AgentContext context) {
|
||||||
|
return (completion, agentContext) -> true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
final BaseAgent toolResultResponder = new Agent("ToolResultResponder",
|
||||||
|
AgentType.CONVERSATION,
|
||||||
|
ModelCategory.MEDIUM,
|
||||||
|
null,
|
||||||
|
"Use the existing tool result in the conversation to answer the original weather question.",
|
||||||
|
"Answer the original question using the tool result already present in the conversation. Do not call any tool.",
|
||||||
|
null,
|
||||||
|
new TextAgentResponseHandler("answer"),
|
||||||
|
context -> {
|
||||||
|
final var conversation = context.getConversation();
|
||||||
|
final var assistantTurn = conversation.getConversation().getLast();
|
||||||
|
context.setStringValue("handoffTurnType", assistantTurn.type().name());
|
||||||
|
context.setStringValue("handoffToolName", assistantTurn.toolCalls().getFirst().name());
|
||||||
|
context.setStringValue("handoffToolArguments", assistantTurn.toolCalls().getFirst().arguments());
|
||||||
|
context.setStringValue("handoffToolCallId", assistantTurn.toolCalls().getFirst().id());
|
||||||
|
conversation.addTurn(new Conversation.Turn(Conversation.TurnType.TOOL, ToolParameters.create().add("forecast", "Berlin is rainy and 21C").json(), null, assistantTurn.toolCalls().getFirst().id()));
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
AgentNext::end);
|
||||||
|
final var agents = Map.of(
|
||||||
|
"ToolCaller", toolCaller,
|
||||||
|
"ToolResultResponder", toolResultResponder);
|
||||||
|
|
||||||
|
final var context = new AgentContext(agents);
|
||||||
|
final var resultContexts = this.agentExecutor.walk(toolCaller, context);
|
||||||
|
final var resultContext = resultContexts.getFirst();
|
||||||
|
|
||||||
|
assertEquals(1, resultContexts.size());
|
||||||
|
assertEquals("ASSISTANT", resultContext.getStringValue("handoffTurnType"));
|
||||||
|
assertEquals("get_weather", resultContext.getStringValue("handoffToolName"));
|
||||||
|
assertTrue(resultContext.getStringValue("handoffToolArguments").contains("Berlin"));
|
||||||
|
assertTrue(resultContext.getConversation().getConversation().stream().anyMatch(turn -> turn.type() == Conversation.TurnType.TOOL
|
||||||
|
&& resultContext.getStringValue("handoffToolCallId").equals(turn.toolCallId())
|
||||||
|
&& turn.text().contains("Berlin is rainy and 21C")));
|
||||||
|
assertTrue(Grader2.assessScoreBinary("Does the text say that Berlin is rainy and 21C?", resultContext.getStringValue("answer"), ModelCategory.MEDIUM, this.modelProfileService, this.clientService));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void doubleBranch() {
|
void doubleBranch() {
|
||||||
@@ -187,6 +341,7 @@ public class AgentTest {
|
|||||||
null,
|
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\", ... ] }",
|
||||||
|
null,
|
||||||
new JsonAgentResponseHandler(),
|
new JsonAgentResponseHandler(),
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
@@ -200,6 +355,7 @@ public class AgentTest {
|
|||||||
null,
|
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\", ... ] }",
|
||||||
|
null,
|
||||||
new JsonAgentResponseHandler(),
|
new JsonAgentResponseHandler(),
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
@@ -213,6 +369,7 @@ public class AgentTest {
|
|||||||
null,
|
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\" }",
|
||||||
|
null,
|
||||||
new JsonAgentResponseHandler(),
|
new JsonAgentResponseHandler(),
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
@@ -226,6 +383,7 @@ public class AgentTest {
|
|||||||
null,
|
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'",
|
||||||
|
null,
|
||||||
new TextAgentResponseHandler("grade"),
|
new TextAgentResponseHandler("grade"),
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ public class CachedClientTest {
|
|||||||
var completion = this.clientService.getClient(ModelCategory.SMALL).conversation(
|
var completion = this.clientService.getClient(ModelCategory.SMALL).conversation(
|
||||||
ModelCategory.SMALL,
|
ModelCategory.SMALL,
|
||||||
Conversation.create("You are a funny person", "Tell me a cat joke"),
|
Conversation.create("You are a funny person", "Tell me a cat joke"),
|
||||||
|
null,
|
||||||
false);
|
false);
|
||||||
LOGGER.info(completion.toString());
|
LOGGER.info(completion.toString());
|
||||||
assertFalse(completion.statistics().cached());
|
assertFalse(completion.statistics().cached());
|
||||||
@@ -60,6 +61,7 @@ public class CachedClientTest {
|
|||||||
completion = this.clientService.getClient(ModelCategory.SMALL).conversation(
|
completion = this.clientService.getClient(ModelCategory.SMALL).conversation(
|
||||||
ModelCategory.SMALL,
|
ModelCategory.SMALL,
|
||||||
Conversation.create("You are a funny person", "Tell me a cat joke"),
|
Conversation.create("You are a funny person", "Tell me a cat joke"),
|
||||||
|
null,
|
||||||
false);
|
false);
|
||||||
LOGGER.info(completion.toString());
|
LOGGER.info(completion.toString());
|
||||||
assertTrue(completion.statistics().cached());
|
assertTrue(completion.statistics().cached());
|
||||||
@@ -69,6 +71,7 @@ public class CachedClientTest {
|
|||||||
completion = this.clientService.getClient(ModelCategory.SMALL).conversation(
|
completion = this.clientService.getClient(ModelCategory.SMALL).conversation(
|
||||||
ModelCategory.SMALL,
|
ModelCategory.SMALL,
|
||||||
Conversation.create("You are a funny person", "Tell me a dog joke"),
|
Conversation.create("You are a funny person", "Tell me a dog joke"),
|
||||||
|
null,
|
||||||
false);
|
false);
|
||||||
LOGGER.info(completion.toString());
|
LOGGER.info(completion.toString());
|
||||||
assertFalse(completion.statistics().cached());
|
assertFalse(completion.statistics().cached());
|
||||||
@@ -80,14 +83,14 @@ public class CachedClientTest {
|
|||||||
void cacheChat() throws Exception {
|
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.");
|
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, false);
|
var completion = this.clientService.getClient(ModelCategory.MEDIUM).conversation(ModelCategory.MEDIUM, conversation, null, false);
|
||||||
LOGGER.info(completion.toString());
|
LOGGER.info(completion.toString());
|
||||||
assertFalse(completion.statistics().cached());
|
assertFalse(completion.statistics().cached());
|
||||||
assertEquals(1, completion.statistics().requests());
|
assertEquals(1, completion.statistics().requests());
|
||||||
final var firstResponse = completion.response();
|
final var firstResponse = completion.response();
|
||||||
assertFalse(firstResponse.isBlank());
|
assertFalse(firstResponse.isBlank());
|
||||||
|
|
||||||
completion = this.clientService.getClient(ModelCategory.MEDIUM).conversation(ModelCategory.MEDIUM, conversation, false);
|
completion = this.clientService.getClient(ModelCategory.MEDIUM).conversation(ModelCategory.MEDIUM, conversation, null, false);
|
||||||
LOGGER.info(completion.toString());
|
LOGGER.info(completion.toString());
|
||||||
assertTrue(completion.statistics().cached());
|
assertTrue(completion.statistics().cached());
|
||||||
assertEquals(0, completion.statistics().requests());
|
assertEquals(0, completion.statistics().requests());
|
||||||
@@ -96,7 +99,7 @@ public class CachedClientTest {
|
|||||||
conversation.addTurn(new Turn(TurnType.ASSISTANT,
|
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."));
|
"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?"));
|
conversation.addTurn(new Turn(TurnType.USER, "Great! How is the weather in summer?"));
|
||||||
completion = this.clientService.getClient(ModelCategory.MEDIUM).conversation(ModelCategory.MEDIUM, conversation, false);
|
completion = this.clientService.getClient(ModelCategory.MEDIUM).conversation(ModelCategory.MEDIUM, conversation, null, false);
|
||||||
LOGGER.info(completion.toString());
|
LOGGER.info(completion.toString());
|
||||||
assertFalse(completion.statistics().cached());
|
assertFalse(completion.statistics().cached());
|
||||||
assertEquals(1, completion.statistics().requests());
|
assertEquals(1, completion.statistics().requests());
|
||||||
@@ -107,19 +110,19 @@ public class CachedClientTest {
|
|||||||
void bypassCacheStillStoresFreshResult() throws Exception {
|
void bypassCacheStillStoresFreshResult() throws Exception {
|
||||||
final var conversation = Conversation.create("You are a witty assistant", "Give me a short joke about caches.");
|
final var conversation = Conversation.create("You are a witty assistant", "Give me a short joke about caches.");
|
||||||
|
|
||||||
final var initialCompletion = this.clientService.getClient(ModelCategory.MEDIUM).conversation(ModelCategory.MEDIUM, conversation, false);
|
final var initialCompletion = this.clientService.getClient(ModelCategory.MEDIUM).conversation(ModelCategory.MEDIUM, conversation, null, false);
|
||||||
LOGGER.info(initialCompletion.toString());
|
LOGGER.info(initialCompletion.toString());
|
||||||
assertFalse(initialCompletion.statistics().cached());
|
assertFalse(initialCompletion.statistics().cached());
|
||||||
assertEquals(1, initialCompletion.statistics().requests());
|
assertEquals(1, initialCompletion.statistics().requests());
|
||||||
assertFalse(initialCompletion.response().isBlank());
|
assertFalse(initialCompletion.response().isBlank());
|
||||||
|
|
||||||
final var bypassedCompletion = this.clientService.getClient(ModelCategory.MEDIUM).conversation(ModelCategory.MEDIUM, conversation, true);
|
final var bypassedCompletion = this.clientService.getClient(ModelCategory.MEDIUM).conversation(ModelCategory.MEDIUM, conversation, null, true);
|
||||||
LOGGER.info(bypassedCompletion.toString());
|
LOGGER.info(bypassedCompletion.toString());
|
||||||
assertFalse(bypassedCompletion.statistics().cached());
|
assertFalse(bypassedCompletion.statistics().cached());
|
||||||
assertEquals(1, bypassedCompletion.statistics().requests());
|
assertEquals(1, bypassedCompletion.statistics().requests());
|
||||||
assertFalse(bypassedCompletion.response().isBlank());
|
assertFalse(bypassedCompletion.response().isBlank());
|
||||||
|
|
||||||
final var cachedCompletion = this.clientService.getClient(ModelCategory.MEDIUM).conversation(ModelCategory.MEDIUM, conversation, false);
|
final var cachedCompletion = this.clientService.getClient(ModelCategory.MEDIUM).conversation(ModelCategory.MEDIUM, conversation, null, false);
|
||||||
LOGGER.info(cachedCompletion.toString());
|
LOGGER.info(cachedCompletion.toString());
|
||||||
assertTrue(cachedCompletion.statistics().cached());
|
assertTrue(cachedCompletion.statistics().cached());
|
||||||
assertEquals(0, cachedCompletion.statistics().requests());
|
assertEquals(0, cachedCompletion.statistics().requests());
|
||||||
@@ -140,7 +143,7 @@ public class CachedClientTest {
|
|||||||
sb.append(chunk.content());
|
sb.append(chunk.content());
|
||||||
System.out.print(chunk.content());
|
System.out.print(chunk.content());
|
||||||
}
|
}
|
||||||
}, false);
|
}, null, false);
|
||||||
assertFalse(completion.statistics().cached());
|
assertFalse(completion.statistics().cached());
|
||||||
assertEquals(1, completion.statistics().requests());
|
assertEquals(1, completion.statistics().requests());
|
||||||
assertTrue(!sb.isEmpty());
|
assertTrue(!sb.isEmpty());
|
||||||
@@ -158,7 +161,7 @@ public class CachedClientTest {
|
|||||||
sb2.append(chunk.content());
|
sb2.append(chunk.content());
|
||||||
System.out.print(chunk.content());
|
System.out.print(chunk.content());
|
||||||
}
|
}
|
||||||
}, false);
|
}, null, false);
|
||||||
assertTrue(completion.statistics().cached());
|
assertTrue(completion.statistics().cached());
|
||||||
assertEquals(sb.toString(), sb2.toString());
|
assertEquals(sb.toString(), sb2.toString());
|
||||||
assertEquals(sb2.toString(), completion.response());
|
assertEquals(sb2.toString(), completion.response());
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
@@ -21,9 +23,13 @@ import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
|
|||||||
import org.springframework.context.annotation.PropertySource;
|
import org.springframework.context.annotation.PropertySource;
|
||||||
|
|
||||||
import de.extio.lmlib.client.ClientService;
|
import de.extio.lmlib.client.ClientService;
|
||||||
|
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.Conversation.TurnType;
|
import de.extio.lmlib.client.Conversation.TurnType;
|
||||||
|
import de.extio.lmlib.client.ToolCallData;
|
||||||
|
import de.extio.lmlib.client.ToolDefinition;
|
||||||
|
import de.extio.lmlib.client.ToolParameters;
|
||||||
import de.extio.lmlib.client.oai.completion.chat.ChatCompletionClient;
|
import de.extio.lmlib.client.oai.completion.chat.ChatCompletionClient;
|
||||||
import de.extio.lmlib.client.oai.completion.text.TextCompletionClient;
|
import de.extio.lmlib.client.oai.completion.text.TextCompletionClient;
|
||||||
import de.extio.lmlib.grader.Grader2;
|
import de.extio.lmlib.grader.Grader2;
|
||||||
@@ -57,6 +63,7 @@ public class CompletionClientTest {
|
|||||||
final var completion = this.textCompletionClient.conversation(
|
final var completion = this.textCompletionClient.conversation(
|
||||||
modelProfile,
|
modelProfile,
|
||||||
Conversation.create("You are a helpful assistant", "Say the color is green"),
|
Conversation.create("You are a helpful assistant", "Say the color is green"),
|
||||||
|
null,
|
||||||
false);
|
false);
|
||||||
|
|
||||||
LOGGER.info(completion.reasoning());
|
LOGGER.info(completion.reasoning());
|
||||||
@@ -71,7 +78,7 @@ public class CompletionClientTest {
|
|||||||
final var conversation = Conversation.create("You are a helpful assistant", "I would like to paint my canvas in a solid color.");
|
final var conversation = Conversation.create("You are a helpful assistant", "I would like to paint my canvas in a solid color.");
|
||||||
conversation.addTurn(new Turn(TurnType.ASSISTANT, "Which color do you want to see?"));
|
conversation.addTurn(new Turn(TurnType.ASSISTANT, "Which color do you want to see?"));
|
||||||
conversation.addTurn(new Turn(TurnType.USER, "You choose!"));
|
conversation.addTurn(new Turn(TurnType.USER, "You choose!"));
|
||||||
final var completion = this.textCompletionClient.conversation(modelProfile, conversation, false);
|
final var completion = this.textCompletionClient.conversation(modelProfile, conversation, null, false);
|
||||||
|
|
||||||
LOGGER.info(completion.reasoning());
|
LOGGER.info(completion.reasoning());
|
||||||
LOGGER.info(completion.response());
|
LOGGER.info(completion.response());
|
||||||
@@ -94,7 +101,7 @@ public class CompletionClientTest {
|
|||||||
System.out.print(chunk.content());
|
System.out.print(chunk.content());
|
||||||
sb.append(chunk.content());
|
sb.append(chunk.content());
|
||||||
}
|
}
|
||||||
}, false);
|
}, null, false);
|
||||||
|
|
||||||
if (completion.reasoning() != null) {
|
if (completion.reasoning() != null) {
|
||||||
// Prompt template does not separate reasoning and response from the stream at the moment
|
// Prompt template does not separate reasoning and response from the stream at the moment
|
||||||
@@ -114,7 +121,7 @@ public class CompletionClientTest {
|
|||||||
final var conversation = Conversation.create("You are a helpful assistant", "I would like to paint my canvas in a solid color.");
|
final var conversation = Conversation.create("You are a helpful assistant", "I would like to paint my canvas in a solid color.");
|
||||||
conversation.addTurn(new Turn(TurnType.ASSISTANT, "Which color do you want to see?"));
|
conversation.addTurn(new Turn(TurnType.ASSISTANT, "Which color do you want to see?"));
|
||||||
conversation.addTurn(new Turn(TurnType.USER, "You choose!"));
|
conversation.addTurn(new Turn(TurnType.USER, "You choose!"));
|
||||||
final var completion = this.chatCompletionclient.conversation(modelProfile, conversation, false);
|
final var completion = this.chatCompletionclient.conversation(modelProfile, conversation, null, false);
|
||||||
|
|
||||||
LOGGER.info(completion.reasoning());
|
LOGGER.info(completion.reasoning());
|
||||||
LOGGER.info(completion.response());
|
LOGGER.info(completion.response());
|
||||||
@@ -137,12 +144,84 @@ public class CompletionClientTest {
|
|||||||
System.out.print(chunk.content());
|
System.out.print(chunk.content());
|
||||||
sb.append(chunk.content());
|
sb.append(chunk.content());
|
||||||
}
|
}
|
||||||
}, false);
|
}, null, false);
|
||||||
|
|
||||||
assertEquals(completion.response(), sb.toString());
|
assertEquals(completion.response(), sb.toString());
|
||||||
assertTrue(Grader2.assessScoreBinary("Does the text explain a chess opening?", completion.response(), modelProfile, this.clientService));
|
assertTrue(Grader2.assessScoreBinary("Does the text explain a chess opening?", completion.response(), modelProfile, this.clientService));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toolCallingChatCompletion() throws Exception {
|
||||||
|
final var modelProfile = this.modelProfileService.getModelProfile("profile.model.chatcompletion");
|
||||||
|
final var toolDefinition = new ToolDefinition(
|
||||||
|
"get_weather",
|
||||||
|
"Gets the current weather for a given location.",
|
||||||
|
ToolParameters.create(Map.of("location", "City and country, for example Berlin, Germany")),
|
||||||
|
true);
|
||||||
|
final var conversation = Conversation.create(
|
||||||
|
"You are a helpful assistant. Use the provided tool when the user asks for weather information. After receiving tool results, answer using the forecast from the tool output.",
|
||||||
|
"What is the weather in Berlin, Germany? Use the tool.");
|
||||||
|
final var toolCallData = ToolCallData.required(List.of(toolDefinition));
|
||||||
|
final var completion = this.chatCompletionclient.conversation(
|
||||||
|
modelProfile,
|
||||||
|
conversation,
|
||||||
|
toolCallData,
|
||||||
|
false);
|
||||||
|
|
||||||
|
assertTrue(this.chatCompletionclient.supportsToolCalling());
|
||||||
|
assertFalse(this.textCompletionClient.supportsToolCalling());
|
||||||
|
assertTrue(this.clientService.supportsToolCalling(modelProfile));
|
||||||
|
assertTrue(completion.finishReason() == CompletionFinishReason.TOOL_CALLS);
|
||||||
|
assertFalse(completion.toolCalls().isEmpty());
|
||||||
|
assertEquals("get_weather", completion.toolCalls().getFirst().name());
|
||||||
|
assertTrue(completion.toolCalls().getFirst().arguments().contains("Berlin"));
|
||||||
|
|
||||||
|
conversation.addTurn(new Turn(TurnType.ASSISTANT, "", completion.toolCalls(), null));
|
||||||
|
conversation.addTurn(new Turn(TurnType.TOOL, ToolParameters.create().add("forecast", "Berlin is rainy and 21C").json(), null, completion.toolCalls().getFirst().id()));
|
||||||
|
|
||||||
|
final var finalCompletion = this.chatCompletionclient.conversation(modelProfile, conversation, ToolCallData.auto(List.of(toolDefinition)), false);
|
||||||
|
assertTrue(finalCompletion.finishReason() == CompletionFinishReason.DONE);
|
||||||
|
assertTrue(Grader2.assessScoreBinary("Does the text say that Berlin is rainy and 21C?", finalCompletion.response(), modelProfile, this.clientService));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void streamToolCallingChatCompletion() throws Exception {
|
||||||
|
final var modelProfile = this.modelProfileService.getModelProfile("profile.model.chatcompletion");
|
||||||
|
final var getWeather = new ToolDefinition(
|
||||||
|
"get_weather",
|
||||||
|
"Gets the current weather for a given location.",
|
||||||
|
ToolParameters.create(Map.of("location", "City and country, for example Berlin, Germany")),
|
||||||
|
true);
|
||||||
|
final var getCpu = new ToolDefinition(
|
||||||
|
"get_cpu",
|
||||||
|
"Gets the current CPU utilization.",
|
||||||
|
ToolParameters.create(Map.of("cpu", "CPU identifier, for example CPU0. Use CPU for overall utilization.")),
|
||||||
|
true);
|
||||||
|
final var completion = this.chatCompletionclient.streamConversation(
|
||||||
|
modelProfile,
|
||||||
|
Conversation.create(
|
||||||
|
"You are a helpful assistant. Use the provided tool when applicable.",
|
||||||
|
"How high is the current CPU utilization?"),
|
||||||
|
chunk -> {
|
||||||
|
if (chunk.reasoningContent() != null) {
|
||||||
|
System.out.print(chunk.reasoningContent());
|
||||||
|
}
|
||||||
|
if (chunk.content() != null) {
|
||||||
|
System.out.print(chunk.content());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ToolCallData.required(List.of(getWeather, getCpu)),
|
||||||
|
false);
|
||||||
|
|
||||||
|
assertTrue(this.chatCompletionclient.supportsToolCalling());
|
||||||
|
assertFalse(this.textCompletionClient.supportsToolCalling());
|
||||||
|
assertTrue(this.clientService.supportsToolCalling(modelProfile));
|
||||||
|
assertTrue(completion.finishReason() == CompletionFinishReason.TOOL_CALLS);
|
||||||
|
assertFalse(completion.toolCalls().isEmpty());
|
||||||
|
assertEquals("get_cpu", completion.toolCalls().getFirst().name());
|
||||||
|
assertTrue(completion.toolCalls().getFirst().arguments().contains("CPU"));
|
||||||
|
}
|
||||||
|
|
||||||
@Disabled
|
@Disabled
|
||||||
@Test
|
@Test
|
||||||
void hugePrompt() throws Exception {
|
void hugePrompt() throws Exception {
|
||||||
@@ -156,6 +235,7 @@ public class CompletionClientTest {
|
|||||||
final var completion = this.textCompletionClient.conversation(
|
final var completion = this.textCompletionClient.conversation(
|
||||||
ModelCategory.MEDIUM,
|
ModelCategory.MEDIUM,
|
||||||
Conversation.create("You are counting machine", "How many numbers do you count?\n" + sb.toString()),
|
Conversation.create("You are counting machine", "How many numbers do you count?\n" + sb.toString()),
|
||||||
|
null,
|
||||||
false);
|
false);
|
||||||
LOGGER.info(completion.response());
|
LOGGER.info(completion.response());
|
||||||
}
|
}
|
||||||
@@ -204,6 +284,7 @@ public class CompletionClientTest {
|
|||||||
final var completion = this.textCompletionClient.conversation(
|
final var completion = this.textCompletionClient.conversation(
|
||||||
ModelCategory.MEDIUM,
|
ModelCategory.MEDIUM,
|
||||||
Conversation.create("You are a helpful assistant", "How do I calculate the annual profit margin?"),
|
Conversation.create("You are a helpful assistant", "How do I calculate the annual profit margin?"),
|
||||||
|
null,
|
||||||
false);
|
false);
|
||||||
LOGGER.info(completion.response());
|
LOGGER.info(completion.response());
|
||||||
}
|
}
|
||||||
@@ -215,6 +296,7 @@ public class CompletionClientTest {
|
|||||||
final var completion = this.textCompletionClient.conversation(
|
final var completion = this.textCompletionClient.conversation(
|
||||||
ModelCategory.MEDIUM,
|
ModelCategory.MEDIUM,
|
||||||
Conversation.create("You are a helpful assistant", "How do I calculate the operating profit ratio?"),
|
Conversation.create("You are a helpful assistant", "How do I calculate the operating profit ratio?"),
|
||||||
|
null,
|
||||||
false);
|
false);
|
||||||
LOGGER.info(completion.response());
|
LOGGER.info(completion.response());
|
||||||
}
|
}
|
||||||
@@ -226,6 +308,7 @@ public class CompletionClientTest {
|
|||||||
final var completion = this.textCompletionClient.conversation(
|
final var completion = this.textCompletionClient.conversation(
|
||||||
ModelCategory.MEDIUM,
|
ModelCategory.MEDIUM,
|
||||||
Conversation.create("You are a helpful assistant", "How do I calculate the return of investment?"),
|
Conversation.create("You are a helpful assistant", "How do I calculate the return of investment?"),
|
||||||
|
null,
|
||||||
false);
|
false);
|
||||||
LOGGER.info(completion.response());
|
LOGGER.info(completion.response());
|
||||||
}
|
}
|
||||||
@@ -237,6 +320,7 @@ public class CompletionClientTest {
|
|||||||
final var completion = this.textCompletionClient.conversation(
|
final var completion = this.textCompletionClient.conversation(
|
||||||
ModelCategory.MEDIUM,
|
ModelCategory.MEDIUM,
|
||||||
Conversation.create("You are a helpful assistant", "How do I calculate the return on net worth?"),
|
Conversation.create("You are a helpful assistant", "How do I calculate the return on net worth?"),
|
||||||
|
null,
|
||||||
false);
|
false);
|
||||||
LOGGER.info(completion.response());
|
LOGGER.info(completion.response());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user