diff --git a/changelog.d/fixes/7930-perplexity-web-quota-cooldown.md b/changelog.d/fixes/7930-perplexity-web-quota-cooldown.md new file mode 100644 index 0000000000..948d880348 --- /dev/null +++ b/changelog.d/fixes/7930-perplexity-web-quota-cooldown.md @@ -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 diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 367398537e..7fae35196e 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -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." } diff --git a/open-sse/executors/perplexity-web.ts b/open-sse/executors/perplexity-web.ts index ece96e0b64..41d1d53379 100644 --- a/open-sse/executors/perplexity-web.ts +++ b/open-sse/executors/perplexity-web.ts @@ -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 = { + message: chunk.error, + type, + code, + }; + if (typeof chunk.resetSeconds === "number" && chunk.resetSeconds > 0) { + errBody.reset_seconds = chunk.resetSeconds; + } + const respHeaders: Record = { "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); diff --git a/open-sse/executors/perplexity-web/protocol.ts b/open-sse/executors/perplexity-web/protocol.ts index 73b5d4e139..fd4c75e6f9 100644 --- a/open-sse/executors/perplexity-web/protocol.ts +++ b/open-sse/executors/perplexity-web/protocol.ts @@ -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__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, signal?: AbortSignal | null @@ -495,6 +586,7 @@ export async function* extractContent( const mdState = new Map(); 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 }; } diff --git a/tests/unit/perplexity-web.test.ts b/tests/unit/perplexity-web.test.ts index 1d892e1655..a04313c601 100644 --- a/tests/unit/perplexity-web.test.ts +++ b/tests/unit/perplexity-web.test.ts @@ -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 () => {