@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 +21 -0
- package/README.md +136 -0
- package/cordis.patch.yml +3 -0
- package/examples/cordis.patch.yml +17 -0
- package/lib/backends/anthropic-like.js +262 -0
- package/lib/backends/brave.js +98 -0
- package/lib/backends/ddg.js +136 -0
- package/lib/backends/exa.js +97 -0
- package/lib/backends/index.js +121 -0
- package/lib/backends/mojeek.js +103 -0
- package/lib/backends/openai.js +171 -0
- package/lib/backends/parallel.js +105 -0
- package/lib/backends/searxng.js +107 -0
- package/lib/backends/serper.js +115 -0
- package/lib/backends/tavily.js +112 -0
- package/lib/client.js +317 -0
- package/lib/index.js +277 -0
- package/lib/provider.js +225 -0
- package/lib/util/abort.js +82 -0
- package/lib/util/log.js +51 -0
- package/lib/util/mcp-client.js +233 -0
- package/lib/util/rpc.js +262 -0
- package/package.json +70 -0
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
|
package/cordis.patch.yml
ADDED
|
@@ -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;
|