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

@@ -2,6 +2,7 @@
"id": "ollama",
"enabledByDefault": true,
"providers": ["ollama"],
"providerDiscoveryEntry": "./provider-discovery.ts",
"providerAuthEnvVars": {
"ollama": ["OLLAMA_API_KEY"]
},

View File

@@ -0,0 +1,30 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const repoRoot = path.resolve(import.meta.dirname, "../..");
function readPluginSource(relativePath: string): string {
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
}
describe("ollama provider discovery import surface", () => {
it("stays off the full provider runtime graph", () => {
const source = readPluginSource("extensions/ollama/provider-discovery.ts");
for (const forbidden of [
"./index",
"./api",
"./runtime-api",
"./src/setup",
"./src/stream",
"./src/embedding-provider",
"./src/memory-embedding-adapter",
"./src/web-search-provider",
"openclaw/plugin-sdk/text-runtime",
"openclaw/plugin-sdk/plugin-entry",
]) {
expect(source, `provider discovery must not import ${forbidden}`).not.toContain(forbidden);
}
});
});

View File

@@ -0,0 +1,199 @@
import type { ProviderCatalogContext } from "openclaw/plugin-sdk/provider-catalog-shared";
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { OLLAMA_DEFAULT_BASE_URL } from "./src/defaults.js";
import {
buildOllamaModelDefinition,
enrichOllamaModelsWithContext,
fetchOllamaModels,
resolveOllamaApiBase,
} from "./src/provider-models.js";
const PROVIDER_ID = "ollama";
const DEFAULT_API_KEY = "ollama-local";
const OLLAMA_CONTEXT_ENRICH_LIMIT = 200;
type OllamaPluginConfig = {
discovery?: {
enabled?: boolean;
};
};
type OllamaProviderLikeConfig = ModelProviderConfig;
type OllamaProviderPlugin = {
id: string;
label: string;
docsPath: string;
envVars: string[];
auth: [];
discovery: {
order: "late";
run: (ctx: ProviderCatalogContext) => ReturnType<typeof runOllamaDiscovery>;
};
};
function normalizeOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readStringValue(value: unknown): string | undefined {
if (typeof value === "string") {
return normalizeOptionalString(value);
}
if (value && typeof value === "object" && "value" in value) {
return normalizeOptionalString((value as { value?: unknown }).value);
}
return undefined;
}
function resolveOllamaDiscoveryApiKey(params: {
env: NodeJS.ProcessEnv;
explicitApiKey?: string;
resolvedApiKey?: string;
}): string {
const envApiKey = params.env.OLLAMA_API_KEY?.trim() ? "OLLAMA_API_KEY" : undefined;
return envApiKey ?? params.explicitApiKey ?? params.resolvedApiKey ?? DEFAULT_API_KEY;
}
function shouldSkipAmbientOllamaDiscovery(env: NodeJS.ProcessEnv): boolean {
return Boolean(env.VITEST) || env.NODE_ENV === "test";
}
function hasMeaningfulExplicitOllamaConfig(
providerConfig: OllamaProviderLikeConfig | undefined,
): boolean {
if (!providerConfig) {
return false;
}
if (Array.isArray(providerConfig.models) && providerConfig.models.length > 0) {
return true;
}
if (typeof providerConfig.baseUrl === "string" && providerConfig.baseUrl.trim()) {
return resolveOllamaApiBase(providerConfig.baseUrl) !== OLLAMA_DEFAULT_BASE_URL;
}
if (readStringValue(providerConfig.apiKey)) {
return true;
}
if (providerConfig.auth) {
return true;
}
if (typeof providerConfig.authHeader === "boolean") {
return true;
}
if (
providerConfig.headers &&
typeof providerConfig.headers === "object" &&
Object.keys(providerConfig.headers).length > 0
) {
return true;
}
if (providerConfig.request) {
return true;
}
if (typeof providerConfig.injectNumCtxForOpenAICompat === "boolean") {
return true;
}
return false;
}
async function buildOllamaProvider(
configuredBaseUrl?: string,
opts?: { quiet?: boolean },
): Promise<ModelProviderConfig> {
const apiBase = resolveOllamaApiBase(configuredBaseUrl);
const { reachable, models } = await fetchOllamaModels(apiBase);
if (!reachable && !opts?.quiet) {
console.warn(`Ollama could not be reached at ${apiBase}.`);
}
const discovered = await enrichOllamaModelsWithContext(
apiBase,
models.slice(0, OLLAMA_CONTEXT_ENRICH_LIMIT),
);
return {
baseUrl: apiBase,
api: "ollama",
models: discovered.map((model) =>
buildOllamaModelDefinition(model.name, model.contextWindow, model.capabilities),
),
};
}
function resolveOllamaPluginConfig(ctx: ProviderCatalogContext): OllamaPluginConfig {
const entries = (ctx.config.plugins?.entries ?? {}) as Record<
string,
{ config?: OllamaPluginConfig }
>;
return entries.ollama?.config ?? {};
}
async function runOllamaDiscovery(ctx: ProviderCatalogContext) {
const pluginConfig = resolveOllamaPluginConfig(ctx);
const explicit = ctx.config.models?.providers?.ollama;
const hasExplicitModels = Array.isArray(explicit?.models) && explicit.models.length > 0;
const hasMeaningfulExplicitConfig = hasMeaningfulExplicitOllamaConfig(explicit);
const discoveryEnabled =
pluginConfig.discovery?.enabled ?? ctx.config.models?.ollamaDiscovery?.enabled;
if (!hasExplicitModels && discoveryEnabled === false) {
return null;
}
const ollamaKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey;
const hasRealOllamaKey =
typeof ollamaKey === "string" &&
ollamaKey.trim().length > 0 &&
ollamaKey.trim() !== DEFAULT_API_KEY;
const explicitApiKey = readStringValue(explicit?.apiKey);
if (hasExplicitModels && explicit) {
return {
provider: {
...explicit,
baseUrl:
typeof explicit.baseUrl === "string" && explicit.baseUrl.trim()
? resolveOllamaApiBase(explicit.baseUrl)
: OLLAMA_DEFAULT_BASE_URL,
api: explicit.api ?? "ollama",
apiKey: resolveOllamaDiscoveryApiKey({
env: ctx.env,
explicitApiKey,
resolvedApiKey: ollamaKey,
}),
},
};
}
if (
!hasRealOllamaKey &&
!hasMeaningfulExplicitConfig &&
shouldSkipAmbientOllamaDiscovery(ctx.env)
) {
return null;
}
const provider = await buildOllamaProvider(explicit?.baseUrl, {
quiet: !hasRealOllamaKey && !hasMeaningfulExplicitConfig,
});
if (provider.models?.length === 0 && !ollamaKey && !explicit?.apiKey) {
return null;
}
return {
provider: {
...provider,
apiKey: resolveOllamaDiscoveryApiKey({
env: ctx.env,
explicitApiKey,
resolvedApiKey: ollamaKey,
}),
},
};
}
export const ollamaProviderDiscovery: OllamaProviderPlugin = {
id: PROVIDER_ID,
label: "Ollama",
docsPath: "/providers/ollama",
envVars: ["OLLAMA_API_KEY"],
auth: [],
discovery: {
order: "late",
run: runOllamaDiscovery,
},
};
export default ollamaProviderDiscovery;

