Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
640
extensions/microsoft-foundry/onboard.ts
Normal file
640
extensions/microsoft-foundry/onboard.ts
Normal file
@@ -0,0 +1,640 @@
|
||||
// Microsoft Foundry setup module handles plugin onboarding behavior.
|
||||
import type { ProviderAuthContext } from "openclaw/plugin-sdk/core";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
normalizeOptionalString,
|
||||
normalizeStringifiedOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
azLoginDeviceCode,
|
||||
azLoginDeviceCodeWithOptions,
|
||||
execAz,
|
||||
getAccessTokenResult,
|
||||
getLoggedInAccount,
|
||||
} from "./cli.js";
|
||||
import {
|
||||
type AzAccount,
|
||||
type AzCognitiveAccount,
|
||||
type AzDeploymentSummary,
|
||||
type FoundryProviderApi,
|
||||
type FoundryResourceOption,
|
||||
type FoundrySelection,
|
||||
buildFoundryProviderBaseUrl,
|
||||
extractFoundryEndpoint,
|
||||
requiresFoundryMaxCompletionTokens,
|
||||
requiresFoundryEntraIdClaudeAuth,
|
||||
requiresFoundryMandatoryAdaptiveClaudeThinking,
|
||||
ANTHROPIC_MESSAGES_API,
|
||||
DEFAULT_API,
|
||||
DEFAULT_GPT5_API,
|
||||
FOUNDRY_ANTHROPIC_SCOPE,
|
||||
usesFoundryResponsesByDefault,
|
||||
} from "./shared.js";
|
||||
|
||||
const FOUNDRY_CONNECTION_TEST_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
|
||||
|
||||
export { listSubscriptions } from "./cli.js";
|
||||
|
||||
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 customSubdomain = normalizeOptionalString(account.customSubdomain);
|
||||
const endpoint = customSubdomain
|
||||
? `https://${customSubdomain}.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 [];
|
||||
}
|
||||
}
|
||||
|
||||
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<{ selected: AzDeploymentSummary; supported: AzDeploymentSummary[] }> {
|
||||
const supported = deployments;
|
||||
if (supported.length === 0) {
|
||||
throw new Error(
|
||||
[
|
||||
`No model deployments were found in ${resource.accountName}.`,
|
||||
"Deploy a model in Microsoft Foundry or Azure OpenAI, then rerun onboard.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
if (supported.length === 1) {
|
||||
const only = supported[0];
|
||||
await ctx.prompter.note(`Using deployment: ${only.name}`, "Model Deployment");
|
||||
return { selected: only, supported };
|
||||
}
|
||||
const selectedDeploymentName = await ctx.prompter.select({
|
||||
message: "Select model deployment",
|
||||
options: supported.map((deployment) => ({
|
||||
value: deployment.name,
|
||||
label: deployment.name,
|
||||
hint: [deployment.modelName, deployment.modelVersion, deployment.sku]
|
||||
.filter(Boolean)
|
||||
.join(" | "),
|
||||
})),
|
||||
});
|
||||
const selected =
|
||||
supported.find((deployment) => deployment.name === selectedDeploymentName) ?? supported[0];
|
||||
return { selected, supported };
|
||||
}
|
||||
|
||||
async function promptFoundryApi(
|
||||
ctx: ProviderAuthContext,
|
||||
initialApi: FoundryProviderApi,
|
||||
): Promise<FoundryProviderApi> {
|
||||
return await ctx.prompter.select({
|
||||
message: "Select request API",
|
||||
options: [
|
||||
{
|
||||
value: ANTHROPIC_MESSAGES_API,
|
||||
label: "Anthropic Messages API",
|
||||
hint: "Use for Claude deployments through Microsoft Foundry /anthropic",
|
||||
},
|
||||
{
|
||||
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 = "claude" | "reasoning-family" | "mai-image" | "other-chat";
|
||||
type ManualFoundryMaiImageModel =
|
||||
| "MAI-Image-2.5-Flash"
|
||||
| "MAI-Image-2.5"
|
||||
| "MAI-Image-2e"
|
||||
| "MAI-Image-2";
|
||||
|
||||
async function promptFoundryModelFamily(
|
||||
ctx: ProviderAuthContext,
|
||||
initialValue: ManualFoundryModelFamilyChoice,
|
||||
): Promise<ManualFoundryModelFamilyChoice> {
|
||||
return await ctx.prompter.select({
|
||||
message: "Model family",
|
||||
options: [
|
||||
{
|
||||
value: "claude",
|
||||
label: "Claude",
|
||||
hint: "Use for Anthropic Claude deployments",
|
||||
},
|
||||
{
|
||||
value: "reasoning-family",
|
||||
label: "GPT-5 series / o-series / Codex",
|
||||
hint: "Use for Azure OpenAI reasoning and Codex deployments",
|
||||
},
|
||||
{
|
||||
value: "mai-image",
|
||||
label: "MAI image model",
|
||||
hint: "Use for Microsoft MAI image deployments",
|
||||
},
|
||||
{
|
||||
value: "other-chat",
|
||||
label: "Other chat model",
|
||||
hint: "Use for other chat/completions style Foundry models",
|
||||
},
|
||||
],
|
||||
initialValue,
|
||||
});
|
||||
}
|
||||
|
||||
async function promptFoundryMaiImageModel(
|
||||
ctx: ProviderAuthContext,
|
||||
): Promise<ManualFoundryMaiImageModel> {
|
||||
return await ctx.prompter.select({
|
||||
message: "MAI image base model",
|
||||
options: [
|
||||
{
|
||||
value: "MAI-Image-2.5-Flash",
|
||||
label: "MAI-Image-2.5-Flash",
|
||||
hint: "Latest fast MAI image deployment",
|
||||
},
|
||||
{
|
||||
value: "MAI-Image-2.5",
|
||||
label: "MAI-Image-2.5",
|
||||
hint: "Latest MAI image deployment",
|
||||
},
|
||||
{
|
||||
value: "MAI-Image-2e",
|
||||
label: "MAI-Image-2e",
|
||||
hint: "Efficient MAI image deployment",
|
||||
},
|
||||
{
|
||||
value: "MAI-Image-2",
|
||||
label: "MAI-Image-2",
|
||||
hint: "MAI image deployment",
|
||||
},
|
||||
],
|
||||
initialValue: "MAI-Image-2.5-Flash",
|
||||
});
|
||||
}
|
||||
|
||||
async function promptFoundryClaudeModel(
|
||||
ctx: ProviderAuthContext,
|
||||
options?: { allowEntraOnlyModels?: boolean },
|
||||
): Promise<string> {
|
||||
return (
|
||||
await ctx.prompter.text({
|
||||
message: "Claude base model",
|
||||
initialValue: "claude-fable-5",
|
||||
placeholder: "claude-fable-5",
|
||||
validate: (v) => {
|
||||
const val = normalizeStringifiedOptionalString(v) ?? "";
|
||||
if (!val) {
|
||||
return "Claude base model is required";
|
||||
}
|
||||
if (!val.toLowerCase().startsWith("claude-")) {
|
||||
return "Use a Claude model name such as claude-fable-5";
|
||||
}
|
||||
if (options?.allowEntraOnlyModels === false && requiresFoundryEntraIdClaudeAuth(val)) {
|
||||
return "Claude Mythos deployments require Microsoft Entra ID auth; choose Entra ID auth or use a Claude model that supports API-key auth.";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
})
|
||||
).trim();
|
||||
}
|
||||
|
||||
async function promptEndpointAndModelBase(
|
||||
ctx: ProviderAuthContext,
|
||||
options?: {
|
||||
endpointInitialValue?: string;
|
||||
modelInitialValue?: string;
|
||||
modelFamilyInitialValue?: ManualFoundryModelFamilyChoice;
|
||||
allowEntraOnlyClaudeModels?: boolean;
|
||||
},
|
||||
): Promise<FoundrySelection> {
|
||||
const endpoint = (
|
||||
await ctx.prompter.text({
|
||||
message: "Microsoft Foundry endpoint URL",
|
||||
placeholder: "https://xxx.services.ai.azure.com or https://xxx.openai.azure.com",
|
||||
...(options?.endpointInitialValue ? { initialValue: options.endpointInitialValue } : {}),
|
||||
validate: (v) => {
|
||||
const val = normalizeStringifiedOptionalString(v) ?? "";
|
||||
if (!val) {
|
||||
return "Endpoint URL is required";
|
||||
}
|
||||
return URL.canParse(val) ? undefined : "Invalid URL";
|
||||
},
|
||||
})
|
||||
).trim();
|
||||
const modelId = (
|
||||
await ctx.prompter.text({
|
||||
message: "Default model/deployment name",
|
||||
...(options?.modelInitialValue ? { initialValue: options.modelInitialValue } : {}),
|
||||
placeholder: "claude-fable-5",
|
||||
validate: (v) => {
|
||||
const val = normalizeStringifiedOptionalString(v) ?? "";
|
||||
if (!val) {
|
||||
return "Model ID is required";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
})
|
||||
).trim();
|
||||
const familyChoice = await promptFoundryModelFamily(
|
||||
ctx,
|
||||
options?.modelFamilyInitialValue ?? "claude",
|
||||
);
|
||||
if (familyChoice === "mai-image") {
|
||||
return {
|
||||
endpoint,
|
||||
modelId,
|
||||
modelNameHint: await promptFoundryMaiImageModel(ctx),
|
||||
api: DEFAULT_API,
|
||||
};
|
||||
}
|
||||
if (familyChoice === "claude") {
|
||||
return {
|
||||
endpoint,
|
||||
modelId,
|
||||
modelNameHint: await promptFoundryClaudeModel(ctx, {
|
||||
allowEntraOnlyModels: options?.allowEntraOnlyClaudeModels ?? true,
|
||||
}),
|
||||
api: ANTHROPIC_MESSAGES_API,
|
||||
};
|
||||
}
|
||||
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",
|
||||
modelFamilyInitialValue: "other-chat",
|
||||
allowEntraOnlyClaudeModels: false,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (params.api === ANTHROPIC_MESSAGES_API) {
|
||||
return {
|
||||
url: `${baseUrl}/v1/messages`,
|
||||
body: {
|
||||
model: params.modelId,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 1,
|
||||
...(requiresFoundryMandatoryAdaptiveClaudeThinking(params.modelNameHint ?? params.modelId)
|
||||
? { thinking: { type: "adaptive" } }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
url: `${baseUrl}/chat/completions`,
|
||||
body: {
|
||||
model: params.modelId,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 = normalizeOptionalString(match[1]);
|
||||
if (!id || seen.has(id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(id);
|
||||
suggestions.push({
|
||||
id,
|
||||
...(normalizeOptionalString(match[2]) ? { label: normalizeOptionalString(match[2]) } : {}),
|
||||
});
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
export function isValidTenantIdentifier(value: string): boolean {
|
||||
const trimmed = normalizeOptionalString(value) ?? "";
|
||||
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 = (
|
||||
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 = normalizeStringifiedOptionalString(value) ?? "";
|
||||
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 = formatErrorMessage(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({
|
||||
scope: params.api === ANTHROPIC_MESSAGES_API ? FOUNDRY_ANTHROPIC_SCOPE : undefined,
|
||||
subscriptionId: params.subscriptionId,
|
||||
tenantId: params.tenantId,
|
||||
});
|
||||
const testRequest = buildFoundryConnectionTest({
|
||||
endpoint: params.endpoint,
|
||||
modelId: params.modelId,
|
||||
modelNameHint: params.modelNameHint,
|
||||
api: params.api,
|
||||
});
|
||||
const { response: res, release } = await fetchWithSsrFGuard({
|
||||
url: testRequest.url,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
...(params.api === ANTHROPIC_MESSAGES_API ? { "anthropic-version": "2023-06-01" } : {}),
|
||||
},
|
||||
body: JSON.stringify(testRequest.body),
|
||||
},
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
try {
|
||||
if (res.status === 400) {
|
||||
const body = await readResponseTextLimited(
|
||||
res,
|
||||
FOUNDRY_CONNECTION_TEST_ERROR_BODY_LIMIT_BYTES,
|
||||
).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 readResponseTextLimited(
|
||||
res,
|
||||
FOUNDRY_CONNECTION_TEST_ERROR_BODY_LIMIT_BYTES,
|
||||
).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!", "✓");
|
||||
}
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
} catch (err) {
|
||||
await params.ctx.prompter.note(
|
||||
`Warning: connection test failed: ${String(err)}\nProceeding anyway.`,
|
||||
"Connection Test",
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user