feat(web-search): add SearXNG as bundled web search provider plugin (#57317)

* feat(web-search): add bundled searxng plugin

* test(web-search): cover searxng config wiring

* test(web-search): include searxng in bundled provider inventory

* test(web-search): keep searxng ordering aligned

* fix(web-search): sanitize searxng result rows

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Charles Dusek 2026-04-01 05:24:33 -05:00 committed by GitHub
parent 3f67581e50
commit 32ae841098
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 766 additions and 3 deletions

View File

@ -0,0 +1,11 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createSearxngWebSearchProvider } from "./src/searxng-search-provider.js";
export default definePluginEntry({
id: "searxng",
name: "SearXNG Plugin",
description: "Bundled SearXNG web search plugin",
register(api) {
api.registerWebSearchProvider(createSearxngWebSearchProvider());
},
});

View File

@ -0,0 +1,41 @@
{
"id": "searxng",
"uiHints": {
"webSearch.baseUrl": {
"label": "SearXNG Base URL",
"help": "Base URL of your SearXNG instance, such as http://localhost:8080 or https://search.example.com/searxng."
},
"webSearch.categories": {
"label": "SearXNG Categories",
"help": "Optional comma-separated categories such as general, news, or science."
},
"webSearch.language": {
"label": "SearXNG Language",
"help": "Optional language code for results such as en, de, or fr."
}
},
"contracts": {
"webSearchProviders": ["searxng"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"webSearch": {
"type": "object",
"additionalProperties": false,
"properties": {
"baseUrl": {
"type": ["string", "object"]
},
"categories": {
"type": "string"
},
"language": {
"type": "string"
}
}
}
}
}
}

View File

@ -0,0 +1,12 @@
{
"name": "@openclaw/searxng-plugin",
"version": "2026.4.1",
"private": true,
"description": "OpenClaw SearXNG plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@ -0,0 +1,85 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
normalizeResolvedSecretInputString,
normalizeSecretInput,
} from "openclaw/plugin-sdk/secret-input";
type SearxngPluginConfig = {
webSearch?: {
baseUrl?: unknown;
categories?: string;
language?: string;
};
};
function normalizeConfiguredString(value: unknown, path: string): string | undefined {
try {
return normalizeSecretInput(
normalizeResolvedSecretInputString({
value,
path,
}),
);
} catch {
return undefined;
}
}
function readInlineEnvSecretRefValue(value: unknown, env: NodeJS.ProcessEnv): string | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const record = value as { source?: unknown; id?: unknown };
if (record.source !== "env" || typeof record.id !== "string") {
return undefined;
}
return normalizeSecretInput(env[record.id]);
}
function normalizeTrimmedString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
function normalizeBaseUrl(value: string | undefined): string | undefined {
return value?.replace(/\/+$/u, "") || undefined;
}
export function resolveSearxngWebSearchConfig(
config?: OpenClawConfig,
): SearxngPluginConfig["webSearch"] | undefined {
const pluginConfig = config?.plugins?.entries?.searxng?.config as SearxngPluginConfig | undefined;
const webSearch = pluginConfig?.webSearch;
if (webSearch && typeof webSearch === "object" && !Array.isArray(webSearch)) {
return webSearch;
}
return undefined;
}
export function resolveSearxngBaseUrl(
config?: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
const webSearch = resolveSearxngWebSearchConfig(config);
return (
normalizeBaseUrl(
normalizeConfiguredString(
webSearch?.baseUrl,
"plugins.entries.searxng.config.webSearch.baseUrl",
),
) ??
normalizeBaseUrl(readInlineEnvSecretRefValue(webSearch?.baseUrl, env)) ??
normalizeBaseUrl(normalizeSecretInput(env.SEARXNG_BASE_URL))
);
}
export function resolveSearxngCategories(config?: OpenClawConfig): string | undefined {
return normalizeTrimmedString(resolveSearxngWebSearchConfig(config)?.categories);
}
export function resolveSearxngLanguage(config?: OpenClawConfig): string | undefined {
return normalizeTrimmedString(resolveSearxngWebSearchConfig(config)?.language);
}

View File

