Skip to content

Commit faa8778

Browse files
ThomasVitalemarkpollack
authored andcommitted
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 <[email protected]>
1 parent 0c0787b commit faa8778

File tree

42 files changed

+1357
-147
lines changed

Some content is hidden

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

42 files changed

+1357
-147
lines changed

auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/main/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientAutoConfiguration.java

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2023-2024 the original author or authors.
2+
* Copyright 2023-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -24,6 +24,7 @@
2424
import org.springframework.ai.chat.client.ChatClientCustomizer;
2525
import org.springframework.ai.chat.client.observation.ChatClientInputContentObservationFilter;
2626
import org.springframework.ai.chat.client.observation.ChatClientObservationConvention;
27+
import org.springframework.ai.chat.client.observation.ChatClientPromptContentObservationFilter;
2728
import org.springframework.ai.chat.model.ChatModel;
2829
import org.springframework.beans.factory.ObjectProvider;
2930
import org.springframework.boot.autoconfigure.AutoConfiguration;
@@ -79,14 +80,28 @@ ChatClient.Builder chatClientBuilder(ChatClientBuilderConfigurer chatClientBuild
7980
return chatClientBuilderConfigurer.configure(builder);
8081
}
8182

83+
/**
84+
* @deprecated in favour of {@link #chatClientPromptContentObservationFilter()}.
85+
*/
8286
@Bean
8387
@ConditionalOnMissingBean
8488
@ConditionalOnProperty(prefix = ChatClientBuilderProperties.CONFIG_PREFIX + ".observations", name = "include-input",
8589
havingValue = "true")
90+
@Deprecated
8691
ChatClientInputContentObservationFilter chatClientInputContentObservationFilter() {
8792
logger.warn(
8893
"You have enabled the inclusion of the input content in the observations, with the risk of exposing sensitive or private information. Please, be careful!");
8994
return new ChatClientInputContentObservationFilter();
9095
}
9196

97+
@Bean
98+
@ConditionalOnMissingBean
99+
@ConditionalOnProperty(prefix = ChatClientBuilderProperties.CONFIG_PREFIX + ".observations",
100+
name = "include-prompt", havingValue = "true")
101+
ChatClientPromptContentObservationFilter chatClientPromptContentObservationFilter() {
102+
logger.warn(
103+
"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!");
104+
return new ChatClientPromptContentObservationFilter();
105+
}
106+
92107
}

auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/main/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientBuilderProperties.java

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2023-2024 the original author or authors.
2+
* Copyright 2023-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -17,6 +17,7 @@
1717
package org.springframework.ai.model.chat.client.autoconfigure;
1818

1919
import org.springframework.boot.context.properties.ConfigurationProperties;
20+
import org.springframework.boot.context.properties.DeprecatedConfigurationProperty;
2021

