The Exa and Perplexity providers hard-coded request parameters that deployments should control while defaults are still unsettled. Exa gains searchType, numResults, and highlightsPerResult; Perplexity gains maxTokens (it previously sent none) and an optional searchRecency. Each follows the deepseek provider's shape: a defaulted Config field, a DEFAULT_* constant, and a positive-integer status() check for numeric limits. The call-level maxResults still flows through WebSearchRequest and wins over the configured default, keeping the seam layering intact. Addresses tianyicui's "make everything configurable" review comment.
161 lines
6.8 KiB
TypeScript
161 lines
6.8 KiB
TypeScript
/**
|
|
* `PerplexitySearchProvider`: a `WebSearchProvider` backed by the Perplexity
|
|
* search API (an OpenAI-compatible `POST /chat/completions`). Maps the generated
|
|
* answer (`choices[0].message.content`) into `content`, and prefers the
|
|
* structured `search_results[]` for `sources[]`, falling back to the URL-only
|
|
* `citations[]` when `search_results` is absent.
|
|
*
|
|
* Network requests use platform-native `fetch` (Node 24), mirroring
|
|
* `@deepseek-ai/dsh-llm-deepseek`'s adapter. The OpenAI-compatible request shape
|
|
* is a provider-private detail and does NOT make this provider depend on
|
|
* `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'
|
|
|
|
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. */
|
|
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.
|
|
*/
|
|
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) {}
|
|
|
|
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 }
|
|
}
|
|
|
|
async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebSearchResult> {
|
|
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 })
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 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
|
|
}
|