@openclaw/brave-plugin 2026.5.1-beta.1
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/dist/.boundary-tsc.stamp +1 -0
- package/dist/.boundary-tsc.tsbuildinfo +1 -0
- package/index.ts +11 -0
- package/openclaw.plugin.json +46 -0
- package/package.json +33 -0
- package/src/brave-web-search-provider.runtime.ts +367 -0
- package/src/brave-web-search-provider.shared.ts +226 -0
- package/src/brave-web-search-provider.test.ts +193 -0
- package/src/brave-web-search-provider.ts +158 -0
- package/test-api.ts +13 -0
- package/tsconfig.json +16 -0
- package/web-search-contract-api.ts +28 -0
- package/web-search-provider.ts +1 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { validateJsonSchemaValue } from "openclaw/plugin-sdk/config-schema";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { __testing } from "../test-api.js";
|
|
5
|
+
import { createBraveWebSearchProvider } from "./brave-web-search-provider.js";
|
|
6
|
+
|
|
7
|
+
const braveManifest = JSON.parse(
|
|
8
|
+
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf-8"),
|
|
9
|
+
) as {
|
|
10
|
+
configSchema?: Record<string, unknown>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
describe("brave web search provider", () => {
|
|
14
|
+
const priorFetch = global.fetch;
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.unstubAllEnvs();
|
|
18
|
+
global.fetch = priorFetch;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("normalizes brave language parameters and swaps reversed ui/search inputs", () => {
|
|
22
|
+
expect(
|
|
23
|
+
__testing.normalizeBraveLanguageParams({
|
|
24
|
+
search_lang: "en-US",
|
|
25
|
+
ui_lang: "ja",
|
|
26
|
+
}),
|
|
27
|
+
).toEqual({
|
|
28
|
+
search_lang: "jp",
|
|
29
|
+
ui_lang: "en-US",
|
|
30
|
+
});
|
|
31
|
+
expect(__testing.normalizeBraveLanguageParams({ search_lang: "tr-TR", ui_lang: "tr" })).toEqual(
|
|
32
|
+
{
|
|
33
|
+
search_lang: "tr",
|
|
34
|
+
ui_lang: "tr-TR",
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
expect(__testing.normalizeBraveLanguageParams({ search_lang: "EN", ui_lang: "en-us" })).toEqual(
|
|
38
|
+
{
|
|
39
|
+
search_lang: "en",
|
|
40
|
+
ui_lang: "en-US",
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("flags invalid brave language fields", () => {
|
|
46
|
+
expect(
|
|
47
|
+
__testing.normalizeBraveLanguageParams({
|
|
48
|
+
search_lang: "xx",
|
|
49
|
+
}),
|
|
50
|
+
).toEqual({ invalidField: "search_lang" });
|
|
51
|
+
expect(__testing.normalizeBraveLanguageParams({ search_lang: "en-US" })).toEqual({
|
|
52
|
+
invalidField: "search_lang",
|
|
53
|
+
});
|
|
54
|
+
expect(__testing.normalizeBraveLanguageParams({ ui_lang: "en" })).toEqual({
|
|
55
|
+
invalidField: "ui_lang",
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("normalizes Brave country codes and falls back unsupported values to ALL", () => {
|
|
60
|
+
expect(__testing.normalizeBraveCountry("de")).toBe("DE");
|
|
61
|
+
expect(__testing.normalizeBraveCountry(" VN ")).toBe("ALL");
|
|
62
|
+
expect(__testing.normalizeBraveCountry("")).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("defaults brave mode to web unless llm-context is explicitly selected", () => {
|
|
66
|
+
expect(__testing.resolveBraveMode()).toBe("web");
|
|
67
|
+
expect(__testing.resolveBraveMode({ mode: "llm-context" })).toBe("llm-context");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("accepts llm-context in the Brave plugin config schema", () => {
|
|
71
|
+
if (!braveManifest.configSchema) {
|
|
72
|
+
throw new Error("Expected Brave manifest config schema");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const result = validateJsonSchemaValue({
|
|
76
|
+
schema: braveManifest.configSchema,
|
|
77
|
+
cacheKey: "test:brave-config-schema",
|
|
78
|
+
value: {
|
|
79
|
+
webSearch: {
|
|
80
|
+
mode: "llm-context",
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(result.ok).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("rejects invalid Brave mode values in the plugin config schema", () => {
|
|
89
|
+
if (!braveManifest.configSchema) {
|
|
90
|
+
throw new Error("Expected Brave manifest config schema");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const result = validateJsonSchemaValue({
|
|
94
|
+
schema: braveManifest.configSchema,
|
|
95
|
+
cacheKey: "test:brave-config-schema",
|
|
96
|
+
value: {
|
|
97
|
+
webSearch: {
|
|
98
|
+
mode: "invalid-mode",
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(result.ok).toBe(false);
|
|
104
|
+
if (result.ok) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
expect(result.errors).toContainEqual(
|
|
108
|
+
expect.objectContaining({
|
|
109
|
+
path: "webSearch.mode",
|
|
110
|
+
allowedValues: ["web", "llm-context"],
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("maps llm-context results into wrapped source entries", () => {
|
|
116
|
+
expect(
|
|
117
|
+
__testing.mapBraveLlmContextResults({
|
|
118
|
+
grounding: {
|
|
119
|
+
generic: [
|
|
120
|
+
{
|
|
121
|
+
url: "https://example.com/post",
|
|
122
|
+
title: "Example",
|
|
123
|
+
snippets: ["a", "", "b"],
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
).toEqual([
|
|
129
|
+
{
|
|
130
|
+
url: "https://example.com/post",
|
|
131
|
+
title: "Example",
|
|
132
|
+
snippets: ["a", "b"],
|
|
133
|
+
siteName: "example.com",
|
|
134
|
+
},
|
|
135
|
+
]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("returns validation errors for invalid date ranges", async () => {
|
|
139
|
+
vi.stubEnv("BRAVE_API_KEY", "");
|
|
140
|
+
const provider = createBraveWebSearchProvider();
|
|
141
|
+
const tool = provider.createTool({
|
|
142
|
+
config: {},
|
|
143
|
+
searchConfig: {
|
|
144
|
+
apiKey: "BSA...",
|
|
145
|
+
brave: { apiKey: "BSA..." },
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
if (!tool) {
|
|
149
|
+
throw new Error("Expected tool definition");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const result = await tool.execute({
|
|
153
|
+
query: "latest gpu news",
|
|
154
|
+
date_after: "2026-03-20",
|
|
155
|
+
date_before: "2026-03-01",
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(result).toMatchObject({
|
|
159
|
+
error: "invalid_date_range",
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("falls back unsupported country values before calling Brave", async () => {
|
|
164
|
+
vi.stubEnv("BRAVE_API_KEY", "test-key");
|
|
165
|
+
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
|
166
|
+
return {
|
|
167
|
+
ok: true,
|
|
168
|
+
json: async () => ({ web: { results: [] } }),
|
|
169
|
+
} as Response;
|
|
170
|
+
});
|
|
171
|
+
global.fetch = mockFetch as typeof global.fetch;
|
|
172
|
+
|
|
173
|
+
const provider = createBraveWebSearchProvider();
|
|
174
|
+
const tool = provider.createTool({
|
|
175
|
+
config: {},
|
|
176
|
+
searchConfig: {
|
|
177
|
+
apiKey: "BSA...",
|
|
178
|
+
brave: { apiKey: "BSA..." },
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
if (!tool) {
|
|
182
|
+
throw new Error("Expected tool definition");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
await tool.execute({
|
|
186
|
+
query: "latest Vietnam news",
|
|
187
|
+
country: "VN",
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0]));
|
|
191
|
+
expect(requestUrl.searchParams.get("country")).toBe("ALL");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SearchConfigRecord,
|
|
3
|
+
WebSearchProviderPlugin,
|
|
4
|
+
WebSearchProviderToolDefinition,
|
|
5
|
+
} from "openclaw/plugin-sdk/provider-web-search";
|
|
6
|
+
import { createWebSearchProviderContractFields } from "openclaw/plugin-sdk/provider-web-search-config-contract";
|
|
7
|
+
|
|
8
|
+
const BRAVE_CREDENTIAL_PATH = "plugins.entries.brave.config.webSearch.apiKey";
|
|
9
|
+
|
|
10
|
+
type BraveWebSearchRuntime = typeof import("./brave-web-search-provider.runtime.js");
|
|
11
|
+
|
|
12
|
+
let braveWebSearchRuntimePromise: Promise<BraveWebSearchRuntime> | undefined;
|
|
13
|
+
|
|
14
|
+
function loadBraveWebSearchRuntime(): Promise<BraveWebSearchRuntime> {
|
|
15
|
+
braveWebSearchRuntimePromise ??= import("./brave-web-search-provider.runtime.js");
|
|
16
|
+
return braveWebSearchRuntimePromise;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const BraveSearchSchema = {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
query: { type: "string", description: "Search query string." },
|
|
23
|
+
count: {
|
|
24
|
+
type: "number",
|
|
25
|
+
description: "Number of results to return (1-10).",
|
|
26
|
+
minimum: 1,
|
|
27
|
+
maximum: 10,
|
|
28
|
+
},
|
|
29
|
+
country: {
|
|
30
|
+
type: "string",
|
|
31
|
+
description:
|
|
32
|
+
"2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.",
|
|
33
|
+
},
|
|
34
|
+
language: {
|
|
35
|
+
type: "string",
|
|
36
|
+
description: "ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').",
|
|
37
|
+
},
|
|
38
|
+
freshness: {
|
|
39
|
+
type: "string",
|
|
40
|
+
description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.",
|
|
41
|
+
},
|
|
42
|
+
date_after: {
|
|
43
|
+
type: "string",
|
|
44
|
+
description: "Only results published after this date (YYYY-MM-DD).",
|
|
45
|
+
},
|
|
46
|
+
date_before: {
|
|
47
|
+
type: "string",
|
|
48
|
+
description: "Only results published before this date (YYYY-MM-DD).",
|
|
49
|
+
},
|
|
50
|
+
search_lang: {
|
|
51
|
+
type: "string",
|
|
52
|
+
description:
|
|
53
|
+
"Brave language code for search results (e.g., 'en', 'de', 'en-gb', 'zh-hans', 'zh-hant', 'pt-br').",
|
|
54
|
+
},
|
|
55
|
+
ui_lang: {
|
|
56
|
+
type: "string",
|
|
57
|
+
description:
|
|
58
|
+
"Locale code for UI elements in language-region format (e.g., 'en-US', 'de-DE', 'fr-FR', 'tr-TR'). Must include region subtag.",
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
} satisfies Record<string, unknown>;
|
|
62
|
+
|
|
63
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
64
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function resolveProviderWebSearchPluginConfig(
|
|
68
|
+
config: unknown,
|
|
69
|
+
pluginId: string,
|
|
70
|
+
): Record<string, unknown> | undefined {
|
|
71
|
+
if (!isRecord(config)) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
const plugins = isRecord(config.plugins) ? config.plugins : undefined;
|
|
75
|
+
const entries = isRecord(plugins?.entries) ? plugins.entries : undefined;
|
|
76
|
+
const entry = isRecord(entries?.[pluginId]) ? entries[pluginId] : undefined;
|
|
77
|
+
const pluginConfig = isRecord(entry?.config) ? entry.config : undefined;
|
|
78
|
+
return isRecord(pluginConfig?.webSearch) ? pluginConfig.webSearch : undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function mergeScopedSearchConfig(
|
|
82
|
+
searchConfig: Record<string, unknown> | undefined,
|
|
83
|
+
key: string,
|
|
84
|
+
pluginConfig: Record<string, unknown> | undefined,
|
|
85
|
+
options?: { mirrorApiKeyToTopLevel?: boolean },
|
|
86
|
+
): Record<string, unknown> | undefined {
|
|
87
|
+
if (!pluginConfig) {
|
|
88
|
+
return searchConfig;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const currentScoped = isRecord(searchConfig?.[key]) ? searchConfig?.[key] : {};
|
|
92
|
+
const next: Record<string, unknown> = {
|
|
93
|
+
...searchConfig,
|
|
94
|
+
[key]: {
|
|
95
|
+
...currentScoped,
|
|
96
|
+
...pluginConfig,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
if (options?.mirrorApiKeyToTopLevel && pluginConfig.apiKey !== undefined) {
|
|
101
|
+
next.apiKey = pluginConfig.apiKey;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return next;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function resolveBraveMode(searchConfig?: Record<string, unknown>): "web" | "llm-context" {
|
|
108
|
+
const brave = isRecord(searchConfig?.brave) ? searchConfig.brave : undefined;
|
|
109
|
+
return brave?.mode === "llm-context" ? "llm-context" : "web";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function createBraveToolDefinition(
|
|
113
|
+
searchConfig?: SearchConfigRecord,
|
|
114
|
+
): WebSearchProviderToolDefinition {
|
|
115
|
+
const braveMode = resolveBraveMode(searchConfig);
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
description:
|
|
119
|
+
braveMode === "llm-context"
|
|
120
|
+
? "Search the web using Brave Search LLM Context API. Returns pre-extracted page content (text chunks, tables, code blocks) optimized for LLM grounding."
|
|
121
|
+
: "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.",
|
|
122
|
+
parameters: BraveSearchSchema,
|
|
123
|
+
execute: async (args) => {
|
|
124
|
+
const { executeBraveSearch } = await loadBraveWebSearchRuntime();
|
|
125
|
+
return await executeBraveSearch(args, searchConfig);
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
|
|
131
|
+
return {
|
|
132
|
+
id: "brave",
|
|
133
|
+
label: "Brave Search",
|
|
134
|
+
hint: "Structured results · country/language/time filters",
|
|
135
|
+
onboardingScopes: ["text-inference"],
|
|
136
|
+
credentialLabel: "Brave Search API key",
|
|
137
|
+
envVars: ["BRAVE_API_KEY"],
|
|
138
|
+
placeholder: "BSA...",
|
|
139
|
+
signupUrl: "https://brave.com/search/api/",
|
|
140
|
+
docsUrl: "https://docs.openclaw.ai/brave-search",
|
|
141
|
+
autoDetectOrder: 10,
|
|
142
|
+
credentialPath: BRAVE_CREDENTIAL_PATH,
|
|
143
|
+
...createWebSearchProviderContractFields({
|
|
144
|
+
credentialPath: BRAVE_CREDENTIAL_PATH,
|
|
145
|
+
searchCredential: { type: "top-level" },
|
|
146
|
+
configuredCredential: { pluginId: "brave" },
|
|
147
|
+
}),
|
|
148
|
+
createTool: (ctx) =>
|
|
149
|
+
createBraveToolDefinition(
|
|
150
|
+
mergeScopedSearchConfig(
|
|
151
|
+
ctx.searchConfig,
|
|
152
|
+
"brave",
|
|
153
|
+
resolveProviderWebSearchPluginConfig(ctx.config, "brave"),
|
|
154
|
+
{ mirrorApiKeyToTopLevel: true },
|
|
155
|
+
),
|
|
156
|
+
),
|
|
157
|
+
};
|
|
158
|
+
}
|
package/test-api.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
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;
|
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,28 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createWebSearchProviderContractFields,
|
|
3
|
+
type WebSearchProviderPlugin,
|
|
4
|
+
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
|
|
5
|
+
|
|
6
|
+
export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
|
|
7
|
+
const credentialPath = "plugins.entries.brave.config.webSearch.apiKey";
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
id: "brave",
|
|
11
|
+
label: "Brave Search",
|
|
12
|
+
hint: "Structured results · country/language/time filters",
|
|
13
|
+
onboardingScopes: ["text-inference"],
|
|
14
|
+
credentialLabel: "Brave Search API key",
|
|
15
|
+
envVars: ["BRAVE_API_KEY"],
|
|
16
|
+
placeholder: "BSA...",
|
|
17
|
+
signupUrl: "https://brave.com/search/api/",
|
|
18
|
+
docsUrl: "https://docs.openclaw.ai/brave-search",
|
|
19
|
+
autoDetectOrder: 10,
|
|
20
|
+
credentialPath,
|
|
21
|
+
...createWebSearchProviderContractFields({
|
|
22
|
+
credentialPath,
|
|
23
|
+
searchCredential: { type: "top-level" },
|
|
24
|
+
configuredCredential: { pluginId: "brave" },
|
|
25
|
+
}),
|
|
26
|
+
createTool: () => null,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createBraveWebSearchProvider } from "./src/brave-web-search-provider.js";
|