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:
@@ -213,6 +213,7 @@ export function getExtraHelpText(): string {
|
||||
|
||||
${chalk.dim("# Search & Tools")}
|
||||
EXA_API_KEY - Exa web search
|
||||
BRAVE_API_KEY - Brave web search
|
||||
PERPLEXITY_API_KEY - Perplexity web search
|
||||
ANTHROPIC_SEARCH_API_KEY - Anthropic search provider
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ const PROVIDERS: Array<SearchProviderId | "auto"> = [
|
||||
"anthropic",
|
||||
"perplexity",
|
||||
"exa",
|
||||
"brave",
|
||||
"jina",
|
||||
"zai",
|
||||
"gemini",
|
||||
@@ -131,14 +132,14 @@ ${chalk.bold("Arguments:")}
|
||||
|
||||
${chalk.bold("Options:")}
|
||||
--provider <name> Provider: ${PROVIDERS.join(", ")}
|
||||
--recency <value> Recency filter (Perplexity only): ${RECENCY_OPTIONS.join(", ")}
|
||||
--recency <value> Recency filter (Brave/Perplexity): ${RECENCY_OPTIONS.join(", ")}
|
||||
-l, --limit <n> Max results to return
|
||||
--compact Render condensed output
|
||||
-h, --help Show this help
|
||||
|
||||
${chalk.bold("Examples:")}
|
||||
${APP_NAME} q --provider=exa "what's the color of the sky"
|
||||
${APP_NAME} q --provider=perplexity --recency=week "latest TypeScript 5.7 changes"
|
||||
${APP_NAME} q --provider=brave --recency=week "latest TypeScript 5.7 changes"
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ const PROVIDERS: Array<SearchProviderId | "auto"> = [
|
||||
"anthropic",
|
||||
"perplexity",
|
||||
"exa",
|
||||
"brave",
|
||||
"jina",
|
||||
"zai",
|
||||
"gemini",
|
||||
|
||||
@@ -617,7 +617,7 @@ export const SETTINGS_SCHEMA = {
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
"providers.webSearch": {
|
||||
type: "enum",
|
||||
values: ["auto", "exa", "jina", "zai", "perplexity", "anthropic"] as const,
|
||||
values: ["auto", "exa", "brave", "jina", "zai", "perplexity", "anthropic"] as const,
|
||||
default: "auto",
|
||||
ui: { tab: "services", label: "Web search provider", description: "Provider for web search tool", submenu: true },
|
||||
},
|
||||
|
||||
@@ -149,9 +149,10 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
|
||||
{
|
||||
value: "auto",
|
||||
label: "Auto",
|
||||
description: "Priority: Exa > Jina > Perplexity > Anthropic > Gemini > Codex > Z.AI",
|
||||
description: "Priority: Exa > Brave > Jina > Perplexity > Anthropic > Gemini > Codex > Z.AI",
|
||||
},
|
||||
{ value: "exa", label: "Exa", description: "Requires EXA_API_KEY" },
|
||||
{ value: "brave", label: "Brave", description: "Requires BRAVE_API_KEY" },
|
||||
{ value: "jina", label: "Jina", description: "Requires JINA_API_KEY" },
|
||||
{ value: "perplexity", label: "Perplexity", description: "Requires PERPLEXITY_API_KEY" },
|
||||
{ value: "anthropic", label: "Anthropic", description: "Uses Anthropic web search" },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user