Files
openmaic/OpenMAIC/app/api/web-search/route.ts
2026-08-16 14:58:47 +08:00

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';
}
}