mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
fix: keep WebSearch usable across third-party models
Native Anthropic web search is only reliable for Claude-family model names, while many configured providers either do not implement the server tool schema or reject it through proxy layers. This routes WebSearch through a single resolver, adds Tavily and Brave fallbacks, and exposes provider key setup in the desktop settings page. Constraint: Third-party Anthropic-compatible endpoints may reject web_search_20250305 even when the model name is Claude-like Constraint: Non-Claude models still need a usable WebSearch path when users configure an external search provider Rejected: Gate native WebSearch by base URL | third-party Claude proxies can support it and official-looking URLs are not the real capability boundary Rejected: Always expose Anthropic native WebSearch | unsupported providers loop on schema or tool errors Confidence: high Scope-risk: moderate Directive: Keep WebSearch capability resolution centralized in WebSearchTool/backend.ts before adding more search providers Tested: bun test src/tools/WebSearchTool/backend.test.ts Tested: cd desktop && bun run test -- generalSettings.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Tested: agent-browser Settings E2E for Tavily/Brave links and settings persistence Tested: live Tavily and Brave fallback searches returned results using transient keys Not-tested: automatic LLM decision to invoke WebSearch end-to-end against a paid model session
This commit is contained in:
parent
9ecc178c7a
commit
3a6bad0e03
@ -108,12 +108,16 @@ describe('Settings > General tab', () => {
|
||||
locale: 'en',
|
||||
thinkingEnabled: true,
|
||||
skipWebFetchPreflight: true,
|
||||
webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' },
|
||||
setThinkingEnabled: vi.fn().mockImplementation(async (enabled: boolean) => {
|
||||
useSettingsStore.setState({ thinkingEnabled: enabled })
|
||||
}),
|
||||
setSkipWebFetchPreflight: vi.fn().mockImplementation(async (enabled: boolean) => {
|
||||
useSettingsStore.setState({ skipWebFetchPreflight: enabled })
|
||||
}),
|
||||
setWebSearch: vi.fn().mockImplementation(async (webSearch) => {
|
||||
useSettingsStore.setState({ webSearch })
|
||||
}),
|
||||
})
|
||||
|
||||
useUIStore.setState({ pendingSettingsTab: null })
|
||||
@ -166,6 +170,39 @@ describe('Settings > General tab', () => {
|
||||
expect(useSettingsStore.getState().setThinkingEnabled).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('saves WebSearch fallback provider settings', () => {
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Tavily' }))
|
||||
fireEvent.change(screen.getByLabelText('Tavily API key'), {
|
||||
target: { value: 'tvly-test-key' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(useSettingsStore.getState().setWebSearch).toHaveBeenCalledWith({
|
||||
mode: 'tavily',
|
||||
tavilyApiKey: 'tvly-test-key',
|
||||
braveApiKey: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('links to WebSearch provider API key dashboards', () => {
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Get Tavily API key' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://app.tavily.com/home',
|
||||
)
|
||||
expect(screen.getByRole('link', { name: 'Get Brave Search API key' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://api-dashboard.search.brave.com/app/keys',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps extension tabs available alongside the terminal tab', () => {
|
||||
render(<Settings />)
|
||||
|
||||
|
||||
@ -490,6 +490,23 @@ export const en = {
|
||||
'settings.general.webFetchPreflightDescription': 'Desktop sessions skip Claude\'s domain preflight by default to avoid false failures on third-party providers and restricted networks.',
|
||||
'settings.general.webFetchPreflightEnabled': 'Skip WebFetch domain preflight',
|
||||
'settings.general.webFetchPreflightHint': 'Turn this off only if you explicitly want to restore the upstream safety preflight before each WebFetch request.',
|
||||
'settings.general.webSearchTitle': 'WebSearch',
|
||||
'settings.general.webSearchDescription': 'Choose how agent web search is resolved across official Claude, third-party providers, and local fallback keys.',
|
||||
'settings.general.webSearch.mode.auto': 'Auto',
|
||||
'settings.general.webSearch.mode.tavily': 'Tavily',
|
||||
'settings.general.webSearch.mode.brave': 'Brave',
|
||||
'settings.general.webSearch.mode.anthropic': 'Claude',
|
||||
'settings.general.webSearch.mode.disabled': 'Off',
|
||||
'settings.general.webSearchTavilyKey': 'Tavily API key',
|
||||
'settings.general.webSearchBraveKey': 'Brave Search API key',
|
||||
'settings.general.webSearchBravePlaceholder': 'Brave Search token',
|
||||
'settings.general.webSearchGetApiKey': 'Get API key',
|
||||
'settings.general.webSearchTavilyApiKeyLink': 'Get Tavily API key',
|
||||
'settings.general.webSearchBraveApiKeyLink': 'Get Brave Search API key',
|
||||
'settings.general.webSearchTavilyFreeHint': 'Create an account and copy a key; the free tier includes 1000 credits.',
|
||||
'settings.general.webSearchBraveFreeHint': 'Create an account to generate a Search API key with free usage for testing.',
|
||||
'settings.general.webSearchHint': 'Auto uses native Claude web search for Claude model names, then falls back to Tavily and Brave keys.',
|
||||
'settings.general.webSearchSave': 'Save',
|
||||
|
||||
// ─── Empty Session ──────────────────────────────────────
|
||||
'empty.title': 'New session',
|
||||
|
||||
@ -492,6 +492,23 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.general.webFetchPreflightDescription': '桌面端默认跳过 Claude 的域名预检,避免第三方服务商或受限网络下出现误报失败。',
|
||||
'settings.general.webFetchPreflightEnabled': '跳过 WebFetch 域名预检',
|
||||
'settings.general.webFetchPreflightHint': '只有在你明确需要恢复上游默认安全预检时,才建议关闭这个选项。',
|
||||
'settings.general.webSearchTitle': 'WebSearch',
|
||||
'settings.general.webSearchDescription': '配置 Agent 联网搜索在 Claude 原生、第三方供应商和本地 fallback key 之间如何选择。',
|
||||
'settings.general.webSearch.mode.auto': '自动',
|
||||
'settings.general.webSearch.mode.tavily': 'Tavily',
|
||||
'settings.general.webSearch.mode.brave': 'Brave',
|
||||
'settings.general.webSearch.mode.anthropic': 'Claude',
|
||||
'settings.general.webSearch.mode.disabled': '关闭',
|
||||
'settings.general.webSearchTavilyKey': 'Tavily API Key',
|
||||
'settings.general.webSearchBraveKey': 'Brave Search API Key',
|
||||
'settings.general.webSearchBravePlaceholder': 'Brave Search token',
|
||||
'settings.general.webSearchGetApiKey': '获取 API Key',
|
||||
'settings.general.webSearchTavilyApiKeyLink': '获取 Tavily API Key',
|
||||
'settings.general.webSearchBraveApiKeyLink': '获取 Brave Search API Key',
|
||||
'settings.general.webSearchTavilyFreeHint': '注册账号即可复制 API Key,免费额度包含 1000 Credits。',
|
||||
'settings.general.webSearchBraveFreeHint': '注册账号后可创建 Search API Key,免费额度可用于测试。',
|
||||
'settings.general.webSearchHint': '自动模式会对 Claude 模型名优先使用原生 WebSearch,失败或非 Claude 模型时再使用 Tavily/Brave。',
|
||||
'settings.general.webSearchSave': '保存',
|
||||
|
||||
// ─── Empty Session ──────────────────────────────────────
|
||||
'empty.title': '新建会话',
|
||||
|
||||
@ -7,7 +7,7 @@ import { ConfirmDialog } from '../components/shared/ConfirmDialog'
|
||||
import { Input } from '../components/shared/Input'
|
||||
import { Button } from '../components/shared/Button'
|
||||
import { Dropdown } from '../components/shared/Dropdown'
|
||||
import type { PermissionMode, EffortLevel, ThemeMode } from '../types/settings'
|
||||
import type { PermissionMode, EffortLevel, ThemeMode, WebSearchMode } from '../types/settings'
|
||||
import type { Locale } from '../i18n'
|
||||
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat } from '../types/provider'
|
||||
import type { ProviderPreset } from '../types/providerPreset'
|
||||
@ -880,8 +880,16 @@ function GeneralSettings() {
|
||||
setTheme,
|
||||
skipWebFetchPreflight,
|
||||
setSkipWebFetchPreflight,
|
||||
webSearch,
|
||||
setWebSearch,
|
||||
} = useSettingsStore()
|
||||
const t = useTranslation()
|
||||
const [webSearchDraft, setWebSearchDraft] = useState(webSearch)
|
||||
const webSearchDirty = JSON.stringify(webSearchDraft) !== JSON.stringify(webSearch)
|
||||
|
||||
useEffect(() => {
|
||||
setWebSearchDraft(webSearch)
|
||||
}, [webSearch])
|
||||
|
||||
const EFFORT_LABELS: Record<EffortLevel, string> = {
|
||||
low: t('settings.general.effort.low'),
|
||||
@ -900,6 +908,14 @@ function GeneralSettings() {
|
||||
{ value: 'dark', label: t('settings.general.appearance.dark') },
|
||||
]
|
||||
|
||||
const WEB_SEARCH_MODES: Array<{ value: WebSearchMode; label: string }> = [
|
||||
{ value: 'auto', label: t('settings.general.webSearch.mode.auto') },
|
||||
{ value: 'tavily', label: t('settings.general.webSearch.mode.tavily') },
|
||||
{ value: 'brave', label: t('settings.general.webSearch.mode.brave') },
|
||||
{ value: 'anthropic', label: t('settings.general.webSearch.mode.anthropic') },
|
||||
{ value: 'disabled', label: t('settings.general.webSearch.mode.disabled') },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
{/* Appearance selector */}
|
||||
@ -1002,6 +1018,96 @@ function GeneralSettings() {
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.webSearchTitle')}</h2>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.webSearchDescription')}</p>
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-4">
|
||||
<div className="grid grid-cols-5 gap-1.5 mb-4">
|
||||
{WEB_SEARCH_MODES.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setWebSearchDraft({ ...webSearchDraft, mode: value })}
|
||||
className={`h-9 px-2 text-xs font-semibold rounded-lg border transition-all truncate ${
|
||||
(webSearchDraft.mode ?? 'auto') === value
|
||||
? 'bg-[var(--color-brand)] text-white border-[var(--color-brand)]'
|
||||
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<Input
|
||||
id="web-search-tavily-key"
|
||||
type="password"
|
||||
label={t('settings.general.webSearchTavilyKey')}
|
||||
value={webSearchDraft.tavilyApiKey ?? ''}
|
||||
placeholder="tvly-..."
|
||||
autoComplete="off"
|
||||
onChange={(event) =>
|
||||
setWebSearchDraft({
|
||||
...webSearchDraft,
|
||||
tavilyApiKey: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="-mt-1 flex items-center justify-between gap-3 text-xs text-[var(--color-text-tertiary)]">
|
||||
<span>{t('settings.general.webSearchTavilyFreeHint')}</span>
|
||||
<a
|
||||
href="https://app.tavily.com/home"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={t('settings.general.webSearchTavilyApiKeyLink')}
|
||||
className="font-medium text-[var(--color-brand)] hover:underline whitespace-nowrap"
|
||||
>
|
||||
{t('settings.general.webSearchGetApiKey')}
|
||||
</a>
|
||||
</div>
|
||||
<Input
|
||||
id="web-search-brave-key"
|
||||
type="password"
|
||||
label={t('settings.general.webSearchBraveKey')}
|
||||
value={webSearchDraft.braveApiKey ?? ''}
|
||||
placeholder={t('settings.general.webSearchBravePlaceholder')}
|
||||
autoComplete="off"
|
||||
onChange={(event) =>
|
||||
setWebSearchDraft({
|
||||
...webSearchDraft,
|
||||
braveApiKey: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="-mt-1 flex items-center justify-between gap-3 text-xs text-[var(--color-text-tertiary)]">
|
||||
<span>{t('settings.general.webSearchBraveFreeHint')}</span>
|
||||
<a
|
||||
href="https://api-dashboard.search.brave.com/app/keys"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={t('settings.general.webSearchBraveApiKeyLink')}
|
||||
className="font-medium text-[var(--color-brand)] hover:underline whitespace-nowrap"
|
||||
>
|
||||
{t('settings.general.webSearchGetApiKey')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-[var(--color-text-tertiary)] leading-5">
|
||||
{t('settings.general.webSearchHint')}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={!webSearchDirty}
|
||||
onClick={() => void setWebSearch(webSearchDraft)}
|
||||
>
|
||||
{t('settings.general.webSearchSave')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand'
|
||||
import { settingsApi } from '../api/settings'
|
||||
import { modelsApi } from '../api/models'
|
||||
import type { PermissionMode, EffortLevel, ModelInfo, ThemeMode } from '../types/settings'
|
||||
import type { PermissionMode, EffortLevel, ModelInfo, ThemeMode, WebSearchSettings } from '../types/settings'
|
||||
import type { Locale } from '../i18n'
|
||||
import { useUIStore } from './uiStore'
|
||||
|
||||
@ -25,6 +25,7 @@ type SettingsStore = {
|
||||
locale: Locale
|
||||
theme: ThemeMode
|
||||
skipWebFetchPreflight: boolean
|
||||
webSearch: WebSearchSettings
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
|
||||
@ -36,6 +37,7 @@ type SettingsStore = {
|
||||
setLocale: (locale: Locale) => void
|
||||
setTheme: (theme: ThemeMode) => Promise<void>
|
||||
setSkipWebFetchPreflight: (enabled: boolean) => Promise<void>
|
||||
setWebSearch: (settings: WebSearchSettings) => Promise<void>
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
@ -48,6 +50,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
locale: getStoredLocale(),
|
||||
theme: useUIStore.getState().theme,
|
||||
skipWebFetchPreflight: true,
|
||||
webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
@ -72,6 +75,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
thinkingEnabled: userSettings.alwaysThinkingEnabled !== false,
|
||||
theme,
|
||||
skipWebFetchPreflight: userSettings.skipWebFetchPreflight !== false,
|
||||
webSearch: normalizeWebSearchSettings(userSettings.webSearch),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
@ -145,4 +149,23 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
set({ skipWebFetchPreflight: prev })
|
||||
}
|
||||
},
|
||||
|
||||
setWebSearch: async (webSearch) => {
|
||||
const prev = get().webSearch
|
||||
const next = normalizeWebSearchSettings(webSearch)
|
||||
set({ webSearch: next })
|
||||
try {
|
||||
await settingsApi.updateUser({ webSearch: next })
|
||||
} catch {
|
||||
set({ webSearch: prev })
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
function normalizeWebSearchSettings(settings: WebSearchSettings | undefined): WebSearchSettings {
|
||||
return {
|
||||
mode: settings?.mode ?? 'auto',
|
||||
tavilyApiKey: settings?.tavilyApiKey ?? '',
|
||||
braveApiKey: settings?.braveApiKey ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,13 @@ export type PermissionMode = 'default' | 'acceptEdits' | 'plan' | 'bypassPermiss
|
||||
|
||||
export type EffortLevel = 'low' | 'medium' | 'high' | 'max'
|
||||
export type ThemeMode = 'light' | 'dark'
|
||||
export type WebSearchMode = 'auto' | 'anthropic' | 'tavily' | 'brave' | 'disabled'
|
||||
|
||||
export type WebSearchSettings = {
|
||||
mode?: WebSearchMode
|
||||
tavilyApiKey?: string
|
||||
braveApiKey?: string
|
||||
}
|
||||
|
||||
export type ModelInfo = {
|
||||
id: string
|
||||
@ -20,5 +27,6 @@ export type UserSettings = {
|
||||
permissionMode?: PermissionMode
|
||||
theme?: ThemeMode
|
||||
skipWebFetchPreflight?: boolean
|
||||
webSearch?: WebSearchSettings
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
@ -2,18 +2,32 @@ import type {
|
||||
BetaContentBlock,
|
||||
BetaWebSearchTool20250305,
|
||||
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import { getAPIProvider } from 'src/utils/model/providers.js'
|
||||
import type { PermissionResult } from 'src/utils/permissions/PermissionResult.js'
|
||||
import { z } from 'zod/v4'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
|
||||
import { queryModelWithStreaming } from '../../services/api/claude.js'
|
||||
import { buildTool, type ToolDef } from '../../Tool.js'
|
||||
import {
|
||||
buildTool,
|
||||
type ToolCallProgress,
|
||||
type ToolDef,
|
||||
type ToolUseContext,
|
||||
} from '../../Tool.js'
|
||||
import { lazySchema } from '../../utils/lazySchema.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { createUserMessage } from '../../utils/messages.js'
|
||||
import { getMainLoopModel, getSmallFastModel } from '../../utils/model/model.js'
|
||||
import { jsonParse, jsonStringify } from '../../utils/slowOperations.js'
|
||||
import { asSystemPrompt } from '../../utils/systemPromptType.js'
|
||||
import {
|
||||
getApiKeyForProvider,
|
||||
getFallbackProvider,
|
||||
isWebSearchEnabledForModel,
|
||||
makeWebSearchUnavailableOutput,
|
||||
markAnthropicNativeUnsupported,
|
||||
resolveWebSearchProvider,
|
||||
searchWithExternalProvider,
|
||||
shouldFallbackFromNativeError,
|
||||
} from './backend.js'
|
||||
import { getWebSearchPrompt, WEB_SEARCH_TOOL_NAME } from './prompt.js'
|
||||
import {
|
||||
getToolUseSummary,
|
||||
@ -37,7 +51,7 @@ const inputSchema = lazySchema(() =>
|
||||
)
|
||||
type InputSchema = ReturnType<typeof inputSchema>
|
||||
|
||||
type Input = z.infer<InputSchema>
|
||||
export type Input = z.infer<InputSchema>
|
||||
|
||||
const searchResultSchema = lazySchema(() => {
|
||||
const searchHitSchema = z.object({
|
||||
@ -166,30 +180,7 @@ export const WebSearchTool = buildTool({
|
||||
return summary ? `Searching for ${summary}` : 'Searching the web'
|
||||
},
|
||||
isEnabled() {
|
||||
const provider = getAPIProvider()
|
||||
const model = getMainLoopModel()
|
||||
|
||||
// Enable for firstParty
|
||||
if (provider === 'firstParty') {
|
||||
return true
|
||||
}
|
||||
|
||||
// Enable for Vertex AI with supported models (Claude 4.0+)
|
||||
if (provider === 'vertex') {
|
||||
const supportsWebSearch =
|
||||
model.includes('claude-opus-4') ||
|
||||
model.includes('claude-sonnet-4') ||
|
||||
model.includes('claude-haiku-4')
|
||||
|
||||
return supportsWebSearch
|
||||
}
|
||||
|
||||
// Foundry only ships models that already support Web Search
|
||||
if (provider === 'foundry') {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
return isWebSearchEnabledForModel(getMainLoopModel())
|
||||
},
|
||||
get inputSchema(): InputSchema {
|
||||
return inputSchema()
|
||||
@ -254,149 +245,104 @@ export const WebSearchTool = buildTool({
|
||||
async call(input, context, _canUseTool, _parentMessage, onProgress) {
|
||||
const startTime = performance.now()
|
||||
const { query } = input
|
||||
const userMessage = createUserMessage({
|
||||
content: 'Perform a web search for the query: ' + query,
|
||||
})
|
||||
const toolSchema = makeToolSchema(input)
|
||||
const model = context.options.mainLoopModel
|
||||
const resolved = resolveWebSearchProvider(model)
|
||||
|
||||
const useHaiku = getFeatureValue_CACHED_MAY_BE_STALE(
|
||||
'tengu_plum_vx3',
|
||||
false,
|
||||
)
|
||||
|
||||
const appState = context.getAppState()
|
||||
const queryStream = queryModelWithStreaming({
|
||||
messages: [userMessage],
|
||||
systemPrompt: asSystemPrompt([
|
||||
'You are an assistant for performing a web search tool use',
|
||||
]),
|
||||
thinkingConfig: useHaiku
|
||||
? { type: 'disabled' as const }
|
||||
: context.options.thinkingConfig,
|
||||
tools: [],
|
||||
signal: context.abortController.signal,
|
||||
options: {
|
||||
getToolPermissionContext: async () => appState.toolPermissionContext,
|
||||
model: useHaiku ? getSmallFastModel() : context.options.mainLoopModel,
|
||||
toolChoice: useHaiku ? { type: 'tool', name: 'web_search' } : undefined,
|
||||
isNonInteractiveSession: context.options.isNonInteractiveSession,
|
||||
hasAppendSystemPrompt: !!context.options.appendSystemPrompt,
|
||||
extraToolSchemas: [toolSchema],
|
||||
querySource: 'web_search_tool',
|
||||
agents: context.options.agentDefinitions.activeAgents,
|
||||
mcpTools: [],
|
||||
agentId: context.agentId,
|
||||
effortValue: appState.effortValue,
|
||||
},
|
||||
})
|
||||
|
||||
const allContentBlocks: BetaContentBlock[] = []
|
||||
let currentToolUseId = null
|
||||
let currentToolUseJson = ''
|
||||
let progressCounter = 0
|
||||
const toolUseQueries = new Map() // Map of tool_use_id to query
|
||||
|
||||
for await (const event of queryStream) {
|
||||
if (event.type === 'assistant') {
|
||||
allContentBlocks.push(...event.message.content)
|
||||
continue
|
||||
}
|
||||
|
||||
// Track tool use ID when server_tool_use starts
|
||||
if (
|
||||
event.type === 'stream_event' &&
|
||||
event.event?.type === 'content_block_start'
|
||||
) {
|
||||
const contentBlock = event.event.content_block
|
||||
if (contentBlock && contentBlock.type === 'server_tool_use') {
|
||||
currentToolUseId = contentBlock.id
|
||||
currentToolUseJson = ''
|
||||
// Note: The ServerToolUseBlock doesn't contain input.query
|
||||
// The actual query comes through input_json_delta events
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Accumulate JSON for current tool use
|
||||
if (
|
||||
currentToolUseId &&
|
||||
event.type === 'stream_event' &&
|
||||
event.event?.type === 'content_block_delta'
|
||||
) {
|
||||
const delta = event.event.delta
|
||||
if (delta?.type === 'input_json_delta' && delta.partial_json) {
|
||||
currentToolUseJson += delta.partial_json
|
||||
|
||||
// Try to extract query from partial JSON for progress updates
|
||||
try {
|
||||
// Look for a complete query field
|
||||
const queryMatch = currentToolUseJson.match(
|
||||
/"query"\s*:\s*"((?:[^"\\]|\\.)*)"/,
|
||||
)
|
||||
if (queryMatch && queryMatch[1]) {
|
||||
// The regex properly handles escaped characters
|
||||
const query = jsonParse('"' + queryMatch[1] + '"')
|
||||
|
||||
if (
|
||||
!toolUseQueries.has(currentToolUseId) ||
|
||||
toolUseQueries.get(currentToolUseId) !== query
|
||||
) {
|
||||
toolUseQueries.set(currentToolUseId, query)
|
||||
progressCounter++
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
toolUseID: `search-progress-${progressCounter}`,
|
||||
data: {
|
||||
type: 'query_update',
|
||||
query,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore parsing errors for partial JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Yield progress when search results come in
|
||||
if (
|
||||
event.type === 'stream_event' &&
|
||||
event.event?.type === 'content_block_start'
|
||||
) {
|
||||
const contentBlock = event.event.content_block
|
||||
if (contentBlock && contentBlock.type === 'web_search_tool_result') {
|
||||
// Get the actual query that was used for this search
|
||||
const toolUseId = contentBlock.tool_use_id
|
||||
const actualQuery = toolUseQueries.get(toolUseId) || query
|
||||
const content = contentBlock.content
|
||||
|
||||
progressCounter++
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
toolUseID: toolUseId || `search-progress-${progressCounter}`,
|
||||
data: {
|
||||
type: 'search_results_received',
|
||||
resultCount: Array.isArray(content) ? content.length : 0,
|
||||
query: actualQuery,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
if (resolved.provider === 'disabled') {
|
||||
const durationSeconds = (performance.now() - startTime) / 1000
|
||||
return {
|
||||
data: makeWebSearchUnavailableOutput(
|
||||
query,
|
||||
durationSeconds,
|
||||
'Web search is not configured for this model. Use a Claude model for native web search or add a Tavily/Brave API key in Settings.',
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// Process the final result
|
||||
const endTime = performance.now()
|
||||
const durationSeconds = (endTime - startTime) / 1000
|
||||
if (resolved.provider === 'tavily' || resolved.provider === 'brave') {
|
||||
onProgress?.({
|
||||
toolUseID: `${resolved.provider}-web-search`,
|
||||
data: {
|
||||
type: 'query_update',
|
||||
query,
|
||||
},
|
||||
})
|
||||
const apiKey = getApiKeyForProvider(resolved.provider, resolved.settings)
|
||||
if (!apiKey) {
|
||||
const durationSeconds = (performance.now() - startTime) / 1000
|
||||
return {
|
||||
data: makeWebSearchUnavailableOutput(
|
||||
query,
|
||||
durationSeconds,
|
||||
`Web search provider ${resolved.provider} is selected but its API key is missing.`,
|
||||
),
|
||||
}
|
||||
}
|
||||
const data = await searchWithExternalProvider(
|
||||
resolved.provider,
|
||||
input,
|
||||
apiKey,
|
||||
context.abortController.signal,
|
||||
)
|
||||
onProgress?.({
|
||||
toolUseID: `${resolved.provider}-web-search`,
|
||||
data: {
|
||||
type: 'search_results_received',
|
||||
resultCount:
|
||||
typeof data.results[1] === 'object' ? data.results[1].content.length : 0,
|
||||
query,
|
||||
},
|
||||
})
|
||||
return { data }
|
||||
}
|
||||
|
||||
const data = makeOutputFromSearchResponse(
|
||||
allContentBlocks,
|
||||
query,
|
||||
durationSeconds,
|
||||
)
|
||||
return { data }
|
||||
try {
|
||||
return await callAnthropicNativeWebSearch(
|
||||
input,
|
||||
context,
|
||||
onProgress,
|
||||
startTime,
|
||||
)
|
||||
} catch (error) {
|
||||
if (!shouldFallbackFromNativeError(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
markAnthropicNativeUnsupported(model)
|
||||
const fallbackProvider = getFallbackProvider(resolved.settings)
|
||||
if (!fallbackProvider) {
|
||||
const durationSeconds = (performance.now() - startTime) / 1000
|
||||
logError(error instanceof Error ? error : new Error(String(error)))
|
||||
return {
|
||||
data: makeWebSearchUnavailableOutput(
|
||||
query,
|
||||
durationSeconds,
|
||||
'Native Anthropic web search failed for this endpoint, and no Tavily/Brave API key is configured for fallback.',
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const apiKey = getApiKeyForProvider(fallbackProvider, resolved.settings)
|
||||
if (!apiKey) {
|
||||
const durationSeconds = (performance.now() - startTime) / 1000
|
||||
return {
|
||||
data: makeWebSearchUnavailableOutput(
|
||||
query,
|
||||
durationSeconds,
|
||||
`Fallback provider ${fallbackProvider} is configured without an API key.`,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
logError(error instanceof Error ? error : new Error(String(error)))
|
||||
const data = await searchWithExternalProvider(
|
||||
fallbackProvider,
|
||||
input,
|
||||
apiKey,
|
||||
context.abortController.signal,
|
||||
)
|
||||
return { data }
|
||||
}
|
||||
},
|
||||
mapToolResultToToolResultBlockParam(output, toolUseID) {
|
||||
const { query, results } = output
|
||||
@ -433,3 +379,155 @@ export const WebSearchTool = buildTool({
|
||||
}
|
||||
},
|
||||
} satisfies ToolDef<InputSchema, Output, WebSearchProgress>)
|
||||
|
||||
async function callAnthropicNativeWebSearch(
|
||||
input: Input,
|
||||
context: ToolUseContext,
|
||||
onProgress: ToolCallProgress<WebSearchProgress> | undefined,
|
||||
startTime: number,
|
||||
) {
|
||||
const { query } = input
|
||||
const userMessage = createUserMessage({
|
||||
content: 'Perform a web search for the query: ' + query,
|
||||
})
|
||||
const toolSchema = makeToolSchema(input)
|
||||
|
||||
const useHaiku = getFeatureValue_CACHED_MAY_BE_STALE(
|
||||
'tengu_plum_vx3',
|
||||
false,
|
||||
)
|
||||
|
||||
const appState = context.getAppState()
|
||||
const queryStream = queryModelWithStreaming({
|
||||
messages: [userMessage],
|
||||
systemPrompt: asSystemPrompt([
|
||||
'You are an assistant for performing a web search tool use',
|
||||
]),
|
||||
thinkingConfig: useHaiku
|
||||
? { type: 'disabled' as const }
|
||||
: context.options.thinkingConfig,
|
||||
tools: [],
|
||||
signal: context.abortController.signal,
|
||||
options: {
|
||||
getToolPermissionContext: async () => appState.toolPermissionContext,
|
||||
model: useHaiku ? getSmallFastModel() : context.options.mainLoopModel,
|
||||
toolChoice: useHaiku ? { type: 'tool', name: 'web_search' } : undefined,
|
||||
isNonInteractiveSession: context.options.isNonInteractiveSession,
|
||||
hasAppendSystemPrompt: !!context.options.appendSystemPrompt,
|
||||
extraToolSchemas: [toolSchema],
|
||||
querySource: 'web_search_tool',
|
||||
agents: context.options.agentDefinitions.activeAgents,
|
||||
mcpTools: [],
|
||||
agentId: context.agentId,
|
||||
effortValue: appState.effortValue,
|
||||
},
|
||||
})
|
||||
|
||||
const allContentBlocks: BetaContentBlock[] = []
|
||||
let currentToolUseId: string | null = null
|
||||
let currentToolUseJson = ''
|
||||
let progressCounter = 0
|
||||
const toolUseQueries = new Map<string, string>()
|
||||
|
||||
for await (const event of queryStream) {
|
||||
if (event.type === 'assistant') {
|
||||
allContentBlocks.push(...event.message.content)
|
||||
continue
|
||||
}
|
||||
|
||||
// Track tool use ID when server_tool_use starts
|
||||
if (
|
||||
event.type === 'stream_event' &&
|
||||
event.event?.type === 'content_block_start'
|
||||
) {
|
||||
const contentBlock = event.event.content_block
|
||||
if (contentBlock && contentBlock.type === 'server_tool_use') {
|
||||
currentToolUseId = contentBlock.id
|
||||
currentToolUseJson = ''
|
||||
// Note: The ServerToolUseBlock doesn't contain input.query
|
||||
// The actual query comes through input_json_delta events
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Accumulate JSON for current tool use
|
||||
if (
|
||||
currentToolUseId &&
|
||||
event.type === 'stream_event' &&
|
||||
event.event?.type === 'content_block_delta'
|
||||
) {
|
||||
const delta = event.event.delta
|
||||
if (delta?.type === 'input_json_delta' && delta.partial_json) {
|
||||
currentToolUseJson += delta.partial_json
|
||||
|
||||
// Try to extract query from partial JSON for progress updates
|
||||
try {
|
||||
// Look for a complete query field
|
||||
const queryMatch = currentToolUseJson.match(
|
||||
/"query"\s*:\s*"((?:[^"\\]|\\.)*)"/,
|
||||
)
|
||||
if (queryMatch && queryMatch[1]) {
|
||||
// The regex properly handles escaped characters
|
||||
const parsedQuery = jsonParse('"' + queryMatch[1] + '"') as string
|
||||
|
||||
if (
|
||||
!toolUseQueries.has(currentToolUseId) ||
|
||||
toolUseQueries.get(currentToolUseId) !== parsedQuery
|
||||
) {
|
||||
toolUseQueries.set(currentToolUseId, parsedQuery)
|
||||
progressCounter++
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
toolUseID: `search-progress-${progressCounter}`,
|
||||
data: {
|
||||
type: 'query_update',
|
||||
query: parsedQuery,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore parsing errors for partial JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Yield progress when search results come in
|
||||
if (
|
||||
event.type === 'stream_event' &&
|
||||
event.event?.type === 'content_block_start'
|
||||
) {
|
||||
const contentBlock = event.event.content_block
|
||||
if (contentBlock && contentBlock.type === 'web_search_tool_result') {
|
||||
// Get the actual query that was used for this search
|
||||
const toolUseId = contentBlock.tool_use_id
|
||||
const actualQuery = toolUseQueries.get(toolUseId) || query
|
||||
const content = contentBlock.content
|
||||
|
||||
progressCounter++
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
toolUseID: toolUseId || `search-progress-${progressCounter}`,
|
||||
data: {
|
||||
type: 'search_results_received',
|
||||
resultCount: Array.isArray(content) ? content.length : 0,
|
||||
query: actualQuery,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process the final result
|
||||
const endTime = performance.now()
|
||||
const durationSeconds = (endTime - startTime) / 1000
|
||||
|
||||
const data = makeOutputFromSearchResponse(
|
||||
allContentBlocks,
|
||||
query,
|
||||
durationSeconds,
|
||||
)
|
||||
return { data }
|
||||
}
|
||||
|
||||
81
src/tools/WebSearchTool/backend.test.ts
Normal file
81
src/tools/WebSearchTool/backend.test.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
isLikelyClaudeModel,
|
||||
isWebSearchEnabledForModel,
|
||||
resolveWebSearchProvider,
|
||||
shouldFallbackFromNativeError,
|
||||
} from './backend.js'
|
||||
|
||||
describe('WebSearch backend resolver', () => {
|
||||
test('detects Claude models by model name instead of provider URL', () => {
|
||||
expect(isLikelyClaudeModel('claude-sonnet-4-5')).toBe(true)
|
||||
expect(isLikelyClaudeModel('anthropic/claude-3-7-sonnet')).toBe(true)
|
||||
expect(isLikelyClaudeModel('anthropic.claude-opus-4-1')).toBe(true)
|
||||
expect(isLikelyClaudeModel('MiniMax-M2.7-highspeed')).toBe(false)
|
||||
})
|
||||
|
||||
test('auto mode prefers native Anthropic web search for Claude model names', () => {
|
||||
expect(
|
||||
resolveWebSearchProvider('anthropic/claude-3-7-sonnet', {
|
||||
mode: 'auto',
|
||||
tavilyApiKey: 'tvly-key',
|
||||
braveApiKey: 'brave-key',
|
||||
}).provider,
|
||||
).toBe('anthropic')
|
||||
})
|
||||
|
||||
test('auto mode keeps WebSearch available for non-Claude models with fallback keys', () => {
|
||||
expect(
|
||||
resolveWebSearchProvider('gpt-5.4', {
|
||||
mode: 'auto',
|
||||
tavilyApiKey: 'tvly-key',
|
||||
braveApiKey: 'brave-key',
|
||||
}).provider,
|
||||
).toBe('tavily')
|
||||
|
||||
expect(
|
||||
resolveWebSearchProvider('gpt-5.4', {
|
||||
mode: 'auto',
|
||||
braveApiKey: 'brave-key',
|
||||
}).provider,
|
||||
).toBe('brave')
|
||||
})
|
||||
|
||||
test('explicit provider modes require their API key', () => {
|
||||
expect(resolveWebSearchProvider('gpt-5.4', { mode: 'tavily' }).provider).toBe(
|
||||
'disabled',
|
||||
)
|
||||
expect(
|
||||
resolveWebSearchProvider('gpt-5.4', {
|
||||
mode: 'brave',
|
||||
braveApiKey: 'brave-key',
|
||||
}).provider,
|
||||
).toBe('brave')
|
||||
})
|
||||
|
||||
test('isEnabled reflects native Claude or external fallback availability', () => {
|
||||
expect(isWebSearchEnabledForModel('claude-sonnet-4-5', { mode: 'auto' })).toBe(
|
||||
true,
|
||||
)
|
||||
expect(
|
||||
isWebSearchEnabledForModel('qwen3-coder', {
|
||||
mode: 'auto',
|
||||
tavilyApiKey: 'tvly-key',
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(isWebSearchEnabledForModel('qwen3-coder', { mode: 'auto' })).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('falls back on native tool schema/provider mismatch errors', () => {
|
||||
expect(
|
||||
shouldFallbackFromNativeError(
|
||||
new Error('422 Extra inputs are not permitted: web_search_20250305'),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(shouldFallbackFromNativeError(new Error('network timeout'))).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
})
|
||||
303
src/tools/WebSearchTool/backend.ts
Normal file
303
src/tools/WebSearchTool/backend.ts
Normal file
@ -0,0 +1,303 @@
|
||||
import { getSettings_DEPRECATED } from '../../utils/settings/settings.js'
|
||||
import type { SettingsJson } from '../../utils/settings/types.js'
|
||||
import type { Input, Output, SearchResult } from './WebSearchTool.js'
|
||||
|
||||
export type WebSearchMode =
|
||||
| 'auto'
|
||||
| 'anthropic'
|
||||
| 'tavily'
|
||||
| 'brave'
|
||||
| 'disabled'
|
||||
|
||||
export type WebSearchProvider = 'anthropic' | 'tavily' | 'brave' | 'disabled'
|
||||
|
||||
export type WebSearchSettings = {
|
||||
mode?: WebSearchMode
|
||||
tavilyApiKey?: string
|
||||
braveApiKey?: string
|
||||
}
|
||||
|
||||
export type ResolvedWebSearch = {
|
||||
provider: WebSearchProvider
|
||||
settings: WebSearchSettings
|
||||
}
|
||||
|
||||
type ExternalSearchHit = {
|
||||
title: string
|
||||
url: string
|
||||
}
|
||||
|
||||
const WEB_SEARCH_MODES = new Set<WebSearchMode>([
|
||||
'auto',
|
||||
'anthropic',
|
||||
'tavily',
|
||||
'brave',
|
||||
'disabled',
|
||||
])
|
||||
|
||||
const unsupportedNativeModels = new Set<string>()
|
||||
|
||||
export function isLikelyClaudeModel(model: string | undefined): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
}
|
||||
|
||||
return /(^|[/:._-])claude([/:._-]|$)/.test(model.toLowerCase())
|
||||
}
|
||||
|
||||
export function getConfiguredWebSearchSettings(
|
||||
settings: Pick<SettingsJson, 'webSearch'> = getSettings_DEPRECATED(),
|
||||
): WebSearchSettings {
|
||||
const raw = settings.webSearch
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return {}
|
||||
}
|
||||
|
||||
const modeCandidate = raw.mode ?? 'auto'
|
||||
|
||||
return {
|
||||
mode: WEB_SEARCH_MODES.has(modeCandidate) ? modeCandidate : 'auto',
|
||||
tavilyApiKey: normalizeApiKey(raw.tavilyApiKey),
|
||||
braveApiKey: normalizeApiKey(raw.braveApiKey),
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveWebSearchProvider(
|
||||
model: string | undefined,
|
||||
settings: WebSearchSettings = getConfiguredWebSearchSettings(),
|
||||
): ResolvedWebSearch {
|
||||
const mode = settings.mode ?? 'auto'
|
||||
|
||||
if (mode === 'disabled') {
|
||||
return { provider: 'disabled', settings }
|
||||
}
|
||||
|
||||
if (mode === 'tavily') {
|
||||
return { provider: settings.tavilyApiKey ? 'tavily' : 'disabled', settings }
|
||||
}
|
||||
|
||||
if (mode === 'brave') {
|
||||
return { provider: settings.braveApiKey ? 'brave' : 'disabled', settings }
|
||||
}
|
||||
|
||||
if (mode === 'anthropic') {
|
||||
return {
|
||||
provider: canUseAnthropicNativeWebSearch(model) ? 'anthropic' : 'disabled',
|
||||
settings,
|
||||
}
|
||||
}
|
||||
|
||||
if (canUseAnthropicNativeWebSearch(model)) {
|
||||
return { provider: 'anthropic', settings }
|
||||
}
|
||||
|
||||
if (settings.tavilyApiKey) {
|
||||
return { provider: 'tavily', settings }
|
||||
}
|
||||
|
||||
if (settings.braveApiKey) {
|
||||
return { provider: 'brave', settings }
|
||||
}
|
||||
|
||||
return { provider: 'disabled', settings }
|
||||
}
|
||||
|
||||
export function isWebSearchEnabledForModel(
|
||||
model: string | undefined,
|
||||
settings: WebSearchSettings = getConfiguredWebSearchSettings(),
|
||||
): boolean {
|
||||
return resolveWebSearchProvider(model, settings).provider !== 'disabled'
|
||||
}
|
||||
|
||||
export function shouldFallbackFromNativeError(error: unknown): boolean {
|
||||
const message = String(error instanceof Error ? error.message : error)
|
||||
return (
|
||||
/\b(400|422)\b/.test(message) ||
|
||||
/web_search|server tool|tool schema|input_schema|extra input|unsupported/i.test(
|
||||
message,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function markAnthropicNativeUnsupported(model: string | undefined): void {
|
||||
const key = normalizeModelKey(model)
|
||||
if (key) {
|
||||
unsupportedNativeModels.add(key)
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchWithExternalProvider(
|
||||
provider: Exclude<WebSearchProvider, 'anthropic' | 'disabled'>,
|
||||
input: Input,
|
||||
apiKey: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<Output> {
|
||||
const startTime = performance.now()
|
||||
const hits =
|
||||
provider === 'tavily'
|
||||
? await searchWithTavily(input, apiKey, signal)
|
||||
: await searchWithBrave(input, apiKey, signal)
|
||||
const durationSeconds = (performance.now() - startTime) / 1000
|
||||
|
||||
return makeExternalSearchOutput(provider, input.query, hits, durationSeconds)
|
||||
}
|
||||
|
||||
export function getFallbackProvider(
|
||||
settings: WebSearchSettings,
|
||||
): Exclude<WebSearchProvider, 'anthropic' | 'disabled'> | null {
|
||||
if (settings.tavilyApiKey) {
|
||||
return 'tavily'
|
||||
}
|
||||
if (settings.braveApiKey) {
|
||||
return 'brave'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function getApiKeyForProvider(
|
||||
provider: Exclude<WebSearchProvider, 'anthropic' | 'disabled'>,
|
||||
settings: WebSearchSettings,
|
||||
): string | null {
|
||||
return provider === 'tavily'
|
||||
? settings.tavilyApiKey ?? null
|
||||
: settings.braveApiKey ?? null
|
||||
}
|
||||
|
||||
export function makeWebSearchUnavailableOutput(
|
||||
query: string,
|
||||
durationSeconds: number,
|
||||
reason: string,
|
||||
): Output {
|
||||
return {
|
||||
query,
|
||||
results: [reason],
|
||||
durationSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
function canUseAnthropicNativeWebSearch(model: string | undefined): boolean {
|
||||
const key = normalizeModelKey(model)
|
||||
return isLikelyClaudeModel(model) && (!key || !unsupportedNativeModels.has(key))
|
||||
}
|
||||
|
||||
function normalizeModelKey(model: string | undefined): string | null {
|
||||
const trimmed = model?.trim().toLowerCase()
|
||||
return trimmed || null
|
||||
}
|
||||
|
||||
function normalizeApiKey(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length ? trimmed : undefined
|
||||
}
|
||||
|
||||
async function searchWithTavily(
|
||||
input: Input,
|
||||
apiKey: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<ExternalSearchHit[]> {
|
||||
const response = await fetch('https://api.tavily.com/search', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: input.query,
|
||||
max_results: 8,
|
||||
search_depth: 'basic',
|
||||
include_answer: false,
|
||||
include_domains: input.allowed_domains,
|
||||
exclude_domains: input.blocked_domains,
|
||||
}),
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Tavily search failed: ${response.status} ${await readErrorBody(response)}`)
|
||||
}
|
||||
|
||||
const body = (await response.json()) as {
|
||||
results?: Array<{ title?: unknown; url?: unknown }>
|
||||
}
|
||||
|
||||
return (body.results ?? [])
|
||||
.map(hit => normalizeHit(hit.title, hit.url))
|
||||
.filter((hit): hit is ExternalSearchHit => hit != null)
|
||||
}
|
||||
|
||||
async function searchWithBrave(
|
||||
input: Input,
|
||||
apiKey: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<ExternalSearchHit[]> {
|
||||
const url = new URL('https://api.search.brave.com/res/v1/web/search')
|
||||
url.searchParams.set('q', applyDomainFiltersToQuery(input))
|
||||
url.searchParams.set('count', '8')
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Subscription-Token': apiKey,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Brave search failed: ${response.status} ${await readErrorBody(response)}`)
|
||||
}
|
||||
|
||||
const body = (await response.json()) as {
|
||||
web?: { results?: Array<{ title?: unknown; url?: unknown }> }
|
||||
}
|
||||
|
||||
return (body.web?.results ?? [])
|
||||
.map(hit => normalizeHit(hit.title, hit.url))
|
||||
.filter((hit): hit is ExternalSearchHit => hit != null)
|
||||
}
|
||||
|
||||
function applyDomainFiltersToQuery(input: Input): string {
|
||||
const allowed = input.allowed_domains?.filter(Boolean) ?? []
|
||||
const blocked = input.blocked_domains?.filter(Boolean) ?? []
|
||||
const allowedClause = allowed.length
|
||||
? `(${allowed.map(domain => `site:${domain}`).join(' OR ')}) `
|
||||
: ''
|
||||
const blockedClause = blocked.length
|
||||
? `${blocked.map(domain => `-site:${domain}`).join(' ')} `
|
||||
: ''
|
||||
|
||||
return `${allowedClause}${blockedClause}${input.query}`.trim()
|
||||
}
|
||||
|
||||
function normalizeHit(title: unknown, url: unknown): ExternalSearchHit | null {
|
||||
if (typeof title !== 'string' || typeof url !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
return { title, url }
|
||||
}
|
||||
|
||||
function makeExternalSearchOutput(
|
||||
provider: Exclude<WebSearchProvider, 'anthropic' | 'disabled'>,
|
||||
query: string,
|
||||
hits: ExternalSearchHit[],
|
||||
durationSeconds: number,
|
||||
): Output {
|
||||
const result: SearchResult = {
|
||||
tool_use_id: `${provider}-web-search`,
|
||||
content: hits,
|
||||
}
|
||||
|
||||
return {
|
||||
query,
|
||||
results: [`Search provider: ${provider}`, result],
|
||||
durationSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
async function readErrorBody(response: Response): Promise<string> {
|
||||
const text = await response.text().catch(() => '')
|
||||
return text.slice(0, 500)
|
||||
}
|
||||
@ -652,6 +652,25 @@ export const SettingsSchema = lazySchema(() =>
|
||||
.describe(
|
||||
'Skip the WebFetch blocklist check for enterprise environments with restrictive security policies',
|
||||
),
|
||||
webSearch: z
|
||||
.object({
|
||||
mode: z
|
||||
.enum(['auto', 'anthropic', 'tavily', 'brave', 'disabled'])
|
||||
.optional()
|
||||
.describe(
|
||||
'WebSearch backend selection. auto uses native Claude web search for Claude model names, then Tavily, then Brave.',
|
||||
),
|
||||
tavilyApiKey: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Tavily API key for WebSearch fallback'),
|
||||
braveApiKey: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Brave Search API key for WebSearch fallback'),
|
||||
})
|
||||
.optional()
|
||||
.describe('Configures native and external WebSearch backends'),
|
||||
sandbox: SandboxSettingsSchema().optional(),
|
||||
feedbackSurveyRate: z
|
||||
.number()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user