diff --git a/extensions/brave/src/brave-web-search-provider.runtime.ts b/extensions/brave/src/brave-web-search-provider.runtime.ts new file mode 100644 index 00000000000..4785d81b027 --- /dev/null +++ b/extensions/brave/src/brave-web-search-provider.runtime.ts @@ -0,0 +1,367 @@ +import type { SearchConfigRecord } from "openclaw/plugin-sdk/provider-web-search"; +import { + buildSearchCacheKey, + DEFAULT_SEARCH_COUNT, + formatCliCommand, + normalizeFreshness, + parseIsoDateRange, + readCachedSearchPayload, + readConfiguredSecretString, + readNumberParam, + readProviderEnvValue, + readStringParam, + resolveSearchCacheTtlMs, + resolveSearchCount, + resolveSearchTimeoutSeconds, + resolveSiteName, + withTrustedWebSearchEndpoint, + wrapWebContent, + writeCachedSearchPayload, +} from "openclaw/plugin-sdk/provider-web-search"; +import { + type BraveLlmContextResponse, + mapBraveLlmContextResults, + normalizeBraveCountry, + normalizeBraveLanguageParams, + resolveBraveConfig, + resolveBraveMode, +} from "./brave-web-search-provider.shared.js"; + +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"; + +type BraveSearchResult = { + title?: string; + url?: string; + description?: string; + age?: string; +}; + +type BraveSearchResponse = { + web?: { + results?: BraveSearchResult[]; + }; +}; + +function resolveBraveApiKey(searchConfig?: SearchConfigRecord): string | undefined { + return ( + readConfiguredSecretString(searchConfig?.apiKey, "tools.web.search.apiKey") ?? + readProviderEnvValue(["BRAVE_API_KEY"]) + ); +} + +function missingBraveKeyPayload() { + return { + error: "missing_brave_api_key", + message: `web_search (brave) needs a Brave Search API key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`, + docs: "https://docs.openclaw.ai/tools/web", + }; +} + +async function runBraveLlmContextSearch(params: { + query: string; + apiKey: string; + timeoutSeconds: number; + country?: string; + search_lang?: string; + freshness?: string; +}): Promise<{ + results: Array<{ + url: string; + title: string; + snippets: string[]; + siteName?: string; + }>; + sources?: BraveLlmContextResponse["sources"]; +}> { + const url = new URL(BRAVE_LLM_CONTEXT_ENDPOINT); + url.searchParams.set("q", params.query); + if (params.country) { + url.searchParams.set("country", params.country); + } + if (params.search_lang) { + url.searchParams.set("search_lang", params.search_lang); + } + if (params.freshness) { + url.searchParams.set("freshness", params.freshness); + } + + return withTrustedWebSearchEndpoint( + { + url: url.toString(), + timeoutSeconds: params.timeoutSeconds, + init: { + method: "GET", + headers: { + Accept: "application/json", + "X-Subscription-Token": params.apiKey, + }, + }, + }, + async (response) => { + if (!response.ok) { + const detail = await response.text(); + throw new Error( + `Brave LLM Context API error (${response.status}): ${detail || response.statusText}`, + ); + } + + const data = (await response.json()) as BraveLlmContextResponse; + return { results: mapBraveLlmContextResults(data), sources: data.sources }; + }, + ); +} + +async function runBraveWebSearch(params: { + query: string; + count: number; + apiKey: string; + timeoutSeconds: number; + country?: string; + search_lang?: string; + ui_lang?: string; + freshness?: string; + dateAfter?: string; + dateBefore?: string; +}): Promise>> { + const url = new URL(BRAVE_SEARCH_ENDPOINT); + url.searchParams.set("q", params.query); + url.searchParams.set("count", String(params.count)); + if (params.country) { + url.searchParams.set("country", params.country); + } + if (params.search_lang) { + url.searchParams.set("search_lang", params.search_lang); + } + if (params.ui_lang) { + url.searchParams.set("ui_lang", params.ui_lang); + } + if (params.freshness) { + url.searchParams.set("freshness", params.freshness); + } else if (params.dateAfter && params.dateBefore) { + url.searchParams.set("freshness", `${params.dateAfter}to${params.dateBefore}`); + } else if (params.dateAfter) { + url.searchParams.set( + "freshness", + `${params.dateAfter}to${new Date().toISOString().slice(0, 10)}`, + ); + } else if (params.dateBefore) { + url.searchParams.set("freshness", `1970-01-01to${params.dateBefore}`); + } + + return withTrustedWebSearchEndpoint( + { + url: url.toString(), + timeoutSeconds: params.timeoutSeconds, + init: { + method: "GET", + headers: { + Accept: "application/json", + "X-Subscription-Token": params.apiKey, + }, + }, + }, + async (response) => { + if (!response.ok) { + const detail = await response.text(); + throw new Error( + `Brave Search API error (${response.status}): ${detail || response.statusText}`, + ); + } + + const data = (await response.json()) as BraveSearchResponse; + const results = Array.isArray(data.web?.results) ? (data.web?.results ?? []) : []; + return results.map((entry) => { + const description = entry.description ?? ""; + const title = entry.title ?? ""; + const url = entry.url ?? ""; + return { + title: title ? wrapWebContent(title, "web_search") : "", + url, + description: description ? wrapWebContent(description, "web_search") : "", + published: entry.age || undefined, + siteName: resolveSiteName(url) || undefined, + }; + }); + }, + ); +} + +export async function executeBraveSearch( + args: Record, + searchConfig?: SearchConfigRecord, +): Promise> { + const apiKey = resolveBraveApiKey(searchConfig); + if (!apiKey) { + return missingBraveKeyPayload(); + } + + const braveConfig = resolveBraveConfig(searchConfig); + const braveMode = resolveBraveMode(braveConfig); + const query = readStringParam(args, "query", { required: true }); + const count = + readNumberParam(args, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined; + const country = normalizeBraveCountry(readStringParam(args, "country")); + const language = readStringParam(args, "language"); + const search_lang = readStringParam(args, "search_lang"); + const ui_lang = readStringParam(args, "ui_lang"); + const normalizedLanguage = normalizeBraveLanguageParams({ + search_lang: search_lang || language, + ui_lang, + }); + + if (normalizedLanguage.invalidField === "search_lang") { + return { + error: "invalid_search_lang", + message: + "search_lang must be a Brave-supported language code like 'en', 'en-gb', 'zh-hans', or 'zh-hant'.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + if (normalizedLanguage.invalidField === "ui_lang") { + return { + error: "invalid_ui_lang", + message: "ui_lang must be a language-region locale like 'en-US'.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + if (normalizedLanguage.ui_lang && braveMode === "llm-context") { + return { + error: "unsupported_ui_lang", + message: + "ui_lang is not supported by Brave llm-context mode. Remove ui_lang or use Brave web mode for locale-based UI hints.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + + const rawFreshness = readStringParam(args, "freshness"); + if (rawFreshness && braveMode === "llm-context") { + return { + error: "unsupported_freshness", + message: + "freshness filtering is not supported by Brave llm-context mode. Remove freshness or use Brave web mode.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + const freshness = rawFreshness ? normalizeFreshness(rawFreshness, "brave") : undefined; + if (rawFreshness && !freshness) { + return { + error: "invalid_freshness", + message: "freshness must be day, week, month, or year.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + + const rawDateAfter = readStringParam(args, "date_after"); + const rawDateBefore = readStringParam(args, "date_before"); + if (rawFreshness && (rawDateAfter || rawDateBefore)) { + return { + error: "conflicting_time_filters", + message: + "freshness and date_after/date_before cannot be used together. Use either freshness (day/week/month/year) or a date range (date_after/date_before), not both.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + if ((rawDateAfter || rawDateBefore) && braveMode === "llm-context") { + return { + error: "unsupported_date_filter", + message: + "date_after/date_before filtering is not supported by Brave llm-context mode. Use Brave web mode for date filters.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + + const parsedDateRange = parseIsoDateRange({ + rawDateAfter, + rawDateBefore, + invalidDateAfterMessage: "date_after must be YYYY-MM-DD format.", + invalidDateBeforeMessage: "date_before must be YYYY-MM-DD format.", + invalidDateRangeMessage: "date_after must be before date_before.", + }); + if ("error" in parsedDateRange) { + return parsedDateRange; + } + + const { dateAfter, dateBefore } = parsedDateRange; + const cacheKey = buildSearchCacheKey([ + "brave", + braveMode, + query, + resolveSearchCount(count, DEFAULT_SEARCH_COUNT), + country, + normalizedLanguage.search_lang, + normalizedLanguage.ui_lang, + freshness, + dateAfter, + dateBefore, + ]); + const cached = readCachedSearchPayload(cacheKey); + if (cached) { + return cached; + } + + const start = Date.now(); + const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig); + const cacheTtlMs = resolveSearchCacheTtlMs(searchConfig); + + if (braveMode === "llm-context") { + const { results, sources } = await runBraveLlmContextSearch({ + query, + apiKey, + timeoutSeconds, + country: country ?? undefined, + search_lang: normalizedLanguage.search_lang, + freshness, + }); + const payload = { + query, + provider: "brave", + mode: "llm-context" as const, + count: results.length, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: "brave", + wrapped: true, + }, + results: results.map((entry) => ({ + title: entry.title ? wrapWebContent(entry.title, "web_search") : "", + url: entry.url, + snippets: entry.snippets.map((snippet) => wrapWebContent(snippet, "web_search")), + siteName: entry.siteName, + })), + sources, + }; + writeCachedSearchPayload(cacheKey, payload, cacheTtlMs); + return payload; + } + + const results = await runBraveWebSearch({ + query, + count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT), + apiKey, + timeoutSeconds, + country: country ?? undefined, + search_lang: normalizedLanguage.search_lang, + ui_lang: normalizedLanguage.ui_lang, + freshness, + dateAfter, + dateBefore, + }); + const payload = { + query, + provider: "brave", + count: results.length, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: "brave", + wrapped: true, + }, + results, + }; + writeCachedSearchPayload(cacheKey, payload, cacheTtlMs); + return payload; +} diff --git a/extensions/brave/src/brave-web-search-provider.shared.ts b/extensions/brave/src/brave-web-search-provider.shared.ts new file mode 100644 index 00000000000..e769bb1501e --- /dev/null +++ b/extensions/brave/src/brave-web-search-provider.shared.ts @@ -0,0 +1,274 @@ +import { Type } from "@sinclair/typebox"; + +export type BraveConfig = { + mode?: string; +}; + +export type BraveLlmContextResult = { url: string; title: string; snippets: string[] }; +export type BraveLlmContextResponse = { + grounding: { generic?: BraveLlmContextResult[] }; + sources?: { url?: string; hostname?: string; date?: string }[]; +}; + +const BRAVE_COUNTRY_CODES = new Set([ + "AR", + "AU", + "AT", + "BE", + "BR", + "CA", + "CL", + "DK", + "FI", + "FR", + "DE", + "GR", + "HK", + "IN", + "ID", + "IT", + "JP", + "KR", + "MY", + "MX", + "NL", + "NZ", + "NO", + "CN", + "PL", + "PT", + "PH", + "RU", + "SA", + "ZA", + "ES", + "SE", + "CH", + "TW", + "TR", + "GB", + "US", + "ALL", +]); + +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 = { + 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 MAX_BRAVE_SEARCH_COUNT = 10; + +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; +} + +export function normalizeBraveCountry(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + const canonical = trimmed.toUpperCase(); + return BRAVE_COUNTRY_CODES.has(canonical) ? canonical : "ALL"; +} + +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()}`; +} + +export function resolveBraveConfig(searchConfig?: Record): BraveConfig { + const brave = searchConfig?.brave; + return brave && typeof brave === "object" && !Array.isArray(brave) ? (brave as BraveConfig) : {}; +} + +export function resolveBraveMode(brave?: BraveConfig): "web" | "llm-context" { + return brave?.mode === "llm-context" ? "llm-context" : "web"; +} + +export 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; + + 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 }; +} + +function resolveSiteName(url: string | undefined): string | undefined { + if (!url) { + return undefined; + } + try { + return new URL(url).hostname; + } catch { + return undefined; + } +} + +export function mapBraveLlmContextResults( + data: BraveLlmContextResponse, +): { url: string; title: string; snippets: string[]; siteName?: string }[] { + const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : []; + return genericResults.map((entry) => ({ + url: entry.url ?? "", + title: entry.title ?? "", + snippets: (entry.snippets ?? []).filter( + (snippet) => typeof snippet === "string" && snippet.length > 0, + ), + siteName: resolveSiteName(entry.url) || undefined, + })); +} + +export function createBraveSchema() { + 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_BRAVE_SEARCH_COUNT, + }), + ), + country: Type.Optional( + Type.String({ + description: + "2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.", + }), + ), + language: Type.Optional( + Type.String({ + description: "ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').", + }), + ), + freshness: Type.Optional( + Type.String({ + description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.", + }), + ), + date_after: Type.Optional( + Type.String({ + description: "Only results published after this date (YYYY-MM-DD).", + }), + ), + date_before: Type.Optional( + Type.String({ + description: "Only results published before this date (YYYY-MM-DD).", + }), + ), + 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.", + }), + ), + }); +} diff --git a/extensions/brave/src/brave-web-search-provider.ts b/extensions/brave/src/brave-web-search-provider.ts index 571643cf419..a167804eb01 100644 --- a/extensions/brave/src/brave-web-search-provider.ts +++ b/extensions/brave/src/brave-web-search-provider.ts @@ -1,449 +1,106 @@ -import { Type } from "@sinclair/typebox"; -import { - buildSearchCacheKey, - DEFAULT_SEARCH_COUNT, - MAX_SEARCH_COUNT, - formatCliCommand, - mergeScopedSearchConfig, - normalizeFreshness, - parseIsoDateRange, - readCachedSearchPayload, - readConfiguredSecretString, - readNumberParam, - readProviderEnvValue, - readStringParam, - resolveProviderWebSearchPluginConfig, - resolveSearchCacheTtlMs, - resolveSearchCount, - resolveSearchTimeoutSeconds, - resolveSiteName, - setTopLevelCredentialValue, - setProviderWebSearchPluginConfigValue, - type SearchConfigRecord, - type WebSearchProviderPlugin, - type WebSearchProviderToolDefinition, - withTrustedWebSearchEndpoint, - wrapWebContent, - writeCachedSearchPayload, +import type { + SearchConfigRecord, + WebSearchProviderPlugin, + WebSearchProviderToolDefinition, } from "openclaw/plugin-sdk/provider-web-search"; +import { + createBraveSchema, + mapBraveLlmContextResults, + normalizeBraveCountry, + normalizeBraveLanguageParams, + resolveBraveConfig, + resolveBraveMode, +} from "./brave-web-search-provider.shared.js"; -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"; -// Mirror Brave's documented country enum so unsupported locale guesses can collapse to ALL. -const BRAVE_COUNTRY_CODES = new Set([ - "AR", - "AU", - "AT", - "BE", - "BR", - "CA", - "CL", - "DK", - "FI", - "FR", - "DE", - "GR", - "HK", - "IN", - "ID", - "IT", - "JP", - "KR", - "MY", - "MX", - "NL", - "NZ", - "NO", - "CN", - "PL", - "PT", - "PH", - "RU", - "SA", - "ZA", - "ES", - "SE", - "CH", - "TW", - "TR", - "GB", - "US", - "ALL", -]); -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 = { - 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; +type ConfigInput = Parameters< + NonNullable +>[0]; +type ConfigTarget = Parameters< + NonNullable +>[0]; -type BraveConfig = { - mode?: string; -}; +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} -type BraveSearchResult = { - title?: string; - url?: string; - description?: string; - age?: string; -}; +function resolveProviderWebSearchPluginConfig( + config: ConfigInput, + pluginId: string, +): Record | undefined { + if (!isRecord(config)) { + return undefined; + } + const plugins = isRecord(config.plugins) ? config.plugins : undefined; + const entries = isRecord(plugins?.entries) ? plugins.entries : undefined; + const entry = isRecord(entries?.[pluginId]) ? entries[pluginId] : undefined; + const pluginConfig = isRecord(entry?.config) ? entry.config : undefined; + return isRecord(pluginConfig?.webSearch) ? pluginConfig.webSearch : undefined; +} -type BraveSearchResponse = { - web?: { - results?: BraveSearchResult[]; +function ensureObject(target: Record, key: string): Record { + const current = target[key]; + if (isRecord(current)) { + return current; + } + const next: Record = {}; + target[key] = next; + return next; +} + +function setProviderWebSearchPluginConfigValue( + configTarget: ConfigTarget, + pluginId: string, + key: string, + value: unknown, +): void { + const plugins = ensureObject(configTarget as Record, "plugins"); + const entries = ensureObject(plugins, "entries"); + const entry = ensureObject(entries, pluginId); + if (entry.enabled === undefined) { + entry.enabled = true; + } + const config = ensureObject(entry, "config"); + const webSearch = ensureObject(config, "webSearch"); + webSearch[key] = value; +} + +function setTopLevelCredentialValue( + searchConfigTarget: Record, + value: unknown, +): void { + searchConfigTarget.apiKey = value; +} + +function mergeScopedSearchConfig( + searchConfig: Record | undefined, + key: string, + pluginConfig: Record | undefined, + options?: { mirrorApiKeyToTopLevel?: boolean }, +): Record | undefined { + if (!pluginConfig) { + return searchConfig; + } + + const currentScoped = isRecord(searchConfig?.[key]) ? searchConfig?.[key] : {}; + const next: Record = { + ...searchConfig, + [key]: { + ...currentScoped, + ...pluginConfig, + }, }; -}; -type BraveLlmContextResult = { url: string; title: string; snippets: string[] }; -type BraveLlmContextResponse = { - grounding: { generic?: BraveLlmContextResult[] }; - sources?: { url?: string; hostname?: string; date?: string }[]; -}; - -function resolveBraveConfig(searchConfig?: SearchConfigRecord): BraveConfig { - const brave = searchConfig?.brave; - return brave && typeof brave === "object" && !Array.isArray(brave) ? (brave as BraveConfig) : {}; -} - -function resolveBraveMode(brave?: BraveConfig): "web" | "llm-context" { - return brave?.mode === "llm-context" ? "llm-context" : "web"; -} - -function resolveBraveApiKey(searchConfig?: SearchConfigRecord): string | undefined { - return ( - readConfiguredSecretString(searchConfig?.apiKey, "tools.web.search.apiKey") ?? - readProviderEnvValue(["BRAVE_API_KEY"]) - ); -} - -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 normalizeBraveCountry(value: string | undefined): string | undefined { - if (!value) { - return undefined; - } - const trimmed = value.trim(); - if (!trimmed) { - return undefined; - } - const canonical = trimmed.toUpperCase(); - return BRAVE_COUNTRY_CODES.has(canonical) ? canonical : "ALL"; -} - -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; - - if (normalizeBraveUiLang(rawSearchLang) && normalizeBraveSearchLang(rawUiLang)) { - searchLangCandidate = rawUiLang; - uiLangCandidate = rawSearchLang; + if (options?.mirrorApiKeyToTopLevel && pluginConfig.apiKey !== undefined) { + next.apiKey = pluginConfig.apiKey; } - 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 }; -} - -function mapBraveLlmContextResults( - data: BraveLlmContextResponse, -): { url: string; title: string; snippets: string[]; siteName?: string }[] { - const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : []; - return genericResults.map((entry) => ({ - url: entry.url ?? "", - title: entry.title ?? "", - snippets: (entry.snippets ?? []).filter((s) => typeof s === "string" && s.length > 0), - siteName: resolveSiteName(entry.url) || undefined, - })); -} - -async function runBraveLlmContextSearch(params: { - query: string; - apiKey: string; - timeoutSeconds: number; - country?: string; - search_lang?: string; - freshness?: string; -}): Promise<{ - results: Array<{ - url: string; - title: string; - snippets: string[]; - siteName?: string; - }>; - sources?: BraveLlmContextResponse["sources"]; -}> { - const url = new URL(BRAVE_LLM_CONTEXT_ENDPOINT); - url.searchParams.set("q", params.query); - if (params.country) { - url.searchParams.set("country", params.country); - } - if (params.search_lang) { - url.searchParams.set("search_lang", params.search_lang); - } - if (params.freshness) { - url.searchParams.set("freshness", params.freshness); - } - - return withTrustedWebSearchEndpoint( - { - url: url.toString(), - timeoutSeconds: params.timeoutSeconds, - init: { - method: "GET", - headers: { - Accept: "application/json", - "X-Subscription-Token": params.apiKey, - }, - }, - }, - async (res) => { - if (!res.ok) { - const detail = await res.text(); - throw new Error(`Brave LLM Context API error (${res.status}): ${detail || res.statusText}`); - } - - const data = (await res.json()) as BraveLlmContextResponse; - return { results: mapBraveLlmContextResults(data), sources: data.sources }; - }, - ); -} - -async function runBraveWebSearch(params: { - query: string; - count: number; - apiKey: string; - timeoutSeconds: number; - country?: string; - search_lang?: string; - ui_lang?: string; - freshness?: string; - dateAfter?: string; - dateBefore?: string; -}): Promise>> { - const url = new URL(BRAVE_SEARCH_ENDPOINT); - url.searchParams.set("q", params.query); - url.searchParams.set("count", String(params.count)); - if (params.country) { - url.searchParams.set("country", params.country); - } - if (params.search_lang) { - url.searchParams.set("search_lang", params.search_lang); - } - if (params.ui_lang) { - url.searchParams.set("ui_lang", params.ui_lang); - } - if (params.freshness) { - url.searchParams.set("freshness", params.freshness); - } else if (params.dateAfter && params.dateBefore) { - url.searchParams.set("freshness", `${params.dateAfter}to${params.dateBefore}`); - } else if (params.dateAfter) { - url.searchParams.set( - "freshness", - `${params.dateAfter}to${new Date().toISOString().slice(0, 10)}`, - ); - } else if (params.dateBefore) { - url.searchParams.set("freshness", `1970-01-01to${params.dateBefore}`); - } - - return withTrustedWebSearchEndpoint( - { - url: url.toString(), - timeoutSeconds: params.timeoutSeconds, - init: { - method: "GET", - headers: { - Accept: "application/json", - "X-Subscription-Token": params.apiKey, - }, - }, - }, - async (res) => { - if (!res.ok) { - const detail = await res.text(); - throw new Error(`Brave Search API error (${res.status}): ${detail || res.statusText}`); - } - - const data = (await res.json()) as BraveSearchResponse; - const results = Array.isArray(data.web?.results) ? (data.web?.results ?? []) : []; - return results.map((entry) => { - const description = entry.description ?? ""; - const title = entry.title ?? ""; - const url = entry.url ?? ""; - return { - title: title ? wrapWebContent(title, "web_search") : "", - url, - description: description ? wrapWebContent(description, "web_search") : "", - published: entry.age || undefined, - siteName: resolveSiteName(url) || undefined, - }; - }); - }, - ); -} - -function createBraveSchema() { - 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, - }), - ), - country: Type.Optional( - Type.String({ - description: - "2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.", - }), - ), - language: Type.Optional( - Type.String({ - description: "ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').", - }), - ), - freshness: Type.Optional( - Type.String({ - description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.", - }), - ), - date_after: Type.Optional( - Type.String({ - description: "Only results published after this date (YYYY-MM-DD).", - }), - ), - date_before: Type.Optional( - Type.String({ - description: "Only results published before this date (YYYY-MM-DD).", - }), - ), - 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.", - }), - ), - }); -} - -function missingBraveKeyPayload() { - return { - error: "missing_brave_api_key", - message: `web_search (brave) needs a Brave Search API key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`, - docs: "https://docs.openclaw.ai/tools/web", - }; + return next; } function createBraveToolDefinition( searchConfig?: SearchConfigRecord, ): WebSearchProviderToolDefinition { - const braveConfig = resolveBraveConfig(searchConfig); - const braveMode = resolveBraveMode(braveConfig); + const braveMode = resolveBraveMode(resolveBraveConfig(searchConfig)); return { description: @@ -452,178 +109,8 @@ function createBraveToolDefinition( : "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.", parameters: createBraveSchema(), execute: async (args) => { - const apiKey = resolveBraveApiKey(searchConfig); - if (!apiKey) { - return missingBraveKeyPayload(); - } - - const params = args; - const query = readStringParam(params, "query", { required: true }); - const count = - readNumberParam(params, "count", { integer: true }) ?? - searchConfig?.maxResults ?? - undefined; - const country = normalizeBraveCountry(readStringParam(params, "country")); - const language = readStringParam(params, "language"); - const search_lang = readStringParam(params, "search_lang"); - const ui_lang = readStringParam(params, "ui_lang"); - const normalizedLanguage = normalizeBraveLanguageParams({ - search_lang: search_lang || language, - ui_lang, - }); - if (normalizedLanguage.invalidField === "search_lang") { - return { - error: "invalid_search_lang", - message: - "search_lang must be a Brave-supported language code like 'en', 'en-gb', 'zh-hans', or 'zh-hant'.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - if (normalizedLanguage.invalidField === "ui_lang") { - return { - error: "invalid_ui_lang", - message: "ui_lang must be a language-region locale like 'en-US'.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - if (normalizedLanguage.ui_lang && braveMode === "llm-context") { - return { - error: "unsupported_ui_lang", - message: - "ui_lang is not supported by Brave llm-context mode. Remove ui_lang or use Brave web mode for locale-based UI hints.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - - const rawFreshness = readStringParam(params, "freshness"); - if (rawFreshness && braveMode === "llm-context") { - return { - error: "unsupported_freshness", - message: - "freshness filtering is not supported by Brave llm-context mode. Remove freshness or use Brave web mode.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - const freshness = rawFreshness ? normalizeFreshness(rawFreshness, "brave") : undefined; - if (rawFreshness && !freshness) { - return { - error: "invalid_freshness", - message: "freshness must be day, week, month, or year.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - - const rawDateAfter = readStringParam(params, "date_after"); - const rawDateBefore = readStringParam(params, "date_before"); - if (rawFreshness && (rawDateAfter || rawDateBefore)) { - return { - error: "conflicting_time_filters", - message: - "freshness and date_after/date_before cannot be used together. Use either freshness (day/week/month/year) or a date range (date_after/date_before), not both.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - if ((rawDateAfter || rawDateBefore) && braveMode === "llm-context") { - return { - error: "unsupported_date_filter", - message: - "date_after/date_before filtering is not supported by Brave llm-context mode. Use Brave web mode for date filters.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - const parsedDateRange = parseIsoDateRange({ - rawDateAfter, - rawDateBefore, - invalidDateAfterMessage: "date_after must be YYYY-MM-DD format.", - invalidDateBeforeMessage: "date_before must be YYYY-MM-DD format.", - invalidDateRangeMessage: "date_after must be before date_before.", - }); - if ("error" in parsedDateRange) { - return parsedDateRange; - } - const { dateAfter, dateBefore } = parsedDateRange; - - const cacheKey = buildSearchCacheKey([ - "brave", - braveMode, - query, - resolveSearchCount(count, DEFAULT_SEARCH_COUNT), - country, - normalizedLanguage.search_lang, - normalizedLanguage.ui_lang, - freshness, - dateAfter, - dateBefore, - ]); - const cached = readCachedSearchPayload(cacheKey); - if (cached) { - return cached; - } - - const start = Date.now(); - const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig); - const cacheTtlMs = resolveSearchCacheTtlMs(searchConfig); - - if (braveMode === "llm-context") { - const { results, sources } = await runBraveLlmContextSearch({ - query, - apiKey, - timeoutSeconds, - country: country ?? undefined, - search_lang: normalizedLanguage.search_lang, - freshness, - }); - const payload = { - query, - provider: "brave", - mode: "llm-context" as const, - count: results.length, - tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: "brave", - wrapped: true, - }, - results: results.map((entry) => ({ - title: entry.title ? wrapWebContent(entry.title, "web_search") : "", - url: entry.url, - snippets: entry.snippets.map((snippet) => wrapWebContent(snippet, "web_search")), - siteName: entry.siteName, - })), - sources, - }; - writeCachedSearchPayload(cacheKey, payload, cacheTtlMs); - return payload; - } - - const results = await runBraveWebSearch({ - query, - count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT), - apiKey, - timeoutSeconds, - country: country ?? undefined, - search_lang: normalizedLanguage.search_lang, - ui_lang: normalizedLanguage.ui_lang, - freshness, - dateAfter, - dateBefore, - }); - const payload = { - query, - provider: "brave", - count: results.length, - tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: "brave", - wrapped: true, - }, - results, - }; - writeCachedSearchPayload(cacheKey, payload, cacheTtlMs); - return payload; + const { executeBraveSearch } = await import("./brave-web-search-provider.runtime.js"); + return await executeBraveSearch(args, searchConfig); }, }; } @@ -662,7 +149,6 @@ export function createBraveWebSearchProvider(): WebSearchProviderPlugin { } export const __testing = { - normalizeFreshness, normalizeBraveCountry, normalizeBraveLanguageParams, resolveBraveMode,