@ -0,0 +1,92 @@
import type { LookupFn } from "openclaw/plugin-sdk/ssrf-runtime";
import { describe, expect, it, vi } from "vitest";
import { __testing } from "./searxng-client.js";
function createLookupFn(addresses: Array<{ address: string; family: number }>): LookupFn {
return vi.fn(async (_hostname: string, options?: unknown) => {
if (typeof options === "number" || !options || !(options as { all?: boolean }).all) {
return addresses[0];
}
return addresses;
}) as unknown as LookupFn;
}
describe("searxng client", () => {
it("preserves a configured base-path prefix when building the search URL", () => {
expect(
__testing.buildSearxngSearchUrl({
baseUrl: "https://search.example.com/searxng",
query: "openclaw",
categories: "general,news",
language: "en",
}),
).toBe(
"https://search.example.com/searxng/search?q=openclaw&format=json&categories=general%2Cnews&language=en",
);
});
it("parses SearXNG JSON results and applies the requested count cap", () => {
expect(
__testing.parseSearxngResponseText(
JSON.stringify({
results: [
{ title: "One", url: "https://example.com/1", content: "A" },
{ title: "Two", url: "https://example.com/2", content: "B" },
],
}),
1,
),
).toEqual([{ title: "One", url: "https://example.com/1", content: "A" }]);
});
it("drops malformed result rows instead of failing the whole response", () => {
expect(
__testing.parseSearxngResponseText(
JSON.stringify({
results: [
{ title: "One", url: "https://example.com/1", content: "A" },
{ title: { text: "bad" }, url: "https://example.com/2" },
{ title: "Three", url: 3, content: "bad-url" },
{ title: "Four", url: "https://example.com/4", content: { text: "bad" } },
],
}),
10,
),
).toEqual([
{ title: "One", url: "https://example.com/1", content: "A" },
{ title: "Four", url: "https://example.com/4", content: undefined },
]);
});
it("rejects invalid JSON bodies", () => {
expect(() => __testing.parseSearxngResponseText("{", 5)).toThrow(
"SearXNG returned invalid JSON.",
);
});
it("allows https public hosts", async () => {
await expect(
__testing.validateSearxngBaseUrl("https://search.example.com/searxng"),
).resolves.toBeUndefined();
});
it("allows cleartext private-network hosts", async () => {
await expect(
__testing.validateSearxngBaseUrl(
"http://matrix-synapse:8080",
createLookupFn([{ address: "10.0.0.5", family: 4 }]),
),
).resolves.toBeUndefined();
});
it("rejects cleartext public hosts", async () => {
await expect(
__testing.validateSearxngBaseUrl(
"http://search.example.com:8080",
createLookupFn([{ address: "93.184.216.34", family: 4 }]),
),
).rejects.toThrow(
"SearXNG HTTP base URL must target a trusted private or loopback host. Use https:// for public hosts.",
);
});
});

View File

