feat: Support OLLAMA provider

- Added OllamaClient to handle conversations with the Ollama API.
- Introduced OllamaDialect for managing thinking modes.
- Created data classes: OllamaFunctionCall, OllamaListedModel, OllamaMessage, OllamaShowRequest, OllamaShowResponse, OllamaTagsResponse, OllamaToolCall, OllamaToolDefinition, and OllamaToolFunction for structured API communication.
- Developed unit tests for OllamaClient to validate model listing, chat completion, streaming responses, and tool calling capabilities.
- Configured test properties for local Ollama instance.
This commit is contained in:
extio-de
2026-05-09 17:47:28 +02:00
parent 210de3c62f
commit 4ff231227e
33 changed files with 1468 additions and 60 deletions

View File

@@ -34,6 +34,7 @@ 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.ModelProfile;
class AgentErrorHandlingTest {
@@ -98,7 +99,8 @@ class AgentErrorHandlingTest {
@Test
void toolCallDataIsRetainedForFollowUpConversationRequests() {
final var client = mock(Client.class);
when(client.supportsToolCalling()).thenReturn(true);
when(client.supportsToolCalling(any(ModelProfile.class))).thenReturn(true);
when(client.supportsToolCalling(any(ModelCategory.class))).thenReturn(true);
final var requestCounter = new AtomicInteger();
final var firstRequestToolCallData = new AtomicReference<ToolCallData>();
final var secondRequestToolCallData = new AtomicReference<ToolCallData>();

View File

@@ -21,7 +21,7 @@ import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.context.annotation.PropertySource;
import org.springframework.test.context.TestPropertySource;
import de.extio.lmlib.agent.responsehandler.JsonAgentResponseHandler;
import de.extio.lmlib.agent.responsehandler.TextAgentResponseHandler;
@@ -43,7 +43,7 @@ import de.extio.lmlib.profile.ModelProfileService;
@SpringBootTest(webEnvironment = WebEnvironment.NONE)
@SpringBootConfiguration
@EnableAutoConfiguration
@PropertySource("classpath:/application-test.properties")
@TestPropertySource("classpath:/application-test.properties")
public class AgentTest {
private static final Logger LOGGER = LoggerFactory.getLogger(AgentTest.class);
@@ -367,7 +367,7 @@ public class AgentTest {
}
@Override
public boolean supportsToolCalling(final AgentContext context) {
public boolean supportsToolCalling(final AgentContext context, final ClientService clientService) {
return true;
}

View File

@@ -16,7 +16,7 @@ import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.PropertySource;
import org.springframework.test.context.TestPropertySource;
import de.extio.lmlib.client.ClientService;
import de.extio.lmlib.client.Conversation;
@@ -29,7 +29,7 @@ import de.extio.lmlib.profile.ModelCategory;
@SpringBootConfiguration
@EnableAutoConfiguration
@Import({ TestCachedClientRepository.Config.class })
@PropertySource("classpath:/application-test.properties")
@TestPropertySource("classpath:/application-test.properties")
public class CachedClientTest {
private static final Logger LOGGER = LoggerFactory.getLogger(CachedClientTest.class);

View File

@@ -20,7 +20,7 @@ import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.context.annotation.PropertySource;
import org.springframework.test.context.TestPropertySource;
import de.extio.lmlib.client.ClientService;
import de.extio.lmlib.client.CompletionFinishReason;
@@ -41,7 +41,7 @@ import de.extio.lmlib.profile.ModelProfileService;
@SpringBootTest(webEnvironment = WebEnvironment.NONE)
@SpringBootConfiguration
@EnableAutoConfiguration
@PropertySource("classpath:/application-test.properties")
@TestPropertySource("classpath:/application-test.properties")
public class CompletionClientTest {
private static final Logger LOGGER = LoggerFactory.getLogger(CompletionClientTest.class);
@@ -174,8 +174,8 @@ public class CompletionClientTest {
toolCallData,
false);
assertTrue(this.chatCompletionclient.supportsToolCalling());
assertFalse(this.textCompletionClient.supportsToolCalling());
assertTrue(this.chatCompletionclient.supportsToolCalling(modelProfile));
assertFalse(this.textCompletionClient.supportsToolCalling(this.modelProfileService.getModelProfile("profile.model.textcompletion")));
assertTrue(this.clientService.supportsToolCalling(modelProfile));
assertTrue(completion.finishReason() == CompletionFinishReason.TOOL_CALLS);
assertEquals(2, completion.toolCalls().size());
@@ -226,8 +226,8 @@ public class CompletionClientTest {
ToolCallData.required(List.of(getWeather, getCpu)).withParallelToolCalls(true),
false);
assertTrue(this.chatCompletionclient.supportsToolCalling());
assertFalse(this.textCompletionClient.supportsToolCalling());
assertTrue(this.chatCompletionclient.supportsToolCalling(modelProfile));
assertFalse(this.textCompletionClient.supportsToolCalling(this.modelProfileService.getModelProfile("profile.model.textcompletion")));
assertTrue(this.clientService.supportsToolCalling(modelProfile));
assertTrue(completion.finishReason() == CompletionFinishReason.TOOL_CALLS);
assertEquals(2, completion.toolCalls().size());

View File

@@ -0,0 +1,152 @@
package de.extio.lmlib.client.ollama;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.test.context.TestPropertySource;
import de.extio.lmlib.client.ClientService;
import de.extio.lmlib.client.CompletionFinishReason;
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.grader.Grader2;
import de.extio.lmlib.profile.ModelProfile;
import de.extio.lmlib.profile.ModelProfileService;
@Disabled("This test requires a running Ollama instance")
@SpringBootTest(webEnvironment = WebEnvironment.NONE)
@SpringBootConfiguration
@EnableAutoConfiguration
@TestPropertySource("classpath:/application-test.properties")
public class OllamaClientTest {
private static final Logger LOGGER = LoggerFactory.getLogger(OllamaClientTest.class);
@Autowired
private OllamaClient ollamaClient;
@Autowired
private ModelProfileService modelProfileService;
@Autowired
private ClientService clientService;
@Test
void listModels() throws Exception {
final var modelProfile = this.modelProfile();
final var modelNames = this.ollamaClient.getModelNames(modelProfile, true);
LOGGER.info("Models: {}", modelNames);
assertFalse(modelNames.isEmpty());
assertFalse(modelNames.getFirst().isBlank());
assertEquals(ModelProfile.ModelProvider.OLLAMA, this.clientService.getClient(modelProfile).getModelProvider());
}
@Test
void chatCompletion() throws Exception {
final var modelProfile = this.modelProfile();
final var completion = this.ollamaClient.conversation(
modelProfile,
Conversation.create("You are a helpful assistant", "Say the color is green"),
null,
false);
LOGGER.info(completion.reasoning());
LOGGER.info(completion.response());
assertTrue(Grader2.assessScoreBinary("Does the text mention the color green?", completion.response(), modelProfile, this.clientService));
}
@Test
void streamChatCompletion() throws Exception {
final var modelProfile = this.modelProfile();
final var conversation = Conversation.create("You are a helpful assistant", "I would like to play a game of chess.");
conversation.addTurn(new Conversation.Turn(Conversation.TurnType.ASSISTANT, "Which opening do you choose?"));
conversation.addTurn(new Conversation.Turn(Conversation.TurnType.USER, "You choose, but explain the opening to me."));
final var responseBuilder = new StringBuilder();
final var reasoningBuilder = new StringBuilder();
final var completion = this.ollamaClient.streamConversation(modelProfile, conversation, chunk -> {
if (chunk.reasoningContent() != null) {
System.out.print(chunk.reasoningContent());
reasoningBuilder.append(chunk.reasoningContent());
}
if (chunk.content() != null) {
System.out.print(chunk.content());
responseBuilder.append(chunk.content());
}
}, null, false);
assertEquals(completion.response(), responseBuilder.toString());
if (completion.reasoning() != null) {
assertEquals(completion.reasoning(), reasoningBuilder.toString());
}
assertTrue(Grader2.assessScoreBinary("Does the text explain a chess opening?", completion.response(), modelProfile, this.clientService));
}
@Test
void toolCallingChatCompletion() throws Exception {
final var modelProfile = this.modelProfile();
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 conversation = Conversation.create(
"You are a helpful assistant. Use the provided tools when the user asks for location information. After receiving tool results, answer the question from the tool output.",
"What is the weather in Berlin, Germany? Use the tool.");
final var completion = this.ollamaClient.conversation(
modelProfile,
conversation,
ToolCallData.required(List.of(weatherTool)).withParallelToolCalls(true),
false);
LOGGER.info(completion.reasoning());
LOGGER.info(completion.response());
LOGGER.info("Tool calls: {}", completion.toolCalls());
assertEquals(CompletionFinishReason.TOOL_CALLS, completion.finishReason());
assertFalse(completion.toolCalls().isEmpty());
final var weatherToolCall = this.findToolCall(completion.toolCalls(), "get_weather");
assertTrue(weatherToolCall.arguments().contains("Berlin"));
conversation.appendToolCallRound(completion, List.of(
new Conversation.ToolResult(ToolParameters.create()
.add("forecast", "Berlin is rainy and 21C")
.json(), weatherToolCall.id())));
LOGGER.info("Conversation after tool call: {}", conversation.getConversation());
final var finalCompletion = this.ollamaClient.conversation(modelProfile, conversation, ToolCallData.auto(List.of(weatherTool)), false);
LOGGER.info(finalCompletion.reasoning());
LOGGER.info(finalCompletion.response());
LOGGER.info("Tool calls: {}", finalCompletion.toolCalls());
assertEquals(CompletionFinishReason.DONE, finalCompletion.finishReason());
assertTrue(Grader2.assessScoreBinary("Does the text say that Berlin is rainy and 21C?", finalCompletion.response(), modelProfile, this.clientService));
}
private ModelProfile modelProfile() {
return this.modelProfileService.getModelProfile("profile.model.ollama");
}
private ToolCall findToolCall(final List<ToolCall> toolCalls, final String toolName) {
return toolCalls.stream()
.filter(toolCall -> toolName.equals(toolCall.name()))
.findFirst()
.orElseThrow(() -> new IllegalStateException("Missing tool call: " + toolName));
}
}

View File

@@ -28,7 +28,7 @@ import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.context.annotation.PropertySource;
import org.springframework.test.context.TestPropertySource;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.TestInstance.Lifecycle;
@@ -45,7 +45,7 @@ import de.extio.lmlib.profile.ModelCategory;
@SpringBootTest(webEnvironment = WebEnvironment.NONE)
@SpringBootConfiguration
@EnableAutoConfiguration
@PropertySource("classpath:/application-test.properties")
@TestPropertySource("classpath:/application-test.properties")
@TestInstance(Lifecycle.PER_CLASS)
public class GraderTest {

View File

@@ -1,9 +1,6 @@
package de.extio.lmlib.token;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.math.BigDecimal;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
@@ -13,7 +10,7 @@ import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.context.annotation.PropertySource;
import org.springframework.test.context.TestPropertySource;
import org.springframework.web.client.RestClient;
import de.extio.lmlib.profile.ModelCategory;
@@ -22,7 +19,7 @@ import de.extio.lmlib.profile.ModelProfileService;
@SpringBootTest(webEnvironment = WebEnvironment.NONE)
@SpringBootConfiguration
@EnableAutoConfiguration
@PropertySource("classpath:/application-test.properties")
@TestPropertySource("classpath:/application-test.properties")
public class TokenizerTest {
private static final String TEXT = "Hello this is a text! How are you doing? The weather is great today! Super sunny shiny!!!";