mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
174 lines
7.4 KiB
TypeScript
174 lines
7.4 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` at the repo's Node floor, 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'
|
|
|
|
/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
|
|
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).
|
|
*
|
|
* @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 query - the original request query, echoed on the result.
|
|
* @param response - the parsed `POST /search` response body.
|
|
* @returns the normalized result; snippet-less entries are dropped
|
|
* ({@link mapExaResult}).
|
|
*/
|
|
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'
|
|
}
|