From 97f90b19d62076b0c1249c89c7dc3a2869cc367a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= Date: Wed, 7 May 2025 11:57:49 +0200 Subject: [PATCH] Fix mutating global RestClient and WebClient builders (#3020) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: Christian Tzolov Co-authored-by: Christian Tzolov --- .../AnthropicChatAutoConfiguration.java | 2 +- .../MistralAiChatAutoConfiguration.java | 22 ++++++++++++------- .../ai/anthropic/api/AnthropicApi.java | 6 +++-- .../ai/anthropic/api/AnthropicApiIT.java | 2 +- .../ai/minimax/api/MiniMaxApi.java | 2 +- .../ai/mistralai/api/MistralAiApi.java | 17 ++++++++++++-- .../ai/ollama/api/OllamaApi.java | 5 ++++- .../ai/openai/api/OpenAiApi.java | 5 +++-- .../ai/openai/api/OpenAiAudioApi.java | 5 +++-- .../ai/zhipuai/api/ZhiPuAiApi.java | 2 +- .../ai/chroma/vectorstore/ChromaApi.java | 2 -- 11 files changed, 47 insertions(+), 23 deletions(-) diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/main/java/org/springframework/ai/model/anthropic/autoconfigure/AnthropicChatAutoConfiguration.java b/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/main/java/org/springframework/ai/model/anthropic/autoconfigure/AnthropicChatAutoConfiguration.java index 760f87afc..93e5c2640 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/main/java/org/springframework/ai/model/anthropic/autoconfigure/AnthropicChatAutoConfiguration.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/main/java/org/springframework/ai/model/anthropic/autoconfigure/AnthropicChatAutoConfiguration.java @@ -52,7 +52,7 @@ import org.springframework.web.reactive.function.client.WebClient; * @since 1.0.0 */ @AutoConfiguration(after = { RestClientAutoConfiguration.class, SpringAiRetryAutoConfiguration.class, - ToolCallingAutoConfiguration.class }) + ToolCallingAutoConfiguration.class, WebClientAutoConfiguration.class }) @EnableConfigurationProperties({ AnthropicChatProperties.class, AnthropicConnectionProperties.class }) @ConditionalOnClass(AnthropicApi.class) @ConditionalOnProperty(name = SpringAIModelProperties.CHAT_MODEL, havingValue = SpringAIModels.ANTHROPIC, diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-mistral-ai/src/main/java/org/springframework/ai/model/mistralai/autoconfigure/MistralAiChatAutoConfiguration.java b/auto-configurations/models/spring-ai-autoconfigure-model-mistral-ai/src/main/java/org/springframework/ai/model/mistralai/autoconfigure/MistralAiChatAutoConfiguration.java index 3be4210a8..bd40285ea 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-mistral-ai/src/main/java/org/springframework/ai/model/mistralai/autoconfigure/MistralAiChatAutoConfiguration.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-mistral-ai/src/main/java/org/springframework/ai/model/mistralai/autoconfigure/MistralAiChatAutoConfiguration.java @@ -35,6 +35,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.retry.support.RetryTemplate; @@ -42,6 +43,7 @@ import org.springframework.util.Assert; import org.springframework.util.StringUtils; import org.springframework.web.client.ResponseErrorHandler; import org.springframework.web.client.RestClient; +import org.springframework.web.reactive.function.client.WebClient; /** * Chat {@link AutoConfiguration Auto-configuration} for Mistral AI. @@ -52,28 +54,30 @@ import org.springframework.web.client.RestClient; * @author Ilayaperumal Gopinathan * @since 0.8.1 */ -@AutoConfiguration(after = { RestClientAutoConfiguration.class, SpringAiRetryAutoConfiguration.class, - ToolCallingAutoConfiguration.class }) +@AutoConfiguration(after = { RestClientAutoConfiguration.class, WebClientAutoConfiguration.class, + SpringAiRetryAutoConfiguration.class, ToolCallingAutoConfiguration.class }) @EnableConfigurationProperties({ MistralAiCommonProperties.class, MistralAiChatProperties.class }) @ConditionalOnProperty(name = SpringAIModelProperties.CHAT_MODEL, havingValue = SpringAIModels.MISTRAL, matchIfMissing = true) @ConditionalOnClass(MistralAiApi.class) @ImportAutoConfiguration(classes = { SpringAiRetryAutoConfiguration.class, RestClientAutoConfiguration.class, - ToolCallingAutoConfiguration.class }) + WebClientAutoConfiguration.class, ToolCallingAutoConfiguration.class }) public class MistralAiChatAutoConfiguration { @Bean @ConditionalOnMissingBean public MistralAiChatModel mistralAiChatModel(MistralAiCommonProperties commonProperties, MistralAiChatProperties chatProperties, ObjectProvider restClientBuilderProvider, - ToolCallingManager toolCallingManager, RetryTemplate retryTemplate, - ResponseErrorHandler responseErrorHandler, ObjectProvider observationRegistry, + ObjectProvider webClientBuilderProvider, ToolCallingManager toolCallingManager, + RetryTemplate retryTemplate, ResponseErrorHandler responseErrorHandler, + ObjectProvider observationRegistry, ObjectProvider observationConvention, ObjectProvider mistralAiToolExecutionEligibilityPredicate) { var mistralAiApi = mistralAiApi(chatProperties.getApiKey(), commonProperties.getApiKey(), chatProperties.getBaseUrl(), commonProperties.getBaseUrl(), - restClientBuilderProvider.getIfAvailable(RestClient::builder), responseErrorHandler); + restClientBuilderProvider.getIfAvailable(RestClient::builder), + webClientBuilderProvider.getIfAvailable(WebClient::builder), responseErrorHandler); var chatModel = MistralAiChatModel.builder() .mistralAiApi(mistralAiApi) @@ -91,7 +95,8 @@ public class MistralAiChatAutoConfiguration { } private MistralAiApi mistralAiApi(String apiKey, String commonApiKey, String baseUrl, String commonBaseUrl, - RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) { + RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder, + ResponseErrorHandler responseErrorHandler) { var resolvedApiKey = StringUtils.hasText(apiKey) ? apiKey : commonApiKey; var resoledBaseUrl = StringUtils.hasText(baseUrl) ? baseUrl : commonBaseUrl; @@ -99,7 +104,8 @@ public class MistralAiChatAutoConfiguration { Assert.hasText(resolvedApiKey, "Mistral API key must be set"); Assert.hasText(resoledBaseUrl, "Mistral base URL must be set"); - return new MistralAiApi(resoledBaseUrl, resolvedApiKey, restClientBuilder, responseErrorHandler); + return new MistralAiApi(resoledBaseUrl, resolvedApiKey, restClientBuilder, webClientBuilder, + responseErrorHandler); } } diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java index a5aa780a0..d7c1aacd2 100644 --- a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java @@ -119,12 +119,14 @@ public class AnthropicApi { this.completionsPath = completionsPath; - this.restClient = restClientBuilder.baseUrl(baseUrl) + this.restClient = restClientBuilder.clone() + .baseUrl(baseUrl) .defaultHeaders(jsonContentHeaders) .defaultStatusHandler(responseErrorHandler) .build(); - this.webClient = webClientBuilder.baseUrl(baseUrl) + this.webClient = webClientBuilder.clone() + .baseUrl(baseUrl) .defaultHeaders(jsonContentHeaders) .defaultStatusHandler(HttpStatusCode::isError, resp -> resp.bodyToMono(String.class) diff --git a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/AnthropicApiIT.java b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/AnthropicApiIT.java index 67f0b61ed..fe80f72a3 100644 --- a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/AnthropicApiIT.java +++ b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/AnthropicApiIT.java @@ -110,7 +110,7 @@ public class AnthropicApiIT { void chatCompletionStreamError() { AnthropicMessage chatCompletionMessage = new AnthropicMessage(List.of(new ContentBlock("Tell me a Joke?")), Role.USER); - AnthropicApi api = AnthropicApi.builder().baseUrl("FAKE_KEY_FOR_ERROR_RESPONSE").build(); + AnthropicApi api = AnthropicApi.builder().apiKey("FAKE_KEY_FOR_ERROR_RESPONSE").build(); Flux response = api.chatCompletionStream(new ChatCompletionRequest( AnthropicApi.ChatModel.CLAUDE_3_OPUS.getValue(), List.of(chatCompletionMessage), null, 100, 0.8, true)); diff --git a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxApi.java b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxApi.java index 0d6dabaa8..eeb6c859a 100644 --- a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxApi.java +++ b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxApi.java @@ -115,7 +115,7 @@ public class MiniMaxApi { .defaultStatusHandler(responseErrorHandler) .build(); - this.webClient = WebClient.builder() + this.webClient = WebClient.builder() // FIXME: use a bean instead .baseUrl(baseUrl) .defaultHeaders(authHeaders) .build(); diff --git a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiApi.java b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiApi.java index 7b32a5911..172961c8e 100644 --- a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiApi.java +++ b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiApi.java @@ -101,18 +101,31 @@ public class MistralAiApi { */ public MistralAiApi(String baseUrl, String mistralAiApiKey, RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) { + this(baseUrl, mistralAiApiKey, restClientBuilder, WebClient.builder(), responseErrorHandler); + } + + /** + * Create a new client api. + * @param baseUrl api base URL. + * @param mistralAiApiKey Mistral api Key. + * @param restClientBuilder RestClient builder. + * @param responseErrorHandler Response error handler. + */ + public MistralAiApi(String baseUrl, String mistralAiApiKey, RestClient.Builder restClientBuilder, + WebClient.Builder webClientBuilder, ResponseErrorHandler responseErrorHandler) { Consumer jsonContentHeaders = headers -> { headers.setBearerAuth(mistralAiApiKey); headers.setContentType(MediaType.APPLICATION_JSON); }; - this.restClient = restClientBuilder.baseUrl(baseUrl) + this.restClient = restClientBuilder.clone() + .baseUrl(baseUrl) .defaultHeaders(jsonContentHeaders) .defaultStatusHandler(responseErrorHandler) .build(); - this.webClient = WebClient.builder().baseUrl(baseUrl).defaultHeaders(jsonContentHeaders).build(); + this.webClient = webClientBuilder.clone().baseUrl(baseUrl).defaultHeaders(jsonContentHeaders).build(); } /** diff --git a/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/api/OllamaApi.java b/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/api/OllamaApi.java index 02f1e8816..0a440f9de 100644 --- a/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/api/OllamaApi.java +++ b/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/api/OllamaApi.java @@ -79,12 +79,15 @@ public class OllamaApi { headers.setAccept(List.of(MediaType.APPLICATION_JSON)); }; - this.restClient = restClientBuilder.baseUrl(baseUrl) + this.restClient = restClientBuilder + .clone() + .baseUrl(baseUrl) .defaultHeaders(defaultHeaders) .defaultStatusHandler(responseErrorHandler) .build(); this.webClient = webClientBuilder + .clone() .baseUrl(baseUrl) .defaultHeaders(defaultHeaders) .build(); diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiApi.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiApi.java index 3cc5d5b48..a0da1221d 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiApi.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiApi.java @@ -115,12 +115,13 @@ public class OpenAiApi { h.setContentType(MediaType.APPLICATION_JSON); h.addAll(headers); }; - this.restClient = restClientBuilder.baseUrl(baseUrl) + this.restClient = restClientBuilder.clone() + .baseUrl(baseUrl) .defaultHeaders(finalHeaders) .defaultStatusHandler(responseErrorHandler) .build(); - this.webClient = webClientBuilder + this.webClient = webClientBuilder.clone() .baseUrl(baseUrl) .defaultHeaders(finalHeaders) .build(); // @formatter:on diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiAudioApi.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiAudioApi.java index 8974081b2..21775d5e7 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiAudioApi.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiAudioApi.java @@ -77,12 +77,13 @@ public class OpenAiAudioApi { // h.setContentType(MediaType.APPLICATION_JSON); }; - this.restClient = restClientBuilder.baseUrl(baseUrl) + this.restClient = restClientBuilder.clone() + .baseUrl(baseUrl) .defaultHeaders(authHeaders) .defaultStatusHandler(responseErrorHandler) .build(); - this.webClient = webClientBuilder.baseUrl(baseUrl).defaultHeaders(authHeaders).build(); + this.webClient = webClientBuilder.clone().baseUrl(baseUrl).defaultHeaders(authHeaders).build(); } public static Builder builder() { diff --git a/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/api/ZhiPuAiApi.java b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/api/ZhiPuAiApi.java index eee867661..98f215dbf 100644 --- a/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/api/ZhiPuAiApi.java +++ b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/api/ZhiPuAiApi.java @@ -116,7 +116,7 @@ public class ZhiPuAiApi { .defaultStatusHandler(responseErrorHandler) .build(); - this.webClient = WebClient.builder() + this.webClient = WebClient.builder() // FIXME: use a builder instead .baseUrl(baseUrl) .defaultHeaders(authHeaders) .build(); diff --git a/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/chroma/vectorstore/ChromaApi.java b/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/chroma/vectorstore/ChromaApi.java index 359a6da0b..72495566d 100644 --- a/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/chroma/vectorstore/ChromaApi.java +++ b/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/chroma/vectorstore/ChromaApi.java @@ -113,7 +113,6 @@ public class ChromaApi { return result; } - @Nullable public void createTenant(String tenantName) { this.restClient.post() @@ -144,7 +143,6 @@ public class ChromaApi { } } - @Nullable public void createDatabase(String tenantName, String databaseName) { this.restClient.post()