({
+ ...provider,
+ name: getWebSearchProviderDisplayName(provider.id, t),
+ }))}
configs={webSearchProvidersConfig}
selectedId={selectedWebSearchProviderId}
onSelect={setSelectedWebSearchProviderId}
diff --git a/components/settings/web-search-settings.tsx b/components/settings/web-search-settings.tsx
index d5cf37761..84d3cf1e8 100644
--- a/components/settings/web-search-settings.tsx
+++ b/components/settings/web-search-settings.tsx
@@ -23,6 +23,13 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps
const provider = WEB_SEARCH_PROVIDERS[selectedProviderId];
const isServerConfigured = !!webSearchProvidersConfig[selectedProviderId]?.isServerConfigured;
+ const buildRequestUrl = (baseUrl: string) => {
+ const trimmed = baseUrl.replace(/\/$/, '');
+ if (!provider.endpointPath) return trimmed;
+ if (trimmed.endsWith(provider.endpointPath)) return trimmed;
+ return `${trimmed}${provider.endpointPath}`;
+ };
+
// Reset showApiKey when provider changes (derived state pattern)
const [prevSelectedProviderId, setPrevSelectedProviderId] = useState(selectedProviderId);
if (selectedProviderId !== prevSelectedProviderId) {
@@ -83,7 +90,7 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
- placeholder={provider.defaultBaseUrl || 'https://api.tavily.com'}
+ placeholder={provider.defaultBaseUrl || ''}
value={webSearchProvidersConfig[selectedProviderId]?.baseUrl || ''}
onChange={(e) =>
setWebSearchProviderConfig(selectedProviderId, {
@@ -102,7 +109,7 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps
provider.defaultBaseUrl ||
'';
if (!effectiveBaseUrl) return null;
- const fullUrl = effectiveBaseUrl + '/search';
+ const fullUrl = buildRequestUrl(effectiveBaseUrl);
return (
{t('settings.requestUrl')}: {fullUrl}
diff --git a/lib/i18n/locales/ar-SA.json b/lib/i18n/locales/ar-SA.json
index de952a41b..5c178050c 100644
--- a/lib/i18n/locales/ar-SA.json
+++ b/lib/i18n/locales/ar-SA.json
@@ -454,7 +454,9 @@
"grok": "Grok",
"tencent-hunyuan": "Tencent Hunyuan",
"xiaomi": "Xiaomi MiMo",
- "ollama": "Ollama (محلي)"
+ "ollama": "Ollama (محلي)",
+ "tavily": "Tavily",
+ "bocha": "Bocha"
},
"providerTypes": {
"openai": "بروتوكول OpenAI",
@@ -954,12 +956,12 @@
"clearCacheSuccess": "تم مسح الذاكرة المؤقتة، ستتم إعادة تحميل الصفحة قريبًا",
"clearCacheFailed": "فشل مسح الذاكرة المؤقتة، يرجى المحاولة مرة أخرى",
"webSearchSettings": "بحث الإنترنت",
- "webSearchApiKey": "مفتاح API لـ Tavily",
- "webSearchApiKeyPlaceholder": "أدخل مفتاح API لـ Tavily",
+ "webSearchApiKey": "مفتاح API للبحث",
+ "webSearchApiKeyPlaceholder": "أدخل مفتاح API للبحث",
"webSearchApiKeyPlaceholderServer": "تم تكوين مفتاح الخادم، يمكنك التجاوز اختياريًا",
- "webSearchApiKeyHint": "احصل على مفتاح API من tavily.com للبحث في الإنترنت",
+ "webSearchApiKeyHint": "احصل على مفتاح API من مزود البحث المحدد",
"webSearchBaseUrl": "العنوان الأساسي",
- "webSearchServerConfigured": "تم تكوين مفتاح API لـ Tavily على الخادم",
+ "webSearchServerConfigured": "تم تكوين مفتاح API للبحث على الخادم",
"optional": "اختياري"
},
"profile": {
diff --git a/lib/i18n/locales/en-US.json b/lib/i18n/locales/en-US.json
index 46535f443..a36b0f15c 100644
--- a/lib/i18n/locales/en-US.json
+++ b/lib/i18n/locales/en-US.json
@@ -454,7 +454,9 @@
"grok": "Grok",
"tencent-hunyuan": "Tencent Hunyuan",
"xiaomi": "Xiaomi MiMo",
- "ollama": "Ollama (Local)"
+ "ollama": "Ollama (Local)",
+ "tavily": "Tavily",
+ "bocha": "Bocha"
},
"providerTypes": {
"openai": "OpenAI Protocol",
@@ -954,12 +956,12 @@
"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": "Search API Key",
+ "webSearchApiKeyPlaceholder": "Enter your search API key",
"webSearchApiKeyPlaceholderServer": "Server key configured, optionally override",
- "webSearchApiKeyHint": "Get an API key from tavily.com for web search",
+ "webSearchApiKeyHint": "Get an API key from the selected search provider",
"webSearchBaseUrl": "Base URL",
- "webSearchServerConfigured": "Server-side Tavily API key is configured",
+ "webSearchServerConfigured": "Server-side search API key is configured",
"optional": "Optional"
},
"profile": {
diff --git a/lib/i18n/locales/ja-JP.json b/lib/i18n/locales/ja-JP.json
index 1f70154d7..d59022bb1 100644
--- a/lib/i18n/locales/ja-JP.json
+++ b/lib/i18n/locales/ja-JP.json
@@ -454,7 +454,9 @@
"grok": "Grok",
"tencent-hunyuan": "Tencent Hunyuan",
"xiaomi": "Xiaomi MiMo",
- "ollama": "Ollama(ローカルモデル)"
+ "ollama": "Ollama(ローカルモデル)",
+ "tavily": "Tavily",
+ "bocha": "Bocha"
},
"providerTypes": {
"openai": "OpenAIプロトコル",
@@ -954,12 +956,12 @@
"clearCacheSuccess": "キャッシュをクリアしました。まもなくページが更新されます",
"clearCacheFailed": "キャッシュのクリアに失敗しました。もう一度お試しください",
"webSearchSettings": "ウェブ検索",
- "webSearchApiKey": "Tavily APIキー",
- "webSearchApiKeyPlaceholder": "Tavily APIキーを入力",
+ "webSearchApiKey": "検索APIキー",
+ "webSearchApiKeyPlaceholder": "検索APIキーを入力",
"webSearchApiKeyPlaceholderServer": "サーバーキー設定済み、任意で上書き",
- "webSearchApiKeyHint": "ウェブ検索用のAPIキーをtavily.comで取得してください",
+ "webSearchApiKeyHint": "選択した検索プロバイダーからAPIキーを取得してください",
"webSearchBaseUrl": "ベースURL",
- "webSearchServerConfigured": "サーバー側でTavily APIキーが設定済みです",
+ "webSearchServerConfigured": "サーバー側で検索APIキーが設定済みです",
"optional": "任意"
},
"profile": {
diff --git a/lib/i18n/locales/ru-RU.json b/lib/i18n/locales/ru-RU.json
index e8cc05b14..c4be26124 100644
--- a/lib/i18n/locales/ru-RU.json
+++ b/lib/i18n/locales/ru-RU.json
@@ -454,7 +454,9 @@
"grok": "Grok",
"tencent-hunyuan": "Tencent Hunyuan",
"xiaomi": "Xiaomi MiMo",
- "ollama": "Ollama (Локальный)"
+ "ollama": "Ollama (Локальный)",
+ "tavily": "Tavily",
+ "bocha": "Bocha"
},
"providerTypes": {
"openai": "Протокол OpenAI",
@@ -954,12 +956,12 @@
"clearCacheSuccess": "Кэш очищен, страница скоро обновится",
"clearCacheFailed": "Не удалось очистить кэш, попробуйте снова",
"webSearchSettings": "Веб-поиск",
- "webSearchApiKey": "Tavily API-ключ",
- "webSearchApiKeyPlaceholder": "Введите ваш Tavily API-ключ",
+ "webSearchApiKey": "API-ключ поиска",
+ "webSearchApiKeyPlaceholder": "Введите API-ключ поиска",
"webSearchApiKeyPlaceholderServer": "Серверный ключ настроен, можно ввести свой",
- "webSearchApiKeyHint": "Получите API-ключ на tavily.com для веб-поиска",
+ "webSearchApiKeyHint": "Получите API-ключ у выбранного поискового провайдера",
"webSearchBaseUrl": "Base URL",
- "webSearchServerConfigured": "Серверный Tavily API-ключ настроен",
+ "webSearchServerConfigured": "Серверный API-ключ поиска настроен",
"optional": "Необязательно"
},
"profile": {
diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json
index da4482fc3..79892a759 100644
--- a/lib/i18n/locales/zh-CN.json
+++ b/lib/i18n/locales/zh-CN.json
@@ -454,7 +454,9 @@
"grok": "Grok",
"tencent-hunyuan": "腾讯混元",
"xiaomi": "小米 MiMo",
- "ollama": "Ollama(本地模型)"
+ "ollama": "Ollama(本地模型)",
+ "tavily": "Tavily",
+ "bocha": "博查"
},
"providerTypes": {
"openai": "OpenAI 协议",
@@ -954,12 +956,12 @@
"clearCacheSuccess": "缓存已清空,页面即将刷新",
"clearCacheFailed": "清空缓存失败,请重试",
"webSearchSettings": "网络搜索",
- "webSearchApiKey": "Tavily API Key",
- "webSearchApiKeyPlaceholder": "输入你的 Tavily API Key",
+ "webSearchApiKey": "搜索 API Key",
+ "webSearchApiKeyPlaceholder": "输入你的搜索 API Key",
"webSearchApiKeyPlaceholderServer": "已配置服务端密钥,可选填覆盖",
- "webSearchApiKeyHint": "从 tavily.com 获取 API Key,用于网络搜索",
+ "webSearchApiKeyHint": "从所选搜索服务商获取 API Key,用于网络搜索",
"webSearchBaseUrl": "Base URL",
- "webSearchServerConfigured": "服务端已配置 Tavily API Key",
+ "webSearchServerConfigured": "服务端已配置搜索 API Key",
"optional": "可选"
},
"profile": {
diff --git a/lib/server/classroom-generation.ts b/lib/server/classroom-generation.ts
index 5e4b6dfa1..bf26a0cf0 100644
--- a/lib/server/classroom-generation.ts
+++ b/lib/server/classroom-generation.ts
@@ -16,10 +16,11 @@ import type { AgentInfo } from '@/lib/generation/pipeline-types';
import { getDefaultAgents } from '@/lib/orchestration/registry/store';
import { createLogger } from '@/lib/logger';
import { isProviderKeyRequired } from '@/lib/ai/providers';
-import { resolveWebSearchApiKey } from '@/lib/server/provider-config';
+import { resolveClassroomWebSearchConfig } from '@/lib/server/web-search-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 { formatSearchResultsAsContext, searchWeb } from '@/lib/web-search';
+import type { WebSearchProviderId } from '@/lib/web-search/types';
import { persistClassroom } from '@/lib/server/classroom-storage';
import {
generateMediaForClassroom,
@@ -36,6 +37,8 @@ export interface GenerateClassroomInput {
requirement: string;
pdfContent?: { text: string; images: string[] };
enableWebSearch?: boolean;
+ webSearchProviderId?: WebSearchProviderId;
+ webSearchApiKey?: string;
enableImageGeneration?: boolean;
enableVideoGeneration?: boolean;
enableTTS?: boolean;
@@ -234,8 +237,8 @@ export async function generateClassroom(
// Web search (optional, graceful degradation)
let researchContext: string | undefined;
if (input.enableWebSearch) {
- const tavilyKey = resolveWebSearchApiKey();
- if (tavilyKey) {
+ const webSearchConfig = resolveClassroomWebSearchConfig(input);
+ if (webSearchConfig) {
try {
const searchQuery = await buildSearchQuery(requirement, pdfText, searchQueryAiCall);
@@ -246,9 +249,11 @@ export async function generateClassroom(
finalQueryLength: searchQuery.finalQueryLength,
});
- const searchResult = await searchWithTavily({
+ const searchResult = await searchWeb({
+ providerId: webSearchConfig.providerId,
query: searchQuery.query,
- apiKey: tavilyKey,
+ apiKey: webSearchConfig.apiKey,
+ baseUrl: webSearchConfig.baseUrl,
});
researchContext = formatSearchResultsAsContext(searchResult);
if (researchContext) {
@@ -258,7 +263,7 @@ 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 web search API key configured, skipping web search');
}
}
diff --git a/lib/server/provider-config.ts b/lib/server/provider-config.ts
index 8b5cf6bc5..20442029b 100644
--- a/lib/server/provider-config.ts
+++ b/lib/server/provider-config.ts
@@ -99,6 +99,7 @@ const VIDEO_ENV_MAP: Record = {
const WEB_SEARCH_ENV_MAP: Record = {
TAVILY: 'tavily',
+ BOCHA: 'bocha',
};
// ---------------------------------------------------------------------------
@@ -411,7 +412,7 @@ export function resolveVideoBaseUrl(
}
// ---------------------------------------------------------------------------
-// Public API — Web Search (Tavily)
+// Public API — Web Search
// ---------------------------------------------------------------------------
/** Returns server-configured web search providers (no apiKeys exposed) */
@@ -425,10 +426,39 @@ export function getServerWebSearchProviders(): Record server key > TAVILY_API_KEY env > empty */
-export function resolveWebSearchApiKey(clientKey?: string): string {
- if (clientKey) return clientKey;
- const serverKey = getConfig().webSearch.tavily?.apiKey;
+/**
+ * Resolve web search API key.
+ *
+ * Backward-compatible call shapes:
+ * - resolveWebSearchApiKey(clientKey) -> Tavily key resolution
+ * - resolveWebSearchApiKey(providerId, clientKey) -> provider-specific resolution
+ */
+export function resolveWebSearchApiKey(clientKey?: string): string;
+export function resolveWebSearchApiKey(providerId: string, clientKey?: string): string;
+export function resolveWebSearchApiKey(providerIdOrClientKey?: string, clientKey?: string): string {
+ const hasProviderId = arguments.length >= 2;
+ const providerId = hasProviderId ? providerIdOrClientKey || 'tavily' : 'tavily';
+ const effectiveClientKey = hasProviderId ? clientKey : providerIdOrClientKey;
+
+ if (effectiveClientKey) return effectiveClientKey;
+ const serverKey = getConfig().webSearch[providerId]?.apiKey;
if (serverKey) return serverKey;
- return process.env.TAVILY_API_KEY || '';
+ return '';
+}
+
+export function resolveWebSearchBaseUrl(
+ providerId: string,
+ clientBaseUrl?: string,
+): string | undefined {
+ if (clientBaseUrl) return clientBaseUrl;
+ return getConfig().webSearch[providerId]?.baseUrl;
+}
+
+export function resolveServerWebSearchProviderId(preferredProviderId?: string): string | undefined {
+ const webSearch = getConfig().webSearch;
+ if (preferredProviderId && webSearch[preferredProviderId]?.apiKey) {
+ return preferredProviderId;
+ }
+ if (webSearch.tavily?.apiKey) return 'tavily';
+ return Object.keys(webSearch)[0];
}
diff --git a/lib/server/web-search-config.ts b/lib/server/web-search-config.ts
new file mode 100644
index 000000000..9d7c5a571
--- /dev/null
+++ b/lib/server/web-search-config.ts
@@ -0,0 +1,80 @@
+import {
+ resolveServerWebSearchProviderId,
+ resolveWebSearchApiKey,
+ resolveWebSearchBaseUrl,
+} from '@/lib/server/provider-config';
+import { WEB_SEARCH_PROVIDERS } from '@/lib/web-search/constants';
+import type { WebSearchProviderId } from '@/lib/web-search/types';
+
+const OFFICIAL_CLIENT_BASE_URLS: Record = {
+ tavily: ['https://api.tavily.com', 'https://api.tavily.com/search'],
+ bocha: [
+ 'https://api.bocha.cn',
+ 'https://api.bocha.cn/v1',
+ 'https://api.bocha.cn/v1/web-search',
+ 'https://api.bochaai.com',
+ 'https://api.bochaai.com/v1',
+ 'https://api.bochaai.com/v1/web-search',
+ ],
+};
+
+function normalizeBaseUrl(value: string): string {
+ return value.replace(/\/+$/, '');
+}
+
+function assertWebSearchProviderId(
+ providerId: string | undefined,
+): providerId is WebSearchProviderId {
+ return !!providerId && providerId in WEB_SEARCH_PROVIDERS;
+}
+
+export function resolveSafeClientWebSearchBaseUrl(
+ providerId: WebSearchProviderId,
+ clientBaseUrl?: string,
+): string | undefined {
+ const trimmed = clientBaseUrl?.trim();
+ if (!trimmed) return undefined;
+
+ let normalized: string;
+ try {
+ const parsed = new URL(trimmed);
+ normalized = normalizeBaseUrl(parsed.toString());
+ } catch {
+ throw new Error(`Unsupported ${WEB_SEARCH_PROVIDERS[providerId].name} base URL`);
+ }
+
+ const allowed = OFFICIAL_CLIENT_BASE_URLS[providerId].map(normalizeBaseUrl);
+ if (!allowed.includes(normalized)) {
+ throw new Error(`Unsupported ${WEB_SEARCH_PROVIDERS[providerId].name} base URL`);
+ }
+ return normalized;
+}
+
+export function resolveWebSearchRouteBaseUrl(
+ providerId: WebSearchProviderId,
+ clientBaseUrl?: string,
+): string | undefined {
+ const safeClientBaseUrl = resolveSafeClientWebSearchBaseUrl(providerId, clientBaseUrl);
+ return resolveWebSearchBaseUrl(providerId, safeClientBaseUrl);
+}
+
+export function resolveClassroomWebSearchConfig(input: {
+ webSearchProviderId?: WebSearchProviderId;
+ webSearchApiKey?: string;
+}): { providerId: WebSearchProviderId; apiKey: string; baseUrl?: string } | undefined {
+ const requestedProviderId = assertWebSearchProviderId(input.webSearchProviderId)
+ ? input.webSearchProviderId
+ : undefined;
+ const providerId =
+ requestedProviderId ?? (resolveServerWebSearchProviderId() as WebSearchProviderId | undefined);
+ if (!providerId) return undefined;
+
+ const apiKey = resolveWebSearchApiKey(providerId, input.webSearchApiKey);
+ if (!apiKey) return undefined;
+
+ return {
+ providerId,
+ apiKey,
+ baseUrl: resolveWebSearchBaseUrl(providerId),
+ };
+}
diff --git a/lib/store/settings.ts b/lib/store/settings.ts
index a879546a9..e8e82996a 100644
--- a/lib/store/settings.ts
+++ b/lib/store/settings.ts
@@ -411,6 +411,7 @@ const getDefaultWebSearchConfig = () => ({
webSearchProviderId: 'tavily' as WebSearchProviderId,
webSearchProvidersConfig: {
tavily: { apiKey: '', baseUrl: '', enabled: true },
+ bocha: { apiKey: '', baseUrl: '', enabled: true },
} as Record,
});
@@ -586,6 +587,21 @@ 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(WEB_SEARCH_PROVIDERS).forEach((pid) => {
+ const providerId = pid as WebSearchProviderId;
+ if (!state.webSearchProvidersConfig![providerId]) {
+ state.webSearchProvidersConfig![providerId] = defaultConfig[providerId];
+ }
+ });
+}
+
// Migrate from old localStorage format
const migrateFromOldStorage = () => {
if (typeof window === 'undefined') return null;
@@ -1187,6 +1203,7 @@ export const useSettingsStore = create()(
const pdfFallback = buildFallback(newPDFConfig);
const imageFallback = buildFallback(newImageConfig);
const videoFallback = buildFallback(newVideoConfig);
+ const webSearchFallback = buildFallback(newWebSearchConfig);
const validLLMProvider = validateProvider(
state.providerId,
@@ -1221,6 +1238,12 @@ export const useSettingsStore = create()(
newVideoConfig,
videoFallback,
);
+ const validWebSearchProvider = validateProvider(
+ state.webSearchProviderId,
+ newWebSearchConfig,
+ webSearchFallback,
+ 'tavily' as WebSearchProviderId,
+ );
// Auto-recover: when provider is empty but server has available ones
let recoveredImageModel = '';
@@ -1384,6 +1407,9 @@ export const useSettingsStore = create()(
...(validPDFProvider !== state.pdfProviderId && {
pdfProviderId: validPDFProvider as PDFProviderId,
}),
+ ...(validWebSearchProvider !== state.webSearchProviderId && {
+ webSearchProviderId: validWebSearchProvider as WebSearchProviderId,
+ }),
...(validImageProvider !== state.imageProviderId && {
imageProviderId: validImageProvider as ImageProviderId,
}),
@@ -1473,6 +1499,7 @@ export const useSettingsStore = create()(
Object.assign(state, defaultAudioConfig);
}
ensureBuiltInAudioProviders(state);
+ ensureBuiltInWebSearchProviders(state);
// Migrate global ttsModelId to per-provider
if ((state as Record).ttsModelId) {
@@ -1572,6 +1599,11 @@ export const useSettingsStore = create()(
enabled: true,
isServerConfigured: oldIsServerConfigured,
},
+ bocha: {
+ apiKey: '',
+ baseUrl: '',
+ enabled: true,
+ },
} as SettingsState['webSearchProvidersConfig'];
delete stateRecord.webSearchApiKey;
delete stateRecord.webSearchIsServerConfigured;
@@ -1579,6 +1611,7 @@ export const useSettingsStore = create()(
ensureValidProviderSelections(state);
ensureBuiltInAudioProviders(state);
+ ensureBuiltInWebSearchProviders(state);
state.thinkingConfigs = pruneThinkingConfigs(state.thinkingConfigs, state.providersConfig);
return state;
@@ -1592,6 +1625,7 @@ export const useSettingsStore = create()(
ensureBuiltInAudioProviders(merged as Partial);
ensureBuiltInImageProviders(merged as Partial);
ensureBuiltInVideoProviders(merged as Partial);
+ ensureBuiltInWebSearchProviders(merged as Partial);
ensureValidProviderSelections(merged as Partial);
const typedMerged = merged as Partial;
typedMerged.thinkingConfigs = pruneThinkingConfigs(
diff --git a/lib/web-search/bocha.ts b/lib/web-search/bocha.ts
new file mode 100644
index 000000000..b7f1dc2f5
--- /dev/null
+++ b/lib/web-search/bocha.ts
@@ -0,0 +1,121 @@
+/**
+ * Bocha Web Search Integration
+ *
+ * Uses raw REST API via proxyFetch for reliable proxy support.
+ * Bocha web search endpoint: POST https://api.bocha.cn/v1/web-search
+ */
+
+import { proxyFetch } from '@/lib/server/proxy-fetch';
+import type { WebSearchResult, WebSearchSource } from '@/lib/types/web-search';
+
+const BOCHA_DEFAULT_BASE_URL = 'https://api.bocha.cn';
+const BOCHA_MAX_RESULTS = 50;
+
+function buildBochaWebSearchUrl(baseUrl?: string): string {
+ const trimmed = (baseUrl || BOCHA_DEFAULT_BASE_URL).replace(/\/$/, '');
+ if (trimmed.endsWith('/v1/web-search')) return trimmed;
+ if (trimmed.endsWith('/v1')) return `${trimmed}/web-search`;
+ return `${trimmed}/v1/web-search`;
+}
+
+function clampCount(maxResults: number): number {
+ return Math.min(Math.max(Math.floor(maxResults), 1), BOCHA_MAX_RESULTS);
+}
+
+function formatBochaError(status: number, statusText: string, errorText: string): string {
+ if (!errorText) return `Bocha API error (${status}): ${statusText}`;
+
+ try {
+ const parsed = JSON.parse(errorText) as {
+ code?: string | number;
+ message?: string;
+ msg?: string | null;
+ log_id?: string;
+ };
+ const code = parsed.code ?? status;
+ const message = parsed.message || parsed.msg || statusText;
+ const logId = parsed.log_id ? `, log_id: ${parsed.log_id}` : '';
+ return `Bocha API error (${code}): ${message}${logId}`;
+ } catch {
+ return `Bocha API error (${status}): ${errorText}`;
+ }
+}
+
+/**
+ * Search the web using Bocha Web Search API and return structured results.
+ */
+export async function searchWithBocha(params: {
+ query: string;
+ apiKey: string;
+ maxResults?: number;
+ baseUrl?: string;
+}): Promise {
+ const { query, apiKey, maxResults = 10, baseUrl } = params;
+ const startedAt = Date.now();
+
+ const res = await proxyFetch(buildBochaWebSearchUrl(baseUrl), {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${apiKey}`,
+ },
+ body: JSON.stringify({
+ query,
+ freshness: 'noLimit',
+ summary: true,
+ count: clampCount(maxResults),
+ }),
+ });
+
+ if (!res.ok) {
+ const errorText = await res.text().catch(() => '');
+ throw new Error(formatBochaError(res.status, res.statusText, errorText));
+ }
+
+ const raw = (await res.json()) as {
+ code?: number | string;
+ msg?: string | null;
+ message?: string;
+ log_id?: string;
+ data?: BochaSearchData;
+ } & BochaSearchData;
+
+ if (raw.code !== undefined && String(raw.code) !== '200') {
+ const message = raw.message || raw.msg || 'Request failed';
+ const logId = raw.log_id ? `, log_id: ${raw.log_id}` : '';
+ throw new Error(`Bocha API error (${raw.code}): ${message}${logId}`);
+ }
+
+ const data = raw.data || raw;
+ const pages = data.webPages?.value || [];
+
+ const sources: WebSearchSource[] = pages
+ .filter((page) => page.url)
+ .map((page) => ({
+ title: page.name || page.url,
+ url: page.url,
+ content: page.summary || page.snippet || '',
+ score: 0,
+ }));
+
+ return {
+ answer: '',
+ sources,
+ query: data.queryContext?.originalQuery || query,
+ responseTime: (Date.now() - startedAt) / 1000,
+ };
+}
+
+interface BochaSearchData {
+ queryContext?: {
+ originalQuery?: string;
+ };
+ webPages?: {
+ value?: Array<{
+ name?: string;
+ url: string;
+ snippet?: string;
+ summary?: string;
+ }>;
+ };
+}
diff --git a/lib/web-search/constants.ts b/lib/web-search/constants.ts
index 6542bbb2a..11eaf0c6c 100644
--- a/lib/web-search/constants.ts
+++ b/lib/web-search/constants.ts
@@ -13,9 +13,35 @@ export const WEB_SEARCH_PROVIDERS: Record string,
+): string {
+ const provider = WEB_SEARCH_PROVIDERS[providerId];
+ if (!provider) return providerId;
+
+ if (t) {
+ const key = `settings.providerNames.${providerId}`;
+ const translated = t(key);
+ if (translated && translated !== key) return translated;
+ }
+
+ return provider.name;
+}
+
/**
* Get all available web search providers
*/
diff --git a/lib/web-search/format.ts b/lib/web-search/format.ts
new file mode 100644
index 000000000..3a7781d20
--- /dev/null
+++ b/lib/web-search/format.ts
@@ -0,0 +1,26 @@
+import type { WebSearchResult } from '@/lib/types/web-search';
+
+/**
+ * Format search results into a markdown context block for LLM prompts.
+ */
+export function formatSearchResultsAsContext(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) {
+ lines.push(`- [${src.title}](${src.url}): ${src.content.slice(0, 200)}`);
+ }
+ }
+
+ return lines.join('\n');
+}
diff --git a/lib/web-search/index.ts b/lib/web-search/index.ts
new file mode 100644
index 000000000..1fd739d74
--- /dev/null
+++ b/lib/web-search/index.ts
@@ -0,0 +1,27 @@
+import { searchWithBocha } from './bocha';
+import { searchWithTavily } from './tavily';
+import type { WebSearchResult } from '@/lib/types/web-search';
+import type { WebSearchProviderId } from './types';
+
+export { formatSearchResultsAsContext } from './format';
+
+export async function searchWeb(params: {
+ providerId: WebSearchProviderId;
+ query: string;
+ apiKey: string;
+ maxResults?: number;
+ baseUrl?: string;
+}): Promise {
+ const { providerId, query, apiKey, maxResults, baseUrl } = params;
+
+ switch (providerId) {
+ case 'bocha':
+ return searchWithBocha({ query, apiKey, maxResults, baseUrl });
+ case 'tavily':
+ return searchWithTavily({ query, apiKey, maxResults, baseUrl });
+ default: {
+ const exhaustive: never = providerId;
+ throw new Error(`Unsupported web search provider: ${exhaustive}`);
+ }
+ }
+}
diff --git a/lib/web-search/tavily.ts b/lib/web-search/tavily.ts
index a2329d7a1..1fdca8e19 100644
--- a/lib/web-search/tavily.ts
+++ b/lib/web-search/tavily.ts
@@ -7,11 +7,17 @@
import { proxyFetch } from '@/lib/server/proxy-fetch';
import type { WebSearchResult, WebSearchSource } from '@/lib/types/web-search';
+export { formatSearchResultsAsContext } from './format';
-const TAVILY_API_URL = 'https://api.tavily.com/search';
+const TAVILY_DEFAULT_BASE_URL = 'https://api.tavily.com';
const TAVILY_MAX_QUERY_LENGTH = 400;
+function buildTavilySearchUrl(baseUrl?: string): string {
+ const trimmed = (baseUrl || TAVILY_DEFAULT_BASE_URL).replace(/\/$/, '');
+ return trimmed.endsWith('/search') ? trimmed : `${trimmed}/search`;
+}
+
/**
* Search the web using Tavily REST API and return structured results.
*/
@@ -19,13 +25,14 @@ export async function searchWithTavily(params: {
query: string;
apiKey: string;
maxResults?: number;
+ baseUrl?: string;
}): Promise {
- const { query, apiKey, maxResults = 5 } = params;
+ const { query, apiKey, maxResults = 5, baseUrl } = 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(buildTavilySearchUrl(baseUrl), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -70,28 +77,3 @@ export async function searchWithTavily(params: {
responseTime: data.response_time,
};
}
-
-/**
- * Format search results into a markdown context block for LLM prompts.
- */
-export function formatSearchResultsAsContext(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) {
- lines.push(`- [${src.title}](${src.url}): ${src.content.slice(0, 200)}`);
- }
- }
-
- return lines.join('\n');
-}
diff --git a/lib/web-search/types.ts b/lib/web-search/types.ts
index f83822c7c..ee6726ca0 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' | 'bocha';
/**
* Web Search Provider Configuration
@@ -15,5 +15,6 @@ export interface WebSearchProviderConfig {
name: string;
requiresApiKey: boolean;
defaultBaseUrl?: string;
+ endpointPath: string;
icon?: string;
}
diff --git a/public/logos/bocha.png b/public/logos/bocha.png
new file mode 100644
index 000000000..ec91dca3e
Binary files /dev/null and b/public/logos/bocha.png differ
diff --git a/public/logos/tavily.svg b/public/logos/tavily.svg
new file mode 100644
index 000000000..45d52d9e6
--- /dev/null
+++ b/public/logos/tavily.svg
@@ -0,0 +1,7 @@
+
diff --git a/tests/server/provider-config.test.ts b/tests/server/provider-config.test.ts
index 90ac9c6f8..e42e77baa 100644
--- a/tests/server/provider-config.test.ts
+++ b/tests/server/provider-config.test.ts
@@ -48,6 +48,7 @@ const ENV_PREFIXES_TO_CLEAR = [
'VIDEO_SORA',
'VIDEO_MINIMAX',
'VIDEO_GROK',
+ 'BOCHA',
];
function clearProviderEnv() {
@@ -57,6 +58,8 @@ function clearProviderEnv() {
delete process.env[`${prefix}_MODELS`];
}
delete process.env.TAVILY_API_KEY;
+ delete process.env.BOCHA_API_KEY;
+ delete process.env.BOCHA_BASE_URL;
}
vi.mock('fs', async (importOriginal) => {
@@ -256,6 +259,31 @@ providers:
const { resolveWebSearchApiKey } = await import('@/lib/server/provider-config');
expect(resolveWebSearchApiKey()).toBe('tvly-bare-env');
});
+
+ it('resolves Bocha API key and base URL from env vars', async () => {
+ vi.stubEnv('BOCHA_API_KEY', 'bocha-env-key');
+ vi.stubEnv('BOCHA_BASE_URL', 'https://proxy.example.com/bocha');
+ const { getServerWebSearchProviders, resolveWebSearchApiKey, resolveWebSearchBaseUrl } =
+ await import('@/lib/server/provider-config');
+
+ expect(resolveWebSearchApiKey('bocha', undefined)).toBe('bocha-env-key');
+ expect(resolveWebSearchBaseUrl('bocha')).toBe('https://proxy.example.com/bocha');
+ expect(getServerWebSearchProviders().bocha).toEqual({
+ baseUrl: 'https://proxy.example.com/bocha',
+ });
+ });
+
+ it('uses client key and base URL before Bocha server config', async () => {
+ vi.stubEnv('BOCHA_API_KEY', 'bocha-env-key');
+ vi.stubEnv('BOCHA_BASE_URL', 'https://proxy.example.com/bocha');
+ const { resolveWebSearchApiKey, resolveWebSearchBaseUrl } =
+ await import('@/lib/server/provider-config');
+
+ expect(resolveWebSearchApiKey('bocha', 'bocha-client-key')).toBe('bocha-client-key');
+ expect(resolveWebSearchBaseUrl('bocha', 'https://client.example.com')).toBe(
+ 'https://client.example.com',
+ );
+ });
});
describe('baseUrl-only providers (e.g. mineru)', () => {
diff --git a/tests/server/web-search-config.test.ts b/tests/server/web-search-config.test.ts
new file mode 100644
index 000000000..73a1cd656
--- /dev/null
+++ b/tests/server/web-search-config.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+
+describe('server web search config', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.unstubAllEnvs();
+ delete process.env.TAVILY_API_KEY;
+ delete process.env.TAVILY_BASE_URL;
+ delete process.env.BOCHA_API_KEY;
+ delete process.env.BOCHA_BASE_URL;
+ });
+
+ it('rejects client-controlled base URLs outside the provider allowlist', async () => {
+ const { resolveSafeClientWebSearchBaseUrl } = await import('@/lib/server/web-search-config');
+
+ expect(() =>
+ resolveSafeClientWebSearchBaseUrl('bocha', 'http://127.0.0.1:3000/internal'),
+ ).toThrow('Unsupported Bocha base URL');
+ });
+
+ it('allows official Bocha client base URLs', async () => {
+ const { resolveSafeClientWebSearchBaseUrl } = await import('@/lib/server/web-search-config');
+
+ expect(resolveSafeClientWebSearchBaseUrl('bocha', 'https://api.bochaai.com/v1')).toBe(
+ 'https://api.bochaai.com/v1',
+ );
+ });
+
+ it('resolves classroom web search config from selected provider and client key', async () => {
+ const { resolveClassroomWebSearchConfig } = await import('@/lib/server/web-search-config');
+
+ expect(
+ resolveClassroomWebSearchConfig({
+ webSearchProviderId: 'bocha',
+ webSearchApiKey: 'bocha-client-key',
+ }),
+ ).toEqual({
+ providerId: 'bocha',
+ apiKey: 'bocha-client-key',
+ baseUrl: undefined,
+ });
+ });
+
+ it('uses server base URL for classroom web search config instead of client-controlled URLs', async () => {
+ vi.stubEnv('BOCHA_API_KEY', 'bocha-server-key');
+ vi.stubEnv('BOCHA_BASE_URL', 'http://internal-proxy.local/bocha');
+
+ const { resolveClassroomWebSearchConfig } = await import('@/lib/server/web-search-config');
+
+ expect(resolveClassroomWebSearchConfig({ webSearchProviderId: 'bocha' })).toEqual({
+ providerId: 'bocha',
+ apiKey: 'bocha-server-key',
+ baseUrl: 'http://internal-proxy.local/bocha',
+ });
+ });
+});
diff --git a/tests/store/settings-server-sync.test.ts b/tests/store/settings-server-sync.test.ts
index 3f43b02bd..6436dc1fa 100644
--- a/tests/store/settings-server-sync.test.ts
+++ b/tests/store/settings-server-sync.test.ts
@@ -627,6 +627,78 @@ describe('fetchServerProviders — ASR stale selection', () => {
});
});
+describe('fetchServerProviders — Web Search provider sync', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ storage.clear();
+ mockFetch.mockReset();
+ });
+
+ async function getStore() {
+ const { useSettingsStore } = await import('@/lib/store/settings');
+ return useSettingsStore;
+ }
+
+ it('marks Bocha as server-configured and stores serverBaseUrl', async () => {
+ const store = await getStore();
+ mockServerResponse({
+ webSearch: {
+ bocha: { baseUrl: 'https://api.bocha.cn' },
+ },
+ });
+
+ await store.getState().fetchServerProviders();
+
+ expect(store.getState().webSearchProvidersConfig.bocha).toMatchObject({
+ isServerConfigured: true,
+ serverBaseUrl: 'https://api.bocha.cn',
+ });
+ });
+
+ it('falls back to Bocha when selected Tavily loses server config and has no client key', async () => {
+ const store = await getStore();
+
+ mockServerResponse({
+ webSearch: {
+ tavily: { baseUrl: 'https://api.tavily.com' },
+ bocha: { baseUrl: 'https://api.bocha.cn' },
+ },
+ });
+ await store.getState().fetchServerProviders();
+ store.getState().setWebSearchProvider('tavily');
+
+ mockServerResponse({
+ webSearch: {
+ bocha: { baseUrl: 'https://api.bocha.cn' },
+ },
+ });
+ await store.getState().fetchServerProviders();
+
+ expect(store.getState().webSearchProviderId).toBe('bocha');
+ });
+
+ it('keeps Bocha selected when it is still server-configured', async () => {
+ const store = await getStore();
+
+ mockServerResponse({
+ webSearch: {
+ bocha: { baseUrl: 'https://api.bocha.cn' },
+ },
+ });
+ await store.getState().fetchServerProviders();
+ store.getState().setWebSearchProvider('bocha');
+
+ mockServerResponse({
+ webSearch: {
+ bocha: { baseUrl: 'https://api.bocha.cn' },
+ },
+ });
+ await store.getState().fetchServerProviders();
+
+ expect(store.getState().webSearchProviderId).toBe('bocha');
+ });
+});
+
describe('fetchServerProviders — PDF stale selection', () => {
beforeEach(() => {
vi.resetModules();
diff --git a/tests/web-search/bocha.test.ts b/tests/web-search/bocha.test.ts
new file mode 100644
index 000000000..609346ba7
--- /dev/null
+++ b/tests/web-search/bocha.test.ts
@@ -0,0 +1,127 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const proxyFetchMock = vi.hoisted(() => vi.fn());
+
+vi.mock('@/lib/server/proxy-fetch', () => ({
+ proxyFetch: proxyFetchMock,
+}));
+
+import { searchWithBocha } from '@/lib/web-search/bocha';
+
+describe('searchWithBocha', () => {
+ beforeEach(() => {
+ proxyFetchMock.mockReset();
+ });
+
+ it('calls Bocha Web Search API and maps web page results', async () => {
+ proxyFetchMock.mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ code: 200,
+ log_id: 'log-ok',
+ msg: null,
+ data: {
+ queryContext: { originalQuery: '阿里巴巴 ESG' },
+ webPages: {
+ value: [
+ {
+ name: 'Alibaba ESG report',
+ url: 'https://example.com/esg',
+ snippet: 'Short snippet',
+ summary: 'Long summary',
+ },
+ {
+ name: 'Snippet only',
+ url: 'https://example.com/snippet',
+ snippet: 'Fallback snippet',
+ },
+ ],
+ },
+ },
+ }),
+ { status: 200, headers: { 'content-type': 'application/json' } },
+ ),
+ );
+
+ const result = await searchWithBocha({
+ query: '阿里巴巴 ESG',
+ apiKey: 'bocha-key',
+ maxResults: 100,
+ });
+
+ expect(proxyFetchMock).toHaveBeenCalledWith(
+ 'https://api.bocha.cn/v1/web-search',
+ expect.objectContaining({
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: 'Bearer bocha-key',
+ },
+ body: JSON.stringify({
+ query: '阿里巴巴 ESG',
+ freshness: 'noLimit',
+ summary: true,
+ count: 50,
+ }),
+ }),
+ );
+ expect(result.query).toBe('阿里巴巴 ESG');
+ expect(result.answer).toBe('');
+ expect(result.sources).toEqual([
+ {
+ title: 'Alibaba ESG report',
+ url: 'https://example.com/esg',
+ content: 'Long summary',
+ score: 0,
+ },
+ {
+ title: 'Snippet only',
+ url: 'https://example.com/snippet',
+ content: 'Fallback snippet',
+ score: 0,
+ },
+ ]);
+ });
+
+ it('supports custom base URLs ending at either host, /v1, or full endpoint', async () => {
+ proxyFetchMock.mockImplementation(() =>
+ Promise.resolve(
+ new Response(JSON.stringify({ code: 200, data: { webPages: { value: [] } } }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }),
+ ),
+ );
+
+ await searchWithBocha({ query: 'q1', apiKey: 'key', baseUrl: 'https://proxy.example.com' });
+ await searchWithBocha({ query: 'q2', apiKey: 'key', baseUrl: 'https://proxy.example.com/v1' });
+ await searchWithBocha({
+ query: 'q3',
+ apiKey: 'key',
+ baseUrl: 'https://proxy.example.com/v1/web-search',
+ });
+
+ expect(proxyFetchMock.mock.calls.map((call) => call[0])).toEqual([
+ 'https://proxy.example.com/v1/web-search',
+ 'https://proxy.example.com/v1/web-search',
+ 'https://proxy.example.com/v1/web-search',
+ ]);
+ });
+
+ it('includes Bocha error details when requests fail', async () => {
+ proxyFetchMock.mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ code: '403',
+ message: 'You do not have enough money',
+ log_id: 'bocha-log-id',
+ }),
+ { status: 403, statusText: 'Forbidden', headers: { 'content-type': 'application/json' } },
+ ),
+ );
+
+ await expect(searchWithBocha({ query: 'q', apiKey: 'key' })).rejects.toThrow(
+ 'Bocha API error (403): You do not have enough money, log_id: bocha-log-id',
+ );
+ });
+});
diff --git a/tests/web-search/constants.test.ts b/tests/web-search/constants.test.ts
new file mode 100644
index 000000000..4c19acfed
--- /dev/null
+++ b/tests/web-search/constants.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest';
+import { getWebSearchProviderDisplayName } from '@/lib/web-search/constants';
+
+describe('web search provider constants', () => {
+ it('uses translated provider names when available', () => {
+ const t = (key: string) => (key === 'settings.providerNames.bocha' ? '博查' : key);
+
+ expect(getWebSearchProviderDisplayName('bocha', t)).toBe('博查');
+ });
+
+ it('falls back to provider metadata name when no translation exists', () => {
+ const t = (key: string) => key;
+
+ expect(getWebSearchProviderDisplayName('tavily', t)).toBe('Tavily');
+ });
+});
diff --git a/tests/web-search/index.test.ts b/tests/web-search/index.test.ts
new file mode 100644
index 000000000..68b7814f5
--- /dev/null
+++ b/tests/web-search/index.test.ts
@@ -0,0 +1,75 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const searchWithBochaMock = vi.hoisted(() => vi.fn());
+const searchWithTavilyMock = vi.hoisted(() => vi.fn());
+
+vi.mock('@/lib/web-search/bocha', () => ({
+ searchWithBocha: searchWithBochaMock,
+}));
+
+vi.mock('@/lib/web-search/tavily', () => ({
+ searchWithTavily: searchWithTavilyMock,
+}));
+
+import { searchWeb } from '@/lib/web-search';
+
+describe('searchWeb', () => {
+ beforeEach(() => {
+ searchWithBochaMock.mockReset();
+ searchWithTavilyMock.mockReset();
+ });
+
+ it('dispatches Tavily provider requests', async () => {
+ searchWithTavilyMock.mockResolvedValueOnce({
+ answer: 'tavily answer',
+ sources: [],
+ query: 'q',
+ responseTime: 0.1,
+ });
+
+ await expect(searchWeb({ providerId: 'tavily', query: 'q', apiKey: 'key' })).resolves.toEqual({
+ answer: 'tavily answer',
+ sources: [],
+ query: 'q',
+ responseTime: 0.1,
+ });
+ expect(searchWithTavilyMock).toHaveBeenCalledWith({
+ query: 'q',
+ apiKey: 'key',
+ maxResults: undefined,
+ baseUrl: undefined,
+ });
+ expect(searchWithBochaMock).not.toHaveBeenCalled();
+ });
+
+ it('dispatches Bocha provider requests', async () => {
+ searchWithBochaMock.mockResolvedValueOnce({
+ answer: '',
+ sources: [],
+ query: 'q',
+ responseTime: 0.2,
+ });
+
+ await expect(
+ searchWeb({
+ providerId: 'bocha',
+ query: 'q',
+ apiKey: 'key',
+ maxResults: 20,
+ baseUrl: 'https://api.bocha.cn',
+ }),
+ ).resolves.toEqual({
+ answer: '',
+ sources: [],
+ query: 'q',
+ responseTime: 0.2,
+ });
+ expect(searchWithBochaMock).toHaveBeenCalledWith({
+ query: 'q',
+ apiKey: 'key',
+ maxResults: 20,
+ baseUrl: 'https://api.bocha.cn',
+ });
+ expect(searchWithTavilyMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/web-search/route.test.ts b/tests/web-search/route.test.ts
new file mode 100644
index 000000000..36717ea6b
--- /dev/null
+++ b/tests/web-search/route.test.ts
@@ -0,0 +1,102 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { NextRequest } from 'next/server';
+
+const mocks = vi.hoisted(() => ({
+ searchWeb: vi.fn(),
+ formatSearchResultsAsContext: vi.fn(() => 'formatted context'),
+ resolveModelFromRequest: vi.fn(),
+}));
+
+vi.mock('@/lib/web-search', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ searchWeb: mocks.searchWeb,
+ formatSearchResultsAsContext: mocks.formatSearchResultsAsContext,
+ };
+});
+
+vi.mock('@/lib/server/resolve-model', () => ({
+ resolveModelFromRequest: mocks.resolveModelFromRequest,
+}));
+
+vi.mock('@/lib/ai/llm', () => ({
+ callLLM: vi.fn(),
+}));
+
+vi.mock('@/lib/logger', () => ({
+ createLogger: () => ({
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ }),
+}));
+
+async function postWebSearch(body: Record) {
+ const { POST } = await import('@/app/api/web-search/route');
+ const request = new Request('http://localhost/api/web-search', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ return POST(request as unknown as NextRequest);
+}
+
+describe('POST /api/web-search', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.unstubAllEnvs();
+ delete process.env.TAVILY_API_KEY;
+ delete process.env.TAVILY_BASE_URL;
+ delete process.env.BOCHA_API_KEY;
+ delete process.env.BOCHA_BASE_URL;
+ mocks.searchWeb.mockReset();
+ mocks.formatSearchResultsAsContext.mockClear();
+ mocks.resolveModelFromRequest.mockReset();
+ mocks.resolveModelFromRequest.mockRejectedValue(new Error('model unavailable'));
+ mocks.searchWeb.mockResolvedValue({
+ answer: '',
+ sources: [],
+ query: 'test query',
+ responseTime: 0.1,
+ });
+ });
+
+ it('rejects client-controlled base URLs outside the provider allowlist', async () => {
+ vi.stubEnv('BOCHA_API_KEY', 'bocha-server-key');
+
+ const res = await postWebSearch({
+ query: 'test query',
+ providerId: 'bocha',
+ baseUrl: 'http://127.0.0.1:3000/internal',
+ });
+ const json = await res.json();
+
+ expect(res.status).toBe(400);
+ expect(json).toMatchObject({
+ success: false,
+ errorCode: 'INVALID_REQUEST',
+ });
+ expect(mocks.searchWeb).not.toHaveBeenCalled();
+ });
+
+ it('uses server-configured base URL when no client base URL is supplied', async () => {
+ vi.stubEnv('BOCHA_API_KEY', 'bocha-server-key');
+ vi.stubEnv('BOCHA_BASE_URL', 'http://internal-proxy.local/bocha');
+
+ const res = await postWebSearch({
+ query: 'test query',
+ providerId: 'bocha',
+ });
+
+ expect(res.status).toBe(200);
+ expect(mocks.searchWeb).toHaveBeenCalledWith(
+ expect.objectContaining({
+ providerId: 'bocha',
+ apiKey: 'bocha-server-key',
+ baseUrl: 'http://internal-proxy.local/bocha',
+ }),
+ );
+ });
+});