refactor: prune unused web seam fields
This commit is contained in:
@@ -8,8 +8,8 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `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`. |
|
||||
| `apiKey` | `$PERPLEXITY_API_KEY` | Perplexity API key. Empty/absent makes the provider unavailable. |
|
||||
| `baseURL` | `https://api.perplexity.ai` | Endpoint base; `/chat/completions` is appended. An unparseable value makes the provider unavailable. |
|
||||
| `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. |
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
|
||||
import { WebError } from '@deepseek-ai/dsh-web'
|
||||
import type {
|
||||
WebProviderStatus,
|
||||
WebSearchProvider,
|
||||
WebSearchRequest,
|
||||
WebSearchResult,
|
||||
@@ -43,7 +42,7 @@ const USER_AGENT = 'deepseek-harness/0.0.1'
|
||||
|
||||
/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
|
||||
export interface PerplexitySearchProviderOptions {
|
||||
/** Perplexity API key. Empty/absent → `status()` reports `missing-credential`. */
|
||||
/** Perplexity API key. Empty/absent makes the provider unavailable. */
|
||||
apiKey: string
|
||||
/** Endpoint base; `/chat/completions` is appended. */
|
||||
baseURL: string
|
||||
@@ -75,18 +74,15 @@ export function mapPerplexityResult(result: PerplexitySearchResult): WebSearchSo
|
||||
* structured `search_results[]`; falls back to URL-only `citations[]` (those
|
||||
* sources carry just a `url`) only when `search_results` is absent.
|
||||
*
|
||||
* @param query - the original request query, echoed on the result.
|
||||
* @param response - the parsed chat-completions response body.
|
||||
* @returns the normalized result; `content` is omitted when the answer is empty.
|
||||
*/
|
||||
export function mapPerplexityResponse(query: string, response: PerplexityResponse): WebSearchResult {
|
||||
export function mapPerplexityResponse(response: PerplexityResponse): WebSearchResult {
|
||||
const content = response.choices?.[0]?.message?.content
|
||||
const sources: WebSearchSource[] = response.search_results !== undefined
|
||||
? response.search_results.map(mapPerplexityResult)
|
||||
: (response.citations ?? []).map(url => ({ url }))
|
||||
return {
|
||||
providerId: PERPLEXITY_PROVIDER_ID,
|
||||
query,
|
||||
...content != null && content.length > 0 ? { content } : {},
|
||||
sources,
|
||||
truncated: false,
|
||||
@@ -102,15 +98,14 @@ export class PerplexitySearchProvider implements WebSearchProvider {
|
||||
// Availability checks stay beside each provider's distinct config contract;
|
||||
// a shared base class would obscure which fields make this backend usable.
|
||||
/* jscpd:ignore-start */
|
||||
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 }
|
||||
available(): boolean {
|
||||
return this.options.apiKey.length > 0
|
||||
&& URL.canParse(this.options.baseURL)
|
||||
&& isPositiveInteger(this.options.maxTokens)
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebSearchResult> {
|
||||
async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`${this.options.baseURL}/chat/completions`, {
|
||||
@@ -127,7 +122,7 @@ export class PerplexitySearchProvider implements WebSearchProvider {
|
||||
messages: [{ role: 'user', content: request.query }],
|
||||
...this.options.searchRecency !== undefined ? { search_recency_filter: this.options.searchRecency } : {},
|
||||
}),
|
||||
...exec?.signal ? { signal: exec.signal } : {},
|
||||
...signal !== undefined ? { signal } : {},
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error })
|
||||
@@ -155,7 +150,7 @@ export class PerplexitySearchProvider implements WebSearchProvider {
|
||||
|
||||
try {
|
||||
const payload = await response.json() as PerplexityResponse
|
||||
return mapPerplexityResponse(request.query, payload)
|
||||
return mapPerplexityResponse(payload)
|
||||
} catch (error: unknown) {
|
||||
if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error })
|
||||
throw new WebError(`Perplexity returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
|
||||
|
||||
@@ -17,7 +17,6 @@ maybe('PerplexitySearchProvider real API', () => {
|
||||
maxTokens: PERPLEXITY_DEFAULT_MAX_TOKENS,
|
||||
})
|
||||
const result = await provider.search({ query: 'What is the DeepSeek Harness SDK?', maxResults: 5 })
|
||||
expect(result.providerId).toBe('perplexity')
|
||||
expect(result.content ?? '').not.toBe('')
|
||||
for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//)
|
||||
}, 30_000)
|
||||
|
||||
@@ -20,7 +20,7 @@ afterEach(() => {
|
||||
|
||||
describe('Perplexity response mapping', () => {
|
||||
it('maps the answer and prefers structured search_results', () => {
|
||||
const result = mapPerplexityResponse('q', {
|
||||
const result = mapPerplexityResponse({
|
||||
choices: [{ message: { content: 'the answer' } }],
|
||||
search_results: [
|
||||
{ url: 'https://a.test', title: 'A', snippet: 'snip', date: '2026-02-02' },
|
||||
@@ -29,8 +29,6 @@ describe('Perplexity response mapping', () => {
|
||||
citations: ['https://ignored.test'],
|
||||
})
|
||||
expect(result).toEqual({
|
||||
providerId: PERPLEXITY_PROVIDER_ID,
|
||||
query: 'q',
|
||||
content: 'the answer',
|
||||
sources: [
|
||||
{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-02-02' },
|
||||
@@ -41,7 +39,7 @@ describe('Perplexity response mapping', () => {
|
||||
})
|
||||
|
||||
it('falls back to URL-only citations when search_results is absent', () => {
|
||||
const result = mapPerplexityResponse('q', {
|
||||
const result = mapPerplexityResponse({
|
||||
choices: [{ message: { content: 'answer' } }],
|
||||
citations: ['https://a.test', 'https://b.test'],
|
||||
})
|
||||
@@ -49,43 +47,39 @@ describe('Perplexity response mapping', () => {
|
||||
})
|
||||
|
||||
it('omits content when the answer is empty or missing', () => {
|
||||
expect(mapPerplexityResponse('q', { citations: [] }).content).toBeUndefined()
|
||||
expect(mapPerplexityResponse('q', { choices: [{ message: { content: '' } }] }).content).toBeUndefined()
|
||||
expect(mapPerplexityResponse('q', { choices: [{ message: { content: null } }] }).content).toBeUndefined()
|
||||
expect(mapPerplexityResponse({ citations: [] }).content).toBeUndefined()
|
||||
expect(mapPerplexityResponse({ choices: [{ message: { content: '' } }] }).content).toBeUndefined()
|
||||
expect(mapPerplexityResponse({ choices: [{ message: { content: null } }] }).content).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits null/empty optional source fields', () => {
|
||||
const result = mapPerplexityResponse('q', {
|
||||
const result = mapPerplexityResponse({
|
||||
search_results: [{ url: 'https://a.test', title: null, snippet: '', date: null }],
|
||||
})
|
||||
expect(result.sources).toEqual([{ url: 'https://a.test' }])
|
||||
})
|
||||
|
||||
it('yields no sources when neither search_results nor citations are present', () => {
|
||||
expect(mapPerplexityResponse('q', { choices: [{ message: { content: 'a' } }] }).sources).toEqual([])
|
||||
expect(mapPerplexityResponse({ choices: [{ message: { content: 'a' } }] }).sources).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('PerplexitySearchProvider status', () => {
|
||||
describe('PerplexitySearchProvider availability', () => {
|
||||
it('is unavailable without a key', () => {
|
||||
expect(new PerplexitySearchProvider({ ...options, apiKey: '' }).status())
|
||||
.toEqual({ available: false, reason: 'missing-credential' })
|
||||
expect(new PerplexitySearchProvider({ ...options, apiKey: '' }).available()).toBe(false)
|
||||
})
|
||||
|
||||
it('is available with a key', () => {
|
||||
expect(new PerplexitySearchProvider(options).status()).toEqual({ available: true })
|
||||
expect(new PerplexitySearchProvider(options).available()).toBe(true)
|
||||
})
|
||||
|
||||
it('is misconfigured when the base URL is unparseable', () => {
|
||||
expect(new PerplexitySearchProvider({ ...options, baseURL: 'not a url' }).status())
|
||||
.toEqual({ available: false, reason: 'misconfigured' })
|
||||
expect(new PerplexitySearchProvider({ ...options, baseURL: 'not a url' }).available()).toBe(false)
|
||||
})
|
||||
|
||||
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' })
|
||||
expect(new PerplexitySearchProvider({ ...options, maxTokens: 0 }).available()).toBe(false)
|
||||
expect(new PerplexitySearchProvider({ ...options, maxTokens: 1.5 }).available()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -114,7 +108,7 @@ describe('PerplexitySearchProvider request mapping', () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ citations: [] }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const controller = new AbortController()
|
||||
await new PerplexitySearchProvider(options).search({ query: 'q' }, { signal: controller.signal })
|
||||
await new PerplexitySearchProvider(options).search({ query: 'q' }, controller.signal)
|
||||
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(init.signal).toBe(controller.signal)
|
||||
})
|
||||
@@ -190,7 +184,7 @@ describe('web-search-perplexity plugin registration', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { searchProvider: PERPLEXITY_PROVIDER_ID })
|
||||
const fiber = await ctx.plugin(perplexityPlugin, { apiKey: 'pplx-key' })
|
||||
await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: PERPLEXITY_PROVIDER_ID })
|
||||
await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ content: 'a', sources: [] })
|
||||
await fiber.dispose()
|
||||
await expect(ctx.web.search({ query: 'q' }))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))
|
||||
|
||||
Reference in New Issue
Block a user