@ -0,0 +1,236 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
DEFAULT_CACHE_TTL_MINUTES,
DEFAULT_SEARCH_COUNT,
normalizeCacheKey,
readCache,
readResponseText,
resolveCacheTtlMs,
resolveSearchCount,
resolveSiteName,
resolveTimeoutSeconds,
withTrustedWebSearchEndpoint,
wrapWebContent,
writeCache,
} from "openclaw/plugin-sdk/provider-web-search";
import {
assertHttpUrlTargetsPrivateNetwork,
type LookupFn,
} from "openclaw/plugin-sdk/ssrf-runtime";
import {
resolveSearxngBaseUrl,
resolveSearxngCategories,
resolveSearxngLanguage,
} from "./config.js";
const DEFAULT_TIMEOUT_SECONDS = 20;
const MAX_RESPONSE_BYTES = 1_000_000;
const SEARXNG_SEARCH_CACHE = new Map<
string,
{ value: Record<string, unknown>; insertedAt: number; expiresAt: number }
>();
type SearxngResult = {
url: string;
title: string;
content?: string;
};
type SearxngResponse = {
results?: SearxngResult[];
};
function normalizeSearxngResult(value: unknown): SearxngResult | null {
if (!value || typeof value !== "object") {
return null;
}
const candidate = value as { url?: unknown; title?: unknown; content?: unknown };
if (typeof candidate.url !== "string" || typeof candidate.title !== "string") {
return null;
}
return {
url: candidate.url,
title: candidate.title,
content: typeof candidate.content === "string" ? candidate.content : undefined,
};
}
function buildSearxngSearchUrl(params: {
baseUrl: string;
query: string;
categories?: string;
language?: string;
}): string {
const url = new URL(params.baseUrl);
const pathname = url.pathname.endsWith("/") ? `${url.pathname}search` : `${url.pathname}/search`;
url.pathname = pathname;
url.search = "";
url.searchParams.set("q", params.query);
url.searchParams.set("format", "json");
if (params.categories) {
url.searchParams.set("categories", params.categories);
}
if (params.language) {
url.searchParams.set("language", params.language);
}
return url.toString();
}
async function validateSearxngBaseUrl(baseUrl: string, lookupFn?: LookupFn): Promise<void> {
let parsed: URL;
try {
parsed = new URL(baseUrl);
} catch {
throw new Error("SearXNG base URL must be a valid http:// or https:// URL.");
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("SearXNG base URL must use http:// or https://.");
}
if (parsed.protocol === "http:") {
await assertHttpUrlTargetsPrivateNetwork(parsed.toString(), {
allowPrivateNetwork: true,
lookupFn,
errorMessage:
"SearXNG HTTP base URL must target a trusted private or loopback host. Use https:// for public hosts.",
});
}
}
function parseSearxngResponseText(text: string, count: number): SearxngResult[] {
let parsed: unknown;
try {
parsed = JSON.parse(text) as SearxngResponse;
} catch {
throw new Error("SearXNG returned invalid JSON.");
}
if (!parsed || typeof parsed !== "object") {
return [];
}
const response = parsed as SearxngResponse;
const rawResults = Array.isArray(response.results) ? response.results : [];
const results: SearxngResult[] = [];
for (const rawResult of rawResults) {
const result = normalizeSearxngResult(rawResult);
if (result) {
results.push(result);
}
if (results.length >= count) {
break;
}
}
return results;
}
export async function runSearxngSearch(params: {
config?: OpenClawConfig;
query: string;
count?: number;
categories?: string;
language?: string;
baseUrl?: string;
timeoutSeconds?: number;
cacheTtlMinutes?: number;
}): Promise<Record<string, unknown>> {
const count = resolveSearchCount(params.count, DEFAULT_SEARCH_COUNT);
const categories = params.categories ?? resolveSearxngCategories(params.config);
const language = params.language ?? resolveSearxngLanguage(params.config);
const baseUrl = params.baseUrl ?? resolveSearxngBaseUrl(params.config);
const timeoutSeconds = resolveTimeoutSeconds(params.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS);
const cacheTtlMs = resolveCacheTtlMs(params.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES);
if (!baseUrl) {
throw new Error(
"SearXNG base URL is not configured. Set SEARXNG_BASE_URL or configure plugins.entries.searxng.config.webSearch.baseUrl.",
);
}
await validateSearxngBaseUrl(baseUrl);
const cacheKey = normalizeCacheKey(
JSON.stringify({
provider: "searxng",
query: params.query,
count,
categories: categories ?? "",
language: language ?? "",
baseUrl,
}),
);
const cached = readCache(SEARXNG_SEARCH_CACHE, cacheKey);
if (cached) {
return { ...cached.value, cached: true };
}
const url = buildSearxngSearchUrl({
baseUrl,
query: params.query,
categories,
language,
});
const startedAt = Date.now();
const results = await withTrustedWebSearchEndpoint(
{
url,
timeoutSeconds,
init: {
method: "GET",
headers: {
Accept: "application/json",
},
},
},
async (response) => {
if (!response.ok) {
const detail = (await readResponseText(response, { maxBytes: 64_000 })).text;
throw new Error(
`SearXNG search error (${response.status}): ${detail || response.statusText}`,
);
}
const body = await readResponseText(response, { maxBytes: MAX_RESPONSE_BYTES });
if (body.truncated) {
throw new Error("SearXNG response too large.");
}
return parseSearxngResponseText(body.text, count);
},
);
const payload = {
query: params.query,
provider: "searxng",
count: results.length,
tookMs: Date.now() - startedAt,
externalContent: {
untrusted: true,
source: "web_search",
provider: "searxng",
wrapped: true,
},
results: results.map((result) => ({
title: wrapWebContent(result.title, "web_search"),
url: result.url,
snippet: result.content ? wrapWebContent(result.content, "web_search") : "",
siteName: resolveSiteName(result.url) || undefined,
})),
} satisfies Record<string, unknown>;
writeCache(SEARXNG_SEARCH_CACHE, cacheKey, payload, cacheTtlMs);
return payload;
}
export const __testing = {
buildSearxngSearchUrl,
normalizeSearxngResult,
parseSearxngResponseText,
validateSearxngBaseUrl,
SEARXNG_SEARCH_CACHE,
};

