diff --git a/packages/opencode/src/plugin/azure.ts b/packages/opencode/src/plugin/azure.ts index 62792b3bd27b..1b8cbd6247fa 100644 --- a/packages/opencode/src/plugin/azure.ts +++ b/packages/opencode/src/plugin/azure.ts @@ -1,26 +1,160 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" +import { OAUTH_DUMMY_KEY } from "../auth" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { Option, Schema } from "effect" + +const AZURE_SCOPE = "https://cognitiveservices.azure.com" +const AZURE_TOKEN_REFRESH_BUFFER = 60_000 +const AzureCliToken = Schema.Struct({ + accessToken: Schema.String, + expires_on: Schema.optional(Schema.Number), + expiresOn: Schema.optional(Schema.String), +}) +const decodeAzureCliToken = Schema.decodeUnknownOption(Schema.fromJsonString(AzureCliToken)) export async function AzureAuthPlugin(_input: PluginInput): Promise { - 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 azureAuthPlugin({ + provider: "azure", + resourceEnv: "AZURE_RESOURCE_NAME", + oauthInstructions: + "Sign in with `az login`. The signed-in Azure identity must have the Cognitive Services OpenAI User role for this resource.", + prompts: process.env.AZURE_RESOURCE_NAME + ? [] + : [ + { + type: "text" as const, + key: "resourceName", + message: "Enter Azure Resource Name", + placeholder: "e.g. my-models", + }, + ], + providerOptions: (resourceName) => ({ resourceName }), + }) +} + +export async function AzureCognitiveServicesAuthPlugin(_input: PluginInput): Promise { + return azureAuthPlugin({ + provider: "azure-cognitive-services", + resourceEnv: "AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", + oauthInstructions: + "Sign in with `az login`. The signed-in Azure identity must have the Cognitive Services User or Foundry User role for this resource.", + prompts: process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME + ? [] + : [ + { + type: "text" as const, + key: "resourceName", + message: "Enter Azure Cognitive Services Resource Name", + placeholder: "e.g. my-models", + }, + ], + }) +} +function azureAuthPlugin(input: { + provider: string + resourceEnv: string + oauthInstructions: string + prompts: NonNullable["methods"][number]["prompts"] + providerOptions?: (resourceName: string) => Record +}): Hooks { return { auth: { - provider: "azure", + provider: input.provider, + async loader(getAuth) { + const auth = await getAuth() + if (auth.type !== "oauth") return {} + + const resourceName = process.env[input.resourceEnv] || auth.accountId + const tokenProvider = azureCliTokenProvider() + + return { + ...((resourceName && input.providerOptions?.(resourceName)) ?? {}), + apiKey: OAUTH_DUMMY_KEY, + async fetch(requestInput: RequestInfo | URL, init?: RequestInit) { + const currentAuth = await getAuth() + if (currentAuth.type !== "oauth") return fetch(requestInput, init) + + const headers = new Headers(requestInput instanceof Request ? requestInput.headers : undefined) + if (init?.headers) { + const entries = + init.headers instanceof Headers + ? init.headers.entries() + : Array.isArray(init.headers) + ? init.headers + : Object.entries(init.headers as Record) + for (const [key, value] of entries) { + if (value !== undefined) headers.set(key, String(value)) + } + } + headers.delete("api-key") + headers.delete("x-api-key") + headers.set("authorization", `Bearer ${await tokenProvider()}`) + headers.set("User-Agent", `opencode/${InstallationVersion}`) + + return fetch(requestInput, { ...init, headers }) + }, + } + }, methods: [ { type: "api", label: "API key", - prompts, + prompts: input.prompts, + }, + { + type: "oauth", + label: "Microsoft Entra ID (OAuth via az cli)", + prompts: input.prompts, + authorize: async (inputs) => ({ + url: "https://learn.microsoft.com/azure/developer/ai/keyless-connections", + instructions: input.oauthInstructions, + method: "auto" as const, + callback: async () => ({ + type: "success" as const, + access: OAUTH_DUMMY_KEY, + refresh: OAUTH_DUMMY_KEY, + expires: Date.now() + 365 * 24 * 60 * 60 * 1000, + accountId: inputs?.resourceName || process.env[input.resourceEnv], + }), + }), }, ], }, } } + +function azureCliTokenProvider() { + let cached: { token: string; expires: number } | undefined + return async () => { + if (cached && cached.expires - Date.now() > AZURE_TOKEN_REFRESH_BUFFER) return cached.token + + const proc = Bun.spawn(["az", "account", "get-access-token", "--resource", AZURE_SCOPE, "--output", "json"], { + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + if (exitCode !== 0) { + throw new Error(stderr.trim() || "Failed to get Azure access token. Run `az login` and try again.") + } + + const decoded = decodeAzureCliToken(stdout) + if (Option.isNone(decoded)) throw new Error("Azure CLI did not return an access token") + + cached = { + token: decoded.value.accessToken, + // Azure CLI's expiresOn is a timezone-less local datetime; expires_on avoids DST ambiguity. + expires: + decoded.value.expires_on !== undefined + ? decoded.value.expires_on * 1000 + : decoded.value.expiresOn + ? new Date(decoded.value.expiresOn).getTime() + : Date.now() + 30 * 60 * 1000, + } + return cached.token + } +} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 0f71b39a9d5e..e93d87236fb2 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -16,7 +16,7 @@ import { CopilotAuthPlugin } from "./github-copilot/copilot" import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" import { PoeAuthPlugin } from "opencode-poe-auth" import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" -import { AzureAuthPlugin } from "./azure" +import { AzureAuthPlugin, AzureCognitiveServicesAuthPlugin } from "./azure" import { DigitalOceanAuthPlugin } from "./digitalocean" import { XaiAuthPlugin } from "./xai" import { SnowflakeCortexAuthPlugin } from "./snowflake-cortex" @@ -75,6 +75,7 @@ function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] { CloudflareWorkersAuthPlugin, CloudflareAIGatewayAuthPlugin, AzureAuthPlugin, + AzureCognitiveServicesAuthPlugin, DigitalOceanAuthPlugin, SnowflakeCortexAuthPlugin, XaiAuthPlugin, diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 4352f8a9b519..73d943cd0d10 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -233,6 +233,7 @@ function custom(dep: CustomDep): Record { 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() !== "") }) @@ -266,15 +267,33 @@ function custom(dep: CustomDep): Record { }, } }), - "azure-cognitive-services": Effect.fnUntraced(function* () { - const resourceName = yield* dep.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME") + "azure-cognitive-services": Effect.fnUntraced(function* (provider: Info) { + const env = yield* dep.env() + const auth = yield* dep.auth(provider.id) + const resourceName = [ + provider.options?.resourceName, + auth?.type === "api" ? auth.metadata?.resourceName : undefined, + auth?.type === "oauth" ? auth.accountId : undefined, + env["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME"], + ].find((name) => typeof name === "string" && name.trim() !== "") return { autoload: false, async getModel(sdk: any, modelID: string, options?: Record) { return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"])) }, options: { - baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined, + baseURL: + resourceName && auth?.type !== "oauth" + ? `https://${resourceName}.cognitiveservices.azure.com/openai` + : undefined, + }, + vars(_options): Record { + if (resourceName) { + return { + AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: resourceName, + } + } + return {} }, } }), diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index b31ebb67792c..b416b7c7faa8 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -378,9 +378,7 @@ If tool calls aren't working well, pick a loaded model with strong tool-calling 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. 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 +1. Head over to the [Azure portal](https://portal.azure.com/) and create an **Azure OpenAI** resource. You'll need the **Resource name**; this becomes part of your API endpoint (`https://RESOURCE_NAME.openai.azure.com/`). 2. Go to [Azure AI Foundry](https://ai.azure.com/) and deploy a model. @@ -394,7 +392,19 @@ If you encounter "I'm sorry, but I cannot assist with that request" errors, try /connect ``` -4. Enter your API key. +4. Choose an auth method. + - **API key**: Paste either `KEY 1` or `KEY 2` from your resource. + - **Microsoft Entra ID (OAuth via az cli)**: Run `az login` first. The signed-in Azure identity must have the `Cognitive Services OpenAI User` role for the resource. + + ```txt + ┌ Select auth method + │ + │ API key + │ Microsoft Entra ID (OAuth via az cli) + └ + ``` + +5. If you chose API key, enter your API key. ```txt ┌ API key @@ -403,7 +413,7 @@ If you encounter "I'm sorry, but I cannot assist with that request" errors, try └ enter ``` -5. Set your resource name as an environment variable: +6. Optional: set your resource name as an environment variable to skip the resource name prompt during `/connect`. ```bash AZURE_RESOURCE_NAME=XXX opencode @@ -415,7 +425,7 @@ If you encounter "I'm sorry, but I cannot assist with that request" errors, try export AZURE_RESOURCE_NAME=XXX ``` -6. Run the `/models` command to select your deployed model. +7. Run the `/models` command to select your deployed model. ```txt /models @@ -425,9 +435,7 @@ If you encounter "I'm sorry, but I cannot assist with that request" errors, try ### Azure Cognitive Services -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 +1. Head over to the [Azure portal](https://portal.azure.com/) and create an **Azure OpenAI** resource. You'll need the **Resource name**; this becomes part of your API endpoint (`https://AZURE_COGNITIVE_SERVICES_RESOURCE_NAME.cognitiveservices.azure.com/`). 2. Go to [Azure AI Foundry](https://ai.azure.com/) and deploy a model. @@ -441,7 +449,19 @@ If you encounter "I'm sorry, but I cannot assist with that request" errors, try /connect ``` -4. Enter your API key. +4. Choose an auth method. + - **API key**: Paste either `KEY 1` or `KEY 2` from your resource. + - **Microsoft Entra ID (OAuth via az cli)**: Run `az login` first. The signed-in Azure identity must have the `Cognitive Services User` or `Foundry User` role for the resource. + + ```txt + ┌ Select auth method + │ + │ API key + │ Microsoft Entra ID (OAuth via az cli) + └ + ``` + +5. If you chose API key, enter your API key. ```txt ┌ API key @@ -450,7 +470,7 @@ If you encounter "I'm sorry, but I cannot assist with that request" errors, try └ enter ``` -5. Set your resource name as an environment variable: +6. Optional: set your resource name as an environment variable to skip the resource name prompt during `/connect`. ```bash AZURE_COGNITIVE_SERVICES_RESOURCE_NAME=XXX opencode @@ -462,7 +482,7 @@ If you encounter "I'm sorry, but I cannot assist with that request" errors, try export AZURE_COGNITIVE_SERVICES_RESOURCE_NAME=XXX ``` -6. Run the `/models` command to select your deployed model. +7. Run the `/models` command to select your deployed model. ```txt /models