|
| 1 | +import type { |
| 2 | + ProviderAuthContext, |
| 3 | + ProviderAuthMethod, |
| 4 | + ProviderAuthResult, |
| 5 | +} from "openclaw/plugin-sdk/core"; |
| 6 | +import { |
| 7 | + ensureApiKeyFromOptionEnvOrPrompt, |
| 8 | + ensureAuthProfileStore, |
| 9 | + normalizeApiKeyInput, |
| 10 | + normalizeOptionalSecretInput, |
| 11 | + type SecretInput, |
| 12 | + validateApiKeyInput, |
| 13 | +} from "openclaw/plugin-sdk/provider-auth"; |
| 14 | +import { getLoggedInAccount, isAzCliInstalled } from "./cli.js"; |
| 15 | +import { |
| 16 | + loginWithTenantFallback, |
| 17 | + listResourceDeployments, |
| 18 | + promptApiKeyEndpointAndModel, |
| 19 | + promptEndpointAndModelManually, |
| 20 | + promptTenantId, |
| 21 | + selectFoundryDeployment, |
| 22 | + selectFoundryResource, |
| 23 | + listSubscriptions, |
| 24 | + testFoundryConnection, |
| 25 | +} from "./onboard.js"; |
| 26 | +import { |
| 27 | + buildFoundryAuthResult, |
| 28 | + type FoundryProviderApi, |
| 29 | + listConfiguredFoundryProfileIds, |
| 30 | + PROVIDER_ID, |
| 31 | + resolveConfiguredModelNameHint, |
| 32 | + resolveFoundryApi, |
| 33 | +} from "./shared.js"; |
| 34 | + |
| 35 | +export const entraIdAuthMethod: ProviderAuthMethod = { |
| 36 | + id: "entra-id", |
| 37 | + label: "Entra ID (az login)", |
| 38 | + hint: "Use your Azure login — no API key needed", |
| 39 | + kind: "custom", |
| 40 | + wizard: { |
| 41 | + choiceId: "microsoft-foundry-entra", |
| 42 | + choiceLabel: "Microsoft Foundry (Entra ID / az login)", |
| 43 | + choiceHint: "Use your Azure login — no API key needed", |
| 44 | + groupId: "microsoft-foundry", |
| 45 | + groupLabel: "Microsoft Foundry", |
| 46 | + groupHint: "Entra ID + API key", |
| 47 | + }, |
| 48 | + run: async (ctx: ProviderAuthContext): Promise<ProviderAuthResult> => { |
| 49 | + if (!isAzCliInstalled()) { |
| 50 | + throw new Error( |
| 51 | + "Azure CLI (az) is not installed.\nInstall it from https://learn.microsoft.com/cli/azure/install-azure-cli", |
| 52 | + ); |
| 53 | + } |
| 54 | + |
| 55 | + let account = getLoggedInAccount(); |
| 56 | + let tenantId = account?.tenantId; |
| 57 | + if (account) { |
| 58 | + const useExisting = await ctx.prompter.confirm({ |
| 59 | + message: `Already logged in as ${account.user?.name ?? "unknown"} (${account.name}). Use this account?`, |
| 60 | + initialValue: true, |
| 61 | + }); |
| 62 | + if (!useExisting) { |
| 63 | + const loginResult = await loginWithTenantFallback(ctx); |
| 64 | + account = loginResult.account; |
| 65 | + tenantId = loginResult.tenantId ?? loginResult.account?.tenantId; |
| 66 | + } |
| 67 | + } else { |
| 68 | + await ctx.prompter.note( |
| 69 | + "You need to log in to Azure. A device code will be displayed - follow the instructions.", |
| 70 | + "Azure Login", |
| 71 | + ); |
| 72 | + const loginResult = await loginWithTenantFallback(ctx); |
| 73 | + account = loginResult.account; |
| 74 | + tenantId = loginResult.tenantId ?? loginResult.account?.tenantId; |
| 75 | + } |
| 76 | + |
| 77 | + const subs = listSubscriptions(); |
| 78 | + let selectedSub = null; |
| 79 | + if (subs.length === 0) { |
| 80 | + tenantId ??= await promptTenantId(ctx, { |
| 81 | + required: true, |
| 82 | + reason: |
| 83 | + "No enabled Azure subscriptions were found. Continue with tenant-scoped Entra ID auth instead.", |
| 84 | + }); |
| 85 | + await ctx.prompter.note(`Continuing with tenant-scoped auth (${tenantId}).`, "Azure Tenant"); |
| 86 | + } else if (subs.length === 1) { |
| 87 | + selectedSub = subs[0]!; |
| 88 | + tenantId ??= selectedSub.tenantId; |
| 89 | + await ctx.prompter.note( |
| 90 | + `Using subscription: ${selectedSub.name} (${selectedSub.id})`, |
| 91 | + "Subscription", |
| 92 | + ); |
| 93 | + } else { |
| 94 | + const selectedId = await ctx.prompter.select({ |
| 95 | + message: "Select Azure subscription", |
| 96 | + options: subs.map((sub) => ({ |
| 97 | + value: sub.id, |
| 98 | + label: `${sub.name} (${sub.id})`, |
| 99 | + })), |
| 100 | + }); |
| 101 | + selectedSub = subs.find((sub) => sub.id === selectedId)!; |
| 102 | + tenantId ??= selectedSub.tenantId; |
| 103 | + } |
| 104 | + |
| 105 | + let endpoint: string; |
| 106 | + let modelId: string; |
| 107 | + let modelNameHint: string | undefined; |
| 108 | + let api: FoundryProviderApi; |
| 109 | + let discoveredDeployments: |
| 110 | + | Array<{ |
| 111 | + name: string; |
| 112 | + modelName?: string; |
| 113 | + api?: "openai-completions" | "openai-responses"; |
| 114 | + }> |
| 115 | + | undefined; |
| 116 | + if (selectedSub) { |
| 117 | + const useDiscoveredResource = await ctx.prompter.confirm({ |
| 118 | + message: "Discover Microsoft Foundry resources from this subscription?", |
| 119 | + initialValue: true, |
| 120 | + }); |
| 121 | + if (useDiscoveredResource) { |
| 122 | + const selectedResource = await selectFoundryResource(ctx, selectedSub); |
| 123 | + const resourceDeployments = listResourceDeployments(selectedResource, selectedSub.id); |
| 124 | + const selectedDeployment = await selectFoundryDeployment( |
| 125 | + ctx, |
| 126 | + selectedResource, |
| 127 | + resourceDeployments, |
| 128 | + ); |
| 129 | + discoveredDeployments = resourceDeployments.map((deployment) => ({ |
| 130 | + name: deployment.name, |
| 131 | + ...(deployment.modelName ? { modelName: deployment.modelName } : {}), |
| 132 | + api: resolveFoundryApi(deployment.name, deployment.modelName), |
| 133 | + })); |
| 134 | + endpoint = selectedResource.endpoint; |
| 135 | + modelId = selectedDeployment.name; |
| 136 | + modelNameHint = resolveConfiguredModelNameHint(modelId, selectedDeployment.modelName); |
| 137 | + api = resolveFoundryApi(modelId, modelNameHint); |
| 138 | + await ctx.prompter.note( |
| 139 | + [ |
| 140 | + `Resource: ${selectedResource.accountName}`, |
| 141 | + `Endpoint: ${endpoint}`, |
| 142 | + `Deployment: ${modelId}`, |
| 143 | + selectedDeployment.modelName ? `Model: ${selectedDeployment.modelName}` : undefined, |
| 144 | + `API: ${api === "openai-responses" ? "Responses" : "Chat Completions"}`, |
| 145 | + ] |
| 146 | + .filter(Boolean) |
| 147 | + .join("\n"), |
| 148 | + "Microsoft Foundry", |
| 149 | + ); |
| 150 | + } else { |
| 151 | + ({ endpoint, modelId, modelNameHint, api } = await promptEndpointAndModelManually(ctx)); |
| 152 | + } |
| 153 | + } else { |
| 154 | + ({ endpoint, modelId, modelNameHint, api } = await promptEndpointAndModelManually(ctx)); |
| 155 | + } |
| 156 | + |
| 157 | + await testFoundryConnection({ |
| 158 | + ctx, |
| 159 | + endpoint, |
| 160 | + modelId, |
| 161 | + modelNameHint, |
| 162 | + api, |
| 163 | + subscriptionId: selectedSub?.id, |
| 164 | + tenantId, |
| 165 | + }); |
| 166 | + |
| 167 | + return buildFoundryAuthResult({ |
| 168 | + profileId: `${PROVIDER_ID}:entra`, |
| 169 | + apiKey: "__entra_id_dynamic__", |
| 170 | + endpoint, |
| 171 | + modelId, |
| 172 | + modelNameHint, |
| 173 | + api, |
| 174 | + authMethod: "entra-id", |
| 175 | + ...(selectedSub?.id ? { subscriptionId: selectedSub.id } : {}), |
| 176 | + ...(selectedSub?.name ? { subscriptionName: selectedSub.name } : {}), |
| 177 | + ...(tenantId ? { tenantId } : {}), |
| 178 | + currentProviderProfileIds: listConfiguredFoundryProfileIds(ctx.config), |
| 179 | + currentPluginsAllow: ctx.config.plugins?.allow, |
| 180 | + ...(discoveredDeployments ? { deployments: discoveredDeployments } : {}), |
| 181 | + notes: [ |
| 182 | + ...(selectedSub?.name ? [`Subscription: ${selectedSub.name}`] : []), |
| 183 | + ...(tenantId ? [`Tenant: ${tenantId}`] : []), |
| 184 | + `Endpoint: ${endpoint}`, |
| 185 | + `Model: ${modelId}`, |
| 186 | + "Token is refreshed automatically via az CLI - keep az login active.", |
| 187 | + ], |
| 188 | + }); |
| 189 | + }, |
| 190 | +}; |
| 191 | + |
| 192 | +export const apiKeyAuthMethod: ProviderAuthMethod = { |
| 193 | + id: "api-key", |
| 194 | + label: "Azure OpenAI API key", |
| 195 | + hint: "Direct Azure OpenAI API key", |
| 196 | + kind: "api_key", |
| 197 | + wizard: { |
| 198 | + choiceId: "microsoft-foundry-apikey", |
| 199 | + choiceLabel: "Microsoft Foundry (API key)", |
| 200 | + groupId: "microsoft-foundry", |
| 201 | + groupLabel: "Microsoft Foundry", |
| 202 | + groupHint: "Entra ID + API key", |
| 203 | + }, |
| 204 | + run: async (ctx) => { |
| 205 | + const authStore = ensureAuthProfileStore(ctx.agentDir, { |
| 206 | + allowKeychainPrompt: false, |
| 207 | + }); |
| 208 | + const existing = authStore.profiles[`${PROVIDER_ID}:default`]; |
| 209 | + const existingMetadata = existing?.type === "api_key" ? existing.metadata : undefined; |
| 210 | + let capturedSecretInput: SecretInput | undefined; |
| 211 | + let capturedCredential = false; |
| 212 | + let capturedMode: "plaintext" | "ref" | undefined; |
| 213 | + await ensureApiKeyFromOptionEnvOrPrompt({ |
| 214 | + token: normalizeOptionalSecretInput(ctx.opts?.azureOpenaiApiKey), |
| 215 | + tokenProvider: PROVIDER_ID, |
| 216 | + secretInputMode: |
| 217 | + ctx.allowSecretRefPrompt === false |
| 218 | + ? (ctx.secretInputMode ?? "plaintext") |
| 219 | + : ctx.secretInputMode, |
| 220 | + config: ctx.config, |
| 221 | + expectedProviders: [PROVIDER_ID], |
| 222 | + provider: PROVIDER_ID, |
| 223 | + envLabel: "AZURE_OPENAI_API_KEY", |
| 224 | + promptMessage: "Enter Azure OpenAI API key", |
| 225 | + normalize: normalizeApiKeyInput, |
| 226 | + validate: validateApiKeyInput, |
| 227 | + prompter: ctx.prompter, |
| 228 | + setCredential: async (apiKey, mode) => { |
| 229 | + capturedSecretInput = apiKey; |
| 230 | + capturedCredential = true; |
| 231 | + capturedMode = mode; |
| 232 | + }, |
| 233 | + }); |
| 234 | + if (!capturedCredential) { |
| 235 | + throw new Error("Missing Azure OpenAI API key."); |
| 236 | + } |
| 237 | + const selection = await promptApiKeyEndpointAndModel(ctx); |
| 238 | + return buildFoundryAuthResult({ |
| 239 | + profileId: `${PROVIDER_ID}:default`, |
| 240 | + apiKey: capturedSecretInput ?? "", |
| 241 | + ...(capturedMode ? { secretInputMode: capturedMode } : {}), |
| 242 | + endpoint: selection.endpoint, |
| 243 | + modelId: selection.modelId, |
| 244 | + modelNameHint: |
| 245 | + selection.modelNameHint ?? existingMetadata?.modelName ?? existingMetadata?.modelId, |
| 246 | + api: selection.api, |
| 247 | + authMethod: "api-key", |
| 248 | + currentProviderProfileIds: listConfiguredFoundryProfileIds(ctx.config), |
| 249 | + currentPluginsAllow: ctx.config.plugins?.allow, |
| 250 | + notes: [`Endpoint: ${selection.endpoint}`, `Model: ${selection.modelId}`], |
| 251 | + }); |
| 252 | + }, |
| 253 | +}; |
0 commit comments