remove overengineering

This commit is contained in:
Filip Hejmowski
2026-08-17 17:52:14 +02:00
parent 8c39e13b77
commit 95f098a721
9 changed files with 299 additions and 1335 deletions
+115
View File
@@ -0,0 +1,115 @@
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { 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.Number,
})
const decodeAzureCliToken = Schema.decodeUnknownPromise(AzureCliToken)
type AzureCommand = {
quiet(): AzureCommand
json(): Promise<unknown>
}
type AzureShell = (strings: TemplateStringsArray, scope: string) => AzureCommand
export async function AzureAuthPlugin(input: PluginInput): Promise<Hooks> {
return createAzureAuthHooks(input.$)
}
export function createAzureAuthHooks(
shell: AzureShell,
request: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> = fetch,
): 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 token = { token: result.accessToken, expires: result.expires_on * 1000 }
tokens.set(scope, token)
return token.token
}
const prompts = []
if (!process.env.AZURE_RESOURCE_NAME) {
prompts.push({
type: "text" as const,
key: "resourceName",
message: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
})
}
return {
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,
async authorize(inputs) {
return {
url: "",
instructions: "Sign in with `az login` before continuing.",
method: "auto",
callback: async () => {
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)
return {
type: "success",
access: OAUTH_DUMMY_KEY,
refresh: OAUTH_DUMMY_KEY,
expires: Date.now() + 365 * 24 * 60 * 60 * 1000,
accountId: resourceName,
}
},
}
},
},
],
},
}
}
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
}
-282
View File
@@ -1,282 +0,0 @@
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import type { Hooks } from "@opencode-ai/plugin"
import { Option, Predicate, Schema } from "effect"
import { OAUTH_DUMMY_KEY } from "../../auth"
import { azureConnection, foundryProjectEndpoint } from "./schema"
export const AZURE_COGNITIVE_SERVICES_SCOPE = "https://cognitiveservices.azure.com/.default"
export const AZURE_FOUNDRY_SCOPE = "https://ai.azure.com/.default"
export const AZURE_RESOURCE_MANAGER_SCOPE = "https://management.azure.com/.default"
export const AZURE_FOUNDRY_PROJECT_ENDPOINT_ENV = "AZURE_AI_PROJECT_ENDPOINT"
export const AZURE_RESOURCE_ID_ENV = "AZURE_RESOURCE_ID"
export const AZURE_RESOURCE_NAME_ENV = "AZURE_RESOURCE_NAME"
const AZURE_TOKEN_REFRESH_BUFFER = 60_000
class AzureCliToken extends Schema.Class<AzureCliToken>("AzureCliToken")({
accessToken: Schema.NonEmptyString,
expires_on: Schema.optionalKey(Schema.Number),
expiresOn: Schema.optionalKey(Schema.String),
subscription: Schema.optionalKey(Schema.NullOr(Schema.String.check(Schema.isUUID()))),
}) {}
const decodeAzureCliToken = Schema.decodeUnknownOption(Schema.fromJsonString(AzureCliToken))
type AzureCliCommandResult = {
stdout: string
stderr: string
exitCode: number
}
type AzureCliCommand = (scope: string, signal?: AbortSignal) => Promise<AzureCliCommandResult>
export type AzureRequest = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
export type AzureAccessToken = {
token: string
subscription?: string
}
export type AzureAuthPluginOptions = {
tokenCommand?: AzureCliCommand
request?: AzureRequest
}
export function createAzureAuth(options: AzureAuthPluginOptions = {}) {
const credential = azureCliTokenProvider(options.tokenCommand ?? runAzureCliTokenCommand)
const token = async (scope: string, signal?: AbortSignal) => (await credential(scope, signal)).token
const request = options.request ?? fetch
const configured = connectionFromEnvironment() !== undefined
return {
credential,
token,
request,
auth: {
provider: "azure",
async loader(getAuth) {
const auth = await getAuth()
if (auth.type !== "oauth") return {}
return {
apiKey: OAUTH_DUMMY_KEY,
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
const currentAuth = await getAuth()
if (currentAuth.type !== "oauth") return request(requestInput, init)
const scope = scopeForRequest(requestInput)
if (!scope) throw new Error("Azure OAuth only supports Azure HTTPS endpoints")
const headers = new Headers(requestInput instanceof Request ? requestInput.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(scope)}`)
headers.set("User-Agent", `opencode/${InstallationVersion}`)
return request(requestInput, { ...init, headers })
},
}
},
methods: [
{
type: "api",
label: "API key",
prompts: configured ? [] : apiPrompts(),
},
{
type: "oauth",
label: "Microsoft Entra ID (Azure CLI)",
prompts: configured ? [] : oauthPrompts(),
authorize: async (inputs) => ({
// Azure CLI owns the interactive sign-in, so OpenCode has no authorization URL to open.
url: "",
instructions:
"Sign in with `az login`. Assign Cognitive Services OpenAI User for Azure OpenAI resources or Foundry User for Foundry resources.",
method: "auto",
callback: async () => {
const input = inputs?.connection ?? inputs?.projectEndpoint ?? inputs?.resourceID ?? inputs?.resourceName
const connection = input ? azureConnection(input) : connectionFromEnvironment()
if (!connection) {
throw new Error("Enter an Azure Resource Name, full Resource ID, or Foundry Project endpoint")
}
await token(connection.projectEndpoint ? AZURE_FOUNDRY_SCOPE : AZURE_RESOURCE_MANAGER_SCOPE)
return {
type: "success",
access: OAUTH_DUMMY_KEY,
refresh: OAUTH_DUMMY_KEY,
expires: Date.now() + 365 * 24 * 60 * 60 * 1000,
// OAuth exposes accountId as the durable discriminator for connection-specific credentials.
accountId: connection.projectEndpoint ?? connection.resourceID ?? connection.resourceName,
}
},
}),
},
],
} satisfies NonNullable<Hooks["auth"]>,
}
}
function apiPrompts(): NonNullable<Hooks["auth"]>["methods"][number]["prompts"] {
return [
{
type: "select",
key: "connectionType",
message: "Select Azure connection type",
options: [
{
label: "Foundry Project endpoint",
value: "projectEndpoint",
hint: "For a Microsoft Foundry project",
},
{
label: "Azure Resource name",
value: "resourceName",
hint: "For a standalone Azure OpenAI resource",
},
{
label: "Azure Resource ID",
value: "resourceID",
hint: "Full ARM resource ID",
},
],
},
{
type: "text",
key: "projectEndpoint",
message: "Enter Foundry Project endpoint",
placeholder: "https://RESOURCE.services.ai.azure.com/api/projects/PROJECT",
when: { key: "connectionType", op: "eq", value: "projectEndpoint" },
validate: (value: string) =>
foundryProjectEndpoint(value)
? undefined
: "Enter a Project endpoint like https://RESOURCE.services.ai.azure.com/api/projects/PROJECT",
},
{
type: "text",
key: "resourceName",
message: "Enter Azure Resource name",
placeholder: "my-resource",
when: { key: "connectionType", op: "eq", value: "resourceName" },
validate: (value: string) => {
const connection = azureConnection(value)
return connection && !connection.resourceID && !connection.projectEndpoint
? undefined
: "Enter a Resource name like my-resource"
},
},
{
type: "text",
key: "resourceID",
message: "Enter Azure Resource ID",
placeholder: "/subscriptions/.../providers/Microsoft.CognitiveServices/accounts/RESOURCE",
when: { key: "connectionType", op: "eq", value: "resourceID" },
validate: (value: string) => (azureConnection(value)?.resourceID ? undefined : "Enter the full ARM Resource ID"),
},
]
}
function oauthPrompts(): NonNullable<Hooks["auth"]>["methods"][number]["prompts"] {
return [
{
type: "text",
key: "connection",
message: "Enter Azure Resource name",
placeholder: "my-resource",
validate: (value: string) =>
azureConnection(value)
? undefined
: "Enter an Azure Resource name, full Resource ID, or Foundry Project endpoint",
},
]
}
function connectionFromEnvironment() {
return [AZURE_RESOURCE_ID_ENV, AZURE_FOUNDRY_PROJECT_ENDPOINT_ENV, AZURE_RESOURCE_NAME_ENV]
.map((name) => azureConnection(process.env[name]))
.find(Predicate.isNotUndefined)
}
function scopeForRequest(input: RequestInfo | URL) {
const url = input instanceof Request ? new URL(input.url) : input instanceof URL ? input : new URL(input)
if (url.protocol !== "https:") return undefined
if (url.hostname.endsWith(".services.ai.azure.com")) {
if (url.pathname === "/models" || url.pathname.startsWith("/models/")) {
return AZURE_COGNITIVE_SERVICES_SCOPE
}
return AZURE_FOUNDRY_SCOPE
}
if (url.hostname.endsWith(".cognitiveservices.azure.com")) return AZURE_COGNITIVE_SERVICES_SCOPE
if (url.hostname.endsWith(".openai.azure.com")) return AZURE_COGNITIVE_SERVICES_SCOPE
return undefined
}
function azureCliTokenProvider(command: NonNullable<AzureAuthPluginOptions["tokenCommand"]>) {
type CachedToken = AzureAccessToken & { expires: number }
const cached = new Map<string, CachedToken>()
const pending = new Map<string, Promise<CachedToken>>()
return async (scope: string, signal?: AbortSignal) => {
const hit = cached.get(scope)
if (hit && hit.expires - Date.now() > AZURE_TOKEN_REFRESH_BUFFER) return hit
const existing = pending.get(scope)
if (existing) return existing
const loading = loadAzureCliToken(command, scope, signal)
.then((credential) => {
cached.set(scope, credential)
return credential
})
.finally(() => pending.delete(scope))
pending.set(scope, loading)
return loading
}
}
async function loadAzureCliToken(
command: NonNullable<AzureAuthPluginOptions["tokenCommand"]>,
scope: string,
signal?: AbortSignal,
) {
const result = await command(scope, signal)
if (result.exitCode !== 0) {
throw new Error(result.stderr.trim() || "Failed to get Azure access token. Run `az login` and try again.")
}
const decoded = decodeAzureCliToken(result.stdout)
if (Option.isNone(decoded)) throw new Error("Azure CLI did not return a valid access token")
const expires =
decoded.value.expires_on !== undefined
? decoded.value.expires_on * 1000
: decoded.value.expiresOn
? new Date(decoded.value.expiresOn).getTime()
: Number.NaN
if (!Number.isFinite(expires)) throw new Error("Azure CLI did not return a valid token expiry")
return {
token: decoded.value.accessToken,
expires,
...(decoded.value.subscription ? { subscription: decoded.value.subscription } : {}),
}
}
async function runAzureCliTokenCommand(scope: string, signal?: AbortSignal) {
try {
const proc = Bun.spawn(["az", "account", "get-access-token", "--scope", scope, "--output", "json"], {
stdout: "pipe",
stderr: "pipe",
signal,
})
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
return { stdout, stderr, exitCode }
} catch (error) {
throw new Error("Azure CLI could not be run. Install `az`, run `az login`, and try again.", {
cause: error,
})
}
}
@@ -1,133 +0,0 @@
import type { Provider } from "@opencode-ai/sdk/v2"
import { Option, Schema } from "effect"
import { AZURE_RESOURCE_MANAGER_SCOPE, type AzureAccessToken, type AzureRequest } from "./auth"
import { AzureResourceID } from "./schema"
class AzureDeployment extends Schema.Class<AzureDeployment>("AzureDeployment")({
name: Schema.NonEmptyString,
type: Schema.optionalKey(Schema.String),
modelName: Schema.optionalKey(Schema.NonEmptyString),
properties: Schema.optionalKey(
Schema.Struct({
provisioningState: Schema.optionalKey(Schema.String),
model: Schema.optionalKey(
Schema.Struct({
name: Schema.NonEmptyString,
format: Schema.optionalKey(Schema.String),
}),
),
}),
),
}) {}
const AzureDeploymentPage = Schema.Struct({
value: Schema.Array(AzureDeployment),
nextLink: Schema.optionalKey(Schema.String),
})
const decodeAzureDeploymentPage = Schema.decodeUnknownOption(Schema.fromJsonString(AzureDeploymentPage))
class AzureResource extends Schema.Class<AzureResource>("AzureResource")({
id: AzureResourceID,
name: Schema.NonEmptyString,
}) {}
const AzureResourcePage = Schema.Struct({
value: Schema.Array(AzureResource),
nextLink: Schema.optionalKey(Schema.String),
})
const decodeAzureResourcePage = Schema.decodeUnknownOption(Schema.fromJsonString(AzureResourcePage))
export function deployedModels(models: Provider["models"], deployments: ReadonlyArray<AzureDeployment>) {
const found = new Map<string, Provider["models"][string]>()
deployments.forEach((deployment) => {
const modelID = deployedModelID(models, deployment)
if (!modelID) return
const model = models[modelID]
if (!model) return
found.set(modelID, {
...model,
api: {
...model.api,
id: deployment.name,
},
})
})
return Object.fromEntries(found)
}
export async function resolveAzureResourceID(
resourceName: string,
credential: (scope: string, signal?: AbortSignal) => Promise<AzureAccessToken>,
request: AzureRequest,
signal: AbortSignal,
) {
const access = await credential(AZURE_RESOURCE_MANAGER_SCOPE, signal)
if (!access.subscription) {
throw new Error(
"Azure CLI did not return an active subscription. Run `az account set --subscription NAME_OR_ID` and try again.",
)
}
return findAzureResourceID(
`https://management.azure.com/subscriptions/${access.subscription}/providers/Microsoft.CognitiveServices/accounts?api-version=2024-10-01`,
resourceName,
access.token,
request,
signal,
)
}
export async function listAzureDeployments(
url: string,
headers: HeadersInit,
request: AzureRequest,
include: (deployment: AzureDeployment) => boolean,
signal: AbortSignal,
deployments: ReadonlyArray<AzureDeployment> = [],
): Promise<ReadonlyArray<AzureDeployment>> {
const response = await request(url, { headers, signal })
if (!response.ok) throw new Error(`Failed to list Azure deployments (${response.status})`)
const decoded = decodeAzureDeploymentPage(await response.text())
if (Option.isNone(decoded)) throw new Error("Azure returned an invalid deployments response")
const found = [...deployments, ...decoded.value.value.filter(include)]
if (!decoded.value.nextLink) return found
const next = new URL(decoded.value.nextLink, url)
if (next.origin !== new URL(url).origin) throw new Error("Azure returned an invalid deployments page")
return listAzureDeployments(next.toString(), headers, request, include, signal, found)
}
async function findAzureResourceID(
url: string,
resourceName: string,
token: string,
request: AzureRequest,
signal: AbortSignal,
) {
const response = await request(url, { headers: { authorization: `Bearer ${token}` }, signal })
if (!response.ok) {
throw new Error(`Failed to list Azure resources in the active subscription (${response.status})`)
}
const decoded = decodeAzureResourcePage(await response.text())
if (Option.isNone(decoded)) throw new Error("Azure returned an invalid resources response")
const resource = decoded.value.value.find((item) => item.name.toLowerCase() === resourceName.toLowerCase())
if (resource) return resource.id.replace(/\/$/, "")
if (decoded.value.nextLink) {
const next = new URL(decoded.value.nextLink, url)
if (next.origin !== new URL(url).origin) throw new Error("Azure returned an invalid resources page")
return findAzureResourceID(next.toString(), resourceName, token, request, signal)
}
throw new Error(
`Azure resource "${resourceName}" was not found in the active subscription. Run \`az account set --subscription NAME_OR_ID\` or reconnect using the full Resource ID.`,
)
}
function deployedModelID(models: Provider["models"], deployment: AzureDeployment) {
if (models[deployment.name]) return deployment.name
const modelName = deployment.modelName ?? deployment.properties?.model?.name
if (!modelName) return undefined
return Object.keys(models).find((modelID) => modelID.toLowerCase() === modelName.toLowerCase())
}
@@ -1,87 +0,0 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import type { Auth } from "@opencode-ai/sdk/v2"
import { Predicate } from "effect"
import {
AZURE_FOUNDRY_SCOPE,
AZURE_FOUNDRY_PROJECT_ENDPOINT_ENV,
AZURE_RESOURCE_MANAGER_SCOPE,
AZURE_RESOURCE_ID_ENV,
AZURE_RESOURCE_NAME_ENV,
createAzureAuth,
type AzureAuthPluginOptions,
} from "./auth"
import { deployedModels, listAzureDeployments, resolveAzureResourceID } from "./discovery"
import { azureConnection } from "./schema"
const AZURE_DISCOVERY_TIMEOUT = 5_000
export async function AzureAuthPlugin(_input: PluginInput): Promise<Hooks> {
return createAzureAuthHooks()
}
export function createAzureAuthHooks(options: AzureAuthPluginOptions = {}): Hooks {
const azure = createAzureAuth(options)
return {
auth: azure.auth,
provider: {
id: "azure",
async models(info, context) {
const apiKey = process.env.AZURE_API_KEY
const auth: Auth | undefined = context.auth ?? (apiKey ? { type: "api", key: apiKey } : undefined)
const connection = [
auth?.type === "oauth" ? auth.accountId : undefined,
auth?.type === "api" ? auth.metadata?.connection : undefined,
auth?.type === "api" ? auth.metadata?.resourceID : undefined,
auth?.type === "api" ? auth.metadata?.projectEndpoint : undefined,
auth?.type === "api" ? auth.metadata?.resourceName : undefined,
process.env[AZURE_RESOURCE_ID_ENV],
process.env[AZURE_FOUNDRY_PROJECT_ENDPOINT_ENV],
process.env[AZURE_RESOURCE_NAME_ENV],
]
.map(azureConnection)
.find(Predicate.isNotUndefined)
if (!connection || !auth) return info.models
const signal = AbortSignal.timeout(AZURE_DISCOVERY_TIMEOUT)
if (connection.projectEndpoint) {
const headers = new Headers()
if (auth.type === "api") headers.set("api-key", auth.key)
if (auth.type === "oauth") {
const token = await azure.token(AZURE_FOUNDRY_SCOPE, signal).catch(() => undefined)
if (!token) return {}
headers.set("authorization", `Bearer ${token}`)
}
if (auth.type !== "api" && auth.type !== "oauth") return info.models
const deployments = await listAzureDeployments(
`${connection.projectEndpoint}/deployments?api-version=v1&deploymentType=ModelDeployment`,
headers,
azure.request,
(deployment) => deployment.type === "ModelDeployment",
signal,
).catch(() => [])
return deployedModels(info.models, deployments)
}
if (auth.type !== "oauth") return info.models
const resourceID =
connection.resourceID ??
(await resolveAzureResourceID(connection.resourceName, azure.credential, azure.request, signal).catch(
() => undefined,
))
if (!resourceID) return {}
const token = await azure.token(AZURE_RESOURCE_MANAGER_SCOPE, signal).catch(() => undefined)
if (!token) return {}
const deployments = await listAzureDeployments(
`https://management.azure.com${resourceID}/deployments?api-version=2024-10-01`,
new Headers({ authorization: `Bearer ${token}` }),
azure.request,
(deployment) => deployment.properties?.provisioningState === "Succeeded",
signal,
).catch(() => [])
return deployedModels(info.models, deployments)
},
},
}
}
@@ -1,62 +0,0 @@
import { Option, Predicate, Schema } from "effect"
export const AzureResourceID = Schema.NonEmptyString.check(
Schema.isPattern(
/^\/subscriptions\/[^/]+\/resourceGroups\/[^/]+\/providers\/Microsoft\.CognitiveServices\/accounts\/[^/]+\/?$/i,
),
)
const AzureResourceName = Schema.NonEmptyString.check(Schema.isPattern(/^[a-z0-9][a-z0-9-]*$/i))
const decodeAzureResourceID = Schema.decodeUnknownOption(AzureResourceID)
const decodeAzureResourceName = Schema.decodeUnknownOption(AzureResourceName)
const decodeURL = Schema.decodeUnknownOption(Schema.URLFromString)
function azureResource(input: unknown) {
if (!Predicate.isString(input)) return undefined
const value = input.trim()
const resourceID = decodeAzureResourceID(value)
if (Option.isSome(resourceID)) {
const normalized = resourceID.value.replace(/\/$/, "")
const resourceName = normalized.split("/").at(-1)
if (resourceName) return { resourceID: normalized, resourceName }
}
const resourceName = decodeAzureResourceName(value)
if (Option.isSome(resourceName)) return { resourceName: resourceName.value }
return undefined
}
export function azureConnection(input: unknown) {
const resource = azureResource(input)
if (resource) return { ...resource, projectEndpoint: undefined }
const projectEndpoint = foundryProjectEndpoint(input)
if (!projectEndpoint) return undefined
const resourceName = azureResourceName(projectEndpoint)
if (!resourceName) return undefined
return { projectEndpoint, resourceID: undefined, resourceName }
}
export function azureResourceName(input: unknown) {
const resource = azureResource(input)
if (resource) return resource.resourceName
const endpoint = decodeURL(input)
if (Option.isNone(endpoint)) return undefined
if (endpoint.value.protocol !== "https:") return undefined
const suffix = [".services.ai.azure.com", ".cognitiveservices.azure.com", ".openai.azure.com"].find((value) =>
endpoint.value.hostname.endsWith(value),
)
if (!suffix) return undefined
return Option.getOrUndefined(decodeAzureResourceName(endpoint.value.hostname.slice(0, -suffix.length)))
}
export function foundryProjectEndpoint(input: unknown) {
const endpoint = decodeURL(input)
if (Option.isNone(endpoint)) return undefined
if (endpoint.value.protocol !== "https:") return undefined
if (!endpoint.value.hostname.endsWith(".services.ai.azure.com")) return undefined
if (!/^\/api\/projects\/[^/]+\/?$/.test(endpoint.value.pathname)) return undefined
return `${endpoint.value.origin}${endpoint.value.pathname.replace(/\/$/, "")}`
}
+10 -18
View File
@@ -8,7 +8,6 @@ import { NoSuchModelError, type Provider as SDK } from "ai"
import { Npm } from "@opencode-ai/core/npm"
import { Hash } from "@opencode-ai/core/util/hash"
import { Plugin } from "../plugin"
import { azureResourceName } from "../plugin/azure/schema"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { type LanguageModelV3 } from "@ai-sdk/provider"
import { ModelsDev } from "@opencode-ai/core/models-dev"
@@ -19,7 +18,7 @@ import { iife } from "@/util/iife"
import { Global } from "@opencode-ai/core/global"
import path from "path"
import { pathToFileURL } from "url"
import { Effect, Layer, Context, Predicate, Schema, Types } from "effect"
import { Effect, Layer, Context, Schema, Types } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
import { EffectPromise } from "@/effect/promise"
@@ -241,28 +240,21 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
azure: Effect.fnUntraced(function* (provider: Info) {
const env = yield* dep.env()
const auth = yield* dep.auth(provider.id)
const resource = [
provider.options?.resourceID,
provider.options?.projectEndpoint,
provider.options?.resourceName,
auth?.type === "api" ? auth.metadata?.connection : undefined,
auth?.type === "api" ? auth.metadata?.resourceID : undefined,
auth?.type === "api" ? auth.metadata?.projectEndpoint : undefined,
auth?.type === "api" ? auth.metadata?.resourceName : undefined,
auth?.type === "oauth" ? auth.accountId : undefined,
env["AZURE_RESOURCE_ID"],
env["AZURE_AI_PROJECT_ENDPOINT"],
env["AZURE_RESOURCE_NAME"],
]
.map(azureResourceName)
.find(Predicate.isString)
const resource = iife(() => {
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() !== "")
})
if (!resource && !provider.options?.baseURL) {
return {
autoload: false,
async getModel() {
throw new Error(
"Azure Resource Name, Resource ID, or Foundry Project endpoint is missing; reconnect the Azure provider",
"AZURE_RESOURCE_NAME is missing, set it using env var or reconnecting the azure provider and setting it",
)
},
}
+97 -598
View File
@@ -1,10 +1,24 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
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 { Predicate } from "effect"
import type { Provider } from "@opencode-ai/sdk"
import type { Auth } from "@opencode-ai/sdk/v2"
import { OAUTH_DUMMY_KEY } from "../../src/auth"
import { createAzureAuthHooks } from "../../src/plugin/azure"
import { azureConnection } from "../../src/plugin/azure/schema"
const resourceName = process.env.AZURE_RESOURCE_NAME
afterEach(() => {
if (resourceName === undefined) delete process.env.AZURE_RESOURCE_NAME
else process.env.AZURE_RESOURCE_NAME = resourceName
})
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",
@@ -15,634 +29,119 @@ const provider: Provider = {
models: {},
}
const oauth: Auth = {
type: "oauth",
access: OAUTH_DUMMY_KEY,
refresh: OAUTH_DUMMY_KEY,
expires: Date.now() + 60 * 60 * 1000,
accountId: "https://test-resource.services.ai.azure.com/api/projects/test-project",
}
const subscriptionID = "00000000-1111-4222-8333-444444444444"
const projectEndpoint = process.env.AZURE_AI_PROJECT_ENDPOINT
const azureApiKey = process.env.AZURE_API_KEY
const resourceID = process.env.AZURE_RESOURCE_ID
const resourceName = process.env.AZURE_RESOURCE_NAME
beforeEach(() => {
delete process.env.AZURE_AI_PROJECT_ENDPOINT
delete process.env.AZURE_API_KEY
delete process.env.AZURE_RESOURCE_ID
delete process.env.AZURE_RESOURCE_NAME
})
afterEach(() => {
if (projectEndpoint === undefined) delete process.env.AZURE_AI_PROJECT_ENDPOINT
else process.env.AZURE_AI_PROJECT_ENDPOINT = projectEndpoint
if (azureApiKey === undefined) delete process.env.AZURE_API_KEY
else process.env.AZURE_API_KEY = azureApiKey
if (resourceID === undefined) delete process.env.AZURE_RESOURCE_ID
else process.env.AZURE_RESOURCE_ID = resourceID
if (resourceName === undefined) delete process.env.AZURE_RESOURCE_NAME
else process.env.AZURE_RESOURCE_NAME = resourceName
})
function loader(hooks: Hooks) {
if (!hooks.auth?.loader) throw new Error("Azure auth loader is missing")
return hooks.auth.loader
}
function modelHook(hooks: Hooks) {
if (!hooks.provider?.models) throw new Error("Azure provider model hook is missing")
return hooks.provider.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 (!Predicate.isFunction(result)) throw new Error("Azure custom fetch is missing")
if (typeof result !== "function") throw new Error("Azure custom fetch is missing")
return async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await result(input, init)
if (!(response instanceof Response)) throw new Error("Azure custom fetch did not return a response")
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 tokenOutput(
accessToken: string,
expires = Date.now() + 60 * 60 * 1000,
subscription: string | null = subscriptionID,
) {
return JSON.stringify({ accessToken, expires_on: Math.floor(expires / 1000), subscription })
}
function models(...ids: string[]): Provider["models"] {
return Object.fromEntries(
ids.map((id) => [
id,
{
id,
providerID: provider.id,
api: { id, url: "", npm: "@ai-sdk/openai-compatible" },
name: id,
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,
},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 0, output: 0 },
status: "active" as const,
options: {},
headers: {},
release_date: "",
},
]),
)
}
function captureRequests() {
const requests: Array<{ url: string; headers: Headers }> = []
return {
requests,
request: async (input: RequestInfo | URL, init?: RequestInit) => {
requests.push({
url: input instanceof Request ? input.url : input.toString(),
headers: new Headers(init?.headers),
})
return new Response(null, { status: 200 })
},
function azureShell(scopes: string[]) {
return (_strings: TemplateStringsArray, scope: string) => {
scopes.push(scope)
const output = {
quiet: () => output,
json: async () => ({
accessToken: `${scope}-token`,
expires_on: Math.floor((Date.now() + 60 * 60 * 1000) / 1000),
}),
}
return output
}
}
describe("plugin.azure", () => {
test("exposes one Azure auth surface for API key and Entra ID", () => {
const hooks = createAzureAuthHooks()
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).toBeUndefined()
expect(hooks.auth?.methods.map((method) => [method.type, method.label])).toEqual([
["api", "API key"],
["oauth", "Microsoft Entra ID (Azure CLI)"],
])
expect(hooks.provider?.id).toBe("azure")
expect(hooks.auth?.methods.map((method) => method.prompts?.[0])).toEqual([
expect.objectContaining({ type: "select", key: "connectionType", message: "Select Azure connection type" }),
expect.objectContaining({ type: "text", key: "connection", message: "Enter Azure Resource name" }),
])
})
test("normalizes every supported Azure connection locator", () => {
expect(azureConnection("test-resource")).toMatchObject({ resourceName: "test-resource" })
expect(
azureConnection(
"/subscriptions/00000000-1111-2222-3333-444444444444/resourceGroups/test-rg/providers/Microsoft.CognitiveServices/accounts/test-resource/",
),
).toEqual({
resourceID:
"/subscriptions/00000000-1111-2222-3333-444444444444/resourceGroups/test-rg/providers/Microsoft.CognitiveServices/accounts/test-resource",
resourceName: "test-resource",
projectEndpoint: undefined,
})
expect(azureConnection("https://test-resource.services.ai.azure.com/api/projects/test-project/")).toEqual({
projectEndpoint: "https://test-resource.services.ai.azure.com/api/projects/test-project",
resourceID: undefined,
resourceName: "test-resource",
})
})
test("maps exact Foundry project model metadata to OpenCode model IDs", async () => {
const scopes: string[] = []
const requests: string[] = []
const hooks = createAzureAuthHooks({
request: async (input) => {
const url = input instanceof Request ? input.url : input.toString()
requests.push(url)
if (url.includes("/api/projects/")) {
return Response.json({
value: [
{
name: "production-gpt",
type: "ModelDeployment",
modelName: "gpt-5-mini",
},
{
name: "production-deepseek",
type: "ModelDeployment",
modelName: "DeepSeek-V4-Flash",
},
{
name: "custom-instruct",
type: "ModelDeployment",
modelName: "custom-instruct",
},
],
})
}
throw new Error(`Unexpected request: ${url}`)
},
tokenCommand: async (scope) => {
scopes.push(scope)
return { stdout: tokenOutput(`${scope}-token`), stderr: "", exitCode: 0 }
},
})
const list = modelHook(hooks)
const result = await list(
{ ...provider, models: models("gpt-5-mini", "deepseek-v4-flash", "custom") },
{ auth: oauth },
)
expect(Object.keys(result)).toEqual(["gpt-5-mini", "deepseek-v4-flash"])
expect(result["gpt-5-mini"].api.id).toBe("production-gpt")
expect(result["deepseek-v4-flash"].api.id).toBe("production-deepseek")
expect(scopes).toEqual(["https://ai.azure.com/.default"])
expect(requests).toHaveLength(1)
})
test("lists project deployments with an API key without invoking Azure CLI", async () => {
let cliCalls = 0
const requests: Array<{ url: string; headers: Headers }> = []
const hooks = createAzureAuthHooks({
request: async (input, init) => {
requests.push({
url: input instanceof Request ? input.url : input.toString(),
headers: new Headers(init?.headers),
})
return Response.json({
value: [{ name: "claude-haiku-4-5", type: "ModelDeployment" }],
})
},
tokenCommand: async () => {
cliCalls++
throw new Error("Azure CLI should not be used for API key auth")
},
})
const list = modelHook(hooks)
const result = await list(
{ ...provider, models: models("gpt-5-mini", "claude-haiku-4-5") },
{
auth: {
type: "api",
key: "project-key",
metadata: {
connectionType: "projectEndpoint",
projectEndpoint: "https://test-resource.services.ai.azure.com/api/projects/test-project",
},
},
},
)
const legacy = await list(
{ ...provider, models: models("gpt-5-mini", "claude-haiku-4-5") },
{
auth: {
type: "api",
key: "project-key",
metadata: {
resourceName: "https://test-resource.services.ai.azure.com/api/projects/test-project",
},
},
},
)
expect(Object.keys(result)).toEqual(["claude-haiku-4-5"])
expect(Object.keys(legacy)).toEqual(["claude-haiku-4-5"])
expect(cliCalls).toBe(0)
expect(requests).toHaveLength(2)
expect(requests.map((request) => request.headers.get("api-key"))).toEqual(["project-key", "project-key"])
expect(requests.map((request) => request.headers.get("authorization"))).toEqual([null, null])
})
test("keeps the app usable when Foundry authentication is unavailable", async () => {
const hooks = createAzureAuthHooks({
tokenCommand: async () => ({ stdout: "", stderr: "Run `az login`", exitCode: 1 }),
})
const list = modelHook(hooks)
expect(await list({ ...provider, models: models("gpt-5-mini") }, { auth: oauth })).toEqual({})
})
test("keeps the app usable when Azure discovery times out", async () => {
const hooks = createAzureAuthHooks({
request: async () => {
throw new DOMException("The operation timed out", "TimeoutError")
},
})
const list = modelHook(hooks)
expect(
await list(
{ ...provider, models: models("gpt-5-mini") },
expect(hooks.auth?.methods[0]).toEqual({
type: "api",
label: "API key",
prompts: [
{
auth: {
type: "api",
key: "project-key",
metadata: { connection: oauth.accountId ?? "" },
},
type: "text",
key: "resourceName",
message: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
},
),
).toEqual({})
],
})
expect(hooks.auth?.methods[1].prompts).toEqual(hooks.auth?.methods[0].prompts)
})
test("lists succeeded Azure deployments without changing their deployment IDs", async () => {
test("checks Azure CLI and stores the resource name", async () => {
const scopes: string[] = []
const requests: Array<{ url: string; headers: Headers }> = []
const hooks = createAzureAuthHooks({
request: async (input, init) => {
requests.push({
url: input instanceof Request ? input.url : input.toString(),
headers: new Headers(init?.headers),
})
return Response.json({
value: [
{ name: "gpt-5-mini", properties: { provisioningState: "Succeeded" } },
{ name: "gpt-5.6-luna", properties: { provisioningState: "Creating" } },
{
name: "DeepSeek-V4-Flash",
properties: {
provisioningState: "Succeeded",
model: { name: "DeepSeek-V4-Flash", format: "DeepSeek" },
},
},
],
})
},
tokenCommand: async (scope) => {
scopes.push(scope)
return { stdout: tokenOutput("management-token"), stderr: "", exitCode: 0 }
},
})
const list = modelHook(hooks)
const result = await list(
{ ...provider, models: models("gpt-5-mini", "gpt-5.6-luna", "gpt-5-nano", "deepseek-v4-flash") },
{
auth: {
...oauth,
accountId:
"/subscriptions/00000000-1111-2222-3333-444444444444/resourceGroups/test-rg/providers/Microsoft.CognitiveServices/accounts/test-resource",
},
},
)
expect(Object.keys(result)).toEqual(["gpt-5-mini", "deepseek-v4-flash"])
expect(result["deepseek-v4-flash"].api.id).toBe("DeepSeek-V4-Flash")
expect(scopes).toEqual(["https://management.azure.com/.default"])
expect(requests).toHaveLength(1)
expect(requests[0].url).toBe(
"https://management.azure.com/subscriptions/00000000-1111-2222-3333-444444444444/resourceGroups/test-rg/providers/Microsoft.CognitiveServices/accounts/test-resource/deployments?api-version=2024-10-01",
)
expect(requests[0].headers.get("authorization")).toBe("Bearer management-token")
})
test("resolves a Resource Name in the active subscription before listing deployments", async () => {
const scopes: string[] = []
const requests: Array<{ url: string; headers: Headers }> = []
const hooks = createAzureAuthHooks({
request: async (input, init) => {
const url = input instanceof Request ? input.url : input.toString()
requests.push({ url, headers: new Headers(init?.headers) })
if (url.endsWith("/providers/Microsoft.CognitiveServices/accounts?api-version=2024-10-01")) {
return Response.json({
value: [
{
id: `/subscriptions/${subscriptionID}/resourceGroups/test-rg/providers/Microsoft.CognitiveServices/accounts/test-resource`,
name: "test-resource",
},
],
})
}
return Response.json({
value: [
{ name: "gpt-5-mini", properties: { provisioningState: "Succeeded" } },
{ name: "gpt-5.6-luna", properties: { provisioningState: "Creating" } },
],
})
},
tokenCommand: async (scope) => {
scopes.push(scope)
return { stdout: tokenOutput("management-token"), stderr: "", exitCode: 0 }
},
})
const list = modelHook(hooks)
const result = await list(
{ ...provider, models: models("gpt-5-mini", "gpt-5.6-luna") },
{ auth: { ...oauth, accountId: "test-resource" } },
)
expect(Object.keys(result)).toEqual(["gpt-5-mini"])
expect(scopes).toEqual(["https://management.azure.com/.default"])
expect(requests.map((request) => request.url)).toEqual([
`https://management.azure.com/subscriptions/${subscriptionID}/providers/Microsoft.CognitiveServices/accounts?api-version=2024-10-01`,
`https://management.azure.com/subscriptions/${subscriptionID}/resourceGroups/test-rg/providers/Microsoft.CognitiveServices/accounts/test-resource/deployments?api-version=2024-10-01`,
])
expect(requests.map((request) => request.headers.get("authorization"))).toEqual([
"Bearer management-token",
"Bearer management-token",
])
})
test("keeps the app usable when Azure CLI does not return an active subscription", async () => {
const hooks = createAzureAuthHooks({
tokenCommand: async () => ({
stdout: tokenOutput("management-token", Date.now() + 60 * 60 * 1000, null),
stderr: "",
exitCode: 0,
}),
})
const list = modelHook(hooks)
expect(
await list({ ...provider, models: models("gpt-5-mini") }, { auth: { ...oauth, accountId: "test-resource" } }),
).toEqual({})
})
test("keeps the app usable when the Resource Name is absent from the active subscription", async () => {
const hooks = createAzureAuthHooks({
request: async () => Response.json({ value: [] }),
tokenCommand: async () => ({ stdout: tokenOutput("management-token"), stderr: "", exitCode: 0 }),
})
const list = modelHook(hooks)
expect(
await list({ ...provider, models: models("gpt-5-mini") }, { auth: { ...oauth, accountId: "missing-resource" } }),
).toEqual({})
})
test("keeps the app usable when the Resource ID cannot list deployments", async () => {
let signal: AbortSignal | null | undefined
const hooks = createAzureAuthHooks({
request: async (_input, init) => {
signal = init?.signal
return new Response(null, { status: 404 })
},
tokenCommand: async () => ({ stdout: tokenOutput("management-token"), stderr: "", exitCode: 0 }),
})
const list = modelHook(hooks)
expect(
await list(
{ ...provider, models: models("gpt-5-mini") },
{
auth: {
...oauth,
accountId:
"/subscriptions/00000000-1111-2222-3333-444444444444/resourceGroups/test-rg/providers/Microsoft.CognitiveServices/accounts/missing-resource",
},
},
),
).toEqual({})
expect(signal).toBeInstanceOf(AbortSignal)
})
test("selects the token scope from the request route and strips API key headers", async () => {
const scopes: string[] = []
const captured = captureRequests()
const hooks = createAzureAuthHooks({
request: captured.request,
tokenCommand: async (scope) => {
scopes.push(scope)
return {
stdout: tokenOutput(scope === "https://ai.azure.com/.default" ? "foundry-token" : "cognitive-token"),
stderr: "",
exitCode: 0,
}
},
})
const fetch = customFetch(await loader(hooks)(async () => oauth, provider))
await fetch("https://test-resource.services.ai.azure.com/anthropic/v1/messages", {
headers: { "api-key": "dummy", "x-api-key": "dummy", "x-keep": "yes" },
})
await fetch("https://test-resource.services.ai.azure.com/models/chat/completions")
await fetch("https://test-resource.cognitiveservices.azure.com/openai/v1/responses")
expect(scopes).toEqual(["https://ai.azure.com/.default", "https://cognitiveservices.azure.com/.default"])
expect(captured.requests.map((request) => request.headers.get("authorization"))).toEqual([
"Bearer foundry-token",
"Bearer cognitive-token",
"Bearer cognitive-token",
])
expect(captured.requests[0].headers.get("api-key")).toBeNull()
expect(captured.requests[0].headers.get("x-api-key")).toBeNull()
expect(captured.requests[0].headers.get("x-keep")).toBe("yes")
expect(captured.requests[0].headers.get("user-agent")).toMatch(/^opencode\//)
})
test("does not send Azure OAuth tokens to unsupported endpoints", async () => {
const scopes: string[] = []
const captured = captureRequests()
const hooks = createAzureAuthHooks({
request: captured.request,
tokenCommand: async (scope) => {
scopes.push(scope)
return { stdout: tokenOutput("azure-token"), stderr: "", exitCode: 0 }
},
})
const fetch = customFetch(await loader(hooks)(async () => oauth, provider))
expect(fetch("https://example.com/v1/responses")).rejects.toThrow("Azure OAuth only supports Azure HTTPS endpoints")
expect(scopes).toEqual([])
expect(captured.requests).toEqual([])
})
test("deduplicates concurrent Azure CLI requests and caches the token", async () => {
const scopes: string[] = []
const hooks = createAzureAuthHooks({
request: captureRequests().request,
tokenCommand: async (scope) => {
scopes.push(scope)
await Bun.sleep(20)
return { stdout: tokenOutput("shared-token"), stderr: "", exitCode: 0 }
},
})
const fetch = customFetch(await loader(hooks)(async () => oauth, provider))
const url = "https://test-resource.openai.azure.com/openai/v1/responses"
await Promise.all([fetch(url), fetch(url), fetch(url)])
await fetch(url)
expect(scopes).toEqual(["https://cognitiveservices.azure.com/.default"])
})
test("accepts the legacy expiresOn field", async () => {
const captured = captureRequests()
let calls = 0
const hooks = createAzureAuthHooks({
request: captured.request,
tokenCommand: async () => {
calls++
return {
stdout: JSON.stringify({
accessToken: "legacy-token",
expiresOn: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
}),
stderr: "",
exitCode: 0,
}
},
})
const fetch = customFetch(await loader(hooks)(async () => oauth, provider))
await fetch("https://test-resource.openai.azure.com/openai/v1/responses")
await fetch("https://test-resource.openai.azure.com/openai/v1/responses")
expect(calls).toBe(1)
expect(captured.requests[0].headers.get("authorization")).toBe("Bearer legacy-token")
})
test("does not cache invalid Azure CLI output", async () => {
const captured = captureRequests()
let calls = 0
const hooks = createAzureAuthHooks({
request: captured.request,
tokenCommand: async () => {
calls++
return {
stdout: calls === 1 ? "not-json" : tokenOutput("recovered-token"),
stderr: "",
exitCode: 0,
}
},
})
const fetch = customFetch(await loader(hooks)(async () => oauth, provider))
const url = "https://test-resource.openai.azure.com/openai/v1/responses"
const error = await fetch(url).then(
() => undefined,
(error: unknown) => error,
)
expect(error).toBeInstanceOf(Error)
if (!(error instanceof Error)) throw new Error("Expected Azure token loading to fail")
expect(error.message).toBe("Azure CLI did not return a valid access token")
await fetch(url)
expect(calls).toBe(2)
expect(captured.requests[0].headers.get("authorization")).toBe("Bearer recovered-token")
})
test("accepts a Foundry project endpoint and checks Azure CLI before storing it", async () => {
const scopes: string[] = []
const hooks = createAzureAuthHooks({
tokenCommand: async (scope) => {
scopes.push(scope)
return { stdout: tokenOutput("connect-token"), stderr: "", exitCode: 0 }
},
})
const method = oauthMethod(hooks)
const prompt = method.prompts?.find((prompt) => prompt.key === "connection")
if (!prompt || prompt.type !== "text") throw new Error("Azure Resource name prompt is missing")
expect(prompt.validate?.("not/a/resource")).toBe(
"Enter an Azure Resource name, full Resource ID, or Foundry Project endpoint",
)
expect(prompt.validate?.("https://connected-resource.services.ai.azure.com/api/projects/connected-project")).toBe(
undefined,
)
const authorization = await method.authorize({
connection: "https://connected-resource.services.ai.azure.com/api/projects/connected-project/",
})
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")
const result = await authorization.callback()
expect(authorization.url).toBe("")
expect(scopes).toEqual(["https://ai.azure.com/.default"])
expect(result).toMatchObject({
expect(await authorization.callback()).toMatchObject({
type: "success",
access: OAUTH_DUMMY_KEY,
refresh: OAUTH_DUMMY_KEY,
accountId: "https://connected-resource.services.ai.azure.com/api/projects/connected-project",
})
})
test("validates and stores a normalized Azure Resource ID", async () => {
const hooks = createAzureAuthHooks({
tokenCommand: async () => ({ stdout: tokenOutput("connect-token"), stderr: "", exitCode: 0 }),
})
const method = oauthMethod(hooks)
const prompt = method.prompts?.find((prompt) => prompt.key === "connection")
if (!prompt || prompt.type !== "text") throw new Error("Azure Resource name prompt is missing")
expect(prompt.validate?.("not/a/resource")).toBe(
"Enter an Azure Resource name, full Resource ID, or Foundry Project endpoint",
)
const authorization = await method.authorize({
connection:
"/subscriptions/00000000-1111-2222-3333-444444444444/resourceGroups/test-rg/providers/Microsoft.CognitiveServices/accounts/test-resource/",
})
if (authorization.method !== "auto") throw new Error("Unexpected Azure authorization method")
expect(await authorization.callback()).toMatchObject({
type: "success",
accountId:
"/subscriptions/00000000-1111-2222-3333-444444444444/resourceGroups/test-rg/providers/Microsoft.CognitiveServices/accounts/test-resource",
})
})
test("uses the first valid Azure account environment value", async () => {
process.env.AZURE_RESOURCE_ID = "not/a/resource"
process.env.AZURE_RESOURCE_NAME = "test-resource"
const hooks = createAzureAuthHooks({
tokenCommand: async () => ({ stdout: tokenOutput("connect-token"), stderr: "", exitCode: 0 }),
})
const method = oauthMethod(hooks)
expect(method.prompts).toEqual([])
const authorization = await method.authorize()
if (authorization.method !== "auto") throw new Error("Unexpected Azure authorization method")
expect(await authorization.callback()).toMatchObject({
type: "success",
accountId: "test-resource",
})
expect(scopes).toEqual(["https://cognitiveservices.azure.com/.default"])
})
test("does not change API-key loading", async () => {
const scopes: string[] = []
const hooks = createAzureAuthHooks(azureShell(scopes))
expect(await loader(hooks)(async () => ({ type: "api", key: "test-key" }), provider)).toEqual({})
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)
})
})
@@ -3,7 +3,7 @@ import { mkdir, unlink } from "fs/promises"
import path from "path"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Effect, Predicate } from "effect"
import { Effect, Layer } from "effect"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
@@ -82,25 +82,7 @@ const paid = (providers: Record<string, { models: Record<string, { cost: { input
return Object.values(item.models).filter((model) => model.cost.input > 0).length
}
function languageConfig(language: unknown) {
if (!Predicate.isObject(language)) throw new Error("Expected an AI SDK language model")
if (!Predicate.isObject(language["config"])) throw new Error("Expected an AI SDK language model config")
return language["config"]
}
function languageBaseURL(language: unknown) {
const baseURL = languageConfig(language)["baseURL"]
if (!Predicate.isString(baseURL)) throw new Error("Expected an AI SDK base URL")
return baseURL
}
function languageURL(language: unknown, path: string) {
const url = languageConfig(language)["url"]
if (!Predicate.isFunction(url)) throw new Error("Expected an AI SDK URL builder")
const result: unknown = Reflect.apply(url, undefined, [{ path }])
if (!Predicate.isString(result)) throw new Error("Expected an AI SDK URL")
return result
}
const languageBaseURL = (language: unknown) => (language as { config: { baseURL: string } }).config.baseURL
const it = testEffect(LayerNode.compile(LayerNode.group([Provider.node, Env.node, Plugin.node])))
const experimentalModels = testEffect(providerLayer({ enableExperimentalModels: true }))
@@ -818,90 +800,38 @@ it.instance("getSmallModel skips inferred models for Azure", () =>
}),
)
it.instance("Azure is the only built-in Azure auth provider", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const hooks = yield* plugin.list()
const providers = hooks.flatMap((hook) => (hook.auth ? [hook.auth.provider] : []))
expect(providers.filter((provider) => ["azure", "azure-cognitive-services"].includes(provider))).toEqual(["azure"])
}),
)
it.instance(
"Azure OpenAI resolves an ARM Resource ID to its resource name",
"Azure uses the resource name stored by Entra login",
Effect.gen(function* () {
const provider = yield* Provider.Service
const model = yield* provider.getModel(ProviderV2.ID.azure, ModelV2.ID.make("gpt-5-mini"))
expect(languageURL(yield* provider.getLanguage(model), "/responses")).toBe(
"https://test-resource.openai.azure.com/openai/v1/responses?api-version=v1",
)
const providers = yield* list
expect(providers[ProviderV2.ID.azure].options.resourceName).toBe("entra-resource")
}),
{
config: {
provider: {
azure: {
options: {
resourceID:
"/subscriptions/00000000-1111-2222-3333-444444444444/resourceGroups/test-rg/providers/Microsoft.CognitiveServices/accounts/test-resource",
init: () =>
setProcessEnv(
"OPENCODE_AUTH_CONTENT",
JSON.stringify({
azure: {
type: "oauth",
access: "opencode-oauth-dummy-key",
refresh: "opencode-oauth-dummy-key",
expires: Date.now() + 60 * 60 * 1000,
accountId: "entra-resource",
},
},
},
},
init: () => setProcessEnv("AZURE_API_KEY", "test-key"),
}),
),
},
)
it.instance("legacy Azure Cognitive Services API-key provider remains available", () =>
it.instance("getSmallModel skips inferred models for Azure Cognitive Services", () =>
Effect.gen(function* () {
yield* set("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "test-resource")
yield* set("AZURE_COGNITIVE_SERVICES_API_KEY", "test-key")
const providerID = ProviderV2.ID.make("azure-cognitive-services")
const providers = yield* list
const model = yield* Provider.use.getSmallModel(providerID)
expect(providers[providerID]).toBeDefined()
const model = yield* Provider.use.getSmallModel(ProviderV2.ID.make("azure-cognitive-services"))
expect(model).toBeUndefined()
}),
)
it.instance(
"Azure resolves a Foundry project endpoint by model shape",
() =>
Effect.gen(function* () {
const provider = yield* Provider.Service
const opus = yield* provider.getModel(ProviderV2.ID.azure, ModelV2.ID.make("claude-opus-4-5"))
expect(languageBaseURL(yield* provider.getLanguage(opus))).toBe(
"https://oauth-resource.services.ai.azure.com/anthropic/v1",
)
const kimi = yield* provider.getModel(ProviderV2.ID.azure, ModelV2.ID.make("kimi-k2.6"))
expect(languageURL(yield* provider.getLanguage(kimi), "/chat/completions")).toBe(
"https://oauth-resource.services.ai.azure.com/models/chat/completions",
)
const gpt = yield* provider.getModel(ProviderV2.ID.azure, ModelV2.ID.make("gpt-5.1"))
expect(languageURL(yield* provider.getLanguage(gpt), "/responses")).toBe(
"https://oauth-resource.openai.azure.com/openai/v1/responses?api-version=v1",
)
}),
{
config: {
provider: {
azure: {
options: {
apiKey: "test-key",
projectEndpoint: "https://oauth-resource.services.ai.azure.com/api/projects/oauth-project",
},
},
},
},
init: () => setProcessEnv("AZURE_API_KEY", "test-key"),
},
)
it.instance(
"getSmallModel respects config small_model override",
Effect.gen(function* () {
+58 -66
View File
@@ -408,22 +408,20 @@ If tool calls aren't working well, pick a loaded model with strong tool-calling
---
### Microsoft Foundry and Azure OpenAI
Use the **Azure** provider for both Microsoft Foundry resources and standalone Azure OpenAI resources. A Foundry resource supports Azure OpenAI models as well as models from providers such as Anthropic, DeepSeek, Meta, and Mistral.
### Azure OpenAI
:::note
If you encounter "I'm sorry, but I cannot assist with that request" errors, try changing the content filter from **DefaultV2** to **Default** in your Azure resource.
:::
1. Create a resource.
- For Microsoft Foundry, create a **Foundry resource** and project in the [Microsoft Foundry portal](https://ai.azure.com/). Copy the Project endpoint from the project overview. It looks like `https://RESOURCE.services.ai.azure.com/api/projects/PROJECT`.
- For standalone Azure OpenAI, create an **Azure OpenAI** resource in the [Azure portal](https://portal.azure.com/) and copy its Resource name.
1. Head over to the [Azure portal](https://portal.azure.com/) and create an **Azure OpenAI** resource. You'll need:
- **Resource name**: This becomes part of your API endpoint (`https://RESOURCE_NAME.openai.azure.com/`)
- **API key**: Either `KEY 1` or `KEY 2` from your resource
2. Deploy a model to the resource.
2. Go to [Azure AI Foundry](https://ai.azure.com/) and deploy a model.
:::note
OpenCode matches exact deployment or Azure model names to its model catalog; it does not rewrite model IDs. If a deployment cannot be matched automatically, use the exact OpenCode model ID as its deployment name.
The deployment name must match the model name for opencode to work properly.
:::
3. Run the `/connect` command and search for **Azure**.
@@ -432,91 +430,85 @@ If you encounter "I'm sorry, but I cannot assist with that request" errors, try
/connect
```
4. Choose an authentication method.
- **API key**: Paste a key from the resource. To discover deployments with an API key, enter a Foundry Project endpoint in the next step.
- **Microsoft Entra ID (Azure CLI)**: [Install the Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli), run `az login`, and assign the signed-in identity the required inference role. Use [`Cognitive Services OpenAI User`](https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/role-based-access-control) for a standalone Azure OpenAI resource or [`Foundry User`](https://learn.microsoft.com/en-us/azure/foundry/concepts/rbac-foundry) for a Foundry resource. `Owner` or `Contributor` alone does not grant inference access.
4. Enter your API key.
```txt
Select auth method
API key
│ API key
│ Microsoft Entra ID (Azure CLI)
```
5. Finish the connection.
- With **Microsoft Entra ID (Azure CLI)**, enter the Azure Resource name. OpenCode resolves it in the active Azure CLI subscription and discovers its deployments. You can paste a full Resource ID or Foundry Project endpoint instead when you need to bypass subscription-wide lookup.
- With **API key**, choose the connection type, enter its value, then enter the key. A Foundry Project endpoint enables deployment discovery; a Resource name or Resource ID keeps the existing Azure OpenAI API-key behavior.
```txt
┌ Enter Azure Resource name
│ my-resource
└ enter
```
API-key setup first asks which locator you want to use:
```txt
┌ Select Azure connection type
│ Foundry Project endpoint
│ Azure Resource name
│ Azure Resource ID
```
If a different Azure CLI subscription is active, select the correct one and reconnect:
5. Set your resource name as an environment variable:
```bash
az account set --subscription NAME_OR_ID
```
A full Resource ID or Foundry Project endpoint bypasses subscription-wide resource discovery.
6. Optional: set one connection value as an environment variable to skip the resource prompt during `/connect`.
```bash
AZURE_RESOURCE_NAME=RESOURCE_NAME opencode
AZURE_RESOURCE_NAME=XXX opencode
```
Or add it to your bash profile:
```bash title="~/.bash_profile"
export AZURE_RESOURCE_NAME=RESOURCE_NAME
export AZURE_RESOURCE_NAME=XXX
```
For a Foundry project, use its Project endpoint:
```bash
AZURE_AI_PROJECT_ENDPOINT=https://RESOURCE.services.ai.azure.com/api/projects/PROJECT opencode
```
Or set the complete Resource ID:
```bash
AZURE_RESOURCE_ID=/subscriptions/SUBSCRIPTION_ID/resourceGroups/RESOURCE_GROUP/providers/Microsoft.CognitiveServices/accounts/RESOURCE_NAME opencode
```
7. Run the `/models` command to select your model. When deployment discovery is available, OpenCode shows only deployments it can match to the Azure model catalog. Resource-name or Resource-ID API-key configurations retain the existing full-catalog behavior.
6. Run the `/models` command to select your deployed model.
```txt
/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 select **Microsoft Entra ID (Azure CLI)** when connecting the **Azure** provider and enter the same Resource name.
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, so you only need to sign in again when the CLI session expires.
---
### Azure Cognitive Services
The separate `azure-cognitive-services` provider remains available for existing API-key configurations. New Microsoft Foundry connections should use the **Azure** provider above; both providers can address the same underlying Foundry resource.
1. Head over to the [Azure portal](https://portal.azure.com/) and create an **Azure OpenAI** resource. You'll need:
- **Resource name**: This becomes part of your API endpoint (`https://AZURE_COGNITIVE_SERVICES_RESOURCE_NAME.cognitiveservices.azure.com/`)
- **API key**: Either `KEY 1` or `KEY 2` from your resource
Existing environment configuration remains supported:
2. Go to [Azure AI Foundry](https://ai.azure.com/) and deploy a model.
```bash
AZURE_COGNITIVE_SERVICES_RESOURCE_NAME=RESOURCE_NAME \
AZURE_COGNITIVE_SERVICES_API_KEY=API_KEY \
opencode
```
:::note
The deployment name must match the model name for opencode to work properly.
:::
3. Run the `/connect` command and search for **Azure Cognitive Services**.
```txt
/connect
```
4. Enter your API key.
```txt
┌ API key
└ enter
```
5. Set your resource name as an environment variable:
```bash
AZURE_COGNITIVE_SERVICES_RESOURCE_NAME=XXX opencode
```
Or add it to your bash profile:
```bash title="~/.bash_profile"
export AZURE_COGNITIVE_SERVICES_RESOURCE_NAME=XXX
```
6. Run the `/models` command to select your deployed model.
```txt
/models
```
---