diff --git a/sdk/ai/azure-ai-agents/CHANGELOG.md b/sdk/ai/azure-ai-agents/CHANGELOG.md index 2611fc2732fa4..c040d9675dcaf 100644 --- a/sdk/ai/azure-ai-agents/CHANGELOG.md +++ b/sdk/ai/azure-ai-agents/CHANGELOG.md @@ -8,6 +8,8 @@ ### Bugs Fixed +- Added the Azure SDK identifier to voice-agent WebSocket connection URLs through the `x-ms-client-sdk` query parameter. + ### Other Changes - Added sync and async conversation samples demonstrating the `x-ms-user-identity` header with the OpenAI ConversationService. 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 e9363a17cfb3a..2eb625ea9cf97 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,7 @@ public void customize(LibraryCustomization libraryCustomization, Logger logger) renameImageGenToolSize(libraryCustomization, logger); modifyPollingStrategies(libraryCustomization, logger); customizeTimeZoneModels(libraryCustomization); + customizeVoiceAgentWebSocketClients(libraryCustomization); annotateBetaClients(libraryCustomization, logger); annotateBetaFields(libraryCustomization, loadBetaAnnotations(logger), logger); } @@ -127,6 +128,60 @@ private static void customizeTimeZoneModel(ClassOrInterfaceDeclaration model) { } } + private void customizeVoiceAgentWebSocketClients(LibraryCustomization customization) { + customization.getClass("com.azure.ai.agents", "AgentsClientBuilder").customizeAst(ast -> { + ClassOrInterfaceDeclaration builder = ast.getClassByName("AgentsClientBuilder") + .orElseThrow(() -> new IllegalStateException("Generated AgentsClientBuilder was not found.")); + ast.addImport("com.azure.core.util.UserAgentUtil"); + + customizeMethodBody(builder, "buildBetaVoiceAgentWebSocketAsyncClient", + "{ return new BetaVoiceAgentWebSocketAsyncClient(buildInnerClient().getBetaVoiceAgentWebSockets()); }", + "{ return new BetaVoiceAgentWebSocketAsyncClient(buildVoiceAgentInnerClient().getBetaVoiceAgentWebSockets()); }"); + customizeMethodBody(builder, "buildBetaVoiceAgentWebSocketClient", + "{ return new BetaVoiceAgentWebSocketClient(buildInnerClient().getBetaVoiceAgentWebSockets()); }", + "{ return new BetaVoiceAgentWebSocketClient(buildVoiceAgentInnerClient().getBetaVoiceAgentWebSockets()); }"); + + if (builder.getMethodsByName("buildVoiceAgentInnerClient").isEmpty()) { + builder.addMember(StaticJavaParser.parseMethodDeclaration( + "private AgentsClientImpl buildVoiceAgentInnerClient() {" + + " this.validateClient();" + + " Configuration buildConfiguration = (configuration == null)" + + " ? Configuration.getGlobalConfiguration() : configuration;" + + " ClientOptions localClientOptions = this.clientOptions == null" + + " ? new ClientOptions() : this.clientOptions;" + + " String clientName = PROPERTIES.getOrDefault(SDK_NAME, \"UnknownName\");" + + " String clientVersion = PROPERTIES.getOrDefault(SDK_VERSION, \"UnknownVersion\");" + + " String applicationId = CoreUtils.getApplicationId(localClientOptions, this.httpLogOptions);" + + " String userAgent = UserAgentUtil.toUserAgentString(applicationId, clientName, clientVersion," + + " buildConfiguration);" + + " HttpPipeline localPipeline = pipeline != null ? pipeline : createHttpPipeline();" + + " HttpPipelinePolicy clientSdkQueryPolicy =" + + " FoundryPolicyHelper.createClientSdkQueryPolicy(userAgent);" + + " localPipeline = FoundryPolicyHelper.prependPolicy(localPipeline, clientSdkQueryPolicy);" + + " AgentsServiceVersion localServiceVersion = (serviceVersion != null)" + + " ? serviceVersion : AgentsServiceVersion.getLatest();" + + " return new AgentsClientImpl(localPipeline, JacksonAdapter.createDefaultSerializerAdapter()," + + " this.endpoint, localServiceVersion);" + + " }")); + } + }); + } + + private static void customizeMethodBody(ClassOrInterfaceDeclaration clazz, String methodName, + String generatedBody, String customizedBody) { + MethodDeclaration method = getSingleMethod(clazz, methodName); + String currentBody = method.getBody() + .orElseThrow(() -> new IllegalStateException(clazz.getNameAsString() + "." + methodName + " has no body.")) + .toString(); + String expectedBody = StaticJavaParser.parseBlock(generatedBody).toString(); + String replacementBody = StaticJavaParser.parseBlock(customizedBody).toString(); + if (!currentBody.equals(expectedBody) && !currentBody.equals(replacementBody)) { + throw new IllegalStateException( + clazz.getNameAsString() + "." + methodName + " no longer has the expected generated body."); + } + method.setBody(StaticJavaParser.parseBlock(customizedBody)); + } + private static MethodDeclaration getSingleMethod(ClassOrInterfaceDeclaration model, String methodName) { List methods = model.getMethodsByName(methodName); if (methods.size() != 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 62fb3ba3407b5..f31cd7a83175c 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 @@ -37,6 +37,7 @@ 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; @@ -383,6 +384,25 @@ private HttpPipeline resolvePipeline(String foundryFeatures) { return FoundryPolicyHelper.prependPolicy(localPipeline, foundryFeaturesPolicy); } + private AgentsClientImpl buildVoiceAgentInnerClient() { + this.validateClient(); + Configuration buildConfiguration + = (configuration == null) ? Configuration.getGlobalConfiguration() : configuration; + ClientOptions localClientOptions = this.clientOptions == null ? new ClientOptions() : this.clientOptions; + String clientName = PROPERTIES.getOrDefault(SDK_NAME, "UnknownName"); + String clientVersion = PROPERTIES.getOrDefault(SDK_VERSION, "UnknownVersion"); + String applicationId = CoreUtils.getApplicationId(localClientOptions, this.httpLogOptions); + String userAgent + = UserAgentUtil.toUserAgentString(applicationId, clientName, clientVersion, buildConfiguration); + HttpPipeline localPipeline = pipeline != null ? pipeline : createHttpPipeline(); + HttpPipelinePolicy clientSdkQueryPolicy = FoundryPolicyHelper.createClientSdkQueryPolicy(userAgent); + localPipeline = FoundryPolicyHelper.prependPolicy(localPipeline, clientSdkQueryPolicy); + AgentsServiceVersion localServiceVersion + = (serviceVersion != null) ? serviceVersion : AgentsServiceVersion.getLatest(); + return new AgentsClientImpl(localPipeline, JacksonAdapter.createDefaultSerializerAdapter(), this.endpoint, + localServiceVersion); + } + private com.openai.core.http.HttpClient createOpenAIHttpClient(String foundryFeatures) { return HttpClientHelper.mapToOpenAIHttpClient(resolvePipeline(foundryFeatures)); } @@ -695,7 +715,7 @@ public ToolboxesClient buildToolboxesClient() { */ @Generated public BetaVoiceAgentWebSocketAsyncClient buildBetaVoiceAgentWebSocketAsyncClient() { - return new BetaVoiceAgentWebSocketAsyncClient(buildInnerClient().getBetaVoiceAgentWebSockets()); + return new BetaVoiceAgentWebSocketAsyncClient(buildVoiceAgentInnerClient().getBetaVoiceAgentWebSockets()); } /** @@ -715,7 +735,7 @@ public BetaAgentEndpointConversationsAsyncClient buildBetaAgentEndpointConversat */ @Generated public BetaVoiceAgentWebSocketClient buildBetaVoiceAgentWebSocketClient() { - return new BetaVoiceAgentWebSocketClient(buildInnerClient().getBetaVoiceAgentWebSockets()); + return new BetaVoiceAgentWebSocketClient(buildVoiceAgentInnerClient().getBetaVoiceAgentWebSockets()); } /** 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..e09c1bdebfb01 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 @@ -8,11 +8,17 @@ import com.azure.core.http.HttpPipelineBuilder; import com.azure.core.http.HttpPipelineCallContext; import com.azure.core.http.HttpPipelineNextPolicy; +import com.azure.core.http.HttpPipelineNextSyncPolicy; import com.azure.core.http.HttpResponse; import com.azure.core.http.policy.HttpPipelinePolicy; import com.azure.core.util.CoreUtils; +import com.azure.core.util.UrlBuilder; import reactor.core.publisher.Mono; +import java.io.UnsupportedEncodingException; +import java.net.MalformedURLException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; @@ -22,6 +28,7 @@ public final class FoundryPolicyHelper { private static final HttpHeaderName FOUNDRY_FEATURES = HttpHeaderName.fromString("Foundry-Features"); + private static final String CLIENT_SDK_QUERY_PARAMETER = "x-ms-client-sdk"; 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 the SDK identifier to the request URL when it isn't already present. + * + * @param userAgent The SDK user agent to add as the {@code x-ms-client-sdk} query parameter. + * @return A policy that adds the SDK identifier, or {@code null} if {@code userAgent} is empty. + */ + public static HttpPipelinePolicy createClientSdkQueryPolicy(String userAgent) { + return CoreUtils.isNullOrEmpty(userAgent) ? null : new ClientSdkQueryPolicy(userAgent); + } + /** * Creates a new pipeline with {@code policy} prepended to the existing pipeline policies. *

