dsh-web-tools-deepseek
English · 简体中文
A provider bundle for the DeepSeek Harness (DSH) ctx.web seam. It routes web_search and web_fetch to DeepSeek's own native Search and Fetch APIs instead of the model-backed web-search tool, and falls back to the native provider when the tools API fails or returns nothing.
The HTTP work lives in a small Rust engine (crates/engine, ~900 KiB) spoken to over NDJSON on stdio; the cordis plugin is a thin JavaScript bridge around it.
tool-web ──▶ ctx.web ──▶ this bundle ──┬─▶ WebSearchProvider ─┐
└─▶ WebFetchProvider ─┤ NDJSON/stdio
▼
dsh-web-tools-deepseek(.exe) (Rust)
│
POST /v1/tools/search │ POST /v1/tools/fetch
▼
https://api.deepseek.com
Why — and what it costs
DSH ships two web providers by default:
| provider | how it works | billed |
|---|
| search | deepseek-official | a server-side tool (web_search_20250305) run inside the model call | tokens — the results land in your context |
| fetch | http | fetches the URL locally, anonymously | free |
This bundle replaces both with DeepSeek's dedicated endpoints:
| endpoint | billed |
|---|
| search | POST /v1/tools/search | ¥5 per 1,000 successful requests (≈ ¥0.005 each) |
| fetch | POST /v1/tools/fetch | ¥5 per 1,000 successful requests (≈ ¥0.005 each) |
Only successful requests are billed. A request counts when it returns HTTP 200 and produced a result — for search a non-empty results, for fetch page content. A 403, a 404, an empty result set, a timeout and a failed call are all free. count changes how many results you get, never the price. See Pricing and limits.
The trade: a predictable per-call price instead of token cost, plus search results that arrive as clean structured fields (title / url / summary / date) rather than as text the model has to parse out of its own tool output. To go back, see Rolling back.
Install
From a clone or directory
git clone https://github.com/CarbonAnime/dsh-web-tools-deepseek.git
dsh plugin --profile desktop add "/absolute/path/to/dsh-web-tools-deepseek"
From GitHub
dsh plugin --profile desktop add "github:CarbonAnime/dsh-web-tools-deepseek"
What the install does
The package declares dsh.bundle.patch, so adding it appends to dsh.profile.bundles and its cordis.patch.yml is applied. That patch:
- inserts the plugin row (registering
deepseek-tools-api for both search and fetch), and
- overrides the
web row so searchProvider and fetchProvider point at it.
Both halves are required. A patch entry replaces that row's whole config rather than deep-merging it, so the override must restate both keys; and because three providers are then registered (deepseek-official, http, deepseek-tools-api) the seam would otherwise refuse to guess and raise WEB_PROVIDER_AMBIGUOUS.
Restart DSH after installing. Cordis re-runs apply() on a config reload, but the plugin module is ESM-cached — code edits and a first mount need a process restart (verified experimentally; see Troubleshooting).
Requirements
| |
|---|
| DSH | @deepseek-ai/dsh-desktop 0.2.x (targets the ctx.web provider seam) |
| Node | ≥ 18.17 |
| Credential | DEEPSEEK_API_KEY in $DSH_HOME/.credentials.yaml, or a literal apiKey |
| Rust | not needed — bin/dsh-web-tools-deepseek-win32-x64.exe is committed. Build it for Linux/macOS, or take a release asset (zip uploads do not keep the executable bit: chmod +x it). |
Building the engine
cargo build --release # or: npm run build:engine
node scripts/stage-engine.mjs # → bin/dsh-web-tools-deepseek-<platform>-<arch>
The engine is resolved in this order, so a source checkout, an installed copy and a packed tarball all work:
$DSH_WEB_TOOLS_DEEPSEEK_BIN
- the
enginePath config field
bin/dsh-web-tools-deepseek-<platform>-<arch>[.exe]
bin/dsh-web-tools-deepseek[.exe]
target/release/…, then target/debug/… (an unstaged checkout)
Configuration
Every field has a working default, so an empty config block is valid. Override fields on the plugin row in a cordis.patch.yml at or after this bundle:
- id: web-tools-deepseek
name: dsh-web-tools-deepseek
config:
count: 8
qps: 4
| field | default | meaning |
|---|
apiKey | '' | Literal API key. Prefer apiKeyEnv — a literal ends up in the config file. |
apiKeyEnv | DEEPSEEK_API_KEY | Credential reference, resolved through the credentials service on every call, so a rotated key is picked up without a restart. |
baseURL | https://api.deepseek.com | API origin. |
searchProviderId | deepseek-tools-api | Id the search provider registers under; must match web.searchProvider. |
fetchProviderId | deepseek-tools-api | Id the fetch provider registers under; must match web.fetchProvider. |
count | 10 | Results requested when the caller passes no maxResults (1–50). Price-neutral. |
snippetSource | summary | Which API field becomes each source's snippet: summary (600–800 chars, "for models to read") or snippet (100–200 chars, "for front-end display"). See below. |
oriQuestion | '' | Sent as ori_question when non-empty; over 128 characters is truncated with a warning. |
localization | '' | Sent as localization when non-empty. This is an IP address used to prefer results local to it — not a language tag. For language, put lang:zh,en in the query. |
blockedAsResult | true | Return fetch 403/404 as a result carrying a clear non-content notice (true), instead of throwing (false). |
includeTitle | true | Prepend the API's title as an H1 when the Markdown does not already carry it. |
Why snippetSource defaults to summary
DeepSeek's documentation labels the two fields by consumer:
snippet — short summary, relevant to the query, usually 100–200 characters, suitable for front-end display.
summary — long summary, relevant to the query, usually 600–800 characters, suitable for models to read.
The only consumer of a DSH WebSource is a model, so summary is the faithful default even though it costs several times the context (8 results ≈ 5K characters ≈ 2K tokens). Set snippetSource: snippet to spend less.
Why need_content is never sent
need_content: true makes the API return each result's full page body in that result's content field — a payload the seam's closed WebSource type has nowhere to put, so it would be bytes paid for and thrown away. (The API's own curl example includes need_content: true, which is easy to misread as required; it is not.) Full page text comes from web_fetch, which is what it is for.
What it does
web_search → POST /v1/tools/search
- Sends
query and count (from maxResults when given, otherwise the count field), plus ori_question / localization when non-empty.
- Rejects a blank query and a query longer than the documented 128 characters locally, spending no request. Counting is by Unicode code point, so an emoji is never split.
- Maps each result onto the seam's
WebSource and drops what the type cannot carry (rerank_score, authority, site_name, site_icon, content) rather than smuggling it into snippet.
- Validates
date into publishedAt (YYYY-MM-DD); a null date produces no publishedAt at all.
- Never sets the top-level
content: WebSearchResult.content is a prose answer for the model, and stitching N summaries together to fake one would be fabrication.
- Reports
truncated: false — one request, no pagination, no invented truncation.
Search syntax passes through verbatim (the API applies it; this plugin never rewrites the query):
| operator | example | meaning |
|---|
site: | DeepSeek site:github.com | restrict to a site; several OR-joined: site:github.com OR site:huggingface.co |
-site: | DeepSeek -site:example.com | exclude a site |
- | DeepSeek -广告 | exclude pages containing a term |
-"…" | DeepSeek -"release notes" | exclude a phrase |
"…" | DeepSeek "exact phrase" | exact phrase |
lang: | DeepSeek lang:zh,en | restrict language |
after: | after:2025 / after:2025-01-01 | published after |
before: | before:2026 / before:2026-06-01 | published before |
web_fetch → POST /v1/tools/fetch
- Validates the URL locally first — non-empty, ≤ 8192 characters, parseable,
http/https, has a host, carries no credentials — and passes the string through unchanged when it does.
- On 200:
{url, statusCode: 200, body: {kind: 'text', content}}, with the API's title prepended as an H1 unless the Markdown already carries it. The comparison strips Markdown escapes, because the API escapes # and -.
- On 403 / 404 — the API's verdicts for "refused by safety policy" and "could not obtain usable page content" — returns them as results whose body opens with
[FETCH FAILED — NOT PAGE CONTENT] and states the URL, the status and the API's own message. A fetch verdict is a result, not an exception: a model that asked to read a page should be told the page was refused, not handed a stack trace.
- Every other non-2xx becomes a
WebError carrying the API's code.
Fallback to the native provider
A search that fails or returns nothing can fall back to another registered provider (deepseek-official by default):
fallbackOn | runs when |
|---|
never | never (pure tools-API behaviour) |
error | the tools API call failed |
empty | the tools API returned zero sources |
both (default) | either |
The target is resolved per call from the live registry — plugin load order is not guaranteed and a config reload can replace the other provider — never through ctx.web.search(), which would resolve back to this provider and recurse. Cancellation is never a reason to spend money: an aborted call throws instead of falling back, and a fallback that also fails reports the primary failure, because that is what made the call unusual.
Concurrency, retries and cancellation
- Search and fetch each hold their own throttle (the account allows 5 QPS per endpoint), as a fixed interval (
ceil(1000 / qps) ms), not a token bucket — a bucket permits a burst of qps requests inside one second, which is exactly the sliding window the server answers with 429.
408 / 429 / 5xx are retried, honouring Retry-After (capped at 30 s) and using exponential backoff otherwise. Transport failures are retried too: no response arrived, so nothing about the request was rejected.
400 / 401 / 402 / 403 / 404 / 413 are never retried, matching DeepSeek's guidance: "for other 4xx errors, do not retry unless you modify the request."
timeoutMs applies per attempt. A caller abort cancels the in-flight request through a cancel frame and surfaces WEB_ABORTED.
- Response bodies are read incrementally and abandoned as soon as they exceed 32 MiB, so a broken or hostile endpoint cannot exhaust memory.
Architecture
lib/index.js cordis entry: config schema, registration, wiring self-check
lib/engine.js spawn + NDJSON framing + request/response correlation + restart
lib/search.js search provider: engine call, native-provider fallback
lib/fetch.js fetch provider: engine call
lib/credentials.js credential resolution (re-read on every call)
lib/diag.js one JSON line per HTTP attempt
lib/web-error.js a WebError-shaped error for the seam
crates/engine/ the Rust engine: HTTP, throttle, retry, deadline, mapping
test/unit.mjs 32 checks — JS bridge, fake child process, no cost
test/engine.mjs 29 checks — real binary, mock HTTP server, no cost
test/smoke.mjs 33 checks — the real API, ~¥0.02
Why split it? The seam is a cordis (TypeScript/JavaScript) extension point, so the plugin must be JS. Everything that has nothing to do with cordis — the HTTP client, the rate limiter, the retry policy, the deadline, the cancellation, the request payloads and the response mapping — lives in Rust instead: one binary, no runtime dependencies to keep current, and the mapping sits next to the response it reads.
The engine protocol
Newline-delimited JSON over stdio. The bridge writes:
{"id":1,"method":"configure","params":{"baseURL":"https://api.deepseek.com","count":10}}
{"id":2,"method":"search","params":{"query":"…","maxResults":8,"apiKey":"sk-…"}}
{"id":3,"method":"fetch","params":{"url":"https://…","apiKey":"sk-…"}}
{"id":4,"method":"cancel","params":{"id":2}}
{"id":5,"method":"ping","params":{}}
{"id":6,"method":"shutdown","params":{}}
The engine answers {"id":N,"ok":true,"result":…} or {"id":N,"ok":false,"error":{"code":"WEB_…","message":"…"}}, plus unsolicited {"event":"ready"|"log"|"call",…} frames. The bridge resolves a call only after ready, and a call event becomes a diagnostics line — which is why the engine never writes logs by hand.
If the engine dies, every in-flight call is rejected and the next call starts a fresh one. A missing binary is reported as WEB_PROVIDER_ERROR listing the paths that were tried, not as a crash.
Pricing and limits
Official documentation: Search guide · Search API · Fetch API
| |
|---|
| Price | ¥5 per 1,000 successful requests per endpoint (≈ ¥0.005 each). No token usage. |
| Billed when | HTTP 200 and a result was produced (non-empty results for search, page content for fetch). |
| Not billed | failures, empty results, timeouts, 4xx/5xx. |
| Rate limit | 5 QPS per account, independently for search and fetch, regardless of which API key is used, and independent of model concurrency. Fetch additionally has a per-target-domain limit. Exceeding either returns 429. |
| Settlement | Usage appears on the console usage page as separate tool-search / tool-fetch entries, after a 5–15 minute delay. |
Errors use the OpenAI-compatible shape with param always null:
| status | meaning (search / fetch) | retried here |
|---|
400 | malformed headers or body | no |
401 | missing or invalid key | no |
402 | insufficient balance | no |
403 | fetch refused by safety policy | no |
404 | fetch could not obtain usable content | no |
408 | read timeout | yes |
413 | request body too large | no |
429 | QPS exceeded | yes, Retry-After first |
500 | server error | yes |
504 | downstream timeout | yes |
Testing
npm run test:unit # 32 checks — no engine, no network, no cost
npm run test:engine # 29 checks — real engine, local mock server, no cost
npm run test:smoke # 33 checks — the real API, ~¥0.02
test/unit.mjs drives the JS bridge with a fake child process: config sanitizing, engine-path resolution, NDJSON framing (split frames, unparsable lines, correlation), cancellation, crash-and-restart, and the fallback matrix — including that a cancelled call never spends a fallback.
test/engine.mjs spawns the built binary against a mock HTTP server and asserts the documented contract: request payloads (no need_content, count from maxResults), the 2xx/403/404 mapping, retry and Retry-After behaviour, the fixed-interval throttle, cancellation, maxResults clamping, and a clean shutdown.
test/smoke.mjs runs the plugin end-to-end against api.deepseek.com through a stub cordis context, cross-checking every mapped value against a raw HTTP call made in the same script, so a mapping bug cannot hide behind a passing assertion. The fallback path is proved for free by pointing the primary at a closed port.
test/smoke.mjs needs DEEPSEEK_API_KEY (or $DSH_HOME/.credentials.yaml) and never prints the key.
Diagnostics
One line per HTTP attempt is appended to %TEMP%/dsh-web-tools-deepseek.log (or $TMPDIR), and echoed to the cordis logger:
2026-10-17T02:20:11.402Z [info] [web-tools-deepseek] registered search="deepseek-tools-api" fetch="deepseek-tools-api" baseURL=https://api.deepseek.com key=credential:DEEPSEEK_API_KEY count=10 snippetSource=summary qps=5 timeoutMs=30000 retries=1 blockedAsResult=true includeTitle=true fallback=deepseek-official:both engine=… diagFile=…
2026-10-17T02:20:11.405Z [info] [web-tools-deepseek] seam row is wired to this plugin (search="deepseek-tools-api", fetch="deepseek-tools-api")
2026-10-17T02:20:11.899Z [call] {"label":"deepseek search","endpoint":"https://api.deepseek.com/v1/tools/search","status":200,"ok":true,"ms":412,"attempts":1}
| variable | effect |
|---|
DSH_WEB_TOOLS_DEEPSEEK_DIAG_FILE | write the log elsewhere |
DSH_WEB_TOOLS_DEEPSEEK_DIAG=0 | turn the log off |
DSH_WEB_TOOLS_DEEPSEEK_BIN | use a specific engine binary |
The second startup line is the one to read first. "Registered" and "selected" are different facts: without the - id: web override this plugin sits inert while deepseek-official and http keep serving every call, which is invisible from the outside. If the log says "registered but NOT selected", that override is missing. (The check reads WebRuntime's TypeScript-private fields, which are ordinary runtime properties; it is diagnostics-only and degrades to (unset).)
Troubleshooting
| symptom | cause |
|---|
WEB_PROVIDER_AMBIGUOUS | more than one provider is registered and the web row names none. Add the - id: web override. |
WEB_PROVIDER_CONFIGURED_UNAVAILABLE | the web row names this provider but available() is false: no resolvable credential, or the engine binary is missing. |
[web-tools-deepseek] engine is not usable: … | the engine could not start; the message lists the paths that were tried. |
WEB_MISSING_CREDENTIAL | apiKeyEnv does not resolve to a value in $DSH_HOME/.credentials.yaml. |
Edits to lib/*.js have no effect | the plugin module is ESM-cached; restart DSH. Config reloads do re-run apply() — proven by adding a real override (qps: 4) and watching the startup line change — but the module itself is not re-imported. A comment-only edit does not even trigger that much, because the parsed config is unchanged. |
Usage page shows no tool-search / tool-fetch | settlement takes 5–15 minutes. |
Rolling back
-
Point the web row back at the defaults and drop the plugin row, then restart:
- id: web
name: '@deepseek-ai/dsh-web'
config:
searchProvider: deepseek-official
fetchProvider: http
Both keys must be written together: a patch replaces the whole config block, so changing one would drop the other. And do not leave the web row pointing at deepseek-tools-api after removing the plugin — that raises WEB_PROVIDER_CONFIGURED_UNAVAILABLE.
-
Or remove the bundle:
dsh plugin --profile desktop remove dsh-web-tools-deepseek
-
Or disable it in the plugin manager.
Known limitations
- Fetch 403 is common, including for DeepSeek's own documentation site (measured:
api-docs.deepseek.com → 403 URL access denied); zh.wikipedia.org and news.ycombinator.com measured 404 failed to fetch URL. DeepSeek states that fetch results depend on the target site's availability and access policy — this is the service's verdict, not a plugin bug. Switch fetchProvider back to http to fetch those sites.
- No pagination — one request returns at most 50 results and
truncated is always false.
- No streaming or progress events — a call resolves when the API answers.
need_content is deliberately unused, so page text always needs a second fetch call (and its own ¥0.005).
- The fallback target is read from a private seam field (
ctx.web.searchProviders). There is no public way to look up another provider, and ctx.web.search() would recurse. If the seam renames that field the fallback degrades to "no fallback", with a warning, rather than breaking search.
- The committed binary is Windows x64. Other platforms build their own or take a release asset; the workflow covers Windows, Linux and both macOS architectures. Release assets do not carry the executable bit, so
chmod +x after downloading.
- HTTP uses
reqwest with default-tls (schannel on Windows), not rustls: a rustls crypto provider needs a C toolchain and NASM to build, which would make cargo build fail on an ordinary machine.
dsh --profile desktop --dump-config is unavailable (profile "desktop" is managed exclusively by the Electron application), so acceptance evidence comes from live behaviour plus the wiring self-check log.
License
MIT © CarbonAnime
Not affiliated with DeepSeek. The Search and Fetch APIs, their pricing and their limits are DeepSeek's and may change; check the official documentation.