feat(web): expose exa/perplexity search tuning as config

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.
This commit is contained in:
Dudu-0223
2026-07-03 16:21:12 +08:00
parent 7441307251
commit 580496b72a
11 changed files with 172 additions and 27 deletions

View File

@@ -28,6 +28,12 @@ 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'
@@ -36,6 +42,12 @@ export interface ExaSearchProviderOptions {
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
}
/**
@@ -73,10 +85,14 @@ export class ExaSearchProvider implements WebSearchProvider {
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`, {
@@ -89,8 +105,9 @@ export class ExaSearchProvider implements WebSearchProvider {
},
body: JSON.stringify({
query: request.query,
contents: { highlights: true },
...request.maxResults !== undefined ? { numResults: request.maxResults } : {},
type: this.options.searchType,
contents: { highlights: { highlightsPerUrl: this.options.highlightsPerResult } },
...numResults !== undefined ? { numResults } : {},
}),
...exec?.signal ? { signal: exec.signal } : {},
})
@@ -133,6 +150,11 @@ 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'