From d9312ac2444abd8d7d9848703c080533d72e0b14 Mon Sep 17 00:00:00 2001 From: haxudev Date: Thu, 19 Mar 2026 10:28:49 +0800 Subject: [PATCH 01/12] 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" + ] + } +} From a0c6a614dcfd6adffa937aaab462aab4434b1b6d Mon Sep 17 00:00:00 2001 From: haxudev Date: Thu, 19 Mar 2026 11:08:10 +0800 Subject: [PATCH 02/12] =?UTF-8?q?rename:=20azure-foundry=20=E2=86=92=20mic?= =?UTF-8?q?rosoft-foundry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebrand to match Microsoft Foundry product name. Provider ID: microsoft-foundry All labels, group IDs, and references updated. --- .../index.ts | 26 +++++++++---------- .../openclaw.plugin.json | 26 +++++++++---------- .../package.json | 4 +-- 3 files changed, 28 insertions(+), 28 deletions(-) rename extensions/{azure-foundry => microsoft-foundry}/index.ts (95%) rename extensions/{azure-foundry => microsoft-foundry}/openclaw.plugin.json (52%) rename extensions/{azure-foundry => microsoft-foundry}/package.json (56%) diff --git a/extensions/azure-foundry/index.ts b/extensions/microsoft-foundry/index.ts similarity index 95% rename from extensions/azure-foundry/index.ts rename to extensions/microsoft-foundry/index.ts index 7fec817b4ed..a6f14b7c04d 100644 --- a/extensions/azure-foundry/index.ts +++ b/extensions/microsoft-foundry/index.ts @@ -7,7 +7,7 @@ import { type ProviderAuthResult, } from "openclaw/plugin-sdk/provider-auth"; -const PROVIDER_ID = "azure-foundry"; +const PROVIDER_ID = "microsoft-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 @@ -97,11 +97,11 @@ const entraIdAuthMethod = { 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)", + choiceId: "microsoft-foundry-entra", + choiceLabel: "Microsoft Foundry (Entra ID / az login)", choiceHint: "Use your Azure login — no API key needed", - groupId: "azure-foundry", - groupLabel: "Microsoft Azure AI Foundry", + groupId: "microsoft-foundry", + groupLabel: "Microsoft Foundry", groupHint: "Entra ID + API key", }, run: async (ctx: ProviderAuthContext): Promise => { @@ -166,7 +166,7 @@ const entraIdAuthMethod = { // 5. Ask endpoint URL const endpoint = String( await ctx.prompter.text({ - message: "Azure AI Foundry endpoint URL", + message: "Microsoft Foundry endpoint URL", placeholder: "https://xxx.openai.azure.com or https://xxx.services.ai.azure.com", validate: (v) => { const val = String(v ?? "").trim(); @@ -295,10 +295,10 @@ const apiKeyAuthMethod = createProviderApiKeyAuthMethod({ 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", + choiceId: "microsoft-foundry-apikey", + choiceLabel: "Microsoft Foundry (API key)", + groupId: "microsoft-foundry", + groupLabel: "Microsoft Foundry", groupHint: "Entra ID + API key", }, }); @@ -324,12 +324,12 @@ function refreshEntraToken(): { apiKey: string; expiresAt: number } { export default definePluginEntry({ id: PROVIDER_ID, - name: "Azure AI Foundry Provider", - description: "Azure AI Foundry provider with Entra ID and API key auth", + name: "Microsoft Foundry Provider", + description: "Microsoft Foundry provider with Entra ID and API key auth", register(api) { api.registerProvider({ id: PROVIDER_ID, - label: "Azure AI Foundry", + label: "Microsoft Foundry", docsPath: "/providers/azure", envVars: ["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT"], auth: [entraIdAuthMethod, apiKeyAuthMethod], diff --git a/extensions/azure-foundry/openclaw.plugin.json b/extensions/microsoft-foundry/openclaw.plugin.json similarity index 52% rename from extensions/azure-foundry/openclaw.plugin.json rename to extensions/microsoft-foundry/openclaw.plugin.json index 92c3000cead..772b5bdefb9 100644 --- a/extensions/azure-foundry/openclaw.plugin.json +++ b/extensions/microsoft-foundry/openclaw.plugin.json @@ -1,28 +1,28 @@ { - "id": "azure-foundry", - "providers": ["azure-foundry"], + "id": "microsoft-foundry", + "providers": ["microsoft-foundry"], "providerAuthEnvVars": { - "azure-foundry": ["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT"] + "microsoft-foundry": ["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT"] }, "providerAuthChoices": [ { - "provider": "azure-foundry", + "provider": "microsoft-foundry", "method": "entra-id", - "choiceId": "azure-foundry-entra", - "choiceLabel": "Azure AI Foundry (Entra ID / az login)", + "choiceId": "microsoft-foundry-entra", + "choiceLabel": "Microsoft Foundry (Entra ID / az login)", "choiceHint": "Use your Azure login — no API key needed", - "groupId": "azure-foundry", - "groupLabel": "Microsoft Azure AI Foundry", + "groupId": "microsoft-foundry", + "groupLabel": "Microsoft Foundry", "groupHint": "Entra ID + API key" }, { - "provider": "azure-foundry", + "provider": "microsoft-foundry", "method": "api-key", - "choiceId": "azure-foundry-apikey", - "choiceLabel": "Azure AI Foundry (API key)", + "choiceId": "microsoft-foundry-apikey", + "choiceLabel": "Microsoft Foundry (API key)", "choiceHint": "Use an Azure OpenAI API key directly", - "groupId": "azure-foundry", - "groupLabel": "Microsoft Azure AI Foundry", + "groupId": "microsoft-foundry", + "groupLabel": "Microsoft Foundry", "groupHint": "Entra ID + API key", "optionKey": "azureOpenaiApiKey", "cliFlag": "--azure-openai-api-key", diff --git a/extensions/azure-foundry/package.json b/extensions/microsoft-foundry/package.json similarity index 56% rename from extensions/azure-foundry/package.json rename to extensions/microsoft-foundry/package.json index 5e4d90bac7f..a69f5eada6a 100644 --- a/extensions/azure-foundry/package.json +++ b/extensions/microsoft-foundry/package.json @@ -1,8 +1,8 @@ { - "name": "@openclaw/azure-foundry", + "name": "@openclaw/microsoft-foundry", "version": "2026.3.14", "private": true, - "description": "OpenClaw Azure AI Foundry provider plugin", + "description": "OpenClaw Microsoft Foundry provider plugin", "type": "module", "openclaw": { "extensions": [ From 0c9c874241e07248a2deae324631a20b2c06fe18 Mon Sep 17 00:00:00 2001 From: haxudev Date: Thu, 19 Mar 2026 11:31:06 +0800 Subject: [PATCH 03/12] fix: use cross-platform az CLI detection (az version instead of which) --- extensions/microsoft-foundry/index.ts | 3 ++- pnpm-lock.yaml | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/extensions/microsoft-foundry/index.ts b/extensions/microsoft-foundry/index.ts index a6f14b7c04d..6acc3ec2f36 100644 --- a/extensions/microsoft-foundry/index.ts +++ b/extensions/microsoft-foundry/index.ts @@ -22,7 +22,8 @@ function execCmd(cmd: string): string { function isAzCliInstalled(): boolean { try { - execCmd("which az"); + // "az version" works on Windows, Linux, and macOS + execCmd("az version --output none"); return true; } catch { return false; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e381cdf6d34..fa47f57f1a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -244,6 +244,8 @@ importers: extensions/anthropic: {} + extensions/azure-foundry: {} + extensions/bluebubbles: dependencies: zod: From ec89a8efee15832a34498f92b6674dad2226ad0b Mon Sep 17 00:00:00 2001 From: haxudev Date: Thu, 19 Mar 2026 14:25:13 +0800 Subject: [PATCH 04/12] Microsoft Foundry: streamline onboarding and GPT-5 runtime Handle Windows az device-code login, tenant fallback, and Azure resource/deployment discovery so onboard can reuse existing Microsoft Foundry setups without manual endpoint entry. Normalize GPT-5 deployments onto the Foundry responses base URL at selection and runtime auth time, and fall back to hard links for Windows plugin staging so local builds and chats work end-to-end. --- CHANGELOG.md | 1 + extensions/microsoft-foundry/index.ts | 633 ++++++++++++++++++++--- scripts/stage-bundled-plugin-runtime.mjs | 21 +- 3 files changed, 577 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfa7100d461..973a2f19db9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai - Control UI/chat: add an expand-to-canvas button on assistant chat bubbles and in-app session navigation from Sessions and Cron views. Thanks @BunsDev. - Plugins/context engines: expose `delegateCompactionToRuntime(...)` on the public plugin SDK, refactor the legacy engine to use the shared helper, and clarify `ownsCompaction` delegation semantics for non-owning engines. (#49061) Thanks @jalehman. - Plugins/MiniMax: add MiniMax-M2.7 and MiniMax-M2.7-highspeed models and update the default model from M2.5 to M2.7. (#49691) Thanks @liyuan97. +- Onboarding/Microsoft Foundry: improve Entra ID setup with Windows-safe `az login` device-code launch, tenant-scoped fallback when Azure returns no default subscriptions, and automatic Azure AI Foundry/Azure OpenAI resource plus deployment discovery so users can pick existing endpoints instead of typing them manually. If no compatible resource or deployment exists, onboard now explains that it must be created first. ### Fixes diff --git a/extensions/microsoft-foundry/index.ts b/extensions/microsoft-foundry/index.ts index 6acc3ec2f36..8cba700163a 100644 --- a/extensions/microsoft-foundry/index.ts +++ b/extensions/microsoft-foundry/index.ts @@ -1,14 +1,21 @@ import { execSync, spawn } from "node:child_process"; -import { definePluginEntry, type ProviderAuthContext } from "openclaw/plugin-sdk/core"; +import { + definePluginEntry, + type ProviderAuthContext, +} from "openclaw/plugin-sdk/core"; import { applyAuthProfileConfig, createProviderApiKeyAuthMethod, + ensureAuthProfileStore, upsertAuthProfile, type ProviderAuthResult, } from "openclaw/plugin-sdk/provider-auth"; +import type { ModelCompatConfig, ModelProviderConfig } from "../../src/config/types.models.js"; +import type { ProviderModelSelectedContext } from "../../src/plugins/types.js"; const PROVIDER_ID = "microsoft-foundry"; const DEFAULT_API = "openai-completions"; +const DEFAULT_GPT5_API = "openai-responses"; const COGNITIVE_SERVICES_RESOURCE = "https://cognitiveservices.azure.com"; const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000; // refresh 5 min before expiry @@ -33,6 +40,7 @@ function isAzCliInstalled(): boolean { interface AzAccount { name: string; id: string; + tenantId?: string; user?: { name?: string }; state?: string; isDefault?: boolean; @@ -48,9 +56,13 @@ function getLoggedInAccount(): AzAccount | 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"); + try { + const raw = execCmd("az account list --output json --all"); + const subs = JSON.parse(raw) as AzAccount[]; + return subs.filter((s) => s.state === "Enabled"); + } catch { + return []; + } } interface AzAccessToken { @@ -58,27 +70,327 @@ interface AzAccessToken { expiresOn?: string; } -function getAccessTokenResult(): AzAccessToken { +interface AzCognitiveAccount { + id: string; + name: string; + kind: string; + location?: string; + resourceGroup?: string; + endpoint?: string | null; + customSubdomain?: string | null; + projects?: string[] | null; +} + +interface FoundryResourceOption { + id: string; + accountName: string; + kind: "AIServices" | "OpenAI"; + location?: string; + resourceGroup: string; + endpoint: string; + projects: string[]; +} + +interface AzDeploymentSummary { + name: string; + modelName?: string; + modelVersion?: string; + state?: string; + sku?: string; +} + +type FoundryProviderApi = typeof DEFAULT_API | typeof DEFAULT_GPT5_API; + +function quoteShellArg(value: string): string { + return JSON.stringify(value); +} + +function getAccessTokenResult(params?: { + subscriptionId?: string; + tenantId?: string; +}): AzAccessToken { + const targetArg = params?.subscriptionId + ? ` --subscription ${JSON.stringify(params.subscriptionId)}` + : params?.tenantId + ? ` --tenant ${JSON.stringify(params.tenantId)}` + : ""; const raw = execCmd( - `az account get-access-token --resource ${COGNITIVE_SERVICES_RESOURCE} --output json`, + `az account get-access-token --resource ${COGNITIVE_SERVICES_RESOURCE} --output json${targetArg}`, ); return JSON.parse(raw) as AzAccessToken; } function buildAzureBaseUrl(endpoint: string, modelId: string): string { - const base = endpoint.replace(/\/+$/, ""); + const base = normalizeFoundryEndpoint(endpoint); if (base.includes("/openai/deployments/")) return base; return `${base}/openai/deployments/${modelId}`; } +function buildFoundryResponsesBaseUrl(endpoint: string): string { + const base = normalizeFoundryEndpoint(endpoint); + return base.endsWith("/openai/v1") ? base : `${base}/openai/v1`; +} + +function normalizeFoundryEndpoint(endpoint: string): string { + const trimmed = endpoint.trim().replace(/\/+$/, ""); + return trimmed.replace(/\/openai(?:\/v1|\/deployments\/[^/]+)?$/i, ""); +} + +function buildFoundryProviderBaseUrl(endpoint: string, modelId: string): string { + return resolveFoundryApi(modelId) === DEFAULT_GPT5_API + ? buildFoundryResponsesBaseUrl(endpoint) + : buildAzureBaseUrl(endpoint, modelId); +} + +function extractFoundryEndpoint(baseUrl: string): string | undefined { + try { + const url = new URL(baseUrl); + return url.origin; + } catch { + return undefined; + } +} + +function isGpt5FamilyDeployment(modelId: string): boolean { + return /^gpt-5(?:$|[-.])/i.test(modelId.trim()); +} + +function resolveFoundryApi(modelId: string): FoundryProviderApi { + return isGpt5FamilyDeployment(modelId) ? DEFAULT_GPT5_API : DEFAULT_API; +} + +function buildFoundryModelCompat(modelId: string): ModelCompatConfig | undefined { + if (!isGpt5FamilyDeployment(modelId)) { + return undefined; + } + return { + maxTokensField: "max_completion_tokens", + }; +} + +function buildFoundryProviderConfig(endpoint: string, modelId: string): ModelProviderConfig { + const compat = buildFoundryModelCompat(modelId); + return { + baseUrl: buildFoundryProviderBaseUrl(endpoint, modelId), + api: resolveFoundryApi(modelId), + models: [ + { + id: modelId, + name: modelId, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + ...(compat ? { compat } : {}), + }, + ], + }; +} + +function normalizeEndpointOrigin(rawUrl: string | null | undefined): string | undefined { + if (!rawUrl) { + return undefined; + } + try { + return new URL(rawUrl).origin; + } catch { + return undefined; + } +} + +function listFoundryResources(): FoundryResourceOption[] { + try { + const raw = execCmd( + 'az cognitiveservices account list --query "[].{id:id,name:name,kind:kind,location:location,resourceGroup:resourceGroup,endpoint:properties.endpoint,customSubdomain:properties.customSubDomainName,projects:properties.associatedProjects}" --output json', + ); + const accounts = JSON.parse(raw) as AzCognitiveAccount[]; + const resources: FoundryResourceOption[] = []; + for (const account of accounts) { + if (!account.resourceGroup) { + continue; + } + if (account.kind === "OpenAI") { + const endpoint = normalizeEndpointOrigin(account.endpoint); + if (!endpoint) { + continue; + } + resources.push({ + id: account.id, + accountName: account.name, + kind: "OpenAI", + location: account.location, + resourceGroup: account.resourceGroup, + endpoint, + projects: [], + }); + continue; + } + if (account.kind !== "AIServices") { + continue; + } + const endpoint = account.customSubdomain?.trim() + ? `https://${account.customSubdomain.trim()}.services.ai.azure.com` + : undefined; + if (!endpoint) { + continue; + } + resources.push({ + id: account.id, + accountName: account.name, + kind: "AIServices", + location: account.location, + resourceGroup: account.resourceGroup, + endpoint, + projects: Array.isArray(account.projects) + ? account.projects.filter((project): project is string => typeof project === "string") + : [], + }); + } + return resources; + } catch { + return []; + } +} + +function listResourceDeployments(resource: FoundryResourceOption): AzDeploymentSummary[] { + try { + const raw = execCmd( + `az cognitiveservices account deployment list -g ${quoteShellArg(resource.resourceGroup)} -n ${quoteShellArg(resource.accountName)} --query "[].{name:name,modelName:properties.model.name,modelVersion:properties.model.version,state:properties.provisioningState,sku:sku.name}" --output json`, + ); + const deployments = JSON.parse(raw) as AzDeploymentSummary[]; + return deployments.filter((deployment) => deployment.state === "Succeeded"); + } catch { + return []; + } +} + +async function selectFoundryResource( + ctx: ProviderAuthContext, + selectedSub: AzAccount, +): Promise { + const resources = listFoundryResources(); + if (resources.length === 0) { + throw new Error(buildCreateFoundryHint(selectedSub)); + } + if (resources.length === 1) { + const only = resources[0]!; + await ctx.prompter.note( + `Using ${only.kind === "AIServices" ? "Azure AI Foundry" : "Azure OpenAI"} resource: ${only.accountName}`, + "Foundry Resource", + ); + return only; + } + const selectedResourceId = await ctx.prompter.select({ + message: "Select Azure AI Foundry / Azure OpenAI resource", + options: resources.map((resource) => ({ + value: resource.id, + label: `${resource.accountName} (${resource.kind === "AIServices" ? "Azure AI Foundry" : "Azure OpenAI"}${resource.location ? `, ${resource.location}` : ""})`, + hint: [ + `RG: ${resource.resourceGroup}`, + resource.projects.length > 0 ? `${resource.projects.length} project(s)` : undefined, + ] + .filter(Boolean) + .join(" | "), + })), + }); + return resources.find((resource) => resource.id === selectedResourceId) ?? resources[0]!; +} + +async function selectFoundryDeployment( + ctx: ProviderAuthContext, + resource: FoundryResourceOption, +): Promise { + const deployments = listResourceDeployments(resource); + if (deployments.length === 0) { + throw new Error( + [ + `No model deployments were found in ${resource.accountName}.`, + "Deploy a model in Azure AI Foundry or Azure OpenAI, then rerun onboard.", + ].join("\n"), + ); + } + if (deployments.length === 1) { + const only = deployments[0]!; + await ctx.prompter.note(`Using deployment: ${only.name}`, "Model Deployment"); + return only; + } + const selectedDeploymentName = await ctx.prompter.select({ + message: "Select model deployment", + options: deployments.map((deployment) => ({ + value: deployment.name, + label: deployment.name, + hint: [deployment.modelName, deployment.modelVersion, deployment.sku].filter(Boolean).join(" | "), + })), + }); + return deployments.find((deployment) => deployment.name === selectedDeploymentName) ?? deployments[0]!; +} + +function buildCreateFoundryHint(selectedSub: AzAccount): string { + return [ + `No Azure AI Foundry or Azure OpenAI resources were found in subscription ${selectedSub.name} (${selectedSub.id}).`, + "Create one in Azure AI Foundry or Azure Portal, then rerun onboard.", + "Azure AI Foundry: https://ai.azure.com", + "Azure OpenAI docs: https://learn.microsoft.com/azure/ai-foundry/openai/how-to/create-resource", + ].join("\n"); +} + +async function promptEndpointAndModelManually(ctx: ProviderAuthContext): Promise<{ + endpoint: string; + modelId: string; +}> { + const endpoint = String( + await ctx.prompter.text({ + message: "Microsoft 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(); + 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(); + return { endpoint, 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 azLoginDeviceCodeWithOptions({}); +} + +async function azLoginDeviceCodeWithOptions(params: { + tenantId?: string; + allowNoSubscriptions?: boolean; +}): Promise { return new Promise((resolve, reject) => { - const child = spawn("az", ["login", "--use-device-code"], { + const args = [ + "login", + "--use-device-code", + ...(params.tenantId ? ["--tenant", params.tenantId] : []), + ...(params.allowNoSubscriptions ? ["--allow-no-subscriptions"] : []), + ]; + const child = spawn("az", args, { stdio: "inherit", + shell: process.platform === "win32", }); child.on("close", (code) => { if (code === 0) resolve(); @@ -88,6 +400,92 @@ async function azLoginDeviceCode(): Promise { }); } +function extractTenantSuggestions(rawMessage: string): Array<{ id: string; label?: string }> { + const suggestions: Array<{ id: string; label?: string }> = []; + const seen = new Set(); + const regex = /([0-9a-fA-F-]{36})(?:\s+'([^'\r\n]+)')?/g; + for (const match of rawMessage.matchAll(regex)) { + const id = match[1]?.trim(); + if (!id || seen.has(id)) { + continue; + } + seen.add(id); + suggestions.push({ + id, + ...(match[2]?.trim() ? { label: match[2].trim() } : {}), + }); + } + return suggestions; +} + +async function promptTenantId( + ctx: ProviderAuthContext, + params?: { + suggestions?: Array<{ id: string; label?: string }>; + required?: boolean; + reason?: string; + }, +): Promise { + const suggestionLines = + params?.suggestions && params.suggestions.length > 0 + ? params.suggestions.map((entry) => `- ${entry.id}${entry.label ? ` (${entry.label})` : ""}`) + : []; + if (params?.reason || suggestionLines.length > 0) { + await ctx.prompter.note( + [ + params?.reason, + suggestionLines.length > 0 ? "Suggested tenants:" : undefined, + ...suggestionLines, + ] + .filter(Boolean) + .join("\n"), + "Azure Tenant", + ); + } + const tenantId = String( + await ctx.prompter.text({ + message: params?.required + ? "Azure tenant ID" + : "Azure tenant ID (optional)", + placeholder: params?.suggestions?.[0]?.id ?? "00000000-0000-0000-0000-000000000000", + validate: (value) => { + const trimmed = String(value ?? "").trim(); + if (!trimmed) { + return params?.required ? "Tenant ID is required" : undefined; + } + return /^[0-9a-fA-F-]{36}$/.test(trimmed) ? undefined : "Enter a valid tenant ID"; + }, + }), + ).trim(); + return tenantId || undefined; +} + +async function loginWithTenantFallback(ctx: ProviderAuthContext): Promise<{ + account: AzAccount | null; + tenantId?: string; +}> { + try { + await azLoginDeviceCode(); + return { account: getLoggedInAccount() }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const tenantId = await promptTenantId(ctx, { + suggestions: extractTenantSuggestions(message), + required: true, + reason: + "Azure login needs a tenant-scoped retry. This often happens when your tenant requires MFA or your account has no Azure subscriptions.", + }); + await azLoginDeviceCodeWithOptions({ + tenantId, + allowNoSubscriptions: true, + }); + return { + account: getLoggedInAccount(), + tenantId, + }; + } +} + // --------------------------------------------------------------------------- // Entra ID auth method // --------------------------------------------------------------------------- @@ -116,35 +514,43 @@ const entraIdAuthMethod = { // 2. Check login status let account = getLoggedInAccount(); + let tenantId = account?.tenantId; 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."); + const loginResult = await loginWithTenantFallback(ctx); + account = loginResult.account; + tenantId = loginResult.tenantId ?? loginResult.account?.tenantId; } } 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."); + const loginResult = await loginWithTenantFallback(ctx); + account = loginResult.account; + tenantId = loginResult.tenantId ?? loginResult.account?.tenantId; } // 3. List and select subscription const subs = listSubscriptions(); + let selectedSub: AzAccount | null = null; if (subs.length === 0) { - throw new Error("No enabled Azure subscriptions found. Please check your Azure account."); - } - - let selectedSub: AzAccount; - if (subs.length === 1) { + tenantId ??= await promptTenantId(ctx, { + required: true, + reason: + "No enabled Azure subscriptions were found. Continue with tenant-scoped Entra ID auth instead.", + }); + await ctx.prompter.note( + `Continuing with tenant-scoped auth (${tenantId}).`, + "Azure Tenant", + ); + } else if (subs.length === 1) { selectedSub = subs[0]!; + tenantId ??= selectedSub.tenantId; await ctx.prompter.note( `Using subscription: ${selectedSub.name} (${selectedSub.id})`, "Subscription", @@ -159,45 +565,48 @@ const entraIdAuthMethod = { options: choices, }); selectedSub = subs.find((s) => s.id === selectedId)!; + tenantId ??= selectedSub.tenantId; } // 4. Set subscription - execCmd(`az account set --subscription "${selectedSub.id}"`); + if (selectedSub) { + execCmd(`az account set --subscription "${selectedSub.id}"`); + } - // 5. Ask endpoint URL - const endpoint = String( - await ctx.prompter.text({ - message: "Microsoft 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(); + // 5. Discover resource + deployment when possible + let endpoint: string; + let modelId: string; + if (selectedSub) { + const useDiscoveredResource = await ctx.prompter.confirm({ + message: "Discover Microsoft Foundry resources from this subscription?", + initialValue: true, + }); + if (useDiscoveredResource) { + const selectedResource = await selectFoundryResource(ctx, selectedSub); + const selectedDeployment = await selectFoundryDeployment(ctx, selectedResource); + endpoint = selectedResource.endpoint; + modelId = selectedDeployment.name; + await ctx.prompter.note( + [ + `Resource: ${selectedResource.accountName}`, + `Endpoint: ${endpoint}`, + `Deployment: ${modelId}`, + ].join("\n"), + "Microsoft Foundry", + ); + } else { + ({ endpoint, modelId } = await promptEndpointAndModelManually(ctx)); + } + } else { + ({ endpoint, modelId } = await promptEndpointAndModelManually(ctx)); + } // 7. Test connection try { - const { accessToken } = getAccessTokenResult(); + const { accessToken } = getAccessTokenResult({ + subscriptionId: selectedSub?.id, + tenantId, + }); const testUrl = `${buildAzureBaseUrl(endpoint, modelId)}/chat/completions?api-version=2024-12-01-preview`; const res = await fetch(testUrl, { method: "POST", @@ -241,9 +650,11 @@ const entraIdAuthMethod = { key: "__entra_id_dynamic__", metadata: { authMethod: "entra-id", - subscriptionId: selectedSub.id, - subscriptionName: selectedSub.name, + ...(selectedSub?.id ? { subscriptionId: selectedSub.id } : {}), + ...(selectedSub?.name ? { subscriptionName: selectedSub.name } : {}), + ...(tenantId ? { tenantId } : {}), endpoint, + modelId, }, }, }, @@ -251,33 +662,56 @@ const entraIdAuthMethod = { 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, - }, - ], - }, + [PROVIDER_ID]: buildFoundryProviderConfig(endpoint, modelId), }, }, }, defaultModel: `${PROVIDER_ID}/${modelId}`, notes: [ - `Subscription: ${selectedSub.name}`, + ...(selectedSub?.name ? [`Subscription: ${selectedSub.name}`] : []), + ...(tenantId ? [`Tenant: ${tenantId}`] : []), `Endpoint: ${endpoint}`, `Model: ${modelId}`, "Token is refreshed automatically via az CLI — keep az login active.", ], }; }, + onModelSelected: async (ctx: ProviderModelSelectedContext) => { + const providerConfig = ctx.config.models?.providers?.[PROVIDER_ID]; + if (!providerConfig || !ctx.model.startsWith(`${PROVIDER_ID}/`)) { + return; + } + const selectedModelId = ctx.model.slice(`${PROVIDER_ID}/`.length); + const selectedModelCompat = buildFoundryModelCompat(selectedModelId); + const providerEndpoint = normalizeFoundryEndpoint(providerConfig.baseUrl ?? ""); + const nextProviderConfig: ModelProviderConfig = { + ...providerConfig, + baseUrl: buildFoundryProviderBaseUrl(providerEndpoint, selectedModelId), + api: resolveFoundryApi(selectedModelId), + models: [ + { + ...(providerConfig.models.find((model: { id: string }) => model.id === selectedModelId) ?? { + id: selectedModelId, + name: selectedModelId, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }), + ...(selectedModelCompat ? { compat: selectedModelCompat } : {}), + }, + ], + }; + applyAuthProfileConfig(ctx.config, { + profileId: `${PROVIDER_ID}:entra`, + provider: PROVIDER_ID, + mode: "api_key", + }); + ctx.config.models ??= {}; + ctx.config.models.providers ??= {}; + ctx.config.models.providers[PROVIDER_ID] = nextProviderConfig; + }, }; // --------------------------------------------------------------------------- @@ -310,8 +744,11 @@ const apiKeyAuthMethod = createProviderApiKeyAuthMethod({ let cachedToken: { token: string; expiresAt: number } | null = null; -function refreshEntraToken(): { apiKey: string; expiresAt: number } { - const result = getAccessTokenResult(); +function refreshEntraToken(params?: { + subscriptionId?: string; + tenantId?: string; +}): { apiKey: string; expiresAt: number } { + const result = getAccessTokenResult(params); const expiresAt = result.expiresOn ? new Date(result.expiresOn).getTime() : Date.now() + 55 * 60 * 1000; // default ~55 min @@ -337,6 +774,19 @@ export default definePluginEntry({ capabilities: { providerFamily: "openai", }, + normalizeResolvedModel: ({ modelId, model }) => { + const endpoint = extractFoundryEndpoint(model.baseUrl ?? ""); + if (!endpoint) { + return model; + } + const compat = buildFoundryModelCompat(modelId); + return { + ...model, + api: resolveFoundryApi(modelId), + baseUrl: buildFoundryProviderBaseUrl(endpoint, modelId), + ...(compat ? { compat } : {}), + }; + }, prepareRuntimeAuth: async (ctx) => { // Only intercept Entra ID auth (placeholder key). // API key users pass through unchanged. @@ -345,13 +795,42 @@ export default definePluginEntry({ } // 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(); + const authStore = ensureAuthProfileStore(ctx.agentDir, { + allowKeychainPrompt: false, + }); + const credential = ctx.profileId ? authStore.profiles[ctx.profileId] : undefined; + const metadata = credential?.type === "api_key" ? credential.metadata : undefined; + const modelId = + typeof ctx.modelId === "string" && ctx.modelId.trim().length > 0 + ? ctx.modelId.trim() + : typeof metadata?.modelId === "string" && metadata.modelId.trim().length > 0 + ? metadata.modelId.trim() + : ctx.modelId; + const endpoint = + typeof metadata?.endpoint === "string" && metadata.endpoint.trim().length > 0 + ? metadata.endpoint.trim() + : extractFoundryEndpoint(ctx.model.baseUrl ?? ""); + const baseUrl = endpoint ? buildFoundryProviderBaseUrl(endpoint, modelId) : undefined; + + // Return cached token if still valid + if (cachedToken && cachedToken.expiresAt > Date.now() + TOKEN_REFRESH_MARGIN_MS) { + return { + apiKey: cachedToken.token, + expiresAt: cachedToken.expiresAt, + ...(baseUrl ? { baseUrl } : {}), + }; + } + + // Refresh via az CLI + const token = refreshEntraToken({ + subscriptionId: metadata?.subscriptionId, + tenantId: metadata?.tenantId, + }); + return { + ...token, + ...(baseUrl ? { baseUrl } : {}), + }; } catch (err) { throw new Error( `Failed to refresh Azure Entra ID token via az CLI: ${String(err)}\n` + diff --git a/scripts/stage-bundled-plugin-runtime.mjs b/scripts/stage-bundled-plugin-runtime.mjs index f38f52aa6c5..4735549e160 100644 --- a/scripts/stage-bundled-plugin-runtime.mjs +++ b/scripts/stage-bundled-plugin-runtime.mjs @@ -7,13 +7,32 @@ function symlinkType() { return process.platform === "win32" ? "junction" : "dir"; } +function shouldFallbackToWindowsFileLink(error, type) { + return ( + process.platform === "win32" && + type !== symlinkType() && + error && + typeof error === "object" && + "code" in error && + (error.code === "EPERM" || error.code === "EACCES") + ); +} + function relativeSymlinkTarget(sourcePath, targetPath) { const relativeTarget = path.relative(path.dirname(targetPath), sourcePath); return relativeTarget || "."; } function symlinkPath(sourcePath, targetPath, type) { - fs.symlinkSync(relativeSymlinkTarget(sourcePath, targetPath), targetPath, type); + try { + fs.symlinkSync(relativeSymlinkTarget(sourcePath, targetPath), targetPath, type); + } catch (error) { + if (shouldFallbackToWindowsFileLink(error, type)) { + fs.linkSync(sourcePath, targetPath); + return; + } + throw error; + } } function shouldWrapRuntimeJsFile(sourcePath) { From 27fe74646c8d5fbc218de626e1090357a1e8890a Mon Sep 17 00:00:00 2001 From: haxudev Date: Thu, 19 Mar 2026 16:00:29 +0800 Subject: [PATCH 05/12] Microsoft Foundry: address PR review findings Fix the renamed workspace path in pnpm-lock, make onboarding test and runtime routing respect the underlying model family for GPT-5 deployments, configure the API-key path with provider metadata, replace shell-built az commands with argument-based execution, and scope the Entra token cache by tenant/subscription so the provider behaves correctly across Foundry setups. --- extensions/microsoft-foundry/index.ts | 496 ++++++++++++++++++++------ pnpm-lock.yaml | 2 +- 2 files changed, 389 insertions(+), 109 deletions(-) diff --git a/extensions/microsoft-foundry/index.ts b/extensions/microsoft-foundry/index.ts index 8cba700163a..df0f86b27e5 100644 --- a/extensions/microsoft-foundry/index.ts +++ b/extensions/microsoft-foundry/index.ts @@ -1,14 +1,19 @@ -import { execSync, spawn } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import { definePluginEntry, type ProviderAuthContext, + type ProviderAuthMethod, } from "openclaw/plugin-sdk/core"; import { applyAuthProfileConfig, - createProviderApiKeyAuthMethod, + buildApiKeyCredential, ensureAuthProfileStore, - upsertAuthProfile, + ensureApiKeyFromOptionEnvOrPrompt, + normalizeApiKeyInput, + normalizeOptionalSecretInput, type ProviderAuthResult, + type SecretInput, + validateApiKeyInput, } from "openclaw/plugin-sdk/provider-auth"; import type { ModelCompatConfig, ModelProviderConfig } from "../../src/config/types.models.js"; import type { ProviderModelSelectedContext } from "../../src/plugins/types.js"; @@ -23,14 +28,18 @@ 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 execAz(args: string[]): string { + return execFileSync("az", args, { + encoding: "utf-8", + timeout: 30_000, + shell: process.platform === "win32", + }).trim(); } function isAzCliInstalled(): boolean { try { // "az version" works on Windows, Linux, and macOS - execCmd("az version --output none"); + execAz(["version", "--output", "none"]); return true; } catch { return false; @@ -48,7 +57,7 @@ interface AzAccount { function getLoggedInAccount(): AzAccount | null { try { - const raw = execCmd("az account show --output json"); + const raw = execAz(["account", "show", "--output", "json"]); return JSON.parse(raw) as AzAccount; } catch { return null; @@ -57,7 +66,7 @@ function getLoggedInAccount(): AzAccount | null { function listSubscriptions(): AzAccount[] { try { - const raw = execCmd("az account list --output json --all"); + const raw = execAz(["account", "list", "--output", "json", "--all"]); const subs = JSON.parse(raw) as AzAccount[]; return subs.filter((s) => s.state === "Enabled"); } catch { @@ -99,27 +108,48 @@ interface AzDeploymentSummary { sku?: string; } -type FoundryProviderApi = typeof DEFAULT_API | typeof DEFAULT_GPT5_API; +type FoundrySelection = { + endpoint: string; + modelId: string; + modelNameHint?: string; +}; -function quoteShellArg(value: string): string { - return JSON.stringify(value); -} +type CachedTokenEntry = { + token: string; + expiresAt: number; +}; + +type FoundryProviderApi = typeof DEFAULT_API | typeof DEFAULT_GPT5_API; function getAccessTokenResult(params?: { subscriptionId?: string; tenantId?: string; }): AzAccessToken { - const targetArg = params?.subscriptionId - ? ` --subscription ${JSON.stringify(params.subscriptionId)}` - : params?.tenantId - ? ` --tenant ${JSON.stringify(params.tenantId)}` - : ""; - const raw = execCmd( - `az account get-access-token --resource ${COGNITIVE_SERVICES_RESOURCE} --output json${targetArg}`, - ); + const args = [ + "account", + "get-access-token", + "--resource", + COGNITIVE_SERVICES_RESOURCE, + "--output", + "json", + ]; + if (params?.subscriptionId) { + args.push("--subscription", params.subscriptionId); + } else if (params?.tenantId) { + args.push("--tenant", params.tenantId); + } + const raw = execAz(args); return JSON.parse(raw) as AzAccessToken; } +function isGpt5FamilyName(value?: string | null): boolean { + return typeof value === "string" && /^gpt-5(?:$|[-.])/i.test(value.trim()); +} + +function isGpt5FamilyDeployment(modelId: string, modelNameHint?: string | null): boolean { + return isGpt5FamilyName(modelId) || isGpt5FamilyName(modelNameHint); +} + function buildAzureBaseUrl(endpoint: string, modelId: string): string { const base = normalizeFoundryEndpoint(endpoint); if (base.includes("/openai/deployments/")) return base; @@ -136,8 +166,12 @@ function normalizeFoundryEndpoint(endpoint: string): string { return trimmed.replace(/\/openai(?:\/v1|\/deployments\/[^/]+)?$/i, ""); } -function buildFoundryProviderBaseUrl(endpoint: string, modelId: string): string { - return resolveFoundryApi(modelId) === DEFAULT_GPT5_API +function buildFoundryProviderBaseUrl( + endpoint: string, + modelId: string, + modelNameHint?: string | null, +): string { + return resolveFoundryApi(modelId, modelNameHint) === DEFAULT_GPT5_API ? buildFoundryResponsesBaseUrl(endpoint) : buildAzureBaseUrl(endpoint, modelId); } @@ -151,16 +185,15 @@ function extractFoundryEndpoint(baseUrl: string): string | undefined { } } -function isGpt5FamilyDeployment(modelId: string): boolean { - return /^gpt-5(?:$|[-.])/i.test(modelId.trim()); +function resolveFoundryApi(modelId: string, modelNameHint?: string | null): FoundryProviderApi { + return isGpt5FamilyDeployment(modelId, modelNameHint) ? DEFAULT_GPT5_API : DEFAULT_API; } -function resolveFoundryApi(modelId: string): FoundryProviderApi { - return isGpt5FamilyDeployment(modelId) ? DEFAULT_GPT5_API : DEFAULT_API; -} - -function buildFoundryModelCompat(modelId: string): ModelCompatConfig | undefined { - if (!isGpt5FamilyDeployment(modelId)) { +function buildFoundryModelCompat( + modelId: string, + modelNameHint?: string | null, +): ModelCompatConfig | undefined { + if (!isGpt5FamilyDeployment(modelId, modelNameHint)) { return undefined; } return { @@ -168,15 +201,19 @@ function buildFoundryModelCompat(modelId: string): ModelCompatConfig | undefined }; } -function buildFoundryProviderConfig(endpoint: string, modelId: string): ModelProviderConfig { - const compat = buildFoundryModelCompat(modelId); +function buildFoundryProviderConfig( + endpoint: string, + modelId: string, + modelNameHint?: string | null, +): ModelProviderConfig { + const compat = buildFoundryModelCompat(modelId, modelNameHint); return { - baseUrl: buildFoundryProviderBaseUrl(endpoint, modelId), - api: resolveFoundryApi(modelId), + baseUrl: buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint), + api: resolveFoundryApi(modelId, modelNameHint), models: [ { id: modelId, - name: modelId, + name: typeof modelNameHint === "string" && modelNameHint.trim().length > 0 ? modelNameHint.trim() : modelId, reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, @@ -199,11 +236,125 @@ function normalizeEndpointOrigin(rawUrl: string | null | undefined): string | un } } +function resolveConfiguredModelNameHint(modelId: string, modelNameHint?: string | null): string | undefined { + const trimmedName = typeof modelNameHint === "string" ? modelNameHint.trim() : ""; + if (trimmedName) { + return trimmedName; + } + const trimmedId = modelId.trim(); + return trimmedId ? trimmedId : undefined; +} + +function buildFoundryCredentialMetadata(params: { + authMethod: "api-key" | "entra-id"; + endpoint: string; + modelId: string; + modelNameHint?: string | null; + subscriptionId?: string; + subscriptionName?: string; + tenantId?: string; +}): Record { + const metadata: Record = { + authMethod: params.authMethod, + endpoint: params.endpoint, + modelId: params.modelId, + }; + const modelName = resolveConfiguredModelNameHint(params.modelId, params.modelNameHint); + if (modelName) { + metadata.modelName = modelName; + } + if (params.subscriptionId) { + metadata.subscriptionId = params.subscriptionId; + } + if (params.subscriptionName) { + metadata.subscriptionName = params.subscriptionName; + } + if (params.tenantId) { + metadata.tenantId = params.tenantId; + } + return metadata; +} + +function buildFoundryAuthResult(params: { + profileId: string; + apiKey: SecretInput; + secretInputMode?: "plaintext" | "ref"; + endpoint: string; + modelId: string; + modelNameHint?: string | null; + authMethod: "api-key" | "entra-id"; + subscriptionId?: string; + subscriptionName?: string; + tenantId?: string; + notes?: string[]; +}): ProviderAuthResult { + return { + profiles: [ + { + profileId: params.profileId, + credential: buildApiKeyCredential( + PROVIDER_ID, + params.apiKey, + buildFoundryCredentialMetadata({ + authMethod: params.authMethod, + endpoint: params.endpoint, + modelId: params.modelId, + modelNameHint: params.modelNameHint, + subscriptionId: params.subscriptionId, + subscriptionName: params.subscriptionName, + tenantId: params.tenantId, + }), + params.secretInputMode ? { secretInputMode: params.secretInputMode } : undefined, + ), + }, + ], + configPatch: { + models: { + providers: { + [PROVIDER_ID]: buildFoundryProviderConfig( + params.endpoint, + params.modelId, + params.modelNameHint, + ), + }, + }, + }, + defaultModel: `${PROVIDER_ID}/${params.modelId}`, + notes: params.notes, + }; +} + +function applyFoundryProfileBinding( + config: ProviderModelSelectedContext["config"], + profileId: string, +): void { + applyAuthProfileConfig(config, { + profileId, + provider: PROVIDER_ID, + mode: "api_key", + }); +} + +function applyFoundryProviderConfig( + config: ProviderModelSelectedContext["config"], + providerConfig: ModelProviderConfig, +): void { + config.models ??= {}; + config.models.providers ??= {}; + config.models.providers[PROVIDER_ID] = providerConfig; +} + function listFoundryResources(): FoundryResourceOption[] { try { - const raw = execCmd( - 'az cognitiveservices account list --query "[].{id:id,name:name,kind:kind,location:location,resourceGroup:resourceGroup,endpoint:properties.endpoint,customSubdomain:properties.customSubDomainName,projects:properties.associatedProjects}" --output json', - ); + const raw = execAz([ + "cognitiveservices", + "account", + "list", + "--query", + "[].{id:id,name:name,kind:kind,location:location,resourceGroup:resourceGroup,endpoint:properties.endpoint,customSubdomain:properties.customSubDomainName,projects:properties.associatedProjects}", + "--output", + "json", + ]); const accounts = JSON.parse(raw) as AzCognitiveAccount[]; const resources: FoundryResourceOption[] = []; for (const account of accounts) { @@ -255,9 +406,20 @@ function listFoundryResources(): FoundryResourceOption[] { function listResourceDeployments(resource: FoundryResourceOption): AzDeploymentSummary[] { try { - const raw = execCmd( - `az cognitiveservices account deployment list -g ${quoteShellArg(resource.resourceGroup)} -n ${quoteShellArg(resource.accountName)} --query "[].{name:name,modelName:properties.model.name,modelVersion:properties.model.version,state:properties.provisioningState,sku:sku.name}" --output json`, - ); + const raw = execAz([ + "cognitiveservices", + "account", + "deployment", + "list", + "-g", + resource.resourceGroup, + "-n", + resource.accountName, + "--query", + "[].{name:name,modelName:properties.model.name,modelVersion:properties.model.version,state:properties.provisioningState,sku:sku.name}", + "--output", + "json", + ]); const deployments = JSON.parse(raw) as AzDeploymentSummary[]; return deployments.filter((deployment) => deployment.state === "Succeeded"); } catch { @@ -338,6 +500,7 @@ function buildCreateFoundryHint(selectedSub: AzAccount): string { async function promptEndpointAndModelManually(ctx: ProviderAuthContext): Promise<{ endpoint: string; modelId: string; + modelNameHint?: string; }> { const endpoint = String( await ctx.prompter.text({ @@ -366,7 +529,86 @@ async function promptEndpointAndModelManually(ctx: ProviderAuthContext): Promise }, }), ).trim(); - return { endpoint, modelId }; + return { endpoint, modelId, modelNameHint: modelId }; +} + +async function promptApiKeyEndpointAndModel(ctx: ProviderAuthContext): Promise { + const endpoint = String( + await ctx.prompter.text({ + message: "Microsoft Foundry endpoint URL", + placeholder: "https://xxx.openai.azure.com or https://xxx.services.ai.azure.com", + initialValue: normalizeOptionalSecretInput(process.env.AZURE_OPENAI_ENDPOINT), + 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(); + const modelId = String( + await ctx.prompter.text({ + message: "Default model/deployment name", + initialValue: "gpt-4o", + validate: (v) => { + const val = String(v ?? "").trim(); + if (!val) return "Model ID is required"; + return undefined; + }, + }), + ).trim(); + const modelNameHintInput = String( + await ctx.prompter.text({ + message: "Underlying Azure model family (optional)", + initialValue: modelId, + placeholder: "gpt-5.4, gpt-4o, etc.", + }), + ).trim(); + return { + endpoint, + modelId, + modelNameHint: modelNameHintInput || modelId, + }; +} + +function buildFoundryConnectionTest(params: { + endpoint: string; + modelId: string; + modelNameHint?: string | null; +}): { url: string; body: Record } { + const baseUrl = buildFoundryProviderBaseUrl( + params.endpoint, + params.modelId, + params.modelNameHint, + ); + if (resolveFoundryApi(params.modelId, params.modelNameHint) === DEFAULT_GPT5_API) { + return { + url: `${baseUrl}/responses?api-version=2025-04-01-preview`, + body: { + model: params.modelId, + input: "hi", + max_output_tokens: 1, + }, + }; + } + return { + url: `${baseUrl}/chat/completions?api-version=2024-12-01-preview`, + body: { + messages: [{ role: "user", content: "hi" }], + max_tokens: 1, + }, + }; +} + +function getFoundryTokenCacheKey(params?: { + subscriptionId?: string; + tenantId?: string; +}): string { + return `${params?.subscriptionId ?? ""}:${params?.tenantId ?? ""}`; } /** @@ -570,12 +812,13 @@ const entraIdAuthMethod = { // 4. Set subscription if (selectedSub) { - execCmd(`az account set --subscription "${selectedSub.id}"`); + execAz(["account", "set", "--subscription", selectedSub.id]); } // 5. Discover resource + deployment when possible let endpoint: string; let modelId: string; + let modelNameHint: string | undefined; if (selectedSub) { const useDiscoveredResource = await ctx.prompter.confirm({ message: "Discover Microsoft Foundry resources from this subscription?", @@ -586,19 +829,21 @@ const entraIdAuthMethod = { const selectedDeployment = await selectFoundryDeployment(ctx, selectedResource); endpoint = selectedResource.endpoint; modelId = selectedDeployment.name; + modelNameHint = resolveConfiguredModelNameHint(modelId, selectedDeployment.modelName); await ctx.prompter.note( [ `Resource: ${selectedResource.accountName}`, `Endpoint: ${endpoint}`, `Deployment: ${modelId}`, + selectedDeployment.modelName ? `Model: ${selectedDeployment.modelName}` : undefined, ].join("\n"), "Microsoft Foundry", ); } else { - ({ endpoint, modelId } = await promptEndpointAndModelManually(ctx)); + ({ endpoint, modelId, modelNameHint } = await promptEndpointAndModelManually(ctx)); } } else { - ({ endpoint, modelId } = await promptEndpointAndModelManually(ctx)); + ({ endpoint, modelId, modelNameHint } = await promptEndpointAndModelManually(ctx)); } // 7. Test connection @@ -607,17 +852,18 @@ const entraIdAuthMethod = { subscriptionId: selectedSub?.id, tenantId, }); - const testUrl = `${buildAzureBaseUrl(endpoint, modelId)}/chat/completions?api-version=2024-12-01-preview`; - const res = await fetch(testUrl, { + const testRequest = buildFoundryConnectionTest({ + endpoint, + modelId, + modelNameHint, + }); + const res = await fetch(testRequest.url, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, - body: JSON.stringify({ - messages: [{ role: "user", content: "hi" }], - max_tokens: 1, - }), + body: JSON.stringify(testRequest.body), }); if (!res.ok && res.status !== 400) { const body = await res.text().catch(() => ""); @@ -639,34 +885,16 @@ const entraIdAuthMethod = { // 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", - ...(selectedSub?.id ? { subscriptionId: selectedSub.id } : {}), - ...(selectedSub?.name ? { subscriptionName: selectedSub.name } : {}), - ...(tenantId ? { tenantId } : {}), - endpoint, - modelId, - }, - }, - }, - ], - configPatch: { - models: { - providers: { - [PROVIDER_ID]: buildFoundryProviderConfig(endpoint, modelId), - }, - }, - }, - defaultModel: `${PROVIDER_ID}/${modelId}`, + return buildFoundryAuthResult({ + profileId, + apiKey: "__entra_id_dynamic__", + endpoint, + modelId, + modelNameHint, + authMethod: "entra-id", + ...(selectedSub?.id ? { subscriptionId: selectedSub.id } : {}), + ...(selectedSub?.name ? { subscriptionName: selectedSub.name } : {}), + ...(tenantId ? { tenantId } : {}), notes: [ ...(selectedSub?.name ? [`Subscription: ${selectedSub.name}`] : []), ...(tenantId ? [`Tenant: ${tenantId}`] : []), @@ -674,7 +902,7 @@ const entraIdAuthMethod = { `Model: ${modelId}`, "Token is refreshed automatically via az CLI — keep az login active.", ], - }; + }); }, onModelSelected: async (ctx: ProviderModelSelectedContext) => { const providerConfig = ctx.config.models?.providers?.[PROVIDER_ID]; @@ -682,15 +910,20 @@ const entraIdAuthMethod = { return; } const selectedModelId = ctx.model.slice(`${PROVIDER_ID}/`.length); - const selectedModelCompat = buildFoundryModelCompat(selectedModelId); + const existingModel = providerConfig.models.find((model: { id: string }) => model.id === selectedModelId); + const selectedModelNameHint = resolveConfiguredModelNameHint( + selectedModelId, + existingModel?.name, + ); + const selectedModelCompat = buildFoundryModelCompat(selectedModelId, selectedModelNameHint); const providerEndpoint = normalizeFoundryEndpoint(providerConfig.baseUrl ?? ""); const nextProviderConfig: ModelProviderConfig = { ...providerConfig, - baseUrl: buildFoundryProviderBaseUrl(providerEndpoint, selectedModelId), - api: resolveFoundryApi(selectedModelId), + baseUrl: buildFoundryProviderBaseUrl(providerEndpoint, selectedModelId, selectedModelNameHint), + api: resolveFoundryApi(selectedModelId, selectedModelNameHint), models: [ { - ...(providerConfig.models.find((model: { id: string }) => model.id === selectedModelId) ?? { + ...(existingModel ?? { id: selectedModelId, name: selectedModelId, reasoning: false, @@ -703,14 +936,8 @@ const entraIdAuthMethod = { }, ], }; - applyAuthProfileConfig(ctx.config, { - profileId: `${PROVIDER_ID}:entra`, - provider: PROVIDER_ID, - mode: "api_key", - }); - ctx.config.models ??= {}; - ctx.config.models.providers ??= {}; - ctx.config.models.providers[PROVIDER_ID] = nextProviderConfig; + applyFoundryProfileBinding(ctx.config, `${PROVIDER_ID}:entra`); + applyFoundryProviderConfig(ctx.config, nextProviderConfig); }, }; @@ -718,17 +945,11 @@ const entraIdAuthMethod = { // API Key auth method // --------------------------------------------------------------------------- -const apiKeyAuthMethod = createProviderApiKeyAuthMethod({ - providerId: PROVIDER_ID, - methodId: "api-key", +const apiKeyAuthMethod: ProviderAuthMethod = { + id: "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], + kind: "api_key", wizard: { choiceId: "microsoft-foundry-apikey", choiceLabel: "Microsoft Foundry (API key)", @@ -736,13 +957,60 @@ const apiKeyAuthMethod = createProviderApiKeyAuthMethod({ groupLabel: "Microsoft Foundry", groupHint: "Entra ID + API key", }, -}); + run: async (ctx) => { + const authStore = ensureAuthProfileStore(ctx.agentDir, { + allowKeychainPrompt: false, + }); + const existing = authStore.profiles[`${PROVIDER_ID}:default`]; + const existingMetadata = existing?.type === "api_key" ? existing.metadata : undefined; + let capturedSecretInput: SecretInput | undefined; + let capturedCredential = false; + let capturedMode: "plaintext" | "ref" | undefined; + await ensureApiKeyFromOptionEnvOrPrompt({ + token: normalizeOptionalSecretInput(ctx.opts?.azureOpenaiApiKey), + tokenProvider: PROVIDER_ID, + secretInputMode: + ctx.allowSecretRefPrompt === false ? (ctx.secretInputMode ?? "plaintext") : ctx.secretInputMode, + config: ctx.config, + expectedProviders: [PROVIDER_ID], + provider: PROVIDER_ID, + envLabel: "AZURE_OPENAI_API_KEY", + promptMessage: "Enter Azure OpenAI API key", + normalize: normalizeApiKeyInput, + validate: validateApiKeyInput, + prompter: ctx.prompter, + setCredential: async (apiKey, mode) => { + capturedSecretInput = apiKey; + capturedCredential = true; + capturedMode = mode; + }, + }); + if (!capturedCredential) { + throw new Error("Missing Azure OpenAI API key."); + } + const selection = await promptApiKeyEndpointAndModel(ctx); + return buildFoundryAuthResult({ + profileId: `${PROVIDER_ID}:default`, + apiKey: capturedSecretInput ?? "", + ...(capturedMode ? { secretInputMode: capturedMode } : {}), + endpoint: selection.endpoint, + modelId: selection.modelId, + modelNameHint: + selection.modelNameHint ?? existingMetadata?.modelName ?? existingMetadata?.modelId, + authMethod: "api-key", + notes: [ + `Endpoint: ${selection.endpoint}`, + `Model: ${selection.modelId}`, + ], + }); + }, +}; // --------------------------------------------------------------------------- // Token cache for prepareRuntimeAuth // --------------------------------------------------------------------------- -let cachedToken: { token: string; expiresAt: number } | null = null; +const cachedTokens = new Map(); function refreshEntraToken(params?: { subscriptionId?: string; @@ -752,7 +1020,10 @@ function refreshEntraToken(params?: { const expiresAt = result.expiresOn ? new Date(result.expiresOn).getTime() : Date.now() + 55 * 60 * 1000; // default ~55 min - cachedToken = { token: result.accessToken, expiresAt }; + cachedTokens.set(getFoundryTokenCacheKey(params), { + token: result.accessToken, + expiresAt, + }); return { apiKey: result.accessToken, expiresAt }; } @@ -779,11 +1050,12 @@ export default definePluginEntry({ if (!endpoint) { return model; } - const compat = buildFoundryModelCompat(modelId); + const modelNameHint = resolveConfiguredModelNameHint(modelId, model.name); + const compat = buildFoundryModelCompat(modelId, modelNameHint); return { ...model, - api: resolveFoundryApi(modelId), - baseUrl: buildFoundryProviderBaseUrl(endpoint, modelId), + api: resolveFoundryApi(modelId, modelNameHint), + baseUrl: buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint), ...(compat ? { compat } : {}), }; }, @@ -807,11 +1079,19 @@ export default definePluginEntry({ : typeof metadata?.modelId === "string" && metadata.modelId.trim().length > 0 ? metadata.modelId.trim() : ctx.modelId; + const modelNameHint = resolveConfiguredModelNameHint(modelId, metadata?.modelName ?? ctx.model.name); const endpoint = typeof metadata?.endpoint === "string" && metadata.endpoint.trim().length > 0 ? metadata.endpoint.trim() : extractFoundryEndpoint(ctx.model.baseUrl ?? ""); - const baseUrl = endpoint ? buildFoundryProviderBaseUrl(endpoint, modelId) : undefined; + const baseUrl = endpoint + ? buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint) + : undefined; + const cacheKey = getFoundryTokenCacheKey({ + subscriptionId: metadata?.subscriptionId, + tenantId: metadata?.tenantId, + }); + const cachedToken = cachedTokens.get(cacheKey); // Return cached token if still valid if (cachedToken && cachedToken.expiresAt > Date.now() + TOKEN_REFRESH_MARGIN_MS) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa47f57f1a8..5bcccb1e71d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -244,7 +244,7 @@ importers: extensions/anthropic: {} - extensions/azure-foundry: {} + extensions/microsoft-foundry: {} extensions/bluebubbles: dependencies: From 0c1efec1c9615d964dc6a19897e2ca82e61c17cd Mon Sep 17 00:00:00 2001 From: haxudev Date: Thu, 19 Mar 2026 16:52:32 +0800 Subject: [PATCH 06/12] Microsoft Foundry: use active model hint at runtime Prefer the currently selected model hint during runtime auth refresh so switching Foundry deployments cannot reuse stale onboarding metadata and route requests to the wrong GPT-5 or non-GPT-5 endpoint. --- extensions/microsoft-foundry/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/extensions/microsoft-foundry/index.ts b/extensions/microsoft-foundry/index.ts index df0f86b27e5..f271c8e71e0 100644 --- a/extensions/microsoft-foundry/index.ts +++ b/extensions/microsoft-foundry/index.ts @@ -1079,7 +1079,12 @@ export default definePluginEntry({ : typeof metadata?.modelId === "string" && metadata.modelId.trim().length > 0 ? metadata.modelId.trim() : ctx.modelId; - const modelNameHint = resolveConfiguredModelNameHint(modelId, metadata?.modelName ?? ctx.model.name); + const activeModelNameHint = + ctx.modelId === metadata?.modelId ? metadata?.modelName : undefined; + const modelNameHint = resolveConfiguredModelNameHint( + modelId, + ctx.model.name ?? activeModelNameHint, + ); const endpoint = typeof metadata?.endpoint === "string" && metadata.endpoint.trim().length > 0 ? metadata.endpoint.trim() From 41cc7c8bf17d159f456f7208a2f657510a6313b2 Mon Sep 17 00:00:00 2001 From: haxudev Date: Thu, 19 Mar 2026 18:36:33 +0800 Subject: [PATCH 07/12] Microsoft Foundry: tighten onboarding retry and model selection hooks Only retry Azure login with an explicit tenant when the CLI failure actually points to tenant or subscription scope, keep HTTP 400 connection checks informative without treating them as a silent success, and move the model-selection hook onto the provider so manual Foundry setups can preserve GPT-5 family hints and resolve the right runtime endpoint. --- extensions/microsoft-foundry/index.ts | 107 ++++++++++++++++---------- 1 file changed, 68 insertions(+), 39 deletions(-) diff --git a/extensions/microsoft-foundry/index.ts b/extensions/microsoft-foundry/index.ts index f271c8e71e0..27e98eea23e 100644 --- a/extensions/microsoft-foundry/index.ts +++ b/extensions/microsoft-foundry/index.ts @@ -529,7 +529,18 @@ async function promptEndpointAndModelManually(ctx: ProviderAuthContext): Promise }, }), ).trim(); - return { endpoint, modelId, modelNameHint: modelId }; + const modelNameHintInput = String( + await ctx.prompter.text({ + message: "Underlying Azure model family (optional)", + initialValue: modelId, + placeholder: "gpt-5.4, gpt-4o, etc.", + }), + ).trim(); + return { + endpoint, + modelId, + modelNameHint: modelNameHintInput || modelId, + }; } async function promptApiKeyEndpointAndModel(ctx: ProviderAuthContext): Promise { @@ -711,6 +722,13 @@ async function loginWithTenantFallback(ctx: ProviderAuthContext): Promise<{ return { account: getLoggedInAccount() }; } catch (error) { const message = error instanceof Error ? error.message : String(error); + const isAzureTenantError = + /AADSTS/i.test(message) || + /no subscriptions found/i.test(message) || + /tenant/i.test(message); + if (!isAzureTenantError) { + throw error; + } const tenantId = await promptTenantId(ctx, { suggestions: extractTenantSuggestions(message), required: true, @@ -872,7 +890,15 @@ const entraIdAuthMethod = { "Connection Test", ); } else { - await ctx.prompter.note("Connection test successful!", "✓"); + const statusNote = res.status === 400 ? " (400 Bad Request — endpoint reachable)" : ""; + const statusBody = res.status === 400 ? await res.text().catch(() => "") : ""; + await ctx.prompter.note(`Connection test successful!${statusNote}`, "✓"); + if (statusBody) { + await ctx.prompter.note( + `Endpoint response: ${statusBody.slice(0, 200)}`, + "Connection Test", + ); + } } } catch (err) { await ctx.prompter.note( @@ -904,41 +930,6 @@ const entraIdAuthMethod = { ], }); }, - onModelSelected: async (ctx: ProviderModelSelectedContext) => { - const providerConfig = ctx.config.models?.providers?.[PROVIDER_ID]; - if (!providerConfig || !ctx.model.startsWith(`${PROVIDER_ID}/`)) { - return; - } - const selectedModelId = ctx.model.slice(`${PROVIDER_ID}/`.length); - const existingModel = providerConfig.models.find((model: { id: string }) => model.id === selectedModelId); - const selectedModelNameHint = resolveConfiguredModelNameHint( - selectedModelId, - existingModel?.name, - ); - const selectedModelCompat = buildFoundryModelCompat(selectedModelId, selectedModelNameHint); - const providerEndpoint = normalizeFoundryEndpoint(providerConfig.baseUrl ?? ""); - const nextProviderConfig: ModelProviderConfig = { - ...providerConfig, - baseUrl: buildFoundryProviderBaseUrl(providerEndpoint, selectedModelId, selectedModelNameHint), - api: resolveFoundryApi(selectedModelId, selectedModelNameHint), - models: [ - { - ...(existingModel ?? { - id: selectedModelId, - name: selectedModelId, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128_000, - maxTokens: 16_384, - }), - ...(selectedModelCompat ? { compat: selectedModelCompat } : {}), - }, - ], - }; - applyFoundryProfileBinding(ctx.config, `${PROVIDER_ID}:entra`); - applyFoundryProviderConfig(ctx.config, nextProviderConfig); - }, }; // --------------------------------------------------------------------------- @@ -1017,8 +1008,9 @@ function refreshEntraToken(params?: { tenantId?: string; }): { apiKey: string; expiresAt: number } { const result = getAccessTokenResult(params); - const expiresAt = result.expiresOn - ? new Date(result.expiresOn).getTime() + const rawExpiry = result.expiresOn ? new Date(result.expiresOn).getTime() : Number.NaN; + const expiresAt = Number.isFinite(rawExpiry) + ? rawExpiry : Date.now() + 55 * 60 * 1000; // default ~55 min cachedTokens.set(getFoundryTokenCacheKey(params), { token: result.accessToken, @@ -1045,6 +1037,43 @@ export default definePluginEntry({ capabilities: { providerFamily: "openai", }, + onModelSelected: async (ctx: ProviderModelSelectedContext) => { + const providerConfig = ctx.config.models?.providers?.[PROVIDER_ID]; + if (!providerConfig || !ctx.model.startsWith(`${PROVIDER_ID}/`)) { + return; + } + const selectedModelId = ctx.model.slice(`${PROVIDER_ID}/`.length); + const existingModel = providerConfig.models.find( + (model: { id: string }) => model.id === selectedModelId, + ); + const selectedModelNameHint = resolveConfiguredModelNameHint( + selectedModelId, + existingModel?.name, + ); + const selectedModelCompat = buildFoundryModelCompat(selectedModelId, selectedModelNameHint); + const providerEndpoint = normalizeFoundryEndpoint(providerConfig.baseUrl ?? ""); + const nextProviderConfig: ModelProviderConfig = { + ...providerConfig, + baseUrl: buildFoundryProviderBaseUrl(providerEndpoint, selectedModelId, selectedModelNameHint), + api: resolveFoundryApi(selectedModelId, selectedModelNameHint), + models: [ + { + ...(existingModel ?? { + id: selectedModelId, + name: selectedModelId, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }), + ...(selectedModelCompat ? { compat: selectedModelCompat } : {}), + }, + ], + }; + applyFoundryProfileBinding(ctx.config, `${PROVIDER_ID}:entra`); + applyFoundryProviderConfig(ctx.config, nextProviderConfig); + }, normalizeResolvedModel: ({ modelId, model }) => { const endpoint = extractFoundryEndpoint(model.baseUrl ?? ""); if (!endpoint) { From 7696a86e10eb2448870514488accccf012974fa3 Mon Sep 17 00:00:00 2001 From: MetaX e|acc Date: Thu, 19 Mar 2026 18:55:17 +0800 Subject: [PATCH 08/12] Update extensions/microsoft-foundry/index.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- extensions/microsoft-foundry/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/microsoft-foundry/index.ts b/extensions/microsoft-foundry/index.ts index 27e98eea23e..2e15e6414bb 100644 --- a/extensions/microsoft-foundry/index.ts +++ b/extensions/microsoft-foundry/index.ts @@ -724,7 +724,7 @@ async function loginWithTenantFallback(ctx: ProviderAuthContext): Promise<{ const message = error instanceof Error ? error.message : String(error); const isAzureTenantError = /AADSTS/i.test(message) || - /no subscriptions found/i.test(message) || + return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(trimmed) ? undefined : "Enter a valid tenant ID"; /tenant/i.test(message); if (!isAzureTenantError) { throw error; From b7876c960956f78592b7c93ea4f758675d4f4ef6 Mon Sep 17 00:00:00 2001 From: haxudev Date: Thu, 19 Mar 2026 22:27:25 +0800 Subject: [PATCH 09/12] Microsoft Foundry: split provider modules and harden runtime auth Split the provider into focused auth, onboarding, CLI, runtime, and shared modules so the Entra ID flow is easier to review and maintain. Add Foundry-specific tests, preserve Azure CLI error details, move token refresh off the synchronous request path, and dedupe concurrent Entra token refreshes so onboarding and GPT-5 runtime behavior stay reliable. --- extensions/microsoft-foundry/auth.ts | 222 ++++ extensions/microsoft-foundry/cli.ts | 162 +++ extensions/microsoft-foundry/index.test.ts | 285 ++++ extensions/microsoft-foundry/index.ts | 1154 +---------------- extensions/microsoft-foundry/onboard.ts | 430 ++++++ extensions/microsoft-foundry/provider.ts | 81 ++ extensions/microsoft-foundry/runtime.ts | 85 ++ .../microsoft-foundry/shared-runtime.ts | 14 + extensions/microsoft-foundry/shared.ts | 312 +++++ 9 files changed, 1595 insertions(+), 1150 deletions(-) create mode 100644 extensions/microsoft-foundry/auth.ts create mode 100644 extensions/microsoft-foundry/cli.ts create mode 100644 extensions/microsoft-foundry/index.test.ts create mode 100644 extensions/microsoft-foundry/onboard.ts create mode 100644 extensions/microsoft-foundry/provider.ts create mode 100644 extensions/microsoft-foundry/runtime.ts create mode 100644 extensions/microsoft-foundry/shared-runtime.ts create mode 100644 extensions/microsoft-foundry/shared.ts diff --git a/extensions/microsoft-foundry/auth.ts b/extensions/microsoft-foundry/auth.ts new file mode 100644 index 00000000000..8091c266803 --- /dev/null +++ b/extensions/microsoft-foundry/auth.ts @@ -0,0 +1,222 @@ +import type { + ProviderAuthContext, + ProviderAuthMethod, + ProviderAuthResult, +} from "openclaw/plugin-sdk/core"; +import { + ensureApiKeyFromOptionEnvOrPrompt, + ensureAuthProfileStore, + normalizeApiKeyInput, + normalizeOptionalSecretInput, + type SecretInput, + validateApiKeyInput, +} from "openclaw/plugin-sdk/provider-auth"; +import { execAz, getLoggedInAccount, isAzCliInstalled } from "./cli.js"; +import { + buildFoundryAuthResult, + PROVIDER_ID, + resolveConfiguredModelNameHint, +} from "./shared.js"; +import { + loginWithTenantFallback, + promptApiKeyEndpointAndModel, + promptEndpointAndModelManually, + promptTenantId, + selectFoundryDeployment, + selectFoundryResource, + listSubscriptions, + testFoundryConnection, +} from "./onboard.js"; + +export const entraIdAuthMethod: ProviderAuthMethod = { + id: "entra-id", + label: "Entra ID (az login)", + hint: "Use your Azure login - no API key needed", + kind: "custom", + wizard: { + choiceId: "microsoft-foundry-entra", + choiceLabel: "Microsoft Foundry (Entra ID / az login)", + choiceHint: "Use your Azure login - no API key needed", + groupId: "microsoft-foundry", + groupLabel: "Microsoft Foundry", + groupHint: "Entra ID + API key", + }, + run: async (ctx: ProviderAuthContext): Promise => { + if (!isAzCliInstalled()) { + throw new Error( + "Azure CLI (az) is not installed.\nInstall it from https://learn.microsoft.com/cli/azure/install-azure-cli", + ); + } + + let account = getLoggedInAccount(); + let tenantId = account?.tenantId; + 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) { + const loginResult = await loginWithTenantFallback(ctx); + account = loginResult.account; + tenantId = loginResult.tenantId ?? loginResult.account?.tenantId; + } + } else { + await ctx.prompter.note( + "You need to log in to Azure. A device code will be displayed - follow the instructions.", + "Azure Login", + ); + const loginResult = await loginWithTenantFallback(ctx); + account = loginResult.account; + tenantId = loginResult.tenantId ?? loginResult.account?.tenantId; + } + + const subs = listSubscriptions(); + let selectedSub = null; + if (subs.length === 0) { + tenantId ??= await promptTenantId(ctx, { + required: true, + reason: "No enabled Azure subscriptions were found. Continue with tenant-scoped Entra ID auth instead.", + }); + await ctx.prompter.note(`Continuing with tenant-scoped auth (${tenantId}).`, "Azure Tenant"); + } else if (subs.length === 1) { + selectedSub = subs[0]!; + tenantId ??= selectedSub.tenantId; + await ctx.prompter.note( + `Using subscription: ${selectedSub.name} (${selectedSub.id})`, + "Subscription", + ); + } else { + const selectedId = await ctx.prompter.select({ + message: "Select Azure subscription", + options: subs.map((sub) => ({ + value: sub.id, + label: `${sub.name} (${sub.id})`, + })), + }); + selectedSub = subs.find((sub) => sub.id === selectedId)!; + tenantId ??= selectedSub.tenantId; + } + + if (selectedSub) { + execAz(["account", "set", "--subscription", selectedSub.id]); + } + + let endpoint: string; + let modelId: string; + let modelNameHint: string | undefined; + if (selectedSub) { + const useDiscoveredResource = await ctx.prompter.confirm({ + message: "Discover Microsoft Foundry resources from this subscription?", + initialValue: true, + }); + if (useDiscoveredResource) { + const selectedResource = await selectFoundryResource(ctx, selectedSub); + const selectedDeployment = await selectFoundryDeployment(ctx, selectedResource); + endpoint = selectedResource.endpoint; + modelId = selectedDeployment.name; + modelNameHint = resolveConfiguredModelNameHint(modelId, selectedDeployment.modelName); + await ctx.prompter.note( + [ + `Resource: ${selectedResource.accountName}`, + `Endpoint: ${endpoint}`, + `Deployment: ${modelId}`, + selectedDeployment.modelName ? `Model: ${selectedDeployment.modelName}` : undefined, + ] + .filter(Boolean) + .join("\n"), + "Microsoft Foundry", + ); + } else { + ({ endpoint, modelId, modelNameHint } = await promptEndpointAndModelManually(ctx)); + } + } else { + ({ endpoint, modelId, modelNameHint } = await promptEndpointAndModelManually(ctx)); + } + + await testFoundryConnection({ + ctx, + endpoint, + modelId, + modelNameHint, + subscriptionId: selectedSub?.id, + tenantId, + }); + + return buildFoundryAuthResult({ + profileId: `${PROVIDER_ID}:entra`, + apiKey: "__entra_id_dynamic__", + endpoint, + modelId, + modelNameHint, + authMethod: "entra-id", + ...(selectedSub?.id ? { subscriptionId: selectedSub.id } : {}), + ...(selectedSub?.name ? { subscriptionName: selectedSub.name } : {}), + ...(tenantId ? { tenantId } : {}), + notes: [ + ...(selectedSub?.name ? [`Subscription: ${selectedSub.name}`] : []), + ...(tenantId ? [`Tenant: ${tenantId}`] : []), + `Endpoint: ${endpoint}`, + `Model: ${modelId}`, + "Token is refreshed automatically via az CLI - keep az login active.", + ], + }); + }, +}; + +export const apiKeyAuthMethod: ProviderAuthMethod = { + id: "api-key", + label: "Azure OpenAI API key", + hint: "Direct Azure OpenAI API key", + kind: "api_key", + wizard: { + choiceId: "microsoft-foundry-apikey", + choiceLabel: "Microsoft Foundry (API key)", + groupId: "microsoft-foundry", + groupLabel: "Microsoft Foundry", + groupHint: "Entra ID + API key", + }, + run: async (ctx) => { + const authStore = ensureAuthProfileStore(ctx.agentDir, { + allowKeychainPrompt: false, + }); + const existing = authStore.profiles[`${PROVIDER_ID}:default`]; + const existingMetadata = existing?.type === "api_key" ? existing.metadata : undefined; + let capturedSecretInput: SecretInput | undefined; + let capturedCredential = false; + let capturedMode: "plaintext" | "ref" | undefined; + await ensureApiKeyFromOptionEnvOrPrompt({ + token: normalizeOptionalSecretInput(ctx.opts?.azureOpenaiApiKey), + tokenProvider: PROVIDER_ID, + secretInputMode: + ctx.allowSecretRefPrompt === false ? (ctx.secretInputMode ?? "plaintext") : ctx.secretInputMode, + config: ctx.config, + expectedProviders: [PROVIDER_ID], + provider: PROVIDER_ID, + envLabel: "AZURE_OPENAI_API_KEY", + promptMessage: "Enter Azure OpenAI API key", + normalize: normalizeApiKeyInput, + validate: validateApiKeyInput, + prompter: ctx.prompter, + setCredential: async (apiKey, mode) => { + capturedSecretInput = apiKey; + capturedCredential = true; + capturedMode = mode; + }, + }); + if (!capturedCredential) { + throw new Error("Missing Azure OpenAI API key."); + } + const selection = await promptApiKeyEndpointAndModel(ctx); + return buildFoundryAuthResult({ + profileId: `${PROVIDER_ID}:default`, + apiKey: capturedSecretInput ?? "", + ...(capturedMode ? { secretInputMode: capturedMode } : {}), + endpoint: selection.endpoint, + modelId: selection.modelId, + modelNameHint: + selection.modelNameHint ?? existingMetadata?.modelName ?? existingMetadata?.modelId, + authMethod: "api-key", + notes: [`Endpoint: ${selection.endpoint}`, `Model: ${selection.modelId}`], + }); + }, +}; diff --git a/extensions/microsoft-foundry/cli.ts b/extensions/microsoft-foundry/cli.ts new file mode 100644 index 00000000000..3bf0965cc9c --- /dev/null +++ b/extensions/microsoft-foundry/cli.ts @@ -0,0 +1,162 @@ +import { execFile, execFileSync, spawn } from "node:child_process"; +import type { AzAccessToken, AzAccount } from "./shared.js"; +import { COGNITIVE_SERVICES_RESOURCE } from "./shared.js"; + +export function execAz(args: string[]): string { + return execFileSync("az", args, { + encoding: "utf-8", + timeout: 30_000, + shell: process.platform === "win32", + }).trim(); +} + +export async function execAzAsync(args: string[]): Promise { + return await new Promise((resolve, reject) => { + execFile( + "az", + args, + { + encoding: "utf-8", + timeout: 30_000, + shell: process.platform === "win32", + }, + (error, stdout, stderr) => { + if (error) { + const details = `${String(stderr ?? "").trim()} ${String(stdout ?? "").trim()}`.trim(); + reject( + new Error( + details ? `${error.message}: ${details}` : error.message, + ), + ); + return; + } + resolve(String(stdout).trim()); + }, + ); + }); +} + +export function isAzCliInstalled(): boolean { + try { + execAz(["version", "--output", "none"]); + return true; + } catch { + return false; + } +} + +export function getLoggedInAccount(): AzAccount | null { + try { + return JSON.parse(execAz(["account", "show", "--output", "json"])) as AzAccount; + } catch { + return null; + } +} + +export function listSubscriptions(): AzAccount[] { + try { + const subs = JSON.parse(execAz(["account", "list", "--output", "json", "--all"])) as AzAccount[]; + return subs.filter((sub) => sub.state === "Enabled"); + } catch { + return []; + } +} + +export function getAccessTokenResult(params?: { + subscriptionId?: string; + tenantId?: string; +}): AzAccessToken { + const args = [ + "account", + "get-access-token", + "--resource", + COGNITIVE_SERVICES_RESOURCE, + "--output", + "json", + ]; + if (params?.subscriptionId) { + args.push("--subscription", params.subscriptionId); + } else if (params?.tenantId) { + args.push("--tenant", params.tenantId); + } + return JSON.parse(execAz(args)) as AzAccessToken; +} + +export async function getAccessTokenResultAsync(params?: { + subscriptionId?: string; + tenantId?: string; +}): Promise { + const args = [ + "account", + "get-access-token", + "--resource", + COGNITIVE_SERVICES_RESOURCE, + "--output", + "json", + ]; + if (params?.subscriptionId) { + args.push("--subscription", params.subscriptionId); + } else if (params?.tenantId) { + args.push("--tenant", params.tenantId); + } + return JSON.parse(await execAzAsync(args)) as AzAccessToken; +} + +export async function azLoginDeviceCode(): Promise { + return azLoginDeviceCodeWithOptions({}); +} + +export async function azLoginDeviceCodeWithOptions(params: { + tenantId?: string; + allowNoSubscriptions?: boolean; +}): Promise { + return new Promise((resolve, reject) => { + const maxCapturedLoginOutputChars = 8_000; + const args = [ + "login", + "--use-device-code", + ...(params.tenantId ? ["--tenant", params.tenantId] : []), + ...(params.allowNoSubscriptions ? ["--allow-no-subscriptions"] : []), + ]; + const child = spawn("az", args, { + stdio: ["inherit", "pipe", "pipe"], + shell: process.platform === "win32", + }); + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + const appendBoundedChunk = (chunks: string[], text: string): void => { + if (!text) { + return; + } + chunks.push(text); + let totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + while (totalLength > maxCapturedLoginOutputChars && chunks.length > 0) { + const removed = chunks.shift(); + totalLength -= removed?.length ?? 0; + } + }; + child.stdout?.on("data", (chunk) => { + const text = String(chunk); + appendBoundedChunk(stdoutChunks, text); + process.stdout.write(text); + }); + child.stderr?.on("data", (chunk) => { + const text = String(chunk); + appendBoundedChunk(stderrChunks, text); + process.stderr.write(text); + }); + child.on("close", (code) => { + if (code === 0) { + resolve(); + return; + } + const output = [...stderrChunks, ...stdoutChunks].join("").trim(); + reject( + new Error( + output ? `az login exited with code ${code}: ${output}` : `az login exited with code ${code}`, + ), + ); + }); + child.on("error", reject); + }); +} diff --git a/extensions/microsoft-foundry/index.test.ts b/extensions/microsoft-foundry/index.test.ts new file mode 100644 index 00000000000..c3271216bcb --- /dev/null +++ b/extensions/microsoft-foundry/index.test.ts @@ -0,0 +1,285 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js"; +import plugin from "./index.js"; +import type { OpenClawConfig } from "../../src/config/types.openclaw.js"; + +const execFileMock = vi.hoisted(() => vi.fn()); +const execFileSyncMock = vi.hoisted(() => vi.fn()); +const ensureAuthProfileStoreMock = vi.hoisted(() => + vi.fn(() => ({ + profiles: {}, + })), +); + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + execFile: execFileMock, + execFileSync: execFileSyncMock, + }; +}); + +vi.mock("openclaw/plugin-sdk/provider-auth", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/provider-auth", + ); + return { + ...actual, + ensureAuthProfileStore: ensureAuthProfileStoreMock, + }; +}); + +function registerProvider() { + const registerProviderMock = vi.fn(); + plugin.register( + createTestPluginApi({ + id: "microsoft-foundry", + name: "Microsoft Foundry", + source: "test", + config: {}, + runtime: {} as never, + registerProvider: registerProviderMock, + }), + ); + expect(registerProviderMock).toHaveBeenCalledTimes(1); + return registerProviderMock.mock.calls[0]?.[0]; +} + +describe("microsoft-foundry plugin", () => { + beforeEach(() => { + execFileMock.mockReset(); + execFileSyncMock.mockReset(); + ensureAuthProfileStoreMock.mockReset(); + ensureAuthProfileStoreMock.mockReturnValue({ profiles: {} }); + }); + + it("keeps the API key profile bound when multiple auth profiles exist without explicit order", async () => { + const provider = registerProvider(); + const config: OpenClawConfig = { + auth: { + profiles: { + "microsoft-foundry:default": { + provider: "microsoft-foundry", + mode: "api_key" as const, + }, + "microsoft-foundry:entra": { + provider: "microsoft-foundry", + mode: "api_key" as const, + }, + }, + }, + models: { + providers: { + "microsoft-foundry": { + baseUrl: "https://example.services.ai.azure.com/openai/v1", + api: "openai-responses", + models: [ + { + id: "gpt-5.4", + name: "gpt-5.4", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }, + ], + }, + }, + }, + }; + + await provider.onModelSelected?.({ + config, + model: "microsoft-foundry/gpt-5.4", + prompter: {} as never, + agentDir: "/tmp/test-agent", + }); + + expect(config.auth?.order?.["microsoft-foundry"]).toBeUndefined(); + }); + + it("uses the active ordered API key profile when model selection rebinding is needed", async () => { + const provider = registerProvider(); + ensureAuthProfileStoreMock.mockReturnValueOnce({ + profiles: { + "microsoft-foundry:default": { + type: "api_key", + provider: "microsoft-foundry", + metadata: { authMethod: "api-key" }, + }, + }, + }); + const config: OpenClawConfig = { + auth: { + profiles: { + "microsoft-foundry:default": { + provider: "microsoft-foundry", + mode: "api_key" as const, + }, + }, + order: { + "microsoft-foundry": ["microsoft-foundry:default"], + }, + }, + models: { + providers: { + "microsoft-foundry": { + baseUrl: "https://example.services.ai.azure.com/openai/v1", + api: "openai-responses", + models: [ + { + id: "gpt-5.4", + name: "gpt-5.4", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }, + ], + }, + }, + }, + }; + + await provider.onModelSelected?.({ + config, + model: "microsoft-foundry/gpt-5.4", + prompter: {} as never, + agentDir: "/tmp/test-agent", + }); + + expect(config.auth?.order?.["microsoft-foundry"]).toEqual(["microsoft-foundry:default"]); + }); + + it("preserves the model-derived base URL for Entra runtime auth refresh", async () => { + const provider = registerProvider(); + execFileMock.mockImplementationOnce( + ( + _file: unknown, + _args: unknown, + _options: unknown, + callback: (error: Error | null, stdout: string, stderr: string) => void, + ) => { + callback( + null, + JSON.stringify({ + accessToken: "test-token", + expiresOn: new Date(Date.now() + 60_000).toISOString(), + }), + "", + ); + }, + ); + ensureAuthProfileStoreMock.mockReturnValueOnce({ + profiles: { + "microsoft-foundry:entra": { + type: "api_key", + provider: "microsoft-foundry", + metadata: { + authMethod: "entra-id", + endpoint: "https://example.services.ai.azure.com", + modelId: "custom-deployment", + modelName: "gpt-5.4", + tenantId: "tenant-id", + }, + }, + }, + }); + + const prepared = await provider.prepareRuntimeAuth?.({ + provider: "microsoft-foundry", + modelId: "custom-deployment", + model: { + provider: "microsoft-foundry", + id: "custom-deployment", + name: "gpt-5.4", + api: "openai-responses", + baseUrl: "https://example.services.ai.azure.com/openai/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }, + apiKey: "__entra_id_dynamic__", + authMode: "api_key", + profileId: "microsoft-foundry:entra", + env: process.env, + agentDir: "/tmp/test-agent", + }); + + expect(prepared?.baseUrl).toBe("https://example.services.ai.azure.com/openai/v1"); + }); + + it("dedupes concurrent Entra token refreshes for the same profile", async () => { + const provider = registerProvider(); + execFileMock.mockImplementationOnce( + ( + _file: unknown, + _args: unknown, + _options: unknown, + callback: (error: Error | null, stdout: string, stderr: string) => void, + ) => { + setTimeout(() => { + callback( + null, + JSON.stringify({ + accessToken: "deduped-token", + expiresOn: new Date(Date.now() + 60_000).toISOString(), + }), + "", + ); + }, 10); + }, + ); + ensureAuthProfileStoreMock.mockReturnValue({ + profiles: { + "microsoft-foundry:entra": { + type: "api_key", + provider: "microsoft-foundry", + metadata: { + authMethod: "entra-id", + endpoint: "https://example.services.ai.azure.com", + modelId: "custom-deployment", + modelName: "gpt-5.4", + tenantId: "tenant-id", + }, + }, + }, + }); + + const runtimeContext = { + provider: "microsoft-foundry", + modelId: "custom-deployment", + model: { + provider: "microsoft-foundry", + id: "custom-deployment", + name: "gpt-5.4", + api: "openai-responses", + baseUrl: "https://example.services.ai.azure.com/openai/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }, + apiKey: "__entra_id_dynamic__", + authMode: "api_key", + profileId: "microsoft-foundry:entra", + env: process.env, + agentDir: "/tmp/test-agent", + }; + + const [first, second] = await Promise.all([ + provider.prepareRuntimeAuth?.(runtimeContext), + provider.prepareRuntimeAuth?.(runtimeContext), + ]); + + expect(execFileMock).toHaveBeenCalledTimes(1); + expect(first?.apiKey).toBe("deduped-token"); + expect(second?.apiKey).toBe("deduped-token"); + }); +}); diff --git a/extensions/microsoft-foundry/index.ts b/extensions/microsoft-foundry/index.ts index 2e15e6414bb..db450301446 100644 --- a/extensions/microsoft-foundry/index.ts +++ b/extensions/microsoft-foundry/index.ts @@ -1,1157 +1,11 @@ -import { execFileSync, spawn } from "node:child_process"; -import { - definePluginEntry, - type ProviderAuthContext, - type ProviderAuthMethod, -} from "openclaw/plugin-sdk/core"; -import { - applyAuthProfileConfig, - buildApiKeyCredential, - ensureAuthProfileStore, - ensureApiKeyFromOptionEnvOrPrompt, - normalizeApiKeyInput, - normalizeOptionalSecretInput, - type ProviderAuthResult, - type SecretInput, - validateApiKeyInput, -} from "openclaw/plugin-sdk/provider-auth"; -import type { ModelCompatConfig, ModelProviderConfig } from "../../src/config/types.models.js"; -import type { ProviderModelSelectedContext } from "../../src/plugins/types.js"; - -const PROVIDER_ID = "microsoft-foundry"; -const DEFAULT_API = "openai-completions"; -const DEFAULT_GPT5_API = "openai-responses"; -const COGNITIVE_SERVICES_RESOURCE = "https://cognitiveservices.azure.com"; -const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000; // refresh 5 min before expiry - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function execAz(args: string[]): string { - return execFileSync("az", args, { - encoding: "utf-8", - timeout: 30_000, - shell: process.platform === "win32", - }).trim(); -} - -function isAzCliInstalled(): boolean { - try { - // "az version" works on Windows, Linux, and macOS - execAz(["version", "--output", "none"]); - return true; - } catch { - return false; - } -} - -interface AzAccount { - name: string; - id: string; - tenantId?: string; - user?: { name?: string }; - state?: string; - isDefault?: boolean; -} - -function getLoggedInAccount(): AzAccount | null { - try { - const raw = execAz(["account", "show", "--output", "json"]); - return JSON.parse(raw) as AzAccount; - } catch { - return null; - } -} - -function listSubscriptions(): AzAccount[] { - try { - const raw = execAz(["account", "list", "--output", "json", "--all"]); - const subs = JSON.parse(raw) as AzAccount[]; - return subs.filter((s) => s.state === "Enabled"); - } catch { - return []; - } -} - -interface AzAccessToken { - accessToken: string; - expiresOn?: string; -} - -interface AzCognitiveAccount { - id: string; - name: string; - kind: string; - location?: string; - resourceGroup?: string; - endpoint?: string | null; - customSubdomain?: string | null; - projects?: string[] | null; -} - -interface FoundryResourceOption { - id: string; - accountName: string; - kind: "AIServices" | "OpenAI"; - location?: string; - resourceGroup: string; - endpoint: string; - projects: string[]; -} - -interface AzDeploymentSummary { - name: string; - modelName?: string; - modelVersion?: string; - state?: string; - sku?: string; -} - -type FoundrySelection = { - endpoint: string; - modelId: string; - modelNameHint?: string; -}; - -type CachedTokenEntry = { - token: string; - expiresAt: number; -}; - -type FoundryProviderApi = typeof DEFAULT_API | typeof DEFAULT_GPT5_API; - -function getAccessTokenResult(params?: { - subscriptionId?: string; - tenantId?: string; -}): AzAccessToken { - const args = [ - "account", - "get-access-token", - "--resource", - COGNITIVE_SERVICES_RESOURCE, - "--output", - "json", - ]; - if (params?.subscriptionId) { - args.push("--subscription", params.subscriptionId); - } else if (params?.tenantId) { - args.push("--tenant", params.tenantId); - } - const raw = execAz(args); - return JSON.parse(raw) as AzAccessToken; -} - -function isGpt5FamilyName(value?: string | null): boolean { - return typeof value === "string" && /^gpt-5(?:$|[-.])/i.test(value.trim()); -} - -function isGpt5FamilyDeployment(modelId: string, modelNameHint?: string | null): boolean { - return isGpt5FamilyName(modelId) || isGpt5FamilyName(modelNameHint); -} - -function buildAzureBaseUrl(endpoint: string, modelId: string): string { - const base = normalizeFoundryEndpoint(endpoint); - if (base.includes("/openai/deployments/")) return base; - return `${base}/openai/deployments/${modelId}`; -} - -function buildFoundryResponsesBaseUrl(endpoint: string): string { - const base = normalizeFoundryEndpoint(endpoint); - return base.endsWith("/openai/v1") ? base : `${base}/openai/v1`; -} - -function normalizeFoundryEndpoint(endpoint: string): string { - const trimmed = endpoint.trim().replace(/\/+$/, ""); - return trimmed.replace(/\/openai(?:\/v1|\/deployments\/[^/]+)?$/i, ""); -} - -function buildFoundryProviderBaseUrl( - endpoint: string, - modelId: string, - modelNameHint?: string | null, -): string { - return resolveFoundryApi(modelId, modelNameHint) === DEFAULT_GPT5_API - ? buildFoundryResponsesBaseUrl(endpoint) - : buildAzureBaseUrl(endpoint, modelId); -} - -function extractFoundryEndpoint(baseUrl: string): string | undefined { - try { - const url = new URL(baseUrl); - return url.origin; - } catch { - return undefined; - } -} - -function resolveFoundryApi(modelId: string, modelNameHint?: string | null): FoundryProviderApi { - return isGpt5FamilyDeployment(modelId, modelNameHint) ? DEFAULT_GPT5_API : DEFAULT_API; -} - -function buildFoundryModelCompat( - modelId: string, - modelNameHint?: string | null, -): ModelCompatConfig | undefined { - if (!isGpt5FamilyDeployment(modelId, modelNameHint)) { - return undefined; - } - return { - maxTokensField: "max_completion_tokens", - }; -} - -function buildFoundryProviderConfig( - endpoint: string, - modelId: string, - modelNameHint?: string | null, -): ModelProviderConfig { - const compat = buildFoundryModelCompat(modelId, modelNameHint); - return { - baseUrl: buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint), - api: resolveFoundryApi(modelId, modelNameHint), - models: [ - { - id: modelId, - name: typeof modelNameHint === "string" && modelNameHint.trim().length > 0 ? modelNameHint.trim() : modelId, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128_000, - maxTokens: 16_384, - ...(compat ? { compat } : {}), - }, - ], - }; -} - -function normalizeEndpointOrigin(rawUrl: string | null | undefined): string | undefined { - if (!rawUrl) { - return undefined; - } - try { - return new URL(rawUrl).origin; - } catch { - return undefined; - } -} - -function resolveConfiguredModelNameHint(modelId: string, modelNameHint?: string | null): string | undefined { - const trimmedName = typeof modelNameHint === "string" ? modelNameHint.trim() : ""; - if (trimmedName) { - return trimmedName; - } - const trimmedId = modelId.trim(); - return trimmedId ? trimmedId : undefined; -} - -function buildFoundryCredentialMetadata(params: { - authMethod: "api-key" | "entra-id"; - endpoint: string; - modelId: string; - modelNameHint?: string | null; - subscriptionId?: string; - subscriptionName?: string; - tenantId?: string; -}): Record { - const metadata: Record = { - authMethod: params.authMethod, - endpoint: params.endpoint, - modelId: params.modelId, - }; - const modelName = resolveConfiguredModelNameHint(params.modelId, params.modelNameHint); - if (modelName) { - metadata.modelName = modelName; - } - if (params.subscriptionId) { - metadata.subscriptionId = params.subscriptionId; - } - if (params.subscriptionName) { - metadata.subscriptionName = params.subscriptionName; - } - if (params.tenantId) { - metadata.tenantId = params.tenantId; - } - return metadata; -} - -function buildFoundryAuthResult(params: { - profileId: string; - apiKey: SecretInput; - secretInputMode?: "plaintext" | "ref"; - endpoint: string; - modelId: string; - modelNameHint?: string | null; - authMethod: "api-key" | "entra-id"; - subscriptionId?: string; - subscriptionName?: string; - tenantId?: string; - notes?: string[]; -}): ProviderAuthResult { - return { - profiles: [ - { - profileId: params.profileId, - credential: buildApiKeyCredential( - PROVIDER_ID, - params.apiKey, - buildFoundryCredentialMetadata({ - authMethod: params.authMethod, - endpoint: params.endpoint, - modelId: params.modelId, - modelNameHint: params.modelNameHint, - subscriptionId: params.subscriptionId, - subscriptionName: params.subscriptionName, - tenantId: params.tenantId, - }), - params.secretInputMode ? { secretInputMode: params.secretInputMode } : undefined, - ), - }, - ], - configPatch: { - models: { - providers: { - [PROVIDER_ID]: buildFoundryProviderConfig( - params.endpoint, - params.modelId, - params.modelNameHint, - ), - }, - }, - }, - defaultModel: `${PROVIDER_ID}/${params.modelId}`, - notes: params.notes, - }; -} - -function applyFoundryProfileBinding( - config: ProviderModelSelectedContext["config"], - profileId: string, -): void { - applyAuthProfileConfig(config, { - profileId, - provider: PROVIDER_ID, - mode: "api_key", - }); -} - -function applyFoundryProviderConfig( - config: ProviderModelSelectedContext["config"], - providerConfig: ModelProviderConfig, -): void { - config.models ??= {}; - config.models.providers ??= {}; - config.models.providers[PROVIDER_ID] = providerConfig; -} - -function listFoundryResources(): FoundryResourceOption[] { - try { - const raw = execAz([ - "cognitiveservices", - "account", - "list", - "--query", - "[].{id:id,name:name,kind:kind,location:location,resourceGroup:resourceGroup,endpoint:properties.endpoint,customSubdomain:properties.customSubDomainName,projects:properties.associatedProjects}", - "--output", - "json", - ]); - const accounts = JSON.parse(raw) as AzCognitiveAccount[]; - const resources: FoundryResourceOption[] = []; - for (const account of accounts) { - if (!account.resourceGroup) { - continue; - } - if (account.kind === "OpenAI") { - const endpoint = normalizeEndpointOrigin(account.endpoint); - if (!endpoint) { - continue; - } - resources.push({ - id: account.id, - accountName: account.name, - kind: "OpenAI", - location: account.location, - resourceGroup: account.resourceGroup, - endpoint, - projects: [], - }); - continue; - } - if (account.kind !== "AIServices") { - continue; - } - const endpoint = account.customSubdomain?.trim() - ? `https://${account.customSubdomain.trim()}.services.ai.azure.com` - : undefined; - if (!endpoint) { - continue; - } - resources.push({ - id: account.id, - accountName: account.name, - kind: "AIServices", - location: account.location, - resourceGroup: account.resourceGroup, - endpoint, - projects: Array.isArray(account.projects) - ? account.projects.filter((project): project is string => typeof project === "string") - : [], - }); - } - return resources; - } catch { - return []; - } -} - -function listResourceDeployments(resource: FoundryResourceOption): AzDeploymentSummary[] { - try { - const raw = execAz([ - "cognitiveservices", - "account", - "deployment", - "list", - "-g", - resource.resourceGroup, - "-n", - resource.accountName, - "--query", - "[].{name:name,modelName:properties.model.name,modelVersion:properties.model.version,state:properties.provisioningState,sku:sku.name}", - "--output", - "json", - ]); - const deployments = JSON.parse(raw) as AzDeploymentSummary[]; - return deployments.filter((deployment) => deployment.state === "Succeeded"); - } catch { - return []; - } -} - -async function selectFoundryResource( - ctx: ProviderAuthContext, - selectedSub: AzAccount, -): Promise { - const resources = listFoundryResources(); - if (resources.length === 0) { - throw new Error(buildCreateFoundryHint(selectedSub)); - } - if (resources.length === 1) { - const only = resources[0]!; - await ctx.prompter.note( - `Using ${only.kind === "AIServices" ? "Azure AI Foundry" : "Azure OpenAI"} resource: ${only.accountName}`, - "Foundry Resource", - ); - return only; - } - const selectedResourceId = await ctx.prompter.select({ - message: "Select Azure AI Foundry / Azure OpenAI resource", - options: resources.map((resource) => ({ - value: resource.id, - label: `${resource.accountName} (${resource.kind === "AIServices" ? "Azure AI Foundry" : "Azure OpenAI"}${resource.location ? `, ${resource.location}` : ""})`, - hint: [ - `RG: ${resource.resourceGroup}`, - resource.projects.length > 0 ? `${resource.projects.length} project(s)` : undefined, - ] - .filter(Boolean) - .join(" | "), - })), - }); - return resources.find((resource) => resource.id === selectedResourceId) ?? resources[0]!; -} - -async function selectFoundryDeployment( - ctx: ProviderAuthContext, - resource: FoundryResourceOption, -): Promise { - const deployments = listResourceDeployments(resource); - if (deployments.length === 0) { - throw new Error( - [ - `No model deployments were found in ${resource.accountName}.`, - "Deploy a model in Azure AI Foundry or Azure OpenAI, then rerun onboard.", - ].join("\n"), - ); - } - if (deployments.length === 1) { - const only = deployments[0]!; - await ctx.prompter.note(`Using deployment: ${only.name}`, "Model Deployment"); - return only; - } - const selectedDeploymentName = await ctx.prompter.select({ - message: "Select model deployment", - options: deployments.map((deployment) => ({ - value: deployment.name, - label: deployment.name, - hint: [deployment.modelName, deployment.modelVersion, deployment.sku].filter(Boolean).join(" | "), - })), - }); - return deployments.find((deployment) => deployment.name === selectedDeploymentName) ?? deployments[0]!; -} - -function buildCreateFoundryHint(selectedSub: AzAccount): string { - return [ - `No Azure AI Foundry or Azure OpenAI resources were found in subscription ${selectedSub.name} (${selectedSub.id}).`, - "Create one in Azure AI Foundry or Azure Portal, then rerun onboard.", - "Azure AI Foundry: https://ai.azure.com", - "Azure OpenAI docs: https://learn.microsoft.com/azure/ai-foundry/openai/how-to/create-resource", - ].join("\n"); -} - -async function promptEndpointAndModelManually(ctx: ProviderAuthContext): Promise<{ - endpoint: string; - modelId: string; - modelNameHint?: string; -}> { - const endpoint = String( - await ctx.prompter.text({ - message: "Microsoft 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(); - 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(); - const modelNameHintInput = String( - await ctx.prompter.text({ - message: "Underlying Azure model family (optional)", - initialValue: modelId, - placeholder: "gpt-5.4, gpt-4o, etc.", - }), - ).trim(); - return { - endpoint, - modelId, - modelNameHint: modelNameHintInput || modelId, - }; -} - -async function promptApiKeyEndpointAndModel(ctx: ProviderAuthContext): Promise { - const endpoint = String( - await ctx.prompter.text({ - message: "Microsoft Foundry endpoint URL", - placeholder: "https://xxx.openai.azure.com or https://xxx.services.ai.azure.com", - initialValue: normalizeOptionalSecretInput(process.env.AZURE_OPENAI_ENDPOINT), - 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(); - const modelId = String( - await ctx.prompter.text({ - message: "Default model/deployment name", - initialValue: "gpt-4o", - validate: (v) => { - const val = String(v ?? "").trim(); - if (!val) return "Model ID is required"; - return undefined; - }, - }), - ).trim(); - const modelNameHintInput = String( - await ctx.prompter.text({ - message: "Underlying Azure model family (optional)", - initialValue: modelId, - placeholder: "gpt-5.4, gpt-4o, etc.", - }), - ).trim(); - return { - endpoint, - modelId, - modelNameHint: modelNameHintInput || modelId, - }; -} - -function buildFoundryConnectionTest(params: { - endpoint: string; - modelId: string; - modelNameHint?: string | null; -}): { url: string; body: Record } { - const baseUrl = buildFoundryProviderBaseUrl( - params.endpoint, - params.modelId, - params.modelNameHint, - ); - if (resolveFoundryApi(params.modelId, params.modelNameHint) === DEFAULT_GPT5_API) { - return { - url: `${baseUrl}/responses?api-version=2025-04-01-preview`, - body: { - model: params.modelId, - input: "hi", - max_output_tokens: 1, - }, - }; - } - return { - url: `${baseUrl}/chat/completions?api-version=2024-12-01-preview`, - body: { - messages: [{ role: "user", content: "hi" }], - max_tokens: 1, - }, - }; -} - -function getFoundryTokenCacheKey(params?: { - subscriptionId?: string; - tenantId?: string; -}): string { - return `${params?.subscriptionId ?? ""}:${params?.tenantId ?? ""}`; -} - -/** - * 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 azLoginDeviceCodeWithOptions({}); -} - -async function azLoginDeviceCodeWithOptions(params: { - tenantId?: string; - allowNoSubscriptions?: boolean; -}): Promise { - return new Promise((resolve, reject) => { - const args = [ - "login", - "--use-device-code", - ...(params.tenantId ? ["--tenant", params.tenantId] : []), - ...(params.allowNoSubscriptions ? ["--allow-no-subscriptions"] : []), - ]; - const child = spawn("az", args, { - stdio: "inherit", - shell: process.platform === "win32", - }); - child.on("close", (code) => { - if (code === 0) resolve(); - else reject(new Error(`az login exited with code ${code}`)); - }); - child.on("error", reject); - }); -} - -function extractTenantSuggestions(rawMessage: string): Array<{ id: string; label?: string }> { - const suggestions: Array<{ id: string; label?: string }> = []; - const seen = new Set(); - const regex = /([0-9a-fA-F-]{36})(?:\s+'([^'\r\n]+)')?/g; - for (const match of rawMessage.matchAll(regex)) { - const id = match[1]?.trim(); - if (!id || seen.has(id)) { - continue; - } - seen.add(id); - suggestions.push({ - id, - ...(match[2]?.trim() ? { label: match[2].trim() } : {}), - }); - } - return suggestions; -} - -async function promptTenantId( - ctx: ProviderAuthContext, - params?: { - suggestions?: Array<{ id: string; label?: string }>; - required?: boolean; - reason?: string; - }, -): Promise { - const suggestionLines = - params?.suggestions && params.suggestions.length > 0 - ? params.suggestions.map((entry) => `- ${entry.id}${entry.label ? ` (${entry.label})` : ""}`) - : []; - if (params?.reason || suggestionLines.length > 0) { - await ctx.prompter.note( - [ - params?.reason, - suggestionLines.length > 0 ? "Suggested tenants:" : undefined, - ...suggestionLines, - ] - .filter(Boolean) - .join("\n"), - "Azure Tenant", - ); - } - const tenantId = String( - await ctx.prompter.text({ - message: params?.required - ? "Azure tenant ID" - : "Azure tenant ID (optional)", - placeholder: params?.suggestions?.[0]?.id ?? "00000000-0000-0000-0000-000000000000", - validate: (value) => { - const trimmed = String(value ?? "").trim(); - if (!trimmed) { - return params?.required ? "Tenant ID is required" : undefined; - } - return /^[0-9a-fA-F-]{36}$/.test(trimmed) ? undefined : "Enter a valid tenant ID"; - }, - }), - ).trim(); - return tenantId || undefined; -} - -async function loginWithTenantFallback(ctx: ProviderAuthContext): Promise<{ - account: AzAccount | null; - tenantId?: string; -}> { - try { - await azLoginDeviceCode(); - return { account: getLoggedInAccount() }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const isAzureTenantError = - /AADSTS/i.test(message) || - return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(trimmed) ? undefined : "Enter a valid tenant ID"; - /tenant/i.test(message); - if (!isAzureTenantError) { - throw error; - } - const tenantId = await promptTenantId(ctx, { - suggestions: extractTenantSuggestions(message), - required: true, - reason: - "Azure login needs a tenant-scoped retry. This often happens when your tenant requires MFA or your account has no Azure subscriptions.", - }); - await azLoginDeviceCodeWithOptions({ - tenantId, - allowNoSubscriptions: true, - }); - return { - account: getLoggedInAccount(), - tenantId, - }; - } -} - -// --------------------------------------------------------------------------- -// 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: "microsoft-foundry-entra", - choiceLabel: "Microsoft Foundry (Entra ID / az login)", - choiceHint: "Use your Azure login — no API key needed", - groupId: "microsoft-foundry", - groupLabel: "Microsoft 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(); - let tenantId = account?.tenantId; - 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) { - const loginResult = await loginWithTenantFallback(ctx); - account = loginResult.account; - tenantId = loginResult.tenantId ?? loginResult.account?.tenantId; - } - } else { - await ctx.prompter.note( - "You need to log in to Azure. A device code will be displayed — follow the instructions.", - "Azure Login", - ); - const loginResult = await loginWithTenantFallback(ctx); - account = loginResult.account; - tenantId = loginResult.tenantId ?? loginResult.account?.tenantId; - } - - // 3. List and select subscription - const subs = listSubscriptions(); - let selectedSub: AzAccount | null = null; - if (subs.length === 0) { - tenantId ??= await promptTenantId(ctx, { - required: true, - reason: - "No enabled Azure subscriptions were found. Continue with tenant-scoped Entra ID auth instead.", - }); - await ctx.prompter.note( - `Continuing with tenant-scoped auth (${tenantId}).`, - "Azure Tenant", - ); - } else if (subs.length === 1) { - selectedSub = subs[0]!; - tenantId ??= selectedSub.tenantId; - 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)!; - tenantId ??= selectedSub.tenantId; - } - - // 4. Set subscription - if (selectedSub) { - execAz(["account", "set", "--subscription", selectedSub.id]); - } - - // 5. Discover resource + deployment when possible - let endpoint: string; - let modelId: string; - let modelNameHint: string | undefined; - if (selectedSub) { - const useDiscoveredResource = await ctx.prompter.confirm({ - message: "Discover Microsoft Foundry resources from this subscription?", - initialValue: true, - }); - if (useDiscoveredResource) { - const selectedResource = await selectFoundryResource(ctx, selectedSub); - const selectedDeployment = await selectFoundryDeployment(ctx, selectedResource); - endpoint = selectedResource.endpoint; - modelId = selectedDeployment.name; - modelNameHint = resolveConfiguredModelNameHint(modelId, selectedDeployment.modelName); - await ctx.prompter.note( - [ - `Resource: ${selectedResource.accountName}`, - `Endpoint: ${endpoint}`, - `Deployment: ${modelId}`, - selectedDeployment.modelName ? `Model: ${selectedDeployment.modelName}` : undefined, - ].join("\n"), - "Microsoft Foundry", - ); - } else { - ({ endpoint, modelId, modelNameHint } = await promptEndpointAndModelManually(ctx)); - } - } else { - ({ endpoint, modelId, modelNameHint } = await promptEndpointAndModelManually(ctx)); - } - - // 7. Test connection - try { - const { accessToken } = getAccessTokenResult({ - subscriptionId: selectedSub?.id, - tenantId, - }); - const testRequest = buildFoundryConnectionTest({ - endpoint, - modelId, - modelNameHint, - }); - const res = await fetch(testRequest.url, { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(testRequest.body), - }); - 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 { - const statusNote = res.status === 400 ? " (400 Bad Request — endpoint reachable)" : ""; - const statusBody = res.status === 400 ? await res.text().catch(() => "") : ""; - await ctx.prompter.note(`Connection test successful!${statusNote}`, "✓"); - if (statusBody) { - await ctx.prompter.note( - `Endpoint response: ${statusBody.slice(0, 200)}`, - "Connection Test", - ); - } - } - } 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 buildFoundryAuthResult({ - profileId, - apiKey: "__entra_id_dynamic__", - endpoint, - modelId, - modelNameHint, - authMethod: "entra-id", - ...(selectedSub?.id ? { subscriptionId: selectedSub.id } : {}), - ...(selectedSub?.name ? { subscriptionName: selectedSub.name } : {}), - ...(tenantId ? { tenantId } : {}), - notes: [ - ...(selectedSub?.name ? [`Subscription: ${selectedSub.name}`] : []), - ...(tenantId ? [`Tenant: ${tenantId}`] : []), - `Endpoint: ${endpoint}`, - `Model: ${modelId}`, - "Token is refreshed automatically via az CLI — keep az login active.", - ], - }); - }, -}; - -// --------------------------------------------------------------------------- -// API Key auth method -// --------------------------------------------------------------------------- - -const apiKeyAuthMethod: ProviderAuthMethod = { - id: "api-key", - label: "Azure OpenAI API key", - hint: "Direct Azure OpenAI API key", - kind: "api_key", - wizard: { - choiceId: "microsoft-foundry-apikey", - choiceLabel: "Microsoft Foundry (API key)", - groupId: "microsoft-foundry", - groupLabel: "Microsoft Foundry", - groupHint: "Entra ID + API key", - }, - run: async (ctx) => { - const authStore = ensureAuthProfileStore(ctx.agentDir, { - allowKeychainPrompt: false, - }); - const existing = authStore.profiles[`${PROVIDER_ID}:default`]; - const existingMetadata = existing?.type === "api_key" ? existing.metadata : undefined; - let capturedSecretInput: SecretInput | undefined; - let capturedCredential = false; - let capturedMode: "plaintext" | "ref" | undefined; - await ensureApiKeyFromOptionEnvOrPrompt({ - token: normalizeOptionalSecretInput(ctx.opts?.azureOpenaiApiKey), - tokenProvider: PROVIDER_ID, - secretInputMode: - ctx.allowSecretRefPrompt === false ? (ctx.secretInputMode ?? "plaintext") : ctx.secretInputMode, - config: ctx.config, - expectedProviders: [PROVIDER_ID], - provider: PROVIDER_ID, - envLabel: "AZURE_OPENAI_API_KEY", - promptMessage: "Enter Azure OpenAI API key", - normalize: normalizeApiKeyInput, - validate: validateApiKeyInput, - prompter: ctx.prompter, - setCredential: async (apiKey, mode) => { - capturedSecretInput = apiKey; - capturedCredential = true; - capturedMode = mode; - }, - }); - if (!capturedCredential) { - throw new Error("Missing Azure OpenAI API key."); - } - const selection = await promptApiKeyEndpointAndModel(ctx); - return buildFoundryAuthResult({ - profileId: `${PROVIDER_ID}:default`, - apiKey: capturedSecretInput ?? "", - ...(capturedMode ? { secretInputMode: capturedMode } : {}), - endpoint: selection.endpoint, - modelId: selection.modelId, - modelNameHint: - selection.modelNameHint ?? existingMetadata?.modelName ?? existingMetadata?.modelId, - authMethod: "api-key", - notes: [ - `Endpoint: ${selection.endpoint}`, - `Model: ${selection.modelId}`, - ], - }); - }, -}; - -// --------------------------------------------------------------------------- -// Token cache for prepareRuntimeAuth -// --------------------------------------------------------------------------- - -const cachedTokens = new Map(); - -function refreshEntraToken(params?: { - subscriptionId?: string; - tenantId?: string; -}): { apiKey: string; expiresAt: number } { - const result = getAccessTokenResult(params); - const rawExpiry = result.expiresOn ? new Date(result.expiresOn).getTime() : Number.NaN; - const expiresAt = Number.isFinite(rawExpiry) - ? rawExpiry - : Date.now() + 55 * 60 * 1000; // default ~55 min - cachedTokens.set(getFoundryTokenCacheKey(params), { - token: result.accessToken, - expiresAt, - }); - return { apiKey: result.accessToken, expiresAt }; -} - -// --------------------------------------------------------------------------- -// Plugin entry -// --------------------------------------------------------------------------- +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { buildMicrosoftFoundryProvider } from "./provider.js"; export default definePluginEntry({ - id: PROVIDER_ID, + id: "microsoft-foundry", name: "Microsoft Foundry Provider", description: "Microsoft Foundry provider with Entra ID and API key auth", register(api) { - api.registerProvider({ - id: PROVIDER_ID, - label: "Microsoft Foundry", - docsPath: "/providers/azure", - envVars: ["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT"], - auth: [entraIdAuthMethod, apiKeyAuthMethod], - capabilities: { - providerFamily: "openai", - }, - onModelSelected: async (ctx: ProviderModelSelectedContext) => { - const providerConfig = ctx.config.models?.providers?.[PROVIDER_ID]; - if (!providerConfig || !ctx.model.startsWith(`${PROVIDER_ID}/`)) { - return; - } - const selectedModelId = ctx.model.slice(`${PROVIDER_ID}/`.length); - const existingModel = providerConfig.models.find( - (model: { id: string }) => model.id === selectedModelId, - ); - const selectedModelNameHint = resolveConfiguredModelNameHint( - selectedModelId, - existingModel?.name, - ); - const selectedModelCompat = buildFoundryModelCompat(selectedModelId, selectedModelNameHint); - const providerEndpoint = normalizeFoundryEndpoint(providerConfig.baseUrl ?? ""); - const nextProviderConfig: ModelProviderConfig = { - ...providerConfig, - baseUrl: buildFoundryProviderBaseUrl(providerEndpoint, selectedModelId, selectedModelNameHint), - api: resolveFoundryApi(selectedModelId, selectedModelNameHint), - models: [ - { - ...(existingModel ?? { - id: selectedModelId, - name: selectedModelId, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128_000, - maxTokens: 16_384, - }), - ...(selectedModelCompat ? { compat: selectedModelCompat } : {}), - }, - ], - }; - applyFoundryProfileBinding(ctx.config, `${PROVIDER_ID}:entra`); - applyFoundryProviderConfig(ctx.config, nextProviderConfig); - }, - normalizeResolvedModel: ({ modelId, model }) => { - const endpoint = extractFoundryEndpoint(model.baseUrl ?? ""); - if (!endpoint) { - return model; - } - const modelNameHint = resolveConfiguredModelNameHint(modelId, model.name); - const compat = buildFoundryModelCompat(modelId, modelNameHint); - return { - ...model, - api: resolveFoundryApi(modelId, modelNameHint), - baseUrl: buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint), - ...(compat ? { compat } : {}), - }; - }, - 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 - try { - const authStore = ensureAuthProfileStore(ctx.agentDir, { - allowKeychainPrompt: false, - }); - const credential = ctx.profileId ? authStore.profiles[ctx.profileId] : undefined; - const metadata = credential?.type === "api_key" ? credential.metadata : undefined; - const modelId = - typeof ctx.modelId === "string" && ctx.modelId.trim().length > 0 - ? ctx.modelId.trim() - : typeof metadata?.modelId === "string" && metadata.modelId.trim().length > 0 - ? metadata.modelId.trim() - : ctx.modelId; - const activeModelNameHint = - ctx.modelId === metadata?.modelId ? metadata?.modelName : undefined; - const modelNameHint = resolveConfiguredModelNameHint( - modelId, - ctx.model.name ?? activeModelNameHint, - ); - const endpoint = - typeof metadata?.endpoint === "string" && metadata.endpoint.trim().length > 0 - ? metadata.endpoint.trim() - : extractFoundryEndpoint(ctx.model.baseUrl ?? ""); - const baseUrl = endpoint - ? buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint) - : undefined; - const cacheKey = getFoundryTokenCacheKey({ - subscriptionId: metadata?.subscriptionId, - tenantId: metadata?.tenantId, - }); - const cachedToken = cachedTokens.get(cacheKey); - - // Return cached token if still valid - if (cachedToken && cachedToken.expiresAt > Date.now() + TOKEN_REFRESH_MARGIN_MS) { - return { - apiKey: cachedToken.token, - expiresAt: cachedToken.expiresAt, - ...(baseUrl ? { baseUrl } : {}), - }; - } - - // Refresh via az CLI - const token = refreshEntraToken({ - subscriptionId: metadata?.subscriptionId, - tenantId: metadata?.tenantId, - }); - return { - ...token, - ...(baseUrl ? { baseUrl } : {}), - }; - } 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", - ); - } - }, - }); + api.registerProvider(buildMicrosoftFoundryProvider()); }, }); diff --git a/extensions/microsoft-foundry/onboard.ts b/extensions/microsoft-foundry/onboard.ts new file mode 100644 index 00000000000..178b3d2a96f --- /dev/null +++ b/extensions/microsoft-foundry/onboard.ts @@ -0,0 +1,430 @@ +import type { ProviderAuthContext } from "openclaw/plugin-sdk/core"; +import { + azLoginDeviceCode, + azLoginDeviceCodeWithOptions, + execAz, + getAccessTokenResult, + getLoggedInAccount, + listSubscriptions, +} from "./cli.js"; +import { + type AzAccount, + type AzCognitiveAccount, + type AzDeploymentSummary, + type FoundryResourceOption, + type FoundrySelection, + buildFoundryProviderBaseUrl, + normalizeEndpointOrigin, + resolveConfiguredModelNameHint, + resolveFoundryApi, + DEFAULT_GPT5_API, +} from "./shared.js"; + +export { listSubscriptions } from "./cli.js"; + +export function listFoundryResources(): FoundryResourceOption[] { + try { + const accounts = JSON.parse( + execAz([ + "cognitiveservices", + "account", + "list", + "--query", + "[].{id:id,name:name,kind:kind,location:location,resourceGroup:resourceGroup,endpoint:properties.endpoint,customSubdomain:properties.customSubDomainName,projects:properties.associatedProjects}", + "--output", + "json", + ]), + ) as AzCognitiveAccount[]; + const resources: FoundryResourceOption[] = []; + for (const account of accounts) { + if (!account.resourceGroup) { + continue; + } + if (account.kind === "OpenAI") { + const endpoint = normalizeEndpointOrigin(account.endpoint); + if (!endpoint) { + continue; + } + resources.push({ + id: account.id, + accountName: account.name, + kind: "OpenAI", + location: account.location, + resourceGroup: account.resourceGroup, + endpoint, + projects: [], + }); + continue; + } + if (account.kind !== "AIServices") { + continue; + } + const endpoint = account.customSubdomain?.trim() + ? `https://${account.customSubdomain.trim()}.services.ai.azure.com` + : undefined; + if (!endpoint) { + continue; + } + resources.push({ + id: account.id, + accountName: account.name, + kind: "AIServices", + location: account.location, + resourceGroup: account.resourceGroup, + endpoint, + projects: Array.isArray(account.projects) + ? account.projects.filter((project): project is string => typeof project === "string") + : [], + }); + } + return resources; + } catch { + return []; + } +} + +export function listResourceDeployments(resource: FoundryResourceOption): AzDeploymentSummary[] { + try { + const deployments = JSON.parse( + execAz([ + "cognitiveservices", + "account", + "deployment", + "list", + "-g", + resource.resourceGroup, + "-n", + resource.accountName, + "--query", + "[].{name:name,modelName:properties.model.name,modelVersion:properties.model.version,state:properties.provisioningState,sku:sku.name}", + "--output", + "json", + ]), + ) as AzDeploymentSummary[]; + return deployments.filter((deployment) => deployment.state === "Succeeded"); + } catch { + return []; + } +} + +export function buildCreateFoundryHint(selectedSub: AzAccount): string { + return [ + `No Azure AI Foundry or Azure OpenAI resources were found in subscription ${selectedSub.name} (${selectedSub.id}).`, + "Create one in Azure AI Foundry or Azure Portal, then rerun onboard.", + "Azure AI Foundry: https://ai.azure.com", + "Azure OpenAI docs: https://learn.microsoft.com/azure/ai-foundry/openai/how-to/create-resource", + ].join("\n"); +} + +export async function selectFoundryResource( + ctx: ProviderAuthContext, + selectedSub: AzAccount, +): Promise { + const resources = listFoundryResources(); + if (resources.length === 0) { + throw new Error(buildCreateFoundryHint(selectedSub)); + } + if (resources.length === 1) { + const only = resources[0]!; + await ctx.prompter.note( + `Using ${only.kind === "AIServices" ? "Azure AI Foundry" : "Azure OpenAI"} resource: ${only.accountName}`, + "Foundry Resource", + ); + return only; + } + const selectedResourceId = await ctx.prompter.select({ + message: "Select Azure AI Foundry / Azure OpenAI resource", + options: resources.map((resource) => ({ + value: resource.id, + label: `${resource.accountName} (${resource.kind === "AIServices" ? "Azure AI Foundry" : "Azure OpenAI"}${resource.location ? `, ${resource.location}` : ""})`, + hint: [ + `RG: ${resource.resourceGroup}`, + resource.projects.length > 0 ? `${resource.projects.length} project(s)` : undefined, + ] + .filter(Boolean) + .join(" | "), + })), + }); + return resources.find((resource) => resource.id === selectedResourceId) ?? resources[0]!; +} + +export async function selectFoundryDeployment( + ctx: ProviderAuthContext, + resource: FoundryResourceOption, +): Promise { + const deployments = listResourceDeployments(resource); + if (deployments.length === 0) { + throw new Error( + [ + `No model deployments were found in ${resource.accountName}.`, + "Deploy a model in Azure AI Foundry or Azure OpenAI, then rerun onboard.", + ].join("\n"), + ); + } + if (deployments.length === 1) { + const only = deployments[0]!; + await ctx.prompter.note(`Using deployment: ${only.name}`, "Model Deployment"); + return only; + } + const selectedDeploymentName = await ctx.prompter.select({ + message: "Select model deployment", + options: deployments.map((deployment) => ({ + value: deployment.name, + label: deployment.name, + hint: [deployment.modelName, deployment.modelVersion, deployment.sku].filter(Boolean).join(" | "), + })), + }); + return deployments.find((deployment) => deployment.name === selectedDeploymentName) ?? deployments[0]!; +} + +export async function promptEndpointAndModelManually( + ctx: ProviderAuthContext, +): Promise { + const endpoint = String( + await ctx.prompter.text({ + message: "Microsoft 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(); + 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(); + const modelNameHintInput = String( + await ctx.prompter.text({ + message: "Underlying Azure model family (optional)", + initialValue: modelId, + placeholder: "gpt-5.4, gpt-4o, etc.", + }), + ).trim(); + return { + endpoint, + modelId, + modelNameHint: modelNameHintInput || modelId, + }; +} + +export async function promptApiKeyEndpointAndModel(ctx: ProviderAuthContext): Promise { + const endpoint = String( + await ctx.prompter.text({ + message: "Microsoft Foundry endpoint URL", + placeholder: "https://xxx.openai.azure.com or https://xxx.services.ai.azure.com", + initialValue: process.env.AZURE_OPENAI_ENDPOINT, + 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(); + const modelId = String( + await ctx.prompter.text({ + message: "Default model/deployment name", + initialValue: "gpt-4o", + validate: (v) => { + const val = String(v ?? "").trim(); + if (!val) return "Model ID is required"; + return undefined; + }, + }), + ).trim(); + const modelNameHintInput = String( + await ctx.prompter.text({ + message: "Underlying Azure model family (optional)", + initialValue: modelId, + placeholder: "gpt-5.4, gpt-4o, etc.", + }), + ).trim(); + return { + endpoint, + modelId, + modelNameHint: modelNameHintInput || modelId, + }; +} + +export function buildFoundryConnectionTest(params: { + endpoint: string; + modelId: string; + modelNameHint?: string | null; +}): { url: string; body: Record } { + const baseUrl = buildFoundryProviderBaseUrl(params.endpoint, params.modelId, params.modelNameHint); + if (resolveFoundryApi(params.modelId, params.modelNameHint) === DEFAULT_GPT5_API) { + return { + url: `${baseUrl}/responses?api-version=2025-04-01-preview`, + body: { + model: params.modelId, + input: "hi", + max_output_tokens: 1, + }, + }; + } + return { + url: `${baseUrl}/chat/completions?api-version=2024-12-01-preview`, + body: { + messages: [{ role: "user", content: "hi" }], + max_tokens: 1, + }, + }; +} + +export function extractTenantSuggestions(rawMessage: string): Array<{ id: string; label?: string }> { + const suggestions: Array<{ id: string; label?: string }> = []; + const seen = new Set(); + const regex = /([0-9a-fA-F-]{36})(?:\s+'([^'\r\n]+)')?/g; + for (const match of rawMessage.matchAll(regex)) { + const id = match[1]?.trim(); + if (!id || seen.has(id)) { + continue; + } + seen.add(id); + suggestions.push({ + id, + ...(match[2]?.trim() ? { label: match[2].trim() } : {}), + }); + } + return suggestions; +} + +export async function promptTenantId( + ctx: ProviderAuthContext, + params?: { + suggestions?: Array<{ id: string; label?: string }>; + required?: boolean; + reason?: string; + }, +): Promise { + const suggestionLines = + params?.suggestions && params.suggestions.length > 0 + ? params.suggestions.map((entry) => `- ${entry.id}${entry.label ? ` (${entry.label})` : ""}`) + : []; + if (params?.reason || suggestionLines.length > 0) { + await ctx.prompter.note( + [ + params?.reason, + suggestionLines.length > 0 ? "Suggested tenants:" : undefined, + ...suggestionLines, + ] + .filter(Boolean) + .join("\n"), + "Azure Tenant", + ); + } + const tenantId = String( + await ctx.prompter.text({ + message: params?.required ? "Azure tenant ID" : "Azure tenant ID (optional)", + placeholder: params?.suggestions?.[0]?.id ?? "00000000-0000-0000-0000-000000000000", + validate: (value) => { + const trimmed = String(value ?? "").trim(); + if (!trimmed) { + return params?.required ? "Tenant ID is required" : undefined; + } + return /^[0-9a-fA-F-]{36}$/.test(trimmed) ? undefined : "Enter a valid tenant ID"; + }, + }), + ).trim(); + return tenantId || undefined; +} + +export async function loginWithTenantFallback( + ctx: ProviderAuthContext, +): Promise<{ account: AzAccount | null; tenantId?: string }> { + try { + await azLoginDeviceCode(); + return { account: getLoggedInAccount() }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const isAzureTenantError = + /AADSTS\d+/i.test(message) || + /no subscriptions found/i.test(message) || + /Please provide a valid tenant/i.test(message) || + /tenant.*not found/i.test(message); + if (!isAzureTenantError) { + throw error; + } + const tenantId = await promptTenantId(ctx, { + suggestions: extractTenantSuggestions(message), + required: true, + reason: + "Azure login needs a tenant-scoped retry. This often happens when your tenant requires MFA or your account has no Azure subscriptions.", + }); + await azLoginDeviceCodeWithOptions({ + tenantId, + allowNoSubscriptions: true, + }); + return { + account: getLoggedInAccount(), + tenantId, + }; + } +} + +export async function testFoundryConnection(params: { + ctx: ProviderAuthContext; + endpoint: string; + modelId: string; + modelNameHint?: string; + subscriptionId?: string; + tenantId?: string; +}): Promise { + try { + const { accessToken } = getAccessTokenResult({ + subscriptionId: params.subscriptionId, + tenantId: params.tenantId, + }); + const testRequest = buildFoundryConnectionTest({ + endpoint: params.endpoint, + modelId: params.modelId, + modelNameHint: params.modelNameHint, + }); + const res = await fetch(testRequest.url, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(testRequest.body), + }); + if (res.status === 400) { + const body = await res.text().catch(() => ""); + await params.ctx.prompter.note( + `Endpoint is reachable but returned 400 Bad Request - check your deployment name and API version.\n${body.slice(0, 200)}`, + "Connection Test", + ); + } else if (!res.ok) { + const body = await res.text().catch(() => ""); + await params.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 params.ctx.prompter.note("Connection test successful!", "✓"); + } + } catch (err) { + await params.ctx.prompter.note( + `Warning: connection test failed: ${String(err)}\nProceeding anyway.`, + "Connection Test", + ); + } +} diff --git a/extensions/microsoft-foundry/provider.ts b/extensions/microsoft-foundry/provider.ts new file mode 100644 index 00000000000..95f5adf93d3 --- /dev/null +++ b/extensions/microsoft-foundry/provider.ts @@ -0,0 +1,81 @@ +import type { ProviderNormalizeResolvedModelContext } from "openclaw/plugin-sdk/core"; +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-models"; +import type { ProviderModelSelectedContext } from "../../src/plugins/types.js"; +import type { ModelProviderConfig } from "../../src/config/types.models.js"; +import { apiKeyAuthMethod, entraIdAuthMethod } from "./auth.js"; +import { prepareFoundryRuntimeAuth } from "./runtime.js"; +import { + PROVIDER_ID, + applyFoundryProfileBinding, + applyFoundryProviderConfig, + buildFoundryModelCompat, + buildFoundryProviderBaseUrl, + extractFoundryEndpoint, + normalizeFoundryEndpoint, + resolveConfiguredModelNameHint, + resolveFoundryApi, + resolveFoundryTargetProfileId, +} from "./shared.js"; + +export function buildMicrosoftFoundryProvider(): ProviderPlugin { + return { + id: PROVIDER_ID, + label: "Microsoft Foundry", + docsPath: "/providers/azure", + envVars: ["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT"], + auth: [entraIdAuthMethod, apiKeyAuthMethod], + capabilities: { + providerFamily: "openai" as const, + }, + onModelSelected: async (ctx: ProviderModelSelectedContext) => { + const providerConfig = ctx.config.models?.providers?.[PROVIDER_ID]; + if (!providerConfig || !ctx.model.startsWith(`${PROVIDER_ID}/`)) { + return; + } + const selectedModelId = ctx.model.slice(`${PROVIDER_ID}/`.length); + const existingModel = providerConfig.models.find((model: { id: string }) => model.id === selectedModelId); + const selectedModelNameHint = resolveConfiguredModelNameHint(selectedModelId, existingModel?.name); + const selectedModelCompat = buildFoundryModelCompat(selectedModelId, selectedModelNameHint); + const providerEndpoint = normalizeFoundryEndpoint(providerConfig.baseUrl ?? ""); + const nextProviderConfig: ModelProviderConfig = { + ...providerConfig, + baseUrl: buildFoundryProviderBaseUrl(providerEndpoint, selectedModelId, selectedModelNameHint), + api: resolveFoundryApi(selectedModelId, selectedModelNameHint), + models: [ + { + ...(existingModel ?? { + id: selectedModelId, + name: selectedModelId, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }), + ...(selectedModelCompat ? { compat: selectedModelCompat } : {}), + }, + ], + }; + const targetProfileId = resolveFoundryTargetProfileId(ctx.config, ctx.agentDir); + if (targetProfileId) { + applyFoundryProfileBinding(ctx.config, targetProfileId); + } + applyFoundryProviderConfig(ctx.config, nextProviderConfig); + }, + normalizeResolvedModel: ({ modelId, model }: ProviderNormalizeResolvedModelContext) => { + const endpoint = extractFoundryEndpoint(String(model.baseUrl ?? "")); + if (!endpoint) { + return model; + } + const modelNameHint = resolveConfiguredModelNameHint(modelId, model.name); + const compat = buildFoundryModelCompat(modelId, modelNameHint); + return { + ...model, + api: resolveFoundryApi(modelId, modelNameHint), + baseUrl: buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint), + ...(compat ? { compat } : {}), + }; + }, + prepareRuntimeAuth: prepareFoundryRuntimeAuth, + }; +} diff --git a/extensions/microsoft-foundry/runtime.ts b/extensions/microsoft-foundry/runtime.ts new file mode 100644 index 00000000000..072e4177080 --- /dev/null +++ b/extensions/microsoft-foundry/runtime.ts @@ -0,0 +1,85 @@ +import { ensureAuthProfileStore } from "openclaw/plugin-sdk/provider-auth"; +import type { ProviderPrepareRuntimeAuthContext } from "openclaw/plugin-sdk/core"; +import { getAccessTokenResultAsync } from "./cli.js"; +import { + type CachedTokenEntry, + TOKEN_REFRESH_MARGIN_MS, + buildFoundryProviderBaseUrl, + extractFoundryEndpoint, + getFoundryTokenCacheKey, + resolveConfiguredModelNameHint, +} from "./shared-runtime.js"; + +const cachedTokens = new Map(); +const refreshPromises = new Map>(); + +async function refreshEntraToken(params?: { + subscriptionId?: string; + tenantId?: string; +}): Promise<{ apiKey: string; expiresAt: number }> { + const result = await getAccessTokenResultAsync(params); + const rawExpiry = result.expiresOn ? new Date(result.expiresOn).getTime() : Number.NaN; + const expiresAt = Number.isFinite(rawExpiry) ? rawExpiry : Date.now() + 55 * 60 * 1000; + cachedTokens.set(getFoundryTokenCacheKey(params), { + token: result.accessToken, + expiresAt, + }); + return { apiKey: result.accessToken, expiresAt }; +} + +export async function prepareFoundryRuntimeAuth(ctx: ProviderPrepareRuntimeAuthContext) { + if (ctx.apiKey !== "__entra_id_dynamic__") { + return null; + } + try { + const authStore = ensureAuthProfileStore(ctx.agentDir, { + allowKeychainPrompt: false, + }); + const credential = ctx.profileId ? authStore.profiles[ctx.profileId] : undefined; + const metadata = credential?.type === "api_key" ? credential.metadata : undefined; + const modelId = + typeof ctx.modelId === "string" && ctx.modelId.trim().length > 0 + ? ctx.modelId.trim() + : typeof metadata?.modelId === "string" && metadata.modelId.trim().length > 0 + ? metadata.modelId.trim() + : ctx.modelId; + const activeModelNameHint = ctx.modelId === metadata?.modelId ? metadata?.modelName : undefined; + const modelNameHint = resolveConfiguredModelNameHint(modelId, ctx.model.name ?? activeModelNameHint); + const endpoint = + typeof metadata?.endpoint === "string" && metadata.endpoint.trim().length > 0 + ? metadata.endpoint.trim() + : extractFoundryEndpoint(ctx.model.baseUrl ?? ""); + const baseUrl = endpoint ? buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint) : undefined; + const cacheKey = getFoundryTokenCacheKey({ + subscriptionId: metadata?.subscriptionId, + tenantId: metadata?.tenantId, + }); + const cachedToken = cachedTokens.get(cacheKey); + if (cachedToken && cachedToken.expiresAt > Date.now() + TOKEN_REFRESH_MARGIN_MS) { + return { + apiKey: cachedToken.token, + expiresAt: cachedToken.expiresAt, + ...(baseUrl ? { baseUrl } : {}), + }; + } + let refreshPromise = refreshPromises.get(cacheKey); + if (!refreshPromise) { + refreshPromise = refreshEntraToken({ + subscriptionId: metadata?.subscriptionId, + tenantId: metadata?.tenantId, + }).finally(() => { + refreshPromises.delete(cacheKey); + }); + refreshPromises.set(cacheKey, refreshPromise); + } + const token = await refreshPromise; + return { + ...token, + ...(baseUrl ? { baseUrl } : {}), + }; + } catch (err) { + throw new Error( + `Failed to refresh Azure Entra ID token via az CLI: ${String(err)}\nMake sure you are logged in: az login --use-device-code`, + ); + } +} diff --git a/extensions/microsoft-foundry/shared-runtime.ts b/extensions/microsoft-foundry/shared-runtime.ts new file mode 100644 index 00000000000..cc5a224668f --- /dev/null +++ b/extensions/microsoft-foundry/shared-runtime.ts @@ -0,0 +1,14 @@ +export { + TOKEN_REFRESH_MARGIN_MS, + buildFoundryProviderBaseUrl, + extractFoundryEndpoint, + resolveConfiguredModelNameHint, + type CachedTokenEntry, +} from "./shared.js"; + +export function getFoundryTokenCacheKey(params?: { + subscriptionId?: string; + tenantId?: string; +}): string { + return `${params?.subscriptionId ?? ""}:${params?.tenantId ?? ""}`; +} diff --git a/extensions/microsoft-foundry/shared.ts b/extensions/microsoft-foundry/shared.ts new file mode 100644 index 00000000000..8de4ac17bce --- /dev/null +++ b/extensions/microsoft-foundry/shared.ts @@ -0,0 +1,312 @@ +import { + applyAuthProfileConfig, + buildApiKeyCredential, + ensureAuthProfileStore, + type ProviderAuthResult, + type SecretInput, +} from "openclaw/plugin-sdk/provider-auth"; +import type { ModelCompatConfig, ModelProviderConfig } from "../../src/config/types.models.js"; +import type { ProviderModelSelectedContext } from "../../src/plugins/types.js"; + +export const PROVIDER_ID = "microsoft-foundry"; +export const DEFAULT_API = "openai-completions"; +export const DEFAULT_GPT5_API = "openai-responses"; +export const COGNITIVE_SERVICES_RESOURCE = "https://cognitiveservices.azure.com"; +export const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000; + +export interface AzAccount { + name: string; + id: string; + tenantId?: string; + user?: { name?: string }; + state?: string; + isDefault?: boolean; +} + +export interface AzAccessToken { + accessToken: string; + expiresOn?: string; +} + +export interface AzCognitiveAccount { + id: string; + name: string; + kind: string; + location?: string; + resourceGroup?: string; + endpoint?: string | null; + customSubdomain?: string | null; + projects?: string[] | null; +} + +export interface FoundryResourceOption { + id: string; + accountName: string; + kind: "AIServices" | "OpenAI"; + location?: string; + resourceGroup: string; + endpoint: string; + projects: string[]; +} + +export interface AzDeploymentSummary { + name: string; + modelName?: string; + modelVersion?: string; + state?: string; + sku?: string; +} + +export type FoundrySelection = { + endpoint: string; + modelId: string; + modelNameHint?: string; +}; + +export type CachedTokenEntry = { + token: string; + expiresAt: number; +}; + +export type FoundryProviderApi = typeof DEFAULT_API | typeof DEFAULT_GPT5_API; + +export function isGpt5FamilyName(value?: string | null): boolean { + return typeof value === "string" && /^gpt-5(?:$|[-.])/i.test(value.trim()); +} + +export function isGpt5FamilyDeployment(modelId: string, modelNameHint?: string | null): boolean { + return isGpt5FamilyName(modelId) || isGpt5FamilyName(modelNameHint); +} + +export function normalizeFoundryEndpoint(endpoint: string): string { + const trimmed = endpoint.trim().replace(/\/+$/, ""); + return trimmed.replace(/\/openai(?:\/v1|\/deployments\/[^/]+)?$/i, ""); +} + +export function buildAzureBaseUrl(endpoint: string, modelId: string): string { + const base = normalizeFoundryEndpoint(endpoint); + if (base.includes("/openai/deployments/")) return base; + return `${base}/openai/deployments/${modelId}`; +} + +export function buildFoundryResponsesBaseUrl(endpoint: string): string { + const base = normalizeFoundryEndpoint(endpoint); + return base.endsWith("/openai/v1") ? base : `${base}/openai/v1`; +} + +export function resolveFoundryApi( + modelId: string, + modelNameHint?: string | null, +): FoundryProviderApi { + return isGpt5FamilyDeployment(modelId, modelNameHint) ? DEFAULT_GPT5_API : DEFAULT_API; +} + +export function buildFoundryProviderBaseUrl( + endpoint: string, + modelId: string, + modelNameHint?: string | null, +): string { + return resolveFoundryApi(modelId, modelNameHint) === DEFAULT_GPT5_API + ? buildFoundryResponsesBaseUrl(endpoint) + : buildAzureBaseUrl(endpoint, modelId); +} + +export function extractFoundryEndpoint(baseUrl: string): string | undefined { + try { + return new URL(baseUrl).origin; + } catch { + return undefined; + } +} + +export function buildFoundryModelCompat( + modelId: string, + modelNameHint?: string | null, +): ModelCompatConfig | undefined { + if (!isGpt5FamilyDeployment(modelId, modelNameHint)) { + return undefined; + } + return { + maxTokensField: "max_completion_tokens", + }; +} + +export function resolveConfiguredModelNameHint( + modelId: string, + modelNameHint?: string | null, +): string | undefined { + const trimmedName = typeof modelNameHint === "string" ? modelNameHint.trim() : ""; + if (trimmedName) { + return trimmedName; + } + const trimmedId = modelId.trim(); + return trimmedId ? trimmedId : undefined; +} + +export function buildFoundryProviderConfig( + endpoint: string, + modelId: string, + modelNameHint?: string | null, +): ModelProviderConfig { + const compat = buildFoundryModelCompat(modelId, modelNameHint); + return { + baseUrl: buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint), + api: resolveFoundryApi(modelId, modelNameHint), + models: [ + { + id: modelId, + name: + typeof modelNameHint === "string" && modelNameHint.trim().length > 0 + ? modelNameHint.trim() + : modelId, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + ...(compat ? { compat } : {}), + }, + ], + }; +} + +export function normalizeEndpointOrigin(rawUrl: string | null | undefined): string | undefined { + if (!rawUrl) { + return undefined; + } + try { + return new URL(rawUrl).origin; + } catch { + return undefined; + } +} + +function buildFoundryCredentialMetadata(params: { + authMethod: "api-key" | "entra-id"; + endpoint: string; + modelId: string; + modelNameHint?: string | null; + subscriptionId?: string; + subscriptionName?: string; + tenantId?: string; +}): Record { + const metadata: Record = { + authMethod: params.authMethod, + endpoint: params.endpoint, + modelId: params.modelId, + }; + const modelName = resolveConfiguredModelNameHint(params.modelId, params.modelNameHint); + if (modelName) { + metadata.modelName = modelName; + } + if (params.subscriptionId) { + metadata.subscriptionId = params.subscriptionId; + } + if (params.subscriptionName) { + metadata.subscriptionName = params.subscriptionName; + } + if (params.tenantId) { + metadata.tenantId = params.tenantId; + } + return metadata; +} + +export function buildFoundryAuthResult(params: { + profileId: string; + apiKey: SecretInput; + secretInputMode?: "plaintext" | "ref"; + endpoint: string; + modelId: string; + modelNameHint?: string | null; + authMethod: "api-key" | "entra-id"; + subscriptionId?: string; + subscriptionName?: string; + tenantId?: string; + notes?: string[]; +}): ProviderAuthResult { + return { + profiles: [ + { + profileId: params.profileId, + credential: buildApiKeyCredential( + PROVIDER_ID, + params.apiKey, + buildFoundryCredentialMetadata({ + authMethod: params.authMethod, + endpoint: params.endpoint, + modelId: params.modelId, + modelNameHint: params.modelNameHint, + subscriptionId: params.subscriptionId, + subscriptionName: params.subscriptionName, + tenantId: params.tenantId, + }), + params.secretInputMode ? { secretInputMode: params.secretInputMode } : undefined, + ), + }, + ], + configPatch: { + models: { + providers: { + [PROVIDER_ID]: buildFoundryProviderConfig( + params.endpoint, + params.modelId, + params.modelNameHint, + ), + }, + }, + }, + defaultModel: `${PROVIDER_ID}/${params.modelId}`, + notes: params.notes, + }; +} + +export function applyFoundryProfileBinding( + config: ProviderModelSelectedContext["config"], + profileId: string, +): void { + applyAuthProfileConfig(config, { + profileId, + provider: PROVIDER_ID, + mode: "api_key", + }); +} + +export function applyFoundryProviderConfig( + config: ProviderModelSelectedContext["config"], + providerConfig: ModelProviderConfig, +): void { + config.models ??= {}; + config.models.providers ??= {}; + config.models.providers[PROVIDER_ID] = providerConfig; +} + +export function resolveFoundryTargetProfileId( + config: ProviderModelSelectedContext["config"], + agentDir?: string, +): string | undefined { + const configuredProfiles = config.auth?.profiles ?? {}; + const configuredProfileEntries = Object.entries(configuredProfiles).filter(([, profile]) => { + return profile.provider === PROVIDER_ID; + }); + if (configuredProfileEntries.length === 0) { + return undefined; + } + const configuredProfileId = + config.auth?.order?.[PROVIDER_ID]?.find((profileId) => profileId.trim().length > 0) ?? + (configuredProfileEntries.length === 1 ? configuredProfileEntries[0]?.[0] : undefined); + if (!configuredProfileId || !agentDir) { + return configuredProfileId; + } + const authStore = ensureAuthProfileStore(agentDir, { + allowKeychainPrompt: false, + }); + const credential = authStore.profiles[configuredProfileId]; + const authMethod = credential?.type === "api_key" ? credential.metadata?.authMethod : undefined; + if (authMethod === "api-key") { + return `${PROVIDER_ID}:default`; + } + if (authMethod === "entra-id") { + return `${PROVIDER_ID}:entra`; + } + return configuredProfileId; +} From 3d2524a8c3c98e8d999714ff53086075101a1998 Mon Sep 17 00:00:00 2001 From: haxudev Date: Sat, 21 Mar 2026 09:12:54 +0800 Subject: [PATCH 10/12] Microsoft Foundry: preserve profile state in extension flows Keep the Foundry extension scoped to its own files while preserving configured model aliases during model switches, accepting tenant domains in tenant-scoped login prompts, and using subscription-scoped discovery without mutating the user's global Azure CLI default. Add extension tests that lock in the tenant identifier and model-preservation behavior. --- extensions/microsoft-foundry/auth.ts | 8 +-- extensions/microsoft-foundry/index.test.ts | 64 ++++++++++++++++++++++ extensions/microsoft-foundry/onboard.ts | 31 +++++++++-- extensions/microsoft-foundry/provider.ts | 41 ++++++++------ extensions/microsoft-foundry/shared.ts | 40 ++++++++++---- 5 files changed, 143 insertions(+), 41 deletions(-) diff --git a/extensions/microsoft-foundry/auth.ts b/extensions/microsoft-foundry/auth.ts index 8091c266803..3c6d3e1c0ad 100644 --- a/extensions/microsoft-foundry/auth.ts +++ b/extensions/microsoft-foundry/auth.ts @@ -11,7 +11,7 @@ import { type SecretInput, validateApiKeyInput, } from "openclaw/plugin-sdk/provider-auth"; -import { execAz, getLoggedInAccount, isAzCliInstalled } from "./cli.js"; +import { getLoggedInAccount, isAzCliInstalled } from "./cli.js"; import { buildFoundryAuthResult, PROVIDER_ID, @@ -97,10 +97,6 @@ export const entraIdAuthMethod: ProviderAuthMethod = { tenantId ??= selectedSub.tenantId; } - if (selectedSub) { - execAz(["account", "set", "--subscription", selectedSub.id]); - } - let endpoint: string; let modelId: string; let modelNameHint: string | undefined; @@ -111,7 +107,7 @@ export const entraIdAuthMethod: ProviderAuthMethod = { }); if (useDiscoveredResource) { const selectedResource = await selectFoundryResource(ctx, selectedSub); - const selectedDeployment = await selectFoundryDeployment(ctx, selectedResource); + const selectedDeployment = await selectFoundryDeployment(ctx, selectedResource, selectedSub.id); endpoint = selectedResource.endpoint; modelId = selectedDeployment.name; modelNameHint = resolveConfiguredModelNameHint(modelId, selectedDeployment.modelName); diff --git a/extensions/microsoft-foundry/index.test.ts b/extensions/microsoft-foundry/index.test.ts index c3271216bcb..288de2e76f5 100644 --- a/extensions/microsoft-foundry/index.test.ts +++ b/extensions/microsoft-foundry/index.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js"; import plugin from "./index.js"; import type { OpenClawConfig } from "../../src/config/types.openclaw.js"; +import { isValidTenantIdentifier } from "./onboard.js"; const execFileMock = vi.hoisted(() => vi.fn()); const execFileSyncMock = vi.hoisted(() => vi.fn()); @@ -282,4 +283,67 @@ describe("microsoft-foundry plugin", () => { expect(first?.apiKey).toBe("deduped-token"); expect(second?.apiKey).toBe("deduped-token"); }); + + it("keeps other configured Foundry models when switching the selected model", async () => { + const provider = registerProvider(); + const config: OpenClawConfig = { + auth: { + profiles: { + "microsoft-foundry:default": { + provider: "microsoft-foundry", + mode: "api_key" as const, + }, + }, + order: { + "microsoft-foundry": ["microsoft-foundry:default"], + }, + }, + models: { + providers: { + "microsoft-foundry": { + baseUrl: "https://example.services.ai.azure.com/openai/v1", + api: "openai-responses", + models: [ + { + id: "alias-one", + name: "gpt-5.4", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }, + { + id: "alias-two", + name: "gpt-4o", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }, + ], + }, + }, + }, + }; + + await provider.onModelSelected?.({ + config, + model: "microsoft-foundry/alias-one", + prompter: {} as never, + agentDir: "/tmp/test-agent", + }); + + expect(config.models?.providers?.["microsoft-foundry"]?.models.map((model) => model.id)).toEqual([ + "alias-one", + "alias-two", + ]); + }); + + it("accepts tenant domains as valid tenant identifiers", () => { + expect(isValidTenantIdentifier("contoso.onmicrosoft.com")).toBe(true); + expect(isValidTenantIdentifier("00000000-0000-0000-0000-000000000000")).toBe(true); + expect(isValidTenantIdentifier("not a tenant")).toBe(false); + }); }); diff --git a/extensions/microsoft-foundry/onboard.ts b/extensions/microsoft-foundry/onboard.ts index 178b3d2a96f..9739e7f1481 100644 --- a/extensions/microsoft-foundry/onboard.ts +++ b/extensions/microsoft-foundry/onboard.ts @@ -22,13 +22,14 @@ import { export { listSubscriptions } from "./cli.js"; -export function listFoundryResources(): FoundryResourceOption[] { +export function listFoundryResources(subscriptionId?: string): FoundryResourceOption[] { try { const accounts = JSON.parse( execAz([ "cognitiveservices", "account", "list", + ...(subscriptionId ? ["--subscription", subscriptionId] : []), "--query", "[].{id:id,name:name,kind:kind,location:location,resourceGroup:resourceGroup,endpoint:properties.endpoint,customSubdomain:properties.customSubDomainName,projects:properties.associatedProjects}", "--output", @@ -83,7 +84,10 @@ export function listFoundryResources(): FoundryResourceOption[] { } } -export function listResourceDeployments(resource: FoundryResourceOption): AzDeploymentSummary[] { +export function listResourceDeployments( + resource: FoundryResourceOption, + subscriptionId?: string, +): AzDeploymentSummary[] { try { const deployments = JSON.parse( execAz([ @@ -91,6 +95,7 @@ export function listResourceDeployments(resource: FoundryResourceOption): AzDepl "account", "deployment", "list", + ...(subscriptionId ? ["--subscription", subscriptionId] : []), "-g", resource.resourceGroup, "-n", @@ -120,7 +125,7 @@ export async function selectFoundryResource( ctx: ProviderAuthContext, selectedSub: AzAccount, ): Promise { - const resources = listFoundryResources(); + const resources = listFoundryResources(selectedSub.id); if (resources.length === 0) { throw new Error(buildCreateFoundryHint(selectedSub)); } @@ -151,8 +156,9 @@ export async function selectFoundryResource( export async function selectFoundryDeployment( ctx: ProviderAuthContext, resource: FoundryResourceOption, + subscriptionId?: string, ): Promise { - const deployments = listResourceDeployments(resource); + const deployments = listResourceDeployments(resource, subscriptionId); if (deployments.length === 0) { throw new Error( [ @@ -307,6 +313,21 @@ export function extractTenantSuggestions(rawMessage: string): Array<{ id: string return suggestions; } +export function isValidTenantIdentifier(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + const isTenantUuid = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test( + trimmed, + ); + const isTenantDomain = + /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/.test( + trimmed, + ); + return isTenantUuid || isTenantDomain; +} + export async function promptTenantId( ctx: ProviderAuthContext, params?: { @@ -340,7 +361,7 @@ export async function promptTenantId( if (!trimmed) { return params?.required ? "Tenant ID is required" : undefined; } - return /^[0-9a-fA-F-]{36}$/.test(trimmed) ? undefined : "Enter a valid tenant ID"; + return isValidTenantIdentifier(trimmed) ? undefined : "Enter a valid tenant ID or tenant domain"; }, }), ).trim(); diff --git a/extensions/microsoft-foundry/provider.ts b/extensions/microsoft-foundry/provider.ts index 95f5adf93d3..8f1fcebdb58 100644 --- a/extensions/microsoft-foundry/provider.ts +++ b/extensions/microsoft-foundry/provider.ts @@ -1,7 +1,5 @@ import type { ProviderNormalizeResolvedModelContext } from "openclaw/plugin-sdk/core"; -import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-models"; -import type { ProviderModelSelectedContext } from "../../src/plugins/types.js"; -import type { ModelProviderConfig } from "../../src/config/types.models.js"; +import type { ModelProviderConfig, ProviderPlugin } from "openclaw/plugin-sdk/provider-models"; import { apiKeyAuthMethod, entraIdAuthMethod } from "./auth.js"; import { prepareFoundryRuntimeAuth } from "./runtime.js"; import { @@ -27,7 +25,7 @@ export function buildMicrosoftFoundryProvider(): ProviderPlugin { capabilities: { providerFamily: "openai" as const, }, - onModelSelected: async (ctx: ProviderModelSelectedContext) => { + onModelSelected: async (ctx) => { const providerConfig = ctx.config.models?.providers?.[PROVIDER_ID]; if (!providerConfig || !ctx.model.startsWith(`${PROVIDER_ID}/`)) { return; @@ -37,24 +35,31 @@ export function buildMicrosoftFoundryProvider(): ProviderPlugin { const selectedModelNameHint = resolveConfiguredModelNameHint(selectedModelId, existingModel?.name); const selectedModelCompat = buildFoundryModelCompat(selectedModelId, selectedModelNameHint); const providerEndpoint = normalizeFoundryEndpoint(providerConfig.baseUrl ?? ""); + const nextModels = providerConfig.models.map((model) => + model.id === selectedModelId + ? { + ...model, + ...(selectedModelCompat ? { compat: selectedModelCompat } : {}), + } + : model, + ); + if (!nextModels.some((model) => model.id === selectedModelId)) { + nextModels.push({ + id: selectedModelId, + name: selectedModelNameHint ?? selectedModelId, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + ...(selectedModelCompat ? { compat: selectedModelCompat } : {}), + }); + } const nextProviderConfig: ModelProviderConfig = { ...providerConfig, baseUrl: buildFoundryProviderBaseUrl(providerEndpoint, selectedModelId, selectedModelNameHint), api: resolveFoundryApi(selectedModelId, selectedModelNameHint), - models: [ - { - ...(existingModel ?? { - id: selectedModelId, - name: selectedModelId, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128_000, - maxTokens: 16_384, - }), - ...(selectedModelCompat ? { compat: selectedModelCompat } : {}), - }, - ], + models: nextModels, }; const targetProfileId = resolveFoundryTargetProfileId(ctx.config, ctx.agentDir); if (targetProfileId) { diff --git a/extensions/microsoft-foundry/shared.ts b/extensions/microsoft-foundry/shared.ts index 8de4ac17bce..71cbf90b9b5 100644 --- a/extensions/microsoft-foundry/shared.ts +++ b/extensions/microsoft-foundry/shared.ts @@ -5,8 +5,7 @@ import { type ProviderAuthResult, type SecretInput, } from "openclaw/plugin-sdk/provider-auth"; -import type { ModelCompatConfig, ModelProviderConfig } from "../../src/config/types.models.js"; -import type { ProviderModelSelectedContext } from "../../src/plugins/types.js"; +import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-models"; export const PROVIDER_ID = "microsoft-foundry"; export const DEFAULT_API = "openai-completions"; @@ -70,6 +69,26 @@ export type CachedTokenEntry = { export type FoundryProviderApi = typeof DEFAULT_API | typeof DEFAULT_GPT5_API; +type FoundryModelCompat = { + maxTokensField: "max_completion_tokens" | "max_tokens"; +}; + +type FoundryAuthProfileConfig = { + provider: string; + mode: "api_key" | "oauth" | "token"; + email?: string; +}; + +type FoundryConfigShape = { + auth?: { + profiles?: Record; + order?: Record; + }; + models?: { + providers?: Record; + }; +}; + export function isGpt5FamilyName(value?: string | null): boolean { return typeof value === "string" && /^gpt-5(?:$|[-.])/i.test(value.trim()); } @@ -122,12 +141,12 @@ export function extractFoundryEndpoint(baseUrl: string): string | undefined { export function buildFoundryModelCompat( modelId: string, modelNameHint?: string | null, -): ModelCompatConfig | undefined { +): FoundryModelCompat | undefined { if (!isGpt5FamilyDeployment(modelId, modelNameHint)) { return undefined; } return { - maxTokensField: "max_completion_tokens", + maxTokensField: "max_completion_tokens" as const, }; } @@ -261,7 +280,7 @@ export function buildFoundryAuthResult(params: { } export function applyFoundryProfileBinding( - config: ProviderModelSelectedContext["config"], + config: FoundryConfigShape, profileId: string, ): void { applyAuthProfileConfig(config, { @@ -272,7 +291,7 @@ export function applyFoundryProfileBinding( } export function applyFoundryProviderConfig( - config: ProviderModelSelectedContext["config"], + config: FoundryConfigShape, providerConfig: ModelProviderConfig, ): void { config.models ??= {}; @@ -281,7 +300,7 @@ export function applyFoundryProviderConfig( } export function resolveFoundryTargetProfileId( - config: ProviderModelSelectedContext["config"], + config: FoundryConfigShape, agentDir?: string, ): string | undefined { const configuredProfiles = config.auth?.profiles ?? {}; @@ -302,11 +321,8 @@ export function resolveFoundryTargetProfileId( }); const credential = authStore.profiles[configuredProfileId]; const authMethod = credential?.type === "api_key" ? credential.metadata?.authMethod : undefined; - if (authMethod === "api-key") { - return `${PROVIDER_ID}:default`; - } - if (authMethod === "entra-id") { - return `${PROVIDER_ID}:entra`; + if (authMethod === "api-key" || authMethod === "entra-id") { + return configuredProfileId; } return configuredProfileId; } From e2f156246c327ea61db2394b5f06092152ed44e3 Mon Sep 17 00:00:00 2001 From: haxudev Date: Sat, 21 Mar 2026 09:29:10 +0800 Subject: [PATCH 11/12] Microsoft Foundry: drop unrelated Windows staging diff Restore the bundled runtime staging script to upstream so this PR stays focused on the Microsoft Foundry provider and does not carry an unrelated Windows symlink fallback change. --- scripts/stage-bundled-plugin-runtime.mjs | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/scripts/stage-bundled-plugin-runtime.mjs b/scripts/stage-bundled-plugin-runtime.mjs index 4735549e160..f38f52aa6c5 100644 --- a/scripts/stage-bundled-plugin-runtime.mjs +++ b/scripts/stage-bundled-plugin-runtime.mjs @@ -7,32 +7,13 @@ function symlinkType() { return process.platform === "win32" ? "junction" : "dir"; } -function shouldFallbackToWindowsFileLink(error, type) { - return ( - process.platform === "win32" && - type !== symlinkType() && - error && - typeof error === "object" && - "code" in error && - (error.code === "EPERM" || error.code === "EACCES") - ); -} - function relativeSymlinkTarget(sourcePath, targetPath) { const relativeTarget = path.relative(path.dirname(targetPath), sourcePath); return relativeTarget || "."; } function symlinkPath(sourcePath, targetPath, type) { - try { - fs.symlinkSync(relativeSymlinkTarget(sourcePath, targetPath), targetPath, type); - } catch (error) { - if (shouldFallbackToWindowsFileLink(error, type)) { - fs.linkSync(sourcePath, targetPath); - return; - } - throw error; - } + fs.symlinkSync(relativeSymlinkTarget(sourcePath, targetPath), targetPath, type); } function shouldWrapRuntimeJsFile(sourcePath) { From 5a1e856359e71cdd6b21acefd829db8bc2608eaa Mon Sep 17 00:00:00 2001 From: haxudev Date: Sat, 21 Mar 2026 09:49:19 +0800 Subject: [PATCH 12/12] Microsoft Foundry: configure Azure API key provider headers Write Azure-style API key provider config for Microsoft Foundry API-key auth so runtime requests use the expected api-key header without falling back to bearer auth. Add a focused extension test that locks in the generated provider config. --- extensions/microsoft-foundry/index.test.ts | 17 +++++++++++++++++ extensions/microsoft-foundry/shared.ts | 13 +++++++++++++ 2 files changed, 30 insertions(+) diff --git a/extensions/microsoft-foundry/index.test.ts b/extensions/microsoft-foundry/index.test.ts index 288de2e76f5..55ce58485e2 100644 --- a/extensions/microsoft-foundry/index.test.ts +++ b/extensions/microsoft-foundry/index.test.ts @@ -3,6 +3,7 @@ import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js import plugin from "./index.js"; import type { OpenClawConfig } from "../../src/config/types.openclaw.js"; import { isValidTenantIdentifier } from "./onboard.js"; +import { buildFoundryAuthResult } from "./shared.js"; const execFileMock = vi.hoisted(() => vi.fn()); const execFileSyncMock = vi.hoisted(() => vi.fn()); @@ -346,4 +347,20 @@ describe("microsoft-foundry plugin", () => { expect(isValidTenantIdentifier("00000000-0000-0000-0000-000000000000")).toBe(true); expect(isValidTenantIdentifier("not a tenant")).toBe(false); }); + + it("writes Azure API key header overrides for API-key auth configs", () => { + const result = buildFoundryAuthResult({ + profileId: "microsoft-foundry:default", + apiKey: "test-api-key", + endpoint: "https://example.services.ai.azure.com", + modelId: "gpt-4o", + authMethod: "api-key", + }); + + expect(result.configPatch?.models?.providers?.["microsoft-foundry"]).toMatchObject({ + apiKey: "test-api-key", + authHeader: false, + headers: { "api-key": "test-api-key" }, + }); + }); }); diff --git a/extensions/microsoft-foundry/shared.ts b/extensions/microsoft-foundry/shared.ts index 71cbf90b9b5..90b02eb9d12 100644 --- a/extensions/microsoft-foundry/shared.ts +++ b/extensions/microsoft-foundry/shared.ts @@ -166,11 +166,20 @@ export function buildFoundryProviderConfig( endpoint: string, modelId: string, modelNameHint?: string | null, + options?: { + authMethod?: "api-key" | "entra-id"; + apiKey?: SecretInput; + }, ): ModelProviderConfig { const compat = buildFoundryModelCompat(modelId, modelNameHint); + const runtimeApiKey = options?.authMethod === "api-key" ? options.apiKey : undefined; + const isApiKeyAuth = typeof runtimeApiKey === "string"; return { baseUrl: buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint), api: resolveFoundryApi(modelId, modelNameHint), + ...(isApiKeyAuth ? { apiKey: runtimeApiKey } : {}), + ...(isApiKeyAuth ? { authHeader: false } : {}), + ...(isApiKeyAuth ? { headers: { "api-key": runtimeApiKey } } : {}), models: [ { id: modelId, @@ -270,6 +279,10 @@ export function buildFoundryAuthResult(params: { params.endpoint, params.modelId, params.modelNameHint, + { + authMethod: params.authMethod, + apiKey: params.apiKey, + }, ), }, },