diff --git a/open-sse/config/searchRegistry.ts b/open-sse/config/searchRegistry.ts new file mode 100644 index 0000000000..4c152571e8 --- /dev/null +++ b/open-sse/config/searchRegistry.ts @@ -0,0 +1,155 @@ +/** + * Search Provider Registry + * + * Defines providers that support the /v1/search endpoint. + * Unlike LLM/embedding providers, search providers don't have "models" — + * a provider IS the model (Serper = Google SERP, Brave = Brave index). + * + * API keys are stored in the same provider credentials system, + * keyed by provider ID (e.g. "serper-search", "brave-search"). + * perplexity-search reuses credentials from the "perplexity" chat provider. + */ + +export interface SearchProviderConfig { + id: string; + name: string; + baseUrl: string; + method: "GET" | "POST"; + authType: "apikey"; + authHeader: string; + costPerQuery: number; + freeMonthlyQuota: number; + searchTypes: string[]; + defaultMaxResults: number; + maxMaxResults: number; + timeoutMs: number; + cacheTTLMs: number; +} + +export const SEARCH_PROVIDERS: Record = { + "serper-search": { + id: "serper-search", + name: "Serper Search", + baseUrl: "https://google.serper.dev", + method: "POST", + authType: "apikey", + authHeader: "x-api-key", + costPerQuery: 0.001, + freeMonthlyQuota: 2500, + searchTypes: ["web", "news"], + defaultMaxResults: 5, + maxMaxResults: 100, + timeoutMs: 10_000, + cacheTTLMs: 5 * 60 * 1000, + }, + + "brave-search": { + id: "brave-search", + name: "Brave Search", + baseUrl: "https://api.search.brave.com/res/v1", + method: "GET", + authType: "apikey", + authHeader: "x-subscription-token", + costPerQuery: 0.005, + freeMonthlyQuota: 1000, + searchTypes: ["web", "news"], + defaultMaxResults: 5, + maxMaxResults: 20, + timeoutMs: 10_000, + cacheTTLMs: 5 * 60 * 1000, + }, + + "perplexity-search": { + id: "perplexity-search", + name: "Perplexity Search", + baseUrl: "https://api.perplexity.ai/search", + method: "POST", + authType: "apikey", + authHeader: "bearer", + costPerQuery: 0.005, + freeMonthlyQuota: 0, + searchTypes: ["web"], + defaultMaxResults: 5, + maxMaxResults: 20, + timeoutMs: 10_000, + cacheTTLMs: 5 * 60 * 1000, + }, + + "exa-search": { + id: "exa-search", + name: "Exa Search", + baseUrl: "https://api.exa.ai/search", + method: "POST", + authType: "apikey", + authHeader: "x-api-key", + costPerQuery: 0.007, + freeMonthlyQuota: 1000, + searchTypes: ["web", "news"], + defaultMaxResults: 5, + maxMaxResults: 100, + timeoutMs: 10_000, + cacheTTLMs: 5 * 60 * 1000, + }, + + "tavily-search": { + id: "tavily-search", + name: "Tavily Search", + baseUrl: "https://api.tavily.com/search", + method: "POST", + authType: "apikey", + authHeader: "bearer", + costPerQuery: 0.008, + freeMonthlyQuota: 1000, + searchTypes: ["web", "news"], + defaultMaxResults: 5, + maxMaxResults: 20, + timeoutMs: 10_000, + cacheTTLMs: 5 * 60 * 1000, + }, +}; + +/** + * Credential fallback mapping — search providers that can reuse credentials + * from a related provider (e.g., perplexity-search uses the same API key as perplexity chat). + */ +export const SEARCH_CREDENTIAL_FALLBACKS: Record = { + "perplexity-search": "perplexity", +}; + +/** + * Get search provider config by ID + */ +export function getSearchProvider(providerId: string): SearchProviderConfig | null { + return SEARCH_PROVIDERS[providerId] || null; +} + +/** + * Get all search providers as a flat list + */ +export function getAllSearchProviders(): Array<{ + id: string; + name: string; + searchTypes: string[]; +}> { + return Object.values(SEARCH_PROVIDERS).map((p) => ({ + id: p.id, + name: p.name, + searchTypes: p.searchTypes, + })); +} + +/** + * Select the cheapest available provider. + * If an explicit provider is given, validate and return it. + * Otherwise, return the cheapest by costPerQuery. + */ +export function selectProvider(explicitProvider?: string): SearchProviderConfig | null { + if (explicitProvider) { + return SEARCH_PROVIDERS[explicitProvider] || null; + } + + const providers = Object.values(SEARCH_PROVIDERS); + if (providers.length === 0) return null; + + return providers.reduce((cheapest, p) => (p.costPerQuery < cheapest.costPerQuery ? p : cheapest)); +} diff --git a/open-sse/handlers/search.ts b/open-sse/handlers/search.ts new file mode 100644 index 0000000000..ab9a8b2668 --- /dev/null +++ b/open-sse/handlers/search.ts @@ -0,0 +1,664 @@ +/** + * Search Handler + * + * Handles POST /v1/search requests. + * Routes to 5 search providers with automatic failover: + * serper-search, brave-search, perplexity-search, exa-search, tavily-search + * + * Request format: + * { + * "query": "search query", + * "provider": "serper-search" | "brave-search" | ... // optional, auto-selects cheapest + * "max_results": 5, + * "search_type": "web" | "news" + * } + */ + +import { getSearchProvider, type SearchProviderConfig } from "../config/searchRegistry.ts"; +import { saveCallLog } from "@/lib/usageDb"; + +// ── Types ──────────────────────────────────────────────────────────────── + +export interface SearchResult { + title: string; + url: string; + display_url?: string; + snippet: string; + position: number; + score: number | null; + published_at: string | null; + favicon_url: string | null; + content: { format: string; text: string; length: number } | null; + metadata: { + author: string | null; + language: string | null; + source_type: string | null; + image_url: string | null; + } | null; + citation: { + provider: string; + retrieved_at: string; + rank: number; + }; + provider_raw: Record | null; +} + +export interface SearchResponse { + provider: string; + query: string; + results: SearchResult[]; + answer: { source: string; text: string | null; model: string | null } | null; + usage: { queries_used: number; search_cost_usd: number; llm_tokens?: number }; + metrics: { + response_time_ms: number; + upstream_latency_ms: number; + gateway_latency_ms?: number; + total_results_available: number | null; + }; + errors: Array<{ provider: string; code: string; message: string }>; +} + +interface SearchHandlerResult { + success: boolean; + status?: number; + error?: string; + data?: SearchResponse; +} + +interface SearchHandlerOptions { + query: string; + provider: string; + maxResults: number; + searchType: string; + country?: string; + language?: string; + timeRange?: string; + offset?: number; + domainFilter?: string[]; + contentOptions?: { snippet?: boolean; full_page?: boolean; format?: string; max_characters?: number }; + strictFilters?: boolean; + providerOptions?: Record; + credentials: Record; + alternateProvider?: string; + alternateCredentials?: Record | null; + log?: any; +} + +// ── Constants ──────────────────────────────────────────────────────────── + +const GLOBAL_TIMEOUT_MS = 15_000; + +// Non-retriable HTTP status codes — fail immediately, don't try alternate +const NON_RETRIABLE = new Set([400, 401, 403, 404]); + +// ── Input Sanitization ────────────────────────────────────────────────── + +// Control characters that should never appear in search queries +const CONTROL_CHAR_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/; + +function sanitizeQuery(query: string): { clean: string; error?: string } { + if (CONTROL_CHAR_RE.test(query)) { + return { clean: "", error: "Query contains invalid control characters" }; + } + const clean = query.normalize("NFKC").trim().replace(/\s+/g, " "); + if (clean.length === 0) { + return { clean: "", error: "Query is empty after normalization" }; + } + return { clean }; +} + +// ── Response Normalizers ──────────────────────────────────────────────── + +function makeResult( + providerId: string, + item: { + title?: string; + url?: string; + snippet?: string; + score?: number; + published_at?: string; + favicon_url?: string; + author?: string; + source_type?: string; + image_url?: string; + full_text?: string; + text_format?: string; + }, + idx: number, + now: string +): SearchResult { + const url = item.url || ""; + return { + title: item.title || "", + url, + display_url: url ? url.replace(/^https?:\/\/(www\.)?/, "").split("?")[0] : undefined, + snippet: item.snippet || "", + position: idx + 1, + score: typeof item.score === "number" ? Math.min(1, Math.max(0, item.score)) : null, + published_at: item.published_at || null, + favicon_url: item.favicon_url || null, + content: item.full_text + ? { format: item.text_format || "text", text: item.full_text, length: item.full_text.length } + : null, + metadata: { + author: item.author || null, + language: null, + source_type: item.source_type || null, + image_url: item.image_url || null, + }, + citation: { provider: providerId, retrieved_at: now, rank: idx + 1 }, + provider_raw: null, + }; +} + +function normalizeSerperResponse( + data: any, + _query: string, + searchType: string +): { results: SearchResult[]; totalResults: number | null } { + const now = new Date().toISOString(); + const items = searchType === "news" ? data.news : data.organic; + if (!Array.isArray(items)) return { results: [], totalResults: null }; + + const results = items.map((item: any, idx: number) => + makeResult( + "serper-search", + { + title: item.title, + url: item.link, + snippet: item.snippet || item.description, + published_at: item.date, + }, + idx, + now + ) + ); + + return { + results, + totalResults: + typeof data.searchParameters?.totalResults === "number" + ? data.searchParameters.totalResults + : null, + }; +} + +function normalizeBraveResponse( + data: any, + _query: string, + searchType: string +): { results: SearchResult[]; totalResults: number | null } { + const now = new Date().toISOString(); + const container = searchType === "news" ? data.news : data.web; + const items = container?.results; + if (!Array.isArray(items)) return { results: [], totalResults: null }; + + const results = items.map((item: any, idx: number) => + makeResult( + "brave-search", + { + title: item.title, + url: item.url, + snippet: item.description, + published_at: item.page_age || item.age, + favicon_url: item.meta_url?.favicon || item.favicon, + }, + idx, + now + ) + ); + + return { results, totalResults: container?.totalCount ?? null }; +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +function parseDomainFilter(domainFilter?: string[]): { + includes: string[]; + excludes: string[]; +} { + if (!domainFilter?.length) return { includes: [], excludes: [] }; + const includes = domainFilter.filter((d) => !d.startsWith("-")); + const excludes = domainFilter.filter((d) => d.startsWith("-")).map((d) => d.slice(1)); + return { includes, excludes }; +} + +// ── Provider Request Builders ─────────────────────────────────────────── + +interface SearchRequestParams { + query: string; + searchType: string; + maxResults: number; + token: string; + country?: string; + language?: string; + domainFilter?: string[]; +} + +function buildSerperRequest( + config: SearchProviderConfig, + params: SearchRequestParams +): { url: string; init: RequestInit } { + const endpoint = params.searchType === "news" ? "/news" : "/search"; + const body: Record = { q: params.query, num: params.maxResults }; + if (params.country) body.gl = params.country.toLowerCase(); + if (params.language) body.hl = params.language; + return { + url: `${config.baseUrl}${endpoint}`, + init: { + method: "POST", + headers: { "Content-Type": "application/json", "X-API-Key": params.token }, + body: JSON.stringify(body), + }, + }; +} + +function buildBraveRequest( + config: SearchProviderConfig, + params: SearchRequestParams +): { url: string; init: RequestInit } { + const endpoint = params.searchType === "news" ? "/news/search" : "/web/search"; + const qp = new URLSearchParams({ q: params.query, count: String(params.maxResults) }); + if (params.country) qp.set("country", params.country); + if (params.language) qp.set("search_lang", params.language); + return { + url: `${config.baseUrl}${endpoint}?${qp}`, + init: { + method: "GET", + headers: { Accept: "application/json", "X-Subscription-Token": params.token }, + }, + }; +} + +function buildPerplexityRequest( + config: SearchProviderConfig, + params: SearchRequestParams +): { url: string; init: RequestInit } { + const body: Record = { query: params.query, max_results: params.maxResults }; + if (params.country) body.country = params.country; + if (params.language) body.search_language_filter = [params.language]; + if (params.domainFilter?.length) body.search_domain_filter = params.domainFilter; + return { + url: config.baseUrl, + init: { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${params.token}` }, + body: JSON.stringify(body), + }, + }; +} + +function buildExaRequest( + config: SearchProviderConfig, + params: SearchRequestParams +): { url: string; init: RequestInit } { + const { includes, excludes } = parseDomainFilter(params.domainFilter); + const body: Record = { + query: params.query, + numResults: params.maxResults, + type: "auto", + text: true, + highlights: true, + }; + if (includes.length) body.includeDomains = includes; + if (excludes.length) body.excludeDomains = excludes; + if (params.searchType === "news") body.category = "news"; + return { + url: config.baseUrl, + init: { + method: "POST", + headers: { "Content-Type": "application/json", "x-api-key": params.token }, + body: JSON.stringify(body), + }, + }; +} + +function buildTavilyRequest( + config: SearchProviderConfig, + params: SearchRequestParams +): { url: string; init: RequestInit } { + const { includes, excludes } = parseDomainFilter(params.domainFilter); + const body: Record = { + query: params.query, + max_results: params.maxResults, + topic: params.searchType === "news" ? "news" : "general", + }; + if (includes.length) body.include_domains = includes; + if (excludes.length) body.exclude_domains = excludes; + if (params.country) body.country = params.country; + return { + url: config.baseUrl, + init: { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${params.token}` }, + body: JSON.stringify(body), + }, + }; +} + +function buildRequest( + config: SearchProviderConfig, + params: SearchRequestParams +): { url: string; init: RequestInit } { + if (config.id === "serper-search") return buildSerperRequest(config, params); + if (config.id === "brave-search") return buildBraveRequest(config, params); + if (config.id === "perplexity-search") return buildPerplexityRequest(config, params); + if (config.id === "exa-search") return buildExaRequest(config, params); + if (config.id === "tavily-search") return buildTavilyRequest(config, params); + // Fallback for future providers: POST with bearer auth + return { + url: config.baseUrl, + init: { + method: config.method, + headers: { "Content-Type": "application/json", Authorization: `Bearer ${params.token}` }, + body: JSON.stringify({ + query: params.query, + max_results: params.maxResults, + search_type: params.searchType, + }), + }, + }; +} + +function normalizePerplexityResponse( + data: any, + _query: string, + _searchType: string +): { results: SearchResult[]; totalResults: number | null } { + const now = new Date().toISOString(); + const items = data.results; + if (!Array.isArray(items)) return { results: [], totalResults: null }; + + const results = items.map((item: any, idx: number) => + makeResult( + "perplexity-search", + { + title: item.title, + url: item.url, + snippet: item.snippet, + published_at: item.date || item.last_updated, + }, + idx, + now + ) + ); + return { results, totalResults: results.length }; +} + +function normalizeExaResponse( + data: any, + _query: string, + _searchType: string +): { results: SearchResult[]; totalResults: number | null } { + const now = new Date().toISOString(); + const items = data.results; + if (!Array.isArray(items)) return { results: [], totalResults: null }; + + const results = items.map((item: any, idx: number) => + makeResult( + "exa-search", + { + title: item.title, + url: item.url, + snippet: item.highlights?.[0] || item.text?.slice(0, 300) || "", + score: item.score, + published_at: item.publishedDate, + favicon_url: item.favicon, + author: item.author, + image_url: item.image, + full_text: item.text, + text_format: "text", + }, + idx, + now + ) + ); + return { results, totalResults: results.length }; +} + +function normalizeTavilyResponse( + data: any, + _query: string, + _searchType: string +): { results: SearchResult[]; totalResults: number | null } { + const now = new Date().toISOString(); + const items = data.results; + if (!Array.isArray(items)) return { results: [], totalResults: null }; + + const results = items.map((item: any, idx: number) => + makeResult( + "tavily-search", + { + title: item.title, + url: item.url, + snippet: item.content || "", + score: item.score, + published_at: item.published_date, + full_text: item.raw_content, + text_format: "text", + }, + idx, + now + ) + ); + return { results, totalResults: results.length }; +} + +function normalizeResponse( + providerId: string, + data: any, + query: string, + searchType: string +): { results: SearchResult[]; totalResults: number | null } { + if (providerId === "serper-search") return normalizeSerperResponse(data, query, searchType); + if (providerId === "brave-search") return normalizeBraveResponse(data, query, searchType); + if (providerId === "perplexity-search") + return normalizePerplexityResponse(data, query, searchType); + if (providerId === "exa-search") return normalizeExaResponse(data, query, searchType); + if (providerId === "tavily-search") return normalizeTavilyResponse(data, query, searchType); + return { results: [], totalResults: null }; +} + +// ── Main Handler ──────────────────────────────────────────────────────── + +export async function handleSearch(options: SearchHandlerOptions): Promise { + const { + query, + provider: providerId, + maxResults, + searchType, + country, + language, + domainFilter, + credentials, + alternateProvider, + alternateCredentials, + log, + } = options; + const startTime = Date.now(); + + // 1. Sanitize input + const { clean: cleanQuery, error: sanitizeError } = sanitizeQuery(query); + if (sanitizeError) { + return { success: false, status: 400, error: sanitizeError }; + } + + // 2. Use resolved provider from route (no re-resolution) + const primaryConfig = getSearchProvider(providerId); + if (!primaryConfig) { + return { + success: false, + status: 400, + error: `Unknown search provider: ${providerId}`, + }; + } + + // 3. Get alternate config for failover (pre-resolved by route) + const alternateConfig = alternateProvider ? getSearchProvider(alternateProvider) : null; + + const requestParams = { + query: cleanQuery, + searchType, + maxResults, + country, + language, + domainFilter, + }; + + // 4. Try primary provider + const result = await tryProvider(primaryConfig, requestParams, credentials, startTime, log); + + if (result.success) return result; + + // 5. Failover to alternate (only for retriable errors and auto-select mode) + if ( + alternateConfig && + alternateCredentials && + !NON_RETRIABLE.has(result.status || 0) && + Date.now() - startTime < GLOBAL_TIMEOUT_MS + ) { + if (log) { + log.warn( + "SEARCH", + `${primaryConfig.id} failed (${result.status}), trying ${alternateConfig.id}` + ); + } + + const fallbackResult = await tryProvider( + alternateConfig, + requestParams, + alternateCredentials, + startTime, + log + ); + + if (fallbackResult.success) return fallbackResult; + } + + return result; +} + +async function tryProvider( + config: SearchProviderConfig, + params: Omit, + credentials: Record, + globalStartTime: number, + log?: any +): Promise { + const startTime = Date.now(); + const token = credentials.apiKey || credentials.accessToken; + + if (!token) { + return { + success: false, + status: 401, + error: `No credentials for search provider: ${config.id}`, + }; + } + + const { query, searchType, maxResults } = params; + const { url, init } = buildRequest(config, { ...params, token }); + + // Timeout: min of provider timeout and remaining global timeout + const remainingGlobal = GLOBAL_TIMEOUT_MS - (Date.now() - globalStartTime); + const timeout = Math.min(config.timeoutMs, Math.max(remainingGlobal, 1000)); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + + if (log) { + log.info("SEARCH", `${config.id} | query: "${query.slice(0, 80)}" | type: ${searchType}`); + } + + try { + const response = await fetch(url, { ...init, signal: controller.signal }); + clearTimeout(timer); + + if (!response.ok) { + const errorText = await response.text(); + if (log) { + log.error("SEARCH", `${config.id} error ${response.status}: ${errorText.slice(0, 200)}`); + } + + saveCallLog({ + method: config.method, + path: "/v1/search", + status: response.status, + model: config.id, + provider: config.id, + duration: Date.now() - startTime, + requestType: "search", + error: errorText.slice(0, 500), + requestBody: { + query: query.slice(0, 200), + search_type: searchType, + max_results: maxResults, + }, + }).catch(() => { /* non-critical — logging must not block search response */ }); + + return { + success: false, + status: response.status, + error: `Search provider ${config.id} returned ${response.status}`, + }; + } + + const data = await response.json(); + const { results, totalResults } = normalizeResponse(config.id, data, query, searchType); + const duration = Date.now() - startTime; + + saveCallLog({ + method: config.method, + path: "/v1/search", + status: 200, + model: config.id, + provider: config.id, + duration, + requestType: "search", + tokens: { prompt_tokens: 0, completion_tokens: 0 }, + requestBody: { query: query.slice(0, 200), search_type: searchType, max_results: maxResults }, + responseBody: { results_count: results.length, cached: false }, + }).catch(() => { /* non-critical — logging must not block search response */ }); + + return { + success: true, + data: { + provider: config.id, + query, + results, + answer: null, + usage: { queries_used: 1, search_cost_usd: config.costPerQuery }, + metrics: { + response_time_ms: duration, + upstream_latency_ms: duration, + total_results_available: totalResults, + }, + errors: [], + }, + }; + } catch (err: any) { + clearTimeout(timer); + + const isTimeout = err.name === "AbortError"; + if (log) { + log.error("SEARCH", `${config.id} ${isTimeout ? "timeout" : "fetch error"}: ${err.message}`); + } + + saveCallLog({ + method: config.method, + path: "/v1/search", + status: isTimeout ? 504 : 502, + model: config.id, + provider: config.id, + duration: Date.now() - startTime, + requestType: "search", + error: err.message, + requestBody: { query: query.slice(0, 200), search_type: searchType, max_results: maxResults }, + }).catch(() => { /* non-critical — logging must not block search response */ }); + + return { + success: false, + status: isTimeout ? 504 : 502, + error: `Search provider ${isTimeout ? "timeout" : "error"}: ${err.message}`, + }; + } +} diff --git a/open-sse/services/searchCache.ts b/open-sse/services/searchCache.ts new file mode 100644 index 0000000000..22d304be2f --- /dev/null +++ b/open-sse/services/searchCache.ts @@ -0,0 +1,142 @@ +/** + * Search Cache — in-memory TTL cache with request coalescing + * + * Bounded at MAX_CACHE_ENTRIES to prevent OOM. + * Request coalescing deduplicates concurrent identical queries + * to prevent cache stampede (critical for agentic tools). + */ + +import { createHash } from "crypto"; + +const MAX_CACHE_ENTRIES = 5000; +const DEFAULT_TTL_MS = parseInt(process.env.SEARCH_CACHE_TTL_MS || String(5 * 60 * 1000), 10); + +interface CacheEntry { + data: T; + expiresAt: number; +} + +const cache = new Map>(); +const inflight = new Map>(); + +let hits = 0; +let misses = 0; + +/** + * Normalize a query for cache key computation. + * NFKC normalization, lowercase, trim, collapse whitespace. + */ +function normalizeQuery(query: string): string { + return query.normalize("NFKC").toLowerCase().trim().replace(/\s+/g, " "); +} + +/** + * Compute a deterministic cache key from search parameters. + */ +export function computeCacheKey( + query: string, + provider: string, + searchType: string, + maxResults: number, + country?: string, + language?: string, + filters?: unknown +): string { + const normalized = normalizeQuery(query); + const payload = JSON.stringify({ + q: normalized, + p: provider, + t: searchType, + n: maxResults, + c: country || null, + l: language || null, + f: filters || null, + }); + return createHash("sha256").update(payload).digest("hex"); +} + +/** + * Evict expired entries and enforce size bound. + * Called lazily on writes. O(n) worst case but amortized O(1). + */ +function evictIfNeeded(): void { + const now = Date.now(); + + // Remove expired entries first + for (const [key, entry] of cache) { + if (entry.expiresAt <= now) { + cache.delete(key); + } + } + + // FIFO eviction if still over limit + while (cache.size >= MAX_CACHE_ENTRIES) { + const firstKey = cache.keys().next().value; + if (firstKey !== undefined) { + cache.delete(firstKey); + } else { + break; + } + } +} + +/** + * Get or coalesce: return cached data, join an inflight request, + * or execute the fetch function and cache the result. + * + * @param key - Cache key from computeCacheKey() + * @param ttlMs - TTL in milliseconds (0 to bypass cache) + * @param fetchFn - Function to execute on cache miss + * @returns The cached or freshly fetched data + */ +export async function getOrCoalesce( + key: string, + ttlMs: number, + fetchFn: () => Promise +): Promise<{ data: T; cached: boolean }> { + // 1. Check cache + const cached = cache.get(key) as CacheEntry | undefined; + if (cached && cached.expiresAt > Date.now()) { + hits++; + return { data: cached.data, cached: true }; + } + + // 2. Join inflight request if one exists (request coalescing) + const existing = inflight.get(key) as Promise | undefined; + if (existing) { + hits++; + const data = await existing; + return { data, cached: true }; + } + + // 3. Cache miss — execute fetch + misses++; + const promise = fetchFn(); + inflight.set(key, promise); + + try { + const data = await promise; + + // Store in cache + if (ttlMs > 0) { + evictIfNeeded(); + cache.set(key, { data, expiresAt: Date.now() + ttlMs }); + } + + return { data, cached: false }; + } finally { + inflight.delete(key); + } +} + +/** + * Get cache statistics for monitoring. + */ +export function getCacheStats(): { size: number; hits: number; misses: number } { + return { size: cache.size, hits, misses }; +} + +/** + * Default TTL for search cache entries. + */ +export const SEARCH_CACHE_DEFAULT_TTL_MS = DEFAULT_TTL_MS; diff --git a/public/providers/brave-search.png b/public/providers/brave-search.png new file mode 100644 index 0000000000..1edb009c53 Binary files /dev/null and b/public/providers/brave-search.png differ diff --git a/public/providers/brave.png b/public/providers/brave.png new file mode 100644 index 0000000000..1edb009c53 Binary files /dev/null and b/public/providers/brave.png differ diff --git a/public/providers/brave.svg b/public/providers/brave.svg new file mode 100644 index 0000000000..062dc8940f --- /dev/null +++ b/public/providers/brave.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/exa-search.png b/public/providers/exa-search.png new file mode 100644 index 0000000000..9300e7997f Binary files /dev/null and b/public/providers/exa-search.png differ diff --git a/public/providers/exa-search.svg b/public/providers/exa-search.svg new file mode 100644 index 0000000000..6c5813283a --- /dev/null +++ b/public/providers/exa-search.svg @@ -0,0 +1,4 @@ + + + exa + diff --git a/public/providers/exa.svg b/public/providers/exa.svg new file mode 100644 index 0000000000..6c5813283a --- /dev/null +++ b/public/providers/exa.svg @@ -0,0 +1,4 @@ + + + exa + diff --git a/public/providers/perplexity-search.png b/public/providers/perplexity-search.png new file mode 100644 index 0000000000..302eae639e Binary files /dev/null and b/public/providers/perplexity-search.png differ diff --git a/public/providers/perplexity.png b/public/providers/perplexity.png index 4e9d56fab2..302eae639e 100644 Binary files a/public/providers/perplexity.png and b/public/providers/perplexity.png differ diff --git a/public/providers/serper-search.png b/public/providers/serper-search.png new file mode 100644 index 0000000000..e8778e796b Binary files /dev/null and b/public/providers/serper-search.png differ diff --git a/public/providers/serper.png b/public/providers/serper.png new file mode 100644 index 0000000000..e8778e796b Binary files /dev/null and b/public/providers/serper.png differ diff --git a/public/providers/tavily-search.png b/public/providers/tavily-search.png new file mode 100644 index 0000000000..153585db01 Binary files /dev/null and b/public/providers/tavily-search.png differ diff --git a/public/providers/tavily.png b/public/providers/tavily.png new file mode 100644 index 0000000000..153585db01 Binary files /dev/null and b/public/providers/tavily.png differ diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx index 03a95600f2..b2c552f531 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx @@ -33,11 +33,29 @@ export default function APIPageClient({ machineId }) { const [viewTab, setViewTab] = useState("api"); const [mcpStatus, setMcpStatus] = useState(null); const [a2aStatus, setA2aStatus] = useState(null); + const [searchProviders, setSearchProviders] = useState([]); const { copied, copy } = useCopyToClipboard(); + const fetchSearchProviders = async () => { + try { + const res = await fetch("/v1/search"); + if (res.ok) { + const data = await res.json(); + setSearchProviders(data.data || []); + } + } catch { + // Search endpoint may not be available + } + }; + useEffect(() => { - Promise.allSettled([loadCloudSettings(), fetchModels(), fetchProtocolStatus()]).finally(() => { + Promise.allSettled([ + loadCloudSettings(), + fetchModels(), + fetchProtocolStatus(), + fetchSearchProviders(), + ]).finally(() => { setLoading(false); }); }, []); @@ -575,6 +593,47 @@ export default function APIPageClient({ machineId }) { + {/* Search & Discovery */} + {searchProviders.length > 0 && ( +
+
+ + travel_explore + +

