From 187f5bda1252c8a146b31c16b35742629b309f8a Mon Sep 17 00:00:00 2001 From: Joseph Mpo Yeti Date: Thu, 9 Apr 2026 16:07:13 +0100 Subject: [PATCH 01/26] Added search tool support for Claude --- app/api/web-search/route.ts | 50 ++- app/generation-preview/page.tsx | 16 +- app/page.tsx | 24 +- components/generation/generation-toolbar.tsx | 283 +++++++++---- components/settings/index.tsx | 8 +- components/settings/tool-edit-dialog.tsx | 65 +++ .../settings/web-search-model-dialog.tsx | 174 ++++++++ components/settings/web-search-settings.tsx | 379 ++++++++++++++++-- lib/i18n/settings.ts | 58 ++- lib/server/api-response.ts | 1 + lib/server/classroom-generation.ts | 50 ++- lib/server/provider-config.ts | 11 +- lib/store/settings.ts | 133 +++++- lib/types/web-search.ts | 2 +- lib/web-search/claude.ts | 170 ++++++++ lib/web-search/constants.ts | 18 + lib/web-search/tavily.ts | 7 +- lib/web-search/types.ts | 8 +- public/logos/tavily.svg | 6 + tests/server/provider-config.test.ts | 22 +- tests/store/settings-web-search.test.ts | 142 +++++++ tests/web-search/claude.test.ts | 281 +++++++++++++ 22 files changed, 1711 insertions(+), 197 deletions(-) create mode 100644 components/settings/tool-edit-dialog.tsx create mode 100644 components/settings/web-search-model-dialog.tsx create mode 100644 lib/web-search/claude.ts create mode 100644 public/logos/tavily.svg create mode 100644 tests/store/settings-web-search.test.ts create mode 100644 tests/web-search/claude.test.ts diff --git a/app/api/web-search/route.ts b/app/api/web-search/route.ts index 53e6d2e0e..85e2dfeea 100644 --- a/app/api/web-search/route.ts +++ b/app/api/web-search/route.ts @@ -2,12 +2,14 @@ * Web Search API * * POST /api/web-search - * Simple JSON request/response using Tavily search. + * Supports multiple search providers (Tavily, Claude). */ import { NextRequest } from 'next/server'; import { callLLM } from '@/lib/ai/llm'; import { searchWithTavily, formatSearchResultsAsContext } from '@/lib/web-search/tavily'; +import { searchWithClaude } from '@/lib/web-search/claude'; +import { WEB_SEARCH_PROVIDERS } from '@/lib/web-search/constants'; import { resolveWebSearchApiKey } from '@/lib/server/provider-config'; import { createLogger } from '@/lib/logger'; import { apiError, apiSuccess } from '@/lib/server/api-response'; @@ -17,6 +19,7 @@ import { } from '@/lib/server/search-query-builder'; import { resolveModelFromHeaders } from '@/lib/server/resolve-model'; import type { AICallFn } from '@/lib/generation/pipeline-types'; +import type { WebSearchProviderId } from '@/lib/web-search/types'; const log = createLogger('WebSearch'); @@ -28,23 +31,43 @@ export async function POST(req: NextRequest) { query: requestQuery, pdfText, apiKey: clientApiKey, + providerId: requestProviderId, + providerConfig, } = body as { query?: string; pdfText?: string; apiKey?: string; + providerId?: WebSearchProviderId; + providerConfig?: { + modelId?: string; + baseUrl?: string; + tools?: Array<{ type: string; name: string }>; + }; }; query = requestQuery; + // Default to tavily if no provider specified, but only if we have a valid provider + const providerId: WebSearchProviderId | null = requestProviderId ?? null; + if (!query || !query.trim()) { return apiError('MISSING_REQUIRED_FIELD', 400, 'query is required'); } - const apiKey = resolveWebSearchApiKey(clientApiKey); + if (!providerId) { + return apiError( + 'MISSING_PROVIDER', + 400, + 'Web search provider is not selected. Please select a provider in the toolbar.', + ); + } + + const apiKey = resolveWebSearchApiKey(providerId, clientApiKey); if (!apiKey) { + const envVar = providerId === 'claude' ? 'ANTHROPIC_API_KEY' : 'TAVILY_API_KEY'; return apiError( 'MISSING_API_KEY', 400, - 'Tavily API key is not configured. Set it in Settings → Web Search or set TAVILY_API_KEY env var.', + `${providerId} API key is not configured. Set it in Settings → Web Search or set ${envVar} env var.`, ); } @@ -75,13 +98,32 @@ export async function POST(req: NextRequest) { const searchQuery = await buildSearchQuery(query, boundedPdfText, aiCall); log.info('Running web search API request', { + provider: providerId, hasPdfContext: searchQuery.hasPdfContext, rawRequirementLength: searchQuery.rawRequirementLength, rewriteAttempted: searchQuery.rewriteAttempted, finalQueryLength: searchQuery.finalQueryLength, }); - const result = await searchWithTavily({ query: searchQuery.query, apiKey }); + const effectiveBaseUrl = + providerConfig?.baseUrl || WEB_SEARCH_PROVIDERS[providerId].defaultBaseUrl || ''; + + let result; + if (providerId === 'claude') { + result = await searchWithClaude({ + query: searchQuery.query, + apiKey, + baseUrl: effectiveBaseUrl, + modelId: providerConfig?.modelId, + tools: providerConfig?.tools, + }); + } else { + result = await searchWithTavily({ + query: searchQuery.query, + apiKey, + baseUrl: effectiveBaseUrl, + }); + } const context = formatSearchResultsAsContext(result); return apiSuccess({ diff --git a/app/generation-preview/page.tsx b/app/generation-preview/page.tsx index b63b4eb69..b24ae2cfc 100644 --- a/app/generation-preview/page.tsx +++ b/app/generation-preview/page.tsx @@ -305,15 +305,25 @@ function GenerationPreviewContent() { setWebSearchSources([]); const wsSettings = useSettingsStore.getState(); - const wsApiKey = - wsSettings.webSearchProvidersConfig?.[wsSettings.webSearchProviderId]?.apiKey; + const wsProviderId = wsSettings.webSearchProviderId; + const wsProviderConfig = wsProviderId + ? wsSettings.webSearchProvidersConfig?.[wsProviderId] + : null; const res = await fetch('/api/web-search', { method: 'POST', headers: getApiHeaders(), body: JSON.stringify({ query: currentSession.requirements.requirement, pdfText: currentSession.pdfText || undefined, - apiKey: wsApiKey || undefined, + providerId: wsProviderId || undefined, + apiKey: wsProviderConfig?.apiKey || undefined, + providerConfig: wsProviderId + ? { + modelId: wsProviderConfig?.modelId, + baseUrl: wsProviderConfig?.baseUrl, + tools: wsProviderConfig?.tools, + } + : undefined, }), signal, }); diff --git a/app/page.tsx b/app/page.tsx index c0da47614..e54d34aa3 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -50,7 +50,6 @@ import { SpeechButton } from '@/components/audio/speech-button'; const log = createLogger('Home'); -const WEB_SEARCH_STORAGE_KEY = 'webSearchEnabled'; const LANGUAGE_STORAGE_KEY = 'generationLanguage'; const RECENT_OPEN_STORAGE_KEY = 'recentClassroomsOpen'; @@ -58,14 +57,12 @@ interface FormState { pdfFile: File | null; requirement: string; language: 'zh-CN' | 'en-US'; - webSearch: boolean; } const initialFormState: FormState = { pdfFile: null, requirement: '', language: 'zh-CN', - webSearch: false, }; function HomePage() { @@ -84,6 +81,8 @@ function HomePage() { // Model setup state const currentModelId = useSettingsStore((s) => s.modelId); + const webSearchEnabled = useSettingsStore((s) => s.webSearchEnabled); + const setWebSearchEnabled = useSettingsStore((s) => s.setWebSearchEnabled); const [recentOpen, setRecentOpen] = useState(true); // Hydrate client-only state after mount (avoids SSR mismatch) @@ -96,10 +95,18 @@ function HomePage() { /* localStorage unavailable */ } try { - const savedWebSearch = localStorage.getItem(WEB_SEARCH_STORAGE_KEY); + // Migrate webSearchEnabled from old localStorage key into the Zustand store + const oldWebSearch = localStorage.getItem('webSearchEnabled'); + if (oldWebSearch === 'true' && !useSettingsStore.getState().webSearchEnabled) { + useSettingsStore.getState().setWebSearchEnabled(true); + } + if (oldWebSearch !== null) localStorage.removeItem('webSearchEnabled'); + } catch { + /* ignore */ + } + try { const savedLanguage = localStorage.getItem(LANGUAGE_STORAGE_KEY); const updates: Partial = {}; - if (savedWebSearch === 'true') updates.webSearch = true; if (savedLanguage === 'zh-CN' || savedLanguage === 'en-US') { updates.language = savedLanguage; } else { @@ -200,7 +207,6 @@ function HomePage() { const updateForm = (field: K, value: FormState[K]) => { setForm((prev) => ({ ...prev, [field]: value })); try { - if (field === 'webSearch') localStorage.setItem(WEB_SEARCH_STORAGE_KEY, String(value)); if (field === 'language') localStorage.setItem(LANGUAGE_STORAGE_KEY, String(value)); if (field === 'requirement') updateRequirementCache(value as string); } catch { @@ -264,7 +270,7 @@ function HomePage() { language: form.language, userNickname: userProfile.nickname || undefined, userBio: userProfile.bio || undefined, - webSearch: form.webSearch || undefined, + webSearch: webSearchEnabled || undefined, }; let pdfStorageKey: string | undefined; @@ -544,8 +550,8 @@ function HomePage() { updateForm('language', lang)} - webSearch={form.webSearch} - onWebSearchChange={(v) => updateForm('webSearch', v)} + webSearch={webSearchEnabled} + onWebSearchChange={setWebSearchEnabled} onSettingsOpen={(section) => { setSettingsSection(section); setSettingsOpen(true); diff --git a/components/generation/generation-toolbar.tsx b/components/generation/generation-toolbar.tsx index 27301bbd8..e4a857721 100644 --- a/components/generation/generation-toolbar.tsx +++ b/components/generation/generation-toolbar.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useRef, useMemo } from 'react'; -import { Bot, Check, ChevronLeft, Globe, Paperclip, FileText, X, Globe2 } from 'lucide-react'; +import { Bot, Check, ChevronLeft, Globe, Paperclip, FileText, X, Globe2, ChevronRight } from 'lucide-react'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Select, @@ -11,6 +11,7 @@ import { SelectValue, } from '@/components/ui/select'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { Switch } from '@/components/ui/switch'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/hooks/use-i18n'; import { useSettingsStore } from '@/lib/store/settings'; @@ -61,17 +62,17 @@ export function GenerationToolbar({ const webSearchProviderId = useSettingsStore((s) => s.webSearchProviderId); const webSearchProvidersConfig = useSettingsStore((s) => s.webSearchProvidersConfig); const setWebSearchProvider = useSettingsStore((s) => s.setWebSearchProvider); + const setWebSearchProviderConfig = useSettingsStore((s) => s.setWebSearchProviderConfig); const fileInputRef = useRef(null); const [isDragging, setIsDragging] = useState(false); + const [webSearchPopoverOpen, setWebSearchPopoverOpen] = useState(false); + const [drillWebSearchProvider, setDrillWebSearchProvider] = useState(null); - // Check if the selected web search provider has a valid config (API key or server-configured) - const webSearchProvider = WEB_SEARCH_PROVIDERS[webSearchProviderId]; - const webSearchConfig = webSearchProvidersConfig[webSearchProviderId]; - const webSearchAvailable = webSearchProvider - ? !webSearchProvider.requiresApiKey || - !!webSearchConfig?.apiKey || - !!webSearchConfig?.isServerConfigured - : false; + // Check if any web search provider has a valid config (API key or server-configured) + const webSearchAvailable = Object.values(WEB_SEARCH_PROVIDERS).some((provider) => { + const config = webSearchProvidersConfig[provider.id]; + return !provider.requiresApiKey || !!config?.apiKey || !!config?.isServerConfigured; + }); // Configured LLM providers (only those with valid credentials + models + endpoint) const configuredProviders = providersConfig @@ -272,90 +273,194 @@ export function GenerationToolbar({ {/* ── Web Search ── */} - {webSearchAvailable ? ( - - - - - - {/* Toggle */} - + + + {/* Level 1: Provider list */} + {!drillWebSearchProvider && ( +
+
+ + + {t('toolbar.webSearchProvider')} + + { + onWebSearchChange(enabled); + }} + className="scale-[0.85] origin-right" + /> +
+
+ {Object.values(WEB_SEARCH_PROVIDERS) + .filter((provider) => { + const cfg = webSearchProvidersConfig[provider.id]; + return !provider.requiresApiKey || !!cfg?.apiKey || !!cfg?.isServerConfigured; + }) + .map((provider) => { + const cfg = webSearchProvidersConfig[provider.id as WebSearchProviderId]; + const isActive = webSearchProviderId === provider.id && webSearch; + const hasModels = !!(cfg?.models?.length || WEB_SEARCH_PROVIDERS[provider.id as WebSearchProviderId]?.models?.length); + + return ( + + ); + })} +
+
)} - /> -
-

- {webSearch ? t('toolbar.webSearchOn') : t('toolbar.webSearchOff')} -

-

- {t('toolbar.webSearchDesc')} -

