From b92a3c531a19710052a2a145e560a49e107b0d0e Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 29 Jun 2026 15:21:12 +0800 Subject: [PATCH] feat(web): add DeepSeek-backed web search provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/module-graph.md | 2 + knip.json | 4 + packages/web/web-search-deepseek/README.md | 36 ++ packages/web/web-search-deepseek/package.json | 35 ++ packages/web/web-search-deepseek/src/index.ts | 81 +++++ .../web/web-search-deepseek/src/provider.ts | 217 ++++++++++++ packages/web/web-search-deepseek/src/types.ts | 58 ++++ .../web-search-deepseek/tests/deepseek.e2e.ts | 36 ++ .../tests/deepseek.spec.ts | 326 ++++++++++++++++++ .../web/web-search-deepseek/tsconfig.json | 24 ++ pnpm-lock.yaml | 13 + tsconfig.build.json | 1 + tsconfig.json | 1 + 13 files changed, 834 insertions(+) create mode 100644 packages/web/web-search-deepseek/README.md create mode 100644 packages/web/web-search-deepseek/package.json create mode 100644 packages/web/web-search-deepseek/src/index.ts create mode 100644 packages/web/web-search-deepseek/src/provider.ts create mode 100644 packages/web/web-search-deepseek/src/types.ts create mode 100644 packages/web/web-search-deepseek/tests/deepseek.e2e.ts create mode 100644 packages/web/web-search-deepseek/tests/deepseek.spec.ts create mode 100644 packages/web/web-search-deepseek/tsconfig.json diff --git a/docs/module-graph.md b/docs/module-graph.md index a17680cb6d..cddfabee14 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -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` | diff --git a/knip.json b/knip.json index 3f0a56097c..f0b8e44705 100644 --- a/knip.json +++ b/knip.json @@ -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"] diff --git a/packages/web/web-search-deepseek/README.md b/packages/web/web-search-deepseek/README.md new file mode 100644 index 0000000000..5b56601bbb --- /dev/null +++ b/packages/web/web-search-deepseek/README.md @@ -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`. diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json new file mode 100644 index 0000000000..617e9f2768 --- /dev/null +++ b/packages/web/web-search-deepseek/package.json @@ -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" + } +} diff --git a/packages/web/web-search-deepseek/src/index.ts b/packages/web/web-search-deepseek/src/index.ts new file mode 100644 index 0000000000..9a04955dd9 --- /dev/null +++ b/packages/web/web-search-deepseek/src/index.ts @@ -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 = 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, + })) +} diff --git a/packages/web/web-search-deepseek/src/provider.ts b/packages/web/web-search-deepseek/src/provider.ts new file mode 100644 index 0000000000..5d02ad01ab --- /dev/null +++ b/packages/web/web-search-deepseek/src/provider.ts @@ -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 { + const map = new Map() + 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() + 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 { + 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' +} diff --git a/packages/web/web-search-deepseek/src/types.ts b/packages/web/web-search-deepseek/src/types.ts new file mode 100644 index 0000000000..bd88ed9663 --- /dev/null +++ b/packages/web/web-search-deepseek/src/types.ts @@ -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 +} diff --git a/packages/web/web-search-deepseek/tests/deepseek.e2e.ts b/packages/web/web-search-deepseek/tests/deepseek.e2e.ts new file mode 100644 index 0000000000..d06e384b31 --- /dev/null +++ b/packages/web/web-search-deepseek/tests/deepseek.e2e.ts @@ -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) +}) diff --git a/packages/web/web-search-deepseek/tests/deepseek.spec.ts b/packages/web/web-search-deepseek/tests/deepseek.spec.ts new file mode 100644 index 0000000000..ff6b37cfa2 --- /dev/null +++ b/packages/web/web-search-deepseek/tests/deepseek.spec.ts @@ -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 + 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 + 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[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)['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 + } + }) +}) diff --git a/packages/web/web-search-deepseek/tsconfig.json b/packages/web/web-search-deepseek/tsconfig.json new file mode 100644 index 0000000000..aa7c949fec --- /dev/null +++ b/packages/web/web-search-deepseek/tsconfig.json @@ -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" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a393b6c4b9..a4e0811228 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/tsconfig.build.json b/tsconfig.build.json index fb7a058ea8..840b9aee5f 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -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" }, diff --git a/tsconfig.json b/tsconfig.json index 120ad5a4fb..ba0af75248 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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" },