diff --git a/packages/opencode/src/plugin/azure.ts b/packages/opencode/src/plugin/azure.ts new file mode 100644 index 00000000000..a4f95351556 --- /dev/null +++ b/packages/opencode/src/plugin/azure.ts @@ -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 +} + +type AzureShell = (strings: TemplateStringsArray, scope: string) => AzureCommand + +export async function AzureAuthPlugin(input: PluginInput): Promise { + return createAzureAuthHooks(input.$) +} + +export function createAzureAuthHooks( + shell: AzureShell, + request: (input: RequestInfo | URL, init?: RequestInit) => Promise = fetch, +): Hooks { + const tokens = new Map() + 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 +} diff --git a/packages/opencode/src/plugin/azure/auth.ts b/packages/opencode/src/plugin/azure/auth.ts deleted file mode 100644 index ae592af0c79..00000000000 --- a/packages/opencode/src/plugin/azure/auth.ts +++ /dev/null @@ -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")({ - 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 - -export type AzureRequest = (input: RequestInfo | URL, init?: RequestInit) => Promise - -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, - } -} - -function apiPrompts(): NonNullable["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["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) { - type CachedToken = AzureAccessToken & { expires: number } - - const cached = new Map() - const pending = new Map>() - - 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, - 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, - }) - } -} diff --git a/packages/opencode/src/plugin/azure/discovery.ts b/packages/opencode/src/plugin/azure/discovery.ts deleted file mode 100644 index 8c9a9b8f607..00000000000 --- a/packages/opencode/src/plugin/azure/discovery.ts +++ /dev/null @@ -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")({ - 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")({ - 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) { - const found = new Map() - 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, - 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 = [], -): Promise> { - 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()) -} diff --git a/packages/opencode/src/plugin/azure/index.ts b/packages/opencode/src/plugin/azure/index.ts deleted file mode 100644 index 5732efc4762..00000000000 --- a/packages/opencode/src/plugin/azure/index.ts +++ /dev/null @@ -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 { - 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) - }, - }, - } -} diff --git a/packages/opencode/src/plugin/azure/schema.ts b/packages/opencode/src/plugin/azure/schema.ts deleted file mode 100644 index 34fbb012530..00000000000 --- a/packages/opencode/src/plugin/azure/schema.ts +++ /dev/null @@ -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(/\/$/, "")}` -} diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 548c633b417..7380c88aab6 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -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 { 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", ) }, } diff --git a/packages/opencode/test/plugin/azure.test.ts b/packages/opencode/test/plugin/azure.test.ts index 745e059566f..5a15476721e 100644 --- a/packages/opencode/test/plugin/azure.test.ts +++ b/packages/opencode/test/plugin/azure.test.ts @@ -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) { 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) }) }) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 68d0b1d7df4..4cfe95c792d 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -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 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* () { diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index d090a546714..7b605919c54 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -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 + ``` ---