mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 17:31:06 +00:00
feat(brave): support configurable search base url
This commit is contained in:
@@ -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