Make ChatClient and Advisor APIs more robust - Part 2

* ChatClient observations now include the full prompt content instead of just the userText and systemText. Furthermore, they include consistent telemetry for the tools passed via the ChatClient and a first-class conversation ID when using memory advisors. Incomplete or unsafe attributes have been deprecated.
* Adopted the new robust Advisor APIs for BaseAdvisor and RetrievalAugmentationAdvisor.
* Improved the prompt augmentation facilities in ChatClientRequest and Prompt for performance and immutability.
* Fixed integration test racing condition.
* Updated the documentation for ChatClient and Observability accordingly.
* Documented changes in upgrade notes.
* Introduced `prompt.augmentUserMessage(String text)` to directly replace the user message content.
* Added `prompt.augmentUserMessage(Function<UserMessage, UserMessage> augmenter)` for more granular updates using the `userMessage.mutate()` pattern, allowing modification of text, media, and metadata.

Relates to gh-2655

Signed-off-by: Thomas Vitale <ThomasVitale@users.noreply.github.com>
This commit is contained in:
Thomas Vitale
2025-04-27 11:46:44 +02:00
committed by Mark Pollack
parent 0c0787b849
commit faa8778b6a
42 changed files with 1368 additions and 158 deletions

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2023-2024 the original author or authors.
* Copyright 2023-2025 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.
@@ -24,6 +24,7 @@ import org.springframework.ai.chat.client.ChatClient;
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.client.observation.ChatClientPromptContentObservationFilter;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfiguration;
@@ -79,14 +80,28 @@ public class ChatClientAutoConfiguration {
return chatClientBuilderConfigurer.configure(builder);
}
/**
* @deprecated in favour of {@link #chatClientPromptContentObservationFilter()}.
*/
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = ChatClientBuilderProperties.CONFIG_PREFIX + ".observations", name = "include-input",
havingValue = "true")
@Deprecated
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();
}
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = ChatClientBuilderProperties.CONFIG_PREFIX + ".observations",
name = "include-prompt", havingValue = "true")
ChatClientPromptContentObservationFilter chatClientPromptContentObservationFilter() {
logger.warn(
"You have enabled the inclusion of the ChatClient prompt content in the observations, with the risk of exposing sensitive or private information. Please, be careful!");
return new ChatClientPromptContentObservationFilter();
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2023-2024 the original author or authors.
* Copyright 2023-2025 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.
@@ -17,6 +17,7 @@
package org.springframework.ai.model.chat.client.autoconfigure;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.DeprecatedConfigurationProperty;
/**
* Configuration properties for the chat client builder.
@@ -25,6 +26,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
* @author Mark Pollack
* @author Josh Long
* @author Arjen Poutsma
* @author Thomas Vitale
* @since 1.0.0
*/
@ConfigurationProperties(ChatClientBuilderProperties.CONFIG_PREFIX)
@@ -37,7 +39,7 @@ public class ChatClientBuilderProperties {
*/
private boolean enabled = true;
private Observations observations = new Observations();
private final Observations observations = new Observations();
public Observations getObservations() {
return this.observations;
@@ -55,9 +57,17 @@ public class ChatClientBuilderProperties {
/**
* Whether to include the input content in the observations.
* @deprecated Use {@link #includePrompt} instead.
*/
@Deprecated
private boolean includeInput = false;
/**
* Whether to include the prompt content in the observations.
*/
private boolean includePrompt = false;
@DeprecatedConfigurationProperty(replacement = "spring.ai.chat.observations.include-prompt")
public boolean isIncludeInput() {
return this.includeInput;
}
@@ -66,6 +76,14 @@ public class ChatClientBuilderProperties {
this.includeInput = includeCompletion;
}
public boolean isIncludePrompt() {
return this.includePrompt;
}
public void setIncludePrompt(boolean includePrompt) {
this.includePrompt = includePrompt;
}
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2023-2024 the original author or authors.
* Copyright 2023-2025 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.
@@ -19,6 +19,7 @@ package org.springframework.ai.model.chat.client.autoconfigure;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.observation.ChatClientInputContentObservationFilter;
import org.springframework.ai.chat.client.observation.ChatClientPromptContentObservationFilter;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
@@ -28,6 +29,7 @@ import static org.assertj.core.api.Assertions.assertThat;
* Unit tests for {@link ChatClientAutoConfiguration} observability support.
*
* @author Christian Tzolov
* @author Thomas Vitale
*/
class ChatClientObservationAutoConfigurationTests {
@@ -46,4 +48,16 @@ class ChatClientObservationAutoConfigurationTests {
.run(context -> assertThat(context).hasSingleBean(ChatClientInputContentObservationFilter.class));
}
@Test
void promptContentFilterDefault() {
this.contextRunner
.run(context -> assertThat(context).doesNotHaveBean(ChatClientPromptContentObservationFilter.class));
}
@Test
void promptContentFilterEnabled() {
this.contextRunner.withPropertyValues("spring.ai.chat.client.observations.include-prompt=true")
.run(context -> assertThat(context).hasSingleBean(ChatClientPromptContentObservationFilter.class));
}
}

View File

@@ -21,7 +21,10 @@ package org.springframework.ai.chat.client;
*
* @author Thomas Vitale
* @since 1.0.0
* @deprecated only introduced to smooth the transition to the new APIs and ensure
* backward compatibility
*/
@Deprecated
public enum ChatClientAttributes {
//@formatter:off

View File

@@ -39,8 +39,12 @@ public record ChatClientRequest(Prompt prompt, Map<String, Object> context) {
Assert.noNullElements(context.keySet(), "context keys cannot be null");
}
public ChatClientRequest copy() {
return new ChatClientRequest(this.prompt.copy(), new HashMap<>(this.context));
}
public Builder mutate() {
return new Builder().prompt(this.prompt).context(this.context);
return new Builder().prompt(this.prompt.copy()).context(new HashMap<>(this.context));
}
public static Builder builder() {

View File

@@ -38,6 +38,14 @@ public record ChatClientResponse(@Nullable ChatResponse chatResponse, Map<String
Assert.noNullElements(context.keySet(), "context keys cannot be null");
}
public ChatClientResponse copy() {
return new ChatClientResponse(this.chatResponse, new HashMap<>(this.context));
}
public Builder mutate() {
return new Builder().chatResponse(this.chatResponse).context(new HashMap<>(this.context));
}
public static Builder builder() {
return new Builder();
}
@@ -51,7 +59,7 @@ public record ChatClientResponse(@Nullable ChatResponse chatResponse, Map<String
private Builder() {
}
public Builder chatResponse(ChatResponse chatResponse) {
public Builder chatResponse(@Nullable ChatResponse chatResponse) {
this.chatResponse = chatResponse;
return this;
}

View File

@@ -493,10 +493,11 @@ public class DefaultChatClient implements ChatClient {
private ChatClientResponse doGetObservableChatClientResponse(ChatClientRequest chatClientRequest,
@Nullable String outputFormat) {
ChatClientRequest formattedChatClientRequest = StringUtils.hasText(outputFormat)
? addFormatInstructionsToPrompt(chatClientRequest, outputFormat) : chatClientRequest;
? augmentPromptWithFormatInstructions(chatClientRequest, outputFormat) : chatClientRequest;
ChatClientObservationContext observationContext = ChatClientObservationContext.builder()
.request(formattedChatClientRequest)
.advisors(advisorChain.getCallAdvisors())
.stream(false)
.withFormat(outputFormat)
.build();
@@ -511,42 +512,16 @@ public class DefaultChatClient implements ChatClient {
}
@NonNull
private static ChatClientRequest addFormatInstructionsToPrompt(ChatClientRequest chatClientRequest,
private static ChatClientRequest augmentPromptWithFormatInstructions(ChatClientRequest chatClientRequest,
String outputFormat) {
List<Message> originalMessages = chatClientRequest.prompt().getInstructions();
if (CollectionUtils.isEmpty(originalMessages)) {
return chatClientRequest;
}
// Create a copy of the message list to avoid modifying the original.
List<Message> modifiedMessages = new ArrayList<>(originalMessages);
// Get the last message (without removing it from original list)
Message lastMessage = modifiedMessages.get(modifiedMessages.size() - 1);
// If the last message is a UserMessage, replace it with the modified version
if (lastMessage instanceof UserMessage userMessage) {
// Remove last message
modifiedMessages.remove(modifiedMessages.size() - 1);
// Create new user message with format instructions
UserMessage userMessageWithFormat = userMessage.mutate()
Prompt augmentedPrompt = chatClientRequest.prompt()
.augmentUserMessage(userMessage -> userMessage.mutate()
.text(userMessage.getText() + System.lineSeparator() + outputFormat)
.build();
// Add modified message back
modifiedMessages.add(userMessageWithFormat);
// Build new ChatClientRequest preserving all properties but with modified
// prompt
return ChatClientRequest.builder()
.prompt(chatClientRequest.prompt().mutate().messages(modifiedMessages).build())
.context(Map.copyOf(chatClientRequest.context()))
.build();
}
return chatClientRequest;
.build());
return ChatClientRequest.builder()
.prompt(augmentedPrompt)
.context(Map.copyOf(chatClientRequest.context()))
.build();
}
@Nullable
@@ -588,6 +563,7 @@ public class DefaultChatClient implements ChatClient {
ChatClientObservationContext observationContext = ChatClientObservationContext.builder()
.request(chatClientRequest)
.advisors(advisorChain.getStreamAdvisors())
.stream(true)
.build();
@@ -660,8 +636,6 @@ public class DefaultChatClient implements ChatClient {
private final Map<String, Object> advisorParams = new HashMap<>();
private final DefaultAroundAdvisorChain.Builder aroundAdvisorChainBuilder;
private final Map<String, Object> toolContext = new HashMap<>();
@Nullable
@@ -718,14 +692,6 @@ public class DefaultChatClient implements ChatClient {
this.observationConvention = observationConvention != null ? observationConvention
: DEFAULT_CHAT_CLIENT_OBSERVATION_CONVENTION;
this.toolContext.putAll(toolContext);
// At the stack bottom add the model call advisors.
// They play the role of the last advisors in the advisor chain.
this.advisors.add(new ChatModelCallAdvisor(chatModel));
this.advisors.add(new ChatModelStreamAdvisor(chatModel));
this.aroundAdvisorChainBuilder = DefaultAroundAdvisorChain.builder(observationRegistry)
.pushAll(this.advisors);
}
private ObservationRegistry getObservationRegistry() {
@@ -822,7 +788,6 @@ public class DefaultChatClient implements ChatClient {
consumer.accept(advisorSpec);
this.advisorParams.putAll(advisorSpec.getParams());
this.advisors.addAll(advisorSpec.getAdvisors());
this.aroundAdvisorChainBuilder.pushAll(advisorSpec.getAdvisors());
return this;
}
@@ -830,7 +795,6 @@ public class DefaultChatClient implements ChatClient {
Assert.notNull(advisors, "advisors cannot be null");
Assert.noNullElements(advisors, "advisors cannot contain null elements");
this.advisors.addAll(Arrays.asList(advisors));
this.aroundAdvisorChainBuilder.pushAll(Arrays.asList(advisors));
return this;
}
@@ -838,7 +802,6 @@ public class DefaultChatClient implements ChatClient {
Assert.notNull(advisors, "advisors cannot be null");
Assert.noNullElements(advisors, "advisors cannot contain null elements");
this.advisors.addAll(advisors);
this.aroundAdvisorChainBuilder.pushAll(advisors);
return this;
}
@@ -983,17 +946,26 @@ public class DefaultChatClient implements ChatClient {
}
public CallResponseSpec call() {
BaseAdvisorChain advisorChain = aroundAdvisorChainBuilder.build();
BaseAdvisorChain advisorChain = buildAdvisorChain();
return new DefaultCallResponseSpec(toAdvisedRequest(this).toChatClientRequest(), advisorChain,
observationRegistry, observationConvention);
}
public StreamResponseSpec stream() {
BaseAdvisorChain advisorChain = aroundAdvisorChainBuilder.build();
BaseAdvisorChain advisorChain = buildAdvisorChain();
return new DefaultStreamResponseSpec(toAdvisedRequest(this).toChatClientRequest(), advisorChain,
observationRegistry, observationConvention);
}
private BaseAdvisorChain buildAdvisorChain() {
// At the stack bottom add the model call advisors.
// They play the role of the last advisors in the advisor chain.
this.advisors.add(ChatModelCallAdvisor.builder().chatModel(this.chatModel).build());
this.advisors.add(ChatModelStreamAdvisor.builder().chatModel(this.chatModel).build());
return DefaultAroundAdvisorChain.builder(this.observationRegistry).pushAll(this.advisors).build();
}
}
// Prompt

View File

@@ -0,0 +1,49 @@
/*
* Copyright 2023-2025 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.advisor;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.util.StringUtils;
import java.util.function.Predicate;
/**
* Utilities to work with advisors.
*/
public final class AdvisorUtils {
private AdvisorUtils() {
}
/**
* Checks whether the provided {@link ChatClientResponse} contains a
* {@link ChatResponse} with at least one result having a non-empty finish reason in
* its metadata.
*/
public static Predicate<ChatClientResponse> onFinishReason() {
return chatClientResponse -> {
ChatResponse chatResponse = chatClientResponse.chatResponse();
return chatResponse != null && chatResponse.getResults() != null
&& chatResponse.getResults()
.stream()
.anyMatch(result -> result != null && result.getMetadata() != null
&& StringUtils.hasText(result.getMetadata().getFinishReason()));
};
}
}

View File

@@ -37,7 +37,8 @@ public final class ChatModelCallAdvisor implements CallAdvisor {
private final ChatModel chatModel;
public ChatModelCallAdvisor(ChatModel chatModel) {
private ChatModelCallAdvisor(ChatModel chatModel) {
Assert.notNull(chatModel, "chatModel cannot be null");
this.chatModel = chatModel;
}
@@ -62,4 +63,26 @@ public final class ChatModelCallAdvisor implements CallAdvisor {
return Ordered.LOWEST_PRECEDENCE;
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private ChatModel chatModel;
private Builder() {
}
public Builder chatModel(ChatModel chatModel) {
this.chatModel = chatModel;
return this;
}
public ChatModelCallAdvisor build() {
return new ChatModelCallAdvisor(this.chatModel);
}
}
}

View File

@@ -37,7 +37,8 @@ public final class ChatModelStreamAdvisor implements StreamAdvisor {
private final ChatModel chatModel;
public ChatModelStreamAdvisor(ChatModel chatModel) {
private ChatModelStreamAdvisor(ChatModel chatModel) {
Assert.notNull(chatModel, "chatModel cannot be null");
this.chatModel = chatModel;
}
@@ -63,4 +64,26 @@ public final class ChatModelStreamAdvisor implements StreamAdvisor {
return Ordered.LOWEST_PRECEDENCE;
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private ChatModel chatModel;
private Builder() {
}
public Builder chatModel(ChatModel chatModel) {
this.chatModel = chatModel;
return this;
}
public ChatModelStreamAdvisor build() {
return new ChatModelStreamAdvisor(this.chatModel);
}
}
}

View File

@@ -57,6 +57,10 @@ public class DefaultAroundAdvisorChain implements BaseAdvisorChain {
public static final AdvisorObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultAdvisorObservationConvention();
private final List<CallAroundAdvisor> originalCallAdvisors;
private final List<StreamAroundAdvisor> originalStreamAdvisors;
private final Deque<CallAroundAdvisor> callAroundAdvisors;
private final Deque<StreamAroundAdvisor> streamAroundAdvisors;
@@ -73,6 +77,8 @@ public class DefaultAroundAdvisorChain implements BaseAdvisorChain {
this.observationRegistry = observationRegistry;
this.callAroundAdvisors = callAroundAdvisors;
this.streamAroundAdvisors = streamAroundAdvisors;
this.originalCallAdvisors = List.copyOf(callAroundAdvisors);
this.originalStreamAdvisors = List.copyOf(streamAroundAdvisors);
}
public static Builder builder(ObservationRegistry observationRegistry) {
@@ -232,6 +238,21 @@ public class DefaultAroundAdvisorChain implements BaseAdvisorChain {
});
}
@Override
public List<CallAroundAdvisor> getCallAdvisors() {
return this.originalCallAdvisors;
}
@Override
public List<StreamAroundAdvisor> getStreamAdvisors() {
return this.originalStreamAdvisors;
}
@Override
public ObservationRegistry getObservationRegistry() {
return this.observationRegistry;
}
public static class Builder {
private final ObservationRegistry observationRegistry;
@@ -246,13 +267,14 @@ public class DefaultAroundAdvisorChain implements BaseAdvisorChain {
this.streamAroundAdvisors = new ConcurrentLinkedDeque<>();
}
public Builder push(Advisor aroundAdvisor) {
Assert.notNull(aroundAdvisor, "the aroundAdvisor must be non-null");
return this.pushAll(List.of(aroundAdvisor));
public Builder push(Advisor advisor) {
Assert.notNull(advisor, "the advisor must be non-null");
return this.pushAll(List.of(advisor));
}
public Builder pushAll(List<? extends Advisor> advisors) {
Assert.notNull(advisors, "the advisors must be non-null");
Assert.noNullElements(advisors, "the advisors must not contain null elements");
if (!CollectionUtils.isEmpty(advisors)) {
List<CallAroundAdvisor> callAroundAdvisorList = advisors.stream()
.filter(a -> a instanceof CallAroundAdvisor)

View File

@@ -18,12 +18,16 @@ package org.springframework.ai.chat.client.advisor.api;
import java.util.function.Predicate;
import org.springframework.ai.chat.client.advisor.AdvisorUtils;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.util.StringUtils;
/**
* A stream utility class to provide support methods handling {@link AdvisedResponse}.
*
* @deprecated in favour of {@link AdvisorUtils}.
*/
@Deprecated
public final class AdvisedResponseStreamUtils {
private AdvisedResponseStreamUtils() {

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2023-2025 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.advisor.api;
import io.micrometer.observation.ObservationRegistry;
/**
* Defines the context for executing a chain of advisors as part of processing a chat
* request.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public interface AdvisorChain {
default ObservationRegistry getObservationRegistry() {
return ObservationRegistry.NOOP;
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2023-2024 the original author or authors.
* Copyright 2023-2025 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.
@@ -21,25 +21,47 @@ import reactor.core.publisher.Mono;
import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.AdvisorUtils;
import org.springframework.util.Assert;
/**
* Base advisor that implements common aspects of the {@link CallAroundAdvisor} and
* {@link StreamAroundAdvisor}, reducing the boilerplate code needed to implement an
* advisor. It provides default implementations for the
* {@link #aroundCall(AdvisedRequest, CallAroundAdvisorChain)} and
* {@link #aroundStream(AdvisedRequest, StreamAroundAdvisorChain)} methods, delegating the
* actual logic to the {@link #before(AdvisedRequest)} and {@link #after(AdvisedResponse)}
* methods.
* Base advisor that implements common aspects of the {@link CallAdvisor} and
* {@link StreamAdvisor}, reducing the boilerplate code needed to implement an advisor.
* <p>
* It provides default implementations for the
* {@link #adviseCall(ChatClientRequest, CallAroundAdvisorChain)} and
* {@link #adviseStream(ChatClientRequest, StreamAroundAdvisorChain)} methods, delegating
* the actual logic to the {@link #before(ChatClientRequest, AdvisorChain advisorChain)}
* and {@link #after(ChatClientResponse, AdvisorChain advisorChain)} methods.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public interface BaseAdvisor extends CallAroundAdvisor, StreamAroundAdvisor {
public interface BaseAdvisor extends CallAdvisor, StreamAdvisor {
Scheduler DEFAULT_SCHEDULER = Schedulers.boundedElastic();
@Override
default ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAroundAdvisorChain chain) {
Assert.notNull(chatClientRequest, "chatClientRequest cannot be null");
Assert.notNull(chain, "chain cannot be null");
ChatClientRequest processedChatClientRequest = before(chatClientRequest, chain);
ChatClientResponse chatClientResponse;
if (chain instanceof CallAdvisorChain callAdvisorChain) {
chatClientResponse = callAdvisorChain.nextCall(processedChatClientRequest);
}
else {
chatClientResponse = chain.nextAroundCall(AdvisedRequest.from(processedChatClientRequest))
.toChatClientResponse();
}
return after(chatClientResponse, chain);
}
@Override
@Deprecated
default AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) {
Assert.notNull(advisedRequest, "advisedRequest cannot be null");
Assert.notNull(chain, "chain cannot be null");
@@ -50,6 +72,36 @@ public interface BaseAdvisor extends CallAroundAdvisor, StreamAroundAdvisor {
}
@Override
default Flux<ChatClientResponse> adviseStream(ChatClientRequest chatClientRequest, StreamAroundAdvisorChain chain) {
Assert.notNull(chatClientRequest, "chatClientRequest cannot be null");
Assert.notNull(chain, "chain cannot be null");
Assert.notNull(getScheduler(), "scheduler cannot be null");
Flux<ChatClientResponse> chatClientResponseFlux;
if (chain instanceof StreamAdvisorChain streamAdvisorChain) {
chatClientResponseFlux = Mono.just(chatClientRequest)
.publishOn(getScheduler())
.map(request -> this.before(request, streamAdvisorChain))
.flatMapMany(streamAdvisorChain::nextStream);
}
else {
chatClientResponseFlux = Mono.just(AdvisedRequest.from(chatClientRequest))
.publishOn(getScheduler())
.map(this::before)
.flatMapMany(chain::nextAroundStream)
.map(AdvisedResponse::toChatClientResponse);
}
return chatClientResponseFlux.map(response -> {
if (AdvisorUtils.onFinishReason().test(response)) {
response = after(response, chain);
}
return response;
}).onErrorResume(error -> Flux.error(new IllegalStateException("Stream processing failed", error)));
}
@Override
@Deprecated
default Flux<AdvisedResponse> aroundStream(AdvisedRequest advisedRequest, StreamAroundAdvisorChain chain) {
Assert.notNull(advisedRequest, "advisedRequest cannot be null");
Assert.notNull(chain, "chain cannot be null");
@@ -76,11 +128,31 @@ public interface BaseAdvisor extends CallAroundAdvisor, StreamAroundAdvisor {
/**
* Logic to be executed before the rest of the advisor chain is called.
*/
AdvisedRequest before(AdvisedRequest request);
default ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) {
Assert.notNull(chatClientRequest, "chatClientRequest cannot be null");
return before(AdvisedRequest.from(chatClientRequest)).toChatClientRequest();
}
/**
* Logic to be executed after the rest of the advisor chain is called.
*/
default ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) {
Assert.notNull(chatClientResponse, "chatClientResponse cannot be null");
return after(AdvisedResponse.from(chatClientResponse)).toChatClientResponse();
}
/**
* Logic to be executed before the rest of the advisor chain is called.
* @deprecated in favor of {@link #before(ChatClientRequest,AdvisorChain)}
*/
@Deprecated
AdvisedRequest before(AdvisedRequest request);
/**
* Logic to be executed after the rest of the advisor chain is called.
* @deprecated in favor of {@link #after(ChatClientResponse,AdvisorChain)}
*/
@Deprecated
AdvisedResponse after(AdvisedResponse advisedResponse);
/**

View File

@@ -19,6 +19,8 @@ package org.springframework.ai.chat.client.advisor.api;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import java.util.List;
/**
* A chain of {@link CallAdvisor} instances orchestrating the execution of a
* {@link ChatClientRequest} on the next {@link CallAdvisor} in the chain.
@@ -39,4 +41,6 @@ public interface CallAdvisorChain extends CallAroundAdvisorChain {
ChatClientResponse nextCall(ChatClientRequest chatClientRequest);
List<CallAroundAdvisor> getCallAdvisors();
}

View File

@@ -28,7 +28,7 @@ import org.springframework.ai.chat.client.ChatClientRequest;
* @deprecated in favor of {@link CallAdvisorChain}
*/
@Deprecated
public interface CallAroundAdvisorChain {
public interface CallAroundAdvisorChain extends AdvisorChain {
/**
* Invokes the next Around Advisor in the CallAroundAdvisorChain with the given

View File

@@ -20,6 +20,8 @@ import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import reactor.core.publisher.Flux;
import java.util.List;
/**
* A chain of {@link StreamAdvisor} instances orchestrating the execution of a
* {@link ChatClientRequest} on the next {@link StreamAdvisor} in the chain.
@@ -40,4 +42,6 @@ public interface StreamAdvisorChain extends StreamAroundAdvisorChain {
Flux<ChatClientResponse> nextStream(ChatClientRequest chatClientRequest);
List<StreamAroundAdvisor> getStreamAdvisors();
}

View File

@@ -29,7 +29,7 @@ import reactor.core.publisher.Flux;
* @deprecated in favor of {@link StreamAdvisorChain}
*/
@Deprecated
public interface StreamAroundAdvisorChain {
public interface StreamAroundAdvisorChain extends AdvisorChain {
/**
* This method delegates the call to the next StreamAroundAdvisor in the chain and is

View File

@@ -89,7 +89,9 @@ public enum AdvisorObservationDocumentation implements ObservationDocumentation
/**
* Advisor type: Before, After or Around.
* @deprecated advisors don't have types anymore, they're all "around"
*/
@Deprecated
ADVISOR_TYPE {
@Override
public String asString() {

View File

@@ -81,6 +81,9 @@ public class DefaultAdvisorObservationConvention implements AdvisorObservationCo
return KeyValue.of(LowCardinalityKeyNames.AI_PROVIDER, AiProvider.SPRING_AI.value());
}
/**
* @deprecated advisors don't have types anymore, they're all "around"
*/
@Deprecated
protected KeyValue advisorType(AdvisorObservationContext context) {
return KeyValue.of(LowCardinalityKeyNames.ADVISOR_TYPE, context.getAdvisorType().name());

View File

@@ -35,7 +35,9 @@ import java.util.Map;
*
* @author Christian Tzolov
* @since 1.0.0
* @deprecated in favor of {@link ChatClientPromptContentObservationFilter}.
*/
@Deprecated
public class ChatClientInputContentObservationFilter implements ObservationFilter {
@Override
@@ -43,8 +45,6 @@ public class ChatClientInputContentObservationFilter implements ObservationFilte
if (!(context instanceof ChatClientObservationContext chatClientObservationContext)) {
return context;
}
// TODO: we really want these? Should probably align with same format as chat
// model observation
chatClientSystemText(chatClientObservationContext);
chatClientSystemParams(chatClientObservationContext);
chatClientUserText(chatClientObservationContext);

View File

@@ -20,6 +20,7 @@ import io.micrometer.observation.Observation;
import org.springframework.ai.chat.client.ChatClientAttributes;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.advisor.api.Advisor;
import org.springframework.ai.observation.AiOperationMetadata;
import org.springframework.ai.observation.conventions.AiOperationType;
import org.springframework.ai.observation.conventions.AiProvider;
@@ -27,6 +28,8 @@ import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import java.util.List;
/**
* Context used to store metadata for chat client workflows.
*
@@ -41,11 +44,17 @@ public class ChatClientObservationContext extends Observation.Context {
private final AiOperationMetadata operationMetadata = new AiOperationMetadata(AiOperationType.FRAMEWORK.value(),
AiProvider.SPRING_AI.value());
private final List<? extends Advisor> advisors;
private final boolean stream;
ChatClientObservationContext(ChatClientRequest chatClientRequest, boolean isStream) {
ChatClientObservationContext(ChatClientRequest chatClientRequest, List<? extends Advisor> advisors,
boolean isStream) {
Assert.notNull(chatClientRequest, "chatClientRequest cannot be null");
Assert.notNull(advisors, "advisors cannot be null");
Assert.noNullElements(advisors, "advisors cannot contain null elements");
this.request = chatClientRequest;
this.advisors = advisors;
this.stream = isStream;
}
@@ -61,6 +70,10 @@ public class ChatClientObservationContext extends Observation.Context {
return this.operationMetadata;
}
public List<? extends Advisor> getAdvisors() {
return this.advisors;
}
public boolean isStream() {
return this.stream;
}
@@ -91,6 +104,8 @@ public class ChatClientObservationContext extends Observation.Context {
private ChatClientRequest chatClientRequest;
private List<? extends Advisor> advisors = List.of();
private String format;
private boolean isStream = false;
@@ -118,6 +133,11 @@ public class ChatClientObservationContext extends Observation.Context {
return this;
}
public Builder advisors(List<? extends Advisor> advisors) {
this.advisors = advisors;
return this;
}
public Builder stream(boolean isStream) {
this.isStream = isStream;
return this;
@@ -132,7 +152,7 @@ public class ChatClientObservationContext extends Observation.Context {
if (StringUtils.hasText(format)) {
this.chatClientRequest.context().put(ChatClientAttributes.OUTPUT_FORMAT.getKey(), format);
}
return new ChatClientObservationContext(this.chatClientRequest, this.isStream);
return new ChatClientObservationContext(this.chatClientRequest, this.advisors, this.isStream);
}
}

View File

@@ -20,6 +20,7 @@ import io.micrometer.common.docs.KeyName;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationConvention;
import io.micrometer.observation.docs.ObservationDocumentation;
import org.springframework.ai.observation.conventions.AiObservationAttributes;
/**
* Documented conventions for chat client observations.
@@ -76,25 +77,6 @@ public enum ChatClientObservationDocumentation implements ObservationDocumentati
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.function.callbacks";
}
},
/**
* List of configured chat client advisors.
*/
@@ -104,51 +86,128 @@ public enum ChatClientObservationDocumentation implements ObservationDocumentati
return "spring.ai.chat.client.advisors";
}
},
/**
* The identifier of the conversation.
*/
CHAT_CLIENT_CONVERSATION_ID {
@Override
public String asString() {
return "spring.ai.chat.client.conversation.id";
}
},
// Request
/**
* Names of the tools made available to the chat client.
*/
CHAT_CLIENT_TOOL_NAMES {
@Override
public String asString() {
return "spring.ai.chat.client.tool.names";
}
},
/**
* Enabled tool function names.
* @deprecated replaced by {@link #CHAT_CLIENT_TOOL_NAMES}
*/
@Deprecated
CHAT_CLIENT_TOOL_FUNCTION_NAMES {
@Override
public String asString() {
return "spring.ai.chat.client.tool.function.names";
}
},
/**
* List of configured chat client function callbacks.
* @deprecated replaced by {@link #CHAT_CLIENT_TOOL_NAMES}
*/
@Deprecated
CHAT_CLIENT_TOOL_FUNCTION_CALLBACKS {
@Override
public String asString() {
return "spring.ai.chat.client.tool.function.callbacks";
}
},
/**
* Map of advisor parameters.
* @deprecated risk to expose sensitive information or break the instrumentation
* since the advisor context map is used to pass arbitrary Java objects between
* advisors and not necessarily serializable. The conversation ID, previously part
* of this, is already included in the {@link #CHAT_CLIENT_CONVERSATION_ID}
* method.
*/
@Deprecated
CHAT_CLIENT_ADVISOR_PARAMS {
@Override
public String asString() {
return "spring.ai.chat.client.advisor.params";
}
},
/**
* Chat client user text.
* @deprecated replaced by {@link #PROMPT}
*/
@Deprecated
CHAT_CLIENT_USER_TEXT {
@Override
public String asString() {
return "spring.ai.chat.client.user.text";
}
},
/**
* Chat client user parameters.
* @deprecated replaced by {@link #PROMPT}
*/
@Deprecated
CHAT_CLIENT_USER_PARAMS {
@Override
public String asString() {
return "spring.ai.chat.client.user.params";
}
},
/**
* Chat client system text.
* @deprecated replaced by {@link #PROMPT}
*/
@Deprecated
CHAT_CLIENT_SYSTEM_TEXT {
@Override
public String asString() {
return "spring.ai.chat.client.system.text";
}
},
/**
* Chat client system parameters.
* @deprecated replaced by {@link #PROMPT}
*/
@Deprecated
CHAT_CLIENT_SYSTEM_PARAM {
@Override
public String asString() {
return "spring.ai.chat.client.system.params";
}
}
},
// Content
/**
* The full prompt requested to be sent to the model.
*/
PROMPT {
@Override
public String asString() {
return AiObservationAttributes.PROMPT.value();
}
},
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright 2023-2025 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.ObservationFilter;
import org.springframework.ai.chat.observation.ChatModelObservationDocumentation;
import org.springframework.ai.observation.tracing.TracingHelper;
import org.springframework.util.CollectionUtils;
import java.util.HashMap;
import java.util.Map;
/**
* An {@link ObservationFilter} to include the chat client prompt content in the
* observation.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public final class ChatClientPromptContentObservationFilter implements ObservationFilter {
@Override
public Observation.Context map(Observation.Context context) {
if (!(context instanceof ChatClientObservationContext chatClientObservationContext)) {
return context;
}
var prompts = processPrompt(chatClientObservationContext);
chatClientObservationContext
.addHighCardinalityKeyValue(ChatModelObservationDocumentation.HighCardinalityKeyNames.PROMPT
.withValue(TracingHelper.concatenateMaps(prompts)));
return chatClientObservationContext;
}
private Map<String, Object> processPrompt(ChatClientObservationContext context) {
if (CollectionUtils.isEmpty(context.getRequest().prompt().getInstructions())) {
return Map.of();
}
var messages = new HashMap<String, Object>();
context.getRequest()
.prompt()
.getInstructions()
.forEach(message -> messages.put(message.getMessageType().getValue(), message.getText()));
return messages;
}
}

View File

@@ -17,13 +17,14 @@
package org.springframework.ai.chat.client.observation;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import io.micrometer.common.KeyValue;
import io.micrometer.common.KeyValues;
import org.springframework.ai.chat.client.ChatClientAttributes;
import org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.api.Advisor;
import org.springframework.ai.chat.client.observation.ChatClientObservationDocumentation.LowCardinalityKeyNames;
import org.springframework.ai.chat.observation.ChatModelObservationDocumentation;
@@ -32,6 +33,7 @@ import org.springframework.ai.observation.conventions.SpringAiKind;
import org.springframework.ai.observation.tracing.TracingHelper;
import org.springframework.lang.Nullable;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
/**
* Default conventions to populate observations for chat client workflows.
@@ -92,26 +94,74 @@ public class DefaultChatClientObservationConvention implements ChatClientObserva
@Override
public KeyValues getHighCardinalityKeyValues(ChatClientObservationContext context) {
var keyValues = KeyValues.empty();
keyValues = chatClientAdvisorNames(keyValues, context);
// TODO: rename attribute? any sensitive data here?
keyValues = advisors(keyValues, context);
keyValues = conversationId(keyValues, context);
keyValues = tools(keyValues, context);
// @deprecated remove before 1.0.0-RC1.
keyValues = chatClientAdvisorParams(keyValues, context);
// TODO: remove this? Already included in chat model observation
// @deprecated remove before 1.0.0-RC1.
keyValues = toolNames(keyValues, context);
// TODO: remove this? Already included in chat model observation
// @deprecated remove before 1.0.0-RC1.
keyValues = toolCallbacks(keyValues, context);
return keyValues;
}
@SuppressWarnings("unchecked")
protected KeyValues chatClientAdvisorNames(KeyValues keyValues, ChatClientObservationContext context) {
if (!(context.getRequest().context().get(ChatClientAttributes.ADVISORS.getKey()) instanceof List<?> advisors)) {
protected KeyValues advisors(KeyValues keyValues, ChatClientObservationContext context) {
if (CollectionUtils.isEmpty(context.getAdvisors())) {
return keyValues;
}
var advisorNames = ((List<Advisor>) advisors).stream().map(Advisor::getName).toList();
var advisorNames = context.getAdvisors().stream().map(Advisor::getName).toList();
return keyValues.and(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_ADVISORS.asString(),
TracingHelper.concatenateStrings(advisorNames));
}
protected KeyValues conversationId(KeyValues keyValues, ChatClientObservationContext context) {
if (CollectionUtils.isEmpty(context.getRequest().context())) {
return keyValues;
}
var conversationIdValue = context.getRequest()
.context()
.get(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY);
if (!(conversationIdValue instanceof String conversationId) || StringUtils.isEmpty(conversationId)) {
return keyValues;
}
return keyValues.and(
ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_CONVERSATION_ID.asString(),
conversationId);
}
protected KeyValues tools(KeyValues keyValues, ChatClientObservationContext context) {
if (context.getRequest().prompt().getOptions() == null) {
return keyValues;
}
if (!(context.getRequest().prompt().getOptions() instanceof ToolCallingChatOptions options)) {
return keyValues;
}
var toolNames = new ArrayList<>(options.getToolNames());
var toolCallbacks = options.getToolCallbacks();
if (CollectionUtils.isEmpty(toolNames) && CollectionUtils.isEmpty(toolCallbacks)) {
return keyValues;
}
toolCallbacks.forEach(toolCallback -> toolNames.add(toolCallback.getToolDefinition().name()));
return keyValues.and(
ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_TOOL_NAMES.asString(),
TracingHelper.concatenateStrings(toolNames.stream().sorted().toList()));
}
/**
* @deprecated risk to expose sensitive information or break the instrumentation since
* the advisor context map is used to pass arbitrary Java objects between advisors and
* not necessarily serializable. The conversation ID, previously part of this, is
* already included in the
* {@link #conversationId(KeyValues, ChatClientObservationContext)} method.
*/
@Deprecated
protected KeyValues chatClientAdvisorParams(KeyValues keyValues, ChatClientObservationContext context) {
if (CollectionUtils.isEmpty(context.getRequest().context())) {
return keyValues;
@@ -123,6 +173,10 @@ public class DefaultChatClientObservationConvention implements ChatClientObserva
TracingHelper.concatenateMaps(chatClientContext));
}
/**
* @deprecated in favor of {@link #tools(KeyValues, ChatClientObservationContext)}
*/
@Deprecated
protected KeyValues toolNames(KeyValues keyValues, ChatClientObservationContext context) {
if (context.getRequest().prompt().getOptions() == null) {
return keyValues;
@@ -141,6 +195,10 @@ public class DefaultChatClientObservationConvention implements ChatClientObserva
TracingHelper.concatenateStrings(toolNames.stream().sorted().toList()));
}
/**
* @deprecated in favor of {@link #tools(KeyValues, ChatClientObservationContext)}
*/
@Deprecated
protected KeyValues toolCallbacks(KeyValues keyValues, ChatClientObservationContext context) {
if (context.getRequest().prompt().getOptions() == null) {
return keyValues;

View File

@@ -22,6 +22,7 @@ import org.springframework.ai.chat.prompt.Prompt;
import java.util.HashMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
@@ -60,4 +61,28 @@ class ChatClientRequestTests {
.hasMessage("context keys cannot be null");
}
@Test
void whenCopyThenImmutableContext() {
Map<String, Object> context = new HashMap<>();
context.put("key", "value");
ChatClientRequest request = ChatClientRequest.builder().prompt(new Prompt()).context(context).build();
ChatClientRequest copy = request.copy();
copy.context().put("key", "newValue");
assertThat(request.context()).isEqualTo(Map.of("key", "value"));
}
@Test
void whenMutateThenImmutableContext() {
Map<String, Object> context = new HashMap<>();
context.put("key", "value");
ChatClientRequest request = ChatClientRequest.builder().prompt(new Prompt()).context(context).build();
ChatClientRequest copy = request.mutate().context("key", "newValue").build();
assertThat(request.context()).isEqualTo(Map.of("key", "value"));
assertThat(copy.context()).isEqualTo(Map.of("key", "newValue"));
}
}

View File

@@ -21,6 +21,7 @@ import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
@@ -48,4 +49,37 @@ class ChatClientResponseTests {
.hasMessage("context keys cannot be null");
}
@Test
void whenCopyThenImmutableContext() {
Map<String, Object> context = new HashMap<>();
context.put("key", "value");
ChatClientResponse response = ChatClientResponse.builder().chatResponse(null).context(context).build();
ChatClientResponse copy = response.copy();
copy.context().put("key2", "value2");
assertThat(response.context()).doesNotContainKey("key2");
assertThat(copy.context()).containsKey("key2");
copy.context().put("key", "newValue");
assertThat(copy.context()).containsEntry("key", "newValue");
assertThat(response.context()).containsEntry("key", "value");
}
@Test
void whenMutateThenImmutableContext() {
Map<String, Object> context = new HashMap<>();
context.put("key", "value");
ChatClientResponse response = ChatClientResponse.builder().chatResponse(null).context(context).build();
ChatClientResponse copy = response.mutate().context(Map.of("key2", "value2")).build();
assertThat(response.context()).doesNotContainKey("key2");
assertThat(copy.context()).containsKey("key2");
copy.context().put("key", "newValue");
assertThat(copy.context()).containsEntry("key", "newValue");
assertThat(response.context()).containsEntry("key", "value");
}
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright 2023-2025 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.advisor;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for {@link ChatModelCallAdvisor}.
*
* @author Thomas Vitale
*/
class ChatModelCallAdvisorTests {
@Test
void whenChatModelIsNullThenThrow() {
assertThatThrownBy(() -> ChatModelCallAdvisor.builder().chatModel(null).build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("chatModel cannot be null");
}
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright 2023-2025 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.advisor;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for {@link ChatModelStreamAdvisor}.
*
* @author Thomas Vitale
*/
class ChatModelStreamAdvisorTests {
@Test
void whenChatModelIsNullThenThrow() {
assertThatThrownBy(() -> ChatModelStreamAdvisor.builder().chatModel(null).build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("chatModel cannot be null");
}
}

View File

@@ -0,0 +1,122 @@
/*
* Copyright 2023-2025 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.advisor;
import io.micrometer.observation.ObservationRegistry;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.*;
import org.springframework.ai.chat.prompt.Prompt;
import reactor.core.publisher.Flux;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Unit tests for {@link DefaultAroundAdvisorChain}.
*
* @author Thomas Vitale
*/
class DefaultAroundAdvisorChainTests {
@Test
void whenObservationRegistryIsNullThenThrow() {
assertThatThrownBy(() -> DefaultAroundAdvisorChain.builder(null).build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("the observationRegistry must be non-null");
}
@Test
void whenAdvisorIsNullThenThrow() {
assertThatThrownBy(() -> DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP).push(null).build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("the advisor must be non-null");
}
@Test
void whenAdvisorListIsNullThenThrow() {
assertThatThrownBy(() -> DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP).pushAll(null).build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("the advisors must be non-null");
}
@Test
void whenAdvisorListContainsNullElementsThenThrow() {
List<Advisor> advisors = new ArrayList<>();
advisors.add(null);
assertThatThrownBy(() -> DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP).pushAll(advisors).build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("the advisors must not contain null elements");
}
@Test
void getObservationRegistry() {
ObservationRegistry observationRegistry = ObservationRegistry.create();
AdvisorChain chain = DefaultAroundAdvisorChain.builder(observationRegistry).build();
assertThat(chain.getObservationRegistry()).isEqualTo(observationRegistry);
}
@Test
void getCallAdvisors() {
CallAdvisor mockAdvisor1 = mock(CallAdvisor.class);
when(mockAdvisor1.getName()).thenReturn("advisor1");
when(mockAdvisor1.adviseCall(any(), any())).thenReturn(ChatClientResponse.builder().build());
CallAdvisor mockAdvisor2 = mock(CallAdvisor.class);
when(mockAdvisor2.getName()).thenReturn("advisor2");
when(mockAdvisor2.adviseCall(any(), any())).thenReturn(ChatClientResponse.builder().build());
List<CallAdvisor> advisors = List.of(mockAdvisor1, mockAdvisor2);
CallAdvisorChain chain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP).pushAll(advisors).build();
assertThat(chain.getCallAdvisors()).containsExactlyInAnyOrder(advisors.toArray(new CallAdvisor[0]));
chain.nextCall(ChatClientRequest.builder().prompt(new Prompt("Hello")).build());
assertThat(chain.getCallAdvisors()).containsExactlyInAnyOrder(advisors.toArray(new CallAdvisor[0]));
chain.nextCall(ChatClientRequest.builder().prompt(new Prompt("Hello")).build());
assertThat(chain.getCallAdvisors()).containsExactlyInAnyOrder(advisors.toArray(new CallAdvisor[0]));
}
@Test
void getStreamAdvisors() {
StreamAdvisor mockAdvisor1 = mock(StreamAdvisor.class);
when(mockAdvisor1.getName()).thenReturn("advisor1");
when(mockAdvisor1.adviseStream(any(), any())).thenReturn(Flux.just(ChatClientResponse.builder().build()));
StreamAdvisor mockAdvisor2 = mock(StreamAdvisor.class);
when(mockAdvisor2.getName()).thenReturn("advisor2");
when(mockAdvisor2.adviseStream(any(), any())).thenReturn(Flux.just(ChatClientResponse.builder().build()));
List<StreamAdvisor> advisors = List.of(mockAdvisor1, mockAdvisor2);
StreamAdvisorChain chain = DefaultAroundAdvisorChain.builder(ObservationRegistry.NOOP)
.pushAll(advisors)
.build();
assertThat(chain.getStreamAdvisors()).containsExactlyInAnyOrder(advisors.toArray(new StreamAdvisor[0]));
chain.nextStream(ChatClientRequest.builder().prompt(new Prompt("Hello")).build()).blockLast();
assertThat(chain.getStreamAdvisors()).containsExactlyInAnyOrder(advisors.toArray(new StreamAdvisor[0]));
chain.nextStream(ChatClientRequest.builder().prompt(new Prompt("Hello")).build()).blockLast();
assertThat(chain.getStreamAdvisors()).containsExactlyInAnyOrder(advisors.toArray(new StreamAdvisor[0]));
}
}

View File

@@ -22,10 +22,16 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.advisor.api.Advisor;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
/**
* Unit tests for {@link ChatClientObservationContext}.
@@ -43,10 +49,29 @@ class ChatClientObservationContextTests {
void whenMandatoryRequestOptionsThenReturn() {
var observationContext = ChatClientObservationContext.builder()
.request(ChatClientRequest.builder().prompt(new Prompt()).build())
.stream(true)
.build();
assertThat(observationContext).isNotNull();
}
@Test
void whenNullAdvisorsThenReturn() {
assertThatThrownBy(() -> ChatClientObservationContext.builder()
.request(ChatClientRequest.builder().prompt(new Prompt()).build())
.advisors(null)
.build()).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("advisors cannot be null");
}
@Test
void whenAdvisorsWithNullElementsThenReturn() {
List<Advisor> advisors = new ArrayList<>();
advisors.add(mock(Advisor.class));
advisors.add(null);
assertThatThrownBy(() -> ChatClientObservationContext.builder()
.request(ChatClientRequest.builder().prompt(new Prompt()).build())
.advisors(advisors)
.build()).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("advisors cannot contain null elements");
}
}

View File

@@ -0,0 +1,88 @@
/*
* Copyright 2023-2025 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.KeyValue;
import io.micrometer.observation.Observation;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.observation.ChatModelObservationDocumentation;
import org.springframework.ai.chat.prompt.Prompt;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Unit tests for {@link ChatClientPromptContentObservationFilter}.
*
* @author Thomas Vitale
*/
class ChatClientPromptContentObservationFilterTests {
private final ChatClientPromptContentObservationFilter observationFilter = new ChatClientPromptContentObservationFilter();
@Test
void whenNotSupportedObservationContextThenReturnOriginalContext() {
var expectedContext = new Observation.Context();
var actualContext = this.observationFilter.map(expectedContext);
assertThat(actualContext).isEqualTo(expectedContext);
}
@Test
void whenEmptyPromptThenReturnOriginalContext() {
var expectedContext = ChatClientObservationContext.builder()
.request(ChatClientRequest.builder().prompt(new Prompt(List.of())).build())
.build();
var actualContext = this.observationFilter.map(expectedContext);
assertThat(actualContext).isEqualTo(expectedContext);
}
@Test
void whenPromptWithTextThenAugmentContext() {
var originalContext = ChatClientObservationContext.builder()
.request(ChatClientRequest.builder().prompt(new Prompt("supercalifragilisticexpialidocious")).build())
.build();
var augmentedContext = this.observationFilter.map(originalContext);
assertThat(augmentedContext.getHighCardinalityKeyValues())
.contains(KeyValue.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.PROMPT.asString(), """
["user":"supercalifragilisticexpialidocious"]"""));
}
@Test
void whenPromptWithMessagesThenAugmentContext() {
var originalContext = ChatClientObservationContext.builder()
.request(ChatClientRequest.builder()
.prompt(new Prompt(List.of(new SystemMessage("you're a chimney sweep"),
new UserMessage("supercalifragilisticexpialidocious"))))
.build())
.build();
var augmentedContext = this.observationFilter.map(originalContext);
assertThat(augmentedContext.getHighCardinalityKeyValues())
.contains(KeyValue.of(ChatModelObservationDocumentation.HighCardinalityKeyNames.PROMPT.asString(), """
["system":"you're a chimney sweep", "user":"supercalifragilisticexpialidocious"]"""));
}
}

View File

@@ -29,6 +29,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.ai.chat.client.ChatClientAttributes;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.api.AdvisedRequest;
import org.springframework.ai.chat.client.advisor.api.AdvisedResponse;
import org.springframework.ai.chat.client.advisor.api.CallAroundAdvisor;
@@ -150,25 +151,28 @@ class DefaultChatClientObservationConventionTests {
.toolNames("tool1", "tool2")
.toolCallbacks(dummyFunction("toolCallback1"), dummyFunction("toolCallback2"))
.build()))
.context("advParam1", "advisorParam1Value")
.context(ChatClientAttributes.ADVISORS.getKey(),
List.of(dummyAdvisor("advisor1"), dummyAdvisor("advisor2")))
.context(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, "007")
.build();
ChatClientObservationContext observationContext = ChatClientObservationContext.builder()
.request(request)
.withFormat("json")
.advisors(List.of(dummyAdvisor("advisor1"), dummyAdvisor("advisor2")))
.stream(true)
.build();
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(),
"[\"tool1\", \"tool2\"]"),
KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_TOOL_FUNCTION_CALLBACKS.asString(),
"[\"toolCallback1\", \"toolCallback2\"]"));
KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_ADVISORS.asString(), """
["advisor1", "advisor2"]"""),
KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_CONVERSATION_ID.asString(), "007"),
KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_TOOL_NAMES.asString(), """
["tool1", "tool2", "toolCallback1", "toolCallback2"]"""),
KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_ADVISOR_PARAMS.asString(), """
["chat_memory_conversation_id":"007"]"""),
KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_TOOL_FUNCTION_NAMES.asString(), """
["tool1", "tool2"]"""),
KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_TOOL_FUNCTION_CALLBACKS.asString(), """
["toolCallback1", "toolCallback2"]"""));
}
@Test

View File

@@ -171,6 +171,7 @@ After specifying the `call()` method on `ChatClient`, there are a few different
* `String content()`: returns the String content of the response
* `ChatResponse chatResponse()`: returns the `ChatResponse` object that contains multiple generations and also metadata about the response, for example how many token were used to create the response.
* `ChatClientResponse chatClientResponse()`: returns a `ChatClientResponse` object that contains the `ChatResponse` object and the ChatClient execution context, giving you access to additional data used during the execution of advisors (e.g. the relevant documents retrieved in a RAG flow).
* `entity()` to return a Java type
** `entity(ParameterizedTypeReference<T> type)`: used to return a `Collection` of entity types.
** `entity(Class<T> type)`: used to return a specific entity type.
@@ -184,6 +185,7 @@ After specifying the `stream()` method on `ChatClient`, there are a few options
* `Flux<String> content()`: Returns a `Flux` of the string being generated by the AI model.
* `Flux<ChatResponse> chatResponse()`: Returns a `Flux` of the `ChatResponse` object, which contains additional metadata about the response.
* `Flux<ChatClientResponse> chatClientResponse()`: returns a `Flux` of the `ChatClientResponse` object that contains the `ChatResponse` object and the ChatClient execution context, giving you access to additional data used during the execution of advisors (e.g. the relevant documents retrieved in a RAG flow).
== Using Defaults

View File

@@ -28,22 +28,43 @@ They measure the time spent performing the invocation and propagate the related
|===
|Name | Description
|`spring.ai.chat.client.advisor.params` | Map of advisor parameters.
|`gen_ai.prompt` | The content of the prompt sent via the chat client. Optional.
|`spring.ai.chat.client.advisor.params` (deprecated) | Map of advisor parameters. The conversation ID is now included in `spring.ai.chat.client.conversation.id`.
|`spring.ai.chat.client.advisors` | List of configured chat client advisors.
|`spring.ai.chat.client.system.params` |Chat client system parameters. Optional.
|`spring.ai.chat.client.system.text` |Chat client system text. Optional.
|`spring.ai.chat.client.tool.function.names` | Enabled tool function names.
|`spring.ai.chat.client.tool.function.callbacks` |List of configured chat client function callbacks.
|`spring.ai.chat.client.user.params` | Chat client user parameters. Optional.
|`spring.ai.chat.client.user.text` | Chat client user text. Optional.
|`spring.ai.chat.client.conversation.id` | Identifier of the conversation when using the chat memory.
|`spring.ai.chat.client.system.params` (deprecated) |Chat client system parameters. Optional. Superseded by `gen_ai.prompt`.
|`spring.ai.chat.client.system.text` (deprecated) |Chat client system text. Optional. Superseded by `gen_ai.prompt`.
|`spring.ai.chat.client.tool.function.names` (deprecated) | Enabled tool function names. Superseded by `spring.ai.chat.client.tool.names`.
|`spring.ai.chat.client.tool.function.callbacks` (deprecated) |List of configured chat client function callbacks. Superseded by `spring.ai.chat.client.tool.names`.
|`spring.ai.chat.client.tool.names` | Names of the tools passed to the chat client.
|`spring.ai.chat.client.user.params` (deprecated) | Chat client user parameters. Optional. Superseded by `gen_ai.prompt`.
|`spring.ai.chat.client.user.text` (deprecated) | Chat client user text. Optional. Superseded by `gen_ai.prompt`.
|===
=== Input Data
=== Prompt Content
The `ChatClient` prompt content is typically big and possibly containing sensitive information.
For those reasons, it is not exported by default.
Spring AI supports exporting the prompt content as span attributes/events across all tracing backends.
[cols="6,3,1", stripes=even]
|====
| Property | Description | Default
| `spring.ai.chat.client.observations.include-prompt` | Whether to include the chat client prompt content in the observations. | `false`
|====
WARNING: If you enable the inclusion of the chat client prompt content in the observations, there's a risk of exposing sensitive or private information. Please, be careful!
=== Input Data (Deprecated)
WARNING: The `spring.ai.chat.client.observations.include-input` property is deprecated, replaced by `spring.ai.chat.client.observations.include-prompt`. See xref:_prompt_content[Prompt Content].
The `ChatClient` input data is typically big and possibly containing sensitive information.
For those reasons, it is not exported by default.
Spring AI supports exporting input data as span attributes across all tracing backends.
Spring AI supports exporting input data as span attributes/events across all tracing backends.
[cols="6,3,1", stripes=even]
|====
@@ -56,7 +77,7 @@ WARNING: If you enable the inclusion of the input content in the observations, t
=== Chat Client Advisors
The `spring.ai.advisor` observations are recorded when a call or stream around advisors is performed.
The `spring.ai.advisor` observations are recorded when an advisor is executed.
They measure the time spent in the advisor (including the time spend on the inner advisors) and propagate the related tracing information.
.Low Cardinality Keys
@@ -66,7 +87,7 @@ They measure the time spent in the advisor (including the time spend on the inne
|`gen_ai.operation.name` | Always `framework`.
|`gen_ai.system` | Always `spring_ai`.
|`spring.ai.advisor.type` | Where the advisor applies it's logic in the request processing, one of `BEFORE`, `AFTER`, or `AROUND`.
|`spring.ai.advisor.type` (deprecated) | Where the advisor applies it's logic in the request processing, one of `BEFORE`, `AFTER`, or `AROUND`. This distinction doesn't apply anymore since all Advisors are always of the same type.
|`spring.ai.kind` | The kind of framework API in Spring AI: `advisor`.
|===

View File

@@ -46,6 +46,84 @@ This approach can save time and reduce the chance of errors when upgrading multi
[[upgrading-to-1-0-0-m8]]
== Upgrading to 1.0.0-M8
=== Chat Client
* The `ChatClient` has been enhanced to solve some inconsistencies or unwanted behaviour whenever user and system prompts were not rendered before using them in an advisor. The new behavior ensures that the user and system prompts are always rendered before executing the chain of advisors. As part of this enhancement, the `AdvisedRequest` and `AdvisedResponse` APIs have been deprecated, replaced by `ChatClientRequest` and `ChatClientResponse`. Advisors now act on a fully built `Prompt` object included in a `ChatClientRequest` instead of the destructured format used in `AdvisedRequest`, guaranteeing consistency and completeness.
For example, if you had a custom advisor that modified the request prompt in the `before` method, you would refactor it as follows:
[source,java,subs="verbatim,quotes"]
----
// --- Before (using AdvisedRequest) ---
@Override
public AdvisedRequest before(AdvisedRequest advisedRequest) {
// Access original user text and parameters directly from AdvisedRequest
String originalUserText = new PromptTemplate(advisedRequest.userText(), advisedRequest.userParams()).render();
// ... retrieve documents, create augmented prompt text ...
List<Document> retrievedDocuments = ...;
String augmentedPromptText = ...; // create augmented text from originalUserText and retrievedDocuments
// Copy existing context and add advisor-specific data
Map<String, Object> context = new HashMap<>(advisedRequest.adviseContext());
context.put("retrievedDocuments", retrievedDocuments); // Example key
// Use the AdvisedRequest builder pattern to return the modified request
return AdvisedRequest.from(advisedRequest)
.userText(augmentedPromptText) // Set the augmented user text
.adviseContext(context) // Set the updated context
.build();
}
// --- After (using ChatClientRequest) ---
@Override
public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain chain) {
String originalUserText = chatClientRequest.prompt().getUserMessage().getText(); // Access prompt directly
// ... retrieve documents ...
List<Document> retrievedDocuments = ...;
String augmentedQueryText = ...; // create augmented text
// Initialize context with existing data and add advisor-specific data
Map<String, Object> context = new HashMap<>(chatClientRequest.context()); // <1>
context.put("retrievedDocuments", retrievedDocuments); // Example key
context.put("originalUserQuery", originalUserText); // Example key
// Use immutable operations
return chatClientRequest.mutate()
.prompt(chatClientRequest.prompt()
.augmentUserMessage(augmentedQueryText) // <2>
)
.context(context) // <3>
.build();
}
----
<1> Initialize the context map with data from the incoming request (`chatClientRequest.context()`) to preserve context from previous advisors, then add new data.
<2> Use methods like `prompt.augmentUserMessage()` to modify the prompt content safely.
<3> Pass the updated context map. This map becomes part of the `ChatClientRequest` and is accessible later via `ChatClientResponse.responseContext()` in the `after` method.
* The chain of advisors can populate the execution context with useful data. For example, when performing retrieval augmented generation, the retrieved documents can be added to the context. It's now possible to return a `ChatClientResponse` object from a `ChatClient` call, which contains the execution context. So, besides the `content()` and `chatResponse()` methods, you can now terminate a `ChatClient` call with `chatClientResponse()` which gives you access to both the `ChatResponse` and the execution context.
In addition to directly replacing the user message text with `augmentUserMessage(String)`, you can provide a function to modify the existing `UserMessage` more granularly:
[source,java,subs="verbatim,quotes"]
----
Prompt originalPrompt = new Prompt(new UserMessage("Tell me about Large Language Models."));
// Example: Append context or modify properties using a Function
Prompt augmentedPrompt = originalPrompt.augmentUserMessage(userMessage ->
userMessage.mutate()
.text(userMessage.getText() + "\n\nFocus on their applications in software development.")
// .media(...) // Potentially add/modify media
// .metadata(...) // Potentially add/modify metadata
.build()
);
// 'augmentedPrompt' now contains the modified UserMessage
----
This approach offers more control when you need to conditionally change parts of the `UserMessage` or work with its media and metadata, rather than just replacing the text content.
=== Chat Memory
* A `ChatMemory` bean is auto-configured for you whenever using one of the Spring AI Model starters. By default, it uses the `MessageWindowChatMemory` implementation and stores the conversation history in memory.
@@ -59,6 +137,15 @@ This approach can save time and reduce the chance of errors when upgrading multi
* The `PromptTemplate` API has been redesigned to support a more flexible and extensible way of templating prompts, relying on a new `TemplateRenderer` API. As part of this change, the `getInputVariables()` and `validate()` methods have been deprecated and will throw an `UnsupportedOperationException` if called. Any logic specific to a template engine should be available through the `TemplateRenderer` API.
=== Observability
* Changes to the `spring.ai.client` observation:
** The `spring.ai.chat.client.tool.function.names` and `spring.ai.chat.client.tool.function.callbacks` attributes have been deprecated, replaced by a new `spring.ai.chat.client.tool.names` attribute that includes the names of all the tools passed to a ChatClient, regardless of the underlying mechanism used to define them.
** The `spring.ai.chat.client.advisor.params` attribute has been deprecated and will not have a replacement. The reason is that there is a risk to expose sensitive information or break the instrumentation since the entries in the advisor context are used to pass arbitrary Java objects between advisors and are not necessarily serializable. The conversation ID that was previously exported here is now available via the dedicated `spring.ai.chat.client.conversation.id` attribute. If you need to export some of the other parameters in the advisor context to the observability system, you can do so by defining an `ObservationFilter` and making an explicit decision on which parameters to export. For inspiration, you can refer to the `ChatClientPromptContentObservationFilter`.
** The content of a prompt as specified via a ChatClient API was included optionally in the `spring.ai.client` observation, broken down in a few attributes: `spring.ai.chat.client.user.text`, `spring.ai.chat.client.user.params`, `spring.ai.chat.client.system.text`, `spring.ai.chat.client.system.params`. All those attributes are now deprecated, replaced by a single `gen_ai.prompt` attribute that contains all the messages in the prompt, solving the problem affecting the deprecated attributes where part of the prompt was not included in the observation, and aligning with the observations used in the ChatModel API. This new attribute can be enabled via the `spring.ai.chat.observations.include-prompt` configuration property, whereas the previous `spring.ai.chat.observations.include-input` configuration property is deprecated.
* Changes to the `spring.ai.advisor` observation:
** The `spring.ai.advisor.type` attribute has been deprecated. In previous releases, the Advisor API was categorized based on the type of advisor (`before`, `after`, `around`). That distinction doesn't apply anymore meaning that all Advisors are now of the same type (`around`).
[[upgrading-to-1-0-0-m7]]
== Upgrading to 1.0.0-M7
@@ -803,9 +890,3 @@ You can access `0.7.1-SNAPSHOT` artifacts as before and still access https://mar
<version>0.7.1-SNAPSHOT</version>
</dependency>
----
== Upgrading to 1.0.0.M4
* PaLM API support removal
As a follow up to the announcement to https://ai.google.dev/palm_docs/deprecation[deprecate PaLM API], the PaLM API support is removed.

View File

@@ -38,6 +38,7 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Description;
import org.springframework.context.annotation.Import;
import org.springframework.test.annotation.DirtiesContext;
import static org.assertj.core.api.Assertions.assertThat;
@@ -49,6 +50,7 @@ import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(classes = TestApplication.class)
@Import(FunctionToolCallbackTests.Tools.class)
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*")
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
public class FunctionToolCallbackTests {
// @formatter:off

View File

@@ -34,6 +34,7 @@ import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.method.MethodToolCallback;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.DirtiesContext;
import static org.assertj.core.api.Assertions.assertThat;
@@ -44,6 +45,7 @@ import static org.assertj.core.api.Assertions.assertThat;
*/
@SpringBootTest(classes = TestApplication.class)
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*")
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
public class MethodToolCallbackTests {
@Autowired

View File

@@ -22,6 +22,7 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
@@ -30,6 +31,7 @@ import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.model.ModelRequest;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
@@ -45,7 +47,7 @@ public class Prompt implements ModelRequest<List<Message>> {
private final List<Message> messages;
private ChatOptions chatOptions;
private final ChatOptions chatOptions;
public Prompt(String contents) {
this(new UserMessage(contents));
@@ -72,6 +74,8 @@ public class Prompt implements ModelRequest<List<Message>> {
}
public Prompt(List<Message> messages, ChatOptions chatOptions) {
Assert.notNull(messages, "messages cannot be null");
Assert.noNullElements(messages, "messages cannot contain null elements");
this.messages = messages;
this.chatOptions = (chatOptions != null) ? chatOptions : ChatOptions.builder().build();
}
@@ -94,6 +98,20 @@ public class Prompt implements ModelRequest<List<Message>> {
return this.messages;
}
/**
* Get the last user message in the prompt. If no user message is found, an empty
* UserMessage is returned.
*/
public UserMessage getUserMessage() {
for (int i = this.messages.size() - 1; i >= 0; i--) {
Message message = this.messages.get(i);
if (message instanceof UserMessage userMessage) {
return userMessage;
}
}
return new UserMessage("");
}
@Override
public String toString() {
return "Prompt{" + "messages=" + this.messages + ", modelOptions=" + this.chatOptions + '}';
@@ -123,17 +141,10 @@ public class Prompt implements ModelRequest<List<Message>> {
List<Message> messagesCopy = new ArrayList<>();
this.messages.forEach(message -> {
if (message instanceof UserMessage userMessage) {
messagesCopy.add(UserMessage.builder()
.text(userMessage.getText())
.media(userMessage.getMedia())
.metadata(message.getMetadata())
.build());
messagesCopy.add(userMessage.copy());
}
else if (message instanceof SystemMessage systemMessage) {
messagesCopy.add(SystemMessage.builder()
.text(systemMessage.getText())
.metadata(systemMessage.getMetadata())
.build());
messagesCopy.add(systemMessage.copy());
}
else if (message instanceof AssistantMessage assistantMessage) {
messagesCopy.add(new AssistantMessage(assistantMessage.getText(), assistantMessage.getMetadata(),
@@ -151,6 +162,38 @@ public class Prompt implements ModelRequest<List<Message>> {
return messagesCopy;
}
/**
* @param userMessageAugmenter the function to augment the last user message.
* @return a new prompt instance with the augmented user message.
*/
public Prompt augmentUserMessage(Function<UserMessage, UserMessage> userMessageAugmenter) {
var messagesCopy = new ArrayList<>(this.messages);
for (int i = messagesCopy.size() - 1; i >= 0; i--) {
Message message = messagesCopy.get(i);
if (message instanceof UserMessage userMessage) {
messagesCopy.set(i, userMessageAugmenter.apply(userMessage));
break;
}
if (i == 0) {
messagesCopy.add(userMessageAugmenter.apply(new UserMessage("")));
}
}
return new Prompt(messagesCopy, null == this.chatOptions ? null : this.chatOptions.copy());
}
/**
* Creates a copy of the prompt, replacing the text content of the last UserMessage
* with the provided text. If no UserMessage exists, a new one with the given text is
* added.
* @param newUserText The new text content for the last user message.
* @return A new Prompt instance with the augmented user message text.
*/
public Prompt augmentUserMessage(String newUserText) {
return augmentUserMessage(userMessage -> userMessage.mutate().text(newUserText).build());
}
public Builder mutate() {
Builder builder = new Builder().messages(instructionsCopy());
builder.chatOptions(this.chatOptions.copy());
@@ -167,7 +210,7 @@ public class Prompt implements ModelRequest<List<Message>> {
private String content;
@Nullable
private List<Message> messages = new ArrayList<>();
private List<Message> messages;
@Nullable
private ChatOptions chatOptions;

View File

@@ -0,0 +1,161 @@
/*
* Copyright 2023-2025 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.prompt;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for {@link Prompt}.
*
* @author Thomas Vitale
*/
class PromptTests {
@Test
void whenContentIsNullThenThrow() {
assertThatThrownBy(() -> new Prompt((String) null)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Content must not be null for SYSTEM or USER messages");
assertThatThrownBy(() -> new Prompt((String) null, ChatOptions.builder().build()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Content must not be null for SYSTEM or USER messages");
}
@Test
void whenContentIsEmptyThenReturn() {
Prompt prompt = new Prompt("");
assertThat(prompt).isNotNull();
prompt = new Prompt("", ChatOptions.builder().build());
assertThat(prompt).isNotNull();
}
@Test
void whenMessageIsNullThenThrow() {
assertThatThrownBy(() -> new Prompt((Message) null)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("messages cannot contain null elements");
assertThatThrownBy(() -> new Prompt((Message) null, ChatOptions.builder().build()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("messages cannot contain null elements");
}
@Test
void whenMessageListIsNullThenThrow() {
assertThatThrownBy(() -> new Prompt((List<Message>) null)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("messages cannot be null");
assertThatThrownBy(() -> new Prompt((List<Message>) null, ChatOptions.builder().build()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("messages cannot be null");
}
@Test
void whenMessageArrayIsNullThenThrow() {
assertThatThrownBy(() -> new Prompt((Message[]) null)).isInstanceOf(NullPointerException.class);
}
@Test
void whenContentAndMessageAreBothDefinedThenThrow() {
assertThatThrownBy(() -> Prompt.builder().content("Something").messages(new UserMessage("Else")).build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("content and messages cannot be set at the same time");
}
@Test
void getUserMessageWhenSingle() {
Prompt prompt = Prompt.builder().messages(new UserMessage("Hello")).build();
assertThat(prompt.getUserMessage()).isNotNull();
assertThat(prompt.getUserMessage().getText()).isEqualTo("Hello");
}
@Test
void getUserMessageWhenMultiple() {
Prompt prompt = Prompt.builder().messages(new UserMessage("Hello"), new UserMessage("How are you?")).build();
assertThat(prompt.getUserMessage()).isNotNull();
assertThat(prompt.getUserMessage().getText()).isEqualTo("How are you?");
}
@Test
void getUserMessageWhenNone() {
Prompt prompt = Prompt.builder().messages(new SystemMessage("You'll be back!")).build();
assertThat(prompt.getUserMessage()).isNotNull();
assertThat(prompt.getUserMessage().getText()).isEqualTo("");
prompt = Prompt.builder().messages(List.of()).build();
assertThat(prompt.getUserMessage()).isNotNull();
assertThat(prompt.getUserMessage().getText()).isEqualTo("");
}
@Test
void augmentUserMessageWhenSingle() {
Prompt prompt = Prompt.builder().messages(new UserMessage("Hello")).build();
assertThat(prompt.getUserMessage()).isNotNull();
assertThat(prompt.getUserMessage().getText()).isEqualTo("Hello");
Prompt copy = prompt.augmentUserMessage(message -> message.mutate().text("How are you?").build());
assertThat(copy.getUserMessage()).isNotNull();
assertThat(copy.getUserMessage().getText()).isEqualTo("How are you?");
assertThat(prompt.getUserMessage()).isNotNull();
assertThat(prompt.getUserMessage().getText()).isEqualTo("Hello");
}
@Test
void augmentUserMessageWhenMultiple() {
Prompt prompt = Prompt.builder().messages(new UserMessage("Hello"), new UserMessage("How are you?")).build();
assertThat(prompt.getUserMessage()).isNotNull();
assertThat(prompt.getUserMessage().getText()).isEqualTo("How are you?");
Prompt copy = prompt.augmentUserMessage(message -> message.mutate().text("What about you?").build());
assertThat(copy.getUserMessage()).isNotNull();
assertThat(copy.getUserMessage().getText()).isEqualTo("What about you?");
assertThat(prompt.getUserMessage()).isNotNull();
assertThat(prompt.getUserMessage().getText()).isEqualTo("How are you?");
}
@Test
void augmentUserMessageWhenNone() {
Prompt prompt = Prompt.builder().messages(new SystemMessage("You'll be back!")).build();
assertThat(prompt.getUserMessage()).isNotNull();
assertThat(prompt.getUserMessage().getText()).isEqualTo("");
Prompt copy = prompt.augmentUserMessage(message -> message.mutate().text("How are you?").build());
assertThat(copy.getUserMessage()).isNotNull();
assertThat(copy.getUserMessage().getText()).isEqualTo("How are you?");
assertThat(prompt.getUserMessage()).isNotNull();
assertThat(prompt.getUserMessage().getText()).isEqualTo("");
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2023-2024 the original author or authors.
* Copyright 2023-2025 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.
@@ -28,8 +28,10 @@ import reactor.core.scheduler.Scheduler;
import org.springframework.ai.chat.client.advisor.api.AdvisedRequest;
import org.springframework.ai.chat.client.advisor.api.AdvisedResponse;
import org.springframework.ai.chat.client.advisor.api.BaseAdvisor;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.AdvisorChain;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.document.Document;
import org.springframework.ai.rag.Query;
import org.springframework.ai.rag.generation.augmentation.ContextualQueryAugmenter;
@@ -78,7 +80,7 @@ public final class RetrievalAugmentationAdvisor implements BaseAdvisor {
private final int order;
public RetrievalAugmentationAdvisor(@Nullable List<QueryTransformer> queryTransformers,
private RetrievalAugmentationAdvisor(@Nullable List<QueryTransformer> queryTransformers,
@Nullable QueryExpander queryExpander, DocumentRetriever documentRetriever,
@Nullable DocumentJoiner documentJoiner, @Nullable QueryAugmenter queryAugmenter,
@Nullable TaskExecutor taskExecutor, @Nullable Scheduler scheduler, @Nullable Integer order) {
@@ -98,14 +100,24 @@ public final class RetrievalAugmentationAdvisor implements BaseAdvisor {
return new Builder();
}
/**
* @deprecated in favour of {@link #before(ChatClientRequest, AdvisorChain)}
*/
@Override
public AdvisedRequest before(AdvisedRequest request) {
Map<String, Object> context = new HashMap<>(request.adviseContext());
@Deprecated
public AdvisedRequest before(AdvisedRequest advisedRequest) {
ChatClientRequest chatClientRequest = advisedRequest.toChatClientRequest();
return AdvisedRequest.from(before(chatClientRequest, null));
}
@Override
public ChatClientRequest before(ChatClientRequest chatClientRequest, @Nullable AdvisorChain advisorChain) {
Map<String, Object> context = new HashMap<>(chatClientRequest.context());
// 0. Create a query from the user text, parameters, and conversation history.
Query originalQuery = Query.builder()
.text(new PromptTemplate(request.userText(), request.userParams()).render())
.history(request.messages())
.text(chatClientRequest.prompt().getUserMessage().getText())
.history(chatClientRequest.prompt().getInstructions())
.context(context)
.build();
@@ -135,8 +147,11 @@ public final class RetrievalAugmentationAdvisor implements BaseAdvisor {
// 5. Augment user query with the document contextual data.
Query augmentedQuery = this.queryAugmenter.augment(originalQuery, documents);
// 6. Update advised request with augmented prompt.
return AdvisedRequest.from(request).userText(augmentedQuery.text()).adviseContext(context).build();
// 6. Update ChatClientRequest with augmented prompt.
return chatClientRequest.mutate()
.prompt(chatClientRequest.prompt().augmentUserMessage(augmentedQuery.text()))
.context(context)
.build();
}
/**
@@ -148,17 +163,30 @@ public final class RetrievalAugmentationAdvisor implements BaseAdvisor {
return Map.entry(query, documents);
}
/**
* @deprecated in favour of {@link #after(ChatClientResponse, AdvisorChain)}
*/
@Override
@Deprecated
public AdvisedResponse after(AdvisedResponse advisedResponse) {
ChatClientResponse chatClientResponse = advisedResponse.toChatClientResponse();
return AdvisedResponse.from(after(chatClientResponse, null));
}
@Override
public ChatClientResponse after(ChatClientResponse chatClientResponse, @Nullable AdvisorChain advisorChain) {
ChatResponse.Builder chatResponseBuilder;
if (advisedResponse.response() == null) {
if (chatClientResponse.chatResponse() == null) {
chatResponseBuilder = ChatResponse.builder();
}
else {
chatResponseBuilder = ChatResponse.builder().from(advisedResponse.response());
chatResponseBuilder = ChatResponse.builder().from(chatClientResponse.chatResponse());
}
chatResponseBuilder.metadata(DOCUMENT_CONTEXT, advisedResponse.adviseContext().get(DOCUMENT_CONTEXT));
return new AdvisedResponse(chatResponseBuilder.build(), advisedResponse.adviseContext());
chatResponseBuilder.metadata(DOCUMENT_CONTEXT, chatClientResponse.context().get(DOCUMENT_CONTEXT));
return ChatClientResponse.builder()
.chatResponse(chatResponseBuilder.build())
.context(chatClientResponse.context())
.build();
}
@Override

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@NonNullApi
@NonNullFields
package org.springframework.ai.rag.advisor;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;