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

@@ -11,10 +11,17 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import type {} from '@deepseek-ai/dsh-web'
import { ExaSearchProvider, EXA_DEFAULT_BASE_URL } from './provider.ts'
import {
ExaSearchProvider,
EXA_DEFAULT_BASE_URL,
EXA_DEFAULT_HIGHLIGHTS_PER_RESULT,
EXA_DEFAULT_SEARCH_TYPE,
} from './provider.ts'
export {
EXA_DEFAULT_BASE_URL,
EXA_DEFAULT_HIGHLIGHTS_PER_RESULT,
EXA_DEFAULT_SEARCH_TYPE,
EXA_PROVIDER_ID,
ExaSearchProvider,
mapExaResponse,
@@ -33,16 +40,29 @@ export interface Config {
apiKey?: string
/** Endpoint base; `/search` is appended. Defaults to the public API. */
baseURL?: string
/** Retrieval mode sent as Exa's `type`. Defaults to `auto`. */
searchType?: 'auto' | 'keyword' | 'neural'
/** Default result count when a request carries no `maxResults`. Omitted = none. */
numResults?: number
/** Highlight sentences requested per result. Defaults to 1. */
highlightsPerResult?: number
}
export const Config: z<Config> = z.object({
apiKey: z.string(),
baseURL: z.string(),
searchType: z.union(['auto', 'keyword', 'neural'] as const),
numResults: z.number().step(1).min(1),
highlightsPerResult: z.number().step(1).min(1),
})
/** Register the Exa search provider with `ctx.web`. */
export function apply(ctx: Context, config: Config): void {
const apiKey = config.apiKey ?? process.env.EXA_API_KEY ?? ''
const baseURL = config.baseURL ?? EXA_DEFAULT_BASE_URL
ctx.web.registerSearchProvider(new ExaSearchProvider({ apiKey, baseURL }))
ctx.web.registerSearchProvider(new ExaSearchProvider({
apiKey: config.apiKey ?? process.env.EXA_API_KEY ?? '',
baseURL: config.baseURL ?? EXA_DEFAULT_BASE_URL,
searchType: config.searchType ?? EXA_DEFAULT_SEARCH_TYPE,
highlightsPerResult: config.highlightsPerResult ?? EXA_DEFAULT_HIGHLIGHTS_PER_RESULT,
...config.numResults !== undefined ? { numResults: config.numResults } : {},
}))
}

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'

View File

@@ -10,10 +10,12 @@
/** Request body sent to Exa's search endpoint. */
export interface ExaSearchRequest {
query: string
/** Retrieval mode: keyword, neural (embeddings), or auto (Exa decides). */
type: 'auto' | 'keyword' | 'neural'
/** Exa's result-count control; the seam still enforces the bound on return. */
numResults?: number
/** Ask Exa to return highlight sentences per result. */
contents: { highlights: true }
contents: { highlights: { highlightsPerUrl: number } }
}
/** One entry of Exa's flat `results[]`. */