From a16dd967da51dd6ab21bcfc0468f298b8e765077 Mon Sep 17 00:00:00 2001 From: MetaX e|acc Date: Thu, 26 Mar 2026 14:33:14 +0800 Subject: [PATCH] feat: Add Microsoft Foundry provider with Entra ID authentication (#51973) * Microsoft Foundry: add native provider * Microsoft Foundry: tighten review fixes * Microsoft Foundry: enable by default * Microsoft Foundry: stabilize API routing --- extensions/microsoft-foundry/auth.ts | 253 +++++ extensions/microsoft-foundry/cli.ts | 191 ++++ extensions/microsoft-foundry/index.test.ts | 875 ++++++++++++++++++ extensions/microsoft-foundry/index.ts | 11 + extensions/microsoft-foundry/onboard.ts | 499 ++++++++++ .../microsoft-foundry/openclaw.plugin.json | 35 + extensions/microsoft-foundry/package.json | 12 + extensions/microsoft-foundry/provider.ts | 109 +++ extensions/microsoft-foundry/runtime.ts | 101 ++ .../microsoft-foundry/shared-runtime.ts | 15 + extensions/microsoft-foundry/shared.ts | 437 +++++++++ .../bundled-plugin-metadata.generated.ts | 49 + ...undled-provider-auth-env-vars.generated.ts | 1 + 13 files changed, 2588 insertions(+) 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/index.ts create mode 100644 extensions/microsoft-foundry/onboard.ts create mode 100644 extensions/microsoft-foundry/openclaw.plugin.json create mode 100644 extensions/microsoft-foundry/package.json 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..f92aa1ee9b1 --- /dev/null +++ b/extensions/microsoft-foundry/auth.ts @@ -0,0 +1,253 @@ +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 { getLoggedInAccount, isAzCliInstalled } from "./cli.js"; +import { + loginWithTenantFallback, + listResourceDeployments, + promptApiKeyEndpointAndModel, + promptEndpointAndModelManually, + promptTenantId, + selectFoundryDeployment, + selectFoundryResource, + listSubscriptions, + testFoundryConnection, +} from "./onboard.js"; +import { + buildFoundryAuthResult, + type FoundryProviderApi, + listConfiguredFoundryProfileIds, + PROVIDER_ID, + resolveConfiguredModelNameHint, + resolveFoundryApi, +} from "./shared.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; + } + + let endpoint: string; + let modelId: string; + let modelNameHint: string | undefined; + let api: FoundryProviderApi; + let discoveredDeployments: + | Array<{ + name: string; + modelName?: string; + api?: "openai-completions" | "openai-responses"; + }> + | 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 resourceDeployments = listResourceDeployments(selectedResource, selectedSub.id); + const selectedDeployment = await selectFoundryDeployment( + ctx, + selectedResource, + resourceDeployments, + ); + discoveredDeployments = resourceDeployments.map((deployment) => ({ + name: deployment.name, + ...(deployment.modelName ? { modelName: deployment.modelName } : {}), + api: resolveFoundryApi(deployment.name, deployment.modelName), + })); + endpoint = selectedResource.endpoint; + modelId = selectedDeployment.name; + modelNameHint = resolveConfiguredModelNameHint(modelId, selectedDeployment.modelName); + api = resolveFoundryApi(modelId, modelNameHint); + await ctx.prompter.note( + [ + `Resource: ${selectedResource.accountName}`, + `Endpoint: ${endpoint}`, + `Deployment: ${modelId}`, + selectedDeployment.modelName ? `Model: ${selectedDeployment.modelName}` : undefined, + `API: ${api === "openai-responses" ? "Responses" : "Chat Completions"}`, + ] + .filter(Boolean) + .join("\n"), + "Microsoft Foundry", + ); + } else { + ({ endpoint, modelId, modelNameHint, api } = await promptEndpointAndModelManually(ctx)); + } + } else { + ({ endpoint, modelId, modelNameHint, api } = await promptEndpointAndModelManually(ctx)); + } + + await testFoundryConnection({ + ctx, + endpoint, + modelId, + modelNameHint, + api, + subscriptionId: selectedSub?.id, + tenantId, + }); + + return buildFoundryAuthResult({ + profileId: `${PROVIDER_ID}:entra`, + apiKey: "__entra_id_dynamic__", + endpoint, + modelId, + modelNameHint, + api, + authMethod: "entra-id", + ...(selectedSub?.id ? { subscriptionId: selectedSub.id } : {}), + ...(selectedSub?.name ? { subscriptionName: selectedSub.name } : {}), + ...(tenantId ? { tenantId } : {}), + currentProviderProfileIds: listConfiguredFoundryProfileIds(ctx.config), + currentPluginsAllow: ctx.config.plugins?.allow, + ...(discoveredDeployments ? { deployments: discoveredDeployments } : {}), + 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, + api: selection.api, + authMethod: "api-key", + currentProviderProfileIds: listConfiguredFoundryProfileIds(ctx.config), + currentPluginsAllow: ctx.config.plugins?.allow, + 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..17339dd307e --- /dev/null +++ b/extensions/microsoft-foundry/cli.ts @@ -0,0 +1,191 @@ +import { execFile, execFileSync, spawn } from "node:child_process"; +import type { AzAccessToken, AzAccount } from "./shared.js"; +import { COGNITIVE_SERVICES_RESOURCE } from "./shared.js"; + +function summarizeAzErrorMessage(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + return ""; + } + const normalized = trimmed.replace(/\s+/g, " "); + if (/not recognized|enoent|spawn .* az/i.test(normalized)) { + return "Azure CLI (az) is not installed or not on PATH."; + } + if (/az login/i.test(normalized) || /please run 'az login'/i.test(normalized)) { + return "Azure CLI is not logged in. Run `az login --use-device-code`."; + } + if ( + /subscription/i.test(normalized) && + /could not be found|does not exist|no subscriptions/i.test(normalized) + ) { + return "Azure CLI could not find an accessible subscription. Check the selected subscription or tenant access."; + } + if ( + /tenant/i.test(normalized) && + /not found|invalid|doesn't exist|does not exist/i.test(normalized) + ) { + return "Azure CLI could not use that tenant. Verify the tenant ID or tenant domain and try `az login --tenant `."; + } + if (/aadsts\d+/i.test(normalized)) { + return "Azure login failed for the selected tenant. Re-run `az login --use-device-code` and confirm the tenant is correct."; + } + return normalized.slice(0, 300); +} + +function buildAzCommandError(error: Error, stderr: string, stdout: string): Error { + const details = summarizeAzErrorMessage(`${String(stderr ?? "")} ${String(stdout ?? "")}`); + return new Error(details ? `${error.message}: ${details}` : error.message); +} + +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) { + reject(buildAzCommandError(error, String(stderr ?? ""), String(stdout ?? ""))); + 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 []; + } +} + +type AccessTokenParams = { + subscriptionId?: string; + tenantId?: string; +}; + +function buildAccessTokenArgs(params?: AccessTokenParams): string[] { + 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 args; +} + +export function getAccessTokenResult(params?: AccessTokenParams): AzAccessToken { + return JSON.parse(execAz(buildAccessTokenArgs(params))) as AzAccessToken; +} + +export async function getAccessTokenResultAsync( + params?: AccessTokenParams, +): Promise { + return JSON.parse(await execAzAsync(buildAccessTokenArgs(params))) 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[] = []; + let stdoutLen = 0; + let stderrLen = 0; + const appendBoundedChunk = (chunks: string[], text: string, len: number): number => { + if (!text) { + return len; + } + chunks.push(text); + let total = len + text.length; + while (total > maxCapturedLoginOutputChars && chunks.length > 0) { + const removed = chunks.shift(); + total -= removed?.length ?? 0; + } + return total; + }; + child.stdout?.on("data", (chunk) => { + const text = String(chunk); + stdoutLen = appendBoundedChunk(stdoutChunks, text, stdoutLen); + process.stdout.write(text); + }); + child.stderr?.on("data", (chunk) => { + const text = String(chunk); + stderrLen = appendBoundedChunk(stderrChunks, text, stderrLen); + 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..05ff9af3e51 --- /dev/null +++ b/extensions/microsoft-foundry/index.test.ts @@ -0,0 +1,875 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../src/config/types.openclaw.js"; +import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js"; +import { getAccessTokenResultAsync } from "./cli.js"; +import plugin from "./index.js"; +import { buildFoundryConnectionTest, isValidTenantIdentifier } from "./onboard.js"; +import { resetFoundryRuntimeAuthCaches } from "./runtime.js"; +import { + buildFoundryAuthResult, + normalizeFoundryEndpoint, + requiresFoundryMaxCompletionTokens, + usesFoundryResponsesByDefault, +} from "./shared.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(() => { + resetFoundryRuntimeAuthCaches(); + 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("retries Entra token refresh after a failed attempt", async () => { + const provider = registerProvider(); + execFileMock + .mockImplementationOnce( + ( + _file: unknown, + _args: unknown, + _options: unknown, + callback: (error: Error | null, stdout: string, stderr: string) => void, + ) => { + callback(new Error("az failed"), "", "Please run 'az login' to setup account."); + }, + ) + .mockImplementationOnce( + ( + _file: unknown, + _args: unknown, + _options: unknown, + callback: (error: Error | null, stdout: string, stderr: string) => void, + ) => { + callback( + null, + JSON.stringify({ + accessToken: "retry-token", + expiresOn: new Date(Date.now() + 10 * 60_000).toISOString(), + }), + "", + ); + }, + ); + 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", + }; + + await expect(provider.prepareRuntimeAuth?.(runtimeContext)).rejects.toThrow( + "Azure CLI is not logged in", + ); + + await expect(provider.prepareRuntimeAuth?.(runtimeContext)).resolves.toMatchObject({ + apiKey: "retry-token", + }); + expect(execFileMock).toHaveBeenCalledTimes(2); + }); + + 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"); + }); + + it("clears failed refresh state so later concurrent retries succeed", async () => { + const provider = registerProvider(); + execFileMock + .mockImplementationOnce( + ( + _file: unknown, + _args: unknown, + _options: unknown, + callback: (error: Error | null, stdout: string, stderr: string) => void, + ) => { + setTimeout(() => { + callback(new Error("az failed"), "", "Please run 'az login' to setup account."); + }, 10); + }, + ) + .mockImplementationOnce( + ( + _file: unknown, + _args: unknown, + _options: unknown, + callback: (error: Error | null, stdout: string, stderr: string) => void, + ) => { + setTimeout(() => { + callback( + null, + JSON.stringify({ + accessToken: "recovered-token", + expiresOn: new Date(Date.now() + 10 * 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 failed = await Promise.allSettled([ + provider.prepareRuntimeAuth?.(runtimeContext), + provider.prepareRuntimeAuth?.(runtimeContext), + ]); + expect(failed.every((result) => result.status === "rejected")).toBe(true); + expect(execFileMock).toHaveBeenCalledTimes(1); + + const [first, second] = await Promise.all([ + provider.prepareRuntimeAuth?.(runtimeContext), + provider.prepareRuntimeAuth?.(runtimeContext), + ]); + expect(execFileMock).toHaveBeenCalledTimes(2); + expect(first?.apiKey).toBe("recovered-token"); + expect(second?.apiKey).toBe("recovered-token"); + }); + + it("refreshes again when a cached token is too close to expiry", 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: "soon-expiring-token", + expiresOn: new Date(Date.now() + 60_000).toISOString(), + }), + "", + ); + }, + ) + .mockImplementationOnce( + ( + _file: unknown, + _args: unknown, + _options: unknown, + callback: (error: Error | null, stdout: string, stderr: string) => void, + ) => { + callback( + null, + JSON.stringify({ + accessToken: "fresh-token", + expiresOn: new Date(Date.now() + 10 * 60_000).toISOString(), + }), + "", + ); + }, + ); + 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", + }; + + await expect(provider.prepareRuntimeAuth?.(runtimeContext)).resolves.toMatchObject({ + apiKey: "soon-expiring-token", + }); + await expect(provider.prepareRuntimeAuth?.(runtimeContext)).resolves.toMatchObject({ + apiKey: "fresh-token", + }); + expect(execFileMock).toHaveBeenCalledTimes(2); + }); + + 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); + }); + + it("defaults Azure OpenAI model families to the documented API surfaces", () => { + expect(usesFoundryResponsesByDefault("gpt-5.4")).toBe(true); + expect(usesFoundryResponsesByDefault("gpt-5.2-codex")).toBe(true); + expect(usesFoundryResponsesByDefault("o4-mini")).toBe(true); + expect(usesFoundryResponsesByDefault("MAI-DS-R1")).toBe(false); + expect(requiresFoundryMaxCompletionTokens("gpt-5.4")).toBe(true); + expect(requiresFoundryMaxCompletionTokens("o3")).toBe(true); + expect(requiresFoundryMaxCompletionTokens("gpt-4o")).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", + api: "openai-responses", + authMethod: "api-key", + }); + + expect(result.configPatch?.models?.providers?.["microsoft-foundry"]).toMatchObject({ + apiKey: "test-api-key", + authHeader: false, + headers: { "api-key": "test-api-key" }, + }); + }); + + it("uses the minimum supported response token count for GPT-5 connection tests", () => { + const testRequest = buildFoundryConnectionTest({ + endpoint: "https://example.services.ai.azure.com", + modelId: "gpt-5.4", + modelNameHint: "gpt-5.4", + api: "openai-responses", + }); + + expect(testRequest.url).toContain("/responses"); + expect(testRequest.body).toMatchObject({ + model: "gpt-5.4", + max_output_tokens: 16, + }); + }); + + it("marks Foundry responses models to omit explicit store=false payloads", () => { + const result = buildFoundryAuthResult({ + profileId: "microsoft-foundry:entra", + apiKey: "__entra_id_dynamic__", + endpoint: "https://example.services.ai.azure.com", + modelId: "gpt-5.2-codex", + modelNameHint: "gpt-5.2-codex", + api: "openai-responses", + authMethod: "entra-id", + }); + + const provider = result.configPatch?.models?.providers?.["microsoft-foundry"]; + expect(provider?.models[0]?.compat).toMatchObject({ + supportsStore: false, + maxTokensField: "max_completion_tokens", + }); + }); + + it("keeps persisted response-mode routing for custom deployment aliases", async () => { + const provider = registerProvider(); + const config: OpenClawConfig = { + auth: { + profiles: { + "microsoft-foundry:entra": { + provider: "microsoft-foundry", + mode: "api_key" as const, + }, + }, + order: { + "microsoft-foundry": ["microsoft-foundry:entra"], + }, + }, + models: { + providers: { + "microsoft-foundry": { + baseUrl: "https://example.services.ai.azure.com/openai/v1", + api: "openai-responses", + models: [ + { + id: "prod-primary", + name: "production alias", + api: "openai-responses", + 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/prod-primary", + prompter: {} as never, + agentDir: "/tmp/test-agent", + }); + + expect(config.models?.providers?.["microsoft-foundry"]?.api).toBe("openai-responses"); + expect(config.models?.providers?.["microsoft-foundry"]?.baseUrl).toBe( + "https://example.services.ai.azure.com/openai/v1", + ); + expect(config.models?.providers?.["microsoft-foundry"]?.models[0]?.api).toBe( + "openai-responses", + ); + }); + + it("normalizes pasted Azure chat completion request URLs to the resource endpoint", () => { + expect( + normalizeFoundryEndpoint( + "https://example.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2024-12-01-preview", + ), + ).toBe("https://example.openai.azure.com"); + }); + + it("preserves project-scoped endpoint prefixes when extracting the Foundry endpoint", 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: {} }); + + const prepared = await provider.prepareRuntimeAuth?.({ + provider: "microsoft-foundry", + modelId: "deployment-gpt5", + model: { + provider: "microsoft-foundry", + id: "deployment-gpt5", + name: "gpt-5.4", + api: "openai-responses", + baseUrl: "https://example.services.ai.azure.com/api/projects/demo/openai/v1/responses", + 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/api/projects/demo/openai/v1", + ); + }); + + it("normalizes pasted Foundry responses request URLs to the resource endpoint", () => { + expect( + normalizeFoundryEndpoint( + "https://example.services.ai.azure.com/openai/v1/responses?api-version=preview", + ), + ).toBe("https://example.services.ai.azure.com"); + }); + + it("includes api-version for non GPT-5 chat completion connection tests", () => { + const testRequest = buildFoundryConnectionTest({ + endpoint: "https://example.services.ai.azure.com", + modelId: "FW-GLM-5", + modelNameHint: "FW-GLM-5", + api: "openai-completions", + }); + + expect(testRequest.url).toContain("/chat/completions"); + expect(testRequest.body).toMatchObject({ + model: "FW-GLM-5", + max_tokens: 1, + }); + }); + + it("returns actionable Azure CLI login errors", async () => { + execFileMock.mockImplementationOnce( + ( + _file: unknown, + _args: unknown, + _options: unknown, + callback: (error: Error | null, stdout: string, stderr: string) => void, + ) => { + callback(new Error("az failed"), "", "Please run 'az login' to setup account."); + }, + ); + + await expect(getAccessTokenResultAsync()).rejects.toThrow("Azure CLI is not logged in"); + }); + + it("keeps Azure API key header overrides when API-key auth uses a secret ref", () => { + const secretRef = { + source: "env" as const, + provider: "default", + id: "AZURE_OPENAI_API_KEY", + }; + const result = buildFoundryAuthResult({ + profileId: "microsoft-foundry:default", + apiKey: secretRef, + endpoint: "https://example.services.ai.azure.com", + modelId: "gpt-4o", + api: "openai-responses", + authMethod: "api-key", + }); + + expect(result.configPatch?.models?.providers?.["microsoft-foundry"]).toMatchObject({ + apiKey: secretRef, + authHeader: false, + headers: { "api-key": secretRef }, + }); + }); + + it("moves the selected Foundry auth profile to the front of auth.order", () => { + const result = buildFoundryAuthResult({ + profileId: "microsoft-foundry:entra", + apiKey: "__entra_id_dynamic__", + endpoint: "https://example.services.ai.azure.com", + modelId: "gpt-5.4", + api: "openai-responses", + authMethod: "entra-id", + currentProviderProfileIds: ["microsoft-foundry:default", "microsoft-foundry:entra"], + }); + + expect(result.configPatch?.auth?.order?.["microsoft-foundry"]).toEqual([ + "microsoft-foundry:entra", + "microsoft-foundry:default", + ]); + }); + + it("persists discovered deployments alongside the selected default model", () => { + const result = buildFoundryAuthResult({ + profileId: "microsoft-foundry:entra", + apiKey: "__entra_id_dynamic__", + endpoint: "https://example.services.ai.azure.com", + modelId: "deployment-gpt5", + modelNameHint: "gpt-5.4", + api: "openai-responses", + authMethod: "entra-id", + deployments: [ + { name: "deployment-gpt5", modelName: "gpt-5.4", api: "openai-responses" }, + { name: "deployment-gpt4o", modelName: "gpt-4o", api: "openai-responses" }, + ], + }); + + const provider = result.configPatch?.models?.providers?.["microsoft-foundry"]; + expect(provider?.models.map((model) => model.id)).toEqual([ + "deployment-gpt5", + "deployment-gpt4o", + ]); + expect(result.defaultModel).toBe("microsoft-foundry/deployment-gpt5"); + }); +}); diff --git a/extensions/microsoft-foundry/index.ts b/extensions/microsoft-foundry/index.ts new file mode 100644 index 00000000000..db450301446 --- /dev/null +++ b/extensions/microsoft-foundry/index.ts @@ -0,0 +1,11 @@ +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { buildMicrosoftFoundryProvider } from "./provider.js"; + +export default definePluginEntry({ + id: "microsoft-foundry", + name: "Microsoft Foundry Provider", + description: "Microsoft Foundry provider with Entra ID and API key auth", + register(api) { + api.registerProvider(buildMicrosoftFoundryProvider()); + }, +}); diff --git a/extensions/microsoft-foundry/onboard.ts b/extensions/microsoft-foundry/onboard.ts new file mode 100644 index 00000000000..e62868c2f45 --- /dev/null +++ b/extensions/microsoft-foundry/onboard.ts @@ -0,0 +1,499 @@ +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 FoundryProviderApi, + type FoundryResourceOption, + type FoundrySelection, + buildFoundryProviderBaseUrl, + extractFoundryEndpoint, + requiresFoundryMaxCompletionTokens, + DEFAULT_API, + DEFAULT_GPT5_API, + usesFoundryResponsesByDefault, +} from "./shared.js"; + +export { listSubscriptions } from "./cli.js"; + +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", + "json", + ]), + ) as AzCognitiveAccount[]; + const resources: FoundryResourceOption[] = []; + for (const account of accounts) { + if (!account.resourceGroup) { + continue; + } + if (account.kind === "OpenAI") { + const endpoint = extractFoundryEndpoint(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, + subscriptionId?: string, +): AzDeploymentSummary[] { + try { + const deployments = JSON.parse( + execAz([ + "cognitiveservices", + "account", + "deployment", + "list", + ...(subscriptionId ? ["--subscription", subscriptionId] : []), + "-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(selectedSub.id); + 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, + deployments: AzDeploymentSummary[], +): Promise { + 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]! + ); +} + +async function promptFoundryApi( + ctx: ProviderAuthContext, + initialApi: FoundryProviderApi, +): Promise { + return await ctx.prompter.select({ + message: "Select request API", + options: [ + { + value: DEFAULT_GPT5_API, + label: "Responses API", + hint: "Recommended for Azure OpenAI GPT, o-series, and Codex deployments", + }, + { + value: "openai-completions", + label: "Chat Completions API", + hint: "Use for Foundry models that only expose chat/completions semantics", + }, + ], + initialValue: initialApi, + }); +} + +type ManualFoundryModelFamilyChoice = "reasoning-family" | "other-chat"; + +async function promptFoundryModelFamily( + ctx: ProviderAuthContext, +): Promise { + return await ctx.prompter.select({ + message: "Model family", + options: [ + { + value: "reasoning-family", + label: "GPT-5 series / o-series / Codex", + hint: "Use for Azure OpenAI reasoning and Codex deployments", + }, + { + value: "other-chat", + label: "Other chat model", + hint: "Use for other chat/completions style Foundry models", + }, + ], + initialValue: "reasoning-family", + }); +} + +async function promptEndpointAndModelBase( + ctx: ProviderAuthContext, + options?: { + endpointInitialValue?: string; + modelInitialValue?: string; + }, +): 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", + ...(options?.endpointInitialValue ? { initialValue: options.endpointInitialValue } : {}), + 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", + ...(options?.modelInitialValue ? { initialValue: options.modelInitialValue } : {}), + placeholder: "gpt-4o", + validate: (v) => { + const val = String(v ?? "").trim(); + if (!val) return "Model ID is required"; + return undefined; + }, + }), + ).trim(); + const familyChoice = await promptFoundryModelFamily(ctx); + const resolvedModelName = + familyChoice === "reasoning-family" + ? usesFoundryResponsesByDefault(modelId) || requiresFoundryMaxCompletionTokens(modelId) + ? modelId + : "gpt-5" + : undefined; + const api = await promptFoundryApi( + ctx, + familyChoice === "reasoning-family" ? DEFAULT_GPT5_API : DEFAULT_API, + ); + return { + endpoint, + modelId, + ...(resolvedModelName ? { modelNameHint: resolvedModelName } : {}), + api, + }; +} + +export async function promptEndpointAndModelManually( + ctx: ProviderAuthContext, +): Promise { + return promptEndpointAndModelBase(ctx); +} + +export async function promptApiKeyEndpointAndModel( + ctx: ProviderAuthContext, +): Promise { + return promptEndpointAndModelBase(ctx, { + endpointInitialValue: process.env.AZURE_OPENAI_ENDPOINT, + modelInitialValue: "gpt-4o", + }); +} + +export function buildFoundryConnectionTest(params: { + endpoint: string; + modelId: string; + modelNameHint?: string | null; + api: FoundryProviderApi; +}): { url: string; body: Record } { + const baseUrl = buildFoundryProviderBaseUrl( + params.endpoint, + params.modelId, + params.modelNameHint, + params.api, + ); + if (params.api === DEFAULT_GPT5_API) { + return { + url: `${baseUrl}/responses`, + body: { + model: params.modelId, + input: "hi", + max_output_tokens: 16, + }, + }; + } + return { + url: `${baseUrl}/chat/completions`, + body: { + model: params.modelId, + 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 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?: { + 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 isValidTenantIdentifier(trimmed) + ? undefined + : "Enter a valid tenant ID or tenant domain"; + }, + }), + ).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; + api: FoundryProviderApi; + 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, + api: params.api, + }); + const signal = + typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(15_000) : undefined; + const res = await fetch(testRequest.url, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(testRequest.body), + ...(signal ? { signal } : {}), + }); + 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/openclaw.plugin.json b/extensions/microsoft-foundry/openclaw.plugin.json new file mode 100644 index 00000000000..d121cad49a3 --- /dev/null +++ b/extensions/microsoft-foundry/openclaw.plugin.json @@ -0,0 +1,35 @@ +{ + "id": "microsoft-foundry", + "enabledByDefault": true, + "providers": ["microsoft-foundry"], + "providerAuthEnvVars": { + "microsoft-foundry": ["AZURE_OPENAI_API_KEY"] + }, + "providerAuthChoices": [ + { + "provider": "microsoft-foundry", + "method": "entra-id", + "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" + }, + { + "provider": "microsoft-foundry", + "method": "api-key", + "choiceId": "microsoft-foundry-apikey", + "choiceLabel": "Microsoft Foundry (API key)", + "choiceHint": "Use an Azure OpenAI API key directly", + "groupId": "microsoft-foundry", + "groupLabel": "Microsoft Foundry", + "groupHint": "Entra ID + API key" + } + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/microsoft-foundry/package.json b/extensions/microsoft-foundry/package.json new file mode 100644 index 00000000000..a69f5eada6a --- /dev/null +++ b/extensions/microsoft-foundry/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/microsoft-foundry", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Microsoft Foundry provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/microsoft-foundry/provider.ts b/extensions/microsoft-foundry/provider.ts new file mode 100644 index 00000000000..481e6cef390 --- /dev/null +++ b/extensions/microsoft-foundry/provider.ts @@ -0,0 +1,109 @@ +import type { ProviderNormalizeResolvedModelContext } from "openclaw/plugin-sdk/core"; +import type { ModelProviderConfig, ProviderPlugin } from "openclaw/plugin-sdk/provider-models"; +import { apiKeyAuthMethod, entraIdAuthMethod } from "./auth.js"; +import { prepareFoundryRuntimeAuth } from "./runtime.js"; +import { + PROVIDER_ID, + applyFoundryProfileBinding, + applyFoundryProviderConfig, + buildFoundryModelCompat, + buildFoundryProviderBaseUrl, + extractFoundryEndpoint, + isFoundryProviderApi, + normalizeFoundryEndpoint, + resolveConfiguredModelNameHint, + resolveFoundryApi, + resolveFoundryTargetProfileId, +} from "./shared.js"; + +export function buildMicrosoftFoundryProvider(): ProviderPlugin { + return { + id: PROVIDER_ID, + label: "Microsoft Foundry", + docsPath: "/providers/models", + envVars: ["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT"], + auth: [entraIdAuthMethod, apiKeyAuthMethod], + capabilities: { + providerFamily: "openai" as const, + }, + onModelSelected: async (ctx) => { + 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 providerEndpoint = normalizeFoundryEndpoint(providerConfig.baseUrl ?? ""); + // Prefer the persisted per-model API choice from onboarding/discovery so arbitrary + // deployment aliases (for example prod-primary) do not fall back to name heuristics. + const selectedModelApi = isFoundryProviderApi(existingModel?.api) + ? existingModel.api + : providerConfig.api; + const selectedModelCompat = buildFoundryModelCompat( + selectedModelId, + selectedModelNameHint, + selectedModelApi, + ); + const nextModels = providerConfig.models.map((model) => + model.id === selectedModelId + ? { + ...model, + api: resolveFoundryApi(selectedModelId, selectedModelNameHint, selectedModelApi), + ...(selectedModelCompat ? { compat: selectedModelCompat } : {}), + } + : model, + ); + if (!nextModels.some((model) => model.id === selectedModelId)) { + nextModels.push({ + id: selectedModelId, + name: selectedModelNameHint ?? selectedModelId, + api: resolveFoundryApi(selectedModelId, selectedModelNameHint, selectedModelApi), + 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, + selectedModelApi, + ), + api: resolveFoundryApi(selectedModelId, selectedModelNameHint, selectedModelApi), + models: nextModels, + }; + const targetProfileId = resolveFoundryTargetProfileId(ctx.config); + 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 configuredApi = isFoundryProviderApi(model.api) ? model.api : undefined; + const compat = buildFoundryModelCompat(modelId, modelNameHint, configuredApi); + return { + ...model, + api: resolveFoundryApi(modelId, modelNameHint, configuredApi), + baseUrl: buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint, configuredApi), + ...(compat ? { compat } : {}), + }; + }, + prepareRuntimeAuth: prepareFoundryRuntimeAuth, + }; +} diff --git a/extensions/microsoft-foundry/runtime.ts b/extensions/microsoft-foundry/runtime.ts new file mode 100644 index 00000000000..88f0442af83 --- /dev/null +++ b/extensions/microsoft-foundry/runtime.ts @@ -0,0 +1,101 @@ +import type { ProviderPrepareRuntimeAuthContext } from "openclaw/plugin-sdk/core"; +import { ensureAuthProfileStore } from "openclaw/plugin-sdk/provider-auth"; +import { getAccessTokenResultAsync } from "./cli.js"; +import { + type CachedTokenEntry, + TOKEN_REFRESH_MARGIN_MS, + buildFoundryProviderBaseUrl, + extractFoundryEndpoint, + getFoundryTokenCacheKey, + isFoundryProviderApi, + resolveConfiguredModelNameHint, +} from "./shared-runtime.js"; + +const cachedTokens = new Map(); +const refreshPromises = new Map>(); + +export function resetFoundryRuntimeAuthCaches(): void { + cachedTokens.clear(); + refreshPromises.clear(); +} + +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 configuredApi = + typeof metadata?.api === "string" && isFoundryProviderApi(metadata.api) + ? metadata.api + : isFoundryProviderApi(ctx.model.api) + ? ctx.model.api + : undefined; + 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, configuredApi) + : 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) { + const details = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to refresh Azure Entra ID token via az CLI: ${details}`); + } +} diff --git a/extensions/microsoft-foundry/shared-runtime.ts b/extensions/microsoft-foundry/shared-runtime.ts new file mode 100644 index 00000000000..eb32541388e --- /dev/null +++ b/extensions/microsoft-foundry/shared-runtime.ts @@ -0,0 +1,15 @@ +export { + TOKEN_REFRESH_MARGIN_MS, + buildFoundryProviderBaseUrl, + extractFoundryEndpoint, + isFoundryProviderApi, + 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..31058b50dcb --- /dev/null +++ b/extensions/microsoft-foundry/shared.ts @@ -0,0 +1,437 @@ +import { + applyAuthProfileConfig, + buildApiKeyCredential, + type ProviderAuthResult, + type SecretInput, +} from "openclaw/plugin-sdk/provider-auth"; +import type { ModelApi, ModelProviderConfig } from "openclaw/plugin-sdk/provider-models"; + +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; + api: FoundryProviderApi; +}; + +export type CachedTokenEntry = { + token: string; + expiresAt: number; +}; + +export type FoundryProviderApi = typeof DEFAULT_API | typeof DEFAULT_GPT5_API; + +export type FoundryDeploymentConfigInput = { + name: string; + modelName?: string; + api?: FoundryProviderApi; +}; + +type FoundryModelCompat = { + supportsStore?: boolean; + 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 normalizeFoundryModelName(value?: string | null): string | undefined { + const trimmed = typeof value === "string" ? value.trim().toLowerCase() : ""; + return trimmed || undefined; +} + +export function usesFoundryResponsesByDefault(value?: string | null): boolean { + const normalized = normalizeFoundryModelName(value); + if (!normalized) { + return false; + } + return ( + normalized.startsWith("gpt-") || + normalized.startsWith("o1") || + normalized.startsWith("o3") || + normalized.startsWith("o4") || + normalized === "computer-use-preview" + ); +} + +export function requiresFoundryMaxCompletionTokens(value?: string | null): boolean { + const normalized = normalizeFoundryModelName(value); + if (!normalized) { + return false; + } + return ( + normalized.startsWith("gpt-5") || + normalized.startsWith("o1") || + normalized.startsWith("o3") || + normalized.startsWith("o4") + ); +} + +export function isFoundryProviderApi(value?: string | null): value is FoundryProviderApi { + return value === DEFAULT_API || value === DEFAULT_GPT5_API; +} + +export function normalizeFoundryEndpoint(endpoint: string): string { + const trimmed = endpoint.trim(); + if (!trimmed) { + return trimmed; + } + try { + const parsed = new URL(trimmed); + parsed.search = ""; + parsed.hash = ""; + const normalizedPath = parsed.pathname.replace(/\/openai(?:$|\/).*/i, "").replace(/\/+$/, ""); + return `${parsed.origin}${normalizedPath && normalizedPath !== "/" ? normalizedPath : ""}`; + } catch { + const withoutQuery = trimmed.replace(/[?#].*$/, "").replace(/\/+$/, ""); + return withoutQuery.replace(/\/openai(?:$|\/).*/i, ""); + } +} + +export function buildFoundryV1BaseUrl(endpoint: string): string { + const base = normalizeFoundryEndpoint(endpoint); + return base.endsWith("/openai/v1") ? base : `${base}/openai/v1`; +} + +export function resolveFoundryApi( + modelId: string, + modelNameHint?: string | null, + configuredApi?: ModelApi | string | null, +): FoundryProviderApi { + if (isFoundryProviderApi(configuredApi)) { + return configuredApi; + } + const configuredModelName = resolveConfiguredModelNameHint(modelId, modelNameHint); + return usesFoundryResponsesByDefault(configuredModelName) ? DEFAULT_GPT5_API : DEFAULT_API; +} + +export function buildFoundryProviderBaseUrl( + endpoint: string, + modelId: string, + modelNameHint?: string | null, + configuredApi?: ModelApi | string | null, +): string { + return buildFoundryV1BaseUrl(endpoint); +} + +export function extractFoundryEndpoint(baseUrl: string | null | undefined): string | undefined { + if (!baseUrl) { + return undefined; + } + try { + return normalizeFoundryEndpoint(baseUrl); + } catch { + return undefined; + } +} + +export function buildFoundryModelCompat( + modelId: string, + modelNameHint?: string | null, + configuredApi?: ModelApi | string | null, +): FoundryModelCompat | undefined { + const resolvedApi = resolveFoundryApi(modelId, modelNameHint, configuredApi); + const configuredModelName = resolveConfiguredModelNameHint(modelId, modelNameHint); + const needsMaxCompletionTokens = requiresFoundryMaxCompletionTokens(configuredModelName); + if (resolvedApi !== DEFAULT_GPT5_API && !needsMaxCompletionTokens) { + return undefined; + } + return { + ...(resolvedApi === DEFAULT_GPT5_API ? { supportsStore: false } : {}), + maxTokensField: needsMaxCompletionTokens ? "max_completion_tokens" : "max_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, + options?: { + api?: FoundryProviderApi; + authMethod?: "api-key" | "entra-id"; + apiKey?: SecretInput; + deployments?: FoundryDeploymentConfigInput[]; + }, +): ModelProviderConfig { + const runtimeApiKey = options?.authMethod === "api-key" ? options.apiKey : undefined; + const isApiKeyAuth = options?.authMethod === "api-key"; + const deployments = options?.deployments?.length + ? options.deployments + : [{ name: modelId, modelName: modelNameHint ?? undefined }]; + const resolvedApi = resolveFoundryApi(modelId, modelNameHint, options?.api); + return { + baseUrl: buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint, resolvedApi), + api: resolvedApi, + ...(isApiKeyAuth + ? { + authHeader: false, + ...(runtimeApiKey !== undefined + ? { apiKey: runtimeApiKey, headers: { "api-key": runtimeApiKey } } + : {}), + } + : {}), + models: deployments.map((deployment) => { + const configuredName = resolveConfiguredModelNameHint(deployment.name, deployment.modelName); + const api = resolveFoundryApi(deployment.name, configuredName, deployment.api); + const compat = buildFoundryModelCompat(deployment.name, configuredName, api); + return { + id: deployment.name, + name: configuredName ?? deployment.name, + api, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + ...(compat ? { compat } : {}), + }; + }), + }; +} + +function buildFoundryCredentialMetadata(params: { + authMethod: "api-key" | "entra-id"; + endpoint: string; + modelId: string; + modelNameHint?: string | null; + api?: FoundryProviderApi; + subscriptionId?: string; + subscriptionName?: string; + tenantId?: string; +}): Record { + const resolvedApi = resolveFoundryApi(params.modelId, params.modelNameHint, params.api); + const metadata: Record = { + authMethod: params.authMethod, + endpoint: params.endpoint, + modelId: params.modelId, + api: resolvedApi, + }; + 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; +} + +/** + * Build the plugins.allow patch so the provider is allowlisted when the + * config already gates plugins via a non-empty allow array. Returns an + * empty object when no patch is needed (allowlist absent / already listed). + */ +function buildPluginsAllowPatch( + currentAllow: string[] | undefined, +): { plugins: { allow: string[] } } | Record { + if (!Array.isArray(currentAllow) || currentAllow.length === 0) { + return {}; + } + if (currentAllow.includes(PROVIDER_ID)) { + return {}; + } + return { plugins: { allow: [...currentAllow, PROVIDER_ID] } }; +} + +function buildFoundryAuthOrderPatch(params: { + profileId: string; + currentProviderProfileIds?: string[]; +}): { auth: { order: Record } } { + const nextOrder = [ + params.profileId, + ...(params.currentProviderProfileIds ?? []).filter( + (profileId) => profileId !== params.profileId, + ), + ]; + return { + auth: { + order: { + [PROVIDER_ID]: nextOrder, + }, + }, + }; +} + +export function listConfiguredFoundryProfileIds(config: FoundryConfigShape): string[] { + return Object.entries(config.auth?.profiles ?? {}) + .filter(([, profile]) => profile.provider === PROVIDER_ID) + .map(([profileId]) => profileId); +} + +export function buildFoundryAuthResult(params: { + profileId: string; + apiKey: SecretInput; + secretInputMode?: "plaintext" | "ref"; + endpoint: string; + modelId: string; + modelNameHint?: string | null; + api: FoundryProviderApi; + authMethod: "api-key" | "entra-id"; + subscriptionId?: string; + subscriptionName?: string; + tenantId?: string; + notes?: string[]; + /** Current plugins.allow so the provider can self-allowlist during onboard. */ + currentPluginsAllow?: string[]; + currentProviderProfileIds?: string[]; + deployments?: FoundryDeploymentConfigInput[]; +}): 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, + api: params.api, + subscriptionId: params.subscriptionId, + subscriptionName: params.subscriptionName, + tenantId: params.tenantId, + }), + params.secretInputMode ? { secretInputMode: params.secretInputMode } : undefined, + ), + }, + ], + configPatch: { + ...buildFoundryAuthOrderPatch({ + profileId: params.profileId, + currentProviderProfileIds: params.currentProviderProfileIds, + }), + models: { + providers: { + [PROVIDER_ID]: buildFoundryProviderConfig( + params.endpoint, + params.modelId, + params.modelNameHint, + { + api: params.api, + authMethod: params.authMethod, + apiKey: params.apiKey, + deployments: params.deployments, + }, + ), + }, + }, + ...buildPluginsAllowPatch(params.currentPluginsAllow), + }, + defaultModel: `${PROVIDER_ID}/${params.modelId}`, + notes: params.notes, + }; +} + +export function applyFoundryProfileBinding(config: FoundryConfigShape, profileId: string): void { + const next = applyAuthProfileConfig(config, { + profileId, + provider: PROVIDER_ID, + mode: "api_key", + }); + config.auth = next.auth; +} + +export function applyFoundryProviderConfig( + config: FoundryConfigShape, + providerConfig: ModelProviderConfig, +): void { + config.models ??= {}; + config.models.providers ??= {}; + config.models.providers[PROVIDER_ID] = providerConfig; +} + +export function resolveFoundryTargetProfileId(config: FoundryConfigShape): 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; + } + // Prefer the explicitly ordered profile; fall back to the sole entry when there is exactly one. + return ( + config.auth?.order?.[PROVIDER_ID]?.find((profileId) => profileId.trim().length > 0) ?? + (configuredProfileEntries.length === 1 ? configuredProfileEntries[0]?.[0] : undefined) + ); +} diff --git a/src/plugins/bundled-plugin-metadata.generated.ts b/src/plugins/bundled-plugin-metadata.generated.ts index 4eadba53810..8b38bd799a5 100644 --- a/src/plugins/bundled-plugin-metadata.generated.ts +++ b/src/plugins/bundled-plugin-metadata.generated.ts @@ -1784,6 +1784,55 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ }, }, }, + { + dirName: "microsoft-foundry", + idHint: "microsoft-foundry", + source: { + source: "./index.ts", + built: "index.js", + }, + packageName: "@openclaw/microsoft-foundry", + packageVersion: "2026.3.14", + packageDescription: "OpenClaw Microsoft Foundry provider plugin", + packageManifest: { + extensions: ["./index.ts"], + }, + manifest: { + id: "microsoft-foundry", + configSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + enabledByDefault: true, + providers: ["microsoft-foundry"], + providerAuthEnvVars: { + "microsoft-foundry": ["AZURE_OPENAI_API_KEY"], + }, + providerAuthChoices: [ + { + provider: "microsoft-foundry", + method: "entra-id", + 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", + }, + { + provider: "microsoft-foundry", + method: "api-key", + choiceId: "microsoft-foundry-apikey", + choiceLabel: "Microsoft Foundry (API key)", + choiceHint: "Use an Azure OpenAI API key directly", + groupId: "microsoft-foundry", + groupLabel: "Microsoft Foundry", + groupHint: "Entra ID + API key", + }, + ], + }, + }, { dirName: "minimax", idHint: "minimax", diff --git a/src/plugins/bundled-provider-auth-env-vars.generated.ts b/src/plugins/bundled-provider-auth-env-vars.generated.ts index 611f11121c6..101c97659d8 100644 --- a/src/plugins/bundled-provider-auth-env-vars.generated.ts +++ b/src/plugins/bundled-provider-auth-env-vars.generated.ts @@ -16,6 +16,7 @@ export const BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES = { kilocode: ["KILOCODE_API_KEY"], kimi: ["KIMI_API_KEY", "KIMICODE_API_KEY"], "kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"], + "microsoft-foundry": ["AZURE_OPENAI_API_KEY"], minimax: ["MINIMAX_API_KEY"], "minimax-portal": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"], mistral: ["MISTRAL_API_KEY"],