remove azure discovery (#46672)

This commit is contained in:
Filip
2026-09-01 22:13:08 +02:00
committed by GitHub
Unverified
parent e2e82f18e2
commit fd73a85de4
3 changed files with 41 additions and 336 deletions
+3 -118
View File
@@ -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<string, { access: string; expires: number }>()
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<Model.ID, Model.Info>()
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")
+34 -207
View File
@@ -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<A, E, R>(vars: Record<string, string | undefined>, fx: () => Ef
)
}
function withAzureCommands<A, E, R>(
run: (args: readonly string[]) => unknown,
fx: () => Effect.Effect<A, E, R>,
deploymentDelay = 0,
signedIn = true,
) {
function withAzureCommands<A, E, R>(run: (args: readonly string[]) => unknown, fx: () => Effect.Effect<A, E, R>) {
return Effect.gen(function* () {
const processes = yield* AppProcess.Service
const directory = (yield* Location.Service).directory
@@ -68,9 +62,6 @@ function withAzureCommands<A, E, R>(
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<A, E, R>(
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<A, E, R>(
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(
+4 -11
View File
@@ -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"
{