From 9c2b6262fe115cecb5b890d88f0bd66a4bead056 Mon Sep 17 00:00:00 2001 From: Mark Stuart <323302876+markstuart-oai@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:28:15 +0000 Subject: [PATCH 1/4] fix(websocket): apply connection query overrides once --- .../client/okhttp/WebSocketParityTest.kt | 56 +++++++++++++++++++ .../com/openai/azure/AzureUrlCategory.kt | 6 +- .../core/http/AsyncResponseConnection.kt | 18 ++---- .../openai/core/http/WebSocketConnections.kt | 5 +- .../com/openai/helpers/RealtimeConnection.kt | 32 +++-------- .../com/openai/azure/AzureUrlCategoryTest.kt | 28 ++++++++++ 6 files changed, 102 insertions(+), 43 deletions(-) diff --git a/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/WebSocketParityTest.kt b/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/WebSocketParityTest.kt index 0f8eb398f..c2b9cac6e 100644 --- a/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/WebSocketParityTest.kt +++ b/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/WebSocketParityTest.kt @@ -1,9 +1,13 @@ package com.openai.client.okhttp import com.openai.core.ClientOptions +import com.openai.core.http.ResponseConnection +import com.openai.core.http.ResponseWebSocketOptions import com.openai.core.http.WebSocketOptions import com.openai.core.jsonMapper import com.openai.helpers.LiveSidebandConnection +import com.openai.helpers.RealtimeConnection +import com.openai.helpers.RealtimeWebSocketOptions import com.openai.models.beta.responses.BetaResponsesClientEvent import java.util.concurrent.CompletableFuture import java.util.concurrent.CountDownLatch @@ -48,6 +52,58 @@ class WebSocketParityTest { } } + @Test + fun connectionQueryReplacesInheritedBaseUrlValues() { + for (mode in listOf("realtime", "responses")) { + MockWebServer().use { server -> + val peer = Peer() + server.enqueue(MockResponse().withWebSocketUpgrade(peer)) + OkHttpClient.builder().build().use { http -> + val client = + ClientOptions.builder() + .httpClient(http) + .apiKey("synthetic") + .baseUrl( + server + .url("/v1/?tenant=base&model=base-model&keep=a%2Bb") + .toString() + ) + .putQueryParam("tenant", "client") + .build() + val connection = + if (mode == "realtime") { + RealtimeConnection.connect( + client, + RealtimeWebSocketOptions.builder() + .model("connection-model") + .putQueryParam("tenant", "connection") + .build(), + ) + } else { + ResponseConnection.connect( + client, + ResponseWebSocketOptions.builder() + .putQueryParam("tenant", "connection") + .build(), + ) + } + connection.use { + val request = server.takeRequest(5, TimeUnit.SECONDS)!! + val url = request.requestUrl!! + assertThat(url.encodedPath).isEqualTo("/v1/$mode") + assertThat(url.queryParameterValues("tenant")).containsExactly("connection") + assertThat(url.queryParameterValues("model")) + .containsExactly( + if (mode == "realtime") "connection-model" else "base-model" + ) + assertThat(url.queryParameterValues("keep")).containsExactly("a+b") + } + assertThat(peer.closed.await(5, TimeUnit.SECONDS)).isTrue() + } + } + } + } + @Test fun betaConnectionsUseBetaTypesInBothModes() { for (async in listOf(false, true)) { diff --git a/openai-java-core/src/main/kotlin/com/openai/azure/AzureUrlCategory.kt b/openai-java-core/src/main/kotlin/com/openai/azure/AzureUrlCategory.kt index e75b0936f..7ad51617b 100644 --- a/openai-java-core/src/main/kotlin/com/openai/azure/AzureUrlCategory.kt +++ b/openai-java-core/src/main/kotlin/com/openai/azure/AzureUrlCategory.kt @@ -21,8 +21,8 @@ internal enum class AzureUrlCategory { companion object { fun categorizeBaseUrl(baseUrl: String, pathMode: AzureUrlPathMode): AzureUrlCategory { - val trimmedBaseUrl = baseUrl.trim().trimEnd('/') - val host = URI.create(trimmedBaseUrl).host + val uri = URI.create(baseUrl.trim()) + val host = uri.host return when (pathMode) { AzureUrlPathMode.LEGACY -> AZURE_LEGACY @@ -36,7 +36,7 @@ internal enum class AzureUrlCategory { // ones. host.endsWith(".azure-api.net", ignoreCase = true) || host.endsWith(".cognitiveservices.azure.com", ignoreCase = true) -> - if (trimmedBaseUrl.endsWith("/openai/v1")) AZURE_UNIFIED + if (uri.rawPath.trimEnd('/').endsWith("/openai/v1")) AZURE_UNIFIED else AZURE_LEGACY else -> NON_AZURE diff --git a/openai-java-core/src/main/kotlin/com/openai/core/http/AsyncResponseConnection.kt b/openai-java-core/src/main/kotlin/com/openai/core/http/AsyncResponseConnection.kt index fe5b0674a..8dd84e7b9 100644 --- a/openai-java-core/src/main/kotlin/com/openai/core/http/AsyncResponseConnection.kt +++ b/openai-java-core/src/main/kotlin/com/openai/core/http/AsyncResponseConnection.kt @@ -6,7 +6,6 @@ import com.fasterxml.jackson.databind.node.ObjectNode import com.openai.azure.AzureUrlCategory import com.openai.core.ClientOptions import com.openai.core.RequestOptions -import com.openai.core.prepare import com.openai.models.responses.Response import com.openai.models.responses.ResponsesClientEvent import com.openai.models.responses.ResponsesServerEvent @@ -138,18 +137,11 @@ private constructor( } clientOptions.requireWebSocketTransport() val request = - HttpRequest.builder() - .method(HttpMethod.GET) - .baseUrl(clientOptions.baseUrl()) - .pathSegments( - com.openai.models.responses.ResponseWebSocketBinding.create().pathSegments - ) - .build() - .prepare( - clientOptions, - options, - com.openai.models.responses.ResponseWebSocketBinding.create().security, - ) + WebSocketConnections.request( + com.openai.models.responses.ResponseWebSocketBinding.create(), + clientOptions, + options, + ) val opening = clientOptions.connectWebSocket( request, diff --git a/openai-java-core/src/main/kotlin/com/openai/core/http/WebSocketConnections.kt b/openai-java-core/src/main/kotlin/com/openai/core/http/WebSocketConnections.kt index eb5c9b378..7ec81bca9 100644 --- a/openai-java-core/src/main/kotlin/com/openai/core/http/WebSocketConnections.kt +++ b/openai-java-core/src/main/kotlin/com/openai/core/http/WebSocketConnections.kt @@ -2,6 +2,7 @@ package com.openai.core.http import com.openai.azure.AzureUrlCategory import com.openai.core.ClientOptions +import com.openai.core.Params import com.openai.core.RequestOptions import com.openai.core.prepare import java.net.URI @@ -50,10 +51,10 @@ internal object WebSocketConnections { ) .open(blocking) { it } - private fun request( + fun request( binding: WebSocketBinding<*, *>, clientOptions: ClientOptions, - options: WebSocketOptions, + options: Params, ): HttpRequest { val baseUrl = URI(clientOptions.baseUrl()) val requestBase = baseUrl.toASCIIString().substringBefore('#').substringBefore('?') diff --git a/openai-java-core/src/main/kotlin/com/openai/helpers/RealtimeConnection.kt b/openai-java-core/src/main/kotlin/com/openai/helpers/RealtimeConnection.kt index 55c555401..e84d83a91 100644 --- a/openai-java-core/src/main/kotlin/com/openai/helpers/RealtimeConnection.kt +++ b/openai-java-core/src/main/kotlin/com/openai/helpers/RealtimeConnection.kt @@ -6,16 +6,14 @@ import com.google.errorprone.annotations.MustBeClosed import com.openai.azure.AzureUrlCategory import com.openai.core.ClientOptions import com.openai.core.RequestOptions -import com.openai.core.http.HttpMethod -import com.openai.core.http.HttpRequest import com.openai.core.http.WebSocketClient +import com.openai.core.http.WebSocketConnections import com.openai.core.http.WebSocketHandshakeException import com.openai.core.http.WebSocketReadFuture import com.openai.core.http.WebSocketWriteNotAttempted import com.openai.core.http.awaitWebSocketFuture import com.openai.core.http.dispatchWebSocketCallback import com.openai.core.http.utf8Size -import com.openai.core.prepare import com.openai.errors.OpenAIIoException import com.openai.models.realtime.RealtimeClientEvent import com.openai.models.realtime.RealtimeServerEvent @@ -153,30 +151,14 @@ private constructor( "GA Realtime WebSockets require unified Azure routing" } val request = - HttpRequest.builder() - .method(HttpMethod.GET) - .baseUrl(clientOptions.baseUrl()) - .pathSegments( - com.openai.models.realtime.RealtimeWebSocketBinding.create().pathSegments - ) - .build() - .prepare( - clientOptions, - options, - com.openai.models.realtime.RealtimeWebSocketBinding.create().security, - ) + WebSocketConnections.request( + com.openai.models.realtime.RealtimeWebSocketBinding.create(), + clientOptions, + options, + ) if (options.reconnect) { - val baseQueryKeys = - java.net - .URI(request.baseUrl) - .rawQuery - ?.split('&') - ?.map { java.net.URLDecoder.decode(it.substringBefore('='), "UTF-8") } - .orEmpty() require( - "intent" !in baseQueryKeys && - "call_id" !in baseQueryKeys && - request.queryParams.values("intent").isEmpty() && + request.queryParams.values("intent").isEmpty() && request.queryParams.values("call_id").isEmpty() ) { "Automatic Realtime recovery requires a model session" diff --git a/openai-java-core/src/test/kotlin/com/openai/azure/AzureUrlCategoryTest.kt b/openai-java-core/src/test/kotlin/com/openai/azure/AzureUrlCategoryTest.kt index bf878178f..cd9bba73c 100644 --- a/openai-java-core/src/test/kotlin/com/openai/azure/AzureUrlCategoryTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/azure/AzureUrlCategoryTest.kt @@ -4,6 +4,34 @@ import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test internal class AzureUrlCategoryTest { + @Test + fun autoUsesThePathBeforeQueryAndFragment() { + for (host in listOf("region.openai.azure.com", "region.services.ai.azure.com")) { + for (suffix in listOf("?tenant=base", "/?api-version=fixture", "/#region")) { + val url = "https://$host/openai/v1$suffix" + assertThat(AzureUrlCategory.categorizeBaseUrl(url, AzureUrlPathMode.AUTO)) + .isEqualTo(AzureUrlCategory.AZURE_UNIFIED) + assertThat(AzureUrlCategory.categorizeBaseUrl(url, AzureUrlPathMode.LEGACY)) + .isEqualTo(AzureUrlCategory.AZURE_LEGACY) + } + for (suffix in listOf("?next=/openai/v1", "#/openai/v1", "/openai%2fv1")) { + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://$host/openai$suffix", + AzureUrlPathMode.AUTO, + ) + ) + .isEqualTo(AzureUrlCategory.AZURE_LEGACY) + } + } + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://region.openai.azure.com.example/openai/v1?tenant=base", + AzureUrlPathMode.AUTO, + ) + ) + .isEqualTo(AzureUrlCategory.NON_AZURE) + } @Test fun isAzure() { From 07d6fd2c28cf2bdcc98115972e35f81c6e1c1512 Mon Sep 17 00:00:00 2001 From: Mark Stuart <323302876+markstuart-oai@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:00:21 +0000 Subject: [PATCH 2/4] fix(websocket): preserve paths for provider authenticators --- .../client/okhttp/WebSocketParityTest.kt | 39 ++++++++++-- .../openai/core/http/WebSocketConnections.kt | 62 ++++++++++--------- 2 files changed, 65 insertions(+), 36 deletions(-) diff --git a/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/WebSocketParityTest.kt b/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/WebSocketParityTest.kt index c2b9cac6e..2d84ecbf7 100644 --- a/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/WebSocketParityTest.kt +++ b/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/WebSocketParityTest.kt @@ -1,6 +1,9 @@ package com.openai.client.okhttp import com.openai.core.ClientOptions +import com.openai.core.http.HttpMethod +import com.openai.core.http.HttpRequest +import com.openai.core.http.HttpRequestAuthenticator import com.openai.core.http.ResponseConnection import com.openai.core.http.ResponseWebSocketOptions import com.openai.core.http.WebSocketOptions @@ -62,13 +65,35 @@ class WebSocketParityTest { val client = ClientOptions.builder() .httpClient(http) - .apiKey("synthetic") .baseUrl( server - .url("/v1/?tenant=base&model=base-model&keep=a%2Bb") + .url("/v1/?tenant=base&model=base-model&keep=a%2Bb%20%C3%A9") .toString() ) .putQueryParam("tenant", "client") + .httpRequestAuthenticator( + object : HttpRequestAuthenticator { + override fun authenticate(request: HttpRequest): HttpRequest { + assertThat(request.method).isEqualTo(HttpMethod.GET) + assertThat(request.baseUrl) + .isEqualTo(server.url("/v1/").toString()) + assertThat(request.pathSegments).containsExactly(mode) + assertThat(request.queryParams.values("tenant")) + .containsExactly("connection +/é") + assertThat(request.queryParams.values("keep")) + .containsExactly("a+b é") + assertThat(request.queryParams.values("model")) + .containsExactly( + if (mode == "realtime") "connection-model" + else "base-model" + ) + return request + .toBuilder() + .replaceHeaders("Authorization", "signed-synthetic") + .build() + } + } + ) .build() val connection = if (mode == "realtime") { @@ -76,14 +101,14 @@ class WebSocketParityTest { client, RealtimeWebSocketOptions.builder() .model("connection-model") - .putQueryParam("tenant", "connection") + .putQueryParam("tenant", "connection +/é") .build(), ) } else { ResponseConnection.connect( client, ResponseWebSocketOptions.builder() - .putQueryParam("tenant", "connection") + .putQueryParam("tenant", "connection +/é") .build(), ) } @@ -91,12 +116,14 @@ class WebSocketParityTest { val request = server.takeRequest(5, TimeUnit.SECONDS)!! val url = request.requestUrl!! assertThat(url.encodedPath).isEqualTo("/v1/$mode") - assertThat(url.queryParameterValues("tenant")).containsExactly("connection") + assertThat(request.getHeader("Authorization")).isEqualTo("signed-synthetic") + assertThat(url.queryParameterValues("tenant")) + .containsExactly("connection +/é") assertThat(url.queryParameterValues("model")) .containsExactly( if (mode == "realtime") "connection-model" else "base-model" ) - assertThat(url.queryParameterValues("keep")).containsExactly("a+b") + assertThat(url.queryParameterValues("keep")).containsExactly("a+b é") } assertThat(peer.closed.await(5, TimeUnit.SECONDS)).isTrue() } diff --git a/openai-java-core/src/main/kotlin/com/openai/core/http/WebSocketConnections.kt b/openai-java-core/src/main/kotlin/com/openai/core/http/WebSocketConnections.kt index 7ec81bca9..e65ee76d6 100644 --- a/openai-java-core/src/main/kotlin/com/openai/core/http/WebSocketConnections.kt +++ b/openai-java-core/src/main/kotlin/com/openai/core/http/WebSocketConnections.kt @@ -41,7 +41,23 @@ internal object WebSocketConnections { WebSocketConnection( clientOptions, requestOptions, - { request(binding, clientOptions, options) }, + { + val request = request(binding, clientOptions, options) + // Generic bindings can include template values. Preserve their once-encoded + // path for custom transports whose HttpRequest.url() uses form encoding. + val separator = if (request.baseUrl.endsWith("/")) "" else "/" + request + .toBuilder() + .baseUrl( + request.baseUrl + + separator + + binding.pathSegments.joinToString("/") { + URLEncoder.encode(it, "UTF-8").replace("+", "%20") + } + ) + .pathSegments(emptyList()) + .build() + }, binding.eventType, binding.validate, options.maxMessageBytes, @@ -64,36 +80,22 @@ internal object WebSocketConnections { ) { "WebSockets require unified Azure routing" } - val request = - HttpRequest.builder() - .method(HttpMethod.GET) - .baseUrl(requestBase) - .pathSegments(binding.pathSegments) - .apply { - baseUrl.rawQuery - ?.split('&') - ?.filter { it.isNotEmpty() } - ?.forEach { - putQueryParam( - URLDecoder.decode(it.substringBefore('='), "UTF-8"), - URLDecoder.decode(it.substringAfter('=', ""), "UTF-8"), - ) - } - } - .build() - .prepare(clientOptions, options, binding.security) - // Both custom transports and OkHttp must see the same once-encoded path. - val separator = if (requestBase.endsWith("/")) "" else "/" - return request - .toBuilder() - .baseUrl( - requestBase + - separator + - binding.pathSegments.joinToString("/") { - URLEncoder.encode(it, "UTF-8").replace("+", "%20") + return HttpRequest.builder() + .method(HttpMethod.GET) + .baseUrl(requestBase) + .pathSegments(binding.pathSegments) + .apply { + baseUrl.rawQuery + ?.split('&') + ?.filter { it.isNotEmpty() } + ?.forEach { + putQueryParam( + URLDecoder.decode(it.substringBefore('='), "UTF-8"), + URLDecoder.decode(it.substringAfter('=', ""), "UTF-8"), + ) } - ) - .pathSegments(emptyList()) + } .build() + .prepare(clientOptions, options, binding.security) } } From c3e0aaae5ec692e5e335c4695896110d1df18077 Mon Sep 17 00:00:00 2001 From: markstuart-oai Date: Wed, 28 Oct 2026 18:58:58 +0000 Subject: [PATCH 3/4] fix(azure): preserve existing unified HTTP gateway routing --- ...zureRequestPreparationCompatibilityTest.kt | 56 +++++++++++++++++++ .../com/openai/azure/AzureUrlCategory.kt | 7 ++- .../com/openai/azure/AzureUrlCategoryTest.kt | 19 ++++++- 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/AzureRequestPreparationCompatibilityTest.kt b/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/AzureRequestPreparationCompatibilityTest.kt index 2fd8621aa..f4dddf897 100644 --- a/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/AzureRequestPreparationCompatibilityTest.kt +++ b/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/AzureRequestPreparationCompatibilityTest.kt @@ -13,6 +13,8 @@ import com.openai.models.chat.completions.ChatCompletionChunk import com.openai.models.chat.completions.ChatCompletionCreateParams import com.openai.models.embeddings.EmbeddingCreateParams import com.openai.models.responses.ResponseCreateParams +import java.net.InetAddress +import java.net.Proxy import java.util.Optional import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletionException @@ -29,6 +31,60 @@ import org.junit.jupiter.api.TestFactory import org.junit.jupiter.api.assertThrows internal class AzureRequestPreparationCompatibilityTest { + @TestFactory + fun autoPreservesUnifiedRoutingForQueryRoutedAzureGateways() = + listOf(false, true).map { async -> + dynamicTest("query-routed gateway async=$async") { + MockWebServer().use { server -> + server.enqueue( + MockResponse().setHeader("Content-Type", "application/json").setBody("{}") + ) + val transport = + okhttp3.OkHttpClient.Builder() + .dns( + object : okhttp3.Dns { + override fun lookup(hostname: String) = + listOf(InetAddress.getLoopbackAddress()) + } + ) + .proxy(Proxy.NO_PROXY) + .build() + val client = + OpenAIOkHttpClient.builder() + .apiKey("fixture-sdk-auth") + .baseUrl( + "http://fixture.azure-api.net:${server.port}/gateway?route=/openai/v1" + ) + .okHttpClient(transport) + .maxRetries(0) + .build() + try { + val params = + ResponseCreateParams.builder() + .model("deployment-fixture") + .input("Hello") + .build() + if (async) { + client.async().responses().create(params).get(5, TimeUnit.SECONDS) + } else { + client.responses().create(params) + } + val request = server.takeRequest(5, TimeUnit.SECONDS)!! + assertThat(request.method).isEqualTo("POST") + assertThat(request.requestUrl!!.encodedPath).isEqualTo("/gateway/responses") + assertThat(request.requestUrl!!.queryParameterNames) + .containsExactly("route") + assertThat(request.requestUrl!!.queryParameter("route")) + .isEqualTo("/openai/v1") + } finally { + client.close() + transport.dispatcher.executorService.shutdown() + transport.connectionPool.evictAll() + } + } + } + } + @TestFactory fun responsesModelVariantsPreserveWireNames() = listOf(false, true).flatMap { async -> diff --git a/openai-java-core/src/main/kotlin/com/openai/azure/AzureUrlCategory.kt b/openai-java-core/src/main/kotlin/com/openai/azure/AzureUrlCategory.kt index 7ad51617b..a0c13a6bf 100644 --- a/openai-java-core/src/main/kotlin/com/openai/azure/AzureUrlCategory.kt +++ b/openai-java-core/src/main/kotlin/com/openai/azure/AzureUrlCategory.kt @@ -36,7 +36,12 @@ internal enum class AzureUrlCategory { // ones. host.endsWith(".azure-api.net", ignoreCase = true) || host.endsWith(".cognitiveservices.azure.com", ignoreCase = true) -> - if (uri.rawPath.trimEnd('/').endsWith("/openai/v1")) AZURE_UNIFIED + // Keep URLs already routed as unified, including query-routed gateways. + if ( + baseUrl.trim().trimEnd('/').endsWith("/openai/v1") || + uri.rawPath.trimEnd('/').endsWith("/openai/v1") + ) + AZURE_UNIFIED else AZURE_LEGACY else -> NON_AZURE diff --git a/openai-java-core/src/test/kotlin/com/openai/azure/AzureUrlCategoryTest.kt b/openai-java-core/src/test/kotlin/com/openai/azure/AzureUrlCategoryTest.kt index cd9bba73c..843d8da9d 100644 --- a/openai-java-core/src/test/kotlin/com/openai/azure/AzureUrlCategoryTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/azure/AzureUrlCategoryTest.kt @@ -6,7 +6,13 @@ import org.junit.jupiter.api.Test internal class AzureUrlCategoryTest { @Test fun autoUsesThePathBeforeQueryAndFragment() { - for (host in listOf("region.openai.azure.com", "region.services.ai.azure.com")) { + for (host in + listOf( + "region.openai.azure.com", + "region.services.ai.azure.com", + "region.azure-api.net", + "region.cognitiveservices.azure.com", + )) { for (suffix in listOf("?tenant=base", "/?api-version=fixture", "/#region")) { val url = "https://$host/openai/v1$suffix" assertThat(AzureUrlCategory.categorizeBaseUrl(url, AzureUrlPathMode.AUTO)) @@ -14,15 +20,22 @@ internal class AzureUrlCategoryTest { assertThat(AzureUrlCategory.categorizeBaseUrl(url, AzureUrlPathMode.LEGACY)) .isEqualTo(AzureUrlCategory.AZURE_LEGACY) } - for (suffix in listOf("?next=/openai/v1", "#/openai/v1", "/openai%2fv1")) { + for (suffix in listOf("?next=/openai/v1", "#/openai/v1")) { assertThat( AzureUrlCategory.categorizeBaseUrl( "https://$host/openai$suffix", AzureUrlPathMode.AUTO, ) ) - .isEqualTo(AzureUrlCategory.AZURE_LEGACY) + .isEqualTo(AzureUrlCategory.AZURE_UNIFIED) } + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://$host/openai/openai%2fv1", + AzureUrlPathMode.AUTO, + ) + ) + .isEqualTo(AzureUrlCategory.AZURE_LEGACY) } assertThat( AzureUrlCategory.categorizeBaseUrl( From 4686a2432b363f80ba8b588317a465cc3707f4c5 Mon Sep 17 00:00:00 2001 From: markstuart-oai Date: Wed, 28 Oct 2026 19:05:32 +0000 Subject: [PATCH 4/4] fix(websocket): preserve stable Azure gateway routing --- ...zureRequestPreparationCompatibilityTest.kt | 110 ++++++++++++++++++ .../openai/core/http/WebSocketConnections.kt | 19 ++- 2 files changed, 123 insertions(+), 6 deletions(-) diff --git a/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/AzureRequestPreparationCompatibilityTest.kt b/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/AzureRequestPreparationCompatibilityTest.kt index f4dddf897..68d00a762 100644 --- a/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/AzureRequestPreparationCompatibilityTest.kt +++ b/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/AzureRequestPreparationCompatibilityTest.kt @@ -3,10 +3,14 @@ package com.openai.client.okhttp import com.fasterxml.jackson.databind.ObjectMapper import com.openai.azure.AzureUrlPathMode import com.openai.client.OpenAIClient +import com.openai.core.ClientOptions +import com.openai.core.http.AsyncResponseConnection import com.openai.core.http.AsyncStreamResponse +import com.openai.core.http.ResponseConnection import com.openai.core.jsonMapper import com.openai.credential.BearerTokenCredential import com.openai.errors.InvalidResourceIdException +import com.openai.helpers.RealtimeConnection import com.openai.models.ChatModel import com.openai.models.ResponsesModel import com.openai.models.chat.completions.ChatCompletionChunk @@ -25,12 +29,118 @@ import java.util.function.Supplier import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.jupiter.api.DynamicTest.dynamicTest import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestFactory import org.junit.jupiter.api.assertThrows internal class AzureRequestPreparationCompatibilityTest { + @TestFactory + fun webSocketsPreserveQueryRoutedAzureGatewaysAndRejectLegacy() = + listOf("responses", "realtime", "beta").flatMap { endpoint -> + listOf(false, true).flatMap { async -> + listOf( + Triple(AzureUrlPathMode.AUTO, "?route=/openai/v1", true), + Triple(AzureUrlPathMode.AUTO, "", false), + Triple(AzureUrlPathMode.LEGACY, "?route=/openai/v1", false), + ) + .map { (mode, query, accepted) -> + dynamicTest("$endpoint async=$async mode=$mode query=$query") { + MockWebServer().use { server -> + server.enqueue( + MockResponse() + .withWebSocketUpgrade( + object : okhttp3.WebSocketListener() { + override fun onClosing( + socket: okhttp3.WebSocket, + code: Int, + reason: String, + ) { + socket.close(code, reason) + } + } + ) + ) + val native = + okhttp3.OkHttpClient.Builder() + .dns( + object : okhttp3.Dns { + override fun lookup(hostname: String) = + listOf(InetAddress.getLoopbackAddress()) + } + ) + .proxy(Proxy.NO_PROXY) + .build() + val options = + ClientOptions.builder() + .apiKey("fixture-sdk-auth") + .baseUrl( + "http://fixture.azure-api.net:${server.port}/gateway$query" + ) + .azureUrlPathMode(mode) + .httpClient(OkHttpClient(native)) + .build() + try { + val connect = { + when (endpoint) { + "realtime" -> + if (async) + RealtimeConnection.connectAsync(options) + .get(5, TimeUnit.SECONDS) + else RealtimeConnection.connect(options) + "beta" -> + if (async) + com.openai.client + .OpenAIClientAsyncImpl(options) + .beta() + .responses() + .connectWebSocket() + .get(5, TimeUnit.SECONDS) + else + com.openai.client + .OpenAIClientImpl(options) + .beta() + .responses() + .connectWebSocket() + else -> + if (async) + AsyncResponseConnection.connect(options) + .get(5, TimeUnit.SECONDS) + else ResponseConnection.connect(options) + } + } + if (accepted && endpoint != "beta") { + connect().use { + val request = server.takeRequest(5, TimeUnit.SECONDS)!! + assertThat(request.method).isEqualTo("GET") + assertThat(request.requestUrl!!.encodedPath) + .isEqualTo("/gateway/$endpoint") + assertThat(request.requestUrl!!.queryParameterNames) + .containsExactly("route") + assertThat( + request.requestUrl!!.queryParameterValues( + "route" + ) + ) + .containsExactly("/openai/v1") + } + } else { + assertThatThrownBy { connect().close() } + .hasMessageContaining("unified Azure routing") + assertThat(server.requestCount).isZero() + } + } finally { + options.close() + native.dispatcher.executorService.shutdown() + native.connectionPool.evictAll() + } + } + } + } + } + } + @TestFactory fun autoPreservesUnifiedRoutingForQueryRoutedAzureGateways() = listOf(false, true).map { async -> diff --git a/openai-java-core/src/main/kotlin/com/openai/core/http/WebSocketConnections.kt b/openai-java-core/src/main/kotlin/com/openai/core/http/WebSocketConnections.kt index e65ee76d6..69df103e2 100644 --- a/openai-java-core/src/main/kotlin/com/openai/core/http/WebSocketConnections.kt +++ b/openai-java-core/src/main/kotlin/com/openai/core/http/WebSocketConnections.kt @@ -42,6 +42,19 @@ internal object WebSocketConnections { clientOptions, requestOptions, { + val requestBase = + URI(clientOptions.baseUrl()) + .toASCIIString() + .substringBefore('#') + .substringBefore('?') + require( + AzureUrlCategory.categorizeBaseUrl( + requestBase, + clientOptions.azureUrlPathMode, + ) != AzureUrlCategory.AZURE_LEGACY + ) { + "WebSockets require unified Azure routing" + } val request = request(binding, clientOptions, options) // Generic bindings can include template values. Preserve their once-encoded // path for custom transports whose HttpRequest.url() uses form encoding. @@ -74,12 +87,6 @@ internal object WebSocketConnections { ): HttpRequest { val baseUrl = URI(clientOptions.baseUrl()) val requestBase = baseUrl.toASCIIString().substringBefore('#').substringBefore('?') - require( - AzureUrlCategory.categorizeBaseUrl(requestBase, clientOptions.azureUrlPathMode) != - AzureUrlCategory.AZURE_LEGACY - ) { - "WebSockets require unified Azure routing" - } return HttpRequest.builder() .method(HttpMethod.GET) .baseUrl(requestBase)