Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 82 additions & 23 deletions apps/agent/agent/lib/perplexity.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,52 @@
const ENDPOINT = "https://api.perplexity.ai/chat/completions";
import { z } from "zod";

const ENDPOINT = "https://api.perplexity.ai/v1/agent";
const TIMEOUT_MS = 45_000;

const EFFORT = { sonar: "fast", "sonar-pro": "low" } as const;

const agentResponse = z.object({
status: z.string(),
output: z.array(z.unknown()),
});

const messageItem = z.object({
type: z.literal("message"),
content: z.array(z.object({ type: z.string(), text: z.string().optional() })),
});

const searchResultsItem = z.object({
type: z.literal("search_results"),
results: z.array(z.object({ url: z.string() })),
});

export type Answer = {
text: string;
citations: string[];
};

function answerOf(output: readonly unknown[]): Answer {
const text = output
.flatMap((item) => messageItem.safeParse(item).data?.content ?? [])
.flatMap((part) =>
part.type === "output_text" && part.text ? [part.text] : [],
)
.join("\n")
.trim();

const citations = [
...new Set(
output
.flatMap(
(item) => searchResultsItem.safeParse(item).data?.results ?? [],
)
.map((result) => result.url),
),
];

return { text, citations };
}

type Outcome<T> = { ok: true; data: T } | { ok: false; reason: string };

export function perplexityEnabled(): boolean {
Expand All @@ -18,6 +59,36 @@ export type AskOptions = {
system?: string;
};

type AgentRequest = {
effort: (typeof EFFORT)[keyof typeof EFFORT];
input: string;
instructions?: string;
tools?: {
type: "web_search";
filters: { search_domain_filter: string[] };
}[];
};

function requestFor(question: string, options: AskOptions): AgentRequest {
const request: AgentRequest = {
effort: EFFORT[options.model ?? "sonar"],
input: question,
};

if (options.system) request.instructions = options.system;

if (options.domains) {
request.tools = [
{
type: "web_search",
filters: { search_domain_filter: options.domains },
},
];
}

return request;
}

export async function ask(
question: string,
options: AskOptions = {},
Expand All @@ -36,36 +107,24 @@ export async function ask(
"content-type": "application/json",
},
signal: controller.signal,
body: JSON.stringify({
model: options.model ?? "sonar",
messages: [
...(options.system
? [{ role: "system", content: options.system }]
: []),
{ role: "user", content: question },
],
search_domain_filter: options.domains,
}),
body: JSON.stringify(requestFor(question, options)),
});

if (!response.ok) {
return { ok: false, reason: `HTTP ${response.status}` };
}

const body = (await response.json()) as {
choices?: { message?: { content?: string } }[];
citations?: string[];
search_results?: { url?: string }[];
};

const text = body.choices?.[0]?.message?.content?.trim() ?? "";
if (!text) return { ok: false, reason: "Empty answer." };
const body = agentResponse.safeParse(await response.json());
if (!body.success) {
return { ok: false, reason: "Unreadable Perplexity answer." };
}

const citations =
body.citations ??
(body.search_results ?? []).flatMap((r) => (r.url ? [r.url] : []));
const answer = answerOf(body.data.output);
if (!answer.text) {
return { ok: false, reason: `Empty answer (${body.data.status}).` };
}

return { ok: true, data: { text, citations } };
return { ok: true, data: answer };
} catch (error) {
const aborted = error instanceof Error && error.name === "AbortError";
return {
Expand Down
147 changes: 147 additions & 0 deletions apps/agent/test/perplexity.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { ask } from "../agent/lib/perplexity";

const realFetch = globalThis.fetch;
const savedKey = process.env.PERPLEXITY_API_KEY;

type Sent = { url: string; body: string };

type OutputItem =
| { type: "search_results"; results: { id: number; url: string }[] }
| { type: "fetch_url_results"; contents: string[] }
| {
type: "message";
role: "assistant";
content: { type: "output_text"; text: string }[];
};

type Reply = {
status?: string;
output?: OutputItem[];
error?: { message: string };
choices?: { message: { content: string } }[];
};

let sent: Sent[] = [];

function respondWith(status: number, payload: Reply) {
globalThis.fetch = (async (input: URL | RequestInfo, init?: RequestInit) => {
sent.push({ url: String(input), body: String(init?.body ?? "") });
return new Response(JSON.stringify(payload), { status });
}) as typeof fetch;
}

function sentBody(index = 0) {
return JSON.parse(sent[index]?.body ?? "{}");
}

const completed: Reply = {
status: "completed",
output: [
{
type: "search_results",
results: [
{ id: 1, url: "https://acme.example/about" },
{ id: 2, url: "https://news.example/acme" },
],
},
{ type: "fetch_url_results", contents: [] },
{
type: "search_results",
results: [{ id: 3, url: "https://acme.example/about" }],
},
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text: " Acme sells anvils.[1] " }],
},
],
};

beforeEach(() => {
sent = [];
process.env.PERPLEXITY_API_KEY = "pplx-test";
});

afterEach(() => {
globalThis.fetch = realFetch;
if (savedKey === undefined) delete process.env.PERPLEXITY_API_KEY;
else process.env.PERPLEXITY_API_KEY = savedKey;
});

describe("ask", () => {
it("calls the Agent API and reads the answer and its sources", async () => {
respondWith(200, completed);

const answer = await ask("What does Acme sell?", {
system: "Be factual.",
});

expect(answer).toEqual({
ok: true,
data: {
text: "Acme sells anvils.[1]",
citations: ["https://acme.example/about", "https://news.example/acme"],
},
});
expect(sent[0]?.url).toBe("https://api.perplexity.ai/v1/agent");
expect(sentBody()).toEqual({
effort: "fast",
input: "What does Acme sell?",
instructions: "Be factual.",
});
});

it("maps sonar-pro to low effort and domains to a web_search filter", async () => {
respondWith(200, completed);

await ask("Who is Jane Doe?", {
model: "sonar-pro",
domains: ["linkedin.com"],
});

expect(sentBody()).toEqual({
effort: "low",
input: "Who is Jane Doe?",
tools: [
{
type: "web_search",
filters: { search_domain_filter: ["linkedin.com"] },
},
],
});
});

it("reports an HTTP failure", async () => {
respondWith(403, { error: { message: "nope" } });

expect(await ask("Anything?")).toEqual({ ok: false, reason: "HTTP 403" });
});

it("reports a response it cannot read rather than an empty answer", async () => {
respondWith(200, { choices: [{ message: { content: "old shape" } }] });

expect(await ask("Anything?")).toEqual({
ok: false,
reason: "Unreadable Perplexity answer.",
});
});

it("reports an answer with no text", async () => {
respondWith(200, { status: "incomplete", output: [] });

expect(await ask("Anything?")).toEqual({
ok: false,
reason: "Empty answer (incomplete).",
});
});

it("needs a key", async () => {
delete process.env.PERPLEXITY_API_KEY;

expect(await ask("Anything?")).toEqual({
ok: false,
reason: "No PERPLEXITY_API_KEY.",
});
});
});