View File

@ -0,0 +1,163 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
resolveSearxngBaseUrl,
resolveSearxngCategories,
resolveSearxngLanguage,
} from "./config.js";
const { runSearxngSearch } = vi.hoisted(() => ({
runSearxngSearch: vi.fn(async (params: Record<string, unknown>) => params),
}));
vi.mock("./searxng-client.js", () => ({
runSearxngSearch,
}));
describe("searxng web search provider", () => {
let createSearxngWebSearchProvider: typeof import("./searxng-search-provider.js").createSearxngWebSearchProvider;
let plugin: typeof import("../index.js").default;
beforeAll(async () => {
vi.resetModules();
({ createSearxngWebSearchProvider } = await import("./searxng-search-provider.js"));
({ default: plugin } = await import("../index.js"));
});
beforeEach(() => {
runSearxngSearch.mockReset();
runSearxngSearch.mockImplementation(async (params: Record<string, unknown>) => params);
});
it("registers a setup-visible web search provider", () => {
const webSearchProviders: unknown[] = [];
plugin.register({
registerWebSearchProvider(provider: unknown) {
webSearchProviders.push(provider);
},
} as never);
expect(plugin.id).toBe("searxng");
expect(webSearchProviders).toHaveLength(1);
const provider = webSearchProviders[0] as Record<string, unknown>;
expect(provider.id).toBe("searxng");
expect(provider.requiresCredential).toBe(true);
expect(provider.envVars).toEqual(["SEARXNG_BASE_URL"]);
expect(provider.onboardingScopes).toEqual(["text-inference"]);
});
it("exposes credential metadata and enables the plugin in config", () => {
const provider = createSearxngWebSearchProvider();
if (!provider.applySelectionConfig) {
throw new Error("Expected applySelectionConfig to be defined");
}
const applied = provider.applySelectionConfig({});
expect(provider.id).toBe("searxng");
expect(provider.label).toBe("SearXNG Search");
expect(provider.requiresCredential).toBe(true);
expect(provider.credentialPath).toBe("plugins.entries.searxng.config.webSearch.baseUrl");
expect(applied.plugins?.entries?.searxng?.enabled).toBe(true);
});
it("maps generic tool arguments into SearXNG search params", async () => {
const provider = createSearxngWebSearchProvider();
const tool = provider.createTool({
config: { test: true },
} as never);
if (!tool) {
throw new Error("Expected tool definition");
}
const result = await tool.execute({
query: "openclaw docs",
count: 4,
categories: "general,news",
language: "en",
});
expect(runSearxngSearch).toHaveBeenCalledWith({
config: { test: true },
query: "openclaw docs",
count: 4,
categories: "general,news",
language: "en",
});
expect(result).toEqual({
config: { test: true },
query: "openclaw docs",
count: 4,
categories: "general,news",
language: "en",
});
});
it("reads base URL from plugin config SecretRef, then env var, stripping trailing slashes", () => {
expect(
resolveSearxngBaseUrl(
{
plugins: {
entries: {
searxng: {
config: {
webSearch: {
baseUrl: {
source: "env",
provider: "default",
id: "SEARXNG_BASE_URL",
},
},
},
},
},
},
} as never,
{ SEARXNG_BASE_URL: "http://localhost:8888/" },
),
).toBe("http://localhost:8888");
expect(
resolveSearxngBaseUrl({} as never, {
SEARXNG_BASE_URL: "https://search.local/searxng///",
}),
).toBe("https://search.local/searxng");
expect(resolveSearxngBaseUrl({} as never, {})).toBeUndefined();
});
it("reads categories and language from plugin config", () => {
const config = {
plugins: {
entries: {
searxng: {
config: {
webSearch: {
categories: "general,news",
language: "de",
},
},
},
},
},
} as never;
expect(resolveSearxngCategories(config)).toBe("general,news");
expect(resolveSearxngLanguage(config)).toBe("de");
});
it("persists base URL to plugin config via setConfiguredCredentialValue", () => {
const provider = createSearxngWebSearchProvider();
const config = {} as Record<string, unknown>;
provider.setConfiguredCredentialValue!(config, "http://search.local:9000");
expect(
(
config as {
plugins?: { entries?: { searxng?: { config?: { webSearch?: { baseUrl?: string } } } } };
}
).plugins?.entries?.searxng?.config?.webSearch?.baseUrl,
).toBe("http://search.local:9000");
});
});

