Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
36
extensions/brave/README.md
Normal file
36
extensions/brave/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# @openclaw/brave-plugin
|
||||
|
||||
Official Brave Search provider plugin for OpenClaw.
|
||||
|
||||
This plugin registers Brave as a `web_search` provider. It supports normal Brave web search and Brave LLM Context API mode.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/brave-plugin
|
||||
```
|
||||
|
||||
Restart the Gateway after installing or updating the plugin.
|
||||
|
||||
## Configure
|
||||
|
||||
Store a Brave Search API key in plugin config or expose `BRAVE_API_KEY` to the Gateway:
|
||||
|
||||
```bash
|
||||
openclaw config set plugins.entries.brave.enabled true
|
||||
openclaw config set tools.web.search.provider brave
|
||||
```
|
||||
|
||||
Provider-specific options live under `plugins.entries.brave.config.webSearch.*`.
|
||||
|
||||
## Docs
|
||||
|
||||
Full setup, config examples, search modes, and tool parameters:
|
||||
|
||||
- https://docs.openclaw.ai/tools/brave-search
|
||||
|
||||
## Package
|
||||
|
||||
- Plugin id: `brave`
|
||||
- Package: `@openclaw/brave-plugin`
|
||||
- Minimum OpenClaw host: `2026.4.10`
|
||||
16
extensions/brave/index.ts
Normal file
16
extensions/brave/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Brave Search plugin entry. It registers the Brave web-search provider and
|
||||
* keeps runtime HTTP execution lazy.
|
||||
*/
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createBraveWebSearchProvider } from "./src/brave-web-search-provider.js";
|
||||
|
||||
/** Plugin entry for Brave Search. */
|
||||
export default definePluginEntry({
|
||||
id: "brave",
|
||||
name: "Brave Plugin",
|
||||
description: "Bundled Brave plugin",
|
||||
register(api) {
|
||||
api.registerWebSearchProvider(createBraveWebSearchProvider());
|
||||
},
|
||||
});
|
||||
12
extensions/brave/npm-shrinkwrap.json
generated
Normal file
12
extensions/brave/npm-shrinkwrap.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/brave-plugin",
|
||||
"version": "2026.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/brave-plugin",
|
||||
"version": "2026.6.11"
|
||||
}
|
||||
}
|
||||
}
|
||||
62
extensions/brave/openclaw.plugin.json
Normal file
62
extensions/brave/openclaw.plugin.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"id": "brave",
|
||||
"name": "Brave",
|
||||
"description": "OpenClaw Brave Search provider plugin for web search.",
|
||||
"icon": "https://cdn.simpleicons.org/brave",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"setup": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "brave",
|
||||
"authMethods": ["api-key"],
|
||||
"envVars": ["BRAVE_API_KEY"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"uiHints": {
|
||||
"webSearch.apiKey": {
|
||||
"label": "Brave Search API Key",
|
||||
"help": "Brave Search API key (fallback: BRAVE_API_KEY env var).",
|
||||
"sensitive": true,
|
||||
"placeholder": "BSA..."
|
||||
},
|
||||
"webSearch.mode": {
|
||||
"label": "Brave Search Mode",
|
||||
"help": "Brave Search mode: web or llm-context."
|
||||
},
|
||||
"webSearch.baseUrl": {
|
||||
"label": "Brave Search Base URL",
|
||||
"help": "Optional Brave-compatible API base URL for trusted proxies. Defaults to https://api.search.brave.com."
|
||||
}
|
||||
},
|
||||
"contracts": {
|
||||
"webSearchProviders": ["brave"]
|
||||
},
|
||||
"configContracts": {
|
||||
"compatibilityRuntimePaths": ["tools.web.search.apiKey"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"webSearch": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"type": ["string", "object"]
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["web", "llm-context"]
|
||||
},
|
||||
"baseUrl": {
|
||||
"type": ["string", "object"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
34
extensions/brave/package.json
Normal file
34
extensions/brave/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@openclaw/brave-plugin",
|
||||
"version": "2026.6.11",
|
||||
"description": "OpenClaw Brave Search provider plugin for web search.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/brave-plugin",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.4.10",
|
||||
"allowInvalidConfigRecovery": true
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.11"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.11"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
52
extensions/brave/src/brave-web-search-provider.merge.test.ts
Normal file
52
extensions/brave/src/brave-web-search-provider.merge.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Brave tests cover brave web search provider.merge plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createBraveWebSearchProvider } from "./brave-web-search-provider.js";
|
||||
|
||||
const runtimeMock = vi.hoisted(() => {
|
||||
const searchConfigs: Array<Record<string, unknown> | undefined> = [];
|
||||
return {
|
||||
searchConfigs,
|
||||
executeBraveSearch: vi.fn(async (_args: unknown, searchConfig?: Record<string, unknown>) => {
|
||||
searchConfigs.push(searchConfig);
|
||||
return { results: [] };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./brave-web-search-provider.runtime.js", () => ({
|
||||
executeBraveSearch: runtimeMock.executeBraveSearch,
|
||||
}));
|
||||
|
||||
describe("brave web search config merge", () => {
|
||||
it("keeps plugin webSearch runtime-only after merging it for the tool", async () => {
|
||||
const provider = createBraveWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
brave: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "brave-test-key",
|
||||
mode: "llm-context",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
searchConfig: { provider: "brave" },
|
||||
});
|
||||
|
||||
await tool?.execute({ query: "OpenClaw docs" });
|
||||
|
||||
const [searchConfig] = runtimeMock.searchConfigs;
|
||||
expect(searchConfig?.brave).toEqual({
|
||||
apiKey: "brave-test-key",
|
||||
mode: "llm-context",
|
||||
});
|
||||
expect(searchConfig?.apiKey).toBe("brave-test-key");
|
||||
expect(Object.keys(searchConfig ?? {})).toEqual(["provider", "apiKey"]);
|
||||
expect(Object.getOwnPropertyDescriptor(searchConfig ?? {}, "brave")?.enumerable).toBe(false);
|
||||
});
|
||||
});
|
||||
560
extensions/brave/src/brave-web-search-provider.runtime.ts
Normal file
560
extensions/brave/src/brave-web-search-provider.runtime.ts
Normal file
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* Brave Search HTTP runtime. It resolves credentials, enforces endpoint safety,
|
||||
* applies caching, and maps Brave web/LLM-context API responses.
|
||||
*/
|
||||
import {
|
||||
assertOkOrThrowProviderError,
|
||||
readProviderJsonResponse,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import type { SearchConfigRecord } from "openclaw/plugin-sdk/provider-web-search";
|
||||
import {
|
||||
buildSearchCacheKey,
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
formatCliCommand,
|
||||
MAX_SEARCH_COUNT,
|
||||
parseWebSearchTimeFilters,
|
||||
readCachedSearchPayload,
|
||||
readConfiguredSecretString,
|
||||
readPositiveIntegerParam,
|
||||
readProviderEnvValue,
|
||||
readStringParam,
|
||||
resolveSearchCacheTtlMs,
|
||||
resolveSearchCount,
|
||||
resolveSearchTimeoutSeconds,
|
||||
resolveSiteName,
|
||||
withSelfHostedWebSearchEndpoint,
|
||||
withTrustedWebSearchEndpoint,
|
||||
wrapWebContent,
|
||||
writeCachedSearchPayload,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import {
|
||||
assertHttpUrlTargetsPrivateNetwork,
|
||||
isBlockedHostnameOrIp,
|
||||
isPrivateIpAddress,
|
||||
resolvePinnedHostnameWithPolicy,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
type BraveLlmContextResponse,
|
||||
mapBraveLlmContextResults,
|
||||
normalizeBraveCountry,
|
||||
normalizeBraveLanguageParams,
|
||||
resolveBraveConfig,
|
||||
resolveBraveMode,
|
||||
} from "./brave-web-search-provider.shared.js";
|
||||
|
||||
const DEFAULT_BRAVE_BASE_URL = "https://api.search.brave.com";
|
||||
const BRAVE_SEARCH_ENDPOINT_PATH = "/res/v1/web/search";
|
||||
const BRAVE_LLM_CONTEXT_ENDPOINT_PATH = "/res/v1/llm/context";
|
||||
const braveHttpLogger = createSubsystemLogger("brave/http");
|
||||
type BraveEndpointMode = "selfHosted" | "strict";
|
||||
type BraveSearchMode = "llm-context" | "web";
|
||||
|
||||
type BraveSearchResult = {
|
||||
title?: string;
|
||||
url?: string;
|
||||
description?: string;
|
||||
age?: string;
|
||||
};
|
||||
|
||||
type BraveSearchResponse = {
|
||||
web?: {
|
||||
results?: BraveSearchResult[];
|
||||
};
|
||||
};
|
||||
|
||||
type BraveHttpDiagnostics = {
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
function logBraveHttp(
|
||||
diagnostics: BraveHttpDiagnostics | undefined,
|
||||
event: string,
|
||||
meta?: Record<string, unknown>,
|
||||
): void {
|
||||
if (!diagnostics?.enabled) {
|
||||
return;
|
||||
}
|
||||
braveHttpLogger.info(`brave http ${event}`, meta);
|
||||
}
|
||||
|
||||
function describeBraveRequestUrl(url: URL): {
|
||||
url: string;
|
||||
query: string;
|
||||
params: Record<string, string>;
|
||||
} {
|
||||
return {
|
||||
url: url.toString(),
|
||||
query: url.searchParams.get("q") ?? "",
|
||||
params: Object.fromEntries(url.searchParams.entries()),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBraveApiKey(searchConfig?: SearchConfigRecord): string | undefined {
|
||||
return (
|
||||
readConfiguredSecretString(searchConfig?.apiKey, "tools.web.search.apiKey") ??
|
||||
readProviderEnvValue(["BRAVE_API_KEY"])
|
||||
);
|
||||
}
|
||||
|
||||
function resolveBraveBaseUrl(braveConfig: { baseUrl?: unknown } | undefined): string {
|
||||
const configured = readConfiguredSecretString(
|
||||
braveConfig?.baseUrl,
|
||||
"plugins.entries.brave.config.webSearch.baseUrl",
|
||||
);
|
||||
return configured?.replace(/\/+$/u, "") || DEFAULT_BRAVE_BASE_URL;
|
||||
}
|
||||
|
||||
function buildBraveEndpointUrl(params: { baseUrl: string; endpointPath: string }): URL {
|
||||
const url = new URL(params.baseUrl);
|
||||
const basePath = url.pathname.replace(/\/+$/u, "");
|
||||
url.pathname = `${basePath}${params.endpointPath}`;
|
||||
url.search = "";
|
||||
return url;
|
||||
}
|
||||
|
||||
async function braveEndpointTargetsPrivateNetwork(url: URL): Promise<boolean> {
|
||||
if (isBlockedHostnameOrIp(url.hostname)) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const pinned = await resolvePinnedHostnameWithPolicy(url.hostname, {
|
||||
policy: {
|
||||
allowPrivateNetwork: true,
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
},
|
||||
});
|
||||
return pinned.addresses.every((address) => isPrivateIpAddress(address));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function validateBraveBaseUrl(baseUrl: string): Promise<BraveEndpointMode> {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(baseUrl);
|
||||
} catch {
|
||||
throw new Error("Brave Search base URL must be a valid http:// or https:// URL.");
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error("Brave Search base URL must use http:// or https://.");
|
||||
}
|
||||
|
||||
if (parsed.protocol === "http:") {
|
||||
await assertHttpUrlTargetsPrivateNetwork(parsed.toString(), {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
errorMessage:
|
||||
"Brave Search HTTP base URL must target a trusted private or loopback host. Use https:// for public hosts.",
|
||||
});
|
||||
return "selfHosted";
|
||||
}
|
||||
|
||||
return (await braveEndpointTargetsPrivateNetwork(parsed)) ? "selfHosted" : "strict";
|
||||
}
|
||||
|
||||
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. 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.`,
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
function setBraveSearchUrlParams(
|
||||
url: URL,
|
||||
params: {
|
||||
query: string;
|
||||
country?: string;
|
||||
search_lang?: string;
|
||||
freshness?: string;
|
||||
dateAfter?: string;
|
||||
dateBefore?: string;
|
||||
allowDateBeforeOnly?: boolean;
|
||||
},
|
||||
): void {
|
||||
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);
|
||||
} 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.allowDateBeforeOnly && params.dateBefore) {
|
||||
url.searchParams.set("freshness", `1970-01-01to${params.dateBefore}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runBraveJsonRequest<T>(
|
||||
params: {
|
||||
baseUrl: string;
|
||||
endpointPath: string;
|
||||
endpointMode: BraveEndpointMode;
|
||||
mode: BraveSearchMode;
|
||||
apiKey: string;
|
||||
timeoutSeconds: number;
|
||||
diagnostics?: BraveHttpDiagnostics;
|
||||
configureUrl: (url: URL) => void;
|
||||
},
|
||||
errorLabel: string,
|
||||
): Promise<T> {
|
||||
const url = buildBraveEndpointUrl({
|
||||
baseUrl: params.baseUrl,
|
||||
endpointPath: params.endpointPath,
|
||||
});
|
||||
params.configureUrl(url);
|
||||
logBraveHttp(params.diagnostics, "request", {
|
||||
mode: params.mode,
|
||||
...describeBraveRequestUrl(url),
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
const withEndpoint =
|
||||
params.endpointMode === "selfHosted"
|
||||
? withSelfHostedWebSearchEndpoint
|
||||
: withTrustedWebSearchEndpoint;
|
||||
return withEndpoint(
|
||||
{
|
||||
url: url.toString(),
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"X-Subscription-Token": params.apiKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (response) => {
|
||||
logBraveHttp(params.diagnostics, "response", {
|
||||
mode: params.mode,
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
await assertOkOrThrowProviderError(response, errorLabel);
|
||||
return readProviderJsonResponse<T>(response, errorLabel);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function runBraveLlmContextSearch(params: {
|
||||
baseUrl: string;
|
||||
endpointMode: BraveEndpointMode;
|
||||
query: string;
|
||||
apiKey: string;
|
||||
timeoutSeconds: number;
|
||||
diagnostics?: BraveHttpDiagnostics;
|
||||
country?: string;
|
||||
search_lang?: string;
|
||||
freshness?: string;
|
||||
dateAfter?: string;
|
||||
dateBefore?: string;
|
||||
}): Promise<{
|
||||
results: Array<{
|
||||
url: string;
|
||||
title: string;
|
||||
snippets: string[];
|
||||
siteName?: string;
|
||||
}>;
|
||||
sources?: BraveLlmContextResponse["sources"];
|
||||
}> {
|
||||
const data = await runBraveJsonRequest<BraveLlmContextResponse>(
|
||||
{
|
||||
baseUrl: params.baseUrl,
|
||||
endpointPath: BRAVE_LLM_CONTEXT_ENDPOINT_PATH,
|
||||
mode: "llm-context",
|
||||
endpointMode: params.endpointMode,
|
||||
apiKey: params.apiKey,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
diagnostics: params.diagnostics,
|
||||
configureUrl: (url) => {
|
||||
setBraveSearchUrlParams(url, params);
|
||||
},
|
||||
},
|
||||
"Brave LLM Context API error",
|
||||
);
|
||||
return { results: mapBraveLlmContextResults(data), sources: data.sources };
|
||||
}
|
||||
|
||||
async function runBraveWebSearch(params: {
|
||||
baseUrl: string;
|
||||
endpointMode: BraveEndpointMode;
|
||||
query: string;
|
||||
count: number;
|
||||
apiKey: string;
|
||||
timeoutSeconds: number;
|
||||
diagnostics?: BraveHttpDiagnostics;
|
||||
country?: string;
|
||||
search_lang?: string;
|
||||
ui_lang?: string;
|
||||
freshness?: string;
|
||||
dateAfter?: string;
|
||||
dateBefore?: string;
|
||||
}): Promise<Array<Record<string, unknown>>> {
|
||||
const data = await runBraveJsonRequest<BraveSearchResponse>(
|
||||
{
|
||||
baseUrl: params.baseUrl,
|
||||
endpointPath: BRAVE_SEARCH_ENDPOINT_PATH,
|
||||
mode: "web",
|
||||
endpointMode: params.endpointMode,
|
||||
apiKey: params.apiKey,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
diagnostics: params.diagnostics,
|
||||
configureUrl: (url) => {
|
||||
setBraveSearchUrlParams(url, {
|
||||
...params,
|
||||
allowDateBeforeOnly: true,
|
||||
});
|
||||
url.searchParams.set("count", String(params.count));
|
||||
if (params.ui_lang) {
|
||||
url.searchParams.set("ui_lang", params.ui_lang);
|
||||
}
|
||||
},
|
||||
},
|
||||
"Brave Search API error",
|
||||
);
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Execute one Brave Search request using web or LLM-context mode. */
|
||||
export async function executeBraveSearch(
|
||||
args: Record<string, unknown>,
|
||||
searchConfig?: SearchConfigRecord,
|
||||
options?: {
|
||||
diagnosticsEnabled?: boolean;
|
||||
},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const apiKey = resolveBraveApiKey(searchConfig);
|
||||
if (!apiKey) {
|
||||
return missingBraveKeyPayload();
|
||||
}
|
||||
|
||||
const braveConfig = resolveBraveConfig(searchConfig);
|
||||
const braveMode = resolveBraveMode(braveConfig);
|
||||
const braveBaseUrl = resolveBraveBaseUrl(braveConfig);
|
||||
const braveEndpointMode = await validateBraveBaseUrl(braveBaseUrl);
|
||||
const query = readStringParam(args, "query", { required: true });
|
||||
const count =
|
||||
readPositiveIntegerParam(args, "count", {
|
||||
max: MAX_SEARCH_COUNT,
|
||||
message: `count must be an integer from 1 to ${MAX_SEARCH_COUNT}.`,
|
||||
}) ??
|
||||
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");
|
||||
const rawDateAfter = readStringParam(args, "date_after");
|
||||
const rawDateBefore = readStringParam(args, "date_before");
|
||||
const parsedTimeFilters = parseWebSearchTimeFilters({
|
||||
rawDateAfter,
|
||||
rawDateBefore,
|
||||
rawFreshness,
|
||||
freshnessProvider: "brave",
|
||||
invalidFreshnessMessage: "freshness must be day, week, month, or year.",
|
||||
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 parsedTimeFilters) {
|
||||
return parsedTimeFilters;
|
||||
}
|
||||
|
||||
const { freshness, dateAfter, dateBefore } = parsedTimeFilters;
|
||||
if (braveMode === "llm-context") {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
if (dateAfter && !dateBefore && dateAfter > today) {
|
||||
return {
|
||||
error: "invalid_date_range",
|
||||
message: "date_after cannot be in the future for Brave llm-context mode.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
if (dateBefore && !dateAfter) {
|
||||
return {
|
||||
error: "unsupported_date_filter",
|
||||
message:
|
||||
"Brave llm-context mode requires date_after when date_before is set. Use a bounded date range or freshness.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
}
|
||||
const llmContextDateEnd =
|
||||
braveMode === "llm-context" && dateAfter
|
||||
? (dateBefore ?? new Date().toISOString().slice(0, 10))
|
||||
: dateBefore;
|
||||
const cacheKey = buildSearchCacheKey(
|
||||
braveMode === "llm-context"
|
||||
? [
|
||||
"brave",
|
||||
braveMode,
|
||||
braveBaseUrl,
|
||||
query,
|
||||
country,
|
||||
normalizedLanguage.search_lang,
|
||||
freshness,
|
||||
dateAfter,
|
||||
llmContextDateEnd,
|
||||
]
|
||||
: [
|
||||
"brave",
|
||||
braveMode,
|
||||
braveBaseUrl,
|
||||
query,
|
||||
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
country,
|
||||
normalizedLanguage.search_lang,
|
||||
normalizedLanguage.ui_lang,
|
||||
freshness,
|
||||
dateAfter,
|
||||
dateBefore,
|
||||
],
|
||||
);
|
||||
const diagnostics: BraveHttpDiagnostics = { enabled: options?.diagnosticsEnabled === true };
|
||||
const cached = readCachedSearchPayload(cacheKey);
|
||||
if (cached) {
|
||||
logBraveHttp(diagnostics, "cache hit", { mode: braveMode, query, cacheKey });
|
||||
return cached;
|
||||
}
|
||||
logBraveHttp(diagnostics, "cache miss", { mode: braveMode, query, cacheKey });
|
||||
|
||||
const start = Date.now();
|
||||
const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig);
|
||||
const cacheTtlMs = resolveSearchCacheTtlMs(searchConfig);
|
||||
|
||||
if (braveMode === "llm-context") {
|
||||
const { results, sources } = await runBraveLlmContextSearch({
|
||||
baseUrl: braveBaseUrl,
|
||||
endpointMode: braveEndpointMode,
|
||||
query,
|
||||
apiKey,
|
||||
timeoutSeconds,
|
||||
diagnostics,
|
||||
country: country ?? undefined,
|
||||
search_lang: normalizedLanguage.search_lang,
|
||||
freshness,
|
||||
dateAfter,
|
||||
dateBefore,
|
||||
});
|
||||
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);
|
||||
logBraveHttp(diagnostics, "cache write", {
|
||||
mode: "llm-context",
|
||||
query,
|
||||
cacheKey,
|
||||
ttlMs: cacheTtlMs,
|
||||
count: results.length,
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
|
||||
const results = await runBraveWebSearch({
|
||||
baseUrl: braveBaseUrl,
|
||||
endpointMode: braveEndpointMode,
|
||||
query,
|
||||
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
apiKey,
|
||||
timeoutSeconds,
|
||||
diagnostics,
|
||||
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);
|
||||
logBraveHttp(diagnostics, "cache write", {
|
||||
mode: "web",
|
||||
query,
|
||||
cacheKey,
|
||||
ttlMs: cacheTtlMs,
|
||||
count: results.length,
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
237
extensions/brave/src/brave-web-search-provider.shared.ts
Normal file
237
extensions/brave/src/brave-web-search-provider.shared.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Brave Search request normalization and result mapping. It validates Brave
|
||||
* country/language params and converts LLM-context responses into web results.
|
||||
*/
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
type BraveConfig = {
|
||||
baseUrl?: unknown;
|
||||
mode?: string;
|
||||
};
|
||||
|
||||
type BraveLlmContextResult = { url: string; title: string; snippets: string[] };
|
||||
/** Brave LLM Context API response subset used by OpenClaw. */
|
||||
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<string, string> = {
|
||||
ja: "jp",
|
||||
zh: "zh-hans",
|
||||
"zh-cn": "zh-hans",
|
||||
"zh-hk": "zh-hant",
|
||||
"zh-sg": "zh-hans",
|
||||
"zh-tw": "zh-hant",
|
||||
};
|
||||
|
||||
const BRAVE_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i;
|
||||
|
||||
function normalizeBraveSearchLang(value: string | undefined): string | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const lower = normalizeLowercaseStringOrEmpty(trimmed);
|
||||
const canonical = BRAVE_SEARCH_LANG_ALIASES[lower] ?? lower;
|
||||
if (!BRAVE_SEARCH_LANG_CODES.has(canonical)) {
|
||||
return undefined;
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
/** Normalize Brave country filter values. */
|
||||
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 `${normalizeLowercaseStringOrEmpty(language)}-${region.toUpperCase()}`;
|
||||
}
|
||||
|
||||
/** Resolve Brave-specific web-search config from scoped search config. */
|
||||
export function resolveBraveConfig(searchConfig?: Record<string, unknown>): BraveConfig {
|
||||
const brave = searchConfig?.brave;
|
||||
return brave && typeof brave === "object" && !Array.isArray(brave) ? (brave as BraveConfig) : {};
|
||||
}
|
||||
|
||||
/** Resolve whether Brave should use web search or LLM Context API mode. */
|
||||
export function resolveBraveMode(brave?: BraveConfig): "web" | "llm-context" {
|
||||
return brave?.mode === "llm-context" ? "llm-context" : "web";
|
||||
}
|
||||
|
||||
/** Normalize Brave search and UI language params, detecting swapped fields. */
|
||||
export function normalizeBraveLanguageParams(params: { search_lang?: string; ui_lang?: string }): {
|
||||
search_lang?: string;
|
||||
ui_lang?: string;
|
||||
invalidField?: "search_lang" | "ui_lang";
|
||||
} {
|
||||
const rawSearchLang = normalizeOptionalString(params.search_lang);
|
||||
const rawUiLang = normalizeOptionalString(params.ui_lang);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** Map Brave LLM Context API grounding results into web-search result rows. */
|
||||
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,
|
||||
}));
|
||||
}
|
||||
824
extensions/brave/src/brave-web-search-provider.test.ts
Normal file
824
extensions/brave/src/brave-web-search-provider.test.ts
Normal file
@@ -0,0 +1,824 @@
|
||||
// Brave tests cover brave web search provider plugin behavior.
|
||||
import fs from "node:fs";
|
||||
import { validateJsonSchemaValue } from "openclaw/plugin-sdk/json-schema-runtime";
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { testing } from "../test-api.js";
|
||||
import { createBraveWebSearchProvider as createBraveWebSearchContractProvider } from "../web-search-contract-api.js";
|
||||
import { createBraveWebSearchProvider } from "./brave-web-search-provider.js";
|
||||
|
||||
const loggerInfoMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
|
||||
createSubsystemLogger: () => ({
|
||||
info: loggerInfoMock,
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
trace: vi.fn(),
|
||||
raw: vi.fn(),
|
||||
isEnabled: () => true,
|
||||
child: () => ({
|
||||
info: loggerInfoMock,
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
trace: vi.fn(),
|
||||
raw: vi.fn(),
|
||||
isEnabled: () => true,
|
||||
child: vi.fn(),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
const braveManifest = JSON.parse(
|
||||
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf-8"),
|
||||
) as {
|
||||
configSchema?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("openclaw/plugin-sdk/runtime-env");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
function jsonResponse(payload: unknown, init?: ResponseInit): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
function malformedJsonResponse(): Response {
|
||||
return new Response("{ nope", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function emptyWebSearchResponse(): Response {
|
||||
return jsonResponse({ web: { results: [] } });
|
||||
}
|
||||
|
||||
function installBraveLlmContextFetch() {
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return jsonResponse({
|
||||
grounding: {
|
||||
generic: [
|
||||
{
|
||||
url: "https://example.com/context",
|
||||
title: "Context",
|
||||
snippets: ["snippet"],
|
||||
},
|
||||
],
|
||||
},
|
||||
sources: [],
|
||||
});
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
return mockFetch;
|
||||
}
|
||||
|
||||
function readHeader(init: unknown, name: string): string | null {
|
||||
const headers = (init as { headers?: HeadersInit } | undefined)?.headers;
|
||||
if (!headers) {
|
||||
return null;
|
||||
}
|
||||
return new Headers(headers).get(name);
|
||||
}
|
||||
|
||||
function fetchCall(mockFetch: { mock: { calls: Array<Array<unknown>> } }, index = 0) {
|
||||
const call = mockFetch.mock.calls[index];
|
||||
if (!call) {
|
||||
throw new Error(`Expected fetch call ${index + 1}`);
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
function fetchRequestUrl(mockFetch: { mock: { calls: Array<Array<unknown>> } }, index = 0) {
|
||||
return new URL(String(fetchCall(mockFetch, index)[0]));
|
||||
}
|
||||
|
||||
function fetchRequestInit(mockFetch: { mock: { calls: Array<Array<unknown>> } }, index = 0) {
|
||||
return fetchCall(mockFetch, index)[1];
|
||||
}
|
||||
|
||||
function createBodyOnlyErrorResponse(params: { body: string; status: number }): Response {
|
||||
const bytes = new TextEncoder().encode(params.body);
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(bytes);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
status: params.status,
|
||||
statusText: "Too Many Requests",
|
||||
headers: new Headers(),
|
||||
body,
|
||||
} as Response;
|
||||
}
|
||||
|
||||
describe("brave web search provider", () => {
|
||||
const priorFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
loggerInfoMock.mockClear();
|
||||
global.fetch = priorFetch;
|
||||
});
|
||||
|
||||
it("points provider metadata at the canonical Brave docs page", () => {
|
||||
expect(createBraveWebSearchProvider().docsUrl).toBe(
|
||||
"https://docs.openclaw.ai/tools/brave-search",
|
||||
);
|
||||
expect(createBraveWebSearchContractProvider().docsUrl).toBe(
|
||||
"https://docs.openclaw.ai/tools/brave-search",
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes legacy top-level apiKey as a Brave-owned compatibility fallback", () => {
|
||||
const apiKey = { source: "env", provider: "default", id: "BRAVE_API_KEY" } as const;
|
||||
const config = {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
apiKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(createBraveWebSearchProvider().getConfiguredCredentialValue?.(config)).toEqual(apiKey);
|
||||
expect(createBraveWebSearchContractProvider().getConfiguredCredentialValue?.(config)).toEqual(
|
||||
apiKey,
|
||||
);
|
||||
expect(createBraveWebSearchProvider().getConfiguredCredentialFallback?.(config)).toEqual({
|
||||
path: "tools.web.search.apiKey",
|
||||
value: apiKey,
|
||||
});
|
||||
expect(
|
||||
createBraveWebSearchContractProvider().getConfiguredCredentialFallback?.(config),
|
||||
).toEqual({
|
||||
path: "tools.web.search.apiKey",
|
||||
value: apiKey,
|
||||
});
|
||||
});
|
||||
|
||||
it("points missing-key users to fetch/browser alternatives", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const provider = createBraveWebSearchProvider();
|
||||
const tool = provider.createTool({ config: {}, searchConfig: {} });
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
const result = await tool.execute({ query: "OpenClaw docs" });
|
||||
|
||||
expect(result).toEqual({
|
||||
error: "missing_brave_api_key",
|
||||
message:
|
||||
"web_search (brave) needs a Brave Search API key. Run `openclaw 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.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes brave language parameters and swaps reversed ui/search inputs", () => {
|
||||
expect(
|
||||
testing.normalizeBraveLanguageParams({
|
||||
search_lang: "en-US",
|
||||
ui_lang: "ja",
|
||||
}),
|
||||
).toEqual({
|
||||
search_lang: "jp",
|
||||
ui_lang: "en-US",
|
||||
});
|
||||
expect(testing.normalizeBraveLanguageParams({ search_lang: "tr-TR", ui_lang: "tr" })).toEqual({
|
||||
search_lang: "tr",
|
||||
ui_lang: "tr-TR",
|
||||
});
|
||||
expect(testing.normalizeBraveLanguageParams({ search_lang: "EN", ui_lang: "en-us" })).toEqual({
|
||||
search_lang: "en",
|
||||
ui_lang: "en-US",
|
||||
});
|
||||
});
|
||||
|
||||
it("flags invalid brave language fields", () => {
|
||||
expect(
|
||||
testing.normalizeBraveLanguageParams({
|
||||
search_lang: "xx",
|
||||
}),
|
||||
).toEqual({ invalidField: "search_lang" });
|
||||
expect(testing.normalizeBraveLanguageParams({ search_lang: "en-US" })).toEqual({
|
||||
invalidField: "search_lang",
|
||||
});
|
||||
expect(testing.normalizeBraveLanguageParams({ ui_lang: "en" })).toEqual({
|
||||
invalidField: "ui_lang",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes Brave country codes and falls back unsupported values to ALL", () => {
|
||||
expect(testing.normalizeBraveCountry("de")).toBe("DE");
|
||||
expect(testing.normalizeBraveCountry(" VN ")).toBe("ALL");
|
||||
expect(testing.normalizeBraveCountry("")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("defaults brave mode to web unless llm-context is explicitly selected", () => {
|
||||
expect(testing.resolveBraveMode()).toBe("web");
|
||||
expect(testing.resolveBraveMode({ mode: "llm-context" })).toBe("llm-context");
|
||||
});
|
||||
|
||||
it("accepts llm-context in the Brave plugin config schema", () => {
|
||||
if (!braveManifest.configSchema) {
|
||||
throw new Error("Expected Brave manifest config schema");
|
||||
}
|
||||
|
||||
const result = validateJsonSchemaValue({
|
||||
schema: braveManifest.configSchema,
|
||||
cacheKey: "test:brave-config-schema",
|
||||
value: {
|
||||
webSearch: {
|
||||
mode: "llm-context",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts baseUrl in the Brave plugin config schema", () => {
|
||||
if (!braveManifest.configSchema) {
|
||||
throw new Error("Expected Brave manifest config schema");
|
||||
}
|
||||
|
||||
const result = validateJsonSchemaValue({
|
||||
schema: braveManifest.configSchema,
|
||||
cacheKey: "test:brave-config-schema-base-url",
|
||||
value: {
|
||||
webSearch: {
|
||||
baseUrl: "https://api.search.brave.com/proxy",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("uses configured Brave baseUrl for web search requests", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return emptyWebSearchResponse();
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
const provider = createBraveWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {},
|
||||
searchConfig: {
|
||||
apiKey: "brave-test-key",
|
||||
brave: {
|
||||
baseUrl: "https://api.search.brave.com/proxy/",
|
||||
mode: "web",
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
await tool.execute({ query: "latest ai news" });
|
||||
|
||||
const requestUrl = fetchRequestUrl(mockFetch);
|
||||
expect(requestUrl.origin).toBe("https://api.search.brave.com");
|
||||
expect(requestUrl.pathname).toBe("/proxy/res/v1/web/search");
|
||||
});
|
||||
|
||||
it("uses configured Brave baseUrl for llm-context requests", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = installBraveLlmContextFetch();
|
||||
const provider = createBraveWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {},
|
||||
searchConfig: {
|
||||
apiKey: "brave-test-key",
|
||||
brave: {
|
||||
baseUrl: "https://api.search.brave.com/proxy",
|
||||
mode: "llm-context",
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
await tool.execute({ query: "latest ai news" });
|
||||
|
||||
const requestUrl = fetchRequestUrl(mockFetch);
|
||||
expect(requestUrl.pathname).toBe("/proxy/res/v1/llm/context");
|
||||
});
|
||||
|
||||
it("reports malformed Brave web search JSON as a provider error", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return malformedJsonResponse();
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
const provider = createBraveWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {},
|
||||
searchConfig: {
|
||||
apiKey: "brave-test-key",
|
||||
brave: { mode: "web" },
|
||||
},
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
await expect(tool.execute({ query: "latest ai news" })).rejects.toThrow(
|
||||
"Brave Search API error: malformed JSON response",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports malformed Brave llm-context JSON as a provider error", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return malformedJsonResponse();
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
const provider = createBraveWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {},
|
||||
searchConfig: {
|
||||
apiKey: "brave-test-key",
|
||||
brave: { mode: "llm-context" },
|
||||
},
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
await expect(tool.execute({ query: "latest ai news" })).rejects.toThrow(
|
||||
"Brave LLM Context API error: malformed JSON response",
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds Brave web error bodies without using response.text", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) =>
|
||||
createBodyOnlyErrorResponse({
|
||||
status: 429,
|
||||
body: `${"x".repeat(24 * 1024)}tail-marker`,
|
||||
}),
|
||||
);
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
const provider = createBraveWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {},
|
||||
searchConfig: {
|
||||
apiKey: "brave-test-key",
|
||||
brave: { mode: "web" },
|
||||
},
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
const error = await tool.execute({ query: "latest ai news" }).catch((value: unknown) => value);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
expect(message).toContain("Brave Search API error (429):");
|
||||
expect(message).not.toContain("tail-marker");
|
||||
expect(message.length).toBeLessThan(700);
|
||||
});
|
||||
|
||||
it("bounds Brave llm-context error bodies without using response.text", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) =>
|
||||
createBodyOnlyErrorResponse({
|
||||
status: 429,
|
||||
body: `${"x".repeat(24 * 1024)}tail-marker`,
|
||||
}),
|
||||
);
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
const provider = createBraveWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {},
|
||||
searchConfig: {
|
||||
apiKey: "brave-test-key",
|
||||
brave: { mode: "llm-context" },
|
||||
},
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
const error = await tool.execute({ query: "latest ai news" }).catch((value: unknown) => value);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
expect(message).toContain("Brave LLM Context API error (429):");
|
||||
expect(message).not.toContain("tail-marker");
|
||||
expect(message.length).toBeLessThan(700);
|
||||
});
|
||||
|
||||
it("keeps Brave cache entries isolated by baseUrl", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return emptyWebSearchResponse();
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
const provider = createBraveWebSearchProvider();
|
||||
const firstTool = provider.createTool({
|
||||
config: {},
|
||||
searchConfig: {
|
||||
apiKey: "brave-test-key",
|
||||
brave: {
|
||||
baseUrl: "https://api.search.brave.com/proxy-one",
|
||||
mode: "web",
|
||||
},
|
||||
},
|
||||
});
|
||||
const secondTool = provider.createTool({
|
||||
config: {},
|
||||
searchConfig: {
|
||||
apiKey: "brave-test-key",
|
||||
brave: {
|
||||
baseUrl: "https://api.search.brave.com/proxy-two",
|
||||
mode: "web",
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!firstTool || !secondTool) {
|
||||
throw new Error("Expected tool definitions");
|
||||
}
|
||||
|
||||
await firstTool.execute({ query: "base url cache identity" });
|
||||
await secondTool.execute({ query: "base url cache identity" });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(fetchRequestUrl(mockFetch).pathname).toBe("/proxy-one/res/v1/web/search");
|
||||
expect(fetchRequestUrl(mockFetch, 1).pathname).toBe("/proxy-two/res/v1/web/search");
|
||||
});
|
||||
|
||||
it("rejects invalid Brave mode values in the plugin config schema", () => {
|
||||
if (!braveManifest.configSchema) {
|
||||
throw new Error("Expected Brave manifest config schema");
|
||||
}
|
||||
|
||||
const result = validateJsonSchemaValue({
|
||||
schema: braveManifest.configSchema,
|
||||
cacheKey: "test:brave-config-schema",
|
||||
value: {
|
||||
webSearch: {
|
||||
mode: "invalid-mode",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
return;
|
||||
}
|
||||
expect(result.errors).toEqual([
|
||||
{
|
||||
path: "webSearch.mode",
|
||||
message: 'must be equal to one of the allowed values (allowed: "web", "llm-context")',
|
||||
text: 'webSearch.mode: must be equal to one of the allowed values (allowed: "web", "llm-context")',
|
||||
allowedValues: ["web", "llm-context"],
|
||||
allowedValuesHiddenCount: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps llm-context results into wrapped source entries", () => {
|
||||
expect(
|
||||
testing.mapBraveLlmContextResults({
|
||||
grounding: {
|
||||
generic: [
|
||||
{
|
||||
url: "https://example.com/post",
|
||||
title: "Example",
|
||||
snippets: ["a", "", "b"],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
url: "https://example.com/post",
|
||||
title: "Example",
|
||||
snippets: ["a", "b"],
|
||||
siteName: "example.com",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns validation errors for invalid date ranges", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const provider = createBraveWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {},
|
||||
searchConfig: {
|
||||
apiKey: "BSA...",
|
||||
brave: { apiKey: "BSA..." },
|
||||
},
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
const result = await tool.execute({
|
||||
query: "latest gpu news",
|
||||
date_after: "2026-03-20",
|
||||
date_before: "2026-03-01",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
error: "invalid_date_range",
|
||||
message: "date_after must be before date_before.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes freshness to Brave llm-context endpoint", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "test-key");
|
||||
const mockFetch = installBraveLlmContextFetch();
|
||||
const provider = createBraveWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {},
|
||||
searchConfig: {
|
||||
apiKey: "BSA...",
|
||||
brave: { mode: "llm-context" },
|
||||
},
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
await tool.execute({ query: "latest ai news", freshness: "week" });
|
||||
|
||||
const requestUrl = fetchRequestUrl(mockFetch);
|
||||
expect(requestUrl.pathname).toBe("/res/v1/llm/context");
|
||||
expect(requestUrl.searchParams.get("freshness")).toBe("pw");
|
||||
});
|
||||
|
||||
it("sends Brave web auth in the X-Subscription-Token header", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return emptyWebSearchResponse();
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
const provider = createBraveWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {},
|
||||
searchConfig: {
|
||||
apiKey: "brave-test-key",
|
||||
brave: { mode: "web" },
|
||||
},
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
await tool.execute({ query: "latest ai news" });
|
||||
|
||||
const requestUrl = fetchRequestUrl(mockFetch);
|
||||
expect(requestUrl.searchParams.get("apikey")).toBeNull();
|
||||
expect(requestUrl.searchParams.get("key")).toBeNull();
|
||||
expect(readHeader(fetchRequestInit(mockFetch), "X-Subscription-Token")).toBe("brave-test-key");
|
||||
});
|
||||
|
||||
it("sends Brave llm-context auth in the X-Subscription-Token header", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = installBraveLlmContextFetch();
|
||||
const provider = createBraveWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {},
|
||||
searchConfig: {
|
||||
apiKey: "brave-test-key",
|
||||
brave: { mode: "llm-context" },
|
||||
},
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
await tool.execute({ query: "latest ai news" });
|
||||
|
||||
const requestUrl = fetchRequestUrl(mockFetch);
|
||||
expect(requestUrl.searchParams.get("apikey")).toBeNull();
|
||||
expect(requestUrl.searchParams.get("key")).toBeNull();
|
||||
expect(readHeader(fetchRequestInit(mockFetch), "X-Subscription-Token")).toBe("brave-test-key");
|
||||
});
|
||||
|
||||
it("passes bounded date ranges to Brave llm-context endpoint", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "test-key");
|
||||
const mockFetch = installBraveLlmContextFetch();
|
||||
const provider = createBraveWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {},
|
||||
searchConfig: {
|
||||
apiKey: "BSA...",
|
||||
brave: { mode: "llm-context" },
|
||||
},
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
await tool.execute({
|
||||
query: "latest ai news",
|
||||
date_after: "2025-01-01",
|
||||
date_before: "2025-01-31",
|
||||
});
|
||||
|
||||
const requestUrl = fetchRequestUrl(mockFetch);
|
||||
expect(requestUrl.pathname).toBe("/res/v1/llm/context");
|
||||
expect(requestUrl.searchParams.get("freshness")).toBe("2025-01-01to2025-01-31");
|
||||
});
|
||||
|
||||
it("uses today as the end date for Brave llm-context date_after-only ranges", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "test-key");
|
||||
const mockFetch = installBraveLlmContextFetch();
|
||||
const provider = createBraveWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {},
|
||||
searchConfig: {
|
||||
apiKey: "BSA...",
|
||||
brave: { mode: "llm-context" },
|
||||
},
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
await tool.execute({ query: "latest ai news", date_after: "2025-01-01" });
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const requestUrl = fetchRequestUrl(mockFetch);
|
||||
expect(requestUrl.pathname).toBe("/res/v1/llm/context");
|
||||
expect(requestUrl.searchParams.get("freshness")).toBe(`2025-01-01to${today}`);
|
||||
});
|
||||
|
||||
it("rejects future Brave llm-context date_after-only ranges before fetch", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "test-key");
|
||||
const mockFetch = installBraveLlmContextFetch();
|
||||
const provider = createBraveWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {},
|
||||
searchConfig: {
|
||||
apiKey: "BSA...",
|
||||
brave: { mode: "llm-context" },
|
||||
},
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
const result = await tool.execute({
|
||||
query: "latest ai news",
|
||||
date_after: "2999-01-01",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
error: "invalid_date_range",
|
||||
message: "date_after cannot be in the future for Brave llm-context mode.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
});
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects Brave llm-context date_before-only ranges before fetch", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "test-key");
|
||||
const mockFetch = installBraveLlmContextFetch();
|
||||
const provider = createBraveWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {},
|
||||
searchConfig: {
|
||||
apiKey: "BSA...",
|
||||
brave: { mode: "llm-context" },
|
||||
},
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
const result = await tool.execute({
|
||||
query: "latest ai news",
|
||||
date_before: "2025-01-31",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
error: "unsupported_date_filter",
|
||||
message:
|
||||
"Brave llm-context mode requires date_after when date_before is set. Use a bounded date range or freshness.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
});
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back unsupported country values before calling Brave", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "test-key");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return emptyWebSearchResponse();
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
const provider = createBraveWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {},
|
||||
searchConfig: {
|
||||
apiKey: "BSA...",
|
||||
brave: { apiKey: "BSA..." },
|
||||
},
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
await tool.execute({
|
||||
query: "latest Vietnam news",
|
||||
country: "VN",
|
||||
});
|
||||
|
||||
const requestUrl = fetchRequestUrl(mockFetch);
|
||||
expect(requestUrl.searchParams.get("country")).toBe("ALL");
|
||||
});
|
||||
|
||||
it("emits brave.http diagnostics for requests, responses, and cache events", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return jsonResponse({
|
||||
web: {
|
||||
results: [
|
||||
{
|
||||
title: "Diagnostics",
|
||||
url: "https://example.com/diagnostics",
|
||||
description: "debug details",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
const provider = createBraveWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: { diagnostics: { flags: ["brave.http"] } },
|
||||
searchConfig: {
|
||||
apiKey: "brave-test-key",
|
||||
brave: { mode: "web" },
|
||||
},
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
await tool.execute({ query: "unique brave diagnostics query", count: 1 });
|
||||
await tool.execute({ query: "unique brave diagnostics query", count: 1 });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const messages = loggerInfoMock.mock.calls.map((call) => call[0]);
|
||||
expect(messages).toEqual([
|
||||
"brave http cache miss",
|
||||
"brave http request",
|
||||
"brave http response",
|
||||
"brave http cache write",
|
||||
"brave http cache hit",
|
||||
]);
|
||||
const requestLog = loggerInfoMock.mock.calls.find(
|
||||
([message]) => message === "brave http request",
|
||||
);
|
||||
expect(requestLog?.[1]).toEqual({
|
||||
mode: "web",
|
||||
query: "unique brave diagnostics query",
|
||||
params: {
|
||||
count: "1",
|
||||
q: "unique brave diagnostics query",
|
||||
},
|
||||
url: "https://api.search.brave.com/res/v1/web/search?q=unique+brave+diagnostics+query&count=1",
|
||||
});
|
||||
const responseLog = loggerInfoMock.mock.calls.find(
|
||||
([message]) => message === "brave http response",
|
||||
);
|
||||
const responsePayload = responseLog?.[1] as
|
||||
| { durationMs?: unknown; mode?: unknown; ok?: unknown; status?: unknown }
|
||||
| undefined;
|
||||
expect(responsePayload?.mode).toBe("web");
|
||||
expect(responsePayload?.status).toBe(200);
|
||||
expect(responsePayload?.ok).toBe(true);
|
||||
expect(typeof responsePayload?.durationMs).toBe("number");
|
||||
expect(responsePayload?.durationMs).toBeGreaterThanOrEqual(0);
|
||||
expect(JSON.stringify(loggerInfoMock.mock.calls)).not.toContain("brave-test-key");
|
||||
expect(JSON.stringify(loggerInfoMock.mock.calls)).not.toContain("X-Subscription-Token");
|
||||
});
|
||||
});
|
||||
107
extensions/brave/src/brave-web-search-provider.ts
Normal file
107
extensions/brave/src/brave-web-search-provider.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Brave web-search provider factory. It builds the agent tool definition and
|
||||
* lazy-loads HTTP execution only when a search is run.
|
||||
*/
|
||||
import { isDiagnosticFlagEnabled } from "openclaw/plugin-sdk/diagnostic-runtime";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import type {
|
||||
SearchConfigRecord,
|
||||
WebSearchProviderPlugin,
|
||||
WebSearchProviderToolDefinition,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import {
|
||||
mergeScopedSearchConfig,
|
||||
resolveProviderWebSearchPluginConfig,
|
||||
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { buildBraveWebSearchProviderBase } from "../web-search-shared.js";
|
||||
|
||||
const loadBraveWebSearchRuntime = createLazyRuntimeModule(
|
||||
() => import("./brave-web-search-provider.runtime.js"),
|
||||
);
|
||||
|
||||
const BraveSearchSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query string." },
|
||||
count: {
|
||||
type: "integer",
|
||||
description: "Number of results to return (1-10).",
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
},
|
||||
country: {
|
||||
type: "string",
|
||||
description:
|
||||
"2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.",
|
||||
},
|
||||
language: {
|
||||
type: "string",
|
||||
description: "ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').",
|
||||
},
|
||||
freshness: {
|
||||
type: "string",
|
||||
description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.",
|
||||
},
|
||||
date_after: {
|
||||
type: "string",
|
||||
description: "Only results published after this date (YYYY-MM-DD).",
|
||||
},
|
||||
date_before: {
|
||||
type: "string",
|
||||
description: "Only results published before this date (YYYY-MM-DD).",
|
||||
},
|
||||
search_lang: {
|
||||
type: "string",
|
||||
description:
|
||||
"Brave language code for search results (e.g., 'en', 'de', 'en-gb', 'zh-hans', 'zh-hant', 'pt-br').",
|
||||
},
|
||||
ui_lang: {
|
||||
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.",
|
||||
},
|
||||
},
|
||||
} satisfies Record<string, unknown>;
|
||||
|
||||
function resolveBraveMode(searchConfig?: Record<string, unknown>): "web" | "llm-context" {
|
||||
const brave = isRecord(searchConfig?.brave) ? searchConfig.brave : undefined;
|
||||
return brave?.mode === "llm-context" ? "llm-context" : "web";
|
||||
}
|
||||
|
||||
function createBraveToolDefinition(
|
||||
searchConfig?: SearchConfigRecord,
|
||||
config?: Parameters<typeof isDiagnosticFlagEnabled>[1],
|
||||
): WebSearchProviderToolDefinition {
|
||||
const braveMode = resolveBraveMode(searchConfig);
|
||||
const diagnosticsEnabled = isDiagnosticFlagEnabled("brave.http", config);
|
||||
|
||||
return {
|
||||
description:
|
||||
braveMode === "llm-context"
|
||||
? "Search the web using Brave Search LLM Context API. Returns pre-extracted page content (text chunks, tables, code blocks) optimized for LLM grounding."
|
||||
: "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: BraveSearchSchema,
|
||||
execute: async (args) => {
|
||||
const { executeBraveSearch } = await loadBraveWebSearchRuntime();
|
||||
return await executeBraveSearch(args, searchConfig, { diagnosticsEnabled });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Create the runtime Brave Search provider descriptor. */
|
||||
export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
|
||||
return {
|
||||
...buildBraveWebSearchProviderBase(),
|
||||
createTool: (ctx) =>
|
||||
createBraveToolDefinition(
|
||||
mergeScopedSearchConfig(
|
||||
ctx.searchConfig,
|
||||
"brave",
|
||||
resolveProviderWebSearchPluginConfig(ctx.config, "brave"),
|
||||
{ mirrorApiKeyToTopLevel: true },
|
||||
),
|
||||
ctx.config,
|
||||
),
|
||||
};
|
||||
}
|
||||
19
extensions/brave/test-api.ts
Normal file
19
extensions/brave/test-api.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Brave Search test API barrel. Tests import normalized helpers through this
|
||||
* path instead of deep runtime modules.
|
||||
*/
|
||||
import {
|
||||
mapBraveLlmContextResults,
|
||||
normalizeBraveCountry,
|
||||
normalizeBraveLanguageParams,
|
||||
resolveBraveMode,
|
||||
} from "./src/brave-web-search-provider.shared.js";
|
||||
|
||||
/** Test-only Brave normalization helpers. */
|
||||
export const testing = {
|
||||
normalizeBraveCountry,
|
||||
normalizeBraveLanguageParams,
|
||||
resolveBraveMode,
|
||||
mapBraveLlmContextResults,
|
||||
} as const;
|
||||
export { testing as __testing };
|
||||
16
extensions/brave/tsconfig.json
Normal file
16
extensions/brave/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
14
extensions/brave/web-search-contract-api.ts
Normal file
14
extensions/brave/web-search-contract-api.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Brave Search contract provider. It exposes provider metadata without creating
|
||||
* the runtime search tool.
|
||||
*/
|
||||
import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-config-contract";
|
||||
import { buildBraveWebSearchProviderBase } from "./web-search-shared.js";
|
||||
|
||||
/** Create the Brave provider descriptor for contract checks. */
|
||||
export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
|
||||
return {
|
||||
...buildBraveWebSearchProviderBase(),
|
||||
createTool: () => null,
|
||||
};
|
||||
}
|
||||
5
extensions/brave/web-search-provider.ts
Normal file
5
extensions/brave/web-search-provider.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Public Brave web-search provider barrel. Runtime consumers import this
|
||||
* lightweight path for the provider factory.
|
||||
*/
|
||||
export { createBraveWebSearchProvider } from "./src/brave-web-search-provider.js";
|
||||
71
extensions/brave/web-search-shared.ts
Normal file
71
extensions/brave/web-search-shared.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Shared Brave Search provider metadata and credential lookup. Contract tests
|
||||
* and runtime provider creation both use this lightweight descriptor.
|
||||
*/
|
||||
import {
|
||||
createWebSearchProviderContractFields,
|
||||
type WebSearchProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
/** Canonical config path for the Brave Search API key. */
|
||||
export const BRAVE_CREDENTIAL_PATH = "plugins.entries.brave.config.webSearch.apiKey";
|
||||
|
||||
/** Resolve legacy top-level Brave credentials from old web-search config. */
|
||||
export function resolveLegacyTopLevelBraveCredential(
|
||||
config: unknown,
|
||||
): { path: string; value: unknown } | undefined {
|
||||
if (!isRecord(config)) {
|
||||
return undefined;
|
||||
}
|
||||
const tools = isRecord(config.tools) ? config.tools : undefined;
|
||||
const web = isRecord(tools?.web) ? tools.web : undefined;
|
||||
const search = isRecord(web?.search) ? web.search : undefined;
|
||||
if (!search || !("apiKey" in search)) {
|
||||
return undefined;
|
||||
}
|
||||
return { path: "tools.web.search.apiKey", value: search.apiKey };
|
||||
}
|
||||
|
||||
function resolveBraveWebSearchPluginConfig(config: unknown): Record<string, unknown> | 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?.brave) ? entries.brave : undefined;
|
||||
const pluginConfig = isRecord(entry?.config) ? entry.config : undefined;
|
||||
return isRecord(pluginConfig?.webSearch) ? pluginConfig.webSearch : undefined;
|
||||
}
|
||||
|
||||
/** Resolve Brave credentials from current plugin config or legacy fallback. */
|
||||
export function resolveConfiguredBraveCredential(config: unknown): unknown {
|
||||
return (
|
||||
resolveBraveWebSearchPluginConfig(config)?.apiKey ??
|
||||
resolveLegacyTopLevelBraveCredential(config)?.value
|
||||
);
|
||||
}
|
||||
|
||||
/** Build the common Brave provider metadata without the runtime tool executor. */
|
||||
export function buildBraveWebSearchProviderBase(): Omit<WebSearchProviderPlugin, "createTool"> {
|
||||
return {
|
||||
id: "brave",
|
||||
label: "Brave Search",
|
||||
hint: "Structured results · country/language/time filters",
|
||||
onboardingScopes: ["text-inference"],
|
||||
credentialLabel: "Brave Search API key",
|
||||
envVars: ["BRAVE_API_KEY"],
|
||||
placeholder: "BSA...",
|
||||
signupUrl: "https://brave.com/search/api/",
|
||||
docsUrl: "https://docs.openclaw.ai/tools/brave-search",
|
||||
autoDetectOrder: 10,
|
||||
credentialPath: BRAVE_CREDENTIAL_PATH,
|
||||
...createWebSearchProviderContractFields({
|
||||
credentialPath: BRAVE_CREDENTIAL_PATH,
|
||||
searchCredential: { type: "top-level" },
|
||||
configuredCredential: { pluginId: "brave" },
|
||||
}),
|
||||
getConfiguredCredentialValue: resolveConfiguredBraveCredential,
|
||||
getConfiguredCredentialFallback: resolveLegacyTopLevelBraveCredential,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user