Initial observability for Spring AI

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

Fixes gh-953

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

View File

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

View File

@@ -15,14 +15,7 @@
*/
package org.springframework.ai.openai;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import io.micrometer.observation.ObservationRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.messages.AssistantMessage;
@@ -33,16 +26,16 @@ import org.springframework.ai.chat.metadata.ChatGenerationMetadata;
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
import org.springframework.ai.chat.metadata.EmptyUsage;
import org.springframework.ai.chat.metadata.RateLimit;
import org.springframework.ai.chat.model.AbstractToolCallSupport;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.model.StreamingChatModel;
import org.springframework.ai.chat.model.*;
import org.springframework.ai.chat.observation.*;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.ModelOptionsUtils;
import org.springframework.ai.model.function.FunctionCallback;
import org.springframework.ai.model.function.FunctionCallbackContext;
import org.springframework.ai.observation.AiOperationMetadata;
import org.springframework.ai.observation.conventions.AiOperationType;
import org.springframework.ai.observation.conventions.AiProvider;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.openai.api.OpenAiApi.ChatCompletion;
import org.springframework.ai.openai.api.OpenAiApi.ChatCompletion.Choice;
@@ -59,10 +52,13 @@ import org.springframework.retry.support.RetryTemplate;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.MimeType;
import org.springframework.util.StringUtils;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* {@link ChatModel} and {@link StreamingChatModel} implementation for {@literal OpenAI}
* backed by {@link OpenAiApi}.
@@ -86,6 +82,8 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode
private static final Logger logger = LoggerFactory.getLogger(OpenAiChatModel.class);
private static final ChatModelObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultChatModelObservationConvention();
/**
* The default options used for the chat completion requests.
*/
@@ -101,6 +99,16 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode
*/
private final OpenAiApi openAiApi;
/**
* Observation registry used for instrumentation.
*/
private final ObservationRegistry observationRegistry;
/**
* Conventions to use for generating observations.
*/
private ChatModelObservationConvention observationConvention = DEFAULT_OBSERVATION_CONVENTION;
/**
* Creates an instance of the OpenAiChatModel.
* @param openAiApi The OpenAiApi instance to be used for interacting with the OpenAI
@@ -147,6 +155,23 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode
public OpenAiChatModel(OpenAiApi openAiApi, OpenAiChatOptions options,
FunctionCallbackContext functionCallbackContext, List<FunctionCallback> toolFunctionCallbacks,
RetryTemplate retryTemplate) {
this(openAiApi, options, functionCallbackContext, toolFunctionCallbacks, retryTemplate,
ObservationRegistry.NOOP);
}
/**
* Initializes a new instance of the OpenAiChatModel.
* @param openAiApi The OpenAiApi instance to be used for interacting with the OpenAI
* Chat API.
* @param options The OpenAiChatOptions to configure the chat model.
* @param functionCallbackContext The function callback context.
* @param toolFunctionCallbacks The tool function callbacks.
* @param retryTemplate The retry template.
* @param observationRegistry The ObservationRegistry used for instrumentation.
*/
public OpenAiChatModel(OpenAiApi openAiApi, OpenAiChatOptions options,
FunctionCallbackContext functionCallbackContext, List<FunctionCallback> toolFunctionCallbacks,
RetryTemplate retryTemplate, ObservationRegistry observationRegistry) {
super(functionCallbackContext, options, toolFunctionCallbacks);
Assert.notNull(openAiApi, "OpenAiApi must not be null");
@@ -154,10 +179,12 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode
Assert.notNull(retryTemplate, "RetryTemplate must not be null");
Assert.isTrue(CollectionUtils.isEmpty(options.getFunctionCallbacks()),
"The default function callbacks must be set via the toolFunctionCallbacks constructor parameter");
Assert.notNull(observationRegistry, "ObservationRegistry must not be null");
this.openAiApi = openAiApi;
this.defaultOptions = options;
this.retryTemplate = retryTemplate;
this.observationRegistry = observationRegistry;
}
@Override
@@ -165,47 +192,64 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode
ChatCompletionRequest request = createRequest(prompt, false);
ResponseEntity<ChatCompletion> completionEntity = this.retryTemplate
.execute(ctx -> this.openAiApi.chatCompletionEntity(request));
ChatModelObservationContext observationContext = ChatModelObservationContext.builder()
.prompt(prompt)
.operationMetadata(buildOperationMetadata())
.requestOptions(buildRequestOptions(request))
.build();
var chatCompletion = completionEntity.getBody();
ChatResponse response = ChatModelObservationDocumentation.CHAT_MODEL_OPERATION
.observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext,
this.observationRegistry)
.observe(() -> {
if (chatCompletion == null) {
logger.warn("No chat completion returned for prompt: {}", prompt);
return new ChatResponse(List.of());
}
ResponseEntity<ChatCompletion> completionEntity = this.retryTemplate
.execute(ctx -> this.openAiApi.chatCompletionEntity(request));
List<Choice> choices = chatCompletion.choices();
if (choices == null) {
logger.warn("No choices returned for prompt: {}", prompt);
return new ChatResponse(List.of());
}
var chatCompletion = completionEntity.getBody();
List<Generation> generations = choices.stream().map(choice -> {
if (chatCompletion == null) {
logger.warn("No chat completion returned for prompt: {}", prompt);
return new ChatResponse(List.of());
}
List<Choice> choices = chatCompletion.choices();
if (choices == null) {
logger.warn("No choices returned for prompt: {}", prompt);
return new ChatResponse(List.of());
}
List<Generation> generations = choices.stream().map(choice -> {
// @formatter:off
Map<String, Object> metadata = Map.of(
"id", chatCompletion.id() != null ? chatCompletion.id() : "",
"role", choice.message().role() != null ? choice.message().role().name() : "",
"index", choice.index(),
"finishReason", choice.finishReason() != null ? choice.finishReason().name() : "");
// @formatter:on
return buildGeneration(choice, metadata);
}).toList();
Map<String, Object> metadata = Map.of(
"id", chatCompletion.id() != null ? chatCompletion.id() : "",
"role", choice.message().role() != null ? choice.message().role().name() : "",
"index", choice.index(),
"finishReason", choice.finishReason() != null ? choice.finishReason().name() : "");
// @formatter:on
return buildGeneration(choice, metadata);
}).toList();
// Non function calling.
RateLimit rateLimit = OpenAiResponseHeaderExtractor.extractAiResponseHeaders(completionEntity);
// Non function calling.
RateLimit rateLimit = OpenAiResponseHeaderExtractor.extractAiResponseHeaders(completionEntity);
ChatResponse chatResponse = new ChatResponse(generations, from(completionEntity.getBody(), rateLimit));
ChatResponse chatResponse = new ChatResponse(generations, from(completionEntity.getBody(), rateLimit));
if (isToolCall(chatResponse, Set.of(OpenAiApi.ChatCompletionFinishReason.TOOL_CALLS.name(),
observationContext.setResponse(chatResponse);
return chatResponse;
});
if (response != null && isToolCall(response, Set.of(OpenAiApi.ChatCompletionFinishReason.TOOL_CALLS.name(),
OpenAiApi.ChatCompletionFinishReason.STOP.name()))) {
var toolCallConversation = handleToolCalls(prompt, chatResponse);
var toolCallConversation = handleToolCalls(prompt, response);
// Recursively call the call method with the tool call message
// conversation that contains the call responses.
return this.call(new Prompt(toolCallConversation, prompt.getOptions()));
}
return chatResponse;
return response;
}
@Override
@@ -434,6 +478,25 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode
}).toList();
}
private AiOperationMetadata buildOperationMetadata() {
return AiOperationMetadata.builder()
.operationType(AiOperationType.CHAT.value())
.provider(AiProvider.OPENAI.value())
.build();
}
private ChatModelRequestOptions buildRequestOptions(OpenAiApi.ChatCompletionRequest request) {
return ChatModelRequestOptions.builder()
.model(StringUtils.hasText(request.model()) ? request.model() : "unknown")
.frequencyPenalty(request.frequencyPenalty())
.maxTokens(request.maxTokens())
.presencePenalty(request.presencePenalty())
.stopSequences(request.stop())
.temperature(request.temperature())
.topP(request.topP())
.build();
}
@Override
public ChatOptions getDefaultOptions() {
return OpenAiChatOptions.fromOptions(this.defaultOptions);
@@ -444,4 +507,13 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode
return "OpenAiChatModel [defaultOptions=" + defaultOptions + "]";
}
/**
* Use the provided convention for reporting observation data
* @param observationConvention The provided convention
*/
public void setObservationConvention(ChatModelObservationConvention observationConvention) {
Assert.notNull(observationConvention, "observationConvention cannot be null");
this.observationConvention = observationConvention;
}
}

