diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e0b1b1608..6072fa20a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/web/search/providers/perplexity-auth.ts b/packages/coding-agent/src/web/search/providers/perplexity-auth.ts index ee8198631..0486fd129 100644 --- a/packages/coding-agent/src/web/search/providers/perplexity-auth.ts +++ b/packages/coding-agent/src/web/search/providers/perplexity-auth.ts @@ -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); diff --git a/packages/coding-agent/src/web/search/providers/perplexity.ts b/packages/coding-agent/src/web/search/providers/perplexity.ts index 757d49a8b..548c8dbc8 100644 --- a/packages/coding-agent/src/web/search/providers/perplexity.ts +++ b/packages/coding-agent/src/web/search/providers/perplexity.ts @@ -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(); diff --git a/packages/coding-agent/test/web/search/perplexity.test.ts b/packages/coding-agent/test/web/search/perplexity.test.ts index 30c4f9949..142939169 100644 --- a/packages/coding-agent/test/web/search/perplexity.test.ts +++ b/packages/coding-agent/test/web/search/perplexity.test.ts @@ -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; },