diff --git a/auto-configurations/common/spring-ai-autoconfigure-retry/src/main/java/org/springframework/ai/retry/autoconfigure/SpringAiRetryAutoConfiguration.java b/auto-configurations/common/spring-ai-autoconfigure-retry/src/main/java/org/springframework/ai/retry/autoconfigure/SpringAiRetryAutoConfiguration.java index 1d7eed38da9..ae6de6cd664 100644 --- a/auto-configurations/common/spring-ai-autoconfigure-retry/src/main/java/org/springframework/ai/retry/autoconfigure/SpringAiRetryAutoConfiguration.java +++ b/auto-configurations/common/spring-ai-autoconfigure-retry/src/main/java/org/springframework/ai/retry/autoconfigure/SpringAiRetryAutoConfiguration.java @@ -17,6 +17,7 @@ package org.springframework.ai.retry.autoconfigure; import java.io.IOException; +import java.net.URI; import java.nio.charset.StandardCharsets; import org.slf4j.Logger; @@ -30,6 +31,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpMethod; import org.springframework.http.client.ClientHttpResponse; import org.springframework.lang.NonNull; import org.springframework.retry.RetryCallback; @@ -87,6 +89,12 @@ public boolean hasError(@NonNull ClientHttpResponse response) throws IOException } @Override + public void handleError(@NonNull URI url, @NonNull HttpMethod method, @NonNull ClientHttpResponse response) + throws IOException { + handleError(response); + } + + @SuppressWarnings("removal") public void handleError(@NonNull ClientHttpResponse response) throws IOException { if (!response.getStatusCode().isError()) { return; 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 497d43acc4a..824cfef2b83 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 @@ -148,6 +148,20 @@ private AnthropicApi(String baseUrl, String completionsPath, ApiKey anthropicApi .build(); } + /** + * Create a new client api. + * @param completionsPath path to append to the base URL. + * @param restClient RestClient instance. + * @param webClient WebClient instance. + * @param apiKey Anthropic api Key. + */ + public AnthropicApi(String completionsPath, RestClient restClient, WebClient webClient, ApiKey apiKey) { + this.completionsPath = completionsPath; + this.restClient = restClient; + this.webClient = webClient; + this.apiKey = apiKey; + } + /** * Creates a model response for the given chat conversation. * @param chatRequest The chat completion request. @@ -176,7 +190,7 @@ public ResponseEntity chatCompletionEntity(ChatCompletio return this.restClient.post() .uri(this.completionsPath) .headers(headers -> { - headers.addAll(additionalHttpHeader); + headers.addAll(HttpHeaders.readOnlyHttpHeaders(additionalHttpHeader)); addDefaultHeadersIfMissing(headers); }) .body(chatRequest) @@ -217,7 +231,7 @@ public Flux chatCompletionStream(ChatCompletionRequest c return this.webClient.post() .uri(this.completionsPath) .headers(headers -> { - headers.addAll(additionalHttpHeader); + headers.addAll(HttpHeaders.readOnlyHttpHeaders(additionalHttpHeader)); addDefaultHeadersIfMissing(headers); }) // @formatter:off .body(Mono.just(chatRequest), ChatCompletionRequest.class) @@ -256,7 +270,7 @@ public Flux chatCompletionStream(ChatCompletionRequest c } private void addDefaultHeadersIfMissing(HttpHeaders headers) { - if (!headers.containsKey(HEADER_X_API_KEY)) { + if (null == headers.getFirst(HEADER_X_API_KEY)) { String apiKeyValue = this.apiKey.getValue(); if (StringUtils.hasText(apiKeyValue)) { headers.add(HEADER_X_API_KEY, apiKeyValue); diff --git a/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/api/DeepSeekApi.java b/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/api/DeepSeekApi.java index 13415829854..cdb061a78da 100644 --- a/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/api/DeepSeekApi.java +++ b/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/api/DeepSeekApi.java @@ -67,7 +67,7 @@ public class DeepSeekApi { private final WebClient webClient; - private DeepSeekStreamFunctionCallingHelper chunkMerger = new DeepSeekStreamFunctionCallingHelper(); + private final DeepSeekStreamFunctionCallingHelper chunkMerger = new DeepSeekStreamFunctionCallingHelper(); /** * Create a new chat completion api. @@ -90,21 +90,39 @@ public DeepSeekApi(String baseUrl, ApiKey apiKey, MultiValueMap this.completionsPath = completionsPath; this.betaPrefixPath = betaPrefixPath; - // @formatter:off + Consumer finalHeaders = h -> { h.setBearerAuth(apiKey.getValue()); h.setContentType(MediaType.APPLICATION_JSON); - h.addAll(headers); + h.addAll(HttpHeaders.readOnlyHttpHeaders(headers)); }; this.restClient = restClientBuilder.baseUrl(baseUrl) .defaultHeaders(finalHeaders) .defaultStatusHandler(responseErrorHandler) .build(); - this.webClient = webClientBuilder - .baseUrl(baseUrl) - .defaultHeaders(finalHeaders) - .build(); // @formatter:on + this.webClient = webClientBuilder.baseUrl(baseUrl).defaultHeaders(finalHeaders).build(); + + } + + /** + * Create a new chat completion api. + * @param completionsPath the path to the chat completions endpoint. + * @param betaPrefixPath the prefix path to the beta feature endpoint. + * @param restClient RestClient instance. + * @param webClient WebClient instance. + */ + public DeepSeekApi(String completionsPath, String betaPrefixPath, RestClient restClient, WebClient webClient) { + + Assert.hasText(completionsPath, "Completions Path must not be null"); + Assert.hasText(betaPrefixPath, "Beta feature path must not be null"); + Assert.notNull(restClient, "RestClient must not be null"); + Assert.notNull(webClient, "WebClient must not be null"); + + this.completionsPath = completionsPath; + this.betaPrefixPath = betaPrefixPath; + this.restClient = restClient; + this.webClient = webClient; } /** @@ -153,7 +171,7 @@ public Flux chatCompletionStream(ChatCompletionRequest chat return this.webClient.post() .uri(this.getEndpoint(chatRequest)) - .headers(headers -> headers.addAll(additionalHttpHeader)) + .headers(headers -> headers.addAll(HttpHeaders.readOnlyHttpHeaders(additionalHttpHeader))) .body(Mono.just(chatRequest), ChatCompletionRequest.class) .retrieve() .bodyToFlux(String.class) diff --git a/models/spring-ai-elevenlabs/src/main/java/org/springframework/ai/elevenlabs/api/ElevenLabsApi.java b/models/spring-ai-elevenlabs/src/main/java/org/springframework/ai/elevenlabs/api/ElevenLabsApi.java index 10ce0349070..691de26b690 100644 --- a/models/spring-ai-elevenlabs/src/main/java/org/springframework/ai/elevenlabs/api/ElevenLabsApi.java +++ b/models/spring-ai-elevenlabs/src/main/java/org/springframework/ai/elevenlabs/api/ElevenLabsApi.java @@ -70,7 +70,7 @@ private ElevenLabsApi(String baseUrl, ApiKey apiKey, MultiValueMap he Consumer finalHeaders = h -> { h.setContentType(MediaType.APPLICATION_JSON); h.set(HTTP_USER_AGENT_HEADER, SPRING_AI_USER_AGENT); - h.addAll(headers); + h.addAll(HttpHeaders.readOnlyHttpHeaders(headers)); }; this.restClient = restClientBuilder.clone() .baseUrl(baseUrl) @@ -159,6 +159,30 @@ public OpenAiApi(String baseUrl, ApiKey apiKey, MultiValueMap he .build(); // @formatter:on } + /** + * Create a new chat completion api. + * @param baseUrl api base URL. + * @param apiKey OpenAI apiKey. + * @param headers the http headers to use. + * @param completionsPath the path to the chat completions endpoint. + * @param embeddingsPath the path to the embeddings endpoint. + * @param restClient RestClient instance. + * @param webClient WebClient instance. + * @param responseErrorHandler Response error handler. + */ + public OpenAiApi(String baseUrl, ApiKey apiKey, MultiValueMap headers, String completionsPath, + String embeddingsPath, ResponseErrorHandler responseErrorHandler, RestClient restClient, + WebClient webClient) { + this.baseUrl = baseUrl; + this.apiKey = apiKey; + this.headers = headers; + this.completionsPath = completionsPath; + this.embeddingsPath = embeddingsPath; + this.responseErrorHandler = responseErrorHandler; + this.restClient = restClient; + this.webClient = webClient; + } + /** * Returns a string containing all text values from the given media content list. Only * elements of type "text" are processed and concatenated in order. @@ -204,7 +228,7 @@ public ResponseEntity chatCompletionEntity(ChatCompletionRequest return this.restClient.post() .uri(this.completionsPath) .headers(headers -> { - headers.addAll(additionalHttpHeader); + headers.addAll(HttpHeaders.readOnlyHttpHeaders(additionalHttpHeader)); addDefaultHeadersIfMissing(headers); }) .body(chatRequest) @@ -243,7 +267,7 @@ public Flux chatCompletionStream(ChatCompletionRequest chat return this.webClient.post() .uri(this.completionsPath) .headers(headers -> { - headers.addAll(additionalHttpHeader); + headers.addAll(HttpHeaders.readOnlyHttpHeaders(additionalHttpHeader)); addDefaultHeadersIfMissing(headers); }) // @formatter:on .body(Mono.just(chatRequest), ChatCompletionRequest.class) @@ -328,7 +352,7 @@ public ResponseEntity> embeddings(EmbeddingRequest< } private void addDefaultHeadersIfMissing(HttpHeaders headers) { - if (!headers.containsKey(HttpHeaders.AUTHORIZATION) && !(this.apiKey instanceof NoopApiKey)) { + if (null == headers.getFirst(HttpHeaders.AUTHORIZATION) && !(this.apiKey instanceof NoopApiKey)) { headers.setBearerAuth(this.apiKey.getValue()); } } 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 cd89852d244..786eaea18ee 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 @@ -73,7 +73,7 @@ public OpenAiAudioApi(String baseUrl, ApiKey apiKey, MultiValueMap authHeaders = h -> h.addAll(headers); + Consumer authHeaders = h -> h.addAll(HttpHeaders.readOnlyHttpHeaders(headers)); // @formatter:off this.restClient = restClientBuilder.clone() @@ -98,6 +98,16 @@ public OpenAiAudioApi(String baseUrl, ApiKey apiKey, MultiValueMap headers, RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) { - Consumer authHeaders = h -> h.addAll(headers); + Consumer authHeaders = h -> h.addAll(HttpHeaders.readOnlyHttpHeaders(headers)); this.restClient = restClientBuilder.clone() .baseUrl(baseUrl) @@ -65,6 +65,10 @@ public OpenAiFileApi(String baseUrl, ApiKey apiKey, MultiValueMap { h.setContentType(MediaType.APPLICATION_JSON); - h.addAll(headers); + h.addAll(HttpHeaders.readOnlyHttpHeaders(headers)); }) .defaultStatusHandler(responseErrorHandler) .defaultRequest(requestHeadersSpec -> { @@ -82,6 +82,16 @@ public OpenAiImageApi(String baseUrl, ApiKey apiKey, MultiValueMap createImage(OpenAiImageRequest openAiImageRequest) { Assert.notNull(openAiImageRequest, "Image request cannot be null."); Assert.hasLength(openAiImageRequest.prompt(), "Prompt cannot be empty."); diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiModerationApi.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiModerationApi.java index a90bd1aad8e..d13d6a95a4c 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiModerationApi.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiModerationApi.java @@ -19,8 +19,6 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.ai.model.ApiKey; import org.springframework.ai.model.NoopApiKey; @@ -49,12 +47,8 @@ public class OpenAiModerationApi { public static final String DEFAULT_MODERATION_MODEL = "omni-moderation-latest"; - private static final String DEFAULT_BASE_URL = "https://api.openai.com"; - private final RestClient restClient; - private final ObjectMapper objectMapper; - /** * Create a new OpenAI Moderation API with the provided base URL. * @param baseUrl the base URL for the OpenAI API. @@ -64,14 +58,12 @@ public class OpenAiModerationApi { public OpenAiModerationApi(String baseUrl, ApiKey apiKey, MultiValueMap headers, RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) { - this.objectMapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - // @formatter:off this.restClient = restClientBuilder.clone() .baseUrl(baseUrl) .defaultHeaders(h -> { h.setContentType(MediaType.APPLICATION_JSON); - h.addAll(headers); + h.addAll(HttpHeaders.readOnlyHttpHeaders(headers)); }) .defaultStatusHandler(responseErrorHandler) .defaultRequest(requestHeadersSpec -> { @@ -82,6 +74,14 @@ public OpenAiModerationApi(String baseUrl, ApiKey apiKey, MultiValueMap createModeration(OpenAiModerationRequest openAiModerationRequest) { Assert.notNull(openAiModerationRequest, "Moderation request cannot be null."); Assert.hasLength(openAiModerationRequest.prompt(), "Prompt cannot be empty."); diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/metadata/support/OpenAiResponseHeaderExtractor.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/metadata/support/OpenAiResponseHeaderExtractor.java index 7a4d344755d..47708ccb3b7 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/metadata/support/OpenAiResponseHeaderExtractor.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/metadata/support/OpenAiResponseHeaderExtractor.java @@ -71,7 +71,7 @@ public static RateLimit extractAiResponseHeaders(ResponseEntity response) { private static Duration getHeaderAsDuration(ResponseEntity response, String headerName) { var headers = response.getHeaders(); - if (headers.containsKey(headerName)) { + if (null != headers.getFirst(headerName)) { var values = headers.get(headerName); if (!CollectionUtils.isEmpty(values)) { return DurationFormatter.TIME_UNIT.parse(values.get(0)); @@ -82,7 +82,7 @@ private static Duration getHeaderAsDuration(ResponseEntity response, String h private static Long getHeaderAsLong(ResponseEntity response, String headerName) { var headers = response.getHeaders(); - if (headers.containsKey(headerName)) { + if (null != headers.getFirst(headerName)) { var values = headers.get(headerName); if (!CollectionUtils.isEmpty(values)) { return parseLong(headerName, values.get(0)); 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 f07d93a159c..6c2ed11757b 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 @@ -174,7 +174,7 @@ private ZhiPuAiApi(String baseUrl, ApiKey apiKey, MultiValueMap Consumer authHeaders = h -> { h.setContentType(MediaType.APPLICATION_JSON); - h.addAll(headers); + h.addAll(HttpHeaders.readOnlyHttpHeaders(headers)); }; this.restClient = restClientBuilder.clone() @@ -190,6 +190,34 @@ private ZhiPuAiApi(String baseUrl, ApiKey apiKey, MultiValueMap .build(); // @formatter:on } + /** + * Create a new chat completion api. + * @param baseUrl api base URL. + * @param apiKey ZhiPuAI apiKey. + * @param headers the http headers to use. + * @param completionsPath the path to the chat completions endpoint. + * @param embeddingsPath the path to the embeddings endpoint. + * @param restClient RestClient instance. + * @param webClient WebClient instance. + * @param responseErrorHandler Response error handler. + */ + public ZhiPuAiApi(String baseUrl, ApiKey apiKey, MultiValueMap headers, String completionsPath, + String embeddingsPath, ResponseErrorHandler responseErrorHandler, RestClient restClient, + WebClient webClient) { + Assert.hasText(completionsPath, "Completions Path must not be null"); + Assert.hasText(embeddingsPath, "Embeddings Path must not be null"); + Assert.notNull(headers, "Headers must not be null"); + + this.baseUrl = baseUrl; + this.apiKey = apiKey; + this.headers = headers; + this.completionsPath = completionsPath; + this.embeddingsPath = embeddingsPath; + this.responseErrorHandler = responseErrorHandler; + this.restClient = restClient; + this.webClient = webClient; + } + public static String getTextContent(List content) { return content.stream() .filter(c -> "text".equals(c.type())) @@ -223,7 +251,7 @@ public ResponseEntity chatCompletionEntity(ChatCompletionRequest return this.restClient.post() .uri(this.completionsPath) .headers(headers -> { - headers.addAll(additionalHttpHeader); + headers.addAll(HttpHeaders.readOnlyHttpHeaders(additionalHttpHeader)); addDefaultHeadersIfMissing(headers); }) .body(chatRequest) @@ -260,7 +288,7 @@ public Flux chatCompletionStream(ChatCompletionRequest chat return this.webClient.post() .uri(this.completionsPath) .headers(headers -> { - headers.addAll(additionalHttpHeader); + headers.addAll(HttpHeaders.readOnlyHttpHeaders(additionalHttpHeader)); addDefaultHeadersIfMissing(headers); }) // @formatter:on .body(Mono.just(chatRequest), ChatCompletionRequest.class) @@ -330,7 +358,7 @@ public ResponseEntity> embeddings(EmbeddingRequest< } private void addDefaultHeadersIfMissing(HttpHeaders headers) { - if (!headers.containsKey(HttpHeaders.AUTHORIZATION) && !(this.apiKey instanceof NoopApiKey)) { + if (null == headers.getFirst(HttpHeaders.AUTHORIZATION) && !(this.apiKey instanceof NoopApiKey)) { headers.setBearerAuth(this.apiKey.getValue()); } } diff --git a/pom.xml b/pom.xml index 9f47b177abc..dfd9dcf4f0f 100644 --- a/pom.xml +++ b/pom.xml @@ -267,6 +267,7 @@ ${java.version} ${java.version} ${java.version} + 2.2.21 3.5.7 @@ -419,7 +420,7 @@ org.jetbrains.kotlin kotlin-maven-plugin - ${kotlin.version} + ${kotlin.compiler.version} ${java.version} true diff --git a/spring-ai-client-chat/src/main/kotlin/org/springframework/ai/chat/client/ChatClientExtensions.kt b/spring-ai-client-chat/src/main/kotlin/org/springframework/ai/chat/client/ChatClientExtensions.kt index 40a4c6ffd84..7128603bbc7 100644 --- a/spring-ai-client-chat/src/main/kotlin/org/springframework/ai/chat/client/ChatClientExtensions.kt +++ b/spring-ai-client-chat/src/main/kotlin/org/springframework/ai/chat/client/ChatClientExtensions.kt @@ -25,8 +25,8 @@ import org.springframework.core.ParameterizedTypeReference * @author Josh Long */ -inline fun ChatClient.CallResponseSpec.entity(): T = +inline fun ChatClient.CallResponseSpec.entity(): T = entity(object : ParameterizedTypeReference() {}) as T -inline fun ChatClient.CallResponseSpec.responseEntity(): ResponseEntity = +inline fun ChatClient.CallResponseSpec.responseEntity(): ResponseEntity = responseEntity(object : ParameterizedTypeReference() {}) diff --git a/spring-ai-model/src/test/kotlin/org/springframework/ai/tool/resolution/TypeResolverHelperKotlinIT.kt b/spring-ai-model/src/test/kotlin/org/springframework/ai/tool/resolution/TypeResolverHelperKotlinIT.kt index b7671a89b98..4f1fc4de843 100644 --- a/spring-ai-model/src/test/kotlin/org/springframework/ai/tool/resolution/TypeResolverHelperKotlinIT.kt +++ b/spring-ai-model/src/test/kotlin/org/springframework/ai/tool/resolution/TypeResolverHelperKotlinIT.kt @@ -39,7 +39,7 @@ class TypeResolverHelperKotlinIT { val functionType = TypeResolverHelper.resolveBeanType(this.applicationContext, beanName); val functionInputClass = TypeResolverHelper.getFunctionArgumentType(functionType, 0).rawClass; assertThat(functionInputClass).isNotNull(); - assertThat(functionInputClass.typeName).isEqualTo(WeatherRequest::class.java.getName()); + assertThat(functionInputClass?.typeName).isEqualTo(WeatherRequest::class.java.getName()); } class Outer { diff --git a/spring-ai-retry/src/main/java/org/springframework/ai/retry/RetryUtils.java b/spring-ai-retry/src/main/java/org/springframework/ai/retry/RetryUtils.java index 3dc125fd2a8..2d7cbf66742 100644 --- a/spring-ai-retry/src/main/java/org/springframework/ai/retry/RetryUtils.java +++ b/spring-ai-retry/src/main/java/org/springframework/ai/retry/RetryUtils.java @@ -57,7 +57,6 @@ public void handleError(URI url, HttpMethod method, @NonNull ClientHttpResponse handleError(response); } - @Override @SuppressWarnings("removal") public void handleError(@NonNull ClientHttpResponse response) throws IOException { if (response.getStatusCode().isError()) {