refactor(core): pin Azure CLI subscriptions and interrupt stale discovery

This commit is contained in:
Filip Hejmowski
2026-09-28 17:18:22 +02:00
parent 526d6ee16b
commit 478e12d2dc
3 changed files with 365 additions and 166 deletions
+167 -120
View File
@@ -1,4 +1,4 @@
import { Clock, Effect, Option, Schedule, Schema, Semaphore, Stream } from "effect"
import { Clock, Effect, FiberHandle, Option, Schedule, 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"
@@ -20,6 +20,8 @@ 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")
// The Azure CLI method keeps its subscription in `refresh`, because credential metadata reaches provider settings.
const refreshPrefix = "azure-cli:"
// A resource name becomes a hostname label and a query literal, so anything else never leaves the process.
const resourcePattern = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/
const decodeJSON = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))
@@ -30,6 +32,7 @@ const decodeToken = Schema.decodeUnknownEffect(
expiresOn: Schema.optional(Schema.NonEmptyString),
}),
)
const decodeSubscription = Schema.decodeUnknownEffect(Schema.Struct({ id: Schema.NonEmptyString }))
const ResourceDeployments = Schema.Struct({ data: Schema.Array(Schema.Unknown) })
const decodeResourceDeployment = Schema.decodeUnknownOption(
Schema.Struct({ id: Schema.NonEmptyString, model: Schema.NonEmptyString, status: Schema.String }),
@@ -47,15 +50,8 @@ const decodeManagementDeployment = Schema.decodeUnknownOption(
}),
}),
)
const AccountQuery = Schema.Struct({
query: Schema.String,
options: Schema.optional(Schema.Struct({ $skipToken: Schema.String })),
})
const Accounts = Schema.Struct({
data: Schema.Array(Schema.Unknown),
$skipToken: Schema.optional(Schema.NonEmptyString),
})
const decodeAccount = Schema.decodeUnknownOption(Schema.Struct({ id: Schema.NonEmptyString }))
const ResourceQuery = Schema.Struct({ query: Schema.String })
const Resources = Schema.Struct({ data: Schema.Array(Schema.Struct({ id: Schema.NonEmptyString })) })
type Deployment = { readonly name: string; readonly model: string }
@@ -75,7 +71,10 @@ export function make(
const providers = yield* Provider.Service
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
const tokens = new Map<string, { access: string; expires: number }>()
// A resource keeps its Azure Resource Manager ID until it is deleted, so discovery looks it up once.
const resourceIDs = new Map<string, string>()
const loading = Semaphore.makeUnsafe(1)
const discovery = yield* FiberHandle.make<void, never>()
const loaded: {
resource?: string
url?: string
@@ -91,23 +90,30 @@ export function make(
Effect.flatMap((result) => decodeJSON(result.stdout.toString("utf8"))),
)
const token = Effect.fn("AzurePlugin.token")(function* (scope: string) {
const token = Effect.fn("AzurePlugin.token")(function* (scope: string, subscription: string | undefined) {
const now = yield* Clock.currentTimeMillis
const cached = tokens.get(scope)
const key = `${subscription ?? ""} ${scope}`
const cached = tokens.get(key)
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 result = yield* command([
"account",
"get-access-token",
"--scope",
scope,
...(subscription === undefined ? [] : ["--subscription", subscription]),
"--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)
tokens.set(key, refreshed)
return refreshed
})
const management = (request: HttpClientRequest.HttpClientRequest) =>
token(managementScope).pipe(
const management = (request: HttpClientRequest.HttpClientRequest, subscription: string | undefined) =>
token(managementScope, subscription).pipe(
Effect.flatMap((current) =>
http.execute(
request.pipe(
@@ -119,30 +125,6 @@ export function make(
),
)
// Resource Graph spans every subscription of the Azure CLI session, unlike a per-subscription account list.
const accounts = Effect.fn("AzurePlugin.accounts")(function* (resource: string) {
return yield* Stream.paginate(undefined, (skipToken: string | undefined) =>
HttpClientRequest.post(
`${endpoints.management}/providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01`,
).pipe(
HttpClientRequest.schemaBodyJson(AccountQuery)({
query: accountQuery(resource),
...(skipToken === undefined ? {} : { options: { $skipToken: skipToken } }),
}),
Effect.flatMap(management),
Effect.flatMap(HttpClientResponse.schemaBodyJson(Accounts)),
Effect.timeout("10 seconds"),
Effect.map(
(response) =>
[
response.data.flatMap((item) => Option.toArray(decodeAccount(item))),
Option.fromNullishOr(response.$skipToken),
] as const,
),
),
).pipe(Stream.runCollect)
})
const available = Boolean(which("az"))
const form = () =>
iife(() => {
@@ -176,25 +158,30 @@ export function make(
Effect.succeed({
mode: "auto" as const,
url: "",
instructions: "Sign in with `az login` before continuing.",
instructions:
"Sign in with `az login` before continuing. The connection keeps the Azure CLI subscription selected now.",
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)
// Pinning the account lets several connections coexist and survive a later `az account set`.
const subscription = yield* command(["account", "show", "--output", "json"]).pipe(
Effect.flatMap(decodeSubscription),
)
const current = yield* token(cognitiveScope, subscription.id)
return Credential.OAuth.make({
type: "oauth",
methodID,
access: current.access,
refresh: "azure-cli",
refresh: `${refreshPrefix}${subscription.id}`,
expires: current.expires,
metadata: { resourceName },
})
}),
}),
refresh: (credential) =>
token(cognitiveScope).pipe(
token(cognitiveScope, credentialSubscription(credential)).pipe(
Effect.map((current) =>
Credential.OAuth.make({ ...credential, access: current.access, expires: current.expires }),
),
@@ -211,18 +198,41 @@ export function make(
return { connection, resource: credentialResource(stored?.value) }
})
const managementDeployments = Effect.fn("AzurePlugin.managementDeployments")(function* (resource: string) {
const account = (yield* accounts(resource))[0]
if (!account) return yield* Effect.fail(new Error(`Azure resource "${resource}" was not found`))
return yield* Stream.paginate(
`${endpoints.management}${account.id}/deployments?api-version=2024-10-01`,
(url) =>
management(HttpClientRequest.get(url)).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(ManagementDeployments)),
Effect.timeout("10 seconds"),
Effect.map(
(response) =>
[
// Resource Graph searches every subscription the Azure CLI account can read, not only the selected one.
const findResource = Effect.fn("AzurePlugin.findResource")(function* (
resource: string,
subscription: string | undefined,
) {
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((request) => management(request, subscription)),
Effect.flatMap(HttpClientResponse.schemaBodyJson(Resources)),
Effect.timeout("10 seconds"),
)
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,
subscription: string | undefined,
) {
const key = `${subscription ?? ""} ${resource.toLowerCase()}`
const id = resourceIDs.get(key) ?? (yield* findResource(resource, subscription))
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), subscription).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(ManagementDeployments)),
Effect.timeout("10 seconds"),
Effect.flatMap((response) =>
// Every page carries the management token, so a page link must stay on the management endpoint.
response.nextLink !== undefined && URL.parse(response.nextLink)?.origin !== origin
? Effect.fail(new Error("Azure returned a deployment page outside the management endpoint"))
: Effect.succeed([
response.value.flatMap((raw): Deployment[] => {
const item = Option.getOrUndefined(decodeManagementDeployment(raw))
return item?.properties.provisioningState === "Succeeded"
@@ -230,15 +240,18 @@ export function make(
: []
}),
Option.fromNullishOr(response.nextLink),
] as const,
),
] as const),
),
).pipe(Stream.runCollect)
),
).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 deployments = Effect.fn("AzurePlugin.deployments")(function* (
const resourceDeployments = Effect.fn("AzurePlugin.resourceDeployments")(function* (
url: string,
resource: string,
credential: Credential.Value,
) {
return yield* http
@@ -260,67 +273,94 @@ export function make(
return item?.status === "succeeded" ? [{ name: item.id, model: item.model }] : []
}),
),
// Azure documents the management API as the deployment inventory, but only an Azure CLI session can
// reach it. The resource's own inventory comes first because it also answers to an API key.
Effect.catch((cause) =>
credential.type === "oauth" ? managementDeployments(resource) : Effect.fail(cause),
),
)
})
const sync = () =>
// Azure documents the management API as the deployment inventory, but only an Azure CLI session can reach it.
// The resource's legacy inventory serves API keys and identities without Azure Resource Manager read access.
const deployments = (url: string, resource: string, credential: Credential.Value) =>
credential.type === "oauth"
? managementDeployments(resource, credentialSubscription(credential)).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
) {
Object.assign(loaded, current, { url: undefined, deployments: undefined })
yield* ctx.provider.reload()
}
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 =
current.connection &&
name !== undefined &&
resourcePattern.test(name) &&
typeof settings?.baseURL !== "string"
? `${endpoints.resource(name)}/deployments?api-version=2022-12-01`
: undefined
// 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 (!current.connection || !name || !url) return
const credential = yield* ctx.integration.connection
.resolve(current.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 (
IntegrationConnection.key(current.connection) !==
IntegrationConnection.key(yield* ctx.integration.connection.active(Provider.ID.azure))
IntegrationConnection.key(current.connection) === IntegrationConnection.key(loaded.connection) &&
current.resource === loaded.resource
)
return
if (JSON.stringify(found) === JSON.stringify(loaded.deployments)) return
loaded.deployments = found
yield* ctx.model.reload()
Object.assign(loaded, current, { url: undefined, deployments: undefined })
yield* ctx.provider.reload()
}),
)
const discover = Effect.fn("AzurePlugin.discover")(function* () {
const connection = loaded.connection
const settings = (yield* providers.get(Provider.ID.azure))?.settings
const name = loaded.resource ?? resolveResourceName(settings)
// A custom endpoint may expose other deployments than the resource does, so it keeps the catalog.
const url =
connection && name !== undefined && resourcePattern.test(name) && typeof settings?.baseURL !== "string"
? `${endpoints.resource(name)}/deployments?api-version=2022-12-01`
: undefined
if (loaded.connection !== connection) return
// Keep the last inventory through transient failures only for the same connection and resource.
if (loaded.url !== url) {
loaded.url = url
if (loaded.deployments) {
loaded.deployments = undefined
yield* ctx.model.reload()
}
}
if (!connection || !name || !url) return
const credential = yield* ctx.integration.connection
.resolve(connection)
.pipe(Effect.orElseSucceed(() => undefined))
if (!credential || (credential.type === "oauth" && credential.methodID !== methodID)) return
const found = yield* deployments(url, name, credential).pipe(
// Azure promises no order; normalize it so a reordered response does not rebuild the model list.
Effect.map((list) => list.toSorted((a, b) => a.name.localeCompare(b.name))),
Effect.catch((cause) =>
Effect.logWarning("failed to sync Azure deployments", { cause }).pipe(Effect.as(undefined)),
),
)
if (!found) return
if (
loaded.connection !== connection ||
IntegrationConnection.key(connection) !==
IntegrationConnection.key(yield* ctx.integration.connection.active(Provider.ID.azure))
)
return
if (JSON.stringify(found) === JSON.stringify(loaded.deployments)) return
const catalog = new Set(
Array.from((yield* providers.snapshot()).records.get(Provider.ID.azure)?.models.keys() ?? [], (id) =>
id.toLowerCase(),
),
)
const unmatched = found.filter((deployment) => !catalog.has(deployment.model.toLowerCase()))
if (unmatched.length > 0)
yield* Effect.logWarning("Azure deployments of models outside the catalog need explicit configuration", {
deployments: unmatched.map((deployment) => deployment.name),
})
loaded.deployments = found
yield* ctx.model.reload()
})
const refresh = (options?: { readonly onlyIfMissing: boolean }) =>
rebind().pipe(Effect.andThen(FiberHandle.run(discovery, discover(), options)))
// 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()) {
@@ -329,7 +369,7 @@ export function make(
!item.provider.package.startsWith("@opencode/ai/providers/azure/")
)
continue
const resourceName = resolveResourceName(item.provider.settings, loaded.resource)
const resourceName = resourceFor(item.provider)
const websocket = responsesWebSocketCapable(item.provider)
if (!resourceName && !websocket) continue
evt.update(item.provider.id, (provider) => {
@@ -345,7 +385,7 @@ export function make(
}
const item = evt.get(Provider.ID.azure)
if (!item) return
// Bind resource settings and discovery to their account, so a switch hides them even while sync is busy.
// 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,
@@ -360,7 +400,7 @@ export function make(
!item.provider.package.startsWith("@opencode/ai/providers/azure/")
)
continue
const resourceName = resolveResourceName(item.provider.settings, loaded.resource)
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")
@@ -384,14 +424,15 @@ export function make(
}
})
// 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(sync),
Stream.runForEach(() => refresh()),
Effect.forkScoped({ startImmediately: true }),
)
// Deployments load in the background so startup never waits on Azure; the catalog serves until they arrive,
// and the last inventory is retained through transient failures.
yield* sync().pipe(Effect.repeat(Schedule.spaced("5 minutes")), Effect.forkScoped)
yield* refresh({ onlyIfMissing: true }).pipe(Effect.repeat(Schedule.spaced("5 minutes")), 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.
@@ -406,7 +447,7 @@ export function make(
target.hostname.endsWith(".services.ai.azure.com") && !target.pathname.startsWith("/models")
? foundryScope
: cognitiveScope
const current = yield* token(scope).pipe(Effect.orDie)
const current = yield* token(scope, credentialSubscription(credential)).pipe(Effect.orDie)
return `Bearer ${current.access}`
})
yield* ctx.session.hook(
@@ -466,15 +507,21 @@ function credentialResource(credential: Credential.Value | undefined) {
return typeof resource === "string" && resource.trim() !== "" ? resource : undefined
}
function accountQuery(resource: string) {
// Connections made before subscriptions were pinned store only the method name and follow the Azure CLI default.
function credentialSubscription(credential: { readonly refresh: string }) {
return credential.refresh.startsWith(refreshPrefix) ? credential.refresh.slice(refreshPrefix.length) : undefined
}
function resourceQuery(resource: string) {
return [
"resources",
"| where type =~ 'microsoft.cognitiveservices/accounts' and kind in~ ('AIServices', 'OpenAI')",
// The custom subdomain is the resource name of every endpoint, and Entra ID authentication requires one.
// Subdomains are globally unique, so a name matches at most one resource.
"| extend resourceName = tostring(properties.customSubDomainName)",
`| where resourceName =~ '${resource}'`,
"| project id",
"| order by id asc",
"| take 1",
].join(" ")
}
+186 -39
View File
@@ -206,6 +206,22 @@ const azureCredential = Effect.gen(function* () {
})
})
const pinnedCredential = (subscription: string) =>
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:${subscription}`,
expires: Date.now() + 60 * 60 * 1000,
metadata: { resourceName: "test-resource" },
}),
})
})
const keyCredential = Effect.gen(function* () {
const credentials = yield* Credential.Service
return yield* credentials.create({
@@ -304,6 +320,7 @@ describe("AzurePlugin", () => {
withAzureCommands(
(args) => {
commands.push([...args])
if (args[1] === "show") return { id: "sub-a", tenantId: "tenant-a" }
return {
accessToken: "legacy-cli-token",
expiresOn: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
@@ -325,17 +342,25 @@ describe("AzurePlugin", () => {
}).pipe(Effect.retry({ times: 1500, schedule: Schedule.spaced("1 millis") }))
const credential = (yield* (yield* Credential.Service).list(integrationID))[0]?.value
expect(credential).toMatchObject({
// The subscription stays out of metadata, which the model resolver merges into provider settings.
expect(credential).toEqual({
type: "oauth",
methodID: Integration.MethodID.make("azure-cli"),
access: "legacy-cli-token",
refresh: "azure-cli:sub-a",
expires: expect.any(Number),
metadata: { resourceName: "test-resource" },
})
expect(commands).toEqual([
// Discovery for the new connection may already be minting its management token afterwards.
expect(commands.slice(0, 2)).toEqual([
["account", "show", "--output", "json"],
[
"account",
"get-access-token",
"--scope",
"https://cognitiveservices.azure.com/.default",
"--subscription",
"sub-a",
"--output",
"json",
],
@@ -350,11 +375,18 @@ describe("AzurePlugin", () => {
return withAzureCommands(
(args) => {
commands.push([...args])
return []
return cliTokens(args)
},
() =>
withAzure(
() => Response.json({ data: [{ id: "gpt-5-mini", model: "gpt-5-mini", status: "succeeded" }] }),
(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
@@ -372,16 +404,10 @@ describe("AzurePlugin", () => {
const deployed = yield* eventually(azureModels, (list) => list.length === 1)
expect(deployed.map((model) => model.id)).toEqual([Model.ID.make("gpt-5-mini")])
expect(requests).toEqual([
{
method: "GET",
path: "/openai/deployments?api-version=2022-12-01",
key: null,
authorization: "Bearer stored-token",
body: "",
},
// A connection from before subscription pinning keeps following the Azure CLI default account.
expect(commands).toEqual([
["account", "get-access-token", "--scope", "https://management.azure.com/.default", "--output", "json"],
])
expect(commands).toEqual([])
}),
),
)
@@ -474,7 +500,7 @@ describe("AzurePlugin", () => {
),
)
it.live("lists deployments through the management API when the resource does not", () => {
it.live("lists Azure CLI deployments through the management API of the pinned subscription", () => {
const commands: string[][] = []
const resource = account("test-resource")
return withAzureCommands(
@@ -485,8 +511,6 @@ describe("AzurePlugin", () => {
() =>
withAzure(
(request) => {
if (request.path.startsWith("/openai/deployments"))
return new Response("Resource not found", { status: 404 })
if (request.path.startsWith("/providers/Microsoft.ResourceGraph/resources"))
return Response.json({ data: [resource] })
return Response.json({
@@ -505,7 +529,7 @@ describe("AzurePlugin", () => {
({ endpoints, requests }) =>
Effect.gen(function* () {
yield* seedCatalog
yield* azureCredential
yield* pinnedCredential("sub-b")
yield* addPlugin(endpoints)
const deployed = yield* eventually(azureModels, (list) => list.length === 1)
@@ -513,28 +537,63 @@ describe("AzurePlugin", () => {
[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.slice(1).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",
],
"account",
"get-access-token",
"--scope",
"https://management.azure.com/.default",
"--subscription",
"sub-b",
"--output",
"json",
],
)
expect(requests[1]?.body).toContain("resourceName =~ 'test-resource'")
])
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 }),
@@ -600,7 +659,7 @@ describe("AzurePlugin", () => {
)
})
it.live("hides the previous account's inventory while its refresh is in flight", () => {
it.live("rebinds a switched account without waiting for the previous account's discovery", () => {
const pending = Promise.withResolvers<Response>()
const calls: AzureRequest[] = []
return withAzure(
@@ -641,17 +700,21 @@ describe("AzurePlugin", () => {
configuration: { resourceName: "other-resource" },
}),
})
expect(yield* azureModels).toEqual([])
expect((yield* providers.available()).some((provider) => provider.id === Provider.ID.azure)).toBe(false)
pending.resolve(Response.json({ data: [{ id: "stale", model: "gpt-5-nano", status: "succeeded" }] }))
// 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")])
}),
)
})
@@ -701,8 +764,8 @@ describe("AzurePlugin", () => {
yield* seedCatalog
yield* azureCredential
yield* addPlugin(endpoints)
yield* eventually(Effect.succeed(requests), (list) => list.length === 4)
expect(requests[3]).toMatchObject({
yield* eventually(Effect.succeed(requests), (list) => list.length === 3)
expect(requests[2]).toMatchObject({
path: "/page-2",
authorization: "Bearer https://management.azure.com/.default-token",
})
@@ -752,13 +815,49 @@ describe("AzurePlugin", () => {
{ integrationID: Integration.ID.make("azure"), credentialID: credential.id },
{ global: true },
)
yield* eventually(Effect.succeed(requests), (list) => list.length === 7)
// 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" }] }),
@@ -871,6 +970,54 @@ describe("AzurePlugin", () => {
),
)
it.effect("mints request tokens for the subscription the connection was made with", () => {
const commands: string[][] = []
return withAzureCommands(
(args) => {
commands.push([...args])
return cliTokens(args)
},
() =>
Effect.gen(function* () {
yield* pinnedCredential("sub-b")
yield* addPlugin()
const hooks = yield* PluginHooks.Service
yield* hooks.trigger("session", "http.request", {
sessionID: Session.ID.make("ses_pinned"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.azure, id: Model.ID.make("gpt-5-mini") }),
kind: "primary",
request: new Request("https://test-resource.openai.azure.com/openai/v1/responses"),
})
expect(commands).toContainEqual([
"account",
"get-access-token",
"--scope",
"https://cognitiveservices.azure.com/.default",
"--subscription",
"sub-b",
"--output",
"json",
])
}),
)
})
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* () {
+12 -7
View File
@@ -268,15 +268,19 @@ choose **Microsoft Entra ID (Azure CLI)** when connecting Azure.
az login
```
Enter the resource name when connecting if it is not already supplied by configuration or the environment.
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.
For a resource in another tenant or subscription, select both explicitly.
Each connection keeps the Azure CLI subscription that was selected when it connected, so a later `az account set` does
not move it. To connect an account in another tenant or subscription, select it before connecting.
```bash
az login --tenant TENANT_ID
az account set --subscription NAME_OR_ID
```
Signing in to several accounts with `az login` lets you keep one connection per account and switch between them.
Instead of configuration, `AZURE_RESOURCE_NAME` supplies the resource name to the OpenCode server. The legacy
`AZURE_COGNITIVE_SERVICES_RESOURCE_NAME` variable also works.
@@ -288,19 +292,20 @@ every five minutes, so a new deployment appears without a restart.
- Before the first successful list for a connection, the whole catalog stays available.
- A temporary refresh failure keeps that connection's last complete inventory.
- Switching accounts hides the previous account's inventory until the new connection is loaded.
- Switching accounts never shows the previous account's inventory; the new account starts from the whole catalog.
- Deployments inherit limits, costs, and capabilities from their catalog models.
Deployment names are their model IDs. A name matching its catalog model, ignoring case, keeps the catalog ID;
other names appear as IDs such as `azure/gpt-production`. These IDs stay stable when other deployments are added or
removed.
OpenCode first tries the resource's legacy deployment inventory with an API key or Entra ID. With the Azure CLI it
falls back to the Azure management API, which needs read access to the resource. Discovery reads every page before
publishing the list. A custom `settings.baseURL` keeps the catalog.
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. Explicitly configured models are always kept.
to a deployment yourself. OpenCode logs a warning naming such deployments. Explicitly configured models are always
kept.
```jsonc title="opencode.jsonc"
{