feat(ai): enable azure responses websocket (#43896)

This commit is contained in:
Shoubhit Dash
2026-08-21 20:25:11 +05:30
committed by GitHub
parent 2636797c65
commit 8676dcf705
6 changed files with 211 additions and 30 deletions
@@ -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<Record<string, unknown>>
@@ -147,18 +149,19 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
Effect.gen(function* () {
const parts = yield* HttpTransport.jsonRequestParts(input)
const headers = Headers.remove(options.headers?.(parts.headers) ?? parts.headers, "content-length")
const channel = input.webSocket
? yield* Effect.gen(function* () {
const create = yield* message(parts.jsonBody)
const base = driver(options, create.message)
return {
url: yield* WebSocketTransport.toWebSocketUrl(parts.url),
headers,
rotateAfterMs: options.rotateAfterMs,
driver: options.driver?.({ request: create.request, message: create.message, base }) ?? base,
}
})
: undefined
const channel =
input.webSocket && (options.enabled?.(parts.url) ?? true)
? yield* Effect.gen(function* () {
const create = yield* message(parts.jsonBody)
const base = driver(options, create.message)
return {
url: yield* WebSocketTransport.toWebSocketUrl(options.url?.(parts.url) ?? parts.url),
headers,
rotateAfterMs: options.rotateAfterMs,
driver: options.driver?.({ request: create.request, message: create.message, base }) ?? base,
}
})
: undefined
return {
http: {
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
+26
View File
@@ -1,3 +1,4 @@
import { Headers } from "effect/unstable/http"
import { Auth } from "../route/auth.js"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
@@ -10,6 +11,7 @@ import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-opt
export const id = ProviderID.make("azure")
const routeAuth = Auth.remove("authorization")
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 55 * 60 * 1000
// Azure needs the customer's resource URL; supply either `resourceName`
// (helper builds the URL) or `baseURL` directly.
@@ -40,6 +42,30 @@ const responsesRoute = OpenAIResponses.route.with({
id: "azure-openai-responses",
provider: id,
auth: routeAuth,
transport: OpenAIResponses.channelTransport({
id: "azure-openai-responses",
name: "Azure OpenAI Responses",
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
enabled: (value) => {
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({
@@ -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)
+26 -15
View File
@@ -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)
}
@@ -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* () {
@@ -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" } }),
),
),
)