Microsoft Foundry: streamline onboarding and GPT-5 runtime

Handle Windows az device-code login, tenant fallback, and Azure resource/deployment discovery so onboard can reuse existing Microsoft Foundry setups without manual endpoint entry. Normalize GPT-5 deployments onto the Foundry responses base URL at selection and runtime auth time, and fall back to hard links for Windows plugin staging so local builds and chats work end-to-end.
This commit is contained in:
haxudev 2026-03-19 14:25:13 +08:00
parent 0c9c874241
commit ec89a8efee
3 changed files with 577 additions and 78 deletions

View File

@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai
- Control UI/chat: add an expand-to-canvas button on assistant chat bubbles and in-app session navigation from Sessions and Cron views. Thanks @BunsDev.
- Plugins/context engines: expose `delegateCompactionToRuntime(...)` on the public plugin SDK, refactor the legacy engine to use the shared helper, and clarify `ownsCompaction` delegation semantics for non-owning engines. (#49061) Thanks @jalehman.
- Plugins/MiniMax: add MiniMax-M2.7 and MiniMax-M2.7-highspeed models and update the default model from M2.5 to M2.7. (#49691) Thanks @liyuan97.
- Onboarding/Microsoft Foundry: improve Entra ID setup with Windows-safe `az login` device-code launch, tenant-scoped fallback when Azure returns no default subscriptions, and automatic Azure AI Foundry/Azure OpenAI resource plus deployment discovery so users can pick existing endpoints instead of typing them manually. If no compatible resource or deployment exists, onboard now explains that it must be created first.
### Fixes

View File

@ -1,14 +1,21 @@
import { execSync, spawn } from "node:child_process";
import { definePluginEntry, type ProviderAuthContext } from "openclaw/plugin-sdk/core";
import {
definePluginEntry,
type ProviderAuthContext,
} from "openclaw/plugin-sdk/core";
import {
applyAuthProfileConfig,
createProviderApiKeyAuthMethod,
ensureAuthProfileStore,
upsertAuthProfile,
type ProviderAuthResult,
} from "openclaw/plugin-sdk/provider-auth";
import type { ModelCompatConfig, ModelProviderConfig } from "../../src/config/types.models.js";
import type { ProviderModelSelectedContext } from "../../src/plugins/types.js";
const PROVIDER_ID = "microsoft-foundry";
const DEFAULT_API = "openai-completions";
const DEFAULT_GPT5_API = "openai-responses";
const COGNITIVE_SERVICES_RESOURCE = "https://cognitiveservices.azure.com";
const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000; // refresh 5 min before expiry
@ -33,6 +40,7 @@ function isAzCliInstalled(): boolean {
interface AzAccount {
name: string;
id: string;
tenantId?: string;
user?: { name?: string };
state?: string;
isDefault?: boolean;
@ -48,9 +56,13 @@ function getLoggedInAccount(): AzAccount | null {
}
function listSubscriptions(): AzAccount[] {
const raw = execCmd("az account list --output json --all");
const subs = JSON.parse(raw) as AzAccount[];
return subs.filter((s) => s.state === "Enabled");
try {
const raw = execCmd("az account list --output json --all");
const subs = JSON.parse(raw) as AzAccount[];
return subs.filter((s) => s.state === "Enabled");
} catch {
return [];
}
}
interface AzAccessToken {
@ -58,27 +70,327 @@ interface AzAccessToken {
expiresOn?: string;
}
function getAccessTokenResult(): AzAccessToken {
interface AzCognitiveAccount {
id: string;
name: string;
kind: string;
location?: string;
resourceGroup?: string;
endpoint?: string | null;
customSubdomain?: string | null;
projects?: string[] | null;
}
interface FoundryResourceOption {
id: string;
accountName: string;
kind: "AIServices" | "OpenAI";
location?: string;
resourceGroup: string;
endpoint: string;
projects: string[];
}
interface AzDeploymentSummary {
name: string;
modelName?: string;
modelVersion?: string;
state?: string;
sku?: string;
}
type FoundryProviderApi = typeof DEFAULT_API | typeof DEFAULT_GPT5_API;
function quoteShellArg(value: string): string {
return JSON.stringify(value);
}
function getAccessTokenResult(params?: {
subscriptionId?: string;
tenantId?: string;
}): AzAccessToken {
const targetArg = params?.subscriptionId
? ` --subscription ${JSON.stringify(params.subscriptionId)}`
: params?.tenantId
? ` --tenant ${JSON.stringify(params.tenantId)}`
: "";
const raw = execCmd(
`az account get-access-token --resource ${COGNITIVE_SERVICES_RESOURCE} --output json`,
`az account get-access-token --resource ${COGNITIVE_SERVICES_RESOURCE} --output json${targetArg}`,
);
return JSON.parse(raw) as AzAccessToken;
}
function buildAzureBaseUrl(endpoint: string, modelId: string): string {
const base = endpoint.replace(/\/+$/, "");
const base = normalizeFoundryEndpoint(endpoint);
if (base.includes("/openai/deployments/")) return base;
return `${base}/openai/deployments/${modelId}`;
}
function buildFoundryResponsesBaseUrl(endpoint: string): string {
const base = normalizeFoundryEndpoint(endpoint);
return base.endsWith("/openai/v1") ? base : `${base}/openai/v1`;
}
function normalizeFoundryEndpoint(endpoint: string): string {
const trimmed = endpoint.trim().replace(/\/+$/, "");
return trimmed.replace(/\/openai(?:\/v1|\/deployments\/[^/]+)?$/i, "");
}
function buildFoundryProviderBaseUrl(endpoint: string, modelId: string): string {
return resolveFoundryApi(modelId) === DEFAULT_GPT5_API
? buildFoundryResponsesBaseUrl(endpoint)
: buildAzureBaseUrl(endpoint, modelId);
}
function extractFoundryEndpoint(baseUrl: string): string | undefined {
try {
const url = new URL(baseUrl);
return url.origin;
} catch {
return undefined;
}
}
function isGpt5FamilyDeployment(modelId: string): boolean {
return /^gpt-5(?:$|[-.])/i.test(modelId.trim());
}
function resolveFoundryApi(modelId: string): FoundryProviderApi {
return isGpt5FamilyDeployment(modelId) ? DEFAULT_GPT5_API : DEFAULT_API;
}
function buildFoundryModelCompat(modelId: string): ModelCompatConfig | undefined {
if (!isGpt5FamilyDeployment(modelId)) {
return undefined;
}
return {
maxTokensField: "max_completion_tokens",
};
}
function buildFoundryProviderConfig(endpoint: string, modelId: string): ModelProviderConfig {
const compat = buildFoundryModelCompat(modelId);
return {
baseUrl: buildFoundryProviderBaseUrl(endpoint, modelId),
api: resolveFoundryApi(modelId),
models: [
{
id: modelId,
name: modelId,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 16_384,
...(compat ? { compat } : {}),
},
],
};
}
function normalizeEndpointOrigin(rawUrl: string | null | undefined): string | undefined {
if (!rawUrl) {
return undefined;
}
try {
return new URL(rawUrl).origin;
} catch {
return undefined;
}
}
function listFoundryResources(): FoundryResourceOption[] {
try {
const raw = execCmd(
'az 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',
);
const accounts = JSON.parse(raw) 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 [];
}
}
function listResourceDeployments(resource: FoundryResourceOption): AzDeploymentSummary[] {
try {
const raw = execCmd(
`az cognitiveservices account deployment list -g ${quoteShellArg(resource.resourceGroup)} -n ${quoteShellArg(resource.accountName)} --query "[].{name:name,modelName:properties.model.name,modelVersion:properties.model.version,state:properties.provisioningState,sku:sku.name}" --output json`,
);
const deployments = JSON.parse(raw) as AzDeploymentSummary[];
return deployments.filter((deployment) => deployment.state === "Succeeded");
} catch {
return [];
}
}
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]!;
}
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]!;
}
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");
}
async function promptEndpointAndModelManually(ctx: ProviderAuthContext): Promise<{
endpoint: string;
modelId: string;
}> {
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();
return { endpoint, modelId };
}
/**
* Interactive az login using device-code flow.
* Spawns az login so terminal output (device code URL) is visible to user.
*/
async function azLoginDeviceCode(): Promise<void> {
return azLoginDeviceCodeWithOptions({});
}
async function azLoginDeviceCodeWithOptions(params: {
tenantId?: string;
allowNoSubscriptions?: boolean;
}): Promise<void> {
return new Promise<void>((resolve, reject) => {
const child = spawn("az", ["login", "--use-device-code"], {
const args = [
"login",
"--use-device-code",
...(params.tenantId ? ["--tenant", params.tenantId] : []),
...(params.allowNoSubscriptions ? ["--allow-no-subscriptions"] : []),
];
const child = spawn("az", args, {
stdio: "inherit",
shell: process.platform === "win32",
});
child.on("close", (code) => {
if (code === 0) resolve();
@ -88,6 +400,92 @@ async function azLoginDeviceCode(): Promise<void> {
});
}
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;
}
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;
}
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 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,
};
}
}
// ---------------------------------------------------------------------------
// Entra ID auth method
// ---------------------------------------------------------------------------
@ -116,35 +514,43 @@ const entraIdAuthMethod = {
// 2. Check login status
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) {
await azLoginDeviceCode();
account = getLoggedInAccount();
if (!account) throw new Error("Failed to get account after login.");
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",
);
await azLoginDeviceCode();
account = getLoggedInAccount();
if (!account) throw new Error("Failed to get account after login.");
const loginResult = await loginWithTenantFallback(ctx);
account = loginResult.account;
tenantId = loginResult.tenantId ?? loginResult.account?.tenantId;
}
// 3. List and select subscription
const subs = listSubscriptions();
let selectedSub: AzAccount | null = null;
if (subs.length === 0) {
throw new Error("No enabled Azure subscriptions found. Please check your Azure account.");
}
let selectedSub: AzAccount;
if (subs.length === 1) {
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",
@ -159,45 +565,48 @@ const entraIdAuthMethod = {
options: choices,
});
selectedSub = subs.find((s) => s.id === selectedId)!;
tenantId ??= selectedSub.tenantId;
}
// 4. Set subscription
execCmd(`az account set --subscription "${selectedSub.id}"`);
if (selectedSub) {
execCmd(`az account set --subscription "${selectedSub.id}"`);
}
// 5. Ask endpoint URL
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();
// 6. Ask model ID
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();
// 5. Discover resource + deployment when possible
let endpoint: string;
let modelId: string;
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;
await ctx.prompter.note(
[
`Resource: ${selectedResource.accountName}`,
`Endpoint: ${endpoint}`,
`Deployment: ${modelId}`,
].join("\n"),
"Microsoft Foundry",
);
} else {
({ endpoint, modelId } = await promptEndpointAndModelManually(ctx));
}
} else {
({ endpoint, modelId } = await promptEndpointAndModelManually(ctx));
}
// 7. Test connection
try {
const { accessToken } = getAccessTokenResult();
const { accessToken } = getAccessTokenResult({
subscriptionId: selectedSub?.id,
tenantId,
});
const testUrl = `${buildAzureBaseUrl(endpoint, modelId)}/chat/completions?api-version=2024-12-01-preview`;
const res = await fetch(testUrl, {
method: "POST",
@ -241,9 +650,11 @@ const entraIdAuthMethod = {
key: "__entra_id_dynamic__",
metadata: {
authMethod: "entra-id",
subscriptionId: selectedSub.id,
subscriptionName: selectedSub.name,
...(selectedSub?.id ? { subscriptionId: selectedSub.id } : {}),
...(selectedSub?.name ? { subscriptionName: selectedSub.name } : {}),
...(tenantId ? { tenantId } : {}),
endpoint,
modelId,
},
},
},
@ -251,33 +662,56 @@ const entraIdAuthMethod = {
configPatch: {
models: {
providers: {
[PROVIDER_ID]: {
baseUrl: buildAzureBaseUrl(endpoint, modelId),
api: DEFAULT_API,
models: [
{
id: modelId,
name: modelId,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 16_384,
},
],
},
[PROVIDER_ID]: buildFoundryProviderConfig(endpoint, modelId),
},
},
},
defaultModel: `${PROVIDER_ID}/${modelId}`,
notes: [
`Subscription: ${selectedSub.name}`,
...(selectedSub?.name ? [`Subscription: ${selectedSub.name}`] : []),
...(tenantId ? [`Tenant: ${tenantId}`] : []),
`Endpoint: ${endpoint}`,
`Model: ${modelId}`,
"Token is refreshed automatically via az CLI — keep az login active.",
],
};
},
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 selectedModelCompat = buildFoundryModelCompat(selectedModelId);
const providerEndpoint = normalizeFoundryEndpoint(providerConfig.baseUrl ?? "");
const nextProviderConfig: ModelProviderConfig = {
...providerConfig,
baseUrl: buildFoundryProviderBaseUrl(providerEndpoint, selectedModelId),
api: resolveFoundryApi(selectedModelId),
models: [
{
...(providerConfig.models.find((model: { id: string }) => model.id === selectedModelId) ?? {
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 } : {}),
},
],
};
applyAuthProfileConfig(ctx.config, {
profileId: `${PROVIDER_ID}:entra`,
provider: PROVIDER_ID,
mode: "api_key",
});
ctx.config.models ??= {};
ctx.config.models.providers ??= {};
ctx.config.models.providers[PROVIDER_ID] = nextProviderConfig;
},
};
// ---------------------------------------------------------------------------
@ -310,8 +744,11 @@ const apiKeyAuthMethod = createProviderApiKeyAuthMethod({
let cachedToken: { token: string; expiresAt: number } | null = null;
function refreshEntraToken(): { apiKey: string; expiresAt: number } {
const result = getAccessTokenResult();
function refreshEntraToken(params?: {
subscriptionId?: string;
tenantId?: string;
}): { apiKey: string; expiresAt: number } {
const result = getAccessTokenResult(params);
const expiresAt = result.expiresOn
? new Date(result.expiresOn).getTime()
: Date.now() + 55 * 60 * 1000; // default ~55 min
@ -337,6 +774,19 @@ export default definePluginEntry({
capabilities: {
providerFamily: "openai",
},
normalizeResolvedModel: ({ modelId, model }) => {
const endpoint = extractFoundryEndpoint(model.baseUrl ?? "");
if (!endpoint) {
return model;
}
const compat = buildFoundryModelCompat(modelId);
return {
...model,
api: resolveFoundryApi(modelId),
baseUrl: buildFoundryProviderBaseUrl(endpoint, modelId),
...(compat ? { compat } : {}),
};
},
prepareRuntimeAuth: async (ctx) => {
// Only intercept Entra ID auth (placeholder key).
// API key users pass through unchanged.
@ -345,13 +795,42 @@ export default definePluginEntry({
}
// Return cached token if still valid
if (cachedToken && cachedToken.expiresAt > Date.now() + TOKEN_REFRESH_MARGIN_MS) {
return { apiKey: cachedToken.token, expiresAt: cachedToken.expiresAt };
}
// Refresh via az CLI
try {
return refreshEntraToken();
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 endpoint =
typeof metadata?.endpoint === "string" && metadata.endpoint.trim().length > 0
? metadata.endpoint.trim()
: extractFoundryEndpoint(ctx.model.baseUrl ?? "");
const baseUrl = endpoint ? buildFoundryProviderBaseUrl(endpoint, modelId) : undefined;
// Return cached token if still valid
if (cachedToken && cachedToken.expiresAt > Date.now() + TOKEN_REFRESH_MARGIN_MS) {
return {
apiKey: cachedToken.token,
expiresAt: cachedToken.expiresAt,
...(baseUrl ? { baseUrl } : {}),
};
}
// Refresh via az CLI
const token = refreshEntraToken({
subscriptionId: metadata?.subscriptionId,
tenantId: metadata?.tenantId,
});
return {
...token,
...(baseUrl ? { baseUrl } : {}),
};
} catch (err) {
throw new Error(
`Failed to refresh Azure Entra ID token via az CLI: ${String(err)}\n` +

View File

@ -7,13 +7,32 @@ function symlinkType() {
return process.platform === "win32" ? "junction" : "dir";
}
function shouldFallbackToWindowsFileLink(error, type) {
return (
process.platform === "win32" &&
type !== symlinkType() &&
error &&
typeof error === "object" &&
"code" in error &&
(error.code === "EPERM" || error.code === "EACCES")
);
}
function relativeSymlinkTarget(sourcePath, targetPath) {
const relativeTarget = path.relative(path.dirname(targetPath), sourcePath);
return relativeTarget || ".";
}
function symlinkPath(sourcePath, targetPath, type) {
fs.symlinkSync(relativeSymlinkTarget(sourcePath, targetPath), targetPath, type);
try {
fs.symlinkSync(relativeSymlinkTarget(sourcePath, targetPath), targetPath, type);
} catch (error) {
if (shouldFallbackToWindowsFileLink(error, type)) {
fs.linkSync(sourcePath, targetPath);
return;
}
throw error;
}
}
function shouldWrapRuntimeJsFile(sourcePath) {