Add brave as alternative WebSearchTool

This commit is contained in:
Eric-Guo
2026-04-12 10:34:08 +08:00
parent 8399d9ed20
commit 711440474c
9 changed files with 777 additions and 53 deletions

View File

@@ -0,0 +1,70 @@
import { afterEach, describe, expect, mock, test } from 'bun:test'
let isFirstPartyBaseUrl = true
mock.module('../adapters/apiAdapter.js', () => ({
ApiSearchAdapter: class ApiSearchAdapter {},
}))
mock.module('../adapters/bingAdapter.js', () => ({
BingSearchAdapter: class BingSearchAdapter {},
}))
mock.module('../adapters/braveAdapter.js', () => ({
BraveSearchAdapter: class BraveSearchAdapter {},
}))
mock.module('../../../utils/model/providers.js', () => ({
isFirstPartyAnthropicBaseUrl: () => isFirstPartyBaseUrl,
}))
const { createAdapter } = await import('../adapters/index')
const originalWebSearchAdapter = process.env.WEB_SEARCH_ADAPTER
afterEach(() => {
isFirstPartyBaseUrl = true
if (originalWebSearchAdapter === undefined) {
delete process.env.WEB_SEARCH_ADAPTER
} else {
process.env.WEB_SEARCH_ADAPTER = originalWebSearchAdapter
}
})
describe('createAdapter', () => {
test('reuses the same instance when the selected backend does not change', () => {
process.env.WEB_SEARCH_ADAPTER = 'brave'
const firstAdapter = createAdapter()
const secondAdapter = createAdapter()
expect(firstAdapter).toBe(secondAdapter)
expect(firstAdapter.constructor.name).toBe('BraveSearchAdapter')
})
test('rebuilds the adapter when WEB_SEARCH_ADAPTER changes', () => {
process.env.WEB_SEARCH_ADAPTER = 'brave'
const braveAdapter = createAdapter()
process.env.WEB_SEARCH_ADAPTER = 'bing'
const bingAdapter = createAdapter()
expect(bingAdapter).not.toBe(braveAdapter)
expect(bingAdapter.constructor.name).toBe('BingSearchAdapter')
})
test('selects the API adapter for first-party Anthropic URLs', () => {
delete process.env.WEB_SEARCH_ADAPTER
isFirstPartyBaseUrl = true
expect(createAdapter().constructor.name).toBe('ApiSearchAdapter')
})
test('selects the Bing adapter for third-party Anthropic base URLs', () => {
delete process.env.WEB_SEARCH_ADAPTER
isFirstPartyBaseUrl = false
expect(createAdapter().constructor.name).toBe('BingSearchAdapter')
})
})

View File

@@ -0,0 +1,106 @@
import { describe, expect, test } from 'bun:test'
import { extractBraveResults } from '../adapters/braveAdapter'
describe('extractBraveResults', () => {
test('extracts generic grounding results', () => {
const results = extractBraveResults({
grounding: {
generic: [
{
title: 'Example Title 1',
url: 'https://example.com/page1',
snippets: ['First result description'],
},
{
title: 'Example Title 2',
url: 'https://example.com/page2',
snippets: ['Second result description'],
},
],
},
})
expect(results).toEqual([
{
title: 'Example Title 1',
url: 'https://example.com/page1',
snippet: 'First result description',
},
{
title: 'Example Title 2',
url: 'https://example.com/page2',
snippet: 'Second result description',
},
])
})
test('combines generic, poi, and map grounding results', () => {
const results = extractBraveResults({
grounding: {
generic: [{ title: 'Generic', url: 'https://example.com/generic' }],
poi: { title: 'POI', url: 'https://maps.example.com/poi' },
map: [{ title: 'Map', url: 'https://maps.example.com/map' }],
},
})
expect(results).toEqual([
{ title: 'Generic', url: 'https://example.com/generic', snippet: undefined },
{ title: 'POI', url: 'https://maps.example.com/poi', snippet: undefined },
{ title: 'Map', url: 'https://maps.example.com/map', snippet: undefined },
])
})
test('joins multiple snippets into one summary string', () => {
const results = extractBraveResults({
grounding: {
generic: [
{
title: 'Joined Snippets',
url: 'https://example.com/joined',
snippets: ['First snippet.', 'Second snippet.'],
},
],
},
})
expect(results[0].snippet).toBe('First snippet. Second snippet.')
})
test('skips entries without a title or URL', () => {
const results = extractBraveResults({
grounding: {
generic: [
{ title: 'Missing URL' },
{ url: 'https://example.com/missing-title' },
{ title: 'Valid', url: 'https://example.com/valid' },
],
},
})
expect(results).toEqual([
{ title: 'Valid', url: 'https://example.com/valid', snippet: undefined },
])
})
test('deduplicates repeated URLs across grounding buckets', () => {
const results = extractBraveResults({
grounding: {
generic: [{ title: 'First', url: 'https://example.com/dup' }],
poi: { title: 'Second', url: 'https://example.com/dup' },
map: [{ title: 'Third', url: 'https://example.com/dup' }],
},
})
expect(results).toEqual([
{ title: 'First', url: 'https://example.com/dup', snippet: undefined },
])
})
test('returns empty array when grounding is missing', () => {
expect(extractBraveResults({})).toEqual([])
})
test('returns empty array when grounding arrays are absent', () => {
expect(extractBraveResults({ grounding: {} })).toEqual([])
})
})