-
- - {/* Provider selector */} -
- - {t('toolbar.webSearchProvider')} - - -
-
-
- ) : ( - - - - + {/* Level 2: Model list for the drilled provider */} + {drillWebSearchProvider && (() => { + const cfg = webSearchProvidersConfig[drillWebSearchProvider]; + const provider = WEB_SEARCH_PROVIDERS[drillWebSearchProvider]; + const models = cfg?.models || []; + const selectedModelId = cfg?.modelId || ''; + return ( +
+ + {models.map((model) => { + const isSelected = selectedModelId === model.id; + return ( + + ); + })} +
+ ); + })()} + + + + + {!webSearchAvailable ? ( {t('toolbar.webSearchNoProvider')} -
- )} + ) : webSearch && webSearchProviderId ? ( + + {(() => { + const providerName = WEB_SEARCH_PROVIDERS[webSearchProviderId]?.name || webSearchProviderId; + const cfg = webSearchProvidersConfig[webSearchProviderId]; + if (webSearchProviderId === 'claude' && cfg?.modelId) { + const models = cfg.models || []; + const modelName = models.find((m) => m.id === cfg.modelId)?.name || cfg.modelId; + return `${providerName} / ${modelName}`; + } + return providerName; + })()} + + ) : null} + {/* ── Language pill ── */} diff --git a/components/settings/index.tsx b/components/settings/index.tsx index 5f122e2e5..4571e9fbc 100644 --- a/components/settings/index.tsx +++ b/components/settings/index.tsx @@ -211,7 +211,7 @@ export function SettingsDialog({ open, onOpenChange, initialSection }: SettingsD const [selectedProviderId, setSelectedProviderId] = useState(providerId); const [selectedPdfProviderId, setSelectedPdfProviderId] = useState(pdfProviderId); const [selectedWebSearchProviderId, setSelectedWebSearchProviderId] = - useState(webSearchProviderId); + useState(webSearchProviderId); const [selectedImageProviderId, setSelectedImageProviderId] = useState(imageProviderId); const [selectedVideoProviderId, setSelectedVideoProviderId] = @@ -551,7 +551,7 @@ export function SettingsDialog({ open, onOpenChange, initialSection }: SettingsD ); } case 'web-search': { - const wsProvider = WEB_SEARCH_PROVIDERS[selectedWebSearchProviderId]; + const wsProvider = selectedWebSearchProviderId ? WEB_SEARCH_PROVIDERS[selectedWebSearchProviderId] : null; if (!wsProvider) return null; return ( <> @@ -826,7 +826,7 @@ export function SettingsDialog({ open, onOpenChange, initialSection }: SettingsD )} - {activeSection === 'web-search' && ( + {activeSection === 'web-search' && selectedWebSearchProviderId && ( )} {activeSection === 'image' && ( diff --git a/components/settings/tool-edit-dialog.tsx b/components/settings/tool-edit-dialog.tsx new file mode 100644 index 000000000..4d81f35f6 --- /dev/null +++ b/components/settings/tool-edit-dialog.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { useState } from 'react'; +import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useI18n } from '@/lib/hooks/use-i18n'; + +interface Tool { + type: string; + name: string; +} + +interface ToolEditDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + tool: Tool | null; + setTool: (tool: Tool | null) => void; + onSave: () => void; +} + +export function ToolEditDialog({ open, onOpenChange, tool, setTool, onSave }: ToolEditDialogProps) { + const { t } = useI18n(); + + const handleClose = () => { + onOpenChange(false); + setTool(null); + }; + + if (!tool) return null; + + return ( + + + {tool.type === '' ? t('settings.webSearchAddToolTitle') : t('settings.webSearchEditToolTitle')} + {t('settings.webSearchToolDialogDesc')} +
+
+ + setTool({ ...tool, type: e.target.value })} + /> +
+
+ + setTool({ ...tool, name: e.target.value })} + /> +
+
+ + +
+
+
+
+ ); +} diff --git a/components/settings/web-search-model-dialog.tsx b/components/settings/web-search-model-dialog.tsx new file mode 100644 index 000000000..37b648041 --- /dev/null +++ b/components/settings/web-search-model-dialog.tsx @@ -0,0 +1,174 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import { Zap, Loader2, CheckCircle2, XCircle } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useI18n } from '@/lib/hooks/use-i18n'; + +interface WebSearchModelDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + model: { id: string; name: string } | null; + setModel: (model: { id: string; name: string } | null) => void; + onSave: () => void; + isEditing: boolean; + apiKey?: string; + baseUrl?: string; +} + +export function WebSearchModelDialog({ + open, + onOpenChange, + model, + setModel, + onSave, + isEditing, + apiKey, + baseUrl, +}: WebSearchModelDialogProps) { + const { t } = useI18n(); + const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle'); + const [testMessage, setTestMessage] = useState(''); + + const canTest = !!(model?.id?.trim() && model?.name?.trim() && apiKey); + + const handleTest = useCallback(async () => { + if (!canTest || !model) return; + setTestStatus('testing'); + setTestMessage(''); + try { + const response = await fetch('/api/verify-model', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + apiKey, + model: `anthropic:${model.id}`, + providerType: 'anthropic', + requiresApiKey: true, + }), + }); + const data = await response.json(); + if (data.success) { + setTestStatus('success'); + setTestMessage(t('settings.connectionSuccess')); + } else { + setTestStatus('error'); + setTestMessage(data.error || t('settings.connectionFailed')); + } + } catch { + setTestStatus('error'); + setTestMessage(t('settings.connectionFailed')); + } + }, [canTest, model, apiKey, baseUrl, t]); + + const handleOpenChange = (open: boolean) => { + if (!open) { + setTestStatus('idle'); + setTestMessage(''); + } + onOpenChange(open); + }; + + return ( + + + + + {isEditing + ? t('settings.webSearchEditModelTitle') + : t('settings.webSearchAddModelTitle')} + + {t('settings.webSearchModelDialogDesc')} + +
+
+ + { + setModel(model ? { ...model, id: e.target.value } : null); + setTestStatus('idle'); + setTestMessage(''); + }} + placeholder="claude-opus-4.6" + className="font-mono text-sm" + /> +
+
+ + { + setModel(model ? { ...model, name: e.target.value } : null); + setTestStatus('idle'); + setTestMessage(''); + }} + placeholder="Claude Opus 4.6" + className="text-sm" + /> +
+ + {/* Test connection */} +
+
+ + +
+ {testMessage && ( +
+
+ {testStatus === 'success' && ( + + )} + {testStatus === 'error' && } +

{testMessage}

+
+
+ )} +
+
+ + + + +
+
+ ); +} diff --git a/components/settings/web-search-settings.tsx b/components/settings/web-search-settings.tsx index d5cf37761..1a1a1619b 100644 --- a/components/settings/web-search-settings.tsx +++ b/components/settings/web-search-settings.tsx @@ -1,13 +1,17 @@ 'use client'; -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; import { useI18n } from '@/lib/hooks/use-i18n'; import { useSettingsStore } from '@/lib/store/settings'; import { WEB_SEARCH_PROVIDERS } from '@/lib/web-search/constants'; import type { WebSearchProviderId } from '@/lib/web-search/types'; -import { Eye, EyeOff } from 'lucide-react'; +import { Eye, EyeOff, Trash2, Settings2, Plus, Zap, Loader2, CheckCircle2, XCircle } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { ToolEditDialog } from './tool-edit-dialog'; +import { WebSearchModelDialog } from './web-search-model-dialog'; interface WebSearchSettingsProps { selectedProviderId: WebSearchProviderId; @@ -16,6 +20,14 @@ interface WebSearchSettingsProps { export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps) { const { t } = useI18n(); const [showApiKey, setShowApiKey] = useState(false); + const [isToolDialogOpen, setIsToolDialogOpen] = useState(false); + const [editingTool, setEditingTool] = useState<{ type: string; name: string } | null>(null); + const [editingToolIndex, setEditingToolIndex] = useState(null); + const [isModelDialogOpen, setIsModelDialogOpen] = useState(false); + const [editingModel, setEditingModel] = useState<{ id: string; name: string } | null>(null); + const [editingModelIndex, setEditingModelIndex] = useState(null); + const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle'); + const [testMessage, setTestMessage] = useState(''); const webSearchProvidersConfig = useSettingsStore((state) => state.webSearchProvidersConfig); const setWebSearchProviderConfig = useSettingsStore((state) => state.setWebSearchProviderConfig); @@ -23,29 +35,163 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps const provider = WEB_SEARCH_PROVIDERS[selectedProviderId]; const isServerConfigured = !!webSearchProvidersConfig[selectedProviderId]?.isServerConfigured; - // Reset showApiKey when provider changes (derived state pattern) const [prevSelectedProviderId, setPrevSelectedProviderId] = useState(selectedProviderId); if (selectedProviderId !== prevSelectedProviderId) { setPrevSelectedProviderId(selectedProviderId); setShowApiKey(false); + setTestStatus('idle'); + setTestMessage(''); } + const handleTestConnection = useCallback(async () => { + setTestStatus('testing'); + setTestMessage(''); + + const config = webSearchProvidersConfig[selectedProviderId]; + const apiKey = config?.apiKey || ''; + const baseUrl = config?.baseUrl || WEB_SEARCH_PROVIDERS[selectedProviderId]?.defaultBaseUrl || ''; + + try { + if (selectedProviderId === 'claude') { + // Use verify-model endpoint with the selected (or default) Claude model + const modelId = config?.modelId || 'claude-sonnet-4-6'; + const response = await fetch('/api/verify-model', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + apiKey, + model: `anthropic:${modelId}`, + providerType: 'anthropic', + requiresApiKey: true, + }), + }); + const data = await response.json(); + if (data.success) { + setTestStatus('success'); + setTestMessage(t('settings.connectionSuccess')); + } else { + setTestStatus('error'); + setTestMessage(data.error || t('settings.connectionFailed')); + } + } else { + // For other providers (Tavily), test via the web search endpoint + const response = await fetch('/api/web-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: 'test connection', + apiKey, + providerId: selectedProviderId, + }), + }); + const data = await response.json(); + if (data.success || response.ok) { + setTestStatus('success'); + setTestMessage(t('settings.connectionSuccess')); + } else { + setTestStatus('error'); + setTestMessage(data.error || t('settings.connectionFailed')); + } + } + } catch { + setTestStatus('error'); + setTestMessage(t('settings.connectionFailed')); + } + }, [webSearchProvidersConfig, selectedProviderId, t]); + + // Guard against undefined provider + if (!provider) { + return null; + } + + const tools = webSearchProvidersConfig[selectedProviderId]?.tools || []; + const models = webSearchProvidersConfig[selectedProviderId]?.models || []; + + const handleAddTool = () => { + setEditingTool({ type: '', name: '' }); + setEditingToolIndex(null); + setIsToolDialogOpen(true); + }; + + const handleEditTool = (tool: { type: string; name: string }, index: number) => { + setEditingTool({ ...tool }); + setEditingToolIndex(index); + setIsToolDialogOpen(true); + }; + + const handleSaveTool = () => { + if (!editingTool) return; + + const newTools = [...tools]; + if (editingToolIndex !== null) { + newTools[editingToolIndex] = editingTool; + } else { + newTools.push(editingTool); + } + setWebSearchProviderConfig(selectedProviderId, { tools: newTools }); + setIsToolDialogOpen(false); + setEditingTool(null); + setEditingToolIndex(null); + }; + + const handleDeleteTool = (index: number) => { + const newTools = tools.filter((_, i) => i !== index); + setWebSearchProviderConfig(selectedProviderId, { tools: newTools }); + }; + + const handleAddModel = () => { + setEditingModel({ id: '', name: '' }); + setEditingModelIndex(null); + setIsModelDialogOpen(true); + }; + + const handleEditModel = (model: { id: string; name: string }, index: number) => { + setEditingModel({ ...model }); + setEditingModelIndex(index); + setIsModelDialogOpen(true); + }; + + const handleSaveModel = () => { + if (!editingModel) return; + + const newModels = [...models]; + if (editingModelIndex !== null) { + newModels[editingModelIndex] = editingModel; + } else { + newModels.push(editingModel); + } + setWebSearchProviderConfig(selectedProviderId, { models: newModels }); + setIsModelDialogOpen(false); + setEditingModel(null); + setEditingModelIndex(null); + }; + + const handleDeleteModel = (index: number) => { + const newModels = models.filter((_, i) => i !== index); + setWebSearchProviderConfig(selectedProviderId, { models: newModels }); + }; + return (
- {/* Server-configured notice */} {isServerConfigured && (
{t('settings.serverConfiguredNotice')}
)} - {/* API Key + Base URL Configuration */} {(provider.requiresApiKey || isServerConfigured) && ( <> -
-
- -
+ {/* API Key */} +
+ +
+
: }
-

{t('settings.webSearchApiKeyHint')}

-
- -
- - - setWebSearchProviderConfig(selectedProviderId, { - baseUrl: e.target.value, - }) +
+ {testMessage && ( +
+
+ {testStatus === 'success' && } + {testStatus === 'error' && } +

{testMessage}

+
+
+ )} +

{t('settings.webSearchApiKeyHint')}

+
+ + {/* Base URL */} +
+ + + setWebSearchProviderConfig(selectedProviderId, { + baseUrl: e.target.value, + }) + } + className="text-sm" + /> + {(() => { + const effectiveBaseUrl = + webSearchProvidersConfig[selectedProviderId]?.baseUrl || + provider.defaultBaseUrl || + ''; + if (!effectiveBaseUrl) return null; + const endpointPath = selectedProviderId === 'claude' ? '/v1/messages' : '/search'; + return ( +

+ {t('settings.requestUrl')}: {effectiveBaseUrl}{endpointPath} +

+ ); + })()}
- {/* Request URL Preview */} - {(() => { - const effectiveBaseUrl = - webSearchProvidersConfig[selectedProviderId]?.baseUrl || - provider.defaultBaseUrl || - ''; - if (!effectiveBaseUrl) return null; - const fullUrl = effectiveBaseUrl + '/search'; - return ( -

- {t('settings.requestUrl')}: {fullUrl} -

- ); - })()} + {selectedProviderId === 'claude' && ( +
+
+
+ + +
+
+ {models.length === 0 ? ( +

{t('settings.webSearchNoModels')} {t('settings.webSearchNoModelsHint')}

+ ) : ( + models.map((model, index) => ( +
+
+
{model.name}
+
{model.id}
+
+
+ + +
+
+ )) + )} +
+
+ +
+
+ + +
+
+ {tools.length === 0 ? ( +

{t('settings.webSearchNoTools')}

+ ) : ( + tools.map((tool, index) => ( +
+
+ {tool.name} + + {tool.type} + +
+
+ + +
+
+ )) + )} +
+
+
+ )} + )} + + + +
); } diff --git a/lib/i18n/settings.ts b/lib/i18n/settings.ts index 356fea554..da1c3ac1f 100644 --- a/lib/i18n/settings.ts +++ b/lib/i18n/settings.ts @@ -555,12 +555,33 @@ export const settingsZhCN = { clearCacheFailed: '清空缓存失败,请重试', // Web Search settings webSearchSettings: '网络搜索', - webSearchApiKey: 'Tavily API Key', - webSearchApiKeyPlaceholder: '输入你的 Tavily API Key', + webSearchApiKey: 'API 密钥', + webSearchTavilyApiKey: 'Tavily API 密钥', + webSearchClaudeApiKey: 'Claude API 密钥', + webSearchApiKeyPlaceholder: '输入 API Key', webSearchApiKeyPlaceholderServer: '已配置服务端密钥,可选填覆盖', - webSearchApiKeyHint: '从 tavily.com 获取 API Key,用于网络搜索', + webSearchApiKeyHint: '请输入相应服务商提供的 API Key,用于网络搜索', webSearchBaseUrl: 'Base URL', - webSearchServerConfigured: '服务端已配置 Tavily API Key', + webSearchServerConfigured: '服务端已配置 API 密钥', + webSearchModelId: '模型 ID', + webSearchApiVersion: 'API 版本', + webSearchToolsConfiguration: '工具', + webSearchNewTool: '新建工具', + webSearchNoTools: '暂无已配置的工具', + webSearchAddToolTitle: '添加工具', + webSearchEditToolTitle: '编辑工具', + webSearchToolDialogDesc: '配置 Claude 搜索工具的类型和名称', + webSearchToolType: '类型', + webSearchToolName: '名称', + webSearchModelsConfiguration: '模型', + webSearchNewModel: '新建模型', + webSearchNoModels: '暂无已配置的模型。', + webSearchNoModelsHint: '请添加至少一个模型才能使用 Claude 搜索。', + webSearchAddModelTitle: '添加模型', + webSearchEditModelTitle: '编辑模型', + webSearchModelDialogDesc: '配置 Claude 搜索模型的 ID 和名称', + webSearchModelIdField: '模型 ID', + webSearchModelNameField: '模型名称', optional: '可选', }, profile: { @@ -1159,12 +1180,33 @@ export const settingsEnUS = { clearCacheFailed: 'Failed to clear cache, please try again', // Web Search settings webSearchSettings: 'Web Search', - webSearchApiKey: 'Tavily API Key', - webSearchApiKeyPlaceholder: 'Enter your Tavily API Key', + webSearchApiKey: 'API Key', + webSearchTavilyApiKey: 'Tavily API Key', + webSearchClaudeApiKey: 'Claude API Key', + webSearchApiKeyPlaceholder: 'Enter API Key', webSearchApiKeyPlaceholderServer: 'Server key configured, optionally override', - webSearchApiKeyHint: 'Get an API key from tavily.com for web search', + webSearchApiKeyHint: 'Enter the API key provided by the search service', webSearchBaseUrl: 'Base URL', - webSearchServerConfigured: 'Server-side Tavily API key is configured', + webSearchServerConfigured: 'Server-side API key is configured', + webSearchModelId: 'Model ID', + webSearchApiVersion: 'API Version', + webSearchToolsConfiguration: 'Tools', + webSearchNewTool: 'New Tool', + webSearchNoTools: 'No tools configured.', + webSearchAddToolTitle: 'Add Tool', + webSearchEditToolTitle: 'Edit Tool', + webSearchToolDialogDesc: "Configure the tool's type and name for Claude search.", + webSearchToolType: 'Type', + webSearchToolName: 'Name', + webSearchModelsConfiguration: 'Models', + webSearchNewModel: 'New Model', + webSearchNoModels: 'No models configured.', + webSearchNoModelsHint: 'Add at least one model to use Claude search.', + webSearchAddModelTitle: 'Add Model', + webSearchEditModelTitle: 'Edit Model', + webSearchModelDialogDesc: "Configure the model's ID and name for Claude search.", + webSearchModelIdField: 'Model ID', + webSearchModelNameField: 'Model Name', optional: 'Optional', }, profile: { diff --git a/lib/server/api-response.ts b/lib/server/api-response.ts index 07d2b6d68..2aa1651f9 100644 --- a/lib/server/api-response.ts +++ b/lib/server/api-response.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; export const API_ERROR_CODES = { MISSING_REQUIRED_FIELD: 'MISSING_REQUIRED_FIELD', + MISSING_PROVIDER: 'MISSING_PROVIDER', MISSING_API_KEY: 'MISSING_API_KEY', INVALID_REQUEST: 'INVALID_REQUEST', INVALID_URL: 'INVALID_URL', diff --git a/lib/server/classroom-generation.ts b/lib/server/classroom-generation.ts index 049cc9f30..fa7ce6b42 100644 --- a/lib/server/classroom-generation.ts +++ b/lib/server/classroom-generation.ts @@ -20,7 +20,12 @@ import { parseModelString } from '@/lib/ai/providers'; import { resolveApiKey, resolveWebSearchApiKey } from '@/lib/server/provider-config'; import { resolveModel } from '@/lib/server/resolve-model'; import { buildSearchQuery } from '@/lib/server/search-query-builder'; -import { searchWithTavily, formatSearchResultsAsContext } from '@/lib/web-search/tavily'; +import { + searchWithTavily, + formatSearchResultsAsContext, +} from '@/lib/web-search/tavily'; +import { searchWithClaude } from '@/lib/web-search/claude'; +import { WEB_SEARCH_PROVIDERS } from '@/lib/web-search/constants'; import { persistClassroom } from '@/lib/server/classroom-storage'; import { generateMediaForClassroom, @@ -38,6 +43,10 @@ export interface GenerateClassroomInput { pdfContent?: { text: string; images: string[] }; language?: string; enableWebSearch?: boolean; + webSearchProviderId?: string; + webSearchBaseUrl?: string; + webSearchModelId?: string; + webSearchTools?: Array<{ type: string; name: string }>; enableImageGeneration?: boolean; enableVideoGeneration?: boolean; enableTTS?: boolean; @@ -254,22 +263,45 @@ export async function generateClassroom( // Web search (optional, graceful degradation) let researchContext: string | undefined; if (input.enableWebSearch) { - const tavilyKey = resolveWebSearchApiKey(); - if (tavilyKey) { + // The providerId should ideally be fetched from the user's settings store. + // Since this is a server-side function, we'd typically pass the user's current providerId in the input. + // For now, we'll use a default or assume it's passed in the request (mocking the settings lookup). + const providerId = input.webSearchProviderId || 'tavily'; + const searchKey = resolveWebSearchApiKey(providerId); + if (searchKey) { try { const searchQuery = await buildSearchQuery(requirement, pdfText, searchQueryAiCall); log.info('Running web search for classroom generation', { + provider: providerId, hasPdfContext: searchQuery.hasPdfContext, rawRequirementLength: searchQuery.rawRequirementLength, rewriteAttempted: searchQuery.rewriteAttempted, finalQueryLength: searchQuery.finalQueryLength, }); - const searchResult = await searchWithTavily({ - query: searchQuery.query, - apiKey: tavilyKey, - }); + const effectiveBaseUrl = + input.webSearchBaseUrl || + WEB_SEARCH_PROVIDERS[providerId as keyof typeof WEB_SEARCH_PROVIDERS]?.defaultBaseUrl || + ''; + + let searchResult; + if (providerId === 'claude') { + searchResult = await searchWithClaude({ + query: searchQuery.query, + apiKey: searchKey, + baseUrl: effectiveBaseUrl, + modelId: input.webSearchModelId, + tools: input.webSearchTools, + }); + } else { + searchResult = await searchWithTavily({ + query: searchQuery.query, + apiKey: searchKey, + baseUrl: effectiveBaseUrl, + }); + } + researchContext = formatSearchResultsAsContext(searchResult); if (researchContext) { log.info(`Web search returned ${searchResult.sources.length} sources`); @@ -278,7 +310,9 @@ export async function generateClassroom( log.warn('Web search failed, continuing without search context:', e); } } else { - log.warn('enableWebSearch is true but no Tavily API key configured, skipping web search'); + log.warn( + `enableWebSearch is true but no API key configured for ${providerId}, skipping web search`, + ); } } diff --git a/lib/server/provider-config.ts b/lib/server/provider-config.ts index 27afa1411..533c68272 100644 --- a/lib/server/provider-config.ts +++ b/lib/server/provider-config.ts @@ -90,6 +90,7 @@ const VIDEO_ENV_MAP: Record = { const WEB_SEARCH_ENV_MAP: Record = { TAVILY: 'tavily', + CLAUDE: 'claude', }; // --------------------------------------------------------------------------- @@ -399,10 +400,12 @@ export function getServerWebSearchProviders(): Record server key > TAVILY_API_KEY env > empty */ -export function resolveWebSearchApiKey(clientKey?: string): string { +/** Resolve Web Search API key: client key > server key > env fallback > empty */ +export function resolveWebSearchApiKey(providerId: string, clientKey?: string): string { if (clientKey) return clientKey; - const serverKey = getConfig().webSearch.tavily?.apiKey; + const serverKey = getConfig().webSearch[providerId]?.apiKey; if (serverKey) return serverKey; - return process.env.TAVILY_API_KEY || ''; + // Claude web search reuses the standard Anthropic API key + const envVar = providerId === 'claude' ? 'ANTHROPIC_API_KEY' : `${providerId.toUpperCase()}_API_KEY`; + return process.env[envVar] || ''; } diff --git a/lib/store/settings.ts b/lib/store/settings.ts index 4b088bbc6..338e813d8 100644 --- a/lib/store/settings.ts +++ b/lib/store/settings.ts @@ -121,7 +121,8 @@ export interface SettingsState { videoGenerationEnabled: boolean; // Web Search settings - webSearchProviderId: WebSearchProviderId; + webSearchEnabled: boolean; + webSearchProviderId: WebSearchProviderId | null; webSearchProvidersConfig: Record< WebSearchProviderId, { @@ -130,6 +131,9 @@ export interface SettingsState { enabled: boolean; isServerConfigured?: boolean; serverBaseUrl?: string; + modelId?: string; + tools?: Array<{ type: string; name: string }>; + models?: Array<{ id: string; name: string }>; } >; @@ -245,10 +249,18 @@ export interface SettingsState { setVideoGenerationEnabled: (enabled: boolean) => void; // Web Search actions - setWebSearchProvider: (providerId: WebSearchProviderId) => void; + setWebSearchEnabled: (enabled: boolean) => void; + setWebSearchProvider: (providerId: WebSearchProviderId | null) => void; setWebSearchProviderConfig: ( providerId: WebSearchProviderId, - config: Partial<{ apiKey: string; baseUrl: string; enabled: boolean }>, + config: Partial<{ + apiKey: string; + baseUrl: string; + enabled: boolean; + modelId: string; + tools: Array<{ type: string; name: string }>; + models: Array<{ id: string; name: string }>; + }>, ) => void; // Server provider actions @@ -340,10 +352,18 @@ const getDefaultVideoConfig = () => ({ // Initialize default Web Search config const getDefaultWebSearchConfig = () => ({ - webSearchProviderId: 'tavily' as WebSearchProviderId, + webSearchProviderId: null as WebSearchProviderId | null, webSearchProvidersConfig: { - tavily: { apiKey: '', baseUrl: '', enabled: true }, - } as Record, + tavily: { apiKey: '', baseUrl: WEB_SEARCH_PROVIDERS.tavily.defaultBaseUrl, enabled: true }, + claude: { + apiKey: '', + baseUrl: WEB_SEARCH_PROVIDERS.claude.defaultBaseUrl, + enabled: true, + modelId: '', + tools: [{ type: 'web_search_20260209', name: 'web_search' }], + models: WEB_SEARCH_PROVIDERS.claude?.models?.map((m) => ({ id: m.id, name: m.name })) ?? [], + }, + } as Record; models?: Array<{ id: string; name: string }> }>, }); /** @@ -369,7 +389,10 @@ function ensureValidProviderSelections(state: Partial): void { state.pdfProviderId = defaultPdfConfig.pdfProviderId; } - if (!hasProviderId(WEB_SEARCH_PROVIDERS, state.webSearchProviderId)) { + if ( + state.webSearchProviderId !== null && + !hasProviderId(WEB_SEARCH_PROVIDERS, state.webSearchProviderId) + ) { state.webSearchProviderId = defaultWebSearchConfig.webSearchProviderId; } @@ -457,6 +480,30 @@ function ensureBuiltInVideoProviders(state: Partial): void { }); } +/** + * Ensure webSearchProvidersConfig includes all built-in web search providers. + * Called on every rehydrate so newly added providers appear automatically. + */ +function ensureBuiltInWebSearchProviders(state: Partial): void { + if (!state.webSearchProvidersConfig) return; + const defaultConfig = getDefaultWebSearchConfig().webSearchProvidersConfig; + Object.keys(defaultConfig).forEach((pid) => { + const providerId = pid as WebSearchProviderId; + if (!state.webSearchProvidersConfig![providerId]) { + state.webSearchProvidersConfig![providerId] = defaultConfig[providerId]; + } else { + const entry = state.webSearchProvidersConfig![providerId]; + if (!entry.baseUrl) { + entry.baseUrl = defaultConfig[providerId].baseUrl; + } + if (!entry.models?.length && defaultConfig[providerId]?.models?.length) { + // Initialize models from defaults if not yet set for this provider + entry.models = defaultConfig[providerId].models; + } + } + }); +} + // Migrate from old localStorage format const migrateFromOldStorage = () => { if (typeof window === 'undefined') return null; @@ -574,6 +621,9 @@ export const useSettingsStore = create()( imageGenerationEnabled: false, videoGenerationEnabled: false, + // Web search toggle (off by default) + webSearchEnabled: false, + // Audio feature toggles (on by default) ttsEnabled: true, asrEnabled: true, @@ -736,17 +786,53 @@ export const useSettingsStore = create()( setASREnabled: (enabled) => set({ asrEnabled: enabled }), // Web Search actions - setWebSearchProvider: (providerId) => set({ webSearchProviderId: providerId }), + setWebSearchEnabled: (enabled) => { + if (enabled) { + const cfg = get().webSearchProvidersConfig; + const hasUsable = Object.values(cfg).some((c) => c.isServerConfigured || c.apiKey); + if (!hasUsable) return; + } + set({ webSearchEnabled: enabled }); + if (!enabled) { + // Also deselect provider (which clears modelId per setWebSearchProvider logic) + get().setWebSearchProvider(null); + } + }, + setWebSearchProvider: (providerId) => + set((state) => { + if (providerId !== null) return { webSearchProviderId: providerId }; + // Deselect: clear modelId for the previously selected provider + const prev = state.webSearchProviderId; + if (!prev || !state.webSearchProvidersConfig[prev]?.modelId) { + return { webSearchProviderId: null }; + } + return { + webSearchProviderId: null, + webSearchProvidersConfig: { + ...state.webSearchProvidersConfig, + [prev]: { ...state.webSearchProvidersConfig[prev], modelId: '' }, + }, + }; + }), setWebSearchProviderConfig: (providerId, config) => - set((state) => ({ - webSearchProvidersConfig: { - ...state.webSearchProvidersConfig, - [providerId]: { - ...state.webSearchProvidersConfig[providerId], - ...config, + set((state) => { + const updatedProviderConfig = { + ...state.webSearchProvidersConfig[providerId], + ...config, + }; + const apiKeyRemoved = + 'apiKey' in config && + !config.apiKey && + !updatedProviderConfig.isServerConfigured; + const isSelected = state.webSearchProviderId === providerId; + return { + webSearchProvidersConfig: { + ...state.webSearchProvidersConfig, + [providerId]: updatedProviderConfig, }, - }, - })), + ...(apiKeyRemoved && isSelected ? { webSearchProviderId: null } : {}), + }; + }), // Fetch server-configured providers and merge into local state fetchServerProviders: async () => { @@ -1212,9 +1298,10 @@ export const useSettingsStore = create()( // Ensure providersConfig has all built-in providers (also in merge below) ensureBuiltInProviders(state); - // Ensure image/video configs have all built-in providers + // Ensure image/video/web-search configs have all built-in providers ensureBuiltInImageProviders(state); ensureBuiltInVideoProviders(state); + ensureBuiltInWebSearchProviders(state); // Migrate from old ttsModel to new ttsProviderId if (state.ttsModel && !state.ttsProviderId) { @@ -1322,6 +1409,10 @@ export const useSettingsStore = create()( const oldIsServerConfigured = (stateRecord.webSearchIsServerConfigured as boolean) || false; state.webSearchProviderId = 'tavily' as WebSearchProviderId; + // Enable web search if old user had a configured provider + if (oldApiKey || oldIsServerConfigured) { + state.webSearchEnabled = true; + } state.webSearchProvidersConfig = { tavily: { apiKey: oldApiKey, @@ -1329,6 +1420,13 @@ export const useSettingsStore = create()( enabled: true, isServerConfigured: oldIsServerConfigured, }, + claude: { + apiKey: '', + baseUrl: '', + enabled: true, + modelId: '', + tools: [{ type: 'web_search_20260209', name: 'web_search' }], + }, } as SettingsState['webSearchProvidersConfig']; delete stateRecord.webSearchApiKey; delete stateRecord.webSearchIsServerConfigured; @@ -1345,6 +1443,7 @@ export const useSettingsStore = create()( ensureBuiltInProviders(merged as Partial); ensureBuiltInImageProviders(merged as Partial); ensureBuiltInVideoProviders(merged as Partial); + ensureBuiltInWebSearchProviders(merged as Partial); ensureValidProviderSelections(merged as Partial); return merged as SettingsState; }, diff --git a/lib/types/web-search.ts b/lib/types/web-search.ts index ba2624b86..10729b552 100644 --- a/lib/types/web-search.ts +++ b/lib/types/web-search.ts @@ -2,7 +2,7 @@ export interface WebSearchSource { title: string; url: string; content: string; - score: number; + score?: number; } export interface WebSearchResult { diff --git a/lib/web-search/claude.ts b/lib/web-search/claude.ts new file mode 100644 index 000000000..d4e457274 --- /dev/null +++ b/lib/web-search/claude.ts @@ -0,0 +1,170 @@ +/** + * Claude Web Search Integration + * + * This provider implements the native Claude web search tool via the Anthropic Messages API. + * It requires a specific model (e.g., claude-opus-4-6) and a specific tool definition. + */ + +import { proxyFetch } from '@/lib/server/proxy-fetch'; +import { createLogger } from '@/lib/logger'; +import type { WebSearchResult, WebSearchSource } from '@/lib/types/web-search'; + +const PAGE_CONTENT_MAX_LENGTH = 2000; +const PAGE_FETCH_TIMEOUT_MS = 5000; + +/** Fetch a URL and return plain text extracted from its HTML. Returns empty string on any failure. */ +async function fetchPageContent(url: string): Promise { + log.info(`Fetching page content: ${url}`); + try { + const res = await proxyFetch(url, { + headers: { Accept: 'text/html', 'User-Agent': 'Mozilla/5.0 (compatible; OpenMAIC/1.0)' }, + signal: AbortSignal.timeout(PAGE_FETCH_TIMEOUT_MS), + }); + if (!res.ok) { + log.warn(`Failed to fetch page content [url="${url}" status=${res.status}]`); + return ''; + } + const html = await res.text(); + // Strip scripts, styles, and all tags; collapse whitespace + const text = html + .replace(//gi, ' ') + .replace(//gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + const content = text.slice(0, PAGE_CONTENT_MAX_LENGTH); + log.info(`Fetched page content [url="${url}" chars=${content.length}]`); + return content; + } catch (e) { + log.warn(`Error fetching page content [url="${url}"]:`, e); + return ''; + } +} + +const log = createLogger('ClaudeSearch'); + +/** + * Search the web using Claude's native web search tool. + */ +export async function searchWithClaude(params: { + query: string; + apiKey: string; + modelId?: string; + baseUrl: string; + tools?: Array<{ type: string; name: string }>; +}): Promise { + const { + query, + apiKey, + modelId = 'claude-sonnet-4-6', + baseUrl, + tools, + } = params; + + const apiVersion = '2023-06-01'; + + const endpoint = `${baseUrl}/v1/messages`; + + try { + const startTime = Date.now(); + const res = await proxyFetch(endpoint, { + method: 'POST', + headers: { + 'x-api-key': apiKey, + 'anthropic-version': apiVersion, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + model: modelId, + max_tokens: 4096, + stream: false, + messages: [ + { + role: 'user', + content: `Search for the following and provide a comprehensive summary with source links: ${query}.`, + }, + ], + tools: tools?.length + ? tools + : [{ type: 'web_search_20260209', name: 'web_search' }], + }), + }); + + if (!res.ok) { + const errorText = await res.text().catch(() => ''); + throw new Error(`Claude API error (${res.status}): ${errorText || res.statusText}`); + } + + const data = (await res.json()) as any; + const contentBlocks: any[] = data.content || []; + + // Extract search results from web_search_tool_result blocks + const searchResultMap = new Map(); + for (const block of contentBlocks) { + if (block.type !== 'web_search_tool_result') continue; + for (const result of block.content || []) { + if (result.type !== 'web_search_result') continue; + if (!searchResultMap.has(result.url)) { + searchResultMap.set(result.url, { + title: result.title || result.url, + url: result.url, + content: '', + }); + } + } + } + + // Collect the final answer text blocks (ignore server_tool_use / web_search_tool_result) + const answerParts: string[] = []; + for (const block of contentBlocks) { + if (block.type === 'text' && block.text) { + answerParts.push(block.text); + // If the block carries citations, make sure those sources are captured + for (const citation of block.citations || []) { + if (citation.url && !searchResultMap.has(citation.url)) { + searchResultMap.set(citation.url, { + title: citation.title || citation.url, + url: citation.url, + content: citation.cited_text || '', + }); + } else if (citation.url) { + const existing = searchResultMap.get(citation.url)!; + if (!existing.content && citation.cited_text) { + existing.content = citation.cited_text; + } + } + } + } + } + + const answerText = answerParts.join(''); + const sources = Array.from(searchResultMap.values()); + + // Fetch page content for sources that have no content from citations + await Promise.all( + sources + .filter((s) => !s.content) + .map(async (s) => { + s.content = await fetchPageContent(s.url); + }), + ); + + // Drop sources for which we could not obtain any content + const sourcesWithContent = sources.filter((s) => s.content); + + return { + answer: answerText, + sources: sourcesWithContent, + query, + responseTime: Date.now() - startTime, + }; + } catch (e) { + log.error('Claude search failed', e); + throw e; + } +} + +/** + * Reuse formatting logic from Tavily. + */ +export { formatSearchResultsAsContext } from './tavily'; diff --git a/lib/web-search/constants.ts b/lib/web-search/constants.ts index 6542bbb2a..13319e5f3 100644 --- a/lib/web-search/constants.ts +++ b/lib/web-search/constants.ts @@ -13,6 +13,24 @@ export const WEB_SEARCH_PROVIDERS: Record { - const { query, apiKey, maxResults = 5 } = params; + const { query, apiKey, baseUrl, maxResults = 5 } = params; // Tavily rejects queries over 400 characters with a 400 error const truncatedQuery = query.slice(0, TAVILY_MAX_QUERY_LENGTH); - const res = await proxyFetch(TAVILY_API_URL, { + const res = await proxyFetch(`${baseUrl}/search`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/lib/web-search/types.ts b/lib/web-search/types.ts index f83822c7c..995c7238a 100644 --- a/lib/web-search/types.ts +++ b/lib/web-search/types.ts @@ -5,15 +5,21 @@ /** * Web Search Provider IDs */ -export type WebSearchProviderId = 'tavily'; +export type WebSearchProviderId = 'tavily' | 'claude'; /** * Web Search Provider Configuration */ +export interface WebSearchModel { + id: string; + name: string; +} + export interface WebSearchProviderConfig { id: WebSearchProviderId; name: string; requiresApiKey: boolean; defaultBaseUrl?: string; icon?: string; + models?: WebSearchModel[]; } diff --git a/public/logos/tavily.svg b/public/logos/tavily.svg new file mode 100644 index 000000000..08e491352 --- /dev/null +++ b/public/logos/tavily.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/tests/server/provider-config.test.ts b/tests/server/provider-config.test.ts index 58d05c942..cd6a64f7f 100644 --- a/tests/server/provider-config.test.ts +++ b/tests/server/provider-config.test.ts @@ -155,15 +155,31 @@ providers: }); describe('resolveWebSearchApiKey', () => { - it('returns client key first', async () => { + it('returns client key first for tavily', async () => { const { resolveWebSearchApiKey } = await import('@/lib/server/provider-config'); - expect(resolveWebSearchApiKey('client-key')).toBe('client-key'); + expect(resolveWebSearchApiKey('tavily', 'client-key')).toBe('client-key'); }); it('falls back to TAVILY_API_KEY env var', async () => { vi.stubEnv('TAVILY_API_KEY', 'tvly-bare-env'); const { resolveWebSearchApiKey } = await import('@/lib/server/provider-config'); - expect(resolveWebSearchApiKey()).toBe('tvly-bare-env'); + expect(resolveWebSearchApiKey('tavily')).toBe('tvly-bare-env'); + }); + + it('returns client key first for claude', async () => { + const { resolveWebSearchApiKey } = await import('@/lib/server/provider-config'); + expect(resolveWebSearchApiKey('claude', 'sk-client')).toBe('sk-client'); + }); + + it('falls back to ANTHROPIC_API_KEY env var for claude', async () => { + vi.stubEnv('ANTHROPIC_API_KEY', 'sk-anthropic-env'); + const { resolveWebSearchApiKey } = await import('@/lib/server/provider-config'); + expect(resolveWebSearchApiKey('claude')).toBe('sk-anthropic-env'); + }); + + it('returns empty string when no key configured', async () => { + const { resolveWebSearchApiKey } = await import('@/lib/server/provider-config'); + expect(resolveWebSearchApiKey('tavily')).toBe(''); }); }); diff --git a/tests/store/settings-web-search.test.ts b/tests/store/settings-web-search.test.ts new file mode 100644 index 000000000..13cbe8a27 --- /dev/null +++ b/tests/store/settings-web-search.test.ts @@ -0,0 +1,142 @@ +/** + * Tests for web search settings store behaviour: + * - Default tools pre-populated for the claude provider + * - ensureBuiltInWebSearchProviders fills missing providers on rehydrate + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@/lib/ai/providers', () => ({ + PROVIDERS: { + openai: { + id: 'openai', + name: 'OpenAI', + type: 'openai', + defaultBaseUrl: 'https://api.openai.com/v1', + requiresApiKey: true, + icon: '', + models: [{ id: 'gpt-4o', name: 'GPT-4o' }], + }, + }, +})); + +vi.mock('@/lib/audio/constants', () => ({ + TTS_PROVIDERS: { + 'browser-native-tts': { + id: 'browser-native-tts', + name: 'Browser Native TTS', + requiresApiKey: false, + defaultModelId: '', + models: [], + voices: [{ id: 'default', name: 'Default', language: 'en', gender: 'neutral' }], + supportedFormats: ['browser'], + }, + }, + ASR_PROVIDERS: { + 'browser-native': { + id: 'browser-native', + name: 'Browser Native ASR', + requiresApiKey: false, + defaultModelId: '', + models: [], + supportedLanguages: ['en'], + supportedFormats: ['browser'], + }, + }, + DEFAULT_TTS_VOICES: { 'browser-native-tts': 'default' }, +})); + +vi.mock('@/lib/audio/types', () => ({})); + +vi.mock('@/lib/pdf/constants', () => ({ + PDF_PROVIDERS: { unpdf: { id: 'unpdf', requiresApiKey: false } }, +})); + +vi.mock('@/lib/media/image-providers', () => ({ + IMAGE_PROVIDERS: {}, +})); + +vi.mock('@/lib/media/video-providers', () => ({ + VIDEO_PROVIDERS: {}, +})); + +vi.mock('@/lib/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false })); + +const storage = new Map(); +vi.stubGlobal('localStorage', { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), +}); + +describe('web search store defaults', () => { + beforeEach(() => { + vi.resetModules(); + storage.clear(); + }); + + async function getStore() { + const { useSettingsStore } = await import('@/lib/store/settings'); + return useSettingsStore; + } + + it('pre-populates the default web_search tool for the claude provider', async () => { + const store = await getStore(); + const claudeConfig = store.getState().webSearchProvidersConfig.claude; + + expect(claudeConfig.tools).toContainEqual({ + type: 'web_search_20260209', + name: 'web_search', + }); + }); + + it('has at least one tool in the claude provider default config', async () => { + const store = await getStore(); + const tools = store.getState().webSearchProvidersConfig.claude.tools ?? []; + expect(tools.length).toBeGreaterThan(0); + }); + + it('populates claude provider config on rehydrate when missing from persisted state', async () => { + // Simulate persisted state that only has tavily (old format before claude was added) + storage.set( + 'openmaic-settings', + JSON.stringify({ + state: { + webSearchProviderId: 'tavily', + webSearchProvidersConfig: { + tavily: { apiKey: '', baseUrl: '', enabled: true }, + // claude missing intentionally + }, + }, + version: 0, + }), + ); + + const store = await getStore(); + const claudeConfig = store.getState().webSearchProvidersConfig.claude; + + expect(claudeConfig).toBeDefined(); + expect(claudeConfig.tools?.length).toBeGreaterThan(0); + }); + + it('setWebSearchProviderConfig persists tool changes', async () => { + const store = await getStore(); + const newTools = [ + { type: 'web_search_20260209', name: 'web_search' }, + { type: 'custom_tool', name: 'my_tool' }, + ]; + + store.getState().setWebSearchProviderConfig('claude', { tools: newTools }); + + expect(store.getState().webSearchProvidersConfig.claude.tools).toEqual(newTools); + }); +}); diff --git a/tests/web-search/claude.test.ts b/tests/web-search/claude.test.ts new file mode 100644 index 000000000..62383c070 --- /dev/null +++ b/tests/web-search/claude.test.ts @@ -0,0 +1,281 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock proxy-fetch and logger so no real HTTP requests are made +vi.mock('@/lib/server/proxy-fetch', () => ({ + proxyFetch: vi.fn(), +})); + +vi.mock('@/lib/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +import { proxyFetch } from '@/lib/server/proxy-fetch'; + +const mockProxyFetch = proxyFetch as ReturnType; + +/** Build a minimal successful Anthropic Messages API response with no sources */ +function mockApiResponse(overrides: { content?: unknown[] } = {}) { + mockProxyFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + content: overrides.content ?? [{ type: 'text', text: 'Search result', citations: [] }], + }), + }); +} + +/** Build a page-fetch response returning simple HTML */ +function mockPageResponse(html: string) { + mockProxyFetch.mockResolvedValueOnce({ + ok: true, + text: async () => html, + }); +} + +/** Build a failing page-fetch response */ +function mockPageFailure() { + mockProxyFetch.mockResolvedValueOnce({ ok: false, status: 404 }); +} + +describe('searchWithClaude', () => { + beforeEach(() => { + vi.resetModules(); + mockProxyFetch.mockReset(); + }); + + async function search(params: Parameters[0]) { + const { searchWithClaude } = await import('@/lib/web-search/claude'); + return searchWithClaude(params); + } + + // ── baseUrl ─────────────────────────────────────────────────────────────── + + it('uses the provided baseUrl to construct the messages endpoint', async () => { + mockApiResponse(); + await search({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com' }); + + const [url] = mockProxyFetch.mock.calls[0]; + expect(url).toBe('https://api.anthropic.com/v1/messages'); + }); + + it('uses a custom baseUrl when provided', async () => { + mockApiResponse(); + await search({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://custom.example.com' }); + + const [url] = mockProxyFetch.mock.calls[0]; + expect(url).toBe('https://custom.example.com/v1/messages'); + }); + + // ── tools fallback ──────────────────────────────────────────────────────── + + it('uses provided tools when non-empty', async () => { + mockApiResponse(); + const customTools = [{ type: 'web_search_custom', name: 'my_search' }]; + await search({ query: 'test', apiKey: 'sk-test', tools: customTools }); + + const body = JSON.parse(mockProxyFetch.mock.calls[0][1].body); + expect(body.tools).toEqual(customTools); + }); + + it('uses default web_search tool when tools is undefined', async () => { + mockApiResponse(); + await search({ query: 'test', apiKey: 'sk-test' }); + + const body = JSON.parse(mockProxyFetch.mock.calls[0][1].body); + expect(body.tools).toEqual([{ type: 'web_search_20260209', name: 'web_search' }]); + }); + + it('uses default web_search tool when tools is an empty array', async () => { + mockApiResponse(); + await search({ query: 'test', apiKey: 'sk-test', tools: [] }); + + const body = JSON.parse(mockProxyFetch.mock.calls[0][1].body); + expect(body.tools).toEqual([{ type: 'web_search_20260209', name: 'web_search' }]); + }); + + // ── page content fetching ──────────────────────────────────────────────── + + it('fetches page content for sources with no citation content', async () => { + mockApiResponse({ + content: [ + { + type: 'web_search_tool_result', + content: [ + { type: 'web_search_result', url: 'https://example.com', title: 'Example' }, + ], + }, + { type: 'text', text: 'Answer', citations: [] }, + ], + }); + mockPageResponse('

Page content here

'); + + const result = await search({ query: 'test', apiKey: 'sk-test' }); + + expect(result.sources).toHaveLength(1); + expect(result.sources[0].content).toBe('Page content here'); + // Second proxyFetch call should be the page fetch + expect(mockProxyFetch.mock.calls[1][0]).toBe('https://example.com'); + }); + + it('strips HTML tags and collapses whitespace from fetched page content', async () => { + mockApiResponse({ + content: [ + { + type: 'web_search_tool_result', + content: [{ type: 'web_search_result', url: 'https://example.com', title: 'Ex' }], + }, + { type: 'text', text: 'Answer', citations: [] }, + ], + }); + mockPageResponse(` + + + + +

Title

+

Some content

+ + + `); + + const result = await search({ query: 'test', apiKey: 'sk-test' }); + + expect(result.sources[0].content).not.toContain('<'); + expect(result.sources[0].content).not.toContain('alert'); + expect(result.sources[0].content).not.toContain('color: red'); + expect(result.sources[0].content).toContain('Title'); + expect(result.sources[0].content).toContain('Some content'); + }); + + it('skips page fetch for sources that already have content from citations', async () => { + mockApiResponse({ + content: [ + { + type: 'web_search_tool_result', + content: [{ type: 'web_search_result', url: 'https://example.com', title: 'Ex' }], + }, + { + type: 'text', + text: 'Answer', + citations: [ + { url: 'https://example.com', title: 'Ex', cited_text: 'Already have this content' }, + ], + }, + ], + }); + + const result = await search({ query: 'test', apiKey: 'sk-test' }); + + // Only 1 proxyFetch call — the API call; no page fetch + expect(mockProxyFetch).toHaveBeenCalledTimes(1); + expect(result.sources[0].content).toBe('Already have this content'); + }); + + it('fetches multiple sources in parallel and fills content for each', async () => { + mockApiResponse({ + content: [ + { + type: 'web_search_tool_result', + content: [ + { type: 'web_search_result', url: 'https://a.com', title: 'A' }, + { type: 'web_search_result', url: 'https://b.com', title: 'B' }, + ], + }, + { type: 'text', text: 'Answer', citations: [] }, + ], + }); + mockPageResponse('

Content A

'); + mockPageResponse('

Content B

'); + + const result = await search({ query: 'test', apiKey: 'sk-test' }); + + expect(result.sources).toHaveLength(2); + // Both pages should have been fetched + const fetchedUrls = mockProxyFetch.mock.calls.slice(1).map(([url]: [string]) => url); + expect(fetchedUrls).toContain('https://a.com'); + expect(fetchedUrls).toContain('https://b.com'); + expect(result.sources.find((s) => s.url === 'https://a.com')?.content).toContain('Content A'); + expect(result.sources.find((s) => s.url === 'https://b.com')?.content).toContain('Content B'); + }); + + it('filters out sources for which page fetch returns no content (non-ok response)', async () => { + mockApiResponse({ + content: [ + { + type: 'web_search_tool_result', + content: [{ type: 'web_search_result', url: 'https://dead.com', title: 'Dead' }], + }, + { type: 'text', text: 'Answer', citations: [] }, + ], + }); + mockPageFailure(); + + const result = await search({ query: 'test', apiKey: 'sk-test' }); + + expect(result.sources).toHaveLength(0); + }); + + it('filters out sources for which page fetch throws (network error)', async () => { + mockApiResponse({ + content: [ + { + type: 'web_search_tool_result', + content: [{ type: 'web_search_result', url: 'https://dead.com', title: 'Dead' }], + }, + { type: 'text', text: 'Answer', citations: [] }, + ], + }); + mockProxyFetch.mockRejectedValueOnce(new Error('Network timeout')); + + const result = await search({ query: 'test', apiKey: 'sk-test' }); + + expect(result.sources).toHaveLength(0); + }); + + it('keeps sources with content and drops sources without after mixed page fetches', async () => { + mockApiResponse({ + content: [ + { + type: 'web_search_tool_result', + content: [ + { type: 'web_search_result', url: 'https://good.com', title: 'Good' }, + { type: 'web_search_result', url: 'https://dead.com', title: 'Dead' }, + ], + }, + { type: 'text', text: 'Answer', citations: [] }, + ], + }); + mockPageResponse('

Good content

'); + mockPageFailure(); + + const result = await search({ query: 'test', apiKey: 'sk-test' }); + + expect(result.sources).toHaveLength(1); + expect(result.sources[0].url).toBe('https://good.com'); + }); + + // ── error propagation ───────────────────────────────────────────────────── + + it('throws when the API returns a non-ok response', async () => { + mockProxyFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + text: async () => 'invalid api key', + }); + + await expect(search({ query: 'test', apiKey: 'bad-key' })).rejects.toThrow( + /Claude API error \(401\)/, + ); + }); + + it('throws when proxyFetch rejects (network error)', async () => { + mockProxyFetch.mockRejectedValueOnce(new Error('Network failure')); + + await expect(search({ query: 'test', apiKey: 'sk-test' })).rejects.toThrow('Network failure'); + }); +}); From 94f7e6901154f5118f209f69656fbac372739b57 Mon Sep 17 00:00:00 2001 From: Joseph Mpo Yeti Date: Thu, 9 Apr 2026 16:23:40 +0100 Subject: [PATCH 02/26] New locales for i18n --- lib/i18n/locales/en-US.json | 31 ++++++++++++++++++++++++++----- lib/i18n/locales/ja-JP.json | 31 ++++++++++++++++++++++++++----- lib/i18n/locales/ru-RU.json | 32 ++++++++++++++++++++++++++------ lib/i18n/locales/zh-CN.json | 31 ++++++++++++++++++++++++++----- 4 files changed, 104 insertions(+), 21 deletions(-) diff --git a/lib/i18n/locales/en-US.json b/lib/i18n/locales/en-US.json index 997857983..50c1d4ea8 100644 --- a/lib/i18n/locales/en-US.json +++ b/lib/i18n/locales/en-US.json @@ -842,13 +842,34 @@ "clearCacheSuccess": "Cache cleared, page will refresh shortly", "clearCacheFailed": "Failed to clear cache, please try again", "webSearchSettings": "Web Search", - "webSearchApiKey": "Tavily API Key", - "webSearchApiKeyPlaceholder": "Enter your Tavily API Key", + "webSearchApiKey": "API Key", + "webSearchApiKeyPlaceholder": "Enter API Key", "webSearchApiKeyPlaceholderServer": "Server key configured, optionally override", - "webSearchApiKeyHint": "Get an API key from tavily.com for web search", + "webSearchApiKeyHint": "Enter the API key provided by the search service", "webSearchBaseUrl": "Base URL", - "webSearchServerConfigured": "Server-side Tavily API key is configured", - "optional": "Optional" + "webSearchServerConfigured": "Server-side API key is configured", + "optional": "Optional", + "webSearchTavilyApiKey": "Tavily API Key", + "webSearchClaudeApiKey": "Claude API Key", + "webSearchModelId": "Model ID", + "webSearchApiVersion": "API Version", + "webSearchToolsConfiguration": "Tools", + "webSearchNewTool": "New Tool", + "webSearchNoTools": "No tools configured.", + "webSearchAddToolTitle": "Add Tool", + "webSearchEditToolTitle": "Edit Tool", + "webSearchToolDialogDesc": "Configure the tool's type and name for Claude search.", + "webSearchToolType": "Type", + "webSearchToolName": "Name", + "webSearchModelsConfiguration": "Models", + "webSearchNewModel": "New Model", + "webSearchNoModels": "No models configured.", + "webSearchNoModelsHint": "Add at least one model to use Claude search.", + "webSearchAddModelTitle": "Add Model", + "webSearchEditModelTitle": "Edit Model", + "webSearchModelDialogDesc": "Configure the model's ID and name for Claude search.", + "webSearchModelIdField": "Model ID", + "webSearchModelNameField": "Model Name" }, "profile": { "title": "Profile", diff --git a/lib/i18n/locales/ja-JP.json b/lib/i18n/locales/ja-JP.json index 6d82ebf07..85e73fbf6 100644 --- a/lib/i18n/locales/ja-JP.json +++ b/lib/i18n/locales/ja-JP.json @@ -842,13 +842,34 @@ "clearCacheSuccess": "キャッシュをクリアしました。まもなくページが更新されます", "clearCacheFailed": "キャッシュのクリアに失敗しました。もう一度お試しください", "webSearchSettings": "ウェブ検索", - "webSearchApiKey": "Tavily APIキー", - "webSearchApiKeyPlaceholder": "Tavily APIキーを入力", + "webSearchApiKey": "API Key", + "webSearchApiKeyPlaceholder": "Enter API Key", "webSearchApiKeyPlaceholderServer": "サーバーキー設定済み、任意で上書き", - "webSearchApiKeyHint": "ウェブ検索用のAPIキーをtavily.comで取得してください", + "webSearchApiKeyHint": "Enter the API key provided by the search service", "webSearchBaseUrl": "ベースURL", - "webSearchServerConfigured": "サーバー側でTavily APIキーが設定済みです", - "optional": "任意" + "webSearchServerConfigured": "Server-side API key is configured", + "optional": "任意", + "webSearchTavilyApiKey": "Tavily API Key", + "webSearchClaudeApiKey": "Claude API Key", + "webSearchModelId": "Model ID", + "webSearchApiVersion": "API Version", + "webSearchToolsConfiguration": "Tools", + "webSearchNewTool": "New Tool", + "webSearchNoTools": "No tools configured.", + "webSearchAddToolTitle": "Add Tool", + "webSearchEditToolTitle": "Edit Tool", + "webSearchToolDialogDesc": "Configure the tool's type and name for Claude search.", + "webSearchToolType": "Type", + "webSearchToolName": "Name", + "webSearchModelsConfiguration": "Models", + "webSearchNewModel": "New Model", + "webSearchNoModels": "No models configured.", + "webSearchNoModelsHint": "Add at least one model to use Claude search.", + "webSearchAddModelTitle": "Add Model", + "webSearchEditModelTitle": "Edit Model", + "webSearchModelDialogDesc": "Configure the model's ID and name for Claude search.", + "webSearchModelIdField": "Model ID", + "webSearchModelNameField": "Model Name" }, "profile": { "title": "プロフィール", diff --git a/lib/i18n/locales/ru-RU.json b/lib/i18n/locales/ru-RU.json index 9afa76e68..f6e42a91a 100644 --- a/lib/i18n/locales/ru-RU.json +++ b/lib/i18n/locales/ru-RU.json @@ -690,7 +690,6 @@ "lang_fa": "فارسی", "lang_pl": "Polski", "lang_ro": "Română", - "lang_ru": "Русский", "lang_sr": "Српски", "lang_sk": "Slovenčina", "lang_sl": "Slovenščina", @@ -843,13 +842,34 @@ "clearCacheSuccess": "Кэш очищен, страница скоро обновится", "clearCacheFailed": "Не удалось очистить кэш, попробуйте снова", "webSearchSettings": "Веб-поиск", - "webSearchApiKey": "Tavily API-ключ", - "webSearchApiKeyPlaceholder": "Введите ваш Tavily API-ключ", + "webSearchApiKey": "API Key", + "webSearchApiKeyPlaceholder": "Enter API Key", "webSearchApiKeyPlaceholderServer": "Серверный ключ настроен, можно ввести свой", - "webSearchApiKeyHint": "Получите API-ключ на tavily.com для веб-поиска", + "webSearchApiKeyHint": "Enter the API key provided by the search service", "webSearchBaseUrl": "Base URL", - "webSearchServerConfigured": "Серверный Tavily API-ключ настроен", - "optional": "Необязательно" + "webSearchServerConfigured": "Server-side API key is configured", + "optional": "Необязательно", + "webSearchTavilyApiKey": "Tavily API Key", + "webSearchClaudeApiKey": "Claude API Key", + "webSearchModelId": "Model ID", + "webSearchApiVersion": "API Version", + "webSearchToolsConfiguration": "Tools", + "webSearchNewTool": "New Tool", + "webSearchNoTools": "No tools configured.", + "webSearchAddToolTitle": "Add Tool", + "webSearchEditToolTitle": "Edit Tool", + "webSearchToolDialogDesc": "Configure the tool's type and name for Claude search.", + "webSearchToolType": "Type", + "webSearchToolName": "Name", + "webSearchModelsConfiguration": "Models", + "webSearchNewModel": "New Model", + "webSearchNoModels": "No models configured.", + "webSearchNoModelsHint": "Add at least one model to use Claude search.", + "webSearchAddModelTitle": "Add Model", + "webSearchEditModelTitle": "Edit Model", + "webSearchModelDialogDesc": "Configure the model's ID and name for Claude search.", + "webSearchModelIdField": "Model ID", + "webSearchModelNameField": "Model Name" }, "profile": { "title": "Профиль", diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index b2f7a2a79..93fa9dc21 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -842,13 +842,34 @@ "clearCacheSuccess": "缓存已清空,页面即将刷新", "clearCacheFailed": "清空缓存失败,请重试", "webSearchSettings": "网络搜索", - "webSearchApiKey": "Tavily API Key", - "webSearchApiKeyPlaceholder": "输入你的 Tavily API Key", + "webSearchApiKey": "API 密钥", + "webSearchApiKeyPlaceholder": "输入 API Key", "webSearchApiKeyPlaceholderServer": "已配置服务端密钥,可选填覆盖", - "webSearchApiKeyHint": "从 tavily.com 获取 API Key,用于网络搜索", + "webSearchApiKeyHint": "请输入相应服务商提供的 API Key,用于网络搜索", "webSearchBaseUrl": "Base URL", - "webSearchServerConfigured": "服务端已配置 Tavily API Key", - "optional": "可选" + "webSearchServerConfigured": "服务端已配置 API 密钥", + "optional": "可选", + "webSearchTavilyApiKey": "Tavily API 密钥", + "webSearchClaudeApiKey": "Claude API 密钥", + "webSearchModelId": "模型 ID", + "webSearchApiVersion": "API 版本", + "webSearchToolsConfiguration": "工具", + "webSearchNewTool": "新建工具", + "webSearchNoTools": "暂无已配置的工具", + "webSearchAddToolTitle": "添加工具", + "webSearchEditToolTitle": "编辑工具", + "webSearchToolDialogDesc": "配置 Claude 搜索工具的类型和名称", + "webSearchToolType": "类型", + "webSearchToolName": "名称", + "webSearchModelsConfiguration": "模型", + "webSearchNewModel": "新建模型", + "webSearchNoModels": "暂无已配置的模型。", + "webSearchNoModelsHint": "请添加至少一个模型才能使用 Claude 搜索。", + "webSearchAddModelTitle": "添加模型", + "webSearchEditModelTitle": "编辑模型", + "webSearchModelDialogDesc": "配置 Claude 搜索模型的 ID 和名称", + "webSearchModelIdField": "模型 ID", + "webSearchModelNameField": "模型名称" }, "profile": { "title": "个人资料", From c102a0e30117491f645f77b2f7ab71a59d4311e6 Mon Sep 17 00:00:00 2001 From: Joseph Mpo Yeti Date: Thu, 9 Apr 2026 16:32:05 +0100 Subject: [PATCH 03/26] Updated translations --- lib/i18n/locales/ja-JP.json | 50 ++++++++++++++++++------------------- lib/i18n/locales/ru-RU.json | 50 ++++++++++++++++++------------------- 2 files changed, 50 insertions(+), 50 deletions(-) diff --git a/lib/i18n/locales/ja-JP.json b/lib/i18n/locales/ja-JP.json index 85e73fbf6..a7b855c94 100644 --- a/lib/i18n/locales/ja-JP.json +++ b/lib/i18n/locales/ja-JP.json @@ -842,34 +842,34 @@ "clearCacheSuccess": "キャッシュをクリアしました。まもなくページが更新されます", "clearCacheFailed": "キャッシュのクリアに失敗しました。もう一度お試しください", "webSearchSettings": "ウェブ検索", - "webSearchApiKey": "API Key", - "webSearchApiKeyPlaceholder": "Enter API Key", + "webSearchApiKey": "APIキー", + "webSearchApiKeyPlaceholder": "APIキーを入力", "webSearchApiKeyPlaceholderServer": "サーバーキー設定済み、任意で上書き", - "webSearchApiKeyHint": "Enter the API key provided by the search service", + "webSearchApiKeyHint": "検索サービスが提供するAPIキーを入力してください", "webSearchBaseUrl": "ベースURL", - "webSearchServerConfigured": "Server-side API key is configured", + "webSearchServerConfigured": "サーバー側のAPIキーが設定済みです", "optional": "任意", - "webSearchTavilyApiKey": "Tavily API Key", - "webSearchClaudeApiKey": "Claude API Key", - "webSearchModelId": "Model ID", - "webSearchApiVersion": "API Version", - "webSearchToolsConfiguration": "Tools", - "webSearchNewTool": "New Tool", - "webSearchNoTools": "No tools configured.", - "webSearchAddToolTitle": "Add Tool", - "webSearchEditToolTitle": "Edit Tool", - "webSearchToolDialogDesc": "Configure the tool's type and name for Claude search.", - "webSearchToolType": "Type", - "webSearchToolName": "Name", - "webSearchModelsConfiguration": "Models", - "webSearchNewModel": "New Model", - "webSearchNoModels": "No models configured.", - "webSearchNoModelsHint": "Add at least one model to use Claude search.", - "webSearchAddModelTitle": "Add Model", - "webSearchEditModelTitle": "Edit Model", - "webSearchModelDialogDesc": "Configure the model's ID and name for Claude search.", - "webSearchModelIdField": "Model ID", - "webSearchModelNameField": "Model Name" + "webSearchTavilyApiKey": "Tavily APIキー", + "webSearchClaudeApiKey": "Claude APIキー", + "webSearchModelId": "モデルID", + "webSearchApiVersion": "APIバージョン", + "webSearchToolsConfiguration": "ツール", + "webSearchNewTool": "新規ツール", + "webSearchNoTools": "ツールが設定されていません。", + "webSearchAddToolTitle": "ツールを追加", + "webSearchEditToolTitle": "ツールを編集", + "webSearchToolDialogDesc": "Claude検索用のツールタイプと名前を設定します。", + "webSearchToolType": "タイプ", + "webSearchToolName": "名前", + "webSearchModelsConfiguration": "モデル", + "webSearchNewModel": "新規モデル", + "webSearchNoModels": "モデルが設定されていません。", + "webSearchNoModelsHint": "Claude検索を使用するには、少なくとも1つのモデルを追加してください。", + "webSearchAddModelTitle": "モデルを追加", + "webSearchEditModelTitle": "モデルを編集", + "webSearchModelDialogDesc": "Claude検索用のモデルIDと名前を設定します。", + "webSearchModelIdField": "モデルID", + "webSearchModelNameField": "モデル名" }, "profile": { "title": "プロフィール", diff --git a/lib/i18n/locales/ru-RU.json b/lib/i18n/locales/ru-RU.json index f6e42a91a..39213bf0d 100644 --- a/lib/i18n/locales/ru-RU.json +++ b/lib/i18n/locales/ru-RU.json @@ -842,34 +842,34 @@ "clearCacheSuccess": "Кэш очищен, страница скоро обновится", "clearCacheFailed": "Не удалось очистить кэш, попробуйте снова", "webSearchSettings": "Веб-поиск", - "webSearchApiKey": "API Key", - "webSearchApiKeyPlaceholder": "Enter API Key", + "webSearchApiKey": "API-ключ", + "webSearchApiKeyPlaceholder": "Введите API-ключ", "webSearchApiKeyPlaceholderServer": "Серверный ключ настроен, можно ввести свой", - "webSearchApiKeyHint": "Enter the API key provided by the search service", + "webSearchApiKeyHint": "Введите API-ключ, предоставленный сервисом поиска", "webSearchBaseUrl": "Base URL", - "webSearchServerConfigured": "Server-side API key is configured", + "webSearchServerConfigured": "API-ключ настроен на стороне сервера", "optional": "Необязательно", - "webSearchTavilyApiKey": "Tavily API Key", - "webSearchClaudeApiKey": "Claude API Key", - "webSearchModelId": "Model ID", - "webSearchApiVersion": "API Version", - "webSearchToolsConfiguration": "Tools", - "webSearchNewTool": "New Tool", - "webSearchNoTools": "No tools configured.", - "webSearchAddToolTitle": "Add Tool", - "webSearchEditToolTitle": "Edit Tool", - "webSearchToolDialogDesc": "Configure the tool's type and name for Claude search.", - "webSearchToolType": "Type", - "webSearchToolName": "Name", - "webSearchModelsConfiguration": "Models", - "webSearchNewModel": "New Model", - "webSearchNoModels": "No models configured.", - "webSearchNoModelsHint": "Add at least one model to use Claude search.", - "webSearchAddModelTitle": "Add Model", - "webSearchEditModelTitle": "Edit Model", - "webSearchModelDialogDesc": "Configure the model's ID and name for Claude search.", - "webSearchModelIdField": "Model ID", - "webSearchModelNameField": "Model Name" + "webSearchTavilyApiKey": "Tavily API-ключ", + "webSearchClaudeApiKey": "Claude API-ключ", + "webSearchModelId": "ID модели", + "webSearchApiVersion": "Версия API", + "webSearchToolsConfiguration": "Инструменты", + "webSearchNewTool": "Новый инструмент", + "webSearchNoTools": "Инструменты не настроены.", + "webSearchAddToolTitle": "Добавить инструмент", + "webSearchEditToolTitle": "Редактировать инструмент", + "webSearchToolDialogDesc": "Настройте тип и название инструмента для поиска Claude.", + "webSearchToolType": "Тип", + "webSearchToolName": "Название", + "webSearchModelsConfiguration": "Модели", + "webSearchNewModel": "Новая модель", + "webSearchNoModels": "Модели не настроены.", + "webSearchNoModelsHint": "Добавьте хотя бы одну модель для использования поиска Claude.", + "webSearchAddModelTitle": "Добавить модель", + "webSearchEditModelTitle": "Редактировать модель", + "webSearchModelDialogDesc": "Настройте ID и название модели для поиска Claude.", + "webSearchModelIdField": "ID модели", + "webSearchModelNameField": "Название модели" }, "profile": { "title": "Профиль", From 982623e78f85281ea5677a353a982add5a85333e Mon Sep 17 00:00:00 2001 From: Joseph Mpo Yeti Date: Thu, 9 Apr 2026 16:43:33 +0100 Subject: [PATCH 04/26] Prevent default web search base URLs from being prepopulated --- components/settings/index.tsx | 2 +- components/settings/web-search-settings.tsx | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/components/settings/index.tsx b/components/settings/index.tsx index 13a2ec11e..e625904ec 100644 --- a/components/settings/index.tsx +++ b/components/settings/index.tsx @@ -211,7 +211,7 @@ export function SettingsDialog({ open, onOpenChange, initialSection }: SettingsD const [selectedProviderId, setSelectedProviderId] = useState(providerId); const [selectedPdfProviderId, setSelectedPdfProviderId] = useState(pdfProviderId); const [selectedWebSearchProviderId, setSelectedWebSearchProviderId] = - useState(webSearchProviderId); + useState(webSearchProviderId ?? 'tavily'); const [selectedImageProviderId, setSelectedImageProviderId] = useState(imageProviderId); const [selectedVideoProviderId, setSelectedVideoProviderId] = diff --git a/components/settings/web-search-settings.tsx b/components/settings/web-search-settings.tsx index 1a1a1619b..ff374418c 100644 --- a/components/settings/web-search-settings.tsx +++ b/components/settings/web-search-settings.tsx @@ -266,7 +266,12 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps autoCorrect="off" spellCheck={false} placeholder={provider.defaultBaseUrl || 'https://api.example.com'} - value={webSearchProvidersConfig[selectedProviderId]?.baseUrl || ''} + value={ + webSearchProvidersConfig[selectedProviderId]?.baseUrl === + (WEB_SEARCH_PROVIDERS[selectedProviderId]?.defaultBaseUrl ?? '') + ? '' + : webSearchProvidersConfig[selectedProviderId]?.baseUrl || '' + } onChange={(e) => setWebSearchProviderConfig(selectedProviderId, { baseUrl: e.target.value, From c8b6b4a10aef99aacec4caf3ade9dc8a5b2c4b2d Mon Sep 17 00:00:00 2001 From: Joseph Mpo Yeti Date: Thu, 9 Apr 2026 16:53:09 +0100 Subject: [PATCH 05/26] fixed coding style issues --- components/generation/generation-toolbar.tsx | 167 +++++++++++------- components/settings/index.tsx | 4 +- components/settings/tool-edit-dialog.tsx | 6 +- .../settings/web-search-model-dialog.tsx | 4 +- components/settings/web-search-settings.tsx | 134 ++++++++------ lib/server/classroom-generation.ts | 5 +- lib/server/provider-config.ts | 3 +- lib/store/settings.ts | 16 +- lib/web-search/claude.ts | 12 +- lib/web-search/constants.ts | 16 +- tests/web-search/claude.test.ts | 8 +- 11 files changed, 229 insertions(+), 146 deletions(-) diff --git a/components/generation/generation-toolbar.tsx b/components/generation/generation-toolbar.tsx index e4a857721..6ea7b9761 100644 --- a/components/generation/generation-toolbar.tsx +++ b/components/generation/generation-toolbar.tsx @@ -1,7 +1,17 @@ 'use client'; import { useState, useRef, useMemo } from 'react'; -import { Bot, Check, ChevronLeft, Globe, Paperclip, FileText, X, Globe2, ChevronRight } from 'lucide-react'; +import { + Bot, + Check, + ChevronLeft, + Globe, + Paperclip, + FileText, + X, + Globe2, + ChevronRight, +} from 'lucide-react'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Select, @@ -66,7 +76,9 @@ export function GenerationToolbar({ const fileInputRef = useRef(null); const [isDragging, setIsDragging] = useState(false); const [webSearchPopoverOpen, setWebSearchPopoverOpen] = useState(false); - const [drillWebSearchProvider, setDrillWebSearchProvider] = useState(null); + const [drillWebSearchProvider, setDrillWebSearchProvider] = useState( + null, + ); // Check if any web search provider has a valid config (API key or server-configured) const webSearchAvailable = Object.values(WEB_SEARCH_PROVIDERS).some((provider) => { @@ -288,7 +300,10 @@ export function GenerationToolbar({ > - {models.map((model) => { - const isSelected = selectedModelId === model.id; - return ( - - ); - })} -
- ); - })()} + {drillWebSearchProvider && + (() => { + const cfg = webSearchProvidersConfig[drillWebSearchProvider]; + const provider = WEB_SEARCH_PROVIDERS[drillWebSearchProvider]; + const models = cfg?.models || []; + const selectedModelId = cfg?.modelId || ''; + return ( +
+ + {models.map((model) => { + const isSelected = selectedModelId === model.id; + return ( + + ); + })} +
+ ); + })()} @@ -449,7 +497,8 @@ export function GenerationToolbar({ ) : webSearch && webSearchProviderId ? ( {(() => { - const providerName = WEB_SEARCH_PROVIDERS[webSearchProviderId]?.name || webSearchProviderId; + const providerName = + WEB_SEARCH_PROVIDERS[webSearchProviderId]?.name || webSearchProviderId; const cfg = webSearchProvidersConfig[webSearchProviderId]; if (webSearchProviderId === 'claude' && cfg?.modelId) { const models = cfg.models || []; diff --git a/components/settings/index.tsx b/components/settings/index.tsx index e625904ec..4feac512a 100644 --- a/components/settings/index.tsx +++ b/components/settings/index.tsx @@ -556,7 +556,9 @@ export function SettingsDialog({ open, onOpenChange, initialSection }: SettingsD ); } case 'web-search': { - const wsProvider = selectedWebSearchProviderId ? WEB_SEARCH_PROVIDERS[selectedWebSearchProviderId] : null; + const wsProvider = selectedWebSearchProviderId + ? WEB_SEARCH_PROVIDERS[selectedWebSearchProviderId] + : null; if (!wsProvider) return null; return ( <> diff --git a/components/settings/tool-edit-dialog.tsx b/components/settings/tool-edit-dialog.tsx index 4d81f35f6..8c3e66b15 100644 --- a/components/settings/tool-edit-dialog.tsx +++ b/components/settings/tool-edit-dialog.tsx @@ -33,7 +33,11 @@ export function ToolEditDialog({ open, onOpenChange, tool, setTool, onSave }: To return ( - {tool.type === '' ? t('settings.webSearchAddToolTitle') : t('settings.webSearchEditToolTitle')} + + {tool.type === '' + ? t('settings.webSearchAddToolTitle') + : t('settings.webSearchEditToolTitle')} + {t('settings.webSearchToolDialogDesc')}
diff --git a/components/settings/web-search-model-dialog.tsx b/components/settings/web-search-model-dialog.tsx index 37b648041..134a51622 100644 --- a/components/settings/web-search-model-dialog.tsx +++ b/components/settings/web-search-model-dialog.tsx @@ -150,9 +150,7 @@ export function WebSearchModelDialog({ )} >
- {testStatus === 'success' && ( - - )} + {testStatus === 'success' && } {testStatus === 'error' && }

{testMessage}

diff --git a/components/settings/web-search-settings.tsx b/components/settings/web-search-settings.tsx index ff374418c..3fcab41f4 100644 --- a/components/settings/web-search-settings.tsx +++ b/components/settings/web-search-settings.tsx @@ -8,7 +8,17 @@ import { useI18n } from '@/lib/hooks/use-i18n'; import { useSettingsStore } from '@/lib/store/settings'; import { WEB_SEARCH_PROVIDERS } from '@/lib/web-search/constants'; import type { WebSearchProviderId } from '@/lib/web-search/types'; -import { Eye, EyeOff, Trash2, Settings2, Plus, Zap, Loader2, CheckCircle2, XCircle } from 'lucide-react'; +import { + Eye, + EyeOff, + Trash2, + Settings2, + Plus, + Zap, + Loader2, + CheckCircle2, + XCircle, +} from 'lucide-react'; import { cn } from '@/lib/utils'; import { ToolEditDialog } from './tool-edit-dialog'; import { WebSearchModelDialog } from './web-search-model-dialog'; @@ -49,7 +59,8 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps const config = webSearchProvidersConfig[selectedProviderId]; const apiKey = config?.apiKey || ''; - const baseUrl = config?.baseUrl || WEB_SEARCH_PROVIDERS[selectedProviderId]?.defaultBaseUrl || ''; + const baseUrl = + config?.baseUrl || WEB_SEARCH_PROVIDERS[selectedProviderId]?.defaultBaseUrl || ''; try { if (selectedProviderId === 'claude') { @@ -224,7 +235,9 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps onClick={handleTestConnection} disabled={ testStatus === 'testing' || - (provider.requiresApiKey && !webSearchProvidersConfig[selectedProviderId]?.apiKey && !isServerConfigured) + (provider.requiresApiKey && + !webSearchProvidersConfig[selectedProviderId]?.apiKey && + !isServerConfigured) } className="gap-1.5" > @@ -242,8 +255,10 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps
@@ -288,7 +303,8 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps const endpointPath = selectedProviderId === 'claude' ? '/v1/messages' : '/search'; return (

- {t('settings.requestUrl')}: {effectiveBaseUrl}{endpointPath} + {t('settings.requestUrl')}: {effectiveBaseUrl} + {endpointPath}

); })()} @@ -297,53 +313,64 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps {selectedProviderId === 'claude' && (
-
- - -
-
- {models.length === 0 ? ( -

{t('settings.webSearchNoModels')} {t('settings.webSearchNoModelsHint')}

- ) : ( - models.map((model, index) => ( -
-
-
{model.name}
-
{model.id}
-
-
- - -
+
+ + +
+
+ {models.length === 0 ? ( +

+ {t('settings.webSearchNoModels')} {t('settings.webSearchNoModelsHint')} +

+ ) : ( + models.map((model, index) => ( +
+
+
{model.name}
+
{model.id}
+
+
+ +
- )) - )} -
+
+ )) + )}
+
- +
{tools.length === 0 ? ( -

{t('settings.webSearchNoTools')}

+

+ {t('settings.webSearchNoTools')} +

) : ( tools.map((tool, index) => (
)} - )} @@ -409,7 +437,11 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps onSave={handleSaveModel} isEditing={editingModelIndex !== null} apiKey={webSearchProvidersConfig[selectedProviderId]?.apiKey || ''} - baseUrl={webSearchProvidersConfig[selectedProviderId]?.baseUrl || WEB_SEARCH_PROVIDERS[selectedProviderId]?.defaultBaseUrl || ''} + baseUrl={ + webSearchProvidersConfig[selectedProviderId]?.baseUrl || + WEB_SEARCH_PROVIDERS[selectedProviderId]?.defaultBaseUrl || + '' + } />
); diff --git a/lib/server/classroom-generation.ts b/lib/server/classroom-generation.ts index ba34c7228..d1a63cb79 100644 --- a/lib/server/classroom-generation.ts +++ b/lib/server/classroom-generation.ts @@ -20,10 +20,7 @@ import { isProviderKeyRequired } from '@/lib/ai/providers'; import { resolveWebSearchApiKey } from '@/lib/server/provider-config'; import { resolveModel } from '@/lib/server/resolve-model'; import { buildSearchQuery } from '@/lib/server/search-query-builder'; -import { - searchWithTavily, - formatSearchResultsAsContext, -} from '@/lib/web-search/tavily'; +import { searchWithTavily, formatSearchResultsAsContext } from '@/lib/web-search/tavily'; import { searchWithClaude } from '@/lib/web-search/claude'; import { WEB_SEARCH_PROVIDERS } from '@/lib/web-search/constants'; import { persistClassroom } from '@/lib/server/classroom-storage'; diff --git a/lib/server/provider-config.ts b/lib/server/provider-config.ts index 4e92b3827..74238834e 100644 --- a/lib/server/provider-config.ts +++ b/lib/server/provider-config.ts @@ -420,6 +420,7 @@ export function resolveWebSearchApiKey(providerId: string, clientKey?: string): const serverKey = getConfig().webSearch[providerId]?.apiKey; if (serverKey) return serverKey; // Claude web search reuses the standard Anthropic API key - const envVar = providerId === 'claude' ? 'ANTHROPIC_API_KEY' : `${providerId.toUpperCase()}_API_KEY`; + const envVar = + providerId === 'claude' ? 'ANTHROPIC_API_KEY' : `${providerId.toUpperCase()}_API_KEY`; return process.env[envVar] || ''; } diff --git a/lib/store/settings.ts b/lib/store/settings.ts index 338e813d8..3edfc3bd3 100644 --- a/lib/store/settings.ts +++ b/lib/store/settings.ts @@ -363,7 +363,17 @@ const getDefaultWebSearchConfig = () => ({ tools: [{ type: 'web_search_20260209', name: 'web_search' }], models: WEB_SEARCH_PROVIDERS.claude?.models?.map((m) => ({ id: m.id, name: m.name })) ?? [], }, - } as Record; models?: Array<{ id: string; name: string }> }>, + } as Record< + WebSearchProviderId, + { + apiKey: string; + baseUrl: string; + enabled: boolean; + modelId?: string; + tools?: Array<{ type: string; name: string }>; + models?: Array<{ id: string; name: string }>; + } + >, }); /** @@ -821,9 +831,7 @@ export const useSettingsStore = create()( ...config, }; const apiKeyRemoved = - 'apiKey' in config && - !config.apiKey && - !updatedProviderConfig.isServerConfigured; + 'apiKey' in config && !config.apiKey && !updatedProviderConfig.isServerConfigured; const isSelected = state.webSearchProviderId === providerId; return { webSearchProvidersConfig: { diff --git a/lib/web-search/claude.ts b/lib/web-search/claude.ts index d4e457274..4a1739f8e 100644 --- a/lib/web-search/claude.ts +++ b/lib/web-search/claude.ts @@ -53,13 +53,7 @@ export async function searchWithClaude(params: { baseUrl: string; tools?: Array<{ type: string; name: string }>; }): Promise { - const { - query, - apiKey, - modelId = 'claude-sonnet-4-6', - baseUrl, - tools, - } = params; + const { query, apiKey, modelId = 'claude-sonnet-4-6', baseUrl, tools } = params; const apiVersion = '2023-06-01'; @@ -84,9 +78,7 @@ export async function searchWithClaude(params: { content: `Search for the following and provide a comprehensive summary with source links: ${query}.`, }, ], - tools: tools?.length - ? tools - : [{ type: 'web_search_20260209', name: 'web_search' }], + tools: tools?.length ? tools : [{ type: 'web_search_20260209', name: 'web_search' }], }), }); diff --git a/lib/web-search/constants.ts b/lib/web-search/constants.ts index 13319e5f3..dbaab46de 100644 --- a/lib/web-search/constants.ts +++ b/lib/web-search/constants.ts @@ -22,14 +22,14 @@ export const WEB_SEARCH_PROVIDERS: Record { mockProxyFetch.mockReset(); }); - async function search(params: Parameters[0]) { + async function search( + params: Parameters[0], + ) { const { searchWithClaude } = await import('@/lib/web-search/claude'); return searchWithClaude(params); } @@ -104,9 +106,7 @@ describe('searchWithClaude', () => { content: [ { type: 'web_search_tool_result', - content: [ - { type: 'web_search_result', url: 'https://example.com', title: 'Example' }, - ], + content: [{ type: 'web_search_result', url: 'https://example.com', title: 'Example' }], }, { type: 'text', text: 'Answer', citations: [] }, ], From 5595cc53761153d1816e17385fb23176b80691f0 Mon Sep 17 00:00:00 2001 From: Joseph Mpo Yeti Date: Fri, 10 Apr 2026 01:35:01 +0100 Subject: [PATCH 06/26] fixed coding style issues --- lib/web-search/claude.ts | 23 ++++++++++- tests/web-search/claude.test.ts | 70 ++++++++++++++++++++++++++------- 2 files changed, 76 insertions(+), 17 deletions(-) diff --git a/lib/web-search/claude.ts b/lib/web-search/claude.ts index 4a1739f8e..0357545f7 100644 --- a/lib/web-search/claude.ts +++ b/lib/web-search/claude.ts @@ -12,6 +12,25 @@ import type { WebSearchResult, WebSearchSource } from '@/lib/types/web-search'; const PAGE_CONTENT_MAX_LENGTH = 2000; const PAGE_FETCH_TIMEOUT_MS = 5000; +interface SearchContent { + url: string; + title: string; + content?: string; + type?: string; + cited_text?: string; +} + +interface SearchResultItem { + type: string; + content?: SearchContent[]; + citations?: SearchContent[]; + text?: string; +} + +interface SearchResult { + content: SearchResultItem[]; +} + /** Fetch a URL and return plain text extracted from its HTML. Returns empty string on any failure. */ async function fetchPageContent(url: string): Promise { log.info(`Fetching page content: ${url}`); @@ -87,8 +106,8 @@ export async function searchWithClaude(params: { throw new Error(`Claude API error (${res.status}): ${errorText || res.statusText}`); } - const data = (await res.json()) as any; - const contentBlocks: any[] = data.content || []; + const data = (await res.json()) as SearchResult; + const contentBlocks: SearchResultItem[] = data.content || []; // Extract search results from web_search_tool_result blocks const searchResultMap = new Map(); diff --git a/tests/web-search/claude.test.ts b/tests/web-search/claude.test.ts index 7f3e485b6..c16442a8c 100644 --- a/tests/web-search/claude.test.ts +++ b/tests/web-search/claude.test.ts @@ -77,7 +77,12 @@ describe('searchWithClaude', () => { it('uses provided tools when non-empty', async () => { mockApiResponse(); const customTools = [{ type: 'web_search_custom', name: 'my_search' }]; - await search({ query: 'test', apiKey: 'sk-test', tools: customTools }); + await search({ + query: 'test', + apiKey: 'sk-test', + baseUrl: 'https://api.anthropic.com', + tools: customTools, + }); const body = JSON.parse(mockProxyFetch.mock.calls[0][1].body); expect(body.tools).toEqual(customTools); @@ -85,7 +90,7 @@ describe('searchWithClaude', () => { it('uses default web_search tool when tools is undefined', async () => { mockApiResponse(); - await search({ query: 'test', apiKey: 'sk-test' }); + await search({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com' }); const body = JSON.parse(mockProxyFetch.mock.calls[0][1].body); expect(body.tools).toEqual([{ type: 'web_search_20260209', name: 'web_search' }]); @@ -93,7 +98,12 @@ describe('searchWithClaude', () => { it('uses default web_search tool when tools is an empty array', async () => { mockApiResponse(); - await search({ query: 'test', apiKey: 'sk-test', tools: [] }); + await search({ + query: 'test', + apiKey: 'sk-test', + baseUrl: 'https://api.anthropic.com', + tools: [], + }); const body = JSON.parse(mockProxyFetch.mock.calls[0][1].body); expect(body.tools).toEqual([{ type: 'web_search_20260209', name: 'web_search' }]); @@ -113,7 +123,11 @@ describe('searchWithClaude', () => { }); mockPageResponse('

Page content here

'); - const result = await search({ query: 'test', apiKey: 'sk-test' }); + const result = await search({ + query: 'test', + apiKey: 'sk-test', + baseUrl: 'https://api.anthropic.com', + }); expect(result.sources).toHaveLength(1); expect(result.sources[0].content).toBe('Page content here'); @@ -142,7 +156,11 @@ describe('searchWithClaude', () => { `); - const result = await search({ query: 'test', apiKey: 'sk-test' }); + const result = await search({ + query: 'test', + apiKey: 'sk-test', + baseUrl: 'https://api.anthropic.com', + }); expect(result.sources[0].content).not.toContain('<'); expect(result.sources[0].content).not.toContain('alert'); @@ -168,7 +186,11 @@ describe('searchWithClaude', () => { ], }); - const result = await search({ query: 'test', apiKey: 'sk-test' }); + const result = await search({ + query: 'test', + apiKey: 'sk-test', + baseUrl: 'https://api.anthropic.com', + }); // Only 1 proxyFetch call — the API call; no page fetch expect(mockProxyFetch).toHaveBeenCalledTimes(1); @@ -191,11 +213,15 @@ describe('searchWithClaude', () => { mockPageResponse('

