fix(perplexity-web): multi-step empty content + advanced-quota cooldown (#7930)

Perplexity's live multi-step/copilot streams can surface the
advanced_models_quota_low upsell instead of any answer text when the
account's weekly advanced-model budget is exhausted. Detect it and
return HTTP 429 with reset_seconds/Retry-After (mapped to
rate_limited_until) instead of a silent empty-content error.

Also fixes plan-goal (thinking) extraction for live multi-step streams
that deliver the plan as an RFC-6902 diff patch against plan_block
instead of a materialized plan_block object — those goals were
previously dropped.

Reconstructed against release/v3.8.49: most of the original "empty
content" fix in this PR was independently and differently addressed on
release already (extractAnswerFromFinalText + longestMarkdownAnswer),
so only the two non-overlapping pieces above are ported here.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
NOXX - Commiter
2026-07-21 19:16:47 +03:00
committed by GitHub
parent f31f3c081e
commit a865fddb26
5 changed files with 301 additions and 12 deletions

View File

@@ -0,0 +1 @@
- **fix(providers):** perplexity-web now detects the `advanced_models_quota_low` upsell surfaced when a stream never materializes answer text, returning HTTP 429 with `reset_seconds`/`Retry-After` instead of a silent empty-content error, and reconstructs plan-goal reasoning from RFC-6902 diff-patched `plan_block` frames (live multi-step streams), not just materialized ones ([#7930](https://github.com/diegosouzapw/OmniRoute/pull/7930)) — thanks @artickc

View File

@@ -334,7 +334,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": 1200,
"tests/unit/perplexity-web.test.ts": 1355,
"tests/unit/provider-models-route.test.ts": 1752,
"tests/unit/provider-validation-specialty.test.ts": 2980,
"tests/unit/providers-page-utils.test.ts": 1294,
@@ -423,5 +423,6 @@
"_rebaseline_2026_07_07_6546_chirag": "PR #6546 (@chirag127) own growth: src/sse/handlers/chatHelpers.ts ->876. Owner-approved rebaseline. Frozen.",
"_rebaseline_2026_07_07_6525_chirag_image_guard": "PR #6525 (@chirag127, #6457) own growth: chat.ts ->1778 (reject image-only models on /v1/chat/completions; stacks on #6515). Owner-approved. Frozen.",
"_rebaseline_2026_07_15_7045_perf_instrumentation": "PR #7045 (@oyi77) own growth: open-sse/utils/stream.ts 2796->2814 (+18) from performance.mark/measure instrumentation around the SSE dispatch chokepoint (b48ba21c4), a TextEncoder hoisting fix to avoid a per-chunk allocation on the hot path (c35e8a9b4), and clearing the fixed-name \"omni-request-body-size\" mark immediately after creation (babysit fix, addressing a review-flagged unbounded-growth leak in Node's global performance timeline). Cohesive wiring at the existing stream-dispatch chokepoint; not extractable. Covered by tests/unit/chatcore-streaming-pipeline.test.ts + tests/unit/stream-request-body-size-mark-7045.test.ts.",
"_rebaseline_2026_07_18_basereds_test_realignment": "Base-red sweep own growth (post 102-PR campaign, full-suite realignment): tests/unit/combo-routing-engine.test.ts 3209->3243 (+34 = least-used tests now prime usage through real handleComboChat calls so recordComboRequest keys by the resolved executionKey exactly as production does — #7015 keying); tests/unit/db-migration-runner.test.ts 1491->1499 (+8 = withNonTestEnvironment now also strips node --test tokens from process.execArgv, matching the #7359 isAutomatedTestProcess widening); tests/unit/executor-default-base.test.ts 1523->1527 (+4 = 1M-beta assertion updated for claude-sonnet-4-6 GA #7129). All three are test-fidelity realignments, not extractable."
"_rebaseline_2026_07_18_basereds_test_realignment": "Base-red sweep own growth (post 102-PR campaign, full-suite realignment): tests/unit/combo-routing-engine.test.ts 3209->3243 (+34 = least-used tests now prime usage through real handleComboChat calls so recordComboRequest keys by the resolved executionKey exactly as production does — #7015 keying); tests/unit/db-migration-runner.test.ts 1491->1499 (+8 = withNonTestEnvironment now also strips node --test tokens from process.execArgv, matching the #7359 isAutomatedTestProcess widening); tests/unit/executor-default-base.test.ts 1523->1527 (+4 = 1M-beta assertion updated for claude-sonnet-4-6 GA #7129). All three are test-fidelity realignments, not extractable.",
"_rebaseline_2026_07_21_7930_pplx_quota_cooldown": "PR #7930 (@artickc) own growth, reconstructed against release/v3.8.49 base-drift: tests/unit/perplexity-web.test.ts 1192->1355 (+163 = two new regression cases — 'Live multi-step: reconstructs answer without status COMPLETED' proving RFC-6902 diff-patched plan_block goals now surface as reasoning_content the same as a materialized plan_block, and 'Advanced-model quota upsell with empty answer surfaces clear error' proving the new advanced_models_quota_low upsell_information detection maps to HTTP 429 + reset_seconds + Retry-After instead of a silent empty-content 502). Most of the PR's original 'multi-step empty content' claims were already independently fixed on release via a different mechanism (extractAnswerFromFinalText + longestMarkdownAnswer); only the two genuinely new, non-conflicting pieces (diff-block plan-goal extraction + quota cooldown) were ported. Covered by the two new tests; not extractable without splitting the whole executor test file."
}

View File

@@ -268,12 +268,28 @@ async function buildNonStreamingResponse(
for await (const chunk of extractContent(eventStream, signal)) {
if (chunk.backendUuid) respBackendUuid = chunk.backendUuid;
if (chunk.error) {
return new Response(
JSON.stringify({
error: { message: chunk.error, type: "upstream_error", code: "PPLX_ERROR" },
}),
{ status: 502, headers: { "Content-Type": "application/json" } }
);
// Quota exhaustion → 429 + reset_seconds so OmniRoute marks rate_limited_until
// and VibeProxy limit badges / rotation skip parse the same shape as model_cooldown.
const isQuota =
chunk.errorCode === "quota_exhausted" ||
/quota exhausted/i.test(chunk.error) ||
(typeof chunk.resetSeconds === "number" && chunk.resetSeconds > 0);
const status = isQuota ? 429 : 502;
const code = chunk.errorCode || (isQuota ? "quota_exhausted" : "PPLX_ERROR");
const type = isQuota ? "quota_exhausted" : "upstream_error";
const errBody: Record<string, unknown> = {
message: chunk.error,
type,
code,
};
if (typeof chunk.resetSeconds === "number" && chunk.resetSeconds > 0) {
errBody.reset_seconds = chunk.resetSeconds;
}
const respHeaders: Record<string, string> = { "Content-Type": "application/json" };
if (typeof chunk.resetSeconds === "number" && chunk.resetSeconds > 0) {
respHeaders["Retry-After"] = String(chunk.resetSeconds);
}
return new Response(JSON.stringify({ error: errBody }), { status, headers: respHeaders });
}
if (chunk.thinking) {
thinkingParts.push(chunk.thinking);

View File

@@ -141,6 +141,14 @@ export interface PplxBlock {
};
}
export interface PplxUpsellInformation {
name?: string;
upsell_type?: string;
title?: string;
description?: string;
cta?: string;
}
export interface PplxStreamEvent {
status?: string;
final?: boolean;
@@ -151,6 +159,7 @@ export interface PplxStreamEvent {
error_code?: string;
error_message?: string;
display_model?: string;
upsell_information?: PplxUpsellInformation;
}
// ─── SSE parsing ────────────────────────────────────────────────────────────
@@ -350,9 +359,22 @@ export interface ContentChunk {
backendUuid?: string;
thinking?: string;
error?: string;
/** Structured error code for quota / rate-limit surfaces (e.g. quota_exhausted). */
errorCode?: string;
/**
* Suggested client/account cooldown in seconds when the stream failed due to
* advanced-model weekly quota (or similar). Downstream marks the connection
* rate_limited_until and VibeProxy limit badges parse this + "reset after Xs".
*/
resetSeconds?: number;
done?: boolean;
}
/** Default cooldown when Perplexity reports advanced-model weekly quota exhaustion
* without an explicit reset clock (weekly window is account-side). Long enough that
* rotation skips the account instead of hammering it every few seconds. */
export const PPLX_ADVANCED_QUOTA_DEFAULT_RESET_SECONDS = 6 * 60 * 60;
// The schematized API delivers the answer text in blocks whose `intended_usage`
// is either the aggregate `ask_text` or per-segment `ask_text_<n>_markdown`
// (older builds used names merely containing "markdown"). All converge on the
@@ -483,6 +505,75 @@ export function longestMarkdownAnswer(
return { usage: bestUsage, answer: bestAnswer };
}
/** Extract goal descriptions from a materialized or diff-patched plan block. */
function extractPlanGoalDescriptions(block: PplxBlock): string[] {
const out: string[] = [];
if (block.plan_block?.goals) {
for (const goal of block.plan_block.goals) {
const desc = goal.description ?? "";
if (desc) out.push(desc);
}
}
// Live multi-step streams send plan as RFC-6902 diff patches, not plan_block.
const patches = block.diff_block?.patches;
if (Array.isArray(patches)) {
for (const patch of patches) {
const value = patch.value as { goals?: Array<{ description?: string }> } | undefined;
if (value && Array.isArray(value.goals)) {
for (const goal of value.goals) {
const desc = goal.description ?? "";
if (desc) out.push(desc);
}
}
}
}
return out;
}
export interface PplxQuotaError {
message: string;
errorCode: string;
resetSeconds: number;
}
function formatUpsellError(upsell: PplxUpsellInformation | undefined): PplxQuotaError | null {
if (!upsell) return null;
const name = String(upsell.name || "");
// advanced_models_quota_low = weekly advanced-model (Opus/Sonnet/GPT/…) budget
// exhausted. Browser still often downgrades to turbo; when no answer text is
// produced we must surface this instead of a silent "empty content" 502.
if (
name === "advanced_models_quota_low" ||
name.includes("quota") ||
String(upsell.upsell_type || "")
.toUpperCase()
.includes("UPGRADE")
) {
const title = (upsell.title || "").trim();
const desc = (upsell.description || "").trim();
const detail = [title, desc].filter(Boolean).join(" — ");
const base = detail
? `Perplexity advanced model quota exhausted: ${detail}`
: "Perplexity advanced model quota exhausted for this account this week. Use pplx-auto/pplx-sonar, wait for the weekly reset, or upgrade (Perplexity Max).";
const resetSeconds = PPLX_ADVANCED_QUOTA_DEFAULT_RESET_SECONDS;
// Append human "reset after …" so VibeProxy's existing message parsers
// (and accountFallback.formatRetryAfter consumers) pick up the cooldown.
const h = Math.floor(resetSeconds / 3600);
const m = Math.floor((resetSeconds % 3600) / 60);
const s = resetSeconds % 60;
const parts: string[] = [];
if (h > 0) parts.push(`${h}h`);
if (m > 0) parts.push(`${m}m`);
if (s > 0 || parts.length === 0) parts.push(`${s}s`);
return {
message: `${base} (reset after ${parts.join(" ")})`,
errorCode: "quota_exhausted",
resetSeconds,
};
}
return null;
}
export async function* extractContent(
eventStream: ReadableStream<Uint8Array>,
signal?: AbortSignal | null
@@ -495,6 +586,7 @@ export async function* extractContent(
const mdState = new Map<string, MarkdownAccumulator>();
let primaryUsage: string | null = null;
let lastEventText: string | undefined;
let lastUpsell: PplxUpsellInformation | undefined;
for await (const event of readPplxSseEvents(eventStream, signal)) {
if (event.error_code || event.error_message) {
@@ -507,6 +599,7 @@ export async function* extractContent(
if (event.backend_uuid) backendUuid = event.backend_uuid;
if (event.text) lastEventText = event.text;
if (event.upsell_information) lastUpsell = event.upsell_information;
const blocks = event.blocks ?? [];
for (const block of blocks) {
@@ -534,10 +627,9 @@ export async function* extractContent(
}
}
// Thinking: plan goals
if (usage === "plan" && block.plan_block?.goals) {
for (const goal of block.plan_block.goals) {
const desc = goal.description ?? "";
// Thinking: plan goals (materialized plan_block OR live multi-step diff_block)
if (usage === "plan") {
for (const desc of extractPlanGoalDescriptions(block)) {
if (desc && !seenThinking.has(desc)) {
seenThinking.add(desc);
yield { thinking: desc, backendUuid: backendUuid ?? undefined };
@@ -636,6 +728,23 @@ export async function* extractContent(
}
}
// No answer materialized through any recovery path — if the stream surfaced
// an advanced-model quota upsell, report it clearly instead of a silent
// empty-content response so callers can cooldown/rotate the account.
if (!fullAnswer.trim()) {
const upsellErr = formatUpsellError(lastUpsell);
if (upsellErr) {
yield {
error: upsellErr.message,
errorCode: upsellErr.errorCode,
resetSeconds: upsellErr.resetSeconds,
done: true,
backendUuid: backendUuid ?? undefined,
};
return;
}
}
yield { delta: "", answer: fullAnswer, backendUuid: backendUuid ?? undefined, done: true };
}

View File

@@ -912,6 +912,168 @@ test("Model mapping: thinking mode uses thinking variant", async () => {
}
});
// ─── Test: Live multi-step stream (no COMPLETED; text_completed + diffs) ────
test("Live multi-step: reconstructs answer without status COMPLETED", async () => {
// Mirrors Chrome 150 / copilot multi-step capture: PENDING frames with
// ask_text + ask_text_0_markdown diff_block chunks, text_completed:true,
// never status COMPLETED. Parser must still return the answer, and plan
// goals delivered as RFC-6902 diff patches (not a materialized plan_block)
// must still surface as reasoning_content.
const pplxEvents = [
{
status: "PENDING",
blocks: [
{
intended_usage: "plan",
diff_block: {
field: "plan_block",
patches: [
{
op: "replace",
path: "",
value: {
progress: "IN_PROGRESS",
goals: [{ id: "0", description: "Greeting the user", final: false }],
},
},
],
},
},
],
},
{
status: "PENDING",
blocks: [
{
intended_usage: "ask_text_0_markdown",
diff_block: {
field: "markdown_block",
patches: [
{ op: "replace", path: "", value: { progress: "IN_PROGRESS", chunks: ["Hello "] } },
],
},
},
{
intended_usage: "ask_text",
diff_block: {
field: "markdown_block",
patches: [
{ op: "replace", path: "", value: { progress: "IN_PROGRESS", chunks: ["Hello "] } },
],
},
},
],
},
{
status: "PENDING",
text_completed: true,
blocks: [
{
intended_usage: "ask_text_0_markdown",
diff_block: {
field: "markdown_block",
patches: [{ op: "add", path: "/chunks/1", value: "there." }],
},
},
{
intended_usage: "ask_text",
diff_block: {
field: "markdown_block",
patches: [{ op: "add", path: "/chunks/1", value: "there." }],
},
},
],
},
{
status: "PENDING",
final: true,
text: '[{"step_type":"INITIAL_QUERY","content":{"query":"hello"}}]',
},
];
const restore = mockFetch(200, pplxEvents);
try {
const executor = new PerplexityWebExecutor();
const result = await executor.execute({
model: "pplx-opus",
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 = JSON.parse(await result.response.text());
assert.equal(json.choices[0].message.content, "Hello there.");
// Plan goals via diff_block should surface as reasoning_content
assert.ok(
String(json.choices[0].message.reasoning_content || "").includes("Greeting the user")
);
} finally {
restore();
}
});
test("Advanced-model quota upsell with empty answer surfaces clear error", async () => {
const pplxEvents = [
{
status: "PENDING",
upsell_information: {
name: "advanced_models_quota_low",
upsell_type: "UPGRADE_TO_PRO",
title: "No advanced model uses left this week",
description: "Upgrade to Perplexity Max",
},
blocks: [
{
intended_usage: "plan",
diff_block: {
field: "plan_block",
patches: [
{
op: "replace",
path: "",
value: { goals: [{ description: "Hello, how can I assist you?" }] },
},
],
},
},
],
},
{ status: "PENDING", final: true, text_completed: true },
];
const restore = mockFetch(200, pplxEvents);
try {
const executor = new PerplexityWebExecutor();
const result = await executor.execute({
model: "pplx-opus",
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, 429);
const json = JSON.parse(await result.response.text());
assert.match(String(json.error?.message || ""), /quota exhausted/i);
assert.match(String(json.error?.message || ""), /No advanced model uses left/i);
assert.match(String(json.error?.message || ""), /reset after/i);
assert.equal(json.error?.code, "quota_exhausted");
assert.equal(json.error?.type, "quota_exhausted");
assert.ok(
typeof json.error?.reset_seconds === "number" && json.error.reset_seconds >= 3600,
"reset_seconds should be a multi-hour weekly-quota cooldown"
);
assert.equal(result.response.headers.get("Retry-After"), String(json.error.reset_seconds));
} finally {
restore();
}
});
// ─── Test: Fallback text field ──────────────────────────────────────────────
test("Non-streaming: falls back to text field when no blocks", async () => {