From 580496b72aa385ac15eed89db3e52f726a69fe94 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 3 Jul 2026 16:21:12 +0800 Subject: [PATCH] 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. --- packages/web/web-search-exa/README.md | 5 +- packages/web/web-search-exa/src/index.ts | 28 +++++++-- packages/web/web-search-exa/src/provider.ts | 26 +++++++- packages/web/web-search-exa/src/types.ts | 4 +- packages/web/web-search-exa/tests/exa.e2e.ts | 9 ++- packages/web/web-search-exa/tests/exa.spec.ts | 59 ++++++++++++++++--- packages/web/web-search-perplexity/README.md | 2 + .../web/web-search-perplexity/src/index.ts | 22 +++++-- .../web/web-search-perplexity/src/provider.ts | 18 ++++++ .../tests/perplexity.e2e.ts | 3 +- .../tests/perplexity.spec.ts | 23 +++++++- 11 files changed, 172 insertions(+), 27 deletions(-) diff --git a/packages/web/web-search-exa/README.md b/packages/web/web-search-exa/README.md index 6485d64c60..0bc58d6559 100644 --- a/packages/web/web-search-exa/README.md +++ b/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`. diff --git a/packages/web/web-search-exa/src/index.ts b/packages/web/web-search-exa/src/index.ts index f266474708..39a20b16a4 100644 --- a/packages/web/web-search-exa/src/index.ts +++ b/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 = 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 } : {}, + })) } diff --git a/packages/web/web-search-exa/src/provider.ts b/packages/web/web-search-exa/src/provider.ts index cfb41cf77f..f187f90344 100644 --- a/packages/web/web-search-exa/src/provider.ts +++ b/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 { + // 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' diff --git a/packages/web/web-search-exa/src/types.ts b/packages/web/web-search-exa/src/types.ts index a0bda5f768..fae42d07cd 100644 --- a/packages/web/web-search-exa/src/types.ts +++ b/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[]`. */ diff --git a/packages/web/web-search-exa/tests/exa.e2e.ts b/packages/web/web-search-exa/tests/exa.e2e.ts index 78f11940e5..32da0a485c 100644 --- a/packages/web/web-search-exa/tests/exa.e2e.ts +++ b/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) diff --git a/packages/web/web-search-exa/tests/exa.spec.ts b/packages/web/web-search-exa/tests/exa.spec.ts index 436e542d7c..dcb6fbea6d 100644 --- a/packages/web/web-search-exa/tests/exa.spec.ts +++ b/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)['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' diff --git a/packages/web/web-search-perplexity/README.md b/packages/web/web-search-perplexity/README.md index e7093a1133..f944413c96 100644 --- a/packages/web/web-search-perplexity/README.md +++ b/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 diff --git a/packages/web/web-search-perplexity/src/index.ts b/packages/web/web-search-perplexity/src/index.ts index 0fd46ffb71..3d375eaabb 100644 --- a/packages/web/web-search-perplexity/src/index.ts +++ b/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 = 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 } : {}, + })) } diff --git a/packages/web/web-search-perplexity/src/provider.ts b/packages/web/web-search-perplexity/src/provider.ts index 5b5feb897b..ed72ea82c3 100644 --- a/packages/web/web-search-perplexity/src/provider.ts +++ b/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 +} diff --git a/packages/web/web-search-perplexity/tests/perplexity.e2e.ts b/packages/web/web-search-perplexity/tests/perplexity.e2e.ts index a546acab70..9414d46937 100644 --- a/packages/web/web-search-perplexity/tests/perplexity.e2e.ts +++ b/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') diff --git a/packages/web/web-search-perplexity/tests/perplexity.spec.ts b/packages/web/web-search-perplexity/tests/perplexity.spec.ts index d84c34a328..6d55f384dd 100644 --- a/packages/web/web-search-perplexity/tests/perplexity.spec.ts +++ b/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)['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 () => {