Add pluggable web search providers

This commit is contained in:
Tak Hoffman
2026-03-10 00:28:16 -05:00
parent c30cabcca4
commit d7f5a6d308
23 changed files with 1527 additions and 326 deletions

View File

@@ -80,6 +80,18 @@ describe("diffs plugin registration", () => {
registerHttpRoute(params: RegisteredHttpRouteParams) {
registeredHttpRouteHandler = params.handler;
},
registerChannel() {},
registerGatewayMethod() {},
registerCli() {},
registerService() {},
registerProvider() {},
registerSearchProvider() {},
registerCommand() {},
registerContextEngine() {},
resolvePath(input: string) {
return input;
},
on() {},
});
plugin.register?.(api as unknown as OpenClawPluginApi);

View File

@@ -43,6 +43,7 @@ function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi
registerCli() {},
registerService() {},
registerProvider() {},
registerSearchProvider() {},
registerHook() {},
registerHttpRoute() {},
registerCommand() {},

View File

@@ -31,6 +31,17 @@ function createApi(params: {
writeConfigFile: (next: Record<string, unknown>) => params.writeConfig(next),
},
} as OpenClawPluginApi["runtime"],
logger: { info() {}, warn() {}, error() {} },
registerTool() {},
registerHook() {},
registerHttpRoute() {},
registerChannel() {},
registerGatewayMethod() {},
registerCli() {},
registerService() {},
registerProvider() {},
registerSearchProvider() {},
registerContextEngine() {},
registerCommand: params.registerCommand,
}) as OpenClawPluginApi;
}

View File