View File

@@ -15,11 +15,9 @@
*/
package org.springframework.ai.openai;
import java.util.List;
import io.micrometer.observation.ObservationRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.document.Document;
import org.springframework.ai.document.MetadataMode;
import org.springframework.ai.embedding.AbstractEmbeddingModel;
@@ -28,13 +26,24 @@ import org.springframework.ai.embedding.EmbeddingOptions;
import org.springframework.ai.embedding.EmbeddingRequest;
import org.springframework.ai.embedding.EmbeddingResponse;
import org.springframework.ai.embedding.EmbeddingResponseMetadata;
import org.springframework.ai.embedding.observation.DefaultEmbeddingModelObservationConvention;
import org.springframework.ai.embedding.observation.EmbeddingModelObservationConvention;
import org.springframework.ai.embedding.observation.EmbeddingModelObservationDocumentation;
import org.springframework.ai.embedding.observation.EmbeddingModelObservationContext;
import org.springframework.ai.embedding.observation.EmbeddingModelRequestOptions;
import org.springframework.ai.model.ModelOptionsUtils;
import org.springframework.ai.observation.AiOperationMetadata;
import org.springframework.ai.observation.conventions.AiOperationType;
import org.springframework.ai.observation.conventions.AiProvider;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.openai.api.OpenAiApi.EmbeddingList;
import org.springframework.ai.openai.metadata.OpenAiUsage;
import org.springframework.ai.retry.RetryUtils;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import java.util.List;
/**
* Open AI Embedding Model implementation.
@@ -46,6 +55,8 @@ public class OpenAiEmbeddingModel extends AbstractEmbeddingModel {
private static final Logger logger = LoggerFactory.getLogger(OpenAiEmbeddingModel.class);
private static final EmbeddingModelObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultEmbeddingModelObservationConvention();
private final OpenAiEmbeddingOptions defaultOptions;
private final RetryTemplate retryTemplate;
@@ -54,6 +65,16 @@ public class OpenAiEmbeddingModel extends AbstractEmbeddingModel {
private final MetadataMode metadataMode;
/**
* Observation registry used for instrumentation.
*/
private final ObservationRegistry observationRegistry;
/**
* Conventions to use for generating observations.
*/
private EmbeddingModelObservationConvention observationConvention = DEFAULT_OBSERVATION_CONVENTION;
/**
* Constructor for the OpenAiEmbeddingModel class.
* @param openAiApi The OpenAiApi instance to use for making API requests.
@@ -92,15 +113,30 @@ public class OpenAiEmbeddingModel extends AbstractEmbeddingModel {
*/
public OpenAiEmbeddingModel(OpenAiApi openAiApi, MetadataMode metadataMode, OpenAiEmbeddingOptions options,
RetryTemplate retryTemplate) {
this(openAiApi, metadataMode, options, retryTemplate, ObservationRegistry.NOOP);
}
/**
* Initializes a new instance of the OpenAiEmbeddingModel class.
* @param openAiApi - The OpenAiApi instance to use for making API requests.
* @param metadataMode - The mode for generating metadata.
* @param options - The options for OpenAI embedding.
* @param retryTemplate - The RetryTemplate for retrying failed API requests.
* @param observationRegistry - The ObservationRegistry used for instrumentation.
*/
public OpenAiEmbeddingModel(OpenAiApi openAiApi, MetadataMode metadataMode, OpenAiEmbeddingOptions options,
RetryTemplate retryTemplate, ObservationRegistry observationRegistry) {
Assert.notNull(openAiApi, "OpenAiService must not be null");
Assert.notNull(metadataMode, "metadataMode must not be null");
Assert.notNull(options, "options must not be null");
Assert.notNull(retryTemplate, "retryTemplate must not be null");
Assert.notNull(observationRegistry, "observationRegistry must not be null");
this.openAiApi = openAiApi;
this.metadataMode = metadataMode;
this.defaultOptions = options;
this.retryTemplate = retryTemplate;
this.observationRegistry = observationRegistry;
}
@Override
@@ -111,26 +147,40 @@ public class OpenAiEmbeddingModel extends AbstractEmbeddingModel {
@Override
public EmbeddingResponse call(EmbeddingRequest request) {
org.springframework.ai.openai.api.OpenAiApi.EmbeddingRequest<List<String>> apiRequest = createRequest(request);
EmbeddingList<OpenAiApi.Embedding> apiEmbeddingResponse = this.retryTemplate
.execute(ctx -> this.openAiApi.embeddings(apiRequest).getBody());
var observationContext = EmbeddingModelObservationContext.builder()
.embeddingRequest(request)
.operationMetadata(buildOperationMetadata())
.requestOptions(buildRequestOptions(apiRequest))
.build();
if (apiEmbeddingResponse == null) {
logger.warn("No embeddings returned for request: {}", request);
return new EmbeddingResponse(List.of());
}
return EmbeddingModelObservationDocumentation.EMBEDDING_MODEL_OPERATION
.observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext,
this.observationRegistry)
.observe(() -> {
EmbeddingList<OpenAiApi.Embedding> apiEmbeddingResponse = this.retryTemplate
.execute(ctx -> this.openAiApi.embeddings(apiRequest).getBody());
var metadata = new EmbeddingResponseMetadata(apiEmbeddingResponse.model(),
OpenAiUsage.from(apiEmbeddingResponse.usage()));
if (apiEmbeddingResponse == null) {
logger.warn("No embeddings returned for request: {}", request);
return new EmbeddingResponse(List.of());
}
List<Embedding> embeddings = apiEmbeddingResponse.data()
.stream()
.map(e -> new Embedding(e.embedding(), e.index()))
.toList();
var metadata = new EmbeddingResponseMetadata(apiEmbeddingResponse.model(),
OpenAiUsage.from(apiEmbeddingResponse.usage()));
return new EmbeddingResponse(embeddings, metadata);
List<Embedding> embeddings = apiEmbeddingResponse.data()
.stream()
.map(e -> new Embedding(e.embedding(), e.index()))
.toList();
EmbeddingResponse embeddingResponse = new EmbeddingResponse(embeddings, metadata);
observationContext.setResponse(embeddingResponse);
return embeddingResponse;
});
}
@SuppressWarnings("unchecked")
@@ -150,4 +200,28 @@ public class OpenAiEmbeddingModel extends AbstractEmbeddingModel {
return apiRequest;
}
private AiOperationMetadata buildOperationMetadata() {
return AiOperationMetadata.builder()
.operationType(AiOperationType.EMBEDDING.value())
.provider(AiProvider.OPENAI.value())
.build();
}
private EmbeddingModelRequestOptions buildRequestOptions(OpenAiApi.EmbeddingRequest<List<String>> request) {
return EmbeddingModelRequestOptions.builder()
.model(StringUtils.hasText(request.model()) ? request.model() : "unknown")
.dimensions(request.dimensions())
.encodingFormat(request.encodingFormat())
.build();
}
/**
* Use the provided convention for reporting observation data
* @param observationConvention The provided convention
*/
public void setObservationConvention(EmbeddingModelObservationConvention observationConvention) {
Assert.notNull(observationConvention, "observationConvention cannot be null");
this.observationConvention = observationConvention;
}
}

