From 9c420212790381a4113ce2aff32984636e49128b Mon Sep 17 00:00:00 2001 From: Mohammad Saif Khan Date: Fri, 10 Apr 2026 17:56:29 +0530 Subject: [PATCH 1/6] Refactor resolveWebSearchApiKey for multiple providers --- lib/server/provider-config.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/server/provider-config.ts b/lib/server/provider-config.ts index 259208fde..2257f5b10 100644 --- a/lib/server/provider-config.ts +++ b/lib/server/provider-config.ts @@ -91,6 +91,7 @@ const VIDEO_ENV_MAP: Record = { const WEB_SEARCH_ENV_MAP: Record = { TAVILY: 'tavily', + ANTHROPIC: 'claude', }; // --------------------------------------------------------------------------- @@ -413,10 +414,13 @@ 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 > 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 || ''; + + if (providerId === 'claude') return process.env.ANTHROPIC_API_KEY || ''; + if (providerId === 'tavily') return process.env.TAVILY_API_KEY || ''; + return ''; } From d2a118863cfae294e64db83cdd0479acceae1e84 Mon Sep 17 00:00:00 2001 From: Mohammad Saif Khan Date: Fri, 10 Apr 2026 17:59:58 +0530 Subject: [PATCH 2/6] Add 'claude' to WebSearchProviderId type --- lib/web-search/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/web-search/types.ts b/lib/web-search/types.ts index f83822c7c..b4d5eb41b 100644 --- a/lib/web-search/types.ts +++ b/lib/web-search/types.ts @@ -5,7 +5,7 @@ /** * Web Search Provider IDs */ -export type WebSearchProviderId = 'tavily'; +export type WebSearchProviderId = 'tavily' | 'claude'; /** * Web Search Provider Configuration From 700bc3f27919c1e71704802061f678d46b4fa705 Mon Sep 17 00:00:00 2001 From: Mohammad Saif Khan Date: Fri, 10 Apr 2026 18:00:52 +0530 Subject: [PATCH 3/6] Add Claude Web Search provider configuration --- lib/web-search/constants.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/web-search/constants.ts b/lib/web-search/constants.ts index 6542bbb2a..cde2c9945 100644 --- a/lib/web-search/constants.ts +++ b/lib/web-search/constants.ts @@ -14,6 +14,12 @@ export const WEB_SEARCH_PROVIDERS: Record Date: Fri, 10 Apr 2026 18:02:43 +0530 Subject: [PATCH 4/6] Add Claude web search integration using Anthropic API This file integrates Claude's web search capabilities using Anthropic's Messages API, allowing for server-side web searches and result parsing. --- lib/web-search/claude.ts | 134 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 lib/web-search/claude.ts diff --git a/lib/web-search/claude.ts b/lib/web-search/claude.ts new file mode 100644 index 000000000..2878ee34f --- /dev/null +++ b/lib/web-search/claude.ts @@ -0,0 +1,134 @@ +/** + * Claude Web Search Integration + * + * Uses Anthropic's Messages API with the built-in server-side web search tool. + * Endpoint: POST https://api.anthropic.com/v1/messages + */ + +import { proxyFetch } from '@/lib/server/proxy-fetch'; +import type { WebSearchResult, WebSearchSource } from '@/lib/types/web-search'; + +const ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages'; + +/** + * Search the web using Claude's built-in web search tool. + * Note: The API automatically executes searches server-side and synthesizes + * an answer. We parse the message response to extract both. + */ +export async function searchWithClaude(params: { + query: string; + apiKey: string; + maxResults?: number; +}): Promise { + const { query, apiKey, maxResults = 5 } = params; + const startTime = Date.now(); + + const res = await proxyFetch(ANTHROPIC_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: 'claude-3-5-sonnet-latest', // Anthropic recommended model for web search capabilities + max_tokens: 1024, + messages: [ + { + role: 'user', + content: `Search the web for the following query and provide a detailed summary of the findings: ${query}`, + }, + ], + tools: [ + { + type: 'web_search_20250305', // Current stable server-side web search tool definition + 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 { + content?: Array<{ + type: string; + text?: string; + content?: Array<{ + type: string; + uri?: string; + url?: string; + title?: string; + cited_text?: string; + }>; + }>; + }; + + const responseTime = Date.now() - startTime; + + let answer = ''; + const sourcesMap = new Map(); + + // Parse Anthropic's multi-block message response + if (data.content && Array.isArray(data.content)) { + for (const block of data.content) { + if (block.type === 'text' && block.text) { + // Collect Claude's synthesized text response intent/answer + answer += block.text + '\n'; + } else if (block.type === 'web_search_tool_result' && Array.isArray(block.content)) { + // Extract URLs and titles from the server-executed search result blocks + for (const result of block.content) { + if (result.type === 'web_search_result') { + const url = result.url || result.uri; + if (url && !sourcesMap.has(url)) { + sourcesMap.set(url, { + title: result.title || 'Untitled', + url: url, + // Claude encrypts raw content snippets for security, so we extract cited text if available + content: result.cited_text || '', + score: 1, // Claude doesn't provide relevance scores + }); + } + } + } + } + } + } + + return { + answer: answer.trim(), + sources: Array.from(sourcesMap.values()).slice(0, maxResults), + query, + responseTime, + }; +} + +/** + * Format search results into a markdown context block for LLM prompts. + */ +export function formatClaudeSearchResultsAsContext(result: WebSearchResult): string { + if (!result.answer && result.sources.length === 0) { + return ''; + } + + const lines: string[] = []; + + if (result.answer) { + lines.push(result.answer); + lines.push(''); + } + + if (result.sources.length > 0) { + lines.push('Sources:'); + for (const src of result.sources) { + // Snippets aren't completely exposed by Anthropic by default, prioritize Title + URL + const contextStr = src.content ? `: ${src.content.slice(0, 200)}` : ''; + lines.push(`- [${src.title}](${src.url})${contextStr}`); + } + } + + return lines.join('\n'); +} From 7299b0b7509f991139197e892161cdfe2197fc9e Mon Sep 17 00:00:00 2001 From: Mohammad Saif Khan Date: Fri, 10 Apr 2026 12:57:51 +0000 Subject: [PATCH 5/6] Add Claude web search support alongside Tavily --- app/api/web-search/route.ts | 28 ++++++++++++++++++++++------ lib/server/provider-config.ts | 2 +- lib/web-search/claude.ts | 4 ++-- pnpm-lock.yaml | 17 +++++++---------- 4 files changed, 32 insertions(+), 19 deletions(-) diff --git a/app/api/web-search/route.ts b/app/api/web-search/route.ts index 53e6d2e0e..a19559fc3 100644 --- a/app/api/web-search/route.ts +++ b/app/api/web-search/route.ts @@ -2,12 +2,16 @@ * Web Search API * * POST /api/web-search - * Simple JSON request/response using Tavily search. + * Simple JSON request/response using Tavily or Claude search. */ import { NextRequest } from 'next/server'; import { callLLM } from '@/lib/ai/llm'; -import { searchWithTavily, formatSearchResultsAsContext } from '@/lib/web-search/tavily'; +import { + searchWithTavily, + formatSearchResultsAsContext as formatTavilyContext, +} from '@/lib/web-search/tavily'; +import { searchWithClaude, formatClaudeSearchResultsAsContext } from '@/lib/web-search/claude'; import { resolveWebSearchApiKey } from '@/lib/server/provider-config'; import { createLogger } from '@/lib/logger'; import { apiError, apiSuccess } from '@/lib/server/api-response'; @@ -17,6 +21,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,10 +33,12 @@ export async function POST(req: NextRequest) { query: requestQuery, pdfText, apiKey: clientApiKey, + providerId = 'tavily', } = body as { query?: string; pdfText?: string; apiKey?: string; + providerId?: WebSearchProviderId; }; query = requestQuery; @@ -39,12 +46,12 @@ export async function POST(req: NextRequest) { return apiError('MISSING_REQUIRED_FIELD', 400, 'query is required'); } - const apiKey = resolveWebSearchApiKey(clientApiKey); + const apiKey = resolveWebSearchApiKey(providerId, clientApiKey); if (!apiKey) { 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 === 'claude' ? 'Anthropic' : 'Tavily'} API key is not configured. Set it in Settings → Web Search or set the appropriate env var.`, ); } @@ -75,14 +82,23 @@ 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 context = formatSearchResultsAsContext(result); + let result; + let context; + + if (providerId === 'claude') { + result = await searchWithClaude({ query: searchQuery.query, apiKey }); + context = formatClaudeSearchResultsAsContext(result); + } else { + result = await searchWithTavily({ query: searchQuery.query, apiKey }); + context = formatTavilyContext(result); + } return apiSuccess({ answer: result.answer, diff --git a/lib/server/provider-config.ts b/lib/server/provider-config.ts index 2257f5b10..4b8973acd 100644 --- a/lib/server/provider-config.ts +++ b/lib/server/provider-config.ts @@ -419,7 +419,7 @@ export function resolveWebSearchApiKey(providerId: string, clientKey?: string): if (clientKey) return clientKey; const serverKey = getConfig().webSearch[providerId]?.apiKey; if (serverKey) return serverKey; - + if (providerId === 'claude') return process.env.ANTHROPIC_API_KEY || ''; if (providerId === 'tavily') return process.env.TAVILY_API_KEY || ''; return ''; diff --git a/lib/web-search/claude.ts b/lib/web-search/claude.ts index 2878ee34f..378ba881c 100644 --- a/lib/web-search/claude.ts +++ b/lib/web-search/claude.ts @@ -12,7 +12,7 @@ const ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages'; /** * Search the web using Claude's built-in web search tool. - * Note: The API automatically executes searches server-side and synthesizes + * Note: The API automatically executes searches server-side and synthesizes * an answer. We parse the message response to extract both. */ export async function searchWithClaude(params: { @@ -88,7 +88,7 @@ export async function searchWithClaude(params: { title: result.title || 'Untitled', url: url, // Claude encrypts raw content snippets for security, so we extract cited text if available - content: result.cited_text || '', + content: result.cited_text || '', score: 1, // Claude doesn't provide relevance scores }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7592f157b..be0f15ee1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -290,7 +290,7 @@ importers: version: 9.39.4(jiti@2.6.1) eslint-config-next: specifier: 16.1.2 - version: 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) + version: 16.1.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) prettier: specifier: 3.8.1 version: 3.8.1 @@ -13808,13 +13808,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 +13847,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 +13872,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 +13883,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 From 8bee72881fb25247f7f54adf3fe97e8828eab264 Mon Sep 17 00:00:00 2001 From: Mohammad Saif Khan Date: Fri, 10 Apr 2026 13:37:20 +0000 Subject: [PATCH 6/6] Add multi-provider web search support to classroom generation --- lib/server/classroom-generation.ts | 39 +++++++++++++++++++++------- tests/server/provider-config.test.ts | 12 ++++++--- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/lib/server/classroom-generation.ts b/lib/server/classroom-generation.ts index a1ceabfb4..f95c12486 100644 --- a/lib/server/classroom-generation.ts +++ b/lib/server/classroom-generation.ts @@ -20,7 +20,12 @@ 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 as formatTavilyContext, +} from '@/lib/web-search/tavily'; +import { searchWithClaude, formatClaudeSearchResultsAsContext } from '@/lib/web-search/claude'; +import type { WebSearchProviderId } from '@/lib/web-search/types'; import { persistClassroom } from '@/lib/server/classroom-storage'; import { generateMediaForClassroom, @@ -38,6 +43,7 @@ export interface GenerateClassroomInput { pdfContent?: { text: string; images: string[] }; language?: string; enableWebSearch?: boolean; + webSearchProviderId?: WebSearchProviderId; enableImageGeneration?: boolean; enableVideoGeneration?: boolean; enableTTS?: boolean; @@ -252,23 +258,36 @@ export async function generateClassroom( // Web search (optional, graceful degradation) let researchContext: string | undefined; if (input.enableWebSearch) { - const tavilyKey = resolveWebSearchApiKey(); - if (tavilyKey) { + const searchProvider = input.webSearchProviderId || 'tavily'; + const searchApiKey = resolveWebSearchApiKey(searchProvider); + + if (searchApiKey) { try { const searchQuery = await buildSearchQuery(requirement, pdfText, searchQueryAiCall); log.info('Running web search for classroom generation', { + provider: searchProvider, hasPdfContext: searchQuery.hasPdfContext, rawRequirementLength: searchQuery.rawRequirementLength, rewriteAttempted: searchQuery.rewriteAttempted, finalQueryLength: searchQuery.finalQueryLength, }); - const searchResult = await searchWithTavily({ - query: searchQuery.query, - apiKey: tavilyKey, - }); - researchContext = formatSearchResultsAsContext(searchResult); + let searchResult; + if (searchProvider === 'claude') { + searchResult = await searchWithClaude({ + query: searchQuery.query, + apiKey: searchApiKey, + }); + researchContext = formatClaudeSearchResultsAsContext(searchResult); + } else { + searchResult = await searchWithTavily({ + query: searchQuery.query, + apiKey: searchApiKey, + }); + researchContext = formatTavilyContext(searchResult); + } + if (researchContext) { log.info(`Web search returned ${searchResult.sources.length} sources`); } @@ -276,7 +295,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 ${searchProvider} API key configured, skipping web search`, + ); } } diff --git a/tests/server/provider-config.test.ts b/tests/server/provider-config.test.ts index 58d05c942..977b7dd2e 100644 --- a/tests/server/provider-config.test.ts +++ b/tests/server/provider-config.test.ts @@ -157,13 +157,19 @@ providers: describe('resolveWebSearchApiKey', () => { it('returns client key first', 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 () => { + it('falls back to TAVILY_API_KEY env var for tavily provider', 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('falls back to ANTHROPIC_API_KEY env var for claude provider', async () => { + vi.stubEnv('ANTHROPIC_API_KEY', 'claude-bare-env'); + const { resolveWebSearchApiKey } = await import('@/lib/server/provider-config'); + expect(resolveWebSearchApiKey('claude')).toBe('claude-bare-env'); }); });