@openclaw/brave-plugin 2026.5.1-beta.1 → 2026.5.2-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.
@@ -2,22 +2,105 @@ import fs from "node:fs";
2
2
  import { validateJsonSchemaValue } from "openclaw/plugin-sdk/config-schema";
3
3
  import { afterEach, describe, expect, it, vi } from "vitest";
4
4
  import { __testing } from "../test-api.js";
5
+ import { createBraveWebSearchProvider as createBraveWebSearchContractProvider } from "../web-search-contract-api.js";
5
6
  import { createBraveWebSearchProvider } from "./brave-web-search-provider.js";
6
7
 
8
+ const loggerInfoMock = vi.hoisted(() => vi.fn());
9
+
10
+ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
11
+ createSubsystemLogger: () => ({
12
+ info: loggerInfoMock,
13
+ debug: vi.fn(),
14
+ warn: vi.fn(),
15
+ error: vi.fn(),
16
+ fatal: vi.fn(),
17
+ trace: vi.fn(),
18
+ raw: vi.fn(),
19
+ isEnabled: () => true,
20
+ child: () => ({
21
+ info: loggerInfoMock,
22
+ debug: vi.fn(),
23
+ warn: vi.fn(),
24
+ error: vi.fn(),
25
+ fatal: vi.fn(),
26
+ trace: vi.fn(),
27
+ raw: vi.fn(),
28
+ isEnabled: () => true,
29
+ child: vi.fn(),
30
+ }),
31
+ }),
32
+ }));
33
+
7
34
  const braveManifest = JSON.parse(
8
35
  fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf-8"),
9
36
  ) as {
10
37
  configSchema?: Record<string, unknown>;
11
38
  };
12
39
 
