diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index ceb07611952..40cdeb01516 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -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,22 @@ 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. Azure +// allows only letters, digits, and hyphens in it. +// https://learn.microsoft.com/azure/ai-services/cognitive-services-custom-subdomains +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 +32,443 @@ 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() - 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() + // A resource keeps its Azure Resource Manager ID until it is deleted, so discovery looks it up once. + const resourceIDs = new Map() + const loading = Semaphore.makeUnsafe(1) + const discovery = yield* FiberHandle.make() + const loaded: { + resource?: string + url?: string + deployments?: readonly Deployment[] + connection?: Effect.Success> + } = {} + + 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. + // https://learn.microsoft.com/rest/api/azureresourcegraph/resourcegraph/resources/resources + 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. + // https://learn.microsoft.com/rest/api/aiservices/accountmanagement/deployments/list + 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: + // Azure Resource Manager accepts Entra ID tokens, never resource keys. + // https://learn.microsoft.com/rest/api/aiservices/accountmanagement/deployments/list + // The resource's own inventory serves API keys and identities without Azure Resource Manager read access. Only + // data-plane version 2022-12-01 has it; later versions dropped `/deployments` and keep `/models`, which lists + // models the resource can deploy rather than its deployments. + // https://github.com/Azure/azure-rest-api-specs/blob/main/specification/cognitiveservices/data-plane/OpenAIAuthoring/stable/2022-12-01/azureopenai.json + 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 Map( + Array.from((yield* providers.snapshot()).records.get(Provider.ID.azure)?.models.keys() ?? [], (id) => [ + id.toLowerCase(), + id, + ]), + ) + const unmatched = found.filter((deployment) => !catalogModel(catalog, deployment)) + 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> | undefined, fallback?: string) { const configured = settings?.resourceName @@ -239,6 +482,60 @@ 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. + // https://learn.microsoft.com/azure/ai-services/cognitive-services-custom-subdomains + "| extend resourceName = tostring(properties.customSubDomainName)", + `| where resourceName =~ '${resource}'`, + "| project id", + "| take 1", + ].join(" ") +} + +// A deployment's ID is its name, while limits, costs, and routes come from the catalog model it deploys. Azure compares +// names without case and may return another case, so IDs are lowercase like the catalog's. +// https://learn.microsoft.com/azure/azure-resource-manager/management/resource-name-rules +function deployedModels(deployments: readonly Deployment[], catalog: readonly Model.MutableInfo[]) { + const models = new Map(catalog.map((model) => [model.id.toLowerCase(), model])) + return new Map( + deployments.flatMap((deployment) => { + const model = catalogModel(models, deployment) + if (!model) return [] + const id = Model.ID.make(deployment.name.toLowerCase()) + const info: Model.MutableInfo = { + ...structuredClone(model), + id, + modelID: Model.ID.make(deployment.name), + name: id === model.id.toLowerCase() ? model.name : `${model.name} (${deployment.name})`, + } + return [[id, info] as const] + }), + ) +} + +// Azure spells some models unlike the catalog: a model name plus a separate version, such as `gpt-4` for GPT-4 Turbo, +// `gpt-35-turbo`, or mixed case such as `DeepSeek-V4-Flash`. A deployment named after a catalog model then stands for +// that model, as it did before discovery; any other is left to explicit configuration. +// https://learn.microsoft.com/azure/foundry/openai/concepts/retired-models +function catalogModel(models: ReadonlyMap, deployment: Deployment) { + return models.get(deployment.model.toLowerCase()) ?? models.get(deployment.name.toLowerCase()) +} + function responsesWebSocketCapable(provider: Provider.Info) { if (provider.package !== "@opencode/ai/providers/azure/responses") return false const settings = provider.settings diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index ddb44295c67..2d4815bd4f0 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -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(value: T | undefined): T { @@ -29,6 +37,103 @@ function required(value: T | undefined): T { return value } +function eventually( + effect: Effect.Effect, + predicate: (value: A) => boolean, + remaining = 3000, +): Effect.Effect { + 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( + respond: (request: AzureRequest) => Response | Promise, + fx: (input: { endpoints: typeof offline; requests: AzureRequest[] }) => Effect.Effect, +) { + 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(vars: Record, fx: () => Effect.Effect) { 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,562 @@ 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() + 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("names models after their deployments and keeps catalog models that Azure spells differently", () => + withAzure( + () => + Response.json({ + data: [ + // Azure compares names without case and may return another case than the one created. + { id: "GPT-5-Nano", model: "gpt-5-nano", status: "succeeded" }, + // A deployment named after another model is still the model it deploys. + { id: "gpt-5", model: "gpt-5-mini", status: "succeeded" }, + // Azure's model name differs from the catalog, as with `gpt-4` for GPT-4 Turbo. + { id: "deepseek-v4-flash", model: "DeepSeek-V4-Flash-2026", status: "succeeded" }, + { id: "ft-legal", model: "gpt-4o-mini.ft-123", status: "succeeded" }, + ], + }), + ({ endpoints }) => + Effect.gen(function* () { + yield* seedCatalog + yield* keyCredential + yield* addPlugin(endpoints) + + const deployed = yield* eventually(azureModels, (list) => list.length === 3) + expect(deployed.map((model) => [model.id, model.modelID, model.name, model.limit.context])).toEqual([ + [Model.ID.make("deepseek-v4-flash"), Model.ID.make("deepseek-v4-flash"), "DeepSeek-V4-Flash", 200_000], + [Model.ID.make("gpt-5"), Model.ID.make("gpt-5"), "GPT-5 Mini (gpt-5)", 400_000], + [Model.ID.make("gpt-5-nano"), Model.ID.make("GPT-5-Nano"), "GPT-5 Nano", 300_000], + ]) + }), + ), + ) + + 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() + 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 +964,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 +1133,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() } diff --git a/services/www/src/docs/content/providers.mdx b/services/www/src/docs/content/providers.mdx index 22588741113..c6ea1b20ea1 100644 --- a/services/www/src/docs/content/providers.mdx +++ b/services/www/src/docs/content/providers.mdx @@ -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,32 @@ 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, in lowercase because Azure ignores case in names, for example +`azure/gpt-production`. A deployment named after its model, such as `gpt-5-mini`, keeps the catalog ID. These IDs stay +stable when other deployments are added or removed. + +OpenCode matches a deployment to the catalog by the model it deploys, or by its name when Azure spells the model +differently, as with `gpt-4` for GPT-4 Turbo. A deployment named after another model, such as `gpt-5` deploying +`gpt-5-mini`, shows that model's name, limits, and costs. + +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" {