From d9312ac2444abd8d7d9848703c080533d72e0b14 Mon Sep 17 00:00:00 2001 From: haxudev Date: Thu, 19 Mar 2026 10:28:49 +0800 Subject: [PATCH] feat: add Azure AI Foundry provider with Entra ID (az login) auth - New extension: extensions/azure-foundry/ - Two auth methods: Entra ID (az login --use-device-code) and API key - Entra ID: dynamic token refresh via prepareRuntimeAuth hook - Smart subscription selection (0/1/multiple, filters disabled) - Connection test during onboard - Zero modification to existing files --- extensions/azure-foundry/index.ts | 363 ++++++++++++++++++ extensions/azure-foundry/openclaw.plugin.json | 38 ++ extensions/azure-foundry/package.json | 12 + 3 files changed, 413 insertions(+) create mode 100644 extensions/azure-foundry/index.ts create mode 100644 extensions/azure-foundry/openclaw.plugin.json create mode 100644 extensions/azure-foundry/package.json diff --git a/extensions/azure-foundry/index.ts b/extensions/azure-foundry/index.ts new file mode 100644 index 00000000000..7fec817b4ed --- /dev/null +++ b/extensions/azure-foundry/index.ts @@ -0,0 +1,363 @@ +import { execSync, spawn } from "node:child_process"; +import { definePluginEntry, type ProviderAuthContext } from "openclaw/plugin-sdk/core"; +import { + applyAuthProfileConfig, + createProviderApiKeyAuthMethod, + upsertAuthProfile, + type ProviderAuthResult, +} from "openclaw/plugin-sdk/provider-auth"; + +const PROVIDER_ID = "azure-foundry"; +const DEFAULT_API = "openai-completions"; +const COGNITIVE_SERVICES_RESOURCE = "https://cognitiveservices.azure.com"; +const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000; // refresh 5 min before expiry + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function execCmd(cmd: string): string { + return execSync(cmd, { encoding: "utf-8", timeout: 30_000 }).trim(); +} + +function isAzCliInstalled(): boolean { + try { + execCmd("which az"); + return true; + } catch { + return false; + } +} + +interface AzAccount { + name: string; + id: string; + user?: { name?: string }; + state?: string; + isDefault?: boolean; +} + +function getLoggedInAccount(): AzAccount | null { + try { + const raw = execCmd("az account show --output json"); + return JSON.parse(raw) as AzAccount; + } catch { + return null; + } +} + +function listSubscriptions(): AzAccount[] { + const raw = execCmd("az account list --output json --all"); + const subs = JSON.parse(raw) as AzAccount[]; + return subs.filter((s) => s.state === "Enabled"); +} + +interface AzAccessToken { + accessToken: string; + expiresOn?: string; +} + +function getAccessTokenResult(): AzAccessToken { + const raw = execCmd( + `az account get-access-token --resource ${COGNITIVE_SERVICES_RESOURCE} --output json`, + ); + return JSON.parse(raw) as AzAccessToken; +} + +function buildAzureBaseUrl(endpoint: string, modelId: string): string { + const base = endpoint.replace(/\/+$/, ""); + if (base.includes("/openai/deployments/")) return base; + return `${base}/openai/deployments/${modelId}`; +} + +/** + * Interactive az login using device-code flow. + * Spawns az login so terminal output (device code URL) is visible to user. + */ +async function azLoginDeviceCode(): Promise { + return new Promise((resolve, reject) => { + const child = spawn("az", ["login", "--use-device-code"], { + stdio: "inherit", + }); + child.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`az login exited with code ${code}`)); + }); + child.on("error", reject); + }); +} + +// --------------------------------------------------------------------------- +// Entra ID auth method +// --------------------------------------------------------------------------- + +const entraIdAuthMethod = { + id: "entra-id", + label: "Entra ID (az login)", + hint: "Use your Azure login — no API key needed", + kind: "custom" as const, + wizard: { + choiceId: "azure-foundry-entra", + choiceLabel: "Azure AI Foundry (Entra ID / az login)", + choiceHint: "Use your Azure login — no API key needed", + groupId: "azure-foundry", + groupLabel: "Microsoft Azure AI Foundry", + groupHint: "Entra ID + API key", + }, + run: async (ctx: ProviderAuthContext): Promise => { + // 1. Check az CLI + if (!isAzCliInstalled()) { + throw new Error( + "Azure CLI (az) is not installed.\n" + + "Install it from https://learn.microsoft.com/cli/azure/install-azure-cli", + ); + } + + // 2. Check login status + let account = getLoggedInAccount(); + if (account) { + const useExisting = await ctx.prompter.confirm({ + message: `Already logged in as ${account.user?.name ?? "unknown"} (${account.name}). Use this account?`, + initialValue: true, + }); + if (!useExisting) { + await azLoginDeviceCode(); + account = getLoggedInAccount(); + if (!account) throw new Error("Failed to get account after login."); + } + } else { + await ctx.prompter.note( + "You need to log in to Azure. A device code will be displayed — follow the instructions.", + "Azure Login", + ); + await azLoginDeviceCode(); + account = getLoggedInAccount(); + if (!account) throw new Error("Failed to get account after login."); + } + + // 3. List and select subscription + const subs = listSubscriptions(); + if (subs.length === 0) { + throw new Error("No enabled Azure subscriptions found. Please check your Azure account."); + } + + let selectedSub: AzAccount; + if (subs.length === 1) { + selectedSub = subs[0]!; + await ctx.prompter.note( + `Using subscription: ${selectedSub.name} (${selectedSub.id})`, + "Subscription", + ); + } else { + const choices = subs.map((s) => ({ + value: s.id, + label: `${s.name} (${s.id})`, + })); + const selectedId = await ctx.prompter.select({ + message: "Select Azure subscription", + options: choices, + }); + selectedSub = subs.find((s) => s.id === selectedId)!; + } + + // 4. Set subscription + execCmd(`az account set --subscription "${selectedSub.id}"`); + + // 5. Ask endpoint URL + const endpoint = String( + await ctx.prompter.text({ + message: "Azure AI Foundry endpoint URL", + placeholder: "https://xxx.openai.azure.com or https://xxx.services.ai.azure.com", + validate: (v) => { + const val = String(v ?? "").trim(); + if (!val) return "Endpoint URL is required"; + try { + new URL(val); + } catch { + return "Invalid URL"; + } + return undefined; + }, + }), + ).trim(); + + // 6. Ask model ID + const modelId = String( + await ctx.prompter.text({ + message: "Default model/deployment name", + placeholder: "gpt-4o", + validate: (v) => { + const val = String(v ?? "").trim(); + if (!val) return "Model ID is required"; + return undefined; + }, + }), + ).trim(); + + // 7. Test connection + try { + const { accessToken } = getAccessTokenResult(); + const testUrl = `${buildAzureBaseUrl(endpoint, modelId)}/chat/completions?api-version=2024-12-01-preview`; + const res = await fetch(testUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + messages: [{ role: "user", content: "hi" }], + max_tokens: 1, + }), + }); + if (!res.ok && res.status !== 400) { + const body = await res.text().catch(() => ""); + await ctx.prompter.note( + `Warning: test request returned ${res.status}. ${body.slice(0, 200)}\nProceeding anyway — you can fix the endpoint later.`, + "Connection Test", + ); + } else { + await ctx.prompter.note("Connection test successful!", "✓"); + } + } catch (err) { + await ctx.prompter.note( + `Warning: connection test failed: ${String(err)}\nProceeding anyway.`, + "Connection Test", + ); + } + + // 8. Build result — store a placeholder key; prepareRuntimeAuth will + // replace it with a fresh Entra ID token at request time. + const profileId = `${PROVIDER_ID}:entra`; + + return { + profiles: [ + { + profileId, + credential: { + type: "api_key", + provider: PROVIDER_ID, + // Placeholder — prepareRuntimeAuth refreshes this dynamically. + key: "__entra_id_dynamic__", + metadata: { + authMethod: "entra-id", + subscriptionId: selectedSub.id, + subscriptionName: selectedSub.name, + endpoint, + }, + }, + }, + ], + configPatch: { + models: { + providers: { + [PROVIDER_ID]: { + baseUrl: buildAzureBaseUrl(endpoint, modelId), + api: DEFAULT_API, + models: [ + { + id: modelId, + name: modelId, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }, + ], + }, + }, + }, + }, + defaultModel: `${PROVIDER_ID}/${modelId}`, + notes: [ + `Subscription: ${selectedSub.name}`, + `Endpoint: ${endpoint}`, + `Model: ${modelId}`, + "Token is refreshed automatically via az CLI — keep az login active.", + ], + }; + }, +}; + +// --------------------------------------------------------------------------- +// API Key auth method +// --------------------------------------------------------------------------- + +const apiKeyAuthMethod = createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "Azure OpenAI API key", + hint: "Direct Azure OpenAI API key", + optionKey: "azureOpenaiApiKey", + flagName: "--azure-openai-api-key", + envVar: "AZURE_OPENAI_API_KEY", + promptMessage: "Enter Azure OpenAI API key", + defaultModel: `${PROVIDER_ID}/gpt-4o`, + expectedProviders: [PROVIDER_ID], + wizard: { + choiceId: "azure-foundry-apikey", + choiceLabel: "Azure AI Foundry (API key)", + groupId: "azure-foundry", + groupLabel: "Microsoft Azure AI Foundry", + groupHint: "Entra ID + API key", + }, +}); + +// --------------------------------------------------------------------------- +// Token cache for prepareRuntimeAuth +// --------------------------------------------------------------------------- + +let cachedToken: { token: string; expiresAt: number } | null = null; + +function refreshEntraToken(): { apiKey: string; expiresAt: number } { + const result = getAccessTokenResult(); + const expiresAt = result.expiresOn + ? new Date(result.expiresOn).getTime() + : Date.now() + 55 * 60 * 1000; // default ~55 min + cachedToken = { token: result.accessToken, expiresAt }; + return { apiKey: result.accessToken, expiresAt }; +} + +// --------------------------------------------------------------------------- +// Plugin entry +// --------------------------------------------------------------------------- + +export default definePluginEntry({ + id: PROVIDER_ID, + name: "Azure AI Foundry Provider", + description: "Azure AI Foundry provider with Entra ID and API key auth", + register(api) { + api.registerProvider({ + id: PROVIDER_ID, + label: "Azure AI Foundry", + docsPath: "/providers/azure", + envVars: ["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT"], + auth: [entraIdAuthMethod, apiKeyAuthMethod], + capabilities: { + providerFamily: "openai", + }, + prepareRuntimeAuth: async (ctx) => { + // Only intercept Entra ID auth (placeholder key). + // API key users pass through unchanged. + if (ctx.apiKey !== "__entra_id_dynamic__") { + return null; // let default handling apply + } + + // Return cached token if still valid + if (cachedToken && cachedToken.expiresAt > Date.now() + TOKEN_REFRESH_MARGIN_MS) { + return { apiKey: cachedToken.token, expiresAt: cachedToken.expiresAt }; + } + + // Refresh via az CLI + try { + return refreshEntraToken(); + } catch (err) { + throw new Error( + `Failed to refresh Azure Entra ID token via az CLI: ${String(err)}\n` + + "Make sure you are logged in: az login --use-device-code", + ); + } + }, + }); + }, +}); diff --git a/extensions/azure-foundry/openclaw.plugin.json b/extensions/azure-foundry/openclaw.plugin.json new file mode 100644 index 00000000000..92c3000cead --- /dev/null +++ b/extensions/azure-foundry/openclaw.plugin.json @@ -0,0 +1,38 @@ +{ + "id": "azure-foundry", + "providers": ["azure-foundry"], + "providerAuthEnvVars": { + "azure-foundry": ["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT"] + }, + "providerAuthChoices": [ + { + "provider": "azure-foundry", + "method": "entra-id", + "choiceId": "azure-foundry-entra", + "choiceLabel": "Azure AI Foundry (Entra ID / az login)", + "choiceHint": "Use your Azure login — no API key needed", + "groupId": "azure-foundry", + "groupLabel": "Microsoft Azure AI Foundry", + "groupHint": "Entra ID + API key" + }, + { + "provider": "azure-foundry", + "method": "api-key", + "choiceId": "azure-foundry-apikey", + "choiceLabel": "Azure AI Foundry (API key)", + "choiceHint": "Use an Azure OpenAI API key directly", + "groupId": "azure-foundry", + "groupLabel": "Microsoft Azure AI Foundry", + "groupHint": "Entra ID + API key", + "optionKey": "azureOpenaiApiKey", + "cliFlag": "--azure-openai-api-key", + "cliOption": "--azure-openai-api-key ", + "cliDescription": "Azure OpenAI API key" + } + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/azure-foundry/package.json b/extensions/azure-foundry/package.json new file mode 100644 index 00000000000..5e4d90bac7f --- /dev/null +++ b/extensions/azure-foundry/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/azure-foundry", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Azure AI Foundry provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +}