diff --git a/eng/versioning/version_client.txt b/eng/versioning/version_client.txt index befc2c3e70be..f34850dd7462 100644 --- a/eng/versioning/version_client.txt +++ b/eng/versioning/version_client.txt @@ -547,6 +547,7 @@ io.clientcore:optional-dependency-tests;1.0.0-beta.1;1.0.0-beta.1 unreleased_com.azure:azure-data-appconfiguration;1.9.0-beta.1 unreleased_com.azure.v2:azure-core;2.0.0-beta.1 +unreleased_com.azure:azure-core-test;1.27.0-beta.14 unreleased_com.azure.v2:azure-identity;2.0.0-beta.1 unreleased_com.azure.v2:azure-data-appconfiguration;2.0.0-beta.1 unreleased_io.clientcore:http-netty4;1.0.0-beta.1 diff --git a/sdk/agrifood/azure-verticals-agrifood-farming/README.md b/sdk/agrifood/azure-verticals-agrifood-farming/README.md index c7986b0b630a..0e9da5017918 100644 --- a/sdk/agrifood/azure-verticals-agrifood-farming/README.md +++ b/sdk/agrifood/azure-verticals-agrifood-farming/README.md @@ -103,8 +103,9 @@ Farm hierarchy is a collection of below entities. ```java readme-sample-createFarmHierarchy // Create Party -JSONObject object = new JSONObject().appendField("name", "party1"); -BinaryData party = BinaryData.fromObject(object); +Map partyData = new HashMap<>(); +partyData.put("name", "party1"); +BinaryData party = BinaryData.fromObject(partyData); partiesClient.createOrUpdateWithResponse("contoso-party", party, null).block(); // Get Party diff --git a/sdk/ai/azure-ai-agents/assets.json b/sdk/ai/azure-ai-agents/assets.json index fc849ff7d2b2..2b1a3831f771 100644 --- a/sdk/ai/azure-ai-agents/assets.json +++ b/sdk/ai/azure-ai-agents/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "java", "TagPrefix": "java/ai/azure-ai-agents", - "Tag": "java/ai/azure-ai-agents_ca2ca780eb" + "Tag": "java/ai/azure-ai-agents_3f32cd8dff" } \ No newline at end of file diff --git a/sdk/ai/azure-ai-agents/pom.xml b/sdk/ai/azure-ai-agents/pom.xml index eb11edd2e282..378c86a00a9b 100644 --- a/sdk/ai/azure-ai-agents/pom.xml +++ b/sdk/ai/azure-ai-agents/pom.xml @@ -75,7 +75,13 @@ com.azure azure-core-test - 1.27.0-beta.13 + 1.27.0-beta.14 + test + + + com.azure + azure-core-http-okhttp + 1.13.2 test 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..cd1e70bb3a1c 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()); + return new ConversationsAsyncClient(getOpenAIAsyncClientBuilder().build() + .withOptions(optionBuilder -> optionBuilder + .httpClient(HttpClientHelper.mapToOpenAIHttpClient(createHttpPipeline())))); } /** @@ -337,7 +339,9 @@ public ConversationsAsyncClient buildConversationsAsyncClient() { * @return an instance of ConversationsClient. */ public ConversationsClient buildConversationsClient() { - return new ConversationsClient(getOpenAIClientBuilder().build()); + return new ConversationsClient(getOpenAIClientBuilder().build() + .withOptions(optionBuilder -> optionBuilder + .httpClient(HttpClientHelper.mapToOpenAIHttpClient(createHttpPipeline())))); } /** @@ -346,8 +350,9 @@ public ConversationsClient buildConversationsClient() { * @return an instance of ResponsesClient */ public ResponsesClient buildResponsesClient() { - OpenAIOkHttpClient.Builder builder = getOpenAIClientBuilder(); - return new ResponsesClient(builder.build()); + return new ResponsesClient(getOpenAIClientBuilder().build() + .withOptions(optionBuilder -> optionBuilder + .httpClient(HttpClientHelper.mapToOpenAIHttpClient(createHttpPipeline())))); } /** @@ -356,7 +361,9 @@ public ResponsesClient buildResponsesClient() { * @return an instance of ResponsesAsyncClient */ public ResponsesAsyncClient buildResponsesAsyncClient() { - return new ResponsesAsyncClient(getOpenAIAsyncClientBuilder().build()); + return new ResponsesAsyncClient(getOpenAIAsyncClientBuilder().build() + .withOptions(optionBuilder -> optionBuilder + .httpClient(HttpClientHelper.mapToOpenAIHttpClient(createHttpPipeline())))); } private OpenAIOkHttpClient.Builder getOpenAIClientBuilder() { @@ -364,12 +371,12 @@ 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)); + // We set the builder retries to 0 to avoid conflicts with the retry policy added through the HttpPipeline. + builder.maxRetries(0); return builder; } @@ -378,24 +385,15 @@ 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)); + // We set the builder retries to 0 to avoid conflicts with the retry policy added through the HttpPipeline. + 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 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..e7df9ee0c91f --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/http/HttpClientHelper.java @@ -0,0 +1,240 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.implementation.http; + +import com.azure.core.exception.HttpResponseException; +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpHeaders; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.HttpPipeline; +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.Timeout; +import com.openai.core.http.Headers; +import com.openai.core.http.HttpClient; +import com.openai.core.http.HttpRequest; +import com.openai.core.http.HttpRequestBody; +import com.openai.core.http.HttpResponse; +import com.openai.errors.BadRequestException; +import com.openai.errors.InternalServerException; +import com.openai.errors.NotFoundException; +import com.openai.errors.OpenAIException; +import com.openai.errors.PermissionDeniedException; +import com.openai.errors.RateLimitException; +import com.openai.errors.UnauthorizedException; +import com.openai.errors.UnexpectedStatusCodeException; +import com.openai.errors.UnprocessableEntityException; + +import java.io.ByteArrayOutputStream; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeoutException; + +/** + * 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() { + } + + /** + * Implements the OpenAI {@link HttpClient} interface that sends the HTTP request through the Azure HTTP pipeline. + * All requests and responses are converted on the fly. + * + * @param httpPipeline The Azure HTTP pipeline that will execute HTTP requests. + * @return A bridge client that honors the OpenAI interface but delegates execution to the Azure pipeline. + */ + public static HttpClient mapToOpenAIHttpClient(HttpPipeline httpPipeline) { + return new HttpClientWrapper(httpPipeline); + } + + private static final class HttpClientWrapper implements HttpClient { + + private final HttpPipeline httpPipeline; + + private HttpClientWrapper(HttpPipeline httpPipeline) { + this.httpPipeline = Objects.requireNonNull(httpPipeline, "'httpPipeline' 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); + + return new AzureHttpResponseAdapter( + this.httpPipeline.sendSync(azureRequest, buildRequestContext(requestOptions))); + } + + @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 = buildAzureRequest(request); + + return this.httpPipeline.send(azureRequest, buildRequestContext(requestOptions)) + .map(response -> (HttpResponse) new AzureHttpResponseAdapter(response)) + .onErrorMap(HttpClientWrapper::mapAzureExceptionToOpenAI) + .toFuture(); + } + + /** + * Maps Azure exceptions to their corresponding OpenAI exception types. + * + * @param throwable The Azure exception to map. + * @return The corresponding OpenAI exception. + */ + private static Throwable mapAzureExceptionToOpenAI(Throwable throwable) { + if (throwable instanceof HttpResponseException) { + HttpResponseException httpResponseException = (HttpResponseException) throwable; + int statusCode = httpResponseException.getResponse().getStatusCode(); + Headers headers = toOpenAIHeaders(httpResponseException.getResponse().getHeaders()); + + switch (statusCode) { + case 400: + return BadRequestException.builder().headers(headers).cause(httpResponseException).build(); + + case 401: + return UnauthorizedException.builder().headers(headers).cause(httpResponseException).build(); + + case 403: + return PermissionDeniedException.builder() + .headers(headers) + .cause(httpResponseException) + .build(); + + case 404: + return NotFoundException.builder().headers(headers).cause(httpResponseException).build(); + + case 422: + return UnprocessableEntityException.builder() + .headers(headers) + .cause(httpResponseException) + .build(); + + case 429: + return RateLimitException.builder().headers(headers).cause(httpResponseException).build(); + + case 500: + case 502: + case 503: + case 504: + return InternalServerException.builder() + .statusCode(statusCode) + .headers(headers) + .cause(httpResponseException) + .build(); + + default: + return UnexpectedStatusCodeException.builder() + .statusCode(statusCode) + .headers(headers) + .cause(httpResponseException) + .build(); + } + } else if (throwable instanceof TimeoutException) { + return throwable; + } else { + return new OpenAIException(throwable.getMessage(), throwable.getCause()); + } + } + + /** + * Converts Azure {@link HttpHeaders} to OpenAI {@link Headers}. + */ + private static Headers toOpenAIHeaders(HttpHeaders azureHeaders) { + Headers.Builder builder = Headers.builder(); + azureHeaders.forEach(header -> builder.put(header.getName(), header.getValue())); + return builder.build(); + } + + /** + * 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 && requestBody.contentLength() > 0) { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + requestBody.writeTo(outputStream); + bodyData = BinaryData.fromBytes(outputStream.toByteArray()); + } + + 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( + 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; + } + + /** + * Builds the request context from the given request options. + * @param requestOptions OpenAI SDK request options + * @return Azure request {@link Context} + */ + private static Context buildRequestContext(RequestOptions requestOptions) { + Context context = new Context("azure-eagerly-read-response", true); + Timeout timeout = requestOptions.getTimeout(); + // we use "read" as it's the closes thing to the "response timeout" + if (timeout != null && !timeout.read().isZero() && !timeout.read().isNegative()) { + context = context.addData("azure-response-timeout", timeout.read()); + } + return context; + } + } +} 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..f4490f771020 --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/http/OpenAiRequestUrlBuilder.java @@ -0,0 +1,138 @@ +// 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. + * This class will be deprecated as soon as support is added in the OpenAI SDK as described in this issue: + * + */ +public 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. + * This method will be deprecated as soon as support is added in the OpenAI SDK as described in this issue: + * + * + * @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/ClientTestBase.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/ClientTestBase.java index fde11bc68459..e420342e5bb0 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/ClientTestBase.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/ClientTestBase.java @@ -105,7 +105,9 @@ private void addTestRecordCustomSanitizers() { } private void addCustomMatchers() { - interceptorManager.addMatchers(new CustomMatcher().setExcludedHeaders(Arrays.asList("Cookie", "Set-Cookie"))); + interceptorManager.addMatchers(new CustomMatcher().setExcludedHeaders(Arrays.asList("Cookie", "Set-Cookie", + "X-Stainless-Arch", "X-Stainless-Lang", "X-Stainless-OS", "X-Stainless-OS-Version", + "X-Stainless-Package-Version", "X-Stainless-Runtime", "X-Stainless-Runtime-Version"))); } protected void sleep(long millis) { 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..7b3293ef4084 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,7 +4,11 @@ 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.core.RequestOptions; +import com.openai.core.Timeout; import com.openai.models.conversations.Conversation; import com.openai.models.conversations.ConversationDeletedResource; import com.openai.models.conversations.ConversationUpdateParams; @@ -16,14 +20,17 @@ import org.junit.jupiter.params.provider.MethodSource; import org.junit.platform.commons.util.StringUtils; +import java.time.Duration; import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; import static com.azure.ai.agents.TestUtils.DISPLAY_NAME_WITH_ARGUMENTS; import static org.junit.jupiter.api.Assertions.*; -@Disabled("Disabled for lack of recordings. Needs to be enabled on the Public Preview release.") public class ConversationsAsyncTests extends ClientTestBase { + private final 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 +42,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() @@ -129,4 +137,17 @@ public void basicItemCRUDOperations(HttpClient httpClient, AgentsServiceVersion assertEquals(conversationId, conversationWithDeletedItem.id()); } + + @Disabled("Flaky test") + @ParameterizedTest(name = DISPLAY_NAME_WITH_ARGUMENTS) + @MethodSource("com.azure.ai.agents.TestUtils#getTestParameters") + public void timeoutResponse(HttpClient httpClient, AgentsServiceVersion serviceVersion) { + ConversationsAsyncClient client = getConversationsAsyncClient(httpClient, serviceVersion); + RequestOptions requestOptions + = RequestOptions.builder().timeout(Timeout.builder().read(Duration.ofMillis(1)).build()).build(); + + ExecutionException thrown = assertThrows(ExecutionException.class, + () -> client.getConversationServiceAsync().create(requestOptions).get()); + assertInstanceOf(TimeoutException.class, thrown.getCause()); + } } diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/ConversationsTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/ConversationsTests.java index 5ac54380fd8e..c8f950410454 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/ConversationsTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/ConversationsTests.java @@ -5,6 +5,8 @@ import com.azure.core.http.HttpClient; import com.openai.core.JsonValue; +import com.openai.core.RequestOptions; +import com.openai.core.Timeout; import com.openai.models.conversations.*; import com.openai.models.conversations.items.*; import com.openai.models.responses.EasyInputMessage; @@ -13,10 +15,12 @@ import org.junit.jupiter.params.provider.MethodSource; import org.junit.platform.commons.util.StringUtils; +import java.time.Duration; +import java.util.concurrent.TimeoutException; + import static com.azure.ai.agents.TestUtils.DISPLAY_NAME_WITH_ARGUMENTS; import static org.junit.jupiter.api.Assertions.*; -@Disabled("Disabled for lack of recordings. Needs to be enabled on the Public Preview release.") public class ConversationsTests extends ClientTestBase { @ParameterizedTest(name = DISPLAY_NAME_WITH_ARGUMENTS) @@ -116,4 +120,17 @@ public void basicItemCRUDOperations(HttpClient httpClient, AgentsServiceVersion assertNotNull(conversationWithDeletedItem); assertEquals(conversationId, conversationWithDeletedItem.id()); } + + @Disabled("Flaky test") + @ParameterizedTest(name = DISPLAY_NAME_WITH_ARGUMENTS) + @MethodSource("com.azure.ai.agents.TestUtils#getTestParameters") + public void timeoutResponse(HttpClient httpClient, AgentsServiceVersion serviceVersion) { + ConversationsClient client = getConversationsSyncClient(httpClient, serviceVersion); + + RequestOptions requestOptions + = RequestOptions.builder().timeout(Timeout.builder().read(Duration.ofMillis(1)).build()).build(); + RuntimeException thrown + = assertThrows(RuntimeException.class, () -> client.getConversationService().create(requestOptions)); + assertInstanceOf(TimeoutException.class, thrown.getCause()); + } } 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..59ab92fb7cde --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/implementation/http/HttpClientHelperTests.java @@ -0,0 +1,342 @@ +// 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.HttpPipelineBuilder; +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.Disabled; +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(new HttpPipelineBuilder().httpClient(recordingClient).build()); + + 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(new HttpPipelineBuilder().httpClient(recordingClient).build()); + + 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(new HttpPipelineBuilder().httpClient(recordingClient).build()); + + 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)); + } + } + + @Disabled("Body gets eagerly evaluated. Instrumentation could be wrong.") + @Test + void executeThrowsUncheckedIOExceptionOnBodyBufferingFailure() { + RecordingHttpClient recordingClient + = new RecordingHttpClient(request -> createMockResponse(request, 200, new HttpHeaders(), "")); + com.openai.core.http.HttpClient openAiClient + = HttpClientHelper.mapToOpenAIHttpClient(new HttpPipelineBuilder().httpClient(recordingClient).build()); + + 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(new HttpPipelineBuilder().httpClient(recordingClient).build()); + + 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); + }); + } + + @Disabled("Body gets eagerly evaluated. Instrumentation could be wrong.") + @Test + void executeAsyncPropagatesRequestBuildingErrors() { + RecordingHttpClient recordingClient + = new RecordingHttpClient(request -> createMockResponse(request, 200, new HttpHeaders(), "")); + com.openai.core.http.HttpClient openAiClient + = HttpClientHelper.mapToOpenAIHttpClient(new HttpPipelineBuilder().httpClient(recordingClient).build()); + + 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(new HttpPipelineBuilder().httpClient(failingClient).build()); + + 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); + } + } +}