Implement perplexity login/render modules and harden OAuth search flow

This commit is contained in:
Ivan Pereira
2026-02-17 08:25:56 +00:00
parent 39b8ee6b57
commit f42531b07c
7 changed files with 855 additions and 39 deletions
+210 -20
View File
@@ -10,8 +10,15 @@ const DESKTOP_AUTH_HELP =
const OTP_AUTH_HELP =
"Provide credentials via PI_PERPLEXITY_EMAIL and PI_PERPLEXITY_OTP, or run interactively to enter email and OTP.";
const AUTH_BASE_URL = "https://www.perplexity.ai/api/auth";
const AUTH_SESSION_URL = `${AUTH_BASE_URL}/session`;
const PERPLEXITY_USER_AGENT = "Perplexity/641 CFNetwork/1568 Darwin/25.2.0";
const PERPLEXITY_API_VERSION = "2.18";
const SESSION_COOKIE_NAMES = [
"__Secure-next-auth.session-token",
"next-auth.session-token",
"__Secure-authjs.session-token",
"authjs.session-token",
] as const;
const execFileAsync = promisify(execFile);
@@ -26,26 +33,162 @@ function normalizeInput(value: string | null | undefined): string | null {
return trimmed ? trimmed : null;
}
function buildAuthHeaders(includeJsonContentType = false): Record<string, string> {
function decodeCookieValue(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function isLikelyPerplexityToken(value: unknown): value is string {
if (typeof value !== "string") {
return false;
}
const token = value.trim();
if (!token || token === "(null)") {
return false;
}
return token.includes(".") && token.length >= 20;
}
function buildAuthHeaders(
includeJsonContentType = false,
cookieHeader?: string,
): Record<string, string> {
return {
Accept: "application/json",
...(includeJsonContentType ? { "Content-Type": "application/json" } : {}),
...(cookieHeader ? { Cookie: cookieHeader } : {}),
"User-Agent": PERPLEXITY_USER_AGENT,
"X-App-ApiVersion": PERPLEXITY_API_VERSION,
};
}
function getSetCookieValues(headers: Headers): string[] {
const withSetCookie = headers as Headers & {
getSetCookie?: () => string[];
raw?: () => Record<string, string[]>;
};
if (typeof withSetCookie.getSetCookie === "function") {
return withSetCookie.getSetCookie();
}
if (typeof withSetCookie.raw === "function") {
const raw = withSetCookie.raw();
const setCookies = raw["set-cookie"];
if (Array.isArray(setCookies)) {
return setCookies;
}
}
const single = headers.get("set-cookie");
return single ? [single] : [];
}
class CookieJar {
#cookies = new Map<string, string>();
capture(headers: Headers): void {
for (const setCookie of getSetCookieValues(headers)) {
const firstPart = setCookie.split(";")[0]?.trim();
if (!firstPart) {
continue;
}
const eqIndex = firstPart.indexOf("=");
if (eqIndex <= 0) {
continue;
}
const name = firstPart.slice(0, eqIndex).trim();
const value = firstPart.slice(eqIndex + 1).trim();
if (!name || !value) {
continue;
}
this.#cookies.set(name, decodeCookieValue(value));
}
}
toHeader(): string | undefined {
if (this.#cookies.size === 0) {
return undefined;
}
return [...this.#cookies.entries()].map(([name, value]) => `${name}=${value}`).join("; ");
}
get(name: string): string | undefined {
return this.#cookies.get(name);
}
}
function extractTokenFromCookies(headers: Headers, jar?: CookieJar): string | null {
for (const cookieName of SESSION_COOKIE_NAMES) {
const fromJar = jar?.get(cookieName);
if (isLikelyPerplexityToken(fromJar)) {
return fromJar;
}
}
for (const setCookie of getSetCookieValues(headers)) {
for (const cookieName of SESSION_COOKIE_NAMES) {
const pattern = new RegExp(`(?:^|[;,]\\s*)${cookieName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}=([^;]+)`);
const match = setCookie.match(pattern);
if (!match) {
continue;
}
const decoded = decodeCookieValue(match[1] ?? "");
if (isLikelyPerplexityToken(decoded)) {
return decoded;
}
}
}
return null;
}
function extractTokenFromPayload(payload: unknown): string | null {
if (!payload || typeof payload !== "object") {
return null;
}
const candidate = payload as Record<string, unknown>;
const possible = [candidate.token, candidate.accessToken, candidate.jwt];
const tokenKeyPattern = /(token|jwt|access)/i;
const queue: unknown[] = [payload];
const seen = new Set<object>();
for (const token of possible) {
if (typeof token === "string" && token.split(".").length === 3) {
return token;
while (queue.length > 0) {
const current = queue.shift();
if (!current || typeof current !== "object") {
continue;
}
if (seen.has(current)) {
continue;
}
seen.add(current);
if (Array.isArray(current)) {
for (const value of current) {
queue.push(value);
}
continue;
}
for (const [key, value] of Object.entries(current as Record<string, unknown>)) {
if (tokenKeyPattern.test(key) && isLikelyPerplexityToken(value)) {
return value.trim();
}
if (value && typeof value === "object") {
queue.push(value);
}
}
}
@@ -64,12 +207,16 @@ async function loginWithEmailOtp(
email: string,
options: AuthenticateOptions,
): Promise<string> {
const cookieJar = new CookieJar();
const csrfResponse = await fetch(`${AUTH_BASE_URL}/csrf`, {
method: "GET",
headers: buildAuthHeaders(),
headers: buildAuthHeaders(false, cookieJar.toHeader()),
signal: options.signal,
});
cookieJar.capture(csrfResponse.headers);
if (!csrfResponse.ok) {
throw new Error(`Failed to fetch CSRF token (HTTP ${csrfResponse.status}).`);
}
@@ -84,11 +231,13 @@ async function loginWithEmailOtp(
const emailResponse = await fetch(`${AUTH_BASE_URL}/signin-email`, {
method: "POST",
headers: buildAuthHeaders(true),
headers: buildAuthHeaders(true, cookieJar.toHeader()),
body: JSON.stringify({ email, csrfToken }),
signal: options.signal,
});
cookieJar.capture(emailResponse.headers);
if (!emailResponse.ok) {
throw new Error(`Failed to send OTP email (HTTP ${emailResponse.status}).`);
}
@@ -106,23 +255,58 @@ async function loginWithEmailOtp(
const otpResponse = await fetch(`${AUTH_BASE_URL}/signin-otp`, {
method: "POST",
headers: buildAuthHeaders(true),
headers: buildAuthHeaders(true, cookieJar.toHeader()),
body: JSON.stringify({ email, otp, csrfToken }),
signal: options.signal,
});
cookieJar.capture(otpResponse.headers);
if (!otpResponse.ok) {
throw new Error(`OTP verification failed (HTTP ${otpResponse.status}).`);
}
const otpPayload = await readJsonResponse(otpResponse);
const token = extractTokenFromPayload(otpPayload);
if (!token) {
throw new Error("Perplexity OTP response did not include a JWT token.");
const directToken = extractTokenFromPayload(otpPayload);
if (directToken) {
return directToken;
}
return token;
const cookieToken = extractTokenFromCookies(otpResponse.headers, cookieJar);
if (cookieToken) {
return cookieToken;
}
const sessionResponse = await fetch(AUTH_SESSION_URL, {
method: "GET",
headers: buildAuthHeaders(false, cookieJar.toHeader()),
signal: options.signal,
});
cookieJar.capture(sessionResponse.headers);
if (sessionResponse.ok) {
const sessionPayload = await readJsonResponse(sessionResponse);
const sessionToken = extractTokenFromPayload(sessionPayload);
if (sessionToken) {
return sessionToken;
}
const sessionCookieToken = extractTokenFromCookies(sessionResponse.headers, cookieJar);
if (sessionCookieToken) {
return sessionCookieToken;
}
}
const otpBodyHint = otpPayload ? JSON.stringify(otpPayload).slice(0, 300) : "(empty or non-JSON)";
const otpKeys =
otpPayload && typeof otpPayload === "object"
? Object.keys(otpPayload as Record<string, unknown>).join(", ")
: "N/A";
throw new Error(
`Perplexity OTP response did not include an access token. OTP keys: ${otpKeys}. OTP body preview: ${otpBodyHint}. Session status: ${sessionResponse.status}.`,
);
}
/** Extract JWT from macOS Perplexity desktop app via `defaults read`. Returns null if app not installed or not logged in. */
@@ -133,8 +317,8 @@ export async function extractFromDesktopApp(): Promise<string | null> {
try {
const { stdout } = await execFileAsync("defaults", ["read", "ai.perplexity.mac", "authToken"]);
const token = stdout.trim();
if (!token || token.split(".").length !== 3) {
const token = normalizeInput(stdout);
if (!token || token === "(null)") {
return null;
}
@@ -150,6 +334,10 @@ export async function authenticate(options: AuthenticateOptions = {}): Promise<s
let sawExpiredToken = false;
if (cached) {
if (cached.expires > Date.now()) {
return cached.access;
}
if (!isJwtExpired(cached.access)) {
return cached.access;
}
@@ -173,13 +361,14 @@ export async function authenticate(options: AuthenticateOptions = {}): Promise<s
}
if (desktopToken) {
if (isJwtExpired(desktopToken)) {
const desktopExpiry = decodeJwtExpiry(desktopToken);
if (desktopExpiry <= Date.now()) {
sawExpiredToken = true;
} else {
await saveToken({
type: "oauth",
access: desktopToken,
expires: decodeJwtExpiry(desktopToken),
expires: desktopExpiry,
});
return desktopToken;
@@ -220,7 +409,8 @@ export async function authenticate(options: AuthenticateOptions = {}): Promise<s
);
}
if (isJwtExpired(otpToken)) {
const otpExpiry = decodeJwtExpiry(otpToken);
if (otpExpiry <= Date.now()) {
throw new AuthError(
"EXPIRED",
`Perplexity returned an expired token from OTP login. Re-run login and verify OTP freshness. ${OTP_AUTH_HELP}`,
@@ -230,7 +420,7 @@ export async function authenticate(options: AuthenticateOptions = {}): Promise<s
await saveToken({
type: "oauth",
access: otpToken,
expires: decodeJwtExpiry(otpToken),
expires: otpExpiry,
email,
});
+117
View File
@@ -0,0 +1,117 @@
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { authenticate } from "../auth/login.js";
import { clearToken } from "../auth/storage.js";
import { AuthError } from "../search/types.js";
const LOGIN_COMMAND_NAME = "perplexity-login";
interface ParsedCommandArgs {
forceRefresh: boolean;
showHelp: boolean;
unknown: string[];
}
function parseCommandArgs(args: string): ParsedCommandArgs {
const tokens = args
.split(/\s+/)
.map((token) => token.trim())
.filter((token) => token.length > 0);
let forceRefresh = false;
let showHelp = false;
const unknown: string[] = [];
for (const token of tokens) {
if (token === "--force" || token === "--refresh" || token === "-f") {
forceRefresh = true;
continue;
}
if (token === "--help" || token === "-h") {
showHelp = true;
continue;
}
unknown.push(token);
}
return { forceRefresh, showHelp, unknown };
}
function usageText(): string {
return `Usage: /${LOGIN_COMMAND_NAME} [--force]\n\nFlags:\n --force, --refresh, -f Clear cached token before login\n --help, -h Show this help`;
}
export function registerPerplexityCommands(pi: ExtensionAPI): void {
pi.registerCommand(LOGIN_COMMAND_NAME, {
description: "Authenticate Perplexity and persist token",
handler: async (args, ctx) => {
const parsed = parseCommandArgs(args);
if (parsed.showHelp) {
ctx.ui.notify(usageText(), "info");
return;
}
if (parsed.unknown.length > 0) {
ctx.ui.notify(
`Unknown arguments: ${parsed.unknown.join(" ")}\n\n${usageText()}`,
"warning",
);
return;
}
if (parsed.forceRefresh) {
await clearToken().catch(() => undefined);
}
let canceledAtEmailPrompt = false;
let canceledAtOtpPrompt = false;
const promptForEmail = async (): Promise<string | undefined> => {
const value = await ctx.ui.input("Perplexity email", "you@example.com");
if (!value?.trim()) {
canceledAtEmailPrompt = true;
return undefined;
}
return value;
};
const promptForOtp = async (email: string): Promise<string | undefined> => {
const value = await ctx.ui.input(`Enter OTP sent to ${email}`, "123456");
if (!value?.trim()) {
canceledAtOtpPrompt = true;
return undefined;
}
return value;
};
try {
await authenticate({
promptForEmail,
promptForOtp,
});
ctx.ui.notify("Perplexity login successful. Token saved.", "info");
} catch (error) {
if (canceledAtEmailPrompt || canceledAtOtpPrompt) {
ctx.ui.notify(
"Perplexity login canceled. Re-run /perplexity-login and provide email + OTP, or set PI_PERPLEXITY_EMAIL and PI_PERPLEXITY_OTP.",
"warning",
);
return;
}
if (error instanceof AuthError) {
ctx.ui.notify(`Perplexity login failed: ${error.message}`, "error");
return;
}
ctx.ui.notify(`Perplexity login failed: ${(error as Error).message || "Unknown error"}`, "error");
}
},
});
}
+7
View File
@@ -2,13 +2,18 @@ import { StringEnum } from "@mariozechner/pi-ai";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { registerPerplexityCommands } from "./commands/login.js";
import { authenticate } from "./auth/login.js";
import { clearToken } from "./auth/storage.js";
import { formatForLLM } from "./search/format.js";
import { searchPerplexity } from "./search/client.js";
import { renderPerplexityCall } from "./render/call.js";
import { renderPerplexityResult } from "./render/result.js";
import { AuthError, SearchError } from "./search/types.js";
export default function (pi: ExtensionAPI) {
registerPerplexityCommands(pi);
pi.registerTool({
name: "perplexity_search",
label: "Perplexity Search",
@@ -24,6 +29,8 @@ export default function (pi: ExtensionAPI) {
Type.Number({ description: "Max sources to return", minimum: 1, maximum: 50 }),
),
}),
renderCall: renderPerplexityCall,
renderResult: renderPerplexityResult,
async execute(toolCallId, params, signal, onUpdate, ctx) {
const start = Date.now();
let sourceCount = 0;
+52
View File
@@ -0,0 +1,52 @@
import type { Theme } from "@mariozechner/pi-coding-agent";
import { Text } from "@mariozechner/pi-tui";
interface PerplexityCallArgs {
query?: unknown;
recency?: unknown;
limit?: unknown;
}
const RECENCY_VALUES = new Set(["hour", "day", "week", "month", "year"] as const);
function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function asPositiveNumber(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return undefined;
}
return value;
}
function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) {
return text;
}
return `${text.slice(0, Math.max(1, maxLength - 1))}`;
}
export function renderPerplexityCall(args: PerplexityCallArgs, theme: Theme): Text {
const query = asString(args?.query)?.trim();
const recencyRaw = asString(args?.recency)?.trim().toLowerCase();
const recency = recencyRaw && RECENCY_VALUES.has(recencyRaw as (typeof RECENCY_VALUES extends Set<infer T> ? T : never))
? recencyRaw
: undefined;
const limit = asPositiveNumber(args?.limit);
let text = theme.fg("toolTitle", theme.bold("perplexity_search "));
text += query ? theme.fg("muted", truncate(query, 90)) : theme.fg("warning", "(missing query)");
if (recency) {
text += theme.fg("dim", `${recency}`);
}
if (typeof limit === "number") {
text += theme.fg("dim", ` • limit ${Math.round(limit)}`);
}
return new Text(text, 0, 0);
}
+124
View File
@@ -0,0 +1,124 @@
import type { AgentToolResult, Theme, ToolRenderResultOptions } from "@mariozechner/pi-coding-agent";
import { Text } from "@mariozechner/pi-tui";
interface PerplexityResultDetails {
model?: unknown;
sourceCount?: unknown;
queryMs?: unknown;
uuid?: unknown;
toolCallId?: unknown;
error?: unknown;
}
function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function asNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) {
return text;
}
return `${text.slice(0, Math.max(1, maxLength - 1))}`;
}
function extractTextContent(result: AgentToolResult<PerplexityResultDetails>): string | undefined {
if (!Array.isArray(result?.content)) {
return undefined;
}
for (const item of result.content) {
if (
item &&
typeof item === "object" &&
"type" in item &&
item.type === "text" &&
"text" in item &&
typeof item.text === "string"
) {
const text = item.text.trim();
if (text.length > 0) {
return text;
}
}
}
return undefined;
}
function isErrorText(text: string | undefined): boolean {
if (!text) {
return false;
}
return text.startsWith("Authentication failed:") || text.startsWith("Perplexity search failed:");
}
export function renderPerplexityResult(
result: AgentToolResult<PerplexityResultDetails>,
options: ToolRenderResultOptions,
theme: Theme,
): Text {
const details = (result?.details ?? {}) as PerplexityResultDetails;
const contentText = extractTextContent(result);
if (options?.isPartial) {
let partial = theme.fg("warning", "Perplexity: searching…");
if (contentText) {
partial += `\n${theme.fg("dim", truncate(contentText.replace(/\s+/g, " "), 140))}`;
}
return new Text(partial, 0, 0);
}
const error = asString(details.error)?.trim();
if (error) {
return new Text(theme.fg("error", `Perplexity error: ${error}`), 0, 0);
}
if (isErrorText(contentText)) {
return new Text(theme.fg("error", truncate(contentText ?? "Perplexity request failed", 200)), 0, 0);
}
const sourceCount = asNumber(details.sourceCount);
const queryMs = asNumber(details.queryMs);
const model = asString(details.model)?.trim();
const uuid = asString(details.uuid)?.trim();
let text = theme.fg("success", "✓ Perplexity");
if (typeof sourceCount === "number") {
text += theme.fg("muted", `${sourceCount} source${sourceCount === 1 ? "" : "s"}`);
}
if (typeof queryMs === "number") {
text += theme.fg("dim", `${(queryMs / 1000).toFixed(queryMs < 1000 ? 2 : 1)}s`);
}
if (!options?.expanded) {
if (contentText) {
const oneLine = contentText.replace(/\s+/g, " ").trim();
if (oneLine.length > 0) {
text += `\n${theme.fg("dim", truncate(oneLine, 160))}`;
}
}
return new Text(text, 0, 0);
}
if (model) {
text += `\n${theme.fg("dim", `model: ${model}`)}`;
}
if (uuid) {
text += `\n${theme.fg("dim", `id: ${uuid}`)}`;
}
if (contentText) {
text += `\n\n${truncate(contentText, 2400)}`;
}
return new Text(text, 0, 0);
}
+188 -18
View File
@@ -1,9 +1,16 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { mergeEvent, readSseEvents } from "./stream.js";
import type { SearchResult, StreamEvent, WebResult } from "./types.js";
import { SearchError } from "./types.js";
const PERPLEXITY_ENDPOINT = "https://www.perplexity.ai/rest/sse/perplexity_ask";
const PERPLEXITY_USER_AGENT = "Perplexity/641 CFNetwork/1568 Darwin/25.2.0";
const MAX_BUN_STDOUT_BYTES = 50 * 1024 * 1024;
const CLOUDFLARE_HINTS = ["just a moment", "cloudflare", "cf-chl", "cf-ray"];
const execFileAsync = promisify(execFile);
export interface SearchParams {
query: string;
@@ -11,6 +18,12 @@ export interface SearchParams {
limit?: number;
}
interface BunFetchResult {
status: number;
contentType: string | null;
bodyText: string;
}
function normalizeUrl(url: string): string {
return url.trim().replace(/\/$/, "").toLowerCase();
}
@@ -127,8 +140,45 @@ function buildRequestBody(params: SearchParams): Record<string, unknown> {
};
}
function mapHttpError(status: number): SearchError {
function buildRequestHeaders(jwt: string, requestId: string): Record<string, string> {
return {
Authorization: `Bearer ${jwt}`,
"Content-Type": "application/json",
Accept: "text/event-stream",
Origin: "https://www.perplexity.ai",
Referer: "https://www.perplexity.ai/",
"User-Agent": PERPLEXITY_USER_AGENT,
"X-App-ApiClient": "default",
"X-App-ApiVersion": "2.18",
"X-Perplexity-Request-Reason": "submit",
"X-Request-ID": requestId,
};
}
function isCloudflareChallenge(status: number, contentType: string | null, bodyText: string): boolean {
if (status !== 403) {
return false;
}
const contentTypeLower = (contentType ?? "").toLowerCase();
const bodyLower = bodyText.toLowerCase();
if (!contentTypeLower.includes("text/html")) {
return false;
}
return CLOUDFLARE_HINTS.some((hint) => bodyLower.includes(hint));
}
function mapHttpError(status: number, bodyText = "", contentType: string | null = null): SearchError {
if (status === 401 || status === 403) {
if (isCloudflareChallenge(status, contentType, bodyText)) {
return new SearchError(
"NETWORK",
"Perplexity request was blocked by Cloudflare challenge in this runtime. Retry via Bun runtime fallback or desktop app token path.",
);
}
return new SearchError(
"AUTH",
"Perplexity rejected authentication (401/403). Sign in to Perplexity desktop app and retry.",
@@ -148,6 +198,100 @@ function mapHttpError(status: number): SearchError {
);
}
function streamFromText(text: string): ReadableStream<Uint8Array> {
const bytes = new TextEncoder().encode(text);
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bytes);
controller.close();
},
});
}
async function fetchViaBunRuntime(
requestBody: Record<string, unknown>,
jwt: string,
requestId: string,
signal?: AbortSignal,
): Promise<BunFetchResult> {
const script = `
const endpoint = process.env.PI_PPLX_ENDPOINT;
const token = process.env.PI_PPLX_TOKEN;
const body = JSON.parse(process.env.PI_PPLX_BODY || "{}");
const requestId = process.env.PI_PPLX_REQUEST_ID || crypto.randomUUID();
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: \`Bearer \${token}\`,
"Content-Type": "application/json",
Accept: "text/event-stream",
Origin: "https://www.perplexity.ai",
Referer: "https://www.perplexity.ai/",
"User-Agent": "${PERPLEXITY_USER_AGENT}",
"X-App-ApiClient": "default",
"X-App-ApiVersion": "2.18",
"X-Perplexity-Request-Reason": "submit",
"X-Request-ID": requestId,
},
body: JSON.stringify(body),
});
const text = await response.text();
process.stdout.write(JSON.stringify({
status: response.status,
contentType: response.headers.get("content-type"),
bodyText: text,
}));
} catch (error) {
process.stdout.write(JSON.stringify({
status: 0,
contentType: null,
bodyText: String(error && error.message ? error.message : error),
}));
}
`;
const { stdout } = await execFileAsync(
"bun",
["-e", script],
{
encoding: "utf8",
maxBuffer: MAX_BUN_STDOUT_BYTES,
signal,
env: {
...process.env,
PI_PPLX_ENDPOINT: PERPLEXITY_ENDPOINT,
PI_PPLX_TOKEN: jwt,
PI_PPLX_BODY: JSON.stringify(requestBody),
PI_PPLX_REQUEST_ID: requestId,
},
},
);
let parsed: unknown;
try {
parsed = JSON.parse(stdout);
} catch {
throw new Error("Bun fallback returned non-JSON output.");
}
if (!parsed || typeof parsed !== "object") {
throw new Error("Bun fallback returned invalid payload.");
}
const result = parsed as Partial<BunFetchResult>;
if (typeof result.status !== "number" || typeof result.bodyText !== "string") {
throw new Error("Bun fallback response missing required fields.");
}
return {
status: result.status,
contentType: typeof result.contentType === "string" ? result.contentType : null,
bodyText: result.bodyText,
};
}
/** Execute a Perplexity search: POST SSE, stream/merge events, extract answer + sources. Throws SearchError on failure. */
export async function searchPerplexity(
params: SearchParams,
@@ -155,24 +299,15 @@ export async function searchPerplexity(
signal?: AbortSignal,
): Promise<SearchResult> {
const requestId = crypto.randomUUID();
const requestBody = buildRequestBody(params);
const requestHeaders = buildRequestHeaders(jwt, requestId);
let response: Response;
try {
response = await fetch(PERPLEXITY_ENDPOINT, {
method: "POST",
headers: {
Authorization: `Bearer ${jwt}`,
"Content-Type": "application/json",
Accept: "text/event-stream",
Origin: "https://www.perplexity.ai",
Referer: "https://www.perplexity.ai/",
"User-Agent": PERPLEXITY_USER_AGENT,
"X-App-ApiClient": "default",
"X-App-ApiVersion": "2.18",
"X-Perplexity-Request-Reason": "submit",
"X-Request-ID": requestId,
},
body: JSON.stringify(buildRequestBody(params)),
headers: requestHeaders,
body: JSON.stringify(requestBody),
signal,
});
} catch (error) {
@@ -186,18 +321,53 @@ export async function searchPerplexity(
);
}
let eventStream: ReadableStream<Uint8Array> | null = null;
if (!response.ok) {
throw mapHttpError(response.status);
let bodyText = "";
try {
bodyText = await response.text();
} catch {
bodyText = "";
}
const contentType = response.headers.get("content-type");
if (isCloudflareChallenge(response.status, contentType, bodyText)) {
let bunResult: BunFetchResult;
try {
bunResult = await fetchViaBunRuntime(requestBody, jwt, requestId, signal);
} catch (error) {
throw new SearchError(
"NETWORK",
`Perplexity request hit Cloudflare challenge and Bun fallback failed: ${(error as Error).message || "unknown error"}`,
);
}
if (bunResult.status !== 200) {
throw mapHttpError(bunResult.status, bunResult.bodyText, bunResult.contentType);
}
eventStream = streamFromText(bunResult.bodyText);
} else {
throw mapHttpError(response.status, bodyText, contentType);
}
} else {
if (!response.body) {
throw new SearchError("STREAM", "Perplexity returned an empty stream body.");
}
eventStream = response.body;
}
if (!response.body) {
throw new SearchError("STREAM", "Perplexity returned an empty stream body.");
if (!eventStream) {
throw new SearchError("STREAM", "Perplexity returned no readable stream.");
}
let snapshot: StreamEvent = {};
try {
for await (const event of readSseEvents(response.body, signal)) {
for await (const event of readSseEvents(eventStream, signal)) {
snapshot = mergeEvent(snapshot, event);
if (event.final || event.status === "COMPLETED") {
break;