重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1,255 @@
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) =>
Object.assign(
{ 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,199 @@
import { execFile, execFileSync, spawn } from "node:child_process";
import {
normalizeOptionalString,
normalizeStringifiedOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
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(`${stderr ?? ""} ${stdout ?? ""}`);
return new Error(details ? `${error.message}: ${details}` : error.message);
}
export function execAz(args: string[]): string {
return (
normalizeOptionalString(
execFileSync("az", args, {
encoding: "utf-8",
timeout: 30_000,
shell: process.platform === "win32",
}),
) ?? ""
);
}
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, stderr ?? "", stdout ?? ""));
return;
}
resolve(normalizeStringifiedOptionalString(stdout) ?? "");
},
);
});
}
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 = normalizeOptionalString([...stderrChunks, ...stdoutChunks].join("")) ?? "";
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,723 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createTestPluginApi } from "../../test/helpers/plugins/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,
supportsFoundryImageInput,
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];
}
const defaultFoundryBaseUrl = "https://example.services.ai.azure.com/openai/v1";
const defaultFoundryProviderId = "microsoft-foundry";
const defaultFoundryModelId = "gpt-5.4";
const defaultFoundryProfileId = "microsoft-foundry:entra";
const defaultFoundryAgentDir = "/tmp/test-agent";
const defaultAzureCliLoginError = "Please run 'az login' to setup account.";
function buildFoundryModel(
overrides: Partial<{
provider: string;
id: string;
name: string;
api: "openai-responses" | "openai-completions";
baseUrl: string;
input: Array<"text" | "image">;
}> = {},
) {
return {
provider: defaultFoundryProviderId,
id: defaultFoundryModelId,
name: defaultFoundryModelId,
api: "openai-responses" as const,
baseUrl: defaultFoundryBaseUrl,
reasoning: false,
input: ["text" as const],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 16_384,
...overrides,
};
}
function buildFoundryConfig(params?: {
profileIds?: string[];
orderedProfileIds?: string[];
models?: ReturnType<typeof buildFoundryModel>[];
}) {
const profileIds = params?.profileIds ?? [];
const orderedProfileIds = params?.orderedProfileIds;
return {
auth: {
profiles: Object.fromEntries(
profileIds.map((profileId) => [
profileId,
{
provider: defaultFoundryProviderId,
mode: "api_key" as const,
},
]),
),
...(orderedProfileIds
? {
order: {
[defaultFoundryProviderId]: orderedProfileIds,
},
}
: {}),
},
models: {
providers: {
[defaultFoundryProviderId]: {
baseUrl: defaultFoundryBaseUrl,
api: "openai-responses" as const,
models: params?.models ?? [buildFoundryModel()],
},
},
},
} satisfies OpenClawConfig;
}
function buildEntraProfileStore(
overrides: Partial<{
endpoint: string;
modelId: string;
modelName: string;
tenantId: string;
}> = {},
) {
return {
profiles: {
[defaultFoundryProfileId]: {
type: "api_key",
provider: defaultFoundryProviderId,
metadata: {
authMethod: "entra-id",
endpoint: "https://example.services.ai.azure.com",
modelId: "custom-deployment",
modelName: defaultFoundryModelId,
tenantId: "tenant-id",
...overrides,
},
},
},
};
}
function buildFoundryRuntimeAuthContext(
overrides: Partial<{
provider: string;
modelId: string;
model: ReturnType<typeof buildFoundryModel>;
apiKey: string;
authMode: "api_key";
profileId: string;
agentDir: string;
}> = {},
) {
const modelId = overrides.modelId ?? "custom-deployment";
return {
provider: defaultFoundryProviderId,
modelId,
model: buildFoundryModel({ id: modelId, ...("model" in overrides ? overrides.model : {}) }),
apiKey: "__entra_id_dynamic__",
authMode: "api_key" as const,
profileId: defaultFoundryProfileId,
env: process.env,
agentDir: defaultFoundryAgentDir,
...overrides,
};
}
function mockAzureCliToken(params: { accessToken: string; expiresInMs: number; delayMs?: number }) {
execFileMock.mockImplementationOnce(
(
_file: unknown,
_args: unknown,
_options: unknown,
callback: (error: Error | null, stdout: string, stderr: string) => void,
) => {
const respond = () =>
callback(
null,
JSON.stringify({
accessToken: params.accessToken,
expiresOn: new Date(Date.now() + params.expiresInMs).toISOString(),
}),
"",
);
if (params.delayMs) {
setTimeout(respond, params.delayMs);
return;
}
respond();
},
);
}
function mockAzureCliLoginFailure(delayMs?: number) {
execFileMock.mockImplementationOnce(
(
_file: unknown,
_args: unknown,
_options: unknown,
callback: (error: Error | null, stdout: string, stderr: string) => void,
) => {
const respond = () => {
callback(new Error("az failed"), "", defaultAzureCliLoginError);
};
if (delayMs) {
setTimeout(respond, delayMs);
return;
}
respond();
},
);
}
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 = buildFoundryConfig({
profileIds: ["microsoft-foundry:default", "microsoft-foundry:entra"],
});
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 = buildFoundryConfig({
profileIds: ["microsoft-foundry:default"],
orderedProfileIds: ["microsoft-foundry:default"],
});
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();
mockAzureCliToken({ accessToken: "test-token", expiresInMs: 60_000 });
ensureAuthProfileStoreMock.mockReturnValueOnce(buildEntraProfileStore());
const prepared = await provider.prepareRuntimeAuth?.(buildFoundryRuntimeAuthContext());
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();
mockAzureCliLoginFailure();
mockAzureCliToken({ accessToken: "retry-token", expiresInMs: 10 * 60_000 });
ensureAuthProfileStoreMock.mockReturnValue(buildEntraProfileStore());
const runtimeContext = buildFoundryRuntimeAuthContext();
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();
mockAzureCliToken({ accessToken: "deduped-token", expiresInMs: 60_000, delayMs: 10 });
ensureAuthProfileStoreMock.mockReturnValue(buildEntraProfileStore());
const runtimeContext = buildFoundryRuntimeAuthContext();
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();
mockAzureCliLoginFailure(10);
mockAzureCliToken({ accessToken: "recovered-token", expiresInMs: 10 * 60_000, delayMs: 10 });
ensureAuthProfileStoreMock.mockReturnValue(buildEntraProfileStore());
const runtimeContext = buildFoundryRuntimeAuthContext();
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();
mockAzureCliToken({ accessToken: "soon-expiring-token", expiresInMs: 60_000 });
mockAzureCliToken({ accessToken: "fresh-token", expiresInMs: 10 * 60_000 });
ensureAuthProfileStoreMock.mockReturnValue(buildEntraProfileStore());
const runtimeContext = buildFoundryRuntimeAuthContext();
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"]);
expect(config.models?.providers?.["microsoft-foundry"]?.models[0]?.input).toEqual([
"text",
"image",
]);
});
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);
expect(supportsFoundryImageInput("gpt-5.4")).toBe(true);
expect(supportsFoundryImageInput("gpt-4o")).toBe(true);
expect(supportsFoundryImageInput("MAI-DS-R1")).toBe(false);
});
it("records GPT-family Foundry deployments as image-capable during auth setup", () => {
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",
});
expect(result.configPatch?.models?.providers?.["microsoft-foundry"]?.models[0]?.input).toEqual([
"text",
"image",
]);
});
it("normalizes stale resolved Foundry rows to provider-owned image capability metadata", () => {
const provider = registerProvider();
const normalized = provider.normalizeResolvedModel?.({
provider: "microsoft-foundry",
modelId: "deployment-gpt5",
model: buildFoundryModel({
id: "deployment-gpt5",
name: "gpt-5.4",
input: ["text"],
}),
});
expect(normalized).toMatchObject({
name: "gpt-5.4",
api: "openai-responses",
input: ["text", "image"],
baseUrl: "https://example.services.ai.azure.com/openai/v1",
compat: {
supportsStore: false,
maxTokensField: "max_completion_tokens",
},
});
});
it("preserves explicit image capability for non-heuristic Foundry deployments", () => {
const provider = registerProvider();
const normalized = provider.normalizeResolvedModel?.({
provider: "microsoft-foundry",
modelId: "custom-vision-deployment",
model: buildFoundryModel({
id: "custom-vision-deployment",
name: "internal alias",
input: ["text", "image"],
}),
});
expect(normalized).toMatchObject({
name: "internal alias",
input: ["text", "image"],
});
});
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();
mockAzureCliToken({ accessToken: "test-token", expiresInMs: 60_000 });
ensureAuthProfileStoreMock.mockReturnValueOnce({ profiles: {} });
const prepared = await provider.prepareRuntimeAuth?.(
buildFoundryRuntimeAuthContext({
modelId: "deployment-gpt5",
model: buildFoundryModel({
id: "deployment-gpt5",
baseUrl: "https://example.services.ai.azure.com/api/projects/demo/openai/v1/responses",
}),
}),
);
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 () => {
mockAzureCliLoginFailure();
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,510 @@
import type { ProviderAuthContext } from "openclaw/plugin-sdk/core";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import {
normalizeOptionalString,
normalizeStringifiedOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
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 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 [];
}
}
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 = (
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 = 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: "gpt-4o",
validate: (v) => {
const val = normalizeStringifiedOptionalString(v) ?? "";
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 = 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({
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",
},
body: JSON.stringify(testRequest.body),
},
timeoutMs: 15_000,
});
try {
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!", "✓");
}
} finally {
await release();
}
} 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,15 @@
{
"name": "@openclaw/microsoft-foundry",
"version": "2026.4.20",
"private": true,
"description": "OpenClaw Microsoft Foundry provider plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View file

@ -0,0 +1,118 @@
import type { ProviderNormalizeResolvedModelContext } from "openclaw/plugin-sdk/core";
import type {
ModelProviderConfig,
ProviderPlugin,
} from "openclaw/plugin-sdk/provider-model-shared";
import { apiKeyAuthMethod, entraIdAuthMethod } from "./auth.js";
import { prepareFoundryRuntimeAuth } from "./runtime.js";
import {
PROVIDER_ID,
applyFoundryProfileBinding,
applyFoundryProviderConfig,
buildFoundryProviderBaseUrl,
extractFoundryEndpoint,
isFoundryProviderApi,
normalizeFoundryEndpoint,
resolveFoundryModelCapabilities,
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],
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 selectedModelCapabilities = resolveFoundryModelCapabilities(
selectedModelId,
existingModel?.name,
isFoundryProviderApi(existingModel?.api) ? existingModel.api : providerConfig.api,
existingModel?.input,
);
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 nextModels = providerConfig.models.map((model) =>
model.id === selectedModelId
? {
...model,
name: selectedModelCapabilities.modelName,
api: selectedModelCapabilities.api,
input: selectedModelCapabilities.input,
...(selectedModelCapabilities.compat
? { compat: selectedModelCapabilities.compat }
: {}),
}
: model,
);
if (!nextModels.some((model) => model.id === selectedModelId)) {
nextModels.push({
id: selectedModelId,
name: selectedModelCapabilities.modelName,
api: selectedModelCapabilities.api,
reasoning: false,
input: selectedModelCapabilities.input,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 16_384,
...(selectedModelCapabilities.compat ? { compat: selectedModelCapabilities.compat } : {}),
});
}
const nextProviderConfig: ModelProviderConfig = {
...providerConfig,
baseUrl: buildFoundryProviderBaseUrl(
providerEndpoint,
selectedModelId,
selectedModelCapabilities.modelName,
selectedModelApi,
),
api: selectedModelCapabilities.api,
models: nextModels,
};
const targetProfileId = resolveFoundryTargetProfileId(ctx.config);
if (targetProfileId) {
applyFoundryProfileBinding(ctx.config, targetProfileId);
}
applyFoundryProviderConfig(ctx.config, nextProviderConfig);
},
normalizeResolvedModel: ({ modelId, model }: ProviderNormalizeResolvedModelContext) => {
const endpoint = extractFoundryEndpoint(model.baseUrl ?? "");
if (!endpoint) {
return model;
}
const capabilities = resolveFoundryModelCapabilities(
modelId,
model.name,
isFoundryProviderApi(model.api) ? model.api : undefined,
model.input,
);
return {
...model,
name: capabilities.modelName,
api: capabilities.api,
input: capabilities.input,
baseUrl: buildFoundryProviderBaseUrl(
endpoint,
modelId,
capabilities.modelName,
capabilities.api,
),
...(capabilities.compat ? { compat: capabilities.compat } : {}),
};
},
prepareRuntimeAuth: prepareFoundryRuntimeAuth,
};
}

