import os import re import json import time import uuid import logging import tempfile import threading from pathlib import Path from datetime import datetime from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError from logging.handlers import RotatingFileHandler from curl_cffi import requests as cffi_requests from perplexity import Client, LabsClient, Emailnator from perplexity.config import DEFAULT_HEADERS, EMAILNATOR_BASE_URL, EMAILNATOR_HEADERS, MODEL_MAPPINGS, LABS_MODELS from perplexity.exceptions import PerplexityError, AuthenticationError, QueryLimitExceededError, AccountCreationError from perplexity.utils import retry_with_backoff, sanitize_query from perplexity.logger import get_logger from fastapi import FastAPI, Request, HTTPException from fastapi.responses import StreamingResponse, JSONResponse, HTMLResponse from fastapi.middleware.cors import CORSMiddleware from starlette.middleware.base import BaseHTTPMiddleware logger = get_logger("app") ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "") API_KEY = os.environ.get("API_KEY", "") _data_candidate = Path(os.environ.get("DATA_DIR", "/data")) DATA_DIR = _data_candidate if _data_candidate.is_dir() and os.access(_data_candidate, os.W_OK) else Path(tempfile.gettempdir()) / "pplx" DATA_DIR.mkdir(parents=True, exist_ok=True) COOKIES_FILE = DATA_DIR / "pplx_cookies.json" EMAILNATOR_COOKIES_FILE = DATA_DIR / "emailnator_cookies.json" HISTORY_FILE = DATA_DIR / "pplx_history.json" LOG_FILE = DATA_DIR / "perplexity.log" THREADS_FILE = DATA_DIR / "pplx_threads.json" LOCK = threading.Lock() ACCOUNT_LOCK = threading.Lock() EXECUTOR = ThreadPoolExecutor(max_workers=2) ACCOUNT_CREATE_TIMEOUT = 120 file_handler = RotatingFileHandler(str(LOG_FILE), maxBytes=5*1024*1024, backupCount=3) file_handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")) logging.getLogger("perplexity").addHandler(file_handler) logging.getLogger("app").addHandler(file_handler) MODE_MAP = { "auto": "auto", "pro": "pro", "reasoning": "reasoning", "deep-research": "deep research", "deep_research": "deep research", "sonar": "pro", "sonar2": "pro", "sonar2-pro": "pro", "sonar2-deep-research": "deep research", } VALID_MODEL_NAMES = set() for mode_models in MODEL_MAPPINGS.values(): for k in mode_models: if k is not None: VALID_MODEL_NAMES.add(k) def _content_to_str(content): if isinstance(content, str): return content if isinstance(content, list): return " ".join(b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") in ("text", "input_text")) return str(content) # ─── Thread / Conversation Store ─────────────────────────────────────────────── def _load_threads(): if THREADS_FILE.exists(): try: with open(THREADS_FILE, "r") as f: return json.load(f) except Exception: pass return {} def _save_threads(threads): with LOCK: with open(THREADS_FILE, "w") as f: json.dump(threads, f) def _get_thread(thread_id): threads = _load_threads() return threads.get(thread_id) def _update_thread(thread_id, data): threads = _load_threads() if thread_id not in threads: threads[thread_id] = { "id": thread_id, "created_at": datetime.utcnow().isoformat(), "messages": [], "backend_uuid": None, "frontend_context_uuid": None, "mode": "auto", } threads[thread_id].update(data) _save_threads(threads) return threads[thread_id] def _build_context_query(messages): if len(messages) <= 1: return None, _content_to_str(messages[-1].get("content", "")) if messages else "" context_parts = [] for m in messages[:-1]: role = m.get("role", "user") content = _content_to_str(m.get("content", "")) if role == "system": continue prefix = "User" if role == "user" else "Assistant" context_parts.append(f"{prefix}: {content}") current_query = _content_to_str(messages[-1].get("content", "")) if messages else "" context = "\n".join(context_parts) if context: full_query = f"[Previous conversation context]\n{context}\n\n[Current question]\n{current_query}" else: full_query = current_query return context, full_query # ─── Cookie / Account Management ─────────────────────────────────────────────── def load_cookies(): if COOKIES_FILE.exists(): try: with open(COOKIES_FILE, "r") as f: data = json.load(f) if data.get("cookies") and time.time() - data.get("saved_at", 0) < 86400: return data except Exception: pass return None def save_cookies(cookies, copilot_remaining=5, file_upload_remaining=10): with LOCK: with open(COOKIES_FILE, "w") as f: json.dump({ "cookies": cookies, "saved_at": time.time(), "copilot_remaining": copilot_remaining, "file_upload_remaining": file_upload_remaining, }, f) def _load_emailnator_cookies(): if EMAILNATOR_COOKIES_FILE.exists(): try: with open(EMAILNATOR_COOKIES_FILE, "r") as f: return json.load(f) except Exception: pass return {} def _save_emailnator_cookies(cookies): with LOCK: with open(EMAILNATOR_COOKIES_FILE, "w") as f: json.dump(cookies, f) def _init_emailnator_session(): cookies = _load_emailnator_cookies() session = cffi_requests.Session( headers=EMAILNATOR_HEADERS.copy(), impersonate="chrome", ) if cookies: for k, v in cookies.items(): session.cookies.set(k, v) try: session.get(EMAILNATOR_BASE_URL, timeout=10) _save_emailnator_cookies(session.cookies.get_dict()) except Exception: pass return session def _get_csrf_token(session): try: resp = session.get("https://www.perplexity.ai/api/auth/csrf", timeout=15) if resp.ok: return resp.json().get("csrfToken", "") except Exception: pass for cookie in session.cookies: if cookie.name == "__Host-next-auth.csrf-token": val = cookie.value.split("%7C")[0] if "%7C" in cookie.value else cookie.value return val return "" def _extract_signin_link(email_body): pattern = r'https://www\.perplexity\.ai/api/auth/callback/email\?[^"\s<>]+' match = re.search(pattern, email_body) if match: link = match.group(0) if link.endswith("="): link = link[:-1] return link return None def _create_account_emailnator(session, csrf): emailnator_cli = Emailnator() emailnator_cli.get_email() email = emailnator_cli.email if not email: return None signin_resp = session.post( "https://www.perplexity.ai/api/auth/signin/email", json={"email": email, "csrfToken": csrf}, headers={"content-type": "application/json"}, timeout=15, ) if not signin_resp.ok: return None try: msg = emailnator_cli.get(func=lambda x: x["subject"] == "Sign in to Perplexity") link = _extract_signin_link(emailnator_cli.open(msg["messageID"])) return link except Exception: return None def _create_account_guerrilla(session, csrf): try: gu_session = cffi_requests.Session(impersonate="chrome", timeout=15) gu_resp = gu_session.get("https://api.guerrillamail.com/ajax.php?f=get_email_address") if not gu_resp.ok: return None email = gu_resp.json().get("email_addr", "") if not email: return None signin_resp = session.post( "https://www.perplexity.ai/api/auth/signin/email", json={"email": email, "csrfToken": csrf}, headers={"content-type": "application/json"}, timeout=15, ) if not signin_resp.ok: return None sid_token = gu_resp.json().get("sid_token", "") for attempt in range(8): time.sleep(3) check = gu_session.get( f"https://api.guerrillamail.com/ajax.php?f=check_email&sid_token={sid_token}&seq=0" ) if not check.ok: continue emails = check.json().get("list", []) for em in emails: if "Perplexity" in em.get("mail_subject", ""): mail_body = gu_session.get( f"https://api.guerrillamail.com/ajax.php?f=fetch_email&sid_token={sid_token}&email_id={em['mail_id']}" ).json().get("mail_body", "") link = _extract_signin_link(mail_body) if link: return link return None except Exception as e: logger.warning(f"Guerrilla Mail failed: {e}") return None @retry_with_backoff(max_attempts=2, exceptions=(AccountCreationError,)) def auto_create_account(): with ACCOUNT_LOCK: session = cffi_requests.Session( headers=DEFAULT_HEADERS.copy(), impersonate="chrome", ) session.get("https://www.perplexity.ai/api/auth/session", timeout=15) csrf = _get_csrf_token(session) if not csrf: raise AccountCreationError("No CSRF token available") signin_link = _create_account_guerrilla(session, csrf) if not signin_link: logger.info("Guerrilla Mail failed, trying Emailnator fallback...") session.get("https://www.perplexity.ai/api/auth/session", timeout=15) csrf = _get_csrf_token(session) if not csrf: raise AccountCreationError("No CSRF token available (fallback)") signin_link = _create_account_emailnator(session, csrf) if not signin_link: raise AccountCreationError("Both Guerrilla Mail and Emailnator failed") session.get(signin_link, timeout=15) final_cookies = {k: v for k, v in session.cookies.get_dict().items()} return final_cookies def _get_client_for_mode(mode): needs_auth = mode in ("pro", "reasoning", "deep research") if not needs_auth: return Client() stored = load_cookies() if stored and stored.get("cookies"): client = Client(stored["cookies"]) client.copilot = stored.get("copilot_remaining", 5) client.file_upload = stored.get("file_upload_remaining", 10) if client.copilot <= 0: logger.info("Copilot credits exhausted, creating new account...") else: return client logger.info("Creating new account...") future = EXECUTOR.submit(auto_create_account) try: cookies = future.result(timeout=ACCOUNT_CREATE_TIMEOUT) except FuturesTimeoutError: raise AccountCreationError("Account creation timed out after {}s".format(ACCOUNT_CREATE_TIMEOUT)) client = Client(cookies) save_cookies(cookies, client.copilot, client.file_upload) return client # ─── Search with Thread Context ──────────────────────────────────────────────── def search_sync(query, mode="auto", model=None, sources="web", language="en-US", cookies_str="", follow_up=None, thread_id=None): try: query = sanitize_query(query) sources_list = [s.strip() for s in sources.split(",")] if sources else ["web"] model_val = model if model and model in VALID_MODEL_NAMES else None thread = _get_thread(thread_id) if thread_id else None if thread and thread.get("backend_uuid"): follow_up = { "backend_uuid": thread["backend_uuid"], "frontend_context_uuid": thread.get("frontend_context_uuid", str(uuid.uuid4())), "attachments": [], } logger.info(f"Using follow_up for thread {thread_id}: backend_uuid={thread['backend_uuid']}") if cookies_str and cookies_str.strip(): try: manual_cookies = json.loads(cookies_str) client = Client(manual_cookies) save_cookies(manual_cookies, client.copilot, client.file_upload) except Exception: client = _get_client_for_mode(mode) else: client = _get_client_for_mode(mode) resp = client.search( query=query, mode=mode, model=model_val, sources=sources_list, language=language, follow_up=follow_up, ) if mode in ("pro", "reasoning", "deep research"): save_cookies( client.session.cookies.get_dict() if hasattr(client.session, "cookies") else {}, client.copilot, client.file_upload, ) answer = resp.get("answer", "No answer returned") if thread_id: backend_uuid = resp.get("backend_uuid") or resp.get("uuid") or (resp.get("text", [{}])[-1].get("uuid") if isinstance(resp.get("text"), list) else None) frontend_context_uuid = resp.get("frontend_context_uuid") or str(uuid.uuid4()) _update_thread(thread_id, { "mode": mode, "backend_uuid": backend_uuid or (thread.get("backend_uuid") if thread else None), "frontend_context_uuid": frontend_context_uuid, }) return answer except PerplexityError as e: logger.error(f"Search error: {e}") if thread_id and thread and thread.get("backend_uuid"): logger.info(f"Session may be expired for thread {thread_id}, retrying without follow_up") thread["backend_uuid"] = None _update_thread(thread_id, {"backend_uuid": None}) raise except Exception as e: logger.error(f"Unexpected search error: {e}") raise def stream_search(query, mode="auto", model=None, sources="web", language="en-US", cookies_str="", follow_up=None, thread_id=None): try: query = sanitize_query(query) sources_list = [s.strip() for s in sources.split(",")] if sources else ["web"] model_val = model if model and model in VALID_MODEL_NAMES else None thread = _get_thread(thread_id) if thread_id else None if thread and thread.get("backend_uuid"): follow_up = { "backend_uuid": thread["backend_uuid"], "frontend_context_uuid": thread.get("frontend_context_uuid", str(uuid.uuid4())), "attachments": [], } logger.info(f"Using follow_up for thread {thread_id}: backend_uuid={thread['backend_uuid']}") if cookies_str and cookies_str.strip(): try: manual_cookies = json.loads(cookies_str) client = Client(manual_cookies) save_cookies(manual_cookies, client.copilot, client.file_upload) except Exception: client = _get_client_for_mode(mode) else: client = _get_client_for_mode(mode) backend_uuid = None frontend_context_uuid = None for chunk in client.search( query=query, mode=mode, model=model_val, sources=sources_list, language=language, stream=True, follow_up=follow_up, ): if "answer" in chunk: yield chunk["answer"] if not backend_uuid and chunk.get("backend_uuid"): backend_uuid = chunk["backend_uuid"] if not backend_uuid and chunk.get("uuid"): backend_uuid = chunk["uuid"] if not frontend_context_uuid and chunk.get("frontend_context_uuid"): frontend_context_uuid = chunk["frontend_context_uuid"] if thread_id: _update_thread(thread_id, { "mode": mode, "backend_uuid": backend_uuid or (thread.get("backend_uuid") if thread else None), "frontend_context_uuid": frontend_context_uuid or str(uuid.uuid4()), }) except PerplexityError as e: logger.error(f"Stream search error: {e}") if thread_id and thread and thread.get("backend_uuid"): logger.info(f"Session may be expired for thread {thread_id}, clearing backend_uuid") _update_thread(thread_id, {"backend_uuid": None}) yield "Error: {}".format(str(e)) except Exception as e: logger.error(f"Unexpected stream search error: {e}") yield "Error: {}".format(str(e)) # ─── Logging ─────────────────────────────────────────────────────────────────── def _log_query(query, mode, model, answer_len, success=True, thread_id=None): try: history = [] if HISTORY_FILE.exists(): with open(HISTORY_FILE, "r") as f: history = json.load(f) history.append({ "timestamp": datetime.utcnow().isoformat(), "query": query[:200], "mode": mode, "model": model or "default", "answer_len": answer_len, "success": success, "thread_id": thread_id, }) history = history[-500:] with open(HISTORY_FILE, "w") as f: json.dump(history, f) except Exception: pass # ─── OpenAI Format Helpers ───────────────────────────────────────────────────── def _extract_query(messages): for m in reversed(messages): if m.get("role") == "user": return _content_to_str(m.get("content", "")) return "" def _extract_sources(messages): for m in messages: if m.get("role") == "system": s = _content_to_str(m.get("content", "")).lower() if "scholar" in s: return "scholar" if "news" in s: return "news" return "web" def _extract_thread_id(messages): for m in messages: if m.get("role") == "system": tid = re.search(r"thread[_\-:]?\s*(\S+)", _content_to_str(m.get("content", "")), re.I) if tid: return tid.group(1) return None def _build_openai_chunk(req_id, created, model, delta, finish_reason=None): chunk = { "id": req_id, "object": "chat.completion.chunk", "created": created, "model": model, "choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}], } return json.dumps(chunk) def _build_openai_response(req_id, created, model, content): return { "id": req_id, "object": "chat.completion", "created": created, "model": model, "choices": [{"index": 0, "message": {"role": "assistant", "content": content}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, } def _extract_query_from_input(input_data): if isinstance(input_data, str): return input_data if isinstance(input_data, list): for item in reversed(input_data): if isinstance(item, dict) and item.get("role") == "user": return _content_to_str(item.get("content", "")) if isinstance(item, dict) and item.get("type") == "message": return _content_to_str(item.get("content", "")) return "" def _extract_system_from_input(input_data): if isinstance(input_data, list): for item in input_data: if isinstance(item, dict): role = item.get("role", "") if role == "system": return _content_to_str(item.get("content", "")) if item.get("type") == "system": return _content_to_str(item.get("content", "")) return "" def _build_responses_sse_event(event_type, data_dict): return "event: {}\ndata: {}\n\n".format(event_type, json.dumps(data_dict)) # ─── Admin UI ────────────────────────────────────────────────────────────────── ADMIN_HTML = """
Auto-creates free Perplexity accounts. Each account gets 5 copilot queries and 10 file uploads. v2 preserves sessions for multi-turn conversations.
Active threads with Perplexity session context. Backend UUID enables follow-up queries without resending full history.
| Mode | Model | Internal | Auth |
|---|
| Model | Protocol |
|---|