From 5f09c9a2c944637cb4d72f6c9451dd779607548f Mon Sep 17 00:00:00 2001 From: Mike Guo Date: Fri, 18 Sep 2026 19:01:45 +0800 Subject: [PATCH 1/3] Restore Python SDK parity improvements --- sdk/ai/azure-ai-agents/CHANGELOG.md | 18 + sdk/ai/azure-ai-agents/README.md | 46 ++- .../src/main/java/AgentsCustomizations.java | 123 ++++++ .../azure/ai/agents/AgentsClientBuilder.java | 273 +++++++++++-- .../ai/agents/BetaAgentsAsyncClient.java | 12 + .../com/azure/ai/agents/BetaAgentsClient.java | 12 + .../agents/BetaMemoryStoresAsyncClient.java | 14 + .../ai/agents/BetaMemoryStoresClient.java | 14 + .../AgentsServicePollUtils.java | 125 +++++- .../OperationLocationPollingStrategy.java | 15 +- .../SyncOperationLocationPollingStrategy.java | 14 +- .../ai/agents/implementation/TokenUtils.java | 98 ++++- .../http/AzureHttpResponseAdapter.java | 87 ++++- .../http/FoundryPolicyHelper.java | 60 ++- .../implementation/http/HttpClientHelper.java | 79 +++- .../implementation/utils/FileUtils.java | 23 +- .../ai/agents/models/CodeFileDetails.java | 7 +- .../ai/agents/ConversationsAsyncTests.java | 9 +- .../azure/ai/agents/ConversationsTests.java | 9 +- ...FoundryFeaturesHeaderVerificationTest.java | 362 +++++++++++++++++- .../AgentsServicePollUtilsTest.java | 207 +++++++++- .../agents/implementation/FileUtilsTest.java | 38 ++ .../http/HttpClientHelperTests.java | 144 ++++++- ...omptAgentDefinitionSerializationTests.java | 18 - .../ReasoningDedupSerializationTests.java | 34 -- sdk/ai/azure-ai-projects/CHANGELOG.md | 17 + sdk/ai/azure-ai-projects/README.md | 114 +++++- .../src/main/java/ProjectsCustomizations.java | 98 +++++ .../ai/projects/AIProjectClientBuilder.java | 224 ++++++++++- .../BetaAgentInsightMonitorsAsyncClient.java | 13 + .../BetaAgentInsightMonitorsClient.java | 13 + .../ai/projects/BetaDatasetsAsyncClient.java | 12 + .../azure/ai/projects/BetaDatasetsClient.java | 12 + .../projects/BetaEvaluatorsAsyncClient.java | 12 + .../ai/projects/BetaEvaluatorsClient.java | 12 + .../ai/projects/BetaModelsAsyncClient.java | 71 ++++ .../azure/ai/projects/BetaModelsClient.java | 70 ++++ .../ai/projects/BetaTelemetryAsyncClient.java | 68 ++++ .../ai/projects/BetaTelemetryClient.java | 72 ++++ .../ai/projects/DatasetsAsyncClient.java | 185 ++++++--- .../com/azure/ai/projects/DatasetsClient.java | 120 ++++-- .../azure/ai/projects/EvaluationsHelper.java | 25 ++ .../implementation/FileUploadHelper.java | 158 ++++++++ .../ProjectsServicePollUtils.java | 102 +++++ .../projects/implementation/TokenUtils.java | 98 ++++- .../http/AzureHttpResponseAdapter.java | 87 ++++- .../http/FoundryPolicyHelper.java | 41 +- .../implementation/http/HttpClientHelper.java | 77 +++- .../models/AzureAIEvaluationDataSource.java | 290 ++++++++++++++ .../ai/projects/models/FileUploadOptions.java | 78 ++++ .../projects/models/ModelUploadOptions.java | 198 ++++++++++ .../src/main/java/module-info.java | 2 +- .../com/azure/ai/projects/IndexesSample.java | 20 +- .../com/azure/ai/projects/ReadmeSamples.java | 39 +- .../ai/projects/BetaTelemetryClientTest.java | 101 +++++ .../azure/ai/projects/DatasetsClientTest.java | 45 ++- .../ai/projects/EvaluationsHelperTests.java | 58 ++- .../azure/ai/projects/FileUploadTests.java | 227 +++++++++++ ...FoundryFeaturesHeaderVerificationTest.java | 275 ++++++++++++- .../azure/ai/projects/JobPollingTests.java | 98 +++++ .../http/HttpClientHelperTests.java | 144 ++++++- 61 files changed, 4820 insertions(+), 297 deletions(-) create mode 100644 sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaTelemetryAsyncClient.java create mode 100644 sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaTelemetryClient.java create mode 100644 sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/FileUploadHelper.java create mode 100644 sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/ProjectsServicePollUtils.java create mode 100644 sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/models/AzureAIEvaluationDataSource.java create mode 100644 sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/models/FileUploadOptions.java create mode 100644 sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/models/ModelUploadOptions.java create mode 100644 sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/BetaTelemetryClientTest.java create mode 100644 sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/FileUploadTests.java create mode 100644 sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/JobPollingTests.java diff --git a/sdk/ai/azure-ai-agents/CHANGELOG.md b/sdk/ai/azure-ai-agents/CHANGELOG.md index 2d52918fa011e..2fbc864a10199 100644 --- a/sdk/ai/azure-ai-agents/CHANGELOG.md +++ b/sdk/ai/azure-ai-agents/CHANGELOG.md @@ -9,12 +9,16 @@ responses to Azure SDK `IterableStream` and Reactor `Flux` types. - Added raw JSON WebSocket sends, complete unknown-event payloads, UTF-8 binary JSON reception, configurable receive limits and overflow policies, and opt-in recovery from malformed events. +- Added saved-job polling resumption for memory updates and agent optimization jobs. - Added custom WebSocket close codes and reasons, and per-event synchronous receive timeouts. +- Added synchronous and asynchronous OpenAI factory overloads accepting a native OpenAI options callback for URL, credential, headers, query parameters, and transport overrides. +- Added opt-in HTTP logging defaults through `AZURE_AI_PROJECTS_CONSOLE_LOGGING` and chunk-as-consumed SSE body logging in the OpenAI bridge, using the configured Java logging backend. - Added realtime handshake options for session IDs, structured inputs, API versions, credential scopes, preview features, extra headers and query parameters, and same-host secure connection URL overrides. - Added preview `BetaVoiceAgentsTelephonyClient` and `BetaVoiceAgentsTelephonyAsyncClient` for outbound call jobs and campaign management, including recipient import, validation, publishing, pausing, resuming, and cancellation. - Added preview `BetaVoiceAgentsConversationsClient` and `BetaVoiceAgentsConversationsAsyncClient` for managing persisted voice-agent conversations and their responses, items, and audio content. +- Added session-affinity routing configuration through `AzureCreateResponseOptions.setRoutingConfig(...)`, `RoutingConfiguration`, and `SessionAffinityConfiguration`, with response details exposed by `ModelRouterDetails.getSessionAffinity()`. - Added preview `BetaVoiceAgentWebSocketClient`, `BetaVoiceAgentWebSocketAsyncClient`, `BetaVoiceAgentWebSocketSessionClient`, and `BetaVoiceAgentWebSocketSessionAsyncClient` with typed realtime events, text and PCM16 audio input, response cancellation, function-call output, persisted-conversation options, and @@ -32,11 +36,25 @@ ### Bugs Fixed - Reject insecure voice-agent WebSocket URLs before token acquisition to prevent sending credentials over plaintext. +- Native asynchronous OpenAI factories and `ResponsesAsyncClient` now retrieve Azure tokens asynchronously, including factory-supplied custom OpenAI transports. +- Supplied empty operations with zero usage when completed memory results are omitted or null. +- Omitted multipart request and response bodies from SDK pipeline logging. +- Preserved UTF-8 characters split across reads when logging OpenAI SSE response bodies. - Made synchronous voice-agent receive-buffer overflow signaling atomic across concurrent callbacks. +- Rejected code-upload paths without a file name with an explicit argument error. +- Agent-scoped OpenAI clients now send agent preview features, including model router controls, when + `AgentsClientBuilder.allowPreview(true)` is configured, and use an overridable API-version query parameter. +- Preserved OpenAI credential and user-agent overrides through the default Azure HTTP bridge. User-supplied pipelines retain their authentication policies. + +- Added Java opt-in guidance to `403 preview_feature_required` errors when preview is disabled, preserving the service response and error details. +- Preserved explicitly supplied empty `Foundry-Features` headers instead of replacing them with automatic preview opt-ins. - Fixed polling for telephony operations that return the `cancelled` status spelling. +- Fixed polling for optimization jobs that return the `cancelled` status spelling. ### Other Changes +- Streamed replayable code-upload content when computing SHA-256 to avoid materializing the entire upload in memory. + ## 2.5.0 (2026-09-09) ### Features Added diff --git a/sdk/ai/azure-ai-agents/README.md b/sdk/ai/azure-ai-agents/README.md index 5cd2bd34ce0f8..04e42d9f2c7fe 100644 --- a/sdk/ai/azure-ai-agents/README.md +++ b/sdk/ai/azure-ai-agents/README.md @@ -117,6 +117,36 @@ ResponseService responseService = responsesClient.getResponseService(); ConversationService conversationService = openAIClient.conversations(); ``` +Agent-scoped OpenAI clients opt in to agent preview features when `allowPreview(true)` is configured, +and use the configured service version. Override the defaults with native OpenAI options: + +```java +OpenAIClient agentClient = builder.buildAgentScopedOpenAIClient("agent-name", options -> options + .replaceHeaders("User-Agent", "my-application/1.0") + .replaceQueryParams("api-version", "v1")); +``` + +The callback is also available on the project-scoped and asynchronous OpenAI factory methods. +It supports URL, credential, headers, query parameters, and transport options. Explicit `Foundry-Features` +headers, including empty values and case-insensitive names, are preserved. Custom OpenAI transports bypass +the Azure pipeline. Custom Azure pipelines retain their authentication policies, which may replace +OpenAI credential overrides. The default bridge delegates authentication to OpenAI using the builder's +Entra credential unless overridden. + +Set `AZURE_AI_PROJECTS_CONSOLE_LOGGING=true` to default the builder's HTTP logging to `BODY_AND_HEADERS`. +Native asynchronous OpenAI clients and `ResponsesAsyncClient` retrieve Azure tokens without blocking. Supply custom +native OpenAI transports through the factory options callback to retain this authentication. Replacing the transport +later through native `withOptions(...)` bypasses the authentication adapter and requires an explicit native credential. +Cancelling a native OpenAI operation's future does not guarantee cancellation of pending Azure token retrieval; +the native client's future decorators control cancellation propagation. +Explicit `HttpLogOptions` take precedence, including `HttpLogDetailLevel.NONE` to disable HTTP logging. +Enable INFO output in your Java logging backend (or set `AZURE_LOG_LEVEL=information` for Azure Core's +default logger). This option does not install console handlers or change other libraries' logging levels. +The default OpenAI bridge logs `text/event-stream` response chunks only as the caller reads them; +it does not pre-consume the stream. Other HTTP messages use Azure Core's logging and redaction rules. +Custom transports and custom pipelines retain their own logging configuration. Body logs are not redacted +and can contain prompts, responses, and other sensitive data; enable them only in a trusted environment. + ### Realtime connection options Use `VoiceAgentWebSocketConnectionOptions` with the synchronous or asynchronous beta voice-agent client's @@ -234,9 +264,9 @@ and [AgentOptimizationAsyncSample.java](https://github.com/Azure/azure-sdk-for-j ### Memory item management -`BetaMemoryStoresClient` and `BetaMemoryStoresAsyncClient` manage memory stores and individual memory items. In addition to store-level operations, use `createMemory`, `updateMemory`, `listMemories`, `getMemory`, and `deleteMemory` to manage individual memories. `ListMemoriesOptions` supports filtering by scope and `MemoryItemKind`, including `MemoryItemKind.PROCEDURAL`. See `MemoryStoreItemsSample` and `MemoryStoreItemsAsyncSample` for complete examples. +`BetaMemoryStoresClient` and `BetaMemoryStoresAsyncClient` manage memory stores and individual memory items. In addition to store-level operations, use `createMemory`, `updateMemory`, `listMemories`, `getMemory`, and `deleteMemory` to manage individual memories. `ListMemoriesOptions` supports filtering by scope and `MemoryItemKind`, including `MemoryItemKind.PROCEDURAL`. See [MemoryStoreItemsSample.java](https://github.com/Azure/azure-sdk-for-java/tree/main/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/memory/MemoryStoreItemsSample.java) and [MemoryStoreItemsAsyncSample.java](https://github.com/Azure/azure-sdk-for-java/tree/main/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/memory/MemoryStoreItemsAsyncSample.java) for complete examples. -For conversational memory workflows, use `beginUpdateMemories` to extract memories from conversation items, `searchMemories` to retrieve relevant memories, and `deleteScope` to remove all memories for a scope. See `MemoryStoreAdvancedSample` and `MemoryStoreAdvancedAsyncSample` for complete synchronous and asynchronous examples. +For conversational memory workflows, use `beginUpdateMemories` to extract memories from conversation items, `searchMemories` to retrieve relevant memories, and `deleteScope` to remove all memories for a scope. See [MemoryStoreAdvancedSample.java](https://github.com/Azure/azure-sdk-for-java/tree/main/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/memory/MemoryStoreAdvancedSample.java) and [MemoryStoreAdvancedAsyncSample.java](https://github.com/Azure/azure-sdk-for-java/tree/main/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/memory/MemoryStoreAdvancedAsyncSample.java) for complete synchronous and asynchronous examples. ### Using OpenAI's official library @@ -518,7 +548,7 @@ MemorySearchPreviewTool tool = new MemorySearchPreviewTool(memoryStore.getName() .setUpdateDelaySeconds(1); ``` -See the full sample in [MemorySearchSync.java](https://github.com/Azure/azure-sdk-for-java/tree/main/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/tools/MemorySearchSync.java) showing how to create an agent with a memory store and use it across multiple conversations. +See the full samples in [MemorySearchSync.java](https://github.com/Azure/azure-sdk-for-java/tree/main/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/tools/MemorySearchSync.java) and [MemorySearchAsync.java](https://github.com/Azure/azure-sdk-for-java/tree/main/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/tools/MemorySearchAsync.java), which show how to create an agent with a memory store and use it across multiple conversations. --- @@ -1095,6 +1125,16 @@ All realtime examples require `FOUNDRY_PROJECT_ENDPOINT` and optionally use `FOU The live audio example requires a Java Sound-compatible microphone and speaker. It streams signed, little-endian, mono PCM16 audio at 24 kHz. These examples use WebSocket transport. Although the generated protocol models include WebRTC signaling events, the Java client does not provide a WebRTC peer connection or media implementation. +### Additional end-to-end samples + +All agent samples use `FOUNDRY_PROJECT_ENDPOINT`. Prompt-agent samples also use `FOUNDRY_MODEL_NAME`. + +- **Agent lifecycle and structured inputs:** [CreateAgent.java](https://github.com/Azure/azure-sdk-for-java/tree/main/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/agents/CreateAgent.java), [GetAgent.java](https://github.com/Azure/azure-sdk-for-java/tree/main/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/agents/GetAgent.java), and [CreateResponseWithStructuredInput.java](https://github.com/Azure/azure-sdk-for-java/tree/main/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/CreateResponseWithStructuredInput.java). +- **Optimization jobs:** the [optimization samples](https://github.com/Azure/azure-sdk-for-java/tree/main/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/optimization) cover SDK polling, application-managed polling, cancellation, listing, retrieval, and deletion. +- **Advanced tools:** additional samples cover structured inputs, generated-file download, File Search streaming, non-preview Web Search, custom search, and end-to-end toolbox search. + +--- + ### Service API versions The client library targets the latest service API version by default. diff --git a/sdk/ai/azure-ai-agents/customizations/src/main/java/AgentsCustomizations.java b/sdk/ai/azure-ai-agents/customizations/src/main/java/AgentsCustomizations.java index bdbb5418c80d3..c3b7a3cf2a5be 100644 --- a/sdk/ai/azure-ai-agents/customizations/src/main/java/AgentsCustomizations.java +++ b/sdk/ai/azure-ai-agents/customizations/src/main/java/AgentsCustomizations.java @@ -9,11 +9,16 @@ import com.github.javaparser.ast.body.FieldDeclaration; import com.github.javaparser.ast.body.MethodDeclaration; import com.github.javaparser.ast.body.TypeDeclaration; +import com.github.javaparser.ast.body.VariableDeclarator; import com.github.javaparser.ast.expr.AnnotationExpr; import com.github.javaparser.ast.expr.AssignExpr; +import com.github.javaparser.ast.expr.MethodCallExpr; import com.github.javaparser.ast.expr.NormalAnnotationExpr; +import com.github.javaparser.ast.expr.ObjectCreationExpr; import com.github.javaparser.ast.expr.StringLiteralExpr; +import com.github.javaparser.ast.stmt.BlockStmt; import com.github.javaparser.ast.stmt.ExpressionStmt; +import com.github.javaparser.ast.stmt.IfStmt; import java.io.IOException; import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; @@ -33,6 +38,9 @@ public class AgentsCustomizations extends Customization { @Override public void customize(LibraryCustomization libraryCustomization, Logger logger) { + libraryCustomization.getClass("com.azure.ai.agents", "AgentsClientBuilder").customizeAst(ast -> + customizeBuilder(ast.getClassByName("AgentsClientBuilder") + .orElseThrow(() -> new IllegalStateException("Generated AgentsClientBuilder was not found.")))); renameImageGenToolSize(libraryCustomization, logger); modifyPollingStrategies(libraryCustomization, logger); // makeRealtimeMessageDiscriminatorsFinal(libraryCustomization); @@ -41,6 +49,89 @@ public void customize(LibraryCustomization libraryCustomization, Logger logger) annotateBetaFields(libraryCustomization, loadBetaAnnotations(logger), logger); } + private static void customizeBuilder(ClassOrInterfaceDeclaration builder) { + MethodDeclaration buildInnerClient = builder.getMethodsByName("buildInnerClient").stream() + .filter(method -> method.getParameters().isEmpty()) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Generated buildInnerClient was not found.")); + MethodDeclaration previewBuildInnerClient = buildInnerClient.clone(); + previewBuildInnerClient.setName("createInnerClientWithPreviewFeatures"); + previewBuildInnerClient.addParameter("String", "previewFeatures"); + List localPipelines = previewBuildInnerClient.findAll(VariableDeclarator.class).stream() + .filter(variable -> "localPipeline".equals(variable.getNameAsString())) + .collect(java.util.stream.Collectors.toList()); + if (localPipelines.size() != 1) { + throw new IllegalStateException("Expected one generated localPipeline variable."); + } + Node localPipelineParent = localPipelines.get(0) + .getParentNode() + .flatMap(Node::getParentNode) + .orElseThrow(() -> new IllegalStateException("Generated localPipeline statement was not found.")); + if (!(localPipelineParent instanceof ExpressionStmt)) { + throw new IllegalStateException("Generated localPipeline parent was not an expression statement."); + } + ExpressionStmt localPipelineStatement = (ExpressionStmt) localPipelineParent; + BlockStmt previewBody = previewBuildInnerClient.getBody() + .orElseThrow(() -> new IllegalStateException("Generated buildInnerClient body was not found.")); + int localPipelineIndex = previewBody.getStatements().indexOf(localPipelineStatement); + if (localPipelineIndex < 0) { + throw new IllegalStateException("Generated localPipeline statement was not in buildInnerClient."); + } + previewBody.getStatements().remove(localPipelineIndex); + previewBody.getStatements().add(localPipelineIndex, + StaticJavaParser.parseStatement("HttpPipeline localPipeline;")); + previewBody.getStatements().add(localPipelineIndex + 1, StaticJavaParser.parseStatement( + "if (CoreUtils.isNullOrEmpty(previewFeatures)) {" + + " localPipeline = pipeline != null ? pipeline : createHttpPipeline();" + + " localPipeline = FoundryPolicyHelper.prependPolicy(localPipeline," + + " FoundryPolicyHelper.createPreviewErrorPolicy(allowPreview));" + + " } else { localPipeline = resolvePipeline(previewFeatures); }")); + List existingPreviewBuilds + = new ArrayList<>(builder.getMethodsByName("createInnerClientWithPreviewFeatures")); + existingPreviewBuilds.forEach(MethodDeclaration::remove); + builder.addMember(previewBuildInnerClient); + + MethodDeclaration generatedPipeline = builder.getMethodsByName("createHttpPipeline").stream() + .filter(method -> method.getParameters().isEmpty()) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Generated createHttpPipeline was not found.")); + List loggingOptions = generatedPipeline.findAll(VariableDeclarator.class).stream() + .filter(variable -> "localHttpLogOptions".equals(variable.getNameAsString())) + .collect(java.util.stream.Collectors.toList()); + if (loggingOptions.size() != 1) { + throw new IllegalStateException("Expected one generated localHttpLogOptions variable."); + } + loggingOptions.get(0).setInitializer("resolveHttpLogOptions()"); + List loggingPolicies = generatedPipeline.findAll(ObjectCreationExpr.class).stream() + .filter(expression -> "HttpLoggingPolicy".equals(expression.getType().getNameAsString())) + .collect(java.util.stream.Collectors.toList()); + if (loggingPolicies.size() != 1) { + throw new IllegalStateException("Expected one generated HttpLoggingPolicy construction."); + } + ObjectCreationExpr loggingPolicy = loggingPolicies.get(0); + MethodCallExpr customLoggingPolicy = new MethodCallExpr("HttpClientHelper.createLoggingPolicy"); + loggingPolicy.getArguments().forEach(argument -> customLoggingPolicy.addArgument(argument.clone())); + loggingPolicy.replace(customLoggingPolicy); + builder.findCompilationUnit().ifPresent(unit -> unit.getImports().removeIf(declaration -> + "com.azure.core.http.policy.HttpLoggingPolicy".equals(declaration.getNameAsString()))); + + MethodDeclaration openAIPipeline = generatedPipeline.clone(); + openAIPipeline.setName("createOpenAIHttpPipeline"); + List authenticationChecks = openAIPipeline.findAll(IfStmt.class).stream() + .filter(statement -> statement.getThenStmt().toString().contains("BearerTokenAuthenticationPolicy")) + .collect(java.util.stream.Collectors.toList()); + if (authenticationChecks.size() != 1) { + throw new IllegalStateException("Expected one generated bearer-token authentication check."); + } + authenticationChecks.get(0).remove(); + + List existingOpenAIPipelines + = new ArrayList<>(builder.getMethodsByName("createOpenAIHttpPipeline")); + existingOpenAIPipelines.forEach(MethodDeclaration::remove); + builder.addMember(openAIPipeline); + + } + private static final String MODELS_PACKAGE = "com.azure.ai.agents.models"; private static final String UNION_MARKER = "AI Tooling: union type"; @@ -657,6 +748,38 @@ private void modifyPollingStrategies(LibraryCustomization customization, Logger customization.getClass("com.azure.ai.agents.implementation", "SyncOperationLocationPollingStrategy") .customizeAst(ast -> ast.getClassByName("SyncOperationLocationPollingStrategy") .ifPresent(clazz -> clazz.addMember(StaticJavaParser.parseMethodDeclaration("@Override public PollResponse poll(PollingContext pollingContext, TypeReference pollResponseType) { return AgentsServicePollUtils.remapStatus(super.poll(pollingContext, pollResponseType)); }")))); + + customizePollingResult(customization, "OperationLocationPollingStrategy"); + customizePollingResult(customization, "SyncOperationLocationPollingStrategy"); + } + + private static void customizePollingResult(LibraryCustomization customization, String className) { + customization.getClass("com.azure.ai.agents.implementation", className).customizeAst(ast -> { + ClassOrInterfaceDeclaration clazz = ast.getClassByName(className) + .orElseThrow(() -> new IllegalStateException("Generated " + className + " was not found.")); + MethodDeclaration getResult = clazz.getMethodsByName("getResult").get(0); + String statusChecks = className.startsWith("Sync") + ? "if (pollingContext.getLatestResponse().getStatus() == LongRunningOperationStatus.FAILED) {" + + " throw LOGGER.logExceptionAsError(new AzureException(\"Long running operation failed.\")); }" + + "if (pollingContext.getLatestResponse().getStatus() == LongRunningOperationStatus.USER_CANCELLED) {" + + " throw LOGGER.logExceptionAsError(new AzureException(\"Long running operation cancelled.\")); }" + : "if (pollingContext.getLatestResponse().getStatus() == LongRunningOperationStatus.FAILED) {" + + " return Mono.error(new AzureException(\"Long running operation failed.\")); }" + + "if (pollingContext.getLatestResponse().getStatus() == LongRunningOperationStatus.USER_CANCELLED) {" + + " return Mono.error(new AzureException(\"Long running operation cancelled.\")); }"; + String deserialize = className.startsWith("Sync") + ? "Map pollResult = PollingUtils.deserializeResponseSync(latestResponseBody, serializer," + + " PollingUtils.POST_POLL_RESULT_TYPE_REFERENCE);" + + "return PollingUtils.deserializeResponseSync(AgentsServicePollUtils.getFinalResultBody(" + + "pollResult, propertyName, resultType), serializer, resultType);" + : "return PollingUtils.deserializeResponse(latestResponseBody, serializer," + + " PollingUtils.POST_POLL_RESULT_TYPE_REFERENCE).flatMap(value -> PollingUtils.deserializeResponse(" + + "AgentsServicePollUtils.getFinalResultBody(value, propertyName, resultType), serializer, resultType))" + + ".switchIfEmpty(Mono.error(new AzureException(\"Cannot get final result\")));"; + getResult.setBody(StaticJavaParser.parseBlock("{" + statusChecks + "if (propertyName != null) {" + + "BinaryData latestResponseBody = BinaryData.fromString(pollingContext.getData(PollingUtils.POLL_RESPONSE_BODY));" + + deserialize + "} else { return super.getResult(pollingContext, resultType); }}")); + }); } private void annotateBetaClients(LibraryCustomization customization, Logger logger) { 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 8e7bede719554..26b62be3e3570 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 @@ -28,8 +28,8 @@ import com.azure.core.http.policy.AddHeadersFromContextPolicy; import com.azure.core.http.policy.AddHeadersPolicy; import com.azure.core.http.policy.BearerTokenAuthenticationPolicy; +import com.azure.core.http.policy.HttpLogDetailLevel; import com.azure.core.http.policy.HttpLogOptions; -import com.azure.core.http.policy.HttpLoggingPolicy; import com.azure.core.http.policy.HttpPipelinePolicy; import com.azure.core.http.policy.HttpPolicyProviders; import com.azure.core.http.policy.RequestIdPolicy; @@ -43,7 +43,6 @@ import com.azure.core.util.builder.ClientBuilderUtil; import com.azure.core.util.logging.ClientLogger; import com.azure.core.util.serializer.JacksonAdapter; -import com.openai.azure.AzureOpenAIServiceVersion; import com.openai.azure.AzureUrlPathMode; import com.openai.client.OpenAIClient; import com.openai.client.OpenAIClientAsync; @@ -56,6 +55,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.function.Consumer; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -92,10 +92,12 @@ public final class AgentsClientBuilder @Generated private static final Map PROPERTIES = CoreUtils.getProperties("azure-ai-agents.properties"); - private static final String AGENT_PREVIEW_FEATURES = Stream - .concat(Arrays.stream(AgentDefinitionOptInKeys.values()).map(AgentDefinitionOptInKeys::toString), - Stream.of(FoundryFeaturesOptInKeys.AGENTS_OPTIMIZATION_V2_PREVIEW.toString())) - .collect(Collectors.joining(",")); + private static final String AGENT_PREVIEW_FEATURES + = Stream + .concat(Arrays.stream(AgentDefinitionOptInKeys.values()).map(AgentDefinitionOptInKeys::toString), + Stream.of(FoundryFeaturesOptInKeys.AGENTS_OPTIMIZATION_V2_PREVIEW.toString(), + FoundryFeaturesOptInKeys.MODEL_ROUTER_CONTROLS_V1_PREVIEW.toString())) + .collect(Collectors.joining(",")); private static final String MEMORY_STORES_PREVIEW_FEATURES = FoundryFeaturesOptInKeys.MEMORY_STORES_V1_PREVIEW.toString(); @@ -325,11 +327,25 @@ private AgentsClientImpl buildInnerClient() { } private AgentsClientImpl buildInnerClient(String previewFeatures) { + return createInnerClientWithPreviewFeatures(previewFeatures); + } + + /** + * Builds an instance of AgentsClientImpl with the provided parameters. + * + * @return an instance of AgentsClientImpl. + */ + @Generated + private AgentsClientImpl createInnerClientWithPreviewFeatures(String previewFeatures) { this.validateClient(); + HttpPipeline localPipeline; if (CoreUtils.isNullOrEmpty(previewFeatures)) { - return buildInnerClient(); + localPipeline = pipeline != null ? pipeline : createHttpPipeline(); + localPipeline = FoundryPolicyHelper.prependPolicy(localPipeline, + FoundryPolicyHelper.createPreviewErrorPolicy(allowPreview)); + } else { + localPipeline = resolvePipeline(previewFeatures); } - HttpPipeline localPipeline = resolvePipeline(previewFeatures); AgentsServiceVersion localServiceVersion = (serviceVersion != null) ? serviceVersion : AgentsServiceVersion.getLatest(); AgentsClientImpl client = new AgentsClientImpl(localPipeline, JacksonAdapter.createDefaultSerializerAdapter(), @@ -348,7 +364,7 @@ private void validateClient() { private HttpPipeline createHttpPipeline() { Configuration buildConfiguration = (configuration == null) ? Configuration.getGlobalConfiguration() : configuration; - HttpLogOptions localHttpLogOptions = this.httpLogOptions == null ? new HttpLogOptions() : this.httpLogOptions; + HttpLogOptions localHttpLogOptions = resolveHttpLogOptions(); ClientOptions localClientOptions = this.clientOptions == null ? new ClientOptions() : this.clientOptions; List policies = new ArrayList<>(); String clientName = PROPERTIES.getOrDefault(SDK_NAME, "UnknownName"); @@ -374,7 +390,7 @@ private HttpPipeline createHttpPipeline() { .filter(p -> p.getPipelinePosition() == HttpPipelinePosition.PER_RETRY) .forEach(p -> policies.add(p)); HttpPolicyProviders.addAfterRetryPolicies(policies); - policies.add(new HttpLoggingPolicy(localHttpLogOptions)); + policies.add(HttpClientHelper.createLoggingPolicy(localHttpLogOptions)); HttpPipeline httpPipeline = new HttpPipelineBuilder().policies(policies.toArray(new HttpPipelinePolicy[0])) .httpClient(httpClient) .clientOptions(localClientOptions) @@ -389,7 +405,46 @@ private HttpPipeline resolvePipeline(String foundryFeatures) { } private com.openai.core.http.HttpClient createOpenAIHttpClient(String foundryFeatures) { - return HttpClientHelper.mapToOpenAIHttpClient(resolvePipeline(foundryFeatures)); + HttpPipeline localPipeline = pipeline != null ? pipeline : createOpenAIHttpPipeline(); + return HttpClientHelper.mapToOpenAIHttpClient( + FoundryPolicyHelper.prependPolicy(localPipeline, + FoundryPolicyHelper.createFoundryFeaturesPolicy(foundryFeatures)), + resolveHttpLogOptions().getLogLevel().shouldLogBody()); + } + + private HttpLogOptions resolveHttpLogOptions() { + if (httpLogOptions != null) { + return httpLogOptions; + } + Configuration buildConfiguration + = configuration == null ? Configuration.getGlobalConfiguration() : configuration; + HttpLogOptions options = new HttpLogOptions(); + if ("true".equalsIgnoreCase(buildConfiguration.get("AZURE_AI_PROJECTS_CONSOLE_LOGGING"))) { + options.setLogLevel(HttpLogDetailLevel.BODY_AND_HEADERS); + } + return options; + } + + /** + * Configures the native OpenAI client to use the Azure HTTP pipeline, including any required Foundry preview + * features, and combines the Azure SDK and native OpenAI user-agent values for telemetry. + * + * @param options the native OpenAI client options to configure. + * @param foundryFeatures the comma-separated Foundry preview features to enable, or {@code null} for none. + */ + private void configureOpenAIOptions(com.openai.core.ClientOptions.Builder options, String foundryFeatures) { + // Route native OpenAI requests through the Azure pipeline and apply any required preview feature policy. + options.httpClient(createOpenAIHttpClient(foundryFeatures)); + // Preserve the native OpenAI identity while adding the Azure SDK identity used for telemetry. + String openAIUserAgent = String.join(" ", options.build().headers().values("User-Agent")); + Configuration buildConfiguration + = configuration == null ? Configuration.getGlobalConfiguration() : configuration; + String applicationId = CoreUtils.getApplicationId(clientOptions == null ? new ClientOptions() : clientOptions, + httpLogOptions == null ? new HttpLogOptions() : httpLogOptions); + String userAgent + = UserAgentUtil.toUserAgentString(applicationId, PROPERTIES.getOrDefault(SDK_NAME, "azure-ai-agents"), + PROPERTIES.getOrDefault(SDK_VERSION, "unknown"), buildConfiguration); + options.replaceHeaders("User-Agent", openAIUserAgent.isEmpty() ? userAgent : userAgent + " " + openAIUserAgent); } /** @@ -408,8 +463,17 @@ public ResponsesClient buildResponsesClient() { * @return an instance of ResponsesAsyncClient */ public ResponsesAsyncClient buildResponsesAsyncClient() { - return new ResponsesAsyncClient(getOpenAIAsyncClientBuilder(null).build() - .withOptions(optionBuilder -> optionBuilder.httpClient(createOpenAIHttpClient(null)))); + // Use a marker credential during native client construction so Azure tokens can be acquired asynchronously + // at the transport boundary instead of blocking the asynchronous request path with getTokenSync(). + TokenUtils.AsyncAuthentication authentication + = new TokenUtils.AsyncAuthentication(tokenCredential, DEFAULT_SCOPES); + return new ResponsesAsyncClient( + getOpenAIAsyncClientBuilder(null, authentication.getCredential()).build().withOptions(options -> { + // Install the Azure-backed transport first, then wrap that final transport with asynchronous + // authentication so each request receives a current Azure bearer token before it is sent. + options.httpClient(createOpenAIHttpClient(null)); + authentication.configure(options); + })); } /** @@ -419,8 +483,23 @@ public ResponsesAsyncClient buildResponsesAsyncClient() { * @return an instance of OpenAIClient */ public OpenAIClient buildOpenAIClient() { + // A null agent name selects the project-scoped OpenAI endpoint rather than an agent-specific endpoint. + // The original implementation only replaced the HTTP transport. Because the native OpenAI user agent was + // already present, the Azure pipeline did not add the Azure SDK identity required for telemetry. Configure + // both the Azure transport and the combined user agent; null indicates that no preview features are needed. return getOpenAIClientBuilder(null).build() - .withOptions(optionBuilder -> optionBuilder.httpClient(createOpenAIHttpClient(null))); + .withOptions(optionBuilder -> configureOpenAIOptions(optionBuilder, null)); + } + + /** + * Builds a project-scoped OpenAI client with caller overrides applied after the defaults. + * + * @param configure callback for OpenAI options, including URL, credentials, headers, query, and transport. + * Custom pipelines retain their own authentication policies. Custom transports bypass the Azure pipeline. + * @return the configured OpenAI client. + */ + public OpenAIClient buildOpenAIClient(Consumer configure) { + return buildOpenAIClient().withOptions(Objects.requireNonNull(configure, "'configure' cannot be null.")); } /** @@ -435,9 +514,25 @@ public OpenAIClient buildAgentScopedOpenAIClient(String agentName) { if (CoreUtils.isNullOrEmpty(agentName)) { throw LOGGER.logExceptionAsError(new IllegalArgumentException("'agentName' cannot be empty.")); } + // Previously, this client only replaced the native HTTP transport. Because the native OpenAI user agent was + // already present, the Azure pipeline could not add the Azure SDK identity required for telemetry. Centralize + // the setup to install the Azure transport and explicitly combine both user agents. return getOpenAIClientBuilder(agentName).build() - .withOptions(optionBuilder -> optionBuilder - .httpClient(createOpenAIHttpClient(allowPreview ? AGENT_PREVIEW_FEATURES : null))); + .withOptions( + optionBuilder -> configureOpenAIOptions(optionBuilder, allowPreview ? AGENT_PREVIEW_FEATURES : null)); + } + + /** + * Builds an agent-scoped OpenAI client with caller overrides. + * + * @param agentName the name of the agent. Must not be null or empty. + * @param configure callback applied after the defaults; see {@link #buildOpenAIClient(Consumer)}. + * @return the configured OpenAI client. + */ + public OpenAIClient buildAgentScopedOpenAIClient(String agentName, + Consumer configure) { + return buildAgentScopedOpenAIClient(agentName) + .withOptions(Objects.requireNonNull(configure, "'configure' cannot be null.")); } /** @@ -447,8 +542,25 @@ public OpenAIClient buildAgentScopedOpenAIClient(String agentName) { * @return an instance of OpenAIAsyncClient */ public OpenAIClientAsync buildOpenAIAsyncClient() { - return getOpenAIAsyncClientBuilder(null).build() - .withOptions(optionBuilder -> optionBuilder.httpClient(createOpenAIHttpClient(null))); + // Previously, the async client used the native builder's synchronous token supplier, which could call + // getTokenSync() and block the asynchronous request path. Delegate to the shared async helper so Azure tokens + // are acquired asynchronously at the transport boundary. A null agent name selects the project endpoint, and + // the no-op callback keeps the standard Azure pipeline, telemetry, and authentication configuration unchanged. + return createOpenAIAsyncClient(null, options -> { + }); + } + + /** + * Builds an asynchronous project-scoped OpenAI client with caller overrides. + * + * Azure tokens are retrieved asynchronously before transport execution. Supply custom transports here; + * replacing the native transport later bypasses Azure authentication and requires an explicit native credential. + * + * @param configure callback applied after the defaults; see {@link #buildOpenAIClient(Consumer)}. + * @return the configured asynchronous OpenAI client. + */ + public OpenAIClientAsync buildOpenAIAsyncClient(Consumer configure) { + return createOpenAIAsyncClient(null, Objects.requireNonNull(configure, "'configure' cannot be null.")); } /** @@ -463,9 +575,41 @@ public OpenAIClientAsync buildAgentScopedOpenAIAsyncClient(String agentName) { if (CoreUtils.isNullOrEmpty(agentName)) { throw LOGGER.logExceptionAsError(new IllegalArgumentException("'agentName' cannot be empty.")); } - return getOpenAIAsyncClientBuilder(agentName).build() - .withOptions(optionBuilder -> optionBuilder - .httpClient(createOpenAIHttpClient(allowPreview ? AGENT_PREVIEW_FEATURES : null))); + // Use the shared async helper to fix the previous blocking authentication path. It performs three ordered + // steps: (1) installs the Azure transport, optional agent preview features, and combined user-agent telemetry; + // (2) applies caller-provided option overrides; and (3) wraps the final transport with asynchronous Azure + // authentication so token acquisition does not call getTokenSync() on the asynchronous request path. + return createOpenAIAsyncClient(agentName, options -> { + }); + } + + /** + * Builds an asynchronous agent-scoped OpenAI client with caller overrides. + * + * Supply custom transports through this callback so asynchronous Azure authentication remains installed. + * + * @param agentName the name of the agent. Must not be null or empty. + * @param configure callback applied after the defaults; see {@link #buildOpenAIClient(Consumer)}. + * @return the configured asynchronous OpenAI client. + * @throws IllegalArgumentException if agentName is null or empty. + */ + public OpenAIClientAsync buildAgentScopedOpenAIAsyncClient(String agentName, + Consumer configure) { + if (CoreUtils.isNullOrEmpty(agentName)) { + throw LOGGER.logExceptionAsError(new IllegalArgumentException("'agentName' cannot be empty.")); + } + return createOpenAIAsyncClient(agentName, Objects.requireNonNull(configure, "'configure' cannot be null.")); + } + + private OpenAIClientAsync createOpenAIAsyncClient(String agentName, + Consumer configure) { + TokenUtils.AsyncAuthentication authentication + = new TokenUtils.AsyncAuthentication(tokenCredential, DEFAULT_SCOPES); + return getOpenAIAsyncClientBuilder(agentName, authentication.getCredential()).build().withOptions(options -> { + configureOpenAIOptions(options, agentName != null && allowPreview ? AGENT_PREVIEW_FEATURES : null); + configure.accept(options); + authentication.configure(options); + }); } private String getDefaultBaseUrl() { @@ -478,7 +622,19 @@ private String getAgentEndpointBaseUrl(String agentName) { return base + "/agents/" + agentName + "/endpoint/protocols/openai"; } + /** + * Creates the native synchronous OpenAI builder and configures synchronous Azure token authentication. + *