View File

@@ -0,0 +1,91 @@
/**
* Integration test for BraveSearchAdapter — hits Brave's LLM context API.
*
* Usage:
* BRAVE_SEARCH_API_KEY=... bun run src/tools/WebSearchTool/__tests__/braveAdapter.integration.ts
*
* Optional env vars:
* BRAVE_QUERY — search query (default: "Claude AI Anthropic")
* BRAVE_API_KEY — fallback key env var
*/
if (!globalThis.MACRO) {
globalThis.MACRO = { VERSION: '0.0.0-test', BUILD_TIME: '0' } as any
}
import { BraveSearchAdapter } from '../adapters/braveAdapter'
const query = process.env.BRAVE_QUERY || 'Claude AI Anthropic'
async function main() {
if (!process.env.BRAVE_SEARCH_API_KEY && !process.env.BRAVE_API_KEY) {
console.error(
'❌ Missing Brave API key. Set BRAVE_SEARCH_API_KEY or BRAVE_API_KEY.',
)
process.exit(1)
}
console.log(`\n🔍 Searching Brave for: "${query}"\n`)
const adapter = new BraveSearchAdapter()
const startTime = Date.now()
const results = await adapter.search(query, {
onProgress: p => {
if (p.type === 'query_update') {
console.log(` → Query sent: ${p.query}`)
}
if (p.type === 'search_results_received') {
console.log(` → Received ${p.resultCount} results`)
}
},
})
const elapsed = Date.now() - startTime
console.log(`\n✅ Done in ${elapsed}ms — ${results.length} result(s)\n`)
if (results.length === 0) {
console.log('⚠️ No results returned. Possible causes:')
console.log(' - Brave returned no grounding data for the query')
console.log(' - Network/firewall issue')
console.log(' - Invalid or rate-limited Brave API key\n')
process.exit(1)
}
for (const [i, r] of results.entries()) {
console.log(` ${i + 1}. ${r.title}`)
console.log(` ${r.url}`)
if (r.snippet) {
const snippet = r.snippet.replace(/\n/g, ' ')
console.log(
` ${snippet.slice(0, 150)}${snippet.length > 150 ? '…' : ''}`,
)
}
console.log()
}
let passed = true
for (const [i, r] of results.entries()) {
if (!r.title || typeof r.title !== 'string') {
console.error(`❌ Result ${i + 1}: missing or non-string title`, r)
passed = false
}
if (!r.url || !r.url.startsWith('http')) {
console.error(`❌ Result ${i + 1}: missing or non-http url`, r)
passed = false
}
}
if (passed) {
console.log('✅ All results have valid structure.\n')
} else {
process.exit(1)
}
}
if (import.meta.main) {
main().catch(e => {
console.error('❌ Fatal error:', e)
process.exit(1)
})
}

View File

