Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
273
extensions/microsoft-foundry/auth.ts
Normal file
273
extensions/microsoft-foundry/auth.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
// Microsoft Foundry plugin module implements auth behavior.
|
||||
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,
|
||||
formatFoundryApiLabel,
|
||||
type FoundryProviderApi,
|
||||
isFoundryMaiImageModel,
|
||||
listConfiguredFoundryProfileIds,
|
||||
PROVIDER_ID,
|
||||
resolveConfiguredModelNameHint,
|
||||
resolveFoundryApi,
|
||||
} from "./shared.js";
|
||||
|
||||
export function shouldTestFoundryTextConnection(params: {
|
||||
modelId: string;
|
||||
modelNameHint?: string | null;
|
||||
}): boolean {
|
||||
return !isFoundryMaiImageModel(
|
||||
resolveConfiguredModelNameHint(params.modelId, params.modelNameHint),
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
onboardingScopes: ["text-inference", "image-generation"],
|
||||
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",
|
||||
);
|
||||
}
|
||||
|
||||
const 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);
|
||||
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);
|
||||
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})`,
|
||||
})),
|
||||
});
|
||||
const match = subs.find((sub) => sub.id === selectedId);
|
||||
if (!match) {
|
||||
throw new Error(`Selected subscription not found: ${selectedId}`);
|
||||
}
|
||||
selectedSub = match;
|
||||
tenantId ??= selectedSub.tenantId;
|
||||
}
|
||||
|
||||
let endpoint: string;
|
||||
let modelId: string;
|
||||
let modelNameHint: string | undefined;
|
||||
let api: FoundryProviderApi;
|
||||
let discoveredDeployments:
|
||||
| Array<{
|
||||
name: string;
|
||||
modelName?: string;
|
||||
api?: FoundryProviderApi;
|
||||
}>
|
||||
| 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 { selected: selectedDeployment, supported: supportedDeployments } =
|
||||
await selectFoundryDeployment(ctx, selectedResource, resourceDeployments);
|
||||
discoveredDeployments = supportedDeployments.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: ${formatFoundryApiLabel(api)}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
"Microsoft Foundry",
|
||||
);
|
||||
} else {
|
||||
({ endpoint, modelId, modelNameHint, api } = await promptEndpointAndModelManually(ctx));
|
||||
}
|
||||
} else {
|
||||
({ endpoint, modelId, modelNameHint, api } = await promptEndpointAndModelManually(ctx));
|
||||
}
|
||||
|
||||
if (shouldTestFoundryTextConnection({ modelId, modelNameHint })) {
|
||||
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)",
|
||||
onboardingScopes: ["text-inference", "image-generation"],
|
||||
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);
|
||||
const existingModelNameHint =
|
||||
existingMetadata?.modelId === selection.modelId
|
||||
? (existingMetadata.modelName ?? existingMetadata.modelId)
|
||||
: undefined;
|
||||
return buildFoundryAuthResult({
|
||||
profileId: `${PROVIDER_ID}:default`,
|
||||
apiKey: capturedSecretInput ?? "",
|
||||
...(capturedMode ? { secretInputMode: capturedMode } : {}),
|
||||
endpoint: selection.endpoint,
|
||||
modelId: selection.modelId,
|
||||
modelNameHint: selection.modelNameHint ?? existingModelNameHint,
|
||||
api: selection.api,
|
||||
authMethod: "api-key",
|
||||
currentProviderProfileIds: listConfiguredFoundryProfileIds(ctx.config),
|
||||
currentPluginsAllow: ctx.config.plugins?.allow,
|
||||
notes: [`Endpoint: ${selection.endpoint}`, `Model: ${selection.modelId}`],
|
||||
});
|
||||
},
|
||||
};
|
||||
212
extensions/microsoft-foundry/cli.ts
Normal file
212
extensions/microsoft-foundry/cli.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
// Microsoft Foundry plugin module implements cli behavior.
|
||||
import { execFile, execFileSync, spawn } from "node:child_process";
|
||||
import {
|
||||
normalizeOptionalString,
|
||||
normalizeStringifiedOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-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",
|
||||
}),
|
||||
) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
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 parseAzJson(execAz(["account", "show", "--output", "json"]), "account") as AzAccount;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function listSubscriptions(): AzAccount[] {
|
||||
try {
|
||||
const subs = parseAzJson(
|
||||
execAz(["account", "list", "--output", "json", "--all"]),
|
||||
"subscriptions",
|
||||
) as AzAccount[];
|
||||
return subs.filter((sub) => sub.state === "Enabled");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function parseAzJson(raw: string, label: string): unknown {
|
||||
try {
|
||||
return JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
throw new Error(`Azure CLI returned malformed ${label} JSON.`);
|
||||
}
|
||||
}
|
||||
|
||||
type AccessTokenParams = {
|
||||
scope?: string;
|
||||
subscriptionId?: string;
|
||||
tenantId?: string;
|
||||
};
|
||||
|
||||
function buildAccessTokenArgs(params?: AccessTokenParams): string[] {
|
||||
const args = ["account", "get-access-token"];
|
||||
if (params?.scope) {
|
||||
args.push("--scope", params.scope);
|
||||
} else {
|
||||
args.push("--resource", COGNITIVE_SERVICES_RESOURCE);
|
||||
}
|
||||
args.push("--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 parseAzJson(execAz(buildAccessTokenArgs(params)), "access token") as AzAccessToken;
|
||||
}
|
||||
|
||||
export async function getAccessTokenResultAsync(
|
||||
params?: AccessTokenParams,
|
||||
): Promise<AzAccessToken> {
|
||||
return parseAzJson(
|
||||
await execAzAsync(buildAccessTokenArgs(params)),
|
||||
"access token",
|
||||
) 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);
|
||||
});
|
||||
}
|
||||
551
extensions/microsoft-foundry/image-generation-provider.test.ts
Normal file
551
extensions/microsoft-foundry/image-generation-provider.test.ts
Normal file
@@ -0,0 +1,551 @@
|
||||
// Microsoft Foundry image provider tests cover MAI request construction.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildMicrosoftFoundryImageGenerationProvider } from "./image-generation-provider.js";
|
||||
import { PROVIDER_ID } from "./shared.js";
|
||||
|
||||
const {
|
||||
assertOkOrThrowHttpErrorMock,
|
||||
createProviderOperationDeadlineMock,
|
||||
isProviderApiKeyConfiguredMock,
|
||||
postJsonRequestMock,
|
||||
postMultipartRequestMock,
|
||||
prepareFoundryRuntimeAuthMock,
|
||||
resolveApiKeyForProviderMock,
|
||||
resolveProviderHttpRequestConfigMock,
|
||||
resolveProviderOperationTimeoutMsMock,
|
||||
sanitizeConfiguredModelProviderRequestMock,
|
||||
} = vi.hoisted(() => ({
|
||||
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
|
||||
createProviderOperationDeadlineMock: vi.fn((params: Record<string, unknown>) => params),
|
||||
isProviderApiKeyConfiguredMock: vi.fn(() => true),
|
||||
postJsonRequestMock: vi.fn(),
|
||||
postMultipartRequestMock: vi.fn(),
|
||||
prepareFoundryRuntimeAuthMock: vi.fn(),
|
||||
resolveApiKeyForProviderMock: vi.fn(async () => ({
|
||||
apiKey: "foundry-key",
|
||||
mode: "api-key" as const,
|
||||
profileId: undefined as string | undefined,
|
||||
source: "test",
|
||||
})),
|
||||
resolveProviderHttpRequestConfigMock: vi.fn((params: Record<string, unknown>) => ({
|
||||
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
|
||||
allowPrivateNetwork: false,
|
||||
headers: new Headers(params.defaultHeaders as HeadersInit | undefined),
|
||||
dispatcherPolicy: undefined,
|
||||
})),
|
||||
resolveProviderOperationTimeoutMsMock: vi.fn(
|
||||
(params: Record<string, unknown>) =>
|
||||
(params.deadline as { timeoutMs?: number }).timeoutMs ?? params.defaultTimeoutMs,
|
||||
),
|
||||
sanitizeConfiguredModelProviderRequestMock: vi.fn((request) => request),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-auth", () => ({
|
||||
isProviderApiKeyConfigured: isProviderApiKeyConfiguredMock,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
|
||||
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-http", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/provider-http")>(
|
||||
"openclaw/plugin-sdk/provider-http",
|
||||
);
|
||||
return {
|
||||
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
|
||||
createProviderOperationDeadline: createProviderOperationDeadlineMock,
|
||||
postJsonRequest: postJsonRequestMock,
|
||||
postMultipartRequest: postMultipartRequestMock,
|
||||
readProviderJsonResponse: actual.readProviderJsonResponse,
|
||||
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
|
||||
resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock,
|
||||
sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
prepareFoundryRuntimeAuth: prepareFoundryRuntimeAuthMock,
|
||||
}));
|
||||
|
||||
function buildConfig(
|
||||
params: {
|
||||
modelId?: string;
|
||||
modelName?: string;
|
||||
baseUrl?: string;
|
||||
includeModel?: boolean;
|
||||
mediaMaxMb?: number;
|
||||
} = {},
|
||||
): OpenClawConfig {
|
||||
const baseUrl = params.baseUrl ?? "https://example.services.ai.azure.com/openai/v1";
|
||||
const modelId = params.modelId ?? "image-deployment";
|
||||
const modelName = params.modelName ?? "MAI-Image-2.5";
|
||||
return {
|
||||
...(params.mediaMaxMb !== undefined
|
||||
? { agents: { defaults: { mediaMaxMb: params.mediaMaxMb } } }
|
||||
: {}),
|
||||
models: {
|
||||
providers: {
|
||||
[PROVIDER_ID]: {
|
||||
baseUrl,
|
||||
api: "openai-completions",
|
||||
models:
|
||||
params.includeModel === false
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: modelId,
|
||||
name: modelName,
|
||||
api: "openai-completions",
|
||||
baseUrl,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 32_000,
|
||||
maxTokens: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function releasedJson(payload: unknown) {
|
||||
return {
|
||||
response: Response.json(payload),
|
||||
release: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function requirePostJsonRequest(): Record<string, unknown> {
|
||||
const request = postJsonRequestMock.mock.calls[0]?.[0];
|
||||
if (!request || typeof request !== "object") {
|
||||
throw new Error("expected Microsoft Foundry JSON image request");
|
||||
}
|
||||
return request as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requirePostMultipartRequest(): Record<string, unknown> {
|
||||
const request = postMultipartRequestMock.mock.calls[0]?.[0];
|
||||
if (!request || typeof request !== "object") {
|
||||
throw new Error("expected Microsoft Foundry multipart image request");
|
||||
}
|
||||
return request as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requireHeaders(value: unknown): Headers {
|
||||
expect(value).toBeInstanceOf(Headers);
|
||||
if (!(value instanceof Headers)) {
|
||||
throw new Error("expected request headers");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
describe("microsoft foundry image generation provider", () => {
|
||||
afterEach(() => {
|
||||
assertOkOrThrowHttpErrorMock.mockClear();
|
||||
createProviderOperationDeadlineMock.mockClear();
|
||||
isProviderApiKeyConfiguredMock.mockClear();
|
||||
postJsonRequestMock.mockReset();
|
||||
postMultipartRequestMock.mockReset();
|
||||
prepareFoundryRuntimeAuthMock.mockReset();
|
||||
resolveApiKeyForProviderMock.mockClear();
|
||||
resolveProviderHttpRequestConfigMock.mockClear();
|
||||
resolveProviderOperationTimeoutMsMock.mockClear();
|
||||
sanitizeConfiguredModelProviderRequestMock.mockClear();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("exposes MAI image provider metadata and capabilities", () => {
|
||||
const provider = buildMicrosoftFoundryImageGenerationProvider();
|
||||
expect(provider.id).toBe(PROVIDER_ID);
|
||||
expect(provider.defaultModel).toBeUndefined();
|
||||
expect(provider.models).toEqual([]);
|
||||
expect(provider.capabilities.generate.maxCount).toBe(1);
|
||||
expect(provider.capabilities.edit.enabled).toBe(true);
|
||||
expect(provider.capabilities.edit.maxInputImages).toBe(1);
|
||||
expect(provider.capabilities.geometry?.sizes).toBeUndefined();
|
||||
expect(provider.capabilities.output?.formats).toEqual(["png"]);
|
||||
expect(provider.isConfigured?.({ agentDir: "/agent" })).toBe(true);
|
||||
expect(isProviderApiKeyConfiguredMock).toHaveBeenCalledWith({
|
||||
provider: PROVIDER_ID,
|
||||
agentDir: "/agent",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends MAI image generation requests to the Foundry MAI endpoint with API-key auth", async () => {
|
||||
postJsonRequestMock.mockResolvedValue(
|
||||
releasedJson({
|
||||
data: [{ b64_json: Buffer.from("png").toString("base64") }],
|
||||
}),
|
||||
);
|
||||
const provider = buildMicrosoftFoundryImageGenerationProvider();
|
||||
|
||||
const result = await provider.generateImage({
|
||||
provider: PROVIDER_ID,
|
||||
model: "image-deployment",
|
||||
prompt: "draw a clean product render",
|
||||
cfg: buildConfig(),
|
||||
size: "768x1365",
|
||||
timeoutMs: 12_345,
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
});
|
||||
|
||||
expect(resolveApiKeyForProviderMock).toHaveBeenCalledWith({
|
||||
provider: PROVIDER_ID,
|
||||
cfg: buildConfig(),
|
||||
agentDir: undefined,
|
||||
store: undefined,
|
||||
});
|
||||
expect(resolveProviderHttpRequestConfigMock).toHaveBeenCalledWith({
|
||||
baseUrl: "https://example.services.ai.azure.com/mai/v1",
|
||||
defaultBaseUrl: "https://example.services.ai.azure.com/mai/v1",
|
||||
allowPrivateNetwork: false,
|
||||
defaultHeaders: { "api-key": "foundry-key" },
|
||||
request: undefined,
|
||||
provider: PROVIDER_ID,
|
||||
capability: "image",
|
||||
transport: "http",
|
||||
});
|
||||
expect(postJsonRequestMock).toHaveBeenCalledOnce();
|
||||
expect(createProviderOperationDeadlineMock).toHaveBeenCalledWith({
|
||||
timeoutMs: 12_345,
|
||||
label: "Microsoft Foundry MAI image generation",
|
||||
});
|
||||
expect(resolveProviderOperationTimeoutMsMock).toHaveBeenCalledWith({
|
||||
deadline: { timeoutMs: 12_345, label: "Microsoft Foundry MAI image generation" },
|
||||
defaultTimeoutMs: 600_000,
|
||||
});
|
||||
const request = requirePostJsonRequest();
|
||||
expect(request.url).toBe("https://example.services.ai.azure.com/mai/v1/images/generations");
|
||||
expect(request.body).toEqual({
|
||||
model: "image-deployment",
|
||||
prompt: "draw a clean product render",
|
||||
width: 768,
|
||||
height: 1365,
|
||||
});
|
||||
expect(Object.fromEntries(requireHeaders(request.headers).entries())).toEqual({
|
||||
"api-key": "foundry-key",
|
||||
"content-type": "application/json",
|
||||
});
|
||||
expect(request.timeoutMs).toBe(12_345);
|
||||
expect(request.ssrfPolicy).toEqual({ allowPrivateNetwork: true });
|
||||
expect(result.model).toBe("image-deployment");
|
||||
expect(result.images[0]?.buffer.toString()).toBe("png");
|
||||
expect(result.images[0]?.mimeType).toBe("image/png");
|
||||
});
|
||||
|
||||
it("accepts a valid max-size MAI image JSON response", async () => {
|
||||
const imageBytes = Buffer.alloc(6 * 1024 * 1024, 1);
|
||||
postJsonRequestMock.mockResolvedValue(
|
||||
releasedJson({
|
||||
data: [{ b64_json: imageBytes.toString("base64") }],
|
||||
}),
|
||||
);
|
||||
const provider = buildMicrosoftFoundryImageGenerationProvider();
|
||||
|
||||
const result = await provider.generateImage({
|
||||
provider: PROVIDER_ID,
|
||||
model: "image-deployment",
|
||||
prompt: "draw it",
|
||||
cfg: buildConfig(),
|
||||
});
|
||||
|
||||
expect(result.images).toHaveLength(1);
|
||||
expect(result.images[0]?.buffer.byteLength).toBe(imageBytes.byteLength);
|
||||
});
|
||||
|
||||
it("honors configured generated media caps above the default image limit", async () => {
|
||||
const imageBytes = Buffer.alloc(7 * 1024 * 1024, 1);
|
||||
postJsonRequestMock.mockResolvedValue(
|
||||
releasedJson({
|
||||
data: [{ b64_json: imageBytes.toString("base64") }],
|
||||
}),
|
||||
);
|
||||
const provider = buildMicrosoftFoundryImageGenerationProvider();
|
||||
|
||||
const result = await provider.generateImage({
|
||||
provider: PROVIDER_ID,
|
||||
model: "image-deployment",
|
||||
prompt: "draw it",
|
||||
cfg: buildConfig({ mediaMaxMb: 8 }),
|
||||
});
|
||||
|
||||
expect(result.images).toHaveLength(1);
|
||||
expect(result.images[0]?.buffer.byteLength).toBe(imageBytes.byteLength);
|
||||
});
|
||||
|
||||
it("rejects oversized MAI image JSON responses", async () => {
|
||||
postJsonRequestMock.mockResolvedValue(
|
||||
releasedJson({
|
||||
data: [{ b64_json: "x".repeat(10 * 1024 * 1024) }],
|
||||
}),
|
||||
);
|
||||
const provider = buildMicrosoftFoundryImageGenerationProvider();
|
||||
|
||||
await expect(
|
||||
provider.generateImage({
|
||||
provider: PROVIDER_ID,
|
||||
model: "image-deployment",
|
||||
prompt: "draw it",
|
||||
cfg: buildConfig(),
|
||||
}),
|
||||
).rejects.toThrow("microsoft-foundry.image-generation: JSON response exceeds");
|
||||
});
|
||||
|
||||
it("uses AZURE_OPENAI_ENDPOINT when env API-key auth has no configured base URL", async () => {
|
||||
vi.stubEnv("AZURE_OPENAI_ENDPOINT", "https://env.services.ai.azure.com");
|
||||
postJsonRequestMock.mockResolvedValue(
|
||||
releasedJson({
|
||||
data: [{ b64_json: Buffer.from("png").toString("base64") }],
|
||||
}),
|
||||
);
|
||||
const provider = buildMicrosoftFoundryImageGenerationProvider();
|
||||
|
||||
await provider.generateImage({
|
||||
provider: PROVIDER_ID,
|
||||
model: "image-deployment",
|
||||
prompt: "draw from env endpoint",
|
||||
cfg: buildConfig({ baseUrl: "" }),
|
||||
});
|
||||
|
||||
expect(requirePostJsonRequest().url).toBe(
|
||||
"https://env.services.ai.azure.com/mai/v1/images/generations",
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes Entra ID auth and sends MAI image edits as multipart form data", async () => {
|
||||
resolveApiKeyForProviderMock.mockResolvedValueOnce({
|
||||
apiKey: "__entra_id_dynamic__",
|
||||
mode: "api-key",
|
||||
profileId: "microsoft-foundry:entra",
|
||||
source: "profile:microsoft-foundry:entra",
|
||||
});
|
||||
prepareFoundryRuntimeAuthMock.mockResolvedValueOnce({
|
||||
apiKey: "entra-token",
|
||||
baseUrl: "https://example.services.ai.azure.com/openai/v1",
|
||||
expiresAt: Date.now() + 60_000,
|
||||
});
|
||||
resolveProviderHttpRequestConfigMock.mockImplementationOnce(
|
||||
(params: Record<string, unknown>) => ({
|
||||
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
|
||||
allowPrivateNetwork: false,
|
||||
headers: new Headers({
|
||||
...(params.defaultHeaders as Record<string, string>),
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
dispatcherPolicy: undefined,
|
||||
}),
|
||||
);
|
||||
postMultipartRequestMock.mockResolvedValue(
|
||||
releasedJson({
|
||||
data: [{ b64_json: Buffer.from("edited").toString("base64") }],
|
||||
}),
|
||||
);
|
||||
const provider = buildMicrosoftFoundryImageGenerationProvider();
|
||||
|
||||
const result = await provider.generateImage({
|
||||
provider: PROVIDER_ID,
|
||||
model: "image-deployment",
|
||||
prompt: "make it brighter",
|
||||
cfg: buildConfig(),
|
||||
agentDir: "/agent",
|
||||
inputImages: [
|
||||
{
|
||||
buffer: Buffer.from("input"),
|
||||
mimeType: "image/png",
|
||||
fileName: "input.png",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(prepareFoundryRuntimeAuthMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentDir: "/agent",
|
||||
provider: PROVIDER_ID,
|
||||
modelId: "image-deployment",
|
||||
apiKey: "__entra_id_dynamic__",
|
||||
authMode: "api-key",
|
||||
profileId: "microsoft-foundry:entra",
|
||||
}),
|
||||
);
|
||||
expect(postMultipartRequestMock).toHaveBeenCalledOnce();
|
||||
const request = requirePostMultipartRequest();
|
||||
expect(request.url).toBe("https://example.services.ai.azure.com/mai/v1/images/edits");
|
||||
expect(Object.fromEntries(requireHeaders(request.headers).entries())).toEqual({
|
||||
authorization: "Bearer entra-token",
|
||||
});
|
||||
const form = request.body as FormData;
|
||||
expect(form.get("model")).toBe("image-deployment");
|
||||
expect(form.get("prompt")).toBe("make it brighter");
|
||||
const image = form.get("image");
|
||||
expect(image).toBeInstanceOf(Blob);
|
||||
expect((image as File).name).toBe("input.png");
|
||||
expect((image as File).type).toBe("image/png");
|
||||
expect(result.images[0]?.buffer.toString()).toBe("edited");
|
||||
});
|
||||
|
||||
it("rejects image edits for MAI text-to-image-only deployments", async () => {
|
||||
const provider = buildMicrosoftFoundryImageGenerationProvider();
|
||||
|
||||
await expect(
|
||||
provider.generateImage({
|
||||
provider: PROVIDER_ID,
|
||||
model: "image-deployment",
|
||||
prompt: "edit it",
|
||||
cfg: buildConfig({ modelName: "MAI-Image-2e" }),
|
||||
inputImages: [{ buffer: Buffer.from("input"), mimeType: "image/png" }],
|
||||
}),
|
||||
).rejects.toThrow("MAI-Image-2e does not support Microsoft Foundry MAI image edits.");
|
||||
expect(resolveApiKeyForProviderMock).not.toHaveBeenCalled();
|
||||
expect(postMultipartRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires an explicit deployment name before making requests", async () => {
|
||||
const provider = buildMicrosoftFoundryImageGenerationProvider();
|
||||
|
||||
await expect(
|
||||
provider.generateImage({
|
||||
provider: PROVIDER_ID,
|
||||
model: "",
|
||||
prompt: "draw it",
|
||||
cfg: buildConfig(),
|
||||
}),
|
||||
).rejects.toThrow("requires a deployment name");
|
||||
expect(resolveApiKeyForProviderMock).not.toHaveBeenCalled();
|
||||
expect(postJsonRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows custom MAI deployment names for generation when model metadata is absent", async () => {
|
||||
postJsonRequestMock.mockResolvedValue(
|
||||
releasedJson({
|
||||
data: [{ b64_json: Buffer.from("png").toString("base64") }],
|
||||
}),
|
||||
);
|
||||
const provider = buildMicrosoftFoundryImageGenerationProvider();
|
||||
|
||||
await provider.generateImage({
|
||||
provider: PROVIDER_ID,
|
||||
model: "prod-image",
|
||||
prompt: "draw it",
|
||||
cfg: buildConfig({ includeModel: false }),
|
||||
size: "800x1000",
|
||||
});
|
||||
|
||||
expect(postJsonRequestMock).toHaveBeenCalledOnce();
|
||||
expect(requirePostJsonRequest().body).toEqual({
|
||||
model: "prod-image",
|
||||
prompt: "draw it",
|
||||
width: 800,
|
||||
height: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows custom mai-image deployment names for generation without model metadata", async () => {
|
||||
postJsonRequestMock.mockResolvedValue(
|
||||
releasedJson({
|
||||
data: [{ b64_json: Buffer.from("png").toString("base64") }],
|
||||
}),
|
||||
);
|
||||
const provider = buildMicrosoftFoundryImageGenerationProvider();
|
||||
|
||||
await provider.generateImage({
|
||||
provider: PROVIDER_ID,
|
||||
model: "mai-image-2-live",
|
||||
prompt: "draw it",
|
||||
cfg: buildConfig({ modelId: "mai-image-2-live", includeModel: false }),
|
||||
});
|
||||
|
||||
expect(postJsonRequestMock).toHaveBeenCalledOnce();
|
||||
expect(requirePostJsonRequest().body).toMatchObject({
|
||||
model: "mai-image-2-live",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows manual custom deployment names when configured name only repeats the id", async () => {
|
||||
postJsonRequestMock.mockResolvedValue(
|
||||
releasedJson({
|
||||
data: [{ b64_json: Buffer.from("png").toString("base64") }],
|
||||
}),
|
||||
);
|
||||
const provider = buildMicrosoftFoundryImageGenerationProvider();
|
||||
|
||||
await provider.generateImage({
|
||||
provider: PROVIDER_ID,
|
||||
model: "prod-image",
|
||||
prompt: "draw it",
|
||||
cfg: buildConfig({ modelId: "prod-image", modelName: "prod-image" }),
|
||||
});
|
||||
|
||||
expect(postJsonRequestMock).toHaveBeenCalledOnce();
|
||||
expect(requirePostJsonRequest().body).toEqual({
|
||||
model: "prod-image",
|
||||
prompt: "draw it",
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
});
|
||||
});
|
||||
|
||||
it("requires MAI-Image-2.5 metadata before editing custom deployment names", async () => {
|
||||
const provider = buildMicrosoftFoundryImageGenerationProvider();
|
||||
|
||||
await expect(
|
||||
provider.generateImage({
|
||||
provider: PROVIDER_ID,
|
||||
model: "prod-image",
|
||||
prompt: "edit it",
|
||||
cfg: buildConfig({ includeModel: false }),
|
||||
inputImages: [{ buffer: Buffer.from("input"), mimeType: "image/png" }],
|
||||
}),
|
||||
).rejects.toThrow("edits require MAI-Image-2.5 model metadata");
|
||||
expect(resolveApiKeyForProviderMock).not.toHaveBeenCalled();
|
||||
expect(postMultipartRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects non-MAI image deployments before making requests", async () => {
|
||||
const provider = buildMicrosoftFoundryImageGenerationProvider();
|
||||
|
||||
await expect(
|
||||
provider.generateImage({
|
||||
provider: PROVIDER_ID,
|
||||
model: "gpt-deployment",
|
||||
prompt: "draw it",
|
||||
cfg: buildConfig({ modelId: "gpt-deployment", modelName: "gpt-5.4" }),
|
||||
}),
|
||||
).rejects.toThrow('supports MAI image deployments only, got "gpt-5.4"');
|
||||
expect(resolveApiKeyForProviderMock).not.toHaveBeenCalled();
|
||||
expect(postJsonRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects literal non-image MAI model names before making requests", async () => {
|
||||
const provider = buildMicrosoftFoundryImageGenerationProvider();
|
||||
|
||||
await expect(
|
||||
provider.generateImage({
|
||||
provider: PROVIDER_ID,
|
||||
model: "MAI-DS-R1",
|
||||
prompt: "draw it",
|
||||
cfg: buildConfig({ includeModel: false }),
|
||||
}),
|
||||
).rejects.toThrow('supports MAI image deployments only, got "MAI-DS-R1"');
|
||||
expect(resolveApiKeyForProviderMock).not.toHaveBeenCalled();
|
||||
expect(postJsonRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects MAI image sizes outside Microsoft Foundry limits", async () => {
|
||||
const provider = buildMicrosoftFoundryImageGenerationProvider();
|
||||
|
||||
await expect(
|
||||
provider.generateImage({
|
||||
provider: PROVIDER_ID,
|
||||
model: "image-deployment",
|
||||
prompt: "draw it",
|
||||
cfg: buildConfig(),
|
||||
size: "512x512",
|
||||
}),
|
||||
).rejects.toThrow("at least 768x768");
|
||||
expect(postJsonRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
404
extensions/microsoft-foundry/image-generation-provider.ts
Normal file
404
extensions/microsoft-foundry/image-generation-provider.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
// Microsoft Foundry image provider routes MAI image deployments to the MAI API.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/core";
|
||||
import type {
|
||||
ImageGenerationProvider,
|
||||
ImageGenerationRequest,
|
||||
ImageGenerationResult,
|
||||
ImageGenerationSourceImage,
|
||||
} from "openclaw/plugin-sdk/image-generation";
|
||||
import {
|
||||
imageSourceUploadFileName,
|
||||
parseOpenAiCompatibleImageResponse,
|
||||
resolveInlineImageJsonResponseMaxBytes,
|
||||
} from "openclaw/plugin-sdk/image-generation";
|
||||
import { MAX_IMAGE_BYTES } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import {
|
||||
assertOkOrThrowHttpError,
|
||||
createProviderOperationDeadline,
|
||||
postJsonRequest,
|
||||
postMultipartRequest,
|
||||
readProviderJsonResponse,
|
||||
resolveProviderHttpRequestConfig,
|
||||
resolveProviderOperationTimeoutMs,
|
||||
sanitizeConfiguredModelProviderRequest,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { prepareFoundryRuntimeAuth } from "./runtime.js";
|
||||
import { extractFoundryEndpoint } from "./shared-runtime.js";
|
||||
import {
|
||||
DEFAULT_API,
|
||||
isFoundryMaiImageModel,
|
||||
isFoundryProviderApi,
|
||||
PROVIDER_ID,
|
||||
} from "./shared.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 600_000;
|
||||
const DEFAULT_IMAGE_SIZE = { width: 1024, height: 1024 };
|
||||
const MAI_MIN_IMAGE_SIDE_PX = 768;
|
||||
const MAI_MAX_IMAGE_PIXELS = 1_048_576;
|
||||
const MAI_IMAGE_BASE_PATH = "/mai/v1";
|
||||
const MAI_IMAGE_MAX_RESULTS = 1;
|
||||
const MAI_IMAGE_OUTPUT_MIME = "image/png";
|
||||
const MB = 1024 * 1024;
|
||||
const MAI_IMAGE_UPLOAD_MIME_TYPES = new Set(["image/jpeg", "image/jpg", "image/png"]);
|
||||
|
||||
type ModelProviderConfig = NonNullable<NonNullable<OpenClawConfig["models"]>["providers"]>[string];
|
||||
|
||||
function readProviderConfig(req: ImageGenerationRequest): ModelProviderConfig | undefined {
|
||||
return req.cfg.models?.providers?.[PROVIDER_ID];
|
||||
}
|
||||
|
||||
function resolveConfiguredModelName(
|
||||
providerConfig: ModelProviderConfig | undefined,
|
||||
model: string,
|
||||
): { modelName: string; hasMetadata: boolean } {
|
||||
const configuredName = providerConfig?.models.find((candidate) => candidate.id === model)?.name;
|
||||
const hasDistinctModelMetadata =
|
||||
normalizeOptionalLowercaseString(configuredName) !== normalizeOptionalLowercaseString(model);
|
||||
return configuredName
|
||||
? { modelName: configuredName, hasMetadata: hasDistinctModelMetadata }
|
||||
: { modelName: model, hasMetadata: false };
|
||||
}
|
||||
|
||||
function ensureMaiImageModel(
|
||||
providerConfig: ModelProviderConfig | undefined,
|
||||
model: string,
|
||||
): { modelName: string; hasMetadata: boolean } {
|
||||
const resolved = resolveConfiguredModelName(providerConfig, model);
|
||||
const normalizedModel = normalizeOptionalLowercaseString(model);
|
||||
if (
|
||||
!isFoundryMaiImageModel(resolved.modelName) &&
|
||||
(resolved.hasMetadata ||
|
||||
(normalizedModel?.startsWith("mai-") && !normalizedModel.startsWith("mai-image-")))
|
||||
) {
|
||||
throw new Error(
|
||||
`Microsoft Foundry image generation supports MAI image deployments only, got "${resolved.modelName}".`,
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function isMaiImageEditModel(modelName: string): boolean {
|
||||
const normalized = normalizeOptionalLowercaseString(modelName);
|
||||
return normalized === "mai-image-2.5" || normalized === "mai-image-2.5-flash";
|
||||
}
|
||||
|
||||
function resolveMaiImageSize(size: string | undefined): { width: number; height: number } {
|
||||
if (!size) {
|
||||
return DEFAULT_IMAGE_SIZE;
|
||||
}
|
||||
const match = size.match(/^(\d{1,5})x(\d{1,5})$/u);
|
||||
if (!match) {
|
||||
throw new Error(`Microsoft Foundry MAI image size must use WIDTHxHEIGHT, got "${size}".`);
|
||||
}
|
||||
const width = Number(match[1]);
|
||||
const height = Number(match[2]);
|
||||
if (
|
||||
!Number.isInteger(width) ||
|
||||
!Number.isInteger(height) ||
|
||||
width < MAI_MIN_IMAGE_SIDE_PX ||
|
||||
height < MAI_MIN_IMAGE_SIDE_PX ||
|
||||
width * height > MAI_MAX_IMAGE_PIXELS
|
||||
) {
|
||||
throw new Error(
|
||||
`Microsoft Foundry MAI image size must be at least 768x768 and at most 1,048,576 total pixels, got "${size}".`,
|
||||
);
|
||||
}
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function resolveGeneratedImageMaxBytes(req: {
|
||||
cfg: { agents?: { defaults?: { mediaMaxMb?: number } } };
|
||||
}): number {
|
||||
const configured = req.cfg.agents?.defaults?.mediaMaxMb;
|
||||
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
|
||||
return Math.floor(configured * MB);
|
||||
}
|
||||
return MAX_IMAGE_BYTES;
|
||||
}
|
||||
|
||||
function assertSingleImageCount(count: number | undefined): void {
|
||||
if (count === undefined || count === 1) {
|
||||
return;
|
||||
}
|
||||
throw new Error("Microsoft Foundry MAI image models return one image per request.");
|
||||
}
|
||||
|
||||
function resolveConfiguredEndpoint(params: {
|
||||
providerConfig: ModelProviderConfig | undefined;
|
||||
preparedBaseUrl?: string;
|
||||
}): string {
|
||||
const endpoint =
|
||||
extractFoundryEndpoint(params.preparedBaseUrl) ??
|
||||
extractFoundryEndpoint(params.providerConfig?.baseUrl) ??
|
||||
extractFoundryEndpoint(process.env.AZURE_OPENAI_ENDPOINT);
|
||||
if (!endpoint) {
|
||||
throw new Error("Microsoft Foundry endpoint missing for MAI image generation.");
|
||||
}
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
function buildMaiImageUrl(baseUrl: string, mode: "generations" | "edits"): string {
|
||||
return `${baseUrl.replace(/\/+$/u, "")}/images/${mode}`;
|
||||
}
|
||||
|
||||
function buildRuntimeModel(params: {
|
||||
providerConfig: ModelProviderConfig | undefined;
|
||||
model: string;
|
||||
modelName: string;
|
||||
}): ProviderRuntimeModel {
|
||||
const api = isFoundryProviderApi(params.providerConfig?.api)
|
||||
? params.providerConfig.api
|
||||
: DEFAULT_API;
|
||||
return {
|
||||
id: params.model,
|
||||
name: params.modelName,
|
||||
api,
|
||||
provider: PROVIDER_ID,
|
||||
baseUrl: params.providerConfig?.baseUrl ?? "",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 32_000,
|
||||
maxTokens: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveMaiImageAuth(params: {
|
||||
req: ImageGenerationRequest;
|
||||
providerConfig: ModelProviderConfig | undefined;
|
||||
model: string;
|
||||
modelName: string;
|
||||
}): Promise<{ headers: Record<string, string>; baseUrl?: string }> {
|
||||
const auth = await resolveApiKeyForProvider({
|
||||
provider: PROVIDER_ID,
|
||||
cfg: params.req.cfg,
|
||||
agentDir: params.req.agentDir,
|
||||
store: params.req.authStore,
|
||||
});
|
||||
if (!auth.apiKey) {
|
||||
throw new Error("Microsoft Foundry API key missing");
|
||||
}
|
||||
if (auth.apiKey !== "__entra_id_dynamic__") {
|
||||
return {
|
||||
headers: {
|
||||
"api-key": auth.apiKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const prepared = await prepareFoundryRuntimeAuth({
|
||||
config: params.req.cfg,
|
||||
agentDir: params.req.agentDir,
|
||||
env: process.env,
|
||||
provider: PROVIDER_ID,
|
||||
modelId: params.model,
|
||||
model: buildRuntimeModel({
|
||||
providerConfig: params.providerConfig,
|
||||
model: params.model,
|
||||
modelName: params.modelName,
|
||||
}),
|
||||
apiKey: auth.apiKey,
|
||||
authMode: auth.mode,
|
||||
...(auth.profileId ? { profileId: auth.profileId } : {}),
|
||||
});
|
||||
if (!prepared?.apiKey) {
|
||||
throw new Error("Microsoft Foundry Entra ID token missing after runtime auth refresh.");
|
||||
}
|
||||
return {
|
||||
headers: {
|
||||
Authorization: `Bearer ${prepared.apiKey}`,
|
||||
},
|
||||
...(prepared.baseUrl ? { baseUrl: prepared.baseUrl } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildEditFormData(params: {
|
||||
req: ImageGenerationRequest;
|
||||
image: ImageGenerationSourceImage;
|
||||
model: string;
|
||||
}): FormData {
|
||||
const mimeType = normalizeOptionalLowercaseString(params.image.mimeType) ?? MAI_IMAGE_OUTPUT_MIME;
|
||||
if (!MAI_IMAGE_UPLOAD_MIME_TYPES.has(mimeType)) {
|
||||
throw new Error("Microsoft Foundry MAI image edits require a PNG or JPEG input image.");
|
||||
}
|
||||
const form = new FormData();
|
||||
form.set("model", params.model);
|
||||
form.set("prompt", params.req.prompt);
|
||||
form.set(
|
||||
"image",
|
||||
new Blob([new Uint8Array(params.image.buffer)], {
|
||||
type: mimeType === "image/jpg" ? "image/jpeg" : mimeType,
|
||||
}),
|
||||
imageSourceUploadFileName({
|
||||
image: params.image,
|
||||
index: 0,
|
||||
fileNamePrefix: "microsoft-foundry-input",
|
||||
}),
|
||||
);
|
||||
return form;
|
||||
}
|
||||
|
||||
function parseMaiImageResponse(payload: unknown, label: string) {
|
||||
const images = parseOpenAiCompatibleImageResponse(payload, {
|
||||
defaultMimeType: MAI_IMAGE_OUTPUT_MIME,
|
||||
fileNamePrefix: "microsoft-foundry-image",
|
||||
malformedResponseError: `${label} response malformed`,
|
||||
sniffMimeType: true,
|
||||
});
|
||||
if (images.length === 0) {
|
||||
throw new Error(`${label} response missing image data`);
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
export function buildMicrosoftFoundryImageGenerationProvider(): ImageGenerationProvider {
|
||||
return {
|
||||
id: PROVIDER_ID,
|
||||
label: "Microsoft Foundry",
|
||||
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
models: [],
|
||||
isConfigured: ({ agentDir }) =>
|
||||
isProviderApiKeyConfigured({
|
||||
provider: PROVIDER_ID,
|
||||
agentDir,
|
||||
}),
|
||||
capabilities: {
|
||||
generate: {
|
||||
maxCount: MAI_IMAGE_MAX_RESULTS,
|
||||
supportsSize: true,
|
||||
},
|
||||
edit: {
|
||||
enabled: true,
|
||||
maxCount: MAI_IMAGE_MAX_RESULTS,
|
||||
maxInputImages: 1,
|
||||
supportsSize: false,
|
||||
},
|
||||
output: {
|
||||
formats: ["png"],
|
||||
},
|
||||
},
|
||||
async generateImage(req): Promise<ImageGenerationResult> {
|
||||
const providerConfig = readProviderConfig(req);
|
||||
const model = normalizeOptionalString(req.model);
|
||||
if (!model) {
|
||||
throw new Error("Microsoft Foundry MAI image generation requires a deployment name.");
|
||||
}
|
||||
const { modelName, hasMetadata } = ensureMaiImageModel(providerConfig, model);
|
||||
const inputImages = req.inputImages ?? [];
|
||||
const mode = inputImages.length > 0 ? "edits" : "generations";
|
||||
assertSingleImageCount(req.count);
|
||||
if (inputImages.length > 1) {
|
||||
throw new Error("Microsoft Foundry MAI image edits support one input image.");
|
||||
}
|
||||
if (
|
||||
mode === "edits" &&
|
||||
(hasMetadata || isFoundryMaiImageModel(model)) &&
|
||||
!isMaiImageEditModel(modelName)
|
||||
) {
|
||||
throw new Error(`${modelName} does not support Microsoft Foundry MAI image edits.`);
|
||||
}
|
||||
if (mode === "edits" && !hasMetadata && !isFoundryMaiImageModel(model)) {
|
||||
throw new Error(
|
||||
"Microsoft Foundry MAI image edits require MAI-Image-2.5 model metadata for custom deployment names.",
|
||||
);
|
||||
}
|
||||
|
||||
const auth = await resolveMaiImageAuth({ req, providerConfig, model, modelName });
|
||||
const endpoint = resolveConfiguredEndpoint({
|
||||
providerConfig,
|
||||
preparedBaseUrl: auth.baseUrl,
|
||||
});
|
||||
const resolvedBaseUrl = `${endpoint}${MAI_IMAGE_BASE_PATH}`;
|
||||
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
|
||||
resolveProviderHttpRequestConfig({
|
||||
baseUrl: resolvedBaseUrl,
|
||||
defaultBaseUrl: resolvedBaseUrl,
|
||||
allowPrivateNetwork: false,
|
||||
defaultHeaders: auth.headers,
|
||||
request: sanitizeConfiguredModelProviderRequest(providerConfig?.request),
|
||||
provider: PROVIDER_ID,
|
||||
capability: "image",
|
||||
transport: "http",
|
||||
});
|
||||
const label =
|
||||
mode === "edits"
|
||||
? "Microsoft Foundry MAI image edit"
|
||||
: "Microsoft Foundry MAI image generation";
|
||||
const deadline = createProviderOperationDeadline({
|
||||
timeoutMs: req.timeoutMs,
|
||||
label,
|
||||
});
|
||||
const timeoutMs = resolveProviderOperationTimeoutMs({
|
||||
deadline,
|
||||
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const request =
|
||||
mode === "edits"
|
||||
? postMultipartRequest({
|
||||
url: buildMaiImageUrl(baseUrl, mode),
|
||||
headers: (() => {
|
||||
const multipartHeaders = new Headers(headers);
|
||||
multipartHeaders.delete("Content-Type");
|
||||
return multipartHeaders;
|
||||
})(),
|
||||
body: buildEditFormData({
|
||||
req,
|
||||
image: inputImages[0],
|
||||
model,
|
||||
}),
|
||||
timeoutMs,
|
||||
fetchFn: fetch,
|
||||
allowPrivateNetwork,
|
||||
ssrfPolicy: req.ssrfPolicy,
|
||||
dispatcherPolicy,
|
||||
})
|
||||
: postJsonRequest({
|
||||
url: buildMaiImageUrl(baseUrl, mode),
|
||||
headers: (() => {
|
||||
const jsonHeaders = new Headers(headers);
|
||||
jsonHeaders.set("Content-Type", "application/json");
|
||||
return jsonHeaders;
|
||||
})(),
|
||||
body: {
|
||||
model,
|
||||
prompt: req.prompt,
|
||||
...resolveMaiImageSize(req.size),
|
||||
},
|
||||
timeoutMs,
|
||||
fetchFn: fetch,
|
||||
allowPrivateNetwork,
|
||||
ssrfPolicy: req.ssrfPolicy,
|
||||
dispatcherPolicy,
|
||||
});
|
||||
|
||||
const { response, release } = await request;
|
||||
try {
|
||||
await assertOkOrThrowHttpError(response, `${label} failed`);
|
||||
const payload = await readProviderJsonResponse(
|
||||
response,
|
||||
"microsoft-foundry.image-generation",
|
||||
{
|
||||
maxBytes: resolveInlineImageJsonResponseMaxBytes(
|
||||
MAI_IMAGE_MAX_RESULTS,
|
||||
resolveGeneratedImageMaxBytes(req),
|
||||
),
|
||||
},
|
||||
);
|
||||
return {
|
||||
images: parseMaiImageResponse(payload, label),
|
||||
model,
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
2033
extensions/microsoft-foundry/index.test.ts
Normal file
2033
extensions/microsoft-foundry/index.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
14
extensions/microsoft-foundry/index.ts
Normal file
14
extensions/microsoft-foundry/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Microsoft Foundry plugin entrypoint registers its OpenClaw integration.
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { buildMicrosoftFoundryImageGenerationProvider } from "./image-generation-provider.js";
|
||||
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());
|
||||
api.registerImageGenerationProvider(buildMicrosoftFoundryImageGenerationProvider());
|
||||
},
|
||||
});
|
||||
73
extensions/microsoft-foundry/onboard.connection.test.ts
Normal file
73
extensions/microsoft-foundry/onboard.connection.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// Microsoft Foundry tests cover bounded connection-test error reads.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as cli from "./cli.js";
|
||||
import { testFoundryConnection } from "./onboard.js";
|
||||
import { DEFAULT_API } from "./shared.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
fetchWithSsrFGuard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||
fetchWithSsrFGuard: hoisted.fetchWithSsrFGuard,
|
||||
}));
|
||||
|
||||
function cancelTrackedResponse(
|
||||
text: string,
|
||||
init: ResponseInit,
|
||||
): {
|
||||
response: Response;
|
||||
wasCanceled: () => boolean;
|
||||
} {
|
||||
let canceled = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(text));
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new Response(stream, init),
|
||||
wasCanceled: () => canceled,
|
||||
};
|
||||
}
|
||||
|
||||
describe("testFoundryConnection", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(cli, "getAccessTokenResult").mockReturnValue({ accessToken: "token" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
hoisted.fetchWithSsrFGuard.mockReset();
|
||||
});
|
||||
|
||||
it("bounds connection-test error bodies without using response.text()", async () => {
|
||||
const note = vi.fn();
|
||||
const tracked = cancelTrackedResponse(`${"foundry failure ".repeat(1024)}tail`, {
|
||||
status: 503,
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
|
||||
hoisted.fetchWithSsrFGuard.mockResolvedValue({
|
||||
response: tracked.response,
|
||||
release: async () => {},
|
||||
});
|
||||
|
||||
await testFoundryConnection({
|
||||
ctx: { prompter: { note } } as never,
|
||||
endpoint: "https://example.openai.azure.com",
|
||||
modelId: "gpt-4o",
|
||||
api: DEFAULT_API,
|
||||
});
|
||||
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
expect(tracked.wasCanceled()).toBe(true);
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Warning: test request returned 503"),
|
||||
"Connection Test",
|
||||
);
|
||||
});
|
||||
});
|
||||
640
extensions/microsoft-foundry/onboard.ts
Normal file
640
extensions/microsoft-foundry/onboard.ts
Normal file
@@ -0,0 +1,640 @@
|
||||
// Microsoft Foundry setup module handles plugin onboarding behavior.
|
||||
import type { ProviderAuthContext } from "openclaw/plugin-sdk/core";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
normalizeOptionalString,
|
||||
normalizeStringifiedOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
azLoginDeviceCode,
|
||||
azLoginDeviceCodeWithOptions,
|
||||
execAz,
|
||||
getAccessTokenResult,
|
||||
getLoggedInAccount,
|
||||
} from "./cli.js";
|
||||
import {
|
||||
type AzAccount,
|
||||
type AzCognitiveAccount,
|
||||
type AzDeploymentSummary,
|
||||
type FoundryProviderApi,
|
||||
type FoundryResourceOption,
|
||||
type FoundrySelection,
|
||||
buildFoundryProviderBaseUrl,
|
||||
extractFoundryEndpoint,
|
||||
requiresFoundryMaxCompletionTokens,
|
||||
requiresFoundryEntraIdClaudeAuth,
|
||||
requiresFoundryMandatoryAdaptiveClaudeThinking,
|
||||
ANTHROPIC_MESSAGES_API,
|
||||
DEFAULT_API,
|
||||
DEFAULT_GPT5_API,
|
||||
FOUNDRY_ANTHROPIC_SCOPE,
|
||||
usesFoundryResponsesByDefault,
|
||||
} from "./shared.js";
|
||||
|
||||
const FOUNDRY_CONNECTION_TEST_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
|
||||
|
||||
export { listSubscriptions } from "./cli.js";
|
||||
|
||||
function listFoundryResources(subscriptionId?: string): FoundryResourceOption[] {
|
||||
try {
|
||||
const accounts = JSON.parse(
|
||||
execAz([
|
||||
"cognitiveservices",
|
||||
"account",
|
||||
"list",
|
||||
...(subscriptionId ? ["--subscription", subscriptionId] : []),
|
||||
"--query",
|
||||
"[].{id:id,name:name,kind:kind,location:location,resourceGroup:resourceGroup,endpoint:properties.endpoint,customSubdomain:properties.customSubDomainName,projects:properties.associatedProjects}",
|
||||
"--output",
|
||||
"json",
|
||||
]),
|
||||
) as AzCognitiveAccount[];
|
||||
const resources: FoundryResourceOption[] = [];
|
||||
for (const account of accounts) {
|
||||
if (!account.resourceGroup) {
|
||||
continue;
|
||||
}
|
||||
if (account.kind === "OpenAI") {
|
||||
const endpoint = extractFoundryEndpoint(account.endpoint);
|
||||
if (!endpoint) {
|
||||
continue;
|
||||
}
|
||||
resources.push({
|
||||
id: account.id,
|
||||
accountName: account.name,
|
||||
kind: "OpenAI",
|
||||
location: account.location,
|
||||
resourceGroup: account.resourceGroup,
|
||||
endpoint,
|
||||
projects: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (account.kind !== "AIServices") {
|
||||
continue;
|
||||
}
|
||||
const customSubdomain = normalizeOptionalString(account.customSubdomain);
|
||||
const endpoint = customSubdomain
|
||||
? `https://${customSubdomain}.services.ai.azure.com`
|
||||
: undefined;
|
||||
if (!endpoint) {
|
||||
continue;
|
||||
}
|
||||
resources.push({
|
||||
id: account.id,
|
||||
accountName: account.name,
|
||||
kind: "AIServices",
|
||||
location: account.location,
|
||||
resourceGroup: account.resourceGroup,
|
||||
endpoint,
|
||||
projects: Array.isArray(account.projects)
|
||||
? account.projects.filter((project): project is string => typeof project === "string")
|
||||
: [],
|
||||
});
|
||||
}
|
||||
return resources;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function listResourceDeployments(
|
||||
resource: FoundryResourceOption,
|
||||
subscriptionId?: string,
|
||||
): AzDeploymentSummary[] {
|
||||
try {
|
||||
const deployments = JSON.parse(
|
||||
execAz([
|
||||
"cognitiveservices",
|
||||
"account",
|
||||
"deployment",
|
||||
"list",
|
||||
...(subscriptionId ? ["--subscription", subscriptionId] : []),
|
||||
"-g",
|
||||
resource.resourceGroup,
|
||||
"-n",
|
||||
resource.accountName,
|
||||
"--query",
|
||||
"[].{name:name,modelName:properties.model.name,modelVersion:properties.model.version,state:properties.provisioningState,sku:sku.name}",
|
||||
"--output",
|
||||
"json",
|
||||
]),
|
||||
) as AzDeploymentSummary[];
|
||||
return deployments.filter((deployment) => deployment.state === "Succeeded");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function buildCreateFoundryHint(selectedSub: AzAccount): string {
|
||||
return [
|
||||
`No Azure AI Foundry or Azure OpenAI resources were found in subscription ${selectedSub.name} (${selectedSub.id}).`,
|
||||
"Create one in Azure AI Foundry or Azure Portal, then rerun onboard.",
|
||||
"Azure AI Foundry: https://ai.azure.com",
|
||||
"Azure OpenAI docs: https://learn.microsoft.com/azure/ai-foundry/openai/how-to/create-resource",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function selectFoundryResource(
|
||||
ctx: ProviderAuthContext,
|
||||
selectedSub: AzAccount,
|
||||
): Promise<FoundryResourceOption> {
|
||||
const resources = listFoundryResources(selectedSub.id);
|
||||
if (resources.length === 0) {
|
||||
throw new Error(buildCreateFoundryHint(selectedSub));
|
||||
}
|
||||
if (resources.length === 1) {
|
||||
const only = resources[0];
|
||||
await ctx.prompter.note(
|
||||
`Using ${only.kind === "AIServices" ? "Azure AI Foundry" : "Azure OpenAI"} resource: ${only.accountName}`,
|
||||
"Foundry Resource",
|
||||
);
|
||||
return only;
|
||||
}
|
||||
const selectedResourceId = await ctx.prompter.select({
|
||||
message: "Select Azure AI Foundry / Azure OpenAI resource",
|
||||
options: resources.map((resource) => ({
|
||||
value: resource.id,
|
||||
label: `${resource.accountName} (${resource.kind === "AIServices" ? "Azure AI Foundry" : "Azure OpenAI"}${resource.location ? `, ${resource.location}` : ""})`,
|
||||
hint: [
|
||||
`RG: ${resource.resourceGroup}`,
|
||||
resource.projects.length > 0 ? `${resource.projects.length} project(s)` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" | "),
|
||||
})),
|
||||
});
|
||||
return resources.find((resource) => resource.id === selectedResourceId) ?? resources[0];
|
||||
}
|
||||
|
||||
export async function selectFoundryDeployment(
|
||||
ctx: ProviderAuthContext,
|
||||
resource: FoundryResourceOption,
|
||||
deployments: AzDeploymentSummary[],
|
||||
): Promise<{ selected: AzDeploymentSummary; supported: AzDeploymentSummary[] }> {
|
||||
const supported = deployments;
|
||||
if (supported.length === 0) {
|
||||
throw new Error(
|
||||
[
|
||||
`No model deployments were found in ${resource.accountName}.`,
|
||||
"Deploy a model in Microsoft Foundry or Azure OpenAI, then rerun onboard.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
if (supported.length === 1) {
|
||||
const only = supported[0];
|
||||
await ctx.prompter.note(`Using deployment: ${only.name}`, "Model Deployment");
|
||||
return { selected: only, supported };
|
||||
}
|
||||
const selectedDeploymentName = await ctx.prompter.select({
|
||||
message: "Select model deployment",
|
||||
options: supported.map((deployment) => ({
|
||||
value: deployment.name,
|
||||
label: deployment.name,
|
||||
hint: [deployment.modelName, deployment.modelVersion, deployment.sku]
|
||||
.filter(Boolean)
|
||||
.join(" | "),
|
||||
})),
|
||||
});
|
||||
const selected =
|
||||
supported.find((deployment) => deployment.name === selectedDeploymentName) ?? supported[0];
|
||||
return { selected, supported };
|
||||
}
|
||||
|
||||
async function promptFoundryApi(
|
||||
ctx: ProviderAuthContext,
|
||||
initialApi: FoundryProviderApi,
|
||||
): Promise<FoundryProviderApi> {
|
||||
return await ctx.prompter.select({
|
||||
message: "Select request API",
|
||||
options: [
|
||||
{
|
||||
value: ANTHROPIC_MESSAGES_API,
|
||||
label: "Anthropic Messages API",
|
||||
hint: "Use for Claude deployments through Microsoft Foundry /anthropic",
|
||||
},
|
||||
{
|
||||
value: DEFAULT_GPT5_API,
|
||||
label: "Responses API",
|
||||
hint: "Recommended for Azure OpenAI GPT, o-series, and Codex deployments",
|
||||
},
|
||||
{
|
||||
value: "openai-completions",
|
||||
label: "Chat Completions API",
|
||||
hint: "Use for Foundry models that only expose chat/completions semantics",
|
||||
},
|
||||
],
|
||||
initialValue: initialApi,
|
||||
});
|
||||
}
|
||||
|
||||
type ManualFoundryModelFamilyChoice = "claude" | "reasoning-family" | "mai-image" | "other-chat";
|
||||
type ManualFoundryMaiImageModel =
|
||||
| "MAI-Image-2.5-Flash"
|
||||
| "MAI-Image-2.5"
|
||||
| "MAI-Image-2e"
|
||||
| "MAI-Image-2";
|
||||
|
||||
async function promptFoundryModelFamily(
|
||||
ctx: ProviderAuthContext,
|
||||
initialValue: ManualFoundryModelFamilyChoice,
|
||||
): Promise<ManualFoundryModelFamilyChoice> {
|
||||
return await ctx.prompter.select({
|
||||
message: "Model family",
|
||||
options: [
|
||||
{
|
||||
value: "claude",
|
||||
label: "Claude",
|
||||
hint: "Use for Anthropic Claude deployments",
|
||||
},
|
||||
{
|
||||
value: "reasoning-family",
|
||||
label: "GPT-5 series / o-series / Codex",
|
||||
hint: "Use for Azure OpenAI reasoning and Codex deployments",
|
||||
},
|
||||
{
|
||||
value: "mai-image",
|
||||
label: "MAI image model",
|
||||
hint: "Use for Microsoft MAI image deployments",
|
||||
},
|
||||
{
|
||||
value: "other-chat",
|
||||
label: "Other chat model",
|
||||
hint: "Use for other chat/completions style Foundry models",
|
||||
},
|
||||
],
|
||||
initialValue,
|
||||
});
|
||||
}
|
||||
|
||||
async function promptFoundryMaiImageModel(
|
||||
ctx: ProviderAuthContext,
|
||||
): Promise<ManualFoundryMaiImageModel> {
|
||||
return await ctx.prompter.select({
|
||||
message: "MAI image base model",
|
||||
options: [
|
||||
{
|
||||
value: "MAI-Image-2.5-Flash",
|
||||
label: "MAI-Image-2.5-Flash",
|
||||
hint: "Latest fast MAI image deployment",
|
||||
},
|
||||
{
|
||||
value: "MAI-Image-2.5",
|
||||
label: "MAI-Image-2.5",
|
||||
hint: "Latest MAI image deployment",
|
||||
},
|
||||
{
|
||||
value: "MAI-Image-2e",
|
||||
label: "MAI-Image-2e",
|
||||
hint: "Efficient MAI image deployment",
|
||||
},
|
||||
{
|
||||
value: "MAI-Image-2",
|
||||
label: "MAI-Image-2",
|
||||
hint: "MAI image deployment",
|
||||
},
|
||||
],
|
||||
initialValue: "MAI-Image-2.5-Flash",
|
||||
});
|
||||
}
|
||||
|
||||
async function promptFoundryClaudeModel(
|
||||
ctx: ProviderAuthContext,
|
||||
options?: { allowEntraOnlyModels?: boolean },
|
||||
): Promise<string> {
|
||||
return (
|
||||
await ctx.prompter.text({
|
||||
message: "Claude base model",
|
||||
initialValue: "claude-fable-5",
|
||||
placeholder: "claude-fable-5",
|
||||
validate: (v) => {
|
||||
const val = normalizeStringifiedOptionalString(v) ?? "";
|
||||
if (!val) {
|
||||
return "Claude base model is required";
|
||||
}
|
||||
if (!val.toLowerCase().startsWith("claude-")) {
|
||||
return "Use a Claude model name such as claude-fable-5";
|
||||
}
|
||||
if (options?.allowEntraOnlyModels === false && requiresFoundryEntraIdClaudeAuth(val)) {
|
||||
return "Claude Mythos deployments require Microsoft Entra ID auth; choose Entra ID auth or use a Claude model that supports API-key auth.";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
})
|
||||
).trim();
|
||||
}
|
||||
|
||||
async function promptEndpointAndModelBase(
|
||||
ctx: ProviderAuthContext,
|
||||
options?: {
|
||||
endpointInitialValue?: string;
|
||||
modelInitialValue?: string;
|
||||
modelFamilyInitialValue?: ManualFoundryModelFamilyChoice;
|
||||
allowEntraOnlyClaudeModels?: boolean;
|
||||
},
|
||||
): Promise<FoundrySelection> {
|
||||
const endpoint = (
|
||||
await ctx.prompter.text({
|
||||
message: "Microsoft Foundry endpoint URL",
|
||||
placeholder: "https://xxx.services.ai.azure.com or https://xxx.openai.azure.com",
|
||||
...(options?.endpointInitialValue ? { initialValue: options.endpointInitialValue } : {}),
|
||||
validate: (v) => {
|
||||
const val = normalizeStringifiedOptionalString(v) ?? "";
|
||||
if (!val) {
|
||||
return "Endpoint URL is required";
|
||||
}
|
||||
return URL.canParse(val) ? undefined : "Invalid URL";
|
||||
},
|
||||
})
|
||||
).trim();
|
||||
const modelId = (
|
||||
await ctx.prompter.text({
|
||||
message: "Default model/deployment name",
|
||||
...(options?.modelInitialValue ? { initialValue: options.modelInitialValue } : {}),
|
||||
placeholder: "claude-fable-5",
|
||||
validate: (v) => {
|
||||
const val = normalizeStringifiedOptionalString(v) ?? "";
|
||||
if (!val) {
|
||||
return "Model ID is required";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
})
|
||||
).trim();
|
||||
const familyChoice = await promptFoundryModelFamily(
|
||||
ctx,
|
||||
options?.modelFamilyInitialValue ?? "claude",
|
||||
);
|
||||
if (familyChoice === "mai-image") {
|
||||
return {
|
||||
endpoint,
|
||||
modelId,
|
||||
modelNameHint: await promptFoundryMaiImageModel(ctx),
|
||||
api: DEFAULT_API,
|
||||
};
|
||||
}
|
||||
if (familyChoice === "claude") {
|
||||
return {
|
||||
endpoint,
|
||||
modelId,
|
||||
modelNameHint: await promptFoundryClaudeModel(ctx, {
|
||||
allowEntraOnlyModels: options?.allowEntraOnlyClaudeModels ?? true,
|
||||
}),
|
||||
api: ANTHROPIC_MESSAGES_API,
|
||||
};
|
||||
}
|
||||
const resolvedModelName =
|
||||
familyChoice === "reasoning-family"
|
||||
? usesFoundryResponsesByDefault(modelId) || requiresFoundryMaxCompletionTokens(modelId)
|
||||
? modelId
|
||||
: "gpt-5"
|
||||
: undefined;
|
||||
const api = await promptFoundryApi(
|
||||
ctx,
|
||||
familyChoice === "reasoning-family" ? DEFAULT_GPT5_API : DEFAULT_API,
|
||||
);
|
||||
return {
|
||||
endpoint,
|
||||
modelId,
|
||||
...(resolvedModelName ? { modelNameHint: resolvedModelName } : {}),
|
||||
api,
|
||||
};
|
||||
}
|
||||
|
||||
export async function promptEndpointAndModelManually(
|
||||
ctx: ProviderAuthContext,
|
||||
): Promise<FoundrySelection> {
|
||||
return promptEndpointAndModelBase(ctx);
|
||||
}
|
||||
|
||||
export async function promptApiKeyEndpointAndModel(
|
||||
ctx: ProviderAuthContext,
|
||||
): Promise<FoundrySelection> {
|
||||
return promptEndpointAndModelBase(ctx, {
|
||||
endpointInitialValue: process.env.AZURE_OPENAI_ENDPOINT,
|
||||
modelInitialValue: "gpt-4o",
|
||||
modelFamilyInitialValue: "other-chat",
|
||||
allowEntraOnlyClaudeModels: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildFoundryConnectionTest(params: {
|
||||
endpoint: string;
|
||||
modelId: string;
|
||||
modelNameHint?: string | null;
|
||||
api: FoundryProviderApi;
|
||||
}): { url: string; body: Record<string, unknown> } {
|
||||
const baseUrl = buildFoundryProviderBaseUrl(
|
||||
params.endpoint,
|
||||
params.modelId,
|
||||
params.modelNameHint,
|
||||
params.api,
|
||||
);
|
||||
if (params.api === DEFAULT_GPT5_API) {
|
||||
return {
|
||||
url: `${baseUrl}/responses`,
|
||||
body: {
|
||||
model: params.modelId,
|
||||
input: "hi",
|
||||
max_output_tokens: 16,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (params.api === ANTHROPIC_MESSAGES_API) {
|
||||
return {
|
||||
url: `${baseUrl}/v1/messages`,
|
||||
body: {
|
||||
model: params.modelId,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 1,
|
||||
...(requiresFoundryMandatoryAdaptiveClaudeThinking(params.modelNameHint ?? params.modelId)
|
||||
? { thinking: { type: "adaptive" } }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
url: `${baseUrl}/chat/completions`,
|
||||
body: {
|
||||
model: params.modelId,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function extractTenantSuggestions(rawMessage: string): Array<{ id: string; label?: string }> {
|
||||
const suggestions: Array<{ id: string; label?: string }> = [];
|
||||
const seen = new Set<string>();
|
||||
const regex = /([0-9a-fA-F-]{36})(?:\s+'([^'\r\n]+)')?/g;
|
||||
for (const match of rawMessage.matchAll(regex)) {
|
||||
const id = normalizeOptionalString(match[1]);
|
||||
if (!id || seen.has(id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(id);
|
||||
suggestions.push({
|
||||
id,
|
||||
...(normalizeOptionalString(match[2]) ? { label: normalizeOptionalString(match[2]) } : {}),
|
||||
});
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
export function isValidTenantIdentifier(value: string): boolean {
|
||||
const trimmed = normalizeOptionalString(value) ?? "";
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
const isTenantUuid =
|
||||
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(trimmed);
|
||||
const isTenantDomain =
|
||||
/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/.test(
|
||||
trimmed,
|
||||
);
|
||||
return isTenantUuid || isTenantDomain;
|
||||
}
|
||||
|
||||
export async function promptTenantId(
|
||||
ctx: ProviderAuthContext,
|
||||
params?: {
|
||||
suggestions?: Array<{ id: string; label?: string }>;
|
||||
required?: boolean;
|
||||
reason?: string;
|
||||
},
|
||||
): Promise<string | undefined> {
|
||||
const suggestionLines =
|
||||
params?.suggestions && params.suggestions.length > 0
|
||||
? params.suggestions.map((entry) => `- ${entry.id}${entry.label ? ` (${entry.label})` : ""}`)
|
||||
: [];
|
||||
if (params?.reason || suggestionLines.length > 0) {
|
||||
await ctx.prompter.note(
|
||||
[
|
||||
params?.reason,
|
||||
suggestionLines.length > 0 ? "Suggested tenants:" : undefined,
|
||||
...suggestionLines,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
"Azure Tenant",
|
||||
);
|
||||
}
|
||||
const tenantId = (
|
||||
await ctx.prompter.text({
|
||||
message: params?.required ? "Azure tenant ID" : "Azure tenant ID (optional)",
|
||||
placeholder: params?.suggestions?.[0]?.id ?? "00000000-0000-0000-0000-000000000000",
|
||||
validate: (value) => {
|
||||
const trimmed = normalizeStringifiedOptionalString(value) ?? "";
|
||||
if (!trimmed) {
|
||||
return params?.required ? "Tenant ID is required" : undefined;
|
||||
}
|
||||
return isValidTenantIdentifier(trimmed)
|
||||
? undefined
|
||||
: "Enter a valid tenant ID or tenant domain";
|
||||
},
|
||||
})
|
||||
).trim();
|
||||
return tenantId || undefined;
|
||||
}
|
||||
|
||||
export async function loginWithTenantFallback(
|
||||
ctx: ProviderAuthContext,
|
||||
): Promise<{ account: AzAccount | null; tenantId?: string }> {
|
||||
try {
|
||||
await azLoginDeviceCode();
|
||||
return { account: getLoggedInAccount() };
|
||||
} catch (error) {
|
||||
const message = formatErrorMessage(error);
|
||||
const isAzureTenantError =
|
||||
/AADSTS\d+/i.test(message) ||
|
||||
/no subscriptions found/i.test(message) ||
|
||||
/Please provide a valid tenant/i.test(message) ||
|
||||
/tenant.*not found/i.test(message);
|
||||
if (!isAzureTenantError) {
|
||||
throw error;
|
||||
}
|
||||
const tenantId = await promptTenantId(ctx, {
|
||||
suggestions: extractTenantSuggestions(message),
|
||||
required: true,
|
||||
reason:
|
||||
"Azure login needs a tenant-scoped retry. This often happens when your tenant requires MFA or your account has no Azure subscriptions.",
|
||||
});
|
||||
await azLoginDeviceCodeWithOptions({
|
||||
tenantId,
|
||||
allowNoSubscriptions: true,
|
||||
});
|
||||
return {
|
||||
account: getLoggedInAccount(),
|
||||
tenantId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function testFoundryConnection(params: {
|
||||
ctx: ProviderAuthContext;
|
||||
endpoint: string;
|
||||
modelId: string;
|
||||
modelNameHint?: string;
|
||||
api: FoundryProviderApi;
|
||||
subscriptionId?: string;
|
||||
tenantId?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const { accessToken } = getAccessTokenResult({
|
||||
scope: params.api === ANTHROPIC_MESSAGES_API ? FOUNDRY_ANTHROPIC_SCOPE : undefined,
|
||||
subscriptionId: params.subscriptionId,
|
||||
tenantId: params.tenantId,
|
||||
});
|
||||
const testRequest = buildFoundryConnectionTest({
|
||||
endpoint: params.endpoint,
|
||||
modelId: params.modelId,
|
||||
modelNameHint: params.modelNameHint,
|
||||
api: params.api,
|
||||
});
|
||||
const { response: res, release } = await fetchWithSsrFGuard({
|
||||
url: testRequest.url,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
...(params.api === ANTHROPIC_MESSAGES_API ? { "anthropic-version": "2023-06-01" } : {}),
|
||||
},
|
||||
body: JSON.stringify(testRequest.body),
|
||||
},
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
try {
|
||||
if (res.status === 400) {
|
||||
const body = await readResponseTextLimited(
|
||||
res,
|
||||
FOUNDRY_CONNECTION_TEST_ERROR_BODY_LIMIT_BYTES,
|
||||
).catch(() => "");
|
||||
await params.ctx.prompter.note(
|
||||
`Endpoint is reachable but returned 400 Bad Request - check your deployment name and API version.\n${body.slice(0, 200)}`,
|
||||
"Connection Test",
|
||||
);
|
||||
} else if (!res.ok) {
|
||||
const body = await readResponseTextLimited(
|
||||
res,
|
||||
FOUNDRY_CONNECTION_TEST_ERROR_BODY_LIMIT_BYTES,
|
||||
).catch(() => "");
|
||||
await params.ctx.prompter.note(
|
||||
`Warning: test request returned ${res.status}. ${body.slice(0, 200)}\nProceeding anyway - you can fix the endpoint later.`,
|
||||
"Connection Test",
|
||||
);
|
||||
} else {
|
||||
await params.ctx.prompter.note("Connection test successful!", "✓");
|
||||
}
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
} catch (err) {
|
||||
await params.ctx.prompter.note(
|
||||
`Warning: connection test failed: ${String(err)}\nProceeding anyway.`,
|
||||
"Connection Test",
|
||||
);
|
||||
}
|
||||
}
|
||||
57
extensions/microsoft-foundry/openclaw.plugin.json
Normal file
57
extensions/microsoft-foundry/openclaw.plugin.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"id": "microsoft-foundry",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"providers": ["microsoft-foundry"],
|
||||
"setup": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "microsoft-foundry",
|
||||
"envVars": ["AZURE_OPENAI_API_KEY"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"contracts": {
|
||||
"imageGenerationProviders": ["microsoft-foundry"]
|
||||
},
|
||||
"imageGenerationProviderMetadata": {
|
||||
"microsoft-foundry": {
|
||||
"authSignals": [
|
||||
{
|
||||
"provider": "microsoft-foundry"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"onboardingScopes": ["text-inference", "image-generation"],
|
||||
"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",
|
||||
"onboardingScopes": ["text-inference", "image-generation"],
|
||||
"groupId": "microsoft-foundry",
|
||||
"groupLabel": "Microsoft Foundry",
|
||||
"groupHint": "Entra ID + API key"
|
||||
}
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
15
extensions/microsoft-foundry/package.json
Normal file
15
extensions/microsoft-foundry/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@openclaw/microsoft-foundry",
|
||||
"version": "2026.6.11",
|
||||
"private": true,
|
||||
"description": "OpenClaw Microsoft Foundry provider plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
263
extensions/microsoft-foundry/provider.ts
Normal file
263
extensions/microsoft-foundry/provider.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
// Microsoft Foundry provider module implements model/runtime integration.
|
||||
import type { ProviderNormalizeResolvedModelContext } from "openclaw/plugin-sdk/core";
|
||||
import {
|
||||
resolveClaudeThinkingProfile,
|
||||
supportsClaudeNativeMaxEffort,
|
||||
type ModelProviderConfig,
|
||||
type ProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { OPENAI_RESPONSES_STREAM_HOOKS } from "openclaw/plugin-sdk/provider-stream-family";
|
||||
import { apiKeyAuthMethod, entraIdAuthMethod } from "./auth.js";
|
||||
import { prepareFoundryRuntimeAuth } from "./runtime.js";
|
||||
import {
|
||||
PROVIDER_ID,
|
||||
applyFoundryProfileBinding,
|
||||
applyFoundryProviderConfig,
|
||||
buildFoundryProviderBaseUrl,
|
||||
extractFoundryEndpoint,
|
||||
isFoundryClaudeMythosPreview,
|
||||
isFoundryProviderApi,
|
||||
mergeFoundryCanonicalModelParams,
|
||||
normalizeFoundryEndpoint,
|
||||
resolveFoundryModelCapabilities,
|
||||
resolveFoundryTargetProfileId,
|
||||
} from "./shared.js";
|
||||
|
||||
type FoundryProviderHooks = Pick<ProviderPlugin, "wrapStreamFn">;
|
||||
|
||||
const wrapOpenAIResponsesStreamFn = OPENAI_RESPONSES_STREAM_HOOKS.wrapStreamFn;
|
||||
|
||||
const wrapMicrosoftFoundryStreamFn: NonNullable<FoundryProviderHooks["wrapStreamFn"]> = (ctx) => {
|
||||
if (ctx.model?.api !== "openai-responses") {
|
||||
return ctx.streamFn ?? null;
|
||||
}
|
||||
|
||||
const baseStreamFn = ctx.streamFn;
|
||||
if (!baseStreamFn) {
|
||||
return wrapOpenAIResponsesStreamFn?.(ctx) ?? null;
|
||||
}
|
||||
|
||||
const streamFnWithResponsesReplayIds: NonNullable<typeof ctx.streamFn> = (
|
||||
model,
|
||||
context,
|
||||
options,
|
||||
) =>
|
||||
baseStreamFn(model, context, {
|
||||
...options,
|
||||
// Foundry validates encrypted reasoning replay against the original item id,
|
||||
// even though its Responses endpoint does not support persisted `store`.
|
||||
replayResponsesItemIds: true,
|
||||
} as typeof options & { replayResponsesItemIds: true });
|
||||
|
||||
return (
|
||||
wrapOpenAIResponsesStreamFn?.({
|
||||
...ctx,
|
||||
streamFn: streamFnWithResponsesReplayIds,
|
||||
}) ?? streamFnWithResponsesReplayIds
|
||||
);
|
||||
};
|
||||
|
||||
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 ||
|
||||
!providerConfig.baseUrl?.trim() ||
|
||||
!Array.isArray(providerConfig.models) ||
|
||||
!ctx.model.startsWith(`${PROVIDER_ID}/`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const selectedModelId = ctx.model.slice(`${PROVIDER_ID}/`.length);
|
||||
const configuredModels = providerConfig.models ?? [];
|
||||
const existingModel = configuredModels.find(
|
||||
(model: { id: string }) => model.id === selectedModelId,
|
||||
);
|
||||
const existingModelApi = isFoundryProviderApi(existingModel?.api)
|
||||
? existingModel.api
|
||||
: undefined;
|
||||
const providerApiForExistingModel =
|
||||
existingModel && isFoundryProviderApi(providerConfig.api) ? providerConfig.api : undefined;
|
||||
const selectedModelCapabilities = resolveFoundryModelCapabilities(
|
||||
selectedModelId,
|
||||
existingModel?.name,
|
||||
existingModelApi ?? providerApiForExistingModel,
|
||||
existingModel?.input,
|
||||
);
|
||||
const providerEndpoint = normalizeFoundryEndpoint(providerConfig.baseUrl ?? "");
|
||||
const selectedProviderEndpoint =
|
||||
extractFoundryEndpoint(existingModel?.baseUrl) ?? providerEndpoint;
|
||||
const nextModels = configuredModels.map((model) => {
|
||||
if (model.id !== selectedModelId) {
|
||||
return model;
|
||||
}
|
||||
const selectedModelEndpoint = extractFoundryEndpoint(model.baseUrl) ?? providerEndpoint;
|
||||
const selectedModelBaseUrl = buildFoundryProviderBaseUrl(
|
||||
selectedModelEndpoint,
|
||||
selectedModelId,
|
||||
selectedModelCapabilities.modelName,
|
||||
selectedModelCapabilities.api,
|
||||
);
|
||||
const nextModel = Object.assign({}, model, {
|
||||
name: selectedModelCapabilities.modelName,
|
||||
api: selectedModelCapabilities.api,
|
||||
baseUrl: selectedModelBaseUrl,
|
||||
reasoning: selectedModelCapabilities.reasoning || model.reasoning,
|
||||
thinkingLevelMap: selectedModelCapabilities.thinkingLevelMap ?? model.thinkingLevelMap,
|
||||
params: mergeFoundryCanonicalModelParams(
|
||||
model.params,
|
||||
selectedModelCapabilities.modelName,
|
||||
),
|
||||
input: selectedModelCapabilities.input,
|
||||
});
|
||||
if (selectedModelCapabilities.compat) {
|
||||
const explicitSupportsReasoningEffort =
|
||||
typeof model.compat?.supportsReasoningEffort === "boolean"
|
||||
? model.compat.supportsReasoningEffort
|
||||
: undefined;
|
||||
const preserveExplicitReasoningEffort =
|
||||
!selectedModelCapabilities.reasoning &&
|
||||
model.reasoning &&
|
||||
explicitSupportsReasoningEffort !== false;
|
||||
const explicitMaxTokensField =
|
||||
typeof model.compat?.maxTokensField === "string"
|
||||
? model.compat.maxTokensField
|
||||
: preserveExplicitReasoningEffort
|
||||
? "max_completion_tokens"
|
||||
: undefined;
|
||||
nextModel.compat = {
|
||||
...model.compat,
|
||||
...selectedModelCapabilities.compat,
|
||||
...(explicitSupportsReasoningEffort !== undefined
|
||||
? { supportsReasoningEffort: explicitSupportsReasoningEffort }
|
||||
: preserveExplicitReasoningEffort
|
||||
? { supportsReasoningEffort: true }
|
||||
: undefined),
|
||||
...(explicitMaxTokensField ? { maxTokensField: explicitMaxTokensField } : {}),
|
||||
};
|
||||
}
|
||||
return nextModel;
|
||||
});
|
||||
if (!nextModels.some((model) => model.id === selectedModelId)) {
|
||||
nextModels.push({
|
||||
id: selectedModelId,
|
||||
name: selectedModelCapabilities.modelName,
|
||||
api: selectedModelCapabilities.api,
|
||||
baseUrl: buildFoundryProviderBaseUrl(
|
||||
providerEndpoint,
|
||||
selectedModelId,
|
||||
selectedModelCapabilities.modelName,
|
||||
selectedModelCapabilities.api,
|
||||
),
|
||||
reasoning: selectedModelCapabilities.reasoning,
|
||||
...(selectedModelCapabilities.thinkingLevelMap
|
||||
? { thinkingLevelMap: selectedModelCapabilities.thinkingLevelMap }
|
||||
: {}),
|
||||
params: mergeFoundryCanonicalModelParams(undefined, selectedModelCapabilities.modelName),
|
||||
input: selectedModelCapabilities.input,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: selectedModelCapabilities.contextWindow,
|
||||
maxTokens: selectedModelCapabilities.maxTokens,
|
||||
...(selectedModelCapabilities.compat ? { compat: selectedModelCapabilities.compat } : {}),
|
||||
});
|
||||
}
|
||||
const nextProviderConfig: ModelProviderConfig = {
|
||||
...providerConfig,
|
||||
baseUrl: buildFoundryProviderBaseUrl(
|
||||
selectedProviderEndpoint,
|
||||
selectedModelId,
|
||||
selectedModelCapabilities.modelName,
|
||||
selectedModelCapabilities.api,
|
||||
),
|
||||
api: selectedModelCapabilities.api,
|
||||
models: nextModels,
|
||||
};
|
||||
const targetProfileId = resolveFoundryTargetProfileId(ctx.config);
|
||||
if (targetProfileId) {
|
||||
applyFoundryProfileBinding(ctx.config, targetProfileId);
|
||||
}
|
||||
applyFoundryProviderConfig(ctx.config, nextProviderConfig);
|
||||
},
|
||||
resolveThinkingProfile: ({ modelId, params }) => {
|
||||
const modelName =
|
||||
typeof params?.canonicalModelId === "string" ? params.canonicalModelId : undefined;
|
||||
const capabilities = resolveFoundryModelCapabilities(modelId, modelName);
|
||||
if (!capabilities.reasoning || capabilities.api !== "anthropic-messages") {
|
||||
return undefined;
|
||||
}
|
||||
const profile = resolveClaudeThinkingProfile(capabilities.modelName, undefined, {
|
||||
includeNativeMax: supportsClaudeNativeMaxEffort({ id: capabilities.modelName }),
|
||||
});
|
||||
if (!isFoundryClaudeMythosPreview(capabilities.modelName)) {
|
||||
return profile;
|
||||
}
|
||||
const levels = profile.levels.filter((level) => level.id !== "off");
|
||||
return {
|
||||
...profile,
|
||||
defaultLevel: "adaptive",
|
||||
levels: levels.some((level) => level.id === "adaptive")
|
||||
? levels
|
||||
: [...levels, { id: "adaptive" }],
|
||||
};
|
||||
},
|
||||
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,
|
||||
);
|
||||
const explicitSupportsReasoningEffort =
|
||||
typeof model.compat?.supportsReasoningEffort === "boolean"
|
||||
? model.compat.supportsReasoningEffort
|
||||
: undefined;
|
||||
const preserveExplicitReasoningEffort = !capabilities.reasoning && model.reasoning;
|
||||
const explicitMaxTokensField =
|
||||
typeof model.compat?.maxTokensField === "string"
|
||||
? model.compat.maxTokensField
|
||||
: preserveExplicitReasoningEffort
|
||||
? "max_completion_tokens"
|
||||
: undefined;
|
||||
const compat = capabilities.compat
|
||||
? {
|
||||
...model.compat,
|
||||
...capabilities.compat,
|
||||
...(explicitSupportsReasoningEffort !== undefined
|
||||
? { supportsReasoningEffort: explicitSupportsReasoningEffort }
|
||||
: preserveExplicitReasoningEffort
|
||||
? { supportsReasoningEffort: true }
|
||||
: undefined),
|
||||
...(explicitMaxTokensField ? { maxTokensField: explicitMaxTokensField } : {}),
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
...model,
|
||||
name: capabilities.modelName,
|
||||
api: capabilities.api,
|
||||
reasoning: capabilities.reasoning || model.reasoning,
|
||||
thinkingLevelMap: capabilities.thinkingLevelMap ?? model.thinkingLevelMap,
|
||||
params: mergeFoundryCanonicalModelParams(model.params, capabilities.modelName),
|
||||
input: capabilities.input,
|
||||
baseUrl: buildFoundryProviderBaseUrl(
|
||||
endpoint,
|
||||
modelId,
|
||||
capabilities.modelName,
|
||||
capabilities.api,
|
||||
),
|
||||
...(compat ? { compat } : {}),
|
||||
};
|
||||
},
|
||||
wrapStreamFn: wrapMicrosoftFoundryStreamFn,
|
||||
prepareRuntimeAuth: prepareFoundryRuntimeAuth,
|
||||
};
|
||||
}
|
||||
149
extensions/microsoft-foundry/runtime.ts
Normal file
149
extensions/microsoft-foundry/runtime.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
// Microsoft Foundry plugin module implements runtime behavior.
|
||||
import type {
|
||||
ProviderPreparedRuntimeAuth,
|
||||
ProviderPrepareRuntimeAuthContext,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { ensureAuthProfileStore } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { getAccessTokenResultAsync } from "./cli.js";
|
||||
import {
|
||||
ANTHROPIC_MESSAGES_API,
|
||||
type CachedTokenEntry,
|
||||
FOUNDRY_ANTHROPIC_SCOPE,
|
||||
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 }>>();
|
||||
const FOUNDRY_TOKEN_FALLBACK_LIFETIME_MS = 55 * 60 * 1000;
|
||||
|
||||
export function resetFoundryRuntimeAuthCaches(): void {
|
||||
cachedTokens.clear();
|
||||
refreshPromises.clear();
|
||||
}
|
||||
|
||||
async function refreshEntraToken(params?: {
|
||||
scope?: string;
|
||||
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 now = resolveDateTimestampMs(Date.now());
|
||||
const expiresAt =
|
||||
asDateTimestampMs(rawExpiry) ??
|
||||
resolveExpiresAtMsFromDurationMs(FOUNDRY_TOKEN_FALLBACK_LIFETIME_MS, { nowMs: now }) ??
|
||||
now;
|
||||
cachedTokens.set(getFoundryTokenCacheKey(params), {
|
||||
token: result.accessToken,
|
||||
expiresAt,
|
||||
});
|
||||
return { apiKey: result.accessToken, expiresAt };
|
||||
}
|
||||
|
||||
export async function prepareFoundryRuntimeAuth(
|
||||
ctx: ProviderPrepareRuntimeAuthContext,
|
||||
): Promise<ProviderPreparedRuntimeAuth> {
|
||||
if (ctx.apiKey !== "__entra_id_dynamic__") {
|
||||
return {
|
||||
apiKey: ctx.apiKey,
|
||||
request: {
|
||||
auth: {
|
||||
mode: "header" as const,
|
||||
headerName: ctx.model.api === ANTHROPIC_MESSAGES_API ? "x-api-key" : "api-key",
|
||||
value: ctx.apiKey,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
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 requestedModelId = normalizeOptionalString(ctx.modelId);
|
||||
const metadataModelId = normalizeOptionalString(metadata?.modelId);
|
||||
const activeModelUsesMetadata = !requestedModelId || requestedModelId === metadataModelId;
|
||||
const activeModelNameHint = activeModelUsesMetadata ? metadata?.modelName : undefined;
|
||||
const modelNameHint = resolveConfiguredModelNameHint(
|
||||
modelId,
|
||||
ctx.model.name ?? activeModelNameHint,
|
||||
);
|
||||
const configuredApi = isFoundryProviderApi(ctx.model.api)
|
||||
? ctx.model.api
|
||||
: activeModelUsesMetadata &&
|
||||
typeof metadata?.api === "string" &&
|
||||
isFoundryProviderApi(metadata.api)
|
||||
? metadata.api
|
||||
: undefined;
|
||||
const endpoint =
|
||||
extractFoundryEndpoint(ctx.model.baseUrl ?? "") ??
|
||||
normalizeOptionalString(metadata?.endpoint);
|
||||
const tokenScope =
|
||||
configuredApi === ANTHROPIC_MESSAGES_API ? FOUNDRY_ANTHROPIC_SCOPE : undefined;
|
||||
const baseUrl = endpoint
|
||||
? buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint, configuredApi)
|
||||
: undefined;
|
||||
const cacheKey = getFoundryTokenCacheKey({
|
||||
scope: tokenScope,
|
||||
subscriptionId: metadata?.subscriptionId,
|
||||
tenantId: metadata?.tenantId,
|
||||
});
|
||||
const cachedToken = cachedTokens.get(cacheKey);
|
||||
const rawNow = Date.now();
|
||||
const hasValidClock = asDateTimestampMs(rawNow) !== undefined;
|
||||
const now = resolveDateTimestampMs(rawNow);
|
||||
const refreshAfterMs =
|
||||
resolveExpiresAtMsFromDurationMs(TOKEN_REFRESH_MARGIN_MS, { nowMs: now }) ?? now;
|
||||
if (cachedToken && hasValidClock && cachedToken.expiresAt > refreshAfterMs) {
|
||||
return {
|
||||
apiKey: cachedToken.token,
|
||||
expiresAt: cachedToken.expiresAt,
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
request: {
|
||||
auth: { mode: "authorization-bearer" as const, token: cachedToken.token },
|
||||
},
|
||||
};
|
||||
}
|
||||
let refreshPromise = refreshPromises.get(cacheKey);
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = refreshEntraToken({
|
||||
scope: tokenScope,
|
||||
subscriptionId: metadata?.subscriptionId,
|
||||
tenantId: metadata?.tenantId,
|
||||
}).finally(() => {
|
||||
refreshPromises.delete(cacheKey);
|
||||
});
|
||||
refreshPromises.set(cacheKey, refreshPromise);
|
||||
}
|
||||
const token = await refreshPromise;
|
||||
return {
|
||||
...token,
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
request: {
|
||||
auth: { mode: "authorization-bearer" as const, token: token.apiKey },
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
const details = formatErrorMessage(err);
|
||||
throw new Error(`Failed to refresh Azure Entra ID token via az CLI: ${details}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
19
extensions/microsoft-foundry/shared-runtime.ts
Normal file
19
extensions/microsoft-foundry/shared-runtime.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// Microsoft Foundry plugin module implements shared runtime behavior.
|
||||
export {
|
||||
TOKEN_REFRESH_MARGIN_MS,
|
||||
buildFoundryProviderBaseUrl,
|
||||
extractFoundryEndpoint,
|
||||
FOUNDRY_ANTHROPIC_SCOPE,
|
||||
isFoundryProviderApi,
|
||||
resolveConfiguredModelNameHint,
|
||||
ANTHROPIC_MESSAGES_API,
|
||||
type CachedTokenEntry,
|
||||
} from "./shared.js";
|
||||
|
||||
export function getFoundryTokenCacheKey(params?: {
|
||||
scope?: string;
|
||||
subscriptionId?: string;
|
||||
tenantId?: string;
|
||||
}): string {
|
||||
return `${params?.scope ?? ""}:${params?.subscriptionId ?? ""}:${params?.tenantId ?? ""}`;
|
||||
}
|
||||
769
extensions/microsoft-foundry/shared.ts
Normal file
769
extensions/microsoft-foundry/shared.ts
Normal file
@@ -0,0 +1,769 @@
|
||||
// Microsoft Foundry plugin module implements shared behavior.
|
||||
import type { AuthConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
buildApiKeyCredential,
|
||||
type ProviderAuthResult,
|
||||
type SecretInput,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
import {
|
||||
resolveClaudeFable5ModelIdentity,
|
||||
supportsClaudeAdaptiveThinking,
|
||||
supportsClaudeNativeXhighEffort,
|
||||
type ModelApi,
|
||||
type ModelProviderConfig,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
export const PROVIDER_ID = "microsoft-foundry";
|
||||
export const DEFAULT_API = "openai-completions";
|
||||
export const DEFAULT_GPT5_API = "openai-responses";
|
||||
export const ANTHROPIC_MESSAGES_API = "anthropic-messages";
|
||||
export const COGNITIVE_SERVICES_RESOURCE = "https://cognitiveservices.azure.com";
|
||||
export const FOUNDRY_ANTHROPIC_SCOPE = "https://ai.azure.com/.default";
|
||||
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
|
||||
| typeof ANTHROPIC_MESSAGES_API;
|
||||
|
||||
type FoundryDeploymentConfigInput = {
|
||||
name: string;
|
||||
modelName?: string;
|
||||
api?: FoundryProviderApi;
|
||||
};
|
||||
|
||||
type FoundryModelCapabilities = {
|
||||
modelName: string;
|
||||
api: FoundryProviderApi;
|
||||
reasoning: boolean;
|
||||
thinkingLevelMap?: Record<string, string | null>;
|
||||
input: Array<"text" | "image">;
|
||||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
compat?: FoundryModelCompat;
|
||||
};
|
||||
|
||||
type FoundryProviderConfigPatch = Omit<ModelProviderConfig, "apiKey" | "headers"> & {
|
||||
apiKey?: SecretInput | undefined;
|
||||
headers?: Record<string, SecretInput> | undefined;
|
||||
};
|
||||
|
||||
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;
|
||||
supportsReasoningEffort?: boolean;
|
||||
supportedReasoningEfforts?: string[];
|
||||
maxTokensField: "max_completion_tokens" | "max_tokens";
|
||||
};
|
||||
|
||||
type FoundryConfigShape = {
|
||||
auth?: AuthConfig;
|
||||
models?: {
|
||||
providers?: Record<string, ModelProviderConfig>;
|
||||
};
|
||||
};
|
||||
|
||||
type FoundryImageDefaultPatch = {
|
||||
agents?: {
|
||||
defaults?: {
|
||||
imageGenerationModel?: {
|
||||
primary: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function normalizeFoundryModelName(value?: string | null): string | undefined {
|
||||
const trimmed = normalizeLowercaseStringOrEmpty(value);
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
export function isAnthropicFoundryDeployment(modelName?: string | null): boolean {
|
||||
const normalized = normalizeFoundryModelName(modelName);
|
||||
return normalized ? normalized.startsWith("claude") : false;
|
||||
}
|
||||
|
||||
export function isFoundryClaudeMythosPreview(value?: string | null): boolean {
|
||||
return normalizeFoundryModelName(value) === "claude-mythos-preview";
|
||||
}
|
||||
|
||||
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.startsWith("deepseek-v4") ||
|
||||
normalized === "computer-use-preview"
|
||||
);
|
||||
}
|
||||
|
||||
export function isFoundryMaiImageModel(value?: string | null): boolean {
|
||||
const normalized = normalizeFoundryModelName(value);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
normalized === "mai-image-2.5-flash" ||
|
||||
normalized === "mai-image-2.5" ||
|
||||
normalized === "mai-image-2e" ||
|
||||
normalized === "mai-image-2" ||
|
||||
normalized === "mai-image-2-efficient"
|
||||
);
|
||||
}
|
||||
|
||||
export function supportsFoundryReasoningContent(value?: string | null): boolean {
|
||||
const normalized = normalizeFoundryModelName(value);
|
||||
return normalized === "mai-ds-r1" || normalized === "mai-thinking-1";
|
||||
}
|
||||
|
||||
export function supportsFoundryImageInput(value?: string | null): boolean {
|
||||
const normalized = normalizeFoundryModelName(value);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
isAnthropicFoundryDeployment(normalized) ||
|
||||
normalized.startsWith("gpt-") ||
|
||||
normalized.startsWith("o1") ||
|
||||
normalized.startsWith("o3") ||
|
||||
normalized.startsWith("o4") ||
|
||||
normalized === "computer-use-preview"
|
||||
);
|
||||
}
|
||||
|
||||
export function requiresFoundryEntraIdClaudeAuth(value?: string | null): boolean {
|
||||
const normalized = normalizeFoundryModelName(value);
|
||||
return normalized
|
||||
? normalized === "claude-mythos-preview" || normalized.startsWith("claude-mythos-")
|
||||
: false;
|
||||
}
|
||||
|
||||
export function requiresFoundryMandatoryAdaptiveClaudeThinking(value?: string | null): boolean {
|
||||
const normalized = normalizeFoundryModelName(value);
|
||||
return normalized
|
||||
? resolveClaudeFable5ModelIdentity({ id: normalized }) !== undefined ||
|
||||
normalized === "claude-mythos-preview" ||
|
||||
normalized.startsWith("claude-mythos-")
|
||||
: false;
|
||||
}
|
||||
|
||||
function supportsFoundryManualClaudeThinking(value?: string | null): boolean {
|
||||
const normalized = normalizeFoundryModelName(value)?.replace(/\./g, "-");
|
||||
return normalized
|
||||
? /(?:^|-)claude-(?:opus-4-(?:1|5)|sonnet-4-5|haiku-4-5)(?=$|[^a-z0-9])/.test(normalized)
|
||||
: false;
|
||||
}
|
||||
|
||||
function resolveFoundryModelTokenLimits(value?: string | null): {
|
||||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
} {
|
||||
const normalized = normalizeFoundryModelName(value);
|
||||
const normalizedVersion = normalized?.replace(/\./g, "-");
|
||||
if (
|
||||
normalized &&
|
||||
(supportsClaudeAdaptiveThinking({ id: normalized }) ||
|
||||
requiresFoundryMandatoryAdaptiveClaudeThinking(normalized))
|
||||
) {
|
||||
return { contextWindow: 1_000_000, maxTokens: 128_000 };
|
||||
}
|
||||
if (
|
||||
normalizedVersion === "claude-opus-4-5" ||
|
||||
normalizedVersion === "claude-sonnet-4-5" ||
|
||||
normalizedVersion === "claude-haiku-4-5"
|
||||
) {
|
||||
return { contextWindow: 200_000, maxTokens: 64_000 };
|
||||
}
|
||||
if (normalizedVersion === "claude-opus-4-1") {
|
||||
return { contextWindow: 200_000, maxTokens: 32_000 };
|
||||
}
|
||||
if (normalized === "mai-ds-r1") {
|
||||
return { contextWindow: 163_840, maxTokens: 163_840 };
|
||||
}
|
||||
return { contextWindow: 128_000, maxTokens: 16_384 };
|
||||
}
|
||||
|
||||
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 supportsFoundryReasoningEffort(value?: string | null): boolean {
|
||||
const normalized = normalizeFoundryModelName(value);
|
||||
if (
|
||||
!normalized ||
|
||||
/^gpt-5-chat(?:-|$)/u.test(normalized) ||
|
||||
/^o1-mini(?:-|$)/u.test(normalized)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
normalized.startsWith("gpt-5") ||
|
||||
normalized.startsWith("o1") ||
|
||||
normalized.startsWith("o3") ||
|
||||
normalized.startsWith("o4")
|
||||
);
|
||||
}
|
||||
|
||||
function resolveFoundryReasoningEfforts(value?: string | null): string[] | undefined {
|
||||
const normalized = normalizeFoundryModelName(value);
|
||||
if (!normalized || !supportsFoundryReasoningEffort(normalized)) {
|
||||
return undefined;
|
||||
}
|
||||
if (normalized === "gpt-5.1-codex-max") {
|
||||
return ["none", "medium", "high", "xhigh"];
|
||||
}
|
||||
if (normalized === "gpt-5-pro") {
|
||||
return ["high"];
|
||||
}
|
||||
if (/^gpt-5\.[2-9](?:\.|-|$)/u.test(normalized)) {
|
||||
return ["none", "low", "medium", "high"];
|
||||
}
|
||||
if (/^gpt-5\.1(?:-|$)/u.test(normalized)) {
|
||||
return ["none", "low", "medium", "high"];
|
||||
}
|
||||
if (/^gpt-5-codex(?:-|$)/u.test(normalized)) {
|
||||
return ["low", "medium", "high"];
|
||||
}
|
||||
if (/^gpt-5(?:-|$)/u.test(normalized)) {
|
||||
return ["minimal", "low", "medium", "high"];
|
||||
}
|
||||
return ["low", "medium", "high"];
|
||||
}
|
||||
|
||||
function buildFoundryThinkingLevelMap(
|
||||
efforts: string[] | undefined,
|
||||
): Record<string, string | null> | undefined {
|
||||
if (!efforts) {
|
||||
return undefined;
|
||||
}
|
||||
const supported = new Set(efforts);
|
||||
return {
|
||||
off: supported.has("none") ? "none" : null,
|
||||
minimal: supported.has("minimal") ? "minimal" : null,
|
||||
low: supported.has("low") ? "low" : null,
|
||||
medium: supported.has("medium") ? "medium" : null,
|
||||
high: supported.has("high") ? "high" : null,
|
||||
xhigh: supported.has("xhigh") ? "xhigh" : null,
|
||||
max: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function isFoundryProviderApi(value?: string | null): value is FoundryProviderApi {
|
||||
return value === DEFAULT_API || value === DEFAULT_GPT5_API || value === ANTHROPIC_MESSAGES_API;
|
||||
}
|
||||
|
||||
export function formatFoundryApiLabel(api: FoundryProviderApi): string {
|
||||
return api === DEFAULT_GPT5_API
|
||||
? "Responses"
|
||||
: api === ANTHROPIC_MESSAGES_API
|
||||
? "Anthropic Messages"
|
||||
: "Chat Completions";
|
||||
}
|
||||
|
||||
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|anthropic)(?:$|\/).*/i, "")
|
||||
.replace(/\/+$/, "");
|
||||
return `${parsed.origin}${normalizedPath && normalizedPath !== "/" ? normalizedPath : ""}`;
|
||||
} catch {
|
||||
const withoutQuery = trimmed.replace(/[?#].*$/, "").replace(/\/+$/, "");
|
||||
return withoutQuery.replace(/\/(?:openai|anthropic)(?:$|\/).*/i, "");
|
||||
}
|
||||
}
|
||||
|
||||
function buildFoundryV1BaseUrl(endpoint: string): string {
|
||||
const base = normalizeFoundryEndpoint(endpoint);
|
||||
return base.endsWith("/openai/v1") ? base : `${base}/openai/v1`;
|
||||
}
|
||||
|
||||
function buildFoundryAnthropicBaseUrl(endpoint: string): string {
|
||||
const base = normalizeFoundryEndpoint(endpoint);
|
||||
return base.endsWith("/anthropic") ? base : `${base}/anthropic`;
|
||||
}
|
||||
|
||||
export function resolveFoundryApi(
|
||||
modelId: string,
|
||||
modelNameHint?: string | null,
|
||||
configuredApi?: ModelApi | null,
|
||||
): FoundryProviderApi {
|
||||
if (isFoundryProviderApi(configuredApi)) {
|
||||
return configuredApi;
|
||||
}
|
||||
const configuredModelName = resolveConfiguredModelNameHint(modelId, modelNameHint);
|
||||
if (isAnthropicFoundryDeployment(configuredModelName)) {
|
||||
return ANTHROPIC_MESSAGES_API;
|
||||
}
|
||||
return usesFoundryResponsesByDefault(configuredModelName) ? DEFAULT_GPT5_API : DEFAULT_API;
|
||||
}
|
||||
|
||||
export function buildFoundryProviderBaseUrl(
|
||||
endpoint: string,
|
||||
modelId: string,
|
||||
modelNameHint?: string | null,
|
||||
configuredApi?: ModelApi | null,
|
||||
): string {
|
||||
const resolvedApi = resolveFoundryApi(modelId, modelNameHint, configuredApi);
|
||||
return resolvedApi === ANTHROPIC_MESSAGES_API
|
||||
? buildFoundryAnthropicBaseUrl(endpoint)
|
||||
: buildFoundryV1BaseUrl(endpoint);
|
||||
}
|
||||
|
||||
export function extractFoundryEndpoint(baseUrl: string | null | undefined): string | undefined {
|
||||
if (!baseUrl) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return normalizeFoundryEndpoint(baseUrl);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function buildFoundryModelCompat(
|
||||
modelId: string,
|
||||
modelNameHint?: string | null,
|
||||
configuredApi?: ModelApi | null,
|
||||
): FoundryModelCompat | undefined {
|
||||
const resolvedApi = resolveFoundryApi(modelId, modelNameHint, configuredApi);
|
||||
if (resolvedApi === ANTHROPIC_MESSAGES_API) {
|
||||
return undefined;
|
||||
}
|
||||
const configuredModelName = resolveConfiguredModelNameHint(modelId, modelNameHint);
|
||||
const needsMaxCompletionTokens = requiresFoundryMaxCompletionTokens(configuredModelName);
|
||||
const supportsReasoningEffort = supportsFoundryReasoningEffort(configuredModelName);
|
||||
const supportedReasoningEfforts = resolveFoundryReasoningEfforts(configuredModelName);
|
||||
if (resolvedApi !== DEFAULT_GPT5_API) {
|
||||
return {
|
||||
supportsReasoningEffort,
|
||||
...(supportedReasoningEfforts ? { supportedReasoningEfforts } : {}),
|
||||
maxTokensField: needsMaxCompletionTokens ? "max_completion_tokens" : "max_tokens",
|
||||
};
|
||||
}
|
||||
return {
|
||||
...(resolvedApi === DEFAULT_GPT5_API ? { supportsStore: false } : {}),
|
||||
...(supportsReasoningEffort ? { supportsReasoningEffort, supportedReasoningEfforts } : {}),
|
||||
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);
|
||||
const supportedReasoningEfforts = resolveFoundryReasoningEfforts(modelName);
|
||||
const isAnthropic = api === ANTHROPIC_MESSAGES_API || isAnthropicFoundryDeployment(modelName);
|
||||
const supportsClaudeThinking =
|
||||
isAnthropic &&
|
||||
(supportsClaudeAdaptiveThinking({ id: modelName }) ||
|
||||
supportsFoundryManualClaudeThinking(modelName) ||
|
||||
requiresFoundryMandatoryAdaptiveClaudeThinking(modelName));
|
||||
const supportsClaudeXhighThinking =
|
||||
isAnthropic && supportsClaudeNativeXhighEffort({ id: modelName });
|
||||
const tokenLimits = resolveFoundryModelTokenLimits(modelName);
|
||||
return {
|
||||
modelName,
|
||||
api,
|
||||
reasoning:
|
||||
supportsClaudeThinking ||
|
||||
supportsFoundryReasoningEffort(modelName) ||
|
||||
supportsFoundryReasoningContent(modelName),
|
||||
...(supportsClaudeXhighThinking
|
||||
? { thinkingLevelMap: { xhigh: "xhigh", max: "max" } }
|
||||
: supportedReasoningEfforts
|
||||
? { thinkingLevelMap: buildFoundryThinkingLevelMap(supportedReasoningEfforts) }
|
||||
: {}),
|
||||
input:
|
||||
normalizedInput.includes("image") || supportsFoundryImageInput(modelName)
|
||||
? ["text", "image"]
|
||||
: normalizedInput,
|
||||
contextWindow: tokenLimits.contextWindow,
|
||||
maxTokens: tokenLimits.maxTokens,
|
||||
compat: buildFoundryModelCompat(modelId, modelName, api),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeFoundryCanonicalModelParams(
|
||||
params: Record<string, unknown> | undefined,
|
||||
modelName: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...params,
|
||||
canonicalModelId: modelName,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function buildFoundryProviderConfig(
|
||||
endpoint: string,
|
||||
modelId: string,
|
||||
modelNameHint?: string | null,
|
||||
options?: {
|
||||
api?: FoundryProviderApi;
|
||||
deployments?: FoundryDeploymentConfigInput[];
|
||||
},
|
||||
): FoundryProviderConfigPatch {
|
||||
const resolvedApi = resolveFoundryApi(modelId, modelNameHint, options?.api);
|
||||
const deployments = options?.deployments?.length
|
||||
? options.deployments
|
||||
: [{ name: modelId, modelName: modelNameHint ?? undefined, api: resolvedApi }];
|
||||
return {
|
||||
baseUrl: buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint, resolvedApi),
|
||||
api: resolvedApi,
|
||||
authHeader: undefined,
|
||||
apiKey: undefined,
|
||||
headers: undefined,
|
||||
models: deployments.map((deployment) => {
|
||||
const capabilities = resolveFoundryModelCapabilities(
|
||||
deployment.name,
|
||||
deployment.modelName,
|
||||
deployment.api ?? resolvedApi,
|
||||
);
|
||||
const modelBaseUrl = buildFoundryProviderBaseUrl(
|
||||
endpoint,
|
||||
deployment.name,
|
||||
capabilities.modelName,
|
||||
capabilities.api,
|
||||
);
|
||||
return Object.assign(
|
||||
{
|
||||
id: deployment.name,
|
||||
name: capabilities.modelName,
|
||||
api: capabilities.api,
|
||||
baseUrl: modelBaseUrl,
|
||||
reasoning: capabilities.reasoning,
|
||||
...(capabilities.thinkingLevelMap
|
||||
? { thinkingLevelMap: capabilities.thinkingLevelMap }
|
||||
: {}),
|
||||
params: mergeFoundryCanonicalModelParams(undefined, capabilities.modelName),
|
||||
input: capabilities.input,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: capabilities.contextWindow,
|
||||
maxTokens: capabilities.maxTokens,
|
||||
},
|
||||
capabilities.compat ? { compat: capabilities.compat } : {},
|
||||
);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSelectedDeploymentModelName(params: {
|
||||
modelId: string;
|
||||
modelNameHint?: string | null;
|
||||
deployments?: FoundryDeploymentConfigInput[];
|
||||
}): string | undefined {
|
||||
const selectedDeployment = params.deployments?.find(
|
||||
(deployment) => deployment.name === params.modelId,
|
||||
);
|
||||
return resolveConfiguredModelNameHint(
|
||||
params.modelId,
|
||||
selectedDeployment?.modelName ?? params.modelNameHint,
|
||||
);
|
||||
}
|
||||
|
||||
function isSelectedMaiImageDeployment(params: {
|
||||
modelId: string;
|
||||
modelNameHint?: string | null;
|
||||
deployments?: FoundryDeploymentConfigInput[];
|
||||
}): boolean {
|
||||
return isFoundryMaiImageModel(resolveSelectedDeploymentModelName(params));
|
||||
}
|
||||
|
||||
function buildFoundryImageDefaultPatch(params: {
|
||||
modelId: string;
|
||||
modelNameHint?: string | null;
|
||||
deployments?: FoundryDeploymentConfigInput[];
|
||||
}): FoundryImageDefaultPatch {
|
||||
if (!isSelectedMaiImageDeployment(params)) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
agents: {
|
||||
defaults: {
|
||||
imageGenerationModel: {
|
||||
primary: `${PROVIDER_ID}/${params.modelId}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
const imageDefaultPatch = buildFoundryImageDefaultPatch(params);
|
||||
const defaultModel = isSelectedMaiImageDeployment(params)
|
||||
? undefined
|
||||
: `${PROVIDER_ID}/${params.modelId}`;
|
||||
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,
|
||||
}),
|
||||
...imageDefaultPatch,
|
||||
models: {
|
||||
providers: {
|
||||
[PROVIDER_ID]: buildFoundryProviderConfig(
|
||||
params.endpoint,
|
||||
params.modelId,
|
||||
params.modelNameHint,
|
||||
{
|
||||
api: params.api,
|
||||
deployments: params.deployments,
|
||||
},
|
||||
) as unknown as ModelProviderConfig,
|
||||
},
|
||||
},
|
||||
...buildPluginsAllowPatch(params.currentPluginsAllow),
|
||||
},
|
||||
...(defaultModel ? { defaultModel } : {}),
|
||||
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)
|
||||
);
|
||||
}
|
||||
16
extensions/microsoft-foundry/tsconfig.json
Normal file
16
extensions/microsoft-foundry/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user