From 9f380c250b56c823ee71f5b4f720303413d8db82 Mon Sep 17 00:00:00 2001 From: Leslie Barry <195984913+LeslieCBarry@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:53:54 +1100 Subject: [PATCH] fix(agent): call Perplexity's Agent API, since chat completions now answers 403 Perplexity retired Sonar chat completions on 27 Sep 2026. A key created after that gets 403 "Sonar is now the Agent API" from /chat/completions, so research_person, find_contact_socials and the LinkedIn slug search all fail. ask() now posts to /v1/agent: sonar maps to effort fast, sonar-pro to effort low, the system text to instructions, and a domain list to a web_search tool filter. The answer is read from the message item's output_text and the sources from every search_results item, parsed with zod. --- apps/agent/agent/lib/perplexity.ts | 105 ++++++++++++++++----- apps/agent/test/perplexity.spec.ts | 147 +++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+), 23 deletions(-) create mode 100644 apps/agent/test/perplexity.spec.ts diff --git a/apps/agent/agent/lib/perplexity.ts b/apps/agent/agent/lib/perplexity.ts index 1e5acca4a..9a53c108c 100644 --- a/apps/agent/agent/lib/perplexity.ts +++ b/apps/agent/agent/lib/perplexity.ts @@ -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 = { ok: true; data: T } | { ok: false; reason: string }; export function perplexityEnabled(): boolean { @@ -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 = {}, @@ -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 { diff --git a/apps/agent/test/perplexity.spec.ts b/apps/agent/test/perplexity.spec.ts new file mode 100644 index 000000000..ddb00439e --- /dev/null +++ b/apps/agent/test/perplexity.spec.ts @@ -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.", + }); + }); +});