feat(plugins): derive bundled web search providers from plugins

This commit is contained in:
Peter Steinberger
2026-03-16 21:59:30 -07:00
parent 21f5675f03
commit 7fa3825e80
3 changed files with 142 additions and 184 deletions

View File

@@ -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();
});
});

View File

@@ -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"];

View File

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