From 7fa3825e80cb888c58b116066cda3ca870802a9c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 21:59:30 -0700 Subject: [PATCH] feat(plugins): derive bundled web search providers from plugins --- src/plugins/contracts/loader.contract.test.ts | 73 +++--- src/plugins/providers.ts | 5 + src/plugins/web-search-providers.ts | 248 ++++++++---------- 3 files changed, 142 insertions(+), 184 deletions(-) diff --git a/src/plugins/contracts/loader.contract.test.ts b/src/plugins/contracts/loader.contract.test.ts index 874a94a0b5e..740366394a6 100644 --- a/src/plugins/contracts/loader.contract.test.ts +++ b/src/plugins/contracts/loader.contract.test.ts @@ -1,36 +1,27 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { withBundledPluginAllowlistCompat } from "../bundled-compat.js"; +import { __testing as providerTesting } from "../providers.js"; +import { resolvePluginWebSearchProviders } from "../web-search-providers.js"; import { providerContractRegistry, webSearchProviderContractRegistry } from "./registry.js"; -const loadOpenClawPluginsMock = vi.fn(); - -vi.mock("../loader.js", () => ({ - loadOpenClawPlugins: (...args: unknown[]) => loadOpenClawPluginsMock(...args), -})); - -const { resolvePluginProviders } = await import("../providers.js"); -const { resolvePluginWebSearchProviders } = await import("../web-search-providers.js"); - function uniqueSortedPluginIds(values: string[]) { return [...new Set(values)].toSorted((left, right) => left.localeCompare(right)); } +function normalizeProviderContractPluginId(pluginId: string) { + return pluginId === "kimi-coding" ? "kimi" : pluginId; +} + describe("plugin loader contract", () => { beforeEach(() => { - loadOpenClawPluginsMock.mockReset(); - loadOpenClawPluginsMock.mockReturnValue({ - providers: [], - mediaUnderstandingProviders: [], - webSearchProviders: [], - }); + vi.restoreAllMocks(); }); it("keeps bundled provider compatibility wired to the provider registry", () => { const providerPluginIds = uniqueSortedPluginIds( - providerContractRegistry.map((entry) => entry.pluginId), + providerContractRegistry.map((entry) => normalizeProviderContractPluginId(entry.pluginId)), ); - - resolvePluginProviders({ - bundledProviderAllowlistCompat: true, + const compatPluginIds = providerTesting.resolveBundledProviderCompatPluginIds({ config: { plugins: { allow: ["openrouter"], @@ -38,37 +29,35 @@ describe("plugin loader contract", () => { }, }); - expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({ - plugins: expect.objectContaining({ - allow: expect.arrayContaining(providerPluginIds), - }), - }), - }), + const compatConfig = withBundledPluginAllowlistCompat({ + config: { + plugins: { + allow: ["openrouter"], + }, + }, + pluginIds: compatPluginIds, + }); + + expect(uniqueSortedPluginIds(compatPluginIds)).toEqual( + expect.arrayContaining(providerPluginIds), ); + expect(compatConfig?.plugins?.allow).toEqual(expect.arrayContaining(providerPluginIds)); }); it("keeps vitest bundled provider enablement wired to the provider registry", () => { const providerPluginIds = uniqueSortedPluginIds( - providerContractRegistry.map((entry) => entry.pluginId), + providerContractRegistry.map((entry) => normalizeProviderContractPluginId(entry.pluginId)), ); - - resolvePluginProviders({ - bundledProviderVitestCompat: true, + const compatConfig = providerTesting.withBundledProviderVitestCompat({ + config: undefined, + pluginIds: providerPluginIds, env: { VITEST: "1" } as NodeJS.ProcessEnv, }); - expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({ - plugins: expect.objectContaining({ - enabled: true, - allow: expect.arrayContaining(providerPluginIds), - }), - }), - }), - ); + expect(compatConfig?.plugins).toMatchObject({ + enabled: true, + allow: expect.arrayContaining(providerPluginIds), + }); }); it("keeps bundled web search loading scoped to the web search registry", () => { @@ -81,7 +70,6 @@ describe("plugin loader contract", () => { expect(uniqueSortedPluginIds(providers.map((provider) => provider.pluginId))).toEqual( webSearchPluginIds, ); - expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); }); it("keeps bundled web search allowlist compatibility wired to the web search registry", () => { @@ -101,6 +89,5 @@ describe("plugin loader contract", () => { expect(uniqueSortedPluginIds(providers.map((provider) => provider.pluginId))).toEqual( webSearchPluginIds, ); - expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); }); }); diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 35ef2703553..45c84986e6c 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -82,6 +82,11 @@ function resolveBundledProviderCompatPluginIds(params: { .toSorted((left, right) => left.localeCompare(right)); } +export const __testing = { + resolveBundledProviderCompatPluginIds, + withBundledProviderVitestCompat, +} as const; + export function resolveOwningPluginIdsForProvider(params: { provider: string; config?: PluginLoadOptions["config"]; diff --git a/src/plugins/web-search-providers.ts b/src/plugins/web-search-providers.ts index 8aba087f1fc..9ecdef1fd3c 100644 --- a/src/plugins/web-search-providers.ts +++ b/src/plugins/web-search-providers.ts @@ -1,148 +1,37 @@ -import { createFirecrawlWebSearchProvider } from "../../extensions/firecrawl/src/firecrawl-search-provider.js"; -import { - createPluginBackedWebSearchProvider, - getScopedCredentialValue, - getTopLevelCredentialValue, - setScopedCredentialValue, - setTopLevelCredentialValue, -} from "../agents/tools/web-search-plugin-factory.js"; +import bravePlugin from "../../extensions/brave/index.js"; +import firecrawlPlugin from "../../extensions/firecrawl/index.js"; +import googlePlugin from "../../extensions/google/index.js"; +import moonshotPlugin from "../../extensions/moonshot/index.js"; +import perplexityPlugin from "../../extensions/perplexity/index.js"; +import xaiPlugin from "../../extensions/xai/index.js"; import { withBundledPluginAllowlistCompat, withBundledPluginEnablementCompat, } from "./bundled-compat.js"; -import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import type { PluginLoadOptions } from "./loader.js"; +import type { PluginWebSearchProviderRegistration } from "./registry.js"; import { getActivePluginRegistry } from "./runtime.js"; +import type { OpenClawPluginApi, WebSearchProviderPlugin } from "./types.js"; import type { PluginWebSearchProviderEntry } from "./types.js"; -const BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS = [ - "brave", - "firecrawl", - "google", - "moonshot", - "perplexity", - "xai", -] as const; +type RegistrablePlugin = { + id: string; + name: string; + register: (api: OpenClawPluginApi) => void; +}; -const BUNDLED_WEB_SEARCH_PROVIDER_REGISTRY = [ - { - pluginId: "brave", - provider: createPluginBackedWebSearchProvider({ - id: "brave", - label: "Brave Search", - hint: "Structured results · country/language/time filters", - envVars: ["BRAVE_API_KEY"], - placeholder: "BSA...", - signupUrl: "https://brave.com/search/api/", - docsUrl: "https://docs.openclaw.ai/brave-search", - autoDetectOrder: 10, - getCredentialValue: getTopLevelCredentialValue, - setCredentialValue: setTopLevelCredentialValue, - }), - }, - { - pluginId: "google", - provider: createPluginBackedWebSearchProvider({ - id: "gemini", - label: "Gemini (Google Search)", - hint: "Google Search grounding · AI-synthesized", - envVars: ["GEMINI_API_KEY"], - placeholder: "AIza...", - signupUrl: "https://aistudio.google.com/apikey", - docsUrl: "https://docs.openclaw.ai/tools/web", - autoDetectOrder: 20, - getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "gemini"), - setCredentialValue: (searchConfigTarget, value) => - setScopedCredentialValue(searchConfigTarget, "gemini", value), - }), - }, - { - pluginId: "xai", - provider: createPluginBackedWebSearchProvider({ - id: "grok", - label: "Grok (xAI)", - hint: "xAI web-grounded responses", - envVars: ["XAI_API_KEY"], - placeholder: "xai-...", - signupUrl: "https://console.x.ai/", - docsUrl: "https://docs.openclaw.ai/tools/web", - autoDetectOrder: 30, - getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "grok"), - setCredentialValue: (searchConfigTarget, value) => - setScopedCredentialValue(searchConfigTarget, "grok", value), - }), - }, - { - pluginId: "moonshot", - provider: createPluginBackedWebSearchProvider({ - id: "kimi", - label: "Kimi (Moonshot)", - hint: "Moonshot web search", - envVars: ["KIMI_API_KEY", "MOONSHOT_API_KEY"], - placeholder: "sk-...", - signupUrl: "https://platform.moonshot.cn/", - docsUrl: "https://docs.openclaw.ai/tools/web", - autoDetectOrder: 40, - getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "kimi"), - setCredentialValue: (searchConfigTarget, value) => - setScopedCredentialValue(searchConfigTarget, "kimi", value), - }), - }, - { - pluginId: "perplexity", - provider: createPluginBackedWebSearchProvider({ - id: "perplexity", - label: "Perplexity Search", - hint: "Structured results · domain/country/language/time filters", - envVars: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"], - placeholder: "pplx-...", - signupUrl: "https://www.perplexity.ai/settings/api", - docsUrl: "https://docs.openclaw.ai/perplexity", - autoDetectOrder: 50, - getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "perplexity"), - setCredentialValue: (searchConfigTarget, value) => - setScopedCredentialValue(searchConfigTarget, "perplexity", value), - }), - }, - { - pluginId: "firecrawl", - provider: createFirecrawlWebSearchProvider(), - }, -] as const; +const BUNDLED_WEB_SEARCH_PLUGINS: readonly RegistrablePlugin[] = [ + bravePlugin, + firecrawlPlugin, + googlePlugin, + moonshotPlugin, + perplexityPlugin, + xaiPlugin, +]; -export function resolvePluginWebSearchProviders(params: { - config?: PluginLoadOptions["config"]; - workspaceDir?: string; - env?: PluginLoadOptions["env"]; - bundledAllowlistCompat?: boolean; -}): PluginWebSearchProviderEntry[] { - const allowlistCompat = params.bundledAllowlistCompat - ? withBundledPluginAllowlistCompat({ - config: params.config, - pluginIds: BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS, - }) - : params.config; - const config = withBundledPluginEnablementCompat({ - config: allowlistCompat, - pluginIds: BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS, - }); - const normalizedPlugins = normalizePluginsConfig(config?.plugins); - - return sortWebSearchProviders( - BUNDLED_WEB_SEARCH_PROVIDER_REGISTRY.filter( - ({ pluginId }) => - resolveEffectiveEnableState({ - id: pluginId, - origin: "bundled", - config: normalizedPlugins, - rootConfig: config, - }).enabled, - ).map((entry) => ({ - ...entry.provider, - pluginId: entry.pluginId, - })), - ); -} +const BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS = BUNDLED_WEB_SEARCH_PLUGINS.map( + (plugin) => plugin.id, +); function sortWebSearchProviders( providers: PluginWebSearchProviderEntry[], @@ -157,18 +46,95 @@ function sortWebSearchProviders( }); } +function mapWebSearchProviderEntries( + entries: PluginWebSearchProviderRegistration[], +): PluginWebSearchProviderEntry[] { + return sortWebSearchProviders( + entries.map((entry) => ({ + ...entry.provider, + pluginId: entry.pluginId, + })), + ); +} + +function normalizeWebSearchPluginConfig(params: { + config?: PluginLoadOptions["config"]; + bundledAllowlistCompat?: boolean; +}): PluginLoadOptions["config"] { + const allowlistCompat = params.bundledAllowlistCompat + ? withBundledPluginAllowlistCompat({ + config: params.config, + pluginIds: BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS, + }) + : params.config; + return withBundledPluginEnablementCompat({ + config: allowlistCompat, + pluginIds: BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS, + }); +} + +function captureBundledWebSearchProviders( + plugin: RegistrablePlugin, +): PluginWebSearchProviderRegistration[] { + const providers: WebSearchProviderPlugin[] = []; + const api = { + registerProvider() {}, + registerSpeechProvider() {}, + registerMediaUnderstandingProvider() {}, + registerWebSearchProvider(provider: WebSearchProviderPlugin) { + providers.push(provider); + }, + registerTool() {}, + }; + plugin.register(api as unknown as OpenClawPluginApi); + return providers.map((provider) => ({ + pluginId: plugin.id, + pluginName: plugin.name, + provider, + source: "bundled", + })); +} + +function resolveBundledWebSearchRegistrations(params: { + config?: PluginLoadOptions["config"]; + bundledAllowlistCompat?: boolean; +}): PluginWebSearchProviderRegistration[] { + const config = normalizeWebSearchPluginConfig(params); + if (config?.plugins?.enabled === false) { + return []; + } + const allowlist = config?.plugins?.allow + ? new Set(config.plugins.allow.map((entry) => entry.trim()).filter(Boolean)) + : null; + return BUNDLED_WEB_SEARCH_PLUGINS.flatMap((plugin) => { + if (allowlist && !allowlist.has(plugin.id)) { + return []; + } + if (config?.plugins?.entries?.[plugin.id]?.enabled === false) { + return []; + } + return captureBundledWebSearchProviders(plugin); + }); +} + +export function resolvePluginWebSearchProviders(params: { + config?: PluginLoadOptions["config"]; + workspaceDir?: string; + env?: PluginLoadOptions["env"]; + bundledAllowlistCompat?: boolean; +}): PluginWebSearchProviderEntry[] { + return mapWebSearchProviderEntries(resolveBundledWebSearchRegistrations(params)); +} + export function resolveRuntimeWebSearchProviders(params: { config?: PluginLoadOptions["config"]; + workspaceDir?: string; + env?: PluginLoadOptions["env"]; bundledAllowlistCompat?: boolean; }): PluginWebSearchProviderEntry[] { const runtimeProviders = getActivePluginRegistry()?.webSearchProviders ?? []; if (runtimeProviders.length > 0) { - return sortWebSearchProviders( - runtimeProviders.map((entry) => ({ - ...entry.provider, - pluginId: entry.pluginId, - })), - ); + return mapWebSearchProviderEntries(runtimeProviders); } return resolvePluginWebSearchProviders(params); }