fix(sse): classify a 2xx body as a disguised upstream failure (#13461)

Pollinations and Perplexity-web can answer a genuine failure (expired
session, exhausted free-tier credits) with HTTP 200 and a structurally
normal completion whose assistant text is just the provider's own
error prose. classifyProviderError() only inspects the body for
400/401/402/403/429, and detectMalformedNonStream() only checked
structural emptiness, so the error text reached the client as a real
answer and combo/auto-fallback never triggered.

Adds classifyFakeSuccessBody() in errorClassifier.ts — allowlisted to
pollinations/perplexity-web, reusing the existing
CREDITS_EXHAUSTED_SIGNALS/ACCOUNT_DEACTIVATED_SIGNALS phrase lists,
gated on short content with a dominant signal match — and wires it
into detectMalformedNonStream() so the existing malformed-200 /
combo-failover path picks it up with no other handler changes.

Regression test: tests/unit/diagnostics-fake-success-13461.test.ts
This commit is contained in:
diegosouzapw
2026-09-16 14:04:18 -03:00
parent 80828fc88a
commit 38494d9dff
5 changed files with 254 additions and 2 deletions

View File

@@ -0,0 +1 @@
- **fix(sse):** Pollinations and Perplexity-web requests now fail over instead of returning the provider's own "out of credits"/"account suspended" text as if it were a real answer — an HTTP 200 body whose short assistant message is dominated by a known credits-exhausted or account-deactivated phrase is now classified as a malformed response and triggers the existing combo/auto-fallback path (#13461) — thanks @arjav1181

View File

@@ -5390,7 +5390,7 @@ export async function handleChatCore({
// this check runs after translation + sanitization + tool-call execution to catch
// cases where a provider returns a structurally valid raw body that translates into
// choices:[] or output:[] with no usable content (Responses API shape included).
const malformedTranslatedReason = detectMalformedNonStream(translatedResponse);
const malformedTranslatedReason = detectMalformedNonStream(translatedResponse, provider);
if (malformedTranslatedReason) {
const totalLatency = Date.now() - startTime;
const rawBytes = (() => {

View File

@@ -1,4 +1,6 @@
import {
ACCOUNT_DEACTIVATED_SIGNALS,
CREDITS_EXHAUSTED_SIGNALS,
isAccountDeactivated,
isCreditsExhausted,
isDailyQuotaExhausted,
@@ -445,3 +447,83 @@ export function classifyProviderError(
return null;
}
// ── "Fake success" 2xx body classifier (#13461) ─────────────────────────────
//
// Some free/web-session providers (reported: Pollinations, Perplexity web via
// cookie session) answer a genuine failure — expired session, exhausted
// free-tier credits — with HTTP 200 and a structurally normal completion
// whose assistant message is just the provider's own error prose. Neither
// classifyProviderError above (gated on 400/401/402/403/429 before it ever
// looks at the body — deliberately NOT changed by this fix, see below) nor
// detectMalformedNonStream (open-sse/utils/diagnostics.ts, structural
// emptiness only) catch this, so the error text is translated and forwarded
// to the client as if the model had genuinely answered with that sentence.
//
// Deliberately narrow, by owner decision (2026-09-15):
// - allowlist-only, starting with the two providers actually reported —
// never applied globally. classifyProviderError()'s status-code gate is
// intentionally left untouched; this lives in a separate sibling
// function instead of loosening that gate.
// - reuses the EXISTING, already-curated CREDITS_EXHAUSTED_SIGNALS /
// ACCOUNT_DEACTIVATED_SIGNALS phrase lists (open-sse/services/
// accountFallback.ts) rather than inventing new fuzzy matching.
// - only trips on SHORT content whose matched signal covers a large
// fraction of it — a multi-paragraph answer that merely *mentions* the
// topic is long and/or the phrase is a small fraction of it, so it is
// never misclassified.
const FAKE_SUCCESS_BODY_ALLOWLIST = new Set(["pollinations", "perplexity-web"]);
/** Exported for tests; not meant as a general-purpose provider predicate. */
export function isFakeSuccessBodyAllowlistedProvider(provider?: string | null): boolean {
if (!provider) return false;
return FAKE_SUCCESS_BODY_ALLOWLIST.has(provider.toLowerCase());
}
// A real prose answer runs to paragraphs; a disguised upstream error is one
// short sentence. Generous headroom above every known signal phrase while
// still excluding genuine longer completions that merely mention the topic.
const FAKE_SUCCESS_MAX_CONTENT_LENGTH = 400;
// The matched signal alone must make up a meaningful share of the message —
// keeps a legitimate answer that references the phrase in passing (as part
// of a much larger sentence/paragraph) from tripping this classifier.
const FAKE_SUCCESS_MIN_SIGNAL_COVERAGE = 0.12;
function matchedSignalCoverage(lowerText: string, signals: readonly string[]): number {
let best = 0;
for (const signal of signals) {
if (lowerText.includes(signal) && signal.length > best) best = signal.length;
}
return lowerText.length > 0 ? best / lowerText.length : 0;
}
/**
* Classify a *successful* (2xx) response's assistant-message text as a
* disguised upstream failure. Returns the matching ProviderErrorType, or
* null when the provider is not on the allowlist, the content is too long
* to be a bare error sentence, or no known signal phrase dominates it.
*
* Only ever meaningful for the narrow provider allowlist above — see
* isFakeSuccessBodyAllowlistedProvider and #13461.
*/
export function classifyFakeSuccessBody(
content: string,
provider?: string | null
): ProviderErrorType | null {
if (!isFakeSuccessBodyAllowlistedProvider(provider)) return null;
const text = String(content || "").trim();
if (!text || text.length > FAKE_SUCCESS_MAX_CONTENT_LENGTH) return null;
const lower = text.toLowerCase();
if (matchedSignalCoverage(lower, CREDITS_EXHAUSTED_SIGNALS) >= FAKE_SUCCESS_MIN_SIGNAL_COVERAGE) {
return PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED;
}
if (
matchedSignalCoverage(lower, ACCOUNT_DEACTIVATED_SIGNALS) >= FAKE_SUCCESS_MIN_SIGNAL_COVERAGE
) {
return PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED;
}
return null;
}

View File

@@ -10,6 +10,7 @@
*/
import { sanitizeErrorMessage } from "./error.ts";
import { classifyFakeSuccessBody } from "../services/errorClassifier.ts";
// ── Types ────────────────────────────────────────────────────────────────────
@@ -22,6 +23,10 @@ export type MalformedReason =
| "parse_fail"
| "empty_choices"
| "empty_stream"
// #13461: a 2xx body whose assistant text is the provider's own error
// prose disguised as a successful completion (allowlisted providers only
// — see classifyFakeSuccessBody in open-sse/services/errorClassifier.ts).
| "content_is_upstream_error"
| string;
export interface ReportMalformed200Opts {
@@ -52,6 +57,7 @@ const REASON_MESSAGES: Record<string, string> = {
parse_fail: "failed to parse upstream stream",
empty_choices: "response had no usable choices/output",
empty_stream: "upstream stream carried no content",
content_is_upstream_error: "upstream reported a failure disguised as a successful response",
};
function describeReason(reason?: MalformedReason): string {
@@ -172,8 +178,19 @@ export function synthResponsesFailure(reason?: MalformedReason): string {
* - Claude Messages shape (type:"message" + content[]) is checked directly,
* since a Claude client receives the body in that shape (no
* `choices`/`object:"response"`).
* - #13461: for the narrow provider allowlist in classifyFakeSuccessBody
* (open-sse/services/errorClassifier.ts), a short Chat Completions
* assistant message that is dominated by a known credits-exhausted /
* account-deactivated phrase is treated as malformed too ("fake success")
* even though it carries non-empty content — see that function's doc
* comment for the false-positive guards. `provider` is optional and comes
* from the single call site in chatCore.ts; every other caller/shape is
* unaffected.
*/
export function detectMalformedNonStream(resp: unknown): MalformedReason | null {
export function detectMalformedNonStream(
resp: unknown,
provider?: string | null
): MalformedReason | null {
if (!resp || typeof resp !== "object") return "empty_choices";
const body = resp as Record<string, unknown>;
@@ -320,9 +337,41 @@ export function detectMalformedNonStream(resp: unknown): MalformedReason | null
});
if (!anyHasOutput) return "empty_choices";
// #13461: only for the narrow provider allowlist — see classifyFakeSuccessBody's
// doc comment for the false-positive guards (short content + dominant signal).
if (provider && classifyFakeSuccessBody(extractChatCompletionText(choices), provider)) {
return "content_is_upstream_error";
}
return null;
}
// Joins every non-empty text-bearing field across all choices of a Chat
// Completions body into one string, for the #13461 fake-success check above.
// Mirrors the shapes `anyHasOutput` already recognizes as "real" content
// (plain string, Anthropic-style content-block array) — reasoning/tool_calls
// are intentionally excluded, since a disguised upstream error always
// surfaces as visible assistant text, never as a reasoning trace.
function extractChatCompletionText(choices: unknown[]): string {
const parts: string[] = [];
for (const choice of choices) {
const c = choice as Record<string, unknown>;
const msg = c?.message as Record<string, unknown> | undefined;
if (typeof msg?.content === "string") {
parts.push(msg.content as string);
} else if (Array.isArray(msg?.content)) {
for (const block of msg.content as unknown[]) {
const b = block as Record<string, unknown> | null;
if (b && typeof b === "object" && b.type === "text" && typeof b.text === "string") {
parts.push(b.text as string);
}
}
}
}
return parts.join(" ");
}
export function describeMalformedNonStream(
resp: unknown,
reason: MalformedReason
@@ -342,6 +391,13 @@ export function describeMalformedNonStream(
type: "upstream_response_error",
};
}
if (reason === "content_is_upstream_error") {
return {
message: "upstream reported a failure disguised as a successful response",
code: "upstream_fake_success",
type: "upstream_response_error",
};
}
return {
message:
reason === "no_terminal"

View File

@@ -0,0 +1,113 @@
/**
* Tests for the #13461 "fake success" 2xx-body classifier.
*
* A free/web-session provider (Pollinations, Perplexity web) can answer a
* genuine failure (expired session, exhausted free-tier credits) with HTTP
* 200 and a structurally normal completion whose message content is just
* the provider's own error prose. Neither classifyProviderError (gated on
* 400/401/402/403/429 before it ever looks at the body) nor
* detectMalformedNonStream (checks structural emptiness only) used to catch
* this — the text was forwarded to the client as if the model had genuinely
* answered with that sentence, and combo/auto-fallback never kicked in.
*
* Covers:
* (a) classifyFakeSuccessBody (open-sse/services/errorClassifier.ts) — new
* sibling classifier, allowlist-gated, reusing CREDITS_EXHAUSTED_SIGNALS
* / ACCOUNT_DEACTIVATED_SIGNALS with a short-content + signal-coverage
* guard.
* (b) detectMalformedNonStream (open-sse/utils/diagnostics.ts) — wired to
* consult the classifier for allowlisted providers so the existing
* malformed-200 / combo-failover path in chatCore.ts picks it up.
* (c) False-positive guards: non-allowlisted provider, long legitimate
* answer that merely mentions the topic, short reply without a
* recognized signal phrase.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { detectMalformedNonStream } from "../../open-sse/utils/diagnostics.ts";
import { classifyFakeSuccessBody } from "../../open-sse/services/errorClassifier.ts";
function chatCompletion(content: string) {
return {
id: "chatcmpl-repro",
object: "chat.completion",
choices: [
{
index: 0,
message: { role: "assistant", content },
finish_reason: "stop",
},
],
};
}
// ── (a) classifyFakeSuccessBody ──────────────────────────────────────────────
test("issue #13461: classifyFakeSuccessBody flags a short credits-exhausted body for an allowlisted provider", () => {
const content =
"You have run out of credits. Please sign up at https://enter.pollinations.ai to continue.";
assert.equal(classifyFakeSuccessBody(content, "pollinations"), "quota_exhausted");
});
test("issue #13461: classifyFakeSuccessBody flags an account-deactivated body for an allowlisted provider", () => {
const content = "Sorry, your account has been suspended. Please contact support.";
assert.equal(classifyFakeSuccessBody(content, "perplexity-web"), "account_deactivated");
});
test("issue #13461: classifyFakeSuccessBody ignores an unrecognized provider (allowlist guard)", () => {
const content = "Sorry, out of credits. Please sign up to continue.";
// Same identical phrase that IS recognized for an allowlisted provider —
// must stay untouched for a provider outside the initial allowlist so the
// blast radius of this fix stays controlled (#13461 owner decision).
assert.equal(classifyFakeSuccessBody(content, "openai"), null);
assert.equal(classifyFakeSuccessBody(content, "anthropic"), null);
});
test("issue #13461: classifyFakeSuccessBody ignores a long legitimate answer that merely mentions credits", () => {
const longAnswer =
"Managing your cloud spend well means watching a few things closely: set a monthly budget " +
"alert, review your invoice line items weekly, and make sure you never run out of credits " +
"mid-project by topping up before the low-balance warning fires. A lot of teams also sign up " +
"for a committed-use discount once their usage is predictable, which can meaningfully lower " +
"the effective per-unit cost over a full year of steady traffic.";
assert.ok(
longAnswer.length > 400,
"fixture must exceed the short-content guard to be meaningful"
);
assert.equal(classifyFakeSuccessBody(longAnswer, "pollinations"), null);
});
test("issue #13461: classifyFakeSuccessBody ignores short content with no recognized signal phrase", () => {
const content = "Please go to perplexity.ai and sign up to continue using this feature.";
assert.equal(classifyFakeSuccessBody(content, "perplexity-web"), null);
});
// ── (b) detectMalformedNonStream wiring ──────────────────────────────────────
test("issue #13461: detectMalformedNonStream flags HTTP 200 credits-exhausted text for an allowlisted provider", () => {
const translated = chatCompletion(
"You have run out of credits. Please sign up at https://enter.pollinations.ai to continue."
);
assert.equal(detectMalformedNonStream(translated, "pollinations"), "content_is_upstream_error");
});
test("issue #13461: detectMalformedNonStream leaves the identical body untouched without a provider", () => {
const translated = chatCompletion(
"You have run out of credits. Please sign up at https://enter.pollinations.ai to continue."
);
assert.equal(detectMalformedNonStream(translated), null);
});
test("issue #13461: detectMalformedNonStream leaves the identical body untouched for a non-allowlisted provider", () => {
const translated = chatCompletion(
"You have run out of credits. Please sign up at https://enter.pollinations.ai to continue."
);
assert.equal(detectMalformedNonStream(translated, "openai"), null);
});
test("issue #13461: detectMalformedNonStream never flags a normal completion for an allowlisted provider", () => {
const translated = chatCompletion("The capital of France is Paris.");
assert.equal(detectMalformedNonStream(translated, "pollinations"), null);
});