opencode-search-plugin 2.0.0 → 2.1.1
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/README.md +49 -16
- package/README.zh-CN.md +48 -15
- package/index.ts +72 -12
- package/package.json +2 -2
- package/plugins/anysearch-search.ts +1 -1
- package/plugins/brave-search.ts +1 -1
- package/plugins/deepseek-search.ts +270 -0
- package/plugins/jina-search.ts +1 -1
- package/plugins/keenable-search.ts +1 -1
- package/plugins/linkup-search.ts +1 -1
- package/plugins/metaso-search.ts +1 -1
- package/plugins/querit-search.ts +1 -1
- package/plugins/serpapi-search.ts +1 -1
- package/plugins/serper-search.ts +1 -1
- package/plugins/youcom-search.ts +1 -1
package/README.md
CHANGED
|
@@ -2,24 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
**English** · [简体中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Eleven self-contained [OpenCode](https://opencode.ai) v2 plugins that register web search providers. Each file stands on its own: copy one into your plugins directory, or install the package to get all eleven.
|
|
6
6
|
|
|
7
|
-
[AnySearch](https://www.anysearch.com/) · [Brave Search](https://brave.com/search/api/) · [Jina](https://jina.ai/) · [Keenable](https://keenable.ai/) · [Linkup](https://www.linkup.so/) · [Metaso](https://metaso.cn/) · [Querit](https://querit.ai/) · [SerpApi](https://serpapi.com/) · [Serper](https://serper.dev/) · [You.com](https://you.com/)
|
|
7
|
+
[AnySearch](https://www.anysearch.com/) · [Brave Search](https://brave.com/search/api/) · [DeepSeek](https://api-docs.deepseek.com/guides/search/) · [Jina](https://jina.ai/) · [Keenable](https://keenable.ai/) · [Linkup](https://www.linkup.so/) · [Metaso](https://metaso.cn/) · [Querit](https://querit.ai/) · [SerpApi](https://serpapi.com/) · [Serper](https://serper.dev/) · [You.com](https://you.com/)
|
|
8
8
|
|
|
9
9
|
## Why this exists
|
|
10
10
|
|
|
11
11
|
OpenCode v2 ships with five web search providers built in: `exa`, `firecrawl`, `parallel`, `tavily` and `tinyfish`. Outside that set, a much wider field of search APIs offers free quotas or free tiers — enough to run an agent's searches without paying for the privilege.
|
|
12
12
|
|
|
13
|
-
This repository adds plugins for
|
|
13
|
+
This repository adds plugins for eleven more of them, taking the pool from five providers to sixteen. AnySearch and Keenable run without a key at all; the other nine bind only when you supply one, so installing this package never routes a query to a provider that would immediately fail. Which of them are worth your time depends on the free tier each one currently offers, so check a provider's pricing before you rely on it.
|
|
14
14
|
|
|
15
15
|
## Install
|
|
16
16
|
|
|
17
|
-
Three equivalent ways to install; pick one. **npm is recommended**: a version published to the registry is immutable, so a pinned `@2.
|
|
17
|
+
Three equivalent ways to install; pick one. **npm is recommended**: a version published to the registry is immutable, so a pinned `@2.1.1` always installs the same bytes, whereas a GitHub tag can in principle be moved by a maintainer to point at a different commit. The first two install the same complete package (all eleven); use the third for one or two files only.
|
|
18
18
|
|
|
19
19
|
### 1. From npm (recommended)
|
|
20
20
|
|
|
21
21
|
```sh
|
|
22
|
-
# add @2.
|
|
22
|
+
# add @2.1.1 to pin
|
|
23
23
|
opencode plugin add opencode-search-plugin
|
|
24
24
|
```
|
|
25
25
|
|
|
@@ -35,7 +35,7 @@ or declare it in `opencode.json(c)`:
|
|
|
35
35
|
### 2. From GitHub
|
|
36
36
|
|
|
37
37
|
```sh
|
|
38
|
-
# add #v2.
|
|
38
|
+
# add #v2.1.1 to pin
|
|
39
39
|
opencode plugin add github:guotree/opencode-search-plugin
|
|
40
40
|
```
|
|
41
41
|
|
|
@@ -67,7 +67,7 @@ For a location that is not auto-discovered, list it explicitly:
|
|
|
67
67
|
|
|
68
68
|
### Skip the services you have no key for
|
|
69
69
|
|
|
70
|
-
Loading the package registers all
|
|
70
|
+
Loading the package registers all eleven, but a provider without a key never joins the search pool — see [Keys](#keys). To keep some out of the registry entirely, use either or both of these optional options:
|
|
71
71
|
|
|
72
72
|
```jsonc
|
|
73
73
|
{
|
|
@@ -80,7 +80,31 @@ Loading the package registers all ten, but a provider without a key never joins
|
|
|
80
80
|
}
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
`disable`
|
|
83
|
+
- `disable` is a deny-list: it never registers the IDs it names — the provider IDs in the table below. AnySearch and Keenable need no key, so `disable` is the only way to keep those two out of the pool.
|
|
84
|
+
- `enable` is an allow-list: only the IDs it names are registered, and everything else is skipped — `"enable": ["osp-brave", "osp-jina"]` runs those two alone.
|
|
85
|
+
|
|
86
|
+
When both are set, **`disable` wins on any overlap**. A per-provider exclusion is never overridden by a bulk allow-list:
|
|
87
|
+
|
|
88
|
+
```jsonc
|
|
89
|
+
{
|
|
90
|
+
"options": {
|
|
91
|
+
"enable": ["osp-brave", "osp-jina", "osp-serper"],
|
|
92
|
+
"disable": ["osp-serper", "osp-youcom"]
|
|
93
|
+
// osp-serper stays out: disable overrides enable.
|
|
94
|
+
// osp-youcom was never allowed in; that entry is inert.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Read as set arithmetic, the registered pool is `enable − disable`. The overlap drops out; a `disable` ID the allow-list never contained was never a candidate to begin with — redundant, but harmless and unreported. The overlap is the only redundancy that earns a warning, and the log names the IDs in it.
|
|
100
|
+
|
|
101
|
+
```text
|
|
102
|
+
enable = {osp-brave, osp-jina, osp-serper, osp-keenable, osp-linkup}
|
|
103
|
+
disable = {osp-keenable, osp-linkup, osp-youcom}
|
|
104
|
+
pool = osp-brave, osp-jina -- enable minus disable
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Configuration that cannot take effect is reported in the log rather than silently dropped: an ID this package does not register (a typo) is named, as is an option that is not an array. An `enable` list that ends up naming no known provider leaves the pool empty — the log says so.
|
|
84
108
|
|
|
85
109
|
## Upgrade
|
|
86
110
|
|
|
@@ -93,7 +117,7 @@ opencode plugin update github:guotree/opencode-search-plugin
|
|
|
93
117
|
opencode plugin update
|
|
94
118
|
```
|
|
95
119
|
|
|
96
|
-
The target is the specifier that was configured: an npm install is named by its package name, a GitHub install answers only to its `github:` form — naming it by its bare package name fails with `Plugin is not configured`. Run `opencode plugin check` first to see what is outdated. An install without a pinned version follows new releases; a pinned one — `@2.
|
|
120
|
+
The target is the specifier that was configured: an npm install is named by its package name, a GitHub install answers only to its `github:` form — naming it by its bare package name fails with `Plugin is not configured`. Run `opencode plugin check` first to see what is outdated. An install without a pinned version follows new releases; a pinned one — `@2.1.1` on npm, `#v2.1.1` on GitHub — stays on its pin until you re-run `plugin add` with a newer pin or edit the specifier in `opencode.json(c)`. A locally copied file is upgraded by copying the new version over the old one.
|
|
97
121
|
|
|
98
122
|
## Uninstall
|
|
99
123
|
|
|
@@ -104,7 +128,7 @@ opencode plugin remove opencode-search-plugin
|
|
|
104
128
|
opencode plugin remove github:guotree/opencode-search-plugin
|
|
105
129
|
```
|
|
106
130
|
|
|
107
|
-
The same rule: pass the specifier you installed with. The command edits the global configuration; if the plugin is instead a `plugins` entry in `opencode.json(c)` or a locally copied file, delete the entry or the copied `.ts` file. Saved `/connect` credentials are untouched — a reinstall finds them again — and to turn off a few providers instead of the whole plugin, use `disable` above.
|
|
131
|
+
The same rule: pass the specifier you installed with. The command edits the global configuration; if the plugin is instead a `plugins` entry in `opencode.json(c)` or a locally copied file, delete the entry or the copied `.ts` file. Saved `/connect` credentials are untouched — a reinstall finds them again — and to turn off a few providers instead of the whole plugin, use `enable`/`disable` above.
|
|
108
132
|
|
|
109
133
|
## Keys
|
|
110
134
|
|
|
@@ -127,12 +151,15 @@ Once keys are in place, pick how OpenCode chooses among them:
|
|
|
127
151
|
|
|
128
152
|
## Providers
|
|
129
153
|
|
|
130
|
-
Every ID below carries an `osp-` prefix — this package's namespace. OpenCode's own providers register bare vendor names (`exa`, `tavily`), and a vendor's official plugin would do the same, so the prefix is what keeps those entries from colliding with ours — both in the websearch pool and in the `/connect` table, where credentials are keyed by integration ID. Use the prefixed form verbatim in `websearch.provider` and in `disable`.
|
|
154
|
+
Every ID below carries an `osp-` prefix — this package's namespace. OpenCode's own providers register bare vendor names (`exa`, `tavily`), and a vendor's official plugin would do the same, so the prefix is what keeps those entries from colliding with ours — both in the websearch pool and in the `/connect` table, where credentials are keyed by integration ID. Use the prefixed form verbatim in `websearch.provider` and in `enable`/`disable`.
|
|
155
|
+
|
|
156
|
+
The namespace also shows on the display name: `/connect` lists each of this package's entries with an `(osp)` marker — `DeepSeek (osp)` beside a built-in `DeepSeek` — so a key saved here cannot be mistaken for a built-in provider's. The websearch pool keeps the plain provider name, since the pool holds only search providers and the collision does not exist there.
|
|
131
157
|
|
|
132
158
|
| Provider | ID | Key (env / `/connect`) | Anonymous | Notes |
|
|
133
159
|
| --- | --- | --- | --- | --- |
|
|
134
160
|
| AnySearch | `osp-anysearch` | `ANYSEARCH_API_KEY` | yes | Free tier per IP; intent router, `cn`/`intl` zones |
|
|
135
161
|
| Brave Search | `osp-brave` | `BRAVE_SEARCH_API_KEY` | no | Independent index, 20 results max |
|
|
162
|
+
| DeepSeek | `osp-deepseek` | `DEEPSEEK_API_KEY` | no | In-house index; $0.75 per 1,000 successful requests; 5 QPS cap |
|
|
136
163
|
| Jina | `osp-jina` | `JINA_API_KEY` | no | Page content is opt-in because it is token-billed |
|
|
137
164
|
| Keenable | `osp-keenable` | `KEENABLE_API_KEY` | yes | 100,000 requests a month with a key; keyless tier is per IP |
|
|
138
165
|
| Linkup | `osp-linkup` | `LINKUP_API_KEY` | no | `flash`/`fast`/`standard`/`deep` reasoning depth |
|
|
@@ -146,7 +173,7 @@ Every ID below carries an `osp-` prefix — this package's namespace. OpenCode's
|
|
|
146
173
|
|
|
147
174
|
Knobs are environment variables, read once when OpenCode starts — change one and restart. Defaults match what each API documents, so leaving them unset is the right starting point.
|
|
148
175
|
|
|
149
|
-
### Shared by all
|
|
176
|
+
### Shared by all eleven
|
|
150
177
|
|
|
151
178
|
| Variable | Default | Meaning |
|
|
152
179
|
| --- | --- | --- |
|
|
@@ -172,6 +199,12 @@ Knobs are environment variables, read once when OpenCode starts — change one a
|
|
|
172
199
|
| `BRAVE_LANG` | — | Result language, e.g. `en`, `zh-hans`, `pt-br` |
|
|
173
200
|
| `BRAVE_FRESHNESS` | — | `pd` (24h), `pw` (7d), `pm` (31d), `py` (365d), or `YYYY-MM-DDtoYYYY-MM-DD` |
|
|
174
201
|
|
|
202
|
+
### DeepSeek
|
|
203
|
+
|
|
204
|
+
| Variable | Default | Meaning |
|
|
205
|
+
| --- | --- | --- |
|
|
206
|
+
| `DEEPSEEK_COUNT` | `10` | Results per search, clamped to 1–50 (DeepSeek's maximum). Price is per request, so more results cost nothing extra |
|
|
207
|
+
|
|
175
208
|
### Jina
|
|
176
209
|
|
|
177
210
|
| Variable | Default | Meaning |
|
|
@@ -253,20 +286,20 @@ Dates are parsed from relative phrases (`3 hours ago`, `3小时前`), absolute f
|
|
|
253
286
|
opencode plugin list
|
|
254
287
|
|
|
255
288
|
# Which providers are actually in the pool?
|
|
256
|
-
# A provider missing here has no key, or
|
|
257
|
-
# AnySearch and Keenable are always present, since neither needs
|
|
289
|
+
# A provider missing here has no key, or `enable`/`disable` kept it out.
|
|
290
|
+
# AnySearch and Keenable are always present, since neither needs a key.
|
|
258
291
|
opencode api get "/api/websearch/provider"
|
|
259
292
|
```
|
|
260
293
|
|
|
261
294
|
Also worth checking:
|
|
262
295
|
|
|
263
|
-
- **A provider is missing from the pool** — it has no key. Run `/connect` or export its variable, then restart; credential changes made in `/connect` rebind without one.
|
|
296
|
+
- **A provider is missing from the pool** — it has no key, or `disable` (or a narrow `enable`) filtered it out. Run `/connect` or export its variable, then restart; credential changes made in `/connect` rebind without one. A name that should be there but is not is often a typo: the log flags unknown IDs and any `enable`/`disable` overlap.
|
|
264
297
|
- **A search fails with `Keenable rate limited the request (HTTP 429)`** — the keyless pool is capped at 1,000 requests an hour and 10 a second per IP, shared with the rest of your egress address. Set `KEENABLE_API_KEY` to draw on your own allowance instead.
|
|
265
298
|
- **A search fails with `Connect X in /connect, or set X_API_KEY`** — the key disappeared between binding and searching. Reconnect or re-export.
|
|
266
299
|
- **Everything is slow or burning credits** — leave `*_CACHE_TTL` at its default. Repeated queries within the window are served from memory.
|
|
267
300
|
- **Nothing happens after editing a plugin file** — OpenCode watches plugins and reloads them; if it does not, `opencode service restart`.
|
|
268
301
|
|
|
269
|
-
Logs live at `~/.local/share/opencode/log/opencode.log`; filter for `loading plugin` to see every plugin OpenCode resolved, with its entrypoint.
|
|
302
|
+
Logs live at `~/.local/share/opencode/log/opencode.log`; filter for `loading plugin` to see every plugin OpenCode resolved, with its entrypoint. Warnings this plugin emits about `enable`/`disable` land in the same file, carrying a `[opencode-search-plugin]` prefix.
|
|
270
303
|
|
|
271
304
|
## Development
|
|
272
305
|
|
package/README.zh-CN.md
CHANGED
|
@@ -2,24 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
# opencode-search-plugin
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
十一个相互独立的 [OpenCode](https://opencode.ai) v2 插件,各自注册一个网页搜索 provider。每个文件都能单独使用:复制一个进你的插件目录,或者安装这个包一次拿全十一个。
|
|
6
6
|
|
|
7
|
-
[AnySearch](https://www.anysearch.com/) · [Brave Search](https://brave.com/search/api/) · [Jina](https://jina.ai/) · [Keenable](https://keenable.ai/) · [Linkup](https://www.linkup.so/) · [Metaso](https://metaso.cn/) · [Querit](https://querit.ai/) · [SerpApi](https://serpapi.com/) · [Serper](https://serper.dev/) · [You.com](https://you.com/)
|
|
7
|
+
[AnySearch](https://www.anysearch.com/) · [Brave Search](https://brave.com/search/api/) · [DeepSeek](https://api-docs.deepseek.com/guides/search/) · [Jina](https://jina.ai/) · [Keenable](https://keenable.ai/) · [Linkup](https://www.linkup.so/) · [Metaso](https://metaso.cn/) · [Querit](https://querit.ai/) · [SerpApi](https://serpapi.com/) · [Serper](https://serper.dev/) · [You.com](https://you.com/)
|
|
8
8
|
|
|
9
9
|
## 为什么做这个插件
|
|
10
10
|
|
|
11
11
|
OpenCode v2 内置了五个网页搜索 provider:`exa`、`firecrawl`、`parallel`、`tavily`、`tinyfish`。在这个范围之外,还有大量搜索 API 提供免费额度或免费层级,足以支撑 agent 的日常检索而不必为此付费。
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
本仓库为其中十一家补上了插件,把可用池从五个 provider 扩到十六个。AnySearch 和 Keenable 无需任何 key 即可使用;其余九家只在你提供 key 之后才会绑定,因此装上这个包也不会把查询路由到一个注定失败的服务上。至于哪几家值得用,取决于它们各自当前的免费额度,长期依赖某家之前建议先确认它的定价。
|
|
14
14
|
|
|
15
15
|
## 安装
|
|
16
16
|
|
|
17
|
-
三种方式等价,任选其一。**推荐 npm**:已发布到 registry 的版本不可变,固定 `@2.
|
|
17
|
+
三种方式等价,任选其一。**推荐 npm**:已发布到 registry 的版本不可变,固定 `@2.1.1` 后每次安装都拿到同一份字节;GitHub 的 tag 在理论上可以被维护者移动、指向别的 commit。前两种装的是同一个完整包(十一个全有),只想用一两个就用第三种。
|
|
18
18
|
|
|
19
19
|
### 1. 从 npm 安装(推荐)
|
|
20
20
|
|
|
21
21
|
```sh
|
|
22
|
-
# 追加 @2.
|
|
22
|
+
# 追加 @2.1.1 可固定到该版本
|
|
23
23
|
opencode plugin add opencode-search-plugin
|
|
24
24
|
```
|
|
25
25
|
|
|
@@ -35,7 +35,7 @@ opencode plugin add opencode-search-plugin
|
|
|
35
35
|
### 2. 从 GitHub 安装
|
|
36
36
|
|
|
37
37
|
```sh
|
|
38
|
-
# 追加 #v2.
|
|
38
|
+
# 追加 #v2.1.1 可固定到该 tag
|
|
39
39
|
opencode plugin add github:guotree/opencode-search-plugin
|
|
40
40
|
```
|
|
41
41
|
|
|
@@ -67,7 +67,7 @@ opencode plugin add github:guotree/opencode-search-plugin
|
|
|
67
67
|
|
|
68
68
|
### 跳过你没有 key 的服务
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
装包会注册全部十一家,但没有 key 的 provider 永远不会进入搜索池——见下文[密钥](#密钥)。想让它们连注册表都不出现,可以单独或同时使用下面两个可选选项:
|
|
71
71
|
|
|
72
72
|
```jsonc
|
|
73
73
|
{
|
|
@@ -80,7 +80,31 @@ opencode plugin add github:guotree/opencode-search-plugin
|
|
|
80
80
|
}
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
`disable`
|
|
83
|
+
- `disable` 是黑名单:它列出的 provider ID(见下文[支持的服务](#支持的服务)的表格)永远不会注册。AnySearch 和 Keenable 不需要 key,想把这两家也排除在搜索池之外,只能用 `disable`。
|
|
84
|
+
- `enable` 是白名单:只注册列出的 ID,其余一律跳过——`"enable": ["osp-brave", "osp-jina"]` 表示只跑这两家。
|
|
85
|
+
|
|
86
|
+
两者同时配置时,**交集由 `disable` 胜出**:逐家的排除绝不会被批量白名单覆盖。
|
|
87
|
+
|
|
88
|
+
```jsonc
|
|
89
|
+
{
|
|
90
|
+
"options": {
|
|
91
|
+
"enable": ["osp-brave", "osp-jina", "osp-serper"],
|
|
92
|
+
"disable": ["osp-serper", "osp-youcom"]
|
|
93
|
+
// osp-serper 仍然不注册:disable 压过 enable。
|
|
94
|
+
// osp-youcom 本来就不在白名单里,这条是冗余的。
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
用集合运算来读:实际注册的池 = `enable − disable`。交集被剔除;`disable` 里不在 `enable` 中的 ID 从一开始就不在候选内——属于无害的冗余,不会被报告。唯一会触发警告的冗余是交集,日志会点出其中的 ID。
|
|
100
|
+
|
|
101
|
+
```text
|
|
102
|
+
enable = {osp-brave, osp-jina, osp-serper, osp-keenable, osp-linkup}
|
|
103
|
+
disable = {osp-keenable, osp-linkup, osp-youcom}
|
|
104
|
+
pool = osp-brave, osp-jina -- enable 减 disable
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
无法生效的配置会在日志里提示,而不是被悄悄丢弃:写错的 ID(本包没注册的名字)会被点名,选项不是数组也会被点名。`enable` 如果最终一个已知 provider 都没匹配上,搜索池会是空的——日志里会写明这一点。
|
|
84
108
|
|
|
85
109
|
## 升级
|
|
86
110
|
|
|
@@ -93,7 +117,7 @@ opencode plugin update github:guotree/opencode-search-plugin
|
|
|
93
117
|
opencode plugin update
|
|
94
118
|
```
|
|
95
119
|
|
|
96
|
-
升级目标用的是配置时的说明符:npm 安装用包名,GitHub 安装只认自己的 `github:` 形式——用裸包名会报 `Plugin is not configured`。先跑 `opencode plugin check` 查看有哪些过期。未固定版本的安装会跟上新版本;固定过的——npm 形式的 `@2.
|
|
120
|
+
升级目标用的是配置时的说明符:npm 安装用包名,GitHub 安装只认自己的 `github:` 形式——用裸包名会报 `Plugin is not configured`。先跑 `opencode plugin check` 查看有哪些过期。未固定版本的安装会跟上新版本;固定过的——npm 形式的 `@2.1.1`、GitHub 形式的 `#v2.1.1`——会停在原地,直到你用新 pin 重新执行 `plugin add`,或直接修改 `opencode.json(c)` 里的说明符。本地复制的文件则用新版本覆盖旧文件来升级。
|
|
97
121
|
|
|
98
122
|
## 卸载
|
|
99
123
|
|
|
@@ -104,7 +128,7 @@ opencode plugin remove opencode-search-plugin
|
|
|
104
128
|
opencode plugin remove github:guotree/opencode-search-plugin
|
|
105
129
|
```
|
|
106
130
|
|
|
107
|
-
同上:传入安装时使用的说明符。该命令修改的是全局配置;若插件是写在 `opencode.json(c)` 的 `plugins` 条目里或本地复制的文件,删掉对应条目、或删掉复制的 `.ts` 文件即可。`/connect` 里保存的密钥不受影响——重新装回就能继续用——只想关掉其中几家而不是整个插件,用上文的 `disable`。
|
|
131
|
+
同上:传入安装时使用的说明符。该命令修改的是全局配置;若插件是写在 `opencode.json(c)` 的 `plugins` 条目里或本地复制的文件,删掉对应条目、或删掉复制的 `.ts` 文件即可。`/connect` 里保存的密钥不受影响——重新装回就能继续用——只想关掉其中几家而不是整个插件,用上文的 `enable`/`disable`。
|
|
108
132
|
|
|
109
133
|
## 密钥
|
|
110
134
|
|
|
@@ -127,12 +151,15 @@ AnySearch 和 Keenable 是例外。两家都有匿名层级,因此始终注册
|
|
|
127
151
|
|
|
128
152
|
## 支持的服务
|
|
129
153
|
|
|
130
|
-
下表每个 ID 都带 `osp-` 前缀——这是本包的命名空间。OpenCode 内置 provider 用的是裸厂商名(`exa`、`tavily`),厂商自己的插件也会注册裸名(Keenable 就已经有一个),所以前缀是避免撞名的唯一保障:既避开搜索池里的重复,也避开 `/connect` 里按 integration ID 存凭据的那张表。在 `websearch.provider` 和 `disable` 里请原样使用带前缀的 ID。
|
|
154
|
+
下表每个 ID 都带 `osp-` 前缀——这是本包的命名空间。OpenCode 内置 provider 用的是裸厂商名(`exa`、`tavily`),厂商自己的插件也会注册裸名(Keenable 就已经有一个),所以前缀是避免撞名的唯一保障:既避开搜索池里的重复,也避开 `/connect` 里按 integration ID 存凭据的那张表。在 `websearch.provider` 和 `enable`/`disable` 里请原样使用带前缀的 ID。
|
|
155
|
+
|
|
156
|
+
命名空间同样体现在显示名上:`/connect` 里本包的每个条目都带 `(osp)` 标记——内置的 `DeepSeek` 旁边是 `DeepSeek (osp)`——存在这里的 key 不会和内置 provider 的混淆。搜索池里仍用纯品牌名,因为池中只有搜索 provider,不存在撞名问题。
|
|
131
157
|
|
|
132
158
|
| 服务 | ID | 密钥(环境变量 / `/connect`) | 匿名可用 | 说明 |
|
|
133
159
|
| --- | --- | --- | --- | --- |
|
|
134
160
|
| AnySearch | `osp-anysearch` | `ANYSEARCH_API_KEY` | 是 | 按 IP 的免费额度;意图路由,`cn` / `intl` 分区 |
|
|
135
161
|
| Brave Search | `osp-brave` | `BRAVE_SEARCH_API_KEY` | 否 | 独立索引,最多 20 条结果 |
|
|
162
|
+
| DeepSeek | `osp-deepseek` | `DEEPSEEK_API_KEY` | 否 | 自研搜索引擎;每千次成功请求 5 元;限速 5 QPS |
|
|
136
163
|
| Jina | `osp-jina` | `JINA_API_KEY` | 否 | 页面正文按 token 计费,因此默认不取 |
|
|
137
164
|
| Keenable | `osp-keenable` | `KEENABLE_API_KEY` | 是 | 有 key 每月 100,000 次免费请求;无 key 层级按 IP 限速 |
|
|
138
165
|
| Linkup | `osp-linkup` | `LINKUP_API_KEY` | 否 | `flash` / `fast` / `standard` / `deep` 四档推理深度 |
|
|
@@ -146,7 +173,7 @@ AnySearch 和 Keenable 是例外。两家都有匿名层级,因此始终注册
|
|
|
146
173
|
|
|
147
174
|
所有配置项都是环境变量,OpenCode 启动时读取一次——改了要重启。默认值取自各家 API 文档,所以一开始什么都不设就是合理的起点。
|
|
148
175
|
|
|
149
|
-
###
|
|
176
|
+
### 十一家通用
|
|
150
177
|
|
|
151
178
|
| 变量 | 默认值 | 含义 |
|
|
152
179
|
| --- | --- | --- |
|
|
@@ -172,6 +199,12 @@ AnySearch 和 Keenable 是例外。两家都有匿名层级,因此始终注册
|
|
|
172
199
|
| `BRAVE_LANG` | — | 结果语言,例如 `en`、`zh-hans`、`pt-br` |
|
|
173
200
|
| `BRAVE_FRESHNESS` | — | `pd`(24 小时)、`pw`(7 天)、`pm`(31 天)、`py`(365 天),或 `YYYY-MM-DDtoYYYY-MM-DD` |
|
|
174
201
|
|
|
202
|
+
### DeepSeek
|
|
203
|
+
|
|
204
|
+
| 变量 | 默认值 | 含义 |
|
|
205
|
+
| --- | --- | --- |
|
|
206
|
+
| `DEEPSEEK_COUNT` | `10` | 每次搜索的结果数,限制在 1–50(DeepSeek 的上限)。按请求计费,条数增多不加价 |
|
|
207
|
+
|
|
175
208
|
### Jina
|
|
176
209
|
|
|
177
210
|
| 变量 | 默认值 | 含义 |
|
|
@@ -253,20 +286,20 @@ AnySearch 和 Keenable 是例外。两家都有匿名层级,因此始终注册
|
|
|
253
286
|
opencode plugin list
|
|
254
287
|
|
|
255
288
|
# 搜索池里到底有哪些 provider?
|
|
256
|
-
# 这里少了一家,说明它没有 key
|
|
289
|
+
# 这里少了一家,说明它没有 key,或者被 `enable`/`disable` 排除了。
|
|
257
290
|
# AnySearch 和 Keenable 永远在列,因为它们都不需要 key。
|
|
258
291
|
opencode api get "/api/websearch/provider"
|
|
259
292
|
```
|
|
260
293
|
|
|
261
294
|
另外几个值得检查的地方:
|
|
262
295
|
|
|
263
|
-
- **某家 provider 不在池里** —— 它没有 key
|
|
296
|
+
- **某家 provider 不在池里** —— 它没有 key,或者被 `disable`(或收窄后的 `enable`)过滤掉了。执行 `/connect` 或导出对应环境变量,然后重启;在 `/connect` 里做的凭据变更会自动重绑,不需要重启。本该在却不在的名字多半是拼写错误:日志会点出无法识别的 ID 和 `enable`/`disable` 的交集。
|
|
264
297
|
- **搜索报错 `Keenable rate limited the request (HTTP 429)`** —— 无 key 层级每个 IP 每小时 1,000 次、每秒 10 次,且与同一出口地址上的其他人共用。改用 `KEENABLE_API_KEY` 就会记到你自己的额度上。
|
|
265
298
|
- **搜索报错 `Connect X in /connect, or set X_API_KEY`** —— key 在绑定和搜索之间消失了。重新连接或重新导出。
|
|
266
299
|
- **一切都很慢、或者积分烧得飞快** —— 把 `*_CACHE_TTL` 保持在默认值。时间窗内的重复查询由内存直接返回。
|
|
267
300
|
- **改了插件文件却没反应** —— OpenCode 会监视插件并自动重载;如果没有,执行 `opencode service restart`。
|
|
268
301
|
|
|
269
|
-
日志在 `~/.local/share/opencode/log/opencode.log`;过滤 `loading plugin` 可以看到 OpenCode
|
|
302
|
+
日志在 `~/.local/share/opencode/log/opencode.log`;过滤 `loading plugin` 可以看到 OpenCode 解析到的每个插件及其入口文件。本插件关于 `enable`/`disable` 的警告也落在同一个文件里,带 `[opencode-search-plugin]` 前缀。
|
|
270
303
|
|
|
271
304
|
## 开发
|
|
272
305
|
|
package/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { Scope } from "effect"
|
|
|
4
4
|
|
|
5
5
|
import { effect as anysearch } from "./plugins/anysearch-search"
|
|
6
6
|
import { effect as brave } from "./plugins/brave-search"
|
|
7
|
+
import { effect as deepseek } from "./plugins/deepseek-search"
|
|
7
8
|
import { effect as jina } from "./plugins/jina-search"
|
|
8
9
|
import { effect as keenable } from "./plugins/keenable-search"
|
|
9
10
|
import { effect as linkup } from "./plugins/linkup-search"
|
|
@@ -16,18 +17,19 @@ import { effect as youcom } from "./plugins/youcom-search"
|
|
|
16
17
|
type ProviderEffect = (ctx: Plugin.Context) => Effect.Effect<void, never, Scope.Scope>
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
|
-
* The
|
|
20
|
+
* The eleven providers this package registers, in alphabetical order. Each entry
|
|
20
21
|
* is the same effect its standalone file uses as a default export, so a file
|
|
21
22
|
* copied out of this repository behaves exactly as it does here.
|
|
22
23
|
*
|
|
23
24
|
* The key of each entry is the websearch provider ID -- the one that appears in
|
|
24
|
-
* `/api/websearch/provider`, in `websearch.provider`, and in the `
|
|
25
|
-
*
|
|
26
|
-
* prefixed `osp-` to namespace it to this package; see CONTRIBUTING.md.
|
|
25
|
+
* `/api/websearch/provider`, in `websearch.provider`, and in the `enable` and
|
|
26
|
+
* `disable` options below. Renaming one would strand existing configuration.
|
|
27
|
+
* Each is prefixed `osp-` to namespace it to this package; see CONTRIBUTING.md.
|
|
27
28
|
*/
|
|
28
29
|
const PROVIDERS: ReadonlyArray<readonly [id: string, run: ProviderEffect]> = [
|
|
29
30
|
["osp-anysearch", anysearch],
|
|
30
31
|
["osp-brave", brave],
|
|
32
|
+
["osp-deepseek", deepseek],
|
|
31
33
|
["osp-jina", jina],
|
|
32
34
|
["osp-keenable", keenable],
|
|
33
35
|
["osp-linkup", linkup],
|
|
@@ -38,24 +40,82 @@ const PROVIDERS: ReadonlyArray<readonly [id: string, run: ProviderEffect]> = [
|
|
|
38
40
|
["osp-youcom", youcom],
|
|
39
41
|
]
|
|
40
42
|
|
|
43
|
+
/** Every ID this package registers, so a typo in an option can be named. */
|
|
44
|
+
const KNOWN_IDS: ReadonlySet<string> = new Set(PROVIDERS.map(([id]) => id))
|
|
45
|
+
|
|
46
|
+
const WARNING = "[opencode-search-plugin]"
|
|
47
|
+
|
|
41
48
|
/**
|
|
42
|
-
* Reads the `disable` plugin option
|
|
43
|
-
*
|
|
44
|
-
*
|
|
49
|
+
* Reads the `enable` or `disable` plugin option: an array of provider IDs.
|
|
50
|
+
* Anything that cannot take effect is reported on stderr rather than acted on
|
|
51
|
+
* -- a wrong shape is ignored outright, and an ID naming no provider here is
|
|
52
|
+
* dropped. A typo in a knob must not cost the user every provider, but it must
|
|
53
|
+
* not vanish silently either: `disable` shrinks the pool by one entry when an
|
|
54
|
+
* ID misses, while `enable` can shrink it to nothing.
|
|
45
55
|
*/
|
|
46
|
-
function
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
56
|
+
function readOption(
|
|
57
|
+
ctx: Plugin.Context,
|
|
58
|
+
option: "enable" | "disable",
|
|
59
|
+
): ReadonlySet<string> | undefined {
|
|
60
|
+
const requested: unknown = ctx.options[option]
|
|
61
|
+
if (requested === undefined) return undefined
|
|
62
|
+
if (!Array.isArray(requested)) {
|
|
63
|
+
console.warn(`${WARNING} ${option} must be an array of provider IDs; ignoring it`)
|
|
64
|
+
return undefined
|
|
65
|
+
}
|
|
66
|
+
const ids = new Set(requested.filter((entry): entry is string => typeof entry === "string"))
|
|
67
|
+
const unknown = [...ids].filter((id) => !KNOWN_IDS.has(id))
|
|
68
|
+
if (unknown.length > 0) {
|
|
69
|
+
console.warn(
|
|
70
|
+
`${WARNING} ${option} names no provider this package registers: ${unknown.join(", ")}`,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
return ids
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolves both options into the sets the registration loop reads. `undefined`
|
|
78
|
+
* from `readOption` means "no opinion": without `enable` every provider is a
|
|
79
|
+
* candidate, and without `disable` nothing is vetoed, so each option on its
|
|
80
|
+
* own behaves exactly as it did before the other existed.
|
|
81
|
+
*/
|
|
82
|
+
function registrationOptions(ctx: Plugin.Context): {
|
|
83
|
+
readonly allowed: ReadonlySet<string> | undefined
|
|
84
|
+
readonly disabled: ReadonlySet<string>
|
|
85
|
+
} {
|
|
86
|
+
const allowed = readOption(ctx, "enable")
|
|
87
|
+
const disabled = readOption(ctx, "disable") ?? new Set<string>()
|
|
88
|
+
|
|
89
|
+
// Deny wins on the overlap: a provider `disable` names stays out even when
|
|
90
|
+
// `enable` lists it, so a per-provider exclusion is never overridden by a
|
|
91
|
+
// bulk allow-list. The overlap is named because the config is redundant,
|
|
92
|
+
// not fatal -- loading continues under the deny.
|
|
93
|
+
const overlap = allowed ? [...allowed].filter((id) => disabled.has(id)) : []
|
|
94
|
+
if (overlap.length > 0) {
|
|
95
|
+
console.warn(
|
|
96
|
+
`${WARNING} disable overrides enable for: ${overlap.join(", ")} -- those providers stay out`,
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// An allow-list that reaches no known provider registers nothing at all. The
|
|
101
|
+
// registration loop stays faithful to that, but the reason belongs in the
|
|
102
|
+
// log: an empty search pool is otherwise indistinguishable from a loaded
|
|
103
|
+
// plugin that silently did nothing.
|
|
104
|
+
if (allowed !== undefined && ![...allowed].some((id) => KNOWN_IDS.has(id))) {
|
|
105
|
+
console.warn(`${WARNING} enable lists no known provider; the search pool will be empty`)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { allowed, disabled }
|
|
50
109
|
}
|
|
51
110
|
|
|
52
111
|
export default Plugin.define({
|
|
53
112
|
id: "opencode-search-plugin",
|
|
54
113
|
effect: (ctx) =>
|
|
55
114
|
Effect.gen(function* () {
|
|
56
|
-
const disabled =
|
|
115
|
+
const { allowed, disabled } = registrationOptions(ctx)
|
|
57
116
|
for (const [id, run] of PROVIDERS) {
|
|
58
117
|
if (disabled.has(id)) continue
|
|
118
|
+
if (allowed !== undefined && !allowed.has(id)) continue
|
|
59
119
|
yield* run(ctx)
|
|
60
120
|
}
|
|
61
121
|
}),
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-search-plugin",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.1.1",
|
|
4
|
+
"description": "Eleven self-contained websearch provider plugins for OpenCode v2: AnySearch, Brave, DeepSeek, Jina, Keenable, Linkup, Metaso, Querit, SerpApi, Serper and You.com.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
@@ -113,7 +113,7 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
113
113
|
// Make "AnySearch" show up in /connect with an API-key method.
|
|
114
114
|
yield* ctx.integration.transform((editor) => {
|
|
115
115
|
editor.update(ID, (integration) => {
|
|
116
|
-
integration.name = "AnySearch"
|
|
116
|
+
integration.name = "AnySearch (osp)"
|
|
117
117
|
})
|
|
118
118
|
editor.method.update({
|
|
119
119
|
integrationID: ID,
|
package/plugins/brave-search.ts
CHANGED
|
@@ -148,7 +148,7 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
148
148
|
// Make "Brave Search" show up in /connect with an API-key method.
|
|
149
149
|
yield* ctx.integration.transform((editor) => {
|
|
150
150
|
editor.update(ID, (integration) => {
|
|
151
|
-
integration.name = "Brave Search"
|
|
151
|
+
integration.name = "Brave Search (osp)"
|
|
152
152
|
})
|
|
153
153
|
editor.method.update({
|
|
154
154
|
integrationID: ID,
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin/effect"
|
|
2
|
+
import { Effect, Schema, Stream } from "effect"
|
|
3
|
+
|
|
4
|
+
// DeepSeek opened the search engine behind its own app to developers on
|
|
5
|
+
// 2026-10-17. Billing is per successful request -- not per token, not per
|
|
6
|
+
// returned result -- so `count` costs nothing extra and every repeat query the
|
|
7
|
+
// cache answers is a request not billed at all. The content field is
|
|
8
|
+
// `summary`: the API documents it as 600-800 characters "intended to be read
|
|
9
|
+
// by a model", which is exactly what an agent consumes. `content` would only
|
|
10
|
+
// fill with whole-page text under `need_content`, which bloats the agent's
|
|
11
|
+
// context for no gain. Fields absent from a hit map the way the other
|
|
12
|
+
// providers map them: a missing title falls back to the URL, a missing
|
|
13
|
+
// summary to an empty string.
|
|
14
|
+
const DeepSeekResponse = Schema.Struct({
|
|
15
|
+
results: Schema.optional(
|
|
16
|
+
Schema.Array(
|
|
17
|
+
Schema.Struct({
|
|
18
|
+
title: Schema.optional(Schema.String),
|
|
19
|
+
url: Schema.String,
|
|
20
|
+
summary: Schema.optional(Schema.String),
|
|
21
|
+
// RFC 3339 in UTC, or null when the publication time is unknown.
|
|
22
|
+
date: Schema.optional(Schema.NullOr(Schema.String)),
|
|
23
|
+
}),
|
|
24
|
+
),
|
|
25
|
+
),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
// The /connect integration ID, and the websearch provider ID it binds to.
|
|
29
|
+
// The `osp-` prefix is this package's namespace: OpenCode's built-ins and a
|
|
30
|
+
// vendor plugin's own entry are bare vendor names, so `deepseek` would collide
|
|
31
|
+
// with them, and with the opencode.db row keyed by integration ID.
|
|
32
|
+
const ID = "osp-deepseek"
|
|
33
|
+
|
|
34
|
+
function envInt(name: string, fallback: number): number {
|
|
35
|
+
const raw = (process.env[name] ?? "").trim()
|
|
36
|
+
if (raw === "") return fallback
|
|
37
|
+
const value = Number(raw)
|
|
38
|
+
return Number.isFinite(value) && value >= 0 ? value : fallback
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Search engines hand out dates in three flavours: relative ("28 minutes ago"),
|
|
42
|
+
// absolute ("Sep 30, 2026" in the US, "31 Mar 2026" elsewhere), and non-English
|
|
43
|
+
// relative ("4天前"). Relative values are rounded upstream, so month/year use
|
|
44
|
+
// 30/365 days. ISO 8601 timestamps are accepted as-is -- DeepSeek emits only
|
|
45
|
+
// that shape, RFC 3339 with a time component.
|
|
46
|
+
// See https://serpapi.com/blog/how-to-parse-dates-from-google-search/
|
|
47
|
+
const RELATIVE_MS: Record<string, number> = {
|
|
48
|
+
minute: 60_000,
|
|
49
|
+
hour: 3_600_000,
|
|
50
|
+
day: 86_400_000,
|
|
51
|
+
week: 604_800_000,
|
|
52
|
+
month: 2_592_000_000,
|
|
53
|
+
year: 31_536_000_000,
|
|
54
|
+
分钟: 60_000,
|
|
55
|
+
小时: 3_600_000,
|
|
56
|
+
天: 86_400_000,
|
|
57
|
+
周: 604_800_000,
|
|
58
|
+
个月: 2_592_000_000,
|
|
59
|
+
月: 2_592_000_000,
|
|
60
|
+
年: 31_536_000_000,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const RELATIVE_DATE =
|
|
64
|
+
/^(\d+)\s*(minute|hour|day|week|month|year|分钟|小时|天|周|个月|月|年)s?\s*(?:ago|前)$/i
|
|
65
|
+
|
|
66
|
+
const ABSOLUTE_DATE = [
|
|
67
|
+
/^\d{1,2}\s+[A-Za-z]{3,9}\s+\d{4}$/, // "3 Jan 2022", "31 Mar 2026"
|
|
68
|
+
/^[A-Za-z]{3,9}\s+\d{1,2},?\s+\d{4}$/, // "Sep 30, 2026", "Jan 27, 2025"
|
|
69
|
+
/^\d{4}-\d{2}-\d{2}$/, // "2026-10-16"
|
|
70
|
+
// ISO 8601 with a time component, which some providers return verbatim.
|
|
71
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/,
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
function parseSearchDate(value: string | undefined): number {
|
|
75
|
+
const text = value?.trim()
|
|
76
|
+
if (!text) return NaN
|
|
77
|
+
|
|
78
|
+
const relative = RELATIVE_DATE.exec(text)
|
|
79
|
+
if (relative) {
|
|
80
|
+
const unit = relative[2] !== undefined ? RELATIVE_MS[relative[2].toLowerCase()] : undefined
|
|
81
|
+
const amount = Number(relative[1])
|
|
82
|
+
if (unit === undefined || !Number.isFinite(amount)) return NaN
|
|
83
|
+
return Date.now() - amount * unit
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Only trust Date.parse on shapes these providers actually emit -- its lenient
|
|
87
|
+
// parser invents a year for strings like "Nov 1", which would date the result
|
|
88
|
+
// to 2001.
|
|
89
|
+
return ABSOLUTE_DATE.some((shape) => shape.test(text)) ? Date.parse(text) : NaN
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function toResult(item: {
|
|
93
|
+
title?: string | undefined
|
|
94
|
+
url: string
|
|
95
|
+
summary?: string | undefined
|
|
96
|
+
date?: string | null | undefined
|
|
97
|
+
}) {
|
|
98
|
+
const published = parseSearchDate(item.date ?? undefined)
|
|
99
|
+
return {
|
|
100
|
+
url: item.url,
|
|
101
|
+
title: item.title || item.url,
|
|
102
|
+
content: item.summary ?? "",
|
|
103
|
+
time: Number.isFinite(published) ? { published } : {},
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
type SearchResult = ReturnType<typeof toResult>
|
|
108
|
+
|
|
109
|
+
// DeepSeek charges per successful search and has no anonymous tier. A repeat
|
|
110
|
+
// query is a fresh billed dispatch, so responses are cached per normalized
|
|
111
|
+
// query. Knobs are read once at load time, restart after changing them.
|
|
112
|
+
//
|
|
113
|
+
// DEEPSEEK_CACHE_TTL ms to reuse a response for the same query (0 disables)
|
|
114
|
+
// DEEPSEEK_COUNT results per search, clamped to 1-50
|
|
115
|
+
// DEEPSEEK_API_KEY required; from https://platform.deepseek.com
|
|
116
|
+
const CACHE_TTL = envInt("DEEPSEEK_CACHE_TTL", 600_000)
|
|
117
|
+
const COUNT = Math.min(Math.max(envInt("DEEPSEEK_COUNT", 10), 1), 50)
|
|
118
|
+
|
|
119
|
+
const CACHE_LIMIT = 200
|
|
120
|
+
const cache = new Map<string, { at: number; results: SearchResult[] }>()
|
|
121
|
+
|
|
122
|
+
function cacheKey(query: string): string {
|
|
123
|
+
return query.trim().replace(/\s+/g, " ")
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function readCache(queryKey: string): SearchResult[] | undefined {
|
|
127
|
+
if (CACHE_TTL <= 0) return undefined
|
|
128
|
+
const hit = cache.get(queryKey)
|
|
129
|
+
return hit && Date.now() - hit.at < CACHE_TTL ? hit.results : undefined
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function writeCache(queryKey: string, results: SearchResult[]): void {
|
|
133
|
+
if (CACHE_TTL <= 0) return
|
|
134
|
+
cache.set(queryKey, { at: Date.now(), results })
|
|
135
|
+
if (cache.size > CACHE_LIMIT) {
|
|
136
|
+
const oldest = cache.keys().next().value
|
|
137
|
+
if (oldest !== undefined) cache.delete(oldest)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const effect = (ctx: Plugin.Context) =>
|
|
142
|
+
Effect.gen(function* () {
|
|
143
|
+
// Make "DeepSeek" show up in /connect with an API-key method.
|
|
144
|
+
yield* ctx.integration.transform((editor) => {
|
|
145
|
+
editor.update(ID, (integration) => {
|
|
146
|
+
integration.name = "DeepSeek (osp)"
|
|
147
|
+
})
|
|
148
|
+
editor.method.update({
|
|
149
|
+
integrationID: ID,
|
|
150
|
+
method: { type: "key", label: "DeepSeek API key" },
|
|
151
|
+
})
|
|
152
|
+
editor.method.update({
|
|
153
|
+
integrationID: ID,
|
|
154
|
+
method: { type: "env", names: ["DEEPSEEK_API_KEY"] },
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// The host instantiates this API's failure channel as `unknown`, which would
|
|
159
|
+
// make the whole plugin effect unrunnable. A credential read must never take
|
|
160
|
+
// the plugin down: a failed read degrades to "no stored key", and the
|
|
161
|
+
// environment fallback below applies exactly as it does when the credential
|
|
162
|
+
// simply does not exist.
|
|
163
|
+
const soften = <A>(read: Effect.Effect<A, unknown>): Effect.Effect<A | undefined, never> =>
|
|
164
|
+
read.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
165
|
+
|
|
166
|
+
// The key comes from the /connect credential, falling back to the
|
|
167
|
+
// environment. Resolved per call so a rotated key needs no restart.
|
|
168
|
+
const resolveKey = () =>
|
|
169
|
+
Effect.gen(function* () {
|
|
170
|
+
const connection = yield* soften(ctx.integration.connection.active(ID))
|
|
171
|
+
const credential = connection
|
|
172
|
+
? yield* soften(ctx.integration.connection.resolve(connection))
|
|
173
|
+
: undefined
|
|
174
|
+
return credential?.type === "key" ? credential.key : process.env.DEEPSEEK_API_KEY
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// DeepSeek is bound to websearch only while a key exists. Deleting the key
|
|
178
|
+
// unbinds the provider, so `random` can never pick a keyless DeepSeek.
|
|
179
|
+
// The transform replays on reload and reads `bound` as it stands then.
|
|
180
|
+
let bound = Boolean(yield* resolveKey())
|
|
181
|
+
|
|
182
|
+
yield* ctx.websearch.transform((editor) => {
|
|
183
|
+
if (!bound) return
|
|
184
|
+
editor.add({
|
|
185
|
+
id: ID,
|
|
186
|
+
name: "DeepSeek",
|
|
187
|
+
execute: ({ query }) =>
|
|
188
|
+
Effect.gen(function* () {
|
|
189
|
+
const queryKey = cacheKey(query)
|
|
190
|
+
const cached = readCache(queryKey)
|
|
191
|
+
if (cached) return cached
|
|
192
|
+
|
|
193
|
+
// Rechecked per call: binding can lag a key deletion by the
|
|
194
|
+
// event round-trip, so this keeps that window explicit.
|
|
195
|
+
const key = yield* resolveKey()
|
|
196
|
+
if (!key) {
|
|
197
|
+
return yield* Effect.fail(
|
|
198
|
+
new Error("Connect DeepSeek in /connect, or set DEEPSEEK_API_KEY"),
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const response = yield* Effect.tryPromise({
|
|
203
|
+
try: () =>
|
|
204
|
+
fetch("https://api.deepseek.com/v1/tools/search", {
|
|
205
|
+
method: "POST",
|
|
206
|
+
headers: {
|
|
207
|
+
Accept: "application/json",
|
|
208
|
+
"Content-Type": "application/json",
|
|
209
|
+
Authorization: "Bearer " + key,
|
|
210
|
+
},
|
|
211
|
+
body: JSON.stringify({
|
|
212
|
+
query: queryKey,
|
|
213
|
+
count: COUNT,
|
|
214
|
+
}),
|
|
215
|
+
}),
|
|
216
|
+
catch: (error) => new Error("DeepSeek request failed: " + String(error)),
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
if (!response.ok) {
|
|
220
|
+
const detail = yield* Effect.tryPromise({
|
|
221
|
+
try: () => response.text(),
|
|
222
|
+
catch: () => new Error("response body unavailable"),
|
|
223
|
+
})
|
|
224
|
+
return yield* Effect.fail(
|
|
225
|
+
new Error("DeepSeek API error " + response.status + ": " + detail),
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const json = yield* Effect.tryPromise({
|
|
230
|
+
try: () => response.json(),
|
|
231
|
+
catch: (error) => new Error("Invalid DeepSeek response: " + String(error)),
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
const body = yield* Schema.decodeUnknownEffect(DeepSeekResponse)(json)
|
|
235
|
+
const results = (body.results ?? []).map(toResult)
|
|
236
|
+
|
|
237
|
+
// DeepSeek charges per dispatch and does not bill failures or empty
|
|
238
|
+
// results, so a hit that never enters the cache is a query paid for
|
|
239
|
+
// again on every repeat.
|
|
240
|
+
writeCache(queryKey, results)
|
|
241
|
+
return results
|
|
242
|
+
}),
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
// Rebind on credential changes: connect, delete, or rotate a key and the
|
|
247
|
+
// provider joins or leaves the websearch pool without a restart. Env-var
|
|
248
|
+
// keys cannot change under a running process, so they bind at startup.
|
|
249
|
+
yield* Effect.forkScoped(
|
|
250
|
+
ctx.event.subscribe().pipe(
|
|
251
|
+
Stream.filter(
|
|
252
|
+
(event) =>
|
|
253
|
+
event.type === "credential.updated" ||
|
|
254
|
+
event.type === "credential.switched" ||
|
|
255
|
+
event.type === "integration.updated",
|
|
256
|
+
),
|
|
257
|
+
Stream.mapEffect(() =>
|
|
258
|
+
Effect.gen(function* () {
|
|
259
|
+
const next = Boolean(yield* resolveKey())
|
|
260
|
+
if (next === bound) return
|
|
261
|
+
bound = next
|
|
262
|
+
yield* ctx.websearch.reload()
|
|
263
|
+
}),
|
|
264
|
+
),
|
|
265
|
+
Stream.runDrain,
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
export default Plugin.define({ id: "deepseek-search", effect })
|
package/plugins/jina-search.ts
CHANGED
|
@@ -112,7 +112,7 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
112
112
|
// Make "Jina" show up in /connect with an API-key method.
|
|
113
113
|
yield* ctx.integration.transform((editor) => {
|
|
114
114
|
editor.update(ID, (integration) => {
|
|
115
|
-
integration.name = "Jina"
|
|
115
|
+
integration.name = "Jina (osp)"
|
|
116
116
|
})
|
|
117
117
|
editor.method.update({
|
|
118
118
|
integrationID: ID,
|
|
@@ -163,7 +163,7 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
163
163
|
// Make "Keenable" show up in /connect with an API-key method.
|
|
164
164
|
yield* ctx.integration.transform((editor) => {
|
|
165
165
|
editor.update(ID, (integration) => {
|
|
166
|
-
integration.name = "Keenable"
|
|
166
|
+
integration.name = "Keenable (osp)"
|
|
167
167
|
})
|
|
168
168
|
editor.method.update({
|
|
169
169
|
integrationID: ID,
|
package/plugins/linkup-search.ts
CHANGED
|
@@ -88,7 +88,7 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
88
88
|
// Make "Linkup" show up in /connect with an API-key method.
|
|
89
89
|
yield* ctx.integration.transform((editor) => {
|
|
90
90
|
editor.update(ID, (integration) => {
|
|
91
|
-
integration.name = "Linkup"
|
|
91
|
+
integration.name = "Linkup (osp)"
|
|
92
92
|
})
|
|
93
93
|
editor.method.update({
|
|
94
94
|
integrationID: ID,
|
package/plugins/metaso-search.ts
CHANGED
|
@@ -143,7 +143,7 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
143
143
|
// Make "Metaso" show up in /connect with an API-key method.
|
|
144
144
|
yield* ctx.integration.transform((editor) => {
|
|
145
145
|
editor.update(ID, (integration) => {
|
|
146
|
-
integration.name = "Metaso Search"
|
|
146
|
+
integration.name = "Metaso Search (osp)"
|
|
147
147
|
})
|
|
148
148
|
editor.method.update({
|
|
149
149
|
integrationID: ID,
|
package/plugins/querit-search.ts
CHANGED
|
@@ -153,7 +153,7 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
153
153
|
// Make "Querit" show up in /connect with an API-key method.
|
|
154
154
|
yield* ctx.integration.transform((editor) => {
|
|
155
155
|
editor.update(ID, (integration) => {
|
|
156
|
-
integration.name = "Querit"
|
|
156
|
+
integration.name = "Querit (osp)"
|
|
157
157
|
})
|
|
158
158
|
editor.method.update({
|
|
159
159
|
integrationID: ID,
|
|
@@ -147,7 +147,7 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
147
147
|
// Make "SerpApi" show up in /connect with an API-key method.
|
|
148
148
|
yield* ctx.integration.transform((editor) => {
|
|
149
149
|
editor.update(ID, (integration) => {
|
|
150
|
-
integration.name = "SerpApi"
|
|
150
|
+
integration.name = "SerpApi (osp)"
|
|
151
151
|
})
|
|
152
152
|
editor.method.update({
|
|
153
153
|
integrationID: ID,
|
package/plugins/serper-search.ts
CHANGED
|
@@ -146,7 +146,7 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
146
146
|
// Make "Serper" show up in /connect with an API-key method.
|
|
147
147
|
yield* ctx.integration.transform((editor) => {
|
|
148
148
|
editor.update(ID, (integration) => {
|
|
149
|
-
integration.name = "Serper"
|
|
149
|
+
integration.name = "Serper (osp)"
|
|
150
150
|
})
|
|
151
151
|
editor.method.update({
|
|
152
152
|
integrationID: ID,
|
package/plugins/youcom-search.ts
CHANGED
|
@@ -177,7 +177,7 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
177
177
|
// Make "You.com" show up in /connect with an API-key method.
|
|
178
178
|
yield* ctx.integration.transform((editor) => {
|
|
179
179
|
editor.update(ID, (integration) => {
|
|
180
|
-
integration.name = "You.com"
|
|
180
|
+
integration.name = "You.com (osp)"
|
|
181
181
|
})
|
|
182
182
|
editor.method.update({
|
|
183
183
|
integrationID: ID,
|