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:
@@ -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;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user