Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/components/settings/sections/web-search-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
43 changes: 42 additions & 1 deletion src/lib/web-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
Expand Down Expand Up @@ -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<string, string>)["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")
})
})
81 changes: 79 additions & 2 deletions src/lib/web-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand All @@ -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:
Expand Down Expand Up @@ -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<WebSearchResult[]> {
// 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),
}
})
}
9 changes: 8 additions & 1 deletion src/stores/wiki-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading