fix(config): apply defaults to perplexity UI
This commit is contained in:
@@ -56,6 +56,8 @@ Once installed, the agent automatically calls `perplexity_search` whenever it ne
|
||||
| `query` | string | ✅ | The search query |
|
||||
| `recency` | string | — | Filter by age: `hour` · `day` · `week` · `month` · `year` |
|
||||
| `limit` | number | — | Max sources to include (1–50) |
|
||||
| `model` | string | — | Model preference, e.g. `pplx_pro_upgraded`, `pplx_pro`, `experimental`, `gpt54`, `gpt54_thinking`, `claude46sonnet`, `claude46sonnetthinking`, `gemini31pro_high`, `nv_nemotron_3_super`, `pplx_reasoning`, `pplx_alpha` |
|
||||
| `incognito` | boolean | — | Whether to hide the search from Perplexity history; defaults to `true` |
|
||||
|
||||
### Output format
|
||||
|
||||
@@ -80,7 +82,7 @@ Provider: perplexity (oauth)
|
||||
Model: pplx_pro_upgraded
|
||||
```
|
||||
|
||||
All queries use `is_incognito: true` — nothing shows up in your Perplexity history.
|
||||
Queries default to `is_incognito: true`, but you can override that per call or via config.
|
||||
|
||||
## How It Works
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import {
|
||||
getConfigPath as defaultGetConfigPath,
|
||||
loadConfig as defaultLoadConfig,
|
||||
saveConfig as defaultSaveConfig,
|
||||
type PerplexityConfig,
|
||||
} from "../config.js";
|
||||
|
||||
const KNOWN_MODELS: { value: string; label: string }[] = [
|
||||
{ value: "pplx_pro_upgraded", label: "Best (auto)" },
|
||||
{ value: "pplx_pro", label: "Default Pro" },
|
||||
{ value: "experimental", label: "Sonar" },
|
||||
{ value: "gpt54", label: "GPT-5.4" },
|
||||
{ value: "gpt54_thinking", label: "GPT-5.4 Thinking" },
|
||||
{ value: "claude46sonnet", label: "Claude 4.6 Sonnet" },
|
||||
{ value: "claude46sonnetthinking", label: "Claude 4.6 Sonnet Thinking" },
|
||||
{ value: "gemini31pro_high", label: "Gemini 3.1 Pro" },
|
||||
{ value: "nv_nemotron_3_super", label: "Nemotron 3 Super" },
|
||||
{ value: "pplx_reasoning", label: "Default Reasoning" },
|
||||
{ value: "pplx_alpha", label: "Deep Research" },
|
||||
];
|
||||
|
||||
function formatCurrentConfig(config: { model?: string; incognito?: boolean }): string {
|
||||
const model = config.model ?? "pplx_pro_upgraded (default)";
|
||||
const modelLabel = KNOWN_MODELS.find((m) => m.value === config.model)?.label;
|
||||
const modelDisplay = modelLabel ? `${model} (${modelLabel})` : model;
|
||||
const incognito = config.incognito ?? true;
|
||||
return `Model: ${modelDisplay}\nIncognito: ${incognito}`;
|
||||
}
|
||||
|
||||
interface ConfigCommandDeps {
|
||||
getConfigPath: () => string;
|
||||
loadConfig: () => Promise<PerplexityConfig>;
|
||||
saveConfig: (config: PerplexityConfig) => Promise<void>;
|
||||
}
|
||||
|
||||
export function registerPerplexityConfigCommand(
|
||||
pi: ExtensionAPI,
|
||||
deps: ConfigCommandDeps = {
|
||||
getConfigPath: defaultGetConfigPath,
|
||||
loadConfig: defaultLoadConfig,
|
||||
saveConfig: defaultSaveConfig,
|
||||
},
|
||||
): void {
|
||||
pi.registerCommand("perplexity-config", {
|
||||
description: "Configure Perplexity search defaults",
|
||||
handler: async (args, ctx) => {
|
||||
if (args.trim() === "--help" || args.trim() === "-h") {
|
||||
ctx.ui.notify(
|
||||
`Usage: /perplexity-config [--show]\n\nInteractively set default model and incognito mode.\nConfig stored at: ${deps.getConfigPath()}`,
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await deps.loadConfig();
|
||||
|
||||
if (args.trim() === "--show") {
|
||||
ctx.ui.notify(`Perplexity config (${deps.getConfigPath()}):\n${formatCurrentConfig(config)}`, "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const modelLabels = KNOWN_MODELS.map((m) => `${m.label} (${m.value})`);
|
||||
const selected = await ctx.ui.select("Default model", modelLabels);
|
||||
if (selected === undefined || selected === null) {
|
||||
ctx.ui.notify("Perplexity config unchanged.", "info");
|
||||
return;
|
||||
}
|
||||
const selectedModel = KNOWN_MODELS[modelLabels.indexOf(selected)]?.value ?? selected;
|
||||
|
||||
const incognito = await ctx.ui.confirm(
|
||||
"Incognito mode",
|
||||
"Hide searches from Perplexity web history? (recommended)",
|
||||
);
|
||||
if (incognito === undefined || incognito === null) {
|
||||
ctx.ui.notify("Perplexity config unchanged.", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
config.model = selectedModel;
|
||||
config.incognito = incognito;
|
||||
|
||||
await deps.saveConfig(config);
|
||||
ctx.ui.notify(`Perplexity config saved:\n${formatCurrentConfig(config)}`, "info");
|
||||
} catch (error) {
|
||||
ctx.ui.notify(
|
||||
`Failed to save Perplexity config: ${error instanceof Error ? error.message : String(error)}`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { mkdir, readFile, writeFile, chmod } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
export interface PerplexityConfig {
|
||||
model?: string;
|
||||
incognito?: boolean;
|
||||
}
|
||||
|
||||
const CONFIG_PATH = join(homedir(), ".config", "pi-perplexity", "config.json");
|
||||
|
||||
export function getConfigPath(): string {
|
||||
return CONFIG_PATH;
|
||||
}
|
||||
|
||||
function isEnoent(error: unknown): boolean {
|
||||
return error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT";
|
||||
}
|
||||
|
||||
function parseConfig(raw: string): PerplexityConfig {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Config file must contain a JSON object");
|
||||
}
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const config: PerplexityConfig = {};
|
||||
if (typeof obj.model === "string" && obj.model.length > 0) config.model = obj.model;
|
||||
if (typeof obj.incognito === "boolean") config.incognito = obj.incognito;
|
||||
return config;
|
||||
}
|
||||
|
||||
/** Load config from ~/.config/pi-perplexity/config.json. Returns {} if file doesn't exist. Throws on parse/IO errors. */
|
||||
export async function loadConfig(configPath: string = CONFIG_PATH): Promise<PerplexityConfig> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(configPath, "utf8");
|
||||
} catch (error) {
|
||||
if (isEnoent(error)) return {};
|
||||
throw error;
|
||||
}
|
||||
return parseConfig(raw);
|
||||
}
|
||||
|
||||
/** Save config to disk with 0600 permissions. */
|
||||
export async function saveConfig(config: PerplexityConfig, configPath: string = CONFIG_PATH): Promise<void> {
|
||||
await mkdir(dirname(configPath), { recursive: true });
|
||||
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
||||
await chmod(configPath, 0o600);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve effective values using priority: per-call param > env var > config file > default.
|
||||
* Returns the model and incognito values to use for a search.
|
||||
*/
|
||||
export function resolveSearchDefaults(
|
||||
params: { model?: string; incognito?: boolean },
|
||||
config: PerplexityConfig,
|
||||
): { model: string; incognito: boolean } {
|
||||
const envModel = process.env.PI_PERPLEXITY_MODEL || undefined;
|
||||
const envIncognito = process.env.PI_PERPLEXITY_INCOGNITO || undefined;
|
||||
|
||||
const model = params.model
|
||||
?? envModel
|
||||
?? config.model
|
||||
?? "pplx_pro_upgraded";
|
||||
|
||||
const incognito = params.incognito
|
||||
?? (envIncognito !== undefined ? envIncognito !== "false" && envIncognito !== "0" : undefined)
|
||||
?? config.incognito
|
||||
?? true;
|
||||
|
||||
return { model, incognito };
|
||||
}
|
||||
+35
-31
@@ -2,23 +2,25 @@ import { StringEnum } from "@mariozechner/pi-ai";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import { registerPerplexityConfigCommand } from "./commands/config.js";
|
||||
import { registerPerplexityCommands } from "./commands/login.js";
|
||||
|
||||
import { authenticate } from "./auth/login.js";
|
||||
|
||||
import { clearToken } from "./auth/storage.js";
|
||||
import { loadConfig, resolveSearchDefaults } from "./config.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";
|
||||
import { errorMessage } from "./render/util.js";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
registerPerplexityCommands(pi);
|
||||
registerPerplexityConfigCommand(pi);
|
||||
pi.registerTool({
|
||||
name: "perplexity_search",
|
||||
label: "Perplexity Search",
|
||||
description: "Search the web with your Perplexity subscription.",
|
||||
description: "Search the web using Perplexity",
|
||||
parameters: Type.Object({
|
||||
query: Type.String({ description: "Search query" }),
|
||||
recency: Type.Optional(
|
||||
@@ -29,6 +31,8 @@ export default function (pi: ExtensionAPI) {
|
||||
limit: Type.Optional(
|
||||
Type.Number({ description: "Max sources to return", minimum: 1, maximum: 50 }),
|
||||
),
|
||||
model: Type.Optional(Type.String({ description: "Model preference" })),
|
||||
incognito: Type.Optional(Type.Boolean({ description: "Hide search from Perplexity history" })),
|
||||
}),
|
||||
renderCall: renderPerplexityCall,
|
||||
renderResult: renderPerplexityResult,
|
||||
@@ -50,13 +54,11 @@ export default function (pi: ExtensionAPI) {
|
||||
return ctx.ui.input(label, placeholder);
|
||||
};
|
||||
|
||||
const authOptions: Parameters<typeof authenticate>[0] = {
|
||||
const jwt = await authenticate({
|
||||
...(signal !== undefined ? { signal } : {}),
|
||||
promptForEmail: async () => promptInput("Perplexity email", "you@example.com"),
|
||||
promptForOtp: async (email) => promptInput(`Enter OTP sent to ${email}`, "123456"),
|
||||
};
|
||||
if (signal) authOptions.signal = signal;
|
||||
|
||||
const jwt = await authenticate(authOptions);
|
||||
});
|
||||
|
||||
if (signal?.aborted) {
|
||||
return {
|
||||
@@ -70,13 +72,26 @@ export default function (pi: ExtensionAPI) {
|
||||
details: { toolCallId },
|
||||
});
|
||||
|
||||
const searchParams: Parameters<typeof searchPerplexity>[0] = {
|
||||
query: params.query,
|
||||
};
|
||||
if (params.recency) searchParams.recency = params.recency;
|
||||
if (typeof params.limit === "number") searchParams.limit = params.limit;
|
||||
const config = await loadConfig();
|
||||
const { model, incognito } = resolveSearchDefaults(
|
||||
{
|
||||
...(params.model !== undefined ? { model: params.model } : {}),
|
||||
...(params.incognito !== undefined ? { incognito: params.incognito } : {}),
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
const result = await searchPerplexity(searchParams, jwt, signal);
|
||||
const result = await searchPerplexity(
|
||||
{
|
||||
query: params.query,
|
||||
model,
|
||||
incognito,
|
||||
...(params.recency !== undefined ? { recency: params.recency } : {}),
|
||||
...(params.limit !== undefined ? { limit: params.limit } : {}),
|
||||
},
|
||||
jwt,
|
||||
signal,
|
||||
);
|
||||
|
||||
const formatted = formatForLLM(result, params.limit);
|
||||
sourceCount =
|
||||
@@ -88,6 +103,7 @@ export default function (pi: ExtensionAPI) {
|
||||
content: [{ type: "text", text: formatted }],
|
||||
details: {
|
||||
model: result.displayModel,
|
||||
incognito,
|
||||
sourceCount,
|
||||
queryMs: Date.now() - start,
|
||||
uuid: result.uuid,
|
||||
@@ -99,29 +115,17 @@ export default function (pi: ExtensionAPI) {
|
||||
if (error instanceof AuthError) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Authentication failed: ${error.message}` }],
|
||||
details: { sourceCount, queryMs, isError: true },
|
||||
details: { sourceCount, queryMs },
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof SearchError) {
|
||||
if (error.code === "AUTH") {
|
||||
// Do NOT clear the token here. The user must re-login explicitly via
|
||||
// /perplexity-login --force. Clearing automatically would silently discard
|
||||
// a token that may still be valid (e.g. a transient 401), and removes the
|
||||
// user's ability to inspect or recover the cached credential themselves.
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Perplexity authentication failed. Run /perplexity-login --force to re-authenticate.`,
|
||||
},
|
||||
],
|
||||
details: { sourceCount, queryMs, isError: true },
|
||||
};
|
||||
await clearToken().catch(() => undefined);
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: `Perplexity search failed: ${error.message}` }],
|
||||
details: { sourceCount, queryMs, isError: true },
|
||||
details: { sourceCount, queryMs },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -129,10 +133,10 @@ export default function (pi: ExtensionAPI) {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Perplexity search failed: ${errorMessage(error)}`,
|
||||
text: `Perplexity search failed: ${(error as Error).message || "Unknown error"}`,
|
||||
},
|
||||
],
|
||||
details: { sourceCount, queryMs, isError: true },
|
||||
details: { sourceCount, queryMs },
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,6 +6,8 @@ interface PerplexityCallArgs {
|
||||
query?: unknown;
|
||||
recency?: unknown;
|
||||
limit?: unknown;
|
||||
model?: unknown;
|
||||
incognito?: unknown;
|
||||
}
|
||||
|
||||
const RECENCY_VALUES = new Set(["hour", "day", "week", "month", "year"] as const);
|
||||
@@ -16,10 +18,20 @@ export function renderPerplexityCall(args: PerplexityCallArgs, theme: Theme): Te
|
||||
? recencyRaw
|
||||
: undefined;
|
||||
const limit = asPositiveInteger(args?.limit);
|
||||
const model = asString(args?.model)?.trim();
|
||||
const incognito = typeof args?.incognito === "boolean" ? args.incognito : undefined;
|
||||
|
||||
let text = theme.fg("toolTitle", theme.bold("perplexity_search "));
|
||||
text += query ? theme.fg("muted", truncate(query, 90)) : theme.fg("warning", "(missing query)");
|
||||
|
||||
if (model) {
|
||||
text += theme.fg("dim", ` • ${model}`);
|
||||
}
|
||||
|
||||
if (typeof incognito === "boolean") {
|
||||
text += theme.fg("dim", ` • incognito ${incognito ? "on" : "off"}`);
|
||||
}
|
||||
|
||||
if (recency) {
|
||||
text += theme.fg("dim", ` • ${recency}`);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { asString, asNumber, truncate } from "./util.js";
|
||||
|
||||
interface PerplexityResultDetails {
|
||||
model?: unknown;
|
||||
incognito?: unknown;
|
||||
sourceCount?: unknown;
|
||||
queryMs?: unknown;
|
||||
uuid?: unknown;
|
||||
@@ -69,9 +70,16 @@ export function renderPerplexityResult(
|
||||
const sourceCount = asNumber(details.sourceCount);
|
||||
const queryMs = asNumber(details.queryMs);
|
||||
const model = asString(details.model)?.trim();
|
||||
const incognito = typeof details.incognito === "boolean" ? details.incognito : undefined;
|
||||
const uuid = asString(details.uuid)?.trim();
|
||||
|
||||
let text = theme.fg("success", "✓ Perplexity");
|
||||
if (model) {
|
||||
text += theme.fg("dim", ` • ${model}`);
|
||||
}
|
||||
if (typeof incognito === "boolean") {
|
||||
text += theme.fg("dim", ` • incognito ${incognito ? "on" : "off"}`);
|
||||
}
|
||||
if (typeof sourceCount === "number") {
|
||||
text += theme.fg("muted", ` • ${sourceCount} source${sourceCount === 1 ? "" : "s"}`);
|
||||
}
|
||||
@@ -90,9 +98,6 @@ export function renderPerplexityResult(
|
||||
return new Text(text, 0, 0);
|
||||
}
|
||||
|
||||
if (model) {
|
||||
text += `\n${theme.fg("dim", `model: ${model}`)}`;
|
||||
}
|
||||
if (uuid) {
|
||||
text += `\n${theme.fg("dim", `id: ${uuid}`)}`;
|
||||
}
|
||||
|
||||
@@ -105,6 +105,8 @@ export interface SearchParams {
|
||||
query: string;
|
||||
recency?: "hour" | "day" | "week" | "month" | "year";
|
||||
limit?: number;
|
||||
model: string;
|
||||
incognito: boolean;
|
||||
}
|
||||
|
||||
function normalizeUrl(url: string): string {
|
||||
@@ -209,7 +211,7 @@ function buildRequestBody(params: SearchParams): Record<string, unknown> {
|
||||
query_str: query,
|
||||
search_focus: "internet",
|
||||
mode: "copilot",
|
||||
model_preference: "pplx_pro_upgraded",
|
||||
model_preference: params.model,
|
||||
sources: ["web"],
|
||||
attachments: [],
|
||||
frontend_uuid: crypto.randomUUID(),
|
||||
@@ -218,7 +220,7 @@ function buildRequestBody(params: SearchParams): Record<string, unknown> {
|
||||
language: "en-US",
|
||||
timezone,
|
||||
search_recency_filter: params.recency ?? null,
|
||||
is_incognito: true,
|
||||
is_incognito: params.incognito,
|
||||
use_schematized_api: true,
|
||||
skip_search_enabled: true,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { registerPerplexityConfigCommand } from "../src/commands/config.js";
|
||||
import { loadConfig, saveConfig } from "../src/config.js";
|
||||
|
||||
let tempDir: string;
|
||||
let configPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "pi-perplexity-command-test-"));
|
||||
configPath = join(tempDir, "config.json");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("perplexity-config command", () => {
|
||||
test("writes selected config to disk", async () => {
|
||||
let handler: ((args: string, ctx: any) => Promise<void>) | undefined;
|
||||
|
||||
registerPerplexityConfigCommand(
|
||||
{
|
||||
registerCommand(name: string, command: { handler: (args: string, ctx: any) => Promise<void> }) {
|
||||
expect(name).toBe("perplexity-config");
|
||||
handler = command.handler;
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
getConfigPath: () => configPath,
|
||||
loadConfig: () => loadConfig(configPath),
|
||||
saveConfig: (config) => saveConfig(config, configPath),
|
||||
},
|
||||
);
|
||||
|
||||
expect(handler).toBeDefined();
|
||||
|
||||
const notifications: Array<{ message: string; level: string }> = [];
|
||||
await handler!("", {
|
||||
ui: {
|
||||
select: async () => "GPT-5.4 (gpt54)",
|
||||
confirm: async () => false,
|
||||
notify: (message: string, level: string) => notifications.push({ message, level }),
|
||||
},
|
||||
});
|
||||
|
||||
const raw = await readFile(configPath, "utf8");
|
||||
expect(JSON.parse(raw)).toEqual({ model: "gpt54", incognito: false });
|
||||
expect(notifications).toContainEqual({
|
||||
message: "Perplexity config saved:\nModel: gpt54 (GPT-5.4)\nIncognito: false",
|
||||
level: "info",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { loadConfig, saveConfig, resolveSearchDefaults } from "../src/config.js";
|
||||
|
||||
let tempDir: string;
|
||||
let configPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "pi-perplexity-test-"));
|
||||
configPath = join(tempDir, "config.json");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("loadConfig", () => {
|
||||
test("returns empty object when file is missing", async () => {
|
||||
const config = await loadConfig(configPath);
|
||||
expect(config).toEqual({});
|
||||
});
|
||||
|
||||
test("returns parsed config from file", async () => {
|
||||
await writeFile(configPath, JSON.stringify({ model: "gpt54", incognito: false }));
|
||||
const config = await loadConfig(configPath);
|
||||
expect(config.model).toBe("gpt54");
|
||||
expect(config.incognito).toBe(false);
|
||||
});
|
||||
|
||||
test("throws on invalid JSON", async () => {
|
||||
await writeFile(configPath, "not json");
|
||||
await expect(loadConfig(configPath)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("throws on non-object JSON", async () => {
|
||||
await writeFile(configPath, '"just a string"');
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("must contain a JSON object");
|
||||
});
|
||||
|
||||
test("ignores unknown fields", async () => {
|
||||
await writeFile(configPath, JSON.stringify({ model: "gpt54", unknown: true }));
|
||||
const config = await loadConfig(configPath);
|
||||
expect(config.model).toBe("gpt54");
|
||||
expect(config).not.toHaveProperty("unknown");
|
||||
});
|
||||
|
||||
test("ignores empty model string", async () => {
|
||||
await writeFile(configPath, JSON.stringify({ model: "" }));
|
||||
const config = await loadConfig(configPath);
|
||||
expect(config).not.toHaveProperty("model");
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveConfig", () => {
|
||||
test("writes file with 0600 permissions", async () => {
|
||||
await saveConfig({ model: "claude46sonnetthinking", incognito: true }, configPath);
|
||||
|
||||
const raw = await readFile(configPath, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
expect(parsed.model).toBe("claude46sonnetthinking");
|
||||
expect(parsed.incognito).toBe(true);
|
||||
|
||||
const stats = await stat(configPath);
|
||||
expect(stats.mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
test("creates parent directories", async () => {
|
||||
const nested = join(tempDir, "a", "b", "config.json");
|
||||
await saveConfig({ model: "gpt54" }, nested);
|
||||
|
||||
const raw = await readFile(nested, "utf8");
|
||||
expect(JSON.parse(raw).model).toBe("gpt54");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSearchDefaults", () => {
|
||||
test("returns hardcoded defaults when no config, env, or params", () => {
|
||||
const result = resolveSearchDefaults({}, {});
|
||||
expect(result.model).toBe("pplx_pro_upgraded");
|
||||
expect(result.incognito).toBe(true);
|
||||
});
|
||||
|
||||
test("config file values override defaults", () => {
|
||||
const result = resolveSearchDefaults({}, { model: "gpt54", incognito: false });
|
||||
expect(result.model).toBe("gpt54");
|
||||
expect(result.incognito).toBe(false);
|
||||
});
|
||||
|
||||
test("env var '0' disables incognito", () => {
|
||||
const original = process.env.PI_PERPLEXITY_INCOGNITO;
|
||||
try {
|
||||
process.env.PI_PERPLEXITY_INCOGNITO = "0";
|
||||
const result = resolveSearchDefaults({}, {});
|
||||
expect(result.incognito).toBe(false);
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.PI_PERPLEXITY_INCOGNITO;
|
||||
else process.env.PI_PERPLEXITY_INCOGNITO = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("env vars override config file", () => {
|
||||
const originalModel = process.env.PI_PERPLEXITY_MODEL;
|
||||
const originalIncognito = process.env.PI_PERPLEXITY_INCOGNITO;
|
||||
try {
|
||||
process.env.PI_PERPLEXITY_MODEL = "experimental";
|
||||
process.env.PI_PERPLEXITY_INCOGNITO = "false";
|
||||
|
||||
const result = resolveSearchDefaults({}, { model: "gpt54", incognito: true });
|
||||
expect(result.model).toBe("experimental");
|
||||
expect(result.incognito).toBe(false);
|
||||
} finally {
|
||||
if (originalModel === undefined) delete process.env.PI_PERPLEXITY_MODEL;
|
||||
else process.env.PI_PERPLEXITY_MODEL = originalModel;
|
||||
if (originalIncognito === undefined) delete process.env.PI_PERPLEXITY_INCOGNITO;
|
||||
else process.env.PI_PERPLEXITY_INCOGNITO = originalIncognito;
|
||||
}
|
||||
});
|
||||
|
||||
test("per-call params override everything", () => {
|
||||
const originalModel = process.env.PI_PERPLEXITY_MODEL;
|
||||
try {
|
||||
process.env.PI_PERPLEXITY_MODEL = "experimental";
|
||||
|
||||
const result = resolveSearchDefaults(
|
||||
{ model: "claude46sonnetthinking", incognito: false },
|
||||
{ model: "gpt54", incognito: true },
|
||||
);
|
||||
expect(result.model).toBe("claude46sonnetthinking");
|
||||
expect(result.incognito).toBe(false);
|
||||
} finally {
|
||||
if (originalModel === undefined) delete process.env.PI_PERPLEXITY_MODEL;
|
||||
else process.env.PI_PERPLEXITY_MODEL = originalModel;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { afterEach, describe, expect, mock, test } from "bun:test";
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
describe("perplexity_search execute", () => {
|
||||
test("includes effective config values in the search request and result details", async () => {
|
||||
const authenticate = mock(async () => "jwt-token");
|
||||
const loadConfig = mock(async () => ({ model: "gpt54", incognito: false }));
|
||||
const resolveSearchDefaults = mock(() => ({ model: "gpt54", incognito: false }));
|
||||
const searchPerplexity = mock(async () => ({
|
||||
answer: "answer",
|
||||
sources: [{ url: "https://example.com" }],
|
||||
displayModel: "gpt54",
|
||||
uuid: "req-123",
|
||||
}));
|
||||
|
||||
mock.module("../src/auth/login.js", () => ({ authenticate }));
|
||||
mock.module("../src/config.js", () => ({
|
||||
getConfigPath: () => "/tmp/pi-perplexity-config.json",
|
||||
loadConfig,
|
||||
resolveSearchDefaults,
|
||||
saveConfig: mock(async () => undefined),
|
||||
}));
|
||||
mock.module("../src/search/client.js", () => ({ searchPerplexity }));
|
||||
|
||||
const { default: registerExtension } = await import(`../src/index.ts?test=${crypto.randomUUID()}`);
|
||||
|
||||
let execute: ((toolCallId: string, params: any, signal?: AbortSignal, onUpdate?: any, ctx?: any) => Promise<any>) | undefined;
|
||||
|
||||
registerExtension({
|
||||
registerCommand() {
|
||||
return undefined;
|
||||
},
|
||||
registerTool(tool: { execute: typeof execute }) {
|
||||
execute = tool.execute;
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(execute).toBeDefined();
|
||||
|
||||
const result = await execute!("tool-1", { query: "how many planets" }, undefined, undefined, { ui: {} });
|
||||
|
||||
expect(loadConfig).toHaveBeenCalledTimes(1);
|
||||
expect(resolveSearchDefaults).toHaveBeenCalledWith({}, { model: "gpt54", incognito: false });
|
||||
expect(searchPerplexity).toHaveBeenCalledWith(
|
||||
{
|
||||
query: "how many planets",
|
||||
model: "gpt54",
|
||||
incognito: false,
|
||||
},
|
||||
"jwt-token",
|
||||
undefined,
|
||||
);
|
||||
expect(result.details.incognito).toBe(false);
|
||||
expect(result.details.model).toBe("gpt54");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
describe("extension entrypoint", () => {
|
||||
test("registers the config command", async () => {
|
||||
const { default: registerExtension } = await import(`../src/index.ts?test=${crypto.randomUUID()}`);
|
||||
|
||||
const commands: string[] = [];
|
||||
|
||||
registerExtension({
|
||||
registerCommand(name: string) {
|
||||
commands.push(name);
|
||||
},
|
||||
registerTool() {
|
||||
return undefined;
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(commands).toContain("perplexity-login");
|
||||
expect(commands).toContain("perplexity-config");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { renderPerplexityCall } from "../src/render/call.js";
|
||||
import { renderPerplexityResult } from "../src/render/result.js";
|
||||
|
||||
const theme = {
|
||||
fg: (_color: string, text: string) => text,
|
||||
bold: (text: string) => text,
|
||||
} as any;
|
||||
|
||||
describe("renderPerplexityCall", () => {
|
||||
test("shows the selected model in the tool call row", () => {
|
||||
const rendered = renderPerplexityCall(
|
||||
{
|
||||
query: "latest bun release notes",
|
||||
model: "claude46sonnetthinking",
|
||||
recency: "week",
|
||||
limit: 5,
|
||||
},
|
||||
theme,
|
||||
).render(200).join("\n");
|
||||
|
||||
expect(rendered).toContain("claude46sonnetthinking");
|
||||
expect(rendered).toContain("week");
|
||||
expect(rendered).toContain("limit 5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderPerplexityResult", () => {
|
||||
test("shows the model in the collapsed success row", () => {
|
||||
const rendered = renderPerplexityResult(
|
||||
{
|
||||
content: [{ type: "text", text: "Result summary" }],
|
||||
details: {
|
||||
model: "gpt54",
|
||||
sourceCount: 3,
|
||||
queryMs: 800,
|
||||
},
|
||||
} as any,
|
||||
{ expanded: false, isPartial: false } as any,
|
||||
theme,
|
||||
).render(200).join("\n");
|
||||
|
||||
expect(rendered).toContain("gpt54");
|
||||
expect(rendered).toContain("3 sources");
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,7 @@ describe("searchPerplexity", () => {
|
||||
|
||||
const controller = new AbortController();
|
||||
const result = await searchPerplexity(
|
||||
{ query: "latest bun release notes", recency: "week" },
|
||||
{ query: "latest bun release notes", recency: "week", model: "pplx_pro_upgraded", incognito: true },
|
||||
"jwt-token",
|
||||
controller.signal,
|
||||
);
|
||||
@@ -92,11 +92,51 @@ describe("searchPerplexity", () => {
|
||||
expect(result.sources).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("passes model and incognito through to request body", async () => {
|
||||
let capturedInit: RequestInit | undefined;
|
||||
|
||||
globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
capturedInit = init;
|
||||
return createSseResponse([
|
||||
{ status: "COMPLETED", final: true, text: "answer", blocks: [] },
|
||||
]);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await searchPerplexity(
|
||||
{ query: "q", model: "claude46sonnetthinking", incognito: false },
|
||||
"jwt-token",
|
||||
);
|
||||
|
||||
const body = JSON.parse(String(capturedInit?.body)) as {
|
||||
params: { model_preference: string; is_incognito: boolean };
|
||||
};
|
||||
expect(body.params.model_preference).toBe("claude46sonnetthinking");
|
||||
expect(body.params.is_incognito).toBe(false);
|
||||
});
|
||||
|
||||
test("passes incognito true through to request body", async () => {
|
||||
let capturedInit: RequestInit | undefined;
|
||||
|
||||
globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
capturedInit = init;
|
||||
return createSseResponse([
|
||||
{ status: "COMPLETED", final: true, text: "answer", blocks: [] },
|
||||
]);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await searchPerplexity({ query: "q", model: "pplx_pro_upgraded", incognito: true }, "jwt-token");
|
||||
|
||||
const body = JSON.parse(String(capturedInit?.body)) as {
|
||||
params: { is_incognito: boolean };
|
||||
};
|
||||
expect(body.params.is_incognito).toBe(true);
|
||||
});
|
||||
|
||||
test("maps 401 and 403 responses to AUTH error", async () => {
|
||||
for (const status of [401, 403]) {
|
||||
globalThis.fetch = (async () => new Response("auth fail", { status })) as unknown as typeof fetch;
|
||||
|
||||
await expect(searchPerplexity({ query: "q" }, "jwt")).rejects.toMatchObject({
|
||||
await expect(searchPerplexity({ query: "q", model: "pplx_pro_upgraded", incognito: true }, "jwt")).rejects.toMatchObject({
|
||||
name: "SearchError",
|
||||
code: "AUTH",
|
||||
});
|
||||
@@ -106,7 +146,7 @@ describe("searchPerplexity", () => {
|
||||
test("maps 429 responses to RATE_LIMIT error", async () => {
|
||||
globalThis.fetch = (async () => new Response("rate limited", { status: 429 })) as unknown as typeof fetch;
|
||||
|
||||
await expect(searchPerplexity({ query: "q" }, "jwt")).rejects.toMatchObject({
|
||||
await expect(searchPerplexity({ query: "q", model: "pplx_pro_upgraded", incognito: true }, "jwt")).rejects.toMatchObject({
|
||||
name: "SearchError",
|
||||
code: "RATE_LIMIT",
|
||||
});
|
||||
@@ -134,7 +174,7 @@ describe("searchPerplexity", () => {
|
||||
},
|
||||
])) as unknown as typeof fetch;
|
||||
|
||||
const result = await searchPerplexity({ query: "q" }, "jwt");
|
||||
const result = await searchPerplexity({ query: "q", model: "pplx_pro_upgraded", incognito: true }, "jwt");
|
||||
|
||||
expect(result.sources).toHaveLength(2);
|
||||
expect(result.sources[0].url).toBe("https://example.com/path");
|
||||
@@ -156,7 +196,7 @@ describe("searchPerplexity", () => {
|
||||
},
|
||||
])) as unknown as typeof fetch;
|
||||
|
||||
const result = await searchPerplexity({ query: "q" }, "jwt");
|
||||
const result = await searchPerplexity({ query: "q", model: "pplx_pro_upgraded", incognito: true }, "jwt");
|
||||
expect(result.answer).toBe("markdown answer");
|
||||
});
|
||||
|
||||
@@ -174,7 +214,7 @@ describe("searchPerplexity", () => {
|
||||
},
|
||||
])) as unknown as typeof fetch;
|
||||
|
||||
const askTextResult = await searchPerplexity({ query: "q" }, "jwt");
|
||||
const askTextResult = await searchPerplexity({ query: "q", model: "pplx_pro_upgraded", incognito: true }, "jwt");
|
||||
expect(askTextResult.answer).toBe("ask answer");
|
||||
|
||||
globalThis.fetch = (async () =>
|
||||
@@ -187,7 +227,7 @@ describe("searchPerplexity", () => {
|
||||
},
|
||||
])) as unknown as typeof fetch;
|
||||
|
||||
const textResult = await searchPerplexity({ query: "q" }, "jwt");
|
||||
const textResult = await searchPerplexity({ query: "q", model: "pplx_pro_upgraded", incognito: true }, "jwt");
|
||||
expect(textResult.answer).toBe("text fallback");
|
||||
});
|
||||
|
||||
@@ -197,7 +237,7 @@ describe("searchPerplexity", () => {
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await searchPerplexity({ query: "q" }, "jwt");
|
||||
await searchPerplexity({ query: "q", model: "pplx_pro_upgraded", incognito: true }, "jwt");
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user