@@ -0,0 +1,273 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
const originalBraveSearchApiKey = process.env.BRAVE_SEARCH_API_KEY
const originalBraveApiKey = process.env.BRAVE_API_KEY
describe('BraveSearchAdapter.search', () => {
const createAdapter = async () => {
const { BraveSearchAdapter } = await import('../adapters/braveAdapter')
return new BraveSearchAdapter()
}
const SAMPLE_RESPONSE = {
grounding: {
generic: [
{
title: 'Result One',
url: 'https://example.com/result1',
snippets: ['Snippet one'],
},
{
title: 'Result Two',
url: 'https://example.com/result2',
snippets: ['Snippet two'],
},
],
},
}
beforeEach(() => {
process.env.BRAVE_SEARCH_API_KEY = 'test-brave-key'
delete process.env.BRAVE_API_KEY
})
afterEach(() => {
mock.restore()
if (originalBraveSearchApiKey === undefined) {
delete process.env.BRAVE_SEARCH_API_KEY
} else {
process.env.BRAVE_SEARCH_API_KEY = originalBraveSearchApiKey
}
if (originalBraveApiKey === undefined) {
delete process.env.BRAVE_API_KEY
} else {
process.env.BRAVE_API_KEY = originalBraveApiKey
}
})
test('returns parsed results from Brave LLM context payload', async () => {
mock.module('axios', () => ({
default: {
get: mock(() => Promise.resolve({ data: SAMPLE_RESPONSE })),
isCancel: () => false,
},
}))
const adapter = await createAdapter()
const results = await adapter.search('test query', {})
expect(results).toHaveLength(2)
expect(results[0]).toEqual({
title: 'Result One',
url: 'https://example.com/result1',
snippet: 'Snippet one',
})
expect(results[1].title).toBe('Result Two')
})
test('calls onProgress with query_update and search_results_received', async () => {
mock.module('axios', () => ({
default: {
get: mock(() => Promise.resolve({ data: SAMPLE_RESPONSE })),
isCancel: () => false,
},
}))
const progressCalls: any[] = []
const onProgress = (p: any) => progressCalls.push(p)
const adapter = await createAdapter()
await adapter.search('test', { onProgress })
expect(progressCalls).toHaveLength(2)
expect(progressCalls[0]).toEqual({
type: 'query_update',
query: 'test',
})
expect(progressCalls[1]).toEqual({
type: 'search_results_received',
resultCount: 2,
query: 'test',
})
})
test('filters results by allowedDomains', async () => {
const mixedResponse = {
grounding: {
generic: [
{ title: 'Allowed', url: 'https://allowed.com/a' },
{ title: 'Blocked', url: 'https://blocked.com/b' },
],
},
}
mock.module('axios', () => ({
default: {
get: mock(() => Promise.resolve({ data: mixedResponse })),
isCancel: () => false,
},
}))
const adapter = await createAdapter()
const results = await adapter.search('test', {
allowedDomains: ['allowed.com'],
})
expect(results).toHaveLength(1)
expect(results[0].url).toBe('https://allowed.com/a')
})
test('filters results by blockedDomains', async () => {
const mixedResponse = {
grounding: {
generic: [
{ title: 'Good', url: 'https://good.com/a' },
{ title: 'Spam', url: 'https://spam.com/b' },
],
},
}
mock.module('axios', () => ({
default: {
get: mock(() => Promise.resolve({ data: mixedResponse })),
isCancel: () => false,
},
}))
const adapter = await createAdapter()
const results = await adapter.search('test', {
blockedDomains: ['spam.com'],
})
expect(results).toHaveLength(1)
expect(results[0].url).toBe('https://good.com/a')
})
test('filters subdomains with allowedDomains', async () => {
const response = {
grounding: {
generic: [
{ title: 'Subdomain', url: 'https://docs.example.com/page' },
{ title: 'Other', url: 'https://other.com/page' },
],
},
}
mock.module('axios', () => ({
default: {
get: mock(() => Promise.resolve({ data: response })),
isCancel: () => false,
},
}))
const adapter = await createAdapter()
const results = await adapter.search('test', {
allowedDomains: ['example.com'],
})
expect(results).toHaveLength(1)
expect(results[0].url).toBe('https://docs.example.com/page')
})
test('throws AbortError when signal is already aborted', async () => {
mock.module('axios', () => ({
default: {
get: mock((_url: string, config: any) => {
if (config?.signal?.aborted) {
const err = new Error('canceled')
;(err as any).__CANCEL__ = true
return Promise.reject(err)
}
return Promise.resolve({ data: SAMPLE_RESPONSE })
}),
isCancel: (e: any) => e?.__CANCEL__ === true,
},
}))
const adapter = await createAdapter()
const controller = new AbortController()
controller.abort()
const { AbortError } = await import('../../../utils/errors')
await expect(
adapter.search('test', { signal: controller.signal }),
).rejects.toThrow(AbortError)
})
test('re-throws non-abort axios errors', async () => {
const networkError = new Error('Network error')
mock.module('axios', () => ({
default: {
get: mock(() => Promise.reject(networkError)),
isCancel: () => false,
},
}))
const adapter = await createAdapter()
await expect(adapter.search('test', {})).rejects.toThrow('Network error')
})
test('sends the documented HTTPS endpoint with query params and auth header', async () => {
const axiosGet = mock(() => Promise.resolve({ data: SAMPLE_RESPONSE }))
mock.module('axios', () => ({
default: {
get: axiosGet,
isCancel: () => false,
},
}))
const adapter = await createAdapter()
await adapter.search('hello world & special=chars', {})
expect(axiosGet.mock.calls).toHaveLength(1)
expect((axiosGet.mock.calls as any[][])[0][0]).toBe(
'https://api.search.brave.com/res/v1/llm/context',
)
expect((axiosGet.mock.calls as any[][])[0][1]).toMatchObject({
params: { q: 'hello world & special=chars' },
headers: {
Accept: 'application/json',
'X-Subscription-Token': 'test-brave-key',
},
})
})
test('accepts BRAVE_API_KEY as a fallback env var', async () => {
delete process.env.BRAVE_SEARCH_API_KEY
process.env.BRAVE_API_KEY = 'fallback-key'
const axiosGet = mock(() => Promise.resolve({ data: SAMPLE_RESPONSE }))
mock.module('axios', () => ({
default: {
get: axiosGet,
isCancel: () => false,
},
}))
const adapter = await createAdapter()
await adapter.search('test', {})
expect((axiosGet.mock.calls as any[][])[0][1].headers).toMatchObject({
'X-Subscription-Token': 'fallback-key',
})
})
test('throws when no Brave API key is configured', async () => {
delete process.env.BRAVE_SEARCH_API_KEY
delete process.env.BRAVE_API_KEY
mock.module('axios', () => ({
default: {
get: mock(() => Promise.resolve({ data: SAMPLE_RESPONSE })),
isCancel: () => false,
},
}))
const adapter = await createAdapter()
await expect(adapter.search('test', {})).rejects.toThrow(
'BraveSearchAdapter requires BRAVE_SEARCH_API_KEY or BRAVE_API_KEY',
)
})
})

