Skip to content

Commit 1f59cca

Browse files
ThomasVitaletzolov
authored andcommitted
Make ChatClient and Advisor APIs more robust - Part 1
- Introduce “ChatClientRequest” and “ChatClientResponse” for propagating requests/responses in a ChatClient advisor chain. - Structure a Prompt at the beginning of the chain, to ensure a consistent view across execution chain and observations. Any template is rendered at the beginning so that every advisor doesn’t have to do it again. - Improve observations to include the complete view of the prompt messages, instead of only considering userText and systemText. - Remove legacy “around” advisor type concept. - Keep backward compatibility for AdvisedRequest, AdvisedResponse, and legacy Advisor APIs. Relates to gh-2655 Signed-off-by: Thomas Vitale <[email protected]>
1 parent 5930839 commit 1f59cca

File tree

53 files changed

+2360
-482
lines changed

Some content is hidden

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

53 files changed

+2360
-482
lines changed

models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/client/AnthropicChatClientIT.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
import org.springframework.ai.chat.model.ChatResponse;
4343
import org.springframework.ai.converter.BeanOutputConverter;
4444
import org.springframework.ai.converter.ListOutputConverter;
45+
import org.springframework.ai.test.CurlyBracketEscaper;
4546
import org.springframework.ai.tool.function.FunctionToolCallback;
4647
import org.springframework.beans.factory.annotation.Autowired;
4748
import org.springframework.beans.factory.annotation.Value;
@@ -189,7 +190,7 @@ void beanStreamOutputConverterRecords() {
189190
.user(u -> u
190191
.text("Generate the filmography of 5 movies for Tom Hanks. " + System.lineSeparator()
191192
+ "{format}")
192-
.param("format", outputConverter.getFormat()))
193+
.param("format", CurlyBracketEscaper.escapeCurlyBrackets(outputConverter.getFormat())))
193194
.stream()
194195
.content();
195196

models/spring-ai-azure-openai/src/test/java/org/springframework/ai/azure/openai/AzureOpenAiChatClientIT.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
3232
import org.springframework.ai.chat.model.ChatResponse;
3333
import org.springframework.ai.converter.BeanOutputConverter;
34+
import org.springframework.ai.test.CurlyBracketEscaper;
3435
import org.springframework.beans.factory.annotation.Autowired;
3536
import org.springframework.beans.factory.annotation.Value;
3637
import org.springframework.boot.SpringBootConfiguration;
@@ -83,7 +84,7 @@ void beanStreamOutputConverterRecords() {
8384
.user(u -> u
8485
.text("Generate the filmography of 5 movies for Tom Hanks. " + System.lineSeparator()
8586
+ "{format}")
86-
.param("format", outputConverter.getFormat()))
87+
.param("format", CurlyBracketEscaper.escapeCurlyBrackets(outputConverter.getFormat())))
8788
.stream()
8889
.chatResponse();
8990

models/spring-ai-bedrock-converse/src/test/java/org/springframework/ai/bedrock/converse/BedrockConverseChatClientIT.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import org.springframework.ai.converter.BeanOutputConverter;
3838
import org.springframework.ai.converter.ListOutputConverter;
3939
import org.springframework.ai.model.tool.ToolCallingChatOptions;
40+
import org.springframework.ai.test.CurlyBracketEscaper;
4041
import org.springframework.ai.tool.function.FunctionToolCallback;
4142
import org.springframework.beans.factory.annotation.Autowired;
4243
import org.springframework.beans.factory.annotation.Value;
@@ -182,7 +183,7 @@ void beanStreamOutputConverterRecords() {
182183
.user(u -> u
183184
.text("Generate the filmography of 5 movies for Tom Hanks. " + System.lineSeparator()
184185
+ "{format}")
185-
.param("format", outputConverter.getFormat()))
186+
.param("format", CurlyBracketEscaper.escapeCurlyBrackets(outputConverter.getFormat())))
186187
.stream()
187188
.chatResponse();
188189

