From c0b40388c9d17fc543a52afa2ccce5569e8648ca Mon Sep 17 00:00:00 2001 From: hith3sh Date: Tue, 7 Jul 2026 09:24:35 +0530 Subject: [PATCH] fix(ai-gateway-provider): support Azure OpenAI v1 responses routing Azure Responses API models created with useDeploymentBasedUrls: false generate URLs like https://.openai.azure.com/openai/v1/responses, which the shared gateway provider matcher did not recognize, so wrapped Azure responses models failed with 'provider "azure.responses" is currently not supported'. Map /openai/v1/responses... to the gateway's azure-openai endpoint shape ({resource}/openai/responses..., Azure's non-deployment Responses route) while keeping deployment-based routing unchanged. The match is deliberately scoped to responses: other v1 paths (chat, embeddings) have no non-deployment Azure route, so they stay unmatched with a clear error instead of routing to an endpoint that 404s at Azure. Changeset covers ai-gateway-provider and workers-ai-provider, which share the matcher via gateway-core. Closes #399 --- .changeset/funny-rivers-protect.md | 8 ++++ .../ai-gateway-provider/test/endpoint.test.ts | 14 +++++++ .../test/request-shaping.test.ts | 41 +++++++++++++++++++ .../gateway-core/src/gateway-providers.ts | 25 ++++++++--- 4 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 .changeset/funny-rivers-protect.md diff --git a/.changeset/funny-rivers-protect.md b/.changeset/funny-rivers-protect.md new file mode 100644 index 000000000..ecbef6d11 --- /dev/null +++ b/.changeset/funny-rivers-protect.md @@ -0,0 +1,8 @@ +--- +"ai-gateway-provider": patch +"workers-ai-provider": patch +--- + +Support Azure OpenAI v1 Responses API URLs when routing wrapped models through AI Gateway. + +`createAzure(...).responses(...)` with `useDeploymentBasedUrls: false` generates `https://{resource}.openai.azure.com/openai/v1/responses?...` URLs, which the shared provider matcher did not recognize, so wrapped Azure responses models failed with `provider "azure.responses" is currently not supported`. These URLs now route through the `azure-openai` gateway provider as `{resource}/openai/responses...` (Azure's non-deployment Responses route). Deployment-based Azure routing is unchanged, and the match is deliberately scoped to `responses` — other v1 paths (chat, embeddings) have no non-deployment Azure route to map to. Applies to both `ai-gateway-provider`'s wrapped-model routing and `workers-ai-provider`'s `createGatewayProvider` URL detection, which share the matcher. diff --git a/packages/ai-gateway-provider/test/endpoint.test.ts b/packages/ai-gateway-provider/test/endpoint.test.ts index ec2495412..5f436103b 100644 --- a/packages/ai-gateway-provider/test/endpoint.test.ts +++ b/packages/ai-gateway-provider/test/endpoint.test.ts @@ -52,6 +52,11 @@ const testCases = [ name: "azure-openai", url: "https://myresource.openai.azure.com/openai/deployments/mydeployment/chat/completions?api-version=2024-02-15-preview", }, + { + expected: "myresource/openai/responses?api-version=2024-02-15-preview", + name: "azure-openai", + url: "https://myresource.openai.azure.com/openai/v1/responses?api-version=2024-02-15-preview", + }, { expected: "v1/chat/completions", name: "openrouter", @@ -73,6 +78,15 @@ describe("ProvidersConfigs endpoint parsing", () => { expect(result).toBe(testCase.expected); }); } + + // Azure's v1 surface is only routable for the Responses API (the sole path + // with a non-deployment Azure route); other v1 paths must stay unmatched + // rather than route to an endpoint that 404s at Azure. + it("does not match Azure v1 paths other than responses", () => { + const url = + "https://myresource.openai.azure.com/openai/v1/chat/completions?api-version=2024-02-15-preview"; + expect(providers.find((p) => p.regex.test(url))).toBeUndefined(); + }); }); describe("Provider auth header selection", () => { diff --git a/packages/ai-gateway-provider/test/request-shaping.test.ts b/packages/ai-gateway-provider/test/request-shaping.test.ts index df13a93ce..50f4bcc7a 100644 --- a/packages/ai-gateway-provider/test/request-shaping.test.ts +++ b/packages/ai-gateway-provider/test/request-shaping.test.ts @@ -3,6 +3,7 @@ import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { CF_TEMP_TOKEN } from "../src/auth"; +import { createAzure } from "../src/providers/azure"; import { createOpenAI } from "../src/providers/openai"; import { createAiGateway } from "../src"; @@ -113,6 +114,46 @@ describe("REST request shaping", () => { expect(captured.headers["cf-aig-byok-alias"]).toBe("alias-1"); expect(captured.headers["cf-aig-zdr"]).toBe("true"); }); + + it("routes Azure responses API v1 URLs through the azure-openai provider entry", async () => { + server.use( + http.post(GATEWAY_URL, async ({ request }) => { + captured.headers = Object.fromEntries(request.headers); + captured.body = await request.json(); + return HttpResponse.json(openAiResponse()); + }), + ); + + const aigateway = createAiGateway({ + accountId: TEST_ACCOUNT_ID, + apiKey: TEST_API_KEY, + gateway: TEST_GATEWAY, + }); + const azure = createAzure({ + resourceName: "myresource", + apiKey: "azure-api-key", + apiVersion: "2024-02-15-preview", + useDeploymentBasedUrls: false, + }); + + await generateText({ + model: aigateway(azure.responses("gpt-5.1")), + prompt: "hi", + maxRetries: 0, + }); + + const body = captured.body as Array<{ + provider: string; + endpoint: string; + query: { model?: string }; + }>; + expect(body).toHaveLength(1); + expect(body[0]?.provider).toBe("azure-openai"); + expect(body[0]?.endpoint).toBe( + "myresource/openai/responses?api-version=2024-02-15-preview", + ); + expect(body[0]?.query.model).toBe("gpt-5.1"); + }); }); describe("Binding request shaping", () => { diff --git a/packages/gateway-core/src/gateway-providers.ts b/packages/gateway-core/src/gateway-providers.ts index ac692df4f..27f137c95 100644 --- a/packages/gateway-core/src/gateway-providers.ts +++ b/packages/gateway-core/src/gateway-providers.ts @@ -134,16 +134,29 @@ function bedrockTransform(url: string): string { return `bedrock-runtime/${region}/${rest}`; } -// Azure's URL carries the resource + deployment, so it needs a bespoke transform -// (mirrors ai-gateway-provider). Only used for bring-your-own-provider detection. +// Azure OpenAI supports both deployment-based URLs +// (`/openai/deployments/{deployment}/...`) and the v1 surface +// (`/openai/v1/...`). AI Gateway expects the former as +// `{resource}/{deployment}/{rest}`. On the v1 surface only the Responses API is +// routable: Azure exposes a non-deployment `/openai/responses` route, so the +// gateway endpoint is `{resource}/openai/responses...`. Other v1 paths (chat, +// embeddings) have no such non-deployment route and stay unmatched rather than +// being routed to an endpoint that 404s at Azure. +// Only used for bring-your-own-provider detection. const AZURE_HOST = - /^https:\/\/(?[^.]+)\.openai\.azure\.com\/openai\/deployments\/(?[^/]+)\/(?.*)$/; + /^https:\/\/(?[^.]+)\.openai\.azure\.com\/openai\/(?:(?:deployments\/(?[^/]+)\/(?.*))|(?:v1\/(?responses.*)))$/; function azureTransform(url: string): string { const m = url.match(AZURE_HOST); if (!m?.groups) return url; - const { resource, deployment, rest } = m.groups; - if (!resource || !deployment || !rest) return url; - return `${resource}/${deployment}/${rest}`; + const { resource, deployment, rest, v1rest } = m.groups; + if (!resource) return url; + if (deployment && rest) { + return `${resource}/${deployment}/${rest}`; + } + if (v1rest) { + return `${resource}/openai/${v1rest}`; + } + return url; } /**