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.
162 lines
6.9 KiB
TypeScript
162 lines
6.9 KiB
TypeScript
/**
|
|
* `ExaSearchProvider`: a `WebSearchProvider` backed by the Exa search API
|
|
* (`POST /search` with highlight contents). Maps Exa's flat `results[]` into the
|
|
* seam's normalized `WebSearchResult`. Exa returns no provider-generated answer,
|
|
* so `content` is omitted; each result maps to a `WebSearchSource` with `url`,
|
|
* `title`, the first highlight as `snippet`, and `publishedDate` as
|
|
* `publishedAt`.
|
|
*
|
|
* Network requests use platform-native `fetch` (Node 24), mirroring
|
|
* `@deepseek-ai/dsh-llm-deepseek`'s adapter — not a cordis HTTP-client service.
|
|
*
|
|
* @module @deepseek-ai/dsh-web-search-exa/provider
|
|
*/
|
|
|
|
import { WebError } from '@deepseek-ai/dsh-web'
|
|
import type {
|
|
WebProviderStatus,
|
|
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'
|
|
|
|
export interface ExaSearchProviderOptions {
|
|
/** Exa API key. Empty/absent → `status()` reports `missing-credential`. */
|
|
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).
|
|
*/
|
|
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. */
|
|
export function mapExaResponse(query: string, 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 { providerId: EXA_PROVIDER_ID, query, sources, truncated: false }
|
|
}
|
|
|
|
/** The Exa-backed search provider. */
|
|
export class ExaSearchProvider implements WebSearchProvider {
|
|
readonly id = EXA_PROVIDER_ID
|
|
|
|
constructor(private readonly options: ExaSearchProviderOptions) {}
|
|
|
|
status(): WebProviderStatus {
|
|
if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' }
|
|
if (!isValidBaseUrl(this.options.baseURL)) return { available: false, reason: 'misconfigured' }
|
|
if (!isPositiveInteger(this.options.highlightsPerResult)) return { available: false, reason: 'misconfigured' }
|
|
if (this.options.numResults !== undefined && !isPositiveInteger(this.options.numResults)) return { available: false, reason: 'misconfigured' }
|
|
return { available: true }
|
|
}
|
|
|
|
async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebSearchResult> {
|
|
// 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',
|
|
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 } : {},
|
|
}),
|
|
...exec?.signal ? { signal: exec.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(request.query, 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'
|
|
}
|