feat(web): add DeepSeek-backed web search provider

Add @deepseek-ai/dsh-web-search-deepseek: a WebSearchProvider that calls
DeepSeek's Anthropic-compatible Messages API with the native
web_search_20250305 server tool and parses the structured
web_search_tool_result blocks into the ctx.web seam's WebSearchResult.

- Namespace plugin (inject: ['web']), no default export — registers into
  ctx.web like dsh-llm-deepseek registers into ctx.llm.
- Strict mode: a response with no web_search_tool_result block throws
  WEB_PROVIDER_ERROR rather than scraping URLs from model prose.
- Reuses $DEEPSEEK_API_KEY; baseURL defaults to the Anthropic-compatible
  base (api.deepseek.com/anthropic/v1) and does NOT reuse
  $DEEPSEEK_BASE_URL, which belongs to the chat-completions LLM adapter.
- snippet joined from text-block citations; sources deduped by url.
- Two-stage build layout (outDir lib/types) matching the other web
  packages; registered in tsconfig.json, tsconfig.build.json, knip.json,
  and docs/module-graph.md.
This commit is contained in:
Dudu-0223
2026-06-29 15:32:03 +08:00
parent b62cf1a31c
commit b92a3c531a
13 changed files with 834 additions and 0 deletions
+2
View File
@@ -25,6 +25,7 @@ graph TD
llm-replay --> session
session-persistence --> session
web-fetch-local --> web
web-search-deepseek --> web
web-search-exa --> web
web-search-perplexity --> web
invariants --> agent
@@ -116,6 +117,7 @@ graph TD
| `llm-replay` | `llm`, `session` |
| `session-persistence` | `session` |
| `web-fetch-local` | `web` |
| `web-search-deepseek` | `web` |
| `web-search-exa` | `web` |
| `web-search-perplexity` | `web` |
| `invariants` | `agent`, `llm`, `session` |
+4
View File
@@ -37,6 +37,10 @@
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
},
"packages/web/web-search-deepseek": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
},
"packages/ui/acp-agent": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
@@ -0,0 +1,36 @@
# @deepseek-ai/dsh-web-search-deepseek
A [DeepSeek](https://deepseek.com)-backed `WebSearchProvider` for the harness [web capability seam](../web/README.md) (`ctx.web`). It calls DeepSeek's **Anthropic-compatible Messages API** (`POST {baseURL}/messages`) with the native `web_search_20250305` server tool enabled, and maps the structured `web_search_tool_result` blocks DeepSeek returns into the seam's normalized `WebSearchResult`.
This is an **implementation** package: it registers a provider into `ctx.web`, it does not own the key and it does not register a model-facing tool. Like `@deepseek-ai/dsh-llm-deepseek`, it is a function/namespace plugin (`inject: ['web']`). The Anthropic wire shape is a provider-private detail — it does **not** make this provider depend on `ctx.llm`.
## How it differs from a dedicated search endpoint
Exa and Perplexity expose dedicated search endpoints; DeepSeek does not. Instead this provider issues a **full Messages model call** carrying the `web_search` server tool, so one search costs a complete model turn in latency and tokens — heavier than a pure retrieval endpoint. DeepSeek runs the search server-side and returns **structured** `web_search_tool_result` blocks; the provider parses those blocks and **never scrapes URLs out of model prose**.
**Strict mode**: if the response carries no `web_search_tool_result` block (native search did not trigger), the provider throws `WebError` `WEB_PROVIDER_ERROR` rather than degrading to prose-scraping — honest and debuggable.
It reuses `$DEEPSEEK_API_KEY` (no new secret) but **not** `$DEEPSEEK_BASE_URL`: the search endpoint is the Anthropic-compatible base (`https://api.deepseek.com/anthropic/v1`), distinct from the chat-completions base (`https://api.deepseek.com`) the LLM adapter uses.
## Config
| Key | Default | Meaning |
|---|---|---|
| `apiKey` | `$DEEPSEEK_API_KEY` | DeepSeek API key. Empty/absent → provider `status()` reports `missing-credential`. Sent as both `x-api-key` and `Authorization: Bearer` (official vs Anthropic-compatible proxy). |
| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic-compatible endpoint base; `/messages` is appended. Use a separate env var such as `$DEEPSEEK_SEARCH_BASE_URL` when overriding it; do not reuse `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions LLM adapter. An unparseable value makes `status()` report `misconfigured`. |
| `model` | `deepseek-v4-flash` | Anthropic-format model name. |
| `apiVersion` | `2023-06-01` | `anthropic-version` header value. |
| `maxTokens` | `4096` | Upper bound on generated tokens for the Messages request. |
| `maxUses` | `5` | Maximum `web_search` server-tool uses per request. |
```yaml
- id: web-search-deepseek
name: '@deepseek-ai/dsh-web-search-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL
```
## Mapping
DeepSeek returns no provider-generated answer surface this provider trusts as `content`, so `content` is omitted. `sources[]` is built from the `web_search_result` items inside `web_search_tool_result` blocks: `url``url`, `title``title`, `publishedAt``page_age`. The per-source `snippet` lives separately in a `text` block's `citations[]` (a `cited_text` keyed by `url`), so the provider joins the two — a result with no citation excerpt simply has no `snippet`. Results are deduped by `url` (a `maxUses > 1` request can surface the same URL across searches). DeepSeek's `web_search` has no result-count knob (only `maxUses`), so `maxResults` is enforced by the seam (truncating `sources[]` and setting `truncated`). Provider failures surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`.
@@ -0,0 +1,35 @@
{
"name": "@deepseek-ai/dsh-web-search-deepseek",
"description": "DeepSeek-backed search provider (native web_search via the Anthropic-compatible API) for the DeepSeek Harness web capability seam (ctx.web)",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-web": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-web": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}
@@ -0,0 +1,81 @@
/**
* `@deepseek-ai/dsh-web-search-deepseek`: registers a DeepSeek-backed
* `WebSearchProvider` with `ctx.web`. A function/namespace plugin (NOT a
* default-export service): it registers INTO the seam's provider registry, like
* `@deepseek-ai/dsh-llm-deepseek` registers an adapter into `ctx.llm`.
*
* The provider talks to DeepSeek's Anthropic-compatible Messages API with the
* native `web_search_20250305` server tool. It reuses `$DEEPSEEK_API_KEY` (no
* new secret) but NOT `$DEEPSEEK_BASE_URL` — the search endpoint is the
* Anthropic-compatible base, distinct from the chat-completions base the LLM
* adapter uses.
*
* @module @deepseek-ai/dsh-web-search-deepseek
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import type {} from '@deepseek-ai/dsh-web'
import {
DeepSeekSearchProvider,
DEEPSEEK_DEFAULT_API_VERSION,
DEEPSEEK_DEFAULT_BASE_URL,
DEEPSEEK_DEFAULT_MAX_TOKENS,
DEEPSEEK_DEFAULT_MAX_USES,
DEEPSEEK_DEFAULT_MODEL,
} from './provider.ts'
export {
DeepSeekSearchProvider,
DEEPSEEK_DEFAULT_API_VERSION,
DEEPSEEK_DEFAULT_BASE_URL,
DEEPSEEK_DEFAULT_MAX_TOKENS,
DEEPSEEK_DEFAULT_MAX_USES,
DEEPSEEK_DEFAULT_MODEL,
DEEPSEEK_PROVIDER_ID,
citationSnippets,
mapAnthropicResponse,
} from './provider.ts'
export type { DeepSeekSearchProviderOptions } from './provider.ts'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'web-search-deepseek'
/** The web seam this provider registers into. */
export const inject = ['web']
export interface Config {
/** DeepSeek API key. Falls back to `$DEEPSEEK_API_KEY`. Empty → unavailable. */
apiKey?: string
/** Anthropic-compatible endpoint base; `/messages` is appended. */
baseURL?: string
/** Anthropic-format model name. Defaults to `deepseek-v4-flash`. */
model?: string
/** `anthropic-version` header value. Defaults to `2023-06-01`. */
apiVersion?: string
/** Upper bound on generated tokens for the Messages request. Defaults to 4096. */
maxTokens?: number
/** Maximum `web_search` server-tool uses per request. Defaults to 5. */
maxUses?: number
}
export const Config: z<Config> = z.object({
apiKey: z.string(),
baseURL: z.string(),
model: z.string(),
apiVersion: z.string(),
maxTokens: z.natural(),
maxUses: z.natural(),
})
/** Register the DeepSeek search provider with `ctx.web`. */
export function apply(ctx: Context, config: Config): void {
ctx.web.registerSearchProvider(new DeepSeekSearchProvider({
apiKey: config.apiKey ?? process.env.DEEPSEEK_API_KEY ?? '',
baseURL: config.baseURL ?? DEEPSEEK_DEFAULT_BASE_URL,
model: config.model ?? DEEPSEEK_DEFAULT_MODEL,
apiVersion: config.apiVersion ?? DEEPSEEK_DEFAULT_API_VERSION,
maxTokens: config.maxTokens ?? DEEPSEEK_DEFAULT_MAX_TOKENS,
maxUses: config.maxUses ?? DEEPSEEK_DEFAULT_MAX_USES,
}))
}
@@ -0,0 +1,217 @@
/**
* `DeepSeekSearchProvider`: a `WebSearchProvider` backed by DeepSeek's
* Anthropic-compatible Messages API with the native `web_search_20250305` server
* tool enabled.
*
* Unlike a dedicated search endpoint (Exa's `POST /search`, Perplexity's
* `/chat/completions`), this issues a FULL Messages model call carrying a server
* tool, so a search costs a complete model turn in latency and tokens. In return
* DeepSeek runs the search server-side and returns STRUCTURED
* `web_search_tool_result` blocks — this provider parses those blocks and never
* scrapes URLs out of model prose. Strict mode: if the response carries no
* `web_search_tool_result` block (native search did not trigger), it throws
* `WEB_PROVIDER_ERROR` rather than degrading to prose-scraping.
*
* Network requests use platform-native `fetch` (Node 24), mirroring
* `@deepseek-ai/dsh-llm-deepseek`'s adapter — not a cordis HTTP-client service.
* The Anthropic wire shape is a provider-private detail and does NOT make this
* provider depend on `ctx.llm`.
*
* @module @deepseek-ai/dsh-web-search-deepseek/provider
*/
import { WebError } from '@deepseek-ai/dsh-web'
import type {
WebProviderStatus,
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'
/**
* 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'
export interface DeepSeekSearchProviderOptions {
/** DeepSeek API key. Empty/absent → `status()` reports `missing-credential`. */
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).
*/
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.
*
* Throws `WEB_PROVIDER_ERROR` (strict mode) when no `web_search_tool_result`
* block is present — native search did not trigger, and prose-scraping is not a
* fallback.
*/
export function mapAnthropicResponse(query: string, 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 { providerId: DEEPSEEK_PROVIDER_ID, query, sources, truncated: false }
}
/** The DeepSeek-backed search provider. */
export class DeepSeekSearchProvider implements WebSearchProvider {
readonly id = DEEPSEEK_PROVIDER_ID
constructor(private readonly options: DeepSeekSearchProviderOptions) {}
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' }
return { available: true }
}
async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebSearchResult> {
let response: Response
try {
response = await fetch(`${this.options.baseURL}/messages`, {
method: 'POST',
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 }],
}),
...exec?.signal ? { signal: exec.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')
}
let payload: AnthropicResponse
try {
payload = await response.json() as AnthropicResponse
} catch (error: unknown) {
if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error })
throw new WebError(`DeepSeek returned an unparseable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
}
return mapAnthropicResponse(request.query, payload)
}
}
/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === 'AbortError'
}
@@ -0,0 +1,58 @@
/**
* Wire types for DeepSeek's Anthropic-compatible Messages API
* (`POST {baseURL}/messages`) with the native `web_search_20250305` server tool
* enabled. Types only — no runtime code.
*
* DeepSeek returns structured content blocks: `web_search_tool_result` blocks
* carry the citeable `web_search_result` items (`url`/`title`/`page_age`), while
* the snippet/excerpt for a URL lives separately in a `text` block's
* `citations[]` (a `cited_text` keyed by `url`). The provider joins the two.
*
* The Anthropic wire shape is a provider-private detail; it does not make this
* provider depend on `ctx.llm`.
*
* @module @deepseek-ai/dsh-web-search-deepseek/types
*/
/** A `web_search_result` item inside a `web_search_tool_result` block. */
export interface WebSearchResultItem {
type: string
url: string
title?: string | null
/** Provider-supplied page age/recency string (mapped to `publishedAt`). */
page_age?: string | null
}
/** A `web_search_tool_result` content block: the citeable result surface. */
export interface WebSearchToolResultBlock {
type: 'web_search_tool_result'
content?: WebSearchResultItem[]
}
/** One citation location inside a `text` block (the snippet surface). */
export interface CitationLocation {
type?: string
url?: string | null
cited_text?: string | null
}
/** A `text` content block: the model's prose plus per-URL citations. */
export interface TextBlock {
type: 'text'
text?: string | null
citations?: CitationLocation[]
}
/** Any content block; only `web_search_tool_result` and `text` are consumed. */
export type ContentBlock = WebSearchToolResultBlock | TextBlock | { type: string }
/** DeepSeek's Anthropic Messages response envelope. */
export interface AnthropicResponse {
content?: ContentBlock[]
}
/** DeepSeek's error response envelope (best-effort; fields vary). */
export interface AnthropicError {
error?: { message?: string } | string
message?: string
}
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import {
DeepSeekSearchProvider,
DEEPSEEK_DEFAULT_API_VERSION,
DEEPSEEK_DEFAULT_BASE_URL,
DEEPSEEK_DEFAULT_MAX_TOKENS,
DEEPSEEK_DEFAULT_MAX_USES,
DEEPSEEK_DEFAULT_MODEL,
} from '@deepseek-ai/dsh-web-search-deepseek'
/**
* Real-API smoke for the DeepSeek search provider. Self-skips without
* `$DEEPSEEK_API_KEY`, per the with-key e2e policy in AGENTS.md § Secrets. This
* is the only test that proves DeepSeek's Anthropic-compatible endpoint actually
* triggers native `web_search` and returns the structured result blocks the
* provider parses — a mock cannot confirm the wire shape is real.
*/
const apiKey = process.env.DEEPSEEK_API_KEY
const maybe = apiKey !== undefined && apiKey.length > 0 ? describe : describe.skip
maybe('DeepSeekSearchProvider real API', () => {
it('returns citeable sources for a live query via native web_search', async () => {
const provider = new DeepSeekSearchProvider({
apiKey: apiKey!,
baseURL: process.env.DEEPSEEK_SEARCH_BASE_URL ?? DEEPSEEK_DEFAULT_BASE_URL,
model: process.env.DEEPSEEK_SEARCH_MODEL ?? DEEPSEEK_DEFAULT_MODEL,
apiVersion: DEEPSEEK_DEFAULT_API_VERSION,
maxTokens: DEEPSEEK_DEFAULT_MAX_TOKENS,
maxUses: DEEPSEEK_DEFAULT_MAX_USES,
})
const result = await provider.search({ query: 'What is the DeepSeek coding agent?', maxResults: 5 })
expect(result.providerId).toBe('deepseek')
expect(result.sources.length).toBeGreaterThan(0)
for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//)
}, 60_000)
})
@@ -0,0 +1,326 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import WebService from '@deepseek-ai/dsh-web'
import {
DeepSeekSearchProvider,
citationSnippets,
mapAnthropicResponse,
DEEPSEEK_PROVIDER_ID,
} from '@deepseek-ai/dsh-web-search-deepseek'
import * as deepseekPlugin from '@deepseek-ai/dsh-web-search-deepseek'
import type { AnthropicResponse } from '@deepseek-ai/dsh-web-search-deepseek/src/types.ts'
const options = {
apiKey: 'ds-key',
baseURL: 'https://api.deepseek.test/anthropic/v1',
model: 'deepseek-chat',
apiVersion: '2023-06-01',
maxTokens: 4096,
maxUses: 5,
}
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' }, ...init })
}
/** A response with one result block plus a text block carrying the snippet. */
function searchResponse(): AnthropicResponse {
return {
content: [
{ type: 'text', text: 'Here is what I found.', citations: [{ type: 'web_search_result_location', url: 'https://a.test', cited_text: 'excerpt for A' }] },
{
type: 'web_search_tool_result',
content: [
{ type: 'web_search_result', url: 'https://a.test', title: 'A', page_age: '2026-02-02' },
{ type: 'web_search_result', url: 'https://b.test', title: 'B' },
],
},
],
}
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe('citationSnippets', () => {
it('maps url → cited_text from text blocks, first occurrence wins', () => {
const map = citationSnippets([
{ type: 'text', citations: [{ url: 'https://a.test', cited_text: 'first' }, { url: 'https://a.test', cited_text: 'second' }] },
{ type: 'text', citations: [{ url: 'https://b.test', cited_text: 'b text' }] },
])
expect(map.get('https://a.test')).toBe('first')
expect(map.get('https://b.test')).toBe('b text')
})
it('ignores citations missing url or cited_text', () => {
const map = citationSnippets([
{ type: 'text', citations: [{ url: 'https://a.test' }, { cited_text: 'orphan' }, { url: '', cited_text: 'empty url' }] },
])
expect(map.size).toBe(0)
})
})
describe('mapAnthropicResponse', () => {
it('joins result items to citation snippets and maps page_age to publishedAt', () => {
const result = mapAnthropicResponse('q', searchResponse())
expect(result).toEqual({
providerId: DEEPSEEK_PROVIDER_ID,
query: 'q',
sources: [
{ url: 'https://a.test', title: 'A', snippet: 'excerpt for A', publishedAt: '2026-02-02' },
{ url: 'https://b.test', title: 'B' },
],
truncated: false,
})
})
it('dedupes repeated urls across result blocks (first wins)', () => {
const result = mapAnthropicResponse('q', {
content: [
{ type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: 'first' }] },
{ type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: 'second' }] },
],
})
expect(result.sources).toEqual([{ url: 'https://a.test', title: 'first' }])
})
it('skips non-result items and items with an empty url', () => {
const result = mapAnthropicResponse('q', {
content: [{
type: 'web_search_tool_result',
content: [
{ type: 'web_search_result_error', url: 'https://err.test' },
{ type: 'web_search_result', url: '' },
{ type: 'web_search_result', url: 'https://ok.test' },
],
}],
})
expect(result.sources).toEqual([{ url: 'https://ok.test' }])
})
it('omits optional fields when absent or empty', () => {
const result = mapAnthropicResponse('q', {
content: [{ type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: '', page_age: '' }] }],
})
expect(result.sources).toEqual([{ url: 'https://a.test' }])
})
it('tolerates a text block with no citations', () => {
const result = mapAnthropicResponse('q', {
content: [
{ type: 'text', text: 'no citations here' },
{ type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: 'A' }] },
],
})
expect(result.sources).toEqual([{ url: 'https://a.test', title: 'A' }])
})
it('tolerates a result block with no content array', () => {
const result = mapAnthropicResponse('q', {
content: [
{ type: 'web_search_tool_result' },
{ type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test' }] },
],
})
expect(result.sources).toEqual([{ url: 'https://a.test' }])
})
it('throws WEB_PROVIDER_ERROR (strict mode) when no result block is present', () => {
expect(() => mapAnthropicResponse('q', { content: [{ type: 'text', text: 'just prose, no search' }] }))
.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
})
it('throws WEB_PROVIDER_ERROR when content is absent entirely', () => {
expect(() => mapAnthropicResponse('q', {}))
.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
})
})
describe('DeepSeekSearchProvider status', () => {
it('is unavailable without a key', () => {
expect(new DeepSeekSearchProvider({ ...options, apiKey: '' }).status())
.toEqual({ available: false, reason: 'missing-credential' })
})
it('is available with a key', () => {
expect(new DeepSeekSearchProvider(options).status()).toEqual({ available: true })
})
it('is misconfigured when the base URL is unparseable', () => {
expect(new DeepSeekSearchProvider({ ...options, baseURL: 'not a url' }).status())
.toEqual({ available: false, reason: 'misconfigured' })
})
})
describe('DeepSeekSearchProvider request mapping', () => {
it('posts an Anthropic Messages request enabling the web_search server tool', async () => {
const fetchMock = vi.fn(async () => jsonResponse(searchResponse()))
vi.stubGlobal('fetch', fetchMock)
await new DeepSeekSearchProvider(options).search({ query: 'hello' })
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
expect(url).toBe('https://api.deepseek.test/anthropic/v1/messages')
const headers = init.headers as Record<string, string>
expect(headers['x-api-key']).toBe('ds-key')
expect(headers['authorization']).toBe('Bearer ds-key')
expect(headers['anthropic-version']).toBe('2023-06-01')
expect(JSON.parse(init.body as string)).toEqual({
model: 'deepseek-chat',
max_tokens: 4096,
messages: [{ role: 'user', content: [{ type: 'text', text: 'Perform a web search for the query: hello' }] }],
tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: 5 }],
})
})
it('forwards the abort signal', async () => {
const fetchMock = vi.fn(async () => jsonResponse(searchResponse()))
vi.stubGlobal('fetch', fetchMock)
const controller = new AbortController()
await new DeepSeekSearchProvider(options).search({ query: 'q' }, { signal: controller.signal })
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
expect(init.signal).toBe(controller.signal)
})
})
describe('DeepSeekSearchProvider error handling', () => {
it('maps an HTTP error to WEB_PROVIDER_ERROR with the provider message', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: { message: 'rate limited' } }, { status: 429 })))
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR', message: 'rate limited' }))
})
it('handles a string-form error body', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: 'bad request' }, { status: 400 })))
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ message: 'bad request' }))
})
it('keeps a status-line message when the error body is not JSON', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('upstream error', { status: 503 })))
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ message: 'DeepSeek API error (HTTP 503)' }))
})
it('keeps the status-line message when the JSON error body carries no detail', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({}, { status: 500 })))
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ message: 'DeepSeek API error (HTTP 500)' }))
})
it('maps an abort to WEB_ABORTED', async () => {
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new DOMException('aborted', 'AbortError'))))
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
})
it('maps an unparseable success body to WEB_PROVIDER_ERROR', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('not json', { status: 200 })))
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
})
it('surfaces an abort during success-body parse as WEB_ABORTED', async () => {
const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: true, status: 200 }
vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response))
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
})
it('surfaces an abort during error-body parse as WEB_ABORTED', async () => {
const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: false, status: 500 }
vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response))
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
})
it('maps a network failure to WEB_PROVIDER_ERROR', async () => {
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new TypeError('connection refused'))))
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
})
it('strict mode flows through search(): a prose-only response throws WEB_PROVIDER_ERROR', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ content: [{ type: 'text', text: 'no search happened' }] })))
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
})
})
describe('web-search-deepseek plugin registration', () => {
it('registers the provider into ctx.web (HMR-safe)', async () => {
const ctx = new Context()
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
const fiber = await ctx.plugin(deepseekPlugin, { apiKey: 'ds-key' })
expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: DEEPSEEK_PROVIDER_ID })
await fiber.dispose()
expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-missing' })
})
it('has no default export (namespace plugin export shape)', () => {
expect('default' in deepseekPlugin).toBe(false)
})
it('survives the real Loader unwrapExports path keeping name/inject/Config', () => {
// A stray `export default apply` would make the cordis Loader's
// unwrapExports (`exports.default ?? exports`) collapse the module to the
// bare `apply` function, DROPPING `inject: ['web']` — the plugin would then
// read ctx.web without injecting it and throw "cannot get property … without
// inject" the moment it loads. A hand-built ctx.plugin(namespace) mount
// bypasses unwrapExports and cannot catch that, so drive the real path.
// Prove it bites: add `export default apply` to src/index.ts, watch this go
// red, revert.
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(deepseekPlugin) as Record<string, unknown>
expect(unwrapped).toBe(deepseekPlugin)
expect(unwrapped.name).toBe('web-search-deepseek')
expect(unwrapped.inject).toEqual(['web'])
expect(typeof unwrapped.apply).toBe('function')
})
it('boots over ctx.web through the unwrapped module without an inject error', async () => {
const ctx = new Context()
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(deepseekPlugin) as Parameters<Context['plugin']>[0]
// A collapsed export shape (dropped inject) would throw "without inject" here.
const fiber = await ctx.plugin(unwrapped, { apiKey: 'ds-key' })
expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: DEEPSEEK_PROVIDER_ID })
await fiber.dispose()
})
it('falls back to the env key and defaults when config omits them', async () => {
const prev = process.env.DEEPSEEK_API_KEY
process.env.DEEPSEEK_API_KEY = 'env-key'
try {
const fetchMock = vi.fn(async () => jsonResponse(searchResponse()))
vi.stubGlobal('fetch', fetchMock)
const ctx = new Context()
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
const fiber = await ctx.plugin(deepseekPlugin, {})
expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: DEEPSEEK_PROVIDER_ID })
await ctx.web.search({ query: 'q' })
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
expect(url).toBe('https://api.deepseek.com/anthropic/v1/messages')
expect((init.headers as Record<string, string>)['x-api-key']).toBe('env-key')
expect(JSON.parse(init.body as string)).toMatchObject({ model: 'deepseek-v4-flash' })
await fiber.dispose()
} finally {
if (prev === undefined) delete process.env.DEEPSEEK_API_KEY
else process.env.DEEPSEEK_API_KEY = prev
}
})
it('is unavailable when neither config nor env supplies a key', async () => {
const prev = process.env.DEEPSEEK_API_KEY
delete process.env.DEEPSEEK_API_KEY
try {
const ctx = new Context()
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
await ctx.plugin(deepseekPlugin, {})
expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-unavailable' })
} finally {
if (prev !== undefined) process.env.DEEPSEEK_API_KEY = prev
}
})
})
@@ -0,0 +1,24 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../web"
}
]
}
+13
View File
@@ -763,6 +763,19 @@ importers:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
packages/web/web-search-deepseek:
dependencies:
schemastery:
specifier: ^3.18.0
version: 3.18.0
devDependencies:
'@deepseek-ai/dsh-web':
specifier: workspace:^
version: link:../web
cordis:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
packages/web/web-search-exa:
dependencies:
schemastery:
+1
View File
@@ -30,6 +30,7 @@
{ "path": "./packages/web/web" },
{ "path": "./packages/web/web-search-exa" },
{ "path": "./packages/web/web-search-perplexity" },
{ "path": "./packages/web/web-search-deepseek" },
{ "path": "./packages/web/web-fetch-local" },
{ "path": "./packages/web/tool-web" },
{ "path": "./packages/support/invariants" },
+1
View File
@@ -41,6 +41,7 @@
{ "path": "./packages/web/web" },
{ "path": "./packages/web/web-search-exa" },
{ "path": "./packages/web/web-search-perplexity" },
{ "path": "./packages/web/web-search-deepseek" },
{ "path": "./packages/web/web-fetch-local" },
{ "path": "./packages/web/tool-web" },
{ "path": "./packages/support/invariants" },