Implement perplexity login/render modules and harden OAuth search flow
This commit is contained in:
+210
-20
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user