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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
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
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
Expand Down Expand Up @@ -48,6 +55,82 @@ 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)
.baseUrl(
server
.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") {
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(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(peer.closed.await(5, TimeUnit.SECONDS)).isTrue()
}
}
}
}

@Test
fun betaConnectionsUseBetaTypesInBothModes() {
for (async in listOf(false, true)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -40,7 +41,23 @@ internal object WebSocketConnections {
WebSocketConnection<C, S>(
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,
Expand All @@ -50,10 +67,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('?')
Expand All @@ -63,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"),
)
Comment thread
markstuart-oai marked this conversation as resolved.
}
)
.pathSegments(emptyList())
}
.build()
.prepare(clientOptions, options, binding.security)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading