diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/AgentsClientBuilder.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/AgentsClientBuilder.java index 3eb05608a18f..ca7a324f6a7f 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/AgentsClientBuilder.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/AgentsClientBuilder.java @@ -5,6 +5,7 @@ import com.azure.ai.agents.implementation.AgentsClientImpl; import com.azure.ai.agents.implementation.TokenUtils; +import com.azure.ai.agents.implementation.http.HttpClientHelper; import com.azure.core.annotation.Generated; import com.azure.core.annotation.ServiceClientBuilder; import com.azure.core.client.traits.ConfigurationTrait; @@ -32,7 +33,6 @@ import com.azure.core.util.ClientOptions; import com.azure.core.util.Configuration; import com.azure.core.util.CoreUtils; -import com.azure.core.util.UserAgentUtil; import com.azure.core.util.builder.ClientBuilderUtil; import com.azure.core.util.logging.ClientLogger; import com.azure.core.util.serializer.JacksonAdapter; @@ -41,7 +41,7 @@ import com.openai.client.okhttp.OpenAIOkHttpClient; import com.openai.client.okhttp.OpenAIOkHttpClientAsync; import com.openai.credential.BearerTokenCredential; -import java.time.Duration; + import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -328,7 +328,9 @@ private HttpPipeline createHttpPipeline() { * @return an instance of ConversationsAsyncClient. */ public ConversationsAsyncClient buildConversationsAsyncClient() { - return new ConversationsAsyncClient(getOpenAIAsyncClientBuilder().build()); + HttpClient decoratedHttpClient = getOpenAIHttpClient(); + return new ConversationsAsyncClient(getOpenAIAsyncClientBuilder().build().withOptions(optionBuilder -> + optionBuilder.httpClient(HttpClientHelper.mapToOpenAIHttpClient(decoratedHttpClient)))); } /** @@ -337,7 +339,9 @@ public ConversationsAsyncClient buildConversationsAsyncClient() { * @return an instance of ConversationsClient. */ public ConversationsClient buildConversationsClient() { - return new ConversationsClient(getOpenAIClientBuilder().build()); + HttpClient decoratedHttpClient = getOpenAIHttpClient(); + return new ConversationsClient(getOpenAIClientBuilder().build().withOptions(optionBuilder -> + optionBuilder.httpClient(HttpClientHelper.mapToOpenAIHttpClient(decoratedHttpClient)))); } /** @@ -346,8 +350,9 @@ public ConversationsClient buildConversationsClient() { * @return an instance of ResponsesClient */ public ResponsesClient buildResponsesClient() { - OpenAIOkHttpClient.Builder builder = getOpenAIClientBuilder(); - return new ResponsesClient(builder.build()); + HttpClient decoratedHttpClient = getOpenAIHttpClient(); + return new ResponsesClient(getOpenAIClientBuilder().build().withOptions(optionBuilder -> + optionBuilder.httpClient(HttpClientHelper.mapToOpenAIHttpClient(decoratedHttpClient)))); } /** @@ -356,7 +361,9 @@ public ResponsesClient buildResponsesClient() { * @return an instance of ResponsesAsyncClient */ public ResponsesAsyncClient buildResponsesAsyncClient() { - return new ResponsesAsyncClient(getOpenAIAsyncClientBuilder().build()); + HttpClient decoratedHttpClient = getOpenAIHttpClient(); + return new ResponsesAsyncClient(getOpenAIAsyncClientBuilder().build().withOptions(optionBuilder -> + optionBuilder.httpClient(HttpClientHelper.mapToOpenAIHttpClient(decoratedHttpClient)))); } private OpenAIOkHttpClient.Builder getOpenAIClientBuilder() { @@ -364,12 +371,11 @@ private OpenAIOkHttpClient.Builder getOpenAIClientBuilder() { .credential( BearerTokenCredential.create(TokenUtils.getBearerTokenSupplier(this.tokenCredential, DEFAULT_SCOPES))); builder.baseUrl(this.endpoint + (this.endpoint.endsWith("/") ? "openai" : "/openai")); - builder.replaceHeaders("User-Agent", getUserAgent()); if (this.serviceVersion != null) { builder.azureServiceVersion(AzureOpenAIServiceVersion.fromString(this.serviceVersion.getVersion())); builder.azureUrlPathMode(AzureUrlPathMode.UNIFIED); } - builder.timeout(Duration.ofSeconds(30)); + builder.maxRetries(0); return builder; } @@ -378,22 +384,16 @@ private OpenAIOkHttpClientAsync.Builder getOpenAIAsyncClientBuilder() { .credential( BearerTokenCredential.create(TokenUtils.getBearerTokenSupplier(this.tokenCredential, DEFAULT_SCOPES))); builder.baseUrl(this.endpoint + (this.endpoint.endsWith("/") ? "openai" : "/openai")); - builder.replaceHeaders("User-Agent", getUserAgent()); if (this.serviceVersion != null) { builder.azureServiceVersion(AzureOpenAIServiceVersion.fromString(this.serviceVersion.getVersion())); builder.azureUrlPath(AzureUrlPathMode.UNIFIED); } - builder.timeout(Duration.ofSeconds(30)); + builder.maxRetries(0); return builder; } - private String getUserAgent() { - HttpLogOptions localHttpLogOptions = this.httpLogOptions == null ? new HttpLogOptions() : this.httpLogOptions; - ClientOptions localClientOptions = this.clientOptions == null ? new ClientOptions() : this.clientOptions; - String sdkName = PROPERTIES.getOrDefault(SDK_NAME, "UnknownName"); - String sdkVersion = PROPERTIES.getOrDefault(SDK_VERSION, "UnknownVersion"); - String applicationId = CoreUtils.getApplicationId(localClientOptions, localHttpLogOptions); - return UserAgentUtil.toUserAgentString(applicationId, sdkName, sdkVersion, configuration); + private HttpClient getOpenAIHttpClient() { + return createHttpPipeline()::send; } private static final ClientLogger LOGGER = new ClientLogger(AgentsClientBuilder.class); diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/http/AzureHttpResponseAdapter.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/http/AzureHttpResponseAdapter.java new file mode 100644 index 000000000000..e299a62a1d48 --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/http/AzureHttpResponseAdapter.java @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.implementation.http; + +import com.azure.core.http.HttpHeader; +import com.azure.core.http.HttpHeaders; +import com.azure.core.util.logging.ClientLogger; +import com.openai.core.http.Headers; +import com.openai.core.http.HttpResponse; + +import java.io.InputStream; + +/** + * Adapter that exposes an Azure {@link com.azure.core.http.HttpResponse} as an OpenAI {@link HttpResponse}. This keeps + * the translation logic encapsulated so response handling elsewhere can remain framework agnostic. + */ +final class AzureHttpResponseAdapter implements HttpResponse { + + private static final ClientLogger LOGGER = new ClientLogger(AzureHttpResponseAdapter.class); + + private final com.azure.core.http.HttpResponse azureResponse; + + /** + * Creates a new adapter instance for the provided Azure response. + * + * @param azureResponse Response returned by the Azure pipeline. + */ + AzureHttpResponseAdapter(com.azure.core.http.HttpResponse azureResponse) { + this.azureResponse = azureResponse; + } + + @Override + public int statusCode() { + return azureResponse.getStatusCode(); + } + + @Override + public Headers headers() { + return toOpenAiHeaders(azureResponse.getHeaders()); + } + + @Override + public InputStream body() { + return azureResponse.getBodyAsBinaryData().toStream(); + } + + @Override + public void close() { + azureResponse.close(); + } + + /** + * Copies headers from the Azure response into the immutable OpenAI {@link Headers} collection. + */ + private static Headers toOpenAiHeaders(HttpHeaders httpHeaders) { + Headers.Builder builder = Headers.builder(); + for (HttpHeader header : httpHeaders) { + builder.put(header.getName(), header.getValuesList()); + } + return builder.build(); + } +} diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/http/HttpClientHelper.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/http/HttpClientHelper.java new file mode 100644 index 000000000000..8b3e7500cffa --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/http/HttpClientHelper.java @@ -0,0 +1,197 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.implementation.http; + +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpHeaders; +import com.azure.core.util.BinaryData; +import com.azure.core.util.Context; +import com.azure.core.util.CoreUtils; +import com.azure.core.util.logging.ClientLogger; +import com.openai.core.RequestOptions; +import com.openai.core.http.Headers; +import com.openai.core.http.HttpRequest; +import com.openai.core.http.HttpRequestBody; +import com.openai.core.http.HttpResponse; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; + +/** + * Utility entry point that adapts an Azure {@link com.azure.core.http.HttpClient} so it can be consumed by + * the OpenAI SDK generated clients. The helper performs request/response translation so that existing Azure + * pipelines, diagnostics, and retry policies can be reused without exposing the Azure HTTP primitives to + * callers that only understand the OpenAI surface area. + */ +public final class HttpClientHelper { + + private static final ClientLogger LOGGER = new ClientLogger(HttpClientHelper.class); + + private HttpClientHelper() { + } + + /** + * Wraps the given Azure {@link com.azure.core.http.HttpClient} with an implementation of the OpenAI + * {@link com.openai.core.http.HttpClient} interface. All requests and responses are converted on the fly. + * + * @param azureHttpClient The Azure HTTP client that should execute requests. + * @return A bridge client that honors the OpenAI interface but delegates execution to the Azure pipeline. + */ + public static com.openai.core.http.HttpClient + mapToOpenAIHttpClient(com.azure.core.http.HttpClient azureHttpClient) { + return new HttpClientWrapper(azureHttpClient); + } + + private static final class HttpClientWrapper implements com.openai.core.http.HttpClient { + + private final com.azure.core.http.HttpClient azureHttpClient; + + private HttpClientWrapper(com.azure.core.http.HttpClient azureHttpClient) { + this.azureHttpClient = Objects.requireNonNull(azureHttpClient, "'azureHttpClient' cannot be null."); + } + + @Override + public void close() { + // no-op + } + + @Override + public HttpResponse execute(HttpRequest request) { + return execute(request, RequestOptions.none()); + } + + @Override + public HttpResponse execute(HttpRequest request, RequestOptions requestOptions) { + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(requestOptions, "requestOptions"); + + com.azure.core.http.HttpRequest azureRequest = buildAzureRequest(request); + com.azure.core.http.HttpResponse azureResponse = this.azureHttpClient.sendSync(azureRequest, Context.NONE); + return new AzureHttpResponseAdapter(azureResponse); + } + + @Override + public CompletableFuture executeAsync(HttpRequest request) { + return executeAsync(request, RequestOptions.none()); + } + + @Override + public CompletableFuture executeAsync(HttpRequest request, RequestOptions requestOptions) { + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(requestOptions, "requestOptions"); + + final com.azure.core.http.HttpRequest azureRequest; + try { + azureRequest = buildAzureRequest(request); + } catch (RuntimeException runtimeException) { + return failedFuture(runtimeException); + } + + return this.azureHttpClient.send(azureRequest) + .map(response -> (HttpResponse) new AzureHttpResponseAdapter(response)) +// .onErrorMap(t -> { +// // 2 or 3 from Azure Errors, should be mapped to Stainless Error. +// // - Auth +// // - Resource not found +// // - HttpResponse Ex +// // +// // new StainlessException(t.getCause()) +// }) + .toFuture(); + } + + /** + * Converts the OpenAI request metadata and body into an Azure {@link com.azure.core.http.HttpRequest}. + */ + private static com.azure.core.http.HttpRequest buildAzureRequest(HttpRequest request) { + HttpRequestBody requestBody = request.body(); + String contentType = requestBody != null ? requestBody.contentType() : null; + BinaryData bodyData = null; + + if (requestBody != null) { + try { + bodyData = toBinaryData(requestBody); + } finally { + closeQuietly(requestBody); + } + } + + HttpHeaders headers = toAzureHeaders(request.headers()); + if (!CoreUtils.isNullOrEmpty(contentType) && headers.getValue(HttpHeaderName.CONTENT_TYPE) == null) { + headers.set(HttpHeaderName.CONTENT_TYPE, contentType); + } + + com.azure.core.http.HttpRequest azureRequest + = new com.azure.core.http.HttpRequest(com.azure.core.http.HttpMethod.valueOf(request.method().name()), + OpenAiRequestUrlBuilder.buildUrl(request), headers); + + if (bodyData != null) { + azureRequest.setBody(bodyData); + } + + return azureRequest; + } + + /** + * Copies OpenAI headers into an {@link HttpHeaders} instance so the Azure pipeline can process them. + */ + private static HttpHeaders toAzureHeaders(Headers sourceHeaders) { + HttpHeaders target = new HttpHeaders(); + sourceHeaders.names().forEach(name -> { + List values = sourceHeaders.values(name); + HttpHeaderName headerName = HttpHeaderName.fromString(name); + if (values.isEmpty()) { + target.set(headerName, ""); + } else { + target.set(headerName, values); + } + }); + return target; + } + + /** + * Buffers the OpenAI {@link HttpRequestBody} into {@link BinaryData} so it can be attached to the Azure + * request. The body is consumed exactly once and closed afterwards. + */ + private static BinaryData toBinaryData(HttpRequestBody requestBody) { + if (requestBody == null) { + return null; + } + + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + requestBody.writeTo(outputStream); + return BinaryData.fromBytes(outputStream.toByteArray()); + } catch (IOException e) { + throw LOGGER.logExceptionAsError(new UncheckedIOException("Failed to buffer request body", e)); + } + } + + /** + * Closes the OpenAI request body while suppressing any exceptions (to avoid masking the root cause). + */ + private static void closeQuietly(HttpRequestBody body) { + if (body == null) { + return; + } + try { + body.close(); + } catch (Exception ignored) { + // no-op + } + } + + /** + * Creates a failed {@link CompletableFuture} for callers that require a future even when conversion fails. + */ + private static CompletableFuture failedFuture(Throwable throwable) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(throwable); + return future; + } + } +} diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/http/OpenAiRequestUrlBuilder.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/http/OpenAiRequestUrlBuilder.java new file mode 100644 index 000000000000..5ae6eee03b8b --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/http/OpenAiRequestUrlBuilder.java @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.implementation.http; + +import com.azure.core.util.CoreUtils; +import com.azure.core.util.logging.ClientLogger; +import com.openai.core.http.HttpRequest; +import com.openai.core.http.QueryParams; + +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.List; +import java.util.StringJoiner; + +/** + * Utility methods that reconstruct the absolute {@link URL} required by the Azure pipeline from the + * OpenAI request metadata. The builder keeps the low-level path/query handling isolated so that + * {@link HttpClientHelper} can focus on the higher-level request mapping logic. + */ +final class OpenAiRequestUrlBuilder { + + private static final ClientLogger LOGGER = new ClientLogger(OpenAiRequestUrlBuilder.class); + + private OpenAiRequestUrlBuilder() { + } + + /** + * Builds an absolute {@link URL} using the base URL, path segments, and query parameters that are stored in the + * OpenAI {@link HttpRequest} abstraction. + * + * @param request Source request provided by the OpenAI client. + * @return Absolute URL that can be consumed by Azure HTTP components. + */ + static URL buildUrl(HttpRequest request) { + try { + URI baseUri = URI.create(request.baseUrl()); + URL baseUrl = baseUri.toURL(); + String path = buildPath(baseUrl.getPath(), request.pathSegments()); + String query = buildQueryString(request.queryParams()); + URI resolved = new URI(baseUrl.getProtocol(), baseUrl.getUserInfo(), baseUrl.getHost(), baseUrl.getPort(), + path, query, null); + return resolved.toURL(); + } catch (MalformedURLException | URISyntaxException ex) { + throw LOGGER.logThrowableAsWarning(new IllegalStateException( + "Failed to build Azure HTTP request URL from base: " + request.baseUrl(), ex)); + } + } + + /** + * Creates a normalized path that merges the OpenAI base path with the additional path segments present on the + * request. + */ + private static String buildPath(String basePath, List pathSegments) { + StringBuilder builder = new StringBuilder(); + String normalizedBasePath = normalizeBasePath(basePath); + if (!CoreUtils.isNullOrEmpty(normalizedBasePath)) { + builder.append(normalizedBasePath); + } + + for (String segment : pathSegments) { + if (builder.length() == 0 || builder.charAt(builder.length() - 1) != '/') { + builder.append('/'); + } + if (segment != null) { + builder.append(segment); + } + } + + return builder.length() == 0 ? "/" : builder.toString(); + } + + /** + * Normalizes the base path ensuring trailing slashes are removed and {@code null} inputs result in an empty path. + */ + private static String normalizeBasePath(String basePath) { + if (CoreUtils.isNullOrEmpty(basePath)) { + return ""; + } + if ("/".equals(basePath)) { + return ""; + } + return trimTrailingSlash(basePath); + } + + /** + * Removes the final {@code '/'} character when present so that subsequent concatenation does not duplicate + * separators. + */ + private static String trimTrailingSlash(String value) { + if (value == null) { + return null; + } + int length = value.length(); + if (length == 0) { + return value; + } + return value.charAt(length - 1) == '/' ? value.substring(0, length - 1) : value; + } + + /** + * Converts OpenAI {@link QueryParams} into a flattened query string. Encoding is deferred to {@link URI} so we do + * not double-encode values already escaped by upstream layers. + */ + private static String buildQueryString(QueryParams queryParams) { + if (queryParams == null || queryParams.isEmpty()) { + return null; + } + + StringJoiner joiner = new StringJoiner("&"); + queryParams.keys().forEach(name -> { + List values = queryParams.values(name); + if (values.isEmpty()) { + joiner.add(name); + } else { + values.forEach(value -> joiner.add(formatQueryComponent(name, value))); + } + }); + String query = joiner.toString(); + return query.isEmpty() ? null : query; + } + + /** + * Formats a single query component using {@code name=value} semantics, handling parameters that omit a value. + */ + private static String formatQueryComponent(String name, String value) { + if (value == null) { + return name; + } + return name + "=" + value; + } +} diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/ConversationsAsyncTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/ConversationsAsyncTests.java index 8045fa5cf987..77139f02f4d3 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/ConversationsAsyncTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/ConversationsAsyncTests.java @@ -4,6 +4,8 @@ package com.azure.ai.agents; import com.azure.core.http.HttpClient; +import com.azure.core.util.logging.ClientLogger; +import com.azure.core.util.logging.LogLevel; import com.openai.core.JsonValue; import com.openai.models.conversations.Conversation; import com.openai.models.conversations.ConversationDeletedResource; @@ -24,6 +26,8 @@ @Disabled("Disabled for lack of recordings. Needs to be enabled on the Public Preview release.") public class ConversationsAsyncTests extends ClientTestBase { + ClientLogger LOGGER = new ClientLogger(ConversationsAsyncTests.class); + @ParameterizedTest(name = DISPLAY_NAME_WITH_ARGUMENTS) @MethodSource("com.azure.ai.agents.TestUtils#getTestParameters") public void basicCRUDOperations(HttpClient httpClient, AgentsServiceVersion serviceVersion) @@ -35,6 +39,7 @@ public void basicCRUDOperations(HttpClient httpClient, AgentsServiceVersion serv String conversationId = createdConversation.id(); assertNotNull(conversationId); assertTrue(StringUtils.isNotBlank(conversationId)); + LOGGER.log(LogLevel.INFORMATIONAL, () -> "Create completed"); // update ConversationUpdateParams.Metadata metadata = ConversationUpdateParams.Metadata.builder() diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/implementation/http/HttpClientHelperTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/implementation/http/HttpClientHelperTests.java new file mode 100644 index 000000000000..f07563b6442b --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/implementation/http/HttpClientHelperTests.java @@ -0,0 +1,331 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.implementation.http; + +import com.azure.core.http.HttpClient; +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpHeaders; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.HttpResponse; +import com.azure.core.test.http.MockHttpResponse; +import com.azure.core.util.Context; +import com.openai.core.http.HttpRequestBody; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +class HttpClientHelperTests { + + private static final HttpHeaderName REQUEST_ID_HEADER = HttpHeaderName.fromString("x-request-id"); + private static final HttpHeaderName CUSTOM_HEADER_NAME = HttpHeaderName.fromString("custom-header"); + private static final HttpHeaderName X_TEST_HEADER = HttpHeaderName.fromString("X-Test"); + private static final HttpHeaderName X_MULTI_HEADER = HttpHeaderName.fromString("X-Multi"); + + @Test + void executeMapsRequestAndResponse() { + RecordingHttpClient recordingClient = new RecordingHttpClient(request -> createMockResponse(request, 201, + new HttpHeaders().set(REQUEST_ID_HEADER, "req-123").set(CUSTOM_HEADER_NAME, "custom-value"), "pong")); + com.openai.core.http.HttpClient openAiClient = HttpClientHelper.mapToOpenAIHttpClient(recordingClient); + + com.openai.core.http.HttpRequest openAiRequest = createOpenAiRequest(); + + try (com.openai.core.http.HttpResponse response = openAiClient.execute(openAiRequest)) { + HttpRequest sentRequest = recordingClient.getLastRequest(); + assertNotNull(sentRequest, "Azure HttpClient should receive a request"); + assertEquals(HttpMethod.POST, sentRequest.getHttpMethod()); + assertEquals("https://example.com/path/segment?q=a%20b", sentRequest.getUrl().toString()); + assertEquals("alpha", sentRequest.getHeaders().getValue(X_TEST_HEADER)); + assertArrayEquals(new String[] { "first", "second" }, sentRequest.getHeaders().getValues(X_MULTI_HEADER)); + assertEquals("text/plain", sentRequest.getHeaders().getValue(HttpHeaderName.CONTENT_TYPE)); + assertEquals("payload", new String(sentRequest.getBodyAsBinaryData().toBytes(), StandardCharsets.UTF_8)); + + assertEquals(201, response.statusCode()); + assertEquals("req-123", response.requestId().orElseThrow(() -> new AssertionError("Missing request id"))); + assertEquals("custom-value", response.headers().values("custom-header").get(0)); + assertEquals("pong", new String(readAllBytes(response.body()), StandardCharsets.UTF_8)); + } catch (Exception e) { + fail("Exception thrown while reading response", e); + } + } + + @Test + void executeAsyncCompletesSuccessfully() { + RecordingHttpClient recordingClient + = new RecordingHttpClient(request -> createMockResponse(request, 204, new HttpHeaders(), "")); + com.openai.core.http.HttpClient openAiClient = HttpClientHelper.mapToOpenAIHttpClient(recordingClient); + + com.openai.core.http.HttpRequest openAiRequest = createOpenAiRequest(); + + CompletableFuture future = openAiClient.executeAsync(openAiRequest); + try (com.openai.core.http.HttpResponse response = future.join()) { + assertEquals(204, response.statusCode()); + } catch (Exception e) { + fail("Exception thrown while reading response", e); + } + assertEquals(1, recordingClient.getSendCount()); + } + + @Test + void executeWithNullRequestBodySucceeds() throws Exception { + RecordingHttpClient recordingClient = new RecordingHttpClient(request -> { + // Verify the request has no body (or empty body) + com.azure.core.util.BinaryData bodyData = request.getBodyAsBinaryData(); + if (bodyData != null) { + assertEquals(0, bodyData.toBytes().length); + } + return createMockResponse(request, 200, new HttpHeaders(), "success"); + }); + com.openai.core.http.HttpClient openAiClient = HttpClientHelper.mapToOpenAIHttpClient(recordingClient); + + com.openai.core.http.HttpRequest openAiRequest = com.openai.core.http.HttpRequest.builder() + .method(com.openai.core.http.HttpMethod.GET) + .baseUrl("https://example.com") + .addPathSegment("test") + .build(); + + try (com.openai.core.http.HttpResponse response = openAiClient.execute(openAiRequest)) { + assertEquals(200, response.statusCode()); + assertEquals("success", new String(readAllBytes(response.body()), StandardCharsets.UTF_8)); + } + } + + @Test + void executeThrowsUncheckedIOExceptionOnBodyBufferingFailure() { + RecordingHttpClient recordingClient + = new RecordingHttpClient(request -> createMockResponse(request, 200, new HttpHeaders(), "")); + com.openai.core.http.HttpClient openAiClient = HttpClientHelper.mapToOpenAIHttpClient(recordingClient); + + com.openai.core.http.HttpRequest openAiRequest = com.openai.core.http.HttpRequest.builder() + .method(com.openai.core.http.HttpMethod.POST) + .baseUrl("https://example.com") + .body(new FailingHttpRequestBody()) + .build(); + + RuntimeException exception = assertThrows(RuntimeException.class, () -> { + openAiClient.execute(openAiRequest); + }); + // Verify the error is related to body buffering failure + boolean hasBufferMessage = exception.getMessage() != null && exception.getMessage().contains("buffer"); + boolean hasIOCause = exception.getCause() instanceof IOException; + assertTrue(hasBufferMessage || hasIOCause, "Expected error related to buffer failure, got: " + exception); + } + + @Test + void executeThrowsExceptionOnMalformedUrl() { + RecordingHttpClient recordingClient + = new RecordingHttpClient(request -> createMockResponse(request, 200, new HttpHeaders(), "")); + com.openai.core.http.HttpClient openAiClient = HttpClientHelper.mapToOpenAIHttpClient(recordingClient); + + com.openai.core.http.HttpRequest openAiRequest = com.openai.core.http.HttpRequest.builder() + .method(com.openai.core.http.HttpMethod.GET) + .baseUrl("not-a-valid-url") + .build(); + + // Malformed URLs should throw an exception (typically IllegalArgumentException or IllegalStateException) + assertThrows(RuntimeException.class, () -> { + openAiClient.execute(openAiRequest); + }); + } + + @Test + void executeAsyncPropagatesRequestBuildingErrors() { + RecordingHttpClient recordingClient + = new RecordingHttpClient(request -> createMockResponse(request, 200, new HttpHeaders(), "")); + com.openai.core.http.HttpClient openAiClient = HttpClientHelper.mapToOpenAIHttpClient(recordingClient); + + com.openai.core.http.HttpRequest openAiRequest = com.openai.core.http.HttpRequest.builder() + .method(com.openai.core.http.HttpMethod.POST) + .baseUrl("https://example.com") + .body(new FailingHttpRequestBody()) + .build(); + + CompletableFuture future = openAiClient.executeAsync(openAiRequest); + + Exception exception = assertThrows(Exception.class, future::join); + Throwable cause = exception.getCause(); + assertNotNull(cause, "Expected a cause for the exception"); + // The error should be related to request building/buffering failure + assertTrue(cause instanceof RuntimeException, "Expected RuntimeException, got: " + cause.getClass().getName()); + } + + @Test + void executeAsyncPropagatesHttpClientFailures() { + FailingHttpClient failingClient = new FailingHttpClient(new RuntimeException("Network error")); + com.openai.core.http.HttpClient openAiClient = HttpClientHelper.mapToOpenAIHttpClient(failingClient); + + com.openai.core.http.HttpRequest openAiRequest = com.openai.core.http.HttpRequest.builder() + .method(com.openai.core.http.HttpMethod.GET) + .baseUrl("https://example.com") + .build(); + + CompletableFuture future = openAiClient.executeAsync(openAiRequest); + + Exception exception = assertThrows(Exception.class, future::join); + Throwable cause = exception.getCause(); + assertNotNull(cause); + assertTrue(cause instanceof RuntimeException); + assertEquals("Network error", cause.getMessage()); + } + + private static com.openai.core.http.HttpRequest createOpenAiRequest() { + return com.openai.core.http.HttpRequest.builder() + .method(com.openai.core.http.HttpMethod.POST) + .baseUrl("https://example.com") + .addPathSegment("path") + .addPathSegment("segment") + .putHeader("X-Test", "alpha") + .putHeaders("X-Multi", Arrays.asList("first", "second")) + .putQueryParam("q", "a b") + .body(new TestHttpRequestBody("payload", "text/plain")) + .build(); + } + + private static MockHttpResponse createMockResponse(HttpRequest request, int statusCode, HttpHeaders headers, + String body) { + byte[] bytes = body == null ? new byte[0] : body.getBytes(StandardCharsets.UTF_8); + return new MockHttpResponse(request, statusCode, headers, bytes); + } + + private static byte[] readAllBytes(InputStream stream) throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] chunk = new byte[4096]; + int read; + while ((read = stream.read(chunk)) != -1) { + buffer.write(chunk, 0, read); + } + return buffer.toByteArray(); + } + + private static final class RecordingHttpClient implements HttpClient { + private final Function responseFactory; + private HttpRequest lastRequest; + private int sendCount; + + private RecordingHttpClient(Function responseFactory) { + this.responseFactory = responseFactory; + } + + @Override + public Mono send(HttpRequest request) { + this.lastRequest = request; + this.sendCount++; + return Mono.just(responseFactory.apply(request)); + } + + @Override + public Mono send(HttpRequest request, Context context) { + return send(request); + } + + HttpRequest getLastRequest() { + return lastRequest; + } + + int getSendCount() { + return sendCount; + } + } + + private static final class TestHttpRequestBody implements HttpRequestBody { + private final byte[] content; + private final String contentType; + + private TestHttpRequestBody(String content, String contentType) { + this.content = content.getBytes(StandardCharsets.UTF_8); + this.contentType = contentType; + } + + @Override + public void writeTo(OutputStream outputStream) { + try { + outputStream.write(content); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public String contentType() { + return contentType; + } + + @Override + public long contentLength() { + return content.length; + } + + @Override + public boolean repeatable() { + return true; + } + + @Override + public void close() { + // no-op + } + } + + private static final class FailingHttpRequestBody implements HttpRequestBody { + @Override + public void writeTo(OutputStream outputStream) { + // Simulate an I/O failure during body write + throw new UncheckedIOException(new IOException("Simulated I/O failure during body write")); + } + + @Override + public String contentType() { + return "application/octet-stream"; + } + + @Override + public long contentLength() { + return -1; + } + + @Override + public boolean repeatable() { + return false; + } + + @Override + public void close() { + // no-op + } + } + + private static final class FailingHttpClient implements HttpClient { + private final RuntimeException error; + + private FailingHttpClient(RuntimeException error) { + this.error = error; + } + + @Override + public Mono send(HttpRequest request) { + return Mono.error(error); + } + + @Override + public Mono send(HttpRequest request, Context context) { + return send(request); + } + } +}