View File

@ -0,0 +1,76 @@
import { Type } from "@sinclair/typebox";
import {
enablePluginInConfig,
getScopedCredentialValue,
readNumberParam,
readStringParam,
resolveProviderWebSearchPluginConfig,
setScopedCredentialValue,
setProviderWebSearchPluginConfigValue,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search";
import { runSearxngSearch } from "./searxng-client.js";
const SearxngSearchSchema = Type.Object(
{
query: Type.String({ description: "Search query string." }),
count: Type.Optional(
Type.Number({
description: "Number of results to return (1-10).",
minimum: 1,
maximum: 10,
}),
),
categories: Type.Optional(
Type.String({
description:
"Optional comma-separated search categories such as general, news, or science.",
}),
),
language: Type.Optional(
Type.String({
description: "Optional language code for results such as en, de, or fr.",
}),
),
},
{ additionalProperties: false },
);
export function createSearxngWebSearchProvider(): WebSearchProviderPlugin {
return {
id: "searxng",
label: "SearXNG Search",
hint: "Self-hosted meta-search with no API key required",
onboardingScopes: ["text-inference"],
requiresCredential: true,
credentialLabel: "SearXNG Base URL",
envVars: ["SEARXNG_BASE_URL"],
placeholder: "http://localhost:8080",
signupUrl: "https://docs.searxng.org/",
autoDetectOrder: 200,
credentialPath: "plugins.entries.searxng.config.webSearch.baseUrl",
inactiveSecretPaths: ["plugins.entries.searxng.config.webSearch.baseUrl"],
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "searxng"),
setCredentialValue: (searchConfigTarget, value) =>
setScopedCredentialValue(searchConfigTarget, "searxng", value),
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "searxng")?.baseUrl,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "searxng", "baseUrl", value);
},
applySelectionConfig: (config) => enablePluginInConfig(config, "searxng").config,
createTool: (ctx) => ({
description:
"Search the web using a self-hosted SearXNG instance. Returns titles, URLs, and snippets.",
parameters: SearxngSearchSchema,
execute: async (args) =>
await runSearxngSearch({
config: ctx.config,
query: readStringParam(args, "query", { required: true }),
count: readNumberParam(args, "count", { integer: true }),
categories: readStringParam(args, "categories"),
language: readStringParam(args, "language"),
}),
}),
};
}

View File

@ -0,0 +1 @@
export { createSearxngWebSearchProvider } from "./src/searxng-search-provider.js";

View File

@ -583,6 +583,8 @@ importers:
specifier: workspace:*
version: link:../..
extensions/searxng: {}
extensions/sglang: {}
extensions/signal: {}

View File

