diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4ac3f4feb..dc108df82 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,8 +1,10 @@ # Changelog ## [Unreleased] + ### Added +- Added anonymous fallback for Perplexity web search, allowing `web_search` and explicit Perplexity provider usage when no Perplexity credentials are configured - Added `gallery` CLI command to render built-in tool renderer output across streaming, in-progress, success, and failure states - Added `omp gallery` filtering and rendering options (`--tool`, `--state`, `--width`, `--expanded`, and `--plain`) for focused renderer previews and plain-text output - Added `omp gallery` fidelity for tools whose renderers are attached on the tool instance (`lsp`, `task`): the gallery now drives them through the same custom-tool render branch production uses, so regressions in that path surface in the gallery rather than only in a live session. @@ -11,12 +13,14 @@ ### Changed +- Changed Perplexity explicit provider availability checks so the setup wizard can mark `perplexity` as available for manual selection without credentials, while auto provider discovery still requires auth - Changed the default persistent model selector shortcut from `Ctrl+L` to `Alt+M`, leaving `Ctrl+L` for display reset. Existing user remaps in `keybindings.yml` are preserved. - Changed the edit tool result header to carry the diff change stats (`+N / -M / K hunks`) inline next to the file path, and removed the redundant lone language-icon metadata row and the blank line between the header and the diff body, so a single-hunk edit renders as `✔ Edit: path:LINE ⟨+3 / 1 hunk⟩` immediately followed by the diff. - Changed the `web_search` tool result rendering: the answer text now shows in full instead of being truncated to a "… N more lines" preview (the `omp q` CLI still caps its compact output), each source renders as a single `title (domain) · age` line with the URL linked on the title (dropping the snippet and bare-URL rows), and the metadata block collapses to one `Provider: @ ()` line plus `Usage:` (removing the redundant Sources/Citations/Request/Queries rows). ### Fixed +- Fixed Perplexity `perplexity_ask` response parsing so OAuth, cookie, and anonymous searches correctly extract answer text and sources from JSON payloads - Fixed framed read results rendering with an extra blank row above and below the output block. - Fixed collapsed search result previews that could show only "… N more matches" when the first grouped section exceeded the preview budget. Collapsed search output now compacts to match rows, fills the budget with visible hits before the summary, and keeps truncation details out of the bottom user-visible notice. - Fixed boolean environment flag overrides that were ORed with settings, so `PI_INTENT_TRACING=0`, `PI_AUTO_QA=0`, and per-backend eval flags now take precedence when present while falling back to config when unset. diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index cefb84942..0f770c0ce 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -284,7 +284,7 @@ export function getExtraHelpText(): string { ${chalk.dim("# Search & Tools")} EXA_API_KEY - Exa web search BRAVE_API_KEY - Brave web search - PERPLEXITY_API_KEY - Perplexity web search (API) + PERPLEXITY_API_KEY - Perplexity web search API key (optional; anonymous fallback) PERPLEXITY_COOKIES - Perplexity web search (session cookie) TAVILY_API_KEY - Tavily web search ANTHROPIC_SEARCH_API_KEY - Anthropic web search (override; isolates search from main ANTHROPIC_API_KEY) diff --git a/packages/coding-agent/src/extensibility/plugins/doctor.ts b/packages/coding-agent/src/extensibility/plugins/doctor.ts index ff33cc562..5bd600c67 100644 --- a/packages/coding-agent/src/extensibility/plugins/doctor.ts +++ b/packages/coding-agent/src/extensibility/plugins/doctor.ts @@ -25,7 +25,6 @@ export async function runDoctorChecks(): Promise { const apiKeys = [ { name: "ANTHROPIC_API_KEY", description: "Anthropic API" }, { name: "OPENAI_API_KEY", description: "OpenAI API" }, - { name: "PERPLEXITY_API_KEY", description: "Perplexity search" }, { name: "EXA_API_KEY", description: "Exa search" }, ]; diff --git a/packages/coding-agent/src/modes/setup-wizard/scenes/web-search.ts b/packages/coding-agent/src/modes/setup-wizard/scenes/web-search.ts index 906803bfe..221da8b4a 100644 --- a/packages/coding-agent/src/modes/setup-wizard/scenes/web-search.ts +++ b/packages/coding-agent/src/modes/setup-wizard/scenes/web-search.ts @@ -19,7 +19,8 @@ type Availability = "checking" | boolean; /** * "Web search" panel: picks the provider the web_search tool should prefer and * reports whether the highlighted provider is ready to use given current - * credentials (env keys or OAuth sign-ins from the Sign in tab). + * credentials (env keys or OAuth sign-ins from the Sign in tab) or an + * unauthenticated fallback. */ export class WebSearchTab implements SetupTab { readonly id = "web-search"; @@ -91,7 +92,7 @@ export class WebSearchTab implements SetupTab { let ready = false; try { const provider = await getSearchProvider(id); - ready = await provider.isAvailable(this.host.ctx.session.modelRegistry.authStorage); + ready = await provider.isExplicitlyAvailable(this.host.ctx.session.modelRegistry.authStorage); } catch { ready = false; } diff --git a/packages/coding-agent/src/web/search/providers/perplexity.ts b/packages/coding-agent/src/web/search/providers/perplexity.ts index e03112a64..92a19865d 100644 --- a/packages/coding-agent/src/web/search/providers/perplexity.ts +++ b/packages/coding-agent/src/web/search/providers/perplexity.ts @@ -1,10 +1,11 @@ /** * Perplexity Web Search Provider * - * Supports three auth modes: + * Supports four auth modes: * - Cookies (`PERPLEXITY_COOKIES`) via `www.perplexity.ai/rest/sse/perplexity_ask` * - OAuth/session bearer via `AuthStorage` and `www.perplexity.ai/rest/sse/perplexity_ask` * - API key (`PERPLEXITY_API_KEY`) via `api.perplexity.ai/chat/completions` + * - Anonymous via `www.perplexity.ai/rest/sse/perplexity_ask` */ import { type AuthStorage, getEnvApiKey } from "@oh-my-pi/pi-ai"; @@ -32,6 +33,8 @@ const DEFAULT_NUM_SEARCH_RESULTS = 20; const OAUTH_EXPIRY_BUFFER_MS = 5 * 60 * 1000; const OAUTH_API_VERSION = "2.18"; const OAUTH_USER_AGENT = "Perplexity/641 CFNetwork/1568 Darwin/25.2.0"; +const ANONYMOUS_USER_AGENT = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; type PerplexityAuth = | { @@ -45,6 +48,9 @@ type PerplexityAuth = | { type: "cookies"; cookies: string; + } + | { + type: "anonymous"; }; interface PerplexityOAuthStreamMarkdownBlock { @@ -149,6 +155,112 @@ function mergeOAuthEventSnapshot( return merged; } + +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 { + return JSON.parse(text); + } catch { + return null; + } +} + +function textFromChunks(value: unknown): string | null { + if (!Array.isArray(value) || value.length === 0) return null; + let text = ""; + for (const chunk of value) { + if (typeof chunk !== "string") return null; + text += chunk; + } + return text.length > 0 ? text : null; +} + +function textFromStructuredAnswer(value: unknown): string | null { + if (!Array.isArray(value)) return null; + for (const item of value) { + const record = asRecord(item); + if (!record) continue; + const text = record.text; + if (typeof text === "string" && text.length > 0) return text; + const chunks = textFromChunks(record.chunks); + if (chunks) return chunks; + } + return null; +} + +function answerFromTextPayload(payload: Record): string | null { + const structured = textFromStructuredAnswer(payload.structured_answer); + if (structured) return structured; + const chunks = textFromChunks(payload.chunks); + if (chunks) return chunks; + const answer = payload.answer; + return typeof answer === "string" && answer.length > 0 ? answer : null; +} + +function parseOAuthTextPayload(text: string): Record | null { + const parsed = parseJson(text); + const direct = asRecord(parsed); + if (direct) return direct; + if (!Array.isArray(parsed)) return null; + + for (const item of parsed) { + const step = asRecord(item); + const content = asRecord(step?.content); + const answer = content?.answer; + if (typeof answer !== "string" || answer.length === 0) continue; + const payload = asRecord(parseJson(answer)); + if (payload) return payload; + } + return null; +} + +function parseOAuthTextAnswer(text: string): string { + const payload = parseOAuthTextPayload(text); + if (payload) { + const answer = answerFromTextPayload(payload); + if (answer) return answer; + } + + const parsed = parseJson(text); + if (!Array.isArray(parsed)) return text; + for (const item of parsed) { + const step = asRecord(item); + const content = asRecord(step?.content); + const answer = content?.answer; + if (typeof answer === "string" && answer.length > 0) return answer; + } + return text; +} + +function sourcesFromTextPayload(text: string | undefined): SearchSource[] { + if (!text) return []; + const payload = parseOAuthTextPayload(text); + const webResults = payload?.web_results; + if (!Array.isArray(webResults) || webResults.length === 0) return []; + + const sources: SearchSource[] = []; + for (const value of webResults) { + const result = asRecord(value); + const url = result?.url; + if (typeof url !== "string" || url.length === 0) continue; + const name = result.name; + const snippet = result.snippet; + const timestamp = result.timestamp; + sources.push({ + title: typeof name === "string" && name.length > 0 ? name : url, + url, + snippet: typeof snippet === "string" ? snippet : undefined, + publishedDate: typeof timestamp === "string" ? timestamp : undefined, + ageSeconds: dateToAgeSeconds(typeof timestamp === "string" ? timestamp : undefined), + }); + } + return sources; +} export interface PerplexitySearchParams { signal?: AbortSignal; query: string; @@ -216,7 +328,7 @@ async function findPerplexityAuth( authStorage: AuthStorage, sessionId: string | undefined, signal: AbortSignal | undefined, -): Promise { +): Promise { // 1. PERPLEXITY_COOKIES env var const cookies = $env.PERPLEXITY_COOKIES?.trim(); if (cookies) { @@ -235,7 +347,9 @@ async function findPerplexityAuth( if (apiKey) { return { type: "api_key", token: apiKey }; } - return null; + + // 4. The consumer ask endpoint currently accepts unauthenticated browser-style requests. + return { type: "anonymous" }; } /** Call Perplexity API-key endpoint. */ @@ -284,7 +398,7 @@ function buildOAuthSources(event: PerplexityOAuthStreamEvent): SearchSource[] { })); } - return (event.sources_list ?? []) + const sources = (event.sources_list ?? []) .filter(source => typeof source.url === "string" && source.url.length > 0) .map(source => ({ title: source.title ?? source.url ?? "", @@ -293,11 +407,13 @@ function buildOAuthSources(event: PerplexityOAuthStreamEvent): SearchSource[] { publishedDate: source.date, ageSeconds: dateToAgeSeconds(source.date), })); + if (sources.length > 0) return sources; + return sourcesFromTextPayload(event.text); } function buildOAuthAnswer(event: PerplexityOAuthStreamEvent): string { if (!event.blocks?.length) { - return typeof event.text === "string" ? event.text : ""; + return typeof event.text === "string" ? parseOAuthTextAnswer(event.text) : ""; } const markdownBlock = event.blocks.find( @@ -324,57 +440,74 @@ function buildOAuthAnswer(event: PerplexityOAuthStreamEvent): string { } } if (typeof event.text === "string" && event.text.length > 0) { - return event.text; + return parseOAuthTextAnswer(event.text); } return ""; } async function callPerplexityOAuth( - auth: { type: "oauth"; token: string } | { type: "cookies"; cookies: string }, + 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(); // The consumer `perplexity_ask` endpoint is itself a research assistant and // has no system-message slot. Prepending the API-style system prompt to the // query makes the model read it as a meta-instruction and refuse with - // "I don't have access to web-search tools in this turn", so OAuth/cookie + // "I don't have access to web-search tools in this turn", so ask-endpoint // searches send the bare query. (The API-key path still uses system_prompt // as a proper `system` message.) const effectiveQuery = params.query; + const headers: Record = { + "Content-Type": "application/json", + Accept: "text/event-stream", + Origin: "https://www.perplexity.ai", + Referer: "https://www.perplexity.ai/", + "User-Agent": auth.type === "anonymous" ? ANONYMOUS_USER_AGENT : OAUTH_USER_AGENT, + "X-Request-ID": requestId, + }; + if (auth.type === "oauth") { + headers.Authorization = `Bearer ${auth.token}`; + } else if (auth.type === "cookies") { + headers.Cookie = auth.cookies; + } + if (auth.type !== "anonymous") { + headers["X-App-ApiClient"] = "default"; + headers["X-App-ApiVersion"] = OAUTH_API_VERSION; + headers["X-Perplexity-Request-Reason"] = "submit"; + } + + const requestParams: Record = { + query_str: effectiveQuery, + search_focus: "internet", + mode: "copilot", + model_preference: auth.type === "anonymous" ? "experimental" : "pplx_pro_upgraded", + sources: ["web"], + attachments: [], + frontend_uuid: crypto.randomUUID(), + frontend_context_uuid: crypto.randomUUID(), + version: OAUTH_API_VERSION, + language: "en-US", + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + search_recency_filter: params.search_recency_filter ?? null, + is_incognito: true, + use_schematized_api: true, + skip_search_enabled: true, + }; + if (auth.type === "anonymous") { + requestParams.send_back_text_in_streaming_api = true; + requestParams.source = "default"; + } + const response = await fetch(PERPLEXITY_OAUTH_ASK_URL, { method: "POST", - headers: { - ...(auth.type === "cookies" ? { Cookie: auth.cookies } : { Authorization: `Bearer ${auth.token}` }), - "Content-Type": "application/json", - Accept: "text/event-stream", - Origin: "https://www.perplexity.ai", - Referer: "https://www.perplexity.ai/", - "User-Agent": OAUTH_USER_AGENT, - "X-App-ApiClient": "default", - "X-App-ApiVersion": OAUTH_API_VERSION, - "X-Perplexity-Request-Reason": "submit", - "X-Request-ID": requestId, - }, + headers, body: JSON.stringify({ query_str: effectiveQuery, - params: { - query_str: effectiveQuery, - search_focus: "internet", - mode: "copilot", - model_preference: "pplx_pro_upgraded", - sources: ["web"], - attachments: [], - frontend_uuid: crypto.randomUUID(), - frontend_context_uuid: crypto.randomUUID(), - version: OAUTH_API_VERSION, - language: "en-US", - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, - search_recency_filter: params.search_recency_filter ?? null, - is_incognito: true, - use_schematized_api: true, - skip_search_enabled: true, - }, + params: requestParams, }), signal: withHardTimeout(params.signal), }); @@ -385,13 +518,13 @@ async function callPerplexityOAuth( if (classified) throw classified; throw new SearchProviderError( "perplexity", - `Perplexity OAuth API error (${response.status}): ${errorText}`, + `Perplexity ask API error (${response.status}): ${errorText}`, response.status, ); } if (!response.body) { - throw new SearchProviderError("perplexity", "Perplexity OAuth API returned no response body", 500); + throw new SearchProviderError("perplexity", "Perplexity ask API returned no response body", 500); } let answer = ""; @@ -403,7 +536,7 @@ async function callPerplexityOAuth( for await (const event of readSseJson(response.body, params.signal)) { if (event.error_code) { const message = event.error_message ?? event.error_code; - throw new SearchProviderError("perplexity", `Perplexity OAuth stream error: ${message}`, 400); + throw new SearchProviderError("perplexity", `Perplexity ask stream error: ${message}`, 400); } mergedEvent = mergeOAuthEventSnapshot(mergedEvent, event); @@ -506,20 +639,17 @@ function applySourceLimit(result: SearchResponse, limit?: number): SearchRespons /** Execute Perplexity web search */ export async function searchPerplexity(params: PerplexitySearchParams): Promise { const auth = await findPerplexityAuth(params.authStorage, params.sessionId, params.signal); - if (!auth) { - throw new Error("Perplexity auth not found. Set PERPLEXITY_COOKIES, PERPLEXITY_API_KEY, or login via OAuth."); - } - if (auth.type === "oauth" || auth.type === "cookies") { - const oauthResult = await callPerplexityOAuth(auth, params); + if (auth.type !== "api_key") { + const askResult = await callPerplexityOAuth(auth, params); return applySourceLimit( { provider: "perplexity", - answer: oauthResult.answer || undefined, - sources: oauthResult.sources, - model: oauthResult.model, - requestId: oauthResult.requestId, - authMode: "oauth", + answer: askResult.answer || undefined, + sources: askResult.sources, + model: askResult.model, + requestId: askResult.requestId, + authMode: auth.type === "anonymous" ? "anonymous" : "oauth", }, params.num_results, ); @@ -568,6 +698,15 @@ export class PerplexityProvider extends SearchProvider { return !!$env.PERPLEXITY_COOKIES?.trim() || authStorage.hasAuth("perplexity") || !!findApiKey(); } + /** + * Perplexity accepts anonymous browser-style ask requests, but keep auto + * provider selection credential-gated so a configured provider keeps priority + * over the anonymous fallback. + */ + isExplicitlyAvailable(_authStorage: AuthStorage): boolean { + return true; + } + search(params: SearchParams): Promise { return searchPerplexity({ signal: params.signal, diff --git a/packages/coding-agent/src/web/search/types.ts b/packages/coding-agent/src/web/search/types.ts index e37e6d961..946d6cc2d 100644 --- a/packages/coding-agent/src/web/search/types.ts +++ b/packages/coding-agent/src/web/search/types.ts @@ -33,7 +33,7 @@ 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: "Requires PERPLEXITY_COOKIES or PERPLEXITY_API_KEY" }, + { 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" },