From 10e1e13fa204b2f634ee874fcee2360f94f18185 Mon Sep 17 00:00:00 2001 From: Thomas Vitale Date: Fri, 16 Aug 2024 15:57:51 +0200 Subject: [PATCH] Add observability support for Ollama - Improve ITs to reuse a single container across tests --- models/spring-ai-ollama/pom.xml | 6 + .../ai/ollama/OllamaChatModel.java | 215 ++++++++++++------ .../ai/ollama/OllamaEmbeddingModel.java | 91 ++++++-- .../ai/ollama/api/OllamaApi.java | 5 +- .../ai/ollama/api/OllamaModel.java | 9 +- .../ai/ollama/BaseOllamaIT.java | 26 +++ .../OllamaChatModelFunctionCallingIT.java | 25 +- .../ai/ollama/OllamaChatModelIT.java | 27 +-- .../ollama/OllamaChatModelMultimodalIT.java | 23 +- .../ollama/OllamaChatModelObservationIT.java | 177 ++++++++++++++ .../ai/ollama/OllamaChatRequestTests.java | 1 - .../ai/ollama/OllamaEmbeddingModelIT.java | 23 +- .../OllamaEmbeddingModelObservationIT.java | 112 +++++++++ .../ai/ollama/OllamaEmbeddingModelTests.java | 18 +- .../ollama/OllamaEmbeddingRequestTests.java | 11 +- .../ai/ollama/OllamaImage.java | 2 +- .../ai/ollama/api/OllamaApiIT.java | 34 ++- .../ollama/api/OllamaModelOptionsTests.java | 4 +- .../ollama/api/tool/MockWeatherService.java | 4 +- .../api/tool/OllamaApiToolFunctionCallIT.java | 27 +-- .../chat/OllamaWithOpenAiChatModelIT.java | 2 +- .../ai/converter/BeanOutputConverter.java | 21 +- .../ollama/OllamaAutoConfiguration.java | 25 +- .../ollama/OllamaChatAutoConfigurationIT.java | 4 +- .../OllamaEmbeddingAutoConfigurationIT.java | 11 +- .../ai/autoconfigure/ollama/OllamaImage.java | 2 +- ...ContainerConnectionDetailsFactoryTest.java | 2 +- 27 files changed, 691 insertions(+), 216 deletions(-) create mode 100644 models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/BaseOllamaIT.java create mode 100644 models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatModelObservationIT.java create mode 100644 models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaEmbeddingModelObservationIT.java diff --git a/models/spring-ai-ollama/pom.xml b/models/spring-ai-ollama/pom.xml index f0b5228c6..d950b8612 100644 --- a/models/spring-ai-ollama/pom.xml +++ b/models/spring-ai-ollama/pom.xml @@ -74,6 +74,12 @@ test + + io.micrometer + micrometer-observation-test + test + + org.testcontainers junit-jupiter diff --git a/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/OllamaChatModel.java b/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/OllamaChatModel.java index 68d1c61e4..79dced215 100644 --- a/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/OllamaChatModel.java +++ b/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/OllamaChatModel.java @@ -21,17 +21,22 @@ import java.util.List; import java.util.Map; import java.util.Set; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; +import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.ai.chat.messages.ToolResponseMessage; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.metadata.ChatGenerationMetadata; import org.springframework.ai.chat.metadata.ChatResponseMetadata; -import org.springframework.ai.chat.model.AbstractToolCallSupport; -import org.springframework.ai.chat.model.ChatModel; -import org.springframework.ai.chat.model.ChatResponse; -import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.model.*; +import org.springframework.ai.chat.observation.ChatModelObservationContext; +import org.springframework.ai.chat.observation.ChatModelObservationConvention; +import org.springframework.ai.chat.observation.ChatModelObservationDocumentation; +import org.springframework.ai.chat.observation.DefaultChatModelObservationConvention; import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.ChatOptionsBuilder; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.ai.model.function.FunctionCallback; @@ -64,6 +69,8 @@ import reactor.core.publisher.Flux; */ public class OllamaChatModel extends AbstractToolCallSupport implements ChatModel { + private static final ChatModelObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultChatModelObservationConvention(); + /** * Low-level Ollama API library. */ @@ -72,61 +79,97 @@ public class OllamaChatModel extends AbstractToolCallSupport implements ChatMode /** * Default options to be used for all chat requests. */ - private OllamaOptions defaultOptions; + private final OllamaOptions defaultOptions; - public OllamaChatModel(OllamaApi chatApi) { - this(chatApi, OllamaOptions.create().withModel(OllamaOptions.DEFAULT_MODEL)); + /** + * Observation registry used for instrumentation. + */ + private final ObservationRegistry observationRegistry; + + /** + * Conventions to use for generating observations. + */ + private ChatModelObservationConvention observationConvention = DEFAULT_OBSERVATION_CONVENTION; + + public OllamaChatModel(OllamaApi ollamaApi) { + this(ollamaApi, OllamaOptions.create().withModel(OllamaOptions.DEFAULT_MODEL)); } - public OllamaChatModel(OllamaApi chatApi, OllamaOptions defaultOptions) { - this(chatApi, defaultOptions, null); + public OllamaChatModel(OllamaApi ollamaApi, OllamaOptions defaultOptions) { + this(ollamaApi, defaultOptions, null); } - public OllamaChatModel(OllamaApi chatApi, OllamaOptions defaultOptions, + public OllamaChatModel(OllamaApi ollamaApi, OllamaOptions defaultOptions, FunctionCallbackContext functionCallbackContext) { - this(chatApi, defaultOptions, functionCallbackContext, List.of()); + this(ollamaApi, defaultOptions, functionCallbackContext, List.of()); + } + + public OllamaChatModel(OllamaApi ollamaApi, OllamaOptions defaultOptions, + FunctionCallbackContext functionCallbackContext, List toolFunctionCallbacks) { + this(ollamaApi, defaultOptions, functionCallbackContext, toolFunctionCallbacks, ObservationRegistry.NOOP); } public OllamaChatModel(OllamaApi chatApi, OllamaOptions defaultOptions, - FunctionCallbackContext functionCallbackContext, List toolFunctionCallbacks) { + FunctionCallbackContext functionCallbackContext, List toolFunctionCallbacks, + ObservationRegistry observationRegistry) { super(functionCallbackContext, defaultOptions, toolFunctionCallbacks); - Assert.notNull(chatApi, "OllamaApi must not be null"); - Assert.notNull(defaultOptions, "DefaultOptions must not be null"); + Assert.notNull(chatApi, "ollamaApi must not be null"); + Assert.notNull(defaultOptions, "defaultOptions must not be null"); + Assert.notNull(observationRegistry, "ObservationRegistry must not be null"); this.chatApi = chatApi; this.defaultOptions = defaultOptions; + this.observationRegistry = observationRegistry; } @Override public ChatResponse call(Prompt prompt) { + OllamaApi.ChatRequest request = ollamaChatRequest(prompt, false); - OllamaApi.ChatResponse response = this.chatApi.chat(ollamaChatRequest(prompt, false)); + ChatModelObservationContext observationContext = ChatModelObservationContext.builder() + .prompt(prompt) + .provider(OllamaApi.PROVIDER_NAME) + .requestOptions(buildRequestOptions(request)) + .build(); - List toolCalls = response.message().toolCalls() == null ? List.of() - : response.message() - .toolCalls() - .stream() - .map(toolCall -> new AssistantMessage.ToolCall("", "function", toolCall.function().name(), - ModelOptionsUtils.toJsonString(toolCall.function().arguments()))) - .toList(); + ChatResponse response = ChatModelObservationDocumentation.CHAT_MODEL_OPERATION + .observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, + this.observationRegistry) + .observe(() -> { - var assistantMessage = new AssistantMessage(response.message().content(), Map.of(), toolCalls); + OllamaApi.ChatResponse ollamaResponse = this.chatApi.chat(request); - ChatGenerationMetadata generationMetadata = ChatGenerationMetadata.NULL; - if (response.promptEvalCount() != null && response.evalCount() != null) { - generationMetadata = ChatGenerationMetadata.from(response.doneReason(), null); - } + List toolCalls = ollamaResponse.message().toolCalls() == null ? List.of() + : ollamaResponse.message() + .toolCalls() + .stream() + .map(toolCall -> new AssistantMessage.ToolCall("", "function", toolCall.function().name(), + ModelOptionsUtils.toJsonString(toolCall.function().arguments()))) + .toList(); - var generator = new Generation(assistantMessage, generationMetadata); - var chatResponse = new ChatResponse(List.of(generator), from(response)); + var assistantMessage = new AssistantMessage(ollamaResponse.message().content(), Map.of(), toolCalls); - if (isToolCall(chatResponse, Set.of("stop"))) { - var toolCallConversation = handleToolCalls(prompt, chatResponse); + ChatGenerationMetadata generationMetadata = ChatGenerationMetadata.NULL; + if (ollamaResponse.promptEvalCount() != null && ollamaResponse.evalCount() != null) { + generationMetadata = ChatGenerationMetadata.from(ollamaResponse.doneReason(), null); + } + + var generator = new Generation(assistantMessage, generationMetadata); + ChatResponse chatResponse = new ChatResponse(List.of(generator), from(ollamaResponse)); + + observationContext.setResponse(chatResponse); + + return chatResponse; + + }); + + if (response != null && isToolCall(response, Set.of("stop"))) { + var toolCallConversation = handleToolCalls(prompt, response); // Recursively call the call method with the tool call message // conversation that contains the call responses. return this.call(new Prompt(toolCallConversation, prompt.getOptions())); } - return chatResponse; + return response; } public static ChatResponseMetadata from(OllamaApi.ChatResponse response) { @@ -147,40 +190,64 @@ public class OllamaChatModel extends AbstractToolCallSupport implements ChatMode @Override public Flux stream(Prompt prompt) { + return Flux.deferContextual(contextView -> { + OllamaApi.ChatRequest request = ollamaChatRequest(prompt, true); - Flux ollamaResponse = this.chatApi.streamingChat(ollamaChatRequest(prompt, true)); + final ChatModelObservationContext observationContext = ChatModelObservationContext.builder() + .prompt(prompt) + .provider(OllamaApi.PROVIDER_NAME) + .requestOptions(buildRequestOptions(request)) + .build(); - Flux chatResponse = ollamaResponse.map(chunk -> { - String content = (chunk.message() != null) ? chunk.message().content() : ""; - List toolCalls = chunk.message().toolCalls() == null ? List.of() - : chunk.message() - .toolCalls() - .stream() - .map(toolCall -> new AssistantMessage.ToolCall("", "function", toolCall.function().name(), - ModelOptionsUtils.toJsonString(toolCall.function().arguments()))) - .toList(); + Observation observation = ChatModelObservationDocumentation.CHAT_MODEL_OPERATION.observation( + this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, + this.observationRegistry); - var assistantMessage = new AssistantMessage(content, Map.of(), toolCalls); + observation.parentObservation(contextView.getOrDefault(ObservationThreadLocalAccessor.KEY, null)).start(); - ChatGenerationMetadata generationMetadata = ChatGenerationMetadata.NULL; - if (chunk.promptEvalCount() != null && chunk.evalCount() != null) { - generationMetadata = ChatGenerationMetadata.from(chunk.doneReason(), null); - } + Flux ollamaResponse = this.chatApi.streamingChat(request); - var generator = new Generation(assistantMessage, generationMetadata); - return new ChatResponse(List.of(generator), from(chunk)); - }); + Flux chatResponse = ollamaResponse.map(chunk -> { + String content = (chunk.message() != null) ? chunk.message().content() : ""; + List toolCalls = chunk.message().toolCalls() == null ? List.of() + : chunk.message() + .toolCalls() + .stream() + .map(toolCall -> new AssistantMessage.ToolCall("", "function", toolCall.function().name(), + ModelOptionsUtils.toJsonString(toolCall.function().arguments()))) + .toList(); - return chatResponse.flatMap(response -> { - if (isToolCall(response, Set.of("stop"))) { - var toolCallConversation = handleToolCalls(prompt, response); - // Recursively call the stream method with the tool call message - // conversation that contains the call responses. - return this.stream(new Prompt(toolCallConversation, prompt.getOptions())); - } - else { - return Flux.just(response); - } + var assistantMessage = new AssistantMessage(content, Map.of(), toolCalls); + + ChatGenerationMetadata generationMetadata = ChatGenerationMetadata.NULL; + if (chunk.promptEvalCount() != null && chunk.evalCount() != null) { + generationMetadata = ChatGenerationMetadata.from(chunk.doneReason(), null); + } + + var generator = new Generation(assistantMessage, generationMetadata); + return new ChatResponse(List.of(generator), from(chunk)); + }); + + // @formatter:off + Flux chatResponseFlux = chatResponse.flatMap(response -> { + if (isToolCall(response, Set.of("stop"))) { + var toolCallConversation = handleToolCalls(prompt, response); + // Recursively call the stream method with the tool call message + // conversation that contains the call responses. + return this.stream(new Prompt(toolCallConversation, prompt.getOptions())); + } + else { + return Flux.just(response); + } + }) + .doOnError(observation::error) + .doFinally(s -> { + observation.stop(); + }) + .contextWrite(ctx -> ctx.put(ObservationThreadLocalAccessor.KEY, observation)); + // @formatter:on + + return new MessageAggregator().aggregate(chatResponseFlux, observationContext::setResponse); }); } @@ -216,13 +283,10 @@ public class OllamaChatModel extends AbstractToolCallSupport implements ChatMode .build()); } else if (message instanceof ToolResponseMessage toolMessage) { - - List responseMessages = toolMessage.getResponses() + return toolMessage.getResponses() .stream() .map(tr -> OllamaApi.Message.builder(Role.TOOL).withContent(tr.responseData()).build()) .toList(); - - return responseMessages; } throw new IllegalArgumentException("Unsupported message type: " + message.getMessageType()); }).flatMap(List::stream).toList(); @@ -290,9 +354,32 @@ public class OllamaChatModel extends AbstractToolCallSupport implements ChatMode }).toList(); } + private ChatOptions buildRequestOptions(OllamaApi.ChatRequest request) { + var options = ModelOptionsUtils.mapToClass(request.options(), OllamaOptions.class); + return ChatOptionsBuilder.builder() + .withModel(request.model()) + .withFrequencyPenalty(options.getFrequencyPenalty()) + .withMaxTokens(options.getMaxTokens()) + .withPresencePenalty(options.getPresencePenalty()) + .withStopSequences(options.getStopSequences()) + .withTemperature(options.getTemperature()) + .withTopK(options.getTopK()) + .withTopP(options.getTopP()) + .build(); + } + @Override public ChatOptions getDefaultOptions() { return OllamaOptions.fromOptions(this.defaultOptions); } + /** + * Use the provided convention for reporting observation data + * @param observationConvention The provided convention + */ + public void setObservationConvention(ChatModelObservationConvention observationConvention) { + Assert.notNull(observationConvention, "observationConvention cannot be null"); + this.observationConvention = observationConvention; + } + } \ No newline at end of file diff --git a/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/OllamaEmbeddingModel.java b/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/OllamaEmbeddingModel.java index 4b1777960..3c43b9154 100644 --- a/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/OllamaEmbeddingModel.java +++ b/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/OllamaEmbeddingModel.java @@ -21,17 +21,14 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.regex.Matcher; import java.util.regex.Pattern; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import io.micrometer.observation.ObservationRegistry; import org.springframework.ai.chat.metadata.EmptyUsage; import org.springframework.ai.document.Document; -import org.springframework.ai.embedding.AbstractEmbeddingModel; -import org.springframework.ai.embedding.Embedding; -import org.springframework.ai.embedding.EmbeddingModel; -import org.springframework.ai.embedding.EmbeddingOptions; -import org.springframework.ai.embedding.EmbeddingRequest; -import org.springframework.ai.embedding.EmbeddingResponse; -import org.springframework.ai.embedding.EmbeddingResponseMetadata; +import org.springframework.ai.embedding.*; +import org.springframework.ai.embedding.observation.DefaultEmbeddingModelObservationConvention; +import org.springframework.ai.embedding.observation.EmbeddingModelObservationContext; +import org.springframework.ai.embedding.observation.EmbeddingModelObservationConvention; +import org.springframework.ai.embedding.observation.EmbeddingModelObservationDocumentation; import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.ai.ollama.api.OllamaApi; import org.springframework.ai.ollama.api.OllamaApi.EmbeddingsResponse; @@ -53,26 +50,47 @@ import org.springframework.util.StringUtils; * most up-to-date information on available models. * * @author Christian Tzolov + * @author Thomas Vitale * @since 0.8.0 */ public class OllamaEmbeddingModel extends AbstractEmbeddingModel { - private final Logger logger = LoggerFactory.getLogger(getClass()); + private static final EmbeddingModelObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultEmbeddingModelObservationConvention(); private final OllamaApi ollamaApi; /** * Default options to be used for all chat requests. */ - private OllamaOptions defaultOptions = OllamaOptions.create().withModel(OllamaOptions.DEFAULT_MODEL); + private final OllamaOptions defaultOptions; + + /** + * Observation registry used for instrumentation. + */ + private final ObservationRegistry observationRegistry; + + /** + * Conventions to use for generating observations. + */ + private EmbeddingModelObservationConvention observationConvention = DEFAULT_OBSERVATION_CONVENTION; public OllamaEmbeddingModel(OllamaApi ollamaApi) { - this.ollamaApi = ollamaApi; + this(ollamaApi, OllamaOptions.create().withModel(OllamaOptions.DEFAULT_MODEL)); } public OllamaEmbeddingModel(OllamaApi ollamaApi, OllamaOptions defaultOptions) { + this(ollamaApi, defaultOptions, ObservationRegistry.NOOP); + } + + public OllamaEmbeddingModel(OllamaApi ollamaApi, OllamaOptions defaultOptions, + ObservationRegistry observationRegistry) { + Assert.notNull(ollamaApi, "openAiApi must not be null"); + Assert.notNull(defaultOptions, "options must not be null"); + Assert.notNull(observationRegistry, "observationRegistry must not be null"); + this.ollamaApi = ollamaApi; this.defaultOptions = defaultOptions; + this.observationRegistry = observationRegistry; } @Override @@ -82,25 +100,39 @@ public class OllamaEmbeddingModel extends AbstractEmbeddingModel { @Override public EmbeddingResponse call(EmbeddingRequest request) { - Assert.notEmpty(request.getInstructions(), "At least one text is required!"); OllamaApi.EmbeddingsRequest ollamaEmbeddingRequest = ollamaEmbeddingRequest(request.getInstructions(), request.getOptions()); - EmbeddingsResponse response = this.ollamaApi.embed(ollamaEmbeddingRequest); + var observationContext = EmbeddingModelObservationContext.builder() + .embeddingRequest(request) + .provider(OllamaApi.PROVIDER_NAME) + .requestOptions(buildRequestOptions(ollamaEmbeddingRequest)) + .build(); - AtomicInteger indexCounter = new AtomicInteger(0); + return EmbeddingModelObservationDocumentation.EMBEDDING_MODEL_OPERATION + .observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, + this.observationRegistry) + .observe(() -> { + EmbeddingsResponse response = this.ollamaApi.embed(ollamaEmbeddingRequest); - List embeddings = response.embeddings() - .stream() - .map(e -> new Embedding(e, indexCounter.getAndIncrement())) - .toList(); + AtomicInteger indexCounter = new AtomicInteger(0); - EmbeddingResponseMetadata embeddingResponseMetadata = new EmbeddingResponseMetadata(response.model(), - new EmptyUsage()); + List embeddings = response.embeddings() + .stream() + .map(e -> new Embedding(e, indexCounter.getAndIncrement())) + .toList(); - return new EmbeddingResponse(embeddings, embeddingResponseMetadata); + EmbeddingResponseMetadata embeddingResponseMetadata = new EmbeddingResponseMetadata(response.model(), + new EmptyUsage()); + + EmbeddingResponse embeddingResponse = new EmbeddingResponse(embeddings, embeddingResponseMetadata); + + observationContext.setResponse(embeddingResponse); + + return embeddingResponse; + }); } /** @@ -126,9 +158,22 @@ public class OllamaEmbeddingModel extends AbstractEmbeddingModel { OllamaOptions.filterNonSupportedFields(mergedOptions.toMap()), mergedOptions.getTruncate()); } + private EmbeddingOptions buildRequestOptions(OllamaApi.EmbeddingsRequest request) { + return EmbeddingOptionsBuilder.builder().withModel(request.model()).build(); + } + + /** + * Use the provided convention for reporting observation data + * @param observationConvention The provided convention + */ + public void setObservationConvention(EmbeddingModelObservationConvention observationConvention) { + Assert.notNull(observationConvention, "observationConvention cannot be null"); + this.observationConvention = observationConvention; + } + public static class DurationParser { - private static Pattern PATTERN = Pattern.compile("(\\d+)(ms|s|m|h)"); + private static final Pattern PATTERN = Pattern.compile("(\\d+)(ms|s|m|h)"); public static Duration parse(String input) { diff --git a/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/api/OllamaApi.java b/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/api/OllamaApi.java index 29c2e5ad0..9da600a58 100644 --- a/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/api/OllamaApi.java +++ b/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/api/OllamaApi.java @@ -26,6 +26,7 @@ import java.util.function.Consumer; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.observation.conventions.AiProvider; import org.springframework.boot.context.properties.bind.ConstructorBinding; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -55,7 +56,9 @@ public class OllamaApi { private static final Log logger = LogFactory.getLog(OllamaApi.class); - private final static String DEFAULT_BASE_URL = "http://localhost:11434"; + private static final String DEFAULT_BASE_URL = "http://localhost:11434"; + + public static final String PROVIDER_NAME = AiProvider.OLLAMA.value(); public static final String REQUEST_BODY_NULL_ERROR = "The request body can not be null."; diff --git a/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/api/OllamaModel.java b/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/api/OllamaModel.java index 14299301a..32481d810 100644 --- a/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/api/OllamaModel.java +++ b/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/api/OllamaModel.java @@ -22,7 +22,7 @@ import org.springframework.ai.model.ChatModelDescription; * * @author Siarhei Blashuk * @author Thomas Vitale - * @since 0.8.1 + * @since 1.0.0 */ public enum OllamaModel implements ChatModelDescription { @@ -106,7 +106,12 @@ public enum OllamaModel implements ChatModelDescription { /** * Uncensored Llama 2 model */ - LLAMA2_UNCENSORED("llama2-uncensored"); + LLAMA2_UNCENSORED("llama2-uncensored"), + + /** + * A high-performing open embedding model with a large token context window. + */ + NOMIC_EMBED_TEXT("nomic-embed-text"); private final String id; diff --git a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/BaseOllamaIT.java b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/BaseOllamaIT.java new file mode 100644 index 000000000..5bda58619 --- /dev/null +++ b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/BaseOllamaIT.java @@ -0,0 +1,26 @@ +package org.springframework.ai.ollama; + +import org.testcontainers.ollama.OllamaContainer; + +public class BaseOllamaIT { + + public static final OllamaContainer ollamaContainer; + + static { + ollamaContainer = new OllamaContainer(OllamaImage.DEFAULT_IMAGE).withReuse(true); + ollamaContainer.start(); + } + + /** + * Change the value to false in order to run multiple Ollama IT tests locally reusing + * the same container image Also add the entry + * + * testcontainers.reuse.enable=true + * + * to the file .testcontainers.properties located in your home directory + */ + public static boolean isDisabled() { + return true; + } + +} diff --git a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatModelFunctionCallingIT.java b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatModelFunctionCallingIT.java index 6f7334d2c..a790a8a89 100644 --- a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatModelFunctionCallingIT.java +++ b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatModelFunctionCallingIT.java @@ -15,16 +15,10 @@ */ package org.springframework.ai.ollama; -import static org.assertj.core.api.Assertions.assertThat; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; - import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIf; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.chat.messages.AssistantMessage; @@ -43,24 +37,25 @@ 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.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.ollama.OllamaContainer; - import reactor.core.publisher.Flux; -@Disabled("For manual smoke testing only.") +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + @Testcontainers @SpringBootTest(classes = OllamaChatModelFunctionCallingIT.Config.class) -class OllamaChatModelFunctionCallingIT { +@DisabledIf("isDisabled") +class OllamaChatModelFunctionCallingIT extends BaseOllamaIT { private static final Logger logger = LoggerFactory.getLogger(OllamaChatModelFunctionCallingIT.class); private static final String MODEL = OllamaModel.MISTRAL.getName(); - @Container - static OllamaContainer ollamaContainer = new OllamaContainer(OllamaImage.DEFAULT_IMAGE); - static String baseUrl = "http://localhost:11434"; @BeforeAll diff --git a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatModelIT.java b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatModelIT.java index aea4daa1c..a773a0546 100644 --- a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatModelIT.java +++ b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatModelIT.java @@ -15,19 +15,11 @@ */ package org.springframework.ai.ollama; -import static org.assertj.core.api.Assertions.assertThat; - -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.junit.jupiter.api.condition.DisabledIf; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.UserMessage; @@ -49,22 +41,25 @@ 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 org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.ollama.OllamaContainer; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest @Testcontainers -@Disabled("For manual smoke testing only.") -class OllamaChatModelIT { +@DisabledIf("isDisabled") +class OllamaChatModelIT extends BaseOllamaIT { private static final String MODEL = OllamaModel.MISTRAL.getName(); private static final Log logger = LogFactory.getLog(OllamaChatModelIT.class); - @Container - static OllamaContainer ollamaContainer = new OllamaContainer(OllamaImage.DEFAULT_IMAGE); - static String baseUrl = "http://localhost:11434"; @BeforeAll diff --git a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatModelMultimodalIT.java b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatModelMultimodalIT.java index c47755502..06964c10d 100644 --- a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatModelMultimodalIT.java +++ b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatModelMultimodalIT.java @@ -15,23 +15,16 @@ */ 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.springframework.ai.ollama.api.OllamaModel; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.ollama.OllamaContainer; - -import org.springframework.ai.model.Media; +import org.junit.jupiter.api.condition.DisabledIf; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.model.Media; import org.springframework.ai.ollama.api.OllamaApi; +import org.springframework.ai.ollama.api.OllamaModel; import org.springframework.ai.ollama.api.OllamaOptions; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringBootConfiguration; @@ -39,13 +32,19 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Bean; import org.springframework.core.io.ClassPathResource; import org.springframework.util.MimeTypeUtils; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.ollama.OllamaContainer; + +import java.io.IOException; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest @Testcontainers -@Disabled("For manual smoke testing only.") -class OllamaChatModelMultimodalIT { +@DisabledIf("isDisabled") +class OllamaChatModelMultimodalIT extends BaseOllamaIT { private static final String MODEL = OllamaModel.MOONDREAM.getName(); diff --git a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatModelObservationIT.java b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatModelObservationIT.java new file mode 100644 index 000000000..25d5f9960 --- /dev/null +++ b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatModelObservationIT.java @@ -0,0 +1,177 @@ +/* + * Copyright 2024 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 io.micrometer.observation.tck.TestObservationRegistry; +import io.micrometer.observation.tck.TestObservationRegistryAssert; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIf; +import org.springframework.ai.chat.metadata.ChatResponseMetadata; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.observation.DefaultChatModelObservationConvention; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.model.function.FunctionCallbackContext; +import org.springframework.ai.observation.conventions.AiOperationType; +import org.springframework.ai.observation.conventions.AiProvider; +import org.springframework.ai.ollama.api.OllamaApi; +import org.springframework.ai.ollama.api.OllamaModel; +import org.springframework.ai.ollama.api.OllamaOptions; +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 reactor.core.publisher.Flux; + +import java.util.List; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.ai.chat.observation.ChatModelObservationDocumentation.HighCardinalityKeyNames; +import static org.springframework.ai.chat.observation.ChatModelObservationDocumentation.LowCardinalityKeyNames; + +/** + * Integration tests for observation instrumentation in {@link OllamaChatModel}. + * + * @author Thomas Vitale + */ +@SpringBootTest(classes = OllamaChatModelObservationIT.Config.class) +@DisabledIf("isDisabled") +public class OllamaChatModelObservationIT extends BaseOllamaIT { + + @Autowired + TestObservationRegistry observationRegistry; + + @Autowired + OllamaChatModel chatModel; + + @BeforeEach + void beforeEach() { + observationRegistry.clear(); + } + + @Test + void observationForChatOperation() { + var options = OllamaOptions.builder() + .withModel(OllamaModel.MISTRAL.getName()) + .withFrequencyPenalty(0f) + .withNumPredict(2048) + .withPresencePenalty(0f) + .withStop(List.of("this-is-the-end")) + .withTemperature(0.7f) + .withTopK(1) + .withTopP(1f) + .build(); + + Prompt prompt = new Prompt("Why does a raven look like a desk?", options); + + ChatResponse chatResponse = chatModel.call(prompt); + assertThat(chatResponse.getResult().getOutput().getContent()).isNotEmpty(); + + ChatResponseMetadata responseMetadata = chatResponse.getMetadata(); + assertThat(responseMetadata).isNotNull(); + + validate(responseMetadata); + } + + @Test + void observationForStreamingChatOperation() { + var options = OllamaOptions.builder() + .withModel(OllamaModel.MISTRAL.getName()) + .withFrequencyPenalty(0f) + .withNumPredict(2048) + .withPresencePenalty(0f) + .withStop(List.of("this-is-the-end")) + .withTemperature(0.7f) + .withTopK(1) + .withTopP(1f) + .build(); + + Prompt prompt = new Prompt("Why does a raven look like a desk?", options); + + Flux chatResponseFlux = chatModel.stream(prompt); + + List responses = chatResponseFlux.collectList().block(); + assertThat(responses).isNotEmpty(); + assertThat(responses).hasSizeGreaterThan(10); + + String aggregatedResponse = responses.subList(0, responses.size() - 1) + .stream() + .map(r -> r.getResult().getOutput().getContent()) + .collect(Collectors.joining()); + assertThat(aggregatedResponse).isNotEmpty(); + + ChatResponse lastChatResponse = responses.get(responses.size() - 1); + + ChatResponseMetadata responseMetadata = lastChatResponse.getMetadata(); + assertThat(responseMetadata).isNotNull(); + + validate(responseMetadata); + } + + private void validate(ChatResponseMetadata responseMetadata) { + TestObservationRegistryAssert.assertThat(observationRegistry) + .doesNotHaveAnyRemainingCurrentObservation() + .hasObservationWithNameEqualTo(DefaultChatModelObservationConvention.DEFAULT_NAME) + .that() + .hasContextualNameEqualTo("chat " + OllamaModel.MISTRAL.getName()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_OPERATION_TYPE.asString(), + AiOperationType.CHAT.value()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_PROVIDER.asString(), AiProvider.OLLAMA.value()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.REQUEST_MODEL.asString(), OllamaModel.MISTRAL.getName()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.RESPONSE_MODEL.asString(), responseMetadata.getModel()) + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_FREQUENCY_PENALTY.asString(), "0.0") + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_MAX_TOKENS.asString(), "2048") + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_PRESENCE_PENALTY.asString(), "0.0") + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_STOP_SEQUENCES.asString(), + "[\"this-is-the-end\"]") + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_TEMPERATURE.asString(), "0.7") + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_TOP_K.asString(), "1") + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_TOP_P.asString(), "1.0") + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.RESPONSE_ID.asString(), responseMetadata.getId()) + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.RESPONSE_FINISH_REASONS.asString(), "[\"stop\"]") + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.USAGE_INPUT_TOKENS.asString(), + String.valueOf(responseMetadata.getUsage().getPromptTokens())) + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.USAGE_OUTPUT_TOKENS.asString(), + String.valueOf(responseMetadata.getUsage().getGenerationTokens())) + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.USAGE_TOTAL_TOKENS.asString(), + String.valueOf(responseMetadata.getUsage().getTotalTokens())) + .hasBeenStarted() + .hasBeenStopped(); + } + + @SpringBootConfiguration + static class Config { + + @Bean + public TestObservationRegistry observationRegistry() { + return TestObservationRegistry.create(); + } + + @Bean + public OllamaApi openAiApi() { + return new OllamaApi(); + } + + @Bean + public OllamaChatModel openAiChatModel(OllamaApi openAiApi, TestObservationRegistry observationRegistry) { + return new OllamaChatModel(openAiApi, OllamaOptions.create(), new FunctionCallbackContext(), List.of(), + observationRegistry); + } + + } + +} diff --git a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatRequestTests.java b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatRequestTests.java index 3ea38e6d0..8dffb29bf 100644 --- a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatRequestTests.java +++ b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatRequestTests.java @@ -16,7 +16,6 @@ package org.springframework.ai.ollama; import org.junit.jupiter.api.Test; - import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.chat.prompt.ChatOptionsBuilder; import org.springframework.ai.chat.prompt.Prompt; diff --git a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaEmbeddingModelIT.java b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaEmbeddingModelIT.java index 6759717a2..ebede2211 100644 --- a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaEmbeddingModelIT.java +++ b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaEmbeddingModelIT.java @@ -15,41 +15,40 @@ */ 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.springframework.ai.ollama.api.OllamaModel; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.ollama.OllamaContainer; +import org.junit.jupiter.api.condition.DisabledIf; import org.springframework.ai.embedding.EmbeddingRequest; import org.springframework.ai.embedding.EmbeddingResponse; import org.springframework.ai.ollama.api.OllamaApi; import org.springframework.ai.ollama.api.OllamaApiIT; +import org.springframework.ai.ollama.api.OllamaModel; import org.springframework.ai.ollama.api.OllamaOptions; 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.testcontainers.junit.jupiter.Testcontainers; + +import java.io.IOException; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest -@Disabled("For manual smoke testing only.") +@DisabledIf("isDisabled") @Testcontainers -class OllamaEmbeddingModelIT { +class OllamaEmbeddingModelIT extends BaseOllamaIT { private static final String MODEL = OllamaModel.MISTRAL.getName(); private static final Log logger = LogFactory.getLog(OllamaApiIT.class); - @Container - static OllamaContainer ollamaContainer = new OllamaContainer(OllamaImage.DEFAULT_IMAGE); + // @Container + // static OllamaContainer ollamaContainer = new + // OllamaContainer(OllamaImage.DEFAULT_IMAGE); static String baseUrl = "http://localhost:11434"; diff --git a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaEmbeddingModelObservationIT.java b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaEmbeddingModelObservationIT.java new file mode 100644 index 000000000..330a0a7df --- /dev/null +++ b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaEmbeddingModelObservationIT.java @@ -0,0 +1,112 @@ +/* + * Copyright 2024 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 io.micrometer.common.KeyValue; +import io.micrometer.observation.tck.TestObservationRegistry; +import io.micrometer.observation.tck.TestObservationRegistryAssert; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIf; +import org.springframework.ai.embedding.EmbeddingRequest; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.ai.embedding.EmbeddingResponseMetadata; +import org.springframework.ai.embedding.observation.DefaultEmbeddingModelObservationConvention; +import org.springframework.ai.observation.conventions.AiOperationType; +import org.springframework.ai.observation.conventions.AiProvider; +import org.springframework.ai.ollama.api.OllamaApi; +import org.springframework.ai.ollama.api.OllamaModel; +import org.springframework.ai.ollama.api.OllamaOptions; +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 java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.ai.embedding.observation.EmbeddingModelObservationDocumentation.HighCardinalityKeyNames; +import static org.springframework.ai.embedding.observation.EmbeddingModelObservationDocumentation.LowCardinalityKeyNames; + +/** + * Integration tests for observation instrumentation in {@link OllamaEmbeddingModel}. + * + * @author Thomas Vitale + */ +@SpringBootTest(classes = OllamaEmbeddingModelObservationIT.Config.class) +@DisabledIf("isDisabled") +public class OllamaEmbeddingModelObservationIT extends BaseOllamaIT { + + @Autowired + TestObservationRegistry observationRegistry; + + @Autowired + OllamaEmbeddingModel embeddingModel; + + @Test + void observationForEmbeddingOperation() { + var options = OllamaOptions.builder().withModel(OllamaModel.NOMIC_EMBED_TEXT.getName()).build(); + + EmbeddingRequest embeddingRequest = new EmbeddingRequest(List.of("Here comes the sun"), options); + + EmbeddingResponse embeddingResponse = embeddingModel.call(embeddingRequest); + assertThat(embeddingResponse.getResults()).isNotEmpty(); + + EmbeddingResponseMetadata responseMetadata = embeddingResponse.getMetadata(); + assertThat(responseMetadata).isNotNull(); + + TestObservationRegistryAssert.assertThat(observationRegistry) + .doesNotHaveAnyRemainingCurrentObservation() + .hasObservationWithNameEqualTo(DefaultEmbeddingModelObservationConvention.DEFAULT_NAME) + .that() + .hasContextualNameEqualTo("embedding " + OllamaModel.NOMIC_EMBED_TEXT.getName()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_OPERATION_TYPE.asString(), + AiOperationType.EMBEDDING.value()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_PROVIDER.asString(), AiProvider.OLLAMA.value()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.REQUEST_MODEL.asString(), + OllamaModel.NOMIC_EMBED_TEXT.getName()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.RESPONSE_MODEL.asString(), responseMetadata.getModel()) + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_EMBEDDING_DIMENSIONS.asString(), + KeyValue.NONE_VALUE) + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.USAGE_INPUT_TOKENS.asString(), + String.valueOf(responseMetadata.getUsage().getPromptTokens())) + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.USAGE_TOTAL_TOKENS.asString(), + String.valueOf(responseMetadata.getUsage().getTotalTokens())) + .hasBeenStarted() + .hasBeenStopped(); + } + + @SpringBootConfiguration + static class Config { + + @Bean + public TestObservationRegistry observationRegistry() { + return TestObservationRegistry.create(); + } + + @Bean + public OllamaApi openAiApi() { + return new OllamaApi(); + } + + @Bean + public OllamaEmbeddingModel openAiEmbeddingModel(OllamaApi openAiApi, + TestObservationRegistry observationRegistry) { + return new OllamaEmbeddingModel(openAiApi, OllamaOptions.builder().build(), observationRegistry); + } + + } + +} diff --git a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaEmbeddingModelTests.java b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaEmbeddingModelTests.java index a6ed94d41..192c99864 100644 --- a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaEmbeddingModelTests.java +++ b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaEmbeddingModelTests.java @@ -15,20 +15,13 @@ */ package org.springframework.ai.ollama; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.when; - -import java.time.Duration; -import java.util.List; -import java.util.Map; - import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Captor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.ai.embedding.EmbeddingOptions; +import org.springframework.ai.embedding.EmbeddingOptionsBuilder; import org.springframework.ai.embedding.EmbeddingRequest; import org.springframework.ai.embedding.EmbeddingResponse; import org.springframework.ai.embedding.EmbeddingResultMetadata; @@ -37,6 +30,13 @@ import org.springframework.ai.ollama.api.OllamaApi.EmbeddingsRequest; import org.springframework.ai.ollama.api.OllamaApi.EmbeddingsResponse; import org.springframework.ai.ollama.api.OllamaOptions; +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + /** * @author Christian Tzolov * @since 1.0.0 @@ -65,7 +65,7 @@ public class OllamaEmbeddingModelTests { var embeddingModel = new OllamaEmbeddingModel(ollamaApi, defaultOptions); EmbeddingResponse response = embeddingModel - .call(new EmbeddingRequest(List.of("Input1", "Input2", "Input3"), EmbeddingOptions.EMPTY)); + .call(new EmbeddingRequest(List.of("Input1", "Input2", "Input3"), EmbeddingOptionsBuilder.builder().build())); assertThat(response.getResults()).hasSize(2); assertThat(response.getResults().get(0).getIndex()).isEqualTo(0); diff --git a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaEmbeddingRequestTests.java b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaEmbeddingRequestTests.java index f5f8479da..cff91717b 100644 --- a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaEmbeddingRequestTests.java +++ b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaEmbeddingRequestTests.java @@ -19,22 +19,23 @@ import org.junit.jupiter.api.Test; import org.springframework.ai.ollama.api.OllamaApi; import org.springframework.ai.ollama.api.OllamaOptions; -import static org.assertj.core.api.Assertions.assertThat; - import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; + /** * @author Christian Tzolov + * @author Thomas Vitale */ public class OllamaEmbeddingRequestTests { - OllamaEmbeddingModel chatModel = new OllamaEmbeddingModel(new OllamaApi(), + OllamaEmbeddingModel embeddingModel = new OllamaEmbeddingModel(new OllamaApi(), new OllamaOptions().withModel("DEFAULT_MODEL").withMainGPU(11).withUseMMap(true).withNumGPU(1)); @Test public void ollamaEmbeddingRequestDefaultOptions() { - var request = chatModel.ollamaEmbeddingRequest(List.of("Hello"), null); + var request = embeddingModel.ollamaEmbeddingRequest(List.of("Hello"), null); assertThat(request.model()).isEqualTo("DEFAULT_MODEL"); assertThat(request.options().get("num_gpu")).isEqualTo(1); @@ -52,7 +53,7 @@ public class OllamaEmbeddingRequestTests { .withUseMMap(true)// .withNumGPU(2); - var request = chatModel.ollamaEmbeddingRequest(List.of("Hello"), promptOptions); + var request = embeddingModel.ollamaEmbeddingRequest(List.of("Hello"), promptOptions); assertThat(request.model()).isEqualTo("PROMPT_MODEL"); assertThat(request.options().get("num_gpu")).isEqualTo(2); diff --git a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaImage.java b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaImage.java index 76d1a65ae..ef7b67250 100644 --- a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaImage.java +++ b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaImage.java @@ -22,6 +22,6 @@ import org.testcontainers.utility.DockerImageName; */ public class OllamaImage { - public static final DockerImageName DEFAULT_IMAGE = DockerImageName.parse("ollama/ollama:0.2.8"); + public static final DockerImageName DEFAULT_IMAGE = DockerImageName.parse("ollama/ollama:0.3.6"); } diff --git a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/api/OllamaApiIT.java b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/api/OllamaApiIT.java index f7259a6d2..e7a336a42 100644 --- a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/api/OllamaApiIT.java +++ b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/api/OllamaApiIT.java @@ -15,18 +15,12 @@ */ package org.springframework.ai.ollama.api; -import static org.assertj.core.api.Assertions.assertThat; - -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.springframework.ai.ollama.OllamaImage; +import org.junit.jupiter.api.condition.DisabledIf; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.ollama.BaseOllamaIT; import org.springframework.ai.ollama.api.OllamaApi.ChatRequest; import org.springframework.ai.ollama.api.OllamaApi.ChatResponse; import org.springframework.ai.ollama.api.OllamaApi.EmbeddingsRequest; @@ -35,26 +29,28 @@ 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.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.ollama.OllamaContainer; +import reactor.core.publisher.Flux; -import reactor.core.publisher.Flux;; +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +; /** * @author Christian Tzolov * @author Thomas Vitale */ -@Disabled("For manual smoke testing only.") @Testcontainers -public class OllamaApiIT { +@DisabledIf("isDisabled") +public class OllamaApiIT extends BaseOllamaIT { private static final String MODEL = OllamaModel.ORCA_MINI.getName(); - private static final Log logger = LogFactory.getLog(OllamaApiIT.class); - - @Container - static OllamaContainer ollamaContainer = new OllamaContainer(OllamaImage.DEFAULT_IMAGE); + private static final Logger logger = LoggerFactory.getLogger(OllamaApiIT.class); static OllamaApi ollamaApi; diff --git a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/api/OllamaModelOptionsTests.java b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/api/OllamaModelOptionsTests.java index e460ffb4c..fd5b6a009 100644 --- a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/api/OllamaModelOptionsTests.java +++ b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/api/OllamaModelOptionsTests.java @@ -15,10 +15,10 @@ */ package org.springframework.ai.ollama.api; -import java.util.List; - import org.junit.jupiter.api.Test; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; /** diff --git a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/api/tool/MockWeatherService.java b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/api/tool/MockWeatherService.java index 36b04d97f..64cb56fd6 100644 --- a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/api/tool/MockWeatherService.java +++ b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/api/tool/MockWeatherService.java @@ -15,14 +15,14 @@ */ package org.springframework.ai.ollama.api.tool; -import java.util.function.Function; - import com.fasterxml.jackson.annotation.JsonClassDescription; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import java.util.function.Function; + /** * @author Christian Tzolov */ diff --git a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/api/tool/OllamaApiToolFunctionCallIT.java b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/api/tool/OllamaApiToolFunctionCallIT.java index 8c4a682e8..9355784ff 100644 --- a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/api/tool/OllamaApiToolFunctionCallIT.java +++ b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/api/tool/OllamaApiToolFunctionCallIT.java @@ -16,36 +16,34 @@ package org.springframework.ai.ollama.api.tool; -import static org.assertj.core.api.Assertions.assertThat; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIf; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.model.ModelOptionsUtils; -import org.springframework.ai.ollama.OllamaImage; +import org.springframework.ai.ollama.BaseOllamaIT; import org.springframework.ai.ollama.api.OllamaApi; import org.springframework.ai.ollama.api.OllamaApi.ChatResponse; import org.springframework.ai.ollama.api.OllamaApi.Message; import org.springframework.ai.ollama.api.OllamaApi.Message.Role; import org.springframework.ai.ollama.api.OllamaApi.Message.ToolCall; import org.springframework.ai.ollama.api.OllamaModel; -import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.ollama.OllamaContainer; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; /** * @author Christian Tzolov */ -@Disabled("For manual smoke testing only.") @Testcontainers -public class OllamaApiToolFunctionCallIT { +@DisabledIf("isDisabled") +public class OllamaApiToolFunctionCallIT extends BaseOllamaIT { private static final String MODEL = OllamaModel.MISTRAL.getName(); @@ -53,9 +51,6 @@ public class OllamaApiToolFunctionCallIT { MockWeatherService weatherService = new MockWeatherService(); - @Container - static OllamaContainer ollamaContainer = new OllamaContainer(OllamaImage.DEFAULT_IMAGE); - static String baseUrl = "http://localhost:11434"; @BeforeAll diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OllamaWithOpenAiChatModelIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OllamaWithOpenAiChatModelIT.java index c43b0364f..ea3f60375 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OllamaWithOpenAiChatModelIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OllamaWithOpenAiChatModelIT.java @@ -75,7 +75,7 @@ class OllamaWithOpenAiChatModelIT { private static final String DEFAULT_OLLAMA_MODEL = "mistral"; @Container - static OllamaContainer ollamaContainer = new OllamaContainer("ollama/ollama:0.2.8"); + static OllamaContainer ollamaContainer = new OllamaContainer("ollama/ollama:0.3.6"); static String baseUrl = "http://localhost:11434"; diff --git a/spring-ai-core/src/main/java/org/springframework/ai/converter/BeanOutputConverter.java b/spring-ai-core/src/main/java/org/springframework/ai/converter/BeanOutputConverter.java index f8631e3c5..c3736801a 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/converter/BeanOutputConverter.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/converter/BeanOutputConverter.java @@ -168,8 +168,25 @@ public class BeanOutputConverter implements StructuredOutputConverter { */ public T convert(@NonNull String text) { try { - if (text.startsWith("```json") && text.endsWith("```")) { - text = text.substring(7, text.length() - 3); + // Remove leading and trailing whitespace + text = text.trim(); + + // Check for and remove triple backticks and "json" identifier + if (text.startsWith("```") && text.endsWith("```")) { + // Remove the first line if it contains "```json" + String[] lines = text.split("\n", 2); + if (lines[0].trim().equalsIgnoreCase("```json")) { + text = lines.length > 1 ? lines[1] : ""; + } + else { + text = text.substring(3); // Remove leading ``` + } + + // Remove trailing ``` + text = text.substring(0, text.length() - 3); + + // Trim again to remove any potential whitespace + text = text.trim(); } return (T) this.objectMapper.readValue(text, this.typeRef); } diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaAutoConfiguration.java index a4a7f14d3..639950a74 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaAutoConfiguration.java @@ -17,11 +17,15 @@ package org.springframework.ai.autoconfigure.ollama; import java.util.List; +import io.micrometer.observation.ObservationRegistry; +import org.springframework.ai.chat.observation.ChatModelObservationConvention; +import org.springframework.ai.embedding.observation.EmbeddingModelObservationConvention; import org.springframework.ai.model.function.FunctionCallback; import org.springframework.ai.model.function.FunctionCallbackContext; import org.springframework.ai.ollama.OllamaChatModel; import org.springframework.ai.ollama.OllamaEmbeddingModel; import org.springframework.ai.ollama.api.OllamaApi; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.ImportAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; @@ -39,6 +43,7 @@ import org.springframework.web.client.RestClient; * * @author Christian Tzolov * @author EddĂș MelĂ©ndez + * @author Thomas Vitale * @since 0.8.0 */ @AutoConfiguration(after = RestClientAutoConfiguration.class) @@ -65,18 +70,30 @@ public class OllamaAutoConfiguration { @ConditionalOnProperty(prefix = OllamaChatProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", matchIfMissing = true) public OllamaChatModel ollamaChatModel(OllamaApi ollamaApi, OllamaChatProperties properties, - List toolFunctionCallbacks, FunctionCallbackContext functionCallbackContext) { + List toolFunctionCallbacks, FunctionCallbackContext functionCallbackContext, + ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + var chatModel = new OllamaChatModel(ollamaApi, properties.getOptions(), functionCallbackContext, + toolFunctionCallbacks, observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)); - return new OllamaChatModel(ollamaApi, properties.getOptions(), functionCallbackContext, toolFunctionCallbacks); + observationConvention.ifAvailable(chatModel::setObservationConvention); + + return chatModel; } @Bean @ConditionalOnMissingBean @ConditionalOnProperty(prefix = OllamaEmbeddingProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", matchIfMissing = true) - public OllamaEmbeddingModel ollamaEmbeddingModel(OllamaApi ollamaApi, OllamaEmbeddingProperties properties) { + public OllamaEmbeddingModel ollamaEmbeddingModel(OllamaApi ollamaApi, OllamaEmbeddingProperties properties, + ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + var embeddingModel = new OllamaEmbeddingModel(ollamaApi, properties.getOptions(), + observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)); - return new OllamaEmbeddingModel(ollamaApi, properties.getOptions()); + observationConvention.ifAvailable(embeddingModel::setObservationConvention); + + return embeddingModel; } static class PropertiesOllamaConnectionDetails implements OllamaConnectionDetails { diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaChatAutoConfigurationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaChatAutoConfigurationIT.java index 03e3a7145..2497001d9 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaChatAutoConfigurationIT.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaChatAutoConfigurationIT.java @@ -60,11 +60,11 @@ public class OllamaChatAutoConfigurationIT { private static final Log logger = LogFactory.getLog(OllamaChatAutoConfigurationIT.class); - private static String MODEL_NAME = "mistral"; + private static final String MODEL_NAME = "mistral"; private static final String OLLAMA_WITH_MODEL = "%s-%s".formatted(MODEL_NAME, OllamaImage.IMAGE); - private static final OllamaContainer ollamaContainer; + private static OllamaContainer ollamaContainer; static { ollamaContainer = new OllamaContainer(OllamaDockerImageName.image()); diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaEmbeddingAutoConfigurationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaEmbeddingAutoConfigurationIT.java index 0599e0da6..fd6106ec2 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaEmbeddingAutoConfigurationIT.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaEmbeddingAutoConfigurationIT.java @@ -18,11 +18,11 @@ 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; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; @@ -37,20 +37,21 @@ import static org.assertj.core.api.Assertions.assertThat; /** * @author Christian Tzolov + * @author Thomas Vitale * @since 0.8.0 */ @Disabled("For manual smoke testing only.") @Testcontainers public class OllamaEmbeddingAutoConfigurationIT { - private static final Log logger = LogFactory.getLog(OllamaEmbeddingAutoConfigurationIT.class); + private static final Logger logger = LoggerFactory.getLogger(OllamaEmbeddingAutoConfigurationIT.class); - private static String MODEL_NAME = "orca-mini"; + private static final String MODEL_NAME = "orca-mini"; @Container static OllamaContainer ollamaContainer = new OllamaContainer(OllamaImage.IMAGE); - static String baseUrl; + static String baseUrl = "http://localhost:11434"; @BeforeAll public static void beforeAll() throws IOException, InterruptedException { diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaImage.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaImage.java index 16358f69e..087dcff9e 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaImage.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/ollama/OllamaImage.java @@ -17,6 +17,6 @@ package org.springframework.ai.autoconfigure.ollama; public class OllamaImage { - public static final String IMAGE = "ollama/ollama:0.2.8"; + public static final String IMAGE = "ollama/ollama:0.3.6"; } diff --git a/spring-ai-spring-boot-testcontainers/src/test/java/org/springframework/ai/testcontainers/service/connection/ollama/OllamaContainerConnectionDetailsFactoryTest.java b/spring-ai-spring-boot-testcontainers/src/test/java/org/springframework/ai/testcontainers/service/connection/ollama/OllamaContainerConnectionDetailsFactoryTest.java index 7dc64bf16..df2a12ae1 100644 --- a/spring-ai-spring-boot-testcontainers/src/test/java/org/springframework/ai/testcontainers/service/connection/ollama/OllamaContainerConnectionDetailsFactoryTest.java +++ b/spring-ai-spring-boot-testcontainers/src/test/java/org/springframework/ai/testcontainers/service/connection/ollama/OllamaContainerConnectionDetailsFactoryTest.java @@ -53,7 +53,7 @@ class OllamaContainerConnectionDetailsFactoryTest { @Container @ServiceConnection - static OllamaContainer ollama = new OllamaContainer("ollama/ollama:0.3.2"); + static OllamaContainer ollama = new OllamaContainer("ollama/ollama:0.3.6"); @Autowired private OllamaEmbeddingModel embeddingModel;