diff --git a/CHANGELOG.md b/CHANGELOG.md index 87ca45239ee..edc7c5a323a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,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. - Plugins/Xiaomi: switch the bundled Xiaomi provider to the `/v1` OpenAI-compatible endpoint and add MiMo V2 Pro plus MiMo V2 Omni to the built-in catalog. (#49214) thanks @DJjjjhao. - Android/Talk: move Talk speech synthesis behind gateway `talk.speak`, keep Talk secrets on the gateway, and switch Android playback to final-response audio instead of device-local ElevenLabs streaming. (#50849) - Plugins/Matrix: add `allowBots` room policy so configured Matrix bot accounts can talk to each other, with optional mention-only gating. Thanks @gumadeiras. diff --git a/extensions/microsoft-foundry/auth.ts b/extensions/microsoft-foundry/auth.ts new file mode 100644 index 00000000000..3c6d3e1c0ad --- /dev/null +++ b/extensions/microsoft-foundry/auth.ts @@ -0,0 +1,218 @@ +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 { + 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; + } + + 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, selectedSub.id); + 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..55ce58485e2 --- /dev/null +++ b/extensions/microsoft-foundry/index.test.ts @@ -0,0 +1,366 @@ +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"; +import { buildFoundryAuthResult } 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(() => { + 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"); + }); + + 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("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/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..9739e7f1481 --- /dev/null +++ b/extensions/microsoft-foundry/onboard.ts @@ -0,0 +1,451 @@ +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(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 = 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, + 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, + subscriptionId?: string, +): Promise { + const deployments = listResourceDeployments(resource, subscriptionId); + 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 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; + 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/openclaw.plugin.json b/extensions/microsoft-foundry/openclaw.plugin.json new file mode 100644 index 00000000000..772b5bdefb9 --- /dev/null +++ b/extensions/microsoft-foundry/openclaw.plugin.json @@ -0,0 +1,38 @@ +{ + "id": "microsoft-foundry", + "providers": ["microsoft-foundry"], + "providerAuthEnvVars": { + "microsoft-foundry": ["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT"] + }, + "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", + "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/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..8f1fcebdb58 --- /dev/null +++ b/extensions/microsoft-foundry/provider.ts @@ -0,0 +1,86 @@ +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, + 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) => { + 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 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: nextModels, + }; + 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..90b02eb9d12 --- /dev/null +++ b/extensions/microsoft-foundry/shared.ts @@ -0,0 +1,341 @@ +import { + applyAuthProfileConfig, + buildApiKeyCredential, + ensureAuthProfileStore, + type ProviderAuthResult, + type SecretInput, +} from "openclaw/plugin-sdk/provider-auth"; +import type { 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; +}; + +export type CachedTokenEntry = { + token: string; + expiresAt: number; +}; + +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()); +} + +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, +): FoundryModelCompat | undefined { + if (!isGpt5FamilyDeployment(modelId, modelNameHint)) { + return undefined; + } + return { + maxTokensField: "max_completion_tokens" as const, + }; +} + +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?: { + 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, + 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, + { + authMethod: params.authMethod, + apiKey: params.apiKey, + }, + ), + }, + }, + }, + defaultModel: `${PROVIDER_ID}/${params.modelId}`, + notes: params.notes, + }; +} + +export function applyFoundryProfileBinding( + config: FoundryConfigShape, + profileId: string, +): void { + applyAuthProfileConfig(config, { + profileId, + provider: PROVIDER_ID, + mode: "api_key", + }); +} + +export function applyFoundryProviderConfig( + config: FoundryConfigShape, + providerConfig: ModelProviderConfig, +): void { + config.models ??= {}; + config.models.providers ??= {}; + config.models.providers[PROVIDER_ID] = providerConfig; +} + +export function resolveFoundryTargetProfileId( + config: FoundryConfigShape, + 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" || authMethod === "entra-id") { + return configuredProfileId; + } + return configuredProfileId; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f438d0a2e3..2ce38a82f40 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -245,6 +245,8 @@ importers: extensions/anthropic: {} + extensions/microsoft-foundry: {} + extensions/bluebubbles: dependencies: zod: