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; +}