40
+ function installBraveLlmContextFetch() {
41
+ const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
42
+ return {
43
+ ok: true,
44
+ json: async () => ({
45
+ grounding: {
46
+ generic: [
47
+ {
48
+ url: "https://example.com/context",
49
+ title: "Context",
50
+ snippets: ["snippet"],
51
+ },
52
+ ],
53
+ },
54
+ sources: [],
55
+ }),
56
+ } as Response;
57
+ });
58
+ global.fetch = mockFetch as typeof global.fetch;
59
+ return mockFetch;
60
+ }
61
+
62
+ function readHeader(init: unknown, name: string): string | null {
63
+ const headers = (init as { headers?: HeadersInit } | undefined)?.headers;
64
+ if (!headers) {
65
+ return null;
66
+ }
67
+ return new Headers(headers).get(name);
68
+ }
69
+
13
70
  describe("brave web search provider", () => {
14
71
  const priorFetch = global.fetch;
15
72
 
16
73
  afterEach(() => {
17
74
  vi.unstubAllEnvs();
75
+ loggerInfoMock.mockClear();
18
76
  global.fetch = priorFetch;
19
77
  });
20
78
 
79
+ it("points provider metadata at the canonical Brave docs page", () => {
80
+ expect(createBraveWebSearchProvider().docsUrl).toBe(
81
+ "https://docs.openclaw.ai/tools/brave-search",
82
+ );
83
+ expect(createBraveWebSearchContractProvider().docsUrl).toBe(
84
+ "https://docs.openclaw.ai/tools/brave-search",
85
+ );
86
+ });
87
+
88
+ it("points missing-key users to fetch/browser alternatives", async () => {
89
+ vi.stubEnv("BRAVE_API_KEY", "");
90
+ const provider = createBraveWebSearchProvider();
91
+ const tool = provider.createTool({ config: {}, searchConfig: {} });
92
+ if (!tool) {
93
+ throw new Error("Expected tool definition");
94
+ }
95
+
96
+ const result = await tool.execute({ query: "OpenClaw docs" });
97
+
98
+ expect(result).toMatchObject({
99
+ error: "missing_brave_api_key",
100
+ message: expect.stringContaining("use web_fetch for a specific URL or the browser tool"),
101
+ });
102
+ });
103
+
21
104
  it("normalizes brave language parameters and swaps reversed ui/search inputs", () => {
22
105
  expect(
23
106
  __testing.normalizeBraveLanguageParams({
@@ -85,6 +168,127 @@ describe("brave web search provider", () => {
85
168
  expect(result.ok).toBe(true);
86
169
  });
87
170
 
171
+ it("accepts baseUrl in the Brave plugin config schema", () => {
172
+ if (!braveManifest.configSchema) {
173
+ throw new Error("Expected Brave manifest config schema");
174
+ }
175
+
176
+ const result = validateJsonSchemaValue({
177
+ schema: braveManifest.configSchema,
178
+ cacheKey: "test:brave-config-schema-base-url",
179
+ value: {
180
+ webSearch: {
181
+ baseUrl: "https://api.search.brave.com/proxy",
182
+ },
183
+ },
184
+ });
185
+
186
+ expect(result.ok).toBe(true);
187
+ });
188
+
189
+ it("uses configured Brave baseUrl for web search requests", async () => {
190
+ vi.stubEnv("BRAVE_API_KEY", "");
191
+ const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
192
+ return {
193
+ ok: true,
194
+ json: async () => ({ web: { results: [] } }),
195
+ } as Response;
196
+ });
197
+ global.fetch = mockFetch as typeof global.fetch;
198
+
199
+ const provider = createBraveWebSearchProvider();
200
+ const tool = provider.createTool({
201
+ config: {},
202
+ searchConfig: {
203
+ apiKey: "brave-test-key",
204
+ brave: {
205
+ baseUrl: "https://api.search.brave.com/proxy/",
206
+ mode: "web",
207
+ },
208
+ },
209
+ });
210
+ if (!tool) {
211
+ throw new Error("Expected tool definition");
212
+ }
213
+
214
+ await tool.execute({ query: "latest ai news" });
215
+
216
+ const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0]));
217
+ expect(requestUrl.origin).toBe("https://api.search.brave.com");
218
+ expect(requestUrl.pathname).toBe("/proxy/res/v1/web/search");
219
+ });
220
+
221
+ it("uses configured Brave baseUrl for llm-context requests", async () => {
222
+ vi.stubEnv("BRAVE_API_KEY", "");
223
+ const mockFetch = installBraveLlmContextFetch();
224
+ const provider = createBraveWebSearchProvider();
225
+ const tool = provider.createTool({
226
+ config: {},
227
+ searchConfig: {
228
+ apiKey: "brave-test-key",
229
+ brave: {
230
+ baseUrl: "https://api.search.brave.com/proxy",
231
+ mode: "llm-context",
232
+ },
233
+ },
234
+ });
235
+ if (!tool) {
236
+ throw new Error("Expected tool definition");
237
+ }
238
+
239
+ await tool.execute({ query: "latest ai news" });
240
+
241
+ const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0]));
242
+ expect(requestUrl.pathname).toBe("/proxy/res/v1/llm/context");
243
+ });
244
+
245
+ it("keeps Brave cache entries isolated by baseUrl", async () => {
246
+ vi.stubEnv("BRAVE_API_KEY", "");
247
+ const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
248
+ return {
249
+ ok: true,
250
+ json: async () => ({ web: { results: [] } }),
251
+ } as Response;
252
+ });
253
+ global.fetch = mockFetch as typeof global.fetch;
254
+
255
+ const provider = createBraveWebSearchProvider();
256
+ const firstTool = provider.createTool({
257
+ config: {},
258
+ searchConfig: {
259
+ apiKey: "brave-test-key",
260
+ brave: {
261
+ baseUrl: "https://api.search.brave.com/proxy-one",
262
+ mode: "web",
263
+ },
264
+ },
265
+ });
266
+ const secondTool = provider.createTool({
267
+ config: {},
268
+ searchConfig: {
269
+ apiKey: "brave-test-key",
270
+ brave: {
271
+ baseUrl: "https://api.search.brave.com/proxy-two",
272
+ mode: "web",
273
+ },
274
+ },
275
+ });
276
+ if (!firstTool || !secondTool) {
277
+ throw new Error("Expected tool definitions");
278
+ }
279
+
280
+ await firstTool.execute({ query: "base url cache identity" });
281
+ await secondTool.execute({ query: "base url cache identity" });
282
+
283
+ expect(mockFetch).toHaveBeenCalledTimes(2);
284
+ expect(new URL(String(mockFetch.mock.calls[0]?.[0])).pathname).toBe(
285
+ "/proxy-one/res/v1/web/search",
286
+ );
287
+ expect(new URL(String(mockFetch.mock.calls[1]?.[0])).pathname).toBe(
288
+ "/proxy-two/res/v1/web/search",
289
+ );
290
+ });
291
+
88
292
  it("rejects invalid Brave mode values in the plugin config schema", () => {
89
293
  if (!braveManifest.configSchema) {
90
294
  throw new Error("Expected Brave manifest config schema");
@@ -160,6 +364,182 @@ describe("brave web search provider", () => {
160
364
  });
161
365
  });
162
366
 
367
+ it("passes freshness to Brave llm-context endpoint", async () => {
368
+ vi.stubEnv("BRAVE_API_KEY", "test-key");
369
+ const mockFetch = installBraveLlmContextFetch();
370
+ const provider = createBraveWebSearchProvider();
371
+ const tool = provider.createTool({
372
+ config: {},
373
+ searchConfig: {
374
+ apiKey: "BSA...",
375
+ brave: { mode: "llm-context" },
376
+ },
377
+ });
378
+ if (!tool) {
379
+ throw new Error("Expected tool definition");
380
+ }
381
+
382
+ await tool.execute({ query: "latest ai news", freshness: "week" });
383
+
384
+ const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0]));
385
+ expect(requestUrl.pathname).toBe("/res/v1/llm/context");
386
+ expect(requestUrl.searchParams.get("freshness")).toBe("pw");
387
+ });
388
+
389
+ it("sends Brave web auth in the X-Subscription-Token header", async () => {
390
+ vi.stubEnv("BRAVE_API_KEY", "");
391
+ const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
392
+ return {
393
+ ok: true,
394
+ json: async () => ({ web: { results: [] } }),
395
+ } as Response;
396
+ });
397
+ global.fetch = mockFetch as typeof global.fetch;
398
+
399
+ const provider = createBraveWebSearchProvider();
400
+ const tool = provider.createTool({
401
+ config: {},
402
+ searchConfig: {
403
+ apiKey: "brave-test-key",
404
+ brave: { mode: "web" },
405
+ },
406
+ });
407
+ if (!tool) {
408
+ throw new Error("Expected tool definition");
409
+ }
410
+
411
+ await tool.execute({ query: "latest ai news" });
412
+
413
+ const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0]));
414
+ expect(requestUrl.searchParams.get("apikey")).toBeNull();
415
+ expect(requestUrl.searchParams.get("key")).toBeNull();
416
+ expect(readHeader(mockFetch.mock.calls[0]?.[1], "X-Subscription-Token")).toBe("brave-test-key");
417
+ });
418
+
419
+ it("sends Brave llm-context auth in the X-Subscription-Token header", async () => {
420
+ vi.stubEnv("BRAVE_API_KEY", "");
421
+ const mockFetch = installBraveLlmContextFetch();
422
+ const provider = createBraveWebSearchProvider();
423
+ const tool = provider.createTool({
424
+ config: {},
425
+ searchConfig: {
426
+ apiKey: "brave-test-key",
427
+ brave: { mode: "llm-context" },
428
+ },
429
+ });
430
+ if (!tool) {
431
+ throw new Error("Expected tool definition");
432
+ }
433
+
434
+ await tool.execute({ query: "latest ai news" });
435
+
436
+ const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0]));
437
+ expect(requestUrl.searchParams.get("apikey")).toBeNull();
438
+ expect(requestUrl.searchParams.get("key")).toBeNull();
439
+ expect(readHeader(mockFetch.mock.calls[0]?.[1], "X-Subscription-Token")).toBe("brave-test-key");
440
+ });
441
+
442
+ it("passes bounded date ranges to Brave llm-context endpoint", async () => {
443
+ vi.stubEnv("BRAVE_API_KEY", "test-key");
444
+ const mockFetch = installBraveLlmContextFetch();
445
+ const provider = createBraveWebSearchProvider();
446
+ const tool = provider.createTool({
447
+ config: {},
448
+ searchConfig: {
449
+ apiKey: "BSA...",
450
+ brave: { mode: "llm-context" },
451
+ },
452
+ });
453
+ if (!tool) {
454
+ throw new Error("Expected tool definition");
455
+ }
456
+
457
+ await tool.execute({
458
+ query: "latest ai news",
459
+ date_after: "2025-01-01",
460
+ date_before: "2025-01-31",
461
+ });
462
+
463
+ const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0]));
464
+ expect(requestUrl.pathname).toBe("/res/v1/llm/context");
465
+ expect(requestUrl.searchParams.get("freshness")).toBe("2025-01-01to2025-01-31");
466
+ });
467
+
468
+ it("uses today as the end date for Brave llm-context date_after-only ranges", async () => {
469
+ vi.stubEnv("BRAVE_API_KEY", "test-key");
470
+ const mockFetch = installBraveLlmContextFetch();
471
+ const provider = createBraveWebSearchProvider();
472
+ const tool = provider.createTool({
473
+ config: {},
474
+ searchConfig: {
475
+ apiKey: "BSA...",
476
+ brave: { mode: "llm-context" },
477
+ },
478
+ });
479
+ if (!tool) {
480
+ throw new Error("Expected tool definition");
481
+ }
482
+
483
+ await tool.execute({ query: "latest ai news", date_after: "2025-01-01" });
484
+
485
+ const today = new Date().toISOString().slice(0, 10);
486
+ const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0]));
487
+ expect(requestUrl.pathname).toBe("/res/v1/llm/context");
488
+ expect(requestUrl.searchParams.get("freshness")).toBe(`2025-01-01to${today}`);
489
+ });
490
+
491
+ it("rejects future Brave llm-context date_after-only ranges before fetch", async () => {
492
+ vi.stubEnv("BRAVE_API_KEY", "test-key");
493
+ const mockFetch = installBraveLlmContextFetch();
494
+ const provider = createBraveWebSearchProvider();
495
+ const tool = provider.createTool({
496
+ config: {},
497
+ searchConfig: {
498
+ apiKey: "BSA...",
499
+ brave: { mode: "llm-context" },
500
+ },
501
+ });
502
+ if (!tool) {
503
+ throw new Error("Expected tool definition");
504
+ }
505
+
506
+ const result = await tool.execute({
507
+ query: "latest ai news",
508
+ date_after: "2999-01-01",
509
+ });
510
+
511
+ expect(result).toMatchObject({
512
+ error: "invalid_date_range",
513
+ });
514
+ expect(mockFetch).not.toHaveBeenCalled();
515
+ });
516
+
517
+ it("rejects Brave llm-context date_before-only ranges before fetch", async () => {
518
+ vi.stubEnv("BRAVE_API_KEY", "test-key");
519
+ const mockFetch = installBraveLlmContextFetch();
520
+ const provider = createBraveWebSearchProvider();
521
+ const tool = provider.createTool({
522
+ config: {},
523
+ searchConfig: {
524
+ apiKey: "BSA...",
525
+ brave: { mode: "llm-context" },
526
+ },
527
+ });
528
+ if (!tool) {
529
+ throw new Error("Expected tool definition");
530
+ }
531
+
532
+ const result = await tool.execute({
533
+ query: "latest ai news",
534
+ date_before: "2025-01-31",
535
+ });
536
+
537
+ expect(result).toMatchObject({
538
+ error: "unsupported_date_filter",
539
+ });
540
+ expect(mockFetch).not.toHaveBeenCalled();
541
+ });
542
+
163
543
  it("falls back unsupported country values before calling Brave", async () => {
164
544
  vi.stubEnv("BRAVE_API_KEY", "test-key");
165
545
  const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
@@ -190,4 +570,77 @@ describe("brave web search provider", () => {
190
570
  const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0]));
191
571
  expect(requestUrl.searchParams.get("country")).toBe("ALL");
192
572
  });