Content A

'); mockPageResponse('

Content B

'); - const result = await search({ query: 'test', apiKey: 'sk-test' }); + const result = await search({ + query: 'test', + apiKey: 'sk-test', + baseUrl: 'https://api.anthropic.com', + }); expect(result.sources).toHaveLength(2); // Both pages should have been fetched - const fetchedUrls = mockProxyFetch.mock.calls.slice(1).map(([url]: [string]) => url); + const fetchedUrls = mockProxyFetch.mock.calls.slice(1).map((call: string[]) => call[0]); expect(fetchedUrls).toContain('https://a.com'); expect(fetchedUrls).toContain('https://b.com'); expect(result.sources.find((s) => s.url === 'https://a.com')?.content).toContain('Content A'); @@ -214,7 +240,11 @@ describe('searchWithClaude', () => { }); mockPageFailure(); - const result = await search({ query: 'test', apiKey: 'sk-test' }); + const result = await search({ + query: 'test', + apiKey: 'sk-test', + baseUrl: 'https://api.anthropic.com', + }); expect(result.sources).toHaveLength(0); }); @@ -231,7 +261,11 @@ describe('searchWithClaude', () => { }); mockProxyFetch.mockRejectedValueOnce(new Error('Network timeout')); - const result = await search({ query: 'test', apiKey: 'sk-test' }); + const result = await search({ + query: 'test', + apiKey: 'sk-test', + baseUrl: 'https://api.anthropic.com', + }); expect(result.sources).toHaveLength(0); }); @@ -252,7 +286,11 @@ describe('searchWithClaude', () => { mockPageResponse('

