mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +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)
156 lines
4.0 KiB
TypeScript
156 lines
4.0 KiB
TypeScript
/**
|
|
* 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<string, SearchProviderConfig> = {
|
|
"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<string, string> = {
|
|
"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));
|
|
}
|