diff --git a/docs/tools/web.md b/docs/tools/web.md index 3e626400a..3ecabf75c 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -86,6 +86,42 @@ current limits and pricing. environment. For a gateway install, put it in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables). +### Custom Brave Search endpoint + +If you're running a self-hosted Brave Search instance or using a proxy, set `tools.web.search.baseUrl` to your custom endpoint: + +```json5 +{ + tools: { + web: { + search: { + baseUrl: "https://your-brave-instance.example.com" + } + } + } +} +``` + +The path `/res/v1/web/search` will be automatically appended to the base URL. + +### Auth header style + +Brave's official API uses `X-Subscription-Token` for authentication. Some proxies expect `Authorization: Bearer` instead. Set `authStyle` to switch: + +```json5 +{ + tools: { + web: { + search: { + baseUrl: "https://your-proxy.example.com", + authStyle: "bearer" // Uses "Authorization: Bearer " + // authStyle: "x-subscription-token" // Default: uses "X-Subscription-Token: " + } + } + } +} +``` + ## Using Perplexity (direct or via OpenRouter) Perplexity Sonar models have built-in web search capabilities and return AI-synthesized @@ -158,6 +194,8 @@ Search the web using your configured provider. search: { enabled: true, apiKey: "BRAVE_API_KEY_HERE", // optional if BRAVE_API_KEY is set + baseUrl: "https://api.search.brave.com", // optional: custom Brave Search base URL + authStyle: "x-subscription-token", // optional: "x-subscription-token" (default) or "bearer" (for proxies) maxResults: 5, timeoutSeconds: 30, cacheTtlMinutes: 15 diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index c6d2b6405..d908a4cb9 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -2,8 +2,13 @@ import { describe, expect, it } from "vitest"; import { __testing } from "./web-search.js"; -const { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl, normalizeFreshness } = - __testing; +const { + inferPerplexityBaseUrlFromApiKey, + resolvePerplexityBaseUrl, + resolveBraveBaseUrl, + resolveBraveAuthStyle, + normalizeFreshness, +} = __testing; describe("web_search perplexity baseUrl defaults", () => { it("detects a Perplexity key prefix", () => { @@ -53,6 +58,65 @@ describe("web_search perplexity baseUrl defaults", () => { }); }); +describe("web_search brave baseUrl defaults", () => { + it("returns default Brave URL when no config is provided", () => { + expect(resolveBraveBaseUrl(undefined)).toBe("https://api.search.brave.com"); + }); + + it("returns default Brave URL when config is empty", () => { + expect(resolveBraveBaseUrl({})).toBe("https://api.search.brave.com"); + }); + + it("uses custom baseUrl from config", () => { + expect(resolveBraveBaseUrl({ baseUrl: "https://custom-brave.example.com" })).toBe( + "https://custom-brave.example.com", + ); + }); + + it("trims whitespace from custom baseUrl", () => { + expect(resolveBraveBaseUrl({ baseUrl: " https://custom-brave.example.com " })).toBe( + "https://custom-brave.example.com", + ); + }); + + it("returns default when baseUrl is empty string", () => { + expect(resolveBraveBaseUrl({ baseUrl: "" })).toBe("https://api.search.brave.com"); + }); + + it("returns default when baseUrl is only whitespace", () => { + expect(resolveBraveBaseUrl({ baseUrl: " " })).toBe("https://api.search.brave.com"); + }); +}); + +describe("web_search brave authStyle defaults", () => { + it("returns x-subscription-token when no config is provided", () => { + expect(resolveBraveAuthStyle(undefined)).toBe("x-subscription-token"); + }); + + it("returns x-subscription-token when config is empty", () => { + expect(resolveBraveAuthStyle({})).toBe("x-subscription-token"); + }); + + it("returns bearer when authStyle is bearer", () => { + expect(resolveBraveAuthStyle({ authStyle: "bearer" })).toBe("bearer"); + }); + + it("returns x-subscription-token when authStyle is x-subscription-token", () => { + expect(resolveBraveAuthStyle({ authStyle: "x-subscription-token" })).toBe( + "x-subscription-token", + ); + }); + + it("is case-insensitive", () => { + expect(resolveBraveAuthStyle({ authStyle: "BEARER" } as never)).toBe("bearer"); + expect(resolveBraveAuthStyle({ authStyle: "Bearer" } as never)).toBe("bearer"); + }); + + it("returns default for unknown values", () => { + expect(resolveBraveAuthStyle({ authStyle: "unknown" } as never)).toBe("x-subscription-token"); + }); +}); + describe("web_search freshness normalization", () => { it("accepts Brave shortcut values", () => { expect(normalizeFreshness("pd")).toBe("pd"); diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index bf5741490..793d681cc 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -18,10 +18,12 @@ import { } from "./web-shared.js"; const SEARCH_PROVIDERS = ["brave", "perplexity"] as const; +const BRAVE_AUTH_STYLES = ["x-subscription-token", "bearer"] as const; const DEFAULT_SEARCH_COUNT = 5; const MAX_SEARCH_COUNT = 10; -const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"; +const DEFAULT_BRAVE_BASE_URL = "https://api.search.brave.com"; +const BRAVE_SEARCH_PATH = "/res/v1/web/search"; const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1"; const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai"; const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro"; @@ -122,6 +124,23 @@ function resolveSearchApiKey(search?: WebSearchConfig): string | undefined { return fromConfig || fromEnv || undefined; } +function resolveBraveBaseUrl(search?: WebSearchConfig): string { + const fromConfig = + search && "baseUrl" in search && typeof search.baseUrl === "string" + ? search.baseUrl.trim() + : ""; + return fromConfig || DEFAULT_BRAVE_BASE_URL; +} + +function resolveBraveAuthStyle(search?: WebSearchConfig): (typeof BRAVE_AUTH_STYLES)[number] { + const raw = + search && "authStyle" in search && typeof search.authStyle === "string" + ? search.authStyle.trim().toLowerCase() + : ""; + if (raw === "bearer") return "bearer"; + return "x-subscription-token"; +} + function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) { if (provider === "perplexity") { return { @@ -317,6 +336,8 @@ async function runWebSearch(params: { search_lang?: string; ui_lang?: string; freshness?: string; + braveBaseUrl?: string; + braveAuthStyle?: (typeof BRAVE_AUTH_STYLES)[number]; perplexityBaseUrl?: string; perplexityModel?: string; }): Promise> { @@ -355,7 +376,8 @@ async function runWebSearch(params: { throw new Error("Unsupported web search provider."); } - const url = new URL(BRAVE_SEARCH_ENDPOINT); + const baseUrl = params.braveBaseUrl ?? DEFAULT_BRAVE_BASE_URL; + const url = new URL(BRAVE_SEARCH_PATH, baseUrl); url.searchParams.set("q", params.query); url.searchParams.set("count", String(params.count)); if (params.country) { @@ -371,11 +393,16 @@ async function runWebSearch(params: { url.searchParams.set("freshness", params.freshness); } + // Build auth header based on configured style (default: X-Subscription-Token for Brave) + const authStyle = params.braveAuthStyle ?? "x-subscription-token"; + const res = await fetch(url.toString(), { method: "GET", headers: { Accept: "application/json", - "X-Subscription-Token": params.apiKey, + ...(authStyle === "bearer" + ? { Authorization: `Bearer ${params.apiKey}` } + : { "X-Subscription-Token": params.apiKey }), }, signal: withTimeout(undefined, params.timeoutSeconds * 1000), }); @@ -470,6 +497,8 @@ export function createWebSearchTool(options?: { search_lang, ui_lang, freshness, + braveBaseUrl: resolveBraveBaseUrl(search), + braveAuthStyle: resolveBraveAuthStyle(search), perplexityBaseUrl: resolvePerplexityBaseUrl( perplexityConfig, perplexityAuth?.source, @@ -485,5 +514,7 @@ export function createWebSearchTool(options?: { export const __testing = { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl, + resolveBraveBaseUrl, + resolveBraveAuthStyle, normalizeFreshness, } as const; diff --git a/src/config/schema.ts b/src/config/schema.ts index 1401b0574..551be95b3 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -437,6 +437,10 @@ const FIELD_HELP: Record = { "tools.web.search.enabled": "Enable the web_search tool (requires a provider API key).", "tools.web.search.provider": 'Search provider ("brave" or "perplexity").', "tools.web.search.apiKey": "Brave Search API key (fallback: BRAVE_API_KEY env var).", + "tools.web.search.baseUrl": + "Brave Search base URL override (default: https://api.search.brave.com).", + "tools.web.search.authStyle": + 'Auth header style: "x-subscription-token" (Brave default) or "bearer" (for select proxies).', "tools.web.search.maxResults": "Default number of results to return (1-10).", "tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.", "tools.web.search.cacheTtlMinutes": "Cache TTL in minutes for web_search results.", diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index db32cb59d..556ac70e0 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -340,6 +340,10 @@ export type ToolsConfig = { provider?: "brave" | "perplexity"; /** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */ apiKey?: string; + /** Brave Search base URL (optional; defaults to https://api.search.brave.com). */ + baseUrl?: string; + /** Auth header style: "x-subscription-token" (Brave default) or "bearer" (for proxies). */ + authStyle?: "x-subscription-token" | "bearer"; /** Default search results count (1-10). */ maxResults?: number; /** Timeout in seconds for search requests. */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 7e95c3538..b39f5e2fc 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -167,6 +167,8 @@ export const ToolsWebSearchSchema = z enabled: z.boolean().optional(), provider: z.union([z.literal("brave"), z.literal("perplexity")]).optional(), apiKey: z.string().optional(), + baseUrl: z.string().optional(), + authStyle: z.union([z.literal("x-subscription-token"), z.literal("bearer")]).optional(), maxResults: z.number().int().positive().optional(), timeoutSeconds: z.number().int().positive().optional(), cacheTtlMinutes: z.number().nonnegative().optional(),