diff --git a/models/spring-ai-openai/pom.xml b/models/spring-ai-openai/pom.xml index 60bcffd32..cfb45f586 100644 --- a/models/spring-ai-openai/pom.xml +++ b/models/spring-ai-openai/pom.xml @@ -88,6 +88,12 @@ test + + io.micrometer + micrometer-observation-test + test + + org.testcontainers qdrant diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.java index cf5b26959..3b710c696 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.java @@ -15,14 +15,7 @@ */ package org.springframework.ai.openai; -import java.util.ArrayList; -import java.util.Base64; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; - +import io.micrometer.observation.ObservationRegistry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.chat.messages.AssistantMessage; @@ -33,16 +26,16 @@ import org.springframework.ai.chat.metadata.ChatGenerationMetadata; import org.springframework.ai.chat.metadata.ChatResponseMetadata; import org.springframework.ai.chat.metadata.EmptyUsage; import org.springframework.ai.chat.metadata.RateLimit; -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.StreamingChatModel; +import org.springframework.ai.chat.model.*; +import org.springframework.ai.chat.observation.*; import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.ai.model.function.FunctionCallback; import org.springframework.ai.model.function.FunctionCallbackContext; +import org.springframework.ai.observation.AiOperationMetadata; +import org.springframework.ai.observation.conventions.AiOperationType; +import org.springframework.ai.observation.conventions.AiProvider; import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.ai.openai.api.OpenAiApi.ChatCompletion; import org.springframework.ai.openai.api.OpenAiApi.ChatCompletion.Choice; @@ -59,10 +52,13 @@ import org.springframework.retry.support.RetryTemplate; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.MimeType; - +import org.springframework.util.StringUtils; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + /** * {@link ChatModel} and {@link StreamingChatModel} implementation for {@literal OpenAI} * backed by {@link OpenAiApi}. @@ -86,6 +82,8 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode private static final Logger logger = LoggerFactory.getLogger(OpenAiChatModel.class); + private static final ChatModelObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultChatModelObservationConvention(); + /** * The default options used for the chat completion requests. */ @@ -101,6 +99,16 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode */ private final OpenAiApi openAiApi; + /** + * Observation registry used for instrumentation. + */ + private final ObservationRegistry observationRegistry; + + /** + * Conventions to use for generating observations. + */ + private ChatModelObservationConvention observationConvention = DEFAULT_OBSERVATION_CONVENTION; + /** * Creates an instance of the OpenAiChatModel. * @param openAiApi The OpenAiApi instance to be used for interacting with the OpenAI @@ -147,6 +155,23 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode public OpenAiChatModel(OpenAiApi openAiApi, OpenAiChatOptions options, FunctionCallbackContext functionCallbackContext, List toolFunctionCallbacks, RetryTemplate retryTemplate) { + this(openAiApi, options, functionCallbackContext, toolFunctionCallbacks, retryTemplate, + ObservationRegistry.NOOP); + } + + /** + * Initializes a new instance of the OpenAiChatModel. + * @param openAiApi The OpenAiApi instance to be used for interacting with the OpenAI + * Chat API. + * @param options The OpenAiChatOptions to configure the chat model. + * @param functionCallbackContext The function callback context. + * @param toolFunctionCallbacks The tool function callbacks. + * @param retryTemplate The retry template. + * @param observationRegistry The ObservationRegistry used for instrumentation. + */ + public OpenAiChatModel(OpenAiApi openAiApi, OpenAiChatOptions options, + FunctionCallbackContext functionCallbackContext, List toolFunctionCallbacks, + RetryTemplate retryTemplate, ObservationRegistry observationRegistry) { super(functionCallbackContext, options, toolFunctionCallbacks); Assert.notNull(openAiApi, "OpenAiApi must not be null"); @@ -154,10 +179,12 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode Assert.notNull(retryTemplate, "RetryTemplate must not be null"); Assert.isTrue(CollectionUtils.isEmpty(options.getFunctionCallbacks()), "The default function callbacks must be set via the toolFunctionCallbacks constructor parameter"); + Assert.notNull(observationRegistry, "ObservationRegistry must not be null"); this.openAiApi = openAiApi; this.defaultOptions = options; this.retryTemplate = retryTemplate; + this.observationRegistry = observationRegistry; } @Override @@ -165,47 +192,64 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode ChatCompletionRequest request = createRequest(prompt, false); - ResponseEntity completionEntity = this.retryTemplate - .execute(ctx -> this.openAiApi.chatCompletionEntity(request)); + ChatModelObservationContext observationContext = ChatModelObservationContext.builder() + .prompt(prompt) + .operationMetadata(buildOperationMetadata()) + .requestOptions(buildRequestOptions(request)) + .build(); - var chatCompletion = completionEntity.getBody(); + ChatResponse response = ChatModelObservationDocumentation.CHAT_MODEL_OPERATION + .observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, + this.observationRegistry) + .observe(() -> { - if (chatCompletion == null) { - logger.warn("No chat completion returned for prompt: {}", prompt); - return new ChatResponse(List.of()); - } + ResponseEntity completionEntity = this.retryTemplate + .execute(ctx -> this.openAiApi.chatCompletionEntity(request)); - List choices = chatCompletion.choices(); - if (choices == null) { - logger.warn("No choices returned for prompt: {}", prompt); - return new ChatResponse(List.of()); - } + var chatCompletion = completionEntity.getBody(); - List generations = choices.stream().map(choice -> { + if (chatCompletion == null) { + logger.warn("No chat completion returned for prompt: {}", prompt); + return new ChatResponse(List.of()); + } + + List choices = chatCompletion.choices(); + if (choices == null) { + logger.warn("No choices returned for prompt: {}", prompt); + return new ChatResponse(List.of()); + } + + List generations = choices.stream().map(choice -> { // @formatter:off - Map metadata = Map.of( - "id", chatCompletion.id() != null ? chatCompletion.id() : "", - "role", choice.message().role() != null ? choice.message().role().name() : "", - "index", choice.index(), - "finishReason", choice.finishReason() != null ? choice.finishReason().name() : ""); - // @formatter:on - return buildGeneration(choice, metadata); - }).toList(); + Map metadata = Map.of( + "id", chatCompletion.id() != null ? chatCompletion.id() : "", + "role", choice.message().role() != null ? choice.message().role().name() : "", + "index", choice.index(), + "finishReason", choice.finishReason() != null ? choice.finishReason().name() : ""); + // @formatter:on + return buildGeneration(choice, metadata); + }).toList(); - // Non function calling. - RateLimit rateLimit = OpenAiResponseHeaderExtractor.extractAiResponseHeaders(completionEntity); + // Non function calling. + RateLimit rateLimit = OpenAiResponseHeaderExtractor.extractAiResponseHeaders(completionEntity); - ChatResponse chatResponse = new ChatResponse(generations, from(completionEntity.getBody(), rateLimit)); + ChatResponse chatResponse = new ChatResponse(generations, from(completionEntity.getBody(), rateLimit)); - if (isToolCall(chatResponse, Set.of(OpenAiApi.ChatCompletionFinishReason.TOOL_CALLS.name(), + observationContext.setResponse(chatResponse); + + return chatResponse; + + }); + + if (response != null && isToolCall(response, Set.of(OpenAiApi.ChatCompletionFinishReason.TOOL_CALLS.name(), OpenAiApi.ChatCompletionFinishReason.STOP.name()))) { - var toolCallConversation = handleToolCalls(prompt, chatResponse); + 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; } @Override @@ -434,6 +478,25 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode }).toList(); } + private AiOperationMetadata buildOperationMetadata() { + return AiOperationMetadata.builder() + .operationType(AiOperationType.CHAT.value()) + .provider(AiProvider.OPENAI.value()) + .build(); + } + + private ChatModelRequestOptions buildRequestOptions(OpenAiApi.ChatCompletionRequest request) { + return ChatModelRequestOptions.builder() + .model(StringUtils.hasText(request.model()) ? request.model() : "unknown") + .frequencyPenalty(request.frequencyPenalty()) + .maxTokens(request.maxTokens()) + .presencePenalty(request.presencePenalty()) + .stopSequences(request.stop()) + .temperature(request.temperature()) + .topP(request.topP()) + .build(); + } + @Override public ChatOptions getDefaultOptions() { return OpenAiChatOptions.fromOptions(this.defaultOptions); @@ -444,4 +507,13 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode return "OpenAiChatModel [defaultOptions=" + 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; + } + } diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiEmbeddingModel.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiEmbeddingModel.java index 7a160d01b..5ec103d5d 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiEmbeddingModel.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiEmbeddingModel.java @@ -15,11 +15,9 @@ */ package org.springframework.ai.openai; -import java.util.List; - +import io.micrometer.observation.ObservationRegistry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; - import org.springframework.ai.document.Document; import org.springframework.ai.document.MetadataMode; import org.springframework.ai.embedding.AbstractEmbeddingModel; @@ -28,13 +26,24 @@ 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.observation.DefaultEmbeddingModelObservationConvention; +import org.springframework.ai.embedding.observation.EmbeddingModelObservationConvention; +import org.springframework.ai.embedding.observation.EmbeddingModelObservationDocumentation; +import org.springframework.ai.embedding.observation.EmbeddingModelObservationContext; +import org.springframework.ai.embedding.observation.EmbeddingModelRequestOptions; import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.observation.AiOperationMetadata; +import org.springframework.ai.observation.conventions.AiOperationType; +import org.springframework.ai.observation.conventions.AiProvider; import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.ai.openai.api.OpenAiApi.EmbeddingList; import org.springframework.ai.openai.metadata.OpenAiUsage; import org.springframework.ai.retry.RetryUtils; import org.springframework.retry.support.RetryTemplate; import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +import java.util.List; /** * Open AI Embedding Model implementation. @@ -46,6 +55,8 @@ public class OpenAiEmbeddingModel extends AbstractEmbeddingModel { private static final Logger logger = LoggerFactory.getLogger(OpenAiEmbeddingModel.class); + private static final EmbeddingModelObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultEmbeddingModelObservationConvention(); + private final OpenAiEmbeddingOptions defaultOptions; private final RetryTemplate retryTemplate; @@ -54,6 +65,16 @@ public class OpenAiEmbeddingModel extends AbstractEmbeddingModel { private final MetadataMode metadataMode; + /** + * Observation registry used for instrumentation. + */ + private final ObservationRegistry observationRegistry; + + /** + * Conventions to use for generating observations. + */ + private EmbeddingModelObservationConvention observationConvention = DEFAULT_OBSERVATION_CONVENTION; + /** * Constructor for the OpenAiEmbeddingModel class. * @param openAiApi The OpenAiApi instance to use for making API requests. @@ -92,15 +113,30 @@ public class OpenAiEmbeddingModel extends AbstractEmbeddingModel { */ public OpenAiEmbeddingModel(OpenAiApi openAiApi, MetadataMode metadataMode, OpenAiEmbeddingOptions options, RetryTemplate retryTemplate) { + this(openAiApi, metadataMode, options, retryTemplate, ObservationRegistry.NOOP); + } + + /** + * Initializes a new instance of the OpenAiEmbeddingModel class. + * @param openAiApi - The OpenAiApi instance to use for making API requests. + * @param metadataMode - The mode for generating metadata. + * @param options - The options for OpenAI embedding. + * @param retryTemplate - The RetryTemplate for retrying failed API requests. + * @param observationRegistry - The ObservationRegistry used for instrumentation. + */ + public OpenAiEmbeddingModel(OpenAiApi openAiApi, MetadataMode metadataMode, OpenAiEmbeddingOptions options, + RetryTemplate retryTemplate, ObservationRegistry observationRegistry) { Assert.notNull(openAiApi, "OpenAiService must not be null"); Assert.notNull(metadataMode, "metadataMode must not be null"); Assert.notNull(options, "options must not be null"); Assert.notNull(retryTemplate, "retryTemplate must not be null"); + Assert.notNull(observationRegistry, "observationRegistry must not be null"); this.openAiApi = openAiApi; this.metadataMode = metadataMode; this.defaultOptions = options; this.retryTemplate = retryTemplate; + this.observationRegistry = observationRegistry; } @Override @@ -111,26 +147,40 @@ public class OpenAiEmbeddingModel extends AbstractEmbeddingModel { @Override public EmbeddingResponse call(EmbeddingRequest request) { - org.springframework.ai.openai.api.OpenAiApi.EmbeddingRequest> apiRequest = createRequest(request); - EmbeddingList apiEmbeddingResponse = this.retryTemplate - .execute(ctx -> this.openAiApi.embeddings(apiRequest).getBody()); + var observationContext = EmbeddingModelObservationContext.builder() + .embeddingRequest(request) + .operationMetadata(buildOperationMetadata()) + .requestOptions(buildRequestOptions(apiRequest)) + .build(); - if (apiEmbeddingResponse == null) { - logger.warn("No embeddings returned for request: {}", request); - return new EmbeddingResponse(List.of()); - } + return EmbeddingModelObservationDocumentation.EMBEDDING_MODEL_OPERATION + .observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, + this.observationRegistry) + .observe(() -> { + EmbeddingList apiEmbeddingResponse = this.retryTemplate + .execute(ctx -> this.openAiApi.embeddings(apiRequest).getBody()); - var metadata = new EmbeddingResponseMetadata(apiEmbeddingResponse.model(), - OpenAiUsage.from(apiEmbeddingResponse.usage())); + if (apiEmbeddingResponse == null) { + logger.warn("No embeddings returned for request: {}", request); + return new EmbeddingResponse(List.of()); + } - List embeddings = apiEmbeddingResponse.data() - .stream() - .map(e -> new Embedding(e.embedding(), e.index())) - .toList(); + var metadata = new EmbeddingResponseMetadata(apiEmbeddingResponse.model(), + OpenAiUsage.from(apiEmbeddingResponse.usage())); - return new EmbeddingResponse(embeddings, metadata); + List embeddings = apiEmbeddingResponse.data() + .stream() + .map(e -> new Embedding(e.embedding(), e.index())) + .toList(); + + EmbeddingResponse embeddingResponse = new EmbeddingResponse(embeddings, metadata); + + observationContext.setResponse(embeddingResponse); + + return embeddingResponse; + }); } @SuppressWarnings("unchecked") @@ -150,4 +200,28 @@ public class OpenAiEmbeddingModel extends AbstractEmbeddingModel { return apiRequest; } + private AiOperationMetadata buildOperationMetadata() { + return AiOperationMetadata.builder() + .operationType(AiOperationType.EMBEDDING.value()) + .provider(AiProvider.OPENAI.value()) + .build(); + } + + private EmbeddingModelRequestOptions buildRequestOptions(OpenAiApi.EmbeddingRequest> request) { + return EmbeddingModelRequestOptions.builder() + .model(StringUtils.hasText(request.model()) ? request.model() : "unknown") + .dimensions(request.dimensions()) + .encodingFormat(request.encodingFormat()) + .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; + } + } diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiImageModel.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiImageModel.java index d9cd72374..43b88cf07 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiImageModel.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiImageModel.java @@ -15,6 +15,7 @@ */ package org.springframework.ai.openai; +import io.micrometer.observation.ObservationRegistry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.image.Image; @@ -24,13 +25,22 @@ import org.springframework.ai.image.ImageOptions; import org.springframework.ai.image.ImagePrompt; import org.springframework.ai.image.ImageResponse; import org.springframework.ai.image.ImageResponseMetadata; +import org.springframework.ai.image.observation.DefaultImageModelObservationConvention; +import org.springframework.ai.image.observation.ImageModelObservationConvention; +import org.springframework.ai.image.observation.ImageModelObservationContext; +import org.springframework.ai.image.observation.ImageModelObservationDocumentation; +import org.springframework.ai.image.observation.ImageModelRequestOptions; import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.observation.AiOperationMetadata; +import org.springframework.ai.observation.conventions.AiOperationType; +import org.springframework.ai.observation.conventions.AiProvider; import org.springframework.ai.openai.api.OpenAiImageApi; import org.springframework.ai.openai.metadata.OpenAiImageGenerationMetadata; import org.springframework.ai.retry.RetryUtils; import org.springframework.http.ResponseEntity; import org.springframework.retry.support.RetryTemplate; import org.springframework.util.Assert; +import org.springframework.util.StringUtils; import java.util.List; @@ -48,10 +58,12 @@ public class OpenAiImageModel implements ImageModel { private final static Logger logger = LoggerFactory.getLogger(OpenAiImageModel.class); + private static final ImageModelObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultImageModelObservationConvention(); + /** * The default options used for the image completion requests. */ - private OpenAiImageOptions defaultOptions; + private final OpenAiImageOptions defaultOptions; /** * The retry template used to retry the OpenAI Image API calls. @@ -63,6 +75,16 @@ public class OpenAiImageModel implements ImageModel { */ private final OpenAiImageApi openAiImageApi; + /** + * Observation registry used for instrumentation. + */ + private final ObservationRegistry observationRegistry; + + /** + * Conventions to use for generating observations. + */ + private ImageModelObservationConvention observationConvention = DEFAULT_OBSERVATION_CONVENTION; + /** * Creates an instance of the OpenAiImageModel. * @param openAiImageApi The OpenAiImageApi instance to be used for interacting with @@ -81,23 +103,52 @@ public class OpenAiImageModel implements ImageModel { * @param retryTemplate The retry template. */ public OpenAiImageModel(OpenAiImageApi openAiImageApi, OpenAiImageOptions options, RetryTemplate retryTemplate) { + this(openAiImageApi, options, retryTemplate, ObservationRegistry.NOOP); + } + + /** + * Initializes a new instance of the OpenAiImageModel. + * @param openAiImageApi The OpenAiImageApi instance to be used for interacting with + * the OpenAI Image API. + * @param options The OpenAiImageOptions to configure the image model. + * @param retryTemplate The retry template. + * @param observationRegistry The ObservationRegistry used for instrumentation. + */ + public OpenAiImageModel(OpenAiImageApi openAiImageApi, OpenAiImageOptions options, RetryTemplate retryTemplate, + ObservationRegistry observationRegistry) { Assert.notNull(openAiImageApi, "OpenAiImageApi must not be null"); Assert.notNull(options, "options must not be null"); Assert.notNull(retryTemplate, "retryTemplate must not be null"); + Assert.notNull(observationRegistry, "observationRegistry must not be null"); this.openAiImageApi = openAiImageApi; this.defaultOptions = options; this.retryTemplate = retryTemplate; + this.observationRegistry = observationRegistry; } @Override public ImageResponse call(ImagePrompt imagePrompt) { - OpenAiImageApi.OpenAiImageRequest imageRequest = createRequest(imagePrompt); - ResponseEntity imageResponseEntity = this.retryTemplate - .execute(ctx -> this.openAiImageApi.createImage(imageRequest)); + var observationContext = ImageModelObservationContext.builder() + .imagePrompt(imagePrompt) + .operationMetadata(buildOperationMetadata()) + .requestOptions(buildRequestOptions(imageRequest)) + .build(); - return convertResponse(imageResponseEntity, imageRequest); + return ImageModelObservationDocumentation.IMAGE_MODEL_OPERATION + .observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, + this.observationRegistry) + .observe(() -> { + ResponseEntity imageResponseEntity = this.retryTemplate + .execute(ctx -> this.openAiImageApi.createImage(imageRequest)); + + ImageResponse imageResponse = convertResponse(imageResponseEntity, imageRequest); + + observationContext.setResponse(imageResponse); + + return imageResponse; + }); } private OpenAiImageApi.OpenAiImageRequest createRequest(ImagePrompt imagePrompt) { @@ -177,4 +228,31 @@ public class OpenAiImageModel implements ImageModel { return openAiImageOptionsBuilder.build(); } + private AiOperationMetadata buildOperationMetadata() { + return AiOperationMetadata.builder() + .operationType(AiOperationType.IMAGE.value()) + .provider(AiProvider.OPENAI.value()) + .build(); + } + + private ImageModelRequestOptions buildRequestOptions(OpenAiImageApi.OpenAiImageRequest request) { + return ImageModelRequestOptions.builder() + .model(StringUtils.hasText(request.model()) ? request.model() : "unknown") + .n(request.n()) + .width(request.size() != null ? Integer.parseInt(request.size().split("x")[0]) : null) + .height(request.size() != null ? Integer.parseInt(request.size().split("x")[1]) : null) + .responseFormat(request.responseFormat()) + .style(request.style()) + .build(); + } + + /** + * Use the provided convention for reporting observation data + * @param observationConvention The provided convention + */ + public void setObservationConvention(ImageModelObservationConvention observationConvention) { + Assert.notNull(observationConvention, "observationConvention cannot be null"); + this.observationConvention = observationConvention; + } + } diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiTestConfiguration.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiTestConfiguration.java index cbb88487f..3eab4ca13 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiTestConfiguration.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiTestConfiguration.java @@ -15,7 +15,6 @@ */ package org.springframework.ai.openai; -import org.springframework.ai.embedding.EmbeddingModel; import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.ai.openai.api.OpenAiAudioApi; import org.springframework.ai.openai.api.OpenAiImageApi; @@ -78,7 +77,7 @@ public class OpenAiTestConfiguration { } @Bean - public EmbeddingModel openAiEmbeddingModel(OpenAiApi api) { + public OpenAiEmbeddingModel openAiEmbeddingModel(OpenAiApi api) { return new OpenAiEmbeddingModel(api); } diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatModelObservationIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatModelObservationIT.java new file mode 100644 index 000000000..a11d8b6bc --- /dev/null +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatModelObservationIT.java @@ -0,0 +1,133 @@ +/* + * 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.openai.chat; + +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.EnabledIfEnvironmentVariable; +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.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.api.OpenAiApi; +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.retry.support.RetryTemplate; + +import java.util.List; + +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 OpenAiChatModel}. + * + * @author Thomas Vitale + */ +@SpringBootTest(classes = OpenAiChatModelObservationIT.Config.class) +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") +public class OpenAiChatModelObservationIT { + + @Autowired + TestObservationRegistry observationRegistry; + + @Autowired + OpenAiChatModel chatModel; + + @Test + void observationForEmbeddingOperation() { + var options = OpenAiChatOptions.builder() + .withModel(OpenAiApi.ChatModel.GPT_4_O_MINI.getValue()) + .withFrequencyPenalty(0f) + .withMaxTokens(2048) + .withPresencePenalty(0f) + .withStop(List.of("this-is-the-end")) + .withTemperature(0.7f) + .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(); + + TestObservationRegistryAssert.assertThat(observationRegistry) + .doesNotHaveAnyRemainingCurrentObservation() + .hasObservationWithNameEqualTo(DefaultChatModelObservationConvention.DEFAULT_NAME) + .that() + .hasContextualNameEqualTo("chat " + OpenAiApi.ChatModel.GPT_4_O_MINI.getValue()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_OPERATION_TYPE.asString(), + AiOperationType.CHAT.value()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_PROVIDER.asString(), AiProvider.OPENAI.value()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.REQUEST_MODEL.asString(), + OpenAiApi.ChatModel.GPT_4_O_MINI.getValue()) + .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(), KeyValue.NONE_VALUE) + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_TOP_P.asString(), "1.0") + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.RESPONSE_ID.asString(), responseMetadata.getId()) + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.RESPONSE_FINISH_REASON.asString(), + chatResponse.getResult().getMetadata().getFinishReason()) + .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 OpenAiApi openAiApi() { + return new OpenAiApi(System.getenv("OPENAI_API_KEY")); + } + + @Bean + public OpenAiChatModel openAiChatModel(OpenAiApi openAiApi, TestObservationRegistry observationRegistry) { + return new OpenAiChatModel(openAiApi, OpenAiChatOptions.builder().build(), new FunctionCallbackContext(), + List.of(), RetryTemplate.defaultInstance(), observationRegistry); + } + + } + +} diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/embedding/EmbeddingIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/embedding/EmbeddingIT.java index 465fb5e70..b15471c65 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/embedding/EmbeddingIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/embedding/EmbeddingIT.java @@ -17,10 +17,13 @@ package org.springframework.ai.openai.embedding; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.springframework.ai.embedding.EmbeddingRequest; import org.springframework.ai.embedding.EmbeddingResponse; import org.springframework.ai.openai.OpenAiEmbeddingModel; import org.springframework.ai.openai.OpenAiEmbeddingOptions; +import org.springframework.ai.openai.OpenAiTestConfiguration; +import org.springframework.ai.openai.testutils.AbstractIT; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -28,8 +31,9 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; -@SpringBootTest -class EmbeddingIT { +@SpringBootTest(classes = OpenAiTestConfiguration.class) +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") +class EmbeddingIT extends AbstractIT { @Autowired private OpenAiEmbeddingModel embeddingModel; diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/embedding/OpenAiEmbeddingModelObservationIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/embedding/OpenAiEmbeddingModelObservationIT.java new file mode 100644 index 000000000..3b4bc8070 --- /dev/null +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/embedding/OpenAiEmbeddingModelObservationIT.java @@ -0,0 +1,118 @@ +/* + * 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.openai.embedding; + +import io.micrometer.observation.tck.TestObservationRegistry; +import io.micrometer.observation.tck.TestObservationRegistryAssert; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.ai.document.MetadataMode; +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.openai.OpenAiEmbeddingModel; +import org.springframework.ai.openai.OpenAiEmbeddingOptions; +import org.springframework.ai.openai.api.OpenAiApi; +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.retry.support.RetryTemplate; + +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 OpenAiEmbeddingModel}. + * + * @author Thomas Vitale + */ +@SpringBootTest(classes = OpenAiEmbeddingModelObservationIT.Config.class) +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") +public class OpenAiEmbeddingModelObservationIT { + + @Autowired + TestObservationRegistry observationRegistry; + + @Autowired + OpenAiEmbeddingModel embeddingModel; + + @Test + void observationForEmbeddingOperation() { + var options = OpenAiEmbeddingOptions.builder() + .withModel(OpenAiApi.EmbeddingModel.TEXT_EMBEDDING_3_SMALL.getValue()) + .withDimensions(1536) + .withEncodingFormat("float") + .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 " + OpenAiApi.EmbeddingModel.TEXT_EMBEDDING_3_SMALL.getValue()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_OPERATION_TYPE.asString(), + AiOperationType.EMBEDDING.value()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_PROVIDER.asString(), AiProvider.OPENAI.value()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.REQUEST_MODEL.asString(), + OpenAiApi.EmbeddingModel.TEXT_EMBEDDING_3_SMALL.getValue()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.RESPONSE_MODEL.asString(), responseMetadata.getModel()) + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_EMBEDDING_DIMENSIONS.asString(), "1536") + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_EMBEDDING_ENCODING_FORMAT.asString(), "float") + .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 OpenAiApi openAiApi() { + return new OpenAiApi(System.getenv("OPENAI_API_KEY")); + } + + @Bean + public OpenAiEmbeddingModel openAiEmbeddingModel(OpenAiApi openAiApi, + TestObservationRegistry observationRegistry) { + return new OpenAiEmbeddingModel(openAiApi, MetadataMode.EMBED, OpenAiEmbeddingOptions.builder().build(), + RetryTemplate.defaultInstance(), observationRegistry); + } + + } + +} diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/image/OpenAiImageModelObservationIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/image/OpenAiImageModelObservationIT.java new file mode 100644 index 000000000..0a1d3087d --- /dev/null +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/image/OpenAiImageModelObservationIT.java @@ -0,0 +1,110 @@ +/* + * 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.openai.image; + +import io.micrometer.observation.tck.TestObservationRegistry; +import io.micrometer.observation.tck.TestObservationRegistryAssert; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.ai.image.ImagePrompt; +import org.springframework.ai.image.ImageResponse; +import org.springframework.ai.image.observation.DefaultImageModelObservationConvention; +import org.springframework.ai.observation.conventions.AiOperationType; +import org.springframework.ai.observation.conventions.AiProvider; +import org.springframework.ai.openai.OpenAiImageModel; +import org.springframework.ai.openai.OpenAiImageOptions; +import org.springframework.ai.openai.api.OpenAiImageApi; +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.retry.support.RetryTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.ai.image.observation.ImageModelObservationDocumentation.HighCardinalityKeyNames; +import static org.springframework.ai.image.observation.ImageModelObservationDocumentation.LowCardinalityKeyNames; + +/** + * Integration tests for observation instrumentation in {@link OpenAiImageModel}. + * + * @author Thomas Vitale + */ +@SpringBootTest(classes = OpenAiImageModelObservationIT.Config.class) +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") +public class OpenAiImageModelObservationIT { + + @Autowired + TestObservationRegistry observationRegistry; + + @Autowired + OpenAiImageModel imageModel; + + @Test + void observationForImageOperation() { + var options = OpenAiImageOptions.builder() + .withModel(OpenAiImageApi.ImageModel.DALL_E_3.getValue()) + .withHeight(1024) + .withWidth(1024) + .withResponseFormat("url") + .withStyle("natural") + .build(); + + var instructions = "Here comes the sun"; + + ImagePrompt imagePrompt = new ImagePrompt(instructions, options); + + ImageResponse imageResponse = imageModel.call(imagePrompt); + assertThat(imageResponse.getResults()).hasSize(1); + + TestObservationRegistryAssert.assertThat(observationRegistry) + .doesNotHaveAnyRemainingCurrentObservation() + .hasObservationWithNameEqualTo(DefaultImageModelObservationConvention.DEFAULT_NAME) + .that() + .hasContextualNameEqualTo("image " + OpenAiImageApi.ImageModel.DALL_E_3.getValue()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_OPERATION_TYPE.asString(), + AiOperationType.IMAGE.value()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_PROVIDER.asString(), AiProvider.OPENAI.value()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.REQUEST_MODEL.asString(), + OpenAiImageApi.ImageModel.DALL_E_3.getValue()) + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_IMAGE_SIZE.asString(), "1024x1024") + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_IMAGE_RESPONSE_FORMAT.asString(), "url") + .hasBeenStarted() + .hasBeenStopped(); + } + + @SpringBootConfiguration + static class Config { + + @Bean + public TestObservationRegistry observationRegistry() { + return TestObservationRegistry.create(); + } + + @Bean + public OpenAiImageApi openAiImageApi() { + return new OpenAiImageApi(System.getenv("OPENAI_API_KEY")); + } + + @Bean + public OpenAiImageModel openAiImageModel(OpenAiImageApi openAiImageApi, + TestObservationRegistry observationRegistry) { + return new OpenAiImageModel(openAiImageApi, OpenAiImageOptions.builder().build(), + RetryTemplate.defaultInstance(), observationRegistry); + } + + } + +} diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/testutils/AbstractIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/testutils/AbstractIT.java index 4c8516e82..ba3a25867 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/testutils/AbstractIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/testutils/AbstractIT.java @@ -29,6 +29,7 @@ import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.chat.prompt.PromptTemplate; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.embedding.EmbeddingModel; import org.springframework.ai.image.ImageModel; import org.springframework.ai.openai.OpenAiAudioSpeechModel; import org.springframework.ai.openai.OpenAiAudioTranscriptionModel; @@ -62,6 +63,9 @@ public abstract class AbstractIT { @Autowired protected ImageModel imageModel; + @Autowired + protected EmbeddingModel embeddingModel; + @Value("classpath:/prompts/eval/qa-evaluator-accurate-answer.st") protected Resource qaEvaluatorAccurateAnswerResource; diff --git a/spring-ai-core/pom.xml b/spring-ai-core/pom.xml index 8b87add70..9b600c4b4 100644 --- a/spring-ai-core/pom.xml +++ b/spring-ai-core/pom.xml @@ -79,6 +79,11 @@ spring-web + + io.micrometer + micrometer-core + + com.knuddels jtokkit diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationFilter.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationFilter.java new file mode 100644 index 000000000..6d70a80bb --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationFilter.java @@ -0,0 +1,62 @@ +/* + * 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.chat.observation; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationFilter; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +import java.util.StringJoiner; + +/** + * An {@link ObservationFilter} to include the chat completion content in the observation. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +public class ChatModelCompletionObservationFilter implements ObservationFilter { + + @Override + public Observation.Context map(Observation.Context context) { + if (!(context instanceof ChatModelObservationContext chatModelObservationContext)) { + return context; + } + + if (chatModelObservationContext.getResponse() == null + || chatModelObservationContext.getResponse().getResults() == null + || CollectionUtils.isEmpty(chatModelObservationContext.getResponse().getResults())) { + return chatModelObservationContext; + } + + StringJoiner completionChoicesJoiner = new StringJoiner(", ", "[", "]"); + chatModelObservationContext.getResponse() + .getResults() + .stream() + .filter(generation -> generation.getOutput() != null + && StringUtils.hasText(generation.getOutput().getContent())) + .forEach(generation -> completionChoicesJoiner.add("\"" + generation.getOutput().getContent() + "\"")); + + if (StringUtils.hasText(chatModelObservationContext.getResponse().getResult().getOutput().getContent())) { + chatModelObservationContext + .addHighCardinalityKeyValue(ChatModelObservationDocumentation.HighCardinalityKeyNames.COMPLETION + .withValue(completionChoicesJoiner.toString())); + } + + return chatModelObservationContext; + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelMeterObservationHandler.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelMeterObservationHandler.java new file mode 100644 index 000000000..1604e0451 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelMeterObservationHandler.java @@ -0,0 +1,50 @@ +/* + * 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.chat.observation; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationHandler; +import org.springframework.ai.model.observation.ModelUsageMetricsGenerator; + +/** + * Handler for generating metrics from chat model observations. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +public class ChatModelMeterObservationHandler implements ObservationHandler { + + private final MeterRegistry meterRegistry; + + public ChatModelMeterObservationHandler(MeterRegistry meterRegistry) { + this.meterRegistry = meterRegistry; + } + + @Override + public void onStop(ChatModelObservationContext context) { + if (context.getResponse() != null && context.getResponse().getMetadata() != null + && context.getResponse().getMetadata().getUsage() != null) { + ModelUsageMetricsGenerator.generate(context.getResponse().getMetadata().getUsage(), context, meterRegistry); + } + } + + @Override + public boolean supportsContext(Observation.Context context) { + return context instanceof ChatModelObservationContext; + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelObservationContext.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelObservationContext.java new file mode 100644 index 000000000..46a2368ce --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelObservationContext.java @@ -0,0 +1,81 @@ +/* + * 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.chat.observation; + +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.model.observation.ModelObservationContext; +import org.springframework.ai.observation.AiOperationMetadata; +import org.springframework.util.Assert; + +/** + * Context used to store metadata for chat model exchanges. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +public class ChatModelObservationContext extends ModelObservationContext { + + private final ChatModelRequestOptions requestOptions; + + ChatModelObservationContext(Prompt prompt, AiOperationMetadata operationMetadata, + ChatModelRequestOptions requestOptions) { + super(prompt, operationMetadata); + Assert.notNull(requestOptions, "requestOptions cannot be null"); + this.requestOptions = requestOptions; + } + + public ChatModelRequestOptions getRequestOptions() { + return this.requestOptions; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private Prompt prompt; + + private AiOperationMetadata operationMetadata; + + private ChatModelRequestOptions requestOptions; + + private Builder() { + } + + public Builder prompt(Prompt prompt) { + this.prompt = prompt; + return this; + } + + public Builder operationMetadata(AiOperationMetadata operationMetadata) { + this.operationMetadata = operationMetadata; + return this; + } + + public Builder requestOptions(ChatModelRequestOptions requestOptions) { + this.requestOptions = requestOptions; + return this; + } + + public ChatModelObservationContext build() { + return new ChatModelObservationContext(prompt, operationMetadata, requestOptions); + } + + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelObservationConvention.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelObservationConvention.java new file mode 100644 index 000000000..dc68b1876 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelObservationConvention.java @@ -0,0 +1,34 @@ +/* + * 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.chat.observation; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationConvention; + +/** + * Interface for an {@link ObservationConvention} for chat model exchanges. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +public interface ChatModelObservationConvention extends ObservationConvention { + + @Override + default boolean supportsContext(Observation.Context context) { + return context instanceof ChatModelObservationContext; + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelObservationDocumentation.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelObservationDocumentation.java new file mode 100644 index 000000000..7e4ef9ca0 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelObservationDocumentation.java @@ -0,0 +1,282 @@ +/* + * 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.chat.observation; + +import io.micrometer.common.docs.KeyName; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationConvention; +import io.micrometer.observation.docs.ObservationDocumentation; +import org.springframework.ai.observation.conventions.AiObservationAttributes; +import org.springframework.ai.observation.conventions.AiObservationEventNames; + +/** + * Documented conventions for chat model observations. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +public enum ChatModelObservationDocumentation implements ObservationDocumentation { + + CHAT_MODEL_OPERATION { + @Override + public Class> getDefaultConvention() { + return DefaultChatModelObservationConvention.class; + } + + @Override + public KeyName[] getLowCardinalityKeyNames() { + return LowCardinalityKeyNames.values(); + } + + @Override + public KeyName[] getHighCardinalityKeyNames() { + return HighCardinalityKeyNames.values(); + } + + @Override + public Observation.Event[] getEvents() { + return Events.values(); + } + }; + + /** + * Low-cardinality observation key names for chat model operations. + */ + public enum LowCardinalityKeyNames implements KeyName { + + /** + * The name of the operation being performed. + */ + AI_OPERATION_TYPE { + @Override + public String asString() { + return AiObservationAttributes.AI_OPERATION_TYPE.value(); + } + }, + + /** + * The model provider as identified by the client instrumentation. + */ + AI_PROVIDER { + @Override + public String asString() { + return AiObservationAttributes.AI_PROVIDER.value(); + } + }, + + /** + * The name of the model a request is being made to. + */ + REQUEST_MODEL { + @Override + public String asString() { + return AiObservationAttributes.REQUEST_MODEL.value(); + } + }, + + /** + * The name of the model that generated the response. + */ + RESPONSE_MODEL { + @Override + public String asString() { + return AiObservationAttributes.RESPONSE_MODEL.value(); + } + } + + } + + /** + * High-cardinality observation key names for chat model operations. + */ + public enum HighCardinalityKeyNames implements KeyName { + + /** + * The frequency penalty setting for the model request. + */ + REQUEST_FREQUENCY_PENALTY { + @Override + public String asString() { + return AiObservationAttributes.REQUEST_FREQUENCY_PENALTY.value(); + } + }, + + /** + * The maximum number of tokens the model generates for a request. + */ + REQUEST_MAX_TOKENS { + @Override + public String asString() { + return AiObservationAttributes.REQUEST_MAX_TOKENS.value(); + } + }, + + /** + * The presence penalty setting for the model request. + */ + REQUEST_PRESENCE_PENALTY { + @Override + public String asString() { + return AiObservationAttributes.REQUEST_PRESENCE_PENALTY.value(); + } + }, + + /** + * List of sequences that the model will use to stop generating further tokens. + */ + REQUEST_STOP_SEQUENCES { + @Override + public String asString() { + return AiObservationAttributes.REQUEST_STOP_SEQUENCES.value(); + } + }, + + /** + * The temperature setting for the model request. + */ + REQUEST_TEMPERATURE { + @Override + public String asString() { + return AiObservationAttributes.REQUEST_TEMPERATURE.value(); + } + }, + + /** + * The top_k sampling setting for the model request. + */ + REQUEST_TOP_K { + @Override + public String asString() { + return AiObservationAttributes.REQUEST_TOP_K.value(); + } + }, + + /** + * The top_p sampling setting for the model request. + */ + REQUEST_TOP_P { + @Override + public String asString() { + return AiObservationAttributes.REQUEST_TOP_P.value(); + } + }, + + // Response + + /** + * Final reason the model stopped generating tokens. + */ + RESPONSE_FINISH_REASON { + @Override + public String asString() { + return AiObservationAttributes.RESPONSE_FINISH_REASON.value(); + } + }, + + /** + * The unique identifier for the AI response. + */ + RESPONSE_ID { + @Override + public String asString() { + return AiObservationAttributes.RESPONSE_ID.value(); + } + }, + + // Usage + + /** + * The number of tokens used in the model input (prompt). + */ + USAGE_INPUT_TOKENS { + @Override + public String asString() { + return AiObservationAttributes.USAGE_INPUT_TOKENS.value(); + } + }, + + /** + * The number of tokens used in the model output (completion). + */ + USAGE_OUTPUT_TOKENS { + @Override + public String asString() { + return AiObservationAttributes.USAGE_OUTPUT_TOKENS.value(); + } + }, + + /** + * The total number of tokens used in the model exchange. + */ + USAGE_TOTAL_TOKENS { + @Override + public String asString() { + return AiObservationAttributes.USAGE_TOTAL_TOKENS.value(); + } + }, + + // Content + + /** + * The full prompt sent to the model. + */ + PROMPT { + @Override + public String asString() { + return AiObservationAttributes.PROMPT.value(); + } + }, + + /** + * The full response received from the model. + */ + COMPLETION { + @Override + public String asString() { + return AiObservationAttributes.COMPLETION.value(); + } + } + + } + + /** + * Events for chat model operations. + */ + public enum Events implements Observation.Event { + + /** + * Content of the prompt sent to the model. + */ + CONTENT_PROMPT { + @Override + public String getName() { + return AiObservationEventNames.CONTENT_PROMPT.value(); + } + }, + + /** + * Content of the completion returned by the model. + */ + CONTENT_COMPLETION { + @Override + public String getName() { + return AiObservationEventNames.CONTENT_COMPLETION.value(); + } + } + + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationFilter.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationFilter.java new file mode 100644 index 000000000..758ff0c7d --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationFilter.java @@ -0,0 +1,54 @@ +/* + * 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.chat.observation; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationFilter; +import org.springframework.util.CollectionUtils; + +import java.util.StringJoiner; + +/** + * An {@link ObservationFilter} to include the chat prompt content in the observation. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +public class ChatModelPromptContentObservationFilter implements ObservationFilter { + + @Override + public Observation.Context map(Observation.Context context) { + if (!(context instanceof ChatModelObservationContext chatModelObservationContext)) { + return context; + } + + if (CollectionUtils.isEmpty(chatModelObservationContext.getRequest().getInstructions())) { + return chatModelObservationContext; + } + + StringJoiner promptMessagesJoiner = new StringJoiner(", ", "[", "]"); + chatModelObservationContext.getRequest() + .getInstructions() + .forEach(message -> promptMessagesJoiner.add("\"" + message.getContent() + "\"")); + + chatModelObservationContext + .addHighCardinalityKeyValue(ChatModelObservationDocumentation.HighCardinalityKeyNames.PROMPT + .withValue(promptMessagesJoiner.toString())); + + return chatModelObservationContext; + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelRequestOptions.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelRequestOptions.java new file mode 100644 index 000000000..ca19bc08f --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelRequestOptions.java @@ -0,0 +1,201 @@ +/* + * 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.chat.observation; + +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +import java.util.List; + +/** + * Represents client-side options for chat model requests. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +public class ChatModelRequestOptions implements ChatOptions { + + private final String model; + + @Nullable + private final Float frequencyPenalty; + + @Nullable + private final Integer maxTokens; + + @Nullable + private final Float presencePenalty; + + @Nullable + private final List stopSequences; + + @Nullable + private final Float temperature; + + @Nullable + private final Integer topK; + + @Nullable + private final Float topP; + + ChatModelRequestOptions(Builder builder) { + Assert.hasText(builder.model, "model cannot be null or empty"); + + this.model = builder.model; + this.frequencyPenalty = builder.frequencyPenalty; + this.maxTokens = builder.maxTokens; + this.presencePenalty = builder.presencePenalty; + this.stopSequences = builder.stopSequences; + this.temperature = builder.temperature; + this.topK = builder.topK; + this.topP = builder.topP; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String model; + + @Nullable + private Float frequencyPenalty; + + @Nullable + private Integer maxTokens; + + @Nullable + private Float presencePenalty; + + @Nullable + private List stopSequences; + + @Nullable + private Float temperature; + + @Nullable + private Integer topK; + + @Nullable + private Float topP; + + private Builder() { + } + + public Builder model(String model) { + this.model = model; + return this; + } + + public Builder frequencyPenalty(@Nullable Float frequencyPenalty) { + this.frequencyPenalty = frequencyPenalty; + return this; + } + + public Builder maxTokens(@Nullable Integer maxTokens) { + this.maxTokens = maxTokens; + return this; + } + + public Builder presencePenalty(@Nullable Float presencePenalty) { + this.presencePenalty = presencePenalty; + return this; + } + + public Builder stopSequences(@Nullable List stopSequences) { + this.stopSequences = stopSequences; + return this; + } + + public Builder temperature(@Nullable Float temperature) { + this.temperature = temperature; + return this; + } + + public Builder topK(@Nullable Integer topK) { + this.topK = topK; + return this; + } + + public Builder topP(@Nullable Float topP) { + this.topP = topP; + return this; + } + + public ChatModelRequestOptions build() { + return new ChatModelRequestOptions(this); + } + + } + + public String getModel() { + return this.model; + } + + @Nullable + public Float getFrequencyPenalty() { + return this.frequencyPenalty; + } + + @Nullable + public Integer getMaxTokens() { + return this.maxTokens; + } + + @Nullable + public Float getPresencePenalty() { + return this.presencePenalty; + } + + @Nullable + public List getStopSequences() { + return this.stopSequences; + } + + @Override + @Nullable + public Float getTemperature() { + return this.temperature; + } + + @Override + @Nullable + public Integer getTopK() { + return this.topK; + } + + @Override + @Nullable + public Float getTopP() { + return this.topP; + } + + @Override + public ChatOptions copy() { + return builder().model(this.model) + .frequencyPenalty(this.frequencyPenalty) + .maxTokens(this.maxTokens) + .presencePenalty(this.presencePenalty) + .stopSequences(this.stopSequences != null ? List.copyOf(this.stopSequences) : null) + .temperature(this.temperature) + .topK(this.topK) + .topP(this.topP) + .build(); + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/DefaultChatModelObservationConvention.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/DefaultChatModelObservationConvention.java new file mode 100644 index 000000000..d0e4b9af3 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/DefaultChatModelObservationConvention.java @@ -0,0 +1,234 @@ +/* + * 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.chat.observation; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.KeyValues; + +import java.util.StringJoiner; + +/** + * Default conventions to populate observations for chat model operations. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +public class DefaultChatModelObservationConvention implements ChatModelObservationConvention { + + private static final KeyValue RESPONSE_MODEL_NONE = KeyValue + .of(ChatModelObservationDocumentation.LowCardinalityKeyNames.RESPONSE_MODEL, KeyValue.NONE_VALUE); + + private static final KeyValue REQUEST_FREQUENCY_PENALTY_NONE = KeyValue + .of(ChatModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_FREQUENCY_PENALTY, KeyValue.NONE_VALUE); + + private static final KeyValue REQUEST_MAX_TOKENS_NONE = KeyValue + .of(ChatModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_MAX_TOKENS, KeyValue.NONE_VALUE); + + private static final KeyValue REQUEST_PRESENCE_PENALTY_NONE = KeyValue + .of(ChatModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_PRESENCE_PENALTY, KeyValue.NONE_VALUE); + + private static final KeyValue REQUEST_STOP_SEQUENCES_NONE = KeyValue + .of(ChatModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_STOP_SEQUENCES, KeyValue.NONE_VALUE); + + private static final KeyValue REQUEST_TEMPERATURE_NONE = KeyValue + .of(ChatModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_TEMPERATURE, KeyValue.NONE_VALUE); + + private static final KeyValue REQUEST_TOP_K_NONE = KeyValue + .of(ChatModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_TOP_K, KeyValue.NONE_VALUE); + + private static final KeyValue REQUEST_TOP_P_NONE = KeyValue + .of(ChatModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_TOP_P, KeyValue.NONE_VALUE); + + private static final KeyValue RESPONSE_FINISH_REASON_NONE = KeyValue + .of(ChatModelObservationDocumentation.HighCardinalityKeyNames.RESPONSE_FINISH_REASON, KeyValue.NONE_VALUE); + + private static final KeyValue RESPONSE_ID_NONE = KeyValue + .of(ChatModelObservationDocumentation.HighCardinalityKeyNames.RESPONSE_ID, KeyValue.NONE_VALUE); + + private static final KeyValue USAGE_INPUT_TOKENS_NONE = KeyValue + .of(ChatModelObservationDocumentation.HighCardinalityKeyNames.USAGE_INPUT_TOKENS, KeyValue.NONE_VALUE); + + private static final KeyValue USAGE_OUTPUT_TOKENS_NONE = KeyValue + .of(ChatModelObservationDocumentation.HighCardinalityKeyNames.USAGE_OUTPUT_TOKENS, KeyValue.NONE_VALUE); + + private static final KeyValue USAGE_TOTAL_TOKENS_NONE = KeyValue + .of(ChatModelObservationDocumentation.HighCardinalityKeyNames.USAGE_TOTAL_TOKENS, KeyValue.NONE_VALUE); + + public static final String DEFAULT_NAME = "gen_ai.client.operation"; + + @Override + public String getName() { + return DEFAULT_NAME; + } + + @Override + public String getContextualName(ChatModelObservationContext context) { + return "%s %s".formatted(context.getOperationMetadata().operationType(), + context.getRequestOptions().getModel()); + } + + @Override + public KeyValues getLowCardinalityKeyValues(ChatModelObservationContext context) { + return KeyValues.of(aiOperationType(context), aiProvider(context), requestModel(context), + responseModel(context)); + } + + protected KeyValue aiOperationType(ChatModelObservationContext context) { + return KeyValue.of(ChatModelObservationDocumentation.LowCardinalityKeyNames.AI_OPERATION_TYPE, + context.getOperationMetadata().operationType()); + } + + protected KeyValue aiProvider(ChatModelObservationContext context) { + return KeyValue.of(ChatModelObservationDocumentation.LowCardinalityKeyNames.AI_PROVIDER, + context.getOperationMetadata().provider()); + } + + protected KeyValue requestModel(ChatModelObservationContext context) { + return KeyValue.of(ChatModelObservationDocumentation.LowCardinalityKeyNames.REQUEST_MODEL, + context.getRequestOptions().getModel()); + } + + protected KeyValue responseModel(ChatModelObservationContext context) { + if (context.getResponse() != null && context.getResponse().getMetadata() != null + && context.getResponse().getMetadata().getModel() != null) { + return KeyValue.of(ChatModelObservationDocumentation.LowCardinalityKeyNames.RESPONSE_MODEL, + context.getResponse().getMetadata().getModel()); + } + return RESPONSE_MODEL_NONE; + } + + @Override + public KeyValues getHighCardinalityKeyValues(ChatModelObservationContext context) { + return KeyValues.of(requestFrequencyPenalty(context), requestMaxTokens(context), + requestPresencePenalty(context), requestStopSequences(context), requestTemperature(context), + requestTopK(context), requestTopP(context), responseFinishReason(context), responseId(context), + usageInputTokens(context), usageOutputTokens(context), usageTotalTokens(context)); + } + + // Request + + protected KeyValue requestFrequencyPenalty(ChatModelObservationContext context) { + if (context.getRequestOptions().getFrequencyPenalty() != null) { + return KeyValue.of(ChatModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_FREQUENCY_PENALTY, + String.valueOf(context.getRequestOptions().getFrequencyPenalty())); + } + return REQUEST_FREQUENCY_PENALTY_NONE; + } + + protected KeyValue requestMaxTokens(ChatModelObservationContext context) { + if (context.getRequestOptions().getMaxTokens() != null) { + return KeyValue.of(ChatModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_MAX_TOKENS, + String.valueOf(context.getRequestOptions().getMaxTokens())); + } + return REQUEST_MAX_TOKENS_NONE; + } + + protected KeyValue requestPresencePenalty(ChatModelObservationContext context) { + if (context.getRequestOptions().getPresencePenalty() != null) { + return KeyValue.of(ChatModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_PRESENCE_PENALTY, + String.valueOf(context.getRequestOptions().getPresencePenalty())); + } + return REQUEST_PRESENCE_PENALTY_NONE; + } + + protected KeyValue requestStopSequences(ChatModelObservationContext context) { + if (context.getRequestOptions().getStopSequences() != null) { + StringJoiner stopSequencesJoiner = new StringJoiner(", ", "[", "]"); + context.getRequestOptions() + .getStopSequences() + .forEach(value -> stopSequencesJoiner.add("\"" + value + "\"")); + return KeyValue.of(ChatModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_STOP_SEQUENCES, + stopSequencesJoiner.toString()); + } + return REQUEST_STOP_SEQUENCES_NONE; + } + + protected KeyValue requestTemperature(ChatModelObservationContext context) { + if (context.getRequestOptions().getTemperature() != null) { + return KeyValue.of(ChatModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_TEMPERATURE, + String.valueOf(context.getRequestOptions().getTemperature())); + } + return REQUEST_TEMPERATURE_NONE; + } + + protected KeyValue requestTopK(ChatModelObservationContext context) { + if (context.getRequestOptions().getTopK() != null) { + return KeyValue.of(ChatModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_TOP_K, + String.valueOf(context.getRequestOptions().getTopK())); + } + return REQUEST_TOP_K_NONE; + } + + protected KeyValue requestTopP(ChatModelObservationContext context) { + if (context.getRequestOptions().getTopP() != null) { + return KeyValue.of(ChatModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_TOP_P, + String.valueOf(context.getRequestOptions().getTopP())); + } + return REQUEST_TOP_P_NONE; + } + + // Response + + protected KeyValue responseFinishReason(ChatModelObservationContext context) { + if (context.getResponse() != null && context.getResponse().getResult() != null + && context.getResponse().getResult().getMetadata() != null + && context.getResponse().getResult().getMetadata().getFinishReason() != null) { + return KeyValue.of(ChatModelObservationDocumentation.HighCardinalityKeyNames.RESPONSE_FINISH_REASON, + context.getResponse().getResult().getMetadata().getFinishReason()); + } + return RESPONSE_FINISH_REASON_NONE; + } + + protected KeyValue responseId(ChatModelObservationContext context) { + if (context.getResponse() != null && context.getResponse().getMetadata() != null + && context.getResponse().getMetadata().getId() != null) { + return KeyValue.of(ChatModelObservationDocumentation.HighCardinalityKeyNames.RESPONSE_ID, + context.getResponse().getMetadata().getId()); + } + return RESPONSE_ID_NONE; + } + + protected KeyValue usageInputTokens(ChatModelObservationContext context) { + if (context.getResponse() != null && context.getResponse().getMetadata() != null + && context.getResponse().getMetadata().getUsage() != null + && context.getResponse().getMetadata().getUsage().getPromptTokens() != null) { + return KeyValue.of(ChatModelObservationDocumentation.HighCardinalityKeyNames.USAGE_INPUT_TOKENS, + String.valueOf(context.getResponse().getMetadata().getUsage().getPromptTokens())); + } + return USAGE_INPUT_TOKENS_NONE; + } + + protected KeyValue usageOutputTokens(ChatModelObservationContext context) { + if (context.getResponse() != null && context.getResponse().getMetadata() != null + && context.getResponse().getMetadata().getUsage() != null + && context.getResponse().getMetadata().getUsage().getGenerationTokens() != null) { + return KeyValue.of(ChatModelObservationDocumentation.HighCardinalityKeyNames.USAGE_OUTPUT_TOKENS, + String.valueOf(context.getResponse().getMetadata().getUsage().getGenerationTokens())); + } + return USAGE_OUTPUT_TOKENS_NONE; + } + + protected KeyValue usageTotalTokens(ChatModelObservationContext context) { + if (context.getResponse() != null && context.getResponse().getMetadata() != null + && context.getResponse().getMetadata().getUsage() != null + && context.getResponse().getMetadata().getUsage().getTotalTokens() != null) { + return KeyValue.of(ChatModelObservationDocumentation.HighCardinalityKeyNames.USAGE_TOTAL_TOKENS, + String.valueOf(context.getResponse().getMetadata().getUsage().getTotalTokens())); + } + return USAGE_TOTAL_TOKENS_NONE; + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/package-info.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/package-info.java new file mode 100644 index 000000000..0f796c0b0 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/package-info.java @@ -0,0 +1,22 @@ +/* + * 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. + */ + +@NonNullApi +@NonNullFields +package org.springframework.ai.chat.observation; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-ai-core/src/main/java/org/springframework/ai/embedding/observation/DefaultEmbeddingModelObservationConvention.java b/spring-ai-core/src/main/java/org/springframework/ai/embedding/observation/DefaultEmbeddingModelObservationConvention.java new file mode 100644 index 000000000..b2103b155 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/embedding/observation/DefaultEmbeddingModelObservationConvention.java @@ -0,0 +1,138 @@ +/* + * 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.embedding.observation; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.KeyValues; +import org.springframework.util.StringUtils; + +/** + * Default conventions to populate observations for embedding model operations. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +public class DefaultEmbeddingModelObservationConvention implements EmbeddingModelObservationConvention { + + private static final KeyValue RESPONSE_MODEL_NONE = KeyValue + .of(EmbeddingModelObservationDocumentation.LowCardinalityKeyNames.RESPONSE_MODEL, KeyValue.NONE_VALUE); + + private static final KeyValue REQUEST_EMBEDDING_DIMENSION_NONE = KeyValue.of( + EmbeddingModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_EMBEDDING_DIMENSIONS, + KeyValue.NONE_VALUE); + + private static final KeyValue REQUEST_EMBEDDING_ENCODING_FORMAT_NONE = KeyValue.of( + EmbeddingModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_EMBEDDING_ENCODING_FORMAT, + KeyValue.NONE_VALUE); + + private static final KeyValue USAGE_INPUT_TOKENS_NONE = KeyValue + .of(EmbeddingModelObservationDocumentation.HighCardinalityKeyNames.USAGE_INPUT_TOKENS, KeyValue.NONE_VALUE); + + private static final KeyValue USAGE_TOTAL_TOKENS_NONE = KeyValue + .of(EmbeddingModelObservationDocumentation.HighCardinalityKeyNames.USAGE_TOTAL_TOKENS, KeyValue.NONE_VALUE); + + public static final String DEFAULT_NAME = "gen_ai.client.operation"; + + @Override + public String getName() { + return DEFAULT_NAME; + } + + @Override + public String getContextualName(EmbeddingModelObservationContext context) { + return "%s %s".formatted(context.getOperationMetadata().operationType(), + context.getRequestOptions().getModel()); + } + + @Override + public KeyValues getLowCardinalityKeyValues(EmbeddingModelObservationContext context) { + return KeyValues.of(aiOperationType(context), aiProvider(context), requestModel(context), + responseModel(context)); + } + + protected KeyValue aiOperationType(EmbeddingModelObservationContext context) { + return KeyValue.of(EmbeddingModelObservationDocumentation.LowCardinalityKeyNames.AI_OPERATION_TYPE, + context.getOperationMetadata().operationType()); + } + + protected KeyValue aiProvider(EmbeddingModelObservationContext context) { + return KeyValue.of(EmbeddingModelObservationDocumentation.LowCardinalityKeyNames.AI_PROVIDER, + context.getOperationMetadata().provider()); + } + + protected KeyValue requestModel(EmbeddingModelObservationContext context) { + return KeyValue.of(EmbeddingModelObservationDocumentation.LowCardinalityKeyNames.REQUEST_MODEL, + context.getRequestOptions().getModel()); + } + + protected KeyValue responseModel(EmbeddingModelObservationContext context) { + if (context.getResponse() != null && context.getResponse().getMetadata() != null + && StringUtils.hasText(context.getResponse().getMetadata().getModel())) { + return KeyValue.of(EmbeddingModelObservationDocumentation.LowCardinalityKeyNames.RESPONSE_MODEL, + context.getResponse().getMetadata().getModel()); + } + return RESPONSE_MODEL_NONE; + } + + @Override + public KeyValues getHighCardinalityKeyValues(EmbeddingModelObservationContext context) { + return KeyValues.of(requestEmbeddingDimension(context), requestEmbeddingFormat(context), + usageInputTokens(context), usageTotalTokens(context)); + } + + // Request + + protected KeyValue requestEmbeddingDimension(EmbeddingModelObservationContext context) { + if (context.getRequestOptions().getDimensions() != null) { + return KeyValue.of( + EmbeddingModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_EMBEDDING_DIMENSIONS, + String.valueOf(context.getRequestOptions().getDimensions())); + } + return REQUEST_EMBEDDING_DIMENSION_NONE; + } + + protected KeyValue requestEmbeddingFormat(EmbeddingModelObservationContext context) { + if (StringUtils.hasText(context.getRequestOptions().getEncodingFormat())) { + return KeyValue.of( + EmbeddingModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_EMBEDDING_ENCODING_FORMAT, + context.getRequestOptions().getEncodingFormat()); + } + return REQUEST_EMBEDDING_ENCODING_FORMAT_NONE; + } + + // Response + + protected KeyValue usageInputTokens(EmbeddingModelObservationContext context) { + if (context.getResponse() != null && context.getResponse().getMetadata() != null + && context.getResponse().getMetadata().getUsage() != null + && context.getResponse().getMetadata().getUsage().getPromptTokens() != null) { + return KeyValue.of(EmbeddingModelObservationDocumentation.HighCardinalityKeyNames.USAGE_INPUT_TOKENS, + String.valueOf(context.getResponse().getMetadata().getUsage().getPromptTokens())); + } + return USAGE_INPUT_TOKENS_NONE; + } + + protected KeyValue usageTotalTokens(EmbeddingModelObservationContext context) { + if (context.getResponse() != null && context.getResponse().getMetadata() != null + && context.getResponse().getMetadata().getUsage() != null + && context.getResponse().getMetadata().getUsage().getTotalTokens() != null) { + return KeyValue.of(EmbeddingModelObservationDocumentation.HighCardinalityKeyNames.USAGE_TOTAL_TOKENS, + String.valueOf(context.getResponse().getMetadata().getUsage().getTotalTokens())); + } + return USAGE_TOTAL_TOKENS_NONE; + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/embedding/observation/EmbeddingModelMeterObservationHandler.java b/spring-ai-core/src/main/java/org/springframework/ai/embedding/observation/EmbeddingModelMeterObservationHandler.java new file mode 100644 index 000000000..8d5fb754b --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/embedding/observation/EmbeddingModelMeterObservationHandler.java @@ -0,0 +1,50 @@ +/* + * 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.embedding.observation; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationHandler; +import org.springframework.ai.model.observation.ModelUsageMetricsGenerator; + +/** + * Handler for generating metrics from embedding model observations. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +public class EmbeddingModelMeterObservationHandler implements ObservationHandler { + + private final MeterRegistry meterRegistry; + + public EmbeddingModelMeterObservationHandler(MeterRegistry meterRegistry) { + this.meterRegistry = meterRegistry; + } + + @Override + public void onStop(EmbeddingModelObservationContext context) { + if (context.getResponse() != null && context.getResponse().getMetadata() != null + && context.getResponse().getMetadata().getUsage() != null) { + ModelUsageMetricsGenerator.generate(context.getResponse().getMetadata().getUsage(), context, meterRegistry); + } + } + + @Override + public boolean supportsContext(Observation.Context context) { + return context instanceof EmbeddingModelObservationContext; + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/embedding/observation/EmbeddingModelObservationContext.java b/spring-ai-core/src/main/java/org/springframework/ai/embedding/observation/EmbeddingModelObservationContext.java new file mode 100644 index 000000000..5273675d7 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/embedding/observation/EmbeddingModelObservationContext.java @@ -0,0 +1,81 @@ +/* + * 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.embedding.observation; + +import org.springframework.ai.embedding.EmbeddingRequest; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.ai.model.observation.ModelObservationContext; +import org.springframework.ai.observation.AiOperationMetadata; +import org.springframework.util.Assert; + +/** + * Context used to store metadata for embedding model exchanges. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +public class EmbeddingModelObservationContext extends ModelObservationContext { + + private final EmbeddingModelRequestOptions requestOptions; + + EmbeddingModelObservationContext(EmbeddingRequest embeddingRequest, AiOperationMetadata operationMetadata, + EmbeddingModelRequestOptions requestOptions) { + super(embeddingRequest, operationMetadata); + Assert.notNull(requestOptions, "requestOptions cannot be null"); + this.requestOptions = requestOptions; + } + + public EmbeddingModelRequestOptions getRequestOptions() { + return requestOptions; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private EmbeddingRequest embeddingRequest; + + private AiOperationMetadata operationMetadata; + + private EmbeddingModelRequestOptions requestOptions; + + private Builder() { + } + + public Builder embeddingRequest(EmbeddingRequest embeddingRequest) { + this.embeddingRequest = embeddingRequest; + return this; + } + + public Builder operationMetadata(AiOperationMetadata operationMetadata) { + this.operationMetadata = operationMetadata; + return this; + } + + public Builder requestOptions(EmbeddingModelRequestOptions requestOptions) { + this.requestOptions = requestOptions; + return this; + } + + public EmbeddingModelObservationContext build() { + return new EmbeddingModelObservationContext(embeddingRequest, operationMetadata, requestOptions); + } + + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/embedding/observation/EmbeddingModelObservationConvention.java b/spring-ai-core/src/main/java/org/springframework/ai/embedding/observation/EmbeddingModelObservationConvention.java new file mode 100644 index 000000000..b09ac9be2 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/embedding/observation/EmbeddingModelObservationConvention.java @@ -0,0 +1,34 @@ +/* + * 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.embedding.observation; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationConvention; + +/** + * Interface for an {@link ObservationConvention} for embedding model exchanges. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +public interface EmbeddingModelObservationConvention extends ObservationConvention { + + @Override + default boolean supportsContext(Observation.Context context) { + return context instanceof EmbeddingModelObservationContext; + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/embedding/observation/EmbeddingModelObservationDocumentation.java b/spring-ai-core/src/main/java/org/springframework/ai/embedding/observation/EmbeddingModelObservationDocumentation.java new file mode 100644 index 000000000..e7371a292 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/embedding/observation/EmbeddingModelObservationDocumentation.java @@ -0,0 +1,149 @@ +/* + * 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.embedding.observation; + +import io.micrometer.common.docs.KeyName; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationConvention; +import io.micrometer.observation.docs.ObservationDocumentation; +import org.springframework.ai.observation.conventions.AiObservationAttributes; +import org.springframework.ai.observation.conventions.AiOperationType; + +/** + * Documented conventions for embedding model observations. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +public enum EmbeddingModelObservationDocumentation implements ObservationDocumentation { + + EMBEDDING_MODEL_OPERATION { + @Override + public Class> getDefaultConvention() { + return DefaultEmbeddingModelObservationConvention.class; + } + + @Override + public KeyName[] getLowCardinalityKeyNames() { + return LowCardinalityKeyNames.values(); + } + + @Override + public KeyName[] getHighCardinalityKeyNames() { + return HighCardinalityKeyNames.values(); + } + }; + + /** + * Low-cardinality observation key names for embedding model operations. + */ + public enum LowCardinalityKeyNames implements KeyName { + + /** + * The name of the operation being performed. Possibly, one of + * {@link AiOperationType}. + */ + AI_OPERATION_TYPE { + @Override + public String asString() { + return AiObservationAttributes.AI_OPERATION_TYPE.value(); + } + }, + + /** + * The model provider as identified by the client instrumentation. + */ + AI_PROVIDER { + @Override + public String asString() { + return AiObservationAttributes.AI_PROVIDER.value(); + } + }, + + /** + * The name of the model a request is being made to. + */ + REQUEST_MODEL { + @Override + public String asString() { + return AiObservationAttributes.REQUEST_MODEL.value(); + } + }, + + /** + * The name of the model that generated the response. + */ + RESPONSE_MODEL { + @Override + public String asString() { + return AiObservationAttributes.RESPONSE_MODEL.value(); + } + } + + } + + /** + * High-cardinality observation key names for embedding model operations. + */ + public enum HighCardinalityKeyNames implements KeyName { + + // Request + + /** + * The number of dimensions the resulting output embeddings have. + */ + REQUEST_EMBEDDING_DIMENSIONS { + @Override + public String asString() { + return AiObservationAttributes.REQUEST_EMBEDDING_DIMENSIONS.value(); + } + }, + + /** + * The format the embeddings are returned in. + */ + REQUEST_EMBEDDING_ENCODING_FORMAT { + @Override + public String asString() { + return AiObservationAttributes.REQUEST_EMBEDDING_ENCODING_FORMAT.value(); + } + }, + + // Usage + + /** + * The number of tokens used in the model input. + */ + USAGE_INPUT_TOKENS { + @Override + public String asString() { + return AiObservationAttributes.USAGE_INPUT_TOKENS.value(); + } + }, + + /** + * The total number of tokens used in the model exchange. + */ + USAGE_TOTAL_TOKENS { + @Override + public String asString() { + return AiObservationAttributes.USAGE_TOTAL_TOKENS.value(); + } + } + + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/embedding/observation/EmbeddingModelRequestOptions.java b/spring-ai-core/src/main/java/org/springframework/ai/embedding/observation/EmbeddingModelRequestOptions.java new file mode 100644 index 000000000..4072e64f3 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/embedding/observation/EmbeddingModelRequestOptions.java @@ -0,0 +1,98 @@ +/* + * 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.embedding.observation; + +import org.springframework.ai.embedding.EmbeddingOptions; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Represents client-side options for embedding model requests. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +public class EmbeddingModelRequestOptions implements EmbeddingOptions { + + private final String model; + + @Nullable + private final Integer dimensions; + + @Nullable + private final String encodingFormat; + + EmbeddingModelRequestOptions(Builder builder) { + Assert.hasText(builder.model, "model cannot be null or empty"); + + this.model = builder.model; + this.dimensions = builder.dimensions; + this.encodingFormat = builder.encodingFormat; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String model; + + @Nullable + private Integer dimensions; + + @Nullable + private String encodingFormat; + + private Builder() { + } + + public Builder model(String model) { + this.model = model; + return this; + } + + public Builder dimensions(@Nullable Integer dimensions) { + this.dimensions = dimensions; + return this; + } + + public Builder encodingFormat(@Nullable String encodingFormat) { + this.encodingFormat = encodingFormat; + return this; + } + + public EmbeddingModelRequestOptions build() { + return new EmbeddingModelRequestOptions(this); + } + + } + + public String getModel() { + return model; + } + + @Nullable + public Integer getDimensions() { + return dimensions; + } + + @Nullable + public String getEncodingFormat() { + return encodingFormat; + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/embedding/observation/package-info.java b/spring-ai-core/src/main/java/org/springframework/ai/embedding/observation/package-info.java new file mode 100644 index 000000000..95579239b --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/embedding/observation/package-info.java @@ -0,0 +1,22 @@ +/* + * 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. + */ + +@NonNullApi +@NonNullFields +package org.springframework.ai.embedding.observation; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-ai-core/src/main/java/org/springframework/ai/image/observation/DefaultImageModelObservationConvention.java b/spring-ai-core/src/main/java/org/springframework/ai/image/observation/DefaultImageModelObservationConvention.java new file mode 100644 index 000000000..06979d566 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/image/observation/DefaultImageModelObservationConvention.java @@ -0,0 +1,104 @@ +/* + * 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.image.observation; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.KeyValues; +import org.springframework.util.StringUtils; + +/** + * Default conventions to populate observations for image model operations. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +public class DefaultImageModelObservationConvention implements ImageModelObservationConvention { + + private static final KeyValue REQUEST_IMAGE_RESPONSE_FORMAT_NONE = KeyValue.of( + ImageModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_IMAGE_RESPONSE_FORMAT, + KeyValue.NONE_VALUE); + + private static final KeyValue REQUEST_IMAGE_SIZE_NONE = KeyValue + .of(ImageModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_IMAGE_SIZE, KeyValue.NONE_VALUE); + + private static final KeyValue REQUEST_IMAGE_STYLE_NONE = KeyValue + .of(ImageModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_IMAGE_STYLE, KeyValue.NONE_VALUE); + + public static final String DEFAULT_NAME = "gen_ai.client.operation"; + + @Override + public String getName() { + return DEFAULT_NAME; + } + + @Override + public String getContextualName(ImageModelObservationContext context) { + return "%s %s".formatted(context.getOperationMetadata().operationType(), + context.getRequestOptions().getModel()); + } + + @Override + public KeyValues getLowCardinalityKeyValues(ImageModelObservationContext context) { + return KeyValues.of(aiOperationType(context), aiProvider(context), requestModel(context)); + } + + protected KeyValue aiOperationType(ImageModelObservationContext context) { + return KeyValue.of(ImageModelObservationDocumentation.LowCardinalityKeyNames.AI_OPERATION_TYPE, + context.getOperationMetadata().operationType()); + } + + protected KeyValue aiProvider(ImageModelObservationContext context) { + return KeyValue.of(ImageModelObservationDocumentation.LowCardinalityKeyNames.AI_PROVIDER, + context.getOperationMetadata().provider()); + } + + protected KeyValue requestModel(ImageModelObservationContext context) { + return KeyValue.of(ImageModelObservationDocumentation.LowCardinalityKeyNames.REQUEST_MODEL, + context.getRequestOptions().getModel()); + } + + @Override + public KeyValues getHighCardinalityKeyValues(ImageModelObservationContext context) { + return KeyValues.of(requestImageFormat(context), requestImageSize(context), requestImageStyle(context)); + } + + // Request + + protected KeyValue requestImageFormat(ImageModelObservationContext context) { + if (StringUtils.hasText(context.getRequestOptions().getResponseFormat())) { + return KeyValue.of(ImageModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_IMAGE_RESPONSE_FORMAT, + context.getRequestOptions().getResponseFormat()); + } + return REQUEST_IMAGE_RESPONSE_FORMAT_NONE; + } + + protected KeyValue requestImageSize(ImageModelObservationContext context) { + if (context.getRequestOptions().getWidth() != null && context.getRequestOptions().getHeight() != null) { + return KeyValue.of(ImageModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_IMAGE_SIZE, + "%sx%s".formatted(context.getRequestOptions().getWidth(), context.getRequestOptions().getHeight())); + } + return REQUEST_IMAGE_SIZE_NONE; + } + + protected KeyValue requestImageStyle(ImageModelObservationContext context) { + if (StringUtils.hasText(context.getRequestOptions().getStyle())) { + return KeyValue.of(ImageModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_IMAGE_STYLE, + context.getRequestOptions().getStyle()); + } + return REQUEST_IMAGE_STYLE_NONE; + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/image/observation/ImageModelObservationContext.java b/spring-ai-core/src/main/java/org/springframework/ai/image/observation/ImageModelObservationContext.java new file mode 100644 index 000000000..3a9c18e92 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/image/observation/ImageModelObservationContext.java @@ -0,0 +1,81 @@ +/* + * 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.image.observation; + +import org.springframework.ai.image.ImagePrompt; +import org.springframework.ai.image.ImageResponse; +import org.springframework.ai.model.observation.ModelObservationContext; +import org.springframework.ai.observation.AiOperationMetadata; +import org.springframework.util.Assert; + +/** + * Context used to store metadata for image model exchanges. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +public class ImageModelObservationContext extends ModelObservationContext { + + private final ImageModelRequestOptions requestOptions; + + ImageModelObservationContext(ImagePrompt imagePrompt, AiOperationMetadata operationMetadata, + ImageModelRequestOptions requestOptions) { + super(imagePrompt, operationMetadata); + Assert.notNull(requestOptions, "requestOptions cannot be null"); + this.requestOptions = requestOptions; + } + + public ImageModelRequestOptions getRequestOptions() { + return requestOptions; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private ImagePrompt imagePrompt; + + private AiOperationMetadata operationMetadata; + + private ImageModelRequestOptions requestOptions; + + private Builder() { + } + + public Builder imagePrompt(ImagePrompt imagePrompt) { + this.imagePrompt = imagePrompt; + return this; + } + + public Builder operationMetadata(AiOperationMetadata operationMetadata) { + this.operationMetadata = operationMetadata; + return this; + } + + public Builder requestOptions(ImageModelRequestOptions requestOptions) { + this.requestOptions = requestOptions; + return this; + } + + public ImageModelObservationContext build() { + return new ImageModelObservationContext(imagePrompt, operationMetadata, requestOptions); + } + + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/image/observation/ImageModelObservationConvention.java b/spring-ai-core/src/main/java/org/springframework/ai/image/observation/ImageModelObservationConvention.java new file mode 100644 index 000000000..faf83e68a --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/image/observation/ImageModelObservationConvention.java @@ -0,0 +1,34 @@ +/* + * 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.image.observation; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationConvention; + +/** + * Interface for an {@link ObservationConvention} for image model exchanges. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +public interface ImageModelObservationConvention extends ObservationConvention { + + @Override + default boolean supportsContext(Observation.Context context) { + return context instanceof ImageModelObservationContext; + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/image/observation/ImageModelObservationDocumentation.java b/spring-ai-core/src/main/java/org/springframework/ai/image/observation/ImageModelObservationDocumentation.java new file mode 100644 index 000000000..445a5404b --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/image/observation/ImageModelObservationDocumentation.java @@ -0,0 +1,214 @@ +/* + * 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.image.observation; + +import io.micrometer.common.docs.KeyName; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationConvention; +import io.micrometer.observation.docs.ObservationDocumentation; +import org.springframework.ai.observation.conventions.AiObservationAttributes; +import org.springframework.ai.observation.conventions.AiObservationEventNames; + +/** + * Documented conventions for image model observations. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +public enum ImageModelObservationDocumentation implements ObservationDocumentation { + + IMAGE_MODEL_OPERATION { + @Override + public Class> getDefaultConvention() { + return DefaultImageModelObservationConvention.class; + } + + @Override + public KeyName[] getLowCardinalityKeyNames() { + return LowCardinalityKeyNames.values(); + } + + @Override + public KeyName[] getHighCardinalityKeyNames() { + return HighCardinalityKeyNames.values(); + } + + @Override + public Observation.Event[] getEvents() { + return Events.values(); + } + }; + + /** + * Low-cardinality observation key names for image model operations. + */ + public enum LowCardinalityKeyNames implements KeyName { + + /** + * The name of the operation being performed. + */ + AI_OPERATION_TYPE { + @Override + public String asString() { + return AiObservationAttributes.AI_OPERATION_TYPE.value(); + } + }, + + /** + * The model provider as identified by the client instrumentation. + */ + AI_PROVIDER { + @Override + public String asString() { + return AiObservationAttributes.AI_PROVIDER.value(); + } + }, + + /** + * The name of the model a request is being made to. + */ + REQUEST_MODEL { + @Override + public String asString() { + return AiObservationAttributes.REQUEST_MODEL.value(); + } + } + + } + + /** + * High-cardinality observation key names for image model operations. + */ + public enum HighCardinalityKeyNames implements KeyName { + + // Request + + /** + * The format in which the generated image is returned. + */ + REQUEST_IMAGE_RESPONSE_FORMAT { + @Override + public String asString() { + return AiObservationAttributes.REQUEST_IMAGE_RESPONSE_FORMAT.value(); + } + }, + + /** + * The size of the image to generate. + */ + REQUEST_IMAGE_SIZE { + @Override + public String asString() { + return AiObservationAttributes.REQUEST_IMAGE_SIZE.value(); + } + }, + + /** + * The style of the image to generate. + */ + REQUEST_IMAGE_STYLE { + @Override + public String asString() { + return AiObservationAttributes.REQUEST_IMAGE_STYLE.value(); + } + }, + + // Response + + /** + * The unique identifier for the AI response. + */ + RESPONSE_ID { + @Override + public String asString() { + return AiObservationAttributes.RESPONSE_ID.value(); + } + }, + + /** + * The name of the model that generated the response. + */ + RESPONSE_MODEL { + @Override + public String asString() { + return AiObservationAttributes.RESPONSE_MODEL.value(); + } + }, + + // Usage + + /** + * The number of tokens used in the model input (prompt). + */ + USAGE_INPUT_TOKENS { + @Override + public String asString() { + return AiObservationAttributes.USAGE_INPUT_TOKENS.value(); + } + }, + + /** + * The number of tokens used in the model output (generation). + */ + USAGE_OUTPUT_TOKENS { + @Override + public String asString() { + return AiObservationAttributes.USAGE_OUTPUT_TOKENS.value(); + } + }, + + /** + * The total number of tokens used in the model exchange. + */ + USAGE_TOTAL_TOKENS { + @Override + public String asString() { + return AiObservationAttributes.USAGE_TOTAL_TOKENS.value(); + } + }, + + // Content + + /** + * The full prompt sent to the model. + */ + PROMPT { + @Override + public String asString() { + return AiObservationAttributes.PROMPT.value(); + } + } + + } + + /** + * Events for image model operations. + */ + public enum Events implements Observation.Event { + + /** + * Content of the prompt sent to the model. + */ + CONTENT_PROMPT { + @Override + public String getName() { + return AiObservationEventNames.CONTENT_PROMPT.value(); + } + } + + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/image/observation/ImageModelPromptContentObservationFilter.java b/spring-ai-core/src/main/java/org/springframework/ai/image/observation/ImageModelPromptContentObservationFilter.java new file mode 100644 index 000000000..afcf0c04c --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/image/observation/ImageModelPromptContentObservationFilter.java @@ -0,0 +1,54 @@ +/* + * 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.image.observation; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationFilter; +import org.springframework.util.CollectionUtils; + +import java.util.StringJoiner; + +/** + * An {@link ObservationFilter} to include the image prompt content in the observation. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +public class ImageModelPromptContentObservationFilter implements ObservationFilter { + + @Override + public Observation.Context map(Observation.Context context) { + if (!(context instanceof ImageModelObservationContext imageModelObservationContext)) { + return context; + } + + if (CollectionUtils.isEmpty(imageModelObservationContext.getRequest().getInstructions())) { + return imageModelObservationContext; + } + + StringJoiner promptMessagesJoiner = new StringJoiner(", ", "[", "]"); + imageModelObservationContext.getRequest() + .getInstructions() + .forEach(message -> promptMessagesJoiner.add("\"" + message.getText() + "\"")); + + imageModelObservationContext + .addHighCardinalityKeyValue(ImageModelObservationDocumentation.HighCardinalityKeyNames.PROMPT + .withValue(promptMessagesJoiner.toString())); + + return imageModelObservationContext; + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/image/observation/ImageModelRequestOptions.java b/spring-ai-core/src/main/java/org/springframework/ai/image/observation/ImageModelRequestOptions.java new file mode 100644 index 000000000..1487e0434 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/image/observation/ImageModelRequestOptions.java @@ -0,0 +1,154 @@ +/* + * 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.image.observation; + +import org.springframework.ai.image.ImageOptions; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Represents client-side options for image model requests. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +public class ImageModelRequestOptions implements ImageOptions { + + private final String model; + + @Nullable + private final Integer n; + + @Nullable + private final Integer width; + + @Nullable + private final Integer height; + + @Nullable + private final String responseFormat; + + @Nullable + private final String style; + + ImageModelRequestOptions(Builder builder) { + Assert.hasText(builder.model, "model cannot be null or empty"); + + this.model = builder.model; + this.n = builder.n; + this.width = builder.width; + this.height = builder.height; + this.responseFormat = builder.responseFormat; + this.style = builder.style; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String model; + + @Nullable + private Integer n; + + @Nullable + private Integer width; + + @Nullable + private Integer height; + + @Nullable + private String responseFormat; + + @Nullable + private String style; + + private Builder() { + } + + public Builder model(String model) { + this.model = model; + return this; + } + + public Builder n(@Nullable Integer n) { + this.n = n; + return this; + } + + public Builder width(@Nullable Integer width) { + this.width = width; + return this; + } + + public Builder height(@Nullable Integer height) { + this.height = height; + return this; + } + + public Builder responseFormat(@Nullable String responseFormat) { + this.responseFormat = responseFormat; + return this; + } + + public Builder style(@Nullable String style) { + this.style = style; + return this; + } + + public ImageModelRequestOptions build() { + return new ImageModelRequestOptions(this); + } + + } + + @Override + public String getModel() { + return model; + } + + @Override + @Nullable + public Integer getN() { + return n; + } + + @Override + @Nullable + public Integer getWidth() { + return width; + } + + @Override + @Nullable + public Integer getHeight() { + return height; + } + + @Override + @Nullable + public String getResponseFormat() { + return responseFormat; + } + + @Nullable + public String getStyle() { + return style; + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/image/observation/package-info.java b/spring-ai-core/src/main/java/org/springframework/ai/image/observation/package-info.java new file mode 100644 index 000000000..f28e93b06 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/image/observation/package-info.java @@ -0,0 +1,22 @@ +/* + * 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. + */ + +@NonNullApi +@NonNullFields +package org.springframework.ai.image.observation; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-ai-core/src/main/java/org/springframework/ai/model/observation/ModelObservationContext.java b/spring-ai-core/src/main/java/org/springframework/ai/model/observation/ModelObservationContext.java new file mode 100644 index 000000000..0c0ac6767 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/model/observation/ModelObservationContext.java @@ -0,0 +1,66 @@ +/* + * 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.model.observation; + +import io.micrometer.observation.Observation; +import org.springframework.ai.observation.AiOperationMetadata; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Context used when sending a request to a machine learning model and waiting for a + * response from the model provider. + * + * @param type of the request object + * @param type of the response object + * @author Thomas Vitale + * @since 1.0.0 + */ +public class ModelObservationContext extends Observation.Context { + + private final REQ request; + + private final AiOperationMetadata operationMetadata; + + @Nullable + private RES response; + + public ModelObservationContext(REQ request, AiOperationMetadata operationMetadata) { + Assert.notNull(request, "request cannot be null"); + Assert.notNull(operationMetadata, "operationMetadata cannot be null"); + this.request = request; + this.operationMetadata = operationMetadata; + } + + public REQ getRequest() { + return this.request; + } + + public AiOperationMetadata getOperationMetadata() { + return this.operationMetadata; + } + + @Nullable + public RES getResponse() { + return this.response; + } + + public void setResponse(RES response) { + Assert.notNull(response, "response cannot be null"); + this.response = response; + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/model/observation/ModelUsageMetricsGenerator.java b/spring-ai-core/src/main/java/org/springframework/ai/model/observation/ModelUsageMetricsGenerator.java new file mode 100644 index 000000000..dfd6e6c84 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/model/observation/ModelUsageMetricsGenerator.java @@ -0,0 +1,80 @@ +/* + * 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.model.observation; + +import io.micrometer.common.KeyValue; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import io.micrometer.observation.Observation; +import org.springframework.ai.chat.metadata.Usage; +import org.springframework.ai.observation.conventions.AiObservationMetricAttributes; +import org.springframework.ai.observation.conventions.AiObservationMetricNames; +import org.springframework.ai.observation.conventions.AiTokenType; + +import java.util.ArrayList; +import java.util.List; + +/** + * Generate metrics about the model usage in the context of an AI operation. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +public final class ModelUsageMetricsGenerator { + + private static final String DESCRIPTION = "Measures number of input and output tokens used"; + + public static void generate(Usage usage, Observation.Context context, MeterRegistry meterRegistry) { + + if (usage.getPromptTokens() != null) { + Counter.builder(AiObservationMetricNames.TOKEN_USAGE.value()) + .tag(AiObservationMetricAttributes.TOKEN_TYPE.value(), AiTokenType.INPUT.value()) + .description(DESCRIPTION) + .tags(createTags(context)) + .register(meterRegistry) + .increment(usage.getPromptTokens()); + } + + if (usage.getGenerationTokens() != null) { + Counter.builder(AiObservationMetricNames.TOKEN_USAGE.value()) + .tag(AiObservationMetricAttributes.TOKEN_TYPE.value(), AiTokenType.OUTPUT.value()) + .description(DESCRIPTION) + .tags(createTags(context)) + .register(meterRegistry) + .increment(usage.getGenerationTokens()); + } + + if (usage.getTotalTokens() != null) { + Counter.builder(AiObservationMetricNames.TOKEN_USAGE.value()) + .tag(AiObservationMetricAttributes.TOKEN_TYPE.value(), AiTokenType.TOTAL.value()) + .description(DESCRIPTION) + .tags(createTags(context)) + .register(meterRegistry) + .increment(usage.getTotalTokens()); + } + + } + + private static List createTags(Observation.Context context) { + List tags = new ArrayList<>(); + for (KeyValue keyValue : context.getLowCardinalityKeyValues()) { + tags.add(Tag.of(keyValue.getKey(), keyValue.getValue())); + } + return tags; + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/model/observation/package-info.java b/spring-ai-core/src/main/java/org/springframework/ai/model/observation/package-info.java new file mode 100644 index 000000000..1d5817736 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/model/observation/package-info.java @@ -0,0 +1,22 @@ +/* + * 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. + */ + +@NonNullApi +@NonNullFields +package org.springframework.ai.model.observation; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; \ No newline at end of file diff --git a/spring-ai-core/src/main/java/org/springframework/ai/observation/AiOperationMetadata.java b/spring-ai-core/src/main/java/org/springframework/ai/observation/AiOperationMetadata.java new file mode 100644 index 000000000..68b1c3dff --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/observation/AiOperationMetadata.java @@ -0,0 +1,69 @@ +/* + * 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.observation; + +import org.springframework.ai.observation.conventions.AiOperationType; +import org.springframework.ai.observation.conventions.AiProvider; +import org.springframework.util.Assert; + +/** + * Metadata associated with an AI operation (e.g. model inference, fine-tuning, + * evaluation). + * + * @param operationType The type of operation performed by the model. Whenever possible, a + * value from {@link AiOperationType}. + * @param provider The name of the system providing the model service. Whenever possible, + * a value from {@link AiProvider}. + * @author Thomas Vitale + * @since 1.0.0 + */ +public record AiOperationMetadata(String operationType, String provider) { + + public AiOperationMetadata { + Assert.hasText(operationType, "operationType cannot be null or empty"); + Assert.hasText(provider, "provider cannot be null or empty"); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String operationType; + + private String provider; + + private Builder() { + } + + public Builder operationType(String operationType) { + this.operationType = operationType; + return this; + } + + public Builder provider(String provider) { + this.provider = provider; + return this; + } + + public AiOperationMetadata build() { + return new AiOperationMetadata(operationType, provider); + } + + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiObservationAttributes.java b/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiObservationAttributes.java new file mode 100644 index 000000000..e0f53208e --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiObservationAttributes.java @@ -0,0 +1,153 @@ +/* + * 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.observation.conventions; + +/** + * Collection of attribute keys used in AI observations (spans, metrics, events). Based on + * the OpenTelemetry Semantic Conventions for AI Systems. + * + * @author Thomas Vitale + * @since 1.0.0 + * @see OTel + * Semantic Conventions. + */ +public enum AiObservationAttributes { + +// @formatter:off + + // GenAI General + + /** + * The name of the operation being performed. + */ + AI_OPERATION_TYPE("gen_ai.operation.name"), + /** + * The model provider as identified by the client instrumentation. + */ + AI_PROVIDER("gen_ai.system"), + + // GenAI Request + + /** + * The name of the model a request is being made to. + */ + REQUEST_MODEL("gen_ai.request.model"), + /** + * The frequency penalty setting for the model request. + */ + REQUEST_FREQUENCY_PENALTY("gen_ai.request.frequency_penalty"), + /** + * The maximum number of tokens the model generates for a request. + */ + REQUEST_MAX_TOKENS("gen_ai.request.max_tokens"), + /** + * The presence penalty setting for the model request. + */ + REQUEST_PRESENCE_PENALTY("gen_ai.request.presence_penalty"), + /** + * List of sequences that the model will use to stop generating further tokens. + */ + REQUEST_STOP_SEQUENCES("gen_ai.request.stop_sequences"), + /** + * The temperature setting for the model request. + */ + REQUEST_TEMPERATURE("gen_ai.request.temperature"), + /** + * The top_k sampling setting for the model request. + */ + REQUEST_TOP_K("gen_ai.request.top_k"), + /** + * The top_p sampling setting for the model request. + */ + REQUEST_TOP_P("gen_ai.request.top_p"), + + /** + * The number of dimensions the resulting output embeddings have. + */ + REQUEST_EMBEDDING_DIMENSIONS("gen_ai.request.embedding.dimensions"), + /** + * The format the embeddings are returned in. + */ + REQUEST_EMBEDDING_ENCODING_FORMAT("gen_ai.request.embedding.encoding_format"), + + /** + * The format in which the generated image is returned. + */ + REQUEST_IMAGE_RESPONSE_FORMAT("gen_ai.request.image.response_format"), + /** + * The size of the image to generate. + */ + REQUEST_IMAGE_SIZE("gen_ai.request.image.size"), + /** + * The style of the image to generate. + */ + REQUEST_IMAGE_STYLE("gen_ai.request.image.style"), + + // GenAI Response + + /** + * Final reason the model stopped generating tokens. + */ + RESPONSE_FINISH_REASON("gen_ai.response.finish_reason"), + /** + * The unique identifier for the AI response. + */ + RESPONSE_ID("gen_ai.response.id"), + /** + * The name of the model that generated the response. + */ + RESPONSE_MODEL("gen_ai.response.model"), + + // GenAI Usage + + /** + * The number of tokens used in the model input. + */ + USAGE_INPUT_TOKENS("gen_ai.usage.input_tokens"), + /** + * The number of tokens used in the model output. + */ + USAGE_OUTPUT_TOKENS("gen_ai.usage.output_tokens"), + /** + * The total number of tokens used in the model exchange. + */ + USAGE_TOTAL_TOKENS("gen_ai.usage.total_tokens"), + + // GenAI Content + + /** + * The full prompt sent to the model. + */ + PROMPT("gen_ai.prompt"), + /** + * The full response received from the model. + */ + COMPLETION("gen_ai.completion"); + + private final String value; + + AiObservationAttributes(String value) { + this.value = value; + } + + public String value() { + return value; + } + +// @formatter:on + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiObservationEventNames.java b/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiObservationEventNames.java new file mode 100644 index 000000000..c3f86f353 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiObservationEventNames.java @@ -0,0 +1,47 @@ +/* + * 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.observation.conventions; + +/** + * Collection of event names used in AI observations. Based on the OpenTelemetry Semantic + * Conventions for AI Systems. + * + * @author Thomas Vitale + * @since 1.0.0 + * @see OTel + * Semantic Conventions. + */ +public enum AiObservationEventNames { + +// @formatter:off + + CONTENT_PROMPT("gen_ai.content.prompt"), + CONTENT_COMPLETION("gen_ai.content.completion"); + + private final String value; + + AiObservationEventNames(String value) { + this.value = value; + } + + public String value() { + return value; + } + +// @formatter:on + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiObservationMetricAttributes.java b/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiObservationMetricAttributes.java new file mode 100644 index 000000000..e8d828e7d --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiObservationMetricAttributes.java @@ -0,0 +1,49 @@ +/* + * 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.observation.conventions; + +/** + * Collection of metric attributes used in AI observations. Based on the OpenTelemetry + * Semantic Conventions for AI Systems. + * + * @author Thomas Vitale + * @since 1.0.0 + * @see OTel + * Semantic Conventions. + */ +public enum AiObservationMetricAttributes { + +// @formatter:off + + /** + * The type of token being counted (input, output, total). + */ + TOKEN_TYPE("gen_ai.token.type"); + + private final String value; + + AiObservationMetricAttributes(String value) { + this.value = value; + } + + public String value() { + return value; + } + +// @formatter:on + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiObservationMetricNames.java b/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiObservationMetricNames.java new file mode 100644 index 000000000..358755319 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiObservationMetricNames.java @@ -0,0 +1,47 @@ +/* + * 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.observation.conventions; + +/** + * Collection of metric names used in AI observations. Based on the OpenTelemetry Semantic + * Conventions for AI Systems. + * + * @author Thomas Vitale + * @since 1.0.0 + * @see OTel + * Semantic Conventions. + */ +public enum AiObservationMetricNames { + +// @formatter:off + + OPERATION_DURATION("gen_ai.client.operation.duration"), + TOKEN_USAGE("gen_ai.client.token.usage"); + + private final String value; + + AiObservationMetricNames(String value) { + this.value = value; + } + + public String value() { + return value; + } + +// @formatter:on + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiOperationType.java b/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiOperationType.java new file mode 100644 index 000000000..45ea85671 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiOperationType.java @@ -0,0 +1,49 @@ +/* + * 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.observation.conventions; + +/** + * Types of operations performed by AI systems. Based on the OpenTelemetry Semantic + * Conventions for AI Systems. + * + * @author Thomas Vitale + * @since 1.0.0 + * @see OTel + * Semantic Conventions. + */ +public enum AiOperationType { + + // @formatter:off + + CHAT("chat"), + EMBEDDING("embedding"), + IMAGE("image"), + TEXT_COMPLETION("text_completion"); + + private final String value; + + AiOperationType(String value) { + this.value = value; + } + + public String value() { + return this.value; + } + + // @formatter:on + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java b/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java new file mode 100644 index 000000000..01f678f02 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java @@ -0,0 +1,50 @@ +/* + * 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.observation.conventions; + +/** + * Collection of systems providing AI functionality. Based on the OpenTelemetry Semantic + * Conventions for AI Systems. + * + * @author Thomas Vitale + * @since 1.0.0 + * @see OTel + * Semantic Conventions. + */ +public enum AiProvider { + + // @formatter:off + + ANTHROPIC("anthropic"), + MISTRAL_AI("mistral_ai"), + OLLAMA("ollama"), + OPENAI("openai"), + VERTEX_AI("vertex_ai"); + + private final String value; + + AiProvider(String value) { + this.value = value; + } + + public String value() { + return this.value; + } + + // @formatter:on + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiTokenType.java b/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiTokenType.java new file mode 100644 index 000000000..a8c2fec38 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiTokenType.java @@ -0,0 +1,48 @@ +/* + * 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.observation.conventions; + +/** + * Types of tokens produced and consumed in an AI operation. Based on the OpenTelemetry + * Semantic Conventions for AI Systems. + * + * @author Thomas Vitale + * @since 1.0.0 + * @see OTel + * Semantic Conventions. + */ +public enum AiTokenType { + +// @formatter:off + + INPUT("input"), + OUTPUT("output"), + TOTAL("total"); + + private final String value; + + AiTokenType(String value) { + this.value = value; + } + + public String value() { + return value; + } + +// @formatter:on + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/package-info.java b/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/package-info.java new file mode 100644 index 000000000..53f533019 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/package-info.java @@ -0,0 +1,22 @@ +/* + * 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. + */ + +@NonNullApi +@NonNullFields +package org.springframework.ai.observation.conventions; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; \ No newline at end of file diff --git a/spring-ai-core/src/main/java/org/springframework/ai/observation/package-info.java b/spring-ai-core/src/main/java/org/springframework/ai/observation/package-info.java new file mode 100644 index 000000000..1ef4dfd32 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/observation/package-info.java @@ -0,0 +1,22 @@ +/* + * 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. + */ + +@NonNullApi +@NonNullFields +package org.springframework.ai.observation; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; \ No newline at end of file diff --git a/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationFilterTests.java b/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationFilterTests.java new file mode 100644 index 000000000..44180e8ad --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationFilterTests.java @@ -0,0 +1,102 @@ +/* + * 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.chat.observation; + +import io.micrometer.common.KeyValue; +import io.micrometer.observation.Observation; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.observation.AiOperationMetadata; +import org.springframework.ai.observation.conventions.AiOperationType; +import org.springframework.ai.observation.conventions.AiProvider; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.ai.chat.observation.ChatModelObservationDocumentation.HighCardinalityKeyNames; + +/** + * Unit tests for {@link ChatModelCompletionObservationFilter}. + * + * @author Thomas Vitale + */ +class ChatModelCompletionObservationFilterTests { + + private final ChatModelCompletionObservationFilter observationFilter = new ChatModelCompletionObservationFilter(); + + @Test + void whenNotSupportedObservationContextThenReturnOriginalContext() { + var expectedContext = new Observation.Context(); + var actualContext = observationFilter.map(expectedContext); + + assertThat(actualContext).isEqualTo(expectedContext); + } + + @Test + void whenEmptyResponseThenReturnOriginalContext() { + var expectedContext = ChatModelObservationContext.builder() + .prompt(generatePrompt()) + .operationMetadata(generateOperationMetadata()) + .requestOptions(ChatModelRequestOptions.builder().model("mistral").build()) + .build(); + var actualContext = observationFilter.map(expectedContext); + + assertThat(actualContext).isEqualTo(expectedContext); + } + + @Test + void whenEmptyCompletionThenReturnOriginalContext() { + var expectedContext = ChatModelObservationContext.builder() + .prompt(generatePrompt()) + .operationMetadata(generateOperationMetadata()) + .requestOptions(ChatModelRequestOptions.builder().model("mistral").build()) + .build(); + expectedContext.setResponse(new ChatResponse(List.of(new Generation(new AssistantMessage(""))))); + var actualContext = observationFilter.map(expectedContext); + + assertThat(actualContext).isEqualTo(expectedContext); + } + + @Test + void whenCompletionWithTextThenAugmentContext() { + var originalContext = ChatModelObservationContext.builder() + .prompt(generatePrompt()) + .operationMetadata(generateOperationMetadata()) + .requestOptions(ChatModelRequestOptions.builder().model("mistral").build()) + .build(); + originalContext.setResponse(new ChatResponse(List.of(new Generation(new AssistantMessage("say please")), + new Generation(new AssistantMessage("seriously, say please"))))); + var augmentedContext = observationFilter.map(originalContext); + + assertThat(augmentedContext.getHighCardinalityKeyValues()).contains(KeyValue + .of(HighCardinalityKeyNames.COMPLETION.asString(), "[\"say please\", \"seriously, say please\"]")); + } + + private Prompt generatePrompt() { + return new Prompt("supercalifragilisticexpialidocious"); + } + + private AiOperationMetadata generateOperationMetadata() { + return AiOperationMetadata.builder() + .operationType(AiOperationType.CHAT.value()) + .provider(AiProvider.OLLAMA.value()) + .build(); + } + +} diff --git a/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelMeterObservationHandlerTests.java b/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelMeterObservationHandlerTests.java new file mode 100644 index 000000000..8c1414c03 --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelMeterObservationHandlerTests.java @@ -0,0 +1,121 @@ +/* + * 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.chat.observation; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.metadata.ChatResponseMetadata; +import org.springframework.ai.chat.metadata.Usage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.observation.AiOperationMetadata; +import org.springframework.ai.observation.conventions.*; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.ai.chat.observation.ChatModelObservationDocumentation.LowCardinalityKeyNames; + +/** + * Unit tests for {@link ChatModelMeterObservationHandler}. + * + * @author Thomas Vitale + */ +class ChatModelMeterObservationHandlerTests { + + private MeterRegistry meterRegistry; + + private ObservationRegistry observationRegistry; + + @BeforeEach + void setUp() { + this.meterRegistry = new SimpleMeterRegistry(); + this.observationRegistry = ObservationRegistry.create(); + this.observationRegistry.observationConfig() + .observationHandler(new ChatModelMeterObservationHandler(this.meterRegistry)); + } + + @Test + void shouldCreateAllMetersDuringAnObservation() { + var observationContext = generateObservationContext(); + var observation = Observation + .createNotStarted(new DefaultChatModelObservationConvention(), () -> observationContext, + observationRegistry) + .start(); + + observationContext.setResponse(new ChatResponse(List.of(new Generation(new AssistantMessage("test"))), + ChatResponseMetadata.builder().withModel("mistral-42").withUsage(new TestUsage()).build())); + + observation.stop(); + + assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value()).meters()).hasSize(3); + assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value()) + .tag(LowCardinalityKeyNames.AI_OPERATION_TYPE.asString(), AiOperationType.CHAT.value()) + .tag(LowCardinalityKeyNames.AI_PROVIDER.asString(), AiProvider.OLLAMA.value()) + .tag(LowCardinalityKeyNames.REQUEST_MODEL.asString(), "mistral") + .tag(LowCardinalityKeyNames.RESPONSE_MODEL.asString(), "mistral-42") + .meters()).hasSize(3); + assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value()) + .tag(AiObservationMetricAttributes.TOKEN_TYPE.value(), AiTokenType.INPUT.value()) + .meters()).hasSize(1); + assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value()) + .tag(AiObservationMetricAttributes.TOKEN_TYPE.value(), AiTokenType.OUTPUT.value()) + .meters()).hasSize(1); + assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value()) + .tag(AiObservationMetricAttributes.TOKEN_TYPE.value(), AiTokenType.TOTAL.value()) + .meters()).hasSize(1); + } + + private ChatModelObservationContext generateObservationContext() { + return ChatModelObservationContext.builder() + .prompt(generatePrompt()) + .operationMetadata(generateOperationMetadata()) + .requestOptions(ChatModelRequestOptions.builder().model("mistral").build()) + .build(); + } + + private Prompt generatePrompt() { + return new Prompt("hello"); + } + + private AiOperationMetadata generateOperationMetadata() { + return AiOperationMetadata.builder() + .operationType(AiOperationType.CHAT.value()) + .provider(AiProvider.OLLAMA.value()) + .build(); + } + + static class TestUsage implements Usage { + + @Override + public Long getPromptTokens() { + return 1000L; + } + + @Override + public Long getGenerationTokens() { + return 500L; + } + + } + +} diff --git a/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelObservationContextTests.java b/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelObservationContextTests.java new file mode 100644 index 000000000..342d36fb7 --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelObservationContextTests.java @@ -0,0 +1,66 @@ +/* + * 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.chat.observation; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.observation.AiOperationMetadata; +import org.springframework.ai.observation.conventions.AiOperationType; +import org.springframework.ai.observation.conventions.AiProvider; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link ChatModelObservationContext}. + * + * @author Thomas Vitale + */ +class ChatModelObservationContextTests { + + @Test + void whenMandatoryRequestOptionsThenReturn() { + var observationContext = ChatModelObservationContext.builder() + .prompt(generatePrompt()) + .operationMetadata(generateOperationMetadata()) + .requestOptions(ChatModelRequestOptions.builder().model("supermodel").build()) + .build(); + + assertThat(observationContext).isNotNull(); + } + + @Test + void whenRequestOptionsIsNullThenThrow() { + assertThatThrownBy(() -> ChatModelObservationContext.builder() + .prompt(generatePrompt()) + .operationMetadata(generateOperationMetadata()) + .requestOptions(null) + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("requestOptions cannot be null"); + } + + private Prompt generatePrompt() { + return new Prompt("hello"); + } + + private AiOperationMetadata generateOperationMetadata() { + return AiOperationMetadata.builder() + .operationType(AiOperationType.CHAT.value()) + .provider(AiProvider.OLLAMA.value()) + .build(); + } + +} diff --git a/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationFilterTests.java b/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationFilterTests.java new file mode 100644 index 000000000..faba1ab2e --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationFilterTests.java @@ -0,0 +1,97 @@ +/* + * 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.chat.observation; + +import io.micrometer.common.KeyValue; +import io.micrometer.observation.Observation; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.observation.AiOperationMetadata; +import org.springframework.ai.observation.conventions.AiOperationType; +import org.springframework.ai.observation.conventions.AiProvider; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.ai.chat.observation.ChatModelObservationDocumentation.HighCardinalityKeyNames; + +/** + * Unit tests for {@link ChatModelPromptContentObservationFilter}. + * + * @author Thomas Vitale + */ +class ChatModelPromptContentObservationFilterTests { + + private final ChatModelPromptContentObservationFilter observationFilter = new ChatModelPromptContentObservationFilter(); + + @Test + void whenNotSupportedObservationContextThenReturnOriginalContext() { + var expectedContext = new Observation.Context(); + var actualContext = observationFilter.map(expectedContext); + + assertThat(actualContext).isEqualTo(expectedContext); + } + + @Test + void whenEmptyPromptThenReturnOriginalContext() { + var expectedContext = ChatModelObservationContext.builder() + .prompt(new Prompt(List.of())) + .operationMetadata(generateOperationMetadata()) + .requestOptions(ChatModelRequestOptions.builder().model("mistral").build()) + .build(); + var actualContext = observationFilter.map(expectedContext); + + assertThat(actualContext).isEqualTo(expectedContext); + } + + @Test + void whenPromptWithTextThenAugmentContext() { + var originalContext = ChatModelObservationContext.builder() + .prompt(new Prompt("supercalifragilisticexpialidocious")) + .operationMetadata(generateOperationMetadata()) + .requestOptions(ChatModelRequestOptions.builder().model("mistral").build()) + .build(); + var augmentedContext = observationFilter.map(originalContext); + + assertThat(augmentedContext.getHighCardinalityKeyValues()).contains( + KeyValue.of(HighCardinalityKeyNames.PROMPT.asString(), "[\"supercalifragilisticexpialidocious\"]")); + } + + @Test + void whenPromptWithMessagesThenAugmentContext() { + var originalContext = ChatModelObservationContext.builder() + .prompt(new Prompt(List.of(new SystemMessage("you're a chimney sweep"), + new UserMessage("supercalifragilisticexpialidocious")))) + .operationMetadata(generateOperationMetadata()) + .requestOptions(ChatModelRequestOptions.builder().model("mistral").build()) + .build(); + var augmentedContext = observationFilter.map(originalContext); + + assertThat(augmentedContext.getHighCardinalityKeyValues()) + .contains(KeyValue.of(HighCardinalityKeyNames.PROMPT.asString(), + "[\"you're a chimney sweep\", \"supercalifragilisticexpialidocious\"]")); + } + + private AiOperationMetadata generateOperationMetadata() { + return AiOperationMetadata.builder() + .operationType(AiOperationType.CHAT.value()) + .provider(AiProvider.OLLAMA.value()) + .build(); + } + +} diff --git a/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelRequestOptionsTests.java b/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelRequestOptionsTests.java new file mode 100644 index 000000000..18eed1f91 --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelRequestOptionsTests.java @@ -0,0 +1,50 @@ +/* + * 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.chat.observation; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link ChatModelRequestOptions}. + * + * @author Thomas Vitale + */ +class ChatModelRequestOptionsTests { + + @Test + void whenMandatoryRequestOptionsThenReturn() { + var requestOptions = ChatModelRequestOptions.builder().model("rowena").build(); + + assertThat(requestOptions).isNotNull(); + } + + @Test + void whenModelIsNullThenThrow() { + assertThatThrownBy(() -> ChatModelRequestOptions.builder().build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("model cannot be null or empty"); + } + + @Test + void whenModelIsEmptyThenThrow() { + assertThatThrownBy(() -> ChatModelRequestOptions.builder().model("").build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("model cannot be null or empty"); + } + +} diff --git a/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/DefaultChatModelObservationConventionTests.java b/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/DefaultChatModelObservationConventionTests.java new file mode 100644 index 000000000..4965f07c4 --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/DefaultChatModelObservationConventionTests.java @@ -0,0 +1,176 @@ +/* + * 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.chat.observation; + +import io.micrometer.common.KeyValue; +import io.micrometer.observation.Observation; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.metadata.ChatGenerationMetadata; +import org.springframework.ai.chat.metadata.ChatResponseMetadata; +import org.springframework.ai.chat.metadata.Usage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.observation.AiOperationMetadata; +import org.springframework.ai.observation.conventions.AiOperationType; +import org.springframework.ai.observation.conventions.AiProvider; + +import java.util.List; + +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; + +/** + * Unit tests for {@link DefaultChatModelObservationConvention}. + * + * @author Thomas Vitale + */ +class DefaultChatModelObservationConventionTests { + + private final DefaultChatModelObservationConvention observationConvention = new DefaultChatModelObservationConvention(); + + @Test + void shouldHaveName() { + assertThat(this.observationConvention.getName()).isEqualTo(DefaultChatModelObservationConvention.DEFAULT_NAME); + } + + @Test + void shouldHaveContextualName() { + ChatModelObservationContext observationContext = ChatModelObservationContext.builder() + .prompt(generatePrompt()) + .operationMetadata(generateOperationMetadata()) + .requestOptions(ChatModelRequestOptions.builder().model("mistral").build()) + .build(); + assertThat(this.observationConvention.getContextualName(observationContext)).isEqualTo("chat mistral"); + } + + @Test + void supportsOnlyChatModelObservationContext() { + ChatModelObservationContext observationContext = ChatModelObservationContext.builder() + .prompt(generatePrompt()) + .operationMetadata(generateOperationMetadata()) + .requestOptions(ChatModelRequestOptions.builder().model("mistral").build()) + .build(); + assertThat(this.observationConvention.supportsContext(observationContext)).isTrue(); + assertThat(this.observationConvention.supportsContext(new Observation.Context())).isFalse(); + } + + @Test + void shouldHaveRequiredKeyValues() { + ChatModelObservationContext observationContext = ChatModelObservationContext.builder() + .prompt(generatePrompt()) + .operationMetadata(generateOperationMetadata()) + .requestOptions(ChatModelRequestOptions.builder().model("mistral").build()) + .build(); + assertThat(this.observationConvention.getLowCardinalityKeyValues(observationContext)).contains( + KeyValue.of(LowCardinalityKeyNames.AI_OPERATION_TYPE.asString(), "chat"), + KeyValue.of(LowCardinalityKeyNames.AI_PROVIDER.asString(), "ollama"), + KeyValue.of(LowCardinalityKeyNames.REQUEST_MODEL.asString(), "mistral")); + } + + @Test + void shouldHaveOptionalKeyValues() { + ChatModelObservationContext observationContext = ChatModelObservationContext.builder() + .prompt(generatePrompt()) + .operationMetadata(generateOperationMetadata()) + .requestOptions(ChatModelRequestOptions.builder() + .model("mistral") + .frequencyPenalty(0.8f) + .maxTokens(200) + .presencePenalty(1.0f) + .stopSequences(List.of("addio", "bye")) + .temperature(0.5f) + .topK(1) + .topP(0.9f) + .build()) + .build(); + observationContext.setResponse(new ChatResponse( + List.of(new Generation(new AssistantMessage("response"), + ChatGenerationMetadata.from("this-is-the-end", null))), + ChatResponseMetadata.builder() + .withId("say33") + .withModel("mistral-42") + .withUsage(new TestUsage()) + .build())); + assertThat(this.observationConvention.getLowCardinalityKeyValues(observationContext)) + .contains(KeyValue.of(LowCardinalityKeyNames.RESPONSE_MODEL.asString(), "mistral-42")); + assertThat(this.observationConvention.getHighCardinalityKeyValues(observationContext)).contains( + KeyValue.of(HighCardinalityKeyNames.REQUEST_FREQUENCY_PENALTY.asString(), "0.8"), + KeyValue.of(HighCardinalityKeyNames.REQUEST_MAX_TOKENS.asString(), "200"), + KeyValue.of(HighCardinalityKeyNames.REQUEST_PRESENCE_PENALTY.asString(), "1.0"), + KeyValue.of(HighCardinalityKeyNames.REQUEST_STOP_SEQUENCES.asString(), "[\"addio\", \"bye\"]"), + KeyValue.of(HighCardinalityKeyNames.REQUEST_TEMPERATURE.asString(), "0.5"), + KeyValue.of(HighCardinalityKeyNames.REQUEST_TOP_K.asString(), "1"), + KeyValue.of(HighCardinalityKeyNames.REQUEST_TOP_P.asString(), "0.9"), + KeyValue.of(HighCardinalityKeyNames.RESPONSE_FINISH_REASON.asString(), "this-is-the-end"), + KeyValue.of(HighCardinalityKeyNames.RESPONSE_ID.asString(), "say33"), + KeyValue.of(HighCardinalityKeyNames.USAGE_INPUT_TOKENS.asString(), "1000"), + KeyValue.of(HighCardinalityKeyNames.USAGE_OUTPUT_TOKENS.asString(), "500"), + KeyValue.of(HighCardinalityKeyNames.USAGE_TOTAL_TOKENS.asString(), "1500")); + } + + @Test + void shouldHaveMissingKeyValues() { + ChatModelObservationContext observationContext = ChatModelObservationContext.builder() + .prompt(generatePrompt()) + .operationMetadata(generateOperationMetadata()) + .requestOptions(ChatModelRequestOptions.builder().model("mistral").build()) + .build(); + assertThat(this.observationConvention.getLowCardinalityKeyValues(observationContext)) + .contains(KeyValue.of(LowCardinalityKeyNames.RESPONSE_MODEL.asString(), KeyValue.NONE_VALUE)); + assertThat(this.observationConvention.getHighCardinalityKeyValues(observationContext)).contains( + KeyValue.of(HighCardinalityKeyNames.REQUEST_FREQUENCY_PENALTY.asString(), KeyValue.NONE_VALUE), + KeyValue.of(HighCardinalityKeyNames.REQUEST_MAX_TOKENS.asString(), KeyValue.NONE_VALUE), + KeyValue.of(HighCardinalityKeyNames.REQUEST_PRESENCE_PENALTY.asString(), KeyValue.NONE_VALUE), + KeyValue.of(HighCardinalityKeyNames.REQUEST_STOP_SEQUENCES.asString(), KeyValue.NONE_VALUE), + KeyValue.of(HighCardinalityKeyNames.REQUEST_TEMPERATURE.asString(), KeyValue.NONE_VALUE), + KeyValue.of(HighCardinalityKeyNames.REQUEST_TOP_K.asString(), KeyValue.NONE_VALUE), + KeyValue.of(HighCardinalityKeyNames.REQUEST_TOP_P.asString(), KeyValue.NONE_VALUE), + KeyValue.of(HighCardinalityKeyNames.RESPONSE_FINISH_REASON.asString(), KeyValue.NONE_VALUE), + KeyValue.of(HighCardinalityKeyNames.RESPONSE_ID.asString(), KeyValue.NONE_VALUE), + KeyValue.of(HighCardinalityKeyNames.USAGE_INPUT_TOKENS.asString(), KeyValue.NONE_VALUE), + KeyValue.of(HighCardinalityKeyNames.USAGE_OUTPUT_TOKENS.asString(), KeyValue.NONE_VALUE), + KeyValue.of(HighCardinalityKeyNames.USAGE_TOTAL_TOKENS.asString(), KeyValue.NONE_VALUE)); + } + + private Prompt generatePrompt() { + return new Prompt("Who let the dogs out?"); + } + + private AiOperationMetadata generateOperationMetadata() { + return AiOperationMetadata.builder() + .operationType(AiOperationType.CHAT.value()) + .provider(AiProvider.OLLAMA.value()) + .build(); + } + + static class TestUsage implements Usage { + + @Override + public Long getPromptTokens() { + return 1000L; + } + + @Override + public Long getGenerationTokens() { + return 500L; + } + + } + +} diff --git a/spring-ai-core/src/test/java/org/springframework/ai/embedding/observation/DefaultEmbeddingModelObservationConventionTests.java b/spring-ai-core/src/test/java/org/springframework/ai/embedding/observation/DefaultEmbeddingModelObservationConventionTests.java new file mode 100644 index 000000000..b69b43cda --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/embedding/observation/DefaultEmbeddingModelObservationConventionTests.java @@ -0,0 +1,149 @@ +/* + * 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.embedding.observation; + +import io.micrometer.common.KeyValue; +import io.micrometer.observation.Observation; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.metadata.Usage; +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.observation.AiOperationMetadata; +import org.springframework.ai.observation.conventions.AiOperationType; +import org.springframework.ai.observation.conventions.AiProvider; + +import java.util.List; +import java.util.Map; + +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; + +/* + * Unit tests for {@link DefaultEmbeddingModelObservationConvention}. + * + * @author Thomas Vitale + */ +class DefaultEmbeddingModelObservationConventionTests { + + private final DefaultEmbeddingModelObservationConvention observationConvention = new DefaultEmbeddingModelObservationConvention(); + + @Test + void shouldHaveName() { + assertThat(this.observationConvention.getName()) + .isEqualTo(DefaultEmbeddingModelObservationConvention.DEFAULT_NAME); + } + + @Test + void shouldHaveContextualName() { + EmbeddingModelObservationContext observationContext = EmbeddingModelObservationContext.builder() + .embeddingRequest(generateEmbeddingRequest()) + .operationMetadata(generateOperationMetadata()) + .requestOptions(EmbeddingModelRequestOptions.builder().model("mistral").build()) + .build(); + assertThat(this.observationConvention.getContextualName(observationContext)).isEqualTo("embedding mistral"); + } + + @Test + void supportsOnlyEmbeddingModelObservationContext() { + EmbeddingModelObservationContext observationContext = EmbeddingModelObservationContext.builder() + .embeddingRequest(generateEmbeddingRequest()) + .operationMetadata(generateOperationMetadata()) + .requestOptions(EmbeddingModelRequestOptions.builder().model("supermodel").build()) + .build(); + assertThat(this.observationConvention.supportsContext(observationContext)).isTrue(); + assertThat(this.observationConvention.supportsContext(new Observation.Context())).isFalse(); + } + + @Test + void shouldHaveRequiredLowCardinalityKeyValues() { + EmbeddingModelObservationContext observationContext = EmbeddingModelObservationContext.builder() + .embeddingRequest(generateEmbeddingRequest()) + .operationMetadata(generateOperationMetadata()) + .requestOptions(EmbeddingModelRequestOptions.builder().model("mistral").build()) + .build(); + assertThat(this.observationConvention.getLowCardinalityKeyValues(observationContext)).contains( + KeyValue.of(LowCardinalityKeyNames.AI_OPERATION_TYPE.asString(), "embedding"), + KeyValue.of(LowCardinalityKeyNames.AI_PROVIDER.asString(), "ollama"), + KeyValue.of(LowCardinalityKeyNames.REQUEST_MODEL.asString(), "mistral")); + } + + @Test + void shouldHaveOptionalKeyValues() { + EmbeddingModelObservationContext observationContext = EmbeddingModelObservationContext.builder() + .embeddingRequest(generateEmbeddingRequest()) + .operationMetadata(generateOperationMetadata()) + .requestOptions(EmbeddingModelRequestOptions.builder() + .model("supermodel") + .dimensions(1492) + .encodingFormat("vector") + .build()) + .build(); + observationContext.setResponse(new EmbeddingResponse(List.of(), + new EmbeddingResponseMetadata("mistral-42", new TestUsage(), Map.of()))); + assertThat(this.observationConvention.getLowCardinalityKeyValues(observationContext)) + .contains(KeyValue.of(LowCardinalityKeyNames.RESPONSE_MODEL.asString(), "mistral-42")); + assertThat(this.observationConvention.getHighCardinalityKeyValues(observationContext)).contains( + KeyValue.of(HighCardinalityKeyNames.REQUEST_EMBEDDING_DIMENSIONS.asString(), "1492"), + KeyValue.of(HighCardinalityKeyNames.REQUEST_EMBEDDING_ENCODING_FORMAT.asString(), "vector"), + KeyValue.of(HighCardinalityKeyNames.USAGE_INPUT_TOKENS.asString(), "1000"), + KeyValue.of(HighCardinalityKeyNames.USAGE_TOTAL_TOKENS.asString(), "1000")); + } + + @Test + void shouldHaveMissingKeyValues() { + EmbeddingModelObservationContext observationContext = EmbeddingModelObservationContext.builder() + .embeddingRequest(generateEmbeddingRequest()) + .operationMetadata(generateOperationMetadata()) + .requestOptions(EmbeddingModelRequestOptions.builder().model("supermodel").build()) + .build(); + assertThat(this.observationConvention.getLowCardinalityKeyValues(observationContext)) + .contains(KeyValue.of(LowCardinalityKeyNames.RESPONSE_MODEL.asString(), KeyValue.NONE_VALUE)); + assertThat(this.observationConvention.getHighCardinalityKeyValues(observationContext)).contains( + KeyValue.of(HighCardinalityKeyNames.REQUEST_EMBEDDING_DIMENSIONS.asString(), KeyValue.NONE_VALUE), + KeyValue.of(HighCardinalityKeyNames.REQUEST_EMBEDDING_ENCODING_FORMAT.asString(), KeyValue.NONE_VALUE), + KeyValue.of(HighCardinalityKeyNames.USAGE_INPUT_TOKENS.asString(), KeyValue.NONE_VALUE), + KeyValue.of(HighCardinalityKeyNames.USAGE_TOTAL_TOKENS.asString(), KeyValue.NONE_VALUE)); + } + + private EmbeddingRequest generateEmbeddingRequest() { + return new EmbeddingRequest(List.of(), EmbeddingOptions.EMPTY); + } + + private AiOperationMetadata generateOperationMetadata() { + return AiOperationMetadata.builder() + .operationType(AiOperationType.EMBEDDING.value()) + .provider(AiProvider.OLLAMA.value()) + .build(); + } + + static class TestUsage implements Usage { + + @Override + public Long getPromptTokens() { + return 1000L; + } + + @Override + public Long getGenerationTokens() { + return 0L; + } + + } + +} diff --git a/spring-ai-core/src/test/java/org/springframework/ai/embedding/observation/EmbeddingModelMeterObservationHandlerTests.java b/spring-ai-core/src/test/java/org/springframework/ai/embedding/observation/EmbeddingModelMeterObservationHandlerTests.java new file mode 100644 index 000000000..3b4daaf40 --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/embedding/observation/EmbeddingModelMeterObservationHandlerTests.java @@ -0,0 +1,126 @@ +/* + * 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.embedding.observation; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.metadata.Usage; +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.observation.AiOperationMetadata; +import org.springframework.ai.observation.conventions.*; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.ai.embedding.observation.EmbeddingModelObservationDocumentation.LowCardinalityKeyNames; + +/** + * Unit tests for {@link EmbeddingModelMeterObservationHandler}. + * + * @author Thomas Vitale + */ +class EmbeddingModelMeterObservationHandlerTests { + + private MeterRegistry meterRegistry; + + private ObservationRegistry observationRegistry; + + @BeforeEach + void setUp() { + this.meterRegistry = new SimpleMeterRegistry(); + this.observationRegistry = ObservationRegistry.create(); + this.observationRegistry.observationConfig() + .observationHandler(new EmbeddingModelMeterObservationHandler(this.meterRegistry)); + } + + @Test + void shouldCreateAllMetersDuringAnObservation() { + var observationContext = generateObservationContext(); + var observation = Observation + .createNotStarted(new DefaultEmbeddingModelObservationConvention(), () -> observationContext, + observationRegistry) + .start(); + + observationContext.setResponse(new EmbeddingResponse(List.of(), + new EmbeddingResponseMetadata("mistral-42", new TestUsage(), Map.of()))); + + observation.stop(); + + assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value()).meters()).hasSize(3); + assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value()) + .tag(LowCardinalityKeyNames.AI_OPERATION_TYPE.asString(), AiOperationType.EMBEDDING.value()) + .tag(LowCardinalityKeyNames.AI_PROVIDER.asString(), AiProvider.OLLAMA.value()) + .tag(LowCardinalityKeyNames.REQUEST_MODEL.asString(), "mistral") + .tag(LowCardinalityKeyNames.RESPONSE_MODEL.asString(), "mistral-42") + .meters()).hasSize(3); + assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value()) + .tag(AiObservationMetricAttributes.TOKEN_TYPE.value(), AiTokenType.INPUT.value()) + .meters()).hasSize(1); + assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value()) + .tag(AiObservationMetricAttributes.TOKEN_TYPE.value(), AiTokenType.OUTPUT.value()) + .meters()).hasSize(1); + assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value()) + .tag(AiObservationMetricAttributes.TOKEN_TYPE.value(), AiTokenType.TOTAL.value()) + .meters()).hasSize(1); + } + + private EmbeddingModelObservationContext generateObservationContext() { + return EmbeddingModelObservationContext.builder() + .embeddingRequest(generateEmbeddingRequest()) + .operationMetadata(generateOperationMetadata()) + .requestOptions(EmbeddingModelRequestOptions.builder().model("mistral").build()) + .build(); + } + + private EmbeddingRequest generateEmbeddingRequest() { + return new EmbeddingRequest(List.of(), EmbeddingOptions.EMPTY); + } + + private AiOperationMetadata generateOperationMetadata() { + return AiOperationMetadata.builder() + .operationType(AiOperationType.EMBEDDING.value()) + .provider(AiProvider.OLLAMA.value()) + .build(); + } + + static class TestUsage implements Usage { + + @Override + public Long getPromptTokens() { + return 1000L; + } + + @Override + public Long getGenerationTokens() { + return 0L; + } + + @Override + public Long getTotalTokens() { + return 1000L; + } + + } + +} diff --git a/spring-ai-core/src/test/java/org/springframework/ai/embedding/observation/EmbeddingModelObservationContextTests.java b/spring-ai-core/src/test/java/org/springframework/ai/embedding/observation/EmbeddingModelObservationContextTests.java new file mode 100644 index 000000000..f9e63fc02 --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/embedding/observation/EmbeddingModelObservationContextTests.java @@ -0,0 +1,69 @@ +/* + * 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.embedding.observation; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.embedding.EmbeddingOptions; +import org.springframework.ai.embedding.EmbeddingRequest; +import org.springframework.ai.observation.AiOperationMetadata; +import org.springframework.ai.observation.conventions.AiOperationType; +import org.springframework.ai.observation.conventions.AiProvider; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link EmbeddingModelObservationContext}. + * + * @author Thomas Vitale + */ +class EmbeddingModelObservationContextTests { + + @Test + void whenMandatoryRequestOptionsThenReturn() { + var observationContext = EmbeddingModelObservationContext.builder() + .embeddingRequest(generateEmbeddingRequest()) + .operationMetadata(generateOperationMetadata()) + .requestOptions(EmbeddingModelRequestOptions.builder().model("supermodel").build()) + .build(); + + assertThat(observationContext).isNotNull(); + } + + @Test + void whenRequestOptionsIsNullThenThrow() { + assertThatThrownBy(() -> EmbeddingModelObservationContext.builder() + .embeddingRequest(generateEmbeddingRequest()) + .operationMetadata(generateOperationMetadata()) + .requestOptions(null) + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("requestOptions cannot be null"); + } + + private EmbeddingRequest generateEmbeddingRequest() { + return new EmbeddingRequest(List.of(), EmbeddingOptions.EMPTY); + } + + private AiOperationMetadata generateOperationMetadata() { + return AiOperationMetadata.builder() + .operationType(AiOperationType.EMBEDDING.value()) + .provider(AiProvider.OLLAMA.value()) + .build(); + } + +} diff --git a/spring-ai-core/src/test/java/org/springframework/ai/embedding/observation/EmbeddingModelRequestOptionsTests.java b/spring-ai-core/src/test/java/org/springframework/ai/embedding/observation/EmbeddingModelRequestOptionsTests.java new file mode 100644 index 000000000..d85dac57e --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/embedding/observation/EmbeddingModelRequestOptionsTests.java @@ -0,0 +1,51 @@ +/* + * 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.embedding.observation; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link EmbeddingModelRequestOptions}. + * + * @author Thomas Vitale + */ +class EmbeddingModelRequestOptionsTests { + + @Test + void whenMandatoryRequestOptionsThenReturn() { + var requestOptions = EmbeddingModelRequestOptions.builder().model("rowena").build(); + + assertThat(requestOptions).isNotNull(); + } + + @Test + void whenModelIsNullThenThrow() { + assertThatThrownBy(() -> EmbeddingModelRequestOptions.builder().build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("model cannot be null or empty"); + } + + @Test + void whenModelIsEmptyThenThrow() { + assertThatThrownBy(() -> EmbeddingModelRequestOptions.builder().model("").build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("model cannot be null or empty"); + } + +} \ No newline at end of file diff --git a/spring-ai-core/src/test/java/org/springframework/ai/image/observation/DefaultImageModelObservationConventionTests.java b/spring-ai-core/src/test/java/org/springframework/ai/image/observation/DefaultImageModelObservationConventionTests.java new file mode 100644 index 000000000..6981e8fb2 --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/image/observation/DefaultImageModelObservationConventionTests.java @@ -0,0 +1,123 @@ +/* + * 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.image.observation; + +import io.micrometer.common.KeyValue; +import io.micrometer.observation.Observation; +import org.junit.jupiter.api.Test; +import org.springframework.ai.image.ImagePrompt; +import org.springframework.ai.observation.AiOperationMetadata; +import org.springframework.ai.observation.conventions.AiObservationAttributes; +import org.springframework.ai.observation.conventions.AiOperationType; +import org.springframework.ai.observation.conventions.AiProvider; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link DefaultImageModelObservationConvention}. + * + * @author Thomas Vitale + */ +class DefaultImageModelObservationConventionTests { + + private final DefaultImageModelObservationConvention observationConvention = new DefaultImageModelObservationConvention(); + + @Test + void shouldHaveName() { + assertThat(this.observationConvention.getName()).isEqualTo(DefaultImageModelObservationConvention.DEFAULT_NAME); + } + + @Test + void shouldHaveContextualName() { + ImageModelObservationContext observationContext = ImageModelObservationContext.builder() + .imagePrompt(generateImagePrompt()) + .operationMetadata(generateOperationMetadata()) + .requestOptions(ImageModelRequestOptions.builder().model("mistral").build()) + .build(); + assertThat(this.observationConvention.getContextualName(observationContext)).isEqualTo("image mistral"); + } + + @Test + void supportsOnlyImageModelObservationContext() { + ImageModelObservationContext observationContext = ImageModelObservationContext.builder() + .imagePrompt(generateImagePrompt()) + .operationMetadata(generateOperationMetadata()) + .requestOptions(ImageModelRequestOptions.builder().model("mistral").build()) + .build(); + assertThat(this.observationConvention.supportsContext(observationContext)).isTrue(); + assertThat(this.observationConvention.supportsContext(new Observation.Context())).isFalse(); + } + + @Test + void shouldHaveRequiredLowCardinalityKeyValues() { + ImageModelObservationContext observationContext = ImageModelObservationContext.builder() + .imagePrompt(generateImagePrompt()) + .operationMetadata(generateOperationMetadata()) + .requestOptions(ImageModelRequestOptions.builder().model("mistral").build()) + .build(); + assertThat(this.observationConvention.getLowCardinalityKeyValues(observationContext)).contains( + KeyValue.of(AiObservationAttributes.AI_OPERATION_TYPE.value(), "image"), + KeyValue.of(AiObservationAttributes.AI_PROVIDER.value(), "ollama"), + KeyValue.of(AiObservationAttributes.REQUEST_MODEL.value(), "mistral")); + } + + @Test + void shouldHaveOptionalHighCardinalityKeyValues() { + ImageModelObservationContext observationContext = ImageModelObservationContext.builder() + .imagePrompt(generateImagePrompt()) + .operationMetadata(generateOperationMetadata()) + .requestOptions(ImageModelRequestOptions.builder() + .model("mistral") + .n(1) + .height(1080) + .width(1920) + .style("sketch") + .responseFormat("base64") + .build()) + .build(); + + assertThat(this.observationConvention.getHighCardinalityKeyValues(observationContext)).contains( + KeyValue.of(AiObservationAttributes.REQUEST_IMAGE_RESPONSE_FORMAT.value(), "base64"), + KeyValue.of(AiObservationAttributes.REQUEST_IMAGE_SIZE.value(), "1920x1080"), + KeyValue.of(AiObservationAttributes.REQUEST_IMAGE_STYLE.value(), "sketch")); + } + + @Test + void shouldHaveMissingHighCardinalityKeyValues() { + ImageModelObservationContext observationContext = ImageModelObservationContext.builder() + .imagePrompt(generateImagePrompt()) + .operationMetadata(generateOperationMetadata()) + .requestOptions(ImageModelRequestOptions.builder().model("mistral").build()) + .build(); + + assertThat(this.observationConvention.getHighCardinalityKeyValues(observationContext)).contains( + KeyValue.of(AiObservationAttributes.REQUEST_IMAGE_RESPONSE_FORMAT.value(), KeyValue.NONE_VALUE), + KeyValue.of(AiObservationAttributes.REQUEST_IMAGE_SIZE.value(), KeyValue.NONE_VALUE), + KeyValue.of(AiObservationAttributes.REQUEST_IMAGE_STYLE.value(), KeyValue.NONE_VALUE)); + } + + private ImagePrompt generateImagePrompt() { + return new ImagePrompt("here comes the sun"); + } + + private AiOperationMetadata generateOperationMetadata() { + return AiOperationMetadata.builder() + .operationType(AiOperationType.IMAGE.value()) + .provider(AiProvider.OLLAMA.value()) + .build(); + } + +} \ No newline at end of file diff --git a/spring-ai-core/src/test/java/org/springframework/ai/image/observation/ImageModelObservationContextTests.java b/spring-ai-core/src/test/java/org/springframework/ai/image/observation/ImageModelObservationContextTests.java new file mode 100644 index 000000000..8ab69b6ef --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/image/observation/ImageModelObservationContextTests.java @@ -0,0 +1,66 @@ +/* + * 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.image.observation; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.image.ImagePrompt; +import org.springframework.ai.observation.AiOperationMetadata; +import org.springframework.ai.observation.conventions.AiOperationType; +import org.springframework.ai.observation.conventions.AiProvider; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link ImageModelObservationContext}. + * + * @author Thomas Vitale + */ +class ImageModelObservationContextTests { + + @Test + void whenMandatoryRequestOptionsThenReturn() { + var observationContext = ImageModelObservationContext.builder() + .imagePrompt(generateImagePrompt()) + .operationMetadata(generateOperationMetadata()) + .requestOptions(ImageModelRequestOptions.builder().model("supersun").build()) + .build(); + + assertThat(observationContext).isNotNull(); + } + + @Test + void whenRequestOptionsIsNullThenThrow() { + assertThatThrownBy(() -> ImageModelObservationContext.builder() + .imagePrompt(generateImagePrompt()) + .operationMetadata(generateOperationMetadata()) + .requestOptions(null) + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("requestOptions cannot be null"); + } + + private ImagePrompt generateImagePrompt() { + return new ImagePrompt("here comes the sun"); + } + + private AiOperationMetadata generateOperationMetadata() { + return AiOperationMetadata.builder() + .operationType(AiOperationType.IMAGE.value()) + .provider(AiProvider.OLLAMA.value()) + .build(); + } + +} diff --git a/spring-ai-core/src/test/java/org/springframework/ai/image/observation/ImageModelPromptContentObservationFilterTests.java b/spring-ai-core/src/test/java/org/springframework/ai/image/observation/ImageModelPromptContentObservationFilterTests.java new file mode 100644 index 000000000..1b6b0c522 --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/image/observation/ImageModelPromptContentObservationFilterTests.java @@ -0,0 +1,96 @@ +/* + * 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.image.observation; + +import io.micrometer.common.KeyValue; +import io.micrometer.observation.Observation; +import org.junit.jupiter.api.Test; +import org.springframework.ai.image.ImageMessage; +import org.springframework.ai.image.ImagePrompt; +import org.springframework.ai.observation.AiOperationMetadata; +import org.springframework.ai.observation.conventions.AiObservationAttributes; +import org.springframework.ai.observation.conventions.AiOperationType; +import org.springframework.ai.observation.conventions.AiProvider; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link ImageModelPromptContentObservationFilter}. + * + * @author Thomas Vitale + */ +class ImageModelPromptContentObservationFilterTests { + + private final ImageModelPromptContentObservationFilter observationFilter = new ImageModelPromptContentObservationFilter(); + + @Test + void whenNotSupportedObservationContextThenReturnOriginalContext() { + var expectedContext = new Observation.Context(); + var actualContext = observationFilter.map(expectedContext); + + assertThat(actualContext).isEqualTo(expectedContext); + } + + @Test + void whenEmptyPromptThenReturnOriginalContext() { + var expectedContext = ImageModelObservationContext.builder() + .imagePrompt(new ImagePrompt("")) + .operationMetadata(generateOperationMetadata()) + .requestOptions(ImageModelRequestOptions.builder().model("mistral").build()) + .build(); + var actualContext = observationFilter.map(expectedContext); + + assertThat(actualContext).isEqualTo(expectedContext); + } + + @Test + void whenPromptWithTextThenAugmentContext() { + var originalContext = ImageModelObservationContext.builder() + .imagePrompt(new ImagePrompt("supercalifragilisticexpialidocious")) + .operationMetadata(generateOperationMetadata()) + .requestOptions(ImageModelRequestOptions.builder().model("mistral").build()) + .build(); + var augmentedContext = observationFilter.map(originalContext); + + assertThat(augmentedContext.getHighCardinalityKeyValues()) + .contains(KeyValue.of(AiObservationAttributes.PROMPT.value(), "[\"supercalifragilisticexpialidocious\"]")); + } + + @Test + void whenPromptWithMessagesThenAugmentContext() { + var originalContext = ImageModelObservationContext.builder() + .imagePrompt(new ImagePrompt(List.of(new ImageMessage("you're a chimney sweep"), + new ImageMessage("supercalifragilisticexpialidocious")))) + .operationMetadata(generateOperationMetadata()) + .requestOptions(ImageModelRequestOptions.builder().model("mistral").build()) + .build(); + var augmentedContext = observationFilter.map(originalContext); + + assertThat(augmentedContext.getHighCardinalityKeyValues()) + .contains(KeyValue.of(AiObservationAttributes.PROMPT.value(), + "[\"you're a chimney sweep\", \"supercalifragilisticexpialidocious\"]")); + } + + private AiOperationMetadata generateOperationMetadata() { + return AiOperationMetadata.builder() + .operationType(AiOperationType.IMAGE.value()) + .provider(AiProvider.OLLAMA.value()) + .build(); + } + +} diff --git a/spring-ai-core/src/test/java/org/springframework/ai/image/observation/ImageModelRequestOptionsTests.java b/spring-ai-core/src/test/java/org/springframework/ai/image/observation/ImageModelRequestOptionsTests.java new file mode 100644 index 000000000..59c444c38 --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/image/observation/ImageModelRequestOptionsTests.java @@ -0,0 +1,51 @@ +/* + * 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.image.observation; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link ImageModelRequestOptions}. + * + * @author Thomas Vitale + */ +class ImageModelRequestOptionsTests { + + @Test + void whenMandatoryRequestOptionsThenReturn() { + var requestOptions = ImageModelRequestOptions.builder().model("rowena").build(); + + assertThat(requestOptions).isNotNull(); + } + + @Test + void whenModelIsNullThenThrow() { + assertThatThrownBy(() -> ImageModelRequestOptions.builder().build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("model cannot be null or empty"); + } + + @Test + void whenModelIsEmptyThenThrow() { + assertThatThrownBy(() -> ImageModelRequestOptions.builder().model("").build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("model cannot be null or empty"); + } + +} diff --git a/spring-ai-core/src/test/java/org/springframework/ai/model/observation/ModelObservationContextTests.java b/spring-ai-core/src/test/java/org/springframework/ai/model/observation/ModelObservationContextTests.java new file mode 100644 index 000000000..7bb47f11c --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/model/observation/ModelObservationContextTests.java @@ -0,0 +1,86 @@ +package org.springframework.ai.model.observation; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.observation.AiOperationMetadata; +import org.springframework.ai.observation.conventions.AiOperationType; +import org.springframework.ai.observation.conventions.AiProvider; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link ModelObservationContext}. + * + * @author Thomas Vitale + */ +class ModelObservationContextTests { + + @Test + void whenRequestAndMetadataThenReturn() { + var observationContext = new ModelObservationContext("test request", + AiOperationMetadata.builder() + .operationType(AiOperationType.CHAT.value()) + .provider(AiProvider.OLLAMA.value()) + .build()); + + assertThat(observationContext).isNotNull(); + } + + @Test + void whenRequestIsNullThenThrow() { + assertThatThrownBy(() -> new ModelObservationContext(null, + AiOperationMetadata.builder() + .operationType(AiOperationType.EMBEDDING.value()) + .provider(AiProvider.OLLAMA.value()) + .build())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("request cannot be null"); + } + + @Test + void whenOperationMetadataIsNullThenThrow() { + assertThatThrownBy(() -> new ModelObservationContext("test request", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("operationMetadata cannot be null"); + } + + @Test + void whenOperationMetadataIsMissingOperationTypeThenThrow() { + assertThatThrownBy(() -> new ModelObservationContext("test request", + AiOperationMetadata.builder().provider(AiProvider.OLLAMA.value()).build())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("operationType cannot be null or empty"); + } + + @Test + void whenOperationMetadataIsMissingProviderThenThrow() { + assertThatThrownBy(() -> new ModelObservationContext("test request", + AiOperationMetadata.builder().operationType(AiOperationType.IMAGE.value()).build())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("provider cannot be null or empty"); + } + + @Test + void whenResponseThenReturn() { + var observationContext = new ModelObservationContext("test request", + AiOperationMetadata.builder() + .operationType(AiOperationType.CHAT.value()) + .provider(AiProvider.OLLAMA.value()) + .build()); + observationContext.setResponse("test response"); + + assertThat(observationContext).isNotNull(); + } + + @Test + void whenResponseIsNullThenThrow() { + var observationContext = new ModelObservationContext("test request", + AiOperationMetadata.builder() + .operationType(AiOperationType.CHAT.value()) + .provider(AiProvider.OLLAMA.value()) + .build()); + assertThatThrownBy(() -> observationContext.setResponse(null)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("response cannot be null"); + } + +} diff --git a/spring-ai-core/src/test/java/org/springframework/ai/model/observation/ModelUsageMetricsGeneratorTests.java b/spring-ai-core/src/test/java/org/springframework/ai/model/observation/ModelUsageMetricsGeneratorTests.java new file mode 100644 index 000000000..53949df14 --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/model/observation/ModelUsageMetricsGeneratorTests.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.model.observation; + +import io.micrometer.common.KeyValue; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.micrometer.observation.Observation; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.metadata.Usage; +import org.springframework.ai.observation.conventions.AiObservationMetricAttributes; +import org.springframework.ai.observation.conventions.AiObservationMetricNames; +import org.springframework.ai.observation.conventions.AiTokenType; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link ModelUsageMetricsGenerator}. + * + * @author Thomas Vitale + */ +class ModelUsageMetricsGeneratorTests { + + @Test + void whenTokenUsageThenMetrics() { + var meterRegistry = new SimpleMeterRegistry(); + var usage = new TestUsage(1000L, 500L, 1500L); + ModelUsageMetricsGenerator.generate(usage, buildContext(), meterRegistry); + + assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value()).meters()).hasSize(3); + assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value()) + .tag(AiObservationMetricAttributes.TOKEN_TYPE.value(), AiTokenType.INPUT.value()) + .counter() + .count()).isEqualTo(1000); + assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value()) + .tag(AiObservationMetricAttributes.TOKEN_TYPE.value(), AiTokenType.OUTPUT.value()) + .counter() + .count()).isEqualTo(500); + assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value()) + .tag(AiObservationMetricAttributes.TOKEN_TYPE.value(), AiTokenType.TOTAL.value()) + .counter() + .count()).isEqualTo(1500); + } + + @Test + void whenPartialTokenUsageThenMetrics() { + var meterRegistry = new SimpleMeterRegistry(); + var usage = new TestUsage(1000L, null, 1000L); + ModelUsageMetricsGenerator.generate(usage, buildContext(), meterRegistry); + + assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value()).meters()).hasSize(2); + assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value()) + .tag(AiObservationMetricAttributes.TOKEN_TYPE.value(), AiTokenType.INPUT.value()) + .counter() + .count()).isEqualTo(1000); + assertThat(meterRegistry.get(AiObservationMetricNames.TOKEN_USAGE.value()) + .tag(AiObservationMetricAttributes.TOKEN_TYPE.value(), AiTokenType.TOTAL.value()) + .counter() + .count()).isEqualTo(1000); + } + + private Observation.Context buildContext() { + var context = new Observation.Context(); + context.addLowCardinalityKeyValue(KeyValue.of("key1", "value1")); + context.addLowCardinalityKeyValue(KeyValue.of("key2", "value2")); + return context; + } + + static class TestUsage implements Usage { + + private final Long promptTokens; + + private final Long generationTokens; + + private final Long totalTokens; + + public TestUsage(Long promptTokens, Long generationTokens, Long totalTokens) { + this.promptTokens = promptTokens; + this.generationTokens = generationTokens; + this.totalTokens = totalTokens; + } + + @Override + public Long getPromptTokens() { + return promptTokens; + } + + @Override + public Long getGenerationTokens() { + return generationTokens; + } + + @Override + public Long getTotalTokens() { + return totalTokens; + } + + } + +} diff --git a/spring-ai-core/src/test/java/org/springframework/ai/observation/AiOperationMetadataTests.java b/spring-ai-core/src/test/java/org/springframework/ai/observation/AiOperationMetadataTests.java new file mode 100644 index 000000000..59e822ddf --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/observation/AiOperationMetadataTests.java @@ -0,0 +1,65 @@ +/* + * 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.observation; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link AiOperationMetadata}. + * + * @author Thomas Vitale + */ +class AiOperationMetadataTests { + + @Test + void whenMandatoryMetadataThenReturn() { + var operationMetadata = AiOperationMetadata.builder().operationType("chat").provider("doofenshmirtz").build(); + + assertThat(operationMetadata).isNotNull(); + } + + @Test + void whenOperationTypeIsNullThenThrow() { + assertThatThrownBy(() -> AiOperationMetadata.builder().provider("doofenshmirtz").build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("operationType cannot be null or empty"); + } + + @Test + void whenOperationTypeIsEmptyThenThrow() { + assertThatThrownBy(() -> AiOperationMetadata.builder().operationType("").provider("doofenshmirtz").build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("operationType cannot be null or empty"); + } + + @Test + void whenProviderIsNullThenThrow() { + assertThatThrownBy(() -> AiOperationMetadata.builder().operationType("chat").build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("provider cannot be null or empty"); + } + + @Test + void whenProviderIsEmptyThenThrow() { + assertThatThrownBy(() -> AiOperationMetadata.builder().operationType("chat").provider("").build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("provider cannot be null or empty"); + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfiguration.java new file mode 100644 index 000000000..a2750b4ac --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfiguration.java @@ -0,0 +1,74 @@ +/* + * 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.autoconfigure.chat.observation; + +import io.micrometer.core.instrument.MeterRegistry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.observation.ChatModelCompletionObservationFilter; +import org.springframework.ai.chat.observation.ChatModelMeterObservationHandler; +import org.springframework.ai.chat.observation.ChatModelPromptContentObservationFilter; +import org.springframework.beans.factory.ObjectProvider; +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; + +/** + * Auto-configuration for Spring AI chat model observations. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +@AutoConfiguration( + afterName = "org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration.class") +@ConditionalOnClass(ChatModel.class) +@EnableConfigurationProperties({ ChatObservationProperties.class }) +public class ChatObservationAutoConfiguration { + + private static final Logger logger = LoggerFactory.getLogger(ChatObservationAutoConfiguration.class); + + @Bean + @ConditionalOnMissingBean + @ConditionalOnClass(MeterRegistry.class) + ChatModelMeterObservationHandler chatModelMeterObservationHandler(ObjectProvider meterRegistry) { + return new ChatModelMeterObservationHandler(meterRegistry.getObject()); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = ChatObservationProperties.CONFIG_PREFIX, name = "include-prompt", + havingValue = "true") + ChatModelPromptContentObservationFilter chatModelPromptObservationFilter() { + logger.warn( + "You have enabled the inclusion of the prompt content in the observations, with the risk of exposing sensitive or private information. Please, be careful!"); + return new ChatModelPromptContentObservationFilter(); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = ChatObservationProperties.CONFIG_PREFIX, name = "include-completion", + havingValue = "true") + ChatModelCompletionObservationFilter chatModelCompletionObservationFilter() { + logger.warn( + "You have enabled the inclusion of the completion content in the observations, with the risk of exposing sensitive or private information. Please, be careful!"); + return new ChatModelCompletionObservationFilter(); + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationProperties.java new file mode 100644 index 000000000..1ae6637c2 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationProperties.java @@ -0,0 +1,57 @@ +/* + * 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.autoconfigure.chat.observation; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for chat model observations. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +@ConfigurationProperties(ChatObservationProperties.CONFIG_PREFIX) +public class ChatObservationProperties { + + public static final String CONFIG_PREFIX = "spring.ai.chat.observations"; + + /** + * Whether to include the completion content in the observations. + */ + private boolean includeCompletion = false; + + /** + * Whether to include the prompt content in the observations. + */ + private boolean includePrompt = false; + + public boolean isIncludeCompletion() { + return includeCompletion; + } + + public void setIncludeCompletion(boolean includeCompletion) { + this.includeCompletion = includeCompletion; + } + + public boolean isIncludePrompt() { + return includePrompt; + } + + public void setIncludePrompt(boolean includePrompt) { + this.includePrompt = includePrompt; + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/package-info.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/package-info.java new file mode 100644 index 000000000..5d159e12a --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/package-info.java @@ -0,0 +1,22 @@ +/* + * 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. + */ + +@NonNullApi +@NonNullFields +package org.springframework.ai.autoconfigure.chat.observation; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/embedding/observation/EmbeddingObservationAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/embedding/observation/EmbeddingObservationAutoConfiguration.java new file mode 100644 index 000000000..8d031d014 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/embedding/observation/EmbeddingObservationAutoConfiguration.java @@ -0,0 +1,46 @@ +/* + * 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.autoconfigure.embedding.observation; + +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.embedding.observation.EmbeddingModelMeterObservationHandler; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; + +/** + * Auto-configuration for Spring AI embedding model observations. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +@AutoConfiguration( + afterName = "org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration.class") +@ConditionalOnClass(EmbeddingModel.class) +public class EmbeddingObservationAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + @ConditionalOnClass(MeterRegistry.class) + EmbeddingModelMeterObservationHandler embeddingModelMeterObservationHandler( + ObjectProvider meterRegistry) { + return new EmbeddingModelMeterObservationHandler(meterRegistry.getObject()); + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/embedding/observation/package-info.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/embedding/observation/package-info.java new file mode 100644 index 000000000..1d7239f59 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/embedding/observation/package-info.java @@ -0,0 +1,22 @@ +/* + * 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. + */ + +@NonNullApi +@NonNullFields +package org.springframework.ai.autoconfigure.embedding.observation; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/image/observation/ImageObservationAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/image/observation/ImageObservationAutoConfiguration.java new file mode 100644 index 000000000..e54333f1a --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/image/observation/ImageObservationAutoConfiguration.java @@ -0,0 +1,53 @@ +/* + * 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.autoconfigure.image.observation; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.image.ImageModel; +import org.springframework.ai.image.observation.ImageModelPromptContentObservationFilter; +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; + +/** + * Auto-configuration for Spring AI image model observations. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +@AutoConfiguration( + afterName = "org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration.class") +@ConditionalOnClass(ImageModel.class) +@EnableConfigurationProperties({ ImageObservationProperties.class }) +public class ImageObservationAutoConfiguration { + + private static final Logger logger = LoggerFactory.getLogger(ImageObservationAutoConfiguration.class); + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = ImageObservationProperties.CONFIG_PREFIX, name = "include-prompt", + havingValue = "true") + ImageModelPromptContentObservationFilter imageModelPromptObservationFilter() { + logger.warn( + "You have enabled the inclusion of the image prompt content in the observations, with the risk of exposing sensitive or private information. Please, be careful!"); + return new ImageModelPromptContentObservationFilter(); + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/image/observation/ImageObservationProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/image/observation/ImageObservationProperties.java new file mode 100644 index 000000000..5663e2854 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/image/observation/ImageObservationProperties.java @@ -0,0 +1,44 @@ +/* + * 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.autoconfigure.image.observation; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for image model observations. + * + * @author Thomas Vitale + * @since 1.0.0 + */ +@ConfigurationProperties(ImageObservationProperties.CONFIG_PREFIX) +public class ImageObservationProperties { + + public static final String CONFIG_PREFIX = "spring.ai.image.observations"; + + /** + * Whether to include the prompt content in the observations. + */ + private boolean includePrompt = false; + + public boolean isIncludePrompt() { + return includePrompt; + } + + public void setIncludePrompt(boolean includePrompt) { + this.includePrompt = includePrompt; + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/image/observation/package-info.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/image/observation/package-info.java new file mode 100644 index 000000000..f95f9e63c --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/image/observation/package-info.java @@ -0,0 +1,22 @@ +/* + * 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. + */ + +@NonNullApi +@NonNullFields +package org.springframework.ai.autoconfigure.image.observation; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfiguration.java index 4ac30e0c6..27f8c49ca 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfiguration.java @@ -15,20 +15,18 @@ */ package org.springframework.ai.autoconfigure.openai; -import java.util.List; - -import org.jetbrains.annotations.NotNull; +import io.micrometer.observation.ObservationRegistry; import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.chat.observation.ChatModelObservationConvention; +import org.springframework.ai.embedding.observation.EmbeddingModelObservationConvention; +import org.springframework.ai.image.observation.ImageModelObservationConvention; import org.springframework.ai.model.function.FunctionCallback; import org.springframework.ai.model.function.FunctionCallbackContext; -import org.springframework.ai.openai.OpenAiAudioSpeechModel; -import org.springframework.ai.openai.OpenAiAudioTranscriptionModel; -import org.springframework.ai.openai.OpenAiChatModel; -import org.springframework.ai.openai.OpenAiEmbeddingModel; -import org.springframework.ai.openai.OpenAiImageModel; +import org.springframework.ai.openai.*; import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.ai.openai.api.OpenAiAudioApi; import org.springframework.ai.openai.api.OpenAiImageApi; +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 +37,7 @@ import org.springframework.boot.autoconfigure.web.reactive.function.client.WebCl import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; +import org.springframework.lang.NonNull; import org.springframework.retry.support.RetryTemplate; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -46,6 +45,8 @@ import org.springframework.web.client.ResponseErrorHandler; import org.springframework.web.client.RestClient; import org.springframework.web.reactive.function.client.WebClient; +import java.util.List; + /** * @author Christian Tzolov * @author Stefan Vassilev @@ -69,13 +70,18 @@ public class OpenAiAutoConfiguration { OpenAiChatProperties chatProperties, RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder, List toolFunctionCallbacks, FunctionCallbackContext functionCallbackContext, RetryTemplate retryTemplate, - ResponseErrorHandler responseErrorHandler) { + ResponseErrorHandler responseErrorHandler, ObjectProvider observationRegistry, + ObjectProvider observationConvention) { var openAiApi = openAiApi(chatProperties, commonProperties, restClientBuilder, webClientBuilder, responseErrorHandler, "chat"); - return new OpenAiChatModel(openAiApi, chatProperties.getOptions(), functionCallbackContext, - toolFunctionCallbacks, retryTemplate); + var chatModel = new OpenAiChatModel(openAiApi, chatProperties.getOptions(), functionCallbackContext, + toolFunctionCallbacks, retryTemplate, observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)); + + observationConvention.ifAvailable(chatModel::setObservationConvention); + + return chatModel; } @Bean @@ -84,14 +90,20 @@ public class OpenAiAutoConfiguration { matchIfMissing = true) public OpenAiEmbeddingModel openAiEmbeddingModel(OpenAiConnectionProperties commonProperties, OpenAiEmbeddingProperties embeddingProperties, RestClient.Builder restClientBuilder, - WebClient.Builder webClientBuilder, RetryTemplate retryTemplate, - ResponseErrorHandler responseErrorHandler) { + WebClient.Builder webClientBuilder, RetryTemplate retryTemplate, ResponseErrorHandler responseErrorHandler, + ObjectProvider observationRegistry, + ObjectProvider observationConvention) { var openAiApi = openAiApi(embeddingProperties, commonProperties, restClientBuilder, webClientBuilder, responseErrorHandler, "embedding"); - return new OpenAiEmbeddingModel(openAiApi, embeddingProperties.getMetadataMode(), - embeddingProperties.getOptions(), retryTemplate); + var embeddingModel = new OpenAiEmbeddingModel(openAiApi, embeddingProperties.getMetadataMode(), + embeddingProperties.getOptions(), retryTemplate, + observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)); + + observationConvention.ifAvailable(embeddingModel::setObservationConvention); + + return embeddingModel; } private OpenAiApi openAiApi(OpenAiChatProperties chatProperties, OpenAiConnectionProperties commonProperties, @@ -116,7 +128,7 @@ public class OpenAiAutoConfiguration { restClientBuilder, webClientBuilder, responseErrorHandler); } - private static @NotNull ResolvedBaseUrlAndApiKey getResolvedBaseUrlAndApiKey(String baseUrl, String apiKey, + private static @NonNull ResolvedBaseUrlAndApiKey getResolvedBaseUrlAndApiKey(String baseUrl, String apiKey, OpenAiConnectionProperties commonProperties, String modelType) { var commonBaseUrl = commonProperties.getBaseUrl(); var commonApiKey = commonProperties.getApiKey(); @@ -142,7 +154,8 @@ public class OpenAiAutoConfiguration { matchIfMissing = true) public OpenAiImageModel openAiImageModel(OpenAiConnectionProperties commonProperties, OpenAiImageProperties imageProperties, RestClient.Builder restClientBuilder, RetryTemplate retryTemplate, - ResponseErrorHandler responseErrorHandler) { + ResponseErrorHandler responseErrorHandler, ObjectProvider observationRegistry, + ObjectProvider observationConvention) { String apiKey = StringUtils.hasText(imageProperties.getApiKey()) ? imageProperties.getApiKey() : commonProperties.getApiKey(); @@ -157,7 +170,12 @@ public class OpenAiAutoConfiguration { var openAiImageApi = new OpenAiImageApi(baseUrl, apiKey, restClientBuilder, responseErrorHandler); - return new OpenAiImageModel(openAiImageApi, imageProperties.getOptions(), retryTemplate); + var imageModel = new OpenAiImageModel(openAiImageApi, imageProperties.getOptions(), retryTemplate, + observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)); + + observationConvention.ifAvailable(imageModel::setObservationConvention); + + return imageModel; } @Bean diff --git a/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 041b8c245..1c74d1a0a 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -13,6 +13,9 @@ org.springframework.ai.autoconfigure.bedrock.anthropic.BedrockAnthropicChatAutoC org.springframework.ai.autoconfigure.bedrock.anthropic3.BedrockAnthropic3ChatAutoConfiguration org.springframework.ai.autoconfigure.bedrock.titan.BedrockTitanChatAutoConfiguration org.springframework.ai.autoconfigure.bedrock.titan.BedrockTitanEmbeddingAutoConfiguration +org.springframework.ai.autoconfigure.chat.observation.ChatObservationAutoConfiguration +org.springframework.ai.autoconfigure.embedding.observation.EmbeddingObservationAutoConfiguration +org.springframework.ai.autoconfigure.image.observation.ImageObservationAutoConfiguration org.springframework.ai.autoconfigure.ollama.OllamaAutoConfiguration org.springframework.ai.autoconfigure.mistralai.MistralAiAutoConfiguration org.springframework.ai.autoconfigure.vectorstore.oracle.OracleVectorStoreAutoConfiguration diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfigurationTests.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfigurationTests.java new file mode 100644 index 000000000..3fe662136 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfigurationTests.java @@ -0,0 +1,74 @@ +/* + * 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.autoconfigure.chat.observation; + +import io.micrometer.core.instrument.composite.CompositeMeterRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.observation.ChatModelCompletionObservationFilter; +import org.springframework.ai.chat.observation.ChatModelMeterObservationHandler; +import org.springframework.ai.chat.observation.ChatModelPromptContentObservationFilter; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link ChatObservationAutoConfiguration}. + * + * @author Thomas Vitale + */ +class ChatObservationAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ChatObservationAutoConfiguration.class)) + .withBean(CompositeMeterRegistry.class); + + @Test + void meterObservationHandler() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(ChatModelMeterObservationHandler.class); + }); + } + + @Test + void promptFilterDefault() { + contextRunner.run(context -> { + assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationFilter.class); + }); + } + + @Test + void promptFilterEnabled() { + contextRunner.withPropertyValues("spring.ai.chat.observations.include-prompt=true").run(context -> { + assertThat(context).hasSingleBean(ChatModelPromptContentObservationFilter.class); + }); + } + + @Test + void completionFilterDefault() { + contextRunner.run(context -> { + assertThat(context).doesNotHaveBean(ChatModelCompletionObservationFilter.class); + }); + } + + @Test + void completionFilterEnabled() { + contextRunner.withPropertyValues("spring.ai.chat.observations.include-completion=true").run(context -> { + assertThat(context).hasSingleBean(ChatModelCompletionObservationFilter.class); + }); + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/embedding/observation/EmbeddingObservationAutoConfigurationTests.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/embedding/observation/EmbeddingObservationAutoConfigurationTests.java new file mode 100644 index 000000000..a0747553a --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/embedding/observation/EmbeddingObservationAutoConfigurationTests.java @@ -0,0 +1,44 @@ +/* + * 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.autoconfigure.embedding.observation; + +import io.micrometer.core.instrument.composite.CompositeMeterRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.ai.embedding.observation.EmbeddingModelMeterObservationHandler; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link EmbeddingObservationAutoConfiguration}. + * + * @author Thomas Vitale + */ +class EmbeddingObservationAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(EmbeddingObservationAutoConfiguration.class)) + .withBean(CompositeMeterRegistry.class); + + @Test + void meterObservationHandler() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(EmbeddingModelMeterObservationHandler.class); + }); + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/image/observation/ImageObservationAutoConfigurationTests.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/image/observation/ImageObservationAutoConfigurationTests.java new file mode 100644 index 000000000..0c26b992a --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/image/observation/ImageObservationAutoConfigurationTests.java @@ -0,0 +1,49 @@ +/* + * 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.autoconfigure.image.observation; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.image.observation.ImageModelPromptContentObservationFilter; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link ImageObservationAutoConfiguration}. + * + * @author Thomas Vitale + */ +class ImageObservationAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ImageObservationAutoConfiguration.class)); + + @Test + void promptFilterDefault() { + contextRunner.run(context -> { + assertThat(context).doesNotHaveBean(ImageModelPromptContentObservationFilter.class); + }); + } + + @Test + void promptFilterEnabled() { + contextRunner.withPropertyValues("spring.ai.image.observations.include-prompt=true").run(context -> { + assertThat(context).hasSingleBean(ImageModelPromptContentObservationFilter.class); + }); + } + +}