diff --git a/packages/opencode/src/plugin/azure.ts b/packages/opencode/src/plugin/azure.ts index 62792b3bd27b..5da3aa63395a 100644 --- a/packages/opencode/src/plugin/azure.ts +++ b/packages/opencode/src/plugin/azure.ts @@ -1,26 +1,172 @@ 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 { + const tokenProvider = azureCliTokenProvider() 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 + 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 () => { + await tokenProvider() + return { + 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 = spawnAzureCliTokenCommand() + 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 + } +} + +function spawnAzureCliTokenCommand() { + try { + return Bun.spawn(["az", "account", "get-access-token", "--resource", AZURE_SCOPE, "--output", "json"], { + stdout: "pipe", + stderr: "pipe", + }) + } catch (error) { + throw new Error("Azure CLI is required for Microsoft Entra ID OAuth. Install `az`, run `az login`, and try again.", { + cause: error, + }) + } +} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 1558c707c3f7..974c03b01787 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 a0ab5eb3474d..fda975bfc39c 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -244,6 +244,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() !== "") }) @@ -278,17 +279,35 @@ function custom(dep: CustomDep): Record { } }), "azure-cognitive-services": Effect.fnUntraced(function* (provider: Info) { - const resourceName = yield* dep.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME") + 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 + // Used only when a Cognitive model relies on the default @ai-sdk/azure endpoint. + azureOpenAICompatibleBaseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai${provider.options?.useDeploymentBasedUrls ? "" : "/v1"}` : undefined, }, + vars(_options): Record { + if (resourceName) { + return { + // Some Cognitive Services catalog entries use the generic Azure placeholder. + AZURE_RESOURCE_NAME: resourceName, + AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: resourceName, + } + } + return {} + }, } }), "amazon-bedrock": Effect.fnUntraced(function* () { @@ -1686,6 +1705,19 @@ const layer = Layer.effect( delete options.fetch } + if ( + model.providerID === "azure-cognitive-services" && + model.api.npm === "@ai-sdk/azure" && + !model.api.url && + typeof options["baseURL"] !== "string" && + typeof options["azureOpenAICompatibleBaseURL"] === "string" && + options["azureOpenAICompatibleBaseURL"] !== "" + ) { + // Azure Cognitive Services hosts multiple protocol shapes under one provider. + // Only default @ai-sdk/azure models use this Azure OpenAI-compatible URL. + options["baseURL"] = options["azureOpenAICompatibleBaseURL"] + } + if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) { options["includeUsage"] = true } @@ -1712,6 +1744,7 @@ const layer = Layer.effect( }) if (baseURL !== undefined) options["baseURL"] = baseURL + delete options["azureOpenAICompatibleBaseURL"] if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key if (model.headers) options["headers"] = { diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 058bad23a1c6..46f695799de1 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -83,6 +83,8 @@ const paid = (providers: Record (language as { config: { baseURL: string } }).config.baseURL +const languageURL = (language: unknown, path: string) => + (language as { config: { url: (options: { path: string }) => string } }).config.url({ path }) const it = testEffect(LayerNode.compile(LayerNode.group([Provider.node, Env.node, Plugin.node]))) const experimentalModels = testEffect(providerLayer({ enableExperimentalModels: true })) @@ -805,6 +807,42 @@ it.instance("getSmallModel skips inferred models for Azure Cognitive Services", }), ) +it.instance("Azure Cognitive Services resolves OAuth resource endpoints by model shape", () => + Effect.gen(function* () { + yield* setProcessEnv( + "OPENCODE_AUTH_CONTENT", + JSON.stringify({ + "azure-cognitive-services": { + type: "oauth", + refresh: "refresh-token", + access: "access-token", + expires: Date.now() + 60_000, + accountId: "oauth-resource", + }, + }), + ) + const provider = yield* Provider.Service + + const opus = yield* provider.getModel( + ProviderV2.ID.make("azure-cognitive-services"), + 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.make("azure-cognitive-services"), 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.make("azure-cognitive-services"), ModelV2.ID.make("gpt-5.1")) + expect(languageURL(yield* provider.getLanguage(gpt), "/responses")).toBe( + "https://oauth-resource.cognitiveservices.azure.com/openai/v1/responses", + ) + }), +) + 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 d3c541311ffe..14a699910cd4 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -414,9 +414,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. @@ -430,7 +428,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 @@ -439,7 +449,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 @@ -451,7 +461,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 @@ -461,9 +471,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. @@ -477,7 +485,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 @@ -486,7 +506,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 @@ -498,7 +518,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