models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/MistralAiChatClientIT.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,14 @@
2828
import reactor.core.publisher.Flux;
2929

3030
import org.springframework.ai.chat.client.ChatClient;
31+
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
3132
import org.springframework.ai.chat.messages.UserMessage;
3233
import org.springframework.ai.chat.model.ChatResponse;
3334
import org.springframework.ai.converter.BeanOutputConverter;
3435
import org.springframework.ai.converter.ListOutputConverter;
3536
import org.springframework.ai.mistralai.api.MistralAiApi;
3637
import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletionRequest.ToolChoice;
38+
import org.springframework.ai.test.CurlyBracketEscaper;
3739
import org.springframework.ai.tool.function.FunctionToolCallback;
3840
import org.springframework.beans.factory.annotation.Autowired;
3941
import org.springframework.beans.factory.annotation.Value;
@@ -198,10 +200,11 @@ void beanStreamOutputConverterRecords() {
198200
// @formatter:off
199201
Flux<String> chatResponse = ChatClient.create(this.chatModel)
200202
.prompt()
203+
.advisors(new SimpleLoggerAdvisor())
201204
.user(u -> u
202205
.text("Generate the filmography of 5 movies for Tom Hanks. " + System.lineSeparator()
203206
+ "{format}")
204-
.param("format", outputConverter.getFormat()))
207+
.param("format", CurlyBracketEscaper.escapeCurlyBrackets(outputConverter.getFormat())))
205208
.stream()
206209
.content();
207210

