Skip to content

Commit 6bdb395

Browse files
committed
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>
1 parent a077e1b commit 6bdb395

File tree

77 files changed

+5801
-85
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

77 files changed

+5801
-85
lines changed

models/spring-ai-openai/pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@
8888
<scope>test</scope>
8989
</dependency>
9090

91+
<dependency>
92+
<groupId>io.micrometer</groupId>
93+
<artifactId>micrometer-observation-test</artifactId>
94+
<scope>test</scope>
95+
</dependency>
96+
9197
<dependency>
9298
<groupId>org.testcontainers</groupId>
9399
<artifactId>qdrant</artifactId>

models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.java

Lines changed: 113 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,7 @@
1515
*/
1616
package org.springframework.ai.openai;
1717

18-
import java.util.ArrayList;
19-
import java.util.Base64;
20-
import java.util.HashSet;
21-
import java.util.List;
22-
import java.util.Map;
23-
import java.util.Set;
24-
import java.util.concurrent.ConcurrentHashMap;
25-
18+
import io.micrometer.observation.ObservationRegistry;
2619
import org.slf4j.Logger;
2720
import org.slf4j.LoggerFactory;
2821
import org.springframework.ai.chat.messages.AssistantMessage;
@@ -33,16 +26,16 @@
3326
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
3427
import org.springframework.ai.chat.metadata.EmptyUsage;
3528
import org.springframework.ai.chat.metadata.RateLimit;
36-
import org.springframework.ai.chat.model.AbstractToolCallSupport;
37-
import org.springframework.ai.chat.model.ChatModel;
38-
import org.springframework.ai.chat.model.ChatResponse;
39-
import org.springframework.ai.chat.model.Generation;
40-
import org.springframework.ai.chat.model.StreamingChatModel;
29+
import org.springframework.ai.chat.model.*;
30+
import org.springframework.ai.chat.observation.*;
4131
import org.springframework.ai.chat.prompt.ChatOptions;
4232
import org.springframework.ai.chat.prompt.Prompt;
4333
import org.springframework.ai.model.ModelOptionsUtils;
4434
import org.springframework.ai.model.function.FunctionCallback;
4535
import org.springframework.ai.model.function.FunctionCallbackContext;
36+
import org.springframework.ai.observation.AiOperationMetadata;
37+
import org.springframework.ai.observation.conventions.AiOperationType;
38+
import org.springframework.ai.observation.conventions.AiProvider;
4639
import org.springframework.ai.openai.api.OpenAiApi;
4740
import org.springframework.ai.openai.api.OpenAiApi.ChatCompletion;
4841
import org.springframework.ai.openai.api.OpenAiApi.ChatCompletion.Choice;
@@ -59,10 +52,13 @@
5952
import org.springframework.util.Assert;
6053
import org.springframework.util.CollectionUtils;
6154
import org.springframework.util.MimeType;
62-
55+
import org.springframework.util.StringUtils;
6356
import reactor.core.publisher.Flux;
6457
import reactor.core.publisher.Mono;
6558

59+
import java.util.*;
60+
import java.util.concurrent.ConcurrentHashMap;
61+
6662
/**
6763
* {@link ChatModel} and {@link StreamingChatModel} implementation for {@literal OpenAI}
6864
* backed by {@link OpenAiApi}.
@@ -86,6 +82,8 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode
8682

8783
private static final Logger logger = LoggerFactory.getLogger(OpenAiChatModel.class);
8884

85+
private static final ChatModelObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultChatModelObservationConvention();
86+
8987
/**
9088
* The default options used for the chat completion requests.
9189
*/
@@ -101,6 +99,16 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode
10199
*/
102100
private final OpenAiApi openAiApi;
103101

