From 59face1bf966d64bcd4fdeac5c4ed4bb20ca2d8c Mon Sep 17 00:00:00 2001 From: Henry Su Date: Mon, 10 Aug 2026 12:18:18 -0500 Subject: [PATCH 1/2] fix(azure): preserve deployment routing across copy/with_options AzureOpenAI.copy()/with_options() reconstructs the client from base_url via the base OpenAI.copy(), which does not pass azure_endpoint or azure_deployment. As a result _azure_endpoint and _azure_deployment were reset to None on the copy, so _prepare_url() no longer bypassed the deployment path for non-deployment endpoints. A client built with azure_deployment would then route e.g. /models to /openai/deployments//models instead of /openai/models, producing 404s after a copy. Preserve _azure_endpoint/_azure_deployment on the copied client unless the caller overrides base_url. --- src/openai/lib/azure.py | 20 ++++++++++++++++++-- tests/lib/test_azure.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/openai/lib/azure.py b/src/openai/lib/azure.py index 4ebe0a98aa..d7d0a6c357 100644 --- a/src/openai/lib/azure.py +++ b/src/openai/lib/azure.py @@ -310,7 +310,7 @@ def copy( if not isinstance(provider, NotGiven): raise OpenAIError("Configure `provider` on `OpenAI`, not on `AzureOpenAI.with_options()`.") - return super().copy( + copied = super().copy( api_key=api_key, admin_api_key=admin_api_key, workload_identity=workload_identity, @@ -334,6 +334,14 @@ def copy( **_extra_kwargs, }, ) + # `super().copy()` reconstructs the client from `base_url`, which does not carry the + # Azure endpoint/deployment context that `_prepare_url` relies on to route + # non-deployment endpoints (e.g. `/models`). Preserve it unless the caller overrides + # the base URL. + if base_url is None: + copied._azure_endpoint = self._azure_endpoint + copied._azure_deployment = self._azure_deployment + return copied with_options = copy @@ -634,7 +642,7 @@ def copy( if not isinstance(provider, NotGiven): raise OpenAIError("Configure `provider` on `AsyncOpenAI`, not on `AsyncAzureOpenAI.with_options()`.") - return super().copy( + copied = super().copy( api_key=api_key, admin_api_key=admin_api_key, workload_identity=workload_identity, @@ -658,6 +666,14 @@ def copy( **_extra_kwargs, }, ) + # `super().copy()` reconstructs the client from `base_url`, which does not carry the + # Azure endpoint/deployment context that `_prepare_url` relies on to route + # non-deployment endpoints (e.g. `/models`). Preserve it unless the caller overrides + # the base URL. + if base_url is None: + copied._azure_endpoint = self._azure_endpoint + copied._azure_deployment = self._azure_deployment + return copied with_options = copy diff --git a/tests/lib/test_azure.py b/tests/lib/test_azure.py index 3e1d783e2c..ddca801e42 100644 --- a/tests/lib/test_azure.py +++ b/tests/lib/test_azure.py @@ -79,6 +79,41 @@ def test_client_copying_override_options(client: Client) -> None: assert copied._custom_query == {"api-version": "2022-05-01"} +@pytest.mark.parametrize( + "client", + [ + AzureOpenAI( + api_version="2024-02-01", + api_key="example API key", + azure_endpoint="https://example-resource.azure.openai.com", + azure_deployment="deployment-client", + ), + AsyncAzureOpenAI( + api_version="2024-02-01", + api_key="example API key", + azure_endpoint="https://example-resource.azure.openai.com", + azure_deployment="deployment-client", + ), + ], +) +@pytest.mark.parametrize("method", ["copy", "with_options"]) +def test_copy_preserves_deployment_routing(client: Client, method: Literal["copy", "with_options"]) -> None: + copied = client.copy() if method == "copy" else client.with_options() + + # a non-deployment endpoint must not be nested under `/deployments//` + req = copied._build_request(FinalRequestOptions.construct(method="get", url="/models", json_data={})) + assert req.url == "https://example-resource.azure.openai.com/openai/models?api-version=2024-02-01" + + # a deployment endpoint must keep the deployment path + req = copied._build_request( + FinalRequestOptions.construct(method="post", url="/chat/completions", json_data={"model": "ignored"}) + ) + assert req.url == ( + "https://example-resource.azure.openai.com/openai/deployments/deployment-client" + "/chat/completions?api-version=2024-02-01" + ) + + def test_enforce_credentials_false_sync() -> None: with update_env(AZURE_OPENAI_API_KEY=Omit(), AZURE_OPENAI_AD_TOKEN=Omit()): AzureOpenAI( From 77a34c695075269e2f79bc11144d324aa2252ec9 Mon Sep 17 00:00:00 2001 From: Marcus Wood Date: Tue, 15 Sep 2026 21:04:59 +0000 Subject: [PATCH 2/2] test(azure): cover copied client routing and endpoint overrides --- tests/lib/test_azure.py | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/tests/lib/test_azure.py b/tests/lib/test_azure.py index 966a91cd8c..fd5067f55c 100644 --- a/tests/lib/test_azure.py +++ b/tests/lib/test_azure.py @@ -98,21 +98,36 @@ def test_client_copying_override_options(client: Client) -> None: ], ) @pytest.mark.parametrize("method", ["copy", "with_options"]) -def test_copy_preserves_deployment_routing(client: Client, method: Literal["copy", "with_options"]) -> None: - copied = client.copy() if method == "copy" else client.with_options() - - # a non-deployment endpoint must not be nested under `/deployments//` - req = copied._build_request(FinalRequestOptions.construct(method="get", url="/models", json_data={})) - assert req.url == "https://example-resource.azure.openai.com/openai/models?api-version=2024-02-01" +@pytest.mark.parametrize("base_url", [None, "https://replacement.example.test/gateway"]) +async def test_copy_preserves_deployment_routing( + client: Client, method: Literal["copy", "with_options"], base_url: str | None +) -> None: + copied = ( + client.copy(timeout=5, base_url=base_url) + if method == "copy" + else client.with_options(timeout=5, base_url=base_url) + ) + copied = copied.with_options(max_retries=0) + root = base_url or "https://example-resource.azure.openai.com/openai" + deployment = "body-model" if base_url else "deployment-client" - # a deployment endpoint must keep the deployment path + # Non-deployment endpoints use the root; deployment endpoints retain their routing. + req = copied._build_request(FinalRequestOptions.construct(method="get", url="/models")) + assert req.url == root + "/models?api-version=2024-02-01" req = copied._build_request( - FinalRequestOptions.construct(method="post", url="/chat/completions", json_data={"model": "ignored"}) - ) - assert req.url == ( - "https://example-resource.azure.openai.com/openai/deployments/deployment-client" - "/chat/completions?api-version=2024-02-01" + FinalRequestOptions.construct(method="post", url="/chat/completions", json_data={"model": "body-model"}) ) + assert req.url == root + f"/deployments/{deployment}/chat/completions?api-version=2024-02-01" + + if isinstance(copied, AsyncAzureOpenAI): + url, headers = await copied._configure_realtime("body-model", {}) + else: + url, headers = copied._configure_realtime("body-model", {}) + assert url == root.replace("https://", "wss://") + (f"/realtime?api-version=2024-02-01&deployment={deployment}") + assert headers == {"api-key": "example API key"} + + # Changing a copy's destination must not change the original client. + assert client._prepare_url("/models") == "https://example-resource.azure.openai.com/openai/models" @pytest.mark.parametrize("client", [sync_client, async_client])