View File

@@ -15,6 +15,7 @@
*/
package org.springframework.ai.openai;
import io.micrometer.observation.ObservationRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.image.Image;
@@ -24,13 +25,22 @@ import org.springframework.ai.image.ImageOptions;
import org.springframework.ai.image.ImagePrompt;
import org.springframework.ai.image.ImageResponse;
import org.springframework.ai.image.ImageResponseMetadata;
import org.springframework.ai.image.observation.DefaultImageModelObservationConvention;
import org.springframework.ai.image.observation.ImageModelObservationConvention;
import org.springframework.ai.image.observation.ImageModelObservationContext;
import org.springframework.ai.image.observation.ImageModelObservationDocumentation;
import org.springframework.ai.image.observation.ImageModelRequestOptions;
import org.springframework.ai.model.ModelOptionsUtils;
import org.springframework.ai.observation.AiOperationMetadata;
import org.springframework.ai.observation.conventions.AiOperationType;
import org.springframework.ai.observation.conventions.AiProvider;
import org.springframework.ai.openai.api.OpenAiImageApi;
import org.springframework.ai.openai.metadata.OpenAiImageGenerationMetadata;
import org.springframework.ai.retry.RetryUtils;
import org.springframework.http.ResponseEntity;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import java.util.List;
@@ -48,10 +58,12 @@ public class OpenAiImageModel implements ImageModel {
private final static Logger logger = LoggerFactory.getLogger(OpenAiImageModel.class);
private static final ImageModelObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultImageModelObservationConvention();
/**
* The default options used for the image completion requests.
*/
private OpenAiImageOptions defaultOptions;
private final OpenAiImageOptions defaultOptions;
/**
* The retry template used to retry the OpenAI Image API calls.
@@ -63,6 +75,16 @@ public class OpenAiImageModel implements ImageModel {
*/
private final OpenAiImageApi openAiImageApi;
/**
* Observation registry used for instrumentation.
*/
private final ObservationRegistry observationRegistry;
/**
* Conventions to use for generating observations.
*/
private ImageModelObservationConvention observationConvention = DEFAULT_OBSERVATION_CONVENTION;
/**
* Creates an instance of the OpenAiImageModel.
* @param openAiImageApi The OpenAiImageApi instance to be used for interacting with
@@ -81,23 +103,52 @@ public class OpenAiImageModel implements ImageModel {
* @param retryTemplate The retry template.
*/
public OpenAiImageModel(OpenAiImageApi openAiImageApi, OpenAiImageOptions options, RetryTemplate retryTemplate) {
this(openAiImageApi, options, retryTemplate, ObservationRegistry.NOOP);
}
/**
* Initializes a new instance of the OpenAiImageModel.
* @param openAiImageApi The OpenAiImageApi instance to be used for interacting with
* the OpenAI Image API.
* @param options The OpenAiImageOptions to configure the image model.
* @param retryTemplate The retry template.
* @param observationRegistry The ObservationRegistry used for instrumentation.
*/
public OpenAiImageModel(OpenAiImageApi openAiImageApi, OpenAiImageOptions options, RetryTemplate retryTemplate,
ObservationRegistry observationRegistry) {
Assert.notNull(openAiImageApi, "OpenAiImageApi must not be null");
Assert.notNull(options, "options must not be null");
Assert.notNull(retryTemplate, "retryTemplate must not be null");
Assert.notNull(observationRegistry, "observationRegistry must not be null");
this.openAiImageApi = openAiImageApi;
this.defaultOptions = options;
this.retryTemplate = retryTemplate;
this.observationRegistry = observationRegistry;
}
@Override
public ImageResponse call(ImagePrompt imagePrompt) {
OpenAiImageApi.OpenAiImageRequest imageRequest = createRequest(imagePrompt);
ResponseEntity<OpenAiImageApi.OpenAiImageResponse> imageResponseEntity = this.retryTemplate
.execute(ctx -> this.openAiImageApi.createImage(imageRequest));
var observationContext = ImageModelObservationContext.builder()
.imagePrompt(imagePrompt)
.operationMetadata(buildOperationMetadata())
.requestOptions(buildRequestOptions(imageRequest))
.build();
return convertResponse(imageResponseEntity, imageRequest);
return ImageModelObservationDocumentation.IMAGE_MODEL_OPERATION
.observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext,
this.observationRegistry)
.observe(() -> {
ResponseEntity<OpenAiImageApi.OpenAiImageResponse> imageResponseEntity = this.retryTemplate
.execute(ctx -> this.openAiImageApi.createImage(imageRequest));
ImageResponse imageResponse = convertResponse(imageResponseEntity, imageRequest);
observationContext.setResponse(imageResponse);
return imageResponse;
});
}
private OpenAiImageApi.OpenAiImageRequest createRequest(ImagePrompt imagePrompt) {
@@ -177,4 +228,31 @@ public class OpenAiImageModel implements ImageModel {
return openAiImageOptionsBuilder.build();
}
private AiOperationMetadata buildOperationMetadata() {
return AiOperationMetadata.builder()
.operationType(AiOperationType.IMAGE.value())
.provider(AiProvider.OPENAI.value())
.build();
}
private ImageModelRequestOptions buildRequestOptions(OpenAiImageApi.OpenAiImageRequest request) {
return ImageModelRequestOptions.builder()
.model(StringUtils.hasText(request.model()) ? request.model() : "unknown")
.n(request.n())
.width(request.size() != null ? Integer.parseInt(request.size().split("x")[0]) : null)
.height(request.size() != null ? Integer.parseInt(request.size().split("x")[1]) : null)
.responseFormat(request.responseFormat())
.style(request.style())
.build();
}
/**
* Use the provided convention for reporting observation data
* @param observationConvention The provided convention
*/
public void setObservationConvention(ImageModelObservationConvention observationConvention) {
Assert.notNull(observationConvention, "observationConvention cannot be null");
this.observationConvention = observationConvention;
}
}