@ -7,13 +7,21 @@ vi.mock("../runtime.js", () => ({
const getScopedWebSearchCredential = (key: string) => (search?: Record<string, unknown>) =>
(search?.[key] as { apiKey?: unknown } | undefined)?.apiKey;
const getConfiguredPluginWebSearchCredential =
const getConfiguredPluginWebSearchConfig =
(pluginId: string) => (config?: Record<string, unknown>) =>
(
config?.plugins as
| { entries?: Record<string, { config?: { webSearch?: { apiKey?: unknown } } }> }
| {
entries?: Record<
string,
{ config?: { webSearch?: { apiKey?: unknown; baseUrl?: unknown } } }
>;
}
| undefined
)?.entries?.[pluginId]?.config?.webSearch?.apiKey;
)?.entries?.[pluginId]?.config?.webSearch;
const getConfiguredPluginWebSearchCredential =
(pluginId: string) => (config?: Record<string, unknown>) =>
getConfiguredPluginWebSearchConfig(pluginId)(config)?.apiKey;
const mockWebSearchProviders = [
{
@ -58,6 +66,15 @@ const mockWebSearchProviders = [
getCredentialValue: getScopedWebSearchCredential("perplexity"),
getConfiguredCredentialValue: getConfiguredPluginWebSearchCredential("perplexity"),
},
{
id: "searxng",
envVars: ["SEARXNG_BASE_URL"],
credentialPath: "plugins.entries.searxng.config.webSearch.baseUrl",
getCredentialValue: (search?: Record<string, unknown>) =>
(search?.searxng as { baseUrl?: unknown } | undefined)?.baseUrl,
getConfiguredCredentialValue: (config?: Record<string, unknown>) =>
getConfiguredPluginWebSearchConfig("searxng")(config)?.baseUrl,
},
{
id: "tavily",
envVars: ["TAVILY_API_KEY"],
@ -179,6 +196,24 @@ describe("web search provider config", () => {
expect(res.ok).toBe(true);
});
it("accepts searxng provider config on the plugin-owned path", () => {
const res = validateConfigObjectWithPlugins(
buildWebSearchProviderConfig({
enabled: true,
provider: "searxng",
providerConfig: {
baseUrl: {
source: "env",
provider: "default",
id: "SEARXNG_BASE_URL",
},
},
}),
);
expect(res.ok).toBe(true);
});
it("rejects legacy scoped Tavily config", () => {
const res = validateConfigObjectWithPlugins({
tools: {
@ -261,6 +296,7 @@ describe("web search provider auto-detection", () => {
delete process.env.MOONSHOT_API_KEY;
delete process.env.PERPLEXITY_API_KEY;
delete process.env.OPENROUTER_API_KEY;
delete process.env.SEARXNG_BASE_URL;
delete process.env.TAVILY_API_KEY;
delete process.env.XAI_API_KEY;
delete process.env.KIMI_API_KEY;
@ -296,6 +332,11 @@ describe("web search provider auto-detection", () => {
expect(resolveSearchProvider({})).toBe("firecrawl");
});
it("auto-detects searxng when only SEARXNG_BASE_URL is set", () => {
process.env.SEARXNG_BASE_URL = "http://localhost:8080";
expect(resolveSearchProvider({})).toBe("searxng");
});
it("auto-detects kimi when only KIMI_API_KEY is set", () => {
process.env.KIMI_API_KEY = "test-kimi-key"; // pragma: allowlist secret
expect(resolveSearchProvider({})).toBe("kimi");

View File

@ -11,6 +11,7 @@ const EXPECTED_BUNDLED_WEB_SEARCH_PROVIDER_KEYS = [
"xai:grok",
"moonshot:kimi",
"perplexity:perplexity",
"searxng:searxng",
"tavily:tavily",
] as const;
const EXPECTED_BUNDLED_WEB_SEARCH_PROVIDER_PLUGIN_IDS = [
@ -22,6 +23,7 @@ const EXPECTED_BUNDLED_WEB_SEARCH_PROVIDER_PLUGIN_IDS = [
"xai",
"moonshot",
"perplexity",
"searxng",
"tavily",
] as const;
const EXPECTED_BUNDLED_WEB_SEARCH_CREDENTIAL_PATHS = [
@ -33,6 +35,7 @@ const EXPECTED_BUNDLED_WEB_SEARCH_CREDENTIAL_PATHS = [
"plugins.entries.xai.config.webSearch.apiKey",
"plugins.entries.moonshot.config.webSearch.apiKey",
"plugins.entries.perplexity.config.webSearch.apiKey",
"plugins.entries.searxng.config.webSearch.baseUrl",
"plugins.entries.tavily.config.webSearch.apiKey",
] as const;