refactor: complete search provider migration
This commit is contained in:
parent
74b5c2e875
commit
d96601a8a2
@ -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
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
{
|
||||
"id": "search-brave",
|
||||
"defaultEnabledWhenBundled": true,
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
|
||||
@ -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": [
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
{
|
||||
"id": "search-gemini",
|
||||
"defaultEnabledWhenBundled": true,
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
|
||||
@ -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": [
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
{
|
||||
"id": "search-grok",
|
||||
"defaultEnabledWhenBundled": true,
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
|
||||
@ -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": [
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
{
|
||||
"id": "search-kimi",
|
||||
"defaultEnabledWhenBundled": true,
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
|
||||
@ -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": [
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
{
|
||||
"id": "search-perplexity",
|
||||
"defaultEnabledWhenBundled": true,
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
|
||||
@ -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": [
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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;
|
||||
@ -1,5 +1,7 @@
|
||||
{
|
||||
"id": "tavily-search",
|
||||
"name": "Tavily Search",
|
||||
"description": "Search the web using Tavily.",
|
||||
"provides": ["providers.search.tavily"],
|
||||
"uiHints": {
|
||||
"apiKey": {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,6 +43,7 @@ export function resolvePluginSkillDirs(params: {
|
||||
origin: record.origin,
|
||||
config: normalizedPlugins,
|
||||
rootConfig: params.config,
|
||||
defaultEnabledWhenBundled: record.defaultEnabledWhenBundled,
|
||||
});
|
||||
if (!enableState.enabled) {
|
||||
continue;
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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];
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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). */
|
||||
|
||||
@ -645,6 +645,7 @@ function validateConfigObjectWithPluginsBase(
|
||||
origin: record.origin,
|
||||
config: normalizedPlugins,
|
||||
rootConfig: config,
|
||||
defaultEnabledWhenBundled: record.defaultEnabledWhenBundled,
|
||||
});
|
||||
let enabled = enableState.enabled;
|
||||
let reason = enableState.reason;
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
|
||||
@ -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)" &&
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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"));
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -155,6 +155,7 @@ export type PluginRecord = {
|
||||
configSchema: boolean;
|
||||
configUiHints?: Record<string, PluginConfigUiHint>;
|
||||
configJsonSchema?: Record<string, unknown>;
|
||||
defaultEnabledWhenBundled?: boolean;
|
||||
};
|
||||
|
||||
export type PluginRegistry = {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
]),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user