2026-08-27 23:50:15 +00:00
|
|
|
import { afterEach, describe, expect, test } from "bun:test"
|
|
|
|
|
import { chmod } from "node:fs/promises"
|
|
|
|
|
import path from "node:path"
|
2026-08-28 05:38:25 +00:00
|
|
|
import { pathToFileURL } from "node:url"
|
2026-08-27 23:50:15 +00:00
|
|
|
import { tmpdir } from "../fixture/fixture"
|
|
|
|
|
import type { Hooks } from "@opencode-ai/plugin"
|
|
|
|
|
import type { Auth, Provider } from "@opencode-ai/sdk/v2"
|
|
|
|
|
import { OAUTH_DUMMY_KEY } from "../../src/auth"
|
|
|
|
|
import { AzureAuthPlugin, createAzureAuthHooks } from "../../src/plugin/azure"
|
2026-08-28 05:38:25 +00:00
|
|
|
import { Process } from "../../src/util/process"
|
|
|
|
|
import { which } from "@opencode-ai/core/util/which"
|
2026-08-27 23:50:15 +00:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const oauth: Auth = {
|
|
|
|
|
type: "oauth",
|
|
|
|
|
access: OAUTH_DUMMY_KEY,
|
|
|
|
|
refresh: OAUTH_DUMMY_KEY,
|
|
|
|
|
expires: Date.now() + 60 * 60 * 1000,
|
|
|
|
|
accountId: "test-resource",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const provider: Provider = {
|
|
|
|
|
id: "azure",
|
|
|
|
|
name: "Azure",
|
|
|
|
|
source: "custom",
|
|
|
|
|
env: [],
|
|
|
|
|
options: {},
|
|
|
|
|
models: {},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function oauthMethod(hooks: Hooks) {
|
|
|
|
|
const method = hooks.auth?.methods.find((method) => method.type === "oauth")
|
|
|
|
|
if (!method || method.type !== "oauth") throw new Error("Azure OAuth method is missing")
|
|
|
|
|
return method
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function loader(hooks: Hooks) {
|
|
|
|
|
if (!hooks.auth?.loader) throw new Error("Azure auth loader is missing")
|
|
|
|
|
return hooks.auth.loader
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function customFetch(options: Record<string, unknown>) {
|
|
|
|
|
const result = options["fetch"]
|
|
|
|
|
if (typeof result !== "function") throw new Error("Azure custom fetch is missing")
|
|
|
|
|
return async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
|
|
|
const response: unknown = await Reflect.apply(result, undefined, [input, init])
|
|
|
|
|
if (!(response instanceof Response)) throw new Error("Azure custom fetch returned an invalid response")
|
|
|
|
|
return response
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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[]) {
|
2026-08-28 05:38:25 +00:00
|
|
|
return async (args: string[]) => {
|
|
|
|
|
const scope = args[args.indexOf("--scope") + 1]
|
|
|
|
|
scopes.push(scope)
|
|
|
|
|
return {
|
|
|
|
|
accessToken: `${scope}-token`,
|
|
|
|
|
expires_on: Math.floor((Date.now() + 60 * 60 * 1000) / 1000),
|
2026-08-27 23:50:15 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function discoveryShell(accounts: unknown, deployments: unknown, commands: string[]) {
|
2026-08-28 05:38:25 +00:00
|
|
|
return async (args: string[]) => {
|
|
|
|
|
const command = ["az", ...args].join(" ")
|
2026-08-27 23:50:15 +00:00
|
|
|
commands.push(command)
|
2026-08-28 05:38:25 +00:00
|
|
|
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")
|
|
|
|
|
const script = path.join(bin, "cli.cjs")
|
|
|
|
|
await Bun.write(calls, "")
|
|
|
|
|
await Bun.write(
|
|
|
|
|
script,
|
|
|
|
|
`
|
|
|
|
|
const fs = require("node:fs")
|
|
|
|
|
const args = process.argv.slice(2)
|
|
|
|
|
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")
|
|
|
|
|
await Bun.write(
|
|
|
|
|
executable,
|
|
|
|
|
process.platform === "win32"
|
|
|
|
|
? `@"${process.execPath}" "${script}" %*\r\n`
|
|
|
|
|
: `#!/bin/sh\nexec '${process.execPath}' '${script}' "$@"\n`,
|
|
|
|
|
)
|
|
|
|
|
await chmod(executable, 0o755)
|
|
|
|
|
return {
|
|
|
|
|
bin,
|
|
|
|
|
calls: async () =>
|
|
|
|
|
(await Bun.file(calls).text())
|
|
|
|
|
.split("\n")
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.map((line) => JSON.parse(line)),
|
2026-08-27 23:50:15 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe("plugin.azure", () => {
|
2026-08-28 05:38:25 +00:00
|
|
|
test("initializes and runs Azure CLI under Node without Bun or a plugin shell", async () => {
|
|
|
|
|
await using tmp = await tmpdir()
|
|
|
|
|
const node = which("node")
|
|
|
|
|
if (!node) throw new Error("Node is required for the Azure runtime compatibility test")
|
|
|
|
|
const bundle = await Bun.build({
|
|
|
|
|
entrypoints: [path.join(import.meta.dir, "../../src/plugin/azure.ts")],
|
|
|
|
|
target: "node",
|
|
|
|
|
format: "esm",
|
|
|
|
|
})
|
|
|
|
|
expect(bundle.success).toBe(true)
|
|
|
|
|
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(
|
|
|
|
|
[
|
|
|
|
|
node,
|
|
|
|
|
"--input-type=module",
|
|
|
|
|
"-e",
|
|
|
|
|
`
|
|
|
|
|
import assert from "node:assert/strict"
|
|
|
|
|
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" })
|
|
|
|
|
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 },
|
|
|
|
|
nothrow: true,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
expect(result.stderr.toString()).toBe("")
|
|
|
|
|
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",
|
|
|
|
|
],
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-27 23:50:15 +00:00
|
|
|
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()
|
2026-08-28 05:38:25 +00:00
|
|
|
const cli = await azureCli(tmp.path)
|
|
|
|
|
process.env.PATH = cli.bin
|
2026-08-27 23:50:15 +00:00
|
|
|
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
|
2026-08-28 05:38:25 +00:00
|
|
|
const hooks = await AzureAuthPlugin()
|
2026-08-27 23:50:15 +00:00
|
|
|
|
2026-08-28 05:38:25 +00:00
|
|
|
expect(await cli.calls()).toHaveLength(profile.signedIn ? 1 : 0)
|
2026-08-27 23:50:15 +00:00
|
|
|
expect(hooks.auth?.methods.some((method) => method.type === "oauth")).toBe(true)
|
|
|
|
|
if (profile.signedIn) expect(oauthMethod(hooks).prompts?.[0].type).toBe("select")
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test("keeps the existing API-key method and adds Entra ID", () => {
|
|
|
|
|
delete process.env.AZURE_RESOURCE_NAME
|
|
|
|
|
const hooks = createAzureAuthHooks(azureShell([]))
|
|
|
|
|
|
|
|
|
|
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)"],
|
|
|
|
|
])
|
|
|
|
|
expect(hooks.auth?.methods[0]).toEqual({
|
|
|
|
|
type: "api",
|
|
|
|
|
label: "API key",
|
|
|
|
|
prompts: [
|
|
|
|
|
{
|
|
|
|
|
type: "text",
|
|
|
|
|
key: "resourceName",
|
|
|
|
|
message: "Enter Azure Resource Name",
|
|
|
|
|
placeholder: "e.g. my-models",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
})
|
|
|
|
|
expect(hooks.auth?.methods[1].prompts).toEqual(hooks.auth?.methods[0].prompts)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("hides Azure CLI authentication when the Azure CLI is not installed", () => {
|
|
|
|
|
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" },
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
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" },
|
|
|
|
|
])
|
|
|
|
|
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" }])
|
|
|
|
|
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))
|
|
|
|
|
const authorization = await oauthMethod(hooks).authorize({ resourceName: "test-resource" })
|
|
|
|
|
if (authorization.method !== "auto") throw new Error("Unexpected Azure authorization method")
|
|
|
|
|
|
|
|
|
|
expect(await authorization.callback()).toMatchObject({
|
|
|
|
|
type: "success",
|
|
|
|
|
access: OAUTH_DUMMY_KEY,
|
|
|
|
|
refresh: OAUTH_DUMMY_KEY,
|
|
|
|
|
accountId: "test-resource",
|
|
|
|
|
})
|
|
|
|
|
expect(scopes).toEqual(["https://cognitiveservices.azure.com/.default"])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("supports Azure CLI versions that only provide expiresOn", async () => {
|
2026-08-28 05:38:25 +00:00
|
|
|
const hooks = createAzureAuthHooks(async () => ({
|
|
|
|
|
accessToken: "legacy-token",
|
|
|
|
|
expiresOn: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
|
|
|
|
}))
|
2026-08-27 23:50:15 +00:00
|
|
|
const authorization = await oauthMethod(hooks).authorize({ resourceName: "test-resource" })
|
|
|
|
|
if (authorization.method !== "auto") throw new Error("Unexpected Azure authorization method")
|
|
|
|
|
|
|
|
|
|
expect(await authorization.callback()).toMatchObject({ type: "success", accountId: "test-resource" })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("rejects Azure CLI tokens without a usable expiration", async () => {
|
2026-08-28 05:38:25 +00:00
|
|
|
const hooks = createAzureAuthHooks(async () => ({ accessToken: "invalid-token" }))
|
2026-08-27 23:50:15 +00:00
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
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" } },
|
|
|
|
|
],
|
|
|
|
|
[],
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
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 () => {
|
2026-08-28 05:38:25 +00:00
|
|
|
const hooks = createAzureAuthHooks(async () => {
|
|
|
|
|
throw new Error("Azure CLI failed")
|
2026-08-27 23:50:15 +00:00
|
|
|
})
|
|
|
|
|
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("does not change API-key loading", async () => {
|
|
|
|
|
const scopes: string[] = []
|
|
|
|
|
const hooks = createAzureAuthHooks(azureShell(scopes))
|
|
|
|
|
const catalog = models("gpt-5-mini")
|
|
|
|
|
const list = hooks.provider?.models
|
|
|
|
|
if (!list) throw new Error("Azure provider model hook is missing")
|
|
|
|
|
|
|
|
|
|
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([])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("uses Azure CLI bearer tokens for Azure inference endpoints", async () => {
|
|
|
|
|
const scopes: string[] = []
|
|
|
|
|
const requests: Headers[] = []
|
|
|
|
|
const hooks = createAzureAuthHooks(azureShell(scopes), async (_input, init) => {
|
|
|
|
|
requests.push(new Headers(init?.headers))
|
|
|
|
|
return new Response(null, { status: 200 })
|
|
|
|
|
})
|
|
|
|
|
const options = await loader(hooks)(async () => oauth, provider)
|
|
|
|
|
const request = customFetch(options)
|
|
|
|
|
|
|
|
|
|
await request("https://test-resource.openai.azure.com/openai/v1/responses", {
|
|
|
|
|
headers: { "api-key": OAUTH_DUMMY_KEY, "x-keep": "yes" },
|
|
|
|
|
})
|
|
|
|
|
await request("https://test-resource.services.ai.azure.com/models/chat/completions", {
|
|
|
|
|
headers: { Authorization: `Bearer ${OAUTH_DUMMY_KEY}` },
|
|
|
|
|
})
|
|
|
|
|
await request("https://test-resource.services.ai.azure.com/anthropic/v1/messages", {
|
|
|
|
|
headers: { "x-api-key": OAUTH_DUMMY_KEY },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(scopes).toEqual(["https://cognitiveservices.azure.com/.default", "https://ai.azure.com/.default"])
|
|
|
|
|
expect(requests.map((headers) => headers.get("authorization"))).toEqual([
|
|
|
|
|
"Bearer https://cognitiveservices.azure.com/.default-token",
|
|
|
|
|
"Bearer https://cognitiveservices.azure.com/.default-token",
|
|
|
|
|
"Bearer https://ai.azure.com/.default-token",
|
|
|
|
|
])
|
|
|
|
|
expect(requests[0].get("api-key")).toBeNull()
|
|
|
|
|
expect(requests[0].get("x-keep")).toBe("yes")
|
|
|
|
|
expect(requests[2].get("x-api-key")).toBeNull()
|
|
|
|
|
expect(requests.every((headers) => headers.get("user-agent")?.startsWith("opencode/"))).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
})
|