From 265ab8a99211712ace7d4dff0e91b27beaf4e328 Mon Sep 17 00:00:00 2001 From: yinh Date: Fri, 21 Nov 2025 22:52:10 +0800 Subject: [PATCH 1/3] feat: DeepSeek: support HTTP client timeout configuration Signed-off-by: yinh --- .../DeepSeekChatAutoConfiguration.java | 11 ++ .../DeepSeekConnectionProperties.java | 110 ++++++++++++ .../DeepSeekRestClientCustomizer.java | 57 +++++++ .../DeepSeekAutoConfigurationIT.java | 23 +++ .../DeepSeekPropertiesTests.java | 156 ++++++++++++++++++ 5 files changed, 357 insertions(+) create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/main/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekRestClientCustomizer.java diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/main/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekChatAutoConfiguration.java b/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/main/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekChatAutoConfiguration.java index eb84d57ee65..fd28626118c 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/main/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekChatAutoConfiguration.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/main/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekChatAutoConfiguration.java @@ -35,7 +35,9 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.restclient.RestClientCustomizer; import org.springframework.boot.restclient.autoconfigure.RestClientAutoConfiguration; +import org.springframework.boot.ssl.SslBundles; import org.springframework.boot.webclient.autoconfigure.WebClientAutoConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.core.retry.RetryTemplate; @@ -59,6 +61,15 @@ matchIfMissing = true) public class DeepSeekChatAutoConfiguration { + @Bean + @ConditionalOnMissingBean(name = "deepSeekRestClientCustomizer") + @ConditionalOnProperty(prefix = "spring.ai.deepseek.http-client", name = "enabled", havingValue = "true", + matchIfMissing = true) + public RestClientCustomizer deepSeekRestClientCustomizer(DeepSeekConnectionProperties connectionProperties, + ObjectProvider sslBundles) { + return new DeepSeekRestClientCustomizer(connectionProperties.getHttpClient(), sslBundles.getIfAvailable()); + } + @Bean @ConditionalOnMissingBean public DeepSeekChatModel deepSeekChatModel(DeepSeekConnectionProperties commonProperties, diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/main/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekConnectionProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/main/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekConnectionProperties.java index 25deeedf7a9..dd4cca5e6c3 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/main/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekConnectionProperties.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/main/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekConnectionProperties.java @@ -16,7 +16,14 @@ package org.springframework.ai.model.deepseek.autoconfigure; +import java.time.Duration; + import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; +import org.springframework.boot.http.client.HttpClientSettings; +import org.springframework.boot.http.client.HttpRedirects; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; /** * Parent properties for DeepSeek. @@ -30,8 +37,111 @@ public class DeepSeekConnectionProperties extends DeepSeekParentProperties { public static final String DEFAULT_BASE_URL = "https://api.deepseek.com"; + /** + * HTTP client settings for DeepSeek API calls. + */ + @NestedConfigurationProperty + private HttpClientConfig httpClient = new HttpClientConfig(); + public DeepSeekConnectionProperties() { super.setBaseUrl(DEFAULT_BASE_URL); } + public HttpClientConfig getHttpClient() { + return this.httpClient; + } + + public void setHttpClient(HttpClientConfig httpClient) { + this.httpClient = httpClient; + } + + /** + * HTTP client configuration settings. This inner class mirrors the structure of + * Spring Boot's HttpClientSettings to provide full control over HTTP client behavior. + */ + public static class HttpClientConfig { + + /** + * Whether to enable custom HTTP client configuration. + */ + private boolean enabled = true; + + /** + * Connection timeout. + */ + private Duration connectTimeout = Duration.ofSeconds(10); + + /** + * Read timeout. + */ + private Duration readTimeout = Duration.ofSeconds(60); + + /** + * HTTP redirect strategy. + */ + private HttpRedirects redirects; + + /** + * SSL bundle name for secure connections. + */ + private String sslBundle; + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public Duration getConnectTimeout() { + return this.connectTimeout; + } + + public void setConnectTimeout(Duration connectTimeout) { + this.connectTimeout = connectTimeout; + } + + public Duration getReadTimeout() { + return this.readTimeout; + } + + public void setReadTimeout(Duration readTimeout) { + this.readTimeout = readTimeout; + } + + public HttpRedirects getRedirects() { + return this.redirects; + } + + public void setRedirects(HttpRedirects redirects) { + this.redirects = redirects; + } + + public String getSslBundle() { + return this.sslBundle; + } + + public void setSslBundle(String sslBundle) { + this.sslBundle = sslBundle; + } + + /** + * Convert to Spring Boot's HttpClientSettings. + * @param sslBundles the SSL bundles registry + * @return HttpClientSettings instance + */ + public HttpClientSettings toHttpClientSettings(SslBundles sslBundles) { + SslBundle bundle = (this.sslBundle != null && sslBundles != null) ? sslBundles.getBundle(this.sslBundle) + : null; + + return HttpClientSettings.defaults() + .withConnectTimeout(this.connectTimeout) + .withReadTimeout(this.readTimeout) + .withRedirects(this.redirects) + .withSslBundle(bundle); + } + + } + } diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/main/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekRestClientCustomizer.java b/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/main/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekRestClientCustomizer.java new file mode 100644 index 00000000000..73759d59643 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/main/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekRestClientCustomizer.java @@ -0,0 +1,57 @@ +/* + * Copyright 2023-2024 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.model.deepseek.autoconfigure; + +import jakarta.annotation.Nullable; + +import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; +import org.springframework.boot.http.client.HttpClientSettings; +import org.springframework.boot.restclient.RestClientCustomizer; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +/** + * This customizer applies HTTP client settings (timeout, SSL, redirects) to + * RestClient.Builder in a non-invasive way, preserving any existing configuration that + * users may have already applied. + * + * @author yinh + */ +public record DeepSeekRestClientCustomizer(DeepSeekConnectionProperties.HttpClientConfig httpClientConfig, + SslBundles sslBundles) implements RestClientCustomizer { + + public DeepSeekRestClientCustomizer(DeepSeekConnectionProperties.HttpClientConfig httpClientConfig, + @Nullable SslBundles sslBundles) { + this.httpClientConfig = httpClientConfig; + this.sslBundles = sslBundles; + } + + @Override + public void customize(RestClient.Builder restClientBuilder) { + if (!this.httpClientConfig.isEnabled()) { + return; + } + + // 将配置转换为 HttpClientSettings + HttpClientSettings settings = this.httpClientConfig.toHttpClientSettings(this.sslBundles); + + ClientHttpRequestFactory requestFactory = ClientHttpRequestFactoryBuilder.detect().build(settings); + + restClientBuilder.requestFactory(requestFactory); + } +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/test/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekAutoConfigurationIT.java b/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/test/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekAutoConfigurationIT.java index 94db5f43bd3..b9717e732ab 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/test/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekAutoConfigurationIT.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/test/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekAutoConfigurationIT.java @@ -73,4 +73,27 @@ void generateStreaming() { }); } + @Test + void generateWithCustomTimeout() { + new ApplicationContextRunner() + .withPropertyValues("spring.ai.deepseek.apiKey=" + System.getenv("DEEPSEEK_API_KEY"), + "spring.ai.deepseek.http-client.connect-timeout=5s", + "spring.ai.deepseek.http-client.read-timeout=30s") + .withConfiguration(SpringAiTestAutoConfigurations.of(DeepSeekChatAutoConfiguration.class)) + .run(context -> { + DeepSeekChatModel client = context.getBean(DeepSeekChatModel.class); + + // Verify that the HTTP client configuration is applied + var connectionProperties = context.getBean(DeepSeekConnectionProperties.class); + assertThat(connectionProperties.getHttpClient().getConnectTimeout().getSeconds()).isEqualTo(5); + assertThat(connectionProperties.getHttpClient().getReadTimeout().getSeconds()).isEqualTo(30); + + // Verify that the client can actually make requests with the configured + // timeout + String response = client.call("Hello"); + assertThat(response).isNotEmpty(); + logger.info("Response with custom timeout: " + response); + }); + } + } diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/test/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekPropertiesTests.java b/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/test/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekPropertiesTests.java index e126ba67aba..ea07e0a2afd 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/test/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekPropertiesTests.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/test/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekPropertiesTests.java @@ -16,10 +16,14 @@ package org.springframework.ai.model.deepseek.autoconfigure; +import java.time.Duration; + import org.junit.jupiter.api.Test; import org.springframework.ai.deepseek.DeepSeekChatModel; import org.springframework.ai.utils.SpringAiTestAutoConfigurations; +import org.springframework.boot.http.client.HttpRedirects; +import org.springframework.boot.restclient.RestClientCustomizer; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import static org.assertj.core.api.Assertions.assertThat; @@ -153,4 +157,156 @@ void chatActivation() { }); } + @Test + public void httpClientDefaultProperties() { + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.deepseek.api-key=API_KEY", + "spring.ai.deepseek.base-url=TEST_BASE_URL") + // @formatter:on + .withConfiguration(SpringAiTestAutoConfigurations.of(DeepSeekChatAutoConfiguration.class)) + .run(context -> { + var connectionProperties = context.getBean(DeepSeekConnectionProperties.class); + var httpClientConfig = connectionProperties.getHttpClient(); + + assertThat(httpClientConfig.isEnabled()).isTrue(); + assertThat(httpClientConfig.getConnectTimeout()).isEqualTo(Duration.ofSeconds(10)); + assertThat(httpClientConfig.getReadTimeout()).isEqualTo(Duration.ofSeconds(60)); + assertThat(httpClientConfig.getRedirects()).isNull(); + assertThat(httpClientConfig.getSslBundle()).isNull(); + }); + } + + @Test + public void httpClientCustomTimeouts() { + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.deepseek.api-key=API_KEY", + "spring.ai.deepseek.base-url=TEST_BASE_URL", + "spring.ai.deepseek.http-client.connect-timeout=5s", + "spring.ai.deepseek.http-client.read-timeout=30s") + // @formatter:on + .withConfiguration(SpringAiTestAutoConfigurations.of(DeepSeekChatAutoConfiguration.class)) + .run(context -> { + var connectionProperties = context.getBean(DeepSeekConnectionProperties.class); + var httpClientConfig = connectionProperties.getHttpClient(); + + assertThat(httpClientConfig.getConnectTimeout()).isEqualTo(Duration.ofSeconds(5)); + assertThat(httpClientConfig.getReadTimeout()).isEqualTo(Duration.ofSeconds(30)); + }); + } + + @Test + public void httpClientRedirects() { + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.deepseek.api-key=API_KEY", + "spring.ai.deepseek.base-url=TEST_BASE_URL", + "spring.ai.deepseek.http-client.redirects=DONT_FOLLOW") + // @formatter:on + .withConfiguration(SpringAiTestAutoConfigurations.of(DeepSeekChatAutoConfiguration.class)) + .run(context -> { + var connectionProperties = context.getBean(DeepSeekConnectionProperties.class); + assertThat(connectionProperties.getHttpClient().getRedirects()).isEqualTo(HttpRedirects.DONT_FOLLOW); + }); + } + + @Test + public void httpClientSslBundle() { + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.deepseek.api-key=API_KEY", + "spring.ai.deepseek.base-url=TEST_BASE_URL", + "spring.ai.deepseek.http-client.ssl-bundle=deepseek-ssl") + // @formatter:on + .withConfiguration(SpringAiTestAutoConfigurations.of(DeepSeekChatAutoConfiguration.class)) + .run(context -> { + var connectionProperties = context.getBean(DeepSeekConnectionProperties.class); + assertThat(connectionProperties.getHttpClient().getSslBundle()).isEqualTo("deepseek-ssl"); + }); + } + + @Test + public void httpClientAllProperties() { + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.deepseek.api-key=API_KEY", + "spring.ai.deepseek.base-url=TEST_BASE_URL", + "spring.ai.deepseek.http-client.enabled=true", + "spring.ai.deepseek.http-client.connect-timeout=15s", + "spring.ai.deepseek.http-client.read-timeout=45s", + "spring.ai.deepseek.http-client.redirects=FOLLOW", + "spring.ai.deepseek.http-client.ssl-bundle=my-bundle") + // @formatter:on + .withConfiguration(SpringAiTestAutoConfigurations.of(DeepSeekChatAutoConfiguration.class)) + .run(context -> { + var connectionProperties = context.getBean(DeepSeekConnectionProperties.class); + var httpClientConfig = connectionProperties.getHttpClient(); + + assertThat(httpClientConfig.isEnabled()).isTrue(); + assertThat(httpClientConfig.getConnectTimeout()).isEqualTo(Duration.ofSeconds(15)); + assertThat(httpClientConfig.getReadTimeout()).isEqualTo(Duration.ofSeconds(45)); + assertThat(httpClientConfig.getRedirects()).isEqualTo(HttpRedirects.FOLLOW); + assertThat(httpClientConfig.getSslBundle()).isEqualTo("my-bundle"); + }); + } + + @Test + public void httpClientDisabled() { + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.deepseek.api-key=API_KEY", + "spring.ai.deepseek.base-url=TEST_BASE_URL", + "spring.ai.deepseek.http-client.enabled=false") + // @formatter:on + .withConfiguration(SpringAiTestAutoConfigurations.of(DeepSeekChatAutoConfiguration.class)) + .run(context -> { + var connectionProperties = context.getBean(DeepSeekConnectionProperties.class); + assertThat(connectionProperties.getHttpClient().isEnabled()).isFalse(); + + // RestClientCustomizer should not be created when disabled + assertThat(context.containsBean("deepSeekRestClientCustomizer")).isFalse(); + }); + } + + @Test + public void restClientCustomizerCreated() { + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.deepseek.api-key=API_KEY", + "spring.ai.deepseek.base-url=TEST_BASE_URL") + // @formatter:on + .withConfiguration(SpringAiTestAutoConfigurations.of(DeepSeekChatAutoConfiguration.class)) + .run(context -> { + assertThat(context).hasSingleBean(RestClientCustomizer.class); + assertThat(context).hasBean("deepSeekRestClientCustomizer"); + }); + } + + @Test + public void httpClientWithChatOptions() { + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.deepseek.api-key=API_KEY", + "spring.ai.deepseek.base-url=TEST_BASE_URL", + "spring.ai.deepseek.http-client.connect-timeout=8s", + "spring.ai.deepseek.http-client.read-timeout=40s", + "spring.ai.deepseek.chat.options.model=deepseek-chat", + "spring.ai.deepseek.chat.options.temperature=0.7") + // @formatter:on + .withConfiguration(SpringAiTestAutoConfigurations.of(DeepSeekChatAutoConfiguration.class)) + .run(context -> { + var connectionProperties = context.getBean(DeepSeekConnectionProperties.class); + var chatProperties = context.getBean(DeepSeekChatProperties.class); + + // HTTP client configuration + assertThat(connectionProperties.getHttpClient().getConnectTimeout()).isEqualTo(Duration.ofSeconds(8)); + assertThat(connectionProperties.getHttpClient().getReadTimeout()).isEqualTo(Duration.ofSeconds(40)); + + // Chat options + assertThat(chatProperties.getOptions().getModel()).isEqualTo("deepseek-chat"); + assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.7); + }); + } + } From 7b889293cc1804c3bdd0261bf1977011503917f9 Mon Sep 17 00:00:00 2001 From: yinh Date: Wed, 26 Nov 2025 16:49:25 +0800 Subject: [PATCH 2/3] perf(deepseek): Adjust the configuration timeout method Signed-off-by: yinh --- .../DeepSeekChatAutoConfiguration.java | 48 +++++-- .../DeepSeekConnectionProperties.java | 119 ++-------------- .../DeepSeekRestClientCustomizer.java | 57 -------- .../DeepSeekAutoConfigurationIT.java | 35 ++++- .../DeepSeekPropertiesTests.java | 132 +----------------- 5 files changed, 84 insertions(+), 307 deletions(-) delete mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/main/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekRestClientCustomizer.java diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/main/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekChatAutoConfiguration.java b/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/main/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekChatAutoConfiguration.java index fd28626118c..d4d856f20ff 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/main/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekChatAutoConfiguration.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/main/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekChatAutoConfiguration.java @@ -35,12 +35,17 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.boot.restclient.RestClientCustomizer; +import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; +import org.springframework.boot.http.client.HttpClientSettings; +import org.springframework.boot.http.client.autoconfigure.HttpClientSettingsPropertyMapper; +import org.springframework.boot.http.client.reactive.ClientHttpConnectorBuilder; import org.springframework.boot.restclient.autoconfigure.RestClientAutoConfiguration; import org.springframework.boot.ssl.SslBundles; import org.springframework.boot.webclient.autoconfigure.WebClientAutoConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.core.retry.RetryTemplate; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.reactive.ClientHttpConnector; import org.springframework.util.Assert; import org.springframework.util.StringUtils; import org.springframework.web.client.ResponseErrorHandler; @@ -61,15 +66,6 @@ matchIfMissing = true) public class DeepSeekChatAutoConfiguration { - @Bean - @ConditionalOnMissingBean(name = "deepSeekRestClientCustomizer") - @ConditionalOnProperty(prefix = "spring.ai.deepseek.http-client", name = "enabled", havingValue = "true", - matchIfMissing = true) - public RestClientCustomizer deepSeekRestClientCustomizer(DeepSeekConnectionProperties connectionProperties, - ObjectProvider sslBundles) { - return new DeepSeekRestClientCustomizer(connectionProperties.getHttpClient(), sslBundles.getIfAvailable()); - } - @Bean @ConditionalOnMissingBean public DeepSeekChatModel deepSeekChatModel(DeepSeekConnectionProperties commonProperties, @@ -78,10 +74,24 @@ public DeepSeekChatModel deepSeekChatModel(DeepSeekConnectionProperties commonPr RetryTemplate retryTemplate, ResponseErrorHandler responseErrorHandler, ObjectProvider observationRegistry, ObjectProvider observationConvention, - ObjectProvider deepseekToolExecutionEligibilityPredicate) { + ObjectProvider deepseekToolExecutionEligibilityPredicate, + ObjectProvider sslBundles, ObjectProvider globalHttpClientSettings, + ObjectProvider> factoryBuilder, + ObjectProvider> webConnectorBuilderProvider) { + + HttpClientSettingsPropertyMapper mapper = new HttpClientSettingsPropertyMapper(sslBundles.getIfAvailable(), + globalHttpClientSettings.getIfAvailable()); + HttpClientSettings httpClientSettings = mapper.map(commonProperties); + + RestClient.Builder restClientBuilder = restClientBuilderProvider.getIfAvailable(RestClient::builder); + applyRestClientSettings(restClientBuilder, httpClientSettings, + factoryBuilder.getIfAvailable(ClientHttpRequestFactoryBuilder::detect)); - var deepSeekApi = deepSeekApi(chatProperties, commonProperties, - restClientBuilderProvider.getIfAvailable(RestClient::builder), + WebClient.Builder webClientBuilder = webClientBuilderProvider.getIfAvailable(WebClient::builder); + applyWebClientSettings(webClientBuilder, httpClientSettings, + webConnectorBuilderProvider.getIfAvailable(ClientHttpConnectorBuilder::detect)); + + var deepSeekApi = deepSeekApi(chatProperties, commonProperties, restClientBuilder, webClientBuilderProvider.getIfAvailable(WebClient::builder), responseErrorHandler); var chatModel = DeepSeekChatModel.builder() @@ -122,4 +132,16 @@ private DeepSeekApi deepSeekApi(DeepSeekChatProperties chatProperties, .build(); } + private void applyRestClientSettings(RestClient.Builder builder, HttpClientSettings httpClientSettings, + ClientHttpRequestFactoryBuilder factoryBuilder) { + ClientHttpRequestFactory requestFactory = factoryBuilder.build(httpClientSettings); + builder.requestFactory(requestFactory); + } + + private void applyWebClientSettings(WebClient.Builder builder, HttpClientSettings httpClientSettings, + ClientHttpConnectorBuilder connectorBuilder) { + ClientHttpConnector connector = connectorBuilder.build(httpClientSettings); + builder.clientConnector(connector); + } + } diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/main/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekConnectionProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/main/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekConnectionProperties.java index dd4cca5e6c3..564f7c16f3e 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/main/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekConnectionProperties.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/main/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekConnectionProperties.java @@ -16,14 +16,8 @@ package org.springframework.ai.model.deepseek.autoconfigure; -import java.time.Duration; - import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.boot.context.properties.NestedConfigurationProperty; -import org.springframework.boot.http.client.HttpClientSettings; -import org.springframework.boot.http.client.HttpRedirects; -import org.springframework.boot.ssl.SslBundle; -import org.springframework.boot.ssl.SslBundles; +import org.springframework.boot.http.client.autoconfigure.HttpClientSettingsProperties; /** * Parent properties for DeepSeek. @@ -31,117 +25,28 @@ * @author Geng Rong */ @ConfigurationProperties(DeepSeekConnectionProperties.CONFIG_PREFIX) -public class DeepSeekConnectionProperties extends DeepSeekParentProperties { +public class DeepSeekConnectionProperties extends HttpClientSettingsProperties { public static final String CONFIG_PREFIX = "spring.ai.deepseek"; - public static final String DEFAULT_BASE_URL = "https://api.deepseek.com"; + private String apiKey; - /** - * HTTP client settings for DeepSeek API calls. - */ - @NestedConfigurationProperty - private HttpClientConfig httpClient = new HttpClientConfig(); + private String baseUrl = "https://api.deepseek.com"; - public DeepSeekConnectionProperties() { - super.setBaseUrl(DEFAULT_BASE_URL); + public String getApiKey() { + return this.apiKey; } - public HttpClientConfig getHttpClient() { - return this.httpClient; + public void setApiKey(String apiKey) { + this.apiKey = apiKey; } - public void setHttpClient(HttpClientConfig httpClient) { - this.httpClient = httpClient; + public String getBaseUrl() { + return this.baseUrl; } - /** - * HTTP client configuration settings. This inner class mirrors the structure of - * Spring Boot's HttpClientSettings to provide full control over HTTP client behavior. - */ - public static class HttpClientConfig { - - /** - * Whether to enable custom HTTP client configuration. - */ - private boolean enabled = true; - - /** - * Connection timeout. - */ - private Duration connectTimeout = Duration.ofSeconds(10); - - /** - * Read timeout. - */ - private Duration readTimeout = Duration.ofSeconds(60); - - /** - * HTTP redirect strategy. - */ - private HttpRedirects redirects; - - /** - * SSL bundle name for secure connections. - */ - private String sslBundle; - - public boolean isEnabled() { - return this.enabled; - } - - public void setEnabled(boolean enabled) { - this.enabled = enabled; - } - - public Duration getConnectTimeout() { - return this.connectTimeout; - } - - public void setConnectTimeout(Duration connectTimeout) { - this.connectTimeout = connectTimeout; - } - - public Duration getReadTimeout() { - return this.readTimeout; - } - - public void setReadTimeout(Duration readTimeout) { - this.readTimeout = readTimeout; - } - - public HttpRedirects getRedirects() { - return this.redirects; - } - - public void setRedirects(HttpRedirects redirects) { - this.redirects = redirects; - } - - public String getSslBundle() { - return this.sslBundle; - } - - public void setSslBundle(String sslBundle) { - this.sslBundle = sslBundle; - } - - /** - * Convert to Spring Boot's HttpClientSettings. - * @param sslBundles the SSL bundles registry - * @return HttpClientSettings instance - */ - public HttpClientSettings toHttpClientSettings(SslBundles sslBundles) { - SslBundle bundle = (this.sslBundle != null && sslBundles != null) ? sslBundles.getBundle(this.sslBundle) - : null; - - return HttpClientSettings.defaults() - .withConnectTimeout(this.connectTimeout) - .withReadTimeout(this.readTimeout) - .withRedirects(this.redirects) - .withSslBundle(bundle); - } - + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; } } diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/main/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekRestClientCustomizer.java b/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/main/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekRestClientCustomizer.java deleted file mode 100644 index 73759d59643..00000000000 --- a/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/main/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekRestClientCustomizer.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2023-2024 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.model.deepseek.autoconfigure; - -import jakarta.annotation.Nullable; - -import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; -import org.springframework.boot.http.client.HttpClientSettings; -import org.springframework.boot.restclient.RestClientCustomizer; -import org.springframework.boot.ssl.SslBundles; -import org.springframework.http.client.ClientHttpRequestFactory; -import org.springframework.web.client.RestClient; - -/** - * This customizer applies HTTP client settings (timeout, SSL, redirects) to - * RestClient.Builder in a non-invasive way, preserving any existing configuration that - * users may have already applied. - * - * @author yinh - */ -public record DeepSeekRestClientCustomizer(DeepSeekConnectionProperties.HttpClientConfig httpClientConfig, - SslBundles sslBundles) implements RestClientCustomizer { - - public DeepSeekRestClientCustomizer(DeepSeekConnectionProperties.HttpClientConfig httpClientConfig, - @Nullable SslBundles sslBundles) { - this.httpClientConfig = httpClientConfig; - this.sslBundles = sslBundles; - } - - @Override - public void customize(RestClient.Builder restClientBuilder) { - if (!this.httpClientConfig.isEnabled()) { - return; - } - - // 将配置转换为 HttpClientSettings - HttpClientSettings settings = this.httpClientConfig.toHttpClientSettings(this.sslBundles); - - ClientHttpRequestFactory requestFactory = ClientHttpRequestFactoryBuilder.detect().build(settings); - - restClientBuilder.requestFactory(requestFactory); - } -} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/test/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekAutoConfigurationIT.java b/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/test/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekAutoConfigurationIT.java index b9717e732ab..401413ec201 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/test/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekAutoConfigurationIT.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/test/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekAutoConfigurationIT.java @@ -16,6 +16,7 @@ package org.springframework.ai.model.deepseek.autoconfigure; +import java.time.Duration; import java.util.Objects; import java.util.stream.Collectors; @@ -77,16 +78,16 @@ void generateStreaming() { void generateWithCustomTimeout() { new ApplicationContextRunner() .withPropertyValues("spring.ai.deepseek.apiKey=" + System.getenv("DEEPSEEK_API_KEY"), - "spring.ai.deepseek.http-client.connect-timeout=5s", - "spring.ai.deepseek.http-client.read-timeout=30s") + "spring.ai.deepseek.connect-timeout=5s", + "spring.ai.deepseek.read-timeout=30s") .withConfiguration(SpringAiTestAutoConfigurations.of(DeepSeekChatAutoConfiguration.class)) .run(context -> { DeepSeekChatModel client = context.getBean(DeepSeekChatModel.class); // Verify that the HTTP client configuration is applied var connectionProperties = context.getBean(DeepSeekConnectionProperties.class); - assertThat(connectionProperties.getHttpClient().getConnectTimeout().getSeconds()).isEqualTo(5); - assertThat(connectionProperties.getHttpClient().getReadTimeout().getSeconds()).isEqualTo(30); + assertThat(connectionProperties.getConnectTimeout()).isEqualTo(Duration.ofSeconds(5)); + assertThat(connectionProperties.getReadTimeout()).isEqualTo(Duration.ofSeconds(30)); // Verify that the client can actually make requests with the configured // timeout @@ -96,4 +97,30 @@ void generateWithCustomTimeout() { }); } + @Test + void generateStreamingWithCustomTimeout() { + new ApplicationContextRunner() + .withPropertyValues("spring.ai.deepseek.apiKey=" + "sk-2567813d742c40e79fa6f1f2ee2f830c", + "spring.ai.deepseek.connect-timeout=1s", + "spring.ai.deepseek.read-timeout=1s") + .withConfiguration(SpringAiTestAutoConfigurations.of(DeepSeekChatAutoConfiguration.class)) + .run(context -> { + DeepSeekChatModel client = context.getBean(DeepSeekChatModel.class); + + // Verify that the HTTP client configuration is applied + var connectionProperties = context.getBean(DeepSeekConnectionProperties.class); + assertThat(connectionProperties.getConnectTimeout()).isEqualTo(Duration.ofSeconds(1)); + assertThat(connectionProperties.getReadTimeout()).isEqualTo(Duration.ofSeconds(1)); + + Flux responseFlux = client.stream(new Prompt(new UserMessage("Hello"))); + String response = Objects.requireNonNull(responseFlux.collectList().block()) + .stream() + .map(chatResponse -> chatResponse.getResults().get(0).getOutput().getText()) + .collect(Collectors.joining()); + + assertThat(response).isNotEmpty(); + logger.info("Response with custom timeout: " + response); + }); + } + } diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/test/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekPropertiesTests.java b/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/test/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekPropertiesTests.java index ea07e0a2afd..1e9165813b6 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/test/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekPropertiesTests.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/test/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekPropertiesTests.java @@ -23,7 +23,6 @@ import org.springframework.ai.deepseek.DeepSeekChatModel; import org.springframework.ai.utils.SpringAiTestAutoConfigurations; import org.springframework.boot.http.client.HttpRedirects; -import org.springframework.boot.restclient.RestClientCustomizer; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import static org.assertj.core.api.Assertions.assertThat; @@ -157,42 +156,21 @@ void chatActivation() { }); } - @Test - public void httpClientDefaultProperties() { - new ApplicationContextRunner().withPropertyValues( - // @formatter:off - "spring.ai.deepseek.api-key=API_KEY", - "spring.ai.deepseek.base-url=TEST_BASE_URL") - // @formatter:on - .withConfiguration(SpringAiTestAutoConfigurations.of(DeepSeekChatAutoConfiguration.class)) - .run(context -> { - var connectionProperties = context.getBean(DeepSeekConnectionProperties.class); - var httpClientConfig = connectionProperties.getHttpClient(); - - assertThat(httpClientConfig.isEnabled()).isTrue(); - assertThat(httpClientConfig.getConnectTimeout()).isEqualTo(Duration.ofSeconds(10)); - assertThat(httpClientConfig.getReadTimeout()).isEqualTo(Duration.ofSeconds(60)); - assertThat(httpClientConfig.getRedirects()).isNull(); - assertThat(httpClientConfig.getSslBundle()).isNull(); - }); - } - @Test public void httpClientCustomTimeouts() { new ApplicationContextRunner().withPropertyValues( // @formatter:off "spring.ai.deepseek.api-key=API_KEY", "spring.ai.deepseek.base-url=TEST_BASE_URL", - "spring.ai.deepseek.http-client.connect-timeout=5s", - "spring.ai.deepseek.http-client.read-timeout=30s") + "spring.ai.deepseek.connect-timeout=5s", + "spring.ai.deepseek.read-timeout=30s") // @formatter:on .withConfiguration(SpringAiTestAutoConfigurations.of(DeepSeekChatAutoConfiguration.class)) .run(context -> { var connectionProperties = context.getBean(DeepSeekConnectionProperties.class); - var httpClientConfig = connectionProperties.getHttpClient(); - assertThat(httpClientConfig.getConnectTimeout()).isEqualTo(Duration.ofSeconds(5)); - assertThat(httpClientConfig.getReadTimeout()).isEqualTo(Duration.ofSeconds(30)); + assertThat(connectionProperties.getConnectTimeout()).isEqualTo(Duration.ofSeconds(5)); + assertThat(connectionProperties.getReadTimeout()).isEqualTo(Duration.ofSeconds(30)); }); } @@ -202,110 +180,12 @@ public void httpClientRedirects() { // @formatter:off "spring.ai.deepseek.api-key=API_KEY", "spring.ai.deepseek.base-url=TEST_BASE_URL", - "spring.ai.deepseek.http-client.redirects=DONT_FOLLOW") - // @formatter:on - .withConfiguration(SpringAiTestAutoConfigurations.of(DeepSeekChatAutoConfiguration.class)) - .run(context -> { - var connectionProperties = context.getBean(DeepSeekConnectionProperties.class); - assertThat(connectionProperties.getHttpClient().getRedirects()).isEqualTo(HttpRedirects.DONT_FOLLOW); - }); - } - - @Test - public void httpClientSslBundle() { - new ApplicationContextRunner().withPropertyValues( - // @formatter:off - "spring.ai.deepseek.api-key=API_KEY", - "spring.ai.deepseek.base-url=TEST_BASE_URL", - "spring.ai.deepseek.http-client.ssl-bundle=deepseek-ssl") + "spring.ai.deepseek.redirects=DONT_FOLLOW") // @formatter:on .withConfiguration(SpringAiTestAutoConfigurations.of(DeepSeekChatAutoConfiguration.class)) .run(context -> { var connectionProperties = context.getBean(DeepSeekConnectionProperties.class); - assertThat(connectionProperties.getHttpClient().getSslBundle()).isEqualTo("deepseek-ssl"); - }); - } - - @Test - public void httpClientAllProperties() { - new ApplicationContextRunner().withPropertyValues( - // @formatter:off - "spring.ai.deepseek.api-key=API_KEY", - "spring.ai.deepseek.base-url=TEST_BASE_URL", - "spring.ai.deepseek.http-client.enabled=true", - "spring.ai.deepseek.http-client.connect-timeout=15s", - "spring.ai.deepseek.http-client.read-timeout=45s", - "spring.ai.deepseek.http-client.redirects=FOLLOW", - "spring.ai.deepseek.http-client.ssl-bundle=my-bundle") - // @formatter:on - .withConfiguration(SpringAiTestAutoConfigurations.of(DeepSeekChatAutoConfiguration.class)) - .run(context -> { - var connectionProperties = context.getBean(DeepSeekConnectionProperties.class); - var httpClientConfig = connectionProperties.getHttpClient(); - - assertThat(httpClientConfig.isEnabled()).isTrue(); - assertThat(httpClientConfig.getConnectTimeout()).isEqualTo(Duration.ofSeconds(15)); - assertThat(httpClientConfig.getReadTimeout()).isEqualTo(Duration.ofSeconds(45)); - assertThat(httpClientConfig.getRedirects()).isEqualTo(HttpRedirects.FOLLOW); - assertThat(httpClientConfig.getSslBundle()).isEqualTo("my-bundle"); - }); - } - - @Test - public void httpClientDisabled() { - new ApplicationContextRunner().withPropertyValues( - // @formatter:off - "spring.ai.deepseek.api-key=API_KEY", - "spring.ai.deepseek.base-url=TEST_BASE_URL", - "spring.ai.deepseek.http-client.enabled=false") - // @formatter:on - .withConfiguration(SpringAiTestAutoConfigurations.of(DeepSeekChatAutoConfiguration.class)) - .run(context -> { - var connectionProperties = context.getBean(DeepSeekConnectionProperties.class); - assertThat(connectionProperties.getHttpClient().isEnabled()).isFalse(); - - // RestClientCustomizer should not be created when disabled - assertThat(context.containsBean("deepSeekRestClientCustomizer")).isFalse(); - }); - } - - @Test - public void restClientCustomizerCreated() { - new ApplicationContextRunner().withPropertyValues( - // @formatter:off - "spring.ai.deepseek.api-key=API_KEY", - "spring.ai.deepseek.base-url=TEST_BASE_URL") - // @formatter:on - .withConfiguration(SpringAiTestAutoConfigurations.of(DeepSeekChatAutoConfiguration.class)) - .run(context -> { - assertThat(context).hasSingleBean(RestClientCustomizer.class); - assertThat(context).hasBean("deepSeekRestClientCustomizer"); - }); - } - - @Test - public void httpClientWithChatOptions() { - new ApplicationContextRunner().withPropertyValues( - // @formatter:off - "spring.ai.deepseek.api-key=API_KEY", - "spring.ai.deepseek.base-url=TEST_BASE_URL", - "spring.ai.deepseek.http-client.connect-timeout=8s", - "spring.ai.deepseek.http-client.read-timeout=40s", - "spring.ai.deepseek.chat.options.model=deepseek-chat", - "spring.ai.deepseek.chat.options.temperature=0.7") - // @formatter:on - .withConfiguration(SpringAiTestAutoConfigurations.of(DeepSeekChatAutoConfiguration.class)) - .run(context -> { - var connectionProperties = context.getBean(DeepSeekConnectionProperties.class); - var chatProperties = context.getBean(DeepSeekChatProperties.class); - - // HTTP client configuration - assertThat(connectionProperties.getHttpClient().getConnectTimeout()).isEqualTo(Duration.ofSeconds(8)); - assertThat(connectionProperties.getHttpClient().getReadTimeout()).isEqualTo(Duration.ofSeconds(40)); - - // Chat options - assertThat(chatProperties.getOptions().getModel()).isEqualTo("deepseek-chat"); - assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.7); + assertThat(connectionProperties.getRedirects()).isEqualTo(HttpRedirects.DONT_FOLLOW); }); } From 29eb951615c60a65b234d8c0b26c70b68f7c394b Mon Sep 17 00:00:00 2001 From: yinh Date: Wed, 26 Nov 2025 17:31:19 +0800 Subject: [PATCH 3/3] perf(anthropic): support HTTP client timeout configuration Signed-off-by: yinh --- .../AnthropicChatAutoConfiguration.java | 40 +++++++++++++-- .../AnthropicConnectionProperties.java | 3 +- .../AnthropicChatAutoConfigurationIT.java | 49 +++++++++++++++++++ .../DeepSeekAutoConfigurationIT.java | 44 ++++++++--------- 4 files changed, 109 insertions(+), 27 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 0ec1bf2636f..60071b6d89c 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 @@ -34,11 +34,18 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; +import org.springframework.boot.http.client.HttpClientSettings; +import org.springframework.boot.http.client.autoconfigure.HttpClientSettingsPropertyMapper; +import org.springframework.boot.http.client.reactive.ClientHttpConnectorBuilder; import org.springframework.boot.restclient.autoconfigure.RestClientAutoConfiguration; +import org.springframework.boot.ssl.SslBundles; import org.springframework.boot.webclient.autoconfigure.WebClientAutoConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import org.springframework.core.retry.RetryTemplate; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.reactive.ClientHttpConnector; import org.springframework.web.client.ResponseErrorHandler; import org.springframework.web.client.RestClient; import org.springframework.web.reactive.function.client.WebClient; @@ -65,15 +72,30 @@ public class AnthropicChatAutoConfiguration { @ConditionalOnMissingBean public AnthropicApi anthropicApi(AnthropicConnectionProperties connectionProperties, ObjectProvider restClientBuilderProvider, - ObjectProvider webClientBuilderProvider, ResponseErrorHandler responseErrorHandler) { + ObjectProvider webClientBuilderProvider, ResponseErrorHandler responseErrorHandler, + ObjectProvider sslBundles, ObjectProvider globalHttpClientSettings, + ObjectProvider> factoryBuilder, + ObjectProvider> webConnectorBuilderProvider) { + + HttpClientSettingsPropertyMapper mapper = new HttpClientSettingsPropertyMapper(sslBundles.getIfAvailable(), + globalHttpClientSettings.getIfAvailable()); + HttpClientSettings httpClientSettings = mapper.map(connectionProperties); + + RestClient.Builder restClientBuilder = restClientBuilderProvider.getIfAvailable(RestClient::builder); + applyRestClientSettings(restClientBuilder, httpClientSettings, + factoryBuilder.getIfAvailable(ClientHttpRequestFactoryBuilder::detect)); + + WebClient.Builder webClientBuilder = webClientBuilderProvider.getIfAvailable(WebClient::builder); + applyWebClientSettings(webClientBuilder, httpClientSettings, + webConnectorBuilderProvider.getIfAvailable(ClientHttpConnectorBuilder::detect)); return AnthropicApi.builder() .baseUrl(connectionProperties.getBaseUrl()) .completionsPath(connectionProperties.getCompletionsPath()) .apiKey(connectionProperties.getApiKey()) .anthropicVersion(connectionProperties.getVersion()) - .restClientBuilder(restClientBuilderProvider.getIfAvailable(RestClient::builder)) - .webClientBuilder(webClientBuilderProvider.getIfAvailable(WebClient::builder)) + .restClientBuilder(restClientBuilder) + .webClientBuilder(webClientBuilder) .responseErrorHandler(responseErrorHandler) .anthropicBetaFeatures(connectionProperties.getBetaVersion()) .build(); @@ -102,4 +124,16 @@ public AnthropicChatModel anthropicChatModel(AnthropicApi anthropicApi, Anthropi return chatModel; } + private void applyRestClientSettings(RestClient.Builder builder, HttpClientSettings httpClientSettings, + ClientHttpRequestFactoryBuilder factoryBuilder) { + ClientHttpRequestFactory requestFactory = factoryBuilder.build(httpClientSettings); + builder.requestFactory(requestFactory); + } + + private void applyWebClientSettings(WebClient.Builder builder, HttpClientSettings httpClientSettings, + ClientHttpConnectorBuilder connectorBuilder) { + ClientHttpConnector connector = connectorBuilder.build(httpClientSettings); + builder.clientConnector(connector); + } + } diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/main/java/org/springframework/ai/model/anthropic/autoconfigure/AnthropicConnectionProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/main/java/org/springframework/ai/model/anthropic/autoconfigure/AnthropicConnectionProperties.java index 871bae627a6..14fb818c176 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/main/java/org/springframework/ai/model/anthropic/autoconfigure/AnthropicConnectionProperties.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/main/java/org/springframework/ai/model/anthropic/autoconfigure/AnthropicConnectionProperties.java @@ -18,6 +18,7 @@ import org.springframework.ai.anthropic.api.AnthropicApi; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.http.client.autoconfigure.HttpClientSettingsProperties; /** * Anthropic API connection properties. @@ -26,7 +27,7 @@ * @since 1.0.0 */ @ConfigurationProperties(AnthropicConnectionProperties.CONFIG_PREFIX) -public class AnthropicConnectionProperties { +public class AnthropicConnectionProperties extends HttpClientSettingsProperties { public static final String CONFIG_PREFIX = "spring.ai.anthropic"; diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/test/java/org/springframework/ai/model/anthropic/autoconfigure/AnthropicChatAutoConfigurationIT.java b/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/test/java/org/springframework/ai/model/anthropic/autoconfigure/AnthropicChatAutoConfigurationIT.java index 53f37f337fa..516fae57ee6 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/test/java/org/springframework/ai/model/anthropic/autoconfigure/AnthropicChatAutoConfigurationIT.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/test/java/org/springframework/ai/model/anthropic/autoconfigure/AnthropicChatAutoConfigurationIT.java @@ -16,7 +16,9 @@ package org.springframework.ai.model.anthropic.autoconfigure; +import java.time.Duration; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; import org.apache.commons.logging.Log; @@ -91,4 +93,51 @@ void stream() { }); } + @Test + void generateWithCustomTimeout() { + new ApplicationContextRunner() + .withPropertyValues("spring.ai.anthropic.apiKey=" + System.getenv("ANTHROPIC_API_KEY"), + "spring.ai.deepseek.connect-timeout=1ms", "spring.ai.deepseek.read-timeout=1ms") + .withConfiguration(SpringAiTestAutoConfigurations.of(AnthropicChatAutoConfiguration.class)) + .run(context -> { + AnthropicChatModel client = context.getBean(AnthropicChatModel.class); + + // Verify that the HTTP client configuration is applied + var connectionProperties = context.getBean(AnthropicConnectionProperties.class); + assertThat(connectionProperties.getConnectTimeout()).isEqualTo(Duration.ofMillis(1)); + assertThat(connectionProperties.getReadTimeout()).isEqualTo(Duration.ofMillis(1)); + + // Verify that the client can actually make requests with the configured + // timeout + String response = client.call("Hello"); + assertThat(response).isNotEmpty(); + logger.info("Response with custom timeout: " + response); + }); + } + + @Test + void generateStreamingWithCustomTimeout() { + new ApplicationContextRunner() + .withPropertyValues("spring.ai.deepseek.apiKey=" + "sk-2567813d742c40e79fa6f1f2ee2f830c", + "spring.ai.deepseek.connect-timeout=1s", "spring.ai.deepseek.read-timeout=1s") + .withConfiguration(SpringAiTestAutoConfigurations.of(AnthropicChatAutoConfiguration.class)) + .run(context -> { + AnthropicChatModel client = context.getBean(AnthropicChatModel.class); + + // Verify that the HTTP client configuration is applied + var connectionProperties = context.getBean(AnthropicConnectionProperties.class); + assertThat(connectionProperties.getConnectTimeout()).isEqualTo(Duration.ofSeconds(1)); + assertThat(connectionProperties.getReadTimeout()).isEqualTo(Duration.ofSeconds(1)); + + Flux responseFlux = client.stream(new Prompt(new UserMessage("Hello"))); + String response = Objects.requireNonNull(responseFlux.collectList().block()) + .stream() + .map(chatResponse -> chatResponse.getResults().get(0).getOutput().getText()) + .collect(Collectors.joining()); + + assertThat(response).isNotEmpty(); + logger.info("Response with custom timeout: " + response); + }); + } + } diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/test/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekAutoConfigurationIT.java b/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/test/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekAutoConfigurationIT.java index 401413ec201..7aa00b8a38b 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/test/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekAutoConfigurationIT.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-deepseek/src/test/java/org/springframework/ai/model/deepseek/autoconfigure/DeepSeekAutoConfigurationIT.java @@ -78,8 +78,7 @@ void generateStreaming() { void generateWithCustomTimeout() { new ApplicationContextRunner() .withPropertyValues("spring.ai.deepseek.apiKey=" + System.getenv("DEEPSEEK_API_KEY"), - "spring.ai.deepseek.connect-timeout=5s", - "spring.ai.deepseek.read-timeout=30s") + "spring.ai.deepseek.connect-timeout=5s", "spring.ai.deepseek.read-timeout=30s") .withConfiguration(SpringAiTestAutoConfigurations.of(DeepSeekChatAutoConfiguration.class)) .run(context -> { DeepSeekChatModel client = context.getBean(DeepSeekChatModel.class); @@ -100,27 +99,26 @@ void generateWithCustomTimeout() { @Test void generateStreamingWithCustomTimeout() { new ApplicationContextRunner() - .withPropertyValues("spring.ai.deepseek.apiKey=" + "sk-2567813d742c40e79fa6f1f2ee2f830c", - "spring.ai.deepseek.connect-timeout=1s", - "spring.ai.deepseek.read-timeout=1s") - .withConfiguration(SpringAiTestAutoConfigurations.of(DeepSeekChatAutoConfiguration.class)) - .run(context -> { - DeepSeekChatModel client = context.getBean(DeepSeekChatModel.class); - - // Verify that the HTTP client configuration is applied - var connectionProperties = context.getBean(DeepSeekConnectionProperties.class); - assertThat(connectionProperties.getConnectTimeout()).isEqualTo(Duration.ofSeconds(1)); - assertThat(connectionProperties.getReadTimeout()).isEqualTo(Duration.ofSeconds(1)); - - Flux responseFlux = client.stream(new Prompt(new UserMessage("Hello"))); - String response = Objects.requireNonNull(responseFlux.collectList().block()) - .stream() - .map(chatResponse -> chatResponse.getResults().get(0).getOutput().getText()) - .collect(Collectors.joining()); - - assertThat(response).isNotEmpty(); - logger.info("Response with custom timeout: " + response); - }); + .withPropertyValues("spring.ai.deepseek.apiKey=" + System.getenv("DEEPSEEK_API_KEY"), + "spring.ai.deepseek.connect-timeout=1s", "spring.ai.deepseek.read-timeout=1s") + .withConfiguration(SpringAiTestAutoConfigurations.of(DeepSeekChatAutoConfiguration.class)) + .run(context -> { + DeepSeekChatModel client = context.getBean(DeepSeekChatModel.class); + + // Verify that the HTTP client configuration is applied + var connectionProperties = context.getBean(DeepSeekConnectionProperties.class); + assertThat(connectionProperties.getConnectTimeout()).isEqualTo(Duration.ofSeconds(1)); + assertThat(connectionProperties.getReadTimeout()).isEqualTo(Duration.ofSeconds(1)); + + Flux responseFlux = client.stream(new Prompt(new UserMessage("Hello"))); + String response = Objects.requireNonNull(responseFlux.collectList().block()) + .stream() + .map(chatResponse -> chatResponse.getResults().get(0).getOutput().getText()) + .collect(Collectors.joining()); + + assertThat(response).isNotEmpty(); + logger.info("Response with custom timeout: " + response); + }); } }