Perplexity's model fields are inconsistent per model: Claude Sonnet 5 reports the real slug in user_selected_model with display_model "turbo", while GLM-5.2 reports the inverse. Pick whichever field is present and not "turbo", and fall back to the requested model when both are missing or "turbo". Also add glm_5_2 to KNOWN_MODELS. Refs #7 (follow-up comment)
344 lines
12 KiB
TypeScript
344 lines
12 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test } from "../test-helpers.js";
|
|
|
|
import { SearchError } from "../../src/search/types.js";
|
|
|
|
let searchPerplexity: typeof import("../../src/search/client.js").searchPerplexity;
|
|
|
|
function createSseResponse(events: Array<Record<string, unknown>>, status = 200): Response {
|
|
const streamText = [
|
|
...events.map((event) => `data: ${JSON.stringify(event)}\n\n`),
|
|
"data: [DONE]\n\n",
|
|
].join("");
|
|
|
|
return new Response(streamText, {
|
|
status,
|
|
headers: { "content-type": "text/event-stream" },
|
|
});
|
|
}
|
|
|
|
describe("searchPerplexity", () => {
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
beforeEach(async () => {
|
|
const mod = await import(`../../src/search/client.js?t=${Date.now()}`);
|
|
searchPerplexity = mod.searchPerplexity;
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
test("builds request body and headers according to protocol", async () => {
|
|
let capturedUrl: RequestInfo | URL | undefined;
|
|
let capturedInit: RequestInit | undefined;
|
|
|
|
globalThis.fetch = (async (url: RequestInfo | URL, init?: RequestInit) => {
|
|
capturedUrl = url;
|
|
capturedInit = init;
|
|
|
|
return createSseResponse([
|
|
{
|
|
status: "COMPLETED",
|
|
final: true,
|
|
blocks: [
|
|
{ intended_usage: "markdown_block", markdown_block: { answer: "answer text" } },
|
|
{
|
|
intended_usage: "web_results",
|
|
web_result_block: {
|
|
web_results: [
|
|
{ name: "Source", url: "https://example.com", snippet: "snippet", timestamp: "2026-02-16T10:00:00.000Z" },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
}) as unknown as typeof fetch;
|
|
|
|
const controller = new AbortController();
|
|
const result = await searchPerplexity(
|
|
{ query: "latest Node release notes", recency: "week", model: "pplx_pro_upgraded" },
|
|
"jwt-token",
|
|
controller.signal,
|
|
);
|
|
|
|
expect(String(capturedUrl)).toBe("https://www.perplexity.ai/rest/sse/perplexity_ask");
|
|
expect(capturedInit?.method).toBe("POST");
|
|
expect(capturedInit?.signal).toBe(controller.signal);
|
|
|
|
const headers = new Headers(capturedInit?.headers);
|
|
expect(headers.get("Authorization")).toBe("Bearer jwt-token");
|
|
expect(headers.get("Accept")).toBe("text/event-stream");
|
|
expect(headers.get("X-App-ApiVersion")).toBe("2.18");
|
|
expect(headers.get("X-Request-ID")).toBeTruthy();
|
|
|
|
const body = JSON.parse(String(capturedInit?.body)) as {
|
|
query_str: string;
|
|
params: {
|
|
query_str: string;
|
|
mode: string;
|
|
model_preference: string;
|
|
is_incognito: boolean;
|
|
search_recency_filter: string | null;
|
|
frontend_uuid: string;
|
|
frontend_context_uuid: string;
|
|
};
|
|
};
|
|
|
|
expect(body.query_str).toBe("latest Node release notes");
|
|
expect(body.params.query_str).toBe("latest Node release notes");
|
|
expect(body.params.mode).toBe("copilot");
|
|
expect(body.params.model_preference).toBe("pplx_pro_upgraded");
|
|
expect(body.params.is_incognito).toBe(true);
|
|
expect(body.params.search_recency_filter).toBe("week");
|
|
expect(body.params.frontend_uuid).toBeTruthy();
|
|
expect(body.params.frontend_context_uuid).toBeTruthy();
|
|
|
|
expect(result.answer).toBe("answer text");
|
|
expect(result.sources).toHaveLength(1);
|
|
});
|
|
|
|
test("passes model 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" },
|
|
"jwt-token",
|
|
);
|
|
|
|
const body = JSON.parse(String(capturedInit?.body)) as {
|
|
params: { model_preference: string };
|
|
};
|
|
expect(body.params.model_preference).toBe("claude46sonnetthinking");
|
|
});
|
|
|
|
test("uses Cookie header for browser-cookie credentials", 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" },
|
|
{ type: "oauth", cookies: "__Secure-next-auth.session-token=session; cf_clearance=clearance" },
|
|
);
|
|
|
|
const headers = new Headers(capturedInit?.headers);
|
|
expect(headers.get("Cookie")).toBe("__Secure-next-auth.session-token=session; cf_clearance=clearance");
|
|
expect(headers.get("Authorization")).toBeNull();
|
|
});
|
|
|
|
test("always sends is_incognito true", 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" }, "jwt-token");
|
|
|
|
const body = JSON.parse(String(capturedInit?.body)) as {
|
|
params: { is_incognito: boolean };
|
|
};
|
|
expect(body.params.is_incognito).toBe(true);
|
|
});
|
|
|
|
test("cancels the response body after a terminal event", async () => {
|
|
let cancelCalled = false;
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode('data: {"status":"COMPLETED","final":true,"text":"answer"}\n\n'));
|
|
},
|
|
cancel() {
|
|
cancelCalled = true;
|
|
},
|
|
});
|
|
|
|
globalThis.fetch = (async () =>
|
|
new Response(stream, {
|
|
status: 200,
|
|
headers: { "content-type": "text/event-stream" },
|
|
})) as unknown as typeof fetch;
|
|
|
|
const result = await searchPerplexity({ query: "q", model: "pplx_pro_upgraded" }, "jwt");
|
|
|
|
expect(result.answer).toBe("answer");
|
|
expect(cancelCalled).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", model: "pplx_pro_upgraded" }, "jwt")).rejects.toMatchObject({
|
|
name: "SearchError",
|
|
code: "AUTH",
|
|
});
|
|
}
|
|
});
|
|
|
|
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", model: "pplx_pro_upgraded" }, "jwt")).rejects.toMatchObject({
|
|
name: "SearchError",
|
|
code: "RATE_LIMIT",
|
|
});
|
|
});
|
|
|
|
test("deduplicates sources by normalized URL", async () => {
|
|
globalThis.fetch = (async () =>
|
|
createSseResponse([
|
|
{
|
|
status: "COMPLETED",
|
|
final: true,
|
|
blocks: [
|
|
{ intended_usage: "markdown_block", markdown_block: { answer: "answer text" } },
|
|
{
|
|
intended_usage: "web_results",
|
|
web_result_block: {
|
|
web_results: [
|
|
{ name: "A", url: "https://example.com/path" },
|
|
{ name: "A duplicate", url: "https://example.com/path/" },
|
|
{ name: "B", url: "https://another.example/path" },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
])) as unknown as typeof fetch;
|
|
|
|
const result = await searchPerplexity({ query: "q", model: "pplx_pro_upgraded" }, "jwt");
|
|
|
|
expect(result.sources).toHaveLength(2);
|
|
expect(result.sources[0].url).toBe("https://example.com/path");
|
|
expect(result.sources[1].url).toBe("https://another.example/path");
|
|
});
|
|
|
|
test("answer extraction prioritizes markdown_block over ask_text and text", async () => {
|
|
globalThis.fetch = (async () =>
|
|
createSseResponse([
|
|
{
|
|
status: "COMPLETED",
|
|
final: true,
|
|
text: "fallback text",
|
|
blocks: [
|
|
{ intended_usage: "ask_text", markdown_block: { answer: "ask text" } },
|
|
{ intended_usage: "markdown_block", markdown_block: { answer: "markdown answer" } },
|
|
],
|
|
sources_list: [{ title: "S", url: "https://example.com" }],
|
|
},
|
|
])) as unknown as typeof fetch;
|
|
|
|
const result = await searchPerplexity({ query: "q", model: "pplx_pro_upgraded" }, "jwt");
|
|
expect(result.answer).toBe("markdown answer");
|
|
});
|
|
|
|
test("answer extraction falls back to ask_text then text", async () => {
|
|
globalThis.fetch = (async () =>
|
|
createSseResponse([
|
|
{
|
|
status: "COMPLETED",
|
|
final: true,
|
|
text: "fallback text",
|
|
blocks: [
|
|
{ intended_usage: "ask_text", markdown_block: { answer: "ask answer" } },
|
|
],
|
|
sources_list: [{ title: "S", url: "https://example.com" }],
|
|
},
|
|
])) as unknown as typeof fetch;
|
|
|
|
const askTextResult = await searchPerplexity({ query: "q", model: "pplx_pro_upgraded" }, "jwt");
|
|
expect(askTextResult.answer).toBe("ask answer");
|
|
|
|
globalThis.fetch = (async () =>
|
|
createSseResponse([
|
|
{
|
|
status: "COMPLETED",
|
|
final: true,
|
|
text: "text fallback",
|
|
sources_list: [{ title: "S", url: "https://example.com" }],
|
|
},
|
|
])) as unknown as typeof fetch;
|
|
|
|
const textResult = await searchPerplexity({ query: "q", model: "pplx_pro_upgraded" }, "jwt");
|
|
expect(textResult.answer).toBe("text fallback");
|
|
});
|
|
|
|
test("prefers user_selected_model over display_model unless it is turbo", async () => {
|
|
globalThis.fetch = (async () =>
|
|
createSseResponse([
|
|
{
|
|
status: "COMPLETED",
|
|
final: true,
|
|
text: "answer",
|
|
display_model: "turbo",
|
|
user_selected_model: "claude50sonnetthinking",
|
|
sources_list: [{ title: "S", url: "https://example.com" }],
|
|
},
|
|
])) as unknown as typeof fetch;
|
|
|
|
const preferred = await searchPerplexity({ query: "q", model: "claude50sonnetthinking" }, "jwt");
|
|
expect(preferred.displayModel).toBe("claude50sonnetthinking");
|
|
|
|
globalThis.fetch = (async () =>
|
|
createSseResponse([
|
|
{
|
|
status: "COMPLETED",
|
|
final: true,
|
|
text: "answer",
|
|
display_model: "pplx_pro_upgraded",
|
|
user_selected_model: "turbo",
|
|
sources_list: [{ title: "S", url: "https://example.com" }],
|
|
},
|
|
])) as unknown as typeof fetch;
|
|
|
|
const fallback = await searchPerplexity({ query: "q", model: "pplx_pro_upgraded" }, "jwt");
|
|
expect(fallback.displayModel).toBe("pplx_pro_upgraded");
|
|
|
|
globalThis.fetch = (async () =>
|
|
createSseResponse([
|
|
{
|
|
status: "COMPLETED",
|
|
final: true,
|
|
text: "answer",
|
|
display_model: "turbo",
|
|
user_selected_model: "turbo",
|
|
sources_list: [{ title: "S", url: "https://example.com" }],
|
|
},
|
|
])) as unknown as typeof fetch;
|
|
|
|
const requested = await searchPerplexity({ query: "q", model: "glm_5_2" }, "jwt");
|
|
expect(requested.displayModel).toBe("glm_5_2");
|
|
});
|
|
|
|
test("returns EMPTY error when response has no answer and no sources", async () => {
|
|
globalThis.fetch = (async () =>
|
|
createSseResponse([{ status: "COMPLETED", final: true }])) as unknown as typeof fetch;
|
|
|
|
let thrown: unknown;
|
|
try {
|
|
await searchPerplexity({ query: "q", model: "pplx_pro_upgraded" }, "jwt");
|
|
} catch (error) {
|
|
thrown = error;
|
|
}
|
|
|
|
expect(thrown).toBeInstanceOf(SearchError);
|
|
expect((thrown as SearchError).code).toBe("EMPTY");
|
|
});
|
|
});
|