999 lines
31 KiB
TypeScript
999 lines
31 KiB
TypeScript
// @ts-nocheck
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
// ─── Import the executor and its dependencies ──────────────────────────────
|
|
|
|
const { PerplexityWebExecutor } = await import("../../open-sse/executors/perplexity-web.ts");
|
|
const { getExecutor, hasSpecializedExecutor } = await import("../../open-sse/executors/index.ts");
|
|
const { __setTlsFetchOverrideForTesting, TlsClientUnavailableError } =
|
|
await import("../../open-sse/services/perplexityTlsClient.ts");
|
|
|
|
// #2459: the executor now routes through tlsFetchPerplexity (Firefox TLS) instead of
|
|
// global fetch. Install one persistent bridge so the tests below can keep stubbing
|
|
// globalThis.fetch (returning a Response) and have it surface as a TlsFetchResult.
|
|
__setTlsFetchOverrideForTesting(async (url, opts) => {
|
|
const res = await (globalThis.fetch as any)(url, opts);
|
|
return {
|
|
status: res.status,
|
|
headers: res.headers,
|
|
text: res.status === 200 ? null : await res.text(),
|
|
body: res.status === 200 ? res.body : null,
|
|
};
|
|
});
|
|
|
|
// ─── Helper: Build a mock SSE stream from Perplexity events ─────────────────
|
|
|
|
function mockPplxStream(events) {
|
|
const encoder = new TextEncoder();
|
|
const chunks = [];
|
|
for (const evt of events) {
|
|
chunks.push(`event: message\r\ndata: ${JSON.stringify(evt)}\r\n\r\n`);
|
|
}
|
|
chunks.push("event: end_of_stream\r\n\r\n");
|
|
const body = chunks.join("");
|
|
return new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode(body));
|
|
controller.close();
|
|
},
|
|
});
|
|
}
|
|
|
|
// ─── Helper: stub globalThis.fetch for testing ──────────────────────────────
|
|
// The persistent bridge above forwards tlsFetchPerplexity calls to globalThis.fetch,
|
|
// so stubbing fetch is still the way to mock Perplexity's upstream response.
|
|
|
|
function mockFetch(status, streamEvents, bodyText) {
|
|
const original = globalThis.fetch;
|
|
globalThis.fetch = async () => {
|
|
if (status === 200) {
|
|
return new Response(mockPplxStream(streamEvents), {
|
|
status,
|
|
headers: { "Content-Type": "text/event-stream" },
|
|
});
|
|
}
|
|
return new Response(bodyText ?? `{"error":"http ${status}"}`, {
|
|
status,
|
|
headers: { "Content-Type": "text/html" },
|
|
});
|
|
};
|
|
return () => {
|
|
globalThis.fetch = original;
|
|
};
|
|
}
|
|
|
|
function mockFetchError(error) {
|
|
const original = globalThis.fetch;
|
|
globalThis.fetch = async () => {
|
|
throw error;
|
|
};
|
|
return () => {
|
|
globalThis.fetch = original;
|
|
};
|
|
}
|
|
|
|
// ─── Test: Executor registration ────────────────────────────────────────────
|
|
|
|
test("PerplexityWebExecutor is registered in executor index", () => {
|
|
assert.ok(hasSpecializedExecutor("perplexity-web"));
|
|
assert.ok(hasSpecializedExecutor("pplx-web"));
|
|
const executor = getExecutor("perplexity-web");
|
|
assert.ok(executor instanceof PerplexityWebExecutor);
|
|
});
|
|
|
|
test("PerplexityWebExecutor alias resolves to same type", () => {
|
|
const a = getExecutor("perplexity-web");
|
|
const b = getExecutor("pplx-web");
|
|
assert.ok(a instanceof PerplexityWebExecutor);
|
|
assert.ok(b instanceof PerplexityWebExecutor);
|
|
});
|
|
|
|
// ─── Test: Constructor ──────────────────────────────────────────────────────
|
|
|
|
test("PerplexityWebExecutor sets correct provider name", () => {
|
|
const executor = new PerplexityWebExecutor();
|
|
assert.equal(executor.getProvider(), "perplexity-web");
|
|
});
|
|
|
|
// ─── Test: Non-streaming response ───────────────────────────────────────────
|
|
|
|
test("Non-streaming: simple text response", async () => {
|
|
const pplxEvents = [
|
|
{
|
|
backend_uuid: "test-uuid-123",
|
|
blocks: [
|
|
{
|
|
intended_usage: "markdown",
|
|
markdown_block: {
|
|
chunks: ["Hello, world!"],
|
|
progress: "DONE",
|
|
},
|
|
},
|
|
],
|
|
status: "COMPLETED",
|
|
},
|
|
];
|
|
|
|
const restore = mockFetch(200, pplxEvents);
|
|
try {
|
|
const executor = new PerplexityWebExecutor();
|
|
const result = await executor.execute({
|
|
model: "pplx-auto",
|
|
body: { messages: [{ role: "user", content: "hi" }], stream: false },
|
|
stream: false,
|
|
credentials: { apiKey: "test-cookie-value" },
|
|
signal: AbortSignal.timeout(10000),
|
|
log: null,
|
|
});
|
|
|
|
assert.equal(result.response.status, 200);
|
|
const json = (await result.response.json()) as any;
|
|
assert.equal(json.object, "chat.completion");
|
|
assert.equal(json.choices[0].message.role, "assistant");
|
|
assert.equal(json.choices[0].message.content, "Hello, world!");
|
|
assert.equal(json.choices[0].finish_reason, "stop");
|
|
assert.ok(json.id.startsWith("chatcmpl-pplx-"));
|
|
assert.ok(json.usage.total_tokens > 0);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test("Non-streaming: strips citations from response", async () => {
|
|
const pplxEvents = [
|
|
{
|
|
blocks: [
|
|
{
|
|
intended_usage: "markdown",
|
|
markdown_block: {
|
|
chunks: ["The answer is 42[1] according to sources[2][3]."],
|
|
progress: "DONE",
|
|
},
|
|
},
|
|
],
|
|
status: "COMPLETED",
|
|
},
|
|
];
|
|
|
|
const restore = mockFetch(200, pplxEvents);
|
|
try {
|
|
const executor = new PerplexityWebExecutor();
|
|
const result = await executor.execute({
|
|
model: "pplx-gpt",
|
|
body: { messages: [{ role: "user", content: "meaning of life" }], stream: false },
|
|
stream: false,
|
|
credentials: { apiKey: "test-cookie" },
|
|
signal: AbortSignal.timeout(10000),
|
|
log: null,
|
|
});
|
|
|
|
const json = (await result.response.json()) as any;
|
|
assert.ok(!json.choices[0].message.content.includes("[1]"));
|
|
assert.ok(!json.choices[0].message.content.includes("[2]"));
|
|
assert.ok(!json.choices[0].message.content.includes("[3]"));
|
|
assert.ok(json.choices[0].message.content.includes("The answer is 42"));
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
// ─── Test: Streaming response ───────────────────────────────────────────────
|
|
|
|
test("Streaming: produces valid SSE chunks", async () => {
|
|
const pplxEvents = [
|
|
{
|
|
backend_uuid: "stream-uuid-456",
|
|
blocks: [
|
|
{
|
|
intended_usage: "markdown",
|
|
markdown_block: { chunks: ["Hello "], progress: "IN_PROGRESS" },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
blocks: [
|
|
{
|
|
intended_usage: "markdown",
|
|
markdown_block: { chunks: ["Hello world!"], progress: "DONE" },
|
|
},
|
|
],
|
|
status: "COMPLETED",
|
|
},
|
|
];
|
|
|
|
const restore = mockFetch(200, pplxEvents);
|
|
try {
|
|
const executor = new PerplexityWebExecutor();
|
|
const result = await executor.execute({
|
|
model: "pplx-sonnet",
|
|
body: { messages: [{ role: "user", content: "hello" }], stream: true },
|
|
stream: true,
|
|
credentials: { apiKey: "test-cookie" },
|
|
signal: AbortSignal.timeout(10000),
|
|
log: null,
|
|
});
|
|
|
|
assert.equal(result.response.status, 200);
|
|
assert.equal(result.response.headers.get("Content-Type"), "text/event-stream");
|
|
|
|
// Read all SSE chunks
|
|
const text = await result.response.text();
|
|
const lines = text.split("\n").filter((l) => l.startsWith("data: "));
|
|
assert.ok(lines.length >= 3, `Expected at least 3 SSE data lines, got ${lines.length}`);
|
|
|
|
// First chunk should have role
|
|
const first = JSON.parse(lines[0].slice(6));
|
|
assert.equal(first.object, "chat.completion.chunk");
|
|
assert.equal(first.choices[0].delta.role, "assistant");
|
|
|
|
// Last data line should be [DONE]
|
|
const lastLine = text.trim().split("\n").filter(Boolean).pop();
|
|
assert.equal(lastLine, "data: [DONE]");
|
|
|
|
// Second-to-last should have finish_reason: stop
|
|
const stopLine = lines[lines.length - 1];
|
|
if (stopLine !== "data: [DONE]") {
|
|
const stop = JSON.parse(stopLine.slice(6));
|
|
assert.equal(stop.choices[0].finish_reason, "stop");
|
|
}
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
// ─── Test: Schematized diff_block streaming (use_schematized_api) ───────────
|
|
|
|
test("Schematized API: diff_block chunks reconstruct answer (non-streaming)", async () => {
|
|
// Mirrors the live www.perplexity.ai schematized API: the answer streams as
|
|
// RFC-6902 JSON-patch frames against markdown_block, a `final:true` flag
|
|
// arrives on a still-PENDING frame, then a COMPLETED frame materializes the
|
|
// full markdown_block. The parser must NOT stop on `final` and must apply
|
|
// the diff patches.
|
|
const pplxEvents = [
|
|
{
|
|
backend_uuid: "diff-uuid-1",
|
|
status: "PENDING",
|
|
blocks: [
|
|
{
|
|
intended_usage: "ask_text_0_markdown",
|
|
diff_block: {
|
|
field: "markdown_block",
|
|
patches: [
|
|
{ op: "replace", path: "", value: { progress: "IN_PROGRESS", chunks: ["The "] } },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
status: "PENDING",
|
|
blocks: [
|
|
{
|
|
intended_usage: "ask_text_0_markdown",
|
|
diff_block: {
|
|
field: "markdown_block",
|
|
patches: [{ op: "add", path: "/chunks/1", value: "answer " }],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
status: "PENDING",
|
|
final: true,
|
|
blocks: [
|
|
{
|
|
intended_usage: "ask_text_0_markdown",
|
|
diff_block: {
|
|
field: "markdown_block",
|
|
patches: [{ op: "add", path: "/chunks/2", value: "is 42." }],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
status: "COMPLETED",
|
|
final: true,
|
|
blocks: [
|
|
{
|
|
intended_usage: "ask_text_0_markdown",
|
|
markdown_block: {
|
|
progress: "DONE",
|
|
chunks: ["The answer is 42."],
|
|
answer: "The answer is 42.",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
const restore = mockFetch(200, pplxEvents);
|
|
try {
|
|
const executor = new PerplexityWebExecutor();
|
|
const result = await executor.execute({
|
|
model: "pplx-auto",
|
|
body: { messages: [{ role: "user", content: "what is the answer?" }], stream: false },
|
|
stream: false,
|
|
credentials: { apiKey: "test-cookie" },
|
|
signal: AbortSignal.timeout(10000),
|
|
log: null,
|
|
});
|
|
|
|
assert.equal(result.response.status, 200);
|
|
const json = JSON.parse(await result.response.text());
|
|
assert.equal(json.choices[0].message.content, "The answer is 42.");
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test("Schematized API: diff_block streams incremental deltas", async () => {
|
|
const pplxEvents = [
|
|
{
|
|
backend_uuid: "diff-uuid-2",
|
|
status: "PENDING",
|
|
blocks: [
|
|
{
|
|
intended_usage: "ask_text_0_markdown",
|
|
diff_block: {
|
|
field: "markdown_block",
|
|
patches: [
|
|
{ op: "replace", path: "", value: { progress: "IN_PROGRESS", chunks: ["one, "] } },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
status: "PENDING",
|
|
blocks: [
|
|
{
|
|
intended_usage: "ask_text_0_markdown",
|
|
diff_block: {
|
|
field: "markdown_block",
|
|
patches: [{ op: "add", path: "/chunks/1", value: "two, " }],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
status: "COMPLETED",
|
|
final: true,
|
|
blocks: [
|
|
{
|
|
intended_usage: "ask_text_0_markdown",
|
|
markdown_block: {
|
|
progress: "DONE",
|
|
chunks: ["one, two, three"],
|
|
answer: "one, two, three",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
const restore = mockFetch(200, pplxEvents);
|
|
try {
|
|
const executor = new PerplexityWebExecutor();
|
|
const result = await executor.execute({
|
|
model: "pplx-auto",
|
|
body: { messages: [{ role: "user", content: "count" }], stream: true },
|
|
stream: true,
|
|
credentials: { apiKey: "test-cookie" },
|
|
signal: AbortSignal.timeout(10000),
|
|
log: null,
|
|
});
|
|
|
|
assert.equal(result.response.status, 200);
|
|
const text = await result.response.text();
|
|
let assembled = "";
|
|
for (const line of text.split("\n")) {
|
|
if (!line.startsWith("data: ")) continue;
|
|
const d = line.slice(6).trim();
|
|
if (d === "[DONE]") continue;
|
|
const o = JSON.parse(d);
|
|
const c = o.choices?.[0]?.delta?.content;
|
|
if (c) assembled += c;
|
|
}
|
|
assert.equal(assembled, "one, two, three");
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
// ─── Test: Thinking/reasoning content ───────────────────────────────────────
|
|
test("Streaming: thinking content emitted as reasoning_content", async () => {
|
|
const pplxEvents = [
|
|
{
|
|
blocks: [
|
|
{
|
|
intended_usage: "pro_search_steps",
|
|
plan_block: {
|
|
steps: [
|
|
{
|
|
step_type: "SEARCH_WEB",
|
|
search_web_content: { queries: [{ query: "test query" }] },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
blocks: [
|
|
{
|
|
intended_usage: "markdown",
|
|
markdown_block: { chunks: ["The answer."], progress: "DONE" },
|
|
},
|
|
],
|
|
status: "COMPLETED",
|
|
},
|
|
];
|
|
|
|
const restore = mockFetch(200, pplxEvents);
|
|
try {
|
|
const executor = new PerplexityWebExecutor();
|
|
const result = await executor.execute({
|
|
model: "pplx-sonnet",
|
|
body: { messages: [{ role: "user", content: "search test" }], stream: true },
|
|
stream: true,
|
|
credentials: { apiKey: "test-cookie" },
|
|
signal: AbortSignal.timeout(10000),
|
|
log: null,
|
|
});
|
|
|
|
const text = await result.response.text();
|
|
const dataLines = text
|
|
.split("\n")
|
|
.filter((l) => l.startsWith("data: ") && l !== "data: [DONE]");
|
|
|
|
// Should have a reasoning_content delta
|
|
const hasReasoning = dataLines.some((l) => {
|
|
const json = JSON.parse(l.slice(6));
|
|
return json.choices?.[0]?.delta?.reasoning_content != null;
|
|
});
|
|
assert.ok(hasReasoning, "Should have reasoning_content delta for thinking steps");
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
// ─── Test: Error handling ───────────────────────────────────────────────────
|
|
|
|
test("Error: 401 returns auth error message", async () => {
|
|
const restore = mockFetch(401, []);
|
|
try {
|
|
const executor = new PerplexityWebExecutor();
|
|
const result = await executor.execute({
|
|
model: "pplx-auto",
|
|
body: { messages: [{ role: "user", content: "hi" }] },
|
|
stream: false,
|
|
credentials: { apiKey: "expired-cookie" },
|
|
signal: AbortSignal.timeout(10000),
|
|
log: null,
|
|
});
|
|
|
|
assert.equal(result.response.status, 401);
|
|
const json = (await result.response.json()) as any;
|
|
assert.ok(json.error.message.includes("auth failed"));
|
|
assert.ok(json.error.message.includes("session-token"));
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test("Error: 429 returns rate limit message", async () => {
|
|
const restore = mockFetch(429, []);
|
|
try {
|
|
const executor = new PerplexityWebExecutor();
|
|
const result = await executor.execute({
|
|
model: "pplx-gpt",
|
|
body: { messages: [{ role: "user", content: "hi" }] },
|
|
stream: false,
|
|
credentials: { apiKey: "test-cookie" },
|
|
signal: AbortSignal.timeout(10000),
|
|
log: null,
|
|
});
|
|
|
|
assert.equal(result.response.status, 429);
|
|
const json = (await result.response.json()) as any;
|
|
assert.ok(json.error.message.includes("rate limited"));
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test("Error: fetch failure returns 502", async () => {
|
|
const restore = mockFetchError(new Error("ECONNREFUSED"));
|
|
try {
|
|
const executor = new PerplexityWebExecutor();
|
|
const result = await executor.execute({
|
|
model: "pplx-auto",
|
|
body: { messages: [{ role: "user", content: "hi" }] },
|
|
stream: false,
|
|
credentials: { apiKey: "test-cookie" },
|
|
signal: AbortSignal.timeout(10000),
|
|
log: null,
|
|
});
|
|
|
|
assert.equal(result.response.status, 502);
|
|
const json = (await result.response.json()) as any;
|
|
assert.ok(json.error.message.includes("ECONNREFUSED"));
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test("Error: empty messages returns 400", async () => {
|
|
const executor = new PerplexityWebExecutor();
|
|
const result = await executor.execute({
|
|
model: "pplx-auto",
|
|
body: { messages: [] },
|
|
stream: false,
|
|
credentials: { apiKey: "test-cookie" },
|
|
signal: AbortSignal.timeout(10000),
|
|
log: null,
|
|
});
|
|
|
|
assert.equal(result.response.status, 400);
|
|
const json = (await result.response.json()) as any;
|
|
assert.ok(json.error.message.includes("Missing or empty messages"));
|
|
});
|
|
|
|
test("Error: missing messages returns 400", async () => {
|
|
const executor = new PerplexityWebExecutor();
|
|
const result = await executor.execute({
|
|
model: "pplx-auto",
|
|
body: {},
|
|
stream: false,
|
|
credentials: { apiKey: "test-cookie" },
|
|
signal: AbortSignal.timeout(10000),
|
|
log: null,
|
|
});
|
|
|
|
assert.equal(result.response.status, 400);
|
|
});
|
|
|
|
// ─── Test: Perplexity SSE error in stream ───────────────────────────────────
|
|
|
|
test("Non-streaming: Perplexity stream error returns 502", async () => {
|
|
const pplxEvents = [{ error_code: "RATE_LIMIT", error_message: "Too many requests" }];
|
|
|
|
const restore = mockFetch(200, pplxEvents);
|
|
try {
|
|
const executor = new PerplexityWebExecutor();
|
|
const result = await executor.execute({
|
|
model: "pplx-auto",
|
|
body: { messages: [{ role: "user", content: "test" }], stream: false },
|
|
stream: false,
|
|
credentials: { apiKey: "test-cookie" },
|
|
signal: AbortSignal.timeout(10000),
|
|
log: null,
|
|
});
|
|
|
|
assert.equal(result.response.status, 502);
|
|
const json = (await result.response.json()) as any;
|
|
assert.ok(json.error.message.includes("Too many requests"));
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
// ─── Test: Message parsing ──────────────────────────────────────────────────
|
|
|
|
test("Message parsing: system + user + assistant history", async () => {
|
|
let capturedBody = null;
|
|
const original = globalThis.fetch;
|
|
globalThis.fetch = async (url, opts) => {
|
|
capturedBody = JSON.parse(opts.body);
|
|
return new Response(
|
|
mockPplxStream([
|
|
{
|
|
blocks: [
|
|
{
|
|
intended_usage: "markdown",
|
|
markdown_block: { chunks: ["response"], progress: "DONE" },
|
|
},
|
|
],
|
|
status: "COMPLETED",
|
|
},
|
|
]),
|
|
{ status: 200, headers: { "Content-Type": "text/event-stream" } }
|
|
);
|
|
};
|
|
|
|
try {
|
|
const executor = new PerplexityWebExecutor();
|
|
await executor.execute({
|
|
model: "pplx-auto",
|
|
body: {
|
|
messages: [
|
|
{ role: "system", content: "You are helpful" },
|
|
{ role: "user", content: "First question" },
|
|
{ role: "assistant", content: "First answer" },
|
|
{ role: "user", content: "Follow up" },
|
|
],
|
|
stream: false,
|
|
},
|
|
stream: false,
|
|
credentials: { apiKey: "test-cookie" },
|
|
signal: AbortSignal.timeout(10000),
|
|
log: null,
|
|
});
|
|
|
|
// The query should contain the current message
|
|
const query = capturedBody.query_str;
|
|
assert.ok(query.includes("Follow up"), "Query should contain current user message");
|
|
assert.ok(query.includes("You are helpful"), "Query should contain system message");
|
|
assert.equal(capturedBody.params.search_focus, "internet");
|
|
assert.equal(capturedBody.params.use_schematized_api, true);
|
|
} finally {
|
|
globalThis.fetch = original;
|
|
}
|
|
});
|
|
|
|
test("Message parsing: developer role treated as system", async () => {
|
|
let capturedBody = null;
|
|
const original = globalThis.fetch;
|
|
globalThis.fetch = async (url, opts) => {
|
|
capturedBody = JSON.parse(opts.body);
|
|
return new Response(
|
|
mockPplxStream([
|
|
{
|
|
blocks: [
|
|
{
|
|
intended_usage: "markdown",
|
|
markdown_block: { chunks: ["ok"], progress: "DONE" },
|
|
},
|
|
],
|
|
status: "COMPLETED",
|
|
},
|
|
]),
|
|
{ status: 200, headers: { "Content-Type": "text/event-stream" } }
|
|
);
|
|
};
|
|
|
|
try {
|
|
const executor = new PerplexityWebExecutor();
|
|
await executor.execute({
|
|
model: "pplx-sonnet",
|
|
body: {
|
|
messages: [
|
|
{ role: "developer", content: "Be concise" },
|
|
{ role: "user", content: "hello" },
|
|
],
|
|
stream: false,
|
|
},
|
|
stream: false,
|
|
credentials: { apiKey: "test-cookie" },
|
|
signal: AbortSignal.timeout(10000),
|
|
log: null,
|
|
});
|
|
|
|
const query = capturedBody.query_str;
|
|
assert.ok(query.includes("Be concise"), "Developer message should be treated as system");
|
|
} finally {
|
|
globalThis.fetch = original;
|
|
}
|
|
});
|
|
|
|
// ─── Test: Auth header construction ─────────────────────────────────────────
|
|
|
|
test("Auth: cookie-based auth sends Cookie header", async () => {
|
|
let capturedHeaders = null;
|
|
const original = globalThis.fetch;
|
|
globalThis.fetch = async (url, opts) => {
|
|
capturedHeaders = opts.headers;
|
|
return new Response(
|
|
mockPplxStream([
|
|
{
|
|
blocks: [
|
|
{
|
|
intended_usage: "markdown",
|
|
markdown_block: { chunks: ["ok"], progress: "DONE" },
|
|
},
|
|
],
|
|
status: "COMPLETED",
|
|
},
|
|
]),
|
|
{ status: 200, headers: { "Content-Type": "text/event-stream" } }
|
|
);
|
|
};
|
|
|
|
try {
|
|
const executor = new PerplexityWebExecutor();
|
|
await executor.execute({
|
|
model: "pplx-auto",
|
|
body: { messages: [{ role: "user", content: "test" }], stream: false },
|
|
stream: false,
|
|
credentials: { apiKey: "my-session-token-value" },
|
|
signal: AbortSignal.timeout(10000),
|
|
log: null,
|
|
});
|
|
|
|
assert.equal(
|
|
capturedHeaders["Cookie"],
|
|
"__Secure-next-auth.session-token=my-session-token-value"
|
|
);
|
|
assert.ok(
|
|
!capturedHeaders["Authorization"],
|
|
"Should not have Authorization header for cookie auth"
|
|
);
|
|
} finally {
|
|
globalThis.fetch = original;
|
|
}
|
|
});
|
|
|
|
test("Auth: JWT auth sends Authorization Bearer header", async () => {
|
|
let capturedHeaders = null;
|
|
const original = globalThis.fetch;
|
|
globalThis.fetch = async (url, opts) => {
|
|
capturedHeaders = opts.headers;
|
|
return new Response(
|
|
mockPplxStream([
|
|
{
|
|
blocks: [
|
|
{
|
|
intended_usage: "markdown",
|
|
markdown_block: { chunks: ["ok"], progress: "DONE" },
|
|
},
|
|
],
|
|
status: "COMPLETED",
|
|
},
|
|
]),
|
|
{ status: 200, headers: { "Content-Type": "text/event-stream" } }
|
|
);
|
|
};
|
|
|
|
try {
|
|
const executor = new PerplexityWebExecutor();
|
|
await executor.execute({
|
|
model: "pplx-auto",
|
|
body: { messages: [{ role: "user", content: "test" }], stream: false },
|
|
stream: false,
|
|
credentials: { accessToken: "jwt-token-value" },
|
|
signal: AbortSignal.timeout(10000),
|
|
log: null,
|
|
});
|
|
|
|
assert.equal(capturedHeaders["Authorization"], "Bearer jwt-token-value");
|
|
assert.ok(!capturedHeaders["Cookie"], "Should not have Cookie header for JWT auth");
|
|
} finally {
|
|
globalThis.fetch = original;
|
|
}
|
|
});
|
|
|
|
// ─── Test: Model mapping ────────────────────────────────────────────────────
|
|
|
|
test("Model mapping: pplx-gpt sends current GPT-5.5 internal preference", async () => {
|
|
let capturedBody = null;
|
|
const original = globalThis.fetch;
|
|
globalThis.fetch = async (url, opts) => {
|
|
capturedBody = JSON.parse(opts.body);
|
|
return new Response(
|
|
mockPplxStream([
|
|
{
|
|
blocks: [
|
|
{
|
|
intended_usage: "markdown",
|
|
markdown_block: { chunks: ["ok"], progress: "DONE" },
|
|
},
|
|
],
|
|
status: "COMPLETED",
|
|
},
|
|
]),
|
|
{ status: 200, headers: { "Content-Type": "text/event-stream" } }
|
|
);
|
|
};
|
|
|
|
try {
|
|
const executor = new PerplexityWebExecutor();
|
|
await executor.execute({
|
|
model: "pplx-gpt",
|
|
body: { messages: [{ role: "user", content: "test" }], stream: false },
|
|
stream: false,
|
|
credentials: { apiKey: "test" },
|
|
signal: AbortSignal.timeout(10000),
|
|
log: null,
|
|
});
|
|
|
|
assert.equal(capturedBody.params.model_preference, "gpt55");
|
|
assert.equal(capturedBody.params.mode, "search");
|
|
} finally {
|
|
globalThis.fetch = original;
|
|
}
|
|
});
|
|
|
|
test("Model mapping: thinking mode uses thinking variant", async () => {
|
|
let capturedBody = null;
|
|
const original = globalThis.fetch;
|
|
globalThis.fetch = async (url, opts) => {
|
|
capturedBody = JSON.parse(opts.body);
|
|
return new Response(
|
|
mockPplxStream([
|
|
{
|
|
blocks: [
|
|
{
|
|
intended_usage: "markdown",
|
|
markdown_block: { chunks: ["ok"], progress: "DONE" },
|
|
},
|
|
],
|
|
status: "COMPLETED",
|
|
},
|
|
]),
|
|
{ status: 200, headers: { "Content-Type": "text/event-stream" } }
|
|
);
|
|
};
|
|
|
|
try {
|
|
const executor = new PerplexityWebExecutor();
|
|
await executor.execute({
|
|
model: "pplx-sonnet",
|
|
body: {
|
|
messages: [{ role: "user", content: "test" }],
|
|
stream: false,
|
|
thinking: true,
|
|
},
|
|
stream: false,
|
|
credentials: { apiKey: "test" },
|
|
signal: AbortSignal.timeout(10000),
|
|
log: null,
|
|
});
|
|
|
|
assert.equal(capturedBody.params.model_preference, "claude50sonnetthinking");
|
|
assert.equal(capturedBody.params.mode, "search");
|
|
} finally {
|
|
globalThis.fetch = original;
|
|
}
|
|
});
|
|
|
|
// ─── Test: Provider registry ────────────────────────────────────────────────
|
|
|
|
test("Provider registry: perplexity-web is registered with correct models", async () => {
|
|
const { PROVIDER_MODELS } = await import("../../open-sse/config/providerModels.ts");
|
|
|
|
const models = PROVIDER_MODELS["pplx-web"];
|
|
assert.ok(models, "pplx-web should be in PROVIDER_MODELS");
|
|
assert.ok(models.length === 10, `Expected 10 models, got ${models.length}`);
|
|
|
|
const modelIds = models.map((m) => m.id);
|
|
assert.ok(modelIds.includes("pplx-auto"));
|
|
assert.ok(modelIds.includes("pplx-gpt"));
|
|
assert.ok(modelIds.includes("pplx-gpt-5.4"));
|
|
assert.ok(modelIds.includes("pplx-sonnet"));
|
|
assert.ok(modelIds.includes("pplx-opus"));
|
|
assert.ok(modelIds.includes("pplx-gemini"));
|
|
assert.ok(modelIds.includes("pplx-nemotron"));
|
|
assert.ok(modelIds.includes("pplx-sonar"));
|
|
assert.ok(modelIds.includes("pplx-kimi"));
|
|
assert.ok(modelIds.includes("pplx-glm"));
|
|
});
|
|
|
|
test("Provider registry: every advertised perplexity-web model has an explicit internal mapping", async () => {
|
|
const { PROVIDER_MODELS } = await import("../../open-sse/config/providerModels.ts");
|
|
const { MODEL_MAP } = await import("../../open-sse/executors/perplexity-web/protocol.ts");
|
|
|
|
const missing = PROVIDER_MODELS["pplx-web"].filter((model) => !MODEL_MAP[model.id]);
|
|
assert.deepEqual(
|
|
missing.map((model) => model.id),
|
|
[],
|
|
"all advertised Perplexity Web models should map to an explicit model_preference"
|
|
);
|
|
});
|
|
|
|
// ─── Test: Fallback text field ──────────────────────────────────────────────
|
|
|
|
test("Non-streaming: falls back to text field when no blocks", async () => {
|
|
const pplxEvents = [{ text: "Fallback answer text", status: "COMPLETED", final: true }];
|
|
|
|
const restore = mockFetch(200, pplxEvents);
|
|
try {
|
|
const executor = new PerplexityWebExecutor();
|
|
const result = await executor.execute({
|
|
model: "pplx-auto",
|
|
body: { messages: [{ role: "user", content: "test" }], stream: false },
|
|
stream: false,
|
|
credentials: { apiKey: "test-cookie" },
|
|
signal: AbortSignal.timeout(10000),
|
|
log: null,
|
|
});
|
|
|
|
const json = (await result.response.json()) as any;
|
|
assert.ok(json.choices[0].message.content.includes("Fallback answer text"));
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
// ─── Test: Request URL and headers ──────────────────────────────────────────
|
|
|
|
test("Request: posts to correct Perplexity SSE endpoint", async () => {
|
|
let capturedUrl = null;
|
|
let capturedHeaders = null;
|
|
const original = globalThis.fetch;
|
|
globalThis.fetch = async (url, opts) => {
|
|
capturedUrl = url;
|
|
capturedHeaders = opts.headers;
|
|
return new Response(
|
|
mockPplxStream([
|
|
{
|
|
blocks: [
|
|
{ intended_usage: "markdown", markdown_block: { chunks: ["ok"], progress: "DONE" } },
|
|
],
|
|
status: "COMPLETED",
|
|
},
|
|
]),
|
|
{ status: 200, headers: { "Content-Type": "text/event-stream" } }
|
|
);
|
|
};
|
|
|
|
try {
|
|
const executor = new PerplexityWebExecutor();
|
|
await executor.execute({
|
|
model: "pplx-auto",
|
|
body: { messages: [{ role: "user", content: "test" }], stream: false },
|
|
stream: false,
|
|
credentials: { apiKey: "test" },
|
|
signal: AbortSignal.timeout(10000),
|
|
log: null,
|
|
});
|
|
|
|
assert.equal(capturedUrl, "https://www.perplexity.ai/rest/sse/perplexity_ask");
|
|
assert.equal(capturedHeaders["Origin"], "https://www.perplexity.ai");
|
|
assert.equal(
|
|
capturedHeaders["x-perplexity-request-endpoint"],
|
|
"https://www.perplexity.ai/rest/sse/perplexity_ask"
|
|
);
|
|
assert.equal(capturedHeaders["x-perplexity-request-reason"], "ask-query-state-provider");
|
|
assert.ok(capturedHeaders["x-request-id"], "x-request-id header should be set");
|
|
assert.equal(capturedHeaders["Accept"], "text/event-stream");
|
|
} finally {
|
|
globalThis.fetch = original;
|
|
}
|
|
});
|
|
|
|
// ─── #2459: Cloudflare challenge vs genuine auth failure ─────────────────────
|
|
|
|
test("Error: Cloudflare 403 challenge returns a distinct (non-cookie) error", async () => {
|
|
const restore = mockFetch(403, [], "<html><title>Just a moment...</title></html>");
|
|
try {
|
|
const executor = new PerplexityWebExecutor();
|
|
const result = await executor.execute({
|
|
model: "pplx-auto",
|
|
body: { messages: [{ role: "user", content: "hi" }] },
|
|
stream: false,
|
|
credentials: { apiKey: "valid-cookie" },
|
|
signal: AbortSignal.timeout(10000),
|
|
log: null,
|
|
});
|
|
|
|
assert.equal(result.response.status, 403);
|
|
const json = (await result.response.json()) as any;
|
|
assert.match(json.error.message, /Cloudflare/i);
|
|
assert.ok(!/session-token/i.test(json.error.message), "must not blame the cookie");
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test("Error: TlsClientUnavailableError returns 502 with install hint", async () => {
|
|
const restore = mockFetchError(new TlsClientUnavailableError("native binary missing"));
|
|
try {
|
|
const executor = new PerplexityWebExecutor();
|
|
const result = await executor.execute({
|
|
model: "pplx-auto",
|
|
body: { messages: [{ role: "user", content: "hi" }] },
|
|
stream: false,
|
|
credentials: { apiKey: "test-cookie" },
|
|
signal: AbortSignal.timeout(10000),
|
|
log: null,
|
|
});
|
|
|
|
assert.equal(result.response.status, 502);
|
|
const json = (await result.response.json()) as any;
|
|
assert.match(json.error.message, /TLS client unavailable/i);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|