View File

@@ -15,7 +15,6 @@
*/
package org.springframework.ai.openai;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.openai.api.OpenAiAudioApi;
import org.springframework.ai.openai.api.OpenAiImageApi;
@@ -78,7 +77,7 @@ public class OpenAiTestConfiguration {
}
@Bean
public EmbeddingModel openAiEmbeddingModel(OpenAiApi api) {
public OpenAiEmbeddingModel openAiEmbeddingModel(OpenAiApi api) {
return new OpenAiEmbeddingModel(api);
}

View File

@@ -0,0 +1,133 @@
/*
* Copyright 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.openai.chat;
import io.micrometer.common.KeyValue;
import io.micrometer.observation.tck.TestObservationRegistry;
import io.micrometer.observation.tck.TestObservationRegistryAssert;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.observation.DefaultChatModelObservationConvention;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.function.FunctionCallbackContext;
import org.springframework.ai.observation.conventions.AiOperationType;
import org.springframework.ai.observation.conventions.AiProvider;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import org.springframework.retry.support.RetryTemplate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.ai.chat.observation.ChatModelObservationDocumentation.HighCardinalityKeyNames;
import static org.springframework.ai.chat.observation.ChatModelObservationDocumentation.LowCardinalityKeyNames;
/**
* Integration tests for observation instrumentation in {@link OpenAiChatModel}.
*
* @author Thomas Vitale
*/
@SpringBootTest(classes = OpenAiChatModelObservationIT.Config.class)
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
public class OpenAiChatModelObservationIT {
@Autowired
TestObservationRegistry observationRegistry;
@Autowired
OpenAiChatModel chatModel;
@Test
void observationForEmbeddingOperation() {
var options = OpenAiChatOptions.builder()
.withModel(OpenAiApi.ChatModel.GPT_4_O_MINI.getValue())
.withFrequencyPenalty(0f)
.withMaxTokens(2048)
.withPresencePenalty(0f)
.withStop(List.of("this-is-the-end"))
.withTemperature(0.7f)
.withTopP(1f)
.build();
Prompt prompt = new Prompt("Why does a raven look like a desk?", options);
ChatResponse chatResponse = chatModel.call(prompt);
assertThat(chatResponse.getResult().getOutput().getContent()).isNotEmpty();
ChatResponseMetadata responseMetadata = chatResponse.getMetadata();
assertThat(responseMetadata).isNotNull();
TestObservationRegistryAssert.assertThat(observationRegistry)
.doesNotHaveAnyRemainingCurrentObservation()
.hasObservationWithNameEqualTo(DefaultChatModelObservationConvention.DEFAULT_NAME)
.that()
.hasContextualNameEqualTo("chat " + OpenAiApi.ChatModel.GPT_4_O_MINI.getValue())
.hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_OPERATION_TYPE.asString(),
AiOperationType.CHAT.value())
.hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_PROVIDER.asString(), AiProvider.OPENAI.value())
.hasLowCardinalityKeyValue(LowCardinalityKeyNames.REQUEST_MODEL.asString(),
OpenAiApi.ChatModel.GPT_4_O_MINI.getValue())
.hasLowCardinalityKeyValue(LowCardinalityKeyNames.RESPONSE_MODEL.asString(), responseMetadata.getModel())
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_FREQUENCY_PENALTY.asString(), "0.0")
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_MAX_TOKENS.asString(), "2048")
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_PRESENCE_PENALTY.asString(), "0.0")
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_STOP_SEQUENCES.asString(),
"[\"this-is-the-end\"]")
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_TEMPERATURE.asString(), "0.7")
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_TOP_K.asString(), KeyValue.NONE_VALUE)
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_TOP_P.asString(), "1.0")
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.RESPONSE_ID.asString(), responseMetadata.getId())
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.RESPONSE_FINISH_REASON.asString(),
chatResponse.getResult().getMetadata().getFinishReason())
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.USAGE_INPUT_TOKENS.asString(),
String.valueOf(responseMetadata.getUsage().getPromptTokens()))
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.USAGE_OUTPUT_TOKENS.asString(),
String.valueOf(responseMetadata.getUsage().getGenerationTokens()))
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.USAGE_TOTAL_TOKENS.asString(),
String.valueOf(responseMetadata.getUsage().getTotalTokens()))
.hasBeenStarted()
.hasBeenStopped();
}
@SpringBootConfiguration
static class Config {
@Bean
public TestObservationRegistry observationRegistry() {
return TestObservationRegistry.create();
}
@Bean
public OpenAiApi openAiApi() {
return new OpenAiApi(System.getenv("OPENAI_API_KEY"));
}
@Bean
public OpenAiChatModel openAiChatModel(OpenAiApi openAiApi, TestObservationRegistry observationRegistry) {
return new OpenAiChatModel(openAiApi, OpenAiChatOptions.builder().build(), new FunctionCallbackContext(),
List.of(), RetryTemplate.defaultInstance(), observationRegistry);
}
}
}