View File

@@ -0,0 +1,169 @@
/**
* Brave-based search adapter — fetches Brave's LLM context API and maps the
* grounding payload into SearchResult objects.
*/
import axios from 'axios'
import { AbortError } from '../../../utils/errors.js'
import type { SearchResult, SearchOptions, WebSearchAdapter } from './types.js'
const FETCH_TIMEOUT_MS = 30_000
const BRAVE_LLM_CONTEXT_URL = 'https://api.search.brave.com/res/v1/llm/context'
const BRAVE_API_KEY_ENV_VARS = ['BRAVE_SEARCH_API_KEY', 'BRAVE_API_KEY'] as const
interface BraveGroundingResult {
title?: string
url?: string
snippets?: string[]
}
interface BraveSearchResponse {
grounding?: {
generic?: BraveGroundingResult[]
map?: BraveGroundingResult[]
poi?: BraveGroundingResult | null
}
}
export class BraveSearchAdapter implements WebSearchAdapter {
async search(
query: string,
options: SearchOptions,
): Promise<SearchResult[]> {
const { signal, onProgress, allowedDomains, blockedDomains } = options
if (signal?.aborted) {
throw new AbortError()
}
onProgress?.({ type: 'query_update', query })
const abortController = new AbortController()
if (signal) {
signal.addEventListener('abort', () => abortController.abort(), {
once: true,
})
}
let payload: BraveSearchResponse
try {
const response = await axios.get<BraveSearchResponse>(
BRAVE_LLM_CONTEXT_URL,
{
signal: abortController.signal,
timeout: FETCH_TIMEOUT_MS,
responseType: 'json',
headers: {
Accept: 'application/json',
'X-Subscription-Token': getBraveApiKey(),
},
params: { q: query },
},
)
payload = response.data
} catch (e) {
if (axios.isCancel(e) || abortController.signal.aborted) {
throw new AbortError()
}
throw e
}
if (abortController.signal.aborted) {
throw new AbortError()
}
const rawResults = extractBraveResults(payload)
const results = rawResults.filter(r => {
try {
const hostname = new URL(r.url).hostname
if (
allowedDomains?.length &&
!allowedDomains.some(
d => hostname === d || hostname.endsWith('.' + d),
)
) {
return false
}
if (
blockedDomains?.length &&
blockedDomains.some(d => hostname === d || hostname.endsWith('.' + d))
) {
return false
}
} catch {
return false
}
return true
})
onProgress?.({
type: 'search_results_received',
resultCount: results.length,
query,
})
return results
}
}
export function extractBraveResults(
payload: BraveSearchResponse,
): SearchResult[] {
const grounding = payload.grounding
if (!grounding) {
return []
}
const entries = [
...(Array.isArray(grounding.generic) ? grounding.generic : []),
...(grounding.poi ? [grounding.poi] : []),
...(Array.isArray(grounding.map) ? grounding.map : []),
]
const seenUrls = new Set<string>()
const results: SearchResult[] = []
for (const entry of entries) {
if (!entry?.url || !entry.title || seenUrls.has(entry.url)) {
continue
}
seenUrls.add(entry.url)
results.push({
title: entry.title,
url: entry.url,
snippet: normalizeSnippet(entry.snippets),
})
}
return results
}
function normalizeSnippet(snippets: string[] | undefined): string | undefined {
if (!Array.isArray(snippets)) {
return undefined
}
const normalized = snippets
.map(snippet => snippet.trim())
.filter(snippet => snippet.length > 0)
if (normalized.length === 0) {
return undefined
}
return normalized.join(' ')
}
function getBraveApiKey(): string {
for (const envVar of BRAVE_API_KEY_ENV_VARS) {
const value = process.env[envVar]?.trim()
if (value) {
return value
}
}
throw new Error(
'BraveSearchAdapter requires BRAVE_SEARCH_API_KEY or BRAVE_API_KEY',
)
}

