feat(coding-agent/web): added Brave web search provider with recency filtering and API integration

- Added Brave web search provider with support for query, result count, and recency filtering.
- Added BRAVE_API_KEY environment variable configuration for Brave search authentication.
- Updated web search provider priority order to include Brave as second-highest priority after Exa.
- Extended recency filter support to Brave search provider alongside Perplexity.
- Implemented BraveProvider class with API integration, response mapping, and parameter validation.
- Added helper functions for recency mapping, result clamping, and snippet building in Brave provider.
This commit is contained in:
can1357
2026-02-16 18:33:02 +01:00
parent 82d6caf0b6
commit 35dbf43670
14 changed files with 208 additions and 14 deletions
@@ -1,7 +1,7 @@
/**
* Unified Web Search Tool
*
* Single tool supporting Anthropic, Perplexity, Exa, Jina, Gemini, Codex, and Z.AI
* Single tool supporting Anthropic, Perplexity, Exa, Brave, Jina, Gemini, Codex, and Z.AI
* providers with provider-specific parameters exposed conditionally.
*
* When EXA_API_KEY is available, additional specialized tools are exposed:
@@ -33,13 +33,13 @@ import { SearchProviderError } from "./types";
export const webSearchSchema = Type.Object({
query: Type.String({ description: "Search query" }),
provider: Type.Optional(
StringEnum(["auto", "exa", "jina", "zai", "anthropic", "perplexity", "gemini", "codex"], {
StringEnum(["auto", "exa", "brave", "jina", "zai", "anthropic", "perplexity", "gemini", "codex"], {
description: "Search provider (default: auto)",
}),
),
recency: Type.Optional(
StringEnum(["day", "week", "month", "year"], {
description: "Recency filter (Perplexity)",
description: "Recency filter (Brave, Perplexity)",
}),
),
limit: Type.Optional(Type.Number({ description: "Max results to return" })),
@@ -47,7 +47,7 @@ export const webSearchSchema = Type.Object({
export type SearchParams = {
query: string;
provider?: "auto" | "exa" | "jina" | "zai" | "anthropic" | "perplexity" | "gemini" | "codex";
provider?: "auto" | "exa" | "brave" | "jina" | "zai" | "anthropic" | "perplexity" | "gemini" | "codex";
recency?: "day" | "week" | "month" | "year";
limit?: number;
/** Maximum output tokens. Defaults to 4096. */
@@ -236,7 +236,7 @@ export async function runSearchQuery(
/**
* Web search tool implementation.
*
* Supports Anthropic, Perplexity, Exa, Jina, Gemini, Codex, and Z.AI providers with automatic fallback.
* Supports Anthropic, Perplexity, Exa, Brave, Jina, Gemini, Codex, and Z.AI providers with automatic fallback.
* Session is accepted for interface consistency but not used.
*/
export class SearchTool implements AgentTool<typeof webSearchSchema, SearchRenderDetails> {
@@ -1,5 +1,6 @@
import { AnthropicProvider } from "./providers/anthropic";
import type { SearchProvider } from "./providers/base";
import { BraveProvider } from "./providers/brave";
import { CodexProvider } from "./providers/codex";
import { ExaProvider } from "./providers/exa";
import { GeminiProvider } from "./providers/gemini";
@@ -13,6 +14,7 @@ export { SearchProvider } from "./providers/base";
const SEARCH_PROVIDERS: Record<SearchProviderId, SearchProvider> = {
exa: new ExaProvider(),
brave: new BraveProvider(),
jina: new JinaProvider(),
perplexity: new PerplexityProvider(),
zai: new ZaiProvider(),
@@ -21,7 +23,16 @@ const SEARCH_PROVIDERS: Record<SearchProviderId, SearchProvider> = {
codex: new CodexProvider(),
} as const;
const SEARCH_PROVIDER_ORDER: SearchProviderId[] = ["exa", "jina", "perplexity", "anthropic", "gemini", "codex", "zai"];
const SEARCH_PROVIDER_ORDER: SearchProviderId[] = [
"exa",
"brave",
"jina",
"perplexity",
"anthropic",
"gemini",
"codex",
"zai",
];
export function getSearchProvider(provider: SearchProviderId): SearchProvider {
return SEARCH_PROVIDERS[provider];
@@ -35,7 +46,7 @@ export function setPreferredSearchProvider(provider: SearchProviderId | "auto"):
preferredProvId = provider;
}
/** Determine which providers are configured (priority order) */
/** Determine which providers are configured (priority: Exa → Brave → Jina → Perplexity → Anthropic → Gemini → Codex → Z.AI) */
export async function resolveProviderChain(
preferredProvider: SearchProviderId | "auto" = preferredProvId,
): Promise<SearchProvider[]> {
@@ -0,0 +1,165 @@
/**
* Brave Web Search Provider
*
* Calls Brave's web search REST API and maps results into the unified
* SearchResponse shape used by the web search tool.
*/
import { getEnvApiKey } from "@oh-my-pi/pi-ai";
import type { SearchResponse, SearchSource } from "../../../web/search/types";
import { SearchProviderError } from "../../../web/search/types";
import type { SearchParams } from "./base";
import { SearchProvider } from "./base";
const BRAVE_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search";
const DEFAULT_NUM_RESULTS = 10;
const MAX_NUM_RESULTS = 20;
const RECENCY_MAP: Record<"day" | "week" | "month" | "year", "pd" | "pw" | "pm" | "py"> = {
day: "pd",
week: "pw",
month: "pm",
year: "py",
};
export interface BraveSearchParams {
query: string;
num_results?: number;
recency?: "day" | "week" | "month" | "year";
signal?: AbortSignal;
}
interface BraveSearchResult {
title?: string | null;
url?: string | null;
description?: string | null;
age?: string | null;
extra_snippets?: string[] | null;
}
interface BraveSearchResponse {
web?: {
results?: BraveSearchResult[];
};
}
/** Find BRAVE_API_KEY from environment or .env files. */
export function findApiKey(): string | null {
return getEnvApiKey("brave") ?? null;
}
function clampNumResults(value: number | undefined): number {
if (!value || Number.isNaN(value)) return DEFAULT_NUM_RESULTS;
return Math.min(MAX_NUM_RESULTS, Math.max(1, value));
}
function dateToAgeSeconds(dateStr: string | null | undefined): number | undefined {
if (!dateStr) return undefined;
try {
const date = new Date(dateStr);
if (Number.isNaN(date.getTime())) return undefined;
return Math.floor((Date.now() - date.getTime()) / 1000);
} catch {
return undefined;
}
}
function buildSnippet(result: BraveSearchResult): string | undefined {
const snippets: string[] = [];
if (result.description?.trim()) {
snippets.push(result.description.trim());
}
if (Array.isArray(result.extra_snippets)) {
for (const snippet of result.extra_snippets) {
if (!snippet?.trim()) continue;
if (snippets.includes(snippet.trim())) continue;
snippets.push(snippet.trim());
}
}
return snippets.length > 0 ? snippets.join("\n") : undefined;
}
async function callBraveSearch(
apiKey: string,
params: BraveSearchParams,
): Promise<{ response: BraveSearchResponse; requestId?: string }> {
const numResults = clampNumResults(params.num_results);
const url = new URL(BRAVE_SEARCH_URL);
url.searchParams.set("q", params.query);
url.searchParams.set("count", String(numResults));
url.searchParams.set("extra_snippets", "true");
if (params.recency) {
url.searchParams.set("freshness", RECENCY_MAP[params.recency]);
}
const response = await fetch(url, {
headers: {
Accept: "application/json",
"X-Subscription-Token": apiKey,
},
signal: params.signal,
});
if (!response.ok) {
const errorText = await response.text();
throw new SearchProviderError("brave", `Brave API error (${response.status}): ${errorText}`, response.status);
}
const data = (await response.json()) as BraveSearchResponse;
const requestId = response.headers.get("x-request-id") ?? response.headers.get("request-id") ?? undefined;
return { response: data, requestId };
}
/** Execute Brave web search. */
export async function searchBrave(params: BraveSearchParams): Promise<SearchResponse> {
const numResults = clampNumResults(params.num_results);
const apiKey = findApiKey();
if (!apiKey) {
throw new Error("BRAVE_API_KEY not found. Set it in environment or .env file.");
}
const { response, requestId } = await callBraveSearch(apiKey, params);
const sources: SearchSource[] = [];
for (const result of response.web?.results ?? []) {
if (!result.url) continue;
sources.push({
title: result.title ?? result.url,
url: result.url,
snippet: buildSnippet(result),
publishedDate: result.age ?? undefined,
ageSeconds: dateToAgeSeconds(result.age),
});
}
return {
provider: "brave",
sources: sources.slice(0, numResults),
requestId,
};
}
/** Search provider for Brave web search. */
export class BraveProvider extends SearchProvider {
readonly id = "brave";
readonly label = "Brave";
isAvailable() {
try {
return !!findApiKey();
} catch {
return false;
}
}
search(params: SearchParams): Promise<SearchResponse> {
return searchBrave({
query: params.query,
num_results: params.numSearchResults ?? params.limit,
recency: params.recency,
signal: params.signal,
});
}
}
@@ -5,7 +5,7 @@
*/
/** Supported web search providers */
export type SearchProviderId = "exa" | "jina" | "zai" | "anthropic" | "perplexity" | "gemini" | "codex";
export type SearchProviderId = "exa" | "brave" | "jina" | "zai" | "anthropic" | "perplexity" | "gemini" | "codex";
/** Source returned by search (all providers) */
export interface SearchSource {