mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-17 21:10:54 +00:00
Add pluggable web search providers
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -43,6 +43,7 @@ function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi
|
||||
registerCli() {},
|
||||
registerService() {},
|
||||
registerProvider() {},
|
||||
registerSearchProvider() {},
|
||||
registerHook() {},
|
||||
registerHttpRoute() {},
|
||||
registerCommand() {},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
|
||||
commands: [],
|
||||
channels,
|
||||
providers: [],
|
||||
searchProviders: [],
|
||||
gatewayHandlers: {},
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -28,6 +28,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
|
||||
channels: [],
|
||||
commands: [],
|
||||
providers: [],
|
||||
searchProviders: [],
|
||||
gatewayHandlers: {},
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
|
||||
@@ -10,6 +10,7 @@ export const registryState: { registry: PluginRegistry } = {
|
||||
typedHooks: [],
|
||||
channels: [],
|
||||
providers: [],
|
||||
searchProviders: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
httpRoutes: [],
|
||||
|
||||
@@ -145,6 +145,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({
|
||||
},
|
||||
],
|
||||
providers: [],
|
||||
searchProviders: [],
|
||||
gatewayHandlers: {},
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
|
||||
@@ -20,6 +20,7 @@ export function createMockPluginRegistry(
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
providers: [],
|
||||
searchProviders: [],
|
||||
commands: [],
|
||||
} as unknown as PluginRegistry;
|
||||
}
|
||||
|
||||
@@ -305,6 +305,7 @@ function createPluginRecord(params: {
|
||||
hookNames: [],
|
||||
channelIds: [],
|
||||
providerIds: [],
|
||||
searchProviderIds: [],
|
||||
gatewayMethods: [],
|
||||
cliCommands: [],
|
||||
services: [],
|
||||
|
||||
66
src/plugins/registry.search-provider.test.ts
Normal file
66
src/plugins/registry.search-provider.test.ts
Normal 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)",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -19,6 +19,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl
|
||||
typedHooks: [],
|
||||
channels: channels as unknown as PluginRegistry["channels"],
|
||||
providers: [],
|
||||
searchProviders: [],
|
||||
gatewayHandlers: {},
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user