Skip to content

Commit b50c915

Browse files
chemicLtzolov
authored andcommitted
Fix mutating global RestClient and WebClient builders (spring-projects#3020)
Since the builders for HTTP clients are mutable and shared, they should only be configured with globally applicable settings. The current use leaks specific details into other usages and affects newly instantiated clients. This PR applies the clone() method right before mutation happens as it probably is the strategy that avoids multiple unnecessary copies. feat: improve RestClient and WebClient instantiation and configuration across AI modules - Ensure RestClient.Builder and WebClient.Builder are properly cloned before setting base URLs and headers in Mistral, OpenAI, and Ollama API classes to prevent side effects. - Add WebClientAutoConfiguration to auto-configuration classes for Anthropic and Mistral AI modules. - Update Mistral AI auto-configuration to inject and use WebClient.Builder. - Clean up test and annotation usage (remove unnecessary @nullable, fix test builder usage in AnthropicApiIT). - Improve consistency and reliability of HTTP client configuration across model integrations. Signed-off-by: Dariusz Jędrzejczyk <[email protected]> Signed-off-by: Christian Tzolov <[email protected]> Co-authored-by: Christian Tzolov <[email protected]> Signed-off-by: minsoo.nam <[email protected]>
1 parent 77293b8 commit b50c915

File tree

11 files changed

+47
-23
lines changed

11 files changed

+47
-23
lines changed

auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/main/java/org/springframework/ai/model/anthropic/autoconfigure/AnthropicChatAutoConfiguration.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
* @since 1.0.0
5353
*/
5454
@AutoConfiguration(after = { RestClientAutoConfiguration.class, SpringAiRetryAutoConfiguration.class,
55-
ToolCallingAutoConfiguration.class })
55+
ToolCallingAutoConfiguration.class, WebClientAutoConfiguration.class })
5656
@EnableConfigurationProperties({ AnthropicChatProperties.class, AnthropicConnectionProperties.class })
5757
@ConditionalOnClass(AnthropicApi.class)
5858
@ConditionalOnProperty(name = SpringAIModelProperties.CHAT_MODEL, havingValue = SpringAIModels.ANTHROPIC,

auto-configurations/models/spring-ai-autoconfigure-model-mistral-ai/src/main/java/org/springframework/ai/model/mistralai/autoconfigure/MistralAiChatAutoConfiguration.java

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,15 @@
3535
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
3636
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
3737
import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
38+
import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration;
3839
import org.springframework.boot.context.properties.EnableConfigurationProperties;
3940
import org.springframework.context.annotation.Bean;
4041
import org.springframework.retry.support.RetryTemplate;
4142
import org.springframework.util.Assert;
4243
import org.springframework.util.StringUtils;
4344
import org.springframework.web.client.ResponseErrorHandler;
4445
import org.springframework.web.client.RestClient;
46+
import org.springframework.web.reactive.function.client.WebClient;
4547

4648
/**
4749
* Chat {@link AutoConfiguration Auto-configuration} for Mistral AI.
@@ -52,28 +54,30 @@
5254
* @author Ilayaperumal Gopinathan
5355
* @since 0.8.1
5456
*/
55-
@AutoConfiguration(after = { RestClientAutoConfiguration.class, SpringAiRetryAutoConfiguration.class,
56-
ToolCallingAutoConfiguration.class })
57+
@AutoConfiguration(after = { RestClientAutoConfiguration.class, WebClientAutoConfiguration.class,
58+
SpringAiRetryAutoConfiguration.class, ToolCallingAutoConfiguration.class })
5759
@EnableConfigurationProperties({ MistralAiCommonProperties.class, MistralAiChatProperties.class })
5860
@ConditionalOnProperty(name = SpringAIModelProperties.CHAT_MODEL, havingValue = SpringAIModels.MISTRAL,
5961
matchIfMissing = true)
6062
@ConditionalOnClass(MistralAiApi.class)
6163
@ImportAutoConfiguration(classes = { SpringAiRetryAutoConfiguration.class, RestClientAutoConfiguration.class,
62-
ToolCallingAutoConfiguration.class })
64+
WebClientAutoConfiguration.class, ToolCallingAutoConfiguration.class })
6365
public class MistralAiChatAutoConfiguration {
6466

6567
@Bean
6668
@ConditionalOnMissingBean
6769
public MistralAiChatModel mistralAiChatModel(MistralAiCommonProperties commonProperties,
6870
MistralAiChatProperties chatProperties, ObjectProvider<RestClient.Builder> restClientBuilderProvider,
69-
ToolCallingManager toolCallingManager, RetryTemplate retryTemplate,
70-
ResponseErrorHandler responseErrorHandler, ObjectProvider<ObservationRegistry> observationRegistry,
71+
ObjectProvider<WebClient.Builder> webClientBuilderProvider, ToolCallingManager toolCallingManager,
72+
RetryTemplate retryTemplate, ResponseErrorHandler responseErrorHandler,
73+
ObjectProvider<ObservationRegistry> observationRegistry,
7174
ObjectProvider<ChatModelObservationConvention> observationConvention,
7275
ObjectProvider<ToolExecutionEligibilityPredicate> mistralAiToolExecutionEligibilityPredicate) {
7376

7477
var mistralAiApi = mistralAiApi(chatProperties.getApiKey(), commonProperties.getApiKey(),
7578
chatProperties.getBaseUrl(), commonProperties.getBaseUrl(),
76-
restClientBuilderProvider.getIfAvailable(RestClient::builder), responseErrorHandler);
79+
restClientBuilderProvider.getIfAvailable(RestClient::builder),
80+
webClientBuilderProvider.getIfAvailable(WebClient::builder), responseErrorHandler);
7781

7882
var chatModel = MistralAiChatModel.builder()
7983
.mistralAiApi(mistralAiApi)
@@ -91,15 +95,17 @@ public MistralAiChatModel mistralAiChatModel(MistralAiCommonProperties commonPro
9195
}
9296

9397
private MistralAiApi mistralAiApi(String apiKey, String commonApiKey, String baseUrl, String commonBaseUrl,
94-
RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) {
98+
RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder,
99+
ResponseErrorHandler responseErrorHandler) {
95100

96101
var resolvedApiKey = StringUtils.hasText(apiKey) ? apiKey : commonApiKey;
97102
var resoledBaseUrl = StringUtils.hasText(baseUrl) ? baseUrl : commonBaseUrl;
98103

99104
Assert.hasText(resolvedApiKey, "Mistral API key must be set");
100105
Assert.hasText(resoledBaseUrl, "Mistral base URL must be set");
101106

102-
return new MistralAiApi(resoledBaseUrl, resolvedApiKey, restClientBuilder, responseErrorHandler);
107+
return new MistralAiApi(resoledBaseUrl, resolvedApiKey, restClientBuilder, webClientBuilder,
108+
responseErrorHandler);
103109
}
104110

105111
}

