feat: Add Microsoft Foundry provider with Entra ID authentication (#51973)

* Microsoft Foundry: add native provider

* Microsoft Foundry: tighten review fixes

* Microsoft Foundry: enable by default

* Microsoft Foundry: stabilize API routing
This commit is contained in:
MetaX e|acc
2026-03-26 14:33:14 +08:00
committed by GitHub
parent 06de515b6c
commit a16dd967da
13 changed files with 2588 additions and 0 deletions

View File

@@ -0,0 +1,253 @@
import type {
ProviderAuthContext,
ProviderAuthMethod,
ProviderAuthResult,
} from "openclaw/plugin-sdk/core";
import {
ensureApiKeyFromOptionEnvOrPrompt,
ensureAuthProfileStore,
normalizeApiKeyInput,
normalizeOptionalSecretInput,
type SecretInput,
validateApiKeyInput,
} from "openclaw/plugin-sdk/provider-auth";
import { getLoggedInAccount, isAzCliInstalled } from "./cli.js";
import {
loginWithTenantFallback,
listResourceDeployments,
promptApiKeyEndpointAndModel,
promptEndpointAndModelManually,
promptTenantId,
selectFoundryDeployment,
selectFoundryResource,
listSubscriptions,
testFoundryConnection,
} from "./onboard.js";
import {
buildFoundryAuthResult,
type FoundryProviderApi,
listConfiguredFoundryProfileIds,
PROVIDER_ID,
resolveConfiguredModelNameHint,
resolveFoundryApi,
} from "./shared.js";
export const entraIdAuthMethod: ProviderAuthMethod = {
id: "entra-id",
label: "Entra ID (az login)",
hint: "Use your Azure login — no API key needed",
kind: "custom",
wizard: {
choiceId: "microsoft-foundry-entra",
choiceLabel: "Microsoft Foundry (Entra ID / az login)",
choiceHint: "Use your Azure login — no API key needed",
groupId: "microsoft-foundry",
groupLabel: "Microsoft Foundry",
groupHint: "Entra ID + API key",
},
run: async (ctx: ProviderAuthContext): Promise<ProviderAuthResult> => {
if (!isAzCliInstalled()) {
throw new Error(
"Azure CLI (az) is not installed.\nInstall it from https://learn.microsoft.com/cli/azure/install-azure-cli",
);
}
let account = getLoggedInAccount();
let tenantId = account?.tenantId;
if (account) {
const useExisting = await ctx.prompter.confirm({
message: `Already logged in as ${account.user?.name ?? "unknown"} (${account.name}). Use this account?`,
initialValue: true,
});
if (!useExisting) {
const loginResult = await loginWithTenantFallback(ctx);
account = loginResult.account;
tenantId = loginResult.tenantId ?? loginResult.account?.tenantId;
}
} else {
await ctx.prompter.note(
"You need to log in to Azure. A device code will be displayed - follow the instructions.",
"Azure Login",
);
const loginResult = await loginWithTenantFallback(ctx);
account = loginResult.account;
tenantId = loginResult.tenantId ?? loginResult.account?.tenantId;
}
const subs = listSubscriptions();
let selectedSub = null;
if (subs.length === 0) {
tenantId ??= await promptTenantId(ctx, {
required: true,
reason:
"No enabled Azure subscriptions were found. Continue with tenant-scoped Entra ID auth instead.",
});
await ctx.prompter.note(`Continuing with tenant-scoped auth (${tenantId}).`, "Azure Tenant");
} else if (subs.length === 1) {
selectedSub = subs[0]!;
tenantId ??= selectedSub.tenantId;
await ctx.prompter.note(
`Using subscription: ${selectedSub.name} (${selectedSub.id})`,
"Subscription",
);
} else {
const selectedId = await ctx.prompter.select({
message: "Select Azure subscription",
options: subs.map((sub) => ({
value: sub.id,
label: `${sub.name} (${sub.id})`,
})),
});
selectedSub = subs.find((sub) => sub.id === selectedId)!;
tenantId ??= selectedSub.tenantId;
}
let endpoint: string;
let modelId: string;
let modelNameHint: string | undefined;
let api: FoundryProviderApi;
let discoveredDeployments:
| Array<{
name: string;
modelName?: string;
api?: "openai-completions" | "openai-responses";
}>
| undefined;
if (selectedSub) {
const useDiscoveredResource = await ctx.prompter.confirm({
message: "Discover Microsoft Foundry resources from this subscription?",
initialValue: true,
});
if (useDiscoveredResource) {
const selectedResource = await selectFoundryResource(ctx, selectedSub);
const resourceDeployments = listResourceDeployments(selectedResource, selectedSub.id);
const selectedDeployment = await selectFoundryDeployment(
ctx,
selectedResource,
resourceDeployments,
);
discoveredDeployments = resourceDeployments.map((deployment) => ({
name: deployment.name,
...(deployment.modelName ? { modelName: deployment.modelName } : {}),
api: resolveFoundryApi(deployment.name, deployment.modelName),
}));
endpoint = selectedResource.endpoint;
modelId = selectedDeployment.name;
modelNameHint = resolveConfiguredModelNameHint(modelId, selectedDeployment.modelName);
api = resolveFoundryApi(modelId, modelNameHint);
await ctx.prompter.note(
[
`Resource: ${selectedResource.accountName}`,
`Endpoint: ${endpoint}`,
`Deployment: ${modelId}`,
selectedDeployment.modelName ? `Model: ${selectedDeployment.modelName}` : undefined,
`API: ${api === "openai-responses" ? "Responses" : "Chat Completions"}`,
]
.filter(Boolean)
.join("\n"),
"Microsoft Foundry",
);
} else {
({ endpoint, modelId, modelNameHint, api } = await promptEndpointAndModelManually(ctx));
}
} else {
({ endpoint, modelId, modelNameHint, api } = await promptEndpointAndModelManually(ctx));
}
await testFoundryConnection({
ctx,
endpoint,
modelId,
modelNameHint,
api,
subscriptionId: selectedSub?.id,
tenantId,
});
return buildFoundryAuthResult({
profileId: `${PROVIDER_ID}:entra`,
apiKey: "__entra_id_dynamic__",
endpoint,
modelId,
modelNameHint,
api,
authMethod: "entra-id",
...(selectedSub?.id ? { subscriptionId: selectedSub.id } : {}),
...(selectedSub?.name ? { subscriptionName: selectedSub.name } : {}),
...(tenantId ? { tenantId } : {}),
currentProviderProfileIds: listConfiguredFoundryProfileIds(ctx.config),
currentPluginsAllow: ctx.config.plugins?.allow,
...(discoveredDeployments ? { deployments: discoveredDeployments } : {}),
notes: [
...(selectedSub?.name ? [`Subscription: ${selectedSub.name}`] : []),
...(tenantId ? [`Tenant: ${tenantId}`] : []),
`Endpoint: ${endpoint}`,
`Model: ${modelId}`,
"Token is refreshed automatically via az CLI - keep az login active.",
],
});
},
};
export const apiKeyAuthMethod: ProviderAuthMethod = {
id: "api-key",
label: "Azure OpenAI API key",
hint: "Direct Azure OpenAI API key",
kind: "api_key",
wizard: {
choiceId: "microsoft-foundry-apikey",
choiceLabel: "Microsoft Foundry (API key)",
groupId: "microsoft-foundry",
groupLabel: "Microsoft Foundry",
groupHint: "Entra ID + API key",
},
run: async (ctx) => {
const authStore = ensureAuthProfileStore(ctx.agentDir, {
allowKeychainPrompt: false,
});
const existing = authStore.profiles[`${PROVIDER_ID}:default`];
const existingMetadata = existing?.type === "api_key" ? existing.metadata : undefined;
let capturedSecretInput: SecretInput | undefined;
let capturedCredential = false;
let capturedMode: "plaintext" | "ref" | undefined;
await ensureApiKeyFromOptionEnvOrPrompt({
token: normalizeOptionalSecretInput(ctx.opts?.azureOpenaiApiKey),
tokenProvider: PROVIDER_ID,
secretInputMode:
ctx.allowSecretRefPrompt === false
? (ctx.secretInputMode ?? "plaintext")
: ctx.secretInputMode,
config: ctx.config,
expectedProviders: [PROVIDER_ID],
provider: PROVIDER_ID,
envLabel: "AZURE_OPENAI_API_KEY",
promptMessage: "Enter Azure OpenAI API key",
normalize: normalizeApiKeyInput,
validate: validateApiKeyInput,
prompter: ctx.prompter,
setCredential: async (apiKey, mode) => {
capturedSecretInput = apiKey;
capturedCredential = true;
capturedMode = mode;
},
});
if (!capturedCredential) {
throw new Error("Missing Azure OpenAI API key.");
}
const selection = await promptApiKeyEndpointAndModel(ctx);
return buildFoundryAuthResult({
profileId: `${PROVIDER_ID}:default`,
apiKey: capturedSecretInput ?? "",
...(capturedMode ? { secretInputMode: capturedMode } : {}),
endpoint: selection.endpoint,
modelId: selection.modelId,
modelNameHint:
selection.modelNameHint ?? existingMetadata?.modelName ?? existingMetadata?.modelId,
api: selection.api,
authMethod: "api-key",
currentProviderProfileIds: listConfiguredFoundryProfileIds(ctx.config),
currentPluginsAllow: ctx.config.plugins?.allow,
notes: [`Endpoint: ${selection.endpoint}`, `Model: ${selection.modelId}`],
});
},
};

View File

@@ -0,0 +1,191 @@
import { execFile, execFileSync, spawn } from "node:child_process";
import type { AzAccessToken, AzAccount } from "./shared.js";
import { COGNITIVE_SERVICES_RESOURCE } from "./shared.js";
function summarizeAzErrorMessage(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return "";
}
const normalized = trimmed.replace(/\s+/g, " ");
if (/not recognized|enoent|spawn .* az/i.test(normalized)) {
return "Azure CLI (az) is not installed or not on PATH.";
}
if (/az login/i.test(normalized) || /please run 'az login'/i.test(normalized)) {
return "Azure CLI is not logged in. Run `az login --use-device-code`.";
}
if (
/subscription/i.test(normalized) &&
/could not be found|does not exist|no subscriptions/i.test(normalized)
) {
return "Azure CLI could not find an accessible subscription. Check the selected subscription or tenant access.";
}
if (
/tenant/i.test(normalized) &&
/not found|invalid|doesn't exist|does not exist/i.test(normalized)
) {
return "Azure CLI could not use that tenant. Verify the tenant ID or tenant domain and try `az login --tenant <tenant>`.";
}
if (/aadsts\d+/i.test(normalized)) {
return "Azure login failed for the selected tenant. Re-run `az login --use-device-code` and confirm the tenant is correct.";
}
return normalized.slice(0, 300);
}
function buildAzCommandError(error: Error, stderr: string, stdout: string): Error {
const details = summarizeAzErrorMessage(`${String(stderr ?? "")} ${String(stdout ?? "")}`);
return new Error(details ? `${error.message}: ${details}` : error.message);
}
export function execAz(args: string[]): string {
return execFileSync("az", args, {
encoding: "utf-8",
timeout: 30_000,
shell: process.platform === "win32",
}).trim();
}
export async function execAzAsync(args: string[]): Promise<string> {
return await new Promise<string>((resolve, reject) => {
execFile(
"az",
args,
{
encoding: "utf-8",
timeout: 30_000,
shell: process.platform === "win32",
},
(error, stdout, stderr) => {
if (error) {
reject(buildAzCommandError(error, String(stderr ?? ""), String(stdout ?? "")));
return;
}
resolve(String(stdout).trim());
},
);
});
}
export function isAzCliInstalled(): boolean {
try {
execAz(["version", "--output", "none"]);
return true;
} catch {
return false;
}
}
export function getLoggedInAccount(): AzAccount | null {
try {
return JSON.parse(execAz(["account", "show", "--output", "json"])) as AzAccount;
} catch {
return null;
}
}
export function listSubscriptions(): AzAccount[] {
try {
const subs = JSON.parse(
execAz(["account", "list", "--output", "json", "--all"]),
) as AzAccount[];
return subs.filter((sub) => sub.state === "Enabled");
} catch {
return [];
}
}
type AccessTokenParams = {
subscriptionId?: string;
tenantId?: string;
};
function buildAccessTokenArgs(params?: AccessTokenParams): string[] {
const args = [
"account",
"get-access-token",
"--resource",
COGNITIVE_SERVICES_RESOURCE,
"--output",
"json",
];
if (params?.subscriptionId) {
args.push("--subscription", params.subscriptionId);
} else if (params?.tenantId) {
args.push("--tenant", params.tenantId);
}
return args;
}
export function getAccessTokenResult(params?: AccessTokenParams): AzAccessToken {
return JSON.parse(execAz(buildAccessTokenArgs(params))) as AzAccessToken;
}
export async function getAccessTokenResultAsync(
params?: AccessTokenParams,
): Promise<AzAccessToken> {
return JSON.parse(await execAzAsync(buildAccessTokenArgs(params))) as AzAccessToken;
}
export async function azLoginDeviceCode(): Promise<void> {
return azLoginDeviceCodeWithOptions({});
}
export async function azLoginDeviceCodeWithOptions(params: {
tenantId?: string;
allowNoSubscriptions?: boolean;
}): Promise<void> {
return new Promise<void>((resolve, reject) => {
const maxCapturedLoginOutputChars = 8_000;
const args = [
"login",
"--use-device-code",
...(params.tenantId ? ["--tenant", params.tenantId] : []),
...(params.allowNoSubscriptions ? ["--allow-no-subscriptions"] : []),
];
const child = spawn("az", args, {
stdio: ["inherit", "pipe", "pipe"],
shell: process.platform === "win32",
});
const stdoutChunks: string[] = [];
const stderrChunks: string[] = [];
let stdoutLen = 0;
let stderrLen = 0;
const appendBoundedChunk = (chunks: string[], text: string, len: number): number => {
if (!text) {
return len;
}
chunks.push(text);
let total = len + text.length;
while (total > maxCapturedLoginOutputChars && chunks.length > 0) {
const removed = chunks.shift();
total -= removed?.length ?? 0;
}
return total;
};
child.stdout?.on("data", (chunk) => {
const text = String(chunk);
stdoutLen = appendBoundedChunk(stdoutChunks, text, stdoutLen);
process.stdout.write(text);
});
child.stderr?.on("data", (chunk) => {
const text = String(chunk);
stderrLen = appendBoundedChunk(stderrChunks, text, stderrLen);
process.stderr.write(text);
});
child.on("close", (code) => {
if (code === 0) {
resolve();
return;
}
const output = [...stderrChunks, ...stdoutChunks].join("").trim();
reject(
new Error(
output
? `az login exited with code ${code}: ${output}`
: `az login exited with code ${code}`,
),
);
});
child.on("error", reject);
});
}