573
+
574
+ it("emits brave.http diagnostics for requests, responses, and cache events", async () => {
575
+ vi.stubEnv("BRAVE_API_KEY", "");
576
+ const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
577
+ return {
578
+ ok: true,
579
+ status: 200,
580
+ json: async () => ({
581
+ web: {
582
+ results: [
583
+ {
584
+ title: "Diagnostics",
585
+ url: "https://example.com/diagnostics",
586
+ description: "debug details",
587
+ },
588
+ ],
589
+ },
590
+ }),
591
+ } as Response;
592
+ });
593
+ global.fetch = mockFetch as typeof global.fetch;
594
+
595
+ const provider = createBraveWebSearchProvider();
596
+ const tool = provider.createTool({
597
+ config: { diagnostics: { flags: ["brave.http"] } },
598
+ searchConfig: {
599
+ apiKey: "brave-test-key",
600
+ brave: { mode: "web" },
601
+ },
602
+ });
603
+ if (!tool) {
604
+ throw new Error("Expected tool definition");
605
+ }
606
+
607
+ await tool.execute({ query: "unique brave diagnostics query", count: 1 });
608
+ await tool.execute({ query: "unique brave diagnostics query", count: 1 });
609
+
610
+ expect(mockFetch).toHaveBeenCalledTimes(1);
611
+ const messages = loggerInfoMock.mock.calls.map((call) => call[0]);
612
+ expect(messages).toEqual(
613
+ expect.arrayContaining([
614
+ "brave http cache miss",
615
+ "brave http request",
616
+ "brave http response",
617
+ "brave http cache write",
618
+ "brave http cache hit",
619
+ ]),
620
+ );
621
+ expect(loggerInfoMock.mock.calls).toEqual(
622
+ expect.arrayContaining([
623
+ [
624
+ "brave http request",
625
+ expect.objectContaining({
626
+ mode: "web",
627
+ query: "unique brave diagnostics query",
628
+ params: expect.objectContaining({ q: "unique brave diagnostics query", count: "1" }),
629
+ url: expect.stringContaining("api.search.brave.com/res/v1/web/search"),
630
+ }),
631
+ ],
632
+ [
633
+ "brave http response",
634
+ expect.objectContaining({
635
+ mode: "web",
636
+ status: 200,
637
+ ok: true,
638
+ durationMs: expect.any(Number),
639
+ }),
640
+ ],
641
+ ]),
642
+ );
643
+ expect(JSON.stringify(loggerInfoMock.mock.calls)).not.toContain("brave-test-key");
644
+ expect(JSON.stringify(loggerInfoMock.mock.calls)).not.toContain("X-Subscription-Token");
645
+ });
193
646
  });
