From 12f2dad62376f3cd267760e79a38d71e2fa4ef34 Mon Sep 17 00:00:00 2001 From: Evaldas Visockas Date: Tue, 7 Apr 2026 23:04:59 +0300 Subject: [PATCH] GH-5765: Fix Open AI Azure Content AI issue If Open AI endpoint is Azure use BufferingClientHttpRequestFactory to send content-length. Signed-off-by: Evaldas Visockas --- .../OpenAIAutoConfigurationUtil.java | 33 +++++++ .../OpenAiChatAutoConfiguration.java | 4 +- .../OpenAiAzureChatHttpTests.java | 91 +++++++++++++++++++ 3 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-openai/src/test/java/org/springframework/ai/model/openai/autoconfigure/OpenAiAzureChatHttpTests.java diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAIAutoConfigurationUtil.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAIAutoConfigurationUtil.java index a5b6084533..8b19017fa5 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAIAutoConfigurationUtil.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAIAutoConfigurationUtil.java @@ -16,10 +16,16 @@ package org.springframework.ai.model.openai.autoconfigure; +import java.net.URI; +import java.util.Locale; + import org.springframework.http.HttpHeaders; +import org.springframework.http.client.BufferingClientHttpRequestFactory; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.lang.NonNull; import org.springframework.util.Assert; import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; public final class OpenAIAutoConfigurationUtil { @@ -27,6 +33,33 @@ private OpenAIAutoConfigurationUtil() { // Avoids instantiation } + public static RestClient.Builder prepareRestClientBuilderForOpenAi(@NonNull RestClient.Builder restClientBuilder, + String baseUrl) { + if (!isAzureOpenAiEndpoint(baseUrl)) { + return restClientBuilder; + } + return restClientBuilder.clone() + .requestFactory(new BufferingClientHttpRequestFactory(new SimpleClientHttpRequestFactory())); + } + + public static boolean isAzureOpenAiEndpoint(String baseUrl) { + if (!StringUtils.hasText(baseUrl)) { + return false; + } + try { + URI uri = URI.create(baseUrl.trim()); + String host = uri.getHost(); + if (host == null) { + return false; + } + String lower = host.toLowerCase(Locale.ROOT); + return lower.endsWith("openai.azure.com"); + } + catch (IllegalArgumentException ex) { + return false; + } + } + public static @NonNull ResolvedConnectionProperties resolveConnectionProperties( OpenAiParentProperties commonProperties, OpenAiParentProperties modelProperties, String modelType) { diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiChatAutoConfiguration.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiChatAutoConfiguration.java index 29715e21f3..62bf46b370 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiChatAutoConfiguration.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiChatAutoConfiguration.java @@ -40,6 +40,7 @@ import org.springframework.web.client.RestClient; import org.springframework.web.reactive.function.client.WebClient; +import static org.springframework.ai.model.openai.autoconfigure.OpenAIAutoConfigurationUtil.prepareRestClientBuilderForOpenAi; import static org.springframework.ai.model.openai.autoconfigure.OpenAIAutoConfigurationUtil.resolveConnectionProperties; /** @@ -75,7 +76,8 @@ public OpenAiApi openAiApi(OpenAiConnectionProperties commonProperties, OpenAiCh .headers(resolved.headers()) .completionsPath(chatProperties.getCompletionsPath()) .embeddingsPath(OpenAiEmbeddingProperties.DEFAULT_EMBEDDINGS_PATH) - .restClientBuilder(restClientBuilderProvider.getIfAvailable(RestClient::builder)) + .restClientBuilder(prepareRestClientBuilderForOpenAi( + restClientBuilderProvider.getIfAvailable(RestClient::builder), resolved.baseUrl())) .webClientBuilder(webClientBuilderProvider.getIfAvailable(WebClient::builder)) .responseErrorHandler(responseErrorHandler.getIfAvailable(() -> RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER)) .build(); diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/test/java/org/springframework/ai/model/openai/autoconfigure/OpenAiAzureChatHttpTests.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/test/java/org/springframework/ai/model/openai/autoconfigure/OpenAiAzureChatHttpTests.java new file mode 100644 index 0000000000..879942641a --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/test/java/org/springframework/ai/model/openai/autoconfigure/OpenAiAzureChatHttpTests.java @@ -0,0 +1,91 @@ +/* + * Copyright 2023-present 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.openai.autoconfigure; + +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicReference; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; + +import org.springframework.http.MediaType; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; + +public class OpenAiAzureChatHttpTests { + + @Test + public void isAzureOpenAiEndpointRecognizesAzureHosts() { + assertThat(OpenAIAutoConfigurationUtil.isAzureOpenAiEndpoint("https://my-resource.openai.azure.com")).isTrue(); + assertThat(OpenAIAutoConfigurationUtil + .isAzureOpenAiEndpoint("https://my-resource.openai.azure.com/openai/deployments/x")).isTrue(); + assertThat(OpenAIAutoConfigurationUtil.isAzureOpenAiEndpoint(" https://Ab.openai.azure.com ")).isTrue(); + assertThat(OpenAIAutoConfigurationUtil.isAzureOpenAiEndpoint("https://openai.azure.com")).isTrue(); + } + + @Test + public void isAzureOpenAiEndpointRejectsNonAzureHosts() { + assertThat(OpenAIAutoConfigurationUtil.isAzureOpenAiEndpoint("https://api.openai.com")).isFalse(); + assertThat(OpenAIAutoConfigurationUtil.isAzureOpenAiEndpoint("https://localhost:8080")).isFalse(); + assertThat(OpenAIAutoConfigurationUtil.isAzureOpenAiEndpoint("")).isFalse(); + assertThat(OpenAIAutoConfigurationUtil.isAzureOpenAiEndpoint(null)).isFalse(); + assertThat(OpenAIAutoConfigurationUtil.isAzureOpenAiEndpoint("not-a-uri")).isFalse(); + } + + @Test + public void prepareRestClientBuilderForOpenAiSendsContentLengthOnPostToLocalServer() throws Exception { + AtomicReference contentLength = new AtomicReference<>(); + AtomicReference transferEncoding = new AtomicReference<>(); + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/v1/chat/completions", exchange -> { + contentLength.set(exchange.getRequestHeaders().getFirst("Content-Length")); + transferEncoding.set(exchange.getRequestHeaders().getFirst("Transfer-Encoding")); + String response = """ + {"id":"1","object":"chat.completion","created":0,"model":"gpt","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]} + """; + byte[] bytes = response.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + }); + server.start(); + int port = server.getAddress().getPort(); + try { + RestClient client = OpenAIAutoConfigurationUtil + .prepareRestClientBuilderForOpenAi(RestClient.builder(), "https://resource.openai.azure.com") + .baseUrl("http://127.0.0.1:" + port) + .build(); + String body = client.post() + .uri("/v1/chat/completions") + .contentType(MediaType.APPLICATION_JSON) + .body("{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}") + .retrieve() + .body(String.class); + assertThat(body).contains("chat.completion"); + assertThat(contentLength.get()).isNotNull().isNotBlank(); + assertThat(transferEncoding.get()).isNull(); + } + finally { + server.stop(0); + } + } + +}