From 8676dcf7054c9b18228efdee2927addd9eb41b9e Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 21 Aug 2026 20:25:11 +0530 Subject: [PATCH] feat(ai): enable azure responses websocket (#43896) --- .../src/protocols/open-responses-channel.ts | 27 +++--- packages/ai/src/providers/azure.ts | 26 +++++ .../ai/test/provider/openai-responses.test.ts | 94 +++++++++++++++++++ packages/core/src/plugin/provider/azure.ts | 41 +++++--- .../core/test/plugin/provider-azure.test.ts | 47 ++++++++++ .../core/test/plugin/provider-openai.test.ts | 6 +- 6 files changed, 211 insertions(+), 30 deletions(-) diff --git a/packages/ai/src/protocols/open-responses-channel.ts b/packages/ai/src/protocols/open-responses-channel.ts index 898b97e476..142fdffd7a 100644 --- a/packages/ai/src/protocols/open-responses-channel.ts +++ b/packages/ai/src/protocols/open-responses-channel.ts @@ -22,6 +22,8 @@ export interface Options { readonly id: string readonly name: string readonly rotateAfterMs?: number + readonly enabled?: (url: string) => boolean + readonly url?: (url: string) => string readonly headers?: (headers: Headers.Headers) => Headers.Headers readonly driver?: (input: { readonly request: Readonly> @@ -147,18 +149,19 @@ export const transport = (options: Options): Transport { + const url = new URL(value) + return ( + url.protocol === "https:" && + url.hostname.endsWith(".openai.azure.com") && + url.pathname.endsWith("/openai/v1/responses") && + url.searchParams.get("api-version") === "v1" + ) + }, + url: (value) => { + const url = new URL(value) + url.searchParams.delete("api-version") + return url.toString() + }, + headers: (headers) => { + const apiKey = headers["api-key"] + if (!apiKey) return headers + return Headers.remove(Headers.set(headers, "authorization", `Bearer ${apiKey}`), "api-key") + }, + }), }) const chatRoute = OpenAIChat.route.with({ diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index fa4d090e4b..8240243733 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -725,6 +725,100 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("builds Azure WebSocket requests with v1 URLs and bearer auth", () => + Effect.gen(function* () { + const deps = Layer.succeed( + RequestExecutor.Service, + RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }), + ) + const cases = [ + { + model: Azure.configure({ resourceName: "opencode-test", apiKey: "azure-key" }).responses("deployment"), + authorization: "Bearer azure-key", + }, + { + model: Azure.configure({ resourceName: "opencode-test", auth: Auth.bearer("entra-token") }).responses( + "deployment", + ), + authorization: "Bearer entra-token", + }, + ] + + yield* Effect.forEach(cases, (item) => + LLMClient.generate(LLM.request({ model: item.model, prompt: "Say hello." }), { + webSocket: { + execute: (exchange) => + Effect.gen(function* () { + expect(exchange.connect.url).toBe("wss://opencode-test.openai.azure.com/openai/v1/responses") + expect(exchange.connect.rotateAfterMs).toBe(55 * 60 * 1000) + expect(exchange.connect.headers.authorization).toBe(item.authorization) + expect(exchange.connect.headers["api-key"]).toBeUndefined() + expect(exchange.connect.headers["openai-beta"]).toBeUndefined() + expect(JSON.parse((yield* exchange.driver.create(undefined)).message)).toMatchObject({ + type: "response.create", + model: "deployment", + store: false, + }) + return { + frames: Stream.make( + JSON.stringify({ type: "response.created", response: { id: "resp_azure" } }), + JSON.stringify({ type: "response.completed", response: { id: "resp_azure" } }), + ), + complete: Effect.void, + } + }), + }, + }).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps)))), + ) + }), + ) + + it.effect("keeps unsupported Azure endpoints and API versions on HTTP", () => + Effect.gen(function* () { + const cases = [ + { + model: Azure.configure({ + resourceName: "opencode-test", + apiKey: "azure-key", + apiVersion: "2025-04-01-preview", + }).responses("deployment"), + url: "https://opencode-test.openai.azure.com/openai/v1/responses?api-version=2025-04-01-preview", + }, + { + model: Azure.configure({ + resourceName: "opencode-test", + apiKey: "azure-key", + useDeploymentBasedUrls: true, + }).responses("deployment"), + url: "https://opencode-test.openai.azure.com/openai/deployments/deployment/responses?api-version=v1", + }, + { + model: Azure.configure({ baseURL: "https://gateway.example/azure", apiKey: "azure-key" }).responses( + "deployment", + ), + url: "https://gateway.example/azure/responses", + }, + ] + + yield* Effect.forEach(cases, (item) => + LLMClient.generate(LLM.request({ model: item.model, prompt: "Say hello." }), { + webSocket: { execute: () => Effect.die("unexpected WebSocket request") }, + }).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + expect(input.request.url).toBe(item.url) + return input.respond(sseEvents({ type: "response.completed", response: {} }), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ), + ) + }), + ) + it.effect("uses exactly one HTTP request when no WebSocket executor is supplied", () => Effect.gen(function* () { const attempts = yield* Ref.make(0) diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index c2a9299ab3..d7fd2a7236 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -1,6 +1,7 @@ import { Effect } from "effect" import { define } from "@opencode-ai/plugin/effect/plugin" import { Form } from "@opencode-ai/schema/form" +import { Model } from "../../model.js" import { Provider } from "../../provider.js" import { iife } from "../../util/iife.js" import { configuredSettings } from "./configured.js" @@ -44,23 +45,24 @@ export const AzurePlugin = define({ if (item.provider.id !== Provider.ID.azure && Provider.packageName(item.provider.package) !== "@ai-sdk/azure") continue const resourceName = resolveResourceName(item.provider.settings) - if (!resourceName) continue - evt.provider.update(item.provider.id, (provider) => { - provider.settings = { - ...provider.settings, - resourceName, - ...(typeof provider.settings?.baseURL === "string" - ? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) } - : {}), - } - }) + if (resourceName) + evt.provider.update(item.provider.id, (provider) => { + provider.settings = { + ...provider.settings, + resourceName, + ...(typeof provider.settings?.baseURL === "string" + ? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) } + : {}), + } + }) for (const model of item.models.values()) { evt.model.update(item.provider.id, model.id, (draft) => { - if (typeof draft.settings?.baseURL !== "string") return - draft.settings.baseURL = expandResourceName( - draft.settings.baseURL, - resolveResourceName(draft.settings, resourceName) ?? resourceName, - ) + if (resourceName && typeof draft.settings?.baseURL === "string") + draft.settings.baseURL = expandResourceName( + draft.settings.baseURL, + resolveResourceName(draft.settings, resourceName) ?? resourceName, + ) + if (responsesWebSocketCapable(item.provider, draft)) draft.capabilities.responsesWebsockets = true }) } } @@ -107,3 +109,12 @@ function expandResourceName(baseURL: string, resourceName: string) { .replaceAll("${AZURE_RESOURCE_NAME}", resourceName) .replaceAll("${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}", resourceName) } + +function responsesWebSocketCapable(provider: Provider.Info, model: Model.Info) { + if (Provider.packageName(model.package ?? provider.package) !== "@ai-sdk/azure") return false + const settings = Provider.mergeOverlay(provider.settings, model.settings) + if (settings?.useCompletionUrls === true || settings?.useDeploymentBasedUrls === true) return false + if (settings?.apiVersion !== undefined && settings.apiVersion !== "v1") return false + if (typeof settings?.baseURL !== "string") return true + return /^https:\/\/[^/]+\.openai\.azure\.com(?:\/|$)/i.test(settings.baseURL) +} diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index ce8718e6c8..48371b781e 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -242,6 +242,53 @@ describe("AzurePlugin", () => { ), ) + it.effect("marks only Azure v1 Responses deployments as WebSocket capable", () => + withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const models = { + responses: Model.ID.make("responses"), + chat: Model.ID.make("chat"), + preview: Model.ID.make("preview"), + deploymentURL: Model.ID.make("deployment-url"), + gateway: Model.ID.make("gateway"), + nonAzure: Model.ID.make("non-azure"), + } + yield* catalog.transform((draft) => { + draft.provider.update(Provider.ID.azure, (provider) => { + provider.package = Provider.aisdk("@ai-sdk/azure") + }) + draft.model.update(Provider.ID.azure, models.responses, () => {}) + draft.model.update(Provider.ID.azure, models.chat, (model) => { + model.settings = { useCompletionUrls: true } + }) + draft.model.update(Provider.ID.azure, models.preview, (model) => { + model.settings = { apiVersion: "2025-04-01-preview" } + }) + draft.model.update(Provider.ID.azure, models.deploymentURL, (model) => { + model.settings = { useDeploymentBasedUrls: true } + }) + draft.model.update(Provider.ID.azure, models.gateway, (model) => { + model.settings = { baseURL: "https://gateway.example/azure" } + }) + draft.model.update(Provider.ID.azure, models.nonAzure, (model) => { + model.package = Provider.aisdk("@ai-sdk/anthropic") + }) + }) + + yield* addPlugin() + + expect( + required(yield* catalog.model.get(Provider.ID.azure, models.responses)).capabilities.responsesWebsockets, + ).toBe(true) + for (const modelID of [models.chat, models.preview, models.deploymentURL, models.gateway, models.nonAzure]) + expect( + required(yield* catalog.model.get(Provider.ID.azure, modelID)).capabilities.responsesWebsockets, + ).toBeUndefined() + }), + ), + ) + it.effect("rejects missing resourceName when baseURL is not configured", () => withEnv({ AZURE_RESOURCE_NAME: undefined }, () => Effect.gen(function* () { diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index d44ec1cb3d..2e96b3f8a0 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -205,7 +205,7 @@ describe("OpenAIPlugin", () => { }), ) - it.effect("selects WebSocket from deployment capability with built-in provider hooks enabled", () => + it.effect("selects Azure WebSocket from capability and the Azure flag only", () => Effect.gen(function* () { const credentials = yield* Credential.Service yield* credentials.create({ @@ -224,7 +224,7 @@ describe("OpenAIPlugin", () => { const agentID = Agent.ID.make("build") const route = OpenAIResponses.route.with({ id: "deployment-responses", - provider: Provider.ID.make("deployment"), + provider: Provider.ID.azure, }) const model = SessionRunnerModel.resolved(route.model({ id: "gpt-5.5" }), { capabilities: { tools: true, input: ["text"], output: ["text"], responsesWebsockets: true }, @@ -258,7 +258,7 @@ describe("OpenAIPlugin", () => { const prepared = yield* program.pipe( Effect.provide( ConfigProvider.layer( - ConfigProvider.fromEnv({ env: { OPENCODE_EXPERIMENTAL_DEPLOYMENT_RESPONSES_WEBSOCKET: "true" } }), + ConfigProvider.fromEnv({ env: { OPENCODE_EXPERIMENTAL_AZURE_RESPONSES_WEBSOCKET: "true" } }), ), ), )