diff --git a/extensions/ollama/openclaw.plugin.json b/extensions/ollama/openclaw.plugin.json index ca97b8c3764..e853f4e267c 100644 --- a/extensions/ollama/openclaw.plugin.json +++ b/extensions/ollama/openclaw.plugin.json @@ -2,6 +2,7 @@ "id": "ollama", "enabledByDefault": true, "providers": ["ollama"], + "providerDiscoveryEntry": "./provider-discovery.ts", "providerAuthEnvVars": { "ollama": ["OLLAMA_API_KEY"] }, diff --git a/extensions/ollama/provider-discovery.import-guard.test.ts b/extensions/ollama/provider-discovery.import-guard.test.ts new file mode 100644 index 00000000000..bebd4e946f6 --- /dev/null +++ b/extensions/ollama/provider-discovery.import-guard.test.ts @@ -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); + } + }); +}); diff --git a/extensions/ollama/provider-discovery.ts b/extensions/ollama/provider-discovery.ts new file mode 100644 index 00000000000..31e37917da3 --- /dev/null +++ b/extensions/ollama/provider-discovery.ts @@ -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; + }; +}; + +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 { + 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; diff --git a/src/agents/models-config.providers.implicit.ts b/src/agents/models-config.providers.implicit.ts index 37965ad3cf2..73f6843953e 100644 --- a/src/agents/models-config.providers.implicit.ts +++ b/src/agents/models-config.providers.implicit.ts @@ -341,15 +341,19 @@ export async function resolveImplicitProviders( ): Promise["providers"]> { const providers: Record = {}; const env = params.env ?? process.env; - const authStore = ensureAuthProfileStore(params.agentDir, { - allowKeychainPrompt: false, - }); + let authStore: ReturnType | 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, diff --git a/src/agents/models-config.providers.secrets.ts b/src/agents/models-config.providers.secrets.ts index 6aba15fbe2d..4146c6567a6 100644 --- a/src/agents/models-config.providers.secrets.ts +++ b/src/agents/models-config.providers.secrets.ts @@ -46,6 +46,14 @@ export type ProviderAuthResolver = ( profileId?: string; }; +type AuthProfileStoreInput = + | ReturnType + | (() => ReturnType); + +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, + 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, + 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, diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 0622f1ccead..be01e4ffb98 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -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(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, diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 4f57a35bff9..a2df35d9393 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -75,6 +75,7 @@ export type PluginManifestRecord = { kind?: PluginKind | PluginKind[]; channels: string[]; providers: string[]; + providerDiscoverySource?: string; modelSupport?: PluginManifestModelSupport; cliBackends: string[]; providerAuthEnvVars?: Record; @@ -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, diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 59fefdfaea4..beba5fd38d3 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -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, diff --git a/src/plugins/provider-discovery.runtime.ts b/src/plugins/provider-discovery.runtime.ts index 92338218922..49869e55994 100644 --- a/src/plugins/provider-discovery.runtime.ts +++ b/src/plugins/provider-discovery.runtime.ts @@ -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, diff --git a/src/plugins/source-loader.ts b/src/plugins/source-loader.ts new file mode 100644 index 00000000000..0e8c150ee5f --- /dev/null +++ b/src/plugins/source-loader.ts @@ -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>(); + 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}`, + ); + } + }; +}