From e13b1ea2d53e66be76ad17159c36325f1bb255c1 Mon Sep 17 00:00:00 2001 From: Christopher Mayor Date: Fri, 24 Apr 2026 14:34:55 -0700 Subject: [PATCH] feat: add Perplexity Sonar provider with OpenAI fallback --- src/lib/llm/providers/perplexity.ts | 117 ++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 src/lib/llm/providers/perplexity.ts diff --git a/src/lib/llm/providers/perplexity.ts b/src/lib/llm/providers/perplexity.ts new file mode 100644 index 0000000..821cfce --- /dev/null +++ b/src/lib/llm/providers/perplexity.ts @@ -0,0 +1,117 @@ +import type { ComparisonRequest, ComparisonResult } from "../types"; +import type { SearchResult } from "./tavily"; +import { generateComparisonWithResearch } from "./openai"; + +const PERPLEXITY_API_URL = "https://api.perplexity.ai/chat/completions"; + +const SYSTEM_PROMPT = `You are a research synthesis engine. Given web search results for multiple items, produce a structured JSON comparison. + +You MUST respond with valid JSON matching this exact structure: +{ + "items": [ + { + "name": "item name", + "description": "brief overview", + "overallScore": 7.5, + "dimensions": { + "Dimension Name": { + "score": 8, + "summary": "brief assessment", + "details": "detailed analysis", + "pros": ["pro 1"], + "cons": ["con 1"] + } + }, + "pros": ["overall pro 1"], + "cons": ["overall con 1"], + "sources": [{ "title": "source", "url": "https://...", "snippet": "excerpt" }] + } + ], + "dimensions": ["Dimension 1", "Dimension 2"], + "summary": "comparison summary", + "recommendation": "clear recommendation" +}`; + +export async function synthesizeResearch( + request: ComparisonRequest, + searchResults: Record +): Promise { + const apiKey = process.env.PERPLEXITY_API_KEY; + if (!apiKey) { + return generateComparisonWithResearch(request, searchResults); + } + + const allResults = Object.values(searchResults).flat(); + if (allResults.length === 0) { + return generateComparisonWithResearch(request, searchResults); + } + + let researchContext = "Search results for each item:\n\n"; + for (const [itemName, results] of Object.entries(searchResults)) { + if (results.length === 0) continue; + researchContext += `=== ${itemName} ===\n`; + for (const r of results) { + researchContext += `- ${r.title}: ${r.content}\n Source: ${r.url}\n`; + } + researchContext += "\n"; + } + + const userPrompt = `Compare: ${request.items.join(", ")} +${request.query ? `Focus: ${request.query}` : ""} +${request.dimensions?.length ? `Dimensions: ${request.dimensions.join(", ")}` : ""} + +${researchContext}`; + + try { + const response = await fetch(PERPLEXITY_API_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "sonar", + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: userPrompt }, + ], + temperature: 0.3, + }), + }); + + if (!response.ok) { + console.error( + `Perplexity API error: ${response.status} ${response.statusText}` + ); + return generateComparisonWithResearch(request, searchResults); + } + + const data = await response.json(); + const content = data.choices?.[0]?.message?.content; + + if (!content) { + console.error("Empty response from Perplexity"); + return generateComparisonWithResearch(request, searchResults); + } + + const parsed: unknown = JSON.parse(content); + + if ( + !parsed || + typeof parsed !== "object" || + !Array.isArray((parsed as Record).items) || + !Array.isArray((parsed as Record).dimensions) + ) { + console.error("Invalid structure from Perplexity, falling back to OpenAI"); + return generateComparisonWithResearch(request, searchResults); + } + + return parsed as ComparisonResult; + } catch (error) { + console.error( + "Perplexity synthesis failed, falling back to OpenAI:", + error instanceof Error ? error.message : error + ); + return generateComparisonWithResearch(request, searchResults); + } +}