/** * PerplexityWebExecutor — Perplexity Web Session Provider * * Routes requests through Perplexity's internal SSE API using a Pro/Max * subscription session cookie or JWT, translating between OpenAI chat * completions format and Perplexity's internal protocol. */ import { BaseExecutor, type ExecuteInput } from "./base.ts"; import { tlsFetchPerplexity, isCloudflareChallenge, TlsClientUnavailableError, type TlsFetchResult, } from "../services/perplexityTlsClient.ts"; const PPLX_SSE_ENDPOINT = "https://www.perplexity.ai/rest/sse/perplexity_ask"; const PPLX_API_VERSION = "client-1.11.0"; // Firefox 148 — must match the `firefox_148` TLS profile used by perplexityTlsClient. // A mismatched UA vs TLS fingerprint is itself a Cloudflare bot signal (issue #2459). const PPLX_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0"; const MODEL_MAP: Record = { "pplx-auto": ["concise", "pplx_pro"], "pplx-sonar": ["copilot", "experimental"], "pplx-gpt": ["copilot", "gpt54"], "pplx-gemini": ["copilot", "gemini31pro_high"], "pplx-sonnet": ["copilot", "claude46sonnet"], "pplx-opus": ["copilot", "claude46opus"], "pplx-nemotron": ["copilot", "nv_nemotron_3_super"], }; const THINKING_MAP: Record = { "pplx-gpt": "gpt54_thinking", "pplx-sonnet": "claude46sonnetthinking", "pplx-opus": "claude46opusthinking", }; const CITATION_RE = /\[\d+\]/g; const GROK_TAG_RE = /]*>.*?<\/grok:[^>]*>/gs; const GROK_SELF_RE = /]*\/>/g; const XML_DECL_RE = /<[?]xml[^?]*[?]>/g; const RESPONSE_TAG_RE = /<\/?response\b[^>]*>/gi; const MULTI_SPACE = / {2,}/g; const MULTI_NL = /\n{3,}/g; // ─── Session continuity ───────────────────────────────────────────────────── const SESSION_MAX_AGE_MS = 3600_000; const SESSION_MAX_ENTRIES = 200; interface SessionEntry { backendUuid: string; ts: number; } const sessionCache = new Map(); function sessionKey(history: Array<{ role: string; content: string }>): string { const parts = history.map((h) => `${h.role}:${h.content}`).join("\n"); let hash = 0x811c9dc5; for (let i = 0; i < parts.length; i++) { hash ^= parts.charCodeAt(i); hash = (hash * 0x01000193) >>> 0; } return hash.toString(16).padStart(8, "0"); } function sessionLookup(history: Array<{ role: string; content: string }>): string | null { if (history.length === 0) return null; const key = sessionKey(history); const entry = sessionCache.get(key); if (!entry) return null; if (Date.now() - entry.ts > SESSION_MAX_AGE_MS) { sessionCache.delete(key); return null; } return entry.backendUuid; } function sessionStore( history: Array<{ role: string; content: string }>, currentMsg: string, responseText: string, backendUuid: string | null ): void { if (!backendUuid) return; const full = [ ...history, { role: "user", content: currentMsg }, { role: "assistant", content: responseText }, ]; const key = sessionKey(full); sessionCache.set(key, { backendUuid, ts: Date.now() }); if (sessionCache.size > SESSION_MAX_ENTRIES) { let oldestKey: string | null = null; let oldestTs = Infinity; for (const [k, v] of sessionCache) { if (v.ts < oldestTs) { oldestTs = v.ts; oldestKey = k; } } if (oldestKey) sessionCache.delete(oldestKey); } } // ─── Helpers ──────────────────────────────────────────────────────────────── function cleanResponse(text: string, strip = true): string { let t = text; t = t.replace(XML_DECL_RE, ""); t = t.replace(CITATION_RE, ""); t = t.replace(GROK_TAG_RE, ""); t = t.replace(GROK_SELF_RE, ""); t = t.replace(RESPONSE_TAG_RE, ""); if (strip) { t = t.replace(MULTI_SPACE, " "); t = t.replace(MULTI_NL, "\n\n"); t = t.trim(); } return t; } // ─── SSE types ────────────────────────────────────────────────────────────── interface PplxBlock { intended_usage?: string; markdown_block?: { answer?: string; chunks?: string[]; progress?: string; chunk_starting_offset?: number; }; web_result_block?: { web_results?: Array<{ url?: string; name?: string; snippet?: string }>; }; plan_block?: { steps?: Array<{ step_type?: string; search_web_content?: { queries?: Array<{ query?: string }> }; read_results_content?: { urls?: string[] }; }>; goals?: Array<{ description?: string }>; }; } interface PplxStreamEvent { status?: string; final?: boolean; text?: string; blocks?: PplxBlock[]; backend_uuid?: string; web_results?: Array<{ url?: string; name?: string }>; error_code?: string; error_message?: string; display_model?: string; } // ─── SSE parsing ──────────────────────────────────────────────────────────── async function* readPplxSseEvents( body: ReadableStream, signal?: AbortSignal | null ): AsyncGenerator { const reader = body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let dataLines: string[] = []; function flush(): PplxStreamEvent | null | "done" { if (dataLines.length === 0) return null; const payload = dataLines.join("\n"); dataLines = []; const trimmed = payload.trim(); if (!trimmed || trimmed === "[DONE]") return "done"; try { return JSON.parse(trimmed) as PplxStreamEvent; } catch { return null; } } try { while (true) { if (signal?.aborted) return; const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); while (true) { const idx = buffer.indexOf("\n"); if (idx < 0) break; const rawLine = buffer.slice(0, idx); buffer = buffer.slice(idx + 1); const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; if (line === "") { const parsed = flush(); if (parsed === "done") return; if (parsed) yield parsed; continue; } if (line.startsWith("data:")) { dataLines.push(line.slice(5).trimStart()); } if (line === "event: end_of_stream") { return; } } } buffer += decoder.decode(); if (buffer.trim().startsWith("data:")) { dataLines.push(buffer.trim().slice(5).trimStart()); } const tail = flush(); if (tail && tail !== "done") yield tail; } finally { reader.releaseLock(); } } // ─── OpenAI → Perplexity translation ──────────────────────────────────────── interface ParsedMessages { systemMsg: string; history: Array<{ role: string; content: string }>; currentMsg: string; } function parseOpenAIMessages(messages: Array>): ParsedMessages { let systemMsg = ""; const history: Array<{ role: string; content: string }> = []; for (const msg of messages) { let role = String(msg.role || "user"); if (role === "developer") role = "system"; let content = ""; if (typeof msg.content === "string") { content = msg.content; } else if (Array.isArray(msg.content)) { content = (msg.content as Array>) .filter((c) => c.type === "text") .map((c) => String(c.text || "")) .join(" "); } if (!content.trim()) continue; if (role === "system") { systemMsg += content + "\n"; } else if (role === "user" || role === "assistant") { history.push({ role, content }); } } let currentMsg = ""; if (history.length > 0 && history[history.length - 1].role === "user") { currentMsg = history.pop()!.content; } return { systemMsg, history, currentMsg }; } function buildPplxRequestBody( query: string, mode: string, modelPref: string, followUpUuid: string | null ): Record { const tz = typeof Intl !== "undefined" ? Intl.DateTimeFormat().resolvedOptions().timeZone : "UTC"; return { query_str: query, params: { query_str: query, search_focus: "internet", mode, model_preference: modelPref, sources: ["web"], attachments: [], frontend_uuid: crypto.randomUUID(), frontend_context_uuid: crypto.randomUUID(), version: PPLX_API_VERSION, language: "en-US", timezone: tz, search_recency_filter: null, is_incognito: true, use_schematized_api: true, last_backend_uuid: followUpUuid, }, }; } function buildQuery(parsed: ParsedMessages, followUpUuid: string | null): string { if (followUpUuid) return parsed.currentMsg; const obj: Record = {}; if (parsed.systemMsg.trim()) { obj.instructions = [ parsed.systemMsg.trim(), "You have built-in web search. Answer questions directly using search results.", ]; } if (parsed.history.length > 0) { obj.history = parsed.history; } if (parsed.currentMsg) { obj.query = parsed.currentMsg; } else if (parsed.history.length === 0) { obj.query = ""; } const json = JSON.stringify(obj); return json.length > 96000 ? json.slice(-96000) : json; } // ─── Content extraction ───────────────────────────────────────────────────── interface ContentChunk { delta?: string; answer?: string; backendUuid?: string; thinking?: string; error?: string; done?: boolean; } async function* extractContent( eventStream: ReadableStream, signal?: AbortSignal | null ): AsyncGenerator { let fullAnswer = ""; let backendUuid: string | null = null; let seenLen = 0; const seenThinking = new Set(); for await (const event of readPplxSseEvents(eventStream, signal)) { if (event.error_code || event.error_message) { yield { error: event.error_message || `Perplexity error: ${event.error_code}`, done: true, }; return; } if (event.backend_uuid) backendUuid = event.backend_uuid; const blocks = event.blocks ?? []; for (const block of blocks) { const usage = block.intended_usage ?? ""; // Thinking: search steps if (usage === "pro_search_steps" && block.plan_block?.steps) { for (const step of block.plan_block.steps) { if (step.step_type === "SEARCH_WEB") { for (const q of step.search_web_content?.queries ?? []) { const qr = q.query ?? ""; if (qr && !seenThinking.has(qr)) { seenThinking.add(qr); yield { thinking: `Searching: ${qr}`, backendUuid: backendUuid ?? undefined }; } } } else if (step.step_type === "READ_RESULTS") { for (const u of (step.read_results_content?.urls ?? []).slice(0, 3)) { if (u && !seenThinking.has(u)) { seenThinking.add(u); yield { thinking: `Reading: ${u}`, backendUuid: backendUuid ?? undefined }; } } } } } // Thinking: plan goals if (usage === "plan" && block.plan_block?.goals) { for (const goal of block.plan_block.goals) { const desc = goal.description ?? ""; if (desc && !seenThinking.has(desc)) { seenThinking.add(desc); yield { thinking: desc, backendUuid: backendUuid ?? undefined }; } } } // Content: markdown blocks if (!usage.includes("markdown")) continue; const mb = block.markdown_block; if (!mb) continue; const chunks = mb.chunks ?? []; if (chunks.length === 0) continue; if (mb.progress === "DONE") { fullAnswer = chunks.join(""); } else { const chunkText = chunks.join(""); const cumulative = fullAnswer + chunkText; if (cumulative.length > seenLen) { const delta = cumulative.slice(seenLen); fullAnswer = cumulative; seenLen = cumulative.length; yield { delta, answer: fullAnswer, backendUuid: backendUuid ?? undefined }; } } } // Fallback: text field if (blocks.length === 0 && event.text) { const t = event.text.trim(); if (t.length > seenLen) { const delta = t.slice(seenLen); fullAnswer = t; seenLen = t.length; yield { delta, answer: fullAnswer, backendUuid: backendUuid ?? undefined }; } } if (event.final || event.status === "COMPLETED") break; } yield { delta: "", answer: fullAnswer, backendUuid: backendUuid ?? undefined, done: true }; } // ─── OpenAI SSE format ────────────────────────────────────────────────────── function sseChunk(data: unknown): string { return `data: ${JSON.stringify(data)}\n\n`; } function buildStreamingResponse( eventStream: ReadableStream, model: string, cid: string, created: number, history: Array<{ role: string; content: string }>, currentMsg: string, signal?: AbortSignal | null ): ReadableStream { const encoder = new TextEncoder(); return new ReadableStream( { async start(controller) { try { // Initial role chunk controller.enqueue( encoder.encode( sseChunk({ id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: null, choices: [ { index: 0, delta: { role: "assistant" }, finish_reason: null, logprobs: null }, ], }) ) ); let fullAnswer = ""; let respBackendUuid: string | null = null; for await (const chunk of extractContent(eventStream, signal)) { if (chunk.backendUuid) respBackendUuid = chunk.backendUuid; if (chunk.error) { controller.enqueue( encoder.encode( sseChunk({ id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: null, choices: [ { index: 0, delta: { content: `[Error: ${chunk.error}]` }, finish_reason: null, logprobs: null, }, ], }) ) ); break; } if (chunk.thinking) { controller.enqueue( encoder.encode( sseChunk({ id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: null, choices: [ { index: 0, delta: { reasoning_content: chunk.thinking + "\n" }, finish_reason: null, logprobs: null, }, ], }) ) ); continue; } if (chunk.done) { fullAnswer = chunk.answer || fullAnswer; break; } let dt = chunk.delta || ""; if (dt) { dt = cleanResponse(dt, false); if (dt) { controller.enqueue( encoder.encode( sseChunk({ id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: null, choices: [ { index: 0, delta: { content: dt }, finish_reason: null, logprobs: null }, ], }) ) ); } } if (chunk.answer) fullAnswer = chunk.answer; } // Stop chunk controller.enqueue( encoder.encode( sseChunk({ id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: null, choices: [{ index: 0, delta: {}, finish_reason: "stop", logprobs: null }], }) ) ); controller.enqueue(encoder.encode("data: [DONE]\n\n")); sessionStore(history, currentMsg, cleanResponse(fullAnswer), respBackendUuid); } catch (err) { controller.enqueue( encoder.encode( sseChunk({ id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: null, choices: [ { index: 0, delta: { content: `[Stream error: ${err instanceof Error ? err.message : String(err)}]`, }, finish_reason: "stop", logprobs: null, }, ], }) ) ); controller.enqueue(encoder.encode("data: [DONE]\n\n")); } finally { try { controller.close(); } catch {} } }, }, { highWaterMark: 16384 } ); } async function buildNonStreamingResponse( eventStream: ReadableStream, model: string, cid: string, created: number, history: Array<{ role: string; content: string }>, currentMsg: string, signal?: AbortSignal | null ): Promise { let fullAnswer = ""; let respBackendUuid: string | null = null; const thinkingParts: string[] = []; for await (const chunk of extractContent(eventStream, signal)) { if (chunk.backendUuid) respBackendUuid = chunk.backendUuid; if (chunk.error) { return new Response( JSON.stringify({ error: { message: chunk.error, type: "upstream_error", code: "PPLX_ERROR" }, }), { status: 502, headers: { "Content-Type": "application/json" } } ); } if (chunk.thinking) { thinkingParts.push(chunk.thinking); continue; } if (chunk.done) { fullAnswer = chunk.answer || fullAnswer; break; } if (chunk.answer) fullAnswer = chunk.answer; } fullAnswer = cleanResponse(fullAnswer); sessionStore(history, currentMsg, fullAnswer, respBackendUuid); const reasoningContent = thinkingParts.length > 0 ? thinkingParts.join("\n") : undefined; const msg: Record = { role: "assistant", content: fullAnswer }; if (reasoningContent) msg.reasoning_content = reasoningContent; const promptTokens = Math.ceil(currentMsg.length / 4); const completionTokens = Math.ceil(fullAnswer.length / 4); return new Response( JSON.stringify({ id: cid, object: "chat.completion", created, model, system_fingerprint: null, choices: [{ index: 0, message: msg, finish_reason: "stop", logprobs: null }], usage: { prompt_tokens: promptTokens, completion_tokens: completionTokens, total_tokens: promptTokens + completionTokens, }, }), { status: 200, headers: { "Content-Type": "application/json" } } ); } // ─── Executor ─────────────────────────────────────────────────────────────── export class PerplexityWebExecutor extends BaseExecutor { constructor() { super("perplexity-web", { id: "perplexity-web", baseUrl: PPLX_SSE_ENDPOINT }); } async execute({ model, body, stream, credentials, signal, log }: ExecuteInput) { const messages = (body as Record).messages as | Array> | undefined; if (!messages || !Array.isArray(messages) || messages.length === 0) { const errResp = new Response( JSON.stringify({ error: { message: "Missing or empty messages array", type: "invalid_request" }, }), { status: 400, headers: { "Content-Type": "application/json" } } ); return { response: errResp, url: PPLX_SSE_ENDPOINT, headers: {}, transformedBody: body }; } // Resolve thinking mode const bodyObj = body as Record; const thinking = bodyObj.thinking === true || (bodyObj.reasoning_effort != null && bodyObj.reasoning_effort !== "none"); let pplxMode: string; let modelPref: string; if (thinking && THINKING_MAP[model]) { pplxMode = "copilot"; modelPref = THINKING_MAP[model]; log?.info?.("PPLX-WEB", `Thinking mode → ${model} using ${modelPref}`); } else if (MODEL_MAP[model]) { [pplxMode, modelPref] = MODEL_MAP[model]; } else { pplxMode = "copilot"; modelPref = model; log?.info?.("PPLX-WEB", `Unmapped model ${model}, using as raw preference`); } // Parse messages and check session continuity const parsed = parseOpenAIMessages(messages); const followUpUuid = sessionLookup(parsed.history); if (followUpUuid) { log?.info?.("PPLX-WEB", `Session continue: ${followUpUuid.slice(0, 12)}...`); } const query = buildQuery(parsed, followUpUuid); if (!query.trim()) { const errResp = new Response( JSON.stringify({ error: { message: "Empty query after processing", type: "invalid_request" }, }), { status: 400, headers: { "Content-Type": "application/json" } } ); return { response: errResp, url: PPLX_SSE_ENDPOINT, headers: {}, transformedBody: body }; } // Build Perplexity request const pplxBody = buildPplxRequestBody(query, pplxMode, modelPref, followUpUuid); const headers: Record = { "Content-Type": "application/json", Accept: "text/event-stream", Origin: "https://www.perplexity.ai", Referer: "https://www.perplexity.ai/", "User-Agent": PPLX_USER_AGENT, "X-App-ApiClient": "default", "X-App-ApiVersion": PPLX_API_VERSION, }; if (credentials.accessToken) { headers["Authorization"] = `Bearer ${credentials.accessToken}`; } else if (credentials.apiKey) { headers["Cookie"] = `__Secure-next-auth.session-token=${credentials.apiKey}`; } log?.info?.( "PPLX-WEB", `Query to ${model} (pref=${modelPref}, mode=${pplxMode}), len=${query.length}` ); // Fetch from Perplexity through the Firefox-fingerprinted TLS client. // Perplexity sits behind Cloudflare Enterprise which pins JA3/JA4 to a real // browser handshake; Node's fetch() is challenged with a 403 page from // VPS/datacenter IPs even with a valid cookie (issue #2459). let response: TlsFetchResult; try { response = await tlsFetchPerplexity(PPLX_SSE_ENDPOINT, { method: "POST", headers, body: JSON.stringify(pplxBody), signal: signal ?? null, stream: true, streamEofSymbol: "[DONE]", }); } catch (err) { const isTlsUnavail = err instanceof TlsClientUnavailableError; log?.error?.("PPLX-WEB", `Fetch failed: ${err instanceof Error ? err.message : String(err)}`); const errResp = new Response( JSON.stringify({ error: { message: isTlsUnavail ? `Perplexity TLS client unavailable: ${(err as Error).message}` : `Perplexity connection failed: ${err instanceof Error ? err.message : String(err)}`, type: "upstream_error", }, }), { status: 502, headers: { "Content-Type": "application/json" } } ); return { response: errResp, url: PPLX_SSE_ENDPOINT, headers, transformedBody: pplxBody }; } if (response.status !== 200 || (!response.body && !response.text)) { const status = response.status; let errMsg = `Perplexity returned HTTP ${status}`; if (status === 401 || status === 403) { if (isCloudflareChallenge(response.text)) { errMsg = "Cloudflare blocked the request — Perplexity's edge rejected this server's TLS fingerprint " + "(common on VPS/datacenter IPs). Ensure tls-client-node is installed with its native binary, " + "or route perplexity-web through a residential proxy."; log?.error?.("PPLX-WEB", "Cloudflare challenge detected — TLS bypass failed"); } else { errMsg = "Perplexity auth failed — session cookie may be expired. Re-paste your __Secure-next-auth.session-token."; } } else if (status === 429) { errMsg = "Perplexity rate limited. Wait a moment and retry."; } log?.warn?.("PPLX-WEB", errMsg); const errResp = new Response( JSON.stringify({ error: { message: errMsg, type: "upstream_error", code: `HTTP_${status}` }, }), { status, headers: { "Content-Type": "application/json" } } ); return { response: errResp, url: PPLX_SSE_ENDPOINT, headers, transformedBody: pplxBody }; } if (!response.body) { const errResp = new Response( JSON.stringify({ error: { message: "Perplexity returned empty response body", type: "upstream_error" }, }), { status: 502, headers: { "Content-Type": "application/json" } } ); return { response: errResp, url: PPLX_SSE_ENDPOINT, headers, transformedBody: pplxBody }; } // Build OpenAI-compatible response const cid = `chatcmpl-pplx-${crypto.randomUUID().slice(0, 12)}`; const created = Math.floor(Date.now() / 1000); let finalResponse: Response; if (stream) { const sseStream = buildStreamingResponse( response.body, model, cid, created, parsed.history, parsed.currentMsg, signal ); finalResponse = new Response(sseStream, { status: 200, headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "X-Accel-Buffering": "no", }, }); } else { finalResponse = await buildNonStreamingResponse( response.body, model, cid, created, parsed.history, parsed.currentMsg, signal ); } return { response: finalResponse, url: PPLX_SSE_ENDPOINT, headers, transformedBody: pplxBody, }; } }