153 lines
5.2 KiB
TypeScript
153 lines
5.2 KiB
TypeScript
/**
|
|
* Web Search API
|
|
*
|
|
* POST /api/web-search
|
|
* Simple JSON request/response using the configured web search provider.
|
|
*/
|
|
|
|
import { NextRequest } from 'next/server';
|
|
import { callLLM } from '@/lib/ai/llm';
|
|
import { formatSearchResultsAsContext, searchWeb } from '@/lib/web-search';
|
|
import { isServerConfiguredProvider, resolveWebSearchApiKey } from '@/lib/server/provider-config';
|
|
import { createLogger } from '@/lib/logger';
|
|
import { apiError, apiSuccess } from '@/lib/server/api-response';
|
|
import {
|
|
buildSearchQuery,
|
|
SEARCH_QUERY_REWRITE_EXCERPT_LENGTH,
|
|
} from '@/lib/server/search-query-builder';
|
|
import { resolveModelFromRequest } from '@/lib/server/resolve-model';
|
|
import type { AICallFn } from '@/lib/generation/pipeline-types';
|
|
import { WEB_SEARCH_PROVIDERS } from '@/lib/web-search/constants';
|
|
import type { BaiduSubSources, WebSearchProviderId } from '@/lib/web-search/types';
|
|
import { resolveWebSearchRouteBaseUrl } from '@/lib/server/web-search-config';
|
|
|
|
const log = createLogger('WebSearch');
|
|
|
|
export async function POST(req: NextRequest) {
|
|
let query: string | undefined;
|
|
try {
|
|
const body = await req.json();
|
|
const {
|
|
query: requestQuery,
|
|
pdfText,
|
|
providerId: requestProviderId,
|
|
apiKey: bodyApiKey,
|
|
baseUrl: bodyBaseUrl,
|
|
baiduSubSources,
|
|
} = body as {
|
|
query?: string;
|
|
pdfText?: string;
|
|
providerId?: WebSearchProviderId;
|
|
apiKey?: string;
|
|
baseUrl?: string;
|
|
baiduSubSources?: BaiduSubSources;
|
|
};
|
|
query = requestQuery;
|
|
|
|
if (!query || !query.trim()) {
|
|
return apiError('MISSING_REQUIRED_FIELD', 400, 'query is required');
|
|
}
|
|
|
|
const providerId: WebSearchProviderId =
|
|
requestProviderId && WEB_SEARCH_PROVIDERS[requestProviderId] ? requestProviderId : 'tavily';
|
|
const provider = WEB_SEARCH_PROVIDERS[providerId];
|
|
// Managed providers are admin-owned: ignore (don't reject) any client-sent
|
|
// key/baseUrl. The server config is authoritative, so a stale client base
|
|
// URL is dropped rather than failing the request.
|
|
const managed = isServerConfiguredProvider('webSearch', providerId);
|
|
const clientApiKey = managed ? undefined : bodyApiKey;
|
|
const clientBaseUrl = managed ? undefined : bodyBaseUrl;
|
|
const apiKey = resolveWebSearchApiKey(providerId, clientApiKey);
|
|
if (provider.requiresApiKey && !apiKey) {
|
|
return apiError(
|
|
'MISSING_API_KEY',
|
|
400,
|
|
`${provider.name} API key is not configured. Set it in Settings -> Web Search or configure ${getWebSearchEnvKey(providerId)} on the server.`,
|
|
);
|
|
}
|
|
let baseUrl: string | undefined;
|
|
try {
|
|
baseUrl = resolveWebSearchRouteBaseUrl(providerId, clientBaseUrl);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Invalid web search base URL';
|
|
return apiError('INVALID_REQUEST', 400, message);
|
|
}
|
|
|
|
// Clamp rewrite input at the route boundary; framework body limits still apply to total request size.
|
|
const boundedPdfText = pdfText?.slice(0, SEARCH_QUERY_REWRITE_EXCERPT_LENGTH);
|
|
|
|
let aiCall: AICallFn | undefined;
|
|
try {
|
|
const { model: languageModel, thinkingConfig } = await resolveModelFromRequest(
|
|
req,
|
|
body,
|
|
'web-search-query-rewrite',
|
|
);
|
|
aiCall = async (systemPrompt, userPrompt) => {
|
|
const result = await callLLM(
|
|
{
|
|
model: languageModel,
|
|
messages: [
|
|
{ role: 'system', content: systemPrompt },
|
|
{ role: 'user', content: userPrompt },
|
|
],
|
|
maxOutputTokens: 256,
|
|
},
|
|
'web-search-query-rewrite',
|
|
undefined,
|
|
thinkingConfig,
|
|
);
|
|
return result.text;
|
|
};
|
|
} catch (error) {
|
|
log.warn('Search query rewrite model unavailable, falling back to raw requirement:', error);
|
|
}
|
|
|
|
const searchQuery = await buildSearchQuery(query, boundedPdfText, aiCall);
|
|
|
|
log.info('Running web search API request', {
|
|
hasPdfContext: searchQuery.hasPdfContext,
|
|
rawRequirementLength: searchQuery.rawRequirementLength,
|
|
rewriteAttempted: searchQuery.rewriteAttempted,
|
|
finalQueryLength: searchQuery.finalQueryLength,
|
|
});
|
|
|
|
const result = await searchWeb({
|
|
providerId,
|
|
query: searchQuery.query,
|
|
apiKey,
|
|
baseUrl,
|
|
...(providerId === 'baidu' && baiduSubSources ? { baiduSubSources } : {}),
|
|
});
|
|
const context = formatSearchResultsAsContext(result);
|
|
|
|
return apiSuccess({
|
|
answer: result.answer,
|
|
sources: result.sources,
|
|
context,
|
|
query: result.query,
|
|
responseTime: result.responseTime,
|
|
});
|
|
} catch (err) {
|
|
log.error(`Web search failed [query="${query?.substring(0, 60) ?? 'unknown'}"]:`, err);
|
|
const message = err instanceof Error ? err.message : 'Web search failed';
|
|
return apiError('INTERNAL_ERROR', 500, message);
|
|
}
|
|
}
|
|
|
|
function getWebSearchEnvKey(providerId: WebSearchProviderId): string {
|
|
switch (providerId) {
|
|
case 'baidu':
|
|
return 'BAIDU_API_KEY';
|
|
case 'bocha':
|
|
return 'BOCHA_API_KEY';
|
|
case 'brave':
|
|
return 'BRAVE_API_KEY';
|
|
case 'minimax':
|
|
return 'WEB_SEARCH_MINIMAX_API_KEY';
|
|
case 'tavily':
|
|
default:
|
|
return 'TAVILY_API_KEY';
|
|
}
|
|
}
|