/** * Perplexity search over its OpenAI-compatible chat-completions endpoint. The generated answer * becomes `content`; sources prefer structured `search_results[]` and fall back to URL-only * `citations[]`. The wire format and native `fetch` client are provider-private and do not use * `ctx.llm`. * @module @deepseek-ai/dsh-web-search-perplexity/provider */ import { WebError } from '@deepseek-ai/dsh-web' import type { WebSearchProvider, WebSearchRequest, WebSearchResult, WebSearchSource, } from '@deepseek-ai/dsh-web' import type { PerplexityError, PerplexityResponse, PerplexitySearchResult } from './types.ts' /** Stable id this provider registers under. */ export const PERPLEXITY_PROVIDER_ID = 'perplexity' /** Default Perplexity endpoint; `/chat/completions` is the operation. */ export const PERPLEXITY_DEFAULT_BASE_URL = 'https://api.perplexity.ai' /** Default search model. */ export const PERPLEXITY_DEFAULT_MODEL = 'sonar' /** Default upper bound on generated answer tokens. */ export const PERPLEXITY_DEFAULT_MAX_TOKENS = 1024 /** Recency filter values Perplexity accepts for `search_recency_filter`. */ export type PerplexityRecency = 'day' | 'week' | 'month' | 'year' /** Attribution header sent on every request. Bump with the package version. */ const USER_AGENT = 'deepseek-harness/0.0.1' /** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */ export interface PerplexitySearchProviderOptions { /** Perplexity API key. Empty/absent makes the provider unavailable. */ apiKey: string /** Endpoint base; `/chat/completions` is appended. */ baseURL: string /** Search model name. */ model: string /** Upper bound on generated answer tokens (`max_tokens`). */ maxTokens: number /** Optional recency window sent as `search_recency_filter`; omitted = no filter. */ searchRecency?: PerplexityRecency } /** * Map one structured Perplexity search result to a normalized source. * * @param result - one entry of the response's `search_results[]`. * @returns the normalized source; blank fields are omitted rather than set empty. */ export function mapPerplexityResult(result: PerplexitySearchResult): WebSearchSource { return { url: result.url, ...result.title != null && result.title.length > 0 ? { title: result.title } : {}, ...result.snippet != null && result.snippet.length > 0 ? { snippet: result.snippet } : {}, ...result.date != null && result.date.length > 0 ? { publishedAt: result.date } : {}, } } /** * Map a Perplexity response envelope to a normalized search result. Prefers * structured `search_results[]`; falls back to URL-only `citations[]` (those * sources carry just a `url`) only when `search_results` is absent. * * @param response - the parsed chat-completions response body. * @returns the normalized result; `content` is omitted when the answer is empty. */ export function mapPerplexityResponse(response: PerplexityResponse): WebSearchResult { const content = response.choices?.[0]?.message?.content const sources: WebSearchSource[] = response.search_results !== undefined ? response.search_results.map(mapPerplexityResult) : (response.citations ?? []).map(url => ({ url })) return { ...content != null && content.length > 0 ? { content } : {}, sources, truncated: false, } } /** The Perplexity-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */ export class PerplexitySearchProvider implements WebSearchProvider { readonly id = PERPLEXITY_PROVIDER_ID constructor(private readonly options: PerplexitySearchProviderOptions) {} // Availability checks stay beside each provider's distinct config contract; // a shared base class would obscure which fields make this backend usable. /* jscpd:ignore-start */ available(): boolean { return this.options.apiKey.length > 0 && URL.canParse(this.options.baseURL) && isPositiveInteger(this.options.maxTokens) } /* jscpd:ignore-end */ async search(request: WebSearchRequest, signal?: AbortSignal): Promise { let response: Response try { response = await fetch(`${this.options.baseURL}/chat/completions`, { method: 'POST', redirect: 'error', headers: { 'authorization': `Bearer ${this.options.apiKey}`, 'content-type': 'application/json', 'accept': 'application/json', 'user-agent': USER_AGENT, }, body: JSON.stringify({ model: this.options.model, max_tokens: this.options.maxTokens, messages: [{ role: 'user', content: request.query }], ...this.options.searchRecency !== undefined ? { search_recency_filter: this.options.searchRecency } : {}, }), ...signal !== undefined ? { signal } : {}, }) } catch (error: unknown) { if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error }) throw new WebError(`Perplexity search request failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) } if (!response.ok) { const status = response.status let message = `Perplexity API error (HTTP ${status})` try { const parsed = await response.json() as PerplexityError const detail = typeof parsed.error === 'string' ? parsed.error : parsed.error?.message ?? parsed.message if (detail !== undefined && detail.length > 0) message = detail } catch (error: unknown) { // An abort fired mid-body must surface as WEB_ABORTED, not be swallowed // into a generic HTTP-error message — cancellation is not a provider // error (the seam's cancellation contract). if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error }) // Otherwise: the HTTP status is already captured in `message` above; a // malformed/non-JSON error body (normal for gateway 5xx/429s) can only // cost a richer provider message, never the real error. } throw new WebError(message, 'WEB_PROVIDER_ERROR') } try { const payload = await response.json() as PerplexityResponse return mapPerplexityResponse(payload) } catch (error: unknown) { if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error }) throw new WebError(`Perplexity returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) } } } // These two predicates are intentionally local: exporting generic internals // from the public web seam would cost more API surface than these pure checks. /* jscpd:ignore-start */ /** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */ function isAbortError(error: unknown): boolean { return error instanceof DOMException && error.name === 'AbortError' } /** True for a request limit that can be sent to Perplexity (a positive whole number). */ function isPositiveInteger(value: number): boolean { return Number.isInteger(value) && value > 0 } /* jscpd:ignore-end */