Microsoft Foundry: split provider modules and harden runtime auth
Split the provider into focused auth, onboarding, CLI, runtime, and shared modules so the Entra ID flow is easier to review and maintain. Add Foundry-specific tests, preserve Azure CLI error details, move token refresh off the synchronous request path, and dedupe concurrent Entra token refreshes so onboarding and GPT-5 runtime behavior stay reliable.
This commit is contained in:
parent
7696a86e10
commit
b7876c9609
222
extensions/microsoft-foundry/auth.ts
Normal file
222
extensions/microsoft-foundry/auth.ts
Normal file
@ -0,0 +1,222 @@
|
||||
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 { execAz, getLoggedInAccount, isAzCliInstalled } from "./cli.js";
|
||||
import {
|
||||
buildFoundryAuthResult,
|
||||
PROVIDER_ID,
|
||||
resolveConfiguredModelNameHint,
|
||||
} from "./shared.js";
|
||||
import {
|
||||
loginWithTenantFallback,
|
||||
promptApiKeyEndpointAndModel,
|
||||
promptEndpointAndModelManually,
|
||||
promptTenantId,
|
||||
selectFoundryDeployment,
|
||||
selectFoundryResource,
|
||||
listSubscriptions,
|
||||
testFoundryConnection,
|
||||
} from "./onboard.js";
|
||||
|
||||
export const entraIdAuthMethod: ProviderAuthMethod = {
|
||||
id: "entra-id",
|
||||
label: "Entra ID (az login)",
|
||||
hint: "Use your Azure login - no API key needed",
|
||||
kind: "custom",
|
||||
wizard: {
|
||||
choiceId: "microsoft-foundry-entra",
|
||||
choiceLabel: "Microsoft Foundry (Entra ID / az login)",
|
||||
choiceHint: "Use your Azure login - no API key needed",
|
||||
groupId: "microsoft-foundry",
|
||||
groupLabel: "Microsoft Foundry",
|
||||
groupHint: "Entra ID + API key",
|
||||
},
|
||||
run: async (ctx: ProviderAuthContext): Promise<ProviderAuthResult> => {
|
||||
if (!isAzCliInstalled()) {
|
||||
throw new Error(
|
||||
"Azure CLI (az) is not installed.\nInstall it from https://learn.microsoft.com/cli/azure/install-azure-cli",
|
||||
);
|
||||
}
|
||||
|
||||
let account = getLoggedInAccount();
|
||||
let tenantId = account?.tenantId;
|
||||
if (account) {
|
||||
const useExisting = await ctx.prompter.confirm({
|
||||
message: `Already logged in as ${account.user?.name ?? "unknown"} (${account.name}). Use this account?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (!useExisting) {
|
||||
const loginResult = await loginWithTenantFallback(ctx);
|
||||
account = loginResult.account;
|
||||
tenantId = loginResult.tenantId ?? loginResult.account?.tenantId;
|
||||
}
|
||||
} else {
|
||||
await ctx.prompter.note(
|
||||
"You need to log in to Azure. A device code will be displayed - follow the instructions.",
|
||||
"Azure Login",
|
||||
);
|
||||
const loginResult = await loginWithTenantFallback(ctx);
|
||||
account = loginResult.account;
|
||||
tenantId = loginResult.tenantId ?? loginResult.account?.tenantId;
|
||||
}
|
||||
|
||||
const subs = listSubscriptions();
|
||||
let selectedSub = null;
|
||||
if (subs.length === 0) {
|
||||
tenantId ??= await promptTenantId(ctx, {
|
||||
required: true,
|
||||
reason: "No enabled Azure subscriptions were found. Continue with tenant-scoped Entra ID auth instead.",
|
||||
});
|
||||
await ctx.prompter.note(`Continuing with tenant-scoped auth (${tenantId}).`, "Azure Tenant");
|
||||
} else if (subs.length === 1) {
|
||||
selectedSub = subs[0]!;
|
||||
tenantId ??= selectedSub.tenantId;
|
||||
await ctx.prompter.note(
|
||||
`Using subscription: ${selectedSub.name} (${selectedSub.id})`,
|
||||
"Subscription",
|
||||
);
|
||||
} else {
|
||||
const selectedId = await ctx.prompter.select({
|
||||
message: "Select Azure subscription",
|
||||
options: subs.map((sub) => ({
|
||||
value: sub.id,
|
||||
label: `${sub.name} (${sub.id})`,
|
||||
})),
|
||||
});
|
||||
selectedSub = subs.find((sub) => sub.id === selectedId)!;
|
||||
tenantId ??= selectedSub.tenantId;
|
||||
}
|
||||
|
||||
if (selectedSub) {
|
||||
execAz(["account", "set", "--subscription", selectedSub.id]);
|
||||
}
|
||||
|
||||
let endpoint: string;
|
||||
let modelId: string;
|
||||
let modelNameHint: string | 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 selectedDeployment = await selectFoundryDeployment(ctx, selectedResource);
|
||||
endpoint = selectedResource.endpoint;
|
||||
modelId = selectedDeployment.name;
|
||||
modelNameHint = resolveConfiguredModelNameHint(modelId, selectedDeployment.modelName);
|
||||
await ctx.prompter.note(
|
||||
[
|
||||
`Resource: ${selectedResource.accountName}`,
|
||||
`Endpoint: ${endpoint}`,
|
||||
`Deployment: ${modelId}`,
|
||||
selectedDeployment.modelName ? `Model: ${selectedDeployment.modelName}` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
"Microsoft Foundry",
|
||||
);
|
||||
} else {
|
||||
({ endpoint, modelId, modelNameHint } = await promptEndpointAndModelManually(ctx));
|
||||
}
|
||||
} else {
|
||||
({ endpoint, modelId, modelNameHint } = await promptEndpointAndModelManually(ctx));
|
||||
}
|
||||
|
||||
await testFoundryConnection({
|
||||
ctx,
|
||||
endpoint,
|
||||
modelId,
|
||||
modelNameHint,
|
||||
subscriptionId: selectedSub?.id,
|
||||
tenantId,
|
||||
});
|
||||
|
||||
return buildFoundryAuthResult({
|
||||
profileId: `${PROVIDER_ID}:entra`,
|
||||
apiKey: "__entra_id_dynamic__",
|
||||
endpoint,
|
||||
modelId,
|
||||
modelNameHint,
|
||||
authMethod: "entra-id",
|
||||
...(selectedSub?.id ? { subscriptionId: selectedSub.id } : {}),
|
||||
...(selectedSub?.name ? { subscriptionName: selectedSub.name } : {}),
|
||||
...(tenantId ? { tenantId } : {}),
|
||||
notes: [
|
||||
...(selectedSub?.name ? [`Subscription: ${selectedSub.name}`] : []),
|
||||
...(tenantId ? [`Tenant: ${tenantId}`] : []),
|
||||
`Endpoint: ${endpoint}`,
|
||||
`Model: ${modelId}`,
|
||||
"Token is refreshed automatically via az CLI - keep az login active.",
|
||||
],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const apiKeyAuthMethod: ProviderAuthMethod = {
|
||||
id: "api-key",
|
||||
label: "Azure OpenAI API key",
|
||||
hint: "Direct Azure OpenAI API key",
|
||||
kind: "api_key",
|
||||
wizard: {
|
||||
choiceId: "microsoft-foundry-apikey",
|
||||
choiceLabel: "Microsoft Foundry (API key)",
|
||||
groupId: "microsoft-foundry",
|
||||
groupLabel: "Microsoft Foundry",
|
||||
groupHint: "Entra ID + API key",
|
||||
},
|
||||
run: async (ctx) => {
|
||||
const authStore = ensureAuthProfileStore(ctx.agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
const existing = authStore.profiles[`${PROVIDER_ID}:default`];
|
||||
const existingMetadata = existing?.type === "api_key" ? existing.metadata : undefined;
|
||||
let capturedSecretInput: SecretInput | undefined;
|
||||
let capturedCredential = false;
|
||||
let capturedMode: "plaintext" | "ref" | undefined;
|
||||
await ensureApiKeyFromOptionEnvOrPrompt({
|
||||
token: normalizeOptionalSecretInput(ctx.opts?.azureOpenaiApiKey),
|
||||
tokenProvider: PROVIDER_ID,
|
||||
secretInputMode:
|
||||
ctx.allowSecretRefPrompt === false ? (ctx.secretInputMode ?? "plaintext") : ctx.secretInputMode,
|
||||
config: ctx.config,
|
||||
expectedProviders: [PROVIDER_ID],
|
||||
provider: PROVIDER_ID,
|
||||
envLabel: "AZURE_OPENAI_API_KEY",
|
||||
promptMessage: "Enter Azure OpenAI API key",
|
||||
normalize: normalizeApiKeyInput,
|
||||
validate: validateApiKeyInput,
|
||||
prompter: ctx.prompter,
|
||||
setCredential: async (apiKey, mode) => {
|
||||
capturedSecretInput = apiKey;
|
||||
capturedCredential = true;
|
||||
capturedMode = mode;
|
||||
},
|
||||
});
|
||||
if (!capturedCredential) {
|
||||
throw new Error("Missing Azure OpenAI API key.");
|
||||
}
|
||||
const selection = await promptApiKeyEndpointAndModel(ctx);
|
||||
return buildFoundryAuthResult({
|
||||
profileId: `${PROVIDER_ID}:default`,
|
||||
apiKey: capturedSecretInput ?? "",
|
||||
...(capturedMode ? { secretInputMode: capturedMode } : {}),
|
||||
endpoint: selection.endpoint,
|
||||
modelId: selection.modelId,
|
||||
modelNameHint:
|
||||
selection.modelNameHint ?? existingMetadata?.modelName ?? existingMetadata?.modelId,
|
||||
authMethod: "api-key",
|
||||
notes: [`Endpoint: ${selection.endpoint}`, `Model: ${selection.modelId}`],
|
||||
});
|
||||
},
|
||||
};
|
||||
162
extensions/microsoft-foundry/cli.ts
Normal file
162
extensions/microsoft-foundry/cli.ts
Normal file
@ -0,0 +1,162 @@
|
||||
import { execFile, execFileSync, spawn } from "node:child_process";
|
||||
import type { AzAccessToken, AzAccount } from "./shared.js";
|
||||
import { COGNITIVE_SERVICES_RESOURCE } from "./shared.js";
|
||||
|
||||
export function execAz(args: string[]): string {
|
||||
return execFileSync("az", args, {
|
||||
encoding: "utf-8",
|
||||
timeout: 30_000,
|
||||
shell: process.platform === "win32",
|
||||
}).trim();
|
||||
}
|
||||
|
||||
export async function execAzAsync(args: string[]): Promise<string> {
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
execFile(
|
||||
"az",
|
||||
args,
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 30_000,
|
||||
shell: process.platform === "win32",
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
const details = `${String(stderr ?? "").trim()} ${String(stdout ?? "").trim()}`.trim();
|
||||
reject(
|
||||
new Error(
|
||||
details ? `${error.message}: ${details}` : error.message,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve(String(stdout).trim());
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function isAzCliInstalled(): boolean {
|
||||
try {
|
||||
execAz(["version", "--output", "none"]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getLoggedInAccount(): AzAccount | null {
|
||||
try {
|
||||
return JSON.parse(execAz(["account", "show", "--output", "json"])) as AzAccount;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function listSubscriptions(): AzAccount[] {
|
||||
try {
|
||||
const subs = JSON.parse(execAz(["account", "list", "--output", "json", "--all"])) as AzAccount[];
|
||||
return subs.filter((sub) => sub.state === "Enabled");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function getAccessTokenResult(params?: {
|
||||
subscriptionId?: string;
|
||||
tenantId?: string;
|
||||
}): AzAccessToken {
|
||||
const args = [
|
||||
"account",
|
||||
"get-access-token",
|
||||
"--resource",
|
||||
COGNITIVE_SERVICES_RESOURCE,
|
||||
"--output",
|
||||
"json",
|
||||
];
|
||||
if (params?.subscriptionId) {
|
||||
args.push("--subscription", params.subscriptionId);
|
||||
} else if (params?.tenantId) {
|
||||
args.push("--tenant", params.tenantId);
|
||||
}
|
||||
return JSON.parse(execAz(args)) as AzAccessToken;
|
||||
}
|
||||
|
||||
export async function getAccessTokenResultAsync(params?: {
|
||||
subscriptionId?: string;
|
||||
tenantId?: string;
|
||||
}): Promise<AzAccessToken> {
|
||||
const args = [
|
||||
"account",
|
||||
"get-access-token",
|
||||
"--resource",
|
||||
COGNITIVE_SERVICES_RESOURCE,
|
||||
"--output",
|
||||
"json",
|
||||
];
|
||||
if (params?.subscriptionId) {
|
||||
args.push("--subscription", params.subscriptionId);
|
||||
} else if (params?.tenantId) {
|
||||
args.push("--tenant", params.tenantId);
|
||||
}
|
||||
return JSON.parse(await execAzAsync(args)) 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[] = [];
|
||||
const appendBoundedChunk = (chunks: string[], text: string): void => {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
chunks.push(text);
|
||||
let totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
while (totalLength > maxCapturedLoginOutputChars && chunks.length > 0) {
|
||||
const removed = chunks.shift();
|
||||
totalLength -= removed?.length ?? 0;
|
||||
}
|
||||
};
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
const text = String(chunk);
|
||||
appendBoundedChunk(stdoutChunks, text);
|
||||
process.stdout.write(text);
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
const text = String(chunk);
|
||||
appendBoundedChunk(stderrChunks, text);
|
||||
process.stderr.write(text);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const output = [...stderrChunks, ...stdoutChunks].join("").trim();
|
||||
reject(
|
||||
new Error(
|
||||
output ? `az login exited with code ${code}: ${output}` : `az login exited with code ${code}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
child.on("error", reject);
|
||||
});
|
||||
}
|
||||
285
extensions/microsoft-foundry/index.test.ts
Normal file
285
extensions/microsoft-foundry/index.test.ts
Normal file
@ -0,0 +1,285 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js";
|
||||
import plugin from "./index.js";
|
||||
import type { OpenClawConfig } from "../../src/config/types.openclaw.js";
|
||||
|
||||
const execFileMock = vi.hoisted(() => vi.fn());
|
||||
const execFileSyncMock = vi.hoisted(() => vi.fn());
|
||||
const ensureAuthProfileStoreMock = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
profiles: {},
|
||||
})),
|
||||
);
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
return {
|
||||
...actual,
|
||||
execFile: execFileMock,
|
||||
execFileSync: execFileSyncMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-auth", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/provider-auth")>(
|
||||
"openclaw/plugin-sdk/provider-auth",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
ensureAuthProfileStore: ensureAuthProfileStoreMock,
|
||||
};
|
||||
});
|
||||
|
||||
function registerProvider() {
|
||||
const registerProviderMock = vi.fn();
|
||||
plugin.register(
|
||||
createTestPluginApi({
|
||||
id: "microsoft-foundry",
|
||||
name: "Microsoft Foundry",
|
||||
source: "test",
|
||||
config: {},
|
||||
runtime: {} as never,
|
||||
registerProvider: registerProviderMock,
|
||||
}),
|
||||
);
|
||||
expect(registerProviderMock).toHaveBeenCalledTimes(1);
|
||||
return registerProviderMock.mock.calls[0]?.[0];
|
||||
}
|
||||
|
||||
describe("microsoft-foundry plugin", () => {
|
||||
beforeEach(() => {
|
||||
execFileMock.mockReset();
|
||||
execFileSyncMock.mockReset();
|
||||
ensureAuthProfileStoreMock.mockReset();
|
||||
ensureAuthProfileStoreMock.mockReturnValue({ profiles: {} });
|
||||
});
|
||||
|
||||
it("keeps the API key profile bound when multiple auth profiles exist without explicit order", async () => {
|
||||
const provider = registerProvider();
|
||||
const config: OpenClawConfig = {
|
||||
auth: {
|
||||
profiles: {
|
||||
"microsoft-foundry:default": {
|
||||
provider: "microsoft-foundry",
|
||||
mode: "api_key" as const,
|
||||
},
|
||||
"microsoft-foundry:entra": {
|
||||
provider: "microsoft-foundry",
|
||||
mode: "api_key" as const,
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
"microsoft-foundry": {
|
||||
baseUrl: "https://example.services.ai.azure.com/openai/v1",
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
name: "gpt-5.4",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 16_384,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await provider.onModelSelected?.({
|
||||
config,
|
||||
model: "microsoft-foundry/gpt-5.4",
|
||||
prompter: {} as never,
|
||||
agentDir: "/tmp/test-agent",
|
||||
});
|
||||
|
||||
expect(config.auth?.order?.["microsoft-foundry"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the active ordered API key profile when model selection rebinding is needed", async () => {
|
||||
const provider = registerProvider();
|
||||
ensureAuthProfileStoreMock.mockReturnValueOnce({
|
||||
profiles: {
|
||||
"microsoft-foundry:default": {
|
||||
type: "api_key",
|
||||
provider: "microsoft-foundry",
|
||||
metadata: { authMethod: "api-key" },
|
||||
},
|
||||
},
|
||||
});
|
||||
const config: OpenClawConfig = {
|
||||
auth: {
|
||||
profiles: {
|
||||
"microsoft-foundry:default": {
|
||||
provider: "microsoft-foundry",
|
||||
mode: "api_key" as const,
|
||||
},
|
||||
},
|
||||
order: {
|
||||
"microsoft-foundry": ["microsoft-foundry:default"],
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
"microsoft-foundry": {
|
||||
baseUrl: "https://example.services.ai.azure.com/openai/v1",
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
name: "gpt-5.4",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 16_384,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await provider.onModelSelected?.({
|
||||
config,
|
||||
model: "microsoft-foundry/gpt-5.4",
|
||||
prompter: {} as never,
|
||||
agentDir: "/tmp/test-agent",
|
||||
});
|
||||
|
||||
expect(config.auth?.order?.["microsoft-foundry"]).toEqual(["microsoft-foundry:default"]);
|
||||
});
|
||||
|
||||
it("preserves the model-derived base URL for Entra runtime auth refresh", async () => {
|
||||
const provider = registerProvider();
|
||||
execFileMock.mockImplementationOnce(
|
||||
(
|
||||
_file: unknown,
|
||||
_args: unknown,
|
||||
_options: unknown,
|
||||
callback: (error: Error | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
callback(
|
||||
null,
|
||||
JSON.stringify({
|
||||
accessToken: "test-token",
|
||||
expiresOn: new Date(Date.now() + 60_000).toISOString(),
|
||||
}),
|
||||
"",
|
||||
);
|
||||
},
|
||||
);
|
||||
ensureAuthProfileStoreMock.mockReturnValueOnce({
|
||||
profiles: {
|
||||
"microsoft-foundry:entra": {
|
||||
type: "api_key",
|
||||
provider: "microsoft-foundry",
|
||||
metadata: {
|
||||
authMethod: "entra-id",
|
||||
endpoint: "https://example.services.ai.azure.com",
|
||||
modelId: "custom-deployment",
|
||||
modelName: "gpt-5.4",
|
||||
tenantId: "tenant-id",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const prepared = await provider.prepareRuntimeAuth?.({
|
||||
provider: "microsoft-foundry",
|
||||
modelId: "custom-deployment",
|
||||
model: {
|
||||
provider: "microsoft-foundry",
|
||||
id: "custom-deployment",
|
||||
name: "gpt-5.4",
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://example.services.ai.azure.com/openai/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 16_384,
|
||||
},
|
||||
apiKey: "__entra_id_dynamic__",
|
||||
authMode: "api_key",
|
||||
profileId: "microsoft-foundry:entra",
|
||||
env: process.env,
|
||||
agentDir: "/tmp/test-agent",
|
||||
});
|
||||
|
||||
expect(prepared?.baseUrl).toBe("https://example.services.ai.azure.com/openai/v1");
|
||||
});
|
||||
|
||||
it("dedupes concurrent Entra token refreshes for the same profile", async () => {
|
||||
const provider = registerProvider();
|
||||
execFileMock.mockImplementationOnce(
|
||||
(
|
||||
_file: unknown,
|
||||
_args: unknown,
|
||||
_options: unknown,
|
||||
callback: (error: Error | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
setTimeout(() => {
|
||||
callback(
|
||||
null,
|
||||
JSON.stringify({
|
||||
accessToken: "deduped-token",
|
||||
expiresOn: new Date(Date.now() + 60_000).toISOString(),
|
||||
}),
|
||||
"",
|
||||
);
|
||||
}, 10);
|
||||
},
|
||||
);
|
||||
ensureAuthProfileStoreMock.mockReturnValue({
|
||||
profiles: {
|
||||
"microsoft-foundry:entra": {
|
||||
type: "api_key",
|
||||
provider: "microsoft-foundry",
|
||||
metadata: {
|
||||
authMethod: "entra-id",
|
||||
endpoint: "https://example.services.ai.azure.com",
|
||||
modelId: "custom-deployment",
|
||||
modelName: "gpt-5.4",
|
||||
tenantId: "tenant-id",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const runtimeContext = {
|
||||
provider: "microsoft-foundry",
|
||||
modelId: "custom-deployment",
|
||||
model: {
|
||||
provider: "microsoft-foundry",
|
||||
id: "custom-deployment",
|
||||
name: "gpt-5.4",
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://example.services.ai.azure.com/openai/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 16_384,
|
||||
},
|
||||
apiKey: "__entra_id_dynamic__",
|
||||
authMode: "api_key",
|
||||
profileId: "microsoft-foundry:entra",
|
||||
env: process.env,
|
||||
agentDir: "/tmp/test-agent",
|
||||
};
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
provider.prepareRuntimeAuth?.(runtimeContext),
|
||||
provider.prepareRuntimeAuth?.(runtimeContext),
|
||||
]);
|
||||
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1);
|
||||
expect(first?.apiKey).toBe("deduped-token");
|
||||
expect(second?.apiKey).toBe("deduped-token");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
430
extensions/microsoft-foundry/onboard.ts
Normal file
430
extensions/microsoft-foundry/onboard.ts
Normal file
@ -0,0 +1,430 @@
|
||||
import type { ProviderAuthContext } from "openclaw/plugin-sdk/core";
|
||||
import {
|
||||
azLoginDeviceCode,
|
||||
azLoginDeviceCodeWithOptions,
|
||||
execAz,
|
||||
getAccessTokenResult,
|
||||
getLoggedInAccount,
|
||||
listSubscriptions,
|
||||
} from "./cli.js";
|
||||
import {
|
||||
type AzAccount,
|
||||
type AzCognitiveAccount,
|
||||
type AzDeploymentSummary,
|
||||
type FoundryResourceOption,
|
||||
type FoundrySelection,
|
||||
buildFoundryProviderBaseUrl,
|
||||
normalizeEndpointOrigin,
|
||||
resolveConfiguredModelNameHint,
|
||||
resolveFoundryApi,
|
||||
DEFAULT_GPT5_API,
|
||||
} from "./shared.js";
|
||||
|
||||
export { listSubscriptions } from "./cli.js";
|
||||
|
||||
export function listFoundryResources(): FoundryResourceOption[] {
|
||||
try {
|
||||
const accounts = JSON.parse(
|
||||
execAz([
|
||||
"cognitiveservices",
|
||||
"account",
|
||||
"list",
|
||||
"--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 = normalizeEndpointOrigin(account.endpoint);
|
||||
if (!endpoint) {
|
||||
continue;
|
||||
}
|
||||
resources.push({
|
||||
id: account.id,
|
||||
accountName: account.name,
|
||||
kind: "OpenAI",
|
||||
location: account.location,
|
||||
resourceGroup: account.resourceGroup,
|
||||
endpoint,
|
||||
projects: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (account.kind !== "AIServices") {
|
||||
continue;
|
||||
}
|
||||
const endpoint = account.customSubdomain?.trim()
|
||||
? `https://${account.customSubdomain.trim()}.services.ai.azure.com`
|
||||
: undefined;
|
||||
if (!endpoint) {
|
||||
continue;
|
||||
}
|
||||
resources.push({
|
||||
id: account.id,
|
||||
accountName: account.name,
|
||||
kind: "AIServices",
|
||||
location: account.location,
|
||||
resourceGroup: account.resourceGroup,
|
||||
endpoint,
|
||||
projects: Array.isArray(account.projects)
|
||||
? account.projects.filter((project): project is string => typeof project === "string")
|
||||
: [],
|
||||
});
|
||||
}
|
||||
return resources;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function listResourceDeployments(resource: FoundryResourceOption): AzDeploymentSummary[] {
|
||||
try {
|
||||
const deployments = JSON.parse(
|
||||
execAz([
|
||||
"cognitiveservices",
|
||||
"account",
|
||||
"deployment",
|
||||
"list",
|
||||
"-g",
|
||||
resource.resourceGroup,
|
||||
"-n",
|
||||
resource.accountName,
|
||||
"--query",
|
||||
"[].{name:name,modelName:properties.model.name,modelVersion:properties.model.version,state:properties.provisioningState,sku:sku.name}",
|
||||
"--output",
|
||||
"json",
|
||||
]),
|
||||
) as AzDeploymentSummary[];
|
||||
return deployments.filter((deployment) => deployment.state === "Succeeded");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCreateFoundryHint(selectedSub: AzAccount): string {
|
||||
return [
|
||||
`No Azure AI Foundry or Azure OpenAI resources were found in subscription ${selectedSub.name} (${selectedSub.id}).`,
|
||||
"Create one in Azure AI Foundry or Azure Portal, then rerun onboard.",
|
||||
"Azure AI Foundry: https://ai.azure.com",
|
||||
"Azure OpenAI docs: https://learn.microsoft.com/azure/ai-foundry/openai/how-to/create-resource",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function selectFoundryResource(
|
||||
ctx: ProviderAuthContext,
|
||||
selectedSub: AzAccount,
|
||||
): Promise<FoundryResourceOption> {
|
||||
const resources = listFoundryResources();
|
||||
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,
|
||||
): Promise<AzDeploymentSummary> {
|
||||
const deployments = listResourceDeployments(resource);
|
||||
if (deployments.length === 0) {
|
||||
throw new Error(
|
||||
[
|
||||
`No model deployments were found in ${resource.accountName}.`,
|
||||
"Deploy a model in Azure AI Foundry or Azure OpenAI, then rerun onboard.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
if (deployments.length === 1) {
|
||||
const only = deployments[0]!;
|
||||
await ctx.prompter.note(`Using deployment: ${only.name}`, "Model Deployment");
|
||||
return only;
|
||||
}
|
||||
const selectedDeploymentName = await ctx.prompter.select({
|
||||
message: "Select model deployment",
|
||||
options: deployments.map((deployment) => ({
|
||||
value: deployment.name,
|
||||
label: deployment.name,
|
||||
hint: [deployment.modelName, deployment.modelVersion, deployment.sku].filter(Boolean).join(" | "),
|
||||
})),
|
||||
});
|
||||
return deployments.find((deployment) => deployment.name === selectedDeploymentName) ?? deployments[0]!;
|
||||
}
|
||||
|
||||
export async function promptEndpointAndModelManually(
|
||||
ctx: ProviderAuthContext,
|
||||
): Promise<FoundrySelection> {
|
||||
const endpoint = String(
|
||||
await ctx.prompter.text({
|
||||
message: "Microsoft Foundry endpoint URL",
|
||||
placeholder: "https://xxx.openai.azure.com or https://xxx.services.ai.azure.com",
|
||||
validate: (v) => {
|
||||
const val = String(v ?? "").trim();
|
||||
if (!val) return "Endpoint URL is required";
|
||||
try {
|
||||
new URL(val);
|
||||
} catch {
|
||||
return "Invalid URL";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
).trim();
|
||||
const modelId = String(
|
||||
await ctx.prompter.text({
|
||||
message: "Default model/deployment name",
|
||||
placeholder: "gpt-4o",
|
||||
validate: (v) => {
|
||||
const val = String(v ?? "").trim();
|
||||
if (!val) return "Model ID is required";
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
).trim();
|
||||
const modelNameHintInput = String(
|
||||
await ctx.prompter.text({
|
||||
message: "Underlying Azure model family (optional)",
|
||||
initialValue: modelId,
|
||||
placeholder: "gpt-5.4, gpt-4o, etc.",
|
||||
}),
|
||||
).trim();
|
||||
return {
|
||||
endpoint,
|
||||
modelId,
|
||||
modelNameHint: modelNameHintInput || modelId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function promptApiKeyEndpointAndModel(ctx: ProviderAuthContext): Promise<FoundrySelection> {
|
||||
const endpoint = String(
|
||||
await ctx.prompter.text({
|
||||
message: "Microsoft Foundry endpoint URL",
|
||||
placeholder: "https://xxx.openai.azure.com or https://xxx.services.ai.azure.com",
|
||||
initialValue: process.env.AZURE_OPENAI_ENDPOINT,
|
||||
validate: (v) => {
|
||||
const val = String(v ?? "").trim();
|
||||
if (!val) return "Endpoint URL is required";
|
||||
try {
|
||||
new URL(val);
|
||||
} catch {
|
||||
return "Invalid URL";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
).trim();
|
||||
const modelId = String(
|
||||
await ctx.prompter.text({
|
||||
message: "Default model/deployment name",
|
||||
initialValue: "gpt-4o",
|
||||
validate: (v) => {
|
||||
const val = String(v ?? "").trim();
|
||||
if (!val) return "Model ID is required";
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
).trim();
|
||||
const modelNameHintInput = String(
|
||||
await ctx.prompter.text({
|
||||
message: "Underlying Azure model family (optional)",
|
||||
initialValue: modelId,
|
||||
placeholder: "gpt-5.4, gpt-4o, etc.",
|
||||
}),
|
||||
).trim();
|
||||
return {
|
||||
endpoint,
|
||||
modelId,
|
||||
modelNameHint: modelNameHintInput || modelId,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFoundryConnectionTest(params: {
|
||||
endpoint: string;
|
||||
modelId: string;
|
||||
modelNameHint?: string | null;
|
||||
}): { url: string; body: Record<string, unknown> } {
|
||||
const baseUrl = buildFoundryProviderBaseUrl(params.endpoint, params.modelId, params.modelNameHint);
|
||||
if (resolveFoundryApi(params.modelId, params.modelNameHint) === DEFAULT_GPT5_API) {
|
||||
return {
|
||||
url: `${baseUrl}/responses?api-version=2025-04-01-preview`,
|
||||
body: {
|
||||
model: params.modelId,
|
||||
input: "hi",
|
||||
max_output_tokens: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
url: `${baseUrl}/chat/completions?api-version=2024-12-01-preview`,
|
||||
body: {
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function extractTenantSuggestions(rawMessage: string): Array<{ id: string; label?: string }> {
|
||||
const suggestions: Array<{ id: string; label?: string }> = [];
|
||||
const seen = new Set<string>();
|
||||
const regex = /([0-9a-fA-F-]{36})(?:\s+'([^'\r\n]+)')?/g;
|
||||
for (const match of rawMessage.matchAll(regex)) {
|
||||
const id = match[1]?.trim();
|
||||
if (!id || seen.has(id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(id);
|
||||
suggestions.push({
|
||||
id,
|
||||
...(match[2]?.trim() ? { label: match[2].trim() } : {}),
|
||||
});
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
export async function promptTenantId(
|
||||
ctx: ProviderAuthContext,
|
||||
params?: {
|
||||
suggestions?: Array<{ id: string; label?: string }>;
|
||||
required?: boolean;
|
||||
reason?: string;
|
||||
},
|
||||
): Promise<string | undefined> {
|
||||
const suggestionLines =
|
||||
params?.suggestions && params.suggestions.length > 0
|
||||
? params.suggestions.map((entry) => `- ${entry.id}${entry.label ? ` (${entry.label})` : ""}`)
|
||||
: [];
|
||||
if (params?.reason || suggestionLines.length > 0) {
|
||||
await ctx.prompter.note(
|
||||
[
|
||||
params?.reason,
|
||||
suggestionLines.length > 0 ? "Suggested tenants:" : undefined,
|
||||
...suggestionLines,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
"Azure Tenant",
|
||||
);
|
||||
}
|
||||
const tenantId = String(
|
||||
await ctx.prompter.text({
|
||||
message: params?.required ? "Azure tenant ID" : "Azure tenant ID (optional)",
|
||||
placeholder: params?.suggestions?.[0]?.id ?? "00000000-0000-0000-0000-000000000000",
|
||||
validate: (value) => {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return params?.required ? "Tenant ID is required" : undefined;
|
||||
}
|
||||
return /^[0-9a-fA-F-]{36}$/.test(trimmed) ? undefined : "Enter a valid tenant ID";
|
||||
},
|
||||
}),
|
||||
).trim();
|
||||
return tenantId || undefined;
|
||||
}
|
||||
|
||||
export async function loginWithTenantFallback(
|
||||
ctx: ProviderAuthContext,
|
||||
): Promise<{ account: AzAccount | null; tenantId?: string }> {
|
||||
try {
|
||||
await azLoginDeviceCode();
|
||||
return { account: getLoggedInAccount() };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const isAzureTenantError =
|
||||
/AADSTS\d+/i.test(message) ||
|
||||
/no subscriptions found/i.test(message) ||
|
||||
/Please provide a valid tenant/i.test(message) ||
|
||||
/tenant.*not found/i.test(message);
|
||||
if (!isAzureTenantError) {
|
||||
throw error;
|
||||
}
|
||||
const tenantId = await promptTenantId(ctx, {
|
||||
suggestions: extractTenantSuggestions(message),
|
||||
required: true,
|
||||
reason:
|
||||
"Azure login needs a tenant-scoped retry. This often happens when your tenant requires MFA or your account has no Azure subscriptions.",
|
||||
});
|
||||
await azLoginDeviceCodeWithOptions({
|
||||
tenantId,
|
||||
allowNoSubscriptions: true,
|
||||
});
|
||||
return {
|
||||
account: getLoggedInAccount(),
|
||||
tenantId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function testFoundryConnection(params: {
|
||||
ctx: ProviderAuthContext;
|
||||
endpoint: string;
|
||||
modelId: string;
|
||||
modelNameHint?: string;
|
||||
subscriptionId?: string;
|
||||
tenantId?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const { accessToken } = getAccessTokenResult({
|
||||
subscriptionId: params.subscriptionId,
|
||||
tenantId: params.tenantId,
|
||||
});
|
||||
const testRequest = buildFoundryConnectionTest({
|
||||
endpoint: params.endpoint,
|
||||
modelId: params.modelId,
|
||||
modelNameHint: params.modelNameHint,
|
||||
});
|
||||
const res = await fetch(testRequest.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(testRequest.body),
|
||||
});
|
||||
if (res.status === 400) {
|
||||
const body = await res.text().catch(() => "");
|
||||
await params.ctx.prompter.note(
|
||||
`Endpoint is reachable but returned 400 Bad Request - check your deployment name and API version.\n${body.slice(0, 200)}`,
|
||||
"Connection Test",
|
||||
);
|
||||
} else if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
await params.ctx.prompter.note(
|
||||
`Warning: test request returned ${res.status}. ${body.slice(0, 200)}\nProceeding anyway - you can fix the endpoint later.`,
|
||||
"Connection Test",
|
||||
);
|
||||
} else {
|
||||
await params.ctx.prompter.note("Connection test successful!", "✓");
|
||||
}
|
||||
} catch (err) {
|
||||
await params.ctx.prompter.note(
|
||||
`Warning: connection test failed: ${String(err)}\nProceeding anyway.`,
|
||||
"Connection Test",
|
||||
);
|
||||
}
|
||||
}
|
||||
81
extensions/microsoft-foundry/provider.ts
Normal file
81
extensions/microsoft-foundry/provider.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import type { ProviderNormalizeResolvedModelContext } from "openclaw/plugin-sdk/core";
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-models";
|
||||
import type { ProviderModelSelectedContext } from "../../src/plugins/types.js";
|
||||
import type { ModelProviderConfig } from "../../src/config/types.models.js";
|
||||
import { apiKeyAuthMethod, entraIdAuthMethod } from "./auth.js";
|
||||
import { prepareFoundryRuntimeAuth } from "./runtime.js";
|
||||
import {
|
||||
PROVIDER_ID,
|
||||
applyFoundryProfileBinding,
|
||||
applyFoundryProviderConfig,
|
||||
buildFoundryModelCompat,
|
||||
buildFoundryProviderBaseUrl,
|
||||
extractFoundryEndpoint,
|
||||
normalizeFoundryEndpoint,
|
||||
resolveConfiguredModelNameHint,
|
||||
resolveFoundryApi,
|
||||
resolveFoundryTargetProfileId,
|
||||
} from "./shared.js";
|
||||
|
||||
export function buildMicrosoftFoundryProvider(): ProviderPlugin {
|
||||
return {
|
||||
id: PROVIDER_ID,
|
||||
label: "Microsoft Foundry",
|
||||
docsPath: "/providers/azure",
|
||||
envVars: ["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT"],
|
||||
auth: [entraIdAuthMethod, apiKeyAuthMethod],
|
||||
capabilities: {
|
||||
providerFamily: "openai" as const,
|
||||
},
|
||||
onModelSelected: async (ctx: ProviderModelSelectedContext) => {
|
||||
const providerConfig = ctx.config.models?.providers?.[PROVIDER_ID];
|
||||
if (!providerConfig || !ctx.model.startsWith(`${PROVIDER_ID}/`)) {
|
||||
return;
|
||||
}
|
||||
const selectedModelId = ctx.model.slice(`${PROVIDER_ID}/`.length);
|
||||
const existingModel = providerConfig.models.find((model: { id: string }) => model.id === selectedModelId);
|
||||
const selectedModelNameHint = resolveConfiguredModelNameHint(selectedModelId, existingModel?.name);
|
||||
const selectedModelCompat = buildFoundryModelCompat(selectedModelId, selectedModelNameHint);
|
||||
const providerEndpoint = normalizeFoundryEndpoint(providerConfig.baseUrl ?? "");
|
||||
const nextProviderConfig: ModelProviderConfig = {
|
||||
...providerConfig,
|
||||
baseUrl: buildFoundryProviderBaseUrl(providerEndpoint, selectedModelId, selectedModelNameHint),
|
||||
api: resolveFoundryApi(selectedModelId, selectedModelNameHint),
|
||||
models: [
|
||||
{
|
||||
...(existingModel ?? {
|
||||
id: selectedModelId,
|
||||
name: selectedModelId,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 16_384,
|
||||
}),
|
||||
...(selectedModelCompat ? { compat: selectedModelCompat } : {}),
|
||||
},
|
||||
],
|
||||
};
|
||||
const targetProfileId = resolveFoundryTargetProfileId(ctx.config, ctx.agentDir);
|
||||
if (targetProfileId) {
|
||||
applyFoundryProfileBinding(ctx.config, targetProfileId);
|
||||
}
|
||||
applyFoundryProviderConfig(ctx.config, nextProviderConfig);
|
||||
},
|
||||
normalizeResolvedModel: ({ modelId, model }: ProviderNormalizeResolvedModelContext) => {
|
||||
const endpoint = extractFoundryEndpoint(String(model.baseUrl ?? ""));
|
||||
if (!endpoint) {
|
||||
return model;
|
||||
}
|
||||
const modelNameHint = resolveConfiguredModelNameHint(modelId, model.name);
|
||||
const compat = buildFoundryModelCompat(modelId, modelNameHint);
|
||||
return {
|
||||
...model,
|
||||
api: resolveFoundryApi(modelId, modelNameHint),
|
||||
baseUrl: buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint),
|
||||
...(compat ? { compat } : {}),
|
||||
};
|
||||
},
|
||||
prepareRuntimeAuth: prepareFoundryRuntimeAuth,
|
||||
};
|
||||
}
|
||||
85
extensions/microsoft-foundry/runtime.ts
Normal file
85
extensions/microsoft-foundry/runtime.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { ensureAuthProfileStore } from "openclaw/plugin-sdk/provider-auth";
|
||||
import type { ProviderPrepareRuntimeAuthContext } from "openclaw/plugin-sdk/core";
|
||||
import { getAccessTokenResultAsync } from "./cli.js";
|
||||
import {
|
||||
type CachedTokenEntry,
|
||||
TOKEN_REFRESH_MARGIN_MS,
|
||||
buildFoundryProviderBaseUrl,
|
||||
extractFoundryEndpoint,
|
||||
getFoundryTokenCacheKey,
|
||||
resolveConfiguredModelNameHint,
|
||||
} from "./shared-runtime.js";
|
||||
|
||||
const cachedTokens = new Map<string, CachedTokenEntry>();
|
||||
const refreshPromises = new Map<string, Promise<{ apiKey: string; expiresAt: number }>>();
|
||||
|
||||
async function refreshEntraToken(params?: {
|
||||
subscriptionId?: string;
|
||||
tenantId?: string;
|
||||
}): Promise<{ apiKey: string; expiresAt: number }> {
|
||||
const result = await getAccessTokenResultAsync(params);
|
||||
const rawExpiry = result.expiresOn ? new Date(result.expiresOn).getTime() : Number.NaN;
|
||||
const expiresAt = Number.isFinite(rawExpiry) ? rawExpiry : Date.now() + 55 * 60 * 1000;
|
||||
cachedTokens.set(getFoundryTokenCacheKey(params), {
|
||||
token: result.accessToken,
|
||||
expiresAt,
|
||||
});
|
||||
return { apiKey: result.accessToken, expiresAt };
|
||||
}
|
||||
|
||||
export async function prepareFoundryRuntimeAuth(ctx: ProviderPrepareRuntimeAuthContext) {
|
||||
if (ctx.apiKey !== "__entra_id_dynamic__") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const authStore = ensureAuthProfileStore(ctx.agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
const credential = ctx.profileId ? authStore.profiles[ctx.profileId] : undefined;
|
||||
const metadata = credential?.type === "api_key" ? credential.metadata : undefined;
|
||||
const modelId =
|
||||
typeof ctx.modelId === "string" && ctx.modelId.trim().length > 0
|
||||
? ctx.modelId.trim()
|
||||
: typeof metadata?.modelId === "string" && metadata.modelId.trim().length > 0
|
||||
? metadata.modelId.trim()
|
||||
: ctx.modelId;
|
||||
const activeModelNameHint = ctx.modelId === metadata?.modelId ? metadata?.modelName : undefined;
|
||||
const modelNameHint = resolveConfiguredModelNameHint(modelId, ctx.model.name ?? activeModelNameHint);
|
||||
const endpoint =
|
||||
typeof metadata?.endpoint === "string" && metadata.endpoint.trim().length > 0
|
||||
? metadata.endpoint.trim()
|
||||
: extractFoundryEndpoint(ctx.model.baseUrl ?? "");
|
||||
const baseUrl = endpoint ? buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint) : undefined;
|
||||
const cacheKey = getFoundryTokenCacheKey({
|
||||
subscriptionId: metadata?.subscriptionId,
|
||||
tenantId: metadata?.tenantId,
|
||||
});
|
||||
const cachedToken = cachedTokens.get(cacheKey);
|
||||
if (cachedToken && cachedToken.expiresAt > Date.now() + TOKEN_REFRESH_MARGIN_MS) {
|
||||
return {
|
||||
apiKey: cachedToken.token,
|
||||
expiresAt: cachedToken.expiresAt,
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
};
|
||||
}
|
||||
let refreshPromise = refreshPromises.get(cacheKey);
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = refreshEntraToken({
|
||||
subscriptionId: metadata?.subscriptionId,
|
||||
tenantId: metadata?.tenantId,
|
||||
}).finally(() => {
|
||||
refreshPromises.delete(cacheKey);
|
||||
});
|
||||
refreshPromises.set(cacheKey, refreshPromise);
|
||||
}
|
||||
const token = await refreshPromise;
|
||||
return {
|
||||
...token,
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
};
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to refresh Azure Entra ID token via az CLI: ${String(err)}\nMake sure you are logged in: az login --use-device-code`,
|
||||
);
|
||||
}
|
||||
}
|
||||
14
extensions/microsoft-foundry/shared-runtime.ts
Normal file
14
extensions/microsoft-foundry/shared-runtime.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export {
|
||||
TOKEN_REFRESH_MARGIN_MS,
|
||||
buildFoundryProviderBaseUrl,
|
||||
extractFoundryEndpoint,
|
||||
resolveConfiguredModelNameHint,
|
||||
type CachedTokenEntry,
|
||||
} from "./shared.js";
|
||||
|
||||
export function getFoundryTokenCacheKey(params?: {
|
||||
subscriptionId?: string;
|
||||
tenantId?: string;
|
||||
}): string {
|
||||
return `${params?.subscriptionId ?? ""}:${params?.tenantId ?? ""}`;
|
||||
}
|
||||
312
extensions/microsoft-foundry/shared.ts
Normal file
312
extensions/microsoft-foundry/shared.ts
Normal file
@ -0,0 +1,312 @@
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
buildApiKeyCredential,
|
||||
ensureAuthProfileStore,
|
||||
type ProviderAuthResult,
|
||||
type SecretInput,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
import type { ModelCompatConfig, ModelProviderConfig } from "../../src/config/types.models.js";
|
||||
import type { ProviderModelSelectedContext } from "../../src/plugins/types.js";
|
||||
|
||||
export const PROVIDER_ID = "microsoft-foundry";
|
||||
export const DEFAULT_API = "openai-completions";
|
||||
export const DEFAULT_GPT5_API = "openai-responses";
|
||||
export const COGNITIVE_SERVICES_RESOURCE = "https://cognitiveservices.azure.com";
|
||||
export const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;
|
||||
|
||||
export interface AzAccount {
|
||||
name: string;
|
||||
id: string;
|
||||
tenantId?: string;
|
||||
user?: { name?: string };
|
||||
state?: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export interface AzAccessToken {
|
||||
accessToken: string;
|
||||
expiresOn?: string;
|
||||
}
|
||||
|
||||
export interface AzCognitiveAccount {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
location?: string;
|
||||
resourceGroup?: string;
|
||||
endpoint?: string | null;
|
||||
customSubdomain?: string | null;
|
||||
projects?: string[] | null;
|
||||
}
|
||||
|
||||
export interface FoundryResourceOption {
|
||||
id: string;
|
||||
accountName: string;
|
||||
kind: "AIServices" | "OpenAI";
|
||||
location?: string;
|
||||
resourceGroup: string;
|
||||
endpoint: string;
|
||||
projects: string[];
|
||||
}
|
||||
|
||||
export interface AzDeploymentSummary {
|
||||
name: string;
|
||||
modelName?: string;
|
||||
modelVersion?: string;
|
||||
state?: string;
|
||||
sku?: string;
|
||||
}
|
||||
|
||||
export type FoundrySelection = {
|
||||
endpoint: string;
|
||||
modelId: string;
|
||||
modelNameHint?: string;
|
||||
};
|
||||
|
||||
export type CachedTokenEntry = {
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
export type FoundryProviderApi = typeof DEFAULT_API | typeof DEFAULT_GPT5_API;
|
||||
|
||||
export function isGpt5FamilyName(value?: string | null): boolean {
|
||||
return typeof value === "string" && /^gpt-5(?:$|[-.])/i.test(value.trim());
|
||||
}
|
||||
|
||||
export function isGpt5FamilyDeployment(modelId: string, modelNameHint?: string | null): boolean {
|
||||
return isGpt5FamilyName(modelId) || isGpt5FamilyName(modelNameHint);
|
||||
}
|
||||
|
||||
export function normalizeFoundryEndpoint(endpoint: string): string {
|
||||
const trimmed = endpoint.trim().replace(/\/+$/, "");
|
||||
return trimmed.replace(/\/openai(?:\/v1|\/deployments\/[^/]+)?$/i, "");
|
||||
}
|
||||
|
||||
export function buildAzureBaseUrl(endpoint: string, modelId: string): string {
|
||||
const base = normalizeFoundryEndpoint(endpoint);
|
||||
if (base.includes("/openai/deployments/")) return base;
|
||||
return `${base}/openai/deployments/${modelId}`;
|
||||
}
|
||||
|
||||
export function buildFoundryResponsesBaseUrl(endpoint: string): string {
|
||||
const base = normalizeFoundryEndpoint(endpoint);
|
||||
return base.endsWith("/openai/v1") ? base : `${base}/openai/v1`;
|
||||
}
|
||||
|
||||
export function resolveFoundryApi(
|
||||
modelId: string,
|
||||
modelNameHint?: string | null,
|
||||
): FoundryProviderApi {
|
||||
return isGpt5FamilyDeployment(modelId, modelNameHint) ? DEFAULT_GPT5_API : DEFAULT_API;
|
||||
}
|
||||
|
||||
export function buildFoundryProviderBaseUrl(
|
||||
endpoint: string,
|
||||
modelId: string,
|
||||
modelNameHint?: string | null,
|
||||
): string {
|
||||
return resolveFoundryApi(modelId, modelNameHint) === DEFAULT_GPT5_API
|
||||
? buildFoundryResponsesBaseUrl(endpoint)
|
||||
: buildAzureBaseUrl(endpoint, modelId);
|
||||
}
|
||||
|
||||
export function extractFoundryEndpoint(baseUrl: string): string | undefined {
|
||||
try {
|
||||
return new URL(baseUrl).origin;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildFoundryModelCompat(
|
||||
modelId: string,
|
||||
modelNameHint?: string | null,
|
||||
): ModelCompatConfig | undefined {
|
||||
if (!isGpt5FamilyDeployment(modelId, modelNameHint)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
maxTokensField: "max_completion_tokens",
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveConfiguredModelNameHint(
|
||||
modelId: string,
|
||||
modelNameHint?: string | null,
|
||||
): string | undefined {
|
||||
const trimmedName = typeof modelNameHint === "string" ? modelNameHint.trim() : "";
|
||||
if (trimmedName) {
|
||||
return trimmedName;
|
||||
}
|
||||
const trimmedId = modelId.trim();
|
||||
return trimmedId ? trimmedId : undefined;
|
||||
}
|
||||
|
||||
export function buildFoundryProviderConfig(
|
||||
endpoint: string,
|
||||
modelId: string,
|
||||
modelNameHint?: string | null,
|
||||
): ModelProviderConfig {
|
||||
const compat = buildFoundryModelCompat(modelId, modelNameHint);
|
||||
return {
|
||||
baseUrl: buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint),
|
||||
api: resolveFoundryApi(modelId, modelNameHint),
|
||||
models: [
|
||||
{
|
||||
id: modelId,
|
||||
name:
|
||||
typeof modelNameHint === "string" && modelNameHint.trim().length > 0
|
||||
? modelNameHint.trim()
|
||||
: modelId,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 16_384,
|
||||
...(compat ? { compat } : {}),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeEndpointOrigin(rawUrl: string | null | undefined): string | undefined {
|
||||
if (!rawUrl) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return new URL(rawUrl).origin;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function buildFoundryCredentialMetadata(params: {
|
||||
authMethod: "api-key" | "entra-id";
|
||||
endpoint: string;
|
||||
modelId: string;
|
||||
modelNameHint?: string | null;
|
||||
subscriptionId?: string;
|
||||
subscriptionName?: string;
|
||||
tenantId?: string;
|
||||
}): Record<string, string> {
|
||||
const metadata: Record<string, string> = {
|
||||
authMethod: params.authMethod,
|
||||
endpoint: params.endpoint,
|
||||
modelId: params.modelId,
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
export function buildFoundryAuthResult(params: {
|
||||
profileId: string;
|
||||
apiKey: SecretInput;
|
||||
secretInputMode?: "plaintext" | "ref";
|
||||
endpoint: string;
|
||||
modelId: string;
|
||||
modelNameHint?: string | null;
|
||||
authMethod: "api-key" | "entra-id";
|
||||
subscriptionId?: string;
|
||||
subscriptionName?: string;
|
||||
tenantId?: string;
|
||||
notes?: string[];
|
||||
}): ProviderAuthResult {
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId: params.profileId,
|
||||
credential: buildApiKeyCredential(
|
||||
PROVIDER_ID,
|
||||
params.apiKey,
|
||||
buildFoundryCredentialMetadata({
|
||||
authMethod: params.authMethod,
|
||||
endpoint: params.endpoint,
|
||||
modelId: params.modelId,
|
||||
modelNameHint: params.modelNameHint,
|
||||
subscriptionId: params.subscriptionId,
|
||||
subscriptionName: params.subscriptionName,
|
||||
tenantId: params.tenantId,
|
||||
}),
|
||||
params.secretInputMode ? { secretInputMode: params.secretInputMode } : undefined,
|
||||
),
|
||||
},
|
||||
],
|
||||
configPatch: {
|
||||
models: {
|
||||
providers: {
|
||||
[PROVIDER_ID]: buildFoundryProviderConfig(
|
||||
params.endpoint,
|
||||
params.modelId,
|
||||
params.modelNameHint,
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultModel: `${PROVIDER_ID}/${params.modelId}`,
|
||||
notes: params.notes,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyFoundryProfileBinding(
|
||||
config: ProviderModelSelectedContext["config"],
|
||||
profileId: string,
|
||||
): void {
|
||||
applyAuthProfileConfig(config, {
|
||||
profileId,
|
||||
provider: PROVIDER_ID,
|
||||
mode: "api_key",
|
||||
});
|
||||
}
|
||||
|
||||
export function applyFoundryProviderConfig(
|
||||
config: ProviderModelSelectedContext["config"],
|
||||
providerConfig: ModelProviderConfig,
|
||||
): void {
|
||||
config.models ??= {};
|
||||
config.models.providers ??= {};
|
||||
config.models.providers[PROVIDER_ID] = providerConfig;
|
||||
}
|
||||
|
||||
export function resolveFoundryTargetProfileId(
|
||||
config: ProviderModelSelectedContext["config"],
|
||||
agentDir?: string,
|
||||
): 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;
|
||||
}
|
||||
const configuredProfileId =
|
||||
config.auth?.order?.[PROVIDER_ID]?.find((profileId) => profileId.trim().length > 0) ??
|
||||
(configuredProfileEntries.length === 1 ? configuredProfileEntries[0]?.[0] : undefined);
|
||||
if (!configuredProfileId || !agentDir) {
|
||||
return configuredProfileId;
|
||||
}
|
||||
const authStore = ensureAuthProfileStore(agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
const credential = authStore.profiles[configuredProfileId];
|
||||
const authMethod = credential?.type === "api_key" ? credential.metadata?.authMethod : undefined;
|
||||
if (authMethod === "api-key") {
|
||||
return `${PROVIDER_ID}:default`;
|
||||
}
|
||||
if (authMethod === "entra-id") {
|
||||
return `${PROVIDER_ID}:entra`;
|
||||
}
|
||||
return configuredProfileId;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user