refactor: complete search provider migration

This commit is contained in:
Tak Hoffman 2026-03-15 14:40:58 -05:00
parent 74b5c2e875
commit d96601a8a2
44 changed files with 507 additions and 1883 deletions

View File

@ -201,7 +201,7 @@ Notes:
- Where to host SDK types: separate package or core export?
- Runtime type distribution: in SDK (types only) or in core?
- How to expose docs links for bundled vs external plugins?
- How to expose docs links from one plugin-owned metadata path regardless of provenance?
- Do we allow limited direct core imports for in-repo plugins during transition?
## Success criteria

View File

@ -1,5 +1,6 @@
{
"id": "search-brave",
"defaultEnabledWhenBundled": true,
"configSchema": {
"type": "object",
"properties": {}

View File

@ -2,7 +2,7 @@
"name": "@openclaw/search-brave",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw bundled Brave search provider plugin",
"description": "OpenClaw Brave search plugin",
"type": "module",
"openclaw": {
"extensions": [

View File

@ -1,3 +1,4 @@
import { Type } from "@sinclair/typebox";
import {
CacheEntry,
createSearchProviderSetupMetadata,
@ -8,14 +9,13 @@ import {
normalizeSecretInput,
readCache,
readResponseText,
readSearchProviderApiKeyValue,
resolveSearchConfig,
resolveSiteName,
type OpenClawConfig,
type SearchProviderContext,
type SearchProviderErrorResult,
type SearchProviderExecutionResult,
type SearchProviderSetupUiMetadata,
type SearchProviderSetupMetadata,
type SearchProviderPlugin,
type SearchProviderRequest,
withTrustedWebToolsEndpoint,
@ -91,6 +91,7 @@ const BRAVE_SEARCH_LANG_ALIASES: Record<string, string> = {
"zh-tw": "zh-hant",
};
const BRAVE_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i;
const MAX_SEARCH_COUNT = 10;
type BraveSearchResult = {
title?: string;
@ -136,7 +137,7 @@ function resolveBraveMode(brave: BraveConfig): "web" | "llm-context" {
function resolveBraveApiKey(search?: WebSearchConfig): string | undefined {
const fromConfigRaw = search
? normalizeResolvedSecretInputString({
value: readSearchProviderApiKeyValue(search as Record<string, unknown>, "brave"),
value: search.apiKey,
path: "tools.web.search.apiKey",
})
: undefined;
@ -395,7 +396,7 @@ async function runBraveWebSearch(params: {
);
}
export const BRAVE_SEARCH_PROVIDER_METADATA: SearchProviderSetupUiMetadata =
export const BRAVE_SEARCH_PROVIDER_METADATA: SearchProviderSetupMetadata =
createSearchProviderSetupMetadata({
provider: "brave",
label: "Brave Search",
@ -404,20 +405,34 @@ export const BRAVE_SEARCH_PROVIDER_METADATA: SearchProviderSetupUiMetadata =
placeholder: "BSA...",
signupUrl: "https://brave.com/search/api/",
apiKeyConfigPath: "tools.web.search.apiKey",
autodetectPriority: 10,
requestSchema: Type.Object({
query: Type.String({ description: "Search query string." }),
count: Type.Optional(Type.Number({ minimum: 1, maximum: MAX_SEARCH_COUNT })),
country: Type.Optional(Type.String()),
language: Type.Optional(Type.String()),
freshness: Type.Optional(Type.String()),
date_after: Type.Optional(Type.String()),
date_before: Type.Optional(Type.String()),
search_lang: Type.Optional(Type.String()),
ui_lang: Type.Optional(Type.String()),
}),
readApiKeyValue: (search) =>
search && typeof search === "object" && !Array.isArray(search) ? search.apiKey : undefined,
writeApiKeyValue: (search, value) => {
search.apiKey = value;
},
});
export function createBundledBraveSearchProvider(): SearchProviderPlugin {
return {
id: "brave",
name: BRAVE_SEARCH_PROVIDER_METADATA.label,
name: "Brave Search",
description:
"Search the web using Brave Search. Supports web and llm-context modes, region-specific search, and localized search parameters.",
pluginOwnedExecution: true,
docsUrl: BRAVE_SEARCH_PROVIDER_METADATA.signupUrl,
setup: {
hint: BRAVE_SEARCH_PROVIDER_METADATA.hint,
credentials: BRAVE_SEARCH_PROVIDER_METADATA,
},
docsUrl: "https://brave.com/search/api/",
setup: BRAVE_SEARCH_PROVIDER_METADATA,
isAvailable: (config) => {
const search = config?.tools?.web?.search;
return Boolean(

View File

@ -1,5 +1,6 @@
{
"id": "search-gemini",
"defaultEnabledWhenBundled": true,
"configSchema": {
"type": "object",
"properties": {}

View File

@ -2,7 +2,7 @@
"name": "@openclaw/search-gemini",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw bundled Gemini search provider plugin",
"description": "OpenClaw Gemini search plugin",
"type": "module",
"openclaw": {
"extensions": [

View File

@ -1,3 +1,4 @@
import { Type } from "@sinclair/typebox";
import {
buildSearchRequestCacheIdentity,
createSearchProviderSetupMetadata,
@ -12,7 +13,7 @@ import {
resolveSearchProviderSectionConfig,
type OpenClawConfig,
type SearchProviderExecutionResult,
type SearchProviderSetupUiMetadata,
type SearchProviderSetupMetadata,
type SearchProviderPlugin,
withTrustedWebToolsEndpoint,
wrapWebContent,
@ -26,6 +27,7 @@ const GEMINI_SEARCH_CACHE = new Map<
string,
{ value: Record<string, unknown>; expiresAt: number }
>();
const MAX_SEARCH_COUNT = 10;
type WebSearchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
? Web extends { search?: infer Search }
@ -156,7 +158,7 @@ async function runGeminiSearch(params: {
);
}
export const GEMINI_SEARCH_PROVIDER_METADATA: SearchProviderSetupUiMetadata =
export const GEMINI_SEARCH_PROVIDER_METADATA: SearchProviderSetupMetadata =
createSearchProviderSetupMetadata({
provider: "gemini",
label: "Gemini (Google Search)",
@ -165,6 +167,11 @@ export const GEMINI_SEARCH_PROVIDER_METADATA: SearchProviderSetupUiMetadata =
placeholder: "AIza...",
signupUrl: "https://aistudio.google.com/apikey",
apiKeyConfigPath: "tools.web.search.gemini.apiKey",
autodetectPriority: 20,
requestSchema: Type.Object({
query: Type.String({ description: "Search query string." }),
count: Type.Optional(Type.Number({ minimum: 1, maximum: MAX_SEARCH_COUNT })),
}),
});
export function createBundledGeminiSearchProvider(): SearchProviderPlugin {
@ -174,10 +181,7 @@ export function createBundledGeminiSearchProvider(): SearchProviderPlugin {
description:
"Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google Search.",
pluginOwnedExecution: true,
setup: {
hint: GEMINI_SEARCH_PROVIDER_METADATA.hint,
credentials: GEMINI_SEARCH_PROVIDER_METADATA,
},
setup: GEMINI_SEARCH_PROVIDER_METADATA,
isAvailable: (config) =>
Boolean(
resolveGeminiApiKey(

View File

@ -1,5 +1,6 @@
{
"id": "search-grok",
"defaultEnabledWhenBundled": true,
"configSchema": {
"type": "object",
"properties": {}

View File

@ -2,7 +2,7 @@
"name": "@openclaw/search-grok",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw bundled Grok search provider plugin",
"description": "OpenClaw Grok search plugin",
"type": "module",
"openclaw": {
"extensions": [

View File

@ -1,3 +1,4 @@
import { Type } from "@sinclair/typebox";
import {
buildSearchRequestCacheIdentity,
createSearchProviderSetupMetadata,
@ -11,7 +12,7 @@ import {
throwWebSearchApiError,
type OpenClawConfig,
type SearchProviderExecutionResult,
type SearchProviderSetupUiMetadata,
type SearchProviderSetupMetadata,
type SearchProviderPlugin,
withTrustedWebToolsEndpoint,
wrapWebContent,
@ -22,6 +23,7 @@ const XAI_API_ENDPOINT = "https://api.x.ai/v1/responses";
const DEFAULT_GROK_MODEL = "grok-4-1-fast";
const GROK_SEARCH_CACHE = new Map<string, { value: Record<string, unknown>; expiresAt: number }>();
const MAX_SEARCH_COUNT = 10;
type WebSearchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
? Web extends { search?: infer Search }
@ -164,7 +166,7 @@ async function runGrokSearch(params: {
);
}
export const GROK_SEARCH_PROVIDER_METADATA: SearchProviderSetupUiMetadata =
export const GROK_SEARCH_PROVIDER_METADATA: SearchProviderSetupMetadata =
createSearchProviderSetupMetadata({
provider: "grok",
label: "Grok (xAI)",
@ -173,6 +175,11 @@ export const GROK_SEARCH_PROVIDER_METADATA: SearchProviderSetupUiMetadata =
placeholder: "xai-...",
signupUrl: "https://console.x.ai/",
apiKeyConfigPath: "tools.web.search.grok.apiKey",
autodetectPriority: 30,
requestSchema: Type.Object({
query: Type.String({ description: "Search query string." }),
count: Type.Optional(Type.Number({ minimum: 1, maximum: MAX_SEARCH_COUNT })),
}),
});
export function createBundledGrokSearchProvider(): SearchProviderPlugin {
@ -182,10 +189,7 @@ export function createBundledGrokSearchProvider(): SearchProviderPlugin {
description:
"Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search.",
pluginOwnedExecution: true,
setup: {
hint: GROK_SEARCH_PROVIDER_METADATA.hint,
credentials: GROK_SEARCH_PROVIDER_METADATA,
},
setup: GROK_SEARCH_PROVIDER_METADATA,
isAvailable: (config) =>
Boolean(
resolveGrokApiKey(

View File

@ -1,5 +1,6 @@
{
"id": "search-kimi",
"defaultEnabledWhenBundled": true,
"configSchema": {
"type": "object",
"properties": {}

View File

@ -2,7 +2,7 @@
"name": "@openclaw/search-kimi",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw bundled Kimi search provider plugin",
"description": "OpenClaw Kimi search plugin",
"type": "module",
"openclaw": {
"extensions": [

View File

@ -1,3 +1,4 @@
import { Type } from "@sinclair/typebox";
import {
buildSearchRequestCacheIdentity,
createSearchProviderSetupMetadata,
@ -10,7 +11,7 @@ import {
resolveSearchProviderSectionConfig,
type OpenClawConfig,
type SearchProviderExecutionResult,
type SearchProviderSetupUiMetadata,
type SearchProviderSetupMetadata,
type SearchProviderPlugin,
withTrustedWebToolsEndpoint,
wrapWebContent,
@ -25,6 +26,7 @@ const KIMI_WEB_SEARCH_TOOL = {
} as const;
const KIMI_SEARCH_CACHE = new Map<string, { value: Record<string, unknown>; expiresAt: number }>();
const MAX_SEARCH_COUNT = 10;
type WebSearchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
? Web extends { search?: infer Search }
@ -216,7 +218,7 @@ async function runKimiSearch(params: {
};
}
export const KIMI_SEARCH_PROVIDER_METADATA: SearchProviderSetupUiMetadata =
export const KIMI_SEARCH_PROVIDER_METADATA: SearchProviderSetupMetadata =
createSearchProviderSetupMetadata({
provider: "kimi",
label: "Kimi (Moonshot)",
@ -225,6 +227,11 @@ export const KIMI_SEARCH_PROVIDER_METADATA: SearchProviderSetupUiMetadata =
placeholder: "sk-...",
signupUrl: "https://platform.moonshot.cn/",
apiKeyConfigPath: "tools.web.search.kimi.apiKey",
autodetectPriority: 40,
requestSchema: Type.Object({
query: Type.String({ description: "Search query string." }),
count: Type.Optional(Type.Number({ minimum: 1, maximum: MAX_SEARCH_COUNT })),
}),
});
export function createBundledKimiSearchProvider(): SearchProviderPlugin {
@ -234,10 +241,7 @@ export function createBundledKimiSearchProvider(): SearchProviderPlugin {
description:
"Search the web using Kimi by Moonshot. Returns AI-synthesized answers with citations from native $web_search.",
pluginOwnedExecution: true,
setup: {
hint: KIMI_SEARCH_PROVIDER_METADATA.hint,
credentials: KIMI_SEARCH_PROVIDER_METADATA,
},
setup: KIMI_SEARCH_PROVIDER_METADATA,
isAvailable: (config) =>
Boolean(
resolveKimiApiKey(

View File

@ -1,5 +1,6 @@
{
"id": "search-perplexity",
"defaultEnabledWhenBundled": true,
"configSchema": {
"type": "object",
"properties": {}

View File

@ -2,7 +2,7 @@
"name": "@openclaw/search-perplexity",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw bundled Perplexity search provider plugin",
"description": "OpenClaw Perplexity search plugin",
"type": "module",
"openclaw": {
"extensions": [

View File

@ -1,3 +1,4 @@
import { Type } from "@sinclair/typebox";
import {
buildSearchRequestCacheIdentity,
createSearchProviderSetupMetadata,
@ -14,7 +15,7 @@ import {
throwWebSearchApiError,
type OpenClawConfig,
type SearchProviderExecutionResult,
type SearchProviderSetupUiMetadata,
type SearchProviderSetupMetadata,
type SearchProviderPlugin,
withTrustedWebToolsEndpoint,
wrapWebContent,
@ -29,6 +30,7 @@ const PERPLEXITY_KEY_PREFIXES = ["pplx-"];
const OPENROUTER_KEY_PREFIXES = ["sk-or-"];
const PERPLEXITY_RECENCY_VALUES = new Set(["day", "week", "month", "year"]);
const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
const MAX_SEARCH_COUNT = 10;
const PERPLEXITY_SEARCH_CACHE = new Map<
string,
@ -376,7 +378,7 @@ function createPerplexityPayload(params: {
return payload;
}
export const PERPLEXITY_SEARCH_PROVIDER_METADATA: SearchProviderSetupUiMetadata =
export const PERPLEXITY_SEARCH_PROVIDER_METADATA: SearchProviderSetupMetadata =
createSearchProviderSetupMetadata({
provider: "perplexity",
label: "Perplexity Search",
@ -390,8 +392,99 @@ export const PERPLEXITY_SEARCH_PROVIDER_METADATA: SearchProviderSetupUiMetadata
resolvePerplexityConfig(resolveSearchConfig<WebSearchConfig>(params.search)),
).transport,
}),
autodetectPriority: 50,
resolveRequestSchema: (params) => {
const runtimeTransport =
params.runtimeMetadata && typeof params.runtimeMetadata.perplexityTransport === "string"
? params.runtimeMetadata.perplexityTransport
: undefined;
return runtimeTransport === "chat_completions"
? createPerplexityChatSchema()
: createPerplexitySearchApiSchema();
},
});
function createPerplexitySearchApiSchema() {
return 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: MAX_SEARCH_COUNT,
}),
),
freshness: Type.Optional(
Type.String({
description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.",
}),
),
country: Type.Optional(
Type.String({
description:
"Native Perplexity Search API only. 2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.",
}),
),
language: Type.Optional(
Type.String({
description:
"Native Perplexity Search API only. ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').",
}),
),
date_after: Type.Optional(
Type.String({
description:
"Native Perplexity Search API only. Only results published after this date (YYYY-MM-DD).",
}),
),
date_before: Type.Optional(
Type.String({
description:
"Native Perplexity Search API only. Only results published before this date (YYYY-MM-DD).",
}),
),
domain_filter: Type.Optional(
Type.Array(Type.String(), {
description:
"Native Perplexity Search API only. Domain filter (max 20). Allowlist: ['nature.com'] or denylist: ['-reddit.com']. Cannot mix.",
}),
),
max_tokens: Type.Optional(
Type.Number({
description:
"Native Perplexity Search API only. Total content budget across all results (default: 25000, max: 1000000).",
minimum: 1,
maximum: 1000000,
}),
),
max_tokens_per_page: Type.Optional(
Type.Number({
description:
"Native Perplexity Search API only. Max tokens extracted per page (default: 2048).",
minimum: 1,
}),
),
});
}
function createPerplexityChatSchema() {
return 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: MAX_SEARCH_COUNT,
}),
),
freshness: Type.Optional(
Type.String({
description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.",
}),
),
});
}
export function createBundledPerplexitySearchProvider(): SearchProviderPlugin {
return {
id: "perplexity",
@ -399,11 +492,8 @@ export function createBundledPerplexitySearchProvider(): SearchProviderPlugin {
description:
"Search the web using Perplexity. Runtime routing decides between native Search API and Sonar chat-completions compatibility. Structured filters are available on the native Search API path.",
pluginOwnedExecution: true,
setup: {
hint: PERPLEXITY_SEARCH_PROVIDER_METADATA.hint,
credentials: PERPLEXITY_SEARCH_PROVIDER_METADATA,
},
resolveRuntimeMetadata: PERPLEXITY_SEARCH_PROVIDER_METADATA.resolveRuntimeMetadata,
setup: PERPLEXITY_SEARCH_PROVIDER_METADATA,
resolveRuntimeMetadata: PERPLEXITY_SEARCH_PROVIDER_METADATA.credentials?.resolveRuntimeMetadata,
isAvailable: (config) =>
Boolean(
resolvePerplexityApiKey(

View File

@ -1,6 +1,9 @@
import { Type } from "@sinclair/typebox";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { createSearchProviderSetupMetadata } from "openclaw/plugin-sdk/web-search";
const TAVILY_SEARCH_ENDPOINT = "https://api.tavily.com/search";
const MAX_SEARCH_COUNT = 10;
type TavilyPluginConfig = {
apiKey?: string;
@ -69,15 +72,52 @@ function resolveFreshnessDays(freshness?: string): number | undefined {
const plugin = {
id: "tavily-search",
name: "Tavily Search",
description: "External Tavily web_search provider plugin",
description: "Tavily web_search plugin",
register(api: OpenClawPluginApi) {
api.registerSearchProvider({
id: "tavily",
name: "Tavily Search",
description:
"Search the web using Tavily via an external plugin provider. Returns structured results and an AI-synthesized answer when available.",
"Search the web using Tavily. Returns structured results and an AI-synthesized answer when available.",
docsUrl: "https://docs.tavily.com/",
configFieldOrder: ["apiKey", "searchDepth"],
setup: createSearchProviderSetupMetadata({
provider: "tavily",
label: "Tavily Search",
hint: "Plugin search with structured results and optional AI answer synthesis.",
envKeys: ["TAVILY_API_KEY"],
placeholder: "tvly-...",
signupUrl: "https://app.tavily.com/home",
apiKeyConfigPath: "plugins.entries.tavily-search.config.apiKey",
install: {
npmSpec: "@openclaw/tavily-search",
localPath: "extensions/tavily-search",
defaultChoice: "local",
},
requestSchema: 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: MAX_SEARCH_COUNT,
}),
),
country: Type.Optional(
Type.String({
description: "Optional 2-letter country code for region-specific results.",
}),
),
freshness: Type.Optional(
Type.String({
description: "Filter by time: 'day', 'week', 'month', or 'year'.",
}),
),
},
{ additionalProperties: true },
),
}),
isAvailable: (config) =>
Boolean(resolveApiKey(resolveRootPluginConfig(config ?? {}, api.id))),
search: async (params, ctx) => {

View File

@ -1,17 +0,0 @@
import type { InstallableSearchProviderPluginCatalogEntry } from "../../src/commands/search-provider-plugin-catalog.js";
import pluginManifest from "./openclaw.plugin.json";
import packageJson from "./package.json";
export const tavilySearchInstallCatalogEntry = {
id: pluginManifest.id,
providerId: "tavily",
meta: {
label: "Tavily Search",
},
description: "Install Tavily as a plugin search provider.",
install: {
npmSpec: packageJson.name,
localPath: "extensions/tavily-search",
defaultChoice: "local",
},
} satisfies InstallableSearchProviderPluginCatalogEntry;

View File

@ -1,5 +1,7 @@
{
"id": "tavily-search",
"name": "Tavily Search",
"description": "Search the web using Tavily.",
"provides": ["providers.search.tavily"],
"uiHints": {
"apiKey": {

View File

@ -1,11 +1,16 @@
{
"name": "@openclaw/tavily-search",
"version": "2026.3.9",
"description": "OpenClaw Tavily external search provider plugin",
"description": "OpenClaw Tavily search plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
],
"install": {
"npmSpec": "@openclaw/tavily-search",
"localPath": "extensions/tavily-search",
"defaultChoice": "local"
}
}
}

View File

@ -43,6 +43,7 @@ export function resolvePluginSkillDirs(params: {
origin: record.origin,
config: normalizedPlugins,
rootConfig: params.config,
defaultEnabledWhenBundled: record.defaultEnabledWhenBundled,
});
if (!enableState.enabled) {
continue;

View File

@ -1,143 +0,0 @@
export const BUILTIN_WEB_SEARCH_PROVIDER_IDS = [
"brave",
"gemini",
"grok",
"kimi",
"perplexity",
] as const;
export type BuiltinWebSearchProviderId = (typeof BUILTIN_WEB_SEARCH_PROVIDER_IDS)[number];
export const MIGRATED_BUNDLED_WEB_SEARCH_PROVIDER_IDS = BUILTIN_WEB_SEARCH_PROVIDER_IDS;
export type MigratedBundledWebSearchProviderId =
(typeof MIGRATED_BUNDLED_WEB_SEARCH_PROVIDER_IDS)[number];
export const bundledCoreWebSearchPluginId = (providerId: BuiltinWebSearchProviderId): string =>
`search-${providerId}`;
export const MIGRATED_BUNDLED_WEB_SEARCH_PLUGIN_IDS = MIGRATED_BUNDLED_WEB_SEARCH_PROVIDER_IDS.map(
bundledCoreWebSearchPluginId,
);
export type BuiltinWebSearchProviderEntry = {
value: BuiltinWebSearchProviderId;
label: string;
hint: string;
envKeys: readonly string[];
placeholder: string;
signupUrl: string;
apiKeyConfigPath: string;
};
const BUILTIN_WEB_SEARCH_PROVIDER_CATALOG: Record<
BuiltinWebSearchProviderId,
Omit<BuiltinWebSearchProviderEntry, "value">
> = {
brave: {
label: "Brave Search",
hint: "Structured results · country/language/time filters",
envKeys: ["BRAVE_API_KEY"],
placeholder: "BSA...",
signupUrl: "https://brave.com/search/api/",
apiKeyConfigPath: "tools.web.search.apiKey",
},
gemini: {
label: "Gemini (Google Search)",
hint: "Google Search grounding · AI-synthesized",
envKeys: ["GEMINI_API_KEY"],
placeholder: "AIza...",
signupUrl: "https://aistudio.google.com/apikey",
apiKeyConfigPath: "tools.web.search.gemini.apiKey",
},
grok: {
label: "Grok (xAI)",
hint: "xAI web-grounded responses",
envKeys: ["XAI_API_KEY"],
placeholder: "xai-...",
signupUrl: "https://console.x.ai/",
apiKeyConfigPath: "tools.web.search.grok.apiKey",
},
kimi: {
label: "Kimi (Moonshot)",
hint: "Moonshot web search",
envKeys: ["KIMI_API_KEY", "MOONSHOT_API_KEY"],
placeholder: "sk-...",
signupUrl: "https://platform.moonshot.cn/",
apiKeyConfigPath: "tools.web.search.kimi.apiKey",
},
perplexity: {
label: "Perplexity Search",
hint: "Structured results · domain/country/language/time filters",
envKeys: ["PERPLEXITY_API_KEY"],
placeholder: "pplx-...",
signupUrl: "https://www.perplexity.ai/settings/api",
apiKeyConfigPath: "tools.web.search.perplexity.apiKey",
},
};
export const BUILTIN_WEB_SEARCH_PROVIDER_OPTIONS: readonly BuiltinWebSearchProviderEntry[] =
BUILTIN_WEB_SEARCH_PROVIDER_IDS.map((value) => ({
value,
...BUILTIN_WEB_SEARCH_PROVIDER_CATALOG[value],
}));
export function isBuiltinWebSearchProviderId(value: string): value is BuiltinWebSearchProviderId {
return BUILTIN_WEB_SEARCH_PROVIDER_IDS.includes(value as BuiltinWebSearchProviderId);
}
export function normalizeBuiltinWebSearchProvider(
value: unknown,
): BuiltinWebSearchProviderId | undefined {
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim().toLowerCase();
return isBuiltinWebSearchProviderId(normalized) ? normalized : undefined;
}
export function getBuiltinWebSearchProviderEntry(
provider: BuiltinWebSearchProviderId,
): BuiltinWebSearchProviderEntry {
return BUILTIN_WEB_SEARCH_PROVIDER_OPTIONS.find((entry) => entry.value === provider)!;
}
function getScopedSearchConfig(
search: Record<string, unknown>,
provider: BuiltinWebSearchProviderId,
): Record<string, unknown> | undefined {
if (provider === "brave") {
return search;
}
const scoped = search[provider];
return typeof scoped === "object" && scoped !== null && !Array.isArray(scoped)
? (scoped as Record<string, unknown>)
: undefined;
}
export function readBuiltinWebSearchApiKeyValue(
search: Record<string, unknown> | undefined,
provider: BuiltinWebSearchProviderId,
): unknown {
if (!search) {
return undefined;
}
return getScopedSearchConfig(search, provider)?.apiKey;
}
export function writeBuiltinWebSearchApiKeyValue(params: {
search: Record<string, unknown>;
provider: BuiltinWebSearchProviderId;
value: unknown;
}): void {
if (params.provider === "brave") {
params.search.apiKey = params.value;
return;
}
const current = getScopedSearchConfig(params.search, params.provider);
if (current) {
current.apiKey = params.value;
return;
}
params.search[params.provider] = { apiKey: params.value };
}

View File

@ -1,174 +1,7 @@
import { describe, expect, it } from "vitest";
import { withEnv } from "../../test-utils/env.js";
import { __testing } from "./web-search.js";
const {
inferPerplexityBaseUrlFromApiKey,
resolvePerplexityBaseUrl,
resolvePerplexityModel,
resolvePerplexityTransport,
isDirectPerplexityBaseUrl,
resolvePerplexityRequestModel,
resolvePerplexityApiKey,
normalizeBraveLanguageParams,
normalizeFreshness,
normalizeToIsoDate,
isoToPerplexityDate,
resolveGrokApiKey,
resolveGrokModel,
resolveGrokInlineCitations,
extractGrokContent,
resolveKimiApiKey,
resolveKimiModel,
resolveKimiBaseUrl,
extractKimiCitations,
resolveBraveMode,
mapBraveLlmContextResults,
} = __testing;
const kimiApiKeyEnv = ["KIMI_API", "KEY"].join("_");
const moonshotApiKeyEnv = ["MOONSHOT_API", "KEY"].join("_");
const openRouterApiKeyEnv = ["OPENROUTER_API", "KEY"].join("_");
const perplexityApiKeyEnv = ["PERPLEXITY_API", "KEY"].join("_");
const openRouterPerplexityApiKey = ["sk", "or", "v1", "test"].join("-");
const directPerplexityApiKey = ["pplx", "test"].join("-");
const enterprisePerplexityApiKey = ["enterprise", "perplexity", "test"].join("-");
describe("web_search perplexity compatibility routing", () => {
it("detects API key prefixes", () => {
expect(inferPerplexityBaseUrlFromApiKey("pplx-123")).toBe("direct");
expect(inferPerplexityBaseUrlFromApiKey("sk-or-v1-123")).toBe("openrouter");
expect(inferPerplexityBaseUrlFromApiKey("unknown-key")).toBeUndefined();
});
it("prefers explicit baseUrl over key-based defaults", () => {
expect(resolvePerplexityBaseUrl({ baseUrl: "https://example.com" }, "config", "pplx-123")).toBe(
"https://example.com",
);
});
it("resolves OpenRouter env auth and transport", () => {
withEnv(
{ [perplexityApiKeyEnv]: undefined, [openRouterApiKeyEnv]: openRouterPerplexityApiKey },
() => {
expect(resolvePerplexityApiKey(undefined)).toEqual({
apiKey: openRouterPerplexityApiKey,
source: "openrouter_env",
});
expect(resolvePerplexityTransport(undefined)).toMatchObject({
baseUrl: "https://openrouter.ai/api/v1",
model: "perplexity/sonar-pro",
transport: "chat_completions",
});
},
);
});
it("uses native Search API for direct Perplexity when no legacy overrides exist", () => {
withEnv(
{ [perplexityApiKeyEnv]: directPerplexityApiKey, [openRouterApiKeyEnv]: undefined },
() => {
expect(resolvePerplexityTransport(undefined)).toMatchObject({
baseUrl: "https://api.perplexity.ai",
model: "perplexity/sonar-pro",
transport: "search_api",
});
},
);
});
it("switches direct Perplexity to chat completions when model override is configured", () => {
expect(resolvePerplexityModel({ model: "perplexity/sonar-reasoning-pro" })).toBe(
"perplexity/sonar-reasoning-pro",
);
expect(
resolvePerplexityTransport({
apiKey: directPerplexityApiKey,
model: "perplexity/sonar-reasoning-pro",
}),
).toMatchObject({
baseUrl: "https://api.perplexity.ai",
model: "perplexity/sonar-reasoning-pro",
transport: "chat_completions",
});
});
it("treats unrecognized configured keys as direct Perplexity by default", () => {
expect(
resolvePerplexityTransport({
apiKey: enterprisePerplexityApiKey,
}),
).toMatchObject({
baseUrl: "https://api.perplexity.ai",
transport: "search_api",
});
});
it("normalizes direct Perplexity models for chat completions", () => {
expect(isDirectPerplexityBaseUrl("https://api.perplexity.ai")).toBe(true);
expect(isDirectPerplexityBaseUrl("https://openrouter.ai/api/v1")).toBe(false);
expect(resolvePerplexityRequestModel("https://api.perplexity.ai", "perplexity/sonar-pro")).toBe(
"sonar-pro",
);
expect(
resolvePerplexityRequestModel("https://openrouter.ai/api/v1", "perplexity/sonar-pro"),
).toBe("perplexity/sonar-pro");
});
});
describe("web_search brave language param normalization", () => {
it("normalizes and auto-corrects swapped Brave language params", () => {
expect(normalizeBraveLanguageParams({ search_lang: "tr-TR", ui_lang: "tr" })).toEqual({
search_lang: "tr",
ui_lang: "tr-TR",
});
expect(normalizeBraveLanguageParams({ search_lang: "EN", ui_lang: "en-us" })).toEqual({
search_lang: "en",
ui_lang: "en-US",
});
});
it("flags invalid Brave language formats", () => {
expect(normalizeBraveLanguageParams({ search_lang: "en-US" })).toEqual({
invalidField: "search_lang",
});
expect(normalizeBraveLanguageParams({ ui_lang: "en" })).toEqual({
invalidField: "ui_lang",
});
});
});
describe("web_search freshness normalization", () => {
it("accepts Brave shortcut values and maps for Perplexity", () => {
expect(normalizeFreshness("pd", "brave")).toBe("pd");
expect(normalizeFreshness("PW", "brave")).toBe("pw");
expect(normalizeFreshness("pd", "perplexity")).toBe("day");
expect(normalizeFreshness("pw", "perplexity")).toBe("week");
});
it("accepts Perplexity values and maps for Brave", () => {
expect(normalizeFreshness("day", "perplexity")).toBe("day");
expect(normalizeFreshness("week", "perplexity")).toBe("week");
expect(normalizeFreshness("day", "brave")).toBe("pd");
expect(normalizeFreshness("week", "brave")).toBe("pw");
});
it("accepts valid date ranges for Brave", () => {
expect(normalizeFreshness("2024-01-01to2024-01-31", "brave")).toBe("2024-01-01to2024-01-31");
});
it("rejects invalid values", () => {
expect(normalizeFreshness("yesterday", "brave")).toBeUndefined();
expect(normalizeFreshness("yesterday", "perplexity")).toBeUndefined();
expect(normalizeFreshness("2024-01-01to2024-01-31", "perplexity")).toBeUndefined();
});
it("rejects invalid date ranges for Brave", () => {
expect(normalizeFreshness("2024-13-01to2024-01-31", "brave")).toBeUndefined();
expect(normalizeFreshness("2024-02-30to2024-03-01", "brave")).toBeUndefined();
expect(normalizeFreshness("2024-03-10to2024-03-01", "brave")).toBeUndefined();
});
});
const { normalizeToIsoDate } = __testing;
describe("web_search date normalization", () => {
it("accepts ISO format", () => {
@ -186,285 +19,4 @@ describe("web_search date normalization", () => {
expect(normalizeToIsoDate("2024/01/15")).toBeUndefined();
expect(normalizeToIsoDate("invalid")).toBeUndefined();
});
it("converts ISO to Perplexity format", () => {
expect(isoToPerplexityDate("2024-01-15")).toBe("1/15/2024");
expect(isoToPerplexityDate("2025-12-31")).toBe("12/31/2025");
expect(isoToPerplexityDate("2024-03-05")).toBe("3/5/2024");
});
it("rejects invalid ISO dates", () => {
expect(isoToPerplexityDate("1/15/2024")).toBeUndefined();
expect(isoToPerplexityDate("invalid")).toBeUndefined();
});
});
describe("web_search grok config resolution", () => {
it("uses config apiKey when provided", () => {
expect(resolveGrokApiKey({ apiKey: "xai-test-key" })).toBe("xai-test-key"); // pragma: allowlist secret
});
it("returns undefined when no apiKey is available", () => {
withEnv({ XAI_API_KEY: undefined }, () => {
expect(resolveGrokApiKey({})).toBeUndefined();
expect(resolveGrokApiKey(undefined)).toBeUndefined();
});
});
it("uses default model when not specified", () => {
expect(resolveGrokModel({})).toBe("grok-4-1-fast");
expect(resolveGrokModel(undefined)).toBe("grok-4-1-fast");
});
it("uses config model when provided", () => {
expect(resolveGrokModel({ model: "grok-3" })).toBe("grok-3");
});
it("defaults inlineCitations to false", () => {
expect(resolveGrokInlineCitations({})).toBe(false);
expect(resolveGrokInlineCitations(undefined)).toBe(false);
});
it("respects inlineCitations config", () => {
expect(resolveGrokInlineCitations({ inlineCitations: true })).toBe(true);
expect(resolveGrokInlineCitations({ inlineCitations: false })).toBe(false);
});
});
describe("web_search grok response parsing", () => {
it("extracts content from Responses API message blocks", () => {
const result = extractGrokContent({
output: [
{
type: "message",
content: [{ type: "output_text", text: "hello from output" }],
},
],
});
expect(result.text).toBe("hello from output");
expect(result.annotationCitations).toEqual([]);
});
it("extracts url_citation annotations from content blocks", () => {
const result = extractGrokContent({
output: [
{
type: "message",
content: [
{
type: "output_text",
text: "hello with citations",
annotations: [
{
type: "url_citation",
url: "https://example.com/a",
start_index: 0,
end_index: 5,
},
{
type: "url_citation",
url: "https://example.com/b",
start_index: 6,
end_index: 10,
},
{
type: "url_citation",
url: "https://example.com/a",
start_index: 11,
end_index: 15,
}, // duplicate
],
},
],
},
],
});
expect(result.text).toBe("hello with citations");
expect(result.annotationCitations).toEqual(["https://example.com/a", "https://example.com/b"]);
});
it("falls back to deprecated output_text", () => {
const result = extractGrokContent({ output_text: "hello from output_text" });
expect(result.text).toBe("hello from output_text");
expect(result.annotationCitations).toEqual([]);
});
it("returns undefined text when no content found", () => {
const result = extractGrokContent({});
expect(result.text).toBeUndefined();
expect(result.annotationCitations).toEqual([]);
});
it("extracts output_text blocks directly in output array (no message wrapper)", () => {
const result = extractGrokContent({
output: [
{ type: "web_search_call" },
{
type: "output_text",
text: "direct output text",
annotations: [
{
type: "url_citation",
url: "https://example.com/direct",
start_index: 0,
end_index: 5,
},
],
},
],
} as Parameters<typeof extractGrokContent>[0]);
expect(result.text).toBe("direct output text");
expect(result.annotationCitations).toEqual(["https://example.com/direct"]);
});
});
describe("web_search kimi config resolution", () => {
it("uses config apiKey when provided", () => {
expect(resolveKimiApiKey({ apiKey: "kimi-test-key" })).toBe("kimi-test-key"); // pragma: allowlist secret
});
it("falls back to KIMI_API_KEY, then MOONSHOT_API_KEY", () => {
const kimiEnvValue = "kimi-env"; // pragma: allowlist secret
const moonshotEnvValue = "moonshot-env"; // pragma: allowlist secret
withEnv({ [kimiApiKeyEnv]: kimiEnvValue, [moonshotApiKeyEnv]: moonshotEnvValue }, () => {
expect(resolveKimiApiKey({})).toBe(kimiEnvValue);
});
withEnv({ [kimiApiKeyEnv]: undefined, [moonshotApiKeyEnv]: moonshotEnvValue }, () => {
expect(resolveKimiApiKey({})).toBe(moonshotEnvValue);
});
});
it("returns undefined when no Kimi key is configured", () => {
withEnv({ KIMI_API_KEY: undefined, MOONSHOT_API_KEY: undefined }, () => {
expect(resolveKimiApiKey({})).toBeUndefined();
expect(resolveKimiApiKey(undefined)).toBeUndefined();
});
});
it("resolves default model and baseUrl", () => {
expect(resolveKimiModel({})).toBe("moonshot-v1-128k");
expect(resolveKimiBaseUrl({})).toBe("https://api.moonshot.ai/v1");
});
});
describe("extractKimiCitations", () => {
it("collects unique URLs from search_results and tool arguments", () => {
expect(
extractKimiCitations({
search_results: [{ url: "https://example.com/a" }, { url: "https://example.com/a" }],
choices: [
{
message: {
tool_calls: [
{
function: {
arguments: JSON.stringify({
search_results: [{ url: "https://example.com/b" }],
url: "https://example.com/c",
}),
},
},
],
},
},
],
}).toSorted(),
).toEqual(["https://example.com/a", "https://example.com/b", "https://example.com/c"]);
});
});
describe("resolveBraveMode", () => {
it("defaults to 'web' when no config is provided", () => {
expect(resolveBraveMode({})).toBe("web");
});
it("defaults to 'web' when mode is undefined", () => {
expect(resolveBraveMode({ mode: undefined })).toBe("web");
});
it("returns 'llm-context' when configured", () => {
expect(resolveBraveMode({ mode: "llm-context" })).toBe("llm-context");
});
it("returns 'web' when mode is explicitly 'web'", () => {
expect(resolveBraveMode({ mode: "web" })).toBe("web");
});
it("falls back to 'web' for unrecognized mode values", () => {
expect(resolveBraveMode({ mode: "invalid" })).toBe("web");
});
});
describe("mapBraveLlmContextResults", () => {
it("maps plain string snippets correctly", () => {
const results = mapBraveLlmContextResults({
grounding: {
generic: [
{
url: "https://example.com/page",
title: "Example Page",
snippets: ["first snippet", "second snippet"],
},
],
},
});
expect(results).toEqual([
{
url: "https://example.com/page",
title: "Example Page",
snippets: ["first snippet", "second snippet"],
siteName: "example.com",
},
]);
});
it("filters out non-string and empty snippets", () => {
const results = mapBraveLlmContextResults({
grounding: {
generic: [
{
url: "https://example.com",
title: "Test",
snippets: ["valid", "", null, undefined, 42, { text: "object" }] as string[],
},
],
},
});
expect(results[0].snippets).toEqual(["valid"]);
});
it("handles missing snippets array", () => {
const results = mapBraveLlmContextResults({
grounding: {
generic: [{ url: "https://example.com", title: "No Snippets" } as never],
},
});
expect(results[0].snippets).toEqual([]);
});
it("handles empty grounding.generic", () => {
expect(mapBraveLlmContextResults({ grounding: { generic: [] } })).toEqual([]);
});
it("handles missing grounding.generic", () => {
expect(mapBraveLlmContextResults({ grounding: {} } as never)).toEqual([]);
});
it("resolves siteName from URL hostname", () => {
const results = mapBraveLlmContextResults({
grounding: {
generic: [{ url: "https://docs.example.org/path", title: "Docs", snippets: ["text"] }],
},
});
expect(results[0].siteName).toBe("docs.example.org");
});
it("sets siteName to undefined for invalid URLs", () => {
const results = mapBraveLlmContextResults({
grounding: {
generic: [{ url: "not-a-url", title: "Bad URL", snippets: ["text"] }],
},
});
expect(results[0].siteName).toBeUndefined();
});
});

View File

@ -1,7 +1,6 @@
import { Type } from "@sinclair/typebox";
import { formatCliCommand as _formatCliCommand } from "../../cli/command-format.js";
import type { OpenClawConfig } from "../../config/config.js";
import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js";
import { logVerbose } from "../../globals.js";
import { resolveCapabilitySlotSelection } from "../../plugins/capability-slots.js";
import { loadOpenClawPlugins } from "../../plugins/loader.js";
@ -15,15 +14,10 @@ import type {
} from "../../plugins/types.js";
import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.js";
import { wrapWebContent } from "../../security/external-content.js";
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readNumberParam, readStringArrayParam, readStringParam } from "./common.js";
import { withTrustedWebToolsEndpoint } from "./web-guarded-fetch.js";
import { resolveCitationRedirectUrl } from "./web-search-citation-redirect.js";
import {
type BuiltinWebSearchProviderId,
isBuiltinWebSearchProviderId as isBuiltinWebSearchProviderIdFromCatalog,
} from "./web-search-provider-catalog.js";
import {
CacheEntry,
DEFAULT_CACHE_TTL_MINUTES,
@ -38,105 +32,18 @@ import {
const DEFAULT_SEARCH_COUNT = 5;
const MAX_SEARCH_COUNT = 10;
const DEFAULT_PROVIDER = "brave";
const _BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
const _BRAVE_LLM_CONTEXT_ENDPOINT = "https://api.search.brave.com/res/v1/llm/context";
const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1";
const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai";
const PERPLEXITY_SEARCH_ENDPOINT = "https://api.perplexity.ai/search";
const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro";
const PERPLEXITY_KEY_PREFIXES = ["pplx-"];
const OPENROUTER_KEY_PREFIXES = ["sk-or-"];
const XAI_API_ENDPOINT = "https://api.x.ai/v1/responses";
const DEFAULT_GROK_MODEL = "grok-4-1-fast";
const DEFAULT_KIMI_BASE_URL = "https://api.moonshot.ai/v1";
const DEFAULT_KIMI_MODEL = "moonshot-v1-128k";
const KIMI_WEB_SEARCH_TOOL = {
type: "builtin_function",
function: { name: "$web_search" },
} as const;
const SEARCH_CACHE = new Map<string, CacheEntry<Record<string, unknown>>>();
const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]);
const BRAVE_FRESHNESS_RANGE = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/;
const BRAVE_SEARCH_LANG_CODES = new Set([
"ar",
"eu",
"bn",
"bg",
"ca",
"zh-hans",
"zh-hant",
"hr",
"cs",
"da",
"nl",
"en",
"en-gb",
"et",
"fi",
"fr",
"gl",
"de",
"el",
"gu",
"he",
"hi",
"hu",
"is",
"it",
"jp",
"kn",
"ko",
"lv",
"lt",
"ms",
"ml",
"mr",
"nb",
"pl",
"pt-br",
"pt-pt",
"pa",
"ro",
"ru",
"sr",
"sk",
"sl",
"es",
"sv",
"ta",
"te",
"th",
"tr",
"uk",
"vi",
]);
const BRAVE_SEARCH_LANG_ALIASES: Record<string, string> = {
ja: "jp",
zh: "zh-hans",
"zh-cn": "zh-hans",
"zh-hk": "zh-hant",
"zh-sg": "zh-hans",
"zh-tw": "zh-hant",
};
const BRAVE_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i;
const PERPLEXITY_RECENCY_VALUES = new Set(["day", "week", "month", "year"]);
const FRESHNESS_TO_RECENCY: Record<string, string> = {
pd: "day",
pw: "week",
pm: "month",
py: "year",
};
const RECENCY_TO_FRESHNESS: Record<string, string> = {
day: "pd",
week: "pw",
month: "pm",
year: "py",
};
const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
const PERPLEXITY_DATE_PATTERN = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/;
@ -211,15 +118,6 @@ const SEARCH_PLUGIN_EXTENSION_FIELDS = {
),
} as const;
function isoToPerplexityDate(iso: string): string | undefined {
const match = iso.match(ISO_DATE_PATTERN);
if (!match) {
return undefined;
}
const [, year, month, day] = match;
return `${parseInt(month, 10)}/${parseInt(day, 10)}/${year}`;
}
function normalizeToIsoDate(value: string): string | undefined {
const trimmed = value.trim();
if (ISO_DATE_PATTERN.test(trimmed)) {
@ -234,98 +132,6 @@ function normalizeToIsoDate(value: string): string | undefined {
return undefined;
}
function createWebSearchSchema(params: {
provider: BuiltinWebSearchProviderId;
perplexityTransport?: PerplexityTransport;
}) {
const perplexityStructuredFilterSchema = {
country: Type.Optional(
Type.String({
description:
"Native Perplexity Search API only. 2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.",
}),
),
language: Type.Optional(
Type.String({
description:
"Native Perplexity Search API only. ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').",
}),
),
date_after: Type.Optional(
Type.String({
description:
"Native Perplexity Search API only. Only results published after this date (YYYY-MM-DD).",
}),
),
date_before: Type.Optional(
Type.String({
description:
"Native Perplexity Search API only. Only results published before this date (YYYY-MM-DD).",
}),
),
} as const;
if (params.provider === "brave") {
return Type.Object({
...SEARCH_QUERY_SCHEMA_FIELDS,
...SEARCH_FILTER_SCHEMA_FIELDS,
search_lang: Type.Optional(
Type.String({
description:
"Brave language code for search results (e.g., 'en', 'de', 'en-gb', 'zh-hans', 'zh-hant', 'pt-br').",
}),
),
ui_lang: Type.Optional(
Type.String({
description:
"Locale code for UI elements in language-region format (e.g., 'en-US', 'de-DE', 'fr-FR', 'tr-TR'). Must include region subtag.",
}),
),
});
}
if (params.provider === "perplexity") {
if (params.perplexityTransport === "chat_completions") {
return Type.Object({
...SEARCH_QUERY_SCHEMA_FIELDS,
freshness: SEARCH_FILTER_SCHEMA_FIELDS.freshness,
});
}
return Type.Object({
...SEARCH_QUERY_SCHEMA_FIELDS,
freshness: SEARCH_FILTER_SCHEMA_FIELDS.freshness,
...perplexityStructuredFilterSchema,
domain_filter: Type.Optional(
Type.Array(Type.String(), {
description:
"Native Perplexity Search API only. Domain filter (max 20). Allowlist: ['nature.com'] or denylist: ['-reddit.com']. Cannot mix.",
}),
),
max_tokens: Type.Optional(
Type.Number({
description:
"Native Perplexity Search API only. Total content budget across all results (default: 25000, max: 1000000).",
minimum: 1,
maximum: 1000000,
}),
),
max_tokens_per_page: Type.Optional(
Type.Number({
description:
"Native Perplexity Search API only. Max tokens extracted per page (default: 2048).",
minimum: 1,
}),
),
});
}
// grok, gemini, kimi, etc.
return Type.Object({
...SEARCH_QUERY_SCHEMA_FIELDS,
...SEARCH_FILTER_SCHEMA_FIELDS,
});
}
type WebSearchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
? Web extends { search?: infer Search }
? Search
@ -351,32 +157,6 @@ type BraveLlmContextResponse = {
sources?: { url?: string; hostname?: string; date?: string }[];
};
type BraveConfig = {
mode?: string;
};
type PerplexityConfig = {
apiKey?: string;
baseUrl?: string;
model?: string;
};
type PerplexityApiKeySource = "config" | "perplexity_env" | "openrouter_env" | "none";
type PerplexityTransport = "search_api" | "chat_completions";
type PerplexityBaseUrlHint = "direct" | "openrouter";
type GrokConfig = {
apiKey?: string;
model?: string;
inlineCitations?: boolean;
};
type KimiConfig = {
apiKey?: string;
baseUrl?: string;
model?: string;
};
type GrokSearchResponse = {
output?: Array<{
type?: string;
@ -539,11 +319,6 @@ function extractGrokContent(data: GrokSearchResponse): {
return { text, annotationCitations: [] };
}
type GeminiConfig = {
apiKey?: string;
model?: string;
};
type GeminiGroundingResponse = {
candidates?: Array<{
content?: {
@ -592,166 +367,6 @@ function resolveSearchEnabled(params: { search?: WebSearchConfig; sandboxed?: bo
return true;
}
function resolveSearchApiKey(search?: WebSearchConfig): string | undefined {
const fromConfigRaw =
search && "apiKey" in search
? normalizeResolvedSecretInputString({
value: search.apiKey,
path: "tools.web.search.apiKey",
})
: undefined;
const fromConfig = normalizeSecretInput(fromConfigRaw);
const fromEnv = normalizeSecretInput(process.env.BRAVE_API_KEY);
return fromConfig || fromEnv || undefined;
}
function resolveBuiltinSearchProvider(search?: WebSearchConfig): BuiltinWebSearchProviderId {
const raw =
search && "provider" in search && typeof search.provider === "string"
? search.provider.trim().toLowerCase()
: "";
if (isBuiltinSearchProviderId(raw)) {
return raw;
}
// Auto-detect provider from available API keys (alphabetical order)
if (raw === "") {
// Brave
if (resolveSearchApiKey(search)) {
logVerbose(
'web_search: no provider configured, auto-detected "brave" from available API keys',
);
return "brave";
}
// Gemini
const geminiConfig = resolveGeminiConfig(search);
if (resolveGeminiApiKey(geminiConfig)) {
logVerbose(
'web_search: no provider configured, auto-detected "gemini" from available API keys',
);
return "gemini";
}
// Grok
const grokConfig = resolveGrokConfig(search);
if (resolveGrokApiKey(grokConfig)) {
logVerbose(
'web_search: no provider configured, auto-detected "grok" from available API keys',
);
return "grok";
}
// Kimi
const kimiConfig = resolveKimiConfig(search);
if (resolveKimiApiKey(kimiConfig)) {
logVerbose(
'web_search: no provider configured, auto-detected "kimi" from available API keys',
);
return "kimi";
}
// Perplexity
const perplexityConfig = resolvePerplexityConfig(search);
const { apiKey: perplexityKey } = resolvePerplexityApiKey(perplexityConfig);
if (perplexityKey) {
logVerbose(
'web_search: no provider configured, auto-detected "perplexity" from available API keys',
);
return "perplexity";
}
}
return "brave";
}
function resolveBraveMode(brave: BraveConfig): "web" | "llm-context" {
return brave.mode === "llm-context" ? "llm-context" : "web";
}
function resolvePerplexityConfig(search?: WebSearchConfig): PerplexityConfig {
if (!search || typeof search !== "object") {
return {};
}
const perplexity = "perplexity" in search ? search.perplexity : undefined;
if (!perplexity || typeof perplexity !== "object") {
return {};
}
return perplexity as PerplexityConfig;
}
function resolvePerplexityApiKey(perplexity?: PerplexityConfig): {
apiKey?: string;
source: PerplexityApiKeySource;
} {
const fromConfig = normalizeApiKey(perplexity?.apiKey);
if (fromConfig) {
return { apiKey: fromConfig, source: "config" };
}
const fromEnvPerplexity = normalizeApiKey(process.env.PERPLEXITY_API_KEY);
if (fromEnvPerplexity) {
return { apiKey: fromEnvPerplexity, source: "perplexity_env" };
}
const fromEnvOpenRouter = normalizeApiKey(process.env.OPENROUTER_API_KEY);
if (fromEnvOpenRouter) {
return { apiKey: fromEnvOpenRouter, source: "openrouter_env" };
}
return { apiKey: undefined, source: "none" };
}
function normalizeApiKey(key: unknown): string {
return normalizeSecretInput(key);
}
function inferPerplexityBaseUrlFromApiKey(apiKey?: string): PerplexityBaseUrlHint | undefined {
if (!apiKey) {
return undefined;
}
const normalized = apiKey.toLowerCase();
if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
return "direct";
}
if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
return "openrouter";
}
return undefined;
}
function resolvePerplexityBaseUrl(
perplexity?: PerplexityConfig,
authSource: PerplexityApiKeySource = "none", // pragma: allowlist secret
configuredKey?: string,
): string {
const fromConfig =
perplexity && "baseUrl" in perplexity && typeof perplexity.baseUrl === "string"
? perplexity.baseUrl.trim()
: "";
if (fromConfig) {
return fromConfig;
}
if (authSource === "perplexity_env") {
return PERPLEXITY_DIRECT_BASE_URL;
}
if (authSource === "openrouter_env") {
return DEFAULT_PERPLEXITY_BASE_URL;
}
if (authSource === "config") {
const inferred = inferPerplexityBaseUrlFromApiKey(configuredKey);
if (inferred === "openrouter") {
return DEFAULT_PERPLEXITY_BASE_URL;
}
return PERPLEXITY_DIRECT_BASE_URL;
}
return DEFAULT_PERPLEXITY_BASE_URL;
}
function resolvePerplexityModel(perplexity?: PerplexityConfig): string {
const fromConfig =
perplexity && "model" in perplexity && typeof perplexity.model === "string"
? perplexity.model.trim()
: "";
return fromConfig || DEFAULT_PERPLEXITY_MODEL;
}
function isDirectPerplexityBaseUrl(baseUrl: string): boolean {
const trimmed = baseUrl.trim();
if (!trimmed) {
@ -771,125 +386,6 @@ function resolvePerplexityRequestModel(baseUrl: string, model: string): string {
return model.startsWith("perplexity/") ? model.slice("perplexity/".length) : model;
}
function resolvePerplexityTransport(perplexity?: PerplexityConfig): {
apiKey?: string;
source: PerplexityApiKeySource;
baseUrl: string;
model: string;
transport: PerplexityTransport;
} {
const auth = resolvePerplexityApiKey(perplexity);
const baseUrl = resolvePerplexityBaseUrl(perplexity, auth.source, auth.apiKey);
const model = resolvePerplexityModel(perplexity);
const hasLegacyOverride = Boolean(
(perplexity?.baseUrl && perplexity.baseUrl.trim()) ||
(perplexity?.model && perplexity.model.trim()),
);
return {
...auth,
baseUrl,
model,
transport:
hasLegacyOverride || !isDirectPerplexityBaseUrl(baseUrl) ? "chat_completions" : "search_api",
};
}
function resolvePerplexitySchemaTransportHint(
perplexity?: PerplexityConfig,
): PerplexityTransport | undefined {
const hasLegacyOverride = Boolean(
(perplexity?.baseUrl && perplexity.baseUrl.trim()) ||
(perplexity?.model && perplexity.model.trim()),
);
return hasLegacyOverride ? "chat_completions" : undefined;
}
function resolveGrokConfig(search?: WebSearchConfig): GrokConfig {
if (!search || typeof search !== "object") {
return {};
}
const grok = "grok" in search ? search.grok : undefined;
if (!grok || typeof grok !== "object") {
return {};
}
return grok as GrokConfig;
}
function resolveGrokApiKey(grok?: GrokConfig): string | undefined {
const fromConfig = normalizeApiKey(grok?.apiKey);
if (fromConfig) {
return fromConfig;
}
const fromEnv = normalizeApiKey(process.env.XAI_API_KEY);
return fromEnv || undefined;
}
function resolveGrokModel(grok?: GrokConfig): string {
const fromConfig =
grok && "model" in grok && typeof grok.model === "string" ? grok.model.trim() : "";
return fromConfig || DEFAULT_GROK_MODEL;
}
function resolveGrokInlineCitations(grok?: GrokConfig): boolean {
return grok?.inlineCitations === true;
}
function resolveKimiConfig(search?: WebSearchConfig): KimiConfig {
if (!search || typeof search !== "object") {
return {};
}
const kimi = "kimi" in search ? search.kimi : undefined;
if (!kimi || typeof kimi !== "object") {
return {};
}
return kimi as KimiConfig;
}
function resolveKimiApiKey(kimi?: KimiConfig): string | undefined {
const fromConfig = normalizeApiKey(kimi?.apiKey);
if (fromConfig) {
return fromConfig;
}
const fromEnvKimi = normalizeApiKey(process.env.KIMI_API_KEY);
if (fromEnvKimi) {
return fromEnvKimi;
}
const fromEnvMoonshot = normalizeApiKey(process.env.MOONSHOT_API_KEY);
return fromEnvMoonshot || undefined;
}
function resolveKimiModel(kimi?: KimiConfig): string {
const fromConfig =
kimi && "model" in kimi && typeof kimi.model === "string" ? kimi.model.trim() : "";
return fromConfig || DEFAULT_KIMI_MODEL;
}
function resolveKimiBaseUrl(kimi?: KimiConfig): string {
const fromConfig =
kimi && "baseUrl" in kimi && typeof kimi.baseUrl === "string" ? kimi.baseUrl.trim() : "";
return fromConfig || DEFAULT_KIMI_BASE_URL;
}
function resolveGeminiConfig(search?: WebSearchConfig): GeminiConfig {
if (!search || typeof search !== "object") {
return {};
}
const gemini = "gemini" in search ? search.gemini : undefined;
if (!gemini || typeof gemini !== "object") {
return {};
}
return gemini as GeminiConfig;
}
function resolveGeminiApiKey(gemini?: GeminiConfig): string | undefined {
const fromConfig = normalizeApiKey(gemini?.apiKey);
if (fromConfig) {
return fromConfig;
}
const fromEnv = normalizeApiKey(process.env.GEMINI_API_KEY);
return fromEnv || undefined;
}
async function withTrustedWebSearchEndpoint<T>(
params: {
url: string;
@ -1002,107 +498,6 @@ function resolveSearchCount(value: unknown, fallback: number): number {
return clamped;
}
function normalizeBraveSearchLang(value: string | undefined): string | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const canonical = BRAVE_SEARCH_LANG_ALIASES[trimmed.toLowerCase()] ?? trimmed.toLowerCase();
if (!BRAVE_SEARCH_LANG_CODES.has(canonical)) {
return undefined;
}
return canonical;
}
function normalizeBraveUiLang(value: string | undefined): string | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const match = trimmed.match(BRAVE_UI_LANG_LOCALE);
if (!match) {
return undefined;
}
const [, language, region] = match;
return `${language.toLowerCase()}-${region.toUpperCase()}`;
}
function normalizeBraveLanguageParams(params: { search_lang?: string; ui_lang?: string }): {
search_lang?: string;
ui_lang?: string;
invalidField?: "search_lang" | "ui_lang";
} {
const rawSearchLang = params.search_lang?.trim() || undefined;
const rawUiLang = params.ui_lang?.trim() || undefined;
let searchLangCandidate = rawSearchLang;
let uiLangCandidate = rawUiLang;
// Recover common LLM mix-up: locale in search_lang + short code in ui_lang.
if (normalizeBraveUiLang(rawSearchLang) && normalizeBraveSearchLang(rawUiLang)) {
searchLangCandidate = rawUiLang;
uiLangCandidate = rawSearchLang;
}
const search_lang = normalizeBraveSearchLang(searchLangCandidate);
if (searchLangCandidate && !search_lang) {
return { invalidField: "search_lang" };
}
const ui_lang = normalizeBraveUiLang(uiLangCandidate);
if (uiLangCandidate && !ui_lang) {
return { invalidField: "ui_lang" };
}
return { search_lang, ui_lang };
}
/**
* Normalizes freshness shortcut to the provider's expected format.
* Accepts both Brave format (pd/pw/pm/py) and Perplexity format (day/week/month/year).
* For Brave, also accepts date ranges (YYYY-MM-DDtoYYYY-MM-DD).
*/
function normalizeFreshness(
value: string | undefined,
provider: BuiltinWebSearchProviderId,
): string | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const lower = trimmed.toLowerCase();
if (BRAVE_FRESHNESS_SHORTCUTS.has(lower)) {
return provider === "brave" ? lower : FRESHNESS_TO_RECENCY[lower];
}
if (PERPLEXITY_RECENCY_VALUES.has(lower)) {
return provider === "perplexity" ? lower : RECENCY_TO_FRESHNESS[lower];
}
// Brave date range support
if (provider === "brave") {
const match = trimmed.match(BRAVE_FRESHNESS_RANGE);
if (match) {
const [, start, end] = match;
if (isValidIsoDate(start) && isValidIsoDate(end) && start <= end) {
return `${start}to${end}`;
}
}
}
return undefined;
}
function isValidIsoDate(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return false;
@ -1556,10 +951,6 @@ function normalizeSearchProviderId(value: string | undefined): string {
return value?.trim().toLowerCase() ?? "";
}
function isBuiltinSearchProviderId(value: string): value is BuiltinWebSearchProviderId {
return isBuiltinWebSearchProviderIdFromCatalog(value);
}
function stableSerializeForCache(value: unknown): string {
if (value === null || value === undefined) {
return String(value);
@ -1677,7 +1068,7 @@ function executePluginSearchProvider(params: {
? stableSerializeForCache(params.context.pluginConfig)
: "no-plugin-config";
const cacheKey = normalizeCacheKey(
`${params.provider.id}:${params.provider.pluginId || "builtin"}:${pluginConfigKey}:${buildSearchRequestCacheIdentity(
`${params.provider.id}:${params.provider.pluginId || "unregistered"}:${pluginConfigKey}:${buildSearchRequestCacheIdentity(
{
query: params.request.query,
count: params.request.count,
@ -1800,13 +1191,6 @@ function getRegisteredSearchProviders(config?: OpenClawConfig): SearchProviderPl
return registry.searchProviders.map((entry) => entry.provider);
}
function resolveBuiltinSchemaProviderId(
provider: SearchProviderPlugin,
): BuiltinWebSearchProviderId | undefined {
const candidate = normalizeSearchProviderId(provider.id);
return isBuiltinSearchProviderId(candidate) ? candidate : undefined;
}
function resolveConfiguredSearchProviderId(params: {
config?: OpenClawConfig;
search?: WebSearchConfig;
@ -1823,36 +1207,15 @@ function resolveConfiguredSearchProviderId(params: {
);
}
function resolvePreferredBuiltinSearchProvider(params: {
search?: WebSearchConfig;
runtimeWebSearch?: RuntimeWebSearchMetadata;
config?: OpenClawConfig;
}): BuiltinWebSearchProviderId {
const configuredProviderId = normalizeSearchProviderId(
resolveConfiguredSearchProviderId({
config: params.config,
search: params.search,
}) ?? undefined,
);
if (isBuiltinSearchProviderId(configuredProviderId)) {
return configuredProviderId;
}
if (
params.runtimeWebSearch?.providerConfigured &&
params.runtimeWebSearch.providerConfigured === configuredProviderId
) {
return params.runtimeWebSearch.providerConfigured;
}
if (
params.runtimeWebSearch?.selectedProvider &&
params.runtimeWebSearch.providerSource !== "none"
) {
return params.runtimeWebSearch.selectedProvider;
}
return resolveBuiltinSearchProvider(params.search);
function sortRegisteredSearchProviders(providers: SearchProviderPlugin[]): SearchProviderPlugin[] {
return [...providers].toSorted((left, right) => {
const leftPriority = left.setup?.autodetectPriority ?? Number.MAX_SAFE_INTEGER;
const rightPriority = right.setup?.autodetectPriority ?? Number.MAX_SAFE_INTEGER;
if (leftPriority !== rightPriority) {
return leftPriority - rightPriority;
}
return left.id.localeCompare(right.id);
});
}
function resolveRegisteredSearchProvider(params: {
@ -1867,7 +1230,7 @@ function resolveRegisteredSearchProvider(params: {
}) ?? undefined,
);
const registeredProviders = new Map(
getRegisteredSearchProviders(params.config).map((provider) => [
sortRegisteredSearchProviders(getRegisteredSearchProviders(params.config)).map((provider) => [
normalizeSearchProviderId(provider.id),
provider,
]),
@ -1901,16 +1264,11 @@ function resolveRegisteredSearchProvider(params: {
}
}
}
const preferredBuiltinProvider = resolvePreferredBuiltinSearchProvider({
config: params.config,
search: params.search,
runtimeWebSearch: params.runtimeWebSearch,
});
return (
registeredProviders.get(preferredBuiltinProvider) ??
registeredProviders.get(DEFAULT_PROVIDER) ??
createMissingSearchProviderPlugin(preferredBuiltinProvider)
);
const firstRegisteredProvider = registeredProviders.values().next().value;
if (firstRegisteredProvider) {
return firstRegisteredProvider;
}
return createMissingSearchProviderPlugin(configuredProviderId || "web-search");
}
function createSearchProviderSchema(params: {
@ -1918,17 +1276,17 @@ function createSearchProviderSchema(params: {
search?: WebSearchConfig;
runtimeWebSearch?: RuntimeWebSearchMetadata;
}) {
const providerId = resolveBuiltinSchemaProviderId(params.provider);
if (providerId) {
const perplexityTransport =
params.runtimeWebSearch?.selectedProvider === "perplexity"
? params.runtimeWebSearch.perplexityTransport
: resolvePerplexitySchemaTransportHint(resolvePerplexityConfig(params.search));
return createWebSearchSchema({
provider: providerId,
perplexityTransport: providerId === "perplexity" ? perplexityTransport : undefined,
if (params.provider.setup?.resolveRequestSchema) {
return params.provider.setup.resolveRequestSchema({
config: params.search
? ({ tools: { web: { search: params.search } } } as OpenClawConfig)
: undefined,
runtimeMetadata: params.runtimeWebSearch as Record<string, unknown> | undefined,
});
}
if (params.provider.setup?.requestSchema) {
return params.provider.setup.requestSchema;
}
return createExtensibleWebSearchSchema();
}
@ -1986,7 +1344,7 @@ function formatWebSearchExecutionLog(provider: SearchProviderPlugin): string {
if (provider.pluginId) {
return `web_search: executing plugin provider "${provider.id}" from "${provider.pluginId}"`;
}
return `web_search: executing built-in provider "${provider.id}"`;
return `web_search: executing registered provider "${provider.id}"`;
}
export function createWebSearchTool(options?: {
@ -2073,31 +1431,9 @@ export function createWebSearchTool(options?: {
}
export const __testing = {
resolveSearchProvider: resolveBuiltinSearchProvider,
resolveSearchProvider: resolveRegisteredSearchProvider,
resolveRegisteredSearchProvider,
inferPerplexityBaseUrlFromApiKey,
resolvePerplexityBaseUrl,
resolvePerplexityModel,
resolvePerplexityTransport,
isDirectPerplexityBaseUrl,
resolvePerplexityRequestModel,
resolvePerplexityApiKey,
normalizeBraveLanguageParams,
normalizeFreshness,
normalizeToIsoDate,
isoToPerplexityDate,
SEARCH_CACHE,
FRESHNESS_TO_RECENCY,
RECENCY_TO_FRESHNESS,
resolveGrokApiKey,
resolveGrokModel,
resolveGrokInlineCitations,
extractGrokContent,
resolveKimiApiKey,
resolveKimiModel,
resolveKimiBaseUrl,
extractKimiCitations,
resolveRedirectUrl: resolveCitationRedirectUrl,
resolveBraveMode,
mapBraveLlmContextResults,
} as const;

View File

@ -234,7 +234,7 @@ describe("web tools defaults", () => {
expect(tool?.name).toBe("web_search");
});
it("uses the configured built-in web_search provider from config", async () => {
it("uses the configured web_search provider from config", async () => {
const mockFetch = installMockFetch(createProviderSuccessPayload("brave"));
const tool = createWebSearchTool({
config: {
@ -263,7 +263,7 @@ describe("web tools defaults", () => {
describe("web_search plugin providers", () => {
it.each(["brave", "perplexity", "grok", "gemini", "kimi"] as const)(
"resolves configured built-in provider %s through bundled plugin registrations when available",
"resolves configured provider %s through plugin registrations when available",
async (providerId) => {
const registry = createEmptyPluginRegistry();
const bundledProvider = BUNDLED_PROVIDER_CREATORS[providerId]();
@ -328,7 +328,7 @@ describe("web_search plugin providers", () => {
},
);
it("prefers an explicitly configured plugin provider over a built-in provider with the same id", async () => {
it("prefers an explicitly configured plugin provider over another registered provider with the same id", async () => {
const searchMock = vi.fn(async () => ({
results: [
{
@ -386,7 +386,7 @@ describe("web_search plugin providers", () => {
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 () => {
it("keeps an explicitly configured plugin provider even when other provider credentials are also present", async () => {
const searchMock = vi.fn(async () => ({
content: "Plugin-configured answer",
citations: ["https://example.com/plugin-configured"],
@ -437,7 +437,7 @@ describe("web_search plugin providers", () => {
expect(details?.citations).toEqual(["https://example.com/plugin-configured"]);
});
it("auto-detects plugin providers before built-in API key detection", async () => {
it("auto-detects registered providers before falling back to later detection candidates", async () => {
vi.stubEnv("BRAVE_API_KEY", "test-brave-key"); // pragma: allowlist secret
const searchMock = vi.fn(async () => ({
content: "Plugin answer",

View File

@ -454,7 +454,7 @@ describe("runConfigureWizard", () => {
);
});
it("configures a bundled plugin search provider from configure without the external install step", async () => {
it("configures a manifest-discovered search provider from configure without a separate install step", async () => {
loadOpenClawPlugins.mockImplementation(({ config }: { config: OpenClawConfig }) => {
const enabled = config.plugins?.entries?.["tavily-search"]?.enabled === true;
return enabled
@ -509,6 +509,11 @@ describe("runConfigureWizard", () => {
provides: ["providers.search.tavily"],
origin: "bundled",
source: "/tmp/bundled/tavily-search",
packageInstall: {
npmSpec: "@openclaw/tavily-search",
localPath: "extensions/tavily-search",
defaultChoice: "local",
},
configSchema: {
type: "object",
required: ["apiKey"],

View File

@ -186,7 +186,7 @@ describe("setupSearch", () => {
);
});
it("shows bundled plugin providers directly in the picker while keeping the external install path available", async () => {
it("shows manifest-discovered providers directly in the picker while keeping install available", async () => {
loadOpenClawPlugins.mockReturnValue({
searchProviders: [],
plugins: [],
@ -201,6 +201,11 @@ describe("setupSearch", () => {
provides: ["providers.search.tavily"],
origin: "bundled",
source: "/tmp/bundled/tavily-search",
packageInstall: {
npmSpec: "@openclaw/tavily-search",
localPath: "extensions/tavily-search",
defaultChoice: "local",
},
configSchema: {
type: "object",
required: ["apiKey"],
@ -255,7 +260,7 @@ describe("setupSearch", () => {
["kimi", "Kimi (Moonshot)"],
["perplexity", "Perplexity Search"],
] as const)(
"does not duplicate built-in provider %s when a bundled search plugin registers the same provider id",
"does not duplicate registered provider %s when a search plugin registers the same provider id",
async (providerId, providerLabel) => {
loadOpenClawPlugins.mockReturnValue({
searchProviders: [
@ -534,13 +539,13 @@ describe("setupSearch", () => {
expect.objectContaining({
options: expect.arrayContaining([
expect.objectContaining({
value: "brave",
label: "Brave Search [Active]",
value: "__keep_current__",
label: "Keep current provider (brave)",
}),
expect.objectContaining({
value: "__install_plugin__",
label: "Install provider plugin",
hint: "Add a web search plugin",
hint: "Install a web search plugin from npm or a local path",
}),
]),
}),
@ -675,7 +680,7 @@ describe("setupSearch", () => {
);
});
it("shows a provider setup note from before_search_provider_configure hooks", async () => {
it("shows a provider setup note from before_provider_configure hooks", async () => {
loadOpenClawPlugins.mockReturnValue({
searchProviders: [
{
@ -719,13 +724,6 @@ describe("setupSearch", () => {
source: "/tmp/tavily-search",
handler: () => ({ note: "Generic provider guidance." }),
},
{
pluginId: "tavily-search",
hookName: "before_search_provider_configure",
priority: 0,
source: "/tmp/tavily-search",
handler: () => ({ note: "Read the provider docs before entering your key." }),
},
],
});
@ -746,8 +744,7 @@ describe("setupSearch", () => {
);
});
it("fires after_search_provider_activate only when the active provider changes", async () => {
const afterActivate = vi.fn();
it("fires after_provider_activate only when the active provider changes", async () => {
const afterProviderActivate = vi.fn();
loadOpenClawPlugins.mockReturnValue({
searchProviders: [
@ -781,13 +778,6 @@ describe("setupSearch", () => {
source: "/tmp/tavily-search",
handler: afterProviderActivate,
},
{
pluginId: "tavily-search",
hookName: "after_search_provider_activate",
priority: 0,
source: "/tmp/tavily-search",
handler: afterActivate,
},
],
});
@ -833,85 +823,6 @@ describe("setupSearch", () => {
workspaceDir: undefined,
}),
);
expect(afterActivate).not.toHaveBeenCalled();
});
it("fires legacy after_search_provider_activate hooks when no generic provider hook is registered", async () => {
const afterActivate = vi.fn();
loadOpenClawPlugins.mockReturnValue({
searchProviders: [
{
pluginId: "tavily-search",
provider: {
id: "tavily",
name: "Tavily Search",
description: "Plugin search",
isAvailable: () => true,
search: async () => ({ content: "ok" }),
},
},
],
plugins: [
{
id: "tavily-search",
name: "Tavily Search",
description: "External Tavily plugin",
origin: "workspace",
source: "/tmp/tavily-search",
configJsonSchema: undefined,
configUiHints: undefined,
},
],
typedHooks: [
{
pluginId: "tavily-search",
hookName: "after_search_provider_activate",
priority: 0,
source: "/tmp/tavily-search",
handler: afterActivate,
},
],
});
const cfg: OpenClawConfig = {
tools: {
web: {
search: {
provider: "brave",
enabled: true,
apiKey: "BSA-test-key",
},
},
},
plugins: {
entries: {
"tavily-search": {
enabled: true,
config: {
apiKey: "tvly-existing-key",
},
},
},
},
};
const { prompter } = createPrompter({
selectValue: "tavily",
});
const result = await setupSearch(cfg, runtime, prompter);
expect(result.tools?.web?.search?.provider).toBe("tavily");
expect(afterActivate).toHaveBeenCalledWith(
expect.objectContaining({
providerId: "tavily",
previousProviderId: "brave",
intent: "switch-active",
}),
expect.objectContaining({
workspaceDir: undefined,
}),
);
});
it("re-prompts invalid plugin config values before saving", async () => {
@ -1146,7 +1057,7 @@ describe("setupSearch", () => {
});
});
it("installs an external search plugin and continues provider setup for the discovered provider", async () => {
it("installs a search plugin and continues provider setup for the discovered provider", async () => {
loadOpenClawPlugins.mockImplementation(({ config }: { config: OpenClawConfig }) => {
const enabled = config.plugins?.entries?.["external-search"]?.enabled === true;
return enabled

View File

@ -18,7 +18,6 @@ import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
import type {
PluginConfigUiHint,
SearchProviderCredentialMetadata,
SearchProviderLegacyConfigMetadata,
SearchProviderSetupMetadata,
} from "../plugins/types.js";
import type { RuntimeEnv } from "../runtime.js";
@ -28,15 +27,12 @@ import {
ensureGenericOnboardingPluginInstalled,
reloadOnboardingPluginRegistry,
} from "./onboarding/plugin-install.js";
import type { InstallablePluginCatalogEntry } from "./onboarding/plugin-install.js";
import {
buildProviderSelectionOptions,
promptProviderManagementIntent,
type ProviderManagementIntent,
} from "./provider-management.js";
import {
SEARCH_PROVIDER_PLUGIN_INSTALL_CATALOG,
type InstallableSearchProviderPluginCatalogEntry,
} from "./search-provider-plugin-catalog.js";
export type SearchProvider = string;
@ -66,6 +62,11 @@ export type SearchProviderPickerEntry = PluginSearchProviderEntry;
type SearchProviderPickerChoice = string;
type SearchProviderFlowIntent = ProviderManagementIntent;
type InstallableSearchProviderPluginCatalogEntry = InstallablePluginCatalogEntry & {
providerId: string;
description: string;
};
type PluginPromptableField =
| {
key: string;
@ -120,18 +121,8 @@ function humanizeConfigKey(value: string): string {
function resolveProviderSetupMetadata(
setup?: SearchProviderSetupMetadata,
legacyConfig?: SearchProviderLegacyConfigMetadata,
): SearchProviderSetupMetadata | undefined {
if (setup) {
return setup;
}
if (!legacyConfig) {
return undefined;
}
return {
hint: legacyConfig.hint,
credentials: legacyConfig,
};
return setup;
}
function resolveProviderCredentialMetadata(
@ -140,18 +131,52 @@ function resolveProviderCredentialMetadata(
return setup?.credentials;
}
export function resolveInstallableSearchProviderPlugins(
providerEntries: SearchProviderPickerEntry[],
): InstallableSearchProviderPluginCatalogEntry[] {
function normalizeInstallMetadata(install?: {
npmSpec?: string;
localPath?: string;
defaultChoice?: "npm" | "local";
}): InstallablePluginCatalogEntry["install"] | undefined {
if (!install?.npmSpec) {
return undefined;
}
return {
npmSpec: install.npmSpec,
...(install.localPath ? { localPath: install.localPath } : {}),
...(install.defaultChoice ? { defaultChoice: install.defaultChoice } : {}),
};
}
export function resolveInstallableSearchProviderPlugins(params: {
config: OpenClawConfig;
providerEntries: SearchProviderPickerEntry[];
workspaceDir?: string;
}): InstallableSearchProviderPluginCatalogEntry[] {
const loadedPluginProviderIds = new Set(
providerEntries.filter((entry) => entry.kind === "plugin").map((entry) => entry.value),
params.providerEntries.filter((entry) => entry.kind === "plugin").map((entry) => entry.value),
);
return SEARCH_PROVIDER_PLUGIN_INSTALL_CATALOG.filter(
(entry) => !loadedPluginProviderIds.has(entry.providerId),
).map((entry) => ({
...entry,
description: entry.description,
}));
const registry = loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
cache: false,
});
return registry.plugins
.map((plugin) => {
const providerId = searchProviderIdFromProvides(plugin.provides);
const install = normalizeInstallMetadata(plugin.packageInstall);
if (!providerId || !install?.npmSpec || loadedPluginProviderIds.has(providerId)) {
return undefined;
}
return {
id: plugin.id,
providerId,
meta: {
label: plugin.name || providerId,
},
description: plugin.description || "Install a web search provider plugin.",
install,
} satisfies InstallableSearchProviderPluginCatalogEntry;
})
.filter((entry): entry is InstallableSearchProviderPluginCatalogEntry => Boolean(entry));
}
function normalizePluginConfigObject(value: unknown): Record<string, unknown> {
@ -545,10 +570,7 @@ export async function resolveSearchProviderPickerEntries(
configured = false;
}
const setup = resolveProviderSetupMetadata(
registration.provider.setup,
registration.provider.legacyConfig,
);
const setup = resolveProviderSetupMetadata(registration.provider.setup);
const baseHint =
setup?.hint?.trim() ||
registration.provider.description?.trim() ||
@ -581,19 +603,14 @@ export async function resolveSearchProviderPickerEntries(
}
try {
loadPluginManifestRegistry({
const registry = loadPluginManifestRegistry({
config,
workspaceDir,
cache: false,
});
const loadedPluginProviderIds = new Set(pluginEntries.map((entry) => entry.value));
const bundledManifestEntries = SEARCH_PROVIDER_PLUGIN_INSTALL_CATALOG.map((installEntry) =>
buildPluginSearchProviderEntryFromManifest({
config,
installEntry,
workspaceDir,
}),
)
const manifestEntries = registry.plugins
.map((plugin) => buildPluginSearchProviderEntryFromManifestRecord(plugin))
.filter(
(entry): entry is PluginSearchProviderEntry =>
Boolean(entry) && !loadedPluginProviderIds.has(entry.value),
@ -606,7 +623,7 @@ export async function resolveSearchProviderPickerEntries(
configured: validation.ok,
};
});
pluginEntries = [...pluginEntries, ...bundledManifestEntries].toSorted((left, right) =>
pluginEntries = [...pluginEntries, ...manifestEntries].toSorted((left, right) =>
left.label.localeCompare(right.label),
);
} catch {
@ -638,6 +655,11 @@ function buildPluginSearchProviderEntryFromManifestRecord(pluginRecord: {
configSchema?: Record<string, unknown>;
configUiHints?: Record<string, PluginConfigUiHint>;
provides: string[];
packageInstall?: {
npmSpec?: string;
localPath?: string;
defaultChoice?: "npm" | "local";
};
}): PluginSearchProviderEntry | undefined {
const providerId = searchProviderIdFromProvides(pluginRecord.provides);
if (!providerId) {
@ -656,27 +678,13 @@ function buildPluginSearchProviderEntryFromManifestRecord(pluginRecord: {
configFieldOrder: undefined,
configJsonSchema: pluginRecord.configSchema,
configUiHints: pluginRecord.configUiHints,
setup: undefined,
setup: (() => {
const install = normalizeInstallMetadata(pluginRecord.packageInstall);
return install ? { install } : undefined;
})(),
};
}
function buildPluginSearchProviderEntryFromManifest(params: {
config: OpenClawConfig;
installEntry: InstallableSearchProviderPluginCatalogEntry;
workspaceDir?: string;
}): PluginSearchProviderEntry | undefined {
const registry = loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
cache: false,
});
const pluginRecord = registry.plugins.find((plugin) => plugin.id === params.installEntry.id);
if (!pluginRecord) {
return undefined;
}
return buildPluginSearchProviderEntryFromManifestRecord(pluginRecord);
}
async function installSearchProviderPlugin(params: {
config: OpenClawConfig;
runtime: RuntimeEnv;
@ -838,6 +846,7 @@ type SearchProviderPickerModelParams = {
providerEntries: SearchProviderPickerEntry[];
includeSkipOption: boolean;
skipHint?: string;
workspaceDir?: string;
};
type SearchProviderPickerModel = {
@ -869,7 +878,7 @@ function formatPickerEntryHint(params: {
export function buildSearchProviderPickerModel(
params: SearchProviderPickerModelParams,
): SearchProviderPickerModel {
const { config, providerEntries, includeSkipOption, skipHint } = params;
const { config, providerEntries, includeSkipOption, skipHint, workspaceDir } = params;
const existingProvider = resolveCapabilitySlotSelection(config, "providers.search");
const existingPluginProvider =
typeof existingProvider === "string" && existingProvider.trim() ? existingProvider : undefined;
@ -908,7 +917,11 @@ export function buildSearchProviderPickerModel(
sortedEntries[0]?.value ??
SEARCH_PROVIDER_SKIP_SENTINEL;
const installableEntries = resolveInstallableSearchProviderPlugins(providerEntries);
const installableEntries = resolveInstallableSearchProviderPlugins({
config,
providerEntries,
workspaceDir,
});
const options: Array<{ value: SearchProviderPickerChoice; label: string; hint?: string }> = [
...(unloadedExistingPluginProvider
? [
@ -1228,6 +1241,7 @@ export async function promptSearchProviderFlow(params: {
providerEntries,
includeSkipOption: params.includeSkipOption,
skipHint: params.skipHint,
workspaceDir: params.opts?.workspaceDir,
});
const action = await promptProviderManagementIntent({
prompter: params.prompter,

View File

@ -1,10 +0,0 @@
import { tavilySearchInstallCatalogEntry } from "../../extensions/tavily-search/install-catalog.js";
import type { InstallablePluginCatalogEntry } from "./onboarding/plugin-install.js";
export type InstallableSearchProviderPluginCatalogEntry = InstallablePluginCatalogEntry & {
providerId: string;
description: string;
};
export const SEARCH_PROVIDER_PLUGIN_INSTALL_CATALOG: readonly InstallableSearchProviderPluginCatalogEntry[] =
[tavilySearchInstallCatalogEntry];

View File

@ -1,4 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createBundledBraveSearchProvider } from "../../extensions/search-brave/src/provider.js";
import { createBundledGeminiSearchProvider } from "../../extensions/search-gemini/src/provider.js";
import { createBundledGrokSearchProvider } from "../../extensions/search-grok/src/provider.js";
import { createBundledKimiSearchProvider } from "../../extensions/search-kimi/src/provider.js";
import { createBundledPerplexitySearchProvider } from "../../extensions/search-perplexity/src/provider.js";
import { validateConfigObject, validateConfigObjectWithPlugins } from "./config.js";
import { buildWebSearchProviderConfig } from "./test-helpers.js";
@ -20,6 +25,26 @@ vi.mock("@mariozechner/pi-ai/oauth", () => ({
const { __testing } = await import("../agents/tools/web-search.js");
const { resolveSearchProvider } = __testing;
const bundledSearchProviders = [
{ pluginId: "search-brave", provider: createBundledBraveSearchProvider() },
{ pluginId: "search-gemini", provider: createBundledGeminiSearchProvider() },
{ pluginId: "search-grok", provider: createBundledGrokSearchProvider() },
{ pluginId: "search-kimi", provider: createBundledKimiSearchProvider() },
{ pluginId: "search-perplexity", provider: createBundledPerplexitySearchProvider() },
];
function resolveSearchProviderId(search: Record<string, unknown>) {
return resolveSearchProvider({
config: {
tools: {
web: {
search,
},
},
},
}).id;
}
describe("web search provider config", () => {
beforeEach(() => {
loadOpenClawPlugins.mockReset();
@ -175,6 +200,8 @@ describe("web search provider auto-detection", () => {
const savedEnv = { ...process.env };
beforeEach(() => {
loadOpenClawPlugins.mockReset();
loadOpenClawPlugins.mockReturnValue({ searchProviders: bundledSearchProviders });
delete process.env.BRAVE_API_KEY;
delete process.env.GEMINI_API_KEY;
delete process.env.KIMI_API_KEY;
@ -192,47 +219,47 @@ describe("web search provider auto-detection", () => {
});
it("falls back to brave when no keys available", () => {
expect(resolveSearchProvider({})).toBe("brave");
expect(resolveSearchProviderId({})).toBe("brave");
});
it("auto-detects brave when only BRAVE_API_KEY is set", () => {
process.env.BRAVE_API_KEY = "test-brave-key"; // pragma: allowlist secret
expect(resolveSearchProvider({})).toBe("brave");
expect(resolveSearchProviderId({})).toBe("brave");
});
it("auto-detects gemini when only GEMINI_API_KEY is set", () => {
process.env.GEMINI_API_KEY = "test-gemini-key"; // pragma: allowlist secret
expect(resolveSearchProvider({})).toBe("gemini");
expect(resolveSearchProviderId({})).toBe("gemini");
});
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");
expect(resolveSearchProviderId({})).toBe("kimi");
});
it("auto-detects perplexity when only PERPLEXITY_API_KEY is set", () => {
process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; // pragma: allowlist secret
expect(resolveSearchProvider({})).toBe("perplexity");
expect(resolveSearchProviderId({})).toBe("perplexity");
});
it("auto-detects perplexity when only OPENROUTER_API_KEY is set", () => {
process.env.OPENROUTER_API_KEY = "sk-or-v1-test"; // pragma: allowlist secret
expect(resolveSearchProvider({})).toBe("perplexity");
expect(resolveSearchProviderId({})).toBe("perplexity");
});
it("auto-detects grok when only XAI_API_KEY is set", () => {
process.env.XAI_API_KEY = "test-xai-key"; // pragma: allowlist secret
expect(resolveSearchProvider({})).toBe("grok");
expect(resolveSearchProviderId({})).toBe("grok");
});
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");
expect(resolveSearchProviderId({})).toBe("kimi");
});
it("auto-detects kimi when only MOONSHOT_API_KEY is set", () => {
process.env.MOONSHOT_API_KEY = "test-moonshot-key"; // pragma: allowlist secret
expect(resolveSearchProvider({})).toBe("kimi");
expect(resolveSearchProviderId({})).toBe("kimi");
});
it("follows alphabetical order — brave wins when multiple keys available", () => {
@ -240,29 +267,25 @@ describe("web search provider auto-detection", () => {
process.env.GEMINI_API_KEY = "test-gemini-key"; // pragma: allowlist secret
process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; // pragma: allowlist secret
process.env.XAI_API_KEY = "test-xai-key"; // pragma: allowlist secret
expect(resolveSearchProvider({})).toBe("brave");
expect(resolveSearchProviderId({})).toBe("brave");
});
it("gemini wins over grok, kimi, and perplexity when brave unavailable", () => {
process.env.GEMINI_API_KEY = "test-gemini-key"; // pragma: allowlist secret
process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; // pragma: allowlist secret
process.env.XAI_API_KEY = "test-xai-key"; // pragma: allowlist secret
expect(resolveSearchProvider({})).toBe("gemini");
expect(resolveSearchProviderId({})).toBe("gemini");
});
it("grok wins over kimi and perplexity when brave and gemini unavailable", () => {
process.env.XAI_API_KEY = "test-xai-key"; // pragma: allowlist secret
process.env.KIMI_API_KEY = "test-kimi-key"; // pragma: allowlist secret
process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; // pragma: allowlist secret
expect(resolveSearchProvider({})).toBe("grok");
expect(resolveSearchProviderId({})).toBe("grok");
});
it("explicit provider always wins regardless of keys", () => {
process.env.BRAVE_API_KEY = "test-brave-key"; // pragma: allowlist secret
expect(
resolveSearchProvider({ provider: "gemini" } as unknown as Parameters<
typeof resolveSearchProvider
>[0]),
).toBe("gemini");
expect(resolveSearchProviderId({ provider: "gemini" })).toBe("gemini");
});
});

View File

@ -1,4 +1,3 @@
import type { BuiltinWebSearchProviderId } from "../agents/tools/web-search-provider-catalog.js";
import type { ChatType } from "../channels/chat-type.js";
import type { SafeBinProfileFixture } from "../infra/exec-safe-bin-policy.js";
import type { AgentElevatedAllowFromConfig, SessionSendPolicyAction } from "./types.base.js";
@ -458,8 +457,8 @@ export type ToolsConfig = {
search?: {
/** Enable web search tool (default: true when API key is present). */
enabled?: boolean;
/** Search provider. Built-ins include "brave", "gemini", "grok", "kimi", and "perplexity"; plugins use their registered id. */
provider?: BuiltinWebSearchProviderId | (string & {});
/** Search provider id registered through the plugin system. */
provider?: string & {};
/** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */
apiKey?: SecretInput;
/** Default search results count (1-10). */

View File

@ -645,6 +645,7 @@ function validateConfigObjectWithPluginsBase(
origin: record.origin,
config: normalizedPlugins,
rootConfig: config,
defaultEnabledWhenBundled: record.defaultEnabledWhenBundled,
});
let enabled = enableState.enabled;
let reason = enableState.reason;

View File

@ -1,6 +1,5 @@
import { z } from "zod";
import { getBlockedNetworkModeReason } from "../agents/sandbox/network-mode.js";
import { BUILTIN_WEB_SEARCH_PROVIDER_IDS } from "../agents/tools/web-search-provider-catalog.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import { AgentModelSchema } from "./zod-schema.agent-model.js";
import {
@ -264,10 +263,8 @@ export const ToolsWebSearchSchema = z
.object({
enabled: z.boolean().optional(),
provider: z
.union([
z.enum(BUILTIN_WEB_SEARCH_PROVIDER_IDS),
z.string().regex(/^[a-z][a-z0-9_-]*$/, "custom provider id"),
])
.string()
.regex(/^[a-z][a-z0-9_-]*$/, "provider id")
.optional(),
apiKey: SecretInputSchema.optional().register(sensitive),
maxResults: z.number().int().positive().optional(),

View File

@ -10,6 +10,7 @@ export type {
SearchProviderRequest,
SearchProviderPlugin,
SearchProviderRuntimeMetadataResolver,
SearchProviderSetupMetadata,
SearchProviderSuccessResult,
} from "../plugins/types.js";
export {
@ -21,24 +22,23 @@ export {
export { withTrustedWebToolsEndpoint } from "../agents/tools/web-guarded-fetch.js";
export { resolveCitationRedirectUrl } from "../agents/tools/web-search-citation-redirect.js";
export type SearchProviderLegacyUiMetadata = {
label: string;
hint: string;
envKeys: readonly string[];
placeholder: string;
signupUrl: string;
apiKeyConfigPath: string;
readApiKeyValue?: (search: Record<string, unknown> | undefined) => unknown;
writeApiKeyValue?: (search: Record<string, unknown>, value: unknown) => void;
};
export type SearchProviderSetupUiMetadata = {
export type SearchProviderSetupParams = {
label: string;
hint: string;
envKeys: readonly string[];
placeholder: string;
signupUrl: string;
apiKeyConfigPath: string;
install?: SearchProviderSetupMetadata["install"];
autodetectPriority?: SearchProviderSetupMetadata["autodetectPriority"];
requestSchema?: SearchProviderSetupMetadata["requestSchema"];
resolveRequestSchema?: SearchProviderSetupMetadata["resolveRequestSchema"];
resolveRuntimeMetadata?: (params: {
search: Record<string, unknown> | undefined;
keyValue?: string;
keySource: "config" | "secretRef" | "env" | "missing";
fallbackEnvVar?: string;
}) => Record<string, unknown>;
readApiKeyValue?: (search: Record<string, unknown> | undefined) => unknown;
writeApiKeyValue?: (search: Record<string, unknown>, value: unknown) => void;
};
@ -51,15 +51,6 @@ export type SearchProviderFilterSupport = {
domainFilter?: boolean;
};
export type SearchProviderLegacyUiMetadataParams = Omit<
SearchProviderLegacyUiMetadata,
"readApiKeyValue" | "writeApiKeyValue"
> & {
provider: string;
};
export type SearchProviderSetupUiMetadataParams = SearchProviderLegacyUiMetadataParams;
const WEB_SEARCH_DOCS_URL = "https://docs.openclaw.ai/tools/web";
export function resolveSearchConfig<T>(search?: Record<string, unknown>): T {
@ -80,27 +71,34 @@ export function resolveSearchProviderSectionConfig<T>(
return scoped as T;
}
export function createLegacySearchProviderMetadata(
params: SearchProviderLegacyUiMetadataParams,
): SearchProviderLegacyUiMetadata {
return {
label: params.label,
hint: params.hint,
envKeys: params.envKeys,
placeholder: params.placeholder,
signupUrl: params.signupUrl,
apiKeyConfigPath: params.apiKeyConfigPath,
resolveRuntimeMetadata: params.resolveRuntimeMetadata,
readApiKeyValue: (search) => readSearchProviderApiKeyValue(search, params.provider),
writeApiKeyValue: (search, value) =>
writeSearchProviderApiKeyValue({ search, provider: params.provider, value }),
};
}
export function createSearchProviderSetupMetadata(
params: SearchProviderSetupUiMetadataParams,
): SearchProviderSetupUiMetadata {
return createLegacySearchProviderMetadata(params);
params: SearchProviderSetupParams & { provider: string },
): SearchProviderSetupMetadata {
return {
hint: params.hint,
credentials: {
label: params.label,
hint: params.hint,
envKeys: params.envKeys,
placeholder: params.placeholder,
signupUrl: params.signupUrl,
apiKeyConfigPath: params.apiKeyConfigPath,
resolveRuntimeMetadata: params.resolveRuntimeMetadata,
readApiKeyValue:
params.readApiKeyValue ??
((search) => readSearchProviderApiKeyValue(search, params.provider)),
writeApiKeyValue:
params.writeApiKeyValue ??
((search, value) =>
writeSearchProviderApiKeyValue({ search, provider: params.provider, value })),
},
...(params.install ? { install: params.install } : {}),
...(params.autodetectPriority !== undefined
? { autodetectPriority: params.autodetectPriority }
: {}),
...(params.requestSchema ? { requestSchema: params.requestSchema } : {}),
...(params.resolveRequestSchema ? { resolveRequestSchema: params.resolveRequestSchema } : {}),
};
}
export function createSearchProviderErrorResult(
@ -130,31 +128,31 @@ export function rejectUnsupportedSearchFilters(params: {
if (params.request.country && params.support.country !== true) {
return createSearchProviderErrorResult(
"unsupported_country",
`country filtering is not supported by the ${provider} provider. Only Brave and Perplexity support country filtering.`,
`country filtering is not supported by the ${provider} provider.`,
);
}
if (params.request.language && params.support.language !== true) {
return createSearchProviderErrorResult(
"unsupported_language",
`language filtering is not supported by the ${provider} provider. Only Brave and Perplexity support language filtering.`,
`language filtering is not supported by the ${provider} provider.`,
);
}
if (params.request.freshness && params.support.freshness !== true) {
return createSearchProviderErrorResult(
"unsupported_freshness",
`freshness filtering is not supported by the ${provider} provider. Only Brave and Perplexity support freshness.`,
`freshness filtering is not supported by the ${provider} provider.`,
);
}
if ((params.request.dateAfter || params.request.dateBefore) && params.support.date !== true) {
return createSearchProviderErrorResult(
"unsupported_date_filter",
`date_after/date_before filtering is not supported by the ${provider} provider. Only Brave and Perplexity support date filtering.`,
`date_after/date_before filtering is not supported by the ${provider} provider.`,
);
}
if (params.request.domainFilter?.length && params.support.domainFilter !== true) {
return createSearchProviderErrorResult(
"unsupported_domain_filter",
`domain_filter is not supported by the ${provider} provider. Only Perplexity supports domain filtering.`,
`domain_filter is not supported by the ${provider} provider.`,
);
}
return undefined;
@ -238,9 +236,6 @@ function getScopedSearchConfig(
search: Record<string, unknown>,
provider: string,
): Record<string, unknown> | undefined {
if (provider === "brave") {
return search;
}
const scoped = search[provider];
return typeof scoped === "object" && scoped !== null && !Array.isArray(scoped)
? (scoped as Record<string, unknown>)
@ -262,10 +257,6 @@ export function writeSearchProviderApiKeyValue(params: {
provider: string;
value: unknown;
}): void {
if (params.provider === "brave") {
params.search.apiKey = params.value;
return;
}
const current = getScopedSearchConfig(params.search, params.provider);
if (current) {
current.apiKey = params.value;

View File

@ -115,7 +115,7 @@ describe("resolveEffectiveEnableState", () => {
expect(state).toEqual({ enabled: false, reason: "disabled in config" });
});
it("enables bundled search provider plugins by default", () => {
it("enables plugins marked defaultEnabledWhenBundled by default", () => {
const normalized = normalizePluginsConfig({
enabled: true,
});
@ -124,11 +124,12 @@ describe("resolveEffectiveEnableState", () => {
origin: "bundled",
config: normalized,
rootConfig: {},
defaultEnabledWhenBundled: true,
});
expect(state).toEqual({ enabled: true });
});
it("enables other migrated bundled search provider plugins by default", () => {
it("applies defaultEnabledWhenBundled consistently across plugin ids", () => {
const normalized = normalizePluginsConfig({
enabled: true,
});
@ -137,6 +138,7 @@ describe("resolveEffectiveEnableState", () => {
origin: "bundled",
config: normalized,
rootConfig: {},
defaultEnabledWhenBundled: true,
});
expect(state).toEqual({ enabled: true });
});

View File

@ -1,4 +1,3 @@
import { MIGRATED_BUNDLED_WEB_SEARCH_PLUGIN_IDS } from "../agents/tools/web-search-provider-catalog.js";
import { normalizeChatChannelId } from "../channels/registry.js";
import type { OpenClawConfig } from "../config/config.js";
import type { PluginRecord } from "./registry.js";
@ -29,7 +28,6 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>([
"ollama",
"phone-control",
"sglang",
...MIGRATED_BUNDLED_WEB_SEARCH_PLUGIN_IDS,
"talk-voice",
"vllm",
]);
@ -195,6 +193,7 @@ export function resolveEnableState(
id: string,
origin: PluginRecord["origin"],
config: NormalizedPluginsConfig,
defaultEnabledWhenBundled = false,
): { enabled: boolean; reason?: string } {
if (!config.enabled) {
return { enabled: false, reason: "plugins disabled" };
@ -219,7 +218,7 @@ export function resolveEnableState(
if (entry?.enabled === true) {
return { enabled: true };
}
if (origin === "bundled" && BUNDLED_ENABLED_BY_DEFAULT.has(id)) {
if (origin === "bundled" && (BUNDLED_ENABLED_BY_DEFAULT.has(id) || defaultEnabledWhenBundled)) {
return { enabled: true };
}
if (origin === "bundled") {
@ -252,8 +251,14 @@ export function resolveEffectiveEnableState(params: {
origin: PluginRecord["origin"];
config: NormalizedPluginsConfig;
rootConfig?: OpenClawConfig;
defaultEnabledWhenBundled?: boolean;
}): { enabled: boolean; reason?: string } {
const base = resolveEnableState(params.id, params.origin, params.config);
const base = resolveEnableState(
params.id,
params.origin,
params.config,
params.defaultEnabledWhenBundled,
);
if (
!base.enabled &&
base.reason === "bundled (disabled by default)" &&

View File

@ -8,8 +8,6 @@
import { concatOptionalTextSegments } from "../shared/text/join-segments.js";
import type { PluginRegistry } from "./registry.js";
import type {
PluginHookAfterSearchProviderActivateEvent,
PluginHookAfterSearchProviderConfigureEvent,
PluginHookAfterProviderActivateEvent,
PluginHookAfterProviderConfigureEvent,
PluginHookAfterCompactionEvent,
@ -18,8 +16,6 @@ import type {
PluginHookAgentEndEvent,
PluginHookBeforeAgentStartEvent,
PluginHookBeforeAgentStartResult,
PluginHookBeforeSearchProviderConfigureEvent,
PluginHookBeforeSearchProviderConfigureResult,
PluginHookBeforeProviderConfigureEvent,
PluginHookBeforeProviderConfigureResult,
PluginHookBeforeModelResolveEvent,
@ -66,8 +62,6 @@ export type {
PluginHookAgentContext,
PluginHookBeforeAgentStartEvent,
PluginHookBeforeAgentStartResult,
PluginHookBeforeSearchProviderConfigureEvent,
PluginHookBeforeSearchProviderConfigureResult,
PluginHookBeforeProviderConfigureEvent,
PluginHookBeforeProviderConfigureResult,
PluginHookBeforeModelResolveEvent,
@ -108,8 +102,6 @@ export type {
PluginHookGatewayContext,
PluginHookGatewayStartEvent,
PluginHookGatewayStopEvent,
PluginHookAfterSearchProviderConfigureEvent,
PluginHookAfterSearchProviderActivateEvent,
PluginHookAfterProviderConfigureEvent,
PluginHookAfterProviderActivateEvent,
};
@ -138,87 +130,6 @@ function getHooksForName<K extends PluginHookName>(
.toSorted((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
}
function getHooksForNameWithoutPluginIds<K extends PluginHookName>(params: {
registry: PluginRegistry;
hookName: K;
excludedPluginIds: ReadonlySet<string>;
}): PluginHookRegistration<K>[] {
return getHooksForName(params.registry, params.hookName).filter(
(hook) => !params.excludedPluginIds.has(hook.pluginId),
);
}
type SearchBeforeProviderAliasDescriptor = {
genericHookName: "before_provider_configure";
legacyHookName: "before_search_provider_configure";
toLegacyEvent: (
event: PluginHookBeforeProviderConfigureEvent,
) => PluginHookBeforeSearchProviderConfigureEvent;
};
type SearchAfterProviderConfigureAliasDescriptor = {
genericHookName: "after_provider_configure";
legacyHookName: "after_search_provider_configure";
toLegacyEvent: (
event: PluginHookAfterProviderConfigureEvent,
) => PluginHookAfterSearchProviderConfigureEvent;
};
type SearchAfterProviderActivateAliasDescriptor = {
genericHookName: "after_provider_activate";
legacyHookName: "after_search_provider_activate";
toLegacyEvent: (
event: PluginHookAfterProviderActivateEvent,
) => PluginHookAfterSearchProviderActivateEvent;
};
const SEARCH_PROVIDER_ALIAS_HOOKS = {
beforeConfigure: {
genericHookName: "before_provider_configure",
legacyHookName: "before_search_provider_configure",
toLegacyEvent: (
event: PluginHookBeforeProviderConfigureEvent,
): PluginHookBeforeSearchProviderConfigureEvent => ({
providerId: event.providerId,
providerLabel: event.providerLabel,
providerSource: event.providerSource,
pluginId: event.pluginId,
intent: event.intent,
activeProviderId: event.activeProviderId,
configured: event.configured,
}),
} satisfies SearchBeforeProviderAliasDescriptor,
afterConfigure: {
genericHookName: "after_provider_configure",
legacyHookName: "after_search_provider_configure",
toLegacyEvent: (
event: PluginHookAfterProviderConfigureEvent,
): PluginHookAfterSearchProviderConfigureEvent => ({
providerId: event.providerId,
providerLabel: event.providerLabel,
providerSource: event.providerSource,
pluginId: event.pluginId,
intent: event.intent,
activeProviderId: event.activeProviderId,
configured: event.configured,
}),
} satisfies SearchAfterProviderConfigureAliasDescriptor,
afterActivate: {
genericHookName: "after_provider_activate",
legacyHookName: "after_search_provider_activate",
toLegacyEvent: (
event: PluginHookAfterProviderActivateEvent,
): PluginHookAfterSearchProviderActivateEvent => ({
providerId: event.providerId,
providerLabel: event.providerLabel,
providerSource: event.providerSource,
pluginId: event.pluginId,
previousProviderId: event.previousProviderId,
intent: event.intent,
}),
} satisfies SearchAfterProviderActivateAliasDescriptor,
} as const;
/**
* Create a hook runner for a specific registry.
*/
@ -280,16 +191,6 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
return next;
};
const mergeBeforeSearchProviderConfigure = (
acc: PluginHookBeforeSearchProviderConfigureResult | undefined,
next: PluginHookBeforeSearchProviderConfigureResult,
): PluginHookBeforeSearchProviderConfigureResult => ({
note: concatOptionalTextSegments({
left: acc?.note,
right: next.note,
}),
});
const mergeBeforeProviderConfigure = (
acc: PluginHookBeforeProviderConfigureResult | undefined,
next: PluginHookBeforeProviderConfigureResult,
@ -401,21 +302,6 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
return result;
}
function getSearchProviderLegacyHooks(
descriptor:
| SearchBeforeProviderAliasDescriptor
| SearchAfterProviderConfigureAliasDescriptor
| SearchAfterProviderActivateAliasDescriptor,
) {
const genericHooks = getHooksForName(registry, descriptor.genericHookName);
const genericPluginIds = new Set(genericHooks.map((hook) => hook.pluginId));
return getHooksForNameWithoutPluginIds({
registry,
hookName: descriptor.legacyHookName,
excludedPluginIds: genericPluginIds,
});
}
// =========================================================================
// Agent Hooks
// =========================================================================
@ -471,98 +357,30 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
);
}
async function runBeforeSearchProviderConfigure(
event: PluginHookBeforeSearchProviderConfigureEvent,
ctx: PluginHookSearchProviderContext,
): Promise<PluginHookBeforeSearchProviderConfigureResult | undefined> {
return runModifyingHook<
"before_search_provider_configure",
PluginHookBeforeSearchProviderConfigureResult
>("before_search_provider_configure", event, ctx, mergeBeforeSearchProviderConfigure);
}
async function runBeforeProviderConfigure(
event: PluginHookBeforeProviderConfigureEvent,
ctx: PluginHookSearchProviderContext,
): Promise<PluginHookBeforeProviderConfigureResult | undefined> {
const aliasDescriptor = SEARCH_PROVIDER_ALIAS_HOOKS.beforeConfigure;
const genericResult = await runModifyingHook<
return runModifyingHook<"before_provider_configure", PluginHookBeforeProviderConfigureResult>(
"before_provider_configure",
PluginHookBeforeProviderConfigureResult
>("before_provider_configure", event, ctx, mergeBeforeProviderConfigure);
if (event.providerKind !== "search") {
return genericResult;
}
const legacyHooks = getSearchProviderLegacyHooks(aliasDescriptor);
const searchResult = await runModifyingHookRegistrations<
"before_search_provider_configure",
PluginHookBeforeSearchProviderConfigureResult
>(
aliasDescriptor.legacyHookName,
legacyHooks as PluginHookRegistration<"before_search_provider_configure">[],
aliasDescriptor.toLegacyEvent(event),
event,
ctx,
mergeBeforeSearchProviderConfigure,
mergeBeforeProviderConfigure,
);
if (!searchResult) {
return genericResult;
}
return mergeBeforeProviderConfigure(genericResult, {
note: searchResult.note,
});
}
async function runAfterProviderConfigure(
event: PluginHookAfterProviderConfigureEvent,
ctx: PluginHookSearchProviderContext,
): Promise<void> {
const aliasDescriptor = SEARCH_PROVIDER_ALIAS_HOOKS.afterConfigure;
await runVoidHook("after_provider_configure", event, ctx);
if (event.providerKind !== "search") {
return;
}
const legacyHooks = getSearchProviderLegacyHooks(aliasDescriptor);
await runVoidHookRegistrations(
aliasDescriptor.legacyHookName,
legacyHooks as PluginHookRegistration<"after_search_provider_configure">[],
aliasDescriptor.toLegacyEvent(event),
ctx,
);
}
async function runAfterProviderActivate(
event: PluginHookAfterProviderActivateEvent,
ctx: PluginHookSearchProviderContext,
): Promise<void> {
const aliasDescriptor = SEARCH_PROVIDER_ALIAS_HOOKS.afterActivate;
await runVoidHook("after_provider_activate", event, ctx);
if (event.providerKind !== "search") {
return;
}
const legacyHooks = getSearchProviderLegacyHooks(aliasDescriptor);
await runVoidHookRegistrations(
aliasDescriptor.legacyHookName,
legacyHooks as PluginHookRegistration<"after_search_provider_activate">[],
aliasDescriptor.toLegacyEvent(event),
ctx,
);
}
async function runAfterSearchProviderConfigure(
event: PluginHookAfterSearchProviderConfigureEvent,
ctx: PluginHookSearchProviderContext,
): Promise<void> {
return runVoidHook("after_search_provider_configure", event, ctx);
}
async function runAfterSearchProviderActivate(
event: PluginHookAfterSearchProviderActivateEvent,
ctx: PluginHookSearchProviderContext,
): Promise<void> {
return runVoidHook("after_search_provider_activate", event, ctx);
}
/**
@ -970,26 +788,13 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
}
function hasProviderConfigureHooks(providerKind?: string): boolean {
if (hasHooks("before_provider_configure") || hasHooks("after_provider_configure")) {
return true;
}
if (providerKind !== "search") {
return false;
}
return (
hasHooks(SEARCH_PROVIDER_ALIAS_HOOKS.beforeConfigure.legacyHookName) ||
hasHooks(SEARCH_PROVIDER_ALIAS_HOOKS.afterConfigure.legacyHookName)
);
void providerKind;
return hasHooks("before_provider_configure") || hasHooks("after_provider_configure");
}
function hasProviderActivationHooks(providerKind?: string): boolean {
if (hasHooks("after_provider_activate")) {
return true;
}
if (providerKind !== "search") {
return false;
}
return hasHooks(SEARCH_PROVIDER_ALIAS_HOOKS.afterActivate.legacyHookName);
void providerKind;
return hasHooks("after_provider_activate");
}
return {
@ -998,11 +803,8 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
runBeforePromptBuild,
runBeforeAgentStart,
runBeforeProviderConfigure,
runBeforeSearchProviderConfigure,
runAfterProviderConfigure,
runAfterSearchProviderConfigure,
runAfterProviderActivate,
runAfterSearchProviderActivate,
runLlmInput,
runLlmOutput,
runAgentEnd,

View File

@ -827,6 +827,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
origin: candidate.origin,
config: normalized,
rootConfig: cfg,
defaultEnabledWhenBundled: manifestRecord.defaultEnabledWhenBundled,
});
const entry = normalized.entries[pluginId];
const record = createPluginRecord({
@ -843,6 +844,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
record.kind = manifestRecord.kind;
record.configUiHints = manifestRecord.configUiHints;
record.configJsonSchema = manifestRecord.configSchema;
record.defaultEnabledWhenBundled = manifestRecord.defaultEnabledWhenBundled;
record.declaredCapabilities = [...manifestRecord.provides];
record.requiredCapabilities = [...manifestRecord.requires];
record.conflictingCapabilities = [...manifestRecord.conflicts];

View File

@ -6,6 +6,7 @@ import {
clearPluginManifestRegistryCache,
loadPluginManifestRegistry,
} from "./manifest-registry.js";
import type { OpenClawPackageManifest } from "./manifest.js";
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
const tempDirs: string[] = [];
@ -36,12 +37,14 @@ function createPluginCandidate(params: {
rootDir: string;
sourceName?: string;
origin: "bundled" | "global" | "workspace" | "config";
packageManifest?: OpenClawPackageManifest;
}): PluginCandidate {
return {
idHint: params.idHint,
source: path.join(params.rootDir, params.sourceName ?? "index.ts"),
rootDir: params.rootDir,
origin: params.origin,
packageManifest: params.packageManifest,
};
}
@ -215,6 +218,37 @@ describe("loadPluginManifestRegistry", () => {
expect(countDuplicateWarnings(loadRegistry(candidates))).toBe(0);
});
it("surfaces package install metadata on manifest records", () => {
const dir = makeTempDir();
writeManifest(dir, {
id: "tavily-search",
name: "Tavily Search",
provides: ["providers.search.tavily"],
configSchema: { type: "object" },
});
const registry = loadRegistry([
createPluginCandidate({
idHint: "tavily-search",
rootDir: dir,
origin: "bundled",
packageManifest: {
install: {
npmSpec: "@openclaw/tavily-search",
localPath: "extensions/tavily-search",
defaultChoice: "local",
},
},
}),
]);
expect(registry.plugins[0]?.packageInstall).toEqual({
npmSpec: "@openclaw/tavily-search",
localPath: "extensions/tavily-search",
defaultChoice: "local",
});
});
it("prefers higher-precedence origins for the same physical directory (config > workspace > global > bundled)", () => {
const dir = makeTempDir();
mkdirSafe(path.join(dir, "sub"));

View File

@ -2,7 +2,7 @@ import fs from "node:fs";
import type { OpenClawConfig } from "../config/config.js";
import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js";
import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js";
import { loadPluginManifest, type PluginManifest } from "./manifest.js";
import { loadPluginManifest, type PluginManifest, type PluginPackageInstall } from "./manifest.js";
import { safeRealpathSync } from "./path-safety.js";
import { resolvePluginCacheInputs } from "./roots.js";
import type { PluginConfigUiHint, PluginDiagnostic, PluginKind, PluginOrigin } from "./types.js";
@ -40,6 +40,8 @@ export type PluginManifestRecord = {
schemaCacheKey?: string;
configSchema?: Record<string, unknown>;
configUiHints?: Record<string, PluginConfigUiHint>;
defaultEnabledWhenBundled?: boolean;
packageInstall?: PluginPackageInstall;
};
export type PluginManifestRegistry = {
@ -138,6 +140,8 @@ function buildRecord(params: {
schemaCacheKey: params.schemaCacheKey,
configSchema: params.configSchema,
configUiHints: params.manifest.uiHints,
defaultEnabledWhenBundled: params.manifest.defaultEnabledWhenBundled,
packageInstall: params.candidate.packageManifest?.install,
};
}

View File

@ -21,6 +21,7 @@ export type PluginManifest = {
name?: string;
description?: string;
version?: string;
defaultEnabledWhenBundled?: boolean;
uiHints?: Record<string, PluginConfigUiHint>;
};
@ -106,6 +107,9 @@ export function loadPluginManifest(
uiHints = raw.uiHints as Record<string, PluginConfigUiHint>;
}
const defaultEnabledWhenBundled =
typeof raw.defaultEnabledWhenBundled === "boolean" ? raw.defaultEnabledWhenBundled : undefined;
return {
ok: true,
manifest: {
@ -121,6 +125,7 @@ export function loadPluginManifest(
name,
description,
version,
defaultEnabledWhenBundled,
uiHints,
},
manifestPath,

View File

@ -155,6 +155,7 @@ export type PluginRecord = {
configSchema: boolean;
configUiHints?: Record<string, PluginConfigUiHint>;
configJsonSchema?: Record<string, unknown>;
defaultEnabledWhenBundled?: boolean;
};
export type PluginRegistry = {

View File

@ -310,14 +310,20 @@ export type SearchProviderInstallMetadata = {
defaultChoice?: "npm" | "local";
};
export type SearchProviderRequestSchemaResolver = (params: {
config?: OpenClawConfig;
runtimeMetadata?: Record<string, unknown>;
}) => Record<string, unknown>;
export type SearchProviderSetupMetadata = {
hint?: string;
credentials?: SearchProviderCredentialMetadata;
install?: SearchProviderInstallMetadata;
autodetectPriority?: number;
requestSchema?: Record<string, unknown>;
resolveRequestSchema?: SearchProviderRequestSchemaResolver;
};
export type SearchProviderLegacyConfigMetadata = SearchProviderCredentialMetadata;
export type SearchProviderRuntimeMetadata = Record<string, unknown>;
export type SearchProviderRuntimeMetadataResolver = (params: {
@ -336,7 +342,6 @@ export type SearchProviderPlugin = {
docsUrl?: string;
configFieldOrder?: string[];
setup?: SearchProviderSetupMetadata;
legacyConfig?: SearchProviderLegacyConfigMetadata;
resolveRuntimeMetadata?: SearchProviderRuntimeMetadataResolver;
isAvailable?: (config?: OpenClawConfig) => boolean;
search: (
@ -544,11 +549,8 @@ export type PluginHookName =
| "before_prompt_build"
| "before_agent_start"
| "before_provider_configure"
| "before_search_provider_configure"
| "after_provider_configure"
| "after_search_provider_configure"
| "after_provider_activate"
| "after_search_provider_activate"
| "llm_input"
| "llm_output"
| "agent_end"
@ -576,11 +578,8 @@ export const PLUGIN_HOOK_NAMES = [
"before_prompt_build",
"before_agent_start",
"before_provider_configure",
"before_search_provider_configure",
"after_provider_configure",
"after_search_provider_configure",
"after_provider_activate",
"after_search_provider_activate",
"llm_input",
"llm_output",
"agent_end",
@ -729,7 +728,7 @@ export type PluginHookProviderLifecycleContext = {
workspaceDir?: string;
};
export type PluginHookSearchProviderSource = "builtin" | "plugin";
export type PluginHookSearchProviderSource = "plugin";
export type PluginHookProviderKind = "search";
@ -749,20 +748,6 @@ export type PluginHookBeforeProviderConfigureResult = {
note?: string;
};
export type PluginHookBeforeSearchProviderConfigureEvent = {
providerId: string;
providerLabel: string;
providerSource: PluginHookSearchProviderSource;
pluginId?: string;
intent: "switch-active" | "configure-provider";
activeProviderId?: string | null;
configured: boolean;
};
export type PluginHookBeforeSearchProviderConfigureResult = {
note?: string;
};
export type PluginHookAfterProviderConfigureEvent = {
providerKind: PluginHookProviderKind;
slot: string;
@ -775,16 +760,6 @@ export type PluginHookAfterProviderConfigureEvent = {
configured: boolean;
};
export type PluginHookAfterSearchProviderConfigureEvent = {
providerId: string;
providerLabel: string;
providerSource: PluginHookSearchProviderSource;
pluginId?: string;
intent: "switch-active" | "configure-provider";
activeProviderId?: string | null;
configured: boolean;
};
export type PluginHookAfterProviderActivateEvent = {
providerKind: PluginHookProviderKind;
slot: string;
@ -796,15 +771,6 @@ export type PluginHookAfterProviderActivateEvent = {
intent: "switch-active" | "configure-provider";
};
export type PluginHookAfterSearchProviderActivateEvent = {
providerId: string;
providerLabel: string;
providerSource: PluginHookSearchProviderSource;
pluginId?: string;
previousProviderId?: string | null;
intent: "switch-active" | "configure-provider";
};
// llm_input hook
export type PluginHookLlmInputEvent = {
runId: string;
@ -1125,29 +1091,14 @@ export type PluginHookHandlerMap = {
| Promise<PluginHookBeforeProviderConfigureResult | void>
| PluginHookBeforeProviderConfigureResult
| void;
before_search_provider_configure: (
event: PluginHookBeforeSearchProviderConfigureEvent,
ctx: PluginHookSearchProviderContext,
) =>
| Promise<PluginHookBeforeSearchProviderConfigureResult | void>
| PluginHookBeforeSearchProviderConfigureResult
| void;
after_provider_configure: (
event: PluginHookAfterProviderConfigureEvent,
ctx: PluginHookProviderLifecycleContext,
) => Promise<void> | void;
after_search_provider_configure: (
event: PluginHookAfterSearchProviderConfigureEvent,
ctx: PluginHookSearchProviderContext,
) => Promise<void> | void;
after_provider_activate: (
event: PluginHookAfterProviderActivateEvent,
ctx: PluginHookProviderLifecycleContext,
) => Promise<void> | void;
after_search_provider_activate: (
event: PluginHookAfterSearchProviderActivateEvent,
ctx: PluginHookSearchProviderContext,
) => Promise<void> | void;
llm_input: (event: PluginHookLlmInputEvent, ctx: PluginHookAgentContext) => Promise<void> | void;
llm_output: (
event: PluginHookLlmOutputEvent,

View File

@ -3,7 +3,6 @@ import { resolveSecretInputRef } from "../config/types.secrets.js";
import { loadOpenClawPlugins } from "../plugins/loader.js";
import type {
SearchProviderCredentialMetadata,
SearchProviderLegacyConfigMetadata,
SearchProviderPlugin,
SearchProviderSetupMetadata,
} from "../plugins/types.js";
@ -93,18 +92,8 @@ type RegisteredSearchProviderRuntimeSupport = {
function resolveProviderSetupMetadata(
setup?: SearchProviderSetupMetadata,
legacyConfig?: SearchProviderLegacyConfigMetadata,
): SearchProviderSetupMetadata | undefined {
if (setup) {
return setup;
}
if (!legacyConfig) {
return undefined;
}
return {
hint: legacyConfig.hint,
credentials: legacyConfig,
};
return setup;
}
function resolveProviderCredentialMetadata(
@ -128,7 +117,7 @@ function resolveRegisteredSearchProviderMetadata(
.map((entry) => [
entry.provider.id,
{
setup: resolveProviderSetupMetadata(entry.provider.setup, entry.provider.legacyConfig),
setup: resolveProviderSetupMetadata(entry.provider.setup),
resolveRuntimeMetadata: entry.provider.resolveRuntimeMetadata,
},
]),