Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 2 additions & 75 deletions packages/core/src/plugin/provider/azure.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import { Clock, Effect, FiberHandle, Option, Schema, Semaphore, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"
import { ChildProcess } from "effect/process"
import path from "node:path"
import { define } from "@opencode/plugin/effect/plugin"
import { Form } from "@opencode/schema/form"
import { FSUtil } from "@opencode/util/fs-util"
import { Global } from "@opencode/util/global"
import { AppProcess } from "@opencode/util/process"
import { App } from "../../app.js"
import { Bus } from "../../bus.js"
Expand All @@ -14,7 +11,6 @@ import { Integration } from "../../integration.js"
import { IntegrationConnection } from "../../integration/connection.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import { iife } from "../../util/iife.js"
import { which } from "../../util/which.js"
import type { PluginInternal } from "../internal.js"
import { configuredSettings } from "./configured.js"
Expand Down Expand Up @@ -52,18 +48,8 @@ const decodeManagementDeployment = Schema.decodeUnknownOption(
}),
}),
)
const ResourceQuery = Schema.Struct({
query: Schema.String,
options: Schema.optional(Schema.Struct({ $top: Schema.Number })),
})
const ResourceQuery = Schema.Struct({ query: Schema.String })
const Resources = Schema.Struct({ data: Schema.Array(Schema.Struct({ id: Schema.NonEmptyString })) })
const ResourceListing = Schema.Struct({ data: Schema.Array(Schema.Unknown) })
const decodeProfile = Schema.decodeUnknownOption(
Schema.fromJsonString(Schema.Struct({ subscriptions: Schema.NonEmptyArray(Schema.Unknown) })),
)
const decodeResourceListing = Schema.decodeUnknownOption(
Schema.Struct({ resourceName: Schema.NonEmptyString, resourceGroup: Schema.String, location: Schema.String }),
)

type Deployment = { readonly name: string; readonly model: string }

