dsh-web-tavily 0.4.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/CHANGELOG.md +77 -0
- package/LICENSE +21 -0
- package/README.md +174 -0
- package/README.zh.md +121 -0
- package/cordis.patch.yml +4 -0
- package/dsh-plugin.json +26 -0
- package/lib/diag.js +60 -0
- package/lib/index.js +392 -0
- package/lib/settings-card.js +175 -0
- package/package.json +38 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
## 0.4.0 (2026-09-13)
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- **Renamed again, to `dsh-web-tavily`.** The 0.3.0 name `dsh-tavily-search`
|
|
10
|
+
turned out to be taken on npm as well (`ouones`, v0.1.2) — as are
|
|
11
|
+
`dsh-tavily-provider`, `dsh-search-tavily`, `dsh-tavily` and
|
|
12
|
+
`dsh-tavily-web-search`; `dsh-web-tavily` is the free one. The package name,
|
|
13
|
+
the manifest `id`, the bundle patch's row id and module name, the Cordis name,
|
|
14
|
+
the settings namespace and the log file all follow it. 0.3.0 was never
|
|
15
|
+
published, so nobody has to migrate twice.
|
|
16
|
+
- **Settings section and log file are now `dsh-web-tavily`**: the section in
|
|
17
|
+
`~/.dsh/settings.yaml` is `dsh-web-tavily:` and the lifecycle log is
|
|
18
|
+
`~/.dsh-tui/dsh-web-tavily.log`.
|
|
19
|
+
|
|
20
|
+
## 0.3.0 (2026-09-13)
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- **Renamed from `dsh-web-search-tavily` to `dsh-tavily-search`.** The npm name
|
|
25
|
+
`dsh-web-search-tavily` is taken by an unrelated package, so the package name,
|
|
26
|
+
the manifest `id`, the bundle patch's row id and module name, and the plugin's
|
|
27
|
+
Cordis name all moved to `dsh-tavily-search`. The provider id (`tavily`),
|
|
28
|
+
the registered fetch provider (`http-trusted`) and every seam contract are
|
|
29
|
+
unchanged, so a profile patch needs no edit.
|
|
30
|
+
- **The settings namespace and the log file follow the package name**: the
|
|
31
|
+
section in `~/.dsh/settings.yaml` is now `dsh-tavily-search:` (rename the old
|
|
32
|
+
`web-search-tavily:` key to keep your values) and the lifecycle log is now
|
|
33
|
+
`~/.dsh-tui/dsh-tavily-search.log`.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- The `repository` field in `package.json` and `source.repository` in
|
|
38
|
+
`dsh-plugin.json`, both pointing at the public repository.
|
|
39
|
+
|
|
40
|
+
## 0.2.0 (2026-09-13)
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
- **A localized settings card** (`lib/settings-card.js`). `settings.installSection`
|
|
45
|
+
registers the namespace and its schema, and the host can always render a generic
|
|
46
|
+
card from that — but a generic card shows raw field keys (`apiKeyEnv`,
|
|
47
|
+
`searchDepth`, …) with no translation. The card registered here is a
|
|
48
|
+
`tuiSettingsSections` section, the same shape every other plugin in this
|
|
49
|
+
ecosystem ships, with Chinese and English descriptions for the credential
|
|
50
|
+
reference, the API base URL, search depth, topic, the AI-answer switch and the
|
|
51
|
+
fallback result cap. `apiKey` is deliberately left out: a literal secret in a
|
|
52
|
+
settings document is the one thing this plugin tells users not to do.
|
|
53
|
+
- The card registration tolerates the first tick: the seam refuses with
|
|
54
|
+
`requires a live Cordis activation context` while the activation is not live
|
|
55
|
+
yet, so the registration retries for ~12 s and logs the refusal once. Verified
|
|
56
|
+
against the headless probe: the card lands on the retry.
|
|
57
|
+
|
|
58
|
+
## 0.1.1 (2026-09-13)
|
|
59
|
+
|
|
60
|
+
### Added
|
|
61
|
+
|
|
62
|
+
- **A lifecycle log** (`lib/diag.js` → `~/.dsh-tui/dsh-web-search-tavily.log`, the
|
|
63
|
+
host logger when there is one, 128 KiB cap, silent under `node --test`). The
|
|
64
|
+
plugin declares `inject = ["web"]`, so on a host without that seam `apply()`
|
|
65
|
+
stays parked — and with no logging at all it looked exactly like a plugin that
|
|
66
|
+
was never loaded. There is now an import-time breadcrumb, an `apply started`
|
|
67
|
+
line, the settings-namespace and provider-registration results, and an unload
|
|
68
|
+
line.
|
|
69
|
+
- Both provider registrations are wrapped in a `try`/`catch` that logs, instead
|
|
70
|
+
of letting a refusing seam take the boot down.
|
|
71
|
+
|
|
72
|
+
### Changed
|
|
73
|
+
|
|
74
|
+
- Documentation: the README is split into an English `README.md` and a Chinese
|
|
75
|
+
`README.zh.md` with reciprocal links, the internal workspace path is gone from
|
|
76
|
+
the install steps, and the published `files` list now carries `README.zh.md`
|
|
77
|
+
and this changelog.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 VviLliAm-qwq
|
|
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,174 @@
|
|
|
1
|
+
# dsh-web-tavily
|
|
2
|
+
|
|
3
|
+
**English** · [中文](README.zh.md)
|
|
4
|
+
|
|
5
|
+
A host-plane Cordis plugin for dsh: it registers the **`tavily`** search provider
|
|
6
|
+
on dsh's web seam (`ctx.web`), so the `web_search` tool goes to the **Tavily
|
|
7
|
+
Search API** (`POST https://api.tavily.com/search`).
|
|
8
|
+
|
|
9
|
+
- It does **not** replace the `web_search` tool itself: the tool, its system
|
|
10
|
+
prompt and its result card are untouched — only the search backend changes.
|
|
11
|
+
- DeepSeek's native search (`web-search-deepseek`) stays available as a fallback
|
|
12
|
+
you can switch to; set `web.searchProvider` back to `deepseek-official` (or
|
|
13
|
+
delete the key) to do so.
|
|
14
|
+
- The key is always resolved as a **reference** (default `TAVILY_API_KEY`): the
|
|
15
|
+
credentials service (`refs:` in `~/.dsh/.credentials.yaml`) first, then the
|
|
16
|
+
process environment, and only then a literal `apiKey` in the configuration. The
|
|
17
|
+
provider never stores a key.
|
|
18
|
+
|
|
19
|
+
## How it works
|
|
20
|
+
|
|
21
|
+
1. The provider (id `tavily`) registers on `ctx.web`; `web.searchProvider: tavily`
|
|
22
|
+
makes it the default choice.
|
|
23
|
+
2. When `web_search` runs, the seam hands `{ query, maxResults }` to this provider.
|
|
24
|
+
3. The provider resolves the key (a snapshot per operation, so one search never
|
|
25
|
+
mixes two configuration versions) and calls `{baseURL}/search`.
|
|
26
|
+
4. The response is mapped: `results[] → sources[]` (`title` / `url` /
|
|
27
|
+
`content→snippet`, plus `published_date → publishedAt` when present), and a
|
|
28
|
+
top-level `answer` (Tavily's AI summary) becomes the tool's optional `content`.
|
|
29
|
+
5. Cancellation (signal) → `WEB_ABORTED`; an HTTP failure or an unparsable body →
|
|
30
|
+
`WEB_PROVIDER_ERROR`; a missing key → `WEB_PROVIDER_CREDENTIAL_MISSING` (the
|
|
31
|
+
message names the configuration step).
|
|
32
|
+
|
|
33
|
+
## Defaults
|
|
34
|
+
|
|
35
|
+
| Field | Default | Meaning |
|
|
36
|
+
| --- | --- | --- |
|
|
37
|
+
| `apiKeyEnv` | `TAVILY_API_KEY` | Credential reference (environment-variable name). |
|
|
38
|
+
| `apiKey` | (none) | A literal key, only when you do not want a reference. |
|
|
39
|
+
| `baseURL` | `https://api.tavily.com` | Overridable with `TAVILY_BASE_URL`. |
|
|
40
|
+
| `searchDepth` | `basic` | `basic` / `advanced` (deeper, higher credit use). |
|
|
41
|
+
| `topic` | `general` | `general` / `news`. |
|
|
42
|
+
| `includeAnswer` | `true` | Ask for Tavily's AI summary (mapped to `content`). |
|
|
43
|
+
| `maxResults` | `10` | Fallback cap; `web_search` passes 8 per call, Tavily's ceiling is 20. |
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
1. Copy the package into
|
|
48
|
+
`~/.dsh/profiles/dsh-tui/node_modules/dsh-web-tavily/`.
|
|
49
|
+
2. Append `"dsh-web-tavily"` to `dsh.profile.bundles` in
|
|
50
|
+
`~/.dsh/profiles/dsh-tui/package.json`.
|
|
51
|
+
3. **Make tavily the default search backend.** This has to be written in the
|
|
52
|
+
profile's user patch layer (the `web:` section of `settings.yaml` does not
|
|
53
|
+
affect service configuration) — edit
|
|
54
|
+
`~/.dsh/profiles/dsh-tui/cordis.patch.yml` and add:
|
|
55
|
+
|
|
56
|
+
```yaml
|
|
57
|
+
- id: web
|
|
58
|
+
name: '@deepseek-ai/dsh-web'
|
|
59
|
+
config:
|
|
60
|
+
searchProvider: tavily
|
|
61
|
+
fetchProvider: http-trusted
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The `web` row belongs to the `dsh-base` layer, so **do not add a new row with
|
|
65
|
+
`insert:`** — the duplicate id crashes the boot with
|
|
66
|
+
`duplicate loader entry id: web`. Override the existing row by id instead, and
|
|
67
|
+
note that an override **replaces the whole config**, which is why
|
|
68
|
+
`fetchProvider` is restated above.
|
|
69
|
+
4. Restart dsh-tui (`/restart`).
|
|
70
|
+
|
|
71
|
+
## Configuration
|
|
72
|
+
|
|
73
|
+
The plugin's own section lives in `~/.dsh/settings.yaml` (it also appears in the
|
|
74
|
+
settings screen under “Web search Tavily”):
|
|
75
|
+
|
|
76
|
+
```yaml
|
|
77
|
+
dsh-web-tavily:
|
|
78
|
+
apiKeyEnv: TAVILY_API_KEY
|
|
79
|
+
searchDepth: basic
|
|
80
|
+
topic: general
|
|
81
|
+
includeAnswer: true
|
|
82
|
+
maxResults: 10
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`TAVILY_BASE_URL` overrides the API origin; the credential reference defaults to
|
|
86
|
+
`TAVILY_API_KEY`.
|
|
87
|
+
|
|
88
|
+
The settings screen renders this namespace as a bilingual card (**Web search
|
|
89
|
+
(Tavily)** / 联网搜索(Tavily)) with a description for every field, so the
|
|
90
|
+
reference is the one control you normally touch. The key itself never enters the
|
|
91
|
+
settings document.
|
|
92
|
+
|
|
93
|
+
## Where the key lives (local only — never in a repository)
|
|
94
|
+
|
|
95
|
+
Add it under `refs:` in `~/.dsh/.credentials.yaml` (that file holds machine-local
|
|
96
|
+
credentials; do not commit or share it):
|
|
97
|
+
|
|
98
|
+
```yaml
|
|
99
|
+
refs:
|
|
100
|
+
TAVILY_API_KEY: 'tvly-...'
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Switching back to DeepSeek's native search
|
|
104
|
+
|
|
105
|
+
Set `web.searchProvider` to `deepseek-official` (or delete the key and let the
|
|
106
|
+
seam use the only provider available). The seam does **not** fall back
|
|
107
|
+
automatically: a broken Tavily configuration fails loudly rather than quietly
|
|
108
|
+
using DeepSeek.
|
|
109
|
+
|
|
110
|
+
## Proxy-compatible fetch (`http-trusted`)
|
|
111
|
+
|
|
112
|
+
The plugin also registers an `http-trusted` fetch provider, which works around
|
|
113
|
+
`web_fetch` being refused by the official `http` provider's safety preflight in
|
|
114
|
+
**fake-IP proxy environments** (Clash and friends resolving domains to
|
|
115
|
+
`198.18.0.0/15`): `WEB_BLOCKED_URL: resolves to a non-public IP address`.
|
|
116
|
+
|
|
117
|
+
- It **reuses the official `@deepseek-ai/dsh-web-fetch-http` transport** and
|
|
118
|
+
relaxes exactly one check — the “resolved address must be public” preflight
|
|
119
|
+
additionally allows `198.18.0.0/15`, the proxy fake-IP range. Everything else
|
|
120
|
+
is unchanged: private, loopback and reserved ranges are still refused
|
|
121
|
+
(`WEB_BLOCKED_URL`), IPv6 answers are still dropped, and address pinning,
|
|
122
|
+
same-origin redirects, the content-type allowlist, byte/character caps and the
|
|
123
|
+
no-credentials cookie rule all stay in place.
|
|
124
|
+
- ⚠️ **Security boundary:** SSRF protection moves from “enforced inside the host”
|
|
125
|
+
to “trust the local proxy's DNS decision”, so the last word on reachability
|
|
126
|
+
belongs to the proxy's routing rules. **Intended for a single-user machine with
|
|
127
|
+
a trusted self-hosted proxy**; for shared or public deployments go back to the
|
|
128
|
+
official `http` provider (below).
|
|
129
|
+
- Addresses that are special by construction (`http://192.168.x.x/` and friends)
|
|
130
|
+
are still refused.
|
|
131
|
+
|
|
132
|
+
```yaml
|
|
133
|
+
# the web row in ~/.dsh/profiles/dsh-tui/cordis.patch.yml
|
|
134
|
+
config:
|
|
135
|
+
searchProvider: tavily
|
|
136
|
+
fetchProvider: http-trusted
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
**Rolling back to the official `http`:** set `fetchProvider` back to `http`. The
|
|
140
|
+
cost is that `web_fetch` is refused again in a fake-IP proxy environment, in
|
|
141
|
+
exchange for the complete safety preflight.
|
|
142
|
+
|
|
143
|
+
## Troubleshooting
|
|
144
|
+
|
|
145
|
+
- `duplicate loader entry id: web` on boot — the profile's `cordis.patch.yml`
|
|
146
|
+
used `insert:` for a row that already exists; rewrite it as the by-id override
|
|
147
|
+
shown above.
|
|
148
|
+
- `web_search` answers `WEB_PROVIDER_CONFIGURED_MISSING` — the plugin did not
|
|
149
|
+
load: check the bundles list and the patch above.
|
|
150
|
+
- `web_fetch` answers `WEB_BLOCKED_URL ... non-public IP` — the proxy fake-IP
|
|
151
|
+
preflight refused it: switch to `http-trusted`, or turn the proxy's fake-IP
|
|
152
|
+
mode off.
|
|
153
|
+
|
|
154
|
+
## Limitations
|
|
155
|
+
|
|
156
|
+
- A missing or invalid Tavily key makes `web_search` fail
|
|
157
|
+
(`WEB_PROVIDER_CREDENTIAL_MISSING` / `WEB_PROVIDER_ERROR`); the message names
|
|
158
|
+
what to configure.
|
|
159
|
+
- `max_results` is clamped to Tavily's ceiling of 20.
|
|
160
|
+
- An invalid `searchDepth` / `topic` is an error, never a silent fallback to the
|
|
161
|
+
default.
|
|
162
|
+
|
|
163
|
+
## Publishing
|
|
164
|
+
|
|
165
|
+
- **Repository**: <https://github.com/VviLliAm-qwq/dsh-web-tavily> (public)
|
|
166
|
+
|
|
167
|
+
## Usage
|
|
168
|
+
|
|
169
|
+
Once installed and configured, let the model call `web_search` as usual: the
|
|
170
|
+
`sources` list it returns (and the optional summary) now comes from Tavily.
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
MIT — see [LICENSE](LICENSE).
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# dsh-web-tavily
|
|
2
|
+
|
|
3
|
+
**中文** · [English](README.md)
|
|
4
|
+
|
|
5
|
+
dsh host 平面 cordis 插件:为 dsh 的 web 能力 seam(`ctx.web`)注册搜索提供商 **`tavily`**,让 `web_search` 工具默认走 **Tavily Search API**(`POST https://api.tavily.com/search`)。
|
|
6
|
+
|
|
7
|
+
- 不替换 `web_search` 工具本身:工具、系统提示、展示卡片均保持原样,只有搜索后端换成 Tavily。
|
|
8
|
+
- 保留 DeepSeek 原生搜索(`web-search-deepseek`)为可切换备选;想切回只需把 `web.searchProvider` 改为 `deepseek-official` 或删掉该键。
|
|
9
|
+
- key 一律按**引用**解析(默认 `TAVILY_API_KEY`):优先凭证服务(`~/.dsh/.credentials.yaml` 的 `refs:`),其次启动环境变量,最后才是配置里的字面 `apiKey`。provider 不保留 key。
|
|
10
|
+
|
|
11
|
+
## 工作原理
|
|
12
|
+
|
|
13
|
+
1. 注册 provider(id = `tavily`)到 `ctx.web`;`web.searchProvider: tavily` 使其成为默认选择。
|
|
14
|
+
2. `web_search` 被调用时,seam 把 `{ query, maxResults }` 交给本 provider。
|
|
15
|
+
3. provider 解析 key(每次操作时快照,一次搜索不会混用两个配置版本)→ 请求 `{baseURL}/search`。
|
|
16
|
+
4. 响应映射:`results[] → sources[]`(`title` / `url` / `content→snippet`,若有 `published_date → publishedAt`),顶层 `answer`(Tavily 的 AI 总结)→ 工具的可选 `content` 摘要。
|
|
17
|
+
5. 取消(信号)→ `WEB_ABORTED`;HTTP 失败 / 不可解析 → `WEB_PROVIDER_ERROR`;缺 key → `WEB_PROVIDER_CREDENTIAL_MISSING`(错误信息带配置指引)。
|
|
18
|
+
|
|
19
|
+
## 默认配置
|
|
20
|
+
|
|
21
|
+
| 字段 | 默认值 | 说明 |
|
|
22
|
+
| --- | --- | --- |
|
|
23
|
+
| `apiKeyEnv` | `TAVILY_API_KEY` | 凭证引用(环境变量名) |
|
|
24
|
+
| `apiKey` | (无) | 字面 key,仅当不想走引用时使用 |
|
|
25
|
+
| `baseURL` | `https://api.tavily.com` | 可用环境变量 `TAVILY_BASE_URL` 覆盖 |
|
|
26
|
+
| `searchDepth` | `basic` | `basic` / `advanced`(advanced 更深度、额度消耗更高) |
|
|
27
|
+
| `topic` | `general` | `general` / `news` |
|
|
28
|
+
| `includeAnswer` | `true` | 是否请求 Tavily 的 AI 总结(映射为 `content`) |
|
|
29
|
+
| `maxResults` | `10` | 兜底结果数上限(`web_search` 每次实际传 8,Tavily 上限 20) |
|
|
30
|
+
|
|
31
|
+
## 安装
|
|
32
|
+
|
|
33
|
+
1. 把整个包复制到
|
|
34
|
+
`~/.dsh/profiles/dsh-tui/node_modules/dsh-web-tavily/`。
|
|
35
|
+
2. 在 `~/.dsh/profiles/dsh-tui/package.json` 的 `dsh.profile.bundles` 追加 `"dsh-web-tavily"`。
|
|
36
|
+
3. **把 web 服务默认搜索改为 tavily**(必须写在 profile 用户补丁层,`settings.yaml` 的 `web:` 段对服务配置无效)——
|
|
37
|
+
编辑 `~/.dsh/profiles/dsh-tui/cordis.patch.yml` 追加:
|
|
38
|
+
|
|
39
|
+
```yaml
|
|
40
|
+
- id: web
|
|
41
|
+
name: '@deepseek-ai/dsh-web'
|
|
42
|
+
config:
|
|
43
|
+
searchProvider: tavily
|
|
44
|
+
fetchProvider: http-trusted
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`web` 行由基座 dsh-base 提供,**不要用 `insert:` 新增同 id 行**——那会撞 id 直接崩
|
|
48
|
+
`duplicate loader entry id: web`;这里要按 id 覆盖既有行,且覆盖是**整行重写,需写全
|
|
49
|
+
`fetchProvider`**。
|
|
50
|
+
4. 重启 dsh-tui(`/restart`)生效。
|
|
51
|
+
|
|
52
|
+
## 配置
|
|
53
|
+
|
|
54
|
+
插件自身的设置段放 `~/.dsh/settings.yaml`(设置页「Plugin configuration」里也会出现「Web search Tavily」类):
|
|
55
|
+
|
|
56
|
+
```yaml
|
|
57
|
+
dsh-web-tavily:
|
|
58
|
+
apiKeyEnv: TAVILY_API_KEY
|
|
59
|
+
searchDepth: basic
|
|
60
|
+
topic: general
|
|
61
|
+
includeAnswer: true
|
|
62
|
+
maxResults: 10
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
可用环境变量覆盖:`TAVILY_BASE_URL`(API 源),凭证引用键默认 `TAVILY_API_KEY`。
|
|
66
|
+
|
|
67
|
+
设置页把这个命名空间渲染成一张**中英双语卡片**(「联网搜索(Tavily)」/ Web search (Tavily)),每个字段都有说明;日常只需要改「凭证引用」那一项。密钥本身永远不进设置文档。
|
|
68
|
+
|
|
69
|
+
## key 存放(本机,不进代码库)
|
|
70
|
+
|
|
71
|
+
在 `~/.dsh/.credentials.yaml` 的 `refs:` 下添加(该文件属于本机凭证,不要提交/外发):
|
|
72
|
+
|
|
73
|
+
```yaml
|
|
74
|
+
refs:
|
|
75
|
+
TAVILY_API_KEY: 'tvly-...'
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## 切回 DeepSeek 原生搜索
|
|
79
|
+
|
|
80
|
+
把 `web.searchProvider` 改为 `deepseek-official`(或删除该键、让 seam 用唯一可用 provider)。注意 seam 不支持自动回退:Tavily 配置失效时搜索会明确报错,而**不会**悄悄走 DeepSeek。
|
|
81
|
+
|
|
82
|
+
## 代理兼容 fetch(`http-trusted`)
|
|
83
|
+
|
|
84
|
+
本插件同时注册 `http-trusted` fetch provider,解决 **fake-IP 代理环境**(Clash 等把域名解析为 `198.18.0.0/15` 假地址)下 `web_fetch` 被官方 `http` provider 的安全预检拒绝的问题(`WEB_BLOCKED_URL: resolves to a non-public IP address`)。
|
|
85
|
+
|
|
86
|
+
- **复用官方 `@deepseek-ai/dsh-web-fetch-http` 传输层**:仅放宽「解析结果必须是公网 IP」这一预检——额外放行 `198.18.0.0/15`(代理 fake-IP 段),**其余全部照旧**:内网/环回/保留段照常拒绝(`WEB_BLOCKED_URL`)、IPv6 答案一律丢弃、地址 pinning、同源重定向、内容类型白名单、字节/字符上限、无凭证 cookie 等防护原封未动。
|
|
87
|
+
- ⚠️ **安全边界**:SSRF 防护从「宿主内强校验」退化为「信任本机代理的 DNS 决策」,网络的最终裁决权移交代理分流规则。**仅适用于单用户本机 + 自制可信代理**;多人共用/公网服务请回退官方 `http`(见下)。
|
|
88
|
+
- IP 来源特殊(`http://192.168.x.x/` 之类)仍会被拒绝。
|
|
89
|
+
|
|
90
|
+
```yaml
|
|
91
|
+
# ~/.dsh/profiles/dsh-tui/cordis.patch.yml 的 web 行
|
|
92
|
+
config:
|
|
93
|
+
searchProvider: tavily
|
|
94
|
+
fetchProvider: http-trusted
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**回退官方 http**:把 `fetchProvider` 改回 `http`(代价:fake-IP 代理环境下 `web_fetch` 将再次被拒,但安全预检完整)。
|
|
98
|
+
|
|
99
|
+
## 加载失败排查
|
|
100
|
+
|
|
101
|
+
- 插件启动报 `duplicate loader entry id: web`:profile `cordis.patch.yml` 误用 `insert:` 新增同 id 行,按上文改写为直接行。
|
|
102
|
+
- `web_search` 报 `WEB_PROVIDER_CONFIGURED_MISSING`:插件未加载(检查 bundles 列表与上述补丁)。
|
|
103
|
+
- `web_fetch` 报 `WEB_BLOCKED_URL ... non-public IP`:代理假 IP 预检拒绝,改用 `http-trusted` 或关闭代理 fake-IP 模式。
|
|
104
|
+
|
|
105
|
+
## 限制说明
|
|
106
|
+
|
|
107
|
+
- Tavily key 缺失或失效 → `web_search` 报错(`WEB_PROVIDER_CREDENTIAL_MISSING` / `WEB_PROVIDER_ERROR`),按错误信息指引配置即可。
|
|
108
|
+
- `max_results` 会被钳制到 Tavily 上限 20。
|
|
109
|
+
- `searchDepth` / `topic` 非法值会直接报错,不会悄悄回退默认。
|
|
110
|
+
|
|
111
|
+
## 发布
|
|
112
|
+
|
|
113
|
+
- **仓库**:<https://github.com/VviLliAm-qwq/dsh-web-tavily>(公开)
|
|
114
|
+
|
|
115
|
+
## 使用
|
|
116
|
+
|
|
117
|
+
装好并配置 key 后,照常让模型调用 `web_search` 即可;工具返回的 `Sources:` 列表与可选总结即来自 Tavily。
|
|
118
|
+
|
|
119
|
+
## 许可
|
|
120
|
+
|
|
121
|
+
MIT — 见 [LICENSE](LICENSE)。
|
package/cordis.patch.yml
ADDED
package/dsh-plugin.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://dsh.community/schemas/dsh-plugin-0.15.json",
|
|
3
|
+
"manifestVersion": "0.15",
|
|
4
|
+
"id": "com.dsh-tui.dsh-web-tavily",
|
|
5
|
+
"name": "dsh-web-tavily",
|
|
6
|
+
"version": "0.4.0",
|
|
7
|
+
"facets": {
|
|
8
|
+
"host": {
|
|
9
|
+
"entry": "lib/index.js",
|
|
10
|
+
"apiVersion": "v1alpha1"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"requires": {
|
|
14
|
+
"contracts": [],
|
|
15
|
+
"services": []
|
|
16
|
+
},
|
|
17
|
+
"permissions": [],
|
|
18
|
+
"contributes": {
|
|
19
|
+
"commands": []
|
|
20
|
+
},
|
|
21
|
+
"subscriptions": [],
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"source": {
|
|
24
|
+
"repository": "https://github.com/VviLliAm-qwq/dsh-web-tavily"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/lib/diag.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded lifecycle log for dsh-web-tavily.
|
|
3
|
+
*
|
|
4
|
+
* The plugin declares `inject = ["web"]`, so on a host that never mounts the web
|
|
5
|
+
* seam `apply()` stays parked — and a parked plugin is indistinguishable from
|
|
6
|
+
* one that was never loaded unless the module says something at import time.
|
|
7
|
+
* That is the whole reason this file exists.
|
|
8
|
+
*
|
|
9
|
+
* Same conventions as the other plugins in this ecosystem: one line per
|
|
10
|
+
* lifecycle event, the host logger when there is one, a plugin-owned file under
|
|
11
|
+
* `~/.dsh-tui/` capped at 128 KiB, and no file writes under `node --test`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { appendFileSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
/** Plugin-owned log path. */
|
|
19
|
+
export const DIAG_LOG = join(homedir(), '.dsh-tui', 'dsh-web-tavily.log');
|
|
20
|
+
|
|
21
|
+
/** Above this size the file is trimmed to its newest half. */
|
|
22
|
+
export const MAX_LOG_BYTES = 128 * 1024;
|
|
23
|
+
|
|
24
|
+
/** Append one line, trimming the file when it has grown past the cap. */
|
|
25
|
+
function appendLogLine(path, line) {
|
|
26
|
+
try {
|
|
27
|
+
if (statSync(path).size > MAX_LOG_BYTES) {
|
|
28
|
+
writeFileSync(path, readFileSync(path, 'utf8').slice(-Math.floor(MAX_LOG_BYTES / 2)));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Missing or unreadable: the append below recreates it.
|
|
33
|
+
}
|
|
34
|
+
appendFileSync(path, line);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Write one lifecycle line.
|
|
39
|
+
*
|
|
40
|
+
* @param ctx - Cordis context when there is one (`ctx.logger` is used if the
|
|
41
|
+
* host offers it); `undefined` at import time.
|
|
42
|
+
* @param message - the line, without timestamp or level.
|
|
43
|
+
* @param options.env - environment override (tests).
|
|
44
|
+
*/
|
|
45
|
+
export function diag(ctx, message, options = {}) {
|
|
46
|
+
try {
|
|
47
|
+
ctx?.logger?.info?.(`dsh-web-tavily: ${message}`);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Observability only; never let logging break the plugin.
|
|
51
|
+
}
|
|
52
|
+
const env = options.env ?? process.env;
|
|
53
|
+
if (typeof env?.NODE_TEST_CONTEXT === 'string') return;
|
|
54
|
+
try {
|
|
55
|
+
appendLogLine(DIAG_LOG, `${new Date().toISOString()} info ${message}\n`);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// An unwritable log path is not worth surfacing.
|
|
59
|
+
}
|
|
60
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-web-tavily: a Tavily-backed search provider for the dsh web
|
|
3
|
+
* capability seam (ctx.web). Registers the `tavily` provider so the
|
|
4
|
+
* model-facing web_search tool runs against the Tavily Search API
|
|
5
|
+
* (POST https://api.tavily.com/search). The provider holds no credential
|
|
6
|
+
* value: the key is resolved per operation through the credentials service
|
|
7
|
+
* (ref, default TAVILY_API_KEY), the launch environment, or a literal
|
|
8
|
+
* `apiKey` config, in that order.
|
|
9
|
+
*
|
|
10
|
+
* The bundle additionally registers `http-trusted`, a proxy-friendly HTTP(S)
|
|
11
|
+
* fetch provider: it reuses the official @deepseek-ai/dsh-web-fetch-http
|
|
12
|
+
* transport (same-origin redirects, content-type allowlist, byte caps,
|
|
13
|
+
* timeout, no credentials, address pinning) but relaxes the public-IP precheck
|
|
14
|
+
* to also admit the proxy fake-IP range 198.18.0.0/15 (Clash-style DNS), which
|
|
15
|
+
* the stock provider rejects as non-public and which breaks web_fetch under a
|
|
16
|
+
* fake-IP DNS proxy. IPv6 answers are dropped entirely (never trust a
|
|
17
|
+
* link-local/ULA answer for routing), and every other non-public IPv4
|
|
18
|
+
* destination — loopback, RFC1918, link-local, CGNAT, multicast, reserved —
|
|
19
|
+
* is still rejected with WEB_BLOCKED_URL.
|
|
20
|
+
*
|
|
21
|
+
* @module dsh-web-tavily
|
|
22
|
+
*/
|
|
23
|
+
import z from "@deepseek-ai/schemastery";
|
|
24
|
+
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
25
|
+
import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
|
|
26
|
+
import { WebError } from "@deepseek-ai/dsh-web";
|
|
27
|
+
import { HttpFetchProvider } from "@deepseek-ai/dsh-web-fetch-http";
|
|
28
|
+
import { lookup } from "node:dns/promises";
|
|
29
|
+
import { isIP } from "node:net";
|
|
30
|
+
import { diag } from "./diag.js";
|
|
31
|
+
import { applySettingsCard } from "./settings-card.js";
|
|
32
|
+
import ipaddr from "ipaddr.js";
|
|
33
|
+
|
|
34
|
+
// Import-time breadcrumb: the only evidence a host that never mounts `ctx.web`
|
|
35
|
+
// can give that this file was read at all (see ./diag.js).
|
|
36
|
+
try {
|
|
37
|
+
diag(undefined, `module imported pid=${process.pid} node=${process.version}`);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Diagnostics must never be the reason a module fails to load.
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
//#region identity & defaults
|
|
44
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
45
|
+
const name = "dsh-web-tavily";
|
|
46
|
+
/** The web seam this provider registers into. */
|
|
47
|
+
const inject = ["web"];
|
|
48
|
+
/** Stable id this provider registers under. */
|
|
49
|
+
const TAVILY_PROVIDER_ID = "tavily";
|
|
50
|
+
/** Default Tavily API origin; the `/search` path is appended per call. */
|
|
51
|
+
const TAVILY_DEFAULT_BASE_URL = "https://api.tavily.com";
|
|
52
|
+
/** Default environment variable naming the credential reference. */
|
|
53
|
+
const DEFAULT_API_KEY_ENV = "TAVILY_API_KEY";
|
|
54
|
+
/** Environment variable that overrides the API origin. */
|
|
55
|
+
const BASE_URL_ENV = "TAVILY_BASE_URL";
|
|
56
|
+
/** Settings namespace carrying this provider's endpoint and behavior options. */
|
|
57
|
+
const SETTINGS_NAMESPACE = "dsh-web-tavily";
|
|
58
|
+
/** Attribution header sent on every request. Bump with the package version. */
|
|
59
|
+
const USER_AGENT = "dsh-web-tavily/0.4.0";
|
|
60
|
+
/** Fallback result cap when the caller sends no maxResults. */
|
|
61
|
+
const DEFAULT_MAX_RESULTS = 10;
|
|
62
|
+
/** Tavily's documented per-request result cap. */
|
|
63
|
+
const TAVILY_MAX_RESULTS_CAP = 20;
|
|
64
|
+
/** Accepted search_depth values. */
|
|
65
|
+
const SEARCH_DEPTHS = ["basic", "advanced"];
|
|
66
|
+
/** Accepted topic values. */
|
|
67
|
+
const TOPICS = ["general", "news"];
|
|
68
|
+
/** Stable id this bundle's proxy-friendly fetch provider registers under. */
|
|
69
|
+
const TRUSTED_FETCH_PROVIDER_ID = "http-trusted";
|
|
70
|
+
/** Resource limits for the trusted fetch provider (mirror dsh-web-fetch-http defaults). */
|
|
71
|
+
const TRUSTED_FETCH_LIMITS = {
|
|
72
|
+
maxResponseBytes: 5e6,
|
|
73
|
+
maxBodyChars: 1e5,
|
|
74
|
+
timeoutMs: 3e4,
|
|
75
|
+
maxRedirects: 5,
|
|
76
|
+
userAgent: USER_AGENT
|
|
77
|
+
};
|
|
78
|
+
//#endregion
|
|
79
|
+
|
|
80
|
+
//#region config
|
|
81
|
+
const Config = z.object({
|
|
82
|
+
apiKey: z.string().role("secret"),
|
|
83
|
+
apiKeyEnv: z.string().role("credential-ref").default(DEFAULT_API_KEY_ENV),
|
|
84
|
+
baseURL: z.string(),
|
|
85
|
+
searchDepth: z.string().default("basic"),
|
|
86
|
+
topic: z.string().default("general"),
|
|
87
|
+
includeAnswer: z.boolean().default(true),
|
|
88
|
+
maxResults: z.number().step(1).min(1).default(DEFAULT_MAX_RESULTS)
|
|
89
|
+
});
|
|
90
|
+
//#endregion
|
|
91
|
+
|
|
92
|
+
//#region abort & error helpers
|
|
93
|
+
/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
|
|
94
|
+
function isAbortError(error) {
|
|
95
|
+
return error instanceof DOMException && error.name === "AbortError";
|
|
96
|
+
}
|
|
97
|
+
/** Build the provider's stable cancellation error while retaining the caller's reason. */
|
|
98
|
+
function searchAborted(signal, fallback) {
|
|
99
|
+
return new WebError("Tavily search aborted", "WEB_ABORTED", { cause: signal?.aborted === true ? signal.reason : fallback });
|
|
100
|
+
}
|
|
101
|
+
/** Throw the provider's stable cancellation error when the caller already aborted. */
|
|
102
|
+
function throwIfAborted(signal) {
|
|
103
|
+
if (signal?.aborted === true) throw searchAborted(signal);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Race a same-process asynchronous preflight against caller cancellation. The
|
|
107
|
+
* attached settlement handlers keep observing an uncooperative operation after
|
|
108
|
+
* abort so a later rejection cannot become unhandled.
|
|
109
|
+
*/
|
|
110
|
+
function abortable(operation, signal) {
|
|
111
|
+
if (signal === undefined) return operation;
|
|
112
|
+
if (signal.aborted) return Promise.reject(searchAborted(signal));
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
const onAbort = () => {
|
|
115
|
+
reject(searchAborted(signal));
|
|
116
|
+
};
|
|
117
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
118
|
+
operation.then((value) => {
|
|
119
|
+
signal.removeEventListener("abort", onAbort);
|
|
120
|
+
resolve(value);
|
|
121
|
+
}, (error) => {
|
|
122
|
+
signal.removeEventListener("abort", onAbort);
|
|
123
|
+
reject(new Error(String(error).replace(/^Error: /u, ""), { cause: error }));
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/** Race a non-cancellable OS lookup without letting it delay tool cancellation. */
|
|
128
|
+
function raceWithSignal(promise, signal) {
|
|
129
|
+
const abortError = () => new Error("web fetch aborted during hostname resolution", { cause: signal.reason });
|
|
130
|
+
if (signal.aborted) return Promise.reject(abortError());
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
const abort = () => {
|
|
133
|
+
reject(abortError());
|
|
134
|
+
};
|
|
135
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
136
|
+
promise.then(resolve, reject).finally(() => {
|
|
137
|
+
signal.removeEventListener("abort", abort);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/** Collapse newlines in a text field so a snippet never breaks the markdown list. */
|
|
142
|
+
function flattenInline(text) {
|
|
143
|
+
return text.replace(/\s*\n+\s*/g, " ");
|
|
144
|
+
}
|
|
145
|
+
/** Extract a human message from a Tavily error body (several documented shapes). */
|
|
146
|
+
function parseErrorDetail(parsed) {
|
|
147
|
+
if (typeof parsed === "string" && parsed.length > 0) return parsed;
|
|
148
|
+
if (parsed == null || typeof parsed !== "object") return undefined;
|
|
149
|
+
if (typeof parsed.error === "string" && parsed.error.length > 0) return parsed.error;
|
|
150
|
+
if (typeof parsed.error?.message === "string" && parsed.error.message.length > 0) return parsed.error.message;
|
|
151
|
+
if (typeof parsed.detail === "string" && parsed.detail.length > 0) return parsed.detail;
|
|
152
|
+
if (parsed.detail != null && typeof parsed.detail === "object" && typeof parsed.detail.error === "string" && parsed.detail.error.length > 0) return parsed.detail.error;
|
|
153
|
+
if (typeof parsed.message === "string" && parsed.message.length > 0) return parsed.message;
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
//#endregion
|
|
157
|
+
|
|
158
|
+
//#region trusted fetch resolver
|
|
159
|
+
/**
|
|
160
|
+
* Whether an IPv4 address is inside the proxy fake-IP range 198.18.0.0/15
|
|
161
|
+
* (RFC 2544 benchmarking block commonly used by Clash-style DNS proxies as
|
|
162
|
+
* fake answers). This is the ONLY non-public range the trusted fetcher admits.
|
|
163
|
+
*/
|
|
164
|
+
function isFakeIpRange(address) {
|
|
165
|
+
try {
|
|
166
|
+
const parsed = ipaddr.parse(address);
|
|
167
|
+
if (!(parsed instanceof ipaddr.IPv4)) return false;
|
|
168
|
+
const [a, b] = parsed.toByteArray();
|
|
169
|
+
return a === 198 && (b === 18 || b === 19);
|
|
170
|
+
} catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** Whether an address is a globally reachable public unicast (mirrors dsh-web-fetch-http). */
|
|
175
|
+
function isPublicUnicast(address) {
|
|
176
|
+
try {
|
|
177
|
+
const parsed = ipaddr.parse(address);
|
|
178
|
+
if (parsed instanceof ipaddr.IPv4) return parsed.range() === "unicast";
|
|
179
|
+
if (parsed.isIPv4MappedAddress()) return parsed.toIPv4Address().range() === "unicast";
|
|
180
|
+
return parsed.range() === "unicast";
|
|
181
|
+
} catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/** WHATWG URL retains brackets around IPv6 hostnames; IP parsers do not. */
|
|
186
|
+
function stripIpv6Brackets(hostname) {
|
|
187
|
+
return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Proxy-friendly destination resolution: accepts public IPv4 unicasts plus the
|
|
191
|
+
* fake-IP range 198.18.0.0/15, drops every IPv6 answer (a ULA/link-local answer
|
|
192
|
+
* is never trusted for routing), and rejects any other IPv4 answer set with
|
|
193
|
+
* `WEB_BLOCKED_URL`. The returned addresses are the only ones the transport may
|
|
194
|
+
* use (pinned connection, same as the official provider).
|
|
195
|
+
*
|
|
196
|
+
* @param hostname - URL hostname, including brackets when it is an IPv6 literal.
|
|
197
|
+
* @param signal - aborts the wait for system resolution.
|
|
198
|
+
* @returns the validated, non-empty address set.
|
|
199
|
+
*/
|
|
200
|
+
async function resolveProxyFriendly(hostname, signal) {
|
|
201
|
+
const unbracketed = stripIpv6Brackets(hostname);
|
|
202
|
+
const literalFamily = isIP(unbracketed);
|
|
203
|
+
const resolved = literalFamily === 0
|
|
204
|
+
? await raceWithSignal(lookup(unbracketed, { all: true, order: "verbatim" }), signal)
|
|
205
|
+
: [{ address: unbracketed, family: literalFamily }];
|
|
206
|
+
if (resolved.length === 0) throw new WebError(`hostname "${hostname}" resolved to no addresses`, "WEB_PROVIDER_ERROR");
|
|
207
|
+
const addresses = [];
|
|
208
|
+
for (const entry of resolved) {
|
|
209
|
+
if (entry.family !== 4 && entry.family !== 6 || isIP(entry.address) !== entry.family) continue;
|
|
210
|
+
if (entry.family === 6) continue;
|
|
211
|
+
if (!isPublicUnicast(entry.address) && !isFakeIpRange(entry.address)) {
|
|
212
|
+
throw new WebError(`URL hostname "${hostname}" resolves to a non-public IP address`, "WEB_BLOCKED_URL");
|
|
213
|
+
}
|
|
214
|
+
addresses.push({ address: entry.address, family: 4 });
|
|
215
|
+
}
|
|
216
|
+
if (addresses.length === 0) throw new WebError(`hostname "${hostname}" resolved to no usable addresses`, "WEB_PROVIDER_ERROR");
|
|
217
|
+
return addresses;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* The proxy-friendly fetch provider: identical transport to
|
|
221
|
+
* @deepseek-ai/dsh-web-fetch-http with permissive destination resolution.
|
|
222
|
+
*/
|
|
223
|
+
class TrustedHttpFetchProvider extends HttpFetchProvider {
|
|
224
|
+
id = TRUSTED_FETCH_PROVIDER_ID;
|
|
225
|
+
}
|
|
226
|
+
//#endregion
|
|
227
|
+
|
|
228
|
+
//#region provider
|
|
229
|
+
/**
|
|
230
|
+
* The Tavily-backed search provider. HTTP redirects fail as `WEB_PROVIDER_ERROR`;
|
|
231
|
+
* failures after dispatch name the endpoint so the user can fix configuration.
|
|
232
|
+
*/
|
|
233
|
+
class TavilySearchProvider {
|
|
234
|
+
constructor(resolveOptions) {
|
|
235
|
+
this.resolveOptions = resolveOptions;
|
|
236
|
+
}
|
|
237
|
+
id = TAVILY_PROVIDER_ID;
|
|
238
|
+
/** True when a key can be resolved and the endpoint parses. */
|
|
239
|
+
available() {
|
|
240
|
+
const options = this.resolveOptions();
|
|
241
|
+
return ((options.apiKey?.length ?? 0) > 0 || options.resolveApiKey !== undefined) && URL.canParse(options.baseURL);
|
|
242
|
+
}
|
|
243
|
+
async search(request, signal) {
|
|
244
|
+
const options = this.resolveOptions();
|
|
245
|
+
const apiKey = await this.apiKey(options, signal);
|
|
246
|
+
throwIfAborted(signal);
|
|
247
|
+
assertEnum("searchDepth", options.searchDepth, SEARCH_DEPTHS);
|
|
248
|
+
assertEnum("topic", options.topic, TOPICS);
|
|
249
|
+
const endpoint = `${options.baseURL}/search`;
|
|
250
|
+
const requested = request.maxResults ?? options.maxResults;
|
|
251
|
+
const maxResults = Math.min(Number.isInteger(requested) && requested > 0 ? requested : options.maxResults, TAVILY_MAX_RESULTS_CAP);
|
|
252
|
+
const body = {
|
|
253
|
+
query: request.query,
|
|
254
|
+
max_results: maxResults,
|
|
255
|
+
search_depth: options.searchDepth,
|
|
256
|
+
topic: options.topic,
|
|
257
|
+
include_answer: options.includeAnswer
|
|
258
|
+
};
|
|
259
|
+
let response;
|
|
260
|
+
try {
|
|
261
|
+
response = await fetch(endpoint, {
|
|
262
|
+
method: "POST",
|
|
263
|
+
redirect: "error",
|
|
264
|
+
headers: {
|
|
265
|
+
authorization: `Bearer ${apiKey}`,
|
|
266
|
+
"content-type": "application/json",
|
|
267
|
+
accept: "application/json",
|
|
268
|
+
"user-agent": USER_AGENT
|
|
269
|
+
},
|
|
270
|
+
body: JSON.stringify(body),
|
|
271
|
+
...(signal !== undefined ? { signal } : {})
|
|
272
|
+
});
|
|
273
|
+
} catch (error) {
|
|
274
|
+
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
|
|
275
|
+
throw new WebError(`Tavily search request failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
276
|
+
}
|
|
277
|
+
if (!response.ok) {
|
|
278
|
+
let message = `Tavily API error (HTTP ${response.status})`;
|
|
279
|
+
try {
|
|
280
|
+
const detail = parseErrorDetail(await response.json());
|
|
281
|
+
if (detail !== undefined && detail.length > 0) message += `: ${detail}`;
|
|
282
|
+
} catch (error) {
|
|
283
|
+
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
|
|
284
|
+
}
|
|
285
|
+
throw new WebError(`${message}\n\nThe web search request used endpoint ${JSON.stringify(endpoint)}. Only the user should choose or change the endpoint (Settings > Plugins > Plugin configuration > Web search Tavily, or the ${BASE_URL_ENV} environment variable).`, "WEB_PROVIDER_ERROR");
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
const data = await response.json();
|
|
289
|
+
const sources = (Array.isArray(data.results) ? data.results : [])
|
|
290
|
+
.filter((item) => item?.url != null && item.url.length > 0)
|
|
291
|
+
.map((item) => ({
|
|
292
|
+
url: item.url,
|
|
293
|
+
...(item.title != null && item.title.length > 0 ? { title: flattenInline(item.title) } : {}),
|
|
294
|
+
...(item.content != null && item.content.length > 0 ? { snippet: flattenInline(item.content) } : {}),
|
|
295
|
+
...(item.published_date != null && item.published_date.length > 0 ? { publishedAt: item.published_date } : {})
|
|
296
|
+
}));
|
|
297
|
+
return {
|
|
298
|
+
...(typeof data.answer === "string" && data.answer.length > 0 ? { content: data.answer } : {}),
|
|
299
|
+
sources,
|
|
300
|
+
truncated: false
|
|
301
|
+
};
|
|
302
|
+
} catch (error) {
|
|
303
|
+
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
|
|
304
|
+
throw new WebError(error instanceof WebError ? error.message : `Tavily returned an unprocessable response body: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Resolve one operation's credential without retaining it on the provider.
|
|
309
|
+
* @param options - the caller's snapshot, so the key and the endpoint it is sent to come from one section.
|
|
310
|
+
* @param signal - abort signal for the surrounding search.
|
|
311
|
+
* @returns the resolved key.
|
|
312
|
+
*/
|
|
313
|
+
async apiKey(options, signal) {
|
|
314
|
+
throwIfAborted(signal);
|
|
315
|
+
if (options.apiKey !== undefined && options.apiKey.length > 0) return options.apiKey;
|
|
316
|
+
let resolved;
|
|
317
|
+
try {
|
|
318
|
+
resolved = await abortable(options.resolveApiKey?.() ?? Promise.resolve(undefined), signal);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
|
|
321
|
+
throw new WebError(`Tavily search credential resolution failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
322
|
+
}
|
|
323
|
+
if (resolved !== undefined && resolved.length > 0) return resolved;
|
|
324
|
+
throw new WebError(`Tavily search has no API key for "${options.apiKeyEnv ?? DEFAULT_API_KEY_ENV}"; store it through the credentials service (refs: TAVILY_API_KEY in ~/.dsh/.credentials.yaml), export it in the launching environment, or set a literal "apiKey" in the dsh-web-tavily config`, "WEB_PROVIDER_CREDENTIAL_MISSING");
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/** Reject a config value outside the provider's accepted set with a friendly message. */
|
|
328
|
+
function assertEnum(field, value, accepted) {
|
|
329
|
+
if (!accepted.includes(value)) throw new WebError(`Tavily search config "${field}" must be one of ${accepted.map((item) => JSON.stringify(item)).join(", ")} (got ${JSON.stringify(value)})`, "WEB_PROVIDER_ERROR");
|
|
330
|
+
}
|
|
331
|
+
//#endregion
|
|
332
|
+
|
|
333
|
+
//#region registration
|
|
334
|
+
/**
|
|
335
|
+
* Project one resolved section into the options the provider serves its next
|
|
336
|
+
* search with. Environment fallbacks stay here rather than in the provider:
|
|
337
|
+
* every value it reads is already fully defaulted.
|
|
338
|
+
*/
|
|
339
|
+
function resolveOptions(ctx, config) {
|
|
340
|
+
const apiKeyEnv = credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV);
|
|
341
|
+
const literalApiKey = config.apiKey !== undefined && config.apiKey.length > 0 ? config.apiKey : undefined;
|
|
342
|
+
return {
|
|
343
|
+
...(literalApiKey === undefined ? {} : { apiKey: literalApiKey }),
|
|
344
|
+
resolveApiKey: async () => {
|
|
345
|
+
const credentials = ctx.get("credentials");
|
|
346
|
+
if (credentials !== undefined) return (await credentials.resolve(apiKeyEnv))?.value;
|
|
347
|
+
const ambient = launchEnvironmentOf(ctx).get(apiKeyEnv);
|
|
348
|
+
return ambient !== undefined && ambient.value.length > 0 ? ambient.value : undefined;
|
|
349
|
+
},
|
|
350
|
+
apiKeyEnv,
|
|
351
|
+
baseURL: config.baseURL ?? launchEnvironmentOf(ctx).get(BASE_URL_ENV)?.value ?? TAVILY_DEFAULT_BASE_URL,
|
|
352
|
+
searchDepth: config.searchDepth ?? "basic",
|
|
353
|
+
topic: config.topic ?? "general",
|
|
354
|
+
includeAnswer: config.includeAnswer ?? true,
|
|
355
|
+
maxResults: config.maxResults ?? DEFAULT_MAX_RESULTS
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
/** Register the Tavily search provider and the proxy-friendly fetch provider. */
|
|
359
|
+
function apply(ctx, config) {
|
|
360
|
+
diag(ctx, `apply started pid=${process.pid} file=${import.meta.url}`);
|
|
361
|
+
// The localized card lives on the optional TUI seam (see ./settings-card.js);
|
|
362
|
+
// the namespace/schema registration above stays authoritative for storage.
|
|
363
|
+
applySettingsCard(ctx, diag);
|
|
364
|
+
let current = () => config;
|
|
365
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
366
|
+
settingsCtx.settings.installSection(ctx, SETTINGS_NAMESPACE, Config, config, {
|
|
367
|
+
setSource: (source) => {
|
|
368
|
+
current = source;
|
|
369
|
+
},
|
|
370
|
+
onChange: () => {}
|
|
371
|
+
});
|
|
372
|
+
diag(ctx, "settings namespace registered");
|
|
373
|
+
});
|
|
374
|
+
// The web seam is the one thing this plugin cannot work without. It is
|
|
375
|
+
// injected (see `inject`), but a host that answers with a shadow instance or
|
|
376
|
+
// refuses the registration must degrade to a logged warning instead of taking
|
|
377
|
+
// the boot down.
|
|
378
|
+
try {
|
|
379
|
+
ctx.web.registerSearchProvider(new TavilySearchProvider(() => resolveOptions(ctx, current())));
|
|
380
|
+
ctx.web.registerFetchProvider(new TrustedHttpFetchProvider(TRUSTED_FETCH_LIMITS, resolveProxyFriendly));
|
|
381
|
+
diag(ctx, `providers registered search=${TAVILY_PROVIDER_ID} fetch=${TRUSTED_FETCH_PROVIDER_ID}`);
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
diag(ctx, `provider registration failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
385
|
+
}
|
|
386
|
+
ctx.effect(() => () => {
|
|
387
|
+
diag(ctx, "unloaded");
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
//#endregion
|
|
391
|
+
|
|
392
|
+
export { Config, DEFAULT_API_KEY_ENV, SETTINGS_NAMESPACE, TAVILY_DEFAULT_BASE_URL, TAVILY_PROVIDER_ID, TRUSTED_FETCH_LIMITS, TRUSTED_FETCH_PROVIDER_ID, TavilySearchProvider, TrustedHttpFetchProvider, apply, inject, isFakeIpRange, name, resolveProxyFriendly };
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The localized settings card for dsh-web-tavily.
|
|
3
|
+
*
|
|
4
|
+
* The harness-side `settings.installSection` registers the namespace and its
|
|
5
|
+
* schema, and the host can always render a generic card from that schema — but a
|
|
6
|
+
* generic card shows raw field keys (`apiKeyEnv`, `searchDepth`, …) with no
|
|
7
|
+
* translation. A localized card is a `tuiSettingsSections` registration, the
|
|
8
|
+
* same shape every other plugin in this ecosystem ships, and a plugin may
|
|
9
|
+
* (and normally does) register both.
|
|
10
|
+
*
|
|
11
|
+
* The seam is optional and may still be activating on the first tick, so the
|
|
12
|
+
* registration is probed and retried instead of being attempted once.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Section namespace; the same one `installSection` registers. */
|
|
16
|
+
const NS = 'dsh-web-tavily';
|
|
17
|
+
|
|
18
|
+
/** How often the optional seam is re-probed while it has not appeared yet. */
|
|
19
|
+
const RETRY_MS = 400;
|
|
20
|
+
|
|
21
|
+
/** Give up after this many attempts (~12 s) and stay silent. */
|
|
22
|
+
const RETRY_LIMIT = 30;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The card.
|
|
26
|
+
*
|
|
27
|
+
* `apiKey` is deliberately absent: a literal secret in a settings document is
|
|
28
|
+
* the one thing this plugin tells users not to do — the key belongs in the
|
|
29
|
+
* credentials store under the reference named below.
|
|
30
|
+
*/
|
|
31
|
+
export const SETTINGS_SECTION = Object.freeze({
|
|
32
|
+
ns: NS,
|
|
33
|
+
title: 'Web search (Tavily)',
|
|
34
|
+
descriptions: { zh: '联网搜索(Tavily)', en: 'Web search (Tavily)' },
|
|
35
|
+
fields: [
|
|
36
|
+
{
|
|
37
|
+
path: ['apiKeyEnv'],
|
|
38
|
+
label: 'Credential reference',
|
|
39
|
+
descriptions: { zh: '凭证引用', en: 'Credential reference' },
|
|
40
|
+
hint: 'Environment-variable-style reference for the Tavily key: the credentials service first, then the launch environment.',
|
|
41
|
+
hintDescriptions: {
|
|
42
|
+
zh: 'Tavily 密钥的引用名(环境变量形式):先查凭证服务,再查启动环境。',
|
|
43
|
+
en: 'Environment-variable-style reference for the Tavily key: the credentials service first, then the launch environment.',
|
|
44
|
+
},
|
|
45
|
+
kind: 'text',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
path: ['baseURL'],
|
|
49
|
+
label: 'API base URL',
|
|
50
|
+
descriptions: { zh: 'API 地址', en: 'API base URL' },
|
|
51
|
+
hint: 'Tavily API origin; the TAVILY_BASE_URL environment variable overrides it.',
|
|
52
|
+
hintDescriptions: {
|
|
53
|
+
zh: 'Tavily API 源地址;环境变量 TAVILY_BASE_URL 可覆盖。',
|
|
54
|
+
en: 'Tavily API origin; the TAVILY_BASE_URL environment variable overrides it.',
|
|
55
|
+
},
|
|
56
|
+
kind: 'text',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
path: ['searchDepth'],
|
|
60
|
+
label: 'Search depth',
|
|
61
|
+
descriptions: { zh: '检索深度', en: 'Search depth' },
|
|
62
|
+
hint: '“advanced” searches further and consumes more credits.',
|
|
63
|
+
hintDescriptions: {
|
|
64
|
+
zh: '「advanced」检索更深入,也消耗更多额度。',
|
|
65
|
+
en: '“advanced” searches further and consumes more credits.',
|
|
66
|
+
},
|
|
67
|
+
kind: 'select',
|
|
68
|
+
options: [
|
|
69
|
+
{ value: 'basic', label: 'basic', descriptions: { zh: '基础', en: 'basic' } },
|
|
70
|
+
{ value: 'advanced', label: 'advanced', descriptions: { zh: '深入', en: 'advanced' } },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
path: ['topic'],
|
|
75
|
+
label: 'Topic',
|
|
76
|
+
descriptions: { zh: '检索主题', en: 'Topic' },
|
|
77
|
+
hint: '“news” biases the index towards recent coverage.',
|
|
78
|
+
hintDescriptions: {
|
|
79
|
+
zh: '「news」偏向最近的新闻报道。',
|
|
80
|
+
en: '“news” biases the index towards recent coverage.',
|
|
81
|
+
},
|
|
82
|
+
kind: 'select',
|
|
83
|
+
options: [
|
|
84
|
+
{ value: 'general', label: 'general', descriptions: { zh: '通用', en: 'general' } },
|
|
85
|
+
{ value: 'news', label: 'news', descriptions: { zh: '新闻', en: 'news' } },
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
path: ['includeAnswer'],
|
|
90
|
+
label: 'Include AI answer',
|
|
91
|
+
descriptions: { zh: '附带 AI 总结', en: 'Include AI answer' },
|
|
92
|
+
hint: 'Ask Tavily for its short AI summary and pass it to the tool as optional content.',
|
|
93
|
+
hintDescriptions: {
|
|
94
|
+
zh: '请求 Tavily 的简短 AI 总结,作为工具的可选内容返回。',
|
|
95
|
+
en: 'Ask Tavily for its short AI summary and pass it to the tool as optional content.',
|
|
96
|
+
},
|
|
97
|
+
kind: 'boolean',
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
path: ['maxResults'],
|
|
101
|
+
label: 'Fallback result cap',
|
|
102
|
+
descriptions: { zh: '兜底结果上限', en: 'Fallback result cap' },
|
|
103
|
+
hint: 'Used when a call passes no limit; Tavily itself caps at 20.',
|
|
104
|
+
hintDescriptions: {
|
|
105
|
+
zh: '调用未指定数量时使用;Tavily 自身上限为 20。',
|
|
106
|
+
en: 'Used when a call passes no limit; Tavily itself caps at 20.',
|
|
107
|
+
},
|
|
108
|
+
kind: 'number',
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Register the card through the optional TUI seam.
|
|
115
|
+
*
|
|
116
|
+
* @param ctx - Cordis context.
|
|
117
|
+
* @param diag - lifecycle logger from `./diag.js`.
|
|
118
|
+
*/
|
|
119
|
+
export function applySettingsCard(ctx, diag) {
|
|
120
|
+
let attempts = 0;
|
|
121
|
+
let refusalLogged = false;
|
|
122
|
+
/** Consecutive refusals before the card is considered unavailable. */
|
|
123
|
+
let refusals = 0;
|
|
124
|
+
const tryOnce = () => {
|
|
125
|
+
let sections;
|
|
126
|
+
try {
|
|
127
|
+
sections = ctx.get('tuiSettingsSections', false);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
sections = undefined;
|
|
131
|
+
}
|
|
132
|
+
if (sections !== undefined && sections !== null && typeof sections.register === 'function') {
|
|
133
|
+
try {
|
|
134
|
+
const dispose = sections.register(SETTINGS_SECTION);
|
|
135
|
+
diag(ctx, 'settings section registered');
|
|
136
|
+
ctx.effect(() => () => {
|
|
137
|
+
try {
|
|
138
|
+
if (typeof dispose === 'function') dispose();
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// Best-effort teardown.
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
// THE FIRST REFUSAL IS NOT A VERDICT. The activation context is
|
|
148
|
+
// not live during the first tick, so `register` answers with
|
|
149
|
+
// "requires a live Cordis activation context" and starts working
|
|
150
|
+
// a few ticks later — the same behaviour every plugin in this
|
|
151
|
+
// ecosystem has to tolerate. Log once, keep trying, and only give
|
|
152
|
+
// up after a long run of refusals.
|
|
153
|
+
refusals += 1;
|
|
154
|
+
if (!refusalLogged) {
|
|
155
|
+
refusalLogged = true;
|
|
156
|
+
diag(ctx, `settings section refused (will retry): ${error instanceof Error ? error.message : String(error)}`);
|
|
157
|
+
}
|
|
158
|
+
if (refusals > RETRY_LIMIT) {
|
|
159
|
+
diag(ctx, `settings section unavailable after ${refusals} refusals; the namespace card stays generic`);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
attempts += 1;
|
|
165
|
+
if (attempts > RETRY_LIMIT) return;
|
|
166
|
+
const timer = setTimeout(tryOnce, RETRY_MS);
|
|
167
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
168
|
+
};
|
|
169
|
+
try {
|
|
170
|
+
tryOnce();
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
diag(ctx, `settings card setup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-web-tavily",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Tavily-backed search provider for dsh: registers the `tavily` provider on ctx.web so the web_search tool runs against the Tavily Search API",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/VviLliAm-qwq/dsh-web-tavily.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "lib/index.js",
|
|
12
|
+
"files": [
|
|
13
|
+
"lib",
|
|
14
|
+
"dsh-plugin.json",
|
|
15
|
+
"cordis.patch.yml",
|
|
16
|
+
"README.md",
|
|
17
|
+
"README.zh.md",
|
|
18
|
+
"CHANGELOG.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"dsh": {
|
|
22
|
+
"bundle": {
|
|
23
|
+
"patch": "./cordis.patch.yml"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": "^22.19 || >=24"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
31
|
+
"@deepseek-ai/schemastery": "^3.18.2",
|
|
32
|
+
"@deepseek-ai/dsh-web": "^0.1.2-rc.1",
|
|
33
|
+
"@deepseek-ai/dsh-web-fetch-http": "^0.1.2-rc.1",
|
|
34
|
+
"@deepseek-ai/dsh-credentials": "^0.1.2-rc.1",
|
|
35
|
+
"@deepseek-ai/dsh-launch-environment": "^0.1.2-rc.1",
|
|
36
|
+
"@deepseek-ai/dsh-settings": "^0.1.2-rc.1"
|
|
37
|
+
}
|
|
38
|
+
}
|