@@ -1,3 +1,4 @@
1
+ import { isDiagnosticFlagEnabled } from "openclaw/plugin-sdk/diagnostic-runtime";
1
2
  import type {
2
3
  SearchConfigRecord,
3
4
  WebSearchProviderPlugin,
@@ -111,8 +112,10 @@ function resolveBraveMode(searchConfig?: Record<string, unknown>): "web" | "llm-
111
112
 
112
113
  function createBraveToolDefinition(
113
114
  searchConfig?: SearchConfigRecord,
115
+ config?: Parameters<typeof isDiagnosticFlagEnabled>[1],
114
116
  ): WebSearchProviderToolDefinition {
115
117
  const braveMode = resolveBraveMode(searchConfig);
118
+ const diagnosticsEnabled = isDiagnosticFlagEnabled("brave.http", config);
116
119
 
117
120
  return {
118
121
  description:
@@ -122,7 +125,7 @@ function createBraveToolDefinition(
122
125
  parameters: BraveSearchSchema,
123
126
  execute: async (args) => {
124
127
  const { executeBraveSearch } = await loadBraveWebSearchRuntime();
125
- return await executeBraveSearch(args, searchConfig);
128
+ return await executeBraveSearch(args, searchConfig, { diagnosticsEnabled });
126
129
  },
127
130
  };
128
131
  }
@@ -137,7 +140,7 @@ export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
137
140
  envVars: ["BRAVE_API_KEY"],
138
141
  placeholder: "BSA...",
139
142
  signupUrl: "https://brave.com/search/api/",
140
- docsUrl: "https://docs.openclaw.ai/brave-search",
143
+ docsUrl: "https://docs.openclaw.ai/tools/brave-search",
141
144
  autoDetectOrder: 10,
142
145
  credentialPath: BRAVE_CREDENTIAL_PATH,
143
146
  ...createWebSearchProviderContractFields({
@@ -153,6 +156,7 @@ export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
153
156
  resolveProviderWebSearchPluginConfig(ctx.config, "brave"),
154
157
  { mirrorApiKeyToTopLevel: true },
155
158
  ),
159
+ ctx.config,
156
160
  ),
157
161
  };
158
162
  }
@@ -15,7 +15,7 @@ export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
15
15
  envVars: ["BRAVE_API_KEY"],
16
16
  placeholder: "BSA...",
17
17
  signupUrl: "https://brave.com/search/api/",
18
- docsUrl: "https://docs.openclaw.ai/brave-search",
18
+ docsUrl: "https://docs.openclaw.ai/tools/brave-search",
19
19
  autoDetectOrder: 10,
20
20
  credentialPath,
21
21
  ...createWebSearchProviderContractFields({
@@ -1 +0,0 @@
1
- 2026-04-29T11:28:47.129Z