mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-30 06:12:17 +00:00
feat(core): support Azure CLI authentication (#45086)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
This commit is contained in:
parent
a438d34fcd
commit
bdf2e84812
4 changed files with 740 additions and 17 deletions
|
|
@ -1,11 +1,49 @@
|
|||
import { Effect } from "effect"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { Clock, Effect, Schema, Semaphore, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Form } from "@opencode-ai/schema/form"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { App } from "../../app.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { iife } from "../../util/iife.js"
|
||||
import { which } from "../../util/which.js"
|
||||
import { configuredSettings } from "./configured.js"
|
||||
|
||||
const cognitiveScope = "https://cognitiveservices.azure.com/.default"
|
||||
const foundryScope = "https://ai.azure.com/.default"
|
||||
const methodID = Integration.MethodID.make("azure-cli")
|
||||
const decodeJSON = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))
|
||||
const decodeProfile = Schema.decodeUnknownEffect(
|
||||
Schema.fromJsonString(Schema.Struct({ subscriptions: Schema.Array(Schema.Unknown) })),
|
||||
)
|
||||
const decodeToken = Schema.decodeUnknownEffect(
|
||||
Schema.Struct({
|
||||
accessToken: Schema.NonEmptyString,
|
||||
expires_on: Schema.optional(Schema.Number),
|
||||
expiresOn: Schema.optional(Schema.NonEmptyString),
|
||||
}),
|
||||
)
|
||||
const decodeAccounts = Schema.decodeUnknownEffect(
|
||||
Schema.Array(Schema.Struct({ name: Schema.NonEmptyString, resourceGroup: Schema.NonEmptyString })),
|
||||
)
|
||||
const Deployments = Schema.Array(
|
||||
Schema.Struct({
|
||||
name: Schema.NonEmptyString,
|
||||
properties: Schema.Struct({
|
||||
model: Schema.Struct({ name: Schema.NonEmptyString }),
|
||||
provisioningState: Schema.NonEmptyString,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const decodeDeployments = Schema.decodeUnknownEffect(Deployments)
|
||||
|
||||
function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
|
||||
if (useChat && sdk.chat) return sdk.chat(modelID)
|
||||
if (sdk.responses) return sdk.responses(modelID)
|
||||
|
|
@ -18,33 +56,185 @@ export const AzurePlugin = define({
|
|||
id: "opencode.provider.azure",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const configured = yield* configuredSettings(Provider.ID.azure)
|
||||
const form = iife(() => {
|
||||
if (resolveResourceName(configured) || typeof configured?.baseURL === "string") return
|
||||
return Form.Fields.make([
|
||||
{
|
||||
type: "string",
|
||||
key: "resourceName",
|
||||
title: "Enter Azure Resource Name",
|
||||
placeholder: "e.g. my-models",
|
||||
required: true,
|
||||
},
|
||||
])
|
||||
const processes = yield* AppProcess.Service
|
||||
const bus = yield* Bus.Service
|
||||
const tokens = new Map<string, { access: string; expires: number }>()
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
const loaded: { resource?: string; deployments?: typeof Deployments.Type } = {}
|
||||
|
||||
const command = (args: string[]) =>
|
||||
processes
|
||||
.run(ChildProcess.make("az", args, { extendEnv: true, stdin: "ignore" }), { timeout: "10 seconds" })
|
||||
.pipe(
|
||||
Effect.flatMap(AppProcess.requireSuccess),
|
||||
Effect.flatMap((result) => decodeJSON(result.stdout.toString("utf8"))),
|
||||
)
|
||||
|
||||
const token = Effect.fn("AzurePlugin.token")(function* (scope: string) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const cached = tokens.get(scope)
|
||||
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 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)
|
||||
return refreshed
|
||||
})
|
||||
|
||||
const available = Boolean(which("az"))
|
||||
// Installing Azure CLI does not mean the user has signed in. Avoid spawning it for unrelated CLI commands.
|
||||
const signedIn = available
|
||||
? yield* Effect.tryPromise(() =>
|
||||
readFile(join(process.env.AZURE_CONFIG_DIR ?? join(homedir(), ".azure"), "azureProfile.json"), "utf8"),
|
||||
).pipe(
|
||||
Effect.flatMap((text) => decodeProfile(text.replace(/^\uFEFF/, ""))),
|
||||
Effect.map((profile) => profile.subscriptions.length > 0),
|
||||
Effect.orElseSucceed(() => false),
|
||||
)
|
||||
: false
|
||||
const accounts =
|
||||
!resolveResourceName(configured) &&
|
||||
typeof configured?.baseURL !== "string" &&
|
||||
!process.env.AZURE_RESOURCE_GROUP &&
|
||||
signedIn
|
||||
? yield* command(["cognitiveservices", "account", "list", "--output", "json", "--only-show-errors"]).pipe(
|
||||
Effect.flatMap(decodeAccounts),
|
||||
Effect.orElseSucceed(() => []),
|
||||
)
|
||||
: []
|
||||
|
||||
const form = (select = false) =>
|
||||
iife(() => {
|
||||
if (resolveResourceName(configured) || typeof configured?.baseURL === "string") return
|
||||
return Form.Fields.make([
|
||||
{
|
||||
type: "string",
|
||||
key: "resourceName",
|
||||
title: "Enter Azure Resource Name",
|
||||
placeholder: "e.g. my-models",
|
||||
required: true,
|
||||
...(select && accounts.length > 0
|
||||
? {
|
||||
options: accounts.map((account) => ({
|
||||
value: account.name,
|
||||
label: account.name,
|
||||
description: account.resourceGroup,
|
||||
})),
|
||||
custom: true,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.method.update({
|
||||
integrationID: Provider.ID.azure,
|
||||
method: { type: "key", label: "API key", form: form() },
|
||||
})
|
||||
if (!available) return
|
||||
draft.method.update({
|
||||
integrationID: Provider.ID.azure,
|
||||
method: {
|
||||
type: "key",
|
||||
label: "API key",
|
||||
form,
|
||||
id: methodID,
|
||||
type: "oauth",
|
||||
label: "Microsoft Entra ID (Azure CLI)",
|
||||
form: form(true),
|
||||
},
|
||||
authorize: (answer) =>
|
||||
Effect.succeed({
|
||||
mode: "auto" as const,
|
||||
url: "",
|
||||
instructions: "Sign in with `az login` before continuing.",
|
||||
callback: Effect.gen(function* () {
|
||||
const resourceName =
|
||||
typeof answer.resourceName === "string" ? answer.resourceName : resolveResourceName(configured)
|
||||
if (!resourceName) return yield* Effect.fail(new Error("Azure resource name is required"))
|
||||
const current = yield* token(cognitiveScope)
|
||||
loaded.resource = resourceName
|
||||
loaded.deployments = yield* discover(resourceName)
|
||||
yield* ctx.catalog.reload()
|
||||
return Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
access: current.access,
|
||||
refresh: "azure-cli",
|
||||
expires: current.expires,
|
||||
metadata: { resourceName },
|
||||
})
|
||||
}),
|
||||
}),
|
||||
refresh: (credential) =>
|
||||
token(cognitiveScope).pipe(
|
||||
Effect.map((current) =>
|
||||
Credential.OAuth.make({ ...credential, access: current.access, expires: current.expires }),
|
||||
),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
const discover = Effect.fn("AzurePlugin.discover")(function* (resource: string) {
|
||||
return yield* Effect.gen(function* () {
|
||||
const group = process.env.AZURE_RESOURCE_GROUP
|
||||
const account = group
|
||||
? { name: resource, resourceGroup: group }
|
||||
: (accounts.length > 0
|
||||
? accounts
|
||||
: yield* command(["cognitiveservices", "account", "list", "--output", "json", "--only-show-errors"]).pipe(
|
||||
Effect.flatMap(decodeAccounts),
|
||||
)
|
||||
).find((item) => item.name.toLowerCase() === resource.toLowerCase())
|
||||
if (!account)
|
||||
return yield* Effect.fail(new Error(`Azure resource "${resource}" was not found in the active subscription`))
|
||||
return yield* command([
|
||||
"cognitiveservices",
|
||||
"account",
|
||||
"deployment",
|
||||
"list",
|
||||
"--name",
|
||||
account.name,
|
||||
"--resource-group",
|
||||
account.resourceGroup,
|
||||
"--output",
|
||||
"json",
|
||||
"--only-show-errors",
|
||||
]).pipe(Effect.flatMap(decodeDeployments))
|
||||
}).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logWarning("Azure model discovery failed", {
|
||||
resource,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}).pipe(Effect.as(undefined)),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const load = Effect.fn("AzurePlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
if (credential?.type !== "oauth" || credential.methodID !== methodID) {
|
||||
loaded.resource = undefined
|
||||
loaded.deployments = undefined
|
||||
return
|
||||
}
|
||||
const resource =
|
||||
typeof credential.metadata?.resourceName === "string" ? credential.metadata.resourceName : undefined
|
||||
loaded.resource = resource
|
||||
loaded.deployments = resource ? yield* discover(resource) : undefined
|
||||
})
|
||||
|
||||
yield* load()
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.id !== Provider.ID.azure && Provider.packageName(item.provider.package) !== "@ai-sdk/azure")
|
||||
continue
|
||||
const resourceName = resolveResourceName(item.provider.settings)
|
||||
const resourceName = resolveResourceName(item.provider.settings, loaded.resource)
|
||||
if (resourceName)
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.settings = {
|
||||
|
|
@ -55,6 +245,31 @@ export const AzurePlugin = define({
|
|||
: {}),
|
||||
}
|
||||
})
|
||||
if (item.provider.id === Provider.ID.azure && loaded.deployments) {
|
||||
// Startup batches catalog transforms, so match against the current draft rather than a pre-startup snapshot.
|
||||
const existing = Array.from(item.models.values())
|
||||
const found = new Map<Model.ID, Model.Info>()
|
||||
loaded.deployments.forEach((deployment) => {
|
||||
if (deployment.properties.provisioningState !== "Succeeded") return
|
||||
const model = existing.find(
|
||||
(model) => model.id.toLowerCase() === deployment.properties.model.name.toLowerCase(),
|
||||
)
|
||||
if (!model) return
|
||||
const id = found.has(model.id) ? Model.ID.make(deployment.name) : model.id
|
||||
found.set(id, {
|
||||
...model,
|
||||
id,
|
||||
name: id === model.id ? model.name : `${model.name} (${deployment.name})`,
|
||||
modelID: Model.ID.make(deployment.name),
|
||||
})
|
||||
})
|
||||
for (const id of Array.from(item.models.keys())) {
|
||||
if (!found.has(Model.ID.make(id))) evt.model.remove(item.provider.id, id)
|
||||
}
|
||||
for (const [id, model] of found) {
|
||||
evt.model.update(item.provider.id, id, (draft) => Object.assign(draft, structuredClone(model)))
|
||||
}
|
||||
}
|
||||
for (const model of item.models.values()) {
|
||||
evt.model.update(item.provider.id, model.id, (draft) => {
|
||||
if (resourceName && typeof draft.settings?.baseURL === "string")
|
||||
|
|
@ -67,6 +282,38 @@ export const AzurePlugin = define({
|
|||
}
|
||||
}
|
||||
})
|
||||
|
||||
const reload = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||
yield* bus.subscribe(Credential.Event.Switched).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("azure")),
|
||||
Stream.runForEach(reload),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
yield* ctx.session.hook(
|
||||
"http.request",
|
||||
(evt) =>
|
||||
Effect.gen(function* () {
|
||||
if (evt.model.providerID !== Provider.ID.azure) return
|
||||
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
if (credential?.type !== "oauth" || credential.methodID !== methodID) return
|
||||
const url = new URL(evt.request.url)
|
||||
const scope =
|
||||
url.hostname.endsWith(".services.ai.azure.com") && !url.pathname.startsWith("/models")
|
||||
? foundryScope
|
||||
: cognitiveScope
|
||||
const current = yield* token(scope).pipe(Effect.orDie)
|
||||
evt.request.headers.delete("api-key")
|
||||
evt.request.headers.delete("x-api-key")
|
||||
evt.request.headers.set("authorization", `Bearer ${current.access}`)
|
||||
evt.request.headers.set("user-agent", App.useragent(ctx.app))
|
||||
}),
|
||||
{ providerID: Provider.ID.azure },
|
||||
)
|
||||
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Command } from "@opencode-ai/core/command"
|
|||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
|
|
@ -56,6 +57,7 @@ const permissionLayer = Layer.succeed(
|
|||
|
||||
export const PluginTestLayer = LayerNode.compile(
|
||||
LayerNode.group([
|
||||
AppProcess.node,
|
||||
FileSystem.node,
|
||||
FSUtil.node,
|
||||
Location.node,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,22 @@
|
|||
import { chmod } from "node:fs/promises"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { describe, expect } from "bun:test"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schedule } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
|
|
@ -47,6 +55,70 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Ef
|
|||
)
|
||||
}
|
||||
|
||||
function withAzureCommands<A, E, R>(
|
||||
run: (args: readonly string[]) => unknown,
|
||||
fx: () => Effect.Effect<A, E, R>,
|
||||
deploymentDelay = 0,
|
||||
signedIn = true,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const processes = yield* AppProcess.Service
|
||||
const directory = (yield* Location.Service).directory
|
||||
const executable = `${directory}/${process.platform === "win32" ? "az.cmd" : "az"}`
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(executable, process.platform === "win32" ? "@exit /b 0\r\n" : "#!/bin/sh\nexit 0\n"),
|
||||
)
|
||||
yield* Effect.promise(() => chmod(executable, 0o755))
|
||||
if (signedIn) {
|
||||
yield* Effect.promise(() => Bun.write(`${directory}/azure-cli/azureProfile.json`, '{"subscriptions":[{}]}'))
|
||||
}
|
||||
const fake = AppProcess.Service.of({
|
||||
...processes,
|
||||
run: (command) => {
|
||||
if (command._tag !== "StandardCommand") return processes.run(command)
|
||||
const value = run(command.args)
|
||||
if (value instanceof Error) {
|
||||
return Effect.fail(new AppProcess.AppProcessError({ command: "az", cause: value }))
|
||||
}
|
||||
const result = Effect.succeed({
|
||||
command: `az ${command.args.join(" ")}`,
|
||||
exitCode: 0,
|
||||
stdout: Buffer.from(JSON.stringify(value)),
|
||||
stderr: Buffer.alloc(0),
|
||||
stdoutTruncated: false,
|
||||
stderrTruncated: false,
|
||||
})
|
||||
if (deploymentDelay > 0 && command.args.includes("deployment")) {
|
||||
return Effect.sleep(`${deploymentDelay} millis`).pipe(Effect.andThen(result))
|
||||
}
|
||||
return result
|
||||
},
|
||||
})
|
||||
return yield* withEnv(
|
||||
{
|
||||
PATH: `${directory}${process.platform === "win32" ? ";" : ":"}${process.env.PATH}`,
|
||||
AZURE_CONFIG_DIR: `${directory}/azure-cli`,
|
||||
},
|
||||
() => fx().pipe(Effect.provideService(AppProcess.Service, fake)),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const azureCredential = 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",
|
||||
expires: Date.now() + 60 * 60 * 1000,
|
||||
metadata: { resourceName: "test-resource" },
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
function fakeSelectorSdk(calls: string[]) {
|
||||
const make = (method: string) => (id: string) => {
|
||||
calls.push(`${method}:${id}`)
|
||||
|
|
@ -82,6 +154,344 @@ describe("AzurePlugin", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.effect("hides Azure CLI authentication when the Azure CLI is not installed", () =>
|
||||
withEnv({ PATH: "/nonexistent" }, () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integration = yield* (yield* Integration.Service).get(Integration.ID.make("azure"))
|
||||
expect(integration?.methods.some((method) => method.type === "oauth")).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("registers Azure CLI authentication alongside API keys", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
|
||||
withAzureCommands(
|
||||
() => [],
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integration = yield* (yield* Integration.Service).get(Integration.ID.make("azure"))
|
||||
expect(integration?.methods).toContainEqual({
|
||||
id: Integration.MethodID.make("azure-cli"),
|
||||
type: "oauth",
|
||||
label: "Microsoft Entra ID (Azure CLI)",
|
||||
form: [
|
||||
{
|
||||
type: "string",
|
||||
key: "resourceName",
|
||||
title: "Enter Azure Resource Name",
|
||||
placeholder: "e.g. my-models",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not invoke Azure CLI at startup without a cached Azure login", () => {
|
||||
const commands: string[] = []
|
||||
return withEnv(
|
||||
{
|
||||
AZURE_RESOURCE_NAME: undefined,
|
||||
AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined,
|
||||
AZURE_RESOURCE_GROUP: undefined,
|
||||
},
|
||||
() =>
|
||||
withAzureCommands(
|
||||
(args) => {
|
||||
commands.push(args.join(" "))
|
||||
return []
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
expect(commands).toEqual([])
|
||||
const integration = yield* (yield* Integration.Service).get(Integration.ID.make("azure"))
|
||||
expect(integration?.methods.some((method) => method.type === "oauth")).toBe(true)
|
||||
}),
|
||||
0,
|
||||
false,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("lists Azure CLI resources and keeps manual resource entry available", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
|
||||
withAzureCommands(
|
||||
() => [
|
||||
{ name: "first-resource", resourceGroup: "first-group" },
|
||||
{ name: "second-resource", resourceGroup: "second-group" },
|
||||
],
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integration = yield* (yield* Integration.Service).get(Integration.ID.make("azure"))
|
||||
const method = integration?.methods.find((item) => item.type === "oauth")
|
||||
expect(method?.form?.[0]).toMatchObject({
|
||||
title: "Enter Azure Resource Name",
|
||||
options: [
|
||||
{ value: "first-resource", label: "first-resource", description: "first-group" },
|
||||
{ value: "second-resource", label: "second-resource", description: "second-group" },
|
||||
],
|
||||
custom: true,
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("connects with the Azure CLI and accepts legacy token expiration", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
|
||||
withAzureCommands(
|
||||
(args) => {
|
||||
if (args.includes("get-access-token")) {
|
||||
return {
|
||||
accessToken: "legacy-cli-token",
|
||||
expiresOn: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
}
|
||||
}
|
||||
return []
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
const integrationID = Integration.ID.make("azure")
|
||||
const attempt = yield* integrations.oauth.connect({
|
||||
integrationID,
|
||||
methodID: Integration.MethodID.make("azure-cli"),
|
||||
answer: { resourceName: "test-resource" },
|
||||
})
|
||||
yield* Effect.gen(function* () {
|
||||
const status = yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })
|
||||
if (status.status !== "complete") return yield* Effect.fail(new Error("Azure CLI authorization pending"))
|
||||
}).pipe(Effect.retry({ times: 1500, schedule: Schedule.spaced("1 millis") }))
|
||||
|
||||
const credential = (yield* (yield* Credential.Service).list(integrationID))[0]?.value
|
||||
expect(credential).toMatchObject({
|
||||
type: "oauth",
|
||||
access: "legacy-cli-token",
|
||||
metadata: { resourceName: "test-resource" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("finishes deployment discovery before Azure authorization completes", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
|
||||
withAzureCommands(
|
||||
(args) => {
|
||||
if (args.includes("get-access-token")) {
|
||||
return {
|
||||
accessToken: "azure-token",
|
||||
expires_on: Math.floor((Date.now() + 60 * 60 * 1000) / 1000),
|
||||
}
|
||||
}
|
||||
if (args.includes("deployment")) {
|
||||
return [
|
||||
{
|
||||
name: "gpt-production",
|
||||
properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" },
|
||||
},
|
||||
]
|
||||
}
|
||||
return [{ name: "test-resource", resourceGroup: "test-group" }]
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(Provider.ID.azure, (provider) => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/azure")
|
||||
})
|
||||
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), () => {})
|
||||
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-nano"), () => {})
|
||||
})
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
const integrationID = Integration.ID.make("azure")
|
||||
const attempt = yield* integrations.oauth.connect({
|
||||
integrationID,
|
||||
methodID: Integration.MethodID.make("azure-cli"),
|
||||
answer: { resourceName: "test-resource" },
|
||||
})
|
||||
yield* Effect.gen(function* () {
|
||||
const status = yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })
|
||||
if (status.status !== "complete") {
|
||||
return yield* Effect.fail(
|
||||
new Error(
|
||||
`Azure CLI authorization ${status.status}${"message" in status ? `: ${status.message}` : ""}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
}).pipe(Effect.retry({ times: 1500, schedule: Schedule.spaced("1 millis") }))
|
||||
|
||||
expect(yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-nano"))).toBeUndefined()
|
||||
expect((yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-mini")))?.modelID).toBe(
|
||||
Model.ID.make("gpt-production"),
|
||||
)
|
||||
}),
|
||||
50,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
for (const batched of [false, true]) {
|
||||
it.effect(
|
||||
`discovers deployments with an existing connection (${batched ? "batched startup" : "ready catalog"})`,
|
||||
() =>
|
||||
withEnv({ AZURE_RESOURCE_GROUP: undefined }, () =>
|
||||
withAzureCommands(
|
||||
(args) => {
|
||||
if (args.includes("deployment")) {
|
||||
return [
|
||||
{
|
||||
name: "gpt-production",
|
||||
properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" },
|
||||
},
|
||||
{
|
||||
name: "gpt-staging",
|
||||
properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" },
|
||||
},
|
||||
{
|
||||
name: "gpt-pending",
|
||||
properties: { model: { name: "gpt-5-mini" }, provisioningState: "Creating" },
|
||||
},
|
||||
]
|
||||
}
|
||||
return [{ name: "test-resource", resourceGroup: "test-group" }]
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* azureCredential
|
||||
const startup = Effect.gen(function* () {
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(Provider.ID.azure, (provider) => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/azure")
|
||||
})
|
||||
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), (model) => {
|
||||
model.name = "GPT-5 Mini"
|
||||
})
|
||||
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-nano"), () => {})
|
||||
})
|
||||
yield* addPlugin()
|
||||
})
|
||||
yield* batched ? State.batch(startup) : startup
|
||||
|
||||
expect((yield* catalog.provider.get(Provider.ID.azure))?.settings?.resourceName).toBe("test-resource")
|
||||
expect((yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-mini")))?.modelID).toBe(
|
||||
Model.ID.make("gpt-production"),
|
||||
)
|
||||
expect((yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-staging")))?.modelID).toBe(
|
||||
Model.ID.make("gpt-staging"),
|
||||
)
|
||||
expect(yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-nano"))).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("keeps existing models when Azure deployment discovery is unavailable", () =>
|
||||
withAzureCommands(
|
||||
() => new Error("management access denied"),
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(Provider.ID.azure, (provider) => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/azure")
|
||||
})
|
||||
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), () => {})
|
||||
})
|
||||
yield* azureCredential
|
||||
yield* addPlugin()
|
||||
|
||||
expect(yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-mini"))).toBeDefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("skips subscription discovery when the resource group is configured", () =>
|
||||
withEnv({ AZURE_RESOURCE_GROUP: "restricted-group" }, () =>
|
||||
withAzureCommands(
|
||||
(args) => {
|
||||
expect(args).toContain("deployment")
|
||||
expect(args).toContain("restricted-group")
|
||||
return [
|
||||
{
|
||||
name: "gpt-production",
|
||||
properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" },
|
||||
},
|
||||
]
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(Provider.ID.azure, (provider) => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/azure")
|
||||
})
|
||||
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), () => {})
|
||||
})
|
||||
yield* azureCredential
|
||||
yield* addPlugin()
|
||||
expect((yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-mini")))?.modelID).toBe(
|
||||
Model.ID.make("gpt-production"),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses the correct bearer token audience for Azure and Foundry requests", () =>
|
||||
withAzureCommands(
|
||||
(args) => {
|
||||
if (args.includes("get-access-token")) {
|
||||
const scope = args[args.indexOf("--scope") + 1]
|
||||
return { accessToken: `${scope}-token`, expires_on: Math.floor((Date.now() + 60 * 60 * 1000) / 1000) }
|
||||
}
|
||||
return []
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* azureCredential
|
||||
yield* addPlugin()
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const model = Model.Ref.make({ providerID: Provider.ID.azure, id: Model.ID.make("gpt-5-mini") })
|
||||
const azure = yield* hooks.trigger("session", "http.request", {
|
||||
sessionID: Session.ID.make("ses_azure"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model,
|
||||
request: new Request("https://test-resource.openai.azure.com/openai/v1/responses", {
|
||||
headers: { "api-key": "stored-token", "x-keep": "yes" },
|
||||
}),
|
||||
})
|
||||
expect(azure.request.headers.get("authorization")).toBe(
|
||||
"Bearer https://cognitiveservices.azure.com/.default-token",
|
||||
)
|
||||
expect(azure.request.headers.has("api-key")).toBe(false)
|
||||
expect(azure.request.headers.get("x-keep")).toBe("yes")
|
||||
|
||||
const foundry = yield* hooks.trigger("session", "http.request", {
|
||||
sessionID: Session.ID.make("ses_foundry"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model,
|
||||
request: new Request("https://test-resource.services.ai.azure.com/anthropic/v1/messages", {
|
||||
headers: { "x-api-key": "stored-token" },
|
||||
}),
|
||||
})
|
||||
expect(foundry.request.headers.get("authorization")).toBe("Bearer https://ai.azure.com/.default-token")
|
||||
expect(foundry.request.headers.has("x-api-key")).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("resolves resourceName from env", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
|
||||
Effect.gen(function* () {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,70 @@ The `providers` object is keyed by provider ID. Each provider accepts these fiel
|
|||
| `body` | JSON fields merged into request bodies. |
|
||||
| `models` | Models to add or override, keyed by catalog model ID. |
|
||||
|
||||
## Azure OpenAI and Microsoft Foundry
|
||||
|
||||
Azure supports either an API key or your existing Microsoft Entra ID session from the Azure CLI.
|
||||
|
||||
1. Install the [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) and sign in:
|
||||
|
||||
```bash
|
||||
az login
|
||||
```
|
||||
|
||||
If the resource belongs to another tenant or subscription, select them first:
|
||||
|
||||
```bash
|
||||
az login --tenant TENANT_ID
|
||||
az account set --subscription NAME_OR_ID
|
||||
```
|
||||
|
||||
2. Find your Azure resource name in the [Azure portal](https://portal.azure.com/) or
|
||||
[Microsoft Foundry](https://ai.azure.com/): open the Azure OpenAI or Foundry resource and copy its **Resource name**.
|
||||
It is also the first part of the endpoint: `my-models` in `https://my-models.openai.azure.com/` or
|
||||
`https://my-models.services.ai.azure.com/`.
|
||||
|
||||
If your identity can list resources, the Azure CLI can display the names and resource groups:
|
||||
|
||||
```bash
|
||||
az cognitiveservices account list \
|
||||
--query "[].{name:name,resourceGroup:resourceGroup}" \
|
||||
--output table
|
||||
```
|
||||
|
||||
3. In OpenCode, run `/connect`, select **Azure**, and choose **Microsoft Entra ID (Azure CLI)**. OpenCode lists the Azure
|
||||
resources visible to your active CLI session, including their resource groups. Select one, or choose **Type your own
|
||||
answer** to enter a resource name that is not listed. If resource listing is unavailable, OpenCode asks for the name
|
||||
directly. If `AZURE_RESOURCE_NAME` is already set, OpenCode uses it without prompting.
|
||||
|
||||
4. Select a deployed model with `/models`.
|
||||
|
||||
OpenCode discovers successful model deployments from the active Azure subscription and refreshes access tokens through your
|
||||
Azure CLI session. Set `AZURE_RESOURCE_GROUP` when you already know the resource group and want to skip subscription-wide
|
||||
resource discovery.
|
||||
|
||||
Listing deployments requires Azure management permissions, which are separate from inference permissions. If your identity
|
||||
cannot list deployments, the Azure model catalog remains available. Select a model whose name matches the deployment, or
|
||||
configure the deployment name explicitly:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"providers": {
|
||||
"azure": {
|
||||
"models": {
|
||||
"gpt-5-mini": {
|
||||
"modelID": "gpt-production",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Your identity needs the **Cognitive Services OpenAI User** role for Azure OpenAI models or the **Cognitive Services User**
|
||||
role for other Foundry models. If a request fails because the token belongs to another tenant, sign in again with
|
||||
`az login --tenant TENANT_ID`.
|
||||
|
||||
## Endpoint
|
||||
|
||||
Override `settings.baseURL` to send an existing provider through a proxy or compatible endpoint. Its existing package,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue