diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index d7fd2a72363..d54a32f9cd7 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -1,11 +1,49 @@ -import { Effect } from "effect" +import { readFile } from "node:fs/promises" +import { homedir } from "node:os" +import { join } from "node:path" +import { Clock, Effect, Schema, Semaphore, Stream } from "effect" +import { ChildProcess } from "effect/unstable/process" import { define } from "@opencode-ai/plugin/effect/plugin" import { Form } from "@opencode-ai/schema/form" +import { AppProcess } from "@opencode-ai/util/process" +import { App } from "../../app.js" +import { Bus } from "../../bus.js" +import { Credential } from "../../credential.js" +import { Integration } from "../../integration.js" import { Model } from "../../model.js" import { Provider } from "../../provider.js" import { iife } from "../../util/iife.js" +import { which } from "../../util/which.js" import { configuredSettings } from "./configured.js" +const cognitiveScope = "https://cognitiveservices.azure.com/.default" +const foundryScope = "https://ai.azure.com/.default" +const methodID = Integration.MethodID.make("azure-cli") +const decodeJSON = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)) +const decodeProfile = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Struct({ subscriptions: Schema.Array(Schema.Unknown) })), +) +const decodeToken = Schema.decodeUnknownEffect( + Schema.Struct({ + accessToken: Schema.NonEmptyString, + expires_on: Schema.optional(Schema.Number), + expiresOn: Schema.optional(Schema.NonEmptyString), + }), +) +const decodeAccounts = Schema.decodeUnknownEffect( + Schema.Array(Schema.Struct({ name: Schema.NonEmptyString, resourceGroup: Schema.NonEmptyString })), +) +const Deployments = Schema.Array( + Schema.Struct({ + name: Schema.NonEmptyString, + properties: Schema.Struct({ + model: Schema.Struct({ name: Schema.NonEmptyString }), + provisioningState: Schema.NonEmptyString, + }), + }), +) +const decodeDeployments = Schema.decodeUnknownEffect(Deployments) + function selectLanguage(sdk: any, modelID: string, useChat: boolean) { if (useChat && sdk.chat) return sdk.chat(modelID) if (sdk.responses) return sdk.responses(modelID) @@ -18,33 +56,185 @@ export const AzurePlugin = define({ id: "opencode.provider.azure", effect: Effect.fn(function* (ctx) { const configured = yield* configuredSettings(Provider.ID.azure) - 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 processes = yield* AppProcess.Service + const bus = yield* Bus.Service + const tokens = new Map() + const loading = Semaphore.makeUnsafe(1) + const loaded: { resource?: string; deployments?: typeof Deployments.Type } = {} + + 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 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")) + // Installing Azure CLI does not mean the user has signed in. Avoid spawning it for unrelated CLI commands. + const signedIn = available + ? yield* Effect.tryPromise(() => + readFile(join(process.env.AZURE_CONFIG_DIR ?? join(homedir(), ".azure"), "azureProfile.json"), "utf8"), + ).pipe( + Effect.flatMap((text) => decodeProfile(text.replace(/^\uFEFF/, ""))), + Effect.map((profile) => profile.subscriptions.length > 0), + Effect.orElseSucceed(() => false), + ) + : false + const accounts = + !resolveResourceName(configured) && + typeof configured?.baseURL !== "string" && + !process.env.AZURE_RESOURCE_GROUP && + signedIn + ? yield* command(["cognitiveservices", "account", "list", "--output", "json", "--only-show-errors"]).pipe( + Effect.flatMap(decodeAccounts), + Effect.orElseSucceed(() => []), + ) + : [] + + const form = (select = false) => + 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, + ...(select && accounts.length > 0 + ? { + options: accounts.map((account) => ({ + value: account.name, + label: account.name, + description: account.resourceGroup, + })), + custom: true, + } + : {}), + }, + ]) + }) + yield* ctx.integration.transform((draft) => { + draft.method.update({ + integrationID: Provider.ID.azure, + method: { type: "key", label: "API key", form: form() }, + }) + if (!available) return draft.method.update({ integrationID: Provider.ID.azure, method: { - type: "key", - label: "API key", - form, + id: methodID, + type: "oauth", + label: "Microsoft Entra ID (Azure CLI)", + form: form(true), }, + 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 + loaded.deployments = yield* discover(resourceName) + yield* ctx.catalog.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 discover = Effect.fn("AzurePlugin.discover")(function* (resource: string) { + return yield* Effect.gen(function* () { + const group = process.env.AZURE_RESOURCE_GROUP + const account = group + ? { name: resource, resourceGroup: group } + : (accounts.length > 0 + ? accounts + : yield* command(["cognitiveservices", "account", "list", "--output", "json", "--only-show-errors"]).pipe( + Effect.flatMap(decodeAccounts), + ) + ).find((item) => item.name.toLowerCase() === resource.toLowerCase()) + if (!account) + return yield* Effect.fail(new Error(`Azure resource "${resource}" was not found in the active subscription`)) + return yield* command([ + "cognitiveservices", + "account", + "deployment", + "list", + "--name", + account.name, + "--resource-group", + account.resourceGroup, + "--output", + "json", + "--only-show-errors", + ]).pipe(Effect.flatMap(decodeDeployments)) + }).pipe( + Effect.catch((error) => + Effect.logWarning("Azure model discovery failed", { + resource, + error: error instanceof Error ? error.message : String(error), + }).pipe(Effect.as(undefined)), + ), + ) + }) + + 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 + loaded.deployments = undefined + return + } + const resource = + typeof credential.metadata?.resourceName === "string" ? credential.metadata.resourceName : undefined + loaded.resource = resource + loaded.deployments = resource ? yield* discover(resource) : undefined + }) + + yield* load() yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { if (item.provider.id !== Provider.ID.azure && Provider.packageName(item.provider.package) !== "@ai-sdk/azure") continue - const resourceName = resolveResourceName(item.provider.settings) + const resourceName = resolveResourceName(item.provider.settings, loaded.resource) if (resourceName) evt.provider.update(item.provider.id, (provider) => { provider.settings = { @@ -55,6 +245,31 @@ export const AzurePlugin = define({ : {}), } }) + if (item.provider.id === Provider.ID.azure && loaded.deployments) { + // Startup batches catalog transforms, so match against the current draft rather than a pre-startup snapshot. + const existing = Array.from(item.models.values()) + const found = new Map() + loaded.deployments.forEach((deployment) => { + if (deployment.properties.provisioningState !== "Succeeded") return + const model = existing.find( + (model) => model.id.toLowerCase() === deployment.properties.model.name.toLowerCase(), + ) + if (!model) return + const id = found.has(model.id) ? Model.ID.make(deployment.name) : model.id + found.set(id, { + ...model, + id, + name: id === model.id ? model.name : `${model.name} (${deployment.name})`, + modelID: Model.ID.make(deployment.name), + }) + }) + for (const id of Array.from(item.models.keys())) { + if (!found.has(Model.ID.make(id))) evt.model.remove(item.provider.id, id) + } + for (const [id, model] of found) { + evt.model.update(item.provider.id, id, (draft) => Object.assign(draft, structuredClone(model))) + } + } for (const model of item.models.values()) { evt.model.update(item.provider.id, model.id, (draft) => { if (resourceName && typeof draft.settings?.baseURL === "string") @@ -67,6 +282,38 @@ export const AzurePlugin = define({ } } }) + + const reload = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.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 }), + ) + + yield* ctx.session.hook( + "http.request", + (evt) => + Effect.gen(function* () { + if (evt.model.providerID !== Provider.ID.azure) return + 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 url = new URL(evt.request.url) + const scope = + url.hostname.endsWith(".services.ai.azure.com") && !url.pathname.startsWith("/models") + ? foundryScope + : cognitiveScope + const current = yield* token(scope).pipe(Effect.orDie) + evt.request.headers.delete("api-key") + evt.request.headers.delete("x-api-key") + evt.request.headers.set("authorization", `Bearer ${current.access}`) + evt.request.headers.set("user-agent", App.useragent(ctx.app)) + }), + { providerID: Provider.ID.azure }, + ) + yield* ctx.aisdk.hook( "sdk", Effect.fn(function* (evt) { diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index bb5bcc3d3a4..86d9f8ed7ae 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -5,6 +5,7 @@ import { Command } from "@opencode-ai/core/command" import { Config } from "@opencode-ai/core/config" import { Credential } from "@opencode-ai/core/credential" import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform" +import { AppProcess } from "@opencode-ai/util/process" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Bus } from "@opencode-ai/core/bus" import { FileSystem } from "@opencode-ai/core/filesystem" @@ -56,6 +57,7 @@ const permissionLayer = Layer.succeed( export const PluginTestLayer = LayerNode.compile( LayerNode.group([ + AppProcess.node, FileSystem.node, FSUtil.node, Location.node, diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index 48371b781e4..ed7a9f81e35 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -1,14 +1,22 @@ +import { chmod } from "node:fs/promises" +import { Agent } from "@opencode-ai/core/agent" import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import type { LanguageModelV3 } from "@ai-sdk/provider" -import { Effect } from "effect" +import { Effect, Schedule } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { Credential } from "@opencode-ai/core/credential" import { Model } from "@opencode-ai/core/model" import { Plugin } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" +import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure" import { Provider } from "@opencode-ai/core/provider" import { Integration } from "@opencode-ai/core/integration" +import { Location } from "@opencode-ai/core/location" +import { Session } from "@opencode-ai/core/session" +import { State } from "@opencode-ai/core/state" +import { AppProcess } from "@opencode-ai/util/process" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" @@ -47,6 +55,70 @@ function withEnv(vars: Record, fx: () => Ef ) } +function withAzureCommands( + run: (args: readonly string[]) => unknown, + fx: () => Effect.Effect, + deploymentDelay = 0, + signedIn = true, +) { + return Effect.gen(function* () { + const processes = yield* AppProcess.Service + const directory = (yield* Location.Service).directory + const executable = `${directory}/${process.platform === "win32" ? "az.cmd" : "az"}` + yield* Effect.promise(() => + Bun.write(executable, process.platform === "win32" ? "@exit /b 0\r\n" : "#!/bin/sh\nexit 0\n"), + ) + yield* Effect.promise(() => chmod(executable, 0o755)) + if (signedIn) { + yield* Effect.promise(() => Bun.write(`${directory}/azure-cli/azureProfile.json`, '{"subscriptions":[{}]}')) + } + const fake = AppProcess.Service.of({ + ...processes, + run: (command) => { + if (command._tag !== "StandardCommand") return processes.run(command) + const value = run(command.args) + if (value instanceof Error) { + return Effect.fail(new AppProcess.AppProcessError({ command: "az", cause: value })) + } + const result = Effect.succeed({ + command: `az ${command.args.join(" ")}`, + exitCode: 0, + stdout: Buffer.from(JSON.stringify(value)), + stderr: Buffer.alloc(0), + stdoutTruncated: false, + stderrTruncated: false, + }) + if (deploymentDelay > 0 && command.args.includes("deployment")) { + return Effect.sleep(`${deploymentDelay} millis`).pipe(Effect.andThen(result)) + } + return result + }, + }) + return yield* withEnv( + { + PATH: `${directory}${process.platform === "win32" ? ";" : ":"}${process.env.PATH}`, + AZURE_CONFIG_DIR: `${directory}/azure-cli`, + }, + () => fx().pipe(Effect.provideService(AppProcess.Service, fake)), + ) + }) +} + +const azureCredential = Effect.gen(function* () { + const credentials = yield* Credential.Service + return yield* credentials.create({ + integrationID: Integration.ID.make("azure"), + value: Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("azure-cli"), + access: "stored-token", + refresh: "azure-cli", + expires: Date.now() + 60 * 60 * 1000, + metadata: { resourceName: "test-resource" }, + }), + }) +}) + function fakeSelectorSdk(calls: string[]) { const make = (method: string) => (id: string) => { calls.push(`${method}:${id}`) @@ -82,6 +154,344 @@ describe("AzurePlugin", () => { ), ) + it.effect("hides Azure CLI authentication when the Azure CLI is not installed", () => + withEnv({ PATH: "/nonexistent" }, () => + Effect.gen(function* () { + yield* addPlugin() + const integration = yield* (yield* Integration.Service).get(Integration.ID.make("azure")) + expect(integration?.methods.some((method) => method.type === "oauth")).toBe(false) + }), + ), + ) + + it.live("registers Azure CLI authentication alongside API keys", () => + withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () => + withAzureCommands( + () => [], + () => + Effect.gen(function* () { + yield* addPlugin() + const integration = yield* (yield* Integration.Service).get(Integration.ID.make("azure")) + expect(integration?.methods).toContainEqual({ + id: Integration.MethodID.make("azure-cli"), + type: "oauth", + label: "Microsoft Entra ID (Azure CLI)", + form: [ + { + type: "string", + key: "resourceName", + title: "Enter Azure Resource Name", + placeholder: "e.g. my-models", + required: true, + }, + ], + }) + }), + ), + ), + ) + + it.live("does not invoke Azure CLI at startup without a cached Azure login", () => { + const commands: string[] = [] + return withEnv( + { + AZURE_RESOURCE_NAME: undefined, + AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined, + AZURE_RESOURCE_GROUP: undefined, + }, + () => + withAzureCommands( + (args) => { + commands.push(args.join(" ")) + return [] + }, + () => + Effect.gen(function* () { + yield* addPlugin() + expect(commands).toEqual([]) + const integration = yield* (yield* Integration.Service).get(Integration.ID.make("azure")) + expect(integration?.methods.some((method) => method.type === "oauth")).toBe(true) + }), + 0, + false, + ), + ) + }) + + it.live("lists Azure CLI resources and keeps manual resource entry available", () => + withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () => + withAzureCommands( + () => [ + { name: "first-resource", resourceGroup: "first-group" }, + { name: "second-resource", resourceGroup: "second-group" }, + ], + () => + Effect.gen(function* () { + yield* addPlugin() + const integration = yield* (yield* Integration.Service).get(Integration.ID.make("azure")) + const method = integration?.methods.find((item) => item.type === "oauth") + expect(method?.form?.[0]).toMatchObject({ + title: "Enter Azure Resource Name", + options: [ + { value: "first-resource", label: "first-resource", description: "first-group" }, + { value: "second-resource", label: "second-resource", description: "second-group" }, + ], + custom: true, + }) + }), + ), + ), + ) + + it.live("connects with the Azure CLI and accepts legacy token expiration", () => + withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () => + withAzureCommands( + (args) => { + if (args.includes("get-access-token")) { + return { + accessToken: "legacy-cli-token", + expiresOn: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + } + } + return [] + }, + () => + Effect.gen(function* () { + yield* addPlugin() + const integrations = yield* Integration.Service + const integrationID = Integration.ID.make("azure") + const attempt = yield* integrations.oauth.connect({ + integrationID, + methodID: Integration.MethodID.make("azure-cli"), + answer: { resourceName: "test-resource" }, + }) + yield* Effect.gen(function* () { + const status = yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID }) + if (status.status !== "complete") return yield* Effect.fail(new Error("Azure CLI authorization pending")) + }).pipe(Effect.retry({ times: 1500, schedule: Schedule.spaced("1 millis") })) + + const credential = (yield* (yield* Credential.Service).list(integrationID))[0]?.value + expect(credential).toMatchObject({ + type: "oauth", + access: "legacy-cli-token", + metadata: { resourceName: "test-resource" }, + }) + }), + ), + ), + ) + + it.live("finishes deployment discovery before Azure authorization completes", () => + withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () => + withAzureCommands( + (args) => { + if (args.includes("get-access-token")) { + return { + accessToken: "azure-token", + expires_on: Math.floor((Date.now() + 60 * 60 * 1000) / 1000), + } + } + if (args.includes("deployment")) { + return [ + { + name: "gpt-production", + properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" }, + }, + ] + } + return [{ name: "test-resource", resourceGroup: "test-group" }] + }, + () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + yield* catalog.transform((draft) => { + draft.provider.update(Provider.ID.azure, (provider) => { + provider.package = Provider.aisdk("@ai-sdk/azure") + }) + draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), () => {}) + draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-nano"), () => {}) + }) + yield* addPlugin() + const integrations = yield* Integration.Service + const integrationID = Integration.ID.make("azure") + const attempt = yield* integrations.oauth.connect({ + integrationID, + methodID: Integration.MethodID.make("azure-cli"), + answer: { resourceName: "test-resource" }, + }) + yield* Effect.gen(function* () { + const status = yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID }) + if (status.status !== "complete") { + return yield* Effect.fail( + new Error( + `Azure CLI authorization ${status.status}${"message" in status ? `: ${status.message}` : ""}`, + ), + ) + } + }).pipe(Effect.retry({ times: 1500, schedule: Schedule.spaced("1 millis") })) + + expect(yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-nano"))).toBeUndefined() + expect((yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-mini")))?.modelID).toBe( + Model.ID.make("gpt-production"), + ) + }), + 50, + ), + ), + ) + + for (const batched of [false, true]) { + it.effect( + `discovers deployments with an existing connection (${batched ? "batched startup" : "ready catalog"})`, + () => + withEnv({ AZURE_RESOURCE_GROUP: undefined }, () => + withAzureCommands( + (args) => { + if (args.includes("deployment")) { + return [ + { + name: "gpt-production", + properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" }, + }, + { + name: "gpt-staging", + properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" }, + }, + { + name: "gpt-pending", + properties: { model: { name: "gpt-5-mini" }, provisioningState: "Creating" }, + }, + ] + } + return [{ name: "test-resource", resourceGroup: "test-group" }] + }, + () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + yield* azureCredential + const startup = Effect.gen(function* () { + yield* catalog.transform((draft) => { + draft.provider.update(Provider.ID.azure, (provider) => { + provider.package = Provider.aisdk("@ai-sdk/azure") + }) + draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), (model) => { + model.name = "GPT-5 Mini" + }) + draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-nano"), () => {}) + }) + yield* addPlugin() + }) + yield* batched ? State.batch(startup) : startup + + expect((yield* catalog.provider.get(Provider.ID.azure))?.settings?.resourceName).toBe("test-resource") + expect((yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-mini")))?.modelID).toBe( + Model.ID.make("gpt-production"), + ) + expect((yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-staging")))?.modelID).toBe( + Model.ID.make("gpt-staging"), + ) + expect(yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-nano"))).toBeUndefined() + }), + ), + ), + ) + } + + it.effect("keeps existing models when Azure deployment discovery is unavailable", () => + withAzureCommands( + () => new Error("management access denied"), + () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + yield* catalog.transform((draft) => { + draft.provider.update(Provider.ID.azure, (provider) => { + provider.package = Provider.aisdk("@ai-sdk/azure") + }) + draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), () => {}) + }) + yield* azureCredential + yield* addPlugin() + + expect(yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-mini"))).toBeDefined() + }), + ), + ) + + it.effect("skips subscription discovery when the resource group is configured", () => + withEnv({ AZURE_RESOURCE_GROUP: "restricted-group" }, () => + withAzureCommands( + (args) => { + expect(args).toContain("deployment") + expect(args).toContain("restricted-group") + return [ + { + name: "gpt-production", + properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" }, + }, + ] + }, + () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + yield* catalog.transform((draft) => { + draft.provider.update(Provider.ID.azure, (provider) => { + provider.package = Provider.aisdk("@ai-sdk/azure") + }) + draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), () => {}) + }) + yield* azureCredential + yield* addPlugin() + expect((yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-mini")))?.modelID).toBe( + Model.ID.make("gpt-production"), + ) + }), + ), + ), + ) + + it.effect("uses the correct bearer token audience for Azure and Foundry requests", () => + withAzureCommands( + (args) => { + if (args.includes("get-access-token")) { + const scope = args[args.indexOf("--scope") + 1] + return { accessToken: `${scope}-token`, expires_on: Math.floor((Date.now() + 60 * 60 * 1000) / 1000) } + } + return [] + }, + () => + Effect.gen(function* () { + yield* azureCredential + yield* addPlugin() + const hooks = yield* PluginHooks.Service + const model = Model.Ref.make({ providerID: Provider.ID.azure, id: Model.ID.make("gpt-5-mini") }) + const azure = yield* hooks.trigger("session", "http.request", { + sessionID: Session.ID.make("ses_azure"), + agent: Agent.ID.make("build"), + model, + request: new Request("https://test-resource.openai.azure.com/openai/v1/responses", { + headers: { "api-key": "stored-token", "x-keep": "yes" }, + }), + }) + expect(azure.request.headers.get("authorization")).toBe( + "Bearer https://cognitiveservices.azure.com/.default-token", + ) + expect(azure.request.headers.has("api-key")).toBe(false) + expect(azure.request.headers.get("x-keep")).toBe("yes") + + const foundry = yield* hooks.trigger("session", "http.request", { + sessionID: Session.ID.make("ses_foundry"), + agent: Agent.ID.make("build"), + model, + request: new Request("https://test-resource.services.ai.azure.com/anthropic/v1/messages", { + headers: { "x-api-key": "stored-token" }, + }), + }) + expect(foundry.request.headers.get("authorization")).toBe("Bearer https://ai.azure.com/.default-token") + expect(foundry.request.headers.has("x-api-key")).toBe(false) + }), + ), + ) + it.effect("resolves resourceName from env", () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => Effect.gen(function* () { diff --git a/packages/www/src/docs/content/providers.mdx b/packages/www/src/docs/content/providers.mdx index d61610a7e77..21e3bda32c9 100644 --- a/packages/www/src/docs/content/providers.mdx +++ b/packages/www/src/docs/content/providers.mdx @@ -38,6 +38,70 @@ The `providers` object is keyed by provider ID. Each provider accepts these fiel | `body` | JSON fields merged into request bodies. | | `models` | Models to add or override, keyed by catalog model ID. | +## Azure OpenAI and Microsoft Foundry + +Azure supports either an API key or your existing Microsoft Entra ID session from the Azure CLI. + +1. Install the [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) and sign in: + + ```bash + az login + ``` + + If the resource belongs to another tenant or subscription, select them first: + + ```bash + az login --tenant TENANT_ID + az account set --subscription NAME_OR_ID + ``` + +2. Find your Azure resource name in the [Azure portal](https://portal.azure.com/) or + [Microsoft Foundry](https://ai.azure.com/): open the Azure OpenAI or Foundry resource and copy its **Resource name**. + It is also the first part of the endpoint: `my-models` in `https://my-models.openai.azure.com/` or + `https://my-models.services.ai.azure.com/`. + + If your identity can list resources, the Azure CLI can display the names and resource groups: + + ```bash + az cognitiveservices account list \ + --query "[].{name:name,resourceGroup:resourceGroup}" \ + --output table + ``` + +3. In OpenCode, run `/connect`, select **Azure**, and choose **Microsoft Entra ID (Azure CLI)**. OpenCode lists the Azure + resources visible to your active CLI session, including their resource groups. Select one, or choose **Type your own + answer** to enter a resource name that is not listed. If resource listing is unavailable, OpenCode asks for the name + directly. If `AZURE_RESOURCE_NAME` is already set, OpenCode uses it without prompting. + +4. Select a deployed model with `/models`. + +OpenCode discovers successful model deployments from the active Azure subscription and refreshes access tokens through your +Azure CLI session. Set `AZURE_RESOURCE_GROUP` when you already know the resource group and want to skip subscription-wide +resource discovery. + +Listing deployments requires Azure management permissions, which are separate from inference permissions. If your identity +cannot list deployments, the Azure model catalog remains available. Select a model whose name matches the deployment, or +configure the deployment name explicitly: + +```jsonc title="opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "providers": { + "azure": { + "models": { + "gpt-5-mini": { + "modelID": "gpt-production", + }, + }, + }, + }, +} +``` + +Your identity needs the **Cognitive Services OpenAI User** role for Azure OpenAI models or the **Cognitive Services User** +role for other Foundry models. If a request fails because the token belongs to another tenant, sign in again with +`az login --tenant TENANT_ID`. + ## Endpoint Override `settings.baseURL` to send an existing provider through a proxy or compatible endpoint. Its existing package,