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
This commit is contained in:
parent
b965ef3802
commit
d9312ac244
363
extensions/azure-foundry/index.ts
Normal file
363
extensions/azure-foundry/index.ts
Normal file
@ -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<void> {
|
||||
return new Promise<void>((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<ProviderAuthResult> => {
|
||||
// 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",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
38
extensions/azure-foundry/openclaw.plugin.json
Normal file
38
extensions/azure-foundry/openclaw.plugin.json
Normal file
@ -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 <key>",
|
||||
"cliDescription": "Azure OpenAI API key"
|
||||
}
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
12
extensions/azure-foundry/package.json
Normal file
12
extensions/azure-foundry/package.json
Normal file
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user