View File

@@ -0,0 +1,875 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../src/config/types.openclaw.js";
import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js";
import { getAccessTokenResultAsync } from "./cli.js";
import plugin from "./index.js";
import { buildFoundryConnectionTest, isValidTenantIdentifier } from "./onboard.js";
import { resetFoundryRuntimeAuthCaches } from "./runtime.js";
import {
buildFoundryAuthResult,
normalizeFoundryEndpoint,
requiresFoundryMaxCompletionTokens,
usesFoundryResponsesByDefault,
} from "./shared.js";
const execFileMock = vi.hoisted(() => vi.fn());
const execFileSyncMock = vi.hoisted(() => vi.fn());
const ensureAuthProfileStoreMock = vi.hoisted(() =>
vi.fn(() => ({
profiles: {},
})),
);
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
execFile: execFileMock,
execFileSync: execFileSyncMock,
};
});
vi.mock("openclaw/plugin-sdk/provider-auth", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/provider-auth")>(
"openclaw/plugin-sdk/provider-auth",
);
return {
...actual,
ensureAuthProfileStore: ensureAuthProfileStoreMock,
};
});
function registerProvider() {
const registerProviderMock = vi.fn();
plugin.register(
createTestPluginApi({
id: "microsoft-foundry",
name: "Microsoft Foundry",
source: "test",
config: {},
runtime: {} as never,
registerProvider: registerProviderMock,
}),
);
expect(registerProviderMock).toHaveBeenCalledTimes(1);
return registerProviderMock.mock.calls[0]?.[0];
}
describe("microsoft-foundry plugin", () => {
beforeEach(() => {
resetFoundryRuntimeAuthCaches();
execFileMock.mockReset();
execFileSyncMock.mockReset();
ensureAuthProfileStoreMock.mockReset();
ensureAuthProfileStoreMock.mockReturnValue({ profiles: {} });
});
it("keeps the API key profile bound when multiple auth profiles exist without explicit order", async () => {
const provider = registerProvider();
const config: OpenClawConfig = {
auth: {
profiles: {
"microsoft-foundry:default": {
provider: "microsoft-foundry",
mode: "api_key" as const,
},
"microsoft-foundry:entra": {
provider: "microsoft-foundry",
mode: "api_key" as const,
},
},
},
models: {
providers: {
"microsoft-foundry": {
baseUrl: "https://example.services.ai.azure.com/openai/v1",
api: "openai-responses",
models: [
{
id: "gpt-5.4",
name: "gpt-5.4",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 16_384,
},
],
},
},
},
};
await provider.onModelSelected?.({
config,
model: "microsoft-foundry/gpt-5.4",
prompter: {} as never,
agentDir: "/tmp/test-agent",
});
expect(config.auth?.order?.["microsoft-foundry"]).toBeUndefined();
});
it("uses the active ordered API key profile when model selection rebinding is needed", async () => {
const provider = registerProvider();
ensureAuthProfileStoreMock.mockReturnValueOnce({
profiles: {
"microsoft-foundry:default": {
type: "api_key",
provider: "microsoft-foundry",
metadata: { authMethod: "api-key" },
},
},
});
const config: OpenClawConfig = {
auth: {
profiles: {
"microsoft-foundry:default": {
provider: "microsoft-foundry",
mode: "api_key" as const,
},
},
order: {
"microsoft-foundry": ["microsoft-foundry:default"],
},
},
models: {
providers: {
"microsoft-foundry": {
baseUrl: "https://example.services.ai.azure.com/openai/v1",
api: "openai-responses",
models: [
{
id: "gpt-5.4",
name: "gpt-5.4",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 16_384,
},
],
},
},
},
};
await provider.onModelSelected?.({
config,
model: "microsoft-foundry/gpt-5.4",
prompter: {} as never,
agentDir: "/tmp/test-agent",
});
expect(config.auth?.order?.["microsoft-foundry"]).toEqual(["microsoft-foundry:default"]);
});
it("preserves the model-derived base URL for Entra runtime auth refresh", async () => {
const provider = registerProvider();
execFileMock.mockImplementationOnce(
(
_file: unknown,
_args: unknown,
_options: unknown,
callback: (error: Error | null, stdout: string, stderr: string) => void,
) => {
callback(
null,
JSON.stringify({
accessToken: "test-token",
expiresOn: new Date(Date.now() + 60_000).toISOString(),
}),
"",
);
},
);
ensureAuthProfileStoreMock.mockReturnValueOnce({
profiles: {
"microsoft-foundry:entra": {
type: "api_key",
provider: "microsoft-foundry",
metadata: {
authMethod: "entra-id",
endpoint: "https://example.services.ai.azure.com",
modelId: "custom-deployment",
modelName: "gpt-5.4",
tenantId: "tenant-id",
},
},
},
});
const prepared = await provider.prepareRuntimeAuth?.({
provider: "microsoft-foundry",
modelId: "custom-deployment",
model: {
provider: "microsoft-foundry",
id: "custom-deployment",
name: "gpt-5.4",
api: "openai-responses",
baseUrl: "https://example.services.ai.azure.com/openai/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 16_384,
},
apiKey: "__entra_id_dynamic__",
authMode: "api_key",
profileId: "microsoft-foundry:entra",
env: process.env,
agentDir: "/tmp/test-agent",
});
expect(prepared?.baseUrl).toBe("https://example.services.ai.azure.com/openai/v1");
});
it("retries Entra token refresh after a failed attempt", async () => {
const provider = registerProvider();
execFileMock
.mockImplementationOnce(
(
_file: unknown,
_args: unknown,
_options: unknown,
callback: (error: Error | null, stdout: string, stderr: string) => void,
) => {
callback(new Error("az failed"), "", "Please run 'az login' to setup account.");
},
)
.mockImplementationOnce(
(
_file: unknown,
_args: unknown,
_options: unknown,
callback: (error: Error | null, stdout: string, stderr: string) => void,
) => {
callback(
null,
JSON.stringify({
accessToken: "retry-token",
expiresOn: new Date(Date.now() + 10 * 60_000).toISOString(),
}),
"",
);
},
);
ensureAuthProfileStoreMock.mockReturnValue({
profiles: {
"microsoft-foundry:entra": {
type: "api_key",
provider: "microsoft-foundry",
metadata: {
authMethod: "entra-id",
endpoint: "https://example.services.ai.azure.com",
modelId: "custom-deployment",
modelName: "gpt-5.4",
tenantId: "tenant-id",
},
},
},
});
const runtimeContext = {
provider: "microsoft-foundry",
modelId: "custom-deployment",
model: {
provider: "microsoft-foundry",
id: "custom-deployment",
name: "gpt-5.4",
api: "openai-responses",
baseUrl: "https://example.services.ai.azure.com/openai/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 16_384,
},
apiKey: "__entra_id_dynamic__",
authMode: "api_key",
profileId: "microsoft-foundry:entra",
env: process.env,
agentDir: "/tmp/test-agent",
};
await expect(provider.prepareRuntimeAuth?.(runtimeContext)).rejects.toThrow(
"Azure CLI is not logged in",
);
await expect(provider.prepareRuntimeAuth?.(runtimeContext)).resolves.toMatchObject({
apiKey: "retry-token",
});
expect(execFileMock).toHaveBeenCalledTimes(2);
});
it("dedupes concurrent Entra token refreshes for the same profile", async () => {
const provider = registerProvider();
execFileMock.mockImplementationOnce(
(
_file: unknown,
_args: unknown,
_options: unknown,
callback: (error: Error | null, stdout: string, stderr: string) => void,
) => {
setTimeout(() => {
callback(
null,
JSON.stringify({
accessToken: "deduped-token",
expiresOn: new Date(Date.now() + 60_000).toISOString(),
}),
"",
);
}, 10);
},
);
ensureAuthProfileStoreMock.mockReturnValue({
profiles: {
"microsoft-foundry:entra": {
type: "api_key",
provider: "microsoft-foundry",
metadata: {
authMethod: "entra-id",
endpoint: "https://example.services.ai.azure.com",
modelId: "custom-deployment",
modelName: "gpt-5.4",
tenantId: "tenant-id",
},
},
},
});
const runtimeContext = {
provider: "microsoft-foundry",
modelId: "custom-deployment",
model: {
provider: "microsoft-foundry",
id: "custom-deployment",
name: "gpt-5.4",
api: "openai-responses",
baseUrl: "https://example.services.ai.azure.com/openai/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 16_384,
},
apiKey: "__entra_id_dynamic__",
authMode: "api_key",
profileId: "microsoft-foundry:entra",
env: process.env,
agentDir: "/tmp/test-agent",
};
const [first, second] = await Promise.all([
provider.prepareRuntimeAuth?.(runtimeContext),
provider.prepareRuntimeAuth?.(runtimeContext),
]);
expect(execFileMock).toHaveBeenCalledTimes(1);
expect(first?.apiKey).toBe("deduped-token");
expect(second?.apiKey).toBe("deduped-token");
});
it("clears failed refresh state so later concurrent retries succeed", async () => {
const provider = registerProvider();
execFileMock
.mockImplementationOnce(
(
_file: unknown,
_args: unknown,
_options: unknown,
callback: (error: Error | null, stdout: string, stderr: string) => void,
) => {
setTimeout(() => {
callback(new Error("az failed"), "", "Please run 'az login' to setup account.");
}, 10);
},
)
.mockImplementationOnce(
(
_file: unknown,
_args: unknown,
_options: unknown,
callback: (error: Error | null, stdout: string, stderr: string) => void,
) => {
setTimeout(() => {
callback(
null,
JSON.stringify({
accessToken: "recovered-token",
expiresOn: new Date(Date.now() + 10 * 60_000).toISOString(),
}),
"",
);
}, 10);
},
);
ensureAuthProfileStoreMock.mockReturnValue({
profiles: {
"microsoft-foundry:entra": {
type: "api_key",
provider: "microsoft-foundry",
metadata: {
authMethod: "entra-id",
endpoint: "https://example.services.ai.azure.com",
modelId: "custom-deployment",
modelName: "gpt-5.4",
tenantId: "tenant-id",
},
},
},
});
const runtimeContext = {
provider: "microsoft-foundry",
modelId: "custom-deployment",
model: {
provider: "microsoft-foundry",
id: "custom-deployment",
name: "gpt-5.4",
api: "openai-responses",
baseUrl: "https://example.services.ai.azure.com/openai/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 16_384,
},
apiKey: "__entra_id_dynamic__",
authMode: "api_key",
profileId: "microsoft-foundry:entra",
env: process.env,
agentDir: "/tmp/test-agent",
};
const failed = await Promise.allSettled([
provider.prepareRuntimeAuth?.(runtimeContext),
provider.prepareRuntimeAuth?.(runtimeContext),
]);
expect(failed.every((result) => result.status === "rejected")).toBe(true);
expect(execFileMock).toHaveBeenCalledTimes(1);
const [first, second] = await Promise.all([
provider.prepareRuntimeAuth?.(runtimeContext),
provider.prepareRuntimeAuth?.(runtimeContext),
]);
expect(execFileMock).toHaveBeenCalledTimes(2);
expect(first?.apiKey).toBe("recovered-token");
expect(second?.apiKey).toBe("recovered-token");
});
it("refreshes again when a cached token is too close to expiry", async () => {
const provider = registerProvider();
execFileMock
.mockImplementationOnce(
(
_file: unknown,
_args: unknown,
_options: unknown,
callback: (error: Error | null, stdout: string, stderr: string) => void,
) => {
callback(
null,
JSON.stringify({
accessToken: "soon-expiring-token",
expiresOn: new Date(Date.now() + 60_000).toISOString(),
}),
"",
);
},
)
.mockImplementationOnce(
(
_file: unknown,
_args: unknown,
_options: unknown,
callback: (error: Error | null, stdout: string, stderr: string) => void,
) => {
callback(
null,
JSON.stringify({
accessToken: "fresh-token",
expiresOn: new Date(Date.now() + 10 * 60_000).toISOString(),
}),
"",
);
},
);
ensureAuthProfileStoreMock.mockReturnValue({
profiles: {
"microsoft-foundry:entra": {
type: "api_key",
provider: "microsoft-foundry",
metadata: {
authMethod: "entra-id",
endpoint: "https://example.services.ai.azure.com",
modelId: "custom-deployment",
modelName: "gpt-5.4",
tenantId: "tenant-id",
},
},
},
});
const runtimeContext = {
provider: "microsoft-foundry",
modelId: "custom-deployment",
model: {
provider: "microsoft-foundry",
id: "custom-deployment",
name: "gpt-5.4",
api: "openai-responses",
baseUrl: "https://example.services.ai.azure.com/openai/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 16_384,
},
apiKey: "__entra_id_dynamic__",
authMode: "api_key",
profileId: "microsoft-foundry:entra",
env: process.env,
agentDir: "/tmp/test-agent",
};
await expect(provider.prepareRuntimeAuth?.(runtimeContext)).resolves.toMatchObject({
apiKey: "soon-expiring-token",
});
await expect(provider.prepareRuntimeAuth?.(runtimeContext)).resolves.toMatchObject({
apiKey: "fresh-token",
});
expect(execFileMock).toHaveBeenCalledTimes(2);
});
it("keeps other configured Foundry models when switching the selected model", async () => {
const provider = registerProvider();
const config: OpenClawConfig = {
auth: {
profiles: {
"microsoft-foundry:default": {
provider: "microsoft-foundry",
mode: "api_key" as const,
},
},
order: {
"microsoft-foundry": ["microsoft-foundry:default"],
},
},
models: {
providers: {
"microsoft-foundry": {
baseUrl: "https://example.services.ai.azure.com/openai/v1",
api: "openai-responses",
models: [
{
id: "alias-one",
name: "gpt-5.4",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 16_384,
},
{
id: "alias-two",
name: "gpt-4o",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 16_384,
},
],
},
},
},
};
await provider.onModelSelected?.({
config,
model: "microsoft-foundry/alias-one",
prompter: {} as never,
agentDir: "/tmp/test-agent",
});
expect(
config.models?.providers?.["microsoft-foundry"]?.models.map((model) => model.id),
).toEqual(["alias-one", "alias-two"]);
});
it("accepts tenant domains as valid tenant identifiers", () => {
expect(isValidTenantIdentifier("contoso.onmicrosoft.com")).toBe(true);
expect(isValidTenantIdentifier("00000000-0000-0000-0000-000000000000")).toBe(true);
expect(isValidTenantIdentifier("not a tenant")).toBe(false);
});
it("defaults Azure OpenAI model families to the documented API surfaces", () => {
expect(usesFoundryResponsesByDefault("gpt-5.4")).toBe(true);
expect(usesFoundryResponsesByDefault("gpt-5.2-codex")).toBe(true);
expect(usesFoundryResponsesByDefault("o4-mini")).toBe(true);
expect(usesFoundryResponsesByDefault("MAI-DS-R1")).toBe(false);
expect(requiresFoundryMaxCompletionTokens("gpt-5.4")).toBe(true);
expect(requiresFoundryMaxCompletionTokens("o3")).toBe(true);
expect(requiresFoundryMaxCompletionTokens("gpt-4o")).toBe(false);
});
it("writes Azure API key header overrides for API-key auth configs", () => {
const result = buildFoundryAuthResult({
profileId: "microsoft-foundry:default",
apiKey: "test-api-key",
endpoint: "https://example.services.ai.azure.com",
modelId: "gpt-4o",
api: "openai-responses",
authMethod: "api-key",
});
expect(result.configPatch?.models?.providers?.["microsoft-foundry"]).toMatchObject({
apiKey: "test-api-key",
authHeader: false,
headers: { "api-key": "test-api-key" },
});
});
it("uses the minimum supported response token count for GPT-5 connection tests", () => {
const testRequest = buildFoundryConnectionTest({
endpoint: "https://example.services.ai.azure.com",
modelId: "gpt-5.4",
modelNameHint: "gpt-5.4",
api: "openai-responses",
});
expect(testRequest.url).toContain("/responses");
expect(testRequest.body).toMatchObject({
model: "gpt-5.4",
max_output_tokens: 16,
});
});
it("marks Foundry responses models to omit explicit store=false payloads", () => {
const result = buildFoundryAuthResult({
profileId: "microsoft-foundry:entra",
apiKey: "__entra_id_dynamic__",
endpoint: "https://example.services.ai.azure.com",
modelId: "gpt-5.2-codex",
modelNameHint: "gpt-5.2-codex",
api: "openai-responses",
authMethod: "entra-id",
});
const provider = result.configPatch?.models?.providers?.["microsoft-foundry"];
expect(provider?.models[0]?.compat).toMatchObject({
supportsStore: false,
maxTokensField: "max_completion_tokens",
});
});
it("keeps persisted response-mode routing for custom deployment aliases", async () => {
const provider = registerProvider();
const config: OpenClawConfig = {
auth: {
profiles: {
"microsoft-foundry:entra": {
provider: "microsoft-foundry",
mode: "api_key" as const,
},
},
order: {
"microsoft-foundry": ["microsoft-foundry:entra"],
},
},
models: {
providers: {
"microsoft-foundry": {
baseUrl: "https://example.services.ai.azure.com/openai/v1",
api: "openai-responses",
models: [
{
id: "prod-primary",
name: "production alias",
api: "openai-responses",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 16_384,
},
],
},
},
},
};
await provider.onModelSelected?.({
config,
model: "microsoft-foundry/prod-primary",
prompter: {} as never,
agentDir: "/tmp/test-agent",
});
expect(config.models?.providers?.["microsoft-foundry"]?.api).toBe("openai-responses");
expect(config.models?.providers?.["microsoft-foundry"]?.baseUrl).toBe(
"https://example.services.ai.azure.com/openai/v1",
);
expect(config.models?.providers?.["microsoft-foundry"]?.models[0]?.api).toBe(
"openai-responses",
);
});
it("normalizes pasted Azure chat completion request URLs to the resource endpoint", () => {
expect(
normalizeFoundryEndpoint(
"https://example.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2024-12-01-preview",
),
).toBe("https://example.openai.azure.com");
});
it("preserves project-scoped endpoint prefixes when extracting the Foundry endpoint", async () => {
const provider = registerProvider();
execFileMock.mockImplementationOnce(
(
_file: unknown,
_args: unknown,
_options: unknown,
callback: (error: Error | null, stdout: string, stderr: string) => void,
) => {
callback(
null,
JSON.stringify({
accessToken: "test-token",
expiresOn: new Date(Date.now() + 60_000).toISOString(),
}),
"",
);
},
);
ensureAuthProfileStoreMock.mockReturnValueOnce({ profiles: {} });
const prepared = await provider.prepareRuntimeAuth?.({
provider: "microsoft-foundry",
modelId: "deployment-gpt5",
model: {
provider: "microsoft-foundry",
id: "deployment-gpt5",
name: "gpt-5.4",
api: "openai-responses",
baseUrl: "https://example.services.ai.azure.com/api/projects/demo/openai/v1/responses",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 16_384,
},
apiKey: "__entra_id_dynamic__",
authMode: "api_key",
profileId: "microsoft-foundry:entra",
env: process.env,
agentDir: "/tmp/test-agent",
});
expect(prepared?.baseUrl).toBe(
"https://example.services.ai.azure.com/api/projects/demo/openai/v1",
);
});
it("normalizes pasted Foundry responses request URLs to the resource endpoint", () => {
expect(
normalizeFoundryEndpoint(
"https://example.services.ai.azure.com/openai/v1/responses?api-version=preview",
),
).toBe("https://example.services.ai.azure.com");
});
it("includes api-version for non GPT-5 chat completion connection tests", () => {
const testRequest = buildFoundryConnectionTest({
endpoint: "https://example.services.ai.azure.com",
modelId: "FW-GLM-5",
modelNameHint: "FW-GLM-5",
api: "openai-completions",
});
expect(testRequest.url).toContain("/chat/completions");
expect(testRequest.body).toMatchObject({
model: "FW-GLM-5",
max_tokens: 1,
});
});
it("returns actionable Azure CLI login errors", async () => {
execFileMock.mockImplementationOnce(
(
_file: unknown,
_args: unknown,
_options: unknown,
callback: (error: Error | null, stdout: string, stderr: string) => void,
) => {
callback(new Error("az failed"), "", "Please run 'az login' to setup account.");
},
);
await expect(getAccessTokenResultAsync()).rejects.toThrow("Azure CLI is not logged in");
});
it("keeps Azure API key header overrides when API-key auth uses a secret ref", () => {
const secretRef = {
source: "env" as const,
provider: "default",
id: "AZURE_OPENAI_API_KEY",
};
const result = buildFoundryAuthResult({
profileId: "microsoft-foundry:default",
apiKey: secretRef,
endpoint: "https://example.services.ai.azure.com",
modelId: "gpt-4o",
api: "openai-responses",
authMethod: "api-key",
});
expect(result.configPatch?.models?.providers?.["microsoft-foundry"]).toMatchObject({
apiKey: secretRef,
authHeader: false,
headers: { "api-key": secretRef },
});
});
it("moves the selected Foundry auth profile to the front of auth.order", () => {
const result = buildFoundryAuthResult({
profileId: "microsoft-foundry:entra",
apiKey: "__entra_id_dynamic__",
endpoint: "https://example.services.ai.azure.com",
modelId: "gpt-5.4",
api: "openai-responses",
authMethod: "entra-id",
currentProviderProfileIds: ["microsoft-foundry:default", "microsoft-foundry:entra"],
});
expect(result.configPatch?.auth?.order?.["microsoft-foundry"]).toEqual([
"microsoft-foundry:entra",
"microsoft-foundry:default",
]);
});
it("persists discovered deployments alongside the selected default model", () => {
const result = buildFoundryAuthResult({
profileId: "microsoft-foundry:entra",
apiKey: "__entra_id_dynamic__",
endpoint: "https://example.services.ai.azure.com",
modelId: "deployment-gpt5",
modelNameHint: "gpt-5.4",
api: "openai-responses",
authMethod: "entra-id",
deployments: [
{ name: "deployment-gpt5", modelName: "gpt-5.4", api: "openai-responses" },
{ name: "deployment-gpt4o", modelName: "gpt-4o", api: "openai-responses" },
],
});
const provider = result.configPatch?.models?.providers?.["microsoft-foundry"];
expect(provider?.models.map((model) => model.id)).toEqual([
"deployment-gpt5",
"deployment-gpt4o",
]);
expect(result.defaultModel).toBe("microsoft-foundry/deployment-gpt5");
});
});