@@ -66,6 +83,45 @@ public static HttpPipeline prependPolicy(HttpPipeline pipeline, HttpPipelinePoli .build(); } + private static final class ClientSdkQueryPolicy implements HttpPipelinePolicy { + + private final String encodedUserAgent; + + private ClientSdkQueryPolicy(String userAgent) { + try { + this.encodedUserAgent = URLEncoder.encode(userAgent, StandardCharsets.UTF_8.name()); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException("UTF-8 encoding is not supported.", e); + } + } + + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + addClientSdkQueryParameter(context); + return next.process(); + } + + @Override + public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNextSyncPolicy next) { + addClientSdkQueryParameter(context); + return next.processSync(); + } + + private void addClientSdkQueryParameter(HttpPipelineCallContext context) { + UrlBuilder urlBuilder = UrlBuilder.parse(context.getHttpRequest().getUrl()); + if (urlBuilder.getQuery().containsKey(CLIENT_SDK_QUERY_PARAMETER)) { + return; + } + + try { + context.getHttpRequest() + .setUrl(urlBuilder.setQueryParameter(CLIENT_SDK_QUERY_PARAMETER, encodedUserAgent).toUrl()); + } catch (MalformedURLException e) { + throw new IllegalStateException("Failed to add the SDK identifier to the request URL.", e); + } + } + } + private static final class FoundryFeaturesPolicy implements HttpPipelinePolicy { private final String foundryFeatures; diff --git a/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/VoiceAgentWebSocketIdentificationTests.java b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/VoiceAgentWebSocketIdentificationTests.java new file mode 100644 index 0000000000000..75d8322d19df8 --- /dev/null +++ b/sdk/ai/azure-ai-agents/src/test/java/com/azure/ai/agents/VoiceAgentWebSocketIdentificationTests.java @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.agents; + +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpHeaders; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.rest.RequestOptions; +import com.azure.core.test.utils.MockTokenCredential; +import com.azure.core.util.ClientOptions; +import com.azure.core.util.Header; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class VoiceAgentWebSocketIdentificationTests { + private static final String ENDPOINT = "https://localhost:8080/api/projects/project"; + private static final String AGENT_NAME = "voice-agent-test"; + private static final String CLIENT_SDK_QUERY_PARAMETER = "x-ms-client-sdk"; + + @Test + public void syncVoiceAgentHandshakeIncludesSdkIdentificationAndPreservesQueryParameters() { + DeterministicHttpClient httpClient = new DeterministicHttpClient().enqueue(101, new HttpHeaders(), new byte[0]); + BetaVoiceAgentWebSocketClient client + = createBuilder(httpClient, new ClientOptions().setApplicationId("test-application")) + .buildBetaVoiceAgentWebSocketClient(); + RequestOptions requestOptions = new RequestOptions().addQueryParam("transport", "websocket"); + + client.connectVoiceAgentWithResponse(AGENT_NAME, requestOptions); + + HttpRequest request = httpClient.getRequest(0); + String userAgent = request.getHeaders().getValue(HttpHeaderName.USER_AGENT); + assertTrue(userAgent.startsWith("test-application azsdk-java-azure-ai-agents/")); + assertEquals(userAgent, getQueryParameters(request).get(CLIENT_SDK_QUERY_PARAMETER)); + assertEquals("websocket", getQueryParameters(request).get("transport")); + assertEquals(AgentsServiceVersion.V1.getVersion(), getQueryParameters(request).get("api-version")); + } + + @Test + public void asyncVoiceAgentHandshakeIncludesSdkIdentification() { + DeterministicHttpClient httpClient = new DeterministicHttpClient().enqueue(101, new HttpHeaders(), new byte[0]); + BetaVoiceAgentWebSocketAsyncClient client + = createBuilder(httpClient, null).buildBetaVoiceAgentWebSocketAsyncClient(); + + StepVerifier.create(client.connectVoiceAgentWithResponse(AGENT_NAME, new RequestOptions())) + .expectNextCount(1) + .verifyComplete(); + + HttpRequest request = httpClient.getRequest(0); + String userAgent = request.getHeaders().getValue(HttpHeaderName.USER_AGENT); + assertTrue(userAgent.startsWith("azsdk-java-azure-ai-agents/")); + assertEquals(userAgent, getQueryParameters(request).get(CLIENT_SDK_QUERY_PARAMETER)); + } + + @Test + public void callerProvidedClientSdkQueryParameterIsPreserved() { + DeterministicHttpClient httpClient = new DeterministicHttpClient().enqueue(101, new HttpHeaders(), new byte[0]); + BetaVoiceAgentWebSocketClient client = createBuilder(httpClient, null).buildBetaVoiceAgentWebSocketClient(); + RequestOptions requestOptions = new RequestOptions().addQueryParam(CLIENT_SDK_QUERY_PARAMETER, "custom-sdk-id"); + + client.connectVoiceAgentWithResponse(AGENT_NAME, requestOptions); + + Map queryParameters = getQueryParameters(httpClient.getRequest(0)); + assertEquals("custom-sdk-id", queryParameters.get(CLIENT_SDK_QUERY_PARAMETER)); + assertEquals(1, countQueryParameters(httpClient.getRequest(0), CLIENT_SDK_QUERY_PARAMETER)); + } + + @Test + public void customUserAgentOverridesHeaderOnly() { + DeterministicHttpClient httpClient = new DeterministicHttpClient().enqueue(101, new HttpHeaders(), new byte[0]); + ClientOptions clientOptions + = new ClientOptions().setHeaders(Collections.singletonList(new Header("User-Agent", "custom-user-agent"))); + BetaVoiceAgentWebSocketClient client + = createBuilder(httpClient, clientOptions).buildBetaVoiceAgentWebSocketClient(); + + client.connectVoiceAgentWithResponse(AGENT_NAME, new RequestOptions()); + + HttpRequest request = httpClient.getRequest(0); + assertEquals("custom-user-agent", request.getHeaders().getValue(HttpHeaderName.USER_AGENT)); + assertTrue( + getQueryParameters(request).get(CLIENT_SDK_QUERY_PARAMETER).startsWith("azsdk-java-azure-ai-agents/")); + } + + private static AgentsClientBuilder createBuilder(DeterministicHttpClient httpClient, ClientOptions clientOptions) { + AgentsClientBuilder builder = new AgentsClientBuilder().endpoint(ENDPOINT) + .credential(new MockTokenCredential()) + .httpClient(httpClient) + .serviceVersion(AgentsServiceVersion.V1); + return clientOptions == null ? builder : builder.clientOptions(clientOptions); + } + + private static Map getQueryParameters(HttpRequest request) { + Map queryParameters = new LinkedHashMap<>(); + String query = request.getUrl().getQuery(); + if (query == null || query.isEmpty()) { + return queryParameters; + } + + for (String pair : query.split("&")) { + int separator = pair.indexOf('='); + String name = separator < 0 ? pair : pair.substring(0, separator); + String value = separator < 0 ? "" : pair.substring(separator + 1); + queryParameters.put(decode(name), decode(value)); + } + return queryParameters; + } + + private static int countQueryParameters(HttpRequest request, String expectedName) { + int count = 0; + for (String pair : request.getUrl().getQuery().split("&")) { + int separator = pair.indexOf('='); + String name = separator < 0 ? pair : pair.substring(0, separator); + if (expectedName.equals(decode(name))) { + count++; + } + } + return count; + } + + private static String decode(String value) { + try { + return URLDecoder.decode(value, StandardCharsets.UTF_8.name()); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException("UTF-8 encoding is not supported.", e); + } + } +}