import type { ProviderAuthContext } from "openclaw/plugin-sdk/core"; import { azLoginDeviceCode, azLoginDeviceCodeWithOptions, execAz, getAccessTokenResult, getLoggedInAccount, listSubscriptions, } from "./cli.js"; import { type AzAccount, type AzCognitiveAccount, type AzDeploymentSummary, type FoundryProviderApi, type FoundryResourceOption, type FoundrySelection, buildFoundryProviderBaseUrl, extractFoundryEndpoint, requiresFoundryMaxCompletionTokens, DEFAULT_API, DEFAULT_GPT5_API, usesFoundryResponsesByDefault, } from "./shared.js"; export { listSubscriptions } from "./cli.js"; export function listFoundryResources(subscriptionId?: string): FoundryResourceOption[] { try { const accounts = JSON.parse( execAz([ "cognitiveservices", "account", "list", ...(subscriptionId ? ["--subscription", subscriptionId] : []), "--query", "[].{id:id,name:name,kind:kind,location:location,resourceGroup:resourceGroup,endpoint:properties.endpoint,customSubdomain:properties.customSubDomainName,projects:properties.associatedProjects}", "--output", "json", ]), ) as AzCognitiveAccount[]; const resources: FoundryResourceOption[] = []; for (const account of accounts) { if (!account.resourceGroup) { continue; } if (account.kind === "OpenAI") { const endpoint = extractFoundryEndpoint(account.endpoint); if (!endpoint) { continue; } resources.push({ id: account.id, accountName: account.name, kind: "OpenAI", location: account.location, resourceGroup: account.resourceGroup, endpoint, projects: [], }); continue; } if (account.kind !== "AIServices") { continue; } const endpoint = account.customSubdomain?.trim() ? `https://${account.customSubdomain.trim()}.services.ai.azure.com` : undefined; if (!endpoint) { continue; } resources.push({ id: account.id, accountName: account.name, kind: "AIServices", location: account.location, resourceGroup: account.resourceGroup, endpoint, projects: Array.isArray(account.projects) ? account.projects.filter((project): project is string => typeof project === "string") : [], }); } return resources; } catch { return []; } } export function listResourceDeployments( resource: FoundryResourceOption, subscriptionId?: string, ): AzDeploymentSummary[] { try { const deployments = JSON.parse( execAz([ "cognitiveservices", "account", "deployment", "list", ...(subscriptionId ? ["--subscription", subscriptionId] : []), "-g", resource.resourceGroup, "-n", resource.accountName, "--query", "[].{name:name,modelName:properties.model.name,modelVersion:properties.model.version,state:properties.provisioningState,sku:sku.name}", "--output", "json", ]), ) as AzDeploymentSummary[]; return deployments.filter((deployment) => deployment.state === "Succeeded"); } catch { return []; } } export function buildCreateFoundryHint(selectedSub: AzAccount): string { return [ `No Azure AI Foundry or Azure OpenAI resources were found in subscription ${selectedSub.name} (${selectedSub.id}).`, "Create one in Azure AI Foundry or Azure Portal, then rerun onboard.", "Azure AI Foundry: https://ai.azure.com", "Azure OpenAI docs: https://learn.microsoft.com/azure/ai-foundry/openai/how-to/create-resource", ].join("\n"); } export async function selectFoundryResource( ctx: ProviderAuthContext, selectedSub: AzAccount, ): Promise { const resources = listFoundryResources(selectedSub.id); if (resources.length === 0) { throw new Error(buildCreateFoundryHint(selectedSub)); } if (resources.length === 1) { const only = resources[0]!; await ctx.prompter.note( `Using ${only.kind === "AIServices" ? "Azure AI Foundry" : "Azure OpenAI"} resource: ${only.accountName}`, "Foundry Resource", ); return only; } const selectedResourceId = await ctx.prompter.select({ message: "Select Azure AI Foundry / Azure OpenAI resource", options: resources.map((resource) => ({ value: resource.id, label: `${resource.accountName} (${resource.kind === "AIServices" ? "Azure AI Foundry" : "Azure OpenAI"}${resource.location ? `, ${resource.location}` : ""})`, hint: [ `RG: ${resource.resourceGroup}`, resource.projects.length > 0 ? `${resource.projects.length} project(s)` : undefined, ] .filter(Boolean) .join(" | "), })), }); return resources.find((resource) => resource.id === selectedResourceId) ?? resources[0]!; } export async function selectFoundryDeployment( ctx: ProviderAuthContext, resource: FoundryResourceOption, deployments: AzDeploymentSummary[], ): Promise { if (deployments.length === 0) { throw new Error( [ `No model deployments were found in ${resource.accountName}.`, "Deploy a model in Azure AI Foundry or Azure OpenAI, then rerun onboard.", ].join("\n"), ); } if (deployments.length === 1) { const only = deployments[0]!; await ctx.prompter.note(`Using deployment: ${only.name}`, "Model Deployment"); return only; } const selectedDeploymentName = await ctx.prompter.select({ message: "Select model deployment", options: deployments.map((deployment) => ({ value: deployment.name, label: deployment.name, hint: [deployment.modelName, deployment.modelVersion, deployment.sku] .filter(Boolean) .join(" | "), })), }); return ( deployments.find((deployment) => deployment.name === selectedDeploymentName) ?? deployments[0]! ); } async function promptFoundryApi( ctx: ProviderAuthContext, initialApi: FoundryProviderApi, ): Promise { return await ctx.prompter.select({ message: "Select request API", options: [ { value: DEFAULT_GPT5_API, label: "Responses API", hint: "Recommended for Azure OpenAI GPT, o-series, and Codex deployments", }, { value: "openai-completions", label: "Chat Completions API", hint: "Use for Foundry models that only expose chat/completions semantics", }, ], initialValue: initialApi, }); } type ManualFoundryModelFamilyChoice = "reasoning-family" | "other-chat"; async function promptFoundryModelFamily( ctx: ProviderAuthContext, ): Promise { return await ctx.prompter.select({ message: "Model family", options: [ { value: "reasoning-family", label: "GPT-5 series / o-series / Codex", hint: "Use for Azure OpenAI reasoning and Codex deployments", }, { value: "other-chat", label: "Other chat model", hint: "Use for other chat/completions style Foundry models", }, ], initialValue: "reasoning-family", }); } async function promptEndpointAndModelBase( ctx: ProviderAuthContext, options?: { endpointInitialValue?: string; modelInitialValue?: string; }, ): Promise { const endpoint = String( await ctx.prompter.text({ message: "Microsoft Foundry endpoint URL", placeholder: "https://xxx.openai.azure.com or https://xxx.services.ai.azure.com", ...(options?.endpointInitialValue ? { initialValue: options.endpointInitialValue } : {}), validate: (v) => { const val = String(v ?? "").trim(); if (!val) return "Endpoint URL is required"; try { new URL(val); } catch { return "Invalid URL"; } return undefined; }, }), ).trim(); const modelId = String( await ctx.prompter.text({ message: "Default model/deployment name", ...(options?.modelInitialValue ? { initialValue: options.modelInitialValue } : {}), placeholder: "gpt-4o", validate: (v) => { const val = String(v ?? "").trim(); if (!val) return "Model ID is required"; return undefined; }, }), ).trim(); const familyChoice = await promptFoundryModelFamily(ctx); const resolvedModelName = familyChoice === "reasoning-family" ? usesFoundryResponsesByDefault(modelId) || requiresFoundryMaxCompletionTokens(modelId) ? modelId : "gpt-5" : undefined; const api = await promptFoundryApi( ctx, familyChoice === "reasoning-family" ? DEFAULT_GPT5_API : DEFAULT_API, ); return { endpoint, modelId, ...(resolvedModelName ? { modelNameHint: resolvedModelName } : {}), api, }; } export async function promptEndpointAndModelManually( ctx: ProviderAuthContext, ): Promise { return promptEndpointAndModelBase(ctx); } export async function promptApiKeyEndpointAndModel( ctx: ProviderAuthContext, ): Promise { return promptEndpointAndModelBase(ctx, { endpointInitialValue: process.env.AZURE_OPENAI_ENDPOINT, modelInitialValue: "gpt-4o", }); } export function buildFoundryConnectionTest(params: { endpoint: string; modelId: string; modelNameHint?: string | null; api: FoundryProviderApi; }): { url: string; body: Record } { const baseUrl = buildFoundryProviderBaseUrl( params.endpoint, params.modelId, params.modelNameHint, params.api, ); if (params.api === DEFAULT_GPT5_API) { return { url: `${baseUrl}/responses`, body: { model: params.modelId, input: "hi", max_output_tokens: 16, }, }; } return { url: `${baseUrl}/chat/completions`, body: { model: params.modelId, messages: [{ role: "user", content: "hi" }], max_tokens: 1, }, }; } export function extractTenantSuggestions( rawMessage: string, ): Array<{ id: string; label?: string }> { const suggestions: Array<{ id: string; label?: string }> = []; const seen = new Set(); const regex = /([0-9a-fA-F-]{36})(?:\s+'([^'\r\n]+)')?/g; for (const match of rawMessage.matchAll(regex)) { const id = match[1]?.trim(); if (!id || seen.has(id)) { continue; } seen.add(id); suggestions.push({ id, ...(match[2]?.trim() ? { label: match[2].trim() } : {}), }); } return suggestions; } export function isValidTenantIdentifier(value: string): boolean { const trimmed = value.trim(); if (!trimmed) { return false; } const isTenantUuid = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(trimmed); const isTenantDomain = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/.test( trimmed, ); return isTenantUuid || isTenantDomain; } export async function promptTenantId( ctx: ProviderAuthContext, params?: { suggestions?: Array<{ id: string; label?: string }>; required?: boolean; reason?: string; }, ): Promise { const suggestionLines = params?.suggestions && params.suggestions.length > 0 ? params.suggestions.map((entry) => `- ${entry.id}${entry.label ? ` (${entry.label})` : ""}`) : []; if (params?.reason || suggestionLines.length > 0) { await ctx.prompter.note( [ params?.reason, suggestionLines.length > 0 ? "Suggested tenants:" : undefined, ...suggestionLines, ] .filter(Boolean) .join("\n"), "Azure Tenant", ); } const tenantId = String( await ctx.prompter.text({ message: params?.required ? "Azure tenant ID" : "Azure tenant ID (optional)", placeholder: params?.suggestions?.[0]?.id ?? "00000000-0000-0000-0000-000000000000", validate: (value) => { const trimmed = String(value ?? "").trim(); if (!trimmed) { return params?.required ? "Tenant ID is required" : undefined; } return isValidTenantIdentifier(trimmed) ? undefined : "Enter a valid tenant ID or tenant domain"; }, }), ).trim(); return tenantId || undefined; } export async function loginWithTenantFallback( ctx: ProviderAuthContext, ): Promise<{ account: AzAccount | null; tenantId?: string }> { try { await azLoginDeviceCode(); return { account: getLoggedInAccount() }; } catch (error) { const message = error instanceof Error ? error.message : String(error); const isAzureTenantError = /AADSTS\d+/i.test(message) || /no subscriptions found/i.test(message) || /Please provide a valid tenant/i.test(message) || /tenant.*not found/i.test(message); if (!isAzureTenantError) { throw error; } const tenantId = await promptTenantId(ctx, { suggestions: extractTenantSuggestions(message), required: true, reason: "Azure login needs a tenant-scoped retry. This often happens when your tenant requires MFA or your account has no Azure subscriptions.", }); await azLoginDeviceCodeWithOptions({ tenantId, allowNoSubscriptions: true, }); return { account: getLoggedInAccount(), tenantId, }; } } export async function testFoundryConnection(params: { ctx: ProviderAuthContext; endpoint: string; modelId: string; modelNameHint?: string; api: FoundryProviderApi; subscriptionId?: string; tenantId?: string; }): Promise { try { const { accessToken } = getAccessTokenResult({ subscriptionId: params.subscriptionId, tenantId: params.tenantId, }); const testRequest = buildFoundryConnectionTest({ endpoint: params.endpoint, modelId: params.modelId, modelNameHint: params.modelNameHint, api: params.api, }); const signal = typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(15_000) : undefined; const res = await fetch(testRequest.url, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(testRequest.body), ...(signal ? { signal } : {}), }); if (res.status === 400) { const body = await res.text().catch(() => ""); await params.ctx.prompter.note( `Endpoint is reachable but returned 400 Bad Request - check your deployment name and API version.\n${body.slice(0, 200)}`, "Connection Test", ); } else if (!res.ok) { const body = await res.text().catch(() => ""); await params.ctx.prompter.note( `Warning: test request returned ${res.status}. ${body.slice(0, 200)}\nProceeding anyway - you can fix the endpoint later.`, "Connection Test", ); } else { await params.ctx.prompter.note("Connection test successful!", "✓"); } } catch (err) { await params.ctx.prompter.note( `Warning: connection test failed: ${String(err)}\nProceeding anyway.`, "Connection Test", ); } }