Add full support for Ollama AI models

- Implement a native client (OllamaApi) to leverage chat/streaming and embedding endpoints.
 - Add a OllamaChatClient implementing the ChatClinet and StreamingChatClinet interfaces.
 - Add a OllamaEmbedding clinent that impl. the EmbeddingClinet interface.
 - Add AutoConfiguraitons with properties for the chat and the embedding clients.
 - Add unit and ITs for the OllamaApi, OllamaChatClient, OllamaEmbeddingClient, and related auto-configuraitons.
 - Remove the old ollama impl. classes and tests.
 - minor fixes to the bedrok test methods names.
This commit is contained in:
Christian Tzolov
2023-12-20 14:19:27 +01:00
committed by Mark Pollack
parent 1a8fcac206
commit 9ab857a8ab
29 changed files with 2470 additions and 598 deletions

View File

@@ -13,6 +13,15 @@ Let's make your `@Beans` intelligent!
:warning:
### Breaking Changes
December 20, 2023 Update
Refactor the Ollama client and related classes and package names
- Replace the org.springframework.ai.ollama.client.OllamaClient by org.springframework.ai.ollama.OllamaChatClient.
- The OllamaChatClient method signatures have changed.
- Rename the org.springframework.ai.autoconfigure.ollama.OllamaProperties into org.springframework.ai.autoconfigure.ollama.OllamaChatProperties and change the suffix to: `spring.ai.ollama.chat`. Some of the properties have changed as well.
December 19, 2023 Update
Renaming of AiClient and related classes and packagenames
@@ -22,7 +31,7 @@ Renaming of AiClient and related classes and packagenames
* Rename AiStreamClient to StreamingChatClient
* Rename package org.sf.ai.client to org.sf.ai.chat
Rename artifact ID of
Rename artifact ID of
* `transformers-embedding` to `spring-ai-transformers`
@@ -144,7 +153,7 @@ Following vector stores are supported:
```xml
<dependency>
<groupId>org.springframework.ai</groupId>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-azure-vector-store-spring-boot-starter</artifactId>
<version>0.8.0-SNAPSHOT</version>
</dependency>
@@ -154,7 +163,7 @@ Following vector stores are supported:
```xml
<dependency>
<groupId>org.springframework.ai</groupId>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-chroma-store-spring-boot-starter</artifactId>
<version>0.8.0-SNAPSHOT</version>
</dependency>
@@ -163,7 +172,7 @@ Following vector stores are supported:
```xml
<dependency>
<groupId>org.springframework.ai</groupId>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-milvus-store-spring-boot-starter</artifactId>
<version>0.8.0-SNAPSHOT</version>
</dependency>
@@ -173,7 +182,7 @@ Following vector stores are supported:
```xml
<dependency>
<groupId>org.springframework.ai</groupId>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
<version>0.8.0-SNAPSHOT</version>
</dependency>
@@ -182,7 +191,7 @@ Following vector stores are supported:
```xml
<dependency>
<groupId>org.springframework.ai</groupId>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pinecone-store-spring-boot-starter</artifactId>
<version>0.8.0-SNAPSHOT</version>
</dependency>
@@ -191,7 +200,7 @@ Following vector stores are supported:
```xml
<dependency>
<groupId>org.springframework.ai</groupId>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-weaviate-store-spring-boot-starter</artifactId>
<version>0.8.0-SNAPSHOT</version>
</dependency>
@@ -200,7 +209,7 @@ Following vector stores are supported:
```xml
<dependency>
<groupId>org.springframework.ai</groupId>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-neo4j-store-spring-boot-starter</artifactId>
<version>0.8.0-SNAPSHOT</version>
</dependency>

View File

@@ -0,0 +1,120 @@
# 1. Ollama Chat and Embedding
## 1.1 OllamaApi
[OllamaApi](./src/main/java/org/springframework/ai/ollama/api/OllamaApi.java) provides is lightweight Java client for [Ollama models](https://ollama.ai/).
The OllamaApi provides the Chat completion as well as Embedding endpoints.
Following class diagram illustrates the OllamaApi interface and building blocks for chat completion:
![OllamaApi Class Diagram](./src/test/resources/doc/Ollama%20Chat%20API.jpg)
The OllamaApi can supports all [Ollama Models](https://ollama.ai/library) providing synchronous chat completion, streaming chat completion and embedding:
```java
ChatResponse chat(ChatRequest chatRequest)
Flux<ChatResponse> streamingChat(ChatRequest chatRequest)
EmbeddingResponse embeddings(EmbeddingRequest embeddingRequest)
```
> NOTE: OllamaApi expose also the Ollama `generation` endpoint but later if inferior compared to the Ollama `chat` endpoint.
The `OllamaApiOptions` is helper class used as type-safe option builder. It provides `toMap` to convert the content into `Map<String, Object>`.
Here is a simple snippet how to use the OllamaApi programmatically:
```java
var request = ChatRequest.builder("orca-mini")
.withStream(false)
.withMessages(List.of(Message.builder(Role.user)
.withContent("What is the capital of Bulgaria and what is the size? " + "What it the national anthem?")
.build()))
.withOptions(Options.builder().withTemperature(0.9f).build())
.build();
ChatResponse response = ollamaApi.chat(request);
```
```java
var request = ChatRequest.builder("orca-mini")
.withStream(true)
.withMessages(List.of(Message.builder(Role.user)
.withContent("What is the capital of Bulgaria and what is the size? " + "What it the national anthem?")
.build()))
.withOptions(Options.builder().withTemperature(0.9f).build().toMap())
.build();
Flux<ChatResponse> response = ollamaApi.streamingChat(request);
List<ChatResponse> responses = response.collectList().block();
```
```java
EmbeddingRequest request = new EmbeddingRequest("orca-mini", "I like to eat apples");
EmbeddingResponse response = ollamaApi.embeddings(request);
```
## 1.2 OllamaChatClient and OllamaEmbeddingClient
The [OllamaChatClient](./src/main/java/org/springframework/ai/ollama/OllamaChatClient.java) implements the Spring-Ai `ChatClient` and `StreamingChatClient` interfaces.
The [OllamaEmbeddingClient](./src/main/java/org/springframework/ai/ollama/OllamaEmbeddingClient.java) implements the Spring AI `EmbeddingClient` interface.
Both the OllamaChatClient and the OllamaEmbeddingClient leverage the `OllamaApi`.
You can configure the clients like this:
```java
@Bean
public OllamaApi ollamaApi() {
return new OllamaApi(baseUrl);
}
@Bean
public OllamaChatClient ollamaChat(OllamaApi ollamaApi) {
return new OllamaChatClient(ollamaApi).withModel(MODEL)
.withOptions(OllamaApiOptions.Options.builder().withTemperature(0.9f).build());
}
@Bean
public OllamaEmbeddingClient ollamaEmbedding(OllamaApi ollamaApi) {
return new OllamaEmbeddingClient(ollamaApi).withModel("orca-mini");
}
```
or you can leverage the `spring-ai-ollama-spring-boot-starter` Spring Boot starter.
For this add the following dependency:
```xml
<dependency>
<artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
<groupId>org.springframework.ai</groupId>
<version>0.8.0-SNAPSHOT</version>
</dependency>
```
Use the `OllamaChatProperties` to configure the Ollama Chat client:
| Property | Description | Default |
| ------------- | ------------- | ------------- |
| spring.ai.ollama.chat.model | Model to use. | llama2 |
| spring.ai.ollama.chat.base-url | The base url of the Ollama server. | http://localhost:11434 |
| spring.ai.ollama.chat.enabled | Allows you to disable the Ollama Chat autoconfiguration. | true |
| spring.ai.ollama.chat.temperature | Controls the randomness of the output. Values can range over [0.0,1.0] | 0.8 |
| spring.ai.ollama.chat.topP | The maximum cumulative probability of tokens to consider when sampling. | - |
| spring.ai.ollama.chat.topK | Max number or responses to generate. | - |
| spring.ai.options.chat.options (WIP) | A Map<String,Object> used to configure the Chat client. | - |
and `OllamaEmbeddingProperties` to configure the Ollama Embedding client:
| Property | Description | Default |
| ------------- | ------------- | ------------- |
| spring.ai.ollama.embedding.model | Model to use. | llama2 |
| spring.ai.ollama.embedding.base-url | The base url of the Ollama server. | http://localhost:11434 |
| spring.ai.ollama.embedding.enabled | Allows you to disable the Ollama embedding autoconfiguration. | true |
| spring.ai.options.embedding.options (WIP) | A Map<String,Object> used to configure the embedding client. | - |

View File

@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.ai</groupId>
@@ -28,6 +27,23 @@
<version>${project.parent.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webflux</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
@@ -39,5 +55,17 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,150 @@
/*
* Copyright 2023-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.ollama;
import java.util.List;
import java.util.Map;
import reactor.core.publisher.Flux;
import org.springframework.ai.chat.ChatClient;
import org.springframework.ai.chat.ChatResponse;
import org.springframework.ai.chat.Generation;
import org.springframework.ai.chat.StreamingChatClient;
import org.springframework.ai.metadata.ChoiceMetadata;
import org.springframework.ai.metadata.Usage;
import org.springframework.ai.ollama.api.OllamaApi;
import org.springframework.ai.ollama.api.OllamaApi.ChatRequest;
import org.springframework.ai.ollama.api.OllamaApi.Message.Role;
import org.springframework.ai.ollama.api.OllamaApiOptions;
import org.springframework.ai.prompt.Prompt;
import org.springframework.ai.prompt.messages.Message;
import org.springframework.ai.prompt.messages.MessageType;
/**
* {@link ChatClient} implementation for {@literal Ollma}.
*
* Ollama allows developers to run large language models and generate embeddings locally.
* It supports open-source models available on [Ollama AI
* Library](https://ollama.ai/library). - Llama 2 (7B parameters, 3.8GB size) - Mistral
* (7B parameters, 4.1GB size)
*
* Please refer to the <a href="https://ollama.ai/">official Ollama website</a> for the
* most up-to-date information on available models.
*
* @author Christian Tzolov
* @since 0.8.0
*/
public class OllamaChatClient implements ChatClient, StreamingChatClient {
private final OllamaApi chatApi;
private String model = "orca-mini";
private Map<String, Object> clientOptions;
public OllamaChatClient(OllamaApi chatApi) {
this.chatApi = chatApi;
}
public OllamaChatClient withModel(String model) {
this.model = model;
return this;
}
public OllamaChatClient withOptions(Map<String, Object> options) {
this.clientOptions = options;
return this;
}
public OllamaChatClient withOptions(OllamaApiOptions.Options options) {
this.clientOptions = options.toMap();
return this;
}
@Override
public ChatResponse generate(Prompt prompt) {
OllamaApi.ChatResponse response = this.chatApi.chat(request(prompt, this.model, false));
var generator = new Generation(response.message().content());
if (response.promptEvalCount() != null && response.evalCount() != null) {
generator = generator.withChoiceMetadata(ChoiceMetadata.from("unknown", extractUsage(response)));
}
return new ChatResponse(List.of(generator));
}
@Override
public Flux<ChatResponse> generateStream(Prompt prompt) {
Flux<OllamaApi.ChatResponse> response = this.chatApi.streamingChat(request(prompt, this.model, true));
return response.map(chunk -> {
Generation generation = (chunk.message() != null) ? new Generation(chunk.message().content())
: new Generation("");
if (Boolean.TRUE.equals(chunk.done())) {
generation = generation.withChoiceMetadata(ChoiceMetadata.from("unknown", extractUsage(chunk)));
}
return new ChatResponse(List.of(generation));
});
}
private Usage extractUsage(OllamaApi.ChatResponse response) {
return new Usage() {
@Override
public Long getPromptTokens() {
return response.promptEvalCount().longValue();
}
@Override
public Long getGenerationTokens() {
return response.evalCount().longValue();
}
};
}
private OllamaApi.ChatRequest request(Prompt prompt, String model, boolean stream) {
List<OllamaApi.Message> ollamaMessages = prompt.getMessages()
.stream()
.filter(message -> message.getMessageType() == MessageType.USER
|| message.getMessageType() == MessageType.ASSISTANT)
.map(m -> OllamaApi.Message.builder(toRole(m)).withContent(m.getContent()).build())
.toList();
return ChatRequest.builder(model)
.withStream(stream)
.withMessages(ollamaMessages)
.withOptions(this.clientOptions)
.build();
}
private OllamaApi.Message.Role toRole(Message message) {
switch (message.getMessageType()) {
case USER:
return Role.user;
case ASSISTANT:
return Role.assistant;
case SYSTEM:
return Role.system;
default:
throw new IllegalArgumentException("Unsupported message type: " + message.getMessageType());
}
}
}

View File

@@ -0,0 +1,111 @@
/*
* Copyright 2023-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.ollama;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.ai.chat.ChatClient;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.AbstractEmbeddingClient;
import org.springframework.ai.embedding.Embedding;
import org.springframework.ai.embedding.EmbeddingClient;
import org.springframework.ai.embedding.EmbeddingResponse;
import org.springframework.ai.ollama.api.OllamaApi;
import org.springframework.ai.ollama.api.OllamaApi.EmbeddingRequest;
import org.springframework.ai.ollama.api.OllamaApiOptions;
import org.springframework.util.Assert;
/**
* {@link EmbeddingClient} implementation for {@literal Ollma}.
*
* Ollama allows developers to run large language models and generate embeddings locally.
* It supports open-source models available on [Ollama AI
* Library](https://ollama.ai/library).
*
* Examples of models supported: - Llama 2 (7B parameters, 3.8GB size) - Mistral (7B
* parameters, 4.1GB size)
*
*
*
* Please refer to the <a href="https://ollama.ai/">official Ollama website</a> for the
* most up-to-date information on available models.
*
* @author Christian Tzolov
*/
public class OllamaEmbeddingClient extends AbstractEmbeddingClient {
private final OllamaApi ollamaApi;
private String model = "orca-mini";
private Map<String, Object> clientOptions;
public OllamaEmbeddingClient(OllamaApi ollamaApi) {
this.ollamaApi = ollamaApi;
}
public OllamaEmbeddingClient withModel(String model) {
this.model = model;
return this;
}
public OllamaEmbeddingClient withOptions(Map<String, Object> options) {
this.clientOptions = options;
return this;
}
public OllamaEmbeddingClient withOptions(OllamaApiOptions.Options options) {
this.clientOptions = options.toMap();
return this;
}
@Override
public List<Double> embed(String text) {
return this.embed(List.of(text)).iterator().next();
}
@Override
public List<Double> embed(Document document) {
return embed(document.getContent());
}
@Override
public List<List<Double>> embed(List<String> texts) {
Assert.notEmpty(texts, "At least one text is required!");
Assert.isTrue(texts.size() == 1, "Ollama Embedding does not support batch embedding!");
String inputContent = texts.iterator().next();
OllamaApi.EmbeddingResponse response = this.ollamaApi
.embeddings(new EmbeddingRequest(this.model, inputContent, this.clientOptions));
return List.of(response.embedding());
}
@Override
public EmbeddingResponse embedForResponse(List<String> texts) {
var indexCounter = new AtomicInteger(0);
List<Embedding> embeddings = this.embed(texts)
.stream()
.map(e -> new Embedding(e, indexCounter.getAndIncrement()))
.toList();
return new EmbeddingResponse(embeddings);
}
}

View File

@@ -0,0 +1,598 @@
/*
* Copyright 2023-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.ollama.api;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.ai.ollama.api.OllamaApiOptions.Options;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.util.Assert;
import org.springframework.util.StreamUtils;
import org.springframework.web.client.ResponseErrorHandler;
import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.client.WebClient;
/**
* Java Client for the Ollama API. https://ollama.ai/
*
* @author Christian Tzolov
* @since 0.8.0
*/
// @formatter:off
public class OllamaApi {
private static final Log logger = LogFactory.getLog(OllamaApi.class);
private final static String DEFAULT_BASE_URL = "http://localhost:11434";
public static final String REQUEST_BODY_NULL_ERROR = "The request body can not be null.";
private final ResponseErrorHandler responseErrorHandler;
private final RestClient restClient;
private final WebClient webClient;
private static class OllamaResponseErrorHandler implements ResponseErrorHandler {
@Override
public boolean hasError(ClientHttpResponse response) throws IOException {
return response.getStatusCode().isError();
}
@Override
public void handleError(ClientHttpResponse response) throws IOException {
if (response.getStatusCode().isError()) {
int statusCode = response.getStatusCode().value();
String statusText = response.getStatusText();
String message = StreamUtils.copyToString(response.getBody(), java.nio.charset.StandardCharsets.UTF_8);
logger.warn(String.format("[%s] %s - %s", statusCode, statusText, message));
throw new RuntimeException(String.format("[%s] %s - %s", statusCode, statusText, message));
}
}
}
/**
* Default constructor that uses the default localhost url.
*/
public OllamaApi() {
this(DEFAULT_BASE_URL);
}
/**
* Crate a new OllamaApi instance with the given base url.
* @param baseUrl The base url of the Ollama server.
*/
public OllamaApi(String baseUrl) {
this(baseUrl, RestClient.builder());
}
/**
* Crate a new OllamaApi instance with the given base url and
* {@link RestClient.Builder}.
* @param baseUrl The base url of the Ollama server.
* @param restClientBuilder The {@link RestClient.Builder} to use.
*/
public OllamaApi(String baseUrl, RestClient.Builder restClientBuilder) {
this.responseErrorHandler = new OllamaResponseErrorHandler();
Consumer<HttpHeaders> defaultHeaders = headers -> {
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
};
this.restClient = restClientBuilder.baseUrl(baseUrl).defaultHeaders(defaultHeaders).build();
this.webClient = WebClient.builder().baseUrl(baseUrl).defaultHeaders(defaultHeaders).build();
}
// --------------------------------------------------------------------------
// Generate & Streaming Generate
// --------------------------------------------------------------------------
/**
* The request object sent to the /generate endpoint.
*
* @param model (required) The model to use for completion.
* @param prompt (required) The prompt(s) to generate completions for.
* @param format (optional) The format to return the response in. Currently the only
* accepted value is "json".
* @param options (optional) additional model parameters listed in the documentation
* for the Modelfile such as temperature.
* @param system (optional) system prompt to (overrides what is defined in the Modelfile).
* @param template (optional) the full prompt or prompt template (overrides what is
* defined in the Modelfile).
* @param context the context parameter returned from a previous request to /generate,
* this can be used to keep a short conversational memory.
* @param stream (optional) if false the response will be returned as a single
* response object, rather than a stream of objects.
* @param raw (optional) if true no formatting will be applied to the prompt and no
* context will be returned. You may choose to use the raw parameter if you are
* specifying a full templated prompt in your request to the API, and are managing
* history yourself.
*/
@JsonInclude(Include.NON_NULL)
public record GenerateRequest(
@JsonProperty("model") String model,
@JsonProperty("prompt") String prompt,
@JsonProperty("format") String format,
@JsonProperty("options") Map<String, Object> options,
@JsonProperty("system") String system,
@JsonProperty("template") String template,
@JsonProperty("context") List<Integer> context,
@JsonProperty("stream") Boolean stream,
@JsonProperty("raw") Boolean raw) {
/**
* Short cut constructor to create a CompletionRequest without options.
* @param model The model used for completion.
* @param prompt The prompt(s) to generate completions for.
* @param stream Whether to stream the response.
*/
public GenerateRequest(String model, String prompt, Boolean stream) {
this(model, prompt, null, null, null, null, null, stream, null);
}
/**
* Short cut constructor to create a CompletionRequest without options.
* @param model The model used for completion.
* @param prompt The prompt(s) to generate completions for.
* @param enableJsonFormat Whether to return the response in json format.
* @param stream Whether to stream the response.
*/
public GenerateRequest(String model, String prompt, boolean enableJsonFormat, Boolean stream) {
this(model, prompt, (enableJsonFormat) ? "json" : null, null, null, null, null, stream, null);
}
/**
* Create a CompletionRequest builder.
* @param prompt The prompt(s) to generate completions for.
*/
public static Builder builder(String prompt) {
return new Builder(prompt);
}
public static class Builder {
private String model;
private final String prompt;
private String format;
private Map<String, Object> options;
private String system;
private String template;
private List<Integer> context;
private Boolean stream;
private Boolean raw;
public Builder(String prompt) {
this.prompt = prompt;
}
public Builder withModel(String model) {
this.model = model;
return this;
}
public Builder withFormat(String format) {
this.format = format;
return this;
}
public Builder withOptions(Map<String, Object> options) {
this.options = options;
return this;
}
public Builder withOptions(Options options) {
this.options = options.toMap();
return this;
}
public Builder withSystem(String system) {
this.system = system;
return this;
}
public Builder withTemplate(String template) {
this.template = template;
return this;
}
public Builder withContext(List<Integer> context) {
this.context = context;
return this;
}
public Builder withStream(Boolean stream) {
this.stream = stream;
return this;
}
public Builder withRaw(Boolean raw) {
this.raw = raw;
return this;
}
public GenerateRequest build() {
return new GenerateRequest(model, prompt, format, options, system, template, context, stream, raw);
}
}
}
/**
* The response object returned from the /generate endpoint. To calculate how fast the
* response is generated in tokens per second (token/s), divide eval_count /
* eval_duration.
*
* @param model The model used for completion.
* @param createdAt When the request was made.
* @param response The completion response. Empty if the response was streamed, if not
* streamed, this will contain the full response
* @param done Whether this is the final response. If true, this response may be
* followed by another response with the following, additional fields: context,
* prompt_eval_count, prompt_eval_duration, eval_count, eval_duration.
* @param context Encoding of the conversation used in this response, this can be sent
* in the next request to keep a conversational memory.
* @param totalDuration Time spent generating the response.
* @param loadDuration Time spent loading the model.
* @param promptEvalCount Number of times the prompt was evaluated.
* @param promptEvalDuration Time spent evaluating the prompt.
* @param evalCount Number of tokens in the response.
* @param evalDuration Time spent generating the response.
*/
@JsonInclude(Include.NON_NULL)
public record GenerateResponse(
@JsonProperty("model") String model,
@JsonProperty("created_at") Instant createdAt,
@JsonProperty("response") String response,
@JsonProperty("done") Boolean done,
@JsonProperty("context") List<Integer> context,
@JsonProperty("total_duration") Duration totalDuration,
@JsonProperty("load_duration") Duration loadDuration,
@JsonProperty("prompt_eval_count") Integer promptEvalCount,
@JsonProperty("prompt_eval_duration") Duration promptEvalDuration,
@JsonProperty("eval_count") Integer evalCount,
@JsonProperty("eval_duration") Duration evalDuration) {
}
/**
* Generate a completion for the given prompt.
* @param completionRequest Completion request.
* @return Completion response.
*/
public GenerateResponse generate(GenerateRequest completionRequest) {
Assert.notNull(completionRequest, REQUEST_BODY_NULL_ERROR);
Assert.isTrue(completionRequest.stream() == false, "Stream mode must be disabled.");
return this.restClient.post()
.uri("/api/generate")
.body(completionRequest)
.retrieve()
.onStatus(this.responseErrorHandler)
.body(GenerateResponse.class);
}
/**
* Generate a streaming completion for the given prompt.
* @param completionRequest Completion request. The request must set the stream
* property to true.
* @return Completion response as a {@link Flux} stream.
*/
public Flux<GenerateResponse> generateStreaming(GenerateRequest completionRequest) {
Assert.notNull(completionRequest, REQUEST_BODY_NULL_ERROR);
Assert.isTrue(completionRequest.stream(), "Request must set the steam property to true.");
return webClient.post()
.uri("/api/generate")
.body(Mono.just(completionRequest), GenerateRequest.class)
.retrieve()
.bodyToFlux(GenerateResponse.class)
.handle((data, sink) -> {
if (logger.isTraceEnabled()) {
logger.trace(data);
}
sink.next(data);
});
}
// --------------------------------------------------------------------------
// Chat & Streaming Chat
// --------------------------------------------------------------------------
/**
* Chat message object.
*
* @param role The role of the message of type {@link Role}.
* @param content The content of the message.
* @param images The list of images to send with the message.
*/
@JsonInclude(Include.NON_NULL)
public record Message(
@JsonProperty("role") Role role,
@JsonProperty("content") String content,
@JsonProperty("images") List<byte[]> images) {
/**
* The role of the message in the conversation.
*/
public enum Role {
/**
* System message type used as instructions to the model.
*/
system,
/**
* User message type.
*/
user,
/**
* Assistant message type. Usually the response from the model.
*/
assistant;
}
public static Builder builder(Role role) {
return new Builder(role);
}
public static class Builder {
private final Role role;
private String content;
private List<byte[]> images;
public Builder(Role role) {
this.role = role;
}
public Builder withContent(String content) {
this.content = content;
return this;
}
public Builder withImages(List<byte[]> images) {
this.images = images;
return this;
}
public Message build() {
return new Message(role, content, images);
}
}
}
/**
* Chat request object.
*
* @param model The model to use for completion.
* @param messages The list of messages to chat with.
* @param stream Whether to stream the response.
* @param format The format to return the response in. Currently the only accepted
* value is "json".
* @param options Additional model parameters. You can use the {@link Options} builder
* to create the options then {@link Options#toMap()} to convert the options into a
* map.
*/
@JsonInclude(Include.NON_NULL)
public record ChatRequest(
@JsonProperty("model") String model,
@JsonProperty("messages") List<Message> messages,
@JsonProperty("stream") Boolean stream,
@JsonProperty("format") String format,
@JsonProperty("options") Map<String, Object> options) {
public static Builder builder(String model) {
return new Builder(model);
}
public static class Builder {
private final String model;
private List<Message> messages = List.of();
private boolean stream = false;
private String format;
private Map<String, Object> options = Map.of();
public Builder(String model) {
Assert.notNull(model, "The model can not be null.");
this.model = model;
}
public Builder withMessages(List<Message> messages) {
this.messages = messages;
return this;
}
public Builder withStream(boolean stream) {
this.stream = stream;
return this;
}
public Builder withFormat(String format) {
this.format = format;
return this;
}
public Builder withOptions(Map<String, Object> options) {
this.options = options;
return this;
}
public Builder withOptions(Options options) {
this.options = options.toMap();
return this;
}
public ChatRequest build() {
return new ChatRequest(model, messages, stream, format, options);
}
}
}
/**
* Ollama chat response object.
*
* @param model The model name used for completion.
* @param createdAt When the request was made.
* @param message The response {@link Message} with {@link Message.Role#assistant}.
* @param done Whether this is the final response. For streaming response only the
* last message is marked as done. If true, this response may be followed by another
* response with the following, additional fields: context, prompt_eval_count,
* prompt_eval_duration, eval_count, eval_duration.
* @param totalDuration Time spent generating the response.
* @param loadDuration Time spent loading the model.
* @param promptEvalCount number of tokens in the prompt.(*)
* @param promptEvalDuration time spent evaluating the prompt.
* @param evalCount number of tokens in the response.
* @param evalDuration time spent generating the response.
* @see <a href=
* "https://github.com/jmorganca/ollama/blob/main/docs/api.md#generate-a-chat-completion">Chat
* Completion API</a>
* @see <a href="https://github.com/jmorganca/ollama/blob/main/api/types.go">Ollama
* Types</a>
*/
@JsonInclude(Include.NON_NULL)
public record ChatResponse(
@JsonProperty("model") String model,
@JsonProperty("created_at") Instant createdAt,
@JsonProperty("message") Message message,
@JsonProperty("done") Boolean done,
@JsonProperty("total_duration") Duration totalDuration,
@JsonProperty("load_duration") Duration loadDuration,
@JsonProperty("prompt_eval_count") Integer promptEvalCount,
@JsonProperty("prompt_eval_duration") Duration promptEvalDuration,
@JsonProperty("eval_count") Integer evalCount,
@JsonProperty("eval_duration") Duration evalDuration) {
}
/**
* Generate the next message in a chat with a provided model.
*
* This is a streaming endpoint (controlled by the 'stream' request property), so
* there will be a series of responses. The final response object will include
* statistics and additional data from the request.
* @param chatRequest Chat request.
* @return Chat response.
*/
public ChatResponse chat(ChatRequest chatRequest) {
Assert.notNull(chatRequest, REQUEST_BODY_NULL_ERROR);
Assert.isTrue(!chatRequest.stream(), "Stream mode must be disabled.");
return this.restClient.post()
.uri("/api/chat")
.body(chatRequest)
.retrieve()
.onStatus(this.responseErrorHandler)
.body(ChatResponse.class);
}
/**
* Streaming response for the chat completion request.
* @param chatRequest Chat request. The request must set the stream property to true.
* @return Chat response as a {@link Flux} stream.
*/
public Flux<ChatResponse> streamingChat(ChatRequest chatRequest) {
Assert.notNull(chatRequest, REQUEST_BODY_NULL_ERROR);
Assert.isTrue(chatRequest.stream(), "Request must set the steam property to true.");
return webClient.post()
.uri("/api/chat")
.body(Mono.just(chatRequest), GenerateRequest.class)
.retrieve()
.bodyToFlux(ChatResponse.class)
.handle((data, sink) -> {
if (logger.isTraceEnabled()) {
logger.trace(data);
}
sink.next(data);
});
}
// --------------------------------------------------------------------------
// Embeddings
// --------------------------------------------------------------------------
/**
* Generate embeddings from a model.
*
* @param model The name of model to generate embeddings from.
* @param prompt The text to generate embeddings for.
* @param options Additional model parameters listed in the documentation for the
* Modelfile such as temperature.
*/
@JsonInclude(Include.NON_NULL)
public record EmbeddingRequest(
@JsonProperty("model") String model,
@JsonProperty("prompt") String prompt,
@JsonProperty("options") Map<String, Object> options) {
/**
* short cut constructor to create a EmbeddingRequest without options.
* @param model The name of model to generate embeddings from.
* @param prompt The text to generate embeddings for.
*/
public EmbeddingRequest(String model, String prompt) {
this(model, prompt, null);
}
}
/**
* The response object returned from the /embedding endpoint.
*
* @param embedding The embedding generated from the model.
*/
@JsonInclude(Include.NON_NULL)
public record EmbeddingResponse(
@JsonProperty("embedding") List<Double> embedding) {
}
/**
* Generate embeddings from a model.
* @param embeddingRequest Embedding request.
* @return Embedding response.
*/
public EmbeddingResponse embeddings(EmbeddingRequest embeddingRequest) {
Assert.notNull(embeddingRequest, REQUEST_BODY_NULL_ERROR);
return this.restClient.post()
.uri("/api/embeddings")
.body(embeddingRequest)
.retrieve()
.onStatus(this.responseErrorHandler)
.body(EmbeddingResponse.class);
}
}
// @formatter:on

View File

@@ -0,0 +1,395 @@
/*
* Copyright 2023-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.ollama.api;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* Helper class for building strongly typed the Ollama request options.
*
* @author Christian Tzolov
* @since 0.8.0
*/
// @formatter:on
public class OllamaApiOptions {
/**
* Runner options which must be set when the model is loaded into memory.
*
* @param useNUMA Whether to use NUMA.
* @param numCtx Sets the size of the context window used to generate the next token.
* (Default: 2048)
* @param numBatch ???
* @param numGQA The number of GQA groups in the transformer layer. Required for some
* models, for example it is 8 for llama2:70b.
* @param numGPU The number of layers to send to the GPU(s). On macOS it defaults to 1
* to enable metal support, 0 to disable.
* @param mainGPU ???
* @param lowVRAM ???
* @param f16KV ???
* @param logitsAll ???
* @param vocabOnly ???
* @param useMMap ???
* @param useMLock ???
* @param embeddingOnly ???
* @param ropeFrequencyBase ???
* @param ropeFrequencyScale ???
* @param numThread Sets the number of threads to use during computation. By default,
* Ollama will detect this for optimal performance. It is recommended to set this
* value to the number of physical CPU cores your system has (as opposed to the
* logical number of cores).
*
* Options specified in GenerateRequest.
* @param mirostat Enable Mirostat sampling for controlling perplexity. (default: 0, 0
* = disabled, 1 = Mirostat, 2 = Mirostat 2.0)
* @param mirostatTau Influences how quickly the algorithm responds to feedback from
* the generated text. A lower learning rate will result in slower adjustments, while
* a higher learning rate will make the algorithm more responsive. (Default: 0.1).
* @param mirostatEta Controls the balance between coherence and diversity of the
* output. A lower value will result in more focused and coherent text. (Default:
* 5.0).
* @param numKeep Unknown.
* @param seed Sets the random number seed to use for generation. Setting this to a
* specific number will make the model generate the same text for the same prompt.
* (Default: 0)
* @param numPredict Maximum number of tokens to predict when generating text.
* (Default: 128, -1 = infinite generation, -2 = fill context)
* @param topK Reduces the probability of generating nonsense. A higher value (e.g.
* 100) will give more diverse answers, while a lower value (e.g. 10) will be more
* conservative. (Default: 40)
* @param topP Works together with top-k. A higher value (e.g., 0.95) will lead to
* more diverse text, while a lower value (e.g., 0.5) will generate more focused and
* conservative text. (Default: 0.9)
* @param tfsZ Tail free sampling is used to reduce the impact of less probable tokens
* from the output. A higher value (e.g., 2.0) will reduce the impact more, while a
* value of 1.0 disables this setting. (default: 1)
* @param typicalP Unknown.
* @param repeatLastN Sets how far back for the model to look back to prevent
* repetition. (Default: 64, 0 = disabled, -1 = num_ctx)
* @param temperature The temperature of the model. Increasing the temperature will
* make the model answer more creatively. (Default: 0.8)
* @param repeatPenalty Sets how strongly to penalize repetitions. A higher value
* (e.g., 1.5) will penalize repetitions more strongly, while a lower value (e.g.,
* 0.9) will be more lenient. (Default: 1.1)
* @param presencePenalty Unknown.
* @param frequencyPenalty Unknown.
* @param penalizeNewline Unknown.
* @param stop Sets the stop sequences to use. When this pattern is encountered the
* LLM will stop generating text and return. Multiple stop patterns may be set by
* specifying multiple separate stop parameters in a modelfile.
* @see <a href=
* "https://github.com/jmorganca/ollama/blob/main/docs/modelfile.md#valid-parameters-and-values">Ollama
* Valid Parameters and Values</a>
* @see <a href="https://github.com/jmorganca/ollama/blob/main/api/types.go">Ollama Go
* Types</a>
*/
@JsonInclude(Include.NON_NULL)
public record Options(
// Runner options which must be set when the model is loaded into memory.
@JsonProperty("numa") Boolean useNUMA, @JsonProperty("num_ctx") Integer numCtx,
@JsonProperty("num_batch") Integer numBatch, @JsonProperty("num_gqa") Integer numGQA,
@JsonProperty("num_gpu") Integer numGPU, @JsonProperty("main_gpu") Integer mainGPU,
@JsonProperty("low_vram") Boolean lowVRAM, @JsonProperty("f16_kv") Boolean f16KV,
@JsonProperty("logits_all") Boolean logitsAll, @JsonProperty("vocab_only") Boolean vocabOnly,
@JsonProperty("use_mmap") Boolean useMMap, @JsonProperty("use_mlock") Boolean useMLock,
@JsonProperty("embedding_only") Boolean embeddingOnly,
@JsonProperty("rope_frequency_base") Float ropeFrequencyBase,
@JsonProperty("rope_frequency_scale") Float ropeFrequencyScale,
@JsonProperty("num_thread") Integer numThread,
// Options specified in GenerateRequest.
@JsonProperty("num_keep") Integer numKeep, @JsonProperty("seed") Integer seed,
@JsonProperty("num_predict") Integer numPredict, @JsonProperty("top_k") Integer topK,
@JsonProperty("top_p") Float topP, @JsonProperty("tfs_z") Float tfsZ,
@JsonProperty("typical_p") Float typicalP, @JsonProperty("repeat_last_n") Integer repeatLastN,
@JsonProperty("temperature") Float temperature, @JsonProperty("repeat_penalty") Float repeatPenalty,
@JsonProperty("presence_penalty") Float presencePenalty,
@JsonProperty("frequency_penalty") Float frequencyPenalty, @JsonProperty("mirostat") Integer mirostat,
@JsonProperty("mirostat_tau") Float mirostatTau, @JsonProperty("mirostat_eta") Float mirostatEta,
@JsonProperty("penalize_newline") Boolean penalizeNewline, @JsonProperty("stop") String[] stop) {
/**
* Convert the {@link Options} object to a {@link Map} of key/value pairs.
* @return The {@link Map} of key/value pairs.
*/
public Map<String, Object> toMap() {
try {
var json = new ObjectMapper().writeValueAsString(this);
return new ObjectMapper().readValue(json, new TypeReference<Map<String, Object>>() {
});
}
catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private Boolean useNUMA;
private Integer numCtx;
private Integer numBatch;
private Integer numGQA;
private Integer numGPU;
private Integer mainGPU;
private Boolean lowVRAM;
private Boolean f16KV;
private Boolean logitsAll;
private Boolean vocabOnly;
private Boolean useMMap;
private Boolean useMLock;
private Boolean embeddingOnly;
private Float ropeFrequencyBase;
private Float ropeFrequencyScale;
private Integer numThread;
private Integer numKeep;
private Integer seed;
private Integer numPredict;
private Integer topK;
private Float topP;
private Float tfsZ;
private Float typicalP;
private Integer repeatLastN;
private Float temperature;
private Float repeatPenalty;
private Float presencePenalty;
private Float frequencyPenalty;
private Integer mirostat;
private Float mirostatTau;
private Float mirostatEta;
private Boolean penalizeNewline;
private String[] stop;
public Builder withUseNUMA(Boolean useNUMA) {
this.useNUMA = useNUMA;
return this;
}
public Builder withNumCtx(Integer numCtx) {
this.numCtx = numCtx;
return this;
}
public Builder withNumBatch(Integer numBatch) {
this.numBatch = numBatch;
return this;
}
public Builder withNumGQA(Integer numGQA) {
this.numGQA = numGQA;
return this;
}
public Builder withNumGPU(Integer numGPU) {
this.numGPU = numGPU;
return this;
}
public Builder withMainGPU(Integer mainGPU) {
this.mainGPU = mainGPU;
return this;
}
public Builder withLowVRAM(Boolean lowVRAM) {
this.lowVRAM = lowVRAM;
return this;
}
public Builder withF16KV(Boolean f16KV) {
this.f16KV = f16KV;
return this;
}
public Builder withLogitsAll(Boolean logitsAll) {
this.logitsAll = logitsAll;
return this;
}
public Builder withVocabOnly(Boolean vocabOnly) {
this.vocabOnly = vocabOnly;
return this;
}
public Builder withUseMMap(Boolean useMMap) {
this.useMMap = useMMap;
return this;
}
public Builder withUseMLock(Boolean useMLock) {
this.useMLock = useMLock;
return this;
}
public Builder withEmbeddingOnly(Boolean embeddingOnly) {
this.embeddingOnly = embeddingOnly;
return this;
}
public Builder withRopeFrequencyBase(Float ropeFrequencyBase) {
this.ropeFrequencyBase = ropeFrequencyBase;
return this;
}
public Builder withRopeFrequencyScale(Float ropeFrequencyScale) {
this.ropeFrequencyScale = ropeFrequencyScale;
return this;
}
public Builder withNumThread(Integer numThread) {
this.numThread = numThread;
return this;
}
public Builder withNumKeep(Integer numKeep) {
this.numKeep = numKeep;
return this;
}
public Builder withSeed(Integer seed) {
this.seed = seed;
return this;
}
public Builder withNumPredict(Integer numPredict) {
this.numPredict = numPredict;
return this;
}
public Builder withTopK(Integer topK) {
this.topK = topK;
return this;
}
public Builder withTopP(Float topP) {
this.topP = topP;
return this;
}
public Builder withTfsZ(Float tfsZ) {
this.tfsZ = tfsZ;
return this;
}
public Builder withTypicalP(Float typicalP) {
this.typicalP = typicalP;
return this;
}
public Builder withRepeatLastN(Integer repeatLastN) {
this.repeatLastN = repeatLastN;
return this;
}
public Builder withTemperature(Float temperature) {
this.temperature = temperature;
return this;
}
public Builder withRepeatPenalty(Float repeatPenalty) {
this.repeatPenalty = repeatPenalty;
return this;
}
public Builder withPresencePenalty(Float presencePenalty) {
this.presencePenalty = presencePenalty;
return this;
}
public Builder withFrequencyPenalty(Float frequencyPenalty) {
this.frequencyPenalty = frequencyPenalty;
return this;
}
public Builder withMirostat(Integer mirostat) {
this.mirostat = mirostat;
return this;
}
public Builder withMirostatTau(Float mirostatTau) {
this.mirostatTau = mirostatTau;
return this;
}
public Builder withMirostatEta(Float mirostatEta) {
this.mirostatEta = mirostatEta;
return this;
}
public Builder withPenalizeNewline(Boolean penalizeNewline) {
this.penalizeNewline = penalizeNewline;
return this;
}
public Builder withStop(String[] stop) {
this.stop = stop;
return this;
}
public Options build() {
return new Options(useNUMA, numCtx, numBatch, numGQA, numGPU, mainGPU, lowVRAM, f16KV, logitsAll,
vocabOnly, useMMap, useMLock, embeddingOnly, ropeFrequencyBase, ropeFrequencyScale, numThread,
numKeep, seed, numPredict, topK, topP, tfsZ, typicalP, repeatLastN, temperature, repeatPenalty,
presencePenalty, frequencyPenalty, mirostat, mirostatTau, mirostatEta, penalizeNewline, stop);
}
}
}
}
// @formatter:on

View File

@@ -1,246 +0,0 @@
package org.springframework.ai.ollama.client;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.ChatClient;
import org.springframework.ai.chat.ChatResponse;
import org.springframework.ai.chat.Generation;
import org.springframework.ai.prompt.Prompt;
import org.springframework.util.CollectionUtils;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.*;
import java.util.function.Consumer;
import java.util.stream.Collectors;
/**
* A client implementation for interacting with Ollama Service. This class acts as an
* interface between the application and the Ollama AI Service, handling request creation,
* communication, and response processing.
*
* @author nullptr
*/
public class OllamaClient implements ChatClient {
/** Logger for logging the events and messages. */
private static final Logger log = LoggerFactory.getLogger(OllamaClient.class);
/** Mapper for JSON serialization and deserialization. */
private static final ObjectMapper jsonMapper = new ObjectMapper();
/** HTTP client for making asynchronous calls to the Ollama Service. */
private static final HttpClient httpClient = HttpClient.newBuilder().build();
/** Base URL of the Ollama Service. */
private final String baseUrl;
/** Name of the model to be used for the AI service. */
private final String model;
/** Optional callback to handle individual generation results. */
private Consumer<OllamaGenerateResult> simpleCallback;
/**
* Constructs an OllamaClient with the specified base URL and model.
* @param baseUrl Base URL of the Ollama Service.
* @param model Model specification for the AI service.
*/
public OllamaClient(String baseUrl, String model) {
this.baseUrl = baseUrl;
this.model = model;
}
/**
* Constructs an OllamaClient with the specified base URL, model, and a callback.
* @param baseUrl Base URL of the Ollama Service.
* @param model Model specification for the AI service.
* @param simpleCallback Callback to handle individual generation results.
*/
public OllamaClient(String baseUrl, String model, Consumer<OllamaGenerateResult> simpleCallback) {
this(baseUrl, model);
this.simpleCallback = simpleCallback;
}
@Override
public ChatResponse generate(Prompt prompt) {
validatePrompt(prompt);
HttpRequest request = buildHttpRequest(prompt);
var response = sendRequest(request);
List<OllamaGenerateResult> results = readGenerateResults(response.body());
return getAiResponse(results);
}
/**
* Validates the provided prompt.
* @param prompt The prompt to validate.
*/
protected void validatePrompt(Prompt prompt) {
if (CollectionUtils.isEmpty(prompt.getMessages())) {
throw new RuntimeException("The prompt message cannot be empty.");
}
if (prompt.getMessages().size() > 1) {
log.warn("Only the first prompt message will be used; subsequent messages will be ignored.");
}
}
/**
* Constructs an HTTP request for the provided prompt.
* @param prompt The prompt for which the request needs to be built.
* @return The constructed HttpRequest.
*/
protected HttpRequest buildHttpRequest(Prompt prompt) {
String requestBody = getGenerateRequestBody(prompt.getMessages().get(0).getContent());
// remove the suffix '/' if necessary
String url = !this.baseUrl.endsWith("/") ? this.baseUrl : this.baseUrl.substring(0, this.baseUrl.length() - 1);
return HttpRequest.newBuilder()
.uri(URI.create("%s/api/generate".formatted(url)))
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.timeout(Duration.ofMinutes(5L))
.build();
}
/**
* Sends the constructed HttpRequest and retrieves the HttpResponse.
* @param request The HttpRequest to be sent.
* @return HttpResponse containing the response data.
*/
protected HttpResponse<InputStream> sendRequest(HttpRequest request) {
var response = httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()).join();
if (response.statusCode() != 200) {
throw new RuntimeException("Ollama call returned an unexpected status: " + response.statusCode());
}
return response;
}
/**
* Serializes the prompt into a request body for the Ollama API call.
* @param prompt The prompt to be serialized.
* @return Serialized request body as a String.
*/
private String getGenerateRequestBody(String prompt) {
var data = Map.of("model", model, "prompt", prompt);
try {
return jsonMapper.writeValueAsString(data);
}
catch (JsonProcessingException ex) {
throw new RuntimeException("Failed to serialize the prompt to JSON", ex);
}
}
/**
* Reads and processes the results from the InputStream provided by the Ollama
* Service.
* @param inputStream InputStream containing the results from the Ollama Service.
* @return List of OllamaGenerateResult.
*/
protected List<OllamaGenerateResult> readGenerateResults(InputStream inputStream) {
try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream))) {
var results = new ArrayList<OllamaGenerateResult>();
String line;
while ((line = bufferedReader.readLine()) != null) {
processResponseLine(line, results);
}
return results;
}
catch (IOException e) {
throw new RuntimeException("Error parsing Ollama generation response.", e);
}
}
/**
* Processes a single line from the Ollama response.
* @param line The line to be processed.
* @param results List to which parsed results will be added.
*/
protected void processResponseLine(String line, List<OllamaGenerateResult> results) {
if (line.isBlank())
return;
log.debug("Received ollama generate response: {}", line);
OllamaGenerateResult result;
try {
result = jsonMapper.readValue(line, OllamaGenerateResult.class);
}
catch (IOException e) {
throw new RuntimeException("Error parsing response line from Ollama.", e);
}
if (result.getModel() == null || result.getDone() == null) {
throw new IllegalStateException("Received invalid data from Ollama. Model = " + result.getModel()
+ " , Done = " + result.getDone());
}
if (simpleCallback != null) {
simpleCallback.accept(result);
}
results.add(result);
}
/**
* Converts the list of OllamaGenerateResult into a structured ChatResponse.
* @param results List of OllamaGenerateResult.
* @return Formulated ChatResponse.
*/
protected ChatResponse getAiResponse(List<OllamaGenerateResult> results) {
var ollamaResponse = results.stream()
.filter(Objects::nonNull)
.filter(it -> it.getResponse() != null && !it.getResponse().isBlank())
.filter(it -> it.getDone() != null)
.map(OllamaGenerateResult::getResponse)
.collect(Collectors.joining(""));
var generation = new Generation(ollamaResponse);
// TODO investigate mapping of additional metadata/runtime info to the response.
return new ChatResponse(Collections.singletonList(generation));
}
/**
* @return Model name for the AI service.
*/
public String getModel() {
return model;
}
/**
* @return Base URL of the Ollama Service.
*/
public String getBaseUrl() {
return baseUrl;
}
/**
* @return Callback that handles individual generation results.
*/
public Consumer<OllamaGenerateResult> getSimpleCallback() {
return simpleCallback;
}
/**
* Sets the callback that handles individual generation results.
* @param simpleCallback The callback to be set.
*/
public void setSimpleCallback(Consumer<OllamaGenerateResult> simpleCallback) {
this.simpleCallback = simpleCallback;
}
}

View File

@@ -1,146 +0,0 @@
package org.springframework.ai.ollama.client;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/**
* Ollama generate a completion api response model
*
* @author nullptr
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public class OllamaGenerateResult {
@JsonProperty("model")
private String model;
@JsonProperty("created_at")
private String createdAt;
@JsonProperty("response")
private String response;
@JsonProperty("done")
private Boolean done;
@JsonProperty("context")
private List<Long> context;
@JsonProperty("total_duration")
private Long totalDuration;
@JsonProperty("load_duration")
private Long loadDuration;
@JsonProperty("prompt_eval_count")
private Long promptEvalCount;
@JsonProperty("prompt_eval_duration")
private Long promptEvalDuration;
@JsonProperty("eval_count")
private Long evalCount;
@JsonProperty("eval_duration")
private Long evalDuration;
public String getModel() {
return model;
}
public void setModel(String model) {
this.model = model;
}
public String getCreatedAt() {
return createdAt;
}
public void setCreatedAt(String createdAt) {
this.createdAt = createdAt;
}
public String getResponse() {
return response;
}
public void setResponse(String response) {
this.response = response;
}
public Boolean getDone() {
return done;
}
public void setDone(Boolean done) {
this.done = done;
}
public List<Long> getContext() {
return context;
}
public void setContext(List<Long> context) {
this.context = context;
}
public Long getTotalDuration() {
return totalDuration;
}
public void setTotalDuration(Long totalDuration) {
this.totalDuration = totalDuration;
}
public Long getLoadDuration() {
return loadDuration;
}
public void setLoadDuration(Long loadDuration) {
this.loadDuration = loadDuration;
}
public Long getPromptEvalCount() {
return promptEvalCount;
}
public void setPromptEvalCount(Long promptEvalCount) {
this.promptEvalCount = promptEvalCount;
}
public Long getPromptEvalDuration() {
return promptEvalDuration;
}
public void setPromptEvalDuration(Long promptEvalDuration) {
this.promptEvalDuration = promptEvalDuration;
}
public Long getEvalCount() {
return evalCount;
}
public void setEvalCount(Long evalCount) {
this.evalCount = evalCount;
}
public Long getEvalDuration() {
return evalDuration;
}
public void setEvalDuration(Long evalDuration) {
this.evalDuration = evalDuration;
}
@Override
public String toString() {
return "OllamaGenerateResult{" + "model='" + model + '\'' + ", createdAt='" + createdAt + '\'' + ", response='"
+ response + '\'' + ", done='" + done + '\'' + ", context=" + context + ", totalDuration="
+ totalDuration + ", loadDuration=" + loadDuration + ", promptEvalCount=" + promptEvalCount
+ ", promptEvalDuration=" + promptEvalDuration + ", evalCount=" + evalCount + ", evalDuration="
+ evalDuration + '}';
}
}

View File

@@ -0,0 +1,192 @@
package org.springframework.ai.ollama;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.springframework.ai.chat.ChatResponse;
import org.springframework.ai.chat.Generation;
import org.springframework.ai.ollama.api.OllamaApi;
import org.springframework.ai.ollama.api.OllamaApiOptions;
import org.springframework.ai.parser.BeanOutputParser;
import org.springframework.ai.parser.ListOutputParser;
import org.springframework.ai.parser.MapOutputParser;
import org.springframework.ai.prompt.Prompt;
import org.springframework.ai.prompt.PromptTemplate;
import org.springframework.ai.prompt.SystemPromptTemplate;
import org.springframework.ai.prompt.messages.Message;
import org.springframework.ai.prompt.messages.UserMessage;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import org.springframework.core.convert.support.DefaultConversionService;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@Testcontainers
@Disabled("For manual smoke testing only.")
class OllamaChatClientIT {
private static String MODEL = "mistral";
private static final Log logger = LogFactory.getLog(OllamaChatClientIT.class);
@Container
static GenericContainer<?> ollamaContainer = new GenericContainer<>("ollama/ollama:0.1.15").withExposedPorts(11434);
static String baseUrl;
@BeforeAll
public static void beforeAll() throws IOException, InterruptedException {
logger.info("Start pulling the '" + MODEL + " ' model ... would take several minutes ...");
ollamaContainer.execInContainer("ollama", "pull", MODEL);
logger.info(MODEL + " pulling competed!");
baseUrl = "http://" + ollamaContainer.getHost() + ":" + ollamaContainer.getMappedPort(11434);
}
@Autowired
private OllamaChatClient client;
@Test
void roleTest() {
Message systemMessage = new SystemPromptTemplate("""
You are a helpful AI assistant. Your name is {name}.
You are an AI assistant that helps people find information.
Your name is {name}
You should reply to the user's request with your name and also in the style of a {voice}.
""").createMessage(Map.of("name", "Bob", "voice", "pirate"));
UserMessage userMessage = new UserMessage("Tell me about 5 famous pirates from the Golden Age of Piracy.");
Prompt prompt = new Prompt(List.of(userMessage, systemMessage));
ChatResponse response = client.generate(prompt);
assertThat(response.getGeneration().getContent()).contains("Blackbeard");
}
@Disabled("TODO: Fix the parser instructions to return the correct format")
@Test
void outputParser() {
DefaultConversionService conversionService = new DefaultConversionService();
ListOutputParser outputParser = new ListOutputParser(conversionService);
String format = outputParser.getFormat();
String template = """
List five {subject}
{format}
""";
PromptTemplate promptTemplate = new PromptTemplate(template,
Map.of("subject", "ice cream flavors.", "format", format));
Prompt prompt = new Prompt(promptTemplate.createMessage());
Generation generation = this.client.generate(prompt).getGeneration();
List<String> list = outputParser.parse(generation.getContent());
assertThat(list).hasSize(5);
}
@Disabled("TODO: Fix the parser instructions to return the correct format")
@Test
void mapOutputParser() {
MapOutputParser outputParser = new MapOutputParser();
String format = outputParser.getFormat();
String template = """
Remove Markdown code blocks from the output.
Provide me a List of {subject}
{format}
""";
PromptTemplate promptTemplate = new PromptTemplate(template,
Map.of("subject", "an array of numbers from 1 to 9 under they key name 'numbers'", "format", format));
Prompt prompt = new Prompt(promptTemplate.createMessage());
Generation generation = client.generate(prompt).getGeneration();
Map<String, Object> result = outputParser.parse(generation.getContent());
assertThat(result.get("numbers")).isEqualTo(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9));
}
record ActorsFilmsRecord(String actor, List<String> movies) {
}
@Disabled("TODO: Fix the parser instructions to return the correct format")
@Test
void beanOutputParserRecords() {
BeanOutputParser<ActorsFilmsRecord> outputParser = new BeanOutputParser<>(ActorsFilmsRecord.class);
String format = outputParser.getFormat();
String template = """
Generate the filmography of 5 movies for Tom Hanks.
{format}
Remove Markdown code blocks from the output.
""";
PromptTemplate promptTemplate = new PromptTemplate(template, Map.of("format", format));
Prompt prompt = new Prompt(promptTemplate.createMessage());
Generation generation = client.generate(prompt).getGeneration();
ActorsFilmsRecord actorsFilms = outputParser.parse(generation.getContent());
assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks");
assertThat(actorsFilms.movies()).hasSize(5);
}
@Disabled("TODO: Fix the parser instructions to return the correct format")
@Test
void beanStreamOutputParserRecords() {
BeanOutputParser<ActorsFilmsRecord> outputParser = new BeanOutputParser<>(ActorsFilmsRecord.class);
String format = outputParser.getFormat();
String template = """
Generate the filmography of 5 movies for Tom Hanks.
{format}
Remove Markdown code blocks from the output.
""";
PromptTemplate promptTemplate = new PromptTemplate(template, Map.of("format", format));
Prompt prompt = new Prompt(promptTemplate.createMessage());
String generationTextFromStream = client.generateStream(prompt)
.collectList()
.block()
.stream()
.map(ChatResponse::getGenerations)
.flatMap(List::stream)
.map(Generation::getContent)
.collect(Collectors.joining());
ActorsFilmsRecord actorsFilms = outputParser.parse(generationTextFromStream);
assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks");
assertThat(actorsFilms.movies()).hasSize(5);
}
@SpringBootConfiguration
public static class TestConfiguration {
@Bean
public OllamaApi ollamaApi() {
return new OllamaApi(baseUrl);
}
@Bean
public OllamaChatClient ollamaChat(OllamaApi ollamaApi) {
return new OllamaChatClient(ollamaApi).withModel(MODEL)
.withOptions(OllamaApiOptions.Options.builder().withTemperature(0.9f).build());
}
}
}

View File

@@ -0,0 +1,82 @@
package org.springframework.ai.ollama;
import java.io.IOException;
import java.util.List;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.springframework.ai.embedding.EmbeddingResponse;
import org.springframework.ai.ollama.api.OllamaApi;
import org.springframework.ai.ollama.api.OllamaApiIT;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@SpringBootTest
@Disabled("For manual smoke testing only.")
@Testcontainers
class OllamaEmbeddingClientIT {
private static final Log logger = LogFactory.getLog(OllamaApiIT.class);
@Container
static GenericContainer<?> ollamaContainer = new GenericContainer<>("ollama/ollama:0.1.15").withExposedPorts(11434);
static String baseUrl;
@BeforeAll
public static void beforeAll() throws IOException, InterruptedException {
logger.info("Start pulling the 'orca-mini' model (3GB) ... would take several minutes ...");
ollamaContainer.execInContainer("ollama", "pull", "orca-mini");
logger.info("orca-mini pulling competed!");
baseUrl = "http://" + ollamaContainer.getHost() + ":" + ollamaContainer.getMappedPort(11434);
}
@Autowired
private OllamaEmbeddingClient embeddingClient;
@Test
void singleEmbedding() {
assertThat(embeddingClient).isNotNull();
EmbeddingResponse embeddingResponse = embeddingClient.embedForResponse(List.of("Hello World"));
assertThat(embeddingResponse.getData()).hasSize(1);
assertThat(embeddingResponse.getData().get(0).getEmbedding()).isNotEmpty();
assertThat(embeddingClient.dimensions()).isEqualTo(3200);
}
@Test
void batchEmbedding() {
assertThatThrownBy(
() -> embeddingClient.embedForResponse(List.of("Hello World", "World is big and salvation is near")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Ollama Embedding does not support batch embedding!");
}
@SpringBootConfiguration
public static class TestConfiguration {
@Bean
public OllamaApi ollamaApi() {
return new OllamaApi(baseUrl);
}
@Bean
public OllamaEmbeddingClient ollamaEmbedding(OllamaApi ollamaApi) {
return new OllamaEmbeddingClient(ollamaApi).withModel("orca-mini");
}
}
}

View File

@@ -0,0 +1,146 @@
/*
* Copyright 2023-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.ollama.api;
import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import reactor.core.publisher.Flux;
import org.springframework.ai.ollama.api.OllamaApi.ChatRequest;
import org.springframework.ai.ollama.api.OllamaApi.ChatResponse;
import org.springframework.ai.ollama.api.OllamaApi.EmbeddingRequest;
import org.springframework.ai.ollama.api.OllamaApi.EmbeddingResponse;
import org.springframework.ai.ollama.api.OllamaApi.GenerateRequest;
import org.springframework.ai.ollama.api.OllamaApi.GenerateResponse;
import org.springframework.ai.ollama.api.OllamaApi.Message;
import org.springframework.ai.ollama.api.OllamaApi.Message.Role;
import org.springframework.ai.ollama.api.OllamaApiOptions.Options;
import static org.assertj.core.api.Assertions.assertThat;;
/**
* @author Christian Tzolov
*/
@Disabled("For manual smoke testing only.")
@Testcontainers
public class OllamaApiIT {
private static final Log logger = LogFactory.getLog(OllamaApiIT.class);
@Container
static GenericContainer<?> ollamaContainer = new GenericContainer<>("ollama/ollama:0.1.15").withExposedPorts(11434);
static OllamaApi ollamaApi;
@BeforeAll
public static void beforeAll() throws IOException, InterruptedException {
logger.info("Start pulling the 'orca-mini' model (3GB) ... would take several minutes ...");
ollamaContainer.execInContainer("ollama", "pull", "orca-mini");
logger.info("orca-mini pulling competed!");
ollamaApi = new OllamaApi("http://" + ollamaContainer.getHost() + ":" + ollamaContainer.getMappedPort(11434));
}
@Test
public void generation() {
var request = GenerateRequest
.builder("What is the capital of Bulgaria and what is the size? What it the national anthem?")
.withModel("orca-mini")
.withStream(false)
.build();
GenerateResponse response = ollamaApi.generate(request);
System.out.println(response);
assertThat(response).isNotNull();
assertThat(response.model()).isEqualTo(response.model());
assertThat(response.response()).contains("Sofia");
}
@Test
public void chat() {
var request = ChatRequest.builder("orca-mini")
.withStream(false)
.withMessages(List.of(Message.builder(Role.user)
.withContent("What is the capital of Bulgaria and what is the size? " + "What it the national anthem?")
.build()))
.withOptions(Options.builder().withTemperature(0.9f).build())
.build();
ChatResponse response = ollamaApi.chat(request);
System.out.println(response);
assertThat(response).isNotNull();
assertThat(response.model()).isEqualTo(response.model());
assertThat(response.done()).isTrue();
assertThat(response.message().role()).isEqualTo(Role.assistant);
assertThat(response.message().content()).contains("Sofia");
}
@Test
public void streamingChat() {
var request = ChatRequest.builder("orca-mini")
.withStream(true)
.withMessages(List.of(Message.builder(Role.user)
.withContent("What is the capital of Bulgaria and what is the size? " + "What it the national anthem?")
.build()))
.withOptions(Options.builder().withTemperature(0.9f).build().toMap())
.build();
Flux<ChatResponse> response = ollamaApi.streamingChat(request);
List<ChatResponse> responses = response.collectList().block();
System.out.println(responses);
assertThat(response).isNotNull();
assertThat(responses.stream()
.filter(r -> r.message() != null)
.map(r -> r.message().content())
.collect(Collectors.joining("\n"))).contains("Sofia");
ChatResponse lastResponse = responses.get(responses.size() - 1);
assertThat(lastResponse.message()).isNull();
assertThat(lastResponse.done()).isTrue();
}
@Test
public void embedText() {
EmbeddingRequest request = new EmbeddingRequest("orca-mini", "I like to eat apples");
EmbeddingResponse response = ollamaApi.embeddings(request);
assertThat(response).isNotNull();
assertThat(response.embedding()).hasSize(3200);
}
}

View File

@@ -1,43 +0,0 @@
package org.springframework.ai.ollama.client;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.ChatResponse;
import org.springframework.ai.prompt.Prompt;
import org.springframework.util.CollectionUtils;
import java.util.function.Consumer;
public class OllamaClientTests {
@Test
@Disabled("For manual smoke testing only.")
public void smokeTest() {
OllamaClient ollama2 = getOllamaClient();
Prompt prompt = new Prompt("Hello");
ChatResponse chatResponse = ollama2.generate(prompt);
Assertions.assertNotNull(chatResponse);
Assertions.assertFalse(CollectionUtils.isEmpty(chatResponse.getGenerations()));
Assertions.assertNotNull(chatResponse.getGeneration());
Assertions.assertNotNull(chatResponse.getGeneration().getContent());
}
private static OllamaClient getOllamaClient() {
Consumer<OllamaGenerateResult> ollamaGenerateResultConsumer = it -> {
if (it.getDone()) {
System.out.println();
System.out.printf("Total duration: %dms%n", it.getTotalDuration() / 1000 / 1000);
System.out.printf("Prompt tokens: %d%n", it.getPromptEvalCount());
System.out.printf("Completion tokens: %d%n", it.getEvalCount());
}
else {
System.out.print(it.getResponse());
}
};
return new OllamaClient("http://127.0.0.1:11434", "llama2", ollamaGenerateResultConsumer);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

View File

@@ -101,6 +101,7 @@
<jtokkit.version>0.6.1</jtokkit.version>
<victools.version>4.31.1</victools.version>
<bedrockruntime.version>2.22.0</bedrockruntime.version>
<jackson.version>2.16.0</jackson.version>
<!-- readers/writer/stores dependencies-->
<pdfbox.version>3.0.0</pdfbox.version>

View File

@@ -11,6 +11,8 @@ import org.springframework.ai.bedrock.jurassic2.api.Ai21Jurassic2ChatBedrockApi;
import org.springframework.ai.bedrock.llama2.api.Llama2ChatBedrockApi;
import org.springframework.ai.bedrock.titan.api.TitanChatBedrockApi;
import org.springframework.ai.bedrock.titan.api.TitanEmbeddingBedrockApi;
import org.springframework.ai.ollama.api.OllamaApi;
import org.springframework.ai.ollama.api.OllamaApiOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.vertex.api.VertexAiApi;
import org.springframework.aot.hint.MemberCategory;
@@ -40,7 +42,7 @@ public class NativeHints implements RuntimeHintsRegistrar {
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
for (var h : Set.of(new BedrockAiHints(), new VertexAiHints(), new OpenAiHints(), new PdfReaderHints(),
new KnuddelsHints()))
new KnuddelsHints(), new OllamaHints()))
h.registerHints(hints, classLoader);
hints.resources().registerResource(new ClassPathResource("embedding/embedding-model-dimensions.properties"));
@@ -71,6 +73,19 @@ public class NativeHints implements RuntimeHintsRegistrar {
}
static class OllamaHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
var mcs = MemberCategory.values();
for (var tr : findJsonAnnotatedClasses(OllamaApi.class))
hints.reflection().registerType(tr, mcs);
for (var tr : findJsonAnnotatedClasses(OllamaApiOptions.class))
hints.reflection().registerType(tr, mcs);
}
}
static class BedrockAiHints implements RuntimeHintsRegistrar {
@Override

View File

@@ -48,7 +48,7 @@ public class BedrockTitanChatAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public TitanChatBedrockApi cohereApi(AwsCredentialsProvider credentialsProvider,
public TitanChatBedrockApi titanApi(AwsCredentialsProvider credentialsProvider,
BedrockTitanChatProperties properties, BedrockAwsConnectionProperties awsProperties) {
return new TitanChatBedrockApi(properties.getModel(), credentialsProvider, awsProperties.getRegion(),
@@ -56,7 +56,7 @@ public class BedrockTitanChatAutoConfiguration {
}
@Bean
public BedrockTitanChatClient cohereChatClient(TitanChatBedrockApi titanChatApi,
public BedrockTitanChatClient titanChatClient(TitanChatBedrockApi titanChatApi,
BedrockTitanChatProperties properties) {
return new BedrockTitanChatClient(titanChatApi).withTemperature(properties.getTemperature())

View File

@@ -57,7 +57,7 @@ public class BedrockTitanEmbeddingAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public BedrockTitanEmbeddingClient cohereEmbeddingClient(TitanEmbeddingBedrockApi titanEmbeddingApi,
public BedrockTitanEmbeddingClient titanEmbeddingClient(TitanEmbeddingBedrockApi titanEmbeddingApi,
BedrockTitanEmbeddingProperties properties) {
return new BedrockTitanEmbeddingClient(titanEmbeddingApi).withInputType(properties.getInputType());

View File

@@ -1,37 +0,0 @@
/*
* Copyright 2023-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.autoconfigure.ollama;
import org.springframework.ai.ollama.client.OllamaClient;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
@AutoConfiguration
@ConditionalOnClass(OllamaClient.class)
@EnableConfigurationProperties(OllamaProperties.class)
public class OllamaAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public OllamaClient ollamaClient(OllamaProperties properties) {
return new OllamaClient(properties.getBaseUrl(), properties.getModel());
}
}

View File

@@ -0,0 +1,74 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.autoconfigure.ollama;
import org.springframework.ai.autoconfigure.NativeHints;
import org.springframework.ai.ollama.OllamaChatClient;
import org.springframework.ai.ollama.api.OllamaApi;
import org.springframework.ai.ollama.api.OllamaApiOptions;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ImportRuntimeHints;
/**
* {@link AutoConfiguration Auto-configuration} for Ollama Chat Client.
*
* @author Christian Tzolov
* @since 0.8.0
*/
@AutoConfiguration
@ConditionalOnClass(OllamaApi.class)
@EnableConfigurationProperties({ OllamaChatProperties.class })
@ConditionalOnProperty(prefix = OllamaChatProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true",
matchIfMissing = true)
@ImportRuntimeHints(NativeHints.class)
public class OllamaChatAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public OllamaApi ollamaApi(OllamaChatProperties properties) {
return new OllamaApi(properties.getBaseUrl());
}
@Bean
public OllamaChatClient ollamaChatClient(OllamaApi ollamaApi, OllamaChatProperties properties) {
var optionsBuilder = OllamaApiOptions.Options.builder();
if (properties.getTemperature() != null) {
optionsBuilder.withTemperature(properties.getTemperature());
}
if (properties.getTopK() != null) {
optionsBuilder.withTopK(properties.getTopK());
}
if (properties.getTopP() != null) {
optionsBuilder.withTopP(properties.getTopP());
}
var options = optionsBuilder.build().toMap();
if (properties.getOptions() != null) {
options.putAll(properties.getOptions());
}
return new OllamaChatClient(ollamaApi).withModel(properties.getModel()).withOptions(options);
}
}

View File

@@ -0,0 +1,125 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.autoconfigure.ollama;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Ollama Chat autoconfiguration properties.
*
* @author Christian Tzolov
* @since 0.8.0
*/
@ConfigurationProperties(OllamaChatProperties.CONFIG_PREFIX)
public class OllamaChatProperties {
public static final String CONFIG_PREFIX = "spring.ai.ollama.chat";
/**
* Base URL where Ollama API server is running.
*/
private String baseUrl = "http://localhost:11434";
/**
* Enable Ollama Chat Client. True by default.
*/
private boolean enabled = true;
/**
* Ollama Chat model name. Defaults to 'llama2'.
*/
private String model = "llama2";
/**
* (optional) Use a lower value to decrease randomness in the response. Defaults to
* 0.7.
*/
private Float temperature = 0.8f;
/**
* (optional) The maximum cumulative probability of tokens to consider when sampling.
* The model uses combined Top-k and nucleus sampling. Nucleus sampling considers the
* smallest set of tokens whose probability sum is at least topP.
*/
private Float topP;
/**
* Max number or responses to generate.
*/
private Integer topK;
private Map<String, Object> options;
public String getBaseUrl() {
return baseUrl;
}
public void setBaseUrl(String baseUrl) {
this.baseUrl = baseUrl;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getModel() {
return model;
}
public void setModel(String model) {
this.model = model;
}
public Float getTemperature() {
return temperature;
}
public void setTemperature(Float temperature) {
this.temperature = temperature;
}
public Float getTopP() {
return topP;
}
public void setTopP(Float topP) {
this.topP = topP;
}
public Integer getTopK() {
return topK;
}
public void setTopK(Integer maxTokens) {
this.topK = maxTokens;
}
public void setOptions(Map<String, Object> options) {
this.options = options;
}
public Map<String, Object> getOptions() {
return options;
}
}

View File

@@ -0,0 +1,58 @@
/*
* Copyright 2023-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.autoconfigure.ollama;
import org.springframework.ai.autoconfigure.NativeHints;
import org.springframework.ai.ollama.OllamaEmbeddingClient;
import org.springframework.ai.ollama.api.OllamaApi;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ImportRuntimeHints;
/**
* {@link AutoConfiguration Auto-configuration} for Ollama Embedding Client.
*
* @author Christian Tzolov
* @since 0.8.0
*/
@AutoConfiguration
@ConditionalOnClass(OllamaApi.class)
@EnableConfigurationProperties({ OllamaEmbeddingProperties.class })
@ConditionalOnProperty(prefix = OllamaEmbeddingProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true",
matchIfMissing = true)
@ImportRuntimeHints(NativeHints.class)
public class OllamaEmbeddingAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public OllamaApi ollamaApi(OllamaEmbeddingProperties properties) {
return new OllamaApi(properties.getBaseUrl());
}
@Bean
@ConditionalOnMissingBean
public OllamaEmbeddingClient ollamaEmbeddingClient(OllamaApi ollamaApi, OllamaEmbeddingProperties properties) {
return new OllamaEmbeddingClient(ollamaApi).withModel(properties.getModel())
.withOptions(properties.getOptions());
}
}

View File

@@ -16,12 +16,25 @@
package org.springframework.ai.autoconfigure.ollama;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(OllamaProperties.CONFIG_PREFIX)
public class OllamaProperties {
/**
* Ollama Embedding autoconfiguration properties.
*
* @author Christian Tzolov
* @since 0.8.0
*/
@ConfigurationProperties(OllamaEmbeddingProperties.CONFIG_PREFIX)
public class OllamaEmbeddingProperties {
public static final String CONFIG_PREFIX = "spring.ai.ollama";
public static final String CONFIG_PREFIX = "spring.ai.ollama.embedding";
/**
* Enable Ollama Embedding Client. True by default.
*/
private boolean enabled = true;
/**
* Base URL where Ollama API server is running.
@@ -29,16 +42,18 @@ public class OllamaProperties {
private String baseUrl = "http://localhost:11434";
/**
* Language model to use.
* Ollama Embedding model name. Defaults to 'llama2'.
*/
private String model = "llama2";
public String getBaseUrl() {
return baseUrl;
private Map<String, Object> options;
public boolean isEnabled() {
return enabled;
}
public void setBaseUrl(String baseUrl) {
this.baseUrl = baseUrl;
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getModel() {
@@ -49,4 +64,20 @@ public class OllamaProperties {
this.model = model;
}
public String getBaseUrl() {
return baseUrl;
}
public void setBaseUrl(String baseUrl) {
this.baseUrl = baseUrl;
}
public void setOptions(Map<String, Object> clientOptions) {
this.options = clientOptions;
}
public Map<String, Object> getOptions() {
return options;
}
}

View File

@@ -1,4 +1,3 @@
org.springframework.ai.autoconfigure.ollama.OllamaAutoConfiguration
org.springframework.ai.autoconfigure.openai.OpenAiAutoConfiguration
org.springframework.ai.autoconfigure.azure.openai.AzureOpenAiAutoConfiguration
org.springframework.ai.autoconfigure.vectorstore.pgvector.PgVectorStoreAutoConfiguration
@@ -17,6 +16,9 @@ org.springframework.ai.autoconfigure.bedrock.cohere.BedrockCohereEmbeddingAutoCo
org.springframework.ai.autoconfigure.bedrock.anthropic.BedrockAnthropicChatAutoConfiguration
org.springframework.ai.autoconfigure.bedrock.titan.BedrockTitanChatAutoConfiguration
org.springframework.ai.autoconfigure.bedrock.titan.BedrockTitanEmbeddingAutoConfiguration
org.springframework.ai.autoconfigure.ollama.OllamaChatAutoConfiguration
org.springframework.ai.autoconfigure.ollama.OllamaEmbeddingAutoConfiguration

View File

@@ -1,81 +0,0 @@
/*
* Copyright 2023-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.autoconfigure.ollama;
import org.junit.jupiter.api.Test;
import org.springframework.ai.ollama.client.OllamaClient;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import static org.assertj.core.api.Assertions.assertThat;
public class OllamaAutoConfigurationTests {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(OllamaAutoConfiguration.class));
@Test
void defaults() {
contextRunner.run(context -> {
OllamaProperties properties = context.getBean(OllamaProperties.class);
assertThat(properties.getBaseUrl()).isEqualTo("http://localhost:11434");
assertThat(properties.getModel()).isEqualTo("llama2");
OllamaClient client = context.getBean(OllamaClient.class);
assertThat(client.getBaseUrl()).isEqualTo("http://localhost:11434");
assertThat(client.getModel()).isEqualTo("llama2");
});
}
@Test
void overrideProperties() {
contextRunner
.withPropertyValues("spring.ai.ollama.base-url=http://localhost:8080", "spring.ai.ollama.model=myModel")
.run(context -> {
OllamaProperties properties = context.getBean(OllamaProperties.class);
assertThat(properties.getBaseUrl()).isEqualTo("http://localhost:8080");
assertThat(properties.getModel()).isEqualTo("myModel");
OllamaClient client = context.getBean(OllamaClient.class);
assertThat(client.getBaseUrl()).isEqualTo("http://localhost:8080");
assertThat(client.getModel()).isEqualTo("myModel");
});
}
@Test
void customConfig() {
contextRunner.withUserConfiguration(CustomConfig.class).run(context -> {
OllamaClient ollamaClient = context.getBean(OllamaClient.class);
assertThat(ollamaClient.getBaseUrl()).isEqualTo("http://localhost:8080");
assertThat(ollamaClient.getModel()).isEqualTo("myModel");
});
}
@Configuration(proxyBeanMethods = false)
static class CustomConfig {
@Bean
OllamaClient myClient() {
return new OllamaClient("http://localhost:8080", "myModel");
}
}
}

View File

@@ -0,0 +1,118 @@
/*
* Copyright 2023-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.autoconfigure.ollama;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import reactor.core.publisher.Flux;
import org.springframework.ai.chat.ChatResponse;
import org.springframework.ai.chat.Generation;
import org.springframework.ai.ollama.OllamaChatClient;
import org.springframework.ai.prompt.Prompt;
import org.springframework.ai.prompt.SystemPromptTemplate;
import org.springframework.ai.prompt.messages.Message;
import org.springframework.ai.prompt.messages.UserMessage;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Christian Tzolov
* @since 0.8.0
*/
@Disabled("For manual smoke testing only.")
@Testcontainers
public class OllamaChatAutoConfigurationIT {
private static final Log logger = LogFactory.getLog(OllamaChatAutoConfigurationIT.class);
private static String MODEL_NAME = "orca-mini";
@Container
static GenericContainer<?> ollamaContainer = new GenericContainer<>("ollama/ollama:0.1.16").withExposedPorts(11434);
static String baseUrl;
@BeforeAll
public static void beforeAll() throws IOException, InterruptedException {
logger.info("Start pulling the '" + MODEL_NAME + " ' model ... would take several minutes ...");
ollamaContainer.execInContainer("ollama", "pull", MODEL_NAME);
logger.info(MODEL_NAME + " pulling competed!");
baseUrl = "http://" + ollamaContainer.getHost() + ":" + ollamaContainer.getMappedPort(11434);
}
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withPropertyValues("spring.ai.ollama.chat.enabled=true", "spring.ai.ollama.chat.model=" + MODEL_NAME,
"spring.ai.ollama.chat.baseUrl=" + baseUrl, "spring.ai.ollama.chat.temperature=0.5",
"spring.ai.ollama.chat.topK=500")
.withConfiguration(AutoConfigurations.of(OllamaChatAutoConfiguration.class));
private final Message systemMessage = new SystemPromptTemplate("""
You are a helpful AI assistant. Your name is {name}.
You are an AI assistant that helps people find information.
Your name is {name}
You should reply to the user's request with your name and also in the style of a {voice}.
""").createMessage(Map.of("name", "Bob", "voice", "pirate"));
private final UserMessage userMessage = new UserMessage(
"Tell me about 3 famous pirates from the Golden Age of Piracy and why they did.");
@Test
public void chatCompletion() {
contextRunner.run(context -> {
OllamaChatClient chatClient = context.getBean(OllamaChatClient.class);
ChatResponse response = chatClient.generate(new Prompt(List.of(userMessage, systemMessage)));
assertThat(response.getGeneration().getContent()).contains("Blackbeard");
});
}
@Test
public void chatCompletionStreaming() {
contextRunner.run(context -> {
OllamaChatClient chatClient = context.getBean(OllamaChatClient.class);
Flux<ChatResponse> response = chatClient.generateStream(new Prompt(List.of(userMessage, systemMessage)));
List<ChatResponse> responses = response.collectList().block();
assertThat(responses.size()).isGreaterThan(1);
String stitchedResponseContent = responses.stream()
.map(ChatResponse::getGenerations)
.flatMap(List::stream)
.map(Generation::getContent)
.collect(Collectors.joining());
assertThat(stitchedResponseContent).contains("Blackbeard");
});
}
}

View File

@@ -0,0 +1,81 @@
/*
* Copyright 2023-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.autoconfigure.ollama;
import org.junit.jupiter.api.Test;
import org.springframework.ai.ollama.OllamaChatClient;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Christian Tzolov
* @since 0.8.0
*/
public class OllamaChatAutoConfigurationTests {
@Test
public void propertiesTest() {
new ApplicationContextRunner()
.withPropertyValues("spring.ai.ollama.chat.enabled=true", "spring.ai.ollama.chat.model=MODEL_XYZ",
"spring.ai.ollama.chat.temperature=0.55", "spring.ai.ollama.chat.topP=0.55",
"spring.ai.ollama.chat.topK=123")
.withConfiguration(AutoConfigurations.of(OllamaChatAutoConfiguration.class))
.run(context -> {
var chatProperties = context.getBean(OllamaChatProperties.class);
assertThat(chatProperties.isEnabled()).isTrue();
assertThat(chatProperties.getModel()).isEqualTo("MODEL_XYZ");
assertThat(chatProperties.getTemperature()).isEqualTo(0.55f);
assertThat(chatProperties.getTopP()).isEqualTo(0.55f);
assertThat(chatProperties.getTopK()).isEqualTo(123);
});
}
@Test
public void enablingDisablingTest() {
// It is enabled by default
new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(OllamaChatAutoConfiguration.class))
.run(context -> {
assertThat(context.getBeansOfType(OllamaChatProperties.class)).isNotEmpty();
assertThat(context.getBeansOfType(OllamaChatClient.class)).isNotEmpty();
});
// Explicitly enable the chat auto-configuration.
new ApplicationContextRunner().withPropertyValues("spring.ai.ollama.chat.enabled=true")
.withConfiguration(AutoConfigurations.of(OllamaChatAutoConfiguration.class))
.run(context -> {
assertThat(context.getBeansOfType(OllamaChatProperties.class)).isNotEmpty();
assertThat(context.getBeansOfType(OllamaChatClient.class)).isNotEmpty();
});
// Explicitly disable the chat auto-configuration.
new ApplicationContextRunner().withPropertyValues("spring.ai.ollama.chat.enabled=false")
.withConfiguration(AutoConfigurations.of(OllamaChatAutoConfiguration.class))
.run(context -> {
assertThat(context.getBeansOfType(OllamaChatProperties.class)).isEmpty();
assertThat(context.getBeansOfType(OllamaChatClient.class)).isEmpty();
});
}
}

View File

@@ -17,6 +17,7 @@
package org.springframework.ai.autoconfigure.ollama;
import java.io.IOException;
import java.util.List;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
@@ -27,45 +28,53 @@ import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.springframework.ai.ollama.client.OllamaClient;
import org.springframework.ai.embedding.EmbeddingResponse;
import org.springframework.ai.ollama.OllamaEmbeddingClient;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
@Disabled("As it downloads the 3GB 'orca-mini' it can take couple of minutes to initialize.")
/**
* @author Christian Tzolov
* @since 0.8.0
*/
@Disabled("For manual smoke testing only.")
@Testcontainers
public class OllamaAutoConfigurationIT {
public class OllamaEmbeddingAutoConfigurationIT {
private static final Log logger = LogFactory.getLog(OllamaAutoConfigurationIT.class);
private static final Log logger = LogFactory.getLog(OllamaEmbeddingAutoConfigurationIT.class);
private static String MODEL_NAME = "orca-mini";
@Container
static GenericContainer<?> ollamaContainer = new GenericContainer<>("ollama/ollama:0.1.10").withExposedPorts(11434);
static GenericContainer<?> ollamaContainer = new GenericContainer<>("ollama/ollama:0.1.16").withExposedPorts(11434);
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withPropertyValues("spring.ai.ollama.baseUrl=http://" + ollamaContainer.getHost() + ":"
+ ollamaContainer.getMappedPort(11434), "spring.ai.ollama.model=orca-mini")
.withConfiguration(AutoConfigurations.of(OllamaAutoConfiguration.class));
static String baseUrl;
@BeforeAll
public static void beforeAll() throws IOException, InterruptedException {
logger.info("Start pulling the 'orca-mini' model (3GB) ... would take several minutes ...");
ollamaContainer.execInContainer("ollama", "pull", "orca-mini");
logger.info("orca-mini pulling competed!");
logger.info("Start pulling the '" + MODEL_NAME + " ' model ... would take several minutes ...");
ollamaContainer.execInContainer("ollama", "pull", MODEL_NAME);
logger.info(MODEL_NAME + " pulling competed!");
baseUrl = "http://" + ollamaContainer.getHost() + ":" + ollamaContainer.getMappedPort(11434);
}
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withPropertyValues("spring.ai.ollama.embedding.enabled=true", "spring.ai.ollama.embedding.model=" + MODEL_NAME,
"spring.ai.ollama.embedding.base-url=" + baseUrl)
.withConfiguration(AutoConfigurations.of(OllamaEmbeddingAutoConfiguration.class));
@Test
void generate() {
public void singleTextEmbedding() {
contextRunner.run(context -> {
OllamaClient client = context.getBean(OllamaClient.class);
assertThat(client.getBaseUrl())
.isEqualTo("http://" + ollamaContainer.getHost() + ":" + ollamaContainer.getMappedPort(11434));
assertThat(client.getModel()).isEqualTo("orca-mini");
String response = client.generate("Hello");
assertThat(response).isNotEmpty();
logger.info("Response: " + response);
OllamaEmbeddingClient embeddingClient = context.getBean(OllamaEmbeddingClient.class);
assertThat(embeddingClient).isNotNull();
EmbeddingResponse embeddingResponse = embeddingClient.embedForResponse(List.of("Hello World"));
assertThat(embeddingResponse.getData()).hasSize(1);
assertThat(embeddingResponse.getData().get(0).getEmbedding()).isNotEmpty();
assertThat(embeddingClient.dimensions()).isEqualTo(3200);
});
}

View File

@@ -0,0 +1,80 @@
/*
* Copyright 2023-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.autoconfigure.ollama;
import org.junit.jupiter.api.Test;
import org.springframework.ai.ollama.OllamaEmbeddingClient;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Christian Tzolov
* @since 0.8.0
*/
public class OllamaEmbeddingAutoConfigurationTests {
@Test
public void propertiesTest() {
new ApplicationContextRunner().withPropertyValues("spring.ai.ollama.embedding.enabled=true",
"spring.ai.ollama.embedding.base-url=TEST_BASE_URL", "spring.ai.ollama.embedding.model=MODEL_XYZ",
"spring.ai.ollama.embedding.options.temperature=0.13" // TODO: Fix the
// float parsing
).withConfiguration(AutoConfigurations.of(OllamaEmbeddingAutoConfiguration.class)).run(context -> {
var properties = context.getBean(OllamaEmbeddingProperties.class);
// java.lang.Float.valueOf(0.13f)
assertThat(properties.isEnabled()).isTrue();
assertThat(properties.getModel()).isEqualTo("MODEL_XYZ");
assertThat(properties.getBaseUrl()).isEqualTo("TEST_BASE_URL");
assertThat(properties.getOptions()).containsKeys("temperature");
assertThat(properties.getOptions().get("temperature")).isEqualTo("0.13");
});
}
@Test
public void enablingDisablingTest() {
// It is enabled by default
new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(OllamaEmbeddingAutoConfiguration.class))
.run(context -> {
assertThat(context.getBeansOfType(OllamaEmbeddingProperties.class)).isNotEmpty();
assertThat(context.getBeansOfType(OllamaEmbeddingClient.class)).isNotEmpty();
});
// Explicitly enable the embedding auto-configuration.
new ApplicationContextRunner().withPropertyValues("spring.ai.ollama.embedding.enabled=true")
.withConfiguration(AutoConfigurations.of(OllamaEmbeddingAutoConfiguration.class))
.run(context -> {
assertThat(context.getBeansOfType(OllamaEmbeddingProperties.class)).isNotEmpty();
assertThat(context.getBeansOfType(OllamaEmbeddingClient.class)).isNotEmpty();
});
// Explicitly disable the embedding auto-configuration.
new ApplicationContextRunner().withPropertyValues("spring.ai.ollama.embedding.enabled=false")
.withConfiguration(AutoConfigurations.of(OllamaEmbeddingAutoConfiguration.class))
.run(context -> {
assertThat(context.getBeansOfType(OllamaEmbeddingProperties.class)).isEmpty();
assertThat(context.getBeansOfType(OllamaEmbeddingClient.class)).isEmpty();
});
}
}