View File

@@ -17,10 +17,13 @@ package org.springframework.ai.openai.embedding;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.ai.embedding.EmbeddingRequest;
import org.springframework.ai.embedding.EmbeddingResponse;
import org.springframework.ai.openai.OpenAiEmbeddingModel;
import org.springframework.ai.openai.OpenAiEmbeddingOptions;
import org.springframework.ai.openai.OpenAiTestConfiguration;
import org.springframework.ai.openai.testutils.AbstractIT;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@@ -28,8 +31,9 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
class EmbeddingIT {
@SpringBootTest(classes = OpenAiTestConfiguration.class)
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
class EmbeddingIT extends AbstractIT {
@Autowired
private OpenAiEmbeddingModel embeddingModel;

View File

@@ -0,0 +1,118 @@
/*
* Copyright 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.openai.embedding;
import io.micrometer.observation.tck.TestObservationRegistry;
import io.micrometer.observation.tck.TestObservationRegistryAssert;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.ai.document.MetadataMode;
import org.springframework.ai.embedding.EmbeddingRequest;
import org.springframework.ai.embedding.EmbeddingResponse;
import org.springframework.ai.embedding.EmbeddingResponseMetadata;
import org.springframework.ai.embedding.observation.DefaultEmbeddingModelObservationConvention;
import org.springframework.ai.observation.conventions.AiOperationType;
import org.springframework.ai.observation.conventions.AiProvider;
import org.springframework.ai.openai.OpenAiEmbeddingModel;
import org.springframework.ai.openai.OpenAiEmbeddingOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import org.springframework.retry.support.RetryTemplate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.ai.embedding.observation.EmbeddingModelObservationDocumentation.HighCardinalityKeyNames;
import static org.springframework.ai.embedding.observation.EmbeddingModelObservationDocumentation.LowCardinalityKeyNames;
/**
* Integration tests for observation instrumentation in {@link OpenAiEmbeddingModel}.
*
* @author Thomas Vitale
*/
@SpringBootTest(classes = OpenAiEmbeddingModelObservationIT.Config.class)
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
public class OpenAiEmbeddingModelObservationIT {
@Autowired
TestObservationRegistry observationRegistry;
@Autowired
OpenAiEmbeddingModel embeddingModel;
@Test
void observationForEmbeddingOperation() {
var options = OpenAiEmbeddingOptions.builder()
.withModel(OpenAiApi.EmbeddingModel.TEXT_EMBEDDING_3_SMALL.getValue())
.withDimensions(1536)
.withEncodingFormat("float")
.build();
EmbeddingRequest embeddingRequest = new EmbeddingRequest(List.of("Here comes the sun"), options);
EmbeddingResponse embeddingResponse = embeddingModel.call(embeddingRequest);
assertThat(embeddingResponse.getResults()).isNotEmpty();
EmbeddingResponseMetadata responseMetadata = embeddingResponse.getMetadata();
assertThat(responseMetadata).isNotNull();
TestObservationRegistryAssert.assertThat(observationRegistry)
.doesNotHaveAnyRemainingCurrentObservation()
.hasObservationWithNameEqualTo(DefaultEmbeddingModelObservationConvention.DEFAULT_NAME)
.that()
.hasContextualNameEqualTo("embedding " + OpenAiApi.EmbeddingModel.TEXT_EMBEDDING_3_SMALL.getValue())
.hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_OPERATION_TYPE.asString(),
AiOperationType.EMBEDDING.value())
.hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_PROVIDER.asString(), AiProvider.OPENAI.value())
.hasLowCardinalityKeyValue(LowCardinalityKeyNames.REQUEST_MODEL.asString(),
OpenAiApi.EmbeddingModel.TEXT_EMBEDDING_3_SMALL.getValue())
.hasLowCardinalityKeyValue(LowCardinalityKeyNames.RESPONSE_MODEL.asString(), responseMetadata.getModel())
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_EMBEDDING_DIMENSIONS.asString(), "1536")
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_EMBEDDING_ENCODING_FORMAT.asString(), "float")
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.USAGE_INPUT_TOKENS.asString(),
String.valueOf(responseMetadata.getUsage().getPromptTokens()))
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.USAGE_TOTAL_TOKENS.asString(),
String.valueOf(responseMetadata.getUsage().getTotalTokens()))
.hasBeenStarted()
.hasBeenStopped();
}
@SpringBootConfiguration
static class Config {
@Bean
public TestObservationRegistry observationRegistry() {
return TestObservationRegistry.create();
}
@Bean
public OpenAiApi openAiApi() {
return new OpenAiApi(System.getenv("OPENAI_API_KEY"));
}
@Bean
public OpenAiEmbeddingModel openAiEmbeddingModel(OpenAiApi openAiApi,
TestObservationRegistry observationRegistry) {
return new OpenAiEmbeddingModel(openAiApi, MetadataMode.EMBED, OpenAiEmbeddingOptions.builder().build(),
RetryTemplate.defaultInstance(), observationRegistry);
}
}
}

