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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user