mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:10:44 +00:00
feat(brave): support configurable search base url
This commit is contained in:
@@ -40,6 +40,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Cron: keep implicit/default isolated cron announce deliveries out of the main session awareness queue, so isolated jobs do not accumulate in the main conversation. Fixes #61426. Thanks @Lihannon.
|
||||
- Subagents: avoid duplicate parent-visible replies when a parent uses `sessions_send` on its own persistent native subagent session, while preserving announce delivery for async sends. Fixes #73550. Thanks @sylviazhang2006-design.
|
||||
- Web search/Brave: add opt-in `brave.http` diagnostics for Brave request URLs/query params, response status/timing, and cache hit/miss/write events without logging API keys or response bodies. Fixes #55196. Thanks @mecampbellsoup.
|
||||
- Web search/Brave: add `plugins.entries.brave.config.webSearch.baseUrl` for Brave-compatible proxies, including endpoint-aware cache keys for both web and LLM Context modes. Fixes #19075. Thanks @jkoprax and @vishnukool.
|
||||
- Web search/config: validate explicit `tools.web.search.provider` values against bundled and installed plugin manifests, while warning for stale third-party plugin config. Fixes #53092. Thanks @TinyTb.
|
||||
- Web search/SearXNG: retry empty non-general category searches once with the general category, so unsupported category engines do not return empty results when general search has matches. Fixes #73552. Thanks @Loukky.
|
||||
- Agents/sandbox: preserve existing workspace file modes when sandbox edits atomically replace files, so 0644 files do not collapse to 0600 after Write/Edit/apply_patch. Fixes #44077. Thanks @patosullivan.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
3545cf963a093d50b69904f859544088212e6522905b72710eb7818caa154b89 config-baseline.json
|
||||
737056ad5544e24250ce91c000ae4a5fe0af751681a529f2e4710b383ef5d4e7 config-baseline.json
|
||||
2d132b4c2e3b0e0f2524fc1cc889d3be658ad0e40c970b2d367bf27348883658 config-baseline.core.json
|
||||
f42329d45c095881bd226bdb192c235980658fd250606d0c0badc2b12f12f5d3 config-baseline.channel.json
|
||||
38b16427911ba4ff19240097e5002fb892178fb3cefdc9c50fd98ad2044c02bf config-baseline.plugin.json
|
||||
726c2fb81319f05be6977cdf5c9598884feafc600e6c76d482be626f4983bc32 config-baseline.plugin.json
|
||||
|
||||
@@ -27,6 +27,7 @@ OpenClaw supports Brave Search API as a `web_search` provider.
|
||||
webSearch: {
|
||||
apiKey: "BRAVE_API_KEY_HERE",
|
||||
mode: "web", // or "llm-context"
|
||||
baseUrl: "https://api.search.brave.com", // optional proxy/base URL override
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -52,6 +53,12 @@ Legacy `tools.web.search.apiKey` still loads through the compatibility shim, but
|
||||
- `web` (default): normal Brave web search with titles, URLs, and snippets
|
||||
- `llm-context`: Brave LLM Context API with pre-extracted text chunks and sources for grounding
|
||||
|
||||
`webSearch.baseUrl` can point Brave requests at a trusted Brave-compatible proxy
|
||||
or gateway. OpenClaw appends `/res/v1/web/search` or `/res/v1/llm/context` to
|
||||
the configured base URL and keeps the base URL in the cache key. Public
|
||||
endpoints must use `https://`; `http://` is accepted only for trusted loopback
|
||||
or private-network proxy hosts.
|
||||
|
||||
## Tool parameters
|
||||
|
||||
<ParamField path="query" type="string" required>
|
||||
@@ -123,6 +130,8 @@ await web_search({
|
||||
- `llm-context` mode supports `freshness` and bounded `date_after` + `date_before` ranges. It does not support `ui_lang`; `date_before` without `date_after` is rejected because Brave requires custom freshness ranges to include both start and end dates.
|
||||
- `ui_lang` must include a region subtag like `en-US`.
|
||||
- Results are cached for 15 minutes by default (configurable via `cacheTtlMinutes`).
|
||||
- Custom `webSearch.baseUrl` values are included in Brave cache identity, so
|
||||
proxy-specific responses do not collide.
|
||||
- Enable the `brave.http` diagnostics flag to log Brave request URLs/query params, response status/timing, and search-cache hit/miss/write events while troubleshooting. The flag never logs the API key or response bodies, but search queries can be sensitive.
|
||||
|
||||
## Related
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
"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": {
|
||||
@@ -38,6 +42,9 @@
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["web", "llm-context"]
|
||||
},
|
||||
"baseUrl": {
|
||||
"type": ["string", "object"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,18 @@ import {
|
||||
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,
|
||||
@@ -28,9 +35,11 @@ import {
|
||||
resolveBraveMode,
|
||||
} from "./brave-web-search-provider.shared.js";
|
||||
|
||||
const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
|
||||
const BRAVE_LLM_CONTEXT_ENDPOINT = "https://api.search.brave.com/res/v1/llm/context";
|
||||
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 BraveSearchResult = {
|
||||
title?: string;
|
||||
@@ -79,6 +88,63 @@ function resolveBraveApiKey(searchConfig?: SearchConfigRecord): string | undefin
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -88,6 +154,8 @@ function missingBraveKeyPayload() {
|
||||
}
|
||||
|
||||
async function runBraveLlmContextSearch(params: {
|
||||
baseUrl: string;
|
||||
endpointMode: BraveEndpointMode;
|
||||
query: string;
|
||||
apiKey: string;
|
||||
timeoutSeconds: number;
|
||||
@@ -106,7 +174,10 @@ async function runBraveLlmContextSearch(params: {
|
||||
}>;
|
||||
sources?: BraveLlmContextResponse["sources"];
|
||||
}> {
|
||||
const url = new URL(BRAVE_LLM_CONTEXT_ENDPOINT);
|
||||
const url = buildBraveEndpointUrl({
|
||||
baseUrl: params.baseUrl,
|
||||
endpointPath: BRAVE_LLM_CONTEXT_ENDPOINT_PATH,
|
||||
});
|
||||
url.searchParams.set("q", params.query);
|
||||
if (params.country) {
|
||||
url.searchParams.set("country", params.country);
|
||||
@@ -130,7 +201,11 @@ async function runBraveLlmContextSearch(params: {
|
||||
...describeBraveRequestUrl(url),
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
return withTrustedWebSearchEndpoint(
|
||||
const withEndpoint =
|
||||
params.endpointMode === "selfHosted"
|
||||
? withSelfHostedWebSearchEndpoint
|
||||
: withTrustedWebSearchEndpoint;
|
||||
return withEndpoint(
|
||||
{
|
||||
url: url.toString(),
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
@@ -163,6 +238,8 @@ async function runBraveLlmContextSearch(params: {
|
||||
}
|
||||
|
||||
async function runBraveWebSearch(params: {
|
||||
baseUrl: string;
|
||||
endpointMode: BraveEndpointMode;
|
||||
query: string;
|
||||
count: number;
|
||||
apiKey: string;
|
||||
@@ -175,7 +252,10 @@ async function runBraveWebSearch(params: {
|
||||
dateAfter?: string;
|
||||
dateBefore?: string;
|
||||
}): Promise<Array<Record<string, unknown>>> {
|
||||
const url = new URL(BRAVE_SEARCH_ENDPOINT);
|
||||
const url = buildBraveEndpointUrl({
|
||||
baseUrl: params.baseUrl,
|
||||
endpointPath: BRAVE_SEARCH_ENDPOINT_PATH,
|
||||
});
|
||||
url.searchParams.set("q", params.query);
|
||||
url.searchParams.set("count", String(params.count));
|
||||
if (params.country) {
|
||||
@@ -205,7 +285,11 @@ async function runBraveWebSearch(params: {
|
||||
...describeBraveRequestUrl(url),
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
return withTrustedWebSearchEndpoint(
|
||||
const withEndpoint =
|
||||
params.endpointMode === "selfHosted"
|
||||
? withSelfHostedWebSearchEndpoint
|
||||
: withTrustedWebSearchEndpoint;
|
||||
return withEndpoint(
|
||||
{
|
||||
url: url.toString(),
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
@@ -263,6 +347,8 @@ export async function executeBraveSearch(
|
||||
|
||||
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 =
|
||||
readNumberParam(args, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined;
|
||||
@@ -358,6 +444,7 @@ export async function executeBraveSearch(
|
||||
? [
|
||||
"brave",
|
||||
braveMode,
|
||||
braveBaseUrl,
|
||||
query,
|
||||
country,
|
||||
normalizedLanguage.search_lang,
|
||||
@@ -368,6 +455,7 @@ export async function executeBraveSearch(
|
||||
: [
|
||||
"brave",
|
||||
braveMode,
|
||||
braveBaseUrl,
|
||||
query,
|
||||
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
country,
|
||||
@@ -392,6 +480,8 @@ export async function executeBraveSearch(
|
||||
|
||||
if (braveMode === "llm-context") {
|
||||
const { results, sources } = await runBraveLlmContextSearch({
|
||||
baseUrl: braveBaseUrl,
|
||||
endpointMode: braveEndpointMode,
|
||||
query,
|
||||
apiKey,
|
||||
timeoutSeconds,
|
||||
@@ -434,6 +524,8 @@ export async function executeBraveSearch(
|
||||
}
|
||||
|
||||
const results = await runBraveWebSearch({
|
||||
baseUrl: braveBaseUrl,
|
||||
endpointMode: braveEndpointMode,
|
||||
query,
|
||||
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
apiKey,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
type BraveConfig = {
|
||||
baseUrl?: unknown;
|
||||
mode?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -168,6 +168,127 @@ describe("brave web search provider", () => {
|
||||
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 {
|
||||
ok: true,
|
||||
json: async () => ({ web: { results: [] } }),
|
||||
} as Response;
|
||||
});
|
||||
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 = new URL(String(mockFetch.mock.calls[0]?.[0]));
|
||||
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 = new URL(String(mockFetch.mock.calls[0]?.[0]));
|
||||
expect(requestUrl.pathname).toBe("/proxy/res/v1/llm/context");
|
||||
});
|
||||
|
||||
it("keeps Brave cache entries isolated by baseUrl", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ web: { results: [] } }),
|
||||
} as Response;
|
||||
});
|
||||
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(new URL(String(mockFetch.mock.calls[0]?.[0])).pathname).toBe(
|
||||
"/proxy-one/res/v1/web/search",
|
||||
);
|
||||
expect(new URL(String(mockFetch.mock.calls[1]?.[0])).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");
|
||||
|
||||
Reference in New Issue
Block a user