Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions sdk/ai/azure-ai-agents/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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<MethodDeclaration> methods = model.getMethodsByName(methodName);
if (methods.size() != 1) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -695,7 +715,7 @@ public ToolboxesClient buildToolboxesClient() {
*/
@Generated
public BetaVoiceAgentWebSocketAsyncClient buildBetaVoiceAgentWebSocketAsyncClient() {
return new BetaVoiceAgentWebSocketAsyncClient(buildInnerClient().getBetaVoiceAgentWebSockets());
return new BetaVoiceAgentWebSocketAsyncClient(buildVoiceAgentInnerClient().getBetaVoiceAgentWebSockets());
}

/**
Expand All @@ -715,7 +735,7 @@ public BetaAgentEndpointConversationsAsyncClient buildBetaAgentEndpointConversat
*/
@Generated
public BetaVoiceAgentWebSocketClient buildBetaVoiceAgentWebSocketClient() {
return new BetaVoiceAgentWebSocketClient(buildInnerClient().getBetaVoiceAgentWebSockets());
return new BetaVoiceAgentWebSocketClient(buildVoiceAgentInnerClient().getBetaVoiceAgentWebSockets());
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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() {
}
Expand All @@ -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.
* <p>
Expand Down Expand Up @@ -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<HttpResponse> 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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String> 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<String, String> getQueryParameters(HttpRequest request) {
Map<String, String> 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);
}
}
}