@gakr-gakr/brave-plugin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/autobot.plugin.json +62 -0
- package/index.ts +11 -0
- package/package.json +34 -0
- package/src/brave-web-search-provider.runtime.ts +570 -0
- package/src/brave-web-search-provider.shared.ts +227 -0
- package/src/brave-web-search-provider.ts +186 -0
- package/test-api.ts +14 -0
- package/tsconfig.json +16 -0
- package/web-search-contract-api.ts +70 -0
- package/web-search-provider.ts +1 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "brave",
|
|
3
|
+
"activation": {
|
|
4
|
+
"onStartup": false
|
|
5
|
+
},
|
|
6
|
+
"providerAuthEnvVars": {
|
|
7
|
+
"brave": ["BRAVE_API_KEY"]
|
|
8
|
+
},
|
|
9
|
+
"setup": {
|
|
10
|
+
"providers": [
|
|
11
|
+
{
|
|
12
|
+
"id": "brave",
|
|
13
|
+
"authMethods": ["api-key"],
|
|
14
|
+
"envVars": ["BRAVE_API_KEY"]
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"uiHints": {
|
|
19
|
+
"webSearch.apiKey": {
|
|
20
|
+
"label": "Brave Search API Key",
|
|
21
|
+
"help": "Brave Search API key (fallback: BRAVE_API_KEY env var).",
|
|
22
|
+
"sensitive": true,
|
|
23
|
+
"placeholder": "BSA..."
|
|
24
|
+
},
|
|
25
|
+
"webSearch.mode": {
|
|
26
|
+
"label": "Brave Search Mode",
|
|
27
|
+
"help": "Brave Search mode: web or llm-context."
|
|
28
|
+
},
|
|
29
|
+
"webSearch.baseUrl": {
|
|
30
|
+
"label": "Brave Search Base URL",
|
|
31
|
+
"help": "Optional Brave-compatible API base URL for trusted proxies. Defaults to https://api.search.brave.com."
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"contracts": {
|
|
35
|
+
"webSearchProviders": ["brave"]
|
|
36
|
+
},
|
|
37
|
+
"configContracts": {
|
|
38
|
+
"compatibilityRuntimePaths": ["tools.web.search.apiKey"]
|
|
39
|
+
},
|
|
40
|
+
"configSchema": {
|
|
41
|
+
"type": "object",
|
|
42
|
+
"additionalProperties": false,
|
|
43
|
+
"properties": {
|
|
44
|
+
"webSearch": {
|
|
45
|
+
"type": "object",
|
|
46
|
+
"additionalProperties": false,
|
|
47
|
+
"properties": {
|
|
48
|
+
"apiKey": {
|
|
49
|
+
"type": ["string", "object"]
|
|
50
|
+
},
|
|
51
|
+
"mode": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"enum": ["web", "llm-context"]
|
|
54
|
+
},
|
|
55
|
+
"baseUrl": {
|
|
56
|
+
"type": ["string", "object"]
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { definePluginEntry } from "autobot/plugin-sdk/plugin-entry";
|
|
2
|
+
import { createBraveWebSearchProvider } from "./src/brave-web-search-provider.js";
|
|
3
|
+
|
|
4
|
+
export default definePluginEntry({
|
|
5
|
+
id: "brave",
|
|
6
|
+
name: "Brave Plugin",
|
|
7
|
+
description: "Bundled Brave plugin",
|
|
8
|
+
register(api) {
|
|
9
|
+
api.registerWebSearchProvider(createBraveWebSearchProvider());
|
|
10
|
+
},
|
|
11
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gakr-gakr/brave-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AutoBot Brave plugin",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/autobot/autobot"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"@gakr-gakr/plugin-sdk": "workspace:*"
|
|
12
|
+
},
|
|
13
|
+
"autobot": {
|
|
14
|
+
"extensions": [
|
|
15
|
+
"./index.ts"
|
|
16
|
+
],
|
|
17
|
+
"install": {
|
|
18
|
+
"npmSpec": "@gakr-gakr/brave-plugin",
|
|
19
|
+
"defaultChoice": "npm",
|
|
20
|
+
"minHostVersion": ">=2026.4.10",
|
|
21
|
+
"allowInvalidConfigRecovery": true
|
|
22
|
+
},
|
|
23
|
+
"compat": {
|
|
24
|
+
"pluginApi": ">=2026.5.19"
|
|
25
|
+
},
|
|
26
|
+
"build": {
|
|
27
|
+
"autobotVersion": "2026.5.19"
|
|
28
|
+
},
|
|
29
|
+
"release": {
|
|
30
|
+
"publishToClawHub": true,
|
|
31
|
+
"publishToNpm": true
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
import { readProviderJsonResponse } from "autobot/plugin-sdk/provider-http";
|
|
2
|
+
import type { SearchConfigRecord } from "autobot/plugin-sdk/provider-web-search";
|
|
3
|
+
import {
|
|
4
|
+
buildSearchCacheKey,
|
|
5
|
+
DEFAULT_SEARCH_COUNT,
|
|
6
|
+
formatCliCommand,
|
|
7
|
+
normalizeFreshness,
|
|
8
|
+
parseIsoDateRange,
|
|
9
|
+
readCachedSearchPayload,
|
|
10
|
+
readConfiguredSecretString,
|
|
11
|
+
readNumberParam,
|
|
12
|
+
readProviderEnvValue,
|
|
13
|
+
readStringParam,
|
|
14
|
+
resolveSearchCacheTtlMs,
|
|
15
|
+
resolveSearchCount,
|
|
16
|
+
resolveSearchTimeoutSeconds,
|
|
17
|
+
resolveSiteName,
|
|
18
|
+
withSelfHostedWebSearchEndpoint,
|
|
19
|
+
withTrustedWebSearchEndpoint,
|
|
20
|
+
wrapWebContent,
|
|
21
|
+
writeCachedSearchPayload,
|
|
22
|
+
} from "autobot/plugin-sdk/provider-web-search";
|
|
23
|
+
import { createSubsystemLogger } from "autobot/plugin-sdk/runtime-env";
|
|
24
|
+
import {
|
|
25
|
+
assertHttpUrlTargetsPrivateNetwork,
|
|
26
|
+
isBlockedHostnameOrIp,
|
|
27
|
+
isPrivateIpAddress,
|
|
28
|
+
resolvePinnedHostnameWithPolicy,
|
|
29
|
+
} from "autobot/plugin-sdk/ssrf-runtime";
|
|
30
|
+
import {
|
|
31
|
+
type BraveLlmContextResponse,
|
|
32
|
+
mapBraveLlmContextResults,
|
|
33
|
+
normalizeBraveCountry,
|
|
34
|
+
normalizeBraveLanguageParams,
|
|
35
|
+
resolveBraveConfig,
|
|
36
|
+
resolveBraveMode,
|
|
37
|
+
} from "./brave-web-search-provider.shared.js";
|
|
38
|
+
|
|
39
|
+
const DEFAULT_BRAVE_BASE_URL = "https://api.search.brave.com";
|
|
40
|
+
const BRAVE_SEARCH_ENDPOINT_PATH = "/res/v1/web/search";
|
|
41
|
+
const BRAVE_LLM_CONTEXT_ENDPOINT_PATH = "/res/v1/llm/context";
|
|
42
|
+
const braveHttpLogger = createSubsystemLogger("brave/http");
|
|
43
|
+
type BraveEndpointMode = "selfHosted" | "strict";
|
|
44
|
+
|
|
45
|
+
type BraveSearchResult = {
|
|
46
|
+
title?: string;
|
|
47
|
+
url?: string;
|
|
48
|
+
description?: string;
|
|
49
|
+
age?: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type BraveSearchResponse = {
|
|
53
|
+
web?: {
|
|
54
|
+
results?: BraveSearchResult[];
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type BraveHttpDiagnostics = {
|
|
59
|
+
enabled?: boolean;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function logBraveHttp(
|
|
63
|
+
diagnostics: BraveHttpDiagnostics | undefined,
|
|
64
|
+
event: string,
|
|
65
|
+
meta?: Record<string, unknown>,
|
|
66
|
+
): void {
|
|
67
|
+
if (!diagnostics?.enabled) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
braveHttpLogger.info(`brave http ${event}`, meta);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function describeBraveRequestUrl(url: URL): {
|
|
74
|
+
url: string;
|
|
75
|
+
query: string;
|
|
76
|
+
params: Record<string, string>;
|
|
77
|
+
} {
|
|
78
|
+
return {
|
|
79
|
+
url: url.toString(),
|
|
80
|
+
query: url.searchParams.get("q") ?? "",
|
|
81
|
+
params: Object.fromEntries(url.searchParams.entries()),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function resolveBraveApiKey(searchConfig?: SearchConfigRecord): string | undefined {
|
|
86
|
+
return (
|
|
87
|
+
readConfiguredSecretString(searchConfig?.apiKey, "tools.web.search.apiKey") ??
|
|
88
|
+
readProviderEnvValue(["BRAVE_API_KEY"])
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function resolveBraveBaseUrl(braveConfig: { baseUrl?: unknown } | undefined): string {
|
|
93
|
+
const configured = readConfiguredSecretString(
|
|
94
|
+
braveConfig?.baseUrl,
|
|
95
|
+
"plugins.entries.brave.config.webSearch.baseUrl",
|
|
96
|
+
);
|
|
97
|
+
return configured?.replace(/\/+$/u, "") || DEFAULT_BRAVE_BASE_URL;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function buildBraveEndpointUrl(params: { baseUrl: string; endpointPath: string }): URL {
|
|
101
|
+
const url = new URL(params.baseUrl);
|
|
102
|
+
const basePath = url.pathname.replace(/\/+$/u, "");
|
|
103
|
+
url.pathname = `${basePath}${params.endpointPath}`;
|
|
104
|
+
url.search = "";
|
|
105
|
+
return url;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function braveEndpointTargetsPrivateNetwork(url: URL): Promise<boolean> {
|
|
109
|
+
if (isBlockedHostnameOrIp(url.hostname)) {
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const pinned = await resolvePinnedHostnameWithPolicy(url.hostname, {
|
|
114
|
+
policy: {
|
|
115
|
+
allowPrivateNetwork: true,
|
|
116
|
+
allowRfc2544BenchmarkRange: true,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
return pinned.addresses.every((address) => isPrivateIpAddress(address));
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function validateBraveBaseUrl(baseUrl: string): Promise<BraveEndpointMode> {
|
|
126
|
+
let parsed: URL;
|
|
127
|
+
try {
|
|
128
|
+
parsed = new URL(baseUrl);
|
|
129
|
+
} catch {
|
|
130
|
+
throw new Error("Brave Search base URL must be a valid http:// or https:// URL.");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
134
|
+
throw new Error("Brave Search base URL must use http:// or https://.");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (parsed.protocol === "http:") {
|
|
138
|
+
await assertHttpUrlTargetsPrivateNetwork(parsed.toString(), {
|
|
139
|
+
dangerouslyAllowPrivateNetwork: true,
|
|
140
|
+
errorMessage:
|
|
141
|
+
"Brave Search HTTP base URL must target a trusted private or loopback host. Use https:// for public hosts.",
|
|
142
|
+
});
|
|
143
|
+
return "selfHosted";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return (await braveEndpointTargetsPrivateNetwork(parsed)) ? "selfHosted" : "strict";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function missingBraveKeyPayload() {
|
|
150
|
+
return {
|
|
151
|
+
error: "missing_brave_api_key",
|
|
152
|
+
message: `web_search (brave) needs a Brave Search API key. Run \`${formatCliCommand("autobot configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment. If you do not want to configure a search API key, use web_fetch for a specific URL or the browser tool for interactive pages.`,
|
|
153
|
+
docs: "https://docs.openclaw.ai/tools/web",
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function runBraveLlmContextSearch(params: {
|
|
158
|
+
baseUrl: string;
|
|
159
|
+
endpointMode: BraveEndpointMode;
|
|
160
|
+
query: string;
|
|
161
|
+
apiKey: string;
|
|
162
|
+
timeoutSeconds: number;
|
|
163
|
+
diagnostics?: BraveHttpDiagnostics;
|
|
164
|
+
country?: string;
|
|
165
|
+
search_lang?: string;
|
|
166
|
+
freshness?: string;
|
|
167
|
+
dateAfter?: string;
|
|
168
|
+
dateBefore?: string;
|
|
169
|
+
}): Promise<{
|
|
170
|
+
results: Array<{
|
|
171
|
+
url: string;
|
|
172
|
+
title: string;
|
|
173
|
+
snippets: string[];
|
|
174
|
+
siteName?: string;
|
|
175
|
+
}>;
|
|
176
|
+
sources?: BraveLlmContextResponse["sources"];
|
|
177
|
+
}> {
|
|
178
|
+
const url = buildBraveEndpointUrl({
|
|
179
|
+
baseUrl: params.baseUrl,
|
|
180
|
+
endpointPath: BRAVE_LLM_CONTEXT_ENDPOINT_PATH,
|
|
181
|
+
});
|
|
182
|
+
url.searchParams.set("q", params.query);
|
|
183
|
+
if (params.country) {
|
|
184
|
+
url.searchParams.set("country", params.country);
|
|
185
|
+
}
|
|
186
|
+
if (params.search_lang) {
|
|
187
|
+
url.searchParams.set("search_lang", params.search_lang);
|
|
188
|
+
}
|
|
189
|
+
if (params.freshness) {
|
|
190
|
+
url.searchParams.set("freshness", params.freshness);
|
|
191
|
+
} else if (params.dateAfter && params.dateBefore) {
|
|
192
|
+
url.searchParams.set("freshness", `${params.dateAfter}to${params.dateBefore}`);
|
|
193
|
+
} else if (params.dateAfter) {
|
|
194
|
+
url.searchParams.set(
|
|
195
|
+
"freshness",
|
|
196
|
+
`${params.dateAfter}to${new Date().toISOString().slice(0, 10)}`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
logBraveHttp(params.diagnostics, "request", {
|
|
201
|
+
mode: "llm-context",
|
|
202
|
+
...describeBraveRequestUrl(url),
|
|
203
|
+
});
|
|
204
|
+
const startedAt = Date.now();
|
|
205
|
+
const withEndpoint =
|
|
206
|
+
params.endpointMode === "selfHosted"
|
|
207
|
+
? withSelfHostedWebSearchEndpoint
|
|
208
|
+
: withTrustedWebSearchEndpoint;
|
|
209
|
+
return withEndpoint(
|
|
210
|
+
{
|
|
211
|
+
url: url.toString(),
|
|
212
|
+
timeoutSeconds: params.timeoutSeconds,
|
|
213
|
+
init: {
|
|
214
|
+
method: "GET",
|
|
215
|
+
headers: {
|
|
216
|
+
Accept: "application/json",
|
|
217
|
+
"X-Subscription-Token": params.apiKey,
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
async (response) => {
|
|
222
|
+
logBraveHttp(params.diagnostics, "response", {
|
|
223
|
+
mode: "llm-context",
|
|
224
|
+
status: response.status,
|
|
225
|
+
ok: response.ok,
|
|
226
|
+
durationMs: Date.now() - startedAt,
|
|
227
|
+
});
|
|
228
|
+
if (!response.ok) {
|
|
229
|
+
const detail = await response.text();
|
|
230
|
+
throw new Error(
|
|
231
|
+
`Brave LLM Context API error (${response.status}): ${detail || response.statusText}`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const data = await readProviderJsonResponse<BraveLlmContextResponse>(
|
|
236
|
+
response,
|
|
237
|
+
"Brave LLM Context API error",
|
|
238
|
+
);
|
|
239
|
+
return { results: mapBraveLlmContextResults(data), sources: data.sources };
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function runBraveWebSearch(params: {
|
|
245
|
+
baseUrl: string;
|
|
246
|
+
endpointMode: BraveEndpointMode;
|
|
247
|
+
query: string;
|
|
248
|
+
count: number;
|
|
249
|
+
apiKey: string;
|
|
250
|
+
timeoutSeconds: number;
|
|
251
|
+
diagnostics?: BraveHttpDiagnostics;
|
|
252
|
+
country?: string;
|
|
253
|
+
search_lang?: string;
|
|
254
|
+
ui_lang?: string;
|
|
255
|
+
freshness?: string;
|
|
256
|
+
dateAfter?: string;
|
|
257
|
+
dateBefore?: string;
|
|
258
|
+
}): Promise<Array<Record<string, unknown>>> {
|
|
259
|
+
const url = buildBraveEndpointUrl({
|
|
260
|
+
baseUrl: params.baseUrl,
|
|
261
|
+
endpointPath: BRAVE_SEARCH_ENDPOINT_PATH,
|
|
262
|
+
});
|
|
263
|
+
url.searchParams.set("q", params.query);
|
|
264
|
+
url.searchParams.set("count", String(params.count));
|
|
265
|
+
if (params.country) {
|
|
266
|
+
url.searchParams.set("country", params.country);
|
|
267
|
+
}
|
|
268
|
+
if (params.search_lang) {
|
|
269
|
+
url.searchParams.set("search_lang", params.search_lang);
|
|
270
|
+
}
|
|
271
|
+
if (params.ui_lang) {
|
|
272
|
+
url.searchParams.set("ui_lang", params.ui_lang);
|
|
273
|
+
}
|
|
274
|
+
if (params.freshness) {
|
|
275
|
+
url.searchParams.set("freshness", params.freshness);
|
|
276
|
+
} else if (params.dateAfter && params.dateBefore) {
|
|
277
|
+
url.searchParams.set("freshness", `${params.dateAfter}to${params.dateBefore}`);
|
|
278
|
+
} else if (params.dateAfter) {
|
|
279
|
+
url.searchParams.set(
|
|
280
|
+
"freshness",
|
|
281
|
+
`${params.dateAfter}to${new Date().toISOString().slice(0, 10)}`,
|
|
282
|
+
);
|
|
283
|
+
} else if (params.dateBefore) {
|
|
284
|
+
url.searchParams.set("freshness", `1970-01-01to${params.dateBefore}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
logBraveHttp(params.diagnostics, "request", {
|
|
288
|
+
mode: "web",
|
|
289
|
+
...describeBraveRequestUrl(url),
|
|
290
|
+
});
|
|
291
|
+
const startedAt = Date.now();
|
|
292
|
+
const withEndpoint =
|
|
293
|
+
params.endpointMode === "selfHosted"
|
|
294
|
+
? withSelfHostedWebSearchEndpoint
|
|
295
|
+
: withTrustedWebSearchEndpoint;
|
|
296
|
+
return withEndpoint(
|
|
297
|
+
{
|
|
298
|
+
url: url.toString(),
|
|
299
|
+
timeoutSeconds: params.timeoutSeconds,
|
|
300
|
+
init: {
|
|
301
|
+
method: "GET",
|
|
302
|
+
headers: {
|
|
303
|
+
Accept: "application/json",
|
|
304
|
+
"X-Subscription-Token": params.apiKey,
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
async (response) => {
|
|
309
|
+
logBraveHttp(params.diagnostics, "response", {
|
|
310
|
+
mode: "web",
|
|
311
|
+
status: response.status,
|
|
312
|
+
ok: response.ok,
|
|
313
|
+
durationMs: Date.now() - startedAt,
|
|
314
|
+
});
|
|
315
|
+
if (!response.ok) {
|
|
316
|
+
const detail = await response.text();
|
|
317
|
+
throw new Error(
|
|
318
|
+
`Brave Search API error (${response.status}): ${detail || response.statusText}`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const data = await readProviderJsonResponse<BraveSearchResponse>(
|
|
323
|
+
response,
|
|
324
|
+
"Brave Search API error",
|
|
325
|
+
);
|
|
326
|
+
const results = Array.isArray(data.web?.results) ? (data.web?.results ?? []) : [];
|
|
327
|
+
return results.map((entry) => {
|
|
328
|
+
const description = entry.description ?? "";
|
|
329
|
+
const title = entry.title ?? "";
|
|
330
|
+
const url = entry.url ?? "";
|
|
331
|
+
return {
|
|
332
|
+
title: title ? wrapWebContent(title, "web_search") : "",
|
|
333
|
+
url,
|
|
334
|
+
description: description ? wrapWebContent(description, "web_search") : "",
|
|
335
|
+
published: entry.age || undefined,
|
|
336
|
+
siteName: resolveSiteName(url) || undefined,
|
|
337
|
+
};
|
|
338
|
+
});
|
|
339
|
+
},
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export async function executeBraveSearch(
|
|
344
|
+
args: Record<string, unknown>,
|
|
345
|
+
searchConfig?: SearchConfigRecord,
|
|
346
|
+
options?: {
|
|
347
|
+
diagnosticsEnabled?: boolean;
|
|
348
|
+
},
|
|
349
|
+
): Promise<Record<string, unknown>> {
|
|
350
|
+
const apiKey = resolveBraveApiKey(searchConfig);
|
|
351
|
+
if (!apiKey) {
|
|
352
|
+
return missingBraveKeyPayload();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const braveConfig = resolveBraveConfig(searchConfig);
|
|
356
|
+
const braveMode = resolveBraveMode(braveConfig);
|
|
357
|
+
const braveBaseUrl = resolveBraveBaseUrl(braveConfig);
|
|
358
|
+
const braveEndpointMode = await validateBraveBaseUrl(braveBaseUrl);
|
|
359
|
+
const query = readStringParam(args, "query", { required: true });
|
|
360
|
+
const count =
|
|
361
|
+
readNumberParam(args, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined;
|
|
362
|
+
const country = normalizeBraveCountry(readStringParam(args, "country"));
|
|
363
|
+
const language = readStringParam(args, "language");
|
|
364
|
+
const search_lang = readStringParam(args, "search_lang");
|
|
365
|
+
const ui_lang = readStringParam(args, "ui_lang");
|
|
366
|
+
const normalizedLanguage = normalizeBraveLanguageParams({
|
|
367
|
+
search_lang: search_lang || language,
|
|
368
|
+
ui_lang,
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
if (normalizedLanguage.invalidField === "search_lang") {
|
|
372
|
+
return {
|
|
373
|
+
error: "invalid_search_lang",
|
|
374
|
+
message:
|
|
375
|
+
"search_lang must be a Brave-supported language code like 'en', 'en-gb', 'zh-hans', or 'zh-hant'.",
|
|
376
|
+
docs: "https://docs.openclaw.ai/tools/web",
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
if (normalizedLanguage.invalidField === "ui_lang") {
|
|
380
|
+
return {
|
|
381
|
+
error: "invalid_ui_lang",
|
|
382
|
+
message: "ui_lang must be a language-region locale like 'en-US'.",
|
|
383
|
+
docs: "https://docs.openclaw.ai/tools/web",
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
if (normalizedLanguage.ui_lang && braveMode === "llm-context") {
|
|
387
|
+
return {
|
|
388
|
+
error: "unsupported_ui_lang",
|
|
389
|
+
message:
|
|
390
|
+
"ui_lang is not supported by Brave llm-context mode. Remove ui_lang or use Brave web mode for locale-based UI hints.",
|
|
391
|
+
docs: "https://docs.openclaw.ai/tools/web",
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const rawFreshness = readStringParam(args, "freshness");
|
|
396
|
+
const freshness = rawFreshness ? normalizeFreshness(rawFreshness, "brave") : undefined;
|
|
397
|
+
if (rawFreshness && !freshness) {
|
|
398
|
+
return {
|
|
399
|
+
error: "invalid_freshness",
|
|
400
|
+
message: "freshness must be day, week, month, or year.",
|
|
401
|
+
docs: "https://docs.openclaw.ai/tools/web",
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const rawDateAfter = readStringParam(args, "date_after");
|
|
406
|
+
const rawDateBefore = readStringParam(args, "date_before");
|
|
407
|
+
if (rawFreshness && (rawDateAfter || rawDateBefore)) {
|
|
408
|
+
return {
|
|
409
|
+
error: "conflicting_time_filters",
|
|
410
|
+
message:
|
|
411
|
+
"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.",
|
|
412
|
+
docs: "https://docs.openclaw.ai/tools/web",
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
const parsedDateRange = parseIsoDateRange({
|
|
416
|
+
rawDateAfter,
|
|
417
|
+
rawDateBefore,
|
|
418
|
+
invalidDateAfterMessage: "date_after must be YYYY-MM-DD format.",
|
|
419
|
+
invalidDateBeforeMessage: "date_before must be YYYY-MM-DD format.",
|
|
420
|
+
invalidDateRangeMessage: "date_after must be before date_before.",
|
|
421
|
+
});
|
|
422
|
+
if ("error" in parsedDateRange) {
|
|
423
|
+
return parsedDateRange;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const { dateAfter, dateBefore } = parsedDateRange;
|
|
427
|
+
if (braveMode === "llm-context") {
|
|
428
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
429
|
+
if (dateAfter && !dateBefore && dateAfter > today) {
|
|
430
|
+
return {
|
|
431
|
+
error: "invalid_date_range",
|
|
432
|
+
message: "date_after cannot be in the future for Brave llm-context mode.",
|
|
433
|
+
docs: "https://docs.openclaw.ai/tools/web",
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
if (dateBefore && !dateAfter) {
|
|
437
|
+
return {
|
|
438
|
+
error: "unsupported_date_filter",
|
|
439
|
+
message:
|
|
440
|
+
"Brave llm-context mode requires date_after when date_before is set. Use a bounded date range or freshness.",
|
|
441
|
+
docs: "https://docs.openclaw.ai/tools/web",
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
const llmContextDateEnd =
|
|
446
|
+
braveMode === "llm-context" && dateAfter
|
|
447
|
+
? (dateBefore ?? new Date().toISOString().slice(0, 10))
|
|
448
|
+
: dateBefore;
|
|
449
|
+
const cacheKey = buildSearchCacheKey(
|
|
450
|
+
braveMode === "llm-context"
|
|
451
|
+
? [
|
|
452
|
+
"brave",
|
|
453
|
+
braveMode,
|
|
454
|
+
braveBaseUrl,
|
|
455
|
+
query,
|
|
456
|
+
country,
|
|
457
|
+
normalizedLanguage.search_lang,
|
|
458
|
+
freshness,
|
|
459
|
+
dateAfter,
|
|
460
|
+
llmContextDateEnd,
|
|
461
|
+
]
|
|
462
|
+
: [
|
|
463
|
+
"brave",
|
|
464
|
+
braveMode,
|
|
465
|
+
braveBaseUrl,
|
|
466
|
+
query,
|
|
467
|
+
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
|
468
|
+
country,
|
|
469
|
+
normalizedLanguage.search_lang,
|
|
470
|
+
normalizedLanguage.ui_lang,
|
|
471
|
+
freshness,
|
|
472
|
+
dateAfter,
|
|
473
|
+
dateBefore,
|
|
474
|
+
],
|
|
475
|
+
);
|
|
476
|
+
const diagnostics: BraveHttpDiagnostics = { enabled: options?.diagnosticsEnabled === true };
|
|
477
|
+
const cached = readCachedSearchPayload(cacheKey);
|
|
478
|
+
if (cached) {
|
|
479
|
+
logBraveHttp(diagnostics, "cache hit", { mode: braveMode, query, cacheKey });
|
|
480
|
+
return cached;
|
|
481
|
+
}
|
|
482
|
+
logBraveHttp(diagnostics, "cache miss", { mode: braveMode, query, cacheKey });
|
|
483
|
+
|
|
484
|
+
const start = Date.now();
|
|
485
|
+
const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig);
|
|
486
|
+
const cacheTtlMs = resolveSearchCacheTtlMs(searchConfig);
|
|
487
|
+
|
|
488
|
+
if (braveMode === "llm-context") {
|
|
489
|
+
const { results, sources } = await runBraveLlmContextSearch({
|
|
490
|
+
baseUrl: braveBaseUrl,
|
|
491
|
+
endpointMode: braveEndpointMode,
|
|
492
|
+
query,
|
|
493
|
+
apiKey,
|
|
494
|
+
timeoutSeconds,
|
|
495
|
+
diagnostics,
|
|
496
|
+
country: country ?? undefined,
|
|
497
|
+
search_lang: normalizedLanguage.search_lang,
|
|
498
|
+
freshness,
|
|
499
|
+
dateAfter,
|
|
500
|
+
dateBefore,
|
|
501
|
+
});
|
|
502
|
+
const payload = {
|
|
503
|
+
query,
|
|
504
|
+
provider: "brave",
|
|
505
|
+
mode: "llm-context" as const,
|
|
506
|
+
count: results.length,
|
|
507
|
+
tookMs: Date.now() - start,
|
|
508
|
+
externalContent: {
|
|
509
|
+
untrusted: true,
|
|
510
|
+
source: "web_search",
|
|
511
|
+
provider: "brave",
|
|
512
|
+
wrapped: true,
|
|
513
|
+
},
|
|
514
|
+
results: results.map((entry) => ({
|
|
515
|
+
title: entry.title ? wrapWebContent(entry.title, "web_search") : "",
|
|
516
|
+
url: entry.url,
|
|
517
|
+
snippets: entry.snippets.map((snippet) => wrapWebContent(snippet, "web_search")),
|
|
518
|
+
siteName: entry.siteName,
|
|
519
|
+
})),
|
|
520
|
+
sources,
|
|
521
|
+
};
|
|
522
|
+
writeCachedSearchPayload(cacheKey, payload, cacheTtlMs);
|
|
523
|
+
logBraveHttp(diagnostics, "cache write", {
|
|
524
|
+
mode: "llm-context",
|
|
525
|
+
query,
|
|
526
|
+
cacheKey,
|
|
527
|
+
ttlMs: cacheTtlMs,
|
|
528
|
+
count: results.length,
|
|
529
|
+
});
|
|
530
|
+
return payload;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const results = await runBraveWebSearch({
|
|
534
|
+
baseUrl: braveBaseUrl,
|
|
535
|
+
endpointMode: braveEndpointMode,
|
|
536
|
+
query,
|
|
537
|
+
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
|
538
|
+
apiKey,
|
|
539
|
+
timeoutSeconds,
|
|
540
|
+
diagnostics,
|
|
541
|
+
country: country ?? undefined,
|
|
542
|
+
search_lang: normalizedLanguage.search_lang,
|
|
543
|
+
ui_lang: normalizedLanguage.ui_lang,
|
|
544
|
+
freshness,
|
|
545
|
+
dateAfter,
|
|
546
|
+
dateBefore,
|
|
547
|
+
});
|
|
548
|
+
const payload = {
|
|
549
|
+
query,
|
|
550
|
+
provider: "brave",
|
|
551
|
+
count: results.length,
|
|
552
|
+
tookMs: Date.now() - start,
|
|
553
|
+
externalContent: {
|
|
554
|
+
untrusted: true,
|
|
555
|
+
source: "web_search",
|
|
556
|
+
provider: "brave",
|
|
557
|
+
wrapped: true,
|
|
558
|
+
},
|
|
559
|
+
results,
|
|
560
|
+
};
|
|
561
|
+
writeCachedSearchPayload(cacheKey, payload, cacheTtlMs);
|
|
562
|
+
logBraveHttp(diagnostics, "cache write", {
|
|
563
|
+
mode: "web",
|
|
564
|
+
query,
|
|
565
|
+
cacheKey,
|
|
566
|
+
ttlMs: cacheTtlMs,
|
|
567
|
+
count: results.length,
|
|
568
|
+
});
|
|
569
|
+
return payload;
|
|
570
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeLowercaseStringOrEmpty,
|
|
3
|
+
normalizeOptionalString,
|
|
4
|
+
} from "autobot/plugin-sdk/string-coerce-runtime";
|
|
5
|
+
|
|
6
|
+
type BraveConfig = {
|
|
7
|
+
baseUrl?: unknown;
|
|
8
|
+
mode?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type BraveLlmContextResult = { url: string; title: string; snippets: string[] };
|
|
12
|
+
export type BraveLlmContextResponse = {
|
|
13
|
+
grounding: { generic?: BraveLlmContextResult[] };
|
|
14
|
+
sources?: { url?: string; hostname?: string; date?: string }[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const BRAVE_COUNTRY_CODES = new Set([
|
|
18
|
+
"AR",
|
|
19
|
+
"AU",
|
|
20
|
+
"AT",
|
|
21
|
+
"BE",
|
|
22
|
+
"BR",
|
|
23
|
+
"CA",
|
|
24
|
+
"CL",
|
|
25
|
+
"DK",
|
|
26
|
+
"FI",
|
|
27
|
+
"FR",
|
|
28
|
+
"DE",
|
|
29
|
+
"GR",
|
|
30
|
+
"HK",
|
|
31
|
+
"IN",
|
|
32
|
+
"ID",
|
|
33
|
+
"IT",
|
|
34
|
+
"JP",
|
|
35
|
+
"KR",
|
|
36
|
+
"MY",
|
|
37
|
+
"MX",
|
|
38
|
+
"NL",
|
|
39
|
+
"NZ",
|
|
40
|
+
"NO",
|
|
41
|
+
"CN",
|
|
42
|
+
"PL",
|
|
43
|
+
"PT",
|
|
44
|
+
"PH",
|
|
45
|
+
"RU",
|
|
46
|
+
"SA",
|
|
47
|
+
"ZA",
|
|
48
|
+
"ES",
|
|
49
|
+
"SE",
|
|
50
|
+
"CH",
|
|
51
|
+
"TW",
|
|
52
|
+
"TR",
|
|
53
|
+
"GB",
|
|
54
|
+
"US",
|
|
55
|
+
"ALL",
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
const BRAVE_SEARCH_LANG_CODES = new Set([
|
|
59
|
+
"ar",
|
|
60
|
+
"eu",
|
|
61
|
+
"bn",
|
|
62
|
+
"bg",
|
|
63
|
+
"ca",
|
|
64
|
+
"zh-hans",
|
|
65
|
+
"zh-hant",
|
|
66
|
+
"hr",
|
|
67
|
+
"cs",
|
|
68
|
+
"da",
|
|
69
|
+
"nl",
|
|
70
|
+
"en",
|
|
71
|
+
"en-gb",
|
|
72
|
+
"et",
|
|
73
|
+
"fi",
|
|
74
|
+
"fr",
|
|
75
|
+
"gl",
|
|
76
|
+
"de",
|
|
77
|
+
"el",
|
|
78
|
+
"gu",
|
|
79
|
+
"he",
|
|
80
|
+
"hi",
|
|
81
|
+
"hu",
|
|
82
|
+
"is",
|
|
83
|
+
"it",
|
|
84
|
+
"jp",
|
|
85
|
+
"kn",
|
|
86
|
+
"ko",
|
|
87
|
+
"lv",
|
|
88
|
+
"lt",
|
|
89
|
+
"ms",
|
|
90
|
+
"ml",
|
|
91
|
+
"mr",
|
|
92
|
+
"nb",
|
|
93
|
+
"pl",
|
|
94
|
+
"pt-br",
|
|
95
|
+
"pt-pt",
|
|
96
|
+
"pa",
|
|
97
|
+
"ro",
|
|
98
|
+
"ru",
|
|
99
|
+
"sr",
|
|
100
|
+
"sk",
|
|
101
|
+
"sl",
|
|
102
|
+
"es",
|
|
103
|
+
"sv",
|
|
104
|
+
"ta",
|
|
105
|
+
"te",
|
|
106
|
+
"th",
|
|
107
|
+
"tr",
|
|
108
|
+
"uk",
|
|
109
|
+
"vi",
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
const BRAVE_SEARCH_LANG_ALIASES: Record<string, string> = {
|
|
113
|
+
ja: "jp",
|
|
114
|
+
zh: "zh-hans",
|
|
115
|
+
"zh-cn": "zh-hans",
|
|
116
|
+
"zh-hk": "zh-hant",
|
|
117
|
+
"zh-sg": "zh-hans",
|
|
118
|
+
"zh-tw": "zh-hant",
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const BRAVE_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i;
|
|
122
|
+
|
|
123
|
+
function normalizeBraveSearchLang(value: string | undefined): string | undefined {
|
|
124
|
+
if (!value) {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
const trimmed = value.trim();
|
|
128
|
+
if (!trimmed) {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
const lower = normalizeLowercaseStringOrEmpty(trimmed);
|
|
132
|
+
const canonical = BRAVE_SEARCH_LANG_ALIASES[lower] ?? lower;
|
|
133
|
+
if (!BRAVE_SEARCH_LANG_CODES.has(canonical)) {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
return canonical;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function normalizeBraveCountry(value: string | undefined): string | undefined {
|
|
140
|
+
if (!value) {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
const trimmed = value.trim();
|
|
144
|
+
if (!trimmed) {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
const canonical = trimmed.toUpperCase();
|
|
148
|
+
return BRAVE_COUNTRY_CODES.has(canonical) ? canonical : "ALL";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function normalizeBraveUiLang(value: string | undefined): string | undefined {
|
|
152
|
+
if (!value) {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
const trimmed = value.trim();
|
|
156
|
+
if (!trimmed) {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
const match = trimmed.match(BRAVE_UI_LANG_LOCALE);
|
|
160
|
+
if (!match) {
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
const [, language, region] = match;
|
|
164
|
+
return `${normalizeLowercaseStringOrEmpty(language)}-${region.toUpperCase()}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function resolveBraveConfig(searchConfig?: Record<string, unknown>): BraveConfig {
|
|
168
|
+
const brave = searchConfig?.brave;
|
|
169
|
+
return brave && typeof brave === "object" && !Array.isArray(brave) ? (brave as BraveConfig) : {};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function resolveBraveMode(brave?: BraveConfig): "web" | "llm-context" {
|
|
173
|
+
return brave?.mode === "llm-context" ? "llm-context" : "web";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function normalizeBraveLanguageParams(params: { search_lang?: string; ui_lang?: string }): {
|
|
177
|
+
search_lang?: string;
|
|
178
|
+
ui_lang?: string;
|
|
179
|
+
invalidField?: "search_lang" | "ui_lang";
|
|
180
|
+
} {
|
|
181
|
+
const rawSearchLang = normalizeOptionalString(params.search_lang);
|
|
182
|
+
const rawUiLang = normalizeOptionalString(params.ui_lang);
|
|
183
|
+
let searchLangCandidate = rawSearchLang;
|
|
184
|
+
let uiLangCandidate = rawUiLang;
|
|
185
|
+
|
|
186
|
+
if (normalizeBraveUiLang(rawSearchLang) && normalizeBraveSearchLang(rawUiLang)) {
|
|
187
|
+
searchLangCandidate = rawUiLang;
|
|
188
|
+
uiLangCandidate = rawSearchLang;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const search_lang = normalizeBraveSearchLang(searchLangCandidate);
|
|
192
|
+
if (searchLangCandidate && !search_lang) {
|
|
193
|
+
return { invalidField: "search_lang" };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const ui_lang = normalizeBraveUiLang(uiLangCandidate);
|
|
197
|
+
if (uiLangCandidate && !ui_lang) {
|
|
198
|
+
return { invalidField: "ui_lang" };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return { search_lang, ui_lang };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function resolveSiteName(url: string | undefined): string | undefined {
|
|
205
|
+
if (!url) {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
return new URL(url).hostname;
|
|
210
|
+
} catch {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function mapBraveLlmContextResults(
|
|
216
|
+
data: BraveLlmContextResponse,
|
|
217
|
+
): { url: string; title: string; snippets: string[]; siteName?: string }[] {
|
|
218
|
+
const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : [];
|
|
219
|
+
return genericResults.map((entry) => ({
|
|
220
|
+
url: entry.url ?? "",
|
|
221
|
+
title: entry.title ?? "",
|
|
222
|
+
snippets: (entry.snippets ?? []).filter(
|
|
223
|
+
(snippet) => typeof snippet === "string" && snippet.length > 0,
|
|
224
|
+
),
|
|
225
|
+
siteName: resolveSiteName(entry.url) || undefined,
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { isDiagnosticFlagEnabled } from "autobot/plugin-sdk/diagnostic-runtime";
|
|
2
|
+
import type {
|
|
3
|
+
SearchConfigRecord,
|
|
4
|
+
WebSearchProviderPlugin,
|
|
5
|
+
WebSearchProviderToolDefinition,
|
|
6
|
+
} from "autobot/plugin-sdk/provider-web-search";
|
|
7
|
+
import { createWebSearchProviderContractFields } from "autobot/plugin-sdk/provider-web-search-config-contract";
|
|
8
|
+
|
|
9
|
+
const BRAVE_CREDENTIAL_PATH = "plugins.entries.brave.config.webSearch.apiKey";
|
|
10
|
+
|
|
11
|
+
type BraveWebSearchRuntime = typeof import("./brave-web-search-provider.runtime.js");
|
|
12
|
+
|
|
13
|
+
let braveWebSearchRuntimePromise: Promise<BraveWebSearchRuntime> | undefined;
|
|
14
|
+
|
|
15
|
+
function loadBraveWebSearchRuntime(): Promise<BraveWebSearchRuntime> {
|
|
16
|
+
braveWebSearchRuntimePromise ??= import("./brave-web-search-provider.runtime.js");
|
|
17
|
+
return braveWebSearchRuntimePromise;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const BraveSearchSchema = {
|
|
21
|
+
type: "object",
|
|
22
|
+
properties: {
|
|
23
|
+
query: { type: "string", description: "Search query string." },
|
|
24
|
+
count: {
|
|
25
|
+
type: "number",
|
|
26
|
+
description: "Number of results to return (1-10).",
|
|
27
|
+
minimum: 1,
|
|
28
|
+
maximum: 10,
|
|
29
|
+
},
|
|
30
|
+
country: {
|
|
31
|
+
type: "string",
|
|
32
|
+
description:
|
|
33
|
+
"2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.",
|
|
34
|
+
},
|
|
35
|
+
language: {
|
|
36
|
+
type: "string",
|
|
37
|
+
description: "ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').",
|
|
38
|
+
},
|
|
39
|
+
freshness: {
|
|
40
|
+
type: "string",
|
|
41
|
+
description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.",
|
|
42
|
+
},
|
|
43
|
+
date_after: {
|
|
44
|
+
type: "string",
|
|
45
|
+
description: "Only results published after this date (YYYY-MM-DD).",
|
|
46
|
+
},
|
|
47
|
+
date_before: {
|
|
48
|
+
type: "string",
|
|
49
|
+
description: "Only results published before this date (YYYY-MM-DD).",
|
|
50
|
+
},
|
|
51
|
+
search_lang: {
|
|
52
|
+
type: "string",
|
|
53
|
+
description:
|
|
54
|
+
"Brave language code for search results (e.g., 'en', 'de', 'en-gb', 'zh-hans', 'zh-hant', 'pt-br').",
|
|
55
|
+
},
|
|
56
|
+
ui_lang: {
|
|
57
|
+
type: "string",
|
|
58
|
+
description:
|
|
59
|
+
"Locale code for UI elements in language-region format (e.g., 'en-US', 'de-DE', 'fr-FR', 'tr-TR'). Must include region subtag.",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
} satisfies Record<string, unknown>;
|
|
63
|
+
|
|
64
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
65
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function resolveProviderWebSearchPluginConfig(
|
|
69
|
+
config: unknown,
|
|
70
|
+
pluginId: string,
|
|
71
|
+
): Record<string, unknown> | undefined {
|
|
72
|
+
if (!isRecord(config)) {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
const plugins = isRecord(config.plugins) ? config.plugins : undefined;
|
|
76
|
+
const entries = isRecord(plugins?.entries) ? plugins.entries : undefined;
|
|
77
|
+
const entry = isRecord(entries?.[pluginId]) ? entries[pluginId] : undefined;
|
|
78
|
+
const pluginConfig = isRecord(entry?.config) ? entry.config : undefined;
|
|
79
|
+
return isRecord(pluginConfig?.webSearch) ? pluginConfig.webSearch : undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function resolveLegacyTopLevelBraveCredential(
|
|
83
|
+
config: unknown,
|
|
84
|
+
): { path: string; value: unknown } | undefined {
|
|
85
|
+
if (!isRecord(config)) {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
const tools = isRecord(config.tools) ? config.tools : undefined;
|
|
89
|
+
const web = isRecord(tools?.web) ? tools.web : undefined;
|
|
90
|
+
const search = isRecord(web?.search) ? web.search : undefined;
|
|
91
|
+
if (!search || !("apiKey" in search)) {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
return { path: "tools.web.search.apiKey", value: search.apiKey };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function resolveConfiguredBraveCredential(config: unknown): unknown {
|
|
98
|
+
return (
|
|
99
|
+
resolveProviderWebSearchPluginConfig(config, "brave")?.apiKey ??
|
|
100
|
+
resolveLegacyTopLevelBraveCredential(config)?.value
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function mergeScopedSearchConfig(
|
|
105
|
+
searchConfig: Record<string, unknown> | undefined,
|
|
106
|
+
key: string,
|
|
107
|
+
pluginConfig: Record<string, unknown> | undefined,
|
|
108
|
+
options?: { mirrorApiKeyToTopLevel?: boolean },
|
|
109
|
+
): Record<string, unknown> | undefined {
|
|
110
|
+
if (!pluginConfig) {
|
|
111
|
+
return searchConfig;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const currentScoped = isRecord(searchConfig?.[key]) ? searchConfig?.[key] : {};
|
|
115
|
+
const next: Record<string, unknown> = {
|
|
116
|
+
...searchConfig,
|
|
117
|
+
[key]: {
|
|
118
|
+
...currentScoped,
|
|
119
|
+
...pluginConfig,
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
if (options?.mirrorApiKeyToTopLevel && pluginConfig.apiKey !== undefined) {
|
|
124
|
+
next.apiKey = pluginConfig.apiKey;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return next;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function resolveBraveMode(searchConfig?: Record<string, unknown>): "web" | "llm-context" {
|
|
131
|
+
const brave = isRecord(searchConfig?.brave) ? searchConfig.brave : undefined;
|
|
132
|
+
return brave?.mode === "llm-context" ? "llm-context" : "web";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function createBraveToolDefinition(
|
|
136
|
+
searchConfig?: SearchConfigRecord,
|
|
137
|
+
config?: Parameters<typeof isDiagnosticFlagEnabled>[1],
|
|
138
|
+
): WebSearchProviderToolDefinition {
|
|
139
|
+
const braveMode = resolveBraveMode(searchConfig);
|
|
140
|
+
const diagnosticsEnabled = isDiagnosticFlagEnabled("brave.http", config);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
description:
|
|
144
|
+
braveMode === "llm-context"
|
|
145
|
+
? "Search the web using Brave Search LLM Context API. Returns pre-extracted page content (text chunks, tables, code blocks) optimized for LLM grounding."
|
|
146
|
+
: "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.",
|
|
147
|
+
parameters: BraveSearchSchema,
|
|
148
|
+
execute: async (args) => {
|
|
149
|
+
const { executeBraveSearch } = await loadBraveWebSearchRuntime();
|
|
150
|
+
return await executeBraveSearch(args, searchConfig, { diagnosticsEnabled });
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
|
|
156
|
+
return {
|
|
157
|
+
id: "brave",
|
|
158
|
+
label: "Brave Search",
|
|
159
|
+
hint: "Structured results · country/language/time filters",
|
|
160
|
+
onboardingScopes: ["text-inference"],
|
|
161
|
+
credentialLabel: "Brave Search API key",
|
|
162
|
+
envVars: ["BRAVE_API_KEY"],
|
|
163
|
+
placeholder: "BSA...",
|
|
164
|
+
signupUrl: "https://brave.com/search/api/",
|
|
165
|
+
docsUrl: "https://docs.openclaw.ai/tools/brave-search",
|
|
166
|
+
autoDetectOrder: 10,
|
|
167
|
+
credentialPath: BRAVE_CREDENTIAL_PATH,
|
|
168
|
+
...createWebSearchProviderContractFields({
|
|
169
|
+
credentialPath: BRAVE_CREDENTIAL_PATH,
|
|
170
|
+
searchCredential: { type: "top-level" },
|
|
171
|
+
configuredCredential: { pluginId: "brave" },
|
|
172
|
+
}),
|
|
173
|
+
getConfiguredCredentialValue: resolveConfiguredBraveCredential,
|
|
174
|
+
getConfiguredCredentialFallback: resolveLegacyTopLevelBraveCredential,
|
|
175
|
+
createTool: (ctx) =>
|
|
176
|
+
createBraveToolDefinition(
|
|
177
|
+
mergeScopedSearchConfig(
|
|
178
|
+
ctx.searchConfig,
|
|
179
|
+
"brave",
|
|
180
|
+
resolveProviderWebSearchPluginConfig(ctx.config, "brave"),
|
|
181
|
+
{ mirrorApiKeyToTopLevel: true },
|
|
182
|
+
),
|
|
183
|
+
ctx.config,
|
|
184
|
+
),
|
|
185
|
+
};
|
|
186
|
+
}
|
package/test-api.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mapBraveLlmContextResults,
|
|
3
|
+
normalizeBraveCountry,
|
|
4
|
+
normalizeBraveLanguageParams,
|
|
5
|
+
resolveBraveMode,
|
|
6
|
+
} from "./src/brave-web-search-provider.shared.js";
|
|
7
|
+
|
|
8
|
+
export const testing = {
|
|
9
|
+
normalizeBraveCountry,
|
|
10
|
+
normalizeBraveLanguageParams,
|
|
11
|
+
resolveBraveMode,
|
|
12
|
+
mapBraveLlmContextResults,
|
|
13
|
+
} as const;
|
|
14
|
+
export { testing as __testing };
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../tsconfig.package-boundary.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"rootDir": "."
|
|
5
|
+
},
|
|
6
|
+
"include": ["./*.ts", "./src/**/*.ts"],
|
|
7
|
+
"exclude": [
|
|
8
|
+
"./**/*.test.ts",
|
|
9
|
+
"./dist/**",
|
|
10
|
+
"./node_modules/**",
|
|
11
|
+
"./src/test-support/**",
|
|
12
|
+
"./src/**/*test-helpers.ts",
|
|
13
|
+
"./src/**/*test-harness.ts",
|
|
14
|
+
"./src/**/*test-support.ts"
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createWebSearchProviderContractFields,
|
|
3
|
+
type WebSearchProviderPlugin,
|
|
4
|
+
} from "autobot/plugin-sdk/provider-web-search-config-contract";
|
|
5
|
+
|
|
6
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
7
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function resolveLegacyTopLevelBraveCredential(
|
|
11
|
+
config: unknown,
|
|
12
|
+
): { path: string; value: unknown } | undefined {
|
|
13
|
+
if (!isRecord(config)) {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
const tools = isRecord(config.tools) ? config.tools : undefined;
|
|
17
|
+
const web = isRecord(tools?.web) ? tools.web : undefined;
|
|
18
|
+
const search = isRecord(web?.search) ? web.search : undefined;
|
|
19
|
+
if (!search || !("apiKey" in search)) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
return { path: "tools.web.search.apiKey", value: search.apiKey };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function resolveProviderWebSearchPluginConfig(
|
|
26
|
+
config: unknown,
|
|
27
|
+
pluginId: string,
|
|
28
|
+
): Record<string, unknown> | undefined {
|
|
29
|
+
if (!isRecord(config)) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
const plugins = isRecord(config.plugins) ? config.plugins : undefined;
|
|
33
|
+
const entries = isRecord(plugins?.entries) ? plugins.entries : undefined;
|
|
34
|
+
const entry = isRecord(entries?.[pluginId]) ? entries[pluginId] : undefined;
|
|
35
|
+
const pluginConfig = isRecord(entry?.config) ? entry.config : undefined;
|
|
36
|
+
return isRecord(pluginConfig?.webSearch) ? pluginConfig.webSearch : undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveConfiguredBraveCredential(config: unknown): unknown {
|
|
40
|
+
return (
|
|
41
|
+
resolveProviderWebSearchPluginConfig(config, "brave")?.apiKey ??
|
|
42
|
+
resolveLegacyTopLevelBraveCredential(config)?.value
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
|
|
47
|
+
const credentialPath = "plugins.entries.brave.config.webSearch.apiKey";
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
id: "brave",
|
|
51
|
+
label: "Brave Search",
|
|
52
|
+
hint: "Structured results · country/language/time filters",
|
|
53
|
+
onboardingScopes: ["text-inference"],
|
|
54
|
+
credentialLabel: "Brave Search API key",
|
|
55
|
+
envVars: ["BRAVE_API_KEY"],
|
|
56
|
+
placeholder: "BSA...",
|
|
57
|
+
signupUrl: "https://brave.com/search/api/",
|
|
58
|
+
docsUrl: "https://docs.openclaw.ai/tools/brave-search",
|
|
59
|
+
autoDetectOrder: 10,
|
|
60
|
+
credentialPath,
|
|
61
|
+
...createWebSearchProviderContractFields({
|
|
62
|
+
credentialPath,
|
|
63
|
+
searchCredential: { type: "top-level" },
|
|
64
|
+
configuredCredential: { pluginId: "brave" },
|
|
65
|
+
}),
|
|
66
|
+
getConfiguredCredentialValue: resolveConfiguredBraveCredential,
|
|
67
|
+
getConfiguredCredentialFallback: resolveLegacyTopLevelBraveCredential,
|
|
68
|
+
createTool: () => null,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createBraveWebSearchProvider } from "./src/brave-web-search-provider.js";
|