From b7876c960956f78592b7c93ea4f758675d4f4ef6 Mon Sep 17 00:00:00 2001 From: haxudev Date: Thu, 19 Mar 2026 22:27:25 +0800 Subject: [PATCH] Microsoft Foundry: split provider modules and harden runtime auth Split the provider into focused auth, onboarding, CLI, runtime, and shared modules so the Entra ID flow is easier to review and maintain. Add Foundry-specific tests, preserve Azure CLI error details, move token refresh off the synchronous request path, and dedupe concurrent Entra token refreshes so onboarding and GPT-5 runtime behavior stay reliable. --- extensions/microsoft-foundry/auth.ts | 222 ++++ extensions/microsoft-foundry/cli.ts | 162 +++ extensions/microsoft-foundry/index.test.ts | 285 ++++ extensions/microsoft-foundry/index.ts | 1154 +---------------- extensions/microsoft-foundry/onboard.ts | 430 ++++++ extensions/microsoft-foundry/provider.ts | 81 ++ extensions/microsoft-foundry/runtime.ts | 85 ++ .../microsoft-foundry/shared-runtime.ts | 14 + extensions/microsoft-foundry/shared.ts | 312 +++++ 9 files changed, 1595 insertions(+), 1150 deletions(-) create mode 100644 extensions/microsoft-foundry/auth.ts create mode 100644 extensions/microsoft-foundry/cli.ts create mode 100644 extensions/microsoft-foundry/index.test.ts create mode 100644 extensions/microsoft-foundry/onboard.ts create mode 100644 extensions/microsoft-foundry/provider.ts create mode 100644 extensions/microsoft-foundry/runtime.ts create mode 100644 extensions/microsoft-foundry/shared-runtime.ts create mode 100644 extensions/microsoft-foundry/shared.ts diff --git a/extensions/microsoft-foundry/auth.ts b/extensions/microsoft-foundry/auth.ts new file mode 100644 index 00000000000..8091c266803 --- /dev/null +++ b/extensions/microsoft-foundry/auth.ts @@ -0,0 +1,222 @@ +import type { + ProviderAuthContext, + ProviderAuthMethod, + ProviderAuthResult, +} from "openclaw/plugin-sdk/core"; +import { + ensureApiKeyFromOptionEnvOrPrompt, + ensureAuthProfileStore, + normalizeApiKeyInput, + normalizeOptionalSecretInput, + type SecretInput, + validateApiKeyInput, +} from "openclaw/plugin-sdk/provider-auth"; +import { execAz, getLoggedInAccount, isAzCliInstalled } from "./cli.js"; +import { + buildFoundryAuthResult, + PROVIDER_ID, + resolveConfiguredModelNameHint, +} from "./shared.js"; +import { + loginWithTenantFallback, + promptApiKeyEndpointAndModel, + promptEndpointAndModelManually, + promptTenantId, + selectFoundryDeployment, + selectFoundryResource, + listSubscriptions, + testFoundryConnection, +} from "./onboard.js"; + +export const entraIdAuthMethod: ProviderAuthMethod = { + id: "entra-id", + label: "Entra ID (az login)", + hint: "Use your Azure login - no API key needed", + kind: "custom", + wizard: { + choiceId: "microsoft-foundry-entra", + choiceLabel: "Microsoft Foundry (Entra ID / az login)", + choiceHint: "Use your Azure login - no API key needed", + groupId: "microsoft-foundry", + groupLabel: "Microsoft Foundry", + groupHint: "Entra ID + API key", + }, + run: async (ctx: ProviderAuthContext): Promise => { + if (!isAzCliInstalled()) { + throw new Error( + "Azure CLI (az) is not installed.\nInstall it from https://learn.microsoft.com/cli/azure/install-azure-cli", + ); + } + + let account = getLoggedInAccount(); + let tenantId = account?.tenantId; + if (account) { + const useExisting = await ctx.prompter.confirm({ + message: `Already logged in as ${account.user?.name ?? "unknown"} (${account.name}). Use this account?`, + initialValue: true, + }); + if (!useExisting) { + const loginResult = await loginWithTenantFallback(ctx); + account = loginResult.account; + tenantId = loginResult.tenantId ?? loginResult.account?.tenantId; + } + } else { + await ctx.prompter.note( + "You need to log in to Azure. A device code will be displayed - follow the instructions.", + "Azure Login", + ); + const loginResult = await loginWithTenantFallback(ctx); + account = loginResult.account; + tenantId = loginResult.tenantId ?? loginResult.account?.tenantId; + } + + const subs = listSubscriptions(); + let selectedSub = null; + if (subs.length === 0) { + tenantId ??= await promptTenantId(ctx, { + required: true, + reason: "No enabled Azure subscriptions were found. Continue with tenant-scoped Entra ID auth instead.", + }); + await ctx.prompter.note(`Continuing with tenant-scoped auth (${tenantId}).`, "Azure Tenant"); + } else if (subs.length === 1) { + selectedSub = subs[0]!; + tenantId ??= selectedSub.tenantId; + await ctx.prompter.note( + `Using subscription: ${selectedSub.name} (${selectedSub.id})`, + "Subscription", + ); + } else { + const selectedId = await ctx.prompter.select({ + message: "Select Azure subscription", + options: subs.map((sub) => ({ + value: sub.id, + label: `${sub.name} (${sub.id})`, + })), + }); + selectedSub = subs.find((sub) => sub.id === selectedId)!; + tenantId ??= selectedSub.tenantId; + } + + if (selectedSub) { + execAz(["account", "set", "--subscription", selectedSub.id]); + } + + let endpoint: string; + let modelId: string; + let modelNameHint: string | undefined; + if (selectedSub) { + const useDiscoveredResource = await ctx.prompter.confirm({ + message: "Discover Microsoft Foundry resources from this subscription?", + initialValue: true, + }); + if (useDiscoveredResource) { + const selectedResource = await selectFoundryResource(ctx, selectedSub); + const selectedDeployment = await selectFoundryDeployment(ctx, selectedResource); + endpoint = selectedResource.endpoint; + modelId = selectedDeployment.name; + modelNameHint = resolveConfiguredModelNameHint(modelId, selectedDeployment.modelName); + await ctx.prompter.note( + [ + `Resource: ${selectedResource.accountName}`, + `Endpoint: ${endpoint}`, + `Deployment: ${modelId}`, + selectedDeployment.modelName ? `Model: ${selectedDeployment.modelName}` : undefined, + ] + .filter(Boolean) + .join("\n"), + "Microsoft Foundry", + ); + } else { + ({ endpoint, modelId, modelNameHint } = await promptEndpointAndModelManually(ctx)); + } + } else { + ({ endpoint, modelId, modelNameHint } = await promptEndpointAndModelManually(ctx)); + } + + await testFoundryConnection({ + ctx, + endpoint, + modelId, + modelNameHint, + subscriptionId: selectedSub?.id, + tenantId, + }); + + return buildFoundryAuthResult({ + profileId: `${PROVIDER_ID}:entra`, + apiKey: "__entra_id_dynamic__", + endpoint, + modelId, + modelNameHint, + authMethod: "entra-id", + ...(selectedSub?.id ? { subscriptionId: selectedSub.id } : {}), + ...(selectedSub?.name ? { subscriptionName: selectedSub.name } : {}), + ...(tenantId ? { tenantId } : {}), + notes: [ + ...(selectedSub?.name ? [`Subscription: ${selectedSub.name}`] : []), + ...(tenantId ? [`Tenant: ${tenantId}`] : []), + `Endpoint: ${endpoint}`, + `Model: ${modelId}`, + "Token is refreshed automatically via az CLI - keep az login active.", + ], + }); + }, +}; + +export const apiKeyAuthMethod: ProviderAuthMethod = { + id: "api-key", + label: "Azure OpenAI API key", + hint: "Direct Azure OpenAI API key", + kind: "api_key", + wizard: { + choiceId: "microsoft-foundry-apikey", + choiceLabel: "Microsoft Foundry (API key)", + groupId: "microsoft-foundry", + groupLabel: "Microsoft Foundry", + groupHint: "Entra ID + API key", + }, + run: async (ctx) => { + const authStore = ensureAuthProfileStore(ctx.agentDir, { + allowKeychainPrompt: false, + }); + const existing = authStore.profiles[`${PROVIDER_ID}:default`]; + const existingMetadata = existing?.type === "api_key" ? existing.metadata : undefined; + let capturedSecretInput: SecretInput | undefined; + let capturedCredential = false; + let capturedMode: "plaintext" | "ref" | undefined; + await ensureApiKeyFromOptionEnvOrPrompt({ + token: normalizeOptionalSecretInput(ctx.opts?.azureOpenaiApiKey), + tokenProvider: PROVIDER_ID, + secretInputMode: + ctx.allowSecretRefPrompt === false ? (ctx.secretInputMode ?? "plaintext") : ctx.secretInputMode, + config: ctx.config, + expectedProviders: [PROVIDER_ID], + provider: PROVIDER_ID, + envLabel: "AZURE_OPENAI_API_KEY", + promptMessage: "Enter Azure OpenAI API key", + normalize: normalizeApiKeyInput, + validate: validateApiKeyInput, + prompter: ctx.prompter, + setCredential: async (apiKey, mode) => { + capturedSecretInput = apiKey; + capturedCredential = true; + capturedMode = mode; + }, + }); + if (!capturedCredential) { + throw new Error("Missing Azure OpenAI API key."); + } + const selection = await promptApiKeyEndpointAndModel(ctx); + return buildFoundryAuthResult({ + profileId: `${PROVIDER_ID}:default`, + apiKey: capturedSecretInput ?? "", + ...(capturedMode ? { secretInputMode: capturedMode } : {}), + endpoint: selection.endpoint, + modelId: selection.modelId, + modelNameHint: + selection.modelNameHint ?? existingMetadata?.modelName ?? existingMetadata?.modelId, + authMethod: "api-key", + notes: [`Endpoint: ${selection.endpoint}`, `Model: ${selection.modelId}`], + }); + }, +}; diff --git a/extensions/microsoft-foundry/cli.ts b/extensions/microsoft-foundry/cli.ts new file mode 100644 index 00000000000..3bf0965cc9c --- /dev/null +++ b/extensions/microsoft-foundry/cli.ts @@ -0,0 +1,162 @@ +import { execFile, execFileSync, spawn } from "node:child_process"; +import type { AzAccessToken, AzAccount } from "./shared.js"; +import { COGNITIVE_SERVICES_RESOURCE } from "./shared.js"; + +export function execAz(args: string[]): string { + return execFileSync("az", args, { + encoding: "utf-8", + timeout: 30_000, + shell: process.platform === "win32", + }).trim(); +} + +export async function execAzAsync(args: string[]): Promise { + return await new Promise((resolve, reject) => { + execFile( + "az", + args, + { + encoding: "utf-8", + timeout: 30_000, + shell: process.platform === "win32", + }, + (error, stdout, stderr) => { + if (error) { + const details = `${String(stderr ?? "").trim()} ${String(stdout ?? "").trim()}`.trim(); + reject( + new Error( + details ? `${error.message}: ${details}` : error.message, + ), + ); + return; + } + resolve(String(stdout).trim()); + }, + ); + }); +} + +export function isAzCliInstalled(): boolean { + try { + execAz(["version", "--output", "none"]); + return true; + } catch { + return false; + } +} + +export function getLoggedInAccount(): AzAccount | null { + try { + return JSON.parse(execAz(["account", "show", "--output", "json"])) as AzAccount; + } catch { + return null; + } +} + +export function listSubscriptions(): AzAccount[] { + try { + const subs = JSON.parse(execAz(["account", "list", "--output", "json", "--all"])) as AzAccount[]; + return subs.filter((sub) => sub.state === "Enabled"); + } catch { + return []; + } +} + +export function getAccessTokenResult(params?: { + subscriptionId?: string; + tenantId?: string; +}): AzAccessToken { + const args = [ + "account", + "get-access-token", + "--resource", + COGNITIVE_SERVICES_RESOURCE, + "--output", + "json", + ]; + if (params?.subscriptionId) { + args.push("--subscription", params.subscriptionId); + } else if (params?.tenantId) { + args.push("--tenant", params.tenantId); + } + return JSON.parse(execAz(args)) as AzAccessToken; +} + +export async function getAccessTokenResultAsync(params?: { + subscriptionId?: string; + tenantId?: string; +}): Promise { + const args = [ + "account", + "get-access-token", + "--resource", + COGNITIVE_SERVICES_RESOURCE, + "--output", + "json", + ]; + if (params?.subscriptionId) { + args.push("--subscription", params.subscriptionId); + } else if (params?.tenantId) { + args.push("--tenant", params.tenantId); + } + return JSON.parse(await execAzAsync(args)) as AzAccessToken; +} + +export async function azLoginDeviceCode(): Promise { + return azLoginDeviceCodeWithOptions({}); +} + +export async function azLoginDeviceCodeWithOptions(params: { + tenantId?: string; + allowNoSubscriptions?: boolean; +}): Promise { + return new Promise((resolve, reject) => { + const maxCapturedLoginOutputChars = 8_000; + const args = [ + "login", + "--use-device-code", + ...(params.tenantId ? ["--tenant", params.tenantId] : []), + ...(params.allowNoSubscriptions ? ["--allow-no-subscriptions"] : []), + ]; + const child = spawn("az", args, { + stdio: ["inherit", "pipe", "pipe"], + shell: process.platform === "win32", + }); + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + const appendBoundedChunk = (chunks: string[], text: string): void => { + if (!text) { + return; + } + chunks.push(text); + let totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + while (totalLength > maxCapturedLoginOutputChars && chunks.length > 0) { + const removed = chunks.shift(); + totalLength -= removed?.length ?? 0; + } + }; + child.stdout?.on("data", (chunk) => { + const text = String(chunk); + appendBoundedChunk(stdoutChunks, text); + process.stdout.write(text); + }); + child.stderr?.on("data", (chunk) => { + const text = String(chunk); + appendBoundedChunk(stderrChunks, text); + process.stderr.write(text); + }); + child.on("close", (code) => { + if (code === 0) { + resolve(); + return; + } + const output = [...stderrChunks, ...stdoutChunks].join("").trim(); + reject( + new Error( + output ? `az login exited with code ${code}: ${output}` : `az login exited with code ${code}`, + ), + ); + }); + child.on("error", reject); + }); +} diff --git a/extensions/microsoft-foundry/index.test.ts b/extensions/microsoft-foundry/index.test.ts new file mode 100644 index 00000000000..c3271216bcb --- /dev/null +++ b/extensions/microsoft-foundry/index.test.ts @@ -0,0 +1,285 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js"; +import plugin from "./index.js"; +import type { OpenClawConfig } from "../../src/config/types.openclaw.js"; + +const execFileMock = vi.hoisted(() => vi.fn()); +const execFileSyncMock = vi.hoisted(() => vi.fn()); +const ensureAuthProfileStoreMock = vi.hoisted(() => + vi.fn(() => ({ + profiles: {}, + })), +); + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + execFile: execFileMock, + execFileSync: execFileSyncMock, + }; +}); + +vi.mock("openclaw/plugin-sdk/provider-auth", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/provider-auth", + ); + return { + ...actual, + ensureAuthProfileStore: ensureAuthProfileStoreMock, + }; +}); + +function registerProvider() { + const registerProviderMock = vi.fn(); + plugin.register( + createTestPluginApi({ + id: "microsoft-foundry", + name: "Microsoft Foundry", + source: "test", + config: {}, + runtime: {} as never, + registerProvider: registerProviderMock, + }), + ); + expect(registerProviderMock).toHaveBeenCalledTimes(1); + return registerProviderMock.mock.calls[0]?.[0]; +} + +describe("microsoft-foundry plugin", () => { + beforeEach(() => { + execFileMock.mockReset(); + execFileSyncMock.mockReset(); + ensureAuthProfileStoreMock.mockReset(); + ensureAuthProfileStoreMock.mockReturnValue({ profiles: {} }); + }); + + it("keeps the API key profile bound when multiple auth profiles exist without explicit order", async () => { + const provider = registerProvider(); + const config: OpenClawConfig = { + auth: { + profiles: { + "microsoft-foundry:default": { + provider: "microsoft-foundry", + mode: "api_key" as const, + }, + "microsoft-foundry:entra": { + provider: "microsoft-foundry", + mode: "api_key" as const, + }, + }, + }, + models: { + providers: { + "microsoft-foundry": { + baseUrl: "https://example.services.ai.azure.com/openai/v1", + api: "openai-responses", + models: [ + { + id: "gpt-5.4", + name: "gpt-5.4", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }, + ], + }, + }, + }, + }; + + await provider.onModelSelected?.({ + config, + model: "microsoft-foundry/gpt-5.4", + prompter: {} as never, + agentDir: "/tmp/test-agent", + }); + + expect(config.auth?.order?.["microsoft-foundry"]).toBeUndefined(); + }); + + it("uses the active ordered API key profile when model selection rebinding is needed", async () => { + const provider = registerProvider(); + ensureAuthProfileStoreMock.mockReturnValueOnce({ + profiles: { + "microsoft-foundry:default": { + type: "api_key", + provider: "microsoft-foundry", + metadata: { authMethod: "api-key" }, + }, + }, + }); + const config: OpenClawConfig = { + auth: { + profiles: { + "microsoft-foundry:default": { + provider: "microsoft-foundry", + mode: "api_key" as const, + }, + }, + order: { + "microsoft-foundry": ["microsoft-foundry:default"], + }, + }, + models: { + providers: { + "microsoft-foundry": { + baseUrl: "https://example.services.ai.azure.com/openai/v1", + api: "openai-responses", + models: [ + { + id: "gpt-5.4", + name: "gpt-5.4", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }, + ], + }, + }, + }, + }; + + await provider.onModelSelected?.({ + config, + model: "microsoft-foundry/gpt-5.4", + prompter: {} as never, + agentDir: "/tmp/test-agent", + }); + + expect(config.auth?.order?.["microsoft-foundry"]).toEqual(["microsoft-foundry:default"]); + }); + + it("preserves the model-derived base URL for Entra runtime auth refresh", async () => { + const provider = registerProvider(); + execFileMock.mockImplementationOnce( + ( + _file: unknown, + _args: unknown, + _options: unknown, + callback: (error: Error | null, stdout: string, stderr: string) => void, + ) => { + callback( + null, + JSON.stringify({ + accessToken: "test-token", + expiresOn: new Date(Date.now() + 60_000).toISOString(), + }), + "", + ); + }, + ); + ensureAuthProfileStoreMock.mockReturnValueOnce({ + profiles: { + "microsoft-foundry:entra": { + type: "api_key", + provider: "microsoft-foundry", + metadata: { + authMethod: "entra-id", + endpoint: "https://example.services.ai.azure.com", + modelId: "custom-deployment", + modelName: "gpt-5.4", + tenantId: "tenant-id", + }, + }, + }, + }); + + const prepared = await provider.prepareRuntimeAuth?.({ + provider: "microsoft-foundry", + modelId: "custom-deployment", + model: { + provider: "microsoft-foundry", + id: "custom-deployment", + name: "gpt-5.4", + api: "openai-responses", + baseUrl: "https://example.services.ai.azure.com/openai/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }, + apiKey: "__entra_id_dynamic__", + authMode: "api_key", + profileId: "microsoft-foundry:entra", + env: process.env, + agentDir: "/tmp/test-agent", + }); + + expect(prepared?.baseUrl).toBe("https://example.services.ai.azure.com/openai/v1"); + }); + + it("dedupes concurrent Entra token refreshes for the same profile", async () => { + const provider = registerProvider(); + execFileMock.mockImplementationOnce( + ( + _file: unknown, + _args: unknown, + _options: unknown, + callback: (error: Error | null, stdout: string, stderr: string) => void, + ) => { + setTimeout(() => { + callback( + null, + JSON.stringify({ + accessToken: "deduped-token", + expiresOn: new Date(Date.now() + 60_000).toISOString(), + }), + "", + ); + }, 10); + }, + ); + ensureAuthProfileStoreMock.mockReturnValue({ + profiles: { + "microsoft-foundry:entra": { + type: "api_key", + provider: "microsoft-foundry", + metadata: { + authMethod: "entra-id", + endpoint: "https://example.services.ai.azure.com", + modelId: "custom-deployment", + modelName: "gpt-5.4", + tenantId: "tenant-id", + }, + }, + }, + }); + + const runtimeContext = { + provider: "microsoft-foundry", + modelId: "custom-deployment", + model: { + provider: "microsoft-foundry", + id: "custom-deployment", + name: "gpt-5.4", + api: "openai-responses", + baseUrl: "https://example.services.ai.azure.com/openai/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }, + apiKey: "__entra_id_dynamic__", + authMode: "api_key", + profileId: "microsoft-foundry:entra", + env: process.env, + agentDir: "/tmp/test-agent", + }; + + const [first, second] = await Promise.all([ + provider.prepareRuntimeAuth?.(runtimeContext), + provider.prepareRuntimeAuth?.(runtimeContext), + ]); + + expect(execFileMock).toHaveBeenCalledTimes(1); + expect(first?.apiKey).toBe("deduped-token"); + expect(second?.apiKey).toBe("deduped-token"); + }); +}); diff --git a/extensions/microsoft-foundry/index.ts b/extensions/microsoft-foundry/index.ts index 2e15e6414bb..db450301446 100644 --- a/extensions/microsoft-foundry/index.ts +++ b/extensions/microsoft-foundry/index.ts @@ -1,1157 +1,11 @@ -import { execFileSync, spawn } from "node:child_process"; -import { - definePluginEntry, - type ProviderAuthContext, - type ProviderAuthMethod, -} from "openclaw/plugin-sdk/core"; -import { - applyAuthProfileConfig, - buildApiKeyCredential, - ensureAuthProfileStore, - ensureApiKeyFromOptionEnvOrPrompt, - normalizeApiKeyInput, - normalizeOptionalSecretInput, - type ProviderAuthResult, - type SecretInput, - validateApiKeyInput, -} from "openclaw/plugin-sdk/provider-auth"; -import type { ModelCompatConfig, ModelProviderConfig } from "../../src/config/types.models.js"; -import type { ProviderModelSelectedContext } from "../../src/plugins/types.js"; - -const PROVIDER_ID = "microsoft-foundry"; -const DEFAULT_API = "openai-completions"; -const DEFAULT_GPT5_API = "openai-responses"; -const COGNITIVE_SERVICES_RESOURCE = "https://cognitiveservices.azure.com"; -const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000; // refresh 5 min before expiry - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function execAz(args: string[]): string { - return execFileSync("az", args, { - encoding: "utf-8", - timeout: 30_000, - shell: process.platform === "win32", - }).trim(); -} - -function isAzCliInstalled(): boolean { - try { - // "az version" works on Windows, Linux, and macOS - execAz(["version", "--output", "none"]); - return true; - } catch { - return false; - } -} - -interface AzAccount { - name: string; - id: string; - tenantId?: string; - user?: { name?: string }; - state?: string; - isDefault?: boolean; -} - -function getLoggedInAccount(): AzAccount | null { - try { - const raw = execAz(["account", "show", "--output", "json"]); - return JSON.parse(raw) as AzAccount; - } catch { - return null; - } -} - -function listSubscriptions(): AzAccount[] { - try { - const raw = execAz(["account", "list", "--output", "json", "--all"]); - const subs = JSON.parse(raw) as AzAccount[]; - return subs.filter((s) => s.state === "Enabled"); - } catch { - return []; - } -} - -interface AzAccessToken { - accessToken: string; - expiresOn?: string; -} - -interface AzCognitiveAccount { - id: string; - name: string; - kind: string; - location?: string; - resourceGroup?: string; - endpoint?: string | null; - customSubdomain?: string | null; - projects?: string[] | null; -} - -interface FoundryResourceOption { - id: string; - accountName: string; - kind: "AIServices" | "OpenAI"; - location?: string; - resourceGroup: string; - endpoint: string; - projects: string[]; -} - -interface AzDeploymentSummary { - name: string; - modelName?: string; - modelVersion?: string; - state?: string; - sku?: string; -} - -type FoundrySelection = { - endpoint: string; - modelId: string; - modelNameHint?: string; -}; - -type CachedTokenEntry = { - token: string; - expiresAt: number; -}; - -type FoundryProviderApi = typeof DEFAULT_API | typeof DEFAULT_GPT5_API; - -function getAccessTokenResult(params?: { - subscriptionId?: string; - tenantId?: string; -}): AzAccessToken { - const args = [ - "account", - "get-access-token", - "--resource", - COGNITIVE_SERVICES_RESOURCE, - "--output", - "json", - ]; - if (params?.subscriptionId) { - args.push("--subscription", params.subscriptionId); - } else if (params?.tenantId) { - args.push("--tenant", params.tenantId); - } - const raw = execAz(args); - return JSON.parse(raw) as AzAccessToken; -} - -function isGpt5FamilyName(value?: string | null): boolean { - return typeof value === "string" && /^gpt-5(?:$|[-.])/i.test(value.trim()); -} - -function isGpt5FamilyDeployment(modelId: string, modelNameHint?: string | null): boolean { - return isGpt5FamilyName(modelId) || isGpt5FamilyName(modelNameHint); -} - -function buildAzureBaseUrl(endpoint: string, modelId: string): string { - const base = normalizeFoundryEndpoint(endpoint); - if (base.includes("/openai/deployments/")) return base; - return `${base}/openai/deployments/${modelId}`; -} - -function buildFoundryResponsesBaseUrl(endpoint: string): string { - const base = normalizeFoundryEndpoint(endpoint); - return base.endsWith("/openai/v1") ? base : `${base}/openai/v1`; -} - -function normalizeFoundryEndpoint(endpoint: string): string { - const trimmed = endpoint.trim().replace(/\/+$/, ""); - return trimmed.replace(/\/openai(?:\/v1|\/deployments\/[^/]+)?$/i, ""); -} - -function buildFoundryProviderBaseUrl( - endpoint: string, - modelId: string, - modelNameHint?: string | null, -): string { - return resolveFoundryApi(modelId, modelNameHint) === DEFAULT_GPT5_API - ? buildFoundryResponsesBaseUrl(endpoint) - : buildAzureBaseUrl(endpoint, modelId); -} - -function extractFoundryEndpoint(baseUrl: string): string | undefined { - try { - const url = new URL(baseUrl); - return url.origin; - } catch { - return undefined; - } -} - -function resolveFoundryApi(modelId: string, modelNameHint?: string | null): FoundryProviderApi { - return isGpt5FamilyDeployment(modelId, modelNameHint) ? DEFAULT_GPT5_API : DEFAULT_API; -} - -function buildFoundryModelCompat( - modelId: string, - modelNameHint?: string | null, -): ModelCompatConfig | undefined { - if (!isGpt5FamilyDeployment(modelId, modelNameHint)) { - return undefined; - } - return { - maxTokensField: "max_completion_tokens", - }; -} - -function buildFoundryProviderConfig( - endpoint: string, - modelId: string, - modelNameHint?: string | null, -): ModelProviderConfig { - const compat = buildFoundryModelCompat(modelId, modelNameHint); - return { - baseUrl: buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint), - api: resolveFoundryApi(modelId, modelNameHint), - models: [ - { - id: modelId, - name: typeof modelNameHint === "string" && modelNameHint.trim().length > 0 ? modelNameHint.trim() : modelId, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128_000, - maxTokens: 16_384, - ...(compat ? { compat } : {}), - }, - ], - }; -} - -function normalizeEndpointOrigin(rawUrl: string | null | undefined): string | undefined { - if (!rawUrl) { - return undefined; - } - try { - return new URL(rawUrl).origin; - } catch { - return undefined; - } -} - -function resolveConfiguredModelNameHint(modelId: string, modelNameHint?: string | null): string | undefined { - const trimmedName = typeof modelNameHint === "string" ? modelNameHint.trim() : ""; - if (trimmedName) { - return trimmedName; - } - const trimmedId = modelId.trim(); - return trimmedId ? trimmedId : undefined; -} - -function buildFoundryCredentialMetadata(params: { - authMethod: "api-key" | "entra-id"; - endpoint: string; - modelId: string; - modelNameHint?: string | null; - subscriptionId?: string; - subscriptionName?: string; - tenantId?: string; -}): Record { - const metadata: Record = { - authMethod: params.authMethod, - endpoint: params.endpoint, - modelId: params.modelId, - }; - const modelName = resolveConfiguredModelNameHint(params.modelId, params.modelNameHint); - if (modelName) { - metadata.modelName = modelName; - } - if (params.subscriptionId) { - metadata.subscriptionId = params.subscriptionId; - } - if (params.subscriptionName) { - metadata.subscriptionName = params.subscriptionName; - } - if (params.tenantId) { - metadata.tenantId = params.tenantId; - } - return metadata; -} - -function buildFoundryAuthResult(params: { - profileId: string; - apiKey: SecretInput; - secretInputMode?: "plaintext" | "ref"; - endpoint: string; - modelId: string; - modelNameHint?: string | null; - authMethod: "api-key" | "entra-id"; - subscriptionId?: string; - subscriptionName?: string; - tenantId?: string; - notes?: string[]; -}): ProviderAuthResult { - return { - profiles: [ - { - profileId: params.profileId, - credential: buildApiKeyCredential( - PROVIDER_ID, - params.apiKey, - buildFoundryCredentialMetadata({ - authMethod: params.authMethod, - endpoint: params.endpoint, - modelId: params.modelId, - modelNameHint: params.modelNameHint, - subscriptionId: params.subscriptionId, - subscriptionName: params.subscriptionName, - tenantId: params.tenantId, - }), - params.secretInputMode ? { secretInputMode: params.secretInputMode } : undefined, - ), - }, - ], - configPatch: { - models: { - providers: { - [PROVIDER_ID]: buildFoundryProviderConfig( - params.endpoint, - params.modelId, - params.modelNameHint, - ), - }, - }, - }, - defaultModel: `${PROVIDER_ID}/${params.modelId}`, - notes: params.notes, - }; -} - -function applyFoundryProfileBinding( - config: ProviderModelSelectedContext["config"], - profileId: string, -): void { - applyAuthProfileConfig(config, { - profileId, - provider: PROVIDER_ID, - mode: "api_key", - }); -} - -function applyFoundryProviderConfig( - config: ProviderModelSelectedContext["config"], - providerConfig: ModelProviderConfig, -): void { - config.models ??= {}; - config.models.providers ??= {}; - config.models.providers[PROVIDER_ID] = providerConfig; -} - -function listFoundryResources(): FoundryResourceOption[] { - try { - const raw = execAz([ - "cognitiveservices", - "account", - "list", - "--query", - "[].{id:id,name:name,kind:kind,location:location,resourceGroup:resourceGroup,endpoint:properties.endpoint,customSubdomain:properties.customSubDomainName,projects:properties.associatedProjects}", - "--output", - "json", - ]); - const accounts = JSON.parse(raw) as AzCognitiveAccount[]; - const resources: FoundryResourceOption[] = []; - for (const account of accounts) { - if (!account.resourceGroup) { - continue; - } - if (account.kind === "OpenAI") { - const endpoint = normalizeEndpointOrigin(account.endpoint); - if (!endpoint) { - continue; - } - resources.push({ - id: account.id, - accountName: account.name, - kind: "OpenAI", - location: account.location, - resourceGroup: account.resourceGroup, - endpoint, - projects: [], - }); - continue; - } - if (account.kind !== "AIServices") { - continue; - } - const endpoint = account.customSubdomain?.trim() - ? `https://${account.customSubdomain.trim()}.services.ai.azure.com` - : undefined; - if (!endpoint) { - continue; - } - resources.push({ - id: account.id, - accountName: account.name, - kind: "AIServices", - location: account.location, - resourceGroup: account.resourceGroup, - endpoint, - projects: Array.isArray(account.projects) - ? account.projects.filter((project): project is string => typeof project === "string") - : [], - }); - } - return resources; - } catch { - return []; - } -} - -function listResourceDeployments(resource: FoundryResourceOption): AzDeploymentSummary[] { - try { - const raw = execAz([ - "cognitiveservices", - "account", - "deployment", - "list", - "-g", - resource.resourceGroup, - "-n", - resource.accountName, - "--query", - "[].{name:name,modelName:properties.model.name,modelVersion:properties.model.version,state:properties.provisioningState,sku:sku.name}", - "--output", - "json", - ]); - const deployments = JSON.parse(raw) as AzDeploymentSummary[]; - return deployments.filter((deployment) => deployment.state === "Succeeded"); - } catch { - return []; - } -} - -async function selectFoundryResource( - ctx: ProviderAuthContext, - selectedSub: AzAccount, -): Promise { - const resources = listFoundryResources(); - if (resources.length === 0) { - throw new Error(buildCreateFoundryHint(selectedSub)); - } - if (resources.length === 1) { - const only = resources[0]!; - await ctx.prompter.note( - `Using ${only.kind === "AIServices" ? "Azure AI Foundry" : "Azure OpenAI"} resource: ${only.accountName}`, - "Foundry Resource", - ); - return only; - } - const selectedResourceId = await ctx.prompter.select({ - message: "Select Azure AI Foundry / Azure OpenAI resource", - options: resources.map((resource) => ({ - value: resource.id, - label: `${resource.accountName} (${resource.kind === "AIServices" ? "Azure AI Foundry" : "Azure OpenAI"}${resource.location ? `, ${resource.location}` : ""})`, - hint: [ - `RG: ${resource.resourceGroup}`, - resource.projects.length > 0 ? `${resource.projects.length} project(s)` : undefined, - ] - .filter(Boolean) - .join(" | "), - })), - }); - return resources.find((resource) => resource.id === selectedResourceId) ?? resources[0]!; -} - -async function selectFoundryDeployment( - ctx: ProviderAuthContext, - resource: FoundryResourceOption, -): Promise { - const deployments = listResourceDeployments(resource); - if (deployments.length === 0) { - throw new Error( - [ - `No model deployments were found in ${resource.accountName}.`, - "Deploy a model in Azure AI Foundry or Azure OpenAI, then rerun onboard.", - ].join("\n"), - ); - } - if (deployments.length === 1) { - const only = deployments[0]!; - await ctx.prompter.note(`Using deployment: ${only.name}`, "Model Deployment"); - return only; - } - const selectedDeploymentName = await ctx.prompter.select({ - message: "Select model deployment", - options: deployments.map((deployment) => ({ - value: deployment.name, - label: deployment.name, - hint: [deployment.modelName, deployment.modelVersion, deployment.sku].filter(Boolean).join(" | "), - })), - }); - return deployments.find((deployment) => deployment.name === selectedDeploymentName) ?? deployments[0]!; -} - -function buildCreateFoundryHint(selectedSub: AzAccount): string { - return [ - `No Azure AI Foundry or Azure OpenAI resources were found in subscription ${selectedSub.name} (${selectedSub.id}).`, - "Create one in Azure AI Foundry or Azure Portal, then rerun onboard.", - "Azure AI Foundry: https://ai.azure.com", - "Azure OpenAI docs: https://learn.microsoft.com/azure/ai-foundry/openai/how-to/create-resource", - ].join("\n"); -} - -async function promptEndpointAndModelManually(ctx: ProviderAuthContext): Promise<{ - endpoint: string; - modelId: string; - modelNameHint?: string; -}> { - const endpoint = String( - await ctx.prompter.text({ - message: "Microsoft Foundry endpoint URL", - placeholder: "https://xxx.openai.azure.com or https://xxx.services.ai.azure.com", - validate: (v) => { - const val = String(v ?? "").trim(); - if (!val) return "Endpoint URL is required"; - try { - new URL(val); - } catch { - return "Invalid URL"; - } - return undefined; - }, - }), - ).trim(); - const modelId = String( - await ctx.prompter.text({ - message: "Default model/deployment name", - placeholder: "gpt-4o", - validate: (v) => { - const val = String(v ?? "").trim(); - if (!val) return "Model ID is required"; - return undefined; - }, - }), - ).trim(); - const modelNameHintInput = String( - await ctx.prompter.text({ - message: "Underlying Azure model family (optional)", - initialValue: modelId, - placeholder: "gpt-5.4, gpt-4o, etc.", - }), - ).trim(); - return { - endpoint, - modelId, - modelNameHint: modelNameHintInput || modelId, - }; -} - -async function promptApiKeyEndpointAndModel(ctx: ProviderAuthContext): Promise { - const endpoint = String( - await ctx.prompter.text({ - message: "Microsoft Foundry endpoint URL", - placeholder: "https://xxx.openai.azure.com or https://xxx.services.ai.azure.com", - initialValue: normalizeOptionalSecretInput(process.env.AZURE_OPENAI_ENDPOINT), - validate: (v) => { - const val = String(v ?? "").trim(); - if (!val) return "Endpoint URL is required"; - try { - new URL(val); - } catch { - return "Invalid URL"; - } - return undefined; - }, - }), - ).trim(); - const modelId = String( - await ctx.prompter.text({ - message: "Default model/deployment name", - initialValue: "gpt-4o", - validate: (v) => { - const val = String(v ?? "").trim(); - if (!val) return "Model ID is required"; - return undefined; - }, - }), - ).trim(); - const modelNameHintInput = String( - await ctx.prompter.text({ - message: "Underlying Azure model family (optional)", - initialValue: modelId, - placeholder: "gpt-5.4, gpt-4o, etc.", - }), - ).trim(); - return { - endpoint, - modelId, - modelNameHint: modelNameHintInput || modelId, - }; -} - -function buildFoundryConnectionTest(params: { - endpoint: string; - modelId: string; - modelNameHint?: string | null; -}): { url: string; body: Record } { - const baseUrl = buildFoundryProviderBaseUrl( - params.endpoint, - params.modelId, - params.modelNameHint, - ); - if (resolveFoundryApi(params.modelId, params.modelNameHint) === DEFAULT_GPT5_API) { - return { - url: `${baseUrl}/responses?api-version=2025-04-01-preview`, - body: { - model: params.modelId, - input: "hi", - max_output_tokens: 1, - }, - }; - } - return { - url: `${baseUrl}/chat/completions?api-version=2024-12-01-preview`, - body: { - messages: [{ role: "user", content: "hi" }], - max_tokens: 1, - }, - }; -} - -function getFoundryTokenCacheKey(params?: { - subscriptionId?: string; - tenantId?: string; -}): string { - return `${params?.subscriptionId ?? ""}:${params?.tenantId ?? ""}`; -} - -/** - * Interactive az login using device-code flow. - * Spawns az login so terminal output (device code URL) is visible to user. - */ -async function azLoginDeviceCode(): Promise { - return azLoginDeviceCodeWithOptions({}); -} - -async function azLoginDeviceCodeWithOptions(params: { - tenantId?: string; - allowNoSubscriptions?: boolean; -}): Promise { - return new Promise((resolve, reject) => { - const args = [ - "login", - "--use-device-code", - ...(params.tenantId ? ["--tenant", params.tenantId] : []), - ...(params.allowNoSubscriptions ? ["--allow-no-subscriptions"] : []), - ]; - const child = spawn("az", args, { - stdio: "inherit", - shell: process.platform === "win32", - }); - child.on("close", (code) => { - if (code === 0) resolve(); - else reject(new Error(`az login exited with code ${code}`)); - }); - child.on("error", reject); - }); -} - -function extractTenantSuggestions(rawMessage: string): Array<{ id: string; label?: string }> { - const suggestions: Array<{ id: string; label?: string }> = []; - const seen = new Set(); - const regex = /([0-9a-fA-F-]{36})(?:\s+'([^'\r\n]+)')?/g; - for (const match of rawMessage.matchAll(regex)) { - const id = match[1]?.trim(); - if (!id || seen.has(id)) { - continue; - } - seen.add(id); - suggestions.push({ - id, - ...(match[2]?.trim() ? { label: match[2].trim() } : {}), - }); - } - return suggestions; -} - -async function promptTenantId( - ctx: ProviderAuthContext, - params?: { - suggestions?: Array<{ id: string; label?: string }>; - required?: boolean; - reason?: string; - }, -): Promise { - const suggestionLines = - params?.suggestions && params.suggestions.length > 0 - ? params.suggestions.map((entry) => `- ${entry.id}${entry.label ? ` (${entry.label})` : ""}`) - : []; - if (params?.reason || suggestionLines.length > 0) { - await ctx.prompter.note( - [ - params?.reason, - suggestionLines.length > 0 ? "Suggested tenants:" : undefined, - ...suggestionLines, - ] - .filter(Boolean) - .join("\n"), - "Azure Tenant", - ); - } - const tenantId = String( - await ctx.prompter.text({ - message: params?.required - ? "Azure tenant ID" - : "Azure tenant ID (optional)", - placeholder: params?.suggestions?.[0]?.id ?? "00000000-0000-0000-0000-000000000000", - validate: (value) => { - const trimmed = String(value ?? "").trim(); - if (!trimmed) { - return params?.required ? "Tenant ID is required" : undefined; - } - return /^[0-9a-fA-F-]{36}$/.test(trimmed) ? undefined : "Enter a valid tenant ID"; - }, - }), - ).trim(); - return tenantId || undefined; -} - -async function loginWithTenantFallback(ctx: ProviderAuthContext): Promise<{ - account: AzAccount | null; - tenantId?: string; -}> { - try { - await azLoginDeviceCode(); - return { account: getLoggedInAccount() }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const isAzureTenantError = - /AADSTS/i.test(message) || - return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(trimmed) ? undefined : "Enter a valid tenant ID"; - /tenant/i.test(message); - if (!isAzureTenantError) { - throw error; - } - const tenantId = await promptTenantId(ctx, { - suggestions: extractTenantSuggestions(message), - required: true, - reason: - "Azure login needs a tenant-scoped retry. This often happens when your tenant requires MFA or your account has no Azure subscriptions.", - }); - await azLoginDeviceCodeWithOptions({ - tenantId, - allowNoSubscriptions: true, - }); - return { - account: getLoggedInAccount(), - tenantId, - }; - } -} - -// --------------------------------------------------------------------------- -// Entra ID auth method -// --------------------------------------------------------------------------- - -const entraIdAuthMethod = { - id: "entra-id", - label: "Entra ID (az login)", - hint: "Use your Azure login — no API key needed", - kind: "custom" as const, - wizard: { - choiceId: "microsoft-foundry-entra", - choiceLabel: "Microsoft Foundry (Entra ID / az login)", - choiceHint: "Use your Azure login — no API key needed", - groupId: "microsoft-foundry", - groupLabel: "Microsoft Foundry", - groupHint: "Entra ID + API key", - }, - run: async (ctx: ProviderAuthContext): Promise => { - // 1. Check az CLI - if (!isAzCliInstalled()) { - throw new Error( - "Azure CLI (az) is not installed.\n" + - "Install it from https://learn.microsoft.com/cli/azure/install-azure-cli", - ); - } - - // 2. Check login status - let account = getLoggedInAccount(); - let tenantId = account?.tenantId; - if (account) { - const useExisting = await ctx.prompter.confirm({ - message: `Already logged in as ${account.user?.name ?? "unknown"} (${account.name}). Use this account?`, - initialValue: true, - }); - if (!useExisting) { - const loginResult = await loginWithTenantFallback(ctx); - account = loginResult.account; - tenantId = loginResult.tenantId ?? loginResult.account?.tenantId; - } - } else { - await ctx.prompter.note( - "You need to log in to Azure. A device code will be displayed — follow the instructions.", - "Azure Login", - ); - const loginResult = await loginWithTenantFallback(ctx); - account = loginResult.account; - tenantId = loginResult.tenantId ?? loginResult.account?.tenantId; - } - - // 3. List and select subscription - const subs = listSubscriptions(); - let selectedSub: AzAccount | null = null; - if (subs.length === 0) { - tenantId ??= await promptTenantId(ctx, { - required: true, - reason: - "No enabled Azure subscriptions were found. Continue with tenant-scoped Entra ID auth instead.", - }); - await ctx.prompter.note( - `Continuing with tenant-scoped auth (${tenantId}).`, - "Azure Tenant", - ); - } else if (subs.length === 1) { - selectedSub = subs[0]!; - tenantId ??= selectedSub.tenantId; - await ctx.prompter.note( - `Using subscription: ${selectedSub.name} (${selectedSub.id})`, - "Subscription", - ); - } else { - const choices = subs.map((s) => ({ - value: s.id, - label: `${s.name} (${s.id})`, - })); - const selectedId = await ctx.prompter.select({ - message: "Select Azure subscription", - options: choices, - }); - selectedSub = subs.find((s) => s.id === selectedId)!; - tenantId ??= selectedSub.tenantId; - } - - // 4. Set subscription - if (selectedSub) { - execAz(["account", "set", "--subscription", selectedSub.id]); - } - - // 5. Discover resource + deployment when possible - let endpoint: string; - let modelId: string; - let modelNameHint: string | undefined; - if (selectedSub) { - const useDiscoveredResource = await ctx.prompter.confirm({ - message: "Discover Microsoft Foundry resources from this subscription?", - initialValue: true, - }); - if (useDiscoveredResource) { - const selectedResource = await selectFoundryResource(ctx, selectedSub); - const selectedDeployment = await selectFoundryDeployment(ctx, selectedResource); - endpoint = selectedResource.endpoint; - modelId = selectedDeployment.name; - modelNameHint = resolveConfiguredModelNameHint(modelId, selectedDeployment.modelName); - await ctx.prompter.note( - [ - `Resource: ${selectedResource.accountName}`, - `Endpoint: ${endpoint}`, - `Deployment: ${modelId}`, - selectedDeployment.modelName ? `Model: ${selectedDeployment.modelName}` : undefined, - ].join("\n"), - "Microsoft Foundry", - ); - } else { - ({ endpoint, modelId, modelNameHint } = await promptEndpointAndModelManually(ctx)); - } - } else { - ({ endpoint, modelId, modelNameHint } = await promptEndpointAndModelManually(ctx)); - } - - // 7. Test connection - try { - const { accessToken } = getAccessTokenResult({ - subscriptionId: selectedSub?.id, - tenantId, - }); - const testRequest = buildFoundryConnectionTest({ - endpoint, - modelId, - modelNameHint, - }); - const res = await fetch(testRequest.url, { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(testRequest.body), - }); - if (!res.ok && res.status !== 400) { - const body = await res.text().catch(() => ""); - await ctx.prompter.note( - `Warning: test request returned ${res.status}. ${body.slice(0, 200)}\nProceeding anyway — you can fix the endpoint later.`, - "Connection Test", - ); - } else { - const statusNote = res.status === 400 ? " (400 Bad Request — endpoint reachable)" : ""; - const statusBody = res.status === 400 ? await res.text().catch(() => "") : ""; - await ctx.prompter.note(`Connection test successful!${statusNote}`, "✓"); - if (statusBody) { - await ctx.prompter.note( - `Endpoint response: ${statusBody.slice(0, 200)}`, - "Connection Test", - ); - } - } - } catch (err) { - await ctx.prompter.note( - `Warning: connection test failed: ${String(err)}\nProceeding anyway.`, - "Connection Test", - ); - } - - // 8. Build result — store a placeholder key; prepareRuntimeAuth will - // replace it with a fresh Entra ID token at request time. - const profileId = `${PROVIDER_ID}:entra`; - - return buildFoundryAuthResult({ - profileId, - apiKey: "__entra_id_dynamic__", - endpoint, - modelId, - modelNameHint, - authMethod: "entra-id", - ...(selectedSub?.id ? { subscriptionId: selectedSub.id } : {}), - ...(selectedSub?.name ? { subscriptionName: selectedSub.name } : {}), - ...(tenantId ? { tenantId } : {}), - notes: [ - ...(selectedSub?.name ? [`Subscription: ${selectedSub.name}`] : []), - ...(tenantId ? [`Tenant: ${tenantId}`] : []), - `Endpoint: ${endpoint}`, - `Model: ${modelId}`, - "Token is refreshed automatically via az CLI — keep az login active.", - ], - }); - }, -}; - -// --------------------------------------------------------------------------- -// API Key auth method -// --------------------------------------------------------------------------- - -const apiKeyAuthMethod: ProviderAuthMethod = { - id: "api-key", - label: "Azure OpenAI API key", - hint: "Direct Azure OpenAI API key", - kind: "api_key", - wizard: { - choiceId: "microsoft-foundry-apikey", - choiceLabel: "Microsoft Foundry (API key)", - groupId: "microsoft-foundry", - groupLabel: "Microsoft Foundry", - groupHint: "Entra ID + API key", - }, - run: async (ctx) => { - const authStore = ensureAuthProfileStore(ctx.agentDir, { - allowKeychainPrompt: false, - }); - const existing = authStore.profiles[`${PROVIDER_ID}:default`]; - const existingMetadata = existing?.type === "api_key" ? existing.metadata : undefined; - let capturedSecretInput: SecretInput | undefined; - let capturedCredential = false; - let capturedMode: "plaintext" | "ref" | undefined; - await ensureApiKeyFromOptionEnvOrPrompt({ - token: normalizeOptionalSecretInput(ctx.opts?.azureOpenaiApiKey), - tokenProvider: PROVIDER_ID, - secretInputMode: - ctx.allowSecretRefPrompt === false ? (ctx.secretInputMode ?? "plaintext") : ctx.secretInputMode, - config: ctx.config, - expectedProviders: [PROVIDER_ID], - provider: PROVIDER_ID, - envLabel: "AZURE_OPENAI_API_KEY", - promptMessage: "Enter Azure OpenAI API key", - normalize: normalizeApiKeyInput, - validate: validateApiKeyInput, - prompter: ctx.prompter, - setCredential: async (apiKey, mode) => { - capturedSecretInput = apiKey; - capturedCredential = true; - capturedMode = mode; - }, - }); - if (!capturedCredential) { - throw new Error("Missing Azure OpenAI API key."); - } - const selection = await promptApiKeyEndpointAndModel(ctx); - return buildFoundryAuthResult({ - profileId: `${PROVIDER_ID}:default`, - apiKey: capturedSecretInput ?? "", - ...(capturedMode ? { secretInputMode: capturedMode } : {}), - endpoint: selection.endpoint, - modelId: selection.modelId, - modelNameHint: - selection.modelNameHint ?? existingMetadata?.modelName ?? existingMetadata?.modelId, - authMethod: "api-key", - notes: [ - `Endpoint: ${selection.endpoint}`, - `Model: ${selection.modelId}`, - ], - }); - }, -}; - -// --------------------------------------------------------------------------- -// Token cache for prepareRuntimeAuth -// --------------------------------------------------------------------------- - -const cachedTokens = new Map(); - -function refreshEntraToken(params?: { - subscriptionId?: string; - tenantId?: string; -}): { apiKey: string; expiresAt: number } { - const result = getAccessTokenResult(params); - const rawExpiry = result.expiresOn ? new Date(result.expiresOn).getTime() : Number.NaN; - const expiresAt = Number.isFinite(rawExpiry) - ? rawExpiry - : Date.now() + 55 * 60 * 1000; // default ~55 min - cachedTokens.set(getFoundryTokenCacheKey(params), { - token: result.accessToken, - expiresAt, - }); - return { apiKey: result.accessToken, expiresAt }; -} - -// --------------------------------------------------------------------------- -// Plugin entry -// --------------------------------------------------------------------------- +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { buildMicrosoftFoundryProvider } from "./provider.js"; export default definePluginEntry({ - id: PROVIDER_ID, + id: "microsoft-foundry", name: "Microsoft Foundry Provider", description: "Microsoft Foundry provider with Entra ID and API key auth", register(api) { - api.registerProvider({ - id: PROVIDER_ID, - label: "Microsoft Foundry", - docsPath: "/providers/azure", - envVars: ["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT"], - auth: [entraIdAuthMethod, apiKeyAuthMethod], - capabilities: { - providerFamily: "openai", - }, - onModelSelected: async (ctx: ProviderModelSelectedContext) => { - const providerConfig = ctx.config.models?.providers?.[PROVIDER_ID]; - if (!providerConfig || !ctx.model.startsWith(`${PROVIDER_ID}/`)) { - return; - } - const selectedModelId = ctx.model.slice(`${PROVIDER_ID}/`.length); - const existingModel = providerConfig.models.find( - (model: { id: string }) => model.id === selectedModelId, - ); - const selectedModelNameHint = resolveConfiguredModelNameHint( - selectedModelId, - existingModel?.name, - ); - const selectedModelCompat = buildFoundryModelCompat(selectedModelId, selectedModelNameHint); - const providerEndpoint = normalizeFoundryEndpoint(providerConfig.baseUrl ?? ""); - const nextProviderConfig: ModelProviderConfig = { - ...providerConfig, - baseUrl: buildFoundryProviderBaseUrl(providerEndpoint, selectedModelId, selectedModelNameHint), - api: resolveFoundryApi(selectedModelId, selectedModelNameHint), - models: [ - { - ...(existingModel ?? { - id: selectedModelId, - name: selectedModelId, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128_000, - maxTokens: 16_384, - }), - ...(selectedModelCompat ? { compat: selectedModelCompat } : {}), - }, - ], - }; - applyFoundryProfileBinding(ctx.config, `${PROVIDER_ID}:entra`); - applyFoundryProviderConfig(ctx.config, nextProviderConfig); - }, - normalizeResolvedModel: ({ modelId, model }) => { - const endpoint = extractFoundryEndpoint(model.baseUrl ?? ""); - if (!endpoint) { - return model; - } - const modelNameHint = resolveConfiguredModelNameHint(modelId, model.name); - const compat = buildFoundryModelCompat(modelId, modelNameHint); - return { - ...model, - api: resolveFoundryApi(modelId, modelNameHint), - baseUrl: buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint), - ...(compat ? { compat } : {}), - }; - }, - prepareRuntimeAuth: async (ctx) => { - // Only intercept Entra ID auth (placeholder key). - // API key users pass through unchanged. - if (ctx.apiKey !== "__entra_id_dynamic__") { - return null; // let default handling apply - } - - // Return cached token if still valid - try { - const authStore = ensureAuthProfileStore(ctx.agentDir, { - allowKeychainPrompt: false, - }); - const credential = ctx.profileId ? authStore.profiles[ctx.profileId] : undefined; - const metadata = credential?.type === "api_key" ? credential.metadata : undefined; - const modelId = - typeof ctx.modelId === "string" && ctx.modelId.trim().length > 0 - ? ctx.modelId.trim() - : typeof metadata?.modelId === "string" && metadata.modelId.trim().length > 0 - ? metadata.modelId.trim() - : ctx.modelId; - const activeModelNameHint = - ctx.modelId === metadata?.modelId ? metadata?.modelName : undefined; - const modelNameHint = resolveConfiguredModelNameHint( - modelId, - ctx.model.name ?? activeModelNameHint, - ); - const endpoint = - typeof metadata?.endpoint === "string" && metadata.endpoint.trim().length > 0 - ? metadata.endpoint.trim() - : extractFoundryEndpoint(ctx.model.baseUrl ?? ""); - const baseUrl = endpoint - ? buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint) - : undefined; - const cacheKey = getFoundryTokenCacheKey({ - subscriptionId: metadata?.subscriptionId, - tenantId: metadata?.tenantId, - }); - const cachedToken = cachedTokens.get(cacheKey); - - // Return cached token if still valid - if (cachedToken && cachedToken.expiresAt > Date.now() + TOKEN_REFRESH_MARGIN_MS) { - return { - apiKey: cachedToken.token, - expiresAt: cachedToken.expiresAt, - ...(baseUrl ? { baseUrl } : {}), - }; - } - - // Refresh via az CLI - const token = refreshEntraToken({ - subscriptionId: metadata?.subscriptionId, - tenantId: metadata?.tenantId, - }); - return { - ...token, - ...(baseUrl ? { baseUrl } : {}), - }; - } catch (err) { - throw new Error( - `Failed to refresh Azure Entra ID token via az CLI: ${String(err)}\n` + - "Make sure you are logged in: az login --use-device-code", - ); - } - }, - }); + api.registerProvider(buildMicrosoftFoundryProvider()); }, }); diff --git a/extensions/microsoft-foundry/onboard.ts b/extensions/microsoft-foundry/onboard.ts new file mode 100644 index 00000000000..178b3d2a96f --- /dev/null +++ b/extensions/microsoft-foundry/onboard.ts @@ -0,0 +1,430 @@ +import type { ProviderAuthContext } from "openclaw/plugin-sdk/core"; +import { + azLoginDeviceCode, + azLoginDeviceCodeWithOptions, + execAz, + getAccessTokenResult, + getLoggedInAccount, + listSubscriptions, +} from "./cli.js"; +import { + type AzAccount, + type AzCognitiveAccount, + type AzDeploymentSummary, + type FoundryResourceOption, + type FoundrySelection, + buildFoundryProviderBaseUrl, + normalizeEndpointOrigin, + resolveConfiguredModelNameHint, + resolveFoundryApi, + DEFAULT_GPT5_API, +} from "./shared.js"; + +export { listSubscriptions } from "./cli.js"; + +export function listFoundryResources(): FoundryResourceOption[] { + try { + const accounts = JSON.parse( + execAz([ + "cognitiveservices", + "account", + "list", + "--query", + "[].{id:id,name:name,kind:kind,location:location,resourceGroup:resourceGroup,endpoint:properties.endpoint,customSubdomain:properties.customSubDomainName,projects:properties.associatedProjects}", + "--output", + "json", + ]), + ) as AzCognitiveAccount[]; + const resources: FoundryResourceOption[] = []; + for (const account of accounts) { + if (!account.resourceGroup) { + continue; + } + if (account.kind === "OpenAI") { + const endpoint = normalizeEndpointOrigin(account.endpoint); + if (!endpoint) { + continue; + } + resources.push({ + id: account.id, + accountName: account.name, + kind: "OpenAI", + location: account.location, + resourceGroup: account.resourceGroup, + endpoint, + projects: [], + }); + continue; + } + if (account.kind !== "AIServices") { + continue; + } + const endpoint = account.customSubdomain?.trim() + ? `https://${account.customSubdomain.trim()}.services.ai.azure.com` + : undefined; + if (!endpoint) { + continue; + } + resources.push({ + id: account.id, + accountName: account.name, + kind: "AIServices", + location: account.location, + resourceGroup: account.resourceGroup, + endpoint, + projects: Array.isArray(account.projects) + ? account.projects.filter((project): project is string => typeof project === "string") + : [], + }); + } + return resources; + } catch { + return []; + } +} + +export function listResourceDeployments(resource: FoundryResourceOption): AzDeploymentSummary[] { + try { + const deployments = JSON.parse( + execAz([ + "cognitiveservices", + "account", + "deployment", + "list", + "-g", + resource.resourceGroup, + "-n", + resource.accountName, + "--query", + "[].{name:name,modelName:properties.model.name,modelVersion:properties.model.version,state:properties.provisioningState,sku:sku.name}", + "--output", + "json", + ]), + ) as AzDeploymentSummary[]; + return deployments.filter((deployment) => deployment.state === "Succeeded"); + } catch { + return []; + } +} + +export function buildCreateFoundryHint(selectedSub: AzAccount): string { + return [ + `No Azure AI Foundry or Azure OpenAI resources were found in subscription ${selectedSub.name} (${selectedSub.id}).`, + "Create one in Azure AI Foundry or Azure Portal, then rerun onboard.", + "Azure AI Foundry: https://ai.azure.com", + "Azure OpenAI docs: https://learn.microsoft.com/azure/ai-foundry/openai/how-to/create-resource", + ].join("\n"); +} + +export async function selectFoundryResource( + ctx: ProviderAuthContext, + selectedSub: AzAccount, +): Promise { + const resources = listFoundryResources(); + if (resources.length === 0) { + throw new Error(buildCreateFoundryHint(selectedSub)); + } + if (resources.length === 1) { + const only = resources[0]!; + await ctx.prompter.note( + `Using ${only.kind === "AIServices" ? "Azure AI Foundry" : "Azure OpenAI"} resource: ${only.accountName}`, + "Foundry Resource", + ); + return only; + } + const selectedResourceId = await ctx.prompter.select({ + message: "Select Azure AI Foundry / Azure OpenAI resource", + options: resources.map((resource) => ({ + value: resource.id, + label: `${resource.accountName} (${resource.kind === "AIServices" ? "Azure AI Foundry" : "Azure OpenAI"}${resource.location ? `, ${resource.location}` : ""})`, + hint: [ + `RG: ${resource.resourceGroup}`, + resource.projects.length > 0 ? `${resource.projects.length} project(s)` : undefined, + ] + .filter(Boolean) + .join(" | "), + })), + }); + return resources.find((resource) => resource.id === selectedResourceId) ?? resources[0]!; +} + +export async function selectFoundryDeployment( + ctx: ProviderAuthContext, + resource: FoundryResourceOption, +): Promise { + const deployments = listResourceDeployments(resource); + if (deployments.length === 0) { + throw new Error( + [ + `No model deployments were found in ${resource.accountName}.`, + "Deploy a model in Azure AI Foundry or Azure OpenAI, then rerun onboard.", + ].join("\n"), + ); + } + if (deployments.length === 1) { + const only = deployments[0]!; + await ctx.prompter.note(`Using deployment: ${only.name}`, "Model Deployment"); + return only; + } + const selectedDeploymentName = await ctx.prompter.select({ + message: "Select model deployment", + options: deployments.map((deployment) => ({ + value: deployment.name, + label: deployment.name, + hint: [deployment.modelName, deployment.modelVersion, deployment.sku].filter(Boolean).join(" | "), + })), + }); + return deployments.find((deployment) => deployment.name === selectedDeploymentName) ?? deployments[0]!; +} + +export async function promptEndpointAndModelManually( + ctx: ProviderAuthContext, +): Promise { + const endpoint = String( + await ctx.prompter.text({ + message: "Microsoft Foundry endpoint URL", + placeholder: "https://xxx.openai.azure.com or https://xxx.services.ai.azure.com", + validate: (v) => { + const val = String(v ?? "").trim(); + if (!val) return "Endpoint URL is required"; + try { + new URL(val); + } catch { + return "Invalid URL"; + } + return undefined; + }, + }), + ).trim(); + const modelId = String( + await ctx.prompter.text({ + message: "Default model/deployment name", + placeholder: "gpt-4o", + validate: (v) => { + const val = String(v ?? "").trim(); + if (!val) return "Model ID is required"; + return undefined; + }, + }), + ).trim(); + const modelNameHintInput = String( + await ctx.prompter.text({ + message: "Underlying Azure model family (optional)", + initialValue: modelId, + placeholder: "gpt-5.4, gpt-4o, etc.", + }), + ).trim(); + return { + endpoint, + modelId, + modelNameHint: modelNameHintInput || modelId, + }; +} + +export async function promptApiKeyEndpointAndModel(ctx: ProviderAuthContext): Promise { + const endpoint = String( + await ctx.prompter.text({ + message: "Microsoft Foundry endpoint URL", + placeholder: "https://xxx.openai.azure.com or https://xxx.services.ai.azure.com", + initialValue: process.env.AZURE_OPENAI_ENDPOINT, + validate: (v) => { + const val = String(v ?? "").trim(); + if (!val) return "Endpoint URL is required"; + try { + new URL(val); + } catch { + return "Invalid URL"; + } + return undefined; + }, + }), + ).trim(); + const modelId = String( + await ctx.prompter.text({ + message: "Default model/deployment name", + initialValue: "gpt-4o", + validate: (v) => { + const val = String(v ?? "").trim(); + if (!val) return "Model ID is required"; + return undefined; + }, + }), + ).trim(); + const modelNameHintInput = String( + await ctx.prompter.text({ + message: "Underlying Azure model family (optional)", + initialValue: modelId, + placeholder: "gpt-5.4, gpt-4o, etc.", + }), + ).trim(); + return { + endpoint, + modelId, + modelNameHint: modelNameHintInput || modelId, + }; +} + +export function buildFoundryConnectionTest(params: { + endpoint: string; + modelId: string; + modelNameHint?: string | null; +}): { url: string; body: Record } { + const baseUrl = buildFoundryProviderBaseUrl(params.endpoint, params.modelId, params.modelNameHint); + if (resolveFoundryApi(params.modelId, params.modelNameHint) === DEFAULT_GPT5_API) { + return { + url: `${baseUrl}/responses?api-version=2025-04-01-preview`, + body: { + model: params.modelId, + input: "hi", + max_output_tokens: 1, + }, + }; + } + return { + url: `${baseUrl}/chat/completions?api-version=2024-12-01-preview`, + body: { + messages: [{ role: "user", content: "hi" }], + max_tokens: 1, + }, + }; +} + +export function extractTenantSuggestions(rawMessage: string): Array<{ id: string; label?: string }> { + const suggestions: Array<{ id: string; label?: string }> = []; + const seen = new Set(); + const regex = /([0-9a-fA-F-]{36})(?:\s+'([^'\r\n]+)')?/g; + for (const match of rawMessage.matchAll(regex)) { + const id = match[1]?.trim(); + if (!id || seen.has(id)) { + continue; + } + seen.add(id); + suggestions.push({ + id, + ...(match[2]?.trim() ? { label: match[2].trim() } : {}), + }); + } + return suggestions; +} + +export async function promptTenantId( + ctx: ProviderAuthContext, + params?: { + suggestions?: Array<{ id: string; label?: string }>; + required?: boolean; + reason?: string; + }, +): Promise { + const suggestionLines = + params?.suggestions && params.suggestions.length > 0 + ? params.suggestions.map((entry) => `- ${entry.id}${entry.label ? ` (${entry.label})` : ""}`) + : []; + if (params?.reason || suggestionLines.length > 0) { + await ctx.prompter.note( + [ + params?.reason, + suggestionLines.length > 0 ? "Suggested tenants:" : undefined, + ...suggestionLines, + ] + .filter(Boolean) + .join("\n"), + "Azure Tenant", + ); + } + const tenantId = String( + await ctx.prompter.text({ + message: params?.required ? "Azure tenant ID" : "Azure tenant ID (optional)", + placeholder: params?.suggestions?.[0]?.id ?? "00000000-0000-0000-0000-000000000000", + validate: (value) => { + const trimmed = String(value ?? "").trim(); + if (!trimmed) { + return params?.required ? "Tenant ID is required" : undefined; + } + return /^[0-9a-fA-F-]{36}$/.test(trimmed) ? undefined : "Enter a valid tenant ID"; + }, + }), + ).trim(); + return tenantId || undefined; +} + +export async function loginWithTenantFallback( + ctx: ProviderAuthContext, +): Promise<{ account: AzAccount | null; tenantId?: string }> { + try { + await azLoginDeviceCode(); + return { account: getLoggedInAccount() }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const isAzureTenantError = + /AADSTS\d+/i.test(message) || + /no subscriptions found/i.test(message) || + /Please provide a valid tenant/i.test(message) || + /tenant.*not found/i.test(message); + if (!isAzureTenantError) { + throw error; + } + const tenantId = await promptTenantId(ctx, { + suggestions: extractTenantSuggestions(message), + required: true, + reason: + "Azure login needs a tenant-scoped retry. This often happens when your tenant requires MFA or your account has no Azure subscriptions.", + }); + await azLoginDeviceCodeWithOptions({ + tenantId, + allowNoSubscriptions: true, + }); + return { + account: getLoggedInAccount(), + tenantId, + }; + } +} + +export async function testFoundryConnection(params: { + ctx: ProviderAuthContext; + endpoint: string; + modelId: string; + modelNameHint?: string; + subscriptionId?: string; + tenantId?: string; +}): Promise { + try { + const { accessToken } = getAccessTokenResult({ + subscriptionId: params.subscriptionId, + tenantId: params.tenantId, + }); + const testRequest = buildFoundryConnectionTest({ + endpoint: params.endpoint, + modelId: params.modelId, + modelNameHint: params.modelNameHint, + }); + const res = await fetch(testRequest.url, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(testRequest.body), + }); + if (res.status === 400) { + const body = await res.text().catch(() => ""); + await params.ctx.prompter.note( + `Endpoint is reachable but returned 400 Bad Request - check your deployment name and API version.\n${body.slice(0, 200)}`, + "Connection Test", + ); + } else if (!res.ok) { + const body = await res.text().catch(() => ""); + await params.ctx.prompter.note( + `Warning: test request returned ${res.status}. ${body.slice(0, 200)}\nProceeding anyway - you can fix the endpoint later.`, + "Connection Test", + ); + } else { + await params.ctx.prompter.note("Connection test successful!", "✓"); + } + } catch (err) { + await params.ctx.prompter.note( + `Warning: connection test failed: ${String(err)}\nProceeding anyway.`, + "Connection Test", + ); + } +} diff --git a/extensions/microsoft-foundry/provider.ts b/extensions/microsoft-foundry/provider.ts new file mode 100644 index 00000000000..95f5adf93d3 --- /dev/null +++ b/extensions/microsoft-foundry/provider.ts @@ -0,0 +1,81 @@ +import type { ProviderNormalizeResolvedModelContext } from "openclaw/plugin-sdk/core"; +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-models"; +import type { ProviderModelSelectedContext } from "../../src/plugins/types.js"; +import type { ModelProviderConfig } from "../../src/config/types.models.js"; +import { apiKeyAuthMethod, entraIdAuthMethod } from "./auth.js"; +import { prepareFoundryRuntimeAuth } from "./runtime.js"; +import { + PROVIDER_ID, + applyFoundryProfileBinding, + applyFoundryProviderConfig, + buildFoundryModelCompat, + buildFoundryProviderBaseUrl, + extractFoundryEndpoint, + normalizeFoundryEndpoint, + resolveConfiguredModelNameHint, + resolveFoundryApi, + resolveFoundryTargetProfileId, +} from "./shared.js"; + +export function buildMicrosoftFoundryProvider(): ProviderPlugin { + return { + id: PROVIDER_ID, + label: "Microsoft Foundry", + docsPath: "/providers/azure", + envVars: ["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT"], + auth: [entraIdAuthMethod, apiKeyAuthMethod], + capabilities: { + providerFamily: "openai" as const, + }, + onModelSelected: async (ctx: ProviderModelSelectedContext) => { + const providerConfig = ctx.config.models?.providers?.[PROVIDER_ID]; + if (!providerConfig || !ctx.model.startsWith(`${PROVIDER_ID}/`)) { + return; + } + const selectedModelId = ctx.model.slice(`${PROVIDER_ID}/`.length); + const existingModel = providerConfig.models.find((model: { id: string }) => model.id === selectedModelId); + const selectedModelNameHint = resolveConfiguredModelNameHint(selectedModelId, existingModel?.name); + const selectedModelCompat = buildFoundryModelCompat(selectedModelId, selectedModelNameHint); + const providerEndpoint = normalizeFoundryEndpoint(providerConfig.baseUrl ?? ""); + const nextProviderConfig: ModelProviderConfig = { + ...providerConfig, + baseUrl: buildFoundryProviderBaseUrl(providerEndpoint, selectedModelId, selectedModelNameHint), + api: resolveFoundryApi(selectedModelId, selectedModelNameHint), + models: [ + { + ...(existingModel ?? { + id: selectedModelId, + name: selectedModelId, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }), + ...(selectedModelCompat ? { compat: selectedModelCompat } : {}), + }, + ], + }; + const targetProfileId = resolveFoundryTargetProfileId(ctx.config, ctx.agentDir); + if (targetProfileId) { + applyFoundryProfileBinding(ctx.config, targetProfileId); + } + applyFoundryProviderConfig(ctx.config, nextProviderConfig); + }, + normalizeResolvedModel: ({ modelId, model }: ProviderNormalizeResolvedModelContext) => { + const endpoint = extractFoundryEndpoint(String(model.baseUrl ?? "")); + if (!endpoint) { + return model; + } + const modelNameHint = resolveConfiguredModelNameHint(modelId, model.name); + const compat = buildFoundryModelCompat(modelId, modelNameHint); + return { + ...model, + api: resolveFoundryApi(modelId, modelNameHint), + baseUrl: buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint), + ...(compat ? { compat } : {}), + }; + }, + prepareRuntimeAuth: prepareFoundryRuntimeAuth, + }; +} diff --git a/extensions/microsoft-foundry/runtime.ts b/extensions/microsoft-foundry/runtime.ts new file mode 100644 index 00000000000..072e4177080 --- /dev/null +++ b/extensions/microsoft-foundry/runtime.ts @@ -0,0 +1,85 @@ +import { ensureAuthProfileStore } from "openclaw/plugin-sdk/provider-auth"; +import type { ProviderPrepareRuntimeAuthContext } from "openclaw/plugin-sdk/core"; +import { getAccessTokenResultAsync } from "./cli.js"; +import { + type CachedTokenEntry, + TOKEN_REFRESH_MARGIN_MS, + buildFoundryProviderBaseUrl, + extractFoundryEndpoint, + getFoundryTokenCacheKey, + resolveConfiguredModelNameHint, +} from "./shared-runtime.js"; + +const cachedTokens = new Map(); +const refreshPromises = new Map>(); + +async function refreshEntraToken(params?: { + subscriptionId?: string; + tenantId?: string; +}): Promise<{ apiKey: string; expiresAt: number }> { + const result = await getAccessTokenResultAsync(params); + const rawExpiry = result.expiresOn ? new Date(result.expiresOn).getTime() : Number.NaN; + const expiresAt = Number.isFinite(rawExpiry) ? rawExpiry : Date.now() + 55 * 60 * 1000; + cachedTokens.set(getFoundryTokenCacheKey(params), { + token: result.accessToken, + expiresAt, + }); + return { apiKey: result.accessToken, expiresAt }; +} + +export async function prepareFoundryRuntimeAuth(ctx: ProviderPrepareRuntimeAuthContext) { + if (ctx.apiKey !== "__entra_id_dynamic__") { + return null; + } + try { + const authStore = ensureAuthProfileStore(ctx.agentDir, { + allowKeychainPrompt: false, + }); + const credential = ctx.profileId ? authStore.profiles[ctx.profileId] : undefined; + const metadata = credential?.type === "api_key" ? credential.metadata : undefined; + const modelId = + typeof ctx.modelId === "string" && ctx.modelId.trim().length > 0 + ? ctx.modelId.trim() + : typeof metadata?.modelId === "string" && metadata.modelId.trim().length > 0 + ? metadata.modelId.trim() + : ctx.modelId; + const activeModelNameHint = ctx.modelId === metadata?.modelId ? metadata?.modelName : undefined; + const modelNameHint = resolveConfiguredModelNameHint(modelId, ctx.model.name ?? activeModelNameHint); + const endpoint = + typeof metadata?.endpoint === "string" && metadata.endpoint.trim().length > 0 + ? metadata.endpoint.trim() + : extractFoundryEndpoint(ctx.model.baseUrl ?? ""); + const baseUrl = endpoint ? buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint) : undefined; + const cacheKey = getFoundryTokenCacheKey({ + subscriptionId: metadata?.subscriptionId, + tenantId: metadata?.tenantId, + }); + const cachedToken = cachedTokens.get(cacheKey); + if (cachedToken && cachedToken.expiresAt > Date.now() + TOKEN_REFRESH_MARGIN_MS) { + return { + apiKey: cachedToken.token, + expiresAt: cachedToken.expiresAt, + ...(baseUrl ? { baseUrl } : {}), + }; + } + let refreshPromise = refreshPromises.get(cacheKey); + if (!refreshPromise) { + refreshPromise = refreshEntraToken({ + subscriptionId: metadata?.subscriptionId, + tenantId: metadata?.tenantId, + }).finally(() => { + refreshPromises.delete(cacheKey); + }); + refreshPromises.set(cacheKey, refreshPromise); + } + const token = await refreshPromise; + return { + ...token, + ...(baseUrl ? { baseUrl } : {}), + }; + } catch (err) { + throw new Error( + `Failed to refresh Azure Entra ID token via az CLI: ${String(err)}\nMake sure you are logged in: az login --use-device-code`, + ); + } +} diff --git a/extensions/microsoft-foundry/shared-runtime.ts b/extensions/microsoft-foundry/shared-runtime.ts new file mode 100644 index 00000000000..cc5a224668f --- /dev/null +++ b/extensions/microsoft-foundry/shared-runtime.ts @@ -0,0 +1,14 @@ +export { + TOKEN_REFRESH_MARGIN_MS, + buildFoundryProviderBaseUrl, + extractFoundryEndpoint, + resolveConfiguredModelNameHint, + type CachedTokenEntry, +} from "./shared.js"; + +export function getFoundryTokenCacheKey(params?: { + subscriptionId?: string; + tenantId?: string; +}): string { + return `${params?.subscriptionId ?? ""}:${params?.tenantId ?? ""}`; +} diff --git a/extensions/microsoft-foundry/shared.ts b/extensions/microsoft-foundry/shared.ts new file mode 100644 index 00000000000..8de4ac17bce --- /dev/null +++ b/extensions/microsoft-foundry/shared.ts @@ -0,0 +1,312 @@ +import { + applyAuthProfileConfig, + buildApiKeyCredential, + ensureAuthProfileStore, + type ProviderAuthResult, + type SecretInput, +} from "openclaw/plugin-sdk/provider-auth"; +import type { ModelCompatConfig, ModelProviderConfig } from "../../src/config/types.models.js"; +import type { ProviderModelSelectedContext } from "../../src/plugins/types.js"; + +export const PROVIDER_ID = "microsoft-foundry"; +export const DEFAULT_API = "openai-completions"; +export const DEFAULT_GPT5_API = "openai-responses"; +export const COGNITIVE_SERVICES_RESOURCE = "https://cognitiveservices.azure.com"; +export const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000; + +export interface AzAccount { + name: string; + id: string; + tenantId?: string; + user?: { name?: string }; + state?: string; + isDefault?: boolean; +} + +export interface AzAccessToken { + accessToken: string; + expiresOn?: string; +} + +export interface AzCognitiveAccount { + id: string; + name: string; + kind: string; + location?: string; + resourceGroup?: string; + endpoint?: string | null; + customSubdomain?: string | null; + projects?: string[] | null; +} + +export interface FoundryResourceOption { + id: string; + accountName: string; + kind: "AIServices" | "OpenAI"; + location?: string; + resourceGroup: string; + endpoint: string; + projects: string[]; +} + +export interface AzDeploymentSummary { + name: string; + modelName?: string; + modelVersion?: string; + state?: string; + sku?: string; +} + +export type FoundrySelection = { + endpoint: string; + modelId: string; + modelNameHint?: string; +}; + +export type CachedTokenEntry = { + token: string; + expiresAt: number; +}; + +export type FoundryProviderApi = typeof DEFAULT_API | typeof DEFAULT_GPT5_API; + +export function isGpt5FamilyName(value?: string | null): boolean { + return typeof value === "string" && /^gpt-5(?:$|[-.])/i.test(value.trim()); +} + +export function isGpt5FamilyDeployment(modelId: string, modelNameHint?: string | null): boolean { + return isGpt5FamilyName(modelId) || isGpt5FamilyName(modelNameHint); +} + +export function normalizeFoundryEndpoint(endpoint: string): string { + const trimmed = endpoint.trim().replace(/\/+$/, ""); + return trimmed.replace(/\/openai(?:\/v1|\/deployments\/[^/]+)?$/i, ""); +} + +export function buildAzureBaseUrl(endpoint: string, modelId: string): string { + const base = normalizeFoundryEndpoint(endpoint); + if (base.includes("/openai/deployments/")) return base; + return `${base}/openai/deployments/${modelId}`; +} + +export function buildFoundryResponsesBaseUrl(endpoint: string): string { + const base = normalizeFoundryEndpoint(endpoint); + return base.endsWith("/openai/v1") ? base : `${base}/openai/v1`; +} + +export function resolveFoundryApi( + modelId: string, + modelNameHint?: string | null, +): FoundryProviderApi { + return isGpt5FamilyDeployment(modelId, modelNameHint) ? DEFAULT_GPT5_API : DEFAULT_API; +} + +export function buildFoundryProviderBaseUrl( + endpoint: string, + modelId: string, + modelNameHint?: string | null, +): string { + return resolveFoundryApi(modelId, modelNameHint) === DEFAULT_GPT5_API + ? buildFoundryResponsesBaseUrl(endpoint) + : buildAzureBaseUrl(endpoint, modelId); +} + +export function extractFoundryEndpoint(baseUrl: string): string | undefined { + try { + return new URL(baseUrl).origin; + } catch { + return undefined; + } +} + +export function buildFoundryModelCompat( + modelId: string, + modelNameHint?: string | null, +): ModelCompatConfig | undefined { + if (!isGpt5FamilyDeployment(modelId, modelNameHint)) { + return undefined; + } + return { + maxTokensField: "max_completion_tokens", + }; +} + +export function resolveConfiguredModelNameHint( + modelId: string, + modelNameHint?: string | null, +): string | undefined { + const trimmedName = typeof modelNameHint === "string" ? modelNameHint.trim() : ""; + if (trimmedName) { + return trimmedName; + } + const trimmedId = modelId.trim(); + return trimmedId ? trimmedId : undefined; +} + +export function buildFoundryProviderConfig( + endpoint: string, + modelId: string, + modelNameHint?: string | null, +): ModelProviderConfig { + const compat = buildFoundryModelCompat(modelId, modelNameHint); + return { + baseUrl: buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint), + api: resolveFoundryApi(modelId, modelNameHint), + models: [ + { + id: modelId, + name: + typeof modelNameHint === "string" && modelNameHint.trim().length > 0 + ? modelNameHint.trim() + : modelId, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + ...(compat ? { compat } : {}), + }, + ], + }; +} + +export function normalizeEndpointOrigin(rawUrl: string | null | undefined): string | undefined { + if (!rawUrl) { + return undefined; + } + try { + return new URL(rawUrl).origin; + } catch { + return undefined; + } +} + +function buildFoundryCredentialMetadata(params: { + authMethod: "api-key" | "entra-id"; + endpoint: string; + modelId: string; + modelNameHint?: string | null; + subscriptionId?: string; + subscriptionName?: string; + tenantId?: string; +}): Record { + const metadata: Record = { + authMethod: params.authMethod, + endpoint: params.endpoint, + modelId: params.modelId, + }; + const modelName = resolveConfiguredModelNameHint(params.modelId, params.modelNameHint); + if (modelName) { + metadata.modelName = modelName; + } + if (params.subscriptionId) { + metadata.subscriptionId = params.subscriptionId; + } + if (params.subscriptionName) { + metadata.subscriptionName = params.subscriptionName; + } + if (params.tenantId) { + metadata.tenantId = params.tenantId; + } + return metadata; +} + +export function buildFoundryAuthResult(params: { + profileId: string; + apiKey: SecretInput; + secretInputMode?: "plaintext" | "ref"; + endpoint: string; + modelId: string; + modelNameHint?: string | null; + authMethod: "api-key" | "entra-id"; + subscriptionId?: string; + subscriptionName?: string; + tenantId?: string; + notes?: string[]; +}): ProviderAuthResult { + return { + profiles: [ + { + profileId: params.profileId, + credential: buildApiKeyCredential( + PROVIDER_ID, + params.apiKey, + buildFoundryCredentialMetadata({ + authMethod: params.authMethod, + endpoint: params.endpoint, + modelId: params.modelId, + modelNameHint: params.modelNameHint, + subscriptionId: params.subscriptionId, + subscriptionName: params.subscriptionName, + tenantId: params.tenantId, + }), + params.secretInputMode ? { secretInputMode: params.secretInputMode } : undefined, + ), + }, + ], + configPatch: { + models: { + providers: { + [PROVIDER_ID]: buildFoundryProviderConfig( + params.endpoint, + params.modelId, + params.modelNameHint, + ), + }, + }, + }, + defaultModel: `${PROVIDER_ID}/${params.modelId}`, + notes: params.notes, + }; +} + +export function applyFoundryProfileBinding( + config: ProviderModelSelectedContext["config"], + profileId: string, +): void { + applyAuthProfileConfig(config, { + profileId, + provider: PROVIDER_ID, + mode: "api_key", + }); +} + +export function applyFoundryProviderConfig( + config: ProviderModelSelectedContext["config"], + providerConfig: ModelProviderConfig, +): void { + config.models ??= {}; + config.models.providers ??= {}; + config.models.providers[PROVIDER_ID] = providerConfig; +} + +export function resolveFoundryTargetProfileId( + config: ProviderModelSelectedContext["config"], + agentDir?: string, +): string | undefined { + const configuredProfiles = config.auth?.profiles ?? {}; + const configuredProfileEntries = Object.entries(configuredProfiles).filter(([, profile]) => { + return profile.provider === PROVIDER_ID; + }); + if (configuredProfileEntries.length === 0) { + return undefined; + } + const configuredProfileId = + config.auth?.order?.[PROVIDER_ID]?.find((profileId) => profileId.trim().length > 0) ?? + (configuredProfileEntries.length === 1 ? configuredProfileEntries[0]?.[0] : undefined); + if (!configuredProfileId || !agentDir) { + return configuredProfileId; + } + const authStore = ensureAuthProfileStore(agentDir, { + allowKeychainPrompt: false, + }); + const credential = authStore.profiles[configuredProfileId]; + const authMethod = credential?.type === "api_key" ? credential.metadata?.authMethod : undefined; + if (authMethod === "api-key") { + return `${PROVIDER_ID}:default`; + } + if (authMethod === "entra-id") { + return `${PROVIDER_ID}:entra`; + } + return configuredProfileId; +}