Compare commits

...
6 Commits
4 changed files with 642 additions and 2 deletions
+214 -2
View File
@@ -1,6 +1,85 @@
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import type { Provider } from "@opencode-ai/sdk/v2"
import { Effect, Schema } from "effect"
import { OAUTH_DUMMY_KEY } from "../auth"
const AZURE_COGNITIVE_SERVICES_SCOPE = "https://cognitiveservices.azure.com/.default"
const AZURE_FOUNDRY_SCOPE = "https://ai.azure.com/.default"
const AZURE_TOKEN_REFRESH_BUFFER = 60_000
const AzureCliToken = Schema.Struct({
accessToken: Schema.NonEmptyString,
expires_on: Schema.optional(Schema.Number),
expiresOn: Schema.optional(Schema.NonEmptyString),
})
const decodeAzureCliToken = Schema.decodeUnknownPromise(AzureCliToken)
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 = {
quiet(): AzureCommand
json(): Promise<unknown>
}
type AzureShell = (strings: TemplateStringsArray, ...values: string[]) => AzureCommand
type AzureAccount = { readonly name: string; readonly resourceGroup: string }
export async function AzureAuthPlugin(input: PluginInput): Promise<Hooks> {
const available = Boolean(Bun.which("az", { PATH: process.env.PATH }))
const accounts =
!process.env.AZURE_RESOURCE_NAME && !process.env.AZURE_RESOURCE_GROUP && available
? await input.$`az cognitiveservices account list --output json --only-show-errors`
.quiet()
.json()
.then(decodeAzureAccounts)
.catch(() => [])
: []
return createAzureAuthHooks(input.$, fetch, accounts, available)
}
export function createAzureAuthHooks(
shell: AzureShell,
request: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> = fetch,
accounts: readonly AzureAccount[] = [],
available = true,
): Hooks {
const tokens = new Map<string, { token: string; expires: number }>()
async function token(scope: string) {
const cached = tokens.get(scope)
if (cached && cached.expires - Date.now() > AZURE_TOKEN_REFRESH_BUFFER) return cached.token
const result = await decodeAzureCliToken(
await shell`az account get-access-token --scope ${scope} --output json`.quiet().json(),
)
const expires = result.expires_on !== undefined ? result.expires_on * 1000 : Date.parse(result.expiresOn ?? "")
if (!Number.isFinite(expires)) throw new Error("Azure CLI returned an invalid token expiration")
const refreshed = { token: result.accessToken, expires }
tokens.set(scope, refreshed)
return refreshed.token
}
export async function AzureAuthPlugin(_input: PluginInput): Promise<Hooks> {
const prompts = []
if (!process.env.AZURE_RESOURCE_NAME) {
prompts.push({
@@ -10,17 +89,150 @@ export async function AzureAuthPlugin(_input: PluginInput): Promise<Hooks> {
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
return {
const hooks: Hooks = {
provider: {
id: "azure",
async models(provider, context) {
if (context.auth?.type !== "oauth") return provider.models
const resource = context.auth.accountId
if (!resource) return {}
return discoverAzureModels(provider.models, resource, shell).catch((error: unknown) => {
Effect.runSync(
Effect.logWarning("Azure model discovery failed", {
resource,
error: error instanceof Error ? error.message : String(error),
}),
)
return provider.models
})
},
},
auth: {
provider: "azure",
async loader(getAuth) {
if ((await getAuth()).type !== "oauth") return {}
return {
apiKey: OAUTH_DUMMY_KEY,
async fetch(input: RequestInfo | URL, init?: RequestInit) {
const headers = new Headers(input instanceof Request ? input.headers : undefined)
new Headers(init?.headers).forEach((value, key) => headers.set(key, value))
headers.delete("api-key")
headers.delete("x-api-key")
headers.set("authorization", `Bearer ${await token(scopeForRequest(input))}`)
headers.set("User-Agent", `opencode/${InstallationVersion}`)
return request(input, { ...init, headers })
},
}
},
methods: [
{
type: "api",
label: "API key",
prompts,
},
{
type: "oauth",
label: "Microsoft Entra ID (Azure CLI)",
prompts: oauthPrompts,
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
if (!resourceName) throw new Error("Azure Resource Name is required")
await token(AZURE_COGNITIVE_SERVICES_SCOPE)
return {
type: "success",
access: OAUTH_DUMMY_KEY,
refresh: OAUTH_DUMMY_KEY,
expires: Date.now() + 365 * 24 * 60 * 60 * 1000,
accountId: resourceName,
}
},
}
},
},
],
},
}
if (!available && hooks.auth) hooks.auth.methods = hooks.auth.methods.filter((method) => method.type !== "oauth")
return hooks
}
async function discoverAzureModels(models: Provider["models"], resourceName: string, shell: AzureShell) {
const resourceGroup = process.env.AZURE_RESOURCE_GROUP
const account = resourceGroup
? { name: resourceName, resourceGroup }
: (
await decodeAzureAccounts(
await shell`az cognitiveservices account list --output json --only-show-errors`.quiet().json(),
)
).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 shell`az cognitiveservices account deployment list --name ${account.name} --resource-group ${account.resourceGroup} --output json --only-show-errors`
.quiet()
.json(),
)
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")) {
return AZURE_FOUNDRY_SCOPE
}
return AZURE_COGNITIVE_SERVICES_SCOPE
}
@@ -250,6 +250,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
return [
provider.options?.resourceName,
auth?.type === "api" ? auth.metadata?.resourceName : undefined,
auth?.type === "oauth" ? auth.accountId : undefined,
env["AZURE_RESOURCE_NAME"],
].find((name) => typeof name === "string" && name.trim() !== "")
})
+394
View File
@@ -0,0 +1,394 @@
import { afterEach, describe, expect, test } from "bun:test"
import type { Hooks } from "@opencode-ai/plugin"
import type { Auth, Provider } from "@opencode-ai/sdk/v2"
import { OAUTH_DUMMY_KEY } from "../../src/auth"
import { createAzureAuthHooks } from "../../src/plugin/azure"
const resourceName = process.env.AZURE_RESOURCE_NAME
const resourceGroup = process.env.AZURE_RESOURCE_GROUP
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
})
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[]) {
return (_strings: TemplateStringsArray, ...values: string[]) => {
const output = {
quiet: () => output,
json: async () => {
const scope = values[0]
scopes.push(scope)
return {
accessToken: `${scope}-token`,
expires_on: Math.floor((Date.now() + 60 * 60 * 1000) / 1000),
}
},
}
return output
}
}
function discoveryShell(accounts: unknown, deployments: unknown, commands: string[]) {
return (strings: TemplateStringsArray, ...values: string[]) => {
const command = String.raw(strings, ...values)
commands.push(command)
const output = {
quiet: () => output,
json: async () => (command.includes("deployment list") ? deployments : accounts),
}
return output
}
}
describe("plugin.azure", () => {
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 () => {
const hooks = createAzureAuthHooks(() => {
const output = {
quiet: () => output,
json: async () => ({
accessToken: "legacy-token",
expiresOn: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
}),
}
return output
})
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 () => {
const hooks = createAzureAuthHooks(() => {
const output = {
quiet: () => output,
json: async () => ({ accessToken: "invalid-token" }),
}
return output
})
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 () => {
const hooks = createAzureAuthHooks(() => {
const output = {
quiet: () => output,
json: async () => {
throw new Error("Azure CLI failed")
},
}
return output
})
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)
})
})
@@ -457,6 +457,39 @@ If you encounter "I'm sorry, but I cannot assist with that request" errors, try
/models
```
#### 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.
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:
```bash
az cognitiveservices account list \
--query "[].{name:name,resourceGroup:resourceGroup}" \
--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:
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"azure": {
"models": {
"gpt-5-mini": {
"id": "gpt-production"
}
}
}
}
}
```
Assign your identity the inference role required by the deployment: **Cognitive Services OpenAI User** for Azure OpenAI models or **Cognitive Services User** for other Foundry models. OpenCode refreshes access tokens through the Azure CLI, including versions earlier than 2.54.0, so you only need to sign in again when the CLI session expires.
---
### Azure Cognitive Services