mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-26 15:03:34 +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',
|
locale: 'en',
|
||||||
thinkingEnabled: true,
|
thinkingEnabled: true,
|
||||||
skipWebFetchPreflight: true,
|
skipWebFetchPreflight: true,
|
||||||
|
webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' },
|
||||||
setThinkingEnabled: vi.fn().mockImplementation(async (enabled: boolean) => {
|
setThinkingEnabled: vi.fn().mockImplementation(async (enabled: boolean) => {
|
||||||
useSettingsStore.setState({ thinkingEnabled: enabled })
|
useSettingsStore.setState({ thinkingEnabled: enabled })
|
||||||
}),
|
}),
|
||||||
setSkipWebFetchPreflight: vi.fn().mockImplementation(async (enabled: boolean) => {
|
setSkipWebFetchPreflight: vi.fn().mockImplementation(async (enabled: boolean) => {
|
||||||
useSettingsStore.setState({ skipWebFetchPreflight: enabled })
|
useSettingsStore.setState({ skipWebFetchPreflight: enabled })
|
||||||
}),
|
}),
|
||||||
|
setWebSearch: vi.fn().mockImplementation(async (webSearch) => {
|
||||||
|
useSettingsStore.setState({ webSearch })
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
useUIStore.setState({ pendingSettingsTab: null })
|
useUIStore.setState({ pendingSettingsTab: null })
|
||||||
@ -166,6 +170,39 @@ describe('Settings > General tab', () => {
|
|||||||
expect(useSettingsStore.getState().setThinkingEnabled).toHaveBeenCalledWith(false)
|
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', () => {
|
it('keeps extension tabs available alongside the terminal tab', () => {
|
||||||
render(<Settings />)
|
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.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.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.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 Session ──────────────────────────────────────
|
||||||
'empty.title': 'New session',
|
'empty.title': 'New session',
|
||||||
|
|||||||
@ -492,6 +492,23 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'settings.general.webFetchPreflightDescription': '桌面端默认跳过 Claude 的域名预检,避免第三方服务商或受限网络下出现误报失败。',
|
'settings.general.webFetchPreflightDescription': '桌面端默认跳过 Claude 的域名预检,避免第三方服务商或受限网络下出现误报失败。',
|
||||||
'settings.general.webFetchPreflightEnabled': '跳过 WebFetch 域名预检',
|
'settings.general.webFetchPreflightEnabled': '跳过 WebFetch 域名预检',
|
||||||
'settings.general.webFetchPreflightHint': '只有在你明确需要恢复上游默认安全预检时,才建议关闭这个选项。',
|
'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 Session ──────────────────────────────────────
|
||||||
'empty.title': '新建会话',
|
'empty.title': '新建会话',
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { ConfirmDialog } from '../components/shared/ConfirmDialog'
|
|||||||
import { Input } from '../components/shared/Input'
|
import { Input } from '../components/shared/Input'
|
||||||
import { Button } from '../components/shared/Button'
|
import { Button } from '../components/shared/Button'
|
||||||
import { Dropdown } from '../components/shared/Dropdown'
|
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 { Locale } from '../i18n'
|
||||||
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat } from '../types/provider'
|
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat } from '../types/provider'
|
||||||
import type { ProviderPreset } from '../types/providerPreset'
|
import type { ProviderPreset } from '../types/providerPreset'
|
||||||
@ -880,8 +880,16 @@ function GeneralSettings() {
|
|||||||
setTheme,
|
setTheme,
|
||||||
skipWebFetchPreflight,
|
skipWebFetchPreflight,
|
||||||
setSkipWebFetchPreflight,
|
setSkipWebFetchPreflight,
|
||||||
|
webSearch,
|
||||||
|
setWebSearch,
|
||||||
} = useSettingsStore()
|
} = useSettingsStore()
|
||||||
const t = useTranslation()
|
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> = {
|
const EFFORT_LABELS: Record<EffortLevel, string> = {
|
||||||
low: t('settings.general.effort.low'),
|
low: t('settings.general.effort.low'),
|
||||||
@ -900,6 +908,14 @@ function GeneralSettings() {
|
|||||||
{ value: 'dark', label: t('settings.general.appearance.dark') },
|
{ 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 (
|
return (
|
||||||
<div className="max-w-xl">
|
<div className="max-w-xl">
|
||||||
{/* Appearance selector */}
|
{/* Appearance selector */}
|
||||||
@ -1002,6 +1018,96 @@ function GeneralSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { settingsApi } from '../api/settings'
|
import { settingsApi } from '../api/settings'
|
||||||
import { modelsApi } from '../api/models'
|
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 type { Locale } from '../i18n'
|
||||||
import { useUIStore } from './uiStore'
|
import { useUIStore } from './uiStore'
|
||||||
|
|
||||||
@ -25,6 +25,7 @@ type SettingsStore = {
|
|||||||
locale: Locale
|
locale: Locale
|
||||||
theme: ThemeMode
|
theme: ThemeMode
|
||||||
skipWebFetchPreflight: boolean
|
skipWebFetchPreflight: boolean
|
||||||
|
webSearch: WebSearchSettings
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
|
|
||||||
@ -36,6 +37,7 @@ type SettingsStore = {
|
|||||||
setLocale: (locale: Locale) => void
|
setLocale: (locale: Locale) => void
|
||||||
setTheme: (theme: ThemeMode) => Promise<void>
|
setTheme: (theme: ThemeMode) => Promise<void>
|
||||||
setSkipWebFetchPreflight: (enabled: boolean) => Promise<void>
|
setSkipWebFetchPreflight: (enabled: boolean) => Promise<void>
|
||||||
|
setWebSearch: (settings: WebSearchSettings) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||||
@ -48,6 +50,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
|||||||
locale: getStoredLocale(),
|
locale: getStoredLocale(),
|
||||||
theme: useUIStore.getState().theme,
|
theme: useUIStore.getState().theme,
|
||||||
skipWebFetchPreflight: true,
|
skipWebFetchPreflight: true,
|
||||||
|
webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' },
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
@ -72,6 +75,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
|||||||
thinkingEnabled: userSettings.alwaysThinkingEnabled !== false,
|
thinkingEnabled: userSettings.alwaysThinkingEnabled !== false,
|
||||||
theme,
|
theme,
|
||||||
skipWebFetchPreflight: userSettings.skipWebFetchPreflight !== false,
|
skipWebFetchPreflight: userSettings.skipWebFetchPreflight !== false,
|
||||||
|
webSearch: normalizeWebSearchSettings(userSettings.webSearch),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
})
|
})
|
||||||
@ -145,4 +149,23 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
|||||||
set({ skipWebFetchPreflight: prev })
|
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 EffortLevel = 'low' | 'medium' | 'high' | 'max'
|
||||||
export type ThemeMode = 'light' | 'dark'
|
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 = {
|
export type ModelInfo = {
|
||||||
id: string
|
id: string
|
||||||
@ -20,5 +27,6 @@ export type UserSettings = {
|
|||||||
permissionMode?: PermissionMode
|
permissionMode?: PermissionMode
|
||||||
theme?: ThemeMode
|
theme?: ThemeMode
|
||||||
skipWebFetchPreflight?: boolean
|
skipWebFetchPreflight?: boolean
|
||||||
|
webSearch?: WebSearchSettings
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,18 +2,32 @@ import type {
|
|||||||
BetaContentBlock,
|
BetaContentBlock,
|
||||||
BetaWebSearchTool20250305,
|
BetaWebSearchTool20250305,
|
||||||
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
} 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 type { PermissionResult } from 'src/utils/permissions/PermissionResult.js'
|
||||||
import { z } from 'zod/v4'
|
import { z } from 'zod/v4'
|
||||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
|
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
|
||||||
import { queryModelWithStreaming } from '../../services/api/claude.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 { lazySchema } from '../../utils/lazySchema.js'
|
||||||
import { logError } from '../../utils/log.js'
|
import { logError } from '../../utils/log.js'
|
||||||
import { createUserMessage } from '../../utils/messages.js'
|
import { createUserMessage } from '../../utils/messages.js'
|
||||||
import { getMainLoopModel, getSmallFastModel } from '../../utils/model/model.js'
|
import { getMainLoopModel, getSmallFastModel } from '../../utils/model/model.js'
|
||||||
import { jsonParse, jsonStringify } from '../../utils/slowOperations.js'
|
import { jsonParse, jsonStringify } from '../../utils/slowOperations.js'
|
||||||
import { asSystemPrompt } from '../../utils/systemPromptType.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 { getWebSearchPrompt, WEB_SEARCH_TOOL_NAME } from './prompt.js'
|
||||||
import {
|
import {
|
||||||
getToolUseSummary,
|
getToolUseSummary,
|
||||||
@ -37,7 +51,7 @@ const inputSchema = lazySchema(() =>
|
|||||||
)
|
)
|
||||||
type InputSchema = ReturnType<typeof inputSchema>
|
type InputSchema = ReturnType<typeof inputSchema>
|
||||||
|
|
||||||
type Input = z.infer<InputSchema>
|
export type Input = z.infer<InputSchema>
|
||||||
|
|
||||||
const searchResultSchema = lazySchema(() => {
|
const searchResultSchema = lazySchema(() => {
|
||||||
const searchHitSchema = z.object({
|
const searchHitSchema = z.object({
|
||||||
@ -166,30 +180,7 @@ export const WebSearchTool = buildTool({
|
|||||||
return summary ? `Searching for ${summary}` : 'Searching the web'
|
return summary ? `Searching for ${summary}` : 'Searching the web'
|
||||||
},
|
},
|
||||||
isEnabled() {
|
isEnabled() {
|
||||||
const provider = getAPIProvider()
|
return isWebSearchEnabledForModel(getMainLoopModel())
|
||||||
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
|
|
||||||
},
|
},
|
||||||
get inputSchema(): InputSchema {
|
get inputSchema(): InputSchema {
|
||||||
return inputSchema()
|
return inputSchema()
|
||||||
@ -254,149 +245,104 @@ export const WebSearchTool = buildTool({
|
|||||||
async call(input, context, _canUseTool, _parentMessage, onProgress) {
|
async call(input, context, _canUseTool, _parentMessage, onProgress) {
|
||||||
const startTime = performance.now()
|
const startTime = performance.now()
|
||||||
const { query } = input
|
const { query } = input
|
||||||
const userMessage = createUserMessage({
|
const model = context.options.mainLoopModel
|
||||||
content: 'Perform a web search for the query: ' + query,
|
const resolved = resolveWebSearchProvider(model)
|
||||||
})
|
|
||||||
const toolSchema = makeToolSchema(input)
|
|
||||||
|
|
||||||
const useHaiku = getFeatureValue_CACHED_MAY_BE_STALE(
|
if (resolved.provider === 'disabled') {
|
||||||
'tengu_plum_vx3',
|
const durationSeconds = (performance.now() - startTime) / 1000
|
||||||
false,
|
return {
|
||||||
)
|
data: makeWebSearchUnavailableOutput(
|
||||||
|
query,
|
||||||
const appState = context.getAppState()
|
durationSeconds,
|
||||||
const queryStream = queryModelWithStreaming({
|
'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.',
|
||||||
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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process the final result
|
if (resolved.provider === 'tavily' || resolved.provider === 'brave') {
|
||||||
const endTime = performance.now()
|
onProgress?.({
|
||||||
const durationSeconds = (endTime - startTime) / 1000
|
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(
|
try {
|
||||||
allContentBlocks,
|
return await callAnthropicNativeWebSearch(
|
||||||
query,
|
input,
|
||||||
durationSeconds,
|
context,
|
||||||
)
|
onProgress,
|
||||||
return { data }
|
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) {
|
mapToolResultToToolResultBlockParam(output, toolUseID) {
|
||||||
const { query, results } = output
|
const { query, results } = output
|
||||||
@ -433,3 +379,155 @@ export const WebSearchTool = buildTool({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
} satisfies ToolDef<InputSchema, Output, WebSearchProgress>)
|
} 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(
|
.describe(
|
||||||
'Skip the WebFetch blocklist check for enterprise environments with restrictive security policies',
|
'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(),
|
sandbox: SandboxSettingsSchema().optional(),
|
||||||
feedbackSurveyRate: z
|
feedbackSurveyRate: z
|
||||||
.number()
|
.number()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user