Add ObservationRegistry support to ChatClient
- Implement observable chat responses in DefaultChatClient - Add ChatClientObservationContext and related classes for metrics - Update ChatClient and builder methods to support ObservationRegistry - Enhance RequestResponseAdvisor with getName() method - Add ChatClient streaming observability support - Introduce ChatClientObservationDocumentation for metric key names - Create DefaultChatClientObservationConvention for implementing conventions - Add ChatClientInputContentObservationFilter for optional input content logging - Update ChatClientAutoConfiguration to include new observation components - Extend ChatClientBuilderProperties with observation configuration options - Add unit tests for new observation classes and configurations - Update AiOperationType and AiProvider enums with new values - Implement safeguards and warnings for sensitive data in observations Resolves #1206
This commit is contained in:
@@ -21,17 +21,19 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.springframework.ai.model.Media;
|
||||
import org.springframework.ai.chat.client.observation.ChatClientObservationConvention;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.prompt.ChatOptions;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.converter.StructuredOutputConverter;
|
||||
import org.springframework.ai.model.Media;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.util.MimeType;
|
||||
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
/**
|
||||
@@ -48,11 +50,25 @@ import reactor.core.publisher.Flux;
|
||||
public interface ChatClient {
|
||||
|
||||
static ChatClient create(ChatModel chatModel) {
|
||||
return builder(chatModel).build();
|
||||
return create(chatModel, ObservationRegistry.NOOP);
|
||||
}
|
||||
|
||||
static ChatClient create(ChatModel chatModel, ObservationRegistry observationRegistry) {
|
||||
return create(chatModel, observationRegistry, null);
|
||||
}
|
||||
|
||||
static ChatClient create(ChatModel chatModel, ObservationRegistry observationRegistry,
|
||||
ChatClientObservationConvention observationConvention) {
|
||||
return builder(chatModel, observationRegistry, observationConvention).build();
|
||||
}
|
||||
|
||||
static Builder builder(ChatModel chatModel) {
|
||||
return new DefaultChatClientBuilder(chatModel);
|
||||
return builder(chatModel, ObservationRegistry.NOOP, null);
|
||||
}
|
||||
|
||||
static Builder builder(ChatModel chatModel, ObservationRegistry observationRegistry,
|
||||
ChatClientObservationConvention customObservationConvention) {
|
||||
return new DefaultChatClientBuilder(chatModel, observationRegistry, customObservationConvention);
|
||||
}
|
||||
|
||||
ChatClientRequestSpec prompt();
|
||||
|
||||
@@ -28,9 +28,10 @@ import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import org.springframework.ai.model.Media;
|
||||
import org.springframework.ai.chat.client.observation.ChatClientObservationContext;
|
||||
import org.springframework.ai.chat.client.observation.ChatClientObservationConvention;
|
||||
import org.springframework.ai.chat.client.observation.ChatClientObservationDocumentation;
|
||||
import org.springframework.ai.chat.client.observation.DefaultChatClientObservationConvention;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.SystemMessage;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
@@ -42,6 +43,7 @@ import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.chat.prompt.PromptTemplate;
|
||||
import org.springframework.ai.converter.BeanOutputConverter;
|
||||
import org.springframework.ai.converter.StructuredOutputConverter;
|
||||
import org.springframework.ai.model.Media;
|
||||
import org.springframework.ai.model.function.FunctionCallback;
|
||||
import org.springframework.ai.model.function.FunctionCallbackWrapper;
|
||||
import org.springframework.ai.model.function.FunctionCallingOptions;
|
||||
@@ -52,6 +54,11 @@ import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.MimeType;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import io.micrometer.observation.Observation;
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
/**
|
||||
* The default implementation of {@link ChatClient} as created by the
|
||||
* {@link Builder#build()} } method.
|
||||
@@ -65,6 +72,8 @@ import org.springframework.util.StringUtils;
|
||||
*/
|
||||
public class DefaultChatClient implements ChatClient {
|
||||
|
||||
private static final ChatClientObservationConvention DEFAULT_CHAT_CLIENT_OBSERVATION_CONVENTION = new DefaultChatClientObservationConvention();
|
||||
|
||||
private final ChatModel chatModel;
|
||||
|
||||
private final DefaultChatClientRequestSpec defaultChatClientRequest;
|
||||
@@ -281,7 +290,7 @@ public class DefaultChatClient implements ChatClient {
|
||||
}
|
||||
|
||||
protected <T> ResponseEntity<ChatResponse, T> doResponseEntity(StructuredOutputConverter<T> boc) {
|
||||
var chatResponse = doGetChatResponse(this.request, boc.getFormat());
|
||||
var chatResponse = doGetObservableChatResponse(this.request, boc.getFormat());
|
||||
var responseContent = chatResponse.getResult().getOutput().getContent();
|
||||
T entity = boc.convert(responseContent);
|
||||
|
||||
@@ -297,7 +306,7 @@ public class DefaultChatClient implements ChatClient {
|
||||
}
|
||||
|
||||
private <T> T doSingleWithBeanOutputConverter(StructuredOutputConverter<T> boc) {
|
||||
var chatResponse = doGetChatResponse(this.request, boc.getFormat());
|
||||
var chatResponse = doGetObservableChatResponse(this.request, boc.getFormat());
|
||||
var stringResponse = chatResponse.getResult().getOutput().getContent();
|
||||
return boc.convert(stringResponse);
|
||||
}
|
||||
@@ -309,7 +318,23 @@ public class DefaultChatClient implements ChatClient {
|
||||
}
|
||||
|
||||
private ChatResponse doGetChatResponse() {
|
||||
return this.doGetChatResponse(this.request, "");
|
||||
return this.doGetObservableChatResponse(this.request, "");
|
||||
}
|
||||
|
||||
private ChatResponse doGetObservableChatResponse(DefaultChatClientRequestSpec inputRequest,
|
||||
String formatParam) {
|
||||
|
||||
ChatClientObservationContext observationContext = new ChatClientObservationContext(inputRequest,
|
||||
formatParam, false);
|
||||
|
||||
return ChatClientObservationDocumentation.AI_CHAT_CLIENT
|
||||
.observation(inputRequest.customObservationConvention, DEFAULT_CHAT_CLIENT_OBSERVATION_CONVENTION,
|
||||
() -> observationContext, inputRequest.observationRegistry)
|
||||
.observe(() -> {
|
||||
ChatResponse chatResponse = doGetChatResponse(inputRequest, formatParam);
|
||||
return chatResponse;
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private ChatResponse doGetChatResponse(DefaultChatClientRequestSpec inputRequest, String formatParam) {
|
||||
@@ -395,6 +420,29 @@ public class DefaultChatClient implements ChatClient {
|
||||
}
|
||||
|
||||
private Flux<ChatResponse> doGetFluxChatResponse(DefaultChatClientRequestSpec inputRequest) {
|
||||
return Flux.deferContextual(contextView -> {
|
||||
ChatClientObservationContext observationContext = new ChatClientObservationContext(inputRequest, "",
|
||||
true);
|
||||
|
||||
Observation observation = ChatClientObservationDocumentation.AI_CHAT_CLIENT.observation(
|
||||
inputRequest.customObservationConvention, DEFAULT_CHAT_CLIENT_OBSERVATION_CONVENTION,
|
||||
() -> observationContext, inputRequest.observationRegistry);
|
||||
|
||||
observation.parentObservation(contextView.getOrDefault(ObservationThreadLocalAccessor.KEY, null))
|
||||
.start();
|
||||
|
||||
// @formatter:off
|
||||
return doGetFluxChatResponse2(inputRequest)
|
||||
.doOnError(observation::error)
|
||||
.doFinally(s -> {
|
||||
observation.stop();
|
||||
})
|
||||
.contextWrite(ctx -> ctx.put(ObservationThreadLocalAccessor.KEY, observation));
|
||||
// @formatter:on
|
||||
});
|
||||
}
|
||||
|
||||
private Flux<ChatResponse> doGetFluxChatResponse2(DefaultChatClientRequestSpec inputRequest) {
|
||||
|
||||
Map<String, Object> context = new ConcurrentHashMap<>();
|
||||
context.putAll(inputRequest.getAdvisorParams());
|
||||
@@ -426,9 +474,7 @@ public class DefaultChatClient implements ChatClient {
|
||||
messages.add(userMessage);
|
||||
}
|
||||
|
||||
if (advisedRequest.getChatOptions() instanceof
|
||||
|
||||
FunctionCallingOptions functionCallingOptions) {
|
||||
if (advisedRequest.getChatOptions() instanceof FunctionCallingOptions functionCallingOptions) {
|
||||
if (!advisedRequest.getFunctionNames().isEmpty()) {
|
||||
functionCallingOptions.setFunctions(new HashSet<>(advisedRequest.getFunctionNames()));
|
||||
}
|
||||
@@ -470,6 +516,10 @@ public class DefaultChatClient implements ChatClient {
|
||||
|
||||
public static class DefaultChatClientRequestSpec implements ChatClientRequestSpec {
|
||||
|
||||
private final ObservationRegistry observationRegistry;
|
||||
|
||||
private final ChatClientObservationConvention customObservationConvention;
|
||||
|
||||
private final ChatModel chatModel;
|
||||
|
||||
private String userText = "";
|
||||
@@ -494,6 +544,14 @@ public class DefaultChatClient implements ChatClient {
|
||||
|
||||
private final Map<String, Object> advisorParams = new HashMap<>();
|
||||
|
||||
private ObservationRegistry getObservationRegistry() {
|
||||
return observationRegistry;
|
||||
}
|
||||
|
||||
private ChatClientObservationConvention getCustomObservationConvention() {
|
||||
return customObservationConvention;
|
||||
}
|
||||
|
||||
public String getUserText() {
|
||||
return userText;
|
||||
}
|
||||
@@ -541,13 +599,15 @@ public class DefaultChatClient implements ChatClient {
|
||||
/* copy constructor */
|
||||
DefaultChatClientRequestSpec(DefaultChatClientRequestSpec ccr) {
|
||||
this(ccr.chatModel, ccr.userText, ccr.userParams, ccr.systemText, ccr.systemParams, ccr.functionCallbacks,
|
||||
ccr.messages, ccr.functionNames, ccr.media, ccr.chatOptions, ccr.advisors, ccr.advisorParams);
|
||||
ccr.messages, ccr.functionNames, ccr.media, ccr.chatOptions, ccr.advisors, ccr.advisorParams,
|
||||
ccr.observationRegistry, ccr.customObservationConvention);
|
||||
}
|
||||
|
||||
public DefaultChatClientRequestSpec(ChatModel chatModel, String userText, Map<String, Object> userParams,
|
||||
String systemText, Map<String, Object> systemParams, List<FunctionCallback> functionCallbacks,
|
||||
List<Message> messages, List<String> functionNames, List<Media> media, ChatOptions chatOptions,
|
||||
List<RequestResponseAdvisor> advisors, Map<String, Object> advisorParams) {
|
||||
List<RequestResponseAdvisor> advisors, Map<String, Object> advisorParams,
|
||||
ObservationRegistry observationRegistry, ChatClientObservationConvention customObservationConvention) {
|
||||
|
||||
this.chatModel = chatModel;
|
||||
this.chatOptions = chatOptions != null ? chatOptions.copy()
|
||||
@@ -564,6 +624,8 @@ public class DefaultChatClient implements ChatClient {
|
||||
this.media.addAll(media);
|
||||
this.advisors.addAll(advisors);
|
||||
this.advisorParams.putAll(advisorParams);
|
||||
this.observationRegistry = observationRegistry;
|
||||
this.customObservationConvention = customObservationConvention;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -571,7 +633,8 @@ public class DefaultChatClient implements ChatClient {
|
||||
* settings are replicated from this {@code ChatClientRequest}.
|
||||
*/
|
||||
public Builder mutate() {
|
||||
DefaultChatClientBuilder builder = (DefaultChatClientBuilder) ChatClient.builder(chatModel)
|
||||
DefaultChatClientBuilder builder = (DefaultChatClientBuilder) ChatClient
|
||||
.builder(chatModel, this.observationRegistry, this.customObservationConvention)
|
||||
.defaultSystem(s -> s.text(this.systemText).params(this.systemParams))
|
||||
.defaultUser(u -> u.text(this.userText)
|
||||
.params(this.userParams)
|
||||
@@ -756,7 +819,8 @@ public class DefaultChatClient implements ChatClient {
|
||||
adviseRequest.userParams(), adviseRequest.systemText(), adviseRequest.systemParams(),
|
||||
adviseRequest.functionCallbacks(), adviseRequest.messages(), adviseRequest.functionNames(),
|
||||
adviseRequest.media(), adviseRequest.chatOptions(), adviseRequest.advisors(),
|
||||
adviseRequest.advisorParams());
|
||||
adviseRequest.advisorParams(), inputRequest.getObservationRegistry(),
|
||||
inputRequest.getCustomObservationConvention());
|
||||
}
|
||||
|
||||
return advisedRequest;
|
||||
|
||||
@@ -26,11 +26,14 @@ import org.springframework.ai.chat.client.ChatClient.Builder;
|
||||
import org.springframework.ai.chat.client.ChatClient.PromptSystemSpec;
|
||||
import org.springframework.ai.chat.client.ChatClient.PromptUserSpec;
|
||||
import org.springframework.ai.chat.client.DefaultChatClient.DefaultChatClientRequestSpec;
|
||||
import org.springframework.ai.chat.client.observation.ChatClientObservationConvention;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.prompt.ChatOptions;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
|
||||
/**
|
||||
* DefaultChatClientBuilder is a builder class for creating a ChatClient.
|
||||
*
|
||||
@@ -48,11 +51,18 @@ public class DefaultChatClientBuilder implements Builder {
|
||||
|
||||
private final ChatModel chatModel;
|
||||
|
||||
public DefaultChatClientBuilder(ChatModel chatModel) {
|
||||
DefaultChatClientBuilder(ChatModel chatModel) {
|
||||
this(chatModel, ObservationRegistry.NOOP, null);
|
||||
}
|
||||
|
||||
public DefaultChatClientBuilder(ChatModel chatModel, ObservationRegistry observationRegistry,
|
||||
ChatClientObservationConvention customObservationConvention) {
|
||||
Assert.notNull(chatModel, "the " + ChatModel.class.getName() + " must be non-null");
|
||||
Assert.notNull(observationRegistry, "the " + ObservationRegistry.class.getName() + " must be non-null");
|
||||
this.chatModel = chatModel;
|
||||
this.defaultRequest = new DefaultChatClientRequestSpec(chatModel, "", Map.of(), "", Map.of(), List.of(),
|
||||
List.of(), List.of(), List.of(), null, List.of(), Map.of());
|
||||
List.of(), List.of(), List.of(), null, List.of(), Map.of(), observationRegistry,
|
||||
customObservationConvention);
|
||||
}
|
||||
|
||||
public ChatClient build() {
|
||||
|
||||
@@ -34,6 +34,13 @@ import org.springframework.ai.chat.prompt.Prompt;
|
||||
*/
|
||||
public interface RequestResponseAdvisor {
|
||||
|
||||
/**
|
||||
* @return the advisor name.
|
||||
*/
|
||||
default String getName() {
|
||||
return this.getClass().getSimpleName();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param request the {@link AdvisedRequest} data to be advised. Represents the row
|
||||
* {@link ChatClient.ChatClientRequestSpec} data before sealed into a {@link Prompt}.
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright 2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.ai.chat.client.observation;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import io.micrometer.common.KeyValue;
|
||||
import io.micrometer.observation.Observation;
|
||||
import io.micrometer.observation.ObservationFilter;
|
||||
|
||||
/**
|
||||
* An {@link ObservationFilter} to include the chat prompt content in the observation.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public class ChatClientInputContentObservationFilter implements ObservationFilter {
|
||||
|
||||
private static final KeyValue CHAT_CLIENT_SYSTEM_TEXT_NONE = KeyValue
|
||||
.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_SYSTEM_TEXT, KeyValue.NONE_VALUE);
|
||||
|
||||
private static final KeyValue CHAT_CLIENT_SYSTEM_PARAM_NONE = KeyValue
|
||||
.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_SYSTEM_PARAM, KeyValue.NONE_VALUE);
|
||||
|
||||
private static final KeyValue CHAT_CLIENT_USER_TEXT_NONE = KeyValue
|
||||
.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_USER_TEXT, KeyValue.NONE_VALUE);
|
||||
|
||||
private static final KeyValue CHAT_CLIENT_USER_PARAM_NONE = KeyValue
|
||||
.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_USER_PARAMS, KeyValue.NONE_VALUE);
|
||||
|
||||
@Override
|
||||
public Observation.Context map(Observation.Context context) {
|
||||
if (!(context instanceof ChatClientObservationContext chatClientObservationContext)) {
|
||||
return context;
|
||||
}
|
||||
|
||||
chatClientObservationContext.addHighCardinalityKeyValue(chatClientSystemText(chatClientObservationContext))
|
||||
.addHighCardinalityKeyValue(chatClientSystemParam(chatClientObservationContext))
|
||||
.addHighCardinalityKeyValue(chatClientUserText(chatClientObservationContext))
|
||||
.addHighCardinalityKeyValue(chatClientUserParam(chatClientObservationContext));
|
||||
|
||||
return chatClientObservationContext;
|
||||
}
|
||||
|
||||
protected KeyValue chatClientSystemText(ChatClientObservationContext context) {
|
||||
if (!StringUtils.hasText(context.getRequest().getUserText())) {
|
||||
return CHAT_CLIENT_SYSTEM_TEXT_NONE;
|
||||
}
|
||||
return KeyValue.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_SYSTEM_TEXT,
|
||||
context.getRequest().getSystemText());
|
||||
}
|
||||
|
||||
protected KeyValue chatClientSystemParam(ChatClientObservationContext context) {
|
||||
if (CollectionUtils.isEmpty(context.getRequest().getSystemParams())) {
|
||||
return CHAT_CLIENT_SYSTEM_PARAM_NONE;
|
||||
}
|
||||
return KeyValue.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_SYSTEM_PARAM,
|
||||
context.getRequest()
|
||||
.getSystemParams()
|
||||
.entrySet()
|
||||
.stream()
|
||||
.map(e -> "\"" + e.getKey() + "\":\"" + e.getValue() + "\"")
|
||||
.collect(Collectors.joining(",", "[", "]")));
|
||||
}
|
||||
|
||||
protected KeyValue chatClientUserText(ChatClientObservationContext context) {
|
||||
if (!StringUtils.hasText(context.getRequest().getUserText())) {
|
||||
return CHAT_CLIENT_USER_TEXT_NONE;
|
||||
}
|
||||
return KeyValue.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_USER_TEXT,
|
||||
context.getRequest().getUserText());
|
||||
}
|
||||
|
||||
protected KeyValue chatClientUserParam(ChatClientObservationContext context) {
|
||||
if (CollectionUtils.isEmpty(context.getRequest().getUserParams())) {
|
||||
return CHAT_CLIENT_USER_PARAM_NONE;
|
||||
}
|
||||
return KeyValue.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_USER_PARAMS,
|
||||
context.getRequest()
|
||||
.getUserParams()
|
||||
.entrySet()
|
||||
.stream()
|
||||
.map(e -> "\"" + e.getKey() + "\":\"" + e.getValue() + "\"")
|
||||
.collect(Collectors.joining(",", "[", "]")));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2024 - 2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.ai.chat.client.observation;
|
||||
|
||||
import org.springframework.ai.chat.client.DefaultChatClient.DefaultChatClientRequestSpec;
|
||||
import org.springframework.ai.observation.AiOperationMetadata;
|
||||
import org.springframework.ai.observation.conventions.AiOperationType;
|
||||
import org.springframework.ai.observation.conventions.AiProvider;
|
||||
|
||||
import io.micrometer.observation.Observation;
|
||||
|
||||
/**
|
||||
* @author Christian Tzolov
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
public class ChatClientObservationContext extends Observation.Context {
|
||||
|
||||
private final boolean stream;
|
||||
|
||||
private final String format;
|
||||
|
||||
private final DefaultChatClientRequestSpec request;
|
||||
|
||||
private final AiOperationMetadata operationMetadata = new AiOperationMetadata(AiOperationType.FRAMEWORK.value(),
|
||||
AiProvider.SPRING_AI.value());
|
||||
|
||||
public ChatClientObservationContext(DefaultChatClientRequestSpec requestSpec, String format, Boolean isStream) {
|
||||
|
||||
this.request = requestSpec;
|
||||
this.format = format;
|
||||
this.stream = isStream;
|
||||
}
|
||||
|
||||
public DefaultChatClientRequestSpec getRequest() {
|
||||
return this.request;
|
||||
}
|
||||
|
||||
public AiOperationMetadata getOperationMetadata() {
|
||||
return this.operationMetadata;
|
||||
}
|
||||
|
||||
public boolean isStream() {
|
||||
return this.stream;
|
||||
}
|
||||
|
||||
public String getFormat() {
|
||||
return this.format;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2024 - 2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.ai.chat.client.observation;
|
||||
|
||||
import io.micrometer.observation.Observation;
|
||||
import io.micrometer.observation.ObservationConvention;
|
||||
|
||||
/**
|
||||
* @author Christian Tzolov
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
public interface ChatClientObservationConvention extends ObservationConvention<ChatClientObservationContext> {
|
||||
|
||||
@Override
|
||||
default boolean supportsContext(Observation.Context context) {
|
||||
return context instanceof ChatClientObservationContext;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright 2024 - 2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.ai.chat.client.observation;
|
||||
|
||||
import io.micrometer.common.docs.KeyName;
|
||||
import io.micrometer.observation.Observation;
|
||||
import io.micrometer.observation.ObservationConvention;
|
||||
import io.micrometer.observation.docs.ObservationDocumentation;
|
||||
|
||||
/**
|
||||
* @author Christian Tzolov
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public enum ChatClientObservationDocumentation implements ObservationDocumentation {
|
||||
|
||||
/**
|
||||
* AI Chat Client observations
|
||||
*/
|
||||
AI_CHAT_CLIENT {
|
||||
@Override
|
||||
public Class<? extends ObservationConvention<? extends Observation.Context>> getDefaultConvention() {
|
||||
return DefaultChatClientObservationConvention.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public KeyName[] getLowCardinalityKeyNames() {
|
||||
return LowCardinalityKeyNames.values();
|
||||
}
|
||||
|
||||
@Override
|
||||
public KeyName[] getHighCardinalityKeyNames() {
|
||||
return HighCardinalityKeyNames.values();
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
public enum LowCardinalityKeyNames implements KeyName {
|
||||
|
||||
/**
|
||||
* Spring AI kind.
|
||||
*/
|
||||
SPRING_AI_KIND {
|
||||
@Override
|
||||
public String asString() {
|
||||
return "spring.ai.kind";
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Is the chat model response a stream.
|
||||
*/
|
||||
STREAM {
|
||||
@Override
|
||||
public String asString() {
|
||||
return "spring.ai.chat.client.stream";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public enum HighCardinalityKeyNames implements KeyName {
|
||||
|
||||
/**
|
||||
* Enabled tool function names.
|
||||
*/
|
||||
CHAT_CLIENT_TOOL_FUNCTION_NAMES {
|
||||
@Override
|
||||
public String asString() {
|
||||
return "spring.ai.chat.client.tool.function.names";
|
||||
}
|
||||
},
|
||||
/**
|
||||
* List of configured chat client function callbacks.
|
||||
*/
|
||||
CHAT_CLIENT_TOOL_FUNCTION_CALLBACKS {
|
||||
@Override
|
||||
public String asString() {
|
||||
return "spring.ai.chat.client.tool.functioncallbacks";
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* List of configured chat client advisors.
|
||||
*/
|
||||
CHAT_CLIENT_ADVISORS {
|
||||
@Override
|
||||
public String asString() {
|
||||
return "spring.ai.chat.client.advisors";
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Map of advisor parameters.
|
||||
*/
|
||||
CHAT_CLIENT_ADVISOR_PARAMS {
|
||||
@Override
|
||||
public String asString() {
|
||||
return "spring.ai.chat.client.advisor.params";
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Chat client user text.
|
||||
*/
|
||||
CHAT_CLIENT_USER_TEXT {
|
||||
@Override
|
||||
public String asString() {
|
||||
return "spring.ai.chat.client.user.text";
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Chat client user parameters.
|
||||
*/
|
||||
CHAT_CLIENT_USER_PARAMS {
|
||||
@Override
|
||||
public String asString() {
|
||||
return "spring.ai.chat.client.user.params";
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Chat client system text.
|
||||
*/
|
||||
CHAT_CLIENT_SYSTEM_TEXT {
|
||||
@Override
|
||||
public String asString() {
|
||||
return "spring.ai.chat.client.system.text";
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Chat client system parameters.
|
||||
*/
|
||||
CHAT_CLIENT_SYSTEM_PARAM {
|
||||
@Override
|
||||
public String asString() {
|
||||
return "spring.ai.chat.client.system.params";
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright 2024 - 2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.ai.chat.client.observation;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.ai.chat.client.observation.ChatClientObservationDocumentation.LowCardinalityKeyNames;
|
||||
import org.springframework.ai.chat.observation.ChatModelObservationDocumentation;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
|
||||
import io.micrometer.common.KeyValue;
|
||||
import io.micrometer.common.KeyValues;
|
||||
|
||||
/**
|
||||
* @author Christian Tzolov
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
public class DefaultChatClientObservationConvention implements ChatClientObservationConvention {
|
||||
|
||||
public static final String DEFAULT_NAME = "spring.ai.chat.client.operation";
|
||||
|
||||
private static final String CHAT_CLIENT_SPRING_AI_KIND = "chat_client";
|
||||
|
||||
private static final KeyValue CHAT_CLIENT_TOOL_FUNCTION_CALLBACKS_NONE = KeyValue.of(
|
||||
ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_TOOL_FUNCTION_CALLBACKS,
|
||||
KeyValue.NONE_VALUE);
|
||||
|
||||
private static final KeyValue CHAT_CLIENT_TOOL_FUNCTION_NAMES_NONE = KeyValue.of(
|
||||
ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_TOOL_FUNCTION_NAMES,
|
||||
KeyValue.NONE_VALUE);
|
||||
|
||||
private static final KeyValue CHAT_CLIENT_ADVISOR_NONE = KeyValue
|
||||
.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_ADVISORS, KeyValue.NONE_VALUE);
|
||||
|
||||
private static final KeyValue CHAT_CLIENT_ADVISOR_PARAM_NONE = KeyValue
|
||||
.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_ADVISOR_PARAMS, KeyValue.NONE_VALUE);
|
||||
|
||||
private final String name;
|
||||
|
||||
public DefaultChatClientObservationConvention() {
|
||||
this(DEFAULT_NAME);
|
||||
}
|
||||
|
||||
public DefaultChatClientObservationConvention(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public String getContextualName(ChatClientObservationContext context) {
|
||||
return "%s %s".formatted(context.getOperationMetadata().provider(), CHAT_CLIENT_SPRING_AI_KIND);
|
||||
}
|
||||
|
||||
@Override
|
||||
public KeyValues getLowCardinalityKeyValues(ChatClientObservationContext context) {
|
||||
return KeyValues.of(springAiKind(context), aiOperationType(context), aiProvider(context), stream(context));
|
||||
}
|
||||
|
||||
@Override
|
||||
public KeyValues getHighCardinalityKeyValues(ChatClientObservationContext context) {
|
||||
return KeyValues.of(toolFunctionNames(context), toolFunctionCallbacks(context), chatClientAvisor(context),
|
||||
chatClientAvisorParam(context));
|
||||
}
|
||||
|
||||
protected KeyValue springAiKind(ChatClientObservationContext context) {
|
||||
return KeyValue.of(ChatClientObservationDocumentation.LowCardinalityKeyNames.SPRING_AI_KIND,
|
||||
CHAT_CLIENT_SPRING_AI_KIND);
|
||||
}
|
||||
|
||||
protected KeyValue stream(ChatClientObservationContext context) {
|
||||
return KeyValue.of(LowCardinalityKeyNames.STREAM, "" + context.isStream());
|
||||
}
|
||||
|
||||
protected KeyValue aiOperationType(ChatClientObservationContext context) {
|
||||
return KeyValue.of(ChatModelObservationDocumentation.LowCardinalityKeyNames.AI_OPERATION_TYPE,
|
||||
context.getOperationMetadata().operationType());
|
||||
}
|
||||
|
||||
protected KeyValue aiProvider(ChatClientObservationContext context) {
|
||||
return KeyValue.of(ChatModelObservationDocumentation.LowCardinalityKeyNames.AI_PROVIDER,
|
||||
context.getOperationMetadata().provider());
|
||||
}
|
||||
|
||||
protected KeyValue toolFunctionNames(ChatClientObservationContext context) {
|
||||
if (CollectionUtils.isEmpty(context.getRequest().getFunctionNames())) {
|
||||
return CHAT_CLIENT_TOOL_FUNCTION_NAMES_NONE;
|
||||
}
|
||||
return KeyValue.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_TOOL_FUNCTION_NAMES,
|
||||
context.getRequest()
|
||||
.getFunctionNames()
|
||||
.stream()
|
||||
.map(v -> "\"" + v + "\"")
|
||||
.collect(Collectors.joining(",", "[", "]")));
|
||||
}
|
||||
|
||||
protected KeyValue toolFunctionCallbacks(ChatClientObservationContext context) {
|
||||
if (CollectionUtils.isEmpty(context.getRequest().getFunctionCallbacks())) {
|
||||
return CHAT_CLIENT_TOOL_FUNCTION_CALLBACKS_NONE;
|
||||
}
|
||||
|
||||
return KeyValue.of(
|
||||
ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_TOOL_FUNCTION_CALLBACKS,
|
||||
context.getRequest()
|
||||
.getFunctionCallbacks()
|
||||
.stream()
|
||||
.map(fc -> "\"" + fc.getName() + "\"")
|
||||
.collect(Collectors.joining(",", "[", "]")));
|
||||
}
|
||||
|
||||
protected KeyValue chatClientAvisor(ChatClientObservationContext context) {
|
||||
if (CollectionUtils.isEmpty(context.getRequest().getAdvisors())) {
|
||||
return CHAT_CLIENT_ADVISOR_NONE;
|
||||
}
|
||||
return KeyValue.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_ADVISORS,
|
||||
context.getRequest()
|
||||
.getAdvisors()
|
||||
.stream()
|
||||
.map(a -> "\"" + a.getName() + "\"")
|
||||
.collect(Collectors.joining(",", "[", "]")));
|
||||
}
|
||||
|
||||
protected KeyValue chatClientAvisorParam(ChatClientObservationContext context) {
|
||||
if (CollectionUtils.isEmpty(context.getRequest().getAdvisorParams())) {
|
||||
return CHAT_CLIENT_ADVISOR_PARAM_NONE;
|
||||
}
|
||||
return KeyValue.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_ADVISOR_PARAMS,
|
||||
context.getRequest()
|
||||
.getAdvisorParams()
|
||||
.entrySet()
|
||||
.stream()
|
||||
.map(e -> "\"" + e.getKey() + "\":\"" + e.getValue() + "\"")
|
||||
.collect(Collectors.joining(",", "[", "]")));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -31,6 +31,7 @@ public enum AiOperationType {
|
||||
|
||||
CHAT("chat"),
|
||||
EMBEDDING("embedding"),
|
||||
FRAMEWORK("framework"),
|
||||
IMAGE("image"),
|
||||
TEXT_COMPLETION("text_completion");
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ public enum AiProvider {
|
||||
MISTRAL_AI("mistral_ai"),
|
||||
OLLAMA("ollama"),
|
||||
OPENAI("openai"),
|
||||
SPRING_AI("spring_ai"),
|
||||
VERTEX_AI("vertex_ai");
|
||||
|
||||
private final String value;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright 2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.ai.chat.client.observation;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.ai.chat.client.DefaultChatClient.DefaultChatClientRequestSpec;
|
||||
import org.springframework.ai.chat.client.observation.ChatClientObservationDocumentation.HighCardinalityKeyNames;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
|
||||
import io.micrometer.common.KeyValue;
|
||||
import io.micrometer.observation.Observation;
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link ChatClientImportContentObservationFilter}.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ChatClientInputContentObservationFilterTests {
|
||||
|
||||
private final ChatClientInputContentObservationFilter observationFilter = new ChatClientInputContentObservationFilter();
|
||||
|
||||
@Test
|
||||
void whenNotSupportedObservationContextThenReturnOriginalContext() {
|
||||
var expectedContext = new Observation.Context();
|
||||
var actualContext = observationFilter.map(expectedContext);
|
||||
|
||||
assertThat(actualContext).isEqualTo(expectedContext);
|
||||
}
|
||||
|
||||
@Mock
|
||||
ChatModel chatModel;
|
||||
|
||||
@Test
|
||||
void whenEmptyInputContentThenReturnOriginalContext() {
|
||||
|
||||
ObservationRegistry observationRegistry = ObservationRegistry.NOOP;
|
||||
ChatClientObservationConvention customObservationConvention = null;
|
||||
|
||||
var request = new DefaultChatClientRequestSpec(chatModel, "", Map.of(), "", Map.of(), List.of(), List.of(),
|
||||
List.of(), List.of(), null, List.of(), Map.of(), observationRegistry, customObservationConvention);
|
||||
|
||||
var expectedContext = new ChatClientObservationContext(request, "", false);
|
||||
|
||||
var actualContext = observationFilter.map(expectedContext);
|
||||
|
||||
assertThat(actualContext).isEqualTo(expectedContext);
|
||||
}
|
||||
|
||||
@Test
|
||||
void whenWithTextThenAugmentContext() {
|
||||
|
||||
ObservationRegistry observationRegistry = ObservationRegistry.NOOP;
|
||||
ChatClientObservationConvention customObservationConvention = null;
|
||||
|
||||
var request = new DefaultChatClientRequestSpec(chatModel, "sample user text", Map.of("up1", "upv1"),
|
||||
"sample system text", Map.of("sp1", "sp1v"), List.of(), List.of(), List.of(), List.of(), null,
|
||||
List.of(), Map.of(), observationRegistry, customObservationConvention);
|
||||
|
||||
var originalContext = new ChatClientObservationContext(request, "", false);
|
||||
|
||||
var augmentedContext = observationFilter.map(originalContext);
|
||||
|
||||
assertThat(augmentedContext.getHighCardinalityKeyValues())
|
||||
.contains(KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_USER_TEXT.asString(), "sample user text"));
|
||||
assertThat(augmentedContext.getHighCardinalityKeyValues())
|
||||
.contains(KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_USER_PARAMS.asString(), "[\"up1\":\"upv1\"]"));
|
||||
assertThat(augmentedContext.getHighCardinalityKeyValues())
|
||||
.contains(KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_SYSTEM_TEXT.asString(), "sample system text"));
|
||||
assertThat(augmentedContext.getHighCardinalityKeyValues())
|
||||
.contains(KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_SYSTEM_PARAM.asString(), "[\"sp1\":\"sp1v\"]"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.ai.chat.client.observation;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.ai.chat.client.DefaultChatClient.DefaultChatClientRequestSpec;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link ChatClientObservationContext}.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ChatClientObservationContextTests {
|
||||
|
||||
@Mock
|
||||
ChatModel chatModel;
|
||||
|
||||
@Test
|
||||
void whenMandatoryRequestOptionsThenReturn() {
|
||||
|
||||
var request = new DefaultChatClientRequestSpec(chatModel, "", Map.of(), "", Map.of(), List.of(), List.of(),
|
||||
List.of(), List.of(), null, List.of(), Map.of(), ObservationRegistry.NOOP, null);
|
||||
|
||||
var observationContext = new ChatClientObservationContext(request, "", true);
|
||||
|
||||
assertThat(observationContext).isNotNull();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Copyright 2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.ai.chat.client.observation;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.ai.chat.client.DefaultChatClient.DefaultChatClientRequestSpec;
|
||||
import org.springframework.ai.chat.client.RequestResponseAdvisor;
|
||||
import org.springframework.ai.chat.client.observation.ChatClientObservationDocumentation.HighCardinalityKeyNames;
|
||||
import org.springframework.ai.chat.client.observation.ChatClientObservationDocumentation.LowCardinalityKeyNames;
|
||||
import org.springframework.ai.chat.metadata.Usage;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.model.function.FunctionCallback;
|
||||
|
||||
import io.micrometer.common.KeyValue;
|
||||
import io.micrometer.observation.Observation;
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link DefaultChatClientObservationConvention}.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DefaultChatClientObservationConventionTests {
|
||||
|
||||
@Mock
|
||||
ChatModel chatModel;
|
||||
|
||||
private final DefaultChatClientObservationConvention observationConvention = new DefaultChatClientObservationConvention();
|
||||
|
||||
DefaultChatClientRequestSpec request;
|
||||
|
||||
@BeforeEach
|
||||
public void beforeEach() {
|
||||
request = new DefaultChatClientRequestSpec(chatModel, "", Map.of(), "", Map.of(), List.of(), List.of(),
|
||||
List.of(), List.of(), null, List.of(), Map.of(), ObservationRegistry.NOOP, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHaveName() {
|
||||
assertThat(this.observationConvention.getName()).isEqualTo(DefaultChatClientObservationConvention.DEFAULT_NAME);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHaveContextualName() {
|
||||
ChatClientObservationContext observationContext = new ChatClientObservationContext(request, "", true);
|
||||
|
||||
assertThat(this.observationConvention.getContextualName(observationContext)).isEqualTo("spring_ai chat_client");
|
||||
}
|
||||
|
||||
@Test
|
||||
void supportsOnlyChatClientObservationContext() {
|
||||
ChatClientObservationContext observationContext = new ChatClientObservationContext(request, "", true);
|
||||
|
||||
assertThat(this.observationConvention.supportsContext(observationContext)).isTrue();
|
||||
assertThat(this.observationConvention.supportsContext(new Observation.Context())).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHaveRequiredKeyValues() {
|
||||
ChatClientObservationContext observationContext = new ChatClientObservationContext(request, "", true);
|
||||
|
||||
assertThat(this.observationConvention.getLowCardinalityKeyValues(observationContext)).contains(
|
||||
KeyValue.of(LowCardinalityKeyNames.SPRING_AI_KIND.asString(), "chat_client"),
|
||||
KeyValue.of(LowCardinalityKeyNames.STREAM.asString(), "true"));
|
||||
}
|
||||
|
||||
static RequestResponseAdvisor dummyAdvisor(String name) {
|
||||
return new RequestResponseAdvisor() {
|
||||
@Override
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static FunctionCallback dummyFunction(String name) {
|
||||
return new FunctionCallback() {
|
||||
@Override
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDescription() {
|
||||
// TODO Auto-generated method stub
|
||||
throw new UnsupportedOperationException("Unimplemented method 'getDescription'");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getInputTypeSchema() {
|
||||
// TODO Auto-generated method stub
|
||||
throw new UnsupportedOperationException("Unimplemented method 'getInputTypeSchema'");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String call(String functionInput) {
|
||||
// TODO Auto-generated method stub
|
||||
throw new UnsupportedOperationException("Unimplemented method 'call'");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHaveOptionalKeyValues() {
|
||||
|
||||
var request = new DefaultChatClientRequestSpec(chatModel, "", Map.of(), "", Map.of(),
|
||||
List.of(dummyFunction("functionCallback1"), dummyFunction("functionCallback2")), List.of(),
|
||||
List.of("function1", "function2"), List.of(), null,
|
||||
List.of(dummyAdvisor("advisor1"), dummyAdvisor("advisor2")), Map.of("advParam1", "advisorParam1Value"),
|
||||
ObservationRegistry.NOOP, null);
|
||||
|
||||
ChatClientObservationContext observationContext = new ChatClientObservationContext(request, "json", true);
|
||||
|
||||
assertThat(this.observationConvention.getHighCardinalityKeyValues(observationContext)).contains(
|
||||
KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_ADVISORS.asString(), "[\"advisor1\",\"advisor2\"]"),
|
||||
KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_ADVISOR_PARAMS.asString(),
|
||||
"[\"advParam1\":\"advisorParam1Value\"]"),
|
||||
KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_TOOL_FUNCTION_NAMES.asString(),
|
||||
"[\"function1\",\"function2\"]"),
|
||||
KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_TOOL_FUNCTION_CALLBACKS.asString(),
|
||||
"[\"functionCallback1\",\"functionCallback2\"]"));
|
||||
}
|
||||
|
||||
static class TestUsage implements Usage {
|
||||
|
||||
@Override
|
||||
public Long getPromptTokens() {
|
||||
return 1000L;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long getGenerationTokens() {
|
||||
return 500L;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -16,9 +16,13 @@
|
||||
|
||||
package org.springframework.ai.autoconfigure.chat.client;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.client.ChatClientCustomizer;
|
||||
import org.springframework.ai.chat.client.observation.ChatClientInputContentObservationFilter;
|
||||
import org.springframework.ai.chat.client.observation.ChatClientObservationConvention;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
@@ -29,6 +33,8 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Scope;
|
||||
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
|
||||
/**
|
||||
* {@link EnableAutoConfiguration Auto-configuration} for {@link ChatClient}.
|
||||
* <p>
|
||||
@@ -49,6 +55,8 @@ import org.springframework.context.annotation.Scope;
|
||||
matchIfMissing = true)
|
||||
public class ChatClientAutoConfiguration {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ChatClientAutoConfiguration.class);
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
ChatClientBuilderConfigurer chatClientBuilderConfigurer(ObjectProvider<ChatClientCustomizer> customizerProvider) {
|
||||
@@ -59,9 +67,24 @@ public class ChatClientAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@Scope("prototype")
|
||||
ChatClient.Builder chatClientBuilder(ChatClientBuilderConfigurer chatClientBuilderConfigurer, ChatModel chatModel) {
|
||||
ChatClient.Builder builder = ChatClient.builder(chatModel);
|
||||
ChatClient.Builder chatClientBuilder(ChatClientBuilderConfigurer chatClientBuilderConfigurer, ChatModel chatModel,
|
||||
ObjectProvider<ObservationRegistry> observationRegistry,
|
||||
ObjectProvider<ChatClientObservationConvention> observationConvention) {
|
||||
|
||||
ChatClient.Builder builder = ChatClient.builder(chatModel,
|
||||
observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP),
|
||||
observationConvention.getIfUnique(() -> null));
|
||||
return chatClientBuilderConfigurer.configure(builder);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
@ConditionalOnProperty(prefix = ChatClientBuilderProperties.CONFIG_PREFIX + ".observations", name = "include-input",
|
||||
havingValue = "true")
|
||||
ChatClientInputContentObservationFilter chatClientInputContentObservationFilter() {
|
||||
logger.warn(
|
||||
"You have enabled the inclusion of the input content in the observations, with the risk of exposing sensitive or private information. Please, be careful!");
|
||||
return new ChatClientInputContentObservationFilter();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
* @author Mark Pollack
|
||||
* @author Josh Long
|
||||
* @author Arjen Poutsma
|
||||
* @since 1.0.0 M1
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@ConfigurationProperties(ChatClientBuilderProperties.CONFIG_PREFIX)
|
||||
public class ChatClientBuilderProperties {
|
||||
@@ -36,12 +36,35 @@ public class ChatClientBuilderProperties {
|
||||
*/
|
||||
private boolean enabled = true;
|
||||
|
||||
private Observations observations = new Observations();
|
||||
|
||||
public Observations getObservations() {
|
||||
return this.observations;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public static class Observations {
|
||||
|
||||
/**
|
||||
* Whether to include the input content in the observations.
|
||||
*/
|
||||
private boolean includeInput = false;
|
||||
|
||||
public boolean isIncludeInput() {
|
||||
return includeInput;
|
||||
}
|
||||
|
||||
public void setIncludeInput(boolean includeCompletion) {
|
||||
this.includeInput = includeCompletion;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.ai.autoconfigure.chat.client;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.client.observation.ChatClientInputContentObservationFilter;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link ChatClientAutoConfiguration} observability support.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
*/
|
||||
class ChatClientObservationAutoConfigurationTests {
|
||||
|
||||
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(ChatClientAutoConfiguration.class));
|
||||
|
||||
@Test
|
||||
void inputContentFilterDefault() {
|
||||
contextRunner.run(context -> {
|
||||
assertThat(context).doesNotHaveBean(ChatClientInputContentObservationFilter.class);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void inputContentFilterEnabled() {
|
||||
contextRunner.withPropertyValues("spring.ai.chat.client.observations.include-input=true").run(context -> {
|
||||
assertThat(context).hasSingleBean(ChatClientInputContentObservationFilter.class);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user