diff --git a/src/components/settings/sections/web-search-section.tsx b/src/components/settings/sections/web-search-section.tsx index 6ef8e50f4..324df925e 100644 --- a/src/components/settings/sections/web-search-section.tsx +++ b/src/components/settings/sections/web-search-section.tsx @@ -54,6 +54,13 @@ const SEARCH_PROVIDERS = [ hint: "Anonymous Firecrawl Search API", configKind: "none", }, + { + id: "brave", + label: "Brave Search", + hint: "Independent index with privacy focus (api.search.brave.com)", + keyPlaceholder: "Enter your Brave Search API subscription token", + needsApiKey: true, + }, ] as const export function WebSearchSection() { diff --git a/src/lib/web-search.test.ts b/src/lib/web-search.test.ts index 7b111012f..27aae6cf6 100644 --- a/src/lib/web-search.test.ts +++ b/src/lib/web-search.test.ts @@ -315,7 +315,7 @@ describe("webSearch", () => { await expect(webSearch("x", { provider: "none", apiKey: "" }, 5)) .rejects.toThrow("Select a search provider") await expect(webSearch("x", { provider: "serpapi", apiKey: "" }, 5)) - .rejects.toThrow("Tavily or SerpApi API key") + .rejects.toThrow(/API key/) await expect(webSearch("x", { provider: "searxng", apiKey: "" }, 5)) .rejects.toThrow("SearXNG instance URL") }) @@ -457,4 +457,45 @@ describe("webSearch", () => { 5, )).rejects.toThrow("Check your Ollama API key") }) + + it("calls Brave Search API with X-Subscription-Token and normalizes results", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ + web: { + results: [ + { title: "Brave A", url: "https://www.example.com/a", description: "First result" }, + { title: "Brave B", url: "https://docs.example/b", description: "Second result" }, + ], + }, + })) + + const out = await webSearch("brave query", { provider: "brave", apiKey: "brave-key" }, 2) + const [url, init] = fetchMock.mock.calls[0] + const parsed = new URL(String(url)) + + expect(parsed.origin + parsed.pathname).toBe("https://api.search.brave.com/res/v1/web/search") + expect(parsed.searchParams.get("q")).toBe("brave query") + expect(parsed.searchParams.get("count")).toBe("2") + expect(init).toEqual(expect.objectContaining({ method: "GET" })) + expect((init?.headers as Record)["X-Subscription-Token"]).toBe("brave-key") + expect(out).toEqual([ + { title: "Brave A", url: "https://www.example.com/a", snippet: "First result", source: "example.com" }, + { title: "Brave B", url: "https://docs.example/b", snippet: "Second result", source: "docs.example" }, + ]) + }) + + it("clamps Brave Search count to the API's 20-result ceiling", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ web: { results: [] } })) + + await webSearch("anything", { provider: "brave", apiKey: "k" }, 50) + const [url] = fetchMock.mock.calls[0] + expect(new URL(String(url)).searchParams.get("count")).toBe("20") + }) + + it("surfaces Brave Search authentication guidance for 401 responses", async () => { + fetchMock.mockResolvedValueOnce(new Response("forbidden", { status: 401 })) + + await expect( + webSearch("x", { provider: "brave", apiKey: "bad" }, 5), + ).rejects.toThrow("Brave Search API authentication failed") + }) }) diff --git a/src/lib/web-search.ts b/src/lib/web-search.ts index 53c41357f..40fd33871 100644 --- a/src/lib/web-search.ts +++ b/src/lib/web-search.ts @@ -140,8 +140,11 @@ export async function webSearch( if (resolved.provider === "none") { throw new Error("Web search not configured. Select a search provider in Settings.") } - if ((resolved.provider === "tavily" || resolved.provider === "serpapi") && !resolved.apiKey) { - throw new Error("Web search not configured. Add a Tavily or SerpApi API key in Settings, or select a key-free provider such as Firecrawl or SearXNG.") + if ( + (resolved.provider === "tavily" || resolved.provider === "serpapi" || resolved.provider === "brave") && + !resolved.apiKey + ) { + throw new Error("Web search not configured. Add a Tavily, SerpApi, or Brave Search API key in Settings, or select a key-free provider such as Firecrawl or SearXNG.") } if (resolved.provider === "searxng" && !resolved.searXngUrl?.trim()) { throw new Error("Web search not configured. Add a SearXNG instance URL in Settings.") @@ -159,6 +162,8 @@ export async function webSearch( return searXngSearch(query, resolved.searXngUrl ?? "", maxResults, resolved.searXngCategories ?? ["general"]) case "ollama": return ollamaSearch(query, resolved.apiKey ?? "", maxResults) + case "brave": + return braveSearch(query, resolved.apiKey, maxResults) case "firecrawl": return firecrawlSearch(query, maxResults) default: @@ -557,3 +562,75 @@ async function ollamaSearch( } }) } + +interface BraveSearchResponse { + web?: { + results?: Array<{ + title?: string + url?: string + description?: string + }> + } + message?: string +} + +async function braveSearch( + query: string, + apiKey: string, + maxResults: number, +): Promise { + // Brave Web Search API caps `count` at 20 per request. Higher values + // are silently clamped server-side; bound here so the URL stays + // honest and we don't surprise users by appearing to ask for more. + const count = Math.max(1, Math.min(maxResults, 20)) + const params = new URLSearchParams({ q: query, count: String(count) }) + + const httpFetch = await getHttpFetch() + let response: Response + try { + response = await httpFetch( + `https://api.search.brave.com/res/v1/web/search?${params.toString()}`, + { + method: "GET", + headers: { + Accept: "application/json", + "X-Subscription-Token": apiKey, + }, + }, + ) + } catch (err) { + if (isFetchNetworkError(err)) { + throw new Error( + "Network error reaching api.search.brave.com. Check your connectivity and whether the Brave Search API key is still valid.", + ) + } + throw err + } + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + throw new Error( + "Brave Search API authentication failed. Check your subscription token in Settings.", + ) + } + const errorText = await response.text().catch(() => "Unknown error") + throw new Error(`Brave search failed (${response.status}): ${errorText}`) + } + + const data = (await response.json()) as BraveSearchResponse + if (data.message && !data.web) { + throw new Error(`Brave search error: ${data.message}`) + } + + return (data.web?.results ?? []) + .slice(0, maxResults) + .map((r) => { + const url = r.url ?? "" + return { + title: r.title ?? "Untitled", + url, + snippet: r.description ?? "", + source: hostnameFromUrl(url), + } + }) +} diff --git a/src/stores/wiki-store.ts b/src/stores/wiki-store.ts index a381495d1..771162b36 100644 --- a/src/stores/wiki-store.ts +++ b/src/stores/wiki-store.ts @@ -38,7 +38,14 @@ interface LlmConfig { codexCliTimeoutMinutes?: number } -export type SearchProvider = "tavily" | "serpapi" | "searxng" | "ollama" | "firecrawl" | "none" +export type SearchProvider = + | "tavily" + | "serpapi" + | "searxng" + | "ollama" + | "brave" + | "firecrawl" + | "none" export type DeepResearchSource = "web" | "anytxt" | "both" export type SerpApiEngine = | "google"