fix(coding-agent/web): unlocked perplexity pro via session cookie

- Sent the OAuth token as `__Secure-next-auth.session-token` cookie since the ask endpoint ignores bearer headers and silently downgrades to `turbo`.
- Fell back to `result.title` when web results omit `name`.
- Renamed `callPerplexityOAuth` to `callPerplexityAsk` and removed a stray brace.
- Added tests covering OAuth, API-key, and anonymous request shapes.
This commit is contained in:
can1357
2026-06-06 20:01:51 +02:00
parent f73892d491
commit 246688e874
4 changed files with 189 additions and 12 deletions
+1
View File
@@ -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
@@ -160,7 +160,6 @@ function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
}
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",
@@ -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" },
@@ -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<string, unknown>, 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<string, unknown>, 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<string, unknown> | 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<string, unknown>).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<string, unknown> | 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<string, unknown>;
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);
});
});