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,6 +11,8 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i
| `apiKey` | `$PERPLEXITY_API_KEY` | Perplexity API key. Empty/absent → provider `status()` reports `missing-credential`. |
| `baseURL` | `https://api.perplexity.ai` | Endpoint base; `/chat/completions` is appended. An unparseable value makes `status()` report `misconfigured`. |
| `model` | `sonar` | Search model name. |
| `maxTokens` | `1024` | Upper bound on generated answer tokens (`max_tokens`). Must be a positive integer. |
| `searchRecency` | (unset) | Recency window sent as `search_recency_filter`: `day`, `week`, `month`, or `year`. Unset sends no filter. |
```yaml
- id: web-search-perplexity

View File

@@ -10,17 +10,18 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import type {} from '@deepseek-ai/dsh-web'
import { PerplexitySearchProvider, PERPLEXITY_DEFAULT_BASE_URL, PERPLEXITY_DEFAULT_MODEL } from './provider.ts'
import { PerplexitySearchProvider, PERPLEXITY_DEFAULT_BASE_URL, PERPLEXITY_DEFAULT_MAX_TOKENS, PERPLEXITY_DEFAULT_MODEL } from './provider.ts'
export {
PERPLEXITY_DEFAULT_BASE_URL,
PERPLEXITY_DEFAULT_MAX_TOKENS,
PERPLEXITY_DEFAULT_MODEL,
PERPLEXITY_PROVIDER_ID,
PerplexitySearchProvider,
mapPerplexityResponse,
mapPerplexityResult,
} from './provider.ts'
export type { PerplexitySearchProviderOptions } from './provider.ts'
export type { PerplexityRecency, PerplexitySearchProviderOptions } from './provider.ts'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'web-search-perplexity'
@@ -35,18 +36,27 @@ export interface Config {
baseURL?: string
/** Search model name. Defaults to `sonar`. */
model?: string
/** Upper bound on generated answer tokens. Defaults to 1024. */
maxTokens?: number
/** Recency window sent as `search_recency_filter`. Omitted = no filter. */
searchRecency?: 'day' | 'week' | 'month' | 'year'
}
export const Config: z<Config> = z.object({
apiKey: z.string(),
baseURL: z.string(),
model: z.string(),
maxTokens: z.number().step(1).min(1),
searchRecency: z.union(['day', 'week', 'month', 'year'] as const),
})
/** Register the Perplexity search provider with `ctx.web`. */
export function apply(ctx: Context, config: Config): void {
const apiKey = config.apiKey ?? process.env.PERPLEXITY_API_KEY ?? ''
const baseURL = config.baseURL ?? PERPLEXITY_DEFAULT_BASE_URL
const model = config.model ?? PERPLEXITY_DEFAULT_MODEL
ctx.web.registerSearchProvider(new PerplexitySearchProvider({ apiKey, baseURL, model }))
ctx.web.registerSearchProvider(new PerplexitySearchProvider({
apiKey: config.apiKey ?? process.env.PERPLEXITY_API_KEY ?? '',
baseURL: config.baseURL ?? PERPLEXITY_DEFAULT_BASE_URL,
model: config.model ?? PERPLEXITY_DEFAULT_MODEL,
maxTokens: config.maxTokens ?? PERPLEXITY_DEFAULT_MAX_TOKENS,
...config.searchRecency !== undefined ? { searchRecency: config.searchRecency } : {},
}))
}

View File

@@ -32,6 +32,12 @@ export const PERPLEXITY_DEFAULT_BASE_URL = 'https://api.perplexity.ai'
/** Default search model. */
export const PERPLEXITY_DEFAULT_MODEL = 'sonar'
/** Default upper bound on generated answer tokens. */
export const PERPLEXITY_DEFAULT_MAX_TOKENS = 1024
/** Recency filter values Perplexity accepts for `search_recency_filter`. */
export type PerplexityRecency = 'day' | 'week' | 'month' | 'year'
/** Attribution header sent on every request. Bump with the package version. */
const USER_AGENT = 'deepseek-harness/0.0.1'
@@ -42,6 +48,10 @@ export interface PerplexitySearchProviderOptions {
baseURL: string
/** Search model name. */
model: string
/** Upper bound on generated answer tokens (`max_tokens`). */
maxTokens: number
/** Optional recency window sent as `search_recency_filter`; omitted = no filter. */
searchRecency?: PerplexityRecency
}
/** Map one structured Perplexity search result to a normalized source. */
@@ -82,6 +92,7 @@ export class PerplexitySearchProvider implements WebSearchProvider {
status(): WebProviderStatus {
if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' }
if (!URL.canParse(this.options.baseURL)) return { available: false, reason: 'misconfigured' }
if (!isPositiveInteger(this.options.maxTokens)) return { available: false, reason: 'misconfigured' }
return { available: true }
}
@@ -98,7 +109,9 @@ export class PerplexitySearchProvider implements WebSearchProvider {
},
body: JSON.stringify({
model: this.options.model,
max_tokens: this.options.maxTokens,
messages: [{ role: 'user', content: request.query }],
...this.options.searchRecency !== undefined ? { search_recency_filter: this.options.searchRecency } : {},
}),
...exec?.signal ? { signal: exec.signal } : {},
})
@@ -140,3 +153,8 @@ export class PerplexitySearchProvider implements WebSearchProvider {
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === 'AbortError'
}
/** True for a request limit that can be sent to Perplexity (a positive whole number). */
function isPositiveInteger(value: number): boolean {
return Number.isInteger(value) && value > 0
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { PerplexitySearchProvider, PERPLEXITY_DEFAULT_BASE_URL, PERPLEXITY_DEFAULT_MODEL } from '@deepseek-ai/dsh-web-search-perplexity'
import { PerplexitySearchProvider, PERPLEXITY_DEFAULT_BASE_URL, PERPLEXITY_DEFAULT_MAX_TOKENS, PERPLEXITY_DEFAULT_MODEL } from '@deepseek-ai/dsh-web-search-perplexity'
/**
* Real-API smoke for the Perplexity search provider. Self-skips without
@@ -14,6 +14,7 @@ maybe('PerplexitySearchProvider real API', () => {
apiKey: apiKey!,
baseURL: process.env.PERPLEXITY_BASE_URL ?? PERPLEXITY_DEFAULT_BASE_URL,
model: process.env.PERPLEXITY_MODEL ?? PERPLEXITY_DEFAULT_MODEL,
maxTokens: PERPLEXITY_DEFAULT_MAX_TOKENS,
})
const result = await provider.search({ query: 'What is the DeepSeek coding agent?', maxResults: 5 })
expect(result.providerId).toBe('perplexity')

View File

@@ -8,7 +8,7 @@ import {
} from '@deepseek-ai/dsh-web-search-perplexity'
import * as perplexityPlugin from '@deepseek-ai/dsh-web-search-perplexity'
const options = { apiKey: 'pplx-key', baseURL: 'https://api.perplexity.test', model: 'sonar' }
const options = { apiKey: 'pplx-key', baseURL: 'https://api.perplexity.test', model: 'sonar', maxTokens: 1024 }
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' }, ...init })
@@ -80,17 +80,34 @@ describe('PerplexitySearchProvider status', () => {
expect(new PerplexitySearchProvider({ ...options, baseURL: 'not a url' }).status())
.toEqual({ available: false, reason: 'misconfigured' })
})
it('is misconfigured when maxTokens is not a positive integer', () => {
expect(new PerplexitySearchProvider({ ...options, maxTokens: 0 }).status())
.toEqual({ available: false, reason: 'misconfigured' })
expect(new PerplexitySearchProvider({ ...options, maxTokens: 1.5 }).status())
.toEqual({ available: false, reason: 'misconfigured' })
})
})
describe('PerplexitySearchProvider request mapping', () => {
it('sends a chat-completions request with the query as a user message', async () => {
it('sends a chat-completions request with the query, model and max_tokens', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ choices: [{ message: { content: 'a' } }], citations: [] }))
vi.stubGlobal('fetch', fetchMock)
await new PerplexitySearchProvider(options).search({ query: 'hello' })
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
expect(url).toBe('https://api.perplexity.test/chat/completions')
expect((init.headers as Record<string, string>)['authorization']).toBe('Bearer pplx-key')
expect(JSON.parse(init.body as string)).toEqual({ model: 'sonar', messages: [{ role: 'user', content: 'hello' }] })
expect(JSON.parse(init.body as string)).toEqual({ model: 'sonar', max_tokens: 1024, messages: [{ role: 'user', content: 'hello' }] })
})
it('sends search_recency_filter when configured, and omits it otherwise', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ choices: [{ message: { content: 'a' } }], citations: [] }))
vi.stubGlobal('fetch', fetchMock)
await new PerplexitySearchProvider({ ...options, searchRecency: 'week' }).search({ query: 'q' })
expect(JSON.parse((fetchMock.mock.calls[0] as unknown as [string, RequestInit])[1].body as string)).toMatchObject({ search_recency_filter: 'week' })
await new PerplexitySearchProvider(options).search({ query: 'q' })
expect(JSON.parse((fetchMock.mock.calls[1] as unknown as [string, RequestInit])[1].body as string)).not.toHaveProperty('search_recency_filter')
})
it('forwards the abort signal', async () => {