remove azure discovery stuff (#46666)
This commit is contained in:
parent
2961956c74
commit
af1f9e6269
3 changed files with 24 additions and 439 deletions
|
|
@ -1,10 +1,6 @@
|
|||
import { readFile } from "node:fs/promises"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { which } from "@opencode-ai/core/util/which"
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
import type { Provider } from "@opencode-ai/sdk/v2"
|
||||
import { Schema } from "effect"
|
||||
import { OAUTH_DUMMY_KEY } from "../auth"
|
||||
import { Process } from "../util/process"
|
||||
|
|
@ -19,58 +15,16 @@ const AzureCliToken = Schema.Struct({
|
|||
expiresOn: Schema.optional(Schema.NonEmptyString),
|
||||
})
|
||||
const decodeAzureCliToken = Schema.decodeUnknownPromise(AzureCliToken)
|
||||
const decodeAzureProfile = Schema.decodeUnknownPromise(
|
||||
Schema.fromJsonString(Schema.Struct({ subscriptions: Schema.Array(Schema.Unknown) })),
|
||||
)
|
||||
|
||||
const decodeAzureAccounts = Schema.decodeUnknownPromise(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
name: Schema.NonEmptyString,
|
||||
resourceGroup: Schema.NonEmptyString,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const decodeAzureDeployments = Schema.decodeUnknownPromise(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
name: Schema.NonEmptyString,
|
||||
properties: Schema.Struct({
|
||||
model: Schema.Struct({
|
||||
name: Schema.NonEmptyString,
|
||||
}),
|
||||
provisioningState: Schema.NonEmptyString,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
type AzureCommand = (args: string[]) => Promise<unknown>
|
||||
type AzureAccount = { readonly name: string; readonly resourceGroup: string }
|
||||
|
||||
export async function AzureAuthPlugin(): Promise<Hooks> {
|
||||
const available = Boolean(which("az"))
|
||||
// Avoid launching Azure CLI on unrelated commands just because the executable is installed.
|
||||
const signedIn = available
|
||||
? await readFile(join(process.env.AZURE_CONFIG_DIR ?? join(homedir(), ".azure"), "azureProfile.json"), "utf8")
|
||||
.then((text) => decodeAzureProfile(text.replace(/^\uFEFF/, "")))
|
||||
.then((profile) => profile.subscriptions.length > 0)
|
||||
.catch(() => false)
|
||||
: false
|
||||
const accounts =
|
||||
!process.env.AZURE_RESOURCE_NAME && !process.env.AZURE_RESOURCE_GROUP && signedIn
|
||||
? await runAzure(["cognitiveservices", "account", "list", "--output", "json", "--only-show-errors"])
|
||||
.then(decodeAzureAccounts)
|
||||
.catch(() => [])
|
||||
: []
|
||||
return createAzureAuthHooks(runAzure, fetch, accounts, available)
|
||||
return createAzureAuthHooks(runAzure, fetch, available)
|
||||
}
|
||||
|
||||
export function createAzureAuthHooks(
|
||||
run: AzureCommand,
|
||||
request: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,
|
||||
accounts: readonly AzureAccount[],
|
||||
available: boolean,
|
||||
): Hooks {
|
||||
const tokens = new Map<string, { token: string; expires: number }>()
|
||||
|
|
@ -97,46 +51,7 @@ export function createAzureAuthHooks(
|
|||
placeholder: "e.g. my-models",
|
||||
})
|
||||
}
|
||||
const oauthPrompts =
|
||||
accounts.length > 0 && !process.env.AZURE_RESOURCE_NAME
|
||||
? [
|
||||
{
|
||||
type: "select" as const,
|
||||
key: "resourceSelection",
|
||||
message: "Select Azure resource",
|
||||
options: [
|
||||
...accounts.map((account) => ({
|
||||
label: account.name,
|
||||
value: account.name,
|
||||
hint: account.resourceGroup,
|
||||
})),
|
||||
{ label: "Enter another resource name", value: "__manual__" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "text" as const,
|
||||
key: "resourceName",
|
||||
message: "Enter Azure Resource Name",
|
||||
placeholder: "e.g. my-models",
|
||||
when: { key: "resourceSelection", op: "eq" as const, value: "__manual__" },
|
||||
},
|
||||
]
|
||||
: prompts
|
||||
|
||||
const hooks: Hooks = {
|
||||
provider: {
|
||||
id: "azure",
|
||||
async models(provider, context) {
|
||||
if (context.auth?.type !== "oauth") return provider.models
|
||||
// Discovery shells out to the Azure CLI, so skip it when the CLI is missing.
|
||||
if (!available) return provider.models
|
||||
const resource = context.auth.accountId
|
||||
if (!resource) return {}
|
||||
// This hook runs outside the app's Effect runtime, so logging here would go to the
|
||||
// console. Fall back to the configured models silently.
|
||||
return discoverAzureModels(provider.models, resource, run).catch(() => provider.models)
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
provider: "azure",
|
||||
async loader(getAuth) {
|
||||
|
|
@ -164,17 +79,14 @@ export function createAzureAuthHooks(
|
|||
{
|
||||
type: "oauth",
|
||||
label: "Microsoft Entra ID (Azure CLI)",
|
||||
prompts: oauthPrompts,
|
||||
prompts,
|
||||
async authorize(inputs) {
|
||||
return {
|
||||
url: "",
|
||||
instructions: "Sign in with `az login` before continuing.",
|
||||
method: "auto",
|
||||
callback: async () => {
|
||||
const resourceName =
|
||||
inputs?.resourceName ??
|
||||
(inputs?.resourceSelection === "__manual__" ? undefined : inputs?.resourceSelection) ??
|
||||
process.env.AZURE_RESOURCE_NAME
|
||||
const resourceName = inputs?.resourceName ?? process.env.AZURE_RESOURCE_NAME
|
||||
if (!resourceName) throw new Error("Azure Resource Name is required")
|
||||
|
||||
await token(AZURE_COGNITIVE_SERVICES_SCOPE)
|
||||
|
|
@ -201,53 +113,6 @@ async function runAzure(args: string[]): Promise<unknown> {
|
|||
return JSON.parse(result.stdout.toString())
|
||||
}
|
||||
|
||||
async function discoverAzureModels(models: Provider["models"], resourceName: string, run: AzureCommand) {
|
||||
const resourceGroup = process.env.AZURE_RESOURCE_GROUP
|
||||
const account = resourceGroup
|
||||
? { name: resourceName, resourceGroup }
|
||||
: (
|
||||
await decodeAzureAccounts(
|
||||
await run(["cognitiveservices", "account", "list", "--output", "json", "--only-show-errors"]),
|
||||
)
|
||||
).find((account) => account.name.toLowerCase() === resourceName.toLowerCase())
|
||||
if (!account) throw new Error(`Azure resource "${resourceName}" was not found in the active subscription`)
|
||||
|
||||
const deployments = await decodeAzureDeployments(
|
||||
await run([
|
||||
"cognitiveservices",
|
||||
"account",
|
||||
"deployment",
|
||||
"list",
|
||||
"--name",
|
||||
account.name,
|
||||
"--resource-group",
|
||||
account.resourceGroup,
|
||||
"--output",
|
||||
"json",
|
||||
"--only-show-errors",
|
||||
]),
|
||||
)
|
||||
const found = new Map<string, Provider["models"][string]>()
|
||||
deployments.forEach((deployment) => {
|
||||
if (deployment.properties.provisioningState !== "Succeeded") return
|
||||
const modelID = Object.keys(models).find(
|
||||
(modelID) => modelID.toLowerCase() === deployment.properties.model.name.toLowerCase(),
|
||||
)
|
||||
if (!modelID) return
|
||||
const id = found.has(modelID) ? deployment.name : modelID
|
||||
found.set(id, {
|
||||
...models[modelID],
|
||||
id,
|
||||
name: id === modelID ? models[modelID].name : `${models[modelID].name} (${deployment.name})`,
|
||||
api: {
|
||||
...models[modelID].api,
|
||||
id: deployment.name,
|
||||
},
|
||||
})
|
||||
})
|
||||
return Object.fromEntries(found)
|
||||
}
|
||||
|
||||
function scopeForRequest(input: RequestInfo | URL) {
|
||||
const url = new URL(input instanceof Request ? input.url : input)
|
||||
if (url.hostname.endsWith(".services.ai.azure.com") && !url.pathname.startsWith("/models")) {
|
||||
|
|
|
|||
|
|
@ -11,17 +11,11 @@ import { Process } from "../../src/util/process"
|
|||
import { which } from "@opencode-ai/core/util/which"
|
||||
|
||||
const resourceName = process.env.AZURE_RESOURCE_NAME
|
||||
const resourceGroup = process.env.AZURE_RESOURCE_GROUP
|
||||
const azureConfig = process.env.AZURE_CONFIG_DIR
|
||||
const originalPath = process.env.PATH
|
||||
|
||||
afterEach(() => {
|
||||
if (resourceName === undefined) delete process.env.AZURE_RESOURCE_NAME
|
||||
else process.env.AZURE_RESOURCE_NAME = resourceName
|
||||
if (resourceGroup === undefined) delete process.env.AZURE_RESOURCE_GROUP
|
||||
else process.env.AZURE_RESOURCE_GROUP = resourceGroup
|
||||
if (azureConfig === undefined) delete process.env.AZURE_CONFIG_DIR
|
||||
else process.env.AZURE_CONFIG_DIR = azureConfig
|
||||
if (originalPath === undefined) delete process.env.PATH
|
||||
else process.env.PATH = originalPath
|
||||
})
|
||||
|
|
@ -64,37 +58,6 @@ function customFetch(options: Record<string, unknown>) {
|
|||
}
|
||||
}
|
||||
|
||||
function models(...ids: string[]): Provider["models"] {
|
||||
return Object.fromEntries(
|
||||
ids.map((id) => [
|
||||
id,
|
||||
{
|
||||
id,
|
||||
providerID: "azure",
|
||||
name: id,
|
||||
family: "",
|
||||
api: { id, url: "", npm: "@ai-sdk/azure" },
|
||||
status: "active",
|
||||
headers: {},
|
||||
options: {},
|
||||
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
limit: { context: 0, output: 0 },
|
||||
capabilities: {
|
||||
temperature: true,
|
||||
reasoning: false,
|
||||
attachment: false,
|
||||
toolcall: true,
|
||||
input: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
interleaved: false,
|
||||
},
|
||||
release_date: "",
|
||||
variants: {},
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
function azureShell(scopes: string[]) {
|
||||
return async (args: string[]) => {
|
||||
const scope = args[args.indexOf("--scope") + 1]
|
||||
|
|
@ -106,14 +69,6 @@ function azureShell(scopes: string[]) {
|
|||
}
|
||||
}
|
||||
|
||||
function discoveryShell(accounts: unknown, deployments: unknown, commands: string[]) {
|
||||
return async (args: string[]) => {
|
||||
const command = ["az", ...args].join(" ")
|
||||
commands.push(command)
|
||||
return command.includes("deployment list") ? deployments : accounts
|
||||
}
|
||||
}
|
||||
|
||||
async function azureCli(dir: string) {
|
||||
const bin = path.join(dir, "azure cli")
|
||||
const calls = path.join(dir, "calls.jsonl")
|
||||
|
|
@ -127,7 +82,7 @@ async function azureCli(dir: string) {
|
|||
fs.appendFileSync(${JSON.stringify(calls)}, JSON.stringify(args) + "\\n")
|
||||
console.log(JSON.stringify(args.includes("get-access-token")
|
||||
? { accessToken: "test-token", expires_on: Math.floor(Date.now() / 1000) + 3600 }
|
||||
: args.includes("deployment") ? [] : [{ name: "test-resource", resourceGroup: "test group & value" }]))
|
||||
: []))
|
||||
`,
|
||||
)
|
||||
const executable = path.join(bin, process.platform === "win32" ? "az.cmd" : "az")
|
||||
|
|
@ -162,7 +117,6 @@ describe("plugin.azure", () => {
|
|||
const entry = path.join(tmp.path, "azure.mjs")
|
||||
await Bun.write(entry, bundle.outputs[0])
|
||||
const cli = await azureCli(tmp.path)
|
||||
await Bun.write(path.join(tmp.path, "azureProfile.json"), '\uFEFF{"subscriptions":[{}]}')
|
||||
for (const installed of [false, true]) {
|
||||
const result = await Process.run(
|
||||
[
|
||||
|
|
@ -174,23 +128,21 @@ describe("plugin.azure", () => {
|
|||
import { AzureAuthPlugin } from ${JSON.stringify(pathToFileURL(entry).href)}
|
||||
assert.equal(typeof Bun, "undefined")
|
||||
delete process.env.AZURE_RESOURCE_NAME
|
||||
delete process.env.AZURE_RESOURCE_GROUP
|
||||
const hooks = await AzureAuthPlugin({ $: undefined })
|
||||
assert.equal(hooks.auth.provider, "azure")
|
||||
assert.deepEqual(hooks.auth.methods.map((method) => method.type), ${JSON.stringify(installed ? ["api", "oauth"] : ["api"])})
|
||||
if (${installed}) {
|
||||
const method = hooks.auth.methods.find((method) => method.type === "oauth")
|
||||
assert.equal(method.prompts[0].type, "select")
|
||||
const authorization = await method.authorize({ resourceSelection: "test-resource" })
|
||||
assert.equal(method.prompts[0].type, "text")
|
||||
const authorization = await method.authorize({ resourceName: "test-resource" })
|
||||
const auth = await authorization.callback()
|
||||
assert.equal(auth.type, "success")
|
||||
assert.equal(auth.accountId, "test-resource")
|
||||
assert.deepEqual(await hooks.provider.models({ models: {} }, { auth: { ...auth, type: "oauth" } }), {})
|
||||
}
|
||||
`,
|
||||
],
|
||||
{
|
||||
env: { PATH: installed ? cli.bin : tmp.path, XDG_DATA_HOME: tmp.path, AZURE_CONFIG_DIR: tmp.path },
|
||||
env: { PATH: installed ? cli.bin : tmp.path, XDG_DATA_HOME: tmp.path },
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
|
|
@ -198,53 +150,27 @@ describe("plugin.azure", () => {
|
|||
expect(result.code).toBe(0)
|
||||
}
|
||||
expect(await cli.calls()).toEqual([
|
||||
["cognitiveservices", "account", "list", "--output", "json", "--only-show-errors"],
|
||||
["account", "get-access-token", "--scope", "https://cognitiveservices.azure.com/.default", "--output", "json"],
|
||||
["cognitiveservices", "account", "list", "--output", "json", "--only-show-errors"],
|
||||
[
|
||||
"cognitiveservices",
|
||||
"account",
|
||||
"deployment",
|
||||
"list",
|
||||
"--name",
|
||||
"test-resource",
|
||||
"--resource-group",
|
||||
"test group & value",
|
||||
"--output",
|
||||
"json",
|
||||
"--only-show-errors",
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
for (const profile of [
|
||||
{ name: "missing", content: undefined, signedIn: false },
|
||||
{ name: "logged out", content: '{"subscriptions":[]}', signedIn: false },
|
||||
{ name: "signed in with BOM", content: '\uFEFF{"subscriptions":[{}]}', signedIn: true },
|
||||
]) {
|
||||
test(`only lists resources for a cached Azure login (${profile.name})`, async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const cli = await azureCli(tmp.path)
|
||||
process.env.PATH = cli.bin
|
||||
process.env.AZURE_CONFIG_DIR = path.join(tmp.path, "azure-cli")
|
||||
if (profile.content)
|
||||
await Bun.write(path.join(process.env.AZURE_CONFIG_DIR, "azureProfile.json"), profile.content)
|
||||
delete process.env.AZURE_RESOURCE_NAME
|
||||
delete process.env.AZURE_RESOURCE_GROUP
|
||||
const hooks = await AzureAuthPlugin()
|
||||
test("does not invoke Azure CLI during initialization", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const cli = await azureCli(tmp.path)
|
||||
process.env.PATH = cli.bin
|
||||
delete process.env.AZURE_RESOURCE_NAME
|
||||
|
||||
expect(await cli.calls()).toHaveLength(profile.signedIn ? 1 : 0)
|
||||
expect(hooks.auth?.methods.some((method) => method.type === "oauth")).toBe(true)
|
||||
if (profile.signedIn) expect(oauthMethod(hooks).prompts?.[0].type).toBe("select")
|
||||
})
|
||||
}
|
||||
const hooks = await AzureAuthPlugin()
|
||||
|
||||
expect(await cli.calls()).toEqual([])
|
||||
expect(oauthMethod(hooks).prompts?.[0].type).toBe("text")
|
||||
})
|
||||
|
||||
test("keeps the existing API-key method and adds Entra ID", () => {
|
||||
delete process.env.AZURE_RESOURCE_NAME
|
||||
const hooks = createAzureAuthHooks(azureShell([]), fetch, [], true)
|
||||
const hooks = createAzureAuthHooks(azureShell([]), fetch, true)
|
||||
|
||||
expect(hooks.auth?.provider).toBe("azure")
|
||||
expect(hooks.provider?.id).toBe("azure")
|
||||
expect(hooks.auth?.methods.map((method) => [method.type, method.label])).toEqual([
|
||||
["api", "API key"],
|
||||
["oauth", "Microsoft Entra ID (Azure CLI)"],
|
||||
|
|
@ -265,76 +191,14 @@ describe("plugin.azure", () => {
|
|||
})
|
||||
|
||||
test("hides Azure CLI authentication when the Azure CLI is not installed", () => {
|
||||
const hooks = createAzureAuthHooks(azureShell([]), fetch, [], false)
|
||||
const hooks = createAzureAuthHooks(azureShell([]), fetch, false)
|
||||
|
||||
expect(hooks.auth?.methods.map((method) => method.type)).toEqual(["api"])
|
||||
})
|
||||
|
||||
test("lists Azure CLI resources and allows entering another resource", () => {
|
||||
delete process.env.AZURE_RESOURCE_NAME
|
||||
const hooks = createAzureAuthHooks(
|
||||
azureShell([]),
|
||||
fetch,
|
||||
[
|
||||
{ name: "first-resource", resourceGroup: "first-group" },
|
||||
{ name: "second-resource", resourceGroup: "second-group" },
|
||||
],
|
||||
true,
|
||||
)
|
||||
|
||||
expect(oauthMethod(hooks).prompts).toEqual([
|
||||
{
|
||||
type: "select",
|
||||
key: "resourceSelection",
|
||||
message: "Select Azure resource",
|
||||
options: [
|
||||
{ label: "first-resource", value: "first-resource", hint: "first-group" },
|
||||
{ label: "second-resource", value: "second-resource", hint: "second-group" },
|
||||
{ label: "Enter another resource name", value: "__manual__" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
key: "resourceName",
|
||||
message: "Enter Azure Resource Name",
|
||||
placeholder: "e.g. my-models",
|
||||
when: { key: "resourceSelection", op: "eq", value: "__manual__" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("uses the selected Azure CLI resource", async () => {
|
||||
const hooks = createAzureAuthHooks(
|
||||
azureShell([]),
|
||||
fetch,
|
||||
[{ name: "selected-resource", resourceGroup: "selected-group" }],
|
||||
true,
|
||||
)
|
||||
const authorization = await oauthMethod(hooks).authorize({ resourceSelection: "selected-resource" })
|
||||
if (authorization.method !== "auto") throw new Error("Unexpected Azure authorization method")
|
||||
|
||||
expect(await authorization.callback()).toMatchObject({ type: "success", accountId: "selected-resource" })
|
||||
})
|
||||
|
||||
test("uses a manually entered Azure resource that was not listed", async () => {
|
||||
const hooks = createAzureAuthHooks(
|
||||
azureShell([]),
|
||||
fetch,
|
||||
[{ name: "listed-resource", resourceGroup: "group" }],
|
||||
true,
|
||||
)
|
||||
const authorization = await oauthMethod(hooks).authorize({
|
||||
resourceSelection: "__manual__",
|
||||
resourceName: "unlisted-resource",
|
||||
})
|
||||
if (authorization.method !== "auto") throw new Error("Unexpected Azure authorization method")
|
||||
|
||||
expect(await authorization.callback()).toMatchObject({ type: "success", accountId: "unlisted-resource" })
|
||||
})
|
||||
|
||||
test("checks Azure CLI and stores the resource name", async () => {
|
||||
const scopes: string[] = []
|
||||
const hooks = createAzureAuthHooks(azureShell(scopes), fetch, [], true)
|
||||
const hooks = createAzureAuthHooks(azureShell(scopes), fetch, true)
|
||||
const authorization = await oauthMethod(hooks).authorize({ resourceName: "test-resource" })
|
||||
if (authorization.method !== "auto") throw new Error("Unexpected Azure authorization method")
|
||||
|
||||
|
|
@ -354,7 +218,6 @@ describe("plugin.azure", () => {
|
|||
expiresOn: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
fetch,
|
||||
[],
|
||||
true,
|
||||
)
|
||||
const authorization = await oauthMethod(hooks).authorize({ resourceName: "test-resource" })
|
||||
|
|
@ -364,158 +227,18 @@ describe("plugin.azure", () => {
|
|||
})
|
||||
|
||||
test("rejects Azure CLI tokens without a usable expiration", async () => {
|
||||
const hooks = createAzureAuthHooks(async () => ({ accessToken: "invalid-token" }), fetch, [], true)
|
||||
const hooks = createAzureAuthHooks(async () => ({ accessToken: "invalid-token" }), fetch, true)
|
||||
const authorization = await oauthMethod(hooks).authorize({ resourceName: "test-resource" })
|
||||
if (authorization.method !== "auto") throw new Error("Unexpected Azure authorization method")
|
||||
|
||||
await expect(authorization.callback()).rejects.toThrow("Azure CLI returned an invalid token expiration")
|
||||
})
|
||||
|
||||
test("discovers deployed models through Azure CLI", async () => {
|
||||
delete process.env.AZURE_RESOURCE_GROUP
|
||||
const commands: string[] = []
|
||||
const hooks = createAzureAuthHooks(
|
||||
discoveryShell(
|
||||
[{ name: "test-resource", resourceGroup: "test-group" }],
|
||||
[
|
||||
{
|
||||
name: "gpt-production",
|
||||
properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" },
|
||||
},
|
||||
{
|
||||
name: "DeepSeek-V4-Flash",
|
||||
properties: { model: { name: "DeepSeek-V4-Flash" }, provisioningState: "Succeeded" },
|
||||
},
|
||||
{
|
||||
name: "phi-production",
|
||||
properties: { model: { name: "Phi-4-mini-instruct" }, provisioningState: "Succeeded" },
|
||||
},
|
||||
{
|
||||
name: "gpt-5-nano",
|
||||
properties: { model: { name: "gpt-5-nano" }, provisioningState: "Creating" },
|
||||
},
|
||||
],
|
||||
commands,
|
||||
),
|
||||
fetch,
|
||||
[],
|
||||
true,
|
||||
)
|
||||
const list = hooks.provider?.models
|
||||
if (!list) throw new Error("Azure provider model hook is missing")
|
||||
|
||||
const result = await list(
|
||||
{
|
||||
...provider,
|
||||
models: models("gpt-5-mini", "deepseek-v4-flash", "phi-4-mini", "phi-4-mini-instruct", "gpt-5-nano"),
|
||||
},
|
||||
{ auth: oauth },
|
||||
)
|
||||
|
||||
expect(Object.keys(result)).toEqual(["gpt-5-mini", "deepseek-v4-flash", "phi-4-mini-instruct"])
|
||||
expect(result["gpt-5-mini"].api.id).toBe("gpt-production")
|
||||
expect(result["deepseek-v4-flash"].api.id).toBe("DeepSeek-V4-Flash")
|
||||
expect(result["phi-4-mini-instruct"].api.id).toBe("phi-production")
|
||||
expect(commands).toEqual([
|
||||
"az cognitiveservices account list --output json --only-show-errors",
|
||||
"az cognitiveservices account deployment list --name test-resource --resource-group test-group --output json --only-show-errors",
|
||||
])
|
||||
})
|
||||
|
||||
test("discovers models directly when the resource group is configured", async () => {
|
||||
process.env.AZURE_RESOURCE_GROUP = "restricted-group"
|
||||
const commands: string[] = []
|
||||
const hooks = createAzureAuthHooks(
|
||||
discoveryShell(
|
||||
[],
|
||||
[{ name: "gpt-production", properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" } }],
|
||||
commands,
|
||||
),
|
||||
fetch,
|
||||
[],
|
||||
true,
|
||||
)
|
||||
const list = hooks.provider?.models
|
||||
if (!list) throw new Error("Azure provider model hook is missing")
|
||||
|
||||
const result = await list({ ...provider, models: models("gpt-5-mini") }, { auth: oauth })
|
||||
|
||||
expect(result["gpt-5-mini"].api.id).toBe("gpt-production")
|
||||
expect(commands).toEqual([
|
||||
"az cognitiveservices account deployment list --name test-resource --resource-group restricted-group --output json --only-show-errors",
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves multiple deployments of the same model", async () => {
|
||||
delete process.env.AZURE_RESOURCE_GROUP
|
||||
const hooks = createAzureAuthHooks(
|
||||
discoveryShell(
|
||||
[{ name: "test-resource", resourceGroup: "test-group" }],
|
||||
[
|
||||
{ name: "gpt-production", properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" } },
|
||||
{ name: "gpt-staging", properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" } },
|
||||
],
|
||||
[],
|
||||
),
|
||||
fetch,
|
||||
[],
|
||||
true,
|
||||
)
|
||||
const list = hooks.provider?.models
|
||||
if (!list) throw new Error("Azure provider model hook is missing")
|
||||
|
||||
const result = await list({ ...provider, models: models("gpt-5-mini") }, { auth: oauth })
|
||||
|
||||
expect(Object.keys(result)).toEqual(["gpt-5-mini", "gpt-staging"])
|
||||
expect(result["gpt-5-mini"].api.id).toBe("gpt-production")
|
||||
expect(result["gpt-staging"].api.id).toBe("gpt-staging")
|
||||
expect(result["gpt-staging"].name).toBe("gpt-5-mini (gpt-staging)")
|
||||
})
|
||||
|
||||
test("keeps configured models available when Azure discovery fails", async () => {
|
||||
const hooks = createAzureAuthHooks(
|
||||
async () => {
|
||||
throw new Error("Azure CLI failed")
|
||||
},
|
||||
fetch,
|
||||
[],
|
||||
true,
|
||||
)
|
||||
const list = hooks.provider?.models
|
||||
if (!list) throw new Error("Azure provider model hook is missing")
|
||||
|
||||
const catalog = models("gpt-5-mini")
|
||||
expect(await list({ ...provider, models: catalog }, { auth: oauth })).toBe(catalog)
|
||||
})
|
||||
|
||||
test("skips model discovery when the Azure CLI is unavailable", async () => {
|
||||
const calls: string[][] = []
|
||||
const hooks = createAzureAuthHooks(
|
||||
async (args) => {
|
||||
calls.push(args)
|
||||
throw new Error("spawn az ENOENT")
|
||||
},
|
||||
fetch,
|
||||
[],
|
||||
false,
|
||||
)
|
||||
const list = hooks.provider?.models
|
||||
if (!list) throw new Error("Azure provider model hook is missing")
|
||||
|
||||
const catalog = models("gpt-5-mini")
|
||||
expect(await list({ ...provider, models: catalog }, { auth: oauth })).toBe(catalog)
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
|
||||
test("does not change API-key loading", async () => {
|
||||
const scopes: string[] = []
|
||||
const hooks = createAzureAuthHooks(azureShell(scopes), fetch, [], true)
|
||||
const catalog = models("gpt-5-mini")
|
||||
const list = hooks.provider?.models
|
||||
if (!list) throw new Error("Azure provider model hook is missing")
|
||||
const hooks = createAzureAuthHooks(azureShell(scopes), fetch, true)
|
||||
|
||||
expect(await loader(hooks)(async () => ({ type: "api", key: "test-key" }), provider)).toEqual({})
|
||||
expect(await list({ ...provider, models: catalog }, { auth: { type: "api", key: "test-key" } })).toBe(catalog)
|
||||
expect(scopes).toEqual([])
|
||||
})
|
||||
|
||||
|
|
@ -528,7 +251,6 @@ describe("plugin.azure", () => {
|
|||
requests.push(new Headers(init?.headers))
|
||||
return new Response(null, { status: 200 })
|
||||
},
|
||||
[],
|
||||
true,
|
||||
)
|
||||
const options = await loader(hooks)(async () => oauth, provider)
|
||||
|
|
|
|||
|
|
@ -459,7 +459,7 @@ If you encounter "I'm sorry, but I cannot assist with that request" errors, try
|
|||
|
||||
#### Microsoft Entra ID (Azure CLI)
|
||||
|
||||
You can use your Azure CLI session instead of an API key. [Install the Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli), run `az login`, then run `/connect`, select **Azure**, and choose **Microsoft Entra ID (Azure CLI)**. OpenCode lists the Resources visible to your Azure CLI session and their Resource groups. Select a Resource, or choose **Enter another resource name** to enter one manually. If resource listing is unavailable, OpenCode asks for the name directly. Use `az login --tenant TENANT_ID` if the Resource belongs to a different tenant.
|
||||
You can use your Azure CLI session instead of an API key. [Install the Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli), run `az login`, then run `/connect`, select **Azure**, and choose **Microsoft Entra ID (Azure CLI)**. Enter the Azure Resource name when prompted. Use `az login --tenant TENANT_ID` if the Resource belongs to a different tenant.
|
||||
|
||||
Find the Resource name by opening your Azure OpenAI or Foundry Resource in the [Azure portal](https://portal.azure.com/) or [Microsoft Foundry](https://ai.azure.com/). 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, you can also find their names and Resource groups with:
|
||||
|
||||
|
|
@ -469,9 +469,7 @@ az cognitiveservices account list \
|
|||
--output table
|
||||
```
|
||||
|
||||
OpenCode finds the Resource group and discovers its deployed models from the active Azure CLI subscription. Run `az account set --subscription NAME_OR_ID` first if the Resource is in a different subscription. Set `AZURE_RESOURCE_GROUP` to skip listing the subscription and query a known Resource directly.
|
||||
|
||||
Model discovery requires Azure control-plane permissions, which are separate from inference permissions. If your identity cannot list deployments, OpenCode keeps the Azure model catalog available instead. Select a model whose name matches your deployment, or configure its deployment name explicitly:
|
||||
OpenCode does not query Azure management APIs or discover deployments. Select a model whose catalog name matches your deployment, or configure its deployment name explicitly:
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue