diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 45b731581..0b985df8c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -28,6 +28,7 @@ - Fixed custom-rendered tools that set `mergeCallAndResult` (e.g. `lsp`) rendering a redundant tool-name line above the framed result once a result arrived. `ToolExecutionComponent`'s custom-tool branch now emits the fallback label only when the tool has no `renderCall` and the call is not suppressed by an existing result, matching the built-in renderer branch. - Fixed the `write` tool result rendering with a green success checkmark even when the write failed. `writeToolRenderer.renderResult` now branches on `result.isError`, rendering the error status icon plus the failure message instead of the success header and content preview. - Fixed Perplexity OAuth/cookie web search returning a refusal answer ("I don't currently have access to the web-search tools in this turn") despite returning real sources. `callPerplexityOAuth` was prepending the API-style `web-search` system prompt to the query (`query_str = systemPrompt + "\n\n" + query`), but the consumer `www.perplexity.ai/rest/sse/perplexity_ask` endpoint has no system-message slot and reads the prepended instruction as a meta-prompt, making the model decline. The OAuth/cookie path now sends the bare query; the API-key path still passes the system prompt as a proper `system` message. +- Fixed Perplexity OAuth web search always returning the free `turbo` model instead of the account's Pro model (e.g. `pplx_pro_upgraded`/Sonar). The `www.perplexity.ai/rest/sse/perplexity_ask` endpoint authenticates via the `__Secure-next-auth.session-token` cookie and ignores the `Authorization: Bearer` header entirely — so sending the OAuth session token as a bearer was treated as an anonymous request, which silently downgrades to `turbo` regardless of `model_preference`. The stored Perplexity OAuth token is itself the next-auth session JWT (the macOS app injects the same value as that cookie), so `callPerplexityAsk` now sends it as the `__Secure-next-auth.session-token` cookie, unlocking Pro model selection. ## [15.9.67] - 2026-06-06 ### Added diff --git a/packages/coding-agent/src/web/search/providers/perplexity.ts b/packages/coding-agent/src/web/search/providers/perplexity.ts index 92a19865d..7a1579f01 100644 --- a/packages/coding-agent/src/web/search/providers/perplexity.ts +++ b/packages/coding-agent/src/web/search/providers/perplexity.ts @@ -160,7 +160,6 @@ function asRecord(value: unknown): Record | null { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; return value as Record; } -} function parseJson(text: string): unknown | null { try { @@ -246,9 +245,10 @@ function sourcesFromTextPayload(text: string | undefined): SearchSource[] { const sources: SearchSource[] = []; for (const value of webResults) { const result = asRecord(value); - const url = result?.url; + if (!result) continue; + const url = result.url; if (typeof url !== "string" || url.length === 0) continue; - const name = result.name; + const name = result.name ?? result.title; const snippet = result.snippet; const timestamp = result.timestamp; sources.push({ @@ -445,11 +445,8 @@ function buildOAuthAnswer(event: PerplexityOAuthStreamEvent): string { return ""; } -async function callPerplexityOAuth( - auth: - | { type: "oauth"; token: string } - | { type: "cookies"; cookies: string } - | { type: "anonymous" }, +async function callPerplexityAsk( + auth: { type: "oauth"; token: string } | { type: "cookies"; cookies: string } | { type: "anonymous" }, params: PerplexitySearchParams, ): Promise<{ answer: string; sources: SearchSource[]; model?: string; requestId?: string }> { const requestId = crypto.randomUUID(); @@ -470,7 +467,13 @@ async function callPerplexityOAuth( "X-Request-ID": requestId, }; if (auth.type === "oauth") { - headers.Authorization = `Bearer ${auth.token}`; + // The ask endpoint authenticates via the next-auth session cookie, NOT a + // bearer header — a bearer (even a garbage one) is ignored and the request + // silently falls back to the anonymous free `turbo` model regardless of + // `model_preference`. The stored OAuth token IS the Perplexity session JWT + // (the native app injects the same value as this cookie), so sending it as + // the cookie is what unlocks the account's Pro model selection. + headers.Cookie = `__Secure-next-auth.session-token=${auth.token}`; } else if (auth.type === "cookies") { headers.Cookie = auth.cookies; } @@ -641,7 +644,7 @@ export async function searchPerplexity(params: PerplexitySearchParams): Promise< const auth = await findPerplexityAuth(params.authStorage, params.sessionId, params.signal); if (auth.type !== "api_key") { - const askResult = await callPerplexityOAuth(auth, params); + const askResult = await callPerplexityAsk(auth, params); return applySourceLimit( { provider: "perplexity", diff --git a/packages/coding-agent/src/web/search/types.ts b/packages/coding-agent/src/web/search/types.ts index 946d6cc2d..334971033 100644 --- a/packages/coding-agent/src/web/search/types.ts +++ b/packages/coding-agent/src/web/search/types.ts @@ -33,7 +33,11 @@ export const SEARCH_PROVIDER_OPTIONS = [ description: "Automatically uses the first configured web-search provider", }, { value: "tavily", label: "Tavily", description: "Requires TAVILY_API_KEY" }, - { value: "perplexity", label: "Perplexity", description: "Uses auth when configured; explicit selection falls back to anonymous search" }, + { + value: "perplexity", + label: "Perplexity", + description: "Uses auth when configured; explicit selection falls back to anonymous search", + }, { value: "brave", label: "Brave", description: "Requires BRAVE_API_KEY" }, { value: "jina", label: "Jina", description: "Requires JINA_API_KEY" }, { value: "kimi", label: "Kimi", description: "Requires MOONSHOT_SEARCH_API_KEY or MOONSHOT_API_KEY" }, diff --git a/packages/coding-agent/test/web/search/perplexity.test.ts b/packages/coding-agent/test/web/search/perplexity.test.ts index 1d2ef6781..4a7e30923 100644 --- a/packages/coding-agent/test/web/search/perplexity.test.ts +++ b/packages/coding-agent/test/web/search/perplexity.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import type { AuthStorage } from "@oh-my-pi/pi-ai"; -import { searchPerplexity } from "@oh-my-pi/pi-coding-agent/web/search/providers/perplexity"; +import { PerplexityProvider, searchPerplexity } from "@oh-my-pi/pi-coding-agent/web/search/providers/perplexity"; import { hookFetch } from "@oh-my-pi/pi-utils"; const API_URL = "https://api.perplexity.ai/chat/completions"; @@ -97,3 +97,172 @@ describe("Perplexity API-key request shape", () => { expect(response.relatedQuestions).toBeUndefined(); }); }); + +const OAUTH_ASK_URL = "https://www.perplexity.ai/rest/sse/perplexity_ask"; + +// OAuth path: getOAuthAccess returns a bearer (no `.`-delimited exp claim, so it +// is treated as non-expiring), making findPerplexityAuth pick the oauth branch. +const oauthAuthStorage = { + async getOAuthAccess() { + return { accessToken: "test-oauth-token" }; + }, + hasAuth() { + return true; + }, +} as unknown as AuthStorage; + +const anonymousAuthStorage = { + async getOAuthAccess() { + return undefined; + }, + hasAuth() { + return false; + }, +} as unknown as AuthStorage; + +function mockOAuth(capture: (body: Record, headers: Headers) => void) { + const event = { + final: true, + display_model: "turbo", + uuid: "req-oauth", + blocks: [ + { intended_usage: "ask_text", markdown_block: { answer: "OAuth answer" } }, + { + intended_usage: "web_results", + web_result_block: { web_results: [{ name: "T", url: "https://example.com", snippet: "s" }] }, + }, + ], + }; + const sseBody = `data: ${JSON.stringify(event)}\n\n`; + return hookFetch(async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === OAUTH_ASK_URL) { + capture(JSON.parse(init?.body as string), new Headers(init?.headers)); + return new Response(sseBody, { status: 200, headers: { "Content-Type": "text/event-stream" } }); + } + return new Response("not mocked", { status: 500 }); + }); +} + +function mockAnonymous(capture: (body: Record, headers: Headers) => void) { + const answerPayload = { + answer: "Anonymous answer", + web_results: [{ name: "Example", url: "https://example.com", snippet: "s" }], + chunks: ["Anonymous ", "answer"], + structured_answer: [{ type: "markdown", text: "Anonymous answer", chunks: ["Anonymous ", "answer"] }], + }; + const event = { + final: true, + display_model: "turbo", + uuid: "req-anon", + text: JSON.stringify([{ step_type: "FINAL", content: { answer: JSON.stringify(answerPayload) }, uuid: "" }]), + }; + const sseBody = `data: ${JSON.stringify(event)}\n\n`; + return hookFetch(async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === OAUTH_ASK_URL) { + capture(JSON.parse(init?.body as string), new Headers(init?.headers)); + return new Response(sseBody, { status: 200, headers: { "Content-Type": "text/event-stream" } }); + } + return new Response("not mocked", { status: 500 }); + }); +} + +describe("Perplexity OAuth request shape", () => { + const savedCookies = process.env.PERPLEXITY_COOKIES; + + beforeEach(() => { + delete process.env.PERPLEXITY_COOKIES; // cookies take precedence over oauth; keep them out + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (savedCookies === undefined) delete process.env.PERPLEXITY_COOKIES; + else process.env.PERPLEXITY_COOKIES = savedCookies; + }); + + it("sends the bare query, never the API-style system prompt, to the ask endpoint", async () => { + let body: Record | undefined; + let headers: Headers | undefined; + using _hook = mockOAuth((b, h) => { + body = b; + headers = h; + }); + + const response = await searchPerplexity({ + query: "quic vs tcp", + system_prompt: "Research assistant with web search. Synthesize comprehensive answers.", + authStorage: oauthAuthStorage, + }); + + // The consumer ask endpoint has no system slot; prepending the prompt makes + // the model refuse ("I don't have web-search tools in this turn"). + expect(body?.query_str).toBe("quic vs tcp"); + expect((body?.params as Record).query_str).toBe("quic vs tcp"); + // The ask endpoint authenticates via the next-auth session cookie; a bearer + // header is ignored and silently downgrades to the anonymous `turbo` model. + expect(headers?.get("cookie")).toBe("__Secure-next-auth.session-token=test-oauth-token"); + expect(headers?.has("authorization")).toBe(false); + expect(response.authMode).toBe("oauth"); + expect(response.answer).toBe("OAuth answer"); + }); +}); + +describe("Perplexity anonymous fallback", () => { + const savedKey = process.env.PERPLEXITY_API_KEY; + const savedPplxKey = process.env.PPLX_API_KEY; + const savedCookies = process.env.PERPLEXITY_COOKIES; + + beforeEach(() => { + delete process.env.PERPLEXITY_API_KEY; + delete process.env.PPLX_API_KEY; + delete process.env.PERPLEXITY_COOKIES; + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (savedKey === undefined) delete process.env.PERPLEXITY_API_KEY; + else process.env.PERPLEXITY_API_KEY = savedKey; + if (savedPplxKey === undefined) delete process.env.PPLX_API_KEY; + else process.env.PPLX_API_KEY = savedPplxKey; + if (savedCookies === undefined) delete process.env.PERPLEXITY_COOKIES; + else process.env.PERPLEXITY_COOKIES = savedCookies; + }); + + it("uses the browser ask endpoint without credential headers when no key is configured", async () => { + let body: Record | undefined; + let headers: Headers | undefined; + using _hook = mockAnonymous((b, h) => { + body = b; + headers = h; + }); + + const response = await searchPerplexity({ query: "anonymous search", authStorage: anonymousAuthStorage }); + const requestParams = body?.params as Record; + + expect(headers?.has("authorization")).toBe(false); + expect(headers?.has("cookie")).toBe(false); + expect(headers?.get("user-agent")).toContain("Mozilla/5.0"); + expect(requestParams.model_preference).toBe("experimental"); + expect(requestParams.send_back_text_in_streaming_api).toBe(true); + expect(requestParams.source).toBe("default"); + expect(response.authMode).toBe("anonymous"); + expect(response.answer).toBe("Anonymous answer"); + expect(response.sources).toEqual([ + { + title: "Example", + url: "https://example.com", + snippet: "s", + publishedDate: undefined, + ageSeconds: undefined, + }, + ]); + }); + + it("keeps anonymous Perplexity out of auto provider selection but allows explicit selection", () => { + const provider = new PerplexityProvider(); + + expect(provider.isAvailable(anonymousAuthStorage)).toBe(false); + expect(provider.isExplicitlyAvailable(anonymousAuthStorage)).toBe(true); + }); +});