fix(web-search): stop perplexity oauth token leaking to api-key endpoint

The consumer ask endpoint (/rest/sse/perplexity_ask) intermittently closes
its socket before responding. getApiConfigs emitted the OAuth session JWT
(returned by getApiKey while OAuth is the active origin) as a direct
api.perplexity.ai api-key config, so a transient transport failure on the
ask endpoint fell through and sent the session token as a Bearer to the
direct API, whose 401 masked the real error.

- Suppress the direct api-key config when getCredentialOrigin reports the
  active perplexity credential as oauth.
- Give the OAuth ask request one transport-only retry; HTTP responses
  (including 401/429) are final and never retried.
- Add regression coverage for both legs.

Fixes #5315
This commit is contained in:
roboomp
2026-07-14 18:28:43 +00:00
parent d0f90f35ae
commit c97449c51d
4 changed files with 152 additions and 13 deletions
+4
View File
@@ -14,6 +14,10 @@
- Removed the unreliable Bing and Yahoo HTML-scraping web search providers
### Fixed
- Fixed Perplexity OAuth web search intermittently failing with `Perplexity authorization failed (401). Check API key or base URL.` when the consumer ask endpoint (`/rest/sse/perplexity_ask`) dropped its socket before responding. `getApiConfigs` was emitting the OAuth session JWT (which `getApiKey` returns while OAuth is the active origin) as a direct `api.perplexity.ai` api-key config, so a transient transport failure on the ask endpoint fell through and sent the session token as a Bearer to the direct API, whose 401 masked the real error. The direct api-key config is now suppressed when the active Perplexity credential origin is OAuth, and the OAuth ask request gets one transport-only retry (HTTP responses, including 401/429, are never retried). ([#5315](https://github.com/can1357/oh-my-pi/issues/5315))
## [16.4.8] - 2026-07-12
### Added
@@ -44,17 +44,26 @@ export async function getApiConfigs(
const useResponses = $env.PI_PERPLEXITY_RESPONSES === "1";
const configs: ApiConfig[] = [];
const perplexityKey = await authStorage.getApiKey("perplexity", sessionId, options);
if (perplexityKey) {
configs.push({
type: "api_key",
apiKey: perplexityKey,
provider: "perplexity",
chatBaseUrl: PERPLEXITY_CHAT_BASE_URL,
responsesBaseUrl: PERPLEXITY_RESPONSES_BASE_URL,
modelPrefix: "",
useResponses,
});
// A Perplexity OAuth session and a real API key are mutually exclusive here:
// when the active credential origin is OAuth, `getApiKey("perplexity")`
// returns the OAuth session JWT (OAuth wins in AuthStorage.getApiKey), not an
// api.perplexity.ai key. Emitting it as a direct api-key config makes the
// search loop send the session token as a Bearer to the direct API endpoint,
// which rejects it with 401 and masks the real (transport) failure — see #5315.
// Skip the direct config in that case; the OAuth ask-endpoint method covers it.
if (authStorage.getCredentialOrigin("perplexity")?.kind !== "oauth") {
const perplexityKey = await authStorage.getApiKey("perplexity", sessionId, options);
if (perplexityKey) {
configs.push({
type: "api_key",
apiKey: perplexityKey,
provider: "perplexity",
chatBaseUrl: PERPLEXITY_CHAT_BASE_URL,
responsesBaseUrl: PERPLEXITY_RESPONSES_BASE_URL,
modelPrefix: "",
useResponses,
});
}
}
const openrouterKey = await authStorage.getApiKey("openrouter", sessionId, options);
@@ -604,7 +604,7 @@ async function callPerplexityAsk(
requestParams.send_back_text_in_streaming_api = true;
}
const response = await (params.fetch ?? fetch)(PERPLEXITY_OAUTH_ASK_URL, {
const requestInit = {
method: "POST",
headers,
body: JSON.stringify({
@@ -612,7 +612,19 @@ async function callPerplexityAsk(
params: requestParams,
}),
signal: withHardTimeout(params.signal),
});
};
// The consumer ask endpoint intermittently drops the socket before sending an
// HTTP response (#5315). Retry the transport exactly once; once we hold an
// HTTP response (handled below) the outcome — including non-2xx — is final and
// never retried, so a real 401/429 is never papered over by a second attempt.
let response: Response;
try {
response = await (params.fetch ?? fetch)(PERPLEXITY_OAUTH_ASK_URL, requestInit);
} catch (error) {
if (params.signal?.aborted) throw error;
response = await (params.fetch ?? fetch)(PERPLEXITY_OAUTH_ASK_URL, requestInit);
}
if (!response.ok) {
const errorText = await response.text();
@@ -18,6 +18,12 @@ const apiKeyAuthStorage = {
if (provider === "openrouter") return process.env.OPENROUTER_API_KEY;
return undefined;
},
getCredentialOrigin(provider: string) {
// Env-backed key (not OAuth) — the direct api-key config must still be emitted.
if (provider === "perplexity" && process.env.PERPLEXITY_API_KEY) return { kind: "env" };
if (provider === "openrouter" && process.env.OPENROUTER_API_KEY) return { kind: "env" };
return undefined;
},
hasAuth() {
return false;
},
@@ -261,6 +267,9 @@ const oauthAuthStorage = {
async getApiKey() {
return undefined;
},
getCredentialOrigin(provider: string) {
return provider === "perplexity" ? { kind: "oauth" } : undefined;
},
hasAuth() {
return true;
},
@@ -273,6 +282,9 @@ const anonymousAuthStorage = {
async getApiKey() {
return undefined;
},
getCredentialOrigin() {
return undefined;
},
hasAuth() {
return false;
},
@@ -370,6 +382,102 @@ describe("Perplexity OAuth request shape", () => {
});
});
describe("Perplexity OAuth transport failure (issue #5315)", () => {
const savedCookies = process.env.PERPLEXITY_COOKIES;
beforeEach(() => {
delete process.env.PERPLEXITY_COOKIES; // cookies precede oauth; keep them out
});
afterEach(() => {
vi.restoreAllMocks();
if (savedCookies === undefined) delete process.env.PERPLEXITY_COOKIES;
else process.env.PERPLEXITY_COOKIES = savedCookies;
});
// Mirrors production: an active OAuth session makes getApiKey("perplexity")
// return the OAuth JWT itself, and getCredentialOrigin reports origin "oauth".
const oauthOriginStorage = {
async getOAuthAccess() {
return { accessToken: "oauth-session-jwt" };
},
async getApiKey(provider: string) {
if (provider === "perplexity") return "oauth-session-jwt";
return undefined;
},
getCredentialOrigin(provider: string) {
return provider === "perplexity" ? { kind: "oauth" } : undefined;
},
async rotateSessionCredential() {
return false;
},
hasAuth() {
return true;
},
} as unknown as AuthStorage;
it("does not emit a direct api-key config from the OAuth session token", async () => {
const methods = await getAvailableAuthMethods(oauthOriginStorage, undefined, undefined);
expect(methods.some(m => m.type === "oauth")).toBe(true);
// The OAuth JWT must never appear as a Perplexity api_key config — that is
// what got sent as a Bearer to api.perplexity.ai and rejected with 401.
expect(methods.some(m => m.type === "api_key" && m.provider === "perplexity")).toBe(false);
});
it("retries the ask endpoint once on transport failure and never falls through to /chat/completions", async () => {
let askCalls = 0;
let apiCalls = 0;
const event = {
final: true,
display_model: "pplx_pro",
uuid: "req-oauth",
blocks: [{ intended_usage: "ask_text", markdown_block: { answer: "OAuth answer" } }],
};
const askBody = `data: ${JSON.stringify(event)}\n\n`;
const fetchMock: FetchImpl = async input => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url === OAUTH_ASK_URL) {
askCalls++;
if (askCalls === 1) throw new TypeError("socket connection closed before an HTTP response");
return new Response(askBody, { status: 200, headers: { "Content-Type": "text/event-stream" } });
}
if (url === API_URL) {
apiCalls++;
return new Response(JSON.stringify({ error: { message: "Unauthorized" } }), { status: 401 });
}
return new Response("not mocked", { status: 500 });
};
const response = await searchPerplexity({
query: "OpenAI official website",
authStorage: oauthOriginStorage,
fetch: fetchMock,
});
expect(askCalls).toBe(2); // first fails at transport, second succeeds
expect(apiCalls).toBe(0); // the OAuth token is never sent to the api-key endpoint
expect(response.authMode).toBe("oauth");
expect(response.answer).toBe("OAuth answer");
});
it("does not retry once an HTTP response is received", async () => {
let askCalls = 0;
const fetchMock: FetchImpl = async input => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url === OAUTH_ASK_URL) {
askCalls++;
return new Response("nope", { status: 401 });
}
return new Response("not mocked", { status: 500 });
};
await expect(
searchPerplexity({ query: "q", authStorage: oauthOriginStorage, fetch: fetchMock }),
).rejects.toThrow();
expect(askCalls).toBe(1); // a real HTTP error is final, not retried
});
});
describe("Perplexity anonymous fallback", () => {
const savedKey = process.env.PERPLEXITY_API_KEY;
const savedPplxKey = process.env.PPLX_API_KEY;
@@ -529,6 +637,9 @@ describe("Perplexity Authentication order", () => {
async getApiKey() {
return undefined;
},
getCredentialOrigin(provider: string) {
return provider === "perplexity" ? { kind: "oauth" } : undefined;
},
hasAuth() {
return true;
},
@@ -556,6 +667,9 @@ describe("Perplexity Authentication order", () => {
if (provider === "perplexity") return "api-key";
return undefined;
},
getCredentialOrigin(provider: string) {
return provider === "perplexity" ? { kind: "oauth" } : undefined;
},
hasAuth() {
return true;
},