From b41c092a2dc2456bfcc4d674aeab0da28902074d Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Fri, 6 Jun 2025 14:35:54 +0200 Subject: [PATCH 01/16] Drafting proposal to allow for flexible base URL # Conflicts: # openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt # openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt --- .../client/okhttp/OpenAIOkHttpClient.kt | 4 ++++ .../client/okhttp/OpenAIOkHttpClientAsync.kt | 3 +++ .../azure/HttpRequestBuilderExtensions.kt | 2 +- .../kotlin/com/openai/core/ClientOptions.kt | 6 +++++ .../com/openai/core/http/ClientOptionsTest.kt | 23 +++++++++++++++++++ 5 files changed, 37 insertions(+), 1 deletion(-) diff --git a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt index 6d34a73ce..763af7b83 100644 --- a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt +++ b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt @@ -186,6 +186,10 @@ class OpenAIOkHttpClient private constructor() { fun fromEnv() = apply { clientOptions.fromEnv() } + fun modelInPath(modelInPath: Boolean) = apply { + clientOptions.modelInPath(modelInPath) + } + /** * Returns an immutable instance of [OpenAIClient]. * diff --git a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt index ad00f0d90..df7a5e664 100644 --- a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt +++ b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt @@ -186,6 +186,9 @@ class OpenAIOkHttpClientAsync private constructor() { fun fromEnv() = apply { clientOptions.fromEnv() } + fun modelInPath(modelInPath: Boolean) = apply { + clientOptions.modelInPath(modelInPath) + } /** * Returns an immutable instance of [OpenAIClientAsync]. * diff --git a/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt b/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt index cbadd4730..834fce676 100644 --- a/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt +++ b/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt @@ -10,7 +10,7 @@ internal fun HttpRequest.Builder.addPathSegmentsForAzure( clientOptions: ClientOptions, deploymentModel: String?, ): HttpRequest.Builder = apply { - if (isAzureEndpoint(clientOptions.baseUrl())) { + if (isAzureEndpoint(clientOptions.baseUrl()) || clientOptions.modelInPath) { addPathSegment("openai") deploymentModel?.let { addPathSegments("deployments", it) } } diff --git a/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt b/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt index 9e1255072..243aabc5d 100644 --- a/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt +++ b/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt @@ -36,6 +36,7 @@ private constructor( @get:JvmName("maxRetries") val maxRetries: Int, @get:JvmName("credential") val credential: Credential, @get:JvmName("azureServiceVersion") val azureServiceVersion: AzureOpenAIServiceVersion?, + @get:JvmName("modelInPath") val modelInPath: Boolean = false, private val organization: String?, private val project: String?, private val webhookSecret: String?, @@ -93,6 +94,7 @@ private constructor( private var azureServiceVersion: AzureOpenAIServiceVersion? = null private var organization: String? = null private var project: String? = null + private var modelInPath: Boolean = false private var webhookSecret: String? = null @JvmSynthetic @@ -113,6 +115,7 @@ private constructor( organization = clientOptions.organization project = clientOptions.project webhookSecret = clientOptions.webhookSecret + modelInPath = clientOptions.modelInPath } fun httpClient(httpClient: HttpClient) = apply { @@ -279,6 +282,8 @@ private constructor( } } + fun modelInPath(modelInPath: Boolean) = apply { this.modelInPath = modelInPath } + /** * Returns an immutable instance of [ClientOptions]. * @@ -368,6 +373,7 @@ private constructor( maxRetries, credential, azureServiceVersion, + modelInPath, organization, project, webhookSecret, diff --git a/openai-java-core/src/test/kotlin/com/openai/core/http/ClientOptionsTest.kt b/openai-java-core/src/test/kotlin/com/openai/core/http/ClientOptionsTest.kt index 0e9c31525..6595c5ed4 100644 --- a/openai-java-core/src/test/kotlin/com/openai/core/http/ClientOptionsTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/core/http/ClientOptionsTest.kt @@ -65,4 +65,27 @@ internal class ClientOptionsTest { .isInstanceOf(IllegalArgumentException::class.java) .hasMessage("Azure API key cannot be empty.") } + + @Test + fun modelInPathTestSet() { + val clientOptions = + ClientOptions.builder() + .httpClient(createOkHttpClient("https://api.openai.com/v1")) + .credential(BearerTokenCredential.create(FAKE_API_KEY)) + .modelInPath(true) + .build() + + assertThat(clientOptions.modelInPath).isTrue() + } + + @Test + fun modelInPathTestDefaultFalse() { + val clientOptions = + ClientOptions.builder() + .httpClient(createOkHttpClient("https://api.openai.com/v1")) + .credential(BearerTokenCredential.create(FAKE_API_KEY)) + .build() + + assertThat(clientOptions.modelInPath).isFalse() + } } From 4c47c2eba2cb9cf4aec547e39a98afd8db00bc01 Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Wed, 2 Jul 2025 15:50:02 +0200 Subject: [PATCH 02/16] Added utility functions for the unified v1 and legacy azure endpoints --- .../client/okhttp/OpenAIOkHttpClient.kt | 4 +-- .../client/okhttp/OpenAIOkHttpClientAsync.kt | 4 +-- .../azure/HttpRequestBuilderExtensions.kt | 13 ++++++-- .../kotlin/com/openai/core/ClientOptions.kt | 30 ++++++++++------- .../src/main/kotlin/com/openai/core/Utils.kt | 18 ++++++++-- .../test/kotlin/com/openai/core/UtilsTest.kt | 33 ++++++++++++++++++- .../com/openai/core/http/ClientOptionsTest.kt | 14 ++++---- .../AzureDisabledUnifiedEndpointsExample.java | 32 ++++++++++++++++++ .../openai/example/AzureEntraIdExample.java | 2 +- .../example/AzureUnifiedEndpointExample.java | 31 +++++++++++++++++ 10 files changed, 152 insertions(+), 29 deletions(-) create mode 100644 openai-java-example/src/main/java/com/openai/example/AzureDisabledUnifiedEndpointsExample.java create mode 100644 openai-java-example/src/main/java/com/openai/example/AzureUnifiedEndpointExample.java diff --git a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt index 763af7b83..ed2e97099 100644 --- a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt +++ b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt @@ -186,8 +186,8 @@ class OpenAIOkHttpClient private constructor() { fun fromEnv() = apply { clientOptions.fromEnv() } - fun modelInPath(modelInPath: Boolean) = apply { - clientOptions.modelInPath(modelInPath) + fun unifiedAzureRoutes(unifiedAzureRoutes: Boolean) = apply { + clientOptions.unifiedAzureRoutes(unifiedAzureRoutes) } /** diff --git a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt index df7a5e664..da4521eed 100644 --- a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt +++ b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt @@ -186,8 +186,8 @@ class OpenAIOkHttpClientAsync private constructor() { fun fromEnv() = apply { clientOptions.fromEnv() } - fun modelInPath(modelInPath: Boolean) = apply { - clientOptions.modelInPath(modelInPath) + fun unifiedAzureRoutes(unifiedAzureRoutes: Boolean) = apply { + clientOptions.unifiedAzureRoutes(unifiedAzureRoutes) } /** * Returns an immutable instance of [OpenAIClientAsync]. diff --git a/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt b/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt index 834fce676..fa8874e84 100644 --- a/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt +++ b/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt @@ -3,6 +3,7 @@ package com.openai.azure import com.openai.core.ClientOptions import com.openai.core.http.HttpRequest import com.openai.core.isAzureEndpoint +import com.openai.core.isAzureUnifiedEndpoint import com.openai.credential.BearerTokenCredential @JvmSynthetic @@ -10,9 +11,17 @@ internal fun HttpRequest.Builder.addPathSegmentsForAzure( clientOptions: ClientOptions, deploymentModel: String?, ): HttpRequest.Builder = apply { - if (isAzureEndpoint(clientOptions.baseUrl()) || clientOptions.modelInPath) { + val baseUrl = clientOptions.baseUrl() + if (isAzureEndpoint(baseUrl)) { addPathSegment("openai") - deploymentModel?.let { addPathSegments("deployments", it) } + // Users can toggle off unified Azure routes using the "unifiedAzureRoutes" option. + if (clientOptions.unifiedAzureRoutes && isAzureUnifiedEndpoint(baseUrl)) { + addPathSegment("v1") + } else { + // Unknown Azure endpoints and legacy Azure endpoints are treated the old way. + // We are assuming in this branch that isAzureLegacyEndpoint(baseUrl) would be true for this base URL. + deploymentModel?.let { addPathSegments("deployments", it) } + } } } diff --git a/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt b/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt index 243aabc5d..d72ecc41c 100644 --- a/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt +++ b/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt @@ -36,7 +36,7 @@ private constructor( @get:JvmName("maxRetries") val maxRetries: Int, @get:JvmName("credential") val credential: Credential, @get:JvmName("azureServiceVersion") val azureServiceVersion: AzureOpenAIServiceVersion?, - @get:JvmName("modelInPath") val modelInPath: Boolean = false, + @get:JvmName("unifiedAzureRoutes") val unifiedAzureRoutes: Boolean = true, private val organization: String?, private val project: String?, private val webhookSecret: String?, @@ -94,8 +94,8 @@ private constructor( private var azureServiceVersion: AzureOpenAIServiceVersion? = null private var organization: String? = null private var project: String? = null - private var modelInPath: Boolean = false private var webhookSecret: String? = null + private var unifiedAzureRoutes: Boolean = true @JvmSynthetic internal fun from(clientOptions: ClientOptions) = apply { @@ -115,7 +115,7 @@ private constructor( organization = clientOptions.organization project = clientOptions.project webhookSecret = clientOptions.webhookSecret - modelInPath = clientOptions.modelInPath + unifiedAzureRoutes = clientOptions.unifiedAzureRoutes } fun httpClient(httpClient: HttpClient) = apply { @@ -282,7 +282,7 @@ private constructor( } } - fun modelInPath(modelInPath: Boolean) = apply { this.modelInPath = modelInPath } + fun unifiedAzureRoutes(unifiedAzureRoutes: Boolean) = apply { this.unifiedAzureRoutes = unifiedAzureRoutes } /** * Returns an immutable instance of [ClientOptions]. @@ -327,13 +327,19 @@ private constructor( baseUrl?.let { if (isAzureEndpoint(it)) { - // Default Azure OpenAI version is used if Azure user doesn't - // specific a service API version in 'queryParams'. - replaceQueryParams( - "api-version", - (azureServiceVersion ?: AzureOpenAIServiceVersion.latestStableVersion()) - .value, - ) + // Non Azure-unified routes will still require an api-version value. + if (!unifiedAzureRoutes || !isAzureUnifiedEndpoint(it)) { + replaceQueryParams( + "api-version", + (azureServiceVersion ?: AzureOpenAIServiceVersion.latestStableVersion()) + .value, + ) + } else { + // We only add the value if it's defined by the user for unified Azure routes. + azureServiceVersion?.let { version -> + replaceQueryParams("api-version", version.value) + } + } } } @@ -373,7 +379,7 @@ private constructor( maxRetries, credential, azureServiceVersion, - modelInPath, + unifiedAzureRoutes, organization, project, webhookSecret, diff --git a/openai-java-core/src/main/kotlin/com/openai/core/Utils.kt b/openai-java-core/src/main/kotlin/com/openai/core/Utils.kt index 06e1cfc69..cfd11c198 100644 --- a/openai-java-core/src/main/kotlin/com/openai/core/Utils.kt +++ b/openai-java-core/src/main/kotlin/com/openai/core/Utils.kt @@ -91,13 +91,27 @@ internal fun Any?.contentToString(): String { @JvmSynthetic internal fun isAzureEndpoint(baseUrl: String): Boolean { - // Azure Endpoint should be in the format of `https://.openai.azure.com`. + // Azure legacy endpoint should be in the format of `https://.openai.azure.com`. + // Or Azure unified endpoint should be in the format of `https://.services.ai.azure.com`. // Or `https://.azure-api.net` for Azure OpenAI Management URL. // Or `-random-.cognitiveservices.azure.com`. val trimmedBaseUrl = baseUrl.trim().trimEnd('/') - return trimmedBaseUrl.endsWith(".openai.azure.com", true) || + return isAzureLegacyEndpoint(trimmedBaseUrl) || isAzureUnifiedEndpoint(baseUrl) || + // exceptions: trimmedBaseUrl.endsWith(".azure-api.net", true) || trimmedBaseUrl.endsWith(".cognitiveservices.azure.com", true) } +@JvmSynthetic +internal fun isAzureLegacyEndpoint(baseUrl: String): Boolean { + val trimmedBaseUrl = baseUrl.trim().trimEnd('/') + return trimmedBaseUrl.endsWith(".openai.azure.com", true) +} + +@JvmSynthetic +internal fun isAzureUnifiedEndpoint(baseUrl: String): Boolean { + val trimmedBaseUrl = baseUrl.trim().trimEnd('/') + return trimmedBaseUrl.endsWith(".services.ai.azure.com", true) +} + internal interface Enum diff --git a/openai-java-core/src/test/kotlin/com/openai/core/UtilsTest.kt b/openai-java-core/src/test/kotlin/com/openai/core/UtilsTest.kt index bb87b5c86..8f7948cdd 100644 --- a/openai-java-core/src/test/kotlin/com/openai/core/UtilsTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/core/UtilsTest.kt @@ -46,4 +46,35 @@ internal class UtilsTest { assertThat(isAzureEndpoint("")).isFalse() assertThat(isAzureEndpoint(" ")).isFalse() } -} + + @Test + fun isAzureLegacyEndpoint() { + // Valid Azure legacy endpoints + assertThat(isAzureLegacyEndpoint("https://region.openai.azure.com")).isTrue() + assertThat(isAzureLegacyEndpoint("https://region.openai.azure.com/")).isTrue() + + // Invalid Azure legacy endpoints + assertThat(isAzureLegacyEndpoint("https://region.azure-api.net")).isFalse() + assertThat(isAzureLegacyEndpoint("https://region.services.ai.azure.com")).isFalse() + assertThat(isAzureLegacyEndpoint("https://example.com")).isFalse() + assertThat(isAzureLegacyEndpoint("https://region.openai.com")).isFalse() + assertThat(isAzureLegacyEndpoint("https://region.azure.com")).isFalse() + assertThat(isAzureLegacyEndpoint("")).isFalse() + assertThat(isAzureLegacyEndpoint(" ")).isFalse() + } + + @Test + fun isAzureUnifiedEndpoint() { + // Valid Azure unified endpoints + assertThat(isAzureUnifiedEndpoint("https://region.services.ai.azure.com")).isTrue() + assertThat(isAzureUnifiedEndpoint("https://region.services.ai.azure.com/")).isTrue() + + // Invalid Azure unified endpoints + assertThat(isAzureUnifiedEndpoint("https://region.openai.azure.com")).isFalse() + assertThat(isAzureUnifiedEndpoint("https://example.com")).isFalse() + assertThat(isAzureUnifiedEndpoint("https://region.openai.com")).isFalse() + assertThat(isAzureUnifiedEndpoint("https://region.azure.com")).isFalse() + assertThat(isAzureUnifiedEndpoint("")).isFalse() + assertThat(isAzureUnifiedEndpoint(" ")).isFalse() + } +} \ No newline at end of file diff --git a/openai-java-core/src/test/kotlin/com/openai/core/http/ClientOptionsTest.kt b/openai-java-core/src/test/kotlin/com/openai/core/http/ClientOptionsTest.kt index 6595c5ed4..c88462ce3 100644 --- a/openai-java-core/src/test/kotlin/com/openai/core/http/ClientOptionsTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/core/http/ClientOptionsTest.kt @@ -67,25 +67,25 @@ internal class ClientOptionsTest { } @Test - fun modelInPathTestSet() { + fun modelInPathTestSetFalse() { val clientOptions = ClientOptions.builder() - .httpClient(createOkHttpClient("https://api.openai.com/v1")) + .httpClient(createOkHttpClient()) .credential(BearerTokenCredential.create(FAKE_API_KEY)) - .modelInPath(true) + .unifiedAzureRoutes(false) .build() - assertThat(clientOptions.modelInPath).isTrue() + assertThat(clientOptions.unifiedAzureRoutes).isFalse() } @Test - fun modelInPathTestDefaultFalse() { + fun modelInPathTestDefaultTrue() { val clientOptions = ClientOptions.builder() - .httpClient(createOkHttpClient("https://api.openai.com/v1")) + .httpClient(createOkHttpClient()) .credential(BearerTokenCredential.create(FAKE_API_KEY)) .build() - assertThat(clientOptions.modelInPath).isFalse() + assertThat(clientOptions.unifiedAzureRoutes).isTrue() } } diff --git a/openai-java-example/src/main/java/com/openai/example/AzureDisabledUnifiedEndpointsExample.java b/openai-java-example/src/main/java/com/openai/example/AzureDisabledUnifiedEndpointsExample.java new file mode 100644 index 000000000..4b470b14e --- /dev/null +++ b/openai-java-example/src/main/java/com/openai/example/AzureDisabledUnifiedEndpointsExample.java @@ -0,0 +1,32 @@ +package com.openai.example; + +import com.openai.azure.AzureOpenAIServiceVersion; +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import com.openai.models.ChatModel; +import com.openai.models.chat.completions.ChatCompletionCreateParams; + +public final class AzureDisabledUnifiedEndpointsExample { + private AzureDisabledUnifiedEndpointsExample() {} + + public static void main(String[] args) { + OpenAIClient client = OpenAIOkHttpClient.builder() + // Gets the API key from the `AZURE_OPENAI_KEY` environment variable + .fromEnv() + .azureServiceVersion(AzureOpenAIServiceVersion.getV2024_05_01_PREVIEW()) + // Disabling unified endpoints will result in the deployment name being passed as a path parameter + .unifiedAzureRoutes(false) + .build(); + + ChatCompletionCreateParams createParams = ChatCompletionCreateParams.builder() + .model(ChatModel.of("DeepSeek-R1")) + .maxCompletionTokens(2048) + .addSystemMessage("Make sure you mention Stainless!") // Developer doesn't work + .addUserMessage("Tell me a story about building the best SDK!") + .build(); + + client.chat().completions().create(createParams).choices().stream() + .flatMap(choice -> choice.message().content().stream()) + .forEach(System.out::println); + } +} \ No newline at end of file diff --git a/openai-java-example/src/main/java/com/openai/example/AzureEntraIdExample.java b/openai-java-example/src/main/java/com/openai/example/AzureEntraIdExample.java index cb67710f6..e273aa259 100644 --- a/openai-java-example/src/main/java/com/openai/example/AzureEntraIdExample.java +++ b/openai-java-example/src/main/java/com/openai/example/AzureEntraIdExample.java @@ -21,7 +21,7 @@ public static void main(String[] args) { .build(); ChatCompletionCreateParams createParams = ChatCompletionCreateParams.builder() - .model(ChatModel.GPT_3_5_TURBO) + .model(ChatModel.GPT_4_1106_PREVIEW) .maxCompletionTokens(2048) .addDeveloperMessage("Make sure you mention Stainless!") .addUserMessage("Tell me a story about building the best SDK!") diff --git a/openai-java-example/src/main/java/com/openai/example/AzureUnifiedEndpointExample.java b/openai-java-example/src/main/java/com/openai/example/AzureUnifiedEndpointExample.java new file mode 100644 index 000000000..7445a739a --- /dev/null +++ b/openai-java-example/src/main/java/com/openai/example/AzureUnifiedEndpointExample.java @@ -0,0 +1,31 @@ +package com.openai.example; + +import com.openai.azure.AzureOpenAIServiceVersion; +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import com.openai.models.ChatModel; +import com.openai.models.chat.completions.ChatCompletionCreateParams; + +public final class AzureUnifiedEndpointExample { + private AzureUnifiedEndpointExample() {} + + public static void main(String[] args) { + OpenAIClient client = OpenAIOkHttpClient.builder() + // Gets the API key from the `AZURE_OPENAI_KEY` environment variable + .fromEnv() + // TODO: remove preview once the api-version has become optional + .azureServiceVersion(AzureOpenAIServiceVersion.fromString("preview")) + .build(); + + ChatCompletionCreateParams createParams = ChatCompletionCreateParams.builder() + .model(ChatModel.of("DeepSeek-R1")) + .maxCompletionTokens(2048) + .addSystemMessage("Make sure you mention Stainless!") // Developer doesn't work + .addUserMessage("Tell me a story about building the best SDK!") + .build(); + + client.chat().completions().create(createParams).choices().stream() + .flatMap(choice -> choice.message().content().stream()) + .forEach(System.out::println); + } +} \ No newline at end of file From b387a3b906928b8a4c440debbc2f6f4d86e85aea Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Wed, 2 Jul 2025 15:59:54 +0200 Subject: [PATCH 03/16] Renamed tests --- .../src/test/kotlin/com/openai/core/http/ClientOptionsTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openai-java-core/src/test/kotlin/com/openai/core/http/ClientOptionsTest.kt b/openai-java-core/src/test/kotlin/com/openai/core/http/ClientOptionsTest.kt index c88462ce3..36a2f32f7 100644 --- a/openai-java-core/src/test/kotlin/com/openai/core/http/ClientOptionsTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/core/http/ClientOptionsTest.kt @@ -67,7 +67,7 @@ internal class ClientOptionsTest { } @Test - fun modelInPathTestSetFalse() { + fun unifiedAzureRoutesTestSetFalse() { val clientOptions = ClientOptions.builder() .httpClient(createOkHttpClient()) @@ -79,7 +79,7 @@ internal class ClientOptionsTest { } @Test - fun modelInPathTestDefaultTrue() { + fun unifiedAzureRoutesTestDefaultTrue() { val clientOptions = ClientOptions.builder() .httpClient(createOkHttpClient()) From c5523efa60ba3d905bcec98bbf13823be4716249 Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Wed, 16 Jul 2025 11:28:54 +0200 Subject: [PATCH 04/16] Aligned impl with spec, fixed tests, added docs --- .../azure/HttpRequestBuilderExtensions.kt | 7 ++-- .../src/main/kotlin/com/openai/core/Utils.kt | 35 +++++++++++++------ .../test/kotlin/com/openai/core/UtilsTest.kt | 33 +++++++---------- .../example/AzureKeyCredentialExample.java | 31 ++++++++++++++++ 4 files changed, 71 insertions(+), 35 deletions(-) create mode 100644 openai-java-example/src/main/java/com/openai/example/AzureKeyCredentialExample.java diff --git a/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt b/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt index fa8874e84..df65e2f0e 100644 --- a/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt +++ b/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt @@ -13,13 +13,12 @@ internal fun HttpRequest.Builder.addPathSegmentsForAzure( ): HttpRequest.Builder = apply { val baseUrl = clientOptions.baseUrl() if (isAzureEndpoint(baseUrl)) { - addPathSegment("openai") // Users can toggle off unified Azure routes using the "unifiedAzureRoutes" option. - if (clientOptions.unifiedAzureRoutes && isAzureUnifiedEndpoint(baseUrl)) { - addPathSegment("v1") - } else { + // Endpoints are assumed to be provided with `/v1/openai` in their path already. + if (!clientOptions.unifiedAzureRoutes || !isAzureUnifiedEndpoint(baseUrl)) { // Unknown Azure endpoints and legacy Azure endpoints are treated the old way. // We are assuming in this branch that isAzureLegacyEndpoint(baseUrl) would be true for this base URL. + addPathSegment("openai") deploymentModel?.let { addPathSegments("deployments", it) } } } diff --git a/openai-java-core/src/main/kotlin/com/openai/core/Utils.kt b/openai-java-core/src/main/kotlin/com/openai/core/Utils.kt index cfd11c198..233f63fe7 100644 --- a/openai-java-core/src/main/kotlin/com/openai/core/Utils.kt +++ b/openai-java-core/src/main/kotlin/com/openai/core/Utils.kt @@ -3,6 +3,7 @@ package com.openai.core import com.openai.errors.OpenAIInvalidDataException +import java.net.URI import java.util.Collections import java.util.SortedMap @@ -96,22 +97,34 @@ internal fun isAzureEndpoint(baseUrl: String): Boolean { // Or `https://.azure-api.net` for Azure OpenAI Management URL. // Or `-random-.cognitiveservices.azure.com`. val trimmedBaseUrl = baseUrl.trim().trimEnd('/') - return isAzureLegacyEndpoint(trimmedBaseUrl) || isAzureUnifiedEndpoint(baseUrl) || - // exceptions: - trimmedBaseUrl.endsWith(".azure-api.net", true) || - trimmedBaseUrl.endsWith(".cognitiveservices.azure.com", true) + val url = URI.create(trimmedBaseUrl) + return url.isAzureLegacyEndpoint() || url.isAzureUnifiedEndpoint() || url.isOtherAzureKnownEndpoint() } -@JvmSynthetic -internal fun isAzureLegacyEndpoint(baseUrl: String): Boolean { - val trimmedBaseUrl = baseUrl.trim().trimEnd('/') - return trimmedBaseUrl.endsWith(".openai.azure.com", true) -} +/** + * Returns whether [this] is an Azure OpenAI resource URL with the old schema. + */ +internal fun URI.isAzureLegacyEndpoint(): Boolean = host.endsWith(".openai.azure.com", true) + +/** + * Returns whether [this] is an Azure OpenAI resource URL with the OpenAI unified schema. + */ +internal fun URI.isAzureUnifiedEndpoint(): Boolean = host.endsWith(".services.ai.azure.com", true) + +/** + * Returns whether [this] is an Azure OpenAI resource URL, but with a schema different to the known ones. + */ +internal fun URI.isOtherAzureKnownEndpoint(): Boolean = + host.endsWith(".azure-api.net", true) || + host.endsWith(".cognitiveservices.azure.com", true) +/** + * Convenience function to check if the given [baseUrl] is an Azure OpenAI resource URL with the unified schema. + */ @JvmSynthetic internal fun isAzureUnifiedEndpoint(baseUrl: String): Boolean { - val trimmedBaseUrl = baseUrl.trim().trimEnd('/') - return trimmedBaseUrl.endsWith(".services.ai.azure.com", true) + val url = URI.create(baseUrl.trim().trimEnd('/')) + return url.isAzureUnifiedEndpoint() } internal interface Enum diff --git a/openai-java-core/src/test/kotlin/com/openai/core/UtilsTest.kt b/openai-java-core/src/test/kotlin/com/openai/core/UtilsTest.kt index 8f7948cdd..54137890f 100644 --- a/openai-java-core/src/test/kotlin/com/openai/core/UtilsTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/core/UtilsTest.kt @@ -2,6 +2,7 @@ package com.openai.core import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows internal class UtilsTest { @Test @@ -34,33 +35,25 @@ internal class UtilsTest { @Test fun isAzureEndpoint() { // Valid Azure endpoints + + // legacy assertThat(isAzureEndpoint("https://region.openai.azure.com")).isTrue() assertThat(isAzureEndpoint("https://region.openai.azure.com/")).isTrue() + // unified with OpenAI + assertThat(isAzureEndpoint("https://region.services.ai.azure.com")).isTrue() + assertThat(isAzureEndpoint("https://region.services.ai.azure.com/")).isTrue() + // other known valid schemas assertThat(isAzureEndpoint("https://region.azure-api.net")).isTrue() assertThat(isAzureEndpoint("https://region.azure-api.net/")).isTrue() + assertThat(isAzureEndpoint("https://region.cognitiveservices.azure.com")).isTrue() + assertThat(isAzureEndpoint("https://region.cognitiveservices.azure.com/")).isTrue() // Invalid Azure endpoints assertThat(isAzureEndpoint("https://example.com")).isFalse() assertThat(isAzureEndpoint("https://region.openai.com")).isFalse() assertThat(isAzureEndpoint("https://region.azure.com")).isFalse() - assertThat(isAzureEndpoint("")).isFalse() - assertThat(isAzureEndpoint(" ")).isFalse() - } - - @Test - fun isAzureLegacyEndpoint() { - // Valid Azure legacy endpoints - assertThat(isAzureLegacyEndpoint("https://region.openai.azure.com")).isTrue() - assertThat(isAzureLegacyEndpoint("https://region.openai.azure.com/")).isTrue() - - // Invalid Azure legacy endpoints - assertThat(isAzureLegacyEndpoint("https://region.azure-api.net")).isFalse() - assertThat(isAzureLegacyEndpoint("https://region.services.ai.azure.com")).isFalse() - assertThat(isAzureLegacyEndpoint("https://example.com")).isFalse() - assertThat(isAzureLegacyEndpoint("https://region.openai.com")).isFalse() - assertThat(isAzureLegacyEndpoint("https://region.azure.com")).isFalse() - assertThat(isAzureLegacyEndpoint("")).isFalse() - assertThat(isAzureLegacyEndpoint(" ")).isFalse() + assertThrows {isAzureEndpoint("")} + assertThrows{isAzureEndpoint(" ")} } @Test @@ -74,7 +67,7 @@ internal class UtilsTest { assertThat(isAzureUnifiedEndpoint("https://example.com")).isFalse() assertThat(isAzureUnifiedEndpoint("https://region.openai.com")).isFalse() assertThat(isAzureUnifiedEndpoint("https://region.azure.com")).isFalse() - assertThat(isAzureUnifiedEndpoint("")).isFalse() - assertThat(isAzureUnifiedEndpoint(" ")).isFalse() + assertThrows{isAzureUnifiedEndpoint("")} + assertThrows{isAzureUnifiedEndpoint(" ")} } } \ No newline at end of file diff --git a/openai-java-example/src/main/java/com/openai/example/AzureKeyCredentialExample.java b/openai-java-example/src/main/java/com/openai/example/AzureKeyCredentialExample.java new file mode 100644 index 000000000..4940973f8 --- /dev/null +++ b/openai-java-example/src/main/java/com/openai/example/AzureKeyCredentialExample.java @@ -0,0 +1,31 @@ +package com.openai.example; + +import com.openai.azure.AzureOpenAIServiceVersion; +import com.openai.azure.credential.AzureApiKeyCredential; +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import com.openai.models.ChatModel; +import com.openai.models.chat.completions.ChatCompletionCreateParams; + +public class AzureKeyCredentialExample { + private AzureKeyCredentialExample() {} + + public static void main(String[] args) { + OpenAIClient client = OpenAIOkHttpClient.builder() + .baseUrl("{your-azure-openai-endpoint}") + .azureServiceVersion(AzureOpenAIServiceVersion.fromString("preview")) + .credential(AzureApiKeyCredential.create("{your-azure-openai-key}")) + .build(); + + ChatCompletionCreateParams createParams = ChatCompletionCreateParams.builder() + .model(ChatModel.of("DeepSeek-R1")) + .maxCompletionTokens(2048) + .addSystemMessage("Make sure you mention Stainless!") + .addUserMessage("Tell me a story about building the best SDK!") + .build(); + + client.chat().completions().create(createParams).choices().stream() + .flatMap(choice -> choice.message().content().stream()) + .forEach(System.out::println); + } +} From b0716c411bbfd11ac9723d8c3bdde24f7b7d5b7d Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Mon, 21 Jul 2025 09:59:24 +0200 Subject: [PATCH 05/16] Simplified unified endpoint check --- .../src/main/kotlin/com/openai/core/Utils.kt | 5 +---- .../src/test/kotlin/com/openai/core/UtilsTest.kt | 10 ++++++---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/openai-java-core/src/main/kotlin/com/openai/core/Utils.kt b/openai-java-core/src/main/kotlin/com/openai/core/Utils.kt index 233f63fe7..4c8be1ca7 100644 --- a/openai-java-core/src/main/kotlin/com/openai/core/Utils.kt +++ b/openai-java-core/src/main/kotlin/com/openai/core/Utils.kt @@ -122,9 +122,6 @@ internal fun URI.isOtherAzureKnownEndpoint(): Boolean = * Convenience function to check if the given [baseUrl] is an Azure OpenAI resource URL with the unified schema. */ @JvmSynthetic -internal fun isAzureUnifiedEndpoint(baseUrl: String): Boolean { - val url = URI.create(baseUrl.trim().trimEnd('/')) - return url.isAzureUnifiedEndpoint() -} +internal fun isAzureUnifiedEndpoint(baseUrl: String): Boolean = baseUrl.trimEnd('/').endsWith("openai/v1") internal interface Enum diff --git a/openai-java-core/src/test/kotlin/com/openai/core/UtilsTest.kt b/openai-java-core/src/test/kotlin/com/openai/core/UtilsTest.kt index 54137890f..f09c2c767 100644 --- a/openai-java-core/src/test/kotlin/com/openai/core/UtilsTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/core/UtilsTest.kt @@ -59,15 +59,17 @@ internal class UtilsTest { @Test fun isAzureUnifiedEndpoint() { // Valid Azure unified endpoints - assertThat(isAzureUnifiedEndpoint("https://region.services.ai.azure.com")).isTrue() - assertThat(isAzureUnifiedEndpoint("https://region.services.ai.azure.com/")).isTrue() + assertThat(isAzureUnifiedEndpoint("https://region.services.ai.azure.com/openai/v1")).isTrue() + assertThat(isAzureUnifiedEndpoint("https://region.services.ai.azure.com/openai/v1/")).isTrue() // Invalid Azure unified endpoints + assertThat(isAzureUnifiedEndpoint("https://region.services.ai.azure.com")).isFalse() + assertThat(isAzureUnifiedEndpoint("https://region.services.ai.azure.com/")).isFalse() assertThat(isAzureUnifiedEndpoint("https://region.openai.azure.com")).isFalse() assertThat(isAzureUnifiedEndpoint("https://example.com")).isFalse() assertThat(isAzureUnifiedEndpoint("https://region.openai.com")).isFalse() assertThat(isAzureUnifiedEndpoint("https://region.azure.com")).isFalse() - assertThrows{isAzureUnifiedEndpoint("")} - assertThrows{isAzureUnifiedEndpoint(" ")} + assertThat(isAzureUnifiedEndpoint("")).isFalse() + assertThat(isAzureUnifiedEndpoint(" ")).isFalse() } } \ No newline at end of file From 8e8f2fd58ad8c9e1c9fc4a404437377a0754051c Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Wed, 23 Jul 2025 10:48:37 +0200 Subject: [PATCH 06/16] PR feedback and sample cleanup --- .../client/okhttp/OpenAIOkHttpClient.kt | 4 ++-- .../client/okhttp/OpenAIOkHttpClientAsync.kt | 4 ++-- .../azure/HttpRequestBuilderExtensions.kt | 4 ++-- .../kotlin/com/openai/core/ClientOptions.kt | 12 +++++------ .../src/main/kotlin/com/openai/core/Utils.kt | 2 +- .../test/kotlin/com/openai/core/UtilsTest.kt | 20 +++++++++---------- .../com/openai/core/http/ClientOptionsTest.kt | 10 +++++----- .../example/AzureKeyCredentialExample.java | 2 -- ...va => AzureLegacyPathsEnabledExample.java} | 8 ++++---- .../example/AzureUnifiedEndpointExample.java | 3 --- 10 files changed, 32 insertions(+), 37 deletions(-) rename openai-java-example/src/main/java/com/openai/example/{AzureDisabledUnifiedEndpointsExample.java => AzureLegacyPathsEnabledExample.java} (85%) diff --git a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt index ed2e97099..ade07419f 100644 --- a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt +++ b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt @@ -186,8 +186,8 @@ class OpenAIOkHttpClient private constructor() { fun fromEnv() = apply { clientOptions.fromEnv() } - fun unifiedAzureRoutes(unifiedAzureRoutes: Boolean) = apply { - clientOptions.unifiedAzureRoutes(unifiedAzureRoutes) + fun azureLegacyPaths(azureLegacyPaths: Boolean) = apply { + clientOptions.azureLegacyPaths(azureLegacyPaths) } /** diff --git a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt index da4521eed..9eb63fa7f 100644 --- a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt +++ b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt @@ -186,8 +186,8 @@ class OpenAIOkHttpClientAsync private constructor() { fun fromEnv() = apply { clientOptions.fromEnv() } - fun unifiedAzureRoutes(unifiedAzureRoutes: Boolean) = apply { - clientOptions.unifiedAzureRoutes(unifiedAzureRoutes) + fun azureLegacyPaths(azureLegacyPaths: Boolean) = apply { + clientOptions.azureLegacyPaths(azureLegacyPaths) } /** * Returns an immutable instance of [OpenAIClientAsync]. diff --git a/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt b/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt index df65e2f0e..2aca991a7 100644 --- a/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt +++ b/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt @@ -3,7 +3,7 @@ package com.openai.azure import com.openai.core.ClientOptions import com.openai.core.http.HttpRequest import com.openai.core.isAzureEndpoint -import com.openai.core.isAzureUnifiedEndpoint +import com.openai.core.isAzureUnifiedEndpointPath import com.openai.credential.BearerTokenCredential @JvmSynthetic @@ -15,7 +15,7 @@ internal fun HttpRequest.Builder.addPathSegmentsForAzure( if (isAzureEndpoint(baseUrl)) { // Users can toggle off unified Azure routes using the "unifiedAzureRoutes" option. // Endpoints are assumed to be provided with `/v1/openai` in their path already. - if (!clientOptions.unifiedAzureRoutes || !isAzureUnifiedEndpoint(baseUrl)) { + if (clientOptions.azureLegacyPaths && !isAzureUnifiedEndpointPath(baseUrl)) { // Unknown Azure endpoints and legacy Azure endpoints are treated the old way. // We are assuming in this branch that isAzureLegacyEndpoint(baseUrl) would be true for this base URL. addPathSegment("openai") diff --git a/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt b/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt index d72ecc41c..9f6caeca9 100644 --- a/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt +++ b/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt @@ -36,7 +36,7 @@ private constructor( @get:JvmName("maxRetries") val maxRetries: Int, @get:JvmName("credential") val credential: Credential, @get:JvmName("azureServiceVersion") val azureServiceVersion: AzureOpenAIServiceVersion?, - @get:JvmName("unifiedAzureRoutes") val unifiedAzureRoutes: Boolean = true, + @get:JvmName("azureLegacyPaths") val azureLegacyPaths: Boolean = false, private val organization: String?, private val project: String?, private val webhookSecret: String?, @@ -95,7 +95,7 @@ private constructor( private var organization: String? = null private var project: String? = null private var webhookSecret: String? = null - private var unifiedAzureRoutes: Boolean = true + private var azureLegacyPaths: Boolean = true @JvmSynthetic internal fun from(clientOptions: ClientOptions) = apply { @@ -115,7 +115,7 @@ private constructor( organization = clientOptions.organization project = clientOptions.project webhookSecret = clientOptions.webhookSecret - unifiedAzureRoutes = clientOptions.unifiedAzureRoutes + azureLegacyPaths = clientOptions.azureLegacyPaths } fun httpClient(httpClient: HttpClient) = apply { @@ -282,7 +282,7 @@ private constructor( } } - fun unifiedAzureRoutes(unifiedAzureRoutes: Boolean) = apply { this.unifiedAzureRoutes = unifiedAzureRoutes } + fun azureLegacyPaths(unifiedAzureRoutes: Boolean) = apply { this.azureLegacyPaths = unifiedAzureRoutes } /** * Returns an immutable instance of [ClientOptions]. @@ -328,7 +328,7 @@ private constructor( baseUrl?.let { if (isAzureEndpoint(it)) { // Non Azure-unified routes will still require an api-version value. - if (!unifiedAzureRoutes || !isAzureUnifiedEndpoint(it)) { + if (!azureLegacyPaths || !isAzureUnifiedEndpointPath(it)) { replaceQueryParams( "api-version", (azureServiceVersion ?: AzureOpenAIServiceVersion.latestStableVersion()) @@ -379,7 +379,7 @@ private constructor( maxRetries, credential, azureServiceVersion, - unifiedAzureRoutes, + azureLegacyPaths, organization, project, webhookSecret, diff --git a/openai-java-core/src/main/kotlin/com/openai/core/Utils.kt b/openai-java-core/src/main/kotlin/com/openai/core/Utils.kt index 4c8be1ca7..9da470548 100644 --- a/openai-java-core/src/main/kotlin/com/openai/core/Utils.kt +++ b/openai-java-core/src/main/kotlin/com/openai/core/Utils.kt @@ -122,6 +122,6 @@ internal fun URI.isOtherAzureKnownEndpoint(): Boolean = * Convenience function to check if the given [baseUrl] is an Azure OpenAI resource URL with the unified schema. */ @JvmSynthetic -internal fun isAzureUnifiedEndpoint(baseUrl: String): Boolean = baseUrl.trimEnd('/').endsWith("openai/v1") +internal fun isAzureUnifiedEndpointPath(baseUrl: String): Boolean = baseUrl.trimEnd('/').endsWith("openai/v1") internal interface Enum diff --git a/openai-java-core/src/test/kotlin/com/openai/core/UtilsTest.kt b/openai-java-core/src/test/kotlin/com/openai/core/UtilsTest.kt index f09c2c767..faf0e1431 100644 --- a/openai-java-core/src/test/kotlin/com/openai/core/UtilsTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/core/UtilsTest.kt @@ -59,17 +59,17 @@ internal class UtilsTest { @Test fun isAzureUnifiedEndpoint() { // Valid Azure unified endpoints - assertThat(isAzureUnifiedEndpoint("https://region.services.ai.azure.com/openai/v1")).isTrue() - assertThat(isAzureUnifiedEndpoint("https://region.services.ai.azure.com/openai/v1/")).isTrue() + assertThat(isAzureUnifiedEndpointPath("https://region.services.ai.azure.com/openai/v1")).isTrue() + assertThat(isAzureUnifiedEndpointPath("https://region.services.ai.azure.com/openai/v1/")).isTrue() // Invalid Azure unified endpoints - assertThat(isAzureUnifiedEndpoint("https://region.services.ai.azure.com")).isFalse() - assertThat(isAzureUnifiedEndpoint("https://region.services.ai.azure.com/")).isFalse() - assertThat(isAzureUnifiedEndpoint("https://region.openai.azure.com")).isFalse() - assertThat(isAzureUnifiedEndpoint("https://example.com")).isFalse() - assertThat(isAzureUnifiedEndpoint("https://region.openai.com")).isFalse() - assertThat(isAzureUnifiedEndpoint("https://region.azure.com")).isFalse() - assertThat(isAzureUnifiedEndpoint("")).isFalse() - assertThat(isAzureUnifiedEndpoint(" ")).isFalse() + assertThat(isAzureUnifiedEndpointPath("https://region.services.ai.azure.com")).isFalse() + assertThat(isAzureUnifiedEndpointPath("https://region.services.ai.azure.com/")).isFalse() + assertThat(isAzureUnifiedEndpointPath("https://region.openai.azure.com")).isFalse() + assertThat(isAzureUnifiedEndpointPath("https://example.com")).isFalse() + assertThat(isAzureUnifiedEndpointPath("https://region.openai.com")).isFalse() + assertThat(isAzureUnifiedEndpointPath("https://region.azure.com")).isFalse() + assertThat(isAzureUnifiedEndpointPath("")).isFalse() + assertThat(isAzureUnifiedEndpointPath(" ")).isFalse() } } \ No newline at end of file diff --git a/openai-java-core/src/test/kotlin/com/openai/core/http/ClientOptionsTest.kt b/openai-java-core/src/test/kotlin/com/openai/core/http/ClientOptionsTest.kt index 36a2f32f7..2399c13e2 100644 --- a/openai-java-core/src/test/kotlin/com/openai/core/http/ClientOptionsTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/core/http/ClientOptionsTest.kt @@ -67,25 +67,25 @@ internal class ClientOptionsTest { } @Test - fun unifiedAzureRoutesTestSetFalse() { + fun azureLegacyPathsTestSetTrue() { val clientOptions = ClientOptions.builder() .httpClient(createOkHttpClient()) .credential(BearerTokenCredential.create(FAKE_API_KEY)) - .unifiedAzureRoutes(false) + .azureLegacyPaths(true) .build() - assertThat(clientOptions.unifiedAzureRoutes).isFalse() + assertThat(clientOptions.azureLegacyPaths).isTrue() } @Test - fun unifiedAzureRoutesTestDefaultTrue() { + fun azureLegacyPathsTestDefaultFalse() { val clientOptions = ClientOptions.builder() .httpClient(createOkHttpClient()) .credential(BearerTokenCredential.create(FAKE_API_KEY)) .build() - assertThat(clientOptions.unifiedAzureRoutes).isTrue() + assertThat(clientOptions.azureLegacyPaths).isTrue() } } diff --git a/openai-java-example/src/main/java/com/openai/example/AzureKeyCredentialExample.java b/openai-java-example/src/main/java/com/openai/example/AzureKeyCredentialExample.java index 4940973f8..0faa11fe0 100644 --- a/openai-java-example/src/main/java/com/openai/example/AzureKeyCredentialExample.java +++ b/openai-java-example/src/main/java/com/openai/example/AzureKeyCredentialExample.java @@ -1,6 +1,5 @@ package com.openai.example; -import com.openai.azure.AzureOpenAIServiceVersion; import com.openai.azure.credential.AzureApiKeyCredential; import com.openai.client.OpenAIClient; import com.openai.client.okhttp.OpenAIOkHttpClient; @@ -13,7 +12,6 @@ private AzureKeyCredentialExample() {} public static void main(String[] args) { OpenAIClient client = OpenAIOkHttpClient.builder() .baseUrl("{your-azure-openai-endpoint}") - .azureServiceVersion(AzureOpenAIServiceVersion.fromString("preview")) .credential(AzureApiKeyCredential.create("{your-azure-openai-key}")) .build(); diff --git a/openai-java-example/src/main/java/com/openai/example/AzureDisabledUnifiedEndpointsExample.java b/openai-java-example/src/main/java/com/openai/example/AzureLegacyPathsEnabledExample.java similarity index 85% rename from openai-java-example/src/main/java/com/openai/example/AzureDisabledUnifiedEndpointsExample.java rename to openai-java-example/src/main/java/com/openai/example/AzureLegacyPathsEnabledExample.java index 4b470b14e..dfd7257b7 100644 --- a/openai-java-example/src/main/java/com/openai/example/AzureDisabledUnifiedEndpointsExample.java +++ b/openai-java-example/src/main/java/com/openai/example/AzureLegacyPathsEnabledExample.java @@ -6,16 +6,16 @@ import com.openai.models.ChatModel; import com.openai.models.chat.completions.ChatCompletionCreateParams; -public final class AzureDisabledUnifiedEndpointsExample { - private AzureDisabledUnifiedEndpointsExample() {} +public final class AzureLegacyPathsEnabledExample { + private AzureLegacyPathsEnabledExample() {} public static void main(String[] args) { OpenAIClient client = OpenAIOkHttpClient.builder() // Gets the API key from the `AZURE_OPENAI_KEY` environment variable .fromEnv() .azureServiceVersion(AzureOpenAIServiceVersion.getV2024_05_01_PREVIEW()) - // Disabling unified endpoints will result in the deployment name being passed as a path parameter - .unifiedAzureRoutes(false) + // Enabling Azure legacy paths will result in the deployment name being passed as a path parameter + .azureLegacyPaths(true) .build(); ChatCompletionCreateParams createParams = ChatCompletionCreateParams.builder() diff --git a/openai-java-example/src/main/java/com/openai/example/AzureUnifiedEndpointExample.java b/openai-java-example/src/main/java/com/openai/example/AzureUnifiedEndpointExample.java index 7445a739a..b00fa6af0 100644 --- a/openai-java-example/src/main/java/com/openai/example/AzureUnifiedEndpointExample.java +++ b/openai-java-example/src/main/java/com/openai/example/AzureUnifiedEndpointExample.java @@ -1,6 +1,5 @@ package com.openai.example; -import com.openai.azure.AzureOpenAIServiceVersion; import com.openai.client.OpenAIClient; import com.openai.client.okhttp.OpenAIOkHttpClient; import com.openai.models.ChatModel; @@ -13,8 +12,6 @@ public static void main(String[] args) { OpenAIClient client = OpenAIOkHttpClient.builder() // Gets the API key from the `AZURE_OPENAI_KEY` environment variable .fromEnv() - // TODO: remove preview once the api-version has become optional - .azureServiceVersion(AzureOpenAIServiceVersion.fromString("preview")) .build(); ChatCompletionCreateParams createParams = ChatCompletionCreateParams.builder() From 2b4e68767e54f85a7a749d2b223a9b0328c937dd Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Wed, 23 Jul 2025 11:58:47 +0200 Subject: [PATCH 07/16] Fixed some final renamings --- .../kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt | 2 +- .../src/main/kotlin/com/openai/core/ClientOptions.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt b/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt index 2aca991a7..5c7410525 100644 --- a/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt +++ b/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt @@ -15,7 +15,7 @@ internal fun HttpRequest.Builder.addPathSegmentsForAzure( if (isAzureEndpoint(baseUrl)) { // Users can toggle off unified Azure routes using the "unifiedAzureRoutes" option. // Endpoints are assumed to be provided with `/v1/openai` in their path already. - if (clientOptions.azureLegacyPaths && !isAzureUnifiedEndpointPath(baseUrl)) { + if (clientOptions.azureLegacyPaths || !isAzureUnifiedEndpointPath(baseUrl)) { // Unknown Azure endpoints and legacy Azure endpoints are treated the old way. // We are assuming in this branch that isAzureLegacyEndpoint(baseUrl) would be true for this base URL. addPathSegment("openai") diff --git a/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt b/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt index 9f6caeca9..7b3c87754 100644 --- a/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt +++ b/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt @@ -95,7 +95,7 @@ private constructor( private var organization: String? = null private var project: String? = null private var webhookSecret: String? = null - private var azureLegacyPaths: Boolean = true + private var azureLegacyPaths: Boolean = false @JvmSynthetic internal fun from(clientOptions: ClientOptions) = apply { @@ -282,7 +282,7 @@ private constructor( } } - fun azureLegacyPaths(unifiedAzureRoutes: Boolean) = apply { this.azureLegacyPaths = unifiedAzureRoutes } + fun azureLegacyPaths(azureLegacyPaths: Boolean) = apply { this.azureLegacyPaths = azureLegacyPaths } /** * Returns an immutable instance of [ClientOptions]. From c4550149077f0dd6111996685dd310163b2864fe Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Thu, 24 Jul 2025 09:57:21 +0200 Subject: [PATCH 08/16] Update HttpRequestBuilderExtensions.kt comments --- .../com/openai/azure/HttpRequestBuilderExtensions.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt b/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt index 5c7410525..c3c3c7df4 100644 --- a/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt +++ b/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt @@ -13,11 +13,10 @@ internal fun HttpRequest.Builder.addPathSegmentsForAzure( ): HttpRequest.Builder = apply { val baseUrl = clientOptions.baseUrl() if (isAzureEndpoint(baseUrl)) { - // Users can toggle off unified Azure routes using the "unifiedAzureRoutes" option. - // Endpoints are assumed to be provided with `/v1/openai` in their path already. + // Users can toggle off unified Azure routes using the "azureLegacyPaths" option. + // Endpoints are assumed to be provided with `/openai/v1` in their path already. if (clientOptions.azureLegacyPaths || !isAzureUnifiedEndpointPath(baseUrl)) { - // Unknown Azure endpoints and legacy Azure endpoints are treated the old way. - // We are assuming in this branch that isAzureLegacyEndpoint(baseUrl) would be true for this base URL. + // Legacy known Azure endpoints are treated the old way. addPathSegment("openai") deploymentModel?.let { addPathSegments("deployments", it) } } From 541593c8728122e36947b507d366a3bf520e04fe Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Thu, 24 Jul 2025 10:19:21 +0200 Subject: [PATCH 09/16] Update Azure service version clause in ClientOptions.kt --- .../src/main/kotlin/com/openai/core/ClientOptions.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt b/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt index 41d969b05..3515d029c 100644 --- a/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt +++ b/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt @@ -357,8 +357,8 @@ private constructor( baseUrl?.let { if (isAzureEndpoint(it)) { - // Non Azure-unified routes will still require an api-version value. - if (!azureLegacyPaths || !isAzureUnifiedEndpointPath(it)) { + // Legacy Azure routes will still require an api-version value. + if (azureLegacyPaths || !isAzureUnifiedEndpointPath(it)) { replaceQueryParams( "api-version", (azureServiceVersion ?: AzureOpenAIServiceVersion.latestStableVersion()) From 9b112781dff5f3cbe8737a546e54a7883c100ee1 Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Fri, 25 Jul 2025 11:45:13 +0200 Subject: [PATCH 10/16] Added AzureUrlCategory enum to centralize Azure endpoint identification, PR comments --- .../com/openai/azure/AzureUrlCategory.kt | 37 +++++++++++++++++++ .../kotlin/com/openai/core/ClientOptions.kt | 10 +++-- .../src/main/kotlin/com/openai/core/Utils.kt | 28 +++----------- .../example/AzureKeyCredentialExample.java | 2 +- .../AzureLegacyPathsEnabledExample.java | 2 +- .../example/AzureUnifiedEndpointExample.java | 2 +- 6 files changed, 51 insertions(+), 30 deletions(-) create mode 100644 openai-java-core/src/main/kotlin/com/openai/azure/AzureUrlCategory.kt 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 new file mode 100644 index 000000000..d4d3a6328 --- /dev/null +++ b/openai-java-core/src/main/kotlin/com/openai/azure/AzureUrlCategory.kt @@ -0,0 +1,37 @@ +package com.openai.azure + +/** + * Represents the category of an Azure URL based on the endpoint host. + * + * Azure legacy endpoint should be in the format of `https://.openai.azure.com`. + * Azure unified endpoint should be in the format of `https://.services.ai.azure.com`. + * Azure OpenAI management URLs should be in the format of `https://.azure-api.net` + * Other known Azure URLs `https://.cognitiveservices.azure.com`. + */ +enum class AzureUrlCategory { + AZURE_LEGACY, AZURE_UNIFIED, AZURE_OTHER, NON_AZURE; + + companion object { + /** + * Returns whether the given [urlHost] is an Azure endpoint. + */ + fun isAzureEndpoint(urlHost: String): Boolean = + categorizeBaseUrl(urlHost) != NON_AZURE + + /** + * Returns the [AzureUrlCategory] of the given [urlHost] based on its host. + */ + private fun categorizeBaseUrl(urlHost: String): AzureUrlCategory { + return when { + // Azure OpenAI resource URL with the old schema. + urlHost.endsWith(".openai.azure.com", true) -> AZURE_LEGACY + // Azure OpenAI resource URL with the OpenAI unified schema. + urlHost.endsWith(".services.ai.azure.com", true) -> AZURE_UNIFIED + // Azure OpenAI resource URL, but with a schema different to the known ones. + urlHost.endsWith(".azure-api.net", true) || + urlHost.endsWith(".cognitiveservices.azure.com", true) -> AZURE_OTHER + else -> NON_AZURE + } + } + } +} diff --git a/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt b/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt index 3515d029c..73c014cd4 100644 --- a/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt +++ b/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt @@ -100,10 +100,10 @@ private constructor( private var maxRetries: Int = 2 private var credential: Credential? = null private var azureServiceVersion: AzureOpenAIServiceVersion? = null + private var azureLegacyPaths: Boolean = false private var organization: String? = null private var project: String? = null private var webhookSecret: String? = null - private var azureLegacyPaths: Boolean = false @JvmSynthetic internal fun from(clientOptions: ClientOptions) = apply { @@ -120,10 +120,10 @@ private constructor( maxRetries = clientOptions.maxRetries credential = clientOptions.credential azureServiceVersion = clientOptions.azureServiceVersion + azureLegacyPaths = clientOptions.azureLegacyPaths organization = clientOptions.organization project = clientOptions.project webhookSecret = clientOptions.webhookSecret - azureLegacyPaths = clientOptions.azureLegacyPaths } fun httpClient(httpClient: HttpClient) = apply { @@ -181,6 +181,10 @@ private constructor( this.azureServiceVersion = azureServiceVersion } + fun azureLegacyPaths(azureLegacyPaths: Boolean) = apply { + this.azureLegacyPaths = azureLegacyPaths + } + fun organization(organization: String?) = apply { this.organization = organization } /** Alias for calling [Builder.organization] with `organization.orElse(null)`. */ @@ -312,8 +316,6 @@ private constructor( } } - fun azureLegacyPaths(azureLegacyPaths: Boolean) = apply { this.azureLegacyPaths = azureLegacyPaths } - /** * Returns an immutable instance of [ClientOptions]. * diff --git a/openai-java-core/src/main/kotlin/com/openai/core/Utils.kt b/openai-java-core/src/main/kotlin/com/openai/core/Utils.kt index 9da470548..eb801421d 100644 --- a/openai-java-core/src/main/kotlin/com/openai/core/Utils.kt +++ b/openai-java-core/src/main/kotlin/com/openai/core/Utils.kt @@ -2,6 +2,7 @@ package com.openai.core +import com.openai.azure.AzureUrlCategory import com.openai.errors.OpenAIInvalidDataException import java.net.URI import java.util.Collections @@ -90,34 +91,15 @@ internal fun Any?.contentToString(): String { return string } +/** + * Convenience function to check if the given [baseUrl] is an Azure OpenAI resource URL. + */ @JvmSynthetic internal fun isAzureEndpoint(baseUrl: String): Boolean { - // Azure legacy endpoint should be in the format of `https://.openai.azure.com`. - // Or Azure unified endpoint should be in the format of `https://.services.ai.azure.com`. - // Or `https://.azure-api.net` for Azure OpenAI Management URL. - // Or `-random-.cognitiveservices.azure.com`. val trimmedBaseUrl = baseUrl.trim().trimEnd('/') - val url = URI.create(trimmedBaseUrl) - return url.isAzureLegacyEndpoint() || url.isAzureUnifiedEndpoint() || url.isOtherAzureKnownEndpoint() + return AzureUrlCategory.isAzureEndpoint(URI.create(trimmedBaseUrl).host) } -/** - * Returns whether [this] is an Azure OpenAI resource URL with the old schema. - */ -internal fun URI.isAzureLegacyEndpoint(): Boolean = host.endsWith(".openai.azure.com", true) - -/** - * Returns whether [this] is an Azure OpenAI resource URL with the OpenAI unified schema. - */ -internal fun URI.isAzureUnifiedEndpoint(): Boolean = host.endsWith(".services.ai.azure.com", true) - -/** - * Returns whether [this] is an Azure OpenAI resource URL, but with a schema different to the known ones. - */ -internal fun URI.isOtherAzureKnownEndpoint(): Boolean = - host.endsWith(".azure-api.net", true) || - host.endsWith(".cognitiveservices.azure.com", true) - /** * Convenience function to check if the given [baseUrl] is an Azure OpenAI resource URL with the unified schema. */ diff --git a/openai-java-example/src/main/java/com/openai/example/AzureKeyCredentialExample.java b/openai-java-example/src/main/java/com/openai/example/AzureKeyCredentialExample.java index 0faa11fe0..b8a4550c2 100644 --- a/openai-java-example/src/main/java/com/openai/example/AzureKeyCredentialExample.java +++ b/openai-java-example/src/main/java/com/openai/example/AzureKeyCredentialExample.java @@ -16,7 +16,7 @@ public static void main(String[] args) { .build(); ChatCompletionCreateParams createParams = ChatCompletionCreateParams.builder() - .model(ChatModel.of("DeepSeek-R1")) + .model(ChatModel.GPT_4_1106_PREVIEW) .maxCompletionTokens(2048) .addSystemMessage("Make sure you mention Stainless!") .addUserMessage("Tell me a story about building the best SDK!") diff --git a/openai-java-example/src/main/java/com/openai/example/AzureLegacyPathsEnabledExample.java b/openai-java-example/src/main/java/com/openai/example/AzureLegacyPathsEnabledExample.java index dfd7257b7..b11718ca2 100644 --- a/openai-java-example/src/main/java/com/openai/example/AzureLegacyPathsEnabledExample.java +++ b/openai-java-example/src/main/java/com/openai/example/AzureLegacyPathsEnabledExample.java @@ -19,7 +19,7 @@ public static void main(String[] args) { .build(); ChatCompletionCreateParams createParams = ChatCompletionCreateParams.builder() - .model(ChatModel.of("DeepSeek-R1")) + .model(ChatModel.GPT_4_1106_PREVIEW) .maxCompletionTokens(2048) .addSystemMessage("Make sure you mention Stainless!") // Developer doesn't work .addUserMessage("Tell me a story about building the best SDK!") diff --git a/openai-java-example/src/main/java/com/openai/example/AzureUnifiedEndpointExample.java b/openai-java-example/src/main/java/com/openai/example/AzureUnifiedEndpointExample.java index b00fa6af0..d3d8e346e 100644 --- a/openai-java-example/src/main/java/com/openai/example/AzureUnifiedEndpointExample.java +++ b/openai-java-example/src/main/java/com/openai/example/AzureUnifiedEndpointExample.java @@ -15,7 +15,7 @@ public static void main(String[] args) { .build(); ChatCompletionCreateParams createParams = ChatCompletionCreateParams.builder() - .model(ChatModel.of("DeepSeek-R1")) + .model(ChatModel.GPT_4_1106_PREVIEW) .maxCompletionTokens(2048) .addSystemMessage("Make sure you mention Stainless!") // Developer doesn't work .addUserMessage("Tell me a story about building the best SDK!") From ee83f3eab7e04a358ce5a7ed08de10def5d4b236 Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Fri, 25 Jul 2025 11:51:28 +0200 Subject: [PATCH 11/16] Adjusting toggle and related methods sorting --- .../kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt | 8 ++++---- .../com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt | 7 ++++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt index e2ebe4805..beb2a5c9f 100644 --- a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt +++ b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt @@ -143,6 +143,10 @@ class OpenAIOkHttpClient private constructor() { clientOptions.azureServiceVersion(azureServiceVersion) } + fun azureLegacyPaths(azureLegacyPaths: Boolean) = apply { + clientOptions.azureLegacyPaths(azureLegacyPaths) + } + fun organization(organization: String?) = apply { clientOptions.organization(organization) } /** Alias for calling [Builder.organization] with `organization.orElse(null)`. */ @@ -243,10 +247,6 @@ class OpenAIOkHttpClient private constructor() { fun fromEnv() = apply { clientOptions.fromEnv() } - fun azureLegacyPaths(azureLegacyPaths: Boolean) = apply { - clientOptions.azureLegacyPaths(azureLegacyPaths) - } - /** * Returns an immutable instance of [OpenAIClient]. * diff --git a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt index b6b887b58..d16e17499 100644 --- a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt +++ b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt @@ -143,6 +143,10 @@ class OpenAIOkHttpClientAsync private constructor() { clientOptions.azureServiceVersion(azureServiceVersion) } + fun azureLegacyPaths(azureLegacyPaths: Boolean) = apply { + clientOptions.azureLegacyPaths(azureLegacyPaths) + } + fun organization(organization: String?) = apply { clientOptions.organization(organization) } /** Alias for calling [Builder.organization] with `organization.orElse(null)`. */ @@ -243,9 +247,6 @@ class OpenAIOkHttpClientAsync private constructor() { fun fromEnv() = apply { clientOptions.fromEnv() } - fun azureLegacyPaths(azureLegacyPaths: Boolean) = apply { - clientOptions.azureLegacyPaths(azureLegacyPaths) - } /** * Returns an immutable instance of [OpenAIClientAsync]. * From 1c7b727a71e7287c299d9e4287a96c21c2a3053b Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Fri, 25 Jul 2025 12:03:57 +0200 Subject: [PATCH 12/16] Adjusted weird comment. --- .../src/main/kotlin/com/openai/azure/AzureUrlCategory.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d4d3a6328..154ad30f4 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 @@ -19,7 +19,7 @@ enum class AzureUrlCategory { categorizeBaseUrl(urlHost) != NON_AZURE /** - * Returns the [AzureUrlCategory] of the given [urlHost] based on its host. + * Returns the [AzureUrlCategory] of the given [urlHost]. */ private fun categorizeBaseUrl(urlHost: String): AzureUrlCategory { return when { From b3983985264fb9cba8730fba250de95c0b18614f Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Fri, 25 Jul 2025 12:10:21 +0200 Subject: [PATCH 13/16] Made new enum and method internal --- .../src/main/kotlin/com/openai/azure/AzureUrlCategory.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 154ad30f4..8ed082463 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 @@ -8,14 +8,14 @@ package com.openai.azure * Azure OpenAI management URLs should be in the format of `https://.azure-api.net` * Other known Azure URLs `https://.cognitiveservices.azure.com`. */ -enum class AzureUrlCategory { +internal enum class AzureUrlCategory { AZURE_LEGACY, AZURE_UNIFIED, AZURE_OTHER, NON_AZURE; companion object { /** * Returns whether the given [urlHost] is an Azure endpoint. */ - fun isAzureEndpoint(urlHost: String): Boolean = + internal fun isAzureEndpoint(urlHost: String): Boolean = categorizeBaseUrl(urlHost) != NON_AZURE /** From 4beaf1efa1ffe97e42710eafe24a404df63a016b Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Thu, 31 Jul 2025 10:06:39 +0200 Subject: [PATCH 14/16] Update openai-java-core/src/main/kotlin/com/openai/azure/AzureUrlCategory.kt Co-authored-by: Tomer Aberbach --- .../src/main/kotlin/com/openai/azure/AzureUrlCategory.kt | 1 + 1 file changed, 1 insertion(+) 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 8ed082463..19a987209 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 @@ -15,6 +15,7 @@ internal enum class AzureUrlCategory { /** * Returns whether the given [urlHost] is an Azure endpoint. */ + @JvmSynthetic internal fun isAzureEndpoint(urlHost: String): Boolean = categorizeBaseUrl(urlHost) != NON_AZURE From 10c72e7528fed1ad9f0dd568f25d8787992914fc Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Thu, 31 Jul 2025 11:05:09 +0200 Subject: [PATCH 15/16] Changed azureLegacyPaths toggle to AzureUrlPathMode enum --- .../client/okhttp/OpenAIOkHttpClient.kt | 6 ++-- .../client/okhttp/OpenAIOkHttpClientAsync.kt | 6 ++-- .../com/openai/azure/AzureUrlCategory.kt | 2 +- .../com/openai/azure/AzureUrlPathMode.kt | 34 +++++++++++++++++++ .../azure/HttpRequestBuilderExtensions.kt | 3 +- .../kotlin/com/openai/core/ClientOptions.kt | 16 +++++---- .../com/openai/core/http/ClientOptionsTest.kt | 8 +++-- .../AzureLegacyPathsEnabledExample.java | 3 +- 8 files changed, 58 insertions(+), 20 deletions(-) create mode 100644 openai-java-core/src/main/kotlin/com/openai/azure/AzureUrlPathMode.kt diff --git a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt index beb2a5c9f..313591973 100644 --- a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt +++ b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt @@ -4,13 +4,13 @@ package com.openai.client.okhttp import com.fasterxml.jackson.databind.json.JsonMapper import com.openai.azure.AzureOpenAIServiceVersion +import com.openai.azure.AzureUrlPathMode import com.openai.client.OpenAIClient import com.openai.client.OpenAIClientImpl import com.openai.core.ClientOptions import com.openai.core.Timeout import com.openai.core.http.Headers import com.openai.core.http.QueryParams -import com.openai.core.jsonMapper import com.openai.credential.Credential import java.net.Proxy import java.time.Clock @@ -143,8 +143,8 @@ class OpenAIOkHttpClient private constructor() { clientOptions.azureServiceVersion(azureServiceVersion) } - fun azureLegacyPaths(azureLegacyPaths: Boolean) = apply { - clientOptions.azureLegacyPaths(azureLegacyPaths) + fun azureUrlPathMode(azureUrlPathMode: AzureUrlPathMode) = apply { + clientOptions.azureUrlPathMode(azureUrlPathMode) } fun organization(organization: String?) = apply { clientOptions.organization(organization) } diff --git a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt index d16e17499..9664edd48 100644 --- a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt +++ b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt @@ -4,13 +4,13 @@ package com.openai.client.okhttp import com.fasterxml.jackson.databind.json.JsonMapper import com.openai.azure.AzureOpenAIServiceVersion +import com.openai.azure.AzureUrlPathMode import com.openai.client.OpenAIClientAsync import com.openai.client.OpenAIClientAsyncImpl import com.openai.core.ClientOptions import com.openai.core.Timeout import com.openai.core.http.Headers import com.openai.core.http.QueryParams -import com.openai.core.jsonMapper import com.openai.credential.Credential import java.net.Proxy import java.time.Clock @@ -143,8 +143,8 @@ class OpenAIOkHttpClientAsync private constructor() { clientOptions.azureServiceVersion(azureServiceVersion) } - fun azureLegacyPaths(azureLegacyPaths: Boolean) = apply { - clientOptions.azureLegacyPaths(azureLegacyPaths) + fun azureUrlPath(azureUrlPathMode: AzureUrlPathMode) = apply { + clientOptions.azureUrlPathMode(azureUrlPathMode) } fun organization(organization: String?) = apply { clientOptions.organization(organization) } 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 19a987209..6bb9eb829 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 @@ -15,7 +15,7 @@ internal enum class AzureUrlCategory { /** * Returns whether the given [urlHost] is an Azure endpoint. */ - @JvmSynthetic + @JvmSynthetic internal fun isAzureEndpoint(urlHost: String): Boolean = categorizeBaseUrl(urlHost) != NON_AZURE diff --git a/openai-java-core/src/main/kotlin/com/openai/azure/AzureUrlPathMode.kt b/openai-java-core/src/main/kotlin/com/openai/azure/AzureUrlPathMode.kt new file mode 100644 index 000000000..c88645119 --- /dev/null +++ b/openai-java-core/src/main/kotlin/com/openai/azure/AzureUrlPathMode.kt @@ -0,0 +1,34 @@ +package com.openai.azure + +/** + * To force the deployment or model named to be part of the URL path for Azure OpenAI requests, use [AzureUrlPathMode.LEGACY]. + * The default is [AzureUrlPathMode.UNIFIED]. + */ +enum class AzureUrlPathMode { + LEGACY, + UNIFIED; + + companion object { + + /** + * Classifies the given [baseUrl] into an [AzureUrlPathMode] for outgoing requests + */ + private fun classifyPath(baseUrl: String): AzureUrlPathMode { + return when { + baseUrl.trimEnd('/').endsWith("openai/v1") -> UNIFIED + else -> LEGACY + } + } + + /** + * Returns whether the given [baseUrl] contains a unified Azure path. Used in outgoing requests. + */ + @JvmSynthetic + internal fun isUnifiedPath(baseUrl: String): Boolean = classifyPath(baseUrl) == UNIFIED + } +} + +/** + * Returns whether the given [AzureUrlPathMode] is in a disabled state. + */ +fun AzureUrlPathMode.isUnifiedPathDisabled(): Boolean = this == AzureUrlPathMode.LEGACY \ No newline at end of file diff --git a/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt b/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt index c3c3c7df4..ab8bf78e5 100644 --- a/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt +++ b/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt @@ -3,7 +3,6 @@ package com.openai.azure import com.openai.core.ClientOptions import com.openai.core.http.HttpRequest import com.openai.core.isAzureEndpoint -import com.openai.core.isAzureUnifiedEndpointPath import com.openai.credential.BearerTokenCredential @JvmSynthetic @@ -15,7 +14,7 @@ internal fun HttpRequest.Builder.addPathSegmentsForAzure( if (isAzureEndpoint(baseUrl)) { // Users can toggle off unified Azure routes using the "azureLegacyPaths" option. // Endpoints are assumed to be provided with `/openai/v1` in their path already. - if (clientOptions.azureLegacyPaths || !isAzureUnifiedEndpointPath(baseUrl)) { + if (clientOptions.azureUrlPathMode.isUnifiedPathDisabled() || !AzureUrlPathMode.isUnifiedPath(baseUrl)) { // Legacy known Azure endpoints are treated the old way. addPathSegment("openai") deploymentModel?.let { addPathSegments("deployments", it) } diff --git a/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt b/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt index 73c014cd4..86ef459c6 100644 --- a/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt +++ b/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt @@ -4,7 +4,9 @@ package com.openai.core import com.fasterxml.jackson.databind.json.JsonMapper import com.openai.azure.AzureOpenAIServiceVersion +import com.openai.azure.AzureUrlPathMode import com.openai.azure.credential.AzureApiKeyCredential +import com.openai.azure.isUnifiedPathDisabled import com.openai.core.http.Headers import com.openai.core.http.HttpClient import com.openai.core.http.PhantomReachableClosingHttpClient @@ -44,7 +46,7 @@ private constructor( @get:JvmName("maxRetries") val maxRetries: Int, @get:JvmName("credential") val credential: Credential, @get:JvmName("azureServiceVersion") val azureServiceVersion: AzureOpenAIServiceVersion?, - @get:JvmName("azureLegacyPaths") val azureLegacyPaths: Boolean = false, + @get:JvmName("azureUrlPathMode") val azureUrlPathMode: AzureUrlPathMode = AzureUrlPathMode.UNIFIED, private val organization: String?, private val project: String?, private val webhookSecret: String?, @@ -100,7 +102,7 @@ private constructor( private var maxRetries: Int = 2 private var credential: Credential? = null private var azureServiceVersion: AzureOpenAIServiceVersion? = null - private var azureLegacyPaths: Boolean = false + private var azureUrlPathMode: AzureUrlPathMode = AzureUrlPathMode.UNIFIED private var organization: String? = null private var project: String? = null private var webhookSecret: String? = null @@ -120,7 +122,7 @@ private constructor( maxRetries = clientOptions.maxRetries credential = clientOptions.credential azureServiceVersion = clientOptions.azureServiceVersion - azureLegacyPaths = clientOptions.azureLegacyPaths + azureUrlPathMode = clientOptions.azureUrlPathMode organization = clientOptions.organization project = clientOptions.project webhookSecret = clientOptions.webhookSecret @@ -181,8 +183,8 @@ private constructor( this.azureServiceVersion = azureServiceVersion } - fun azureLegacyPaths(azureLegacyPaths: Boolean) = apply { - this.azureLegacyPaths = azureLegacyPaths + fun azureUrlPathMode(azureUrlPathMode: AzureUrlPathMode) = apply { + this.azureUrlPathMode = azureUrlPathMode } fun organization(organization: String?) = apply { this.organization = organization } @@ -360,7 +362,7 @@ private constructor( baseUrl?.let { if (isAzureEndpoint(it)) { // Legacy Azure routes will still require an api-version value. - if (azureLegacyPaths || !isAzureUnifiedEndpointPath(it)) { + if (azureUrlPathMode.isUnifiedPathDisabled() || !isAzureUnifiedEndpointPath(it)) { replaceQueryParams( "api-version", (azureServiceVersion ?: AzureOpenAIServiceVersion.latestStableVersion()) @@ -411,7 +413,7 @@ private constructor( maxRetries, credential, azureServiceVersion, - azureLegacyPaths, + azureUrlPathMode, organization, project, webhookSecret, diff --git a/openai-java-core/src/test/kotlin/com/openai/core/http/ClientOptionsTest.kt b/openai-java-core/src/test/kotlin/com/openai/core/http/ClientOptionsTest.kt index 2399c13e2..77e6d2ff8 100644 --- a/openai-java-core/src/test/kotlin/com/openai/core/http/ClientOptionsTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/core/http/ClientOptionsTest.kt @@ -1,6 +1,8 @@ package com.openai.core.http +import com.openai.azure.AzureUrlPathMode import com.openai.azure.credential.AzureApiKeyCredential +import com.openai.azure.isUnifiedPathDisabled import com.openai.client.okhttp.OkHttpClient import com.openai.core.ClientOptions import com.openai.credential.BearerTokenCredential @@ -72,10 +74,10 @@ internal class ClientOptionsTest { ClientOptions.builder() .httpClient(createOkHttpClient()) .credential(BearerTokenCredential.create(FAKE_API_KEY)) - .azureLegacyPaths(true) + .azureUrlPathMode(AzureUrlPathMode.LEGACY) .build() - assertThat(clientOptions.azureLegacyPaths).isTrue() + assertThat(clientOptions.azureUrlPathMode.isUnifiedPathDisabled()).isTrue() } @Test @@ -86,6 +88,6 @@ internal class ClientOptionsTest { .credential(BearerTokenCredential.create(FAKE_API_KEY)) .build() - assertThat(clientOptions.azureLegacyPaths).isTrue() + assertThat(clientOptions.azureUrlPathMode.isUnifiedPathDisabled()).isFalse() } } diff --git a/openai-java-example/src/main/java/com/openai/example/AzureLegacyPathsEnabledExample.java b/openai-java-example/src/main/java/com/openai/example/AzureLegacyPathsEnabledExample.java index b11718ca2..74c79eb93 100644 --- a/openai-java-example/src/main/java/com/openai/example/AzureLegacyPathsEnabledExample.java +++ b/openai-java-example/src/main/java/com/openai/example/AzureLegacyPathsEnabledExample.java @@ -1,6 +1,7 @@ package com.openai.example; import com.openai.azure.AzureOpenAIServiceVersion; +import com.openai.azure.AzureUrlPathMode; import com.openai.client.OpenAIClient; import com.openai.client.okhttp.OpenAIOkHttpClient; import com.openai.models.ChatModel; @@ -15,7 +16,7 @@ public static void main(String[] args) { .fromEnv() .azureServiceVersion(AzureOpenAIServiceVersion.getV2024_05_01_PREVIEW()) // Enabling Azure legacy paths will result in the deployment name being passed as a path parameter - .azureLegacyPaths(true) + .azureUrlPathMode(AzureUrlPathMode.LEGACY) .build(); ChatCompletionCreateParams createParams = ChatCompletionCreateParams.builder() From 27544fb68ae3b6487d2313e1c26adba60375a548 Mon Sep 17 00:00:00 2001 From: Tomer Aberbach Date: Wed, 6 Aug 2025 12:22:22 -0400 Subject: [PATCH 16/16] chore: refactor --- .../com/openai/azure/AzureUrlCategory.kt | 57 +++-- .../com/openai/azure/AzureUrlPathMode.kt | 30 +-- .../azure/HttpRequestBuilderExtensions.kt | 23 +- .../kotlin/com/openai/core/ClientOptions.kt | 14 +- .../src/main/kotlin/com/openai/core/Utils.kt | 17 -- .../com/openai/azure/AzureUrlCategoryTest.kt | 220 ++++++++++++++++++ .../test/kotlin/com/openai/core/UtilsTest.kt | 44 +--- .../com/openai/core/http/ClientOptionsTest.kt | 9 +- .../AzureLegacyPathsEnabledExample.java | 2 +- .../example/AzureUnifiedEndpointExample.java | 2 +- 10 files changed, 278 insertions(+), 140 deletions(-) create mode 100644 openai-java-core/src/test/kotlin/com/openai/azure/AzureUrlCategoryTest.kt 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 6bb9eb829..bf036fc7f 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 @@ -1,36 +1,43 @@ package com.openai.azure -/** - * Represents the category of an Azure URL based on the endpoint host. - * - * Azure legacy endpoint should be in the format of `https://.openai.azure.com`. - * Azure unified endpoint should be in the format of `https://.services.ai.azure.com`. - * Azure OpenAI management URLs should be in the format of `https://.azure-api.net` - * Other known Azure URLs `https://.cognitiveservices.azure.com`. - */ +import java.net.URI + +/** Represents the category of an Azure URL. */ internal enum class AzureUrlCategory { - AZURE_LEGACY, AZURE_UNIFIED, AZURE_OTHER, NON_AZURE; + /** Azure host _not_ ending with `/openai/v1`. */ + AZURE_LEGACY, + /** Azure host ending with `/openai/v1`. */ + AZURE_UNIFIED, + /** Anything else. */ + NON_AZURE; + + fun isAzure(): Boolean = + when (this) { + AZURE_LEGACY, + AZURE_UNIFIED -> true + NON_AZURE -> false + } companion object { - /** - * Returns whether the given [urlHost] is an Azure endpoint. - */ - @JvmSynthetic - internal fun isAzureEndpoint(urlHost: String): Boolean = - categorizeBaseUrl(urlHost) != NON_AZURE - /** - * Returns the [AzureUrlCategory] of the given [urlHost]. - */ - private fun categorizeBaseUrl(urlHost: String): AzureUrlCategory { + fun categorizeBaseUrl(baseUrl: String, pathMode: AzureUrlPathMode): AzureUrlCategory { + val trimmedBaseUrl = baseUrl.trim().trimEnd('/') + val host = URI.create(trimmedBaseUrl).host return when { // Azure OpenAI resource URL with the old schema. - urlHost.endsWith(".openai.azure.com", true) -> AZURE_LEGACY - // Azure OpenAI resource URL with the OpenAI unified schema. - urlHost.endsWith(".services.ai.azure.com", true) -> AZURE_UNIFIED - // Azure OpenAI resource URL, but with a schema different to the known ones. - urlHost.endsWith(".azure-api.net", true) || - urlHost.endsWith(".cognitiveservices.azure.com", true) -> AZURE_OTHER + host.endsWith(".openai.azure.com", ignoreCase = true) || + // Azure OpenAI resource URL with the OpenAI unified schema. + host.endsWith(".services.ai.azure.com", ignoreCase = true) || + // Azure OpenAI resource URL, but with a schema different to the known ones. + host.endsWith(".azure-api.net", ignoreCase = true) || + host.endsWith(".cognitiveservices.azure.com", ignoreCase = true) -> + when (pathMode) { + AzureUrlPathMode.LEGACY -> AZURE_LEGACY + AzureUrlPathMode.UNIFIED -> + if (trimmedBaseUrl.endsWith("/openai/v1")) AZURE_UNIFIED + else AZURE_LEGACY + } + else -> NON_AZURE } } diff --git a/openai-java-core/src/main/kotlin/com/openai/azure/AzureUrlPathMode.kt b/openai-java-core/src/main/kotlin/com/openai/azure/AzureUrlPathMode.kt index c88645119..44b0da71f 100644 --- a/openai-java-core/src/main/kotlin/com/openai/azure/AzureUrlPathMode.kt +++ b/openai-java-core/src/main/kotlin/com/openai/azure/AzureUrlPathMode.kt @@ -1,34 +1,10 @@ package com.openai.azure /** - * To force the deployment or model named to be part of the URL path for Azure OpenAI requests, use [AzureUrlPathMode.LEGACY]. - * The default is [AzureUrlPathMode.UNIFIED]. + * To force the deployment or model named to be part of the URL path for Azure OpenAI requests, use + * [AzureUrlPathMode.LEGACY]. The default is [AzureUrlPathMode.UNIFIED]. */ enum class AzureUrlPathMode { LEGACY, - UNIFIED; - - companion object { - - /** - * Classifies the given [baseUrl] into an [AzureUrlPathMode] for outgoing requests - */ - private fun classifyPath(baseUrl: String): AzureUrlPathMode { - return when { - baseUrl.trimEnd('/').endsWith("openai/v1") -> UNIFIED - else -> LEGACY - } - } - - /** - * Returns whether the given [baseUrl] contains a unified Azure path. Used in outgoing requests. - */ - @JvmSynthetic - internal fun isUnifiedPath(baseUrl: String): Boolean = classifyPath(baseUrl) == UNIFIED - } + UNIFIED, } - -/** - * Returns whether the given [AzureUrlPathMode] is in a disabled state. - */ -fun AzureUrlPathMode.isUnifiedPathDisabled(): Boolean = this == AzureUrlPathMode.LEGACY \ No newline at end of file diff --git a/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt b/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt index ab8bf78e5..afb5dfbba 100644 --- a/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt +++ b/openai-java-core/src/main/kotlin/com/openai/azure/HttpRequestBuilderExtensions.kt @@ -2,7 +2,6 @@ package com.openai.azure import com.openai.core.ClientOptions import com.openai.core.http.HttpRequest -import com.openai.core.isAzureEndpoint import com.openai.credential.BearerTokenCredential @JvmSynthetic @@ -10,15 +9,12 @@ internal fun HttpRequest.Builder.addPathSegmentsForAzure( clientOptions: ClientOptions, deploymentModel: String?, ): HttpRequest.Builder = apply { - val baseUrl = clientOptions.baseUrl() - if (isAzureEndpoint(baseUrl)) { - // Users can toggle off unified Azure routes using the "azureLegacyPaths" option. - // Endpoints are assumed to be provided with `/openai/v1` in their path already. - if (clientOptions.azureUrlPathMode.isUnifiedPathDisabled() || !AzureUrlPathMode.isUnifiedPath(baseUrl)) { - // Legacy known Azure endpoints are treated the old way. - addPathSegment("openai") - deploymentModel?.let { addPathSegments("deployments", it) } - } + val urlCategory = + AzureUrlCategory.categorizeBaseUrl(clientOptions.baseUrl(), clientOptions.azureUrlPathMode) + if (urlCategory == AzureUrlCategory.AZURE_LEGACY) { + // Legacy known Azure endpoints are treated the old way. + addPathSegment("openai") + deploymentModel?.let { addPathSegments("deployments", it) } } } @@ -26,10 +22,9 @@ internal fun HttpRequest.Builder.addPathSegmentsForAzure( internal fun HttpRequest.Builder.replaceBearerTokenForAzure( clientOptions: ClientOptions ): HttpRequest.Builder = apply { - if ( - isAzureEndpoint(clientOptions.baseUrl()) && - clientOptions.credential is BearerTokenCredential - ) { + val urlCategory = + AzureUrlCategory.categorizeBaseUrl(clientOptions.baseUrl(), clientOptions.azureUrlPathMode) + if (urlCategory.isAzure() && clientOptions.credential is BearerTokenCredential) { replaceHeaders("Authorization", "Bearer ${clientOptions.credential.token()}") } } diff --git a/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt b/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt index f159604b1..6c91bb4b6 100644 --- a/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt +++ b/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt @@ -4,9 +4,9 @@ package com.openai.core import com.fasterxml.jackson.databind.json.JsonMapper import com.openai.azure.AzureOpenAIServiceVersion +import com.openai.azure.AzureUrlCategory import com.openai.azure.AzureUrlPathMode import com.openai.azure.credential.AzureApiKeyCredential -import com.openai.azure.isUnifiedPathDisabled import com.openai.core.http.AsyncStreamResponse import com.openai.core.http.Headers import com.openai.core.http.HttpClient @@ -100,7 +100,7 @@ private constructor( @get:JvmName("maxRetries") val maxRetries: Int, @get:JvmName("credential") val credential: Credential, @get:JvmName("azureServiceVersion") val azureServiceVersion: AzureOpenAIServiceVersion?, - @get:JvmName("azureUrlPathMode") val azureUrlPathMode: AzureUrlPathMode = AzureUrlPathMode.UNIFIED, + @get:JvmName("azureUrlPathMode") val azureUrlPathMode: AzureUrlPathMode, private val organization: String?, private val project: String?, private val webhookSecret: String?, @@ -494,20 +494,20 @@ private constructor( } baseUrl?.let { - if (isAzureEndpoint(it)) { + when (AzureUrlCategory.categorizeBaseUrl(it, azureUrlPathMode)) { // Legacy Azure routes will still require an api-version value. - if (azureUrlPathMode.isUnifiedPathDisabled() || !isAzureUnifiedEndpointPath(it)) { + AzureUrlCategory.AZURE_LEGACY -> replaceQueryParams( "api-version", (azureServiceVersion ?: AzureOpenAIServiceVersion.latestStableVersion()) .value, ) - } else { - // We only add the value if it's defined by the user for unified Azure routes. + // We only add the value if it's defined by the user for unified Azure routes. + AzureUrlCategory.AZURE_UNIFIED -> azureServiceVersion?.let { version -> replaceQueryParams("api-version", version.value) } - } + AzureUrlCategory.NON_AZURE -> {} } } diff --git a/openai-java-core/src/main/kotlin/com/openai/core/Utils.kt b/openai-java-core/src/main/kotlin/com/openai/core/Utils.kt index eb801421d..d8516f3e3 100644 --- a/openai-java-core/src/main/kotlin/com/openai/core/Utils.kt +++ b/openai-java-core/src/main/kotlin/com/openai/core/Utils.kt @@ -2,9 +2,7 @@ package com.openai.core -import com.openai.azure.AzureUrlCategory import com.openai.errors.OpenAIInvalidDataException -import java.net.URI import java.util.Collections import java.util.SortedMap @@ -91,19 +89,4 @@ internal fun Any?.contentToString(): String { return string } -/** - * Convenience function to check if the given [baseUrl] is an Azure OpenAI resource URL. - */ -@JvmSynthetic -internal fun isAzureEndpoint(baseUrl: String): Boolean { - val trimmedBaseUrl = baseUrl.trim().trimEnd('/') - return AzureUrlCategory.isAzureEndpoint(URI.create(trimmedBaseUrl).host) -} - -/** - * Convenience function to check if the given [baseUrl] is an Azure OpenAI resource URL with the unified schema. - */ -@JvmSynthetic -internal fun isAzureUnifiedEndpointPath(baseUrl: String): Boolean = baseUrl.trimEnd('/').endsWith("openai/v1") - internal interface Enum 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 new file mode 100644 index 000000000..6735cd8a6 --- /dev/null +++ b/openai-java-core/src/test/kotlin/com/openai/azure/AzureUrlCategoryTest.kt @@ -0,0 +1,220 @@ +package com.openai.azure + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +internal class AzureUrlCategoryTest { + + @Test + fun isAzure() { + assertThat(AzureUrlCategory.AZURE_LEGACY.isAzure()).isTrue() + assertThat(AzureUrlCategory.AZURE_UNIFIED.isAzure()).isTrue() + assertThat(AzureUrlCategory.NON_AZURE.isAzure()).isFalse() + } + + @Test + fun categorizeBaseUrl() { + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://region.openai.azure.com", + AzureUrlPathMode.LEGACY, + ) + ) + .isEqualTo(AzureUrlCategory.AZURE_LEGACY) + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://region.openai.azure.com", + AzureUrlPathMode.UNIFIED, + ) + ) + .isEqualTo(AzureUrlCategory.AZURE_LEGACY) + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://region.openai.azure.com/", + AzureUrlPathMode.LEGACY, + ) + ) + .isEqualTo(AzureUrlCategory.AZURE_LEGACY) + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://region.openai.azure.com/", + AzureUrlPathMode.UNIFIED, + ) + ) + .isEqualTo(AzureUrlCategory.AZURE_LEGACY) + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://region.openai.azure.com/openai/v1", + AzureUrlPathMode.LEGACY, + ) + ) + .isEqualTo(AzureUrlCategory.AZURE_LEGACY) + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://region.openai.azure.com/openai/v1", + AzureUrlPathMode.UNIFIED, + ) + ) + .isEqualTo(AzureUrlCategory.AZURE_UNIFIED) + + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://region.services.ai.azure.com", + AzureUrlPathMode.LEGACY, + ) + ) + .isEqualTo(AzureUrlCategory.AZURE_LEGACY) + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://region.services.ai.azure.com", + AzureUrlPathMode.UNIFIED, + ) + ) + .isEqualTo(AzureUrlCategory.AZURE_LEGACY) + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://region.services.ai.azure.com/", + AzureUrlPathMode.LEGACY, + ) + ) + .isEqualTo(AzureUrlCategory.AZURE_LEGACY) + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://region.services.ai.azure.com/", + AzureUrlPathMode.UNIFIED, + ) + ) + .isEqualTo(AzureUrlCategory.AZURE_LEGACY) + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://region.services.ai.azure.com/openai/v1", + AzureUrlPathMode.LEGACY, + ) + ) + .isEqualTo(AzureUrlCategory.AZURE_LEGACY) + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://region.services.ai.azure.com/openai/v1", + AzureUrlPathMode.UNIFIED, + ) + ) + .isEqualTo(AzureUrlCategory.AZURE_UNIFIED) + + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://region.azure-api.net", + AzureUrlPathMode.LEGACY, + ) + ) + .isEqualTo(AzureUrlCategory.AZURE_LEGACY) + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://region.azure-api.net", + AzureUrlPathMode.UNIFIED, + ) + ) + .isEqualTo(AzureUrlCategory.AZURE_LEGACY) + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://region.azure-api.net/", + AzureUrlPathMode.LEGACY, + ) + ) + .isEqualTo(AzureUrlCategory.AZURE_LEGACY) + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://region.azure-api.net/", + AzureUrlPathMode.UNIFIED, + ) + ) + .isEqualTo(AzureUrlCategory.AZURE_LEGACY) + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://region.azure-api.net/openai/v1", + AzureUrlPathMode.LEGACY, + ) + ) + .isEqualTo(AzureUrlCategory.AZURE_LEGACY) + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://region.azure-api.net/openai/v1", + AzureUrlPathMode.UNIFIED, + ) + ) + .isEqualTo(AzureUrlCategory.AZURE_UNIFIED) + + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://region.cognitiveservices.azure.com", + AzureUrlPathMode.LEGACY, + ) + ) + .isEqualTo(AzureUrlCategory.AZURE_LEGACY) + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://region.cognitiveservices.azure.com", + AzureUrlPathMode.UNIFIED, + ) + ) + .isEqualTo(AzureUrlCategory.AZURE_LEGACY) + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://region.cognitiveservices.azure.com/", + AzureUrlPathMode.LEGACY, + ) + ) + .isEqualTo(AzureUrlCategory.AZURE_LEGACY) + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://region.cognitiveservices.azure.com/", + AzureUrlPathMode.UNIFIED, + ) + ) + .isEqualTo(AzureUrlCategory.AZURE_LEGACY) + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://region.cognitiveservices.azure.com/openai/v1", + AzureUrlPathMode.LEGACY, + ) + ) + .isEqualTo(AzureUrlCategory.AZURE_LEGACY) + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://region.cognitiveservices.azure.com/openai/v1", + AzureUrlPathMode.UNIFIED, + ) + ) + .isEqualTo(AzureUrlCategory.AZURE_UNIFIED) + + assertThat( + AzureUrlCategory.categorizeBaseUrl("https://example.com", AzureUrlPathMode.LEGACY) + ) + .isEqualTo(AzureUrlCategory.NON_AZURE) + assertThat( + AzureUrlCategory.categorizeBaseUrl("https://example.com", AzureUrlPathMode.UNIFIED) + ) + .isEqualTo(AzureUrlCategory.NON_AZURE) + assertThat( + AzureUrlCategory.categorizeBaseUrl("https://example.com/", AzureUrlPathMode.LEGACY) + ) + .isEqualTo(AzureUrlCategory.NON_AZURE) + assertThat( + AzureUrlCategory.categorizeBaseUrl("https://example.com/", AzureUrlPathMode.UNIFIED) + ) + .isEqualTo(AzureUrlCategory.NON_AZURE) + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://example.com/openai/v1", + AzureUrlPathMode.LEGACY, + ) + ) + .isEqualTo(AzureUrlCategory.NON_AZURE) + assertThat( + AzureUrlCategory.categorizeBaseUrl( + "https://example.com/openai/v1", + AzureUrlPathMode.UNIFIED, + ) + ) + .isEqualTo(AzureUrlCategory.NON_AZURE) + } +} diff --git a/openai-java-core/src/test/kotlin/com/openai/core/UtilsTest.kt b/openai-java-core/src/test/kotlin/com/openai/core/UtilsTest.kt index faf0e1431..2f3a54ba3 100644 --- a/openai-java-core/src/test/kotlin/com/openai/core/UtilsTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/core/UtilsTest.kt @@ -2,7 +2,6 @@ package com.openai.core import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows internal class UtilsTest { @Test @@ -31,45 +30,4 @@ internal class UtilsTest { assertThat(arrayOf(byteArrayOf(1, 2), byteArrayOf(3)).contentToString()) .isEqualTo("[[1, 2], [3]]") } - - @Test - fun isAzureEndpoint() { - // Valid Azure endpoints - - // legacy - assertThat(isAzureEndpoint("https://region.openai.azure.com")).isTrue() - assertThat(isAzureEndpoint("https://region.openai.azure.com/")).isTrue() - // unified with OpenAI - assertThat(isAzureEndpoint("https://region.services.ai.azure.com")).isTrue() - assertThat(isAzureEndpoint("https://region.services.ai.azure.com/")).isTrue() - // other known valid schemas - assertThat(isAzureEndpoint("https://region.azure-api.net")).isTrue() - assertThat(isAzureEndpoint("https://region.azure-api.net/")).isTrue() - assertThat(isAzureEndpoint("https://region.cognitiveservices.azure.com")).isTrue() - assertThat(isAzureEndpoint("https://region.cognitiveservices.azure.com/")).isTrue() - - // Invalid Azure endpoints - assertThat(isAzureEndpoint("https://example.com")).isFalse() - assertThat(isAzureEndpoint("https://region.openai.com")).isFalse() - assertThat(isAzureEndpoint("https://region.azure.com")).isFalse() - assertThrows {isAzureEndpoint("")} - assertThrows{isAzureEndpoint(" ")} - } - - @Test - fun isAzureUnifiedEndpoint() { - // Valid Azure unified endpoints - assertThat(isAzureUnifiedEndpointPath("https://region.services.ai.azure.com/openai/v1")).isTrue() - assertThat(isAzureUnifiedEndpointPath("https://region.services.ai.azure.com/openai/v1/")).isTrue() - - // Invalid Azure unified endpoints - assertThat(isAzureUnifiedEndpointPath("https://region.services.ai.azure.com")).isFalse() - assertThat(isAzureUnifiedEndpointPath("https://region.services.ai.azure.com/")).isFalse() - assertThat(isAzureUnifiedEndpointPath("https://region.openai.azure.com")).isFalse() - assertThat(isAzureUnifiedEndpointPath("https://example.com")).isFalse() - assertThat(isAzureUnifiedEndpointPath("https://region.openai.com")).isFalse() - assertThat(isAzureUnifiedEndpointPath("https://region.azure.com")).isFalse() - assertThat(isAzureUnifiedEndpointPath("")).isFalse() - assertThat(isAzureUnifiedEndpointPath(" ")).isFalse() - } -} \ No newline at end of file +} diff --git a/openai-java-core/src/test/kotlin/com/openai/core/http/ClientOptionsTest.kt b/openai-java-core/src/test/kotlin/com/openai/core/http/ClientOptionsTest.kt index 77e6d2ff8..ee15cf062 100644 --- a/openai-java-core/src/test/kotlin/com/openai/core/http/ClientOptionsTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/core/http/ClientOptionsTest.kt @@ -2,7 +2,6 @@ package com.openai.core.http import com.openai.azure.AzureUrlPathMode import com.openai.azure.credential.AzureApiKeyCredential -import com.openai.azure.isUnifiedPathDisabled import com.openai.client.okhttp.OkHttpClient import com.openai.core.ClientOptions import com.openai.credential.BearerTokenCredential @@ -69,7 +68,7 @@ internal class ClientOptionsTest { } @Test - fun azureLegacyPathsTestSetTrue() { + fun azureUrlPathMode_setToLegacy() { val clientOptions = ClientOptions.builder() .httpClient(createOkHttpClient()) @@ -77,17 +76,17 @@ internal class ClientOptionsTest { .azureUrlPathMode(AzureUrlPathMode.LEGACY) .build() - assertThat(clientOptions.azureUrlPathMode.isUnifiedPathDisabled()).isTrue() + assertThat(clientOptions.azureUrlPathMode).isEqualTo(AzureUrlPathMode.LEGACY) } @Test - fun azureLegacyPathsTestDefaultFalse() { + fun azureUrlPathMode_defaultsToUnified() { val clientOptions = ClientOptions.builder() .httpClient(createOkHttpClient()) .credential(BearerTokenCredential.create(FAKE_API_KEY)) .build() - assertThat(clientOptions.azureUrlPathMode.isUnifiedPathDisabled()).isFalse() + assertThat(clientOptions.azureUrlPathMode).isEqualTo(AzureUrlPathMode.UNIFIED) } } diff --git a/openai-java-example/src/main/java/com/openai/example/AzureLegacyPathsEnabledExample.java b/openai-java-example/src/main/java/com/openai/example/AzureLegacyPathsEnabledExample.java index 74c79eb93..7cef6c50d 100644 --- a/openai-java-example/src/main/java/com/openai/example/AzureLegacyPathsEnabledExample.java +++ b/openai-java-example/src/main/java/com/openai/example/AzureLegacyPathsEnabledExample.java @@ -30,4 +30,4 @@ public static void main(String[] args) { .flatMap(choice -> choice.message().content().stream()) .forEach(System.out::println); } -} \ No newline at end of file +} diff --git a/openai-java-example/src/main/java/com/openai/example/AzureUnifiedEndpointExample.java b/openai-java-example/src/main/java/com/openai/example/AzureUnifiedEndpointExample.java index d3d8e346e..c4f71c0c5 100644 --- a/openai-java-example/src/main/java/com/openai/example/AzureUnifiedEndpointExample.java +++ b/openai-java-example/src/main/java/com/openai/example/AzureUnifiedEndpointExample.java @@ -25,4 +25,4 @@ public static void main(String[] args) { .flatMap(choice -> choice.message().content().stream()) .forEach(System.out::println); } -} \ No newline at end of file +}