remove acount discoverability

This commit is contained in:
Filip Hejmowski
2026-09-20 16:06:13 +02:00
parent 4d783412e2
commit 526d6ee16b
3 changed files with 13 additions and 171 deletions
+11 -47
View File
@@ -55,13 +55,7 @@ const Accounts = Schema.Struct({
data: Schema.Array(Schema.Unknown),
$skipToken: Schema.optional(Schema.NonEmptyString),
})
const Account = Schema.Struct({
id: Schema.NonEmptyString,
resourceName: Schema.NonEmptyString,
resourceGroup: Schema.String,
location: Schema.String,
})
const decodeAccount = Schema.decodeUnknownOption(Account)
const decodeAccount = Schema.decodeUnknownOption(Schema.Struct({ id: Schema.NonEmptyString }))
type Deployment = { readonly name: string; readonly model: string }
@@ -88,7 +82,6 @@ export function make(
deployments?: readonly Deployment[]
connection?: Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
} = {}
const listed: { accounts: readonly (typeof Account.Type)[] } = { accounts: [] }
const command = (args: string[]) =>
processes
@@ -127,7 +120,7 @@ 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) {
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`,
@@ -150,45 +143,17 @@ export function make(
).pipe(Stream.runCollect)
})
// Runs only while connecting, so listing Azure resources never costs anything at startup.
const detect = Effect.fn("AzurePlugin.detect")(function* () {
const found = yield* accounts()
if (found.length === 1) return found[0].resourceName
listed.accounts = found
yield* ctx.integration.reload()
return yield* Effect.fail(
new Error(
found.length === 0
? "No Azure resources were found for this Azure CLI session. Connect again and enter the resource name."
: `Found ${found.length} Azure resources. Connect again to choose one.`,
),
)
})
const available = Boolean(which("az"))
const form = (cli: boolean) =>
const form = () =>
iife(() => {
if (resolveResourceName(configured) || typeof configured?.baseURL === "string") return
// Only the Azure CLI session can look the resource up, and only until a lookup found several to choose from.
const detectable = cli && listed.accounts.length === 0
return Form.Fields.make([
{
type: "string",
key: "resourceName",
title: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
required: !detectable,
...(detectable ? { description: "Leave empty to use the resource of your Azure CLI session" } : {}),
...(cli && listed.accounts.length > 0
? {
options: listed.accounts.map((account) => ({
value: account.resourceName,
label: account.resourceName,
description: `${account.resourceGroup} · ${account.location}`,
})),
custom: true,
}
: {}),
required: true,
},
])
})
@@ -196,7 +161,7 @@ export function make(
yield* ctx.integration.transform((editor) => {
editor.method.update({
integrationID: Provider.ID.azure,
method: { type: "key", label: "API key", form: form(false) },
method: { type: "key", label: "API key", form: form() },
})
if (!available) return
editor.method.update({
@@ -205,7 +170,7 @@ export function make(
id: methodID,
type: "oauth",
label: "Microsoft Entra ID (Azure CLI)",
form: form(true),
form: form(),
},
authorize: (answer) =>
Effect.succeed({
@@ -215,8 +180,7 @@ export function make(
callback: Effect.gen(function* () {
const resourceName =
(typeof answer.resourceName === "string" ? answer.resourceName.trim() : "") ||
resolveResourceName(configured) ||
(typeof configured?.baseURL === "string" ? undefined : yield* detect())
resolveResourceName(configured)
if (!resourceName) return yield* Effect.fail(new Error("Azure resource name is required"))
const current = yield* token(cognitiveScope)
return Credential.OAuth.make({
@@ -502,15 +466,15 @@ function credentialResource(credential: Credential.Value | undefined) {
return typeof resource === "string" && resource.trim() !== "" ? resource : undefined
}
function accountQuery(resource?: string) {
function accountQuery(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.
"| extend resourceName = tostring(properties.customSubDomainName)",
resource === undefined ? "| where isnotempty(resourceName)" : `| where resourceName =~ '${resource}'`,
"| project id, resourceName, resourceGroup, location",
"| order by resourceName asc",
`| where resourceName =~ '${resource}'`,
"| project id",
"| order by id asc",
].join(" ")
}
@@ -264,9 +264,8 @@ describe("AzurePlugin", () => {
type: "string",
key: "resourceName",
title: "Enter Azure Resource Name",
description: "Leave empty to use the resource of your Azure CLI session",
placeholder: "e.g. my-models",
required: false,
required: true,
},
],
})
@@ -813,125 +812,6 @@ describe("AzurePlugin", () => {
),
)
it.live("uses the only Azure resource of the Azure CLI session while connecting", () => {
const commands: string[][] = []
return withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
withAzureCommands(
(args) => {
commands.push([...args])
return cliTokens(args)
},
() =>
withAzure(
(request) =>
request.method === "POST"
? Response.json({ data: [account("detected-resource")] })
: Response.json({ data: [] }),
({ endpoints, requests }) =>
Effect.gen(function* () {
yield* addPlugin(endpoints)
// Looking the resource up belongs to connecting, never to startup.
expect(commands).toEqual([])
const integrations = yield* Integration.Service
const integrationID = Integration.ID.make("azure")
const attempt = yield* integrations.oauth.connect({
integrationID,
methodID: Integration.MethodID.make("azure-cli"),
})
yield* eventually(
integrations.oauth.status({ integrationID, attemptID: attempt.attemptID }).pipe(Effect.orDie),
(status) => status.status !== "pending",
)
expect((yield* (yield* Credential.Service).list(integrationID))[0]?.value).toMatchObject({
type: "oauth",
access: "https://cognitiveservices.azure.com/.default-token",
metadata: { resourceName: "detected-resource" },
})
expect(commands.map((args) => args[3])).toEqual([
"https://management.azure.com/.default",
"https://cognitiveservices.azure.com/.default",
])
expect(requests[0]?.body).toContain("isnotempty(resourceName)")
}),
),
),
)
})
it.live("offers resources across all Resource Graph pages instead of choosing the first page's only resource", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
withAzureCommands(cliTokens, () =>
withAzure(
(request) =>
Response.json(
request.body.includes('"$skipToken":"next"')
? { data: [account("second-resource")] }
: { data: [account("first-resource")], $skipToken: "next" },
),
({ endpoints, requests }) =>
Effect.gen(function* () {
yield* addPlugin(endpoints)
const integrations = yield* Integration.Service
const integrationID = Integration.ID.make("azure")
const attempt = yield* integrations.oauth.connect({
integrationID,
methodID: Integration.MethodID.make("azure-cli"),
})
const status = yield* eventually(
integrations.oauth.status({ integrationID, attemptID: attempt.attemptID }).pipe(Effect.orDie),
(status) => status.status !== "pending",
)
expect(status).toMatchObject({
status: "failed",
message: "Found 2 Azure resources. Connect again to choose one.",
})
expect(requests).toHaveLength(2)
expect(JSON.parse(requests[1].body)).toEqual({
query: JSON.parse(requests[0].body).query,
options: { $skipToken: "next" },
})
expect(yield* (yield* Credential.Service).list(integrationID)).toEqual([])
const methods = (yield* integrations.get(integrationID))?.methods ?? []
expect(methods.find((method) => method.type === "oauth")?.form).toEqual([
{
type: "string",
key: "resourceName",
title: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
required: true,
options: [
{
value: "first-resource",
label: "first-resource",
description: "rg-first-resource · swedencentral",
},
{
value: "second-resource",
label: "second-resource",
description: "rg-second-resource · swedencentral",
},
],
custom: true,
},
])
// An API key cannot look resources up, so its prompt stays a plain required name.
expect(methods.find((method) => method.type === "key")?.form).toEqual([
{
type: "string",
key: "resourceName",
title: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
required: true,
},
])
}),
),
),
),
)
it.effect("uses the correct bearer token audience for Azure and Foundry requests", () =>
withAzureCommands(
(args) => {
+1 -3
View File
@@ -268,9 +268,7 @@ choose **Microsoft Entra ID (Azure CLI)** when connecting Azure.
az login
```
With this method you can leave the resource name empty. OpenCode then looks up the Azure OpenAI and Foundry resources
of your Azure CLI session while connecting. It uses the resource when there is only one, and otherwise asks you to
connect again and choose from the resources it found. The lookup only runs while connecting, never at startup.
Enter the resource name when connecting if it is not already supplied by configuration or the environment.
For a resource in another tenant or subscription, select both explicitly.