View File

@@ -0,0 +1,110 @@
/*
* Copyright 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.openai.image;
import io.micrometer.observation.tck.TestObservationRegistry;
import io.micrometer.observation.tck.TestObservationRegistryAssert;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.ai.image.ImagePrompt;
import org.springframework.ai.image.ImageResponse;
import org.springframework.ai.image.observation.DefaultImageModelObservationConvention;
import org.springframework.ai.observation.conventions.AiOperationType;
import org.springframework.ai.observation.conventions.AiProvider;
import org.springframework.ai.openai.OpenAiImageModel;
import org.springframework.ai.openai.OpenAiImageOptions;
import org.springframework.ai.openai.api.OpenAiImageApi;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import org.springframework.retry.support.RetryTemplate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.ai.image.observation.ImageModelObservationDocumentation.HighCardinalityKeyNames;
import static org.springframework.ai.image.observation.ImageModelObservationDocumentation.LowCardinalityKeyNames;
/**
* Integration tests for observation instrumentation in {@link OpenAiImageModel}.
*
* @author Thomas Vitale
*/
@SpringBootTest(classes = OpenAiImageModelObservationIT.Config.class)
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
public class OpenAiImageModelObservationIT {
@Autowired
TestObservationRegistry observationRegistry;
@Autowired
OpenAiImageModel imageModel;
@Test
void observationForImageOperation() {
var options = OpenAiImageOptions.builder()
.withModel(OpenAiImageApi.ImageModel.DALL_E_3.getValue())
.withHeight(1024)
.withWidth(1024)
.withResponseFormat("url")
.withStyle("natural")
.build();
var instructions = "Here comes the sun";
ImagePrompt imagePrompt = new ImagePrompt(instructions, options);
ImageResponse imageResponse = imageModel.call(imagePrompt);
assertThat(imageResponse.getResults()).hasSize(1);
TestObservationRegistryAssert.assertThat(observationRegistry)
.doesNotHaveAnyRemainingCurrentObservation()
.hasObservationWithNameEqualTo(DefaultImageModelObservationConvention.DEFAULT_NAME)
.that()
.hasContextualNameEqualTo("image " + OpenAiImageApi.ImageModel.DALL_E_3.getValue())
.hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_OPERATION_TYPE.asString(),
AiOperationType.IMAGE.value())
.hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_PROVIDER.asString(), AiProvider.OPENAI.value())
.hasLowCardinalityKeyValue(LowCardinalityKeyNames.REQUEST_MODEL.asString(),
OpenAiImageApi.ImageModel.DALL_E_3.getValue())
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_IMAGE_SIZE.asString(), "1024x1024")
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_IMAGE_RESPONSE_FORMAT.asString(), "url")
.hasBeenStarted()
.hasBeenStopped();
}
@SpringBootConfiguration
static class Config {
@Bean
public TestObservationRegistry observationRegistry() {
return TestObservationRegistry.create();
}
@Bean
public OpenAiImageApi openAiImageApi() {
return new OpenAiImageApi(System.getenv("OPENAI_API_KEY"));
}
@Bean
public OpenAiImageModel openAiImageModel(OpenAiImageApi openAiImageApi,
TestObservationRegistry observationRegistry) {
return new OpenAiImageModel(openAiImageApi, OpenAiImageOptions.builder().build(),
RetryTemplate.defaultInstance(), observationRegistry);
}
}
}

View File

@@ -29,6 +29,7 @@ import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.image.ImageModel;
import org.springframework.ai.openai.OpenAiAudioSpeechModel;
import org.springframework.ai.openai.OpenAiAudioTranscriptionModel;
@@ -62,6 +63,9 @@ public abstract class AbstractIT {
@Autowired
protected ImageModel imageModel;
@Autowired
protected EmbeddingModel embeddingModel;
@Value("classpath:/prompts/eval/qa-evaluator-accurate-answer.st")
protected Resource qaEvaluatorAccurateAnswerResource;