mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 09:21:35 +07:00
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:
253
extensions/microsoft-foundry/auth.ts
Normal file
253
extensions/microsoft-foundry/auth.ts
Normal 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}`],
|
||||
});
|
||||
},
|
||||
};
|
||||
191
extensions/microsoft-foundry/cli.ts
Normal file
191
extensions/microsoft-foundry/cli.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
875
extensions/microsoft-foundry/index.test.ts
Normal file
875
extensions/microsoft-foundry/index.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
11
extensions/microsoft-foundry/index.ts
Normal file
11
extensions/microsoft-foundry/index.ts
Normal 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());
|
||||
},
|
||||
});
|
||||
499
extensions/microsoft-foundry/onboard.ts
Normal file
499
extensions/microsoft-foundry/onboard.ts
Normal 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",
|
||||
);
|
||||
}
|
||||
}
|
||||
35
extensions/microsoft-foundry/openclaw.plugin.json
Normal file
35
extensions/microsoft-foundry/openclaw.plugin.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
12
extensions/microsoft-foundry/package.json
Normal file
12
extensions/microsoft-foundry/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
109
extensions/microsoft-foundry/provider.ts
Normal file
109
extensions/microsoft-foundry/provider.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
101
extensions/microsoft-foundry/runtime.ts
Normal file
101
extensions/microsoft-foundry/runtime.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
15
extensions/microsoft-foundry/shared-runtime.ts
Normal file
15
extensions/microsoft-foundry/shared-runtime.ts
Normal 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 ?? ""}`;
|
||||
}
|
||||
437
extensions/microsoft-foundry/shared.ts
Normal file
437
extensions/microsoft-foundry/shared.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user