mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 06:12:10 +03:00
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:
1
changelog.d/fixes/7930-perplexity-web-quota-cooldown.md
Normal file
1
changelog.d/fixes/7930-perplexity-web-quota-cooldown.md
Normal 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
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user