/** * Exa and Parallel MCP provider definitions. * * Both endpoints are free to use without an API key; keys raise the rate * limits (EXA_API_KEY / PARALLEL_API_KEY). Provider selection mirrors * opencode: an explicit PI_SEARCH_PROVIDER override wins, otherwise the * provider is chosen deterministically per session so a conversation stays * on one backend while both get exercised over time. */ export type ProviderName = "exa" | "parallel"; export interface SearchOptions { query: string; queries?: string[]; numResults?: number; sessionId: string; modelName?: string; } export interface AdvancedSearchOptions { query: string; type?: string; numResults?: number; category?: string; includeDomains?: string[]; excludeDomains?: string[]; startPublishedDate?: string; endPublishedDate?: string; maxAgeHours?: number; contextMaxCharacters?: number; enableHighlights?: boolean; enableSummary?: boolean; additionalQueries?: string[]; } export interface FetchOptions { urls: string[]; objective?: string; maxCharacters?: number; fullContent?: boolean; sessionId: string; modelName?: string; } export interface Provider { name: ProviderName; label: string; searchTool: string; fetchTool: string; advancedSearchTool?: string; url: string; headers: Record; buildSearchArgs(options: SearchOptions): Record; buildAdvancedSearchArgs?(options: AdvancedSearchOptions): Record; buildFetchArgs(options: FetchOptions): Record; normalizeOutput(text: string, maxResults?: number): string; } export type AdvancedProvider = Provider & { advancedSearchTool: string; buildAdvancedSearchArgs(options: AdvancedSearchOptions): Record; }; function checksum(text: string): string { let sum = 0; for (let i = 0; i < text.length; i++) { sum = (sum * 31 + text.charCodeAt(i)) >>> 0; } return sum.toString(36); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function nonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } function normalizeExaOutput(text: string): string { try { const result: unknown = JSON.parse(text); return isRecord(result) && typeof result.context === "string" ? result.context : text; } catch { return text; } } function normalizeParallelOutput(text: string, maxResults?: number): string { let result: unknown; try { result = JSON.parse(text); } catch { return text; } if (!isRecord(result) || !Array.isArray(result.results)) return text; const results = result.results.filter(isRecord).slice(0, maxResults); const formatted = results.map((item, index) => { const lines = [`## ${index + 1}. ${nonEmptyString(item.title) || `Result ${index + 1}`}`]; const url = nonEmptyString(item.url); const publishedDate = nonEmptyString(item.publish_date) || nonEmptyString(item.published_date); const excerpts = Array.isArray(item.excerpts) ? item.excerpts.flatMap((excerpt) => (typeof excerpt === "string" && excerpt.trim() ? [excerpt.trim()] : [])) : []; if (url) lines.push(`URL: ${url}`); if (publishedDate) lines.push(`Published: ${publishedDate}`); if (excerpts.length > 0) lines.push(...excerpts); return lines.join("\n"); }); return formatted.length > 0 ? formatted.join("\n\n") : text; } const EXA_URL = process.env.EXA_API_KEY ? `https://mcp.exa.ai/mcp?exaApiKey=${encodeURIComponent(process.env.EXA_API_KEY)}&tools=web_search_exa,web_fetch_exa,web_search_advanced_exa` : "https://mcp.exa.ai/mcp?tools=web_search_exa,web_fetch_exa,web_search_advanced_exa"; const PARALLEL_URL = "https://search.parallel.ai/mcp"; const exa: AdvancedProvider = { name: "exa", label: "Exa", searchTool: "web_search_exa", fetchTool: "web_fetch_exa", advancedSearchTool: "web_search_advanced_exa", url: EXA_URL, headers: {}, buildSearchArgs: ({ query, numResults }) => ({ query, ...(numResults !== undefined ? { numResults } : {}), }), buildAdvancedSearchArgs: (options) => { const args: Record = { query: options.query }; for (const [key, value] of [ ["numResults", options.numResults], ["type", options.type], ["category", options.category], ["includeDomains", options.includeDomains], ["excludeDomains", options.excludeDomains], ["startPublishedDate", options.startPublishedDate], ["endPublishedDate", options.endPublishedDate], ["maxAgeHours", options.maxAgeHours], ["contextMaxCharacters", options.contextMaxCharacters], ["enableHighlights", options.enableHighlights], ["enableSummary", options.enableSummary], ["additionalQueries", options.additionalQueries], ] as const) { if (value !== undefined) args[key] = value; } return args; }, buildFetchArgs: ({ urls, maxCharacters }) => ({ urls, ...(maxCharacters !== undefined ? { maxCharacters } : {}), }), normalizeOutput: normalizeExaOutput, }; const parallel: Provider = { name: "parallel", label: "Parallel", searchTool: "web_search", fetchTool: "web_fetch", url: PARALLEL_URL, headers: { "User-Agent": "pi-search", ...(process.env.PARALLEL_API_KEY ? { Authorization: `Bearer ${process.env.PARALLEL_API_KEY}` } : {}), }, buildSearchArgs: ({ query, queries, sessionId, modelName }) => ({ objective: query, search_queries: queries && queries.length > 0 ? queries : [query], session_id: sessionId, ...(modelName ? { model_name: modelName } : {}), }), buildFetchArgs: ({ urls, objective, fullContent, sessionId, modelName }) => ({ urls, ...(objective ? { objective } : {}), ...(fullContent ? { full_content: true } : {}), session_id: sessionId, ...(modelName ? { model_name: modelName } : {}), }), normalizeOutput: normalizeParallelOutput, }; const PROVIDERS: Record = { exa, parallel }; export function getExaProvider(): AdvancedProvider { return exa; } export function getProvider(sessionId: string): Provider { const override = process.env.PI_SEARCH_PROVIDER; if (override === "exa" || override === "parallel") return PROVIDERS[override]; const pick = parseInt(checksum(sessionId), 36) % 2 === 0 ? "exa" : "parallel"; return PROVIDERS[pick]; }