Skip to content

Commit b3c04f4

Browse files
fix(azure): preserve deployment routing across copy/with_options (#3593)
- [x] I understand that this repository is auto-generated and my pull request may not be merged ## Changes being requested Fixes a routing regression in `AzureOpenAI` / `AsyncAzureOpenAI` after `.copy()` / `.with_options()`. This change is in **hand-maintained** code — `src/openai/lib/azure.py` has no `File generated from our OpenAPI spec by Stainless` header, and `copy()`/`with_options()` there is custom Azure logic, so it is not affected by codegen. ### Problem `AzureOpenAI.copy()` (aliased as `with_options()`) delegates to the base `OpenAI.copy()`, which reconstructs the client from `base_url` and does **not** pass `azure_endpoint` / `azure_deployment` back to `AzureOpenAI.__init__`. Because `_azure_endpoint` and `_azure_deployment` are only set from those constructor arguments, they get reset to `None` on the copied client. `_prepare_url()` uses those attributes to bypass the deployment path for **non-deployment** endpoints: ```python if self._azure_deployment and self._azure_endpoint and url not in _deployments_endpoints: # -> {endpoint}/openai/{url} (no /deployments/<name>/) ``` Once they are `None`, that bypass no longer runs, so a client configured with `azure_deployment` starts routing non-deployment endpoints (e.g. `/models`) under the deployment path, which 404s. ### Reproduction (no network / key required) ```python from openai import AzureOpenAI c = AzureOpenAI( azure_endpoint="https://example.openai.azure.com", azure_deployment="my-deploy", api_version="2024-06-01", azure_ad_token="fake-token", ) print(c._prepare_url("/models")) # https://example.openai.azure.com/openai/models ✅ c2 = c.with_options(timeout=30) print(c2._azure_endpoint, c2._azure_deployment) # None None print(c2._prepare_url("/models")) # https://example.openai.azure.com/openai/deployments/my-deploy/models ❌ (404) ``` ### Fix Preserve `_azure_endpoint` / `_azure_deployment` on the copied client, unless the caller overrides `base_url` in the copy (in which case the old endpoint context is intentionally not carried over). Applied symmetrically to the sync and async clients. ### Why minimal - Only `copy()` in each of the two Azure clients changes; two guarded lines each. - Public API, credential handling, and the mutually-exclusive `base_url` / `azure_endpoint` constructor contract are untouched (the fix deliberately avoids passing `azure_endpoint` alongside `base_url`). - Deployment endpoints (e.g. `/chat/completions`) are unaffected — `base_url` already encodes the deployment and `_build_request` continues to guard on `"/deployments" in base_url.path`. ### Tests Added `test_copy_preserves_deployment_routing` in `tests/lib/test_azure.py` (sync + async × `copy` / `with_options`), asserting that after a copy: - `/models` → `{endpoint}/openai/models` (not nested under `/deployments/<name>/`), and - `/chat/completions` still keeps the deployment path. Fails on `main` (wrong `/models` URL), passes with this change. ### Validation - `rye run pytest tests/lib/test_azure.py` → 63 passed - `rye run pytest tests/lib/` → all pass except a pre-existing, unrelated failure (`test_bedrock_auth_conformance.py::test_retry_signing_fixture`, which fails identically on clean `main` in this environment) - `ruff check` / `ruff format` clean on both files - `pyright` and `mypy` clean on `src/openai/lib/azure.py` ### Compatibility No public API change; behavior only changes for the previously-broken post-copy case. Copies that override `base_url` keep their current behavior. ## Additional context & links Discovered by auditing sibling copy/state-preservation logic; no existing issue or PR covers this. `.with_options()` is a common pattern (per-request timeouts/headers), so Azure users combining it with `azure_deployment` are likely to hit this. --------- Co-authored-by: Marcus Wood <marcuswood@openai.com>
1 parent ccc10f5 commit b3c04f4

2 files changed

Lines changed: 68 additions & 2 deletions

File tree

‎src/openai/lib/azure.py‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,7 @@ def copy(
410410
current_provider=self._azure_ad_token_provider,
411411
)
412412

413-
return super().copy(
413+
copied = super().copy(
414414
api_key=api_key,
415415
admin_api_key=admin_api_key,
416416
workload_identity=workload_identity,
@@ -434,6 +434,14 @@ def copy(
434434
**_extra_kwargs,
435435
},
436436
)
437+
# `super().copy()` reconstructs the client from `base_url`, which does not carry the
438+
# Azure endpoint/deployment context that `_prepare_url` relies on to route
439+
# non-deployment endpoints (e.g. `/models`). Preserve it unless the caller overrides
440+
# the base URL.
441+
if base_url is None:
442+
copied._azure_endpoint = self._azure_endpoint
443+
copied._azure_deployment = self._azure_deployment
444+
return copied
437445

438446
with_options = copy
439447

@@ -762,7 +770,7 @@ def copy(
762770
current_provider=self._azure_ad_token_provider,
763771
)
764772

765-
return super().copy(
773+
copied = super().copy(
766774
api_key=api_key,
767775
admin_api_key=admin_api_key,
768776
workload_identity=workload_identity,
@@ -786,6 +794,14 @@ def copy(
786794
**_extra_kwargs,
787795
},
788796
)
797+
# `super().copy()` reconstructs the client from `base_url`, which does not carry the
798+
# Azure endpoint/deployment context that `_prepare_url` relies on to route
799+
# non-deployment endpoints (e.g. `/models`). Preserve it unless the caller overrides
800+
# the base URL.
801+
if base_url is None:
802+
copied._azure_endpoint = self._azure_endpoint
803+
copied._azure_deployment = self._azure_deployment
804+
return copied
789805

790806
with_options = copy
791807

‎tests/lib/test_azure.py‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,56 @@ def test_client_copying_override_options(client: Client) -> None:
8080
assert copied._custom_query == {"api-version": "2022-05-01"}
8181

8282

83+
@pytest.mark.parametrize(
84+
"client",
85+
[
86+
AzureOpenAI(
87+
api_version="2024-02-01",
88+
api_key="example API key",
89+
azure_endpoint="https://example-resource.azure.openai.com",
90+
azure_deployment="deployment-client",
91+
),
92+
AsyncAzureOpenAI(
93+
api_version="2024-02-01",
94+
api_key="example API key",
95+
azure_endpoint="https://example-resource.azure.openai.com",
96+
azure_deployment="deployment-client",
97+
),
98+
],
99+
)
100+
@pytest.mark.parametrize("method", ["copy", "with_options"])
101+
@pytest.mark.parametrize("base_url", [None, "https://replacement.example.test/gateway"])
102+
async def test_copy_preserves_deployment_routing(
103+
client: Client, method: Literal["copy", "with_options"], base_url: str | None
104+
) -> None:
105+
copied = (
106+
client.copy(timeout=5, base_url=base_url)
107+
if method == "copy"
108+
else client.with_options(timeout=5, base_url=base_url)
109+
)
110+
copied = copied.with_options(max_retries=0)
111+
root = base_url or "https://example-resource.azure.openai.com/openai"
112+
deployment = "body-model" if base_url else "deployment-client"
113+
114+
# Non-deployment endpoints use the root; deployment endpoints retain their routing.
115+
req = copied._build_request(FinalRequestOptions.construct(method="get", url="/models"))
116+
assert req.url == root + "/models?api-version=2024-02-01"
117+
req = copied._build_request(
118+
FinalRequestOptions.construct(method="post", url="/chat/completions", json_data={"model": "body-model"})
119+
)
120+
assert req.url == root + f"/deployments/{deployment}/chat/completions?api-version=2024-02-01"
121+
122+
if isinstance(copied, AsyncAzureOpenAI):
123+
url, headers = await copied._configure_realtime("body-model", {})
124+
else:
125+
url, headers = copied._configure_realtime("body-model", {})
126+
assert url == root.replace("https://", "wss://") + (f"/realtime?api-version=2024-02-01&deployment={deployment}")
127+
assert headers == {"api-key": "example API key"}
128+
129+
# Changing a copy's destination must not change the original client.
130+
assert client._prepare_url("/models") == "https://example-resource.azure.openai.com/openai/models"
131+
132+
83133
@pytest.mark.parametrize("client", [sync_client, async_client])
84134
@pytest.mark.parametrize("method", ["copy", "with_options"])
85135
def test_client_copying_rejects_x509_workload_identity(client: Client, method: Literal["copy", "with_options"]) -> None:

0 commit comments

Comments
 (0)