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 15:07:07 +00:00
parent 82d6caf0b6
commit 35dbf43670
14 changed files with 208 additions and 14 deletions
+5 -1
View File
@@ -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,
+3
View File
@@ -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
+1
View File
@@ -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,
+5
View File
@@ -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`).
+1
View File
@@ -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 {