+ * Unlike {@link #getOpenAIAsyncClientBuilder(String, com.openai.credential.Credential)}, this helper can use a + * bearer-token supplier directly because calls made by the resulting client are synchronous. The async helper uses + * a marker credential and resolves the real token at the transport boundary to avoid blocking its request path. + * + * @param agentName agent name, or {@code null} for the project-scoped endpoint. + * @return the configured native synchronous builder. + */ private OpenAIOkHttpClient.Builder getOpenAIClientBuilder(String agentName) { + // The supplier obtains an Azure token when the synchronous OpenAI client needs authentication. This path may + // block while resolving the token, which is acceptable here but is intentionally avoided by the async helper. OpenAIOkHttpClient.Builder builder = OpenAIOkHttpClient.builder() .credential( BearerTokenCredential.create(TokenUtils.getBearerTokenSupplier(this.tokenCredential, DEFAULT_SCOPES))); @@ -487,27 +643,47 @@ private OpenAIOkHttpClient.Builder getOpenAIClientBuilder(String agentName) { builder.baseUrl(getDefaultBaseUrl()); } else { builder.baseUrl(getAgentEndpointBaseUrl(agentName)); - // The agent endpoint exposes a single service version, addressed as 'v1'. It must be - // sent explicitly; UNIFIED mode alone omits api-version, which the endpoint rejects. - builder.azureServiceVersion(AzureOpenAIServiceVersion.fromString(AgentsServiceVersion.V1.getVersion())); + if (allowPreview) { + builder.putHeader("Foundry-Features", AGENT_PREVIEW_FEATURES); + } + // Agent-scoped endpoints require an explicit API version. Without this query parameter, the service may + // reject the request or route it using an unintended version; honor the caller's version when configured. + AgentsServiceVersion localVersion + = serviceVersion == null ? AgentsServiceVersion.getLatest() : serviceVersion; + builder.putQueryParam("api-version", localVersion.getVersion()); } // We set the builder retries to 0 to avoid conflicts with the retry policy added through the HttpPipeline. builder.maxRetries(0); return builder; } - private OpenAIOkHttpClientAsync.Builder getOpenAIAsyncClientBuilder(String agentName) { - OpenAIOkHttpClientAsync.Builder builder = OpenAIOkHttpClientAsync.builder() - .credential( - BearerTokenCredential.create(TokenUtils.getBearerTokenSupplier(this.tokenCredential, DEFAULT_SCOPES))); + /** + * Creates the native asynchronous builder with its initial authentication credential. + * + * @param agentName agent name, or {@code null} for the project-scoped endpoint. + * @param credential native credential used during client construction. The default async path supplies a unique + * marker credential that {@link TokenUtils.AsyncAuthentication} recognizes and replaces with an asynchronously + * acquired Azure bearer token at the transport boundary. + * @return the configured native asynchronous builder. + */ + private OpenAIOkHttpClientAsync.Builder getOpenAIAsyncClientBuilder(String agentName, + com.openai.credential.Credential credential) { + // The OpenAI builder requires a credential up front. AsyncAuthentication passes a marker here, then wraps the + // final transport so the marker is never sent: each request receives a real Azure token asynchronously. + OpenAIOkHttpClientAsync.Builder builder = OpenAIOkHttpClientAsync.builder().credential(credential); builder.azureUrlPath(AzureUrlPathMode.UNIFIED); if (CoreUtils.isNullOrEmpty(agentName)) { builder.baseUrl(getDefaultBaseUrl()); } else { builder.baseUrl(getAgentEndpointBaseUrl(agentName)); - // The agent endpoint exposes a single service version, addressed as 'v1'. It must be - // sent explicitly; UNIFIED mode alone omits api-version, which the endpoint rejects. - builder.azureServiceVersion(AzureOpenAIServiceVersion.fromString(AgentsServiceVersion.V1.getVersion())); + if (allowPreview) { + builder.putHeader("Foundry-Features", AGENT_PREVIEW_FEATURES); + } + // Agent-scoped endpoints require an explicit API version. Without this query parameter, the service may + // reject the request or route it using an unintended version; honor the caller's version when configured. + AgentsServiceVersion localVersion + = serviceVersion == null ? AgentsServiceVersion.getLatest() : serviceVersion; + builder.putQueryParam("api-version", localVersion.getVersion()); } // We set the builder retries to 0 to avoid conflicts with the retry policy added through the HttpPipeline. builder.maxRetries(0); @@ -859,4 +1035,39 @@ private BetaVoiceAgentWebSocketAsyncClient buildBetaVoiceAgentWebSocketAsyncClie private BetaVoiceAgentWebSocketClient buildBetaVoiceAgentWebSocketClient() { return new BetaVoiceAgentWebSocketClient(createVoiceAgentWebSocketConfiguration()); } + + @Generated + private HttpPipeline createOpenAIHttpPipeline() { + Configuration buildConfiguration + = (configuration == null) ? Configuration.getGlobalConfiguration() : configuration; + HttpLogOptions localHttpLogOptions = resolveHttpLogOptions(); + ClientOptions localClientOptions = this.clientOptions == null ? new ClientOptions() : this.clientOptions; + List policies = new ArrayList<>(); + String clientName = PROPERTIES.getOrDefault(SDK_NAME, "UnknownName"); + String clientVersion = PROPERTIES.getOrDefault(SDK_VERSION, "UnknownVersion"); + String applicationId = CoreUtils.getApplicationId(localClientOptions, localHttpLogOptions); + policies.add(new UserAgentPolicy(applicationId, clientName, clientVersion, buildConfiguration)); + policies.add(new RequestIdPolicy()); + policies.add(new AddHeadersFromContextPolicy()); + HttpHeaders headers = CoreUtils.createHttpHeadersFromClientOptions(localClientOptions); + if (headers != null) { + policies.add(new AddHeadersPolicy(headers)); + } + this.pipelinePolicies.stream() + .filter(p -> p.getPipelinePosition() == HttpPipelinePosition.PER_CALL) + .forEach(p -> policies.add(p)); + HttpPolicyProviders.addBeforeRetryPolicies(policies); + policies.add(ClientBuilderUtil.validateAndGetRetryPolicy(retryPolicy, retryOptions, new RetryPolicy())); + policies.add(new AddDatePolicy()); + this.pipelinePolicies.stream() + .filter(p -> p.getPipelinePosition() == HttpPipelinePosition.PER_RETRY) + .forEach(p -> policies.add(p)); + HttpPolicyProviders.addAfterRetryPolicies(policies); + policies.add(HttpClientHelper.createLoggingPolicy(localHttpLogOptions)); + HttpPipeline httpPipeline = new HttpPipelineBuilder().policies(policies.toArray(new HttpPipelinePolicy[0])) + .httpClient(httpClient) + .clientOptions(localClientOptions) + .build(); + return httpPipeline; + } } diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaAgentsAsyncClient.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaAgentsAsyncClient.java index 1ee141c74b5f8..6f225b353c410 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaAgentsAsyncClient.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaAgentsAsyncClient.java @@ -39,6 +39,18 @@ @Beta(warningText = "This class is in preview and may change in future releases.") public final class BetaAgentsAsyncClient { + /** + * Resumes an existing optimization job. Use the cancellation API to cancel the job. + * + * @param jobId saved optimization job ID. + * @return the resumed poller. + */ + public PollerFlux resumeOptimizationJob(String jobId) { + return com.azure.ai.agents.implementation.AgentsServicePollUtils.resumeAsync( + () -> getOptimizationJobWithResponse(jobId, new RequestOptions()), AgentOptimizationJob.class, + AgentOptimizationJobResult.class); + } + @Generated private final BetaAgentsImpl serviceClient; diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaAgentsClient.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaAgentsClient.java index 3e63f6123dd4e..e29a5766a6004 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaAgentsClient.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaAgentsClient.java @@ -33,6 +33,18 @@ @Beta(warningText = "This class is in preview and may change in future releases.") public final class BetaAgentsClient { + /** + * Resumes an existing optimization job. Use the cancellation API to cancel the job. + * + * @param jobId saved optimization job ID. + * @return the resumed poller. + */ + public SyncPoller resumeOptimizationJob(String jobId) { + return com.azure.ai.agents.implementation.AgentsServicePollUtils.resume( + () -> getOptimizationJobWithResponse(jobId, new RequestOptions()), AgentOptimizationJob.class, + AgentOptimizationJobResult.class); + } + @Generated private final BetaAgentsImpl serviceClient; diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaMemoryStoresAsyncClient.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaMemoryStoresAsyncClient.java index 41abc82dc9e2b..353dacb829ae3 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaMemoryStoresAsyncClient.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaMemoryStoresAsyncClient.java @@ -55,6 +55,20 @@ @Beta(warningText = "This class is in preview and may change in future releases.") public final class BetaMemoryStoresAsyncClient { + /** + * Resumes polling an existing memory update without creating another update. + * + * @param name memory store name. + * @param updateId saved update ID from a previous poll response. + * @return a poller exposing update metadata and the completed result. + */ + public PollerFlux resumeUpdateMemories(String name, + String updateId) { + return com.azure.ai.agents.implementation.AgentsServicePollUtils.resumeAsync( + () -> getUpdateResultWithResponse(name, updateId, new RequestOptions()), MemoryStoreUpdateResponse.class, + MemoryStoreUpdateCompletedResult.class); + } + @Generated private final BetaMemoryStoresImpl serviceClient; diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaMemoryStoresClient.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaMemoryStoresClient.java index 40c6ab22d2072..467c9134ab27c 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaMemoryStoresClient.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaMemoryStoresClient.java @@ -49,6 +49,20 @@ @Beta(warningText = "This class is in preview and may change in future releases.") public final class BetaMemoryStoresClient { + /** + * Resumes polling an existing memory update without creating another update. + * + * @param name memory store name. + * @param updateId saved update ID from a previous poll response. + * @return a poller exposing update metadata and the completed result. + */ + public SyncPoller resumeUpdateMemories(String name, + String updateId) { + return com.azure.ai.agents.implementation.AgentsServicePollUtils.resume( + () -> getUpdateResultWithResponse(name, updateId, new RequestOptions()), MemoryStoreUpdateResponse.class, + MemoryStoreUpdateCompletedResult.class); + } + @Generated private final BetaMemoryStoresImpl serviceClient; diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/AgentsServicePollUtils.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/AgentsServicePollUtils.java index f23b4f677bebc..88fc6c2960728 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/AgentsServicePollUtils.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/AgentsServicePollUtils.java @@ -3,9 +3,22 @@ package com.azure.ai.agents.implementation; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.Map; + +import com.azure.ai.agents.models.JobStatus; +import com.azure.ai.agents.models.MemoryStoreUpdateCompletedResult; import com.azure.ai.agents.models.MemoryStoreUpdateStatus; +import com.azure.core.util.BinaryData; +import com.azure.core.util.CoreUtils; +import com.azure.core.util.logging.ClientLogger; import com.azure.core.util.polling.LongRunningOperationStatus; import com.azure.core.util.polling.PollResponse; +import com.azure.core.util.polling.PollingContext; +import com.azure.core.util.serializer.TypeReference; + +import reactor.core.publisher.Mono; /** * Shared polling helpers for the Agents SDK. @@ -14,12 +27,98 @@ * delegate here so that the two strategies stay in sync and only minimal edits are needed in the * generated files.

* - *

This class is package-private; it is not part of the public API.

+ *

This implementation class is not part of the public API.

*/ -final class AgentsServicePollUtils { +public final class AgentsServicePollUtils { + private static final ClientLogger LOGGER = new ClientLogger(AgentsServicePollUtils.class); + private AgentsServicePollUtils() { } + /** + * Resumes a job using its existing GET operation. + * @param getResponse status retrieval. + * @param pollType status model type. + * @param resultType final result type. + * @param status type. + * @param result type. + * @return a synchronous poller that does not create a new job. + */ + public static com.azure.core.util.polling.SyncPoller resume( + java.util.function.Supplier> getResponse, Class pollType, + Class resultType) { + java.util.function.Function, PollResponse> poll = context -> { + com.azure.core.http.rest.Response response = getResponse.get(); + BinaryData body = response.getValue(); + context.setData(PollingUtils.POLL_RESPONSE_BODY, body.toString()); + return new PollResponse<>(mapStatus((String) body.toObject(Map.class).get("status")), + body.toObject(pollType), + PollingUtils.getRetryAfterFromHeaders(response.getHeaders(), OffsetDateTime::now)); + }; + return com.azure.core.util.polling.SyncPoller.createPoller(Duration.ofSeconds(1), poll, poll, + (context, response) -> { + throw new UnsupportedOperationException("Use the job cancellation API."); + }, context -> resumedResult(context, resultType)); + } + + /** + * Resumes a job using its existing asynchronous GET operation. + * @param getResponse status retrieval. + * @param pollType status model type. + * @param resultType final result type. + * @param status type. + * @param result type. + * @return an asynchronous poller that does not create a new job. + */ + public static com.azure.core.util.polling.PollerFlux resumeAsync( + java.util.function.Supplier>> getResponse, Class pollType, + Class resultType) { + java.util.function.Function, Mono>> poll + = context -> Mono.defer(getResponse).map(response -> { + BinaryData body = response.getValue(); + context.setData(PollingUtils.POLL_RESPONSE_BODY, body.toString()); + return new PollResponse<>(mapStatus((String) body.toObject(Map.class).get("status")), + body.toObject(pollType), + PollingUtils.getRetryAfterFromHeaders(response.getHeaders(), OffsetDateTime::now)); + }); + return new com.azure.core.util.polling.PollerFlux<>(Duration.ofSeconds(1), + context -> poll.apply(context).map(PollResponse::getValue), poll, + (context, response) -> Mono.error(new UnsupportedOperationException("Use the job cancellation API.")), + context -> Mono.fromCallable(() -> resumedResult(context, resultType))); + } + + private static U resumedResult(PollingContext context, Class resultType) { + if (context.getLatestResponse().getStatus() != LongRunningOperationStatus.SUCCESSFULLY_COMPLETED) { + throw new com.azure.core.exception.AzureException("Long running operation failed or was cancelled."); + } + Map body = BinaryData.fromString(context.getData(PollingUtils.POLL_RESPONSE_BODY)) + .toObject(PollingUtils.POST_POLL_RESULT_TYPE_REFERENCE); + return getFinalResultBody(body, "result", TypeReference.createInstance(resultType)).toObject(resultType); + } + + /** + * Extracts the final result without replacing a non-null service result. + * + * @param response the final polling response body. + * @param propertyName the final result property. + * @param resultType the expected result type. + * @param the result type. + * @return the service result, or the memory-update fallback when absent. + */ + static BinaryData getFinalResultBody(Map response, String propertyName, + TypeReference resultType) { + Object result = response == null ? null : response.get(propertyName); + if (result != null) { + return BinaryData.fromObject(result); + } + if ("result".equals(propertyName) && MemoryStoreUpdateCompletedResult.class.equals(resultType.getJavaType())) { + return BinaryData.fromString("{\"memory_operations\":[],\"usage\":{\"embedding_tokens\":0," + + "\"input_tokens\":0,\"input_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0}," + + "\"output_tokens\":0,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":0}}"); + } + throw LOGGER.logExceptionAsError(new com.azure.core.exception.AzureException("Cannot get final result")); + } + /** * Remaps a {@link PollResponse} whose status may contain a custom service terminal state * ({@code "completed"}, {@code "superseded"}) that the base {@code OperationResourcePollingStrategy} @@ -41,10 +140,11 @@ static PollResponse remapStatus(PollResponse response) { } private static LongRunningOperationStatus mapCustomStatus(LongRunningOperationStatus status) { - // Standard statuses (Succeeded, Failed, Canceled, InProgress, NotStarted) are already - // mapped correctly by the parent's PollResult; only remap the custom ones. + // Standard statuses (Failed, Canceled, InProgress, NotStarted) are already mapped by the caller or parent's + // PollResult. Remap the service's Succeeded spelling and service-specific terminal statuses here. String name = status.toString(); - if (MemoryStoreUpdateStatus.COMPLETED.toString().equalsIgnoreCase(name)) { + if (JobStatus.SUCCEEDED.toString().equalsIgnoreCase(name) + || MemoryStoreUpdateStatus.COMPLETED.toString().equalsIgnoreCase(name)) { return LongRunningOperationStatus.SUCCESSFULLY_COMPLETED; } else if (MemoryStoreUpdateStatus.SUPERSEDED.toString().equalsIgnoreCase(name) // Optimization jobs and telephony use "cancelled"; MemoryStoreUpdateStatus intentionally has no CANCELLED. @@ -53,4 +153,19 @@ private static LongRunningOperationStatus mapCustomStatus(LongRunningOperationSt } return status; } + + static LongRunningOperationStatus mapStatus(String statusValue) { + if (CoreUtils.isNullOrEmpty(statusValue) || CoreUtils.isNullOrEmpty(statusValue.trim())) { + return LongRunningOperationStatus.IN_PROGRESS; + } + String status = statusValue.trim(); + if (JobStatus.QUEUED.toString().equalsIgnoreCase(status) + || JobStatus.IN_PROGRESS.toString().equalsIgnoreCase(status)) { + return LongRunningOperationStatus.IN_PROGRESS; + } else if (JobStatus.FAILED.toString().equalsIgnoreCase(status)) { + return LongRunningOperationStatus.FAILED; + } else { + return mapCustomStatus(LongRunningOperationStatus.fromString(status, false)); + } + } } diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/OperationLocationPollingStrategy.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/OperationLocationPollingStrategy.java index f07bb0e69d2a9..7743eb35078e8 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/OperationLocationPollingStrategy.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/OperationLocationPollingStrategy.java @@ -112,24 +112,17 @@ public Mono> onInitialResponse(Response response, PollingCont public Mono getResult(PollingContext pollingContext, TypeReference resultType) { if (pollingContext.getLatestResponse().getStatus() == LongRunningOperationStatus.FAILED) { return Mono.error(new AzureException("Long running operation failed.")); - } else if (pollingContext.getLatestResponse().getStatus() == LongRunningOperationStatus.USER_CANCELLED) { + } + if (pollingContext.getLatestResponse().getStatus() == LongRunningOperationStatus.USER_CANCELLED) { return Mono.error(new AzureException("Long running operation cancelled.")); } if (propertyName != null) { - // take the last poll response body from PollingContext, - // and de-serialize the property as final result BinaryData latestResponseBody = BinaryData.fromString(pollingContext.getData(PollingUtils.POLL_RESPONSE_BODY)); return PollingUtils .deserializeResponse(latestResponseBody, serializer, PollingUtils.POST_POLL_RESULT_TYPE_REFERENCE) - .flatMap(value -> { - if (value.get(propertyName) != null) { - return BinaryData.fromObjectAsync(value.get(propertyName)) - .flatMap(result -> PollingUtils.deserializeResponse(result, serializer, resultType)); - } else { - return Mono.error(new AzureException("Cannot get final result")); - } - }) + .flatMap(value -> PollingUtils.deserializeResponse( + AgentsServicePollUtils.getFinalResultBody(value, propertyName, resultType), serializer, resultType)) .switchIfEmpty(Mono.error(new AzureException("Cannot get final result"))); } else { return super.getResult(pollingContext, resultType); diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/SyncOperationLocationPollingStrategy.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/SyncOperationLocationPollingStrategy.java index 53d935775f636..c7d0cc0c6f9ef 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/SyncOperationLocationPollingStrategy.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/SyncOperationLocationPollingStrategy.java @@ -107,22 +107,18 @@ public PollResponse onInitialResponse(Response response, PollingContext public U getResult(PollingContext pollingContext, TypeReference resultType) { if (pollingContext.getLatestResponse().getStatus() == LongRunningOperationStatus.FAILED) { throw LOGGER.logExceptionAsError(new AzureException("Long running operation failed.")); - } else if (pollingContext.getLatestResponse().getStatus() == LongRunningOperationStatus.USER_CANCELLED) { + } + if (pollingContext.getLatestResponse().getStatus() == LongRunningOperationStatus.USER_CANCELLED) { throw LOGGER.logExceptionAsError(new AzureException("Long running operation cancelled.")); } if (propertyName != null) { - // take the last poll response body from PollingContext, - // and de-serialize the property as final result BinaryData latestResponseBody = BinaryData.fromString(pollingContext.getData(PollingUtils.POLL_RESPONSE_BODY)); Map pollResult = PollingUtils.deserializeResponseSync(latestResponseBody, serializer, PollingUtils.POST_POLL_RESULT_TYPE_REFERENCE); - if (pollResult != null && pollResult.get(propertyName) != null) { - return PollingUtils.deserializeResponseSync(BinaryData.fromObject(pollResult.get(propertyName)), - serializer, resultType); - } else { - throw LOGGER.logExceptionAsError(new AzureException("Cannot get final result")); - } + return PollingUtils.deserializeResponseSync( + AgentsServicePollUtils.getFinalResultBody(pollResult, propertyName, resultType), serializer, + resultType); } else { return super.getResult(pollingContext, resultType); } diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/TokenUtils.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/TokenUtils.java index 3f53589dabc92..2116cc94ceb9d 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/TokenUtils.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/TokenUtils.java @@ -6,15 +6,111 @@ import com.azure.core.credential.AccessToken; import com.azure.core.credential.TokenCredential; import com.azure.core.credential.TokenRequestContext; - +import com.azure.core.exception.AzureException; +import com.openai.core.ClientOptions; +import com.openai.core.LogLevel; +import com.openai.core.RequestOptions; +import com.openai.core.http.HttpClient; +import com.openai.core.http.HttpRequest; +import com.openai.core.http.HttpResponse; +import com.openai.credential.BearerTokenCredential; +import com.openai.credential.Credential; import java.util.Arrays; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; import java.util.function.Supplier; +import reactor.core.publisher.Mono; /** * Utility class used to forward token authentication to Stainless clients */ public final class TokenUtils { + /** + * Resolves the default Azure credential at the native async transport boundary. + * Explicit native credential overrides bypass this adapter. + */ + public static final class AsyncAuthentication { + private final TokenCredential tokenCredential; + private final String[] scopes; + private final String marker = "azure-async-" + UUID.randomUUID(); + private final Credential credential = BearerTokenCredential.create(marker); + + /** + * Creates authentication state for one native client. + * @param tokenCredential Azure credential, required when default authentication is used. + * @param scopes token scopes. + */ + public AsyncAuthentication(TokenCredential tokenCredential, String... scopes) { + this.tokenCredential = tokenCredential; + this.scopes = scopes.clone(); + } + + /** + * Gets the placeholder resolved by the authenticated transport before sending. + * @return the native credential. + */ + public Credential getCredential() { + return credential; + } + + /** + * Wraps the final caller-selected transport after applying native options. + * @param options native client options. + * @return the authentication transport, before native client decorators are applied. + */ + public HttpClient configure(ClientOptions.Builder options) { + ClientOptions configured = options.build(); + if (configured.credential() != credential) { + return configured.httpClient(); + } + HttpClient transport = configured.toBuilder().maxRetries(0).logLevel(LogLevel.OFF).build().httpClient(); + HttpClient authenticatedTransport = new HttpClient() { + @Override + public HttpResponse execute(HttpRequest request, RequestOptions requestOptions) { + if (requiresToken(request)) { + request = authenticate(request, tokenCredential.getTokenSync(tokenContext())); + } + return transport.execute(request, requestOptions); + } + + @Override + public CompletableFuture executeAsync(HttpRequest request, + RequestOptions requestOptions) { + return Mono + .defer(() -> requiresToken(request) + ? tokenCredential.getToken(tokenContext()) + .switchIfEmpty( + Mono.error(new AzureException("The credential returned no access token."))) + .map(token -> authenticate(request, token)) + : Mono.just(request)) + .flatMap(authenticated -> Mono + .fromFuture(() -> transport.executeAsync(authenticated, requestOptions))) + .toFuture(); + } + + @Override + public void close() { + transport.close(); + } + }; + options.httpClient(authenticatedTransport); + return authenticatedTransport; + } + + private boolean requiresToken(HttpRequest request) { + return request.headers().values("Authorization").contains("Bearer " + marker); + } + + private TokenRequestContext tokenContext() { + return new TokenRequestContext().setScopes(Arrays.asList(scopes)); + } + + private HttpRequest authenticate(HttpRequest request, AccessToken token) { + return request.toBuilder().replaceHeaders("Authorization", "Bearer " + token.getToken()).build(); + } + } + /** * Utility authentication function. * 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 index 3b8e01b93907a..155f2557f0834 100644 --- 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 @@ -4,11 +4,21 @@ package com.azure.ai.agents.implementation.http; import com.azure.core.http.HttpHeader; +import com.azure.core.http.HttpHeaderName; 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; +import java.io.FilterInputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.function.Consumer; /** * Adapter that exposes an Azure {@link com.azure.core.http.HttpResponse} as an OpenAI {@link HttpResponse}. This keeps @@ -16,7 +26,10 @@ */ final class AzureHttpResponseAdapter implements HttpResponse { + private static final ClientLogger LOGGER = new ClientLogger(AzureHttpResponseAdapter.class); + private final com.azure.core.http.HttpResponse azureResponse; + private final Consumer bodyLogger; /** * Creates a new adapter instance for the provided Azure response. @@ -24,7 +37,24 @@ final class AzureHttpResponseAdapter implements HttpResponse { * @param azureResponse Response returned by the Azure pipeline. */ AzureHttpResponseAdapter(com.azure.core.http.HttpResponse azureResponse) { + this(azureResponse, false); + } + + AzureHttpResponseAdapter(com.azure.core.http.HttpResponse azureResponse, boolean logBody) { + this(azureResponse, + logBody && isEventStream(azureResponse) + ? value -> LOGGER.info("OpenAI response body chunk: {}", value) + : null); + } + + private static boolean isEventStream(com.azure.core.http.HttpResponse response) { + String contentType = response.getHeaderValue(HttpHeaderName.CONTENT_TYPE); + return contentType != null && "text/event-stream".equalsIgnoreCase(contentType.split(";", 2)[0].trim()); + } + + AzureHttpResponseAdapter(com.azure.core.http.HttpResponse azureResponse, Consumer bodyLogger) { this.azureResponse = azureResponse; + this.bodyLogger = bodyLogger; } @Override @@ -39,7 +69,62 @@ public Headers headers() { @Override public InputStream body() { - return azureResponse.getBodyAsInputStreamSync(); + InputStream stream = azureResponse.getBodyAsInputStreamSync(); + if (bodyLogger == null) { + return stream; + } + return new FilterInputStream(stream) { + private final CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPLACE) + .onUnmappableCharacter(CodingErrorAction.REPLACE); + private final ByteBuffer pending = ByteBuffer.allocate(1024); + private final CharBuffer decoded = CharBuffer.allocate(1024); + private boolean finished; + + @Override + public int read() throws IOException { + int value = in.read(); + if (value != -1) { + pending.put((byte) value); + } + logDecoded(value == -1); + return value; + } + + @Override + public int read(byte[] bytes, int offset, int length) throws IOException { + int count = in.read(bytes, offset, length); + int consumed = 0; + while (consumed < count) { + int size = Math.min(count - consumed, pending.remaining()); + pending.put(bytes, offset + consumed, size); + consumed += size; + logDecoded(false); + } + if (count == -1) { + logDecoded(true); + } + return count; + } + + private void logDecoded(boolean endOfInput) { + if (finished) { + return; + } + pending.flip(); + decoder.decode(pending, decoded, endOfInput); + pending.compact(); + if (endOfInput) { + decoder.flush(decoded); + finished = true; + } + decoded.flip(); + if (decoded.hasRemaining()) { + bodyLogger.accept(decoded.toString()); + } + decoded.clear(); + } + }; } @Override diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/http/FoundryPolicyHelper.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/http/FoundryPolicyHelper.java index 9a66ef33575da..15415c72335c9 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/http/FoundryPolicyHelper.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/http/FoundryPolicyHelper.java @@ -3,6 +3,7 @@ package com.azure.ai.agents.implementation.http; +import com.azure.core.exception.HttpResponseException; import com.azure.core.http.HttpHeaderName; import com.azure.core.http.HttpPipeline; import com.azure.core.http.HttpPipelineBuilder; @@ -11,10 +12,15 @@ import com.azure.core.http.HttpResponse; import com.azure.core.http.policy.HttpPipelinePolicy; import com.azure.core.util.CoreUtils; -import reactor.core.publisher.Mono; - +import com.azure.core.util.logging.ClientLogger; +import com.azure.json.JsonProviders; +import com.azure.json.JsonReader; +import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import reactor.core.publisher.Mono; /** * Utility methods for adding AI Foundry-specific policies to Azure Core {@link HttpPipeline HttpPipelines}. @@ -22,6 +28,7 @@ public final class FoundryPolicyHelper { private static final HttpHeaderName FOUNDRY_FEATURES = HttpHeaderName.fromString("Foundry-Features"); + private static final ClientLogger LOGGER = new ClientLogger(FoundryPolicyHelper.class); private FoundryPolicyHelper() { } @@ -36,6 +43,16 @@ public static HttpPipelinePolicy createFoundryFeaturesPolicy(String foundryFeatu return CoreUtils.isNullOrEmpty(foundryFeatures) ? null : new FoundryFeaturesPolicy(foundryFeatures); } + /** + * Creates a policy that adds Java preview opt-in guidance to preview-required service errors. + * + * @param allowPreview Whether automatic preview opt-in is enabled for the client. + * @return The error policy, or {@code null} when preview is already enabled. + */ + public static HttpPipelinePolicy createPreviewErrorPolicy(boolean allowPreview) { + return allowPreview ? null : new PreviewErrorPolicy(); + } + /** * Creates a new pipeline with {@code policy} prepended to the existing pipeline policies. *

@@ -76,10 +93,47 @@ private FoundryFeaturesPolicy(String foundryFeatures) { @Override public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { - if (CoreUtils.isNullOrEmpty(context.getHttpRequest().getHeaders().getValue(FOUNDRY_FEATURES))) { + if (context.getHttpRequest().getHeaders().get(FOUNDRY_FEATURES) == null) { context.getHttpRequest().getHeaders().set(FOUNDRY_FEATURES, foundryFeatures); } return next.process(); } } + + private static final class PreviewErrorPolicy implements HttpPipelinePolicy { + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + return next.process().flatMap(response -> { + if (response.getStatusCode() != 403) { + return Mono.just(response); + } + HttpResponse bufferedResponse = response.buffer(); + return bufferedResponse.getBodyAsByteArray().flatMap(bytes -> { + HttpResponseException exception = previewException(bufferedResponse, bytes); + return exception == null + ? Mono.just(bufferedResponse) + : Mono.error(LOGGER.logExceptionAsError(exception)); + }); + }); + } + + private static HttpResponseException previewException(HttpResponse response, byte[] bytes) { + Object value; + try (JsonReader reader = JsonProviders.createReader(bytes)) { + value = reader.readUntyped(); + } catch (IOException | IllegalStateException exception) { + return null; + } + if (!(value instanceof Map)) { + return null; + } + Object error = ((Map) value).get("error"); + if (!(error instanceof Map) || !"preview_feature_required".equals(((Map) error).get("code"))) { + return null; + } + String message = "Status code 403, \"" + new String(bytes, StandardCharsets.UTF_8) + + "\". To use preview features, configure AgentsClientBuilder.allowPreview(true)."; + return new HttpResponseException(message, response, value); + } + } } 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 index 67c01ba40d62e..b324cfd58c52c 100644 --- 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 @@ -9,6 +9,7 @@ import com.azure.core.http.HttpHeaders; import com.azure.core.http.HttpMethod; import com.azure.core.http.HttpPipeline; +import com.azure.core.http.policy.UserAgentPolicy; import com.azure.core.util.BinaryData; import com.azure.core.util.Context; import com.azure.core.util.CoreUtils; @@ -21,7 +22,6 @@ import com.openai.core.http.HttpRequestBody; import com.openai.core.http.HttpResponse; import com.openai.errors.BadRequestException; -import reactor.core.scheduler.Schedulers; import com.openai.errors.InternalServerException; import com.openai.errors.NotFoundException; import com.openai.errors.OpenAIException; @@ -30,8 +30,6 @@ import com.openai.errors.UnauthorizedException; import com.openai.errors.UnexpectedStatusCodeException; import com.openai.errors.UnprocessableEntityException; -import reactor.core.publisher.Mono; - import java.io.ByteArrayOutputStream; import java.net.MalformedURLException; import java.net.URI; @@ -39,6 +37,8 @@ import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeoutException; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; /** * Utility entry point that adapts an Azure {@link com.azure.core.http.HttpClient} so it can be consumed by @@ -53,6 +53,47 @@ public final class HttpClientHelper { private HttpClientHelper() { } + /** + * Creates a logging policy that never logs multipart upload bodies. Multipart bodies may contain credentials and + * user file contents, and logging them may buffer large streaming uploads. Requests retain their configured + * metadata logging level while body logging is reduced to headers. + * @param options caller logging settings, which are not modified. + * @return multipart-aware logging policy. + */ + public static com.azure.core.http.policy.HttpPipelinePolicy + createLoggingPolicy(com.azure.core.http.policy.HttpLogOptions options) { + com.azure.core.http.policy.HttpLoggingPolicy normal = new com.azure.core.http.policy.HttpLoggingPolicy(options); + com.azure.core.http.policy.HttpLoggingPolicy headers + = new com.azure.core.http.policy.HttpLoggingPolicy(new com.azure.core.http.policy.HttpLogOptions() + .setLogLevel(options.getLogLevel().shouldLogHeaders() + ? com.azure.core.http.policy.HttpLogDetailLevel.HEADERS + : com.azure.core.http.policy.HttpLogDetailLevel.BASIC) + .setAllowedHeaderNames(options.getAllowedHeaderNames()) + .setAllowedQueryParamNames(options.getAllowedQueryParamNames()) + .disableRedactedHeaderLogging(options.isRedactedHeaderLoggingDisabled())); + return new com.azure.core.http.policy.HttpPipelinePolicy() { + private com.azure.core.http.policy.HttpLoggingPolicy + select(com.azure.core.http.HttpPipelineCallContext context) { + String contentType = context.getHttpRequest().getHeaders().getValue(HttpHeaderName.CONTENT_TYPE); + return options.getLogLevel().shouldLogBody() + && contentType != null + && contentType.toLowerCase(java.util.Locale.ROOT).startsWith("multipart/") ? headers : normal; + } + + @Override + public Mono process(com.azure.core.http.HttpPipelineCallContext context, + com.azure.core.http.HttpPipelineNextPolicy next) { + return select(context).process(context, next); + } + + @Override + public com.azure.core.http.HttpResponse processSync(com.azure.core.http.HttpPipelineCallContext context, + com.azure.core.http.HttpPipelineNextSyncPolicy next) { + return select(context).processSync(context, next); + } + }; + } + /** * 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. @@ -61,15 +102,28 @@ private HttpClientHelper() { * @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); + return mapToOpenAIHttpClient(httpPipeline, false); + } + + /** + * Adapts an Azure pipeline with optional logging of SSE bodies as they are consumed. + * + * @param httpPipeline the pipeline used to execute requests. + * @param logBody whether to log consumed SSE response bytes. Body content may contain sensitive data. + * @return the OpenAI transport adapter. + */ + public static HttpClient mapToOpenAIHttpClient(HttpPipeline httpPipeline, boolean logBody) { + return new HttpClientWrapper(httpPipeline, logBody); } private static final class HttpClientWrapper implements HttpClient { private final HttpPipeline httpPipeline; + private final boolean logBody; - private HttpClientWrapper(HttpPipeline httpPipeline) { + private HttpClientWrapper(HttpPipeline httpPipeline, boolean logBody) { this.httpPipeline = Objects.requireNonNull(httpPipeline, "'httpPipeline' cannot be null."); + this.logBody = logBody; } @Override @@ -90,7 +144,8 @@ public HttpResponse execute(HttpRequest request, RequestOptions requestOptions) try { com.azure.core.http.HttpRequest azureRequest = buildAzureRequest(request); return new AzureHttpResponseAdapter( - this.httpPipeline.sendSync(azureRequest, buildRequestContext(requestOptions))); + this.httpPipeline.sendSync(azureRequest, buildRequestContext(requestOptions, azureRequest)), + logBody); } catch (MalformedURLException exception) { throw new OpenAIException("Invalid URL in request: " + exception.getMessage(), LOGGER.logThrowableAsError(exception)); @@ -108,8 +163,9 @@ public CompletableFuture executeAsync(HttpRequest request, Request Objects.requireNonNull(requestOptions, "requestOptions"); return Mono.fromCallable(() -> buildAzureRequest(request)) - .flatMap(azureRequest -> this.httpPipeline.send(azureRequest, buildRequestContext(requestOptions))) - .map(response -> (HttpResponse) new AzureHttpResponseAdapter(response)) + .flatMap(azureRequest -> this.httpPipeline.send(azureRequest, + buildRequestContext(requestOptions, azureRequest))) + .map(response -> (HttpResponse) new AzureHttpResponseAdapter(response, logBody)) .onErrorMap(HttpClientWrapper::mapAzureExceptionToOpenAI) // publishOn moves the CompletableFuture completion (and all OpenAI SDK continuations that // run synchronously on it) off the Netty/OkHttp I/O thread and onto a thread pool that @@ -244,8 +300,13 @@ private static HttpHeaders toAzureHeaders(Headers sourceHeaders) { * @param requestOptions OpenAI SDK request options * @return Azure request {@link Context} */ - private static Context buildRequestContext(RequestOptions requestOptions) { + private static Context buildRequestContext(RequestOptions requestOptions, + com.azure.core.http.HttpRequest request) { Context context = Context.NONE; + String userAgent = request.getHeaders().getValue(HttpHeaderName.USER_AGENT); + if (!CoreUtils.isNullOrEmpty(userAgent)) { + context = context.addData(UserAgentPolicy.OVERRIDE_USER_AGENT_CONTEXT_KEY, userAgent); + } Timeout timeout = requestOptions.getTimeout(); // we use "read" as it's the closest thing to the "response timeout" if (timeout != null && !timeout.read().isZero() && !timeout.read().isNegative()) { diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/utils/FileUtils.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/utils/FileUtils.java index d0a6ec26304dd..8f17e474262d0 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/utils/FileUtils.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/utils/FileUtils.java @@ -10,6 +10,7 @@ import java.io.IOException; import java.io.OutputStream; +import java.io.UncheckedIOException; import java.nio.channels.AsynchronousFileChannel; import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; @@ -17,6 +18,7 @@ import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.security.MessageDigest; +import java.security.DigestOutputStream; import java.security.NoSuchAlgorithmException; /** @@ -114,14 +116,29 @@ private static OpenOption[] openOptions(boolean overwrite) { /** * Computes the lowercase hex-encoded SHA-256 digest of the given binary content. * - *

The content is fully read in order to compute the digest.

+ *

Replayable content is streamed into the digest without materializing a byte array. Non-replayable + * content is buffered using {@link BinaryData#toBytes()}.

* * @param content the binary content to hash. * @return the lowercase hex-encoded SHA-256 digest of {@code content}. */ public static String computeSha256(BinaryData content) { try { - byte[] hash = MessageDigest.getInstance("SHA-256").digest(content.toBytes()); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + if (content.isReplayable()) { + content.writeTo(new DigestOutputStream(new OutputStream() { + @Override + public void write(int value) { + } + + @Override + public void write(byte[] bytes, int offset, int length) { + } + }, digest)); + } else { + digest.update(content.toBytes()); + } + byte[] hash = digest.digest(); StringBuilder builder = new StringBuilder(hash.length * 2); for (byte value : hash) { builder.append(Character.forDigit((value >> 4) & 0xF, 16)); @@ -130,6 +147,8 @@ public static String computeSha256(BinaryData content) { return builder.toString(); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException("SHA-256 is not available.", e); + } catch (IOException e) { + throw new UncheckedIOException("Unable to read content for SHA-256 hashing.", e); } } } diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/CodeFileDetails.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/CodeFileDetails.java index dd3b65d861b07..ec300dea480de 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/CodeFileDetails.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/CodeFileDetails.java @@ -47,11 +47,16 @@ public CodeFileDetails(BinaryData content) { * Creates an instance of CodeFileDetails class. * * @param filePath path to the file on disk to upload. + * @throws IllegalArgumentException if the path has no file name. */ public CodeFileDetails(String filePath) { Path path = Paths.get(filePath); + Path fileName = path.getFileName(); + if (fileName == null) { + throw new IllegalArgumentException("The provided path has no file name: " + filePath); + } this.content = BinaryData.fromFile(path); - this.filename = path.getFileName().toString(); + this.filename = fileName.toString(); } /** 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 16e7ab8830d14..581e5b5336774 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 @@ -93,9 +93,14 @@ public void basicItemCRUDOperations(HttpClient httpClient, AgentsServiceVersion assertNotNull(conversationItem); assertNotNull(conversationItem.data()); assertFalse(conversationItem.data().isEmpty()); - assertTrue(conversationItem.data().get(0).isMessage()); - Message createdConversationItem = conversationItem.data().get(0).asMessage(); + Message createdConversationItem = conversationItem.data() + .stream() + .filter(ConversationItem::isMessage) + .map(ConversationItem::asMessage) + .findFirst() + .orElseThrow(() -> new AssertionError( + "Created conversation item did not contain a message: " + conversationItem.data())); assertTrue(createdConversationItem.content().get(0).isInputText()); assertEquals("Hello, agent!", createdConversationItem.content().get(0).asInputText().text()); 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 9aa2ba4a2e6e9..5314843238b91 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 @@ -81,9 +81,14 @@ public void basicItemCRUDOperations(HttpClient httpClient, AgentsServiceVersion assertNotNull(conversationItem); assertNotNull(conversationItem.data()); assertFalse(conversationItem.data().isEmpty()); - assertTrue(conversationItem.data().get(0).isMessage()); - Message createdConversationItem = conversationItem.data().get(0).asMessage(); + Message createdConversationItem = conversationItem.data() + .stream() + .filter(ConversationItem::isMessage) + .map(ConversationItem::asMessage) + .findFirst() + .orElseThrow(() -> new AssertionError( + "Created conversation item did not contain a message: " + conversationItem.data())); assertTrue(createdConversationItem.content().get(0).isInputText()); assertEquals("Hello, agent!", createdConversationItem.content().get(0).asInputText().text()); diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/FoundryFeaturesHeaderVerificationTest.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/FoundryFeaturesHeaderVerificationTest.java index 93aa7b0bdf58b..e6585f352f9c7 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/FoundryFeaturesHeaderVerificationTest.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/FoundryFeaturesHeaderVerificationTest.java @@ -3,8 +3,15 @@ package com.azure.ai.agents; +import com.azure.ai.agents.implementation.realtime.VoiceAgentWebSocketUtils; + +import com.azure.ai.agents.implementation.http.HttpClientHelper; import com.azure.ai.agents.implementation.models.AgentDefinitionOptInKeys; import com.azure.ai.agents.implementation.models.FoundryFeaturesOptInKeys; +import com.azure.core.credential.AccessToken; +import com.azure.core.credential.TokenCredential; +import com.azure.core.credential.TokenRequestContext; +import com.azure.core.exception.HttpResponseException; import com.azure.core.http.HttpClient; import com.azure.core.http.HttpHeaderName; import com.azure.core.http.HttpHeaders; @@ -20,30 +27,136 @@ import com.azure.core.test.utils.MockTokenCredential; import com.azure.core.util.BinaryData; import com.azure.core.util.Context; -import org.junit.jupiter.api.Test; -import reactor.core.publisher.Mono; - +import com.openai.client.OpenAIClientAsync; +import com.openai.core.ClientOptions; +import com.openai.credential.BearerTokenCredential; import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; import static org.junit.jupiter.api.Assertions.assertTrue; public class FoundryFeaturesHeaderVerificationTest { + @Test + public void asyncAuthenticationPreservesLazyCredentialsAndRetryCount() { + RecordingHttpClient transport = new RecordingHttpClient(request -> new MockHttpResponse(request, 500, + new HttpHeaders().set(HttpHeaderName.CONTENT_TYPE, "application/json"), + "{}".getBytes(StandardCharsets.UTF_8))); + com.openai.core.http.HttpClient custom + = HttpClientHelper.mapToOpenAIHttpClient(new HttpPipelineBuilder().httpClient(transport).build()); + AgentsClientBuilder builder = createBuilder(transport); + OpenAIClientAsync client = builder.buildOpenAIAsyncClient(options -> options.httpClient(custom).maxRetries(1)); + assertThrows(CompletionException.class, () -> client.models().list().join()); + assertEquals(2, transport.requests.size()); + AtomicInteger calls = new AtomicInteger(); + OpenAIClientAsync overridden = builder.buildOpenAIAsyncClient( + options -> options.httpClient(custom).maxRetries(0).credential(BearerTokenCredential.create(() -> { + calls.incrementAndGet(); + return "custom-token"; + }))); + assertEquals(0, calls.get()); + assertThrows(CompletionException.class, () -> overridden.models().list().join()); + assertTrue(calls.get() > 0); + assertEquals("Bearer custom-token", + transport.getLastRequest().getHeaders().getValue(HttpHeaderName.AUTHORIZATION)); + } + + @Test + public void asyncOpenAIAuthenticationNeverRequestsSynchronousTokens() { + RecordingHttpClient transport = newOpenAIRecordingHttpClient(); + AtomicInteger requests = new AtomicInteger(); + TokenCredential credential = new TokenCredential() { + @Override + public Mono getToken(TokenRequestContext context) { + assertEquals(Collections.singletonList("https://ai.azure.com/.default"), context.getScopes()); + return Mono.defer(() -> { + requests.incrementAndGet(); + return Mono.just(new AccessToken("async-token", OffsetDateTime.now().plusHours(1))); + }); + } + + @Override + public AccessToken getTokenSync(TokenRequestContext context) { + throw new AssertionError("Async authentication must not call getTokenSync"); + } + }; + AgentsClientBuilder builder = new AgentsClientBuilder().endpoint("https://localhost/projects/test") + .credential(credential) + .httpClient(transport); + builder.buildOpenAIAsyncClient().models().list().join(); + builder.buildAgentScopedOpenAIAsyncClient("agent").models().list().join(); + com.openai.core.http.HttpClient custom + = HttpClientHelper.mapToOpenAIHttpClient(new HttpPipelineBuilder().httpClient(transport).build()); + builder.buildOpenAIAsyncClient(options -> options.httpClient(custom)).models().list().join(); + builder.buildAgentScopedOpenAIAsyncClient("agent", options -> options.httpClient(custom)) + .models() + .list() + .join(); + builder.buildResponsesAsyncClient() + .createResponseWithResponse(BinaryData.fromString("{\"model\":\"gpt-4o\",\"input\":\"hi\"}"), null) + .block(Duration.ofSeconds(5)); + assertEquals(5, requests.get()); + assertEquals("Bearer async-token", + transport.getLastRequest().getHeaders().getValue(HttpHeaderName.AUTHORIZATION)); + builder.buildOpenAIAsyncClient(options -> options.apiKey("override").httpClient(custom)).models().list().join(); + assertEquals(5, requests.get()); + assertEquals("Bearer override", transport.getLastRequest().getHeaders().getValue(HttpHeaderName.AUTHORIZATION)); + } + + @Test + public void asyncAuthenticationWaitsWithoutBlockingAndDoesNotSendOnFailure() { + Sinks.One pending = Sinks.one(); + RecordingHttpClient transport = newOpenAIRecordingHttpClient(); + AgentsClientBuilder builder = new AgentsClientBuilder().endpoint("https://localhost/projects/test") + .httpClient(transport) + .credential(context -> pending.asMono()); + OpenAIClientAsync client = builder.buildOpenAIAsyncClient(); + CompletableFuture result = assertTimeoutPreemptively(Duration.ofSeconds(2), () -> client.models().list()); + assertFalse(result.isDone()); + assertTrue(transport.requests.isEmpty()); + pending.tryEmitValue(new AccessToken("delayed", OffsetDateTime.now().plusHours(1))); + result.join(); + assertEquals("Bearer delayed", transport.getLastRequest().getHeaders().getValue(HttpHeaderName.AUTHORIZATION)); + int sent = transport.requests.size(); + for (Mono failure : Arrays + .asList(Mono.error(new IllegalStateException("token failed")), Mono.empty())) { + OpenAIClientAsync failingClient = builder.credential(context -> failure).buildOpenAIAsyncClient(); + assertThrows(CompletionException.class, () -> failingClient.models().list().join()); + assertEquals(sent, transport.requests.size()); + } + } + private static final HttpHeaderName FOUNDRY_FEATURES = HttpHeaderName.fromString("Foundry-Features"); private static final HttpHeaderName CUSTOM_PIPELINE_HEADER = HttpHeaderName.fromString("X-Custom-Pipeline"); private static final String CUSTOM_PIPELINE_VALUE = "custom-pipeline"; - private static final String AGENT_PREVIEW_FEATURES = Stream - .concat(Arrays.stream(AgentDefinitionOptInKeys.values()).map(AgentDefinitionOptInKeys::toString), - Stream.of(FoundryFeaturesOptInKeys.AGENTS_OPTIMIZATION_V2_PREVIEW.toString())) - .collect(Collectors.joining(",")); + private static final String AGENT_PREVIEW_FEATURES + = Stream + .concat(Arrays.stream(AgentDefinitionOptInKeys.values()).map(AgentDefinitionOptInKeys::toString), + Stream.of(FoundryFeaturesOptInKeys.AGENTS_OPTIMIZATION_V2_PREVIEW.toString(), + FoundryFeaturesOptInKeys.MODEL_ROUTER_CONTROLS_V1_PREVIEW.toString())) + .collect(Collectors.joining(",")); @Test public void voicePreviewFactoriesAreOnlyPublicOnBetaBuilder() throws ReflectiveOperationException { @@ -214,6 +327,94 @@ public void allowPreviewDoesNotOverrideExplicitHeader() { assertEquals(explicitHeader, foundryFeatures(httpClient)); } + @Test + public void allowPreviewPreservesExplicitEmptyHeader() { + RecordingHttpClient httpClient = new RecordingHttpClient(); + RequestOptions options = new RequestOptions().setHeader(HttpHeaderName.fromString("foundry-features"), ""); + + createBuilder(httpClient).allowPreview(true) + .buildAgentsClient() + .createAgentVersionWithResponse("agent", BinaryData.fromString("{}"), options); + + assertEquals("", foundryFeatures(httpClient)); + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void previewRequiredErrorIncludesGuidanceAndPreservesResponse(boolean async) { + String body = "{\"error\":{\"code\":\"preview_feature_required\",\"message\":\"Voice preview required\"," + + "\"details\":[{\"code\":\"detail\",\"message\":\"Service detail\"}]}}"; + RecordingHttpClient httpClient = errorClient(403, body); + AgentsClientBuilder builder = createBuilder(createCustomPipeline(httpClient)); + + HttpResponseException exception = createVersionError(builder, async); + + assertTrue(exception.getMessage().contains("AgentsClientBuilder.allowPreview(true)")); + assertTrue(exception.getMessage().contains("Voice preview required")); + assertEquals(403, exception.getResponse().getStatusCode()); + assertSame(httpClient.getLastRequest(), exception.getResponse().getRequest()); + assertEquals("request-id", + exception.getResponse().getHeaderValue(HttpHeaderName.fromString("x-ms-request-id"))); + assertEquals(body, exception.getResponse().getBodyAsString().block()); + Map error = (Map) ((Map) exception.getValue()).get("error"); + assertEquals("preview_feature_required", error.get("code")); + assertEquals(1, ((List) error.get("details")).size()); + assertEquals(CUSTOM_PIPELINE_VALUE, customPipelineHeader(httpClient)); + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void previewEnabledDoesNotAddErrorGuidance(boolean async) { + RecordingHttpClient httpClient + = errorClient(403, "{\"error\":{\"code\":\"preview_feature_required\",\"message\":\"Preview required\"}}"); + + HttpResponseException exception = createVersionError(createBuilder(httpClient).allowPreview(true), async); + + assertFalse(exception.getMessage().contains("AgentsClientBuilder.allowPreview(true)")); + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void unrelatedErrorsDoNotAddPreviewGuidance(boolean async) { + for (String body : new String[] { + "{\"error\":{\"code\":\"forbidden\",\"message\":\"Access denied\"}}", + "not json", + "", + "null", + "[]" }) { + HttpResponseException exception = createVersionError(createBuilder(errorClient(403, body)), async); + assertFalse(exception.getMessage().contains("AgentsClientBuilder.allowPreview(true)")); + assertEquals(403, exception.getResponse().getStatusCode()); + assertEquals(body, exception.getResponse().getBodyAsString().block()); + } + HttpResponseException exception = createVersionError( + createBuilder( + errorClient(400, "{\"error\":{\"code\":\"preview_feature_required\",\"message\":\"Bad request\"}}")), + async); + assertFalse(exception.getMessage().contains("AgentsClientBuilder.allowPreview(true)")); + assertEquals(400, exception.getResponse().getStatusCode()); + } + + private static RecordingHttpClient errorClient(int status, String body) { + return new RecordingHttpClient(request -> new MockHttpResponse(request, status, + new HttpHeaders().set(HttpHeaderName.CONTENT_TYPE, "application/json") + .set(HttpHeaderName.fromString("x-ms-request-id"), "request-id"), + body.getBytes(StandardCharsets.UTF_8))); + } + + private static HttpResponseException createVersionError(AgentsClientBuilder builder, boolean async) { + return assertThrows(HttpResponseException.class, () -> { + if (async) { + builder.buildAgentsAsyncClient() + .createAgentVersionWithResponse("agent", BinaryData.fromString("{}"), new RequestOptions()) + .block(); + } else { + builder.buildAgentsClient() + .createAgentVersionWithResponse("agent", BinaryData.fromString("{}"), new RequestOptions()); + } + }); + } + @Test public void allowPreviewFalseDoesNotAddGaAgentHeader() { RecordingHttpClient httpClient = new RecordingHttpClient(); @@ -319,13 +520,16 @@ public void openAIAndResponsesClientsUseCustomPipeline() { } @Test - public void agentScopedOpenAIClientUsesCustomPipelineAndAllowPreviewHeader() { + public void agentScopedOpenAIClientUsesCustomPipelineAndConditionalPreviewHeader() { RecordingHttpClient httpClient = newOpenAIRecordingHttpClient(); HttpPipeline customPipeline = createCustomPipeline(httpClient); createBuilder(customPipeline).buildAgentScopedOpenAIClient("agent").models().list(); assertEquals(CUSTOM_PIPELINE_VALUE, customPipelineHeader(httpClient)); assertNull(foundryFeatures(httpClient)); + assertEquals("/api/projects/project/agents/agent/endpoint/protocols/openai/models", + httpClient.getLastRequest().getUrl().getPath()); + assertEquals("api-version=v1", httpClient.getLastRequest().getUrl().getQuery()); createBuilder(customPipeline).allowPreview(true).buildAgentScopedOpenAIClient("agent").models().list(); assertEquals(CUSTOM_PIPELINE_VALUE, customPipelineHeader(httpClient)); @@ -336,6 +540,103 @@ private static RecordingHttpClient newOpenAIRecordingHttpClient() { return new RecordingHttpClient(FoundryFeaturesHeaderVerificationTest::openAIResponse); } + @Test + public void explicitLogOptionsOverrideConsoleLoggingDefault() throws java.io.IOException { + for (boolean enabled : new boolean[] { false, true }) { + RecordingHttpClient httpClient = new RecordingHttpClient(request -> new MockHttpResponse(request, 200, + new HttpHeaders().set(HttpHeaderName.CONTENT_TYPE, "text/event-stream; charset=utf-8"), + "data: test\n\n".getBytes(StandardCharsets.UTF_8))); + AgentsClientBuilder builder + = createBuilder(httpClient).configuration(com.azure.core.util.Configuration.getGlobalConfiguration() + .clone() + .put("AZURE_AI_PROJECTS_CONSOLE_LOGGING", "true")); + if (!enabled) { + builder.httpLogOptions(new com.azure.core.http.policy.HttpLogOptions() + .setLogLevel(com.azure.core.http.policy.HttpLogDetailLevel.NONE)); + } + java.util.concurrent.atomic.AtomicReference transport + = new java.util.concurrent.atomic.AtomicReference<>(); + builder.buildOpenAIClient(options -> transport.set(options.build().httpClient())); + com.openai.core.http.HttpRequest request = com.openai.core.http.HttpRequest.builder() + .method(com.openai.core.http.HttpMethod.GET) + .baseUrl("https://localhost/stream") + .build(); + try (com.openai.core.http.HttpResponse response = transport.get().execute(request); + java.io.InputStream body = response.body()) { + assertEquals(enabled, body instanceof java.io.FilterInputStream); + assertEquals('d', body.read()); + } + } + } + + @Test + public void realtimeHandshakeOverridesPreserveSecurityAndDefaults() { + com.azure.ai.agents.implementation.realtime.VoiceAgentWebSocketClientConfiguration configuration + = new com.azure.ai.agents.implementation.realtime.VoiceAgentWebSocketClientConfiguration( + java.net.URI.create("https://localhost/api/projects/project"), new MockTokenCredential(), "v1", + "test-sdk", new HttpHeaders().set("X-Custom", "builder"), null); + com.azure.ai.agents.models.VoiceAgentWebSocketConnectionOptions options + = new com.azure.ai.agents.models.VoiceAgentWebSocketConnectionOptions() + .setConnectionUrl(java.net.URI.create("wss://localhost/custom?sig=a%2Bb")) + .setAgentSessionId("session id") + .setApiVersion("preview") + .setStructuredInputs("{\"language\":\"en\"}") + .setCredentialScopes(Collections.singletonList("custom-scope")) + .setExtraQuery(Collections.singletonMap("api-version", "override")); + Map headers = new java.util.LinkedHashMap<>(); + headers.put("foundry-features", ""); + headers.put("user-agent", "custom-agent"); + headers.put("Authorization", "must-not-be-used"); + options.setExtraHeaders(headers); + java.net.URI uri = VoiceAgentWebSocketUtils.buildWebSocketUri(configuration, "agent", options); + assertEquals("/custom", uri.getPath()); + assertTrue(uri.getRawQuery().contains("sig=a%2Bb")); + assertTrue(uri.getRawQuery().contains("api-version=override")); + assertTrue(uri.getRawQuery().contains("agent_session_id=session%20id")); + HttpHeaders actual = VoiceAgentWebSocketUtils.buildHeaders(configuration, options, "test-token"); + assertEquals("", actual.getValue(FOUNDRY_FEATURES)); + assertEquals("custom-agent", actual.getValue(HttpHeaderName.USER_AGENT)); + assertEquals("Bearer test-token", actual.getValue(HttpHeaderName.AUTHORIZATION)); + assertEquals("builder", actual.getValue("X-Custom")); + assertEquals(options.getStructuredInputs(), actual.getValue("x-ms-voice-structured-inputs")); + assertEquals(Collections.singletonList("custom-scope"), + VoiceAgentWebSocketUtils.createTokenRequestContext(options).getScopes()); + for (String unsafe : new String[] { + "wss://other.example/custom", + "ws://localhost/custom", + "wss://localhost:444/custom", + "wss://user@localhost/custom", + "wss://localhost/custom#fragment" }) { + options.setConnectionUrl(java.net.URI.create(unsafe)); + assertThrows(IllegalArgumentException.class, + () -> VoiceAgentWebSocketUtils.buildWebSocketUri(configuration, "agent", options)); + } + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void openAIOverridesPreserveCredentialsHeadersAndQuery(boolean async) { + RecordingHttpClient httpClient = newOpenAIRecordingHttpClient(); + AgentsClientBuilder builder = createBuilder(httpClient); + java.util.function.Consumer configure + = options -> options.baseUrl("https://localhost:8080/custom/openai") + .apiKey("test-api-key") + .replaceHeaders("User-Agent", "review-client/1.0") + .replaceHeaders("foundry-features", "") + .replaceQueryParams("api-version", "test-version"); + if (async) { + builder.buildAgentScopedOpenAIAsyncClient("agent", configure).models().list().join(); + } else { + builder.buildAgentScopedOpenAIClient("agent", configure).models().list(); + } + assertEquals("/custom/openai/models", httpClient.getLastRequest().getUrl().getPath()); + assertEquals("api-version=test-version", httpClient.getLastRequest().getUrl().getQuery()); + assertEquals("Bearer test-api-key", + httpClient.getLastRequest().getHeaders().getValue(HttpHeaderName.AUTHORIZATION)); + assertEquals("", foundryFeatures(httpClient)); + assertEquals("review-client/1.0", httpClient.getLastRequest().getHeaders().getValue(HttpHeaderName.USER_AGENT)); + } + private static AgentsClientBuilder createBuilder(RecordingHttpClient httpClient) { return new AgentsClientBuilder().endpoint("https://localhost:8080/api/projects/project") .credential(new MockTokenCredential()) @@ -343,6 +644,51 @@ private static AgentsClientBuilder createBuilder(RecordingHttpClient httpClient) .serviceVersion(AgentsServiceVersion.V1); } + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void customOpenAITransportRetainsAuthenticationAndAgentDefaults(boolean async) { + RecordingHttpClient customTransport = newOpenAIRecordingHttpClient(); + AtomicInteger tokenRequests = new AtomicInteger(); + AgentsClientBuilder builder = new AgentsClientBuilder().endpoint("https://localhost/api/projects/project") + .allowPreview(true) + .clientOptions(new com.azure.core.util.ClientOptions().setApplicationId("review-app")) + .httpClient(request -> Mono.error(new AssertionError("Default transport must not be used"))) + .credential(context -> { + assertEquals(Collections.singletonList("https://ai.azure.com/.default"), context.getScopes()); + tokenRequests.incrementAndGet(); + return Mono.just(new AccessToken("test-token", OffsetDateTime.now().plusHours(1))); + }); + com.openai.core.http.HttpClient transport + = HttpClientHelper.mapToOpenAIHttpClient(new HttpPipelineBuilder().httpClient(customTransport).build()); + if (async) { + builder.buildAgentScopedOpenAIAsyncClient("agent", options -> options.httpClient(transport)) + .models() + .list() + .join(); + } else { + builder.buildAgentScopedOpenAIClient("agent", options -> options.httpClient(transport)).models().list(); + } + assertEquals(AGENT_PREVIEW_FEATURES, foundryFeatures(customTransport)); + assertTrue(customTransport.getLastRequest() + .getHeaders() + .getValue(HttpHeaderName.USER_AGENT) + .startsWith("review-app azsdk-java-azure-ai-agents/")); + assertEquals("api-version=v1", customTransport.getLastRequest().getUrl().getQuery()); + assertEquals("Bearer test-token", + customTransport.getLastRequest().getHeaders().getValue(HttpHeaderName.AUTHORIZATION)); + int initialTokenRequests = tokenRequests.get(); + assertTrue(initialTokenRequests > 0); + if (async) { + builder.buildOpenAIAsyncClient(options -> options.httpClient(transport)).models().list().join(); + } else { + builder.buildOpenAIClient(options -> options.httpClient(transport)).models().list(); + } + assertNull(foundryFeatures(customTransport)); + assertNull(customTransport.getLastRequest().getUrl().getQuery()); + assertEquals("/api/projects/project/openai/v1/models", customTransport.getLastRequest().getUrl().getPath()); + assertTrue(tokenRequests.get() > initialTokenRequests); + } + private static AgentsClientBuilder createBuilder(HttpPipeline pipeline) { return new AgentsClientBuilder().endpoint("https://localhost:8080/api/projects/project") .credential(new MockTokenCredential()) diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/implementation/AgentsServicePollUtilsTest.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/implementation/AgentsServicePollUtilsTest.java index a3977df0a7f5d..8b46879236d37 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/implementation/AgentsServicePollUtilsTest.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/implementation/AgentsServicePollUtilsTest.java @@ -3,20 +3,223 @@ package com.azure.ai.agents.implementation; +import com.azure.ai.agents.AgentsClientBuilder; +import com.azure.ai.agents.AgentsServiceVersion; +import com.azure.ai.agents.models.AgentOptimizationJob; +import com.azure.ai.agents.models.AgentOptimizationJobResult; +import com.azure.ai.agents.models.MemoryStoreUpdateCompletedResult; +import com.azure.ai.agents.models.MemoryStoreUpdateResponse; +import com.azure.core.exception.AzureException; +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.test.http.MockHttpResponse; +import com.azure.core.util.BinaryData; +import com.azure.core.util.polling.AsyncPollResponse; import com.azure.core.util.polling.LongRunningOperationStatus; import com.azure.core.util.polling.PollResponse; +import com.azure.core.util.polling.SyncPoller; +import com.azure.core.util.serializer.TypeReference; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; - -import java.util.stream.Stream; +import org.junit.jupiter.params.provider.ValueSource; +import reactor.core.publisher.Mono; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; class AgentsServicePollUtilsTest { + static Stream memoryResultCases() { + return Stream.of(false, true) + .flatMap(async -> Stream + .of("", ",\"result\":null", ",\"result\":{\"memory_operations\":[],\"usage\":{\"total_tokens\":17}}") + .flatMap( + result -> Stream.of(false, true).map(resume -> Arguments.of(async, result, "completed", resume)))); + } + + @ParameterizedTest + @MethodSource("memoryResultCases") + void memoryPollerHandlesEmptyResult(boolean async, String resultJson, String status, boolean resume) { + HttpClient httpClient = request -> { + if (resume) { + assertEquals(HttpMethod.GET, request.getHttpMethod()); + assertTrue(request.getUrl().getPath().endsWith("/updates/update-123")); + } + boolean initial = request.getHttpMethod() == HttpMethod.POST; + String body = initial + ? "{\"update_id\":\"update-123\",\"status\":\"queued\"}" + : "{\"update_id\":\"update-123\",\"status\":\"" + status + "\"" + resultJson + "}"; + HttpHeaders headers = new HttpHeaders().set(HttpHeaderName.CONTENT_TYPE, "application/json") + .set(HttpHeaderName.fromString("Operation-Location"), + "https://localhost/api/projects/project/memory_stores/store/updates/update-123") + .set(HttpHeaderName.RETRY_AFTER, "0"); + return Mono.just( + new MockHttpResponse(request, initial ? 202 : 200, headers, body.getBytes(StandardCharsets.UTF_8))); + }; + AgentsClientBuilder builder = new AgentsClientBuilder().endpoint("https://localhost/api/projects/project") + .pipeline(new HttpPipelineBuilder().httpClient(httpClient).build()); + MemoryStoreUpdateCompletedResult result; + if (async) { + com.azure.ai.agents.BetaMemoryStoresAsyncClient client = builder.beta().buildBetaMemoryStoresAsyncClient(); + AsyncPollResponse response = (resume + ? client.resumeUpdateMemories("store", "update-123") + : client.beginUpdateMemories("store", "scope")).setPollInterval(Duration.ofMillis(1)) + .blockFirst(Duration.ofSeconds(5)); + assertNotNull(response); + result = response.getFinalResult().block(Duration.ofSeconds(5)); + } else { + com.azure.ai.agents.BetaMemoryStoresClient client = builder.beta().buildBetaMemoryStoresClient(); + result = (resume + ? client.resumeUpdateMemories("store", "update-123") + : client.beginUpdateMemories("store", "scope")).setPollInterval(Duration.ofMillis(1)) + .getFinalResult(Duration.ofSeconds(5)); + } + assertNotNull(result); + assertTrue(result.getMemoryOperations().isEmpty()); + assertNotNull(result.getUsage()); + assertEquals(resultJson.contains("17") ? 17 : 0, result.getUsage().getTotalTokens()); + if (!resultJson.contains("17")) { + assertEquals(0, result.getUsage().getEmbeddingTokens()); + assertEquals(0, result.getUsage().getInputTokens()); + assertEquals(0, result.getUsage().getOutputTokens()); + assertEquals(0, result.getUsage().getInputTokensDetails().getCachedTokensCount()); + assertEquals(0, result.getUsage().getInputTokensDetails().getCacheWriteTokens()); + assertEquals(0, result.getUsage().getOutputTokensDetails().getReasoningTokens()); + } + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + void resumedMemoryPollerTreatsSupersededAsCancelled(boolean async) { + HttpClient httpClient = request -> { + HttpHeaders headers = new HttpHeaders().set(HttpHeaderName.CONTENT_TYPE, "application/json") + .set(HttpHeaderName.RETRY_AFTER, "0"); + return Mono.just(new MockHttpResponse(request, 200, headers, + "{\"update_id\":\"update-123\",\"status\":\"superseded\"}".getBytes(StandardCharsets.UTF_8))); + }; + AgentsClientBuilder builder = new AgentsClientBuilder().endpoint("https://localhost/api/projects/project") + .pipeline(new HttpPipelineBuilder().httpClient(httpClient).build()); + + LongRunningOperationStatus status = async + ? builder.beta() + .buildBetaMemoryStoresAsyncClient() + .resumeUpdateMemories("store", "update-123") + .blockFirst(Duration.ofSeconds(5)) + .getStatus() + : builder.beta() + .buildBetaMemoryStoresClient() + .resumeUpdateMemories("store", "update-123") + .poll() + .getStatus(); + + assertEquals(LongRunningOperationStatus.USER_CANCELLED, status); + } + + @Test + void missingNonMemoryResultStillFails() { + assertThrows(AzureException.class, () -> AgentsServicePollUtils.getFinalResultBody(Collections.emptyMap(), + "result", TypeReference.createInstance(AgentOptimizationJobResult.class))); + } + + @Test + void suppliedMemoryResultIsPreserved() { + java.util.Map suppliedResult = BinaryData + .fromString("{\"memory_operations\":[{\"operation\":\"create\",\"memory_id\":\"memory-123\"}]," + + "\"usage\":{\"total_tokens\":17},\"additional_property\":\"preserved\"}") + .toObject(PollingUtils.POST_POLL_RESULT_TYPE_REFERENCE); + BinaryData result + = AgentsServicePollUtils.getFinalResultBody(Collections.singletonMap("result", suppliedResult), "result", + TypeReference.createInstance(MemoryStoreUpdateCompletedResult.class)); + assertEquals(suppliedResult, result.toObject(PollingUtils.POST_POLL_RESULT_TYPE_REFERENCE)); + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + void optimizationPollerExposesJobIdAndFinalResult(boolean async) { + List requests = new ArrayList<>(); + HttpClient httpClient = request -> { + requests.add(request); + boolean initial = request.getHttpMethod() == HttpMethod.POST; + String body = initial + ? "{\"id\":\"job-123\",\"status\":\"queued\"}" + : "{\"id\":\"job-123\",\"status\":\"succeeded\"," + + "\"result\":{\"baseline\":\"candidate-baseline\",\"best\":\"candidate-best\",\"candidates\":[]}}"; + HttpHeaders headers = new HttpHeaders().set(HttpHeaderName.CONTENT_TYPE, "application/json") + .set(HttpHeaderName.fromString("Operation-Location"), + "https://localhost/api/projects/project/operations/job-123") + .set(HttpHeaderName.RETRY_AFTER, "0"); + return Mono.just( + new MockHttpResponse(request, initial ? 201 : 200, headers, body.getBytes(StandardCharsets.UTF_8))); + }; + AgentsClientBuilder builder = new AgentsClientBuilder().endpoint("https://localhost/api/projects/project") + .pipeline(new HttpPipelineBuilder().httpClient(httpClient).build()) + .serviceVersion(AgentsServiceVersion.V1); + + AgentOptimizationJobResult result; + if (async) { + AsyncPollResponse response = builder.beta() + .buildBetaAgentsAsyncClient() + .beginCreateOptimizationJob(new AgentOptimizationJob()) + .setPollInterval(Duration.ofMillis(1)) + .blockFirst(Duration.ofSeconds(5)); + assertNotNull(response); + assertEquals("job-123", response.getValue().getId()); + result = response.getFinalResult().block(Duration.ofSeconds(5)); + } else { + SyncPoller poller = builder.beta() + .buildBetaAgentsClient() + .beginCreateOptimizationJob(new AgentOptimizationJob()) + .setPollInterval(Duration.ofMillis(1)); + assertEquals("job-123", poller.poll().getValue().getId()); + result = poller.getFinalResult(Duration.ofSeconds(5)); + } + + assertNotNull(result); + assertEquals("candidate-baseline", result.getBaseline()); + assertEquals("candidate-best", result.getBest()); + assertEquals(1L, requests.stream().filter(request -> request.getHttpMethod() == HttpMethod.POST).count()); + assertTrue(requests.stream().anyMatch(request -> request.getHttpMethod() == HttpMethod.GET)); + requests.stream().filter(request -> request.getHttpMethod() == HttpMethod.GET).forEach(request -> { + assertEquals("/api/projects/project/operations/job-123", request.getUrl().getPath()); + assertEquals("api-version=" + AgentsServiceVersion.V1.getVersion(), request.getUrl().getQuery()); + }); + } + + static Stream mapStatusCases() { + return Stream.of(Arguments.of(null, LongRunningOperationStatus.IN_PROGRESS), + Arguments.of("", LongRunningOperationStatus.IN_PROGRESS), + Arguments.of(" ", LongRunningOperationStatus.IN_PROGRESS), + Arguments.of("queued", LongRunningOperationStatus.IN_PROGRESS), + Arguments.of(" IN_PROGRESS ", LongRunningOperationStatus.IN_PROGRESS), + Arguments.of("succeeded", LongRunningOperationStatus.SUCCESSFULLY_COMPLETED), + Arguments.of("failed", LongRunningOperationStatus.FAILED), + Arguments.of("cancelled", LongRunningOperationStatus.USER_CANCELLED), + Arguments.of(" completed ", LongRunningOperationStatus.SUCCESSFULLY_COMPLETED), + Arguments.of("SUPERSEDED", LongRunningOperationStatus.USER_CANCELLED), + Arguments.of("future_status", LongRunningOperationStatus.fromString("future_status", false))); + } + + @ParameterizedTest + @MethodSource("mapStatusCases") + void mapStatusMapsServiceStatuses(String status, LongRunningOperationStatus expected) { + assertEquals(expected, AgentsServicePollUtils.mapStatus(status)); + } + static Stream remapStatusCases() { return Stream.of( // Custom statuses that need remapping diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/implementation/FileUtilsTest.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/implementation/FileUtilsTest.java index 1378da14b610c..9fe7b4c655edf 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/implementation/FileUtilsTest.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/implementation/FileUtilsTest.java @@ -4,6 +4,7 @@ package com.azure.ai.agents.implementation; import com.azure.ai.agents.implementation.utils.FileUtils; +import com.azure.ai.agents.models.CodeFileDetails; import com.azure.core.util.BinaryData; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -11,15 +12,31 @@ import reactor.test.StepVerifier; import java.io.IOException; +import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Arrays; public class FileUtilsTest { @TempDir Path temporaryDirectory; + @Test + public void codeFileDetailsRejectsRootPath() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> new CodeFileDetails(temporaryDirectory.toAbsolutePath().getRoot().toString())); + } + + @Test + public void codeFileDetailsPreservesFileNameAndContent() throws IOException { + Path file = Files.write(temporaryDirectory.resolve("agent.zip"), new byte[] { 1, 2, 3 }); + CodeFileDetails details = new CodeFileDetails(file.toString()); + Assertions.assertEquals("agent.zip", details.getFilename()); + Assertions.assertArrayEquals(new byte[] { 1, 2, 3 }, details.getContent().toBytes()); + } + @Test public void writeToFileAsyncCreatesNewFile() throws IOException { Path destinationFile = temporaryDirectory.resolve("new-file.txt"); @@ -138,6 +155,27 @@ public void computeSha256IsRepeatableForFileBackedContent() throws IOException { Assertions.assertEquals(FileUtils.computeSha256(content), FileUtils.computeSha256(content)); } + @Test + public void computeSha256StreamsLargeFileAndPreservesUploadContent() throws IOException { + byte[] bytes = new byte[1_000_000]; + Arrays.fill(bytes, (byte) 'a'); + BinaryData content = BinaryData.fromFile(Files.write(temporaryDirectory.resolve("large.zip"), bytes)); + + Assertions.assertEquals("cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0", + FileUtils.computeSha256(content)); + Assertions.assertArrayEquals(bytes, content.toBytes()); + } + + @Test + public void computeSha256PreservesReplayableStreamForUpload() { + byte[] bytes = "abc".getBytes(StandardCharsets.UTF_8); + BinaryData content = BinaryData.fromStream(new ByteArrayInputStream(bytes), (long) bytes.length); + + Assertions.assertEquals("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + FileUtils.computeSha256(content)); + Assertions.assertArrayEquals(bytes, content.toBytes()); + } + @Test public void computeSha256DiffersForDifferentContent() { Assertions.assertNotEquals(FileUtils.computeSha256(BinaryData.fromString("content-a")), 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 index bf3b9a4bd77bc..072f14359e19d 100644 --- 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 @@ -4,6 +4,7 @@ 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.HttpPipelineBuilder; import com.azure.core.http.HttpRequest; @@ -11,10 +12,7 @@ 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.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; @@ -24,6 +22,13 @@ import java.util.Arrays; import java.util.concurrent.CompletableFuture; import java.util.function.Function; +import java.util.stream.Stream; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import reactor.core.publisher.Mono; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -33,6 +38,137 @@ class HttpClientHelperTests { + @ParameterizedTest + @MethodSource("responseContentTypes") + void responseBodyLoggingOnlyWrapsEventStreams(String contentType, boolean eventStream) throws IOException { + for (boolean logBody : new boolean[] { false, true }) { + HttpHeaders headers = new HttpHeaders(); + if (contentType != null) { + headers.set(HttpHeaderName.CONTENT_TYPE, contentType); + } + InputStream original = new ByteArrayInputStream("data: hello\n\n".getBytes(StandardCharsets.UTF_8)); + MockHttpResponse response = new MockHttpResponse( + new HttpRequest(com.azure.core.http.HttpMethod.GET, "https://localhost/stream"), 200, headers) { + @Override + public InputStream getBodyAsInputStreamSync() { + return original; + } + }; + try (AzureHttpResponseAdapter adapter = new AzureHttpResponseAdapter(response, logBody); + InputStream body = adapter.body()) { + assertEquals(logBody && eventStream, body != original); + assertEquals("data: hello\n\n", new String(readAllBytes(body), StandardCharsets.UTF_8)); + } + } + } + + private static Stream responseContentTypes() { + return Stream.of(Arguments.of("text/event-stream", true), + Arguments.of("Text/Event-Stream; Charset=UTF-8", true), + Arguments.of(" \ttext/event-stream \t; charset=\"utf-8\"", true), + Arguments.of("text/event-stream; extension=\"value;with;semicolons\"", true), + Arguments.of("application/json", false), Arguments.of("text/event-stream-extra", false), + Arguments.of("application/json; extension=\"text/event-stream\"", false), + Arguments.of("text/event-stream, application/json", false), Arguments.of("", false), + Arguments.of((String) null, false)); + } + + @Test + void multipartUploadsSkipBodyLoggerAndPreservePayload() { + com.azure.core.http.policy.HttpLogOptions options = new com.azure.core.http.policy.HttpLogOptions() + .setLogLevel(com.azure.core.http.policy.HttpLogDetailLevel.BODY_AND_HEADERS) + .setRequestLogger((logger, context) -> Mono.error(new AssertionError("Body logger invoked"))); + byte[] payload = "private upload contents".getBytes(StandardCharsets.UTF_8); + HttpClient transport = request -> { + org.junit.jupiter.api.Assertions.assertArrayEquals(payload, request.getBodyAsBinaryData().toBytes()); + assertEquals("Multipart/Form-Data; boundary=test", + request.getHeaders().getValue(HttpHeaderName.CONTENT_TYPE)); + return Mono.just(new MockHttpResponse(request, 200, new byte[0])); + }; + com.azure.core.http.HttpPipeline pipeline = new HttpPipelineBuilder().httpClient(transport) + .policies(HttpClientHelper.createLoggingPolicy(options)) + .build(); + for (boolean async : new boolean[] { false, true }) { + HttpRequest request = new HttpRequest(com.azure.core.http.HttpMethod.POST, "https://localhost/upload") + .setHeader(HttpHeaderName.CONTENT_TYPE, "Multipart/Form-Data; boundary=test") + .setBody(payload); + try (HttpResponse response + = async ? pipeline.send(request).block() : pipeline.sendSync(request, Context.NONE)) { + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + } + } + assertEquals(com.azure.core.http.policy.HttpLogDetailLevel.BODY_AND_HEADERS, options.getLogLevel()); + } + + @Test + void responseBodyLoggingPreservesSplitUtf8() throws IOException { + String text + = "\u00e9\u4e2d\ud83d\ude00" + String.join("", java.util.Collections.nCopies(600, "data: \u00e9\n")); + byte[] expected = text.getBytes(StandardCharsets.UTF_8); + for (int readSize : new int[] { 1, 2, 3, 5, 2048 }) { + java.util.List chunks = new java.util.ArrayList<>(); + MockHttpResponse response + = createMockResponse(new HttpRequest(com.azure.core.http.HttpMethod.GET, "https://localhost/stream"), + 200, new HttpHeaders(), text); + try (AzureHttpResponseAdapter adapter = new AzureHttpResponseAdapter(response, chunks::add); + InputStream body = adapter.body()) { + ByteArrayOutputStream actual = new ByteArrayOutputStream(); + actual.write(body.read()); + assertTrue(chunks.isEmpty()); + byte[] buffer = new byte[readSize + 2]; + int count; + while ((count = body.read(buffer, 2, readSize)) != -1) { + actual.write(buffer, 2, count); + } + org.junit.jupiter.api.Assertions.assertArrayEquals(expected, actual.toByteArray()); + assertEquals(text, String.join("", chunks)); + int logged = chunks.size(); + assertEquals(-1, body.read()); + assertEquals(logged, chunks.size()); + } + } + } + + @Test + void responseBodyLoggingReplacesTruncatedUtf8AtEof() throws IOException { + java.util.List chunks = new java.util.ArrayList<>(); + MockHttpResponse response + = new MockHttpResponse(new HttpRequest(com.azure.core.http.HttpMethod.GET, "https://localhost/stream"), 200, + new HttpHeaders(), new byte[] { (byte) 0xe2, (byte) 0x82 }); + try (AzureHttpResponseAdapter adapter = new AzureHttpResponseAdapter(response, chunks::add); + InputStream body = adapter.body()) { + assertEquals(0xe2, body.read()); + assertEquals(0x82, body.read()); + assertTrue(chunks.isEmpty()); + assertEquals(-1, body.read()); + assertEquals("\ufffd", String.join("", chunks)); + assertEquals(-1, body.read()); + assertEquals(1, chunks.size()); + } + } + + @Test + void responseBodyLoggingIsLazyAndPreservesBytes() throws IOException { + java.util.List chunks = new java.util.ArrayList<>(); + MockHttpResponse response + = createMockResponse(new HttpRequest(com.azure.core.http.HttpMethod.GET, "https://localhost/stream"), 200, + new HttpHeaders(), "data: hello\n\n"); + AzureHttpResponseAdapter adapter = new AzureHttpResponseAdapter(response, chunks::add); + assertTrue(chunks.isEmpty()); + try (InputStream body = adapter.body()) { + assertTrue(chunks.isEmpty()); + assertEquals('d', body.read()); + assertEquals("d", chunks.get(0)); + assertEquals("ata: hello\n\n", new String(readAllBytes(body), StandardCharsets.UTF_8)); + assertEquals("data: hello\n\n", String.join("", chunks)); + int chunkCount = chunks.size(); + assertEquals(-1, body.read()); + assertEquals(chunkCount, chunks.size()); + } + adapter.close(); + } + @Test void executeAsyncCompletesSuccessfully() { RecordingHttpClient recordingClient diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/models/PromptAgentDefinitionSerializationTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/models/PromptAgentDefinitionSerializationTests.java index 62c59a2429298..de3e0618467af 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/models/PromptAgentDefinitionSerializationTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/models/PromptAgentDefinitionSerializationTests.java @@ -14,10 +14,8 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.util.Collections; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -541,22 +539,6 @@ public void testRoundTripWithReasoningAndToolChoice() throws IOException { assertEquals(Reasoning.GenerateSummary.AUTO, deserialized.getReasoning().generateSummary().get()); } - /** - * Tests round-trip serialization of the managed harness and skill references. - */ - @Test - public void testRoundTripWithHarnessAndSkills() throws IOException { - PromptAgentDefinition original = new PromptAgentDefinition(TEST_MODEL).setHarness(new GitHubCopilotHarness()) - .setSkills(Collections.singletonList(new SkillReference("coding-skill").setVersion("1"))); - - PromptAgentDefinition deserialized = deserializeFromJson(serializeToJson(original)); - - assertInstanceOf(GitHubCopilotHarness.class, deserialized.getHarness()); - assertEquals(1, deserialized.getSkills().size()); - assertEquals("coding-skill", deserialized.getSkills().get(0).getName()); - assertEquals("1", deserialized.getSkills().get(0).getVersion()); - } - /** * Tests that reasoning is absent when not set. */ diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/models/ReasoningDedupSerializationTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/models/ReasoningDedupSerializationTests.java index bf9150b0e6de9..e052f2919775a 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/models/ReasoningDedupSerializationTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/models/ReasoningDedupSerializationTests.java @@ -3,7 +3,6 @@ package com.azure.ai.agents.models; -import com.azure.core.util.BinaryData; import com.azure.json.JsonProviders; import com.azure.json.JsonReader; import com.azure.json.JsonWriter; @@ -25,39 +24,6 @@ public class ReasoningDedupSerializationTests { private static final String TEST_MODEL = "gpt-4o"; - @Test - public void testVoiceResponseAudioConfigRoundTrip() throws IOException { - try (JsonReader reader = JsonProviders.createReader("{\"audio\":{\"output\":{}}}")) { - VoiceAgentResponseCreateOptions response = VoiceAgentResponseCreateOptions.fromJson(reader); - assertNotNull(response.getAudio().getOutput()); - VoiceAgentResponseCreateOptions roundTrip - = BinaryData.fromObject(response).toObject(VoiceAgentResponseCreateOptions.class); - assertNotNull(roundTrip.getAudio().getOutput()); - } - } - - @Test - public void testVoiceRealtimeResponseObjectRoundTrip() throws IOException { - try (JsonReader reader = JsonProviders.createReader("{\"object\":\"realtime.response\"}")) { - VoiceAgentRealtimeResponse response = VoiceAgentRealtimeResponse.fromJson(reader); - assertEquals(VoiceResponseBaseObject.REALTIME_RESPONSE, response.getObject()); - VoiceAgentRealtimeResponse roundTrip - = BinaryData.fromObject(response).toObject(VoiceAgentRealtimeResponse.class); - assertEquals(response.getObject(), roundTrip.getObject()); - } - } - - @Test - public void testVoiceRealtimeResponseBaseObjectRoundTrip() throws IOException { - try (JsonReader reader = JsonProviders.createReader("{\"object\":\"realtime.response\"}")) { - VoiceAgentRealtimeResponseBase response = VoiceAgentRealtimeResponseBase.fromJson(reader); - assertEquals(VoiceResponseBaseObject.REALTIME_RESPONSE, response.getObject()); - VoiceAgentRealtimeResponseBase roundTrip - = BinaryData.fromObject(response).toObject(VoiceAgentRealtimeResponseBase.class); - assertEquals(response.getObject(), roundTrip.getObject()); - } - } - // ----------------------------------------------------------------------- // Reasoning on PromptAgentDefinition — getter / setter // ----------------------------------------------------------------------- diff --git a/sdk/ai/azure-ai-projects/CHANGELOG.md b/sdk/ai/azure-ai-projects/CHANGELOG.md index 90db0a049b367..1b8663e8064f4 100644 --- a/sdk/ai/azure-ai-projects/CHANGELOG.md +++ b/sdk/ai/azure-ai-projects/CHANGELOG.md @@ -4,10 +4,27 @@ ### Features Added +- Added local model upload and registration helpers, dataset filename filtering, and configurable Blob upload options. +- Added saved-job polling resumption for data generation, evaluator generation, and agent-insight runs. +- Added Azure evaluation data-source factories and native OpenAI conversion helpers. +- Added synchronous and asynchronous OpenAI factory overloads accepting a native OpenAI options callback for URL, credential, headers, query parameters, and transport overrides. +- Added `BetaTelemetryClient` and `BetaTelemetryAsyncClient` for retrieving and caching the project's Application Insights connection string. +- Added opt-in HTTP logging defaults through `AZURE_AI_PROJECTS_CONSOLE_LOGGING` and chunk-as-consumed SSE body logging in the OpenAI bridge, using the configured Java logging backend. + ### Breaking Changes ### Bugs Fixed +- Native asynchronous OpenAI factories now retrieve Azure tokens asynchronously, including when a custom transport is supplied through the factory callback. +- Added preview opt-in guidance to evaluation-rule `preview_feature_required` errors without consuming their response bodies. +- Omitted multipart request and response bodies from SDK pipeline logging. +- Rejected empty dataset folders and filters matching no files before requesting upload storage. +- Preserved UTF-8 characters split across reads when logging OpenAI SSE response bodies. +- Validated dataset upload file names before making service requests. +- Agent-scoped OpenAI clients now automatically send agent preview features and the configured API version, with explicit caller overrides preserved. +- Preserved OpenAI credential and user-agent overrides through the default Azure HTTP bridge. User-supplied pipelines retain their authentication policies. +- Preserved explicitly empty `Foundry-Features` headers. + ### Other Changes ## 2.5.0 (2026-09-09) diff --git a/sdk/ai/azure-ai-projects/README.md b/sdk/ai/azure-ai-projects/README.md index 05726006004e4..621360dc27438 100644 --- a/sdk/ai/azure-ai-projects/README.md +++ b/sdk/ai/azure-ai-projects/README.md @@ -121,6 +121,108 @@ OpenAIClient openAIClient = builder.buildOpenAIClient(); OpenAIClientAsync openAIClientAsync = builder.buildOpenAIAsyncClient(); ``` +Agent-scoped OpenAI clients automatically opt in to agent preview features, independently of `allowPreview`. +They use the project's configured API version. Customize OpenAI defaults with the options callback: + +```java +OpenAIClient agentClient = builder.buildAgentScopedOpenAIClient("agent-name", options -> options + .replaceHeaders("User-Agent", "my-application/1.0") + .replaceQueryParams("api-version", "v1")); +``` + +The same callback is available on `buildOpenAIClient`, `buildOpenAIAsyncClient`, and +`buildAgentScopedOpenAIAsyncClient`. Use `baseUrl`, `apiKey` or `credential`, and `httpClient` on the native +OpenAI options to override those defaults. Use `replaceHeaders` and `replaceQueryParams` to replace existing +values. Explicit `Foundry-Features` headers, including empty values and case-insensitive names, are preserved. +Custom OpenAI transports bypass the Azure pipeline; custom Azure pipelines retain their own policies, +including authentication policies that may replace an OpenAI credential override. + +### Asynchronous OpenAI authentication + +Native asynchronous OpenAI clients retrieve Azure tokens using `TokenCredential.getToken(...)` without blocking. +Provide custom native transports through `buildOpenAIAsyncClient(options -> options.httpClient(transport))` or the +agent-scoped factory callback. These callbacks retain asynchronous Azure authentication and honor explicit credential +overrides. Replacing the transport afterward through the native client's `withOptions(...)` bypasses the authentication +adapter; supply an explicit native credential as well, or rebuild through the factory callback instead. +Cancelling a native OpenAI operation's future does not guarantee cancellation of pending Azure token retrieval; +the native client's future decorators control cancellation propagation. + +### Application Insights configuration + +```java +BetaTelemetryClient telemetry = builder.beta().buildBetaTelemetryClient(); +String connectionString = telemetry.getApplicationInsightsConnectionString(); + +BetaTelemetryAsyncClient telemetryAsync = builder.beta().buildBetaTelemetryAsyncClient(); +Mono connectionStringAsync = telemetryAsync.getApplicationInsightsConnectionString(); +``` + +Each telemetry client caches successful lookups for its lifetime. Create a new client to refresh a rotated +connection string. Missing connections raise `ResourceNotFoundException`; missing or invalid credentials +raise `IllegalStateException`. Failed lookups are not cached. Treat the returned connection string as a secret. + +### HTTP logging + +Set `AZURE_AI_PROJECTS_CONSOLE_LOGGING=true` to default the builder's HTTP logging to `BODY_AND_HEADERS`. +Explicit `HttpLogOptions` take precedence, including `HttpLogDetailLevel.NONE` to disable HTTP logging. +Enable INFO output in your Java logging backend (or set `AZURE_LOG_LEVEL=information` for Azure Core's +default logger). This option does not install console handlers or change other libraries' logging levels. +The default OpenAI bridge logs `text/event-stream` response chunks only as the caller reads them; +it does not pre-consume the stream. Other HTTP messages use Azure Core's logging and redaction rules. +Custom transports and custom pipelines retain their own logging configuration. Body logs are not redacted +and can contain prompts, responses, and other sensitive data; enable them only in a trusted environment. + +SDK-created pipelines omit request and response bodies for multipart uploads, even with body logging enabled. +This protection does not change logging policies in user-supplied pipelines or Blob clients configured through upload options. + +### Uploads and saved jobs + +`FileUploadOptions` supports filename regular-expression filtering for folders, Blob client configuration, and per-file +upload configuration. Empty folders and filters matching no files fail before requesting storage. Single-file uploads +ignore the filename filter. Uploads overwrite existing blobs by default; set Blob request conditions through the upload +callback to change that behavior. + +`BetaModelsClient.createModel` and its asynchronous counterpart upload a file or folder using Azure Blob Storage, +register the container, and optionally wait for the model to become available. They do not require AzCopy. + +```java readme-sample-local-model-upload +FileUploadOptions files = new FileUploadOptions() + .setFilePattern(Pattern.compile("\\.(bin|json|safetensors)$")); +ModelUploadOptions options = new ModelUploadOptions() + .setFileUploadOptions(files) + .setDescription("Local model weights") + .setTimeout(Duration.ofMinutes(5)); +ModelVersion model = builder.beta().buildBetaModelsClient() + .createModel("my-model", "1", Paths.get("model"), options); +``` + +Only HTTP 404 is treated as pending during registration polling. The wait timeout starts after registration is accepted; +it does not cover file uploads. With `setWaitForCompletion(false)`, the returned model is the submitted metadata, not a +confirmation that registration has completed. + +Save service job IDs to resume polling after restarting your application. Resumption uses GET requests and does not +create another job. Configure the same project endpoint and credentials when rebuilding the client. + +```java readme-sample-resume-generation-job +DataGenerationJobResult result = builder.beta().buildBetaDatasetsClient() + .resumeGenerationJob(savedJobId) + .getFinalResult(Duration.ofMinutes(5)); +``` + +Evaluator generation and agent-insight runs also expose resume methods, with native asynchronous counterparts. +Use the corresponding job cancellation API to cancel service work; stopping polling alone does not cancel a job. + +### Azure evaluation sources + +`AzureAIEvaluationDataSource` provides factories for CSV, target completions, response retrieval, benchmarks, red teams, +and traces. Convert these sources to native OpenAI request types with `EvaluationsHelper.toDataSource`. + +```java readme-sample-azure-evaluation-source +EvalCreateParams.DataSourceConfig schema = EvaluationsHelper.createDataSourceConfig("traces_preview"); +RunCreateParams.DataSource source = EvaluationsHelper.toDataSource( + AzureAIEvaluationDataSource.traces().setAgentName("my-agent").setLookbackHours(24).setMaxTraces(100)); +``` + ### Preview operation groups and beta clients Several operation groups in the AI Projects client library expose **preview** service features. These features require the `Foundry-Features` HTTP header. The SDK populates that header for you; you do not need to set the header value manually. @@ -155,7 +257,7 @@ The async `Beta*AsyncClient` counterparts follow the same behavior. ## Examples -The examples below show common operations for core AI Projects sub-clients. For complete runnable samples, see the [package samples][package_samples]. Additional preview samples are available for data generation jobs (`DataGenerationJobsSample`, `DataGenerationJobsAsyncSample`, and `DataGenerationJobWithEvaluationSample`), model management (`ModelsSample` and `ModelsAsyncSample`), routines (`RoutinesSample`, `RoutinesAsyncSample`, `RoutinesManualDispatchSample`, `RoutinesManualDispatchAsyncSample`, and related trigger samples), and packaged skills (`SkillsPackageSample` and `SkillsPackageAsyncSample`). +The examples below show common operations for core AI Projects sub-clients. For complete runnable samples, see the [package samples][package_samples]. Additional preview samples are available for data generation jobs (`DataGenerationJobsSample`, `DataGenerationJobsAsyncSample`, and `DataGenerationJobWithEvaluationSample`), model management (`ModelsSample`, `ModelsAsyncSample`, and `ModelsCreateAndPollSample`), routines (`RoutinesSample`, `RoutinesAsyncSample`, `RoutinesManualDispatchSample`, `RoutinesManualDispatchAsyncSample`, and related trigger samples), and packaged skills (`SkillsPackageSample` and `SkillsPackageAsyncSample`). ### Connections operations @@ -536,7 +638,7 @@ Index operations allow you to create and enumerate search indexes used by your A #### Create or update an index version -```java com.azure.ai.projects.IndexesGetSample.createOrUpdateIndex +```java com.azure.ai.projects.IndexesSample.createOrUpdateIndex String indexName = Configuration.getGlobalConfiguration().get("INDEX_NAME", "my-index"); String indexVersion = Configuration.getGlobalConfiguration().get("INDEX_VERSION", "2.0"); String aiSearchConnectionName = Configuration.getGlobalConfiguration().get("AI_SEARCH_CONNECTION_NAME", ""); @@ -555,7 +657,7 @@ System.out.println("Index created: " + index.getName()); #### List indexes -```java com.azure.ai.projects.IndexesListSample.listIndexes +```java com.azure.ai.projects.IndexesSample.listIndexes indexesClient.listLatestIndexVersions().forEach(index -> { System.out.println("Index name: " + index.getName()); System.out.println("Index version: " + index.getVersion()); @@ -566,7 +668,7 @@ indexesClient.listLatestIndexVersions().forEach(index -> { #### List index versions -```java com.azure.ai.projects.IndexesListVersionsSample.listIndexVersions +```java com.azure.ai.projects.IndexesSample.listIndexVersions String indexName = Configuration.getGlobalConfiguration().get("INDEX_NAME", "my-index"); @@ -580,7 +682,7 @@ indexesClient.listIndexVersions(indexName).forEach(index -> { #### Get an index version -```java com.azure.ai.projects.IndexesGetSample.getIndex +```java com.azure.ai.projects.IndexesSample.getIndex String indexName = Configuration.getGlobalConfiguration().get("INDEX_NAME", "my-index"); String indexVersion = Configuration.getGlobalConfiguration().get("INDEX_VERSION", "1.0"); @@ -596,7 +698,7 @@ System.out.println("Type: " + index.getType()); #### Delete an index version -```java com.azure.ai.projects.IndexesDeleteSample.deleteIndex +```java com.azure.ai.projects.IndexesSample.deleteIndex String indexName = Configuration.getGlobalConfiguration().get("INDEX_NAME", "my-index"); String indexVersion = Configuration.getGlobalConfiguration().get("INDEX_VERSION", "1.0"); diff --git a/sdk/ai/azure-ai-projects/customizations/src/main/java/ProjectsCustomizations.java b/sdk/ai/azure-ai-projects/customizations/src/main/java/ProjectsCustomizations.java index 3a8e80784716f..6f7ec0cce835e 100644 --- a/sdk/ai/azure-ai-projects/customizations/src/main/java/ProjectsCustomizations.java +++ b/sdk/ai/azure-ai-projects/customizations/src/main/java/ProjectsCustomizations.java @@ -1,12 +1,21 @@ import com.azure.autorest.customization.ClassCustomization; import com.azure.autorest.customization.Customization; import com.azure.autorest.customization.LibraryCustomization; +import com.github.javaparser.StaticJavaParser; +import com.github.javaparser.ast.Node; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; import com.github.javaparser.ast.body.FieldDeclaration; import com.github.javaparser.ast.body.MethodDeclaration; import com.github.javaparser.ast.body.TypeDeclaration; +import com.github.javaparser.ast.body.VariableDeclarator; import com.github.javaparser.ast.expr.AnnotationExpr; +import com.github.javaparser.ast.expr.MethodCallExpr; import com.github.javaparser.ast.expr.NormalAnnotationExpr; +import com.github.javaparser.ast.expr.ObjectCreationExpr; import com.github.javaparser.ast.expr.StringLiteralExpr; +import com.github.javaparser.ast.stmt.BlockStmt; +import com.github.javaparser.ast.stmt.ExpressionStmt; +import com.github.javaparser.ast.stmt.IfStmt; import java.io.IOException; import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; @@ -25,10 +34,99 @@ public class ProjectsCustomizations extends Customization { @Override public void customize(LibraryCustomization libraryCustomization, Logger logger) { + customizeBuilder(libraryCustomization); annotateBetaClients(libraryCustomization, logger); annotateBetaFields(libraryCustomization, loadBetaAnnotations(logger), logger); } + private static void customizeBuilder(LibraryCustomization customization) { + customization.getClass("com.azure.ai.projects", "AIProjectClientBuilder").customizeAst(ast -> { + ClassOrInterfaceDeclaration builder = ast.getClassByName("AIProjectClientBuilder") + .orElseThrow(() -> new IllegalStateException("Generated AIProjectClientBuilder was not found.")); + MethodDeclaration buildInnerClient = builder.getMethodsByName("buildInnerClient").stream() + .filter(method -> method.getParameters().isEmpty()) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Generated buildInnerClient was not found.")); + MethodDeclaration previewBuildInnerClient = buildInnerClient.clone(); + previewBuildInnerClient.setName("createInnerClientWithPreviewFeatures"); + previewBuildInnerClient.addParameter("String", "previewFeatures"); + List localPipelines = previewBuildInnerClient.findAll(VariableDeclarator.class).stream() + .filter(variable -> "localPipeline".equals(variable.getNameAsString())) + .collect(java.util.stream.Collectors.toList()); + if (localPipelines.size() != 1) { + throw new IllegalStateException("Expected one generated localPipeline variable."); + } + Node localPipelineParent = localPipelines.get(0) + .getParentNode() + .flatMap(Node::getParentNode) + .orElseThrow(() -> new IllegalStateException("Generated localPipeline statement was not found.")); + if (!(localPipelineParent instanceof ExpressionStmt)) { + throw new IllegalStateException("Generated localPipeline parent was not an expression statement."); + } + ExpressionStmt localPipelineStatement = (ExpressionStmt) localPipelineParent; + BlockStmt previewBody = previewBuildInnerClient.getBody() + .orElseThrow(() -> new IllegalStateException("Generated buildInnerClient body was not found.")); + int localPipelineIndex = previewBody.getStatements().indexOf(localPipelineStatement); + if (localPipelineIndex < 0) { + throw new IllegalStateException("Generated localPipeline statement was not in buildInnerClient."); + } + previewBody.getStatements().remove(localPipelineIndex); + previewBody.getStatements().add(localPipelineIndex, + StaticJavaParser.parseStatement("HttpPipeline localPipeline;")); + previewBody.getStatements().add(localPipelineIndex + 1, StaticJavaParser.parseStatement( + "if (CoreUtils.isNullOrEmpty(previewFeatures)) {" + + " localPipeline = pipeline != null ? pipeline : createHttpPipeline();" + + " localPipeline = FoundryPolicyHelper.prependPolicy(localPipeline," + + " FoundryPolicyHelper.createPreviewErrorPolicy(allowPreview));" + + " } else { localPipeline = resolvePipeline(previewFeatures); }")); + List existingPreviewBuilds + = new ArrayList<>(builder.getMethodsByName("createInnerClientWithPreviewFeatures")); + existingPreviewBuilds.forEach(MethodDeclaration::remove); + builder.addMember(previewBuildInnerClient); + + MethodDeclaration generatedPipeline = builder.getMethodsByName("createHttpPipeline").stream() + .filter(method -> method.getParameters().isEmpty()) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Generated createHttpPipeline was not found.")); + + List loggingOptions = generatedPipeline.findAll(VariableDeclarator.class).stream() + .filter(variable -> "localHttpLogOptions".equals(variable.getNameAsString())) + .collect(java.util.stream.Collectors.toList()); + if (loggingOptions.size() != 1) { + throw new IllegalStateException("Expected one generated localHttpLogOptions variable."); + } + loggingOptions.get(0).setInitializer("resolveHttpLogOptions()"); + + List loggingPolicies = generatedPipeline.findAll(ObjectCreationExpr.class).stream() + .filter(expression -> "HttpLoggingPolicy".equals(expression.getType().getNameAsString())) + .collect(java.util.stream.Collectors.toList()); + if (loggingPolicies.size() != 1) { + throw new IllegalStateException("Expected one generated HttpLoggingPolicy construction."); + } + ObjectCreationExpr loggingPolicy = loggingPolicies.get(0); + MethodCallExpr customLoggingPolicy = new MethodCallExpr("HttpClientHelper.createLoggingPolicy"); + loggingPolicy.getArguments().forEach(argument -> customLoggingPolicy.addArgument(argument.clone())); + loggingPolicy.replace(customLoggingPolicy); + builder.findCompilationUnit().ifPresent(unit -> unit.getImports().removeIf(declaration -> + "com.azure.core.http.policy.HttpLoggingPolicy".equals(declaration.getNameAsString()))); + + MethodDeclaration openAIPipeline = generatedPipeline.clone(); + openAIPipeline.setName("createOpenAIHttpPipeline"); + List authenticationChecks = openAIPipeline.findAll(IfStmt.class).stream() + .filter(statement -> statement.getThenStmt().toString().contains("BearerTokenAuthenticationPolicy")) + .collect(java.util.stream.Collectors.toList()); + if (authenticationChecks.size() != 1) { + throw new IllegalStateException("Expected one generated bearer-token authentication check."); + } + authenticationChecks.get(0).remove(); + + List existingOpenAIPipelines + = new ArrayList<>(builder.getMethodsByName("createOpenAIHttpPipeline")); + existingOpenAIPipelines.forEach(MethodDeclaration::remove); + builder.addMember(openAIPipeline); + }); + } + private void annotateBetaClients(LibraryCustomization customization, Logger logger) { customization.getPackage("com.azure.ai.projects") .listClasses() diff --git a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/AIProjectClientBuilder.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/AIProjectClientBuilder.java index 2fd7aefc6eaab..504cc2d8bef3a 100644 --- a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/AIProjectClientBuilder.java +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/AIProjectClientBuilder.java @@ -25,8 +25,8 @@ import com.azure.core.http.policy.AddHeadersFromContextPolicy; import com.azure.core.http.policy.AddHeadersPolicy; import com.azure.core.http.policy.BearerTokenAuthenticationPolicy; +import com.azure.core.http.policy.HttpLogDetailLevel; import com.azure.core.http.policy.HttpLogOptions; -import com.azure.core.http.policy.HttpLoggingPolicy; import com.azure.core.http.policy.HttpPipelinePolicy; import com.azure.core.http.policy.HttpPolicyProviders; import com.azure.core.http.policy.RequestIdPolicy; @@ -36,9 +36,11 @@ 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; +import com.openai.azure.AzureUrlPathMode; import com.openai.client.OpenAIClient; import com.openai.client.OpenAIClientAsync; import com.openai.client.okhttp.OpenAIOkHttpClient; @@ -48,6 +50,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.function.Consumer; /** * A builder for creating a new instance of the AIProjectClient type. @@ -102,6 +105,11 @@ public final class AIProjectClientBuilder private static final String MODELS_PREVIEW_FEATURES = FoundryFeaturesOptInKeys.MODELS_V1_PREVIEW.toString(); + private static final String AGENT_PREVIEW_FEATURES + = String.join(",", "WorkflowAgents=V1Preview", "ExternalAgents=V1Preview", "VoiceAgents=V1Preview", + "DraftAgents=V1Preview", FoundryFeaturesOptInKeys.AGENTS_OPTIMIZATION_V2_PREVIEW.toString(), + FoundryFeaturesOptInKeys.MODEL_ROUTER_CONTROLS_V1_PREVIEW.toString()); + private static final String RED_TEAMS_PREVIEW_FEATURES = FoundryFeaturesOptInKeys.RED_TEAMS_V1_PREVIEW.toString(); private static final String EVALUATIONS_PREVIEW_FEATURES @@ -343,11 +351,25 @@ private AIProjectClientImpl buildInnerClient() { } private AIProjectClientImpl buildInnerClient(String previewFeatures) { + return createInnerClientWithPreviewFeatures(previewFeatures); + } + + /** + * Builds an instance of AIProjectClientImpl with the provided parameters. + * + * @return an instance of AIProjectClientImpl. + */ + @Generated + private AIProjectClientImpl createInnerClientWithPreviewFeatures(String previewFeatures) { this.validateClient(); + HttpPipeline localPipeline; if (CoreUtils.isNullOrEmpty(previewFeatures)) { - return buildInnerClient(); + localPipeline = pipeline != null ? pipeline : createHttpPipeline(); + localPipeline = FoundryPolicyHelper.prependPolicy(localPipeline, + FoundryPolicyHelper.createPreviewErrorPolicy(allowPreview)); + } else { + localPipeline = resolvePipeline(previewFeatures); } - HttpPipeline localPipeline = resolvePipeline(previewFeatures); AIProjectsServiceVersion localServiceVersion = (serviceVersion != null) ? serviceVersion : AIProjectsServiceVersion.getLatest(); AIProjectClientImpl client = new AIProjectClientImpl(localPipeline, @@ -366,7 +388,7 @@ private void validateClient() { private HttpPipeline createHttpPipeline() { Configuration buildConfiguration = (configuration == null) ? Configuration.getGlobalConfiguration() : configuration; - HttpLogOptions localHttpLogOptions = this.httpLogOptions == null ? new HttpLogOptions() : this.httpLogOptions; + HttpLogOptions localHttpLogOptions = resolveHttpLogOptions(); ClientOptions localClientOptions = this.clientOptions == null ? new ClientOptions() : this.clientOptions; List policies = new ArrayList<>(); String clientName = PROPERTIES.getOrDefault(SDK_NAME, "UnknownName"); @@ -392,7 +414,7 @@ private HttpPipeline createHttpPipeline() { .filter(p -> p.getPipelinePosition() == HttpPipelinePosition.PER_RETRY) .forEach(p -> policies.add(p)); HttpPolicyProviders.addAfterRetryPolicies(policies); - policies.add(new HttpLoggingPolicy(localHttpLogOptions)); + policies.add(HttpClientHelper.createLoggingPolicy(localHttpLogOptions)); HttpPipeline httpPipeline = new HttpPipelineBuilder().policies(policies.toArray(new HttpPipelinePolicy[0])) .httpClient(httpClient) .clientOptions(localClientOptions) @@ -402,12 +424,44 @@ private HttpPipeline createHttpPipeline() { private HttpPipeline resolvePipeline(String foundryFeatures) { HttpPipeline localPipeline = pipeline != null ? pipeline : createHttpPipeline(); + localPipeline = FoundryPolicyHelper.prependPolicy(localPipeline, + FoundryPolicyHelper.createPreviewErrorPolicy(allowPreview)); HttpPipelinePolicy foundryFeaturesPolicy = FoundryPolicyHelper.createFoundryFeaturesPolicy(foundryFeatures); return FoundryPolicyHelper.prependPolicy(localPipeline, foundryFeaturesPolicy); } private com.openai.core.http.HttpClient createOpenAIHttpClient(String foundryFeatures) { - return HttpClientHelper.mapToOpenAIHttpClient(resolvePipeline(foundryFeatures)); + HttpPipeline localPipeline = pipeline != null ? pipeline : createOpenAIHttpPipeline(); + return HttpClientHelper.mapToOpenAIHttpClient( + FoundryPolicyHelper.prependPolicy(localPipeline, + FoundryPolicyHelper.createFoundryFeaturesPolicy(foundryFeatures)), + resolveHttpLogOptions().getLogLevel().shouldLogBody()); + } + + private HttpLogOptions resolveHttpLogOptions() { + if (httpLogOptions != null) { + return httpLogOptions; + } + Configuration buildConfiguration + = configuration == null ? Configuration.getGlobalConfiguration() : configuration; + HttpLogOptions options = new HttpLogOptions(); + if ("true".equalsIgnoreCase(buildConfiguration.get("AZURE_AI_PROJECTS_CONSOLE_LOGGING"))) { + options.setLogLevel(HttpLogDetailLevel.BODY_AND_HEADERS); + } + return options; + } + + private void configureOpenAIOptions(com.openai.core.ClientOptions.Builder options, String foundryFeatures) { + options.httpClient(createOpenAIHttpClient(foundryFeatures)); + String openAIUserAgent = String.join(" ", options.build().headers().values("User-Agent")); + Configuration buildConfiguration + = configuration == null ? Configuration.getGlobalConfiguration() : configuration; + String applicationId = CoreUtils.getApplicationId(clientOptions == null ? new ClientOptions() : clientOptions, + httpLogOptions == null ? new HttpLogOptions() : httpLogOptions); + String userAgent + = UserAgentUtil.toUserAgentString(applicationId, PROPERTIES.getOrDefault(SDK_NAME, "azure-ai-projects"), + PROPERTIES.getOrDefault(SDK_VERSION, "unknown"), buildConfiguration); + options.replaceHeaders("User-Agent", openAIUserAgent.isEmpty() ? userAgent : userAgent + " " + openAIUserAgent); } /** @@ -518,7 +572,18 @@ public EvaluationRulesClient buildEvaluationRulesClient() { */ public OpenAIClient buildOpenAIClient() { return getOpenAIClientBuilder(null).build() - .withOptions(optionBuilder -> optionBuilder.httpClient(createOpenAIHttpClient(null))); + .withOptions(optionBuilder -> configureOpenAIOptions(optionBuilder, null)); + } + + /** + * Builds a project-scoped OpenAI client with caller overrides applied after the defaults. + * + * @param configure callback for OpenAI options, including URL, credentials, headers, query, and transport. + * Custom pipelines retain their own authentication policies. Custom transports bypass the Azure pipeline. + * @return the configured OpenAI client. + */ + public OpenAIClient buildOpenAIClient(Consumer configure) { + return buildOpenAIClient().withOptions(Objects.requireNonNull(configure, "'configure' cannot be null.")); } /** @@ -534,7 +599,20 @@ public OpenAIClient buildAgentScopedOpenAIClient(String agentName) { throw LOGGER.logExceptionAsError(new IllegalArgumentException("'agentName' cannot be empty.")); } return getOpenAIClientBuilder(agentName).build() - .withOptions(optionBuilder -> optionBuilder.httpClient(createOpenAIHttpClient(null))); + .withOptions(optionBuilder -> configureOpenAIOptions(optionBuilder, AGENT_PREVIEW_FEATURES)); + } + + /** + * Builds an agent-scoped OpenAI client with preview headers and caller overrides. + * + * @param agentName the name of the agent. Must not be null or empty. + * @param configure callback applied after the defaults; see {@link #buildOpenAIClient(Consumer)}. + * @return the configured OpenAI client. + */ + public OpenAIClient buildAgentScopedOpenAIClient(String agentName, + Consumer configure) { + return buildAgentScopedOpenAIClient(agentName) + .withOptions(Objects.requireNonNull(configure, "'configure' cannot be null.")); } /** @@ -544,8 +622,21 @@ public OpenAIClient buildAgentScopedOpenAIClient(String agentName) { * @return an instance of OpenAIAsyncClient */ public OpenAIClientAsync buildOpenAIAsyncClient() { - return getOpenAIAsyncClientBuilder(null).build() - .withOptions(optionBuilder -> optionBuilder.httpClient(createOpenAIHttpClient(null))); + return createOpenAIAsyncClient(null, options -> { + }); + } + + /** + * Builds an asynchronous project-scoped OpenAI client with caller overrides. + * + * Azure tokens are retrieved asynchronously before transport execution. Supply custom transports here; + * replacing the native transport later bypasses Azure authentication and requires an explicit native credential. + * + * @param configure callback applied after the defaults; see {@link #buildOpenAIClient(Consumer)}. + * @return the configured asynchronous OpenAI client. + */ + public OpenAIClientAsync buildOpenAIAsyncClient(Consumer configure) { + return createOpenAIAsyncClient(null, Objects.requireNonNull(configure, "'configure' cannot be null.")); } /** @@ -560,8 +651,37 @@ public OpenAIClientAsync buildAgentScopedOpenAIAsyncClient(String agentName) { if (CoreUtils.isNullOrEmpty(agentName)) { throw LOGGER.logExceptionAsError(new IllegalArgumentException("'agentName' cannot be empty.")); } - return getOpenAIAsyncClientBuilder(agentName).build() - .withOptions(optionBuilder -> optionBuilder.httpClient(createOpenAIHttpClient(null))); + return createOpenAIAsyncClient(agentName, options -> { + }); + } + + /** + * Builds an asynchronous agent-scoped OpenAI client with preview headers and caller overrides. + * + * Supply custom transports through this callback so asynchronous Azure authentication remains installed. + * + * @param agentName the name of the agent. Must not be null or empty. + * @param configure callback applied after the defaults; see {@link #buildOpenAIClient(Consumer)}. + * @return the configured asynchronous OpenAI client. + * @throws IllegalArgumentException if agentName is null or empty. + */ + public OpenAIClientAsync buildAgentScopedOpenAIAsyncClient(String agentName, + Consumer configure) { + if (CoreUtils.isNullOrEmpty(agentName)) { + throw LOGGER.logExceptionAsError(new IllegalArgumentException("'agentName' cannot be empty.")); + } + return createOpenAIAsyncClient(agentName, Objects.requireNonNull(configure, "'configure' cannot be null.")); + } + + private OpenAIClientAsync createOpenAIAsyncClient(String agentName, + Consumer configure) { + TokenUtils.AsyncAuthentication authentication + = new TokenUtils.AsyncAuthentication(tokenCredential, DEFAULT_SCOPES); + return getOpenAIAsyncClientBuilder(agentName, authentication.getCredential()).build().withOptions(options -> { + configureOpenAIOptions(options, agentName == null ? null : AGENT_PREVIEW_FEATURES); + configure.accept(options); + authentication.configure(options); + }); } private String getDefaultBaseUrl() { @@ -579,16 +699,29 @@ private OpenAIOkHttpClient.Builder getOpenAIClientBuilder(String agentName) { .credential( BearerTokenCredential.create(TokenUtils.getBearerTokenSupplier(this.tokenCredential, DEFAULT_SCOPES))); builder.baseUrl(CoreUtils.isNullOrEmpty(agentName) ? getDefaultBaseUrl() : getAgentEndpointBaseUrl(agentName)); + builder.azureUrlPathMode(AzureUrlPathMode.UNIFIED); + if (!CoreUtils.isNullOrEmpty(agentName)) { + builder.putHeader("Foundry-Features", AGENT_PREVIEW_FEATURES); + AIProjectsServiceVersion localVersion + = serviceVersion == null ? AIProjectsServiceVersion.getLatest() : serviceVersion; + builder.putQueryParam("api-version", localVersion.getVersion()); + } // We set the builder retries to 0 to avoid conflicts with the retry policy added through the HttpPipeline. builder.maxRetries(0); return builder; } - private OpenAIOkHttpClientAsync.Builder getOpenAIAsyncClientBuilder(String agentName) { - OpenAIOkHttpClientAsync.Builder builder = OpenAIOkHttpClientAsync.builder() - .credential( - BearerTokenCredential.create(TokenUtils.getBearerTokenSupplier(this.tokenCredential, DEFAULT_SCOPES))); + private OpenAIOkHttpClientAsync.Builder getOpenAIAsyncClientBuilder(String agentName, + com.openai.credential.Credential credential) { + OpenAIOkHttpClientAsync.Builder builder = OpenAIOkHttpClientAsync.builder().credential(credential); builder.baseUrl(CoreUtils.isNullOrEmpty(agentName) ? getDefaultBaseUrl() : getAgentEndpointBaseUrl(agentName)); + builder.azureUrlPath(AzureUrlPathMode.UNIFIED); + if (!CoreUtils.isNullOrEmpty(agentName)) { + builder.putHeader("Foundry-Features", AGENT_PREVIEW_FEATURES); + AIProjectsServiceVersion localVersion + = serviceVersion == null ? AIProjectsServiceVersion.getLatest() : serviceVersion; + builder.putQueryParam("api-version", localVersion.getVersion()); + } // We set the builder retries to 0 to avoid conflicts with the retry policy added through the HttpPipeline. builder.maxRetries(0); return builder; @@ -780,6 +913,41 @@ private BetaAgentInsightMonitorsClient buildBetaAgentInsightMonitorsClient() { buildInnerClient(AGENT_INSIGHTS_PREVIEW_FEATURES).getBetaAgentInsightMonitors()); } + @Generated + private HttpPipeline createOpenAIHttpPipeline() { + Configuration buildConfiguration + = (configuration == null) ? Configuration.getGlobalConfiguration() : configuration; + HttpLogOptions localHttpLogOptions = resolveHttpLogOptions(); + ClientOptions localClientOptions = this.clientOptions == null ? new ClientOptions() : this.clientOptions; + List policies = new ArrayList<>(); + String clientName = PROPERTIES.getOrDefault(SDK_NAME, "UnknownName"); + String clientVersion = PROPERTIES.getOrDefault(SDK_VERSION, "UnknownVersion"); + String applicationId = CoreUtils.getApplicationId(localClientOptions, localHttpLogOptions); + policies.add(new UserAgentPolicy(applicationId, clientName, clientVersion, buildConfiguration)); + policies.add(new RequestIdPolicy()); + policies.add(new AddHeadersFromContextPolicy()); + HttpHeaders headers = CoreUtils.createHttpHeadersFromClientOptions(localClientOptions); + if (headers != null) { + policies.add(new AddHeadersPolicy(headers)); + } + this.pipelinePolicies.stream() + .filter(p -> p.getPipelinePosition() == HttpPipelinePosition.PER_CALL) + .forEach(p -> policies.add(p)); + HttpPolicyProviders.addBeforeRetryPolicies(policies); + policies.add(ClientBuilderUtil.validateAndGetRetryPolicy(retryPolicy, retryOptions, new RetryPolicy())); + policies.add(new AddDatePolicy()); + this.pipelinePolicies.stream() + .filter(p -> p.getPipelinePosition() == HttpPipelinePosition.PER_RETRY) + .forEach(p -> policies.add(p)); + HttpPolicyProviders.addAfterRetryPolicies(policies); + policies.add(HttpClientHelper.createLoggingPolicy(localHttpLogOptions)); + HttpPipeline httpPipeline = new HttpPipelineBuilder().policies(policies.toArray(new HttpPipelinePolicy[0])) + .httpClient(httpClient) + .clientOptions(localClientOptions) + .build(); + return httpPipeline; + } + /** * Returns the sub-builder used to create beta clients for preview-only service areas. *

@@ -827,7 +995,9 @@ public BetaAIProjectClientBuilder beta() { BetaRoutinesClient.class, BetaSkillsClient.class, BetaDatasetsClient.class, - BetaAgentInsightMonitorsClient.class }) + BetaAgentInsightMonitorsClient.class, + BetaTelemetryClient.class, + BetaTelemetryAsyncClient.class }) public final class BetaAIProjectClientBuilder { /** @@ -837,6 +1007,26 @@ public final class BetaAIProjectClientBuilder { private BetaAIProjectClientBuilder() { } + /** + * Builds an asynchronous client for the project's telemetry configuration. + * + * @return an asynchronous telemetry client. + */ + @Beta + public BetaTelemetryAsyncClient buildBetaTelemetryAsyncClient() { + return new BetaTelemetryAsyncClient(buildConnectionsAsyncClient()); + } + + /** + * Builds a synchronous client for the project's telemetry configuration. + * + * @return a synchronous telemetry client. + */ + @Beta + public BetaTelemetryClient buildBetaTelemetryClient() { + return new BetaTelemetryClient(buildConnectionsClient()); + } + /** * Builds an asynchronous beta Models client for preview model operations. *

diff --git a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaAgentInsightMonitorsAsyncClient.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaAgentInsightMonitorsAsyncClient.java index 5826f4938a964..378d3f62a334a 100644 --- a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaAgentInsightMonitorsAsyncClient.java +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaAgentInsightMonitorsAsyncClient.java @@ -48,6 +48,19 @@ @Beta(warningText = "This class is in preview and may change in future releases.") public final class BetaAgentInsightMonitorsAsyncClient { + /** + * Resumes an existing agent insight run without starting another run. + * + * @param monitorId monitor ID. + * @param runId saved run ID. + * @return the resumed poller. Use the run cancellation API to cancel. + */ + public PollerFlux resumeAgentInsightRun(String monitorId, String runId) { + return com.azure.ai.projects.implementation.ProjectsServicePollUtils.resumeAsync( + () -> getAgentInsightRunWithResponse(monitorId, runId, new RequestOptions()), AgentInsightRun.class, + AgentInsightRunResult.class); + } + @Generated private final BetaAgentInsightMonitorsImpl serviceClient; diff --git a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaAgentInsightMonitorsClient.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaAgentInsightMonitorsClient.java index 308bcf0db899d..72b215442b2e3 100644 --- a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaAgentInsightMonitorsClient.java +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaAgentInsightMonitorsClient.java @@ -42,6 +42,19 @@ @Beta(warningText = "This class is in preview and may change in future releases.") public final class BetaAgentInsightMonitorsClient { + /** + * Resumes an existing agent insight run without starting another run. + * + * @param monitorId monitor ID. + * @param runId saved run ID. + * @return the resumed poller. Use the run cancellation API to cancel. + */ + public SyncPoller resumeAgentInsightRun(String monitorId, String runId) { + return com.azure.ai.projects.implementation.ProjectsServicePollUtils.resume( + () -> getAgentInsightRunWithResponse(monitorId, runId, new RequestOptions()), AgentInsightRun.class, + AgentInsightRunResult.class); + } + @Generated private final BetaAgentInsightMonitorsImpl serviceClient; diff --git a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaDatasetsAsyncClient.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaDatasetsAsyncClient.java index 2f9db4a46db1b..e0b2521664c06 100644 --- a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaDatasetsAsyncClient.java +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaDatasetsAsyncClient.java @@ -36,6 +36,18 @@ @Beta(warningText = "This class is in preview and may change in future releases.") public final class BetaDatasetsAsyncClient { + /** + * Resumes an existing data generation job without creating a new job. + * + * @param jobId saved job ID. + * @return the resumed poller. Use the job cancellation API to cancel. + */ + public PollerFlux resumeGenerationJob(String jobId) { + return com.azure.ai.projects.implementation.ProjectsServicePollUtils.resumeAsync( + () -> getGenerationJobWithResponse(jobId, new RequestOptions()), DataGenerationJob.class, + DataGenerationJobResult.class); + } + @Generated private final BetaDatasetsImpl serviceClient; diff --git a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaDatasetsClient.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaDatasetsClient.java index 942e519482162..0cbf7774e6104 100644 --- a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaDatasetsClient.java +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaDatasetsClient.java @@ -30,6 +30,18 @@ @Beta(warningText = "This class is in preview and may change in future releases.") public final class BetaDatasetsClient { + /** + * Resumes an existing data generation job without creating a new job. + * + * @param jobId saved job ID. + * @return the resumed poller. Use the job cancellation API to cancel. + */ + public SyncPoller resumeGenerationJob(String jobId) { + return com.azure.ai.projects.implementation.ProjectsServicePollUtils.resume( + () -> getGenerationJobWithResponse(jobId, new RequestOptions()), DataGenerationJob.class, + DataGenerationJobResult.class); + } + @Generated private final BetaDatasetsImpl serviceClient; diff --git a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaEvaluatorsAsyncClient.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaEvaluatorsAsyncClient.java index 92036e6b13eca..c63f549436427 100644 --- a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaEvaluatorsAsyncClient.java +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaEvaluatorsAsyncClient.java @@ -41,6 +41,18 @@ @Beta(warningText = "This class is in preview and may change in future releases.") public final class BetaEvaluatorsAsyncClient { + /** + * Resumes an existing evaluator generation job without creating a new job. + * + * @param jobId saved job ID. + * @return the resumed poller. Use the job cancellation API to cancel. + */ + public PollerFlux resumeEvaluatorGenerationJob(String jobId) { + return com.azure.ai.projects.implementation.ProjectsServicePollUtils.resumeAsync( + () -> getEvaluatorGenerationJobWithResponse(jobId, new RequestOptions()), EvaluatorGenerationJob.class, + EvaluatorVersion.class); + } + @Generated private final BetaEvaluatorsImpl serviceClient; diff --git a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaEvaluatorsClient.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaEvaluatorsClient.java index 99206c9b90631..afc6ab0f8978a 100644 --- a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaEvaluatorsClient.java +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaEvaluatorsClient.java @@ -35,6 +35,18 @@ @Beta(warningText = "This class is in preview and may change in future releases.") public final class BetaEvaluatorsClient { + /** + * Resumes an existing evaluator generation job without creating a new job. + * + * @param jobId saved job ID. + * @return the resumed poller. Use the job cancellation API to cancel. + */ + public SyncPoller resumeEvaluatorGenerationJob(String jobId) { + return com.azure.ai.projects.implementation.ProjectsServicePollUtils.resume( + () -> getEvaluatorGenerationJobWithResponse(jobId, new RequestOptions()), EvaluatorGenerationJob.class, + EvaluatorVersion.class); + } + @Generated private final BetaEvaluatorsImpl serviceClient; diff --git a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaModelsAsyncClient.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaModelsAsyncClient.java index d324eed611379..ef07c6fef36e7 100644 --- a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaModelsAsyncClient.java +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaModelsAsyncClient.java @@ -4,6 +4,7 @@ package com.azure.ai.projects; import com.azure.ai.projects.implementation.BetaModelsImpl; +import com.azure.ai.projects.implementation.FileUploadHelper; import com.azure.ai.projects.implementation.JsonMergePatchHelper; import com.azure.ai.projects.implementation.utils.Beta; import com.azure.ai.projects.models.CreateAsyncResponse; @@ -11,6 +12,7 @@ import com.azure.ai.projects.models.ModelCredentialInput; import com.azure.ai.projects.models.ModelPendingUploadInput; import com.azure.ai.projects.models.ModelPendingUploadResult; +import com.azure.ai.projects.models.ModelUploadOptions; import com.azure.ai.projects.models.ModelVersion; import com.azure.ai.projects.models.UpdateModelVersionInput; import com.azure.core.annotation.Generated; @@ -28,9 +30,16 @@ import com.azure.core.http.rest.Response; import com.azure.core.util.BinaryData; import com.azure.core.util.FluxUtil; +import com.azure.core.util.polling.LongRunningOperationStatus; +import com.azure.core.util.polling.PollResponse; +import com.azure.core.util.polling.PollerFlux; +import com.azure.storage.blob.BlobContainerAsyncClient; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.stream.Collectors; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; /** * Initializes a new instance of the asynchronous AIProjectClient type. @@ -39,6 +48,68 @@ @Beta(warningText = "This class is in preview and may change in future releases.") public final class BetaModelsAsyncClient { + /** + * Uploads a local file or folder and registers a model using native asynchronous storage and service calls. + * Only HTTP 404 is retried while waiting. Upload failures prevent registration. + * + * @param name model name. + * @param version model version. + * @param source local file or folder. + * @param options metadata, upload settings and wait settings; null uses defaults. + * @return the registered model, or the submitted model when waiting is disabled. + */ + public Mono createModel(String name, String version, Path source, ModelUploadOptions options) { + return Mono.defer(() -> { + ModelUploadOptions settings = options == null ? new ModelUploadOptions() : options; + return Mono.fromCallable(() -> FileUploadHelper.getModelFiles(name, version, source, settings)) + .subscribeOn(Schedulers.boundedElastic()) + .flatMap(files -> startModelPendingUploadWithResponse(name, version, + BinaryData + .fromObject(new ModelPendingUploadInput().setConnectionName(settings.getConnectionName())), + new RequestOptions()).flatMap(pendingResponse -> { + com.azure.ai.projects.models.BlobReference reference + = FileUploadHelper.getModelBlobReference(pendingResponse.getValue()); + BlobContainerAsyncClient container + = FileUploadHelper.createContainerBuilder(reference, settings.getFileUploadOptions()) + .buildAsyncClient(); + boolean directory = Files.isDirectory(source); + ModelVersion submitted = FileUploadHelper.createModelVersion(reference.getBlobUrl(), settings); + return Flux.fromIterable(files).concatMap(file -> { + String blobName = directory + ? source.relativize(file).toString().replace('\\', '/') + : file.getFileName().toString(); + return container.getBlobAsyncClient(blobName) + .uploadWithResponse( + FileUploadHelper.createUploadOptions(file, settings.getFileUploadOptions())); + }) + .then(Mono.defer(() -> createModelVersionAsync(name, version, submitted))) + .then(Mono.defer(() -> { + if (!settings.isWaitForCompletion()) { + return Mono.just(submitted); + } + PollerFlux poller + = new PollerFlux<>(settings.getPollInterval(), context -> Mono.just(submitted), + context -> getModelVersion(name, version) + .map(model -> new PollResponse<>( + LongRunningOperationStatus.SUCCESSFULLY_COMPLETED, model)) + .onErrorResume(HttpResponseException.class, + exception -> exception.getResponse() != null + && exception.getResponse().getStatusCode() == 404 + ? Mono.just(new PollResponse<>( + LongRunningOperationStatus.IN_PROGRESS, submitted)) + : Mono.error(exception)), + (context, + response) -> Mono.error(new UnsupportedOperationException( + "Model registration cannot be cancelled.")), + context -> Mono.just(context.getLatestResponse().getValue())); + return poller.last() + .flatMap(response -> response.getFinalResult()) + .timeout(settings.getTimeout()); + })); + })); + }); + } + @Generated private final BetaModelsImpl serviceClient; diff --git a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaModelsClient.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaModelsClient.java index c4969bfee5196..c984472d8bddd 100644 --- a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaModelsClient.java +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaModelsClient.java @@ -4,6 +4,7 @@ package com.azure.ai.projects; import com.azure.ai.projects.implementation.BetaModelsImpl; +import com.azure.ai.projects.implementation.FileUploadHelper; import com.azure.ai.projects.implementation.JsonMergePatchHelper; import com.azure.ai.projects.implementation.utils.Beta; import com.azure.ai.projects.models.CreateAsyncResponse; @@ -11,6 +12,7 @@ import com.azure.ai.projects.models.ModelCredentialInput; import com.azure.ai.projects.models.ModelPendingUploadInput; import com.azure.ai.projects.models.ModelPendingUploadResult; +import com.azure.ai.projects.models.ModelUploadOptions; import com.azure.ai.projects.models.ModelVersion; import com.azure.ai.projects.models.UpdateModelVersionInput; import com.azure.core.annotation.Generated; @@ -25,6 +27,14 @@ import com.azure.core.http.rest.RequestOptions; import com.azure.core.http.rest.Response; import com.azure.core.util.BinaryData; +import com.azure.core.util.Context; +import com.azure.core.util.polling.LongRunningOperationStatus; +import com.azure.core.util.polling.PollResponse; +import com.azure.core.util.polling.SyncPoller; +import com.azure.storage.blob.BlobContainerClient; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; /** * Initializes a new instance of the synchronous AIProjectClient type. @@ -33,6 +43,66 @@ @Beta(warningText = "This class is in preview and may change in future releases.") public final class BetaModelsClient { + private static final com.azure.core.util.logging.ClientLogger LOGGER + = new com.azure.core.util.logging.ClientLogger(BetaModelsClient.class); + + /** + * Uploads a local file or folder, registers a model version, and optionally waits for it to become available. + * Only HTTP 404 is retried while waiting. Upload failures prevent registration. + * + * @param name model name. + * @param version model version. + * @param source local file or folder. + * @param options metadata, upload settings and wait settings; null uses defaults. + * @return the registered model, or the submitted model when waiting is disabled. + * @throws HttpResponseException if registration fails or a poll returns an error other than HTTP 404. + * @throws IllegalArgumentException if an upload path has no file name. + * @throws UnsupportedOperationException if the internal poller is cancelled. + */ + public ModelVersion createModel(String name, String version, Path source, ModelUploadOptions options) { + ModelUploadOptions settings = options == null ? new ModelUploadOptions() : options; + List files = FileUploadHelper.getModelFiles(name, version, source, settings); + com.azure.ai.projects.models.BlobReference reference + = FileUploadHelper.getModelBlobReference(startModelPendingUploadWithResponse(name, version, + BinaryData.fromObject(new ModelPendingUploadInput().setConnectionName(settings.getConnectionName())), + new RequestOptions()).getValue()); + BlobContainerClient container + = FileUploadHelper.createContainerBuilder(reference, settings.getFileUploadOptions()).buildClient(); + boolean directory = Files.isDirectory(source); + for (Path file : files) { + Path fileName = file.getFileName(); + if (fileName == null) { + throw LOGGER + .logExceptionAsError(new IllegalArgumentException("The upload path must have a file name.")); + } + String blobName = directory ? source.relativize(file).toString().replace('\\', '/') : fileName.toString(); + container.getBlobClient(blobName) + .uploadWithResponse(FileUploadHelper.createUploadOptions(file, settings.getFileUploadOptions()), null, + Context.NONE); + } + ModelVersion submitted = FileUploadHelper.createModelVersion(reference.getBlobUrl(), settings); + createModelVersionAsync(name, version, submitted); + if (!settings.isWaitForCompletion()) { + return submitted; + } + SyncPoller poller = SyncPoller.createPoller(settings.getPollInterval(), + context -> new PollResponse<>(LongRunningOperationStatus.IN_PROGRESS, submitted), context -> { + try { + return new PollResponse<>(LongRunningOperationStatus.SUCCESSFULLY_COMPLETED, + getModelVersion(name, version)); + } catch (HttpResponseException exception) { + if (exception.getResponse() == null || exception.getResponse().getStatusCode() != 404) { + throw LOGGER.logExceptionAsError(exception); + } + return new PollResponse<>(LongRunningOperationStatus.IN_PROGRESS, submitted); + } + }, (context, response) -> { + throw LOGGER + .logExceptionAsError(new UnsupportedOperationException("Model registration cannot be cancelled.")); + }, context -> context.getLatestResponse().getValue()); + return poller.getFinalResult(settings.getTimeout()); + } + @Generated private final BetaModelsImpl serviceClient; diff --git a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaTelemetryAsyncClient.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaTelemetryAsyncClient.java new file mode 100644 index 0000000000000..8788d2505f262 --- /dev/null +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaTelemetryAsyncClient.java @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.projects; + +import com.azure.ai.projects.models.ApiKeyCredential; +import com.azure.ai.projects.models.Connection; +import com.azure.ai.projects.models.ConnectionType; +import com.azure.ai.projects.implementation.utils.Beta; +import com.azure.core.annotation.ServiceClient; +import com.azure.core.annotation.ServiceMethod; +import com.azure.core.annotation.ReturnType; +import com.azure.core.exception.ResourceNotFoundException; +import com.azure.core.util.CoreUtils; +import reactor.core.publisher.Mono; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Asynchronous access to the project's telemetry configuration. + * Instances are created through + * {@link AIProjectClientBuilder.BetaAIProjectClientBuilder#buildBetaTelemetryAsyncClient()}. + */ +@ServiceClient(builder = AIProjectClientBuilder.class, isAsync = true) +@Beta(warningText = "This class is in preview and may change in future releases.") +public final class BetaTelemetryAsyncClient { + private final ConnectionsAsyncClient connections; + private final AtomicReference connectionString = new AtomicReference<>(); + + BetaTelemetryAsyncClient(ConnectionsAsyncClient connections) { + this.connections = connections; + } + + /** + * Gets the project's Application Insights connection string, caching successful lookups for this client. + * + * @return the Application Insights connection string. + * @throws ResourceNotFoundException if the project has no Application Insights connection. + * @throws IllegalStateException if the connection does not contain a nonempty API key credential. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Mono getApplicationInsightsConnectionString() { + return Mono.defer(() -> { + String cached = connectionString.get(); + if (cached != null) { + return Mono.just(cached); + } + return connections.listConnections(ConnectionType.APPLICATION_INSIGHTS, null) + .next() + .filter(connection -> !CoreUtils.isNullOrEmpty(connection.getName())) + .switchIfEmpty( + Mono.error(new ResourceNotFoundException("No Application Insights connection found.", null))) + .flatMap(connection -> connections.getConnection(connection.getName(), true)) + .map(BetaTelemetryAsyncClient::getConnectionString) + .doOnNext(connectionString::set); + }); + } + + private static String getConnectionString(Connection connection) { + if (!(connection.getCredential() instanceof ApiKeyCredential)) { + throw new IllegalStateException("Application Insights connection does not use API Key credentials."); + } + String value = ((ApiKeyCredential) connection.getCredential()).getApiKey(); + if (CoreUtils.isNullOrEmpty(value)) { + throw new IllegalStateException("Application Insights connection does not have a connection string."); + } + return value; + } +} diff --git a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaTelemetryClient.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaTelemetryClient.java new file mode 100644 index 0000000000000..c2e462453210a --- /dev/null +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaTelemetryClient.java @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.projects; + +import com.azure.ai.projects.models.ApiKeyCredential; +import com.azure.ai.projects.models.Connection; +import com.azure.ai.projects.models.ConnectionType; +import com.azure.ai.projects.implementation.utils.Beta; +import com.azure.core.annotation.ServiceClient; +import com.azure.core.annotation.ServiceMethod; +import com.azure.core.annotation.ReturnType; +import com.azure.core.exception.ResourceNotFoundException; +import com.azure.core.util.CoreUtils; +import com.azure.core.util.logging.ClientLogger; + +import java.util.Iterator; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Synchronous access to the project's telemetry configuration. + * Instances are created through {@link AIProjectClientBuilder.BetaAIProjectClientBuilder#buildBetaTelemetryClient()}. + */ +@ServiceClient(builder = AIProjectClientBuilder.class) +@Beta(warningText = "This class is in preview and may change in future releases.") +public final class BetaTelemetryClient { + private static final ClientLogger LOGGER = new ClientLogger(BetaTelemetryClient.class); + private final ConnectionsClient connections; + private final AtomicReference connectionString = new AtomicReference<>(); + + BetaTelemetryClient(ConnectionsClient connections) { + this.connections = connections; + } + + /** + * Gets the project's Application Insights connection string, caching successful lookups for this client. + * + * @return the Application Insights connection string. + * @throws ResourceNotFoundException if the project has no Application Insights connection. + * @throws IllegalStateException if the connection does not contain a nonempty API key credential. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public String getApplicationInsightsConnectionString() { + String cached = connectionString.get(); + if (cached != null) { + return cached; + } + Iterator iterator + = connections.listConnections(ConnectionType.APPLICATION_INSIGHTS, null).iterator(); + if (!iterator.hasNext()) { + throw LOGGER + .logExceptionAsError(new ResourceNotFoundException("No Application Insights connection found.", null)); + } + String name = iterator.next().getName(); + if (CoreUtils.isNullOrEmpty(name)) { + throw LOGGER + .logExceptionAsError(new ResourceNotFoundException("No Application Insights connection found.", null)); + } + Connection connection = connections.getConnection(name, true); + if (!(connection.getCredential() instanceof ApiKeyCredential)) { + throw LOGGER.logExceptionAsError( + new IllegalStateException("Application Insights connection does not use API Key credentials.")); + } + String value = ((ApiKeyCredential) connection.getCredential()).getApiKey(); + if (CoreUtils.isNullOrEmpty(value)) { + throw LOGGER.logExceptionAsError( + new IllegalStateException("Application Insights connection does not have a connection string.")); + } + connectionString.set(value); + return value; + } +} diff --git a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/DatasetsAsyncClient.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/DatasetsAsyncClient.java index d20f27d5e6504..65ad0c3789c1d 100644 --- a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/DatasetsAsyncClient.java +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/DatasetsAsyncClient.java @@ -4,10 +4,12 @@ package com.azure.ai.projects; import com.azure.ai.projects.implementation.DatasetsImpl; +import com.azure.ai.projects.implementation.FileUploadHelper; import com.azure.ai.projects.implementation.JsonMergePatchHelper; import com.azure.ai.projects.models.DatasetCredential; import com.azure.ai.projects.models.DatasetVersion; import com.azure.ai.projects.models.FileDatasetVersion; +import com.azure.ai.projects.models.FileUploadOptions; import com.azure.ai.projects.models.FolderDatasetVersion; import com.azure.ai.projects.models.PendingUploadRequest; import com.azure.ai.projects.models.PendingUploadResponse; @@ -27,18 +29,13 @@ import com.azure.core.util.BinaryData; import com.azure.core.util.FluxUtil; import com.azure.storage.blob.BlobAsyncClient; -import com.azure.storage.blob.BlobClientBuilder; import com.azure.storage.blob.BlobContainerAsyncClient; -import com.azure.storage.blob.BlobContainerClientBuilder; -import java.io.IOException; -import java.io.UncheckedIOException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.List; import java.util.stream.Collectors; -import java.util.stream.Stream; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; /** * Initializes a new instance of the asynchronous AIProjectClient type. @@ -201,28 +198,66 @@ public Mono> createDatasetWithFileWithResponse(String name, @ServiceMethod(returns = ReturnType.SINGLE) public Mono> createDatasetWithFileWithResponse(String name, String version, Path filePath, String connectionName, RequestOptions requestOptions) { - if (!Files.isRegularFile(filePath)) { - return Mono.error(new IllegalArgumentException("The provided path is not a file: " + filePath)); - } - PendingUploadRequest request = new PendingUploadRequest(); - if (connectionName != null) { - request.setConnectionName(connectionName); - } - return this.pendingUploadWithResponse(name, version, BinaryData.fromObject(request), requestOptions) - .flatMap(FluxUtil::toMono) - .map(protocolMethodData -> protocolMethodData.toObject(PendingUploadResponse.class)) - .flatMap(pendingUploadResponse -> { - String sasUri = pendingUploadResponse.getBlobReference().getCredential().getSasUrl(); - BlobAsyncClient blobClient = new BlobClientBuilder().endpoint(sasUri) - .blobName(filePath.getFileName().toString()) - .buildAsyncClient(); - return blobClient.upload(BinaryData.fromFile(filePath), true).thenReturn(blobClient.getBlobUrl()); - }) - .flatMap(blobUrl -> { - FileDatasetVersion fileDataset = new FileDatasetVersion().setDataUrl(blobUrl); - return this.createOrUpdateDatasetVersionWithResponse(name, version, BinaryData.fromObject(fileDataset), - requestOptions); - }); + return createDatasetWithFileWithResponse(name, version, filePath, connectionName, null, requestOptions); + } + + /** + * Uploads a file and registers a dataset using custom blob upload settings. + * + * @param name the dataset name. + * @param version the dataset version. + * @param filePath the local file. + * @param connectionName the storage connection, or null for the default. + * @param uploadOptions the upload options, or null for defaults. + * @return the created dataset asynchronously. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Mono createDatasetWithFile(String name, String version, Path filePath, + String connectionName, FileUploadOptions uploadOptions) { + return createDatasetWithFileWithResponse(name, version, filePath, connectionName, uploadOptions, + new RequestOptions()).map(response -> response.getValue().toObject(FileDatasetVersion.class)); + } + + /** + * Uploads a file and registers a dataset using custom blob upload settings. + * + * @param name the dataset name. + * @param version the dataset version. + * @param filePath the local file. + * @param connectionName the storage connection, or null for the default. + * @param uploadOptions the upload options, or null for defaults. + * @param requestOptions project request options; blob options are configured separately. + * @return the dataset response asynchronously. + * @throws IllegalArgumentException if the path is not a regular file or upload credentials are missing. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Mono> createDatasetWithFileWithResponse(String name, String version, Path filePath, + String connectionName, FileUploadOptions uploadOptions, RequestOptions requestOptions) { + return Mono.defer(() -> { + if (filePath == null || filePath.getFileName() == null || !Files.isRegularFile(filePath)) { + return Mono.error(new IllegalArgumentException("The provided path is not a file: " + filePath)); + } + PendingUploadRequest request = new PendingUploadRequest(); + if (connectionName != null) { + request.setConnectionName(connectionName); + } + return this.pendingUploadWithResponse(name, version, BinaryData.fromObject(request), requestOptions) + .flatMap(FluxUtil::toMono) + .map(protocolMethodData -> protocolMethodData.toObject(PendingUploadResponse.class)) + .flatMap(pendingUploadResponse -> { + BlobAsyncClient blobClient = FileUploadHelper + .createContainerBuilder(pendingUploadResponse.getBlobReference(), uploadOptions) + .buildAsyncClient() + .getBlobAsyncClient(filePath.getFileName().toString()); + return blobClient.uploadWithResponse(FileUploadHelper.createUploadOptions(filePath, uploadOptions)) + .thenReturn(blobClient.getBlobUrl()); + }) + .flatMap(blobUrl -> { + FileDatasetVersion fileDataset = new FileDatasetVersion().setDataUrl(blobUrl); + return this.createOrUpdateDatasetVersionWithResponse(name, version, + BinaryData.fromObject(fileDataset), requestOptions); + }); + }).subscribeOn(Schedulers.boundedElastic()); } /** @@ -300,41 +335,67 @@ public Mono> createDatasetWithFolderWithResponse(String nam @ServiceMethod(returns = ReturnType.SINGLE) public Mono> createDatasetWithFolderWithResponse(String name, String version, Path folderPath, String connectionName, RequestOptions requestOptions) { - if (!Files.isDirectory(folderPath)) { - return Mono.error(new IllegalArgumentException("The provided path is not a folder: " + folderPath)); - } - PendingUploadRequest request = new PendingUploadRequest(); - if (connectionName != null) { - request.setConnectionName(connectionName); - } - return this.pendingUploadWithResponse(name, version, BinaryData.fromObject(request), requestOptions) - .flatMap(FluxUtil::toMono) - .map(protocolMethodData -> protocolMethodData.toObject(PendingUploadResponse.class)) - .flatMap(pendingUploadResponse -> { - String containerUrl = pendingUploadResponse.getBlobReference().getBlobUrl(); - String sasUri = pendingUploadResponse.getBlobReference().getCredential().getSasUrl(); - BlobContainerAsyncClient containerClient - = new BlobContainerClientBuilder().endpoint(sasUri).buildAsyncClient(); - try { - List files; - try (Stream fileStream = Files.walk(folderPath)) { - files = fileStream.filter(Files::isRegularFile).collect(Collectors.toList()); - } - return Flux.fromIterable(files).flatMap(filePath -> { - String relativePath = folderPath.relativize(filePath).toString().replace('\\', '/'); - return containerClient.getBlobAsyncClient(relativePath) - .upload(BinaryData.fromFile(filePath), true); - }).then(Mono.just(containerUrl)); - } catch (IOException e) { - return Mono.error(new UncheckedIOException("Failed to walk folder path: " + folderPath, e)); - } catch (RuntimeException e) { - return Mono.error(e); + return createDatasetWithFolderWithResponse(name, version, folderPath, connectionName, null, requestOptions); + } + + /** + * Uploads matching files recursively and registers a folder dataset. + * + * @param name the dataset name. + * @param version the dataset version. + * @param folderPath the local directory. + * @param connectionName the storage connection, or null for the default. + * @param uploadOptions the filename filter and blob settings, or null for defaults. + * @return the created dataset asynchronously. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Mono createDatasetWithFolder(String name, String version, Path folderPath, + String connectionName, FileUploadOptions uploadOptions) { + return createDatasetWithFolderWithResponse(name, version, folderPath, connectionName, uploadOptions, + new RequestOptions()).map(response -> response.getValue().toObject(FolderDatasetVersion.class)); + } + + /** + * Uploads matching files recursively and registers a folder dataset. Relative paths are preserved. + * + * @param name the dataset name. + * @param version the dataset version. + * @param folderPath the local directory. + * @param connectionName the storage connection, or null for the default. + * @param uploadOptions the filename filter and blob settings, or null for defaults. + * @param requestOptions project request options; blob options are configured separately. + * @return the dataset response asynchronously. + * @throws IllegalArgumentException if the folder contains no matching files or upload credentials are missing. + * @throws java.io.UncheckedIOException if the folder cannot be traversed. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Mono> createDatasetWithFolderWithResponse(String name, String version, Path folderPath, + String connectionName, FileUploadOptions uploadOptions, RequestOptions requestOptions) { + return Mono.fromCallable(() -> FileUploadHelper.getFiles(folderPath, uploadOptions)) + .subscribeOn(Schedulers.boundedElastic()) + .flatMap(files -> { + PendingUploadRequest request = new PendingUploadRequest(); + if (connectionName != null) { + request.setConnectionName(connectionName); } - }) - .flatMap(containerUrl -> { - FolderDatasetVersion folderDataset = new FolderDatasetVersion().setDataUrl(containerUrl); - return this.createOrUpdateDatasetVersionWithResponse(name, version, - BinaryData.fromObject(folderDataset), requestOptions); + return this.pendingUploadWithResponse(name, version, BinaryData.fromObject(request), requestOptions) + .flatMap(FluxUtil::toMono) + .map(protocolMethodData -> protocolMethodData.toObject(PendingUploadResponse.class)) + .flatMap(pendingUploadResponse -> { + BlobContainerAsyncClient containerClient = FileUploadHelper + .createContainerBuilder(pendingUploadResponse.getBlobReference(), uploadOptions) + .buildAsyncClient(); + return Flux.fromIterable(files).concatMap(filePath -> { + String relativePath = folderPath.relativize(filePath).toString().replace('\\', '/'); + return containerClient.getBlobAsyncClient(relativePath) + .uploadWithResponse(FileUploadHelper.createUploadOptions(filePath, uploadOptions)); + }).then(Mono.just(containerClient.getBlobContainerUrl())); + }) + .flatMap(containerUrl -> { + FolderDatasetVersion folderDataset = new FolderDatasetVersion().setDataUrl(containerUrl); + return this.createOrUpdateDatasetVersionWithResponse(name, version, + BinaryData.fromObject(folderDataset), requestOptions); + }); }); } diff --git a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/DatasetsClient.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/DatasetsClient.java index 434d951315775..4c60c7fa091e1 100644 --- a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/DatasetsClient.java +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/DatasetsClient.java @@ -4,11 +4,12 @@ package com.azure.ai.projects; import com.azure.ai.projects.implementation.DatasetsImpl; +import com.azure.ai.projects.implementation.FileUploadHelper; import com.azure.ai.projects.implementation.JsonMergePatchHelper; -import com.azure.ai.projects.models.BlobReferenceSasCredential; import com.azure.ai.projects.models.DatasetCredential; import com.azure.ai.projects.models.DatasetVersion; import com.azure.ai.projects.models.FileDatasetVersion; +import com.azure.ai.projects.models.FileUploadOptions; import com.azure.ai.projects.models.FolderDatasetVersion; import com.azure.ai.projects.models.PendingUploadRequest; import com.azure.ai.projects.models.PendingUploadResponse; @@ -24,16 +25,14 @@ import com.azure.core.http.rest.RequestOptions; import com.azure.core.http.rest.Response; import com.azure.core.util.BinaryData; +import com.azure.core.util.Context; import com.azure.core.util.logging.ClientLogger; import com.azure.storage.blob.BlobClient; -import com.azure.storage.blob.BlobClientBuilder; import com.azure.storage.blob.BlobContainerClient; -import com.azure.storage.blob.BlobContainerClientBuilder; -import java.io.IOException; import java.io.UncheckedIOException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.stream.Stream; +import java.util.List; /** * Initializes a new instance of the synchronous AIProjectClient type. @@ -191,7 +190,43 @@ public Response createDatasetWithFileWithResponse(String name, Strin @ServiceMethod(returns = ReturnType.SINGLE) public Response createDatasetWithFileWithResponse(String name, String version, Path filePath, String connectionName, RequestOptions requestOptions) { - if (!Files.isRegularFile(filePath)) { + return createDatasetWithFileWithResponse(name, version, filePath, connectionName, null, requestOptions); + } + + /** + * Uploads a file and registers a dataset using custom blob upload settings. + * + * @param name the dataset name. + * @param version the dataset version. + * @param filePath the local file. + * @param connectionName the storage connection, or null for the default. + * @param uploadOptions the upload options, or null for defaults. + * @return the created dataset. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public FileDatasetVersion createDatasetWithFile(String name, String version, Path filePath, String connectionName, + FileUploadOptions uploadOptions) { + return createDatasetWithFileWithResponse(name, version, filePath, connectionName, uploadOptions, + new RequestOptions()).getValue().toObject(FileDatasetVersion.class); + } + + /** + * Uploads a file and registers a dataset using custom blob upload settings. + * + * @param name the dataset name. + * @param version the dataset version. + * @param filePath the local file. + * @param connectionName the storage connection, or null for the default. + * @param uploadOptions the upload options, or null for defaults. + * @param requestOptions project request options; blob options are configured separately. + * @return the dataset response. + * @throws IllegalArgumentException if the path is not a regular file or upload credentials are missing. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Response createDatasetWithFileWithResponse(String name, String version, Path filePath, + String connectionName, FileUploadOptions uploadOptions, RequestOptions requestOptions) { + Path fileName = filePath == null ? null : filePath.getFileName(); + if (fileName == null || !Files.isRegularFile(filePath)) { throw LOGGER .logThrowableAsError(new IllegalArgumentException("The provided path is not a file: " + filePath)); } @@ -203,11 +238,12 @@ public Response createDatasetWithFileWithResponse(String name, Strin = this.pendingUploadWithResponse(name, version, BinaryData.fromObject(body), requestOptions) .getValue() .toObject(PendingUploadResponse.class); - BlobReferenceSasCredential credential = pendingUploadResponse.getBlobReference().getCredential(); - BlobClient blobClient = new BlobClientBuilder().endpoint(credential.getSasUrl()) - .blobName(filePath.getFileName().toString()) - .buildClient(); - blobClient.upload(BinaryData.fromFile(filePath), true); + BlobClient blobClient + = FileUploadHelper.createContainerBuilder(pendingUploadResponse.getBlobReference(), uploadOptions) + .buildClient() + .getBlobClient(fileName.toString()); + blobClient.uploadWithResponse(FileUploadHelper.createUploadOptions(filePath, uploadOptions), null, + requestOptions == null ? Context.NONE : requestOptions.getContext()); return this.createOrUpdateDatasetVersionWithResponse(name, version, BinaryData.fromObject(new FileDatasetVersion().setDataUrl(blobClient.getBlobUrl())), requestOptions); } @@ -288,10 +324,43 @@ public Response createDatasetWithFolderWithResponse(String name, Str @ServiceMethod(returns = ReturnType.SINGLE) public Response createDatasetWithFolderWithResponse(String name, String version, Path folderPath, String connectionName, RequestOptions requestOptions) { - if (!Files.isDirectory(folderPath)) { - throw LOGGER - .logExceptionAsError(new IllegalArgumentException("The provided path is not a folder: " + folderPath)); - } + return createDatasetWithFolderWithResponse(name, version, folderPath, connectionName, null, requestOptions); + } + + /** + * Uploads matching files recursively and registers a folder dataset. + * + * @param name the dataset name. + * @param version the dataset version. + * @param folderPath the local directory. + * @param connectionName the storage connection, or null for the default. + * @param uploadOptions the filename filter and blob settings, or null for defaults. + * @return the created dataset. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public FolderDatasetVersion createDatasetWithFolder(String name, String version, Path folderPath, + String connectionName, FileUploadOptions uploadOptions) { + return createDatasetWithFolderWithResponse(name, version, folderPath, connectionName, uploadOptions, + new RequestOptions()).getValue().toObject(FolderDatasetVersion.class); + } + + /** + * Uploads matching files recursively and registers a folder dataset. Relative paths are preserved. + * + * @param name the dataset name. + * @param version the dataset version. + * @param folderPath the local directory. + * @param connectionName the storage connection, or null for the default. + * @param uploadOptions the filename filter and blob settings, or null for defaults. + * @param requestOptions project request options; blob options are configured separately. + * @return the dataset response. + * @throws IllegalArgumentException if the folder contains no matching files or upload credentials are missing. + * @throws java.io.UncheckedIOException if the folder cannot be traversed. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Response createDatasetWithFolderWithResponse(String name, String version, Path folderPath, + String connectionName, FileUploadOptions uploadOptions, RequestOptions requestOptions) { + List files = FileUploadHelper.getFiles(folderPath, uploadOptions); PendingUploadRequest request = new PendingUploadRequest(); if (connectionName != null) { request.setConnectionName(connectionName); @@ -300,21 +369,18 @@ public Response createDatasetWithFolderWithResponse(String name, Str = this.pendingUploadWithResponse(name, version, BinaryData.fromObject(request), requestOptions) .getValue() .toObject(PendingUploadResponse.class); - String containerUrl = pendingUploadResponse.getBlobReference().getBlobUrl(); - BlobReferenceSasCredential credential = pendingUploadResponse.getBlobReference().getCredential(); BlobContainerClient containerClient - = new BlobContainerClientBuilder().endpoint(credential.getSasUrl()).buildClient(); - // Upload all files in the directory - try (Stream fileStream = Files.walk(folderPath)) { - fileStream.filter(Files::isRegularFile).forEach(filePath -> { - String relativePath = folderPath.relativize(filePath).toString().replace('\\', '/'); - containerClient.getBlobClient(relativePath).upload(BinaryData.fromFile(filePath), true); - }); - } catch (IOException e) { - throw LOGGER.logExceptionAsError(new UncheckedIOException("Failed to walk folder path: " + folderPath, e)); + = FileUploadHelper.createContainerBuilder(pendingUploadResponse.getBlobReference(), uploadOptions) + .buildClient(); + for (Path filePath : files) { + String relativePath = folderPath.relativize(filePath).toString().replace('\\', '/'); + containerClient.getBlobClient(relativePath) + .uploadWithResponse(FileUploadHelper.createUploadOptions(filePath, uploadOptions), null, + requestOptions == null ? Context.NONE : requestOptions.getContext()); } return this.createOrUpdateDatasetVersionWithResponse(name, version, - BinaryData.fromObject(new FolderDatasetVersion().setDataUrl(containerUrl)), requestOptions); + BinaryData.fromObject(new FolderDatasetVersion().setDataUrl(containerClient.getBlobContainerUrl())), + requestOptions); } /** diff --git a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/EvaluationsHelper.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/EvaluationsHelper.java index 0aad2ca00767c..c97e8e5aab7a0 100644 --- a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/EvaluationsHelper.java +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/EvaluationsHelper.java @@ -4,8 +4,11 @@ package com.azure.ai.projects; import com.azure.ai.projects.implementation.OpenAIJsonHelper; +import com.azure.ai.projects.models.AzureAIEvaluationDataSource; import com.azure.ai.projects.models.TestingCriterionAzureAIEvaluator; +import com.azure.core.util.BinaryData; import com.openai.models.evals.EvalCreateParams; +import com.openai.models.evals.runs.RunCreateParams; /** * Helper methods for Azure AI evaluations. @@ -14,6 +17,28 @@ public final class EvaluationsHelper { private EvaluationsHelper() { } + /** + * Converts an Azure evaluation run data source to the native OpenAI parameter union. + * @param source Azure data source. + * @return a native run data source preserving Azure-specific fields. + */ + public static RunCreateParams.DataSource toDataSource(AzureAIEvaluationDataSource source) { + return OpenAIJsonHelper.toOpenAIType(source, RunCreateParams.DataSource.class); + } + + /** + * Creates an Azure evaluation schema configuration. + * @param scenario scenario such as responses, red_team, traces_preview, or benchmark_preview. + * @return native evaluation data-source configuration. + */ + public static EvalCreateParams.DataSourceConfig createDataSourceConfig(String scenario) { + java.util.Map configuration = new java.util.LinkedHashMap<>(); + configuration.put("type", "azure_ai_source"); + configuration.put("scenario", java.util.Objects.requireNonNull(scenario, "scenario")); + return OpenAIJsonHelper.fromBinaryData(BinaryData.fromObject(configuration), + EvalCreateParams.DataSourceConfig.class); + } + /** * Converts an Azure AI evaluator model to an OpenAI evaluation testing criterion. * diff --git a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/FileUploadHelper.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/FileUploadHelper.java new file mode 100644 index 0000000000000..8e8dc8abf8e40 --- /dev/null +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/FileUploadHelper.java @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.projects.implementation; + +import com.azure.ai.projects.models.BlobReference; +import com.azure.ai.projects.models.FileUploadOptions; +import com.azure.ai.projects.models.ModelUploadOptions; +import com.azure.ai.projects.models.ModelVersion; +import com.azure.core.util.BinaryData; +import com.azure.core.util.CoreUtils; +import com.azure.core.util.FluxUtil; +import com.azure.storage.blob.BlobContainerClientBuilder; +import com.azure.storage.blob.options.BlobParallelUploadOptions; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import reactor.core.publisher.Flux; + +/** Shared local-file validation and blob upload configuration. */ +public final class FileUploadHelper { + private FileUploadHelper() { + } + + /** + * Validates a model upload before requesting remote storage. + * @param name model name. + * @param version model version. + * @param source local source. + * @param options upload options. + * @return selected files. + */ + public static List getModelFiles(String name, String version, Path source, ModelUploadOptions options) { + if (name == null || name.trim().isEmpty() || version == null || version.trim().isEmpty()) { + throw new IllegalArgumentException("Model name and version must not be empty."); + } + if (source == null || source.getFileName() == null || !Files.exists(source)) { + throw new IllegalArgumentException("A model file or folder is required."); + } + if (Files.isDirectory(source)) { + return getFiles(source, options.getFileUploadOptions()); + } + try { + if (!Files.isRegularFile(source) || Files.size(source) == 0) { + throw new IllegalArgumentException("The model source must be a nonempty regular file."); + } + } catch (IOException exception) { + throw new UncheckedIOException(exception); + } + return java.util.Collections.singletonList(source); + } + + /** + * Creates the model registration payload without SAS query parameters. + * @param blobUrl uploaded blob or container URL. + * @param options model metadata. + * @return the registration payload. + */ + public static ModelVersion createModelVersion(String blobUrl, ModelUploadOptions options) { + return new ModelVersion(com.azure.core.util.UrlBuilder.parse(blobUrl).setQuery(null).toString()) + .setWeightType(options.getWeightType()) + .setBaseModel(options.getBaseModel()) + .setDescription(options.getDescription()) + .setTags(options.getTags()); + } + + /** + * Reads both modeled and datastore-style model pending-upload responses. + * @param response raw pending-upload response. + * @return the validated storage reference. + */ + public static BlobReference getModelBlobReference(BinaryData response) { + java.util.Map payload = response.toObject(java.util.Map.class); + Object reference = payload.get("blobReferenceForConsumption"); + if (reference == null) { + reference = payload.get("blobReference"); + } + BlobReference result + = reference == null ? null : BinaryData.fromObject(reference).toObject(BlobReference.class); + if (result == null + || CoreUtils.isNullOrEmpty(result.getBlobUrl()) + || result.getCredential() == null + || CoreUtils.isNullOrEmpty(result.getCredential().getSasUrl())) { + throw new IllegalArgumentException("The model pending upload response has no blob URI or SAS credential."); + } + return result; + } + + /** + * Selects regular files recursively, rejecting empty selections before any upload. + * @param folder the local directory. + * @param options the optional upload settings. + * @return the selected files. + */ + public static List getFiles(Path folder, FileUploadOptions options) { + if (folder == null || !Files.isDirectory(folder)) { + throw new IllegalArgumentException("The provided path is not a folder: " + folder); + } + try (Stream paths = Files.walk(folder)) { + List files = paths.filter(Files::isRegularFile) + .filter(path -> options == null + || options.getFilePattern() == null + || options.getFilePattern().matcher(path.getFileName().toString()).find()) + .collect(Collectors.toList()); + if (files.isEmpty()) { + throw new IllegalArgumentException("The provided folder contains no matching files."); + } + return files; + } catch (IOException exception) { + throw new UncheckedIOException("Failed to walk the upload folder.", exception); + } + } + + /** + * Builds a blob container client configuration using service-issued SAS credentials. + * @param reference the service's blob reference. + * @param options optional configuration callbacks. + * @return the configured builder. + */ + public static BlobContainerClientBuilder createContainerBuilder(BlobReference reference, + FileUploadOptions options) { + if (reference == null + || reference.getCredential() == null + || CoreUtils.isNullOrEmpty(reference.getCredential().getSasUrl())) { + throw new IllegalArgumentException("The pending upload response has no blob SAS credential."); + } + BlobContainerClientBuilder builder = new BlobContainerClientBuilder(); + if (options != null && options.getBlobClientConfiguration() != null) { + options.getBlobClientConfiguration().accept(builder); + } + return builder.endpoint(reference.getCredential().getSasUrl()); + } + + /** + * Creates fresh upload options for a file. + * @param file the file to upload. + * @param options optional configuration callbacks. + * @return the blob upload options. + */ + public static BlobParallelUploadOptions createUploadOptions(Path file, FileUploadOptions options) { + BlobParallelUploadOptions upload = new BlobParallelUploadOptions( + Flux.using(() -> Files.newInputStream(file), FluxUtil::toFluxByteBuffer, stream -> { + try { + stream.close(); + } catch (IOException exception) { + throw new UncheckedIOException(exception); + } + })); + if (options != null && options.getBlobUploadConfiguration() != null) { + options.getBlobUploadConfiguration().accept(upload); + } + return upload; + } +} diff --git a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/ProjectsServicePollUtils.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/ProjectsServicePollUtils.java new file mode 100644 index 0000000000000..c658d4e1618b0 --- /dev/null +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/ProjectsServicePollUtils.java @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.projects.implementation; + +import com.azure.core.exception.AzureException; +import com.azure.core.http.rest.Response; +import com.azure.core.util.BinaryData; +import com.azure.core.util.polling.LongRunningOperationStatus; +import com.azure.core.util.polling.PollResponse; +import com.azure.core.util.polling.PollerFlux; +import com.azure.core.util.polling.PollingContext; +import com.azure.core.util.polling.SyncPoller; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Supplier; +import reactor.core.publisher.Mono; + +/** Internal helpers for resuming existing Projects jobs with Azure Core pollers. */ +public final class ProjectsServicePollUtils { + private ProjectsServicePollUtils() { + } + + /** + * Resumes a job through its existing GET endpoint. + * @param getResponse status retrieval. + * @param pollType status model type. + * @param resultType final result type. + * @param status type. + * @param result type. + * @return the resumed sync poller. + */ + public static SyncPoller resume(Supplier> getResponse, Class pollType, + Class resultType) { + Function, PollResponse> poll = context -> response(getResponse.get(), context, pollType); + return SyncPoller.createPoller(Duration.ofSeconds(1), poll, poll, (context, current) -> { + throw new UnsupportedOperationException("Use the job cancellation API."); + }, context -> result(context, resultType)); + } + + /** + * Resumes a job through its existing asynchronous GET endpoint. + * @param getResponse status retrieval. + * @param pollType status model type. + * @param resultType final result type. + * @param status type. + * @param result type. + * @return the resumed async poller. + */ + public static PollerFlux resumeAsync(Supplier>> getResponse, + Class pollType, Class resultType) { + Function, Mono>> poll + = context -> Mono.defer(getResponse).map(value -> response(value, context, pollType)); + return new PollerFlux<>(Duration.ofSeconds(1), context -> poll.apply(context).map(PollResponse::getValue), poll, + (context, current) -> Mono.error(new UnsupportedOperationException("Use the job cancellation API.")), + context -> Mono.fromCallable(() -> result(context, resultType))); + } + + private static PollResponse response(Response response, PollingContext context, + Class type) { + BinaryData body = response.getValue(); + context.setData(PollingUtils.POLL_RESPONSE_BODY, body.toString()); + Object rawStatus = body.toObject(Map.class).get("status"); + String status = rawStatus == null ? "in_progress" : rawStatus.toString().toLowerCase(Locale.ROOT); + LongRunningOperationStatus mapped; + switch (status) { + case "succeeded": + case "completed": + mapped = LongRunningOperationStatus.SUCCESSFULLY_COMPLETED; + break; + + case "failed": + mapped = LongRunningOperationStatus.FAILED; + break; + + case "cancelled": + case "canceled": + mapped = LongRunningOperationStatus.USER_CANCELLED; + break; + + default: + mapped = LongRunningOperationStatus.IN_PROGRESS; + } + return new PollResponse<>(mapped, body.toObject(type), + PollingUtils.getRetryAfterFromHeaders(response.getHeaders(), OffsetDateTime::now)); + } + + private static U result(PollingContext context, Class type) { + if (context.getLatestResponse().getStatus() != LongRunningOperationStatus.SUCCESSFULLY_COMPLETED) { + throw new AzureException("Long running operation failed or was cancelled."); + } + Object result + = BinaryData.fromString(context.getData(PollingUtils.POLL_RESPONSE_BODY)).toObject(Map.class).get("result"); + if (result == null) { + throw new AzureException("Cannot get final result."); + } + return BinaryData.fromObject(result).toObject(type); + } +} diff --git a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/TokenUtils.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/TokenUtils.java index f160b85081294..6bd3ad6278af9 100644 --- a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/TokenUtils.java +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/TokenUtils.java @@ -6,15 +6,111 @@ import com.azure.core.credential.AccessToken; import com.azure.core.credential.TokenCredential; import com.azure.core.credential.TokenRequestContext; - +import com.azure.core.exception.AzureException; +import com.openai.core.ClientOptions; +import com.openai.core.LogLevel; +import com.openai.core.RequestOptions; +import com.openai.core.http.HttpClient; +import com.openai.core.http.HttpRequest; +import com.openai.core.http.HttpResponse; +import com.openai.credential.BearerTokenCredential; +import com.openai.credential.Credential; import java.util.Arrays; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; import java.util.function.Supplier; +import reactor.core.publisher.Mono; /** * Utility class used to forward token authentication to Stainless clients */ public final class TokenUtils { + /** + * Resolves the default Azure credential at the native async transport boundary. + * Explicit native credential overrides bypass this adapter. + */ + public static final class AsyncAuthentication { + private final TokenCredential tokenCredential; + private final String[] scopes; + private final String marker = "azure-async-" + UUID.randomUUID(); + private final Credential credential = BearerTokenCredential.create(marker); + + /** + * Creates authentication state for one native client. + * @param tokenCredential Azure credential, required when default authentication is used. + * @param scopes token scopes. + */ + public AsyncAuthentication(TokenCredential tokenCredential, String... scopes) { + this.tokenCredential = tokenCredential; + this.scopes = scopes.clone(); + } + + /** + * Gets the placeholder resolved by the authenticated transport before sending. + * @return the native credential. + */ + public Credential getCredential() { + return credential; + } + + /** + * Wraps the final caller-selected transport after applying native options. + * @param options native client options. + * @return the authentication transport, before native client decorators are applied. + */ + public HttpClient configure(ClientOptions.Builder options) { + ClientOptions configured = options.build(); + if (configured.credential() != credential) { + return configured.httpClient(); + } + HttpClient transport = configured.toBuilder().maxRetries(0).logLevel(LogLevel.OFF).build().httpClient(); + HttpClient authenticatedTransport = new HttpClient() { + @Override + public HttpResponse execute(HttpRequest request, RequestOptions requestOptions) { + if (requiresToken(request)) { + request = authenticate(request, tokenCredential.getTokenSync(tokenContext())); + } + return transport.execute(request, requestOptions); + } + + @Override + public CompletableFuture executeAsync(HttpRequest request, + RequestOptions requestOptions) { + return Mono + .defer(() -> requiresToken(request) + ? tokenCredential.getToken(tokenContext()) + .switchIfEmpty( + Mono.error(new AzureException("The credential returned no access token."))) + .map(token -> authenticate(request, token)) + : Mono.just(request)) + .flatMap(authenticated -> Mono + .fromFuture(() -> transport.executeAsync(authenticated, requestOptions))) + .toFuture(); + } + + @Override + public void close() { + transport.close(); + } + }; + options.httpClient(authenticatedTransport); + return authenticatedTransport; + } + + private boolean requiresToken(HttpRequest request) { + return request.headers().values("Authorization").contains("Bearer " + marker); + } + + private TokenRequestContext tokenContext() { + return new TokenRequestContext().setScopes(Arrays.asList(scopes)); + } + + private HttpRequest authenticate(HttpRequest request, AccessToken token) { + return request.toBuilder().replaceHeaders("Authorization", "Bearer " + token.getToken()).build(); + } + } + /** * Utility authentication function. * diff --git a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/http/AzureHttpResponseAdapter.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/http/AzureHttpResponseAdapter.java index ab3104013ef26..6cb05cfefa838 100644 --- a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/http/AzureHttpResponseAdapter.java +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/http/AzureHttpResponseAdapter.java @@ -4,11 +4,21 @@ package com.azure.ai.projects.implementation.http; import com.azure.core.http.HttpHeader; +import com.azure.core.http.HttpHeaderName; 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; +import java.io.FilterInputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.function.Consumer; /** * Adapter that exposes an Azure {@link com.azure.core.http.HttpResponse} as an OpenAI {@link HttpResponse}. This keeps @@ -16,7 +26,10 @@ */ final class AzureHttpResponseAdapter implements HttpResponse { + private static final ClientLogger LOGGER = new ClientLogger(AzureHttpResponseAdapter.class); + private final com.azure.core.http.HttpResponse azureResponse; + private final Consumer bodyLogger; /** * Creates a new adapter instance for the provided Azure response. @@ -24,7 +37,24 @@ final class AzureHttpResponseAdapter implements HttpResponse { * @param azureResponse Response returned by the Azure pipeline. */ AzureHttpResponseAdapter(com.azure.core.http.HttpResponse azureResponse) { + this(azureResponse, false); + } + + AzureHttpResponseAdapter(com.azure.core.http.HttpResponse azureResponse, boolean logBody) { + this(azureResponse, + logBody && isEventStream(azureResponse) + ? value -> LOGGER.info("OpenAI response body chunk: {}", value) + : null); + } + + private static boolean isEventStream(com.azure.core.http.HttpResponse response) { + String contentType = response.getHeaderValue(HttpHeaderName.CONTENT_TYPE); + return contentType != null && "text/event-stream".equalsIgnoreCase(contentType.split(";", 2)[0].trim()); + } + + AzureHttpResponseAdapter(com.azure.core.http.HttpResponse azureResponse, Consumer bodyLogger) { this.azureResponse = azureResponse; + this.bodyLogger = bodyLogger; } @Override @@ -39,7 +69,62 @@ public Headers headers() { @Override public InputStream body() { - return azureResponse.getBodyAsInputStreamSync(); + InputStream stream = azureResponse.getBodyAsInputStreamSync(); + if (bodyLogger == null) { + return stream; + } + return new FilterInputStream(stream) { + private final CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPLACE) + .onUnmappableCharacter(CodingErrorAction.REPLACE); + private final ByteBuffer pending = ByteBuffer.allocate(1024); + private final CharBuffer decoded = CharBuffer.allocate(1024); + private boolean finished; + + @Override + public int read() throws IOException { + int value = in.read(); + if (value != -1) { + pending.put((byte) value); + } + logDecoded(value == -1); + return value; + } + + @Override + public int read(byte[] bytes, int offset, int length) throws IOException { + int count = in.read(bytes, offset, length); + int consumed = 0; + while (consumed < count) { + int size = Math.min(count - consumed, pending.remaining()); + pending.put(bytes, offset + consumed, size); + consumed += size; + logDecoded(false); + } + if (count == -1) { + logDecoded(true); + } + return count; + } + + private void logDecoded(boolean endOfInput) { + if (finished) { + return; + } + pending.flip(); + decoder.decode(pending, decoded, endOfInput); + pending.compact(); + if (endOfInput) { + decoder.flush(decoded); + finished = true; + } + decoded.flip(); + if (decoded.hasRemaining()) { + bodyLogger.accept(decoded.toString()); + } + decoded.clear(); + } + }; } @Override diff --git a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/http/FoundryPolicyHelper.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/http/FoundryPolicyHelper.java index 624237e4a11f6..da9bd9a58ab14 100644 --- a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/http/FoundryPolicyHelper.java +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/http/FoundryPolicyHelper.java @@ -3,6 +3,7 @@ package com.azure.ai.projects.implementation.http; +import com.azure.core.exception.HttpResponseException; import com.azure.core.http.HttpHeaderName; import com.azure.core.http.HttpPipeline; import com.azure.core.http.HttpPipelineBuilder; @@ -11,10 +12,14 @@ import com.azure.core.http.HttpResponse; import com.azure.core.http.policy.HttpPipelinePolicy; import com.azure.core.util.CoreUtils; -import reactor.core.publisher.Mono; - +import com.azure.json.JsonProviders; +import com.azure.json.JsonReader; +import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import reactor.core.publisher.Mono; /** * Utility methods for adding AI Foundry-specific policies to Azure Core {@link HttpPipeline HttpPipelines}. @@ -26,6 +31,36 @@ public final class FoundryPolicyHelper { private FoundryPolicyHelper() { } + /** + * Creates a policy that adds preview opt-in guidance while preserving the service response. + * @param allowPreview Whether preview is already enabled. + * @return The policy, or null when preview is enabled. + */ + public static HttpPipelinePolicy createPreviewErrorPolicy(boolean allowPreview) { + return allowPreview ? null : (context, next) -> next.process().flatMap(response -> { + if (response.getStatusCode() != 403) { + return Mono.just(response); + } + HttpResponse buffered = response.buffer(); + return buffered.getBodyAsByteArray().defaultIfEmpty(new byte[0]).flatMap(bytes -> { + Object value; + try (JsonReader reader = JsonProviders.createReader(bytes)) { + value = reader.readUntyped(); + } catch (IOException | IllegalStateException exception) { + return Mono.just(buffered); + } + Object error = value instanceof Map ? ((Map) value).get("error") : null; + if (!(error instanceof Map) || !"preview_feature_required".equals(((Map) error).get("code"))) { + return Mono.just(buffered); + } + return Mono.error(new HttpResponseException( + "Status code 403, \"" + new String(bytes, StandardCharsets.UTF_8) + + "\". To use preview features, configure AIProjectClientBuilder.allowPreview(true).", + buffered, value)); + }); + }); + } + /** * Creates a policy that adds the {@code Foundry-Features} header when it isn't already present on the request. * @@ -76,7 +111,7 @@ private FoundryFeaturesPolicy(String foundryFeatures) { @Override public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { - if (CoreUtils.isNullOrEmpty(context.getHttpRequest().getHeaders().getValue(FOUNDRY_FEATURES))) { + if (context.getHttpRequest().getHeaders().get(FOUNDRY_FEATURES) == null) { context.getHttpRequest().getHeaders().set(FOUNDRY_FEATURES, foundryFeatures); } return next.process(); diff --git a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/http/HttpClientHelper.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/http/HttpClientHelper.java index d2eb34cb3d9c8..e98831b870d1f 100644 --- a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/http/HttpClientHelper.java +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/http/HttpClientHelper.java @@ -9,6 +9,7 @@ import com.azure.core.http.HttpHeaders; import com.azure.core.http.HttpMethod; import com.azure.core.http.HttpPipeline; +import com.azure.core.http.policy.UserAgentPolicy; import com.azure.core.util.BinaryData; import com.azure.core.util.Context; import com.azure.core.util.CoreUtils; @@ -29,9 +30,6 @@ import com.openai.errors.UnauthorizedException; import com.openai.errors.UnexpectedStatusCodeException; import com.openai.errors.UnprocessableEntityException; -import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; - import java.io.ByteArrayOutputStream; import java.net.MalformedURLException; import java.net.URI; @@ -39,6 +37,8 @@ import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeoutException; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; /** * Utility entry point that adapts an Azure {@link com.azure.core.http.HttpClient} so it can be consumed by @@ -53,6 +53,45 @@ public final class HttpClientHelper { private HttpClientHelper() { } + /** + * Creates a logging policy that never logs multipart upload bodies. + * @param options caller logging settings, which are not modified. + * @return multipart-aware logging policy. + */ + public static com.azure.core.http.policy.HttpPipelinePolicy + createLoggingPolicy(com.azure.core.http.policy.HttpLogOptions options) { + com.azure.core.http.policy.HttpLoggingPolicy normal = new com.azure.core.http.policy.HttpLoggingPolicy(options); + com.azure.core.http.policy.HttpLoggingPolicy headers + = new com.azure.core.http.policy.HttpLoggingPolicy(new com.azure.core.http.policy.HttpLogOptions() + .setLogLevel(options.getLogLevel().shouldLogHeaders() + ? com.azure.core.http.policy.HttpLogDetailLevel.HEADERS + : com.azure.core.http.policy.HttpLogDetailLevel.BASIC) + .setAllowedHeaderNames(options.getAllowedHeaderNames()) + .setAllowedQueryParamNames(options.getAllowedQueryParamNames()) + .disableRedactedHeaderLogging(options.isRedactedHeaderLoggingDisabled())); + return new com.azure.core.http.policy.HttpPipelinePolicy() { + private com.azure.core.http.policy.HttpLoggingPolicy + select(com.azure.core.http.HttpPipelineCallContext context) { + String contentType = context.getHttpRequest().getHeaders().getValue(HttpHeaderName.CONTENT_TYPE); + return options.getLogLevel().shouldLogBody() + && contentType != null + && contentType.toLowerCase(java.util.Locale.ROOT).startsWith("multipart/") ? headers : normal; + } + + @Override + public Mono process(com.azure.core.http.HttpPipelineCallContext context, + com.azure.core.http.HttpPipelineNextPolicy next) { + return select(context).process(context, next); + } + + @Override + public com.azure.core.http.HttpResponse processSync(com.azure.core.http.HttpPipelineCallContext context, + com.azure.core.http.HttpPipelineNextSyncPolicy next) { + return select(context).processSync(context, next); + } + }; + } + /** * 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. @@ -61,15 +100,28 @@ private HttpClientHelper() { * @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); + return mapToOpenAIHttpClient(httpPipeline, false); + } + + /** + * Adapts an Azure pipeline with optional logging of SSE bodies as they are consumed. + * + * @param httpPipeline the pipeline used to execute requests. + * @param logBody whether to log consumed SSE response bytes. Body content may contain sensitive data. + * @return the OpenAI transport adapter. + */ + public static HttpClient mapToOpenAIHttpClient(HttpPipeline httpPipeline, boolean logBody) { + return new HttpClientWrapper(httpPipeline, logBody); } private static final class HttpClientWrapper implements HttpClient { private final HttpPipeline httpPipeline; + private final boolean logBody; - private HttpClientWrapper(HttpPipeline httpPipeline) { + private HttpClientWrapper(HttpPipeline httpPipeline, boolean logBody) { this.httpPipeline = Objects.requireNonNull(httpPipeline, "'httpPipeline' cannot be null."); + this.logBody = logBody; } @Override @@ -90,7 +142,8 @@ public HttpResponse execute(HttpRequest request, RequestOptions requestOptions) try { com.azure.core.http.HttpRequest azureRequest = buildAzureRequest(request); return new AzureHttpResponseAdapter( - this.httpPipeline.sendSync(azureRequest, buildRequestContext(requestOptions))); + this.httpPipeline.sendSync(azureRequest, buildRequestContext(requestOptions, azureRequest)), + logBody); } catch (MalformedURLException exception) { throw new OpenAIException("Invalid URL in request: " + exception.getMessage(), LOGGER.logThrowableAsError(exception)); @@ -108,8 +161,9 @@ public CompletableFuture executeAsync(HttpRequest request, Request Objects.requireNonNull(requestOptions, "requestOptions"); return Mono.fromCallable(() -> buildAzureRequest(request)) - .flatMap(azureRequest -> this.httpPipeline.send(azureRequest, buildRequestContext(requestOptions))) - .map(response -> (HttpResponse) new AzureHttpResponseAdapter(response)) + .flatMap(azureRequest -> this.httpPipeline.send(azureRequest, + buildRequestContext(requestOptions, azureRequest))) + .map(response -> (HttpResponse) new AzureHttpResponseAdapter(response, logBody)) .onErrorMap(HttpClientWrapper::mapAzureExceptionToOpenAI) // publishOn moves the CompletableFuture completion (and all OpenAI SDK continuations that // run synchronously on it) off the Netty/OkHttp I/O thread and onto a thread pool that @@ -244,8 +298,13 @@ private static HttpHeaders toAzureHeaders(Headers sourceHeaders) { * @param requestOptions OpenAI SDK request options * @return Azure request {@link Context} */ - private static Context buildRequestContext(RequestOptions requestOptions) { + private static Context buildRequestContext(RequestOptions requestOptions, + com.azure.core.http.HttpRequest request) { Context context = Context.NONE; + String userAgent = request.getHeaders().getValue(HttpHeaderName.USER_AGENT); + if (!CoreUtils.isNullOrEmpty(userAgent)) { + context = context.addData(UserAgentPolicy.OVERRIDE_USER_AGENT_CONTEXT_KEY, userAgent); + } Timeout timeout = requestOptions.getTimeout(); // we use "read" as it's the closest thing to the "response timeout" if (timeout != null && !timeout.read().isZero() && !timeout.read().isNegative()) { diff --git a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/models/AzureAIEvaluationDataSource.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/models/AzureAIEvaluationDataSource.java new file mode 100644 index 0000000000000..a2ed6eb3b88e9 --- /dev/null +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/models/AzureAIEvaluationDataSource.java @@ -0,0 +1,290 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.projects.models; + +import com.azure.ai.projects.implementation.OpenAIJsonHelper; +import com.azure.core.annotation.Fluent; +import com.azure.core.util.BinaryData; +import com.azure.json.JsonSerializable; +import com.azure.json.JsonWriter; +import com.openai.models.evals.runs.CreateEvalCompletionsRunDataSource; +import java.io.IOException; +import java.time.OffsetDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** Azure-specific evaluation run data sources, convertible with {@code EvaluationsHelper.toDataSource}. */ +@Fluent +public final class AzureAIEvaluationDataSource implements JsonSerializable { + private static final com.azure.core.util.logging.ClientLogger LOGGER + = new com.azure.core.util.logging.ClientLogger(AzureAIEvaluationDataSource.class); + private final Map properties = new LinkedHashMap<>(); + + private AzureAIEvaluationDataSource(String type) { + properties.put("type", type); + } + + /** + * Gets the wire discriminator. + * @return the wire discriminator. + */ + public String getType() { + return (String) properties.get("type"); + } + + /** + * Creates a CSV file data source. + * @param fileId uploaded CSV file ID. + * @return a CSV data source. + */ + public static AzureAIEvaluationDataSource csv(String fileId) { + Map source = new LinkedHashMap<>(); + source.put("type", "file_id"); + source.put("id", Objects.requireNonNull(fileId, "fileId")); + AzureAIEvaluationDataSource result = new AzureAIEvaluationDataSource("csv"); + result.properties.put("source", source); + return result; + } + + /** + * Creates a target-completion data source. + * @param source native inline or file-ID source. + * @param target model or agent target. + * @param inputMessages native input-message configuration. + * @return the target-completion data source. + */ + public static AzureAIEvaluationDataSource targetCompletions(CreateEvalCompletionsRunDataSource.Source source, + Target target, CreateEvalCompletionsRunDataSource.InputMessages inputMessages) { + AzureAIEvaluationDataSource result = new AzureAIEvaluationDataSource("azure_ai_target_completions"); + result.properties.put("source", nativeValue(Objects.requireNonNull(source, "source"))); + result.properties.put("target", azureValue(Objects.requireNonNull(target, "target"))); + return result.setInputMessages(Objects.requireNonNull(inputMessages, "inputMessages")); + } + + /** + * Creates a continuous-response retrieval data source. + * @param source native inline or file-ID source. + * @param dataMapping source-field mapping including response ID. + * @return the response-retrieval data source. + */ + public static AzureAIEvaluationDataSource responses(CreateEvalCompletionsRunDataSource.Source source, + Map dataMapping) { + AzureAIEvaluationDataSource result = new AzureAIEvaluationDataSource("azure_ai_responses"); + Map generation = new LinkedHashMap<>(); + generation.put("type", "response_retrieval"); + generation.put("source", nativeValue(Objects.requireNonNull(source, "source"))); + generation.put("data_mapping", new LinkedHashMap<>(Objects.requireNonNull(dataMapping, "dataMapping"))); + result.properties.put("item_generation_params", generation); + return result; + } + + /** + * Creates a benchmark data source. Model sampling parameters must be omitted for benchmark targets. + * @param target model or agent target. + * @return the benchmark data source. + */ + public static AzureAIEvaluationDataSource benchmark(Target target) { + AzureAIEvaluationDataSource result = new AzureAIEvaluationDataSource("azure_ai_benchmark_preview"); + result.properties.put("target", azureValue(Objects.requireNonNull(target, "target"))); + return result; + } + + /** + * Creates a red-team data source. + * @param itemGenerationParams JSON item-generation settings. + * @param target model or agent target. + * @return the red-team data source. + */ + public static AzureAIEvaluationDataSource redTeam(BinaryData itemGenerationParams, Target target) { + AzureAIEvaluationDataSource result = new AzureAIEvaluationDataSource("azure_ai_red_team"); + result.properties.put("item_generation_params", + Objects.requireNonNull(itemGenerationParams, "itemGenerationParams").toObject(Map.class)); + result.properties.put("target", azureValue(Objects.requireNonNull(target, "target"))); + return result; + } + + /** + * Creates a traces-preview data source. + * @return a traces-preview data source with service-default query settings. + */ + public static AzureAIEvaluationDataSource traces() { + return new AzureAIEvaluationDataSource("azure_ai_traces_preview"); + } + + /** + * Sets the input-message configuration. + * @param value input messages for target completions or benchmarks. + * @return this source. + */ + public AzureAIEvaluationDataSource setInputMessages(CreateEvalCompletionsRunDataSource.InputMessages value) { + requireType("azure_ai_target_completions", "azure_ai_benchmark_preview"); + put("input_messages", value == null ? null : nativeValue(value)); + return this; + } + + /** + * Sets the maximum retrieved conversation turns for response evaluation. + * @param value maximum retrieved conversation turns. + * @return this source. + */ + public AzureAIEvaluationDataSource setMaxNumTurns(Integer value) { + requireType("azure_ai_responses"); + Map generation = (Map) properties.get("item_generation_params"); + Map updated = new LinkedHashMap<>(); + generation.forEach((name, setting) -> updated.put(name.toString(), setting)); + if (value == null) { + updated.remove("max_num_turns"); + } else { + updated.put("max_num_turns", value); + } + properties.put("item_generation_params", updated); + return this; + } + + /** + * Sets the hourly response-evaluation run limit. + * @param value hourly run limit for response evaluation. + * @return this source. + */ + public AzureAIEvaluationDataSource setMaxRunsHourly(Integer value) { + requireType("azure_ai_responses"); + put("max_runs_hourly", value); + return this; + } + + /** + * Sets the response event configuration ID. + * @param value response event configuration ID. + * @return this source. + */ + public AzureAIEvaluationDataSource setEventConfigurationId(String value) { + requireType("azure_ai_responses"); + put("event_configuration_id", value); + return this; + } + + /** + * Sets the trace IDs to evaluate. + * @param value trace IDs to evaluate. + * @return this source. + */ + public AzureAIEvaluationDataSource setTraceIds(List value) { + requireType("azure_ai_traces_preview"); + put("trace_ids", value == null ? null : new java.util.ArrayList<>(value)); + return this; + } + + /** + * Sets the agent ID for trace filtering. + * @param value agent ID for trace filtering. + * @return this source. + */ + public AzureAIEvaluationDataSource setAgentId(String value) { + requireType("azure_ai_traces_preview"); + put("agent_id", value); + return this; + } + + /** + * Sets the agent name for trace filtering. + * @param value agent name for trace filtering. + * @return this source. + */ + public AzureAIEvaluationDataSource setAgentName(String value) { + requireType("azure_ai_traces_preview"); + put("agent_name", value); + return this; + } + + /** + * Sets the trace lookback window. + * @param value trace lookback window in hours. + * @return this source. + */ + public AzureAIEvaluationDataSource setLookbackHours(Integer value) { + requireType("azure_ai_traces_preview"); + put("lookback_hours", value); + return this; + } + + /** + * Sets the end of the trace query window. + * @param value end of the trace query window, serialized as Unix seconds. + * @return this source. + */ + public AzureAIEvaluationDataSource setEndTime(OffsetDateTime value) { + requireType("azure_ai_traces_preview"); + put("end_time", value == null ? null : value.toEpochSecond()); + return this; + } + + /** + * Sets the maximum traces to evaluate. + * @param value maximum traces to evaluate. + * @return this source. + */ + public AzureAIEvaluationDataSource setMaxTraces(Integer value) { + requireType("azure_ai_traces_preview"); + put("max_traces", value); + return this; + } + + /** + * Sets the trace ingestion delay. + * @param value trace ingestion delay in seconds. + * @return this source. + */ + public AzureAIEvaluationDataSource setIngestionDelaySeconds(Integer value) { + requireType("azure_ai_traces_preview"); + put("ingestion_delay_seconds", value); + return this; + } + + private void requireType(String... types) { + for (String type : types) { + if (type.equals(getType())) { + return; + } + } + throw LOGGER.logExceptionAsError(new IllegalStateException("This option is not supported for " + getType())); + } + + private void put(String name, Object value) { + if (value == null) { + properties.remove(name); + } else { + properties.put(name, value); + } + } + + private static Object nativeValue(Object value) { + return OpenAIJsonHelper.toBinaryData(value).toObject(Object.class); + } + + private static Object azureValue(Target value) { + return BinaryData.fromObject(value).toObject(Object.class); + } + + @Override + public JsonWriter toJson(JsonWriter writer) throws IOException { + return writer.writeMap(properties, JsonWriter::writeUntyped); + } + + /** + * Reads an Azure evaluation source while preserving extension fields. + * @param reader JSON reader. + * @return the source, or null for JSON null. + * @throws IOException if the JSON cannot be read. + */ + public static AzureAIEvaluationDataSource fromJson(com.azure.json.JsonReader reader) throws IOException { + return reader.readObject(objectReader -> { + Map fields = objectReader.readMap(com.azure.json.JsonReader::readUntyped); + AzureAIEvaluationDataSource source = new AzureAIEvaluationDataSource((String) fields.get("type")); + source.properties.putAll(fields); + return source; + }); + } +} diff --git a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/models/FileUploadOptions.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/models/FileUploadOptions.java new file mode 100644 index 0000000000000..3665849c7f40a --- /dev/null +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/models/FileUploadOptions.java @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.projects.models; + +import com.azure.core.annotation.Fluent; +import com.azure.storage.blob.BlobContainerClientBuilder; +import com.azure.storage.blob.options.BlobParallelUploadOptions; +import java.util.function.Consumer; +import java.util.regex.Pattern; + +/** Options for uploading local files to project-managed blob storage. */ +@Fluent +public final class FileUploadOptions { + private Pattern filePattern; + private Consumer blobClientConfiguration; + private Consumer blobUploadConfiguration; + + /** Creates upload options with no filename filter and overwrite enabled. */ + public FileUploadOptions() { + } + + /** + * Gets the pattern searched against each filename during folder uploads. + * @return the pattern, or null to upload all files. + */ + public Pattern getFilePattern() { + return filePattern; + } + + /** + * Sets a pattern searched against filenames, not their relative paths. Ignored for a single file. + * @param filePattern the pattern, or null for all files. + * @return these options. + */ + public FileUploadOptions setFilePattern(Pattern filePattern) { + this.filePattern = filePattern; + return this; + } + + /** + * Gets the blob client configuration callback. + * @return the callback, or null. + */ + public Consumer getBlobClientConfiguration() { + return blobClientConfiguration; + } + + /** + * Configures the blob client's transport, retry and logging options. The service-provided SAS endpoint is + * applied after this callback. Do not configure project credentials on this client. + * @param configuration the callback, or null for defaults. + * @return these options. + */ + public FileUploadOptions setBlobClientConfiguration(Consumer configuration) { + this.blobClientConfiguration = configuration; + return this; + } + + /** + * Gets the callback applied to each file's blob upload options. + * @return the callback, or null. + */ + public Consumer getBlobUploadConfiguration() { + return blobUploadConfiguration; + } + + /** + * Configures each upload's headers, metadata, transfer settings and request conditions. Uploads overwrite + * existing blobs by default; set an If-None-Match condition of "*" to reject existing blobs. + * @param configuration the callback, or null for defaults. A fresh options instance is supplied for each file. + * @return these options. + */ + public FileUploadOptions setBlobUploadConfiguration(Consumer configuration) { + this.blobUploadConfiguration = configuration; + return this; + } +} diff --git a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/models/ModelUploadOptions.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/models/ModelUploadOptions.java new file mode 100644 index 0000000000000..cdc8fd4978351 --- /dev/null +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/models/ModelUploadOptions.java @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.projects.models; + +import com.azure.core.annotation.Fluent; +import java.time.Duration; +import java.util.Map; + +/** Options for uploading and registering a local model. */ +@Fluent +@com.azure.ai.projects.implementation.utils.Beta(warningText = "Preview API. Models=V1Preview") +public final class ModelUploadOptions { + private FoundryModelWeightType weightType; + private String baseModel; + private String description; + private Map tags; + private String connectionName; + private FileUploadOptions fileUploadOptions; + private boolean waitForCompletion = true; + private Duration timeout = Duration.ofMinutes(5); + private Duration pollInterval = Duration.ofSeconds(2); + + /** Creates default model upload options. */ + public ModelUploadOptions() { + } + + /** + * Gets the model weight type. + * @return the model weight type. + */ + public FoundryModelWeightType getWeightType() { + return weightType; + } + + /** + * Sets the model weight type. + * @param value the model weight type. + * @return these options. + */ + public ModelUploadOptions setWeightType(FoundryModelWeightType value) { + weightType = value; + return this; + } + + /** + * Gets the base model asset ID. + * @return the base model asset ID. + */ + public String getBaseModel() { + return baseModel; + } + + /** + * Sets the base model asset ID. + * @param value the base model asset ID. + * @return these options. + */ + public ModelUploadOptions setBaseModel(String value) { + baseModel = value; + return this; + } + + /** + * Gets the description. + * @return the description. + */ + public String getDescription() { + return description; + } + + /** + * Sets the description. + * @param value the description. + * @return these options. + */ + public ModelUploadOptions setDescription(String value) { + description = value; + return this; + } + + /** + * Gets the tags. + * @return the tags. + */ + public Map getTags() { + return tags; + } + + /** + * Sets the tags. + * @param value the tags. + * @return these options. + */ + public ModelUploadOptions setTags(Map value) { + tags = value; + return this; + } + + /** + * Gets the storage connection name. + * @return the storage connection name. + */ + public String getConnectionName() { + return connectionName; + } + + /** + * Sets the storage connection name. + * @param value the storage connection name. + * @return these options. + */ + public ModelUploadOptions setConnectionName(String value) { + connectionName = value; + return this; + } + + /** + * Gets the file selection and Blob upload settings. + * @return the file selection and Blob upload settings. + */ + public FileUploadOptions getFileUploadOptions() { + return fileUploadOptions; + } + + /** + * Sets the file selection and Blob upload settings. + * @param value the file selection and Blob upload settings. + * @return these options. + */ + public ModelUploadOptions setFileUploadOptions(FileUploadOptions value) { + fileUploadOptions = value; + return this; + } + + /** + * Gets whether to wait until the model can be retrieved. + * @return whether to wait until the model can be retrieved. + */ + public boolean isWaitForCompletion() { + return waitForCompletion; + } + + /** + * Sets whether to wait for registration. + * @param value whether to wait for registration. + * @return these options. + */ + public ModelUploadOptions setWaitForCompletion(boolean value) { + waitForCompletion = value; + return this; + } + + /** + * Gets the registration timeout. + * @return the registration timeout (default five minutes). + */ + public Duration getTimeout() { + return timeout; + } + + /** + * Sets the timeout for waiting after registration has been accepted. + * @param value a positive registration timeout. + * @return these options. + * @throws IllegalArgumentException if the duration is null or not positive. + */ + public ModelUploadOptions setTimeout(Duration value) { + timeout = positive(value); + return this; + } + + /** + * Gets the polling interval. + * @return the polling interval (default two seconds). + */ + public Duration getPollInterval() { + return pollInterval; + } + + /** + * Sets the polling interval. + * @param value a positive polling interval. + * @return these options. + * @throws IllegalArgumentException if the duration is null or not positive. + */ + public ModelUploadOptions setPollInterval(Duration value) { + pollInterval = positive(value); + return this; + } + + private static Duration positive(Duration value) { + if (value == null || value.isNegative() || value.isZero()) { + throw new IllegalArgumentException("Duration must be positive."); + } + return value; + } +} diff --git a/sdk/ai/azure-ai-projects/src/main/java/module-info.java b/sdk/ai/azure-ai-projects/src/main/java/module-info.java index d8166570eb1b1..446ce592c41b8 100644 --- a/sdk/ai/azure-ai-projects/src/main/java/module-info.java +++ b/sdk/ai/azure-ai-projects/src/main/java/module-info.java @@ -4,7 +4,7 @@ module com.azure.ai.projects { requires transitive com.azure.core; - requires com.azure.storage.blob; + requires transitive com.azure.storage.blob; requires transitive openai.java.core; requires transitive openai.java.client.okhttp; requires com.azure.ai.agents; diff --git a/sdk/ai/azure-ai-projects/src/samples/java/com/azure/ai/projects/IndexesSample.java b/sdk/ai/azure-ai-projects/src/samples/java/com/azure/ai/projects/IndexesSample.java index bb8ae989ef8a9..e87e8b14a19b4 100644 --- a/sdk/ai/azure-ai-projects/src/samples/java/com/azure/ai/projects/IndexesSample.java +++ b/sdk/ai/azure-ai-projects/src/samples/java/com/azure/ai/projects/IndexesSample.java @@ -24,7 +24,7 @@ public static void main(String[] args) { } public static void createOrUpdateIndex() { - // BEGIN:com.azure.ai.projects.IndexesGetSample.createOrUpdateIndex + // BEGIN:com.azure.ai.projects.IndexesSample.createOrUpdateIndex String indexName = Configuration.getGlobalConfiguration().get("INDEX_NAME", "my-index"); String indexVersion = Configuration.getGlobalConfiguration().get("INDEX_VERSION", "2.0"); String aiSearchConnectionName = Configuration.getGlobalConfiguration().get("AI_SEARCH_CONNECTION_NAME", ""); @@ -39,22 +39,22 @@ public static void createOrUpdateIndex() { ); System.out.println("Index created: " + index.getName()); - // END:com.azure.ai.projects.IndexesGetSample.createOrUpdateIndex + // END:com.azure.ai.projects.IndexesSample.createOrUpdateIndex } public static void listIndexes() { - // BEGIN:com.azure.ai.projects.IndexesListSample.listIndexes + // BEGIN:com.azure.ai.projects.IndexesSample.listIndexes indexesClient.listLatestIndexVersions().forEach(index -> { System.out.println("Index name: " + index.getName()); System.out.println("Index version: " + index.getVersion()); System.out.println("Index description: " + index.getDescription()); System.out.println("-------------------------------------------------"); }); - // END:com.azure.ai.projects.IndexesListSample.listIndexes + // END:com.azure.ai.projects.IndexesSample.listIndexes } public static void listIndexVersions() { - // BEGIN:com.azure.ai.projects.IndexesListVersionsSample.listIndexVersions + // BEGIN:com.azure.ai.projects.IndexesSample.listIndexVersions String indexName = Configuration.getGlobalConfiguration().get("INDEX_NAME", "my-index"); @@ -64,11 +64,11 @@ public static void listIndexVersions() { System.out.println("Index type: " + index.getType()); }); - // END:com.azure.ai.projects.IndexesListVersionsSample.listIndexVersions + // END:com.azure.ai.projects.IndexesSample.listIndexVersions } public static void getIndex() { - // BEGIN:com.azure.ai.projects.IndexesGetSample.getIndex + // BEGIN:com.azure.ai.projects.IndexesSample.getIndex String indexName = Configuration.getGlobalConfiguration().get("INDEX_NAME", "my-index"); String indexVersion = Configuration.getGlobalConfiguration().get("INDEX_VERSION", "1.0"); @@ -80,11 +80,11 @@ public static void getIndex() { System.out.println("Version: " + index.getVersion()); System.out.println("Type: " + index.getType()); - // END:com.azure.ai.projects.IndexesGetSample.getIndex + // END:com.azure.ai.projects.IndexesSample.getIndex } public static void deleteIndex() { - // BEGIN:com.azure.ai.projects.IndexesDeleteSample.deleteIndex + // BEGIN:com.azure.ai.projects.IndexesSample.deleteIndex String indexName = Configuration.getGlobalConfiguration().get("INDEX_NAME", "my-index"); String indexVersion = Configuration.getGlobalConfiguration().get("INDEX_VERSION", "1.0"); @@ -94,6 +94,6 @@ public static void deleteIndex() { System.out.println("Deleted index: " + indexName + ", version: " + indexVersion); - // END:com.azure.ai.projects.IndexesDeleteSample.deleteIndex + // END:com.azure.ai.projects.IndexesSample.deleteIndex } } diff --git a/sdk/ai/azure-ai-projects/src/samples/java/com/azure/ai/projects/ReadmeSamples.java b/sdk/ai/azure-ai-projects/src/samples/java/com/azure/ai/projects/ReadmeSamples.java index 019183f70c677..915e45daf60ab 100644 --- a/sdk/ai/azure-ai-projects/src/samples/java/com/azure/ai/projects/ReadmeSamples.java +++ b/sdk/ai/azure-ai-projects/src/samples/java/com/azure/ai/projects/ReadmeSamples.java @@ -8,19 +8,56 @@ import com.azure.ai.agents.AgentsClientBuilder; import com.azure.ai.agents.BetaMemoryStoresClient; import com.azure.ai.agents.ResponsesClient; +import com.azure.ai.projects.models.AzureAIEvaluationDataSource; +import com.azure.ai.projects.models.DataGenerationJobResult; +import com.azure.ai.projects.models.FileUploadOptions; +import com.azure.ai.projects.models.ModelUploadOptions; +import com.azure.ai.projects.models.ModelVersion; import com.azure.ai.projects.models.TestingCriterionAzureAIEvaluator; import com.azure.core.util.BinaryData; import com.openai.client.OpenAIClient; import com.openai.client.OpenAIClientAsync; import com.openai.models.evals.EvalCreateParams; +import com.openai.models.evals.runs.RunCreateParams; import com.openai.services.async.EvalServiceAsync; import com.openai.services.blocking.EvalService; - +import java.nio.file.Paths; +import java.time.Duration; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; +import java.util.regex.Pattern; public final class ReadmeSamples { + public void localModelUpload(AIProjectClientBuilder builder) { + // BEGIN: readme-sample-local-model-upload + FileUploadOptions files = new FileUploadOptions() + .setFilePattern(Pattern.compile("\\.(bin|json|safetensors)$")); + ModelUploadOptions options = new ModelUploadOptions() + .setFileUploadOptions(files) + .setDescription("Local model weights") + .setTimeout(Duration.ofMinutes(5)); + ModelVersion model = builder.beta().buildBetaModelsClient() + .createModel("my-model", "1", Paths.get("model"), options); + // END: readme-sample-local-model-upload + } + + public void resumeGenerationJob(AIProjectClientBuilder builder, String savedJobId) { + // BEGIN: readme-sample-resume-generation-job + DataGenerationJobResult result = builder.beta().buildBetaDatasetsClient() + .resumeGenerationJob(savedJobId) + .getFinalResult(Duration.ofMinutes(5)); + // END: readme-sample-resume-generation-job + } + + public void evaluationDataSources() { + // BEGIN: readme-sample-azure-evaluation-source + EvalCreateParams.DataSourceConfig schema = EvaluationsHelper.createDataSourceConfig("traces_preview"); + RunCreateParams.DataSource source = EvaluationsHelper.toDataSource( + AzureAIEvaluationDataSource.traces().setAgentName("my-agent").setLookbackHours(24).setMaxTraces(100)); + // END: readme-sample-azure-evaluation-source + } + public void readmeSamples() { // BEGIN: com.azure.ai.projects.clientInitialization AIProjectClientBuilder builder = new AIProjectClientBuilder() diff --git a/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/BetaTelemetryClientTest.java b/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/BetaTelemetryClientTest.java new file mode 100644 index 0000000000000..8c0f1bc012b9c --- /dev/null +++ b/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/BetaTelemetryClientTest.java @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.projects; + +import com.azure.core.exception.ResourceNotFoundException; +import com.azure.core.http.HttpClient; +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpHeaders; +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 org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import reactor.core.publisher.Mono; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class BetaTelemetryClientTest { + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void cachesSuccessfulConnectionString(boolean async) { + List requests = new ArrayList<>(); + Supplier lookup + = createLookup(async, requests, "{\"value\":[{\"name\":\"insights\",\"type\":\"AppInsights\"}]}", + "{\"credentials\":{\"type\":\"ApiKey\",\"key\":\"InstrumentationKey=test\"}}"); + assertEquals("InstrumentationKey=test", lookup.get()); + assertEquals("InstrumentationKey=test", lookup.get()); + assertEquals(2, requests.size()); + assertTrue(requests.get(0).getUrl().getQuery().contains("connectionType=AppInsights")); + assertTrue(requests.get(1).getUrl().getPath().contains("insights")); + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void missingConnectionIsNotCached(boolean async) { + List requests = new ArrayList<>(); + Supplier lookup = createLookup(async, requests, "{\"value\":[]}", "{}"); + assertThrows(ResourceNotFoundException.class, lookup::get); + assertThrows(ResourceNotFoundException.class, lookup::get); + assertEquals(2, requests.size()); + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void rejectsInvalidCredentials(boolean async) { + for (String credentials : new String[] { + "{}", + "{\"credentials\":{\"type\":\"EntraID\"}}", + "{\"credentials\":{\"type\":\"ApiKey\",\"key\":\"\"}}" }) { + List requests = new ArrayList<>(); + Supplier lookup + = createLookup(async, requests, "{\"value\":[{\"name\":\"insights\"}]}", credentials); + assertThrows(IllegalStateException.class, lookup::get); + assertThrows(IllegalStateException.class, lookup::get); + assertEquals(4, requests.size()); + } + } + + private static Supplier createLookup(boolean async, List requests, String listResponse, + String credentialResponse) { + HttpClient httpClient = new HttpClient() { + @Override + public Mono send(HttpRequest request) { + assertTrue(async, "Synchronous telemetry must not use the asynchronous transport"); + return Mono.fromSupplier(() -> createResponse(request)); + } + + @Override + public HttpResponse sendSync(HttpRequest request, Context context) { + assertFalse(async, "Asynchronous telemetry must not use the synchronous transport"); + return createResponse(request); + } + + private HttpResponse createResponse(HttpRequest request) { + requests.add(request); + String body = request.getUrl().getPath().endsWith("/connections") ? listResponse : credentialResponse; + return new MockHttpResponse(request, 200, + new HttpHeaders().set(HttpHeaderName.CONTENT_TYPE, "application/json"), + body.getBytes(StandardCharsets.UTF_8)); + } + }; + AIProjectClientBuilder builder + = new AIProjectClientBuilder().endpoint("https://localhost/api/projects/project").httpClient(httpClient); + if (async) { + BetaTelemetryAsyncClient client = builder.beta().buildBetaTelemetryAsyncClient(); + return () -> client.getApplicationInsightsConnectionString().block(); + } + BetaTelemetryClient client = builder.beta().buildBetaTelemetryClient(); + return client::getApplicationInsightsConnectionString; + } +} diff --git a/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/DatasetsClientTest.java b/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/DatasetsClientTest.java index 7bcd4ee5f82b5..9498d90b8bd7d 100644 --- a/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/DatasetsClientTest.java +++ b/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/DatasetsClientTest.java @@ -4,26 +4,65 @@ import com.azure.ai.projects.models.DatasetVersion; import com.azure.ai.projects.models.FileDatasetVersion; +import com.azure.ai.projects.models.FileUploadOptions; import com.azure.ai.projects.models.FolderDatasetVersion; import com.azure.ai.projects.models.PendingUploadRequest; import com.azure.ai.projects.models.PendingUploadResponse; import com.azure.core.http.HttpClient; import com.azure.core.http.rest.RequestOptions; +import com.azure.core.test.annotation.DoNotRecord; import com.azure.core.test.annotation.LiveOnly; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; import java.io.FileNotFoundException; import java.io.IOException; import java.net.URISyntaxException; import java.nio.file.Files; import java.nio.file.Path; import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; import static com.azure.ai.projects.TestUtils.DISPLAY_NAME_WITH_ARGUMENTS; public class DatasetsClientTest extends ClientTestBase { + @DoNotRecord + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void testUploadRejectsEmptySelection(boolean async, @TempDir Path folder) throws IOException { + AIProjectClientBuilder builder = new AIProjectClientBuilder().endpoint("https://localhost") + .httpClient(request -> reactor.core.publisher.Mono.error(new AssertionError("Unexpected HTTP request"))); + FileUploadOptions options = new FileUploadOptions().setFilePattern(java.util.regex.Pattern.compile("\\.json$")); + for (boolean populated : new boolean[] { false, true }) { + if (populated) { + Files.write(folder.resolve("excluded.txt"), new byte[] { 1 }); + } + Assertions.assertThrows(IllegalArgumentException.class, () -> { + if (async) { + builder.buildDatasetsAsyncClient() + .createDatasetWithFolder("dataset", "1", folder, null, options) + .block(java.time.Duration.ofSeconds(5)); + } else { + builder.buildDatasetsClient().createDatasetWithFolder("dataset", "1", folder, null, options); + } + }); + } + } + + @Test + @DoNotRecord + public void testCreateDatasetRejectsRootPath() { + DatasetsClient client = new AIProjectClientBuilder().endpoint("https://localhost") + .httpClient(request -> reactor.core.publisher.Mono.error(new AssertionError("Unexpected HTTP request"))) + .buildDatasetsClient(); + Path root = java.nio.file.Paths.get("").toAbsolutePath().getRoot(); + Assertions.assertThrows(IllegalArgumentException.class, + () -> client.createDatasetWithFileWithResponse("dataset", "1", root, null, new RequestOptions())); + } + @LiveOnly @ParameterizedTest(name = DISPLAY_NAME_WITH_ARGUMENTS) @MethodSource("com.azure.ai.projects.TestUtils#getTestParameters") diff --git a/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/EvaluationsHelperTests.java b/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/EvaluationsHelperTests.java index 30fa05e7fa5d0..ca3f624a603e1 100644 --- a/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/EvaluationsHelperTests.java +++ b/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/EvaluationsHelperTests.java @@ -3,17 +3,71 @@ package com.azure.ai.projects; +import com.azure.ai.projects.models.AzureAIAgentTarget; +import com.azure.ai.projects.models.AzureAIEvaluationDataSource; import com.azure.ai.projects.models.TestingCriterionAzureAIEvaluator; import com.azure.core.util.BinaryData; import com.fasterxml.jackson.core.JsonProcessingException; import com.openai.core.ObjectMappers; import com.openai.models.evals.EvalCreateParams; +import com.openai.models.evals.runs.CreateEvalCompletionsRunDataSource; +import java.util.Collections; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; -import java.util.Collections; - public class EvaluationsHelperTests { + @Test + public void azureDataSourcesPreserveTheirWireShape() throws java.io.IOException { + CreateEvalCompletionsRunDataSource.Source source = CreateEvalCompletionsRunDataSource.Source + .ofFileId(CreateEvalCompletionsRunDataSource.Source.FileId.builder().id("file-123").build()); + CreateEvalCompletionsRunDataSource.InputMessages input = CreateEvalCompletionsRunDataSource.InputMessages + .ofItemReference(CreateEvalCompletionsRunDataSource.InputMessages.ItemReference.builder() + .itemReference("item.messages") + .build()); + AzureAIAgentTarget target = new AzureAIAgentTarget("agent"); + AzureAIEvaluationDataSource[] sources = { + AzureAIEvaluationDataSource.csv("file-123"), + AzureAIEvaluationDataSource.targetCompletions(source, target, input), + AzureAIEvaluationDataSource.responses(source, Collections.singletonMap("response_id", "item.response_id")) + .setMaxNumTurns(4) + .setMaxRunsHourly(10) + .setEventConfigurationId("events"), + AzureAIEvaluationDataSource.benchmark(target).setInputMessages(input), + AzureAIEvaluationDataSource.redTeam(BinaryData.fromString("{\"type\":\"synthetic\"}"), target), + AzureAIEvaluationDataSource.traces() + .setTraceIds(Collections.singletonList("trace")) + .setAgentId("agent-id") + .setAgentName("agent") + .setLookbackHours(24) + .setMaxTraces(10) + .setIngestionDelaySeconds(30) + .setEndTime(java.time.OffsetDateTime.parse("2026-01-01T00:00:00Z")) }; + String[] types = { + "csv", + "azure_ai_target_completions", + "azure_ai_responses", + "azure_ai_benchmark_preview", + "azure_ai_red_team", + "azure_ai_traces_preview" }; + for (int index = 0; index < sources.length; index++) { + com.fasterxml.jackson.databind.JsonNode expected + = ObjectMappers.jsonMapper().readTree(sources[index].toJsonString()); + com.fasterxml.jackson.databind.JsonNode actual = ObjectMappers.jsonMapper() + .readTree( + ObjectMappers.jsonMapper().writeValueAsString(EvaluationsHelper.toDataSource(sources[index]))); + Assertions.assertEquals(types[index], actual.path("type").asText(), + "Before conversion: " + expected + "; after conversion: " + actual); + Assertions.assertEquals(expected, actual); + } + com.fasterxml.jackson.databind.JsonNode config = ObjectMappers.jsonMapper() + .readTree(ObjectMappers.jsonMapper() + .writeValueAsString(EvaluationsHelper.createDataSourceConfig("traces_preview"))); + Assertions.assertEquals("azure_ai_source", config.get("type").asText()); + Assertions.assertEquals("traces_preview", config.get("scenario").asText()); + Assertions.assertThrows(IllegalStateException.class, + () -> AzureAIEvaluationDataSource.csv("file").setMaxTraces(1)); + } + @Test public void convertsAzureAIEvaluatorToTestingCriterion() throws JsonProcessingException { TestingCriterionAzureAIEvaluator evaluator diff --git a/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/FileUploadTests.java b/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/FileUploadTests.java new file mode 100644 index 0000000000000..59ce32b559c59 --- /dev/null +++ b/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/FileUploadTests.java @@ -0,0 +1,227 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.projects; + +import com.azure.ai.projects.models.FileUploadOptions; +import com.azure.ai.projects.models.ModelUploadOptions; +import com.azure.ai.projects.models.ModelVersion; +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.test.http.MockHttpResponse; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Pattern; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import reactor.core.publisher.Mono; + +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; + +class FileUploadTests { + @ParameterizedTest + @ValueSource(booleans = { false, true }) + void uploadsForwardOptionsAndDoNotRegisterFailures(boolean async, @TempDir Path folder) throws IOException { + Files.write(folder.resolve("weights.bin"), new byte[] { 1, 2 }); + Files.write(folder.resolve("excluded.txt"), new byte[] { 3 }); + for (boolean model : new boolean[] { false, true }) { + for (boolean failUpload : new boolean[] { false, true }) { + AtomicInteger projectCalls = new AtomicInteger(); + AtomicInteger uploadCalls = new AtomicInteger(); + HttpClient blob = request -> { + uploadCalls.incrementAndGet(); + consumeBody(request); + assertTrue(request.getUrl().getPath().endsWith("weights.bin")); + assertEquals("review", request.getHeaders().getValue("x-ms-meta-purpose")); + assertEquals("*", request.getHeaders().getValue(HttpHeaderName.IF_NONE_MATCH)); + return failUpload + ? Mono.error(new IllegalArgumentException("upload failed")) + : Mono.just(new MockHttpResponse(request, 201, + new HttpHeaders().set(HttpHeaderName.ETAG, "\"etag\""), new byte[0])); + }; + AIProjectClientBuilder builder + = new AIProjectClientBuilder().endpoint("https://localhost/projects/test") + .pipeline(new HttpPipelineBuilder().httpClient(request -> { + assertTrue(request.getHttpMethod() != HttpMethod.GET, "Waiting must be disabled"); + int call = projectCalls.incrementAndGet(); + if (call == 1) { + return Mono.just(jsonResponse(request, 200, pendingResponse())); + } + assertEquals(2, call); + assertEquals(1, uploadCalls.get()); + assertTrue(!request.getBodyAsBinaryData().toString().contains("sig=")); + return Mono.just(jsonResponse(request, model ? 202 : 201, + model ? "{}" : request.getBodyAsBinaryData().toString())); + }).build()); + FileUploadOptions upload = new FileUploadOptions().setFilePattern(Pattern.compile("\\.bin$")) + .setBlobClientConfiguration(client -> client.httpClient(blob)) + .setBlobUploadConfiguration( + options -> options.setMetadata(Collections.singletonMap("purpose", "review")) + .setRequestConditions( + new com.azure.storage.blob.models.BlobRequestConditions().setIfNoneMatch("*"))); + Runnable action = () -> { + if (model) { + ModelUploadOptions options + = new ModelUploadOptions().setFileUploadOptions(upload).setWaitForCompletion(false); + Path file = folder.resolve("weights.bin"); + ModelVersion submitted = async + ? builder.beta() + .buildBetaModelsAsyncClient() + .createModel("model", "1", file, options) + .block(Duration.ofSeconds(5)) + : builder.beta().buildBetaModelsClient().createModel("model", "1", file, options); + assertNotNull(submitted); + assertEquals("https://storage.example/container", submitted.getBlobUrl()); + } else if (async) { + assertNotNull(builder.buildDatasetsAsyncClient() + .createDatasetWithFolder("dataset", "1", folder, null, upload) + .block(Duration.ofSeconds(5))); + } else { + assertNotNull(builder.buildDatasetsClient() + .createDatasetWithFolder("dataset", "1", folder, null, upload)); + } + }; + if (failUpload) { + assertThrows(IllegalArgumentException.class, action::run); + } else { + action.run(); + } + assertEquals(failUpload ? 1 : 2, projectCalls.get()); + assertEquals(1, uploadCalls.get()); + } + } + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + void modelUploadRegistersMetadataAndWaits(boolean async, @TempDir Path folder) throws IOException { + Files.createDirectories(folder.resolve("nested")); + Files.write(folder.resolve("nested/model.bin"), new byte[] { 1, 2, 3 }); + Files.write(folder.resolve("excluded.txt"), new byte[] { 4 }); + List uploads = new ArrayList<>(); + AtomicReference> registered = new AtomicReference<>(); + AtomicInteger polls = new AtomicInteger(); + AtomicInteger calls = new AtomicInteger(); + HttpClient blobClient = request -> { + consumeBody(request); + uploads.add(request); + return Mono.just(new MockHttpResponse(request, 201, new HttpHeaders().set(HttpHeaderName.ETAG, "\"etag\""), + new byte[0])); + }; + HttpClient projectClient = request -> { + int call = calls.incrementAndGet(); + if (call == 1) { + String pending = pendingResponse(); + return Mono.just(jsonResponse(request, 200, + async + ? pending.replace("blobReference", "blobReferenceForConsumption") + .replace("pendingUploadId", "temporaryDataReferenceId") + : pending)); + } + if (request.getHttpMethod() != HttpMethod.GET) { + assertEquals(1, uploads.size()); + registered.set(request.getBodyAsBinaryData().toObject(Map.class)); + return Mono.just(jsonResponse(request, 202, "{}")); + } + if (polls.incrementAndGet() == 1) { + return Mono.just(jsonResponse(request, 404, "{\"error\":{\"code\":\"NotFound\"}}")); + } + return Mono.just(jsonResponse(request, 200, + "{\"blobUri\":\"https://storage.example/container\",\"name\":\"model\",\"version\":\"1\"}")); + }; + FileUploadOptions upload = new FileUploadOptions().setFilePattern(Pattern.compile("\\.bin$")) + .setBlobClientConfiguration(builder -> builder.httpClient(blobClient)) + .setBlobUploadConfiguration(options -> options.setMetadata(Collections.singletonMap("purpose", "model"))); + ModelUploadOptions options = new ModelUploadOptions().setFileUploadOptions(upload) + .setDescription("description") + .setBaseModel("base") + .setTags(Collections.singletonMap("tag", "value")) + .setPollInterval(Duration.ofMillis(1)) + .setTimeout(Duration.ofSeconds(5)); + AIProjectClientBuilder builder = new AIProjectClientBuilder().endpoint("https://localhost/projects/test") + .pipeline(new HttpPipelineBuilder().httpClient(projectClient).build()); + ModelVersion model = async + ? builder.beta() + .buildBetaModelsAsyncClient() + .createModel("model", "1", folder, options) + .block(Duration.ofSeconds(10)) + : builder.beta().buildBetaModelsClient().createModel("model", "1", folder, options); + assertNotNull(model); + assertEquals("model", model.getName()); + assertEquals(2, polls.get()); + assertEquals("/container/nested/model.bin", java.net.URI.create(uploads.get(0).getUrl().toString()).getPath()); + assertEquals("model", uploads.get(0).getHeaders().getValue("x-ms-meta-purpose")); + assertTrue(uploads.get(0).getUrl().getQuery().contains("sig=")); + assertEquals("https://storage.example/container", registered.get().get("blobUri")); + assertEquals("description", registered.get().get("description")); + assertEquals("base", registered.get().get("baseModel")); + assertEquals(Collections.singletonMap("tag", "value"), registered.get().get("tags")); + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + void invalidModelSourceNeverRequestsStorage(boolean async, @TempDir Path folder) throws IOException { + AIProjectClientBuilder builder = new AIProjectClientBuilder().endpoint("https://localhost") + .httpClient(request -> Mono.error(new AssertionError("Unexpected HTTP request"))); + Path emptyFile = Files.createFile(folder.resolve("empty.bin")); + for (Path source : new Path[] { + folder.resolve("missing"), + emptyFile, + Files.createDirectory(folder.resolve("empty")) }) { + assertThrows(IllegalArgumentException.class, () -> { + if (async) { + builder.beta() + .buildBetaModelsAsyncClient() + .createModel("model", "1", source, null) + .block(Duration.ofSeconds(5)); + } else { + builder.beta().buildBetaModelsClient().createModel("model", "1", source, null); + } + }); + } + assertThrows(IllegalArgumentException.class, () -> new ModelUploadOptions().setTimeout(Duration.ZERO)); + } + + private static String pendingResponse() { + return "{\"pendingUploadId\":\"upload\",\"blobReference\":{\"blobUri\":\"https://storage.example/container\"," + + "\"storageAccountArmId\":\"storage\",\"credential\":{\"type\":\"SAS\"," + + "\"sasUri\":\"https://storage.example/container?sv=2024-11-04&sr=c&sig=fake\"}}}"; + } + + private static MockHttpResponse jsonResponse(HttpRequest request, int status, String body) { + return new MockHttpResponse(request, status, + new HttpHeaders().set(HttpHeaderName.CONTENT_TYPE, "application/json"), + body.getBytes(StandardCharsets.UTF_8)); + } + + private static void consumeBody(HttpRequest request) { + try (InputStream stream = request.getBodyAsBinaryData().toStream()) { + byte[] buffer = new byte[8192]; + int bytesRead = stream.read(buffer); + while (bytesRead != -1) { + bytesRead = stream.read(buffer); + } + } catch (IOException exception) { + throw new UncheckedIOException(exception); + } + } +} diff --git a/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/FoundryFeaturesHeaderVerificationTest.java b/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/FoundryFeaturesHeaderVerificationTest.java index f63ef430fb09e..87109cbac8c09 100644 --- a/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/FoundryFeaturesHeaderVerificationTest.java +++ b/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/FoundryFeaturesHeaderVerificationTest.java @@ -3,6 +3,11 @@ package com.azure.ai.projects; +import com.azure.ai.projects.implementation.TokenUtils; +import com.azure.ai.projects.implementation.http.HttpClientHelper; +import com.azure.core.credential.AccessToken; +import com.azure.core.credential.TokenCredential; +import com.azure.core.credential.TokenRequestContext; import com.azure.core.http.HttpClient; import com.azure.core.http.HttpHeaderName; import com.azure.core.http.HttpHeaders; @@ -18,18 +23,170 @@ import com.azure.core.test.utils.MockTokenCredential; import com.azure.core.util.BinaryData; import com.azure.core.util.Context; -import org.junit.jupiter.api.Test; -import reactor.core.publisher.Mono; - +import com.openai.client.OpenAIClientAsync; +import com.openai.core.ClientOptions; +import com.openai.credential.BearerTokenCredential; import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.OffsetDateTime; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; +import static org.junit.jupiter.api.Assertions.assertTrue; public class FoundryFeaturesHeaderVerificationTest { + @Test + public void asyncAuthenticationPreservesLazyCredentialsAndRetryCount() { + RecordingHttpClient transport = new RecordingHttpClient(request -> new MockHttpResponse(request, 500, + new HttpHeaders().set(HttpHeaderName.CONTENT_TYPE, "application/json"), + "{}".getBytes(StandardCharsets.UTF_8))); + com.openai.core.http.HttpClient custom + = HttpClientHelper.mapToOpenAIHttpClient(new HttpPipelineBuilder().httpClient(transport).build()); + AIProjectClientBuilder builder = createBuilder(transport); + OpenAIClientAsync client = builder.buildOpenAIAsyncClient(options -> options.httpClient(custom).maxRetries(1)); + assertThrows(CompletionException.class, () -> client.models().list().join()); + assertEquals(2, transport.requests.size()); + AtomicInteger calls = new AtomicInteger(); + OpenAIClientAsync overridden = builder.buildOpenAIAsyncClient( + options -> options.httpClient(custom).maxRetries(0).credential(BearerTokenCredential.create(() -> { + calls.incrementAndGet(); + return "custom-token"; + }))); + assertEquals(0, calls.get()); + assertThrows(CompletionException.class, () -> overridden.models().list().join()); + assertTrue(calls.get() > 0); + assertEquals("Bearer custom-token", + transport.getLastRequest().getHeaders().getValue(HttpHeaderName.AUTHORIZATION)); + } + + @Test + public void cancellingAuthenticatedTransportCancelsTokenSubscription() { + AtomicBoolean cancelled = new AtomicBoolean(); + RecordingHttpClient transport = newOpenAIRecordingHttpClient(); + TokenUtils.AsyncAuthentication authentication = new TokenUtils.AsyncAuthentication( + context -> Mono.never().doOnCancel(() -> cancelled.set(true)), + "https://ai.azure.com/.default"); + ClientOptions.Builder options = ClientOptions.builder() + .credential(authentication.getCredential()) + .httpClient( + HttpClientHelper.mapToOpenAIHttpClient(new HttpPipelineBuilder().httpClient(transport).build())); + com.openai.core.http.HttpClient authenticatedTransport = authentication.configure(options); + com.openai.core.http.HttpRequest request = com.openai.core.http.HttpRequest.builder() + .method(com.openai.core.http.HttpMethod.GET) + .baseUrl("https://localhost/models") + .putHeader("Authorization", "Bearer " + ((BearerTokenCredential) authentication.getCredential()).token()) + .build(); + CompletableFuture result = authenticatedTransport.executeAsync(request); + assertTrue(result.cancel(true)); + assertTrue(cancelled.get()); + assertTrue(transport.requests.isEmpty()); + } + + @Test + public void asyncAuthenticationWaitsWithoutBlockingAndDoesNotSendOnFailure() { + Sinks.One pending = Sinks.one(); + RecordingHttpClient transport = newOpenAIRecordingHttpClient(); + AIProjectClientBuilder builder = new AIProjectClientBuilder().endpoint("https://localhost/projects/test") + .httpClient(transport) + .credential(context -> pending.asMono()); + OpenAIClientAsync client = builder.buildOpenAIAsyncClient(); + CompletableFuture result = assertTimeoutPreemptively(Duration.ofSeconds(2), () -> client.models().list()); + assertFalse(result.isDone()); + assertTrue(transport.requests.isEmpty()); + pending.tryEmitValue(new AccessToken("delayed", OffsetDateTime.now().plusHours(1))); + result.join(); + assertEquals("Bearer delayed", transport.getLastRequest().getHeaders().getValue(HttpHeaderName.AUTHORIZATION)); + int sent = transport.requests.size(); + for (Mono failure : Arrays + .asList(Mono.error(new IllegalStateException("token failed")), Mono.empty())) { + OpenAIClientAsync failingClient = builder.credential(context -> failure).buildOpenAIAsyncClient(); + assertThrows(CompletionException.class, () -> failingClient.models().list().join()); + assertEquals(sent, transport.requests.size()); + } + } + + @Test + public void asyncOpenAIAuthenticationNeverRequestsSynchronousTokens() { + RecordingHttpClient transport = newOpenAIRecordingHttpClient(); + AtomicInteger requests = new AtomicInteger(); + TokenCredential credential = new TokenCredential() { + @Override + public Mono getToken(TokenRequestContext context) { + assertEquals(Collections.singletonList("https://ai.azure.com/.default"), context.getScopes()); + return Mono.defer(() -> { + requests.incrementAndGet(); + return Mono.just(new AccessToken("async-token", OffsetDateTime.now().plusHours(1))); + }); + } + + @Override + public AccessToken getTokenSync(TokenRequestContext context) { + throw new AssertionError("Async authentication must not call getTokenSync"); + } + }; + AIProjectClientBuilder builder = new AIProjectClientBuilder().endpoint("https://localhost/projects/test") + .credential(credential) + .httpClient(transport); + builder.buildOpenAIAsyncClient().models().list().join(); + builder.buildAgentScopedOpenAIAsyncClient("agent").models().list().join(); + com.openai.core.http.HttpClient custom + = HttpClientHelper.mapToOpenAIHttpClient(new HttpPipelineBuilder().httpClient(transport).build()); + builder.buildOpenAIAsyncClient(options -> options.httpClient(custom)).models().list().join(); + builder.buildAgentScopedOpenAIAsyncClient("agent", options -> options.httpClient(custom)) + .models() + .list() + .join(); + assertEquals(4, requests.get()); + assertEquals("Bearer async-token", + transport.getLastRequest().getHeaders().getValue(HttpHeaderName.AUTHORIZATION)); + builder.buildOpenAIAsyncClient(options -> options.apiKey("override").httpClient(custom)).models().list().join(); + assertEquals(4, requests.get()); + assertEquals("Bearer override", transport.getLastRequest().getHeaders().getValue(HttpHeaderName.AUTHORIZATION)); + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void previewRequiredErrorPreservesResponse(boolean async) { + String body = "{\"error\":{\"code\":\"preview_feature_required\",\"message\":\"Preview required\"}}"; + HttpClient httpClient = request -> Mono.just( + new MockHttpResponse(request, 403, new HttpHeaders().set(HttpHeaderName.CONTENT_TYPE, "application/json"), + body.getBytes(StandardCharsets.UTF_8))); + AIProjectClientBuilder builder = new AIProjectClientBuilder().endpoint("https://localhost/projects/test") + .pipeline(new HttpPipelineBuilder().httpClient(httpClient).build()); + com.azure.core.exception.HttpResponseException error = org.junit.jupiter.api.Assertions + .assertThrows(com.azure.core.exception.HttpResponseException.class, () -> { + if (async) { + builder.buildEvaluationRulesAsyncClient() + .createOrUpdateEvaluationRuleWithResponse("rule", BinaryData.fromString("{}"), + new RequestOptions()) + .block(Duration.ofSeconds(5)); + } else { + builder.buildEvaluationRulesClient() + .createOrUpdateEvaluationRuleWithResponse("rule", BinaryData.fromString("{}"), + new RequestOptions()); + } + }); + assertTrue(error.getMessage().contains("AIProjectClientBuilder.allowPreview(true)")); + assertEquals(body, error.getResponse().getBodyAsString().block()); + } + private static final HttpHeaderName FOUNDRY_FEATURES = HttpHeaderName.fromString("Foundry-Features"); private static final HttpHeaderName CUSTOM_PIPELINE_HEADER = HttpHeaderName.fromString("X-Custom-Pipeline"); private static final String CUSTOM_PIPELINE_VALUE = "custom-pipeline"; @@ -229,13 +386,76 @@ public void openAIClientsUseCustomPipeline() { builder.buildAgentScopedOpenAIClient("agent").models().list(); assertEquals(CUSTOM_PIPELINE_VALUE, customPipelineHeader(httpClient)); - assertNull(foundryFeatures(httpClient)); + assertEquals( + "WorkflowAgents=V1Preview,ExternalAgents=V1Preview,VoiceAgents=V1Preview," + + "DraftAgents=V1Preview,AgentsOptimization=V2Preview,ModelRouterControls=V1Preview", + foundryFeatures(httpClient)); + assertEquals("/api/projects/project/agents/agent/endpoint/protocols/openai/models", + httpClient.getLastRequest().getUrl().getPath()); + assertEquals("api-version=v1", httpClient.getLastRequest().getUrl().getQuery()); + + builder.buildAgentScopedOpenAIAsyncClient("agent").models().list().join(); + assertEquals("api-version=v1", httpClient.getLastRequest().getUrl().getQuery()); + assertEquals(CUSTOM_PIPELINE_VALUE, customPipelineHeader(httpClient)); } private static RecordingHttpClient newOpenAIRecordingHttpClient() { return new RecordingHttpClient(FoundryFeaturesHeaderVerificationTest::openAIResponse); } + @Test + public void explicitLogOptionsOverrideConsoleLoggingDefault() throws java.io.IOException { + for (boolean enabled : new boolean[] { false, true }) { + RecordingHttpClient httpClient = new RecordingHttpClient(request -> new MockHttpResponse(request, 200, + new HttpHeaders().set(HttpHeaderName.CONTENT_TYPE, "text/event-stream; charset=utf-8"), + "data: test\n\n".getBytes(StandardCharsets.UTF_8))); + AIProjectClientBuilder builder + = createBuilder(httpClient).configuration(com.azure.core.util.Configuration.getGlobalConfiguration() + .clone() + .put("AZURE_AI_PROJECTS_CONSOLE_LOGGING", "true")); + if (!enabled) { + builder.httpLogOptions(new com.azure.core.http.policy.HttpLogOptions() + .setLogLevel(com.azure.core.http.policy.HttpLogDetailLevel.NONE)); + } + java.util.concurrent.atomic.AtomicReference transport + = new java.util.concurrent.atomic.AtomicReference<>(); + builder.buildOpenAIClient(options -> transport.set(options.build().httpClient())); + com.openai.core.http.HttpRequest request = com.openai.core.http.HttpRequest.builder() + .method(com.openai.core.http.HttpMethod.GET) + .baseUrl("https://localhost/stream") + .build(); + try (com.openai.core.http.HttpResponse response = transport.get().execute(request); + java.io.InputStream body = response.body()) { + assertEquals(enabled, body instanceof java.io.FilterInputStream); + assertEquals('d', body.read()); + } + } + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void openAIOverridesPreserveCredentialsHeadersAndQuery(boolean async) { + RecordingHttpClient httpClient = newOpenAIRecordingHttpClient(); + AIProjectClientBuilder builder = createBuilder(httpClient); + java.util.function.Consumer configure + = options -> options.baseUrl("https://localhost:8080/custom/openai") + .apiKey("test-api-key") + .replaceHeaders("User-Agent", "review-client/1.0") + .replaceHeaders("foundry-features", "") + .replaceQueryParams("api-version", "test-version"); + if (async) { + builder.buildAgentScopedOpenAIAsyncClient("agent", configure).models().list().join(); + } else { + builder.buildAgentScopedOpenAIClient("agent", configure).models().list(); + } + assertEquals("/custom/openai/models", httpClient.getLastRequest().getUrl().getPath()); + assertEquals("api-version=test-version", httpClient.getLastRequest().getUrl().getQuery()); + assertEquals("Bearer test-api-key", + httpClient.getLastRequest().getHeaders().getValue(HttpHeaderName.AUTHORIZATION)); + assertEquals("", foundryFeatures(httpClient)); + assertEquals("review-client/1.0", httpClient.getLastRequest().getHeaders().getValue(HttpHeaderName.USER_AGENT)); + } + private static AIProjectClientBuilder createBuilder(RecordingHttpClient httpClient) { return new AIProjectClientBuilder().endpoint("https://localhost:8080/api/projects/project") .credential(new MockTokenCredential()) @@ -243,6 +463,53 @@ private static AIProjectClientBuilder createBuilder(RecordingHttpClient httpClie .serviceVersion(AIProjectsServiceVersion.V1); } + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void customOpenAITransportRetainsAuthenticationAndAgentDefaults(boolean async) { + RecordingHttpClient customTransport = newOpenAIRecordingHttpClient(); + AtomicInteger tokenRequests = new AtomicInteger(); + AIProjectClientBuilder builder = new AIProjectClientBuilder().endpoint("https://localhost/api/projects/project") + .clientOptions(new com.azure.core.util.ClientOptions().setApplicationId("review-app")) + .httpClient(request -> Mono.error(new AssertionError("Default transport must not be used"))) + .credential(context -> { + assertEquals(Collections.singletonList("https://ai.azure.com/.default"), context.getScopes()); + tokenRequests.incrementAndGet(); + return Mono.just(new AccessToken("test-token", OffsetDateTime.now().plusHours(1))); + }); + com.openai.core.http.HttpClient transport + = HttpClientHelper.mapToOpenAIHttpClient(new HttpPipelineBuilder().httpClient(customTransport).build()); + if (async) { + builder.buildAgentScopedOpenAIAsyncClient("agent", options -> options.httpClient(transport)) + .models() + .list() + .join(); + } else { + builder.buildAgentScopedOpenAIClient("agent", options -> options.httpClient(transport)).models().list(); + } + assertEquals( + "WorkflowAgents=V1Preview,ExternalAgents=V1Preview,VoiceAgents=V1Preview," + + "DraftAgents=V1Preview,AgentsOptimization=V2Preview,ModelRouterControls=V1Preview", + foundryFeatures(customTransport)); + assertEquals("api-version=v1", customTransport.getLastRequest().getUrl().getQuery()); + assertTrue(customTransport.getLastRequest() + .getHeaders() + .getValue(HttpHeaderName.USER_AGENT) + .startsWith("review-app azsdk-java-azure-ai-projects/")); + assertEquals("Bearer test-token", + customTransport.getLastRequest().getHeaders().getValue(HttpHeaderName.AUTHORIZATION)); + int initialTokenRequests = tokenRequests.get(); + assertTrue(initialTokenRequests > 0); + if (async) { + builder.buildOpenAIAsyncClient(options -> options.httpClient(transport)).models().list().join(); + } else { + builder.buildOpenAIClient(options -> options.httpClient(transport)).models().list(); + } + assertNull(foundryFeatures(customTransport)); + assertNull(customTransport.getLastRequest().getUrl().getQuery()); + assertEquals("/api/projects/project/openai/v1/models", customTransport.getLastRequest().getUrl().getPath()); + assertTrue(tokenRequests.get() > initialTokenRequests); + } + private static AIProjectClientBuilder createBuilder(HttpPipeline pipeline) { return new AIProjectClientBuilder().endpoint("https://localhost:8080/api/projects/project") .credential(new MockTokenCredential()) diff --git a/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/JobPollingTests.java b/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/JobPollingTests.java new file mode 100644 index 0000000000000..33d82046e1bf8 --- /dev/null +++ b/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/JobPollingTests.java @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.projects; + +import com.azure.core.exception.AzureException; +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.test.http.MockHttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import reactor.core.publisher.Mono; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class JobPollingTests { + @ParameterizedTest + @ValueSource(booleans = { false, true }) + void resumesExistingJobsUsingGetOnly(boolean async) { + AIProjectClientBuilder builder = builder("succeeded"); + if (async) { + assertNotNull(builder.beta() + .buildBetaDatasetsAsyncClient() + .resumeGenerationJob("job") + .setPollInterval(Duration.ofMillis(1)) + .last() + .flatMap(response -> response.getFinalResult()) + .block(Duration.ofSeconds(5))); + assertNotNull(builder.beta() + .buildBetaEvaluatorsAsyncClient() + .resumeEvaluatorGenerationJob("job") + .setPollInterval(Duration.ofMillis(1)) + .last() + .flatMap(response -> response.getFinalResult()) + .block(Duration.ofSeconds(5))); + assertNotNull(builder.beta() + .buildBetaAgentInsightMonitorsAsyncClient() + .resumeAgentInsightRun("monitor", "run") + .setPollInterval(Duration.ofMillis(1)) + .last() + .flatMap(response -> response.getFinalResult()) + .block(Duration.ofSeconds(5))); + } else { + assertNotNull(builder.beta() + .buildBetaDatasetsClient() + .resumeGenerationJob("job") + .setPollInterval(Duration.ofMillis(1)) + .getFinalResult(Duration.ofSeconds(5))); + assertNotNull(builder.beta() + .buildBetaEvaluatorsClient() + .resumeEvaluatorGenerationJob("job") + .setPollInterval(Duration.ofMillis(1)) + .getFinalResult(Duration.ofSeconds(5))); + assertNotNull(builder.beta() + .buildBetaAgentInsightMonitorsClient() + .resumeAgentInsightRun("monitor", "run") + .setPollInterval(Duration.ofMillis(1)) + .getFinalResult(Duration.ofSeconds(5))); + } + } + + @ParameterizedTest + @ValueSource(strings = { "failed", "cancelled" }) + void failedJobsDoNotReturnResults(String status) { + AIProjectClientBuilder builder = builder(status); + assertThrows(AzureException.class, + () -> builder.beta() + .buildBetaDatasetsClient() + .resumeGenerationJob("job") + .setPollInterval(Duration.ofMillis(1)) + .getFinalResult(Duration.ofSeconds(5))); + assertThrows(AzureException.class, + () -> builder.beta() + .buildBetaDatasetsAsyncClient() + .resumeGenerationJob("job") + .setPollInterval(Duration.ofMillis(1)) + .last() + .flatMap(response -> response.getFinalResult()) + .block(Duration.ofSeconds(5))); + } + + private static AIProjectClientBuilder builder(String status) { + return new AIProjectClientBuilder().endpoint("https://localhost/projects/test") + .pipeline(new HttpPipelineBuilder().httpClient(request -> { + assertEquals(HttpMethod.GET, request.getHttpMethod()); + String body = "{\"id\":\"job\",\"status\":\"" + status + "\",\"result\":{}}"; + return Mono.just(new MockHttpResponse(request, 200, + new HttpHeaders().set(HttpHeaderName.CONTENT_TYPE, "application/json"), + body.getBytes(StandardCharsets.UTF_8))); + }).build()); + } +} diff --git a/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/implementation/http/HttpClientHelperTests.java b/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/implementation/http/HttpClientHelperTests.java index a24bdb41b0878..c8e7805593b7d 100644 --- a/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/implementation/http/HttpClientHelperTests.java +++ b/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/implementation/http/HttpClientHelperTests.java @@ -4,6 +4,7 @@ package com.azure.ai.projects.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.HttpPipelineBuilder; import com.azure.core.http.HttpRequest; @@ -11,10 +12,7 @@ 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.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; @@ -24,6 +22,13 @@ import java.util.Arrays; import java.util.concurrent.CompletableFuture; import java.util.function.Function; +import java.util.stream.Stream; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import reactor.core.publisher.Mono; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -33,6 +38,137 @@ class HttpClientHelperTests { + @ParameterizedTest + @MethodSource("responseContentTypes") + void responseBodyLoggingOnlyWrapsEventStreams(String contentType, boolean eventStream) throws IOException { + for (boolean logBody : new boolean[] { false, true }) { + HttpHeaders headers = new HttpHeaders(); + if (contentType != null) { + headers.set(HttpHeaderName.CONTENT_TYPE, contentType); + } + InputStream original = new ByteArrayInputStream("data: hello\n\n".getBytes(StandardCharsets.UTF_8)); + MockHttpResponse response = new MockHttpResponse( + new HttpRequest(com.azure.core.http.HttpMethod.GET, "https://localhost/stream"), 200, headers) { + @Override + public InputStream getBodyAsInputStreamSync() { + return original; + } + }; + try (AzureHttpResponseAdapter adapter = new AzureHttpResponseAdapter(response, logBody); + InputStream body = adapter.body()) { + assertEquals(logBody && eventStream, body != original); + assertEquals("data: hello\n\n", new String(readAllBytes(body), StandardCharsets.UTF_8)); + } + } + } + + private static Stream responseContentTypes() { + return Stream.of(Arguments.of("text/event-stream", true), + Arguments.of("Text/Event-Stream; Charset=UTF-8", true), + Arguments.of(" \ttext/event-stream \t; charset=\"utf-8\"", true), + Arguments.of("text/event-stream; extension=\"value;with;semicolons\"", true), + Arguments.of("application/json", false), Arguments.of("text/event-stream-extra", false), + Arguments.of("application/json; extension=\"text/event-stream\"", false), + Arguments.of("text/event-stream, application/json", false), Arguments.of("", false), + Arguments.of((String) null, false)); + } + + @Test + void multipartUploadsSkipBodyLoggerAndPreservePayload() { + com.azure.core.http.policy.HttpLogOptions options = new com.azure.core.http.policy.HttpLogOptions() + .setLogLevel(com.azure.core.http.policy.HttpLogDetailLevel.BODY_AND_HEADERS) + .setRequestLogger((logger, context) -> Mono.error(new AssertionError("Body logger invoked"))); + byte[] payload = "private upload contents".getBytes(StandardCharsets.UTF_8); + HttpClient transport = request -> { + org.junit.jupiter.api.Assertions.assertArrayEquals(payload, request.getBodyAsBinaryData().toBytes()); + assertEquals("Multipart/Form-Data; boundary=test", + request.getHeaders().getValue(HttpHeaderName.CONTENT_TYPE)); + return Mono.just(new MockHttpResponse(request, 200, new byte[0])); + }; + com.azure.core.http.HttpPipeline pipeline = new HttpPipelineBuilder().httpClient(transport) + .policies(HttpClientHelper.createLoggingPolicy(options)) + .build(); + for (boolean async : new boolean[] { false, true }) { + HttpRequest request = new HttpRequest(com.azure.core.http.HttpMethod.POST, "https://localhost/upload") + .setHeader(HttpHeaderName.CONTENT_TYPE, "Multipart/Form-Data; boundary=test") + .setBody(payload); + try (HttpResponse response + = async ? pipeline.send(request).block() : pipeline.sendSync(request, Context.NONE)) { + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + } + } + assertEquals(com.azure.core.http.policy.HttpLogDetailLevel.BODY_AND_HEADERS, options.getLogLevel()); + } + + @Test + void responseBodyLoggingPreservesSplitUtf8() throws IOException { + String text + = "\u00e9\u4e2d\ud83d\ude00" + String.join("", java.util.Collections.nCopies(600, "data: \u00e9\n")); + byte[] expected = text.getBytes(StandardCharsets.UTF_8); + for (int readSize : new int[] { 1, 2, 3, 5, 2048 }) { + java.util.List chunks = new java.util.ArrayList<>(); + MockHttpResponse response + = createMockResponse(new HttpRequest(com.azure.core.http.HttpMethod.GET, "https://localhost/stream"), + 200, new HttpHeaders(), text); + try (AzureHttpResponseAdapter adapter = new AzureHttpResponseAdapter(response, chunks::add); + InputStream body = adapter.body()) { + ByteArrayOutputStream actual = new ByteArrayOutputStream(); + actual.write(body.read()); + assertTrue(chunks.isEmpty()); + byte[] buffer = new byte[readSize + 2]; + int count; + while ((count = body.read(buffer, 2, readSize)) != -1) { + actual.write(buffer, 2, count); + } + org.junit.jupiter.api.Assertions.assertArrayEquals(expected, actual.toByteArray()); + assertEquals(text, String.join("", chunks)); + int logged = chunks.size(); + assertEquals(-1, body.read()); + assertEquals(logged, chunks.size()); + } + } + } + + @Test + void responseBodyLoggingReplacesTruncatedUtf8AtEof() throws IOException { + java.util.List chunks = new java.util.ArrayList<>(); + MockHttpResponse response + = new MockHttpResponse(new HttpRequest(com.azure.core.http.HttpMethod.GET, "https://localhost/stream"), 200, + new HttpHeaders(), new byte[] { (byte) 0xe2, (byte) 0x82 }); + try (AzureHttpResponseAdapter adapter = new AzureHttpResponseAdapter(response, chunks::add); + InputStream body = adapter.body()) { + assertEquals(0xe2, body.read()); + assertEquals(0x82, body.read()); + assertTrue(chunks.isEmpty()); + assertEquals(-1, body.read()); + assertEquals("\ufffd", String.join("", chunks)); + assertEquals(-1, body.read()); + assertEquals(1, chunks.size()); + } + } + + @Test + void responseBodyLoggingIsLazyAndPreservesBytes() throws IOException { + java.util.List chunks = new java.util.ArrayList<>(); + MockHttpResponse response + = createMockResponse(new HttpRequest(com.azure.core.http.HttpMethod.GET, "https://localhost/stream"), 200, + new HttpHeaders(), "data: hello\n\n"); + AzureHttpResponseAdapter adapter = new AzureHttpResponseAdapter(response, chunks::add); + assertTrue(chunks.isEmpty()); + try (InputStream body = adapter.body()) { + assertTrue(chunks.isEmpty()); + assertEquals('d', body.read()); + assertEquals("d", chunks.get(0)); + assertEquals("ata: hello\n\n", new String(readAllBytes(body), StandardCharsets.UTF_8)); + assertEquals("data: hello\n\n", String.join("", chunks)); + int chunkCount = chunks.size(); + assertEquals(-1, body.read()); + assertEquals(chunkCount, chunks.size()); + } + adapter.close(); + } + @Test void executeAsyncCompletesSuccessfully() { RecordingHttpClient recordingClient From b5d5c7f4265cf2897f5459a75c24b70052a90262 Mon Sep 17 00:00:00 2001 From: Mike Guo Date: Sun, 20 Sep 2026 12:26:23 +0800 Subject: [PATCH 2/3] Fix parity review feedback --- .../implementation/http/HttpClientHelper.java | 2 +- .../http/HttpClientHelperTests.java | 4 ++-- .../implementation/FileUploadHelper.java | 2 +- .../implementation/http/HttpClientHelper.java | 2 +- .../implementation/FileUploadHelperTests.java | 20 +++++++++++++++++++ .../http/HttpClientHelperTests.java | 4 ++-- 6 files changed, 27 insertions(+), 7 deletions(-) create mode 100644 sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/implementation/FileUploadHelperTests.java 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 index b324cfd58c52c..e5a9b73b12182 100644 --- 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 @@ -77,7 +77,7 @@ private HttpClientHelper() { String contentType = context.getHttpRequest().getHeaders().getValue(HttpHeaderName.CONTENT_TYPE); return options.getLogLevel().shouldLogBody() && contentType != null - && contentType.toLowerCase(java.util.Locale.ROOT).startsWith("multipart/") ? headers : normal; + && contentType.trim().toLowerCase(java.util.Locale.ROOT).startsWith("multipart/") ? headers : normal; } @Override 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 index 072f14359e19d..56ace3df1af38 100644 --- 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 @@ -81,7 +81,7 @@ void multipartUploadsSkipBodyLoggerAndPreservePayload() { byte[] payload = "private upload contents".getBytes(StandardCharsets.UTF_8); HttpClient transport = request -> { org.junit.jupiter.api.Assertions.assertArrayEquals(payload, request.getBodyAsBinaryData().toBytes()); - assertEquals("Multipart/Form-Data; boundary=test", + assertEquals(" \tMultipart/Form-Data; boundary=test", request.getHeaders().getValue(HttpHeaderName.CONTENT_TYPE)); return Mono.just(new MockHttpResponse(request, 200, new byte[0])); }; @@ -90,7 +90,7 @@ void multipartUploadsSkipBodyLoggerAndPreservePayload() { .build(); for (boolean async : new boolean[] { false, true }) { HttpRequest request = new HttpRequest(com.azure.core.http.HttpMethod.POST, "https://localhost/upload") - .setHeader(HttpHeaderName.CONTENT_TYPE, "Multipart/Form-Data; boundary=test") + .setHeader(HttpHeaderName.CONTENT_TYPE, " \tMultipart/Form-Data; boundary=test") .setBody(payload); try (HttpResponse response = async ? pipeline.send(request).block() : pipeline.sendSync(request, Context.NONE)) { diff --git a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/FileUploadHelper.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/FileUploadHelper.java index 8e8dc8abf8e40..02f4f8efd3361 100644 --- a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/FileUploadHelper.java +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/FileUploadHelper.java @@ -97,7 +97,7 @@ public static BlobReference getModelBlobReference(BinaryData response) { * @return the selected files. */ public static List getFiles(Path folder, FileUploadOptions options) { - if (folder == null || !Files.isDirectory(folder)) { + if (folder == null || folder.getFileName() == null || !Files.isDirectory(folder)) { throw new IllegalArgumentException("The provided path is not a folder: " + folder); } try (Stream paths = Files.walk(folder)) { diff --git a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/http/HttpClientHelper.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/http/HttpClientHelper.java index e98831b870d1f..4a7556384954b 100644 --- a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/http/HttpClientHelper.java +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/http/HttpClientHelper.java @@ -75,7 +75,7 @@ private HttpClientHelper() { String contentType = context.getHttpRequest().getHeaders().getValue(HttpHeaderName.CONTENT_TYPE); return options.getLogLevel().shouldLogBody() && contentType != null - && contentType.toLowerCase(java.util.Locale.ROOT).startsWith("multipart/") ? headers : normal; + && contentType.trim().toLowerCase(java.util.Locale.ROOT).startsWith("multipart/") ? headers : normal; } @Override diff --git a/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/implementation/FileUploadHelperTests.java b/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/implementation/FileUploadHelperTests.java new file mode 100644 index 0000000000000..8af8f93b2ec06 --- /dev/null +++ b/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/implementation/FileUploadHelperTests.java @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.projects.implementation; + +import java.nio.file.Path; +import java.nio.file.Paths; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +class FileUploadHelperTests { + + @Test + void getFilesRejectsFilesystemRoot() { + Path root = Paths.get("").toAbsolutePath().getRoot(); + + assertThrows(IllegalArgumentException.class, () -> FileUploadHelper.getFiles(root, null)); + } +} \ No newline at end of file diff --git a/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/implementation/http/HttpClientHelperTests.java b/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/implementation/http/HttpClientHelperTests.java index c8e7805593b7d..098d723650dfc 100644 --- a/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/implementation/http/HttpClientHelperTests.java +++ b/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/implementation/http/HttpClientHelperTests.java @@ -81,7 +81,7 @@ void multipartUploadsSkipBodyLoggerAndPreservePayload() { byte[] payload = "private upload contents".getBytes(StandardCharsets.UTF_8); HttpClient transport = request -> { org.junit.jupiter.api.Assertions.assertArrayEquals(payload, request.getBodyAsBinaryData().toBytes()); - assertEquals("Multipart/Form-Data; boundary=test", + assertEquals(" \tMultipart/Form-Data; boundary=test", request.getHeaders().getValue(HttpHeaderName.CONTENT_TYPE)); return Mono.just(new MockHttpResponse(request, 200, new byte[0])); }; @@ -90,7 +90,7 @@ void multipartUploadsSkipBodyLoggerAndPreservePayload() { .build(); for (boolean async : new boolean[] { false, true }) { HttpRequest request = new HttpRequest(com.azure.core.http.HttpMethod.POST, "https://localhost/upload") - .setHeader(HttpHeaderName.CONTENT_TYPE, "Multipart/Form-Data; boundary=test") + .setHeader(HttpHeaderName.CONTENT_TYPE, " \tMultipart/Form-Data; boundary=test") .setBody(payload); try (HttpResponse response = async ? pipeline.send(request).block() : pipeline.sendSync(request, Context.NONE)) { From ea5203114b4d19c3c1b2cde62aa7360f177f15c5 Mon Sep 17 00:00:00 2001 From: Mike Guo Date: Sun, 20 Sep 2026 13:01:49 +0800 Subject: [PATCH 3/3] Format review fixes --- .../azure/ai/agents/implementation/http/HttpClientHelper.java | 4 +++- .../ai/projects/implementation/http/HttpClientHelper.java | 4 +++- .../ai/projects/implementation/FileUploadHelperTests.java | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) 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 index e5a9b73b12182..38dfab423bc58 100644 --- 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 @@ -77,7 +77,9 @@ private HttpClientHelper() { String contentType = context.getHttpRequest().getHeaders().getValue(HttpHeaderName.CONTENT_TYPE); return options.getLogLevel().shouldLogBody() && contentType != null - && contentType.trim().toLowerCase(java.util.Locale.ROOT).startsWith("multipart/") ? headers : normal; + && contentType.trim().toLowerCase(java.util.Locale.ROOT).startsWith("multipart/") + ? headers + : normal; } @Override diff --git a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/http/HttpClientHelper.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/http/HttpClientHelper.java index 4a7556384954b..9b3b1cd623913 100644 --- a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/http/HttpClientHelper.java +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/http/HttpClientHelper.java @@ -75,7 +75,9 @@ private HttpClientHelper() { String contentType = context.getHttpRequest().getHeaders().getValue(HttpHeaderName.CONTENT_TYPE); return options.getLogLevel().shouldLogBody() && contentType != null - && contentType.trim().toLowerCase(java.util.Locale.ROOT).startsWith("multipart/") ? headers : normal; + && contentType.trim().toLowerCase(java.util.Locale.ROOT).startsWith("multipart/") + ? headers + : normal; } @Override diff --git a/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/implementation/FileUploadHelperTests.java b/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/implementation/FileUploadHelperTests.java index 8af8f93b2ec06..11ef73168ce71 100644 --- a/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/implementation/FileUploadHelperTests.java +++ b/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/implementation/FileUploadHelperTests.java @@ -17,4 +17,4 @@ void getFilesRejectsFilesystemRoot() { assertThrows(IllegalArgumentException.class, () -> FileUploadHelper.getFiles(root, null)); } -} \ No newline at end of file +}