Files
deepseek-harness/packages/web/web-search-deepseek/src/provider.ts
Yichen Jiang fee12f1af0 refactor(llm-deepseek)!: rename the provider route to deepseek-official
The native adapter's route was named deepseek, colliding with pi-ai's
catalog provider of the same name, so the two DeepSeek paths could never
be mounted side by side. The web settings page needs both configurable at
once. Compositions, fixtures, goldens, scaffolding defaults, and docs all
move together (pre-release, no shim); TUI/session-query-spill/
missing-credential goldens re-recorded through their keyless refresh
modes because provider-name length shifts box padding and spill
truncation points.
2026-07-29 16:36:07 +08:00

214 lines
8.8 KiB
TypeScript

/**
* DeepSeek search through an Anthropic-compatible Messages model call with the native
* `web_search_20250305` server tool. Each search costs a model turn, but returns structured
* result blocks; absence of those blocks is an error rather than a prose-scraping fallback.
* The wire format and native `fetch` client are provider-private and do not use `ctx.llm`.
* @module @deepseek-ai/dsh-web-search-deepseek/provider
*/
import { WebError } from '@deepseek-ai/dsh-web'
import type {
WebSearchProvider,
WebSearchRequest,
WebSearchResult,
WebSearchSource,
} from '@deepseek-ai/dsh-web'
import type {
AnthropicError,
AnthropicResponse,
ContentBlock,
TextBlock,
WebSearchToolResultBlock,
} from './types.ts'
/** Stable id this provider registers under. */
export const DEEPSEEK_PROVIDER_ID = 'deepseek-official'
/**
* Default endpoint: DeepSeek's Anthropic-compatible surface, `/v1` included
* (`/messages` is appended). This is NOT the chat-completions base
* (`https://api.deepseek.com`) `@deepseek-ai/dsh-llm-deepseek` uses, so this
* provider does NOT reuse `$DEEPSEEK_BASE_URL` — only the API key is shared.
*/
export const DEEPSEEK_DEFAULT_BASE_URL = 'https://api.deepseek.com/anthropic/v1'
/** Default Anthropic-format model name (aligned with the repo's DeepSeek model vocabulary). */
export const DEEPSEEK_DEFAULT_MODEL = 'deepseek-v4-flash'
/** Default `anthropic-version` header value. */
export const DEEPSEEK_DEFAULT_API_VERSION = '2023-06-01'
/** Default upper bound on generated tokens for the Messages request. */
export const DEEPSEEK_DEFAULT_MAX_TOKENS = 4096
/** Default maximum `web_search` server-tool uses per request. */
export const DEEPSEEK_DEFAULT_MAX_USES = 5
/** Attribution header sent on every request. Bump with the package version. */
const USER_AGENT = 'deepseek-harness/0.0.1'
/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
export interface DeepSeekSearchProviderOptions {
/** DeepSeek API key. Empty/absent makes the provider unavailable. */
apiKey: string
/** Endpoint base; `/messages` is appended. */
baseURL: string
/** Anthropic-format model name. */
model: string
/** `anthropic-version` header value. */
apiVersion: string
/** Upper bound on generated tokens for the Messages request. */
maxTokens: number
/** Maximum `web_search` server-tool uses per request. */
maxUses: number
}
/**
* Build a `url → cited_text` map from every `text` block's `citations[]`. This
* is the snippet surface: Anthropic `web_search_result` items carry
* `url`/`title`/`page_age` but typically NO inline snippet — the excerpt lives
* in a separate `text` block's citation, keyed by `url` (first occurrence wins).
*
* @param blocks - the response's content blocks; non-`text` blocks are skipped.
* @returns the `url → cited_text` map (empty when no citations are present).
*/
export function citationSnippets(blocks: readonly ContentBlock[]): Map<string, string> {
const map = new Map<string, string>()
for (const block of blocks) {
if (block.type !== 'text') continue
for (const cite of (block as TextBlock).citations ?? []) {
if (cite.url != null && cite.url.length > 0 && cite.cited_text != null && cite.cited_text.length > 0 && !map.has(cite.url)) {
map.set(cite.url, cite.cited_text)
}
}
}
return map
}
/**
* Map a DeepSeek Anthropic Messages response to a normalized search result. Walks
* `web_search_tool_result` blocks for citeable `web_search_result` items, joins each to its
* citation excerpt as `snippet`, and dedupes by `url` (a `max_uses > 1` request can surface
* the same URL across searches). The seam owns the final `maxResults` truncation, so
* `truncated` is always `false` here.
*
* @param response - the parsed Messages response body.
* @returns the normalized result with deduped, snippet-joined sources.
* @throws {@link WebError} when native search produced no result block.
*/
export function mapAnthropicResponse(response: AnthropicResponse): WebSearchResult {
const blocks = response.content ?? []
const resultBlocks = blocks.filter(
(block): block is WebSearchToolResultBlock => block.type === 'web_search_tool_result',
)
if (resultBlocks.length === 0) {
throw new WebError(
'DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search',
'WEB_PROVIDER_ERROR',
)
}
const snippets = citationSnippets(blocks)
const seen = new Set<string>()
const sources: WebSearchSource[] = []
for (const block of resultBlocks) {
for (const item of block.content ?? []) {
if (item.type !== 'web_search_result' || item.url.length === 0 || seen.has(item.url)) continue
seen.add(item.url)
const snippet = snippets.get(item.url)
sources.push({
url: item.url,
...item.title != null && item.title.length > 0 ? { title: item.title } : {},
...snippet != null && snippet.length > 0 ? { snippet } : {},
...item.page_age != null && item.page_age.length > 0 ? { publishedAt: item.page_age } : {},
})
}
}
return { sources, truncated: false }
}
/** The DeepSeek-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */
export class DeepSeekSearchProvider implements WebSearchProvider {
readonly id = DEEPSEEK_PROVIDER_ID
constructor(private readonly options: DeepSeekSearchProviderOptions) {}
available(): boolean {
return this.options.apiKey.length > 0
&& URL.canParse(this.options.baseURL)
&& isPositiveInteger(this.options.maxTokens)
&& isPositiveInteger(this.options.maxUses)
}
async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
let response: Response
try {
response = await fetch(`${this.options.baseURL}/messages`, {
method: 'POST',
redirect: 'error',
headers: {
// Official DeepSeek expects `x-api-key`; an Anthropic-compatible proxy
// may expect `Authorization: Bearer` — send both so either resolves.
'x-api-key': this.options.apiKey,
'authorization': `Bearer ${this.options.apiKey}`,
'anthropic-version': this.options.apiVersion,
'content-type': 'application/json',
'accept': 'application/json',
'user-agent': USER_AGENT,
},
body: JSON.stringify({
model: this.options.model,
max_tokens: this.options.maxTokens,
messages: [{
role: 'user',
content: [{ type: 'text', text: `Perform a web search for the query: ${request.query}` }],
}],
tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: this.options.maxUses }],
}),
...signal !== undefined ? { signal } : {},
})
} catch (error: unknown) {
if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error })
throw new WebError(`DeepSeek search request failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
}
if (!response.ok) {
const status = response.status
let message = `DeepSeek API error (HTTP ${status})`
try {
const parsed = await response.json() as AnthropicError
const detail = typeof parsed.error === 'string' ? parsed.error : parsed.error?.message ?? parsed.message
if (detail !== undefined && detail.length > 0) message = detail
} catch (error: unknown) {
// An abort fired mid-body must surface as WEB_ABORTED, not be swallowed
// into a generic HTTP-error message — cancellation is not a provider
// error (the seam's cancellation contract).
if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error })
// Otherwise: the HTTP status is already captured in `message` above; a
// malformed/non-JSON error body (normal for gateway 5xx/429s) can only
// cost a richer provider message, never the real error.
}
throw new WebError(message, 'WEB_PROVIDER_ERROR')
}
try {
const payload = await response.json() as AnthropicResponse
return mapAnthropicResponse(payload)
} catch (error: unknown) {
if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error })
if (error instanceof WebError) throw error
throw new WebError(`DeepSeek returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
}
}
}
/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === 'AbortError'
}
/** True for DeepSeek request limits that can be sent to the Messages API. */
function isPositiveInteger(value: number): boolean {
return Number.isInteger(value) && value > 0
}