From 1b60650dde017471a5b40d2bb7507fb0ef24b117 Mon Sep 17 00:00:00 2001 From: Mike Guo Date: Thu, 17 Sep 2026 07:20:55 +0800 Subject: [PATCH 01/25] Rebase voice agents changes onto main --- sdk/ai/azure-ai-agents/CHANGELOG.md | 36 +- sdk/ai/azure-ai-agents/README.md | 217 +++- .../src/main/java/AgentsCustomizations.java | 56 + sdk/ai/azure-ai-agents/pom.xml | 56 + .../azure/ai/agents/AgentsClientBuilder.java | 233 +++- .../ai/agents/BetaAgentsAsyncClient.java | 12 + .../com/azure/ai/agents/BetaAgentsClient.java | 12 + .../agents/BetaMemoryStoresAsyncClient.java | 14 + .../ai/agents/BetaMemoryStoresClient.java | 14 + .../BetaVoiceAgentWebSocketAsyncClient.java | 53 + .../agents/BetaVoiceAgentWebSocketClient.java | 53 + ...VoiceAgentWebSocketSessionAsyncClient.java | 635 ++++++++++ .../VoiceAgentWebSocketSessionClient.java | 613 ++++++++++ .../ai/agents/VoiceAgentWebSocketUtils.java | 188 +++ .../AgentsServicePollUtils.java | 121 +- .../OperationLocationPollingStrategy.java | 15 +- .../SyncOperationLocationPollingStrategy.java | 14 +- .../ai/agents/implementation/TokenUtils.java | 98 +- .../http/AzureHttpResponseAdapter.java | 87 +- .../http/FoundryPolicyHelper.java | 60 +- .../implementation/http/HttpClientHelper.java | 77 +- ...oiceAgentWebSocketClientConfiguration.java | 97 ++ .../VoiceAgentWebSocketHandshakeHandler.java | 68 ++ .../VoiceAgentWebSocketHttpResponse.java | 127 ++ .../implementation/utils/FileUtils.java | 23 +- .../ai/agents/models/CodeFileDetails.java | 7 +- .../agents/models/RawRealtimeServerEvent.java | 59 + .../VoiceAgentWebSocketConnectionOptions.java | 438 +++++++ .../VoiceAgentWebSocketOverflowStrategy.java | 17 + .../src/main/java/module-info.java | 11 +- .../com/azure/ai/agents/ReadmeSamples.java | 26 + .../voice/VoiceAgentBasicAsyncSample.java | 66 ++ .../agents/voice/VoiceAgentBasicSample.java | 67 ++ .../voice/VoiceAgentGenerateSample.java | 57 + ...AgentLiveAudioConversationAsyncSample.java | 375 ++++++ .../VoiceAgentLiveFunctionToolSample.java | 165 +++ ...eAgentLiveTextConversationAsyncSample.java | 160 +++ .../VoiceAgentLiveTextConversationSample.java | 157 +++ ...VoiceAgentReadConversationAudioSample.java | 79 ++ .../VoiceAgentReadConversationSample.java | 68 ++ .../voice/VoiceAgentRealtimeSampleUtils.java | 152 +++ .../agents/voice/VoiceAgentSampleUtils.java | 31 + .../voice/VoiceAgentVersionsSample.java | 70 ++ .../voice/VoiceAgentWithToolsSample.java | 112 ++ .../ai/agents/ConversationsAsyncTests.java | 9 +- .../azure/ai/agents/ConversationsTests.java | 9 +- ...FoundryFeaturesHeaderVerificationTest.java | 361 +++++- .../AgentsServicePollUtilsTest.java | 187 ++- .../agents/implementation/FileUtilsTest.java | 38 + .../http/HttpClientHelperTests.java | 144 ++- ...oiceAgentDefinitionSerializationTests.java | 115 ++ .../VoiceAgentConversationsAsyncTests.java | 346 ++++++ .../voice/VoiceAgentConversationsTests.java | 341 ++++++ .../voice/VoiceAgentCrudAsyncTests.java | 289 +++++ .../ai/agents/voice/VoiceAgentCrudTests.java | 276 +++++ ...LiveAudioConversationAsyncSampleTests.java | 227 ++++ .../voice/VoiceAgentRealtimeLiveTests.java | 398 +++++++ .../voice/VoiceAgentTelephonyLiveTests.java | 314 +++++ .../voice/VoiceAgentTelephonyTests.java | 439 +++++++ .../VoiceAgentWebSocketSessionTests.java | 1038 +++++++++++++++++ .../resources/websocket-localhost-cert.pem | 19 + .../resources/websocket-localhost-key.pem | 28 + sdk/ai/azure-ai-projects/CHANGELOG.md | 17 + sdk/ai/azure-ai-projects/README.md | 102 ++ .../src/main/java/ProjectsCustomizations.java | 41 + .../ai/projects/AIProjectClientBuilder.java | 178 ++- .../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/DatasetsAsyncClient.java | 185 ++- .../com/azure/ai/projects/DatasetsClient.java | 120 +- .../azure/ai/projects/EvaluationsHelper.java | 25 + .../ai/projects/TelemetryAsyncClient.java | 65 ++ .../azure/ai/projects/TelemetryClient.java | 70 ++ .../implementation/FileUploadHelper.java | 149 +++ .../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/ReadmeSamples.java | 39 +- .../azure/ai/projects/DatasetsClientTest.java | 45 +- .../ai/projects/EvaluationsHelperTests.java | 58 +- .../azure/ai/projects/FileUploadTests.java | 211 ++++ ...FoundryFeaturesHeaderVerificationTest.java | 275 ++++- .../azure/ai/projects/JobPollingTests.java | 98 ++ .../ai/projects/TelemetryClientTest.java | 101 ++ .../http/HttpClientHelperTests.java | 144 ++- 97 files changed, 12551 insertions(+), 234 deletions(-) create mode 100644 sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketAsyncClient.java create mode 100644 sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketClient.java create mode 100644 sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketSessionAsyncClient.java create mode 100644 sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketSessionClient.java create mode 100644 sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketUtils.java create mode 100644 sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/realtime/VoiceAgentWebSocketClientConfiguration.java create mode 100644 sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/realtime/VoiceAgentWebSocketHandshakeHandler.java create mode 100644 sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/realtime/VoiceAgentWebSocketHttpResponse.java create mode 100644 sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/RawRealtimeServerEvent.java create mode 100644 sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/VoiceAgentWebSocketConnectionOptions.java create mode 100644 sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/VoiceAgentWebSocketOverflowStrategy.java create mode 100644 sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentBasicAsyncSample.java create mode 100644 sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentBasicSample.java create mode 100644 sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentGenerateSample.java create mode 100644 sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSample.java create mode 100644 sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveFunctionToolSample.java create mode 100644 sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveTextConversationAsyncSample.java create mode 100644 sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveTextConversationSample.java create mode 100644 sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentReadConversationAudioSample.java create mode 100644 sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentReadConversationSample.java create mode 100644 sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentRealtimeSampleUtils.java create mode 100644 sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentSampleUtils.java create mode 100644 sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentVersionsSample.java create mode 100644 sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentWithToolsSample.java create mode 100644 sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsAsyncTests.java create mode 100644 sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsTests.java create mode 100644 sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentCrudAsyncTests.java create mode 100644 sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentCrudTests.java create mode 100644 sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSampleTests.java create mode 100644 sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentRealtimeLiveTests.java create mode 100644 sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentTelephonyLiveTests.java create mode 100644 sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentTelephonyTests.java create mode 100644 sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentWebSocketSessionTests.java create mode 100644 sdk/ai/azure-ai-agents/src/test/resources/websocket-localhost-cert.pem create mode 100644 sdk/ai/azure-ai-agents/src/test/resources/websocket-localhost-key.pem create mode 100644 sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/TelemetryAsyncClient.java create mode 100644 sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/TelemetryClient.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/FileUploadTests.java create mode 100644 sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/JobPollingTests.java create mode 100644 sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/TelemetryClientTest.java diff --git a/sdk/ai/azure-ai-agents/CHANGELOG.md b/sdk/ai/azure-ai-agents/CHANGELOG.md index e6560c78eed14..ea46fcba07f59 100644 --- a/sdk/ai/azure-ai-agents/CHANGELOG.md +++ b/sdk/ai/azure-ai-agents/CHANGELOG.md @@ -7,20 +7,46 @@ - Added `VersionSelector.setVersionSelectionRule` as a convenience for configuring a single version selection rule. - Added public `StreamingResponseUtils` in the `com.azure.ai.agents.util` package for converting OpenAI streaming responses to Azure SDK `IterableStream` and Reactor `Flux` types. -- Added `BetaVoiceAgentsTelephonyClient` and `BetaVoiceAgentsTelephonyAsyncClient`, built through - `AgentsClientBuilder.beta()`, for managing voice-agent outbound call jobs and telephony campaigns. -- Added `BetaVoiceAgentsConversationsClient` and `BetaVoiceAgentsConversationsAsyncClient`, built through - `AgentsClientBuilder.beta()`, for managing persisted voice-agent conversations and their responses, items, and - audio content. +- Added raw JSON WebSocket sends, complete unknown-event payloads, UTF-8 binary JSON reception, transport customization, + 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 session-affinity routing configuration through `AzureCreateResponseOptions.setRoutingConfig(...)`, `RoutingConfiguration`, and `SessionAffinityConfiguration`, with response details exposed by `ModelRouterDetails.getSessionAffinity()`. +- Added preview synchronous and asynchronous voice-agent WebSocket clients and session APIs with typed realtime events, text and PCM16 audio input, response cancellation, function-call output, persisted-conversation options, and authenticated `wss://` transport. +- Added synchronous and asynchronous live text conversation samples, an asynchronous Java Sound microphone/speaker sample with barge-in, and a live client-executed function-tool sample. ### Breaking Changes +- Voice-agent WebSocket connections now require secure endpoints, including localhost. Configure certificate trust for + local TLS servers through the transport callbacks. Synchronous sessions now enforce a 32 MiB default message limit. +- Replaced `generateAgent` and `generateAgentWithResponse` on `AgentsClient` and `AgentsAsyncClient` with + `createAgentFromPrompt` and `createAgentFromPromptWithResponse` on `BetaAgentsClient` and `BetaAgentsAsyncClient`. +- Moved `getId()` and `getConversationId()` from `VoiceResponseBase` to `VoiceResponse`. ### 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 automatically send agent preview features, including model router controls, 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 optimization jobs and telephony operations 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 3fbb328e55582..bbcb5ed722c7e 100644 --- a/sdk/ai/azure-ai-agents/README.md +++ b/sdk/ai/azure-ai-agents/README.md @@ -68,6 +68,8 @@ The Agents client library has the following sub-clients which group the differen - `ResponsesClient` / `ResponsesAsyncClient`: Create responses that require Azure-specific request fields, such as an explicit `AgentReference` or structured inputs. For standard OpenAI Responses API calls through a configured agent endpoint, use an agent-scoped OpenAI client. See the [OpenAI Responses API documentation][openai_responses_api_docs] for more information. - `BetaMemoryStoresClient` / `BetaMemoryStoresAsyncClient` **(preview)**: Manage memory stores and individual memory items for agents. - `ToolboxesClient` / `ToolboxesAsyncClient`: Manage toolboxes and toolbox versions. +- `BetaVoiceAgentWebSocketClient` / `BetaVoiceAgentWebSocketAsyncClient` **(preview)**: Open typed realtime WebSocket sessions with voice agents. +- `BetaAgentEndpointConversationsClient` / `BetaAgentEndpointConversationsAsyncClient` **(preview)**: Read persisted voice-agent conversations, transcripts, and audio metadata. Conversation operations are accessed through the [OpenAI Official Java SDK][openai_java_sdk]'s `ConversationService`. See the [OpenAI's Conversation API documentation][openai_conversations_api_docs] for more information. @@ -114,6 +116,56 @@ ResponseService responseService = responsesClient.getResponseService(); ConversationService conversationService = openAIClient.conversations(); ``` +Agent-scoped OpenAI clients automatically opt in to agent preview features, independently of `allowPreview`, +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 +`connect` method to set session IDs, agent version overrides, structured inputs, API versions, credential +scopes, preview features, and extra handshake headers or query parameters. + +```java +VoiceAgentWebSocketConnectionOptions options = new VoiceAgentWebSocketConnectionOptions() + .setAgentSessionId("session-id") + .setAgentVersionOverride("2") + .setStructuredInputs("{\"language\":\"en\"}") + .setExtraHeaders(Collections.singletonMap("User-Agent", "my-application/1.0")); +``` + +Extra query parameters and non-protected headers override defaults. Authentication and WebSocket protocol +headers remain transport-controlled. An explicitly empty `Foundry-Features` value is preserved. +`setConnectionUrl` accepts a full `wss://` URI on the project endpoint's host and port, with no user information +or fragment. Existing query parameters are preserved unless overridden. URL validation happens before token +acquisition; cross-host overrides are rejected to prevent credentials from being sent to another host. + ### Agent version drafts Draft agent versions are preview candidates that are not promoted to the agent's latest released version. Create one with @@ -188,9 +240,17 @@ Build clients whose names start with `Beta` from `AgentsClientBuilder.beta()`. T |---|---| | `BetaAgentsClient` | `WorkflowAgents=V1Preview,ExternalAgents=V1Preview,DraftAgents=V1Preview,AgentsOptimization=V2Preview` | | `BetaMemoryStoresClient` | `MemoryStores=V1Preview` | +| `BetaVoiceAgentWebSocketClient` | `VoiceAgents=V1Preview` | +| `BetaAgentEndpointConversationsClient` | `VoiceAgents=V1Preview` | The async `Beta*AsyncClient` counterparts follow the same behavior. +### Realtime voice-agent sessions + +Use `BetaVoiceAgentWebSocketClient` or `BetaVoiceAgentWebSocketAsyncClient` to open a typed, bidirectional session with an existing voice agent. The client acquires a token for `https://ai.azure.com/.default`, negotiates the `realtime` WebSocket subprotocol, and sends the required `VoiceAgents=V1Preview` feature header automatically. + +The session API supports text and PCM16 audio input, typed streaming server events, response cancellation, client-executed function tools, and persisted conversations. See [Realtime voice-agent WebSocket examples](#realtime-voice-agent-websocket-examples-preview) for a walkthrough and complete samples. + ### Agent optimization The preview `BetaAgentsClient` and `BetaAgentsAsyncClient` can create and monitor agent optimization jobs. These jobs @@ -202,9 +262,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 @@ -486,7 +546,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. --- @@ -928,6 +988,157 @@ See the full sample in [CreateResponseWithStructuredInput.java](https://github.c --- +### Voice agent samples (preview) + +The [voice-agent samples](https://github.com/Azure/azure-sdk-for-java/tree/main/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice) cover agent management and persisted conversations. + +| Scenario | Samples | +|---|---| +| Lifecycle | [VoiceAgentBasicSample.java](https://github.com/Azure/azure-sdk-for-java/tree/main/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentBasicSample.java) and [VoiceAgentBasicAsyncSample.java](https://github.com/Azure/azure-sdk-for-java/tree/main/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentBasicAsyncSample.java) create, retrieve, update, list, enable, disable, and delete voice agents. | +| Versions and drafts | [VoiceAgentVersionsSample.java](https://github.com/Azure/azure-sdk-for-java/tree/main/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentVersionsSample.java) creates and lists released and draft versions. | +| Guided generation | [VoiceAgentGenerateSample.java](https://github.com/Azure/azure-sdk-for-java/tree/main/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentGenerateSample.java) generates a voice-agent definition. | +| Audio and tools | [VoiceAgentWithToolsSample.java](https://github.com/Azure/azure-sdk-for-java/tree/main/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentWithToolsSample.java) configures PCM audio, transcription, voice activity detection, function tools, and system tools. | +| Persisted conversations | [VoiceAgentReadConversationSample.java](https://github.com/Azure/azure-sdk-for-java/tree/main/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentReadConversationSample.java) reads responses and transcripts, while [VoiceAgentReadConversationAudioSample.java](https://github.com/Azure/azure-sdk-for-java/tree/main/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentReadConversationAudioSample.java) downloads call and item audio. | + +Authenticate with `DefaultAzureCredential`. Every voice sample requires `FOUNDRY_PROJECT_ENDPOINT`. Samples that create explicit definitions optionally use `FOUNDRY_VOICE_MODEL`, `FOUNDRY_VOICE_MODEL_TYPE`, and `FOUNDRY_VOICE_AGENT_NAME`. The persisted-conversation samples require `FOUNDRY_VOICE_AGENT_NAME` and `FOUNDRY_VOICE_CONVERSATION_ID`. + +### Realtime voice-agent WebSocket examples (preview) + +Realtime WebSocket sessions provide bidirectional text and audio communication with a voice agent. Create the voice agent before opening a session; the lifecycle samples above demonstrate how to create one. + +#### Create a realtime WebSocket client + +Build a synchronous or asynchronous preview client from the same `AgentsClientBuilder`. Beta clients automatically send the required preview feature header. + +```java +AgentsClientBuilder builder = new AgentsClientBuilder() + .credential(new DefaultAzureCredentialBuilder().build()) + .endpoint(endpoint); + +BetaVoiceAgentWebSocketClient realtimeClient + = builder.beta().buildBetaVoiceAgentWebSocketClient(); +BetaVoiceAgentWebSocketAsyncClient realtimeAsyncClient + = builder.beta().buildBetaVoiceAgentWebSocketAsyncClient(); +``` + +#### Send a synchronous text turn + +Connections require an `https://` or `wss://` project endpoint. Insecure endpoints and untrusted connection URL overrides +are rejected before acquiring a token. This also applies to localhost; use certificate-verified TLS for local servers. + +Unknown server event types are returned as `RawRealtimeServerEvent`; `getRawEvent()` preserves the complete JSON object. +Use `sendEvent(BinaryData)` to send raw JSON objects, including event types or fields not modeled by this SDK. Both +clients accept UTF-8 JSON in text or binary WebSocket messages. + +Configure `VoiceAgentWebSocketConnectionOptions` before connecting and do not modify it while the session is active: + +- `setReceiveBufferCapacity` sets a bounded event queue (default 256, range 1-65536). +- `setOverflowStrategy` defaults to `ERROR`, which closes an overflowing connection. `DROP_OLDEST` and `DROP_LATEST` + explicitly permit data loss and should only be used when the application can tolerate missing events. +- `setMaxMessageSize` limits accepted message bytes (default 32 MiB). Oversized messages terminate the connection. + The sync transport checks size after receiving a complete message; this does not bound the transport's allocation. +- Malformed JSON or invalid UTF-8 terminates reception by default. Set `setMalformedEventHandler` to report and skip + malformed events while continuing reception. This callback must not block; throwing from it terminates the session. +- `setHttpClientConfiguration` customizes the sync OkHttp builder, including TLS trust and keepalive. Use + `setAsyncHttpClientConfiguration` for the async Reactor Netty transport. Authentication headers, subprotocol, redirects, + and handshake timeout remain SDK-controlled. Keep TLS certificate and hostname verification enabled. + +```java com.azure.ai.agents.realtime_forward_compatibility +VoiceAgentWebSocketConnectionOptions options + = new VoiceAgentWebSocketConnectionOptions() + .setReceiveBufferCapacity(512) + .setMaxMessageSize(8 * 1024 * 1024) + .setOverflowStrategy(VoiceAgentWebSocketOverflowStrategy.ERROR); +try (VoiceAgentWebSocketSessionClient session = realtimeClient.connect(agentName, options)) { + session.sendEvent(BinaryData.fromString( + "{\"type\":\"response.create\",\"event_id\":\"response-1\"}")); + for (RealtimeServerEvent event : session.receiveEvents()) { + if (event instanceof RawRealtimeServerEvent) { + BinaryData payload + = ((RawRealtimeServerEvent) event).getRawEvent(); + System.out.println("Received an unrecognized event with " + payload.getLength() + " bytes."); + } + } +} +``` + +Connect to the voice agent, add the user's text to the conversation, and request a response. Consume the typed server events until the response finishes. A session supports only one consumer of `receiveEvents()`. + +For bounded synchronous waits, use `receiveEvents(Duration)` with a positive per-event timeout. A timeout raises +`IllegalStateException` with a `TimeoutException` cause, leaves the session open, and allows the same iterator to retry. +Use `close(code, reason)` or asynchronous `closeAsync(code, reason)` to send a custom close frame. Close reasons must +fit in 123 UTF-8 bytes and close codes must be valid WebSocket codes. The first asynchronous close request wins. + +```java +try (VoiceAgentWebSocketSessionClient session = realtimeClient.connect(agentName)) { + session.sendText("Hello! Tell me about the services you provide."); + session.createResponse(); + + for (RealtimeServerEvent event : session.receiveEvents()) { + if (event instanceof RealtimeServerEventResponseTextDelta) { + System.out.print(((RealtimeServerEventResponseTextDelta) event).getDelta()); + } else if (event instanceof RealtimeServerEventRealtimeServerEventError) { + RealtimeServerEventRealtimeServerEventError error + = (RealtimeServerEventRealtimeServerEventError) event; + System.out.println("Session error: " + error.getError().getMessage()); + } else if (event instanceof RealtimeServerEventResponseDone) { + break; + } + } +} +``` + +Use `sendText` and `createResponse` again for subsequent turns while the session remains open. Call `cancelResponse` to interrupt an active response. + +#### Send an asynchronous text turn + +The asynchronous client returns a `Mono` when connecting and a `Flux` when receiving events. `Mono.usingWhen` closes the session on completion, error, or cancellation. + +```java +Mono.usingWhen( + realtimeAsyncClient.connect(agentName), + session -> session.sendText("Hello! Tell me about the services you provide.") + .then(session.createResponse()) + .thenMany(session.receiveEvents()) + .doOnNext(event -> { + if (event instanceof RealtimeServerEventResponseTextDelta) { + System.out.print(((RealtimeServerEventResponseTextDelta) event).getDelta()); + } + }) + .takeUntil(event -> event instanceof RealtimeServerEventResponseDone) + .then(), + VoiceAgentWebSocketSessionAsyncClient::closeAsync, + (session, error) -> session.closeAsync(), + VoiceAgentWebSocketSessionAsyncClient::closeAsync) + .block(); +``` + +#### Stream audio and handle function tools + +Use `appendInputAudio` to send PCM16 chunks, `commitInputAudio` to commit buffered audio when server-side voice activity detection is not configured, and `clearInputAudio` to discard pending input. Audio output arrives through `RealtimeServerEventResponseAudioDelta` events. When a `RealtimeServerEventResponseFunctionCallArgumentsDone` event requests a client-side tool, execute the function and call `sendFunctionCallOutput` with its call ID and serialized result. + +| Scenario | Complete sample | +|---|---| +| Synchronous live text | [VoiceAgentLiveTextConversationSample.java](https://github.com/Azure/azure-sdk-for-java/tree/main/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveTextConversationSample.java) | +| Asynchronous live text | [VoiceAgentLiveTextConversationAsyncSample.java](https://github.com/Azure/azure-sdk-for-java/tree/main/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveTextConversationAsyncSample.java) | +| Asynchronous live audio | [VoiceAgentLiveAudioConversationAsyncSample.java](https://github.com/Azure/azure-sdk-for-java/tree/main/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSample.java) | +| Live function tool | [VoiceAgentLiveFunctionToolSample.java](https://github.com/Azure/azure-sdk-for-java/tree/main/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveFunctionToolSample.java) | + +All realtime examples require `FOUNDRY_PROJECT_ENDPOINT` and optionally use `FOUNDRY_VOICE_AGENT_NAME`. The function-tool example also optionally uses `FOUNDRY_VOICE_MODEL` and `FOUNDRY_VOICE_MODEL_TYPE`. The asynchronous text and audio examples delete their generated agents by default; set `FOUNDRY_KEEP_VOICE_AGENT=true` to retain them. + +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 output:** [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 the `AgentStructuredOutput*` samples. +- **Workflow agents:** `WorkflowMultiAgentSample`, `WorkflowMultiAgentAsyncSample`, and `WorkflowMultiAgentMcpApprovalSample` demonstrate CSDL workflows and MCP approval handling. +- **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..768839393ff70 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 @@ -33,6 +33,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 +44,27 @@ public void customize(LibraryCustomization libraryCustomization, Logger logger) annotateBetaFields(libraryCustomization, loadBetaAnnotations(logger), logger); } + private static void customizeBuilder(ClassOrInterfaceDeclaration builder) { + builder.getMethodsByName("buildInnerClient").stream() + .filter(method -> method.getParameters().isEmpty()) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Generated buildInnerClient was not found.")) + .getBody().ifPresent(body -> { + if (!body.toString().contains("createPreviewErrorPolicy")) { + body.addStatement(2, StaticJavaParser.parseStatement( + "localPipeline = FoundryPolicyHelper.prependPolicy(localPipeline, " + + "FoundryPolicyHelper.createPreviewErrorPolicy(allowPreview));")); + } + }); + MethodDeclaration pipelineMethod = builder.getMethodsByName("createHttpPipeline").stream() + .filter(method -> method.getParameters().isEmpty()) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Generated createHttpPipeline was not found.")); + pipelineMethod.setBody(StaticJavaParser.parseBlock("{ return createHttpPipeline(true); }")); + builder.findCompilationUnit().ifPresent(unit -> unit.getImports().removeIf(declaration -> + "com.azure.core.http.policy.HttpLoggingPolicy".equals(declaration.getNameAsString()))); + } + private static final String MODELS_PACKAGE = "com.azure.ai.agents.models"; private static final String UNION_MARKER = "AI Tooling: union type"; @@ -657,6 +681,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/pom.xml b/sdk/ai/azure-ai-agents/pom.xml index 0e49aae3f313c..ba9357dcb2830 100644 --- a/sdk/ai/azure-ai-agents/pom.xml +++ b/sdk/ai/azure-ai-agents/pom.xml @@ -47,6 +47,8 @@ 0.0 0.0 + --add-modules java.desktop + --add-reads com.azure.ai.agents=java.desktop --add-exports com.azure.core/com.azure.core.implementation.util=ALL-UNNAMED --add-opens com.azure.core/com.azure.core.implementation.util=ALL-UNNAMED @@ -68,6 +70,52 @@ azure-core-http-netty 1.16.7 + + io.projectreactor.netty + reactor-netty-http + 1.2.18 + + + io.netty + netty-codec-http + 4.1.137.Final + + + io.netty + netty-transport + 4.1.137.Final + + + io.netty + netty-common + 4.1.137.Final + + + io.netty + netty-codec + 4.1.137.Final + + + io.netty + netty-buffer + 4.1.137.Final + + + com.squareup.okhttp3 + okhttp + 4.12.0 + + + com.squareup.okio + okio + + + + + com.squareup.okio + okio-jvm + 3.18.1 + @@ -102,6 +150,14 @@ com.openai:openai-java:[4.45.0] + io.projectreactor.netty:reactor-netty-http:[1.2.18] + io.netty:netty-codec-http:[4.1.137.Final] + io.netty:netty-transport:[4.1.137.Final] + io.netty:netty-common:[4.1.137.Final] + io.netty:netty-codec:[4.1.137.Final] + io.netty:netty-buffer:[4.1.137.Final] + com.squareup.okhttp3:okhttp:[4.12.0] + com.squareup.okio:okio-jvm:[3.18.1] 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 824df8d8f9be5..bba9a60864df1 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 @@ -9,6 +9,7 @@ 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.ai.agents.implementation.realtime.VoiceAgentWebSocketClientConfiguration; import com.azure.ai.agents.implementation.utils.Beta; import com.azure.core.annotation.Generated; import com.azure.core.annotation.ServiceClientBuilder; @@ -22,12 +23,13 @@ import com.azure.core.http.HttpPipeline; import com.azure.core.http.HttpPipelineBuilder; import com.azure.core.http.HttpPipelinePosition; +import com.azure.core.http.ProxyOptions; import com.azure.core.http.policy.AddDatePolicy; 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; @@ -37,21 +39,23 @@ 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.AzureOpenAIServiceVersion; import com.openai.azure.AzureUrlPathMode; import com.openai.client.OpenAIClient; import com.openai.client.OpenAIClientAsync; import com.openai.client.okhttp.OpenAIOkHttpClient; import com.openai.client.okhttp.OpenAIOkHttpClientAsync; import com.openai.credential.BearerTokenCredential; +import java.net.URI; import java.util.ArrayList; import java.util.Arrays; 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; @@ -88,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(); @@ -313,6 +319,8 @@ public AgentsClientBuilder retryPolicy(RetryPolicy retryPolicy) { private AgentsClientImpl buildInnerClient() { this.validateClient(); HttpPipeline localPipeline = (pipeline != null) ? pipeline : createHttpPipeline(); + localPipeline = FoundryPolicyHelper.prependPolicy(localPipeline, + FoundryPolicyHelper.createPreviewErrorPolicy(allowPreview)); AgentsServiceVersion localServiceVersion = (serviceVersion != null) ? serviceVersion : AgentsServiceVersion.getLatest(); AgentsClientImpl client = new AgentsClientImpl(localPipeline, JacksonAdapter.createDefaultSerializerAdapter(), @@ -342,9 +350,13 @@ private void validateClient() { @Generated private HttpPipeline createHttpPipeline() { + return createHttpPipeline(true); + } + + private HttpPipeline createHttpPipeline(boolean authenticate) { 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"); @@ -363,14 +375,14 @@ private HttpPipeline createHttpPipeline() { HttpPolicyProviders.addBeforeRetryPolicies(policies); policies.add(ClientBuilderUtil.validateAndGetRetryPolicy(retryPolicy, retryOptions, new RetryPolicy())); policies.add(new AddDatePolicy()); - if (tokenCredential != null) { + if (authenticate && tokenCredential != null) { policies.add(new BearerTokenAuthenticationPolicy(tokenCredential, DEFAULT_SCOPES)); } this.pipelinePolicies.stream() .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) @@ -385,7 +397,37 @@ private HttpPipeline resolvePipeline(String foundryFeatures) { } private com.openai.core.http.HttpClient createOpenAIHttpClient(String foundryFeatures) { - return HttpClientHelper.mapToOpenAIHttpClient(resolvePipeline(foundryFeatures)); + HttpPipeline localPipeline = pipeline != null ? pipeline : createHttpPipeline(false); + 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-agents"), + PROPERTIES.getOrDefault(SDK_VERSION, "unknown"), buildConfiguration); + options.replaceHeaders("User-Agent", openAIUserAgent.isEmpty() ? userAgent : userAgent + " " + openAIUserAgent); } /** @@ -404,8 +446,13 @@ public ResponsesClient buildResponsesClient() { * @return an instance of ResponsesAsyncClient */ public ResponsesAsyncClient buildResponsesAsyncClient() { - return new ResponsesAsyncClient(getOpenAIAsyncClientBuilder(null).build() - .withOptions(optionBuilder -> optionBuilder.httpClient(createOpenAIHttpClient(null)))); + TokenUtils.AsyncAuthentication authentication + = new TokenUtils.AsyncAuthentication(tokenCredential, DEFAULT_SCOPES); + return new ResponsesAsyncClient( + getOpenAIAsyncClientBuilder(null, authentication.getCredential()).build().withOptions(options -> { + options.httpClient(createOpenAIHttpClient(null)); + authentication.configure(options); + })); } /** @@ -416,7 +463,18 @@ public ResponsesAsyncClient buildResponsesAsyncClient() { */ 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.")); } /** @@ -432,8 +490,20 @@ public OpenAIClient buildAgentScopedOpenAIClient(String agentName) { throw LOGGER.logExceptionAsError(new IllegalArgumentException("'agentName' cannot be empty.")); } return getOpenAIClientBuilder(agentName).build() - .withOptions(optionBuilder -> optionBuilder - .httpClient(createOpenAIHttpClient(allowPreview ? AGENT_PREVIEW_FEATURES : 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.")); } /** @@ -443,8 +513,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.")); } /** @@ -459,9 +542,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(allowPreview ? AGENT_PREVIEW_FEATURES : 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() { @@ -483,27 +594,28 @@ 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())); + builder.putHeader("Foundry-Features", AGENT_PREVIEW_FEATURES); + 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))); + private OpenAIOkHttpClientAsync.Builder getOpenAIAsyncClientBuilder(String agentName, + com.openai.credential.Credential credential) { + 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())); + builder.putHeader("Foundry-Features", AGENT_PREVIEW_FEATURES); + 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); @@ -563,10 +675,12 @@ public BetaAgentsClientBuilder beta() { serviceClients = { BetaAgentsClient.class, BetaMemoryStoresClient.class, + BetaVoiceAgentWebSocketClient.class, BetaVoiceAgentsTelephonyClient.class, BetaVoiceAgentsConversationsClient.class, BetaAgentsAsyncClient.class, BetaMemoryStoresAsyncClient.class, + BetaVoiceAgentWebSocketAsyncClient.class, BetaVoiceAgentsTelephonyAsyncClient.class, BetaVoiceAgentsConversationsAsyncClient.class }) public final class BetaAgentsClientBuilder { @@ -670,6 +784,26 @@ public BetaMemoryStoresClient buildBetaMemoryStoresClient() { return new BetaMemoryStoresClient(buildInnerClient(MEMORY_STORES_PREVIEW_FEATURES).getBetaMemoryStores()); } + /** + * Builds an asynchronous client for realtime voice-agent WebSocket sessions. + * + * @return an asynchronous voice-agent WebSocket client. + */ + @Beta + public BetaVoiceAgentWebSocketAsyncClient buildBetaVoiceAgentWebSocketAsyncClient() { + return new BetaVoiceAgentWebSocketAsyncClient(createVoiceAgentWebSocketConfiguration()); + } + + /** + * Builds a synchronous client for realtime voice-agent WebSocket sessions. + * + * @return a synchronous voice-agent WebSocket client. + */ + @Beta + public BetaVoiceAgentWebSocketClient buildBetaVoiceAgentWebSocketClient() { + return new BetaVoiceAgentWebSocketClient(createVoiceAgentWebSocketConfiguration()); + } + /** * Builds a synchronous beta client for preview voice-agent telephony operations. *

@@ -794,4 +928,43 @@ private BetaVoiceAgentsConversationsClient buildBetaVoiceAgentsConversationsClie private BetaVoiceAgentsTelephonyClient buildBetaVoiceAgentsTelephonyClient() { return new BetaVoiceAgentsTelephonyClient(buildInnerClient().getBetaVoiceAgentsTelephonies()); } + + private VoiceAgentWebSocketClientConfiguration createVoiceAgentWebSocketConfiguration() { + validateClient(); + Objects.requireNonNull(tokenCredential, + "'credential' must be configured to build a voice-agent WebSocket client."); + Configuration buildConfiguration + = configuration == null ? Configuration.getGlobalConfiguration() : configuration; + ClientOptions localClientOptions = clientOptions == null ? new ClientOptions() : clientOptions; + HttpLogOptions localLogOptions = httpLogOptions == null ? new HttpLogOptions() : httpLogOptions; + String clientName = PROPERTIES.getOrDefault(SDK_NAME, "azure-ai-agents"); + String clientVersion = PROPERTIES.getOrDefault(SDK_VERSION, "unknown"); + String applicationId = CoreUtils.getApplicationId(localClientOptions, localLogOptions); + String userAgent + = UserAgentUtil.toUserAgentString(applicationId, clientName, clientVersion, buildConfiguration); + HttpHeaders headers = CoreUtils.createHttpHeadersFromClientOptions(localClientOptions); + ProxyOptions proxyOptions = ProxyOptions.fromConfiguration(buildConfiguration); + AgentsServiceVersion localServiceVersion + = serviceVersion == null ? AgentsServiceVersion.getLatest() : serviceVersion; + return new VoiceAgentWebSocketClientConfiguration(URI.create(endpoint), tokenCredential, + localServiceVersion.getVersion(), userAgent, headers, proxyOptions); + } + + /** + * Builds an asynchronous client for realtime voice-agent WebSocket sessions. + * + * @return an asynchronous voice-agent WebSocket client. + */ + private BetaVoiceAgentWebSocketAsyncClient buildBetaVoiceAgentWebSocketAsyncClient() { + return new BetaVoiceAgentWebSocketAsyncClient(createVoiceAgentWebSocketConfiguration()); + } + + /** + * Builds a synchronous client for realtime voice-agent WebSocket sessions. + * + * @return a synchronous voice-agent WebSocket client. + */ + private BetaVoiceAgentWebSocketClient buildBetaVoiceAgentWebSocketClient() { + return new BetaVoiceAgentWebSocketClient(createVoiceAgentWebSocketConfiguration()); + } } 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/BetaVoiceAgentWebSocketAsyncClient.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketAsyncClient.java new file mode 100644 index 0000000000000..54674c48b1fe6 --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketAsyncClient.java @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents; + +import com.azure.ai.agents.implementation.realtime.VoiceAgentWebSocketClientConfiguration; +import com.azure.ai.agents.implementation.utils.Beta; +import com.azure.ai.agents.models.VoiceAgentWebSocketConnectionOptions; +import com.azure.core.annotation.ServiceClient; +import reactor.core.publisher.Mono; + +import java.util.Objects; + +/** + * An asynchronous client for opening realtime WebSocket sessions with Foundry voice agents. + */ +@ServiceClient(builder = AgentsClientBuilder.class, isAsync = true) +@Beta(warningText = "This class is in preview and may change in future releases.") +public final class BetaVoiceAgentWebSocketAsyncClient { + private final VoiceAgentWebSocketClientConfiguration configuration; + + BetaVoiceAgentWebSocketAsyncClient(VoiceAgentWebSocketClientConfiguration configuration) { + this.configuration = Objects.requireNonNull(configuration, "'configuration' cannot be null."); + } + + /** + * Opens a realtime WebSocket session using the voice agent's persisted configuration. + * + * @param agentName the voice agent name. + * @return a connected session. + */ + public Mono connect(String agentName) { + return connect(agentName, new VoiceAgentWebSocketConnectionOptions()); + } + + /** + * Opens a realtime WebSocket session. + * + * @param agentName the voice agent name. + * @param options connection options. + * @return a connected session. + */ + public Mono connect(String agentName, + VoiceAgentWebSocketConnectionOptions options) { + Objects.requireNonNull(agentName, "'agentName' cannot be null."); + Objects.requireNonNull(options, "'options' cannot be null."); + return Mono.defer(() -> { + VoiceAgentWebSocketSessionAsyncClient session + = new VoiceAgentWebSocketSessionAsyncClient(configuration, agentName, options); + return session.connect().thenReturn(session); + }); + } +} diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketClient.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketClient.java new file mode 100644 index 0000000000000..7d402ac1a551c --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketClient.java @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents; + +import com.azure.ai.agents.implementation.realtime.VoiceAgentWebSocketClientConfiguration; +import com.azure.ai.agents.implementation.utils.Beta; +import com.azure.ai.agents.models.VoiceAgentWebSocketConnectionOptions; +import com.azure.core.annotation.ServiceClient; +import com.azure.core.util.logging.ClientLogger; + +import java.util.Objects; + +/** + * A synchronous client for opening realtime WebSocket sessions with Foundry voice agents. + */ +@ServiceClient(builder = AgentsClientBuilder.class) +@Beta(warningText = "This class is in preview and may change in future releases.") +public final class BetaVoiceAgentWebSocketClient { + private static final ClientLogger LOGGER = new ClientLogger(BetaVoiceAgentWebSocketClient.class); + private final VoiceAgentWebSocketClientConfiguration configuration; + + BetaVoiceAgentWebSocketClient(VoiceAgentWebSocketClientConfiguration configuration) { + this.configuration = Objects.requireNonNull(configuration, "'configuration' cannot be null."); + } + + /** + * Opens a realtime WebSocket session using the voice agent's persisted configuration. + * + * @param agentName the voice agent name. + * @return a connected session. + */ + public VoiceAgentWebSocketSessionClient connect(String agentName) { + return connect(agentName, new VoiceAgentWebSocketConnectionOptions()); + } + + /** + * Opens a realtime WebSocket session. + * + * @param agentName the voice agent name. + * @param options connection options. + * @throws IllegalArgumentException if {@code agentName} is empty. + * @return a connected session. + */ + public VoiceAgentWebSocketSessionClient connect(String agentName, VoiceAgentWebSocketConnectionOptions options) { + Objects.requireNonNull(agentName, "'agentName' cannot be null."); + if (agentName.isEmpty()) { + throw LOGGER.logExceptionAsError(new IllegalArgumentException("'agentName' cannot be empty.")); + } + Objects.requireNonNull(options, "'options' cannot be null."); + return VoiceAgentWebSocketSessionClient.connect(configuration, agentName, options); + } +} diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketSessionAsyncClient.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketSessionAsyncClient.java new file mode 100644 index 0000000000000..4ad20868c1ea7 --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketSessionAsyncClient.java @@ -0,0 +1,635 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents; + +import com.azure.ai.agents.implementation.realtime.VoiceAgentWebSocketClientConfiguration; +import com.azure.ai.agents.implementation.realtime.VoiceAgentWebSocketHandshakeHandler; +import com.azure.ai.agents.implementation.realtime.VoiceAgentWebSocketHttpResponse; +import com.azure.ai.agents.implementation.utils.Beta; +import com.azure.ai.agents.models.RealtimeClientEvent; +import com.azure.ai.agents.models.RealtimeClientEventConversationItemCreate; +import com.azure.ai.agents.models.RealtimeClientEventInputAudioBufferAppend; +import com.azure.ai.agents.models.RealtimeClientEventInputAudioBufferClear; +import com.azure.ai.agents.models.RealtimeClientEventInputAudioBufferCommit; +import com.azure.ai.agents.models.RealtimeClientEventResponseCancel; +import com.azure.ai.agents.models.RealtimeClientEventResponseCreate; +import com.azure.ai.agents.models.RealtimeConversationItem; +import com.azure.ai.agents.models.RealtimeConversationItemFunctionCallOutput; +import com.azure.ai.agents.models.RealtimeConversationItemMessageUser; +import com.azure.ai.agents.models.RealtimeConversationItemMessageUserContent; +import com.azure.ai.agents.models.RealtimeConversationItemMessageUserContentType; +import com.azure.ai.agents.models.RealtimeServerEvent; +import com.azure.ai.agents.models.VoiceAgentResponseCreateParams; +import com.azure.ai.agents.models.VoiceAgentWebSocketConnectionOptions; +import com.azure.core.credential.AccessToken; +import com.azure.core.credential.TokenRequestContext; +import com.azure.core.exception.ClientAuthenticationException; +import com.azure.core.exception.HttpResponseException; +import com.azure.core.exception.ResourceModifiedException; +import com.azure.core.exception.ResourceNotFoundException; +import com.azure.core.http.HttpHeader; +import com.azure.core.http.ProxyOptions; +import com.azure.core.util.AsyncCloseable; +import com.azure.core.util.BinaryData; +import com.azure.core.util.logging.ClientLogger; +import io.netty.channel.Channel; +import io.netty.channel.ChannelOption; +import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame; +import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; +import io.netty.handler.codec.http.websocketx.WebSocketClientHandshakeException; +import io.netty.handler.codec.http.websocketx.WebSocketFrame; +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.util.Base64; +import java.util.Collections; +import java.util.Objects; +import java.util.Queue; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.CancellationException; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import reactor.core.Disposable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.netty.Connection; +import reactor.netty.http.client.HttpClient; +import reactor.netty.http.client.WebsocketClientSpec; +import reactor.netty.http.websocket.WebsocketInbound; +import reactor.netty.http.websocket.WebsocketOutbound; +import reactor.netty.transport.ProxyProvider; + +/** + * An asynchronous bidirectional realtime session connected to a Foundry voice agent. + * + *

Instances are created by {@link BetaVoiceAgentWebSocketAsyncClient#connect(String)}. A session supports one + * subscriber to {@link #receiveEvents()}. Close the session when it is no longer needed.

+ */ +@Beta(warningText = "This class is in preview and may change in future releases.") +public final class VoiceAgentWebSocketSessionAsyncClient implements AsyncCloseable, AutoCloseable { + private static final ClientLogger LOGGER = new ClientLogger(VoiceAgentWebSocketSessionAsyncClient.class); + private static final int MAX_OUTSTANDING_SENDS = 256; + + private final VoiceAgentWebSocketClientConfiguration configuration; + private final VoiceAgentWebSocketConnectionOptions options; + private final HttpClient httpClient; + private final URI websocketUri; + private final AtomicReference state = new AtomicReference<>(State.NEW); + private final AtomicReference channel = new AtomicReference<>(); + private final AtomicReference outbound = new AtomicReference<>(); + private final AtomicReference connectionOperation = new AtomicReference<>(); + private final AtomicReference lifecycle = new AtomicReference<>(); + private final AtomicBoolean receiveClaimed = new AtomicBoolean(); + private final Semaphore sendPermits = new Semaphore(MAX_OUTSTANDING_SENDS); + private final Queue eventQueue; + private final Sinks.Many events; + private final Sinks.One ready = Sinks.one(); + private final Sinks.One closeSignal = Sinks.one(); + private final AtomicReference> closeOperation = new AtomicReference<>(); + + private volatile Integer closeCode; + private volatile String closeReason; + + VoiceAgentWebSocketSessionAsyncClient(VoiceAgentWebSocketClientConfiguration configuration, String agentName, + VoiceAgentWebSocketConnectionOptions options) { + this(configuration, agentName, options, HttpClient.create()); + } + + VoiceAgentWebSocketSessionAsyncClient(VoiceAgentWebSocketClientConfiguration configuration, String agentName, + VoiceAgentWebSocketConnectionOptions options, HttpClient httpClient) { + this.configuration = Objects.requireNonNull(configuration, "'configuration' cannot be null."); + Objects.requireNonNull(agentName, "'agentName' cannot be null."); + if (agentName.isEmpty()) { + throw new IllegalArgumentException("'agentName' cannot be empty."); + } + this.options = options == null ? new VoiceAgentWebSocketConnectionOptions() : options; + this.eventQueue = new ArrayBlockingQueue<>(this.options.getReceiveBufferCapacity()); + this.events = Sinks.many().unicast().onBackpressureBuffer(eventQueue); + this.httpClient = Objects.requireNonNull(httpClient, "'httpClient' cannot be null."); + this.websocketUri = VoiceAgentWebSocketUtils.buildWebSocketUri(configuration, agentName, this.options); + } + + Mono connect() { + return Mono.defer(() -> { + if (!state.compareAndSet(State.NEW, State.CONNECTING)) { + return Mono.error(new IllegalStateException("The voice-agent session has already been started.")); + } + + TokenRequestContext tokenContext = VoiceAgentWebSocketUtils.createTokenRequestContext(options); + return configuration.getCredential().getToken(tokenContext).map(AccessToken::getToken).flatMap(token -> { + Disposable connection = openWebSocket(token).subscribe(unused -> { + }, this::terminateWithError, this::terminateNormally); + connectionOperation.set(connection); + return ready.asMono().timeout(options.getHandshakeTimeout()); + }) + .doOnError(this::terminateWithError) + .doOnCancel(() -> terminateWithError(new CancellationException("WebSocket connection cancelled."))); + }); + } + + /** + * Gets the WebSocket endpoint used by this session. + * + * @return the WebSocket endpoint. + */ + public URI getEndpoint() { + return websocketUri; + } + + /** + * Determines whether the WebSocket session is open. + * + * @return {@code true} when the session is open. + */ + public boolean isOpen() { + Channel current = channel.get(); + return state.get() == State.OPEN && current != null && current.isActive(); + } + + /** + * Gets the peer close code after the session closes. + * + * @return the close code, or {@code null} when no close frame has been received. + */ + public Integer getCloseCode() { + return closeCode; + } + + /** + * Gets the peer close reason after the session closes. + * + * @return the close reason, or {@code null} when no close frame has been received. + */ + public String getCloseReason() { + return closeReason; + } + + /** + * Receives typed server events in wire order. Only one subscriber is supported per session. + * + * @return the server event stream. + */ + public Flux receiveEvents() { + return Flux.defer(() -> receiveClaimed.compareAndSet(false, true) + ? events.asFlux() + : Flux.error(new IllegalStateException("Only one receiveEvents subscriber is supported per session."))); + } + + /** + * Sends a typed realtime client event. + * + * @param event the event to send. + * @return a completion signal emitted after the frame is written. + */ + public Mono sendEvent(RealtimeClientEvent event) { + Objects.requireNonNull(event, "'event' cannot be null."); + return Mono.defer(() -> { + Channel current = requireOpenChannel(); + if (!sendPermits.tryAcquire()) { + return Mono.error(new IllegalStateException("Too many voice-agent WebSocket sends are outstanding.")); + } + + final String json; + try { + json = serialize(event); + } catch (IOException error) { + sendPermits.release(); + return Mono + .error(new IllegalArgumentException("Failed to serialize the realtime client event.", error)); + } + + return Mono.create(sink -> current.writeAndFlush(new TextWebSocketFrame(json)).addListener(result -> { + sendPermits.release(); + if (result.isSuccess()) { + sink.success(); + } else { + sink.error(result.cause()); + } + })); + }); + } + + /** + * Sends a JSON object, including event types and fields unknown to this SDK. + * @param event the complete JSON event. + * @return completion after the frame is written, or an error for invalid JSON or a closed session. + */ + public Mono sendEvent(BinaryData event) { + Objects.requireNonNull(event, "'event' cannot be null."); + return Mono.defer(() -> { + String json = VoiceAgentWebSocketUtils.validateEvent(event); + Channel current = requireOpenChannel(); + if (!sendPermits.tryAcquire()) { + return Mono.error(new IllegalStateException("Too many voice-agent WebSocket sends are outstanding.")); + } + return Mono.create(sink -> current.writeAndFlush(new TextWebSocketFrame(json)).addListener(result -> { + sendPermits.release(); + if (result.isSuccess()) { + sink.success(); + } else { + sink.error(result.cause()); + } + })); + }); + } + + /** + * Adds an item to the session conversation. + * + * @param item the item to add. + * @return a completion signal emitted after the event is written. + */ + public Mono createConversationItem(RealtimeConversationItem item) { + return createConversationItem(item, null); + } + + /** + * Adds an item after a specific conversation item. + * + * @param item the item to add. + * @param previousItemId the preceding item identifier, or {@code null} to append. + * @return a completion signal emitted after the event is written. + */ + public Mono createConversationItem(RealtimeConversationItem item, String previousItemId) { + Objects.requireNonNull(item, "'item' cannot be null."); + return sendEvent(new RealtimeClientEventConversationItemCreate(item).setPreviousItemId(previousItemId)); + } + + /** + * Adds a user text message to the session conversation. + * + * @param text the user message. + * @return a completion signal emitted after the event is written. + */ + public Mono sendText(String text) { + Objects.requireNonNull(text, "'text' cannot be null."); + RealtimeConversationItemMessageUserContent content = new RealtimeConversationItemMessageUserContent() + .setType(RealtimeConversationItemMessageUserContentType.INPUT_TEXT) + .setText(text); + return createConversationItem(new RealtimeConversationItemMessageUser(Collections.singletonList(content))); + } + + /** + * Appends PCM or encoded audio bytes to the input audio buffer. + * + * @param audio the bytes in the input format configured by the voice agent. + * @return a completion signal emitted after the event is written. + */ + public Mono appendInputAudio(BinaryData audio) { + Objects.requireNonNull(audio, "'audio' cannot be null."); + String encoded = Base64.getEncoder().encodeToString(audio.toBytes()); + return sendEvent(new RealtimeClientEventInputAudioBufferAppend(encoded)); + } + + /** + * Clears the input audio buffer. + * + * @return a completion signal emitted after the event is written. + */ + public Mono clearInputAudio() { + return sendEvent(new RealtimeClientEventInputAudioBufferClear()); + } + + /** + * Commits the input audio buffer. + * + * @return a completion signal emitted after the event is written. + */ + public Mono commitInputAudio() { + return sendEvent(new RealtimeClientEventInputAudioBufferCommit()); + } + + /** + * Requests a response using the persisted voice-agent configuration. + * + * @return a completion signal emitted after the event is written. + */ + public Mono createResponse() { + return sendEvent(new RealtimeClientEventResponseCreate()); + } + + /** + * Requests a response with per-response options. + * + * @param responseOptions the response options. + * @return a completion signal emitted after the event is written. + */ + public Mono createResponse(VoiceAgentResponseCreateParams responseOptions) { + Objects.requireNonNull(responseOptions, "'responseOptions' cannot be null."); + return sendEvent(new RealtimeClientEventResponseCreate().setResponse(responseOptions)); + } + + /** + * Cancels the response currently writing to the default conversation. + * + * @return a completion signal emitted after the event is written. + */ + public Mono cancelResponse() { + return sendEvent(new RealtimeClientEventResponseCancel()); + } + + /** + * Cancels a specific response. + * + * @param responseId the response identifier. + * @return a completion signal emitted after the event is written. + */ + public Mono cancelResponse(String responseId) { + Objects.requireNonNull(responseId, "'responseId' cannot be null."); + return sendEvent(new RealtimeClientEventResponseCancel().setResponseId(responseId)); + } + + /** + * Sends a function-call result and requests the next response. + * + * @param callId the function call identifier. + * @param output the serialized function result. + * @return a completion signal emitted after both events are written. + */ + public Mono sendFunctionCallOutput(String callId, String output) { + RealtimeConversationItemFunctionCallOutput item + = new RealtimeConversationItemFunctionCallOutput(callId, output); + return createConversationItem(item).then(createResponse()); + } + + /** + * Closes the WebSocket session. + * + * @return a completion signal for closing the session. + */ + @Override + public Mono closeAsync() { + return closeAsync(1000, ""); + } + + /** + * Closes the session with an application-selected WebSocket close frame. + * The first requested close frame wins when close is called more than once. + * @param code a valid WebSocket close code. + * @param reason non-null reason of at most 123 UTF-8 bytes. + * @return a completion signal, or an error if the code or reason is invalid. + */ + public Mono closeAsync(int code, String reason) { + try { + VoiceAgentWebSocketUtils.validateClose(code, reason); + } catch (IllegalArgumentException exception) { + return Mono.error(exception); + } + Mono existing = closeOperation.get(); + if (existing != null) { + return existing; + } + + Mono created = Mono.defer(() -> { + State current = state.get(); + if (current == State.CLOSED || current == State.NEW) { + state.set(State.CLOSED); + events.tryEmitComplete(); + return Mono.empty(); + } + state.set(State.CLOSING); + WebsocketOutbound currentOutbound = outbound.get(); + Channel currentChannel = channel.get(); + Mono graceful = currentOutbound == null ? Mono.empty() : currentOutbound.sendClose(code, reason); + Mono disposed = currentChannel == null ? Mono.empty() : Connection.from(currentChannel).onDispose(); + return graceful.then(disposed).timeout(options.getCloseTimeout()).onErrorResume(error -> { + if (currentChannel != null) { + currentChannel.close(); + } + return Mono.empty(); + }).doFinally(signal -> terminateNormally()); + }).cache(); + + if (closeOperation.compareAndSet(null, created)) { + return created; + } + return closeOperation.get(); + } + + /** + * Closes the WebSocket session synchronously. + */ + @Override + public void close() { + closeAsync().block(options.getCloseTimeout().plusSeconds(1)); + } + + private Mono openWebSocket(String token) { + HttpClient configured = options.getAsyncHttpClientConfiguration() == null + ? httpClient + : Objects.requireNonNull(options.getAsyncHttpClientConfiguration().apply(httpClient), + "Configured transport cannot be null."); + HttpClient client = configureProxy(configured).followRedirect(false) + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, toConnectTimeoutMillis(options.getHandshakeTimeout())) + .doOnConnected(connection -> connection.addHandlerLast("voiceAgentHandshakeResponseObserver", + new VoiceAgentWebSocketHandshakeHandler(this::terminateWithError))) + .headers(headers -> { + for (HttpHeader header : VoiceAgentWebSocketUtils.buildHeaders(configuration, options, token)) { + headers.set(header.getName(), header.getValue()); + } + }); + WebsocketClientSpec spec = WebsocketClientSpec.builder() + .protocols(VoiceAgentWebSocketUtils.SUBPROTOCOL) + .maxFramePayloadLength(options.getMaxMessageSize()) + .handlePing(false) + .build(); + + return client.websocket(spec).uri(websocketUri.toString()).connect().flatMap(connection -> { + if (!(connection instanceof WebsocketInbound) || !(connection instanceof WebsocketOutbound)) { + return Mono.error(new IllegalStateException("The WebSocket transport returned an invalid connection.")); + } + return handleConnection((WebsocketInbound) connection, (WebsocketOutbound) connection); + }); + } + + private static int toConnectTimeoutMillis(Duration timeout) { + if (timeout.compareTo(Duration.ofMillis(Integer.MAX_VALUE)) >= 0) { + return Integer.MAX_VALUE; + } + return Math.toIntExact(Math.max(1L, timeout.toMillis())); + } + + private Mono handleConnection(WebsocketInbound inbound, WebsocketOutbound outbound) { + this.outbound.set(outbound); + inbound.withConnection(connection -> channel.set(connection.channel())); + state.set(State.OPEN); + ready.tryEmitEmpty(); + + inbound.receiveCloseStatus().subscribe(status -> { + closeCode = status.code(); + closeReason = status.reasonText(); + }, error -> LOGGER.atVerbose().addKeyValue("error", error.getMessage()).log("Close status unavailable.")); + + Disposable receive = inbound.aggregateFrames(options.getMaxMessageSize()) + .receiveFrames() + .subscribe(this::handleFrame, this::terminateWithError, this::terminateNormally); + lifecycle.set(receive); + return closeSignal.asMono(); + } + + private void handleFrame(WebSocketFrame frame) { + if (frame instanceof TextWebSocketFrame || frame instanceof BinaryWebSocketFrame) { + try { + byte[] bytes = new byte[frame.content().readableBytes()]; + frame.content().getBytes(frame.content().readerIndex(), bytes); + RealtimeServerEvent event + = VoiceAgentWebSocketUtils.deserializeEvent(VoiceAgentWebSocketUtils.decodeEvent(bytes)); + Sinks.EmitResult result = events.tryEmitNext(event); + if (result == Sinks.EmitResult.FAIL_OVERFLOW || result == Sinks.EmitResult.FAIL_ZERO_SUBSCRIBER) { + switch (options.getOverflowStrategy()) { + case DROP_LATEST: + return; + + case DROP_OLDEST: + eventQueue.poll(); + result = events.tryEmitNext(event); + break; + + default: + break; + } + } + if (result.isFailure()) { + terminateWithError(new IllegalStateException("Voice-agent event emission failed: " + result)); + } + } catch (IOException | RuntimeException error) { + Throwable failure = error; + if (options.getMalformedEventHandler() != null) { + try { + options.getMalformedEventHandler().accept(error); + return; + } catch (RuntimeException callbackError) { + failure = callbackError; + } + } + closeWithProtocolError(1007, "Invalid JSON event", failure); + } + } + } + + private void closeWithProtocolError(int code, String reason, Throwable error) { + WebsocketOutbound current = outbound.get(); + terminateWithError(error); + if (current != null) { + current.sendClose(code, reason).subscribe(unused -> { + }, ignored -> { + }); + } + } + + private void terminateWithError(Throwable error) { + Throwable mappedError = mapHandshakeError(error); + State previous = state.getAndSet(State.CLOSED); + if (previous == State.CLOSED) { + return; + } + ready.tryEmitError(mappedError); + events.tryEmitError(mappedError); + closeSignal.tryEmitError(mappedError); + disposeReceive(); + } + + private Throwable mapHandshakeError(Throwable error) { + Throwable current = error; + while (current != null && !(current instanceof WebSocketClientHandshakeException)) { + current = current.getCause(); + } + if (current == null) { + return error; + } + WebSocketClientHandshakeException handshakeError = (WebSocketClientHandshakeException) current; + if (handshakeError.response() == null) { + return error; + } + VoiceAgentWebSocketHttpResponse response + = new VoiceAgentWebSocketHttpResponse(websocketUri, handshakeError.response()); + String message = "Voice-agent WebSocket handshake failed with status " + response.getStatusCode() + "."; + switch (response.getStatusCode()) { + case 401: + return new ClientAuthenticationException(message, response, error); + + case 404: + return new ResourceNotFoundException(message, response, error); + + case 409: + return new ResourceModifiedException(message, response, error); + + default: + return new HttpResponseException(message, response, error); + } + } + + private void terminateNormally() { + State previous = state.getAndSet(State.CLOSED); + if (previous == State.CLOSED) { + return; + } + if (previous == State.CONNECTING) { + ready.tryEmitError(new IllegalStateException("The WebSocket closed before the handshake completed.")); + } + events.tryEmitComplete(); + closeSignal.tryEmitEmpty(); + disposeReceive(); + } + + private void disposeReceive() { + Disposable connection = connectionOperation.getAndSet(null); + if (connection != null && !connection.isDisposed()) { + connection.dispose(); + } + Disposable receive = lifecycle.getAndSet(null); + if (receive != null && !receive.isDisposed()) { + receive.dispose(); + } + } + + private Channel requireOpenChannel() { + Channel current = channel.get(); + if (state.get() != State.OPEN || current == null || !current.isActive()) { + throw LOGGER + .logExceptionAsError(new IllegalStateException("The voice-agent WebSocket session is not open.")); + } + return current; + } + + private HttpClient configureProxy(HttpClient client) { + ProxyOptions proxy = configuration.getProxyOptions(); + if (proxy == null) { + return client; + } + return client.proxy(typeSpec -> { + ProxyProvider.Proxy proxyType; + switch (proxy.getType()) { + case SOCKS4: + proxyType = ProxyProvider.Proxy.SOCKS4; + break; + + case SOCKS5: + proxyType = ProxyProvider.Proxy.SOCKS5; + break; + + default: + proxyType = ProxyProvider.Proxy.HTTP; + break; + } + ProxyProvider.Builder builder = typeSpec.type(proxyType).socketAddress(proxy.getAddress()); + if (proxy.getUsername() != null) { + builder.username(proxy.getUsername()).password(ignored -> proxy.getPassword()); + } + if (proxy.getNonProxyHosts() != null) { + builder.nonProxyHosts(proxy.getNonProxyHosts()); + } + }); + } + + private static String serialize(RealtimeClientEvent event) throws IOException { + return event.toJsonString(); + } + + private enum State { + NEW, CONNECTING, OPEN, CLOSING, CLOSED + } +} diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketSessionClient.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketSessionClient.java new file mode 100644 index 0000000000000..fc29802691849 --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketSessionClient.java @@ -0,0 +1,613 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents; + +import com.azure.ai.agents.implementation.realtime.VoiceAgentWebSocketClientConfiguration; +import com.azure.ai.agents.implementation.realtime.VoiceAgentWebSocketHttpResponse; +import com.azure.ai.agents.implementation.utils.Beta; +import com.azure.ai.agents.models.RealtimeClientEvent; +import com.azure.ai.agents.models.RealtimeClientEventConversationItemCreate; +import com.azure.ai.agents.models.RealtimeClientEventInputAudioBufferAppend; +import com.azure.ai.agents.models.RealtimeClientEventInputAudioBufferClear; +import com.azure.ai.agents.models.RealtimeClientEventInputAudioBufferCommit; +import com.azure.ai.agents.models.RealtimeClientEventResponseCancel; +import com.azure.ai.agents.models.RealtimeClientEventResponseCreate; +import com.azure.ai.agents.models.RealtimeConversationItem; +import com.azure.ai.agents.models.RealtimeConversationItemFunctionCallOutput; +import com.azure.ai.agents.models.RealtimeConversationItemMessageUser; +import com.azure.ai.agents.models.RealtimeConversationItemMessageUserContent; +import com.azure.ai.agents.models.RealtimeConversationItemMessageUserContentType; +import com.azure.ai.agents.models.RealtimeServerEvent; +import com.azure.ai.agents.models.VoiceAgentResponseCreateParams; +import com.azure.ai.agents.models.VoiceAgentWebSocketConnectionOptions; +import com.azure.core.exception.ClientAuthenticationException; +import com.azure.core.exception.HttpResponseException; +import com.azure.core.exception.ResourceModifiedException; +import com.azure.core.exception.ResourceNotFoundException; +import com.azure.core.http.HttpHeader; +import com.azure.core.http.ProxyOptions; +import com.azure.core.util.BinaryData; +import com.azure.core.util.IterableStream; +import com.azure.core.util.logging.ClientLogger; +import java.io.IOException; +import java.net.Proxy; +import java.net.URI; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import java.util.Collections; +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import okhttp3.Credentials; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; +import okio.ByteString; + +/** + * A synchronous bidirectional realtime session connected to a Foundry voice agent. + */ +@Beta(warningText = "This class is in preview and may change in future releases.") +public final class VoiceAgentWebSocketSessionClient implements AutoCloseable { + private static final ClientLogger LOGGER = new ClientLogger(VoiceAgentWebSocketSessionClient.class); + private final URI websocketUri; + private final VoiceAgentWebSocketConnectionOptions options; + private final OkHttpClient httpClient; + private final BlockingQueue events; + private final int receiveBufferCapacity; + private final CountDownLatch handshakeCompleted = new CountDownLatch(1); + private final CountDownLatch closeCompleted = new CountDownLatch(1); + private final AtomicReference connectionError = new AtomicReference<>(); + private final AtomicBoolean receiveClaimed = new AtomicBoolean(); + private final AtomicBoolean open = new AtomicBoolean(); + private final AtomicBoolean closed = new AtomicBoolean(); + private final AtomicBoolean clientShutdown = new AtomicBoolean(); + + private volatile WebSocket webSocket; + private volatile Integer closeCode; + private volatile String closeReason; + + private VoiceAgentWebSocketSessionClient(VoiceAgentWebSocketClientConfiguration configuration, String agentName, + VoiceAgentWebSocketConnectionOptions options) { + this.options = options; + this.receiveBufferCapacity = options.getReceiveBufferCapacity(); + this.events = new ArrayBlockingQueue<>(receiveBufferCapacity + 1); + this.websocketUri = VoiceAgentWebSocketUtils.buildWebSocketUri(configuration, agentName, options); + String token = configuration.getCredential() + .getTokenSync(VoiceAgentWebSocketUtils.createTokenRequestContext(options)) + .getToken(); + this.httpClient = createHttpClient(configuration, options); + Request.Builder request = new Request.Builder().url(websocketUri.toString()) + .header("Sec-WebSocket-Protocol", VoiceAgentWebSocketUtils.SUBPROTOCOL); + for (HttpHeader header : VoiceAgentWebSocketUtils.buildHeaders(configuration, options, token)) { + request.header(header.getName(), header.getValue()); + } + this.webSocket = httpClient.newWebSocket(request.build(), new Listener()); + } + + static VoiceAgentWebSocketSessionClient connect(VoiceAgentWebSocketClientConfiguration configuration, + String agentName, VoiceAgentWebSocketConnectionOptions options) { + VoiceAgentWebSocketSessionClient session + = new VoiceAgentWebSocketSessionClient(configuration, agentName, options); + try { + session.awaitHandshake(); + return session; + } catch (RuntimeException error) { + session.webSocket.cancel(); + session.shutdownHttpClient(); + throw error; + } + } + + /** + * Gets the WebSocket endpoint used by this session. + * + * @return the WebSocket endpoint. + */ + public URI getEndpoint() { + return websocketUri; + } + + /** + * Determines whether the session is open. + * + * @return {@code true} when the session is open. + */ + public boolean isOpen() { + return open.get() && !closed.get(); + } + + /** + * Gets the peer close code. + * + * @return the close code, or {@code null}. + */ + public Integer getCloseCode() { + return closeCode; + } + + /** + * Gets the peer close reason. + * + * @return the close reason, or {@code null}. + */ + public String getCloseReason() { + return closeReason; + } + + /** + * Receives typed server events in wire order. The returned stream may be iterated once. + * + * @throws IllegalStateException if the event stream has already been claimed. + * @return the server event stream. + */ + public IterableStream receiveEvents() { + return receiveEvents(null); + } + + /** + * Receives events with a timeout for each wait. A timeout leaves the session open and the iterator can be retried. + * @param timeout positive per-event timeout, or null to wait indefinitely. + * @return the single-consumer event stream. + * @throws IllegalArgumentException if timeout is zero or negative. + * @throws IllegalStateException if another receiver exists or a wait times out (with a TimeoutException cause). + */ + public IterableStream receiveEvents(Duration timeout) { + if (timeout != null && (timeout.isZero() || timeout.isNegative())) { + throw LOGGER.logExceptionAsError(new IllegalArgumentException("Receive timeout must be positive.")); + } + if (!receiveClaimed.compareAndSet(false, true)) { + throw LOGGER.logExceptionAsError( + new IllegalStateException("Only one receiveEvents iterator is supported per session.")); + } + return IterableStream.of(() -> new EventIterator(events, timeout)); + } + + /** + * Sends a typed realtime client event. + * + * @param event the event to send. + * @throws IllegalArgumentException if the event cannot be serialized. + * @throws IllegalStateException if the session is closed or cannot accept another event. + */ + public void sendEvent(RealtimeClientEvent event) { + Objects.requireNonNull(event, "'event' cannot be null."); + ensureOpen(); + final String json; + try { + json = event.toJsonString(); + } catch (IOException error) { + throw LOGGER.logExceptionAsError( + new IllegalArgumentException("Failed to serialize the realtime client event.", error)); + } + if (!webSocket.send(json)) { + throw LOGGER.logExceptionAsError( + new IllegalStateException("The voice-agent WebSocket send queue is full or closed.")); + } + } + + /** + * Sends a JSON object, including event types and fields unknown to this SDK. + * @param event the complete JSON event. + * @throws IllegalArgumentException if the event is not a JSON object. + * @throws IllegalStateException if the session cannot accept the event. + */ + public void sendEvent(BinaryData event) { + String json = VoiceAgentWebSocketUtils.validateEvent(event); + ensureOpen(); + if (!webSocket.send(json)) { + throw LOGGER.logExceptionAsError( + new IllegalStateException("The voice-agent WebSocket send queue is full or closed.")); + } + } + + /** + * Adds a conversation item. + * + * @param item the item to add. + */ + public void createConversationItem(RealtimeConversationItem item) { + createConversationItem(item, null); + } + + /** + * Adds a conversation item after another item. + * + * @param item the item to add. + * @param previousItemId the preceding item identifier. + */ + public void createConversationItem(RealtimeConversationItem item, String previousItemId) { + Objects.requireNonNull(item, "'item' cannot be null."); + sendEvent(new RealtimeClientEventConversationItemCreate(item).setPreviousItemId(previousItemId)); + } + + /** + * Adds a user text message. + * + * @param text the user message. + */ + public void sendText(String text) { + Objects.requireNonNull(text, "'text' cannot be null."); + RealtimeConversationItemMessageUserContent content = new RealtimeConversationItemMessageUserContent() + .setType(RealtimeConversationItemMessageUserContentType.INPUT_TEXT) + .setText(text); + createConversationItem(new RealtimeConversationItemMessageUser(Collections.singletonList(content))); + } + + /** + * Appends audio to the input buffer. + * + * @param audio the audio bytes. + */ + public void appendInputAudio(BinaryData audio) { + Objects.requireNonNull(audio, "'audio' cannot be null."); + sendEvent(new RealtimeClientEventInputAudioBufferAppend(Base64.getEncoder().encodeToString(audio.toBytes()))); + } + + /** Clears the input audio buffer. */ + public void clearInputAudio() { + sendEvent(new RealtimeClientEventInputAudioBufferClear()); + } + + /** Commits the input audio buffer. */ + public void commitInputAudio() { + sendEvent(new RealtimeClientEventInputAudioBufferCommit()); + } + + /** Requests a response using the voice agent's configuration. */ + public void createResponse() { + sendEvent(new RealtimeClientEventResponseCreate()); + } + + /** + * Requests a response with per-response options. + * + * @param responseOptions response options. + */ + public void createResponse(VoiceAgentResponseCreateParams responseOptions) { + Objects.requireNonNull(responseOptions, "'responseOptions' cannot be null."); + sendEvent(new RealtimeClientEventResponseCreate().setResponse(responseOptions)); + } + + /** Cancels the active response. */ + public void cancelResponse() { + sendEvent(new RealtimeClientEventResponseCancel()); + } + + /** + * Cancels a specific response. + * + * @param responseId the response identifier. + */ + public void cancelResponse(String responseId) { + Objects.requireNonNull(responseId, "'responseId' cannot be null."); + sendEvent(new RealtimeClientEventResponseCancel().setResponseId(responseId)); + } + + /** + * Sends a function-call result and requests the next response. + * + * @param callId the function call identifier. + * @param output the serialized function output. + */ + public void sendFunctionCallOutput(String callId, String output) { + createConversationItem(new RealtimeConversationItemFunctionCallOutput(callId, output)); + createResponse(); + } + + /** Closes the session. */ + @Override + public void close() { + close(1000, ""); + } + + /** + * Closes the session with an application-selected WebSocket close frame. + * @param code a valid WebSocket close code. + * @param reason non-null reason of at most 123 UTF-8 bytes. + * @throws IllegalArgumentException if the code or reason is invalid. + */ + public void close(int code, String reason) { + VoiceAgentWebSocketUtils.validateClose(code, reason); + if (!closed.compareAndSet(false, true)) { + shutdownHttpClient(); + return; + } + open.set(false); + if (!webSocket.close(code, reason)) { + webSocket.cancel(); + closeCompleted.countDown(); + } + try { + if (!closeCompleted.await(options.getCloseTimeout().toMillis(), TimeUnit.MILLISECONDS)) { + webSocket.cancel(); + } + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + webSocket.cancel(); + } finally { + signal(EventSignal.complete()); + shutdownHttpClient(); + } + } + + private void awaitHandshake() { + try { + if (!handshakeCompleted.await(options.getHandshakeTimeout().toMillis(), TimeUnit.MILLISECONDS)) { + webSocket.cancel(); + throw LOGGER + .logExceptionAsError(new IllegalStateException("Voice-agent WebSocket handshake timed out.")); + } + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + webSocket.cancel(); + throw LOGGER.logExceptionAsError( + new IllegalStateException("Interrupted while opening the voice-agent WebSocket session.", error)); + } + Throwable error = connectionError.get(); + if (error instanceof RuntimeException) { + throw LOGGER.logExceptionAsError((RuntimeException) error); + } + if (error != null) { + throw LOGGER.logExceptionAsError( + new IllegalStateException("Failed to open the voice-agent WebSocket session.", error)); + } + } + + private void ensureOpen() { + if (!isOpen()) { + throw LOGGER + .logExceptionAsError(new IllegalStateException("The voice-agent WebSocket session is not open.")); + } + } + + private void fail(Throwable error, Response response) { + Throwable mapped; + try { + mapped = response == null ? error : mapHandshakeError(error, response); + } finally { + if (response != null) { + response.close(); + } + } + connectionError.compareAndSet(null, mapped); + open.set(false); + closed.set(true); + handshakeCompleted.countDown(); + signal(EventSignal.error(mapped)); + closeCompleted.countDown(); + shutdownHttpClient(); + } + + private Throwable mapHandshakeError(Throwable error, Response response) { + VoiceAgentWebSocketHttpResponse azureResponse = new VoiceAgentWebSocketHttpResponse(websocketUri, response); + String message = "Voice-agent WebSocket handshake failed with status " + response.code() + "."; + switch (response.code()) { + case 401: + return new ClientAuthenticationException(message, azureResponse, error); + + case 404: + return new ResourceNotFoundException(message, azureResponse, error); + + case 409: + return new ResourceModifiedException(message, azureResponse, error); + + default: + return new HttpResponseException(message, azureResponse, error); + } + } + + private synchronized void signal(EventSignal signal) { + if (signal.event != null && events.size() >= receiveBufferCapacity) { + switch (options.getOverflowStrategy()) { + case DROP_LATEST: + return; + + case DROP_OLDEST: + events.poll(); + break; + + default: + break; + } + } + if ((signal.event != null && events.size() >= receiveBufferCapacity) || !events.offer(signal)) { + events.clear(); + events.add(EventSignal.error(new IllegalStateException("Voice-agent receive buffer overflow."))); + WebSocket current = webSocket; + if (current != null) { + current.cancel(); + } + open.set(false); + closed.set(true); + shutdownHttpClient(); + } + } + + private void shutdownHttpClient() { + if (clientShutdown.compareAndSet(false, true)) { + httpClient.dispatcher().executorService().shutdown(); + httpClient.connectionPool().evictAll(); + } + } + + private static OkHttpClient createHttpClient(VoiceAgentWebSocketClientConfiguration configuration, + VoiceAgentWebSocketConnectionOptions options) { + OkHttpClient.Builder builder = new OkHttpClient.Builder(); + if (options.getHttpClientConfiguration() != null) { + options.getHttpClientConfiguration().accept(builder); + } + builder.connectTimeout(options.getHandshakeTimeout().toMillis(), TimeUnit.MILLISECONDS) + .readTimeout(0, TimeUnit.MILLISECONDS) + .followRedirects(false); + ProxyOptions proxyOptions = configuration.getProxyOptions(); + if (proxyOptions != null) { + Proxy.Type proxyType = proxyOptions.getType() == ProxyOptions.Type.SOCKS4 + || proxyOptions.getType() == ProxyOptions.Type.SOCKS5 ? Proxy.Type.SOCKS : Proxy.Type.HTTP; + builder.proxy(new Proxy(proxyType, proxyOptions.getAddress())); + if (proxyOptions.getUsername() != null) { + builder.proxyAuthenticator((route, response) -> response.request() + .newBuilder() + .header("Proxy-Authorization", + Credentials.basic(proxyOptions.getUsername(), proxyOptions.getPassword())) + .build()); + } + } + return builder.build(); + } + + private final class Listener extends WebSocketListener { + @Override + public void onOpen(WebSocket webSocket, Response response) { + open.set(true); + handshakeCompleted.countDown(); + } + + @Override + public void onMessage(WebSocket webSocket, String text) { + if (text.getBytes(StandardCharsets.UTF_8).length > options.getMaxMessageSize()) { + fail(new IllegalArgumentException("Voice-agent message exceeds the configured size limit."), null); + webSocket.close(1009, "Message too large"); + return; + } + try { + signal(EventSignal.event(VoiceAgentWebSocketUtils.deserializeEvent(text))); + } catch (IOException | RuntimeException error) { + malformedEvent(webSocket, error); + } + } + + @Override + public void onMessage(WebSocket webSocket, ByteString bytes) { + if (bytes.size() > options.getMaxMessageSize()) { + fail(new IllegalArgumentException("Voice-agent message exceeds the configured size limit."), null); + webSocket.close(1009, "Message too large"); + return; + } + try { + onMessage(webSocket, VoiceAgentWebSocketUtils.decodeEvent(bytes.toByteArray())); + } catch (CharacterCodingException error) { + malformedEvent(webSocket, error); + } + } + + private void malformedEvent(WebSocket webSocket, Throwable error) { + if (options.getMalformedEventHandler() != null) { + try { + options.getMalformedEventHandler().accept(error); + return; + } catch (RuntimeException callbackError) { + error = callbackError; + } + } + fail(new IllegalArgumentException("Invalid JSON event.", error), null); + webSocket.close(1007, "Invalid JSON event"); + } + + @Override + public void onClosing(WebSocket webSocket, int code, String reason) { + closeCode = code; + closeReason = reason; + webSocket.close(code == 1005 ? 1000 : code, reason); + } + + @Override + public void onClosed(WebSocket webSocket, int code, String reason) { + closeCode = code; + closeReason = reason; + open.set(false); + closed.set(true); + signal(EventSignal.complete()); + handshakeCompleted.countDown(); + closeCompleted.countDown(); + shutdownHttpClient(); + } + + @Override + public void onFailure(WebSocket webSocket, Throwable error, Response response) { + fail(error, response); + } + } + + private static final class EventSignal { + private final RealtimeServerEvent event; + private final Throwable error; + private final boolean complete; + + private EventSignal(RealtimeServerEvent event, Throwable error, boolean complete) { + this.event = event; + this.error = error; + this.complete = complete; + } + + private static EventSignal event(RealtimeServerEvent event) { + return new EventSignal(event, null, false); + } + + private static EventSignal error(Throwable error) { + return new EventSignal(null, error, false); + } + + private static EventSignal complete() { + return new EventSignal(null, null, true); + } + } + + private static final class EventIterator implements Iterator { + private final BlockingQueue events; + private final Duration timeout; + private EventSignal next; + + private EventIterator(BlockingQueue events, Duration timeout) { + this.events = events; + this.timeout = timeout; + } + + @Override + public boolean hasNext() { + if (next == null) { + try { + next = timeout == null ? events.take() : events.poll(timeout.toNanos(), TimeUnit.NANOSECONDS); + if (next == null) { + throw new IllegalStateException("Timed out waiting for a voice-agent event.", + new TimeoutException()); + } + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for a voice-agent event.", error); + } + } + if (next.error != null) { + Throwable error = next.error; + next = null; + if (error instanceof RuntimeException) { + throw (RuntimeException) error; + } + throw new IllegalStateException("Voice-agent event stream failed.", error); + } + return !next.complete; + } + + @Override + public RealtimeServerEvent next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + RealtimeServerEvent event = next.event; + next = null; + return event; + } + } +} diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketUtils.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketUtils.java new file mode 100644 index 0000000000000..e68c053ed747f --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketUtils.java @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents; + +import com.azure.ai.agents.implementation.realtime.VoiceAgentWebSocketClientConfiguration; +import com.azure.ai.agents.models.RawRealtimeServerEvent; +import com.azure.ai.agents.models.RealtimeServerEvent; +import com.azure.ai.agents.models.VoiceAgentTransport; +import com.azure.ai.agents.models.VoiceAgentWebSocketConnectionOptions; +import com.azure.core.credential.TokenRequestContext; +import com.azure.core.http.HttpHeader; +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpHeaders; +import com.azure.core.util.BinaryData; +import com.azure.core.util.UrlBuilder; +import com.azure.json.JsonProviders; +import com.azure.json.JsonReader; +import com.azure.json.JsonToken; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.Locale; +import java.util.Objects; + +final class VoiceAgentWebSocketUtils { + static String decodeEvent(byte[] bytes) throws CharacterCodingException { + return StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString(); + } + + static String validateEvent(BinaryData event) { + String json = Objects.requireNonNull(event, "'event' cannot be null.").toString(); + try (JsonReader reader = JsonProviders.createReader(json)) { + if (reader.nextToken() != JsonToken.START_OBJECT) { + throw new IllegalArgumentException("A realtime event must be a JSON object."); + } + reader.readUntyped(); + if (reader.nextToken() != JsonToken.END_DOCUMENT) { + throw new IllegalArgumentException("A realtime event must contain only one JSON object."); + } + return json; + } catch (IOException error) { + throw new IllegalArgumentException("Invalid realtime JSON event.", error); + } + } + + static RealtimeServerEvent deserializeEvent(String json) throws IOException { + validateEvent(BinaryData.fromString(json)); + RawRealtimeServerEvent raw = new RawRealtimeServerEvent(BinaryData.fromString(json)); + if (raw.getType() == null) { + return raw; + } + try (JsonReader reader = JsonProviders.createReader(json)) { + RealtimeServerEvent event = RealtimeServerEvent.fromJson(reader); + return event.getClass() == RealtimeServerEvent.class ? raw : event; + } + } + + static final String TOKEN_SCOPE = "https://ai.azure.com/.default"; + static final String PREVIEW_FEATURE = "VoiceAgents=V1Preview"; + static final String SUBPROTOCOL = "realtime"; + static final int INBOUND_CAPACITY = 256; + + private VoiceAgentWebSocketUtils() { + } + + static void validateClose(int code, String reason) { + if (code < 1000 + || code >= 5000 + || code == 1004 + || code == 1005 + || code == 1006 + || (code >= 1015 && code < 3000)) { + throw new IllegalArgumentException("Invalid WebSocket close code: " + code); + } + if (reason == null || reason.getBytes(StandardCharsets.UTF_8).length > 123) { + throw new IllegalArgumentException("Close reason must be non-null and at most 123 UTF-8 bytes."); + } + } + + static boolean isProtectedHeader(String name) { + String lower = name.toLowerCase(Locale.ROOT); + return "authorization".equals(lower) + || "host".equals(lower) + || "upgrade".equals(lower) + || "connection".equals(lower) + || "foundry-features".equals(lower) + || lower.startsWith("sec-websocket-"); + } + + static URI buildWebSocketUri(VoiceAgentWebSocketClientConfiguration configuration, String agentName, + VoiceAgentWebSocketConnectionOptions options) { + URI endpoint = configuration.getEndpoint(); + String scheme; + if ("https".equalsIgnoreCase(endpoint.getScheme()) || "wss".equalsIgnoreCase(endpoint.getScheme())) { + scheme = "wss"; + } else { + throw new IllegalArgumentException( + "Voice-agent WebSocket endpoints must use https or wss to protect credentials."); + } + if (endpoint.getHost() == null || endpoint.getRawUserInfo() != null || endpoint.getRawFragment() != null) { + throw new IllegalArgumentException( + "The project endpoint must have a host and no user information or fragment."); + } + + String basePath = endpoint.getRawPath() == null ? "" : endpoint.getRawPath().replaceAll("/$", ""); + String path = basePath + "/agents/" + encode(agentName) + "/endpoint/protocols/voice"; + URI baseUri = URI.create(scheme + "://" + endpoint.getRawAuthority() + path); + if (options.getConnectionUrl() != null) { + baseUri = options.getConnectionUrl(); + int endpointPort = endpoint.getPort() == -1 ? 443 : endpoint.getPort(); + int overridePort = baseUri.getPort() == -1 ? 443 : baseUri.getPort(); + if (!"wss".equalsIgnoreCase(baseUri.getScheme()) + || baseUri.getHost() == null + || !baseUri.getHost().equalsIgnoreCase(endpoint.getHost()) + || endpointPort != overridePort + || baseUri.getRawUserInfo() != null + || baseUri.getRawFragment() != null) { + throw new IllegalArgumentException( + "Connection URL must be a wss URL on the project endpoint's host and port, without user information or a fragment."); + } + } + UrlBuilder url = UrlBuilder.parse(baseUri.toString()); + url.setQueryParameter("api-version", + encode(options.getApiVersion() == null ? configuration.getApiVersion() : options.getApiVersion())); + url.setQueryParameter("x-ms-client-sdk", encode(configuration.getUserAgent())); + VoiceAgentTransport transport = options.getTransport(); + if (transport != null) { + url.setQueryParameter("transport", encode(transport.toString())); + } + if (options.isStoreEnabled() != null) { + url.setQueryParameter("store", options.isStoreEnabled().toString()); + } + if (options.getAgentVersionOverride() != null) { + url.setQueryParameter("x-agent-version-override", encode(options.getAgentVersionOverride())); + } + if (options.getAgentSessionId() != null) { + url.setQueryParameter("agent_session_id", encode(options.getAgentSessionId())); + } + options.getExtraQuery().forEach((name, value) -> url.setQueryParameter(encode(name), encode(value))); + return URI.create(url.toString()); + } + + static TokenRequestContext createTokenRequestContext(VoiceAgentWebSocketConnectionOptions options) { + return options.getCredentialScopes() == null || options.getCredentialScopes().isEmpty() + ? new TokenRequestContext().addScopes(TOKEN_SCOPE) + : new TokenRequestContext().setScopes(options.getCredentialScopes()); + } + + static HttpHeaders buildHeaders(VoiceAgentWebSocketClientConfiguration configuration, + VoiceAgentWebSocketConnectionOptions options, String token) { + HttpHeaders headers = new HttpHeaders().set(HttpHeaderName.USER_AGENT, configuration.getUserAgent()); + if (configuration.getHeaders() != null) { + for (HttpHeader header : configuration.getHeaders()) { + if (!isProtectedHeader(header.getName())) { + headers.set(HttpHeaderName.fromString(header.getName()), header.getValue()); + } + } + } + headers.set(HttpHeaderName.fromString("Foundry-Features"), options.getFoundryFeatures()); + if (options.getStructuredInputs() != null) { + headers.set(HttpHeaderName.fromString("x-ms-voice-structured-inputs"), options.getStructuredInputs()); + } + options.getExtraHeaders().forEach((name, value) -> { + if (!isProtectedHeader(name) || "Foundry-Features".equalsIgnoreCase(name)) { + headers.set(HttpHeaderName.fromString(name), value); + } + }); + return headers.set(HttpHeaderName.AUTHORIZATION, "Bearer " + token); + } + + private static String encode(String value) { + try { + return URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20"); + } catch (UnsupportedEncodingException error) { + throw new IllegalStateException("UTF-8 encoding is unavailable.", error); + } + } +} 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..9cf37184d7513 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,96 @@ * 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(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(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} @@ -53,4 +150,24 @@ private static LongRunningOperationStatus mapCustomStatus(LongRunningOperationSt } return status; } + + private static LongRunningOperationStatus mapStatus(Object statusValue) { + if (statusValue == null || CoreUtils.isNullOrEmpty(statusValue.toString().trim())) { + return LongRunningOperationStatus.IN_PROGRESS; + } + String status = statusValue.toString().trim(); + if (JobStatus.QUEUED.toString().equalsIgnoreCase(status) + || JobStatus.IN_PROGRESS.toString().equalsIgnoreCase(status)) { + return LongRunningOperationStatus.IN_PROGRESS; + } else if (JobStatus.SUCCEEDED.toString().equalsIgnoreCase(status) + || MemoryStoreUpdateStatus.COMPLETED.toString().equalsIgnoreCase(status)) { + return LongRunningOperationStatus.SUCCESSFULLY_COMPLETED; + } else if (JobStatus.FAILED.toString().equalsIgnoreCase(status)) { + return LongRunningOperationStatus.FAILED; + } else if (JobStatus.CANCELLED.toString().equalsIgnoreCase(status) + || MemoryStoreUpdateStatus.SUPERSEDED.toString().equalsIgnoreCase(status)) { + return LongRunningOperationStatus.USER_CANCELLED; + } + return 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..e8ec10052349b 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,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-agents/src/main/java/com/azure/ai/agents/implementation/realtime/VoiceAgentWebSocketClientConfiguration.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/realtime/VoiceAgentWebSocketClientConfiguration.java new file mode 100644 index 0000000000000..d123c4d966303 --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/realtime/VoiceAgentWebSocketClientConfiguration.java @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.implementation.realtime; + +import com.azure.core.credential.TokenCredential; +import com.azure.core.http.HttpHeaders; +import com.azure.core.http.ProxyOptions; + +import java.net.URI; +import java.util.Objects; + +/** + * Immutable configuration used by the voice-agent WebSocket clients. + */ +public final class VoiceAgentWebSocketClientConfiguration { + private final URI endpoint; + private final TokenCredential credential; + private final String apiVersion; + private final String userAgent; + private final HttpHeaders headers; + private final ProxyOptions proxyOptions; + + /** + * Creates the connection configuration. + * + * @param endpoint the Foundry project endpoint. + * @param credential the credential used for authentication. + * @param apiVersion the service API version. + * @param userAgent the SDK user agent. + * @param headers safe additional handshake headers. + * @param proxyOptions proxy settings loaded from configuration. + */ + public VoiceAgentWebSocketClientConfiguration(URI endpoint, TokenCredential credential, String apiVersion, + String userAgent, HttpHeaders headers, ProxyOptions proxyOptions) { + this.endpoint = Objects.requireNonNull(endpoint, "'endpoint' cannot be null."); + this.credential = Objects.requireNonNull(credential, "'credential' cannot be null."); + this.apiVersion = Objects.requireNonNull(apiVersion, "'apiVersion' cannot be null."); + this.userAgent = Objects.requireNonNull(userAgent, "'userAgent' cannot be null."); + this.headers = headers; + this.proxyOptions = proxyOptions; + } + + /** + * Gets the endpoint. + * + * @return the endpoint. + */ + public URI getEndpoint() { + return endpoint; + } + + /** + * Gets the credential. + * + * @return the credential. + */ + public TokenCredential getCredential() { + return credential; + } + + /** + * Gets the API version. + * + * @return the API version. + */ + public String getApiVersion() { + return apiVersion; + } + + /** + * Gets the user agent. + * + * @return the user agent. + */ + public String getUserAgent() { + return userAgent; + } + + /** + * Gets safe additional headers. + * + * @return safe additional headers, or {@code null}. + */ + public HttpHeaders getHeaders() { + return headers; + } + + /** + * Gets proxy settings. + * + * @return proxy settings, or {@code null}. + */ + public ProxyOptions getProxyOptions() { + return proxyOptions; + } +} diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/realtime/VoiceAgentWebSocketHandshakeHandler.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/realtime/VoiceAgentWebSocketHandshakeHandler.java new file mode 100644 index 0000000000000..70d5dec2212fa --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/realtime/VoiceAgentWebSocketHandshakeHandler.java @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.implementation.realtime; + +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInboundHandlerAdapter; +import io.netty.buffer.Unpooled; +import io.netty.handler.codec.http.DefaultFullHttpResponse; +import io.netty.handler.codec.http.HttpContent; +import io.netty.handler.codec.http.HttpResponse; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.LastHttpContent; +import io.netty.handler.codec.http.websocketx.WebSocketClientHandshakeException; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Objects; +import java.util.function.Consumer; + +/** + * Observes the HTTP response used for a WebSocket upgrade and reports rejected handshakes. + */ +public final class VoiceAgentWebSocketHandshakeHandler extends ChannelInboundHandlerAdapter { + private final Consumer errorConsumer; + private ByteArrayOutputStream responseBody; + private HttpResponse rejectedResponse; + + /** + * Creates a handshake response observer. + * + * @param errorConsumer consumer invoked when the server rejects the upgrade. + */ + public VoiceAgentWebSocketHandshakeHandler(Consumer errorConsumer) { + this.errorConsumer = Objects.requireNonNull(errorConsumer, "'errorConsumer' cannot be null."); + } + + @Override + public void channelRead(ChannelHandlerContext context, Object message) throws IOException { + if (message instanceof HttpResponse) { + HttpResponse response = (HttpResponse) message; + if (!HttpResponseStatus.SWITCHING_PROTOCOLS.equals(response.status())) { + rejectedResponse = response; + } + } + if (rejectedResponse != null && message instanceof HttpContent) { + HttpContent content = (HttpContent) message; + ByteBuf byteBuf = content.content(); + if (byteBuf != null && byteBuf.isReadable()) { + if (responseBody == null) { + responseBody = new ByteArrayOutputStream(); + } + byteBuf.readBytes(responseBody, byteBuf.readableBytes()); + } + if (message instanceof LastHttpContent) { + byte[] body = responseBody == null ? new byte[0] : responseBody.toByteArray(); + DefaultFullHttpResponse response = new DefaultFullHttpResponse(rejectedResponse.protocolVersion(), + rejectedResponse.status(), Unpooled.wrappedBuffer(body)); + response.headers().set(rejectedResponse.headers()); + errorConsumer + .accept(new WebSocketClientHandshakeException("Voice-agent WebSocket handshake failed.", response)); + context.close(); + } + } + context.fireChannelRead(message); + } +} diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/realtime/VoiceAgentWebSocketHttpResponse.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/realtime/VoiceAgentWebSocketHttpResponse.java new file mode 100644 index 0000000000000..7183a02aaf310 --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/realtime/VoiceAgentWebSocketHttpResponse.java @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.implementation.realtime; + +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpHeaders; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.HttpResponse; +import io.netty.buffer.ByteBufUtil; +import io.netty.handler.codec.http.FullHttpResponse; +import okhttp3.ResponseBody; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Map; + +/** + * Azure Core response adapter for a rejected WebSocket handshake. + */ +public final class VoiceAgentWebSocketHttpResponse extends HttpResponse { + private final int statusCode; + private final HttpHeaders headers; + private final byte[] body; + + /** + * Creates a response adapter. + * + * @param endpoint the WebSocket endpoint. + * @param response the rejected Netty handshake response. + */ + public VoiceAgentWebSocketHttpResponse(URI endpoint, io.netty.handler.codec.http.HttpResponse response) { + super(new HttpRequest(HttpMethod.GET, toHttpUrl(endpoint))); + this.statusCode = response.status().code(); + this.headers = new HttpHeaders(); + for (Map.Entry header : response.headers()) { + this.headers.set(com.azure.core.http.HttpHeaderName.fromString(header.getKey()), header.getValue()); + } + this.body = response instanceof FullHttpResponse + ? ByteBufUtil.getBytes(((FullHttpResponse) response).content()) + : new byte[0]; + } + + /** + * Creates a response adapter. + * + * @param endpoint the WebSocket endpoint. + * @param response the rejected OkHttp handshake response. + */ + public VoiceAgentWebSocketHttpResponse(URI endpoint, okhttp3.Response response) { + super(new HttpRequest(HttpMethod.GET, toHttpUrl(endpoint))); + this.statusCode = response.code(); + this.headers = new HttpHeaders(); + for (String name : response.headers().names()) { + for (String value : response.headers(name)) { + this.headers.add(HttpHeaderName.fromString(name), value); + } + } + this.body = readBody(response.body()); + } + + private static byte[] readBody(ResponseBody responseBody) { + if (responseBody == null) { + return new byte[0]; + } + try { + return responseBody.bytes(); + } catch (IOException error) { + throw new UncheckedIOException("Failed to read the WebSocket handshake response body.", error); + } + } + + private static String toHttpUrl(URI endpoint) { + String endpointUrl = endpoint.toString(); + if ("wss".equalsIgnoreCase(endpoint.getScheme())) { + return "https" + endpointUrl.substring(endpoint.getScheme().length()); + } + if ("ws".equalsIgnoreCase(endpoint.getScheme())) { + return "http" + endpointUrl.substring(endpoint.getScheme().length()); + } + return endpointUrl; + } + + @Override + public int getStatusCode() { + return statusCode; + } + + @Override + @SuppressWarnings("deprecation") + public String getHeaderValue(String name) { + return headers.getValue(HttpHeaderName.fromString(name)); + } + + @Override + public HttpHeaders getHeaders() { + return headers; + } + + @Override + public Flux getBody() { + return body.length == 0 ? Flux.empty() : Flux.just(ByteBuffer.wrap(Arrays.copyOf(body, body.length))); + } + + @Override + public Mono getBodyAsByteArray() { + return Mono.just(Arrays.copyOf(body, body.length)); + } + + @Override + public Mono getBodyAsString() { + return getBodyAsString(StandardCharsets.UTF_8); + } + + @Override + public Mono getBodyAsString(Charset charset) { + return Mono.just(new String(body, charset)); + } +} 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..5fbb7893d5490 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/main/java/com/azure/ai/agents/models/RawRealtimeServerEvent.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/RawRealtimeServerEvent.java new file mode 100644 index 0000000000000..27bb2b7ce46d2 --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/RawRealtimeServerEvent.java @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.models; + +import com.azure.ai.agents.implementation.utils.Beta; +import com.azure.core.util.BinaryData; +import com.azure.json.JsonReader; +import com.azure.json.JsonWriter; +import java.io.IOException; +import java.util.Map; +import java.util.Objects; + +/** A server event whose complete JSON payload is retained for forward compatibility. */ +@Beta(warningText = "This class is in preview and may change in future releases.") +public final class RawRealtimeServerEvent extends RealtimeServerEvent { + private final BinaryData rawEvent; + private final RealtimeServerEventType type; + + /** + * Creates an event from a JSON object. + * @param rawEvent the complete event payload. + */ + public RawRealtimeServerEvent(BinaryData rawEvent) { + this.rawEvent + = BinaryData.fromString(Objects.requireNonNull(rawEvent, "'rawEvent' cannot be null.").toString()); + Object value = this.rawEvent.toObject(Map.class).get("type"); + this.type = value instanceof String ? RealtimeServerEventType.fromString((String) value) : null; + } + + /** + * Gets the complete event, including fields unknown to this SDK. + * @return the JSON payload. + */ + public BinaryData getRawEvent() { + return rawEvent; + } + + @Override + public RealtimeServerEventType getType() { + return type; + } + + @Override + public JsonWriter toJson(JsonWriter writer) throws IOException { + return writer.writeRawValue(rawEvent.toString()); + } + + /** + * Reads a raw event without discarding unknown properties. + * @param reader the JSON reader. + * @return the event, or null for JSON null. + * @throws IOException if the JSON cannot be read. + */ + public static RawRealtimeServerEvent fromJson(JsonReader reader) throws IOException { + Object payload = reader.readUntyped(); + return payload == null ? null : new RawRealtimeServerEvent(BinaryData.fromObject(payload)); + } +} diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/VoiceAgentWebSocketConnectionOptions.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/VoiceAgentWebSocketConnectionOptions.java new file mode 100644 index 0000000000000..3ab514c25a851 --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/VoiceAgentWebSocketConnectionOptions.java @@ -0,0 +1,438 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.models; + +import com.azure.ai.agents.implementation.utils.Beta; +import com.azure.core.annotation.Fluent; +import com.azure.core.util.logging.ClientLogger; +import java.net.URI; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.UnaryOperator; +import okhttp3.OkHttpClient; +import reactor.netty.http.client.HttpClient; + +/** + * Options used when opening a realtime voice-agent WebSocket session. + */ +@Beta(warningText = "This class is in preview and may change in future releases.") +@Fluent +public final class VoiceAgentWebSocketConnectionOptions { + private static final ClientLogger LOGGER = new ClientLogger(VoiceAgentWebSocketConnectionOptions.class); + private int receiveBufferCapacity = 256; + private int maxMessageSize = 32 * 1024 * 1024; + private VoiceAgentWebSocketOverflowStrategy overflowStrategy = VoiceAgentWebSocketOverflowStrategy.ERROR; + private Consumer malformedEventHandler; + + /** + * Gets the maximum number of queued events. + * @return the capacity, default 256. + */ + public int getReceiveBufferCapacity() { + return receiveBufferCapacity; + } + + /** + * Sets the bounded receive queue capacity. Configure before connecting. + * @param capacity number of events, between 1 and 65536. + * @return this options instance. + * @throws IllegalArgumentException if capacity is outside the supported range. + */ + public VoiceAgentWebSocketConnectionOptions setReceiveBufferCapacity(int capacity) { + if (capacity < 1 || capacity > 65536) { + throw LOGGER + .logExceptionAsError(new IllegalArgumentException("Receive capacity must be between 1 and 65536.")); + } + this.receiveBufferCapacity = capacity; + return this; + } + + /** + * Gets the maximum accepted JSON message size. + * @return the size in bytes, default 32 MiB. + */ + public int getMaxMessageSize() { + return maxMessageSize; + } + + /** + * Sets the maximum accepted JSON message size. Oversized messages terminate the connection. + * @param bytes positive size in bytes. + * @return this options instance. + * @throws IllegalArgumentException if bytes is not positive. + */ + public VoiceAgentWebSocketConnectionOptions setMaxMessageSize(int bytes) { + if (bytes <= 0) { + throw LOGGER.logExceptionAsError(new IllegalArgumentException("Message size must be positive.")); + } + this.maxMessageSize = bytes; + return this; + } + + /** + * Gets the receive queue overflow action. + * @return the strategy, default ERROR. + */ + public VoiceAgentWebSocketOverflowStrategy getOverflowStrategy() { + return overflowStrategy; + } + + /** + * Sets the receive queue overflow action. Drop strategies explicitly permit data loss. + * @param strategy the non-null strategy. + * @return this options instance. + */ + public VoiceAgentWebSocketConnectionOptions setOverflowStrategy(VoiceAgentWebSocketOverflowStrategy strategy) { + this.overflowStrategy = Objects.requireNonNull(strategy, "'strategy' cannot be null."); + return this; + } + + /** + * Gets the callback for skipping malformed events. + * @return the callback, or null to terminate on malformed events. + */ + public Consumer getMalformedEventHandler() { + return malformedEventHandler; + } + + /** + * Sets a callback that reports and skips malformed events without terminating reception. + * The callback runs on the receive thread and must not block. If it throws, the session terminates. + * This does not recover from transport errors or oversized messages. + * @param handler callback, or null to terminate on malformed events (the default). + * @return this options instance. + */ + public VoiceAgentWebSocketConnectionOptions setMalformedEventHandler(Consumer handler) { + this.malformedEventHandler = handler; + return this; + } + + private Consumer httpClientConfiguration; + private UnaryOperator asyncHttpClientConfiguration; + + /** + * Sets synchronous transport customization, for example certificate trust or ping interval. + * Redirects and handshake timeouts remain SDK-controlled. Do not disable TLS hostname verification. + * @param configure callback applied to the per-session transport, or null for defaults. + * @return this options instance. + */ + public VoiceAgentWebSocketConnectionOptions setHttpClientConfiguration(Consumer configure) { + this.httpClientConfiguration = configure; + return this; + } + + /** + * Gets synchronous transport customization. + * @return the callback, or null. + */ + public Consumer getHttpClientConfiguration() { + return httpClientConfiguration; + } + + /** + * Sets asynchronous transport customization, for example certificate trust or channel handlers. + * Redirects, authentication, subprotocol and handshake timeouts remain SDK-controlled. + * Do not disable TLS hostname verification. + * @param configure callback returning a configured transport, or null for defaults. + * @return this options instance. + */ + public VoiceAgentWebSocketConnectionOptions setAsyncHttpClientConfiguration(UnaryOperator configure) { + this.asyncHttpClientConfiguration = configure; + return this; + } + + /** + * Gets asynchronous transport customization. + * @return the callback, or null. + */ + public UnaryOperator getAsyncHttpClientConfiguration() { + return asyncHttpClientConfiguration; + } + + private VoiceAgentTransport transport = VoiceAgentTransport.WEBSOCKET; + private Boolean store; + private String agentVersionOverride; + private Duration handshakeTimeout = Duration.ofSeconds(30); + private Duration closeTimeout = Duration.ofSeconds(10); + private String agentSessionId; + private String structuredInputs; + private URI connectionUrl; + private String apiVersion; + private String foundryFeatures = "VoiceAgents=V1Preview"; + private List credentialScopes; + private Map extraQuery = Collections.emptyMap(); + private Map extraHeaders = Collections.emptyMap(); + + /** + * Creates options for opening a realtime voice-agent WebSocket session. + */ + public VoiceAgentWebSocketConnectionOptions() { + } + + /** + * Gets the session correlation identifier. + * @return the session identifier, or null. + */ + public String getAgentSessionId() { + return agentSessionId; + } + + /** + * Sets the session correlation identifier. + * @param agentSessionId the session identifier, or null. + * @return this options instance. + */ + public VoiceAgentWebSocketConnectionOptions setAgentSessionId(String agentSessionId) { + this.agentSessionId = agentSessionId; + return this; + } + + /** + * Gets the structured inputs JSON object sent in the handshake header. + * @return the structured inputs, or null. + */ + public String getStructuredInputs() { + return structuredInputs; + } + + /** + * Sets the structured inputs JSON object sent in the handshake header. + * @param structuredInputs the JSON object, or null. + * @return this options instance. + */ + public VoiceAgentWebSocketConnectionOptions setStructuredInputs(String structuredInputs) { + this.structuredInputs = structuredInputs; + return this; + } + + /** + * Gets the full WebSocket URL override. + * @return the URL override, or null. + */ + public URI getConnectionUrl() { + return connectionUrl; + } + + /** + * Sets a full WebSocket URL override. It must use wss and the project endpoint's host and port. + * User information and fragments are not supported. Existing query parameters are preserved unless overridden. + * @param connectionUrl the URL override, or null to use the agent route. + * @return this options instance. + */ + public VoiceAgentWebSocketConnectionOptions setConnectionUrl(URI connectionUrl) { + this.connectionUrl = connectionUrl; + return this; + } + + /** + * Gets the handshake API version override. + * @return the API version, or null. + */ + public String getApiVersion() { + return apiVersion; + } + + /** + * Sets the handshake API version override. + * @param apiVersion the API version, or null to use the client's version. + * @return this options instance. + */ + public VoiceAgentWebSocketConnectionOptions setApiVersion(String apiVersion) { + this.apiVersion = apiVersion; + return this; + } + + /** + * Gets the preview feature header value. + * @return the preview feature header value. + */ + public String getFoundryFeatures() { + return foundryFeatures; + } + + /** + * Sets the preview feature header value. + * @param foundryFeatures comma-separated preview features, or an empty string to suppress opt-in. + * @return this options instance. + */ + public VoiceAgentWebSocketConnectionOptions setFoundryFeatures(String foundryFeatures) { + this.foundryFeatures = Objects.requireNonNull(foundryFeatures, "'foundryFeatures' cannot be null."); + return this; + } + + /** + * Gets credential scopes for the handshake. + * @return an unmodifiable list, or null to use the default Foundry scope. + */ + public List getCredentialScopes() { + return credentialScopes; + } + + /** + * Sets credential scopes for the handshake. + * @param credentialScopes the scopes, or null to use the default Foundry scope. + * @return this options instance. + */ + public VoiceAgentWebSocketConnectionOptions setCredentialScopes(List credentialScopes) { + this.credentialScopes + = credentialScopes == null ? null : Collections.unmodifiableList(new ArrayList<>(credentialScopes)); + return this; + } + + /** + * Gets additional handshake query parameters. + * @return an unmodifiable map of query parameters. + */ + public Map getExtraQuery() { + return extraQuery; + } + + /** + * Sets additional handshake query parameters, taking precedence over defaults. + * @param extraQuery unencoded query names and values, or null to clear. + * @return this options instance. + */ + public VoiceAgentWebSocketConnectionOptions setExtraQuery(Map extraQuery) { + this.extraQuery = extraQuery == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new LinkedHashMap<>(extraQuery)); + return this; + } + + /** + * Gets additional handshake headers. + * @return an unmodifiable map of headers. + */ + public Map getExtraHeaders() { + return extraHeaders; + } + + /** + * Sets additional handshake headers. Authorization, host, connection, upgrade, and WebSocket protocol headers + * remain transport-controlled. Other headers override defaults case-insensitively, including empty values. + * @param extraHeaders the additional headers, or null to clear. + * @return this options instance. + */ + public VoiceAgentWebSocketConnectionOptions setExtraHeaders(Map extraHeaders) { + this.extraHeaders = extraHeaders == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new LinkedHashMap<>(extraHeaders)); + return this; + } + + /** + * Gets the session transport. + * + * @return the session transport. + */ + public VoiceAgentTransport getTransport() { + return transport; + } + + /** + * Sets the session transport. WebRTC transport performs signaling only; the SDK does not provide a WebRTC media + * implementation. + * + * @param transport the session transport. + * @return this options instance. + */ + public VoiceAgentWebSocketConnectionOptions setTransport(VoiceAgentTransport transport) { + this.transport = transport; + return this; + } + + /** + * Gets whether the conversation is persisted for this session. + * + * @return whether the conversation is persisted, or {@code null} to use the agent definition. + */ + public Boolean isStoreEnabled() { + return store; + } + + /** + * Sets whether the conversation is persisted for this session. + * + * @param store whether the conversation is persisted, or {@code null} to use the agent definition. + * @return this options instance. + */ + public VoiceAgentWebSocketConnectionOptions setStoreEnabled(Boolean store) { + this.store = store; + return this; + } + + /** + * Gets the agent version override. + * + * @return the agent version override. + */ + public String getAgentVersionOverride() { + return agentVersionOverride; + } + + /** + * Sets the agent version override. + * + * @param agentVersionOverride the agent version to use for this session. + * @return this options instance. + */ + public VoiceAgentWebSocketConnectionOptions setAgentVersionOverride(String agentVersionOverride) { + this.agentVersionOverride = agentVersionOverride; + return this; + } + + /** + * Gets the WebSocket handshake timeout. + * + * @return the WebSocket handshake timeout. + */ + public Duration getHandshakeTimeout() { + return handshakeTimeout; + } + + /** + * Sets the WebSocket handshake timeout. + * + * @param handshakeTimeout the positive handshake timeout. + * @return this options instance. + */ + public VoiceAgentWebSocketConnectionOptions setHandshakeTimeout(Duration handshakeTimeout) { + validatePositive(handshakeTimeout, "handshakeTimeout"); + this.handshakeTimeout = handshakeTimeout; + return this; + } + + /** + * Gets the graceful close timeout. + * + * @return the graceful close timeout. + */ + public Duration getCloseTimeout() { + return closeTimeout; + } + + /** + * Sets the graceful close timeout. + * + * @param closeTimeout the positive close timeout. + * @return this options instance. + */ + public VoiceAgentWebSocketConnectionOptions setCloseTimeout(Duration closeTimeout) { + validatePositive(closeTimeout, "closeTimeout"); + this.closeTimeout = closeTimeout; + return this; + } + + private static void validatePositive(Duration duration, String name) { + if (duration == null || duration.isZero() || duration.isNegative()) { + throw new IllegalArgumentException("'" + name + "' must be positive."); + } + } +} diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/VoiceAgentWebSocketOverflowStrategy.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/VoiceAgentWebSocketOverflowStrategy.java new file mode 100644 index 0000000000000..224a1353c5b1d --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/VoiceAgentWebSocketOverflowStrategy.java @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.models; + +import com.azure.ai.agents.implementation.utils.Beta; + +/** Action taken when a voice-agent session's bounded receive queue fills. */ +@Beta(warningText = "This enum is in preview and may change in future releases.") +public enum VoiceAgentWebSocketOverflowStrategy { + /** Terminate the connection with an error. No overflow is silently ignored. */ + ERROR, + /** Discard the oldest buffered event to accept the new event. This loses data. */ + DROP_OLDEST, + /** Discard the incoming event. This loses data. */ + DROP_LATEST +} diff --git a/sdk/ai/azure-ai-agents/src/main/java/module-info.java b/sdk/ai/azure-ai-agents/src/main/java/module-info.java index ef7e2a9cd86e2..273961fce85b5 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/module-info.java +++ b/sdk/ai/azure-ai-agents/src/main/java/module-info.java @@ -4,8 +4,17 @@ module com.azure.ai.agents { requires transitive com.azure.core; - requires transitive openai.java.client.okhttp; requires transitive openai.java.core; + requires transitive openai.java.client.okhttp; + requires transitive reactor.netty.http; + requires reactor.netty.core; + requires io.netty.codec.http; + requires io.netty.transport; + requires io.netty.common; + requires io.netty.codec; + requires io.netty.buffer; + requires transitive okhttp3; + requires okio; exports com.azure.ai.agents; exports com.azure.ai.agents.models; diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/ReadmeSamples.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/ReadmeSamples.java index 40ee3dfdf99e2..122d282aa4e52 100644 --- a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/ReadmeSamples.java +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/ReadmeSamples.java @@ -10,10 +10,15 @@ import com.azure.ai.agents.models.FixedRatioVersionSelectionRule; import com.azure.ai.agents.models.PromptAgentDefinition; import com.azure.ai.agents.models.ProtocolConfiguration; +import com.azure.ai.agents.models.RawRealtimeServerEvent; +import com.azure.ai.agents.models.RealtimeServerEvent; import com.azure.ai.agents.models.ResponsesProtocolConfiguration; import com.azure.ai.agents.models.SessionLogEvent; import com.azure.ai.agents.models.UpdateAgentDetailsOptions; import com.azure.ai.agents.models.VersionSelector; +import com.azure.ai.agents.models.VoiceAgentWebSocketConnectionOptions; +import com.azure.ai.agents.models.VoiceAgentWebSocketOverflowStrategy; +import com.azure.core.util.BinaryData; import com.azure.core.util.IterableStream; import com.azure.identity.AuthenticationUtil; import com.azure.identity.DefaultAzureCredentialBuilder; @@ -28,6 +33,27 @@ import com.openai.services.blocking.ConversationService; public final class ReadmeSamples { + public void realtimeForwardCompatibility(BetaVoiceAgentWebSocketClient realtimeClient, String agentName) { + // BEGIN: com.azure.ai.agents.realtime_forward_compatibility + VoiceAgentWebSocketConnectionOptions options + = new VoiceAgentWebSocketConnectionOptions() + .setReceiveBufferCapacity(512) + .setMaxMessageSize(8 * 1024 * 1024) + .setOverflowStrategy(VoiceAgentWebSocketOverflowStrategy.ERROR); + try (VoiceAgentWebSocketSessionClient session = realtimeClient.connect(agentName, options)) { + session.sendEvent(BinaryData.fromString( + "{\"type\":\"response.create\",\"event_id\":\"response-1\"}")); + for (RealtimeServerEvent event : session.receiveEvents()) { + if (event instanceof RawRealtimeServerEvent) { + BinaryData payload + = ((RawRealtimeServerEvent) event).getRawEvent(); + System.out.println("Received an unrecognized event with " + payload.getLength() + " bytes."); + } + } + } + // END: com.azure.ai.agents.realtime_forward_compatibility + } + public void readmeSamples() { String endpoint = "my-resource-url"; String model = "model"; diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentBasicAsyncSample.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentBasicAsyncSample.java new file mode 100644 index 0000000000000..d5ee1ea3e8e86 --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentBasicAsyncSample.java @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.voice; + +import com.azure.ai.agents.AgentsAsyncClient; +import com.azure.ai.agents.AgentsClientBuilder; +import com.azure.core.util.Configuration; +import com.azure.identity.DefaultAzureCredentialBuilder; +import com.azure.ai.agents.models.AgentKind; +import com.azure.ai.agents.models.CreateAgentVersionInput; +import com.azure.ai.agents.models.VoiceModelType; +import reactor.core.publisher.Mono; + +/** + * Demonstrates the asynchronous voice-agent lifecycle. + * + *

Before running the sample, set these environment variables:

+ *
    + *
  • {@code FOUNDRY_PROJECT_ENDPOINT} - The Azure AI Project endpoint.
  • + *
  • {@code FOUNDRY_VOICE_MODEL} - Optional. The voice model or deployment name. Defaults to {@code gpt-realtime}.
  • + *
  • {@code FOUNDRY_VOICE_MODEL_TYPE} - Optional. The voice model type. Defaults to {@code managed}.
  • + *
  • {@code FOUNDRY_VOICE_AGENT_NAME} - The voice agent name. Defaults to {@code voice-agent-async-java}.
  • + *
+ */ +public class VoiceAgentBasicAsyncSample { + public static void main(String[] args) { + Configuration configuration = Configuration.getGlobalConfiguration(); + String endpoint = configuration.get("FOUNDRY_PROJECT_ENDPOINT"); + String model = configuration.get("FOUNDRY_VOICE_MODEL", "gpt-realtime"); + VoiceModelType modelType = VoiceModelType.fromString(configuration.get( + "FOUNDRY_VOICE_MODEL_TYPE", VoiceModelType.MANAGED.toString())); + String agentName = configuration.get("FOUNDRY_VOICE_AGENT_NAME", "voice-agent-async-java"); + + AgentsAsyncClient client = new AgentsClientBuilder() + .credential(new DefaultAzureCredentialBuilder().build()) + .endpoint(endpoint) + .allowPreview(true) + .buildAgentsAsyncClient(); + + client.createAgentVersion(agentName, + new CreateAgentVersionInput(VoiceAgentSampleUtils.createDefinition(modelType, model, + "You are a friendly voice assistant. Keep replies short and natural."))) + .doOnNext(created -> System.out.printf("Created voice agent %s, version %s%n", + created.getName(), created.getVersion())) + .then(client.getAgent(agentName)) + .doOnNext(agent -> System.out.printf("Retrieved voice agent %s, state %s%n", + agent.getName(), agent.getState())) + .thenMany(client.listAgents(AgentKind.VOICE, null, null, null, null)) + .doOnNext(agent -> System.out.println("Voice agent: " + agent.getName())) + .then(client.createAgentVersion(agentName, + new CreateAgentVersionInput(VoiceAgentSampleUtils.createDefinition(modelType, model, + "You are a friendly voice assistant. Always greet the caller warmly.")) + .setDescription("Updated voice-agent instructions."))) + .doOnNext(updated -> System.out.println("Created updated version: " + updated.getVersion())) + .then(client.disableAgent(agentName)) + .then(client.enableAgent(agentName)) + .then(client.deleteAgent(agentName) + .doOnSuccess(ignored -> System.out.println("Deleted agent after successful completion: " + agentName))) + .onErrorResume(error -> client.deleteAgent(agentName) + .doOnSuccess(ignored -> System.out.println("Deleted agent during error cleanup: " + agentName)) + .onErrorResume(cleanupError -> Mono.empty()) + .then(Mono.error(error))) + .block(); + } +} diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentBasicSample.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentBasicSample.java new file mode 100644 index 0000000000000..e03ab19962386 --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentBasicSample.java @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.voice; + +import com.azure.ai.agents.AgentsClient; +import com.azure.ai.agents.AgentsClientBuilder; +import com.azure.core.util.Configuration; +import com.azure.identity.DefaultAzureCredentialBuilder; +import com.azure.ai.agents.models.AgentDetails; +import com.azure.ai.agents.models.AgentKind; +import com.azure.ai.agents.models.AgentVersionDetails; +import com.azure.ai.agents.models.CreateAgentVersionInput; +import com.azure.ai.agents.models.VoiceModelType; + +/** + * Demonstrates the synchronous voice-agent lifecycle. + * + *

Before running the sample, set these environment variables:

+ *
    + *
  • {@code FOUNDRY_PROJECT_ENDPOINT} - The Azure AI Project endpoint.
  • + *
  • {@code FOUNDRY_VOICE_MODEL} - Optional. The voice model or deployment name. Defaults to {@code gpt-realtime}.
  • + *
  • {@code FOUNDRY_VOICE_MODEL_TYPE} - Optional. The voice model type. Defaults to {@code managed}.
  • + *
  • {@code FOUNDRY_VOICE_AGENT_NAME} - The voice agent name. Defaults to {@code voice-agent-java}.
  • + *
+ */ +public class VoiceAgentBasicSample { + public static void main(String[] args) { + Configuration configuration = Configuration.getGlobalConfiguration(); + String endpoint = configuration.get("FOUNDRY_PROJECT_ENDPOINT"); + String model = configuration.get("FOUNDRY_VOICE_MODEL", "gpt-realtime"); + VoiceModelType modelType = VoiceModelType.fromString(configuration.get( + "FOUNDRY_VOICE_MODEL_TYPE", VoiceModelType.MANAGED.toString())); + String agentName = configuration.get("FOUNDRY_VOICE_AGENT_NAME", "voice-agent-java"); + + AgentsClient client = new AgentsClientBuilder() + .credential(new DefaultAzureCredentialBuilder().build()) + .endpoint(endpoint) + .allowPreview(true) + .buildAgentsClient(); + try { + AgentVersionDetails created = client.createAgentVersion(agentName, + new CreateAgentVersionInput(VoiceAgentSampleUtils.createDefinition(modelType, model, + "You are a friendly voice assistant. Keep replies short and natural."))); + System.out.printf("Created voice agent %s, version %s%n", created.getName(), created.getVersion()); + + AgentDetails agent = client.getAgent(agentName); + System.out.printf("Retrieved voice agent %s, state %s%n", agent.getName(), agent.getState()); + for (AgentDetails item : client.listAgents(AgentKind.VOICE, null, null, null, null)) { + System.out.println("Voice agent: " + item.getName()); + } + + AgentVersionDetails updated = client.createAgentVersion(agentName, + new CreateAgentVersionInput(VoiceAgentSampleUtils.createDefinition(modelType, model, + "You are a friendly voice assistant. Always greet the caller warmly.")) + .setDescription("Updated voice-agent instructions.")); + System.out.println("Created updated version: " + updated.getVersion()); + client.disableAgent(agentName); + System.out.println("Disabled voice agent"); + client.enableAgent(agentName); + System.out.println("Enabled voice agent"); + } finally { + client.deleteAgent(agentName); + System.out.println("Deleted voice agent: " + agentName); + } + } +} diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentGenerateSample.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentGenerateSample.java new file mode 100644 index 0000000000000..d24846311b2a4 --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentGenerateSample.java @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.voice; + +import com.azure.ai.agents.AgentsClient; +import com.azure.ai.agents.AgentsClientBuilder; +import com.azure.ai.agents.BetaAgentsClient; +import com.azure.core.util.Configuration; +import com.azure.identity.DefaultAzureCredentialBuilder; +import com.azure.ai.agents.models.AgentDetails; +import com.azure.ai.agents.models.AgentVersionDetails; +import com.azure.ai.agents.models.VoiceAgentDefinition; +import com.azure.core.util.BinaryData; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Demonstrates guided authoring of a voice agent through the agent generation API. + * + *

Before running the sample, set these environment variables:

+ *
    + *
  • {@code FOUNDRY_PROJECT_ENDPOINT} - The Azure AI Project endpoint.
  • + *
  • {@code FOUNDRY_VOICE_AGENT_NAME} - The voice agent name. Defaults to {@code generated-voice-agent-java}.
  • + *
+ */ +public class VoiceAgentGenerateSample { + public static void main(String[] args) { + Configuration configuration = Configuration.getGlobalConfiguration(); + String endpoint = configuration.get("FOUNDRY_PROJECT_ENDPOINT"); + String agentName = configuration.get("FOUNDRY_VOICE_AGENT_NAME", "generated-voice-agent-java"); + + AgentsClientBuilder builder = new AgentsClientBuilder() + .credential(new DefaultAzureCredentialBuilder().build()) + .endpoint(endpoint) + .allowPreview(true); + AgentsClient client = builder.buildAgentsClient(); + BetaAgentsClient betaClient = builder.beta().buildBetaAgentsClient(); + + Map request = new LinkedHashMap<>(); + request.put("kind", "voice"); + request.put("name", agentName); + AgentDetails generated = betaClient.createAgentFromPrompt(BinaryData.fromObject(request)); + try { + System.out.println("Generated voice agent: " + generated.getName()); + AgentVersionDetails latest = generated.getVersions().getLatest(); + if (latest != null && latest.getDefinition() instanceof VoiceAgentDefinition) { + VoiceAgentDefinition definition = (VoiceAgentDefinition) latest.getDefinition(); + System.out.println("Instructions: " + definition.getInstructions()); + } + } finally { + client.deleteAgent(generated.getName()); + System.out.println("Deleted agent: " + generated.getName()); + } + } +} diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSample.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSample.java new file mode 100644 index 0000000000000..e3c157dc6cbac --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSample.java @@ -0,0 +1,375 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.voice; + +import com.azure.ai.agents.AgentsAsyncClient; +import com.azure.ai.agents.AgentsClientBuilder; +import com.azure.ai.agents.BetaVoiceAgentsConversationsAsyncClient; +import com.azure.ai.agents.BetaAgentsAsyncClient; +import com.azure.ai.agents.BetaVoiceAgentWebSocketAsyncClient; +import com.azure.ai.agents.VoiceAgentWebSocketSessionAsyncClient; +import com.azure.ai.agents.models.CreateAgentVersionInput; +import com.azure.ai.agents.models.RealtimeServerEventConversationItemInputAudioTranscriptionCompleted; +import com.azure.ai.agents.models.RealtimeServerEventInputAudioBufferSpeechStarted; +import com.azure.ai.agents.models.RealtimeServerEventError; +import com.azure.ai.agents.models.RealtimeServerEventResponseAudioDelta; +import com.azure.ai.agents.models.RealtimeServerEventResponseAudioTranscriptDone; +import com.azure.ai.agents.models.RealtimeServerEventResponseCreated; +import com.azure.ai.agents.models.RealtimeServerEventResponseDone; +import com.azure.ai.agents.models.RealtimeServerEventSessionCreated; +import com.azure.ai.agents.models.VoiceAgentDefinition; +import com.azure.core.util.BinaryData; +import com.azure.core.util.Configuration; +import com.azure.identity.DefaultAzureCredentialBuilder; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.core.scheduler.Schedulers; + +import javax.sound.sampled.AudioFormat; +import javax.sound.sampled.AudioSystem; +import javax.sound.sampled.LineUnavailableException; +import javax.sound.sampled.SourceDataLine; +import javax.sound.sampled.TargetDataLine; +import java.io.IOException; +import java.io.InputStream; +import java.time.Duration; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Demonstrates an asynchronous hands-free voice conversation using Java Sound and server-side VAD. + * + *

To end the call, focus the terminal running the sample and press Enter. The sample then closes the WebSocket, + * stops the microphone and speaker, reads the persisted conversation, and deletes the agent unless + * {@code FOUNDRY_KEEP_VOICE_AGENT} is set to {@code true}.

+ * + *

Disconnection or an audio failure also stops the call and releases the audio devices. Up to 60 seconds of PCM + * audio can wait for playback so that faster-than-realtime responses do not block WebSocket reception. Exceeding + * that limit ends the call rather than dropping speech or growing memory without a bound. This sample must be the + * only reader of standard input.

+ * + *

Before running the sample, set these environment variables:

+ *
    + *
  • {@code FOUNDRY_PROJECT_ENDPOINT} - The Azure AI Project endpoint.
  • + *
  • {@code FOUNDRY_VOICE_AGENT_NAME} - Optional. The voice agent name. Defaults to + * {@code sample-live-audio-conversation-agent-async-java}.
  • + *
  • {@code FOUNDRY_KEEP_VOICE_AGENT} - Optional. Set to {@code true} to keep the agent after the sample. + * Defaults to {@code false}.
  • + *
+ */ +public class VoiceAgentLiveAudioConversationAsyncSample { + private static final Duration SEND_TIMEOUT = Duration.ofSeconds(10); + + public static void main(String[] args) { + Configuration configuration = Configuration.getGlobalConfiguration(); + String endpoint = configuration.get("FOUNDRY_PROJECT_ENDPOINT"); + String agentName = configuration.get("FOUNDRY_VOICE_AGENT_NAME", + "sample-live-audio-conversation-agent-async-java"); + boolean keepAgent + = Boolean.parseBoolean(configuration.get("FOUNDRY_KEEP_VOICE_AGENT", "false")); + + AgentsClientBuilder builder = new AgentsClientBuilder() + .credential(new DefaultAzureCredentialBuilder().build()) + .endpoint(endpoint) + .allowPreview(true); + AgentsAsyncClient agents = builder.buildAgentsAsyncClient(); + BetaAgentsAsyncClient betaAgents = builder.beta().buildBetaAgentsAsyncClient(); + BetaVoiceAgentWebSocketAsyncClient realtime = builder.beta().buildBetaVoiceAgentWebSocketAsyncClient(); + BetaVoiceAgentsConversationsAsyncClient conversations + = builder.beta().buildBetaVoiceAgentsConversationsAsyncClient(); + + Map request = new LinkedHashMap<>(); + request.put("kind", "voice"); + request.put("name", agentName); + AtomicReference conversationId = new AtomicReference<>(); + + betaAgents.createAgentFromPrompt(BinaryData.fromObject(request)) + .flatMap(generated -> { + VoiceAgentDefinition definition + = (VoiceAgentDefinition) generated.getVersions().getLatest().getDefinition(); + return agents.createAgentVersion(agentName, + new CreateAgentVersionInput(definition.setStore(true))); + }) + .then(Mono.usingWhen(realtime.connect(agentName), + session -> runConversation(session, conversationId), + VoiceAgentWebSocketSessionAsyncClient::closeAsync, + (session, error) -> session.closeAsync(), + VoiceAgentWebSocketSessionAsyncClient::closeAsync)) + .then(Mono.defer(() -> conversationId.get() == null + ? Mono.fromRunnable(() -> System.out.println("No persisted conversation ID was returned.")) + : VoiceAgentRealtimeSampleUtils.readConversation(conversations, agentName, conversationId.get()))) + .then(Mono.defer(() -> cleanupAgent(agents, agentName, keepAgent))) + .onErrorResume(error -> Mono.defer(() -> cleanupAgent(agents, agentName, keepAgent)) + .onErrorResume(cleanupError -> Mono.empty()) + .then(Mono.error(error))) + .block(); + } + + private static Mono cleanupAgent(AgentsAsyncClient agents, String agentName, boolean keepAgent) { + if (keepAgent) { + return Mono.fromRunnable(() -> System.out.println("Kept voice agent: " + agentName)); + } + return agents.deleteAgent(agentName) + .doOnSuccess(ignored -> System.out.println("Deleted voice agent: " + agentName)); + } + + private static Mono runConversation(VoiceAgentWebSocketSessionAsyncClient session, + AtomicReference conversationId) { + AudioProcessor processor = new AudioProcessor(session); + AtomicBoolean responseActive = new AtomicBoolean(); + Mono receive = session.receiveEvents().concatMap(event -> { + if (event instanceof RealtimeServerEventSessionCreated) { + String id = ((RealtimeServerEventSessionCreated) event).getConversationId(); + if (id != null) { + conversationId.set(id); + } + } else if (event instanceof RealtimeServerEventInputAudioBufferSpeechStarted) { + if (responseActive.get()) { + processor.skipPendingAudio(); + System.out.println("(listening...)"); + return session.cancelResponse().timeout(SEND_TIMEOUT); + } + } else if (event instanceof RealtimeServerEventConversationItemInputAudioTranscriptionCompleted) { + System.out.println("You: " + + ((RealtimeServerEventConversationItemInputAudioTranscriptionCompleted) event) + .getTranscript().trim()); + } else if (event instanceof RealtimeServerEventResponseCreated) { + responseActive.set(true); + } else if (event instanceof RealtimeServerEventResponseDone) { + responseActive.set(false); + } else if (event instanceof RealtimeServerEventResponseAudioDelta) { + processor.queueAudio(((RealtimeServerEventResponseAudioDelta) event).getDelta()); + } else if (event instanceof RealtimeServerEventResponseAudioTranscriptDone) { + System.out.println("Agent: " + + ((RealtimeServerEventResponseAudioTranscriptDone) event).getTranscript()); + } else if (event instanceof RealtimeServerEventError) { + RealtimeServerEventError error + = (RealtimeServerEventError) event; + System.out.println("Session error: " + error.getError().getMessage()); + } + return Mono.empty(); + }).then(); + return runConversation(receive, processor, System.in); + } + + static Mono runConversation(Mono receive, AudioProcessor processor, InputStream input) { + return Mono.usingWhen(Mono.fromSupplier(() -> processor), audio -> { + System.out.println("Speak now; talk over the agent to interrupt it. Press Enter to end the session."); + return Mono.fromRunnable(processor::start) + .subscribeOn(Schedulers.boundedElastic()) + .then(Mono.firstWithSignal(receive, processor.failure.asMono(), waitForEnter(input))); + }, VoiceAgentLiveAudioConversationAsyncSample::closeAudio, + (audio, error) -> closeAudio(audio), VoiceAgentLiveAudioConversationAsyncSample::closeAudio); + } + + private static Mono closeAudio(AudioProcessor processor) { + return Mono.fromRunnable(processor::close).subscribeOn(Schedulers.boundedElastic()); + } + + static Mono waitForEnter(InputStream input) { + return Flux.interval(Duration.ZERO, Duration.ofMillis(100), Schedulers.boundedElastic()) + .handle((tick, sink) -> { + try { + int available = input.available(); + for (int remaining = available; remaining > 0; remaining--) { + int next = input.read(); + if (next == '\n' || next == '\r' || next == -1) { + sink.complete(); + return; + } + } + } catch (IOException error) { + sink.error(error); + } + }).then(); + } + + static final class AudioProcessor implements AutoCloseable { + private static final int CHUNK_BYTES = 2400; + static final int MAX_PLAYBACK_BYTES = VoiceAgentRealtimeSampleUtils.SAMPLE_RATE * 2 * 60; + private static final byte[] STOP = new byte[0]; + private final VoiceAgentWebSocketSessionAsyncClient session; + private final AudioFormat format = new AudioFormat(VoiceAgentRealtimeSampleUtils.SAMPLE_RATE, 16, 1, true, false); + private final BlockingQueue playback = new LinkedBlockingQueue<>(MAX_PLAYBACK_BYTES / 2); + private int queuedPlaybackBytes; + private final AtomicBoolean running = new AtomicBoolean(); + private final Sinks.Empty failure = Sinks.empty(); + private boolean closed; + private TargetDataLine microphone; + private SourceDataLine speaker; + private Thread captureThread; + private Thread playbackThread; + + AudioProcessor(VoiceAgentWebSocketSessionAsyncClient session) { + this(session, null, null); + } + + AudioProcessor(VoiceAgentWebSocketSessionAsyncClient session, TargetDataLine microphone, SourceDataLine speaker) { + this.session = session; + this.microphone = microphone; + this.speaker = speaker; + } + + synchronized void start() { + if (closed) { + throw new IllegalStateException("Audio processor is already closed."); + } + try { + if (microphone == null) { + microphone = AudioSystem.getTargetDataLine(format); + } + microphone.open(format, CHUNK_BYTES * 4); + if (speaker == null) { + speaker = AudioSystem.getSourceDataLine(format); + } + speaker.open(format); + microphone.start(); + speaker.start(); + } catch (LineUnavailableException | IllegalArgumentException error) { + close(); + throw new IllegalStateException("A 24-kHz mono PCM16 microphone and speaker are required.", error); + } + + running.set(true); + captureThread = new Thread(this::capture, "voice-agent-microphone"); + playbackThread = new Thread(this::playback, "voice-agent-speaker"); + captureThread.setDaemon(true); + playbackThread.setDaemon(true); + captureThread.start(); + playbackThread.start(); + } + + private void capture() { + byte[] buffer = new byte[CHUNK_BYTES]; + try { + while (running.get()) { + int read = microphone.read(buffer, 0, buffer.length); + if (read > 0 && running.get()) { + session.appendInputAudio(BinaryData.fromBytes(Arrays.copyOf(buffer, read))).block(SEND_TIMEOUT); + } + } + } catch (RuntimeException error) { + fail(error); + } + } + + private void playback() { + try { + while (running.get()) { + byte[] pcm = playback.take(); + if (pcm == STOP) { + break; + } + synchronized (playback) { + queuedPlaybackBytes -= pcm.length; + } + speaker.write(pcm, 0, pcm.length); + } + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + fail(error); + } catch (RuntimeException error) { + fail(error); + } + } + + void queueAudio(byte[] pcm) { + if (pcm == null || pcm.length == 0) { + return; + } + if (pcm.length % 2 != 0) { + fail(new IllegalArgumentException("PCM16 audio must contain complete two-byte samples.")); + return; + } + synchronized (playback) { + if (!running.get()) { + return; + } + if (pcm.length <= MAX_PLAYBACK_BYTES - queuedPlaybackBytes && playback.offer(pcm)) { + queuedPlaybackBytes += pcm.length; + return; + } + } + fail(new IllegalStateException("Audio playback backlog exceeded 60 seconds.")); + } + + private void fail(Throwable error) { + if (running.compareAndSet(true, false)) { + failure.tryEmitError(error); + } + } + + synchronized void skipPendingAudio() { + clearPlayback(); + if (speaker != null) { + speaker.flush(); + } + } + + @Override + public synchronized void close() { + if (closed) { + return; + } + closed = true; + running.set(false); + clearPlayback(); + playback.offer(STOP); + try { + closeLine(microphone); + } finally { + try { + closeLine(speaker); + } finally { + if (captureThread != null) { + captureThread.interrupt(); + } + if (playbackThread != null) { + playbackThread.interrupt(); + } + join(captureThread); + join(playbackThread); + } + } + } + + private void clearPlayback() { + synchronized (playback) { + byte[] discarded; + while ((discarded = playback.poll()) != null) { + queuedPlaybackBytes -= discarded.length; + } + } + } + + private static void closeLine(javax.sound.sampled.DataLine line) { + if (line != null) { + try { + line.stop(); + } finally { + line.close(); + } + } + } + + private static void join(Thread thread) { + if (thread != null && thread != Thread.currentThread()) { + try { + thread.join(SEND_TIMEOUT.toMillis()); + if (thread.isAlive()) { + System.err.println("Audio thread did not stop: " + thread.getName()); + } + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + } + } + } + } +} diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveFunctionToolSample.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveFunctionToolSample.java new file mode 100644 index 0000000000000..4c11e45b37b13 --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveFunctionToolSample.java @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.voice; + +import com.azure.ai.agents.AgentsClient; +import com.azure.ai.agents.AgentsClientBuilder; +import com.azure.ai.agents.BetaVoiceAgentWebSocketClient; +import com.azure.ai.agents.VoiceAgentWebSocketSessionClient; +import com.azure.ai.agents.models.CreateAgentVersionInput; +import com.azure.ai.agents.models.RealtimeConversationItem; +import com.azure.ai.agents.models.RealtimeConversationItemType; +import com.azure.ai.agents.models.RealtimeServerEvent; +import com.azure.ai.agents.models.RealtimeServerEventError; +import com.azure.ai.agents.models.RealtimeServerEventResponseDone; +import com.azure.ai.agents.models.RealtimeServerEventResponseFunctionCallArgumentsDone; +import com.azure.ai.agents.models.RealtimeServerEventResponseTextDone; +import com.azure.ai.agents.models.VoiceAgentDefinition; +import com.azure.ai.agents.models.VoiceAgentFunctionTool; +import com.azure.ai.agents.models.VoiceAgentTool; +import com.azure.ai.agents.models.VoiceModelType; +import com.azure.ai.agents.models.VoiceOutputModality; +import com.azure.core.util.BinaryData; +import com.azure.core.util.Configuration; +import com.azure.identity.DefaultAzureCredentialBuilder; + +import java.time.Duration; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * Demonstrates executing a client-side function tool during a live voice-agent session. + * + *

Before running the sample, set these environment variables:

+ *
    + *
  • {@code FOUNDRY_PROJECT_ENDPOINT} - The Azure AI Project endpoint.
  • + *
  • {@code FOUNDRY_VOICE_AGENT_NAME} - Optional. The voice agent name. Defaults to + * {@code sample-voice-agent-function-tool-java}.
  • + *
  • {@code FOUNDRY_VOICE_MODEL} - Optional. The voice model. Defaults to {@code gpt-realtime}.
  • + *
  • {@code FOUNDRY_VOICE_MODEL_TYPE} - Optional. The voice model type. Defaults to {@code managed}.
  • + *
+ */ +public class VoiceAgentLiveFunctionToolSample { + private static final Duration RESPONSE_TIMEOUT = Duration.ofSeconds(45); + + public static void main(String[] args) { + Configuration configuration = Configuration.getGlobalConfiguration(); + String endpoint = configuration.get("FOUNDRY_PROJECT_ENDPOINT"); + String agentName = configuration.get("FOUNDRY_VOICE_AGENT_NAME", + "sample-voice-agent-function-tool-java"); + String model = configuration.get("FOUNDRY_VOICE_MODEL", "gpt-realtime"); + VoiceModelType modelType = VoiceModelType.fromString(configuration.get( + "FOUNDRY_VOICE_MODEL_TYPE", VoiceModelType.MANAGED.toString())); + + AgentsClientBuilder builder = new AgentsClientBuilder() + .credential(new DefaultAzureCredentialBuilder().build()) + .endpoint(endpoint) + .allowPreview(true); + AgentsClient agents = builder.buildAgentsClient(); + BetaVoiceAgentWebSocketClient realtime = builder.beta().buildBetaVoiceAgentWebSocketClient(); + + Map cityProperty = new LinkedHashMap<>(); + cityProperty.put("type", "string"); + cityProperty.put("description", "City name, for example Seattle."); + Map properties = new LinkedHashMap<>(); + properties.put("city", cityProperty); + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", properties); + schema.put("required", Collections.singletonList("city")); + + VoiceAgentFunctionTool weatherTool = new VoiceAgentFunctionTool("get_weather") + .setDescription("Get the current weather for a city.") + .setParameters(BinaryData.fromObject(schema)); + VoiceAgentDefinition definition = new VoiceAgentDefinition() + .setModelType(modelType) + .setModel(model) + .setInstructions("Use the get_weather tool when asked about weather, then answer using its result.") + .setOutputModalities(Collections.singletonList(VoiceOutputModality.TEXT)) + .setTools(Collections.singletonList(weatherTool)); + + try { + agents.createAgentVersion(agentName, new CreateAgentVersionInput(definition)); + System.out.println("Created voice agent: " + agentName); + try (VoiceAgentWebSocketSessionClient session = realtime.connect(agentName)) { + ExecutorService receiver = Executors.newSingleThreadExecutor(); + Future response = receiver.submit(() -> receiveResponse(session)); + try { + session.sendText("What's the weather like in Seattle right now?"); + session.createResponse(); + response.get(RESPONSE_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + } catch (TimeoutException error) { + System.out.println("Timed out waiting for the agent's reply; cancelling the active response."); + session.cancelResponse(); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + } catch (java.util.concurrent.ExecutionException error) { + throw new IllegalStateException("The realtime receive loop failed.", error.getCause()); + } finally { + response.cancel(true); + receiver.shutdownNow(); + } + } + } finally { + agents.deleteAgent(agentName); + System.out.println("Deleted voice agent: " + agentName); + } + } + + private static void receiveResponse(VoiceAgentWebSocketSessionClient session) { + for (RealtimeServerEvent event : session.receiveEvents()) { + if (event instanceof RealtimeServerEventResponseFunctionCallArgumentsDone) { + RealtimeServerEventResponseFunctionCallArgumentsDone call + = (RealtimeServerEventResponseFunctionCallArgumentsDone) event; + session.sendFunctionCallOutput(call.getCallId(), executeTool(call)); + } else if (event instanceof RealtimeServerEventResponseTextDone) { + System.out.println("Agent: " + ((RealtimeServerEventResponseTextDone) event).getText()); + } else if (event instanceof RealtimeServerEventResponseDone) { + if (!containsFunctionCall((RealtimeServerEventResponseDone) event)) { + return; + } + } else if (event instanceof RealtimeServerEventError) { + RealtimeServerEventError error + = (RealtimeServerEventError) event; + System.out.println("Session error: " + error.getError().getMessage()); + return; + } + } + } + + @SuppressWarnings("unchecked") + private static String executeTool(RealtimeServerEventResponseFunctionCallArgumentsDone call) { + Map arguments = BinaryData.fromString(call.getArguments()).toObject(Map.class); + System.out.printf("Tool call: %s(%s)%n", call.getName(), arguments); + Map result = new LinkedHashMap<>(); + if ("get_weather".equals(call.getName())) { + result.put("city", arguments.get("city")); + result.put("condition", "sunny"); + result.put("temperature_f", 72); + } else { + result.put("error", "Unknown tool: " + call.getName()); + } + return BinaryData.fromObject(result).toString(); + } + + private static boolean containsFunctionCall(RealtimeServerEventResponseDone event) { + List output = event.getResponse().getOutput(); + if (output == null) { + return false; + } + for (RealtimeConversationItem item : output) { + if (item.getType() == RealtimeConversationItemType.FUNCTION_CALL) { + return true; + } + } + return false; + } +} diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveTextConversationAsyncSample.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveTextConversationAsyncSample.java new file mode 100644 index 0000000000000..9e56761dac38b --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveTextConversationAsyncSample.java @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.voice; + +import com.azure.ai.agents.AgentsAsyncClient; +import com.azure.ai.agents.AgentsClientBuilder; +import com.azure.ai.agents.BetaVoiceAgentsConversationsAsyncClient; +import com.azure.ai.agents.BetaAgentsAsyncClient; +import com.azure.ai.agents.BetaVoiceAgentWebSocketAsyncClient; +import com.azure.ai.agents.VoiceAgentWebSocketSessionAsyncClient; +import com.azure.ai.agents.models.CreateAgentVersionInput; +import com.azure.ai.agents.models.VoiceAgentDefinition; +import com.azure.core.util.BinaryData; +import com.azure.core.util.Configuration; +import com.azure.identity.DefaultAzureCredentialBuilder; +import reactor.core.Disposable; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.core.scheduler.Schedulers; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Scanner; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Demonstrates an asynchronous, typed, multi-turn realtime conversation with a persisted voice agent. + * + *

To end the call, submit a blank line or enter {@code exit} or {@code quit}. The sample then closes the WebSocket, + * reads the persisted conversation, and deletes the agent unless {@code FOUNDRY_KEEP_VOICE_AGENT} is set to + * {@code true}.

+ * + *

Before running the sample, set these environment variables:

+ *
    + *
  • {@code FOUNDRY_PROJECT_ENDPOINT} - The Azure AI Project endpoint.
  • + *
  • {@code FOUNDRY_VOICE_AGENT_NAME} - Optional. The voice agent name. Defaults to + * {@code sample-live-text-conversation-agent-async-java}.
  • + *
  • {@code FOUNDRY_KEEP_VOICE_AGENT} - Optional. Set to {@code true} to keep the agent after the sample. + * Defaults to {@code false}.
  • + *
+ */ +public class VoiceAgentLiveTextConversationAsyncSample { + private static final Duration RESPONSE_TIMEOUT = Duration.ofSeconds(45); + + public static void main(String[] args) { + Configuration configuration = Configuration.getGlobalConfiguration(); + String endpoint = configuration.get("FOUNDRY_PROJECT_ENDPOINT"); + String agentName = configuration.get("FOUNDRY_VOICE_AGENT_NAME", + "sample-live-text-conversation-agent-async-java"); + boolean keepAgent + = Boolean.parseBoolean(configuration.get("FOUNDRY_KEEP_VOICE_AGENT", "false")); + + AgentsClientBuilder builder = new AgentsClientBuilder() + .credential(new DefaultAzureCredentialBuilder().build()) + .endpoint(endpoint) + .allowPreview(true); + AgentsAsyncClient agents = builder.buildAgentsAsyncClient(); + BetaAgentsAsyncClient betaAgents = builder.beta().buildBetaAgentsAsyncClient(); + BetaVoiceAgentWebSocketAsyncClient realtime = builder.beta().buildBetaVoiceAgentWebSocketAsyncClient(); + BetaVoiceAgentsConversationsAsyncClient conversations + = builder.beta().buildBetaVoiceAgentsConversationsAsyncClient(); + + Map request = new LinkedHashMap<>(); + request.put("kind", "voice"); + request.put("name", agentName); + + AtomicReference conversationId = new AtomicReference<>(); + VoiceAgentRealtimeSampleUtils.SpeakerPlayer player = new VoiceAgentRealtimeSampleUtils.SpeakerPlayer(); + Scanner scanner = new Scanner(System.in); + + betaAgents.createAgentFromPrompt(BinaryData.fromObject(request)) + .flatMap(generated -> { + VoiceAgentDefinition definition + = (VoiceAgentDefinition) generated.getVersions().getLatest().getDefinition(); + return agents.createAgentVersion(agentName, + new CreateAgentVersionInput(definition.setStore(true))); + }) + .then(Mono.usingWhen(realtime.connect(agentName), + session -> runConversation(session, scanner, conversationId, player), + VoiceAgentWebSocketSessionAsyncClient::closeAsync, + (session, error) -> session.closeAsync(), + VoiceAgentWebSocketSessionAsyncClient::closeAsync)) + .then(Mono.defer(() -> conversationId.get() == null + ? Mono.fromRunnable(() -> System.out.println("No persisted conversation ID was returned.")) + : VoiceAgentRealtimeSampleUtils.readConversation(conversations, agentName, conversationId.get()))) + .then(Mono.defer(() -> cleanupAgent(agents, agentName, keepAgent))) + .onErrorResume(error -> Mono.defer(() -> cleanupAgent(agents, agentName, keepAgent)) + .onErrorResume(cleanupError -> Mono.empty()) + .then(Mono.error(error))) + .doFinally(signal -> { + scanner.close(); + player.close(); + }) + .block(); + } + + private static Mono cleanupAgent(AgentsAsyncClient agents, String agentName, boolean keepAgent) { + if (keepAgent) { + return Mono.fromRunnable(() -> System.out.println("Kept voice agent: " + agentName)); + } + return agents.deleteAgent(agentName) + .doOnSuccess(ignored -> System.out.println("Deleted voice agent: " + agentName)); + } + + private static Mono runConversation(VoiceAgentWebSocketSessionAsyncClient session, Scanner scanner, + AtomicReference conversationId, VoiceAgentRealtimeSampleUtils.SpeakerPlayer player) { + AtomicReference> responseCompleted = new AtomicReference<>(); + Disposable receiver = session.receiveEvents().subscribe(event -> { + if (VoiceAgentRealtimeSampleUtils.handleResponseEvent(event, conversationId, player)) { + Sinks.One completion = responseCompleted.getAndSet(null); + if (completion != null) { + completion.tryEmitEmpty(); + } + } + }, error -> { + Sinks.One completion = responseCompleted.getAndSet(null); + if (completion != null) { + completion.tryEmitError(error); + } + }); + + System.out.println("Type a message and press Enter. Blank line (or 'exit') ends the session."); + return prompt(session, scanner, responseCompleted) + .doFinally(signal -> { + receiver.dispose(); + System.out.printf("(received %.2fs of reply audio%s)%n", player.getSecondsReceived(), + player.isEnabled() ? " and played it" : ""); + }); + } + + private static Mono prompt(VoiceAgentWebSocketSessionAsyncClient session, Scanner scanner, + AtomicReference> responseCompleted) { + return Mono.fromCallable(() -> { + System.out.print("You: "); + return scanner.nextLine().trim(); + }).subscribeOn(Schedulers.boundedElastic()).flatMap(text -> { + String normalized = text.toLowerCase(Locale.ROOT); + if (text.isEmpty() || "exit".equals(normalized) || "quit".equals(normalized)) { + return Mono.empty(); + } + + Sinks.One completion = Sinks.one(); + responseCompleted.set(completion); + return session.sendText(text) + .then(session.createResponse()) + .then(completion.asMono().timeout(RESPONSE_TIMEOUT)) + .onErrorResume(java.util.concurrent.TimeoutException.class, error -> { + System.out.println("Timed out waiting for the agent's reply; cancelling the active response."); + return session.cancelResponse() + .then(completion.asMono().timeout(Duration.ofSeconds(10))) + .onErrorMap(cancelError -> new IllegalStateException( + "Unable to cancel the active response.", cancelError)); + }) + .then(Mono.defer(() -> prompt(session, scanner, responseCompleted))); + }); + } +} diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveTextConversationSample.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveTextConversationSample.java new file mode 100644 index 0000000000000..ba8b1c35b964b --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveTextConversationSample.java @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.voice; + +import com.azure.ai.agents.AgentsClient; +import com.azure.ai.agents.AgentsClientBuilder; +import com.azure.ai.agents.BetaVoiceAgentsConversationsClient; +import com.azure.ai.agents.BetaAgentsClient; +import com.azure.ai.agents.BetaVoiceAgentWebSocketClient; +import com.azure.ai.agents.VoiceAgentWebSocketSessionClient; +import com.azure.ai.agents.models.AgentDetails; +import com.azure.ai.agents.models.AgentVersionDetails; +import com.azure.ai.agents.models.CreateAgentVersionInput; +import com.azure.ai.agents.models.RealtimeServerEvent; +import com.azure.ai.agents.models.VoiceAgentDefinition; +import com.azure.core.util.BinaryData; +import com.azure.core.util.Configuration; +import com.azure.identity.DefaultAzureCredentialBuilder; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Scanner; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Demonstrates a synchronous, typed, multi-turn realtime conversation with a persisted voice agent. + * + *

Before running the sample, set these environment variables:

+ *
    + *
  • {@code FOUNDRY_PROJECT_ENDPOINT} - The Azure AI Project endpoint.
  • + *
  • {@code FOUNDRY_VOICE_AGENT_NAME} - Optional. The voice agent name. Defaults to + * {@code sample-live-text-conversation-agent-java}.
  • + *
+ */ +public class VoiceAgentLiveTextConversationSample { + private static final Duration RESPONSE_TIMEOUT = Duration.ofSeconds(45); + + public static void main(String[] args) { + Configuration configuration = Configuration.getGlobalConfiguration(); + String endpoint = configuration.get("FOUNDRY_PROJECT_ENDPOINT"); + String agentName = configuration.get("FOUNDRY_VOICE_AGENT_NAME", + "sample-live-text-conversation-agent-java"); + + AgentsClientBuilder builder = new AgentsClientBuilder() + .credential(new DefaultAzureCredentialBuilder().build()) + .endpoint(endpoint) + .allowPreview(true); + AgentsClient agents = builder.buildAgentsClient(); + BetaAgentsClient betaAgents = builder.beta().buildBetaAgentsClient(); + BetaVoiceAgentWebSocketClient realtime = builder.beta().buildBetaVoiceAgentWebSocketClient(); + BetaVoiceAgentsConversationsClient conversations = builder.beta().buildBetaVoiceAgentsConversationsClient(); + + Map request = new LinkedHashMap<>(); + request.put("kind", "voice"); + request.put("name", agentName); + AgentDetails generated = betaAgents.createAgentFromPrompt(BinaryData.fromObject(request)); + + try { + AgentVersionDetails latest = generated.getVersions().getLatest(); + VoiceAgentDefinition definition = (VoiceAgentDefinition) latest.getDefinition(); + agents.createAgentVersion(agentName, new CreateAgentVersionInput(definition.setStore(true))); + + AtomicReference conversationId = new AtomicReference<>(); + try (VoiceAgentRealtimeSampleUtils.SpeakerPlayer player + = new VoiceAgentRealtimeSampleUtils.SpeakerPlayer(); + VoiceAgentWebSocketSessionClient session = realtime.connect(agentName); + Scanner scanner = new Scanner(System.in)) { + AtomicReference> responseCompleted = new AtomicReference<>(); + ExecutorService receiver = Executors.newSingleThreadExecutor(); + receiver.submit(() -> { + try { + for (RealtimeServerEvent event : session.receiveEvents()) { + if (VoiceAgentRealtimeSampleUtils.handleResponseEvent(event, conversationId, player)) { + CompletableFuture completion = responseCompleted.getAndSet(null); + if (completion != null) { + completion.complete(null); + } + } + } + } catch (RuntimeException error) { + CompletableFuture completion = responseCompleted.getAndSet(null); + if (completion != null) { + completion.completeExceptionally(error); + } + } + }); + + try { + System.out.println("Type a message and press Enter. Blank line (or 'exit') ends the session."); + while (true) { + System.out.print("You: "); + String prompt = scanner.nextLine().trim(); + String normalized = prompt.toLowerCase(Locale.ROOT); + if (prompt.isEmpty() || "exit".equals(normalized) || "quit".equals(normalized)) { + break; + } + + CompletableFuture completion = new CompletableFuture<>(); + responseCompleted.set(completion); + session.sendText(prompt); + session.createResponse(); + if (!awaitResponse(session, completion)) { + break; + } + } + } finally { + receiver.shutdownNow(); + } + System.out.printf("(received %.2fs of reply audio%s)%n", player.getSecondsReceived(), + player.isEnabled() ? " and played it" : ""); + } + + if (conversationId.get() != null) { + VoiceAgentRealtimeSampleUtils.readConversation(conversations, agentName, conversationId.get()); + } else { + System.out.println("No persisted conversation ID was returned."); + } + } finally { + agents.deleteAgent(agentName); + System.out.println("Deleted voice agent: " + agentName); + } + } + + private static boolean awaitResponse(VoiceAgentWebSocketSessionClient session, + CompletableFuture completion) { + try { + completion.get(RESPONSE_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + return true; + } catch (TimeoutException error) { + System.out.println("Timed out waiting for the agent's reply; cancelling the active response."); + session.cancelResponse(); + try { + completion.get(10, TimeUnit.SECONDS); + return true; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return false; + } catch (ExecutionException | TimeoutException ignored) { + return false; + } + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + return false; + } catch (ExecutionException error) { + throw new IllegalStateException("The realtime receive loop failed.", error.getCause()); + } + } +} diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentReadConversationAudioSample.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentReadConversationAudioSample.java new file mode 100644 index 0000000000000..94a9cf318437e --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentReadConversationAudioSample.java @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.voice; + +import com.azure.ai.agents.BetaVoiceAgentsConversationsClient; +import com.azure.ai.agents.AgentsClientBuilder; +import com.azure.ai.agents.models.VoiceAudioItemResponse; +import com.azure.ai.agents.models.VoiceRecordingResponse; +import com.azure.core.exception.ResourceNotFoundException; +import com.azure.core.http.rest.RequestOptions; +import com.azure.core.util.BinaryData; +import com.azure.core.util.Configuration; +import com.azure.identity.DefaultAzureCredentialBuilder; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +/** + * Demonstrates downloading whole-call and item-level audio from a persisted voice conversation. + * + *

Before running the sample, set these environment variables:

+ *
    + *
  • {@code FOUNDRY_PROJECT_ENDPOINT} - The Azure AI Project endpoint.
  • + *
  • {@code FOUNDRY_VOICE_AGENT_NAME} - The voice agent name.
  • + *
  • {@code FOUNDRY_VOICE_CONVERSATION_ID} - The persisted voice conversation ID.
  • + *
+ */ +public class VoiceAgentReadConversationAudioSample { + public static void main(String[] args) throws IOException { + Configuration configuration = Configuration.getGlobalConfiguration(); + String endpoint = configuration.get("FOUNDRY_PROJECT_ENDPOINT"); + String agentName = configuration.get("FOUNDRY_VOICE_AGENT_NAME"); + String conversationId = configuration.get("FOUNDRY_VOICE_CONVERSATION_ID"); + BetaVoiceAgentsConversationsClient conversations = new AgentsClientBuilder() + .credential(new DefaultAzureCredentialBuilder().build()) + .endpoint(endpoint) + .beta() + .buildBetaVoiceAgentsConversationsClient(); + + VoiceRecordingResponse recording = conversations.getAgentConversationAudio(agentName, conversationId); + System.out.printf("Recording: format=%s, rate=%d, channels=%d, duration=%s%n", + recording.getFormat(), recording.getSampleRate(), recording.getChannels(), recording.getDurationMs()); + if (recording.getBlobUri() != null) { + System.out.println("Recording is stored in customer storage: " + recording.getBlobUri()); + } else { + Path output = Files.createTempFile(conversationId + "-", ".wav"); + Files.write(output, conversations.downloadAgentConversationAudio(agentName, conversationId).toBytes()); + System.out.println("Wrote merged recording: " + output); + } + + for (BinaryData itemData : conversations.listAgentConversationItems(agentName, conversationId, + new RequestOptions())) { + @SuppressWarnings("unchecked") + Map item = itemData.toObject(Map.class); + String itemId = (String) item.get("id"); + if (itemId == null) { + continue; + } + try { + VoiceAudioItemResponse metadata = conversations.getAgentConversationAudioItem( + agentName, conversationId, itemId); + if (metadata.getBlobUri() != null) { + System.out.println("Item audio is stored in customer storage: " + metadata.getBlobUri()); + } else { + Path output = Files.createTempFile(conversationId + "-" + itemId + "-", ".wav"); + Files.write(output, conversations.downloadAgentConversationAudioItem( + agentName, conversationId, itemId).toBytes()); + System.out.println("Wrote item audio: " + output); + } + break; + } catch (ResourceNotFoundException ignored) { + // This transcript item has no persisted audio. + } + } + } +} diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentReadConversationSample.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentReadConversationSample.java new file mode 100644 index 0000000000000..86ce493b83411 --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentReadConversationSample.java @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.voice; + +import com.azure.ai.agents.BetaVoiceAgentsConversationsClient; +import com.azure.ai.agents.AgentsClientBuilder; +import com.azure.ai.agents.models.RealtimeConversationItem; +import com.azure.ai.agents.models.VoiceConversation; +import com.azure.ai.agents.models.VoiceResponse; +import com.azure.core.http.rest.RequestOptions; +import com.azure.core.util.BinaryData; +import com.azure.core.util.Configuration; +import com.azure.identity.DefaultAzureCredentialBuilder; + +import java.util.Map; + +/** + * Demonstrates reading a persisted voice conversation, its responses, and transcript items. + * + *

Before running the sample, set these environment variables:

+ *
    + *
  • {@code FOUNDRY_PROJECT_ENDPOINT} - The Azure AI Project endpoint.
  • + *
  • {@code FOUNDRY_VOICE_AGENT_NAME} - The voice agent name.
  • + *
  • {@code FOUNDRY_VOICE_CONVERSATION_ID} - The persisted voice conversation ID.
  • + *
+ */ +public class VoiceAgentReadConversationSample { + public static void main(String[] args) { + Configuration configuration = Configuration.getGlobalConfiguration(); + String endpoint = configuration.get("FOUNDRY_PROJECT_ENDPOINT"); + String agentName = configuration.get("FOUNDRY_VOICE_AGENT_NAME"); + String conversationId = configuration.get("FOUNDRY_VOICE_CONVERSATION_ID"); + BetaVoiceAgentsConversationsClient conversations = new AgentsClientBuilder() + .credential(new DefaultAzureCredentialBuilder().build()) + .endpoint(endpoint) + .beta() + .buildBetaVoiceAgentsConversationsClient(); + + VoiceConversation conversation = conversations.getAgentConversation(agentName, conversationId); + System.out.printf("Conversation %s: status=%s, created=%s, usage=%s%n", + conversation.getId(), conversation.getStatus(), conversation.getCreatedAt(), conversation.getUsage()); + + for (VoiceResponse response : conversations.listAgentConversationResponses(agentName, conversationId)) { + VoiceResponse detail = conversations.getAgentConversationResponse(agentName, conversationId, + response.getId()); + System.out.printf("Response %s: status=%s, usage=%s%n", + detail.getId(), detail.getStatus(), detail.getUsage()); + for (RealtimeConversationItem item : conversations.listAgentConversationResponseItems( + agentName, conversationId, response.getId())) { + System.out.println(" Response item type: " + item.getType()); + } + } + + for (BinaryData itemData : conversations.listAgentConversationItems(agentName, conversationId, + new RequestOptions())) { + @SuppressWarnings("unchecked") + Map item = itemData.toObject(Map.class); + String itemId = (String) item.get("id"); + System.out.printf("Transcript item: type=%s, id=%s%n", item.get("type"), itemId); + if (itemId != null) { + RealtimeConversationItem fetched = conversations.getAgentConversationItem(agentName, + conversationId, itemId); + System.out.println(" Fetched item type: " + fetched.getType()); + } + } + } +} diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentRealtimeSampleUtils.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentRealtimeSampleUtils.java new file mode 100644 index 0000000000000..7b9ea6ca8c731 --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentRealtimeSampleUtils.java @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.voice; + +import com.azure.ai.agents.BetaVoiceAgentsConversationsAsyncClient; +import com.azure.ai.agents.BetaVoiceAgentsConversationsClient; +import com.azure.ai.agents.models.RealtimeServerEvent; +import com.azure.ai.agents.models.RealtimeServerEventError; +import com.azure.ai.agents.models.RealtimeServerEventResponseAudioDelta; +import com.azure.ai.agents.models.RealtimeServerEventResponseAudioTranscriptDone; +import com.azure.ai.agents.models.RealtimeServerEventResponseDone; +import com.azure.ai.agents.models.RealtimeServerEventSessionCreated; +import com.azure.ai.agents.models.VoiceConversation; +import com.azure.core.http.rest.RequestOptions; +import com.azure.core.util.BinaryData; +import reactor.core.publisher.Mono; + +import javax.sound.sampled.AudioFormat; +import javax.sound.sampled.AudioSystem; +import javax.sound.sampled.LineUnavailableException; +import javax.sound.sampled.SourceDataLine; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +final class VoiceAgentRealtimeSampleUtils { + static final int SAMPLE_RATE = 24000; + + private VoiceAgentRealtimeSampleUtils() { + } + + static boolean handleResponseEvent(RealtimeServerEvent event, AtomicReference conversationId, + SpeakerPlayer player) { + if (event instanceof RealtimeServerEventSessionCreated) { + String id = ((RealtimeServerEventSessionCreated) event).getConversationId(); + if (id != null) { + conversationId.set(id); + } + } else if (event instanceof RealtimeServerEventResponseAudioDelta) { + player.play(((RealtimeServerEventResponseAudioDelta) event).getDelta()); + } else if (event instanceof RealtimeServerEventResponseAudioTranscriptDone) { + System.out.println("Agent: " + + ((RealtimeServerEventResponseAudioTranscriptDone) event).getTranscript()); + } else if (event instanceof RealtimeServerEventError) { + RealtimeServerEventError error + = (RealtimeServerEventError) event; + System.out.println("Session error: " + error.getError().getMessage()); + return true; + } + return event instanceof RealtimeServerEventResponseDone; + } + + static void readConversation(BetaVoiceAgentsConversationsClient conversations, String agentName, + String conversationId) { + VoiceConversation conversation = conversations.getAgentConversation(agentName, conversationId); + System.out.printf("Conversation %s: status=%s, created=%s%n", conversation.getId(), + conversation.getStatus(), conversation.getCreatedAt()); + for (BinaryData item : conversations.listAgentConversationItems(agentName, conversationId, + new RequestOptions())) { + printConversationItem(item); + } + } + + static Mono readConversation(BetaVoiceAgentsConversationsAsyncClient conversations, String agentName, + String conversationId) { + return conversations.getAgentConversation(agentName, conversationId) + .doOnNext(conversation -> System.out.printf("Conversation %s: status=%s, created=%s%n", + conversation.getId(), conversation.getStatus(), conversation.getCreatedAt())) + .thenMany(conversations.listAgentConversationItems(agentName, conversationId, new RequestOptions())) + .doOnNext(VoiceAgentRealtimeSampleUtils::printConversationItem) + .then(); + } + + @SuppressWarnings("unchecked") + private static void printConversationItem(BinaryData itemData) { + Map item = itemData.toObject(Map.class); + System.out.printf(" - %s id=%s%n", item.get("role") == null ? item.get("type") : item.get("role"), + item.get("id")); + Object contentValue = item.get("content"); + if (!(contentValue instanceof List)) { + return; + } + StringBuilder transcript = new StringBuilder(); + for (Object partValue : (List) contentValue) { + if (partValue instanceof Map) { + Map part = (Map) partValue; + Object text = part.get("transcript") == null ? part.get("text") : part.get("transcript"); + if (text != null && !text.toString().trim().isEmpty()) { + if (transcript.length() > 0) { + transcript.append(' '); + } + transcript.append(text.toString().trim()); + } + } + } + if (transcript.length() > 0) { + System.out.println(" " + transcript); + } + } + + static final class SpeakerPlayer implements AutoCloseable { + private SourceDataLine line; + private long bytesReceived; + + SpeakerPlayer() { + AudioFormat format = new AudioFormat(SAMPLE_RATE, 16, 1, true, false); + try { + line = AudioSystem.getSourceDataLine(format); + line.open(format); + line.start(); + } catch (LineUnavailableException | IllegalArgumentException error) { + line = null; + System.out.println("(speaker playback unavailable; audio will be counted but not played)"); + } + } + + synchronized void play(byte[] pcm) { + if (pcm == null) { + return; + } + bytesReceived += pcm.length; + if (line != null) { + line.write(pcm, 0, pcm.length); + } + } + + synchronized void discardQueuedAudio() { + if (line != null) { + line.flush(); + } + } + + double getSecondsReceived() { + return bytesReceived / 2.0 / SAMPLE_RATE; + } + + boolean isEnabled() { + return line != null; + } + + @Override + public synchronized void close() { + if (line != null) { + line.drain(); + line.stop(); + line.close(); + line = null; + } + } + } +} diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentSampleUtils.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentSampleUtils.java new file mode 100644 index 0000000000000..5f7a8da6ddf82 --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentSampleUtils.java @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.voice; + +import com.azure.ai.agents.models.VoiceAgentAudioConfig; +import com.azure.ai.agents.models.VoiceAgentAudioOutputConfig; +import com.azure.ai.agents.models.VoiceAgentDefinition; +import com.azure.ai.agents.models.VoiceModelType; +import com.azure.ai.agents.models.VoiceOutputModality; +import com.azure.ai.agents.models.VoiceType; + +import java.util.Collections; + +final class VoiceAgentSampleUtils { + private VoiceAgentSampleUtils() { + } + + static VoiceAgentDefinition createDefinition(VoiceModelType modelType, String model, String instructions) { + VoiceAgentAudioOutputConfig output = new VoiceAgentAudioOutputConfig() + .setVoice("en-US-AvaNeural") + .setVoiceType(VoiceType.AZURE_STANDARD); + return new VoiceAgentDefinition() + .setModelType(modelType) + .setModel(model) + .setInstructions(instructions) + .setAudio(new VoiceAgentAudioConfig().setOutput(output)) + .setOutputModalities(Collections.singletonList(VoiceOutputModality.AUDIO)) + .setStore(true); + } +} diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentVersionsSample.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentVersionsSample.java new file mode 100644 index 0000000000000..724b553e30756 --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentVersionsSample.java @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.voice; + +import com.azure.ai.agents.AgentsClient; +import com.azure.ai.agents.AgentsClientBuilder; +import com.azure.core.util.Configuration; +import com.azure.identity.DefaultAzureCredentialBuilder; +import com.azure.ai.agents.models.AgentVersionDetails; +import com.azure.ai.agents.models.CreateAgentVersionInput; +import com.azure.ai.agents.models.VoiceModelType; + +/** + * Demonstrates released and draft voice-agent versions. + * + *

Before running the sample, set these environment variables:

+ *
    + *
  • {@code FOUNDRY_PROJECT_ENDPOINT} - The Azure AI Project endpoint.
  • + *
  • {@code FOUNDRY_VOICE_MODEL} - Optional. The voice model or deployment name. Defaults to {@code gpt-realtime}.
  • + *
  • {@code FOUNDRY_VOICE_MODEL_TYPE} - Optional. The voice model type. Defaults to {@code managed}.
  • + *
  • {@code FOUNDRY_VOICE_AGENT_NAME} - The voice agent name. Defaults to {@code versioned-voice-agent-java}.
  • + *
+ */ +public class VoiceAgentVersionsSample { + public static void main(String[] args) { + Configuration configuration = Configuration.getGlobalConfiguration(); + String endpoint = configuration.get("FOUNDRY_PROJECT_ENDPOINT"); + String model = configuration.get("FOUNDRY_VOICE_MODEL", "gpt-realtime"); + VoiceModelType modelType = VoiceModelType.fromString(configuration.get( + "FOUNDRY_VOICE_MODEL_TYPE", VoiceModelType.MANAGED.toString())); + String agentName = configuration.get("FOUNDRY_VOICE_AGENT_NAME", "versioned-voice-agent-java"); + + AgentsClient client = new AgentsClientBuilder() + .credential(new DefaultAzureCredentialBuilder().build()) + .endpoint(endpoint) + .allowPreview(true) + .buildAgentsClient(); + try { + AgentVersionDetails first = client.createAgentVersion(agentName, + new CreateAgentVersionInput(VoiceAgentSampleUtils.createDefinition(modelType, model, + "You are a helpful voice assistant."))); + AgentVersionDetails released = client.createAgentVersion(agentName, + new CreateAgentVersionInput(VoiceAgentSampleUtils.createDefinition(modelType, model, + "You are a helpful voice assistant. Greet the caller by name.")) + .setDescription("Added a personalized greeting.")); + AgentVersionDetails draft = client.createAgentVersion(agentName, + new CreateAgentVersionInput(VoiceAgentSampleUtils.createDefinition(modelType, model, + "You are an experimental voice assistant.")) + .setDescription("Candidate persona under review.") + .setDraft(true)); + System.out.printf("Created versions %s, %s and draft %s%n", + first.getVersion(), released.getVersion(), draft.getVersion()); + + System.out.println("Released versions:"); + for (AgentVersionDetails version : client.listAgentVersions(agentName)) { + System.out.printf(" %s (draft=%s)%n", version.getVersion(), version.isDraft()); + } + System.out.println("All versions including drafts:"); + for (AgentVersionDetails version : client.listAgentVersions(agentName, null, null, null, null, true)) { + System.out.printf(" %s (draft=%s)%n", version.getVersion(), version.isDraft()); + } + AgentVersionDetails fetched = client.getAgentVersionDetails(agentName, released.getVersion()); + System.out.println("Fetched version: " + fetched.getVersion()); + } finally { + client.deleteAgent(agentName); + System.out.println("Deleted agent: " + agentName); + } + } +} diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentWithToolsSample.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentWithToolsSample.java new file mode 100644 index 0000000000000..c4b2987c3a001 --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentWithToolsSample.java @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.voice; + +import com.azure.ai.agents.AgentsClient; +import com.azure.ai.agents.AgentsClientBuilder; +import com.azure.core.util.BinaryData; +import com.azure.core.util.Configuration; +import com.azure.identity.DefaultAzureCredentialBuilder; +import com.azure.ai.agents.models.AgentVersionDetails; +import com.azure.ai.agents.models.CreateAgentVersionInput; +import com.azure.ai.agents.models.RealtimeAudioFormatsAudioPcm; +import com.azure.ai.agents.models.RealtimeAudioFormatsAudioPcmRate; +import com.azure.ai.agents.models.VoiceAgentAudioConfig; +import com.azure.ai.agents.models.VoiceAgentAudioInputConfig; +import com.azure.ai.agents.models.VoiceAgentAudioOutputConfig; +import com.azure.ai.agents.models.VoiceAgentDefinition; +import com.azure.ai.agents.models.VoiceAgentFunctionTool; +import com.azure.ai.agents.models.VoiceAgentInputTranscription; +import com.azure.ai.agents.models.VoiceAgentInputTranscriptionModel; +import com.azure.ai.agents.models.VoiceAgentServerVadTurnDetection; +import com.azure.ai.agents.models.VoiceAgentEndConversationSystemTool; +import com.azure.ai.agents.models.VoiceAgentTool; +import com.azure.ai.agents.models.VoiceOutputModality; +import com.azure.ai.agents.models.VoiceType; +import com.azure.ai.agents.models.VoiceModelType; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Demonstrates a voice-agent definition with audio processing, transcription, and tools. + * + *

Before running the sample, set these environment variables:

+ *
    + *
  • {@code FOUNDRY_PROJECT_ENDPOINT} - The Azure AI Project endpoint.
  • + *
  • {@code FOUNDRY_VOICE_MODEL} - Optional. The voice model or deployment name. Defaults to {@code gpt-realtime}.
  • + *
  • {@code FOUNDRY_VOICE_MODEL_TYPE} - Optional. The voice model type. Defaults to {@code managed}.
  • + *
  • {@code FOUNDRY_VOICE_AGENT_NAME} - The voice agent name. Defaults to {@code voice-agent-with-tools-java}.
  • + *
+ */ +public class VoiceAgentWithToolsSample { + public static void main(String[] args) { + Configuration configuration = Configuration.getGlobalConfiguration(); + String endpoint = configuration.get("FOUNDRY_PROJECT_ENDPOINT"); + String model = configuration.get("FOUNDRY_VOICE_MODEL", "gpt-realtime"); + VoiceModelType modelType = VoiceModelType.fromString(configuration.get( + "FOUNDRY_VOICE_MODEL_TYPE", VoiceModelType.MANAGED.toString())); + String agentName = configuration.get("FOUNDRY_VOICE_AGENT_NAME", "voice-agent-with-tools-java"); + + AgentsClient client = new AgentsClientBuilder() + .credential(new DefaultAzureCredentialBuilder().build()) + .endpoint(endpoint) + .allowPreview(true) + .buildAgentsClient(); + + RealtimeAudioFormatsAudioPcm pcm = new RealtimeAudioFormatsAudioPcm() + .setRate(RealtimeAudioFormatsAudioPcmRate.TWO_FOUR_ZERO_ZERO_ZERO); + VoiceAgentAudioInputConfig input = new VoiceAgentAudioInputConfig() + .setFormat(pcm) + .setTurnDetection(new VoiceAgentServerVadTurnDetection() + .setThreshold(0.5) + .setPrefixPaddingMs(300L) + .setSilenceDurationMs(500L)) + .setTranscription(new VoiceAgentInputTranscription(VoiceAgentInputTranscriptionModel.WHISPER_1)); + VoiceAgentAudioOutputConfig output = new VoiceAgentAudioOutputConfig() + .setVoice("en-US-AvaNeural") + .setVoiceType(VoiceType.AZURE_STANDARD); + Map cityProperty = new LinkedHashMap<>(); + cityProperty.put("type", "string"); + cityProperty.put("description", "City name, for example Seattle."); + Map properties = new LinkedHashMap<>(); + properties.put("city", cityProperty); + Map parameters = new LinkedHashMap<>(); + parameters.put("type", "object"); + parameters.put("properties", properties); + parameters.put("required", Collections.singletonList("city")); + VoiceAgentFunctionTool weather = new VoiceAgentFunctionTool("get_weather") + .setDescription("Get the current weather for a city.") + .setParameters(BinaryData.fromObject(parameters)); + VoiceAgentEndConversationSystemTool endCall = new VoiceAgentEndConversationSystemTool(); + VoiceAgentDefinition definition = new VoiceAgentDefinition() + .setModelType(modelType) + .setModel(model) + .setInstructions("Use tools when they help answer the caller.") + .setAudio(new VoiceAgentAudioConfig().setInput(input).setOutput(output)) + .setOutputModalities(Collections.singletonList(VoiceOutputModality.AUDIO)) + .setTools(Arrays.asList(weather, endCall)) + .setStore(true); + + boolean agentCreated = false; + try { + AgentVersionDetails created = client.createAgentVersion(agentName, + new CreateAgentVersionInput(definition)); + agentCreated = true; + AgentVersionDetails fetched = client.getAgentVersionDetails(agentName, created.getVersion()); + VoiceAgentDefinition fetchedDefinition = (VoiceAgentDefinition) fetched.getDefinition(); + System.out.println("Configured voice tools: " + fetchedDefinition.getTools().size()); + for (VoiceAgentTool tool : fetchedDefinition.getTools()) { + System.out.printf(" %s%n", tool.getType()); + } + } finally { + if (agentCreated) { + client.deleteAgent(agentName); + System.out.println("Deleted agent: " + agentName); + } + } + } +} 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..a7091e3acb719 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,13 @@ package com.azure.ai.agents; +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 +25,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 +325,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 +518,16 @@ public void openAIAndResponsesClientsUseCustomPipeline() { } @Test - public void agentScopedOpenAIClientUsesCustomPipelineAndAllowPreviewHeader() { + public void agentScopedOpenAIClientUsesCustomPipelineAndPreviewHeaderByDefault() { RecordingHttpClient httpClient = newOpenAIRecordingHttpClient(); HttpPipeline customPipeline = createCustomPipeline(httpClient); createBuilder(customPipeline).buildAgentScopedOpenAIClient("agent").models().list(); assertEquals(CUSTOM_PIPELINE_VALUE, customPipelineHeader(httpClient)); - assertNull(foundryFeatures(httpClient)); + assertEquals(AGENT_PREVIEW_FEATURES, 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 +538,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 +642,50 @@ 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") + .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..7ec98173a67ba 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,203 @@ 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 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/VoiceAgentDefinitionSerializationTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/models/VoiceAgentDefinitionSerializationTests.java index 1dc171e00caec..39ad80a8bc695 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/models/VoiceAgentDefinitionSerializationTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/models/VoiceAgentDefinitionSerializationTests.java @@ -3,15 +3,24 @@ 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; import com.openai.models.responses.ToolChoiceFunction; import com.openai.models.responses.ToolChoiceMcp; import com.openai.models.responses.ToolChoiceOptions; import org.junit.jupiter.api.Test; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.util.Arrays; +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; public class VoiceAgentDefinitionSerializationTests { @@ -69,4 +78,110 @@ private VoiceAgentDefinition roundTrip(VoiceAgentDefinition value) throws IOExce String json = UnionTypeSerializationTestUtils.serialize(value); return UnionTypeSerializationTestUtils.deserialize(json, VoiceAgentDefinition::fromJson); } + + @Test + public void fullVoiceDefinitionRoundTrips() throws IOException { + RealtimeAudioFormatsAudioPcm pcm + = new RealtimeAudioFormatsAudioPcm().setRate(RealtimeAudioFormatsAudioPcmRate.TWO_FOUR_ZERO_ZERO_ZERO); + VoiceAgentAudioInputConfig input = new VoiceAgentAudioInputConfig().setFormat(pcm) + .setTurnDetection(new VoiceAgentServerVadTurnDetection().setThreshold(0.5) + .setPrefixPaddingMs(300L) + .setSilenceDurationMs(500L)) + .setTranscription(new VoiceAgentInputTranscription(VoiceAgentInputTranscriptionModel.WHISPER_1)); + VoiceAgentAudioOutputConfig output = new VoiceAgentAudioOutputConfig().setFormat(pcm) + .setVoice("en-US-AvaNeural") + .setVoiceType(VoiceType.AZURE_STANDARD); + VoiceAgentFunctionTool functionTool + = new VoiceAgentFunctionTool("get_weather").setDescription("Get weather for a city.") + .setParameters(BinaryData.fromString("{}")); + VoiceAgentSystemTool systemTool = new VoiceAgentEndConversationSystemTool(); + + VoiceAgentDefinition original = new VoiceAgentDefinition().setModelType(VoiceModelType.MANAGED) + .setModel("gpt-realtime") + .setInstructions("Keep replies short and natural.") + .setAudio(new VoiceAgentAudioConfig().setInput(input).setOutput(output)) + .setOutputModalities(Collections.singletonList(VoiceOutputModality.AUDIO)) + .setTools(Arrays.asList(functionTool, systemTool)) + .setStore(true); + + String json = serialize(original); + assertTrue(json.contains("\"kind\":\"voice\"")); + assertTrue(json.contains("\"model_type\":\"managed\"")); + assertTrue(json.contains("\"model\":\"gpt-realtime\"")); + assertTrue(json.contains("\"voice\":\"en-US-AvaNeural\"")); + assertTrue(json.contains("\"voice_type\":\"azure-standard\"")); + assertTrue(json.contains("\"rate\":24000")); + assertTrue(json.contains("\"type\":\"server_vad\"")); + assertTrue(json.contains("\"model\":\"whisper-1\"")); + assertTrue(json.contains("\"output_modalities\":[\"audio\"]")); + assertTrue(json.contains("\"store\":true")); + assertTrue(json.contains("\"name\":\"get_weather\"")); + assertTrue(json.contains("\"name\":\"end_conversation\"")); + + AgentDefinition deserialized; + try (JsonReader reader = JsonProviders.createReader(json)) { + deserialized = AgentDefinition.fromJson(reader); + } + assertInstanceOf(VoiceAgentDefinition.class, deserialized); + VoiceAgentDefinition voice = (VoiceAgentDefinition) deserialized; + assertEquals(AgentKind.VOICE, voice.getKind()); + assertEquals(VoiceModelType.MANAGED, voice.getModelType()); + assertEquals("gpt-realtime", voice.getModel()); + assertEquals("Keep replies short and natural.", voice.getInstructions()); + assertEquals(Boolean.TRUE, voice.isStore()); + assertEquals(VoiceOutputModality.AUDIO, voice.getOutputModalities().get(0)); + + VoiceAgentAudioInputConfig deserializedInput = voice.getAudio().getInput(); + RealtimeAudioFormatsAudioPcm deserializedInputFormat + = assertInstanceOf(RealtimeAudioFormatsAudioPcm.class, deserializedInput.getFormat()); + assertEquals(pcm.getRate(), deserializedInputFormat.getRate()); + VoiceAgentServerVadTurnDetection deserializedVad + = assertInstanceOf(VoiceAgentServerVadTurnDetection.class, deserializedInput.getTurnDetection()); + VoiceAgentServerVadTurnDetection originalVad = (VoiceAgentServerVadTurnDetection) input.getTurnDetection(); + assertEquals(originalVad.getThreshold(), deserializedVad.getThreshold()); + assertEquals(originalVad.getPrefixPaddingMs(), deserializedVad.getPrefixPaddingMs()); + assertEquals(originalVad.getSilenceDurationMs(), deserializedVad.getSilenceDurationMs()); + assertEquals(input.getTranscription().getModel(), deserializedInput.getTranscription().getModel()); + + VoiceAgentAudioOutputConfig deserializedOutput = voice.getAudio().getOutput(); + RealtimeAudioFormatsAudioPcm deserializedOutputFormat + = assertInstanceOf(RealtimeAudioFormatsAudioPcm.class, deserializedOutput.getFormat()); + assertEquals(pcm.getRate(), deserializedOutputFormat.getRate()); + assertEquals(output.getVoice(), deserializedOutput.getVoice()); + assertEquals(output.getVoiceType(), deserializedOutput.getVoiceType()); + + assertEquals(2, voice.getTools().size()); + VoiceAgentFunctionTool deserializedFunction + = assertInstanceOf(VoiceAgentFunctionTool.class, voice.getTools().get(0)); + assertEquals(functionTool.getName(), deserializedFunction.getName()); + assertEquals(functionTool.getDescription(), deserializedFunction.getDescription()); + VoiceAgentEndConversationSystemTool deserializedSystem + = assertInstanceOf(VoiceAgentEndConversationSystemTool.class, voice.getTools().get(1)); + assertEquals(systemTool.getName(), deserializedSystem.getName()); + } + + @Test + public void selfDeployedVoiceDefinitionRoundTrips() throws IOException { + VoiceAgentDefinition original = new VoiceAgentDefinition().setModelType(VoiceModelType.SELF_DEPLOYED) + .setModel("customer-realtime-deployment") + .setInstructions("Use the customer deployment."); + + String json = serialize(original); + VoiceAgentDefinition deserialized; + try (JsonReader reader = JsonProviders.createReader(json)) { + deserialized = VoiceAgentDefinition.fromJson(reader); + } + + assertEquals(VoiceModelType.SELF_DEPLOYED, deserialized.getModelType()); + assertEquals("customer-realtime-deployment", deserialized.getModel()); + assertEquals("Use the customer deployment.", deserialized.getInstructions()); + } + + private static String serialize(VoiceAgentDefinition definition) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (JsonWriter writer = JsonProviders.createWriter(output)) { + definition.toJson(writer); + } + return output.toString("UTF-8"); + } } diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsAsyncTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsAsyncTests.java new file mode 100644 index 0000000000000..432c887e1a9f9 --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsAsyncTests.java @@ -0,0 +1,346 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.voice; + +import com.azure.ai.agents.models.VoiceConversation; +import com.azure.ai.agents.AgentsAsyncClient; +import com.azure.ai.agents.AgentsClientBuilder; +import com.azure.ai.agents.BetaVoiceAgentsConversationsAsyncClient; +import com.azure.ai.agents.VoiceAgentWebSocketSessionAsyncClient; +import com.azure.ai.agents.models.CreateAgentVersionInput; +import com.azure.ai.agents.models.RealtimeServerEventResponseDone; +import com.azure.ai.agents.models.RealtimeServerEventSessionCreated; +import com.azure.ai.agents.models.VoiceAgentAudioConfig; +import com.azure.ai.agents.models.VoiceAgentAudioOutputConfig; +import com.azure.ai.agents.models.VoiceAgentDefinition; +import com.azure.ai.agents.models.VoiceModelType; +import com.azure.ai.agents.models.VoiceOutputModality; +import com.azure.ai.agents.models.VoiceType; +import com.azure.ai.agents.models.VoiceConversationStatus; +import com.azure.ai.agents.models.VoiceAudioItemResponse; +import com.azure.ai.agents.models.VoiceRecordingResponse; +import com.azure.ai.agents.models.VoiceResponse; +import com.azure.core.exception.HttpResponseException; +import com.azure.core.http.HttpClient; +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpHeaders; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.HttpResponse; +import com.azure.core.http.rest.RequestOptions; +import com.azure.core.test.http.MockHttpResponse; +import com.azure.core.test.utils.MockTokenCredential; +import com.azure.core.util.BinaryData; +import com.azure.core.util.Context; +import com.azure.core.util.Configuration; +import com.azure.identity.DefaultAzureCredentialBuilder; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.Collections; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Persisted voice REST coverage, separate from native OpenAI conversation tests. + * Deterministic cases use scripted HTTP responses, not service recordings. The live parity case requires + * AZURE_TEST_MODE=LIVE, FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_VOICE_MODEL_NAME and DefaultAzureCredential authentication. + * It creates and deletes its own agent and conversation; no microphone or speaker is required. + * Interrupted/generated audio is excluded, matching the Python parity test. + */ +public class VoiceAgentConversationsAsyncTests { + private static final Duration TIMEOUT = Duration.ofSeconds(30); + private static final String AGENT = "test-conversations-read-agent-async-java"; + private static final String CONVERSATION = "conversation-1"; + private static final String ROOT = "/agents/" + AGENT + "/endpoint/protocols/voice/conversations"; + private static final String PATH = ROOT + "/" + CONVERSATION; + private static final String ENVELOPE + = "{\"id\":\"conversation-1\",\"status\":\"completed\",\"created_at\":1700000000}"; + private static final String RESPONSE = "{\"id\":\"response-1\",\"status\":\"completed\"}"; + private static final String USER_ITEM = "{\"id\":\"user-1\",\"type\":\"message\",\"role\":\"user\",\"content\":[]}"; + private static final String ASSISTANT_ITEM + = "{\"id\":\"assistant-1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[]}"; + + @Test + @EnabledIfEnvironmentVariable(named = "AZURE_TEST_MODE", matches = "LIVE") + public void readLivePersistedConversation() { + Configuration configuration = Configuration.getGlobalConfiguration(); + String endpoint = configuration.get("FOUNDRY_PROJECT_ENDPOINT"); + String model = configuration.get("FOUNDRY_VOICE_MODEL_NAME"); + assertNotNull(endpoint, "FOUNDRY_PROJECT_ENDPOINT is required for live parity testing."); + assertNotNull(model, "FOUNDRY_VOICE_MODEL_NAME is required for live parity testing."); + String agentName = "test-voice-read-" + UUID.randomUUID(); + AgentsClientBuilder builder = new AgentsClientBuilder().endpoint(endpoint) + .credential(new DefaultAzureCredentialBuilder().build()) + .allowPreview(true); + AgentsAsyncClient agents = builder.buildAgentsAsyncClient(); + BetaVoiceAgentsConversationsAsyncClient conversations + = builder.beta().buildBetaVoiceAgentsConversationsAsyncClient(); + VoiceAgentDefinition definition = new VoiceAgentDefinition().setModelType(VoiceModelType.MANAGED) + .setModel(model) + .setInstructions("You are a helpful voice assistant. Keep replies short.") + .setAudio(new VoiceAgentAudioConfig().setOutput( + new VoiceAgentAudioOutputConfig().setVoice("en-US-AvaNeural").setVoiceType(VoiceType.AZURE_STANDARD))) + .setOutputModalities(Collections.singletonList(VoiceOutputModality.AUDIO)) + .setStore(true); + AtomicReference conversationId = new AtomicReference<>(); + boolean created = false; + boolean reading = false; + try { + agents.createAgentVersion(agentName, new CreateAgentVersionInput(definition)).block(TIMEOUT); + created = true; + Mono.usingWhen(builder.beta().buildBetaVoiceAgentWebSocketAsyncClient().connect(agentName), + session -> session.receiveEvents().index().concatMap(indexed -> { + if (indexed.getT1() == 0) { + assertTrue(indexed.getT2() instanceof RealtimeServerEventSessionCreated, + "The first event must be session.created."); + conversationId.set(((RealtimeServerEventSessionCreated) indexed.getT2()).getConversationId()); + assertNotNull(conversationId.get(), "store=True must return a conversation ID."); + return session.sendText("Say hello.") + .then(session.createResponse()) + .thenReturn(indexed.getT2()); + } + return Mono.just(indexed.getT2()); + }) + .filter(RealtimeServerEventResponseDone.class::isInstance) + .next() + .switchIfEmpty(Mono.error(new AssertionError("Session ended without response.done."))) + .timeout(Duration.ofSeconds(45)) + .then(), + VoiceAgentWebSocketSessionAsyncClient::closeAsync, (session, error) -> session.closeAsync(), + VoiceAgentWebSocketSessionAsyncClient::closeAsync).block(Duration.ofSeconds(90)); + Mono.delay(Duration.ofSeconds(30)).block(Duration.ofSeconds(35)); + reading = true; + assertPersistedConversation(conversations, agentName, conversationId.get()); + } finally { + try { + if (!reading && conversationId.get() != null) { + conversations.deleteAgentConversation(agentName, conversationId.get()).block(TIMEOUT); + } + } finally { + if (created) { + agents.deleteAgent(agentName).block(TIMEOUT); + } + } + } + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void readPersistedConversation(boolean blobStorage) { + ScriptedTransport transport = new ScriptedTransport(); + enqueueTranscript(transport, ENVELOPE); + String blob = blobStorage ? ",\"blob_uri\":\"https://storage.example/recording.wav\"" : ""; + transport.get(PATH + "/audio", 200, "{\"conversation_id\":\"conversation-1\",\"format\":\"wav\"," + + "\"sample_rate\":24000,\"channels\":2,\"channel_layout\":{},\"duration_ms\":1000" + blob + "}"); + if (!blobStorage) { + transport.audio(PATH + "/audio/content"); + } + transport.get(PATH + "/items/user-1/audio", 404, + "{\"error\":{\"code\":\"NotFound\",\"message\":\"No audio\"}}"); + transport.get(PATH + "/items/assistant-1/audio", 200, + "{\"conversation_id\":\"conversation-1\",\"item_id\":\"assistant-1\",\"role\":\"assistant\"" + blob + "}"); + if (!blobStorage) { + transport.audio(PATH + "/items/assistant-1/audio/content"); + } + transport.delete(PATH); + + assertPersistedConversation(client(transport), AGENT, CONVERSATION); + transport.assertComplete(); + } + + @Test + public void incompleteConversationFailsInsteadOfSkippingAudioAndStillDeletes() { + ScriptedTransport transport = new ScriptedTransport(); + enqueueTranscript(transport, ENVELOPE.replace("completed", "in_progress")); + transport.delete(PATH); + assertThrows(AssertionError.class, () -> assertPersistedConversation(client(transport), AGENT, CONVERSATION)); + transport.assertComplete(); + } + + @Test + public void nonNotFoundItemAudioErrorPropagatesAndStillDeletes() { + ScriptedTransport transport = new ScriptedTransport(); + enqueueTranscript(transport, ENVELOPE); + transport.get(PATH + "/audio", 200, + "{\"conversation_id\":\"conversation-1\",\"format\":\"wav\"," + + "\"sample_rate\":24000,\"channels\":2,\"channel_layout\":{},\"duration_ms\":1000," + + "\"blob_uri\":\"https://storage.example/recording.wav\"}"); + transport.get(PATH + "/items/user-1/audio", 403, + "{\"error\":{\"code\":\"Forbidden\",\"message\":\"Audio access denied\"}}"); + transport.delete(PATH); + HttpResponseException error = assertThrows(HttpResponseException.class, + () -> assertPersistedConversation(client(transport), AGENT, CONVERSATION)); + assertEquals(403, error.getResponse().getStatusCode()); + transport.assertComplete(); + } + + private static void enqueueTranscript(ScriptedTransport transport, String envelope) { + transport.get(ROOT, 200, page(envelope)); + transport.get(PATH, 200, envelope); + transport.get(PATH + "/responses", 200, page(RESPONSE)); + transport.get(PATH + "/responses/response-1", 200, RESPONSE); + transport.get(PATH + "/responses/response-1/items", 200, page()); + transport.get(PATH + "/items", 200, page(USER_ITEM, ASSISTANT_ITEM)); + transport.get(PATH + "/items/user-1", 200, USER_ITEM); + } + + private static BetaVoiceAgentsConversationsAsyncClient client(HttpClient transport) { + return new AgentsClientBuilder().endpoint("https://localhost") + .credential(new MockTokenCredential()) + .httpClient(transport) + .allowPreview(true) + .beta() + .buildBetaVoiceAgentsConversationsAsyncClient(); + } + + private static void assertPersistedConversation(BetaVoiceAgentsConversationsAsyncClient client, String agentName, + String conversationId) { + try { + assertEquals(Boolean.TRUE, + client.listAgentConversations(agentName) + .any(conversation -> conversationId.equals(conversation.getId())) + .block(TIMEOUT)); + VoiceConversation conversation = client.getAgentConversation(agentName, conversationId).block(TIMEOUT); + assertNotNull(conversation); + assertEquals(conversationId, conversation.getId()); + assertTrue( + Arrays + .asList(VoiceConversationStatus.IN_PROGRESS, VoiceConversationStatus.COMPLETED, + VoiceConversationStatus.FAILED) + .contains(conversation.getStatus())); + assertNotNull(conversation.getCreatedAt()); + + List responses + = client.listAgentConversationResponses(agentName, conversationId).collectList().block(TIMEOUT); + assertNotNull(responses); + assertFalse(responses.isEmpty()); + String responseId = responses.get(0).getId(); + VoiceResponse response + = client.getAgentConversationResponse(agentName, conversationId, responseId).block(TIMEOUT); + assertNotNull(response); + assertEquals(responseId, response.getId()); + client.listAgentConversationResponseItems(agentName, conversationId, responseId, new RequestOptions()) + .collectList() + .block(TIMEOUT); + + List items = client.listAgentConversationItems(agentName, conversationId, new RequestOptions()) + .collectList() + .block(TIMEOUT); + assertNotNull(items); + assertFalse(items.isEmpty()); + String firstId = itemId(items.get(0)); + assertNotNull(firstId); + assertFalse(firstId.isEmpty()); + BinaryData fetched + = client.getAgentConversationItemWithResponse(agentName, conversationId, firstId, new RequestOptions()) + .block(TIMEOUT) + .getValue(); + assertEquals(firstId, itemId(fetched)); + + assertEquals(VoiceConversationStatus.COMPLETED, conversation.getStatus(), + "Audio assertions require a finalized conversation."); + VoiceRecordingResponse recording + = client.getAgentConversationAudio(agentName, conversationId).block(TIMEOUT); + assertNotNull(recording); + assertNotNull(recording.getFormat()); + if (recording.getBlobUri() == null || recording.getBlobUri().isEmpty()) { + assertAudio(client.downloadAgentConversationAudio(agentName, conversationId)); + } + for (BinaryData item : items) { + String id = itemId(item); + if (id == null || id.isEmpty()) { + continue; + } + VoiceAudioItemResponse audio; + try { + audio = client.getAgentConversationAudioItem(agentName, conversationId, id).block(TIMEOUT); + } catch (HttpResponseException error) { + if (error.getResponse().getStatusCode() == 404) { + continue; + } + throw error; + } + assertNotNull(audio); + assertNotNull(audio.getRole()); + if (audio.getBlobUri() == null || audio.getBlobUri().isEmpty()) { + assertAudio(client.downloadAgentConversationAudioItem(agentName, conversationId, id)); + } + break; + } + } finally { + client.deleteAgentConversation(agentName, conversationId).block(TIMEOUT); + } + } + + private static String itemId(BinaryData item) { + return (String) item.toObject(Map.class).get("id"); + } + + private static void assertAudio(Mono content) { + BinaryData audio = content.block(TIMEOUT); + assertNotNull(audio); + assertTrue(audio.toBytes().length > 0); + } + + private static String page(String... entries) { + return "{\"data\":[" + String.join(",", entries) + "],\"has_more\":false}"; + } + + private static final class ScriptedTransport implements HttpClient { + private final Deque> requests = new ArrayDeque<>(); + + void get(String path, int status, String json) { + expect(HttpMethod.GET, path, status, "application/json", BinaryData.fromString(json).toBytes()); + } + + void audio(String path) { + expect(HttpMethod.GET, path, 200, "audio/wav", new byte[] { 82, 73, 70, 70, 0, 1, 2, 3 }); + } + + void delete(String path) { + expect(HttpMethod.DELETE, path, 204, "application/json", new byte[0]); + } + + private void expect(HttpMethod method, String path, int status, String contentType, byte[] body) { + requests.add(request -> { + assertEquals(method, request.getHttpMethod()); + assertEquals(path, request.getUrl().getPath()); + return new MockHttpResponse(request, status, + new HttpHeaders().set(HttpHeaderName.CONTENT_TYPE, contentType), body); + }); + } + + @Override + public Mono send(HttpRequest request) { + return Mono.fromSupplier(() -> { + assertFalse(requests.isEmpty(), "Unexpected request: " + request.getUrl()); + return requests.removeFirst().apply(request); + }); + } + + @Override + public HttpResponse sendSync(HttpRequest request, Context context) { + throw new AssertionError("The async client must not use synchronous HTTP."); + } + + void assertComplete() { + assertTrue(requests.isEmpty(), "Not all expected voice REST operations were called."); + } + } +} diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsTests.java new file mode 100644 index 0000000000000..14f0ac1596bd7 --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsTests.java @@ -0,0 +1,341 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.voice; + +import com.azure.ai.agents.AgentsClient; +import com.azure.ai.agents.AgentsClientBuilder; +import com.azure.ai.agents.BetaVoiceAgentsConversationsClient; +import com.azure.ai.agents.VoiceAgentWebSocketSessionClient; +import com.azure.ai.agents.models.CreateAgentVersionInput; +import com.azure.ai.agents.models.RealtimeServerEvent; +import com.azure.ai.agents.models.RealtimeServerEventResponseDone; +import com.azure.ai.agents.models.RealtimeServerEventSessionCreated; +import com.azure.ai.agents.models.VoiceAgentAudioConfig; +import com.azure.ai.agents.models.VoiceAgentAudioOutputConfig; +import com.azure.ai.agents.models.VoiceAgentDefinition; +import com.azure.ai.agents.models.VoiceConversation; +import com.azure.ai.agents.models.VoiceConversationStatus; +import com.azure.ai.agents.models.VoiceAudioItemResponse; +import com.azure.ai.agents.models.VoiceModelType; +import com.azure.ai.agents.models.VoiceOutputModality; +import com.azure.ai.agents.models.VoiceRecordingResponse; +import com.azure.ai.agents.models.VoiceResponse; +import com.azure.ai.agents.models.VoiceType; +import com.azure.core.exception.HttpResponseException; +import com.azure.core.http.HttpClient; +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpHeaders; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.HttpResponse; +import com.azure.core.http.rest.RequestOptions; +import com.azure.core.test.http.MockHttpResponse; +import com.azure.core.test.utils.MockTokenCredential; +import com.azure.core.util.BinaryData; +import com.azure.core.util.Configuration; +import com.azure.core.util.Context; +import com.azure.identity.DefaultAzureCredentialBuilder; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.Collections; +import java.util.Deque; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Synchronous parity for Python test_voice_agent_conversations.py. + * Deterministic cases use scripted HTTP responses, not service recordings. The live case requires + * AZURE_TEST_MODE=LIVE, FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_VOICE_MODEL_NAME and DefaultAzureCredential authentication. + * It creates and deletes its own agent and conversation without a microphone or speaker. + * Interrupted/generated audio is excluded, matching the Python test. + */ +public class VoiceAgentConversationsTests { + private static final Duration TIMEOUT = Duration.ofSeconds(30); + private static final String AGENT = "test-conversations-read-agent-java"; + private static final String CONVERSATION = "conversation-1"; + private static final String ROOT = "/agents/" + AGENT + "/endpoint/protocols/voice/conversations"; + private static final String PATH = ROOT + "/" + CONVERSATION; + private static final String ENVELOPE + = "{\"id\":\"conversation-1\",\"status\":\"completed\",\"created_at\":1700000000}"; + private static final String RESPONSE = "{\"id\":\"response-1\",\"status\":\"completed\"}"; + private static final String USER_ITEM = "{\"id\":\"user-1\",\"type\":\"message\",\"role\":\"user\",\"content\":[]}"; + private static final String ASSISTANT_ITEM + = "{\"id\":\"assistant-1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[]}"; + + @Test + @EnabledIfEnvironmentVariable(named = "AZURE_TEST_MODE", matches = "LIVE") + public void readLivePersistedConversation() throws InterruptedException { + Configuration configuration = Configuration.getGlobalConfiguration(); + String endpoint = configuration.get("FOUNDRY_PROJECT_ENDPOINT"); + String model = configuration.get("FOUNDRY_VOICE_MODEL_NAME"); + assertNotNull(endpoint, "FOUNDRY_PROJECT_ENDPOINT is required for live parity testing."); + assertNotNull(model, "FOUNDRY_VOICE_MODEL_NAME is required for live parity testing."); + String agentName = "test-voice-read-sync-" + UUID.randomUUID(); + AgentsClientBuilder builder = new AgentsClientBuilder().endpoint(endpoint) + .credential(new DefaultAzureCredentialBuilder().build()) + .allowPreview(true); + AgentsClient agents = builder.buildAgentsClient(); + BetaVoiceAgentsConversationsClient conversations = builder.beta().buildBetaVoiceAgentsConversationsClient(); + VoiceAgentDefinition definition = new VoiceAgentDefinition().setModelType(VoiceModelType.MANAGED) + .setModel(model) + .setInstructions("You are a helpful voice assistant. Keep replies short.") + .setAudio(new VoiceAgentAudioConfig().setOutput( + new VoiceAgentAudioOutputConfig().setVoice("en-US-AvaNeural").setVoiceType(VoiceType.AZURE_STANDARD))) + .setOutputModalities(Collections.singletonList(VoiceOutputModality.AUDIO)) + .setStore(true); + String conversationId = null; + boolean created = false; + boolean reading = false; + try { + agents.createAgentVersion(agentName, new CreateAgentVersionInput(definition)); + created = true; + try (VoiceAgentWebSocketSessionClient session + = builder.beta().buildBetaVoiceAgentWebSocketClient().connect(agentName)) { + Iterator events = session.receiveEvents(TIMEOUT).iterator(); + assertTrue(events.hasNext(), "Expected session.created."); + RealtimeServerEvent first = events.next(); + assertTrue(first instanceof RealtimeServerEventSessionCreated, + "The first event must be session.created."); + conversationId = ((RealtimeServerEventSessionCreated) first).getConversationId(); + assertNotNull(conversationId, "store=True must return a conversation ID."); + session.sendText("Say hello."); + session.createResponse(); + long deadline = System.nanoTime() + Duration.ofSeconds(45).toNanos(); + boolean responseDone = false; + while (System.nanoTime() < deadline && events.hasNext()) { + if (events.next() instanceof RealtimeServerEventResponseDone) { + responseDone = true; + break; + } + } + assertTrue(responseDone, "Session ended or timed out without response.done."); + } + Thread.sleep(TIMEOUT.toMillis()); + reading = true; + assertPersistedConversation(conversations, agentName, conversationId); + } finally { + try { + if (!reading && conversationId != null) { + conversations.deleteAgentConversation(agentName, conversationId); + } + } finally { + if (created) { + agents.deleteAgent(agentName); + } + } + } + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void readPersistedConversation(boolean blobStorage) { + ScriptedTransport transport = new ScriptedTransport(); + enqueueTranscript(transport, ENVELOPE); + String blob = blobStorage ? ",\"blob_uri\":\"https://storage.example/recording.wav\"" : ""; + transport.get(PATH + "/audio", 200, "{\"conversation_id\":\"conversation-1\",\"format\":\"wav\"," + + "\"sample_rate\":24000,\"channels\":2,\"channel_layout\":{},\"duration_ms\":1000" + blob + "}"); + if (!blobStorage) { + transport.audio(PATH + "/audio/content"); + } + transport.get(PATH + "/items/user-1/audio", 404, + "{\"error\":{\"code\":\"NotFound\",\"message\":\"No audio\"}}"); + transport.get(PATH + "/items/assistant-1/audio", 200, + "{\"conversation_id\":\"conversation-1\",\"item_id\":\"assistant-1\",\"role\":\"assistant\"" + blob + "}"); + if (!blobStorage) { + transport.audio(PATH + "/items/assistant-1/audio/content"); + } + transport.delete(PATH); + + assertPersistedConversation(client(transport), AGENT, CONVERSATION); + transport.assertComplete(); + } + + @Test + public void incompleteConversationFailsInsteadOfSkippingAudioAndStillDeletes() { + ScriptedTransport transport = new ScriptedTransport(); + enqueueTranscript(transport, ENVELOPE.replace("completed", "in_progress")); + transport.delete(PATH); + assertThrows(AssertionError.class, () -> assertPersistedConversation(client(transport), AGENT, CONVERSATION)); + transport.assertComplete(); + } + + @Test + public void nonNotFoundItemAudioErrorPropagatesAndStillDeletes() { + ScriptedTransport transport = new ScriptedTransport(); + enqueueTranscript(transport, ENVELOPE); + transport.get(PATH + "/audio", 200, + "{\"conversation_id\":\"conversation-1\",\"format\":\"wav\"," + + "\"sample_rate\":24000,\"channels\":2,\"channel_layout\":{},\"duration_ms\":1000," + + "\"blob_uri\":\"https://storage.example/recording.wav\"}"); + transport.get(PATH + "/items/user-1/audio", 403, + "{\"error\":{\"code\":\"Forbidden\",\"message\":\"Audio access denied\"}}"); + transport.delete(PATH); + HttpResponseException error = assertThrows(HttpResponseException.class, + () -> assertPersistedConversation(client(transport), AGENT, CONVERSATION)); + assertEquals(403, error.getResponse().getStatusCode()); + transport.assertComplete(); + } + + private static void enqueueTranscript(ScriptedTransport transport, String envelope) { + transport.get(ROOT, 200, page(envelope)); + transport.get(PATH, 200, envelope); + transport.get(PATH + "/responses", 200, page(RESPONSE)); + transport.get(PATH + "/responses/response-1", 200, RESPONSE); + transport.get(PATH + "/responses/response-1/items", 200, page()); + transport.get(PATH + "/items", 200, page(USER_ITEM, ASSISTANT_ITEM)); + transport.get(PATH + "/items/user-1", 200, USER_ITEM); + } + + private static BetaVoiceAgentsConversationsClient client(HttpClient transport) { + return new AgentsClientBuilder().endpoint("https://localhost") + .credential(new MockTokenCredential()) + .httpClient(transport) + .allowPreview(true) + .beta() + .buildBetaVoiceAgentsConversationsClient(); + } + + private static void assertPersistedConversation(BetaVoiceAgentsConversationsClient client, String agentName, + String conversationId) { + try { + assertTrue(client.listAgentConversations(agentName) + .stream() + .anyMatch(conversation -> conversationId.equals(conversation.getId()))); + VoiceConversation conversation = client.getAgentConversation(agentName, conversationId); + assertNotNull(conversation); + assertEquals(conversationId, conversation.getId()); + assertTrue( + Arrays + .asList(VoiceConversationStatus.IN_PROGRESS, VoiceConversationStatus.COMPLETED, + VoiceConversationStatus.FAILED) + .contains(conversation.getStatus())); + assertNotNull(conversation.getCreatedAt()); + + List responses = client.listAgentConversationResponses(agentName, conversationId) + .stream() + .collect(Collectors.toList()); + assertFalse(responses.isEmpty()); + String responseId = responses.get(0).getId(); + VoiceResponse response = client.getAgentConversationResponse(agentName, conversationId, responseId); + assertNotNull(response); + assertEquals(responseId, response.getId()); + client.listAgentConversationResponseItems(agentName, conversationId, responseId, new RequestOptions()) + .stream() + .collect(Collectors.toList()); + + List items = client.listAgentConversationItems(agentName, conversationId, new RequestOptions()) + .stream() + .collect(Collectors.toList()); + assertFalse(items.isEmpty()); + String firstId = itemId(items.get(0)); + assertNotNull(firstId); + assertFalse(firstId.isEmpty()); + BinaryData fetched + = client.getAgentConversationItemWithResponse(agentName, conversationId, firstId, new RequestOptions()) + .getValue(); + assertEquals(firstId, itemId(fetched)); + + assertEquals(VoiceConversationStatus.COMPLETED, conversation.getStatus(), + "Audio assertions require a finalized conversation."); + VoiceRecordingResponse recording = client.getAgentConversationAudio(agentName, conversationId); + assertNotNull(recording); + assertNotNull(recording.getFormat()); + if (recording.getBlobUri() == null || recording.getBlobUri().isEmpty()) { + assertAudio(client.downloadAgentConversationAudio(agentName, conversationId)); + } + for (BinaryData item : items) { + String id = itemId(item); + if (id == null || id.isEmpty()) { + continue; + } + VoiceAudioItemResponse audio; + try { + audio = client.getAgentConversationAudioItem(agentName, conversationId, id); + } catch (HttpResponseException error) { + if (error.getResponse().getStatusCode() == 404) { + continue; + } + throw error; + } + assertNotNull(audio); + assertNotNull(audio.getRole()); + if (audio.getBlobUri() == null || audio.getBlobUri().isEmpty()) { + assertAudio(client.downloadAgentConversationAudioItem(agentName, conversationId, id)); + } + break; + } + } finally { + client.deleteAgentConversation(agentName, conversationId); + } + } + + private static String itemId(BinaryData item) { + return (String) item.toObject(Map.class).get("id"); + } + + private static void assertAudio(BinaryData audio) { + assertNotNull(audio); + assertTrue(audio.toBytes().length > 0); + } + + private static String page(String... entries) { + return "{\"data\":[" + String.join(",", entries) + "],\"has_more\":false}"; + } + + private static final class ScriptedTransport implements HttpClient { + private final Deque> requests = new ArrayDeque<>(); + + void get(String path, int status, String json) { + expect(HttpMethod.GET, path, status, "application/json", BinaryData.fromString(json).toBytes()); + } + + void audio(String path) { + expect(HttpMethod.GET, path, 200, "audio/wav", new byte[] { 82, 73, 70, 70, 0, 1, 2, 3 }); + } + + void delete(String path) { + expect(HttpMethod.DELETE, path, 204, "application/json", new byte[0]); + } + + private void expect(HttpMethod method, String path, int status, String contentType, byte[] body) { + requests.add(request -> { + assertEquals(method, request.getHttpMethod()); + assertEquals(path, request.getUrl().getPath()); + return new MockHttpResponse(request, status, + new HttpHeaders().set(HttpHeaderName.CONTENT_TYPE, contentType), body); + }); + } + + @Override + public Mono send(HttpRequest request) { + throw new AssertionError("The sync client must not use asynchronous HTTP."); + } + + @Override + public HttpResponse sendSync(HttpRequest request, Context context) { + assertFalse(requests.isEmpty(), "Unexpected request: " + request.getUrl()); + return requests.removeFirst().apply(request); + } + + void assertComplete() { + assertTrue(requests.isEmpty(), "Not all expected voice REST operations were called."); + } + } +} diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentCrudAsyncTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentCrudAsyncTests.java new file mode 100644 index 0000000000000..b368526738714 --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentCrudAsyncTests.java @@ -0,0 +1,289 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.voice; + +import com.azure.ai.agents.AgentsAsyncClient; +import com.azure.ai.agents.AgentsClientBuilder; +import com.azure.ai.agents.models.AgentDetails; +import com.azure.ai.agents.models.AgentState; +import com.azure.ai.agents.models.AgentVersionDetails; +import com.azure.ai.agents.models.CreateAgentVersionInput; +import com.azure.ai.agents.models.VoiceAgentAudioConfig; +import com.azure.ai.agents.models.VoiceAgentAudioOutputConfig; +import com.azure.ai.agents.models.VoiceAgentDefinition; +import com.azure.ai.agents.models.VoiceModelType; +import com.azure.ai.agents.models.VoiceOutputModality; +import com.azure.ai.agents.models.VoiceType; +import com.azure.core.http.HttpClient; +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpHeaders; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.HttpResponse; +import com.azure.core.test.http.MockHttpResponse; +import com.azure.core.test.utils.MockTokenCredential; +import com.azure.core.util.BinaryData; +import com.azure.core.util.Configuration; +import com.azure.core.util.Context; +import com.azure.identity.DefaultAzureCredentialBuilder; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.Collections; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Parity for Python test_voice_agent_crud_async.py: versioned CRUD, disable/enable and guided generation. + * Offline tests use scripted HTTP responses, not recordings. Live tests require AZURE_TEST_MODE=LIVE, + * FOUNDRY_PROJECT_ENDPOINT and DefaultAzureCredential authentication. CRUD and state tests additionally + * require FOUNDRY_VOICE_MODEL_NAME. Every scenario deletes only its own uniquely named live agent. + */ +public class VoiceAgentCrudAsyncTests { + private static final Duration TIMEOUT = Duration.ofMinutes(2); + private static final String AGENT = "voice-agent-crud-async-java"; + private static final String MODEL = "voice-model"; + private static final String INSTRUCTIONS = "You are a helpful voice assistant."; + private static final String UPDATED_INSTRUCTIONS = INSTRUCTIONS + " Always greet the caller by name."; + + enum Scenario { + CRUD, DISABLE_ENABLE, GENERATE + } + + @ParameterizedTest + @EnumSource(Scenario.class) + public void voiceAgentOperations(Scenario scenario) { + ScriptedTransport transport = new ScriptedTransport(); + String path = "/agents/" + AGENT; + Map first = version("1", INSTRUCTIONS); + switch (scenario) { + case CRUD: + Map second = version("2", UPDATED_INSTRUCTIONS); + transport.expect(HttpMethod.POST, path + "/versions", createBody(INSTRUCTIONS), first); + transport.expect(HttpMethod.POST, path + "/versions", createBody(UPDATED_INSTRUCTIONS), second); + transport.expect(HttpMethod.GET, path, null, agent(second, "enabled")); + transport.expect(HttpMethod.GET, path + "/versions/1", null, first); + transport.expect(HttpMethod.GET, path + "/versions", null, + object("data", java.util.Arrays.asList(first, second), "has_more", false)); + break; + + case DISABLE_ENABLE: + transport.expect(HttpMethod.POST, path + "/versions", createBody(INSTRUCTIONS), first); + transport.expect(HttpMethod.POST, path + ":disable", null, null, 204); + transport.expect(HttpMethod.GET, path, null, agent(first, "disabled")); + transport.expect(HttpMethod.POST, path + ":enable", null, null, 204); + transport.expect(HttpMethod.GET, path, null, agent(first, "enabled")); + break; + + case GENERATE: + transport.expect(HttpMethod.POST, "/agents:generate", object("kind", "voice", "name", AGENT), + agent(first, "enabled")); + break; + + default: + throw new AssertionError("Unexpected scenario: " + scenario); + } + transport.expect(HttpMethod.DELETE, path, null, object("deleted", true, "name", AGENT)); + AgentsClientBuilder builder = new AgentsClientBuilder().endpoint("https://localhost") + .credential(new MockTokenCredential()) + .httpClient(transport) + .allowPreview(true); + StepVerifier.create(runScenario(builder, AGENT, MODEL, scenario)).expectComplete().verify(TIMEOUT); + assertTrue(transport.requests.isEmpty(), "All expected REST operations must be exercised."); + } + + @ParameterizedTest + @EnumSource(Scenario.class) + @EnabledIfEnvironmentVariable(named = "AZURE_TEST_MODE", matches = "LIVE") + public void voiceAgentOperationsLive(Scenario scenario) { + Configuration configuration = Configuration.getGlobalConfiguration(); + String endpoint = configuration.get("FOUNDRY_PROJECT_ENDPOINT"); + String model = configuration.get("FOUNDRY_VOICE_MODEL_NAME"); + assertNotNull(endpoint, "FOUNDRY_PROJECT_ENDPOINT is required for live tests."); + if (scenario != Scenario.GENERATE) { + assertNotNull(model, "FOUNDRY_VOICE_MODEL_NAME is required for this live test."); + } + AgentsClientBuilder builder = new AgentsClientBuilder().endpoint(endpoint) + .credential(new DefaultAzureCredentialBuilder().build()) + .allowPreview(true); + StepVerifier.create(runScenario(builder, "test-voice-crud-" + UUID.randomUUID(), model, scenario)) + .expectComplete() + .verify(TIMEOUT); + } + + private static Mono runScenario(AgentsClientBuilder builder, String name, String model, Scenario scenario) { + AgentsAsyncClient client = builder.buildAgentsAsyncClient(); + Mono create = scenario == Scenario.GENERATE + ? builder.beta() + .buildBetaAgentsAsyncClient() + .createAgentFromPrompt(BinaryData.fromObject(object("kind", "voice", "name", name))) + : client.createAgentVersion(name, new CreateAgentVersionInput(definition(model, INSTRUCTIONS))); + return Mono.usingWhen(create, created -> { + if (scenario == Scenario.GENERATE) { + AgentDetails generated = (AgentDetails) created; + validateAgent(generated, name, null); + VoiceAgentDefinition voice + = assertInstanceOf(VoiceAgentDefinition.class, generated.getVersions().getLatest().getDefinition()); + assertNotNull(voice.getInstructions()); + assertFalse(voice.getInstructions().isEmpty()); + return Mono.empty(); + } + AgentVersionDetails first = (AgentVersionDetails) created; + validateVersion(first, name, null); + validateDefinition(first, model, INSTRUCTIONS); + if (scenario == Scenario.DISABLE_ENABLE) { + return client.disableAgent(name) + .then(client.getAgent(name)) + .doOnNext(agent -> assertEquals(AgentState.DISABLED, agent.getState())) + .then(client.enableAgent(name)) + .then(client.getAgent(name)) + .doOnNext(agent -> assertEquals(AgentState.ENABLED, agent.getState())) + .then(); + } + return client.createAgentVersion(name, new CreateAgentVersionInput(definition(model, UPDATED_INSTRUCTIONS))) + .flatMap(second -> { + validateVersion(second, name, null); + validateDefinition(second, model, UPDATED_INSTRUCTIONS); + assertNotEquals(first.getVersion(), second.getVersion()); + return client.getAgent(name) + .doOnNext(agent -> validateAgent(agent, name, second.getVersion())) + .then(client.getAgentVersionDetails(name, first.getVersion())) + .doOnNext(version -> { + validateVersion(version, name, first.getVersion()); + validateDefinition(version, model, INSTRUCTIONS); + }) + .thenMany(client.listAgentVersions(name)) + .collectList() + .doOnNext(versions -> { + assertTrue(versions.size() >= 2); + versions.forEach(version -> validateVersion(version, name, null)); + assertTrue( + versions.stream().anyMatch(version -> first.getVersion().equals(version.getVersion()))); + assertTrue(versions.stream() + .anyMatch(version -> second.getVersion().equals(version.getVersion()))); + }) + .then(); + }); + }, created -> client.deleteAgent(name), (created, error) -> client.deleteAgent(name), + created -> client.deleteAgent(name)); + } + + private static VoiceAgentDefinition definition(String model, String instructions) { + return new VoiceAgentDefinition().setModelType(VoiceModelType.MANAGED) + .setModel(model) + .setInstructions(instructions) + .setAudio(new VoiceAgentAudioConfig().setOutput( + new VoiceAgentAudioOutputConfig().setVoice("en-US-AvaNeural").setVoiceType(VoiceType.AZURE_STANDARD))) + .setOutputModalities(Collections.singletonList(VoiceOutputModality.AUDIO)); + } + + private static void validateDefinition(AgentVersionDetails version, String model, String instructions) { + VoiceAgentDefinition voice = assertInstanceOf(VoiceAgentDefinition.class, version.getDefinition()); + assertEquals(VoiceModelType.MANAGED, voice.getModelType()); + assertEquals(model, voice.getModel()); + assertEquals(instructions, voice.getInstructions()); + assertNotNull(voice.getAudio()); + assertNotNull(voice.getAudio().getOutput()); + assertEquals("en-US-AvaNeural", voice.getAudio().getOutput().getVoice()); + assertEquals(VoiceType.AZURE_STANDARD, voice.getAudio().getOutput().getVoiceType()); + assertEquals(Collections.singletonList(VoiceOutputModality.AUDIO), voice.getOutputModalities()); + } + + private static void validateVersion(AgentVersionDetails version, String name, String expectedVersion) { + assertNotNull(version); + assertNotNull(version.getId()); + assertEquals(name, version.getName()); + assertNotNull(version.getVersion()); + assertFalse(version.getVersion().isEmpty()); + assertNotNull(version.getCreatedAt()); + assertInstanceOf(VoiceAgentDefinition.class, version.getDefinition()); + if (expectedVersion != null) { + assertEquals(expectedVersion, version.getVersion()); + } + } + + private static void validateAgent(AgentDetails agent, String name, String expectedVersion) { + assertNotNull(agent); + assertNotNull(agent.getId()); + assertEquals(name, agent.getName()); + assertNotNull(agent.getVersions()); + validateVersion(agent.getVersions().getLatest(), name, expectedVersion); + } + + private static Map createBody(String instructions) { + return object("definition", + object("kind", "voice", "model_type", "managed", "model", MODEL, "instructions", instructions, "audio", + object("output", object("voice", "en-US-AvaNeural", "voice_type", "azure-standard")), + "output_modalities", Collections.singletonList("audio"))); + } + + private static Map version(String version, String instructions) { + return object("id", AGENT + ":" + version, "name", AGENT, "version", version, "object", "agent.version", + "created_at", 1700000000, "definition", createBody(instructions).get("definition")); + } + + private static Map agent(Map latest, String state) { + return object("id", AGENT, "name", AGENT, "object", "agent", "state", state, "versions", + object("latest", latest)); + } + + private static Map object(Object... entries) { + Map result = new LinkedHashMap<>(); + for (int index = 0; index < entries.length; index += 2) { + result.put((String) entries[index], entries[index + 1]); + } + return result; + } + + private static final class ScriptedTransport implements HttpClient { + private final Deque>> requests = new ArrayDeque<>(); + + void expect(HttpMethod method, String path, Map body, Map response) { + expect(method, path, body, response, 200); + } + + void expect(HttpMethod method, String path, Map body, Map response, + int status) { + requests.add(request -> { + assertEquals(method, request.getHttpMethod()); + assertEquals(path, request.getUrl().getPath()); + Mono check = body == null + ? Mono.empty() + : BinaryData.fromFlux(request.getBody()) + .doOnNext(actual -> assertEquals(body, actual.toObject(Map.class))) + .then(); + return check.thenReturn(new MockHttpResponse(request, status, + new HttpHeaders().set(HttpHeaderName.CONTENT_TYPE, "application/json"), + response == null ? new byte[0] : BinaryData.fromObject(response).toBytes())); + }); + } + + @Override + public Mono send(HttpRequest request) { + return Mono.defer(() -> { + assertFalse(requests.isEmpty(), "Unexpected request: " + request.getUrl()); + return requests.removeFirst().apply(request); + }); + } + + @Override + public HttpResponse sendSync(HttpRequest request, Context context) { + throw new AssertionError("The async client must not use synchronous HTTP."); + } + } +} diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentCrudTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentCrudTests.java new file mode 100644 index 0000000000000..74b5003dc9cc5 --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentCrudTests.java @@ -0,0 +1,276 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.voice; + +import com.azure.ai.agents.AgentsClient; +import com.azure.ai.agents.AgentsClientBuilder; +import com.azure.ai.agents.models.AgentDetails; +import com.azure.ai.agents.models.AgentState; +import com.azure.ai.agents.models.AgentVersionDetails; +import com.azure.ai.agents.models.CreateAgentVersionInput; +import com.azure.ai.agents.models.VoiceAgentAudioConfig; +import com.azure.ai.agents.models.VoiceAgentAudioOutputConfig; +import com.azure.ai.agents.models.VoiceAgentDefinition; +import com.azure.ai.agents.models.VoiceModelType; +import com.azure.ai.agents.models.VoiceOutputModality; +import com.azure.ai.agents.models.VoiceType; +import com.azure.core.http.HttpClient; +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpHeaders; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.HttpResponse; +import com.azure.core.test.http.MockHttpResponse; +import com.azure.core.test.utils.MockTokenCredential; +import com.azure.core.util.BinaryData; +import com.azure.core.util.Configuration; +import com.azure.core.util.Context; +import com.azure.identity.DefaultAzureCredentialBuilder; +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.Collections; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import reactor.core.publisher.Mono; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Synchronous parity for Python test_voice_agent_crud.py: versioned CRUD, disable/enable and guided generation. + * Offline tests use scripted HTTP responses, not recordings. Live tests require AZURE_TEST_MODE=LIVE, + * FOUNDRY_PROJECT_ENDPOINT and DefaultAzureCredential authentication. CRUD and state tests additionally + * require FOUNDRY_VOICE_MODEL_NAME. Every scenario deletes only its own uniquely named live agent. + */ +public class VoiceAgentCrudTests { + private static final String AGENT = "voice-agent-crud-java"; + private static final String MODEL = "voice-model"; + private static final String INSTRUCTIONS = "You are a helpful voice assistant."; + private static final String UPDATED_INSTRUCTIONS = INSTRUCTIONS + " Always greet the caller by name."; + + enum Scenario { + CRUD, DISABLE_ENABLE, GENERATE + } + + @ParameterizedTest + @EnumSource(Scenario.class) + public void voiceAgentOperations(Scenario scenario) { + ScriptedTransport transport = new ScriptedTransport(); + String path = "/agents/" + AGENT; + Map first = version("1", INSTRUCTIONS); + switch (scenario) { + case CRUD: + Map second = version("2", UPDATED_INSTRUCTIONS); + transport.expect(HttpMethod.POST, path + "/versions", createBody(INSTRUCTIONS), first); + transport.expect(HttpMethod.POST, path + "/versions", createBody(UPDATED_INSTRUCTIONS), second); + transport.expect(HttpMethod.GET, path, null, agent(second, "enabled")); + transport.expect(HttpMethod.GET, path + "/versions/1", null, first); + transport.expect(HttpMethod.GET, path + "/versions", null, + object("data", Arrays.asList(first, second), "has_more", false)); + break; + + case DISABLE_ENABLE: + transport.expect(HttpMethod.POST, path + "/versions", createBody(INSTRUCTIONS), first); + transport.expect(HttpMethod.POST, path + ":disable", null, null, 204); + transport.expect(HttpMethod.GET, path, null, agent(first, "disabled")); + transport.expect(HttpMethod.POST, path + ":enable", null, null, 204); + transport.expect(HttpMethod.GET, path, null, agent(first, "enabled")); + break; + + case GENERATE: + transport.expect(HttpMethod.POST, "/agents:generate", object("kind", "voice", "name", AGENT), + agent(first, "enabled")); + break; + + default: + throw new AssertionError("Unexpected scenario: " + scenario); + } + transport.expect(HttpMethod.DELETE, path, null, object("deleted", true, "name", AGENT)); + AgentsClientBuilder builder = new AgentsClientBuilder().endpoint("https://localhost") + .credential(new MockTokenCredential()) + .httpClient(transport) + .allowPreview(true); + runScenario(builder, AGENT, MODEL, scenario); + assertTrue(transport.requests.isEmpty(), "All expected REST operations must be exercised."); + } + + @ParameterizedTest + @EnumSource(Scenario.class) + @EnabledIfEnvironmentVariable(named = "AZURE_TEST_MODE", matches = "LIVE") + public void voiceAgentOperationsLive(Scenario scenario) { + Configuration configuration = Configuration.getGlobalConfiguration(); + String endpoint = configuration.get("FOUNDRY_PROJECT_ENDPOINT"); + String model = configuration.get("FOUNDRY_VOICE_MODEL_NAME"); + assertNotNull(endpoint, "FOUNDRY_PROJECT_ENDPOINT is required for live tests."); + if (scenario != Scenario.GENERATE) { + assertNotNull(model, "FOUNDRY_VOICE_MODEL_NAME is required for this live test."); + } + AgentsClientBuilder builder = new AgentsClientBuilder().endpoint(endpoint) + .credential(new DefaultAzureCredentialBuilder().build()) + .allowPreview(true); + runScenario(builder, "test-voice-crud-sync-" + UUID.randomUUID(), model, scenario); + } + + private static void runScenario(AgentsClientBuilder builder, String name, String model, Scenario scenario) { + AgentsClient client = builder.buildAgentsClient(); + boolean created = false; + try { + if (scenario == Scenario.GENERATE) { + AgentDetails generated = builder.beta() + .buildBetaAgentsClient() + .createAgentFromPrompt(BinaryData.fromObject(object("kind", "voice", "name", name))); + created = true; + validateAgent(generated, name, null); + VoiceAgentDefinition voice + = assertInstanceOf(VoiceAgentDefinition.class, generated.getVersions().getLatest().getDefinition()); + assertNotNull(voice.getInstructions()); + assertFalse(voice.getInstructions().isEmpty()); + return; + } + AgentVersionDetails first + = client.createAgentVersion(name, new CreateAgentVersionInput(definition(model, INSTRUCTIONS))); + created = true; + validateVersion(first, name, null); + validateDefinition(first, model, INSTRUCTIONS); + if (scenario == Scenario.DISABLE_ENABLE) { + client.disableAgent(name); + assertEquals(AgentState.DISABLED, client.getAgent(name).getState()); + client.enableAgent(name); + assertEquals(AgentState.ENABLED, client.getAgent(name).getState()); + return; + } + AgentVersionDetails second + = client.createAgentVersion(name, new CreateAgentVersionInput(definition(model, UPDATED_INSTRUCTIONS))); + validateVersion(second, name, null); + validateDefinition(second, model, UPDATED_INSTRUCTIONS); + assertNotEquals(first.getVersion(), second.getVersion()); + validateAgent(client.getAgent(name), name, second.getVersion()); + AgentVersionDetails retrieved = client.getAgentVersionDetails(name, first.getVersion()); + validateVersion(retrieved, name, first.getVersion()); + validateDefinition(retrieved, model, INSTRUCTIONS); + List versions = client.listAgentVersions(name).stream().collect(Collectors.toList()); + assertTrue(versions.size() >= 2); + versions.forEach(version -> validateVersion(version, name, null)); + assertTrue(versions.stream().anyMatch(version -> first.getVersion().equals(version.getVersion()))); + assertTrue(versions.stream().anyMatch(version -> second.getVersion().equals(version.getVersion()))); + } finally { + if (created) { + client.deleteAgent(name); + } + } + } + + private static VoiceAgentDefinition definition(String model, String instructions) { + return new VoiceAgentDefinition().setModelType(VoiceModelType.MANAGED) + .setModel(model) + .setInstructions(instructions) + .setAudio(new VoiceAgentAudioConfig().setOutput( + new VoiceAgentAudioOutputConfig().setVoice("en-US-AvaNeural").setVoiceType(VoiceType.AZURE_STANDARD))) + .setOutputModalities(Collections.singletonList(VoiceOutputModality.AUDIO)); + } + + private static void validateDefinition(AgentVersionDetails version, String model, String instructions) { + VoiceAgentDefinition voice = assertInstanceOf(VoiceAgentDefinition.class, version.getDefinition()); + assertEquals(VoiceModelType.MANAGED, voice.getModelType()); + assertEquals(model, voice.getModel()); + assertEquals(instructions, voice.getInstructions()); + assertNotNull(voice.getAudio()); + assertNotNull(voice.getAudio().getOutput()); + assertEquals("en-US-AvaNeural", voice.getAudio().getOutput().getVoice()); + assertEquals(VoiceType.AZURE_STANDARD, voice.getAudio().getOutput().getVoiceType()); + assertEquals(Collections.singletonList(VoiceOutputModality.AUDIO), voice.getOutputModalities()); + } + + private static void validateVersion(AgentVersionDetails version, String name, String expectedVersion) { + assertNotNull(version); + assertNotNull(version.getId()); + assertEquals(name, version.getName()); + assertNotNull(version.getVersion()); + assertFalse(version.getVersion().isEmpty()); + assertNotNull(version.getCreatedAt()); + assertInstanceOf(VoiceAgentDefinition.class, version.getDefinition()); + if (expectedVersion != null) { + assertEquals(expectedVersion, version.getVersion()); + } + } + + private static void validateAgent(AgentDetails agent, String name, String expectedVersion) { + assertNotNull(agent); + assertNotNull(agent.getId()); + assertEquals(name, agent.getName()); + assertNotNull(agent.getVersions()); + validateVersion(agent.getVersions().getLatest(), name, expectedVersion); + } + + private static Map createBody(String instructions) { + return object("definition", + object("kind", "voice", "model_type", "managed", "model", MODEL, "instructions", instructions, "audio", + object("output", object("voice", "en-US-AvaNeural", "voice_type", "azure-standard")), + "output_modalities", Collections.singletonList("audio"))); + } + + private static Map version(String version, String instructions) { + return object("id", AGENT + ":" + version, "name", AGENT, "version", version, "object", "agent.version", + "created_at", 1700000000, "definition", createBody(instructions).get("definition")); + } + + private static Map agent(Map latest, String state) { + return object("id", AGENT, "name", AGENT, "object", "agent", "state", state, "versions", + object("latest", latest)); + } + + private static Map object(Object... entries) { + Map result = new LinkedHashMap<>(); + for (int index = 0; index < entries.length; index += 2) { + result.put((String) entries[index], entries[index + 1]); + } + return result; + } + + private static final class ScriptedTransport implements HttpClient { + private final Deque> requests = new ArrayDeque<>(); + + void expect(HttpMethod method, String path, Map body, Map response) { + expect(method, path, body, response, 200); + } + + void expect(HttpMethod method, String path, Map body, Map response, + int status) { + requests.add(request -> { + assertEquals(method, request.getHttpMethod()); + assertEquals(path, request.getUrl().getPath()); + if (body != null) { + assertNotNull(request.getBodyAsBinaryData()); + assertEquals(body, request.getBodyAsBinaryData().toObject(Map.class)); + } + return new MockHttpResponse(request, status, + new HttpHeaders().set(HttpHeaderName.CONTENT_TYPE, "application/json"), + response == null ? new byte[0] : BinaryData.fromObject(response).toBytes()); + }); + } + + @Override + public Mono send(HttpRequest request) { + throw new AssertionError("The sync client must not use asynchronous HTTP."); + } + + @Override + public HttpResponse sendSync(HttpRequest request, Context context) { + assertFalse(requests.isEmpty(), "Unexpected request: " + request.getUrl()); + return requests.removeFirst().apply(request); + } + } +} diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSampleTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSampleTests.java new file mode 100644 index 0000000000000..818edb61713db --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSampleTests.java @@ -0,0 +1,227 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.voice; + +import com.azure.ai.agents.models.RealtimeServerEvent; +import com.azure.ai.agents.models.RealtimeServerEventConversationCreated; +import com.azure.core.util.BinaryData; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import javax.sound.sampled.SourceDataLine; +import javax.sound.sampled.TargetDataLine; +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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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 VoiceAgentLiveAudioConversationAsyncSampleTests { + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + @Test + public void conversationCreatedModelIsAvailable() { + RealtimeServerEvent event = BinaryData + .fromString("{\"type\":\"conversation.created\"," + + "\"conversation\":{\"id\":\"test\",\"object\":\"realtime.conversation\"}}") + .toObject(RealtimeServerEvent.class); + assertTrue(event instanceof RealtimeServerEventConversationCreated); + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void receiveTerminationClosesAudio(boolean failReceive) { + FakeAudio audio = new FakeAudio(); + IllegalStateException error = new IllegalStateException("Disconnected"); + Mono receive = failReceive ? Mono.error(error) : Mono.empty(); + Mono conversation + = VoiceAgentLiveAudioConversationAsyncSample.runConversation(receive, audio.processor(), emptyInput()); + if (failReceive) { + assertSame(error, assertThrows(IllegalStateException.class, () -> conversation.block(TIMEOUT))); + } else { + conversation.block(TIMEOUT); + } + audio.assertClosed(); + } + + @Test + public void microphoneFailureClosesAudio() { + FakeAudio audio = new FakeAudio(); + audio.readFailure = new IllegalStateException("Microphone failed"); + Mono conversation + = VoiceAgentLiveAudioConversationAsyncSample.runConversation(Mono.never(), audio.processor(), emptyInput()); + assertSame(audio.readFailure, assertThrows(IllegalStateException.class, () -> conversation.block(TIMEOUT))); + audio.assertClosed(); + } + + @Test + public void playbackAcceptsBurstsAndEnforcesByteLimitWithoutBlocking() throws Exception { + FakeAudio audio = new FakeAudio(); + VoiceAgentLiveAudioConversationAsyncSample.AudioProcessor processor = audio.processor(); + CountDownLatch receiving = new CountDownLatch(1); + CompletableFuture conversation + = VoiceAgentLiveAudioConversationAsyncSample + .runConversation(Mono.never().doOnSubscribe(subscription -> receiving.countDown()), processor, + emptyInput()) + .toFuture(); + try { + assertTrue(receiving.await(5, TimeUnit.SECONDS)); + processor.queueAudio(new byte[2]); + assertTrue(audio.writing.await(5, TimeUnit.SECONDS)); + assertTimeoutPreemptively(TIMEOUT, () -> { + for (int chunk = 0; chunk < 100; chunk++) { + processor.queueAudio(new byte[2400]); + } + }); + assertFalse(conversation.isDone()); + processor.skipPendingAudio(); + processor + .queueAudio(new byte[VoiceAgentLiveAudioConversationAsyncSample.AudioProcessor.MAX_PLAYBACK_BYTES]); + assertFalse(conversation.isDone()); + assertTimeoutPreemptively(TIMEOUT, () -> processor.queueAudio(new byte[2])); + ExecutionException error + = assertThrows(ExecutionException.class, () -> conversation.get(5, TimeUnit.SECONDS)); + assertEquals("Audio playback backlog exceeded 60 seconds.", error.getCause().getMessage()); + audio.assertClosed(); + } finally { + conversation.cancel(true); + processor.close(); + } + } + + @Test + public void cancellationClosesAudioAndCancelsReceive() throws Exception { + FakeAudio audio = new FakeAudio(); + AtomicBoolean cancelled = new AtomicBoolean(); + CountDownLatch receiving = new CountDownLatch(1); + VoiceAgentLiveAudioConversationAsyncSample.AudioProcessor processor = audio.processor(); + CompletableFuture conversation + = VoiceAgentLiveAudioConversationAsyncSample.runConversation(Mono.never() + .doOnSubscribe(subscription -> receiving.countDown()) + .doOnCancel(() -> cancelled.set(true)), processor, emptyInput()).toFuture(); + try { + assertTrue(receiving.await(5, TimeUnit.SECONDS)); + conversation.cancel(true); + assertTrue(audio.closed.await(5, TimeUnit.SECONDS)); + assertTrue(cancelled.get()); + } finally { + conversation.cancel(true); + processor.close(); + } + audio.assertClosed(); + } + + @Test + public void closedProcessorCannotRestart() { + FakeAudio audio = new FakeAudio(); + VoiceAgentLiveAudioConversationAsyncSample.AudioProcessor processor = audio.processor(); + processor.close(); + assertThrows(IllegalStateException.class, processor::start); + audio.assertClosed(); + } + + @Test + public void enterStopsConversationWithoutClosingStandardInput() { + FakeAudio audio = new FakeAudio(); + AtomicBoolean inputClosed = new AtomicBoolean(); + InputStream input = new ByteArrayInputStream("end\n".getBytes(StandardCharsets.UTF_8)) { + @Override + public void close() { + inputClosed.set(true); + } + }; + VoiceAgentLiveAudioConversationAsyncSample.runConversation(Mono.never(), audio.processor(), input) + .block(TIMEOUT); + assertFalse(inputClosed.get()); + audio.assertClosed(); + } + + @Test + public void inputPollingNeverReadsUnavailableBytes() { + InputStream input = new InputStream() { + @Override + public int read() { + throw new AssertionError("A read with no available bytes can block indefinitely."); + } + }; + Mono.firstWithSignal(VoiceAgentLiveAudioConversationAsyncSample.waitForEnter(input), + Mono.delay(Duration.ofMillis(300)).then()).block(TIMEOUT); + } + + private static InputStream emptyInput() { + return new ByteArrayInputStream(new byte[0]); + } + + private static final class FakeAudio implements InvocationHandler { + private final CountDownLatch closed = new CountDownLatch(2); + private final CountDownLatch writing = new CountDownLatch(1); + private final AtomicReference captureThread = new AtomicReference<>(); + private final AtomicReference playbackThread = new AtomicReference<>(); + private RuntimeException readFailure; + + VoiceAgentLiveAudioConversationAsyncSample.AudioProcessor processor() { + TargetDataLine microphone = (TargetDataLine) Proxy.newProxyInstance(getClass().getClassLoader(), + new Class[] { TargetDataLine.class }, this); + SourceDataLine speaker = (SourceDataLine) Proxy.newProxyInstance(getClass().getClassLoader(), + new Class[] { SourceDataLine.class }, this); + return new VoiceAgentLiveAudioConversationAsyncSample.AudioProcessor(null, microphone, speaker); + } + + @Override + public Object invoke(Object proxy, Method method, Object[] arguments) throws InterruptedException { + switch (method.getName()) { + case "read": + captureThread.set(Thread.currentThread()); + if (readFailure != null) { + throw readFailure; + } + closed.await(); + return 0; + + case "write": + playbackThread.set(Thread.currentThread()); + writing.countDown(); + closed.await(); + return arguments[2]; + + case "close": + closed.countDown(); + return null; + + default: + return null; + } + } + + void assertClosed() { + assertEquals(0, closed.getCount()); + assertTimeoutPreemptively(TIMEOUT, () -> { + if (captureThread.get() != null) { + captureThread.get().join(); + assertFalse(captureThread.get().isAlive()); + } + if (playbackThread.get() != null) { + playbackThread.get().join(); + assertFalse(playbackThread.get().isAlive()); + } + }); + } + } +} diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentRealtimeLiveTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentRealtimeLiveTests.java new file mode 100644 index 0000000000000..594fbb7a4e849 --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentRealtimeLiveTests.java @@ -0,0 +1,398 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.voice; + +import com.azure.ai.agents.AgentsAsyncClient; +import com.azure.ai.agents.AgentsClient; +import com.azure.ai.agents.AgentsClientBuilder; +import com.azure.ai.agents.VoiceAgentWebSocketSessionAsyncClient; +import com.azure.ai.agents.VoiceAgentWebSocketSessionClient; +import com.azure.ai.agents.models.CreateAgentVersionInput; +import com.azure.ai.agents.models.RealtimeClientEvent; +import com.azure.ai.agents.models.RealtimeClientEventConversationItemCreate; +import com.azure.ai.agents.models.RealtimeClientEventResponseCreate; +import com.azure.ai.agents.models.RealtimeConversationItemFunctionCallOutput; +import com.azure.ai.agents.models.RealtimeConversationItemMessageUser; +import com.azure.ai.agents.models.RealtimeConversationItemMessageUserContent; +import com.azure.ai.agents.models.RealtimeConversationItemMessageUserContentType; +import com.azure.ai.agents.models.RealtimeServerEvent; +import com.azure.ai.agents.models.RealtimeServerEventError; +import com.azure.ai.agents.models.RealtimeServerEventResponseAudioDelta; +import com.azure.ai.agents.models.RealtimeServerEventResponseAudioTranscriptDone; +import com.azure.ai.agents.models.RealtimeServerEventResponseDone; +import com.azure.ai.agents.models.RealtimeServerEventResponseFunctionCallArgumentsDone; +import com.azure.ai.agents.models.RealtimeServerEventResponseTextDone; +import com.azure.ai.agents.models.RealtimeServerEventSessionCreated; +import com.azure.ai.agents.models.VoiceAgentAudioConfig; +import com.azure.ai.agents.models.VoiceAgentAudioOutputConfig; +import com.azure.ai.agents.models.VoiceAgentDefinition; +import com.azure.ai.agents.models.VoiceAgentFunctionTool; +import com.azure.ai.agents.models.VoiceModelType; +import com.azure.ai.agents.models.VoiceOutputModality; +import com.azure.ai.agents.models.VoiceType; +import com.azure.core.util.BinaryData; +import com.azure.core.util.Configuration; +import com.azure.identity.DefaultAzureCredentialBuilder; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Sync/async parity for the Python live realtime suites. Live cases require AZURE_TEST_MODE=LIVE, + * FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_VOICE_MODEL_NAME and DefaultAzureCredential authentication. + * They create and delete real agents but require no audio hardware. Offline cases validate the + * same event assertions with synthetic payloads, not service recordings. + */ +public class VoiceAgentRealtimeLiveTests { + private static final Duration EVENT_TIMEOUT = Duration.ofSeconds(30); + private static final Duration RESPONSE_TIMEOUT = Duration.ofSeconds(45); + private static final String SESSION = "{\"type\":\"session.created\",\"session\":{}}"; + private static final String DONE = "{\"type\":\"response.done\",\"response\":{\"output\":[]}}"; + private static final String TOOL_DONE = "{\"type\":\"response.done\",\"response\":{\"output\":[" + + "{\"type\":\"function_call\",\"name\":\"get_weather\",\"call_id\":\"call-1\",\"arguments\":\"{}\"}]}}"; + + enum Scenario { + LIFECYCLE, AUDIO, FUNCTION + } + + @ParameterizedTest + @EnumSource(Scenario.class) + @EnabledIfEnvironmentVariable(named = "AZURE_TEST_MODE", matches = "LIVE") + public void realtimeLive(Scenario scenario) { + AgentsClientBuilder builder = liveBuilder(); + AgentsClient agents = builder.buildAgentsClient(); + String agentName = "test-realtime-sync-" + UUID.randomUUID(); + boolean created = false; + try { + agents.createAgentVersion(agentName, new CreateAgentVersionInput(definition(scenario))); + created = true; + try (VoiceAgentWebSocketSessionClient session + = builder.beta().buildBetaVoiceAgentWebSocketClient().connect(agentName)) { + Turn turn = new Turn(scenario); + Iterator events = session.receiveEvents(EVENT_TIMEOUT).iterator(); + turn.accept(events.next()).forEach(session::sendEvent); + long deadline = System.nanoTime() + RESPONSE_TIMEOUT.toNanos(); + while (!turn.done && System.nanoTime() < deadline && events.hasNext()) { + turn.accept(events.next()).forEach(session::sendEvent); + } + turn.assertComplete(); + } + } finally { + if (created) { + agents.deleteAgent(agentName); + } + } + } + + @ParameterizedTest + @EnumSource(Scenario.class) + @EnabledIfEnvironmentVariable(named = "AZURE_TEST_MODE", matches = "LIVE") + public void realtimeLiveAsync(Scenario scenario) { + AgentsClientBuilder builder = liveBuilder(); + AgentsAsyncClient agents = builder.buildAgentsAsyncClient(); + String agentName = "test-realtime-async-" + UUID.randomUUID(); + boolean created = false; + try { + agents.createAgentVersion(agentName, new CreateAgentVersionInput(definition(scenario))) + .block(EVENT_TIMEOUT); + created = true; + Turn turn = new Turn(scenario); + Mono.usingWhen(builder.beta().buildBetaVoiceAgentWebSocketAsyncClient().connect(agentName), + session -> session.receiveEvents() + .timeout(EVENT_TIMEOUT) + .concatMap(event -> Flux.fromIterable(turn.accept(event)) + .concatMap(session::sendEvent) + .then(Mono.just(turn.done))) + .filter(Boolean::booleanValue) + .next() + .switchIfEmpty(Mono.error(new AssertionError("Session ended before the turn completed."))) + .timeout(RESPONSE_TIMEOUT) + .doOnNext(ignored -> turn.assertComplete()) + .then(), + VoiceAgentWebSocketSessionAsyncClient::closeAsync, (session, error) -> session.closeAsync(), + VoiceAgentWebSocketSessionAsyncClient::closeAsync).block(Duration.ofSeconds(90)); + } finally { + if (created) { + agents.deleteAgent(agentName).block(EVENT_TIMEOUT); + } + } + } + + @ParameterizedTest + @EnumSource(Scenario.class) + public void syntheticEventsExerciseLiveAssertions(Scenario scenario) { + Turn turn = new Turn(scenario); + List initial = turn.accept(event(SESSION)); + assertEquals(scenario == Scenario.LIFECYCLE ? 0 : 2, initial.size()); + if (scenario != Scenario.LIFECYCLE) { + assertInstanceOf(RealtimeClientEventConversationItemCreate.class, initial.get(0)); + assertInstanceOf(RealtimeClientEventResponseCreate.class, initial.get(1)); + } + if (scenario == Scenario.AUDIO) { + turn.accept(event("{\"type\":\"response.output_audio.delta\",\"delta\":\"AQID\"}")); + turn.accept(event("{\"type\":\"response.output_audio_transcript.done\",\"transcript\":\"Hello\"}")); + turn.accept(event(DONE)); + } else if (scenario == Scenario.FUNCTION) { + assertTrue(turn.accept(functionCall("call-1")).isEmpty()); + assertTrue(turn.accept(functionCall("call-2")).isEmpty()); + assertFalse(turn.done); + List outputs = turn.accept(event(TOOL_DONE)); + assertEquals(3, outputs.size()); + for (int index = 0; index < 2; index++) { + RealtimeClientEventConversationItemCreate create + = assertInstanceOf(RealtimeClientEventConversationItemCreate.class, outputs.get(index)); + RealtimeConversationItemFunctionCallOutput output + = assertInstanceOf(RealtimeConversationItemFunctionCallOutput.class, create.getItem()); + assertEquals("call-" + (index + 1), output.getCallId()); + Map result = BinaryData.fromString(output.getOutput()).toObject(Map.class); + assertEquals("Seattle", result.get("city")); + assertEquals("sunny", result.get("condition")); + assertEquals(72, result.get("temperature_f")); + } + assertInstanceOf(RealtimeClientEventResponseCreate.class, outputs.get(2)); + assertFalse(turn.done); + turn.accept(event("{\"type\":\"response.output_text.done\",\"text\":\"Sunny in Seattle.\"}")); + turn.accept(event(DONE)); + } + turn.assertComplete(); + } + + @ParameterizedTest + @ValueSource( + strings = { + "missing-audio", + "empty-audio", + "missing-transcript", + "empty-transcript", + "duplicate-transcript", + "missing-done" }) + public void incompleteAudioTurnsFail(String omission) { + Turn turn = new Turn(Scenario.AUDIO); + turn.accept(event(SESSION)); + assertThrows(AssertionError.class, () -> { + if (!"missing-audio".equals(omission)) { + String delta = "empty-audio".equals(omission) ? "" : "AQID"; + turn.accept(event("{\"type\":\"response.output_audio.delta\",\"delta\":\"" + delta + "\"}")); + } + if (!"missing-transcript".equals(omission)) { + String transcript = "empty-transcript".equals(omission) ? " " : "Hello"; + RealtimeServerEvent transcriptEvent = event( + "{\"type\":\"response.output_audio_transcript.done\"," + "\"transcript\":\"" + transcript + "\"}"); + turn.accept(transcriptEvent); + if ("duplicate-transcript".equals(omission)) { + turn.accept(transcriptEvent); + } + } + if (!"missing-done".equals(omission)) { + turn.accept(event(DONE)); + } + turn.assertComplete(); + }); + } + + @Test + public void missingHandshakeServiceErrorsAndMissingToolResultFail() { + assertThrows(AssertionError.class, () -> new Turn(Scenario.LIFECYCLE).accept(event(DONE))); + Turn audio = new Turn(Scenario.AUDIO); + audio.accept(event(SESSION)); + assertThrows(AssertionError.class, () -> audio.accept(event( + "{\"type\":\"error\",\"error\":{\"type\":\"server_error\",\"message\":\"boom\",\"code\":\"failed\"}}"))); + Turn tool = new Turn(Scenario.FUNCTION); + tool.accept(event(SESSION)); + tool.accept(event(TOOL_DONE)); + assertFalse(tool.done); + assertThrows(AssertionError.class, tool::assertComplete); + tool.accept(event("{\"type\":\"response.output_text.done\",\"text\":\"Unverified answer\"}")); + tool.accept(event(DONE)); + assertThrows(AssertionError.class, tool::assertComplete); + } + + private static AgentsClientBuilder liveBuilder() { + String endpoint = Configuration.getGlobalConfiguration().get("FOUNDRY_PROJECT_ENDPOINT"); + assertNotNull(endpoint, "FOUNDRY_PROJECT_ENDPOINT is required for live testing."); + return new AgentsClientBuilder().endpoint(endpoint) + .allowPreview(true) + .credential(new DefaultAzureCredentialBuilder().build()); + } + + private static VoiceAgentDefinition definition(Scenario scenario) { + String model = Configuration.getGlobalConfiguration().get("FOUNDRY_VOICE_MODEL_NAME"); + assertNotNull(model, "FOUNDRY_VOICE_MODEL_NAME is required for live testing."); + return definition(scenario, model); + } + + @Test + public void functionToolParametersSerializeAsObject() { + Map request + = BinaryData.fromObject(new CreateAgentVersionInput(definition(Scenario.FUNCTION, "test-model"))) + .toObject(Map.class); + Map agentDefinition = assertInstanceOf(Map.class, request.get("definition")); + List tools = assertInstanceOf(List.class, agentDefinition.get("tools")); + assertEquals(1, tools.size()); + Map tool = assertInstanceOf(Map.class, tools.get(0)); + assertEquals("get_weather", tool.get("name")); + Map parameters = assertInstanceOf(Map.class, tool.get("parameters")); + assertEquals("object", parameters.get("type")); + assertEquals(Collections.singletonList("city"), parameters.get("required")); + Map properties = assertInstanceOf(Map.class, parameters.get("properties")); + Map city = assertInstanceOf(Map.class, properties.get("city")); + assertEquals("string", city.get("type")); + assertEquals("City name, e.g. Seattle.", city.get("description")); + } + + private static VoiceAgentDefinition definition(Scenario scenario, String model) { + VoiceAgentDefinition definition = new VoiceAgentDefinition().setModelType(VoiceModelType.MANAGED) + .setModel(model) + .setInstructions("You are a helpful voice assistant. Keep replies short."); + if (scenario == Scenario.FUNCTION) { + Map citySchema = new LinkedHashMap<>(); + citySchema.put("type", "string"); + citySchema.put("description", "City name, e.g. Seattle."); + Map parameters = new LinkedHashMap<>(); + parameters.put("type", "object"); + parameters.put("properties", Collections.singletonMap("city", citySchema)); + parameters.put("required", Collections.singletonList("city")); + definition + .setInstructions("You are a helpful voice assistant. Use the get_weather tool when the " + + "caller asks about the weather, then answer using its result.") + .setOutputModalities(Collections.singletonList(VoiceOutputModality.TEXT)) + .setTools(Collections.singletonList( + new VoiceAgentFunctionTool("get_weather").setDescription("Get the current weather for a city.") + .setParameters(BinaryData.fromObject(parameters)))); + } else { + definition.setOutputModalities(Collections.singletonList(VoiceOutputModality.AUDIO)) + .setAudio( + new VoiceAgentAudioConfig().setOutput(new VoiceAgentAudioOutputConfig().setVoice("en-US-AvaNeural") + .setVoiceType(VoiceType.AZURE_STANDARD))); + } + return definition; + } + + private static RealtimeServerEvent event(String json) { + return BinaryData.fromString(json).toObject(RealtimeServerEvent.class); + } + + private static RealtimeServerEvent functionCall(String callId) { + return event("{\"type\":\"response.function_call_arguments.done\",\"name\":\"get_weather\"," + "\"call_id\":\"" + + callId + "\",\"arguments\":\"{\\\"city\\\":\\\"Seattle\\\"}\"}"); + } + + private static final class Turn { + private final Scenario scenario; + private final List pending = new ArrayList<>(); + private boolean started; + private boolean done; + private int audioDeltas; + private long audioBytes; + private int transcripts; + private int toolCalls; + private String finalText; + + private Turn(Scenario scenario) { + this.scenario = scenario; + } + + private List accept(RealtimeServerEvent event) { + if (event instanceof RealtimeServerEventError) { + fail("Session error: " + ((RealtimeServerEventError) event).getError().getMessage()); + } + if (!started) { + assertInstanceOf(RealtimeServerEventSessionCreated.class, event, + "The first event must be session.created."); + assertEquals("session.created", event.getType().toString()); + started = true; + done = scenario == Scenario.LIFECYCLE; + if (!done) { + String prompt = scenario == Scenario.FUNCTION + ? "What's the weather like in Seattle right now?" + : "Say the word 'hello' and nothing else."; + return Arrays + .asList(new RealtimeClientEventConversationItemCreate(new RealtimeConversationItemMessageUser( + Collections.singletonList(new RealtimeConversationItemMessageUserContent() + .setType(RealtimeConversationItemMessageUserContentType.INPUT_TEXT) + .setText(prompt)))), + new RealtimeClientEventResponseCreate()); + } + } else if (event instanceof RealtimeServerEventResponseAudioDelta) { + audioDeltas++; + byte[] delta = ((RealtimeServerEventResponseAudioDelta) event).getDelta(); + assertNotNull(delta); + audioBytes += delta.length; + } else if (event instanceof RealtimeServerEventResponseAudioTranscriptDone) { + transcripts++; + String transcript = ((RealtimeServerEventResponseAudioTranscriptDone) event).getTranscript(); + assertNotNull(transcript); + assertFalse(transcript.trim().isEmpty()); + } else if (event instanceof RealtimeServerEventResponseFunctionCallArgumentsDone) { + RealtimeServerEventResponseFunctionCallArgumentsDone call + = (RealtimeServerEventResponseFunctionCallArgumentsDone) event; + assertEquals("get_weather", call.getName()); + Map arguments = BinaryData.fromString(call.getArguments()).toObject(Map.class); + String city = assertInstanceOf(String.class, arguments.get("city")); + assertNotNull(call.getCallId()); + Map result = new LinkedHashMap<>(); + result.put("city", city); + result.put("condition", "sunny"); + result.put("temperature_f", 72); + pending + .add(new RealtimeClientEventConversationItemCreate(new RealtimeConversationItemFunctionCallOutput( + call.getCallId(), BinaryData.fromObject(result).toString()))); + toolCalls++; + } else if (event instanceof RealtimeServerEventResponseTextDone) { + finalText = ((RealtimeServerEventResponseTextDone) event).getText(); + } else if (event instanceof RealtimeServerEventResponseDone) { + if (!pending.isEmpty()) { + List outputs = new ArrayList<>(pending); + pending.clear(); + outputs.add(new RealtimeClientEventResponseCreate()); + return outputs; + } + RealtimeServerEventResponseDone response = (RealtimeServerEventResponseDone) event; + assertNotNull(response.getResponse()); + done = scenario != Scenario.FUNCTION + || response.getResponse().getOutput() == null + || response.getResponse() + .getOutput() + .stream() + .noneMatch(item -> "function_call".equals(item.getType().toString())); + } + return Collections.emptyList(); + } + + private void assertComplete() { + assertTrue(started, "Did not receive session.created."); + assertTrue(done, "Did not receive the final response.done within the timeout."); + if (scenario == Scenario.AUDIO) { + assertTrue(audioDeltas > 0, "Expected at least one audio delta."); + assertTrue(audioBytes > 0, "Expected non-empty streamed audio."); + assertEquals(1, transcripts, "Expected exactly one audio-transcript-done event."); + } else if (scenario == Scenario.FUNCTION) { + assertTrue(toolCalls > 0, "Expected at least one get_weather call."); + assertNotNull(finalText); + assertFalse(finalText.trim().isEmpty(), "Expected a non-empty final text reply."); + } + } + } +} diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentTelephonyLiveTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentTelephonyLiveTests.java new file mode 100644 index 0000000000000..cc563f0d30afe --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentTelephonyLiveTests.java @@ -0,0 +1,314 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.voice; + +import com.azure.ai.agents.AgentsClient; +import com.azure.ai.agents.AgentsClientBuilder; +import com.azure.ai.agents.BetaVoiceAgentsTelephonyClient; +import com.azure.ai.agents.models.CreateAgentVersionInput; +import com.azure.ai.agents.models.CreateTelephonyCallJobRequest; +import com.azure.ai.agents.models.CreateTwilioTelephonyBindingRequest; +import com.azure.ai.agents.models.PstnTelephonyTransferDestination; +import com.azure.ai.agents.models.TelephonyBinding; +import com.azure.ai.agents.models.TelephonyBindingListItem; +import com.azure.ai.agents.models.TelephonyBindingStatus; +import com.azure.ai.agents.models.TelephonyCallJobSchedule; +import com.azure.ai.agents.models.TelephonyCallRecord; +import com.azure.ai.agents.models.TelephonyCallJob; +import com.azure.ai.agents.models.TelephonyCallSummary; +import com.azure.ai.agents.models.TelephonyOutboundDestination; +import com.azure.ai.agents.models.TelephonyOutboundDestinationType; +import com.azure.ai.agents.models.TelephonyProvider; +import com.azure.ai.agents.models.TelephonyTransferTarget; +import com.azure.ai.agents.models.TelephonyTransferTargets; +import com.azure.ai.agents.models.UpdateTelephonyBindingRequest; +import com.azure.ai.agents.models.VoiceAgentAudioConfig; +import com.azure.ai.agents.models.VoiceAgentAudioOutputConfig; +import com.azure.ai.agents.models.VoiceAgentDefinition; +import com.azure.ai.agents.models.VoiceModelType; +import com.azure.ai.agents.models.VoiceOutputModality; +import com.azure.ai.agents.models.VoiceType; +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.rest.Response; +import com.azure.core.http.rest.RequestOptions; +import com.azure.core.util.BinaryData; +import com.azure.core.util.Configuration; +import com.azure.identity.DefaultAzureCredentialBuilder; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.Collections; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Live Twilio validation for voice-agent telephony. This test places a real PSTN call and may incur provider charges. + * It runs only when AZURE_TEST_MODE=LIVE, requires FOUNDRY_VOICE_MODEL_NAME, and uses + * DefaultAzureCredential authentication. FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_TELEPHONY_CONNECTION_1, + * FOUNDRY_TELEPHONY_CONNECTION_2, FOUNDRY_TELEPHONY_NUMBER_1, and FOUNDRY_TELEPHONY_NUMBER_2 can override the test + * project defaults. Binding get, update, and delete have isolated live tests so a failure in one operation does not + * prevent the other operations from running. + */ +@Execution(ExecutionMode.SAME_THREAD) +public class VoiceAgentTelephonyLiveTests { + private static final String DEFAULT_ENDPOINT + = "https://voice-first-agents-df-tip.services.ai.azure.com/api/projects/voice-first-agents-df-tip"; + private static final String DEFAULT_CONNECTION_1 = "twilio-sdk-testing-1"; + private static final String DEFAULT_CONNECTION_2 = "twilio-sdk-testing-2"; + private static final String DEFAULT_NUMBER_1 = "+13853864628"; + private static final String DEFAULT_NUMBER_2 = "+18509702029"; + private static final Duration CALL_TIMEOUT = Duration.ofMinutes(2); + private static final Duration POLL_INTERVAL = Duration.ofSeconds(2); + + private enum BindingMutation { + GET, UPDATE, DELETE + } + + @ParameterizedTest + @EnumSource(BindingMutation.class) + @EnabledIfEnvironmentVariable(named = "AZURE_TEST_MODE", matches = "LIVE") + public void bindingMutationLive(BindingMutation mutation) { + Configuration configuration = Configuration.getGlobalConfiguration(); + String endpoint = configuration.get("FOUNDRY_PROJECT_ENDPOINT", DEFAULT_ENDPOINT); + String model = configuration.get("FOUNDRY_VOICE_MODEL_NAME"); + assertNotNull(model, "FOUNDRY_VOICE_MODEL_NAME is required for live telephony testing."); + String connection = configuration.get("FOUNDRY_TELEPHONY_CONNECTION_1", DEFAULT_CONNECTION_1); + String number = e164(configuration, "FOUNDRY_TELEPHONY_NUMBER_1", DEFAULT_NUMBER_1); + AgentsClientBuilder builder = new AgentsClientBuilder().endpoint(endpoint) + .credential(new DefaultAzureCredentialBuilder().build()) + .allowPreview(true); + AgentsClient agents = builder.buildAgentsClient(); + BetaVoiceAgentsTelephonyClient telephony = builder.beta().buildBetaVoiceAgentsTelephonyClient(); + String agentName = "test-telephony-binding-" + mutation.toString().toLowerCase() + "-" + shortId(); + boolean agentCreated = false; + try { + agents.createAgentVersion(agentName, + new CreateAgentVersionInput(definition(model, "Greet the caller briefly, then say goodbye."))); + agentCreated = true; + TelephonyBinding binding = telephony.createTelephonyBinding(agentName, + new CreateTwilioTelephonyBindingRequest(connection, number).setLabel("Java SDK live test")); + TelephonyBindingListItem listedBinding = findBinding(telephony, agentName, binding.getId()); + assertNotNull(listedBinding.getEtag()); + + if (mutation == BindingMutation.GET) { + TelephonyBinding retrieved = telephony.getTelephonyBinding(agentName, binding.getId()); + assertEquals(binding.getId(), retrieved.getId()); + } else if (mutation == BindingMutation.UPDATE) { + TelephonyBinding updated + = telephony.updateTelephonyBinding(agentName, binding.getId(), listedBinding.getEtag(), + new UpdateTelephonyBindingRequest().setLabel("Updated Java SDK live test")); + assertEquals("Updated Java SDK live test", updated.getLabel()); + } else { + telephony.deleteTelephonyBinding(agentName, binding.getId(), listedBinding.getEtag()); + assertTrue(telephony.listTelephonyBindings(agentName) + .stream() + .noneMatch(item -> binding.getId().equals(item.getId()))); + } + } finally { + if (agentCreated) { + safeCleanup("delete binding test agent", () -> agents.deleteAgent(agentName)); + } + } + } + + @Test + @EnabledIfEnvironmentVariable(named = "AZURE_TEST_MODE", matches = "LIVE") + public void twilioBindingAndOutboundCallLive() throws InterruptedException { + Configuration configuration = Configuration.getGlobalConfiguration(); + String endpoint = configuration.get("FOUNDRY_PROJECT_ENDPOINT", DEFAULT_ENDPOINT); + String model = configuration.get("FOUNDRY_VOICE_MODEL_NAME"); + assertNotNull(model, "FOUNDRY_VOICE_MODEL_NAME is required for live telephony testing."); + String connection1 = configuration.get("FOUNDRY_TELEPHONY_CONNECTION_1", DEFAULT_CONNECTION_1); + String connection2 = configuration.get("FOUNDRY_TELEPHONY_CONNECTION_2", DEFAULT_CONNECTION_2); + String number1 = e164(configuration, "FOUNDRY_TELEPHONY_NUMBER_1", DEFAULT_NUMBER_1); + String number2 = e164(configuration, "FOUNDRY_TELEPHONY_NUMBER_2", DEFAULT_NUMBER_2); + + AgentsClientBuilder builder = new AgentsClientBuilder().endpoint(endpoint) + .credential(new DefaultAzureCredentialBuilder().build()) + .allowPreview(true); + AgentsClient agents = builder.buildAgentsClient(); + BetaVoiceAgentsTelephonyClient telephony = builder.beta().buildBetaVoiceAgentsTelephonyClient(); + String suffix = UUID.randomUUID().toString(); + String inboundAgent = "test-telephony-inbound-" + suffix; + String outboundAgent = "test-telephony-outbound-" + suffix; + String callJobId = null; + String scheduledCallJobId = null; + String inboundCallId = null; + boolean inboundAgentCreated = false; + boolean outboundAgentCreated = false; + try { + agents.createAgentVersion(inboundAgent, + new CreateAgentVersionInput(definition(model, "Greet the caller briefly, then say goodbye."))); + inboundAgentCreated = true; + agents.createAgentVersion(outboundAgent, + new CreateAgentVersionInput(definition(model, "Say hello, wait for one reply, then say goodbye."))); + outboundAgentCreated = true; + + TelephonyBinding binding = telephony.createTelephonyBinding(inboundAgent, + new CreateTwilioTelephonyBindingRequest(connection1, number1).setLabel("Java SDK live test")); + assertNotNull(binding.getId()); + assertEquals(TelephonyProvider.TWILIO, binding.getProvider()); + assertEquals(TelephonyBindingStatus.ACTIVE, binding.getStatus()); + assertNotNull(binding.getIncomingCallUrl()); + + TelephonyBindingListItem listedBinding = findBinding(telephony, inboundAgent, binding.getId()); + assertNotNull(listedBinding.getEtag()); + + Response initialTargetsResponse + = telephony.getTelephonyTransferTargetsWithResponse(inboundAgent, new RequestOptions()); + TelephonyTransferTargets initialTargets + = initialTargetsResponse.getValue().toObject(TelephonyTransferTargets.class); + assertTrue(initialTargets.getTransferTargets().isEmpty()); + TelephonyTransferTarget transferTarget = new TelephonyTransferTarget("test_number_2", + "Java SDK live test target", new PstnTelephonyTransferDestination(number2)); + TelephonyTransferTargets replacedTargets = telephony.replaceTelephonyTransferTargets(inboundAgent, + requireEtag(initialTargetsResponse, "telephony transfer targets"), + Collections.singletonList(transferTarget)); + assertEquals(1, replacedTargets.getTransferTargets().size()); + + CreateTelephonyCallJobRequest request = new CreateTelephonyCallJobRequest( + new TelephonyOutboundDestination(TelephonyOutboundDestinationType.PHONE_NUMBER, number1), connection2, + number2).setPurpose("Java SDK live telephony validation"); + TelephonyCallJob job + = telephony.createTelephonyCallJob(outboundAgent, UUID.randomUUID().toString(), request); + callJobId = job.getId(); + assertNotNull(callJobId); + assertEquals(outboundAgent, job.getAgentName()); + assertEquals(connection2, job.getConnectionName()); + assertEquals(number2, job.getSource()); + + TelephonyCallSummary inboundCall = waitForInboundCall(telephony, inboundAgent); + inboundCallId = inboundCall.getId(); + assertNotNull(inboundCallId); + assertEquals(TelephonyProvider.TWILIO, inboundCall.getProvider()); + assertEquals(number2, inboundCall.getCallerNumber()); + assertEquals(number1, inboundCall.getProviderNumber()); + + TelephonyCallRecord callRecord = telephony.getTelephonyCall(inboundAgent, inboundCallId); + assertEquals(inboundCallId, callRecord.getId()); + TelephonyCallRecord endedCall = telephony.endTelephonyCall(inboundAgent, inboundCallId); + assertEquals(inboundCallId, endedCall.getId()); + inboundCallId = null; + + TelephonyCallJob dispatchedJob = telephony.getTelephonyCallJob(outboundAgent, callJobId); + assertTrue(dispatchedJob.getAttemptCount() > 0, "The outbound call job did not create an attempt."); + + OffsetDateTime notBefore = OffsetDateTime.now().plusMinutes(10); + CreateTelephonyCallJobRequest scheduledRequest = new CreateTelephonyCallJobRequest( + new TelephonyOutboundDestination(TelephonyOutboundDestinationType.PHONE_NUMBER, number1), connection2, + number2).setPurpose("Java SDK live cancellation validation") + .setSchedule( + new TelephonyCallJobSchedule().setNotBefore(notBefore).setExpiresAt(notBefore.plusMinutes(10))); + TelephonyCallJob scheduledJob + = telephony.createTelephonyCallJob(outboundAgent, UUID.randomUUID().toString(), scheduledRequest); + scheduledCallJobId = scheduledJob.getId(); + TelephonyCallJob cancelledJob = telephony.cancelTelephonyCallJob(outboundAgent, scheduledCallJobId, + Long.toString(scheduledJob.getRevision())); + assertNotNull(cancelledJob.getCancellation()); + scheduledCallJobId = null; + + telephony.replaceTelephonyTransferTargets(inboundAgent, getTransferTargetsEtag(telephony, inboundAgent), + Collections.emptyList()); + } finally { + if (inboundCallId != null) { + String callId = inboundCallId; + safeCleanup("end inbound call", () -> telephony.endTelephonyCall(inboundAgent, callId)); + } + if (callJobId != null) { + String jobId = callJobId; + safeCleanup("cancel outbound call job", () -> { + TelephonyCallJob currentJob = telephony.getTelephonyCallJob(outboundAgent, jobId); + telephony.cancelTelephonyCallJob(outboundAgent, jobId, Long.toString(currentJob.getRevision())); + }); + } + if (scheduledCallJobId != null) { + String jobId = scheduledCallJobId; + safeCleanup("cancel scheduled outbound call job", () -> { + TelephonyCallJob scheduledJob = telephony.getTelephonyCallJob(outboundAgent, jobId); + telephony.cancelTelephonyCallJob(outboundAgent, jobId, Long.toString(scheduledJob.getRevision())); + }); + } + if (inboundAgentCreated) { + safeCleanup("clear telephony transfer targets", + () -> telephony.replaceTelephonyTransferTargets(inboundAgent, + getTransferTargetsEtag(telephony, inboundAgent), Collections.emptyList())); + } + if (outboundAgentCreated) { + safeCleanup("delete outbound agent", () -> agents.deleteAgent(outboundAgent)); + } + if (inboundAgentCreated) { + safeCleanup("delete inbound agent", () -> agents.deleteAgent(inboundAgent)); + } + } + } + + private static TelephonyCallSummary waitForInboundCall(BetaVoiceAgentsTelephonyClient telephony, String agentName) + throws InterruptedException { + long deadline = System.nanoTime() + CALL_TIMEOUT.toNanos(); + while (System.nanoTime() < deadline) { + for (TelephonyCallSummary call : telephony.listTelephonyCalls(agentName)) { + return call; + } + Thread.sleep(POLL_INTERVAL.toMillis()); + } + throw new AssertionError("No inbound Twilio call arrived within " + CALL_TIMEOUT + "."); + } + + private static VoiceAgentDefinition definition(String model, String instructions) { + return new VoiceAgentDefinition().setModelType(VoiceModelType.MANAGED) + .setModel(model) + .setInstructions(instructions) + .setOutputModalities(Collections.singletonList(VoiceOutputModality.AUDIO)) + .setAudio(new VoiceAgentAudioConfig().setOutput( + new VoiceAgentAudioOutputConfig().setVoice("en-US-AvaNeural").setVoiceType(VoiceType.AZURE_STANDARD))); + } + + private static String e164(Configuration configuration, String name, String defaultValue) { + String value = configuration.get(name, defaultValue); + assertNotNull(value, name + " is required for live telephony testing."); + value = value.trim(); + assertTrue(value.matches("^\\+[1-9]\\d{7,14}$"), name + " must be an E.164 number such as +14255550123."); + return value; + } + + private static String shortId() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 12); + } + + private static TelephonyBindingListItem findBinding(BetaVoiceAgentsTelephonyClient telephony, String agentName, + String bindingId) { + return telephony.listTelephonyBindings(agentName) + .stream() + .filter(item -> bindingId.equals(item.getId())) + .findFirst() + .orElseThrow(() -> new AssertionError("Created binding was not listed.")); + } + + private static String getTransferTargetsEtag(BetaVoiceAgentsTelephonyClient telephony, String agentName) { + return requireEtag(telephony.getTelephonyTransferTargetsWithResponse(agentName, new RequestOptions()), + "telephony transfer targets"); + } + + private static String requireEtag(Response response, String resource) { + String etag = response.getHeaders().getValue(HttpHeaderName.ETAG); + assertNotNull(etag, "The service did not return an ETag for " + resource + "."); + return etag; + } + + private static void safeCleanup(String action, Runnable cleanup) { + try { + cleanup.run(); + } catch (RuntimeException exception) { + System.err.printf("Failed to %s: %s%n", action, exception.getMessage()); + } + } +} diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentTelephonyTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentTelephonyTests.java new file mode 100644 index 0000000000000..0073de2028faa --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentTelephonyTests.java @@ -0,0 +1,439 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.voice; + +import com.azure.ai.agents.AgentsClientBuilder; +import com.azure.ai.agents.BetaVoiceAgentsTelephonyAsyncClient; +import com.azure.ai.agents.BetaVoiceAgentsTelephonyClient; +import com.azure.ai.agents.models.CreateTelephonyCallJobRequest; +import com.azure.ai.agents.models.CreateTwilioTelephonyBindingRequest; +import com.azure.ai.agents.models.TelephonyBinding; +import com.azure.ai.agents.models.TelephonyBindingListItem; +import com.azure.ai.agents.models.TelephonyBindingStatus; +import com.azure.ai.agents.models.TelephonyCallRecord; +import com.azure.ai.agents.models.TelephonyCallJob; +import com.azure.ai.agents.models.TelephonyCallJobStatus; +import com.azure.ai.agents.models.TelephonyCallSummary; +import com.azure.ai.agents.models.TelephonyOutboundDestination; +import com.azure.ai.agents.models.TelephonyOutboundDestinationType; +import com.azure.ai.agents.models.TelephonyOperation; +import com.azure.ai.agents.models.TelephonyTransferTargets; +import com.azure.ai.agents.models.UpdateTelephonyBindingRequest; +import com.azure.core.exception.HttpResponseException; +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.HttpMethod; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.HttpResponse; +import com.azure.core.test.http.MockHttpResponse; +import com.azure.core.test.utils.MockTokenCredential; +import com.azure.core.util.BinaryData; +import com.azure.core.util.Context; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.Collections; +import java.util.Deque; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Supplier; +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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Sync/async parity for the Python telephony suites. These are HTTP contract tests, not recordings or live calls. + * Python currently skips these scenarios for service routing/version issues. No provider account is needed here. + */ +public class VoiceAgentTelephonyTests { + private static final String AGENT = "voice-telephony-test"; + private static final String MISSING = "nonexistent-id"; + private static final String ROOT = "/agents/" + AGENT + "/telephony"; + private static final String CONNECTION_1 = "twilio-sdk-testing-1"; + private static final String CONNECTION_2 = "twilio-sdk-testing-2"; + private static final String NUMBER_1 = "+13853864628"; + private static final String NUMBER_2 = "+18509702029"; + private static final HttpHeaderName IDEMPOTENCY_KEY = HttpHeaderName.fromString("Idempotency-Key"); + private static final Duration TIMEOUT = Duration.ofSeconds(10); + private static final String EMPTY_PAGE = "{\"data\":[],\"has_more\":false}"; + private static final String EMPTY_TARGETS = "{\"transfer_targets\":[]}"; + private static final String TARGETS = "{\"transfer_targets\":[{\"name\":\"sales_desk\"," + + "\"description\":\"Transfers to the sales desk for pricing questions.\"," + + "\"destination\":{\"kind\":\"pstn\",\"value\":\"+14255550123\"}}]}"; + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void createsTwilioBindingAndOutboundCallJob(boolean async) { + String bindingRequest = "{\"connection_name\":\"" + CONNECTION_1 + "\",\"label\":\"Java SDK test\"," + + "\"phone_number\":\"" + NUMBER_1 + "\",\"provider\":\"twilio\"}"; + String bindingResponse = "{\"provider\":\"twilio\",\"id\":\"binding-1\",\"label\":\"Java SDK test\"," + + "\"status\":\"active\",\"incoming_call_url\":\"https://example.test/incoming\"," + + "\"connection_name\":\"" + CONNECTION_1 + "\",\"phone_number\":\"" + NUMBER_1 + "\"}"; + String jobRequest = "{\"destination\":{\"type\":\"phone_number\",\"value\":\"" + NUMBER_1 + "\"}," + + "\"connection_name\":\"" + CONNECTION_2 + "\",\"source\":\"" + NUMBER_2 + "\"," + + "\"purpose\":\"Java SDK telephony validation\"}"; + String jobResponse = "{\"destination\":{\"type\":\"phone_number\",\"value\":\"" + NUMBER_1 + "\"}," + + "\"connection_name\":\"" + CONNECTION_2 + "\",\"source\":\"" + NUMBER_2 + "\"," + + "\"purpose\":\"Java SDK telephony validation\",\"id\":\"job-1\"," + + "\"object\":\"telephony.call_job\",\"agent_name\":\"" + AGENT + "\",\"status\":\"accepted\"," + + "\"retry_policy\":{\"max_attempts\":1},\"attempt_count\":0,\"revision\":1," + + "\"created_at\":1,\"updated_at\":1}"; + String cancelledJobResponse = jobResponse.replace("\"status\":\"accepted\"", "\"status\":\"cancelled\"") + .replace("\"revision\":1", "\"revision\":2"); + String operationResponse = "{\"id\":\"operation-1\",\"object\":\"telephony.operation\"," + + "\"status\":\"succeeded\",\"created_at\":1," + + "\"resource\":{\"id\":\"job-1\",\"type\":\"telephony.call_job\"}}"; + ScriptedTransport transport = new ScriptedTransport(async); + transport.expect(HttpMethod.POST, ROOT + "/bindings", bindingRequest, 201, bindingResponse); + transport.expect(HttpMethod.POST, ROOT + "/call_jobs", jobRequest, + header(IDEMPOTENCY_KEY, "offline-idempotency-key"), 202, jobResponse, new HttpHeaders()); + transport.expect(HttpMethod.GET, ROOT + "/call_jobs/job-1", null, 200, jobResponse); + transport.expect(HttpMethod.POST, ROOT + "/call_jobs/job-1:cancel", null, header(HttpHeaderName.IF_MATCH, "1"), + 200, cancelledJobResponse, new HttpHeaders()); + transport.expect(HttpMethod.GET, ROOT + "/operations/operation-1", null, 200, operationResponse); + AgentsClientBuilder builder = builder(transport); + BetaVoiceAgentsTelephonyClient syncClient = builder.beta().buildBetaVoiceAgentsTelephonyClient(); + BetaVoiceAgentsTelephonyAsyncClient asyncClient = builder.beta().buildBetaVoiceAgentsTelephonyAsyncClient(); + + CreateTwilioTelephonyBindingRequest bindingRequestModel + = new CreateTwilioTelephonyBindingRequest(CONNECTION_1, NUMBER_1).setLabel("Java SDK test"); + TelephonyBinding binding = call(async, () -> syncClient.createTelephonyBinding(AGENT, bindingRequestModel), + () -> asyncClient.createTelephonyBinding(AGENT, bindingRequestModel)); + assertEquals("binding-1", binding.getId()); + assertEquals(TelephonyBindingStatus.ACTIVE, binding.getStatus()); + + CreateTelephonyCallJobRequest jobRequestModel = new CreateTelephonyCallJobRequest( + new TelephonyOutboundDestination(TelephonyOutboundDestinationType.PHONE_NUMBER, NUMBER_1), CONNECTION_2, + NUMBER_2).setPurpose("Java SDK telephony validation"); + TelephonyCallJob job + = call(async, () -> syncClient.createTelephonyCallJob(AGENT, "offline-idempotency-key", jobRequestModel), + () -> asyncClient.createTelephonyCallJob(AGENT, "offline-idempotency-key", jobRequestModel)); + assertEquals("job-1", job.getId()); + assertEquals(TelephonyCallJobStatus.ACCEPTED, job.getStatus()); + assertEquals(1L, job.getRevision()); + assertEquals("job-1", call(async, () -> syncClient.getTelephonyCallJob(AGENT, "job-1"), + () -> asyncClient.getTelephonyCallJob(AGENT, "job-1")).getId()); + TelephonyCallJob cancelled = call(async, () -> syncClient.cancelTelephonyCallJob(AGENT, "job-1", "1"), + () -> asyncClient.cancelTelephonyCallJob(AGENT, "job-1", "1")); + assertEquals(TelephonyCallJobStatus.CANCELLED, cancelled.getStatus()); + assertEquals(2L, cancelled.getRevision()); + TelephonyOperation operation = call(async, () -> syncClient.getTelephonyOperation(AGENT, "operation-1"), + () -> asyncClient.getTelephonyOperation(AGENT, "operation-1")); + assertEquals("operation-1", operation.getId()); + assertEquals("job-1", operation.getResource().getId()); + transport.assertComplete(); + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void bindingTransferTargetAndCallLifecycle(boolean async) { + String bindingPath = ROOT + "/bindings/binding-1"; + String binding = "{\"provider\":\"twilio\",\"id\":\"binding-1\",\"label\":\"Java SDK test\"," + + "\"status\":\"active\",\"incoming_call_url\":\"https://example.test/incoming\"," + + "\"connection_name\":\"" + CONNECTION_1 + "\",\"phone_number\":\"" + NUMBER_1 + "\"}"; + String listedBinding = binding.substring(0, binding.length() - 1) + ",\"etag\":\"binding-etag\"}"; + String updatedBinding = binding.replace("Java SDK test", "Updated Java SDK test"); + String callPath = ROOT + "/calls/call-1"; + String activeCall = callRecord("in_progress", "bridging"); + String endedCall = callRecord("success", "completed"); + ScriptedTransport transport = new ScriptedTransport(async); + transport.expect(HttpMethod.GET, bindingPath, null, 200, binding); + transport.expect(HttpMethod.GET, ROOT + "/bindings", null, 200, + "{\"data\":[" + listedBinding + "],\"has_more\":false}"); + transport.expect(HttpMethod.PATCH, bindingPath, "{\"status\":\"active\",\"label\":\"Updated Java SDK test\"}", + header(HttpHeaderName.IF_MATCH, "*"), 200, updatedBinding, new HttpHeaders()); + transport.expect(HttpMethod.GET, ROOT + "/transfer_targets", null, Collections.emptyMap(), 200, EMPTY_TARGETS, + new HttpHeaders().set(HttpHeaderName.ETAG, "targets-etag")); + transport.expect(HttpMethod.PUT, ROOT + "/transfer_targets", TARGETS, + header(HttpHeaderName.IF_MATCH, "targets-etag"), 200, TARGETS, + new HttpHeaders().set(HttpHeaderName.ETAG, "updated-targets-etag")); + transport.expect(HttpMethod.GET, ROOT + "/calls", null, 200, + "{\"data\":[" + activeCall + "],\"has_more\":false}"); + transport.expect(HttpMethod.GET, callPath, null, 200, activeCall); + transport.expect(HttpMethod.POST, callPath + ":transfer", "{\"target\":\"sales_desk\"}", 200, activeCall); + transport.expect(HttpMethod.POST, callPath + ":end", null, 200, endedCall); + transport.expect(HttpMethod.DELETE, bindingPath, null, header(HttpHeaderName.IF_MATCH, "*"), 204, null, + new HttpHeaders()); + AgentsClientBuilder builder = builder(transport); + BetaVoiceAgentsTelephonyClient syncClient = builder.beta().buildBetaVoiceAgentsTelephonyClient(); + BetaVoiceAgentsTelephonyAsyncClient asyncClient = builder.beta().buildBetaVoiceAgentsTelephonyAsyncClient(); + + assertEquals("binding-1", call(async, () -> syncClient.getTelephonyBinding(AGENT, "binding-1"), + () -> asyncClient.getTelephonyBinding(AGENT, "binding-1")).getId()); + TelephonyBindingListItem listed = async + ? asyncClient.listTelephonyBindings(AGENT).blockFirst(TIMEOUT) + : syncClient.listTelephonyBindings(AGENT).iterator().next(); + assertNotNull(listed); + assertEquals("binding-etag", listed.getEtag()); + UpdateTelephonyBindingRequest update + = new UpdateTelephonyBindingRequest().setStatus(TelephonyBindingStatus.ACTIVE) + .setLabel("Updated Java SDK test"); + assertEquals("Updated Java SDK test", + call(async, () -> syncClient.updateTelephonyBinding(AGENT, "binding-1", "*", update), + () -> asyncClient.updateTelephonyBinding(AGENT, "binding-1", "*", update)).getLabel()); + assertTrue(call(async, () -> syncClient.getTelephonyTransferTargets(AGENT), + () -> asyncClient.getTelephonyTransferTargets(AGENT)).getTransferTargets().isEmpty()); + TelephonyTransferTargets desired = BinaryData.fromString(TARGETS).toObject(TelephonyTransferTargets.class); + assertTargets(call(async, + () -> syncClient.replaceTelephonyTransferTargets(AGENT, "targets-etag", desired.getTransferTargets()), + () -> asyncClient.replaceTelephonyTransferTargets(AGENT, "targets-etag", desired.getTransferTargets()))); + TelephonyCallSummary summary = async + ? asyncClient.listTelephonyCalls(AGENT).blockFirst(TIMEOUT) + : syncClient.listTelephonyCalls(AGENT).iterator().next(); + assertNotNull(summary); + assertEquals("call-1", summary.getId()); + assertEquals("call-1", call(async, () -> syncClient.getTelephonyCall(AGENT, "call-1"), + () -> asyncClient.getTelephonyCall(AGENT, "call-1")).getId()); + TelephonyCallRecord transferred + = call(async, () -> syncClient.transferTelephonyCall(AGENT, "call-1", "sales_desk"), + () -> asyncClient.transferTelephonyCall(AGENT, "call-1", "sales_desk")); + assertEquals("call-1", transferred.getId()); + assertEquals("success", call(async, () -> syncClient.endTelephonyCall(AGENT, "call-1"), + () -> asyncClient.endTelephonyCall(AGENT, "call-1")).getStatus().toString()); + call(async, () -> { + syncClient.deleteTelephonyBinding(AGENT, "binding-1", "*"); + return null; + }, () -> asyncClient.deleteTelephonyBinding(AGENT, "binding-1", "*")); + transport.assertComplete(); + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void bindingsAndTransferTargets(boolean async) { + ScriptedTransport transport = new ScriptedTransport(async); + transport.expect(HttpMethod.GET, ROOT + "/bindings", null, 200, EMPTY_PAGE); + transport.expect(HttpMethod.GET, ROOT + "/transfer_targets", null, Collections.emptyMap(), 200, EMPTY_TARGETS, + new HttpHeaders().set(HttpHeaderName.ETAG, "targets-etag-1")); + transport.expect(HttpMethod.PUT, ROOT + "/transfer_targets", TARGETS, + header(HttpHeaderName.IF_MATCH, "targets-etag-1"), 200, TARGETS, + new HttpHeaders().set(HttpHeaderName.ETAG, "targets-etag-2")); + transport.expect(HttpMethod.GET, ROOT + "/transfer_targets", null, Collections.emptyMap(), 200, TARGETS, + new HttpHeaders().set(HttpHeaderName.ETAG, "targets-etag-2")); + transport.expect(HttpMethod.PUT, ROOT + "/transfer_targets", EMPTY_TARGETS, + header(HttpHeaderName.IF_MATCH, "targets-etag-2"), 200, EMPTY_TARGETS, + new HttpHeaders().set(HttpHeaderName.ETAG, "targets-etag-3")); + transport.notFound(HttpMethod.GET, ROOT + "/bindings/" + MISSING, null); + transport.notFound(HttpMethod.PATCH, ROOT + "/bindings/" + MISSING, "{\"status\":\"suspended\"}"); + transport.notFound(HttpMethod.DELETE, ROOT + "/bindings/" + MISSING, null); + AgentsClientBuilder builder = builder(transport); + BetaVoiceAgentsTelephonyClient syncClient = builder.beta().buildBetaVoiceAgentsTelephonyClient(); + BetaVoiceAgentsTelephonyAsyncClient asyncClient = builder.beta().buildBetaVoiceAgentsTelephonyAsyncClient(); + assertEquals(0L, + async + ? asyncClient.listTelephonyBindings(AGENT).count().block(TIMEOUT) + : syncClient.listTelephonyBindings(AGENT).stream().count()); + assertTrue(call(async, () -> syncClient.getTelephonyTransferTargets(AGENT), + () -> asyncClient.getTelephonyTransferTargets(AGENT)).getTransferTargets().isEmpty()); + TelephonyTransferTargets desired = BinaryData.fromString(TARGETS).toObject(TelephonyTransferTargets.class); + TelephonyTransferTargets replaced = call(async, + () -> syncClient.replaceTelephonyTransferTargets(AGENT, "targets-etag-1", desired.getTransferTargets()), + () -> asyncClient.replaceTelephonyTransferTargets(AGENT, "targets-etag-1", desired.getTransferTargets())); + assertTargets(replaced); + assertTargets(call(async, () -> syncClient.getTelephonyTransferTargets(AGENT), + () -> asyncClient.getTelephonyTransferTargets(AGENT))); + assertTrue(call(async, + () -> syncClient.replaceTelephonyTransferTargets(AGENT, "targets-etag-2", Collections.emptyList()), + () -> asyncClient.replaceTelephonyTransferTargets(AGENT, "targets-etag-2", Collections.emptyList())) + .getTransferTargets() + .isEmpty()); + assertNotFound(() -> call(async, () -> syncClient.getTelephonyBinding(AGENT, MISSING), + () -> asyncClient.getTelephonyBinding(AGENT, MISSING)), true); + UpdateTelephonyBindingRequest update + = new UpdateTelephonyBindingRequest().setStatus(TelephonyBindingStatus.SUSPENDED); + assertNotFound(() -> call(async, () -> syncClient.updateTelephonyBinding(AGENT, MISSING, null, update), + () -> asyncClient.updateTelephonyBinding(AGENT, MISSING, null, update)), true); + assertNotFound(() -> call(async, () -> { + syncClient.deleteTelephonyBinding(AGENT, MISSING, null); + return null; + }, () -> asyncClient.deleteTelephonyBinding(AGENT, MISSING, null)), true); + transport.assertComplete(); + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void callsNotFound(boolean async) { + ScriptedTransport transport = new ScriptedTransport(async); + transport.expect(HttpMethod.GET, ROOT + "/calls", null, 200, EMPTY_PAGE); + transport.notFound(HttpMethod.GET, ROOT + "/calls/" + MISSING, null); + transport.notFound(HttpMethod.POST, ROOT + "/calls/" + MISSING + ":transfer", + "{\"target\":\"nonexistent-target\"}"); + transport.notFound(HttpMethod.POST, ROOT + "/calls/" + MISSING + ":end", null); + AgentsClientBuilder builder = builder(transport); + BetaVoiceAgentsTelephonyClient syncClient = builder.beta().buildBetaVoiceAgentsTelephonyClient(); + BetaVoiceAgentsTelephonyAsyncClient asyncClient = builder.beta().buildBetaVoiceAgentsTelephonyAsyncClient(); + assertEquals(0L, + async + ? asyncClient.listTelephonyCalls(AGENT).count().block(TIMEOUT) + : syncClient.listTelephonyCalls(AGENT).stream().count()); + assertNotFound(() -> call(async, () -> syncClient.getTelephonyCall(AGENT, MISSING), + () -> asyncClient.getTelephonyCall(AGENT, MISSING)), true); + assertNotFound(() -> call(async, () -> syncClient.transferTelephonyCall(AGENT, MISSING, "nonexistent-target"), + () -> asyncClient.transferTelephonyCall(AGENT, MISSING, "nonexistent-target")), false); + assertNotFound(() -> call(async, () -> syncClient.endTelephonyCall(AGENT, MISSING), + () -> asyncClient.endTelephonyCall(AGENT, MISSING)), false); + transport.assertComplete(); + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void generatedAudioNotFound(boolean async) { + ScriptedTransport transport = new ScriptedTransport(async); + String path = "/agents/" + AGENT + "/endpoint/protocols/voice/conversations/" + MISSING + "/items/" + MISSING + + "/audio/generated"; + transport.notFound(HttpMethod.GET, path, null); + transport.notFound(HttpMethod.GET, path + "/content", null); + AgentsClientBuilder builder = builder(transport); + assertNotFound(() -> call(async, + () -> builder.beta() + .buildBetaVoiceAgentsConversationsClient() + .getAgentConversationGeneratedAudioItem(AGENT, MISSING, MISSING), + () -> builder.beta() + .buildBetaVoiceAgentsConversationsAsyncClient() + .getAgentConversationGeneratedAudioItem(AGENT, MISSING, MISSING)), + true); + assertNotFound(() -> call(async, + () -> builder.beta() + .buildBetaVoiceAgentsConversationsClient() + .downloadAgentConversationGeneratedAudioItem(AGENT, MISSING, MISSING), + () -> builder.beta() + .buildBetaVoiceAgentsConversationsAsyncClient() + .downloadAgentConversationGeneratedAudioItem(AGENT, MISSING, MISSING)), + false); + transport.assertComplete(); + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void callJobNotFound(boolean async) { + ScriptedTransport transport = new ScriptedTransport(async); + transport.notFound(HttpMethod.GET, ROOT + "/call_jobs/" + MISSING, null); + transport.notFound(HttpMethod.POST, ROOT + "/call_jobs/" + MISSING + ":cancel", null); + AgentsClientBuilder builder = builder(transport); + BetaVoiceAgentsTelephonyClient syncClient = builder.beta().buildBetaVoiceAgentsTelephonyClient(); + BetaVoiceAgentsTelephonyAsyncClient asyncClient = builder.beta().buildBetaVoiceAgentsTelephonyAsyncClient(); + assertNotFound(() -> call(async, () -> syncClient.getTelephonyCallJob(AGENT, MISSING), + () -> asyncClient.getTelephonyCallJob(AGENT, MISSING)), true); + assertNotFound(() -> call(async, () -> syncClient.cancelTelephonyCallJob(AGENT, MISSING, null), + () -> asyncClient.cancelTelephonyCallJob(AGENT, MISSING, null)), true); + transport.assertComplete(); + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void operationNotFound(boolean async) { + ScriptedTransport transport = new ScriptedTransport(async); + transport.notFound(HttpMethod.GET, ROOT + "/operations/" + MISSING, null); + AgentsClientBuilder builder = builder(transport); + assertNotFound( + () -> call(async, + () -> builder.beta().buildBetaVoiceAgentsTelephonyClient().getTelephonyOperation(AGENT, MISSING), + () -> builder.beta().buildBetaVoiceAgentsTelephonyAsyncClient().getTelephonyOperation(AGENT, MISSING)), + true); + transport.assertComplete(); + } + + private static void assertTargets(TelephonyTransferTargets targets) { + assertNotNull(targets); + assertEquals(1, targets.getTransferTargets().size()); + assertEquals("sales_desk", targets.getTransferTargets().get(0).getName()); + assertEquals("pstn", targets.getTransferTargets().get(0).getDestination().getKind().toString()); + } + + private static Map header(HttpHeaderName name, String value) { + return Collections.singletonMap(name, value); + } + + private static String callRecord(String status, String phase) { + return "{\"id\":\"call-1\",\"provider\":\"twilio\",\"status\":\"" + status + "\",\"phase\":\"" + phase + + "\",\"started_at\":1,\"events\":[],\"events_truncated\":false,\"caller_number\":\"" + NUMBER_2 + + "\",\"provider_number\":\"" + NUMBER_1 + "\"}"; + } + + private static T call(boolean async, Supplier syncCall, Supplier> asyncCall) { + return async ? asyncCall.get().block(TIMEOUT) : syncCall.get(); + } + + private static void assertNotFound(Runnable operation, boolean typed) { + HttpResponseException error = assertThrows(HttpResponseException.class, operation::run); + assertEquals(404, error.getResponse().getStatusCode()); + if (typed) { + assertInstanceOf(ResourceNotFoundException.class, error); + } + } + + private static AgentsClientBuilder builder(HttpClient transport) { + return new AgentsClientBuilder().endpoint("https://localhost") + .credential(new MockTokenCredential()) + .httpClient(transport) + .allowPreview(true); + } + + private static final class ScriptedTransport implements HttpClient { + private final boolean async; + private final Deque> requests = new ArrayDeque<>(); + + ScriptedTransport(boolean async) { + this.async = async; + } + + void notFound(HttpMethod method, String path, String body) { + expect(method, path, body, 404, "{\"error\":{\"code\":\"NotFound\",\"message\":\"Resource not found\"}}"); + } + + void expect(HttpMethod method, String path, String body, int status, String response) { + expect(method, path, body, Collections.emptyMap(), status, response, new HttpHeaders()); + } + + void expect(HttpMethod method, String path, String body, Map expectedHeaders, + int status, String response, HttpHeaders responseHeaders) { + requests.add(request -> { + assertEquals(method, request.getHttpMethod()); + assertEquals(path, request.getUrl().getPath()); + for (Map.Entry header : expectedHeaders.entrySet()) { + assertEquals(header.getValue(), request.getHeaders().getValue(header.getKey())); + } + if (!expectedHeaders.containsKey(HttpHeaderName.IF_MATCH)) { + assertNull(request.getHeaders().getValue(HttpHeaderName.IF_MATCH)); + } + if (body != null) { + assertEquals(BinaryData.fromString(body).toObject(Map.class), + request.getBodyAsBinaryData().toObject(Map.class)); + } + responseHeaders.set(HttpHeaderName.CONTENT_TYPE, "application/json"); + byte[] responseBody = response == null ? new byte[0] : BinaryData.fromString(response).toBytes(); + return new MockHttpResponse(request, status, responseHeaders, responseBody); + }); + } + + private HttpResponse respond(HttpRequest request) { + assertFalse(requests.isEmpty(), "Unexpected request: " + request.getUrl()); + return requests.removeFirst().apply(request); + } + + @Override + public Mono send(HttpRequest request) { + assertTrue(async, "Sync test must use synchronous HTTP."); + return Mono.fromSupplier(() -> respond(request)); + } + + @Override + public HttpResponse sendSync(HttpRequest request, Context context) { + assertFalse(async, "Async test must use asynchronous HTTP."); + return respond(request); + } + + void assertComplete() { + assertTrue(requests.isEmpty(), "All telephony operations must be called."); + } + } +} diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentWebSocketSessionTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentWebSocketSessionTests.java new file mode 100644 index 0000000000000..b54d105dd78b4 --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentWebSocketSessionTests.java @@ -0,0 +1,1038 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents.voice; + +import com.azure.ai.agents.AgentsClientBuilder; +import com.azure.ai.agents.BetaVoiceAgentWebSocketAsyncClient; +import com.azure.ai.agents.BetaVoiceAgentWebSocketClient; +import com.azure.ai.agents.VoiceAgentWebSocketSessionAsyncClient; +import com.azure.ai.agents.VoiceAgentWebSocketSessionClient; +import com.azure.ai.agents.implementation.realtime.VoiceAgentWebSocketHttpResponse; +import com.azure.ai.agents.models.RawRealtimeServerEvent; +import com.azure.ai.agents.models.RealtimeClientEventResponseCreate; +import com.azure.ai.agents.models.RealtimeServerEventSessionCreated; +import com.azure.ai.agents.models.RealtimeServerEvent; +import com.azure.ai.agents.models.VoiceAgentServerEventWarning; +import com.azure.ai.agents.models.VoiceAgentTransport; +import com.azure.ai.agents.models.VoiceAgentWebSocketConnectionOptions; +import com.azure.ai.agents.models.VoiceAgentWebSocketOverflowStrategy; +import com.azure.core.credential.AccessToken; +import com.azure.core.credential.TokenCredential; +import com.azure.core.exception.ResourceModifiedException; +import com.azure.core.util.BinaryData; +import com.azure.core.util.ClientOptions; +import com.azure.core.util.Configuration; +import com.azure.core.util.Header; +import io.netty.buffer.Unpooled; +import io.netty.handler.codec.http.DefaultFullHttpResponse; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpVersion; +import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame; +import io.netty.handler.codec.http.websocketx.ContinuationWebSocketFrame; +import io.netty.handler.codec.http.websocketx.PingWebSocketFrame; +import io.netty.handler.codec.http.websocketx.PongWebSocketFrame; +import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; +import io.netty.handler.codec.http.websocketx.WebSocketFrame; +import java.io.File; +import java.io.InputStream; +import java.net.URI; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.KeyStore; +import java.security.cert.CertificateFactory; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.netty.DisposableServer; +import reactor.netty.http.Http11SslContextSpec; +import reactor.netty.http.server.HttpServer; +import reactor.netty.http.server.WebsocketServerSpec; +import reactor.test.StepVerifier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +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 VoiceAgentWebSocketSessionTests { + private DisposableServer server; + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void handshakeOverridesPreserveQueryAndSingleUserAgent(boolean async) { + AtomicReference requestUri = new AtomicReference<>(); + AtomicReference headers = new AtomicReference<>(); + server = tlsServer().host("localhost").port(0).handle((request, response) -> { + requestUri.set(request.uri()); + headers.set(request.requestHeaders().copy()); + return response.sendWebsocket((inbound, outbound) -> inbound.receive().then(), + WebsocketServerSpec.builder().protocols("realtime").build()); + }).bindNow(); + AgentsClientBuilder builder + = new AgentsClientBuilder().endpoint("https://localhost:" + server.port() + "/api/projects/project/") + .credential(request -> Mono.just(new AccessToken("test-token", OffsetDateTime.now().plusHours(1)))) + .configuration(Configuration.NONE); + for (String userAgentHeader : new String[] { "", "User-Agent", "user-agent" }) { + Map extra = new LinkedHashMap<>(); + extra.put("X-Custom", "custom-value"); + extra.put("Authorization", "must-not-override-token"); + extra.put("Sec-WebSocket-Protocol", "other"); + VoiceAgentWebSocketConnectionOptions options + = tlsOptions().setExtraQuery(Collections.singletonMap("foo", "bar value")); + if (!userAgentHeader.isEmpty()) { + extra.put(userAgentHeader, "custom-user-agent"); + options.setConnectionUrl(URI.create("wss://localhost:" + server.port() + "/custom?sig=abc")); + } + options.setExtraHeaders(extra); + if (async) { + VoiceAgentWebSocketSessionAsyncClient session = builder.beta() + .buildBetaVoiceAgentWebSocketAsyncClient() + .connect("agent name", options) + .block(Duration.ofSeconds(5)); + session.closeAsync().block(Duration.ofSeconds(5)); + assertFalse(session.isOpen()); + } else { + VoiceAgentWebSocketSessionClient session + = builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent name", options); + session.close(); + assertFalse(session.isOpen()); + } + assertEquals("Bearer test-token", headers.get().get(HttpHeaderNames.AUTHORIZATION)); + assertEquals("realtime", headers.get().get(HttpHeaderNames.SEC_WEBSOCKET_PROTOCOL)); + assertEquals("VoiceAgents=V1Preview", headers.get().get("Foundry-Features")); + assertEquals("custom-value", headers.get().get("X-Custom")); + assertEquals(1, headers.get().getAll(HttpHeaderNames.USER_AGENT).size()); + String userAgent = headers.get().get(HttpHeaderNames.USER_AGENT); + String uri = decode(requestUri.get()); + assertTrue(uri.contains("api-version=v1")); + assertTrue(uri.contains("foo=bar value")); + assertEquals(1, requestUri.get().chars().filter(character -> character == '?').count()); + if (userAgentHeader.isEmpty()) { + assertTrue(userAgent.startsWith("azsdk-java-azure-ai-agents/"), userAgent); + assertTrue(uri.contains("x-ms-client-sdk=" + userAgent)); + assertTrue(uri.startsWith("/api/projects/project/agents/agent name/endpoint/protocols/voice?")); + } else { + assertEquals("custom-user-agent", userAgent); + assertTrue(uri.startsWith("/custom?")); + assertTrue(uri.contains("sig=abc")); + assertTrue(uri.contains("x-ms-client-sdk=azsdk-java-azure-ai-agents/"), uri); + } + } + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void typedStringAndMappingSendsRejectInvalidJson(boolean async) { + List messages = new CopyOnWriteArrayList<>(); + server = startServer(messages, new AtomicReference<>(), new AtomicReference<>(), new AtomicReference<>(), + new AtomicReference<>(), new AtomicReference<>(), false); + AgentsClientBuilder builder = new AgentsClientBuilder().endpoint("https://localhost:" + server.port()) + .credential(request -> Mono.just(new AccessToken("test-token", OffsetDateTime.now().plusHours(1)))); + String raw = "{\"type\": \"response.create\"}"; + BinaryData mapping = BinaryData.fromObject(Collections.singletonMap("type", "response.cancel")); + if (async) { + VoiceAgentWebSocketSessionAsyncClient session = builder.beta() + .buildBetaVoiceAgentWebSocketAsyncClient() + .connect("agent", tlsOptions()) + .block(Duration.ofSeconds(5)); + try { + StepVerifier.create(session.sendEvent(BinaryData.fromString("not valid json"))) + .expectError(IllegalArgumentException.class) + .verify(Duration.ofSeconds(5)); + session.sendEvent(new RealtimeClientEventResponseCreate()) + .then(session.sendEvent(BinaryData.fromString(raw))) + .then(session.sendEvent(mapping)) + .block(Duration.ofSeconds(5)); + StepVerifier.create(session.receiveEvents().take(3)).expectNextCount(3).verifyComplete(); + } finally { + session.close(); + } + } else { + try (VoiceAgentWebSocketSessionClient session + = builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent", tlsOptions())) { + assertThrows(IllegalArgumentException.class, + () -> session.sendEvent(BinaryData.fromString("not valid json"))); + session.sendEvent(new RealtimeClientEventResponseCreate()); + session.sendEvent(BinaryData.fromString(raw)); + session.sendEvent(mapping); + Iterator events = session.receiveEvents(Duration.ofSeconds(5)).iterator(); + for (int index = 0; index < 3; index++) { + assertWarningEvent(events.next()); + } + } + } + assertEquals(3, messages.size()); + assertEquals("response.create", BinaryData.fromString(messages.get(0)).toObject(Map.class).get("type")); + assertEquals(raw, messages.get(1)); + assertEquals(mapping.toObject(Map.class), BinaryData.fromString(messages.get(2)).toObject(Map.class)); + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void pingPongFramesAreNotApplicationEvents(boolean async) { + Flux frames = Flux.defer(() -> Flux.just(new PingWebSocketFrame(), new PongWebSocketFrame(), + new TextWebSocketFrame("{\"type\":\"session.created\",\"session\":{}}"), + new TextWebSocketFrame("{\"type\":\"future.event\",\"foo\":\"bar\"}"))); + server = tlsServer().host("localhost") + .port(0) + .handle((request, response) -> response.sendWebsocket( + (inbound, outbound) -> outbound.sendObject(frames).then(inbound.receive().then()), + WebsocketServerSpec.builder().protocols("realtime").build())) + .bindNow(); + AgentsClientBuilder builder = new AgentsClientBuilder().endpoint("https://localhost:" + server.port()) + .credential(request -> Mono.just(new AccessToken("test-token", OffsetDateTime.now().plusHours(1)))); + List events = new ArrayList<>(); + if (async) { + VoiceAgentWebSocketSessionAsyncClient session = builder.beta() + .buildBetaVoiceAgentWebSocketAsyncClient() + .connect("agent", tlsOptions()) + .block(Duration.ofSeconds(5)); + try { + events.addAll(session.receiveEvents().take(2).collectList().block(Duration.ofSeconds(5))); + } finally { + session.close(); + } + } else { + try (VoiceAgentWebSocketSessionClient session + = builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent", tlsOptions())) { + Iterator iterator = session.receiveEvents(Duration.ofSeconds(5)).iterator(); + events.add(iterator.next()); + events.add(iterator.next()); + } + } + assertEquals(2, events.size()); + assertInstanceOf(RealtimeServerEventSessionCreated.class, events.get(0)); + RawRealtimeServerEvent unknown = assertInstanceOf(RawRealtimeServerEvent.class, events.get(1)); + assertEquals("bar", unknown.getRawEvent().toObject(Map.class).get("foo")); + } + + @Test + public void explicitDefaultPortOverrideIsTrustedBeforeAuthentication() { + AtomicInteger tokens = new AtomicInteger(); + IllegalStateException tokenError = new IllegalStateException("Stop before network access."); + AgentsClientBuilder builder + = new AgentsClientBuilder().endpoint("https://example.com/api/projects/project").credential(request -> { + tokens.incrementAndGet(); + return Mono.error(tokenError); + }); + VoiceAgentWebSocketConnectionOptions options + = new VoiceAgentWebSocketConnectionOptions().setConnectionUrl(URI.create("wss://example.com:443/custom")); + assertEquals(tokenError, assertThrows(IllegalStateException.class, + () -> builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent", options))); + StepVerifier.create(builder.beta().buildBetaVoiceAgentWebSocketAsyncClient().connect("agent", options)) + .expectErrorSatisfies(error -> assertEquals(tokenError, error)) + .verify(Duration.ofSeconds(5)); + assertEquals(2, tokens.get()); + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void malformedEventsCanBeReportedAndSkipped(boolean async) { + AtomicInteger failures = new AtomicInteger(); + server = frameWebSocketServer(Flux.defer(() -> Flux.just(new TextWebSocketFrame("{broken"), + new BinaryWebSocketFrame(Unpooled.wrappedBuffer(new byte[] { (byte) 0xc3, 0x28 })), + new BinaryWebSocketFrame(Unpooled.copiedBuffer(warningJson(), StandardCharsets.UTF_8)), + new TextWebSocketFrame(warningJson())))); + VoiceAgentWebSocketConnectionOptions options + = tlsOptions().setMalformedEventHandler(error -> failures.incrementAndGet()); + AgentsClientBuilder builder = new AgentsClientBuilder().endpoint("https://localhost:" + server.port()) + .credential(request -> Mono.just(new AccessToken("test-token", OffsetDateTime.now().plusHours(1)))); + if (async) { + VoiceAgentWebSocketSessionAsyncClient session = builder.beta() + .buildBetaVoiceAgentWebSocketAsyncClient() + .connect("agent", options) + .block(Duration.ofSeconds(5)); + StepVerifier.create(session.receiveEvents()) + .assertNext(this::assertWarningEvent) + .assertNext(this::assertWarningEvent) + .verifyComplete(); + session.close(); + } else { + try (VoiceAgentWebSocketSessionClient session + = builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent", options)) { + Iterator events = session.receiveEvents(Duration.ofSeconds(5)).iterator(); + assertWarningEvent(events.next()); + assertWarningEvent(events.next()); + assertFalse(events.hasNext()); + } + } + assertEquals(2, failures.get()); + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void boundedQueuesHonorOverflowPolicies(boolean async) { + for (VoiceAgentWebSocketOverflowStrategy strategy : VoiceAgentWebSocketOverflowStrategy.values()) { + server = frameWebSocketServer(Flux.range(0, 4) + .map(index -> new TextWebSocketFrame("{\"type\":\"future.event\",\"index\":" + index + "}"))); + VoiceAgentWebSocketConnectionOptions options + = tlsOptions().setReceiveBufferCapacity(2).setOverflowStrategy(strategy); + AgentsClientBuilder builder = new AgentsClientBuilder().endpoint("https://localhost:" + server.port()) + .credential(request -> Mono.just(new AccessToken("test-token", OffsetDateTime.now().plusHours(1)))); + List received = new ArrayList<>(); + boolean overflowError = strategy == VoiceAgentWebSocketOverflowStrategy.ERROR; + if (async) { + VoiceAgentWebSocketSessionAsyncClient session = builder.beta() + .buildBetaVoiceAgentWebSocketAsyncClient() + .connect("agent", options) + .block(Duration.ofSeconds(5)); + assertTimeoutPreemptively(Duration.ofSeconds(5), () -> { + while (session.isOpen()) { + Thread.yield(); + } + }); + if (overflowError) { + StepVerifier.create(session.receiveEvents()) + .expectNextCount(2) + .expectError(IllegalStateException.class) + .verify(); + } else { + received.addAll(session.receiveEvents().collectList().block(Duration.ofSeconds(5))); + } + session.close(); + } else { + try (VoiceAgentWebSocketSessionClient session + = builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent", options)) { + assertTimeoutPreemptively(Duration.ofSeconds(5), () -> { + while (session.isOpen()) { + Thread.yield(); + } + }); + Iterator events = session.receiveEvents(Duration.ofSeconds(5)).iterator(); + if (overflowError) { + assertThrows(IllegalStateException.class, events::hasNext); + } else { + events.forEachRemaining(received::add); + } + } + } + if (!overflowError) { + assertEquals(2, received.size()); + int first = strategy == VoiceAgentWebSocketOverflowStrategy.DROP_OLDEST ? 2 : 0; + for (int index = 0; index < received.size(); index++) { + assertEquals(first + index, + ((RawRealtimeServerEvent) received.get(index)).getRawEvent().toObject(Map.class).get("index")); + } + } + server.disposeNow(); + } + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void messageSizeLimitCannotBeBypassedByRecoveryHandler(boolean async) { + server = oneShotWebSocketServer(warningJson()); + AtomicBoolean recovered = new AtomicBoolean(); + VoiceAgentWebSocketConnectionOptions options + = tlsOptions().setMaxMessageSize(16).setMalformedEventHandler(error -> recovered.set(true)); + AgentsClientBuilder builder = new AgentsClientBuilder().endpoint("https://localhost:" + server.port()) + .credential(request -> Mono.just(new AccessToken("test-token", OffsetDateTime.now().plusHours(1)))); + if (async) { + StepVerifier.create(builder.beta() + .buildBetaVoiceAgentWebSocketAsyncClient() + .connect("agent", options) + .flatMapMany(VoiceAgentWebSocketSessionAsyncClient::receiveEvents)).expectError().verify(); + } else { + assertThrows(RuntimeException.class, () -> { + try (VoiceAgentWebSocketSessionClient session + = builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent", options)) { + session.receiveEvents(Duration.ofSeconds(5)).iterator().next(); + } + }); + } + assertFalse(recovered.get()); + } + + @Test + public void rawEventRoundTripsAndOptionsValidateBounds() throws Exception { + BinaryData payload = BinaryData.fromString("{\"type\":\"future.event\",\"nested\":{\"value\":42}}"); + RawRealtimeServerEvent event = new RawRealtimeServerEvent(payload); + RawRealtimeServerEvent copy = BinaryData.fromObject(event).toObject(RawRealtimeServerEvent.class); + assertEquals(payload.toObject(Map.class), copy.getRawEvent().toObject(Map.class)); + server = oneShotWebSocketServer("{\"type\":42,\"value\":1}"); + VoiceAgentWebSocketSessionAsyncClient session + = createAsyncClient(server.port()).connect("agent", tlsOptions()).block(Duration.ofSeconds(5)); + try { + StepVerifier.create(session.receiveEvents()) + .assertNext(received -> assertInstanceOf(RawRealtimeServerEvent.class, received)) + .verifyComplete(); + } finally { + session.close(); + } + VoiceAgentWebSocketConnectionOptions options = new VoiceAgentWebSocketConnectionOptions(); + assertThrows(IllegalArgumentException.class, () -> options.setReceiveBufferCapacity(0)); + assertThrows(IllegalArgumentException.class, () -> options.setReceiveBufferCapacity(65537)); + assertThrows(IllegalArgumentException.class, () -> options.setMaxMessageSize(0)); + assertThrows(NullPointerException.class, () -> options.setOverflowStrategy(null)); + } + + @Test + public void insecureEndpointsAreRejectedBeforeAuthentication() { + AtomicBoolean requested = new AtomicBoolean(); + TokenCredential credential = context -> { + requested.set(true); + return Mono.error(new AssertionError("Token retrieval must not run.")); + }; + for (String endpoint : new String[] { + "http://example.com", + "ws://example.com", + "https://user@example.com", + "https://example.com/#fragment" }) { + AgentsClientBuilder builder = new AgentsClientBuilder().endpoint(endpoint).credential(credential); + assertThrows(IllegalArgumentException.class, + () -> builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent")); + StepVerifier.create(builder.beta().buildBetaVoiceAgentWebSocketAsyncClient().connect("agent")) + .expectError(IllegalArgumentException.class) + .verify(); + } + AgentsClientBuilder builder = new AgentsClientBuilder().endpoint("https://example.com").credential(credential); + for (String override : new String[] { + "ws://example.com", + "wss://other.example.com", + "wss://example.com:8443" }) { + VoiceAgentWebSocketConnectionOptions options + = new VoiceAgentWebSocketConnectionOptions().setConnectionUrl(URI.create(override)); + assertThrows(IllegalArgumentException.class, + () -> builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent", options)); + StepVerifier.create(builder.beta().buildBetaVoiceAgentWebSocketAsyncClient().connect("agent", options)) + .expectError(IllegalArgumentException.class) + .verify(); + } + assertFalse(requested.get()); + } + + private static File tlsResource(String name) { + try { + return resourceFile(name); + } catch (Exception error) { + throw new IllegalStateException(error); + } + } + + private static HttpServer tlsServer() { + return HttpServer.create() + .secure(ssl -> ssl.sslContext(Http11SslContextSpec.forServer(tlsResource("websocket-localhost-cert.pem"), + tlsResource("websocket-localhost-key.pem")))); + } + + private static VoiceAgentWebSocketConnectionOptions tlsOptions() { + return new VoiceAgentWebSocketConnectionOptions() + .setAsyncHttpClientConfiguration( + client -> client.secure(ssl -> ssl.sslContext(Http11SslContextSpec.forClient() + .configure(builder -> builder.trustManager(tlsResource("websocket-localhost-cert.pem")))))) + .setHttpClientConfiguration(builder -> { + try (InputStream input = Files.newInputStream(tlsResource("websocket-localhost-cert.pem").toPath())) { + KeyStore store = KeyStore.getInstance(KeyStore.getDefaultType()); + store.load(null, null); + store.setCertificateEntry("localhost", + CertificateFactory.getInstance("X.509").generateCertificate(input)); + TrustManagerFactory factory + = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + factory.init(store); + X509TrustManager trust = (X509TrustManager) factory.getTrustManagers()[0]; + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, new TrustManager[] { trust }, null); + builder.sslSocketFactory(context.getSocketFactory(), trust); + } catch (Exception error) { + throw new IllegalStateException(error); + } + }); + } + + @Test + public void rawEventsUseCustomizedTlsTransports() { + List messages = new CopyOnWriteArrayList<>(); + server = tlsServer().host("localhost") + .port(0) + .handle((request, response) -> response.sendWebsocket( + (inbound, outbound) -> outbound.sendString(inbound.receive().asString().doOnNext(messages::add)).then(), + WebsocketServerSpec.builder().protocols("realtime").build())) + .bindNow(); + AgentsClientBuilder builder = new AgentsClientBuilder().endpoint("https://localhost:" + server.port()) + .credential(request -> Mono.just(new AccessToken("test-token", OffsetDateTime.now().plusHours(1)))); + BinaryData payload = BinaryData.fromString("{\"type\":\"future.event\",\"value\":42}"); + try (VoiceAgentWebSocketSessionClient session + = builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent", tlsOptions())) { + assertThrows(IllegalArgumentException.class, () -> session.sendEvent(BinaryData.fromString("[]"))); + assertThrows(IllegalArgumentException.class, () -> session.sendEvent(BinaryData.fromString("{} {}"))); + session.sendEvent(payload); + RawRealtimeServerEvent received = assertInstanceOf(RawRealtimeServerEvent.class, + session.receiveEvents(Duration.ofSeconds(5)).iterator().next()); + assertEquals(payload.toObject(Map.class), received.getRawEvent().toObject(Map.class)); + } + VoiceAgentWebSocketSessionAsyncClient session = builder.beta() + .buildBetaVoiceAgentWebSocketAsyncClient() + .connect("agent", tlsOptions()) + .block(Duration.ofSeconds(5)); + StepVerifier.create(session.sendEvent(BinaryData.fromString("[]"))) + .expectError(IllegalArgumentException.class) + .verify(); + StepVerifier.create(session.receiveEvents().take(1)) + .then(() -> session.sendEvent(payload).block(Duration.ofSeconds(5))) + .assertNext(event -> assertEquals(42, + ((RawRealtimeServerEvent) event).getRawEvent().toObject(Map.class).get("value"))) + .verifyComplete(); + session.close(); + assertEquals(2, messages.size()); + } + + @Test + public void customCloseFrameAndReceiveTimeout() { + server = startServer(new CopyOnWriteArrayList<>(), new AtomicReference<>(), new AtomicReference<>(), + new AtomicReference<>(), new AtomicReference<>(), new AtomicReference<>(), false); + AgentsClientBuilder builder + = new AgentsClientBuilder().endpoint("https://localhost:" + server.port() + "/api/projects/project") + .credential(request -> Mono.just(new AccessToken("test-token", OffsetDateTime.now().plusHours(1)))); + try (VoiceAgentWebSocketSessionClient session + = builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent", tlsOptions())) { + Iterator iterator = session.receiveEvents(Duration.ofMillis(20)).iterator(); + IllegalStateException timeout = assertThrows(IllegalStateException.class, iterator::hasNext); + assertInstanceOf(TimeoutException.class, timeout.getCause()); + assertTrue(session.isOpen()); + assertThrows(IllegalArgumentException.class, () -> session.close(1005, "invalid")); + session.close(4001, "finished"); + assertEquals(4001, session.getCloseCode()); + assertEquals("finished", session.getCloseReason()); + } + VoiceAgentWebSocketSessionAsyncClient session = builder.beta() + .buildBetaVoiceAgentWebSocketAsyncClient() + .connect("agent", tlsOptions()) + .block(Duration.ofSeconds(5)); + assertNotNull(session); + StepVerifier.create(session.closeAsync(1006, "invalid")).expectError(IllegalArgumentException.class).verify(); + session.closeAsync(4002, "done").block(Duration.ofSeconds(5)); + assertEquals(4002, session.getCloseCode()); + assertEquals("done", session.getCloseReason()); + } + + @AfterEach + public void disposeServer() { + if (server != null) { + server.disposeNow(); + } + } + + @Test + public void asyncSessionNegotiatesHandshakeAndExchangesTypedEvents() { + List clientMessages = new CopyOnWriteArrayList<>(); + AtomicReference requestUri = new AtomicReference<>(); + AtomicReference authorization = new AtomicReference<>(); + AtomicReference foundryFeatures = new AtomicReference<>(); + AtomicReference userAgent = new AtomicReference<>(); + AtomicReference customHeader = new AtomicReference<>(); + server + = startServer(clientMessages, requestUri, authorization, foundryFeatures, userAgent, customHeader, false); + AtomicReference> requestedScopes = new AtomicReference<>(); + TokenCredential credential = request -> { + requestedScopes.set(request.getScopes()); + return Mono.just(new AccessToken("test-token", OffsetDateTime.now().plusHours(1))); + }; + VoiceAgentWebSocketConnectionOptions options = tlsOptions().setTransport(VoiceAgentTransport.WEBSOCKET) + .setStoreEnabled(true) + .setAgentVersionOverride("version 2"); + BetaVoiceAgentWebSocketAsyncClient client + = new AgentsClientBuilder().endpoint("https://localhost:" + server.port() + "/api/projects/project") + .credential(credential) + .configuration(Configuration.NONE) + .clientOptions(new ClientOptions().setApplicationId("test-app") + .setHeaders(Collections.singletonList(new Header("X-Test-Header", "test-value")))) + .beta() + .buildBetaVoiceAgentWebSocketAsyncClient(); + + VoiceAgentWebSocketSessionAsyncClient session = client.connect("agent name", options).block(); + assertTrue(session.isOpen()); + + StepVerifier.create(session.receiveEvents().take(4)) + .then(() -> session.sendText("hello").block()) + .assertNext(this::assertWarningEvent) + .then(() -> session.appendInputAudio(BinaryData.fromBytes(new byte[] { 1, 2, 3 })).block()) + .assertNext(this::assertWarningEvent) + .then(() -> session.createResponse().block()) + .assertNext(this::assertWarningEvent) + .then(() -> session.cancelResponse("response-1").block()) + .assertNext(this::assertWarningEvent) + .verifyComplete(); + + assertEquals(Collections.singletonList("https://ai.azure.com/.default"), requestedScopes.get()); + assertEquals("Bearer test-token", authorization.get()); + assertEquals("VoiceAgents=V1Preview", foundryFeatures.get()); + assertTrue(userAgent.get().startsWith("test-app azsdk-java-")); + assertEquals("test-value", customHeader.get()); + String decodedUri = decode(requestUri.get()); + assertTrue(decodedUri.contains("/agents/agent name/endpoint/protocols/voice")); + assertTrue(decodedUri.contains("api-version=v1")); + assertTrue(decodedUri.contains("transport=websocket")); + assertTrue(decodedUri.contains("store=true")); + assertTrue(decodedUri.contains("x-agent-version-override=version 2")); + assertTrue(decodedUri.contains("x-ms-client-sdk=test-app azsdk-java-")); + assertEquals(4, clientMessages.size()); + assertTrue(clientMessages.get(0).contains("\"type\":\"conversation.item.create\"")); + assertTrue(clientMessages.get(0).contains("\"role\":\"user\"")); + assertTrue(clientMessages.get(0).contains("\"text\":\"hello\"")); + assertTrue(clientMessages.get(1).contains("\"audio\":\"AQID\"")); + assertTrue(clientMessages.get(2).contains("\"type\":\"response.create\"")); + assertTrue(clientMessages.get(3).contains("\"response_id\":\"response-1\"")); + + StepVerifier.create(session.receiveEvents()) + .expectErrorMatches( + error -> error instanceof IllegalStateException && error.getMessage().contains("Only one")) + .verify(); + session.close(); + assertFalse(session.isOpen()); + } + + @Test + public void syncConnectRejectsNullArguments() { + TokenCredential credential + = request -> Mono.just(new AccessToken("test-token", OffsetDateTime.now().plusHours(1))); + BetaVoiceAgentWebSocketClient client + = new AgentsClientBuilder().endpoint("https://localhost/api/projects/project") + .credential(credential) + .configuration(Configuration.NONE) + .beta() + .buildBetaVoiceAgentWebSocketClient(); + + NullPointerException agentNameException + = assertThrows(NullPointerException.class, () -> client.connect(null, tlsOptions())); + assertEquals("'agentName' cannot be null.", agentNameException.getMessage()); + + NullPointerException optionsException + = assertThrows(NullPointerException.class, () -> client.connect("agent", null)); + assertEquals("'options' cannot be null.", optionsException.getMessage()); + } + + @Test + public void tokenFailureOccursBeforeNetworkAccess() { + AtomicBoolean connected = new AtomicBoolean(); + server = tlsServer().host("localhost").port(0).handle((request, response) -> { + connected.set(true); + return response.send(); + }).bindNow(); + TokenCredential credential = request -> Mono.error(new IllegalStateException("token unavailable")); + BetaVoiceAgentWebSocketAsyncClient client + = new AgentsClientBuilder().endpoint("https://localhost:" + server.port() + "/api/projects/project") + .credential(credential) + .configuration(Configuration.NONE) + .beta() + .buildBetaVoiceAgentWebSocketAsyncClient(); + + StepVerifier.create(client.connect("agent", tlsOptions())) + .expectErrorMatches( + error -> error instanceof IllegalStateException && error.getMessage().contains("token unavailable")) + .verify(); + assertFalse(connected.get()); + } + + @Test + public void tokenAcquisitionDoesNotUseHandshakeTimeout() { + server = startServer(new CopyOnWriteArrayList<>(), new AtomicReference<>(), new AtomicReference<>(), + new AtomicReference<>(), new AtomicReference<>(), new AtomicReference<>(), false); + TokenCredential credential = request -> Mono.delay(Duration.ofMillis(1500)) + .map(ignored -> new AccessToken("test-token", OffsetDateTime.now().plusHours(1))); + BetaVoiceAgentWebSocketAsyncClient client + = new AgentsClientBuilder().endpoint("https://localhost:" + server.port() + "/api/projects/project") + .credential(credential) + .configuration(Configuration.NONE) + .beta() + .buildBetaVoiceAgentWebSocketAsyncClient(); + VoiceAgentWebSocketConnectionOptions options = tlsOptions().setHandshakeTimeout(Duration.ofSeconds(1)); + + StepVerifier.create(client.connect("agent", options).flatMap(session -> { + assertTrue(session.isOpen()); + return session.closeAsync(); + })).verifyComplete(); + } + + @Test + public void syncTokenFailureOccursBeforeNetworkAccess() { + AtomicBoolean connected = new AtomicBoolean(); + server = tlsServer().host("localhost").port(0).handle((request, response) -> { + connected.set(true); + return response.send(); + }).bindNow(); + TokenCredential credential = request -> Mono.error(new IllegalStateException("token unavailable")); + BetaVoiceAgentWebSocketClient client + = new AgentsClientBuilder().endpoint("https://localhost:" + server.port() + "/api/projects/project") + .credential(credential) + .configuration(Configuration.NONE) + .beta() + .buildBetaVoiceAgentWebSocketClient(); + + IllegalStateException exception + = assertThrows(IllegalStateException.class, () -> client.connect("agent", tlsOptions())); + assertTrue(exception.getMessage().contains("token unavailable")); + assertFalse(connected.get()); + } + + @Test + public void rejectedHandshakeMapsConflictToAzureException() { + server = tlsServer().host("localhost") + .port(0) + .handle( + (request, response) -> response.status(HttpResponseStatus.CONFLICT).sendString(Mono.just("conflict"))) + .bindNow(); + TokenCredential credential + = request -> Mono.just(new AccessToken("test-token", OffsetDateTime.now().plusHours(1))); + BetaVoiceAgentWebSocketAsyncClient client + = new AgentsClientBuilder().endpoint("https://localhost:" + server.port() + "/api/projects/project") + .credential(credential) + .configuration(Configuration.NONE) + .beta() + .buildBetaVoiceAgentWebSocketAsyncClient(); + + StepVerifier.create(client.connect("disabled-agent", tlsOptions())).expectErrorSatisfies(error -> { + ResourceModifiedException exception = assertInstanceOf(ResourceModifiedException.class, error); + assertEquals(409, exception.getResponse().getStatusCode()); + }).verify(); + } + + @Test + public void nettyHandshakeResponseExposesBufferedBody() { + DefaultFullHttpResponse nettyResponse = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, + HttpResponseStatus.CONFLICT, Unpooled.copiedBuffer("conflict", StandardCharsets.UTF_8)); + VoiceAgentWebSocketHttpResponse response + = new VoiceAgentWebSocketHttpResponse(URI.create("wss://example.com"), nettyResponse); + + assertEquals("conflict", response.getBodyAsString().block()); + } + + @Test + public void syncRejectedHandshakeMapsConflictToAzureException() { + server = tlsServer().host("localhost") + .port(0) + .handle( + (request, response) -> response.status(HttpResponseStatus.CONFLICT).sendString(Mono.just("conflict"))) + .bindNow(); + TokenCredential credential + = request -> Mono.just(new AccessToken("test-token", OffsetDateTime.now().plusHours(1))); + BetaVoiceAgentWebSocketClient client + = new AgentsClientBuilder().endpoint("https://localhost:" + server.port() + "/api/projects/project") + .credential(credential) + .configuration(Configuration.NONE) + .beta() + .buildBetaVoiceAgentWebSocketClient(); + + ResourceModifiedException exception + = assertThrows(ResourceModifiedException.class, () -> client.connect("disabled-agent", tlsOptions())); + assertEquals(409, exception.getResponse().getStatusCode()); + assertEquals("conflict", exception.getResponse().getBodyAsString().block()); + } + + @Test + public void syncClientRejectsEmptyAgentName() { + TokenCredential credential + = request -> Mono.just(new AccessToken("test-token", OffsetDateTime.now().plusHours(1))); + BetaVoiceAgentWebSocketClient client = new AgentsClientBuilder().endpoint("https://example.com") + .credential(credential) + .configuration(Configuration.NONE) + .beta() + .buildBetaVoiceAgentWebSocketClient(); + + IllegalArgumentException exception + = assertThrows(IllegalArgumentException.class, () -> client.connect("", tlsOptions())); + assertEquals("'agentName' cannot be empty.", exception.getMessage()); + } + + @Test + public void cancellingAsyncConnectCancelsTokenRequest() { + AtomicBoolean tokenRequestCancelled = new AtomicBoolean(); + TokenCredential credential + = request -> Mono.never().doOnCancel(() -> tokenRequestCancelled.set(true)); + BetaVoiceAgentWebSocketAsyncClient client = new AgentsClientBuilder().endpoint("https://example.com") + .credential(credential) + .configuration(Configuration.NONE) + .beta() + .buildBetaVoiceAgentWebSocketAsyncClient(); + + StepVerifier.create(client.connect("agent", tlsOptions())).thenCancel().verify(); + assertTrue(tokenRequestCancelled.get()); + } + + @Test + public void unknownEventFallsBackToRealtimeServerEvent() { + server = oneShotWebSocketServer("{\"type\":\"future.event\",\"value\":42}"); + VoiceAgentWebSocketSessionAsyncClient session + = createAsyncClient(server.port()).connect("agent", tlsOptions()).block(); + + StepVerifier.create(session.receiveEvents()).assertNext(event -> { + assertEquals("future.event", event.getType().toString()); + RawRealtimeServerEvent raw = assertInstanceOf(RawRealtimeServerEvent.class, event); + assertEquals(42, raw.getRawEvent().toObject(Map.class).get("value")); + }).verifyComplete(); + session.close(); + } + + @Test + public void fragmentedTextFrameIsAggregated() { + String message = warningJson(); + int split = message.length() / 2; + Flux frames = Flux.just(new TextWebSocketFrame(false, 0, message.substring(0, split)), + new ContinuationWebSocketFrame(true, 0, message.substring(split))); + server = frameWebSocketServer(frames); + VoiceAgentWebSocketSessionAsyncClient session + = createAsyncClient(server.port()).connect("agent", tlsOptions()).block(); + + StepVerifier.create(session.receiveEvents()).assertNext(this::assertWarningEvent).verifyComplete(); + session.close(); + } + + @Test + public void binaryJsonFrameIsParsed() { + server = frameWebSocketServer( + Mono.just(new BinaryWebSocketFrame(Unpooled.copiedBuffer(warningJson(), StandardCharsets.UTF_8)))); + VoiceAgentWebSocketSessionAsyncClient session + = createAsyncClient(server.port()).connect("agent", tlsOptions()).block(); + + StepVerifier.create(session.receiveEvents()).assertNext(this::assertWarningEvent).verifyComplete(); + session.close(); + } + + @Test + public void malformedJsonTerminatesReceiveStream() { + server = oneShotWebSocketServer("{not-json"); + VoiceAgentWebSocketSessionAsyncClient session + = createAsyncClient(server.port()).connect("agent", tlsOptions()).block(); + + StepVerifier.create(session.receiveEvents()).expectError().verify(); + assertFalse(session.isOpen()); + } + + @Test + public void syncReceiveBufferOverflowFailsTheEventStream() { + Flux frames = Flux.range(0, 257).map(index -> new TextWebSocketFrame(warningJson())); + server = frameWebSocketServer(frames); + TokenCredential credential + = request -> Mono.just(new AccessToken("test-token", OffsetDateTime.now().plusHours(1))); + BetaVoiceAgentWebSocketClient client + = new AgentsClientBuilder().endpoint("https://localhost:" + server.port() + "/api/projects/project") + .credential(credential) + .configuration(Configuration.NONE) + .beta() + .buildBetaVoiceAgentWebSocketClient(); + + try (VoiceAgentWebSocketSessionClient session = client.connect("agent", tlsOptions())) { + assertTimeoutPreemptively(Duration.ofSeconds(5), () -> { + while (session.isOpen()) { + Thread.yield(); + } + }); + IllegalStateException exception + = assertThrows(IllegalStateException.class, () -> session.receiveEvents().iterator().hasNext()); + assertEquals("Voice-agent receive buffer overflow.", exception.getMessage()); + } + } + + @Test + public void syncOrderlyClosePreservesFullReceiveBuffer() { + Flux frames = Flux.range(0, 256).map(index -> new TextWebSocketFrame(warningJson())); + server = frameWebSocketServer(frames); + TokenCredential credential + = request -> Mono.just(new AccessToken("test-token", OffsetDateTime.now().plusHours(1))); + BetaVoiceAgentWebSocketClient client + = new AgentsClientBuilder().endpoint("https://localhost:" + server.port() + "/api/projects/project") + .credential(credential) + .configuration(Configuration.NONE) + .beta() + .buildBetaVoiceAgentWebSocketClient(); + + try (VoiceAgentWebSocketSessionClient session = client.connect("agent", tlsOptions())) { + assertTimeoutPreemptively(Duration.ofSeconds(5), () -> { + Iterator events = session.receiveEvents().iterator(); + int eventCount = 0; + while (events.hasNext()) { + events.next(); + eventCount++; + } + assertEquals(256, eventCount); + }); + } + } + + @Test + public void closeIsIdempotentAndSendAfterCloseFails() { + List clientMessages = new CopyOnWriteArrayList<>(); + server = startServer(clientMessages, new AtomicReference<>(), new AtomicReference<>(), new AtomicReference<>(), + new AtomicReference<>(), new AtomicReference<>(), false); + VoiceAgentWebSocketSessionAsyncClient session + = createAsyncClient(server.port()).connect("agent", tlsOptions()).block(); + + StepVerifier.create(session.closeAsync().then(session.closeAsync())).verifyComplete(); + StepVerifier.create(session.sendText("after close")) + .expectErrorMatches( + error -> error instanceof IllegalStateException && error.getMessage().contains("not open")) + .verify(); + } + + @Test + public void secureSessionUsesWssAndReceivesTypedEvent() throws Exception { + File certificate = resourceFile("websocket-localhost-cert.pem"); + File privateKey = resourceFile("websocket-localhost-key.pem"); + Http11SslContextSpec serverSsl = Http11SslContextSpec.forServer(certificate, privateKey); + WebsocketServerSpec websocketSpec = WebsocketServerSpec.builder().protocols("realtime").build(); + server = tlsServer().host("localhost") + .port(0) + .secure(ssl -> ssl.sslContext(serverSsl)) + .handle((request, response) -> response.sendWebsocket( + (inbound, outbound) -> outbound.sendString(Mono.just(warningJson()), StandardCharsets.UTF_8) + .then(outbound.sendClose()), + websocketSpec)) + .bindNow(); + + TokenCredential credential + = request -> Mono.just(new AccessToken("tls-token", OffsetDateTime.now().plusHours(1))); + VoiceAgentWebSocketSessionAsyncClient session + = new AgentsClientBuilder().endpoint("https://localhost:" + server.port() + "/api/projects/project") + .credential(credential) + .beta() + .buildBetaVoiceAgentWebSocketAsyncClient() + .connect("secure-agent", tlsOptions()) + .block(Duration.ofSeconds(5)); + + assertEquals("wss", session.getEndpoint().getScheme()); + StepVerifier.create(session.receiveEvents()).assertNext(this::assertWarningEvent).verifyComplete(); + session.close(); + } + + @Test + public void syncSessionReceivesTypedEventAndCloses() { + List clientMessages = new CopyOnWriteArrayList<>(); + AtomicReference requestUri = new AtomicReference<>(); + AtomicReference authorization = new AtomicReference<>(); + AtomicReference foundryFeatures = new AtomicReference<>(); + AtomicReference userAgent = new AtomicReference<>(); + server = startServer(clientMessages, requestUri, authorization, foundryFeatures, userAgent, + new AtomicReference<>(), true); + TokenCredential credential + = request -> Mono.just(new AccessToken("sync-token", OffsetDateTime.now().plusHours(1))); + BetaVoiceAgentWebSocketClient client + = new AgentsClientBuilder().endpoint("https://localhost:" + server.port() + "/api/projects/project") + .credential(credential) + .configuration(Configuration.NONE) + .beta() + .buildBetaVoiceAgentWebSocketClient(); + + try (VoiceAgentWebSocketSessionClient session = client.connect("sync-agent", tlsOptions())) { + Iterator events = session.receiveEvents().iterator(); + assertWarningEvent(events.next()); + session.sendFunctionCallOutput("call-1", "{\"temperature\":72}"); + assertWarningEvent(events.next()); + assertWarningEvent(events.next()); + + assertEquals(2, clientMessages.size()); + Map functionOutput = BinaryData.fromString(clientMessages.get(0)).toObject(Map.class); + assertEquals("conversation.item.create", functionOutput.get("type")); + Map item = (Map) functionOutput.get("item"); + assertEquals("function_call_output", item.get("type")); + assertEquals("call-1", item.get("call_id")); + assertEquals("{\"temperature\":72}", item.get("output")); + Map responseCreate = BinaryData.fromString(clientMessages.get(1)).toObject(Map.class); + assertEquals("response.create", responseCreate.get("type")); + assertTrue(session.isOpen()); + } + } + + private BetaVoiceAgentWebSocketAsyncClient createAsyncClient(int port) { + TokenCredential credential + = request -> Mono.just(new AccessToken("test-token", OffsetDateTime.now().plusHours(1))); + return new AgentsClientBuilder().endpoint("https://localhost:" + port + "/api/projects/project") + .credential(credential) + .configuration(Configuration.NONE) + .beta() + .buildBetaVoiceAgentWebSocketAsyncClient(); + } + + private DisposableServer frameWebSocketServer(org.reactivestreams.Publisher frames) { + WebsocketServerSpec spec = WebsocketServerSpec.builder().protocols("realtime").build(); + return tlsServer().host("localhost") + .port(0) + .handle((request, response) -> response + .sendWebsocket((inbound, outbound) -> outbound.sendObject(frames).then(outbound.sendClose()), spec)) + .bindNow(); + } + + private DisposableServer oneShotWebSocketServer(String message) { + WebsocketServerSpec spec = WebsocketServerSpec.builder().protocols("realtime").build(); + return tlsServer().host("localhost") + .port(0) + .handle((request, response) -> response.sendWebsocket((inbound, + outbound) -> outbound.sendString(Mono.just(message), StandardCharsets.UTF_8).then(outbound.sendClose()), + spec)) + .bindNow(); + } + + private DisposableServer startServer(List clientMessages, AtomicReference requestUri, + AtomicReference authorization, AtomicReference foundryFeatures, + AtomicReference userAgent, AtomicReference customHeader, boolean sendInitialEvent) { + WebsocketServerSpec spec = WebsocketServerSpec.builder().protocols("realtime").build(); + return tlsServer().host("localhost").port(0).handle((request, response) -> { + requestUri.set(request.uri()); + authorization.set(request.requestHeaders().get(HttpHeaderNames.AUTHORIZATION)); + foundryFeatures.set(request.requestHeaders().get("Foundry-Features")); + userAgent.set(request.requestHeaders().get(HttpHeaderNames.USER_AGENT)); + customHeader.set(request.requestHeaders().get("X-Test-Header")); + return response.sendWebsocket((inbound, outbound) -> { + Flux replies = inbound.receive() + .asString(StandardCharsets.UTF_8) + .doOnNext(clientMessages::add) + .map(ignored -> warningJson()); + if (sendInitialEvent) { + replies = replies.startWith(warningJson()); + } + return outbound.sendString(replies, StandardCharsets.UTF_8).then(); + }, spec); + }).bindNow(); + } + + private void assertWarningEvent(RealtimeServerEvent event) { + VoiceAgentServerEventWarning warning = assertInstanceOf(VoiceAgentServerEventWarning.class, event); + assertEquals("loopback warning", warning.getWarning().getMessage()); + assertEquals("test_warning", warning.getWarning().getCode()); + } + + private static String warningJson() { + return "{\"type\":\"warning\",\"event_id\":\"event-1\",\"warning\":{" + + "\"message\":\"loopback warning\",\"code\":\"test_warning\"}}"; + } + + private static File resourceFile(String name) throws Exception { + URL resource = VoiceAgentWebSocketSessionTests.class.getClassLoader().getResource(name); + assertNotNull(resource); + return Paths.get(resource.toURI()).toFile(); + } + + private static String decode(String value) { + try { + return URLDecoder.decode(value, StandardCharsets.UTF_8.name()); + } catch (Exception error) { + throw new IllegalStateException(error); + } + } +} diff --git a/sdk/ai/azure-ai-agents/src/test/resources/websocket-localhost-cert.pem b/sdk/ai/azure-ai-agents/src/test/resources/websocket-localhost-cert.pem new file mode 100644 index 0000000000000..51389bf8a86e1 --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/test/resources/websocket-localhost-cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDJTCCAg2gAwIBAgIUfAusxvG/l3WCuKMFyNBEfEr30rswDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDkwMzA5NTgyMloXDTM2MDgz +MTA5NTgyMlowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEA3YAeebCQfQbC+BoOyHqM6EgmXxIfpixqG26ElKdCehCH +yt6FqnHhvjf/TRfbwgij/GVxIR1wO4CXkAATNMxsaFu/EbDzI/eDqZKIOQIzd0if +JHC20kVibaFnNDlI9NKC/Ywphz0d8JXCHnYVMVJP27moNYcG91/Lka8223O+qoAw +sta603tAcpsFEl9muc8y0UhwAKED03Gr0mjjGZZ6vTvCE+i3IsslZKXqtS6Fo1wm +NTUCpB/yF8i+WnnVrLetMy45D3hEjPeh2p8cjTHOsOyKkqNAH6hVB0bBzCccN+cY +1YtI0A4Umu7FO5RlAoR8eYnBdoZ06qpv1eXjwj/QZwIDAQABo28wbTAdBgNVHQ4E +FgQU6saTU+QSavalG6I49czWXwtSwY0wHwYDVR0jBBgwFoAU6saTU+QSavalG6I4 +9czWXwtSwY0wDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SH +BH8AAAEwDQYJKoZIhvcNAQELBQADggEBAJ73cFPhCnH1IoItmWDJxhIaR7g1MIfh +o7DxnXEf8ZFw7bpzo4Epp6R6+RRH/fnbosw73vtDqEVZQfxKKjAo0NvguNJIuOoz +oISXYpAIX8eBT2ZrH6m0tJfgwyp7V0+SaHChy1+TmtnaT7rfC7N5r/rcr1abQV78 +qUK7N1+aF0dV1fGE4oP3jon+MNc7pZSagVDTz/k2qHwDnwPoVG37BXf7UZ7jbA2g +/afI/YHCt7zT8aHtjJWJMWLgHOtFTGqx1h7x1rEiLNPK/6USrSFEw+7ZQDFSbG7g +NQyC8lVm3QCVeze2q2/x1DUz20UnGaHz+o3fuK+qsDg/NtHIUMM3wj4= +-----END CERTIFICATE----- diff --git a/sdk/ai/azure-ai-agents/src/test/resources/websocket-localhost-key.pem b/sdk/ai/azure-ai-agents/src/test/resources/websocket-localhost-key.pem new file mode 100644 index 0000000000000..d1576a3c7c4d2 --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/test/resources/websocket-localhost-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDdgB55sJB9BsL4 +Gg7IeozoSCZfEh+mLGobboSUp0J6EIfK3oWqceG+N/9NF9vCCKP8ZXEhHXA7gJeQ +ABM0zGxoW78RsPMj94Opkog5AjN3SJ8kcLbSRWJtoWc0OUj00oL9jCmHPR3wlcIe +dhUxUk/buag1hwb3X8uRrzbbc76qgDCy1rrTe0BymwUSX2a5zzLRSHAAoQPTcavS +aOMZlnq9O8IT6LciyyVkpeq1LoWjXCY1NQKkH/IXyL5aedWst60zLjkPeESM96Ha +nxyNMc6w7IqSo0AfqFUHRsHMJxw35xjVi0jQDhSa7sU7lGUChHx5icF2hnTqqm/V +5ePCP9BnAgMBAAECggEAASQTFmWptpRLVjpkIfWno92DRjpgGVu87C3SRLgJjOhE +CIJ6WFmyGrEnbxE5ZLMuaHxEtVY+e1JEGilagkIdBEQF23/mQwAqYZem6oxB7Qk5 +J3wu27/XdTw/dET7RMr98E74XzgaFheWPfURdym28ruBFQRbv9PgWUWdDt2/ndBY +e9XDZ0737YDGjWkZFwLZ/q6YDEUc4NhTClVvzCyTlLMVVL4xsvPzmyxlT093Hdys +ZDs/6UVJOPYsgv7Z9ww6fwv+oPi/oNtvX3dEOjEQkLvmIfXrPSvYQZVQ6Ok5K0UF +eKUP8tB2ZIrX70nhg8R8ThFjldMPb/lS2i59PWQBgQKBgQDwehtp27diMPnUo7ZR +xSPt2UAUTiRRlwQo4rFZNiR5ZMLPBAX4DP5aQDyDiTAMqBOjHqgipagra9xOapCN +uqlEINaMlsuSwMD1cxkp85V5FWab+u1MqBr3B1aq2INqrYDGmPs3kS6Po4M0N12+ +O6Bob4YWBaabIY+rEEJuBmK9QQKBgQDrzGyXVRYGarKW4tiWVeRAA5yNF/w0YW8B +u62wUXLXUXfzhND4ETCUxUpgkcjY1AICWQQnbUFXi/0WUowkKZg8Vh0zfBJmsbs2 +LPhCUEMITKB3owwJLKDCpSake+9Afxi7XB4UltsjInep1XGE6tvKKr9bAF1Sqd47 +V74dv0CbpwKBgQCkuT/l91dar2myuqG8yWmfF13Jiu1d5jA3QXFyRqAdd2PqIjtk +eqIQeEf7YhHD2a354poRgZ/8flnebSivrNkdjdDpZLH1yItkln76OZx94Kb02aGL +DOvLov8+8Ci0/jxjzY7ntU9LnRnWvsY79OQgJaSXmS9SvF6JMw4OB9nDAQKBgQCJ +kxrkbJtOISCTkkTl6bUjeDf1xkG62gInU7XyAoNrhzfiF+LIaVcb5cQQdd5mS8Pk +VMVsr30JND70sDLdwnr08RVWfZRK4HWnFTO/lQ6XIAYb50BVdflRt4PFQh4EVmM6 +pXNTdfTjGfARYdw6vcCAwtIkqSDJ4xwrKXVd68EpTwKBgDURo00XBIFSyqAbvxwF +NchLRcoNZVxxd9hHGTUiTgstfDGxEuHpWEETiVsGGnU/Mq+cTKW98P3y/mdW9Gd+ +mYRmo1L0J6lkBMP3xD5PXxbvTWkxVReuSCl0zUqfKcHjwllpsS91If0HVeg0c5ze +/C4ecakj7llQKhUYIm/dNULb +-----END PRIVATE KEY----- diff --git a/sdk/ai/azure-ai-projects/CHANGELOG.md b/sdk/ai/azure-ai-projects/CHANGELOG.md index 90db0a049b367..96311c7b747cd 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 `TelemetryClient` and `TelemetryAsyncClient` 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..461e0cd509f69 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 +TelemetryClient telemetry = builder.buildTelemetryClient(); +String connectionString = telemetry.getApplicationInsightsConnectionString(); + +TelemetryAsyncClient telemetryAsync = builder.buildTelemetryAsyncClient(); +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. 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..30ae5533454db 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,10 +1,16 @@ 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.NodeList; +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.expr.AnnotationExpr; +import com.github.javaparser.ast.expr.ArrayInitializerExpr; +import com.github.javaparser.ast.expr.Expression; +import com.github.javaparser.ast.expr.MemberValuePair; import com.github.javaparser.ast.expr.NormalAnnotationExpr; import com.github.javaparser.ast.expr.StringLiteralExpr; import java.io.IOException; @@ -25,10 +31,45 @@ public class ProjectsCustomizations extends Customization { @Override public void customize(LibraryCustomization libraryCustomization, Logger logger) { + libraryCustomization.getClass("com.azure.ai.projects", "AIProjectClientBuilder").customizeAst(ast -> { + MethodDeclaration pipelineMethod = ast.getClassByName("AIProjectClientBuilder") + .orElseThrow(() -> new IllegalStateException("Generated AIProjectClientBuilder was not found.")) + .getMethodsByName("createHttpPipeline") + .stream() + .filter(method -> method.getParameters().isEmpty()) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Generated createHttpPipeline was not found.")); + pipelineMethod.setBody(StaticJavaParser.parseBlock("{ return createHttpPipeline(true); }")); + }); + libraryCustomization.getClass("com.azure.ai.projects", "AIProjectClientBuilder").customizeAst(ast -> + addTelemetryClients(ast.getClassByName("AIProjectClientBuilder") + .orElseThrow(() -> new IllegalStateException("Generated AIProjectClientBuilder was not found.")))); annotateBetaClients(libraryCustomization, logger); annotateBetaFields(libraryCustomization, loadBetaAnnotations(logger), logger); } + private static void addTelemetryClients(ClassOrInterfaceDeclaration builder) { + NormalAnnotationExpr annotation = builder.getAnnotationByName("ServiceClientBuilder") + .filter(AnnotationExpr::isNormalAnnotationExpr) + .map(AnnotationExpr::asNormalAnnotationExpr) + .orElseThrow(() -> new IllegalStateException( + builder.getNameAsString() + " has no normal @ServiceClientBuilder annotation.")); + MemberValuePair pair = annotation.getPairs().stream() + .filter(candidate -> "serviceClients".equals(candidate.getNameAsString())) + .findFirst() + .orElseThrow(() -> new IllegalStateException("@ServiceClientBuilder has no serviceClients value.")); + Expression value = pair.getValue(); + ArrayInitializerExpr clients = value.isArrayInitializerExpr() + ? value.asArrayInitializerExpr() + : new ArrayInitializerExpr(new NodeList<>(value)); + for (String serviceClient : new String[] { "TelemetryClient.class", "TelemetryAsyncClient.class" }) { + if (clients.getValues().stream().noneMatch(existing -> serviceClient.equals(existing.toString()))) { + clients.getValues().add(StaticJavaParser.parseExpression(serviceClient)); + } + } + pair.setValue(clients); + } + 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..a323138432c17 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. @@ -83,7 +86,9 @@ DatasetsAsyncClient.class, IndexesAsyncClient.class, DeploymentsAsyncClient.class, - EvaluationRulesAsyncClient.class }) + EvaluationRulesAsyncClient.class, + TelemetryClient.class, + TelemetryAsyncClient.class }) public final class AIProjectClientBuilder implements HttpTrait, ConfigurationTrait, TokenCredentialTrait, EndpointTrait { @@ -102,6 +107,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 @@ -344,9 +354,6 @@ private AIProjectClientImpl buildInnerClient() { private AIProjectClientImpl buildInnerClient(String previewFeatures) { this.validateClient(); - if (CoreUtils.isNullOrEmpty(previewFeatures)) { - return buildInnerClient(); - } HttpPipeline localPipeline = resolvePipeline(previewFeatures); AIProjectsServiceVersion localServiceVersion = (serviceVersion != null) ? serviceVersion : AIProjectsServiceVersion.getLatest(); @@ -364,9 +371,13 @@ private void validateClient() { @Generated private HttpPipeline createHttpPipeline() { + return createHttpPipeline(true); + } + + private HttpPipeline createHttpPipeline(boolean authenticate) { 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"); @@ -385,14 +396,14 @@ private HttpPipeline createHttpPipeline() { HttpPolicyProviders.addBeforeRetryPolicies(policies); policies.add(ClientBuilderUtil.validateAndGetRetryPolicy(retryPolicy, retryOptions, new RetryPolicy())); policies.add(new AddDatePolicy()); - if (tokenCredential != null) { + if (authenticate && tokenCredential != null) { policies.add(new BearerTokenAuthenticationPolicy(tokenCredential, DEFAULT_SCOPES)); } this.pipelinePolicies.stream() .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 +413,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 : createHttpPipeline(false); + 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); } /** @@ -420,6 +463,24 @@ public ConnectionsAsyncClient buildConnectionsAsyncClient() { return new ConnectionsAsyncClient(buildInnerClient().getConnections()); } + /** + * Builds an asynchronous client for the project's telemetry configuration. + * + * @return an asynchronous telemetry client. + */ + public TelemetryAsyncClient buildTelemetryAsyncClient() { + return new TelemetryAsyncClient(buildConnectionsAsyncClient()); + } + + /** + * Builds a synchronous client for the project's telemetry configuration. + * + * @return a synchronous telemetry client. + */ + public TelemetryClient buildTelemetryClient() { + return new TelemetryClient(buildConnectionsClient()); + } + /** * Builds an instance of DatasetsAsyncClient class. * @@ -518,7 +579,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 +606,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 +629,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 +658,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 +706,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; 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/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/TelemetryAsyncClient.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/TelemetryAsyncClient.java new file mode 100644 index 0000000000000..0b7aa706bda50 --- /dev/null +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/TelemetryAsyncClient.java @@ -0,0 +1,65 @@ +// 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.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#buildTelemetryAsyncClient()}. + */ +@ServiceClient(builder = AIProjectClientBuilder.class, isAsync = true) +public final class TelemetryAsyncClient { + private final ConnectionsAsyncClient connections; + private final AtomicReference connectionString = new AtomicReference<>(); + + TelemetryAsyncClient(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(TelemetryAsyncClient::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/TelemetryClient.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/TelemetryClient.java new file mode 100644 index 0000000000000..541241f243e11 --- /dev/null +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/TelemetryClient.java @@ -0,0 +1,70 @@ +// 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.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#buildTelemetryClient()}. + */ +@ServiceClient(builder = AIProjectClientBuilder.class) +public final class TelemetryClient { + private static final ClientLogger LOGGER = new ClientLogger(TelemetryClient.class); + private final ConnectionsClient connections; + private final AtomicReference connectionString = new AtomicReference<>(); + + TelemetryClient(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/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..b9fa59a9f91bf --- /dev/null +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/FileUploadHelper.java @@ -0,0 +1,149 @@ +// 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.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; + +/** 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(BinaryData.fromFile(file)); + 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/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/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..c51f32b4e3fb9 --- /dev/null +++ b/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/FileUploadTests.java @@ -0,0 +1,211 @@ +// 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.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(); + 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 -> { + 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)); + } +} 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/TelemetryClientTest.java b/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/TelemetryClientTest.java new file mode 100644 index 0000000000000..0a2afa590a39a --- /dev/null +++ b/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/TelemetryClientTest.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 TelemetryClientTest { + @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) { + TelemetryAsyncClient client = builder.buildTelemetryAsyncClient(); + return () -> client.getApplicationInsightsConnectionString().block(); + } + TelemetryClient client = builder.buildTelemetryClient(); + return client::getApplicationInsightsConnectionString; + } +} 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 00a892a34ea56a0c47fd297bff4a0a44fd4bbc39 Mon Sep 17 00:00:00 2001 From: Mike Guo Date: Thu, 17 Sep 2026 07:26:35 +0800 Subject: [PATCH 02/25] Remove redundant generated model serialization tests --- ...omptAgentDefinitionSerializationTests.java | 18 ---------- .../ReasoningDedupSerializationTests.java | 34 ------------------- 2 files changed, 52 deletions(-) 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 // ----------------------------------------------------------------------- From 5c12c60127337719458119cac0736ee37ec0b595 Mon Sep 17 00:00:00 2001 From: Mike Guo Date: Thu, 17 Sep 2026 09:17:35 +0800 Subject: [PATCH 03/25] Refine Agents polling and telephony coverage --- .../AgentsServicePollUtils.java | 28 +++++------ .../AgentsServicePollUtilsTest.java | 20 ++++++++ .../voice/VoiceAgentTelephonyLiveTests.java | 50 +++++++------------ 3 files changed, 52 insertions(+), 46 deletions(-) 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 9cf37184d7513..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 @@ -51,7 +51,8 @@ public static com.azure.core.util.polling.SyncPoller resume( 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(body.toObject(Map.class).get("status")), body.toObject(pollType), + 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, @@ -76,7 +77,8 @@ public static com.azure.core.util.polling.PollerFlux resumeAsync( = context -> Mono.defer(getResponse).map(response -> { BinaryData body = response.getValue(); context.setData(PollingUtils.POLL_RESPONSE_BODY, body.toString()); - return new PollResponse<>(mapStatus(body.toObject(Map.class).get("status")), body.toObject(pollType), + 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), @@ -138,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. @@ -151,23 +154,18 @@ private static LongRunningOperationStatus mapCustomStatus(LongRunningOperationSt return status; } - private static LongRunningOperationStatus mapStatus(Object statusValue) { - if (statusValue == null || CoreUtils.isNullOrEmpty(statusValue.toString().trim())) { + static LongRunningOperationStatus mapStatus(String statusValue) { + if (CoreUtils.isNullOrEmpty(statusValue) || CoreUtils.isNullOrEmpty(statusValue.trim())) { return LongRunningOperationStatus.IN_PROGRESS; } - String status = statusValue.toString().trim(); + String status = statusValue.trim(); if (JobStatus.QUEUED.toString().equalsIgnoreCase(status) || JobStatus.IN_PROGRESS.toString().equalsIgnoreCase(status)) { return LongRunningOperationStatus.IN_PROGRESS; - } else if (JobStatus.SUCCEEDED.toString().equalsIgnoreCase(status) - || MemoryStoreUpdateStatus.COMPLETED.toString().equalsIgnoreCase(status)) { - return LongRunningOperationStatus.SUCCESSFULLY_COMPLETED; } else if (JobStatus.FAILED.toString().equalsIgnoreCase(status)) { return LongRunningOperationStatus.FAILED; - } else if (JobStatus.CANCELLED.toString().equalsIgnoreCase(status) - || MemoryStoreUpdateStatus.SUPERSEDED.toString().equalsIgnoreCase(status)) { - return LongRunningOperationStatus.USER_CANCELLED; + } else { + return mapCustomStatus(LongRunningOperationStatus.fromString(status, false)); } - return LongRunningOperationStatus.fromString(status, false); } } 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 7ec98173a67ba..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 @@ -200,6 +200,26 @@ void optimizationPollerExposesJobIdAndFinalResult(boolean async) { }); } + 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/voice/VoiceAgentTelephonyLiveTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentTelephonyLiveTests.java index cc563f0d30afe..1f19eb3d5b0f7 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentTelephonyLiveTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentTelephonyLiveTests.java @@ -43,8 +43,6 @@ import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.api.parallel.ExecutionMode; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -55,8 +53,7 @@ * It runs only when AZURE_TEST_MODE=LIVE, requires FOUNDRY_VOICE_MODEL_NAME, and uses * DefaultAzureCredential authentication. FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_TELEPHONY_CONNECTION_1, * FOUNDRY_TELEPHONY_CONNECTION_2, FOUNDRY_TELEPHONY_NUMBER_1, and FOUNDRY_TELEPHONY_NUMBER_2 can override the test - * project defaults. Binding get, update, and delete have isolated live tests so a failure in one operation does not - * prevent the other operations from running. + * project defaults. */ @Execution(ExecutionMode.SAME_THREAD) public class VoiceAgentTelephonyLiveTests { @@ -69,14 +66,9 @@ public class VoiceAgentTelephonyLiveTests { private static final Duration CALL_TIMEOUT = Duration.ofMinutes(2); private static final Duration POLL_INTERVAL = Duration.ofSeconds(2); - private enum BindingMutation { - GET, UPDATE, DELETE - } - - @ParameterizedTest - @EnumSource(BindingMutation.class) + @Test @EnabledIfEnvironmentVariable(named = "AZURE_TEST_MODE", matches = "LIVE") - public void bindingMutationLive(BindingMutation mutation) { + public void bindingLifecycleLive() { Configuration configuration = Configuration.getGlobalConfiguration(); String endpoint = configuration.get("FOUNDRY_PROJECT_ENDPOINT", DEFAULT_ENDPOINT); String model = configuration.get("FOUNDRY_VOICE_MODEL_NAME"); @@ -88,7 +80,7 @@ public void bindingMutationLive(BindingMutation mutation) { .allowPreview(true); AgentsClient agents = builder.buildAgentsClient(); BetaVoiceAgentsTelephonyClient telephony = builder.beta().buildBetaVoiceAgentsTelephonyClient(); - String agentName = "test-telephony-binding-" + mutation.toString().toLowerCase() + "-" + shortId(); + String agentName = "test-telephony-binding-" + shortId(); boolean agentCreated = false; try { agents.createAgentVersion(agentName, @@ -99,20 +91,18 @@ public void bindingMutationLive(BindingMutation mutation) { TelephonyBindingListItem listedBinding = findBinding(telephony, agentName, binding.getId()); assertNotNull(listedBinding.getEtag()); - if (mutation == BindingMutation.GET) { - TelephonyBinding retrieved = telephony.getTelephonyBinding(agentName, binding.getId()); - assertEquals(binding.getId(), retrieved.getId()); - } else if (mutation == BindingMutation.UPDATE) { - TelephonyBinding updated - = telephony.updateTelephonyBinding(agentName, binding.getId(), listedBinding.getEtag(), - new UpdateTelephonyBindingRequest().setLabel("Updated Java SDK live test")); - assertEquals("Updated Java SDK live test", updated.getLabel()); - } else { - telephony.deleteTelephonyBinding(agentName, binding.getId(), listedBinding.getEtag()); - assertTrue(telephony.listTelephonyBindings(agentName) - .stream() - .noneMatch(item -> binding.getId().equals(item.getId()))); - } + TelephonyBinding retrieved = telephony.getTelephonyBinding(agentName, binding.getId()); + assertEquals(binding.getId(), retrieved.getId()); + TelephonyBinding updated = telephony.updateTelephonyBinding(agentName, binding.getId(), + listedBinding.getEtag(), new UpdateTelephonyBindingRequest().setLabel("Updated Java SDK live test")); + assertEquals("Updated Java SDK live test", updated.getLabel()); + + String updatedEtag = findBinding(telephony, agentName, binding.getId()).getEtag(); + assertNotNull(updatedEtag); + telephony.deleteTelephonyBinding(agentName, binding.getId(), updatedEtag); + assertTrue(telephony.listTelephonyBindings(agentName) + .stream() + .noneMatch(item -> binding.getId().equals(item.getId()))); } finally { if (agentCreated) { safeCleanup("delete binding test agent", () -> agents.deleteAgent(agentName)); @@ -160,9 +150,6 @@ public void twilioBindingAndOutboundCallLive() throws InterruptedException { assertEquals(TelephonyBindingStatus.ACTIVE, binding.getStatus()); assertNotNull(binding.getIncomingCallUrl()); - TelephonyBindingListItem listedBinding = findBinding(telephony, inboundAgent, binding.getId()); - assertNotNull(listedBinding.getEtag()); - Response initialTargetsResponse = telephony.getTelephonyTransferTargetsWithResponse(inboundAgent, new RequestOptions()); TelephonyTransferTargets initialTargets @@ -195,8 +182,9 @@ public void twilioBindingAndOutboundCallLive() throws InterruptedException { TelephonyCallRecord callRecord = telephony.getTelephonyCall(inboundAgent, inboundCallId); assertEquals(inboundCallId, callRecord.getId()); - TelephonyCallRecord endedCall = telephony.endTelephonyCall(inboundAgent, inboundCallId); - assertEquals(inboundCallId, endedCall.getId()); + TelephonyCallRecord transferredCall + = telephony.transferTelephonyCall(inboundAgent, inboundCallId, "test_number_2"); + assertEquals(inboundCallId, transferredCall.getId()); inboundCallId = null; TelephonyCallJob dispatchedJob = telephony.getTelephonyCallJob(outboundAgent, callJobId); From 1dd475774bb63c2ea42ecc3554b1afd59640cde9 Mon Sep 17 00:00:00 2001 From: Mike Guo Date: Thu, 17 Sep 2026 09:40:44 +0800 Subject: [PATCH 04/25] Fix TypeSpec generated documentation formatting --- sdk/ai/azure-ai-agents/CHANGELOG.md | 1 + .../com/azure/ai/agents/AgentsClientBuilder.java | 12 ++++++------ .../com/azure/ai/agents/models/CodeFileDetails.java | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/sdk/ai/azure-ai-agents/CHANGELOG.md b/sdk/ai/azure-ai-agents/CHANGELOG.md index ea46fcba07f59..48d6442820052 100644 --- a/sdk/ai/azure-ai-agents/CHANGELOG.md +++ b/sdk/ai/azure-ai-agents/CHANGELOG.md @@ -27,6 +27,7 @@ - Replaced `generateAgent` and `generateAgentWithResponse` on `AgentsClient` and `AgentsAsyncClient` with `createAgentFromPrompt` and `createAgentFromPromptWithResponse` on `BetaAgentsClient` and `BetaAgentsAsyncClient`. - Moved `getId()` and `getConversationId()` from `VoiceResponseBase` to `VoiceResponse`. + ### Bugs Fixed - Reject insecure voice-agent WebSocket URLs before token acquisition to prevent sending credentials over plaintext. 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 bba9a60864df1..a5588ab1d65ad 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 @@ -520,9 +520,9 @@ public OpenAIClientAsync buildOpenAIAsyncClient() { /** * 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. - * + * 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. */ @@ -549,12 +549,12 @@ public OpenAIClientAsync buildAgentScopedOpenAIAsyncClient(String agentName) { /** * 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. - * + * 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. + * @throws IllegalArgumentException if agentName is null or empty. */ public OpenAIClientAsync buildAgentScopedOpenAIAsyncClient(String agentName, Consumer configure) { 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 5fbb7893d5490..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,7 +47,7 @@ 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. + * @throws IllegalArgumentException if the path has no file name. */ public CodeFileDetails(String filePath) { Path path = Paths.get(filePath); From ab43d8948c2c87e8e029a5cb0d000cf98f511b68 Mon Sep 17 00:00:00 2001 From: Mike Guo Date: Thu, 17 Sep 2026 09:50:03 +0800 Subject: [PATCH 05/25] Fix voice agent CI failures --- .../azure/ai/agents/VoiceAgentWebSocketSessionClient.java | 5 +++++ .../com/azure/ai/agents/voice/VoiceAgentTelephonyTests.java | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketSessionClient.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketSessionClient.java index fc29802691849..d35216d24d5a0 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketSessionClient.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketSessionClient.java @@ -70,6 +70,7 @@ public final class VoiceAgentWebSocketSessionClient implements AutoCloseable { private final CountDownLatch handshakeCompleted = new CountDownLatch(1); private final CountDownLatch closeCompleted = new CountDownLatch(1); private final AtomicReference connectionError = new AtomicReference<>(); + private final AtomicBoolean handshakeSucceeded = new AtomicBoolean(); private final AtomicBoolean receiveClaimed = new AtomicBoolean(); private final AtomicBoolean open = new AtomicBoolean(); private final AtomicBoolean closed = new AtomicBoolean(); @@ -356,6 +357,9 @@ private void awaitHandshake() { throw LOGGER.logExceptionAsError( new IllegalStateException("Interrupted while opening the voice-agent WebSocket session.", error)); } + if (handshakeSucceeded.get()) { + return; + } Throwable error = connectionError.get(); if (error instanceof RuntimeException) { throw LOGGER.logExceptionAsError((RuntimeException) error); @@ -471,6 +475,7 @@ private static OkHttpClient createHttpClient(VoiceAgentWebSocketClientConfigurat private final class Listener extends WebSocketListener { @Override public void onOpen(WebSocket webSocket, Response response) { + handshakeSucceeded.set(true); open.set(true); handshakeCompleted.countDown(); } diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentTelephonyTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentTelephonyTests.java index 0073de2028faa..1beb59aed20ac 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentTelephonyTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentTelephonyTests.java @@ -230,7 +230,7 @@ public void bindingsAndTransferTargets(boolean async) { BetaVoiceAgentsTelephonyAsyncClient asyncClient = builder.beta().buildBetaVoiceAgentsTelephonyAsyncClient(); assertEquals(0L, async - ? asyncClient.listTelephonyBindings(AGENT).count().block(TIMEOUT) + ? asyncClient.listTelephonyBindings(AGENT).count().block(TIMEOUT).longValue() : syncClient.listTelephonyBindings(AGENT).stream().count()); assertTrue(call(async, () -> syncClient.getTelephonyTransferTargets(AGENT), () -> asyncClient.getTelephonyTransferTargets(AGENT)).getTransferTargets().isEmpty()); @@ -273,7 +273,7 @@ public void callsNotFound(boolean async) { BetaVoiceAgentsTelephonyAsyncClient asyncClient = builder.beta().buildBetaVoiceAgentsTelephonyAsyncClient(); assertEquals(0L, async - ? asyncClient.listTelephonyCalls(AGENT).count().block(TIMEOUT) + ? asyncClient.listTelephonyCalls(AGENT).count().block(TIMEOUT).longValue() : syncClient.listTelephonyCalls(AGENT).stream().count()); assertNotFound(() -> call(async, () -> syncClient.getTelephonyCall(AGENT, MISSING), () -> asyncClient.getTelephonyCall(AGENT, MISSING)), true); From 5197e1222c75471c3ad3dc7fe87d4ae278b7b601 Mon Sep 17 00:00:00 2001 From: Mike Guo Date: Thu, 17 Sep 2026 10:15:08 +0800 Subject: [PATCH 06/25] Fix file upload tests on Windows --- .../src/test/java/com/azure/ai/projects/FileUploadTests.java | 2 ++ 1 file changed, 2 insertions(+) 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 index c51f32b4e3fb9..e8a0c689bde3e 100644 --- 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 @@ -47,6 +47,7 @@ void uploadsForwardOptionsAndDoNotRegisterFailures(boolean async, @TempDir Path AtomicInteger uploadCalls = new AtomicInteger(); HttpClient blob = request -> { uploadCalls.incrementAndGet(); + request.getBodyAsBinaryData().toBytes(); assertTrue(request.getUrl().getPath().endsWith("weights.bin")); assertEquals("review", request.getHeaders().getValue("x-ms-meta-purpose")); assertEquals("*", request.getHeaders().getValue(HttpHeaderName.IF_NONE_MATCH)); @@ -119,6 +120,7 @@ void modelUploadRegistersMetadataAndWaits(boolean async, @TempDir Path folder) t AtomicInteger polls = new AtomicInteger(); AtomicInteger calls = new AtomicInteger(); HttpClient blobClient = request -> { + request.getBodyAsBinaryData().toBytes(); uploads.add(request); return Mono.just(new MockHttpResponse(request, 201, new HttpHeaders().set(HttpHeaderName.ETAG, "\"etag\""), new byte[0])); From 0566d523b1425e759a31b6f985399ebf2f060f7a Mon Sep 17 00:00:00 2001 From: Mike Guo Date: Thu, 17 Sep 2026 11:02:44 +0800 Subject: [PATCH 07/25] Fix platform-specific test races --- ...ntLiveAudioConversationAsyncSampleTests.java | 11 +++++------ .../com/azure/ai/projects/FileUploadTests.java | 17 +++++++++++++++-- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSampleTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSampleTests.java index 818edb61713db..9728f4547e1c8 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSampleTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSampleTests.java @@ -109,18 +109,17 @@ public void playbackAcceptsBurstsAndEnforcesByteLimitWithoutBlocking() throws Ex @Test public void cancellationClosesAudioAndCancelsReceive() throws Exception { FakeAudio audio = new FakeAudio(); - AtomicBoolean cancelled = new AtomicBoolean(); + CountDownLatch cancelled = new CountDownLatch(1); CountDownLatch receiving = new CountDownLatch(1); VoiceAgentLiveAudioConversationAsyncSample.AudioProcessor processor = audio.processor(); - CompletableFuture conversation - = VoiceAgentLiveAudioConversationAsyncSample.runConversation(Mono.never() - .doOnSubscribe(subscription -> receiving.countDown()) - .doOnCancel(() -> cancelled.set(true)), processor, emptyInput()).toFuture(); + CompletableFuture conversation = VoiceAgentLiveAudioConversationAsyncSample.runConversation( + Mono.never().doOnSubscribe(subscription -> receiving.countDown()).doOnCancel(cancelled::countDown), + processor, emptyInput()).toFuture(); try { assertTrue(receiving.await(5, TimeUnit.SECONDS)); conversation.cancel(true); assertTrue(audio.closed.await(5, TimeUnit.SECONDS)); - assertTrue(cancelled.get()); + assertTrue(cancelled.await(5, TimeUnit.SECONDS)); } finally { conversation.cancel(true); processor.close(); 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 index e8a0c689bde3e..2d58cf5075a3b 100644 --- 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 @@ -14,6 +14,8 @@ 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; @@ -47,7 +49,7 @@ void uploadsForwardOptionsAndDoNotRegisterFailures(boolean async, @TempDir Path AtomicInteger uploadCalls = new AtomicInteger(); HttpClient blob = request -> { uploadCalls.incrementAndGet(); - request.getBodyAsBinaryData().toBytes(); + 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)); @@ -120,7 +122,7 @@ void modelUploadRegistersMetadataAndWaits(boolean async, @TempDir Path folder) t AtomicInteger polls = new AtomicInteger(); AtomicInteger calls = new AtomicInteger(); HttpClient blobClient = request -> { - request.getBodyAsBinaryData().toBytes(); + consumeBody(request); uploads.add(request); return Mono.just(new MockHttpResponse(request, 201, new HttpHeaders().set(HttpHeaderName.ETAG, "\"etag\""), new byte[0])); @@ -210,4 +212,15 @@ private static MockHttpResponse jsonResponse(HttpRequest request, int status, St 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]; + while (stream.read(buffer) != -1) { + // Drain the file-backed body so the mock behaves like a real transport. + } + } catch (IOException exception) { + throw new UncheckedIOException(exception); + } + } } From 71a58ea2d0c310ae2baf18d8c7642a5517eddcdc Mon Sep 17 00:00:00 2001 From: Mike Guo Date: Thu, 17 Sep 2026 11:25:37 +0800 Subject: [PATCH 08/25] Fix file upload stream cleanup --- .../ai/projects/implementation/FileUploadHelper.java | 11 ++++++++++- .../java/com/azure/ai/projects/FileUploadTests.java | 5 +++-- 2 files changed, 13 insertions(+), 3 deletions(-) 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 b9fa59a9f91bf..8e8dc8abf8e40 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 @@ -9,6 +9,7 @@ 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; @@ -18,6 +19,7 @@ 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 { @@ -140,7 +142,14 @@ public static BlobContainerClientBuilder createContainerBuilder(BlobReference re * @return the blob upload options. */ public static BlobParallelUploadOptions createUploadOptions(Path file, FileUploadOptions options) { - BlobParallelUploadOptions upload = new BlobParallelUploadOptions(BinaryData.fromFile(file)); + 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); } 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 index 2d58cf5075a3b..59ce32b559c59 100644 --- 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 @@ -216,8 +216,9 @@ private static MockHttpResponse jsonResponse(HttpRequest request, int status, St private static void consumeBody(HttpRequest request) { try (InputStream stream = request.getBodyAsBinaryData().toStream()) { byte[] buffer = new byte[8192]; - while (stream.read(buffer) != -1) { - // Drain the file-backed body so the mock behaves like a real transport. + int bytesRead = stream.read(buffer); + while (bytesRead != -1) { + bytesRead = stream.read(buffer); } } catch (IOException exception) { throw new UncheckedIOException(exception); From 0f928c3bc799b2b403fa355d9653f3a9ae35f19c Mon Sep 17 00:00:00 2001 From: Mike Guo Date: Thu, 17 Sep 2026 11:44:27 +0800 Subject: [PATCH 09/25] Stabilize token timeout test --- .../ai/agents/voice/VoiceAgentWebSocketSessionTests.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentWebSocketSessionTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentWebSocketSessionTests.java index b54d105dd78b4..a24fd7e4edf96 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentWebSocketSessionTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentWebSocketSessionTests.java @@ -665,10 +665,10 @@ public void tokenAcquisitionDoesNotUseHandshakeTimeout() { .buildBetaVoiceAgentWebSocketAsyncClient(); VoiceAgentWebSocketConnectionOptions options = tlsOptions().setHandshakeTimeout(Duration.ofSeconds(1)); - StepVerifier.create(client.connect("agent", options).flatMap(session -> { + StepVerifier.withVirtualTime(() -> client.connect("agent", options).flatMap(session -> { assertTrue(session.isOpen()); return session.closeAsync(); - })).verifyComplete(); + })).thenAwait(Duration.ofMillis(1500)).verifyComplete(); } @Test From c7dd2e7445e81c07155bb7b302dcbab19c2a9b41 Mon Sep 17 00:00:00 2001 From: Mike Guo Date: Thu, 17 Sep 2026 12:59:53 +0800 Subject: [PATCH 10/25] Simplify Agents client builder generation --- .../src/main/java/AgentsCustomizations.java | 19 ++--------- .../azure/ai/agents/AgentsClientBuilder.java | 32 +++++++------------ 2 files changed, 14 insertions(+), 37 deletions(-) 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 768839393ff70..63891f0089a2b 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 @@ -45,24 +45,11 @@ public void customize(LibraryCustomization libraryCustomization, Logger logger) } private static void customizeBuilder(ClassOrInterfaceDeclaration builder) { - builder.getMethodsByName("buildInnerClient").stream() + MethodDeclaration buildInnerClient = builder.getMethodsByName("buildInnerClient").stream() .filter(method -> method.getParameters().isEmpty()) .findFirst() - .orElseThrow(() -> new IllegalStateException("Generated buildInnerClient was not found.")) - .getBody().ifPresent(body -> { - if (!body.toString().contains("createPreviewErrorPolicy")) { - body.addStatement(2, StaticJavaParser.parseStatement( - "localPipeline = FoundryPolicyHelper.prependPolicy(localPipeline, " - + "FoundryPolicyHelper.createPreviewErrorPolicy(allowPreview));")); - } - }); - MethodDeclaration pipelineMethod = builder.getMethodsByName("createHttpPipeline").stream() - .filter(method -> method.getParameters().isEmpty()) - .findFirst() - .orElseThrow(() -> new IllegalStateException("Generated createHttpPipeline was not found.")); - pipelineMethod.setBody(StaticJavaParser.parseBlock("{ return createHttpPipeline(true); }")); - builder.findCompilationUnit().ifPresent(unit -> unit.getImports().removeIf(declaration -> - "com.azure.core.http.policy.HttpLoggingPolicy".equals(declaration.getNameAsString()))); + .orElseThrow(() -> new IllegalStateException("Generated buildInnerClient was not found.")); + buildInnerClient.setBody(StaticJavaParser.parseBlock("{ return buildInnerClient(null); }")); } private static final String MODELS_PACKAGE = "com.azure.ai.agents.models"; 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 a5588ab1d65ad..36e7e4a48e5c0 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 @@ -317,28 +317,23 @@ public AgentsClientBuilder retryPolicy(RetryPolicy retryPolicy) { */ @Generated private AgentsClientImpl buildInnerClient() { - this.validateClient(); - HttpPipeline localPipeline = (pipeline != null) ? pipeline : createHttpPipeline(); - localPipeline = FoundryPolicyHelper.prependPolicy(localPipeline, - FoundryPolicyHelper.createPreviewErrorPolicy(allowPreview)); - AgentsServiceVersion localServiceVersion - = (serviceVersion != null) ? serviceVersion : AgentsServiceVersion.getLatest(); - AgentsClientImpl client = new AgentsClientImpl(localPipeline, JacksonAdapter.createDefaultSerializerAdapter(), - this.endpoint, localServiceVersion); - return client; + return buildInnerClient(null); } private AgentsClientImpl buildInnerClient(String previewFeatures) { this.validateClient(); + HttpPipeline localPipeline; if (CoreUtils.isNullOrEmpty(previewFeatures)) { - return buildInnerClient(); + localPipeline = pipeline != null ? pipeline : createHttpPipeline(true); + 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(), - this.endpoint, localServiceVersion); - return client; + = serviceVersion != null ? serviceVersion : AgentsServiceVersion.getLatest(); + return new AgentsClientImpl(localPipeline, JacksonAdapter.createDefaultSerializerAdapter(), this.endpoint, + localServiceVersion); } @Generated @@ -348,11 +343,6 @@ private void validateClient() { Objects.requireNonNull(endpoint, "'endpoint' cannot be null."); } - @Generated - private HttpPipeline createHttpPipeline() { - return createHttpPipeline(true); - } - private HttpPipeline createHttpPipeline(boolean authenticate) { Configuration buildConfiguration = (configuration == null) ? Configuration.getGlobalConfiguration() : configuration; @@ -391,7 +381,7 @@ private HttpPipeline createHttpPipeline(boolean authenticate) { } private HttpPipeline resolvePipeline(String foundryFeatures) { - HttpPipeline localPipeline = pipeline != null ? pipeline : createHttpPipeline(); + HttpPipeline localPipeline = pipeline != null ? pipeline : createHttpPipeline(true); HttpPipelinePolicy foundryFeaturesPolicy = FoundryPolicyHelper.createFoundryFeaturesPolicy(foundryFeatures); return FoundryPolicyHelper.prependPolicy(localPipeline, foundryFeaturesPolicy); } From bf2afd862a1e2d3844ea42056100425a3827957a Mon Sep 17 00:00:00 2001 From: Mike Guo Date: Thu, 17 Sep 2026 13:36:29 +0800 Subject: [PATCH 11/25] Fix renamed clients in documentation --- sdk/ai/azure-ai-agents/CHANGELOG.md | 2 ++ sdk/ai/azure-ai-agents/README.md | 9 +++++---- sdk/ai/azure-ai-projects/README.md | 12 +++++------ .../com/azure/ai/projects/IndexesSample.java | 20 +++++++++---------- 4 files changed, 23 insertions(+), 20 deletions(-) diff --git a/sdk/ai/azure-ai-agents/CHANGELOG.md b/sdk/ai/azure-ai-agents/CHANGELOG.md index 48d6442820052..525904ce83d4a 100644 --- a/sdk/ai/azure-ai-agents/CHANGELOG.md +++ b/sdk/ai/azure-ai-agents/CHANGELOG.md @@ -16,6 +16,8 @@ - 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 synchronous and asynchronous voice-agent WebSocket clients and session APIs with typed realtime events, text and PCM16 audio input, response cancellation, function-call output, persisted-conversation options, and authenticated `wss://` transport. - Added synchronous and asynchronous live text conversation samples, an asynchronous Java Sound microphone/speaker sample with barge-in, and a live client-executed function-tool sample. diff --git a/sdk/ai/azure-ai-agents/README.md b/sdk/ai/azure-ai-agents/README.md index bbcb5ed722c7e..5cdd44075be8f 100644 --- a/sdk/ai/azure-ai-agents/README.md +++ b/sdk/ai/azure-ai-agents/README.md @@ -69,7 +69,8 @@ The Agents client library has the following sub-clients which group the differen - `BetaMemoryStoresClient` / `BetaMemoryStoresAsyncClient` **(preview)**: Manage memory stores and individual memory items for agents. - `ToolboxesClient` / `ToolboxesAsyncClient`: Manage toolboxes and toolbox versions. - `BetaVoiceAgentWebSocketClient` / `BetaVoiceAgentWebSocketAsyncClient` **(preview)**: Open typed realtime WebSocket sessions with voice agents. -- `BetaAgentEndpointConversationsClient` / `BetaAgentEndpointConversationsAsyncClient` **(preview)**: Read persisted voice-agent conversations, transcripts, and audio metadata. +- `BetaVoiceAgentsTelephonyClient` / `BetaVoiceAgentsTelephonyAsyncClient` **(preview)**: Manage voice-agent outbound calls and telephony campaigns. +- `BetaVoiceAgentsConversationsClient` / `BetaVoiceAgentsConversationsAsyncClient` **(preview)**: Read persisted voice-agent conversations, transcripts, and audio metadata. Conversation operations are accessed through the [OpenAI Official Java SDK][openai_java_sdk]'s `ConversationService`. See the [OpenAI's Conversation API documentation][openai_conversations_api_docs] for more information. @@ -241,7 +242,8 @@ Build clients whose names start with `Beta` from `AgentsClientBuilder.beta()`. T | `BetaAgentsClient` | `WorkflowAgents=V1Preview,ExternalAgents=V1Preview,DraftAgents=V1Preview,AgentsOptimization=V2Preview` | | `BetaMemoryStoresClient` | `MemoryStores=V1Preview` | | `BetaVoiceAgentWebSocketClient` | `VoiceAgents=V1Preview` | -| `BetaAgentEndpointConversationsClient` | `VoiceAgents=V1Preview` | +| `BetaVoiceAgentsTelephonyClient` | `VoiceAgents=V1Preview` | +| `BetaVoiceAgentsConversationsClient` | `VoiceAgents=V1Preview` | The async `Beta*AsyncClient` counterparts follow the same behavior. @@ -1132,8 +1134,7 @@ The live audio example requires a Java Sound-compatible microphone and speaker. All agent samples use `FOUNDRY_PROJECT_ENDPOINT`. Prompt-agent samples also use `FOUNDRY_MODEL_NAME`. -- **Agent lifecycle and structured output:** [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 the `AgentStructuredOutput*` samples. -- **Workflow agents:** `WorkflowMultiAgentSample`, `WorkflowMultiAgentAsyncSample`, and `WorkflowMultiAgentMcpApprovalSample` demonstrate CSDL workflows and MCP approval handling. +- **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. diff --git a/sdk/ai/azure-ai-projects/README.md b/sdk/ai/azure-ai-projects/README.md index 461e0cd509f69..11c21eff3821b 100644 --- a/sdk/ai/azure-ai-projects/README.md +++ b/sdk/ai/azure-ai-projects/README.md @@ -257,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 @@ -638,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", ""); @@ -657,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()); @@ -668,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"); @@ -682,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"); @@ -698,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/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 } } From 7b16e729e28f20e822454da40fe4d5a273f0e8b5 Mon Sep 17 00:00:00 2001 From: Mike Guo Date: Thu, 17 Sep 2026 17:37:48 +0800 Subject: [PATCH 12/25] Regenerate OpenAI HTTP pipeline from builder template --- .../src/main/java/AgentsCustomizations.java | 44 ++++++++++++++++++ .../azure/ai/agents/AgentsClientBuilder.java | 46 +++++++++++++++++-- 2 files changed, 85 insertions(+), 5 deletions(-) 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 63891f0089a2b..89bbc58ca8141 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,15 @@ 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.ExpressionStmt; +import com.github.javaparser.ast.stmt.IfStmt; import java.io.IOException; import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; @@ -50,6 +54,46 @@ private static void customizeBuilder(ClassOrInterfaceDeclaration builder) { .findFirst() .orElseThrow(() -> new IllegalStateException("Generated buildInnerClient was not found.")); buildInnerClient.setBody(StaticJavaParser.parseBlock("{ return buildInnerClient(null); }")); + + 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"; 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 36e7e4a48e5c0..e1e068e305681 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 @@ -324,7 +324,7 @@ private AgentsClientImpl buildInnerClient(String previewFeatures) { this.validateClient(); HttpPipeline localPipeline; if (CoreUtils.isNullOrEmpty(previewFeatures)) { - localPipeline = pipeline != null ? pipeline : createHttpPipeline(true); + localPipeline = pipeline != null ? pipeline : createHttpPipeline(); localPipeline = FoundryPolicyHelper.prependPolicy(localPipeline, FoundryPolicyHelper.createPreviewErrorPolicy(allowPreview)); } else { @@ -343,7 +343,8 @@ private void validateClient() { Objects.requireNonNull(endpoint, "'endpoint' cannot be null."); } - private HttpPipeline createHttpPipeline(boolean authenticate) { + @Generated + private HttpPipeline createHttpPipeline() { Configuration buildConfiguration = (configuration == null) ? Configuration.getGlobalConfiguration() : configuration; HttpLogOptions localHttpLogOptions = resolveHttpLogOptions(); @@ -365,7 +366,7 @@ private HttpPipeline createHttpPipeline(boolean authenticate) { HttpPolicyProviders.addBeforeRetryPolicies(policies); policies.add(ClientBuilderUtil.validateAndGetRetryPolicy(retryPolicy, retryOptions, new RetryPolicy())); policies.add(new AddDatePolicy()); - if (authenticate && tokenCredential != null) { + if (tokenCredential != null) { policies.add(new BearerTokenAuthenticationPolicy(tokenCredential, DEFAULT_SCOPES)); } this.pipelinePolicies.stream() @@ -381,13 +382,13 @@ private HttpPipeline createHttpPipeline(boolean authenticate) { } private HttpPipeline resolvePipeline(String foundryFeatures) { - HttpPipeline localPipeline = pipeline != null ? pipeline : createHttpPipeline(true); + HttpPipeline localPipeline = pipeline != null ? pipeline : createHttpPipeline(); HttpPipelinePolicy foundryFeaturesPolicy = FoundryPolicyHelper.createFoundryFeaturesPolicy(foundryFeatures); return FoundryPolicyHelper.prependPolicy(localPipeline, foundryFeaturesPolicy); } private com.openai.core.http.HttpClient createOpenAIHttpClient(String foundryFeatures) { - HttpPipeline localPipeline = pipeline != null ? pipeline : createHttpPipeline(false); + HttpPipeline localPipeline = pipeline != null ? pipeline : createOpenAIHttpPipeline(); return HttpClientHelper.mapToOpenAIHttpClient( FoundryPolicyHelper.prependPolicy(localPipeline, FoundryPolicyHelper.createFoundryFeaturesPolicy(foundryFeatures)), @@ -957,4 +958,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; + } } From e81ae989ef1d895ee8418f9cbf640e485ac8fb10 Mon Sep 17 00:00:00 2001 From: Mike Guo Date: Thu, 17 Sep 2026 18:52:52 +0800 Subject: [PATCH 13/25] Preserve generated inner client construction --- .../src/main/java/AgentsCustomizations.java | 38 ++++++++++++++++++- .../azure/ai/agents/AgentsClientBuilder.java | 25 ++++++++++-- 2 files changed, 58 insertions(+), 5 deletions(-) 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 89bbc58ca8141..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 @@ -16,6 +16,7 @@ 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; @@ -53,7 +54,42 @@ private static void customizeBuilder(ClassOrInterfaceDeclaration builder) { .filter(method -> method.getParameters().isEmpty()) .findFirst() .orElseThrow(() -> new IllegalStateException("Generated buildInnerClient was not found.")); - buildInnerClient.setBody(StaticJavaParser.parseBlock("{ return buildInnerClient(null); }")); + 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()) 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 e1e068e305681..769bb27016575 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 @@ -317,10 +317,26 @@ public AgentsClientBuilder retryPolicy(RetryPolicy retryPolicy) { */ @Generated private AgentsClientImpl buildInnerClient() { - return buildInnerClient(null); + this.validateClient(); + HttpPipeline localPipeline = (pipeline != null) ? pipeline : createHttpPipeline(); + AgentsServiceVersion localServiceVersion + = (serviceVersion != null) ? serviceVersion : AgentsServiceVersion.getLatest(); + AgentsClientImpl client = new AgentsClientImpl(localPipeline, JacksonAdapter.createDefaultSerializerAdapter(), + this.endpoint, localServiceVersion); + return client; } 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)) { @@ -331,9 +347,10 @@ private AgentsClientImpl buildInnerClient(String previewFeatures) { localPipeline = resolvePipeline(previewFeatures); } AgentsServiceVersion localServiceVersion - = serviceVersion != null ? serviceVersion : AgentsServiceVersion.getLatest(); - return new AgentsClientImpl(localPipeline, JacksonAdapter.createDefaultSerializerAdapter(), this.endpoint, - localServiceVersion); + = (serviceVersion != null) ? serviceVersion : AgentsServiceVersion.getLatest(); + AgentsClientImpl client = new AgentsClientImpl(localPipeline, JacksonAdapter.createDefaultSerializerAdapter(), + this.endpoint, localServiceVersion); + return client; } @Generated From 2dd4004063c96100c44c4eced25304a9d49feda7 Mon Sep 17 00:00:00 2001 From: Mike Guo Date: Thu, 17 Sep 2026 19:22:13 +0800 Subject: [PATCH 14/25] Add OpenAI client builder implementation comments --- .../azure/ai/agents/AgentsClientBuilder.java | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) 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 769bb27016575..bb34407ca9611 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 @@ -425,8 +425,18 @@ private HttpLogOptions resolveHttpLogOptions() { 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; @@ -454,10 +464,14 @@ public ResponsesClient buildResponsesClient() { * @return an instance of ResponsesAsyncClient */ public ResponsesAsyncClient buildResponsesAsyncClient() { + // 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); })); @@ -470,7 +484,11 @@ 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. return getOpenAIClientBuilder(null).build() + // 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. .withOptions(optionBuilder -> configureOpenAIOptions(optionBuilder, null)); } @@ -497,6 +515,9 @@ 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 with agent preview features and explicitly combine both user agents. return getOpenAIClientBuilder(agentName).build() .withOptions(optionBuilder -> configureOpenAIOptions(optionBuilder, AGENT_PREVIEW_FEATURES)); } @@ -521,6 +542,10 @@ public OpenAIClient buildAgentScopedOpenAIClient(String agentName, * @return an instance of OpenAIAsyncClient */ public OpenAIClientAsync buildOpenAIAsyncClient() { + // 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 -> { }); } @@ -550,6 +575,10 @@ public OpenAIClientAsync buildAgentScopedOpenAIAsyncClient(String agentName) { if (CoreUtils.isNullOrEmpty(agentName)) { throw LOGGER.logExceptionAsError(new IllegalArgumentException("'agentName' cannot be empty.")); } + // Use the shared async helper to fix the previous blocking authentication path. It performs three ordered + // steps: (1) installs the Azure transport, 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 -> { }); } @@ -593,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))); @@ -603,6 +644,8 @@ private OpenAIOkHttpClient.Builder getOpenAIClientBuilder(String agentName) { } else { builder.baseUrl(getAgentEndpointBaseUrl(agentName)); 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()); @@ -612,8 +655,19 @@ private OpenAIOkHttpClient.Builder getOpenAIClientBuilder(String agentName) { return builder; } + /** + * 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)) { @@ -621,6 +675,8 @@ private OpenAIOkHttpClientAsync.Builder getOpenAIAsyncClientBuilder(String agent } else { builder.baseUrl(getAgentEndpointBaseUrl(agentName)); 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()); From 8219c8d2571964df652c919ef339df6056458297 Mon Sep 17 00:00:00 2001 From: Mike Guo Date: Thu, 17 Sep 2026 20:19:35 +0800 Subject: [PATCH 15/25] Align Agents sources with code generation --- .../main/java/com/azure/ai/agents/AgentsClientBuilder.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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 bb34407ca9611..4c96a686846db 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 @@ -435,7 +435,6 @@ private HttpLogOptions resolveHttpLogOptions() { 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 @@ -485,10 +484,10 @@ public ResponsesAsyncClient buildResponsesAsyncClient() { */ 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() - // 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. .withOptions(optionBuilder -> configureOpenAIOptions(optionBuilder, null)); } From 4c2984fa84c7ce38bf58d1c32452b2745f2c9b3d Mon Sep 17 00:00:00 2001 From: Mike Guo Date: Fri, 18 Sep 2026 07:59:43 +0800 Subject: [PATCH 16/25] Update voice callers for regenerated models --- ...VoiceAgentWebSocketSessionAsyncClient.java | 43 +++++---- .../VoiceAgentWebSocketSessionClient.java | 43 +++++---- ...AgentLiveAudioConversationAsyncSample.java | 46 +++++----- .../VoiceAgentLiveFunctionToolSample.java | 34 ++++---- ...VoiceAgentReadConversationAudioSample.java | 8 +- .../voice/VoiceAgentRealtimeSampleUtils.java | 32 +++---- .../agents/voice/VoiceAgentSampleUtils.java | 8 +- .../voice/VoiceAgentWithToolsSample.java | 20 ++--- ...oiceAgentDefinitionSerializationTests.java | 22 ++--- .../VoiceAgentConversationsAsyncTests.java | 28 +++--- .../voice/VoiceAgentConversationsTests.java | 28 +++--- .../voice/VoiceAgentCrudAsyncTests.java | 9 +- .../ai/agents/voice/VoiceAgentCrudTests.java | 9 +- ...LiveAudioConversationAsyncSampleTests.java | 4 +- .../voice/VoiceAgentRealtimeLiveTests.java | 87 +++++++++---------- .../voice/VoiceAgentTelephonyLiveTests.java | 29 ++++--- .../voice/VoiceAgentTelephonyTests.java | 46 +++------- .../VoiceAgentWebSocketSessionTests.java | 14 +-- 18 files changed, 239 insertions(+), 271 deletions(-) diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketSessionAsyncClient.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketSessionAsyncClient.java index 4ad20868c1ea7..03174d9ca722d 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketSessionAsyncClient.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketSessionAsyncClient.java @@ -8,19 +8,17 @@ import com.azure.ai.agents.implementation.realtime.VoiceAgentWebSocketHttpResponse; import com.azure.ai.agents.implementation.utils.Beta; import com.azure.ai.agents.models.RealtimeClientEvent; -import com.azure.ai.agents.models.RealtimeClientEventConversationItemCreate; -import com.azure.ai.agents.models.RealtimeClientEventInputAudioBufferAppend; -import com.azure.ai.agents.models.RealtimeClientEventInputAudioBufferClear; -import com.azure.ai.agents.models.RealtimeClientEventInputAudioBufferCommit; -import com.azure.ai.agents.models.RealtimeClientEventResponseCancel; -import com.azure.ai.agents.models.RealtimeClientEventResponseCreate; +import com.azure.ai.agents.models.RealtimeConversationItemCreateEvent; +import com.azure.ai.agents.models.RealtimeInputAudioBufferAppendEvent; +import com.azure.ai.agents.models.RealtimeInputAudioBufferClearEvent; +import com.azure.ai.agents.models.RealtimeInputAudioBufferCommitEvent; +import com.azure.ai.agents.models.RealtimeResponseCancelEvent; +import com.azure.ai.agents.models.RealtimeResponseCreateEvent; import com.azure.ai.agents.models.RealtimeConversationItem; import com.azure.ai.agents.models.RealtimeConversationItemFunctionCallOutput; -import com.azure.ai.agents.models.RealtimeConversationItemMessageUser; -import com.azure.ai.agents.models.RealtimeConversationItemMessageUserContent; -import com.azure.ai.agents.models.RealtimeConversationItemMessageUserContentType; +import com.azure.ai.agents.models.RealtimeConversationItemUserMessage; import com.azure.ai.agents.models.RealtimeServerEvent; -import com.azure.ai.agents.models.VoiceAgentResponseCreateParams; +import com.azure.ai.agents.models.VoiceAgentResponseCreateOptions; import com.azure.ai.agents.models.VoiceAgentWebSocketConnectionOptions; import com.azure.core.credential.AccessToken; import com.azure.core.credential.TokenRequestContext; @@ -32,6 +30,7 @@ import com.azure.core.http.ProxyOptions; import com.azure.core.util.AsyncCloseable; import com.azure.core.util.BinaryData; +import com.openai.models.realtime.RealtimeConversationItemUserMessage.Content; import com.azure.core.util.logging.ClientLogger; import io.netty.channel.Channel; import io.netty.channel.ChannelOption; @@ -255,7 +254,7 @@ public Mono createConversationItem(RealtimeConversationItem item) { */ public Mono createConversationItem(RealtimeConversationItem item, String previousItemId) { Objects.requireNonNull(item, "'item' cannot be null."); - return sendEvent(new RealtimeClientEventConversationItemCreate(item).setPreviousItemId(previousItemId)); + return sendEvent(new RealtimeConversationItemCreateEvent(item).setPreviousItemId(previousItemId)); } /** @@ -266,10 +265,8 @@ public Mono createConversationItem(RealtimeConversationItem item, String p */ public Mono sendText(String text) { Objects.requireNonNull(text, "'text' cannot be null."); - RealtimeConversationItemMessageUserContent content = new RealtimeConversationItemMessageUserContent() - .setType(RealtimeConversationItemMessageUserContentType.INPUT_TEXT) - .setText(text); - return createConversationItem(new RealtimeConversationItemMessageUser(Collections.singletonList(content))); + Content content = Content.builder().type(Content.Type.INPUT_TEXT).text(text).build(); + return createConversationItem(new RealtimeConversationItemUserMessage(Collections.singletonList(content))); } /** @@ -281,7 +278,7 @@ public Mono sendText(String text) { public Mono appendInputAudio(BinaryData audio) { Objects.requireNonNull(audio, "'audio' cannot be null."); String encoded = Base64.getEncoder().encodeToString(audio.toBytes()); - return sendEvent(new RealtimeClientEventInputAudioBufferAppend(encoded)); + return sendEvent(new RealtimeInputAudioBufferAppendEvent(encoded)); } /** @@ -290,7 +287,7 @@ public Mono appendInputAudio(BinaryData audio) { * @return a completion signal emitted after the event is written. */ public Mono clearInputAudio() { - return sendEvent(new RealtimeClientEventInputAudioBufferClear()); + return sendEvent(new RealtimeInputAudioBufferClearEvent()); } /** @@ -299,7 +296,7 @@ public Mono clearInputAudio() { * @return a completion signal emitted after the event is written. */ public Mono commitInputAudio() { - return sendEvent(new RealtimeClientEventInputAudioBufferCommit()); + return sendEvent(new RealtimeInputAudioBufferCommitEvent()); } /** @@ -308,7 +305,7 @@ public Mono commitInputAudio() { * @return a completion signal emitted after the event is written. */ public Mono createResponse() { - return sendEvent(new RealtimeClientEventResponseCreate()); + return sendEvent(new RealtimeResponseCreateEvent()); } /** @@ -317,9 +314,9 @@ public Mono createResponse() { * @param responseOptions the response options. * @return a completion signal emitted after the event is written. */ - public Mono createResponse(VoiceAgentResponseCreateParams responseOptions) { + public Mono createResponse(VoiceAgentResponseCreateOptions responseOptions) { Objects.requireNonNull(responseOptions, "'responseOptions' cannot be null."); - return sendEvent(new RealtimeClientEventResponseCreate().setResponse(responseOptions)); + return sendEvent(new RealtimeResponseCreateEvent().setResponse(responseOptions)); } /** @@ -328,7 +325,7 @@ public Mono createResponse(VoiceAgentResponseCreateParams responseOptions) * @return a completion signal emitted after the event is written. */ public Mono cancelResponse() { - return sendEvent(new RealtimeClientEventResponseCancel()); + return sendEvent(new RealtimeResponseCancelEvent()); } /** @@ -339,7 +336,7 @@ public Mono cancelResponse() { */ public Mono cancelResponse(String responseId) { Objects.requireNonNull(responseId, "'responseId' cannot be null."); - return sendEvent(new RealtimeClientEventResponseCancel().setResponseId(responseId)); + return sendEvent(new RealtimeResponseCancelEvent().setResponseId(responseId)); } /** diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketSessionClient.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketSessionClient.java index d35216d24d5a0..2ce56b6e90621 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketSessionClient.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketSessionClient.java @@ -7,19 +7,17 @@ import com.azure.ai.agents.implementation.realtime.VoiceAgentWebSocketHttpResponse; import com.azure.ai.agents.implementation.utils.Beta; import com.azure.ai.agents.models.RealtimeClientEvent; -import com.azure.ai.agents.models.RealtimeClientEventConversationItemCreate; -import com.azure.ai.agents.models.RealtimeClientEventInputAudioBufferAppend; -import com.azure.ai.agents.models.RealtimeClientEventInputAudioBufferClear; -import com.azure.ai.agents.models.RealtimeClientEventInputAudioBufferCommit; -import com.azure.ai.agents.models.RealtimeClientEventResponseCancel; -import com.azure.ai.agents.models.RealtimeClientEventResponseCreate; +import com.azure.ai.agents.models.RealtimeConversationItemCreateEvent; +import com.azure.ai.agents.models.RealtimeInputAudioBufferAppendEvent; +import com.azure.ai.agents.models.RealtimeInputAudioBufferClearEvent; +import com.azure.ai.agents.models.RealtimeInputAudioBufferCommitEvent; +import com.azure.ai.agents.models.RealtimeResponseCancelEvent; +import com.azure.ai.agents.models.RealtimeResponseCreateEvent; import com.azure.ai.agents.models.RealtimeConversationItem; import com.azure.ai.agents.models.RealtimeConversationItemFunctionCallOutput; -import com.azure.ai.agents.models.RealtimeConversationItemMessageUser; -import com.azure.ai.agents.models.RealtimeConversationItemMessageUserContent; -import com.azure.ai.agents.models.RealtimeConversationItemMessageUserContentType; +import com.azure.ai.agents.models.RealtimeConversationItemUserMessage; import com.azure.ai.agents.models.RealtimeServerEvent; -import com.azure.ai.agents.models.VoiceAgentResponseCreateParams; +import com.azure.ai.agents.models.VoiceAgentResponseCreateOptions; import com.azure.ai.agents.models.VoiceAgentWebSocketConnectionOptions; import com.azure.core.exception.ClientAuthenticationException; import com.azure.core.exception.HttpResponseException; @@ -28,6 +26,7 @@ import com.azure.core.http.HttpHeader; import com.azure.core.http.ProxyOptions; import com.azure.core.util.BinaryData; +import com.openai.models.realtime.RealtimeConversationItemUserMessage.Content; import com.azure.core.util.IterableStream; import com.azure.core.util.logging.ClientLogger; import java.io.IOException; @@ -231,7 +230,7 @@ public void createConversationItem(RealtimeConversationItem item) { */ public void createConversationItem(RealtimeConversationItem item, String previousItemId) { Objects.requireNonNull(item, "'item' cannot be null."); - sendEvent(new RealtimeClientEventConversationItemCreate(item).setPreviousItemId(previousItemId)); + sendEvent(new RealtimeConversationItemCreateEvent(item).setPreviousItemId(previousItemId)); } /** @@ -241,10 +240,8 @@ public void createConversationItem(RealtimeConversationItem item, String previou */ public void sendText(String text) { Objects.requireNonNull(text, "'text' cannot be null."); - RealtimeConversationItemMessageUserContent content = new RealtimeConversationItemMessageUserContent() - .setType(RealtimeConversationItemMessageUserContentType.INPUT_TEXT) - .setText(text); - createConversationItem(new RealtimeConversationItemMessageUser(Collections.singletonList(content))); + Content content = Content.builder().type(Content.Type.INPUT_TEXT).text(text).build(); + createConversationItem(new RealtimeConversationItemUserMessage(Collections.singletonList(content))); } /** @@ -254,22 +251,22 @@ public void sendText(String text) { */ public void appendInputAudio(BinaryData audio) { Objects.requireNonNull(audio, "'audio' cannot be null."); - sendEvent(new RealtimeClientEventInputAudioBufferAppend(Base64.getEncoder().encodeToString(audio.toBytes()))); + sendEvent(new RealtimeInputAudioBufferAppendEvent(Base64.getEncoder().encodeToString(audio.toBytes()))); } /** Clears the input audio buffer. */ public void clearInputAudio() { - sendEvent(new RealtimeClientEventInputAudioBufferClear()); + sendEvent(new RealtimeInputAudioBufferClearEvent()); } /** Commits the input audio buffer. */ public void commitInputAudio() { - sendEvent(new RealtimeClientEventInputAudioBufferCommit()); + sendEvent(new RealtimeInputAudioBufferCommitEvent()); } /** Requests a response using the voice agent's configuration. */ public void createResponse() { - sendEvent(new RealtimeClientEventResponseCreate()); + sendEvent(new RealtimeResponseCreateEvent()); } /** @@ -277,14 +274,14 @@ public void createResponse() { * * @param responseOptions response options. */ - public void createResponse(VoiceAgentResponseCreateParams responseOptions) { + public void createResponse(VoiceAgentResponseCreateOptions responseOptions) { Objects.requireNonNull(responseOptions, "'responseOptions' cannot be null."); - sendEvent(new RealtimeClientEventResponseCreate().setResponse(responseOptions)); + sendEvent(new RealtimeResponseCreateEvent().setResponse(responseOptions)); } /** Cancels the active response. */ public void cancelResponse() { - sendEvent(new RealtimeClientEventResponseCancel()); + sendEvent(new RealtimeResponseCancelEvent()); } /** @@ -294,7 +291,7 @@ public void cancelResponse() { */ public void cancelResponse(String responseId) { Objects.requireNonNull(responseId, "'responseId' cannot be null."); - sendEvent(new RealtimeClientEventResponseCancel().setResponseId(responseId)); + sendEvent(new RealtimeResponseCancelEvent().setResponseId(responseId)); } /** diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSample.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSample.java index e3c157dc6cbac..7e510fa1cfe83 100644 --- a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSample.java +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSample.java @@ -10,14 +10,14 @@ import com.azure.ai.agents.BetaVoiceAgentWebSocketAsyncClient; import com.azure.ai.agents.VoiceAgentWebSocketSessionAsyncClient; import com.azure.ai.agents.models.CreateAgentVersionInput; -import com.azure.ai.agents.models.RealtimeServerEventConversationItemInputAudioTranscriptionCompleted; -import com.azure.ai.agents.models.RealtimeServerEventInputAudioBufferSpeechStarted; -import com.azure.ai.agents.models.RealtimeServerEventError; -import com.azure.ai.agents.models.RealtimeServerEventResponseAudioDelta; -import com.azure.ai.agents.models.RealtimeServerEventResponseAudioTranscriptDone; -import com.azure.ai.agents.models.RealtimeServerEventResponseCreated; -import com.azure.ai.agents.models.RealtimeServerEventResponseDone; -import com.azure.ai.agents.models.RealtimeServerEventSessionCreated; +import com.azure.ai.agents.models.RealtimeConversationItemInputAudioTranscriptionCompletedEvent; +import com.azure.ai.agents.models.RealtimeInputAudioBufferSpeechStartedEvent; +import com.azure.ai.agents.models.RealtimeErrorEvent; +import com.azure.ai.agents.models.RealtimeResponseAudioDeltaEvent; +import com.azure.ai.agents.models.RealtimeResponseAudioTranscriptDoneEvent; +import com.azure.ai.agents.models.RealtimeResponseCreatedEvent; +import com.azure.ai.agents.models.RealtimeResponseDoneEvent; +import com.azure.ai.agents.models.RealtimeSessionCreatedEvent; import com.azure.ai.agents.models.VoiceAgentDefinition; import com.azure.core.util.BinaryData; import com.azure.core.util.Configuration; @@ -125,34 +125,34 @@ private static Mono runConversation(VoiceAgentWebSocketSessionAsyncClient AudioProcessor processor = new AudioProcessor(session); AtomicBoolean responseActive = new AtomicBoolean(); Mono receive = session.receiveEvents().concatMap(event -> { - if (event instanceof RealtimeServerEventSessionCreated) { - String id = ((RealtimeServerEventSessionCreated) event).getConversationId(); + if (event instanceof RealtimeSessionCreatedEvent) { + String id = ((RealtimeSessionCreatedEvent) event).getConversationId(); if (id != null) { conversationId.set(id); } - } else if (event instanceof RealtimeServerEventInputAudioBufferSpeechStarted) { + } else if (event instanceof RealtimeInputAudioBufferSpeechStartedEvent) { if (responseActive.get()) { processor.skipPendingAudio(); System.out.println("(listening...)"); return session.cancelResponse().timeout(SEND_TIMEOUT); } - } else if (event instanceof RealtimeServerEventConversationItemInputAudioTranscriptionCompleted) { + } else if (event instanceof RealtimeConversationItemInputAudioTranscriptionCompletedEvent) { System.out.println("You: " - + ((RealtimeServerEventConversationItemInputAudioTranscriptionCompleted) event) + + ((RealtimeConversationItemInputAudioTranscriptionCompletedEvent) event) .getTranscript().trim()); - } else if (event instanceof RealtimeServerEventResponseCreated) { + } else if (event instanceof RealtimeResponseCreatedEvent) { responseActive.set(true); - } else if (event instanceof RealtimeServerEventResponseDone) { + } else if (event instanceof RealtimeResponseDoneEvent) { responseActive.set(false); - } else if (event instanceof RealtimeServerEventResponseAudioDelta) { - processor.queueAudio(((RealtimeServerEventResponseAudioDelta) event).getDelta()); - } else if (event instanceof RealtimeServerEventResponseAudioTranscriptDone) { + } else if (event instanceof RealtimeResponseAudioDeltaEvent) { + processor.queueAudio(((RealtimeResponseAudioDeltaEvent) event).getDelta()); + } else if (event instanceof RealtimeResponseAudioTranscriptDoneEvent) { System.out.println("Agent: " - + ((RealtimeServerEventResponseAudioTranscriptDone) event).getTranscript()); - } else if (event instanceof RealtimeServerEventError) { - RealtimeServerEventError error - = (RealtimeServerEventError) event; - System.out.println("Session error: " + error.getError().getMessage()); + + ((RealtimeResponseAudioTranscriptDoneEvent) event).getTranscript()); + } else if (event instanceof RealtimeErrorEvent) { + RealtimeErrorEvent error + = (RealtimeErrorEvent) event; + System.out.println("Session error: " + error.getError().message()); } return Mono.empty(); }).then(); diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveFunctionToolSample.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveFunctionToolSample.java index 4c11e45b37b13..a7b1bb8a2b61a 100644 --- a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveFunctionToolSample.java +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveFunctionToolSample.java @@ -11,10 +11,10 @@ import com.azure.ai.agents.models.RealtimeConversationItem; import com.azure.ai.agents.models.RealtimeConversationItemType; import com.azure.ai.agents.models.RealtimeServerEvent; -import com.azure.ai.agents.models.RealtimeServerEventError; -import com.azure.ai.agents.models.RealtimeServerEventResponseDone; -import com.azure.ai.agents.models.RealtimeServerEventResponseFunctionCallArgumentsDone; -import com.azure.ai.agents.models.RealtimeServerEventResponseTextDone; +import com.azure.ai.agents.models.RealtimeErrorEvent; +import com.azure.ai.agents.models.RealtimeResponseDoneEvent; +import com.azure.ai.agents.models.RealtimeResponseFunctionCallArgumentsDoneEvent; +import com.azure.ai.agents.models.RealtimeResponseTextDoneEvent; import com.azure.ai.agents.models.VoiceAgentDefinition; import com.azure.ai.agents.models.VoiceAgentFunctionTool; import com.azure.ai.agents.models.VoiceAgentTool; @@ -116,27 +116,27 @@ public static void main(String[] args) { private static void receiveResponse(VoiceAgentWebSocketSessionClient session) { for (RealtimeServerEvent event : session.receiveEvents()) { - if (event instanceof RealtimeServerEventResponseFunctionCallArgumentsDone) { - RealtimeServerEventResponseFunctionCallArgumentsDone call - = (RealtimeServerEventResponseFunctionCallArgumentsDone) event; + if (event instanceof RealtimeResponseFunctionCallArgumentsDoneEvent) { + RealtimeResponseFunctionCallArgumentsDoneEvent call + = (RealtimeResponseFunctionCallArgumentsDoneEvent) event; session.sendFunctionCallOutput(call.getCallId(), executeTool(call)); - } else if (event instanceof RealtimeServerEventResponseTextDone) { - System.out.println("Agent: " + ((RealtimeServerEventResponseTextDone) event).getText()); - } else if (event instanceof RealtimeServerEventResponseDone) { - if (!containsFunctionCall((RealtimeServerEventResponseDone) event)) { + } else if (event instanceof RealtimeResponseTextDoneEvent) { + System.out.println("Agent: " + ((RealtimeResponseTextDoneEvent) event).getText()); + } else if (event instanceof RealtimeResponseDoneEvent) { + if (!containsFunctionCall((RealtimeResponseDoneEvent) event)) { return; } - } else if (event instanceof RealtimeServerEventError) { - RealtimeServerEventError error - = (RealtimeServerEventError) event; - System.out.println("Session error: " + error.getError().getMessage()); + } else if (event instanceof RealtimeErrorEvent) { + RealtimeErrorEvent error + = (RealtimeErrorEvent) event; + System.out.println("Session error: " + error.getError().message()); return; } } } @SuppressWarnings("unchecked") - private static String executeTool(RealtimeServerEventResponseFunctionCallArgumentsDone call) { + private static String executeTool(RealtimeResponseFunctionCallArgumentsDoneEvent call) { Map arguments = BinaryData.fromString(call.getArguments()).toObject(Map.class); System.out.printf("Tool call: %s(%s)%n", call.getName(), arguments); Map result = new LinkedHashMap<>(); @@ -150,7 +150,7 @@ private static String executeTool(RealtimeServerEventResponseFunctionCallArgumen return BinaryData.fromObject(result).toString(); } - private static boolean containsFunctionCall(RealtimeServerEventResponseDone event) { + private static boolean containsFunctionCall(RealtimeResponseDoneEvent event) { List output = event.getResponse().getOutput(); if (output == null) { return false; diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentReadConversationAudioSample.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentReadConversationAudioSample.java index 94a9cf318437e..431e30c5213b0 100644 --- a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentReadConversationAudioSample.java +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentReadConversationAudioSample.java @@ -5,8 +5,8 @@ import com.azure.ai.agents.BetaVoiceAgentsConversationsClient; import com.azure.ai.agents.AgentsClientBuilder; -import com.azure.ai.agents.models.VoiceAudioItemResponse; -import com.azure.ai.agents.models.VoiceRecordingResponse; +import com.azure.ai.agents.models.VoiceAudioItem; +import com.azure.ai.agents.models.VoiceRecording; import com.azure.core.exception.ResourceNotFoundException; import com.azure.core.http.rest.RequestOptions; import com.azure.core.util.BinaryData; @@ -40,7 +40,7 @@ public static void main(String[] args) throws IOException { .beta() .buildBetaVoiceAgentsConversationsClient(); - VoiceRecordingResponse recording = conversations.getAgentConversationAudio(agentName, conversationId); + VoiceRecording recording = conversations.getAgentConversationAudio(agentName, conversationId); System.out.printf("Recording: format=%s, rate=%d, channels=%d, duration=%s%n", recording.getFormat(), recording.getSampleRate(), recording.getChannels(), recording.getDurationMs()); if (recording.getBlobUri() != null) { @@ -60,7 +60,7 @@ public static void main(String[] args) throws IOException { continue; } try { - VoiceAudioItemResponse metadata = conversations.getAgentConversationAudioItem( + VoiceAudioItem metadata = conversations.getAgentConversationAudioItem( agentName, conversationId, itemId); if (metadata.getBlobUri() != null) { System.out.println("Item audio is stored in customer storage: " + metadata.getBlobUri()); diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentRealtimeSampleUtils.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentRealtimeSampleUtils.java index 7b9ea6ca8c731..6ce3f33a22d51 100644 --- a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentRealtimeSampleUtils.java +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentRealtimeSampleUtils.java @@ -6,11 +6,11 @@ import com.azure.ai.agents.BetaVoiceAgentsConversationsAsyncClient; import com.azure.ai.agents.BetaVoiceAgentsConversationsClient; import com.azure.ai.agents.models.RealtimeServerEvent; -import com.azure.ai.agents.models.RealtimeServerEventError; -import com.azure.ai.agents.models.RealtimeServerEventResponseAudioDelta; -import com.azure.ai.agents.models.RealtimeServerEventResponseAudioTranscriptDone; -import com.azure.ai.agents.models.RealtimeServerEventResponseDone; -import com.azure.ai.agents.models.RealtimeServerEventSessionCreated; +import com.azure.ai.agents.models.RealtimeErrorEvent; +import com.azure.ai.agents.models.RealtimeResponseAudioDeltaEvent; +import com.azure.ai.agents.models.RealtimeResponseAudioTranscriptDoneEvent; +import com.azure.ai.agents.models.RealtimeResponseDoneEvent; +import com.azure.ai.agents.models.RealtimeSessionCreatedEvent; import com.azure.ai.agents.models.VoiceConversation; import com.azure.core.http.rest.RequestOptions; import com.azure.core.util.BinaryData; @@ -32,23 +32,23 @@ private VoiceAgentRealtimeSampleUtils() { static boolean handleResponseEvent(RealtimeServerEvent event, AtomicReference conversationId, SpeakerPlayer player) { - if (event instanceof RealtimeServerEventSessionCreated) { - String id = ((RealtimeServerEventSessionCreated) event).getConversationId(); + if (event instanceof RealtimeSessionCreatedEvent) { + String id = ((RealtimeSessionCreatedEvent) event).getConversationId(); if (id != null) { conversationId.set(id); } - } else if (event instanceof RealtimeServerEventResponseAudioDelta) { - player.play(((RealtimeServerEventResponseAudioDelta) event).getDelta()); - } else if (event instanceof RealtimeServerEventResponseAudioTranscriptDone) { + } else if (event instanceof RealtimeResponseAudioDeltaEvent) { + player.play(((RealtimeResponseAudioDeltaEvent) event).getDelta()); + } else if (event instanceof RealtimeResponseAudioTranscriptDoneEvent) { System.out.println("Agent: " - + ((RealtimeServerEventResponseAudioTranscriptDone) event).getTranscript()); - } else if (event instanceof RealtimeServerEventError) { - RealtimeServerEventError error - = (RealtimeServerEventError) event; - System.out.println("Session error: " + error.getError().getMessage()); + + ((RealtimeResponseAudioTranscriptDoneEvent) event).getTranscript()); + } else if (event instanceof RealtimeErrorEvent) { + RealtimeErrorEvent error + = (RealtimeErrorEvent) event; + System.out.println("Session error: " + error.getError().message()); return true; } - return event instanceof RealtimeServerEventResponseDone; + return event instanceof RealtimeResponseDoneEvent; } static void readConversation(BetaVoiceAgentsConversationsClient conversations, String agentName, diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentSampleUtils.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentSampleUtils.java index 5f7a8da6ddf82..2b37d1c0b1e02 100644 --- a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentSampleUtils.java +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentSampleUtils.java @@ -3,8 +3,8 @@ package com.azure.ai.agents.voice; -import com.azure.ai.agents.models.VoiceAgentAudioConfig; -import com.azure.ai.agents.models.VoiceAgentAudioOutputConfig; +import com.azure.ai.agents.models.VoiceAgentAudioConfiguration; +import com.azure.ai.agents.models.VoiceAgentAudioOutputConfiguration; import com.azure.ai.agents.models.VoiceAgentDefinition; import com.azure.ai.agents.models.VoiceModelType; import com.azure.ai.agents.models.VoiceOutputModality; @@ -17,14 +17,14 @@ private VoiceAgentSampleUtils() { } static VoiceAgentDefinition createDefinition(VoiceModelType modelType, String model, String instructions) { - VoiceAgentAudioOutputConfig output = new VoiceAgentAudioOutputConfig() + VoiceAgentAudioOutputConfiguration output = new VoiceAgentAudioOutputConfiguration() .setVoice("en-US-AvaNeural") .setVoiceType(VoiceType.AZURE_STANDARD); return new VoiceAgentDefinition() .setModelType(modelType) .setModel(model) .setInstructions(instructions) - .setAudio(new VoiceAgentAudioConfig().setOutput(output)) + .setAudio(new VoiceAgentAudioConfiguration().setOutput(output)) .setOutputModalities(Collections.singletonList(VoiceOutputModality.AUDIO)) .setStore(true); } diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentWithToolsSample.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentWithToolsSample.java index c4b2987c3a001..7386f7daf3d7e 100644 --- a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentWithToolsSample.java +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentWithToolsSample.java @@ -10,11 +10,11 @@ import com.azure.identity.DefaultAzureCredentialBuilder; import com.azure.ai.agents.models.AgentVersionDetails; import com.azure.ai.agents.models.CreateAgentVersionInput; -import com.azure.ai.agents.models.RealtimeAudioFormatsAudioPcm; -import com.azure.ai.agents.models.RealtimeAudioFormatsAudioPcmRate; -import com.azure.ai.agents.models.VoiceAgentAudioConfig; -import com.azure.ai.agents.models.VoiceAgentAudioInputConfig; -import com.azure.ai.agents.models.VoiceAgentAudioOutputConfig; +import com.azure.ai.agents.models.RealtimePcmAudioFormat; +import com.openai.models.realtime.RealtimeAudioFormats.AudioPcm.Rate; +import com.azure.ai.agents.models.VoiceAgentAudioConfiguration; +import com.azure.ai.agents.models.VoiceAgentAudioInputConfiguration; +import com.azure.ai.agents.models.VoiceAgentAudioOutputConfiguration; import com.azure.ai.agents.models.VoiceAgentDefinition; import com.azure.ai.agents.models.VoiceAgentFunctionTool; import com.azure.ai.agents.models.VoiceAgentInputTranscription; @@ -57,16 +57,16 @@ public static void main(String[] args) { .allowPreview(true) .buildAgentsClient(); - RealtimeAudioFormatsAudioPcm pcm = new RealtimeAudioFormatsAudioPcm() - .setRate(RealtimeAudioFormatsAudioPcmRate.TWO_FOUR_ZERO_ZERO_ZERO); - VoiceAgentAudioInputConfig input = new VoiceAgentAudioInputConfig() + RealtimePcmAudioFormat pcm = new RealtimePcmAudioFormat() + .setRate(Rate._24000); + VoiceAgentAudioInputConfiguration input = new VoiceAgentAudioInputConfiguration() .setFormat(pcm) .setTurnDetection(new VoiceAgentServerVadTurnDetection() .setThreshold(0.5) .setPrefixPaddingMs(300L) .setSilenceDurationMs(500L)) .setTranscription(new VoiceAgentInputTranscription(VoiceAgentInputTranscriptionModel.WHISPER_1)); - VoiceAgentAudioOutputConfig output = new VoiceAgentAudioOutputConfig() + VoiceAgentAudioOutputConfiguration output = new VoiceAgentAudioOutputConfiguration() .setVoice("en-US-AvaNeural") .setVoiceType(VoiceType.AZURE_STANDARD); Map cityProperty = new LinkedHashMap<>(); @@ -86,7 +86,7 @@ public static void main(String[] args) { .setModelType(modelType) .setModel(model) .setInstructions("Use tools when they help answer the caller.") - .setAudio(new VoiceAgentAudioConfig().setInput(input).setOutput(output)) + .setAudio(new VoiceAgentAudioConfiguration().setInput(input).setOutput(output)) .setOutputModalities(Collections.singletonList(VoiceOutputModality.AUDIO)) .setTools(Arrays.asList(weather, endCall)) .setStore(true); diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/models/VoiceAgentDefinitionSerializationTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/models/VoiceAgentDefinitionSerializationTests.java index 39ad80a8bc695..53c59ec5bc3fa 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/models/VoiceAgentDefinitionSerializationTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/models/VoiceAgentDefinitionSerializationTests.java @@ -10,6 +10,7 @@ import com.openai.models.responses.ToolChoiceFunction; import com.openai.models.responses.ToolChoiceMcp; import com.openai.models.responses.ToolChoiceOptions; +import com.openai.models.realtime.RealtimeAudioFormats.AudioPcm.Rate; import org.junit.jupiter.api.Test; import java.io.ByteArrayOutputStream; @@ -81,14 +82,13 @@ private VoiceAgentDefinition roundTrip(VoiceAgentDefinition value) throws IOExce @Test public void fullVoiceDefinitionRoundTrips() throws IOException { - RealtimeAudioFormatsAudioPcm pcm - = new RealtimeAudioFormatsAudioPcm().setRate(RealtimeAudioFormatsAudioPcmRate.TWO_FOUR_ZERO_ZERO_ZERO); - VoiceAgentAudioInputConfig input = new VoiceAgentAudioInputConfig().setFormat(pcm) + RealtimePcmAudioFormat pcm = new RealtimePcmAudioFormat().setRate(Rate._24000); + VoiceAgentAudioInputConfiguration input = new VoiceAgentAudioInputConfiguration().setFormat(pcm) .setTurnDetection(new VoiceAgentServerVadTurnDetection().setThreshold(0.5) .setPrefixPaddingMs(300L) .setSilenceDurationMs(500L)) .setTranscription(new VoiceAgentInputTranscription(VoiceAgentInputTranscriptionModel.WHISPER_1)); - VoiceAgentAudioOutputConfig output = new VoiceAgentAudioOutputConfig().setFormat(pcm) + VoiceAgentAudioOutputConfiguration output = new VoiceAgentAudioOutputConfiguration().setFormat(pcm) .setVoice("en-US-AvaNeural") .setVoiceType(VoiceType.AZURE_STANDARD); VoiceAgentFunctionTool functionTool @@ -99,7 +99,7 @@ public void fullVoiceDefinitionRoundTrips() throws IOException { VoiceAgentDefinition original = new VoiceAgentDefinition().setModelType(VoiceModelType.MANAGED) .setModel("gpt-realtime") .setInstructions("Keep replies short and natural.") - .setAudio(new VoiceAgentAudioConfig().setInput(input).setOutput(output)) + .setAudio(new VoiceAgentAudioConfiguration().setInput(input).setOutput(output)) .setOutputModalities(Collections.singletonList(VoiceOutputModality.AUDIO)) .setTools(Arrays.asList(functionTool, systemTool)) .setStore(true); @@ -131,9 +131,9 @@ public void fullVoiceDefinitionRoundTrips() throws IOException { assertEquals(Boolean.TRUE, voice.isStore()); assertEquals(VoiceOutputModality.AUDIO, voice.getOutputModalities().get(0)); - VoiceAgentAudioInputConfig deserializedInput = voice.getAudio().getInput(); - RealtimeAudioFormatsAudioPcm deserializedInputFormat - = assertInstanceOf(RealtimeAudioFormatsAudioPcm.class, deserializedInput.getFormat()); + VoiceAgentAudioInputConfiguration deserializedInput = voice.getAudio().getInput(); + RealtimePcmAudioFormat deserializedInputFormat + = assertInstanceOf(RealtimePcmAudioFormat.class, deserializedInput.getFormat()); assertEquals(pcm.getRate(), deserializedInputFormat.getRate()); VoiceAgentServerVadTurnDetection deserializedVad = assertInstanceOf(VoiceAgentServerVadTurnDetection.class, deserializedInput.getTurnDetection()); @@ -143,9 +143,9 @@ public void fullVoiceDefinitionRoundTrips() throws IOException { assertEquals(originalVad.getSilenceDurationMs(), deserializedVad.getSilenceDurationMs()); assertEquals(input.getTranscription().getModel(), deserializedInput.getTranscription().getModel()); - VoiceAgentAudioOutputConfig deserializedOutput = voice.getAudio().getOutput(); - RealtimeAudioFormatsAudioPcm deserializedOutputFormat - = assertInstanceOf(RealtimeAudioFormatsAudioPcm.class, deserializedOutput.getFormat()); + VoiceAgentAudioOutputConfiguration deserializedOutput = voice.getAudio().getOutput(); + RealtimePcmAudioFormat deserializedOutputFormat + = assertInstanceOf(RealtimePcmAudioFormat.class, deserializedOutput.getFormat()); assertEquals(pcm.getRate(), deserializedOutputFormat.getRate()); assertEquals(output.getVoice(), deserializedOutput.getVoice()); assertEquals(output.getVoiceType(), deserializedOutput.getVoiceType()); diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsAsyncTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsAsyncTests.java index 432c887e1a9f9..020fa1cdd2023 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsAsyncTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsAsyncTests.java @@ -9,17 +9,17 @@ import com.azure.ai.agents.BetaVoiceAgentsConversationsAsyncClient; import com.azure.ai.agents.VoiceAgentWebSocketSessionAsyncClient; import com.azure.ai.agents.models.CreateAgentVersionInput; -import com.azure.ai.agents.models.RealtimeServerEventResponseDone; -import com.azure.ai.agents.models.RealtimeServerEventSessionCreated; -import com.azure.ai.agents.models.VoiceAgentAudioConfig; -import com.azure.ai.agents.models.VoiceAgentAudioOutputConfig; +import com.azure.ai.agents.models.RealtimeResponseDoneEvent; +import com.azure.ai.agents.models.RealtimeSessionCreatedEvent; +import com.azure.ai.agents.models.VoiceAgentAudioConfiguration; +import com.azure.ai.agents.models.VoiceAgentAudioOutputConfiguration; import com.azure.ai.agents.models.VoiceAgentDefinition; import com.azure.ai.agents.models.VoiceModelType; import com.azure.ai.agents.models.VoiceOutputModality; import com.azure.ai.agents.models.VoiceType; import com.azure.ai.agents.models.VoiceConversationStatus; -import com.azure.ai.agents.models.VoiceAudioItemResponse; -import com.azure.ai.agents.models.VoiceRecordingResponse; +import com.azure.ai.agents.models.VoiceAudioItem; +import com.azure.ai.agents.models.VoiceRecording; import com.azure.ai.agents.models.VoiceResponse; import com.azure.core.exception.HttpResponseException; import com.azure.core.http.HttpClient; @@ -94,8 +94,9 @@ public void readLivePersistedConversation() { VoiceAgentDefinition definition = new VoiceAgentDefinition().setModelType(VoiceModelType.MANAGED) .setModel(model) .setInstructions("You are a helpful voice assistant. Keep replies short.") - .setAudio(new VoiceAgentAudioConfig().setOutput( - new VoiceAgentAudioOutputConfig().setVoice("en-US-AvaNeural").setVoiceType(VoiceType.AZURE_STANDARD))) + .setAudio(new VoiceAgentAudioConfiguration() + .setOutput(new VoiceAgentAudioOutputConfiguration().setVoice("en-US-AvaNeural") + .setVoiceType(VoiceType.AZURE_STANDARD))) .setOutputModalities(Collections.singletonList(VoiceOutputModality.AUDIO)) .setStore(true); AtomicReference conversationId = new AtomicReference<>(); @@ -107,9 +108,9 @@ public void readLivePersistedConversation() { Mono.usingWhen(builder.beta().buildBetaVoiceAgentWebSocketAsyncClient().connect(agentName), session -> session.receiveEvents().index().concatMap(indexed -> { if (indexed.getT1() == 0) { - assertTrue(indexed.getT2() instanceof RealtimeServerEventSessionCreated, + assertTrue(indexed.getT2() instanceof RealtimeSessionCreatedEvent, "The first event must be session.created."); - conversationId.set(((RealtimeServerEventSessionCreated) indexed.getT2()).getConversationId()); + conversationId.set(((RealtimeSessionCreatedEvent) indexed.getT2()).getConversationId()); assertNotNull(conversationId.get(), "store=True must return a conversation ID."); return session.sendText("Say hello.") .then(session.createResponse()) @@ -117,7 +118,7 @@ public void readLivePersistedConversation() { } return Mono.just(indexed.getT2()); }) - .filter(RealtimeServerEventResponseDone.class::isInstance) + .filter(RealtimeResponseDoneEvent.class::isInstance) .next() .switchIfEmpty(Mono.error(new AssertionError("Session ended without response.done."))) .timeout(Duration.ofSeconds(45)) @@ -255,8 +256,7 @@ private static void assertPersistedConversation(BetaVoiceAgentsConversationsAsyn assertEquals(VoiceConversationStatus.COMPLETED, conversation.getStatus(), "Audio assertions require a finalized conversation."); - VoiceRecordingResponse recording - = client.getAgentConversationAudio(agentName, conversationId).block(TIMEOUT); + VoiceRecording recording = client.getAgentConversationAudio(agentName, conversationId).block(TIMEOUT); assertNotNull(recording); assertNotNull(recording.getFormat()); if (recording.getBlobUri() == null || recording.getBlobUri().isEmpty()) { @@ -267,7 +267,7 @@ private static void assertPersistedConversation(BetaVoiceAgentsConversationsAsyn if (id == null || id.isEmpty()) { continue; } - VoiceAudioItemResponse audio; + VoiceAudioItem audio; try { audio = client.getAgentConversationAudioItem(agentName, conversationId, id).block(TIMEOUT); } catch (HttpResponseException error) { diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsTests.java index 14f0ac1596bd7..cdc7ab6a70a4b 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsTests.java @@ -9,17 +9,17 @@ import com.azure.ai.agents.VoiceAgentWebSocketSessionClient; import com.azure.ai.agents.models.CreateAgentVersionInput; import com.azure.ai.agents.models.RealtimeServerEvent; -import com.azure.ai.agents.models.RealtimeServerEventResponseDone; -import com.azure.ai.agents.models.RealtimeServerEventSessionCreated; -import com.azure.ai.agents.models.VoiceAgentAudioConfig; -import com.azure.ai.agents.models.VoiceAgentAudioOutputConfig; +import com.azure.ai.agents.models.RealtimeResponseDoneEvent; +import com.azure.ai.agents.models.RealtimeSessionCreatedEvent; +import com.azure.ai.agents.models.VoiceAgentAudioConfiguration; +import com.azure.ai.agents.models.VoiceAgentAudioOutputConfiguration; import com.azure.ai.agents.models.VoiceAgentDefinition; import com.azure.ai.agents.models.VoiceConversation; import com.azure.ai.agents.models.VoiceConversationStatus; -import com.azure.ai.agents.models.VoiceAudioItemResponse; +import com.azure.ai.agents.models.VoiceAudioItem; import com.azure.ai.agents.models.VoiceModelType; import com.azure.ai.agents.models.VoiceOutputModality; -import com.azure.ai.agents.models.VoiceRecordingResponse; +import com.azure.ai.agents.models.VoiceRecording; import com.azure.ai.agents.models.VoiceResponse; import com.azure.ai.agents.models.VoiceType; import com.azure.core.exception.HttpResponseException; @@ -96,8 +96,9 @@ public void readLivePersistedConversation() throws InterruptedException { VoiceAgentDefinition definition = new VoiceAgentDefinition().setModelType(VoiceModelType.MANAGED) .setModel(model) .setInstructions("You are a helpful voice assistant. Keep replies short.") - .setAudio(new VoiceAgentAudioConfig().setOutput( - new VoiceAgentAudioOutputConfig().setVoice("en-US-AvaNeural").setVoiceType(VoiceType.AZURE_STANDARD))) + .setAudio(new VoiceAgentAudioConfiguration() + .setOutput(new VoiceAgentAudioOutputConfiguration().setVoice("en-US-AvaNeural") + .setVoiceType(VoiceType.AZURE_STANDARD))) .setOutputModalities(Collections.singletonList(VoiceOutputModality.AUDIO)) .setStore(true); String conversationId = null; @@ -111,16 +112,15 @@ public void readLivePersistedConversation() throws InterruptedException { Iterator events = session.receiveEvents(TIMEOUT).iterator(); assertTrue(events.hasNext(), "Expected session.created."); RealtimeServerEvent first = events.next(); - assertTrue(first instanceof RealtimeServerEventSessionCreated, - "The first event must be session.created."); - conversationId = ((RealtimeServerEventSessionCreated) first).getConversationId(); + assertTrue(first instanceof RealtimeSessionCreatedEvent, "The first event must be session.created."); + conversationId = ((RealtimeSessionCreatedEvent) first).getConversationId(); assertNotNull(conversationId, "store=True must return a conversation ID."); session.sendText("Say hello."); session.createResponse(); long deadline = System.nanoTime() + Duration.ofSeconds(45).toNanos(); boolean responseDone = false; while (System.nanoTime() < deadline && events.hasNext()) { - if (events.next() instanceof RealtimeServerEventResponseDone) { + if (events.next() instanceof RealtimeResponseDoneEvent) { responseDone = true; break; } @@ -254,7 +254,7 @@ private static void assertPersistedConversation(BetaVoiceAgentsConversationsClie assertEquals(VoiceConversationStatus.COMPLETED, conversation.getStatus(), "Audio assertions require a finalized conversation."); - VoiceRecordingResponse recording = client.getAgentConversationAudio(agentName, conversationId); + VoiceRecording recording = client.getAgentConversationAudio(agentName, conversationId); assertNotNull(recording); assertNotNull(recording.getFormat()); if (recording.getBlobUri() == null || recording.getBlobUri().isEmpty()) { @@ -265,7 +265,7 @@ private static void assertPersistedConversation(BetaVoiceAgentsConversationsClie if (id == null || id.isEmpty()) { continue; } - VoiceAudioItemResponse audio; + VoiceAudioItem audio; try { audio = client.getAgentConversationAudioItem(agentName, conversationId, id); } catch (HttpResponseException error) { diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentCrudAsyncTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentCrudAsyncTests.java index b368526738714..335a8cd63a91c 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentCrudAsyncTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentCrudAsyncTests.java @@ -9,8 +9,8 @@ import com.azure.ai.agents.models.AgentState; import com.azure.ai.agents.models.AgentVersionDetails; import com.azure.ai.agents.models.CreateAgentVersionInput; -import com.azure.ai.agents.models.VoiceAgentAudioConfig; -import com.azure.ai.agents.models.VoiceAgentAudioOutputConfig; +import com.azure.ai.agents.models.VoiceAgentAudioConfiguration; +import com.azure.ai.agents.models.VoiceAgentAudioOutputConfiguration; import com.azure.ai.agents.models.VoiceAgentDefinition; import com.azure.ai.agents.models.VoiceModelType; import com.azure.ai.agents.models.VoiceOutputModality; @@ -187,8 +187,9 @@ private static VoiceAgentDefinition definition(String model, String instructions return new VoiceAgentDefinition().setModelType(VoiceModelType.MANAGED) .setModel(model) .setInstructions(instructions) - .setAudio(new VoiceAgentAudioConfig().setOutput( - new VoiceAgentAudioOutputConfig().setVoice("en-US-AvaNeural").setVoiceType(VoiceType.AZURE_STANDARD))) + .setAudio(new VoiceAgentAudioConfiguration() + .setOutput(new VoiceAgentAudioOutputConfiguration().setVoice("en-US-AvaNeural") + .setVoiceType(VoiceType.AZURE_STANDARD))) .setOutputModalities(Collections.singletonList(VoiceOutputModality.AUDIO)); } diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentCrudTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentCrudTests.java index 74b5003dc9cc5..f502e686873b0 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentCrudTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentCrudTests.java @@ -9,8 +9,8 @@ import com.azure.ai.agents.models.AgentState; import com.azure.ai.agents.models.AgentVersionDetails; import com.azure.ai.agents.models.CreateAgentVersionInput; -import com.azure.ai.agents.models.VoiceAgentAudioConfig; -import com.azure.ai.agents.models.VoiceAgentAudioOutputConfig; +import com.azure.ai.agents.models.VoiceAgentAudioConfiguration; +import com.azure.ai.agents.models.VoiceAgentAudioOutputConfiguration; import com.azure.ai.agents.models.VoiceAgentDefinition; import com.azure.ai.agents.models.VoiceModelType; import com.azure.ai.agents.models.VoiceOutputModality; @@ -177,8 +177,9 @@ private static VoiceAgentDefinition definition(String model, String instructions return new VoiceAgentDefinition().setModelType(VoiceModelType.MANAGED) .setModel(model) .setInstructions(instructions) - .setAudio(new VoiceAgentAudioConfig().setOutput( - new VoiceAgentAudioOutputConfig().setVoice("en-US-AvaNeural").setVoiceType(VoiceType.AZURE_STANDARD))) + .setAudio(new VoiceAgentAudioConfiguration() + .setOutput(new VoiceAgentAudioOutputConfiguration().setVoice("en-US-AvaNeural") + .setVoiceType(VoiceType.AZURE_STANDARD))) .setOutputModalities(Collections.singletonList(VoiceOutputModality.AUDIO)); } diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSampleTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSampleTests.java index 9728f4547e1c8..0b77049b84e35 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSampleTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSampleTests.java @@ -4,7 +4,7 @@ package com.azure.ai.agents.voice; import com.azure.ai.agents.models.RealtimeServerEvent; -import com.azure.ai.agents.models.RealtimeServerEventConversationCreated; +import com.azure.ai.agents.models.RealtimeConversationCreatedEvent; import com.azure.core.util.BinaryData; import java.io.ByteArrayInputStream; import java.io.InputStream; @@ -42,7 +42,7 @@ public void conversationCreatedModelIsAvailable() { .fromString("{\"type\":\"conversation.created\"," + "\"conversation\":{\"id\":\"test\",\"object\":\"realtime.conversation\"}}") .toObject(RealtimeServerEvent.class); - assertTrue(event instanceof RealtimeServerEventConversationCreated); + assertTrue(event instanceof RealtimeConversationCreatedEvent); } @ParameterizedTest diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentRealtimeLiveTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentRealtimeLiveTests.java index 594fbb7a4e849..fbcfc28eeac09 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentRealtimeLiveTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentRealtimeLiveTests.java @@ -10,22 +10,20 @@ import com.azure.ai.agents.VoiceAgentWebSocketSessionClient; import com.azure.ai.agents.models.CreateAgentVersionInput; import com.azure.ai.agents.models.RealtimeClientEvent; -import com.azure.ai.agents.models.RealtimeClientEventConversationItemCreate; -import com.azure.ai.agents.models.RealtimeClientEventResponseCreate; +import com.azure.ai.agents.models.RealtimeConversationItemCreateEvent; +import com.azure.ai.agents.models.RealtimeResponseCreateEvent; import com.azure.ai.agents.models.RealtimeConversationItemFunctionCallOutput; -import com.azure.ai.agents.models.RealtimeConversationItemMessageUser; -import com.azure.ai.agents.models.RealtimeConversationItemMessageUserContent; -import com.azure.ai.agents.models.RealtimeConversationItemMessageUserContentType; +import com.azure.ai.agents.models.RealtimeConversationItemUserMessage; import com.azure.ai.agents.models.RealtimeServerEvent; -import com.azure.ai.agents.models.RealtimeServerEventError; -import com.azure.ai.agents.models.RealtimeServerEventResponseAudioDelta; -import com.azure.ai.agents.models.RealtimeServerEventResponseAudioTranscriptDone; -import com.azure.ai.agents.models.RealtimeServerEventResponseDone; -import com.azure.ai.agents.models.RealtimeServerEventResponseFunctionCallArgumentsDone; -import com.azure.ai.agents.models.RealtimeServerEventResponseTextDone; -import com.azure.ai.agents.models.RealtimeServerEventSessionCreated; -import com.azure.ai.agents.models.VoiceAgentAudioConfig; -import com.azure.ai.agents.models.VoiceAgentAudioOutputConfig; +import com.azure.ai.agents.models.RealtimeErrorEvent; +import com.azure.ai.agents.models.RealtimeResponseAudioDeltaEvent; +import com.azure.ai.agents.models.RealtimeResponseAudioTranscriptDoneEvent; +import com.azure.ai.agents.models.RealtimeResponseDoneEvent; +import com.azure.ai.agents.models.RealtimeResponseFunctionCallArgumentsDoneEvent; +import com.azure.ai.agents.models.RealtimeResponseTextDoneEvent; +import com.azure.ai.agents.models.RealtimeSessionCreatedEvent; +import com.azure.ai.agents.models.VoiceAgentAudioConfiguration; +import com.azure.ai.agents.models.VoiceAgentAudioOutputConfiguration; import com.azure.ai.agents.models.VoiceAgentDefinition; import com.azure.ai.agents.models.VoiceAgentFunctionTool; import com.azure.ai.agents.models.VoiceModelType; @@ -34,6 +32,7 @@ import com.azure.core.util.BinaryData; import com.azure.core.util.Configuration; import com.azure.identity.DefaultAzureCredentialBuilder; +import com.openai.models.realtime.RealtimeConversationItemUserMessage.Content; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; @@ -147,8 +146,8 @@ public void syntheticEventsExerciseLiveAssertions(Scenario scenario) { List initial = turn.accept(event(SESSION)); assertEquals(scenario == Scenario.LIFECYCLE ? 0 : 2, initial.size()); if (scenario != Scenario.LIFECYCLE) { - assertInstanceOf(RealtimeClientEventConversationItemCreate.class, initial.get(0)); - assertInstanceOf(RealtimeClientEventResponseCreate.class, initial.get(1)); + assertInstanceOf(RealtimeConversationItemCreateEvent.class, initial.get(0)); + assertInstanceOf(RealtimeResponseCreateEvent.class, initial.get(1)); } if (scenario == Scenario.AUDIO) { turn.accept(event("{\"type\":\"response.output_audio.delta\",\"delta\":\"AQID\"}")); @@ -161,8 +160,8 @@ public void syntheticEventsExerciseLiveAssertions(Scenario scenario) { List outputs = turn.accept(event(TOOL_DONE)); assertEquals(3, outputs.size()); for (int index = 0; index < 2; index++) { - RealtimeClientEventConversationItemCreate create - = assertInstanceOf(RealtimeClientEventConversationItemCreate.class, outputs.get(index)); + RealtimeConversationItemCreateEvent create + = assertInstanceOf(RealtimeConversationItemCreateEvent.class, outputs.get(index)); RealtimeConversationItemFunctionCallOutput output = assertInstanceOf(RealtimeConversationItemFunctionCallOutput.class, create.getItem()); assertEquals("call-" + (index + 1), output.getCallId()); @@ -171,7 +170,7 @@ public void syntheticEventsExerciseLiveAssertions(Scenario scenario) { assertEquals("sunny", result.get("condition")); assertEquals(72, result.get("temperature_f")); } - assertInstanceOf(RealtimeClientEventResponseCreate.class, outputs.get(2)); + assertInstanceOf(RealtimeResponseCreateEvent.class, outputs.get(2)); assertFalse(turn.done); turn.accept(event("{\"type\":\"response.output_text.done\",\"text\":\"Sunny in Seattle.\"}")); turn.accept(event(DONE)); @@ -283,8 +282,8 @@ private static VoiceAgentDefinition definition(Scenario scenario, String model) .setParameters(BinaryData.fromObject(parameters)))); } else { definition.setOutputModalities(Collections.singletonList(VoiceOutputModality.AUDIO)) - .setAudio( - new VoiceAgentAudioConfig().setOutput(new VoiceAgentAudioOutputConfig().setVoice("en-US-AvaNeural") + .setAudio(new VoiceAgentAudioConfiguration() + .setOutput(new VoiceAgentAudioOutputConfiguration().setVoice("en-US-AvaNeural") .setVoiceType(VoiceType.AZURE_STANDARD))); } return definition; @@ -315,12 +314,11 @@ private Turn(Scenario scenario) { } private List accept(RealtimeServerEvent event) { - if (event instanceof RealtimeServerEventError) { - fail("Session error: " + ((RealtimeServerEventError) event).getError().getMessage()); + if (event instanceof RealtimeErrorEvent) { + fail("Session error: " + ((RealtimeErrorEvent) event).getError().message()); } if (!started) { - assertInstanceOf(RealtimeServerEventSessionCreated.class, event, - "The first event must be session.created."); + assertInstanceOf(RealtimeSessionCreatedEvent.class, event, "The first event must be session.created."); assertEquals("session.created", event.getType().toString()); started = true; done = scenario == Scenario.LIFECYCLE; @@ -328,26 +326,24 @@ private List accept(RealtimeServerEvent event) { String prompt = scenario == Scenario.FUNCTION ? "What's the weather like in Seattle right now?" : "Say the word 'hello' and nothing else."; - return Arrays - .asList(new RealtimeClientEventConversationItemCreate(new RealtimeConversationItemMessageUser( - Collections.singletonList(new RealtimeConversationItemMessageUserContent() - .setType(RealtimeConversationItemMessageUserContentType.INPUT_TEXT) - .setText(prompt)))), - new RealtimeClientEventResponseCreate()); + return Arrays.asList( + new RealtimeConversationItemCreateEvent(new RealtimeConversationItemUserMessage(Collections + .singletonList(Content.builder().type(Content.Type.INPUT_TEXT).text(prompt).build()))), + new RealtimeResponseCreateEvent()); } - } else if (event instanceof RealtimeServerEventResponseAudioDelta) { + } else if (event instanceof RealtimeResponseAudioDeltaEvent) { audioDeltas++; - byte[] delta = ((RealtimeServerEventResponseAudioDelta) event).getDelta(); + byte[] delta = ((RealtimeResponseAudioDeltaEvent) event).getDelta(); assertNotNull(delta); audioBytes += delta.length; - } else if (event instanceof RealtimeServerEventResponseAudioTranscriptDone) { + } else if (event instanceof RealtimeResponseAudioTranscriptDoneEvent) { transcripts++; - String transcript = ((RealtimeServerEventResponseAudioTranscriptDone) event).getTranscript(); + String transcript = ((RealtimeResponseAudioTranscriptDoneEvent) event).getTranscript(); assertNotNull(transcript); assertFalse(transcript.trim().isEmpty()); - } else if (event instanceof RealtimeServerEventResponseFunctionCallArgumentsDone) { - RealtimeServerEventResponseFunctionCallArgumentsDone call - = (RealtimeServerEventResponseFunctionCallArgumentsDone) event; + } else if (event instanceof RealtimeResponseFunctionCallArgumentsDoneEvent) { + RealtimeResponseFunctionCallArgumentsDoneEvent call + = (RealtimeResponseFunctionCallArgumentsDoneEvent) event; assertEquals("get_weather", call.getName()); Map arguments = BinaryData.fromString(call.getArguments()).toObject(Map.class); String city = assertInstanceOf(String.class, arguments.get("city")); @@ -356,20 +352,19 @@ private List accept(RealtimeServerEvent event) { result.put("city", city); result.put("condition", "sunny"); result.put("temperature_f", 72); - pending - .add(new RealtimeClientEventConversationItemCreate(new RealtimeConversationItemFunctionCallOutput( - call.getCallId(), BinaryData.fromObject(result).toString()))); + pending.add(new RealtimeConversationItemCreateEvent(new RealtimeConversationItemFunctionCallOutput( + call.getCallId(), BinaryData.fromObject(result).toString()))); toolCalls++; - } else if (event instanceof RealtimeServerEventResponseTextDone) { - finalText = ((RealtimeServerEventResponseTextDone) event).getText(); - } else if (event instanceof RealtimeServerEventResponseDone) { + } else if (event instanceof RealtimeResponseTextDoneEvent) { + finalText = ((RealtimeResponseTextDoneEvent) event).getText(); + } else if (event instanceof RealtimeResponseDoneEvent) { if (!pending.isEmpty()) { List outputs = new ArrayList<>(pending); pending.clear(); - outputs.add(new RealtimeClientEventResponseCreate()); + outputs.add(new RealtimeResponseCreateEvent()); return outputs; } - RealtimeServerEventResponseDone response = (RealtimeServerEventResponseDone) event; + RealtimeResponseDoneEvent response = (RealtimeResponseDoneEvent) event; assertNotNull(response.getResponse()); done = scenario != Scenario.FUNCTION || response.getResponse().getOutput() == null diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentTelephonyLiveTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentTelephonyLiveTests.java index 1f19eb3d5b0f7..18be0acbf3d38 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentTelephonyLiveTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentTelephonyLiveTests.java @@ -7,8 +7,8 @@ import com.azure.ai.agents.AgentsClientBuilder; import com.azure.ai.agents.BetaVoiceAgentsTelephonyClient; import com.azure.ai.agents.models.CreateAgentVersionInput; -import com.azure.ai.agents.models.CreateTelephonyCallJobRequest; -import com.azure.ai.agents.models.CreateTwilioTelephonyBindingRequest; +import com.azure.ai.agents.models.CreateTelephonyCallJobInput; +import com.azure.ai.agents.models.CreateTwilioTelephonyBindingInput; import com.azure.ai.agents.models.PstnTelephonyTransferDestination; import com.azure.ai.agents.models.TelephonyBinding; import com.azure.ai.agents.models.TelephonyBindingListItem; @@ -22,9 +22,9 @@ import com.azure.ai.agents.models.TelephonyProvider; import com.azure.ai.agents.models.TelephonyTransferTarget; import com.azure.ai.agents.models.TelephonyTransferTargets; -import com.azure.ai.agents.models.UpdateTelephonyBindingRequest; -import com.azure.ai.agents.models.VoiceAgentAudioConfig; -import com.azure.ai.agents.models.VoiceAgentAudioOutputConfig; +import com.azure.ai.agents.models.UpdateTelephonyBindingInput; +import com.azure.ai.agents.models.VoiceAgentAudioConfiguration; +import com.azure.ai.agents.models.VoiceAgentAudioOutputConfiguration; import com.azure.ai.agents.models.VoiceAgentDefinition; import com.azure.ai.agents.models.VoiceModelType; import com.azure.ai.agents.models.VoiceOutputModality; @@ -87,17 +87,17 @@ public void bindingLifecycleLive() { new CreateAgentVersionInput(definition(model, "Greet the caller briefly, then say goodbye."))); agentCreated = true; TelephonyBinding binding = telephony.createTelephonyBinding(agentName, - new CreateTwilioTelephonyBindingRequest(connection, number).setLabel("Java SDK live test")); + new CreateTwilioTelephonyBindingInput(connection, number).setLabel("Java SDK live test")); TelephonyBindingListItem listedBinding = findBinding(telephony, agentName, binding.getId()); - assertNotNull(listedBinding.getEtag()); + assertNotNull(listedBinding.getETag()); TelephonyBinding retrieved = telephony.getTelephonyBinding(agentName, binding.getId()); assertEquals(binding.getId(), retrieved.getId()); TelephonyBinding updated = telephony.updateTelephonyBinding(agentName, binding.getId(), - listedBinding.getEtag(), new UpdateTelephonyBindingRequest().setLabel("Updated Java SDK live test")); + listedBinding.getETag(), new UpdateTelephonyBindingInput().setLabel("Updated Java SDK live test")); assertEquals("Updated Java SDK live test", updated.getLabel()); - String updatedEtag = findBinding(telephony, agentName, binding.getId()).getEtag(); + String updatedEtag = findBinding(telephony, agentName, binding.getId()).getETag(); assertNotNull(updatedEtag); telephony.deleteTelephonyBinding(agentName, binding.getId(), updatedEtag); assertTrue(telephony.listTelephonyBindings(agentName) @@ -144,7 +144,7 @@ public void twilioBindingAndOutboundCallLive() throws InterruptedException { outboundAgentCreated = true; TelephonyBinding binding = telephony.createTelephonyBinding(inboundAgent, - new CreateTwilioTelephonyBindingRequest(connection1, number1).setLabel("Java SDK live test")); + new CreateTwilioTelephonyBindingInput(connection1, number1).setLabel("Java SDK live test")); assertNotNull(binding.getId()); assertEquals(TelephonyProvider.TWILIO, binding.getProvider()); assertEquals(TelephonyBindingStatus.ACTIVE, binding.getStatus()); @@ -162,7 +162,7 @@ public void twilioBindingAndOutboundCallLive() throws InterruptedException { Collections.singletonList(transferTarget)); assertEquals(1, replacedTargets.getTransferTargets().size()); - CreateTelephonyCallJobRequest request = new CreateTelephonyCallJobRequest( + CreateTelephonyCallJobInput request = new CreateTelephonyCallJobInput( new TelephonyOutboundDestination(TelephonyOutboundDestinationType.PHONE_NUMBER, number1), connection2, number2).setPurpose("Java SDK live telephony validation"); TelephonyCallJob job @@ -191,7 +191,7 @@ public void twilioBindingAndOutboundCallLive() throws InterruptedException { assertTrue(dispatchedJob.getAttemptCount() > 0, "The outbound call job did not create an attempt."); OffsetDateTime notBefore = OffsetDateTime.now().plusMinutes(10); - CreateTelephonyCallJobRequest scheduledRequest = new CreateTelephonyCallJobRequest( + CreateTelephonyCallJobInput scheduledRequest = new CreateTelephonyCallJobInput( new TelephonyOutboundDestination(TelephonyOutboundDestinationType.PHONE_NUMBER, number1), connection2, number2).setPurpose("Java SDK live cancellation validation") .setSchedule( @@ -256,8 +256,9 @@ private static VoiceAgentDefinition definition(String model, String instructions .setModel(model) .setInstructions(instructions) .setOutputModalities(Collections.singletonList(VoiceOutputModality.AUDIO)) - .setAudio(new VoiceAgentAudioConfig().setOutput( - new VoiceAgentAudioOutputConfig().setVoice("en-US-AvaNeural").setVoiceType(VoiceType.AZURE_STANDARD))); + .setAudio(new VoiceAgentAudioConfiguration() + .setOutput(new VoiceAgentAudioOutputConfiguration().setVoice("en-US-AvaNeural") + .setVoiceType(VoiceType.AZURE_STANDARD))); } private static String e164(Configuration configuration, String name, String defaultValue) { diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentTelephonyTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentTelephonyTests.java index 1beb59aed20ac..1505651aa71d7 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentTelephonyTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentTelephonyTests.java @@ -6,8 +6,8 @@ import com.azure.ai.agents.AgentsClientBuilder; import com.azure.ai.agents.BetaVoiceAgentsTelephonyAsyncClient; import com.azure.ai.agents.BetaVoiceAgentsTelephonyClient; -import com.azure.ai.agents.models.CreateTelephonyCallJobRequest; -import com.azure.ai.agents.models.CreateTwilioTelephonyBindingRequest; +import com.azure.ai.agents.models.CreateTelephonyCallJobInput; +import com.azure.ai.agents.models.CreateTwilioTelephonyBindingInput; import com.azure.ai.agents.models.TelephonyBinding; import com.azure.ai.agents.models.TelephonyBindingListItem; import com.azure.ai.agents.models.TelephonyBindingStatus; @@ -17,9 +17,8 @@ import com.azure.ai.agents.models.TelephonyCallSummary; import com.azure.ai.agents.models.TelephonyOutboundDestination; import com.azure.ai.agents.models.TelephonyOutboundDestinationType; -import com.azure.ai.agents.models.TelephonyOperation; import com.azure.ai.agents.models.TelephonyTransferTargets; -import com.azure.ai.agents.models.UpdateTelephonyBindingRequest; +import com.azure.ai.agents.models.UpdateTelephonyBindingInput; import com.azure.core.exception.HttpResponseException; import com.azure.core.exception.ResourceNotFoundException; import com.azure.core.http.HttpClient; @@ -90,9 +89,6 @@ public void createsTwilioBindingAndOutboundCallJob(boolean async) { + "\"created_at\":1,\"updated_at\":1}"; String cancelledJobResponse = jobResponse.replace("\"status\":\"accepted\"", "\"status\":\"cancelled\"") .replace("\"revision\":1", "\"revision\":2"); - String operationResponse = "{\"id\":\"operation-1\",\"object\":\"telephony.operation\"," - + "\"status\":\"succeeded\",\"created_at\":1," - + "\"resource\":{\"id\":\"job-1\",\"type\":\"telephony.call_job\"}}"; ScriptedTransport transport = new ScriptedTransport(async); transport.expect(HttpMethod.POST, ROOT + "/bindings", bindingRequest, 201, bindingResponse); transport.expect(HttpMethod.POST, ROOT + "/call_jobs", jobRequest, @@ -100,19 +96,18 @@ public void createsTwilioBindingAndOutboundCallJob(boolean async) { transport.expect(HttpMethod.GET, ROOT + "/call_jobs/job-1", null, 200, jobResponse); transport.expect(HttpMethod.POST, ROOT + "/call_jobs/job-1:cancel", null, header(HttpHeaderName.IF_MATCH, "1"), 200, cancelledJobResponse, new HttpHeaders()); - transport.expect(HttpMethod.GET, ROOT + "/operations/operation-1", null, 200, operationResponse); AgentsClientBuilder builder = builder(transport); BetaVoiceAgentsTelephonyClient syncClient = builder.beta().buildBetaVoiceAgentsTelephonyClient(); BetaVoiceAgentsTelephonyAsyncClient asyncClient = builder.beta().buildBetaVoiceAgentsTelephonyAsyncClient(); - CreateTwilioTelephonyBindingRequest bindingRequestModel - = new CreateTwilioTelephonyBindingRequest(CONNECTION_1, NUMBER_1).setLabel("Java SDK test"); + CreateTwilioTelephonyBindingInput bindingRequestModel + = new CreateTwilioTelephonyBindingInput(CONNECTION_1, NUMBER_1).setLabel("Java SDK test"); TelephonyBinding binding = call(async, () -> syncClient.createTelephonyBinding(AGENT, bindingRequestModel), () -> asyncClient.createTelephonyBinding(AGENT, bindingRequestModel)); assertEquals("binding-1", binding.getId()); assertEquals(TelephonyBindingStatus.ACTIVE, binding.getStatus()); - CreateTelephonyCallJobRequest jobRequestModel = new CreateTelephonyCallJobRequest( + CreateTelephonyCallJobInput jobRequestModel = new CreateTelephonyCallJobInput( new TelephonyOutboundDestination(TelephonyOutboundDestinationType.PHONE_NUMBER, NUMBER_1), CONNECTION_2, NUMBER_2).setPurpose("Java SDK telephony validation"); TelephonyCallJob job @@ -127,10 +122,6 @@ public void createsTwilioBindingAndOutboundCallJob(boolean async) { () -> asyncClient.cancelTelephonyCallJob(AGENT, "job-1", "1")); assertEquals(TelephonyCallJobStatus.CANCELLED, cancelled.getStatus()); assertEquals(2L, cancelled.getRevision()); - TelephonyOperation operation = call(async, () -> syncClient.getTelephonyOperation(AGENT, "operation-1"), - () -> asyncClient.getTelephonyOperation(AGENT, "operation-1")); - assertEquals("operation-1", operation.getId()); - assertEquals("job-1", operation.getResource().getId()); transport.assertComplete(); } @@ -174,10 +165,9 @@ public void bindingTransferTargetAndCallLifecycle(boolean async) { ? asyncClient.listTelephonyBindings(AGENT).blockFirst(TIMEOUT) : syncClient.listTelephonyBindings(AGENT).iterator().next(); assertNotNull(listed); - assertEquals("binding-etag", listed.getEtag()); - UpdateTelephonyBindingRequest update - = new UpdateTelephonyBindingRequest().setStatus(TelephonyBindingStatus.ACTIVE) - .setLabel("Updated Java SDK test"); + assertEquals("binding-etag", listed.getETag()); + UpdateTelephonyBindingInput update = new UpdateTelephonyBindingInput().setStatus(TelephonyBindingStatus.ACTIVE) + .setLabel("Updated Java SDK test"); assertEquals("Updated Java SDK test", call(async, () -> syncClient.updateTelephonyBinding(AGENT, "binding-1", "*", update), () -> asyncClient.updateTelephonyBinding(AGENT, "binding-1", "*", update)).getLabel()); @@ -248,8 +238,8 @@ public void bindingsAndTransferTargets(boolean async) { .isEmpty()); assertNotFound(() -> call(async, () -> syncClient.getTelephonyBinding(AGENT, MISSING), () -> asyncClient.getTelephonyBinding(AGENT, MISSING)), true); - UpdateTelephonyBindingRequest update - = new UpdateTelephonyBindingRequest().setStatus(TelephonyBindingStatus.SUSPENDED); + UpdateTelephonyBindingInput update + = new UpdateTelephonyBindingInput().setStatus(TelephonyBindingStatus.SUSPENDED); assertNotFound(() -> call(async, () -> syncClient.updateTelephonyBinding(AGENT, MISSING, null, update), () -> asyncClient.updateTelephonyBinding(AGENT, MISSING, null, update)), true); assertNotFound(() -> call(async, () -> { @@ -328,20 +318,6 @@ public void callJobNotFound(boolean async) { transport.assertComplete(); } - @ParameterizedTest - @ValueSource(booleans = { false, true }) - public void operationNotFound(boolean async) { - ScriptedTransport transport = new ScriptedTransport(async); - transport.notFound(HttpMethod.GET, ROOT + "/operations/" + MISSING, null); - AgentsClientBuilder builder = builder(transport); - assertNotFound( - () -> call(async, - () -> builder.beta().buildBetaVoiceAgentsTelephonyClient().getTelephonyOperation(AGENT, MISSING), - () -> builder.beta().buildBetaVoiceAgentsTelephonyAsyncClient().getTelephonyOperation(AGENT, MISSING)), - true); - transport.assertComplete(); - } - private static void assertTargets(TelephonyTransferTargets targets) { assertNotNull(targets); assertEquals(1, targets.getTransferTargets().size()); diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentWebSocketSessionTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentWebSocketSessionTests.java index a24fd7e4edf96..a60e7b5ec3612 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentWebSocketSessionTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentWebSocketSessionTests.java @@ -10,10 +10,10 @@ import com.azure.ai.agents.VoiceAgentWebSocketSessionClient; import com.azure.ai.agents.implementation.realtime.VoiceAgentWebSocketHttpResponse; import com.azure.ai.agents.models.RawRealtimeServerEvent; -import com.azure.ai.agents.models.RealtimeClientEventResponseCreate; -import com.azure.ai.agents.models.RealtimeServerEventSessionCreated; +import com.azure.ai.agents.models.RealtimeResponseCreateEvent; +import com.azure.ai.agents.models.RealtimeSessionCreatedEvent; import com.azure.ai.agents.models.RealtimeServerEvent; -import com.azure.ai.agents.models.VoiceAgentServerEventWarning; +import com.azure.ai.agents.models.VoiceAgentWarningEvent; import com.azure.ai.agents.models.VoiceAgentTransport; import com.azure.ai.agents.models.VoiceAgentWebSocketConnectionOptions; import com.azure.ai.agents.models.VoiceAgentWebSocketOverflowStrategy; @@ -167,7 +167,7 @@ public void typedStringAndMappingSendsRejectInvalidJson(boolean async) { StepVerifier.create(session.sendEvent(BinaryData.fromString("not valid json"))) .expectError(IllegalArgumentException.class) .verify(Duration.ofSeconds(5)); - session.sendEvent(new RealtimeClientEventResponseCreate()) + session.sendEvent(new RealtimeResponseCreateEvent()) .then(session.sendEvent(BinaryData.fromString(raw))) .then(session.sendEvent(mapping)) .block(Duration.ofSeconds(5)); @@ -180,7 +180,7 @@ public void typedStringAndMappingSendsRejectInvalidJson(boolean async) { = builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent", tlsOptions())) { assertThrows(IllegalArgumentException.class, () -> session.sendEvent(BinaryData.fromString("not valid json"))); - session.sendEvent(new RealtimeClientEventResponseCreate()); + session.sendEvent(new RealtimeResponseCreateEvent()); session.sendEvent(BinaryData.fromString(raw)); session.sendEvent(mapping); Iterator events = session.receiveEvents(Duration.ofSeconds(5)).iterator(); @@ -229,7 +229,7 @@ public void pingPongFramesAreNotApplicationEvents(boolean async) { } } assertEquals(2, events.size()); - assertInstanceOf(RealtimeServerEventSessionCreated.class, events.get(0)); + assertInstanceOf(RealtimeSessionCreatedEvent.class, events.get(0)); RawRealtimeServerEvent unknown = assertInstanceOf(RawRealtimeServerEvent.class, events.get(1)); assertEquals("bar", unknown.getRawEvent().toObject(Map.class).get("foo")); } @@ -1012,7 +1012,7 @@ private DisposableServer startServer(List clientMessages, AtomicReferenc } private void assertWarningEvent(RealtimeServerEvent event) { - VoiceAgentServerEventWarning warning = assertInstanceOf(VoiceAgentServerEventWarning.class, event); + VoiceAgentWarningEvent warning = assertInstanceOf(VoiceAgentWarningEvent.class, event); assertEquals("loopback warning", warning.getWarning().getMessage()); assertEquals("test_warning", warning.getWarning().getCode()); } From 23b9628f98eb0f21e6c316e9e921dc5adfcd1101 Mon Sep 17 00:00:00 2001 From: Mike Guo Date: Fri, 18 Sep 2026 09:00:55 +0800 Subject: [PATCH 17/25] Address voice client review feedback --- sdk/ai/azure-ai-agents/README.md | 8 +- .../BetaVoiceAgentWebSocketAsyncClient.java | 8 +- .../agents/BetaVoiceAgentWebSocketClient.java | 7 +- ...oiceAgentWebSocketSessionAsyncClient.java} | 8 +- ...BetaVoiceAgentWebSocketSessionClient.java} | 12 +- .../implementation/http/HttpClientHelper.java | 4 +- .../com/azure/ai/agents/ReadmeSamples.java | 2 +- ...AgentLiveAudioConversationAsyncSample.java | 14 +- .../VoiceAgentLiveFunctionToolSample.java | 6 +- ...eAgentLiveTextConversationAsyncSample.java | 10 +- .../VoiceAgentLiveTextConversationSample.java | 6 +- .../VoiceAgentConversationsAsyncTests.java | 6 +- .../voice/VoiceAgentConversationsTests.java | 4 +- .../voice/VoiceAgentRealtimeLiveTests.java | 10 +- .../VoiceAgentWebSocketSessionTests.java | 148 +++++++++++------- .../resources/websocket-localhost-cert.pem | 19 --- .../resources/websocket-localhost-key.pem | 28 ---- sdk/ai/azure-ai-projects/CHANGELOG.md | 2 +- sdk/ai/azure-ai-projects/README.md | 4 +- .../src/main/java/ProjectsCustomizations.java | 6 +- .../ai/projects/AIProjectClientBuilder.java | 12 +- ...ent.java => BetaTelemetryAsyncClient.java} | 10 +- ...ryClient.java => BetaTelemetryClient.java} | 10 +- ...Test.java => BetaTelemetryClientTest.java} | 6 +- 24 files changed, 176 insertions(+), 174 deletions(-) rename sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/{VoiceAgentWebSocketSessionAsyncClient.java => BetaVoiceAgentWebSocketSessionAsyncClient.java} (98%) rename sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/{VoiceAgentWebSocketSessionClient.java => BetaVoiceAgentWebSocketSessionClient.java} (97%) delete mode 100644 sdk/ai/azure-ai-agents/src/test/resources/websocket-localhost-cert.pem delete mode 100644 sdk/ai/azure-ai-agents/src/test/resources/websocket-localhost-key.pem rename sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/{TelemetryAsyncClient.java => BetaTelemetryAsyncClient.java} (88%) rename sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/{TelemetryClient.java => BetaTelemetryClient.java} (90%) rename sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/{TelemetryClientTest.java => BetaTelemetryClientTest.java} (95%) diff --git a/sdk/ai/azure-ai-agents/README.md b/sdk/ai/azure-ai-agents/README.md index 5cdd44075be8f..a05ca9f3c724e 100644 --- a/sdk/ai/azure-ai-agents/README.md +++ b/sdk/ai/azure-ai-agents/README.md @@ -1051,7 +1051,7 @@ VoiceAgentWebSocketConnectionOptions options .setReceiveBufferCapacity(512) .setMaxMessageSize(8 * 1024 * 1024) .setOverflowStrategy(VoiceAgentWebSocketOverflowStrategy.ERROR); -try (VoiceAgentWebSocketSessionClient session = realtimeClient.connect(agentName, options)) { +try (BetaVoiceAgentWebSocketSessionClient session = realtimeClient.connect(agentName, options)) { session.sendEvent(BinaryData.fromString( "{\"type\":\"response.create\",\"event_id\":\"response-1\"}")); for (RealtimeServerEvent event : session.receiveEvents()) { @@ -1072,7 +1072,7 @@ Use `close(code, reason)` or asynchronous `closeAsync(code, reason)` to send a c fit in 123 UTF-8 bytes and close codes must be valid WebSocket codes. The first asynchronous close request wins. ```java -try (VoiceAgentWebSocketSessionClient session = realtimeClient.connect(agentName)) { +try (BetaVoiceAgentWebSocketSessionClient session = realtimeClient.connect(agentName)) { session.sendText("Hello! Tell me about the services you provide."); session.createResponse(); @@ -1109,9 +1109,9 @@ Mono.usingWhen( }) .takeUntil(event -> event instanceof RealtimeServerEventResponseDone) .then(), - VoiceAgentWebSocketSessionAsyncClient::closeAsync, + BetaVoiceAgentWebSocketSessionAsyncClient::closeAsync, (session, error) -> session.closeAsync(), - VoiceAgentWebSocketSessionAsyncClient::closeAsync) + BetaVoiceAgentWebSocketSessionAsyncClient::closeAsync) .block(); ``` diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketAsyncClient.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketAsyncClient.java index 54674c48b1fe6..7b6dccf82165c 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketAsyncClient.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketAsyncClient.java @@ -29,7 +29,7 @@ public final class BetaVoiceAgentWebSocketAsyncClient { * @param agentName the voice agent name. * @return a connected session. */ - public Mono connect(String agentName) { + public Mono connect(String agentName) { return connect(agentName, new VoiceAgentWebSocketConnectionOptions()); } @@ -40,13 +40,13 @@ public Mono connect(String agentName) { * @param options connection options. * @return a connected session. */ - public Mono connect(String agentName, + public Mono connect(String agentName, VoiceAgentWebSocketConnectionOptions options) { Objects.requireNonNull(agentName, "'agentName' cannot be null."); Objects.requireNonNull(options, "'options' cannot be null."); return Mono.defer(() -> { - VoiceAgentWebSocketSessionAsyncClient session - = new VoiceAgentWebSocketSessionAsyncClient(configuration, agentName, options); + BetaVoiceAgentWebSocketSessionAsyncClient session + = new BetaVoiceAgentWebSocketSessionAsyncClient(configuration, agentName, options); return session.connect().thenReturn(session); }); } diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketClient.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketClient.java index 7d402ac1a551c..2b10fa0e97c92 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketClient.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketClient.java @@ -30,7 +30,7 @@ public final class BetaVoiceAgentWebSocketClient { * @param agentName the voice agent name. * @return a connected session. */ - public VoiceAgentWebSocketSessionClient connect(String agentName) { + public BetaVoiceAgentWebSocketSessionClient connect(String agentName) { return connect(agentName, new VoiceAgentWebSocketConnectionOptions()); } @@ -42,12 +42,13 @@ public VoiceAgentWebSocketSessionClient connect(String agentName) { * @throws IllegalArgumentException if {@code agentName} is empty. * @return a connected session. */ - public VoiceAgentWebSocketSessionClient connect(String agentName, VoiceAgentWebSocketConnectionOptions options) { + public BetaVoiceAgentWebSocketSessionClient connect(String agentName, + VoiceAgentWebSocketConnectionOptions options) { Objects.requireNonNull(agentName, "'agentName' cannot be null."); if (agentName.isEmpty()) { throw LOGGER.logExceptionAsError(new IllegalArgumentException("'agentName' cannot be empty.")); } Objects.requireNonNull(options, "'options' cannot be null."); - return VoiceAgentWebSocketSessionClient.connect(configuration, agentName, options); + return BetaVoiceAgentWebSocketSessionClient.connect(configuration, agentName, options); } } diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketSessionAsyncClient.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketSessionAsyncClient.java similarity index 98% rename from sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketSessionAsyncClient.java rename to sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketSessionAsyncClient.java index 03174d9ca722d..e9170ef501e86 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketSessionAsyncClient.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketSessionAsyncClient.java @@ -68,8 +68,8 @@ * subscriber to {@link #receiveEvents()}. Close the session when it is no longer needed.

*/ @Beta(warningText = "This class is in preview and may change in future releases.") -public final class VoiceAgentWebSocketSessionAsyncClient implements AsyncCloseable, AutoCloseable { - private static final ClientLogger LOGGER = new ClientLogger(VoiceAgentWebSocketSessionAsyncClient.class); +public final class BetaVoiceAgentWebSocketSessionAsyncClient implements AsyncCloseable, AutoCloseable { + private static final ClientLogger LOGGER = new ClientLogger(BetaVoiceAgentWebSocketSessionAsyncClient.class); private static final int MAX_OUTSTANDING_SENDS = 256; private final VoiceAgentWebSocketClientConfiguration configuration; @@ -92,12 +92,12 @@ public final class VoiceAgentWebSocketSessionAsyncClient implements AsyncCloseab private volatile Integer closeCode; private volatile String closeReason; - VoiceAgentWebSocketSessionAsyncClient(VoiceAgentWebSocketClientConfiguration configuration, String agentName, + BetaVoiceAgentWebSocketSessionAsyncClient(VoiceAgentWebSocketClientConfiguration configuration, String agentName, VoiceAgentWebSocketConnectionOptions options) { this(configuration, agentName, options, HttpClient.create()); } - VoiceAgentWebSocketSessionAsyncClient(VoiceAgentWebSocketClientConfiguration configuration, String agentName, + BetaVoiceAgentWebSocketSessionAsyncClient(VoiceAgentWebSocketClientConfiguration configuration, String agentName, VoiceAgentWebSocketConnectionOptions options, HttpClient httpClient) { this.configuration = Objects.requireNonNull(configuration, "'configuration' cannot be null."); Objects.requireNonNull(agentName, "'agentName' cannot be null."); diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketSessionClient.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketSessionClient.java similarity index 97% rename from sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketSessionClient.java rename to sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketSessionClient.java index 2ce56b6e90621..be800bda66c63 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketSessionClient.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketSessionClient.java @@ -59,8 +59,8 @@ * A synchronous bidirectional realtime session connected to a Foundry voice agent. */ @Beta(warningText = "This class is in preview and may change in future releases.") -public final class VoiceAgentWebSocketSessionClient implements AutoCloseable { - private static final ClientLogger LOGGER = new ClientLogger(VoiceAgentWebSocketSessionClient.class); +public final class BetaVoiceAgentWebSocketSessionClient implements AutoCloseable { + private static final ClientLogger LOGGER = new ClientLogger(BetaVoiceAgentWebSocketSessionClient.class); private final URI websocketUri; private final VoiceAgentWebSocketConnectionOptions options; private final OkHttpClient httpClient; @@ -79,7 +79,7 @@ public final class VoiceAgentWebSocketSessionClient implements AutoCloseable { private volatile Integer closeCode; private volatile String closeReason; - private VoiceAgentWebSocketSessionClient(VoiceAgentWebSocketClientConfiguration configuration, String agentName, + private BetaVoiceAgentWebSocketSessionClient(VoiceAgentWebSocketClientConfiguration configuration, String agentName, VoiceAgentWebSocketConnectionOptions options) { this.options = options; this.receiveBufferCapacity = options.getReceiveBufferCapacity(); @@ -97,10 +97,10 @@ private VoiceAgentWebSocketSessionClient(VoiceAgentWebSocketClientConfiguration this.webSocket = httpClient.newWebSocket(request.build(), new Listener()); } - static VoiceAgentWebSocketSessionClient connect(VoiceAgentWebSocketClientConfiguration configuration, + static BetaVoiceAgentWebSocketSessionClient connect(VoiceAgentWebSocketClientConfiguration configuration, String agentName, VoiceAgentWebSocketConnectionOptions options) { - VoiceAgentWebSocketSessionClient session - = new VoiceAgentWebSocketSessionClient(configuration, agentName, options); + BetaVoiceAgentWebSocketSessionClient session + = new BetaVoiceAgentWebSocketSessionClient(configuration, agentName, options); try { session.awaitHandshake(); return session; 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 e8ec10052349b..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 @@ -54,7 +54,9 @@ private HttpClientHelper() { } /** - * Creates a logging policy that never logs multipart upload bodies. + * 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. */ diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/ReadmeSamples.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/ReadmeSamples.java index 122d282aa4e52..61a458472bcf0 100644 --- a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/ReadmeSamples.java +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/ReadmeSamples.java @@ -40,7 +40,7 @@ public void realtimeForwardCompatibility(BetaVoiceAgentWebSocketClient realtimeC .setReceiveBufferCapacity(512) .setMaxMessageSize(8 * 1024 * 1024) .setOverflowStrategy(VoiceAgentWebSocketOverflowStrategy.ERROR); - try (VoiceAgentWebSocketSessionClient session = realtimeClient.connect(agentName, options)) { + try (BetaVoiceAgentWebSocketSessionClient session = realtimeClient.connect(agentName, options)) { session.sendEvent(BinaryData.fromString( "{\"type\":\"response.create\",\"event_id\":\"response-1\"}")); for (RealtimeServerEvent event : session.receiveEvents()) { diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSample.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSample.java index 7e510fa1cfe83..ae7e5242ff1e1 100644 --- a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSample.java +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSample.java @@ -8,7 +8,7 @@ import com.azure.ai.agents.BetaVoiceAgentsConversationsAsyncClient; import com.azure.ai.agents.BetaAgentsAsyncClient; import com.azure.ai.agents.BetaVoiceAgentWebSocketAsyncClient; -import com.azure.ai.agents.VoiceAgentWebSocketSessionAsyncClient; +import com.azure.ai.agents.BetaVoiceAgentWebSocketSessionAsyncClient; import com.azure.ai.agents.models.CreateAgentVersionInput; import com.azure.ai.agents.models.RealtimeConversationItemInputAudioTranscriptionCompletedEvent; import com.azure.ai.agents.models.RealtimeInputAudioBufferSpeechStartedEvent; @@ -99,9 +99,9 @@ public static void main(String[] args) { }) .then(Mono.usingWhen(realtime.connect(agentName), session -> runConversation(session, conversationId), - VoiceAgentWebSocketSessionAsyncClient::closeAsync, + BetaVoiceAgentWebSocketSessionAsyncClient::closeAsync, (session, error) -> session.closeAsync(), - VoiceAgentWebSocketSessionAsyncClient::closeAsync)) + BetaVoiceAgentWebSocketSessionAsyncClient::closeAsync)) .then(Mono.defer(() -> conversationId.get() == null ? Mono.fromRunnable(() -> System.out.println("No persisted conversation ID was returned.")) : VoiceAgentRealtimeSampleUtils.readConversation(conversations, agentName, conversationId.get()))) @@ -120,7 +120,7 @@ private static Mono cleanupAgent(AgentsAsyncClient agents, String agentNam .doOnSuccess(ignored -> System.out.println("Deleted voice agent: " + agentName)); } - private static Mono runConversation(VoiceAgentWebSocketSessionAsyncClient session, + private static Mono runConversation(BetaVoiceAgentWebSocketSessionAsyncClient session, AtomicReference conversationId) { AudioProcessor processor = new AudioProcessor(session); AtomicBoolean responseActive = new AtomicBoolean(); @@ -195,7 +195,7 @@ static final class AudioProcessor implements AutoCloseable { private static final int CHUNK_BYTES = 2400; static final int MAX_PLAYBACK_BYTES = VoiceAgentRealtimeSampleUtils.SAMPLE_RATE * 2 * 60; private static final byte[] STOP = new byte[0]; - private final VoiceAgentWebSocketSessionAsyncClient session; + private final BetaVoiceAgentWebSocketSessionAsyncClient session; private final AudioFormat format = new AudioFormat(VoiceAgentRealtimeSampleUtils.SAMPLE_RATE, 16, 1, true, false); private final BlockingQueue playback = new LinkedBlockingQueue<>(MAX_PLAYBACK_BYTES / 2); private int queuedPlaybackBytes; @@ -207,11 +207,11 @@ static final class AudioProcessor implements AutoCloseable { private Thread captureThread; private Thread playbackThread; - AudioProcessor(VoiceAgentWebSocketSessionAsyncClient session) { + AudioProcessor(BetaVoiceAgentWebSocketSessionAsyncClient session) { this(session, null, null); } - AudioProcessor(VoiceAgentWebSocketSessionAsyncClient session, TargetDataLine microphone, SourceDataLine speaker) { + AudioProcessor(BetaVoiceAgentWebSocketSessionAsyncClient session, TargetDataLine microphone, SourceDataLine speaker) { this.session = session; this.microphone = microphone; this.speaker = speaker; diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveFunctionToolSample.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveFunctionToolSample.java index a7b1bb8a2b61a..02150e962b15f 100644 --- a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveFunctionToolSample.java +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveFunctionToolSample.java @@ -6,7 +6,7 @@ import com.azure.ai.agents.AgentsClient; import com.azure.ai.agents.AgentsClientBuilder; import com.azure.ai.agents.BetaVoiceAgentWebSocketClient; -import com.azure.ai.agents.VoiceAgentWebSocketSessionClient; +import com.azure.ai.agents.BetaVoiceAgentWebSocketSessionClient; import com.azure.ai.agents.models.CreateAgentVersionInput; import com.azure.ai.agents.models.RealtimeConversationItem; import com.azure.ai.agents.models.RealtimeConversationItemType; @@ -89,7 +89,7 @@ public static void main(String[] args) { try { agents.createAgentVersion(agentName, new CreateAgentVersionInput(definition)); System.out.println("Created voice agent: " + agentName); - try (VoiceAgentWebSocketSessionClient session = realtime.connect(agentName)) { + try (BetaVoiceAgentWebSocketSessionClient session = realtime.connect(agentName)) { ExecutorService receiver = Executors.newSingleThreadExecutor(); Future response = receiver.submit(() -> receiveResponse(session)); try { @@ -114,7 +114,7 @@ public static void main(String[] args) { } } - private static void receiveResponse(VoiceAgentWebSocketSessionClient session) { + private static void receiveResponse(BetaVoiceAgentWebSocketSessionClient session) { for (RealtimeServerEvent event : session.receiveEvents()) { if (event instanceof RealtimeResponseFunctionCallArgumentsDoneEvent) { RealtimeResponseFunctionCallArgumentsDoneEvent call diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveTextConversationAsyncSample.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveTextConversationAsyncSample.java index 9e56761dac38b..762acf65eb728 100644 --- a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveTextConversationAsyncSample.java +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveTextConversationAsyncSample.java @@ -8,7 +8,7 @@ import com.azure.ai.agents.BetaVoiceAgentsConversationsAsyncClient; import com.azure.ai.agents.BetaAgentsAsyncClient; import com.azure.ai.agents.BetaVoiceAgentWebSocketAsyncClient; -import com.azure.ai.agents.VoiceAgentWebSocketSessionAsyncClient; +import com.azure.ai.agents.BetaVoiceAgentWebSocketSessionAsyncClient; import com.azure.ai.agents.models.CreateAgentVersionInput; import com.azure.ai.agents.models.VoiceAgentDefinition; import com.azure.core.util.BinaryData; @@ -80,9 +80,9 @@ public static void main(String[] args) { }) .then(Mono.usingWhen(realtime.connect(agentName), session -> runConversation(session, scanner, conversationId, player), - VoiceAgentWebSocketSessionAsyncClient::closeAsync, + BetaVoiceAgentWebSocketSessionAsyncClient::closeAsync, (session, error) -> session.closeAsync(), - VoiceAgentWebSocketSessionAsyncClient::closeAsync)) + BetaVoiceAgentWebSocketSessionAsyncClient::closeAsync)) .then(Mono.defer(() -> conversationId.get() == null ? Mono.fromRunnable(() -> System.out.println("No persisted conversation ID was returned.")) : VoiceAgentRealtimeSampleUtils.readConversation(conversations, agentName, conversationId.get()))) @@ -105,7 +105,7 @@ private static Mono cleanupAgent(AgentsAsyncClient agents, String agentNam .doOnSuccess(ignored -> System.out.println("Deleted voice agent: " + agentName)); } - private static Mono runConversation(VoiceAgentWebSocketSessionAsyncClient session, Scanner scanner, + private static Mono runConversation(BetaVoiceAgentWebSocketSessionAsyncClient session, Scanner scanner, AtomicReference conversationId, VoiceAgentRealtimeSampleUtils.SpeakerPlayer player) { AtomicReference> responseCompleted = new AtomicReference<>(); Disposable receiver = session.receiveEvents().subscribe(event -> { @@ -131,7 +131,7 @@ private static Mono runConversation(VoiceAgentWebSocketSessionAsyncClient }); } - private static Mono prompt(VoiceAgentWebSocketSessionAsyncClient session, Scanner scanner, + private static Mono prompt(BetaVoiceAgentWebSocketSessionAsyncClient session, Scanner scanner, AtomicReference> responseCompleted) { return Mono.fromCallable(() -> { System.out.print("You: "); diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveTextConversationSample.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveTextConversationSample.java index ba8b1c35b964b..fe76519a70f8d 100644 --- a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveTextConversationSample.java +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveTextConversationSample.java @@ -8,7 +8,7 @@ import com.azure.ai.agents.BetaVoiceAgentsConversationsClient; import com.azure.ai.agents.BetaAgentsClient; import com.azure.ai.agents.BetaVoiceAgentWebSocketClient; -import com.azure.ai.agents.VoiceAgentWebSocketSessionClient; +import com.azure.ai.agents.BetaVoiceAgentWebSocketSessionClient; import com.azure.ai.agents.models.AgentDetails; import com.azure.ai.agents.models.AgentVersionDetails; import com.azure.ai.agents.models.CreateAgentVersionInput; @@ -72,7 +72,7 @@ public static void main(String[] args) { AtomicReference conversationId = new AtomicReference<>(); try (VoiceAgentRealtimeSampleUtils.SpeakerPlayer player = new VoiceAgentRealtimeSampleUtils.SpeakerPlayer(); - VoiceAgentWebSocketSessionClient session = realtime.connect(agentName); + BetaVoiceAgentWebSocketSessionClient session = realtime.connect(agentName); Scanner scanner = new Scanner(System.in)) { AtomicReference> responseCompleted = new AtomicReference<>(); ExecutorService receiver = Executors.newSingleThreadExecutor(); @@ -130,7 +130,7 @@ public static void main(String[] args) { } } - private static boolean awaitResponse(VoiceAgentWebSocketSessionClient session, + private static boolean awaitResponse(BetaVoiceAgentWebSocketSessionClient session, CompletableFuture completion) { try { completion.get(RESPONSE_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsAsyncTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsAsyncTests.java index 020fa1cdd2023..dd0016d65b7dd 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsAsyncTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsAsyncTests.java @@ -7,7 +7,7 @@ import com.azure.ai.agents.AgentsAsyncClient; import com.azure.ai.agents.AgentsClientBuilder; import com.azure.ai.agents.BetaVoiceAgentsConversationsAsyncClient; -import com.azure.ai.agents.VoiceAgentWebSocketSessionAsyncClient; +import com.azure.ai.agents.BetaVoiceAgentWebSocketSessionAsyncClient; import com.azure.ai.agents.models.CreateAgentVersionInput; import com.azure.ai.agents.models.RealtimeResponseDoneEvent; import com.azure.ai.agents.models.RealtimeSessionCreatedEvent; @@ -123,8 +123,8 @@ public void readLivePersistedConversation() { .switchIfEmpty(Mono.error(new AssertionError("Session ended without response.done."))) .timeout(Duration.ofSeconds(45)) .then(), - VoiceAgentWebSocketSessionAsyncClient::closeAsync, (session, error) -> session.closeAsync(), - VoiceAgentWebSocketSessionAsyncClient::closeAsync).block(Duration.ofSeconds(90)); + BetaVoiceAgentWebSocketSessionAsyncClient::closeAsync, (session, error) -> session.closeAsync(), + BetaVoiceAgentWebSocketSessionAsyncClient::closeAsync).block(Duration.ofSeconds(90)); Mono.delay(Duration.ofSeconds(30)).block(Duration.ofSeconds(35)); reading = true; assertPersistedConversation(conversations, agentName, conversationId.get()); diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsTests.java index cdc7ab6a70a4b..126c53ec31cc6 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsTests.java @@ -6,7 +6,7 @@ import com.azure.ai.agents.AgentsClient; import com.azure.ai.agents.AgentsClientBuilder; import com.azure.ai.agents.BetaVoiceAgentsConversationsClient; -import com.azure.ai.agents.VoiceAgentWebSocketSessionClient; +import com.azure.ai.agents.BetaVoiceAgentWebSocketSessionClient; import com.azure.ai.agents.models.CreateAgentVersionInput; import com.azure.ai.agents.models.RealtimeServerEvent; import com.azure.ai.agents.models.RealtimeResponseDoneEvent; @@ -107,7 +107,7 @@ public void readLivePersistedConversation() throws InterruptedException { try { agents.createAgentVersion(agentName, new CreateAgentVersionInput(definition)); created = true; - try (VoiceAgentWebSocketSessionClient session + try (BetaVoiceAgentWebSocketSessionClient session = builder.beta().buildBetaVoiceAgentWebSocketClient().connect(agentName)) { Iterator events = session.receiveEvents(TIMEOUT).iterator(); assertTrue(events.hasNext(), "Expected session.created."); diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentRealtimeLiveTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentRealtimeLiveTests.java index fbcfc28eeac09..2e9c42f09a4a7 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentRealtimeLiveTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentRealtimeLiveTests.java @@ -6,8 +6,8 @@ import com.azure.ai.agents.AgentsAsyncClient; import com.azure.ai.agents.AgentsClient; import com.azure.ai.agents.AgentsClientBuilder; -import com.azure.ai.agents.VoiceAgentWebSocketSessionAsyncClient; -import com.azure.ai.agents.VoiceAgentWebSocketSessionClient; +import com.azure.ai.agents.BetaVoiceAgentWebSocketSessionAsyncClient; +import com.azure.ai.agents.BetaVoiceAgentWebSocketSessionClient; import com.azure.ai.agents.models.CreateAgentVersionInput; import com.azure.ai.agents.models.RealtimeClientEvent; import com.azure.ai.agents.models.RealtimeConversationItemCreateEvent; @@ -87,7 +87,7 @@ public void realtimeLive(Scenario scenario) { try { agents.createAgentVersion(agentName, new CreateAgentVersionInput(definition(scenario))); created = true; - try (VoiceAgentWebSocketSessionClient session + try (BetaVoiceAgentWebSocketSessionClient session = builder.beta().buildBetaVoiceAgentWebSocketClient().connect(agentName)) { Turn turn = new Turn(scenario); Iterator events = session.receiveEvents(EVENT_TIMEOUT).iterator(); @@ -130,8 +130,8 @@ public void realtimeLiveAsync(Scenario scenario) { .timeout(RESPONSE_TIMEOUT) .doOnNext(ignored -> turn.assertComplete()) .then(), - VoiceAgentWebSocketSessionAsyncClient::closeAsync, (session, error) -> session.closeAsync(), - VoiceAgentWebSocketSessionAsyncClient::closeAsync).block(Duration.ofSeconds(90)); + BetaVoiceAgentWebSocketSessionAsyncClient::closeAsync, (session, error) -> session.closeAsync(), + BetaVoiceAgentWebSocketSessionAsyncClient::closeAsync).block(Duration.ofSeconds(90)); } finally { if (created) { agents.deleteAgent(agentName).block(EVENT_TIMEOUT); diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentWebSocketSessionTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentWebSocketSessionTests.java index a60e7b5ec3612..402e4f0a7cb2d 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentWebSocketSessionTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentWebSocketSessionTests.java @@ -6,8 +6,8 @@ import com.azure.ai.agents.AgentsClientBuilder; import com.azure.ai.agents.BetaVoiceAgentWebSocketAsyncClient; import com.azure.ai.agents.BetaVoiceAgentWebSocketClient; -import com.azure.ai.agents.VoiceAgentWebSocketSessionAsyncClient; -import com.azure.ai.agents.VoiceAgentWebSocketSessionClient; +import com.azure.ai.agents.BetaVoiceAgentWebSocketSessionAsyncClient; +import com.azure.ai.agents.BetaVoiceAgentWebSocketSessionClient; import com.azure.ai.agents.implementation.realtime.VoiceAgentWebSocketHttpResponse; import com.azure.ai.agents.models.RawRealtimeServerEvent; import com.azure.ai.agents.models.RealtimeResponseCreateEvent; @@ -35,16 +35,15 @@ import io.netty.handler.codec.http.websocketx.PongWebSocketFrame; import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; import io.netty.handler.codec.http.websocketx.WebSocketFrame; -import java.io.File; import java.io.InputStream; import java.net.URI; -import java.net.URL; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.Paths; import java.security.KeyStore; -import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; import java.time.Duration; import java.time.OffsetDateTime; import java.util.ArrayList; @@ -53,15 +52,18 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; +import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -83,6 +85,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; public class VoiceAgentWebSocketSessionTests { + private static final TestCertificate TLS_CERTIFICATE = TestCertificate.create(); + private DisposableServer server; @ParameterizedTest @@ -113,14 +117,14 @@ public void handshakeOverridesPreserveQueryAndSingleUserAgent(boolean async) { } options.setExtraHeaders(extra); if (async) { - VoiceAgentWebSocketSessionAsyncClient session = builder.beta() + BetaVoiceAgentWebSocketSessionAsyncClient session = builder.beta() .buildBetaVoiceAgentWebSocketAsyncClient() .connect("agent name", options) .block(Duration.ofSeconds(5)); session.closeAsync().block(Duration.ofSeconds(5)); assertFalse(session.isOpen()); } else { - VoiceAgentWebSocketSessionClient session + BetaVoiceAgentWebSocketSessionClient session = builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent name", options); session.close(); assertFalse(session.isOpen()); @@ -159,7 +163,7 @@ public void typedStringAndMappingSendsRejectInvalidJson(boolean async) { String raw = "{\"type\": \"response.create\"}"; BinaryData mapping = BinaryData.fromObject(Collections.singletonMap("type", "response.cancel")); if (async) { - VoiceAgentWebSocketSessionAsyncClient session = builder.beta() + BetaVoiceAgentWebSocketSessionAsyncClient session = builder.beta() .buildBetaVoiceAgentWebSocketAsyncClient() .connect("agent", tlsOptions()) .block(Duration.ofSeconds(5)); @@ -176,7 +180,7 @@ public void typedStringAndMappingSendsRejectInvalidJson(boolean async) { session.close(); } } else { - try (VoiceAgentWebSocketSessionClient session + try (BetaVoiceAgentWebSocketSessionClient session = builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent", tlsOptions())) { assertThrows(IllegalArgumentException.class, () -> session.sendEvent(BinaryData.fromString("not valid json"))); @@ -211,7 +215,7 @@ public void pingPongFramesAreNotApplicationEvents(boolean async) { .credential(request -> Mono.just(new AccessToken("test-token", OffsetDateTime.now().plusHours(1)))); List events = new ArrayList<>(); if (async) { - VoiceAgentWebSocketSessionAsyncClient session = builder.beta() + BetaVoiceAgentWebSocketSessionAsyncClient session = builder.beta() .buildBetaVoiceAgentWebSocketAsyncClient() .connect("agent", tlsOptions()) .block(Duration.ofSeconds(5)); @@ -221,7 +225,7 @@ public void pingPongFramesAreNotApplicationEvents(boolean async) { session.close(); } } else { - try (VoiceAgentWebSocketSessionClient session + try (BetaVoiceAgentWebSocketSessionClient session = builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent", tlsOptions())) { Iterator iterator = session.receiveEvents(Duration.ofSeconds(5)).iterator(); events.add(iterator.next()); @@ -266,7 +270,7 @@ public void malformedEventsCanBeReportedAndSkipped(boolean async) { AgentsClientBuilder builder = new AgentsClientBuilder().endpoint("https://localhost:" + server.port()) .credential(request -> Mono.just(new AccessToken("test-token", OffsetDateTime.now().plusHours(1)))); if (async) { - VoiceAgentWebSocketSessionAsyncClient session = builder.beta() + BetaVoiceAgentWebSocketSessionAsyncClient session = builder.beta() .buildBetaVoiceAgentWebSocketAsyncClient() .connect("agent", options) .block(Duration.ofSeconds(5)); @@ -276,7 +280,7 @@ public void malformedEventsCanBeReportedAndSkipped(boolean async) { .verifyComplete(); session.close(); } else { - try (VoiceAgentWebSocketSessionClient session + try (BetaVoiceAgentWebSocketSessionClient session = builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent", options)) { Iterator events = session.receiveEvents(Duration.ofSeconds(5)).iterator(); assertWarningEvent(events.next()); @@ -300,7 +304,7 @@ public void boundedQueuesHonorOverflowPolicies(boolean async) { List received = new ArrayList<>(); boolean overflowError = strategy == VoiceAgentWebSocketOverflowStrategy.ERROR; if (async) { - VoiceAgentWebSocketSessionAsyncClient session = builder.beta() + BetaVoiceAgentWebSocketSessionAsyncClient session = builder.beta() .buildBetaVoiceAgentWebSocketAsyncClient() .connect("agent", options) .block(Duration.ofSeconds(5)); @@ -319,7 +323,7 @@ public void boundedQueuesHonorOverflowPolicies(boolean async) { } session.close(); } else { - try (VoiceAgentWebSocketSessionClient session + try (BetaVoiceAgentWebSocketSessionClient session = builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent", options)) { assertTimeoutPreemptively(Duration.ofSeconds(5), () -> { while (session.isOpen()) { @@ -359,10 +363,10 @@ public void messageSizeLimitCannotBeBypassedByRecoveryHandler(boolean async) { StepVerifier.create(builder.beta() .buildBetaVoiceAgentWebSocketAsyncClient() .connect("agent", options) - .flatMapMany(VoiceAgentWebSocketSessionAsyncClient::receiveEvents)).expectError().verify(); + .flatMapMany(BetaVoiceAgentWebSocketSessionAsyncClient::receiveEvents)).expectError().verify(); } else { assertThrows(RuntimeException.class, () -> { - try (VoiceAgentWebSocketSessionClient session + try (BetaVoiceAgentWebSocketSessionClient session = builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent", options)) { session.receiveEvents(Duration.ofSeconds(5)).iterator().next(); } @@ -378,7 +382,7 @@ public void rawEventRoundTripsAndOptionsValidateBounds() throws Exception { RawRealtimeServerEvent copy = BinaryData.fromObject(event).toObject(RawRealtimeServerEvent.class); assertEquals(payload.toObject(Map.class), copy.getRawEvent().toObject(Map.class)); server = oneShotWebSocketServer("{\"type\":42,\"value\":1}"); - VoiceAgentWebSocketSessionAsyncClient session + BetaVoiceAgentWebSocketSessionAsyncClient session = createAsyncClient(server.port()).connect("agent", tlsOptions()).block(Duration.ofSeconds(5)); try { StepVerifier.create(session.receiveEvents()) @@ -429,31 +433,21 @@ public void insecureEndpointsAreRejectedBeforeAuthentication() { assertFalse(requested.get()); } - private static File tlsResource(String name) { - try { - return resourceFile(name); - } catch (Exception error) { - throw new IllegalStateException(error); - } - } - private static HttpServer tlsServer() { return HttpServer.create() - .secure(ssl -> ssl.sslContext(Http11SslContextSpec.forServer(tlsResource("websocket-localhost-cert.pem"), - tlsResource("websocket-localhost-key.pem")))); + .secure(ssl -> ssl.sslContext(Http11SslContextSpec.forServer(TLS_CERTIFICATE.keyManagerFactory))); } private static VoiceAgentWebSocketConnectionOptions tlsOptions() { return new VoiceAgentWebSocketConnectionOptions() .setAsyncHttpClientConfiguration( client -> client.secure(ssl -> ssl.sslContext(Http11SslContextSpec.forClient() - .configure(builder -> builder.trustManager(tlsResource("websocket-localhost-cert.pem")))))) + .configure(builder -> builder.trustManager(TLS_CERTIFICATE.certificate))))) .setHttpClientConfiguration(builder -> { - try (InputStream input = Files.newInputStream(tlsResource("websocket-localhost-cert.pem").toPath())) { + try { KeyStore store = KeyStore.getInstance(KeyStore.getDefaultType()); store.load(null, null); - store.setCertificateEntry("localhost", - CertificateFactory.getInstance("X.509").generateCertificate(input)); + store.setCertificateEntry("localhost", TLS_CERTIFICATE.certificate); TrustManagerFactory factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); factory.init(store); @@ -479,7 +473,7 @@ public void rawEventsUseCustomizedTlsTransports() { AgentsClientBuilder builder = new AgentsClientBuilder().endpoint("https://localhost:" + server.port()) .credential(request -> Mono.just(new AccessToken("test-token", OffsetDateTime.now().plusHours(1)))); BinaryData payload = BinaryData.fromString("{\"type\":\"future.event\",\"value\":42}"); - try (VoiceAgentWebSocketSessionClient session + try (BetaVoiceAgentWebSocketSessionClient session = builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent", tlsOptions())) { assertThrows(IllegalArgumentException.class, () -> session.sendEvent(BinaryData.fromString("[]"))); assertThrows(IllegalArgumentException.class, () -> session.sendEvent(BinaryData.fromString("{} {}"))); @@ -488,7 +482,7 @@ public void rawEventsUseCustomizedTlsTransports() { session.receiveEvents(Duration.ofSeconds(5)).iterator().next()); assertEquals(payload.toObject(Map.class), received.getRawEvent().toObject(Map.class)); } - VoiceAgentWebSocketSessionAsyncClient session = builder.beta() + BetaVoiceAgentWebSocketSessionAsyncClient session = builder.beta() .buildBetaVoiceAgentWebSocketAsyncClient() .connect("agent", tlsOptions()) .block(Duration.ofSeconds(5)); @@ -511,7 +505,7 @@ public void customCloseFrameAndReceiveTimeout() { AgentsClientBuilder builder = new AgentsClientBuilder().endpoint("https://localhost:" + server.port() + "/api/projects/project") .credential(request -> Mono.just(new AccessToken("test-token", OffsetDateTime.now().plusHours(1)))); - try (VoiceAgentWebSocketSessionClient session + try (BetaVoiceAgentWebSocketSessionClient session = builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent", tlsOptions())) { Iterator iterator = session.receiveEvents(Duration.ofMillis(20)).iterator(); IllegalStateException timeout = assertThrows(IllegalStateException.class, iterator::hasNext); @@ -522,7 +516,7 @@ public void customCloseFrameAndReceiveTimeout() { assertEquals(4001, session.getCloseCode()); assertEquals("finished", session.getCloseReason()); } - VoiceAgentWebSocketSessionAsyncClient session = builder.beta() + BetaVoiceAgentWebSocketSessionAsyncClient session = builder.beta() .buildBetaVoiceAgentWebSocketAsyncClient() .connect("agent", tlsOptions()) .block(Duration.ofSeconds(5)); @@ -540,6 +534,11 @@ public void disposeServer() { } } + @AfterAll + public static void deleteTlsCertificate() { + TLS_CERTIFICATE.delete(); + } + @Test public void asyncSessionNegotiatesHandshakeAndExchangesTypedEvents() { List clientMessages = new CopyOnWriteArrayList<>(); @@ -567,7 +566,7 @@ public void asyncSessionNegotiatesHandshakeAndExchangesTypedEvents() { .beta() .buildBetaVoiceAgentWebSocketAsyncClient(); - VoiceAgentWebSocketSessionAsyncClient session = client.connect("agent name", options).block(); + BetaVoiceAgentWebSocketSessionAsyncClient session = client.connect("agent name", options).block(); assertTrue(session.isOpen()); StepVerifier.create(session.receiveEvents().take(4)) @@ -779,7 +778,7 @@ public void cancellingAsyncConnectCancelsTokenRequest() { @Test public void unknownEventFallsBackToRealtimeServerEvent() { server = oneShotWebSocketServer("{\"type\":\"future.event\",\"value\":42}"); - VoiceAgentWebSocketSessionAsyncClient session + BetaVoiceAgentWebSocketSessionAsyncClient session = createAsyncClient(server.port()).connect("agent", tlsOptions()).block(); StepVerifier.create(session.receiveEvents()).assertNext(event -> { @@ -797,7 +796,7 @@ public void fragmentedTextFrameIsAggregated() { Flux frames = Flux.just(new TextWebSocketFrame(false, 0, message.substring(0, split)), new ContinuationWebSocketFrame(true, 0, message.substring(split))); server = frameWebSocketServer(frames); - VoiceAgentWebSocketSessionAsyncClient session + BetaVoiceAgentWebSocketSessionAsyncClient session = createAsyncClient(server.port()).connect("agent", tlsOptions()).block(); StepVerifier.create(session.receiveEvents()).assertNext(this::assertWarningEvent).verifyComplete(); @@ -808,7 +807,7 @@ public void fragmentedTextFrameIsAggregated() { public void binaryJsonFrameIsParsed() { server = frameWebSocketServer( Mono.just(new BinaryWebSocketFrame(Unpooled.copiedBuffer(warningJson(), StandardCharsets.UTF_8)))); - VoiceAgentWebSocketSessionAsyncClient session + BetaVoiceAgentWebSocketSessionAsyncClient session = createAsyncClient(server.port()).connect("agent", tlsOptions()).block(); StepVerifier.create(session.receiveEvents()).assertNext(this::assertWarningEvent).verifyComplete(); @@ -818,7 +817,7 @@ public void binaryJsonFrameIsParsed() { @Test public void malformedJsonTerminatesReceiveStream() { server = oneShotWebSocketServer("{not-json"); - VoiceAgentWebSocketSessionAsyncClient session + BetaVoiceAgentWebSocketSessionAsyncClient session = createAsyncClient(server.port()).connect("agent", tlsOptions()).block(); StepVerifier.create(session.receiveEvents()).expectError().verify(); @@ -838,7 +837,7 @@ public void syncReceiveBufferOverflowFailsTheEventStream() { .beta() .buildBetaVoiceAgentWebSocketClient(); - try (VoiceAgentWebSocketSessionClient session = client.connect("agent", tlsOptions())) { + try (BetaVoiceAgentWebSocketSessionClient session = client.connect("agent", tlsOptions())) { assertTimeoutPreemptively(Duration.ofSeconds(5), () -> { while (session.isOpen()) { Thread.yield(); @@ -863,7 +862,7 @@ public void syncOrderlyClosePreservesFullReceiveBuffer() { .beta() .buildBetaVoiceAgentWebSocketClient(); - try (VoiceAgentWebSocketSessionClient session = client.connect("agent", tlsOptions())) { + try (BetaVoiceAgentWebSocketSessionClient session = client.connect("agent", tlsOptions())) { assertTimeoutPreemptively(Duration.ofSeconds(5), () -> { Iterator events = session.receiveEvents().iterator(); int eventCount = 0; @@ -881,7 +880,7 @@ public void closeIsIdempotentAndSendAfterCloseFails() { List clientMessages = new CopyOnWriteArrayList<>(); server = startServer(clientMessages, new AtomicReference<>(), new AtomicReference<>(), new AtomicReference<>(), new AtomicReference<>(), new AtomicReference<>(), false); - VoiceAgentWebSocketSessionAsyncClient session + BetaVoiceAgentWebSocketSessionAsyncClient session = createAsyncClient(server.port()).connect("agent", tlsOptions()).block(); StepVerifier.create(session.closeAsync().then(session.closeAsync())).verifyComplete(); @@ -893,9 +892,7 @@ public void closeIsIdempotentAndSendAfterCloseFails() { @Test public void secureSessionUsesWssAndReceivesTypedEvent() throws Exception { - File certificate = resourceFile("websocket-localhost-cert.pem"); - File privateKey = resourceFile("websocket-localhost-key.pem"); - Http11SslContextSpec serverSsl = Http11SslContextSpec.forServer(certificate, privateKey); + Http11SslContextSpec serverSsl = Http11SslContextSpec.forServer(TLS_CERTIFICATE.keyManagerFactory); WebsocketServerSpec websocketSpec = WebsocketServerSpec.builder().protocols("realtime").build(); server = tlsServer().host("localhost") .port(0) @@ -908,7 +905,7 @@ public void secureSessionUsesWssAndReceivesTypedEvent() throws Exception { TokenCredential credential = request -> Mono.just(new AccessToken("tls-token", OffsetDateTime.now().plusHours(1))); - VoiceAgentWebSocketSessionAsyncClient session + BetaVoiceAgentWebSocketSessionAsyncClient session = new AgentsClientBuilder().endpoint("https://localhost:" + server.port() + "/api/projects/project") .credential(credential) .beta() @@ -939,7 +936,7 @@ public void syncSessionReceivesTypedEventAndCloses() { .beta() .buildBetaVoiceAgentWebSocketClient(); - try (VoiceAgentWebSocketSessionClient session = client.connect("sync-agent", tlsOptions())) { + try (BetaVoiceAgentWebSocketSessionClient session = client.connect("sync-agent", tlsOptions())) { Iterator events = session.receiveEvents().iterator(); assertWarningEvent(events.next()); session.sendFunctionCallOutput("call-1", "{\"temperature\":72}"); @@ -1022,10 +1019,55 @@ private static String warningJson() { + "\"message\":\"loopback warning\",\"code\":\"test_warning\"}}"; } - private static File resourceFile(String name) throws Exception { - URL resource = VoiceAgentWebSocketSessionTests.class.getClassLoader().getResource(name); - assertNotNull(resource); - return Paths.get(resource.toURI()).toFile(); + private static final class TestCertificate { + private final Path path; + private final KeyManagerFactory keyManagerFactory; + private final X509Certificate certificate; + + private TestCertificate(Path path, KeyManagerFactory keyManagerFactory, X509Certificate certificate) { + this.path = path; + this.keyManagerFactory = keyManagerFactory; + this.certificate = certificate; + } + + private static TestCertificate create() { + try { + Path path = Files.createTempFile("voice-agent-websocket-", ".p12"); + Files.delete(path); + String password = UUID.randomUUID().toString(); + String executable + = Paths + .get(System.getProperty("java.home"), "bin", + System.getProperty("os.name").startsWith("Windows") ? "keytool.exe" : "keytool") + .toString(); + Process process = new ProcessBuilder(executable, "-genkeypair", "-alias", "localhost", "-keyalg", "RSA", + "-keysize", "2048", "-validity", "1", "-dname", "CN=localhost", "-ext", "SAN=dns:localhost", + "-storetype", "PKCS12", "-keystore", path.toString(), "-storepass", password, "-keypass", password, + "-noprompt").redirectErrorStream(true).start(); + if (process.waitFor() != 0) { + throw new IllegalStateException("keytool failed to generate the test certificate."); + } + KeyStore store = KeyStore.getInstance("PKCS12"); + try (InputStream input = Files.newInputStream(path)) { + store.load(input, password.toCharArray()); + } + KeyManagerFactory keyManagerFactory + = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(store, password.toCharArray()); + return new TestCertificate(path, keyManagerFactory, + (X509Certificate) store.getCertificate("localhost")); + } catch (Exception error) { + throw new IllegalStateException(error); + } + } + + private void delete() { + try { + Files.deleteIfExists(path); + } catch (Exception error) { + throw new IllegalStateException(error); + } + } } private static String decode(String value) { diff --git a/sdk/ai/azure-ai-agents/src/test/resources/websocket-localhost-cert.pem b/sdk/ai/azure-ai-agents/src/test/resources/websocket-localhost-cert.pem deleted file mode 100644 index 51389bf8a86e1..0000000000000 --- a/sdk/ai/azure-ai-agents/src/test/resources/websocket-localhost-cert.pem +++ /dev/null @@ -1,19 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDJTCCAg2gAwIBAgIUfAusxvG/l3WCuKMFyNBEfEr30rswDQYJKoZIhvcNAQEL -BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDkwMzA5NTgyMloXDTM2MDgz -MTA5NTgyMlowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF -AAOCAQ8AMIIBCgKCAQEA3YAeebCQfQbC+BoOyHqM6EgmXxIfpixqG26ElKdCehCH -yt6FqnHhvjf/TRfbwgij/GVxIR1wO4CXkAATNMxsaFu/EbDzI/eDqZKIOQIzd0if -JHC20kVibaFnNDlI9NKC/Ywphz0d8JXCHnYVMVJP27moNYcG91/Lka8223O+qoAw -sta603tAcpsFEl9muc8y0UhwAKED03Gr0mjjGZZ6vTvCE+i3IsslZKXqtS6Fo1wm -NTUCpB/yF8i+WnnVrLetMy45D3hEjPeh2p8cjTHOsOyKkqNAH6hVB0bBzCccN+cY -1YtI0A4Umu7FO5RlAoR8eYnBdoZ06qpv1eXjwj/QZwIDAQABo28wbTAdBgNVHQ4E -FgQU6saTU+QSavalG6I49czWXwtSwY0wHwYDVR0jBBgwFoAU6saTU+QSavalG6I4 -9czWXwtSwY0wDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SH -BH8AAAEwDQYJKoZIhvcNAQELBQADggEBAJ73cFPhCnH1IoItmWDJxhIaR7g1MIfh -o7DxnXEf8ZFw7bpzo4Epp6R6+RRH/fnbosw73vtDqEVZQfxKKjAo0NvguNJIuOoz -oISXYpAIX8eBT2ZrH6m0tJfgwyp7V0+SaHChy1+TmtnaT7rfC7N5r/rcr1abQV78 -qUK7N1+aF0dV1fGE4oP3jon+MNc7pZSagVDTz/k2qHwDnwPoVG37BXf7UZ7jbA2g -/afI/YHCt7zT8aHtjJWJMWLgHOtFTGqx1h7x1rEiLNPK/6USrSFEw+7ZQDFSbG7g -NQyC8lVm3QCVeze2q2/x1DUz20UnGaHz+o3fuK+qsDg/NtHIUMM3wj4= ------END CERTIFICATE----- diff --git a/sdk/ai/azure-ai-agents/src/test/resources/websocket-localhost-key.pem b/sdk/ai/azure-ai-agents/src/test/resources/websocket-localhost-key.pem deleted file mode 100644 index d1576a3c7c4d2..0000000000000 --- a/sdk/ai/azure-ai-agents/src/test/resources/websocket-localhost-key.pem +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDdgB55sJB9BsL4 -Gg7IeozoSCZfEh+mLGobboSUp0J6EIfK3oWqceG+N/9NF9vCCKP8ZXEhHXA7gJeQ -ABM0zGxoW78RsPMj94Opkog5AjN3SJ8kcLbSRWJtoWc0OUj00oL9jCmHPR3wlcIe -dhUxUk/buag1hwb3X8uRrzbbc76qgDCy1rrTe0BymwUSX2a5zzLRSHAAoQPTcavS -aOMZlnq9O8IT6LciyyVkpeq1LoWjXCY1NQKkH/IXyL5aedWst60zLjkPeESM96Ha -nxyNMc6w7IqSo0AfqFUHRsHMJxw35xjVi0jQDhSa7sU7lGUChHx5icF2hnTqqm/V -5ePCP9BnAgMBAAECggEAASQTFmWptpRLVjpkIfWno92DRjpgGVu87C3SRLgJjOhE -CIJ6WFmyGrEnbxE5ZLMuaHxEtVY+e1JEGilagkIdBEQF23/mQwAqYZem6oxB7Qk5 -J3wu27/XdTw/dET7RMr98E74XzgaFheWPfURdym28ruBFQRbv9PgWUWdDt2/ndBY -e9XDZ0737YDGjWkZFwLZ/q6YDEUc4NhTClVvzCyTlLMVVL4xsvPzmyxlT093Hdys -ZDs/6UVJOPYsgv7Z9ww6fwv+oPi/oNtvX3dEOjEQkLvmIfXrPSvYQZVQ6Ok5K0UF -eKUP8tB2ZIrX70nhg8R8ThFjldMPb/lS2i59PWQBgQKBgQDwehtp27diMPnUo7ZR -xSPt2UAUTiRRlwQo4rFZNiR5ZMLPBAX4DP5aQDyDiTAMqBOjHqgipagra9xOapCN -uqlEINaMlsuSwMD1cxkp85V5FWab+u1MqBr3B1aq2INqrYDGmPs3kS6Po4M0N12+ -O6Bob4YWBaabIY+rEEJuBmK9QQKBgQDrzGyXVRYGarKW4tiWVeRAA5yNF/w0YW8B -u62wUXLXUXfzhND4ETCUxUpgkcjY1AICWQQnbUFXi/0WUowkKZg8Vh0zfBJmsbs2 -LPhCUEMITKB3owwJLKDCpSake+9Afxi7XB4UltsjInep1XGE6tvKKr9bAF1Sqd47 -V74dv0CbpwKBgQCkuT/l91dar2myuqG8yWmfF13Jiu1d5jA3QXFyRqAdd2PqIjtk -eqIQeEf7YhHD2a354poRgZ/8flnebSivrNkdjdDpZLH1yItkln76OZx94Kb02aGL -DOvLov8+8Ci0/jxjzY7ntU9LnRnWvsY79OQgJaSXmS9SvF6JMw4OB9nDAQKBgQCJ -kxrkbJtOISCTkkTl6bUjeDf1xkG62gInU7XyAoNrhzfiF+LIaVcb5cQQdd5mS8Pk -VMVsr30JND70sDLdwnr08RVWfZRK4HWnFTO/lQ6XIAYb50BVdflRt4PFQh4EVmM6 -pXNTdfTjGfARYdw6vcCAwtIkqSDJ4xwrKXVd68EpTwKBgDURo00XBIFSyqAbvxwF -NchLRcoNZVxxd9hHGTUiTgstfDGxEuHpWEETiVsGGnU/Mq+cTKW98P3y/mdW9Gd+ -mYRmo1L0J6lkBMP3xD5PXxbvTWkxVReuSCl0zUqfKcHjwllpsS91If0HVeg0c5ze -/C4ecakj7llQKhUYIm/dNULb ------END PRIVATE KEY----- diff --git a/sdk/ai/azure-ai-projects/CHANGELOG.md b/sdk/ai/azure-ai-projects/CHANGELOG.md index 96311c7b747cd..1b8663e8064f4 100644 --- a/sdk/ai/azure-ai-projects/CHANGELOG.md +++ b/sdk/ai/azure-ai-projects/CHANGELOG.md @@ -8,7 +8,7 @@ - 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 `TelemetryClient` and `TelemetryAsyncClient` for retrieving and caching the project's Application Insights connection string. +- 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 diff --git a/sdk/ai/azure-ai-projects/README.md b/sdk/ai/azure-ai-projects/README.md index 11c21eff3821b..b3581f7b3cb05 100644 --- a/sdk/ai/azure-ai-projects/README.md +++ b/sdk/ai/azure-ai-projects/README.md @@ -150,10 +150,10 @@ the native client's future decorators control cancellation propagation. ### Application Insights configuration ```java -TelemetryClient telemetry = builder.buildTelemetryClient(); +BetaTelemetryClient telemetry = builder.buildBetaTelemetryClient(); String connectionString = telemetry.getApplicationInsightsConnectionString(); -TelemetryAsyncClient telemetryAsync = builder.buildTelemetryAsyncClient(); +BetaTelemetryAsyncClient telemetryAsync = builder.buildBetaTelemetryAsyncClient(); Mono connectionStringAsync = telemetryAsync.getApplicationInsightsConnectionString(); ``` 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 30ae5533454db..9d2ccfdd8c161 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 @@ -42,13 +42,13 @@ public void customize(LibraryCustomization libraryCustomization, Logger logger) pipelineMethod.setBody(StaticJavaParser.parseBlock("{ return createHttpPipeline(true); }")); }); libraryCustomization.getClass("com.azure.ai.projects", "AIProjectClientBuilder").customizeAst(ast -> - addTelemetryClients(ast.getClassByName("AIProjectClientBuilder") + addBetaTelemetryClients(ast.getClassByName("AIProjectClientBuilder") .orElseThrow(() -> new IllegalStateException("Generated AIProjectClientBuilder was not found.")))); annotateBetaClients(libraryCustomization, logger); annotateBetaFields(libraryCustomization, loadBetaAnnotations(logger), logger); } - private static void addTelemetryClients(ClassOrInterfaceDeclaration builder) { + private static void addBetaTelemetryClients(ClassOrInterfaceDeclaration builder) { NormalAnnotationExpr annotation = builder.getAnnotationByName("ServiceClientBuilder") .filter(AnnotationExpr::isNormalAnnotationExpr) .map(AnnotationExpr::asNormalAnnotationExpr) @@ -62,7 +62,7 @@ private static void addTelemetryClients(ClassOrInterfaceDeclaration builder) { ArrayInitializerExpr clients = value.isArrayInitializerExpr() ? value.asArrayInitializerExpr() : new ArrayInitializerExpr(new NodeList<>(value)); - for (String serviceClient : new String[] { "TelemetryClient.class", "TelemetryAsyncClient.class" }) { + for (String serviceClient : new String[] { "BetaTelemetryClient.class", "BetaTelemetryAsyncClient.class" }) { if (clients.getValues().stream().noneMatch(existing -> serviceClient.equals(existing.toString()))) { clients.getValues().add(StaticJavaParser.parseExpression(serviceClient)); } 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 a323138432c17..2975da0739d9f 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 @@ -87,8 +87,8 @@ IndexesAsyncClient.class, DeploymentsAsyncClient.class, EvaluationRulesAsyncClient.class, - TelemetryClient.class, - TelemetryAsyncClient.class }) + BetaTelemetryClient.class, + BetaTelemetryAsyncClient.class }) public final class AIProjectClientBuilder implements HttpTrait, ConfigurationTrait, TokenCredentialTrait, EndpointTrait { @@ -468,8 +468,8 @@ public ConnectionsAsyncClient buildConnectionsAsyncClient() { * * @return an asynchronous telemetry client. */ - public TelemetryAsyncClient buildTelemetryAsyncClient() { - return new TelemetryAsyncClient(buildConnectionsAsyncClient()); + public BetaTelemetryAsyncClient buildBetaTelemetryAsyncClient() { + return new BetaTelemetryAsyncClient(buildConnectionsAsyncClient()); } /** @@ -477,8 +477,8 @@ public TelemetryAsyncClient buildTelemetryAsyncClient() { * * @return a synchronous telemetry client. */ - public TelemetryClient buildTelemetryClient() { - return new TelemetryClient(buildConnectionsClient()); + public BetaTelemetryClient buildBetaTelemetryClient() { + return new BetaTelemetryClient(buildConnectionsClient()); } /** diff --git a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/TelemetryAsyncClient.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaTelemetryAsyncClient.java similarity index 88% rename from sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/TelemetryAsyncClient.java rename to sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaTelemetryAsyncClient.java index 0b7aa706bda50..5683ec0df2162 100644 --- a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/TelemetryAsyncClient.java +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaTelemetryAsyncClient.java @@ -6,6 +6,7 @@ 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; @@ -16,14 +17,15 @@ /** * Asynchronous access to the project's telemetry configuration. - * Instances are created through {@link AIProjectClientBuilder#buildTelemetryAsyncClient()}. + * Instances are created through {@link AIProjectClientBuilder#buildBetaTelemetryAsyncClient()}. */ @ServiceClient(builder = AIProjectClientBuilder.class, isAsync = true) -public final class TelemetryAsyncClient { +@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<>(); - TelemetryAsyncClient(ConnectionsAsyncClient connections) { + BetaTelemetryAsyncClient(ConnectionsAsyncClient connections) { this.connections = connections; } @@ -47,7 +49,7 @@ public Mono getApplicationInsightsConnectionString() { .switchIfEmpty( Mono.error(new ResourceNotFoundException("No Application Insights connection found.", null))) .flatMap(connection -> connections.getConnection(connection.getName(), true)) - .map(TelemetryAsyncClient::getConnectionString) + .map(BetaTelemetryAsyncClient::getConnectionString) .doOnNext(connectionString::set); }); } diff --git a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/TelemetryClient.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaTelemetryClient.java similarity index 90% rename from sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/TelemetryClient.java rename to sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaTelemetryClient.java index 541241f243e11..76f6733f0903e 100644 --- a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/TelemetryClient.java +++ b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaTelemetryClient.java @@ -6,6 +6,7 @@ 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; @@ -18,15 +19,16 @@ /** * Synchronous access to the project's telemetry configuration. - * Instances are created through {@link AIProjectClientBuilder#buildTelemetryClient()}. + * Instances are created through {@link AIProjectClientBuilder#buildBetaTelemetryClient()}. */ @ServiceClient(builder = AIProjectClientBuilder.class) -public final class TelemetryClient { - private static final ClientLogger LOGGER = new ClientLogger(TelemetryClient.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<>(); - TelemetryClient(ConnectionsClient connections) { + BetaTelemetryClient(ConnectionsClient connections) { this.connections = connections; } diff --git a/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/TelemetryClientTest.java b/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/BetaTelemetryClientTest.java similarity index 95% rename from sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/TelemetryClientTest.java rename to sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/BetaTelemetryClientTest.java index 0a2afa590a39a..f1c4eecb64478 100644 --- a/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/TelemetryClientTest.java +++ b/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/BetaTelemetryClientTest.java @@ -25,7 +25,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -public class TelemetryClientTest { +public class BetaTelemetryClientTest { @ParameterizedTest @ValueSource(booleans = { false, true }) public void cachesSuccessfulConnectionString(boolean async) { @@ -92,10 +92,10 @@ private HttpResponse createResponse(HttpRequest request) { AIProjectClientBuilder builder = new AIProjectClientBuilder().endpoint("https://localhost/api/projects/project").httpClient(httpClient); if (async) { - TelemetryAsyncClient client = builder.buildTelemetryAsyncClient(); + BetaTelemetryAsyncClient client = builder.buildBetaTelemetryAsyncClient(); return () -> client.getApplicationInsightsConnectionString().block(); } - TelemetryClient client = builder.buildTelemetryClient(); + BetaTelemetryClient client = builder.buildBetaTelemetryClient(); return client::getApplicationInsightsConnectionString; } } From 2a86373635c83c43758882f28a22b9d4dd100bbb Mon Sep 17 00:00:00 2001 From: Mike Guo Date: Fri, 18 Sep 2026 09:14:54 +0800 Subject: [PATCH 18/25] Document beta voice WebSocket clients --- sdk/ai/azure-ai-agents/CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sdk/ai/azure-ai-agents/CHANGELOG.md b/sdk/ai/azure-ai-agents/CHANGELOG.md index 525904ce83d4a..912e022f5bd04 100644 --- a/sdk/ai/azure-ai-agents/CHANGELOG.md +++ b/sdk/ai/azure-ai-agents/CHANGELOG.md @@ -19,7 +19,10 @@ - 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 synchronous and asynchronous voice-agent WebSocket clients and session APIs with typed realtime events, text and PCM16 audio input, response cancellation, function-call output, persisted-conversation options, and authenticated `wss://` transport. +- 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 + authenticated `wss://` transport. - Added synchronous and asynchronous live text conversation samples, an asynchronous Java Sound microphone/speaker sample with barge-in, and a live client-executed function-tool sample. ### Breaking Changes From 21959cc1e7a536dda1e55af4e03793a76b498f69 Mon Sep 17 00:00:00 2001 From: Mike Guo Date: Fri, 18 Sep 2026 09:58:57 +0800 Subject: [PATCH 19/25] Align Projects beta client construction --- sdk/ai/azure-ai-projects/README.md | 4 +- .../src/main/java/ProjectsCustomizations.java | 85 ++++++++++-------- .../ai/projects/AIProjectClientBuilder.java | 89 +++++++++++++------ .../ai/projects/BetaTelemetryAsyncClient.java | 3 +- .../ai/projects/BetaTelemetryClient.java | 2 +- .../ai/projects/BetaTelemetryClientTest.java | 4 +- 6 files changed, 117 insertions(+), 70 deletions(-) diff --git a/sdk/ai/azure-ai-projects/README.md b/sdk/ai/azure-ai-projects/README.md index b3581f7b3cb05..621360dc27438 100644 --- a/sdk/ai/azure-ai-projects/README.md +++ b/sdk/ai/azure-ai-projects/README.md @@ -150,10 +150,10 @@ the native client's future decorators control cancellation propagation. ### Application Insights configuration ```java -BetaTelemetryClient telemetry = builder.buildBetaTelemetryClient(); +BetaTelemetryClient telemetry = builder.beta().buildBetaTelemetryClient(); String connectionString = telemetry.getApplicationInsightsConnectionString(); -BetaTelemetryAsyncClient telemetryAsync = builder.buildBetaTelemetryAsyncClient(); +BetaTelemetryAsyncClient telemetryAsync = builder.beta().buildBetaTelemetryAsyncClient(); Mono connectionStringAsync = telemetryAsync.getApplicationInsightsConnectionString(); ``` 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 9d2ccfdd8c161..8bcaeeba0a9e9 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 @@ -2,17 +2,17 @@ import com.azure.autorest.customization.Customization; import com.azure.autorest.customization.LibraryCustomization; import com.github.javaparser.StaticJavaParser; -import com.github.javaparser.ast.NodeList; 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.ArrayInitializerExpr; -import com.github.javaparser.ast.expr.Expression; -import com.github.javaparser.ast.expr.MemberValuePair; +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.IfStmt; import java.io.IOException; import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; @@ -31,43 +31,56 @@ public class ProjectsCustomizations extends Customization { @Override public void customize(LibraryCustomization libraryCustomization, Logger logger) { - libraryCustomization.getClass("com.azure.ai.projects", "AIProjectClientBuilder").customizeAst(ast -> { - MethodDeclaration pipelineMethod = ast.getClassByName("AIProjectClientBuilder") - .orElseThrow(() -> new IllegalStateException("Generated AIProjectClientBuilder was not found.")) - .getMethodsByName("createHttpPipeline") - .stream() - .filter(method -> method.getParameters().isEmpty()) - .findFirst() - .orElseThrow(() -> new IllegalStateException("Generated createHttpPipeline was not found.")); - pipelineMethod.setBody(StaticJavaParser.parseBlock("{ return createHttpPipeline(true); }")); - }); - libraryCustomization.getClass("com.azure.ai.projects", "AIProjectClientBuilder").customizeAst(ast -> - addBetaTelemetryClients(ast.getClassByName("AIProjectClientBuilder") - .orElseThrow(() -> new IllegalStateException("Generated AIProjectClientBuilder was not found.")))); + customizeBuilder(libraryCustomization); annotateBetaClients(libraryCustomization, logger); annotateBetaFields(libraryCustomization, loadBetaAnnotations(logger), logger); } - private static void addBetaTelemetryClients(ClassOrInterfaceDeclaration builder) { - NormalAnnotationExpr annotation = builder.getAnnotationByName("ServiceClientBuilder") - .filter(AnnotationExpr::isNormalAnnotationExpr) - .map(AnnotationExpr::asNormalAnnotationExpr) - .orElseThrow(() -> new IllegalStateException( - builder.getNameAsString() + " has no normal @ServiceClientBuilder annotation.")); - MemberValuePair pair = annotation.getPairs().stream() - .filter(candidate -> "serviceClients".equals(candidate.getNameAsString())) - .findFirst() - .orElseThrow(() -> new IllegalStateException("@ServiceClientBuilder has no serviceClients value.")); - Expression value = pair.getValue(); - ArrayInitializerExpr clients = value.isArrayInitializerExpr() - ? value.asArrayInitializerExpr() - : new ArrayInitializerExpr(new NodeList<>(value)); - for (String serviceClient : new String[] { "BetaTelemetryClient.class", "BetaTelemetryAsyncClient.class" }) { - if (clients.getValues().stream().noneMatch(existing -> serviceClient.equals(existing.toString()))) { - clients.getValues().add(StaticJavaParser.parseExpression(serviceClient)); + 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 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."); } - } - pair.setValue(clients); + 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) { 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 2975da0739d9f..eb3f4e1fa98fb 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 @@ -86,9 +86,7 @@ DatasetsAsyncClient.class, IndexesAsyncClient.class, DeploymentsAsyncClient.class, - EvaluationRulesAsyncClient.class, - BetaTelemetryClient.class, - BetaTelemetryAsyncClient.class }) + EvaluationRulesAsyncClient.class }) public final class AIProjectClientBuilder implements HttpTrait, ConfigurationTrait, TokenCredentialTrait, EndpointTrait { @@ -371,10 +369,6 @@ private void validateClient() { @Generated private HttpPipeline createHttpPipeline() { - return createHttpPipeline(true); - } - - private HttpPipeline createHttpPipeline(boolean authenticate) { Configuration buildConfiguration = (configuration == null) ? Configuration.getGlobalConfiguration() : configuration; HttpLogOptions localHttpLogOptions = resolveHttpLogOptions(); @@ -396,7 +390,7 @@ private HttpPipeline createHttpPipeline(boolean authenticate) { HttpPolicyProviders.addBeforeRetryPolicies(policies); policies.add(ClientBuilderUtil.validateAndGetRetryPolicy(retryPolicy, retryOptions, new RetryPolicy())); policies.add(new AddDatePolicy()); - if (authenticate && tokenCredential != null) { + if (tokenCredential != null) { policies.add(new BearerTokenAuthenticationPolicy(tokenCredential, DEFAULT_SCOPES)); } this.pipelinePolicies.stream() @@ -420,7 +414,7 @@ private HttpPipeline resolvePipeline(String foundryFeatures) { } private com.openai.core.http.HttpClient createOpenAIHttpClient(String foundryFeatures) { - HttpPipeline localPipeline = pipeline != null ? pipeline : createHttpPipeline(false); + HttpPipeline localPipeline = pipeline != null ? pipeline : createOpenAIHttpPipeline(); return HttpClientHelper.mapToOpenAIHttpClient( FoundryPolicyHelper.prependPolicy(localPipeline, FoundryPolicyHelper.createFoundryFeaturesPolicy(foundryFeatures)), @@ -463,24 +457,6 @@ public ConnectionsAsyncClient buildConnectionsAsyncClient() { return new ConnectionsAsyncClient(buildInnerClient().getConnections()); } - /** - * Builds an asynchronous client for the project's telemetry configuration. - * - * @return an asynchronous telemetry client. - */ - public BetaTelemetryAsyncClient buildBetaTelemetryAsyncClient() { - return new BetaTelemetryAsyncClient(buildConnectionsAsyncClient()); - } - - /** - * Builds a synchronous client for the project's telemetry configuration. - * - * @return a synchronous telemetry client. - */ - public BetaTelemetryClient buildBetaTelemetryClient() { - return new BetaTelemetryClient(buildConnectionsClient()); - } - /** * Builds an instance of DatasetsAsyncClient class. * @@ -920,6 +896,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. *

@@ -967,7 +978,9 @@ public BetaAIProjectClientBuilder beta() { BetaRoutinesClient.class, BetaSkillsClient.class, BetaDatasetsClient.class, - BetaAgentInsightMonitorsClient.class }) + BetaAgentInsightMonitorsClient.class, + BetaTelemetryClient.class, + BetaTelemetryAsyncClient.class }) public final class BetaAIProjectClientBuilder { /** @@ -977,6 +990,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/BetaTelemetryAsyncClient.java b/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaTelemetryAsyncClient.java index 5683ec0df2162..8788d2505f262 100644 --- 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 @@ -17,7 +17,8 @@ /** * Asynchronous access to the project's telemetry configuration. - * Instances are created through {@link AIProjectClientBuilder#buildBetaTelemetryAsyncClient()}. + * 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.") 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 index 76f6733f0903e..c2e462453210a 100644 --- 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 @@ -19,7 +19,7 @@ /** * Synchronous access to the project's telemetry configuration. - * Instances are created through {@link AIProjectClientBuilder#buildBetaTelemetryClient()}. + * 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.") 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 index f1c4eecb64478..8c0f1bc012b9c 100644 --- 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 @@ -92,10 +92,10 @@ private HttpResponse createResponse(HttpRequest request) { AIProjectClientBuilder builder = new AIProjectClientBuilder().endpoint("https://localhost/api/projects/project").httpClient(httpClient); if (async) { - BetaTelemetryAsyncClient client = builder.buildBetaTelemetryAsyncClient(); + BetaTelemetryAsyncClient client = builder.beta().buildBetaTelemetryAsyncClient(); return () -> client.getApplicationInsightsConnectionString().block(); } - BetaTelemetryClient client = builder.buildBetaTelemetryClient(); + BetaTelemetryClient client = builder.beta().buildBetaTelemetryClient(); return client::getApplicationInsightsConnectionString; } } From 835193e469eb069ecc203cea01227bb4874b6814 Mon Sep 17 00:00:00 2001 From: Mike Guo Date: Fri, 18 Sep 2026 10:08:23 +0800 Subject: [PATCH 20/25] Use standard pipeline without preview features --- .../java/com/azure/ai/projects/AIProjectClientBuilder.java | 3 +++ 1 file changed, 3 insertions(+) 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 eb3f4e1fa98fb..4fa66d8322f26 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 @@ -352,6 +352,9 @@ private AIProjectClientImpl buildInnerClient() { private AIProjectClientImpl buildInnerClient(String previewFeatures) { this.validateClient(); + if (CoreUtils.isNullOrEmpty(previewFeatures)) { + return buildInnerClient(); + } HttpPipeline localPipeline = resolvePipeline(previewFeatures); AIProjectsServiceVersion localServiceVersion = (serviceVersion != null) ? serviceVersion : AIProjectsServiceVersion.getLatest(); From a73d9c0e5f5efdf96f3fd6b44049fefc421ad378 Mon Sep 17 00:00:00 2001 From: Mike Guo Date: Fri, 18 Sep 2026 10:38:36 +0800 Subject: [PATCH 21/25] Preserve Projects preview error handling --- .../src/main/java/ProjectsCustomizations.java | 44 +++++++++++++++++++ .../ai/projects/AIProjectClientBuilder.java | 18 +++++++- 2 files changed, 60 insertions(+), 2 deletions(-) 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 8bcaeeba0a9e9..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 @@ -2,6 +2,7 @@ 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; @@ -12,6 +13,8 @@ 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; @@ -40,6 +43,47 @@ 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() 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 4fa66d8322f26..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 @@ -351,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, From 0fcc563666c8981d3bf80ae18c8fb6e02de191ea Mon Sep 17 00:00:00 2001 From: Mike Guo Date: Fri, 18 Sep 2026 15:22:55 +0800 Subject: [PATCH 22/25] Address voice agent review feedback --- sdk/ai/azure-ai-agents/CHANGELOG.md | 3 +- sdk/ai/azure-ai-agents/README.md | 8 +- .../azure/ai/agents/AgentsClientBuilder.java | 21 +++-- .../BetaVoiceAgentWebSocketAsyncClient.java | 9 +- .../agents/BetaVoiceAgentWebSocketClient.java | 9 +- ...VoiceAgentWebSocketSessionAsyncClient.java | 3 +- .../BetaVoiceAgentWebSocketSessionClient.java | 1 + .../realtime}/VoiceAgentWebSocketUtils.java | 27 +++--- .../agents/models/RawRealtimeServerEvent.java | 2 +- .../VoiceAgentWebSocketConnectionOptions.java | 33 ++++++- .../VoiceAgentWebSocketOverflowStrategy.java | 2 +- .../com/azure/ai/agents/ReadmeSamples.java | 2 +- ...AgentLiveAudioConversationAsyncSample.java | 2 +- .../VoiceAgentLiveFunctionToolSample.java | 2 +- ...eAgentLiveTextConversationAsyncSample.java | 2 +- .../VoiceAgentLiveTextConversationSample.java | 2 +- ...FoundryFeaturesHeaderVerificationTest.java | 7 +- .../VoiceAgentConversationsAsyncTests.java | 2 +- .../voice/VoiceAgentConversationsTests.java | 2 +- .../voice/VoiceAgentRealtimeLiveTests.java | 4 +- .../VoiceAgentWebSocketSessionTests.java | 91 ++++++++++--------- 21 files changed, 140 insertions(+), 94 deletions(-) rename sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/{ => implementation/realtime}/VoiceAgentWebSocketUtils.java (88%) diff --git a/sdk/ai/azure-ai-agents/CHANGELOG.md b/sdk/ai/azure-ai-agents/CHANGELOG.md index 912e022f5bd04..6cf921497316e 100644 --- a/sdk/ai/azure-ai-agents/CHANGELOG.md +++ b/sdk/ai/azure-ai-agents/CHANGELOG.md @@ -42,7 +42,8 @@ - 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 automatically send agent preview features, including model router controls, and use an overridable API-version query parameter. +- 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. diff --git a/sdk/ai/azure-ai-agents/README.md b/sdk/ai/azure-ai-agents/README.md index a05ca9f3c724e..58903f28801dc 100644 --- a/sdk/ai/azure-ai-agents/README.md +++ b/sdk/ai/azure-ai-agents/README.md @@ -117,7 +117,7 @@ ResponseService responseService = responsesClient.getResponseService(); ConversationService conversationService = openAIClient.conversations(); ``` -Agent-scoped OpenAI clients automatically opt in to agent preview features, independently of `allowPreview`, +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 @@ -1051,7 +1051,7 @@ VoiceAgentWebSocketConnectionOptions options .setReceiveBufferCapacity(512) .setMaxMessageSize(8 * 1024 * 1024) .setOverflowStrategy(VoiceAgentWebSocketOverflowStrategy.ERROR); -try (BetaVoiceAgentWebSocketSessionClient session = realtimeClient.connect(agentName, options)) { +try (BetaVoiceAgentWebSocketSessionClient session = realtimeClient.openWebSocketSession(agentName, options)) { session.sendEvent(BinaryData.fromString( "{\"type\":\"response.create\",\"event_id\":\"response-1\"}")); for (RealtimeServerEvent event : session.receiveEvents()) { @@ -1072,7 +1072,7 @@ Use `close(code, reason)` or asynchronous `closeAsync(code, reason)` to send a c fit in 123 UTF-8 bytes and close codes must be valid WebSocket codes. The first asynchronous close request wins. ```java -try (BetaVoiceAgentWebSocketSessionClient session = realtimeClient.connect(agentName)) { +try (BetaVoiceAgentWebSocketSessionClient session = realtimeClient.openWebSocketSession(agentName)) { session.sendText("Hello! Tell me about the services you provide."); session.createResponse(); @@ -1098,7 +1098,7 @@ The asynchronous client returns a `Mono` when connecting and a `Flux session.sendText("Hello! Tell me about the services you provide.") .then(session.createResponse()) .thenMany(session.receiveEvents()) 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 4c96a686846db..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 @@ -516,13 +516,14 @@ public OpenAIClient buildAgentScopedOpenAIClient(String agentName) { } // 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 with agent preview features and explicitly combine both user agents. + // the setup to install the Azure transport and explicitly combine both user agents. return getOpenAIClientBuilder(agentName).build() - .withOptions(optionBuilder -> configureOpenAIOptions(optionBuilder, AGENT_PREVIEW_FEATURES)); + .withOptions( + optionBuilder -> configureOpenAIOptions(optionBuilder, allowPreview ? AGENT_PREVIEW_FEATURES : null)); } /** - * Builds an agent-scoped OpenAI client with preview headers and caller overrides. + * 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)}. @@ -575,7 +576,7 @@ public OpenAIClientAsync buildAgentScopedOpenAIAsyncClient(String agentName) { throw LOGGER.logExceptionAsError(new IllegalArgumentException("'agentName' cannot be empty.")); } // Use the shared async helper to fix the previous blocking authentication path. It performs three ordered - // steps: (1) installs the Azure transport, agent preview features, and combined user-agent telemetry; + // 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 -> { @@ -583,7 +584,7 @@ public OpenAIClientAsync buildAgentScopedOpenAIAsyncClient(String agentName) { } /** - * Builds an asynchronous agent-scoped OpenAI client with preview headers and caller overrides. + * Builds an asynchronous agent-scoped OpenAI client with caller overrides. * * Supply custom transports through this callback so asynchronous Azure authentication remains installed. * @@ -605,7 +606,7 @@ private OpenAIClientAsync createOpenAIAsyncClient(String agentName, 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); + configureOpenAIOptions(options, agentName != null && allowPreview ? AGENT_PREVIEW_FEATURES : null); configure.accept(options); authentication.configure(options); }); @@ -642,7 +643,9 @@ private OpenAIOkHttpClient.Builder getOpenAIClientBuilder(String agentName) { builder.baseUrl(getDefaultBaseUrl()); } else { builder.baseUrl(getAgentEndpointBaseUrl(agentName)); - builder.putHeader("Foundry-Features", AGENT_PREVIEW_FEATURES); + 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 @@ -673,7 +676,9 @@ private OpenAIOkHttpClientAsync.Builder getOpenAIAsyncClientBuilder(String agent builder.baseUrl(getDefaultBaseUrl()); } else { builder.baseUrl(getAgentEndpointBaseUrl(agentName)); - builder.putHeader("Foundry-Features", AGENT_PREVIEW_FEATURES); + 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 diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketAsyncClient.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketAsyncClient.java index 7b6dccf82165c..4b5ed49dece92 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketAsyncClient.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketAsyncClient.java @@ -29,8 +29,8 @@ public final class BetaVoiceAgentWebSocketAsyncClient { * @param agentName the voice agent name. * @return a connected session. */ - public Mono connect(String agentName) { - return connect(agentName, new VoiceAgentWebSocketConnectionOptions()); + public Mono openWebSocketSession(String agentName) { + return openWebSocketSession(agentName, new VoiceAgentWebSocketConnectionOptions()); } /** @@ -40,13 +40,14 @@ public Mono connect(String agentName) * @param options connection options. * @return a connected session. */ - public Mono connect(String agentName, + public Mono openWebSocketSession(String agentName, VoiceAgentWebSocketConnectionOptions options) { Objects.requireNonNull(agentName, "'agentName' cannot be null."); Objects.requireNonNull(options, "'options' cannot be null."); + VoiceAgentWebSocketConnectionOptions optionsSnapshot = new VoiceAgentWebSocketConnectionOptions(options); return Mono.defer(() -> { BetaVoiceAgentWebSocketSessionAsyncClient session - = new BetaVoiceAgentWebSocketSessionAsyncClient(configuration, agentName, options); + = new BetaVoiceAgentWebSocketSessionAsyncClient(configuration, agentName, optionsSnapshot); return session.connect().thenReturn(session); }); } diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketClient.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketClient.java index 2b10fa0e97c92..232c956f84313 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketClient.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketClient.java @@ -30,8 +30,8 @@ public final class BetaVoiceAgentWebSocketClient { * @param agentName the voice agent name. * @return a connected session. */ - public BetaVoiceAgentWebSocketSessionClient connect(String agentName) { - return connect(agentName, new VoiceAgentWebSocketConnectionOptions()); + public BetaVoiceAgentWebSocketSessionClient openWebSocketSession(String agentName) { + return openWebSocketSession(agentName, new VoiceAgentWebSocketConnectionOptions()); } /** @@ -42,13 +42,14 @@ public BetaVoiceAgentWebSocketSessionClient connect(String agentName) { * @throws IllegalArgumentException if {@code agentName} is empty. * @return a connected session. */ - public BetaVoiceAgentWebSocketSessionClient connect(String agentName, + public BetaVoiceAgentWebSocketSessionClient openWebSocketSession(String agentName, VoiceAgentWebSocketConnectionOptions options) { Objects.requireNonNull(agentName, "'agentName' cannot be null."); if (agentName.isEmpty()) { throw LOGGER.logExceptionAsError(new IllegalArgumentException("'agentName' cannot be empty.")); } Objects.requireNonNull(options, "'options' cannot be null."); - return BetaVoiceAgentWebSocketSessionClient.connect(configuration, agentName, options); + return BetaVoiceAgentWebSocketSessionClient.connect(configuration, agentName, + new VoiceAgentWebSocketConnectionOptions(options)); } } diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketSessionAsyncClient.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketSessionAsyncClient.java index e9170ef501e86..2846393be7321 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketSessionAsyncClient.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketSessionAsyncClient.java @@ -6,6 +6,7 @@ import com.azure.ai.agents.implementation.realtime.VoiceAgentWebSocketClientConfiguration; import com.azure.ai.agents.implementation.realtime.VoiceAgentWebSocketHandshakeHandler; import com.azure.ai.agents.implementation.realtime.VoiceAgentWebSocketHttpResponse; +import com.azure.ai.agents.implementation.realtime.VoiceAgentWebSocketUtils; import com.azure.ai.agents.implementation.utils.Beta; import com.azure.ai.agents.models.RealtimeClientEvent; import com.azure.ai.agents.models.RealtimeConversationItemCreateEvent; @@ -64,7 +65,7 @@ /** * An asynchronous bidirectional realtime session connected to a Foundry voice agent. * - *

Instances are created by {@link BetaVoiceAgentWebSocketAsyncClient#connect(String)}. A session supports one + *

Instances are created by {@link BetaVoiceAgentWebSocketAsyncClient#openWebSocketSession(String)}. A session supports one * subscriber to {@link #receiveEvents()}. Close the session when it is no longer needed.

*/ @Beta(warningText = "This class is in preview and may change in future releases.") diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketSessionClient.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketSessionClient.java index be800bda66c63..b253413a7750b 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketSessionClient.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketSessionClient.java @@ -5,6 +5,7 @@ import com.azure.ai.agents.implementation.realtime.VoiceAgentWebSocketClientConfiguration; import com.azure.ai.agents.implementation.realtime.VoiceAgentWebSocketHttpResponse; +import com.azure.ai.agents.implementation.realtime.VoiceAgentWebSocketUtils; import com.azure.ai.agents.implementation.utils.Beta; import com.azure.ai.agents.models.RealtimeClientEvent; import com.azure.ai.agents.models.RealtimeConversationItemCreateEvent; diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketUtils.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/realtime/VoiceAgentWebSocketUtils.java similarity index 88% rename from sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketUtils.java rename to sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/realtime/VoiceAgentWebSocketUtils.java index e68c053ed747f..ea921f92d37c2 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/VoiceAgentWebSocketUtils.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/implementation/realtime/VoiceAgentWebSocketUtils.java @@ -1,9 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -package com.azure.ai.agents; +package com.azure.ai.agents.implementation.realtime; -import com.azure.ai.agents.implementation.realtime.VoiceAgentWebSocketClientConfiguration; import com.azure.ai.agents.models.RawRealtimeServerEvent; import com.azure.ai.agents.models.RealtimeServerEvent; import com.azure.ai.agents.models.VoiceAgentTransport; @@ -28,8 +27,8 @@ import java.util.Locale; import java.util.Objects; -final class VoiceAgentWebSocketUtils { - static String decodeEvent(byte[] bytes) throws CharacterCodingException { +public final class VoiceAgentWebSocketUtils { + public static String decodeEvent(byte[] bytes) throws CharacterCodingException { return StandardCharsets.UTF_8.newDecoder() .onMalformedInput(CodingErrorAction.REPORT) .onUnmappableCharacter(CodingErrorAction.REPORT) @@ -37,7 +36,7 @@ static String decodeEvent(byte[] bytes) throws CharacterCodingException { .toString(); } - static String validateEvent(BinaryData event) { + public static String validateEvent(BinaryData event) { String json = Objects.requireNonNull(event, "'event' cannot be null.").toString(); try (JsonReader reader = JsonProviders.createReader(json)) { if (reader.nextToken() != JsonToken.START_OBJECT) { @@ -53,7 +52,7 @@ static String validateEvent(BinaryData event) { } } - static RealtimeServerEvent deserializeEvent(String json) throws IOException { + public static RealtimeServerEvent deserializeEvent(String json) throws IOException { validateEvent(BinaryData.fromString(json)); RawRealtimeServerEvent raw = new RawRealtimeServerEvent(BinaryData.fromString(json)); if (raw.getType() == null) { @@ -65,15 +64,13 @@ static RealtimeServerEvent deserializeEvent(String json) throws IOException { } } - static final String TOKEN_SCOPE = "https://ai.azure.com/.default"; - static final String PREVIEW_FEATURE = "VoiceAgents=V1Preview"; - static final String SUBPROTOCOL = "realtime"; - static final int INBOUND_CAPACITY = 256; + private static final String TOKEN_SCOPE = "https://ai.azure.com/.default"; + public static final String SUBPROTOCOL = "realtime"; private VoiceAgentWebSocketUtils() { } - static void validateClose(int code, String reason) { + public static void validateClose(int code, String reason) { if (code < 1000 || code >= 5000 || code == 1004 @@ -87,7 +84,7 @@ static void validateClose(int code, String reason) { } } - static boolean isProtectedHeader(String name) { + private static boolean isProtectedHeader(String name) { String lower = name.toLowerCase(Locale.ROOT); return "authorization".equals(lower) || "host".equals(lower) @@ -97,7 +94,7 @@ static boolean isProtectedHeader(String name) { || lower.startsWith("sec-websocket-"); } - static URI buildWebSocketUri(VoiceAgentWebSocketClientConfiguration configuration, String agentName, + public static URI buildWebSocketUri(VoiceAgentWebSocketClientConfiguration configuration, String agentName, VoiceAgentWebSocketConnectionOptions options) { URI endpoint = configuration.getEndpoint(); String scheme; @@ -150,13 +147,13 @@ static URI buildWebSocketUri(VoiceAgentWebSocketClientConfiguration configuratio return URI.create(url.toString()); } - static TokenRequestContext createTokenRequestContext(VoiceAgentWebSocketConnectionOptions options) { + public static TokenRequestContext createTokenRequestContext(VoiceAgentWebSocketConnectionOptions options) { return options.getCredentialScopes() == null || options.getCredentialScopes().isEmpty() ? new TokenRequestContext().addScopes(TOKEN_SCOPE) : new TokenRequestContext().setScopes(options.getCredentialScopes()); } - static HttpHeaders buildHeaders(VoiceAgentWebSocketClientConfiguration configuration, + public static HttpHeaders buildHeaders(VoiceAgentWebSocketClientConfiguration configuration, VoiceAgentWebSocketConnectionOptions options, String token) { HttpHeaders headers = new HttpHeaders().set(HttpHeaderName.USER_AGENT, configuration.getUserAgent()); if (configuration.getHeaders() != null) { diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/RawRealtimeServerEvent.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/RawRealtimeServerEvent.java index 27bb2b7ce46d2..47e4dd5af1ad5 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/RawRealtimeServerEvent.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/RawRealtimeServerEvent.java @@ -12,7 +12,7 @@ import java.util.Objects; /** A server event whose complete JSON payload is retained for forward compatibility. */ -@Beta(warningText = "This class is in preview and may change in future releases.") +@Beta(warningText = "Preview API. VoiceAgents=V1Preview") public final class RawRealtimeServerEvent extends RealtimeServerEvent { private final BinaryData rawEvent; private final RealtimeServerEventType type; diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/VoiceAgentWebSocketConnectionOptions.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/VoiceAgentWebSocketConnectionOptions.java index 3ab514c25a851..766b045890fa5 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/VoiceAgentWebSocketConnectionOptions.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/VoiceAgentWebSocketConnectionOptions.java @@ -22,7 +22,7 @@ /** * Options used when opening a realtime voice-agent WebSocket session. */ -@Beta(warningText = "This class is in preview and may change in future releases.") +@Beta(warningText = "Preview API. VoiceAgents=V1Preview") @Fluent public final class VoiceAgentWebSocketConnectionOptions { private static final ClientLogger LOGGER = new ClientLogger(VoiceAgentWebSocketConnectionOptions.class); @@ -176,6 +176,37 @@ public UnaryOperator getAsyncHttpClientConfiguration() { public VoiceAgentWebSocketConnectionOptions() { } + /** + * Creates a copy of the supplied connection options. + * + * @param source the options to copy. + * @throws NullPointerException if {@code source} is null. + */ + public VoiceAgentWebSocketConnectionOptions(VoiceAgentWebSocketConnectionOptions source) { + Objects.requireNonNull(source, "'source' cannot be null."); + this.receiveBufferCapacity = source.receiveBufferCapacity; + this.maxMessageSize = source.maxMessageSize; + this.overflowStrategy = source.overflowStrategy; + this.malformedEventHandler = source.malformedEventHandler; + this.httpClientConfiguration = source.httpClientConfiguration; + this.asyncHttpClientConfiguration = source.asyncHttpClientConfiguration; + this.transport = source.transport; + this.store = source.store; + this.agentVersionOverride = source.agentVersionOverride; + this.handshakeTimeout = source.handshakeTimeout; + this.closeTimeout = source.closeTimeout; + this.agentSessionId = source.agentSessionId; + this.structuredInputs = source.structuredInputs; + this.connectionUrl = source.connectionUrl; + this.apiVersion = source.apiVersion; + this.foundryFeatures = source.foundryFeatures; + this.credentialScopes = source.credentialScopes == null + ? null + : Collections.unmodifiableList(new ArrayList<>(source.credentialScopes)); + this.extraQuery = Collections.unmodifiableMap(new LinkedHashMap<>(source.extraQuery)); + this.extraHeaders = Collections.unmodifiableMap(new LinkedHashMap<>(source.extraHeaders)); + } + /** * Gets the session correlation identifier. * @return the session identifier, or null. diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/VoiceAgentWebSocketOverflowStrategy.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/VoiceAgentWebSocketOverflowStrategy.java index 224a1353c5b1d..39f3dc0acb619 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/VoiceAgentWebSocketOverflowStrategy.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/VoiceAgentWebSocketOverflowStrategy.java @@ -6,7 +6,7 @@ import com.azure.ai.agents.implementation.utils.Beta; /** Action taken when a voice-agent session's bounded receive queue fills. */ -@Beta(warningText = "This enum is in preview and may change in future releases.") +@Beta(warningText = "Preview API. VoiceAgents=V1Preview") public enum VoiceAgentWebSocketOverflowStrategy { /** Terminate the connection with an error. No overflow is silently ignored. */ ERROR, diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/ReadmeSamples.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/ReadmeSamples.java index 61a458472bcf0..4baeeb806c163 100644 --- a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/ReadmeSamples.java +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/ReadmeSamples.java @@ -40,7 +40,7 @@ public void realtimeForwardCompatibility(BetaVoiceAgentWebSocketClient realtimeC .setReceiveBufferCapacity(512) .setMaxMessageSize(8 * 1024 * 1024) .setOverflowStrategy(VoiceAgentWebSocketOverflowStrategy.ERROR); - try (BetaVoiceAgentWebSocketSessionClient session = realtimeClient.connect(agentName, options)) { + try (BetaVoiceAgentWebSocketSessionClient session = realtimeClient.openWebSocketSession(agentName, options)) { session.sendEvent(BinaryData.fromString( "{\"type\":\"response.create\",\"event_id\":\"response-1\"}")); for (RealtimeServerEvent event : session.receiveEvents()) { diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSample.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSample.java index ae7e5242ff1e1..2a80cc060fbae 100644 --- a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSample.java +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveAudioConversationAsyncSample.java @@ -97,7 +97,7 @@ public static void main(String[] args) { return agents.createAgentVersion(agentName, new CreateAgentVersionInput(definition.setStore(true))); }) - .then(Mono.usingWhen(realtime.connect(agentName), + .then(Mono.usingWhen(realtime.openWebSocketSession(agentName), session -> runConversation(session, conversationId), BetaVoiceAgentWebSocketSessionAsyncClient::closeAsync, (session, error) -> session.closeAsync(), diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveFunctionToolSample.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveFunctionToolSample.java index 02150e962b15f..f51801608fb3d 100644 --- a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveFunctionToolSample.java +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveFunctionToolSample.java @@ -89,7 +89,7 @@ public static void main(String[] args) { try { agents.createAgentVersion(agentName, new CreateAgentVersionInput(definition)); System.out.println("Created voice agent: " + agentName); - try (BetaVoiceAgentWebSocketSessionClient session = realtime.connect(agentName)) { + try (BetaVoiceAgentWebSocketSessionClient session = realtime.openWebSocketSession(agentName)) { ExecutorService receiver = Executors.newSingleThreadExecutor(); Future response = receiver.submit(() -> receiveResponse(session)); try { diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveTextConversationAsyncSample.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveTextConversationAsyncSample.java index 762acf65eb728..c054427a5cc3a 100644 --- a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveTextConversationAsyncSample.java +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveTextConversationAsyncSample.java @@ -78,7 +78,7 @@ public static void main(String[] args) { return agents.createAgentVersion(agentName, new CreateAgentVersionInput(definition.setStore(true))); }) - .then(Mono.usingWhen(realtime.connect(agentName), + .then(Mono.usingWhen(realtime.openWebSocketSession(agentName), session -> runConversation(session, scanner, conversationId, player), BetaVoiceAgentWebSocketSessionAsyncClient::closeAsync, (session, error) -> session.closeAsync(), diff --git a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveTextConversationSample.java b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveTextConversationSample.java index fe76519a70f8d..6b9ce05b1c00c 100644 --- a/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveTextConversationSample.java +++ b/sdk/ai/azure-ai-agents/src/samples/java/com/azure/ai/agents/voice/VoiceAgentLiveTextConversationSample.java @@ -72,7 +72,7 @@ public static void main(String[] args) { AtomicReference conversationId = new AtomicReference<>(); try (VoiceAgentRealtimeSampleUtils.SpeakerPlayer player = new VoiceAgentRealtimeSampleUtils.SpeakerPlayer(); - BetaVoiceAgentWebSocketSessionClient session = realtime.connect(agentName); + BetaVoiceAgentWebSocketSessionClient session = realtime.openWebSocketSession(agentName); Scanner scanner = new Scanner(System.in)) { AtomicReference> responseCompleted = new AtomicReference<>(); ExecutorService receiver = Executors.newSingleThreadExecutor(); 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 a7091e3acb719..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,6 +3,8 @@ 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; @@ -518,13 +520,13 @@ public void openAIAndResponsesClientsUseCustomPipeline() { } @Test - public void agentScopedOpenAIClientUsesCustomPipelineAndPreviewHeaderByDefault() { + public void agentScopedOpenAIClientUsesCustomPipelineAndConditionalPreviewHeader() { RecordingHttpClient httpClient = newOpenAIRecordingHttpClient(); HttpPipeline customPipeline = createCustomPipeline(httpClient); createBuilder(customPipeline).buildAgentScopedOpenAIClient("agent").models().list(); assertEquals(CUSTOM_PIPELINE_VALUE, customPipelineHeader(httpClient)); - assertEquals(AGENT_PREVIEW_FEATURES, foundryFeatures(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()); @@ -648,6 +650,7 @@ public void customOpenAITransportRetainsAuthenticationAndAgentDefaults(boolean a 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 -> { diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsAsyncTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsAsyncTests.java index dd0016d65b7dd..6d684658132ab 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsAsyncTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsAsyncTests.java @@ -105,7 +105,7 @@ public void readLivePersistedConversation() { try { agents.createAgentVersion(agentName, new CreateAgentVersionInput(definition)).block(TIMEOUT); created = true; - Mono.usingWhen(builder.beta().buildBetaVoiceAgentWebSocketAsyncClient().connect(agentName), + Mono.usingWhen(builder.beta().buildBetaVoiceAgentWebSocketAsyncClient().openWebSocketSession(agentName), session -> session.receiveEvents().index().concatMap(indexed -> { if (indexed.getT1() == 0) { assertTrue(indexed.getT2() instanceof RealtimeSessionCreatedEvent, diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsTests.java index 126c53ec31cc6..590ed1b4cceaf 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentConversationsTests.java @@ -108,7 +108,7 @@ public void readLivePersistedConversation() throws InterruptedException { agents.createAgentVersion(agentName, new CreateAgentVersionInput(definition)); created = true; try (BetaVoiceAgentWebSocketSessionClient session - = builder.beta().buildBetaVoiceAgentWebSocketClient().connect(agentName)) { + = builder.beta().buildBetaVoiceAgentWebSocketClient().openWebSocketSession(agentName)) { Iterator events = session.receiveEvents(TIMEOUT).iterator(); assertTrue(events.hasNext(), "Expected session.created."); RealtimeServerEvent first = events.next(); diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentRealtimeLiveTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentRealtimeLiveTests.java index 2e9c42f09a4a7..9b05562145d25 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentRealtimeLiveTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentRealtimeLiveTests.java @@ -88,7 +88,7 @@ public void realtimeLive(Scenario scenario) { agents.createAgentVersion(agentName, new CreateAgentVersionInput(definition(scenario))); created = true; try (BetaVoiceAgentWebSocketSessionClient session - = builder.beta().buildBetaVoiceAgentWebSocketClient().connect(agentName)) { + = builder.beta().buildBetaVoiceAgentWebSocketClient().openWebSocketSession(agentName)) { Turn turn = new Turn(scenario); Iterator events = session.receiveEvents(EVENT_TIMEOUT).iterator(); turn.accept(events.next()).forEach(session::sendEvent); @@ -118,7 +118,7 @@ public void realtimeLiveAsync(Scenario scenario) { .block(EVENT_TIMEOUT); created = true; Turn turn = new Turn(scenario); - Mono.usingWhen(builder.beta().buildBetaVoiceAgentWebSocketAsyncClient().connect(agentName), + Mono.usingWhen(builder.beta().buildBetaVoiceAgentWebSocketAsyncClient().openWebSocketSession(agentName), session -> session.receiveEvents() .timeout(EVENT_TIMEOUT) .concatMap(event -> Flux.fromIterable(turn.accept(event)) diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentWebSocketSessionTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentWebSocketSessionTests.java index 402e4f0a7cb2d..17aba1dcb715e 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentWebSocketSessionTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentWebSocketSessionTests.java @@ -119,13 +119,13 @@ public void handshakeOverridesPreserveQueryAndSingleUserAgent(boolean async) { if (async) { BetaVoiceAgentWebSocketSessionAsyncClient session = builder.beta() .buildBetaVoiceAgentWebSocketAsyncClient() - .connect("agent name", options) + .openWebSocketSession("agent name", options) .block(Duration.ofSeconds(5)); session.closeAsync().block(Duration.ofSeconds(5)); assertFalse(session.isOpen()); } else { BetaVoiceAgentWebSocketSessionClient session - = builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent name", options); + = builder.beta().buildBetaVoiceAgentWebSocketClient().openWebSocketSession("agent name", options); session.close(); assertFalse(session.isOpen()); } @@ -165,7 +165,7 @@ public void typedStringAndMappingSendsRejectInvalidJson(boolean async) { if (async) { BetaVoiceAgentWebSocketSessionAsyncClient session = builder.beta() .buildBetaVoiceAgentWebSocketAsyncClient() - .connect("agent", tlsOptions()) + .openWebSocketSession("agent", tlsOptions()) .block(Duration.ofSeconds(5)); try { StepVerifier.create(session.sendEvent(BinaryData.fromString("not valid json"))) @@ -181,7 +181,7 @@ public void typedStringAndMappingSendsRejectInvalidJson(boolean async) { } } else { try (BetaVoiceAgentWebSocketSessionClient session - = builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent", tlsOptions())) { + = builder.beta().buildBetaVoiceAgentWebSocketClient().openWebSocketSession("agent", tlsOptions())) { assertThrows(IllegalArgumentException.class, () -> session.sendEvent(BinaryData.fromString("not valid json"))); session.sendEvent(new RealtimeResponseCreateEvent()); @@ -217,7 +217,7 @@ public void pingPongFramesAreNotApplicationEvents(boolean async) { if (async) { BetaVoiceAgentWebSocketSessionAsyncClient session = builder.beta() .buildBetaVoiceAgentWebSocketAsyncClient() - .connect("agent", tlsOptions()) + .openWebSocketSession("agent", tlsOptions()) .block(Duration.ofSeconds(5)); try { events.addAll(session.receiveEvents().take(2).collectList().block(Duration.ofSeconds(5))); @@ -226,7 +226,7 @@ public void pingPongFramesAreNotApplicationEvents(boolean async) { } } else { try (BetaVoiceAgentWebSocketSessionClient session - = builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent", tlsOptions())) { + = builder.beta().buildBetaVoiceAgentWebSocketClient().openWebSocketSession("agent", tlsOptions())) { Iterator iterator = session.receiveEvents(Duration.ofSeconds(5)).iterator(); events.add(iterator.next()); events.add(iterator.next()); @@ -250,8 +250,9 @@ public void explicitDefaultPortOverrideIsTrustedBeforeAuthentication() { VoiceAgentWebSocketConnectionOptions options = new VoiceAgentWebSocketConnectionOptions().setConnectionUrl(URI.create("wss://example.com:443/custom")); assertEquals(tokenError, assertThrows(IllegalStateException.class, - () -> builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent", options))); - StepVerifier.create(builder.beta().buildBetaVoiceAgentWebSocketAsyncClient().connect("agent", options)) + () -> builder.beta().buildBetaVoiceAgentWebSocketClient().openWebSocketSession("agent", options))); + StepVerifier + .create(builder.beta().buildBetaVoiceAgentWebSocketAsyncClient().openWebSocketSession("agent", options)) .expectErrorSatisfies(error -> assertEquals(tokenError, error)) .verify(Duration.ofSeconds(5)); assertEquals(2, tokens.get()); @@ -272,7 +273,7 @@ public void malformedEventsCanBeReportedAndSkipped(boolean async) { if (async) { BetaVoiceAgentWebSocketSessionAsyncClient session = builder.beta() .buildBetaVoiceAgentWebSocketAsyncClient() - .connect("agent", options) + .openWebSocketSession("agent", options) .block(Duration.ofSeconds(5)); StepVerifier.create(session.receiveEvents()) .assertNext(this::assertWarningEvent) @@ -281,7 +282,7 @@ public void malformedEventsCanBeReportedAndSkipped(boolean async) { session.close(); } else { try (BetaVoiceAgentWebSocketSessionClient session - = builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent", options)) { + = builder.beta().buildBetaVoiceAgentWebSocketClient().openWebSocketSession("agent", options)) { Iterator events = session.receiveEvents(Duration.ofSeconds(5)).iterator(); assertWarningEvent(events.next()); assertWarningEvent(events.next()); @@ -306,7 +307,7 @@ public void boundedQueuesHonorOverflowPolicies(boolean async) { if (async) { BetaVoiceAgentWebSocketSessionAsyncClient session = builder.beta() .buildBetaVoiceAgentWebSocketAsyncClient() - .connect("agent", options) + .openWebSocketSession("agent", options) .block(Duration.ofSeconds(5)); assertTimeoutPreemptively(Duration.ofSeconds(5), () -> { while (session.isOpen()) { @@ -324,7 +325,7 @@ public void boundedQueuesHonorOverflowPolicies(boolean async) { session.close(); } else { try (BetaVoiceAgentWebSocketSessionClient session - = builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent", options)) { + = builder.beta().buildBetaVoiceAgentWebSocketClient().openWebSocketSession("agent", options)) { assertTimeoutPreemptively(Duration.ofSeconds(5), () -> { while (session.isOpen()) { Thread.yield(); @@ -362,12 +363,12 @@ public void messageSizeLimitCannotBeBypassedByRecoveryHandler(boolean async) { if (async) { StepVerifier.create(builder.beta() .buildBetaVoiceAgentWebSocketAsyncClient() - .connect("agent", options) + .openWebSocketSession("agent", options) .flatMapMany(BetaVoiceAgentWebSocketSessionAsyncClient::receiveEvents)).expectError().verify(); } else { assertThrows(RuntimeException.class, () -> { try (BetaVoiceAgentWebSocketSessionClient session - = builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent", options)) { + = builder.beta().buildBetaVoiceAgentWebSocketClient().openWebSocketSession("agent", options)) { session.receiveEvents(Duration.ofSeconds(5)).iterator().next(); } }); @@ -383,7 +384,7 @@ public void rawEventRoundTripsAndOptionsValidateBounds() throws Exception { assertEquals(payload.toObject(Map.class), copy.getRawEvent().toObject(Map.class)); server = oneShotWebSocketServer("{\"type\":42,\"value\":1}"); BetaVoiceAgentWebSocketSessionAsyncClient session - = createAsyncClient(server.port()).connect("agent", tlsOptions()).block(Duration.ofSeconds(5)); + = createAsyncClient(server.port()).openWebSocketSession("agent", tlsOptions()).block(Duration.ofSeconds(5)); try { StepVerifier.create(session.receiveEvents()) .assertNext(received -> assertInstanceOf(RawRealtimeServerEvent.class, received)) @@ -412,8 +413,8 @@ public void insecureEndpointsAreRejectedBeforeAuthentication() { "https://example.com/#fragment" }) { AgentsClientBuilder builder = new AgentsClientBuilder().endpoint(endpoint).credential(credential); assertThrows(IllegalArgumentException.class, - () -> builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent")); - StepVerifier.create(builder.beta().buildBetaVoiceAgentWebSocketAsyncClient().connect("agent")) + () -> builder.beta().buildBetaVoiceAgentWebSocketClient().openWebSocketSession("agent")); + StepVerifier.create(builder.beta().buildBetaVoiceAgentWebSocketAsyncClient().openWebSocketSession("agent")) .expectError(IllegalArgumentException.class) .verify(); } @@ -425,8 +426,9 @@ public void insecureEndpointsAreRejectedBeforeAuthentication() { VoiceAgentWebSocketConnectionOptions options = new VoiceAgentWebSocketConnectionOptions().setConnectionUrl(URI.create(override)); assertThrows(IllegalArgumentException.class, - () -> builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent", options)); - StepVerifier.create(builder.beta().buildBetaVoiceAgentWebSocketAsyncClient().connect("agent", options)) + () -> builder.beta().buildBetaVoiceAgentWebSocketClient().openWebSocketSession("agent", options)); + StepVerifier + .create(builder.beta().buildBetaVoiceAgentWebSocketAsyncClient().openWebSocketSession("agent", options)) .expectError(IllegalArgumentException.class) .verify(); } @@ -474,7 +476,7 @@ public void rawEventsUseCustomizedTlsTransports() { .credential(request -> Mono.just(new AccessToken("test-token", OffsetDateTime.now().plusHours(1)))); BinaryData payload = BinaryData.fromString("{\"type\":\"future.event\",\"value\":42}"); try (BetaVoiceAgentWebSocketSessionClient session - = builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent", tlsOptions())) { + = builder.beta().buildBetaVoiceAgentWebSocketClient().openWebSocketSession("agent", tlsOptions())) { assertThrows(IllegalArgumentException.class, () -> session.sendEvent(BinaryData.fromString("[]"))); assertThrows(IllegalArgumentException.class, () -> session.sendEvent(BinaryData.fromString("{} {}"))); session.sendEvent(payload); @@ -484,7 +486,7 @@ public void rawEventsUseCustomizedTlsTransports() { } BetaVoiceAgentWebSocketSessionAsyncClient session = builder.beta() .buildBetaVoiceAgentWebSocketAsyncClient() - .connect("agent", tlsOptions()) + .openWebSocketSession("agent", tlsOptions()) .block(Duration.ofSeconds(5)); StepVerifier.create(session.sendEvent(BinaryData.fromString("[]"))) .expectError(IllegalArgumentException.class) @@ -506,7 +508,7 @@ public void customCloseFrameAndReceiveTimeout() { = new AgentsClientBuilder().endpoint("https://localhost:" + server.port() + "/api/projects/project") .credential(request -> Mono.just(new AccessToken("test-token", OffsetDateTime.now().plusHours(1)))); try (BetaVoiceAgentWebSocketSessionClient session - = builder.beta().buildBetaVoiceAgentWebSocketClient().connect("agent", tlsOptions())) { + = builder.beta().buildBetaVoiceAgentWebSocketClient().openWebSocketSession("agent", tlsOptions())) { Iterator iterator = session.receiveEvents(Duration.ofMillis(20)).iterator(); IllegalStateException timeout = assertThrows(IllegalStateException.class, iterator::hasNext); assertInstanceOf(TimeoutException.class, timeout.getCause()); @@ -518,7 +520,7 @@ public void customCloseFrameAndReceiveTimeout() { } BetaVoiceAgentWebSocketSessionAsyncClient session = builder.beta() .buildBetaVoiceAgentWebSocketAsyncClient() - .connect("agent", tlsOptions()) + .openWebSocketSession("agent", tlsOptions()) .block(Duration.ofSeconds(5)); assertNotNull(session); StepVerifier.create(session.closeAsync(1006, "invalid")).expectError(IllegalArgumentException.class).verify(); @@ -566,7 +568,10 @@ public void asyncSessionNegotiatesHandshakeAndExchangesTypedEvents() { .beta() .buildBetaVoiceAgentWebSocketAsyncClient(); - BetaVoiceAgentWebSocketSessionAsyncClient session = client.connect("agent name", options).block(); + Mono sessionMono + = client.openWebSocketSession("agent name", options); + options.setStoreEnabled(false).setAgentVersionOverride("mutated"); + BetaVoiceAgentWebSocketSessionAsyncClient session = sessionMono.block(); assertTrue(session.isOpen()); StepVerifier.create(session.receiveEvents().take(4)) @@ -620,11 +625,11 @@ public void syncConnectRejectsNullArguments() { .buildBetaVoiceAgentWebSocketClient(); NullPointerException agentNameException - = assertThrows(NullPointerException.class, () -> client.connect(null, tlsOptions())); + = assertThrows(NullPointerException.class, () -> client.openWebSocketSession(null, tlsOptions())); assertEquals("'agentName' cannot be null.", agentNameException.getMessage()); NullPointerException optionsException - = assertThrows(NullPointerException.class, () -> client.connect("agent", null)); + = assertThrows(NullPointerException.class, () -> client.openWebSocketSession("agent", null)); assertEquals("'options' cannot be null.", optionsException.getMessage()); } @@ -643,7 +648,7 @@ public void tokenFailureOccursBeforeNetworkAccess() { .beta() .buildBetaVoiceAgentWebSocketAsyncClient(); - StepVerifier.create(client.connect("agent", tlsOptions())) + StepVerifier.create(client.openWebSocketSession("agent", tlsOptions())) .expectErrorMatches( error -> error instanceof IllegalStateException && error.getMessage().contains("token unavailable")) .verify(); @@ -664,7 +669,7 @@ public void tokenAcquisitionDoesNotUseHandshakeTimeout() { .buildBetaVoiceAgentWebSocketAsyncClient(); VoiceAgentWebSocketConnectionOptions options = tlsOptions().setHandshakeTimeout(Duration.ofSeconds(1)); - StepVerifier.withVirtualTime(() -> client.connect("agent", options).flatMap(session -> { + StepVerifier.withVirtualTime(() -> client.openWebSocketSession("agent", options).flatMap(session -> { assertTrue(session.isOpen()); return session.closeAsync(); })).thenAwait(Duration.ofMillis(1500)).verifyComplete(); @@ -686,7 +691,7 @@ public void syncTokenFailureOccursBeforeNetworkAccess() { .buildBetaVoiceAgentWebSocketClient(); IllegalStateException exception - = assertThrows(IllegalStateException.class, () -> client.connect("agent", tlsOptions())); + = assertThrows(IllegalStateException.class, () -> client.openWebSocketSession("agent", tlsOptions())); assertTrue(exception.getMessage().contains("token unavailable")); assertFalse(connected.get()); } @@ -707,7 +712,7 @@ public void rejectedHandshakeMapsConflictToAzureException() { .beta() .buildBetaVoiceAgentWebSocketAsyncClient(); - StepVerifier.create(client.connect("disabled-agent", tlsOptions())).expectErrorSatisfies(error -> { + StepVerifier.create(client.openWebSocketSession("disabled-agent", tlsOptions())).expectErrorSatisfies(error -> { ResourceModifiedException exception = assertInstanceOf(ResourceModifiedException.class, error); assertEquals(409, exception.getResponse().getStatusCode()); }).verify(); @@ -739,8 +744,8 @@ public void syncRejectedHandshakeMapsConflictToAzureException() { .beta() .buildBetaVoiceAgentWebSocketClient(); - ResourceModifiedException exception - = assertThrows(ResourceModifiedException.class, () -> client.connect("disabled-agent", tlsOptions())); + ResourceModifiedException exception = assertThrows(ResourceModifiedException.class, + () -> client.openWebSocketSession("disabled-agent", tlsOptions())); assertEquals(409, exception.getResponse().getStatusCode()); assertEquals("conflict", exception.getResponse().getBodyAsString().block()); } @@ -756,7 +761,7 @@ public void syncClientRejectsEmptyAgentName() { .buildBetaVoiceAgentWebSocketClient(); IllegalArgumentException exception - = assertThrows(IllegalArgumentException.class, () -> client.connect("", tlsOptions())); + = assertThrows(IllegalArgumentException.class, () -> client.openWebSocketSession("", tlsOptions())); assertEquals("'agentName' cannot be empty.", exception.getMessage()); } @@ -771,7 +776,7 @@ public void cancellingAsyncConnectCancelsTokenRequest() { .beta() .buildBetaVoiceAgentWebSocketAsyncClient(); - StepVerifier.create(client.connect("agent", tlsOptions())).thenCancel().verify(); + StepVerifier.create(client.openWebSocketSession("agent", tlsOptions())).thenCancel().verify(); assertTrue(tokenRequestCancelled.get()); } @@ -779,7 +784,7 @@ public void cancellingAsyncConnectCancelsTokenRequest() { public void unknownEventFallsBackToRealtimeServerEvent() { server = oneShotWebSocketServer("{\"type\":\"future.event\",\"value\":42}"); BetaVoiceAgentWebSocketSessionAsyncClient session - = createAsyncClient(server.port()).connect("agent", tlsOptions()).block(); + = createAsyncClient(server.port()).openWebSocketSession("agent", tlsOptions()).block(); StepVerifier.create(session.receiveEvents()).assertNext(event -> { assertEquals("future.event", event.getType().toString()); @@ -797,7 +802,7 @@ public void fragmentedTextFrameIsAggregated() { new ContinuationWebSocketFrame(true, 0, message.substring(split))); server = frameWebSocketServer(frames); BetaVoiceAgentWebSocketSessionAsyncClient session - = createAsyncClient(server.port()).connect("agent", tlsOptions()).block(); + = createAsyncClient(server.port()).openWebSocketSession("agent", tlsOptions()).block(); StepVerifier.create(session.receiveEvents()).assertNext(this::assertWarningEvent).verifyComplete(); session.close(); @@ -808,7 +813,7 @@ public void binaryJsonFrameIsParsed() { server = frameWebSocketServer( Mono.just(new BinaryWebSocketFrame(Unpooled.copiedBuffer(warningJson(), StandardCharsets.UTF_8)))); BetaVoiceAgentWebSocketSessionAsyncClient session - = createAsyncClient(server.port()).connect("agent", tlsOptions()).block(); + = createAsyncClient(server.port()).openWebSocketSession("agent", tlsOptions()).block(); StepVerifier.create(session.receiveEvents()).assertNext(this::assertWarningEvent).verifyComplete(); session.close(); @@ -818,7 +823,7 @@ public void binaryJsonFrameIsParsed() { public void malformedJsonTerminatesReceiveStream() { server = oneShotWebSocketServer("{not-json"); BetaVoiceAgentWebSocketSessionAsyncClient session - = createAsyncClient(server.port()).connect("agent", tlsOptions()).block(); + = createAsyncClient(server.port()).openWebSocketSession("agent", tlsOptions()).block(); StepVerifier.create(session.receiveEvents()).expectError().verify(); assertFalse(session.isOpen()); @@ -837,7 +842,7 @@ public void syncReceiveBufferOverflowFailsTheEventStream() { .beta() .buildBetaVoiceAgentWebSocketClient(); - try (BetaVoiceAgentWebSocketSessionClient session = client.connect("agent", tlsOptions())) { + try (BetaVoiceAgentWebSocketSessionClient session = client.openWebSocketSession("agent", tlsOptions())) { assertTimeoutPreemptively(Duration.ofSeconds(5), () -> { while (session.isOpen()) { Thread.yield(); @@ -862,7 +867,7 @@ public void syncOrderlyClosePreservesFullReceiveBuffer() { .beta() .buildBetaVoiceAgentWebSocketClient(); - try (BetaVoiceAgentWebSocketSessionClient session = client.connect("agent", tlsOptions())) { + try (BetaVoiceAgentWebSocketSessionClient session = client.openWebSocketSession("agent", tlsOptions())) { assertTimeoutPreemptively(Duration.ofSeconds(5), () -> { Iterator events = session.receiveEvents().iterator(); int eventCount = 0; @@ -881,7 +886,7 @@ public void closeIsIdempotentAndSendAfterCloseFails() { server = startServer(clientMessages, new AtomicReference<>(), new AtomicReference<>(), new AtomicReference<>(), new AtomicReference<>(), new AtomicReference<>(), false); BetaVoiceAgentWebSocketSessionAsyncClient session - = createAsyncClient(server.port()).connect("agent", tlsOptions()).block(); + = createAsyncClient(server.port()).openWebSocketSession("agent", tlsOptions()).block(); StepVerifier.create(session.closeAsync().then(session.closeAsync())).verifyComplete(); StepVerifier.create(session.sendText("after close")) @@ -910,7 +915,7 @@ public void secureSessionUsesWssAndReceivesTypedEvent() throws Exception { .credential(credential) .beta() .buildBetaVoiceAgentWebSocketAsyncClient() - .connect("secure-agent", tlsOptions()) + .openWebSocketSession("secure-agent", tlsOptions()) .block(Duration.ofSeconds(5)); assertEquals("wss", session.getEndpoint().getScheme()); @@ -936,7 +941,7 @@ public void syncSessionReceivesTypedEventAndCloses() { .beta() .buildBetaVoiceAgentWebSocketClient(); - try (BetaVoiceAgentWebSocketSessionClient session = client.connect("sync-agent", tlsOptions())) { + try (BetaVoiceAgentWebSocketSessionClient session = client.openWebSocketSession("sync-agent", tlsOptions())) { Iterator events = session.receiveEvents().iterator(); assertWarningEvent(events.next()); session.sendFunctionCallOutput("call-1", "{\"temperature\":72}"); From eec49fe390a9041719b7be1ae19ee74748e6c9ce Mon Sep 17 00:00:00 2001 From: Mike Guo Date: Fri, 18 Sep 2026 18:07:51 +0800 Subject: [PATCH 23/25] Remove native WebSocket transport configuration --- sdk/ai/azure-ai-agents/README.md | 4 - ...VoiceAgentWebSocketSessionAsyncClient.java | 6 +- .../BetaVoiceAgentWebSocketSessionClient.java | 3 - .../VoiceAgentWebSocketConnectionOptions.java | 47 ----------- .../src/main/java/module-info.java | 4 +- .../VoiceAgentWebSocketSessionTests.java | 82 ++++++++++++------- 6 files changed, 57 insertions(+), 89 deletions(-) diff --git a/sdk/ai/azure-ai-agents/README.md b/sdk/ai/azure-ai-agents/README.md index 58903f28801dc..2a2e5e203b9b4 100644 --- a/sdk/ai/azure-ai-agents/README.md +++ b/sdk/ai/azure-ai-agents/README.md @@ -1041,10 +1041,6 @@ Configure `VoiceAgentWebSocketConnectionOptions` before connecting and do not mo The sync transport checks size after receiving a complete message; this does not bound the transport's allocation. - Malformed JSON or invalid UTF-8 terminates reception by default. Set `setMalformedEventHandler` to report and skip malformed events while continuing reception. This callback must not block; throwing from it terminates the session. -- `setHttpClientConfiguration` customizes the sync OkHttp builder, including TLS trust and keepalive. Use - `setAsyncHttpClientConfiguration` for the async Reactor Netty transport. Authentication headers, subprotocol, redirects, - and handshake timeout remain SDK-controlled. Keep TLS certificate and hostname verification enabled. - ```java com.azure.ai.agents.realtime_forward_compatibility VoiceAgentWebSocketConnectionOptions options = new VoiceAgentWebSocketConnectionOptions() diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketSessionAsyncClient.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketSessionAsyncClient.java index 2846393be7321..307768bbe9151 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketSessionAsyncClient.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketSessionAsyncClient.java @@ -416,11 +416,7 @@ public void close() { } private Mono openWebSocket(String token) { - HttpClient configured = options.getAsyncHttpClientConfiguration() == null - ? httpClient - : Objects.requireNonNull(options.getAsyncHttpClientConfiguration().apply(httpClient), - "Configured transport cannot be null."); - HttpClient client = configureProxy(configured).followRedirect(false) + HttpClient client = configureProxy(httpClient).followRedirect(false) .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, toConnectTimeoutMillis(options.getHandshakeTimeout())) .doOnConnected(connection -> connection.addHandlerLast("voiceAgentHandshakeResponseObserver", new VoiceAgentWebSocketHandshakeHandler(this::terminateWithError))) diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketSessionClient.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketSessionClient.java index b253413a7750b..525217e2249a6 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketSessionClient.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketSessionClient.java @@ -448,9 +448,6 @@ private void shutdownHttpClient() { private static OkHttpClient createHttpClient(VoiceAgentWebSocketClientConfiguration configuration, VoiceAgentWebSocketConnectionOptions options) { OkHttpClient.Builder builder = new OkHttpClient.Builder(); - if (options.getHttpClientConfiguration() != null) { - options.getHttpClientConfiguration().accept(builder); - } builder.connectTimeout(options.getHandshakeTimeout().toMillis(), TimeUnit.MILLISECONDS) .readTimeout(0, TimeUnit.MILLISECONDS) .followRedirects(false); diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/VoiceAgentWebSocketConnectionOptions.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/VoiceAgentWebSocketConnectionOptions.java index 766b045890fa5..23d29798891b7 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/VoiceAgentWebSocketConnectionOptions.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/models/VoiceAgentWebSocketConnectionOptions.java @@ -15,9 +15,6 @@ import java.util.Map; import java.util.Objects; import java.util.function.Consumer; -import java.util.function.UnaryOperator; -import okhttp3.OkHttpClient; -import reactor.netty.http.client.HttpClient; /** * Options used when opening a realtime voice-agent WebSocket session. @@ -114,48 +111,6 @@ public VoiceAgentWebSocketConnectionOptions setMalformedEventHandler(Consumer httpClientConfiguration; - private UnaryOperator asyncHttpClientConfiguration; - - /** - * Sets synchronous transport customization, for example certificate trust or ping interval. - * Redirects and handshake timeouts remain SDK-controlled. Do not disable TLS hostname verification. - * @param configure callback applied to the per-session transport, or null for defaults. - * @return this options instance. - */ - public VoiceAgentWebSocketConnectionOptions setHttpClientConfiguration(Consumer configure) { - this.httpClientConfiguration = configure; - return this; - } - - /** - * Gets synchronous transport customization. - * @return the callback, or null. - */ - public Consumer getHttpClientConfiguration() { - return httpClientConfiguration; - } - - /** - * Sets asynchronous transport customization, for example certificate trust or channel handlers. - * Redirects, authentication, subprotocol and handshake timeouts remain SDK-controlled. - * Do not disable TLS hostname verification. - * @param configure callback returning a configured transport, or null for defaults. - * @return this options instance. - */ - public VoiceAgentWebSocketConnectionOptions setAsyncHttpClientConfiguration(UnaryOperator configure) { - this.asyncHttpClientConfiguration = configure; - return this; - } - - /** - * Gets asynchronous transport customization. - * @return the callback, or null. - */ - public UnaryOperator getAsyncHttpClientConfiguration() { - return asyncHttpClientConfiguration; - } - private VoiceAgentTransport transport = VoiceAgentTransport.WEBSOCKET; private Boolean store; private String agentVersionOverride; @@ -188,8 +143,6 @@ public VoiceAgentWebSocketConnectionOptions(VoiceAgentWebSocketConnectionOptions this.maxMessageSize = source.maxMessageSize; this.overflowStrategy = source.overflowStrategy; this.malformedEventHandler = source.malformedEventHandler; - this.httpClientConfiguration = source.httpClientConfiguration; - this.asyncHttpClientConfiguration = source.asyncHttpClientConfiguration; this.transport = source.transport; this.store = source.store; this.agentVersionOverride = source.agentVersionOverride; diff --git a/sdk/ai/azure-ai-agents/src/main/java/module-info.java b/sdk/ai/azure-ai-agents/src/main/java/module-info.java index 273961fce85b5..06498120ce9ef 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/module-info.java +++ b/sdk/ai/azure-ai-agents/src/main/java/module-info.java @@ -6,14 +6,14 @@ requires transitive com.azure.core; requires transitive openai.java.core; requires transitive openai.java.client.okhttp; - requires transitive reactor.netty.http; + requires reactor.netty.http; requires reactor.netty.core; requires io.netty.codec.http; requires io.netty.transport; requires io.netty.common; requires io.netty.codec; requires io.netty.buffer; - requires transitive okhttp3; + requires okhttp3; requires okio; exports com.azure.ai.agents; diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentWebSocketSessionTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentWebSocketSessionTests.java index 17aba1dcb715e..b6d8e4ec31c3e 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentWebSocketSessionTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentWebSocketSessionTests.java @@ -43,7 +43,6 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.security.KeyStore; -import java.security.cert.X509Certificate; import java.time.Duration; import java.time.OffsetDateTime; import java.util.ArrayList; @@ -60,9 +59,7 @@ import java.util.concurrent.atomic.AtomicReference; import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; -import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; -import javax.net.ssl.X509TrustManager; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -85,8 +82,19 @@ import static org.junit.jupiter.api.Assertions.assertTrue; public class VoiceAgentWebSocketSessionTests { + private static final String TRUST_STORE_PROPERTY = "javax.net.ssl.trustStore"; + private static final String TRUST_STORE_PASSWORD_PROPERTY = "javax.net.ssl.trustStorePassword"; + private static final String TRUST_STORE_TYPE_PROPERTY = "javax.net.ssl.trustStoreType"; + private static final String ORIGINAL_TRUST_STORE = System.getProperty(TRUST_STORE_PROPERTY); + private static final String ORIGINAL_TRUST_STORE_PASSWORD = System.getProperty(TRUST_STORE_PASSWORD_PROPERTY); + private static final String ORIGINAL_TRUST_STORE_TYPE = System.getProperty(TRUST_STORE_TYPE_PROPERTY); + private static final SSLContext ORIGINAL_SSL_CONTEXT = getDefaultSslContext(); private static final TestCertificate TLS_CERTIFICATE = TestCertificate.create(); + static { + TLS_CERTIFICATE.installTrustStore(); + } + private DisposableServer server; @ParameterizedTest @@ -441,26 +449,7 @@ private static HttpServer tlsServer() { } private static VoiceAgentWebSocketConnectionOptions tlsOptions() { - return new VoiceAgentWebSocketConnectionOptions() - .setAsyncHttpClientConfiguration( - client -> client.secure(ssl -> ssl.sslContext(Http11SslContextSpec.forClient() - .configure(builder -> builder.trustManager(TLS_CERTIFICATE.certificate))))) - .setHttpClientConfiguration(builder -> { - try { - KeyStore store = KeyStore.getInstance(KeyStore.getDefaultType()); - store.load(null, null); - store.setCertificateEntry("localhost", TLS_CERTIFICATE.certificate); - TrustManagerFactory factory - = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - factory.init(store); - X509TrustManager trust = (X509TrustManager) factory.getTrustManagers()[0]; - SSLContext context = SSLContext.getInstance("TLS"); - context.init(null, new TrustManager[] { trust }, null); - builder.sslSocketFactory(context.getSocketFactory(), trust); - } catch (Exception error) { - throw new IllegalStateException(error); - } - }); + return new VoiceAgentWebSocketConnectionOptions(); } @Test @@ -538,6 +527,10 @@ public void disposeServer() { @AfterAll public static void deleteTlsCertificate() { + SSLContext.setDefault(ORIGINAL_SSL_CONTEXT); + restoreProperty(TRUST_STORE_PROPERTY, ORIGINAL_TRUST_STORE); + restoreProperty(TRUST_STORE_PASSWORD_PROPERTY, ORIGINAL_TRUST_STORE_PASSWORD); + restoreProperty(TRUST_STORE_TYPE_PROPERTY, ORIGINAL_TRUST_STORE_TYPE); TLS_CERTIFICATE.delete(); } @@ -1026,13 +1019,15 @@ private static String warningJson() { private static final class TestCertificate { private final Path path; + private final String password; + private final KeyStore keyStore; private final KeyManagerFactory keyManagerFactory; - private final X509Certificate certificate; - private TestCertificate(Path path, KeyManagerFactory keyManagerFactory, X509Certificate certificate) { + private TestCertificate(Path path, String password, KeyStore keyStore, KeyManagerFactory keyManagerFactory) { this.path = path; + this.password = password; + this.keyStore = keyStore; this.keyManagerFactory = keyManagerFactory; - this.certificate = certificate; } private static TestCertificate create() { @@ -1059,8 +1054,23 @@ private static TestCertificate create() { KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); keyManagerFactory.init(store, password.toCharArray()); - return new TestCertificate(path, keyManagerFactory, - (X509Certificate) store.getCertificate("localhost")); + return new TestCertificate(path, password, store, keyManagerFactory); + } catch (Exception error) { + throw new IllegalStateException(error); + } + } + + private void installTrustStore() { + try { + System.setProperty(TRUST_STORE_PROPERTY, path.toString()); + System.setProperty(TRUST_STORE_PASSWORD_PROPERTY, password); + System.setProperty(TRUST_STORE_TYPE_PROPERTY, "PKCS12"); + TrustManagerFactory trustManagerFactory + = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init(keyStore); + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, trustManagerFactory.getTrustManagers(), null); + SSLContext.setDefault(sslContext); } catch (Exception error) { throw new IllegalStateException(error); } @@ -1075,6 +1085,22 @@ private void delete() { } } + private static SSLContext getDefaultSslContext() { + try { + return SSLContext.getDefault(); + } catch (Exception error) { + throw new IllegalStateException(error); + } + } + + private static void restoreProperty(String name, String value) { + if (value == null) { + System.clearProperty(name); + } else { + System.setProperty(name, value); + } + } + private static String decode(String value) { try { return URLDecoder.decode(value, StandardCharsets.UTF_8.name()); From 71fbd5d57adf914abe713980bb1f56cb070187e1 Mon Sep 17 00:00:00 2001 From: Mike Guo Date: Fri, 18 Sep 2026 19:00:10 +0800 Subject: [PATCH 24/25] Narrow branch to voice agent WebSocket support --- sdk/ai/azure-ai-agents/CHANGELOG.md | 27 +- sdk/ai/azure-ai-agents/README.md | 48 +-- .../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, 303 insertions(+), 4825 deletions(-) delete mode 100644 sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaTelemetryAsyncClient.java delete mode 100644 sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaTelemetryClient.java delete mode 100644 sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/FileUploadHelper.java delete mode 100644 sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/ProjectsServicePollUtils.java delete mode 100644 sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/models/AzureAIEvaluationDataSource.java delete mode 100644 sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/models/FileUploadOptions.java delete mode 100644 sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/models/ModelUploadOptions.java delete mode 100644 sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/BetaTelemetryClientTest.java delete mode 100644 sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/FileUploadTests.java delete 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 6cf921497316e..2d52918fa011e 100644 --- a/sdk/ai/azure-ai-agents/CHANGELOG.md +++ b/sdk/ai/azure-ai-agents/CHANGELOG.md @@ -7,18 +7,14 @@ - Added `VersionSelector.setVersionSelectionRule` as a convenience for configuring a single version selection rule. - Added public `StreamingResponseUtils` in the `com.azure.ai.agents.util` package for converting OpenAI streaming responses to Azure SDK `IterableStream` and Reactor `Flux` types. -- Added raw JSON WebSocket sends, complete unknown-event payloads, UTF-8 binary JSON reception, transport customization, - 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 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 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 @@ -27,8 +23,8 @@ ### Breaking Changes -- Voice-agent WebSocket connections now require secure endpoints, including localhost. Configure certificate trust for - local TLS servers through the transport callbacks. Synchronous sessions now enforce a 32 MiB default message limit. +- Voice-agent WebSocket connections now require secure endpoints, including localhost. Synchronous sessions now + enforce a 32 MiB default message limit. - Replaced `generateAgent` and `generateAgentWithResponse` on `AgentsClient` and `AgentsAsyncClient` with `createAgentFromPrompt` and `createAgentFromPromptWithResponse` on `BetaAgentsClient` and `BetaAgentsAsyncClient`. - Moved `getId()` and `getConversationId()` from `VoiceResponseBase` to `VoiceResponse`. @@ -36,24 +32,11 @@ ### 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 optimization jobs and telephony operations that return the `cancelled` status spelling. +- Fixed polling for telephony operations 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 2a2e5e203b9b4..94488799b7eb1 100644 --- a/sdk/ai/azure-ai-agents/README.md +++ b/sdk/ai/azure-ai-agents/README.md @@ -117,40 +117,10 @@ 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 -`connect` method to set session IDs, agent version overrides, structured inputs, API versions, credential +`openWebSocketSession` method to set session IDs, agent version overrides, structured inputs, API versions, credential scopes, preview features, and extra handshake headers or query parameters. ```java @@ -264,9 +234,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.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. +`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. -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. +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. ### Using OpenAI's official library @@ -548,7 +518,7 @@ MemorySearchPreviewTool tool = new MemorySearchPreviewTool(memoryStore.getName() .setUpdateDelaySeconds(1); ``` -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. +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. --- @@ -1126,16 +1096,6 @@ 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 c3b7a3cf2a5be..bdbb5418c80d3 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,16 +9,11 @@ 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; @@ -38,9 +33,6 @@ 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); @@ -49,89 +41,6 @@ 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"; @@ -748,38 +657,6 @@ 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 26b62be3e3570..8e7bede719554 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,6 +43,7 @@ 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; @@ -55,7 +56,6 @@ 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,12 +92,10 @@ 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(), - FoundryFeaturesOptInKeys.MODEL_ROUTER_CONTROLS_V1_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())) + .collect(Collectors.joining(",")); private static final String MEMORY_STORES_PREVIEW_FEATURES = FoundryFeaturesOptInKeys.MEMORY_STORES_V1_PREVIEW.toString(); @@ -327,25 +325,11 @@ 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)) { - localPipeline = pipeline != null ? pipeline : createHttpPipeline(); - localPipeline = FoundryPolicyHelper.prependPolicy(localPipeline, - FoundryPolicyHelper.createPreviewErrorPolicy(allowPreview)); - } else { - localPipeline = resolvePipeline(previewFeatures); + return buildInnerClient(); } + HttpPipeline localPipeline = resolvePipeline(previewFeatures); AgentsServiceVersion localServiceVersion = (serviceVersion != null) ? serviceVersion : AgentsServiceVersion.getLatest(); AgentsClientImpl client = new AgentsClientImpl(localPipeline, JacksonAdapter.createDefaultSerializerAdapter(), @@ -364,7 +348,7 @@ private void validateClient() { private HttpPipeline createHttpPipeline() { Configuration buildConfiguration = (configuration == null) ? Configuration.getGlobalConfiguration() : configuration; - HttpLogOptions localHttpLogOptions = resolveHttpLogOptions(); + HttpLogOptions localHttpLogOptions = this.httpLogOptions == null ? new HttpLogOptions() : this.httpLogOptions; ClientOptions localClientOptions = this.clientOptions == null ? new ClientOptions() : this.clientOptions; List policies = new ArrayList<>(); String clientName = PROPERTIES.getOrDefault(SDK_NAME, "UnknownName"); @@ -390,7 +374,7 @@ private HttpPipeline createHttpPipeline() { .filter(p -> p.getPipelinePosition() == HttpPipelinePosition.PER_RETRY) .forEach(p -> policies.add(p)); HttpPolicyProviders.addAfterRetryPolicies(policies); - policies.add(HttpClientHelper.createLoggingPolicy(localHttpLogOptions)); + policies.add(new HttpLoggingPolicy(localHttpLogOptions)); HttpPipeline httpPipeline = new HttpPipelineBuilder().policies(policies.toArray(new HttpPipelinePolicy[0])) .httpClient(httpClient) .clientOptions(localClientOptions) @@ -405,46 +389,7 @@ private HttpPipeline resolvePipeline(String foundryFeatures) { } private com.openai.core.http.HttpClient createOpenAIHttpClient(String 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); + return HttpClientHelper.mapToOpenAIHttpClient(resolvePipeline(foundryFeatures)); } /** @@ -463,17 +408,8 @@ public ResponsesClient buildResponsesClient() { * @return an instance of ResponsesAsyncClient */ public ResponsesAsyncClient buildResponsesAsyncClient() { - // 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); - })); + return new ResponsesAsyncClient(getOpenAIAsyncClientBuilder(null).build() + .withOptions(optionBuilder -> optionBuilder.httpClient(createOpenAIHttpClient(null)))); } /** @@ -483,23 +419,8 @@ 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 -> 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.")); + .withOptions(optionBuilder -> optionBuilder.httpClient(createOpenAIHttpClient(null))); } /** @@ -514,25 +435,9 @@ 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 -> 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.")); + .withOptions(optionBuilder -> optionBuilder + .httpClient(createOpenAIHttpClient(allowPreview ? AGENT_PREVIEW_FEATURES : null))); } /** @@ -542,25 +447,8 @@ public OpenAIClient buildAgentScopedOpenAIClient(String agentName, * @return an instance of OpenAIAsyncClient */ public OpenAIClientAsync buildOpenAIAsyncClient() { - // 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.")); + return getOpenAIAsyncClientBuilder(null).build() + .withOptions(optionBuilder -> optionBuilder.httpClient(createOpenAIHttpClient(null))); } /** @@ -575,41 +463,9 @@ public OpenAIClientAsync buildAgentScopedOpenAIAsyncClient(String agentName) { if (CoreUtils.isNullOrEmpty(agentName)) { throw LOGGER.logExceptionAsError(new IllegalArgumentException("'agentName' cannot be empty.")); } - // 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); - }); + return getOpenAIAsyncClientBuilder(agentName).build() + .withOptions(optionBuilder -> optionBuilder + .httpClient(createOpenAIHttpClient(allowPreview ? AGENT_PREVIEW_FEATURES : null))); } private String getDefaultBaseUrl() { @@ -622,19 +478,7 @@ 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))); @@ -643,47 +487,27 @@ private OpenAIOkHttpClient.Builder getOpenAIClientBuilder(String agentName) { builder.baseUrl(getDefaultBaseUrl()); } else { builder.baseUrl(getAgentEndpointBaseUrl(agentName)); - 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()); + // 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())); } // We set the builder retries to 0 to avoid conflicts with the retry policy added through the HttpPipeline. builder.maxRetries(0); return builder; } - /** - * 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); + private OpenAIOkHttpClientAsync.Builder getOpenAIAsyncClientBuilder(String agentName) { + OpenAIOkHttpClientAsync.Builder builder = OpenAIOkHttpClientAsync.builder() + .credential( + BearerTokenCredential.create(TokenUtils.getBearerTokenSupplier(this.tokenCredential, DEFAULT_SCOPES))); builder.azureUrlPath(AzureUrlPathMode.UNIFIED); if (CoreUtils.isNullOrEmpty(agentName)) { builder.baseUrl(getDefaultBaseUrl()); } else { builder.baseUrl(getAgentEndpointBaseUrl(agentName)); - 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()); + // 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())); } // We set the builder retries to 0 to avoid conflicts with the retry policy added through the HttpPipeline. builder.maxRetries(0); @@ -1035,39 +859,4 @@ 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 6f225b353c410..1ee141c74b5f8 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,18 +39,6 @@ @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 e29a5766a6004..3e63f6123dd4e 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,18 +33,6 @@ @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 353dacb829ae3..41abc82dc9e2b 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,20 +55,6 @@ @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 467c9134ab27c..40c6ab22d2072 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,20 +49,6 @@ @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 88fc6c2960728..f23b4f677bebc 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,22 +3,9 @@ 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. @@ -27,98 +14,12 @@ * delegate here so that the two strategies stay in sync and only minimal edits are needed in the * generated files.

* - *

This implementation class is not part of the public API.

+ *

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

*/ -public final class AgentsServicePollUtils { - private static final ClientLogger LOGGER = new ClientLogger(AgentsServicePollUtils.class); - +final class AgentsServicePollUtils { 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} @@ -140,11 +41,10 @@ static PollResponse remapStatus(PollResponse response) { } private static LongRunningOperationStatus mapCustomStatus(LongRunningOperationStatus status) { - // 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. + // Standard statuses (Succeeded, Failed, Canceled, InProgress, NotStarted) are already + // mapped correctly by the parent's PollResult; only remap the custom ones. String name = status.toString(); - if (JobStatus.SUCCEEDED.toString().equalsIgnoreCase(name) - || MemoryStoreUpdateStatus.COMPLETED.toString().equalsIgnoreCase(name)) { + if (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. @@ -153,19 +53,4 @@ 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 7743eb35078e8..f07bb0e69d2a9 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,17 +112,24 @@ 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.")); - } - if (pollingContext.getLatestResponse().getStatus() == LongRunningOperationStatus.USER_CANCELLED) { + } else 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 -> PollingUtils.deserializeResponse( - AgentsServicePollUtils.getFinalResultBody(value, propertyName, resultType), serializer, resultType)) + .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")); + } + }) .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 c7d0cc0c6f9ef..53d935775f636 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,18 +107,22 @@ 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.")); - } - if (pollingContext.getLatestResponse().getStatus() == LongRunningOperationStatus.USER_CANCELLED) { + } else 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); - return PollingUtils.deserializeResponseSync( - AgentsServicePollUtils.getFinalResultBody(pollResult, propertyName, resultType), serializer, - resultType); + 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")); + } } 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 2116cc94ceb9d..3f53589dabc92 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,111 +6,15 @@ 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 155f2557f0834..3b8e01b93907a 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,21 +4,11 @@ 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 @@ -26,10 +16,7 @@ */ 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. @@ -37,24 +24,7 @@ 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 @@ -69,62 +39,7 @@ public Headers headers() { @Override public InputStream body() { - 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(); - } - }; + return azureResponse.getBodyAsInputStreamSync(); } @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 15415c72335c9..9a66ef33575da 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,7 +3,6 @@ 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; @@ -12,15 +11,10 @@ import com.azure.core.http.HttpResponse; import com.azure.core.http.policy.HttpPipelinePolicy; import com.azure.core.util.CoreUtils; -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 reactor.core.publisher.Mono; + 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}. @@ -28,7 +22,6 @@ 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() { } @@ -43,16 +36,6 @@ 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. *

@@ -93,47 +76,10 @@ private FoundryFeaturesPolicy(String foundryFeatures) { @Override public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { - if (context.getHttpRequest().getHeaders().get(FOUNDRY_FEATURES) == null) { + if (CoreUtils.isNullOrEmpty(context.getHttpRequest().getHeaders().getValue(FOUNDRY_FEATURES))) { 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 b324cfd58c52c..67c01ba40d62e 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,7 +9,6 @@ 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; @@ -22,6 +21,7 @@ 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,6 +30,8 @@ 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; @@ -37,8 +39,6 @@ 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,47 +53,6 @@ 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. @@ -102,28 +61,15 @@ public com.azure.core.http.HttpResponse processSync(com.azure.core.http.HttpPipe * @return A bridge client that honors the OpenAI interface but delegates execution to the Azure pipeline. */ public static HttpClient mapToOpenAIHttpClient(HttpPipeline 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); + return new HttpClientWrapper(httpPipeline); } private static final class HttpClientWrapper implements HttpClient { private final HttpPipeline httpPipeline; - private final boolean logBody; - private HttpClientWrapper(HttpPipeline httpPipeline, boolean logBody) { + private HttpClientWrapper(HttpPipeline httpPipeline) { this.httpPipeline = Objects.requireNonNull(httpPipeline, "'httpPipeline' cannot be null."); - this.logBody = logBody; } @Override @@ -144,8 +90,7 @@ 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, azureRequest)), - logBody); + this.httpPipeline.sendSync(azureRequest, buildRequestContext(requestOptions))); } catch (MalformedURLException exception) { throw new OpenAIException("Invalid URL in request: " + exception.getMessage(), LOGGER.logThrowableAsError(exception)); @@ -163,9 +108,8 @@ public CompletableFuture executeAsync(HttpRequest request, Request Objects.requireNonNull(requestOptions, "requestOptions"); return Mono.fromCallable(() -> buildAzureRequest(request)) - .flatMap(azureRequest -> this.httpPipeline.send(azureRequest, - buildRequestContext(requestOptions, azureRequest))) - .map(response -> (HttpResponse) new AzureHttpResponseAdapter(response, logBody)) + .flatMap(azureRequest -> this.httpPipeline.send(azureRequest, buildRequestContext(requestOptions))) + .map(response -> (HttpResponse) new AzureHttpResponseAdapter(response)) .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 @@ -300,13 +244,8 @@ private static HttpHeaders toAzureHeaders(Headers sourceHeaders) { * @param requestOptions OpenAI SDK request options * @return Azure request {@link Context} */ - private static Context buildRequestContext(RequestOptions requestOptions, - com.azure.core.http.HttpRequest request) { + private static Context buildRequestContext(RequestOptions requestOptions) { 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 8f17e474262d0..d0a6ec26304dd 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,7 +10,6 @@ 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; @@ -18,7 +17,6 @@ import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.security.MessageDigest; -import java.security.DigestOutputStream; import java.security.NoSuchAlgorithmException; /** @@ -116,29 +114,14 @@ private static OpenOption[] openOptions(boolean overwrite) { /** * Computes the lowercase hex-encoded SHA-256 digest of the given binary content. * - *

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

+ *

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

* * @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 { - 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(); + byte[] hash = MessageDigest.getInstance("SHA-256").digest(content.toBytes()); StringBuilder builder = new StringBuilder(hash.length * 2); for (byte value : hash) { builder.append(Character.forDigit((value >> 4) & 0xF, 16)); @@ -147,8 +130,6 @@ public void write(byte[] bytes, int offset, int length) { 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 ec300dea480de..dd3b65d861b07 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,16 +47,11 @@ 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 = fileName.toString(); + this.filename = path.getFileName().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 581e5b5336774..16e7ab8830d14 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,14 +93,9 @@ 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() - .stream() - .filter(ConversationItem::isMessage) - .map(ConversationItem::asMessage) - .findFirst() - .orElseThrow(() -> new AssertionError( - "Created conversation item did not contain a message: " + conversationItem.data())); + Message createdConversationItem = conversationItem.data().get(0).asMessage(); 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 5314843238b91..9aa2ba4a2e6e9 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,14 +81,9 @@ 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() - .stream() - .filter(ConversationItem::isMessage) - .map(ConversationItem::asMessage) - .findFirst() - .orElseThrow(() -> new AssertionError( - "Created conversation item did not contain a message: " + conversationItem.data())); + Message createdConversationItem = conversationItem.data().get(0).asMessage(); 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 e6585f352f9c7..93aa7b0bdf58b 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,15 +3,8 @@ 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; @@ -27,136 +20,30 @@ import com.azure.core.test.utils.MockTokenCredential; import com.azure.core.util.BinaryData; import com.azure.core.util.Context; -import com.openai.client.OpenAIClientAsync; -import com.openai.core.ClientOptions; -import com.openai.credential.BearerTokenCredential; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + 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(), - FoundryFeaturesOptInKeys.MODEL_ROUTER_CONTROLS_V1_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())) + .collect(Collectors.joining(",")); @Test public void voicePreviewFactoriesAreOnlyPublicOnBetaBuilder() throws ReflectiveOperationException { @@ -327,94 +214,6 @@ 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(); @@ -520,16 +319,13 @@ public void openAIAndResponsesClientsUseCustomPipeline() { } @Test - public void agentScopedOpenAIClientUsesCustomPipelineAndConditionalPreviewHeader() { + public void agentScopedOpenAIClientUsesCustomPipelineAndAllowPreviewHeader() { 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)); @@ -540,103 +336,6 @@ 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()) @@ -644,51 +343,6 @@ 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 8b46879236d37..a3977df0a7f5d 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,223 +3,20 @@ 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 org.junit.jupiter.params.provider.ValueSource; -import reactor.core.publisher.Mono; + +import java.util.stream.Stream; 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 9fe7b4c655edf..1378da14b610c 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,7 +4,6 @@ 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; @@ -12,31 +11,15 @@ 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"); @@ -155,27 +138,6 @@ 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 072f14359e19d..bf3b9a4bd77bc 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,7 +4,6 @@ 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; @@ -12,7 +11,10 @@ import com.azure.core.test.http.MockHttpResponse; import com.azure.core.util.Context; import com.openai.core.http.HttpRequestBody; -import java.io.ByteArrayInputStream; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; @@ -22,13 +24,6 @@ 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; @@ -38,137 +33,6 @@ 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 de3e0618467af..62c59a2429298 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,8 +14,10 @@ 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; @@ -539,6 +541,22 @@ 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 e052f2919775a..bf9150b0e6de9 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,6 +3,7 @@ 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; @@ -24,6 +25,39 @@ 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 1b8663e8064f4..90db0a049b367 100644 --- a/sdk/ai/azure-ai-projects/CHANGELOG.md +++ b/sdk/ai/azure-ai-projects/CHANGELOG.md @@ -4,27 +4,10 @@ ### 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 621360dc27438..05726006004e4 100644 --- a/sdk/ai/azure-ai-projects/README.md +++ b/sdk/ai/azure-ai-projects/README.md @@ -121,108 +121,6 @@ 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. @@ -257,7 +155,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`, `ModelsAsyncSample`, and `ModelsCreateAndPollSample`), 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` and `ModelsAsyncSample`), routines (`RoutinesSample`, `RoutinesAsyncSample`, `RoutinesManualDispatchSample`, `RoutinesManualDispatchAsyncSample`, and related trigger samples), and packaged skills (`SkillsPackageSample` and `SkillsPackageAsyncSample`). ### Connections operations @@ -638,7 +536,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.IndexesSample.createOrUpdateIndex +```java com.azure.ai.projects.IndexesGetSample.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", ""); @@ -657,7 +555,7 @@ System.out.println("Index created: " + index.getName()); #### List indexes -```java com.azure.ai.projects.IndexesSample.listIndexes +```java com.azure.ai.projects.IndexesListSample.listIndexes indexesClient.listLatestIndexVersions().forEach(index -> { System.out.println("Index name: " + index.getName()); System.out.println("Index version: " + index.getVersion()); @@ -668,7 +566,7 @@ indexesClient.listLatestIndexVersions().forEach(index -> { #### List index versions -```java com.azure.ai.projects.IndexesSample.listIndexVersions +```java com.azure.ai.projects.IndexesListVersionsSample.listIndexVersions String indexName = Configuration.getGlobalConfiguration().get("INDEX_NAME", "my-index"); @@ -682,7 +580,7 @@ indexesClient.listIndexVersions(indexName).forEach(index -> { #### Get an index version -```java com.azure.ai.projects.IndexesSample.getIndex +```java com.azure.ai.projects.IndexesGetSample.getIndex String indexName = Configuration.getGlobalConfiguration().get("INDEX_NAME", "my-index"); String indexVersion = Configuration.getGlobalConfiguration().get("INDEX_VERSION", "1.0"); @@ -698,7 +596,7 @@ System.out.println("Type: " + index.getType()); #### Delete an index version -```java com.azure.ai.projects.IndexesSample.deleteIndex +```java com.azure.ai.projects.IndexesDeleteSample.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 6f7ec0cce835e..3a8e80784716f 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,21 +1,12 @@ 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; @@ -34,99 +25,10 @@ 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 504cc2d8bef3a..2fd7aefc6eaab 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,11 +36,9 @@ 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; @@ -50,7 +48,6 @@ 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. @@ -105,11 +102,6 @@ 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 @@ -351,25 +343,11 @@ 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)) { - localPipeline = pipeline != null ? pipeline : createHttpPipeline(); - localPipeline = FoundryPolicyHelper.prependPolicy(localPipeline, - FoundryPolicyHelper.createPreviewErrorPolicy(allowPreview)); - } else { - localPipeline = resolvePipeline(previewFeatures); + return buildInnerClient(); } + HttpPipeline localPipeline = resolvePipeline(previewFeatures); AIProjectsServiceVersion localServiceVersion = (serviceVersion != null) ? serviceVersion : AIProjectsServiceVersion.getLatest(); AIProjectClientImpl client = new AIProjectClientImpl(localPipeline, @@ -388,7 +366,7 @@ private void validateClient() { private HttpPipeline createHttpPipeline() { Configuration buildConfiguration = (configuration == null) ? Configuration.getGlobalConfiguration() : configuration; - HttpLogOptions localHttpLogOptions = resolveHttpLogOptions(); + HttpLogOptions localHttpLogOptions = this.httpLogOptions == null ? new HttpLogOptions() : this.httpLogOptions; ClientOptions localClientOptions = this.clientOptions == null ? new ClientOptions() : this.clientOptions; List policies = new ArrayList<>(); String clientName = PROPERTIES.getOrDefault(SDK_NAME, "UnknownName"); @@ -414,7 +392,7 @@ private HttpPipeline createHttpPipeline() { .filter(p -> p.getPipelinePosition() == HttpPipelinePosition.PER_RETRY) .forEach(p -> policies.add(p)); HttpPolicyProviders.addAfterRetryPolicies(policies); - policies.add(HttpClientHelper.createLoggingPolicy(localHttpLogOptions)); + policies.add(new HttpLoggingPolicy(localHttpLogOptions)); HttpPipeline httpPipeline = new HttpPipelineBuilder().policies(policies.toArray(new HttpPipelinePolicy[0])) .httpClient(httpClient) .clientOptions(localClientOptions) @@ -424,44 +402,12 @@ 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) { - 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); + return HttpClientHelper.mapToOpenAIHttpClient(resolvePipeline(foundryFeatures)); } /** @@ -572,18 +518,7 @@ public EvaluationRulesClient buildEvaluationRulesClient() { */ public OpenAIClient buildOpenAIClient() { return getOpenAIClientBuilder(null).build() - .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.")); + .withOptions(optionBuilder -> optionBuilder.httpClient(createOpenAIHttpClient(null))); } /** @@ -599,20 +534,7 @@ public OpenAIClient buildAgentScopedOpenAIClient(String agentName) { throw LOGGER.logExceptionAsError(new IllegalArgumentException("'agentName' cannot be empty.")); } return getOpenAIClientBuilder(agentName).build() - .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.")); + .withOptions(optionBuilder -> optionBuilder.httpClient(createOpenAIHttpClient(null))); } /** @@ -622,21 +544,8 @@ public OpenAIClient buildAgentScopedOpenAIClient(String agentName, * @return an instance of OpenAIAsyncClient */ public OpenAIClientAsync buildOpenAIAsyncClient() { - 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.")); + return getOpenAIAsyncClientBuilder(null).build() + .withOptions(optionBuilder -> optionBuilder.httpClient(createOpenAIHttpClient(null))); } /** @@ -651,37 +560,8 @@ public OpenAIClientAsync buildAgentScopedOpenAIAsyncClient(String agentName) { if (CoreUtils.isNullOrEmpty(agentName)) { throw LOGGER.logExceptionAsError(new IllegalArgumentException("'agentName' cannot be empty.")); } - 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); - }); + return getOpenAIAsyncClientBuilder(agentName).build() + .withOptions(optionBuilder -> optionBuilder.httpClient(createOpenAIHttpClient(null))); } private String getDefaultBaseUrl() { @@ -699,29 +579,16 @@ 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, - com.openai.credential.Credential credential) { - OpenAIOkHttpClientAsync.Builder builder = OpenAIOkHttpClientAsync.builder().credential(credential); + private OpenAIOkHttpClientAsync.Builder getOpenAIAsyncClientBuilder(String agentName) { + OpenAIOkHttpClientAsync.Builder builder = OpenAIOkHttpClientAsync.builder() + .credential( + BearerTokenCredential.create(TokenUtils.getBearerTokenSupplier(this.tokenCredential, DEFAULT_SCOPES))); 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; @@ -913,41 +780,6 @@ 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. *

@@ -995,9 +827,7 @@ public BetaAIProjectClientBuilder beta() { BetaRoutinesClient.class, BetaSkillsClient.class, BetaDatasetsClient.class, - BetaAgentInsightMonitorsClient.class, - BetaTelemetryClient.class, - BetaTelemetryAsyncClient.class }) + BetaAgentInsightMonitorsClient.class }) public final class BetaAIProjectClientBuilder { /** @@ -1007,26 +837,6 @@ 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 378d3f62a334a..5826f4938a964 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,19 +48,6 @@ @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 72b215442b2e3..308bcf0db899d 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,19 +42,6 @@ @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 e0b2521664c06..2f9db4a46db1b 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,18 +36,6 @@ @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 0cbf7774e6104..942e519482162 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,18 +30,6 @@ @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 c63f549436427..92036e6b13eca 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,18 +41,6 @@ @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 afc6ab0f8978a..99206c9b90631 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,18 +35,6 @@ @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 ef07c6fef36e7..d324eed611379 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,7 +4,6 @@ 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; @@ -12,7 +11,6 @@ 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; @@ -30,16 +28,9 @@ 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. @@ -48,68 +39,6 @@ @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 c984472d8bddd..c4969bfee5196 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,7 +4,6 @@ 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; @@ -12,7 +11,6 @@ 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; @@ -27,14 +25,6 @@ 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. @@ -43,66 +33,6 @@ @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 deleted file mode 100644 index 8788d2505f262..0000000000000 --- a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaTelemetryAsyncClient.java +++ /dev/null @@ -1,68 +0,0 @@ -// 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 deleted file mode 100644 index c2e462453210a..0000000000000 --- a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/BetaTelemetryClient.java +++ /dev/null @@ -1,72 +0,0 @@ -// 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 65ad0c3789c1d..d20f27d5e6504 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,12 +4,10 @@ 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; @@ -29,13 +27,18 @@ 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. @@ -198,66 +201,28 @@ public Mono> createDatasetWithFileWithResponse(String name, @ServiceMethod(returns = ReturnType.SINGLE) public Mono> createDatasetWithFileWithResponse(String name, String version, Path filePath, String connectionName, RequestOptions 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()); + 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); + }); } /** @@ -335,67 +300,41 @@ public Mono> createDatasetWithFolderWithResponse(String nam @ServiceMethod(returns = ReturnType.SINGLE) public Mono> createDatasetWithFolderWithResponse(String name, String version, Path folderPath, String connectionName, RequestOptions requestOptions) { - 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); + 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 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); - }); + }) + .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 4c60c7fa091e1..434d951315775 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,12 +4,11 @@ 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; @@ -25,14 +24,16 @@ 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.List; +import java.util.stream.Stream; /** * Initializes a new instance of the synchronous AIProjectClient type. @@ -190,43 +191,7 @@ public Response createDatasetWithFileWithResponse(String name, Strin @ServiceMethod(returns = ReturnType.SINGLE) public Response createDatasetWithFileWithResponse(String name, String version, Path filePath, String connectionName, RequestOptions 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. - */ - @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)) { + if (!Files.isRegularFile(filePath)) { throw LOGGER .logThrowableAsError(new IllegalArgumentException("The provided path is not a file: " + filePath)); } @@ -238,12 +203,11 @@ public Response createDatasetWithFileWithResponse(String name, Strin = this.pendingUploadWithResponse(name, version, BinaryData.fromObject(body), requestOptions) .getValue() .toObject(PendingUploadResponse.class); - BlobClient blobClient - = FileUploadHelper.createContainerBuilder(pendingUploadResponse.getBlobReference(), uploadOptions) - .buildClient() - .getBlobClient(fileName.toString()); - blobClient.uploadWithResponse(FileUploadHelper.createUploadOptions(filePath, uploadOptions), null, - requestOptions == null ? Context.NONE : requestOptions.getContext()); + BlobReferenceSasCredential credential = pendingUploadResponse.getBlobReference().getCredential(); + BlobClient blobClient = new BlobClientBuilder().endpoint(credential.getSasUrl()) + .blobName(filePath.getFileName().toString()) + .buildClient(); + blobClient.upload(BinaryData.fromFile(filePath), true); return this.createOrUpdateDatasetVersionWithResponse(name, version, BinaryData.fromObject(new FileDatasetVersion().setDataUrl(blobClient.getBlobUrl())), requestOptions); } @@ -324,43 +288,10 @@ public Response createDatasetWithFolderWithResponse(String name, Str @ServiceMethod(returns = ReturnType.SINGLE) public Response createDatasetWithFolderWithResponse(String name, String version, Path folderPath, String connectionName, RequestOptions requestOptions) { - 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); + if (!Files.isDirectory(folderPath)) { + throw LOGGER + .logExceptionAsError(new IllegalArgumentException("The provided path is not a folder: " + folderPath)); + } PendingUploadRequest request = new PendingUploadRequest(); if (connectionName != null) { request.setConnectionName(connectionName); @@ -369,18 +300,21 @@ 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 - = 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()); + = 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)); } return this.createOrUpdateDatasetVersionWithResponse(name, version, - BinaryData.fromObject(new FolderDatasetVersion().setDataUrl(containerClient.getBlobContainerUrl())), - requestOptions); + BinaryData.fromObject(new FolderDatasetVersion().setDataUrl(containerUrl)), 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 c97e8e5aab7a0..0aad2ca00767c 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,11 +4,8 @@ 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. @@ -17,28 +14,6 @@ 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 deleted file mode 100644 index 8e8dc8abf8e40..0000000000000 --- a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/FileUploadHelper.java +++ /dev/null @@ -1,158 +0,0 @@ -// 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 deleted file mode 100644 index c658d4e1618b0..0000000000000 --- a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/implementation/ProjectsServicePollUtils.java +++ /dev/null @@ -1,102 +0,0 @@ -// 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 6bd3ad6278af9..f160b85081294 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,111 +6,15 @@ 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 6cb05cfefa838..ab3104013ef26 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,21 +4,11 @@ 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 @@ -26,10 +16,7 @@ */ 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. @@ -37,24 +24,7 @@ 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 @@ -69,62 +39,7 @@ public Headers headers() { @Override public InputStream body() { - 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(); - } - }; + return azureResponse.getBodyAsInputStreamSync(); } @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 da9bd9a58ab14..624237e4a11f6 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,7 +3,6 @@ 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; @@ -12,14 +11,10 @@ import com.azure.core.http.HttpResponse; import com.azure.core.http.policy.HttpPipelinePolicy; import com.azure.core.util.CoreUtils; -import com.azure.json.JsonProviders; -import com.azure.json.JsonReader; -import java.io.IOException; -import java.nio.charset.StandardCharsets; +import reactor.core.publisher.Mono; + 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}. @@ -31,36 +26,6 @@ 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. * @@ -111,7 +76,7 @@ private FoundryFeaturesPolicy(String foundryFeatures) { @Override public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { - if (context.getHttpRequest().getHeaders().get(FOUNDRY_FEATURES) == null) { + if (CoreUtils.isNullOrEmpty(context.getHttpRequest().getHeaders().getValue(FOUNDRY_FEATURES))) { 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 e98831b870d1f..d2eb34cb3d9c8 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,7 +9,6 @@ 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; @@ -30,6 +29,9 @@ 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; @@ -37,8 +39,6 @@ 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,45 +53,6 @@ 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. @@ -100,28 +61,15 @@ public com.azure.core.http.HttpResponse processSync(com.azure.core.http.HttpPipe * @return A bridge client that honors the OpenAI interface but delegates execution to the Azure pipeline. */ public static HttpClient mapToOpenAIHttpClient(HttpPipeline 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); + return new HttpClientWrapper(httpPipeline); } private static final class HttpClientWrapper implements HttpClient { private final HttpPipeline httpPipeline; - private final boolean logBody; - private HttpClientWrapper(HttpPipeline httpPipeline, boolean logBody) { + private HttpClientWrapper(HttpPipeline httpPipeline) { this.httpPipeline = Objects.requireNonNull(httpPipeline, "'httpPipeline' cannot be null."); - this.logBody = logBody; } @Override @@ -142,8 +90,7 @@ 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, azureRequest)), - logBody); + this.httpPipeline.sendSync(azureRequest, buildRequestContext(requestOptions))); } catch (MalformedURLException exception) { throw new OpenAIException("Invalid URL in request: " + exception.getMessage(), LOGGER.logThrowableAsError(exception)); @@ -161,9 +108,8 @@ public CompletableFuture executeAsync(HttpRequest request, Request Objects.requireNonNull(requestOptions, "requestOptions"); return Mono.fromCallable(() -> buildAzureRequest(request)) - .flatMap(azureRequest -> this.httpPipeline.send(azureRequest, - buildRequestContext(requestOptions, azureRequest))) - .map(response -> (HttpResponse) new AzureHttpResponseAdapter(response, logBody)) + .flatMap(azureRequest -> this.httpPipeline.send(azureRequest, buildRequestContext(requestOptions))) + .map(response -> (HttpResponse) new AzureHttpResponseAdapter(response)) .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 @@ -298,13 +244,8 @@ private static HttpHeaders toAzureHeaders(Headers sourceHeaders) { * @param requestOptions OpenAI SDK request options * @return Azure request {@link Context} */ - private static Context buildRequestContext(RequestOptions requestOptions, - com.azure.core.http.HttpRequest request) { + private static Context buildRequestContext(RequestOptions requestOptions) { 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 deleted file mode 100644 index a2ed6eb3b88e9..0000000000000 --- a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/models/AzureAIEvaluationDataSource.java +++ /dev/null @@ -1,290 +0,0 @@ -// 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 deleted file mode 100644 index 3665849c7f40a..0000000000000 --- a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/models/FileUploadOptions.java +++ /dev/null @@ -1,78 +0,0 @@ -// 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 deleted file mode 100644 index cdc8fd4978351..0000000000000 --- a/sdk/ai/azure-ai-projects/src/main/java/com/azure/ai/projects/models/ModelUploadOptions.java +++ /dev/null @@ -1,198 +0,0 @@ -// 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 446ce592c41b8..d8166570eb1b1 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 transitive com.azure.storage.blob; + requires 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 e87e8b14a19b4..bb8ae989ef8a9 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.IndexesSample.createOrUpdateIndex + // BEGIN:com.azure.ai.projects.IndexesGetSample.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.IndexesSample.createOrUpdateIndex + // END:com.azure.ai.projects.IndexesGetSample.createOrUpdateIndex } public static void listIndexes() { - // BEGIN:com.azure.ai.projects.IndexesSample.listIndexes + // BEGIN:com.azure.ai.projects.IndexesListSample.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.IndexesSample.listIndexes + // END:com.azure.ai.projects.IndexesListSample.listIndexes } public static void listIndexVersions() { - // BEGIN:com.azure.ai.projects.IndexesSample.listIndexVersions + // BEGIN:com.azure.ai.projects.IndexesListVersionsSample.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.IndexesSample.listIndexVersions + // END:com.azure.ai.projects.IndexesListVersionsSample.listIndexVersions } public static void getIndex() { - // BEGIN:com.azure.ai.projects.IndexesSample.getIndex + // BEGIN:com.azure.ai.projects.IndexesGetSample.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.IndexesSample.getIndex + // END:com.azure.ai.projects.IndexesGetSample.getIndex } public static void deleteIndex() { - // BEGIN:com.azure.ai.projects.IndexesSample.deleteIndex + // BEGIN:com.azure.ai.projects.IndexesDeleteSample.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.IndexesSample.deleteIndex + // END:com.azure.ai.projects.IndexesDeleteSample.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 915e45daf60ab..019183f70c677 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,56 +8,19 @@ 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 deleted file mode 100644 index 8c0f1bc012b9c..0000000000000 --- a/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/BetaTelemetryClientTest.java +++ /dev/null @@ -1,101 +0,0 @@ -// 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 9498d90b8bd7d..7bcd4ee5f82b5 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,65 +4,26 @@ 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 ca3f624a603e1..30fa05e7fa5d0 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,71 +3,17 @@ 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; -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)); - } +import java.util.Collections; +public class EvaluationsHelperTests { @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 deleted file mode 100644 index 59ce32b559c59..0000000000000 --- a/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/FileUploadTests.java +++ /dev/null @@ -1,227 +0,0 @@ -// 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 87109cbac8c09..f63ef430fb09e 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,11 +3,6 @@ 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; @@ -23,170 +18,18 @@ import com.azure.core.test.utils.MockTokenCredential; import com.azure.core.util.BinaryData; import com.azure.core.util.Context; -import com.openai.client.OpenAIClientAsync; -import com.openai.core.ClientOptions; -import com.openai.credential.BearerTokenCredential; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + 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"; @@ -386,76 +229,13 @@ public void openAIClientsUseCustomPipeline() { builder.buildAgentScopedOpenAIClient("agent").models().list(); assertEquals(CUSTOM_PIPELINE_VALUE, customPipelineHeader(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)); + assertNull(foundryFeatures(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()) @@ -463,53 +243,6 @@ 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 deleted file mode 100644 index 33d82046e1bf8..0000000000000 --- a/sdk/ai/azure-ai-projects/src/test/java/com/azure/ai/projects/JobPollingTests.java +++ /dev/null @@ -1,98 +0,0 @@ -// 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 c8e7805593b7d..a24bdb41b0878 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,7 +4,6 @@ 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; @@ -12,7 +11,10 @@ import com.azure.core.test.http.MockHttpResponse; import com.azure.core.util.Context; import com.openai.core.http.HttpRequestBody; -import java.io.ByteArrayInputStream; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; @@ -22,13 +24,6 @@ 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; @@ -38,137 +33,6 @@ 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 3ec5e71295ae6766daee35ebdf6aabc1c317484a Mon Sep 17 00:00:00 2001 From: Mike Guo Date: Fri, 18 Sep 2026 20:01:45 +0800 Subject: [PATCH 25/25] Address voice agent WebSocket review feedback --- sdk/ai/azure-ai-agents/README.md | 21 +++++++++---------- ...VoiceAgentWebSocketSessionAsyncClient.java | 1 - .../voice/VoiceAgentTelephonyLiveTests.java | 16 ++++++++++++-- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/sdk/ai/azure-ai-agents/README.md b/sdk/ai/azure-ai-agents/README.md index 94488799b7eb1..5cd2bd34ce0f8 100644 --- a/sdk/ai/azure-ai-agents/README.md +++ b/sdk/ai/azure-ai-agents/README.md @@ -1043,13 +1043,12 @@ try (BetaVoiceAgentWebSocketSessionClient session = realtimeClient.openWebSocket session.createResponse(); for (RealtimeServerEvent event : session.receiveEvents()) { - if (event instanceof RealtimeServerEventResponseTextDelta) { - System.out.print(((RealtimeServerEventResponseTextDelta) event).getDelta()); - } else if (event instanceof RealtimeServerEventRealtimeServerEventError) { - RealtimeServerEventRealtimeServerEventError error - = (RealtimeServerEventRealtimeServerEventError) event; - System.out.println("Session error: " + error.getError().getMessage()); - } else if (event instanceof RealtimeServerEventResponseDone) { + if (event instanceof RealtimeResponseTextDeltaEvent) { + System.out.print(((RealtimeResponseTextDeltaEvent) event).getDelta()); + } else if (event instanceof RealtimeErrorEvent) { + RealtimeErrorEvent error = (RealtimeErrorEvent) event; + System.out.println("Session error: " + error.getError().message()); + } else if (event instanceof RealtimeResponseDoneEvent) { break; } } @@ -1069,11 +1068,11 @@ Mono.usingWhen( .then(session.createResponse()) .thenMany(session.receiveEvents()) .doOnNext(event -> { - if (event instanceof RealtimeServerEventResponseTextDelta) { - System.out.print(((RealtimeServerEventResponseTextDelta) event).getDelta()); + if (event instanceof RealtimeResponseTextDeltaEvent) { + System.out.print(((RealtimeResponseTextDeltaEvent) event).getDelta()); } }) - .takeUntil(event -> event instanceof RealtimeServerEventResponseDone) + .takeUntil(event -> event instanceof RealtimeResponseDoneEvent) .then(), BetaVoiceAgentWebSocketSessionAsyncClient::closeAsync, (session, error) -> session.closeAsync(), @@ -1083,7 +1082,7 @@ Mono.usingWhen( #### Stream audio and handle function tools -Use `appendInputAudio` to send PCM16 chunks, `commitInputAudio` to commit buffered audio when server-side voice activity detection is not configured, and `clearInputAudio` to discard pending input. Audio output arrives through `RealtimeServerEventResponseAudioDelta` events. When a `RealtimeServerEventResponseFunctionCallArgumentsDone` event requests a client-side tool, execute the function and call `sendFunctionCallOutput` with its call ID and serialized result. +Use `appendInputAudio` to send PCM16 chunks, `commitInputAudio` to commit buffered audio when server-side voice activity detection is not configured, and `clearInputAudio` to discard pending input. Audio output arrives through `RealtimeResponseAudioDeltaEvent` events. When a `RealtimeResponseFunctionCallArgumentsDoneEvent` event requests a client-side tool, execute the function and call `sendFunctionCallOutput` with its call ID and serialized result. | Scenario | Complete sample | |---|---| diff --git a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketSessionAsyncClient.java b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketSessionAsyncClient.java index 307768bbe9151..3dfe0a837b206 100644 --- a/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketSessionAsyncClient.java +++ b/sdk/ai/azure-ai-agents/src/main/java/com/azure/ai/agents/BetaVoiceAgentWebSocketSessionAsyncClient.java @@ -428,7 +428,6 @@ private Mono openWebSocket(String token) { WebsocketClientSpec spec = WebsocketClientSpec.builder() .protocols(VoiceAgentWebSocketUtils.SUBPROTOCOL) .maxFramePayloadLength(options.getMaxMessageSize()) - .handlePing(false) .build(); return client.websocket(spec).uri(websocketUri.toString()).connect().flatMap(connection -> { diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentTelephonyLiveTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentTelephonyLiveTests.java index 18be0acbf3d38..56f57c25325f0 100644 --- a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentTelephonyLiveTests.java +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/voice/VoiceAgentTelephonyLiveTests.java @@ -187,8 +187,7 @@ public void twilioBindingAndOutboundCallLive() throws InterruptedException { assertEquals(inboundCallId, transferredCall.getId()); inboundCallId = null; - TelephonyCallJob dispatchedJob = telephony.getTelephonyCallJob(outboundAgent, callJobId); - assertTrue(dispatchedJob.getAttemptCount() > 0, "The outbound call job did not create an attempt."); + waitForDispatchedCallJob(telephony, outboundAgent, callJobId); OffsetDateTime notBefore = OffsetDateTime.now().plusMinutes(10); CreateTelephonyCallJobInput scheduledRequest = new CreateTelephonyCallJobInput( @@ -251,6 +250,19 @@ private static TelephonyCallSummary waitForInboundCall(BetaVoiceAgentsTelephonyC throw new AssertionError("No inbound Twilio call arrived within " + CALL_TIMEOUT + "."); } + private static void waitForDispatchedCallJob(BetaVoiceAgentsTelephonyClient telephony, String agentName, + String callJobId) throws InterruptedException { + long deadline = System.nanoTime() + CALL_TIMEOUT.toNanos(); + while (System.nanoTime() < deadline) { + TelephonyCallJob callJob = telephony.getTelephonyCallJob(agentName, callJobId); + if (callJob.getAttemptCount() > 0) { + return; + } + Thread.sleep(POLL_INTERVAL.toMillis()); + } + throw new AssertionError("The outbound call job did not create an attempt within " + CALL_TIMEOUT + "."); + } + private static VoiceAgentDefinition definition(String model, String instructions) { return new VoiceAgentDefinition().setModelType(VoiceModelType.MANAGED) .setModel(model)