206 lines
7.2 KiB
TypeScript
206 lines
7.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,
|
|
resolveServerWebSearchProviderId,
|
|
resolveWebSearchApiKey,
|
|
resolveWebSearchModel,
|
|
} 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 '@openmaic/generation';
|
|
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';
|
|
import { providerAccessApiError } from '@/lib/server/provider-access-response';
|
|
|
|
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,
|
|
claudeModelId,
|
|
} = body as {
|
|
query?: string;
|
|
pdfText?: string;
|
|
providerId?: WebSearchProviderId;
|
|
apiKey?: string;
|
|
baseUrl?: string;
|
|
baiduSubSources?: BaiduSubSources;
|
|
claudeModelId?: string;
|
|
};
|
|
query = requestQuery;
|
|
|
|
if (!query || !query.trim()) {
|
|
return apiError('MISSING_REQUIRED_FIELD', 400, 'query is required');
|
|
}
|
|
|
|
const serverProviderId = resolveServerWebSearchProviderId() as WebSearchProviderId | undefined;
|
|
let providerId: WebSearchProviderId =
|
|
requestProviderId && WEB_SEARCH_PROVIDERS[requestProviderId]
|
|
? requestProviderId
|
|
: (serverProviderId ?? 'tavily');
|
|
|
|
// Prefer the operator's server-configured backend over stale client defaults
|
|
// (e.g. Tavily without a key, or Brave HTML scrape with empty results).
|
|
if (
|
|
serverProviderId &&
|
|
isServerConfiguredProvider('webSearch', serverProviderId) &&
|
|
providerId !== serverProviderId &&
|
|
!isServerConfiguredProvider('webSearch', providerId)
|
|
) {
|
|
log.info(
|
|
`Using server-configured web search provider "${serverProviderId}" instead of "${providerId}"`,
|
|
);
|
|
providerId = serverProviderId;
|
|
}
|
|
|
|
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;
|
|
// SearXNG base URLs are operator-managed only (SEARXNG_BASE_URL); never trust client input.
|
|
const clientBaseUrl = managed || providerId === 'searxng' ? 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);
|
|
}
|
|
if (provider.requiresBaseUrl && !baseUrl) {
|
|
return apiError(
|
|
'MISSING_REQUIRED_FIELD',
|
|
400,
|
|
getMissingBaseUrlMessage(providerId, provider.name),
|
|
);
|
|
}
|
|
|
|
// 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 } : {}),
|
|
// A server-pinned model (WEB_SEARCH_CLAUDE_MODELS) is authoritative over
|
|
// the client-selected one, matching how managed keys/base URLs behave.
|
|
...(providerId === 'claude'
|
|
? { claudeModelId: resolveWebSearchModel('claude', claudeModelId) }
|
|
: {}),
|
|
});
|
|
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 providerAccessError = providerAccessApiError(err);
|
|
if (providerAccessError) return providerAccessError;
|
|
|
|
const message = err instanceof Error ? err.message : 'Web search failed';
|
|
return apiError('INTERNAL_ERROR', 500, message);
|
|
}
|
|
}
|
|
|
|
function getMissingBaseUrlMessage(providerId: WebSearchProviderId, providerName: string): string {
|
|
if (providerId === 'searxng') {
|
|
return `${providerName} base URL is not configured. Set SEARXNG_BASE_URL on the server.`;
|
|
}
|
|
return `${providerName} base URL is not configured. Set ${getWebSearchEnvKey(providerId)} on the server or configure the base URL in Settings -> Web Search.`;
|
|
}
|
|
|
|
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 'claude':
|
|
return 'WEB_SEARCH_CLAUDE_API_KEY';
|
|
case 'minimax':
|
|
return 'WEB_SEARCH_MINIMAX_API_KEY';
|
|
case 'searxng':
|
|
return 'SEARXNG_BASE_URL';
|
|
case 'tavily':
|
|
default:
|
|
return 'TAVILY_API_KEY';
|
|
}
|
|
}
|