/** * 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" | "none"; authHeader: string; costPerQuery: number; freeMonthlyQuota: number; searchTypes: string[]; defaultMaxResults: number; maxMaxResults: number; timeoutMs: number; cacheTTLMs: number; /** * Last-resort provider: excluded from automatic (cost-based) selection so a * cost-0 free provider never overrides a configured paid one. Only used when no * credentialed provider is available, or when requested explicitly by id. */ fallbackOnly?: boolean; } 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, }, "google-pse-search": { id: "google-pse-search", name: "Google Programmable Search", baseUrl: "https://www.googleapis.com/customsearch/v1", method: "GET", authType: "apikey", authHeader: "key", costPerQuery: 0.005, freeMonthlyQuota: 3000, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 10, timeoutMs: 10_000, cacheTTLMs: 5 * 60 * 1000, }, "linkup-search": { id: "linkup-search", name: "Linkup Search", baseUrl: "https://api.linkup.so/v1/search", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.005, freeMonthlyQuota: 1000, searchTypes: ["web"], defaultMaxResults: 5, maxMaxResults: 50, timeoutMs: 10_000, cacheTTLMs: 5 * 60 * 1000, }, "searchapi-search": { id: "searchapi-search", name: "SearchAPI", baseUrl: "https://www.searchapi.io/api/v1/search", method: "GET", authType: "apikey", authHeader: "api_key", costPerQuery: 0.004, freeMonthlyQuota: 100, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 100, timeoutMs: 10_000, cacheTTLMs: 5 * 60 * 1000, }, "youcom-search": { id: "youcom-search", name: "You.com Search", baseUrl: "https://ydc-index.io/v1/search", method: "GET", authType: "apikey", authHeader: "x-api-key", costPerQuery: 0.005, freeMonthlyQuota: 0, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 100, timeoutMs: 10_000, cacheTTLMs: 5 * 60 * 1000, }, "searxng-search": { id: "searxng-search", name: "SearXNG Search", baseUrl: "http://localhost:8888/search", method: "GET", authType: "none", authHeader: "none", costPerQuery: 0, freeMonthlyQuota: 999999, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 50, timeoutMs: 10_000, cacheTTLMs: 3 * 60 * 1000, }, "ollama-search": { id: "ollama-search", name: "Ollama Search", baseUrl: "https://ollama.com/api/web_search", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0, freeMonthlyQuota: 1000, searchTypes: ["web"], defaultMaxResults: 5, maxMaxResults: 10, timeoutMs: 10_000, cacheTTLMs: 5 * 60 * 1000, }, "zai-search": { id: "zai-search", name: "Z.AI Coding Plan Search", baseUrl: "https://api.z.ai/api/mcp/web_search_prime/mcp", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0, freeMonthlyQuota: 0, searchTypes: ["web"], defaultMaxResults: 5, maxMaxResults: 50, timeoutMs: 10_000, cacheTTLMs: 5 * 60 * 1000, }, // Free, no-API-key DuckDuckGo lite scraping (free-claude-code port). Last-resort // only (fallbackOnly): never auto-selected over a configured provider; served by // the dedicated HTML path in open-sse/handlers/search.ts (not the generic JSON one). "duckduckgo-free": { id: "duckduckgo-free", name: "DuckDuckGo (free)", baseUrl: "https://lite.duckduckgo.com/lite/", method: "POST", authType: "none", authHeader: "none", costPerQuery: 0, freeMonthlyQuota: 999999, searchTypes: ["web"], defaultMaxResults: 5, maxMaxResults: 25, timeoutMs: 10_000, cacheTTLMs: 5 * 60 * 1000, fallbackOnly: true, }, }; /** * 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", "ollama-search": "ollama-cloud", "zai-search": "zai", }; /** * Get search provider config by ID */ export function getSearchProvider(providerId: string): SearchProviderConfig | null { return SEARCH_PROVIDERS[providerId] || null; } export function supportsSearchType( providerOrId: SearchProviderConfig | string | null | undefined, searchType: string ): boolean { const provider = typeof providerOrId === "string" ? getSearchProvider(providerOrId) : providerOrId || null; if (!provider) return false; return provider.searchTypes.includes(searchType); } /** * 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, searchType?: string ): SearchProviderConfig | null { if (explicitProvider) { const provider = SEARCH_PROVIDERS[explicitProvider] || null; if (!provider) return null; if (searchType && !supportsSearchType(provider, searchType)) return null; return provider; } // Auto-selection excludes fallbackOnly providers so a free cost-0 provider never // overrides a configured paid one — they are reached only via explicit id or the // route handler's last-resort step. const providers = Object.values(SEARCH_PROVIDERS).filter( (provider) => !provider.fallbackOnly && (searchType ? supportsSearchType(provider, searchType) : true) ); if (providers.length === 0) return null; return providers.reduce((cheapest, p) => (p.costPerQuery < cheapest.costPerQuery ? p : cheapest)); }