View file

@ -0,0 +1,102 @@
import type { ProviderPrepareRuntimeAuthContext } from "openclaw/plugin-sdk/core";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { ensureAuthProfileStore } from "openclaw/plugin-sdk/provider-auth";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
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 =
normalizeOptionalString(ctx.modelId) ??
normalizeOptionalString(metadata?.modelId) ??
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 =
normalizeOptionalString(metadata?.endpoint) ??
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 = formatErrorMessage(err);
throw new Error(`Failed to refresh Azure Entra ID token via az CLI: ${details}`, {
cause: err,
});
}
}

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,493 @@
import {
applyAuthProfileConfig,
buildApiKeyCredential,
type ProviderAuthResult,
type SecretInput,
} from "openclaw/plugin-sdk/provider-auth";
import type { ModelApi, ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
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;
};
export type FoundryModelCapabilities = {
modelName: string;
api: FoundryProviderApi;
input: Array<"text" | "image">;
compat?: FoundryModelCompat;
};
function normalizeModelInput(input?: unknown): Array<"text" | "image"> {
const normalized = Array.isArray(input)
? input.filter((item): item is "text" | "image" => item === "text" || item === "image")
: [];
return normalized.length > 0 ? normalized : ["text"];
}
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 = normalizeLowercaseStringOrEmpty(value);
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 supportsFoundryImageInput(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 = normalizeOptionalString(endpoint) ?? "";
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 | 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 | 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 | 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 resolveFoundryModelCapabilities(
modelId: string,
modelNameHint?: string | null,
configuredApi?: ModelApi | null,
existingInput?: unknown,
): FoundryModelCapabilities {
const modelName = resolveConfiguredModelNameHint(modelId, modelNameHint) ?? modelId;
const api = resolveFoundryApi(modelId, modelName, configuredApi);
const normalizedInput = normalizeModelInput(existingInput);
return {
modelName,
api,
input:
normalizedInput.includes("image") || supportsFoundryImageInput(modelName)
? ["text", "image"]
: normalizedInput,
compat: buildFoundryModelCompat(modelId, modelName, api),
};
}
export function resolveConfiguredModelNameHint(
modelId: string,
modelNameHint?: string | null,
): string | undefined {
const trimmedName = normalizeOptionalString(modelNameHint) ?? "";
if (trimmedName) {
return trimmedName;
}
const trimmedId = normalizeOptionalString(modelId) ?? "";
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 capabilities = resolveFoundryModelCapabilities(
deployment.name,
deployment.modelName,
deployment.api,
);
return Object.assign(
{
id: deployment.name,
name: capabilities.modelName,
api: capabilities.api,
reasoning: false,
input: capabilities.input,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128e3,
maxTokens: 16384,
},
capabilities.compat ? { compat: capabilities.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) => normalizeOptionalString(profileId)) ??
(configuredProfileEntries.length === 1 ? configuredProfileEntries[0]?.[0] : undefined)
);
}

View file

@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}