View File

@@ -0,0 +1,11 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { buildMicrosoftFoundryProvider } from "./provider.js";
export default definePluginEntry({
id: "microsoft-foundry",
name: "Microsoft Foundry Provider",
description: "Microsoft Foundry provider with Entra ID and API key auth",
register(api) {
api.registerProvider(buildMicrosoftFoundryProvider());
},
});

View File

@@ -0,0 +1,499 @@
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",
);
}
}

View File

@@ -0,0 +1,35 @@
{
"id": "microsoft-foundry",
"enabledByDefault": true,
"providers": ["microsoft-foundry"],
"providerAuthEnvVars": {
"microsoft-foundry": ["AZURE_OPENAI_API_KEY"]
},
"providerAuthChoices": [
{
"provider": "microsoft-foundry",
"method": "entra-id",
"choiceId": "microsoft-foundry-entra",
"choiceLabel": "Microsoft Foundry (Entra ID / az login)",
"choiceHint": "Use your Azure login — no API key needed",
"groupId": "microsoft-foundry",
"groupLabel": "Microsoft Foundry",
"groupHint": "Entra ID + API key"
},
{
"provider": "microsoft-foundry",
"method": "api-key",
"choiceId": "microsoft-foundry-apikey",
"choiceLabel": "Microsoft Foundry (API key)",
"choiceHint": "Use an Azure OpenAI API key directly",
"groupId": "microsoft-foundry",
"groupLabel": "Microsoft Foundry",
"groupHint": "Entra ID + API key"
}
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,12 @@
{
"name": "@openclaw/microsoft-foundry",
"version": "2026.3.14",
"private": true,
"description": "OpenClaw Microsoft Foundry provider plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,109 @@
import type { ProviderNormalizeResolvedModelContext } from "openclaw/plugin-sdk/core";
import type { ModelProviderConfig, ProviderPlugin } from "openclaw/plugin-sdk/provider-models";
import { apiKeyAuthMethod, entraIdAuthMethod } from "./auth.js";
import { prepareFoundryRuntimeAuth } from "./runtime.js";
import {
PROVIDER_ID,
applyFoundryProfileBinding,
applyFoundryProviderConfig,
buildFoundryModelCompat,
buildFoundryProviderBaseUrl,
extractFoundryEndpoint,
isFoundryProviderApi,
normalizeFoundryEndpoint,
resolveConfiguredModelNameHint,
resolveFoundryApi,
resolveFoundryTargetProfileId,
} from "./shared.js";
export function buildMicrosoftFoundryProvider(): ProviderPlugin {
return {
id: PROVIDER_ID,
label: "Microsoft Foundry",
docsPath: "/providers/models",
envVars: ["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT"],
auth: [entraIdAuthMethod, apiKeyAuthMethod],
capabilities: {
providerFamily: "openai" as const,
},
onModelSelected: async (ctx) => {
const providerConfig = ctx.config.models?.providers?.[PROVIDER_ID];
if (!providerConfig || !ctx.model.startsWith(`${PROVIDER_ID}/`)) {
return;
}
const selectedModelId = ctx.model.slice(`${PROVIDER_ID}/`.length);
const existingModel = providerConfig.models.find(
(model: { id: string }) => model.id === selectedModelId,
);
const selectedModelNameHint = resolveConfiguredModelNameHint(
selectedModelId,
existingModel?.name,
);
const providerEndpoint = normalizeFoundryEndpoint(providerConfig.baseUrl ?? "");
// Prefer the persisted per-model API choice from onboarding/discovery so arbitrary
// deployment aliases (for example prod-primary) do not fall back to name heuristics.
const selectedModelApi = isFoundryProviderApi(existingModel?.api)
? existingModel.api
: providerConfig.api;
const selectedModelCompat = buildFoundryModelCompat(
selectedModelId,
selectedModelNameHint,
selectedModelApi,
);
const nextModels = providerConfig.models.map((model) =>
model.id === selectedModelId
? {
...model,
api: resolveFoundryApi(selectedModelId, selectedModelNameHint, selectedModelApi),
...(selectedModelCompat ? { compat: selectedModelCompat } : {}),
}
: model,
);
if (!nextModels.some((model) => model.id === selectedModelId)) {
nextModels.push({
id: selectedModelId,
name: selectedModelNameHint ?? selectedModelId,
api: resolveFoundryApi(selectedModelId, selectedModelNameHint, selectedModelApi),
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 16_384,
...(selectedModelCompat ? { compat: selectedModelCompat } : {}),
});
}
const nextProviderConfig: ModelProviderConfig = {
...providerConfig,
baseUrl: buildFoundryProviderBaseUrl(
providerEndpoint,
selectedModelId,
selectedModelNameHint,
selectedModelApi,
),
api: resolveFoundryApi(selectedModelId, selectedModelNameHint, selectedModelApi),
models: nextModels,
};
const targetProfileId = resolveFoundryTargetProfileId(ctx.config);
if (targetProfileId) {
applyFoundryProfileBinding(ctx.config, targetProfileId);
}
applyFoundryProviderConfig(ctx.config, nextProviderConfig);
},
normalizeResolvedModel: ({ modelId, model }: ProviderNormalizeResolvedModelContext) => {
const endpoint = extractFoundryEndpoint(String(model.baseUrl ?? ""));
if (!endpoint) {
return model;
}
const modelNameHint = resolveConfiguredModelNameHint(modelId, model.name);
const configuredApi = isFoundryProviderApi(model.api) ? model.api : undefined;
const compat = buildFoundryModelCompat(modelId, modelNameHint, configuredApi);
return {
...model,
api: resolveFoundryApi(modelId, modelNameHint, configuredApi),
baseUrl: buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint, configuredApi),
...(compat ? { compat } : {}),
};
},
prepareRuntimeAuth: prepareFoundryRuntimeAuth,
};
}

View File

@@ -0,0 +1,101 @@
import type { ProviderPrepareRuntimeAuthContext } from "openclaw/plugin-sdk/core";
import { ensureAuthProfileStore } from "openclaw/plugin-sdk/provider-auth";
import { getAccessTokenResultAsync } from "./cli.js";
import {
type CachedTokenEntry,
TOKEN_REFRESH_MARGIN_MS,
buildFoundryProviderBaseUrl,
extractFoundryEndpoint,
getFoundryTokenCacheKey,
isFoundryProviderApi,
resolveConfiguredModelNameHint,
} from "./shared-runtime.js";
const cachedTokens = new Map<string, CachedTokenEntry>();
const refreshPromises = new Map<string, Promise<{ apiKey: string; expiresAt: number }>>();
export function resetFoundryRuntimeAuthCaches(): void {
cachedTokens.clear();
refreshPromises.clear();
}
async function refreshEntraToken(params?: {
subscriptionId?: string;
tenantId?: string;
}): Promise<{ apiKey: string; expiresAt: number }> {
const result = await getAccessTokenResultAsync(params);
const rawExpiry = result.expiresOn ? new Date(result.expiresOn).getTime() : Number.NaN;
const expiresAt = Number.isFinite(rawExpiry) ? rawExpiry : Date.now() + 55 * 60 * 1000;
cachedTokens.set(getFoundryTokenCacheKey(params), {
token: result.accessToken,
expiresAt,
});
return { apiKey: result.accessToken, expiresAt };
}
export async function prepareFoundryRuntimeAuth(ctx: ProviderPrepareRuntimeAuthContext) {
if (ctx.apiKey !== "__entra_id_dynamic__") {
return null;
}
try {
const authStore = ensureAuthProfileStore(ctx.agentDir, {
allowKeychainPrompt: false,
});
const credential = ctx.profileId ? authStore.profiles[ctx.profileId] : undefined;
const metadata = credential?.type === "api_key" ? credential.metadata : undefined;
const modelId =
typeof ctx.modelId === "string" && ctx.modelId.trim().length > 0
? ctx.modelId.trim()
: typeof metadata?.modelId === "string" && metadata.modelId.trim().length > 0
? metadata.modelId.trim()
: ctx.modelId;
const activeModelNameHint = ctx.modelId === metadata?.modelId ? metadata?.modelName : undefined;
const modelNameHint = resolveConfiguredModelNameHint(
modelId,
ctx.model.name ?? activeModelNameHint,
);
const configuredApi =
typeof metadata?.api === "string" && isFoundryProviderApi(metadata.api)
? metadata.api
: isFoundryProviderApi(ctx.model.api)
? ctx.model.api
: undefined;
const endpoint =
typeof metadata?.endpoint === "string" && metadata.endpoint.trim().length > 0
? metadata.endpoint.trim()
: extractFoundryEndpoint(ctx.model.baseUrl ?? "");
const baseUrl = endpoint
? buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint, configuredApi)
: undefined;
const cacheKey = getFoundryTokenCacheKey({
subscriptionId: metadata?.subscriptionId,
tenantId: metadata?.tenantId,
});
const cachedToken = cachedTokens.get(cacheKey);
if (cachedToken && cachedToken.expiresAt > Date.now() + TOKEN_REFRESH_MARGIN_MS) {
return {
apiKey: cachedToken.token,
expiresAt: cachedToken.expiresAt,
...(baseUrl ? { baseUrl } : {}),
};
}
let refreshPromise = refreshPromises.get(cacheKey);
if (!refreshPromise) {
refreshPromise = refreshEntraToken({
subscriptionId: metadata?.subscriptionId,
tenantId: metadata?.tenantId,
}).finally(() => {
refreshPromises.delete(cacheKey);
});
refreshPromises.set(cacheKey, refreshPromise);
}
const token = await refreshPromise;
return {
...token,
...(baseUrl ? { baseUrl } : {}),
};
} catch (err) {
const details = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to refresh Azure Entra ID token via az CLI: ${details}`);
}
}

View File

@@ -0,0 +1,15 @@
export {
TOKEN_REFRESH_MARGIN_MS,
buildFoundryProviderBaseUrl,
extractFoundryEndpoint,
isFoundryProviderApi,
resolveConfiguredModelNameHint,
type CachedTokenEntry,
} from "./shared.js";
export function getFoundryTokenCacheKey(params?: {
subscriptionId?: string;
tenantId?: string;
}): string {
return `${params?.subscriptionId ?? ""}:${params?.tenantId ?? ""}`;
}

View File

@@ -0,0 +1,437 @@
import {
applyAuthProfileConfig,
buildApiKeyCredential,
type ProviderAuthResult,
type SecretInput,
} from "openclaw/plugin-sdk/provider-auth";
import type { ModelApi, ModelProviderConfig } from "openclaw/plugin-sdk/provider-models";
export const PROVIDER_ID = "microsoft-foundry";
export const DEFAULT_API = "openai-completions";
export const DEFAULT_GPT5_API = "openai-responses";
export const COGNITIVE_SERVICES_RESOURCE = "https://cognitiveservices.azure.com";
export const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;
export interface AzAccount {
name: string;
id: string;
tenantId?: string;
user?: { name?: string };
state?: string;
isDefault?: boolean;
}
export interface AzAccessToken {
accessToken: string;
expiresOn?: string;
}
export interface AzCognitiveAccount {
id: string;
name: string;
kind: string;
location?: string;
resourceGroup?: string;
endpoint?: string | null;
customSubdomain?: string | null;
projects?: string[] | null;
}
export interface FoundryResourceOption {
id: string;
accountName: string;
kind: "AIServices" | "OpenAI";
location?: string;
resourceGroup: string;
endpoint: string;
projects: string[];
}
export interface AzDeploymentSummary {
name: string;
modelName?: string;
modelVersion?: string;
state?: string;
sku?: string;
}
export type FoundrySelection = {
endpoint: string;
modelId: string;
modelNameHint?: string;
api: FoundryProviderApi;
};
export type CachedTokenEntry = {
token: string;
expiresAt: number;
};
export type FoundryProviderApi = typeof DEFAULT_API | typeof DEFAULT_GPT5_API;
export type FoundryDeploymentConfigInput = {
name: string;
modelName?: string;
api?: FoundryProviderApi;
};
type FoundryModelCompat = {
supportsStore?: boolean;
maxTokensField: "max_completion_tokens" | "max_tokens";
};
type FoundryAuthProfileConfig = {
provider: string;
mode: "api_key" | "oauth" | "token";
email?: string;
};
type FoundryConfigShape = {
auth?: {
profiles?: Record<string, FoundryAuthProfileConfig>;
order?: Record<string, string[]>;
};
models?: {
providers?: Record<string, ModelProviderConfig>;
};
};
export function normalizeFoundryModelName(value?: string | null): string | undefined {
const trimmed = typeof value === "string" ? value.trim().toLowerCase() : "";
return trimmed || undefined;
}
export function usesFoundryResponsesByDefault(value?: string | null): boolean {
const normalized = normalizeFoundryModelName(value);
if (!normalized) {
return false;
}
return (
normalized.startsWith("gpt-") ||
normalized.startsWith("o1") ||
normalized.startsWith("o3") ||
normalized.startsWith("o4") ||
normalized === "computer-use-preview"
);
}
export function requiresFoundryMaxCompletionTokens(value?: string | null): boolean {
const normalized = normalizeFoundryModelName(value);
if (!normalized) {
return false;
}
return (
normalized.startsWith("gpt-5") ||
normalized.startsWith("o1") ||
normalized.startsWith("o3") ||
normalized.startsWith("o4")
);
}
export function isFoundryProviderApi(value?: string | null): value is FoundryProviderApi {
return value === DEFAULT_API || value === DEFAULT_GPT5_API;
}
export function normalizeFoundryEndpoint(endpoint: string): string {
const trimmed = endpoint.trim();
if (!trimmed) {
return trimmed;
}
try {
const parsed = new URL(trimmed);
parsed.search = "";
parsed.hash = "";
const normalizedPath = parsed.pathname.replace(/\/openai(?:$|\/).*/i, "").replace(/\/+$/, "");
return `${parsed.origin}${normalizedPath && normalizedPath !== "/" ? normalizedPath : ""}`;
} catch {
const withoutQuery = trimmed.replace(/[?#].*$/, "").replace(/\/+$/, "");
return withoutQuery.replace(/\/openai(?:$|\/).*/i, "");
}
}
export function buildFoundryV1BaseUrl(endpoint: string): string {
const base = normalizeFoundryEndpoint(endpoint);
return base.endsWith("/openai/v1") ? base : `${base}/openai/v1`;
}
export function resolveFoundryApi(
modelId: string,
modelNameHint?: string | null,
configuredApi?: ModelApi | string | null,
): FoundryProviderApi {
if (isFoundryProviderApi(configuredApi)) {
return configuredApi;
}
const configuredModelName = resolveConfiguredModelNameHint(modelId, modelNameHint);
return usesFoundryResponsesByDefault(configuredModelName) ? DEFAULT_GPT5_API : DEFAULT_API;
}
export function buildFoundryProviderBaseUrl(
endpoint: string,
modelId: string,
modelNameHint?: string | null,
configuredApi?: ModelApi | string | null,
): string {
return buildFoundryV1BaseUrl(endpoint);
}
export function extractFoundryEndpoint(baseUrl: string | null | undefined): string | undefined {
if (!baseUrl) {
return undefined;
}
try {
return normalizeFoundryEndpoint(baseUrl);
} catch {
return undefined;
}
}
export function buildFoundryModelCompat(
modelId: string,
modelNameHint?: string | null,
configuredApi?: ModelApi | string | null,
): FoundryModelCompat | undefined {
const resolvedApi = resolveFoundryApi(modelId, modelNameHint, configuredApi);
const configuredModelName = resolveConfiguredModelNameHint(modelId, modelNameHint);
const needsMaxCompletionTokens = requiresFoundryMaxCompletionTokens(configuredModelName);
if (resolvedApi !== DEFAULT_GPT5_API && !needsMaxCompletionTokens) {
return undefined;
}
return {
...(resolvedApi === DEFAULT_GPT5_API ? { supportsStore: false } : {}),
maxTokensField: needsMaxCompletionTokens ? "max_completion_tokens" : "max_tokens",
};
}
export function resolveConfiguredModelNameHint(
modelId: string,
modelNameHint?: string | null,
): string | undefined {
const trimmedName = typeof modelNameHint === "string" ? modelNameHint.trim() : "";
if (trimmedName) {
return trimmedName;
}
const trimmedId = modelId.trim();
return trimmedId ? trimmedId : undefined;
}
export function buildFoundryProviderConfig(
endpoint: string,
modelId: string,
modelNameHint?: string | null,
options?: {
api?: FoundryProviderApi;
authMethod?: "api-key" | "entra-id";
apiKey?: SecretInput;
deployments?: FoundryDeploymentConfigInput[];
},
): ModelProviderConfig {
const runtimeApiKey = options?.authMethod === "api-key" ? options.apiKey : undefined;
const isApiKeyAuth = options?.authMethod === "api-key";
const deployments = options?.deployments?.length
? options.deployments
: [{ name: modelId, modelName: modelNameHint ?? undefined }];
const resolvedApi = resolveFoundryApi(modelId, modelNameHint, options?.api);
return {
baseUrl: buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint, resolvedApi),
api: resolvedApi,
...(isApiKeyAuth
? {
authHeader: false,
...(runtimeApiKey !== undefined
? { apiKey: runtimeApiKey, headers: { "api-key": runtimeApiKey } }
: {}),
}
: {}),
models: deployments.map((deployment) => {
const configuredName = resolveConfiguredModelNameHint(deployment.name, deployment.modelName);
const api = resolveFoundryApi(deployment.name, configuredName, deployment.api);
const compat = buildFoundryModelCompat(deployment.name, configuredName, api);
return {
id: deployment.name,
name: configuredName ?? deployment.name,
api,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 16_384,
...(compat ? { compat } : {}),
};
}),
};
}
function buildFoundryCredentialMetadata(params: {
authMethod: "api-key" | "entra-id";
endpoint: string;
modelId: string;
modelNameHint?: string | null;
api?: FoundryProviderApi;
subscriptionId?: string;
subscriptionName?: string;
tenantId?: string;
}): Record<string, string> {
const resolvedApi = resolveFoundryApi(params.modelId, params.modelNameHint, params.api);
const metadata: Record<string, string> = {
authMethod: params.authMethod,
endpoint: params.endpoint,
modelId: params.modelId,
api: resolvedApi,
};
const modelName = resolveConfiguredModelNameHint(params.modelId, params.modelNameHint);
if (modelName) {
metadata.modelName = modelName;
}
if (params.subscriptionId) {
metadata.subscriptionId = params.subscriptionId;
}
if (params.subscriptionName) {
metadata.subscriptionName = params.subscriptionName;
}
if (params.tenantId) {
metadata.tenantId = params.tenantId;
}
return metadata;
}
/**
* Build the plugins.allow patch so the provider is allowlisted when the
* config already gates plugins via a non-empty allow array. Returns an
* empty object when no patch is needed (allowlist absent / already listed).
*/
function buildPluginsAllowPatch(
currentAllow: string[] | undefined,
): { plugins: { allow: string[] } } | Record<string, never> {
if (!Array.isArray(currentAllow) || currentAllow.length === 0) {
return {};
}
if (currentAllow.includes(PROVIDER_ID)) {
return {};
}
return { plugins: { allow: [...currentAllow, PROVIDER_ID] } };
}
function buildFoundryAuthOrderPatch(params: {
profileId: string;
currentProviderProfileIds?: string[];
}): { auth: { order: Record<string, string[]> } } {
const nextOrder = [
params.profileId,
...(params.currentProviderProfileIds ?? []).filter(
(profileId) => profileId !== params.profileId,
),
];
return {
auth: {
order: {
[PROVIDER_ID]: nextOrder,
},
},
};
}
export function listConfiguredFoundryProfileIds(config: FoundryConfigShape): string[] {
return Object.entries(config.auth?.profiles ?? {})
.filter(([, profile]) => profile.provider === PROVIDER_ID)
.map(([profileId]) => profileId);
}
export function buildFoundryAuthResult(params: {
profileId: string;
apiKey: SecretInput;
secretInputMode?: "plaintext" | "ref";
endpoint: string;
modelId: string;
modelNameHint?: string | null;
api: FoundryProviderApi;
authMethod: "api-key" | "entra-id";
subscriptionId?: string;
subscriptionName?: string;
tenantId?: string;
notes?: string[];
/** Current plugins.allow so the provider can self-allowlist during onboard. */
currentPluginsAllow?: string[];
currentProviderProfileIds?: string[];
deployments?: FoundryDeploymentConfigInput[];
}): ProviderAuthResult {
return {
profiles: [
{
profileId: params.profileId,
credential: buildApiKeyCredential(
PROVIDER_ID,
params.apiKey,
buildFoundryCredentialMetadata({
authMethod: params.authMethod,
endpoint: params.endpoint,
modelId: params.modelId,
modelNameHint: params.modelNameHint,
api: params.api,
subscriptionId: params.subscriptionId,
subscriptionName: params.subscriptionName,
tenantId: params.tenantId,
}),
params.secretInputMode ? { secretInputMode: params.secretInputMode } : undefined,
),
},
],
configPatch: {
...buildFoundryAuthOrderPatch({
profileId: params.profileId,
currentProviderProfileIds: params.currentProviderProfileIds,
}),
models: {
providers: {
[PROVIDER_ID]: buildFoundryProviderConfig(
params.endpoint,
params.modelId,
params.modelNameHint,
{
api: params.api,
authMethod: params.authMethod,
apiKey: params.apiKey,
deployments: params.deployments,
},
),
},
},
...buildPluginsAllowPatch(params.currentPluginsAllow),
},
defaultModel: `${PROVIDER_ID}/${params.modelId}`,
notes: params.notes,
};
}
export function applyFoundryProfileBinding(config: FoundryConfigShape, profileId: string): void {
const next = applyAuthProfileConfig(config, {
profileId,
provider: PROVIDER_ID,
mode: "api_key",
});
config.auth = next.auth;
}
export function applyFoundryProviderConfig(
config: FoundryConfigShape,
providerConfig: ModelProviderConfig,
): void {
config.models ??= {};
config.models.providers ??= {};
config.models.providers[PROVIDER_ID] = providerConfig;
}
export function resolveFoundryTargetProfileId(config: FoundryConfigShape): string | undefined {
const configuredProfiles = config.auth?.profiles ?? {};
const configuredProfileEntries = Object.entries(configuredProfiles).filter(([, profile]) => {
return profile.provider === PROVIDER_ID;
});
if (configuredProfileEntries.length === 0) {
return undefined;
}
// Prefer the explicitly ordered profile; fall back to the sole entry when there is exactly one.
return (
config.auth?.order?.[PROVIDER_ID]?.find((profileId) => profileId.trim().length > 0) ??
(configuredProfileEntries.length === 1 ? configuredProfileEntries[0]?.[0] : undefined)
);
}

View File

@@ -1784,6 +1784,55 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [
},
},
},
{
dirName: "microsoft-foundry",
idHint: "microsoft-foundry",
source: {
source: "./index.ts",
built: "index.js",
},
packageName: "@openclaw/microsoft-foundry",
packageVersion: "2026.3.14",
packageDescription: "OpenClaw Microsoft Foundry provider plugin",
packageManifest: {
extensions: ["./index.ts"],
},
manifest: {
id: "microsoft-foundry",
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
enabledByDefault: true,
providers: ["microsoft-foundry"],
providerAuthEnvVars: {
"microsoft-foundry": ["AZURE_OPENAI_API_KEY"],
},
providerAuthChoices: [
{
provider: "microsoft-foundry",
method: "entra-id",
choiceId: "microsoft-foundry-entra",
choiceLabel: "Microsoft Foundry (Entra ID / az login)",
choiceHint: "Use your Azure login — no API key needed",
groupId: "microsoft-foundry",
groupLabel: "Microsoft Foundry",
groupHint: "Entra ID + API key",
},
{
provider: "microsoft-foundry",
method: "api-key",
choiceId: "microsoft-foundry-apikey",
choiceLabel: "Microsoft Foundry (API key)",
choiceHint: "Use an Azure OpenAI API key directly",
groupId: "microsoft-foundry",
groupLabel: "Microsoft Foundry",
groupHint: "Entra ID + API key",
},
],
},
},
{
dirName: "minimax",
idHint: "minimax",

View File

@@ -16,6 +16,7 @@ export const BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES = {
kilocode: ["KILOCODE_API_KEY"],
kimi: ["KIMI_API_KEY", "KIMICODE_API_KEY"],
"kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"],
"microsoft-foundry": ["AZURE_OPENAI_API_KEY"],
minimax: ["MINIMAX_API_KEY"],
"minimax-portal": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"],
mistral: ["MISTRAL_API_KEY"],