Compare commits

...

6 Commits

Author SHA1 Message Date
Filip Hejmowski 20b0075b70 refactor(core): drop Azure CLI subscription pinning 2026-09-28 17:33:02 +02:00
Filip Hejmowski de060b5b85 refactor(core): drop periodic Azure deployment refresh 2026-09-28 17:22:49 +02:00
Filip Hejmowski 478e12d2dc refactor(core): pin Azure CLI subscriptions and interrupt stale discovery 2026-09-28 17:18:22 +02:00
Filip Hejmowski 526d6ee16b remove acount discoverability 2026-09-20 16:06:13 +02:00
Filip Hejmowski 4d783412e2 refactor(core): remove Azure connection validation 2026-09-20 00:11:14 +02:00
Filip Hejmowski 40894101d9 feat(core): discover and validate Azure resources
Discover Azure resources and deployments in the background with stable model IDs, paginated inventories, and connection-bound snapshots. Validate resource access before saving new API-key or Azure CLI connections, report actionable failures, and preserve active credentials on rejected attempts.
2026-09-19 23:08:44 +02:00
3 changed files with 1154 additions and 215 deletions
+469 -191
View File
@@ -1,4 +1,5 @@
import { Clock, Effect, Schema, Semaphore, Stream } from "effect"
import { Clock, Effect, FiberHandle, Option, Schema, Semaphore, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { ChildProcess } from "effect/unstable/process"
import { define } from "@opencode/plugin/effect/plugin"
import { Form } from "@opencode/schema/form"
@@ -7,14 +8,20 @@ import { App } from "../../app.js"
import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { IntegrationConnection } from "../../integration/connection.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import { iife } from "../../util/iife.js"
import { which } from "../../util/which.js"
import type { PluginInternal } from "../internal.js"
import { configuredSettings } from "./configured.js"
const cognitiveScope = "https://cognitiveservices.azure.com/.default"
const foundryScope = "https://ai.azure.com/.default"
const managementScope = "https://management.azure.com/.default"
const methodID = Integration.MethodID.make("azure-cli")
// A resource name becomes a hostname label and a query literal, so anything else never leaves the process.
const resourcePattern = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/
const decodeJSON = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))
const decodeToken = Schema.decodeUnknownEffect(
Schema.Struct({
@@ -23,209 +30,437 @@ const decodeToken = Schema.decodeUnknownEffect(
expiresOn: Schema.optional(Schema.NonEmptyString),
}),
)
export const AzurePlugin = define({
id: "opencode.provider.azure",
effect: Effect.fn(function* (ctx) {
const configured = yield* configuredSettings(Provider.ID.azure)
const processes = yield* AppProcess.Service
const bus = yield* Bus.Service
const tokens = new Map<string, { access: string; expires: number }>()
const loading = Semaphore.makeUnsafe(1)
const loaded: { resource?: string } = {}
const ResourceDeployments = Schema.Struct({ data: Schema.Array(Schema.Unknown) })
const decodeResourceDeployment = Schema.decodeUnknownOption(
Schema.Struct({ id: Schema.NonEmptyString, model: Schema.NonEmptyString, status: Schema.String }),
)
const ManagementDeployments = Schema.Struct({
value: Schema.Array(Schema.Unknown),
nextLink: Schema.optional(Schema.NonEmptyString),
})
const decodeManagementDeployment = Schema.decodeUnknownOption(
Schema.Struct({
name: Schema.NonEmptyString,
properties: Schema.Struct({
model: Schema.Struct({ name: Schema.NonEmptyString }),
provisioningState: Schema.String,
}),
}),
)
const ResourceQuery = Schema.Struct({ query: Schema.String })
const Resources = Schema.Struct({ data: Schema.Array(Schema.Struct({ id: Schema.NonEmptyString })) })
const command = (args: string[]) =>
processes
.run(ChildProcess.make("az", args, { extendEnv: true, stdin: "ignore" }), { timeout: "10 seconds" })
.pipe(
Effect.flatMap(AppProcess.requireSuccess),
Effect.flatMap((result) => decodeJSON(result.stdout.toString("utf8"))),
type Deployment = { readonly name: string; readonly model: string }
export function make(
endpoints = {
resource: (name: string) => `https://${name}.openai.azure.com/openai`,
management: "https://management.azure.com",
},
) {
return define({
id: "opencode.provider.azure",
effect: Effect.fn(function* (ctx) {
const configured = yield* configuredSettings(Provider.ID.azure)
const processes = yield* AppProcess.Service
const bus = yield* Bus.Service
const credentials = yield* Credential.Service
const providers = yield* Provider.Service
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
const tokens = new Map<string, { access: string; expires: number }>()
// A resource keeps its Azure Resource Manager ID until it is deleted, so discovery looks it up once.
const resourceIDs = new Map<string, string>()
const loading = Semaphore.makeUnsafe(1)
const discovery = yield* FiberHandle.make<void, never>()
const loaded: {
resource?: string
url?: string
deployments?: readonly Deployment[]
connection?: Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
} = {}
const command = (args: string[]) =>
processes
.run(ChildProcess.make("az", args, { extendEnv: true, stdin: "ignore" }), { timeout: "10 seconds" })
.pipe(
Effect.flatMap(AppProcess.requireSuccess),
Effect.flatMap((result) => decodeJSON(result.stdout.toString("utf8"))),
)
const token = Effect.fn("AzurePlugin.token")(function* (scope: string) {
const now = yield* Clock.currentTimeMillis
const cached = tokens.get(scope)
if (cached && cached.expires - now > 5 * 60_000) return cached
const result = yield* command(["account", "get-access-token", "--scope", scope, "--output", "json"]).pipe(
Effect.flatMap(decodeToken),
)
const token = Effect.fn("AzurePlugin.token")(function* (scope: string) {
const now = yield* Clock.currentTimeMillis
const cached = tokens.get(scope)
if (cached && cached.expires - now > 5 * 60_000) return cached
const result = yield* command(["account", "get-access-token", "--scope", scope, "--output", "json"]).pipe(
Effect.flatMap(decodeToken),
)
const expires = result.expires_on !== undefined ? result.expires_on * 1000 : Date.parse(result.expiresOn ?? "")
if (!Number.isFinite(expires))
return yield* Effect.fail(new Error("Azure CLI returned an invalid token expiration"))
const refreshed = { access: result.accessToken, expires }
tokens.set(scope, refreshed)
return refreshed
})
const available = Boolean(which("az"))
const form = () =>
iife(() => {
if (resolveResourceName(configured) || typeof configured?.baseURL === "string") return
return Form.Fields.make([
{
type: "string",
key: "resourceName",
title: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
required: true,
},
])
const expires = result.expires_on !== undefined ? result.expires_on * 1000 : Date.parse(result.expiresOn ?? "")
if (!Number.isFinite(expires))
return yield* Effect.fail(new Error("Azure CLI returned an invalid token expiration"))
const refreshed = { access: result.accessToken, expires }
tokens.set(scope, refreshed)
return refreshed
})
yield* ctx.integration.transform((editor) => {
editor.method.update({
integrationID: Provider.ID.azure,
method: { type: "key", label: "API key", form: form() },
})
if (!available) return
editor.method.update({
integrationID: Provider.ID.azure,
method: {
id: methodID,
type: "oauth",
label: "Microsoft Entra ID (Azure CLI)",
form: form(),
},
authorize: (answer) =>
Effect.succeed({
mode: "auto" as const,
url: "",
instructions: "Sign in with `az login` before continuing.",
callback: Effect.gen(function* () {
const resourceName =
typeof answer.resourceName === "string" ? answer.resourceName : resolveResourceName(configured)
if (!resourceName) return yield* Effect.fail(new Error("Azure resource name is required"))
const current = yield* token(cognitiveScope)
loaded.resource = resourceName
yield* ctx.provider.reload()
return Credential.OAuth.make({
type: "oauth",
methodID,
access: current.access,
refresh: "azure-cli",
expires: current.expires,
metadata: { resourceName },
})
}),
}),
refresh: (credential) =>
token(cognitiveScope).pipe(
Effect.map((current) =>
Credential.OAuth.make({ ...credential, access: current.access, expires: current.expires }),
const management = (request: HttpClientRequest.HttpClientRequest) =>
token(managementScope).pipe(
Effect.flatMap((current) =>
http.execute(
request.pipe(
HttpClientRequest.bearerToken(current.access),
HttpClientRequest.acceptJson,
HttpClientRequest.setHeader("User-Agent", App.useragent(ctx.app)),
),
),
),
})
})
const load = Effect.fn("AzurePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
if (credential?.type !== "oauth" || credential.methodID !== methodID) {
loaded.resource = undefined
return
}
const resource =
typeof credential.metadata?.resourceName === "string" ? credential.metadata.resourceName : undefined
loaded.resource = resource
})
yield* load()
yield* ctx.provider.transform((evt) => {
for (const item of evt.list()) {
if (
item.provider.id !== Provider.ID.azure &&
!item.provider.package.startsWith("@opencode/ai/providers/azure/")
)
continue
const resourceName = resolveResourceName(item.provider.settings, loaded.resource)
const websocket = responsesWebSocketCapable(item.provider)
if (!resourceName && !websocket) continue
evt.update(item.provider.id, (provider) => {
provider.settings = {
...provider.settings,
...(resourceName === undefined ? {} : { resourceName }),
...(websocket ? { transport: provider.settings?.transport ?? "websocket" } : {}),
...(resourceName !== undefined && typeof provider.settings?.baseURL === "string"
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
: {}),
}
const available = Boolean(which("az"))
const form = () =>
iife(() => {
if (resolveResourceName(configured) || typeof configured?.baseURL === "string") return
return Form.Fields.make([
{
type: "string",
key: "resourceName",
title: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
required: true,
},
])
})
}
})
yield* ctx.model.transform((models) => {
for (const item of models.provider.list()) {
if (
item.provider.id !== Provider.ID.azure &&
!item.provider.package.startsWith("@opencode/ai/providers/azure/")
yield* ctx.integration.transform((editor) => {
editor.method.update({
integrationID: Provider.ID.azure,
method: { type: "key", label: "API key", form: form() },
})
if (!available) return
editor.method.update({
integrationID: Provider.ID.azure,
method: {
id: methodID,
type: "oauth",
label: "Microsoft Entra ID (Azure CLI)",
form: form(),
},
authorize: (answer) =>
Effect.succeed({
mode: "auto" as const,
url: "",
instructions: "Sign in with `az login` before continuing.",
callback: Effect.gen(function* () {
const resourceName =
(typeof answer.resourceName === "string" ? answer.resourceName.trim() : "") ||
resolveResourceName(configured)
if (!resourceName) return yield* Effect.fail(new Error("Azure resource name is required"))
const current = yield* token(cognitiveScope)
return Credential.OAuth.make({
type: "oauth",
methodID,
access: current.access,
refresh: "azure-cli",
expires: current.expires,
metadata: { resourceName },
})
}),
}),
refresh: (credential) =>
token(cognitiveScope).pipe(
Effect.map((current) =>
Credential.OAuth.make({ ...credential, access: current.access, expires: current.expires }),
),
),
})
})
const load = Effect.fn("AzurePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
// Startup awaits this, so it reads the stored credential: resolving the connection would refresh an
// expired token through the Azure CLI.
const stored =
connection?.type === "credential" ? yield* credentials.get(Credential.ID.make(connection.id)) : undefined
return { connection, resource: credentialResource(stored?.value) }
})
// Resource Graph searches every subscription the Azure CLI account can read, not only the selected one.
const findResource = Effect.fn("AzurePlugin.findResource")(function* (resource: string) {
const response = yield* HttpClientRequest.post(
`${endpoints.management}/providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01`,
).pipe(
HttpClientRequest.schemaBodyJson(ResourceQuery)({ query: resourceQuery(resource) }),
Effect.flatMap(management),
Effect.flatMap(HttpClientResponse.schemaBodyJson(Resources)),
Effect.timeout("10 seconds"),
)
continue
const resourceName = resolveResourceName(item.provider.settings, loaded.resource)
for (const model of models.list(item.provider.id)) {
models.update(item.provider.id, model.id, (draft) => {
if (resourceName && typeof draft.settings?.baseURL === "string")
draft.settings.baseURL = expandResourceName(
draft.settings.baseURL,
resolveResourceName(draft.settings, resourceName) ?? resourceName,
)
const id = response.data[0]?.id
if (!id) return yield* Effect.fail(new Error(`Azure resource "${resource}" was not found`))
return id
})
const managementDeployments = Effect.fn("AzurePlugin.managementDeployments")(function* (resource: string) {
const key = resource.toLowerCase()
const id = resourceIDs.get(key) ?? (yield* findResource(resource))
resourceIDs.set(key, id)
const origin = new URL(endpoints.management).origin
return yield* Stream.paginate(`${endpoints.management}${id}/deployments?api-version=2024-10-01`, (url) =>
management(HttpClientRequest.get(url)).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(ManagementDeployments)),
Effect.timeout("10 seconds"),
Effect.flatMap((response) =>
// Every page carries the management token, so a page link must stay on the management endpoint.
response.nextLink !== undefined && URL.parse(response.nextLink)?.origin !== origin
? Effect.fail(new Error("Azure returned a deployment page outside the management endpoint"))
: Effect.succeed([
response.value.flatMap((raw): Deployment[] => {
const item = Option.getOrUndefined(decodeManagementDeployment(raw))
return item?.properties.provisioningState === "Succeeded"
? [{ name: item.name, model: item.properties.model.name }]
: []
}),
Option.fromNullishOr(response.nextLink),
] as const),
),
),
).pipe(
Stream.runCollect,
// A moved or recreated resource has a new ID, so the next discovery looks it up again.
Effect.tapError(() => Effect.sync(() => resourceIDs.delete(key))),
)
})
const resourceDeployments = Effect.fn("AzurePlugin.resourceDeployments")(function* (
url: string,
credential: Credential.Value,
) {
return yield* http
.execute(
HttpClientRequest.get(url).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.setHeader("User-Agent", App.useragent(ctx.app)),
credential.type === "key"
? HttpClientRequest.setHeader("api-key", credential.key)
: HttpClientRequest.bearerToken(credential.access),
),
)
.pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(ResourceDeployments)),
Effect.timeout("10 seconds"),
Effect.map((response) =>
response.data.flatMap((raw): Deployment[] => {
const item = Option.getOrUndefined(decodeResourceDeployment(raw))
return item?.status === "succeeded" ? [{ name: item.id, model: item.model }] : []
}),
),
)
})
// Azure documents the management API as the deployment inventory, but only an Azure CLI session can reach it.
// The resource's legacy inventory serves API keys and identities without Azure Resource Manager read access.
const deployments = (url: string, resource: string, credential: Credential.Value) =>
credential.type === "oauth"
? managementDeployments(resource).pipe(
Effect.catch(() => resourceDeployments(url, credential)),
)
: resourceDeployments(url, credential)
// Local and quick, so a switch rebinds the provider before discovery for the new connection calls Azure.
const rebind = () =>
loading.withPermit(
Effect.gen(function* () {
const current = yield* load()
if (
IntegrationConnection.key(current.connection) === IntegrationConnection.key(loaded.connection) &&
current.resource === loaded.resource
)
return
Object.assign(loaded, current, { url: undefined, deployments: undefined })
yield* ctx.provider.reload()
}),
)
const discover = Effect.fn("AzurePlugin.discover")(function* () {
const connection = loaded.connection
const settings = (yield* providers.get(Provider.ID.azure))?.settings
const name = loaded.resource ?? resolveResourceName(settings)
// A custom endpoint may expose other deployments than the resource does, so it keeps the catalog.
const url =
connection && name !== undefined && resourcePattern.test(name) && typeof settings?.baseURL !== "string"
? `${endpoints.resource(name)}/deployments?api-version=2022-12-01`
: undefined
if (loaded.connection !== connection) return
// Keep the last inventory through transient failures only for the same connection and resource.
if (loaded.url !== url) {
loaded.url = url
if (loaded.deployments) {
loaded.deployments = undefined
yield* ctx.model.reload()
}
}
if (!connection || !name || !url) return
const credential = yield* ctx.integration.connection
.resolve(connection)
.pipe(Effect.orElseSucceed(() => undefined))
if (!credential || (credential.type === "oauth" && credential.methodID !== methodID)) return
const found = yield* deployments(url, name, credential).pipe(
// Azure promises no order; normalize it so a reordered response does not rebuild the model list.
Effect.map((list) => list.toSorted((a, b) => a.name.localeCompare(b.name))),
Effect.catch((cause) =>
Effect.logWarning("failed to sync Azure deployments", { cause }).pipe(Effect.as(undefined)),
),
)
if (!found) return
if (
loaded.connection !== connection ||
IntegrationConnection.key(connection) !==
IntegrationConnection.key(yield* ctx.integration.connection.active(Provider.ID.azure))
)
return
if (JSON.stringify(found) === JSON.stringify(loaded.deployments)) return
const catalog = new Set(
Array.from((yield* providers.snapshot()).records.get(Provider.ID.azure)?.models.keys() ?? [], (id) =>
id.toLowerCase(),
),
)
const unmatched = found.filter((deployment) => !catalog.has(deployment.model.toLowerCase()))
if (unmatched.length > 0)
yield* Effect.logWarning("Azure deployments of models outside the catalog need explicit configuration", {
deployments: unmatched.map((deployment) => deployment.name),
})
loaded.deployments = found
yield* ctx.model.reload()
})
const refresh = () => rebind().pipe(Effect.andThen(FiberHandle.run(discovery, discover())))
// The connection's resource wins for Azure itself, matching the runtime merge of credentials over settings.
const resourceFor = (provider: Provider.Info) =>
provider.id === Provider.ID.azure
? (loaded.resource ?? resolveResourceName(provider.settings))
: resolveResourceName(provider.settings, loaded.resource)
Object.assign(loaded, yield* load())
yield* ctx.provider.transform((evt) => {
for (const item of evt.list()) {
if (
item.provider.id !== Provider.ID.azure &&
!item.provider.package.startsWith("@opencode/ai/providers/azure/")
)
continue
const resourceName = resourceFor(item.provider)
const websocket = responsesWebSocketCapable(item.provider)
if (!resourceName && !websocket) continue
evt.update(item.provider.id, (provider) => {
provider.settings = {
...provider.settings,
...(resourceName === undefined ? {} : { resourceName }),
...(websocket ? { transport: provider.settings?.transport ?? "websocket" } : {}),
...(resourceName !== undefined && typeof provider.settings?.baseURL === "string"
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
: {}),
}
})
}
}
})
const item = evt.get(Provider.ID.azure)
if (!item) return
// Bind resource settings and discovery to their account, so a switch hides them until the rebind.
// Keep the full templates here for explicit configuration; the model transform narrows the visible list.
evt.add({
info: item.provider,
models: Array.from(item.models.values()),
sourceConnection: loaded.connection,
})
})
yield* ctx.model.transform((models) => {
for (const item of models.provider.list()) {
if (
item.provider.id !== Provider.ID.azure &&
!item.provider.package.startsWith("@opencode/ai/providers/azure/")
)
continue
const resourceName = resourceFor(item.provider)
for (const model of models.list(item.provider.id)) {
models.update(item.provider.id, model.id, (draft) => {
if (resourceName && typeof draft.settings?.baseURL === "string")
draft.settings.baseURL = expandResourceName(
draft.settings.baseURL,
resolveResourceName(draft.settings, resourceName) ?? resourceName,
)
})
}
}
if (!loaded.deployments) return
// Narrowing here rather than in the provider catalog keeps every catalog model available as the base
// of a model the user configures explicitly; those are applied after this transform.
const catalog = models.list(Provider.ID.azure)
const deployed = deployedModels(loaded.deployments, catalog)
for (const model of catalog) {
if (!deployed.has(model.id)) models.remove(Provider.ID.azure, model.id)
}
for (const [id, model] of deployed) {
models.update(Provider.ID.azure, id, (draft) => Object.assign(draft, model))
}
})
const reload = () => loading.withPermit(load().pipe(Effect.andThen(ctx.provider.reload())))
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("azure")),
Stream.runForEach(reload),
Effect.forkScoped({ startImmediately: true }),
)
// A switch interrupts discovery for the previous connection instead of waiting for its Azure calls.
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("azure")),
Stream.runForEach(() => refresh()),
Effect.forkScoped({ startImmediately: true }),
)
// Deployments load in the background so startup never waits on Azure; the catalog serves until they arrive.
// Later changes load when the connection changes, so a new deployment needs a reconnect or restart.
yield* refresh().pipe(Effect.forkScoped)
// Entra bearer tokens are minted per request from the target URL's scope, so they are injected
// at the transport hooks rather than stored as a credential.
const bearer = Effect.fn("AzurePlugin.bearer")(function* (url: string) {
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
if (credential?.type !== "oauth" || credential.methodID !== methodID) return
const target = new URL(url)
const scope =
target.hostname.endsWith(".services.ai.azure.com") && !target.pathname.startsWith("/models")
? foundryScope
: cognitiveScope
const current = yield* token(scope).pipe(Effect.orDie)
return `Bearer ${current.access}`
})
yield* ctx.session.hook(
"http.request",
(evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.azure) return
const authorization = yield* bearer(evt.request.url)
if (!authorization) return
evt.request.headers.delete("api-key")
evt.request.headers.delete("x-api-key")
evt.request.headers.set("authorization", authorization)
evt.request.headers.set("user-agent", App.useragent(ctx.app))
}),
{ providerID: Provider.ID.azure },
)
yield* ctx.session.hook(
"experimental.ws.handshake",
(evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.azure) return
const authorization = yield* bearer(evt.url)
if (!authorization) return
delete evt.headers["api-key"]
delete evt.headers["x-api-key"]
evt.headers.authorization = authorization
evt.headers["user-agent"] = App.useragent(ctx.app)
}),
{ providerID: Provider.ID.azure },
)
}),
})
// Entra bearer tokens are minted per request from the target URL's scope, so they are injected
// at the transport hooks rather than stored as a credential.
const bearer = Effect.fn("AzurePlugin.bearer")(function* (url: string) {
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
if (credential?.type !== "oauth" || credential.methodID !== methodID) return
const target = new URL(url)
const scope =
target.hostname.endsWith(".services.ai.azure.com") && !target.pathname.startsWith("/models")
? foundryScope
: cognitiveScope
const current = yield* token(scope).pipe(Effect.orDie)
return `Bearer ${current.access}`
})
yield* ctx.session.hook(
"http.request",
(evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.azure) return
const authorization = yield* bearer(evt.request.url)
if (!authorization) return
evt.request.headers.delete("api-key")
evt.request.headers.delete("x-api-key")
evt.request.headers.set("authorization", authorization)
evt.request.headers.set("user-agent", App.useragent(ctx.app))
}),
{ providerID: Provider.ID.azure },
)
yield* ctx.session.hook(
"experimental.ws.handshake",
(evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.azure) return
const authorization = yield* bearer(evt.url)
if (!authorization) return
delete evt.headers["api-key"]
delete evt.headers["x-api-key"]
evt.headers.authorization = authorization
evt.headers["user-agent"] = App.useragent(ctx.app)
}),
{ providerID: Provider.ID.azure },
)
}),
} satisfies PluginInternal.InternalPlugin)
}
export const AzurePlugin = make()
function resolveResourceName(settings: Readonly<Record<string, unknown>> | undefined, fallback?: string) {
const configured = settings?.resourceName
@@ -239,6 +474,49 @@ function expandResourceName(baseURL: string, resourceName: string) {
.replaceAll("${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}", resourceName)
}
// The Azure CLI method stores the resource as credential metadata, the API key method as its form answer.
function credentialResource(credential: Credential.Value | undefined) {
const resource =
credential?.type === "key"
? credential.configuration?.resourceName
: credential?.methodID === methodID
? credential.metadata?.resourceName
: undefined
return typeof resource === "string" && resource.trim() !== "" ? resource : undefined
}
function resourceQuery(resource: string) {
return [
"resources",
"| where type =~ 'microsoft.cognitiveservices/accounts' and kind in~ ('AIServices', 'OpenAI')",
// The custom subdomain is the resource name of every endpoint, and Entra ID authentication requires one.
// Subdomains are globally unique, so a name matches at most one resource.
"| extend resourceName = tostring(properties.customSubDomainName)",
`| where resourceName =~ '${resource}'`,
"| project id",
"| take 1",
].join(" ")
}
// Azure addresses a model by its deployment name, while limits, costs, and routes belong to the catalog model it
// deploys. A deployment of a model the catalog does not know has none of those and is left to explicit configuration.
function deployedModels(deployments: readonly Deployment[], catalog: readonly Model.MutableInfo[]) {
const models = new Map(catalog.map((model) => [model.id.toLowerCase(), model]))
return deployments.reduce((result, deployment) => {
const model = models.get(deployment.model.toLowerCase())
if (!model) return result
// A deployment's ID must not depend on which other deployments happen to exist.
const id = deployment.name.toLowerCase() === model.id.toLowerCase() ? model.id : Model.ID.make(deployment.name)
if (result.has(id)) return result
return result.set(id, {
...structuredClone(model),
id,
modelID: Model.ID.make(deployment.name),
name: id === model.id ? model.name : `${model.name} (${deployment.name})`,
})
}, new Map<Model.ID, Model.MutableInfo>())
}
function responsesWebSocketCapable(provider: Provider.Info) {
if (provider.package !== "@opencode/ai/providers/azure/responses") return false
const settings = provider.settings
+658 -21
View File
@@ -1,27 +1,35 @@
import { chmod } from "node:fs/promises"
import { Agent } from "@opencode/core/agent"
import { describe, expect } from "bun:test"
import { Effect, Schedule } from "effect"
import { Effect, Schedule, Schema } from "effect"
import { Config } from "@opencode/core/config"
import { ConfigProviderPlugin } from "@opencode/core/config/plugin/provider"
import { Bus } from "@opencode/core/bus"
import { Credential } from "@opencode/core/credential"
import { Model } from "@opencode/core/model"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { PluginHooks } from "@opencode/core/plugin/hooks"
import { AzurePlugin } from "@opencode/core/plugin/provider/azure"
import { make } from "@opencode/core/plugin/provider/azure"
import { Provider } from "@opencode/core/provider"
import { Integration } from "@opencode/core/integration"
import { Location } from "@opencode/core/location"
import { Session } from "@opencode/core/session"
import { Document, Info } from "@opencode/schema/config"
import { AppProcess } from "@opencode/util/process"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const decodeConfig = Schema.decodeUnknownSync(Info)
const addPlugin = Effect.fn(function* () {
// Nothing listens here, so tests that are not about deployments never find any.
const offline = { resource: () => "http://127.0.0.1:1/openai", management: "http://127.0.0.1:1" }
const addPlugin = Effect.fn(function* (endpoints = offline) {
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* AzurePlugin.effect(host)
yield* make(endpoints).effect(host)
})
function required<T>(value: T | undefined): T {
@@ -29,6 +37,103 @@ function required<T>(value: T | undefined): T {
return value
}
function eventually<A, R>(
effect: Effect.Effect<A, never, R>,
predicate: (value: A) => boolean,
remaining = 3000,
): Effect.Effect<A, Error, R> {
return Effect.gen(function* () {
const value = yield* effect
if (predicate(value)) return value
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
yield* Effect.promise(() => Bun.sleep(1))
return yield* eventually(effect, predicate, remaining - 1)
})
}
type AzureRequest = {
readonly method: string
readonly path: string
readonly key: string | null
readonly authorization: string | null
readonly body: string
}
function withAzure<A, E, R>(
respond: (request: AzureRequest) => Response | Promise<Response>,
fx: (input: { endpoints: typeof offline; requests: AzureRequest[] }) => Effect.Effect<A, E, R>,
) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const requests: AzureRequest[] = []
const server = Bun.serve({
port: 0,
fetch: async (request) => {
const url = new URL(request.url)
const received = {
method: request.method,
path: url.pathname + url.search,
key: request.headers.get("api-key"),
authorization: request.headers.get("authorization"),
body: await request.text(),
}
requests.push(received)
return respond(received)
},
})
return { requests, server }
}),
({ requests, server }) =>
fx({
requests,
endpoints: { resource: () => `${server.url.origin}/openai`, management: server.url.origin },
}),
({ server }) => Effect.promise(() => server.stop(true)),
)
}
const seedCatalog = Effect.gen(function* () {
const catalog = yield* Provider.Service
yield* catalog.transform((editor) => {
editor.update(Provider.ID.azure, (provider) => {
provider.package = "@opencode/ai/providers/azure/responses"
})
editor.models.update(Provider.ID.azure, Model.ID.make("gpt-5"), () => {})
editor.models.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), (model) => {
model.name = "GPT-5 Mini"
model.limit = { context: 400_000, output: 128_000 }
})
editor.models.update(Provider.ID.azure, Model.ID.make("gpt-5-nano"), (model) => {
model.name = "GPT-5 Nano"
model.limit = { context: 300_000, output: 64_000 }
})
editor.models.update(Provider.ID.azure, Model.ID.make("deepseek-v4-flash"), (model) => {
model.name = "DeepSeek-V4-Flash"
model.package = "@opencode/ai/providers/openai-compatible"
model.settings = { baseURL: "https://${AZURE_RESOURCE_NAME}.services.ai.azure.com/models" }
})
})
})
const azureModels = Effect.gen(function* () {
const models = yield* Model.Service
return (yield* models.all())
.filter((model) => model.providerID === Provider.ID.azure)
.toSorted((a, b) => a.id.localeCompare(b.id))
})
const cliTokens = (args: readonly string[]) => ({
accessToken: `${args[args.indexOf("--scope") + 1]}-token`,
expires_on: Math.floor((Date.now() + 60 * 60 * 1000) / 1000),
})
const account = (name: string) => ({
id: `/subscriptions/sub/resourceGroups/rg-${name}/providers/Microsoft.CognitiveServices/accounts/${name}`,
resourceName: name,
resourceGroup: `rg-${name}`,
location: "swedencentral",
})
function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
@@ -100,6 +205,15 @@ const azureCredential = Effect.gen(function* () {
}),
})
})
const keyCredential = Effect.gen(function* () {
const credentials = yield* Credential.Service
return yield* credentials.create({
integrationID: Integration.ID.make("azure"),
value: Credential.Key.make({ type: "key", key: "secret", configuration: { resourceName: "test-resource" } }),
})
})
describe("AzurePlugin", () => {
it.effect("registers a resource name form when the environment does not provide one", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
@@ -216,15 +330,14 @@ describe("AzurePlugin", () => {
access: "legacy-cli-token",
metadata: { resourceName: "test-resource" },
})
expect(commands).toEqual([
[
"account",
"get-access-token",
"--scope",
"https://cognitiveservices.azure.com/.default",
"--output",
"json",
],
// Discovery for the new connection may already be minting its management token afterwards.
expect(commands[0]).toEqual([
"account",
"get-access-token",
"--scope",
"https://cognitiveservices.azure.com/.default",
"--output",
"json",
])
}),
),
@@ -236,30 +349,532 @@ describe("AzurePlugin", () => {
return withAzureCommands(
(args) => {
commands.push([...args])
return []
return cliTokens(args)
},
() =>
withAzure(
(request) =>
request.method === "POST"
? Response.json({ data: [account("test-resource")] })
: Response.json({
value: [
{ name: "gpt-5-mini", properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" } },
],
}),
({ endpoints, requests }) =>
Effect.gen(function* () {
const catalog = yield* Provider.Service
const models = yield* Model.Service
yield* seedCatalog
yield* azureCredential
yield* addPlugin(endpoints)
// Startup serves the whole catalog; the deployments arrive afterwards without blocking it.
expect(commands).toEqual([])
expect(requests).toEqual([])
expect((yield* catalog.get(Provider.ID.azure))?.settings?.resourceName).toBe("test-resource")
expect(yield* models.get(Provider.ID.azure, Model.ID.make("gpt-5-mini"))).toBeDefined()
expect(yield* models.get(Provider.ID.azure, Model.ID.make("gpt-5-nano"))).toBeDefined()
const deployed = yield* eventually(azureModels, (list) => list.length === 1)
expect(deployed.map((model) => model.id)).toEqual([Model.ID.make("gpt-5-mini")])
expect(commands).toEqual([
["account", "get-access-token", "--scope", "https://management.azure.com/.default", "--output", "json"],
])
}),
),
)
})
it.live("does not refresh an expired Azure CLI token while starting", () => {
const commands: string[][] = []
return withAzureCommands(
(args) => {
commands.push([...args])
return { accessToken: "refreshed-token", expires_on: Math.floor((Date.now() + 60 * 60 * 1000) / 1000) }
},
() =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
yield* credentials.create({
integrationID: Integration.ID.make("azure"),
value: Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("azure-cli"),
access: "expired-token",
refresh: "azure-cli",
expires: Date.now() - 60 * 60 * 1000,
metadata: { resourceName: "test-resource" },
}),
})
const catalog = yield* Provider.Service
const models = yield* Model.Service
yield* catalog.transform((editor) => {
editor.update(Provider.ID.azure, (provider) => {
provider.package = "@opencode/ai/providers/azure/responses"
})
editor.models.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), () => {})
editor.models.update(Provider.ID.azure, Model.ID.make("gpt-5-nano"), () => {})
})
yield* azureCredential
yield* addPlugin()
expect(commands).toEqual([])
expect((yield* catalog.get(Provider.ID.azure))?.settings?.resourceName).toBe("test-resource")
expect(yield* models.get(Provider.ID.azure, Model.ID.make("gpt-5-mini"))).toBeDefined()
expect(yield* models.get(Provider.ID.azure, Model.ID.make("gpt-5-nano"))).toBeDefined()
}),
)
})
it.live("narrows the catalog to the deployments listed with an API key", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
withAzure(
() =>
Response.json({
data: [
{ id: "gpt-5-mini-eu", model: "gpt-5-mini", status: "succeeded" },
{ id: "gpt-5-mini", model: "gpt-5-mini", status: "succeeded" },
{ id: "nano-b", model: "gpt-5-nano", status: "succeeded" },
{ id: "nano-a", model: "gpt-5-nano", status: "succeeded" },
{ id: "DeepSeek-V4-Flash", model: "DeepSeek-V4-Flash", status: "succeeded" },
{ id: "gpt-5-pending", model: "gpt-5", status: "creating" },
{ id: "fine-tuned", model: "gpt-4o-mini.ft-123", status: "succeeded" },
{ id: 42 },
],
}),
({ endpoints, requests }) =>
Effect.gen(function* () {
const catalog = yield* Provider.Service
yield* seedCatalog
yield* keyCredential
yield* addPlugin(endpoints)
const deployed = yield* eventually(azureModels, (list) => list.length === 5)
expect(deployed.map((model) => [model.id, model.modelID, model.name])).toEqual([
[Model.ID.make("deepseek-v4-flash"), Model.ID.make("DeepSeek-V4-Flash"), "DeepSeek-V4-Flash"],
// The deployment named after its model keeps the model ID even though Azure listed another first.
[Model.ID.make("gpt-5-mini"), Model.ID.make("gpt-5-mini"), "GPT-5 Mini"],
[Model.ID.make("gpt-5-mini-eu"), Model.ID.make("gpt-5-mini-eu"), "GPT-5 Mini (gpt-5-mini-eu)"],
// Custom deployment IDs are stable independently of the other deployments of the same model.
[Model.ID.make("nano-a"), Model.ID.make("nano-a"), "GPT-5 Nano (nano-a)"],
[Model.ID.make("nano-b"), Model.ID.make("nano-b"), "GPT-5 Nano (nano-b)"],
])
// A further deployment of a model keeps the catalog facts of that model.
expect(deployed[2]?.limit).toEqual({ context: 400_000, output: 128_000 })
// The resource name entered with the API key reaches catalog endpoints, not only the request route.
expect(deployed[0]?.settings?.baseURL).toBe("https://test-resource.services.ai.azure.com/models")
expect((yield* catalog.get(Provider.ID.azure))?.settings?.resourceName).toBe("test-resource")
expect(requests).toEqual([
{
method: "GET",
path: "/openai/deployments?api-version=2022-12-01",
key: "secret",
authorization: null,
body: "",
},
])
}),
),
),
)
it.live("lists Azure CLI deployments through the management API", () => {
const commands: string[][] = []
const resource = account("test-resource")
return withAzureCommands(
(args) => {
commands.push([...args])
return cliTokens(args)
},
() =>
withAzure(
(request) => {
if (request.path.startsWith("/providers/Microsoft.ResourceGraph/resources"))
return Response.json({ data: [resource] })
return Response.json({
value: [
{
name: "gpt-production",
properties: { model: { format: "OpenAI", name: "gpt-5-mini" }, provisioningState: "Succeeded" },
},
{
name: "gpt-5-nano",
properties: { model: { format: "OpenAI", name: "gpt-5-nano" }, provisioningState: "Failed" },
},
],
})
},
({ endpoints, requests }) =>
Effect.gen(function* () {
yield* seedCatalog
yield* azureCredential
yield* addPlugin(endpoints)
const deployed = yield* eventually(azureModels, (list) => list.length === 1)
expect(deployed.map((model) => [model.id, model.modelID])).toEqual([
[Model.ID.make("gpt-production"), Model.ID.make("gpt-production")],
])
expect(commands).toEqual([
["account", "get-access-token", "--scope", "https://management.azure.com/.default", "--output", "json"],
])
expect(requests.map((request) => [request.method, request.path, request.authorization])).toEqual([
[
"POST",
"/providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01",
"Bearer https://management.azure.com/.default-token",
],
[
"GET",
`${resource.id}/deployments?api-version=2024-10-01`,
"Bearer https://management.azure.com/.default-token",
],
])
expect(requests[0]?.body).toContain("resourceName =~ 'test-resource'")
}),
),
)
})
it.live("falls back to the resource inventory when the Azure CLI cannot read the management API", () =>
withAzureCommands(cliTokens, () =>
withAzure(
(request) =>
request.path.startsWith("/openai/deployments")
? Response.json({ data: [{ id: "gpt-5-mini", model: "gpt-5-mini", status: "succeeded" }] })
: new Response("Forbidden", { status: 403 }),
({ endpoints, requests }) =>
Effect.gen(function* () {
yield* seedCatalog
yield* azureCredential
yield* addPlugin(endpoints)
const deployed = yield* eventually(azureModels, (list) => list.length === 1)
expect(deployed.map((model) => model.id)).toEqual([Model.ID.make("gpt-5-mini")])
expect(requests.map((request) => [request.method, request.path, request.authorization])).toEqual([
[
"POST",
"/providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01",
"Bearer https://management.azure.com/.default-token",
],
["GET", "/openai/deployments?api-version=2022-12-01", "Bearer stored-token"],
])
}),
),
),
)
it.live("keeps the catalog when deployments cannot be listed", () =>
withAzure(
() => new Response("Unavailable", { status: 503 }),
({ endpoints, requests }) =>
Effect.gen(function* () {
yield* seedCatalog
yield* keyCredential
yield* addPlugin(endpoints)
yield* eventually(Effect.succeed(requests), (list) => list.length === 1)
yield* Effect.promise(() => Bun.sleep(25))
expect((yield* azureModels).map((model) => model.id)).toEqual([
Model.ID.make("deepseek-v4-flash"),
Model.ID.make("gpt-5"),
Model.ID.make("gpt-5-mini"),
Model.ID.make("gpt-5-nano"),
])
}),
),
)
it.live("keeps the listed deployments while the Azure CLI cannot refresh its token", () => {
const commands: string[][] = []
return withAzureCommands(
(args) => {
commands.push([...args])
return new Error("az: please run 'az login' to setup account")
},
() =>
withAzure(
() => Response.json({ data: [{ id: "gpt-5-mini", model: "gpt-5-mini", status: "succeeded" }] }),
({ endpoints }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const bus = yield* Bus.Service
yield* seedCatalog
const credential = yield* azureCredential
yield* addPlugin(endpoints)
yield* eventually(azureModels, (list) => list.length === 1)
// A failed refresh of the same account retains its last successful inventory.
yield* credentials.update(credential.id, {
value: Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("azure-cli"),
access: "expired-token",
refresh: "azure-cli",
expires: Date.now() - 60 * 60 * 1000,
metadata: { resourceName: "test-resource" },
}),
})
yield* bus.publish(
Credential.Event.Switched,
{ integrationID: Integration.ID.make("azure"), credentialID: credential.id },
{ global: true },
)
yield* eventually(Effect.succeed(commands), (list) => list.length > 0)
yield* Effect.promise(() => Bun.sleep(25))
expect((yield* azureModels).map((model) => model.id)).toEqual([Model.ID.make("gpt-5-mini")])
}),
),
)
})
it.live("rebinds a switched account without waiting for the previous account's discovery", () => {
const pending = Promise.withResolvers<Response>()
const calls: AzureRequest[] = []
return withAzure(
(request) => {
calls.push(request)
if (calls.length === 2) return pending.promise
return Response.json({
data: [
{
id: request.key === "secret" ? "production-a" : "production-b",
model: "gpt-5-mini",
status: "succeeded",
},
],
})
},
({ endpoints }) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const credentials = yield* Credential.Service
const providers = yield* Provider.Service
yield* seedCatalog
const first = yield* keyCredential
yield* addPlugin(endpoints)
const previous = yield* eventually(azureModels, (list) => list.some((model) => model.id === "production-a"))
yield* bus.publish(
Credential.Event.Switched,
{ integrationID: Integration.ID.make("azure"), credentialID: first.id },
{ global: true },
)
yield* eventually(Effect.succeed(calls), (list) => list.length === 2)
const next = yield* credentials.create({
integrationID: Integration.ID.make("azure"),
value: Credential.Key.make({
type: "key",
key: "other-key",
configuration: { resourceName: "other-resource" },
}),
})
// The previous account's discovery is still waiting on Azure, yet the new account is already listed.
const deployed = yield* eventually(azureModels, (list) => list.length === 1 && list[0]?.id === "production-b")
expect(deployed[0]?.settings?.resourceName).toBe("other-resource")
expect((yield* providers.available()).some((provider) => provider.id === Provider.ID.azure)).toBe(true)
expect((yield* providers.snapshot()).records.get(Provider.ID.azure)?.sourceConnection).toMatchObject({
type: "credential",
id: next.id,
})
expect(previous.map((model) => model.id)).toEqual([Model.ID.make("production-a")])
// The interrupted discovery never publishes the previous account's late answer.
pending.resolve(Response.json({ data: [{ id: "stale", model: "gpt-5-nano", status: "succeeded" }] }))
yield* Effect.promise(() => Bun.sleep(25))
expect((yield* azureModels).map((model) => model.id)).toEqual([Model.ID.make("production-b")])
}),
)
})
it.live("keeps deployment IDs when another deployment of the same model is removed", () => {
const inventory = { names: ["nano-a", "nano-b"] }
return withAzure(
() => Response.json({ data: inventory.names.map((id) => ({ id, model: "gpt-5-nano", status: "succeeded" })) }),
({ endpoints }) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
yield* seedCatalog
const credential = yield* keyCredential
yield* addPlugin(endpoints)
const before = yield* eventually(azureModels, (list) => list.length === 2)
expect(before.map((model) => model.id)).toEqual([Model.ID.make("nano-a"), Model.ID.make("nano-b")])
inventory.names = ["nano-b"]
yield* bus.publish(
Credential.Event.Switched,
{ integrationID: Integration.ID.make("azure"), credentialID: credential.id },
{ global: true },
)
const after = yield* eventually(azureModels, (list) => list.length === 1)
expect(after).toEqual([before[1]])
}),
)
})
it.live("loads all management deployment pages before publishing the inventory", () => {
const pending = Promise.withResolvers<Response>()
const state = { url: "" }
return withAzureCommands(cliTokens, () =>
withAzure(
(request) => {
if (request.path.startsWith("/openai/deployments")) return new Response("Not found", { status: 404 })
if (request.method === "POST") return Response.json({ data: [account("test-resource")] })
if (request.path === "/page-2") return pending.promise
return Response.json({
nextLink: `${state.url}/page-2`,
value: [{ name: "mini", properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" } }],
})
},
({ endpoints, requests }) =>
Effect.gen(function* () {
state.url = endpoints.management
yield* seedCatalog
yield* azureCredential
yield* addPlugin(endpoints)
yield* eventually(Effect.succeed(requests), (list) => list.length === 3)
expect(requests[2]).toMatchObject({
path: "/page-2",
authorization: "Bearer https://management.azure.com/.default-token",
})
expect(yield* azureModels).toHaveLength(4)
pending.resolve(
Response.json({
value: [
{ name: "nano", properties: { model: { name: "gpt-5-nano" }, provisioningState: "Succeeded" } },
],
}),
)
const deployed = yield* eventually(azureModels, (list) => list.length === 2)
expect(deployed.map((model) => model.modelID)).toEqual([Model.ID.make("mini"), Model.ID.make("nano")])
}),
),
)
})
it.live("keeps the last complete inventory when a later management page fails", () => {
const state = { fail: false, url: "" }
return withAzureCommands(cliTokens, () =>
withAzure(
(request) => {
if (request.path.startsWith("/openai/deployments")) return new Response("Not found", { status: 404 })
if (request.method === "POST") return Response.json({ data: [account("test-resource")] })
if (request.path === "/page-2") return new Response("Unavailable", { status: 503 })
if (state.fail)
return Response.json({
nextLink: `${state.url}/page-2`,
value: [{ name: "nano", properties: { model: { name: "gpt-5-nano" }, provisioningState: "Succeeded" } }],
})
return Response.json({
value: [{ name: "mini", properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" } }],
})
},
({ endpoints, requests }) =>
Effect.gen(function* () {
state.url = endpoints.management
const bus = yield* Bus.Service
yield* seedCatalog
const credential = yield* azureCredential
yield* addPlugin(endpoints)
yield* eventually(azureModels, (list) => list.length === 1 && list[0]?.id === "mini")
state.fail = true
yield* bus.publish(
Credential.Event.Switched,
{ integrationID: Integration.ID.make("azure"), credentialID: credential.id },
{ global: true },
)
// The cached resource skips Resource Graph; the failed page then falls back to the resource inventory.
yield* eventually(Effect.succeed(requests), (list) => list.length === 5)
expect(requests.map((request) => request.path)).toEqual([
"/providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01",
`${account("test-resource").id}/deployments?api-version=2024-10-01`,
`${account("test-resource").id}/deployments?api-version=2024-10-01`,
"/page-2",
"/openai/deployments?api-version=2022-12-01",
])
yield* Effect.promise(() => Bun.sleep(25))
expect((yield* azureModels).map((model) => model.id)).toEqual([Model.ID.make("mini")])
}),
),
)
})
it.live("does not follow a management page outside the management endpoint", () =>
withAzureCommands(cliTokens, () =>
withAzure(
(request) => {
if (request.path.startsWith("/openai/deployments")) return new Response("Not found", { status: 404 })
if (request.method === "POST") return Response.json({ data: [account("test-resource")] })
return Response.json({
nextLink: "https://attacker.example/page-2",
value: [{ name: "mini", properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" } }],
})
},
({ endpoints, requests }) =>
Effect.gen(function* () {
yield* seedCatalog
yield* azureCredential
yield* addPlugin(endpoints)
yield* eventually(Effect.succeed(requests), (list) => list.length === 3)
yield* Effect.promise(() => Bun.sleep(25))
expect(requests[2]?.path).toBe("/openai/deployments?api-version=2022-12-01")
// A partial inventory is never published in place of the catalog.
expect(yield* azureModels).toHaveLength(4)
}),
),
),
)
it.live("keeps the catalog for a custom endpoint", () =>
withAzure(
() => Response.json({ data: [{ id: "gpt-5-mini", model: "gpt-5-mini", status: "succeeded" }] }),
({ endpoints, requests }) =>
Effect.gen(function* () {
const catalog = yield* Provider.Service
yield* seedCatalog
yield* catalog.transform((editor) => {
editor.update(Provider.ID.azure, (provider) => {
provider.settings = { baseURL: "https://gateway.example/azure" }
})
})
yield* keyCredential
yield* addPlugin(endpoints)
yield* Effect.promise(() => Bun.sleep(50))
expect(requests).toEqual([])
expect(yield* azureModels).toHaveLength(4)
}),
),
)
it.live("leaves a model that is configured explicitly to the configuration", () =>
withAzure(
() => Response.json({ data: [{ id: "gpt-5-mini", model: "gpt-5-mini", status: "succeeded" }] }),
({ endpoints }) =>
Effect.gen(function* () {
yield* seedCatalog
yield* keyCredential
yield* addPlugin(endpoints)
const plugin = yield* Plugin.Service
yield* ConfigProviderPlugin.Plugin.effect(yield* PluginHost.make(plugin)).pipe(
Effect.provide(
Config.testLayer([
new Document({
type: "document",
info: decodeConfig({
providers: { azure: { models: { "gpt-5-nano": { modelID: "nano-production" } } } },
}),
}),
]),
),
)
const deployed = yield* eventually(azureModels, (list) => list.length === 2)
expect(deployed.map((model) => [model.id, model.modelID, model.name, model.limit.context])).toEqual([
[Model.ID.make("gpt-5-mini"), Model.ID.make("gpt-5-mini"), "GPT-5 Mini", 400_000],
[Model.ID.make("gpt-5-nano"), Model.ID.make("nano-production"), "GPT-5 Nano", 300_000],
])
}),
),
)
it.effect("uses the correct bearer token audience for Azure and Foundry requests", () =>
withAzureCommands(
(args) => {
@@ -319,6 +934,21 @@ describe("AzurePlugin", () => {
),
)
it.effect("prefers the connection's resource over configuration, as requests do", () =>
Effect.gen(function* () {
const catalog = yield* Provider.Service
yield* catalog.transform((editor) => {
editor.update(Provider.ID.azure, (provider) => {
provider.package = "@opencode/ai/providers/azure/responses"
provider.settings = { resourceName: "from-config" }
})
})
yield* keyCredential
yield* addPlugin()
expect(required(yield* catalog.get(Provider.ID.azure)).settings?.resourceName).toBe("test-resource")
}),
)
it.effect("resolves resourceName from env", () =>
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () {
@@ -473,7 +1103,14 @@ describe("AzurePlugin", () => {
yield* addPlugin()
expect((yield* catalog.get(Provider.ID.azure))?.settings?.transport).toBe("websocket")
for (const modelID of [models.responses, models.chat, models.preview, models.deploymentURL, models.gateway, models.nonAzure]) {
for (const modelID of [
models.responses,
models.chat,
models.preview,
models.deploymentURL,
models.gateway,
models.nonAzure,
]) {
const model = required(yield* service.get(Provider.ID.azure, modelID))
expect(model.settings?.transport).toBeUndefined()
}
+27 -3
View File
@@ -268,7 +268,11 @@ choose **Microsoft Entra ID (Azure CLI)** when connecting Azure.
az login
```
For a resource in another tenant or subscription, select both explicitly.
Enter the resource name when connecting if it is not already supplied by configuration or the environment. A resource
name saved with a connection takes precedence over configuration; connect again to change it.
Azure CLI connections use the account selected in the Azure CLI. For a resource in another tenant or subscription,
select both explicitly.
```bash
az login --tenant TENANT_ID
@@ -278,8 +282,28 @@ az account set --subscription NAME_OR_ID
Instead of configuration, `AZURE_RESOURCE_NAME` supplies the resource name to the OpenCode server. The legacy
`AZURE_COGNITIVE_SERVICES_RESOURCE_NAME` variable also works.
OpenCode does not query Azure management APIs or discover deployments. If a deployment does not match its catalog
model name, map an OpenCode model ID to the deployment with `modelID`.
### Deployments
Azure serves a model only through a deployment, so OpenCode lists the deployments of your resource and shows those
instead of the whole Azure catalog. The list loads in the background after startup, after connecting, and after
switching accounts. To pick up a deployment added later, restart OpenCode or connect again.
- Until the list loads for a connection, or if it fails, the whole catalog stays available.
- A failed reload for the same connection keeps its last complete inventory.
- Switching accounts never shows the previous account's inventory; the new account starts from the whole catalog.
- Deployments inherit limits, costs, and capabilities from their catalog models.
Deployment names are their model IDs. A name matching its catalog model, ignoring case, keeps the catalog ID;
other names appear as IDs such as `azure/gpt-production`. These IDs stay stable when other deployments are added or
removed.
With the Azure CLI, OpenCode lists deployments through the Azure management API, which needs read access to the
resource. If that fails, and always with an API key, it uses the resource's legacy deployment inventory. Discovery reads
every page before publishing the list. A custom `settings.baseURL` keeps the catalog.
Configure a deployment explicitly when its model is not in the catalog, such as a fine-tuned model, or to map a model ID
to a deployment yourself. OpenCode logs a warning naming such deployments. Explicitly configured models are always
kept.
```jsonc title="opencode.jsonc"
{