Skip to content

fix(azure): carry declared options through withOptions and fix deployment paths - #2514

Open
morgan-coded wants to merge 2 commits into
openai:mainfrom
morgan-coded:fix/azure-declared-options
Open

fix(azure): carry declared options through withOptions and fix deployment paths#2514
morgan-coded wants to merge 2 commits into
openai:mainfrom
morgan-coded:fix/azure-declared-options

Conversation

@morgan-coded

Copy link
Copy Markdown
Contributor

AzureOpenAI overrides withOptions only to re-type it: apiVersion and deployment are instance fields that OpenAI.withOptions cannot see when it rebuilds the clone, and the inherited baseURL is always carried across. So a clone throws The OPENAI_API_VERSION environment variable is missing or empty when that variable is unset, silently rebuilds the deployment segment from body.model when it is set — a client configured for deployment-a starts sending /openai/deployments/request-model/chat/completions, which 404s — and rejects withOptions({ endpoint }) with baseURL and endpoint are mutually exclusive, though endpoint is declared on the Partial<AzureClientOptions> the override accepts. Separately, the deployment-path suppression was a substring test on the base URL, so a host like deployments.example.com or a gateway path like /azure/deployments-proxy/openai dropped the deployment segment and Azure answered 404 with nothing to show the SDK had dropped it.

Following src/bedrock.ts, which overrides withOptions to re-inject a subclass-only field the base clone cannot see, the Azure override now re-injects apiVersion and deployment ahead of the caller's options and clears the inherited baseURL only when the caller passed an endpoint and no explicit baseURL, so caller-supplied values still win and { deployment: undefined } still clears. The suppression now tests new URL(baseURL).pathname for a deployments path segment, so /openai/deployments/existing and its trailing-slash form still suppress and only the false matches change. One residual stays: with OPENAI_BASE_URL set, withOptions({ endpoint }) still throws baseURL and endpoint are mutually exclusive, exactly as new AzureOpenAI({ endpoint }) does in that environment.

I added 10 regression tests across tests/lib/azure.test.ts and tests/lib/azure-deployment-path-safety.test.ts — nine red before the fix, the tenth a parity pin on the boundary case — then ran lint, tsc --noEmit, and the full suite, with 7019 handwritten and 556 generated tests passing.

AzureClientOptions declares a workloadIdentity member that the Azure credential guard never consults, and I've deliberately left it untouched here: wiring it up would federate an Azure-only client's calls through OpenAI's own token exchange at the hardcoded https://auth.openai.com/oauth/token, so whether Azure should accept it or declare it ?: never alongside provider/dataResidency/credential/x509Transport is your call, not mine — flagging it rather than changing it.

…ment paths

`AzureOpenAI` contradicts its own declared option surface in two places:

- `withOptions` re-types the option bag but re-injects nothing. `apiVersion`
  and `deployment` live only as instance fields and never reach `_options`, so
  `OpenAI.withOptions` rebuilds the clone without them: the clone throws
  `The OPENAI_API_VERSION environment variable is missing or empty` when the
  variable is unset, and otherwise silently misroutes, rebuilding the
  deployment segment from `body.model`. An `endpoint` override — a declared
  member of the `Partial<AzureClientOptions>` the override advertises — always
  collided with the inherited base URL. Re-inject the Azure-only options the
  way `BedrockOpenAI.withOptions` re-injects its own subclass-only field.

- Deployment suppression tested the whole base URL for the `/deployments`
  substring, so a gateway path such as `/azure/deployments-proxy/openai` (or a
  host like `deployments.example.com`) silently dropped the deployment segment
  and Azure answered 404. Test the base URL's path for a `/deployments`
  segment instead.
@morgan-coded
morgan-coded requested a review from a team as a code owner August 28, 2026 19:46

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8679512bb7

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/azure.ts Outdated
Comment on lines +166 to +167
if (options.endpoint !== undefined && options.baseURL === undefined) {
azureOptions.baseURL = undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require an own endpoint before clearing the trusted base URL

If Object.prototype.endpoint is polluted after an Azure client is constructed and OPENAI_BASE_URL is unset, even client.withOptions({ maxRetries: 0 }) enters this branch despite having no own endpoint. The inherited value remains visible when the clone's constructor reads endpoint, so the clone replaces the trusted base URL with the polluted origin and sends its API key there on the next request. Check for an own endpoint property before clearing the inherited base URL.

AGENTS.md reference: AGENTS.md:L98-L105

Useful? React with 👍 / 👎.

…e URL

Follow-up to the `withOptions` override and the deployment segment test.

- The override advertises that it clears the inherited `baseURL` only when the
  caller passed an `endpoint` and no explicit `baseURL`, but both halves of the
  guard were plain member reads that answer from the prototype chain. An option
  bag that never mentioned either field — `withOptions({ maxRetries: 0 })` —
  could enter the branch and lose the base URL it was supposed to inherit, and
  the mirror case, an inherited `baseURL` alongside a real `endpoint` override,
  skipped the branch and failed with `baseURL and endpoint are mutually
  exclusive`. Test what the caller passed with the `hasOwn` helper the client
  already uses for `baseURL`. Passing `endpoint: undefined` is still not passing
  an endpoint, so the value check stays.

- `hasDeploymentPathSegment` parsed the base URL unguarded on the request path,
  so a base URL that is not an absolute URL now threw `TypeError: Invalid URL`
  where the previous substring test simply reported no match and let the joined
  request URL decide. Report no segment instead, which is what the substring
  test answered for every base URL a request could be built from.
@morgan-coded

Copy link
Copy Markdown
Contributor Author

Tightened the withOptions guard to test what the caller passed — hasOwn(options, 'endpoint') and !hasOwn(options, 'baseURL'), the hasOwn test OpenAI.withOptions already applies to baseURL at src/client.ts:690 — rather than reading both fields through the prototype chain. The automated review flagged that at src/azure.ts:167. Also wrapped the parse in hasDeploymentPathSegment: a base URL like https:// threw TypeError: Invalid URL out of buildRequest where the pre-PR substring test reported no match, so it reports no segment there instead. 144062a5 sits on top of the reviewed head, so the re-review diff is one commit.

The repro in that comment is accurate for 8679512b — the clone does rebuild against the polluted origin with the key attached. Unmodified main never reaches that state through this path: it refuses the clone with The OPENAI_API_VERSION environment variable is missing or empty when that variable is unset and baseURL and endpoint are mutually exclusive when it is set, so nothing released is affected and the defect existed on this PR's head commit and nowhere else. What made the path reachable is this PR's own fix — the clone builds at all because apiVersion and deployment are now carried into it — and the constructor takes its options by destructuring (src/azure.ts:86-96), so an inherited endpoint is visible to it.

Two sentences in the description are stale now. Corrected:

  • Tests: the branch adds 19 test blocks and 32 executed cases to the two azure test files, of which 16 blocks and 20 cases are red at the pre-PR parent; the remaining 3 blocks and 12 cases are parity pins that pass there by design. The full suite is 7041 handwritten and 556 generated passing.
  • Deployment paths: on a base URL that parses, the answer moves in both directions — deployments.example.com and /azure/deployments-proxy/openai stop suppressing, and a base URL whose /deployments segment appears once WHATWG normalization runs (a backslash, an embedded tab, carriage return, or newline) starts suppressing where the raw substring test did not match, which now has its own test block. A base URL that does not parse always reports no segment, so the ones that textually carried /deployments insert where they used to suppress, and the request still ends at TypeError: Invalid URL once the path is joined, as it did before.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant