diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index d54a32f9cd7..9eb79918d94 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -1,6 +1,3 @@ -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" @@ -20,9 +17,6 @@ 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, @@ -30,20 +24,6 @@ const decodeToken = Schema.decodeUnknownEffect( 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) @@ -60,7 +40,7 @@ export const AzurePlugin = define({ const bus = yield* Bus.Service const tokens = new Map() const loading = Semaphore.makeUnsafe(1) - const loaded: { resource?: string; deployments?: typeof Deployments.Type } = {} + const loaded: { resource?: string } = {} const command = (args: string[]) => processes @@ -86,28 +66,7 @@ export const AzurePlugin = define({ }) 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) => + const form = () => iife(() => { if (resolveResourceName(configured) || typeof configured?.baseURL === "string") return return Form.Fields.make([ @@ -117,16 +76,6 @@ export const AzurePlugin = define({ 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, - } - : {}), }, ]) }) @@ -143,7 +92,7 @@ export const AzurePlugin = define({ id: methodID, type: "oauth", label: "Microsoft Entra ID (Azure CLI)", - form: form(true), + form: form(), }, authorize: (answer) => Effect.succeed({ @@ -156,7 +105,6 @@ export const AzurePlugin = define({ 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", @@ -177,42 +125,6 @@ export const AzurePlugin = define({ }) }) - 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 @@ -220,13 +132,11 @@ export const AzurePlugin = define({ : 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() @@ -245,31 +155,6 @@ 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") diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index 11314a6c7b7..4a630282754 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -14,7 +14,6 @@ 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 { fakeSelectorSdk } from "../fixture/selector" import { testEffect } from "../lib/effect" @@ -54,12 +53,7 @@ function withEnv(vars: Record, fx: () => Ef ) } -function withAzureCommands( - run: (args: readonly string[]) => unknown, - fx: () => Effect.Effect, - deploymentDelay = 0, - signedIn = true, -) { +function withAzureCommands(run: (args: readonly string[]) => unknown, fx: () => Effect.Effect) { return Effect.gen(function* () { const processes = yield* AppProcess.Service const directory = (yield* Location.Service).directory @@ -68,9 +62,6 @@ function withAzureCommands( 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) => { @@ -79,7 +70,7 @@ function withAzureCommands( if (value instanceof Error) { return Effect.fail(new AppProcess.AppProcessError({ command: "az", cause: value })) } - const result = Effect.succeed({ + return Effect.succeed({ command: `az ${command.args.join(" ")}`, exitCode: 0, stdout: Buffer.from(JSON.stringify(value)), @@ -87,16 +78,11 @@ function withAzureCommands( 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)), ) @@ -177,13 +163,12 @@ describe("AzurePlugin", () => { ), ) - it.live("does not invoke Azure CLI at startup without a cached Azure login", () => { + it.live("does not invoke Azure CLI at startup without an Azure connection", () => { const commands: string[] = [] return withEnv( { AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined, - AZURE_RESOURCE_GROUP: undefined, }, () => withAzureCommands( @@ -198,48 +183,20 @@ describe("AzurePlugin", () => { 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 }, () => + it.live("connects with the Azure CLI and accepts legacy token expiration", () => { + const commands: string[][] = [] + return 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(), - } + commands.push([...args]) + return { + accessToken: "legacy-cli-token", + expiresOn: new Date(Date.now() + 60 * 60 * 1000).toISOString(), } - return [] }, () => Effect.gen(function* () { @@ -262,130 +219,28 @@ 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", + ], + ]) }), ), - ), - ) - - 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"), + it.live("does not invoke Azure CLI at startup with an existing connection", () => { + const commands: string[][] = [] + return withAzureCommands( + (args) => { + commands.push([...args]) + return [] + }, () => Effect.gen(function* () { const catalog = yield* Catalog.Service @@ -394,46 +249,18 @@ describe("AzurePlugin", () => { 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* azureCredential yield* addPlugin() + expect(commands).toEqual([]) + 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"))).toBeDefined() + expect(yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-nano"))).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( diff --git a/packages/www/src/docs/content/providers.mdx b/packages/www/src/docs/content/providers.mdx index 21e3bda32c9..845dd3ad9bd 100644 --- a/packages/www/src/docs/content/providers.mdx +++ b/packages/www/src/docs/content/providers.mdx @@ -68,20 +68,13 @@ Azure supports either an API key or your existing Microsoft Entra ID session fro --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. +3. In OpenCode, run `/connect`, select **Azure**, and choose **Microsoft Entra ID (Azure CLI)**. Enter the Azure resource + name when prompted. 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: +OpenCode does not query Azure management APIs or discover deployments. Select a model whose catalog name matches your +deployment, or configure the deployment name explicitly: ```jsonc title="opencode.jsonc" {