2026-06-25 15:04:12 +08:00
|
|
|
/**
|
|
|
|
|
* `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'
|
|
|
|
|
|
|
|
|
|
/** 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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 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' }
|
2026-06-25 16:03:55 +08:00
|
|
|
if (!URL.canParse(this.options.baseURL)) return { available: false, reason: 'misconfigured' }
|
2026-06-25 15:04:12 +08:00
|
|
|
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,
|
|
|
|
|
messages: [{ role: 'user', content: request.query }],
|
|
|
|
|
}),
|
|
|
|
|
...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
|
2026-06-25 16:03:55 +08:00
|
|
|
} 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.
|
2026-06-25 15:04:12 +08:00
|
|
|
}
|
|
|
|
|
throw new WebError(message, 'WEB_PROVIDER_ERROR')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let payload: PerplexityResponse
|
|
|
|
|
try {
|
|
|
|
|
payload = await response.json() as PerplexityResponse
|
|
|
|
|
} catch (error: unknown) {
|
2026-06-25 16:03:55 +08:00
|
|
|
if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error })
|
2026-06-25 15:04:12 +08:00
|
|
|
throw new WebError(`Perplexity returned an unparseable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
|
|
|
|
|
}
|
|
|
|
|
return mapPerplexityResponse(request.query, payload)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
|
|
|
|
|
function isAbortError(error: unknown): boolean {
|
|
|
|
|
return error instanceof DOMException && error.name === 'AbortError'
|
|
|
|
|
}
|