models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/OpenAiChatClientIT.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest.AudioParameters;
4444
import org.springframework.ai.openai.api.tool.MockWeatherService;
4545
import org.springframework.ai.openai.testutils.AbstractIT;
46+
import org.springframework.ai.test.CurlyBracketEscaper;
4647
import org.springframework.ai.tool.function.FunctionToolCallback;
4748
import org.springframework.beans.factory.annotation.Value;
4849
import org.springframework.boot.test.context.SpringBootTest;
@@ -220,7 +221,7 @@ void beanStreamOutputConverterRecords() {
220221
.user(u -> u
221222
.text("Generate the filmography of 5 movies for Tom Hanks. " + System.lineSeparator()
222223
+ "{format}")
223-
.param("format", outputConverter.getFormat()))
224+
.param("format", CurlyBracketEscaper.escapeCurlyBrackets(outputConverter.getFormat())))
224225
.stream()
225226
.chatResponse();
226227

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,8 @@ interface CallResponseSpec {
156156
@Nullable
157157
<T> T entity(Class<T> type);
158158

159+
ChatClientResponse chatClientResponse();
160+
159161
@Nullable
160162
ChatResponse chatResponse();
161163

@@ -172,6 +174,8 @@ interface CallResponseSpec {
172174

173175
interface StreamResponseSpec {
174176

177+
Flux<ChatClientResponse> chatClientResponse();
178+
175179
Flux<ChatResponse> chatResponse();
176180

177181
Flux<String> content();
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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;
18+
19+
/**
20+
* Common attributes used in {@link ChatClient} context.
21+
*
22+
* @author Thomas Vitale
23+
* @since 1.0.0
24+
*/
25+
public enum ChatClientAttributes {
26+
27+
//@formatter:off
28+
29+
@Deprecated // Only for backward compatibility until the next release.
30+
ADVISORS("spring.ai.chat.client.advisors"),
31+
@Deprecated // Only for backward compatibility until the next release.
32+
CHAT_MODEL("spring.ai.chat.client.model"),
33+
@Deprecated // Only for backward compatibility until the next release.
34+
OUTPUT_FORMAT("spring.ai.chat.client.output.format"),
35+
@Deprecated // Only for backward compatibility until the next release.
36+
USER_PARAMS("spring.ai.chat.client.user.params"),
37+
@Deprecated // Only for backward compatibility until the next release.
38+
SYSTEM_PARAMS("spring.ai.chat.client.system.params");
39+
40+
//@formatter:on
41+
42+
private final String key;
43+
44+
ChatClientAttributes(String key) {
45+
this.key = key;
46+
}
47+
48+
public String getKey() {
49+
return key;
50+
}
51+
52+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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;
18+
19+
import org.springframework.ai.chat.prompt.Prompt;
20+
import org.springframework.util.Assert;
21+
22+
import java.util.HashMap;
23+
import java.util.Map;
24+
25+
/**
26+
* Represents a request processed by a {@link ChatClient} that ultimately is used to build
27+
* a {@link Prompt} to be sent to an AI model.
28+
*
29+
* @param prompt The prompt to be sent to the AI model
30+
* @param context The contextual data through the execution chain
31+
* @author Thomas Vitale
32+
* @since 1.0.0
33+
*/
34+
public record ChatClientRequest(Prompt prompt, Map<String, Object> context) {
35+
36+
public ChatClientRequest {
37+
Assert.notNull(prompt, "prompt cannot be null");
38+
Assert.notNull(context, "context cannot be null");
39+
Assert.noNullElements(context.keySet(), "context keys cannot be null");
40+
}
41+
42+
public Builder mutate() {
43+
return new Builder().prompt(this.prompt).context(this.context);
44+
}
45+
46+
public static Builder builder() {
47+
return new Builder();
48+
}
49+
50+
public static final class Builder {
51+
52+
private Prompt prompt;
53+
54+
private Map<String, Object> context = new HashMap<>();
55+
56+
private Builder() {
57+
}
58+
59+
public Builder prompt(Prompt prompt) {
60+
Assert.notNull(prompt, "prompt cannot be null");
61+
this.prompt = prompt;
62+
return this;
63+
}
64+
65+
public Builder context(Map<String, Object> context) {
66+
Assert.notNull(context, "context cannot be null");
67+
this.context.putAll(context);
68+
return this;
69+
}
70+
71+
public Builder context(String key, Object value) {
72+
Assert.notNull(key, "key cannot be null");
73+
this.context.put(key, value);
74+
return this;
75+
}
76+
77+
public ChatClientRequest build() {
78+
return new ChatClientRequest(prompt, context);
79+
}
80+
81+
}
82+
83+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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;
18+
19+
import org.springframework.ai.chat.model.ChatResponse;
20+
import org.springframework.lang.Nullable;
21+
import org.springframework.util.Assert;
22+
23+
import java.util.HashMap;
24+
import java.util.Map;
25+
26+
/**
27+
* Represents a response returned by a {@link ChatClient}.
28+
*
29+
* @param chatResponse The response returned by the AI model
30+
* @param context The contextual data propagated through the execution chain
31+
* @author Thomas Vitale
32+
* @since 1.0.0
33+
*/
34+
public record ChatClientResponse(@Nullable ChatResponse chatResponse, Map<String, Object> context) {
35+
36+
public ChatClientResponse {
37+
Assert.notNull(context, "context cannot be null");
38+
Assert.noNullElements(context.keySet(), "context keys cannot be null");
39+
}
40+
41+
public static Builder builder() {
42+
return new Builder();
43+
}
44+
45+
public static class Builder {
46+
47+
private ChatResponse chatResponse;
48+
49+
private Map<String, Object> context = new HashMap<>();
50+
51+
private Builder() {
52+
}
53+
54+
public Builder chatResponse(ChatResponse chatResponse) {
55+
this.chatResponse = chatResponse;
56+
return this;
57+
}
58+
59+
public Builder context(Map<String, Object> context) {
60+
Assert.notNull(context, "context cannot be null");
61+
this.context.putAll(context);
62+
return this;
63+
}
64+
65+
public Builder context(String key, Object value) {
66+
Assert.notNull(key, "key cannot be null");
67+
this.context.put(key, value);
68+
return this;
69+
}
70+
71+
public ChatClientResponse build() {
72+
return new ChatClientResponse(this.chatResponse, this.context);
73+
}
74+
75+
}
76+
77+
}

0 commit comments

Comments
 (0)