This commit is contained in:
Joey Kerper 2026-01-30 01:51:35 -08:00 committed by GitHub
commit 490125f045
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 148 additions and 5 deletions

View File

@ -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 <key>"
// authStyle: "x-subscription-token" // Default: uses "X-Subscription-Token: <key>"
}
}
}
}
```
## 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

View File

@ -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");

View File

@ -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<Record<string, unknown>> {
@ -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;

View File

@ -437,6 +437,10 @@ const FIELD_HELP: Record<string, string> = {
"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.",

View File

@ -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. */

View File

@ -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(),