Bläddra i källkod

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.
Dudu-0223 3 månader sedan
förälder
incheckning
580496b72a

+ 4 - 1
packages/web/web-search-exa/README.md

@@ -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`.

+ 24 - 4
packages/web/web-search-exa/src/index.ts

@@ -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 } : {},
+  }))
 }

+ 24 - 2
packages/web/web-search-exa/src/provider.ts

@@ -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'

+ 3 - 1
packages/web/web-search-exa/src/types.ts

@@ -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[]`. */

+ 7 - 2
packages/web/web-search-exa/tests/exa.e2e.ts

@@ -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)

+ 52 - 7
packages/web/web-search-exa/tests/exa.spec.ts

@@ -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('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('omits numResults when maxResults is absent', async () => {
+  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'

+ 2 - 0
packages/web/web-search-perplexity/README.md

@@ -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

+ 16 - 6
packages/web/web-search-perplexity/src/index.ts

@@ -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 } : {},
+  }))
 }

+ 18 - 0
packages/web/web-search-perplexity/src/provider.ts

@@ -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
+}

+ 2 - 1
packages/web/web-search-perplexity/tests/perplexity.e2e.ts

@@ -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')

+ 20 - 3
packages/web/web-search-perplexity/tests/perplexity.spec.ts

@@ -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 () => {