@240xu/dsh-websearch 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Xu198440 (ova442757@163.com)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # @240xu/dsh-websearch
2
+
3
+ > Aggregated web search provider for [DeepSeek Harness](https://github.com/deepseek-ai/dsh) — one plugin id `unified` that fans out to eleven backends concurrently, merges + URL-dedups results, and stays usable even when some backends go down.
4
+
5
+ A pure-Cordis drop-in: registers ONE provider at `ctx.web` so the `dsh-web` selection rule never fires `WEB_PROVIDER_AMBIGUOUS`. Zero-config search out of the box (Exa + Parallel + DuckDuckGo + SearXNG are keyless); API-key backends (DeepSeek / Anthropic / OpenAI / Brave / Tavily / Serper / Mojeek) auto-activate once their key is supplied via the credentials service or env.
6
+
7
+ ---
8
+
9
+ DSH([DeepSeek Harness](https://github.com/deepseek-ai/dsh))原生插件:向 `ctx.web` 注册**唯一**的聚合搜索 provider `unified`,内置十一后端并发 fan-out,合并 + URL 去重后返回——即使部分后端宕机仍可用。
10
+
11
+ 纯 Cordis 直插:只注册一个 provider,所以 `dsh-web` 的选择规则永远不会触发 `WEB_PROVIDER_AMBIGUOUS`。**零配置即可搜索**——Exa + Parallel + DuckDuckGo + SearXNG 四者皆无 key、开箱即用;DeepSeek / Anthropic / OpenAI / Brave / Tavily / Serper / Mojeek 在提供 API key 后自动激活。
12
+
13
+ ## Backends | 后端
14
+
15
+ | id | key | endpoint | how it returns | notes |
16
+ |---|---|---|---|---|
17
+ | `exa` | none | `https://mcp.exa.ai/mcp` | streamable-http MCP, tool `web_search_exa { query, numResults }`, text blocks "Title:/URL:/Published:/Highlights:" | opencode 同源,无 key |
18
+ | `parallel` | none | `https://search.parallel.ai/mcp` | streamable-http MCP, tool `web_search { objective, search_queries[], session_id?, model_name? }` returns JSON-string `{ search_id, results: [{ url, title, publish_date, excerpts[] }] }` | opencode 同源,无 key |
19
+ | `ddg` | none | `https://html.duckduckgo.com/html/` | HTML scrape, decode `uddg=` redirect param for real URL + `result__snippet` | 兜底,零依赖 |
20
+ | `searxng` | none/optional | `https://searx.be/search` (default) | REST JSON `/search?q=`, public instances unlimited | 元搜索,隐私友好 |
21
+ | `brave` | `BRAVE_API_KEY` | `https://api.search.brave.com/res/v1/web/search` | REST JSON, independent index, 2000/mo free | 高质量,无 Google 偏见 |
22
+ | `tavily` | `TAVILY_API_KEY` | `https://api.tavily.com/search` | REST JSON, AI-focused, `answer` + `results[]`, deep search | AI 专用,含答案摘要 |
23
+ | `serper` | `SERPER_API_KEY` | `https://google.serper.dev/search` | REST JSON, scrapes Google `organic[]`, 2500/mo free | 极速,结构化 |
24
+ | `mojeek` | `MOJEEK_API_KEY` | `https://api.mojeek.com/v1/search` | REST JSON, independent index, 1000/day free | 无追踪,英文为主 |
25
+ | `deepseek` | `DEEPSEEK_API_KEY` | `https://api.deepseek.com/anthropic/v1/messages` | Anthropic Messages API + native `web_search_20250305` server tool, model `deepseek-v4-flash` | 与 dav web-search-deepseek 同机制 |
26
+ | `anthropic` | `ANTHROPIC_API_KEY` | `https://api.anthropic.com/v1/messages` | Anthropic Messages + `web_search_20250305`, model `claude-sonnet-4-6` | Claude Code 同机制 |
27
+ | `openai` | `OPENAI_API_KEY` | `https://api.openai.com/v1/responses` | Responses API native `web_search` tool, parse `url_citation` annotations | Codex 同机制 |
28
+
29
+ **Default enabled set**: `["exa", "parallel", "ddg", "searxng"]` — all keyless. The key-gated backends only join when their `available()` returns true (key present + baseURL reachable).
30
+
31
+ ## Install | 安装
32
+
33
+ ### 方式一:作为 profile 依赖安装(推荐,规范做法)
34
+
35
+ ```bash
36
+ cd ~/.dsh/profiles/web
37
+ # 在 package.json 的 dependencies 里加入:
38
+ # "@240xu/dsh-websearch": "file:/path/to/@240xu/dsh-websearch"
39
+ pnpm install
40
+ ```
41
+
42
+ 插件自带 `dsh.bundle` 元数据(package.json 声明 `"dsh": {"bundle": {"patch": "./cordis.patch.yml"}}`),把它加入 profile package.json 的 `dsh.profile.bundles` 列表后即自动注册,**无需**在 cordis.patch.yml 手写条目。
43
+
44
+ 只需在 `~/.dsh/profiles/web/cordis.patch.yml` 里把内置 web_search 工具指到 unified provider:
45
+
46
+ ```yaml
47
+ - id: web
48
+ name: '@deepseek-ai/dsh-web'
49
+ config:
50
+ searchProvider: unified
51
+ - id: web-search-deepseek
52
+ name: '@deepseek-ai/dsh-web-search-deepseek'
53
+ disabled: true
54
+ ```
55
+
56
+ 重启 `dsh web` 即生效。
57
+
58
+ ## Settings | 配置
59
+
60
+ 插件向 DSH 设置面板注册 `unified-search` namespace(Settings → Unified Search),全部字段扁平化、开箱可编辑:
61
+
62
+ **全局**
63
+
64
+ | 字段 | 默认 | 说明 |
65
+ |---|---|---|
66
+ | `numResults` | 8 | 每次搜索返回结果数(1-50) |
67
+ | `concurrency` | 6 | 并发后端数(1-10) |
68
+ | `backendTimeoutMs` | 30000 | 单后端超时毫秒 |
69
+
70
+ **11 个后端开关**:`enableExa` / `enableParallel` / `enableDdg` / `enableSearxng`(默认开);`enableBrave` / `enableTavily` / `enableSerper` / `enableMojeek` / `enableDeepseek` / `enableAnthropic` / `enableOpenai`(默认关)
71
+
72
+ **API Key 授权**(credential-ref,填环境变量名,实际 key 存于 credentials 服务或环境变量):
73
+
74
+ | 字段 | 默认引用 |
75
+ |---|---|
76
+ | `braveApiKeyEnv` | `BRAVE_API_KEY` |
77
+ | `tavilyApiKeyEnv` | `TAVILY_API_KEY` |
78
+ | `serperApiKeyEnv` | `SERPER_API_KEY` |
79
+ | `mojeekApiKeyEnv` | `MOJEEK_API_KEY` |
80
+ | `deepseekApiKeyEnv` | `DEEPSEEK_API_KEY` |
81
+ | `anthropicApiKeyEnv` | `ANTHROPIC_API_KEY` |
82
+ | `openaiApiKeyEnv` | `OPENAI_API_KEY` |
83
+ | `searxngApiKeyEnv` | `SEARXNG_API_KEY`(私有实例可选) |
84
+
85
+ **Base URL / Model**:每个 key-gated 后端都有对应 `${id}BaseURL`;DeepSeek/Anthropic/OpenAI 另有 `${id}Model`;Tavily 有 `tavilySearchDepth`(basic/advanced)。
86
+
87
+ 环境变量兜底:未在 credentials 配置时回退读同名环境变量;`DSH_UNIFIED_SEARCH_BACKENDS` 可逗号分隔强制指定启用集合。
88
+
89
+ ## Design | 设计
90
+
91
+ - **One provider, no ambiguity**: a single `registeredSearchProvider({id:"unified"})` — the `dsh-web` seam's selection rule picks it unambiguously, and `search()` caps `maxResults` itself.
92
+ - **`Promise.allSettled` fan-out**: every enabled + available backend fires concurrently; a single backend failure is demoted to a soft `null` so the rest still contribute — only when ALL fail does the provider throw `WEB_PROVIDER_ERROR`.
93
+ - **Per-backend abort demotion**: a single backend aborting becomes soft-null; the provider only rethrows `WEB_ABORTED` when the caller's own `AbortSignal` fires.
94
+ - **URL dedup + merge**: results across backends are merged and deduped by case-insensitive URL; missing title/snippet/publishedAt from one backend get filled in from another (e.g. Exa's title + Parallel's excerpt).
95
+ - **Concurrency & Timeout Control**: `concurrency` (default 6) limits simultaneous calls; `backendTimeoutMs` (default 30s) caps each backend.
96
+ - **Streamable-http MCP, custom handshake**: `lib/util/mcp-client.js` implements `initialize → notifications/initialized → tools/call` with `Mcp-Session-Id` header caching against Exa and Parallel — no dependency on the full MCP SDK.
97
+ - **hooks.recordRequest/recordOutcome bridge**: each backend routes its request/outcome to `lib/util/log.js` which records `web/unified-search-backend-request` / `…backend-outcome` events onto the active DSH session via `ctx.get("agents")?.currentInitiator()?.session.append(...)` (mirrors `dsh-web-search-deepseek`).
98
+
99
+ ## Architecture | 架构
100
+
101
+ ```
102
+ lib/
103
+ index.js # name/inject/Config/apply — registers settings + provider
104
+ provider.js # UnifiedSearchProvider — fan-out, abort demotion, dedup, truncate, concurrency, timeout
105
+ util/
106
+ abort.js # isAbortError / searchAborted / throwIfSearchAborted / maybeAbortError
107
+ mcp-client.js # streamable-http MCP client (initialize + tools/call + session cache)
108
+ log.js # recordBackendRequest / recordBackendOutcome → session event log
109
+ backends/
110
+ exa.js # Exa (web_search_exa) via mcp-client
111
+ parallel.js # Parallel (web_search) via mcp-client
112
+ ddg.js # DuckDuckGo HTML scrape (uddg redirect decode)
113
+ searxng.js # SearXNG REST JSON (keyless)
114
+ brave.js # Brave Search REST (key-gated)
115
+ tavily.js # Tavily REST (key-gated, AI answer + deep search)
116
+ serper.js # Serper.dev REST (key-gated, Google scrape)
117
+ mojeek.js # Mojeek REST (key-gated)
118
+ anthropic-like.js # shared: DeepSeek + Anthropic (Messages API + web_search_* server tool)
119
+ openai.js # OpenAI /responses + web_search tool (url_citation parsing)
120
+ index.js # unified registry + individual exports
121
+ tests/
122
+ parse.test.js # 28 unit tests for each backend's pure parse fn
123
+ provider.test.js # 5 fan-out tests (merge/dedup, abort demotion, all-fail, maxResults cap, concurrency)
124
+ ```
125
+
126
+ ## Test | 测试
127
+
128
+ ```bash
129
+ node --test tests/parse.test.js tests/provider.test.js
130
+ ```
131
+
132
+ 33/33 pass.
133
+
134
+ ## License | 许可
135
+
136
+ MIT © 2026 240xu
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: dsh-websearch
3
+ name: '@240xu/dsh-websearch'
@@ -0,0 +1,17 @@
1
+ # ── dsh-websearch v2.0: unified free web search ───────────────────────
2
+ # Registers provider id "unified" that fans out to 11 backends concurrently:
3
+ # keyless: exa, parallel, ddg, searxng
4
+ # key-gated: deepseek, anthropic, openai, brave, tavily, serper, mojeek
5
+ # (activate when their API key is set via Settings > Unified Search)
6
+ #
7
+ # web-search-deepseek stays disabled because the unified plugin supersedes it.
8
+ # web.searchProvider pins the built-in web_search tool onto the unified provider.
9
+ - id: unified-search
10
+ name: "@deepseek-ai/dsh-websearch"
11
+ - id: web-search-deepseek
12
+ name: "@deepseek-ai/dsh-web-search-deepseek"
13
+ disabled: true
14
+ - id: web
15
+ name: "@deepseek-ai/dsh-web"
16
+ config:
17
+ searchProvider: unified
@@ -0,0 +1,262 @@
1
+ // anthropic-like.js — shared backend for DeepSeek & Anthropic Messages-API
2
+ // providers using the native web_search server tool. Both speak the
3
+ // Anthropic-compatible Messages API; they differ only in baseURL, default
4
+ // model name, and api-version header. DeepSeek's endpoint is
5
+ // https://api.deepseek.com/anthropic/v1 (note the /anthropic prefix — chat
6
+ // completions use a different base, NOT reused here).
7
+ //
8
+ // Response shape (Anthropic Messages format):
9
+ // content: [
10
+ // { type: "web_search_tool_result", content: [ { type:"web_search_result", url, title, page_age }, ... ] },
11
+ // { type: "text", text, citations:[{url, cited_text}] },
12
+ // ...
13
+ // ]
14
+ // The snippet comes from a text block's citation[] (keyed by url, first match
15
+ // wins). Each search costs a model turn; absence of a web_search_tool_result
16
+ // block is an error, not a prose fallback.
17
+
18
+ import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort.js";
19
+ import { WebError } from "@deepseek-ai/dsh-web";
20
+
21
+ /** Attribution header; bumped with the package version. */
22
+ const UA = "dsh-websearch/2.0";
23
+
24
+ /**
25
+ * Factory: create an Anthropic-like Messages backend.
26
+ *
27
+ * @param {object} spec
28
+ * @param {string} spec.id backend id ("deepseek" | "anthropic")
29
+ * @param {string} spec.defaultBaseURL
30
+ * @param {string} spec.defaultModel
31
+ * @param {string} spec.defaultApiVersion e.g. "2023-06-01"
32
+ * @param {string} spec.toolName "web_search_20250305"
33
+ * @param {string} [spec.futureToolName] optional newer tool for dual fallback
34
+ * @returns {object} backend object { id, requiresCredential, available, search }
35
+ */
36
+ export function createAnthropicLikeBackend(spec) {
37
+ const {
38
+ id,
39
+ defaultBaseURL,
40
+ defaultModel,
41
+ defaultApiVersion,
42
+ toolName,
43
+ futureToolName,
44
+ } = spec;
45
+
46
+ return {
47
+ id,
48
+ requiresCredential: true,
49
+
50
+ /**
51
+ * options provider by provider.js: { apiKey, baseURL?, model?, apiVersion?, maxTokens?, maxUses?, apiVersion }
52
+ */
53
+ available(options) {
54
+ // Per the official reference impl (dsh-web-search-deepseek): a backend is
55
+ // usable when it has either a literal apiKey OR a lazy resolver function —
56
+ // availability is a cheap local check, the key is resolved per search.
57
+ const okKey = (typeof options?.apiKey === "string" && options.apiKey.length > 0)
58
+ || typeof options?.resolveApiKey === "function";
59
+ const urlOk = typeof options?.baseURL === "string" && URL.canParse(options.baseURL);
60
+ return okKey && urlOk;
61
+ },
62
+
63
+ /**
64
+ * @param {WebSearchRequest} request
65
+ * @param {AbortSignal} signal
66
+ * @param {object} options resolved options (apiKey, baseURL, model, apiVersion, maxTokens, maxUses)
67
+ * @param {{ recordRequest?: Function, recordOutcome?: Function }} [hooks]
68
+ * @returns {Promise<WebSearchResult>}
69
+ */
70
+ async search(request, signal, options, hooks = {}) {
71
+ throwIfSearchAborted(signal);
72
+ const apiKey = options?.apiKey ?? await options?.resolveApiKey?.();
73
+ if (!apiKey || apiKey.length === 0) {
74
+ hooks.recordOutcome?.({ status: "error", message: "credential missing" });
75
+ throw new WebError(
76
+ `${id} search has no API key`,
77
+ "WEB_PROVIDER_CREDENTIAL_MISSING",
78
+ );
79
+ }
80
+
81
+ const baseURL = options.baseURL || defaultBaseURL;
82
+ const model = options.model || defaultModel;
83
+ const apiVersion = options.apiVersion || defaultApiVersion;
84
+ const maxTokens = options.maxTokens ?? 4096;
85
+ const maxUses = options.maxUses ?? 5;
86
+ const endpoint = `${baseURL.replace(/\/$/, "")}/messages`;
87
+
88
+ const body = {
89
+ model,
90
+ max_tokens: maxTokens,
91
+ messages: [
92
+ {
93
+ role: "user",
94
+ content: [
95
+ {
96
+ type: "text",
97
+ text: `Perform a web search for the query: ${request.query}`,
98
+ },
99
+ ],
100
+ },
101
+ ],
102
+ tools: [{ type: toolName, name: "web_search", max_uses: maxUses }],
103
+ };
104
+
105
+ hooks.recordRequest?.({
106
+ endpoint,
107
+ toolName: `${id}:web_search`,
108
+ body: { model, max_tokens: maxTokens, max_uses: maxUses, query: request.query },
109
+ headers: { "anthropic-version": apiVersion },
110
+ });
111
+
112
+ throwIfSearchAborted(signal);
113
+
114
+ let response;
115
+ try {
116
+ response = await fetch(endpoint, {
117
+ method: "POST",
118
+ redirect: "error",
119
+ headers: {
120
+ "x-api-key": apiKey,
121
+ "authorization": `Bearer ${apiKey}`,
122
+ "anthropic-version": apiVersion,
123
+ "content-type": "application/json",
124
+ "accept": "application/json",
125
+ "user-agent": UA,
126
+ },
127
+ body: JSON.stringify(body),
128
+ ...(signal !== undefined ? { signal } : {}),
129
+ });
130
+ } catch (err) {
131
+ if (signal?.aborted === true || isAbortError(err)) throw searchAborted(signal, err);
132
+ hooks.recordOutcome?.({ status: "error", message: String(err?.message ?? err) });
133
+ throw new WebError(
134
+ `${id} search request failed: ${String(err?.message ?? err)}`,
135
+ "WEB_PROVIDER_ERROR",
136
+ { cause: err },
137
+ );
138
+ }
139
+
140
+ if (!response.ok) {
141
+ let message = `${id} API error (HTTP ${response.status})`;
142
+ try {
143
+ const parsed = await response.json();
144
+ const detail =
145
+ typeof parsed.error === "string"
146
+ ? parsed.error
147
+ : parsed.error?.message ?? parsed.message;
148
+ if (detail !== undefined && detail.length > 0) message = detail;
149
+ } catch (err) {
150
+ if (signal?.aborted === true || isAbortError(err)) throw searchAborted(signal, err);
151
+ }
152
+ hooks.recordOutcome?.({ status: "error", message });
153
+ throw new WebError(message, "WEB_PROVIDER_ERROR");
154
+ }
155
+
156
+ try {
157
+ const result = mapAnthropicResponse(await response.json(), id, futureToolName);
158
+ hooks.recordOutcome?.({ status: "ok", sources: result.sources.length });
159
+ return { sources: result.sources, content: undefined, truncated: false };
160
+ } catch (err) {
161
+ if (signal?.aborted === true || isAbortError(err)) throw searchAborted(signal, err);
162
+ if (err instanceof WebError) {
163
+ hooks.recordOutcome?.({ status: "error", message: err.message });
164
+ throw err;
165
+ }
166
+ hooks.recordOutcome?.({ status: "error", message: String(err?.message ?? err) });
167
+ throw new WebError(
168
+ `${id} returned an unprocessable response body: ${String(err?.message ?? err)}`,
169
+ "WEB_PROVIDER_ERROR",
170
+ { cause: err },
171
+ );
172
+ }
173
+ },
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Optional dual-tool fallback: try the newer tool first; if that tool name is
179
+ * rejected by the API (HTTP error mapping is upstream of us), the provider
180
+ * retries with the established fallback. Here we just expose both — the
181
+ * provider loops through candidates and uses whichever result block appears.
182
+ */
183
+ export function tryDualToolResponse(response, id, primaryTool, fallbackTool) {
184
+ return mapAnthropicResponse(response, id, primaryTool);
185
+ }
186
+
187
+ /**
188
+ * Walk web_search_tool_result blocks for citeable web_search_result items,
189
+ * join each to its citation excerpt as snippet, dedupe by url.
190
+ * @param {object} response parsed Messages body
191
+ * @param {string} id backend id (for error message)
192
+ * @returns {{sources: WebSearchSource[]}}
193
+ */
194
+ export function mapAnthropicResponse(response, id) {
195
+ const blocks = response?.content ?? [];
196
+ const resultBlocks = blocks.filter(
197
+ (b) => b.type === "web_search_tool_result",
198
+ );
199
+ if (resultBlocks.length === 0) {
200
+ throw new WebError(
201
+ `${id} returned no web_search_tool_result blocks; native web search may not have triggered`,
202
+ "WEB_PROVIDER_ERROR",
203
+ );
204
+ }
205
+ const snippets = citationSnippets(blocks);
206
+ const seen = new Set();
207
+ const sources = [];
208
+ for (const block of resultBlocks) {
209
+ for (const item of block.content ?? []) {
210
+ if (item.type !== "web_search_result" || !item.url || item.url.length === 0 || seen.has(item.url)) continue;
211
+ seen.add(item.url);
212
+ const snippet = snippets.get(item.url);
213
+ const source = { url: item.url };
214
+ if (item.title != null && item.title.length > 0) source.title = item.title;
215
+ if (snippet != null && snippet.length > 0) source.snippet = snippet;
216
+ if (item.page_age != null && item.page_age.length > 0) source.publishedAt = item.page_age;
217
+ sources.push(source);
218
+ }
219
+ }
220
+ return { sources };
221
+ }
222
+
223
+ /**
224
+ * Build a url → cited_text map from text blocks' citations[] (first wins).
225
+ * web_search_result items carry url/title/page_age but typically no inline
226
+ * snippet; the excerpt lives in a separate text block's citation keyed by url.
227
+ */
228
+ function citationSnippets(blocks) {
229
+ const map = new Map();
230
+ for (const block of blocks) {
231
+ if (block.type !== "text") continue;
232
+ for (const cite of block.citations ?? []) {
233
+ if (
234
+ cite.url != null &&
235
+ cite.url.length > 0 &&
236
+ cite.cited_text != null &&
237
+ cite.cited_text.length > 0 &&
238
+ !map.has(cite.url)
239
+ ) {
240
+ map.set(cite.url, cite.cited_text);
241
+ }
242
+ }
243
+ }
244
+ return map;
245
+ }
246
+
247
+ /** Pre-built instances for the two anthropic-like providers we ship. */
248
+ export const deepseekBackend = createAnthropicLikeBackend({
249
+ id: "deepseek",
250
+ defaultBaseURL: "https://api.deepseek.com/anthropic/v1",
251
+ defaultModel: "deepseek-v4-flash",
252
+ defaultApiVersion: "2023-06-01",
253
+ toolName: "web_search_20250305",
254
+ });
255
+
256
+ export const anthropicBackend = createAnthropicLikeBackend({
257
+ id: "anthropic",
258
+ defaultBaseURL: "https://api.anthropic.com/v1",
259
+ defaultModel: "claude-sonnet-4-6",
260
+ defaultApiVersion: "2023-06-01",
261
+ toolName: "web_search_20250305",
262
+ });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Brave Search backend for dsh-websearch.
3
+ * REST API: https://api.search.brave.com/res/v1/web/search
4
+ * Auth: X-Subscription-Token header
5
+ * Free tier: 2,000 requests/month
6
+ */
7
+
8
+ import { WebError } from "@deepseek-ai/dsh-web";
9
+ import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort.js";
10
+
11
+ const BRAVE_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
12
+
13
+ /** @type {import("../provider.js").Backend} */
14
+ export const braveBackend = {
15
+ id: "brave",
16
+ requiresCredential: true,
17
+
18
+ available(options) {
19
+ return typeof options?.resolveApiKey === "function";
20
+ },
21
+
22
+ async search(request, signal, options, hooks) {
23
+ throwIfSearchAborted(signal);
24
+ const backendOpts = options ?? {};
25
+
26
+ const apiKey = await backendOpts.resolveApiKey?.();
27
+ if (!apiKey) {
28
+ throw new WebError("Brave Search: API key not configured", "WEB_PROVIDER_UNAVAILABLE");
29
+ }
30
+
31
+ const baseURL = backendOpts.baseURL ?? BRAVE_ENDPOINT;
32
+ const count = Math.min(request.maxResults ?? 8, 20);
33
+ const url = new URL(baseURL);
34
+ url.searchParams.set("q", request.query);
35
+ url.searchParams.set("count", String(count));
36
+
37
+ hooks?.recordRequest?.({
38
+ endpoint: baseURL,
39
+ toolName: "brave_search",
40
+ body: { q: request.query, count },
41
+ });
42
+
43
+ let response;
44
+ try {
45
+ response = await fetch(url, {
46
+ method: "GET",
47
+ headers: {
48
+ "X-Subscription-Token": apiKey,
49
+ "Accept": "application/json",
50
+ "User-Agent": "dsh-websearch/2.0",
51
+ },
52
+ signal,
53
+ });
54
+ } catch (error) {
55
+ if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
56
+ throw new WebError(`Brave Search request failed: ${error}`, "WEB_PROVIDER_ERROR", { cause: error });
57
+ }
58
+
59
+ if (!response.ok) {
60
+ const text = await response.text().catch(() => "");
61
+ hooks?.recordOutcome?.({ status: "error", message: `HTTP ${response.status}: ${text}` });
62
+ throw new WebError(`Brave Search API error (HTTP ${response.status}): ${text}`, "WEB_PROVIDER_ERROR");
63
+ }
64
+
65
+ let data;
66
+ try {
67
+ data = await response.json();
68
+ } catch (error) {
69
+ throw new WebError("Brave Search: invalid JSON response", "WEB_PROVIDER_ERROR", { cause: error });
70
+ }
71
+
72
+ const sources = parseBraveResult(data, request.maxResults ?? 8);
73
+ hooks?.recordOutcome?.({ status: "success", count: sources.length });
74
+ return { sources };
75
+ },
76
+ };
77
+
78
+ /**
79
+ * Parse Brave Search API response.
80
+ * @param {object} data - Response JSON
81
+ * @param {number} maxResults - Maximum results to return
82
+ * @returns {Array<{url:string,title?:string,snippet?:string,publishedAt?:string}>}
83
+ */
84
+ export function parseBraveResult(data, maxResults) {
85
+ const results = data?.web?.results ?? [];
86
+ const sources = [];
87
+ for (const item of results) {
88
+ if (sources.length >= maxResults) break;
89
+ if (!item?.url) continue;
90
+ sources.push({
91
+ url: item.url,
92
+ ...(item.title ? { title: item.title } : {}),
93
+ ...(item.description ? { snippet: item.description } : {}),
94
+ ...(item.age ? { publishedAt: item.age } : {}),
95
+ });
96
+ }
97
+ return sources;
98
+ }
@@ -0,0 +1,136 @@
1
+ // ddg.js — DuckDuckGo HTML backend (keyless, no API key required)
2
+ // Endpoint: https://html.duckduckgo.com/html/ (POST form-encoded q=...)
3
+ // Returns a simple HTML results page. We extract .result__a (anchor with
4
+ // href through duckduckgo.com/l/?uddg=<encoded-url> redirect), .result__title
5
+ // text, and .result__snippet text. This is a fallback tier: Exa/Parallel
6
+ // are richer, DDG is the zero-dependency safety-net.
7
+
8
+ import { searchAborted, isAbortError } from "../util/abort.js";
9
+ import { WebError } from "@deepseek-ai/dsh-web";
10
+
11
+ export const DDG_ENDPOINT = "https://html.duckduckgo.com/html/";
12
+
13
+ export const ddgBackend = {
14
+ id: "ddg",
15
+ requiresCredential: false,
16
+
17
+ available() {
18
+ return true; // keyless
19
+ },
20
+
21
+ /**
22
+ * @param {WebSearchRequest} request
23
+ * @param {AbortSignal} signal
24
+ * @param {{ recordRequest?: Function, recordOutcome?: Function }} [hooks]
25
+ * @returns {Promise<WebSearchResult>}
26
+ */
27
+ async search(request, signal, options, hooks = {}) {
28
+ throwIfSearchAborted(signal);
29
+ const body = "q=" + encodeURIComponent(request.query) + "&b=" + Math.random().toString().slice(2);
30
+ hooks.recordRequest?.({ endpoint: DDG_ENDPOINT, toolName: "html", body: { q: request.query }, headers: {} });
31
+
32
+ let text;
33
+ try {
34
+ const res = await fetch(DDG_ENDPOINT, {
35
+ method: "POST",
36
+ redirect: "error",
37
+ headers: {
38
+ "Content-Type": "application/x-www-form-urlencoded",
39
+ "Accept": "text/html",
40
+ "User-Agent": "dsh-websearch/2.0",
41
+ },
42
+ body,
43
+ ...(signal !== undefined ? { signal } : {}),
44
+ });
45
+ if (!res.ok) {
46
+ let detail = "";
47
+ try { detail = (await res.text()).slice(0, 200); } catch {}
48
+ throw new WebError(`ddg HTTP ${res.status}${detail ? ": " + detail : ""}`, "WEB_PROVIDER_ERROR");
49
+ }
50
+ text = await res.text();
51
+ } catch (err) {
52
+ if (signal?.aborted === true || isAbortError(err)) throw searchAborted(signal, err);
53
+ hooks.recordOutcome?.({ status: "error", message: String(err?.message ?? err) });
54
+ throw new WebError(
55
+ `ddg search failed: ${String(err?.message ?? err)}`,
56
+ "WEB_PROVIDER_ERROR",
57
+ { cause: err },
58
+ );
59
+ }
60
+
61
+ hooks.recordOutcome?.({ status: "ok" });
62
+
63
+ const sources = parseDdgHtml(text, request.maxResults ?? 8);
64
+ return { sources, content: undefined, truncated: false };
65
+ },
66
+ };
67
+
68
+ /**
69
+ * Minimal regex-based DDG HTML parser. Extracts result blocks:
70
+ * <a class="result__a" href="...uddg=<encoded>...">Title Text</a>
71
+ * <a class="result__snippet" ...>Snippet text</a>
72
+ * The real URL is the uddg= query param (DDG wraps results in a redirect).
73
+ *
74
+ * Resilient to class-attribute ordering / whitespace variations.
75
+ *
76
+ * @param {string} html
77
+ * @param {number} maxResults
78
+ * @returns {Array<{url:string,title?:string,snippet?:string}>}
79
+ */
80
+ export function parseDdgHtml(html, maxResults) {
81
+ if (!html) return [];
82
+ // Result anchors: <a ... class="result__a" ... href="<url>">Title</a>
83
+ const anchorRe = /<a\b[^>]*\bclass="[^"]*result__a[^"]*"[^>]*\bhref="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
84
+ const sources = [];
85
+ let m;
86
+ while ((m = anchorRe.exec(html)) !== null) {
87
+ const rawHref = m[1];
88
+ const url = decodeDdgUddg(rawHref);
89
+ if (!url) continue;
90
+
91
+ // Title text: strip nested tags inside the anchor.
92
+ const title = stripTags(m[2]).trim();
93
+
94
+ // Snippet: search forward from this anchor for the nearest result__snippet.
95
+ const after = html.slice(m.index + m[0].length);
96
+ const snipRe = /<a\b[^>]*\bclass="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/a>/i;
97
+ const snipM = snipRe.exec(after);
98
+ const snippet = snipM ? stripTags(snipM[1]).trim() : undefined;
99
+
100
+ const source = { url };
101
+ if (title) source.title = title;
102
+ if (snippet && snippet.length > 0) source.snippet = snippet;
103
+ sources.push(source);
104
+ if (sources.length >= maxResults) break;
105
+ }
106
+ return sources;
107
+ }
108
+
109
+ /**
110
+ * Decode DDG's uddg= redirect param to the real target URL.
111
+ * Links look like //duckduckgo.com/l/?uddg=<encoded>&rut=... — we want <decoded>.
112
+ */
113
+ function decodeDdgUddg(href) {
114
+ try {
115
+ // Strip optional protocol-relative prefix.
116
+ const u = href.replace(/^\/\//, "https://");
117
+ const idx = u.indexOf("uddg=");
118
+ if (idx === -1) {
119
+ // If there's no uddg param but it's a clean http(s) URL, use as-is.
120
+ return /^https?:\/\//.test(u) ? u : null;
121
+ }
122
+ const tail = u.slice(idx + 5);
123
+ const amp = tail.indexOf("&");
124
+ const enc = amp === -1 ? tail : tail.slice(0, amp);
125
+ return decodeURIComponent(enc);
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ /** Strip all HTML tags from a fragment, collapse whitespace. */
132
+ function stripTags(s) {
133
+ return String(s).replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
134
+ }
135
+
136
+ export default ddgBackend;