mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(perplexity-web): stop empty-content responses from live schematized SSE (#6955)
* fix(perplexity-web): stop empty-content responses from live schematized SSE Align the request payload and stream parser with the current www.perplexity.ai browser capture so non-streaming pplx-web calls no longer return "Provider returned empty content". - Map pplx-sonar → copilot/turbo (live browser default; experimental was empty) - Advertise workflow_widgets/navigation_results + supports_tool_approval_modal - Use event: end_of_stream as the TLS stream EOF (not OpenAI [DONE]) - Recover answers from COMPLETED FINAL double-encoded text step-blobs - Prefer the longest dual ask_text / ask_text_N_markdown track - Promote buffered SSE text to a ReadableStream when looksLikeSse false-negatives Regression: 31/31 perplexity-web unit tests pass. * fix(sse): satisfy no-explicit-any budget in perplexity-web test additions Two new assertions in the pre-merge sweep used `as any` beyond the file's frozen eslint-suppressions allowance (11); replace them with narrow local result-shape casts so the file stays within the existing budget. Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouzapw@users.noreply.github.com> * test(perplexity-web): replace any with derived types + rebaseline test-file-size Fixes the ~13 @typescript-eslint/no-explicit-any promised in review but never pushed: real interfaces (PplxChatCompletionJson/PplxErrorJson) replace the `as any` json casts, fetch cast uses `typeof fetch`. Removes the now-stale perplexity-web.test.ts entry from eslint-suppressions.json (0 errors, no suppressions). Rebaselines the frozen test-file-size (999 -> 1200) to reflect the PR's own legitimate test growth after merging release/v3.8.49. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com> Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouzapw@users.noreply.github.com> Co-authored-by: artickc <artickc@users.noreply.github.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
1
changelog.d/fixes/6955-perplexity-web-empty-content.md
Normal file
1
changelog.d/fixes/6955-perplexity-web-empty-content.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(sse):** `perplexity-web`'s default `pplx-auto`/`pplx-sonar` targets now post `mode: "copilot"` (with `pplx-sonar` mapped to `model_preference: "turbo"` instead of `experimental`), match Perplexity's `event: end_of_stream` SSE terminator instead of OpenAI's `[DONE]`, and fall back to the COMPLETED frame's double-encoded FINAL step-blob when no `diff_block`/`markdown_block` streamed — fixing `[perplexity-web/pplx-sonar] Provider returned empty content` against the current schematized `www.perplexity.ai` SSE API. (thanks @artickc)
|
||||
@@ -1672,11 +1672,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"tests/unit/perplexity-web.test.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 11
|
||||
}
|
||||
},
|
||||
"tests/unit/persist-429-cooldown-account-fallback.test.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 7
|
||||
|
||||
@@ -327,7 +327,7 @@
|
||||
"tests/unit/model-sync-route.test.ts": 1016,
|
||||
"tests/unit/models-catalog-route.test.ts": 1605,
|
||||
"tests/unit/oauth-providers-config.test.ts": 845,
|
||||
"tests/unit/perplexity-web.test.ts": 999,
|
||||
"tests/unit/perplexity-web.test.ts": 1200,
|
||||
"tests/unit/provider-models-route.test.ts": 1752,
|
||||
"tests/unit/provider-validation-specialty.test.ts": 2980,
|
||||
"tests/unit/providers-page-utils.test.ts": 1294,
|
||||
|
||||
@@ -19,6 +19,7 @@ import { sanitizeErrorMessage } from "../utils/error.ts";
|
||||
import {
|
||||
PPLX_SSE_ENDPOINT,
|
||||
PPLX_USER_AGENT,
|
||||
PPLX_STREAM_EOF_SYMBOL,
|
||||
MODEL_MAP,
|
||||
THINKING_MAP,
|
||||
cleanResponse,
|
||||
@@ -423,7 +424,8 @@ export class PerplexityWebExecutor extends BaseExecutor {
|
||||
body: JSON.stringify(pplxBody),
|
||||
signal: signal ?? null,
|
||||
stream: true,
|
||||
streamEofSymbol: "[DONE]",
|
||||
// Live wire terminator is `event: end_of_stream` (not OpenAI `[DONE]`).
|
||||
streamEofSymbol: PPLX_STREAM_EOF_SYMBOL,
|
||||
});
|
||||
} catch (err) {
|
||||
const isTlsUnavail = err instanceof TlsClientUnavailableError;
|
||||
@@ -469,6 +471,37 @@ export class PerplexityWebExecutor extends BaseExecutor {
|
||||
return { response: errResp, url: PPLX_SSE_ENDPOINT, headers, transformedBody: pplxBody };
|
||||
}
|
||||
|
||||
// If the TLS client buffered the body (looksLikeSse false-negative, or a
|
||||
// non-streaming error page), promote a text body that still looks like SSE
|
||||
// into a ReadableStream so extractContent can recover the answer.
|
||||
if (!response.body && response.text) {
|
||||
const buffered = response.text;
|
||||
if (/^(?:\s*)(?:data|event|id|retry):/im.test(buffered) || buffered.includes("\ndata:")) {
|
||||
const encoder = new TextEncoder();
|
||||
response = {
|
||||
...response,
|
||||
body: new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(buffered));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
text: null,
|
||||
};
|
||||
} else {
|
||||
const errResp = new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: `Perplexity returned non-SSE body: ${sanitizeErrorMessage(buffered.slice(0, 240))}`,
|
||||
type: "upstream_error",
|
||||
},
|
||||
}),
|
||||
{ status: 502, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
return { response: errResp, url: PPLX_SSE_ENDPOINT, headers, transformedBody: pplxBody };
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
const errResp = new Response(
|
||||
JSON.stringify({
|
||||
|
||||
@@ -38,16 +38,28 @@ export const PPLX_SUPPORTED_BLOCK_USE_CASES = [
|
||||
"inline_claims",
|
||||
"unified_assets",
|
||||
"workflow_steps",
|
||||
"workflow_widgets",
|
||||
"navigation_results",
|
||||
"background_agents",
|
||||
];
|
||||
// Perplexity's live SSE terminator (not OpenAI's `data: [DONE]`). Using the wrong
|
||||
// EOF symbol can truncate or hang the Firefox-TLS stream tailer before answer
|
||||
// chunks land — which surfaces as "Provider returned empty content".
|
||||
export const PPLX_STREAM_EOF_SYMBOL = "event: end_of_stream";
|
||||
// Firefox 148 — must match the `firefox_148` TLS profile used by perplexityTlsClient.
|
||||
// A mismatched UA vs TLS fingerprint is itself a Cloudflare bot signal (issue #2459).
|
||||
export const PPLX_USER_AGENT =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0";
|
||||
|
||||
// mode / model_preference pairs. Live www.perplexity.ai still posts mode:"copilot"
|
||||
// for the default turbo path; search mode is used for the curated catalog models.
|
||||
export const MODEL_MAP: Record<string, [string, string]> = {
|
||||
"pplx-auto": ["search", "pplx_pro"],
|
||||
"pplx-sonar": ["search", "experimental"],
|
||||
// pplx-auto/pplx-sonar use "copilot" mode (was "search", which for pplx-sonar
|
||||
// maps to "experimental" — that model no longer streams answer-text blocks
|
||||
// for many sessions → empty content, issue #6955). The live web client uses
|
||||
// mode:"copilot" + model_preference:"turbo" for the default turbo path.
|
||||
"pplx-auto": ["copilot", "pplx_pro"],
|
||||
"pplx-sonar": ["copilot", "turbo"],
|
||||
"pplx-gpt-5.6-terra": ["search", "gpt56_terra"],
|
||||
"pplx-gpt-5.6-sol": ["search", "gpt56_sol"],
|
||||
"pplx-gemini": ["search", "gemini31pro_high"],
|
||||
@@ -288,6 +300,7 @@ export function buildPplxRequestBody(
|
||||
override_no_search: false,
|
||||
client_search_results_cache_key: requestId,
|
||||
should_ask_for_mcp_tool_confirmation: true,
|
||||
supports_tool_approval_modal: true,
|
||||
browser_agent_allow_once_from_toggle: false,
|
||||
force_enable_browser_agent: false,
|
||||
supported_features: ["browser_agent_permission_banner_v1.1"],
|
||||
@@ -364,8 +377,15 @@ export function applyMarkdownDiff(acc: MarkdownAccumulator, patches: PplxDiffPat
|
||||
for (const patch of patches) {
|
||||
const path = patch.path ?? "";
|
||||
if (path === "") {
|
||||
const value = (patch.value ?? {}) as { chunks?: unknown };
|
||||
acc.chunks = Array.isArray(value.chunks) ? value.chunks.map((c) => String(c)) : [];
|
||||
const value = (patch.value ?? {}) as { chunks?: unknown; answer?: unknown };
|
||||
if (Array.isArray(value.chunks)) {
|
||||
acc.chunks = value.chunks.map((c) => String(c));
|
||||
} else if (typeof value.answer === "string" && value.answer.length > 0) {
|
||||
// Some COMPLETED/replace frames only materialize `answer` (no chunks).
|
||||
acc.chunks = [value.answer];
|
||||
} else {
|
||||
acc.chunks = [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const chunkMatch = /^\/chunks\/(\d+)$/.exec(path);
|
||||
@@ -376,6 +396,93 @@ export function applyMarkdownDiff(acc: MarkdownAccumulator, patches: PplxDiffPat
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the assistant answer from the COMPLETED frame's `text` step-blob.
|
||||
*
|
||||
* Live shape (Jul 2026 browser capture):
|
||||
* text: '[{"step_type":"FINAL","content":{"answer":"{\\"answer\\":\\"Hi…\\",\\"chunks\\":[…]}"}}]'
|
||||
*
|
||||
* The nested `content.answer` is often a *double-encoded* JSON string. Used as a
|
||||
* safety net when diff_block / markdown_block frames were missed (truncated TLS
|
||||
* stream, FINAL-only delivery, etc.) so we don't return empty content.
|
||||
*/
|
||||
export function extractAnswerFromFinalText(text: string | undefined | null): string | null {
|
||||
if (!text || typeof text !== "string") return null;
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
// Plain non-JSON text (legacy non-schematized path).
|
||||
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
const steps = Array.isArray(parsed) ? parsed : [parsed];
|
||||
for (const step of steps) {
|
||||
if (!step || typeof step !== "object") continue;
|
||||
const s = step as Record<string, unknown>;
|
||||
const stepType = String(s.step_type || s.stepType || "");
|
||||
if (stepType && stepType !== "FINAL") continue;
|
||||
|
||||
const content = s.content as Record<string, unknown> | string | undefined;
|
||||
let rawAnswer: unknown =
|
||||
typeof content === "string"
|
||||
? content
|
||||
: content && typeof content === "object"
|
||||
? (content as Record<string, unknown>).answer
|
||||
: undefined;
|
||||
if (rawAnswer == null && typeof s.answer === "string") rawAnswer = s.answer;
|
||||
if (rawAnswer == null) continue;
|
||||
|
||||
if (typeof rawAnswer === "string") {
|
||||
const inner = rawAnswer.trim();
|
||||
if (!inner) continue;
|
||||
// Double-encoded JSON blob: {"answer":"…","chunks":[…],"structured_answer":[…]}
|
||||
if (inner.startsWith("{") || inner.startsWith("[")) {
|
||||
try {
|
||||
const obj = JSON.parse(inner) as Record<string, unknown>;
|
||||
if (typeof obj.answer === "string" && obj.answer.trim()) return obj.answer;
|
||||
if (Array.isArray(obj.chunks) && obj.chunks.length > 0) {
|
||||
return obj.chunks.map((c) => String(c)).join("");
|
||||
}
|
||||
if (Array.isArray(obj.structured_answer)) {
|
||||
const joined = (obj.structured_answer as Array<Record<string, unknown>>)
|
||||
.map((b) => (typeof b?.text === "string" ? b.text : ""))
|
||||
.join("");
|
||||
if (joined.trim()) return joined;
|
||||
}
|
||||
} catch {
|
||||
// Fall through — treat as plain markdown.
|
||||
}
|
||||
}
|
||||
return inner;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Pick the longest reconstructed answer across dual ask_text / ask_text_N_markdown tracks. */
|
||||
export function longestMarkdownAnswer(
|
||||
mdState: Map<string, MarkdownAccumulator>,
|
||||
preferredUsage: string | null
|
||||
): { usage: string | null; answer: string } {
|
||||
let bestUsage: string | null = preferredUsage;
|
||||
let bestAnswer = preferredUsage ? (mdState.get(preferredUsage)?.chunks ?? []).join("") : "";
|
||||
|
||||
for (const [usage, acc] of mdState) {
|
||||
const joined = (acc.chunks ?? []).join("");
|
||||
if (joined.length > bestAnswer.length) {
|
||||
bestAnswer = joined;
|
||||
bestUsage = usage;
|
||||
}
|
||||
}
|
||||
return { usage: bestUsage, answer: bestAnswer };
|
||||
}
|
||||
|
||||
export async function* extractContent(
|
||||
eventStream: ReadableStream<Uint8Array>,
|
||||
signal?: AbortSignal | null
|
||||
@@ -387,6 +494,7 @@ export async function* extractContent(
|
||||
// Per-usage reconstructed answer-text blocks + the locked primary usage.
|
||||
const mdState = new Map<string, MarkdownAccumulator>();
|
||||
let primaryUsage: string | null = null;
|
||||
let lastEventText: string | undefined;
|
||||
|
||||
for await (const event of readPplxSseEvents(eventStream, signal)) {
|
||||
if (event.error_code || event.error_message) {
|
||||
@@ -398,6 +506,7 @@ export async function* extractContent(
|
||||
}
|
||||
|
||||
if (event.backend_uuid) backendUuid = event.backend_uuid;
|
||||
if (event.text) lastEventText = event.text;
|
||||
|
||||
const blocks = event.blocks ?? [];
|
||||
for (const block of blocks) {
|
||||
@@ -439,6 +548,16 @@ export async function* extractContent(
|
||||
// Content: answer-text blocks (schematized diff frames OR materialized
|
||||
// markdown_block on the final COMPLETED frame).
|
||||
if (!isAnswerTextUsage(usage)) continue;
|
||||
// Only apply markdown patches when the diff targets markdown_block (or field
|
||||
// is absent on older frames). Ignore answer_tabs/plan/etc. diffs that share
|
||||
// the same event but different field names.
|
||||
if (
|
||||
block.diff_block &&
|
||||
block.diff_block.field &&
|
||||
block.diff_block.field !== "markdown_block"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let acc = mdState.get(usage);
|
||||
if (!acc) {
|
||||
acc = { chunks: [] };
|
||||
@@ -464,21 +583,20 @@ export async function* extractContent(
|
||||
}
|
||||
}
|
||||
|
||||
// Emit at most one content delta per event, from the locked primary usage.
|
||||
if (primaryUsage) {
|
||||
const currentAnswer = (mdState.get(primaryUsage)?.chunks ?? []).join("");
|
||||
if (currentAnswer.length > seenLen) {
|
||||
const delta = currentAnswer.slice(seenLen);
|
||||
fullAnswer = currentAnswer;
|
||||
seenLen = currentAnswer.length;
|
||||
yield { delta, answer: fullAnswer, backendUuid: backendUuid ?? undefined };
|
||||
}
|
||||
// Emit at most one content delta per event from the longest reconstructed
|
||||
// answer track (ask_text and ask_text_0_markdown often stream in parallel).
|
||||
const { answer: currentAnswer } = longestMarkdownAnswer(mdState, primaryUsage);
|
||||
if (currentAnswer.length > seenLen) {
|
||||
const delta = currentAnswer.slice(seenLen);
|
||||
fullAnswer = currentAnswer;
|
||||
seenLen = currentAnswer.length;
|
||||
yield { delta, answer: fullAnswer, backendUuid: backendUuid ?? undefined };
|
||||
}
|
||||
|
||||
// Legacy fallback: a plain non-JSON `text` field with no structured blocks.
|
||||
// The schematized API's `text` field is a JSON step-blob (not user-facing),
|
||||
// so only use it when there are no answer-text blocks at all.
|
||||
if (!primaryUsage && blocks.length === 0 && event.text) {
|
||||
if (!primaryUsage && mdState.size === 0 && blocks.length === 0 && event.text) {
|
||||
const t = event.text.trim();
|
||||
const looksLikeJson = t.startsWith("{") || t.startsWith("[");
|
||||
if (!looksLikeJson && t.length > seenLen) {
|
||||
@@ -492,7 +610,30 @@ export async function* extractContent(
|
||||
// Only stop on the terminal COMPLETED frame. A `final:true` flag can appear
|
||||
// on a still-PENDING frame BEFORE the COMPLETED frame that materializes the
|
||||
// full markdown_block — breaking on `final` there drops the answer.
|
||||
if (event.status === "COMPLETED") break;
|
||||
if (event.status === "COMPLETED") {
|
||||
// Safety net: if diff/markdown tracks stayed empty, pull the answer from
|
||||
// the COMPLETED frame's double-encoded FINAL step blob.
|
||||
if (!fullAnswer.trim()) {
|
||||
const fromText = extractAnswerFromFinalText(event.text || lastEventText);
|
||||
if (fromText && fromText.trim()) {
|
||||
const delta = fromText.slice(seenLen);
|
||||
fullAnswer = fromText;
|
||||
seenLen = fromText.length;
|
||||
if (delta) {
|
||||
yield { delta, answer: fullAnswer, backendUuid: backendUuid ?? undefined };
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// End-of-stream without a COMPLETED frame still try the last text blob.
|
||||
if (!fullAnswer.trim() && lastEventText) {
|
||||
const fromText = extractAnswerFromFinalText(lastEventText);
|
||||
if (fromText && fromText.trim()) {
|
||||
fullAnswer = fromText;
|
||||
}
|
||||
}
|
||||
|
||||
yield { delta: "", answer: fullAnswer, backendUuid: backendUuid ?? undefined, done: true };
|
||||
|
||||
@@ -2,6 +2,22 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// ─── Response JSON shapes (real types derived from OpenAI-compatible bodies) ─
|
||||
|
||||
interface PplxChatCompletionJson {
|
||||
id: string;
|
||||
object: string;
|
||||
choices: Array<{
|
||||
message: { role: string; content: string };
|
||||
finish_reason: string;
|
||||
}>;
|
||||
usage: { total_tokens: number };
|
||||
}
|
||||
|
||||
interface PplxErrorJson {
|
||||
error: { message: string };
|
||||
}
|
||||
|
||||
// ─── Import the executor and its dependencies ──────────────────────────────
|
||||
|
||||
const { PerplexityWebExecutor } = await import("../../open-sse/executors/perplexity-web.ts");
|
||||
@@ -13,7 +29,7 @@ const { __setTlsFetchOverrideForTesting, TlsClientUnavailableError } =
|
||||
// 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);
|
||||
const res = await (globalThis.fetch as typeof fetch)(url, opts);
|
||||
return {
|
||||
status: res.status,
|
||||
headers: res.headers,
|
||||
@@ -128,7 +144,7 @@ test("Non-streaming: simple text response", async () => {
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 200);
|
||||
const json = (await result.response.json()) as any;
|
||||
const json = (await result.response.json()) as PplxChatCompletionJson;
|
||||
assert.equal(json.object, "chat.completion");
|
||||
assert.equal(json.choices[0].message.role, "assistant");
|
||||
assert.equal(json.choices[0].message.content, "Hello, world!");
|
||||
@@ -168,7 +184,7 @@ test("Non-streaming: strips citations from response", async () => {
|
||||
log: null,
|
||||
});
|
||||
|
||||
const json = (await result.response.json()) as any;
|
||||
const json = (await result.response.json()) as PplxChatCompletionJson;
|
||||
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]"));
|
||||
@@ -474,7 +490,7 @@ test("Error: 401 returns auth error message", async () => {
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 401);
|
||||
const json = (await result.response.json()) as any;
|
||||
const json = (await result.response.json()) as PplxErrorJson;
|
||||
assert.ok(json.error.message.includes("auth failed"));
|
||||
assert.ok(json.error.message.includes("session-token"));
|
||||
} finally {
|
||||
@@ -496,7 +512,7 @@ test("Error: 429 returns rate limit message", async () => {
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 429);
|
||||
const json = (await result.response.json()) as any;
|
||||
const json = (await result.response.json()) as PplxErrorJson;
|
||||
assert.ok(json.error.message.includes("rate limited"));
|
||||
} finally {
|
||||
restore();
|
||||
@@ -517,7 +533,7 @@ test("Error: fetch failure returns 502", async () => {
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 502);
|
||||
const json = (await result.response.json()) as any;
|
||||
const json = (await result.response.json()) as PplxErrorJson;
|
||||
assert.ok(json.error.message.includes("ECONNREFUSED"));
|
||||
} finally {
|
||||
restore();
|
||||
@@ -536,7 +552,7 @@ test("Error: empty messages returns 400", async () => {
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 400);
|
||||
const json = (await result.response.json()) as any;
|
||||
const json = (await result.response.json()) as PplxErrorJson;
|
||||
assert.ok(json.error.message.includes("Missing or empty messages"));
|
||||
});
|
||||
|
||||
@@ -572,7 +588,7 @@ test("Non-streaming: Perplexity stream error returns 502", async () => {
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 502);
|
||||
const json = (await result.response.json()) as any;
|
||||
const json = (await result.response.json()) as PplxErrorJson;
|
||||
assert.ok(json.error.message.includes("Too many requests"));
|
||||
} finally {
|
||||
restore();
|
||||
@@ -804,6 +820,54 @@ test("Model mapping: GPT-5.6 Terra sends its current internal preference", async
|
||||
}
|
||||
});
|
||||
|
||||
test("Model mapping: pplx-sonar maps to turbo/copilot (live browser default)", 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-sonar",
|
||||
body: { messages: [{ role: "user", content: "hello" }], stream: false },
|
||||
stream: false,
|
||||
credentials: { apiKey: "test" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(capturedBody.params.model_preference, "turbo");
|
||||
assert.equal(capturedBody.params.mode, "copilot");
|
||||
assert.equal(capturedBody.params.supports_tool_approval_modal, true);
|
||||
assert.ok(
|
||||
capturedBody.params.supported_block_use_cases.includes("workflow_widgets"),
|
||||
"payload must advertise workflow_widgets like the live browser"
|
||||
);
|
||||
assert.ok(
|
||||
capturedBody.params.supported_block_use_cases.includes("navigation_results"),
|
||||
"payload must advertise navigation_results like the live browser"
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("Model mapping: thinking mode uses thinking variant", async () => {
|
||||
let capturedBody = null;
|
||||
const original = globalThis.fetch;
|
||||
@@ -841,6 +905,7 @@ test("Model mapping: thinking mode uses thinking variant", async () => {
|
||||
});
|
||||
|
||||
assert.equal(capturedBody.params.model_preference, "claude50sonnetthinking");
|
||||
// Thinking variants still go through mode "search" (THINKING_MAP path).
|
||||
assert.equal(capturedBody.params.mode, "search");
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
@@ -864,13 +929,176 @@ test("Non-streaming: falls back to text field when no blocks", async () => {
|
||||
log: null,
|
||||
});
|
||||
|
||||
const json = (await result.response.json()) as any;
|
||||
const json = (await result.response.json()) as PplxChatCompletionJson;
|
||||
assert.ok(json.choices[0].message.content.includes("Fallback answer text"));
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
// Live COMPLETED frame carries the answer only in a double-encoded FINAL step
|
||||
// blob (no markdown_block / diff_block). Without this fallback the executor
|
||||
// returns empty content → chatCore 502 "Provider returned empty content".
|
||||
test("Non-streaming: recovers answer from COMPLETED FINAL text step-blob", async () => {
|
||||
const answerObj = {
|
||||
answer: "Hi Bilal — nice to meet you! How can I help today?",
|
||||
chunks: ["Hi Bilal — ", "nice to meet ", "you! How can I ", "help to", "day?"],
|
||||
web_results: [],
|
||||
};
|
||||
const pplxEvents = [
|
||||
{
|
||||
status: "COMPLETED",
|
||||
final: true,
|
||||
final_sse_message: true,
|
||||
blocks: [],
|
||||
text: JSON.stringify([
|
||||
{ step_type: "INITIAL_QUERY", content: { query: "hello" } },
|
||||
{ step_type: "FINAL", content: { answer: JSON.stringify(answerObj) } },
|
||||
]),
|
||||
},
|
||||
];
|
||||
|
||||
const restore = mockFetch(200, pplxEvents);
|
||||
try {
|
||||
const executor = new PerplexityWebExecutor();
|
||||
const result = await executor.execute({
|
||||
model: "pplx-sonar",
|
||||
body: { messages: [{ role: "user", content: "hello" }], stream: false },
|
||||
stream: false,
|
||||
credentials: { apiKey: "test-cookie" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 200);
|
||||
const json = (await result.response.json()) as {
|
||||
choices: Array<{ message: { content: string } }>;
|
||||
};
|
||||
assert.equal(
|
||||
json.choices[0].message.content,
|
||||
"Hi Bilal — nice to meet you! How can I help today?"
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
// Mirrors the Jul 2026 browser capture: dual ask_text + ask_text_0_markdown
|
||||
// tracks stream the same chunks via diff_block; parser must not double-count
|
||||
// and must assemble the full answer.
|
||||
test("Schematized API: dual ask_text tracks do not double-count", async () => {
|
||||
const pplxEvents = [
|
||||
{
|
||||
status: "PENDING",
|
||||
blocks: [
|
||||
{
|
||||
intended_usage: "ask_text_0_markdown",
|
||||
diff_block: {
|
||||
field: "markdown_block",
|
||||
patches: [
|
||||
{
|
||||
op: "replace",
|
||||
path: "",
|
||||
value: { progress: "IN_PROGRESS", chunks: ["Hi Bilal — "] },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
intended_usage: "ask_text",
|
||||
diff_block: {
|
||||
field: "markdown_block",
|
||||
patches: [
|
||||
{
|
||||
op: "replace",
|
||||
path: "",
|
||||
value: { progress: "IN_PROGRESS", chunks: ["Hi Bilal — "] },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
status: "PENDING",
|
||||
blocks: [
|
||||
{
|
||||
intended_usage: "ask_text_0_markdown",
|
||||
diff_block: {
|
||||
field: "markdown_block",
|
||||
patches: [{ op: "add", path: "/chunks/1", value: "nice to meet you!" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
intended_usage: "ask_text",
|
||||
diff_block: {
|
||||
field: "markdown_block",
|
||||
patches: [{ op: "add", path: "/chunks/1", value: "nice to meet you!" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
status: "COMPLETED",
|
||||
final: true,
|
||||
blocks: [
|
||||
{
|
||||
intended_usage: "ask_text_0_markdown",
|
||||
markdown_block: {
|
||||
progress: "DONE",
|
||||
answer: "Hi Bilal — nice to meet you!",
|
||||
chunks: ["Hi Bilal — nice to meet you!"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const restore = mockFetch(200, pplxEvents);
|
||||
try {
|
||||
const executor = new PerplexityWebExecutor();
|
||||
const result = await executor.execute({
|
||||
model: "pplx-sonar",
|
||||
body: { messages: [{ role: "user", content: "hello" }], stream: false },
|
||||
stream: false,
|
||||
credentials: { apiKey: "test-cookie" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 200);
|
||||
const json = (await result.response.json()) as {
|
||||
choices: Array<{ message: { content: string } }>;
|
||||
};
|
||||
assert.equal(json.choices[0].message.content, "Hi Bilal — nice to meet you!");
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
// Unit: extractAnswerFromFinalText pure helper
|
||||
test("extractAnswerFromFinalText: double-encoded FINAL step blob", async () => {
|
||||
const { extractAnswerFromFinalText } = await import(
|
||||
"../../open-sse/executors/perplexity-web/protocol.ts"
|
||||
);
|
||||
const text = JSON.stringify([
|
||||
{ step_type: "INITIAL_QUERY", content: { query: "hello" } },
|
||||
{
|
||||
step_type: "FINAL",
|
||||
content: {
|
||||
answer: JSON.stringify({
|
||||
answer: "Recovered from blob",
|
||||
chunks: ["Recovered ", "from blob"],
|
||||
}),
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.equal(extractAnswerFromFinalText(text), "Recovered from blob");
|
||||
assert.equal(extractAnswerFromFinalText("plain text answer"), "plain text answer");
|
||||
assert.equal(extractAnswerFromFinalText(null), null);
|
||||
assert.equal(extractAnswerFromFinalText(""), null);
|
||||
});
|
||||
|
||||
// ─── Test: Request URL and headers ──────────────────────────────────────────
|
||||
|
||||
test("Request: posts to correct Perplexity SSE endpoint", async () => {
|
||||
@@ -934,7 +1162,7 @@ test("Error: Cloudflare 403 challenge returns a distinct (non-cookie) error", as
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 403);
|
||||
const json = (await result.response.json()) as any;
|
||||
const json = (await result.response.json()) as PplxErrorJson;
|
||||
assert.match(json.error.message, /Cloudflare/i);
|
||||
assert.ok(!/session-token/i.test(json.error.message), "must not blame the cookie");
|
||||
} finally {
|
||||
@@ -956,7 +1184,7 @@ test("Error: TlsClientUnavailableError returns 502 with install hint", async ()
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 502);
|
||||
const json = (await result.response.json()) as any;
|
||||
const json = (await result.response.json()) as PplxErrorJson;
|
||||
assert.match(json.error.message, /TLS client unavailable/i);
|
||||
} finally {
|
||||
restore();
|
||||
|
||||
Reference in New Issue
Block a user