models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,12 +119,14 @@ private AnthropicApi(String baseUrl, String completionsPath, String anthropicApi
119119

120120
this.completionsPath = completionsPath;
121121

122-
this.restClient = restClientBuilder.baseUrl(baseUrl)
122+
this.restClient = restClientBuilder.clone()
123+
.baseUrl(baseUrl)
123124
.defaultHeaders(jsonContentHeaders)
124125
.defaultStatusHandler(responseErrorHandler)
125126
.build();
126127

127-
this.webClient = webClientBuilder.baseUrl(baseUrl)
128+
this.webClient = webClientBuilder.clone()
129+
.baseUrl(baseUrl)
128130
.defaultHeaders(jsonContentHeaders)
129131
.defaultStatusHandler(HttpStatusCode::isError,
130132
resp -> resp.bodyToMono(String.class)

models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/AnthropicApiIT.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ void chatCompletionStream() {
110110
void chatCompletionStreamError() {
111111
AnthropicMessage chatCompletionMessage = new AnthropicMessage(List.of(new ContentBlock("Tell me a Joke?")),
112112
Role.USER);
113-
AnthropicApi api = AnthropicApi.builder().baseUrl("FAKE_KEY_FOR_ERROR_RESPONSE").build();
113+
AnthropicApi api = AnthropicApi.builder().apiKey("FAKE_KEY_FOR_ERROR_RESPONSE").build();
114114

115115
Flux<ChatCompletionResponse> response = api.chatCompletionStream(new ChatCompletionRequest(
116116
AnthropicApi.ChatModel.CLAUDE_3_OPUS.getValue(), List.of(chatCompletionMessage), null, 100, 0.8, true));

models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxApi.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ public MiniMaxApi(String baseUrl, String miniMaxToken, RestClient.Builder restCl
115115
.defaultStatusHandler(responseErrorHandler)
116116
.build();
117117

118-
this.webClient = WebClient.builder()
118+
this.webClient = WebClient.builder() // FIXME: use a bean instead
119119
.baseUrl(baseUrl)
120120
.defaultHeaders(authHeaders)
121121
.build();

models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiApi.java

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,18 +101,31 @@ public MistralAiApi(String baseUrl, String mistralAiApiKey) {
101101
*/
102102
public MistralAiApi(String baseUrl, String mistralAiApiKey, RestClient.Builder restClientBuilder,
103103
ResponseErrorHandler responseErrorHandler) {
104+
this(baseUrl, mistralAiApiKey, restClientBuilder, WebClient.builder(), responseErrorHandler);
105+
}
106+
107+
/**
108+
* Create a new client api.
109+
* @param baseUrl api base URL.
110+
* @param mistralAiApiKey Mistral api Key.
111+
* @param restClientBuilder RestClient builder.
112+
* @param responseErrorHandler Response error handler.
113+
*/
114+
public MistralAiApi(String baseUrl, String mistralAiApiKey, RestClient.Builder restClientBuilder,
115+
WebClient.Builder webClientBuilder, ResponseErrorHandler responseErrorHandler) {
104116

105117
Consumer<HttpHeaders> jsonContentHeaders = headers -> {
106118
headers.setBearerAuth(mistralAiApiKey);
107119
headers.setContentType(MediaType.APPLICATION_JSON);
108120
};
109121

110-
this.restClient = restClientBuilder.baseUrl(baseUrl)
122+
this.restClient = restClientBuilder.clone()
123+
.baseUrl(baseUrl)
111124
.defaultHeaders(jsonContentHeaders)
112125
.defaultStatusHandler(responseErrorHandler)
113126
.build();
114127

115-
this.webClient = WebClient.builder().baseUrl(baseUrl).defaultHeaders(jsonContentHeaders).build();
128+
this.webClient = webClientBuilder.clone().baseUrl(baseUrl).defaultHeaders(jsonContentHeaders).build();
116129
}
117130

118131
/**

models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/api/OllamaApi.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,15 @@ private OllamaApi(String baseUrl, RestClient.Builder restClientBuilder, WebClien
7979
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
8080
};
8181

82-
this.restClient = restClientBuilder.baseUrl(baseUrl)
82+
this.restClient = restClientBuilder
83+
.clone()
84+
.baseUrl(baseUrl)
8385
.defaultHeaders(defaultHeaders)
8486
.defaultStatusHandler(responseErrorHandler)
8587
.build();
8688

8789
this.webClient = webClientBuilder
90+
.clone()
8891
.baseUrl(baseUrl)
8992
.defaultHeaders(defaultHeaders)
9093
.build();

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,12 +115,13 @@ public OpenAiApi(String baseUrl, ApiKey apiKey, MultiValueMap<String, String> he
115115
h.setContentType(MediaType.APPLICATION_JSON);
116116
h.addAll(headers);
117117
};
118-
this.restClient = restClientBuilder.baseUrl(baseUrl)
118+
this.restClient = restClientBuilder.clone()
119+
.baseUrl(baseUrl)
119120
.defaultHeaders(finalHeaders)
120121
.defaultStatusHandler(responseErrorHandler)
121122
.build();
122123

123-
this.webClient = webClientBuilder
124+
this.webClient = webClientBuilder.clone()
124125
.baseUrl(baseUrl)
125126
.defaultHeaders(finalHeaders)
126127
.build(); // @formatter:on

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,13 @@ public OpenAiAudioApi(String baseUrl, ApiKey apiKey, MultiValueMap<String, Strin
7777
// h.setContentType(MediaType.APPLICATION_JSON);
7878
};
7979

80-
this.restClient = restClientBuilder.baseUrl(baseUrl)
80+
this.restClient = restClientBuilder.clone()
81+
.baseUrl(baseUrl)
8182
.defaultHeaders(authHeaders)
8283
.defaultStatusHandler(responseErrorHandler)
8384
.build();
8485

85-
this.webClient = webClientBuilder.baseUrl(baseUrl).defaultHeaders(authHeaders).build();
86+
this.webClient = webClientBuilder.clone().baseUrl(baseUrl).defaultHeaders(authHeaders).build();
8687
}
8788

8889
public static Builder builder() {

models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/api/ZhiPuAiApi.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ public ZhiPuAiApi(String baseUrl, String zhiPuAiToken, RestClient.Builder restCl
116116
.defaultStatusHandler(responseErrorHandler)
117117
.build();
118118

119-
this.webClient = WebClient.builder()
119+
this.webClient = WebClient.builder() // FIXME: use a builder instead
120120
.baseUrl(baseUrl)
121121
.defaultHeaders(authHeaders)
122122
.build();

0 commit comments

Comments
 (0)