Files
deepseek-harness/packages/web/web-deepseek-api/src/search.ts
T
yudshj e715991ab9 feat(web): give the model Search API summaries
The DeepSeek Search API returns a short `snippet` for display and a longer
`summary` written for model reading. Carry `summary` on `WebSearchSource`,
map it from the Search API, and render it in place of the snippet in
`web_search` output while the search card keeps the snippet.

`tool-web` collapses summary whitespace and caps each summary at the new
`searchSummaryMaxChars` setting (800 characters by default, the upper end of
the guide's typical range; live summaries measured 1,000-2,300). The cut keeps
surrogate pairs intact and ends in an ellipsis. The PTC tool output type gains
the optional field, so the affected system-prompt sidecars are refreshed, and
the web search round now exercises a multi-line summary.
2026-10-20 12:46:14 +08:00

115 lines
5.0 KiB
TypeScript

/**
* DeepSeek Search API provider: one direct request per search, without an
* auxiliary model request.
* @module @deepseek-ai/dsh-web-deepseek-api/search
*/
import type { WebSearchProvider, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web'
import {
dispatchDeepSeekWeb, hasDeepSeekWebCredential, invalidResponse, isRecord,
type DeepSeekWebAuth, type DeepSeekWebGuidance,
} from './transport.ts'
/** Stable id the Search API provider registers under in the search registry. */
export const DEEPSEEK_SEARCH_API_PROVIDER_ID = 'deepseek-search-api'
/** Default Search and Fetch API base, including its `/v1/tools` prefix; `/search` and `/fetch` are appended. */
export const DEEPSEEK_WEB_API_BASE_URL = 'https://api.deepseek.com/v1/tools'
/** Default Search API result count, the service's own default. */
export const DEEPSEEK_DEFAULT_COUNT = 10
/** Longest `ori_question` the Search API accepts, in characters. */
const ORIGINAL_QUESTION_MAX_CHARS = 128
/** Settings named by endpoint failures of either API in this package. */
export const DEEPSEEK_WEB_API_GUIDANCE: Omit<DeepSeekWebGuidance, 'subject'> = {
plugin: 'web-deepseek-api', field: 'baseURL', label: 'Search / Fetch API base URL', page: 'Web search',
}
/** Authentication, endpoint, and result settings snapshotted for one search. */
export interface DeepSeekSearchApiOptions extends DeepSeekWebAuth {
/** API base including its path prefix; `/search` is appended. */
baseURL: string
/** Results requested per search, from 1 through 50; a smaller request bound wins. */
count: number
/** Read the user question that triggered the search; undefined or blank sends none. */
originalQuestion?: () => string | undefined
}
/**
* Validate a Search API response and project its sources into the web service.
* @param response - untrusted JSON from the Search endpoint.
* @returns sources with provider order, the display `snippet`, the model-facing `summary`, and nullable
* publication dates preserved.
* @throws {@link WebError} when the response does not contain valid search results.
*/
export function mapSearchResponse(response: unknown): WebSearchResult {
if (!isRecord(response) || !Array.isArray(response.results)) throw invalidResponse()
const sources = response.results.map((item: unknown) => {
if (!isRecord(item) || typeof item.url !== 'string' || item.url.length === 0
|| typeof item.title !== 'string' || typeof item.snippet !== 'string'
|| (item.summary !== undefined && typeof item.summary !== 'string')
|| (item.date !== null && typeof item.date !== 'string')) throw invalidResponse()
return {
url: item.url,
...item.title.length > 0 ? { title: item.title } : {},
...item.snippet.length > 0 ? { snippet: item.snippet } : {},
...typeof item.summary === 'string' && item.summary.length > 0 ? { summary: item.summary } : {},
...item.date !== null && item.date.length > 0 ? { publishedAt: item.date } : {},
}
})
return { sources, truncated: false }
}
/**
* Search through the DeepSeek Search API. HTTP redirects fail as `WEB_PROVIDER_ERROR`;
* failures after dispatch name the endpoint and tell the model how the user can configure it.
*/
export class DeepSeekSearchApiProvider implements WebSearchProvider {
readonly id = DEEPSEEK_SEARCH_API_PROVIDER_ID
/**
* @param resolveOptions - the options for the NEXT search, read once at each
* search's entry so one search never mixes two settings sections.
*/
constructor(private readonly resolveOptions: () => DeepSeekSearchApiOptions) {}
available(): boolean {
const options = this.resolveOptions()
return hasDeepSeekWebCredential(options) && URL.canParse(options.baseURL) && isPositiveInteger(options.count)
}
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
const options = this.resolveOptions()
const question = boundedQuestion(options.originalQuestion?.())
return dispatchDeepSeekWeb(options, {
endpoint: `${options.baseURL.replace(/\/+$/u, '')}/search`,
body: {
query: request.query,
count: Math.min(options.count, request.maxResults ?? options.count),
need_content: false,
...question === undefined ? {} : { ori_question: question },
},
decode: mapSearchResponse,
guidance: { subject: 'Search', ...DEEPSEEK_WEB_API_GUIDANCE },
}, signal)
}
}
/**
* Bound the triggering question to what `ori_question` accepts: whitespace
* collapses to single spaces and the first 128 characters remain.
* @param question - the user question, when one exists.
* @returns the bounded question, or undefined when nothing remains.
*/
function boundedQuestion(question: string | undefined): string | undefined {
const collapsed = question?.replace(/\s+/gu, ' ').trim() ?? ''
if (collapsed.length === 0) return undefined
return Array.from(collapsed).slice(0, ORIGINAL_QUESTION_MAX_CHARS).join('')
}
/** True for positive integral request limits. */
function isPositiveInteger(value: number): boolean {
return Number.isInteger(value) && value > 0
}