102+
/**
103+
* Observation registry used for instrumentation.
104+
*/
105+
private final ObservationRegistry observationRegistry;
106+
107+
/**
108+
* Conventions to use for generating observations.
109+
*/
110+
private ChatModelObservationConvention observationConvention = DEFAULT_OBSERVATION_CONVENTION;
111+
104112
/**
105113
* Creates an instance of the OpenAiChatModel.
106114
* @param openAiApi The OpenAiApi instance to be used for interacting with the OpenAI
@@ -147,65 +155,101 @@ public OpenAiChatModel(OpenAiApi openAiApi, OpenAiChatOptions options,
147155
public OpenAiChatModel(OpenAiApi openAiApi, OpenAiChatOptions options,
148156
FunctionCallbackContext functionCallbackContext, List<FunctionCallback> toolFunctionCallbacks,
149157
RetryTemplate retryTemplate) {
158+
this(openAiApi, options, functionCallbackContext, toolFunctionCallbacks, retryTemplate,
159+
ObservationRegistry.NOOP);
160+
}
161+
162+
/**
163+
* Initializes a new instance of the OpenAiChatModel.
164+
* @param openAiApi The OpenAiApi instance to be used for interacting with the OpenAI
165+
* Chat API.
166+
* @param options The OpenAiChatOptions to configure the chat model.
167+
* @param functionCallbackContext The function callback context.
168+
* @param toolFunctionCallbacks The tool function callbacks.
169+
* @param retryTemplate The retry template.
170+
* @param observationRegistry The ObservationRegistry used for instrumentation.
171+
*/
172+
public OpenAiChatModel(OpenAiApi openAiApi, OpenAiChatOptions options,
173+
FunctionCallbackContext functionCallbackContext, List<FunctionCallback> toolFunctionCallbacks,
174+
RetryTemplate retryTemplate, ObservationRegistry observationRegistry) {
150175
super(functionCallbackContext, options, toolFunctionCallbacks);
151176

152177
Assert.notNull(openAiApi, "OpenAiApi must not be null");
153178
Assert.notNull(options, "Options must not be null");
154179
Assert.notNull(retryTemplate, "RetryTemplate must not be null");
155180
Assert.isTrue(CollectionUtils.isEmpty(options.getFunctionCallbacks()),
156181
"The default function callbacks must be set via the toolFunctionCallbacks constructor parameter");
182+
Assert.notNull(observationRegistry, "ObservationRegistry must not be null");
157183

158184
this.openAiApi = openAiApi;
159185
this.defaultOptions = options;
160186
this.retryTemplate = retryTemplate;
187+
this.observationRegistry = observationRegistry;
161188
}
162189