View File

@@ -341,15 +341,19 @@ export async function resolveImplicitProviders(
): Promise<NonNullable<OpenClawConfig["models"]>["providers"]> {
const providers: Record<string, ProviderConfig> = {};
const env = params.env ?? process.env;
const authStore = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
});
let authStore: ReturnType<typeof ensureAuthProfileStore> | undefined;
const getAuthStore = () =>
(authStore ??= ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
}));
const context: ImplicitProviderContext = {
...params,
authStore,
get authStore() {
return getAuthStore();
},
env,
resolveProviderApiKey: createProviderApiKeyResolver(env, authStore, params.config),
resolveProviderAuth: createProviderAuthResolver(env, authStore, params.config),
resolveProviderApiKey: createProviderApiKeyResolver(env, getAuthStore, params.config),
resolveProviderAuth: createProviderAuthResolver(env, getAuthStore, params.config),
};
const discoveryProviders = await resolvePluginDiscoveryProviders({
config: params.config,

View File

@@ -46,6 +46,14 @@ export type ProviderAuthResolver = (
profileId?: string;
};
type AuthProfileStoreInput =
| ReturnType<typeof ensureAuthProfileStore>
| (() => ReturnType<typeof ensureAuthProfileStore>);
function resolveAuthProfileStoreInput(input: AuthProfileStoreInput) {
return typeof input === "function" ? input() : input;
}
const ENV_VAR_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
export function normalizeApiKeyConfig(value: string): string {
@@ -321,7 +329,7 @@ export function resolveMissingProviderApiKey(params: {
export function createProviderApiKeyResolver(
env: NodeJS.ProcessEnv,
authStore: ReturnType<typeof ensureAuthProfileStore>,
authStoreInput: AuthProfileStoreInput,
config?: OpenClawConfig,
): ProviderApiKeyResolver {
return (provider: string): { apiKey: string | undefined; discoveryApiKey?: string } => {
@@ -333,36 +341,40 @@ export function createProviderApiKeyResolver(
discoveryApiKey: toDiscoveryApiKey(env[envVar]),
};
}
const fromProfiles = resolveApiKeyFromProfiles({
provider: authProvider,
store: authStore,
env,
});
if (fromProfiles?.apiKey) {
return {
apiKey: fromProfiles.apiKey,
discoveryApiKey: fromProfiles.discoveryApiKey,
};
}
const fromConfig = resolveConfigBackedProviderAuth({
provider: authProvider,
config,
});
return {
apiKey: fromConfig?.apiKey,
discoveryApiKey: fromConfig?.discoveryApiKey,
};
if (fromConfig?.apiKey) {
return {
apiKey: fromConfig.apiKey,
discoveryApiKey: fromConfig.discoveryApiKey,
};
}
const fromProfiles = resolveApiKeyFromProfiles({
provider: authProvider,
store: resolveAuthProfileStoreInput(authStoreInput),
env,
});
return fromProfiles?.apiKey
? {
apiKey: fromProfiles.apiKey,
discoveryApiKey: fromProfiles.discoveryApiKey,
}
: { apiKey: undefined, discoveryApiKey: undefined };
};
}
export function createProviderAuthResolver(
env: NodeJS.ProcessEnv,
authStore: ReturnType<typeof ensureAuthProfileStore>,
authStoreInput: AuthProfileStoreInput,
config?: OpenClawConfig,
): ProviderAuthResolver {
return (provider: string, options?: { oauthMarker?: string }) => {
const authProvider = resolveProviderIdForAuth(provider, { config, env });
const authStore = resolveAuthProfileStoreInput(authStoreInput);
const ids = listProfilesForProvider(authStore, authProvider);
let oauthCandidate:
| {
apiKey: string | undefined;
@@ -425,7 +437,6 @@ export function createProviderAuthResolver(
source: "none",
};
}
return {
apiKey: undefined,
discoveryApiKey: undefined,

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}`,
);
}
};
}