dsh-web-search-custom
Discovered让 DSH Web UI 使用任意 JSON 搜索 API 的通用搜索插件,默认对接自建 SearXNG,支持 API Key、GET/POST 与字段映射。
dsh-web-search-custom
Point the DeepSeek Harness (dsh)
web profile at any JSON search API — give it a URL and (optionally) an API
key, and it replaces the built-in DeepSeek search. It ships pointed at the
AnySearch unified search API, and
the keyless path needs no account at all:
POST https://api.anysearch.com/v1/search # apiKey empty -> anonymous tier, no Authorization header
POST https://api.anysearch.com/v1/search # apiKey set -> Authorization: Bearer <key>, that key's quota
[!IMPORTANT] Design intent. The plugin is a thin adapter, nothing more: it owns one search provider behind the
ctx.webcapability seam and one settings card. It ships no search engine, no index and no cache; the only credential it ever sends is the one you type into the card (or export as an environment variable), and an empty key means no auth header at all. Uninstall it and the stock provider is back with zero residue.
Table of Contents
- Why
- Features
- How it works
- Requirements
- Install
- Configure in the settings page
- Configuration reference
- Vertical and parameterized queries
- Other configuration channels
- Uninstall
- Limits
- Development
- License
Why
dsh's web profile resolves search through the ctx.web seam. Out of the box
that seam is served by a hosted search backend, which is the wrong trade for
anyone who runs their own search stack (SearXNG, an internal search gateway, a
vendor API), wants queries to stay inside their own network, or wants to bring
their own search key.
This plugin registers one additional provider into that seam and lets you select
it. Everything else — the web_search tool, result capping, cancellation, error
propagation — keeps working unchanged, because it is the same seam the stock
provider uses.
Features
- Standard dsh bundle plugin. Zero intrusion: no dsh source is modified; removing the plugin restores the stock search provider.
- Zero runtime dependencies. Only dsh-shipped packages are used
(
@deepseek-ai/dsh-settings,@deepseek-ai/schemastery, plus React on the browser side). Nothing is vendored or bundled. - AnySearch by default, keyless out of the box.
api: autodetects the AnySearch host and speaks its native POST envelope. LeaveapiKeyempty and the request carries noAuthorizationheader (anonymous, per-IP rate-limited free tier); set a key and it is sent asAuthorization: Bearer <key>against that key's quota. An invalid key becomes a clear error instead of a silent fallback to anonymous. - SearXNG and friends still work. Switch
apitogenericfor templated GET/POST endpoints; the shipped mapping matches SearXNG'sresults[]/url/title/content/publishedDateshape. - In-settings configuration card (Settings → Plugins): profile, URL, API key, result cap, timeout and the whole field mapping are editable in the page, with the same overridden/reset affordances as first-party plugin cards.
How it works
web_search tool
│
▼
ctx.web (capability seam) ──selection──▶ provider id "custom"
│ │
│ ▼
│ fetch(your endpoint) ──▶ your JSON API
│ │
└◀──── WebSearchResult { sources[], truncated } ◀──┘
The host side registers the namespace web-search-custom (so the settings page
can render a card for it) and registers a search provider with the stable id
custom. The provider's search(query, signal) reads the live settings value on
every call, picks a profile, performs the HTTP request with the caller's
cancellation signal plus its own timeout, and maps the JSON payload into
sources[] (deduplicated by URL, non-http(s) entries dropped, HTML stripped,
entities decoded).
api | Request | Response mapping | Fields that apply |
|---|---|---|---|
anysearch (auto-detected) | POST {"query","max_results"}; Authorization: Bearer only when a key is set | data.results[].{title,url,snippet}, falling back to content | url, apiKey, headers, timeoutMs, maxResults |
generic | templated GET/POST ({query} URL-encoded, {queryRaw} verbatim, {apiKey}) | resultsPath + urlField / titleField / snippetField / publishedField | all of the above plus method, body, authHeader, authScheme, resultsPath, the four field names |
Both a non-2xx status and a non-zero business code fail the search. The vendor's
message and request_id are preserved in the error text; credentials that the
API returns inside a 402 body are redacted before the message can reach a log or
the model.
Provider selection follows the seam's own rules, resolved at call time:
a configured id wins; with no id configured, exactly one usable provider is
required. The plugin's own cordis.patch.yml therefore also sets
web.searchProvider: custom so the choice is explicit rather than accidental.
Requirements
| Item | Value |
|---|---|
| DeepSeek Harness | >=0.1.2-alpha.3 <0.1.8 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.7-alpha.0 <0.1.8 || >=0.2.0-alpha.0 <0.3.0 || >=0.2.1-alpha.0 <0.3.0 (verified on 0.1.2-alpha.4, 0.1.5-rc.1, 0.1.5-rc.2, 0.1.7-rc.1, 0.2.0-rc.1 and 0.2.1-rc.2) |
| Node.js | `^22.19.0 |
| Runtime dependencies | none — the peers come from the dsh host install |
| Network | outbound access from the dsh host to the endpoint you configure (AnySearch resolves to its mainland-China gateway) |
| AnySearch API key | optional — the anonymous tier works without one |
The dsh range is declared under dsh.engines.dsh (and mirrored in
peerDependencies for the dsh packages the plugin relies on). The disjunction is
load-bearing rather than cosmetic: npm semver only satisfies a prerelease from a
range group that itself contains a prerelease with the same
[major, minor, patch] tuple, so a plain <0.2.0 group does not cover
0.1.5-rc.1 or 0.1.7-rc.1. DSH itself checks the same peers at install and
startup preflight with includePrerelease: true; that gate is more permissive,
which is precisely why the pnpm-strict side needs its own counter-proof.
tests/entry.test.mjs pins this with a decision table, a counter-proof against
the old single range, and tests/host-integration.test.mjs runs the host's real
evaluatePluginCompatibility and real semver against it.
[!IMPORTANT] dsh 0.1.7 moved the settings model. The plugin was adapted for
0.1.7-rc.1:settings.installSection()is gone (an exportedConfigschema is the namespace now, and its fields must be.volatile()to be editable live), the browser-sidesettingsScopeservice was replaced byconfigForms, and the card moved from the removedsettings.plugin.itemslot toplugins.itemon the Plugins page. The card renders through the shared official form primitives, so it looks and behaves like the built-in provider cards. Details: docs/DSH-0.1.7-ADAPTATION.md.
Install
dsh plugin --profile web add dsh-web-search-custom
Restart the dsh web instance afterwards. From a local checkout, pass the directory path instead of the package name.
Configure in the settings page
Open Settings → Plugins in the web UI and click into the "Custom search (web-search-custom)" row — its page shows the form:
- the API profile (
auto/anysearch/generic), - the search URL (AnySearch by default),
- the API key — leave it empty for the keyless tier,
- the result cap (
maxResults, 1–10 on AnySearch), - request method, POST body template, extra headers (JSON), timeout,
- the result field mapping (
resultsPath/urlField/titleField/snippetField/publishedField; generic profile only).
Saving writes the web-search-custom: entry of your profile patch document
($DSH_HOME/profiles/web/cordis.patch.yml) and takes effect immediately — the
next search uses the new value, no restart. The form, its overridden/reset badges
and its read-only notice are the same shared components the first-party provider
cards use.
If an older release pinned a different URL there, clear that field (Reset → Save) so the shipped AnySearch default applies again.
Configuration reference
| Field | Default | Description |
|---|---|---|
api | auto | Profile: auto (decided by the URL host), anysearch (AnySearch POST API), generic (any templated JSON endpoint) |
url | https://api.anysearch.com/v1/search | Search endpoint. The AnySearch profile POSTs here; the generic profile supports {query} (URL-encoded), {queryRaw} (verbatim) and {apiKey} placeholders and appends q= for GET without a {query} |
apiKey | empty | Optional. Empty in the AnySearch profile sends no Authorization header (anonymous free tier); set it to send Authorization: Bearer <key>. In the generic profile the header name/scheme fields apply, and a {apiKey} in the URL or body suppresses the extra header |
maxResults | 10 | AnySearch profile only: sent as max_results, computed as min(this, caller limit) and clamped to 1–10 |
method | GET | Generic profile only: GET or POST; anything else degrades to GET |
body | {"query":"{query}"} | Generic profile only: POST body template, supports {query} / {queryRaw} / {apiKey} |
headers | {} | Extra request headers as a JSON string |
authHeader | Authorization | Generic profile only: header name used for the API key |
authScheme | Bearer | Generic profile only: auth scheme; leave empty to send the bare key |
timeoutMs | 30000 | Per-request timeout |
resultsPath | results | Generic profile only: dotted path to the result array, e.g. data.results |
urlField / titleField / snippetField / publishedField | url / title / content / publishedDate | Generic profile only: result field-name mapping (SearXNG's defaults) |
In the generic profile each field also falls back to a wider list of common
aliases at mapping time (for example a missing titleField value still tries
title then name). The AnySearch profile always reads
data.results[].title / .url / .snippet and falls back to .content.
Vertical and parameterized queries
The AnySearch profile sends a plain general-web query. For vertical search
(tag / params / zone / language — see the
API docs) switch to the
generic profile and write the body yourself; the response envelope stays the same,
so only the field mapping changes:
- id: web-search-custom
config:
api: generic
url: 'https://api.anysearch.com/v1/search'
method: POST
body: '{"query":"{queryRaw}","tag":"code.doc","params":{"library":"golang"},"max_results":5}'
resultsPath: data.results
urlField: url
titleField: title
snippetField: snippet
publishedField: ''
Other configuration channels
Profile patch layer (settings document alternative)
- id: web-search-custom
config:
api: auto
url: 'https://api.anysearch.com/v1/search'
apiKey: ''
maxResults: 10
method: GET
body: '{"query":"{query}"}'
headers: '{}'
authHeader: Authorization
authScheme: Bearer
timeoutMs: 30000
resultsPath: results
urlField: url
titleField: title
snippetField: content
publishedField: publishedDate
Note: a patch replaces the whole
configblock — write every key when overriding.
Environment variables
Environment overrides win over the settings page and require no file edit:
| Variable | Description |
|---|---|
WEB_SEARCH_CUSTOM_API | Override the profile (auto / anysearch / generic) |
WEB_SEARCH_CUSTOM_URL | Override the search URL |
WEB_SEARCH_CUSTOM_API_KEY | Override the API key (recommended for secrets); empty keeps the keyless tier |
WEB_SEARCH_CUSTOM_MAX_RESULTS | Override the AnySearch result cap |
WEB_SEARCH_CUSTOM_HEADERS | Extra headers, JSON string |
WEB_SEARCH_CUSTOM_TIMEOUT_MS | Timeout in milliseconds |
Uninstall
dsh plugin --profile web remove dsh-web-search-custom
Restart dsh and the stock search provider takes over again.
Limits
- The API key is stored as an ordinary settings field, not in a credential
store. For sensitive deployments prefer
WEB_SEARCH_CUSTOM_API_KEY. - AnySearch caps a single request at 10 results (
max_results1–10); the tool layer truncates further to the caller'smaxResults. - AnySearch's anonymous tier is rate-limited per client IP and metered against a daily free quota. When it runs out the API answers 402 and may return auto-generated credentials; the plugin surfaces that as an error and redacts those credentials instead of storing them. Add a key to keep searching.
- Result mapping is field-based, not query-language aware: the generic profile
needs the field mapping (and, if necessary,
resultsPath) adjusted for exotic payload shapes.
Development
pnpm install # dev dependencies (@deepseek-ai/dsh + dsh-settings 0.1.7-rc.1,
# schemastery 3.18.4 — the host-contract test loads the real packages)
pnpm test # node --test tests/*.test.mjs && node tests/client-smoke.mjs
pnpm run test:host # host-contract subset, on its own
pnpm is required for the dev install: the dsh devDependency pulls native
subprocess helpers whose build scripts must be explicitly declined (see
allowBuilds in pnpm-workspace.yaml) — nothing here needs a native build.
| Test | What it proves |
|---|---|
tests/entry.test.mjs | Real entry load (import('../src/index.js')), manifest declarations, the 0.1.7 settings-namespace source (Config exported, every field .volatile(), {get()} live references, configure({ auto: false })), the engines decision table (hand-rolled comparator + counter-proof + cross-check against the host's real semver.satisfies), four-way key parity (host schema ↔ client fields ↔ client views ↔ patch config), locale key parity, static guards against the removed client APIs, dependency hygiene |
tests/anysearch.test.mjs | AnySearch profile: auto-detection, keyless vs Bearer headers, max_results arithmetic, vendor envelope mapping, the error envelope (message + request_id + 402 credential redaction), and generic-profile non-regression |
tests/host-integration.test.mjs | Driven by the real host packages: real Cordis + WebRuntime + the real @deepseek-ai/cosmokit volatile protocol (a _commitVolatile-style update is visible to the next search), the real dsh-settings schema helpers (volatileForm / isVolatilePath / projectForm / plainConfig), real source-level assertions on the client bundles this card depends on, and the host's real evaluatePluginCompatibility + semver against the declared ranges |
tests/client-smoke.mjs | Browser half: bundle id, short service names, locale parity, configForms.whileServed gating, the plugins.item registration contract (id/order/label thunk), both views (summary one-liner vs page form), save/reset write path, invalid-input blocking, read-only disabling and the read-only write refusal |
tests/live-search.mjs | Opt-in live check against a real endpoint: DSH_WSC_LIVE_URL='https://api.anysearch.com/v1/search' node tests/live-search.mjs (add DSH_WSC_LIVE_BAD_KEY=1 to also assert the invalid-key error path) |
Live checks (opt-in, they hit the network)
# end-to-end through the real seam against a live endpoint
DSH_WSC_LIVE_URL='https://api.anysearch.com/v1/search' node tests/live-search.mjs 'your query'
# the same, plus the invalid-key error path
DSH_WSC_LIVE_URL='https://api.anysearch.com/v1/search' DSH_WSC_LIVE_BAD_KEY=1 node tests/live-search.mjs 'your query'
# two-arm control: prove the user-layer url is what decides, and that the new default returns results
DSH_WSC_LIVE_PARITY=1 node tests/live-settings-parity.mjs
The default pnpm test suite stays offline-runnable; these scripts print SKIP unless the
environment variable above is set.
src/index.js host half — settings namespace, profile dispatch, result mapping
lib/client.js browser half — settings card (hand-written bundle, no build step;
renders through the shared official form primitives)
cordis.patch.yml bundle patch: select provider "custom" and mount the plugin
tests/ entry + anysearch + host-contract + client-smoke + opt-in live check
dsh 0.2.1-rc.2 适配结论
对桌面端 D:\DeepSeek Harness\(FileVersion 0.2.1-rc.2)做了 asar 解包源码比对 + 宿主真闸实跑:
- 契约点零漂移:
dsh-web的registerSearchProvider与选择语义、dsh-settings的configure()/volatileForm、client 的configForms.whileServed与slots.inject在 0.2.1 全部保持原形状(官方dsh-client-ui-settings-web-search仍require("@deepseek-ai/dsh-client-ui-primitives"))。src/与lib/client.js未改一行。 - 本轮补的是 pnpm 安装期,不是加载闸:旧四段区间在真闸下
evaluatePluginCompatibility(pkg, {}, '0.2.1-rc.2') → undefined(PASS),即本来就能加载; 被拒的是严格模式。真负例>=0.1.7-alpha.0 <0.1.8被判 REJECT,证明闸确实在判。 WebRuntime.Config的两个字段是.volatile():searchProvider.get()—— 直接传裸值会TypeError。测试夹具已改为经cosmo.createVolatile()包成活引用(这是夹具漂移,不是插件缺陷)。evaluatePluginCompatibility返回体改名:peers→requirements(0.2.0 时是peers); 测试对两种形状都取。- 闸语义在 0.2.1 变了:顶层
engines.dsh存在时它独自判定、peers 被忽略;本仓区间写在嵌套的dsh.engines.dsh,故实际仍由 peers 判定(真闸实测确认)。 - 版本号保持本仓自己的 0.3.0(不跟宿主发布号,见
v0.2.0-rc.1那轮的说明)。 - 验证:
node --test tests/*.test.mjs55 pass / 0 fail / 0 skipped + client-smoke 24 项; 原先那条「primitives 不可解析 → SKIP」的静默跳过已消除(把dsh-client-ui-primitives@0.2.1-rc.2显式装成 devDep)。
dsh 0.2.0-rc.1 适配结论
对桌面端 D:\DeepSeek Harness\(FileVersion 0.2.0-rc.1)做了 asar 解包源码比对 +
真机闸实测。要点:
- 唯一必改项是兼容区间:原区间在 0.2.0-rc.1 下被启动闸拒绝
(
dsh: skipping profile bundle ...),插件整个 bundle 不加载(web 与 desktop 同时失效)。 追加|| >=0.2.0-alpha.0 <0.3.0后放行。 - 闸只读
peerDependencies:判定函数(dsh-app-boot的evaluatePluginCompatibility)只遍历peerDependencies里@deepseek-ai/dsh*的条目, 从不读dsh.engines.dsh(全树 grep 零消费者)。两者必须逐字一致,测试已守护。 - 两种 semver 模式:宿主闸用
includePrerelease: true,此时「预发布可见性」规则被绕过, 于是上界自身的预发布也被放行(<0.1.8放行0.1.8-rc.1、<0.3.0放行0.3.0-alpha.0); 严格模式(pnpm 安装期)会拒绝它们。所以上界拦的是正式版,不是预发布。 若要连预发布一起拒,上界须写成<0.3.0-0。 - 凭证细节:详细取证、改动清单、测试结果与诚实缺口见
docs/DSH-0.2.0-ADAPTATION.md。
License
Comments
Loading…
Similar plugins
by kkgace
Self-hosted SearXNG web search provider for DeepSeek Harness (dsh). No API key or vendor account.
★ 0
MIT
JavaScript
Sep 17, 2026
dsh plugin --profile web add dsh-web-searxng-searchby rogerdigital
DeepSeek Harness (dsh) plugin that adds a SearXNG-backed web_search provider to the ctx.web seam — free, self-hosted, key-less search instead of paid Exa/Perplexity APIs.
★ 10
↓ 269/wk
MIT
TypeScript
Sep 21, 2026
dsh plugin --profile web add dsh-searxngby n0pe-sled
dsh plugin: SEARXNG-backed web search provider for the dsh web_search tool (host-only, no Settings UI)
★ 0
TypeScript
Sep 29, 2026
dsh plugin --profile web add dsh-web-search-searxngby chancoki
为 DeepSeek Harness (DSH) Web GUI 提供模型搜索功能的插件——在模型选择弹窗中添加关键字搜索框,快速筛选模型。
★ 0
JavaScript
Sep 7, 2026
dsh plugin --profile web add dsh-model-search-pluginby iasiv5
DSH 插件:(Web Profile 专用)自动跳过 BrowserAuth,访问 Web 地址即可直接使用,无需每次复制启动 URL 中的随机 Token 到浏览器。
★ 3
↓ 636/wk
MIT
JavaScript
Oct 26, 2026
dsh plugin --profile web add @iasiv5/dsh-skip-browser-authby MYCF711
免 API Key 的 dsh 联网搜索 / 网页抓取插件:引擎=逻辑源、入口=直连/镜像/加速路由自动回退,不消耗任何模型 token
★ 6
GPL-3.0
JavaScript
Oct 27, 2026
dsh plugin --profile web add dsh-websearch-direct