163190
@Override
164191
public ChatResponse call(Prompt prompt) {
165192

166193
ChatCompletionRequest request = createRequest(prompt, false);
167194

168-
ResponseEntity<ChatCompletion> completionEntity = this.retryTemplate
169-
.execute(ctx -> this.openAiApi.chatCompletionEntity(request));
195+
ChatModelObservationContext observationContext = ChatModelObservationContext.builder()
196+
.prompt(prompt)
197+
.operationMetadata(buildOperationMetadata())
198+
.requestOptions(buildRequestOptions(request))
199+
.build();
170200

171-
var chatCompletion = completionEntity.getBody();
201+
ChatResponse response = ChatModelObservationDocumentation.CHAT_MODEL_OPERATION
202+
.observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext,
203+
this.observationRegistry)
204+
.observe(() -> {
172205

173-
if (chatCompletion == null) {
174-
logger.warn("No chat completion returned for prompt: {}", prompt);
175-
return new ChatResponse(List.of());
176-
}
206+
ResponseEntity<ChatCompletion> completionEntity = this.retryTemplate
207+
.execute(ctx -> this.openAiApi.chatCompletionEntity(request));
177208

178-
List<Choice> choices = chatCompletion.choices();
179-
if (choices == null) {
180-
logger.warn("No choices returned for prompt: {}", prompt);
181-
return new ChatResponse(List.of());
182-
}
209+
var chatCompletion = completionEntity.getBody();
210+
211+
if (chatCompletion == null) {
212+
logger.warn("No chat completion returned for prompt: {}", prompt);
213+
return new ChatResponse(List.of());
214+
}
183215

184-
List<Generation> generations = choices.stream().map(choice -> {
216+
List<Choice> choices = chatCompletion.choices();
217+
if (choices == null) {
218+
logger.warn("No choices returned for prompt: {}", prompt);
219+
return new ChatResponse(List.of());
220+
}
221+
222+
List<Generation> generations = choices.stream().map(choice -> {
185223
// @formatter:off
186-
Map<String, Object> metadata = Map.of(
187-
"id", chatCompletion.id() != null ? chatCompletion.id() : "",
188-
"role", choice.message().role() != null ? choice.message().role().name() : "",
189-
"index", choice.index(),
190-
"finishReason", choice.finishReason() != null ? choice.finishReason().name() : "");
191-
// @formatter:on
192-
return buildGeneration(choice, metadata);
193-
}).toList();
224+
Map<String, Object> metadata = Map.of(
225+
"id", chatCompletion.id() != null ? chatCompletion.id() : "",
226+
"role", choice.message().role() != null ? choice.message().role().name() : "",
227+
"index", choice.index(),
228+
"finishReason", choice.finishReason() != null ? choice.finishReason().name() : "");
229+
// @formatter:on
230+
return buildGeneration(choice, metadata);
231+
}).toList();
232+
233+
// Non function calling.
234+
RateLimit rateLimit = OpenAiResponseHeaderExtractor.extractAiResponseHeaders(completionEntity);
235+
236+
ChatResponse chatResponse = new ChatResponse(generations, from(completionEntity.getBody(), rateLimit));
194237

195-
// Non function calling.
196-
RateLimit rateLimit = OpenAiResponseHeaderExtractor.extractAiResponseHeaders(completionEntity);
238+
observationContext.setResponse(chatResponse);
197239

198-
ChatResponse chatResponse = new ChatResponse(generations, from(completionEntity.getBody(), rateLimit));
240+
return chatResponse;
199241

200-
if (isToolCall(chatResponse, Set.of(OpenAiApi.ChatCompletionFinishReason.TOOL_CALLS.name(),
242+
});
243+
244+
if (response != null && isToolCall(response, Set.of(OpenAiApi.ChatCompletionFinishReason.TOOL_CALLS.name(),
201245
OpenAiApi.ChatCompletionFinishReason.STOP.name()))) {
202-
var toolCallConversation = handleToolCalls(prompt, chatResponse);
246+
var toolCallConversation = handleToolCalls(prompt, response);
203247
// Recursively call the call method with the tool call message
204248
// conversation that contains the call responses.
205249
return this.call(new Prompt(toolCallConversation, prompt.getOptions()));
206250
}
207251

208-
return chatResponse;
252+
return response;
209253
}
210254

211255
@Override
@@ -434,6 +478,25 @@ private List<OpenAiApi.FunctionTool> getFunctionTools(Set<String> functionNames)
434478
}).toList();
435479
}
436480

481+
private AiOperationMetadata buildOperationMetadata() {
482+
return AiOperationMetadata.builder()
483+
.operationType(AiOperationType.CHAT.value())
484+
.provider(AiProvider.OPENAI.value())
485+
.build();
486+
}
487+
488+
private ChatModelRequestOptions buildRequestOptions(OpenAiApi.ChatCompletionRequest request) {
489+
return ChatModelRequestOptions.builder()
490+
.model(StringUtils.hasText(request.model()) ? request.model() : "unknown")
491+
.frequencyPenalty(request.frequencyPenalty())
492+
.maxTokens(request.maxTokens())
493+
.presencePenalty(request.presencePenalty())
494+
.stopSequences(request.stop())
495+
.temperature(request.temperature())
496+
.topP(request.topP())
497+
.build();
498+
}
499+
437500
@Override
438501
public ChatOptions getDefaultOptions() {
439502
return OpenAiChatOptions.fromOptions(this.defaultOptions);
@@ -444,4 +507,13 @@ public String toString() {
444507
return "OpenAiChatModel [defaultOptions=" + defaultOptions + "]";
445508
}
446509

510+
/**
511+
* Use the provided convention for reporting observation data
512+
* @param observationConvention The provided convention
513+
*/
514+
public void setObservationConvention(ChatModelObservationConvention observationConvention) {
515+
Assert.notNull(observationConvention, "observationConvention cannot be null");
516+
this.observationConvention = observationConvention;
517+
}
518+
447519
}

0 commit comments

Comments
 (0)