Initial observability for Spring AI

* Observation APIs for chat, embedding and image models
* Conventions based on OpenTelemetry Semantic Conventions for GenAI
* Instrumentation for OpenAI chat, embedding, and image models
* Autoconfiguration for observability for OpenAI

Fixes gh-953

Signed-off-by: Thomas Vitale <ThomasVitale@users.noreply.github.com>
This commit is contained in:
Thomas Vitale
2024-08-01 22:25:43 +02:00
parent a077e1b952
commit 6bdb395686
77 changed files with 5801 additions and 85 deletions

View File

@@ -88,6 +88,12 @@
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-observation-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>qdrant</artifactId>

View File

@@ -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<FunctionCallback> 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<FunctionCallback> 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<ChatCompletion> 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<ChatCompletion> completionEntity = this.retryTemplate
.execute(ctx -> this.openAiApi.chatCompletionEntity(request));
List<Choice> choices = chatCompletion.choices();
if (choices == null) {
logger.warn("No choices returned for prompt: {}", prompt);
return new ChatResponse(List.of());
}
var chatCompletion = completionEntity.getBody();
List<Generation> generations = choices.stream().map(choice -> {
if (chatCompletion == null) {
logger.warn("No chat completion returned for prompt: {}", prompt);
return new ChatResponse(List.of());
}
List<Choice> choices = chatCompletion.choices();
if (choices == null) {
logger.warn("No choices returned for prompt: {}", prompt);
return new ChatResponse(List.of());
}
List<Generation> generations = choices.stream().map(choice -> {
// @formatter:off
Map<String, Object> 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<String, Object> 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;
}
}

View File

@@ -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<List<String>> apiRequest = createRequest(request);
EmbeddingList<OpenAiApi.Embedding> 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<OpenAiApi.Embedding> 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<Embedding> 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<Embedding> 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<List<String>> 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;
}
}

View File

@@ -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<OpenAiImageApi.OpenAiImageResponse> 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<OpenAiImageApi.OpenAiImageResponse> 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;
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}
}

View File

@@ -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;

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;

View File

@@ -79,6 +79,11 @@
<artifactId>spring-web</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-core</artifactId>
</dependency>
<dependency>
<groupId>com.knuddels</groupId>
<artifactId>jtokkit</artifactId>

View File

@@ -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;
}
}

View File

@@ -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<ChatModelObservationContext> {
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;
}
}

View File

@@ -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<Prompt, ChatResponse> {
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);
}
}
}

View File

@@ -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<ChatModelObservationContext> {
@Override
default boolean supportsContext(Observation.Context context) {
return context instanceof ChatModelObservationContext;
}
}

View File

@@ -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();
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -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<String> 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<String> 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<String> 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<String> 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();
}
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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<EmbeddingModelObservationContext> {
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;
}
}

View File

@@ -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<EmbeddingRequest, EmbeddingResponse> {
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);
}
}
}

View File

@@ -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<EmbeddingModelObservationContext> {
@Override
default boolean supportsContext(Observation.Context context) {
return context instanceof EmbeddingModelObservationContext;
}
}

View File

@@ -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();
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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<ImagePrompt, ImageResponse> {
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);
}
}
}

View File

@@ -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<ImageModelObservationContext> {
@Override
default boolean supportsContext(Observation.Context context) {
return context instanceof ImageModelObservationContext;
}
}

View File

@@ -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();
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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 <REQ> type of the request object
* @param <RES> type of the response object
* @author Thomas Vitale
* @since 1.0.0
*/
public class ModelObservationContext<REQ, RES> 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;
}
}

View File

@@ -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<Tag> createTags(Observation.Context context) {
List<Tag> tags = new ArrayList<>();
for (KeyValue keyValue : context.getLowCardinalityKeyValues()) {
tags.add(Tag.of(keyValue.getKey(), keyValue.getValue()));
}
return tags;
}
}

View File

@@ -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;

View File

@@ -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);
}
}
}

View File

@@ -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 <a href=
* "https://github.com/open-telemetry/semantic-conventions/tree/main/docs/gen-ai">OTel
* Semantic Conventions</a>.
*/
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
}

View File

@@ -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 <a href=
* "https://github.com/open-telemetry/semantic-conventions/tree/main/docs/gen-ai">OTel
* Semantic Conventions</a>.
*/
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
}

View File

@@ -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 <a href=
* "https://github.com/open-telemetry/semantic-conventions/tree/main/docs/gen-ai">OTel
* Semantic Conventions</a>.
*/
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
}

View File

@@ -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 <a href=
* "https://github.com/open-telemetry/semantic-conventions/tree/main/docs/gen-ai">OTel
* Semantic Conventions</a>.
*/
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
}

View File

@@ -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 <a href=
* "https://github.com/open-telemetry/semantic-conventions/tree/main/docs/gen-ai">OTel
* Semantic Conventions</a>.
*/
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
}

View File

@@ -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 <a href=
* "https://github.com/open-telemetry/semantic-conventions/tree/main/docs/gen-ai">OTel
* Semantic Conventions</a>.
*/
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
}

View File

@@ -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 <a href=
* "https://github.com/open-telemetry/semantic-conventions/tree/main/docs/gen-ai">OTel
* Semantic Conventions</a>.
*/
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
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();
}
}

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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");
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}

View File

@@ -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");
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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");
}
}

View File

@@ -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<String, String>("test request",
AiOperationMetadata.builder()
.operationType(AiOperationType.CHAT.value())
.provider(AiProvider.OLLAMA.value())
.build());
assertThat(observationContext).isNotNull();
}
@Test
void whenRequestIsNullThenThrow() {
assertThatThrownBy(() -> new ModelObservationContext<String, String>(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<String, String>("test request", null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("operationMetadata cannot be null");
}
@Test
void whenOperationMetadataIsMissingOperationTypeThenThrow() {
assertThatThrownBy(() -> new ModelObservationContext<String, String>("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<String, String>("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<String, String>("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<String, String>("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");
}
}

View File

@@ -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;
}
}
}

View File

@@ -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");
}
}

View File

@@ -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> 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();
}
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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> meterRegistry) {
return new EmbeddingModelMeterObservationHandler(meterRegistry.getObject());
}
}

View File

@@ -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;

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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<FunctionCallback> toolFunctionCallbacks,
FunctionCallbackContext functionCallbackContext, RetryTemplate retryTemplate,
ResponseErrorHandler responseErrorHandler) {
ResponseErrorHandler responseErrorHandler, ObjectProvider<ObservationRegistry> observationRegistry,
ObjectProvider<ChatModelObservationConvention> 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> observationRegistry,
ObjectProvider<EmbeddingModelObservationConvention> 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> observationRegistry,
ObjectProvider<ImageModelObservationConvention> 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

View File

@@ -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

View File

@@ -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);
});
}
}

View File

@@ -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);
});
}
}

View File

@@ -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);
});
}
}