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