haxudev b7876c9609 Microsoft Foundry: split provider modules and harden runtime auth
Split the provider into focused auth, onboarding, CLI, runtime, and shared modules so the Entra ID flow is easier to review and maintain. Add Foundry-specific tests, preserve Azure CLI error details, move token refresh off the synchronous request path, and dedupe concurrent Entra token refreshes so onboarding and GPT-5 runtime behavior stay reliable.
2026-03-19 23:32:28 +08:00

431 lines
13 KiB
TypeScript

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<FoundryResourceOption> {
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<AzDeploymentSummary> {
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<FoundrySelection> {
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<FoundrySelection> {
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<string, unknown> } {
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<string>();
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<string | undefined> {
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<void> {
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",
);
}
}