import { getProviderCredentials } from "@/sse/services/auth"; import { recordCost } from "@/domain/costRules"; import * as defaultLog from "@/sse/utils/logger"; import { getAllSearchProviders, getSearchProvider, selectProvider, supportsSearchType, SEARCH_CREDENTIAL_FALLBACKS, SEARCH_PROVIDERS, type SearchProviderConfig, } from "@omniroute/open-sse/config/searchRegistry.ts"; import { handleSearch, type SearchResponse } from "@omniroute/open-sse/handlers/search.ts"; import { computeCacheKey, getOrCoalesce, SEARCH_CACHE_DEFAULT_TTL_MS, } from "@omniroute/open-sse/services/searchCache.ts"; type SearchLogger = typeof defaultLog; export interface ExecuteWebSearchInput { query: string; provider?: string; max_results?: number; limit?: number; search_type?: "web" | "news"; offset?: number; country?: string; language?: string; time_range?: "any" | "day" | "week" | "month" | "year"; content?: { snippet?: boolean; full_page?: boolean; format?: "text" | "markdown"; max_characters?: number; }; filters?: { include_domains?: string[]; exclude_domains?: string[]; safe_search?: "off" | "moderate" | "strict"; }; provider_options?: Record; strict_filters?: boolean; apiKeyId?: string | null; log?: SearchLogger; } export interface ExecuteWebSearchResult { cached: boolean; data: SearchResponse; } export class WebSearchExecutionError extends Error { statusCode: number; constructor(message: string, statusCode: number) { super(message); this.statusCode = statusCode; } } async function resolveSearchCredentials(providerId: string) { const creds = await getProviderCredentials(providerId).catch(() => null); if (creds) return creds; const fallbackId = SEARCH_CREDENTIAL_FALLBACKS[providerId]; if (fallbackId) return getProviderCredentials(fallbackId).catch(() => null); return null; } function buildDomainFilter(filters?: { include_domains?: string[]; exclude_domains?: string[]; }): string[] | undefined { if (!filters) return undefined; const parts: string[] = []; if (filters.include_domains?.length) parts.push(...filters.include_domains); if (filters.exclude_domains?.length) parts.push(...filters.exclude_domains.map((d) => `-${d}`)); return parts.length > 0 ? parts : undefined; } function normalizeMaxResults(input: ExecuteWebSearchInput, providerConfig: SearchProviderConfig) { const fromMaxResults = typeof input.max_results === "number" ? input.max_results : typeof input.max_results === "string" ? Number(input.max_results) : Number.NaN; const fromLimit = typeof input.limit === "number" ? input.limit : typeof input.limit === "string" ? Number(input.limit) : Number.NaN; const requested = Number.isFinite(fromMaxResults) ? fromMaxResults : Number.isFinite(fromLimit) ? fromLimit : providerConfig.defaultMaxResults; return Math.min(Math.max(1, requested), providerConfig.maxMaxResults); } function assertValidSearchInput(input: ExecuteWebSearchInput) { if (typeof input.query !== "string" || input.query.trim().length === 0) { throw new WebSearchExecutionError("Missing required field: query", 400); } if (input.query.trim().length > 500) { throw new WebSearchExecutionError("Query must be 500 characters or fewer", 400); } if (input.search_type && input.search_type !== "web" && input.search_type !== "news") { throw new WebSearchExecutionError(`Unsupported search_type: ${String(input.search_type)}`, 400); } } export async function executeWebSearch( input: ExecuteWebSearchInput ): Promise { assertValidSearchInput(input); const log = input.log || defaultLog; const searchType = input.search_type || "web"; if (input.provider) { const explicitProvider = getSearchProvider(input.provider); if (!explicitProvider) { throw new WebSearchExecutionError(`Unknown search provider: ${input.provider}`, 400); } if (!supportsSearchType(explicitProvider, searchType)) { throw new WebSearchExecutionError( `Search provider ${input.provider} does not support search_type: ${searchType}`, 400 ); } } let providerConfig = selectProvider(input.provider, searchType); if (!providerConfig) { throw new WebSearchExecutionError( input.provider ? `Unknown search provider: ${input.provider}` : `No search providers available. Add an API key for a search provider (${getAllSearchProviders() .map((provider) => provider.id) .join(", ")}) in the dashboard.`, 400 ); } let credentials: Record | null = null; let alternateProviderId: string | undefined; let alternateCredentials: Record | null = null; if (input.provider) { credentials = await resolveSearchCredentials(providerConfig.id); if ( !credentials && providerConfig.authType === "none" && typeof input.provider_options?.baseUrl === "string" && input.provider_options.baseUrl.trim().length > 0 ) { credentials = { providerSpecificData: { baseUrl: input.provider_options.baseUrl.trim() }, }; } if (!credentials) { throw new WebSearchExecutionError( providerConfig.authType === "none" ? `Search provider ${providerConfig.id} is not configured. Set its base URL in the dashboard or pass provider_options.baseUrl.` : `No credentials configured for search provider: ${providerConfig.id}. Add an API key for "${providerConfig.id}" in the dashboard.`, 400 ); } } else { credentials = await resolveSearchCredentials(providerConfig.id); if (!credentials) { const sortedIds = Object.values(SEARCH_PROVIDERS) .filter((provider) => supportsSearchType(provider, searchType)) .sort((a, b) => a.costPerQuery - b.costPerQuery) .map((provider) => provider.id); for (const providerId of sortedIds) { if (providerId === providerConfig.id) continue; const altConfig = getSearchProvider(providerId); const altCreds = await resolveSearchCredentials(providerId); if (altConfig && altCreds) { providerConfig = altConfig; credentials = altCreds; break; } } } if (!credentials) { throw new WebSearchExecutionError( `No credentials configured for any search provider. Add an API key for a search provider (${Object.keys( SEARCH_PROVIDERS ).join(", ")}) in the dashboard.`, 400 ); } const otherIds = Object.values(SEARCH_PROVIDERS) .filter((provider) => supportsSearchType(provider, searchType)) .sort((a, b) => a.costPerQuery - b.costPerQuery) .map((provider) => provider.id) .filter((providerId) => providerId !== providerConfig.id); for (const providerId of otherIds) { const creds = await resolveSearchCredentials(providerId); if (creds) { alternateProviderId = providerId; alternateCredentials = creds; break; } } } const clampedMaxResults = normalizeMaxResults(input, providerConfig); const cacheKey = computeCacheKey( input.query.trim(), providerConfig.id, searchType, clampedMaxResults, input.country, input.language, { filters: input.filters, offset: input.offset, time_range: input.time_range, } ); const ttl = providerConfig.cacheTTLMs ?? SEARCH_CACHE_DEFAULT_TTL_MS; const { data, cached } = await getOrCoalesce(cacheKey, ttl, async () => { const result = await handleSearch({ query: input.query.trim(), provider: providerConfig.id, maxResults: clampedMaxResults, searchType, country: input.country, language: input.language, timeRange: input.time_range, offset: input.offset, domainFilter: buildDomainFilter(input.filters), contentOptions: input.content, strictFilters: input.strict_filters, providerOptions: input.provider_options, credentials, alternateProvider: alternateProviderId, alternateCredentials, log, }); if (!result.success || !result.data) { throw new WebSearchExecutionError(result.error || "Search failed", result.status || 502); } return result.data; }); if (!cached && input.apiKeyId && input.apiKeyId !== "local" && data.usage?.search_cost_usd > 0) { try { recordCost(input.apiKeyId, data.usage.search_cost_usd); } catch (error: any) { log.warn("SEARCH", `Cost recording failed: ${error?.message || String(error)}`); } } return { data, cached }; }