@@ -113,7 +113,6 @@ export function createOpenClawTools(
const webSearchTool = createWebSearchTool({
config: options?.config,
sandboxed: options?.sandboxed,
runtimeWebSearch: runtimeWebTools?.search,
});
const webFetchTool = createWebFetchTool({
config: options?.config,

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,22 @@
import { EnvHttpProxyAgent } from "undici";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createEmptyPluginRegistry } from "../../plugins/registry.js";
import { getActivePluginRegistry, setActivePluginRegistry } from "../../plugins/runtime.js";
import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import { __testing as webSearchTesting } from "./web-search.js";
import { createWebFetchTool, createWebSearchTool } from "./web-tools.js";
let previousPluginRegistry = getActivePluginRegistry();
beforeEach(() => {
previousPluginRegistry = getActivePluginRegistry();
setActivePluginRegistry(createEmptyPluginRegistry());
});
afterEach(() => {
setActivePluginRegistry(previousPluginRegistry ?? createEmptyPluginRegistry());
});
function installMockFetch(payload: unknown) {
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
Promise.resolve({
@@ -169,8 +182,8 @@ describe("web tools defaults", () => {
expect(tool?.name).toBe("web_search");
});
it("prefers runtime-selected web_search provider over local provider config", async () => {
const mockFetch = installMockFetch(createProviderSuccessPayload("gemini"));
it("uses the configured built-in web_search provider from config", async () => {
const mockFetch = installMockFetch(createProviderSuccessPayload("brave"));
const tool = createWebSearchTool({
config: {
tools: {
@@ -186,20 +199,314 @@ describe("web tools defaults", () => {
},
},
sandboxed: true,
runtimeWebSearch: {
providerConfigured: "brave",
providerSource: "auto-detect",
selectedProvider: "gemini",
selectedProviderKeySource: "secretRef",
diagnostics: [],
},
});
const result = await tool?.execute?.("call-runtime-provider", { query: "runtime override" });
const result = await tool?.execute?.("call-config-provider", { query: "config provider" });
expect(mockFetch).toHaveBeenCalled();
expect(String(mockFetch.mock.calls[0]?.[0])).toContain("generativelanguage.googleapis.com");
expect((result?.details as { provider?: string } | undefined)?.provider).toBe("gemini");
expect(String(mockFetch.mock.calls[0]?.[0])).toContain("api.search.brave.com");
expect((result?.details as { provider?: string } | undefined)?.provider).toBe("brave");
});
});
describe("web_search plugin providers", () => {
it("prefers an explicitly configured plugin provider over a built-in provider with the same id", async () => {
const searchMock = vi.fn(async () => ({
results: [
{
title: "Plugin Result",
url: "https://example.com/plugin",
description: "Plugin description",
},
],
}));
const registry = createEmptyPluginRegistry();
registry.searchProviders.push({
pluginId: "plugin-search",
source: "test",
provider: {
id: "brave",
name: "Plugin Brave Override",
pluginId: "plugin-search",
search: searchMock,
},
});
setActivePluginRegistry(registry);
const tool = createWebSearchTool({
config: {
plugins: {
entries: {
"plugin-search": {
enabled: true,
config: { endpoint: "https://plugin.example" },
},
},
},
tools: {
web: {
search: {
provider: "brave",
apiKey: "brave-config-test", // pragma: allowlist secret
},
},
},
},
sandboxed: true,
});
const result = await tool?.execute?.("plugin-explicit", { query: "override" });
const details = result?.details as
| {
provider?: string;
results?: Array<{ url: string }>;
}
| undefined;
expect(searchMock).toHaveBeenCalledOnce();
expect(details?.provider).toBe("brave");
expect(details?.results?.[0]?.url).toBe("https://example.com/plugin");
});
it("keeps an explicitly configured plugin provider even when built-in credentials are also present", async () => {
const searchMock = vi.fn(async () => ({
content: "Plugin-configured answer",
citations: ["https://example.com/plugin-configured"],
}));
const registry = createEmptyPluginRegistry();
registry.searchProviders.push({
pluginId: "plugin-search",
source: "test",
provider: {
id: "searxng",
name: "SearXNG",
pluginId: "plugin-search",
search: searchMock,
},
});
setActivePluginRegistry(registry);
const tool = createWebSearchTool({
config: {
plugins: {
entries: {
"plugin-search": {
enabled: true,
config: { endpoint: "https://plugin.example" },
},
},
},
tools: {
web: {
search: {
provider: "searxng",
apiKey: "brave-config-test", // pragma: allowlist secret
gemini: {
apiKey: "gemini-config-test", // pragma: allowlist secret
},
},
},
},
},
sandboxed: true,
});
const result = await tool?.execute?.("plugin-over-runtime", { query: "plugin configured" });
const details = result?.details as { provider?: string; citations?: string[] } | undefined;
expect(searchMock).toHaveBeenCalledOnce();
expect(details?.provider).toBe("searxng");
expect(details?.citations).toEqual(["https://example.com/plugin-configured"]);
});
it("auto-detects plugin providers before built-in API key detection", async () => {
vi.stubEnv("BRAVE_API_KEY", "test-brave-key"); // pragma: allowlist secret
const searchMock = vi.fn(async () => ({
content: "Plugin answer",
citations: ["https://example.com/plugin-auto"],
}));
const registry = createEmptyPluginRegistry();
registry.searchProviders.push({
pluginId: "plugin-auto",
source: "test",
provider: {
id: "plugin-auto",
name: "Plugin Auto",
pluginId: "plugin-auto",
isAvailable: () => true,
search: searchMock,
},
});
setActivePluginRegistry(registry);
const tool = createWebSearchTool({ config: {}, sandboxed: true });
const result = await tool?.execute?.("plugin-auto", { query: "auto" });
const details = result?.details as { provider?: string; citations?: string[] } | undefined;
expect(searchMock).toHaveBeenCalledOnce();
expect(details?.provider).toBe("plugin-auto");
expect(details?.citations).toEqual(["https://example.com/plugin-auto"]);
});
it("fails closed when a configured custom provider is not registered", async () => {
const mockFetch = installMockFetch(createProviderSuccessPayload("brave"));
const tool = createWebSearchTool({
config: {
tools: {
web: {
search: {
provider: "searxng",
apiKey: "brave-config-test", // pragma: allowlist secret
},
},
},
},
sandboxed: true,
});
const result = await tool?.execute?.("plugin-missing", { query: "missing provider" });
expect(mockFetch).not.toHaveBeenCalled();
expect(result?.details).toMatchObject({
error: "unknown_search_provider",
provider: "searxng",
});
});
it("preserves plugin error payloads without caching them as success responses", async () => {
webSearchTesting.SEARCH_CACHE.clear();
const searchMock = vi
.fn()
.mockResolvedValueOnce({ error: "rate_limited" })
.mockResolvedValueOnce({
results: [
{
title: "Recovered",
url: "https://example.com/recovered",
},
],
});
const registry = createEmptyPluginRegistry();
registry.searchProviders.push({
pluginId: "plugin-search",
source: "test",
provider: {
id: "searxng",
name: "SearXNG",
pluginId: "plugin-search",
search: searchMock,
},
});
setActivePluginRegistry(registry);
const tool = createWebSearchTool({
config: {
tools: {
web: {
search: {
provider: "searxng",
},
},
},
},
sandboxed: true,
});
const firstResult = await tool?.execute?.("plugin-error-1", { query: "same-query" });
const secondResult = await tool?.execute?.("plugin-error-2", { query: "same-query" });
expect(searchMock).toHaveBeenCalledTimes(2);
expect(firstResult?.details).toMatchObject({
error: "rate_limited",
provider: "searxng",
});
expect((secondResult?.details as { cached?: boolean } | undefined)?.cached).not.toBe(true);
expect(secondResult?.details).toMatchObject({
provider: "searxng",
results: [{ url: "https://example.com/recovered" }],
});
});
it("does not reuse cached plugin results across different plugin configs", async () => {
webSearchTesting.SEARCH_CACHE.clear();
const searchMock = vi
.fn()
.mockImplementation(async (_request, context: { pluginConfig?: { endpoint?: string } }) => ({
results: [
{
title: "Plugin Result",
url: `https://example.com/${context.pluginConfig?.endpoint || "missing"}`,
},
],
}));
const registry = createEmptyPluginRegistry();
registry.searchProviders.push({
pluginId: "plugin-search",
source: "test",
provider: {
id: "searxng",
name: "SearXNG",
pluginId: "plugin-search",
search: searchMock,
},
});
setActivePluginRegistry(registry);
const firstTool = createWebSearchTool({
config: {
plugins: {
entries: {
"plugin-search": {
enabled: true,
config: { endpoint: "tenant-a" },
},
},
},
tools: {
web: {
search: {
provider: "searxng",
},
},
},
},
sandboxed: true,
});
const secondTool = createWebSearchTool({
config: {
plugins: {
entries: {
"plugin-search": {
enabled: true,
config: { endpoint: "tenant-b" },
},
},
},
tools: {
web: {
search: {
provider: "searxng",
},
},
},
},
sandboxed: true,
});
const firstResult = await firstTool?.execute?.("plugin-cache-a", { query: "same-query" });
const secondResult = await secondTool?.execute?.("plugin-cache-b", { query: "same-query" });
const firstDetails = firstResult?.details as
| { results?: Array<{ url: string }>; cached?: boolean }
| undefined;
const secondDetails = secondResult?.details as
| { results?: Array<{ url: string }>; cached?: boolean }
| undefined;
expect(searchMock).toHaveBeenCalledTimes(2);
expect(firstDetails?.results?.[0]?.url).toBe("https://example.com/tenant-a");
expect(secondDetails?.results?.[0]?.url).toBe("https://example.com/tenant-b");
expect(secondDetails?.cached).not.toBe(true);
});
});

View File

@@ -82,6 +82,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
commands: [],
channels,
providers: [],
searchProviders: [],
gatewayHandlers: {},
httpRoutes: [],
cliRegistrars: [],

View File

@@ -214,7 +214,7 @@ export async function setupSearch(
const defaultProvider: SearchProvider = (() => {
if (existingProvider && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === existingProvider)) {
return existingProvider;
return existingProvider as SearchProvider;
}
const detected = SEARCH_PROVIDER_OPTIONS.find(
(e) => hasExistingKey(config, e.value) || hasKeyInEnv(e),

View File

@@ -1,15 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { validateConfigObject } from "./config.js";
import { validateConfigObject, validateConfigObjectWithPlugins } from "./config.js";
import { buildWebSearchProviderConfig } from "./test-helpers.js";
const loadOpenClawPlugins = vi.hoisted(() => vi.fn(() => ({ searchProviders: [] })));
vi.mock("../runtime.js", () => ({
defaultRuntime: { log: vi.fn(), error: vi.fn() },
}));
vi.mock("../plugins/loader.js", () => ({
loadOpenClawPlugins,
}));
vi.mock("@mariozechner/pi-ai/oauth", () => ({
getOAuthApiKey: vi.fn(async () => null),
getOAuthProviders: () => [],
}));
const { __testing } = await import("../agents/tools/web-search.js");
const { resolveSearchProvider } = __testing;
describe("web search provider config", () => {
beforeEach(() => {
loadOpenClawPlugins.mockReset();
loadOpenClawPlugins.mockReturnValue({ searchProviders: [] });
});
it("accepts custom plugin provider ids", () => {
const res = validateConfigObject(
buildWebSearchProviderConfig({
provider: "searxng",
}),
);
expect(res.ok).toBe(true);
});
it("rejects unknown custom plugin provider ids during plugin-aware validation", () => {
const res = validateConfigObjectWithPlugins(
buildWebSearchProviderConfig({
provider: "brvae",
}),
);
expect(res.ok).toBe(false);
expect(res.issues.some((issue) => issue.path === "tools.web.search.provider")).toBe(true);
});
it("accepts registered custom plugin provider ids during plugin-aware validation", () => {
loadOpenClawPlugins.mockReturnValue({
searchProviders: [
{
provider: {
id: "searxng",
},
},
],
});
const res = validateConfigObjectWithPlugins(
buildWebSearchProviderConfig({
provider: "searxng",
}),
);
expect(res.ok).toBe(true);
});
it("rejects invalid custom plugin provider ids", () => {
const res = validateConfigObject(
buildWebSearchProviderConfig({
provider: "SearXNG!",
}),
);
expect(res.ok).toBe(false);
});
it("accepts perplexity provider and config", () => {
const res = validateConfigObject(
buildWebSearchProviderConfig({

View File

@@ -457,8 +457,8 @@ export type ToolsConfig = {
search?: {
/** Enable web search tool (default: true when API key is present). */
enabled?: boolean;
/** Search provider ("brave", "gemini", "grok", "kimi", or "perplexity"). */
provider?: "brave" | "gemini" | "grok" | "kimi" | "perplexity";
/** Search provider. Built-ins include "brave", "gemini", "grok", "kimi", and "perplexity"; plugins use their registered id. */
provider?: "brave" | "gemini" | "grok" | "kimi" | "perplexity" | (string & {});
/** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */
apiKey?: SecretInput;
/** Default search results count (1-10). */

View File

@@ -6,6 +6,7 @@ import {
resolveEffectiveEnableState,
resolveMemorySlotDecision,
} from "../plugins/config-state.js";
import { loadOpenClawPlugins } from "../plugins/loader.js";
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
import {
@@ -388,8 +389,55 @@ function validateConfigObjectWithPluginsBase(
return info.normalizedPlugins;
};
const validateWebSearchProvider = () => {
const provider = config.tools?.web?.search?.provider;
if (
typeof provider !== "string" ||
provider === "brave" ||
provider === "perplexity" ||
provider === "grok" ||
provider === "gemini" ||
provider === "kimi"
) {
return;
}
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
try {
const pluginRegistry = loadOpenClawPlugins({
config,
workspaceDir: workspaceDir ?? undefined,
logger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
},
cache: false,
});
const normalizedProvider = provider.trim().toLowerCase();
const registered = pluginRegistry.searchProviders.some(
(entry) => entry.provider.id === normalizedProvider,
);
if (registered) {
return;
}
} catch {
// Fall through and surface the unknown provider issue below.
}
if (provider.trim()) {
issues.push({
path: "tools.web.search.provider",
message: `unknown web search provider: ${provider}`,
});
}
};
const allowedChannels = new Set<string>(["defaults", "modelByChannel", ...CHANNEL_IDS]);
validateWebSearchProvider();
if (config.channels && isRecord(config.channels)) {
for (const key of Object.keys(config.channels)) {
const trimmed = key.trim();

View File

@@ -269,6 +269,7 @@ export const ToolsWebSearchSchema = z
z.literal("grok"),
z.literal("gemini"),
z.literal("kimi"),
z.string().regex(/^[a-z][a-z0-9_-]*$/, "custom provider id"),
])
.optional(),
apiKey: SecretInputSchema.optional().register(sensitive),

View File

@@ -28,6 +28,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
channels: [],
commands: [],
providers: [],
searchProviders: [],
gatewayHandlers: {},
httpRoutes: [],
cliRegistrars: [],

View File

@@ -10,6 +10,7 @@ export const registryState: { registry: PluginRegistry } = {
typedHooks: [],
channels: [],
providers: [],
searchProviders: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],

View File

@@ -145,6 +145,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({
},
],
providers: [],
searchProviders: [],
gatewayHandlers: {},
httpRoutes: [],
cliRegistrars: [],

View File

@@ -20,6 +20,7 @@ export function createMockPluginRegistry(
cliRegistrars: [],
services: [],
providers: [],
searchProviders: [],
commands: [],
} as unknown as PluginRegistry;
}

View File

@@ -305,6 +305,7 @@ function createPluginRecord(params: {
hookNames: [],
channelIds: [],
providerIds: [],
searchProviderIds: [],
gatewayMethods: [],
cliCommands: [],
services: [],

View File

@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from "vitest";
import { createPluginRegistry, type PluginRecord } from "./registry.js";
function createRecord(id: string): PluginRecord {
return {
id,
name: id,
source: `/tmp/${id}.ts`,
origin: "workspace",
enabled: true,
status: "loaded",
toolNames: [],
hookNames: [],
channelIds: [],
providerIds: [],
searchProviderIds: [],
gatewayMethods: [],
cliCommands: [],
services: [],
commands: [],
httpRoutes: 0,
hookCount: 0,
configSchema: false,
};
}
describe("search provider registration", () => {
it("rejects duplicate provider ids case-insensitively and tracks plugin ids", () => {
const { registry, createApi } = createPluginRegistry({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
runtime: {} as never,
});
const firstApi = createApi(createRecord("first-plugin"), { config: {} });
const secondApi = createApi(createRecord("second-plugin"), { config: {} });
firstApi.registerSearchProvider({
id: "Tavily",
name: "Tavily",
search: async () => ({ content: "ok" }),
});
secondApi.registerSearchProvider({
id: "tavily",
name: "Duplicate Tavily",
search: async () => ({ content: "duplicate" }),
});
expect(registry.searchProviders).toHaveLength(1);
expect(registry.searchProviders[0]?.provider.id).toBe("tavily");
expect(registry.searchProviders[0]?.provider.pluginId).toBe("first-plugin");
expect(registry.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "error",
pluginId: "second-plugin",
message: "search provider already registered: tavily (first-plugin)",
}),
]),
);
});
});

View File

@@ -42,6 +42,7 @@ import type {
PluginHookName,
PluginHookHandlerMap,
PluginHookRegistration as TypedPluginHookRegistration,
SearchProviderPlugin,
} from "./types.js";
export type PluginToolRegistration = {
@@ -81,6 +82,12 @@ export type PluginProviderRegistration = {
source: string;
};
export type PluginSearchProviderRegistration = {
pluginId: string;
provider: SearchProviderPlugin;
source: string;
};
export type PluginHookRegistration = {
pluginId: string;
entry: HookEntry;
@@ -116,6 +123,7 @@ export type PluginRecord = {
hookNames: string[];
channelIds: string[];
providerIds: string[];
searchProviderIds: string[];
gatewayMethods: string[];
cliCommands: string[];
services: string[];
@@ -134,6 +142,7 @@ export type PluginRegistry = {
typedHooks: TypedPluginHookRegistration[];
channels: PluginChannelRegistration[];
providers: PluginProviderRegistration[];
searchProviders: PluginSearchProviderRegistration[];
gatewayHandlers: GatewayRequestHandlers;
httpRoutes: PluginHttpRouteRegistration[];
cliRegistrars: PluginCliRegistration[];
@@ -174,6 +183,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
typedHooks: [],
channels: [],
providers: [],
searchProviders: [],
gatewayHandlers: {},
httpRoutes: [],
cliRegistrars: [],
@@ -467,6 +477,41 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
};
const registerSearchProvider = (record: PluginRecord, provider: SearchProviderPlugin) => {
const id = typeof provider?.id === "string" ? provider.id.trim().toLowerCase() : "";
if (!id) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: "search provider registration missing id",
});
return;
}
const existing = registry.searchProviders.find((entry) => entry.provider.id === id);
if (existing) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `search provider already registered: ${id} (${existing.pluginId})`,
});
return;
}
const normalizedProvider = {
...provider,
id,
pluginId: record.id,
};
record.searchProviderIds.push(id);
registry.searchProviders.push({
pluginId: record.id,
provider: normalizedProvider,
source: record.source,
});
};
const registerCli = (
record: PluginRecord,
registrar: OpenClawPluginCliRegistrar,
@@ -607,6 +652,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerHttpRoute: (params) => registerHttpRoute(record, params),
registerChannel: (registration) => registerChannel(record, registration),
registerProvider: (provider) => registerProvider(record, provider),
registerSearchProvider: (provider) => registerSearchProvider(record, provider),
registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler),
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
registerService: (service) => registerService(record, service),
@@ -625,6 +671,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerTool,
registerChannel,
registerProvider,
registerSearchProvider,
registerGatewayMethod,
registerCli,
registerService,

View File

@@ -239,6 +239,73 @@ export type OpenClawPluginGatewayMethod = {
handler: GatewayRequestHandler;
};
export type SearchProviderRequest = {
query: string;
count: number;
country?: string;
language?: string;
search_lang?: string;
ui_lang?: string;
freshness?: string;
dateAfter?: string;
dateBefore?: string;
domainFilter?: string[];
maxTokens?: number;
maxTokensPerPage?: number;
providerConfig?: Record<string, unknown>;
};
export type SearchProviderResultItem = {
url: string;
title?: string;
description?: string;
published?: string;
};
export type SearchProviderCitation =
| string
| {
url: string;
title?: string;
};
export type SearchProviderSuccessResult = {
error?: undefined;
message?: undefined;
results?: SearchProviderResultItem[];
citations?: SearchProviderCitation[];
content?: string;
tookMs?: number;
};
export type SearchProviderErrorResult = {
error: string;
message?: string;
docs?: string;
tookMs?: number;
};
export type SearchProviderExecutionResult = SearchProviderSuccessResult | SearchProviderErrorResult;
export type SearchProviderContext = {
config: OpenClawConfig;
timeoutSeconds: number;
cacheTtlMs: number;
pluginConfig?: Record<string, unknown>;
};
export type SearchProviderPlugin = {
id: string;
name: string;
description?: string;
pluginId?: string;
isAvailable?: (config?: OpenClawConfig) => boolean;
search: (
params: SearchProviderRequest,
ctx: SearchProviderContext,
) => Promise<SearchProviderExecutionResult>;
};
// =============================================================================
// Plugin Commands
// =============================================================================
@@ -388,6 +455,7 @@ export type OpenClawPluginApi = {
registerCli: (registrar: OpenClawPluginCliRegistrar, opts?: { commands?: string[] }) => void;
registerService: (service: OpenClawPluginService) => void;
registerProvider: (provider: ProviderPlugin) => void;
registerSearchProvider: (provider: SearchProviderPlugin) => void;
/**
* Register a custom command that bypasses the LLM agent.
* Plugin commands are processed before built-in commands and before agent invocation.

View File

@@ -19,6 +19,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl
typedHooks: [],
channels: channels as unknown as PluginRegistry["channels"],
providers: [],
searchProviders: [],
gatewayHandlers: {},
httpRoutes: [],
cliRegistrars: [],

View File

@@ -307,4 +307,50 @@ describe("finalizeOnboardingWizard", () => {
expect(progressUpdate).toHaveBeenCalledWith("Restarting Gateway service…");
expect(progressStop).toHaveBeenCalledWith("Gateway service restart scheduled.");
});
it("does not report plugin-provided web search providers as missing API keys", async () => {
const prompter = buildWizardPrompter({
select: vi.fn(async () => "later") as never,
confirm: vi.fn(async () => false),
});
const runtime = createRuntime();
await finalizeOnboardingWizard({
flow: "advanced",
opts: {
acceptRisk: true,
authChoice: "skip",
installDaemon: false,
skipHealth: true,
skipUi: true,
},
baseConfig: {},
nextConfig: {
tools: {
web: {
search: {
enabled: true,
provider: "searxng",
},
},
},
},
workspaceDir: "/tmp",
settings: {
port: 18789,
bind: "loopback",
authMode: "token",
gatewayToken: undefined,
tailscaleMode: "off",
tailscaleResetOnExit: false,
},
prompter,
runtime,
});
const noteCalls = (prompter.note as ReturnType<typeof vi.fn>).mock.calls;
const webSearchNote = noteCalls.find((call) => call?.[1] === "Web search");
expect(webSearchNote?.[0]).toContain("plugin-provided provider");
expect(webSearchNote?.[0]).not.toContain("no API key was found");
});
});

View File

@@ -488,8 +488,8 @@ export async function finalizeOnboardingWizard(
await import("../commands/onboard-search.js");
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === webSearchProvider);
const label = entry?.label ?? webSearchProvider;
const storedKey = resolveExistingKey(nextConfig, webSearchProvider);
const keyConfigured = hasExistingKey(nextConfig, webSearchProvider);
const storedKey = entry ? resolveExistingKey(nextConfig, entry.value) : undefined;
const keyConfigured = entry ? hasExistingKey(nextConfig, entry.value) : false;
const envAvailable = entry ? hasKeyInEnv(entry) : false;
const hasKey = keyConfigured || envAvailable;
const keySource = storedKey
@@ -499,7 +499,20 @@ export async function finalizeOnboardingWizard(
: envAvailable
? `API key: provided via ${entry?.envKeys.join(" / ")} env var.`
: undefined;
if (webSearchEnabled !== false && hasKey) {
if (!entry) {
await prompter.note(
[
webSearchEnabled !== false
? "Web search is enabled through a plugin-provided provider."
: "Web search is configured through a plugin-provided provider but currently disabled.",
"",
`Provider: ${label}`,
"Plugin-managed providers may use plugin config or plugin-specific credentials instead of the built-in API key fields.",
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",
);
} else if (webSearchEnabled !== false && hasKey) {
await prompter.note(
[
"Web search is enabled, so your agent can look things up online when needed.",