2122
/**
2223
* Configuration properties for the chat client builder.
@@ -25,6 +26,7 @@
2526
* @author Mark Pollack
2627
* @author Josh Long
2728
* @author Arjen Poutsma
29+
* @author Thomas Vitale
2830
* @since 1.0.0
2931
*/
3032
@ConfigurationProperties(ChatClientBuilderProperties.CONFIG_PREFIX)
@@ -37,7 +39,7 @@ public class ChatClientBuilderProperties {
3739
*/
3840
private boolean enabled = true;
3941

40-
private Observations observations = new Observations();
42+
private final Observations observations = new Observations();
4143

4244
public Observations getObservations() {
4345
return this.observations;
@@ -55,9 +57,17 @@ public static class Observations {
5557

5658
/**
5759
* Whether to include the input content in the observations.
60+
* @deprecated Use {@link #includePrompt} instead.
5861
*/
62+
@Deprecated
5963
private boolean includeInput = false;
6064

65+
/**
66+
* Whether to include the prompt content in the observations.
67+
*/
68+
private boolean includePrompt = false;
69+
70+
@DeprecatedConfigurationProperty(replacement = "spring.ai.chat.observations.include-prompt")
6171
public boolean isIncludeInput() {
6272
return this.includeInput;
6373
}
@@ -66,6 +76,14 @@ public void setIncludeInput(boolean includeCompletion) {
6676
this.includeInput = includeCompletion;
6777
}
6878

79+
public boolean isIncludePrompt() {
80+
return this.includePrompt;
81+
}
82+
83+
public void setIncludePrompt(boolean includePrompt) {
84+
this.includePrompt = includePrompt;
85+
}
86+
6987
}
7088

7189
}

auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/test/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientObservationAutoConfigurationTests.java

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2023-2024 the original author or authors.
2+
* Copyright 2023-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -19,6 +19,7 @@
1919
import org.junit.jupiter.api.Test;
2020

2121
import org.springframework.ai.chat.client.observation.ChatClientInputContentObservationFilter;
22+
import org.springframework.ai.chat.client.observation.ChatClientPromptContentObservationFilter;
2223
import org.springframework.boot.autoconfigure.AutoConfigurations;
2324
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
2425

@@ -28,6 +29,7 @@
2829
* Unit tests for {@link ChatClientAutoConfiguration} observability support.
2930
*
3031
* @author Christian Tzolov
32+
* @author Thomas Vitale
3133
*/
3234
class ChatClientObservationAutoConfigurationTests {
3335

@@ -46,4 +48,16 @@ void inputContentFilterEnabled() {
4648
.run(context -> assertThat(context).hasSingleBean(ChatClientInputContentObservationFilter.class));
4749
}
4850

51+
@Test
52+
void promptContentFilterDefault() {
53+
this.contextRunner
54+
.run(context -> assertThat(context).doesNotHaveBean(ChatClientPromptContentObservationFilter.class));
55+
}
56+
57+
@Test
58+
void promptContentFilterEnabled() {
59+
this.contextRunner.withPropertyValues("spring.ai.chat.client.observations.include-prompt=true")
60+
.run(context -> assertThat(context).hasSingleBean(ChatClientPromptContentObservationFilter.class));
61+
}
62+
4963
}

spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientAttributes.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@
2121
*
2222
* @author Thomas Vitale
2323
* @since 1.0.0
24+
* @deprecated only introduced to smooth the transition to the new APIs and ensure
25+
* backward compatibility
2426
*/
27+
@Deprecated
2528
public enum ChatClientAttributes {
2629

2730
//@formatter:off

spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientRequest.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,12 @@ public record ChatClientRequest(Prompt prompt, Map<String, Object> context) {
3939
Assert.noNullElements(context.keySet(), "context keys cannot be null");
4040
}
4141

42+
public ChatClientRequest copy() {
43+
return new ChatClientRequest(this.prompt.copy(), new HashMap<>(this.context));
44+
}
45+
4246
public Builder mutate() {
43-
return new Builder().prompt(this.prompt).context(this.context);
47+
return new Builder().prompt(this.prompt.copy()).context(new HashMap<>(this.context));
4448
}
4549

4650
public static Builder builder() {

spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientResponse.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ public record ChatClientResponse(@Nullable ChatResponse chatResponse, Map<String
3838
Assert.noNullElements(context.keySet(), "context keys cannot be null");
3939
}
4040

41+
public ChatClientResponse copy() {
42+
return new ChatClientResponse(this.chatResponse, new HashMap<>(this.context));
43+
}
44+
45+
public Builder mutate() {
46+
return new Builder().chatResponse(this.chatResponse).context(new HashMap<>(this.context));
47+
}
48+
4149
public static Builder builder() {
4250
return new Builder();
4351
}
@@ -51,7 +59,7 @@ public static class Builder {
5159
private Builder() {
5260
}
5361

54-
public Builder chatResponse(ChatResponse chatResponse) {
62+
public Builder chatResponse(@Nullable ChatResponse chatResponse) {
5563
this.chatResponse = chatResponse;
5664
return this;
5765
}

spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java

Lines changed: 22 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -493,10 +493,11 @@ private ChatClientResponse doGetObservableChatClientResponse(ChatClientRequest c
493493
private ChatClientResponse doGetObservableChatClientResponse(ChatClientRequest chatClientRequest,
494494
@Nullable String outputFormat) {
495495
ChatClientRequest formattedChatClientRequest = StringUtils.hasText(outputFormat)
496-
? addFormatInstructionsToPrompt(chatClientRequest, outputFormat) : chatClientRequest;
496+
? augmentPromptWithFormatInstructions(chatClientRequest, outputFormat) : chatClientRequest;
497497

498498
ChatClientObservationContext observationContext = ChatClientObservationContext.builder()
499499
.request(formattedChatClientRequest)
500+
.advisors(advisorChain.getCallAdvisors())
500501
.stream(false)
501502
.withFormat(outputFormat)
502503
.build();
@@ -511,42 +512,16 @@ private ChatClientResponse doGetObservableChatClientResponse(ChatClientRequest c
511512
}
512513

513514
@NonNull
514-
private static ChatClientRequest addFormatInstructionsToPrompt(ChatClientRequest chatClientRequest,
515+
private static ChatClientRequest augmentPromptWithFormatInstructions(ChatClientRequest chatClientRequest,
515516
String outputFormat) {
516-
List<Message> originalMessages = chatClientRequest.prompt().getInstructions();
517-
518-
if (CollectionUtils.isEmpty(originalMessages)) {
519-
return chatClientRequest;
520-
}
521-
522-
// Create a copy of the message list to avoid modifying the original.
523-
List<Message> modifiedMessages = new ArrayList<>(originalMessages);
524-
525-
// Get the last message (without removing it from original list)
526-
Message lastMessage = modifiedMessages.get(modifiedMessages.size() - 1);
527-
528-
// If the last message is a UserMessage, replace it with the modified version
529-
if (lastMessage instanceof UserMessage userMessage) {
530-
// Remove last message
531-
modifiedMessages.remove(modifiedMessages.size() - 1);
532-
533-
// Create new user message with format instructions
534-
UserMessage userMessageWithFormat = userMessage.mutate()
517+
Prompt augmentedPrompt = chatClientRequest.prompt()
518+
.augmentUserMessage(userMessage -> userMessage.mutate()
535519
.text(userMessage.getText() + System.lineSeparator() + outputFormat)
536-
.build();
537-
538-
// Add modified message back
539-
modifiedMessages.add(userMessageWithFormat);
540-
541-
// Build new ChatClientRequest preserving all properties but with modified
542-
// prompt
543-
return ChatClientRequest.builder()
544-
.prompt(chatClientRequest.prompt().mutate().messages(modifiedMessages).build())
545-
.context(Map.copyOf(chatClientRequest.context()))
546-
.build();
547-
}
548-
549-
return chatClientRequest;
520+
.build());
521+
return ChatClientRequest.builder()
522+
.prompt(augmentedPrompt)
523+
.context(Map.copyOf(chatClientRequest.context()))
524+
.build();
550525
}
551526

552527
@Nullable
@@ -588,6 +563,7 @@ private Flux<ChatClientResponse> doGetObservableFluxChatResponse(ChatClientReque
588563

589564
ChatClientObservationContext observationContext = ChatClientObservationContext.builder()
590565
.request(chatClientRequest)
566+
.advisors(advisorChain.getStreamAdvisors())
591567
.stream(true)
592568
.build();
593569

@@ -660,8 +636,6 @@ public static class DefaultChatClientRequestSpec implements ChatClientRequestSpe
660636

661637
private final Map<String, Object> advisorParams = new HashMap<>();
662638

663-
private final DefaultAroundAdvisorChain.Builder aroundAdvisorChainBuilder;
664-
665639
private final Map<String, Object> toolContext = new HashMap<>();
666640

667641
@Nullable
@@ -718,14 +692,6 @@ public DefaultChatClientRequestSpec(ChatModel chatModel, @Nullable String userTe
718692
this.observationConvention = observationConvention != null ? observationConvention
719693
: DEFAULT_CHAT_CLIENT_OBSERVATION_CONVENTION;
720694
this.toolContext.putAll(toolContext);
721-
722-
// At the stack bottom add the model call advisors.
723-
// They play the role of the last advisors in the advisor chain.
724-
this.advisors.add(new ChatModelCallAdvisor(chatModel));
725-
this.advisors.add(new ChatModelStreamAdvisor(chatModel));
726-
727-
this.aroundAdvisorChainBuilder = DefaultAroundAdvisorChain.builder(observationRegistry)
728-
.pushAll(this.advisors);
729695
}
730696

731697
private ObservationRegistry getObservationRegistry() {
@@ -822,23 +788,20 @@ public ChatClientRequestSpec advisors(Consumer<ChatClient.AdvisorSpec> consumer)
822788
consumer.accept(advisorSpec);
823789
this.advisorParams.putAll(advisorSpec.getParams());
824790
this.advisors.addAll(advisorSpec.getAdvisors());
825-
this.aroundAdvisorChainBuilder.pushAll(advisorSpec.getAdvisors());
826791
return this;
827792
}
828793

829794
public ChatClientRequestSpec advisors(Advisor... advisors) {
830795
Assert.notNull(advisors, "advisors cannot be null");
831796
Assert.noNullElements(advisors, "advisors cannot contain null elements");
832797
this.advisors.addAll(Arrays.asList(advisors));
833-
this.aroundAdvisorChainBuilder.pushAll(Arrays.asList(advisors));
834798
return this;
835799
}
836800

837801
public ChatClientRequestSpec advisors(List<Advisor> advisors) {
838802
Assert.notNull(advisors, "advisors cannot be null");
839803
Assert.noNullElements(advisors, "advisors cannot contain null elements");
840804
this.advisors.addAll(advisors);
841-
this.aroundAdvisorChainBuilder.pushAll(advisors);
842805
return this;
843806
}
844807

@@ -983,17 +946,26 @@ public ChatClientRequestSpec user(Consumer<PromptUserSpec> consumer) {
983946
}
984947

985948
public CallResponseSpec call() {
986-
BaseAdvisorChain advisorChain = aroundAdvisorChainBuilder.build();
949+
BaseAdvisorChain advisorChain = buildAdvisorChain();
987950
return new DefaultCallResponseSpec(toAdvisedRequest(this).toChatClientRequest(), advisorChain,
988951
observationRegistry, observationConvention);
989952
}
990953

991954
public StreamResponseSpec stream() {
992-
BaseAdvisorChain advisorChain = aroundAdvisorChainBuilder.build();
955+
BaseAdvisorChain advisorChain = buildAdvisorChain();
993956
return new DefaultStreamResponseSpec(toAdvisedRequest(this).toChatClientRequest(), advisorChain,
994957
observationRegistry, observationConvention);
995958
}
996959

960+
private BaseAdvisorChain buildAdvisorChain() {
961+
// At the stack bottom add the model call advisors.
962+
// They play the role of the last advisors in the advisor chain.
963+
this.advisors.add(ChatModelCallAdvisor.builder().chatModel(this.chatModel).build());
964+
this.advisors.add(ChatModelStreamAdvisor.builder().chatModel(this.chatModel).build());
965+
966+
return DefaultAroundAdvisorChain.builder(this.observationRegistry).pushAll(this.advisors).build();
967+
}
968+
997969
}
998970

999971
// Prompt
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* Copyright 2023-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.chat.client.advisor;
18+
19+
import org.springframework.ai.chat.client.ChatClientResponse;
20+
import org.springframework.ai.chat.model.ChatResponse;
21+
import org.springframework.util.StringUtils;
22+
23+
import java.util.function.Predicate;
24+
25+
/**
26+
* Utilities to work with advisors.
27+
*/
28+
public final class AdvisorUtils {
29+
30+
private AdvisorUtils() {
31+
}
32+
33+
/**
34+
* Checks whether the provided {@link ChatClientResponse} contains a
35+
* {@link ChatResponse} with at least one result having a non-empty finish reason in
36+
* its metadata.
37+
*/
38+
public static Predicate<ChatClientResponse> onFinishReason() {
39+
return chatClientResponse -> {
40+
ChatResponse chatResponse = chatClientResponse.chatResponse();
41+
return chatResponse != null && chatResponse.getResults() != null
42+
&& chatResponse.getResults()
43+
.stream()
44+
.anyMatch(result -> result != null && result.getMetadata() != null
45+
&& StringUtils.hasText(result.getMetadata().getFinishReason()));
46+
};
47+
}
48+
49+
}

0 commit comments

Comments
 (0)