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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user