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

@@ -31,7 +31,6 @@ import de.extio.lmlib.client.Conversation.Turn;
import de.extio.lmlib.client.Conversation.TurnType;
import de.extio.lmlib.profile.ModelCategory;
import de.extio.lmlib.profile.ModelProfile;
import de.extio.lmlib.profile.ModelProfile.ModelProvider;
public interface BaseAgent {
@@ -83,15 +82,7 @@ public interface BaseAgent {
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);
@@ -161,7 +152,9 @@ public interface BaseAgent {
final boolean skipCache = split.context().isSkipCache() || split.context().isAlwaysSkipCache();
split.context().setSkipCache(false);
final var conversation = this.setupConversation(split);
var toolCallData = completionClient.supportsToolCalling() ? this.toolCallData(split.context()) : null;
final var requestedModelProfile = this.modelProfile(split.context());
final var supportsToolCalling = requestedModelProfile != null ? completionClient.supportsToolCalling(requestedModelProfile) : completionClient.supportsToolCalling(this.modelCategory(split.context()));
var toolCallData = supportsToolCalling ? this.toolCallData(split.context()) : null;
this.rememberToolCallData(split.context(), toolCallData);
boolean parseable = false;

View File

@@ -21,5 +21,7 @@ public interface Client {
ModelProvider getModelProvider();
boolean supportsToolCalling();
boolean supportsToolCalling(ModelCategory modelCategory);
boolean supportsToolCalling(ModelProfile modelProfile);
}

View File

@@ -6,6 +6,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import de.extio.lmlib.client.ollama.OllamaClient;
import de.extio.lmlib.client.oai.completion.chat.ChatCompletionClient;
import de.extio.lmlib.client.oai.completion.text.TextCompletionClient;
import de.extio.lmlib.profile.ProfileAutoConfiguration;
@@ -33,5 +34,11 @@ public class ClientAutoConfiguration {
ChatCompletionClient chatCompletionClient() {
return new ChatCompletionClient();
}
@Bean
@ConditionalOnMissingBean
OllamaClient ollamaClient() {
return new OllamaClient();
}
}

View File

@@ -38,7 +38,7 @@ public class ClientService {
}
public boolean supportsToolCalling(final ModelCategory category) {
return this.getClient(category).supportsToolCalling();
return this.getClient(category).supportsToolCalling(category);
}
@Cacheable("clients")
@@ -56,7 +56,7 @@ public class ClientService {
}
public boolean supportsToolCalling(final ModelProfile modelProfile) {
return this.getClient(modelProfile).supportsToolCalling();
return this.getClient(modelProfile).supportsToolCalling(modelProfile);
}
private static class InterceptingClient implements Client {
@@ -122,8 +122,13 @@ public class ClientService {
}
@Override
public boolean supportsToolCalling() {
return this.client.supportsToolCalling();
public boolean supportsToolCalling(final ModelCategory modelCategory) {
return this.client.supportsToolCalling(modelCategory);
}
@Override
public boolean supportsToolCalling(final ModelProfile modelProfile) {
return this.client.supportsToolCalling(modelProfile);
}
}

View File

@@ -61,7 +61,8 @@ public final class Conversation {
if (completion == null) {
throw new IllegalArgumentException("completion must not be null");
}
this.appendToolCallRound(completion.response(), completion.toolCalls(), toolResults);
final var assistantText = completion.toolCalls().isEmpty() ? completion.response() : "";
this.appendToolCallRound(assistantText, completion.toolCalls(), toolResults);
}
public void appendToolCallRound(final String assistantText, final List<ToolCall> toolCalls, final List<ToolResult> toolResults) {

View File

@@ -78,8 +78,13 @@ public class CachedClient implements Client {
}
@Override
public boolean supportsToolCalling() {
return this.client.supportsToolCalling();
public boolean supportsToolCalling(final ModelCategory modelCategory) {
return this.client.supportsToolCalling(modelCategory);
}
@Override
public boolean supportsToolCalling(final ModelProfile modelProfile) {
return this.client.supportsToolCalling(modelProfile);
}
private Completion getCachedResponse(final ModelCategory modelCategory, final ModelProfile modelProfile, final boolean skipCache, final Supplier<Completion> supplier, final String... keys) {

View File

@@ -24,6 +24,7 @@ import de.extio.lmlib.client.ToolCallData;
import de.extio.lmlib.client.ToolDefinition;
import de.extio.lmlib.client.Conversation.Turn;
import de.extio.lmlib.client.oai.completion.AbstractCompletionClient;
import de.extio.lmlib.profile.ModelCategory;
import de.extio.lmlib.profile.ModelProfile;
import de.extio.lmlib.profile.ModelProfile.ModelProvider;
@@ -36,8 +37,14 @@ public class ChatCompletionClient extends AbstractCompletionClient {
}
@Override
public boolean supportsToolCalling() {
return true;
public boolean supportsToolCalling(final ModelCategory modelCategory) {
final var modelProfile = this.modelProfileService.getModelProfile(modelCategory.getModelProfile(), modelCategory);
return this.supportsToolCalling(modelProfile);
}
@Override
public boolean supportsToolCalling(final ModelProfile modelProfile) {
return modelProfile != null && modelProfile.modelProvider() == ModelProvider.OAI_CHAT_COMPLETION;
}
@Override

View File

@@ -23,6 +23,7 @@ import de.extio.lmlib.client.Conversation;
import de.extio.lmlib.client.ToolCallData;
import de.extio.lmlib.client.Conversation.Turn;
import de.extio.lmlib.client.oai.completion.AbstractCompletionClient;
import de.extio.lmlib.profile.ModelCategory;
import de.extio.lmlib.profile.ModelProfile;
import de.extio.lmlib.profile.ModelProfile.ModelProvider;
import de.extio.lmlib.prompt.PromptStrategy;
@@ -41,7 +42,12 @@ public class TextCompletionClient extends AbstractCompletionClient {
}
@Override
public boolean supportsToolCalling() {
public boolean supportsToolCalling(final ModelCategory modelCategory) {
return false;
}
@Override
public boolean supportsToolCalling(final ModelProfile modelProfile) {
return false;
}

View File

@@ -0,0 +1,75 @@
package de.extio.lmlib.client.ollama;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonProperty;
final class OllamaChatRequest {
private String model;
private List<OllamaMessage> messages;
private List<OllamaToolDefinition> tools;
private Map<String, Object> options;
private Boolean stream;
private Object think;
@JsonProperty("model")
public String getModel() {
return this.model;
}
public void setModel(final String model) {
this.model = model;
}
@JsonProperty("messages")
public List<OllamaMessage> getMessages() {
return this.messages;
}
public void setMessages(final List<OllamaMessage> messages) {
this.messages = messages;
}
@JsonProperty("tools")
public List<OllamaToolDefinition> getTools() {
return this.tools;
}
public void setTools(final List<OllamaToolDefinition> tools) {
this.tools = tools;
}
@JsonProperty("options")
public Map<String, Object> getOptions() {
return this.options;
}
public void setOptions(final Map<String, Object> options) {
this.options = options;
}
@JsonProperty("stream")
public Boolean getStream() {
return this.stream;
}
public void setStream(final Boolean stream) {
this.stream = stream;
}
@JsonProperty("think")
public Object getThink() {
return this.think;
}
public void setThink(final Object think) {
this.think = think;
}
}

View File

@@ -0,0 +1,174 @@
package de.extio.lmlib.client.ollama;
import com.fasterxml.jackson.annotation.JsonProperty;
final class OllamaChatResponse {
private String model;
private String createdAt;
private OllamaMessage message;
private Boolean done;
private String doneReason;
private Long totalDuration;
private Long loadDuration;
private Integer promptEvalCount;
private Long promptEvalDuration;
private Integer evalCount;
private Long evalDuration;
private String error;
@JsonProperty("model")
public String getModel() {
return this.model;
}
public void setModel(final String model) {
this.model = model;
}
@JsonProperty("created_at")
public String getCreatedAt() {
return this.createdAt;
}
public void setCreatedAt(final String createdAt) {
this.createdAt = createdAt;
}
@JsonProperty("message")
public OllamaMessage getMessage() {
return this.message;
}
public void setMessage(final OllamaMessage message) {
this.message = message;
}
@JsonProperty("done")
public Boolean getDone() {
return this.done;
}
public void setDone(final Boolean done) {
this.done = done;
}
@JsonProperty("done_reason")
public String getDoneReason() {
return this.doneReason;
}
public void setDoneReason(final String doneReason) {
this.doneReason = doneReason;
}
@JsonProperty("total_duration")
public Long getTotalDuration() {
return this.totalDuration;
}
public void setTotalDuration(final Long totalDuration) {
this.totalDuration = totalDuration;
}
@JsonProperty("load_duration")
public Long getLoadDuration() {
return this.loadDuration;
}
public void setLoadDuration(final Long loadDuration) {
this.loadDuration = loadDuration;
}
@JsonProperty("prompt_eval_count")
public Integer getPromptEvalCount() {
return this.promptEvalCount;
}
public void setPromptEvalCount(final Integer promptEvalCount) {
this.promptEvalCount = promptEvalCount;
}
@JsonProperty("prompt_eval_duration")
public Long getPromptEvalDuration() {
return this.promptEvalDuration;
}
public void setPromptEvalDuration(final Long promptEvalDuration) {
this.promptEvalDuration = promptEvalDuration;
}
@JsonProperty("eval_count")
public Integer getEvalCount() {
return this.evalCount;
}
public void setEvalCount(final Integer evalCount) {
this.evalCount = evalCount;
}
@JsonProperty("eval_duration")
public Long getEvalDuration() {
return this.evalDuration;
}
public void setEvalDuration(final Long evalDuration) {
this.evalDuration = evalDuration;
}
@JsonProperty("error")
public String getError() {
return this.error;
}
public void setError(final String error) {
this.error = error;
}
void copyFrom(final OllamaChatResponse other) {
if (other.model != null && !other.model.isBlank()) {
this.model = other.model;
}
if (other.createdAt != null && !other.createdAt.isBlank()) {
this.createdAt = other.createdAt;
}
if (other.done != null) {
this.done = other.done;
}
if (other.doneReason != null && !other.doneReason.isBlank()) {
this.doneReason = other.doneReason;
}
if (other.totalDuration != null) {
this.totalDuration = other.totalDuration;
}
if (other.loadDuration != null) {
this.loadDuration = other.loadDuration;
}
if (other.promptEvalCount != null) {
this.promptEvalCount = other.promptEvalCount;
}
if (other.promptEvalDuration != null) {
this.promptEvalDuration = other.promptEvalDuration;
}
if (other.evalCount != null) {
this.evalCount = other.evalCount;
}
if (other.evalDuration != null) {
this.evalDuration = other.evalDuration;
}
if (other.error != null && !other.error.isBlank()) {
this.error = other.error;
}
}
}

View File

@@ -0,0 +1,577 @@
package de.extio.lmlib.client.ollama;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatusCode;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestClientResponseException;
import com.fasterxml.jackson.annotation.JsonInclude;
import de.extio.lmlib.client.Chunk;
import de.extio.lmlib.client.Client;
import de.extio.lmlib.client.Completion;
import de.extio.lmlib.client.CompletionFinishReason;
import de.extio.lmlib.client.CompletionStatistics;
import de.extio.lmlib.client.Conversation;
import de.extio.lmlib.client.Conversation.Turn;
import de.extio.lmlib.client.ToolCall;
import de.extio.lmlib.client.ToolCallData;
import de.extio.lmlib.client.ToolDefinition;
import de.extio.lmlib.client.oai.completion.FinishReasons;
import de.extio.lmlib.profile.ModelCategory;
import de.extio.lmlib.profile.ModelProfile;
import de.extio.lmlib.profile.ModelProfile.ModelProvider;
import de.extio.lmlib.profile.ModelProfileService;
import de.extio.lmlib.token.TokenizerResolver;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
public class OllamaClient implements Client {
private static final Logger LOGGER = LoggerFactory.getLogger(OllamaClient.class);
private static final OllamaDialect DEFAULT_OLLAMA_DIALECT = new OllamaDialect() {
};
private final Map<String, List<String>> resolvedModelNames = new ConcurrentHashMap<>();
private final Map<String, OllamaShowResponse> resolvedModelDetails = new ConcurrentHashMap<>();
private final ObjectMapper objectMapper = JsonMapper.builder()
.changeDefaultPropertyInclusion(incl -> incl.withValueInclusion(JsonInclude.Include.NON_NULL))
.changeDefaultPropertyInclusion(incl -> incl.withContentInclusion(JsonInclude.Include.NON_NULL))
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.build();
@Autowired
@Qualifier("lmLibRestClientBuilder")
private RestClient.Builder restClientBuilder;
@Autowired
private TokenizerResolver tokenizerResolver;
@Autowired
private ModelProfileService modelProfileService;
@Autowired(required = false)
private OllamaDialect ollamaDialect;
@Override
public Completion conversation(final ModelCategory modelCategory, final Conversation conversation, final ToolCallData toolCallData, final boolean skipCache) {
return this.streamConversation(modelCategory, conversation, null, toolCallData, skipCache);
}
@Override
public Completion conversation(final ModelProfile modelProfile, final Conversation conversation, final ToolCallData toolCallData, final boolean skipCache) {
return this.streamConversation(modelProfile, conversation, null, toolCallData, skipCache);
}
@Override
public Completion streamConversation(final ModelCategory modelCategory_, final Conversation conversation, final Consumer<Chunk> chunkConsumer, final ToolCallData toolCallData, final boolean skipCache) {
final var modelCategory = modelCategory_ == null ? ModelCategory.MEDIUM : modelCategory_;
final var modelProfile = this.modelProfileService.getModelProfile(modelCategory.getModelProfile(), modelCategory);
return this.streamConversation(modelProfile, conversation, chunkConsumer, toolCallData, skipCache);
}
@Override
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.OLLAMA) {
throw new IllegalArgumentException("Invalid ModelProfile");
}
final var request = this.createRequest(conversation, modelProfile, chunkConsumer != null, toolCallData);
final String requestBody;
try {
requestBody = this.objectMapper.writeValueAsString(request);
}
catch (final JacksonException e) {
throw new IllegalStateException("Cannot serialize request body", e);
}
LOGGER.debug("Requesting Ollama chat at {}", modelProfile.url());
final var start = LocalDateTime.now();
final var restClient = this.restClientBuilder.baseUrl(modelProfile.url()).build();
var requestSpec = restClient
.method(HttpMethod.POST)
.uri("/api/chat")
.header("Content-Type", "application/json");
if (modelProfile.apiKey() != null && !modelProfile.apiKey().isBlank()) {
requestSpec = requestSpec.header("Authorization", "Bearer " + modelProfile.apiKey());
}
final var response = requestSpec
.body(requestBody)
.exchange((clientRequest, clientResponse) -> {
if (clientResponse.getStatusCode().isError()) {
this.logErrorResponse(clientResponse.getStatusCode(), clientResponse.getBody());
throw new IllegalStateException("Error response from server: " + clientResponse.getStatusCode());
}
if (chunkConsumer == null) {
final var ollamaResponse = this.objectMapper.readValue(clientResponse.getBody(), OllamaChatResponse.class);
this.throwIfEmbeddedError(ollamaResponse);
return ollamaResponse;
}
return this.readStream(clientResponse.getBody(), chunkConsumer);
});
this.throwIfEmbeddedError(response);
final var content = response.getMessage() != null ? response.getMessage().getContent() : null;
final var reasoning = response.getMessage() != null ? response.getMessage().getThinking() : null;
final var toolCalls = this.toToolCalls(response.getMessage() != null ? response.getMessage().getToolCalls() : List.of());
final var finishReason = this.mapFinishReason(response.getDoneReason(), toolCalls);
final var statistics = this.createCompletionStatistics(modelProfile, start, response, conversation, content, reasoning);
return new Completion(content, reasoning, finishReason, statistics, toolCalls);
}
@Override
public List<String> getModelNames(final ModelProfile modelProfile, final boolean forceReload) {
if (modelProfile == null || modelProfile.modelProvider() != ModelProvider.OLLAMA) {
return List.of();
}
if (modelProfile.modelName() != null && !modelProfile.modelName().isBlank()) {
return List.of(modelProfile.modelName());
}
if (modelProfile.url() == null || modelProfile.url().isBlank()) {
return List.of();
}
final var cacheKey = modelProfile.category() + "|" + modelProfile.url() + "|" + modelProfile.apiKey();
if (forceReload) {
final var modelNames = this.loadModelNames(modelProfile);
this.resolvedModelNames.put(cacheKey, modelNames);
return modelNames;
}
return this.resolvedModelNames.computeIfAbsent(cacheKey, key -> this.loadModelNames(modelProfile));
}
@Override
public ModelProvider getModelProvider() {
return ModelProvider.OLLAMA;
}
@Override
public boolean supportsToolCalling(final ModelCategory modelCategory) {
final var modelProfile = this.modelProfileService.getModelProfile(modelCategory.getModelProfile(), modelCategory);
return this.supportsToolCalling(modelProfile);
}
@Override
public boolean supportsToolCalling(final ModelProfile modelProfile) {
if (modelProfile == null || modelProfile.modelProvider() != ModelProvider.OLLAMA) {
return false;
}
final var modelName = this.getPrimaryModelName(modelProfile);
if (modelName == null || modelName.isBlank()) {
return false;
}
return this.supportsToolCapability(this.getModelDetails(modelProfile, modelName));
}
private OllamaChatRequest createRequest(final Conversation conversation, final ModelProfile modelProfile, final boolean streaming, final ToolCallData toolCallData) {
final var modelName = this.getPrimaryModelName(modelProfile);
if (modelName == null || modelName.isBlank()) {
throw new IllegalStateException("No model name resolved for Ollama profile " + modelProfile.category());
}
final var modelDetails = this.getModelDetails(modelProfile, modelName);
final var request = new OllamaChatRequest();
request.setModel(modelName);
request.setMessages(this.createMessages(conversation));
request.setStream(streaming);
final var think = this.createThinkRequestValue(modelProfile, modelDetails);
if (think != null) {
request.setThink(think);
}
request.setOptions(this.createOptions(modelProfile));
if (toolCallData != null && toolCallData.hasTools()) {
if (this.supportsToolCapability(modelDetails)) {
request.setTools(this.createTools(toolCallData.tools()));
}
else {
LOGGER.debug("Ollama model {} does not advertise tool capability. Tools will be omitted from the request.", modelName);
}
}
return request;
}
private Object createThinkRequestValue(final ModelProfile modelProfile, final OllamaShowResponse modelDetails) {
final var thinkMode = this.getOllamaDialect().think(modelProfile);
if (thinkMode == null || !thinkMode.isEnabled()) {
return null;
}
if (!this.supportsThinkingCapability(modelDetails)) {
LOGGER.debug("Ollama model {} does not advertise thinking capability. Think will be omitted from the request.", this.getPrimaryModelName(modelProfile));
return null;
}
return thinkMode;
}
private List<OllamaMessage> createMessages(final Conversation conversation) {
final List<OllamaMessage> messages = new ArrayList<>();
final Map<String, String> toolCallNamesById = new LinkedHashMap<>();
for (final Turn turn : conversation.getConversation()) {
final var message = new OllamaMessage();
message.setRole(this.mapRole(turn));
message.setContent(turn.text() != null ? turn.text() : "");
if (!turn.toolCalls().isEmpty()) {
message.setToolCalls(turn.toolCalls().stream().map(toolCall -> {
final var function = new OllamaFunctionCall();
function.setName(toolCall.name());
function.setArguments(this.parseJsonOrText(toolCall.arguments()));
final var ollamaToolCall = new OllamaToolCall();
ollamaToolCall.setId(toolCall.id());
ollamaToolCall.setType(toolCall.type());
ollamaToolCall.setFunction(function);
if (toolCall.id() != null && !toolCall.id().isBlank() && toolCall.name() != null && !toolCall.name().isBlank()) {
toolCallNamesById.put(toolCall.id(), toolCall.name());
}
return ollamaToolCall;
}).toList());
}
message.setToolCallId(turn.toolCallId());
if (turn.type() == Conversation.TurnType.TOOL && turn.toolCallId() != null && !turn.toolCallId().isBlank()) {
message.setToolName(toolCallNamesById.get(turn.toolCallId()));
}
messages.add(message);
}
return messages;
}
private String mapRole(final Turn turn) {
return switch (turn.type()) {
case SYSTEM -> "system";
case USER -> "user";
case ASSISTANT -> "assistant";
case TOOL -> "tool";
};
}
private Map<String, Object> createOptions(final ModelProfile modelProfile) {
final var options = new LinkedHashMap<String, Object>();
options.put("num_predict", modelProfile.maxTokens());
options.put("temperature", modelProfile.temperature());
options.put("top_p", modelProfile.topP());
return options;
}
private List<OllamaToolDefinition> createTools(final List<ToolDefinition> toolDefinitions) {
return toolDefinitions.stream().map(toolDefinition -> {
final var function = new OllamaToolFunction();
function.setName(toolDefinition.name());
function.setDescription(toolDefinition.description());
function.setParameters(toolDefinition.parameters());
final var tool = new OllamaToolDefinition();
tool.setType("function");
tool.setFunction(function);
return tool;
}).toList();
}
private OllamaChatResponse readStream(final InputStream inputStream, final Consumer<Chunk> chunkConsumer) throws IOException {
final var response = new OllamaChatResponse();
final var contentBuilder = new StringBuilder();
final var reasoningBuilder = new StringBuilder();
final List<ToolCall> toolCalls = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
line = line.trim();
if (line.isEmpty()) {
continue;
}
final var streamResponse = this.objectMapper.readValue(line, OllamaChatResponse.class);
this.throwIfEmbeddedError(streamResponse);
response.copyFrom(streamResponse);
if (streamResponse.getMessage() != null) {
String content = null;
String reasoning = null;
if (streamResponse.getMessage().getContent() != null && !streamResponse.getMessage().getContent().isEmpty()) {
content = streamResponse.getMessage().getContent();
contentBuilder.append(content);
}
if (streamResponse.getMessage().getThinking() != null && !streamResponse.getMessage().getThinking().isEmpty()) {
reasoning = streamResponse.getMessage().getThinking();
reasoningBuilder.append(reasoning);
}
if (content != null || reasoning != null) {
chunkConsumer.accept(new Chunk(content, reasoning));
}
if (streamResponse.getMessage().getToolCalls() != null && !streamResponse.getMessage().getToolCalls().isEmpty()) {
toolCalls.clear();
toolCalls.addAll(this.toToolCalls(streamResponse.getMessage().getToolCalls()));
}
}
if (Boolean.TRUE.equals(streamResponse.getDone())) {
break;
}
}
}
final var message = new OllamaMessage();
message.setRole("assistant");
message.setContent(contentBuilder.toString());
message.setThinking(reasoningBuilder.toString());
message.setToolCalls(this.toOllamaToolCalls(toolCalls));
response.setMessage(message);
return response;
}
private List<ToolCall> toToolCalls(final List<OllamaToolCall> ollamaToolCalls) {
if (ollamaToolCalls == null || ollamaToolCalls.isEmpty()) {
return List.of();
}
final List<ToolCall> toolCalls = new ArrayList<>();
for (int i = 0; i < ollamaToolCalls.size(); i++) {
final var toolCall = ollamaToolCalls.get(i);
if (toolCall == null || toolCall.getFunction() == null || toolCall.getFunction().getName() == null || toolCall.getFunction().getName().isBlank()) {
continue;
}
toolCalls.add(new ToolCall(
toolCall.getId() != null && !toolCall.getId().isBlank() ? toolCall.getId() : "ollama-tool-call-" + (i + 1),
toolCall.getType() != null && !toolCall.getType().isBlank() ? toolCall.getType() : "function",
toolCall.getFunction().getName(),
this.serializeArguments(toolCall.getFunction().getArguments())));
}
return List.copyOf(toolCalls);
}
private List<OllamaToolCall> toOllamaToolCalls(final List<ToolCall> toolCalls) {
return toolCalls.stream().map(toolCall -> {
final var function = new OllamaFunctionCall();
function.setName(toolCall.name());
function.setArguments(this.parseJsonOrText(toolCall.arguments()));
final var ollamaToolCall = new OllamaToolCall();
ollamaToolCall.setId(toolCall.id());
ollamaToolCall.setType(toolCall.type());
ollamaToolCall.setFunction(function);
return ollamaToolCall;
}).toList();
}
private CompletionFinishReason mapFinishReason(final String finishReason, final List<ToolCall> toolCalls) {
if (toolCalls != null && !toolCalls.isEmpty()) {
return CompletionFinishReason.TOOL_CALLS;
}
return switch (finishReason) {
case FinishReasons.FINISH_REASON_LENGTH -> CompletionFinishReason.TOKEN_LIMIT_REACHED;
case FinishReasons.FINISH_REASON_CONTENT_FILTER -> CompletionFinishReason.CONTENT_FILTERED;
case FinishReasons.FINISH_REASON_TOOL_CALLS -> CompletionFinishReason.TOOL_CALLS;
default -> CompletionFinishReason.DONE;
};
}
private CompletionStatistics createCompletionStatistics(final ModelProfile modelProfile, final LocalDateTime start, final OllamaChatResponse response, final Conversation conversation, final String content, final String reasoning) {
int inTokens = 0;
int outTokens = 0;
int reasoningOutTokens = 0;
if (response.getPromptEvalCount() != null && response.getPromptEvalCount() > 0) {
inTokens = response.getPromptEvalCount();
}
if (response.getEvalCount() != null && response.getEvalCount() > 0) {
outTokens = response.getEvalCount();
}
if (reasoning != null && !reasoning.isBlank()) {
reasoningOutTokens = this.tokenizerResolver.count(reasoning, modelProfile);
outTokens = Math.max(0, outTokens - reasoningOutTokens);
}
if (inTokens <= 0) {
inTokens = this.tokenizerResolver.count(this.createPromptForCounting(conversation), modelProfile);
}
if (outTokens <= 0 && content != null && !content.isBlank()) {
outTokens = this.tokenizerResolver.count(content, modelProfile);
}
final var duration = response.getTotalDuration() != null && response.getTotalDuration() > 0
? Duration.ofNanos(response.getTotalDuration())
: Duration.between(start, LocalDateTime.now());
var cost = BigDecimal.ZERO;
cost = cost.add(BigDecimal.valueOf(inTokens).multiply(modelProfile.costPerInToken()));
cost = cost.add(BigDecimal.valueOf(outTokens).multiply(modelProfile.costPerOutToken()));
cost = cost.add(BigDecimal.valueOf(reasoningOutTokens).multiply(modelProfile.costPerReasoningOutToken()));
return new CompletionStatistics(1, duration, inTokens, 0, outTokens, reasoningOutTokens, cost, false);
}
private String createPromptForCounting(final Conversation conversation) {
final var builder = new StringBuilder();
for (final var turn : conversation.getConversation()) {
builder.append(turn.type().name());
builder.append(':');
builder.append(turn.text() != null ? turn.text() : "");
builder.append('\n');
for (final var toolCall : turn.toolCalls()) {
builder.append(toolCall.name());
builder.append(':');
builder.append(toolCall.arguments());
builder.append('\n');
}
}
return builder.toString();
}
private String getPrimaryModelName(final ModelProfile modelProfile) {
return this.getModelNames(modelProfile, false).stream().findFirst().orElse("");
}
private List<String> loadModelNames(final ModelProfile modelProfile) {
try {
var requestSpec = this.restClientBuilder.baseUrl(modelProfile.url()).build()
.method(HttpMethod.GET)
.uri("/api/tags");
if (modelProfile.apiKey() != null && !modelProfile.apiKey().isBlank()) {
requestSpec = requestSpec.header("Authorization", "Bearer " + modelProfile.apiKey());
}
final var response = requestSpec.retrieve().body(OllamaTagsResponse.class);
return response == null || response.getModels() == null ? List.of()
: response.getModels().stream()
.map(model -> model.getName() != null && !model.getName().isBlank() ? model.getName() : model.getModel())
.filter(Objects::nonNull)
.filter(modelName -> !modelName.isBlank())
.toList();
}
catch (final RestClientResponseException e) {
LOGGER.warn("Failed to load Ollama model names: {} {}", e.getStatusCode(), e.getStatusText());
return List.of();
}
catch (final RestClientException e) {
LOGGER.warn("Failed to load Ollama model names: {}", e.getMessage());
return List.of();
}
catch (final RuntimeException e) {
LOGGER.warn("Failed to load Ollama model names", e);
return List.of();
}
}
private OllamaShowResponse getModelDetails(final ModelProfile modelProfile, final String modelName) {
final var cacheKey = this.cacheKey(modelProfile, modelName);
return this.resolvedModelDetails.computeIfAbsent(cacheKey, key -> this.loadModelDetails(modelProfile, modelName));
}
private OllamaShowResponse loadModelDetails(final ModelProfile modelProfile, final String modelName) {
try {
final var request = new OllamaShowRequest();
request.setModel(modelName);
request.setVerbose(false);
var requestSpec = this.restClientBuilder.baseUrl(modelProfile.url()).build()
.method(HttpMethod.POST)
.uri("/api/show")
.header("Content-Type", "application/json");
if (modelProfile.apiKey() != null && !modelProfile.apiKey().isBlank()) {
requestSpec = requestSpec.header("Authorization", "Bearer " + modelProfile.apiKey());
}
final var requestBody = this.objectMapper.writeValueAsString(request);
final var response = requestSpec.body(requestBody).retrieve().body(OllamaShowResponse.class);
return response == null ? new OllamaShowResponse() : response;
}
catch (final RestClientResponseException e) {
LOGGER.warn("Failed to load Ollama model details for {}: {} {}", modelName, e.getStatusCode(), e.getStatusText());
return new OllamaShowResponse();
}
catch (final RestClientException e) {
LOGGER.warn("Failed to load Ollama model details for {}: {}", modelName, e.getMessage());
return new OllamaShowResponse();
}
catch (final JacksonException e) {
LOGGER.warn("Failed to serialize Ollama show request for {}", modelName, e);
return new OllamaShowResponse();
}
catch (final RuntimeException e) {
LOGGER.warn("Failed to load Ollama model details for {}", modelName, e);
return new OllamaShowResponse();
}
}
private boolean supportsThinkingCapability(final OllamaShowResponse modelDetails) {
return this.hasCapability(modelDetails, "thinking");
}
private boolean supportsToolCapability(final OllamaShowResponse modelDetails) {
return this.hasCapability(modelDetails, "tools")
|| this.hasCapability(modelDetails, "tool")
|| this.hasCapability(modelDetails, "tool-calling")
|| this.hasCapability(modelDetails, "tool_calling");
}
private boolean hasCapability(final OllamaShowResponse modelDetails, final String expectedCapability) {
if (modelDetails == null || modelDetails.getCapabilities() == null || modelDetails.getCapabilities().isEmpty()) {
return false;
}
return modelDetails.getCapabilities().stream()
.filter(Objects::nonNull)
.map(capability -> capability.toLowerCase(Locale.ROOT).trim())
.anyMatch(capability -> capability.equals(expectedCapability) || capability.contains(expectedCapability));
}
private String cacheKey(final ModelProfile modelProfile, final String modelName) {
return modelProfile.category() + "|" + modelProfile.url() + "|" + modelProfile.apiKey() + "|" + modelName;
}
private OllamaDialect getOllamaDialect() {
return this.ollamaDialect == null ? DEFAULT_OLLAMA_DIALECT : this.ollamaDialect;
}
private void throwIfEmbeddedError(final OllamaChatResponse response) {
if (response != null && response.getError() != null && !response.getError().isBlank()) {
LOGGER.warn("Error response from Ollama: {}", response.getError());
throw new IllegalStateException(response.getError());
}
}
private void logErrorResponse(final HttpStatusCode statusCode, final InputStream responseBody) {
try {
LOGGER.warn("Error response from server: {} {}", statusCode, new String(responseBody.readAllBytes(), StandardCharsets.UTF_8));
}
catch (final IOException e) {
LOGGER.warn("Error response from server: {} <failed to read response body: {}>", statusCode, e.getMessage());
}
}
private Object parseJsonOrText(final String value) {
if (value == null || value.isBlank()) {
return value;
}
try {
return this.objectMapper.readValue(value, Object.class);
}
catch (final Exception e) {
return value;
}
}
private String serializeArguments(final Object value) {
if (value == null) {
return null;
}
if (value instanceof final String string) {
return string;
}
try {
return this.objectMapper.writeValueAsString(value);
}
catch (final JacksonException e) {
throw new IllegalStateException("Cannot serialize tool call arguments", e);
}
}
}

View File

@@ -0,0 +1,35 @@
package de.extio.lmlib.client.ollama;
import com.fasterxml.jackson.annotation.JsonValue;
import de.extio.lmlib.profile.ModelProfile;
public interface OllamaDialect {
default ThinkMode think(final ModelProfile modelProfile) {
return ThinkMode.ENABLED;
}
enum ThinkMode {
DISABLED(Boolean.FALSE),
ENABLED(Boolean.TRUE),
LOW("low"),
MEDIUM("medium"),
HIGH("high");
private final Object wireValue;
ThinkMode(final Object wireValue) {
this.wireValue = wireValue;
}
public boolean isEnabled() {
return (this.wireValue instanceof final Boolean val && val) || (this.wireValue instanceof final String str && !str.isBlank());
}
@JsonValue
public Object getWireValue() {
return this.wireValue;
}
}
}

View File

@@ -0,0 +1,39 @@
package de.extio.lmlib.client.ollama;
import com.fasterxml.jackson.annotation.JsonProperty;
final class OllamaFunctionCall {
private String name;
private String description;
private Object arguments;
@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("arguments")
public Object getArguments() {
return this.arguments;
}
public void setArguments(final Object arguments) {
this.arguments = arguments;
}
}

View File

@@ -0,0 +1,28 @@
package de.extio.lmlib.client.ollama;
import com.fasterxml.jackson.annotation.JsonProperty;
final class OllamaListedModel {
private String name;
private String model;
@JsonProperty("name")
public String getName() {
return this.name;
}
public void setName(final String name) {
this.name = name;
}
@JsonProperty("model")
public String getModel() {
return this.model;
}
public void setModel(final String model) {
this.model = model;
}
}

View File

@@ -0,0 +1,74 @@
package de.extio.lmlib.client.ollama;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonProperty;
final class OllamaMessage {
private String role;
private String content;
private String thinking;
private List<OllamaToolCall> toolCalls;
private String toolCallId;
private String toolName;
@JsonProperty("role")
public String getRole() {
return this.role;
}
public void setRole(final String role) {
this.role = role;
}
@JsonProperty("content")
public String getContent() {
return this.content;
}
public void setContent(final String content) {
this.content = content;
}
@JsonProperty("thinking")
public String getThinking() {
return this.thinking;
}
public void setThinking(final String thinking) {
this.thinking = thinking;
}
@JsonProperty("tool_calls")
public List<OllamaToolCall> getToolCalls() {
return this.toolCalls;
}
public void setToolCalls(final List<OllamaToolCall> toolCalls) {
this.toolCalls = toolCalls;
}
@JsonProperty("tool_call_id")
public String getToolCallId() {
return this.toolCallId;
}
public void setToolCallId(final String toolCallId) {
this.toolCallId = toolCallId;
}
@JsonProperty("tool_name")
public String getToolName() {
return this.toolName;
}
public void setToolName(final String toolName) {
this.toolName = toolName;
}
}

View File

@@ -0,0 +1,28 @@
package de.extio.lmlib.client.ollama;
import com.fasterxml.jackson.annotation.JsonProperty;
final class OllamaShowRequest {
private String model;
private Boolean verbose;
@JsonProperty("model")
public String getModel() {
return this.model;
}
public void setModel(final String model) {
this.model = model;
}
@JsonProperty("verbose")
public Boolean getVerbose() {
return this.verbose;
}
public void setVerbose(final Boolean verbose) {
this.verbose = verbose;
}
}

View File

@@ -0,0 +1,19 @@
package de.extio.lmlib.client.ollama;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonProperty;
final class OllamaShowResponse {
private List<String> capabilities;
@JsonProperty("capabilities")
public List<String> getCapabilities() {
return this.capabilities;
}
public void setCapabilities(final List<String> capabilities) {
this.capabilities = capabilities;
}
}

View File

@@ -0,0 +1,19 @@
package de.extio.lmlib.client.ollama;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonProperty;
final class OllamaTagsResponse {
private List<OllamaListedModel> models;
@JsonProperty("models")
public List<OllamaListedModel> getModels() {
return this.models;
}
public void setModels(final List<OllamaListedModel> models) {
this.models = models;
}
}

View File

@@ -0,0 +1,39 @@
package de.extio.lmlib.client.ollama;
import com.fasterxml.jackson.annotation.JsonProperty;
final class OllamaToolCall {
private String id;
private String type;
private OllamaFunctionCall function;
@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 OllamaFunctionCall getFunction() {
return this.function;
}
public void setFunction(final OllamaFunctionCall function) {
this.function = function;
}
}

View File

@@ -0,0 +1,28 @@
package de.extio.lmlib.client.ollama;
import com.fasterxml.jackson.annotation.JsonProperty;
final class OllamaToolDefinition {
private String type;
private OllamaToolFunction function;
@JsonProperty("type")
public String getType() {
return this.type;
}
public void setType(final String type) {
this.type = type;
}
@JsonProperty("function")
public OllamaToolFunction getFunction() {
return this.function;
}
public void setFunction(final OllamaToolFunction function) {
this.function = function;
}
}

View File

@@ -0,0 +1,41 @@
package de.extio.lmlib.client.ollama;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonProperty;
final class OllamaToolFunction {
private String name;
private String description;
private Map<String, Object> parameters;
@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;
}
}

View File

@@ -45,6 +45,6 @@ public record ModelProfile(
}
public static enum ModelProvider {
OAI_TEXT_COMPLETION, OAI_CHAT_COMPLETION;
OAI_TEXT_COMPLETION, OAI_CHAT_COMPLETION, OLLAMA;
}
}