mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 09:21:35 +07:00
* Microsoft Foundry: add native provider * Microsoft Foundry: tighten review fixes * Microsoft Foundry: enable by default * Microsoft Foundry: stabilize API routing
500 lines
15 KiB
TypeScript
500 lines
15 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 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<FoundryResourceOption> {
|
|
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<AzDeploymentSummary> {
|
|
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<FoundryProviderApi> {
|
|
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<ManualFoundryModelFamilyChoice> {
|
|
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<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",
|
|
...(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<FoundrySelection> {
|
|
return promptEndpointAndModelBase(ctx);
|
|
}
|
|
|
|
export async function promptApiKeyEndpointAndModel(
|
|
ctx: ProviderAuthContext,
|
|
): Promise<FoundrySelection> {
|
|
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<string, unknown> } {
|
|
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<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 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<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 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<void> {
|
|
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",
|
|
);
|
|
}
|
|
}
|