plugins: load lightweight provider discovery entries

This commit is contained in:
Peter Steinberger
2026-04-09 00:33:30 +01:00
parent 0fce013ebf
commit fbbd644d7a
10 changed files with 440 additions and 29 deletions

View File

@@ -189,6 +189,30 @@ export function clearPluginLoaderCache(): void {
const defaultLogger = () => createSubsystemLogger("plugins");
function shouldProfilePluginLoader(): boolean {
return process.env.OPENCLAW_PLUGIN_LOAD_PROFILE === "1";
}
function profilePluginLoaderSync<T>(params: {
phase: string;
pluginId?: string;
source: string;
run: () => T;
}): T {
if (!shouldProfilePluginLoader()) {
return params.run();
}
const startMs = performance.now();
try {
return params.run();
} finally {
const elapsedMs = performance.now() - startMs;
console.error(
`[plugin-load-profile] phase=${params.phase} plugin=${params.pluginId ?? "(core)"} elapsedMs=${elapsedMs.toFixed(1)} source=${params.source}`,
);
}
}
/**
* On Windows, the Node.js ESM loader requires absolute paths to be expressed
* as file:// URLs (e.g. file:///C:/Users/...). Raw drive-letter paths like
@@ -1134,9 +1158,14 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
throw new Error("Unable to resolve plugin runtime module");
}
const safeRuntimePath = toSafeImportPath(runtimeModulePath);
const runtimeModule = getJiti(runtimeModulePath)(safeRuntimePath) as {
createPluginRuntime?: (options?: CreatePluginRuntimeOptions) => PluginRuntime;
};
const runtimeModule = profilePluginLoaderSync({
phase: "runtime-module",
source: runtimeModulePath,
run: () =>
getJiti(runtimeModulePath)(safeRuntimePath) as {
createPluginRuntime?: (options?: CreatePluginRuntimeOptions) => PluginRuntime;
},
});
if (typeof runtimeModule.createPluginRuntime !== "function") {
throw new Error("Plugin runtime module missing createPluginRuntime export");
}
@@ -1550,7 +1579,12 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
// Track the plugin as imported once module evaluation begins. Top-level
// code may have already executed even if evaluation later throws.
recordImportedPluginId(record.id);
mod = getJiti(safeSource)(safeImportSource) as OpenClawPluginModule;
mod = profilePluginLoaderSync({
phase: registrationMode,
pluginId: record.id,
source: safeSource,
run: () => getJiti(safeSource)(safeImportSource) as OpenClawPluginModule,
});
} catch (err) {
recordPluginError({
logger,
@@ -2006,7 +2040,12 @@ export async function loadOpenClawPluginCliRegistry(
let mod: OpenClawPluginModule | null = null;
try {
mod = getJiti(safeSource)(safeImportSource) as OpenClawPluginModule;
mod = profilePluginLoaderSync({
phase: "cli-metadata",
pluginId: record.id,
source: safeSource,
run: () => getJiti(safeSource)(safeImportSource) as OpenClawPluginModule,
});
} catch (err) {
recordPluginError({
logger,

View File

@@ -75,6 +75,7 @@ export type PluginManifestRecord = {
kind?: PluginKind | PluginKind[];
channels: string[];
providers: string[];
providerDiscoverySource?: string;
modelSupport?: PluginManifestModelSupport;
cliBackends: string[];
providerAuthEnvVars?: Record<string, string[]>;
@@ -309,6 +310,9 @@ function buildRecord(params: {
kind: params.manifest.kind,
channels: params.manifest.channels ?? [],
providers: params.manifest.providers ?? [],
providerDiscoverySource: params.manifest.providerDiscoveryEntry
? path.resolve(params.candidate.rootDir, params.manifest.providerDiscoveryEntry)
: undefined,
modelSupport: params.manifest.modelSupport,
cliBackends: params.manifest.cliBackends ?? [],
providerAuthEnvVars: params.manifest.providerAuthEnvVars,

View File

@@ -96,6 +96,11 @@ export type PluginManifest = {
kind?: PluginKind | PluginKind[];
channels?: string[];
providers?: string[];
/**
* Optional lightweight module that exports provider plugin metadata for
* auth/catalog discovery. It should not import the full plugin runtime.
*/
providerDiscoveryEntry?: string;
/**
* Cheap model-family ownership metadata used before plugin runtime loads.
* Use this for shorthand model refs that omit an explicit provider prefix.
@@ -531,6 +536,7 @@ export function loadPluginManifest(
const version = normalizeOptionalString(raw.version);
const channels = normalizeTrimmedStringList(raw.channels);
const providers = normalizeTrimmedStringList(raw.providers);
const providerDiscoveryEntry = normalizeOptionalString(raw.providerDiscoveryEntry);
const modelSupport = normalizeManifestModelSupport(raw.modelSupport);
const cliBackends = normalizeTrimmedStringList(raw.cliBackends);
const providerAuthEnvVars = normalizeStringListRecord(raw.providerAuthEnvVars);
@@ -560,6 +566,7 @@ export function loadPluginManifest(
kind,
channels,
providers,
providerDiscoveryEntry,
modelSupport,
cliBackends,
providerAuthEnvVars,

View File

@@ -1,13 +1,86 @@
import type { OpenClawConfig } from "../config/config.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import { resolveDiscoveredProviderPluginIds } from "./providers.js";
import { resolvePluginProviders } from "./providers.runtime.js";
import { createPluginSourceLoader } from "./source-loader.js";
import type { ProviderPlugin } from "./types.js";
type ProviderDiscoveryModule =
| ProviderPlugin
| ProviderPlugin[]
| {
default?: ProviderPlugin | ProviderPlugin[];
providers?: ProviderPlugin[];
provider?: ProviderPlugin;
};
function normalizeDiscoveryModule(value: ProviderDiscoveryModule): ProviderPlugin[] {
const resolved =
value && typeof value === "object" && "default" in value && value.default !== undefined
? value.default
: value;
if (Array.isArray(resolved)) {
return resolved;
}
if (resolved && typeof resolved === "object" && "id" in resolved) {
return [resolved];
}
if (value && typeof value === "object" && !Array.isArray(value)) {
const record = value as { providers?: ProviderPlugin[]; provider?: ProviderPlugin };
if (Array.isArray(record.providers)) {
return record.providers;
}
if (record.provider) {
return [record.provider];
}
}
return [];
}
function resolveProviderDiscoveryEntryPlugins(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
onlyPluginIds?: string[];
}): ProviderPlugin[] {
const pluginIds = resolveDiscoveredProviderPluginIds(params);
const pluginIdSet = new Set(pluginIds);
const records = loadPluginManifestRegistry(params).plugins.filter(
(plugin) => plugin.providerDiscoverySource && pluginIdSet.has(plugin.id),
);
if (records.length === 0) {
return [];
}
const loadSource = createPluginSourceLoader();
const providers: ProviderPlugin[] = [];
for (const manifest of records) {
try {
const moduleExport = loadSource(manifest.providerDiscoverySource!) as ProviderDiscoveryModule;
providers.push(
...normalizeDiscoveryModule(moduleExport).map((provider) => ({
...provider,
pluginId: manifest.id,
})),
);
} catch {
// Discovery fast path is optional. Fall back to the full plugin loader
// below so existing plugin diagnostics/load behavior remains canonical.
return [];
}
}
return providers;
}
export function resolvePluginDiscoveryProvidersRuntime(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
onlyPluginIds?: string[];
}): ProviderPlugin[] {
const entryProviders = resolveProviderDiscoveryEntryPlugins(params);
if (entryProviders.length > 0) {
return entryProviders;
}
return resolvePluginProviders({
...params,
bundledProviderAllowlistCompat: true,

View File

@@ -0,0 +1,43 @@
import { createJiti } from "jiti";
import {
buildPluginLoaderAliasMap,
buildPluginLoaderJitiOptions,
shouldPreferNativeJiti,
} from "./sdk-alias.js";
export type PluginSourceLoader = (modulePath: string) => unknown;
function shouldProfilePluginSourceLoader(): boolean {
return process.env.OPENCLAW_PLUGIN_LOAD_PROFILE === "1";
}
export function createPluginSourceLoader(): PluginSourceLoader {
const loaders = new Map<string, ReturnType<typeof createJiti>>();
return (modulePath) => {
const tryNative = shouldPreferNativeJiti(modulePath);
const aliasMap = buildPluginLoaderAliasMap(modulePath, process.argv[1], import.meta.url);
const cacheKey = JSON.stringify({
tryNative,
aliasMap: Object.entries(aliasMap).toSorted(([left], [right]) => left.localeCompare(right)),
});
let jiti = loaders.get(cacheKey);
if (!jiti) {
jiti = createJiti(import.meta.url, {
...buildPluginLoaderJitiOptions(aliasMap),
tryNative,
});
loaders.set(cacheKey, jiti);
}
if (!shouldProfilePluginSourceLoader()) {
return jiti(modulePath);
}
const startMs = performance.now();
try {
return jiti(modulePath);
} finally {
console.error(
`[plugin-load-profile] phase=source-loader plugin=(direct) elapsedMs=${(performance.now() - startMs).toFixed(1)} source=${modulePath}`,
);
}
};
}