+ {t("categorySearch") || "Search & Discovery"} +

+
+
+
+ ({ + id: p.id, + name: p.name, + owned_by: p.id, + type: "search", + }))} + expanded={expandedEndpoint === "search"} + onToggle={() => + setExpandedEndpoint(expandedEndpoint === "search" ? null : "search") + } + copy={copy} + copied={copied} + baseUrl={currentEndpoint} + /> +
+
+ )} + {/* Utility & Management */}
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index eaac32caae..ff770ed039 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -101,6 +101,7 @@ export default function ProviderDetailPage() { const isOpenAICompatible = isOpenAICompatibleProvider(providerId); const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId); const isCompatible = isOpenAICompatible || isAnthropicCompatible; + const isSearchProvider = providerId.endsWith("-search"); const providerStorageAlias = isCompatible ? providerId : providerAlias; const providerDisplayAlias = isCompatible ? providerNode?.prefix || providerId : providerAlias; @@ -1060,21 +1061,43 @@ export default function ProviderDetailPage() { )} - {/* Models */} - -

{t("availableModels")}

- {renderModelsSection()} + {/* Models — hidden for search providers (they don't have models) */} + {!isSearchProvider && ( + +

{t("availableModels")}

+ {renderModelsSection()} - {/* Custom Models — available for ALL providers */} - {!isCompatible && ( - - )} -
+ {/* Custom Models — available for non-compatible, non-search providers */} + {!isCompatible && ( + + )} +
+ )} + + {/* Search provider info */} + {isSearchProvider && ( + +

{t("searchProvider") || "Search Provider"}

+

+ {t("searchProviderDesc") || + "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected."} +

+ {providerId === "perplexity-search" && ( +
+ link +

+ Uses the same API key as Perplexity (chat provider). If you already + have Perplexity configured, no additional setup is needed. +

+
+ )} +
+ )} {/* Modals */} {providerId === "kiro" ? ( diff --git a/src/app/api/v1/search/route.ts b/src/app/api/v1/search/route.ts new file mode 100644 index 0000000000..dc119b981c --- /dev/null +++ b/src/app/api/v1/search/route.ts @@ -0,0 +1,268 @@ +import { CORS_ORIGIN } from "@/shared/utils/cors"; +import { handleSearch } from "@omniroute/open-sse/handlers/search.ts"; +import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth"; +import { + getAllSearchProviders, + getSearchProvider, + selectProvider, + SEARCH_PROVIDERS, + SEARCH_CREDENTIAL_FALLBACKS, +} from "@omniroute/open-sse/config/searchRegistry.ts"; +import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; +import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; +import * as log from "@/sse/utils/logger"; +import { toJsonErrorPayload } from "@/shared/utils/upstreamError"; +import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; +import { v1SearchSchema } from "@/shared/validation/schemas"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { recordCost } from "@/domain/costRules"; +import { + computeCacheKey, + getOrCoalesce, + SEARCH_CACHE_DEFAULT_TTL_MS, +} from "@omniroute/open-sse/services/searchCache.ts"; + +const CORS_HEADERS = { + "Access-Control-Allow-Origin": CORS_ORIGIN, + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "*", +}; + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return new Response(null, { headers: CORS_HEADERS }); +} + +/** + * GET /v1/search — list available search providers + */ +export async function GET() { + const providers = getAllSearchProviders(); + const timestamp = Math.floor(Date.now() / 1000); + + const data = providers.map((p) => ({ + id: p.id, + object: "search_provider", + created: timestamp, + name: p.name, + search_types: p.searchTypes, + })); + + return new Response(JSON.stringify({ object: "list", data }), { + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); +} + +// Helper: resolve credentials with fallback (e.g., perplexity-search → perplexity) +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; +} + +// Helper: build domain filter array from filters object +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; +} + +/** + * POST /v1/search — execute a web search + */ +export async function POST(request: Request) { + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + log.warn("SEARCH", "Invalid JSON body"); + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body"); + } + + const validation = validateBody(v1SearchSchema, rawBody); + if (isValidationFailure(validation)) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, validation.error.message); + } + const body = validation.data; + + // Optional API key validation + if (process.env.REQUIRE_API_KEY === "true") { + const apiKey = extractApiKey(request); + if (!apiKey) { + return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key"); + } + const valid = await isValidApiKey(apiKey); + if (!valid) { + return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); + } + } + + // Enforce API key policies — use "search" as model identifier for consistent policy config + const policy = await enforceApiKeyPolicy(request, "search"); + if (policy.rejection) return policy.rejection; + + // Resolve provider and credentials + let providerConfig = selectProvider(body.provider); + if (!providerConfig) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + body.provider ? `Unknown search provider: ${body.provider}` : "No search providers available" + ); + } + + let credentials: Record | null = null; + let alternateProviderId: string | undefined; + let alternateCredentials: Record | null = null; + + if (body.provider) { + // Explicit provider — single credential lookup (with fallback) + credentials = await resolveSearchCredentials(providerConfig.id); + if (!credentials) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `No credentials configured for search provider: ${providerConfig.id}. Add an API key for "${providerConfig.id}" in the dashboard.` + ); + } + } else { + // Auto-select — try the resolved provider first, then iterate others by cost + credentials = await resolveSearchCredentials(providerConfig.id); + + if (!credentials) { + // Sort by cost to find cheapest with credentials + const sortedIds = Object.values(SEARCH_PROVIDERS) + .sort((a, b) => a.costPerQuery - b.costPerQuery) + .map((p) => p.id); + + for (const pid of sortedIds) { + if (pid === providerConfig.id) continue; + const altConfig = getSearchProvider(pid); + const altCreds = await resolveSearchCredentials(pid); + if (altConfig && altCreds) { + providerConfig = altConfig; + credentials = altCreds; + break; + } + } + } + + if (!credentials) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `No credentials configured for any search provider. Add an API key for a search provider (${Object.keys(SEARCH_PROVIDERS).join(", ")}) in the dashboard.` + ); + } + + // Find alternate for failover — must bind credentials to the matched provider + const otherIds = Object.values(SEARCH_PROVIDERS) + .sort((a, b) => a.costPerQuery - b.costPerQuery) + .map((p) => p.id) + .filter((id) => id !== providerConfig.id); + + for (const pid of otherIds) { + const creds = await resolveSearchCredentials(pid); + if (creds) { + alternateProviderId = pid; + alternateCredentials = creds; + break; + } + } + } + + // Clamp max_results to provider limit + const clampedMaxResults = Math.min(body.max_results, providerConfig.maxMaxResults); + + // Cache key — includes all fields that affect results + const cacheKey = computeCacheKey( + body.query, + providerConfig.id, + body.search_type, + clampedMaxResults, + body.country, + body.language, + { filters: body.filters, offset: body.offset, time_range: body.time_range } + ); + + const ttl = providerConfig.cacheTTLMs || SEARCH_CACHE_DEFAULT_TTL_MS; + + try { + const { data: searchResult, cached } = await getOrCoalesce(cacheKey, ttl, async () => { + const result = await handleSearch({ + query: body.query, + provider: providerConfig.id, + maxResults: clampedMaxResults, + searchType: body.search_type, + country: body.country, + language: body.language, + timeRange: body.time_range, + offset: body.offset, + domainFilter: buildDomainFilter(body.filters), + contentOptions: body.content, + strictFilters: body.strict_filters, + providerOptions: body.provider_options, + credentials, + alternateProvider: alternateProviderId, + alternateCredentials, + log, + }); + + if (!result.success) { + throw new SearchError(result.error || "Search failed", result.status || 502); + } + + return result.data!; + }); + + // Record cost for budget tracking (skip cache hits — no provider cost) + if (!cached && policy.apiKeyInfo?.id && searchResult.usage?.search_cost_usd > 0) { + try { + recordCost(policy.apiKeyInfo.id, searchResult.usage.search_cost_usd); + } catch (e: any) { + log.warn("SEARCH", `Cost recording failed: ${e?.message}`); + } + } + + const response = { + id: `search-${crypto.randomUUID()}`, + ...searchResult, + cached, + usage: cached ? { queries_used: 0, search_cost_usd: 0 } : searchResult.usage, + }; + + return new Response(JSON.stringify(response), { + status: 200, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); + } catch (err: any) { + if (err instanceof SearchError) { + const errorPayload = toJsonErrorPayload(err.message, "Search provider error"); + return new Response(JSON.stringify(errorPayload), { + status: err.statusCode, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); + } + + log.error("SEARCH", `Unexpected error: ${err.message}`); + const errorPayload = toJsonErrorPayload(err.message, "Internal search error"); + return new Response(JSON.stringify(errorPayload), { + status: 500, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); + } +} + +class SearchError extends Error { + statusCode: number; + constructor(message: string, statusCode: number) { + super(message); + this.statusCode = statusCode; + } +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index c93a0827a6..48b8cdd0fd 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -818,7 +818,12 @@ "settingsApi": "Settings API", "categoryCore": "Core APIs", "categoryMedia": "Media & Multi-Modal", + "categorySearch": "Search & Discovery", "categoryUtility": "Utility & Management", + "webSearch": "Web Search", + "webSearchDesc": "Unified web search across multiple providers with automatic failover and caching", + "searchProvider": "Search Provider", + "searchProviderDesc": "This provider is used for web search via POST /v1/search. No model configuration needed — search providers are ready to use once an API key is connected.", "enableCloudTitle": "Enable Cloud Proxy", "whatYouGet": "What you will get", "cloudBenefitAccess": "Access your API from anywhere in the world", diff --git a/src/lib/db/migrations/007_search_request_type.sql b/src/lib/db/migrations/007_search_request_type.sql new file mode 100644 index 0000000000..3917771df8 --- /dev/null +++ b/src/lib/db/migrations/007_search_request_type.sql @@ -0,0 +1,4 @@ +-- Add request_type column to call_logs for non-chat request tracking (search, embed, rerank). +-- Backward-compatible: DEFAULT NULL means existing rows are unaffected. +ALTER TABLE call_logs ADD COLUMN request_type TEXT DEFAULT NULL; +CREATE INDEX IF NOT EXISTS idx_call_logs_request_type ON call_logs(request_type); diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 89633c24aa..083f41c078 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -440,6 +440,69 @@ async function validateAnthropicCompatibleProvider({ apiKey, providerSpecificDat } } +// ── Search provider validators (factored) ── + +async function validateSearchProvider( + url: string, + init: RequestInit +): Promise<{ valid: boolean; error: string | null }> { + try { + const response = await fetch(url, init); + if (response.ok) return { valid: true, error: null }; + if (response.status === 401 || response.status === 403) { + return { valid: false, error: "Invalid API key" }; + } + return { valid: false, error: `Validation failed: ${response.status}` }; + } catch (error: any) { + return { valid: false, error: error.message || "Validation failed" }; + } +} + +const SEARCH_VALIDATOR_CONFIGS: Record< + string, + (apiKey: string) => { url: string; init: RequestInit } +> = { + "serper-search": (apiKey) => ({ + url: "https://google.serper.dev/search", + init: { + method: "POST", + headers: { "Content-Type": "application/json", "X-API-Key": apiKey }, + body: JSON.stringify({ q: "test", num: 1 }), + }, + }), + "brave-search": (apiKey) => ({ + url: "https://api.search.brave.com/res/v1/web/search?q=test&count=1", + init: { + method: "GET", + headers: { Accept: "application/json", "X-Subscription-Token": apiKey }, + }, + }), + "perplexity-search": (apiKey) => ({ + url: "https://api.perplexity.ai/search", + init: { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify({ query: "test", max_results: 1 }), + }, + }), + "exa-search": (apiKey) => ({ + url: "https://api.exa.ai/search", + init: { + method: "POST", + headers: { "Content-Type": "application/json", "x-api-key": apiKey }, + body: JSON.stringify({ query: "test", numResults: 1 }), + }, + }), + "tavily-search": (apiKey) => ({ + url: "https://api.tavily.com/search", + init: { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify({ query: "test", max_results: 1 }), + }, + }), +}; + export async function validateProviderApiKey({ provider, apiKey, providerSpecificData = {} }: any) { if (!provider || !apiKey) { return { valid: false, error: "Provider and API key required", unsupported: false }; @@ -468,6 +531,16 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi nanobanana: validateNanoBananaProvider, elevenlabs: validateElevenLabsProvider, inworld: validateInworldProvider, + // Search providers — use factored validator + ...Object.fromEntries( + Object.entries(SEARCH_VALIDATOR_CONFIGS).map(([id, configFn]) => [ + id, + ({ apiKey }: any) => { + const { url, init } = configFn(apiKey); + return validateSearchProvider(url, init); + }, + ]) + ), }; if (SPECIALTY_VALIDATORS[provider]) { diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index 05d8c677c9..8c6bfc5875 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -186,6 +186,7 @@ export async function saveCallLog(entry: any) { duration: entry.duration || 0, tokensIn: entry.tokens?.prompt_tokens || 0, tokensOut: entry.tokens?.completion_tokens || 0, + requestType: entry.requestType || null, sourceFormat: entry.sourceFormat || null, targetFormat: entry.targetFormat || null, apiKeyId, @@ -201,10 +202,10 @@ export async function saveCallLog(entry: any) { db.prepare( ` INSERT INTO call_logs (id, timestamp, method, path, status, model, provider, - account, connection_id, duration, tokens_in, tokens_out, source_format, target_format, + account, connection_id, duration, tokens_in, tokens_out, request_type, source_format, target_format, api_key_id, api_key_name, combo_name, request_body, response_body, error) VALUES (@id, @timestamp, @method, @path, @status, @model, @provider, - @account, @connectionId, @duration, @tokensIn, @tokensOut, @sourceFormat, @targetFormat, + @account, @connectionId, @duration, @tokensIn, @tokensOut, @requestType, @sourceFormat, @targetFormat, @apiKeyId, @apiKeyName, @comboName, @requestBody, @responseBody, @error) ` ).run(logEntry); diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 8da4fa6bf7..5f9d93fbc5 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -390,8 +390,6 @@ export const APIKEY_PROVIDERS = { website: "https://cloud.google.com/vertex-ai", authHint: "Provide Service Account JSON or OAuth access_token", }, - // Z.AI (formerly ZhipuAI) — GLM-5 family with 128k output - // Added 2026-03-17 based on ClawRouter feature analysis zai: { id: "zai", alias: "zai", @@ -402,6 +400,56 @@ export const APIKEY_PROVIDERS = { website: "https://open.bigmodel.cn", apiHint: "API key from https://open.bigmodel.cn/usercenter/apikeys", }, + "perplexity-search": { + id: "perplexity-search", + alias: "pplx-search", + name: "Perplexity Search", + icon: "search", + color: "#20808D", + textIcon: "PS", + website: "https://docs.perplexity.ai/guides/search-quickstart", + authHint: "Same API key as Perplexity (pplx-...)", + }, + "serper-search": { + id: "serper-search", + alias: "serper-search", + name: "Serper Search", + icon: "search", + color: "#4285F4", + textIcon: "SP", + website: "https://serper.dev", + authHint: "API key from serper.dev dashboard", + }, + "brave-search": { + id: "brave-search", + alias: "brave-search", + name: "Brave Search", + icon: "travel_explore", + color: "#FB542B", + textIcon: "BR", + website: "https://brave.com/search/api", + authHint: "Subscription token from Brave Search API dashboard", + }, + "exa-search": { + id: "exa-search", + alias: "exa-search", + name: "Exa Search", + icon: "neurology", + color: "#1E40AF", + textIcon: "EX", + website: "https://exa.ai", + authHint: "API key from dashboard.exa.ai", + }, + "tavily-search": { + id: "tavily-search", + alias: "tavily-search", + name: "Tavily Search", + icon: "manage_search", + color: "#5B4FDB", + textIcon: "TV", + website: "https://tavily.com", + authHint: "API key from app.tavily.com (format: tvly-...)", + }, }; export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-"; diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index dbe7758381..9dd115e684 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -1081,3 +1081,137 @@ export const guideSettingsSaveSchema = z.object({ apiKey: z.string().optional(), model: z.string().trim().min(1, "Model is required"), }); + +// ── Search Schemas ───────────────────────────────────────────────────── +// Unified search request/response schemas. Final contract — all fields optional +// with defaults. New features add implementations, not new fields. +// Multi-query deferred to POST /v1/search/batch (separate PRD). + +export const v1SearchSchema = z + .object({ + // Core + query: z + .string() + .trim() + .min(1, "Query is required") + .max(500, "Query must be 500 characters or fewer"), + provider: z + .enum(["serper-search", "brave-search", "perplexity-search", "exa-search", "tavily-search"]) + .optional(), + max_results: z.coerce.number().int().min(1).max(100).default(5), + search_type: z.enum(["web", "news"]).default("web"), + offset: z.coerce.number().int().min(0).default(0), + + // Locale + country: z.string().max(2).toUpperCase().optional(), + language: z.string().min(2).max(5).optional(), + time_range: z.enum(["any", "day", "week", "month", "year"]).optional(), + + // Content control + content: z + .object({ + snippet: z.boolean().default(true), + full_page: z.boolean().default(false), + format: z.enum(["text", "markdown"]).default("text"), + max_characters: z.coerce.number().int().min(100).max(100000).optional(), + }) + .optional(), + + // Filters + filters: z + .object({ + include_domains: z.array(z.string().max(253)).max(20).optional(), + exclude_domains: z.array(z.string().max(253)).max(20).optional(), + safe_search: z.enum(["off", "moderate", "strict"]).optional(), + }) + .optional(), + + // Answer synthesis (Phase 2 — returns null until implemented) + synthesis: z + .object({ + strategy: z.enum(["none", "auto", "provider", "internal"]).default("none"), + model: z.string().optional(), + max_tokens: z.coerce.number().int().min(1).max(4000).optional(), + }) + .optional(), + + // Provider-specific passthrough + provider_options: z.record(z.string(), z.unknown()).optional(), + + // Strict mode — reject if provider doesn't support a requested filter + strict_filters: z.boolean().default(false), + }) + .catchall(z.unknown()); + +export const searchResultSchema = z.object({ + title: z.string(), + url: z.string(), + display_url: z.string().optional(), + snippet: z.string(), + position: z.number().int().positive(), + score: z.number().min(0).max(1).nullable().optional(), + published_at: z.string().nullable().optional(), + favicon_url: z.string().nullable().optional(), + content: z + .object({ + format: z.enum(["text", "markdown"]).optional(), + text: z.string().optional(), + length: z.number().int().optional(), + }) + .nullable() + .optional(), + metadata: z + .object({ + author: z.string().nullable().optional(), + language: z.string().nullable().optional(), + source_type: z + .enum(["article", "blog", "forum", "video", "academic", "news", "other"]) + .nullable() + .optional(), + image_url: z.string().nullable().optional(), + }) + .nullable() + .optional(), + citation: z.object({ + provider: z.string(), + retrieved_at: z.string(), + rank: z.number().int().positive(), + }), + provider_raw: z.record(z.string(), z.unknown()).nullable().optional(), +}); + +export const v1SearchResponseSchema = z.object({ + id: z.string(), + provider: z.string(), + query: z.string(), + results: z.array(searchResultSchema), + cached: z.boolean(), + answer: z + .object({ + source: z.enum(["none", "provider", "internal"]).optional(), + text: z.string().nullable().optional(), + model: z.string().nullable().optional(), + }) + .nullable() + .optional(), + usage: z.object({ + queries_used: z.number().int().min(0), + search_cost_usd: z.number().min(0), + llm_tokens: z.number().int().min(0).optional(), + }), + metrics: z.object({ + response_time_ms: z.number().int().min(0), + upstream_latency_ms: z.number().int().min(0).optional(), + gateway_latency_ms: z.number().int().min(0).optional(), + total_results_available: z.number().int().nullable(), + }), + errors: z + .array( + z.object({ + provider: z.string(), + code: z.string(), + message: z.string(), + }) + ) + .optional(), +}); diff --git a/tests/unit/search-registry.test.mjs b/tests/unit/search-registry.test.mjs new file mode 100644 index 0000000000..8bcf74245b --- /dev/null +++ b/tests/unit/search-registry.test.mjs @@ -0,0 +1,277 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// ═══════════════════════════════════════════════════════════════ +// Search Registry + Cache Unit Tests +// Tests for searchRegistry, searchCache, and response normalization +// ═══════════════════════════════════════════════════════════════ + +const { SEARCH_PROVIDERS, getSearchProvider, getAllSearchProviders, selectProvider } = + await import("../../open-sse/config/searchRegistry.ts"); + +const { computeCacheKey, getOrCoalesce, getCacheStats, SEARCH_CACHE_DEFAULT_TTL_MS } = + await import("../../open-sse/services/searchCache.ts"); + +// ─── Registry Tests ────────────────────────────────────────── + +test("SEARCH_PROVIDERS has all 5 providers", () => { + assert.ok(SEARCH_PROVIDERS["serper-search"], "serper should exist"); + assert.ok(SEARCH_PROVIDERS["brave-search"], "brave should exist"); + assert.ok(SEARCH_PROVIDERS["perplexity-search"], "perplexity-search should exist"); + assert.ok(SEARCH_PROVIDERS["exa-search"], "exa should exist"); + assert.ok(SEARCH_PROVIDERS["tavily-search"], "tavily should exist"); + assert.equal(Object.keys(SEARCH_PROVIDERS).length, 5); +}); + +test("serper-search config is correct", () => { + const s = SEARCH_PROVIDERS["serper-search"]; + assert.equal(s.id, "serper-search"); + assert.equal(s.method, "POST"); + assert.equal(s.authHeader, "x-api-key"); + assert.equal(s.costPerQuery, 0.001); + assert.equal(s.freeMonthlyQuota, 2500); + assert.deepEqual(s.searchTypes, ["web", "news"]); +}); + +test("brave-search config is correct", () => { + const b = SEARCH_PROVIDERS["brave-search"]; + assert.equal(b.id, "brave-search"); + assert.equal(b.method, "GET"); + assert.equal(b.authHeader, "x-subscription-token"); + assert.equal(b.costPerQuery, 0.005); + assert.equal(b.freeMonthlyQuota, 1000); +}); + +test("perplexity-search config is correct", () => { + const p = SEARCH_PROVIDERS["perplexity-search"]; + assert.equal(p.id, "perplexity-search"); + assert.equal(p.method, "POST"); + assert.equal(p.authHeader, "bearer"); + assert.equal(p.baseUrl, "https://api.perplexity.ai/search"); + assert.equal(p.costPerQuery, 0.005); + assert.equal(p.freeMonthlyQuota, 0); + assert.deepEqual(p.searchTypes, ["web"]); +}); + +test("getSearchProvider returns config for valid ID", () => { + const config = getSearchProvider("serper-search"); + assert.ok(config); + assert.equal(config.id, "serper-search"); +}); + +test("getSearchProvider returns null for unknown ID", () => { + assert.equal(getSearchProvider("unknown"), null); +}); + +test("tavily config is correct", () => { + const t = SEARCH_PROVIDERS["tavily-search"]; + assert.equal(t.id, "tavily-search"); + assert.equal(t.method, "POST"); + assert.equal(t.authHeader, "bearer"); + assert.equal(t.baseUrl, "https://api.tavily.com/search"); + assert.equal(t.costPerQuery, 0.008); + assert.equal(t.freeMonthlyQuota, 1000); + assert.deepEqual(t.searchTypes, ["web", "news"]); +}); + +test("getAllSearchProviders returns flat list", () => { + const all = getAllSearchProviders(); + assert.equal(all.length, 5); + assert.ok(all.some((p) => p.id === "serper-search")); + assert.ok(all.some((p) => p.id === "brave-search")); + assert.ok(all.some((p) => p.id === "perplexity-search")); + assert.ok(all.some((p) => p.id === "exa-search")); + assert.ok(all.some((p) => p.id === "tavily-search")); + // Each entry should have id, name, searchTypes + for (const p of all) { + assert.ok(p.id); + assert.ok(p.name); + assert.ok(Array.isArray(p.searchTypes)); + } +}); + +test("selectProvider with explicit provider returns that provider", () => { + const config = selectProvider("brave-search"); + assert.ok(config); + assert.equal(config.id, "brave-search"); +}); + +test("selectProvider with unknown provider returns null", () => { + assert.equal(selectProvider("unknown"), null); +}); + +test("selectProvider without argument returns cheapest (serper)", () => { + const config = selectProvider(); + assert.ok(config); + assert.equal(config.id, "serper-search"); // $0.001 < $0.005 +}); + +// ─── Cache Key Tests ───────────────────────────────────────── + +test("computeCacheKey is deterministic", () => { + const k1 = computeCacheKey("hello world", "auto", "web", 5); + const k2 = computeCacheKey("hello world", "auto", "web", 5); + assert.equal(k1, k2); +}); + +test("computeCacheKey normalizes query (case, whitespace)", () => { + const k1 = computeCacheKey("Hello World", "auto", "web", 5); + const k2 = computeCacheKey("hello world", "auto", "web", 5); + assert.equal(k1, k2); +}); + +test("computeCacheKey differs by provider", () => { + const k1 = computeCacheKey("test", "serper", "web", 5); + const k2 = computeCacheKey("test", "brave", "web", 5); + assert.notEqual(k1, k2); +}); + +test("computeCacheKey differs by search_type", () => { + const k1 = computeCacheKey("test", "auto", "web", 5); + const k2 = computeCacheKey("test", "auto", "news", 5); + assert.notEqual(k1, k2); +}); + +test("computeCacheKey differs by max_results", () => { + const k1 = computeCacheKey("test", "auto", "web", 5); + const k2 = computeCacheKey("test", "auto", "web", 10); + assert.notEqual(k1, k2); +}); + +// ─── Cache + Coalescing Tests ──────────────────────────────── + +test("getOrCoalesce caches and returns on second call", async () => { + let callCount = 0; + const key = "test-cache-hit-" + Date.now(); + + const r1 = await getOrCoalesce(key, 60_000, async () => { + callCount++; + return { value: 42 }; + }); + assert.equal(r1.cached, false); + assert.deepEqual(r1.data, { value: 42 }); + + const r2 = await getOrCoalesce(key, 60_000, async () => { + callCount++; + return { value: 99 }; + }); + assert.equal(r2.cached, true); + assert.deepEqual(r2.data, { value: 42 }); // original value, not 99 + assert.equal(callCount, 1); // fetchFn called only once +}); + +test("getOrCoalesce coalesces concurrent requests", async () => { + let callCount = 0; + const key = "test-coalesce-" + Date.now(); + + const fetchFn = async () => { + callCount++; + await new Promise((r) => setTimeout(r, 50)); // simulate async + return { value: "coalesced" }; + }; + + // Launch 3 concurrent requests with the same key + const [r1, r2, r3] = await Promise.all([ + getOrCoalesce(key, 60_000, fetchFn), + getOrCoalesce(key, 60_000, fetchFn), + getOrCoalesce(key, 60_000, fetchFn), + ]); + + assert.equal(callCount, 1); // Only one fetch executed + assert.deepEqual(r1.data, { value: "coalesced" }); + assert.deepEqual(r2.data, { value: "coalesced" }); + assert.deepEqual(r3.data, { value: "coalesced" }); +}); + +test("getOrCoalesce respects TTL=0 (no caching)", async () => { + let callCount = 0; + const key = "test-no-cache-" + Date.now(); + + await getOrCoalesce(key, 0, async () => { + callCount++; + return { value: 1 }; + }); + await getOrCoalesce(key, 0, async () => { + callCount++; + return { value: 2 }; + }); + + assert.equal(callCount, 2); // Both calls executed +}); + +test("getCacheStats returns valid stats", () => { + const stats = getCacheStats(); + assert.equal(typeof stats.size, "number"); + assert.equal(typeof stats.hits, "number"); + assert.equal(typeof stats.misses, "number"); +}); + +test("SEARCH_CACHE_DEFAULT_TTL_MS is positive", () => { + assert.ok(SEARCH_CACHE_DEFAULT_TTL_MS > 0); +}); + +// ─── Validation Schema Tests ──────────────────────────────── + +test("v1SearchSchema validates correct input", async () => { + const { v1SearchSchema } = await import("../../src/shared/validation/schemas.ts"); + + const result = v1SearchSchema.safeParse({ + query: "test query", + provider: "serper-search", + max_results: 10, + search_type: "web", + }); + assert.ok(result.success); + assert.equal(result.data.query, "test query"); + assert.equal(result.data.provider, "serper-search"); + assert.equal(result.data.max_results, 10); +}); + +test("v1SearchSchema rejects empty query", async () => { + const { v1SearchSchema } = await import("../../src/shared/validation/schemas.ts"); + + const result = v1SearchSchema.safeParse({ query: "" }); + assert.ok(!result.success); +}); + +test("v1SearchSchema rejects query over 500 chars", async () => { + const { v1SearchSchema } = await import("../../src/shared/validation/schemas.ts"); + + const result = v1SearchSchema.safeParse({ query: "a".repeat(501) }); + assert.ok(!result.success); +}); + +test("v1SearchSchema rejects invalid provider", async () => { + const { v1SearchSchema } = await import("../../src/shared/validation/schemas.ts"); + + const result = v1SearchSchema.safeParse({ query: "test", provider: "google" }); + assert.ok(!result.success); +}); + +test("v1SearchSchema accepts tavily provider", async () => { + const { v1SearchSchema } = await import("../../src/shared/validation/schemas.ts"); + + const result = v1SearchSchema.safeParse({ query: "test", provider: "tavily-search" }); + assert.ok(result.success); + assert.equal(result.data.provider, "tavily-search"); +}); + +test("v1SearchSchema applies defaults", async () => { + const { v1SearchSchema } = await import("../../src/shared/validation/schemas.ts"); + + const result = v1SearchSchema.safeParse({ query: "test" }); + assert.ok(result.success); + assert.equal(result.data.max_results, 5); + assert.equal(result.data.search_type, "web"); + assert.equal(result.data.provider, undefined); +}); + +test("v1SearchSchema allows unknown fields (forward compat)", async () => { + const { v1SearchSchema } = await import("../../src/shared/validation/schemas.ts"); + + const result = v1SearchSchema.safeParse({ + query: "test", + future_field: true, + }); + assert.ok(result.success); +});