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 extends ObservationConvention extends Observation.Context>> 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 extends ObservationConvention extends Observation.Context>> 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 extends ObservationConvention extends Observation.Context>> 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);
+ });
+ }
+
+}