haxudev b7876c9609 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.
2026-03-19 23:32:28 +08:00

313 lines
8.5 KiB
TypeScript

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;
}