From 399dd528c2b9521121b3189cbe617812e3cf93da Mon Sep 17 00:00:00 2001 From: Xiting Zhang Date: Tue, 6 Jan 2026 15:07:35 -0800 Subject: [PATCH 1/2] [VoiceLive]Add custom query parameters support and optional model parameter --- sdk/ai/azure-ai-voicelive/CHANGELOG.md | 10 + .../ai/voicelive/VoiceLiveAsyncClient.java | 80 ++++- .../ai/voicelive/VoiceLiveClientBuilder.java | 34 +- .../voicelive/VoiceLiveAsyncClientTest.java | 53 ++- .../voicelive/VoiceLiveClientBuilderTest.java | 62 ++++ .../VoiceLiveClientQueryParametersTest.java | 315 ++++++++++++++++++ 6 files changed, 527 insertions(+), 27 deletions(-) create mode 100644 sdk/ai/azure-ai-voicelive/src/test/java/com/azure/ai/voicelive/VoiceLiveClientQueryParametersTest.java diff --git a/sdk/ai/azure-ai-voicelive/CHANGELOG.md b/sdk/ai/azure-ai-voicelive/CHANGELOG.md index 1fae3f3b5120..12fc93b04743 100644 --- a/sdk/ai/azure-ai-voicelive/CHANGELOG.md +++ b/sdk/ai/azure-ai-voicelive/CHANGELOG.md @@ -4,6 +4,16 @@ ### Features Added +- Added custom query parameter support for WebSocket connections: + - `VoiceLiveClientBuilder.customQueryParameters(Map)` to set custom query parameters + - Custom parameters are merged with endpoint URL parameters and SDK-managed parameters + - Parameter precedence: Endpoint URL params → Custom params → api-version (SDK managed) → model (method parameter) + - Enables scenarios like deployment-id, region, or other service-specific parameters +- Enhanced session creation flexibility: + - Added `VoiceLiveAsyncClient.startSession()` overload without model parameter + - Model can now be provided via custom query parameters or endpoint URL if required + - Original `startSession(String model)` method preserved for backward compatibility + ### Breaking Changes ### Bugs Fixed diff --git a/sdk/ai/azure-ai-voicelive/src/main/java/com/azure/ai/voicelive/VoiceLiveAsyncClient.java b/sdk/ai/azure-ai-voicelive/src/main/java/com/azure/ai/voicelive/VoiceLiveAsyncClient.java index e18bcdf824f4..deb9469d65ad 100644 --- a/sdk/ai/azure-ai-voicelive/src/main/java/com/azure/ai/voicelive/VoiceLiveAsyncClient.java +++ b/sdk/ai/azure-ai-voicelive/src/main/java/com/azure/ai/voicelive/VoiceLiveAsyncClient.java @@ -3,16 +3,19 @@ package com.azure.ai.voicelive; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + import com.azure.core.annotation.ServiceClient; import com.azure.core.credential.KeyCredential; import com.azure.core.credential.TokenCredential; import com.azure.core.http.HttpHeaders; import com.azure.core.util.logging.ClientLogger; -import reactor.core.publisher.Mono; -import java.net.URI; -import java.net.URISyntaxException; -import java.util.Objects; +import reactor.core.publisher.Mono; /** * The VoiceLiveAsyncClient provides methods to create and manage real-time voice communication sessions @@ -27,6 +30,7 @@ public final class VoiceLiveAsyncClient { private final TokenCredential tokenCredential; private final String apiVersion; private final HttpHeaders additionalHeaders; + private final Map customQueryParameters; /** * Creates a VoiceLiveAsyncClient with API key authentication. @@ -35,13 +39,16 @@ public final class VoiceLiveAsyncClient { * @param keyCredential The API key credential. * @param apiVersion The API version. * @param additionalHeaders Additional headers to include in requests. + * @param customQueryParameters Custom query parameters to include in WebSocket connection. */ - VoiceLiveAsyncClient(URI endpoint, KeyCredential keyCredential, String apiVersion, HttpHeaders additionalHeaders) { + VoiceLiveAsyncClient(URI endpoint, KeyCredential keyCredential, String apiVersion, HttpHeaders additionalHeaders, + Map customQueryParameters) { this.endpoint = Objects.requireNonNull(endpoint, "'endpoint' cannot be null"); this.keyCredential = Objects.requireNonNull(keyCredential, "'keyCredential' cannot be null"); this.tokenCredential = null; this.apiVersion = Objects.requireNonNull(apiVersion, "'apiVersion' cannot be null"); this.additionalHeaders = additionalHeaders != null ? additionalHeaders : new HttpHeaders(); + this.customQueryParameters = customQueryParameters; } /** @@ -51,14 +58,16 @@ public final class VoiceLiveAsyncClient { * @param tokenCredential The token credential. * @param apiVersion The API version. * @param additionalHeaders Additional headers to include in requests. + * @param customQueryParameters Custom query parameters to include in WebSocket connection. */ VoiceLiveAsyncClient(URI endpoint, TokenCredential tokenCredential, String apiVersion, - HttpHeaders additionalHeaders) { + HttpHeaders additionalHeaders, Map customQueryParameters) { this.endpoint = Objects.requireNonNull(endpoint, "'endpoint' cannot be null"); this.keyCredential = null; this.tokenCredential = Objects.requireNonNull(tokenCredential, "'tokenCredential' cannot be null"); this.apiVersion = Objects.requireNonNull(apiVersion, "'apiVersion' cannot be null"); this.additionalHeaders = additionalHeaders != null ? additionalHeaders : new HttpHeaders(); + this.customQueryParameters = customQueryParameters; } /** @@ -66,6 +75,7 @@ public final class VoiceLiveAsyncClient { * * @param model The model to use for the session. * @return A Mono containing the connected VoiceLiveSessionAsyncClient. + * @throws NullPointerException if {@code model} is null. */ public Mono startSession(String model) { Objects.requireNonNull(model, "'model' cannot be null"); @@ -81,6 +91,24 @@ public Mono startSession(String model) { }); } + /** + * Starts a new VoiceLiveSessionAsyncClient for real-time voice communication without specifying a model. + * The model can be provided via custom query parameters or through the endpoint URL if required by the service. + * + * @return A Mono containing the connected VoiceLiveSessionAsyncClient. + */ + public Mono startSession() { + return Mono.fromCallable(() -> convertToWebSocketEndpoint(endpoint, null)).flatMap(wsEndpoint -> { + VoiceLiveSessionAsyncClient session; + if (keyCredential != null) { + session = new VoiceLiveSessionAsyncClient(wsEndpoint, keyCredential); + } else { + session = new VoiceLiveSessionAsyncClient(wsEndpoint, tokenCredential); + } + return session.connect(additionalHeaders).thenReturn(session); + }); + } + /** * Gets the API version. * @@ -124,26 +152,42 @@ private URI convertToWebSocketEndpoint(URI httpEndpoint, String model) { path = path.replaceAll("/$", "") + "/voice-live/realtime"; } - // Build query string - StringBuilder queryBuilder = new StringBuilder(); + // Build query parameter map to avoid duplicates + Map queryParams = new LinkedHashMap<>(); + + // Start with existing query parameters from the endpoint URL if (httpEndpoint.getQuery() != null && !httpEndpoint.getQuery().isEmpty()) { - queryBuilder.append(httpEndpoint.getQuery()); + String[] pairs = httpEndpoint.getQuery().split("&"); + for (String pair : pairs) { + int idx = pair.indexOf("="); + if (idx > 0) { + String key = pair.substring(0, idx); + String value = pair.substring(idx + 1); + queryParams.put(key, value); + } + } } - // Add api-version if not present - if (!queryBuilder.toString().contains("api-version=")) { - if (queryBuilder.length() > 0) { - queryBuilder.append("&"); - } - queryBuilder.append("api-version=").append(apiVersion); + // Add/override with custom query parameters + if (customQueryParameters != null && !customQueryParameters.isEmpty()) { + queryParams.putAll(customQueryParameters); } - // Add model if not present - if (!queryBuilder.toString().contains("model=")) { + // Ensure api-version is set (SDK's version takes precedence) + queryParams.put("api-version", apiVersion); + + // Add model if provided (function parameter takes precedence) + if (model != null && !model.isEmpty()) { + queryParams.put("model", model); + } + + // Build final query string + StringBuilder queryBuilder = new StringBuilder(); + for (Map.Entry entry : queryParams.entrySet()) { if (queryBuilder.length() > 0) { queryBuilder.append("&"); } - queryBuilder.append("model=").append(model); + queryBuilder.append(entry.getKey()).append("=").append(entry.getValue()); } return new URI(scheme, httpEndpoint.getUserInfo(), httpEndpoint.getHost(), httpEndpoint.getPort(), path, diff --git a/sdk/ai/azure-ai-voicelive/src/main/java/com/azure/ai/voicelive/VoiceLiveClientBuilder.java b/sdk/ai/azure-ai-voicelive/src/main/java/com/azure/ai/voicelive/VoiceLiveClientBuilder.java index c89987016753..ac0618fb360b 100644 --- a/sdk/ai/azure-ai-voicelive/src/main/java/com/azure/ai/voicelive/VoiceLiveClientBuilder.java +++ b/sdk/ai/azure-ai-voicelive/src/main/java/com/azure/ai/voicelive/VoiceLiveClientBuilder.java @@ -3,6 +3,12 @@ package com.azure.ai.voicelive; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + import com.azure.core.annotation.ServiceClientBuilder; import com.azure.core.client.traits.EndpointTrait; import com.azure.core.client.traits.KeyCredentialTrait; @@ -14,10 +20,6 @@ import com.azure.core.util.CoreUtils; import com.azure.core.util.logging.ClientLogger; -import java.net.URI; -import java.net.URISyntaxException; -import java.util.Objects; - /** * Builder for creating instances of {@link VoiceLiveAsyncClient}. */ @@ -31,6 +33,7 @@ public final class VoiceLiveClientBuilder implements TokenCredentialTrait customQueryParameters; /** * Creates a new instance of VoiceLiveClientBuilder. @@ -107,6 +110,23 @@ public VoiceLiveClientBuilder clientOptions(ClientOptions clientOptions) { return this; } + /** + * Sets custom query parameters to be included in the WebSocket connection URL. + * These parameters will be appended to the query string when establishing the WebSocket connection. + * This will replace any previously set custom query parameters. + * + * @param customQueryParameters A map of query parameter names to values. + * @return The updated VoiceLiveClientBuilder instance. + */ + public VoiceLiveClientBuilder customQueryParameters(Map customQueryParameters) { + if (customQueryParameters != null) { + this.customQueryParameters = new HashMap<>(customQueryParameters); + } else { + this.customQueryParameters = null; + } + return this; + } + /** * Builds a {@link VoiceLiveAsyncClient} instance with the configured options. * @@ -126,9 +146,11 @@ public VoiceLiveAsyncClient buildAsyncClient() { HttpHeaders additionalHeaders = CoreUtils.createHttpHeadersFromClientOptions(clientOptions); if (keyCredential != null) { - return new VoiceLiveAsyncClient(endpoint, keyCredential, version.getVersion(), additionalHeaders); + return new VoiceLiveAsyncClient(endpoint, keyCredential, version.getVersion(), additionalHeaders, + customQueryParameters); } else { - return new VoiceLiveAsyncClient(endpoint, tokenCredential, version.getVersion(), additionalHeaders); + return new VoiceLiveAsyncClient(endpoint, tokenCredential, version.getVersion(), additionalHeaders, + customQueryParameters); } } } diff --git a/sdk/ai/azure-ai-voicelive/src/test/java/com/azure/ai/voicelive/VoiceLiveAsyncClientTest.java b/sdk/ai/azure-ai-voicelive/src/test/java/com/azure/ai/voicelive/VoiceLiveAsyncClientTest.java index f71306338a6a..3e840d84c82b 100644 --- a/sdk/ai/azure-ai-voicelive/src/test/java/com/azure/ai/voicelive/VoiceLiveAsyncClientTest.java +++ b/sdk/ai/azure-ai-voicelive/src/test/java/com/azure/ai/voicelive/VoiceLiveAsyncClientTest.java @@ -38,7 +38,7 @@ class VoiceLiveAsyncClientTest { @BeforeEach void setUp() throws Exception { testEndpoint = new URI("https://test.cognitiveservices.azure.com"); - client = new VoiceLiveAsyncClient(testEndpoint, mockKeyCredential, "2024-10-01-preview", mockHeaders); + client = new VoiceLiveAsyncClient(testEndpoint, mockKeyCredential, "2024-10-01-preview", mockHeaders, null); } @Test @@ -51,7 +51,7 @@ void testConstructorWithValidParameters() { void testConstructorWithNullEndpoint() { // Act & Assert assertThrows(NullPointerException.class, () -> { - new VoiceLiveAsyncClient(null, mockKeyCredential, "2024-10-01-preview", mockHeaders); + new VoiceLiveAsyncClient(null, mockKeyCredential, "2024-10-01-preview", mockHeaders, null); }); } @@ -59,7 +59,7 @@ void testConstructorWithNullEndpoint() { void testConstructorWithNullCredential() { // Act & Assert assertThrows(NullPointerException.class, () -> { - new VoiceLiveAsyncClient(testEndpoint, (KeyCredential) null, "2024-10-01-preview", mockHeaders); + new VoiceLiveAsyncClient(testEndpoint, (KeyCredential) null, "2024-10-01-preview", mockHeaders, null); }); } @@ -167,4 +167,51 @@ void testReturnTypeOptimization() { // The returned Mono should contain a VoiceLiveSessionAsyncClient when subscribed }); } + + @Test + void testStartSessionWithoutModel() { + // Test that startSession() without parameters works + assertDoesNotThrow(() -> { + Mono sessionMono = client.startSession(); + assertNotNull(sessionMono); + }); + } + + @Test + void testConstructorWithCustomQueryParameters() throws Exception { + // Arrange + java.util.Map customParams = new java.util.HashMap<>(); + customParams.put("deployment-id", "test-deployment"); + customParams.put("custom-param", "custom-value"); + + // Act + VoiceLiveAsyncClient clientWithParams = new VoiceLiveAsyncClient(testEndpoint, mockKeyCredential, + "2024-10-01-preview", mockHeaders, customParams); + + // Assert + assertNotNull(clientWithParams); + } + + @Test + void testConstructorWithNullCustomQueryParameters() throws Exception { + // Act & Assert + assertDoesNotThrow(() -> { + VoiceLiveAsyncClient clientWithNullParams + = new VoiceLiveAsyncClient(testEndpoint, mockKeyCredential, "2024-10-01-preview", mockHeaders, null); + assertNotNull(clientWithNullParams); + }); + } + + @Test + void testConstructorWithEmptyCustomQueryParameters() throws Exception { + // Arrange + java.util.Map emptyParams = new java.util.HashMap<>(); + + // Act & Assert + assertDoesNotThrow(() -> { + VoiceLiveAsyncClient clientWithEmptyParams = new VoiceLiveAsyncClient(testEndpoint, mockKeyCredential, + "2024-10-01-preview", mockHeaders, emptyParams); + assertNotNull(clientWithEmptyParams); + }); + } } diff --git a/sdk/ai/azure-ai-voicelive/src/test/java/com/azure/ai/voicelive/VoiceLiveClientBuilderTest.java b/sdk/ai/azure-ai-voicelive/src/test/java/com/azure/ai/voicelive/VoiceLiveClientBuilderTest.java index ccbba54f414c..a8942d052e3a 100644 --- a/sdk/ai/azure-ai-voicelive/src/test/java/com/azure/ai/voicelive/VoiceLiveClientBuilderTest.java +++ b/sdk/ai/azure-ai-voicelive/src/test/java/com/azure/ai/voicelive/VoiceLiveClientBuilderTest.java @@ -221,4 +221,66 @@ void testBuilderReturnsBuilder() { assertSame(clientBuilder, clientBuilder.credential(mockKeyCredential)); assertSame(clientBuilder, clientBuilder.serviceVersion(VoiceLiveServiceVersion.V2025_10_01)); } + + @Test + void testBuilderWithCustomQueryParameters() { + // Arrange + String endpoint = "https://test.cognitiveservices.azure.com"; + java.util.Map customParams = new java.util.HashMap<>(); + customParams.put("deployment-id", "test-deployment"); + customParams.put("region", "eastus"); + + // Act & Assert + assertDoesNotThrow(() -> { + VoiceLiveAsyncClient client = clientBuilder.endpoint(endpoint) + .credential(mockKeyCredential) + .customQueryParameters(customParams) + .buildAsyncClient(); + + assertNotNull(client); + }); + } + + @Test + void testBuilderWithNullCustomQueryParameters() { + // Arrange + String endpoint = "https://test.cognitiveservices.azure.com"; + + // Act & Assert + assertDoesNotThrow(() -> { + VoiceLiveAsyncClient client = clientBuilder.endpoint(endpoint) + .credential(mockKeyCredential) + .customQueryParameters(null) + .buildAsyncClient(); + + assertNotNull(client); + }); + } + + @Test + void testBuilderWithEmptyCustomQueryParameters() { + // Arrange + String endpoint = "https://test.cognitiveservices.azure.com"; + java.util.Map emptyParams = new java.util.HashMap<>(); + + // Act & Assert + assertDoesNotThrow(() -> { + VoiceLiveAsyncClient client = clientBuilder.endpoint(endpoint) + .credential(mockKeyCredential) + .customQueryParameters(emptyParams) + .buildAsyncClient(); + + assertNotNull(client); + }); + } + + @Test + void testCustomQueryParametersReturnsBuilder() { + // Arrange + java.util.Map customParams = new java.util.HashMap<>(); + customParams.put("test", "value"); + + // Act & Assert + assertSame(clientBuilder, clientBuilder.customQueryParameters(customParams)); + } } diff --git a/sdk/ai/azure-ai-voicelive/src/test/java/com/azure/ai/voicelive/VoiceLiveClientQueryParametersTest.java b/sdk/ai/azure-ai-voicelive/src/test/java/com/azure/ai/voicelive/VoiceLiveClientQueryParametersTest.java new file mode 100644 index 000000000000..a9ae93d627e5 --- /dev/null +++ b/sdk/ai/azure-ai-voicelive/src/test/java/com/azure/ai/voicelive/VoiceLiveClientQueryParametersTest.java @@ -0,0 +1,315 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.voicelive; + +import com.azure.core.credential.KeyCredential; +import com.azure.core.http.HttpHeaders; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Method; +import java.net.URI; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for custom query parameters feature in VoiceLive client. + */ +@ExtendWith(MockitoExtension.class) +class VoiceLiveClientQueryParametersTest { + + @Mock + private KeyCredential mockKeyCredential; + + @Mock + private HttpHeaders mockHeaders; + + private URI testEndpoint; + private String apiVersion; + + @BeforeEach + void setUp() throws Exception { + testEndpoint = new URI("https://test.cognitiveservices.azure.com"); + apiVersion = "2024-10-01-preview"; + } + + @Test + void testClientBuilderWithCustomQueryParameters() { + // Arrange + Map customParams = new HashMap<>(); + customParams.put("deployment-id", "test-deployment"); + customParams.put("region", "eastus"); + + // Act + VoiceLiveClientBuilder builder = new VoiceLiveClientBuilder(); + VoiceLiveAsyncClient client = builder.endpoint(testEndpoint.toString()) + .credential(mockKeyCredential) + .customQueryParameters(customParams) + .buildAsyncClient(); + + // Assert + assertNotNull(client); + } + + @Test + void testConvertToWebSocketEndpointWithCustomQueryParameters() throws Exception { + // Arrange + Map customParams = new HashMap<>(); + customParams.put("deployment-id", "test-deployment"); + customParams.put("custom-param", "custom-value"); + + VoiceLiveAsyncClient client + = new VoiceLiveAsyncClient(testEndpoint, mockKeyCredential, apiVersion, mockHeaders, customParams); + + // Use reflection to access the private method + Method method + = VoiceLiveAsyncClient.class.getDeclaredMethod("convertToWebSocketEndpoint", URI.class, String.class); + method.setAccessible(true); + + // Act + URI result = (URI) method.invoke(client, testEndpoint, "gpt-4o-realtime-preview"); + + // Assert + assertNotNull(result); + assertEquals("wss", result.getScheme()); + assertNotNull(result.getQuery()); + assertTrue(result.getQuery().contains("api-version=" + apiVersion)); + assertTrue(result.getQuery().contains("model=gpt-4o-realtime-preview")); + assertTrue(result.getQuery().contains("deployment-id=test-deployment")); + assertTrue(result.getQuery().contains("custom-param=custom-value")); + } + + @Test + void testConvertToWebSocketEndpointWithoutModel() throws Exception { + // Arrange + Map customParams = new HashMap<>(); + customParams.put("deployment-id", "test-deployment"); + + VoiceLiveAsyncClient client + = new VoiceLiveAsyncClient(testEndpoint, mockKeyCredential, apiVersion, mockHeaders, customParams); + + // Use reflection to access the private method + Method method + = VoiceLiveAsyncClient.class.getDeclaredMethod("convertToWebSocketEndpoint", URI.class, String.class); + method.setAccessible(true); + + // Act + URI result = (URI) method.invoke(client, testEndpoint, null); + + // Assert + assertNotNull(result); + assertNotNull(result.getQuery()); + assertTrue(result.getQuery().contains("api-version=" + apiVersion)); + assertTrue(result.getQuery().contains("deployment-id=test-deployment")); + // Model should not be in query string when null + assertTrue(!result.getQuery().contains("model=")); + } + + @Test + void testConvertToWebSocketEndpointWithExistingQueryParameters() throws Exception { + // Arrange + URI endpointWithQuery = new URI("https://test.cognitiveservices.azure.com?existing-param=existing-value"); + Map customParams = new HashMap<>(); + customParams.put("custom-param", "custom-value"); + + VoiceLiveAsyncClient client + = new VoiceLiveAsyncClient(endpointWithQuery, mockKeyCredential, apiVersion, mockHeaders, customParams); + + // Use reflection to access the private method + Method method + = VoiceLiveAsyncClient.class.getDeclaredMethod("convertToWebSocketEndpoint", URI.class, String.class); + method.setAccessible(true); + + // Act + URI result = (URI) method.invoke(client, endpointWithQuery, "gpt-4o-realtime-preview"); + + // Assert + assertNotNull(result); + assertNotNull(result.getQuery()); + assertTrue(result.getQuery().contains("existing-param=existing-value")); + assertTrue(result.getQuery().contains("custom-param=custom-value")); + assertTrue(result.getQuery().contains("api-version=" + apiVersion)); + assertTrue(result.getQuery().contains("model=gpt-4o-realtime-preview")); + } + + @Test + void testCustomQueryParametersOverrideExistingParameters() throws Exception { + // Arrange - endpoint has deployment-id, custom params also have deployment-id + URI endpointWithQuery = new URI("https://test.cognitiveservices.azure.com?deployment-id=old-value"); + Map customParams = new HashMap<>(); + customParams.put("deployment-id", "new-value"); + + VoiceLiveAsyncClient client + = new VoiceLiveAsyncClient(endpointWithQuery, mockKeyCredential, apiVersion, mockHeaders, customParams); + + // Use reflection to access the private method + Method method + = VoiceLiveAsyncClient.class.getDeclaredMethod("convertToWebSocketEndpoint", URI.class, String.class); + method.setAccessible(true); + + // Act + URI result = (URI) method.invoke(client, endpointWithQuery, "gpt-4o-realtime-preview"); + + // Assert + assertNotNull(result); + assertNotNull(result.getQuery()); + // Should contain the new value from customParams + assertTrue(result.getQuery().contains("deployment-id=new-value")); + // Should not contain the old value + assertTrue(!result.getQuery().contains("deployment-id=old-value")); + } + + @Test + void testApiVersionAndModelTakePrecedence() throws Exception { + // Arrange - custom params try to set api-version and model + Map customParams = new HashMap<>(); + customParams.put("api-version", "wrong-version"); + customParams.put("model", "wrong-model"); + + VoiceLiveAsyncClient client + = new VoiceLiveAsyncClient(testEndpoint, mockKeyCredential, apiVersion, mockHeaders, customParams); + + // Use reflection to access the private method + Method method + = VoiceLiveAsyncClient.class.getDeclaredMethod("convertToWebSocketEndpoint", URI.class, String.class); + method.setAccessible(true); + + // Act + URI result = (URI) method.invoke(client, testEndpoint, "correct-model"); + + // Assert + assertNotNull(result); + assertNotNull(result.getQuery()); + // SDK's apiVersion should take precedence + assertTrue(result.getQuery().contains("api-version=" + apiVersion)); + // Method parameter model should take precedence + assertTrue(result.getQuery().contains("model=correct-model")); + // Should not contain the wrong values + assertTrue(!result.getQuery().contains("api-version=wrong-version")); + assertTrue(!result.getQuery().contains("model=wrong-model")); + } + + @Test + void testConvertToWebSocketEndpointWithNullCustomQueryParameters() throws Exception { + // Arrange + VoiceLiveAsyncClient client + = new VoiceLiveAsyncClient(testEndpoint, mockKeyCredential, apiVersion, mockHeaders, null); + + // Use reflection to access the private method + Method method + = VoiceLiveAsyncClient.class.getDeclaredMethod("convertToWebSocketEndpoint", URI.class, String.class); + method.setAccessible(true); + + // Act + URI result = (URI) method.invoke(client, testEndpoint, "gpt-4o-realtime-preview"); + + // Assert + assertNotNull(result); + assertNotNull(result.getQuery()); + assertTrue(result.getQuery().contains("api-version=" + apiVersion)); + assertTrue(result.getQuery().contains("model=gpt-4o-realtime-preview")); + } + + @Test + void testConvertToWebSocketEndpointWithEmptyCustomQueryParameters() throws Exception { + // Arrange + Map emptyParams = new HashMap<>(); + VoiceLiveAsyncClient client + = new VoiceLiveAsyncClient(testEndpoint, mockKeyCredential, apiVersion, mockHeaders, emptyParams); + + // Use reflection to access the private method + Method method + = VoiceLiveAsyncClient.class.getDeclaredMethod("convertToWebSocketEndpoint", URI.class, String.class); + method.setAccessible(true); + + // Act + URI result = (URI) method.invoke(client, testEndpoint, "gpt-4o-realtime-preview"); + + // Assert + assertNotNull(result); + assertNotNull(result.getQuery()); + assertTrue(result.getQuery().contains("api-version=" + apiVersion)); + assertTrue(result.getQuery().contains("model=gpt-4o-realtime-preview")); + } + + @Test + void testSchemeConversionWithCustomQueryParameters() throws Exception { + // Test https -> wss conversion + Map customParams = new HashMap<>(); + customParams.put("test-param", "test-value"); + + VoiceLiveAsyncClient client + = new VoiceLiveAsyncClient(testEndpoint, mockKeyCredential, apiVersion, mockHeaders, customParams); + + Method method + = VoiceLiveAsyncClient.class.getDeclaredMethod("convertToWebSocketEndpoint", URI.class, String.class); + method.setAccessible(true); + + URI result = (URI) method.invoke(client, testEndpoint, "model"); + assertEquals("wss", result.getScheme()); + + // Test http -> ws conversion + URI httpEndpoint = new URI("http://test.cognitiveservices.azure.com"); + result = (URI) method.invoke(client, httpEndpoint, "model"); + assertEquals("ws", result.getScheme()); + + // Test wss remains wss + URI wssEndpoint = new URI("wss://test.cognitiveservices.azure.com"); + result = (URI) method.invoke(client, wssEndpoint, "model"); + assertEquals("wss", result.getScheme()); + } + + @Test + void testPathNormalizationWithCustomQueryParameters() throws Exception { + // Arrange + Map customParams = new HashMap<>(); + customParams.put("param", "value"); + + VoiceLiveAsyncClient client + = new VoiceLiveAsyncClient(testEndpoint, mockKeyCredential, apiVersion, mockHeaders, customParams); + + Method method + = VoiceLiveAsyncClient.class.getDeclaredMethod("convertToWebSocketEndpoint", URI.class, String.class); + method.setAccessible(true); + + // Test path without /realtime + URI result = (URI) method.invoke(client, testEndpoint, "model"); + assertTrue(result.getPath().endsWith("/voice-live/realtime")); + + // Test path with trailing slash + URI endpointWithSlash = new URI("https://test.cognitiveservices.azure.com/"); + result = (URI) method.invoke(client, endpointWithSlash, "model"); + assertTrue(result.getPath().endsWith("/voice-live/realtime")); + } + + @Test + void testStartSessionWithAndWithoutModelParameter() { + // Arrange + Map customParams = new HashMap<>(); + customParams.put("deployment-id", "test"); + + VoiceLiveAsyncClient client = new VoiceLiveClientBuilder().endpoint(testEndpoint.toString()) + .credential(mockKeyCredential) + .customQueryParameters(customParams) + .buildAsyncClient(); + + // Act & Assert - startSession with model + assertDoesNotThrow(() -> { + assertNotNull(client.startSession("gpt-4o-realtime-preview")); + }); + + // Act & Assert - startSession without model + assertDoesNotThrow(() -> { + assertNotNull(client.startSession()); + }); + } +} From fbf81acb451db079f02eb55b3e3d86f92c467ab4 Mon Sep 17 00:00:00 2001 From: Xiting Zhang Date: Fri, 9 Jan 2026 14:47:51 -0800 Subject: [PATCH 2/2] create VoiceLiveRequestOptions --- sdk/ai/azure-ai-voicelive/CHANGELOG.md | 17 +- .../ai/voicelive/VoiceLiveAsyncClient.java | 164 ++++++++- .../ai/voicelive/VoiceLiveClientBuilder.java | 26 +- .../models/VoiceLiveRequestOptions.java | 92 +++++ .../voicelive/VoiceLiveAsyncClientTest.java | 43 +-- .../voicelive/VoiceLiveClientBuilderTest.java | 62 ---- .../VoiceLiveClientQueryParametersTest.java | 315 ------------------ .../VoiceLiveRequestOptionsTest.java | 182 ++++++++++ 8 files changed, 440 insertions(+), 461 deletions(-) create mode 100644 sdk/ai/azure-ai-voicelive/src/main/java/com/azure/ai/voicelive/models/VoiceLiveRequestOptions.java delete mode 100644 sdk/ai/azure-ai-voicelive/src/test/java/com/azure/ai/voicelive/VoiceLiveClientQueryParametersTest.java create mode 100644 sdk/ai/azure-ai-voicelive/src/test/java/com/azure/ai/voicelive/VoiceLiveRequestOptionsTest.java diff --git a/sdk/ai/azure-ai-voicelive/CHANGELOG.md b/sdk/ai/azure-ai-voicelive/CHANGELOG.md index 12fc93b04743..0f0827d6483a 100644 --- a/sdk/ai/azure-ai-voicelive/CHANGELOG.md +++ b/sdk/ai/azure-ai-voicelive/CHANGELOG.md @@ -4,15 +4,14 @@ ### Features Added -- Added custom query parameter support for WebSocket connections: - - `VoiceLiveClientBuilder.customQueryParameters(Map)` to set custom query parameters - - Custom parameters are merged with endpoint URL parameters and SDK-managed parameters - - Parameter precedence: Endpoint URL params → Custom params → api-version (SDK managed) → model (method parameter) - - Enables scenarios like deployment-id, region, or other service-specific parameters -- Enhanced session creation flexibility: - - Added `VoiceLiveAsyncClient.startSession()` overload without model parameter - - Model can now be provided via custom query parameters or endpoint URL if required - - Original `startSession(String model)` method preserved for backward compatibility +- Added `VoiceLiveRequestOptions` class for per-request customization: + - Supports custom query parameters via `addCustomQueryParameter(String key, String value)` method + - Supports custom headers via `addCustomHeader(String name, String value)` and `setCustomHeaders(HttpHeaders)` methods + - Custom parameters and headers can be passed to session creation methods +- Enhanced session creation with new overloads: + - Added `startSession(String model, VoiceLiveRequestOptions requestOptions)` for model with custom options + - Added `startSession(VoiceLiveRequestOptions requestOptions)` for custom options without explicit model parameter + - Original `startSession(String model)` and `startSession()` methods preserved for backward compatibility ### Breaking Changes diff --git a/sdk/ai/azure-ai-voicelive/src/main/java/com/azure/ai/voicelive/VoiceLiveAsyncClient.java b/sdk/ai/azure-ai-voicelive/src/main/java/com/azure/ai/voicelive/VoiceLiveAsyncClient.java index deb9469d65ad..d0b9fb5afaec 100644 --- a/sdk/ai/azure-ai-voicelive/src/main/java/com/azure/ai/voicelive/VoiceLiveAsyncClient.java +++ b/sdk/ai/azure-ai-voicelive/src/main/java/com/azure/ai/voicelive/VoiceLiveAsyncClient.java @@ -9,6 +9,7 @@ import java.util.Map; import java.util.Objects; +import com.azure.ai.voicelive.models.VoiceLiveRequestOptions; import com.azure.core.annotation.ServiceClient; import com.azure.core.credential.KeyCredential; import com.azure.core.credential.TokenCredential; @@ -30,7 +31,6 @@ public final class VoiceLiveAsyncClient { private final TokenCredential tokenCredential; private final String apiVersion; private final HttpHeaders additionalHeaders; - private final Map customQueryParameters; /** * Creates a VoiceLiveAsyncClient with API key authentication. @@ -39,16 +39,13 @@ public final class VoiceLiveAsyncClient { * @param keyCredential The API key credential. * @param apiVersion The API version. * @param additionalHeaders Additional headers to include in requests. - * @param customQueryParameters Custom query parameters to include in WebSocket connection. */ - VoiceLiveAsyncClient(URI endpoint, KeyCredential keyCredential, String apiVersion, HttpHeaders additionalHeaders, - Map customQueryParameters) { + VoiceLiveAsyncClient(URI endpoint, KeyCredential keyCredential, String apiVersion, HttpHeaders additionalHeaders) { this.endpoint = Objects.requireNonNull(endpoint, "'endpoint' cannot be null"); this.keyCredential = Objects.requireNonNull(keyCredential, "'keyCredential' cannot be null"); this.tokenCredential = null; this.apiVersion = Objects.requireNonNull(apiVersion, "'apiVersion' cannot be null"); this.additionalHeaders = additionalHeaders != null ? additionalHeaders : new HttpHeaders(); - this.customQueryParameters = customQueryParameters; } /** @@ -58,16 +55,14 @@ public final class VoiceLiveAsyncClient { * @param tokenCredential The token credential. * @param apiVersion The API version. * @param additionalHeaders Additional headers to include in requests. - * @param customQueryParameters Custom query parameters to include in WebSocket connection. */ VoiceLiveAsyncClient(URI endpoint, TokenCredential tokenCredential, String apiVersion, - HttpHeaders additionalHeaders, Map customQueryParameters) { + HttpHeaders additionalHeaders) { this.endpoint = Objects.requireNonNull(endpoint, "'endpoint' cannot be null"); this.keyCredential = null; this.tokenCredential = Objects.requireNonNull(tokenCredential, "'tokenCredential' cannot be null"); this.apiVersion = Objects.requireNonNull(apiVersion, "'apiVersion' cannot be null"); this.additionalHeaders = additionalHeaders != null ? additionalHeaders : new HttpHeaders(); - this.customQueryParameters = customQueryParameters; } /** @@ -109,6 +104,59 @@ public Mono startSession() { }); } + /** + * Starts a new VoiceLiveSessionAsyncClient for real-time voice communication with custom request options. + * + * @param model The model to use for the session. + * @param requestOptions Custom query parameters and headers for the request. + * @return A Mono containing the connected VoiceLiveSessionAsyncClient. + * @throws NullPointerException if {@code model} or {@code requestOptions} is null. + */ + public Mono startSession(String model, VoiceLiveRequestOptions requestOptions) { + Objects.requireNonNull(model, "'model' cannot be null"); + Objects.requireNonNull(requestOptions, "'requestOptions' cannot be null"); + + return Mono + .fromCallable(() -> convertToWebSocketEndpoint(endpoint, model, requestOptions.getCustomQueryParameters())) + .flatMap(wsEndpoint -> { + VoiceLiveSessionAsyncClient session; + if (keyCredential != null) { + session = new VoiceLiveSessionAsyncClient(wsEndpoint, keyCredential); + } else { + session = new VoiceLiveSessionAsyncClient(wsEndpoint, tokenCredential); + } + // Merge additional headers with custom headers from requestOptions + HttpHeaders mergedHeaders = mergeHeaders(additionalHeaders, requestOptions.getCustomHeaders()); + return session.connect(mergedHeaders).thenReturn(session); + }); + } + + /** + * Starts a new VoiceLiveSessionAsyncClient for real-time voice communication with custom request options. + * The model can be provided via custom query parameters. + * + * @param requestOptions Custom query parameters and headers for the request. + * @return A Mono containing the connected VoiceLiveSessionAsyncClient. + * @throws NullPointerException if {@code requestOptions} is null. + */ + public Mono startSession(VoiceLiveRequestOptions requestOptions) { + Objects.requireNonNull(requestOptions, "'requestOptions' cannot be null"); + + return Mono + .fromCallable(() -> convertToWebSocketEndpoint(endpoint, null, requestOptions.getCustomQueryParameters())) + .flatMap(wsEndpoint -> { + VoiceLiveSessionAsyncClient session; + if (keyCredential != null) { + session = new VoiceLiveSessionAsyncClient(wsEndpoint, keyCredential); + } else { + session = new VoiceLiveSessionAsyncClient(wsEndpoint, tokenCredential); + } + // Merge additional headers with custom headers from requestOptions + HttpHeaders mergedHeaders = mergeHeaders(additionalHeaders, requestOptions.getCustomHeaders()); + return session.connect(mergedHeaders).thenReturn(session); + }); + } + /** * Gets the API version. * @@ -118,6 +166,24 @@ String getApiVersion() { return apiVersion; } + /** + * Merges two HttpHeaders objects, with custom headers taking precedence. + * + * @param baseHeaders The base headers. + * @param customHeaders The custom headers to merge. + * @return The merged HttpHeaders. + */ + private HttpHeaders mergeHeaders(HttpHeaders baseHeaders, HttpHeaders customHeaders) { + HttpHeaders merged = new HttpHeaders(); + if (baseHeaders != null) { + baseHeaders.forEach(header -> merged.set(header.getName(), header.getValue())); + } + if (customHeaders != null) { + customHeaders.forEach(header -> merged.set(header.getName(), header.getValue())); + } + return merged; + } + /** * Converts an HTTP endpoint to a WebSocket endpoint. * @@ -168,9 +234,85 @@ private URI convertToWebSocketEndpoint(URI httpEndpoint, String model) { } } - // Add/override with custom query parameters - if (customQueryParameters != null && !customQueryParameters.isEmpty()) { - queryParams.putAll(customQueryParameters); + // Ensure api-version is set (SDK's version takes precedence) + queryParams.put("api-version", apiVersion); + + // Add model if provided (function parameter takes precedence) + if (model != null && !model.isEmpty()) { + queryParams.put("model", model); + } + + // Build final query string + StringBuilder queryBuilder = new StringBuilder(); + for (Map.Entry entry : queryParams.entrySet()) { + if (queryBuilder.length() > 0) { + queryBuilder.append("&"); + } + queryBuilder.append(entry.getKey()).append("=").append(entry.getValue()); + } + + return new URI(scheme, httpEndpoint.getUserInfo(), httpEndpoint.getHost(), httpEndpoint.getPort(), path, + queryBuilder.length() > 0 ? queryBuilder.toString() : null, httpEndpoint.getFragment()); + } catch (URISyntaxException e) { + throw LOGGER + .logExceptionAsError(new IllegalArgumentException("Failed to convert endpoint to WebSocket URI", e)); + } + } + + /** + * Converts an HTTP endpoint to a WebSocket endpoint with additional custom query parameters. + * + * @param httpEndpoint The HTTP endpoint to convert. + * @param model The model name to include in the query string. + * @param additionalQueryParams Additional custom query parameters to include. + * @return The WebSocket endpoint URI. + */ + private URI convertToWebSocketEndpoint(URI httpEndpoint, String model, Map additionalQueryParams) { + try { + String scheme; + switch (httpEndpoint.getScheme().toLowerCase()) { + case "wss": + case "ws": + scheme = httpEndpoint.getScheme(); + break; + + case "https": + scheme = "wss"; + break; + + case "http": + scheme = "ws"; + break; + + default: + throw LOGGER.logExceptionAsError( + new IllegalArgumentException("Scheme " + httpEndpoint.getScheme() + " is not supported")); + } + + String path = httpEndpoint.getPath(); + if (!path.endsWith("/realtime")) { + path = path.replaceAll("/$", "") + "/voice-live/realtime"; + } + + // Build query parameter map to avoid duplicates + Map queryParams = new LinkedHashMap<>(); + + // Start with existing query parameters from the endpoint URL + if (httpEndpoint.getQuery() != null && !httpEndpoint.getQuery().isEmpty()) { + String[] pairs = httpEndpoint.getQuery().split("&"); + for (String pair : pairs) { + int idx = pair.indexOf("="); + if (idx > 0) { + String key = pair.substring(0, idx); + String value = pair.substring(idx + 1); + queryParams.put(key, value); + } + } + } + + // Add/override with custom query parameters from request options + if (additionalQueryParams != null && !additionalQueryParams.isEmpty()) { + queryParams.putAll(additionalQueryParams); } // Ensure api-version is set (SDK's version takes precedence) diff --git a/sdk/ai/azure-ai-voicelive/src/main/java/com/azure/ai/voicelive/VoiceLiveClientBuilder.java b/sdk/ai/azure-ai-voicelive/src/main/java/com/azure/ai/voicelive/VoiceLiveClientBuilder.java index ac0618fb360b..e655bde45d91 100644 --- a/sdk/ai/azure-ai-voicelive/src/main/java/com/azure/ai/voicelive/VoiceLiveClientBuilder.java +++ b/sdk/ai/azure-ai-voicelive/src/main/java/com/azure/ai/voicelive/VoiceLiveClientBuilder.java @@ -5,8 +5,6 @@ import java.net.URI; import java.net.URISyntaxException; -import java.util.HashMap; -import java.util.Map; import java.util.Objects; import com.azure.core.annotation.ServiceClientBuilder; @@ -33,7 +31,6 @@ public final class VoiceLiveClientBuilder implements TokenCredentialTrait customQueryParameters; /** * Creates a new instance of VoiceLiveClientBuilder. @@ -110,23 +107,6 @@ public VoiceLiveClientBuilder clientOptions(ClientOptions clientOptions) { return this; } - /** - * Sets custom query parameters to be included in the WebSocket connection URL. - * These parameters will be appended to the query string when establishing the WebSocket connection. - * This will replace any previously set custom query parameters. - * - * @param customQueryParameters A map of query parameter names to values. - * @return The updated VoiceLiveClientBuilder instance. - */ - public VoiceLiveClientBuilder customQueryParameters(Map customQueryParameters) { - if (customQueryParameters != null) { - this.customQueryParameters = new HashMap<>(customQueryParameters); - } else { - this.customQueryParameters = null; - } - return this; - } - /** * Builds a {@link VoiceLiveAsyncClient} instance with the configured options. * @@ -146,11 +126,9 @@ public VoiceLiveAsyncClient buildAsyncClient() { HttpHeaders additionalHeaders = CoreUtils.createHttpHeadersFromClientOptions(clientOptions); if (keyCredential != null) { - return new VoiceLiveAsyncClient(endpoint, keyCredential, version.getVersion(), additionalHeaders, - customQueryParameters); + return new VoiceLiveAsyncClient(endpoint, keyCredential, version.getVersion(), additionalHeaders); } else { - return new VoiceLiveAsyncClient(endpoint, tokenCredential, version.getVersion(), additionalHeaders, - customQueryParameters); + return new VoiceLiveAsyncClient(endpoint, tokenCredential, version.getVersion(), additionalHeaders); } } } diff --git a/sdk/ai/azure-ai-voicelive/src/main/java/com/azure/ai/voicelive/models/VoiceLiveRequestOptions.java b/sdk/ai/azure-ai-voicelive/src/main/java/com/azure/ai/voicelive/models/VoiceLiveRequestOptions.java new file mode 100644 index 000000000000..aa43c315425d --- /dev/null +++ b/sdk/ai/azure-ai-voicelive/src/main/java/com/azure/ai/voicelive/models/VoiceLiveRequestOptions.java @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.voicelive.models; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import com.azure.core.annotation.Fluent; +import com.azure.core.http.HttpHeaders; + +/** + * Options for customizing VoiceLive requests with additional query parameters and headers. + */ +@Fluent +public final class VoiceLiveRequestOptions { + + private Map customQueryParameters; + private HttpHeaders customHeaders; + + /** + * Creates a new instance of VoiceLiveRequestOptions. + */ + public VoiceLiveRequestOptions() { + this.customQueryParameters = new HashMap<>(); + this.customHeaders = new HttpHeaders(); + } + + /** + * Gets the custom query parameters. + * + * @return The custom query parameters. + */ + public Map getCustomQueryParameters() { + return customQueryParameters; + } + + /** + * Adds a custom query parameter. + * + * @param key The query parameter key. + * @param value The query parameter value. + * @return The updated VoiceLiveRequestOptions object. + * @throws NullPointerException if {@code key} is null. + */ + public VoiceLiveRequestOptions addCustomQueryParameter(String key, String value) { + Objects.requireNonNull(key, "'key' cannot be null"); + if (this.customQueryParameters == null) { + this.customQueryParameters = new HashMap<>(); + } + this.customQueryParameters.put(key, value); + return this; + } + + /** + * Gets the custom headers. + * + * @return The custom headers. + */ + public HttpHeaders getCustomHeaders() { + return customHeaders; + } + + /** + * Sets the custom headers. + * + * @param customHeaders The custom headers to set. + * @return The updated VoiceLiveRequestOptions object. + */ + public VoiceLiveRequestOptions setCustomHeaders(HttpHeaders customHeaders) { + this.customHeaders = customHeaders != null ? customHeaders : new HttpHeaders(); + return this; + } + + /** + * Adds a custom header. + * + * @param name The header name. + * @param value The header value. + * @return The updated VoiceLiveRequestOptions object. + * @throws NullPointerException if {@code name} is null. + */ + public VoiceLiveRequestOptions addCustomHeader(String name, String value) { + Objects.requireNonNull(name, "'name' cannot be null"); + if (this.customHeaders == null) { + this.customHeaders = new HttpHeaders(); + } + this.customHeaders.set(name, value); + return this; + } +} diff --git a/sdk/ai/azure-ai-voicelive/src/test/java/com/azure/ai/voicelive/VoiceLiveAsyncClientTest.java b/sdk/ai/azure-ai-voicelive/src/test/java/com/azure/ai/voicelive/VoiceLiveAsyncClientTest.java index 3e840d84c82b..7562512c2592 100644 --- a/sdk/ai/azure-ai-voicelive/src/test/java/com/azure/ai/voicelive/VoiceLiveAsyncClientTest.java +++ b/sdk/ai/azure-ai-voicelive/src/test/java/com/azure/ai/voicelive/VoiceLiveAsyncClientTest.java @@ -38,7 +38,7 @@ class VoiceLiveAsyncClientTest { @BeforeEach void setUp() throws Exception { testEndpoint = new URI("https://test.cognitiveservices.azure.com"); - client = new VoiceLiveAsyncClient(testEndpoint, mockKeyCredential, "2024-10-01-preview", mockHeaders, null); + client = new VoiceLiveAsyncClient(testEndpoint, mockKeyCredential, "2024-10-01-preview", mockHeaders); } @Test @@ -51,7 +51,7 @@ void testConstructorWithValidParameters() { void testConstructorWithNullEndpoint() { // Act & Assert assertThrows(NullPointerException.class, () -> { - new VoiceLiveAsyncClient(null, mockKeyCredential, "2024-10-01-preview", mockHeaders, null); + new VoiceLiveAsyncClient(null, mockKeyCredential, "2024-10-01-preview", mockHeaders); }); } @@ -59,7 +59,7 @@ void testConstructorWithNullEndpoint() { void testConstructorWithNullCredential() { // Act & Assert assertThrows(NullPointerException.class, () -> { - new VoiceLiveAsyncClient(testEndpoint, (KeyCredential) null, "2024-10-01-preview", mockHeaders, null); + new VoiceLiveAsyncClient(testEndpoint, (KeyCredential) null, "2024-10-01-preview", mockHeaders); }); } @@ -177,41 +177,4 @@ void testStartSessionWithoutModel() { }); } - @Test - void testConstructorWithCustomQueryParameters() throws Exception { - // Arrange - java.util.Map customParams = new java.util.HashMap<>(); - customParams.put("deployment-id", "test-deployment"); - customParams.put("custom-param", "custom-value"); - - // Act - VoiceLiveAsyncClient clientWithParams = new VoiceLiveAsyncClient(testEndpoint, mockKeyCredential, - "2024-10-01-preview", mockHeaders, customParams); - - // Assert - assertNotNull(clientWithParams); - } - - @Test - void testConstructorWithNullCustomQueryParameters() throws Exception { - // Act & Assert - assertDoesNotThrow(() -> { - VoiceLiveAsyncClient clientWithNullParams - = new VoiceLiveAsyncClient(testEndpoint, mockKeyCredential, "2024-10-01-preview", mockHeaders, null); - assertNotNull(clientWithNullParams); - }); - } - - @Test - void testConstructorWithEmptyCustomQueryParameters() throws Exception { - // Arrange - java.util.Map emptyParams = new java.util.HashMap<>(); - - // Act & Assert - assertDoesNotThrow(() -> { - VoiceLiveAsyncClient clientWithEmptyParams = new VoiceLiveAsyncClient(testEndpoint, mockKeyCredential, - "2024-10-01-preview", mockHeaders, emptyParams); - assertNotNull(clientWithEmptyParams); - }); - } } diff --git a/sdk/ai/azure-ai-voicelive/src/test/java/com/azure/ai/voicelive/VoiceLiveClientBuilderTest.java b/sdk/ai/azure-ai-voicelive/src/test/java/com/azure/ai/voicelive/VoiceLiveClientBuilderTest.java index a8942d052e3a..ccbba54f414c 100644 --- a/sdk/ai/azure-ai-voicelive/src/test/java/com/azure/ai/voicelive/VoiceLiveClientBuilderTest.java +++ b/sdk/ai/azure-ai-voicelive/src/test/java/com/azure/ai/voicelive/VoiceLiveClientBuilderTest.java @@ -221,66 +221,4 @@ void testBuilderReturnsBuilder() { assertSame(clientBuilder, clientBuilder.credential(mockKeyCredential)); assertSame(clientBuilder, clientBuilder.serviceVersion(VoiceLiveServiceVersion.V2025_10_01)); } - - @Test - void testBuilderWithCustomQueryParameters() { - // Arrange - String endpoint = "https://test.cognitiveservices.azure.com"; - java.util.Map customParams = new java.util.HashMap<>(); - customParams.put("deployment-id", "test-deployment"); - customParams.put("region", "eastus"); - - // Act & Assert - assertDoesNotThrow(() -> { - VoiceLiveAsyncClient client = clientBuilder.endpoint(endpoint) - .credential(mockKeyCredential) - .customQueryParameters(customParams) - .buildAsyncClient(); - - assertNotNull(client); - }); - } - - @Test - void testBuilderWithNullCustomQueryParameters() { - // Arrange - String endpoint = "https://test.cognitiveservices.azure.com"; - - // Act & Assert - assertDoesNotThrow(() -> { - VoiceLiveAsyncClient client = clientBuilder.endpoint(endpoint) - .credential(mockKeyCredential) - .customQueryParameters(null) - .buildAsyncClient(); - - assertNotNull(client); - }); - } - - @Test - void testBuilderWithEmptyCustomQueryParameters() { - // Arrange - String endpoint = "https://test.cognitiveservices.azure.com"; - java.util.Map emptyParams = new java.util.HashMap<>(); - - // Act & Assert - assertDoesNotThrow(() -> { - VoiceLiveAsyncClient client = clientBuilder.endpoint(endpoint) - .credential(mockKeyCredential) - .customQueryParameters(emptyParams) - .buildAsyncClient(); - - assertNotNull(client); - }); - } - - @Test - void testCustomQueryParametersReturnsBuilder() { - // Arrange - java.util.Map customParams = new java.util.HashMap<>(); - customParams.put("test", "value"); - - // Act & Assert - assertSame(clientBuilder, clientBuilder.customQueryParameters(customParams)); - } } diff --git a/sdk/ai/azure-ai-voicelive/src/test/java/com/azure/ai/voicelive/VoiceLiveClientQueryParametersTest.java b/sdk/ai/azure-ai-voicelive/src/test/java/com/azure/ai/voicelive/VoiceLiveClientQueryParametersTest.java deleted file mode 100644 index a9ae93d627e5..000000000000 --- a/sdk/ai/azure-ai-voicelive/src/test/java/com/azure/ai/voicelive/VoiceLiveClientQueryParametersTest.java +++ /dev/null @@ -1,315 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.azure.ai.voicelive; - -import com.azure.core.credential.KeyCredential; -import com.azure.core.http.HttpHeaders; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.lang.reflect.Method; -import java.net.URI; -import java.util.HashMap; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * Unit tests for custom query parameters feature in VoiceLive client. - */ -@ExtendWith(MockitoExtension.class) -class VoiceLiveClientQueryParametersTest { - - @Mock - private KeyCredential mockKeyCredential; - - @Mock - private HttpHeaders mockHeaders; - - private URI testEndpoint; - private String apiVersion; - - @BeforeEach - void setUp() throws Exception { - testEndpoint = new URI("https://test.cognitiveservices.azure.com"); - apiVersion = "2024-10-01-preview"; - } - - @Test - void testClientBuilderWithCustomQueryParameters() { - // Arrange - Map customParams = new HashMap<>(); - customParams.put("deployment-id", "test-deployment"); - customParams.put("region", "eastus"); - - // Act - VoiceLiveClientBuilder builder = new VoiceLiveClientBuilder(); - VoiceLiveAsyncClient client = builder.endpoint(testEndpoint.toString()) - .credential(mockKeyCredential) - .customQueryParameters(customParams) - .buildAsyncClient(); - - // Assert - assertNotNull(client); - } - - @Test - void testConvertToWebSocketEndpointWithCustomQueryParameters() throws Exception { - // Arrange - Map customParams = new HashMap<>(); - customParams.put("deployment-id", "test-deployment"); - customParams.put("custom-param", "custom-value"); - - VoiceLiveAsyncClient client - = new VoiceLiveAsyncClient(testEndpoint, mockKeyCredential, apiVersion, mockHeaders, customParams); - - // Use reflection to access the private method - Method method - = VoiceLiveAsyncClient.class.getDeclaredMethod("convertToWebSocketEndpoint", URI.class, String.class); - method.setAccessible(true); - - // Act - URI result = (URI) method.invoke(client, testEndpoint, "gpt-4o-realtime-preview"); - - // Assert - assertNotNull(result); - assertEquals("wss", result.getScheme()); - assertNotNull(result.getQuery()); - assertTrue(result.getQuery().contains("api-version=" + apiVersion)); - assertTrue(result.getQuery().contains("model=gpt-4o-realtime-preview")); - assertTrue(result.getQuery().contains("deployment-id=test-deployment")); - assertTrue(result.getQuery().contains("custom-param=custom-value")); - } - - @Test - void testConvertToWebSocketEndpointWithoutModel() throws Exception { - // Arrange - Map customParams = new HashMap<>(); - customParams.put("deployment-id", "test-deployment"); - - VoiceLiveAsyncClient client - = new VoiceLiveAsyncClient(testEndpoint, mockKeyCredential, apiVersion, mockHeaders, customParams); - - // Use reflection to access the private method - Method method - = VoiceLiveAsyncClient.class.getDeclaredMethod("convertToWebSocketEndpoint", URI.class, String.class); - method.setAccessible(true); - - // Act - URI result = (URI) method.invoke(client, testEndpoint, null); - - // Assert - assertNotNull(result); - assertNotNull(result.getQuery()); - assertTrue(result.getQuery().contains("api-version=" + apiVersion)); - assertTrue(result.getQuery().contains("deployment-id=test-deployment")); - // Model should not be in query string when null - assertTrue(!result.getQuery().contains("model=")); - } - - @Test - void testConvertToWebSocketEndpointWithExistingQueryParameters() throws Exception { - // Arrange - URI endpointWithQuery = new URI("https://test.cognitiveservices.azure.com?existing-param=existing-value"); - Map customParams = new HashMap<>(); - customParams.put("custom-param", "custom-value"); - - VoiceLiveAsyncClient client - = new VoiceLiveAsyncClient(endpointWithQuery, mockKeyCredential, apiVersion, mockHeaders, customParams); - - // Use reflection to access the private method - Method method - = VoiceLiveAsyncClient.class.getDeclaredMethod("convertToWebSocketEndpoint", URI.class, String.class); - method.setAccessible(true); - - // Act - URI result = (URI) method.invoke(client, endpointWithQuery, "gpt-4o-realtime-preview"); - - // Assert - assertNotNull(result); - assertNotNull(result.getQuery()); - assertTrue(result.getQuery().contains("existing-param=existing-value")); - assertTrue(result.getQuery().contains("custom-param=custom-value")); - assertTrue(result.getQuery().contains("api-version=" + apiVersion)); - assertTrue(result.getQuery().contains("model=gpt-4o-realtime-preview")); - } - - @Test - void testCustomQueryParametersOverrideExistingParameters() throws Exception { - // Arrange - endpoint has deployment-id, custom params also have deployment-id - URI endpointWithQuery = new URI("https://test.cognitiveservices.azure.com?deployment-id=old-value"); - Map customParams = new HashMap<>(); - customParams.put("deployment-id", "new-value"); - - VoiceLiveAsyncClient client - = new VoiceLiveAsyncClient(endpointWithQuery, mockKeyCredential, apiVersion, mockHeaders, customParams); - - // Use reflection to access the private method - Method method - = VoiceLiveAsyncClient.class.getDeclaredMethod("convertToWebSocketEndpoint", URI.class, String.class); - method.setAccessible(true); - - // Act - URI result = (URI) method.invoke(client, endpointWithQuery, "gpt-4o-realtime-preview"); - - // Assert - assertNotNull(result); - assertNotNull(result.getQuery()); - // Should contain the new value from customParams - assertTrue(result.getQuery().contains("deployment-id=new-value")); - // Should not contain the old value - assertTrue(!result.getQuery().contains("deployment-id=old-value")); - } - - @Test - void testApiVersionAndModelTakePrecedence() throws Exception { - // Arrange - custom params try to set api-version and model - Map customParams = new HashMap<>(); - customParams.put("api-version", "wrong-version"); - customParams.put("model", "wrong-model"); - - VoiceLiveAsyncClient client - = new VoiceLiveAsyncClient(testEndpoint, mockKeyCredential, apiVersion, mockHeaders, customParams); - - // Use reflection to access the private method - Method method - = VoiceLiveAsyncClient.class.getDeclaredMethod("convertToWebSocketEndpoint", URI.class, String.class); - method.setAccessible(true); - - // Act - URI result = (URI) method.invoke(client, testEndpoint, "correct-model"); - - // Assert - assertNotNull(result); - assertNotNull(result.getQuery()); - // SDK's apiVersion should take precedence - assertTrue(result.getQuery().contains("api-version=" + apiVersion)); - // Method parameter model should take precedence - assertTrue(result.getQuery().contains("model=correct-model")); - // Should not contain the wrong values - assertTrue(!result.getQuery().contains("api-version=wrong-version")); - assertTrue(!result.getQuery().contains("model=wrong-model")); - } - - @Test - void testConvertToWebSocketEndpointWithNullCustomQueryParameters() throws Exception { - // Arrange - VoiceLiveAsyncClient client - = new VoiceLiveAsyncClient(testEndpoint, mockKeyCredential, apiVersion, mockHeaders, null); - - // Use reflection to access the private method - Method method - = VoiceLiveAsyncClient.class.getDeclaredMethod("convertToWebSocketEndpoint", URI.class, String.class); - method.setAccessible(true); - - // Act - URI result = (URI) method.invoke(client, testEndpoint, "gpt-4o-realtime-preview"); - - // Assert - assertNotNull(result); - assertNotNull(result.getQuery()); - assertTrue(result.getQuery().contains("api-version=" + apiVersion)); - assertTrue(result.getQuery().contains("model=gpt-4o-realtime-preview")); - } - - @Test - void testConvertToWebSocketEndpointWithEmptyCustomQueryParameters() throws Exception { - // Arrange - Map emptyParams = new HashMap<>(); - VoiceLiveAsyncClient client - = new VoiceLiveAsyncClient(testEndpoint, mockKeyCredential, apiVersion, mockHeaders, emptyParams); - - // Use reflection to access the private method - Method method - = VoiceLiveAsyncClient.class.getDeclaredMethod("convertToWebSocketEndpoint", URI.class, String.class); - method.setAccessible(true); - - // Act - URI result = (URI) method.invoke(client, testEndpoint, "gpt-4o-realtime-preview"); - - // Assert - assertNotNull(result); - assertNotNull(result.getQuery()); - assertTrue(result.getQuery().contains("api-version=" + apiVersion)); - assertTrue(result.getQuery().contains("model=gpt-4o-realtime-preview")); - } - - @Test - void testSchemeConversionWithCustomQueryParameters() throws Exception { - // Test https -> wss conversion - Map customParams = new HashMap<>(); - customParams.put("test-param", "test-value"); - - VoiceLiveAsyncClient client - = new VoiceLiveAsyncClient(testEndpoint, mockKeyCredential, apiVersion, mockHeaders, customParams); - - Method method - = VoiceLiveAsyncClient.class.getDeclaredMethod("convertToWebSocketEndpoint", URI.class, String.class); - method.setAccessible(true); - - URI result = (URI) method.invoke(client, testEndpoint, "model"); - assertEquals("wss", result.getScheme()); - - // Test http -> ws conversion - URI httpEndpoint = new URI("http://test.cognitiveservices.azure.com"); - result = (URI) method.invoke(client, httpEndpoint, "model"); - assertEquals("ws", result.getScheme()); - - // Test wss remains wss - URI wssEndpoint = new URI("wss://test.cognitiveservices.azure.com"); - result = (URI) method.invoke(client, wssEndpoint, "model"); - assertEquals("wss", result.getScheme()); - } - - @Test - void testPathNormalizationWithCustomQueryParameters() throws Exception { - // Arrange - Map customParams = new HashMap<>(); - customParams.put("param", "value"); - - VoiceLiveAsyncClient client - = new VoiceLiveAsyncClient(testEndpoint, mockKeyCredential, apiVersion, mockHeaders, customParams); - - Method method - = VoiceLiveAsyncClient.class.getDeclaredMethod("convertToWebSocketEndpoint", URI.class, String.class); - method.setAccessible(true); - - // Test path without /realtime - URI result = (URI) method.invoke(client, testEndpoint, "model"); - assertTrue(result.getPath().endsWith("/voice-live/realtime")); - - // Test path with trailing slash - URI endpointWithSlash = new URI("https://test.cognitiveservices.azure.com/"); - result = (URI) method.invoke(client, endpointWithSlash, "model"); - assertTrue(result.getPath().endsWith("/voice-live/realtime")); - } - - @Test - void testStartSessionWithAndWithoutModelParameter() { - // Arrange - Map customParams = new HashMap<>(); - customParams.put("deployment-id", "test"); - - VoiceLiveAsyncClient client = new VoiceLiveClientBuilder().endpoint(testEndpoint.toString()) - .credential(mockKeyCredential) - .customQueryParameters(customParams) - .buildAsyncClient(); - - // Act & Assert - startSession with model - assertDoesNotThrow(() -> { - assertNotNull(client.startSession("gpt-4o-realtime-preview")); - }); - - // Act & Assert - startSession without model - assertDoesNotThrow(() -> { - assertNotNull(client.startSession()); - }); - } -} diff --git a/sdk/ai/azure-ai-voicelive/src/test/java/com/azure/ai/voicelive/VoiceLiveRequestOptionsTest.java b/sdk/ai/azure-ai-voicelive/src/test/java/com/azure/ai/voicelive/VoiceLiveRequestOptionsTest.java new file mode 100644 index 000000000000..9b87d3b6e2e6 --- /dev/null +++ b/sdk/ai/azure-ai-voicelive/src/test/java/com/azure/ai/voicelive/VoiceLiveRequestOptionsTest.java @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.voicelive; + +import com.azure.ai.voicelive.models.VoiceLiveRequestOptions; +import com.azure.core.credential.KeyCredential; +import com.azure.core.http.HttpHeaders; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Method; +import java.net.URI; +import java.util.Map; + +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.assertTrue; + +/** + * Unit tests for VoiceLiveRequestOptions feature in VoiceLive client. + */ +@ExtendWith(MockitoExtension.class) +class VoiceLiveRequestOptionsTest { + + @Mock + private KeyCredential mockKeyCredential; + + @Mock + private HttpHeaders mockHeaders; + + private URI testEndpoint; + private String apiVersion; + + @BeforeEach + void setUp() throws Exception { + testEndpoint = new URI("https://test.cognitiveservices.azure.com"); + apiVersion = "2024-10-01-preview"; + } + + @Test + void testRequestOptionsWithCustomQueryParameters() { + // Arrange & Act + VoiceLiveRequestOptions options + = new VoiceLiveRequestOptions().addCustomQueryParameter("deployment-id", "test-deployment") + .addCustomQueryParameter("region", "eastus"); + + // Assert + assertNotNull(options.getCustomQueryParameters()); + assertEquals(2, options.getCustomQueryParameters().size()); + assertEquals("test-deployment", options.getCustomQueryParameters().get("deployment-id")); + assertEquals("eastus", options.getCustomQueryParameters().get("region")); + } + + @Test + void testRequestOptionsWithCustomHeaders() { + // Arrange & Act + VoiceLiveRequestOptions options + = new VoiceLiveRequestOptions().addCustomHeader("X-Custom-Header", "custom-value") + .addCustomHeader("X-Another-Header", "another-value"); + + // Assert + assertNotNull(options.getCustomHeaders()); + assertEquals("custom-value", options.getCustomHeaders().getValue("X-Custom-Header")); + assertEquals("another-value", options.getCustomHeaders().getValue("X-Another-Header")); + } + + @Test + void testRequestOptionsFluentApi() { + // Arrange & Act + VoiceLiveRequestOptions options = new VoiceLiveRequestOptions(); + + VoiceLiveRequestOptions result1 = options.addCustomQueryParameter("key1", "value1"); + VoiceLiveRequestOptions result2 = options.addCustomHeader("Header1", "value1"); + + // Assert + assertSame(options, result1); + assertSame(options, result2); + } + + @Test + void testRequestOptionsSetCustomHeaders() { + // Arrange + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Custom-Header", "value1"); + headers.set("X-Another-Header", "value2"); + + // Act + VoiceLiveRequestOptions options = new VoiceLiveRequestOptions().setCustomHeaders(headers); + + // Assert + assertNotNull(options.getCustomHeaders()); + assertEquals("value1", options.getCustomHeaders().getValue("X-Custom-Header")); + assertEquals("value2", options.getCustomHeaders().getValue("X-Another-Header")); + } + + @Test + void testConvertToWebSocketEndpointWithRequestOptions() throws Exception { + // Arrange + VoiceLiveRequestOptions requestOptions + = new VoiceLiveRequestOptions().addCustomQueryParameter("deployment-id", "test-deployment") + .addCustomQueryParameter("custom-param", "custom-value"); + + VoiceLiveAsyncClient client + = new VoiceLiveAsyncClient(testEndpoint, mockKeyCredential, apiVersion, mockHeaders); + + // Use reflection to access the private method + Method method = VoiceLiveAsyncClient.class.getDeclaredMethod("convertToWebSocketEndpoint", URI.class, + String.class, Map.class); + method.setAccessible(true); + + // Act + URI result = (URI) method.invoke(client, testEndpoint, "gpt-4o-realtime-preview", + requestOptions.getCustomQueryParameters()); + + // Assert + assertNotNull(result); + assertEquals("wss", result.getScheme()); + assertNotNull(result.getQuery()); + assertTrue(result.getQuery().contains("api-version=" + apiVersion)); + assertTrue(result.getQuery().contains("model=gpt-4o-realtime-preview")); + assertTrue(result.getQuery().contains("deployment-id=test-deployment")); + assertTrue(result.getQuery().contains("custom-param=custom-value")); + } + + @Test + void testConvertToWebSocketEndpointWithRequestOptionsWithoutModel() throws Exception { + // Arrange + VoiceLiveRequestOptions requestOptions + = new VoiceLiveRequestOptions().addCustomQueryParameter("deployment-id", "test-deployment") + .addCustomQueryParameter("model", "custom-model-from-params"); + + VoiceLiveAsyncClient client + = new VoiceLiveAsyncClient(testEndpoint, mockKeyCredential, apiVersion, mockHeaders); + + // Use reflection to access the private method + Method method = VoiceLiveAsyncClient.class.getDeclaredMethod("convertToWebSocketEndpoint", URI.class, + String.class, Map.class); + method.setAccessible(true); + + // Act - passing null as model parameter + URI result = (URI) method.invoke(client, testEndpoint, null, requestOptions.getCustomQueryParameters()); + + // Assert + assertNotNull(result); + assertEquals("wss", result.getScheme()); + assertNotNull(result.getQuery()); + assertTrue(result.getQuery().contains("api-version=" + apiVersion)); + assertTrue(result.getQuery().contains("model=custom-model-from-params")); + assertTrue(result.getQuery().contains("deployment-id=test-deployment")); + } + + @Test + void testRequestOptionsQueryParameterPrecedence() throws Exception { + // Arrange - endpoint has api-version, requestOptions overrides it (but SDK should win) + URI endpointWithQuery = new URI("https://test.cognitiveservices.azure.com?api-version=old-version"); + + VoiceLiveRequestOptions requestOptions + = new VoiceLiveRequestOptions().addCustomQueryParameter("deployment-id", "test-deployment"); + + VoiceLiveAsyncClient client + = new VoiceLiveAsyncClient(endpointWithQuery, mockKeyCredential, apiVersion, mockHeaders); + + // Use reflection to access the private method + Method method = VoiceLiveAsyncClient.class.getDeclaredMethod("convertToWebSocketEndpoint", URI.class, + String.class, Map.class); + method.setAccessible(true); + + // Act + URI result = (URI) method.invoke(client, endpointWithQuery, "gpt-4o-realtime-preview", + requestOptions.getCustomQueryParameters()); + + // Assert - SDK's api-version should take precedence + assertNotNull(result); + assertTrue(result.getQuery().contains("api-version=" + apiVersion)); + assertTrue(result.getQuery().contains("deployment-id=test-deployment")); + } +}