/** * `ExaSearchProvider`: a `WebSearchProvider` backed by the Exa search API (`POST /search` with * highlight contents). It maps the first non-blank highlight to `snippet`, maps * `publishedDate` to `publishedAt`, drops entries without a snippet, and omits `content` * because Exa returns no generated answer. * @module @deepseek-ai/dsh-web-search-exa/provider */ import { WebError } from '@deepseek-ai/dsh-web' import type { WebSearchProvider, WebSearchRequest, WebSearchResult, WebSearchSource, } from '@deepseek-ai/dsh-web' import type { ExaError, ExaResult, ExaSearchResponse } from './types.ts' /** Stable id this provider registers under. */ export const EXA_PROVIDER_ID = 'exa' /** Default Exa search endpoint; `/search` is the operation. */ export const EXA_DEFAULT_BASE_URL = 'https://api.exa.ai' /** Default retrieval mode: let Exa pick between keyword and neural search. */ export const EXA_DEFAULT_SEARCH_TYPE = 'auto' /** Default number of highlight sentences requested per result. */ export const EXA_DEFAULT_HIGHLIGHTS_PER_RESULT = 1 /** 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 ExaSearchProviderOptions { /** Exa API key. Empty/absent makes the provider unavailable. */ apiKey: string /** Endpoint base; `/search` is appended. */ baseURL: string /** Retrieval mode sent as Exa's `type`. */ searchType: 'auto' | 'keyword' | 'neural' /** Default result count when a request carries no `maxResults`. */ numResults?: number /** Highlight sentences requested per result (Exa's `highlightsPerUrl`). */ highlightsPerResult: number } /** * Map one Exa result to a normalized source, or `undefined` when it carries no * portable snippet (an entry with no highlight is dropped — the seam has no * other field to derive a snippet from, and inventing one would lie). * * @param result - one entry of Exa's `results[]`. * @returns the normalized source, or `undefined` when the entry has no * non-blank highlight. */ export function mapExaResult(result: ExaResult): WebSearchSource | undefined { const snippet = result.highlights?.find(highlight => highlight.trim().length > 0) if (snippet === undefined) return undefined return { url: result.url, ...result.title != null && result.title.length > 0 ? { title: result.title } : {}, snippet, ...result.publishedDate != null && result.publishedDate.length > 0 ? { publishedAt: result.publishedDate } : {}, } } /** * Map an Exa response envelope to a normalized search result. * * @param response - the parsed `POST /search` response body. * @returns the normalized result; snippet-less entries are dropped * ({@link mapExaResult}). */ export function mapExaResponse(response: ExaSearchResponse): WebSearchResult { const sources = (response.results ?? []) .map(mapExaResult) .filter((source): source is WebSearchSource => source !== undefined) // Exa returns no generated answer, so `content` is omitted. The seam owns the // final `maxResults` truncation, so this provider reports `truncated: false`. return { sources, truncated: false } } /** The Exa-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */ export class ExaSearchProvider implements WebSearchProvider { readonly id = EXA_PROVIDER_ID constructor(private readonly options: ExaSearchProviderOptions) {} available(): boolean { return this.options.apiKey.length > 0 && isValidBaseUrl(this.options.baseURL) && isPositiveInteger(this.options.highlightsPerResult) && (this.options.numResults === undefined || isPositiveInteger(this.options.numResults)) } async search(request: WebSearchRequest, signal?: AbortSignal): Promise { // A per-request bound wins over the configured default; either may be absent. const numResults = request.maxResults ?? this.options.numResults let response: Response try { response = await fetch(`${this.options.baseURL}/search`, { method: 'POST', redirect: 'error', headers: { 'authorization': `Bearer ${this.options.apiKey}`, 'content-type': 'application/json', 'accept': 'application/json', 'user-agent': USER_AGENT, }, body: JSON.stringify({ query: request.query, type: this.options.searchType, contents: { highlights: { highlightsPerUrl: this.options.highlightsPerResult } }, ...numResults !== undefined ? { numResults } : {}, }), ...signal !== undefined ? { signal } : {}, }) } catch (error: unknown) { if (isAbortError(error)) throw new WebError('Exa search aborted', 'WEB_ABORTED', { cause: error }) throw new WebError(`Exa search request failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) } if (!response.ok) { const status = response.status let message = `Exa API error (HTTP ${status})` try { const parsed = await response.json() as ExaError const detail = parsed.error ?? 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('Exa 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 ExaSearchResponse return mapExaResponse(payload) } catch (error: unknown) { if (isAbortError(error)) throw new WebError('Exa search aborted', 'WEB_ABORTED', { cause: error }) throw new WebError(`Exa returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) } } } /** True when `baseURL` parses as an absolute URL (a cheap local config check). */ function isValidBaseUrl(baseURL: string): boolean { return URL.canParse(baseURL) } /** True for a request limit that can be sent to Exa (a positive whole number). */ function isPositiveInteger(value: number): boolean { return Number.isInteger(value) && value > 0 } /** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */ function isAbortError(error: unknown): boolean { return error instanceof DOMException && error.name === 'AbortError' }