Good content

'); mockPageFailure(); - const result = await search({ query: 'test', apiKey: 'sk-test' }); + const result = await search({ + query: 'test', + apiKey: 'sk-test', + baseUrl: 'https://api.anthropic.com', + }); expect(result.sources).toHaveLength(1); expect(result.sources[0].url).toBe('https://good.com'); @@ -268,14 +306,16 @@ describe('searchWithClaude', () => { text: async () => 'invalid api key', }); - await expect(search({ query: 'test', apiKey: 'bad-key' })).rejects.toThrow( - /Claude API error \(401\)/, - ); + await expect( + search({ query: 'test', apiKey: 'bad-key', baseUrl: 'https://api.anthropic.com' }), + ).rejects.toThrow(/Claude API error \(401\)/); }); it('throws when proxyFetch rejects (network error)', async () => { mockProxyFetch.mockRejectedValueOnce(new Error('Network failure')); - await expect(search({ query: 'test', apiKey: 'sk-test' })).rejects.toThrow('Network failure'); + await expect( + search({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com' }), + ).rejects.toThrow('Network failure'); }); }); From c50e454df48aaee1f7631e855242072236b094af Mon Sep 17 00:00:00 2001 From: Joseph Mpo Yeti Date: Fri, 10 Apr 2026 01:40:38 +0100 Subject: [PATCH 07/26] fixing lint errors --- components/settings/web-search-model-dialog.tsx | 2 +- components/settings/web-search-settings.tsx | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/components/settings/web-search-model-dialog.tsx b/components/settings/web-search-model-dialog.tsx index 134a51622..36dfeee67 100644 --- a/components/settings/web-search-model-dialog.tsx +++ b/components/settings/web-search-model-dialog.tsx @@ -70,7 +70,7 @@ export function WebSearchModelDialog({ setTestStatus('error'); setTestMessage(t('settings.connectionFailed')); } - }, [canTest, model, apiKey, baseUrl, t]); + }, [canTest, model, apiKey, t]); const handleOpenChange = (open: boolean) => { if (!open) { diff --git a/components/settings/web-search-settings.tsx b/components/settings/web-search-settings.tsx index 3fcab41f4..2df156745 100644 --- a/components/settings/web-search-settings.tsx +++ b/components/settings/web-search-settings.tsx @@ -59,8 +59,6 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps const config = webSearchProvidersConfig[selectedProviderId]; const apiKey = config?.apiKey || ''; - const baseUrl = - config?.baseUrl || WEB_SEARCH_PROVIDERS[selectedProviderId]?.defaultBaseUrl || ''; try { if (selectedProviderId === 'claude') { From b633b5f1a9e65fdb77353071c77804e595a1591e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:47:05 +0000 Subject: [PATCH 08/26] fix: add SSRF protection to fetchPageContent in claude.ts Agent-Logs-Url: https://github.com/joseph-mpo-yeti/OpenMAIC/sessions/099e53fb-e1db-49f4-af7f-4f17cb3d364a Co-authored-by: joseph-mpo-yeti <55380155+joseph-mpo-yeti@users.noreply.github.com> --- lib/web-search/claude.ts | 10 +++- tests/web-search/claude.test.ts | 95 +++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/lib/web-search/claude.ts b/lib/web-search/claude.ts index 0357545f7..b8fbfa9c0 100644 --- a/lib/web-search/claude.ts +++ b/lib/web-search/claude.ts @@ -6,6 +6,7 @@ */ import { proxyFetch } from '@/lib/server/proxy-fetch'; +import { validateUrlForSSRF } from '@/lib/server/ssrf-guard'; import { createLogger } from '@/lib/logger'; import type { WebSearchResult, WebSearchSource } from '@/lib/types/web-search'; @@ -31,8 +32,15 @@ interface SearchResult { content: SearchResultItem[]; } +const log = createLogger('ClaudeSearch'); + /** Fetch a URL and return plain text extracted from its HTML. Returns empty string on any failure. */ async function fetchPageContent(url: string): Promise { + const ssrfError = validateUrlForSSRF(url); + if (ssrfError) { + log.warn(`Blocked page fetch due to SSRF check [url="${url}" reason="${ssrfError}"]`); + return ''; + } log.info(`Fetching page content: ${url}`); try { const res = await proxyFetch(url, { @@ -60,8 +68,6 @@ async function fetchPageContent(url: string): Promise { } } -const log = createLogger('ClaudeSearch'); - /** * Search the web using Claude's native web search tool. */ diff --git a/tests/web-search/claude.test.ts b/tests/web-search/claude.test.ts index c16442a8c..d4a82989d 100644 --- a/tests/web-search/claude.test.ts +++ b/tests/web-search/claude.test.ts @@ -270,6 +270,101 @@ describe('searchWithClaude', () => { expect(result.sources).toHaveLength(0); }); + // ── SSRF protection ─────────────────────────────────────────────────────── + + it('skips page fetch for localhost URLs (SSRF protection)', async () => { + mockApiResponse({ + content: [ + { + type: 'web_search_tool_result', + content: [{ type: 'web_search_result', url: 'http://localhost/secret', title: 'Local' }], + }, + { type: 'text', text: 'Answer', citations: [] }, + ], + }); + + const result = await search({ + query: 'test', + apiKey: 'sk-test', + baseUrl: 'https://api.anthropic.com', + }); + + // Only the API call should have been made; no page fetch + expect(mockProxyFetch).toHaveBeenCalledTimes(1); + expect(result.sources).toHaveLength(0); + }); + + it('skips page fetch for private IP URLs (SSRF protection)', async () => { + mockApiResponse({ + content: [ + { + type: 'web_search_tool_result', + content: [ + { type: 'web_search_result', url: 'http://192.168.1.1/admin', title: 'Private' }, + ], + }, + { type: 'text', text: 'Answer', citations: [] }, + ], + }); + + const result = await search({ + query: 'test', + apiKey: 'sk-test', + baseUrl: 'https://api.anthropic.com', + }); + + expect(mockProxyFetch).toHaveBeenCalledTimes(1); + expect(result.sources).toHaveLength(0); + }); + + it('skips page fetch for non-HTTP(S) URLs (SSRF protection)', async () => { + mockApiResponse({ + content: [ + { + type: 'web_search_tool_result', + content: [{ type: 'web_search_result', url: 'file:///etc/passwd', title: 'File' }], + }, + { type: 'text', text: 'Answer', citations: [] }, + ], + }); + + const result = await search({ + query: 'test', + apiKey: 'sk-test', + baseUrl: 'https://api.anthropic.com', + }); + + expect(mockProxyFetch).toHaveBeenCalledTimes(1); + expect(result.sources).toHaveLength(0); + }); + + it('skips page fetch for metadata endpoint URLs (SSRF protection)', async () => { + mockApiResponse({ + content: [ + { + type: 'web_search_tool_result', + content: [ + { + type: 'web_search_result', + url: 'http://169.254.169.254/latest/meta-data/', + title: 'Metadata', + }, + ], + }, + { type: 'text', text: 'Answer', citations: [] }, + ], + }); + + const result = await search({ + query: 'test', + apiKey: 'sk-test', + baseUrl: 'https://api.anthropic.com', + }); + + expect(mockProxyFetch).toHaveBeenCalledTimes(1); + expect(result.sources).toHaveLength(0); + }); + it('keeps sources with content and drops sources without after mixed page fetches', async () => { mockApiResponse({ content: [ From 08b80daa30b0aae11f4d866e187b291e8e3a34ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 01:01:06 +0000 Subject: [PATCH 09/26] fix: address all remaining review feedback from PR review thread Agent-Logs-Url: https://github.com/joseph-mpo-yeti/OpenMAIC/sessions/e1ab8fe2-fe0d-45a5-a0af-30945b99c268 Co-authored-by: joseph-mpo-yeti <55380155+joseph-mpo-yeti@users.noreply.github.com> --- app/api/web-search/route.ts | 11 +++++- app/page.tsx | 8 +++- components/settings/index.tsx | 2 +- components/settings/tool-edit-dialog.tsx | 1 - .../settings/web-search-model-dialog.tsx | 11 ++++-- components/settings/web-search-settings.tsx | 17 ++++---- lib/server/provider-config.ts | 2 + lib/store/settings.ts | 39 +++++++++++++++---- lib/web-search/claude.ts | 2 +- 9 files changed, 67 insertions(+), 26 deletions(-) diff --git a/app/api/web-search/route.ts b/app/api/web-search/route.ts index 85e2dfeea..74213aac1 100644 --- a/app/api/web-search/route.ts +++ b/app/api/web-search/route.ts @@ -11,6 +11,7 @@ import { searchWithTavily, formatSearchResultsAsContext } from '@/lib/web-search import { searchWithClaude } from '@/lib/web-search/claude'; import { WEB_SEARCH_PROVIDERS } from '@/lib/web-search/constants'; import { resolveWebSearchApiKey } from '@/lib/server/provider-config'; +import { validateUrlForSSRF } from '@/lib/server/ssrf-guard'; import { createLogger } from '@/lib/logger'; import { apiError, apiSuccess } from '@/lib/server/api-response'; import { @@ -46,7 +47,7 @@ export async function POST(req: NextRequest) { }; query = requestQuery; - // Default to tavily if no provider specified, but only if we have a valid provider + // Provider must be explicitly specified const providerId: WebSearchProviderId | null = requestProviderId ?? null; if (!query || !query.trim()) { @@ -108,6 +109,14 @@ export async function POST(req: NextRequest) { const effectiveBaseUrl = providerConfig?.baseUrl || WEB_SEARCH_PROVIDERS[providerId].defaultBaseUrl || ''; + // Validate client-supplied base URL against SSRF in production + if (providerConfig?.baseUrl && process.env.NODE_ENV === 'production') { + const ssrfError = validateUrlForSSRF(providerConfig.baseUrl); + if (ssrfError) { + return apiError('INVALID_URL', 400, ssrfError); + } + } + let result; if (providerId === 'claude') { result = await searchWithClaude({ diff --git a/app/page.tsx b/app/page.tsx index 73a69fab6..3094cf5fb 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -99,7 +99,13 @@ function HomePage() { // Migrate webSearchEnabled from old localStorage key into the Zustand store const oldWebSearch = localStorage.getItem('webSearchEnabled'); if (oldWebSearch === 'true' && !useSettingsStore.getState().webSearchEnabled) { - useSettingsStore.getState().setWebSearchEnabled(true); + const store = useSettingsStore.getState(); + // Ensure a default provider is selected for backwards compatibility (Tavily was the + // only provider before multi-provider support was added) + if (!store.webSearchProviderId) { + store.setWebSearchProvider('tavily'); + } + store.setWebSearchEnabled(true); } if (oldWebSearch !== null) localStorage.removeItem('webSearchEnabled'); } catch { diff --git a/components/settings/index.tsx b/components/settings/index.tsx index 4feac512a..43a8e4bb1 100644 --- a/components/settings/index.tsx +++ b/components/settings/index.tsx @@ -991,7 +991,7 @@ export function SettingsDialog({ open, onOpenChange, initialSection }: SettingsD )} {activeSection === 'web-search' && selectedWebSearchProviderId && ( - + )} {activeSection === 'image' && ( diff --git a/components/settings/tool-edit-dialog.tsx b/components/settings/tool-edit-dialog.tsx index 8c3e66b15..3f2df601a 100644 --- a/components/settings/tool-edit-dialog.tsx +++ b/components/settings/tool-edit-dialog.tsx @@ -1,6 +1,5 @@ 'use client'; -import { useState } from 'react'; import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; diff --git a/components/settings/web-search-model-dialog.tsx b/components/settings/web-search-model-dialog.tsx index 36dfeee67..2c1f08f05 100644 --- a/components/settings/web-search-model-dialog.tsx +++ b/components/settings/web-search-model-dialog.tsx @@ -25,6 +25,7 @@ interface WebSearchModelDialogProps { isEditing: boolean; apiKey?: string; baseUrl?: string; + isServerConfigured?: boolean; } export function WebSearchModelDialog({ @@ -36,12 +37,13 @@ export function WebSearchModelDialog({ isEditing, apiKey, baseUrl, + isServerConfigured, }: WebSearchModelDialogProps) { const { t } = useI18n(); const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle'); const [testMessage, setTestMessage] = useState(''); - const canTest = !!(model?.id?.trim() && model?.name?.trim() && apiKey); + const canTest = !!(model?.id?.trim() && model?.name?.trim() && (apiKey || isServerConfigured)); const handleTest = useCallback(async () => { if (!canTest || !model) return; @@ -53,9 +55,10 @@ export function WebSearchModelDialog({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey, + baseUrl: baseUrl || undefined, model: `anthropic:${model.id}`, providerType: 'anthropic', - requiresApiKey: true, + requiresApiKey: !isServerConfigured, }), }); const data = await response.json(); @@ -70,7 +73,7 @@ export function WebSearchModelDialog({ setTestStatus('error'); setTestMessage(t('settings.connectionFailed')); } - }, [canTest, model, apiKey, t]); + }, [canTest, model, apiKey, baseUrl, isServerConfigured, t]); const handleOpenChange = (open: boolean) => { if (!open) { @@ -101,7 +104,7 @@ export function WebSearchModelDialog({ setTestStatus('idle'); setTestMessage(''); }} - placeholder="claude-opus-4.6" + placeholder="claude-opus-4-6" className="font-mono text-sm" />
diff --git a/components/settings/web-search-settings.tsx b/components/settings/web-search-settings.tsx index 2df156745..2aa827639 100644 --- a/components/settings/web-search-settings.tsx +++ b/components/settings/web-search-settings.tsx @@ -45,20 +45,14 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps const provider = WEB_SEARCH_PROVIDERS[selectedProviderId]; const isServerConfigured = !!webSearchProvidersConfig[selectedProviderId]?.isServerConfigured; - const [prevSelectedProviderId, setPrevSelectedProviderId] = useState(selectedProviderId); - if (selectedProviderId !== prevSelectedProviderId) { - setPrevSelectedProviderId(selectedProviderId); - setShowApiKey(false); - setTestStatus('idle'); - setTestMessage(''); - } - const handleTestConnection = useCallback(async () => { setTestStatus('testing'); setTestMessage(''); const config = webSearchProvidersConfig[selectedProviderId]; const apiKey = config?.apiKey || ''; + const baseUrl = + config?.baseUrl || WEB_SEARCH_PROVIDERS[selectedProviderId]?.defaultBaseUrl || ''; try { if (selectedProviderId === 'claude') { @@ -69,9 +63,10 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey, + baseUrl: baseUrl || undefined, model: `anthropic:${modelId}`, providerType: 'anthropic', - requiresApiKey: true, + requiresApiKey: !isServerConfigured, }), }); const data = await response.json(); @@ -91,6 +86,7 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps query: 'test connection', apiKey, providerId: selectedProviderId, + providerConfig: { baseUrl: baseUrl || undefined }, }), }); const data = await response.json(); @@ -106,7 +102,7 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps setTestStatus('error'); setTestMessage(t('settings.connectionFailed')); } - }, [webSearchProvidersConfig, selectedProviderId, t]); + }, [webSearchProvidersConfig, selectedProviderId, isServerConfigured, t]); // Guard against undefined provider if (!provider) { @@ -440,6 +436,7 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps WEB_SEARCH_PROVIDERS[selectedProviderId]?.defaultBaseUrl || '' } + isServerConfigured={isServerConfigured} />
); diff --git a/lib/server/provider-config.ts b/lib/server/provider-config.ts index 74238834e..ae361a3a0 100644 --- a/lib/server/provider-config.ts +++ b/lib/server/provider-config.ts @@ -92,6 +92,8 @@ const VIDEO_ENV_MAP: Record = { const WEB_SEARCH_ENV_MAP: Record = { TAVILY: 'tavily', CLAUDE: 'claude', + // Also recognise ANTHROPIC_API_KEY so server-config detection aligns with resolveWebSearchApiKey + ANTHROPIC: 'claude', }; // --------------------------------------------------------------------------- diff --git a/lib/store/settings.ts b/lib/store/settings.ts index 3edfc3bd3..8854d3ce0 100644 --- a/lib/store/settings.ts +++ b/lib/store/settings.ts @@ -798,12 +798,19 @@ export const useSettingsStore = create()( // Web Search actions setWebSearchEnabled: (enabled) => { if (enabled) { - const cfg = get().webSearchProvidersConfig; - const hasUsable = Object.values(cfg).some((c) => c.isServerConfigured || c.apiKey); - if (!hasUsable) return; - } - set({ webSearchEnabled: enabled }); - if (!enabled) { + const state = get(); + const cfg = state.webSearchProvidersConfig; + const firstUsable = (Object.keys(cfg) as WebSearchProviderId[]).find( + (id) => cfg[id].isServerConfigured || cfg[id].apiKey, + ); + if (!firstUsable) return; + set({ webSearchEnabled: true }); + // Auto-select a provider when none is selected yet + if (!state.webSearchProviderId) { + get().setWebSearchProvider(firstUsable); + } + } else { + set({ webSearchEnabled: false }); // Also deselect provider (which clears modelId per setWebSearchProvider logic) get().setWebSearchProvider(null); } @@ -833,12 +840,30 @@ export const useSettingsStore = create()( const apiKeyRemoved = 'apiKey' in config && !config.apiKey && !updatedProviderConfig.isServerConfigured; const isSelected = state.webSearchProviderId === providerId; + + // When the selected provider loses its key, try to switch to another usable provider + // or disable web search entirely + let extraUpdates: Record = {}; + if (apiKeyRemoved && isSelected) { + const updatedConfig = { + ...state.webSearchProvidersConfig, + [providerId]: updatedProviderConfig, + }; + const otherUsable = (Object.keys(updatedConfig) as WebSearchProviderId[]).find( + (id) => id !== providerId && (updatedConfig[id].isServerConfigured || updatedConfig[id].apiKey), + ); + extraUpdates = { + webSearchProviderId: otherUsable ?? null, + ...(otherUsable ? {} : { webSearchEnabled: false }), + }; + } + return { webSearchProvidersConfig: { ...state.webSearchProvidersConfig, [providerId]: updatedProviderConfig, }, - ...(apiKeyRemoved && isSelected ? { webSearchProviderId: null } : {}), + ...extraUpdates, }; }), diff --git a/lib/web-search/claude.ts b/lib/web-search/claude.ts index b8fbfa9c0..ae66ba547 100644 --- a/lib/web-search/claude.ts +++ b/lib/web-search/claude.ts @@ -154,7 +154,7 @@ export async function searchWithClaude(params: { } } - const answerText = answerParts.join(''); + const answerText = answerParts.join('\n\n'); const sources = Array.from(searchResultMap.values()); // Fetch page content for sources that have no content from citations From feb0f28da1b76c09a67dbb707ca897186377aeb6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 01:03:15 +0000 Subject: [PATCH 10/26] fix: rename ambiguous variable names in settings store for clarity Agent-Logs-Url: https://github.com/joseph-mpo-yeti/OpenMAIC/sessions/e1ab8fe2-fe0d-45a5-a0af-30945b99c268 Co-authored-by: joseph-mpo-yeti <55380155+joseph-mpo-yeti@users.noreply.github.com> --- lib/store/settings.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/store/settings.ts b/lib/store/settings.ts index 8854d3ce0..958fa9660 100644 --- a/lib/store/settings.ts +++ b/lib/store/settings.ts @@ -800,14 +800,14 @@ export const useSettingsStore = create()( if (enabled) { const state = get(); const cfg = state.webSearchProvidersConfig; - const firstUsable = (Object.keys(cfg) as WebSearchProviderId[]).find( + const firstUsableProviderId = (Object.keys(cfg) as WebSearchProviderId[]).find( (id) => cfg[id].isServerConfigured || cfg[id].apiKey, ); - if (!firstUsable) return; + if (!firstUsableProviderId) return; set({ webSearchEnabled: true }); // Auto-select a provider when none is selected yet if (!state.webSearchProviderId) { - get().setWebSearchProvider(firstUsable); + get().setWebSearchProvider(firstUsableProviderId); } } else { set({ webSearchEnabled: false }); @@ -849,12 +849,12 @@ export const useSettingsStore = create()( ...state.webSearchProvidersConfig, [providerId]: updatedProviderConfig, }; - const otherUsable = (Object.keys(updatedConfig) as WebSearchProviderId[]).find( + const otherUsableProviderId = (Object.keys(updatedConfig) as WebSearchProviderId[]).find( (id) => id !== providerId && (updatedConfig[id].isServerConfigured || updatedConfig[id].apiKey), ); extraUpdates = { - webSearchProviderId: otherUsable ?? null, - ...(otherUsable ? {} : { webSearchEnabled: false }), + webSearchProviderId: otherUsableProviderId ?? null, + ...(otherUsableProviderId ? {} : { webSearchEnabled: false }), }; } From 35d5e20e3b36b0b8d02c85d26c1e4dce759fab03 Mon Sep 17 00:00:00 2001 From: Joseph Mpo Yeti Date: Fri, 10 Apr 2026 02:20:23 +0100 Subject: [PATCH 11/26] fixing lint errors --- components/settings/index.tsx | 5 ++++- lib/store/settings.ts | 8 ++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/components/settings/index.tsx b/components/settings/index.tsx index 43a8e4bb1..3a4d5406d 100644 --- a/components/settings/index.tsx +++ b/components/settings/index.tsx @@ -991,7 +991,10 @@ export function SettingsDialog({ open, onOpenChange, initialSection }: SettingsD )} {activeSection === 'web-search' && selectedWebSearchProviderId && ( - + )} {activeSection === 'image' && ( diff --git a/lib/store/settings.ts b/lib/store/settings.ts index 958fa9660..b1ed7c93e 100644 --- a/lib/store/settings.ts +++ b/lib/store/settings.ts @@ -849,8 +849,12 @@ export const useSettingsStore = create()( ...state.webSearchProvidersConfig, [providerId]: updatedProviderConfig, }; - const otherUsableProviderId = (Object.keys(updatedConfig) as WebSearchProviderId[]).find( - (id) => id !== providerId && (updatedConfig[id].isServerConfigured || updatedConfig[id].apiKey), + const otherUsableProviderId = ( + Object.keys(updatedConfig) as WebSearchProviderId[] + ).find( + (id) => + id !== providerId && + (updatedConfig[id].isServerConfigured || updatedConfig[id].apiKey), ); extraUpdates = { webSearchProviderId: otherUsableProviderId ?? null, From 6a18278e843bbb04c481da89a9877120a48b7562 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 01:35:21 +0000 Subject: [PATCH 12/26] fix: address 5 items from second PR review thread Agent-Logs-Url: https://github.com/joseph-mpo-yeti/OpenMAIC/sessions/09469d80-8e91-449b-8bc2-9c11f83555df Co-authored-by: joseph-mpo-yeti <55380155+joseph-mpo-yeti@users.noreply.github.com> --- app/api/web-search/route.ts | 4 ++++ components/generation/generation-toolbar.tsx | 12 +++++++----- components/settings/tool-edit-dialog.tsx | 4 +++- lib/server/classroom-generation.ts | 17 ++++++++++++----- 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/app/api/web-search/route.ts b/app/api/web-search/route.ts index 74213aac1..21891737b 100644 --- a/app/api/web-search/route.ts +++ b/app/api/web-search/route.ts @@ -62,6 +62,10 @@ export async function POST(req: NextRequest) { ); } + if (!(providerId in WEB_SEARCH_PROVIDERS)) { + return apiError('INVALID_REQUEST', 400, `Unknown web search provider: ${providerId}`); + } + const apiKey = resolveWebSearchApiKey(providerId, clientApiKey); if (!apiKey) { const envVar = providerId === 'claude' ? 'ANTHROPIC_API_KEY' : 'TAVILY_API_KEY'; diff --git a/components/generation/generation-toolbar.tsx b/components/generation/generation-toolbar.tsx index 6ea7b9761..955f59720 100644 --- a/components/generation/generation-toolbar.tsx +++ b/components/generation/generation-toolbar.tsx @@ -364,10 +364,10 @@ export function GenerationToolbar({ .map((provider) => { const cfg = webSearchProvidersConfig[provider.id as WebSearchProviderId]; const isActive = webSearchProviderId === provider.id && webSearch; - const hasModels = !!( - cfg?.models?.length || - WEB_SEARCH_PROVIDERS[provider.id as WebSearchProviderId]?.models?.length - ); + const builtinModels = + WEB_SEARCH_PROVIDERS[provider.id as WebSearchProviderId]?.models || []; + const effectiveModels = cfg?.models?.length ? cfg.models : builtinModels; + const hasModels = effectiveModels.length > 0; return ( - +
diff --git a/lib/server/classroom-generation.ts b/lib/server/classroom-generation.ts index d1a63cb79..bf34cc97d 100644 --- a/lib/server/classroom-generation.ts +++ b/lib/server/classroom-generation.ts @@ -258,10 +258,17 @@ export async function generateClassroom( // Web search (optional, graceful degradation) let researchContext: string | undefined; if (input.enableWebSearch) { - // The providerId should ideally be fetched from the user's settings store. - // Since this is a server-side function, we'd typically pass the user's current providerId in the input. - // For now, we'll use a default or assume it's passed in the request (mocking the settings lookup). - const providerId = input.webSearchProviderId || 'tavily'; + // Validate and resolve the provider ID; unknown values are treated as 'tavily' (safe default). + const rawProviderId = input.webSearchProviderId || 'tavily'; + const providerId = + rawProviderId in WEB_SEARCH_PROVIDERS + ? (rawProviderId as keyof typeof WEB_SEARCH_PROVIDERS) + : ('tavily' as const); + if (rawProviderId !== providerId) { + log.warn( + `Unknown webSearchProviderId "${rawProviderId}", falling back to tavily`, + ); + } const searchKey = resolveWebSearchApiKey(providerId); if (searchKey) { try { @@ -277,7 +284,7 @@ export async function generateClassroom( const effectiveBaseUrl = input.webSearchBaseUrl || - WEB_SEARCH_PROVIDERS[providerId as keyof typeof WEB_SEARCH_PROVIDERS]?.defaultBaseUrl || + WEB_SEARCH_PROVIDERS[providerId]?.defaultBaseUrl || ''; let searchResult; From 70fdcef9f829b35abeb5737104d98ff9c8b300a8 Mon Sep 17 00:00:00 2001 From: Joseph Mpo Yeti Date: Fri, 10 Apr 2026 03:39:01 +0100 Subject: [PATCH 13/26] fixing model verification error --- components/generation/generation-toolbar.tsx | 23 +++++++++++++------ components/settings/tool-edit-dialog.tsx | 4 +++- .../settings/web-search-model-dialog.tsx | 5 +--- components/settings/web-search-settings.tsx | 6 ----- lib/i18n/locales/en-US.json | 2 +- lib/server/classroom-generation.ts | 8 ++----- 6 files changed, 23 insertions(+), 25 deletions(-) diff --git a/components/generation/generation-toolbar.tsx b/components/generation/generation-toolbar.tsx index 955f59720..1f64576b4 100644 --- a/components/generation/generation-toolbar.tsx +++ b/components/generation/generation-toolbar.tsx @@ -415,6 +415,12 @@ export function GenerationToolbar({ {t('settings.serverConfigured')} )} + {hasModels && cfg?.modelId && ( + + {effectiveModels.find((m) => m.id === cfg.modelId)?.name || + cfg.modelId} + + )} {isActive && !hasModels && ( )} @@ -434,9 +440,7 @@ export function GenerationToolbar({ (() => { const cfg = webSearchProvidersConfig[drillWebSearchProvider]; const provider = WEB_SEARCH_PROVIDERS[drillWebSearchProvider]; - const models = cfg?.models?.length - ? cfg.models - : (provider?.models || []); + const models = cfg?.models?.length ? cfg.models : provider?.models || []; const selectedModelId = cfg?.modelId || ''; return (
@@ -502,15 +506,20 @@ export function GenerationToolbar({ const providerName = WEB_SEARCH_PROVIDERS[webSearchProviderId]?.name || webSearchProviderId; const cfg = webSearchProvidersConfig[webSearchProviderId]; - if (webSearchProviderId === 'claude' && cfg?.modelId) { - const models = cfg.models || []; - const modelName = models.find((m) => m.id === cfg.modelId)?.name || cfg.modelId; + const builtinModels = + WEB_SEARCH_PROVIDERS[webSearchProviderId as WebSearchProviderId]?.models || []; + const effectiveModels = cfg?.models?.length ? cfg.models : builtinModels; + if (cfg?.modelId) { + const modelName = + effectiveModels.find((m) => m.id === cfg.modelId)?.name || cfg.modelId; return `${providerName} / ${modelName}`; } return providerName; })()} - ) : null} + ) : ( + {t('toolbar.webSearchProvider')} + )} {/* ── Language pill ── */} diff --git a/components/settings/tool-edit-dialog.tsx b/components/settings/tool-edit-dialog.tsx index b325ceb91..6a3375636 100644 --- a/components/settings/tool-edit-dialog.tsx +++ b/components/settings/tool-edit-dialog.tsx @@ -61,7 +61,9 @@ export function ToolEditDialog({ open, onOpenChange, tool, setTool, onSave }: To - +
diff --git a/components/settings/web-search-model-dialog.tsx b/components/settings/web-search-model-dialog.tsx index 2c1f08f05..d000ad9c5 100644 --- a/components/settings/web-search-model-dialog.tsx +++ b/components/settings/web-search-model-dialog.tsx @@ -24,7 +24,6 @@ interface WebSearchModelDialogProps { onSave: () => void; isEditing: boolean; apiKey?: string; - baseUrl?: string; isServerConfigured?: boolean; } @@ -36,7 +35,6 @@ export function WebSearchModelDialog({ onSave, isEditing, apiKey, - baseUrl, isServerConfigured, }: WebSearchModelDialogProps) { const { t } = useI18n(); @@ -55,7 +53,6 @@ export function WebSearchModelDialog({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey, - baseUrl: baseUrl || undefined, model: `anthropic:${model.id}`, providerType: 'anthropic', requiresApiKey: !isServerConfigured, @@ -73,7 +70,7 @@ export function WebSearchModelDialog({ setTestStatus('error'); setTestMessage(t('settings.connectionFailed')); } - }, [canTest, model, apiKey, baseUrl, isServerConfigured, t]); + }, [canTest, model, apiKey, isServerConfigured, t]); const handleOpenChange = (open: boolean) => { if (!open) { diff --git a/components/settings/web-search-settings.tsx b/components/settings/web-search-settings.tsx index 2aa827639..ec4fbd920 100644 --- a/components/settings/web-search-settings.tsx +++ b/components/settings/web-search-settings.tsx @@ -63,7 +63,6 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey, - baseUrl: baseUrl || undefined, model: `anthropic:${modelId}`, providerType: 'anthropic', requiresApiKey: !isServerConfigured, @@ -431,11 +430,6 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps onSave={handleSaveModel} isEditing={editingModelIndex !== null} apiKey={webSearchProvidersConfig[selectedProviderId]?.apiKey || ''} - baseUrl={ - webSearchProvidersConfig[selectedProviderId]?.baseUrl || - WEB_SEARCH_PROVIDERS[selectedProviderId]?.defaultBaseUrl || - '' - } isServerConfigured={isServerConfigured} />
diff --git a/lib/i18n/locales/en-US.json b/lib/i18n/locales/en-US.json index 50c1d4ea8..19e440ac1 100644 --- a/lib/i18n/locales/en-US.json +++ b/lib/i18n/locales/en-US.json @@ -17,7 +17,7 @@ "webSearchOn": "Enabled", "webSearchOff": "Click to enable", "webSearchDesc": "Search the web for up-to-date information before generation", - "webSearchProvider": "Search engine", + "webSearchProvider": "Web Search", "webSearchNoProvider": "Configure search API key in Settings", "selectProvider": "Select provider", "configureProvider": "Set up model", diff --git a/lib/server/classroom-generation.ts b/lib/server/classroom-generation.ts index bf34cc97d..edf0ec8eb 100644 --- a/lib/server/classroom-generation.ts +++ b/lib/server/classroom-generation.ts @@ -265,9 +265,7 @@ export async function generateClassroom( ? (rawProviderId as keyof typeof WEB_SEARCH_PROVIDERS) : ('tavily' as const); if (rawProviderId !== providerId) { - log.warn( - `Unknown webSearchProviderId "${rawProviderId}", falling back to tavily`, - ); + log.warn(`Unknown webSearchProviderId "${rawProviderId}", falling back to tavily`); } const searchKey = resolveWebSearchApiKey(providerId); if (searchKey) { @@ -283,9 +281,7 @@ export async function generateClassroom( }); const effectiveBaseUrl = - input.webSearchBaseUrl || - WEB_SEARCH_PROVIDERS[providerId]?.defaultBaseUrl || - ''; + input.webSearchBaseUrl || WEB_SEARCH_PROVIDERS[providerId]?.defaultBaseUrl || ''; let searchResult; if (providerId === 'claude') { From a5ad3769951d9452e60eb12777e05dcb5b131922 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 02:51:57 +0000 Subject: [PATCH 14/26] fix: address 3 items from third PR review thread Agent-Logs-Url: https://github.com/joseph-mpo-yeti/OpenMAIC/sessions/0aa49d19-df5b-4a08-a7e1-2295ae9cfc38 Co-authored-by: joseph-mpo-yeti <55380155+joseph-mpo-yeti@users.noreply.github.com> --- app/api/web-search/route.ts | 10 ++-- lib/server/classroom-generation.ts | 76 +++++++++++++++++------------- lib/web-search/claude.ts | 3 +- 3 files changed, 49 insertions(+), 40 deletions(-) diff --git a/app/api/web-search/route.ts b/app/api/web-search/route.ts index 21891737b..044251561 100644 --- a/app/api/web-search/route.ts +++ b/app/api/web-search/route.ts @@ -110,17 +110,17 @@ export async function POST(req: NextRequest) { finalQueryLength: searchQuery.finalQueryLength, }); - const effectiveBaseUrl = - providerConfig?.baseUrl || WEB_SEARCH_PROVIDERS[providerId].defaultBaseUrl || ''; - - // Validate client-supplied base URL against SSRF in production - if (providerConfig?.baseUrl && process.env.NODE_ENV === 'production') { + // Validate client-supplied base URL against SSRF in all environments + if (providerConfig?.baseUrl) { const ssrfError = validateUrlForSSRF(providerConfig.baseUrl); if (ssrfError) { return apiError('INVALID_URL', 400, ssrfError); } } + const effectiveBaseUrl = + providerConfig?.baseUrl || WEB_SEARCH_PROVIDERS[providerId].defaultBaseUrl || ''; + let result; if (providerId === 'claude') { result = await searchWithClaude({ diff --git a/lib/server/classroom-generation.ts b/lib/server/classroom-generation.ts index edf0ec8eb..223d0b332 100644 --- a/lib/server/classroom-generation.ts +++ b/lib/server/classroom-generation.ts @@ -23,6 +23,7 @@ import { buildSearchQuery } from '@/lib/server/search-query-builder'; import { searchWithTavily, formatSearchResultsAsContext } from '@/lib/web-search/tavily'; import { searchWithClaude } from '@/lib/web-search/claude'; import { WEB_SEARCH_PROVIDERS } from '@/lib/web-search/constants'; +import { validateUrlForSSRF } from '@/lib/server/ssrf-guard'; import { persistClassroom } from '@/lib/server/classroom-storage'; import { generateMediaForClassroom, @@ -269,43 +270,50 @@ export async function generateClassroom( } const searchKey = resolveWebSearchApiKey(providerId); if (searchKey) { - try { - const searchQuery = await buildSearchQuery(requirement, pdfText, searchQueryAiCall); - - log.info('Running web search for classroom generation', { - provider: providerId, - hasPdfContext: searchQuery.hasPdfContext, - rawRequirementLength: searchQuery.rawRequirementLength, - rewriteAttempted: searchQuery.rewriteAttempted, - finalQueryLength: searchQuery.finalQueryLength, - }); - - const effectiveBaseUrl = - input.webSearchBaseUrl || WEB_SEARCH_PROVIDERS[providerId]?.defaultBaseUrl || ''; - - let searchResult; - if (providerId === 'claude') { - searchResult = await searchWithClaude({ - query: searchQuery.query, - apiKey: searchKey, - baseUrl: effectiveBaseUrl, - modelId: input.webSearchModelId, - tools: input.webSearchTools, + const ssrfError = input.webSearchBaseUrl + ? validateUrlForSSRF(input.webSearchBaseUrl) + : null; + if (ssrfError) { + log.warn(`webSearchBaseUrl rejected by SSRF guard (${ssrfError}), skipping web search`); + } else { + try { + const searchQuery = await buildSearchQuery(requirement, pdfText, searchQueryAiCall); + + log.info('Running web search for classroom generation', { + provider: providerId, + hasPdfContext: searchQuery.hasPdfContext, + rawRequirementLength: searchQuery.rawRequirementLength, + rewriteAttempted: searchQuery.rewriteAttempted, + finalQueryLength: searchQuery.finalQueryLength, }); - } else { - searchResult = await searchWithTavily({ - query: searchQuery.query, - apiKey: searchKey, - baseUrl: effectiveBaseUrl, - }); - } - researchContext = formatSearchResultsAsContext(searchResult); - if (researchContext) { - log.info(`Web search returned ${searchResult.sources.length} sources`); + const effectiveBaseUrl = + input.webSearchBaseUrl || WEB_SEARCH_PROVIDERS[providerId]?.defaultBaseUrl || ''; + + let searchResult; + if (providerId === 'claude') { + searchResult = await searchWithClaude({ + query: searchQuery.query, + apiKey: searchKey, + baseUrl: effectiveBaseUrl, + modelId: input.webSearchModelId, + tools: input.webSearchTools, + }); + } else { + searchResult = await searchWithTavily({ + query: searchQuery.query, + apiKey: searchKey, + baseUrl: effectiveBaseUrl, + }); + } + + researchContext = formatSearchResultsAsContext(searchResult); + if (researchContext) { + log.info(`Web search returned ${searchResult.sources.length} sources`); + } + } catch (e) { + log.warn('Web search failed, continuing without search context:', e); } - } catch (e) { - log.warn('Web search failed, continuing without search context:', e); } } else { log.warn( diff --git a/lib/web-search/claude.ts b/lib/web-search/claude.ts index ae66ba547..9365fee96 100644 --- a/lib/web-search/claude.ts +++ b/lib/web-search/claude.ts @@ -78,7 +78,8 @@ export async function searchWithClaude(params: { baseUrl: string; tools?: Array<{ type: string; name: string }>; }): Promise { - const { query, apiKey, modelId = 'claude-sonnet-4-6', baseUrl, tools } = params; + const { query, apiKey, modelId: rawModelId, baseUrl, tools } = params; + const modelId = rawModelId?.trim() || 'claude-sonnet-4-6'; const apiVersion = '2023-06-01'; From c821b1058ad556686365170f5089c7f933ced722 Mon Sep 17 00:00:00 2001 From: Joseph Mpo Yeti Date: Fri, 10 Apr 2026 03:59:14 +0100 Subject: [PATCH 15/26] fixing lint errors --- lib/server/classroom-generation.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/server/classroom-generation.ts b/lib/server/classroom-generation.ts index 223d0b332..8d1421178 100644 --- a/lib/server/classroom-generation.ts +++ b/lib/server/classroom-generation.ts @@ -270,9 +270,7 @@ export async function generateClassroom( } const searchKey = resolveWebSearchApiKey(providerId); if (searchKey) { - const ssrfError = input.webSearchBaseUrl - ? validateUrlForSSRF(input.webSearchBaseUrl) - : null; + const ssrfError = input.webSearchBaseUrl ? validateUrlForSSRF(input.webSearchBaseUrl) : null; if (ssrfError) { log.warn(`webSearchBaseUrl rejected by SSRF guard (${ssrfError}), skipping web search`); } else { From 7907f2bdf31591f02c2383cb468c65925e33a033 Mon Sep 17 00:00:00 2001 From: Joseph Mpo Yeti Date: Fri, 10 Apr 2026 05:12:56 +0100 Subject: [PATCH 16/26] fix: resolved Copilot review comments --- components/settings/web-search-model-dialog.tsx | 5 ++++- components/settings/web-search-settings.tsx | 9 +++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/components/settings/web-search-model-dialog.tsx b/components/settings/web-search-model-dialog.tsx index d000ad9c5..c3923b5b6 100644 --- a/components/settings/web-search-model-dialog.tsx +++ b/components/settings/web-search-model-dialog.tsx @@ -25,6 +25,7 @@ interface WebSearchModelDialogProps { isEditing: boolean; apiKey?: string; isServerConfigured?: boolean; + baseUrl?: string; } export function WebSearchModelDialog({ @@ -36,6 +37,7 @@ export function WebSearchModelDialog({ isEditing, apiKey, isServerConfigured, + baseUrl, }: WebSearchModelDialogProps) { const { t } = useI18n(); const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle'); @@ -54,6 +56,7 @@ export function WebSearchModelDialog({ body: JSON.stringify({ apiKey, model: `anthropic:${model.id}`, + baseUrl: baseUrl || '', providerType: 'anthropic', requiresApiKey: !isServerConfigured, }), @@ -70,7 +73,7 @@ export function WebSearchModelDialog({ setTestStatus('error'); setTestMessage(t('settings.connectionFailed')); } - }, [canTest, model, apiKey, isServerConfigured, t]); + }, [canTest, model, apiKey, baseUrl, isServerConfigured, t]); const handleOpenChange = (open: boolean) => { if (!open) { diff --git a/components/settings/web-search-settings.tsx b/components/settings/web-search-settings.tsx index ec4fbd920..6898f2051 100644 --- a/components/settings/web-search-settings.tsx +++ b/components/settings/web-search-settings.tsx @@ -64,6 +64,7 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps body: JSON.stringify({ apiKey, model: `anthropic:${modelId}`, + baseUrl: config?.baseUrl || '', providerType: 'anthropic', requiresApiKey: !isServerConfigured, }), @@ -128,9 +129,9 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps const newTools = [...tools]; if (editingToolIndex !== null) { - newTools[editingToolIndex] = editingTool; + newTools[editingToolIndex] = { type: editingTool.type.trim(), name: editingTool.name.trim() }; } else { - newTools.push(editingTool); + newTools.push({ type: editingTool.type.trim(), name: editingTool.name.trim() }); } setWebSearchProviderConfig(selectedProviderId, { tools: newTools }); setIsToolDialogOpen(false); @@ -160,9 +161,9 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps const newModels = [...models]; if (editingModelIndex !== null) { - newModels[editingModelIndex] = editingModel; + newModels[editingModelIndex] = { id: editingModel.id.trim(), name: editingModel.name.trim() }; } else { - newModels.push(editingModel); + newModels.push({ id: editingModel.id.trim(), name: editingModel.name.trim() }); } setWebSearchProviderConfig(selectedProviderId, { models: newModels }); setIsModelDialogOpen(false); From e2cdfa09aace3ddc7952bdbdc8db24c65b2130b3 Mon Sep 17 00:00:00 2001 From: Joseph Mpo Yeti Date: Sat, 11 Apr 2026 18:55:41 +0100 Subject: [PATCH 17/26] fix: returning string promise for ssrf-guard --- app/api/web-search/route.ts | 6 +++--- lib/server/ssrf-guard.ts | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/api/web-search/route.ts b/app/api/web-search/route.ts index f95945636..e59247bd5 100644 --- a/app/api/web-search/route.ts +++ b/app/api/web-search/route.ts @@ -13,7 +13,7 @@ import { WEB_SEARCH_PROVIDERS } from '@/lib/web-search/constants'; import { resolveWebSearchApiKey } from '@/lib/server/provider-config'; import { validateUrlForSSRF } from '@/lib/server/ssrf-guard'; import { createLogger } from '@/lib/logger'; -import { apiError, apiSuccess } from '@/lib/server/api-response'; +import { API_ERROR_CODES, apiError, apiSuccess } from '@/lib/server/api-response'; import { buildSearchQuery, SEARCH_QUERY_REWRITE_EXCERPT_LENGTH, @@ -112,9 +112,9 @@ export async function POST(req: NextRequest) { // Validate client-supplied base URL against SSRF in all environments if (providerConfig?.baseUrl) { - const ssrfError = validateUrlForSSRF(providerConfig.baseUrl); + const ssrfError = await validateUrlForSSRF(providerConfig.baseUrl); if (ssrfError) { - return apiError('INVALID_URL', 400, ssrfError); + return apiError(API_ERROR_CODES.INVALID_URL, 400, ssrfError); } } diff --git a/lib/server/ssrf-guard.ts b/lib/server/ssrf-guard.ts index e40bb8142..a100b872f 100644 --- a/lib/server/ssrf-guard.ts +++ b/lib/server/ssrf-guard.ts @@ -166,7 +166,7 @@ export function isPrivateIP(ip: string): boolean { * Validate a URL against SSRF attacks. * Returns null if the URL is safe, or an error message string if blocked. */ -export async function validateUrlForSSRF(url: string): Promise { +export async function validateUrlForSSRF(url: string): Promise { let parsed: URL; try { parsed = new URL(url); @@ -181,7 +181,7 @@ export async function validateUrlForSSRF(url: string): Promise { // Self-hosted deployments can set ALLOW_LOCAL_NETWORKS=true to skip private-IP checks const allowLocal = process.env.ALLOW_LOCAL_NETWORKS; if (allowLocal === 'true' || allowLocal === '1') { - return null; + return ''; } const hostname = normalizeAddress(parsed.hostname); @@ -196,7 +196,7 @@ export async function validateUrlForSSRF(url: string): Promise { } if (isIP(hostname)) { - return null; + return ''; } let resolvedAddresses: Array<{ address: string; family: number }>; @@ -214,5 +214,5 @@ export async function validateUrlForSSRF(url: string): Promise { return 'Local/private network URLs are not allowed'; } - return null; + return ''; } From 3444c04572e3d4d0f0404afb6ebe63ef634a7e28 Mon Sep 17 00:00:00 2001 From: Joseph Mpo Yeti Date: Sat, 11 Apr 2026 19:06:03 +0100 Subject: [PATCH 18/26] fix: returning string promise for ssrf-guard --- lib/web-search/claude.ts | 2 +- tests/server/ssrf-guard.test.ts | 10 +++++----- tests/web-search/claude.test.ts | 29 +++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/lib/web-search/claude.ts b/lib/web-search/claude.ts index 9365fee96..55b5abd0a 100644 --- a/lib/web-search/claude.ts +++ b/lib/web-search/claude.ts @@ -36,7 +36,7 @@ const log = createLogger('ClaudeSearch'); /** Fetch a URL and return plain text extracted from its HTML. Returns empty string on any failure. */ async function fetchPageContent(url: string): Promise { - const ssrfError = validateUrlForSSRF(url); + const ssrfError = await validateUrlForSSRF(url); if (ssrfError) { log.warn(`Blocked page fetch due to SSRF check [url="${url}" reason="${ssrfError}"]`); return ''; diff --git a/tests/server/ssrf-guard.test.ts b/tests/server/ssrf-guard.test.ts index 9aa95d813..108b3a926 100644 --- a/tests/server/ssrf-guard.test.ts +++ b/tests/server/ssrf-guard.test.ts @@ -21,21 +21,21 @@ describe('validateUrlForSSRF', () => { const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard'); - await expect(validateUrlForSSRF('https://api.openai.com')).resolves.toBeNull(); + await expect(validateUrlForSSRF('https://api.openai.com')).resolves.toBe(''); expect(lookupMock).toHaveBeenCalledWith('api.openai.com', { all: true, verbatim: true }); }); it('allows a public IP literal without DNS lookup', async () => { const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard'); - await expect(validateUrlForSSRF('https://8.8.8.8')).resolves.toBeNull(); + await expect(validateUrlForSSRF('https://8.8.8.8')).resolves.toBe(''); expect(lookupMock).not.toHaveBeenCalled(); }); it('allows a public IPv6 literal without DNS lookup', async () => { const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard'); - await expect(validateUrlForSSRF('https://[2606:4700:4700::1111]')).resolves.toBeNull(); + await expect(validateUrlForSSRF('https://[2606:4700:4700::1111]')).resolves.toBe(''); expect(lookupMock).not.toHaveBeenCalled(); }); @@ -126,7 +126,7 @@ describe('validateUrlForSSRF', () => { const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard'); // 2002:0808:0808:: embeds 8.8.8.8 - await expect(validateUrlForSSRF('http://[2002:0808:0808::]')).resolves.toBeNull(); + await expect(validateUrlForSSRF('http://[2002:0808:0808::]')).resolves.toBe(''); expect(lookupMock).not.toHaveBeenCalled(); }); @@ -146,7 +146,7 @@ describe('validateUrlForSSRF', () => { // Client IPv4 8.8.8.8 XOR 0xFFFFFFFF = 0xF7F7F7F7 → hextets f7f7:f7f7 await expect( validateUrlForSSRF('http://[2001:0000:4136:e378:8000:63bf:f7f7:f7f7]'), - ).resolves.toBeNull(); + ).resolves.toBe(''); expect(lookupMock).not.toHaveBeenCalled(); }); diff --git a/tests/web-search/claude.test.ts b/tests/web-search/claude.test.ts index d4a82989d..f601b7a8e 100644 --- a/tests/web-search/claude.test.ts +++ b/tests/web-search/claude.test.ts @@ -5,6 +5,35 @@ vi.mock('@/lib/server/proxy-fetch', () => ({ proxyFetch: vi.fn(), })); +// Mock ssrf-guard to avoid real DNS lookups in tests +vi.mock('@/lib/server/ssrf-guard', () => ({ + validateUrlForSSRF: async (url: string): Promise => { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return 'Invalid URL'; + } + if (!['http:', 'https:'].includes(parsed.protocol)) { + return 'Only HTTP(S) URLs are allowed'; + } + const hostname = parsed.hostname.replace(/^\[|\]$/g, ''); + const privatePatterns = [ + /^localhost$/i, + /^127\./, + /^10\./, + /^172\.(1[6-9]|2\d|3[01])\./, + /^192\.168\./, + /^169\.254\./, + /^::1$/, + ]; + if (privatePatterns.some((p) => p.test(hostname))) { + return 'Local/private network URLs are not allowed'; + } + return ''; + }, +})); + vi.mock('@/lib/logger', () => ({ createLogger: () => ({ info: vi.fn(), From 69dcaaada0ac9038e9aa56dc11551e4547f42ca4 Mon Sep 17 00:00:00 2001 From: Joseph Mpo Yeti Date: Sat, 18 Apr 2026 07:35:21 +0100 Subject: [PATCH 19/26] fix: updated locales, added allowed_callers for claude search tools, handle duplicated claude model keys --- components/settings/web-search-settings.tsx | 13 +++++++++++-- lib/i18n/locales/ar-SA.json | 3 ++- lib/i18n/locales/en-US.json | 3 ++- lib/i18n/locales/ja-JP.json | 3 ++- lib/i18n/locales/ru-RU.json | 3 ++- lib/i18n/locales/zh-CN.json | 3 ++- lib/web-search/claude.ts | 6 +++--- 7 files changed, 24 insertions(+), 10 deletions(-) diff --git a/components/settings/web-search-settings.tsx b/components/settings/web-search-settings.tsx index 6898f2051..7849b1932 100644 --- a/components/settings/web-search-settings.tsx +++ b/components/settings/web-search-settings.tsx @@ -20,6 +20,7 @@ import { XCircle, } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { toast } from 'sonner'; import { ToolEditDialog } from './tool-edit-dialog'; import { WebSearchModelDialog } from './web-search-model-dialog'; @@ -159,11 +160,19 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps const handleSaveModel = () => { if (!editingModel) return; + const trimmedId = editingModel.id.trim(); + const trimmedName = editingModel.name.trim(); + const isDuplicate = models.some((m, i) => m.id === trimmedId && i !== editingModelIndex); + if (isDuplicate) { + toast.error(t('settings.webSearchModelIdDuplicate')); + return; + } + const newModels = [...models]; if (editingModelIndex !== null) { - newModels[editingModelIndex] = { id: editingModel.id.trim(), name: editingModel.name.trim() }; + newModels[editingModelIndex] = { id: trimmedId, name: trimmedName }; } else { - newModels.push({ id: editingModel.id.trim(), name: editingModel.name.trim() }); + newModels.push({ id: trimmedId, name: trimmedName }); } setWebSearchProviderConfig(selectedProviderId, { models: newModels }); setIsModelDialogOpen(false); diff --git a/lib/i18n/locales/ar-SA.json b/lib/i18n/locales/ar-SA.json index 9115ec1cf..2a9282817 100644 --- a/lib/i18n/locales/ar-SA.json +++ b/lib/i18n/locales/ar-SA.json @@ -899,7 +899,8 @@ "webSearchEditModelTitle": "تعديل النموذج", "webSearchModelDialogDesc": "قم بتكوين معرّف النموذج واسمه لبحث Claude.", "webSearchModelIdField": "معرّف النموذج", - "webSearchModelNameField": "اسم النموذج" + "webSearchModelNameField": "اسم النموذج", + "webSearchModelIdDuplicate": "يوجد نموذج بهذا المعرّف بالفعل" }, "profile": { "title": "الملف الشخصي", diff --git a/lib/i18n/locales/en-US.json b/lib/i18n/locales/en-US.json index aec1bfbb3..135335e8f 100644 --- a/lib/i18n/locales/en-US.json +++ b/lib/i18n/locales/en-US.json @@ -900,7 +900,8 @@ "webSearchEditModelTitle": "Edit Model", "webSearchModelDialogDesc": "Configure the model's ID and name for Claude search.", "webSearchModelIdField": "Model ID", - "webSearchModelNameField": "Model Name" + "webSearchModelNameField": "Model Name", + "webSearchModelIdDuplicate": "A model with this ID already exists" }, "profile": { "title": "Profile", diff --git a/lib/i18n/locales/ja-JP.json b/lib/i18n/locales/ja-JP.json index 2b3680894..3b2e8fcee 100644 --- a/lib/i18n/locales/ja-JP.json +++ b/lib/i18n/locales/ja-JP.json @@ -900,7 +900,8 @@ "webSearchEditModelTitle": "モデルを編集", "webSearchModelDialogDesc": "Claude検索用のモデルIDと名前を設定します。", "webSearchModelIdField": "モデルID", - "webSearchModelNameField": "モデル名" + "webSearchModelNameField": "モデル名", + "webSearchModelIdDuplicate": "このIDのモデルはすでに存在します" }, "profile": { "title": "プロフィール", diff --git a/lib/i18n/locales/ru-RU.json b/lib/i18n/locales/ru-RU.json index 69440b127..a9a588ac7 100644 --- a/lib/i18n/locales/ru-RU.json +++ b/lib/i18n/locales/ru-RU.json @@ -900,7 +900,8 @@ "webSearchEditModelTitle": "Редактировать модель", "webSearchModelDialogDesc": "Настройте ID и название модели для поиска Claude.", "webSearchModelIdField": "ID модели", - "webSearchModelNameField": "Название модели" + "webSearchModelNameField": "Название модели", + "webSearchModelIdDuplicate": "Модель с таким ID уже существует" }, "profile": { "title": "Профиль", diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index 8dc72de8a..538ce63c9 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -900,7 +900,8 @@ "webSearchEditModelTitle": "编辑模型", "webSearchModelDialogDesc": "配置 Claude 搜索模型的 ID 和名称", "webSearchModelIdField": "模型 ID", - "webSearchModelNameField": "模型名称" + "webSearchModelNameField": "模型名称", + "webSearchModelIdDuplicate": "该模型 ID 已存在" }, "profile": { "title": "个人资料", diff --git a/lib/web-search/claude.ts b/lib/web-search/claude.ts index 55b5abd0a..b38fd41fe 100644 --- a/lib/web-search/claude.ts +++ b/lib/web-search/claude.ts @@ -80,9 +80,7 @@ export async function searchWithClaude(params: { }): Promise { const { query, apiKey, modelId: rawModelId, baseUrl, tools } = params; const modelId = rawModelId?.trim() || 'claude-sonnet-4-6'; - const apiVersion = '2023-06-01'; - const endpoint = `${baseUrl}/v1/messages`; try { @@ -104,7 +102,9 @@ export async function searchWithClaude(params: { content: `Search for the following and provide a comprehensive summary with source links: ${query}.`, }, ], - tools: tools?.length ? tools : [{ type: 'web_search_20260209', name: 'web_search' }], + tools: (tools?.length ? tools : [{ type: 'web_search_20260209', name: 'web_search' }]).map((t) => { + return { ...t, allowed_callers: ['direct'] }; + }), }), }); From bea14b9534204b3e20162188080d1f167826a7cc Mon Sep 17 00:00:00 2001 From: Joseph Mpo Yeti Date: Sat, 18 Apr 2026 07:36:17 +0100 Subject: [PATCH 20/26] fix: updated locales, added allowed_callers for claude search tools, handle duplicated claude model keys --- lib/web-search/claude.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/web-search/claude.ts b/lib/web-search/claude.ts index b38fd41fe..43f305597 100644 --- a/lib/web-search/claude.ts +++ b/lib/web-search/claude.ts @@ -102,9 +102,11 @@ export async function searchWithClaude(params: { content: `Search for the following and provide a comprehensive summary with source links: ${query}.`, }, ], - tools: (tools?.length ? tools : [{ type: 'web_search_20260209', name: 'web_search' }]).map((t) => { - return { ...t, allowed_callers: ['direct'] }; - }), + tools: (tools?.length ? tools : [{ type: 'web_search_20260209', name: 'web_search' }]).map( + (t) => { + return { ...t, allowed_callers: ['direct'] }; + }, + ), }), }); From 6e6b6f963ca85c6d35e286e7a7aa75ac71e0bc0c Mon Sep 17 00:00:00 2001 From: Joseph Mpo Yeti Date: Sat, 18 Apr 2026 07:43:38 +0100 Subject: [PATCH 21/26] updated tests --- tests/web-search/claude.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/web-search/claude.test.ts b/tests/web-search/claude.test.ts index f601b7a8e..be5f3ada2 100644 --- a/tests/web-search/claude.test.ts +++ b/tests/web-search/claude.test.ts @@ -114,7 +114,7 @@ describe('searchWithClaude', () => { }); const body = JSON.parse(mockProxyFetch.mock.calls[0][1].body); - expect(body.tools).toEqual(customTools); + expect(body.tools).toEqual(customTools.map((t) => ({ ...t, allowed_callers: ['direct'] }))); }); it('uses default web_search tool when tools is undefined', async () => { @@ -122,7 +122,9 @@ describe('searchWithClaude', () => { await search({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com' }); const body = JSON.parse(mockProxyFetch.mock.calls[0][1].body); - expect(body.tools).toEqual([{ type: 'web_search_20260209', name: 'web_search' }]); + expect(body.tools).toEqual([ + { type: 'web_search_20260209', name: 'web_search', allowed_callers: ['direct'] }, + ]); }); it('uses default web_search tool when tools is an empty array', async () => { @@ -135,7 +137,9 @@ describe('searchWithClaude', () => { }); const body = JSON.parse(mockProxyFetch.mock.calls[0][1].body); - expect(body.tools).toEqual([{ type: 'web_search_20260209', name: 'web_search' }]); + expect(body.tools).toEqual([ + { type: 'web_search_20260209', name: 'web_search', allowed_callers: ['direct'] }, + ]); }); // ── page content fetching ──────────────────────────────────────────────── From 74ed4b00f5d124e06afd549a035ab63d52b2301a Mon Sep 17 00:00:00 2001 From: Joseph Mpo Yeti Date: Sat, 18 Apr 2026 13:17:35 +0100 Subject: [PATCH 22/26] using Anthropic SDK for search and connection test in search settings. --- app/api/web-search/route.ts | 43 ++++-- .../settings/web-search-model-dialog.tsx | 38 +++-- components/settings/web-search-settings.tsx | 135 ++++++++++-------- lib/server/classroom-generation.ts | 3 +- lib/server/provider-config.ts | 4 +- lib/web-search/claude.ts | 100 ++++++------- lib/web-search/constants.ts | 2 + lib/web-search/types.ts | 1 + package.json | 1 + pnpm-lock.yaml | 54 +++++-- tests/web-search/claude.test.ts | 112 +++++++++------ 11 files changed, 280 insertions(+), 213 deletions(-) diff --git a/app/api/web-search/route.ts b/app/api/web-search/route.ts index e59247bd5..26b148b5e 100644 --- a/app/api/web-search/route.ts +++ b/app/api/web-search/route.ts @@ -21,6 +21,7 @@ import { import { resolveModelFromHeaders } from '@/lib/server/resolve-model'; import type { AICallFn } from '@/lib/generation/pipeline-types'; import type { WebSearchProviderId } from '@/lib/web-search/types'; +import { Tool } from '@anthropic-ai/sdk/resources'; const log = createLogger('WebSearch'); @@ -118,24 +119,36 @@ export async function POST(req: NextRequest) { } } - const effectiveBaseUrl = + const baseUrl = providerConfig?.baseUrl || WEB_SEARCH_PROVIDERS[providerId].defaultBaseUrl || ''; let result; - if (providerId === 'claude') { - result = await searchWithClaude({ - query: searchQuery.query, - apiKey, - baseUrl: effectiveBaseUrl, - modelId: providerConfig?.modelId, - tools: providerConfig?.tools, - }); - } else { - result = await searchWithTavily({ - query: searchQuery.query, - apiKey, - baseUrl: effectiveBaseUrl, - }); + switch (providerId) { + case 'claude': { + const defaultTool = { type: 'web_search_20260209', name: 'web_search' }; + const tools = (providerConfig?.tools?.length ? providerConfig?.tools : [defaultTool]).map( + (tool) => { + return { ...tool, allowed_callers: ['direct'] } as Tool; + }, + ); + + result = await searchWithClaude({ + query: searchQuery.query, + apiKey, + baseUrl, + tools, + modelId: providerConfig?.modelId, + }); + break; + } + case 'tavily': { + result = await searchWithTavily({ + query: searchQuery.query, + apiKey, + baseUrl, + }); + break; + } } const context = formatSearchResultsAsContext(result); diff --git a/components/settings/web-search-model-dialog.tsx b/components/settings/web-search-model-dialog.tsx index c3923b5b6..272988326 100644 --- a/components/settings/web-search-model-dialog.tsx +++ b/components/settings/web-search-model-dialog.tsx @@ -21,23 +21,28 @@ interface WebSearchModelDialogProps { onOpenChange: (open: boolean) => void; model: { id: string; name: string } | null; setModel: (model: { id: string; name: string } | null) => void; + isModelValid: ( + providerId: string, + modelId: string, + ) => Promise<{ status: boolean; error?: string }>; onSave: () => void; isEditing: boolean; apiKey?: string; + providerId: string; isServerConfigured?: boolean; - baseUrl?: string; } export function WebSearchModelDialog({ open, - onOpenChange, model, - setModel, - onSave, isEditing, + providerId, apiKey, isServerConfigured, - baseUrl, + onOpenChange, + setModel, + onSave, + isModelValid, }: WebSearchModelDialogProps) { const { t } = useI18n(); const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle'); @@ -47,33 +52,22 @@ export function WebSearchModelDialog({ const handleTest = useCallback(async () => { if (!canTest || !model) return; - setTestStatus('testing'); - setTestMessage(''); try { - const response = await fetch('/api/verify-model', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - apiKey, - model: `anthropic:${model.id}`, - baseUrl: baseUrl || '', - providerType: 'anthropic', - requiresApiKey: !isServerConfigured, - }), - }); - const data = await response.json(); - if (data.success) { + setTestStatus('testing'); + setTestMessage(''); + const { status, error } = await isModelValid(providerId, model.id); + if (status) { setTestStatus('success'); setTestMessage(t('settings.connectionSuccess')); } else { setTestStatus('error'); - setTestMessage(data.error || t('settings.connectionFailed')); + setTestMessage(error || t('settings.connectionFailed')); } } catch { setTestStatus('error'); setTestMessage(t('settings.connectionFailed')); } - }, [canTest, model, apiKey, baseUrl, isServerConfigured, t]); + }, [canTest, model, providerId, t, isModelValid]); const handleOpenChange = (open: boolean) => { if (!open) { diff --git a/components/settings/web-search-settings.tsx b/components/settings/web-search-settings.tsx index 7849b1932..0ee01c444 100644 --- a/components/settings/web-search-settings.tsx +++ b/components/settings/web-search-settings.tsx @@ -46,64 +46,76 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps const provider = WEB_SEARCH_PROVIDERS[selectedProviderId]; const isServerConfigured = !!webSearchProvidersConfig[selectedProviderId]?.isServerConfigured; - const handleTestConnection = useCallback(async () => { - setTestStatus('testing'); - setTestMessage(''); + const isModelValid = useCallback( + async (providerId: string, modelId?: string): Promise<{ status: boolean; error?: string }> => { + const config = webSearchProvidersConfig[selectedProviderId]; + const apiKey = config?.apiKey || ''; + const baseUrl = + config?.baseUrl || WEB_SEARCH_PROVIDERS[selectedProviderId]?.defaultBaseUrl || ''; - const config = webSearchProvidersConfig[selectedProviderId]; - const apiKey = config?.apiKey || ''; - const baseUrl = - config?.baseUrl || WEB_SEARCH_PROVIDERS[selectedProviderId]?.defaultBaseUrl || ''; + switch (providerId) { + case 'tavily': { + const response = await fetch('/api/web-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: 'test connection', + baseUrl, + apiKey, + providerId: providerId, + providerConfig: { baseUrl: baseUrl || undefined }, + }), + }); + const data = await response.json(); + return Promise.resolve({ status: data.success || response.ok, error: data.error }); + } + case 'claude': { + // Use verify-model endpoint with the selected (or default) model + const model = modelId || config?.modelId || 'claude-haiku-4-5'; + const response = await fetch('/api/web-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: 'test connection', + apiKey, + providerType: 'anthropic', + providerId: providerId, + requiresApiKey: !isServerConfigured, + providerConfig: { + baseUrl, + modelId: model, + tools: [], + }, + }), + }); + const data = await response.json(); + return Promise.resolve({ status: data.success, error: data.error }); + } + default: { + return Promise.reject({ status: false }); + } + } + }, + [webSearchProvidersConfig, isServerConfigured, selectedProviderId], + ); + const handleTestConnection = useCallback(async () => { try { - if (selectedProviderId === 'claude') { - // Use verify-model endpoint with the selected (or default) Claude model - const modelId = config?.modelId || 'claude-sonnet-4-6'; - const response = await fetch('/api/verify-model', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - apiKey, - model: `anthropic:${modelId}`, - baseUrl: config?.baseUrl || '', - providerType: 'anthropic', - requiresApiKey: !isServerConfigured, - }), - }); - const data = await response.json(); - if (data.success) { - setTestStatus('success'); - setTestMessage(t('settings.connectionSuccess')); - } else { - setTestStatus('error'); - setTestMessage(data.error || t('settings.connectionFailed')); - } + setTestStatus('testing'); + setTestMessage(''); + const { status, error } = await isModelValid(selectedProviderId); + if (status) { + setTestStatus('success'); + setTestMessage(t('settings.connectionSuccess')); } else { - // For other providers (Tavily), test via the web search endpoint - const response = await fetch('/api/web-search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - query: 'test connection', - apiKey, - providerId: selectedProviderId, - providerConfig: { baseUrl: baseUrl || undefined }, - }), - }); - const data = await response.json(); - if (data.success || response.ok) { - setTestStatus('success'); - setTestMessage(t('settings.connectionSuccess')); - } else { - setTestStatus('error'); - setTestMessage(data.error || t('settings.connectionFailed')); - } + setTestStatus('error'); + setTestMessage(error || t('settings.connectionFailed')); } } catch { setTestStatus('error'); setTestMessage(t('settings.connectionFailed')); } - }, [webSearchProvidersConfig, selectedProviderId, isServerConfigured, t]); + }, [selectedProviderId, isModelValid, t]); // Guard against undefined provider if (!provider) { @@ -185,6 +197,19 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps setWebSearchProviderConfig(selectedProviderId, { models: newModels }); }; + const getApiKeyLabel = (selectedProviderId: string) => { + switch (selectedProviderId) { + case 'claude': { + return t('settings.webSearchClaudeApiKey'); + } + case 'tavily': { + return t('settings.webSearchTavilyApiKey'); + } + default: + return t('settings.webSearchApiKey'); + } + }; + return (
{isServerConfigured && ( @@ -197,13 +222,7 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps <> {/* API Key */}
- +
{t('settings.requestUrl')}: {effectiveBaseUrl} @@ -439,7 +458,9 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps setModel={setEditingModel} onSave={handleSaveModel} isEditing={editingModelIndex !== null} + isModelValid={isModelValid} apiKey={webSearchProvidersConfig[selectedProviderId]?.apiKey || ''} + providerId={selectedProviderId || ''} isServerConfigured={isServerConfigured} />
diff --git a/lib/server/classroom-generation.ts b/lib/server/classroom-generation.ts index 9f08c2d7d..f25dccb65 100644 --- a/lib/server/classroom-generation.ts +++ b/lib/server/classroom-generation.ts @@ -32,6 +32,7 @@ import { import type { UserRequirements } from '@/lib/types/generation'; import type { Scene, Stage } from '@/lib/types/stage'; import { AGENT_COLOR_PALETTE, AGENT_DEFAULT_AVATARS } from '@/lib/constants/agent-defaults'; +import { Tool } from '@anthropic-ai/sdk/resources'; const log = createLogger('Classroom'); @@ -277,7 +278,7 @@ export async function generateClassroom( apiKey: searchKey, baseUrl: effectiveBaseUrl, modelId: input.webSearchModelId, - tools: input.webSearchTools, + tools: input.webSearchTools?.map((t) => t as Tool) || [], }); } else { searchResult = await searchWithTavily({ diff --git a/lib/server/provider-config.ts b/lib/server/provider-config.ts index 4c305ff9e..0ebcc1eef 100644 --- a/lib/server/provider-config.ts +++ b/lib/server/provider-config.ts @@ -407,9 +407,9 @@ export function resolveVideoBaseUrl( // --------------------------------------------------------------------------- /** Returns server-configured web search providers (no apiKeys exposed) */ -export function getServerWebSearchProviders(): Record { +export function getServerWebSearchProviders(): Record { const cfg = getConfig(); - const result: Record = {}; + const result: Record = {}; for (const [id, entry] of Object.entries(cfg.webSearch)) { result[id] = {}; if (entry.baseUrl) result[id].baseUrl = entry.baseUrl; diff --git a/lib/web-search/claude.ts b/lib/web-search/claude.ts index 43f305597..b341d9520 100644 --- a/lib/web-search/claude.ts +++ b/lib/web-search/claude.ts @@ -9,29 +9,18 @@ import { proxyFetch } from '@/lib/server/proxy-fetch'; import { validateUrlForSSRF } from '@/lib/server/ssrf-guard'; import { createLogger } from '@/lib/logger'; import type { WebSearchResult, WebSearchSource } from '@/lib/types/web-search'; +import Anthropic from '@anthropic-ai/sdk'; +import { Tool, WebSearchTool20260209 } from '@anthropic-ai/sdk/resources'; + +const DEFAULT_WEB_SEARCH_TOOL: WebSearchTool20260209 = { + type: 'web_search_20260209', + name: 'web_search', + allowed_callers: ['direct'], +}; const PAGE_CONTENT_MAX_LENGTH = 2000; const PAGE_FETCH_TIMEOUT_MS = 5000; -interface SearchContent { - url: string; - title: string; - content?: string; - type?: string; - cited_text?: string; -} - -interface SearchResultItem { - type: string; - content?: SearchContent[]; - citations?: SearchContent[]; - text?: string; -} - -interface SearchResult { - content: SearchResultItem[]; -} - const log = createLogger('ClaudeSearch'); /** Fetch a URL and return plain text extracted from its HTML. Returns empty string on any failure. */ @@ -76,60 +65,49 @@ export async function searchWithClaude(params: { apiKey: string; modelId?: string; baseUrl: string; - tools?: Array<{ type: string; name: string }>; + tools?: Tool[]; }): Promise { - const { query, apiKey, modelId: rawModelId, baseUrl, tools } = params; + const { query, apiKey, modelId: rawModelId, baseUrl, tools: rawTools } = params; const modelId = rawModelId?.trim() || 'claude-sonnet-4-6'; - const apiVersion = '2023-06-01'; - const endpoint = `${baseUrl}/v1/messages`; + const tools: (Tool | WebSearchTool20260209)[] = + rawTools && rawTools.length > 0 + ? rawTools.map( + (t) => ({ ...t, allowed_callers: ['direct'] }) as Tool & { allowed_callers: string[] }, + ) + : [DEFAULT_WEB_SEARCH_TOOL]; try { const startTime = Date.now(); - const res = await proxyFetch(endpoint, { - method: 'POST', - headers: { - 'x-api-key': apiKey, - 'anthropic-version': apiVersion, - 'content-type': 'application/json', - }, - body: JSON.stringify({ - model: modelId, + const client = new Anthropic({ baseURL: baseUrl, apiKey, fetch: proxyFetch as typeof fetch }); + const response = await client.messages + .create({ max_tokens: 4096, - stream: false, messages: [ { role: 'user', content: `Search for the following and provide a comprehensive summary with source links: ${query}.`, }, ], - tools: (tools?.length ? tools : [{ type: 'web_search_20260209', name: 'web_search' }]).map( - (t) => { - return { ...t, allowed_callers: ['direct'] }; - }, - ), - }), - }); - - if (!res.ok) { - const errorText = await res.text().catch(() => ''); - throw new Error(`Claude API error (${res.status}): ${errorText || res.statusText}`); - } + model: modelId || '', + tools: tools as Tool[], + }) + .catch(async (err) => { + if (err instanceof Anthropic.APIError) { + throw new Error(`Claude API error (${err.status}): ${err.message}`); + } else { + throw err; + } + }); - const data = (await res.json()) as SearchResult; - const contentBlocks: SearchResultItem[] = data.content || []; + const contentBlocks = response.content; // Extract search results from web_search_tool_result blocks const searchResultMap = new Map(); for (const block of contentBlocks) { if (block.type !== 'web_search_tool_result') continue; - for (const result of block.content || []) { - if (result.type !== 'web_search_result') continue; - if (!searchResultMap.has(result.url)) { - searchResultMap.set(result.url, { - title: result.title || result.url, - url: result.url, - content: '', - }); + for (const source of getWebSearchResult(block.content)) { + if (!searchResultMap.has(source.url)) { + searchResultMap.set(source.url, source); } } } @@ -141,13 +119,14 @@ export async function searchWithClaude(params: { answerParts.push(block.text); // If the block carries citations, make sure those sources are captured for (const citation of block.citations || []) { - if (citation.url && !searchResultMap.has(citation.url)) { + if (citation.type !== 'web_search_result_location') continue; + if (!searchResultMap.has(citation.url)) { searchResultMap.set(citation.url, { title: citation.title || citation.url, url: citation.url, content: citation.cited_text || '', }); - } else if (citation.url) { + } else { const existing = searchResultMap.get(citation.url)!; if (!existing.content && citation.cited_text) { existing.content = citation.cited_text; @@ -184,6 +163,13 @@ export async function searchWithClaude(params: { } } +function getWebSearchResult(content: Anthropic.WebSearchToolResultBlockContent): WebSearchSource[] { + if (!Array.isArray(content)) return []; + return content + .filter((r) => r.type === 'web_search_result') + .map((r) => ({ title: r.title || r.url, url: r.url, content: '' })); +} + /** * Reuse formatting logic from Tavily. */ diff --git a/lib/web-search/constants.ts b/lib/web-search/constants.ts index dbaab46de..106d1985f 100644 --- a/lib/web-search/constants.ts +++ b/lib/web-search/constants.ts @@ -13,6 +13,7 @@ export const WEB_SEARCH_PROVIDERS: Record=6.9.0'} @@ -6158,6 +6170,10 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@0.3.1: resolution: {integrity: sha512-4JD/Ivzg7PoW8NzdrBSr3UFwC9mHgvI7Z6z3QGBsSHgKaRTUDmyZAAKJo2UbG1kUVfS9WS8bi36N49U1xw43DA==} @@ -8605,6 +8621,9 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -9427,6 +9446,12 @@ snapshots: transitivePeerDependencies: - encoding + '@anthropic-ai/sdk@0.90.0(zod@4.3.6)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 4.3.6 + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -9891,7 +9916,7 @@ snapshots: - youtube-transcript - youtubei.js - '@copilotkit/runtime@1.53.0(@ag-ui/encoder@0.0.47)(@cfworker/json-schema@4.1.1)(@copilotkitnext/shared@1.53.0)(@langchain/core@1.1.31(@opentelemetry/api@1.9.0)(openai@4.104.0(ws@8.19.0)(zod@4.3.6)))(@langchain/langgraph-sdk@1.7.1(@langchain/core@1.1.31(@opentelemetry/api@1.9.0)(openai@4.104.0(ws@8.19.0)(zod@4.3.6))))(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(ws@8.19.0)': + '@copilotkit/runtime@1.53.0(@ag-ui/encoder@0.0.47)(@anthropic-ai/sdk@0.90.0(zod@4.3.6))(@cfworker/json-schema@4.1.1)(@copilotkitnext/shared@1.53.0)(@langchain/core@1.1.31(@opentelemetry/api@1.9.0)(openai@4.104.0(ws@8.19.0)(zod@4.3.6)))(@langchain/langgraph-sdk@1.7.1(@langchain/core@1.1.31(@opentelemetry/api@1.9.0)(openai@4.104.0(ws@8.19.0)(zod@4.3.6))))(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(ws@8.19.0)': dependencies: '@ag-ui/client': 0.0.47 '@ag-ui/core': 0.0.47 @@ -9921,6 +9946,7 @@ snapshots: type-graphql: 2.0.0-rc.1(class-validator@0.14.4)(graphql-scalars@1.25.0(graphql@16.13.1))(graphql@16.13.1) zod: 3.25.76 optionalDependencies: + '@anthropic-ai/sdk': 0.90.0(zod@4.3.6) '@langchain/langgraph-sdk': 1.7.1(@langchain/core@1.1.31(@opentelemetry/api@1.9.0)(openai@4.104.0(ws@8.19.0)(zod@4.3.6))) transitivePeerDependencies: - '@ag-ui/encoder' @@ -13808,13 +13834,13 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-next@16.1.2(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + eslint-config-next@16.1.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 16.1.2 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1)) @@ -13847,22 +13873,21 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -13873,7 +13898,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -13884,8 +13909,6 @@ snapshots: semver: 6.3.1 string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -15751,6 +15774,11 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.2 + ts-algebra: 2.0.0 + json-schema-traverse@0.3.1: {} json-schema-traverse@0.4.1: {} @@ -18599,6 +18627,8 @@ snapshots: trough@2.2.0: {} + ts-algebra@2.0.0: {} + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 diff --git a/tests/web-search/claude.test.ts b/tests/web-search/claude.test.ts index be5f3ada2..da06bfc42 100644 --- a/tests/web-search/claude.test.ts +++ b/tests/web-search/claude.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -// Mock proxy-fetch and logger so no real HTTP requests are made +// Mock proxy-fetch — intercepted for both the Anthropic API call and page-content fetches vi.mock('@/lib/server/proxy-fetch', () => ({ proxyFetch: vi.fn(), })); @@ -44,20 +44,27 @@ vi.mock('@/lib/logger', () => ({ })); import { proxyFetch } from '@/lib/server/proxy-fetch'; +import { searchWithClaude } from '@/lib/web-search/claude'; const mockProxyFetch = proxyFetch as ReturnType; -/** Build a minimal successful Anthropic Messages API response with no sources */ +/** Mock a successful Anthropic Messages API response (first proxyFetch call). */ function mockApiResponse(overrides: { content?: unknown[] } = {}) { - mockProxyFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - content: overrides.content ?? [{ type: 'text', text: 'Search result', citations: [] }], - }), + const body = JSON.stringify({ + id: 'msg_test', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-6', + stop_reason: 'end_turn', + usage: { input_tokens: 10, output_tokens: 20 }, + content: overrides.content ?? [{ type: 'text', text: 'Search result', citations: [] }], }); + mockProxyFetch.mockResolvedValueOnce( + new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }), + ); } -/** Build a page-fetch response returning simple HTML */ +/** Mock a page-content fetch response. */ function mockPageResponse(html: string) { mockProxyFetch.mockResolvedValueOnce({ ok: true, @@ -65,29 +72,25 @@ function mockPageResponse(html: string) { }); } -/** Build a failing page-fetch response */ +/** Mock a failing page-content fetch. */ function mockPageFailure() { mockProxyFetch.mockResolvedValueOnce({ ok: false, status: 404 }); } describe('searchWithClaude', () => { beforeEach(() => { - vi.resetModules(); mockProxyFetch.mockReset(); }); - async function search( - params: Parameters[0], - ) { - const { searchWithClaude } = await import('@/lib/web-search/claude'); - return searchWithClaude(params); - } - // ── baseUrl ─────────────────────────────────────────────────────────────── it('uses the provided baseUrl to construct the messages endpoint', async () => { mockApiResponse(); - await search({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com' }); + await searchWithClaude({ + query: 'test', + apiKey: 'sk-test', + baseUrl: 'https://api.anthropic.com', + }); const [url] = mockProxyFetch.mock.calls[0]; expect(url).toBe('https://api.anthropic.com/v1/messages'); @@ -95,22 +98,26 @@ describe('searchWithClaude', () => { it('uses a custom baseUrl when provided', async () => { mockApiResponse(); - await search({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://custom.example.com' }); + await searchWithClaude({ + query: 'test', + apiKey: 'sk-test', + baseUrl: 'https://custom.example.com', + }); const [url] = mockProxyFetch.mock.calls[0]; expect(url).toBe('https://custom.example.com/v1/messages'); }); - // ── tools fallback ──────────────────────────────────────────────────────── + // ── tools ───────────────────────────────────────────────────────────────── - it('uses provided tools when non-empty', async () => { + it('uses provided tools with allowed_callers when non-empty', async () => { mockApiResponse(); - const customTools = [{ type: 'web_search_custom', name: 'my_search' }]; - await search({ + const customTools = [{ type: 'web_search_custom', name: 'my_search', input_schema: {} }]; + await searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com', - tools: customTools, + tools: customTools as never, }); const body = JSON.parse(mockProxyFetch.mock.calls[0][1].body); @@ -119,7 +126,11 @@ describe('searchWithClaude', () => { it('uses default web_search tool when tools is undefined', async () => { mockApiResponse(); - await search({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com' }); + await searchWithClaude({ + query: 'test', + apiKey: 'sk-test', + baseUrl: 'https://api.anthropic.com', + }); const body = JSON.parse(mockProxyFetch.mock.calls[0][1].body); expect(body.tools).toEqual([ @@ -129,7 +140,7 @@ describe('searchWithClaude', () => { it('uses default web_search tool when tools is an empty array', async () => { mockApiResponse(); - await search({ + await searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com', @@ -156,7 +167,7 @@ describe('searchWithClaude', () => { }); mockPageResponse('

Page content here

'); - const result = await search({ + const result = await searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com', @@ -189,7 +200,7 @@ describe('searchWithClaude', () => { `); - const result = await search({ + const result = await searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com', @@ -213,13 +224,19 @@ describe('searchWithClaude', () => { type: 'text', text: 'Answer', citations: [ - { url: 'https://example.com', title: 'Ex', cited_text: 'Already have this content' }, + { + type: 'web_search_result_location', + url: 'https://example.com', + title: 'Ex', + cited_text: 'Already have this content', + encrypted_index: '', + }, ], }, ], }); - const result = await search({ + const result = await searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com', @@ -246,14 +263,14 @@ describe('searchWithClaude', () => { mockPageResponse('

Content A

'); mockPageResponse('

Content B

'); - const result = await search({ + const result = await searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com', }); expect(result.sources).toHaveLength(2); - // Both pages should have been fetched + // Page fetches are calls [1] and [2] const fetchedUrls = mockProxyFetch.mock.calls.slice(1).map((call: string[]) => call[0]); expect(fetchedUrls).toContain('https://a.com'); expect(fetchedUrls).toContain('https://b.com'); @@ -273,7 +290,7 @@ describe('searchWithClaude', () => { }); mockPageFailure(); - const result = await search({ + const result = await searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com', @@ -294,7 +311,7 @@ describe('searchWithClaude', () => { }); mockProxyFetch.mockRejectedValueOnce(new Error('Network timeout')); - const result = await search({ + const result = await searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com', @@ -316,7 +333,7 @@ describe('searchWithClaude', () => { ], }); - const result = await search({ + const result = await searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com', @@ -340,7 +357,7 @@ describe('searchWithClaude', () => { ], }); - const result = await search({ + const result = await searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com', @@ -361,7 +378,7 @@ describe('searchWithClaude', () => { ], }); - const result = await search({ + const result = await searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com', @@ -388,7 +405,7 @@ describe('searchWithClaude', () => { ], }); - const result = await search({ + const result = await searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com', @@ -414,7 +431,7 @@ describe('searchWithClaude', () => { mockPageResponse('

Good content

'); mockPageFailure(); - const result = await search({ + const result = await searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com', @@ -427,15 +444,16 @@ describe('searchWithClaude', () => { // ── error propagation ───────────────────────────────────────────────────── it('throws when the API returns a non-ok response', async () => { - mockProxyFetch.mockResolvedValueOnce({ - ok: false, - status: 401, - statusText: 'Unauthorized', - text: async () => 'invalid api key', + const errorBody = JSON.stringify({ + type: 'error', + error: { type: 'authentication_error', message: 'invalid x-api-key' }, }); + mockProxyFetch.mockResolvedValueOnce( + new Response(errorBody, { status: 401, headers: { 'content-type': 'application/json' } }), + ); await expect( - search({ query: 'test', apiKey: 'bad-key', baseUrl: 'https://api.anthropic.com' }), + searchWithClaude({ query: 'test', apiKey: 'bad-key', baseUrl: 'https://api.anthropic.com' }), ).rejects.toThrow(/Claude API error \(401\)/); }); @@ -443,7 +461,7 @@ describe('searchWithClaude', () => { mockProxyFetch.mockRejectedValueOnce(new Error('Network failure')); await expect( - search({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com' }), - ).rejects.toThrow('Network failure'); + searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com' }), + ).rejects.toThrow(); }); }); From c3b376625e35ebbcc2360fc40a4be978d5bdb921 Mon Sep 17 00:00:00 2001 From: Joseph Mpo Yeti Date: Sun, 19 Apr 2026 18:08:48 +0100 Subject: [PATCH 23/26] uses @ai-sdk with a custom fetch including allowed_callers in payload instead of adding Anthropic SDK as a direct dependency --- app/api/web-search/route.ts | 10 +- lib/server/classroom-generation.ts | 3 +- lib/web-search/claude.ts | 168 +++++------ lib/web-search/constants.ts | 7 +- package.json | 1 - tests/web-search/claude.test.ts | 454 +++++++++++++---------------- 6 files changed, 279 insertions(+), 364 deletions(-) diff --git a/app/api/web-search/route.ts b/app/api/web-search/route.ts index 26b148b5e..b81ee6b1e 100644 --- a/app/api/web-search/route.ts +++ b/app/api/web-search/route.ts @@ -21,7 +21,6 @@ import { import { resolveModelFromHeaders } from '@/lib/server/resolve-model'; import type { AICallFn } from '@/lib/generation/pipeline-types'; import type { WebSearchProviderId } from '@/lib/web-search/types'; -import { Tool } from '@anthropic-ai/sdk/resources'; const log = createLogger('WebSearch'); @@ -125,19 +124,12 @@ export async function POST(req: NextRequest) { let result; switch (providerId) { case 'claude': { - const defaultTool = { type: 'web_search_20260209', name: 'web_search' }; - const tools = (providerConfig?.tools?.length ? providerConfig?.tools : [defaultTool]).map( - (tool) => { - return { ...tool, allowed_callers: ['direct'] } as Tool; - }, - ); - result = await searchWithClaude({ query: searchQuery.query, apiKey, baseUrl, - tools, modelId: providerConfig?.modelId, + tools: providerConfig?.tools, }); break; } diff --git a/lib/server/classroom-generation.ts b/lib/server/classroom-generation.ts index dec775c1a..d4f88b5ee 100644 --- a/lib/server/classroom-generation.ts +++ b/lib/server/classroom-generation.ts @@ -32,7 +32,6 @@ import { import type { UserRequirements } from '@/lib/types/generation'; import type { Scene, Stage } from '@/lib/types/stage'; import { AGENT_COLOR_PALETTE, AGENT_DEFAULT_AVATARS } from '@/lib/constants/agent-defaults'; -import { Tool } from '@anthropic-ai/sdk/resources'; const log = createLogger('Classroom'); @@ -278,7 +277,7 @@ export async function generateClassroom( apiKey: searchKey, baseUrl: effectiveBaseUrl, modelId: input.webSearchModelId, - tools: input.webSearchTools?.map((t) => t as Tool) || [], + tools: input.webSearchTools, }); } else { searchResult = await searchWithTavily({ diff --git a/lib/web-search/claude.ts b/lib/web-search/claude.ts index b341d9520..6a2f37868 100644 --- a/lib/web-search/claude.ts +++ b/lib/web-search/claude.ts @@ -1,22 +1,60 @@ /** * Claude Web Search Integration * - * This provider implements the native Claude web search tool via the Anthropic Messages API. - * It requires a specific model (e.g., claude-opus-4-6) and a specific tool definition. + * Uses the AI SDK Anthropic provider with the native web_search_20260209 tool. */ +import { generateText } from 'ai'; +import { createAnthropic, type AnthropicProvider } from '@ai-sdk/anthropic'; import { proxyFetch } from '@/lib/server/proxy-fetch'; import { validateUrlForSSRF } from '@/lib/server/ssrf-guard'; import { createLogger } from '@/lib/logger'; import type { WebSearchResult, WebSearchSource } from '@/lib/types/web-search'; -import Anthropic from '@anthropic-ai/sdk'; -import { Tool, WebSearchTool20260209 } from '@anthropic-ai/sdk/resources'; -const DEFAULT_WEB_SEARCH_TOOL: WebSearchTool20260209 = { - type: 'web_search_20260209', - name: 'web_search', - allowed_callers: ['direct'], -}; +type ToolDef = { type: string; name: string }; + +const DEFAULT_TOOLS: ToolDef[] = [{ type: 'web_search_20260209', name: 'web_search' }]; + +function buildTools(provider: AnthropicProvider, configuredTools?: ToolDef[]) { + const defs = configuredTools?.length ? configuredTools : DEFAULT_TOOLS; + return Object.fromEntries( + defs.map((t) => + t.type === 'web_search_20250305' + ? [t.name, provider.tools.webSearch_20250305()] + : [t.name, provider.tools.webSearch_20260209()], + ), + ); +} + +/** + * Wraps proxyFetch to inject `allowed_callers: ["direct"]` on every tool in outgoing + * Anthropic API requests. The AI SDK's provider-defined tool serialisation hard-codes the + * tool object and never emits `allowed_callers`, so we must patch it at the fetch layer. + */ +async function fetchWithAllowedCallers(url: string, init?: RequestInit): Promise { + if (init?.method === 'POST' && typeof init.body === 'string') { + try { + const body = JSON.parse(init.body); + if (Array.isArray(body?.tools)) { + const before = body.tools.map((t: Record) => t.allowed_callers); + body.tools = body.tools.map((tool: Record) => + tool.allowed_callers ? tool : { ...tool, allowed_callers: ['direct'] }, + ); + const after = body.tools.map((t: Record) => t.allowed_callers); + log.debug(`fetchWithAllowedCallers: injecting allowed_callers url: ${url}, before: ${before}, after: ${after}`); + init = { ...init, body: JSON.stringify(body) }; + } else { + log.debug(`fetchWithAllowedCallers: POST to ${url} — no tools array in body`); + } + log.debug(`final payload: ${JSON.stringify(body)}`) + } catch { + /* leave body unchanged if it can't be parsed */ + } + } else { + log.info(`fetchWithAllowedCallers: called [method=${init?.method} bodyType=${typeof init?.body}]`); + } + return proxyFetch(url, init); +} const PAGE_CONTENT_MAX_LENGTH = 2000; const PAGE_FETCH_TIMEOUT_MS = 5000; @@ -58,101 +96,56 @@ async function fetchPageContent(url: string): Promise { } /** - * Search the web using Claude's native web search tool. + * Search the web using Claude's native web search tool via the AI SDK. */ export async function searchWithClaude(params: { query: string; apiKey: string; modelId?: string; baseUrl: string; - tools?: Tool[]; + tools?: ToolDef[]; }): Promise { - const { query, apiKey, modelId: rawModelId, baseUrl, tools: rawTools } = params; + const { query, apiKey, modelId: rawModelId, baseUrl, tools } = params; const modelId = rawModelId?.trim() || 'claude-sonnet-4-6'; - const tools: (Tool | WebSearchTool20260209)[] = - rawTools && rawTools.length > 0 - ? rawTools.map( - (t) => ({ ...t, allowed_callers: ['direct'] }) as Tool & { allowed_callers: string[] }, - ) - : [DEFAULT_WEB_SEARCH_TOOL]; + + const provider = createAnthropic({ + apiKey, + baseURL: baseUrl, + fetch: fetchWithAllowedCallers as typeof fetch, + }); try { const startTime = Date.now(); - const client = new Anthropic({ baseURL: baseUrl, apiKey, fetch: proxyFetch as typeof fetch }); - const response = await client.messages - .create({ - max_tokens: 4096, - messages: [ - { - role: 'user', - content: `Search for the following and provide a comprehensive summary with source links: ${query}.`, - }, - ], - model: modelId || '', - tools: tools as Tool[], - }) - .catch(async (err) => { - if (err instanceof Anthropic.APIError) { - throw new Error(`Claude API error (${err.status}): ${err.message}`); - } else { - throw err; - } - }); - - const contentBlocks = response.content; - - // Extract search results from web_search_tool_result blocks - const searchResultMap = new Map(); - for (const block of contentBlocks) { - if (block.type !== 'web_search_tool_result') continue; - for (const source of getWebSearchResult(block.content)) { - if (!searchResultMap.has(source.url)) { - searchResultMap.set(source.url, source); - } - } - } - // Collect the final answer text blocks (ignore server_tool_use / web_search_tool_result) - const answerParts: string[] = []; - for (const block of contentBlocks) { - if (block.type === 'text' && block.text) { - answerParts.push(block.text); - // If the block carries citations, make sure those sources are captured - for (const citation of block.citations || []) { - if (citation.type !== 'web_search_result_location') continue; - if (!searchResultMap.has(citation.url)) { - searchResultMap.set(citation.url, { - title: citation.title || citation.url, - url: citation.url, - content: citation.cited_text || '', - }); - } else { - const existing = searchResultMap.get(citation.url)!; - if (!existing.content && citation.cited_text) { - existing.content = citation.cited_text; - } - } - } - } - } + const result = await generateText({ + model: provider(modelId), + messages: [ + { + role: 'user', + content: `Search for the following and provide a comprehensive summary with source links: ${query}.`, + }, + ], + maxOutputTokens: 4096, + tools: buildTools(provider, tools), + }); - const answerText = answerParts.join('\n\n'); - const sources = Array.from(searchResultMap.values()); + // The AI SDK surfaces web search results as sources (url + title only; no snippet content). + // We fetch each page to populate content, then drop any that fail. + const sources: WebSearchSource[] = result.sources.flatMap((s) => { + if (s.sourceType !== 'url') return []; + return [{ url: s.url, title: s.title || s.url, content: '' }]; + }); - // Fetch page content for sources that have no content from citations await Promise.all( - sources - .filter((s) => !s.content) - .map(async (s) => { - s.content = await fetchPageContent(s.url); - }), + sources.map(async (s) => { + s.content = await fetchPageContent(s.url); + }), ); - // Drop sources for which we could not obtain any content const sourcesWithContent = sources.filter((s) => s.content); return { - answer: answerText, + answer: result.text, sources: sourcesWithContent, query, responseTime: Date.now() - startTime, @@ -163,13 +156,6 @@ export async function searchWithClaude(params: { } } -function getWebSearchResult(content: Anthropic.WebSearchToolResultBlockContent): WebSearchSource[] { - if (!Array.isArray(content)) return []; - return content - .filter((r) => r.type === 'web_search_result') - .map((r) => ({ title: r.title || r.url, url: r.url, content: '' })); -} - /** * Reuse formatting logic from Tavily. */ diff --git a/lib/web-search/constants.ts b/lib/web-search/constants.ts index 106d1985f..7c56fe274 100644 --- a/lib/web-search/constants.ts +++ b/lib/web-search/constants.ts @@ -20,18 +20,15 @@ export const WEB_SEARCH_PROVIDERS: Record ({ - proxyFetch: vi.fn(), -})); +// ── AI SDK mocks ────────────────────────────────────────────────────────────── + +const { mockGenerateText, mockProvider, mockTool, mockCreateAnthropic } = vi.hoisted(() => { + const mockTool = {}; + const mockModel = {}; + const mockProvider = Object.assign( + vi.fn(() => mockModel), + { + tools: { + webSearch_20260209: vi.fn(() => mockTool), + webSearch_20250305: vi.fn(() => mockTool), + }, + }, + ); + const mockCreateAnthropic = vi.fn(() => mockProvider); + const mockGenerateText = vi.fn(); + return { mockGenerateText, mockProvider, mockTool, mockCreateAnthropic }; +}); + +vi.mock('ai', () => ({ generateText: mockGenerateText })); +vi.mock('@ai-sdk/anthropic', () => ({ createAnthropic: mockCreateAnthropic })); + +// ── Infrastructure mocks ────────────────────────────────────────────────────── + +vi.mock('@/lib/server/proxy-fetch', () => ({ proxyFetch: vi.fn() })); -// Mock ssrf-guard to avoid real DNS lookups in tests vi.mock('@/lib/server/ssrf-guard', () => ({ validateUrlForSSRF: async (url: string): Promise => { let parsed: URL; @@ -14,9 +34,7 @@ vi.mock('@/lib/server/ssrf-guard', () => ({ } catch { return 'Invalid URL'; } - if (!['http:', 'https:'].includes(parsed.protocol)) { - return 'Only HTTP(S) URLs are allowed'; - } + if (!['http:', 'https:'].includes(parsed.protocol)) return 'Only HTTP(S) URLs are allowed'; const hostname = parsed.hostname.replace(/^\[|\]$/g, ''); const privatePatterns = [ /^localhost$/i, @@ -27,144 +45,150 @@ vi.mock('@/lib/server/ssrf-guard', () => ({ /^169\.254\./, /^::1$/, ]; - if (privatePatterns.some((p) => p.test(hostname))) { + if (privatePatterns.some((p) => p.test(hostname))) return 'Local/private network URLs are not allowed'; - } return ''; }, })); vi.mock('@/lib/logger', () => ({ - createLogger: () => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }), + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), })); +// ── Helpers ─────────────────────────────────────────────────────────────────── + import { proxyFetch } from '@/lib/server/proxy-fetch'; import { searchWithClaude } from '@/lib/web-search/claude'; const mockProxyFetch = proxyFetch as ReturnType; -/** Mock a successful Anthropic Messages API response (first proxyFetch call). */ -function mockApiResponse(overrides: { content?: unknown[] } = {}) { - const body = JSON.stringify({ - id: 'msg_test', - type: 'message', - role: 'assistant', - model: 'claude-sonnet-4-6', - stop_reason: 'end_turn', - usage: { input_tokens: 10, output_tokens: 20 }, - content: overrides.content ?? [{ type: 'text', text: 'Search result', citations: [] }], - }); - mockProxyFetch.mockResolvedValueOnce( - new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }), - ); +type UrlSource = { sourceType: 'url'; type: 'source'; id: string; url: string; title?: string }; + +function mockAIResponse(text = 'Search result', sources: UrlSource[] = []) { + mockGenerateText.mockResolvedValueOnce({ text, sources }); } -/** Mock a page-content fetch response. */ function mockPageResponse(html: string) { - mockProxyFetch.mockResolvedValueOnce({ - ok: true, - text: async () => html, - }); + mockProxyFetch.mockResolvedValueOnce({ ok: true, text: async () => html }); } -/** Mock a failing page-content fetch. */ function mockPageFailure() { mockProxyFetch.mockResolvedValueOnce({ ok: false, status: 404 }); } +// ── Tests ───────────────────────────────────────────────────────────────────── + describe('searchWithClaude', () => { beforeEach(() => { mockProxyFetch.mockReset(); + mockGenerateText.mockReset(); + mockCreateAnthropic.mockClear(); + }); + + // ── fetch interceptor: allowed_callers injection ────────────────────────── + + it('injects allowed_callers=["direct"] on tools that omit it', async () => { + mockAIResponse(); + await searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com' }); + + const wrappedFetch = mockCreateAnthropic.mock.calls[0][0].fetch as ( + url: string, + init?: RequestInit, + ) => Promise; + + const body = JSON.stringify({ tools: [{ type: 'web_search_20260209', name: 'web_search' }] }); + mockProxyFetch.mockResolvedValueOnce(new Response('{}', { status: 200 })); + await wrappedFetch('https://api.anthropic.com/v1/messages', { method: 'POST', body }); + + const sentBody = JSON.parse(mockProxyFetch.mock.calls[0][1].body as string); + expect(sentBody.tools[0].allowed_callers).toEqual(['direct']); }); - // ── baseUrl ─────────────────────────────────────────────────────────────── + it('does not overwrite allowed_callers when already set', async () => { + mockAIResponse(); + await searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com' }); + + const wrappedFetch = mockCreateAnthropic.mock.calls[0][0].fetch as ( + url: string, + init?: RequestInit, + ) => Promise; + + const body = JSON.stringify({ + tools: [{ type: 'web_search_20260209', name: 'web_search', allowed_callers: ['agent'] }], + }); + mockProxyFetch.mockResolvedValueOnce(new Response('{}', { status: 200 })); + await wrappedFetch('https://api.anthropic.com/v1/messages', { method: 'POST', body }); - it('uses the provided baseUrl to construct the messages endpoint', async () => { - mockApiResponse(); + const sentBody = JSON.parse(mockProxyFetch.mock.calls[0][1].body as string); + expect(sentBody.tools[0].allowed_callers).toEqual(['agent']); + }); + + // ── provider setup ──────────────────────────────────────────────────────── + + it('passes baseUrl and apiKey to createAnthropic', async () => { + mockAIResponse(); await searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com', }); - - const [url] = mockProxyFetch.mock.calls[0]; - expect(url).toBe('https://api.anthropic.com/v1/messages'); + expect(mockCreateAnthropic).toHaveBeenCalledWith( + expect.objectContaining({ baseURL: 'https://api.anthropic.com', apiKey: 'sk-test' }), + ); }); - it('uses a custom baseUrl when provided', async () => { - mockApiResponse(); + it('calls generateText with the web_search tool', async () => { + mockAIResponse(); await searchWithClaude({ query: 'test', apiKey: 'sk-test', - baseUrl: 'https://custom.example.com', + baseUrl: 'https://api.anthropic.com', }); - - const [url] = mockProxyFetch.mock.calls[0]; - expect(url).toBe('https://custom.example.com/v1/messages'); + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ tools: expect.objectContaining({ web_search: mockTool }) }), + ); }); - // ── tools ───────────────────────────────────────────────────────────────── - - it('uses provided tools with allowed_callers when non-empty', async () => { - mockApiResponse(); - const customTools = [{ type: 'web_search_custom', name: 'my_search', input_schema: {} }]; + it('falls back to claude-sonnet-4-6 when no modelId provided', async () => { + mockAIResponse(); await searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com', - tools: customTools as never, }); - - const body = JSON.parse(mockProxyFetch.mock.calls[0][1].body); - expect(body.tools).toEqual(customTools.map((t) => ({ ...t, allowed_callers: ['direct'] }))); + expect(mockProvider).toHaveBeenCalledWith('claude-sonnet-4-6'); }); - it('uses default web_search tool when tools is undefined', async () => { - mockApiResponse(); + it('uses the provided modelId', async () => { + mockAIResponse(); await searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com', + modelId: 'claude-opus-4-7', }); - - const body = JSON.parse(mockProxyFetch.mock.calls[0][1].body); - expect(body.tools).toEqual([ - { type: 'web_search_20260209', name: 'web_search', allowed_callers: ['direct'] }, - ]); + expect(mockProvider).toHaveBeenCalledWith('claude-opus-4-7'); }); - it('uses default web_search tool when tools is an empty array', async () => { - mockApiResponse(); - await searchWithClaude({ + // ── answer ──────────────────────────────────────────────────────────────── + + it('returns the answer text from generateText', async () => { + mockAIResponse('Comprehensive answer about the topic'); + const result = await searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com', - tools: [], }); - - const body = JSON.parse(mockProxyFetch.mock.calls[0][1].body); - expect(body.tools).toEqual([ - { type: 'web_search_20260209', name: 'web_search', allowed_callers: ['direct'] }, - ]); + expect(result.answer).toBe('Comprehensive answer about the topic'); + expect(result.query).toBe('test'); }); - // ── page content fetching ──────────────────────────────────────────────── + // ── page content fetching ───────────────────────────────────────────────── - it('fetches page content for sources with no citation content', async () => { - mockApiResponse({ - content: [ - { - type: 'web_search_tool_result', - content: [{ type: 'web_search_result', url: 'https://example.com', title: 'Example' }], - }, - { type: 'text', text: 'Answer', citations: [] }, - ], - }); + it('fetches page content for each source URL', async () => { + mockAIResponse('Answer', [ + { sourceType: 'url', type: 'source', id: '1', url: 'https://example.com', title: 'Example' }, + ]); mockPageResponse('

Page content here

'); const result = await searchWithClaude({ @@ -173,30 +197,20 @@ describe('searchWithClaude', () => { baseUrl: 'https://api.anthropic.com', }); + expect(mockProxyFetch).toHaveBeenCalledWith('https://example.com', expect.any(Object)); expect(result.sources).toHaveLength(1); expect(result.sources[0].content).toBe('Page content here'); - // Second proxyFetch call should be the page fetch - expect(mockProxyFetch.mock.calls[1][0]).toBe('https://example.com'); }); it('strips HTML tags and collapses whitespace from fetched page content', async () => { - mockApiResponse({ - content: [ - { - type: 'web_search_tool_result', - content: [{ type: 'web_search_result', url: 'https://example.com', title: 'Ex' }], - }, - { type: 'text', text: 'Answer', citations: [] }, - ], - }); + mockAIResponse('Answer', [ + { sourceType: 'url', type: 'source', id: '1', url: 'https://example.com', title: 'Ex' }, + ]); mockPageResponse(` - -

Title

-

Some content

- +

Title

Some content

`); @@ -213,53 +227,11 @@ describe('searchWithClaude', () => { expect(result.sources[0].content).toContain('Some content'); }); - it('skips page fetch for sources that already have content from citations', async () => { - mockApiResponse({ - content: [ - { - type: 'web_search_tool_result', - content: [{ type: 'web_search_result', url: 'https://example.com', title: 'Ex' }], - }, - { - type: 'text', - text: 'Answer', - citations: [ - { - type: 'web_search_result_location', - url: 'https://example.com', - title: 'Ex', - cited_text: 'Already have this content', - encrypted_index: '', - }, - ], - }, - ], - }); - - const result = await searchWithClaude({ - query: 'test', - apiKey: 'sk-test', - baseUrl: 'https://api.anthropic.com', - }); - - // Only 1 proxyFetch call — the API call; no page fetch - expect(mockProxyFetch).toHaveBeenCalledTimes(1); - expect(result.sources[0].content).toBe('Already have this content'); - }); - - it('fetches multiple sources in parallel and fills content for each', async () => { - mockApiResponse({ - content: [ - { - type: 'web_search_tool_result', - content: [ - { type: 'web_search_result', url: 'https://a.com', title: 'A' }, - { type: 'web_search_result', url: 'https://b.com', title: 'B' }, - ], - }, - { type: 'text', text: 'Answer', citations: [] }, - ], - }); + it('fetches multiple sources in parallel', async () => { + mockAIResponse('Answer', [ + { sourceType: 'url', type: 'source', id: '1', url: 'https://a.com', title: 'A' }, + { sourceType: 'url', type: 'source', id: '2', url: 'https://b.com', title: 'B' }, + ]); mockPageResponse('

Content A

'); mockPageResponse('

Content B

'); @@ -270,24 +242,17 @@ describe('searchWithClaude', () => { }); expect(result.sources).toHaveLength(2); - // Page fetches are calls [1] and [2] - const fetchedUrls = mockProxyFetch.mock.calls.slice(1).map((call: string[]) => call[0]); + const fetchedUrls = mockProxyFetch.mock.calls.map((call: unknown[]) => call[0]); expect(fetchedUrls).toContain('https://a.com'); expect(fetchedUrls).toContain('https://b.com'); expect(result.sources.find((s) => s.url === 'https://a.com')?.content).toContain('Content A'); expect(result.sources.find((s) => s.url === 'https://b.com')?.content).toContain('Content B'); }); - it('filters out sources for which page fetch returns no content (non-ok response)', async () => { - mockApiResponse({ - content: [ - { - type: 'web_search_tool_result', - content: [{ type: 'web_search_result', url: 'https://dead.com', title: 'Dead' }], - }, - { type: 'text', text: 'Answer', citations: [] }, - ], - }); + it('filters out sources where page fetch returns non-ok response', async () => { + mockAIResponse('Answer', [ + { sourceType: 'url', type: 'source', id: '1', url: 'https://dead.com', title: 'Dead' }, + ]); mockPageFailure(); const result = await searchWithClaude({ @@ -295,20 +260,13 @@ describe('searchWithClaude', () => { apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com', }); - expect(result.sources).toHaveLength(0); }); - it('filters out sources for which page fetch throws (network error)', async () => { - mockApiResponse({ - content: [ - { - type: 'web_search_tool_result', - content: [{ type: 'web_search_result', url: 'https://dead.com', title: 'Dead' }], - }, - { type: 'text', text: 'Answer', citations: [] }, - ], - }); + it('filters out sources where page fetch throws (network error)', async () => { + mockAIResponse('Answer', [ + { sourceType: 'url', type: 'source', id: '1', url: 'https://dead.com', title: 'Dead' }, + ]); mockProxyFetch.mockRejectedValueOnce(new Error('Network timeout')); const result = await searchWithClaude({ @@ -316,149 +274,133 @@ describe('searchWithClaude', () => { apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com', }); - expect(result.sources).toHaveLength(0); }); - // ── SSRF protection ─────────────────────────────────────────────────────── - - it('skips page fetch for localhost URLs (SSRF protection)', async () => { - mockApiResponse({ - content: [ - { - type: 'web_search_tool_result', - content: [{ type: 'web_search_result', url: 'http://localhost/secret', title: 'Local' }], - }, - { type: 'text', text: 'Answer', citations: [] }, - ], - }); + it('keeps sources with content and drops sources without after mixed page fetches', async () => { + mockAIResponse('Answer', [ + { sourceType: 'url', type: 'source', id: '1', url: 'https://good.com', title: 'Good' }, + { sourceType: 'url', type: 'source', id: '2', url: 'https://dead.com', title: 'Dead' }, + ]); + mockPageResponse('

Good content

'); + mockPageFailure(); const result = await searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com', }); - - // Only the API call should have been made; no page fetch - expect(mockProxyFetch).toHaveBeenCalledTimes(1); - expect(result.sources).toHaveLength(0); + expect(result.sources).toHaveLength(1); + expect(result.sources[0].url).toBe('https://good.com'); }); - it('skips page fetch for private IP URLs (SSRF protection)', async () => { - mockApiResponse({ - content: [ - { - type: 'web_search_tool_result', - content: [ - { type: 'web_search_result', url: 'http://192.168.1.1/admin', title: 'Private' }, - ], - }, - { type: 'text', text: 'Answer', citations: [] }, + it('ignores non-url sources (document sources)', async () => { + mockGenerateText.mockResolvedValueOnce({ + text: 'Answer', + sources: [ + { sourceType: 'document', type: 'source', id: '1', mediaType: 'text/plain', title: 'Doc' }, + { sourceType: 'url', type: 'source', id: '2', url: 'https://example.com', title: 'Web' }, ], }); + mockPageResponse('

Web content

'); const result = await searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com', }); + expect(result.sources).toHaveLength(1); + expect(result.sources[0].url).toBe('https://example.com'); + }); + + // ── SSRF protection ─────────────────────────────────────────────────────── + + it('skips page fetch for localhost URLs (SSRF protection)', async () => { + mockAIResponse('Answer', [ + { + sourceType: 'url', + type: 'source', + id: '1', + url: 'http://localhost/secret', + title: 'Local', + }, + ]); - expect(mockProxyFetch).toHaveBeenCalledTimes(1); + const result = await searchWithClaude({ + query: 'test', + apiKey: 'sk-test', + baseUrl: 'https://api.anthropic.com', + }); + expect(mockProxyFetch).not.toHaveBeenCalled(); expect(result.sources).toHaveLength(0); }); - it('skips page fetch for non-HTTP(S) URLs (SSRF protection)', async () => { - mockApiResponse({ - content: [ - { - type: 'web_search_tool_result', - content: [{ type: 'web_search_result', url: 'file:///etc/passwd', title: 'File' }], - }, - { type: 'text', text: 'Answer', citations: [] }, - ], - }); + it('skips page fetch for private IP URLs (SSRF protection)', async () => { + mockAIResponse('Answer', [ + { + sourceType: 'url', + type: 'source', + id: '1', + url: 'http://192.168.1.1/admin', + title: 'Private', + }, + ]); const result = await searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com', }); - - expect(mockProxyFetch).toHaveBeenCalledTimes(1); + expect(mockProxyFetch).not.toHaveBeenCalled(); expect(result.sources).toHaveLength(0); }); - it('skips page fetch for metadata endpoint URLs (SSRF protection)', async () => { - mockApiResponse({ - content: [ - { - type: 'web_search_tool_result', - content: [ - { - type: 'web_search_result', - url: 'http://169.254.169.254/latest/meta-data/', - title: 'Metadata', - }, - ], - }, - { type: 'text', text: 'Answer', citations: [] }, - ], - }); + it('skips page fetch for non-HTTP(S) URLs (SSRF protection)', async () => { + mockAIResponse('Answer', [ + { sourceType: 'url', type: 'source', id: '1', url: 'file:///etc/passwd', title: 'File' }, + ]); const result = await searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com', }); - - expect(mockProxyFetch).toHaveBeenCalledTimes(1); + expect(mockProxyFetch).not.toHaveBeenCalled(); expect(result.sources).toHaveLength(0); }); - it('keeps sources with content and drops sources without after mixed page fetches', async () => { - mockApiResponse({ - content: [ - { - type: 'web_search_tool_result', - content: [ - { type: 'web_search_result', url: 'https://good.com', title: 'Good' }, - { type: 'web_search_result', url: 'https://dead.com', title: 'Dead' }, - ], - }, - { type: 'text', text: 'Answer', citations: [] }, - ], - }); - mockPageResponse('

Good content

'); - mockPageFailure(); + it('skips page fetch for cloud metadata endpoint URLs (SSRF protection)', async () => { + mockAIResponse('Answer', [ + { + sourceType: 'url', + type: 'source', + id: '1', + url: 'http://169.254.169.254/latest/meta-data/', + title: 'Meta', + }, + ]); const result = await searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com', }); - - expect(result.sources).toHaveLength(1); - expect(result.sources[0].url).toBe('https://good.com'); + expect(mockProxyFetch).not.toHaveBeenCalled(); + expect(result.sources).toHaveLength(0); }); // ── error propagation ───────────────────────────────────────────────────── - it('throws when the API returns a non-ok response', async () => { - const errorBody = JSON.stringify({ - type: 'error', - error: { type: 'authentication_error', message: 'invalid x-api-key' }, - }); - mockProxyFetch.mockResolvedValueOnce( - new Response(errorBody, { status: 401, headers: { 'content-type': 'application/json' } }), - ); + it('throws when generateText rejects', async () => { + mockGenerateText.mockRejectedValueOnce(new Error('Claude API error (401): invalid x-api-key')); await expect( searchWithClaude({ query: 'test', apiKey: 'bad-key', baseUrl: 'https://api.anthropic.com' }), - ).rejects.toThrow(/Claude API error \(401\)/); + ).rejects.toThrow('Claude API error (401)'); }); - it('throws when proxyFetch rejects (network error)', async () => { - mockProxyFetch.mockRejectedValueOnce(new Error('Network failure')); + it('throws when generateText rejects with a network error', async () => { + mockGenerateText.mockRejectedValueOnce(new Error('Network failure')); await expect( searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com' }), From b71292289b341b39c1e7a838aa9998686116bd70 Mon Sep 17 00:00:00 2001 From: Joseph Mpo Yeti Date: Sun, 19 Apr 2026 18:09:44 +0100 Subject: [PATCH 24/26] fixed format --- lib/web-search/claude.ts | 10 +++++++--- tests/web-search/claude.test.ts | 12 ++++++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/lib/web-search/claude.ts b/lib/web-search/claude.ts index 6a2f37868..68a81f24d 100644 --- a/lib/web-search/claude.ts +++ b/lib/web-search/claude.ts @@ -41,17 +41,21 @@ async function fetchWithAllowedCallers(url: string, init?: RequestInit): Promise tool.allowed_callers ? tool : { ...tool, allowed_callers: ['direct'] }, ); const after = body.tools.map((t: Record) => t.allowed_callers); - log.debug(`fetchWithAllowedCallers: injecting allowed_callers url: ${url}, before: ${before}, after: ${after}`); + log.debug( + `fetchWithAllowedCallers: injecting allowed_callers url: ${url}, before: ${before}, after: ${after}`, + ); init = { ...init, body: JSON.stringify(body) }; } else { log.debug(`fetchWithAllowedCallers: POST to ${url} — no tools array in body`); } - log.debug(`final payload: ${JSON.stringify(body)}`) + log.debug(`final payload: ${JSON.stringify(body)}`); } catch { /* leave body unchanged if it can't be parsed */ } } else { - log.info(`fetchWithAllowedCallers: called [method=${init?.method} bodyType=${typeof init?.body}]`); + log.info( + `fetchWithAllowedCallers: called [method=${init?.method} bodyType=${typeof init?.body}]`, + ); } return proxyFetch(url, init); } diff --git a/tests/web-search/claude.test.ts b/tests/web-search/claude.test.ts index e515a5d5b..6f7ce33b4 100644 --- a/tests/web-search/claude.test.ts +++ b/tests/web-search/claude.test.ts @@ -89,7 +89,11 @@ describe('searchWithClaude', () => { it('injects allowed_callers=["direct"] on tools that omit it', async () => { mockAIResponse(); - await searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com' }); + await searchWithClaude({ + query: 'test', + apiKey: 'sk-test', + baseUrl: 'https://api.anthropic.com', + }); const wrappedFetch = mockCreateAnthropic.mock.calls[0][0].fetch as ( url: string, @@ -106,7 +110,11 @@ describe('searchWithClaude', () => { it('does not overwrite allowed_callers when already set', async () => { mockAIResponse(); - await searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com' }); + await searchWithClaude({ + query: 'test', + apiKey: 'sk-test', + baseUrl: 'https://api.anthropic.com', + }); const wrappedFetch = mockCreateAnthropic.mock.calls[0][0].fetch as ( url: string, From 4edcd26565d3c7effad5b44d8492c92094d66877 Mon Sep 17 00:00:00 2001 From: Joseph Mpo Yeti Date: Sun, 19 Apr 2026 18:13:13 +0100 Subject: [PATCH 25/26] removed @anthropic-ai/sdk as direct dependency --- pnpm-lock.yaml | 37 ++----------------------------------- 1 file changed, 2 insertions(+), 35 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66d2f003b..6a886eb78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,9 +20,6 @@ importers: '@ai-sdk/react': specifier: ^3.0.44 version: 3.0.118(react@19.2.3)(zod@4.3.6) - '@anthropic-ai/sdk': - specifier: ^0.90.0 - version: 0.90.0(zod@4.3.6) '@base-ui/react': specifier: ^1.1.0 version: 1.2.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -31,7 +28,7 @@ importers: version: 0.37.0(axios@1.13.6)(ignore@5.3.2)(lodash@4.17.23)(playwright@1.58.2)(ws@8.19.0) '@copilotkit/runtime': specifier: ^1.51.2 - version: 1.53.0(@ag-ui/encoder@0.0.47)(@anthropic-ai/sdk@0.90.0(zod@4.3.6))(@cfworker/json-schema@4.1.1)(@copilotkitnext/shared@1.53.0)(@langchain/core@1.1.31(@opentelemetry/api@1.9.0)(openai@4.104.0(ws@8.19.0)(zod@4.3.6)))(@langchain/langgraph-sdk@1.7.1(@langchain/core@1.1.31(@opentelemetry/api@1.9.0)(openai@4.104.0(ws@8.19.0)(zod@4.3.6))))(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(ws@8.19.0) + version: 1.53.0(@ag-ui/encoder@0.0.47)(@cfworker/json-schema@4.1.1)(@copilotkitnext/shared@1.53.0)(@langchain/core@1.1.31(@opentelemetry/api@1.9.0)(openai@4.104.0(ws@8.19.0)(zod@4.3.6)))(@langchain/langgraph-sdk@1.7.1(@langchain/core@1.1.31(@opentelemetry/api@1.9.0)(openai@4.104.0(ws@8.19.0)(zod@4.3.6))))(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(ws@8.19.0) '@fontsource-variable/inter': specifier: ^5.2.8 version: 5.2.8 @@ -514,15 +511,6 @@ packages: '@anthropic-ai/sdk@0.9.1': resolution: {integrity: sha512-wa1meQ2WSfoY8Uor3EdrJq0jTiZJoKoSii2ZVWRY1oN4Tlr5s59pADg9T79FTbPe1/se5c3pBeZgJL63wmuoBA==} - '@anthropic-ai/sdk@0.90.0': - resolution: {integrity: sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg==} - hasBin: true - peerDependencies: - zod: ^3.25.0 || ^4.0.0 - peerDependenciesMeta: - zod: - optional: true - '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -6334,10 +6322,6 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - json-schema-to-ts@3.1.1: - resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} - engines: {node: '>=16'} - json-schema-traverse@0.3.1: resolution: {integrity: sha512-4JD/Ivzg7PoW8NzdrBSr3UFwC9mHgvI7Z6z3QGBsSHgKaRTUDmyZAAKJo2UbG1kUVfS9WS8bi36N49U1xw43DA==} @@ -8785,9 +8769,6 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} - ts-algebra@2.0.0: - resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} - ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -9615,12 +9596,6 @@ snapshots: transitivePeerDependencies: - encoding - '@anthropic-ai/sdk@0.90.0(zod@4.3.6)': - dependencies: - json-schema-to-ts: 3.1.1 - optionalDependencies: - zod: 4.3.6 - '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -10085,7 +10060,7 @@ snapshots: - youtube-transcript - youtubei.js - '@copilotkit/runtime@1.53.0(@ag-ui/encoder@0.0.47)(@anthropic-ai/sdk@0.90.0(zod@4.3.6))(@cfworker/json-schema@4.1.1)(@copilotkitnext/shared@1.53.0)(@langchain/core@1.1.31(@opentelemetry/api@1.9.0)(openai@4.104.0(ws@8.19.0)(zod@4.3.6)))(@langchain/langgraph-sdk@1.7.1(@langchain/core@1.1.31(@opentelemetry/api@1.9.0)(openai@4.104.0(ws@8.19.0)(zod@4.3.6))))(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(ws@8.19.0)': + '@copilotkit/runtime@1.53.0(@ag-ui/encoder@0.0.47)(@cfworker/json-schema@4.1.1)(@copilotkitnext/shared@1.53.0)(@langchain/core@1.1.31(@opentelemetry/api@1.9.0)(openai@4.104.0(ws@8.19.0)(zod@4.3.6)))(@langchain/langgraph-sdk@1.7.1(@langchain/core@1.1.31(@opentelemetry/api@1.9.0)(openai@4.104.0(ws@8.19.0)(zod@4.3.6))))(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(ws@8.19.0)': dependencies: '@ag-ui/client': 0.0.47 '@ag-ui/core': 0.0.47 @@ -10115,7 +10090,6 @@ snapshots: type-graphql: 2.0.0-rc.1(class-validator@0.14.4)(graphql-scalars@1.25.0(graphql@16.13.1))(graphql@16.13.1) zod: 3.25.76 optionalDependencies: - '@anthropic-ai/sdk': 0.90.0(zod@4.3.6) '@langchain/langgraph-sdk': 1.7.1(@langchain/core@1.1.31(@opentelemetry/api@1.9.0)(openai@4.104.0(ws@8.19.0)(zod@4.3.6))) transitivePeerDependencies: - '@ag-ui/encoder' @@ -16050,11 +16024,6 @@ snapshots: json-parse-even-better-errors@2.3.1: {} - json-schema-to-ts@3.1.1: - dependencies: - '@babel/runtime': 7.29.2 - ts-algebra: 2.0.0 - json-schema-traverse@0.3.1: {} json-schema-traverse@0.4.1: {} @@ -18903,8 +18872,6 @@ snapshots: trough@2.2.0: {} - ts-algebra@2.0.0: {} - ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 From c3235636a7caf2fb523e4ced477cdfa582211927 Mon Sep 17 00:00:00 2001 From: Joseph Mpo Yeti Date: Sun, 19 Apr 2026 18:17:00 +0100 Subject: [PATCH 26/26] fix: resolve TypeScript errors in claude web-search tests Cast mock.calls through unknown to avoid tuple-index type errors on Vitest mock objects whose parameter types are inferred as empty tuples. Co-Authored-By: Claude Sonnet 4.6 --- tests/web-search/claude.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/web-search/claude.test.ts b/tests/web-search/claude.test.ts index 6f7ce33b4..faa3f1297 100644 --- a/tests/web-search/claude.test.ts +++ b/tests/web-search/claude.test.ts @@ -95,7 +95,8 @@ describe('searchWithClaude', () => { baseUrl: 'https://api.anthropic.com', }); - const wrappedFetch = mockCreateAnthropic.mock.calls[0][0].fetch as ( + const calls = mockCreateAnthropic.mock.calls as unknown as [options: Record][]; + const wrappedFetch = calls[0]![0]!['fetch'] as ( url: string, init?: RequestInit, ) => Promise; @@ -104,7 +105,8 @@ describe('searchWithClaude', () => { mockProxyFetch.mockResolvedValueOnce(new Response('{}', { status: 200 })); await wrappedFetch('https://api.anthropic.com/v1/messages', { method: 'POST', body }); - const sentBody = JSON.parse(mockProxyFetch.mock.calls[0][1].body as string); + const proxyCalls = mockProxyFetch.mock.calls as unknown as [string, RequestInit][]; + const sentBody = JSON.parse(proxyCalls[0]![1]!.body as string); expect(sentBody.tools[0].allowed_callers).toEqual(['direct']); }); @@ -116,7 +118,10 @@ describe('searchWithClaude', () => { baseUrl: 'https://api.anthropic.com', }); - const wrappedFetch = mockCreateAnthropic.mock.calls[0][0].fetch as ( + const calls2 = mockCreateAnthropic.mock.calls as unknown as [ + options: Record, + ][]; + const wrappedFetch = calls2[0]![0]!['fetch'] as ( url: string, init?: RequestInit, ) => Promise; @@ -127,7 +132,8 @@ describe('searchWithClaude', () => { mockProxyFetch.mockResolvedValueOnce(new Response('{}', { status: 200 })); await wrappedFetch('https://api.anthropic.com/v1/messages', { method: 'POST', body }); - const sentBody = JSON.parse(mockProxyFetch.mock.calls[0][1].body as string); + const proxyCalls2 = mockProxyFetch.mock.calls as unknown as [string, RequestInit][]; + const sentBody = JSON.parse(proxyCalls2[0]![1]!.body as string); expect(sentBody.tools[0].allowed_callers).toEqual(['agent']); });