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:
@@ -10,6 +10,9 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i
|
||||
|---|---|---|
|
||||
| `apiKey` | `$EXA_API_KEY` | Exa API key. Empty/absent → provider `status()` reports `missing-credential` (the seam reports `configured-unavailable`/`none`). |
|
||||
| `baseURL` | `https://api.exa.ai` | Endpoint base; `/search` is appended. An unparseable value makes `status()` report `misconfigured`. |
|
||||
| `searchType` | `auto` | Retrieval mode sent as Exa's `type`: `auto` (Exa decides), `keyword`, or `neural`. |
|
||||
| `numResults` | (unset) | Default result count when a request carries no `maxResults`. Unset sends no default. Must be a positive integer. |
|
||||
| `highlightsPerResult` | `1` | Highlight sentences requested per result (Exa's `highlightsPerUrl`). Must be a positive integer. |
|
||||
|
||||
```yaml
|
||||
- id: web-search-exa
|
||||
@@ -20,4 +23,4 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i
|
||||
|
||||
## Mapping
|
||||
|
||||
Exa returns a flat `results[]` and no generated answer, so `content` is omitted. Each result maps to a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← the first non-empty `highlights[]` entry (a result with no highlight has no portable snippet and is dropped), `publishedAt` ← `publishedDate`. The provider passes `maxResults` through as Exa's `numResults` for a cost/latency optimization; the final bound is enforced by the seam. Provider failures (HTTP errors, network failure, unparseable or wrong-shape bodies) surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`.
|
||||
Exa returns a flat `results[]` and no generated answer, so `content` is omitted. Each result maps to a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← the first non-empty `highlights[]` entry (a result with no highlight has no portable snippet and is dropped), `publishedAt` ← `publishedDate`. A request's `maxResults` wins over the configured `numResults` default and is sent as Exa's `numResults` for a cost/latency optimization; the final bound is enforced by the seam. Provider failures (HTTP errors, network failure, unparseable or wrong-shape bodies) surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`.
|
||||
|
||||
@@ -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 } : {},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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[]`. */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ExaSearchProvider, EXA_DEFAULT_BASE_URL } from '@deepseek-ai/dsh-web-search-exa'
|
||||
import { ExaSearchProvider, EXA_DEFAULT_BASE_URL, EXA_DEFAULT_HIGHLIGHTS_PER_RESULT, EXA_DEFAULT_SEARCH_TYPE } from '@deepseek-ai/dsh-web-search-exa'
|
||||
|
||||
/**
|
||||
* Real-API smoke for the Exa search provider. Self-skips without `$EXA_API_KEY`
|
||||
@@ -10,7 +10,12 @@ const maybe = apiKey !== undefined && apiKey.length > 0 ? describe : describe.sk
|
||||
|
||||
maybe('ExaSearchProvider real API', () => {
|
||||
it('returns sources for a live query', async () => {
|
||||
const provider = new ExaSearchProvider({ apiKey: apiKey!, baseURL: process.env.EXA_BASE_URL ?? EXA_DEFAULT_BASE_URL })
|
||||
const provider = new ExaSearchProvider({
|
||||
apiKey: apiKey!,
|
||||
baseURL: process.env.EXA_BASE_URL ?? EXA_DEFAULT_BASE_URL,
|
||||
searchType: EXA_DEFAULT_SEARCH_TYPE,
|
||||
highlightsPerResult: EXA_DEFAULT_HIGHLIGHTS_PER_RESULT,
|
||||
})
|
||||
const result = await provider.search({ query: 'DeepSeek coding agent', maxResults: 5 })
|
||||
expect(result.providerId).toBe('exa')
|
||||
expect(result.sources.length).toBeGreaterThan(0)
|
||||
|
||||
@@ -4,7 +4,7 @@ import WebService from '@deepseek-ai/dsh-web'
|
||||
import { ExaSearchProvider, mapExaResponse, mapExaResult, EXA_PROVIDER_ID } from '@deepseek-ai/dsh-web-search-exa'
|
||||
import * as exaPlugin from '@deepseek-ai/dsh-web-search-exa'
|
||||
|
||||
const options = { apiKey: 'exa-key', baseURL: 'https://api.exa.test' }
|
||||
const options = { apiKey: 'exa-key', baseURL: 'https://api.exa.test', searchType: 'auto' as const, highlightsPerResult: 1 }
|
||||
|
||||
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
|
||||
return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' }, ...init })
|
||||
@@ -65,7 +65,7 @@ describe('Exa result mapping', () => {
|
||||
|
||||
describe('ExaSearchProvider status', () => {
|
||||
it('is unavailable without a key', () => {
|
||||
expect(new ExaSearchProvider({ apiKey: '', baseURL: options.baseURL }).status())
|
||||
expect(new ExaSearchProvider({ ...options, apiKey: '' }).status())
|
||||
.toEqual({ available: false, reason: 'missing-credential' })
|
||||
})
|
||||
|
||||
@@ -74,27 +74,60 @@ describe('ExaSearchProvider status', () => {
|
||||
})
|
||||
|
||||
it('is misconfigured when the base URL is unparseable', () => {
|
||||
expect(new ExaSearchProvider({ apiKey: 'exa-key', baseURL: 'not a url' }).status())
|
||||
expect(new ExaSearchProvider({ ...options, baseURL: 'not a url' }).status())
|
||||
.toEqual({ available: false, reason: 'misconfigured' })
|
||||
})
|
||||
|
||||
it('is misconfigured when highlightsPerResult is not a positive integer', () => {
|
||||
expect(new ExaSearchProvider({ ...options, highlightsPerResult: 0 }).status())
|
||||
.toEqual({ available: false, reason: 'misconfigured' })
|
||||
expect(new ExaSearchProvider({ ...options, highlightsPerResult: 1.5 }).status())
|
||||
.toEqual({ available: false, reason: 'misconfigured' })
|
||||
})
|
||||
|
||||
it('is misconfigured when numResults is set but not a positive integer', () => {
|
||||
expect(new ExaSearchProvider({ ...options, numResults: -1 }).status())
|
||||
.toEqual({ available: false, reason: 'misconfigured' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('ExaSearchProvider request mapping', () => {
|
||||
it('sends query, highlights, numResults and bearer auth', async () => {
|
||||
it('sends query, type, highlights, numResults and bearer auth', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ results: [{ url: 'https://a.test', highlights: ['hi'] }] }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const provider = new ExaSearchProvider(options)
|
||||
const provider = new ExaSearchProvider({ ...options, searchType: 'neural', highlightsPerResult: 3 })
|
||||
await provider.search({ query: 'hello', maxResults: 5 })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(url).toBe('https://api.exa.test/search')
|
||||
expect((init.headers as Record<string, string>)['authorization']).toBe('Bearer exa-key')
|
||||
expect(JSON.parse(init.body as string)).toEqual({ query: 'hello', contents: { highlights: true }, numResults: 5 })
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
query: 'hello',
|
||||
type: 'neural',
|
||||
contents: { highlights: { highlightsPerUrl: 3 } },
|
||||
numResults: 5,
|
||||
})
|
||||
})
|
||||
|
||||
it('omits numResults when maxResults is absent', async () => {
|
||||
it('falls back to the configured numResults when a request omits maxResults', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ results: [] }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
await new ExaSearchProvider({ ...options, numResults: 7 }).search({ query: 'q' })
|
||||
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(JSON.parse(init.body as string)).toMatchObject({ numResults: 7 })
|
||||
})
|
||||
|
||||
it('lets a request maxResults win over the configured numResults', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ results: [] }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
await new ExaSearchProvider({ ...options, numResults: 7 }).search({ query: 'q', maxResults: 2 })
|
||||
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(JSON.parse(init.body as string)).toMatchObject({ numResults: 2 })
|
||||
})
|
||||
|
||||
it('omits numResults when neither maxResults nor a configured default is set', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ results: [] }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
await new ExaSearchProvider(options).search({ query: 'q' })
|
||||
@@ -184,6 +217,18 @@ describe('web-search-exa plugin registration', () => {
|
||||
expect('default' in exaPlugin).toBe(false)
|
||||
})
|
||||
|
||||
it('threads searchType and highlightsPerResult config into the request', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ results: [] }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID })
|
||||
const fiber = await ctx.plugin(exaPlugin, { apiKey: 'exa-key', searchType: 'keyword', highlightsPerResult: 2 })
|
||||
await ctx.web.search({ query: 'q' })
|
||||
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(JSON.parse(init.body as string)).toMatchObject({ type: 'keyword', contents: { highlights: { highlightsPerUrl: 2 } } })
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('falls back to $EXA_API_KEY and the default base URL when config omits them', async () => {
|
||||
const prev = process.env.EXA_API_KEY
|
||||
process.env.EXA_API_KEY = 'env-key'
|
||||
|
||||
Reference in New Issue
Block a user