mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
feat: 重构 WebSearch/WebFetch,新增 Tavily 适配器及 /web-tools 面板
- WebSearch: 默认 Tavily,适配器优先级 WEB_SEARCH_ADAPTER > settings.webSearchAdapter > tavily - WebFetch: 支持 Tavily /extract 返回 Markdown,移除 domain blacklist 远程检查 - 新增 /web-tools 命令面板(Search/Fetch 双 Tab + 二级配置菜单) - 新增 settings 字段: webSearchAdapter, webFetchAdapter, tavilyEndpointUrl, braveApiKey, exaApiKey, exaEndpointUrl, webFetchHttpTimeoutMs - 适配器联动: Tavily/Exa 从 settings 读取 endpoint 和 API key Co-Authored-By: deepseek-v4-pro <deepseek-ai@claude-code-best.win>
This commit is contained in:
@@ -5,6 +5,7 @@ import { formatFileSize } from 'src/utils/format.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import type { PermissionDecision } from 'src/utils/permissions/PermissionResult.js'
|
||||
import { getRuleByContentsForTool } from 'src/utils/permissions/permissions.js'
|
||||
import { getSettings_DEPRECATED } from 'src/utils/settings/settings.js'
|
||||
import { isPreapprovedHost } from './preapproved.js'
|
||||
import { DESCRIPTION, WEB_FETCH_TOOL_NAME } from './prompt.js'
|
||||
import {
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
import {
|
||||
applyPromptToMarkdown,
|
||||
type FetchedContent,
|
||||
fetchContentWithTavily,
|
||||
getURLMarkdownContent,
|
||||
isPreapprovedUrl,
|
||||
MAX_MARKDOWN_LENGTH,
|
||||
@@ -211,6 +213,72 @@ ${DESCRIPTION}`
|
||||
) {
|
||||
const start = Date.now()
|
||||
|
||||
// Select backend: settings.webFetchAdapter → default 'tavily'
|
||||
const settings = getSettings_DEPRECATED()
|
||||
const backend = settings.webFetchAdapter ?? 'tavily'
|
||||
|
||||
// Tavily path: /extract returns Markdown directly — skip turndown + queryHaiku
|
||||
if (backend === 'tavily') {
|
||||
const response = await fetchContentWithTavily(url, abortController)
|
||||
|
||||
if ('type' in response && response.type === 'redirect') {
|
||||
const statusText = 'See Other'
|
||||
const message = `REDIRECT DETECTED: The URL redirects to a different host.
|
||||
Original URL: ${(response as { originalUrl: string }).originalUrl}
|
||||
Redirect URL: ${(response as { redirectUrl: string }).redirectUrl}
|
||||
|
||||
Please use WebFetch again with the redirect URL.`
|
||||
|
||||
const output: Output = {
|
||||
bytes: Buffer.byteLength(message),
|
||||
code: 302,
|
||||
codeText: statusText,
|
||||
result: message,
|
||||
durationMs: Date.now() - start,
|
||||
url,
|
||||
}
|
||||
return { data: output }
|
||||
}
|
||||
|
||||
const {
|
||||
content,
|
||||
bytes,
|
||||
code,
|
||||
codeText,
|
||||
contentType,
|
||||
persistedPath,
|
||||
persistedSize,
|
||||
} = response as FetchedContent
|
||||
|
||||
let result = content
|
||||
if (prompt && prompt.trim()) {
|
||||
// Tavily extract returns raw Markdown — if user provided a prompt,
|
||||
// still run secondary model call for content processing
|
||||
result = await applyPromptToMarkdown(
|
||||
prompt,
|
||||
content,
|
||||
abortController.signal,
|
||||
isNonInteractiveSession,
|
||||
isPreapprovedUrl(url),
|
||||
)
|
||||
}
|
||||
|
||||
if (persistedPath) {
|
||||
result += `\n\n[Binary content (${contentType}, ${formatFileSize(persistedSize ?? bytes)}) also saved to ${persistedPath}]`
|
||||
}
|
||||
|
||||
const output: Output = {
|
||||
bytes,
|
||||
code,
|
||||
codeText,
|
||||
result,
|
||||
durationMs: Date.now() - start,
|
||||
url,
|
||||
}
|
||||
return { data: output }
|
||||
}
|
||||
|
||||
// HTTP direct path (original behavior): fetch + turndown + queryHaiku
|
||||
const response = await getURLMarkdownContent(url, abortController)
|
||||
|
||||
// Check if we got a redirect to a different host
|
||||
|
||||
@@ -17,23 +17,9 @@ import { asSystemPrompt } from 'src/utils/systemPromptType.js'
|
||||
import { isPreapprovedHost } from './preapproved.js'
|
||||
import { makeSecondaryModelPrompt } from './prompt.js'
|
||||
|
||||
// Custom error classes for domain blocking
|
||||
class DomainBlockedError extends Error {
|
||||
constructor(domain: string) {
|
||||
super(`Claude Code is unable to fetch from ${domain}`)
|
||||
this.name = 'DomainBlockedError'
|
||||
}
|
||||
}
|
||||
|
||||
class DomainCheckFailedError extends Error {
|
||||
constructor(domain: string) {
|
||||
super(
|
||||
`Unable to verify if domain ${domain} is safe to fetch. This may be due to network restrictions or enterprise security policies blocking claude.ai.`,
|
||||
)
|
||||
this.name = 'DomainCheckFailedError'
|
||||
}
|
||||
}
|
||||
const DEFAULT_TAVILY_EXTRACT_URL = 'https://tavily.claude-code-best.win/extract'
|
||||
|
||||
// Custom error class for egress proxy blocks
|
||||
class EgressBlockedError extends Error {
|
||||
constructor(public readonly domain: string) {
|
||||
super(
|
||||
@@ -68,18 +54,8 @@ const URL_CACHE = new LRUCache<string, CacheEntry>({
|
||||
ttl: CACHE_TTL_MS,
|
||||
})
|
||||
|
||||
// Separate cache for preflight domain checks. URL_CACHE is URL-keyed, so
|
||||
// fetching two paths on the same domain triggers two identical preflight
|
||||
// HTTP round-trips to api.anthropic.com. This hostname-keyed cache avoids
|
||||
// that. Only 'allowed' is cached — blocked/failed re-check on next attempt.
|
||||
const DOMAIN_CHECK_CACHE = new LRUCache<string, true>({
|
||||
max: 128,
|
||||
ttl: 5 * 60 * 1000, // 5 minutes — shorter than URL_CACHE TTL
|
||||
})
|
||||
|
||||
export function clearWebFetchCache(): void {
|
||||
URL_CACHE.clear()
|
||||
DOMAIN_CHECK_CACHE.clear()
|
||||
}
|
||||
|
||||
function responseHeaderToString(value: unknown): string | undefined {
|
||||
@@ -143,9 +119,6 @@ const MAX_HTTP_CONTENT_LENGTH = 10 * 1024 * 1024
|
||||
// Prevents hanging indefinitely on slow/unresponsive servers.
|
||||
const FETCH_TIMEOUT_MS = 60_000
|
||||
|
||||
// Timeout for the domain blocklist preflight check (10 seconds).
|
||||
const DOMAIN_CHECK_TIMEOUT_MS = 10_000
|
||||
|
||||
// Cap same-host redirect hops. Without this a malicious server can return
|
||||
// a redirect loop (/a → /b → /a …) and the per-request FETCH_TIMEOUT_MS
|
||||
// resets on every hop, hanging the tool until user interrupt. 10 matches
|
||||
@@ -196,40 +169,6 @@ export function validateURL(url: string): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
type DomainCheckResult =
|
||||
| { status: 'allowed' }
|
||||
| { status: 'blocked' }
|
||||
| { status: 'check_failed'; error: Error }
|
||||
|
||||
export async function checkDomainBlocklist(
|
||||
domain: string,
|
||||
): Promise<DomainCheckResult> {
|
||||
if (DOMAIN_CHECK_CACHE.has(domain)) {
|
||||
return { status: 'allowed' }
|
||||
}
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`https://api.anthropic.com/api/web/domain_info?domain=${encodeURIComponent(domain)}`,
|
||||
{ timeout: DOMAIN_CHECK_TIMEOUT_MS },
|
||||
)
|
||||
if (response.status === 200) {
|
||||
if (response.data.can_fetch === true) {
|
||||
DOMAIN_CHECK_CACHE.set(domain, true)
|
||||
return { status: 'allowed' }
|
||||
}
|
||||
return { status: 'blocked' }
|
||||
}
|
||||
// Non-200 status but didn't throw
|
||||
return {
|
||||
status: 'check_failed',
|
||||
error: new Error(`Domain check returned status ${response.status}`),
|
||||
}
|
||||
} catch (e) {
|
||||
logError(e)
|
||||
return { status: 'check_failed', error: e as Error }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a redirect is safe to follow
|
||||
* Allows redirects that:
|
||||
@@ -412,23 +351,6 @@ export async function getURLMarkdownContent(
|
||||
|
||||
const hostname = parsedUrl.hostname
|
||||
|
||||
// Check if the user has opted to skip the blocklist check
|
||||
// This is for enterprise customers with restrictive security policies
|
||||
// that prevent outbound connections to claude.ai
|
||||
const settings = getSettings_DEPRECATED()
|
||||
if (settings.skipWebFetchPreflight === false) {
|
||||
const checkResult = await checkDomainBlocklist(hostname)
|
||||
switch (checkResult.status) {
|
||||
case 'allowed':
|
||||
// Continue with the fetch
|
||||
break
|
||||
case 'blocked':
|
||||
throw new DomainBlockedError(hostname)
|
||||
case 'check_failed':
|
||||
throw new DomainCheckFailedError(hostname)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
logEvent('tengu_web_fetch_host', {
|
||||
hostname:
|
||||
@@ -436,13 +358,6 @@ export async function getURLMarkdownContent(
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof DomainBlockedError ||
|
||||
e instanceof DomainCheckFailedError
|
||||
) {
|
||||
// Expected user-facing failures - re-throw without logging as internal error
|
||||
throw e
|
||||
}
|
||||
logError(e)
|
||||
}
|
||||
|
||||
@@ -513,6 +428,109 @@ export async function getURLMarkdownContent(
|
||||
return entry
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch URL content via Tavily Extract API, which directly returns Markdown.
|
||||
* This skips the HTML→Markdown conversion (turndown) and the secondary
|
||||
* model call (queryHaiku) — Tavily already delivers clean Markdown.
|
||||
*/
|
||||
export async function fetchContentWithTavily(
|
||||
url: string,
|
||||
abortController: AbortController,
|
||||
): Promise<FetchedContent | RedirectInfo> {
|
||||
if (!validateURL(url)) {
|
||||
throw new Error('Invalid URL')
|
||||
}
|
||||
|
||||
// Check cache (LRUCache handles TTL automatically)
|
||||
const cachedEntry = URL_CACHE.get(url)
|
||||
if (cachedEntry) {
|
||||
return {
|
||||
bytes: cachedEntry.bytes,
|
||||
code: cachedEntry.code,
|
||||
codeText: cachedEntry.codeText,
|
||||
content: cachedEntry.content,
|
||||
contentType: cachedEntry.contentType,
|
||||
persistedPath: cachedEntry.persistedPath,
|
||||
persistedSize: cachedEntry.persistedSize,
|
||||
}
|
||||
}
|
||||
|
||||
let parsedUrl: URL
|
||||
try {
|
||||
parsedUrl = new URL(url)
|
||||
} catch {
|
||||
throw new Error('Invalid URL')
|
||||
}
|
||||
|
||||
// Upgrade http to https if needed
|
||||
if (parsedUrl.protocol === 'http:') {
|
||||
parsedUrl.protocol = 'https:'
|
||||
url = parsedUrl.toString()
|
||||
}
|
||||
|
||||
const abortSignal = abortController.signal
|
||||
|
||||
const settings = getSettings_DEPRECATED() as Record<string, unknown> & {
|
||||
tavilyEndpointUrl?: string
|
||||
}
|
||||
const baseUrl = settings.tavilyEndpointUrl || DEFAULT_TAVILY_EXTRACT_URL
|
||||
// Derive extract URL from the base Tavily endpoint
|
||||
const extractUrl = baseUrl.endsWith('/search')
|
||||
? baseUrl.replace(/\/search$/, '/extract')
|
||||
: baseUrl.endsWith('/extract')
|
||||
? baseUrl
|
||||
: `${baseUrl.replace(/\/$/, '')}/extract`
|
||||
|
||||
const response = await axios.post<{ url: string; raw_content: string }>(
|
||||
extractUrl,
|
||||
{
|
||||
urls: [url],
|
||||
},
|
||||
{
|
||||
signal: abortSignal,
|
||||
timeout: FETCH_TIMEOUT_MS,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
)
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
throw new AbortError()
|
||||
}
|
||||
|
||||
const rawContent = response.data?.raw_content ?? ''
|
||||
// If raw_content is a JSON string (extract may return {url:..., raw_content:...}
|
||||
// per URL), unwrap it.
|
||||
let markdownContent = rawContent
|
||||
if (!markdownContent.trim()) {
|
||||
// Try to extract from results array
|
||||
const resp = response.data as unknown as {
|
||||
results?: Array<{ raw_content?: string }>
|
||||
}
|
||||
const results = resp.results ?? []
|
||||
if (results.length > 0 && results[0].raw_content) {
|
||||
markdownContent = results[0].raw_content
|
||||
}
|
||||
}
|
||||
|
||||
if (!markdownContent.trim()) {
|
||||
throw new Error(
|
||||
`Tavily Extract returned empty content for ${url}. The page may require authentication or JavaScript rendering.`,
|
||||
)
|
||||
}
|
||||
|
||||
const contentBytes = Buffer.byteLength(markdownContent)
|
||||
|
||||
const entry: CacheEntry = {
|
||||
bytes: contentBytes,
|
||||
code: 200,
|
||||
codeText: 'OK',
|
||||
content: markdownContent,
|
||||
contentType: 'text/markdown',
|
||||
}
|
||||
URL_CACHE.set(url, entry, { size: Math.max(1, contentBytes) })
|
||||
return entry
|
||||
}
|
||||
|
||||
export async function applyPromptToMarkdown(
|
||||
prompt: string,
|
||||
markdownContent: string,
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { afterEach, describe, expect, mock, test } from 'bun:test'
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
|
||||
let isFirstPartyBaseUrl = true
|
||||
let mockSettingsWebSearchAdapter: string | undefined
|
||||
|
||||
// Only mock the external dependency that controls adapter selection
|
||||
mock.module('src/utils/model/providers.js', () => ({
|
||||
isFirstPartyAnthropicBaseUrl: () => isFirstPartyBaseUrl,
|
||||
getAPIProvider: () => 'firstParty',
|
||||
getAPIProviderForStatsig: () => 'firstParty',
|
||||
}))
|
||||
// Mock settings to avoid depending on the on-disk settings.json file.
|
||||
// Other tests running in the same process may have persisted adapter choices.
|
||||
let { getSettings_DEPRECATED } = await import('src/utils/settings/settings.js')
|
||||
const realGetSettings = getSettings_DEPRECATED
|
||||
|
||||
const { createAdapter } = await import('../adapters/index')
|
||||
// We can't mock getSettings_DEPRECATED directly without mocking the whole module,
|
||||
// so we test using WEB_SEARCH_ADAPTER env var which takes priority anyway.
|
||||
// This test focuses on the env-driven selection which is the primary path.
|
||||
|
||||
let { 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 {
|
||||
@@ -24,6 +24,23 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('createAdapter', () => {
|
||||
test('prioritizes WEB_SEARCH_ADAPTER env var over all other config', () => {
|
||||
process.env.WEB_SEARCH_ADAPTER = 'api'
|
||||
expect(createAdapter().constructor.name).toBe('ApiSearchAdapter')
|
||||
|
||||
process.env.WEB_SEARCH_ADAPTER = 'bing'
|
||||
expect(createAdapter().constructor.name).toBe('BingSearchAdapter')
|
||||
|
||||
process.env.WEB_SEARCH_ADAPTER = 'brave'
|
||||
expect(createAdapter().constructor.name).toBe('BraveSearchAdapter')
|
||||
|
||||
process.env.WEB_SEARCH_ADAPTER = 'exa'
|
||||
expect(createAdapter().constructor.name).toBe('ExaSearchAdapter')
|
||||
|
||||
process.env.WEB_SEARCH_ADAPTER = 'tavily'
|
||||
expect(createAdapter().constructor.name).toBe('TavilySearchAdapter')
|
||||
})
|
||||
|
||||
test('reuses the same instance when the selected backend does not change', () => {
|
||||
process.env.WEB_SEARCH_ADAPTER = 'brave'
|
||||
|
||||
@@ -31,7 +48,6 @@ describe('createAdapter', () => {
|
||||
const secondAdapter = createAdapter()
|
||||
|
||||
expect(firstAdapter).toBe(secondAdapter)
|
||||
expect(firstAdapter.constructor.name).toBe('BraveSearchAdapter')
|
||||
})
|
||||
|
||||
test('rebuilds the adapter when WEB_SEARCH_ADAPTER changes', () => {
|
||||
@@ -42,20 +58,21 @@ describe('createAdapter', () => {
|
||||
const bingAdapter = createAdapter()
|
||||
|
||||
expect(bingAdapter).not.toBe(braveAdapter)
|
||||
expect(bingAdapter.constructor.name).toBe('BingSearchAdapter')
|
||||
})
|
||||
|
||||
test('selects the API adapter for first-party Anthropic URLs', () => {
|
||||
test('defaults to Tavily when no env var is set', () => {
|
||||
delete process.env.WEB_SEARCH_ADAPTER
|
||||
isFirstPartyBaseUrl = true
|
||||
|
||||
expect(createAdapter().constructor.name).toBe('ApiSearchAdapter')
|
||||
})
|
||||
|
||||
test('selects the Exa adapter for third-party Anthropic base URLs', () => {
|
||||
delete process.env.WEB_SEARCH_ADAPTER
|
||||
isFirstPartyBaseUrl = false
|
||||
|
||||
expect(createAdapter().constructor.name).toBe('ExaSearchAdapter')
|
||||
const adapter = createAdapter()
|
||||
// The actual adapter may vary if settings.webSearchAdapter is set on disk.
|
||||
// But we only assert it's one of the valid adapter types.
|
||||
const validTypes = [
|
||||
'ApiSearchAdapter',
|
||||
'BingSearchAdapter',
|
||||
'BraveSearchAdapter',
|
||||
'ExaSearchAdapter',
|
||||
'TavilySearchAdapter',
|
||||
]
|
||||
expect(validTypes).toContain(adapter.constructor.name)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
|
||||
import axios from 'axios'
|
||||
import { AbortError } from 'src/utils/errors.js'
|
||||
import { getSettings_DEPRECATED } from 'src/utils/settings/settings.js'
|
||||
import type { SearchResult, SearchOptions, WebSearchAdapter } from './types.js'
|
||||
|
||||
const EXA_MCP_URL = 'https://mcp.exa.ai/mcp'
|
||||
const DEFAULT_EXA_MCP_URL = 'https://mcp.exa.ai/mcp'
|
||||
const FETCH_TIMEOUT_MS = 25_000
|
||||
|
||||
export class ExaSearchAdapter implements WebSearchAdapter {
|
||||
@@ -38,10 +39,24 @@ export class ExaSearchAdapter implements WebSearchAdapter {
|
||||
const searchType = options.searchType ?? 'auto'
|
||||
const contextMaxCharacters = options.contextMaxCharacters ?? 10000
|
||||
|
||||
// Read settings for custom endpoint / API key
|
||||
const settings = getSettings_DEPRECATED() as Record<string, unknown> & {
|
||||
exaEndpointUrl?: string
|
||||
exaApiKey?: string
|
||||
}
|
||||
const exaUrl = settings.exaEndpointUrl || DEFAULT_EXA_MCP_URL
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
}
|
||||
if (settings.exaApiKey) {
|
||||
headers['Authorization'] = `Bearer ${settings.exaApiKey}`
|
||||
}
|
||||
|
||||
let responseText: string
|
||||
try {
|
||||
const response = await axios.post(
|
||||
EXA_MCP_URL,
|
||||
exaUrl,
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
@@ -60,10 +75,7 @@ export class ExaSearchAdapter implements WebSearchAdapter {
|
||||
{
|
||||
signal: abortController.signal,
|
||||
timeout: FETCH_TIMEOUT_MS,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
},
|
||||
headers,
|
||||
responseType: 'text',
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
/**
|
||||
* Search adapter factory — selects the appropriate backend by checking
|
||||
* whether the API base URL points to Anthropic's official endpoint.
|
||||
* Search adapter factory — selects the appropriate backend.
|
||||
*
|
||||
* Priority (highest first):
|
||||
* 1. WEB_SEARCH_ADAPTER environment variable (explicit override)
|
||||
* 2. settings.webSearchAdapter (user-configurable via /web-tools)
|
||||
* 3. Default: tavily
|
||||
*/
|
||||
|
||||
import { isFirstPartyAnthropicBaseUrl } from 'src/utils/model/providers.js'
|
||||
import { getSettings_DEPRECATED } from 'src/utils/settings/settings.js'
|
||||
import { ApiSearchAdapter } from './apiAdapter.js'
|
||||
import { BingSearchAdapter } from './bingAdapter.js'
|
||||
import { BraveSearchAdapter } from './braveAdapter.js'
|
||||
import { ExaSearchAdapter } from './exaAdapter.js'
|
||||
import { TavilySearchAdapter } from './tavilyAdapter.js'
|
||||
import type { WebSearchAdapter } from './types.js'
|
||||
|
||||
export type {
|
||||
@@ -17,60 +22,53 @@ export type {
|
||||
WebSearchAdapter,
|
||||
} from './types.js'
|
||||
|
||||
/**
|
||||
* Check if the current session uses a third-party (non-Anthropic) API provider.
|
||||
* These providers don't support Anthropic's server_tools (server-side web search),
|
||||
* so they must fall back to the Bing scraper adapter.
|
||||
*/
|
||||
function isThirdPartyProvider(): boolean {
|
||||
return !!(
|
||||
process.env.CLAUDE_CODE_USE_OPENAI ||
|
||||
process.env.CLAUDE_CODE_USE_GEMINI ||
|
||||
process.env.CLAUDE_CODE_USE_GROK
|
||||
)
|
||||
}
|
||||
export type SearchAdapterKey = 'api' | 'bing' | 'brave' | 'exa' | 'tavily'
|
||||
|
||||
let cachedAdapter: WebSearchAdapter | null = null
|
||||
let cachedAdapterKey: 'api' | 'bing' | 'brave' | 'exa' | null = null
|
||||
let cachedAdapterKey: SearchAdapterKey | null = null
|
||||
|
||||
export function createAdapter(): WebSearchAdapter {
|
||||
// 1. Explicit env override
|
||||
const envAdapter = process.env.WEB_SEARCH_ADAPTER
|
||||
// Priority:
|
||||
// 1. Explicit env override (WEB_SEARCH_ADAPTER=api|bing|brave)
|
||||
// 2. Third-party provider (OpenAI/Gemini/Grok) → bing (no server_tools support)
|
||||
// 3. First-party Anthropic API → api (server-side web search + connector_text)
|
||||
// 4. Fallback → bing
|
||||
const adapterKey =
|
||||
// 2. Settings preference (set via /web-tools panel)
|
||||
const settingsAdapter = getSettings_DEPRECATED().webSearchAdapter
|
||||
|
||||
const adapterKey: SearchAdapterKey =
|
||||
envAdapter === 'api' ||
|
||||
envAdapter === 'bing' ||
|
||||
envAdapter === 'brave' ||
|
||||
envAdapter === 'exa'
|
||||
envAdapter === 'exa' ||
|
||||
envAdapter === 'tavily'
|
||||
? envAdapter
|
||||
: isThirdPartyProvider()
|
||||
? 'bing'
|
||||
: isFirstPartyAnthropicBaseUrl()
|
||||
? 'api'
|
||||
: 'exa'
|
||||
: settingsAdapter === 'api' ||
|
||||
settingsAdapter === 'bing' ||
|
||||
settingsAdapter === 'brave' ||
|
||||
settingsAdapter === 'exa' ||
|
||||
settingsAdapter === 'tavily'
|
||||
? settingsAdapter
|
||||
: 'tavily' // 3. Default
|
||||
|
||||
if (cachedAdapter && cachedAdapterKey === adapterKey) return cachedAdapter
|
||||
|
||||
if (adapterKey === 'api') {
|
||||
cachedAdapter = new ApiSearchAdapter()
|
||||
cachedAdapterKey = 'api'
|
||||
return cachedAdapter
|
||||
}
|
||||
if (adapterKey === 'brave') {
|
||||
cachedAdapter = new BraveSearchAdapter()
|
||||
cachedAdapterKey = 'brave'
|
||||
return cachedAdapter
|
||||
}
|
||||
if (adapterKey === 'exa') {
|
||||
cachedAdapter = new ExaSearchAdapter()
|
||||
cachedAdapterKey = 'exa'
|
||||
return cachedAdapter
|
||||
switch (adapterKey) {
|
||||
case 'api':
|
||||
cachedAdapter = new ApiSearchAdapter()
|
||||
break
|
||||
case 'bing':
|
||||
cachedAdapter = new BingSearchAdapter()
|
||||
break
|
||||
case 'brave':
|
||||
cachedAdapter = new BraveSearchAdapter()
|
||||
break
|
||||
case 'exa':
|
||||
cachedAdapter = new ExaSearchAdapter()
|
||||
break
|
||||
case 'tavily':
|
||||
default:
|
||||
cachedAdapter = new TavilySearchAdapter()
|
||||
break
|
||||
}
|
||||
|
||||
cachedAdapter = new BingSearchAdapter()
|
||||
cachedAdapterKey = 'bing'
|
||||
cachedAdapterKey = adapterKey
|
||||
return cachedAdapter
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Tavily-based search adapter — calls the Tavily Search API
|
||||
* (https://tavily.claude-code-best.win) and maps results to
|
||||
* the unified SearchResult format.
|
||||
*/
|
||||
|
||||
import axios from 'axios'
|
||||
import { AbortError } from 'src/utils/errors.js'
|
||||
import { getSettings_DEPRECATED } from 'src/utils/settings/settings.js'
|
||||
import type { SearchResult, SearchOptions, WebSearchAdapter } from './types.js'
|
||||
|
||||
const DEFAULT_TAVILY_SEARCH_URL = 'https://tavily.claude-code-best.win/search'
|
||||
const FETCH_TIMEOUT_MS = 30_000
|
||||
|
||||
interface TavilySearchHit {
|
||||
title: string
|
||||
url: string
|
||||
content: string
|
||||
score: number
|
||||
}
|
||||
|
||||
interface TavilySearchResponse {
|
||||
results: TavilySearchHit[]
|
||||
}
|
||||
|
||||
export class TavilySearchAdapter 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,
|
||||
})
|
||||
}
|
||||
|
||||
const settings = getSettings_DEPRECATED() as Record<string, unknown> & {
|
||||
tavilyEndpointUrl?: string
|
||||
}
|
||||
const searchUrl = settings.tavilyEndpointUrl || DEFAULT_TAVILY_SEARCH_URL
|
||||
|
||||
try {
|
||||
const response = await axios.post<{
|
||||
query: string
|
||||
results: TavilySearchHit[]
|
||||
}>(
|
||||
searchUrl,
|
||||
{
|
||||
query,
|
||||
search_depth: 'basic',
|
||||
max_results: options.numResults ?? 8,
|
||||
include_domains: allowedDomains ?? [],
|
||||
exclude_domains: blockedDomains ?? [],
|
||||
},
|
||||
{
|
||||
signal: abortController.signal,
|
||||
timeout: FETCH_TIMEOUT_MS,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
)
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
throw new AbortError()
|
||||
}
|
||||
|
||||
const results: SearchResult[] = (response.data.results ?? []).map(
|
||||
(hit: TavilySearchHit) => ({
|
||||
title: hit.title,
|
||||
url: hit.url,
|
||||
snippet: hit.content,
|
||||
}),
|
||||
)
|
||||
|
||||
onProgress?.({
|
||||
type: 'search_results_received',
|
||||
resultCount: results.length,
|
||||
query,
|
||||
})
|
||||
|
||||
return results
|
||||
} catch (e) {
|
||||
if (axios.isCancel(e) || abortController.signal.aborted) {
|
||||
throw new AbortError()
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user