Expand All @@ -78,7 +64,6 @@ export function make(
effect: Effect.fn(function* (ctx) {
const configured = yield* configuredSettings(Provider.ID.azure)
const processes = yield* AppProcess.Service
const fs = yield* FSUtil.Service
const bus = yield* Bus.Service
const credentials = yield* Credential.Service
const providers = yield* Provider.Service
Expand Down Expand Up @@ -133,8 +118,6 @@ export function make(

const available = Boolean(which("az"))
const configuredResource = Boolean(resolveResourceName(configured) || typeof configured?.baseURL === "string")
// Undefined until the Azure CLI answers; a failed lookup leaves the form to manual entry.
const listing: { resources?: readonly Form.Option[] } = {}

yield* ctx.integration.transform((editor) => {
// The retired azure-cognitive-services provider's env var still connects Azure.
Expand Down Expand Up @@ -177,16 +160,7 @@ export function make(
placeholder: "e.g. my-models",
required: true,
pattern: resourcePattern.source,
// Resources are listed once at startup, so one created later is typed in. Without a list the field
// is a plain text input.
...iife(() => {
if (!listing.resources) return { description: "Requests use your `az login` session." }
if (listing.resources.length === 0)
return {
description: "No Azure OpenAI or AI Services resources found for your `az login` account.",
}
return { custom: true, options: listing.resources }
}),
description: "Enter the name of your Azure OpenAI or AI Services resource.",
},
]),
},
Expand Down Expand Up @@ -371,42 +345,6 @@ export function make(

Object.assign(loaded, yield* load())

// Lists the resources the Azure CLI account can reach in the background, so the connect form can offer them
// without startup waiting on Azure. The CLI's profile shows a signed-in account without running the CLI.
// https://learn.microsoft.com/cli/azure/azure-cli-configuration#cli-configuration-file
const listResources = Effect.fn("AzurePlugin.listResources")(function* () {
const profile = yield* fs.readFileStringSafe(
path.join(process.env.AZURE_CONFIG_DIR ?? path.join(Global.Path.home, ".azure"), "azureProfile.json"),
)
// The Azure CLI writes the profile with a byte order mark.
if (Option.isNone(decodeProfile(profile?.replace(/^\uFEFF/, "")))) return
const response = yield* HttpClientRequest.post(
`${endpoints.management}/providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01`,
).pipe(
HttpClientRequest.schemaBodyJson(ResourceQuery)({ query: resourceListQuery, options: { $top: 1000 } }),
Effect.flatMap(management),
Effect.flatMap(HttpClientResponse.schemaBodyJson(ResourceListing)),
Effect.timeout("10 seconds"),
)
listing.resources = response.data.flatMap((raw): Form.Option[] => {
const item = Option.getOrUndefined(decodeResourceListing(raw))
if (!item || !resourcePattern.test(item.resourceName)) return []
return [
{
value: item.resourceName,
label: item.resourceName,
description: `${item.resourceGroup} · ${item.location}`,
},
]
})
yield* ctx.integration.reload()
})
if (available && !configuredResource)
yield* listResources().pipe(
Effect.catch((cause) => Effect.logDebug("failed to list Azure resources", { cause })),
Effect.forkScoped,
)

yield* ctx.provider.transform((evt) => {
for (const item of evt.list()) {
if (
Expand Down Expand Up @@ -553,17 +491,6 @@ function credentialResource(credential: Credential.Value | undefined) {
return typeof resource === "string" && resource.trim() !== "" ? resource : undefined
}

// Entra ID authentication requires a custom subdomain, which is the resource name every endpoint uses.
// https://learn.microsoft.com/azure/ai-services/cognitive-services-custom-subdomains
const resourceListQuery = [
"resources",
"| where type =~ 'microsoft.cognitiveservices/accounts' and kind in~ ('AIServices', 'OpenAI')",
"| extend resourceName = tostring(properties.customSubDomainName)",
"| where isnotempty(resourceName)",
"| project resourceName, resourceGroup, location",
"| order by resourceName asc",
].join(" ")

function resourceQuery(resource: string) {
return [
"resources",
Expand Down
59 changes: 14 additions & 45 deletions packages/core/test/plugin/provider-azure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,13 @@ type AzureRequest = {
type Route = (request: AzureRequest) => Response | Promise<Response>

// Answers like Azure: the resource's own deployment list, Resource Graph, and the management API.
const fakeAzure = (routes: { resource?: Route; management?: Route; resources?: () => unknown[] }) =>
const fakeAzure = (routes: { resource?: Route; management?: Route }) =>
Effect.acquireRelease(
Effect.sync(() => {
const requests: AzureRequest[] = []
const server = Bun.serve({
port: 0,
fetch: async (raw) => {
fetch: (raw) => {
const url = new URL(raw.url)
const request = {
method: raw.method,
Expand All @@ -81,12 +81,8 @@ const fakeAzure = (routes: { resource?: Route; management?: Route; resources?: (
}
requests.push(request)
if (request.path.startsWith("/openai/deployments")) return routes.resource?.(request) ?? notFound()
if (request.path.startsWith("/providers/Microsoft.ResourceGraph/")) {
// The connect form lists every resource; discovery looks up one resource's ID.
if ((await raw.text()).includes("project resourceName"))
return routes.resources ? Response.json({ data: routes.resources() }) : unauthorized()
if (request.path.startsWith("/providers/Microsoft.ResourceGraph/"))
return Response.json({ data: [{ id: resourceID }] })
}
return routes.management?.(request) ?? notFound()
},
})
Expand Down Expand Up @@ -125,7 +121,8 @@ const fakeAzureCli = Effect.fn(function* (respond: (args: readonly string[]) =>
const executable = `${directory}/${windows ? "az.cmd" : "az"}`
yield* Effect.promise(() => Bun.write(executable, windows ? "@exit /b 0\r\n" : "#!/bin/sh\nexit 0\n"))
yield* Effect.promise(() => chmod(executable, 0o755))
// The CLI writes its profile with a byte order mark.
// A signed-in profile, so tests show that startup leaves Azure alone even when the CLI could reach it. The CLI
// writes it with a byte order mark.
yield* Effect.promise(() =>
Bun.write(`${directory}/azure/azureProfile.json`, `\uFEFF${JSON.stringify({ subscriptions: [{ id: "sub" }] })}`),
)
Expand Down Expand Up @@ -245,31 +242,21 @@ describe("AzurePlugin connecting", () => {
}),
)

it.live("offers the resources the Azure CLI can reach and accepts a typed resource name", () =>
it.live("asks for the resource name as text without calling Azure", () =>
Effect.gen(function* () {
yield* setEnv(noResourceEnv)
const cli = yield* fakeAzureCli()
const azure = yield* fakeAzure({
resources: () => [
{ resourceName: "alpha", resourceGroup: "models", location: "eastus" },
{ resourceName: "not a hostname", resourceGroup: "models", location: "eastus" },
{ id: 42 },
],
})
const azure = yield* fakeAzure({})
yield* addPlugin(azure.endpoints).pipe(cli.provide)
const integrations = yield* Integration.Service
const field = Effect.gen(function* () {
const method = required(yield* integrations.get(azureID)).methods.find((method) => method.type === "external")
const field = method?.type === "external" ? method.form?.[0] : undefined
return field?.type === "string" ? field : undefined
})
const method = required(yield* integrations.get(azureID)).methods.find((method) => method.type === "external")
const field = method?.type === "external" ? method.form?.[0] : undefined
expect(field).toMatchObject({ type: "string", key: "resourceName" })
expect(field?.type === "string" ? field.options : undefined).toBeUndefined()

const listed = required(yield* eventually(field, (field) => (field?.options?.length ?? 0) > 0))
expect(listed).toMatchObject({
key: "resourceName",
custom: true,
options: [{ value: "alpha", label: "alpha", description: "models · eastus" }],
})
yield* settle
expect(cli.commands).toEqual([])
expect(azure.requests).toEqual([])

const external = (resourceName: string) =>
integrations.connection.external({
Expand All @@ -290,24 +277,6 @@ describe("AzurePlugin connecting", () => {
}),
)

it.live("asks for the resource name as text when the Azure CLI lists no resources", () =>
Effect.gen(function* () {
yield* setEnv(noResourceEnv)
const cli = yield* fakeAzureCli()
const azure = yield* fakeAzure({ resources: () => [] })
yield* addPlugin(azure.endpoints).pipe(cli.provide)
const integrations = yield* Integration.Service
const field = Effect.gen(function* () {
const method = required(yield* integrations.get(azureID)).methods.find((method) => method.type === "external")
const field = method?.type === "external" ? method.form?.[0] : undefined
return field?.type === "string" ? field : undefined
})

const listed = required(yield* eventually(field, (field) => field?.description?.startsWith("No ") === true))
expect(listed.options).toBeUndefined()
expect(listed.custom).toBeUndefined()
}),
)
})

describe("AzurePlugin startup", () => {
Expand Down
Loading