diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index c65c7b8816d..21712086ed9 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -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> } = {} - 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(" ") } diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index a37f319ebf3..ec58fd006ed 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -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) => { diff --git a/services/www/src/docs/content/providers.mdx b/services/www/src/docs/content/providers.mdx index af99520fa60..90e55eec4f4 100644 --- a/services/www/src/docs/content/providers.mdx +++ b/services/www/src/docs/content/providers.mdx @@ -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.