Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/8200-perplexity-web-401-cooldown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- fix(providers): carve cookie-auth providers out of terminal 401 'expired' classification so one 401 cooldowns instead of killing the connection
23 changes: 18 additions & 5 deletions src/sse/services/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,10 +373,21 @@ function isTerminalConnectionStatus(connection: ProviderConnectionView): boolean
return status === "credits_exhausted" || status === "banned" || status === "expired";
}

// #8200: cookie-auth providers (perplexity-web, grok-web, ...) use a rotating browser
// session, not a static API key — a 401 means "session needs a refresh", not "dead".
function isRecoverableCookieAuth401(provider: string | null, providerErrorType: string | null): boolean {
return (
providerErrorType !== PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED &&
provider != null &&
resolveProviderId(provider) in WEB_COOKIE_PROVIDERS
);
}

function resolveTerminalConnectionStatus(
status: number,
result: { permanent?: boolean; creditsExhausted?: boolean },
providerErrorType: string | null = null
providerErrorType: string | null = null,
provider: string | null = null
): string | null {
if (result.creditsExhausted || status === 402) return "credits_exhausted";
if (
Expand All @@ -389,9 +400,10 @@ function resolveTerminalConnectionStatus(
return "banned";
}
if (
providerErrorType === PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED ||
providerErrorType === PROVIDER_ERROR_TYPES.UNAUTHORIZED ||
status === 401
(providerErrorType === PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED ||
providerErrorType === PROVIDER_ERROR_TYPES.UNAUTHORIZED ||
status === 401) &&
!isRecoverableCookieAuth401(provider, providerErrorType)
) {
return "expired";
}
Expand Down Expand Up @@ -2086,7 +2098,8 @@ export async function markAccountUnavailable(
const terminalStatus = resolveTerminalConnectionStatus(
status,
result as { permanent?: boolean; creditsExhausted?: boolean },
providerErrorType
providerErrorType,
provider
);
const cooldownMs = terminalStatus ? 0 : rawCooldownMs;

Expand Down
84 changes: 84 additions & 0 deletions tests/unit/8200-perplexity-web-401-cooldown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

// Permanent regression guard for #8200: a single recoverable 401 on a
// cookie-auth provider (perplexity-web) must NOT terminal-expire the only
// connection. Before the fix, resolveTerminalConnectionStatus() mapped ANY
// non-OAuth 401 to the TERMINAL testStatus "expired" (cooldownMs 0, no
// self-heal), which then made getProviderCredentials() filter the
// connection out on the very next request -> "No active credentials".

const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-8200-perplexity-"));
process.env.DATA_DIR = TEST_DATA_DIR;

const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const auth = await import("../../src/sse/services/auth.ts");

async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}

test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});

test("BUG #8200: single perplexity-web 401 (cookie expiry) does not terminal-expire the only connection", async () => {
await resetStorage();

const conn = await providersDb.createProviderConnection({
provider: "perplexity-web",
authType: "apikey",
apiKey: "__Secure-next-auth.session-token=stale-cookie",
isActive: true,
testStatus: "active",
});
const connId = String(conn.id);

await auth.markAccountUnavailable(
connId,
401,
"Perplexity auth failed — session cookie may be expired. Re-paste your __Secure-next-auth.session-token.",
"perplexity-web",
"pplx-auto"
);

const after = await providersDb.getProviderConnectionById(connId);
assert.equal(after.isActive, true, "connection row should stay active");
assert.notEqual(
after.testStatus,
"expired",
"a recoverable perplexity-web 401 must not be classified terminal 'expired'"
);

const credentials = await auth.getProviderCredentials("perplexity-web");
assert.notEqual(
credentials,
null,
"getProviderCredentials must still return the connection after exactly one 401"
);
});

test("markAccountUnavailable keeps the existing 401->expired terminal mapping for plain apikey providers", async () => {
await resetStorage();

const conn = await providersDb.createProviderConnection({
provider: "openai",
authType: "apikey",
apiKey: "sk-expired",
isActive: true,
testStatus: "active",
});
const connId = String(conn.id);

await auth.markAccountUnavailable(connId, 401, "unauthorized", "openai", "gpt-4.1");

const after = await providersDb.getProviderConnectionById(connId);
assert.equal(after.testStatus, "expired");
});
Loading