/** * 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 { WebProviderStatus, 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 → `status()` reports `missing-credential`. */ 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 query - the original request query, echoed on the result. * @param response - the parsed chat-completions response body. * @returns the normalized result; `content` is omitted when the answer is empty. */ export function mapPerplexityResponse(query: string, 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 { providerId: PERPLEXITY_PROVIDER_ID, query, ...content != null && content.length > 0 ? { content } : {}, sources, truncated: false, } } /** The Perplexity-backed search provider. */ 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 */ status(): WebProviderStatus { if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' } if (!URL.canParse(this.options.baseURL)) return { available: false, reason: 'misconfigured' } if (!isPositiveInteger(this.options.maxTokens)) return { available: false, reason: 'misconfigured' } return { available: true } } /* jscpd:ignore-end */ async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise { let response: Response try { response = await fetch(`${this.options.baseURL}/chat/completions`, { method: 'POST', 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 } : {}, }), ...exec?.signal ? { signal: exec.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(request.query, 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 */