mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
refactor(core): pin Azure CLI subscriptions and interrupt stale discovery
This commit is contained in:
@@ -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(" ")
|
||||
}
|
||||
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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"
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user