mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 12:22:14 +03:00
Add POST /v1/search — a unified search endpoint routing queries across 5 providers (Serper, Brave, Perplexity Search, Exa, Tavily) with automatic failover, in-memory caching, and request coalescing. No open-source AI gateway offers unified search routing. This chains free tiers for 5,500+ searches/month with zero downtime. Providers: Serper ($0.001/q, 2500/mo free), Brave ($0.005/q, 1000/mo), Perplexity Search ($0.005/q), Exa ($0.007/q, 1000/mo), Tavily ($0.008/q, 1000/mo). Auto-select picks cheapest with credentials. Architecture follows existing patterns: - searchRegistry.ts (same as embeddingRegistry.ts) - search.ts handler (same as embeddings.ts) - route.ts (same as /v1/embeddings/route.ts) - searchCache.ts (bounded TTL cache + request coalescing) Schema finalized — all future fields defined as optional with safe defaults. No breaking changes when implementing content extraction, answer synthesis, or ranking. Key features: - Per-provider request builders and response normalizers - Enriched response: display_url, score, favicon_url, content block, metadata, answer block, errors array, upstream_latency_ms metrics - Cost-sorted auto-select with failover on 429/5xx/timeout - Credential fallback (perplexity-search reuses perplexity chat key) - Cache key includes all result-affecting parameters - max_results clamped to provider limits, sanitized error responses - Factored validators (validateSearchProvider factory) - CORS headers on all responses - Dashboard: Search & Discovery section, search provider template - DB migration 007: request_type column in call_logs - 28 unit tests (registry, cache, coalescing, validation)
143 lines
3.5 KiB
TypeScript
143 lines
3.5 KiB
TypeScript
/**
|
|
* 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<T> {
|
|
data: T;
|
|
expiresAt: number;
|
|
}
|
|
|
|
const cache = new Map<string, CacheEntry<unknown>>();
|
|
const inflight = new Map<string, Promise<unknown>>();
|
|
|
|
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<T>(
|
|
key: string,
|
|
ttlMs: number,
|
|
fetchFn: () => Promise<T>
|
|
): Promise<{ data: T; cached: boolean }> {
|
|
// 1. Check cache
|
|
const cached = cache.get(key) as CacheEntry<T> | 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<T> | 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;
|