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:
@@ -717,7 +717,11 @@ fn sanitize_braces(pattern: &str) -> Cow<'_, str> {
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
|
||||
if modified { Cow::Owned(result) } else { Cow::Borrowed(pattern) }
|
||||
if modified {
|
||||
Cow::Owned(result)
|
||||
} else {
|
||||
Cow::Borrowed(pattern)
|
||||
}
|
||||
}
|
||||
fn build_matcher(
|
||||
pattern: &str,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
|
||||
- Added support for Brave search provider with BRAVE_API_KEY environment variable
|
||||
|
||||
## [12.7.0] - 2026-02-16
|
||||
### Added
|
||||
|
||||
@@ -68,6 +68,7 @@ const serviceProviderMap: Record<string, KeyResolver> = {
|
||||
cursor: "CURSOR_ACCESS_TOKEN",
|
||||
"azure-openai-responses": "AZURE_OPENAI_API_KEY",
|
||||
exa: "EXA_API_KEY",
|
||||
brave: "BRAVE_API_KEY",
|
||||
perplexity: "PERPLEXITY_API_KEY",
|
||||
// GitHub Copilot uses GitHub personal access token
|
||||
"github-copilot": () => $env.COPILOT_GITHUB_TOKEN || $env.GH_TOKEN || $env.GITHUB_TOKEN,
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added Brave web search provider as an alternative search option with recency filtering support
|
||||
- Added `BRAVE_API_KEY` environment variable support for Brave web search authentication
|
||||
- Added pagination support for fetching GitHub issue comments, allowing retrieval of all comments beyond the initial 50-comment limit
|
||||
- Added comment count display showing partial results when not all comments could be fetched (e.g., '5 of 10 comments')
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated web search provider priority order to include Brave (Exa → Brave → Jina → Perplexity → Anthropic → Gemini → Codex → Z.AI)
|
||||
- Extended recency filter support to Brave provider alongside Perplexity
|
||||
- Changed GitHub issue comment fetching to use paginated API requests with 100 comments per page instead of single request with 50-comment limit
|
||||
|
||||
## [12.7.5] - 2026-02-16
|
||||
|
||||
@@ -146,6 +146,7 @@ OAuth host chain: `KIMI_CODE_OAUTH_HOST` → `KIMI_OAUTH_HOST` → `https://auth
|
||||
| Variable | Used by |
|
||||
|---|---|
|
||||
| `EXA_API_KEY` | Exa search provider and Exa MCP tools |
|
||||
| `BRAVE_API_KEY` | Brave search provider |
|
||||
| `PERPLEXITY_API_KEY` | Perplexity search provider API-key mode |
|
||||
| `ZAI_API_KEY` | z.ai search provider (also checks stored OAuth in `agent.db`) |
|
||||
| `OPENAI_API_KEY` / Codex OAuth in DB | Codex search provider availability/auth |
|
||||
@@ -303,6 +304,6 @@ Treat these as secrets; do not log or commit them:
|
||||
|
||||
- Provider/API keys and OAuth/bearer credentials (all `*_API_KEY`, `*_TOKEN`, OAuth access/refresh tokens)
|
||||
- Cloud credentials (`AWS_*`, `GOOGLE_APPLICATION_CREDENTIALS` path may expose service-account material)
|
||||
- Search/provider auth vars (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, Anthropic search keys)
|
||||
- Search/provider auth vars (`EXA_API_KEY`, `BRAVE_API_KEY`, `PERPLEXITY_API_KEY`, Anthropic search keys)
|
||||
|
||||
Python runtime also explicitly strips many common key vars before spawning kernel subprocesses (`packages/coding-agent/src/ipy/runtime.ts`).
|
||||
|
||||
@@ -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