View File

@@ -6,36 +6,42 @@
import { isFirstPartyAnthropicBaseUrl } from '../../../utils/model/providers.js'
import { ApiSearchAdapter } from './apiAdapter.js'
import { BingSearchAdapter } from './bingAdapter.js'
import { BraveSearchAdapter } from './braveAdapter.js'
import type { WebSearchAdapter } from './types.js'
export type { SearchResult, SearchOptions, SearchProgress, WebSearchAdapter } from './types.js'
export type {
SearchResult,
SearchOptions,
SearchProgress,
WebSearchAdapter,
} from './types.js'
let cachedAdapter: WebSearchAdapter | null = null
let cachedAdapterKey: 'api' | 'bing' | 'brave' | null = null
export function createAdapter(): WebSearchAdapter {
// 直接用 bing 适配器,跳过 API 适配器的选择逻辑
return new BingSearchAdapter()
// // Adapter is stateless — safe to reuse across calls within a session
// if (cachedAdapter) return cachedAdapter
const envAdapter = process.env.WEB_SEARCH_ADAPTER
const adapterKey =
envAdapter === 'api' || envAdapter === 'bing' || envAdapter === 'brave'
? envAdapter
: isFirstPartyAnthropicBaseUrl()
? 'api'
: 'bing'
// // Env override: WEB_SEARCH_ADAPTER=api|bing forces specific backend
// const envAdapter = process.env.WEB_SEARCH_ADAPTER
// if (envAdapter === 'api') {
// cachedAdapter = new ApiSearchAdapter()
// return cachedAdapter
// }
// if (envAdapter === 'bing') {
// cachedAdapter = new BingSearchAdapter()
// return cachedAdapter
// }
if (cachedAdapter && cachedAdapterKey === adapterKey) return cachedAdapter
// // Anthropic official URL → API server-side search
// if (isFirstPartyAnthropicBaseUrl()) {
// cachedAdapter = new ApiSearchAdapter()
// return cachedAdapter
// }
if (adapterKey === 'api') {
cachedAdapter = new ApiSearchAdapter()
cachedAdapterKey = 'api'
return cachedAdapter
}
if (adapterKey === 'bing') {
cachedAdapter = new BingSearchAdapter()
cachedAdapterKey = 'bing'
return cachedAdapter
}
// // Third-party proxies / non-Anthropic endpoints → Bing fallback
// cachedAdapter = new BingSearchAdapter()
// return cachedAdapter
cachedAdapter = new BraveSearchAdapter()
cachedAdapterKey = 'brave'
return cachedAdapter
}