opencode-search-plugin 2.1.0 → 2.2.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/README.md CHANGED
@@ -4,7 +4,7 @@
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
 
@@ -14,12 +14,12 @@ This repository adds plugins for eleven more of them, taking the pool from five
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.1.0` 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.
17
+ Three equivalent ways to install; pick one. **npm is recommended**: a version published to the registry is immutable, so a pinned `@2.2.0` 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.1.0 to pin
22
+ # add @2.2.0 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.1.0 to pin
38
+ # add #v2.2.0 to pin
39
39
  opencode plugin add github:guotree/opencode-search-plugin
40
40
  ```
41
41
 
@@ -117,7 +117,7 @@ opencode plugin update github:guotree/opencode-search-plugin
117
117
  opencode plugin update
118
118
  ```
119
119
 
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.0` on npm, `#v2.1.0` 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.
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.2.0` on npm, `#v2.2.0` 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.
121
121
 
122
122
  ## Uninstall
123
123
 
@@ -153,6 +153,8 @@ Once keys are in place, pick how OpenCode chooses among them:
153
153
 
154
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
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.
157
+
156
158
  | Provider | ID | Key (env / `/connect`) | Anonymous | Notes |
157
159
  | --- | --- | --- | --- | --- |
158
160
  | AnySearch | `osp-anysearch` | `ANYSEARCH_API_KEY` | yes | Free tier per IP; intent router, `cn`/`intl` zones |
@@ -176,6 +178,7 @@ Knobs are environment variables, read once when OpenCode starts — change one a
176
178
  | Variable | Default | Meaning |
177
179
  | --- | --- | --- |
178
180
  | `<PROVIDER>_CACHE_TTL` | `600000` | Milliseconds to reuse a response for the same normalized query. `0` disables caching. |
181
+ | `<PROVIDER>_TIMEOUT` | `25000` | Milliseconds to wait for the provider before failing. `0` removes the deadline but keeps cancellation. |
179
182
 
180
183
  ### AnySearch
181
184
 
@@ -211,6 +214,7 @@ Knobs are environment variables, read once when OpenCode starts — change one a
211
214
  | `JINA_INCLUDE_CONTENT` | off | `true` returns each hit's full page markdown — far more context and billed tokens |
212
215
  | `JINA_GL` / `JINA_HL` | — | Country / language code |
213
216
  | `JINA_LOCATION` | — | City-level origin to simulate, e.g. `Berlin` |
217
+ | `JINA_TIMEOUT` | `25000`, or `60000` with `JINA_INCLUDE_CONTENT` | Only provider whose deadline varies by mode: fetching whole pages is legitimately slower than a snippet search |
214
218
 
215
219
  ### Keenable
216
220
 
@@ -272,7 +276,7 @@ There is deliberately no result-count knob: Google withdrew the `num` parameter,
272
276
  1. OpenCode asks the selected provider for the query.
273
277
  2. The normalized query (trimmed, whitespace collapsed) is checked against an in-memory cache. A hit returns immediately — repeat queries are free and instant.
274
278
  3. On a miss the key is resolved again, so a key rotated in `/connect` applies to the very next search.
275
- 4. The request goes out; failures surface the HTTP status and response body so you can see what the provider actually said.
279
+ 4. The request goes out under a deadline (`<PROVIDER>_TIMEOUT`, default 25s). OpenCode applies no timeout of its own around a search, so without this a provider that accepted the connection and then went silent would hang the whole turn. Failures surface the HTTP status and response body, or name the timeout when that is what happened.
276
280
  5. The response is schema-decoded, mapped to `{ url, title, content, time }`, and cached.
277
281
 
278
282
  Dates are parsed from relative phrases (`3 hours ago`, `3小时前`), absolute formats (`Sep 30, 2026`, `31 Mar 2026`), and ISO 8601. Strings whose lenient parsing would invent a wrong year — `Nov 1`, which `Date.parse` reads as 2001 — are dropped rather than reported as a bogus timestamp.
@@ -294,6 +298,7 @@ Also worth checking:
294
298
  - **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.
295
299
  - **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.
296
300
  - **A search fails with `Connect X in /connect, or set X_API_KEY`** — the key disappeared between binding and searching. Reconnect or re-export.
301
+ - **A search fails with `X search timed out after 25000ms`** — the provider accepted the request and never answered within the deadline. Retrying usually works; if a provider is consistently slow for you, raise its `<PROVIDER>_TIMEOUT`. This is a deliberate bound: without one a stalled provider hangs the whole turn, because OpenCode applies no timeout of its own around a search.
297
302
  - **Everything is slow or burning credits** — leave `*_CACHE_TTL` at its default. Repeated queries within the window are served from memory.
298
303
  - **Nothing happens after editing a plugin file** — OpenCode watches plugins and reloads them; if it does not, `opencode service restart`.
299
304
 
package/README.zh-CN.md CHANGED
@@ -4,7 +4,7 @@
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
 
@@ -14,12 +14,12 @@ OpenCode v2 内置了五个网页搜索 provider:`exa`、`firecrawl`、`parall
14
14
 
15
15
  ## 安装
16
16
 
17
- 三种方式等价,任选其一。**推荐 npm**:已发布到 registry 的版本不可变,固定 `@2.1.0` 后每次安装都拿到同一份字节;GitHub 的 tag 在理论上可以被维护者移动、指向别的 commit。前两种装的是同一个完整包(十一个全有),只想用一两个就用第三种。
17
+ 三种方式等价,任选其一。**推荐 npm**:已发布到 registry 的版本不可变,固定 `@2.2.0` 后每次安装都拿到同一份字节;GitHub 的 tag 在理论上可以被维护者移动、指向别的 commit。前两种装的是同一个完整包(十一个全有),只想用一两个就用第三种。
18
18
 
19
19
  ### 1. 从 npm 安装(推荐)
20
20
 
21
21
  ```sh
22
- # 追加 @2.1.0 可固定到该版本
22
+ # 追加 @2.2.0 可固定到该版本
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.1.0 可固定到该 tag
38
+ # 追加 #v2.2.0 可固定到该 tag
39
39
  opencode plugin add github:guotree/opencode-search-plugin
40
40
  ```
41
41
 
@@ -117,7 +117,7 @@ opencode plugin update github:guotree/opencode-search-plugin
117
117
  opencode plugin update
118
118
  ```
119
119
 
120
- 升级目标用的是配置时的说明符:npm 安装用包名,GitHub 安装只认自己的 `github:` 形式——用裸包名会报 `Plugin is not configured`。先跑 `opencode plugin check` 查看有哪些过期。未固定版本的安装会跟上新版本;固定过的——npm 形式的 `@2.1.0`、GitHub 形式的 `#v2.1.0`——会停在原地,直到你用新 pin 重新执行 `plugin add`,或直接修改 `opencode.json(c)` 里的说明符。本地复制的文件则用新版本覆盖旧文件来升级。
120
+ 升级目标用的是配置时的说明符:npm 安装用包名,GitHub 安装只认自己的 `github:` 形式——用裸包名会报 `Plugin is not configured`。先跑 `opencode plugin check` 查看有哪些过期。未固定版本的安装会跟上新版本;固定过的——npm 形式的 `@2.2.0`、GitHub 形式的 `#v2.2.0`——会停在原地,直到你用新 pin 重新执行 `plugin add`,或直接修改 `opencode.json(c)` 里的说明符。本地复制的文件则用新版本覆盖旧文件来升级。
121
121
 
122
122
  ## 卸载
123
123
 
@@ -153,6 +153,8 @@ AnySearch 和 Keenable 是例外。两家都有匿名层级,因此始终注册
153
153
 
154
154
  下表每个 ID 都带 `osp-` 前缀——这是本包的命名空间。OpenCode 内置 provider 用的是裸厂商名(`exa`、`tavily`),厂商自己的插件也会注册裸名(Keenable 就已经有一个),所以前缀是避免撞名的唯一保障:既避开搜索池里的重复,也避开 `/connect` 里按 integration ID 存凭据的那张表。在 `websearch.provider` 和 `enable`/`disable` 里请原样使用带前缀的 ID。
155
155
 
156
+ 命名空间同样体现在显示名上:`/connect` 里本包的每个条目都带 `(osp)` 标记——内置的 `DeepSeek` 旁边是 `DeepSeek (osp)`——存在这里的 key 不会和内置 provider 的混淆。搜索池里仍用纯品牌名,因为池中只有搜索 provider,不存在撞名问题。
157
+
156
158
  | 服务 | ID | 密钥(环境变量 / `/connect`) | 匿名可用 | 说明 |
157
159
  | --- | --- | --- | --- | --- |
158
160
  | AnySearch | `osp-anysearch` | `ANYSEARCH_API_KEY` | 是 | 按 IP 的免费额度;意图路由,`cn` / `intl` 分区 |
@@ -176,6 +178,7 @@ AnySearch 和 Keenable 是例外。两家都有匿名层级,因此始终注册
176
178
  | 变量 | 默认值 | 含义 |
177
179
  | --- | --- | --- |
178
180
  | `<PROVIDER>_CACHE_TTL` | `600000` | 同一条归一化查询可复用响应的毫秒数。`0` 关闭缓存。 |
181
+ | `<PROVIDER>_TIMEOUT` | `25000` | 等待 provider 返回的毫秒数,超时即失败。`0` 取消该期限,但保留取消能力。 |
179
182
 
180
183
  ### AnySearch
181
184
 
@@ -211,6 +214,7 @@ AnySearch 和 Keenable 是例外。两家都有匿名层级,因此始终注册
211
214
  | `JINA_INCLUDE_CONTENT` | 关 | `true` 返回每条命中的整页 markdown——上下文和计费 token 都会大幅增加 |
212
215
  | `JINA_GL` / `JINA_HL` | — | 国家 / 语言代码 |
213
216
  | `JINA_LOCATION` | — | 模拟的城市级来源,例如 `Berlin` |
217
+ | `JINA_TIMEOUT` | `25000`;开启 `JINA_INCLUDE_CONTENT` 时为 `60000` | 唯一一个默认值随模式变化的 provider:抓取整页本来就比取摘要慢得多 |
214
218
 
215
219
  ### Keenable
216
220
 
@@ -272,7 +276,7 @@ AnySearch 和 Keenable 是例外。两家都有匿名层级,因此始终注册
272
276
  1. OpenCode 向选定的 provider 发起查询。
273
277
  2. 归一化后的查询(去首尾、压缩空白)先查内存缓存;命中则直接返回——重复查询免费且即时。
274
278
  3. 未命中时重新解析密钥,因此在 `/connect` 里轮换的 key 立即对下一次搜索生效。
275
- 4. 发出请求;失败时会带上 HTTP 状态码和响应体,让你看到服务方到底说了什么。
279
+ 4. 发出请求,且带期限(`<PROVIDER>_TIMEOUT`,默认 25 秒)。OpenCode 自身并不给搜索加任何超时,所以没有这道期限的话,一个接受了连接却不再应答的 provider 会挂住整轮对话。失败时会带上 HTTP 状态码和响应体;如果是超时,报错会直接点明是超时。
276
280
  5. 响应经 schema 解码,映射为 `{ url, title, content, time }`,然后写入缓存。
277
281
 
278
282
  日期解析支持相对表述(`3 hours ago`、`3小时前`)、绝对格式(`Sep 30, 2026`、`31 Mar 2026`)和 ISO 8601。凡是宽松解析会凭空造出错误年份的字符串——比如 `Nov 1`,`Date.parse` 会读成 2001 年——一律丢弃,而不是返回一个错误的时间戳。
@@ -294,6 +298,7 @@ opencode api get "/api/websearch/provider"
294
298
  - **某家 provider 不在池里** —— 它没有 key,或者被 `disable`(或收窄后的 `enable`)过滤掉了。执行 `/connect` 或导出对应环境变量,然后重启;在 `/connect` 里做的凭据变更会自动重绑,不需要重启。本该在却不在的名字多半是拼写错误:日志会点出无法识别的 ID 和 `enable`/`disable` 的交集。
295
299
  - **搜索报错 `Keenable rate limited the request (HTTP 429)`** —— 无 key 层级每个 IP 每小时 1,000 次、每秒 10 次,且与同一出口地址上的其他人共用。改用 `KEENABLE_API_KEY` 就会记到你自己的额度上。
296
300
  - **搜索报错 `Connect X in /connect, or set X_API_KEY`** —— key 在绑定和搜索之间消失了。重新连接或重新导出。
301
+ - **搜索报错 `X search timed out after 25000ms`** —— provider 接受了请求,但在期限内没有返回。重试通常就好;如果某家 provider 在你这边一贯偏慢,调大它的 `<PROVIDER>_TIMEOUT`。这道期限是刻意加的:没有它,一个卡住的 provider 会挂住整轮对话,因为 OpenCode 自身并不给搜索设置任何超时。
297
302
  - **一切都很慢、或者积分烧得飞快** —— 把 `*_CACHE_TTL` 保持在默认值。时间窗内的重复查询由内存直接返回。
298
303
  - **改了插件文件却没反应** —— OpenCode 会监视插件并自动重载;如果没有,执行 `opencode service restart`。
299
304
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-search-plugin",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
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",
@@ -73,8 +73,12 @@ function envStr(name: string): string {
73
73
  // ANYSEARCH_ZONE "cn" or "intl"
74
74
  // ANYSEARCH_LANGUAGE preferred result language, e.g. "zh-CN"
75
75
  // ANYSEARCH_FORMAT "markdown" (default) or "json" for the content field
76
+ // ANYSEARCH_TIMEOUT ms to wait before failing (0 removes the deadline)
76
77
  // ANYSEARCH_API_KEY optional; anonymous access works without it
77
78
  const CACHE_TTL = envInt("ANYSEARCH_CACHE_TTL", 600_000)
79
+ // OpenCode's own providers each bounded themselves at 25s, and the host adds
80
+ // no deadline of its own around `execute`, so this is the only one that exists.
81
+ const SEARCH_TIMEOUT = envInt("ANYSEARCH_TIMEOUT", 25_000)
78
82
  const SEARCH_SIZE = Math.min(Math.max(envInt("ANYSEARCH_MAX_RESULTS", 10), 1), 10)
79
83
  const TAG = envStr("ANYSEARCH_TAG")
80
84
  const ZONE = envStr("ANYSEARCH_ZONE")
@@ -103,6 +107,14 @@ function writeCache(queryKey: string, results: SearchResult[]): void {
103
107
  }
104
108
  }
105
109
 
110
+ // A hung upstream must not hang the agent: the deadline bounds it, and the
111
+ // thunk's signal carries host cancellation into the socket. `timeout <= 0`
112
+ // removes the deadline but never the cancellation, because
113
+ // `AbortSignal.timeout(0)` aborts instantly rather than "never".
114
+ function timedSignal(signal: AbortSignal, timeout: number): AbortSignal {
115
+ return timeout > 0 ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
116
+ }
117
+
106
118
  // Unlike the other providers in this repo, AnySearch never gates on `bound`:
107
119
  // anonymous access is a first-class mode, so the provider is always in the
108
120
  // pool. The key is still read per call, which is why no credential-event
@@ -113,7 +125,7 @@ export const effect = (ctx: Plugin.Context) =>
113
125
  // Make "AnySearch" show up in /connect with an API-key method.
114
126
  yield* ctx.integration.transform((editor) => {
115
127
  editor.update(ID, (integration) => {
116
- integration.name = "AnySearch"
128
+ integration.name = "AnySearch (osp)"
117
129
  })
118
130
  editor.method.update({
119
131
  integrationID: ID,
@@ -159,7 +171,7 @@ export const effect = (ctx: Plugin.Context) =>
159
171
  if (key) headers.Authorization = "Bearer " + key
160
172
 
161
173
  const response = yield* Effect.tryPromise({
162
- try: () =>
174
+ try: (signal) =>
163
175
  fetch("https://api.anysearch.com/v1/search", {
164
176
  method: "POST",
165
177
  headers,
@@ -171,8 +183,14 @@ export const effect = (ctx: Plugin.Context) =>
171
183
  ...(LANGUAGE ? { language: LANGUAGE } : {}),
172
184
  format: FORMAT,
173
185
  }),
186
+ signal: timedSignal(signal, SEARCH_TIMEOUT),
174
187
  }),
175
- catch: (error) => new Error("AnySearch request failed: " + String(error)),
188
+ // A timeout is its own failure: naming it stops the user hunting
189
+ // for a key problem that was never there.
190
+ catch: (error) =>
191
+ error instanceof Error && error.name === "TimeoutError"
192
+ ? new Error("AnySearch search timed out after " + SEARCH_TIMEOUT + "ms")
193
+ : new Error("AnySearch request failed: " + String(error)),
176
194
  })
177
195
 
178
196
  if (!response.ok) {
@@ -114,12 +114,17 @@ type SearchResult = ReturnType<typeof toResult>
114
114
  // BRAVE_LANG result language code, e.g. "en", "zh-hans", "pt-br"
115
115
  // BRAVE_FRESHNESS "pd" (24h) "pw" (7d) "pm" (31d) "py" (365d) or a
116
116
  // "YYYY-MM-DDtoYYYY-MM-DD" range
117
+ // BRAVE_TIMEOUT ms to wait for Brave before failing (0 removes the
118
+ // deadline; cancellation still applies)
117
119
  // BRAVE_SEARCH_API_KEY required; from https://brave.com/search/api/
118
120
  const CACHE_TTL = envInt("BRAVE_CACHE_TTL", 600_000)
119
121
  const SEARCH_SIZE = Math.min(Math.max(envInt("BRAVE_NUM", 8), 1), 20)
120
122
  const COUNTRY = envStr("BRAVE_COUNTRY")
121
123
  const SEARCH_LANG = envStr("BRAVE_LANG")
122
124
  const FRESHNESS = envStr("BRAVE_FRESHNESS")
125
+ // OpenCode's own providers each bound themselves at 25s, and the host adds no
126
+ // deadline of its own around `execute`, so this is the only one that exists.
127
+ const SEARCH_TIMEOUT = envInt("BRAVE_TIMEOUT", 25_000)
123
128
 
124
129
  const CACHE_LIMIT = 200
125
130
  const cache = new Map<string, { at: number; results: SearchResult[] }>()
@@ -143,12 +148,20 @@ function writeCache(queryKey: string, results: SearchResult[]): void {
143
148
  }
144
149
  }
145
150
 
151
+ // A hung upstream must not hang the agent: the deadline bounds it, and the
152
+ // thunk's signal carries host cancellation into the socket. `timeout <= 0`
153
+ // removes the deadline but never the cancellation, because
154
+ // `AbortSignal.timeout(0)` aborts instantly rather than "never".
155
+ function timedSignal(signal: AbortSignal, timeout: number): AbortSignal {
156
+ return timeout > 0 ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
157
+ }
158
+
146
159
  export const effect = (ctx: Plugin.Context) =>
147
160
  Effect.gen(function* () {
148
161
  // Make "Brave Search" show up in /connect with an API-key method.
149
162
  yield* ctx.integration.transform((editor) => {
150
163
  editor.update(ID, (integration) => {
151
- integration.name = "Brave Search"
164
+ integration.name = "Brave Search (osp)"
152
165
  })
153
166
  editor.method.update({
154
167
  integrationID: ID,
@@ -212,14 +225,20 @@ export const effect = (ctx: Plugin.Context) =>
212
225
  if (FRESHNESS) url.searchParams.set("freshness", FRESHNESS)
213
226
 
214
227
  const response = yield* Effect.tryPromise({
215
- try: () =>
228
+ try: (signal) =>
216
229
  fetch(url, {
217
230
  headers: {
218
231
  Accept: "application/json",
219
232
  "X-Subscription-Token": key,
220
233
  },
234
+ signal: timedSignal(signal, SEARCH_TIMEOUT),
221
235
  }),
222
- catch: (error) => new Error("Brave request failed: " + String(error)),
236
+ // A timeout is its own failure: naming it stops the user hunting
237
+ // for a key problem that was never there.
238
+ catch: (error) =>
239
+ error instanceof Error && error.name === "TimeoutError"
240
+ ? new Error("Brave search timed out after " + SEARCH_TIMEOUT + "ms")
241
+ : new Error("Brave request failed: " + String(error)),
223
242
  })
224
243
 
225
244
  if (!response.ok) {
@@ -112,8 +112,12 @@ type SearchResult = ReturnType<typeof toResult>
112
112
  //
113
113
  // DEEPSEEK_CACHE_TTL ms to reuse a response for the same query (0 disables)
114
114
  // DEEPSEEK_COUNT results per search, clamped to 1-50
115
+ // DEEPSEEK_TIMEOUT ms to wait before failing (0 removes the deadline)
115
116
  // DEEPSEEK_API_KEY required; from https://platform.deepseek.com
116
117
  const CACHE_TTL = envInt("DEEPSEEK_CACHE_TTL", 600_000)
118
+ // OpenCode's own providers each bounded themselves at 25s, and the host adds
119
+ // no deadline of its own around `execute`, so this is the only one that exists.
120
+ const SEARCH_TIMEOUT = envInt("DEEPSEEK_TIMEOUT", 25_000)
117
121
  const COUNT = Math.min(Math.max(envInt("DEEPSEEK_COUNT", 10), 1), 50)
118
122
 
119
123
  const CACHE_LIMIT = 200
@@ -138,12 +142,20 @@ function writeCache(queryKey: string, results: SearchResult[]): void {
138
142
  }
139
143
  }
140
144
 
145
+ // A hung upstream must not hang the agent: the deadline bounds it, and the
146
+ // thunk's signal carries host cancellation into the socket. `timeout <= 0`
147
+ // removes the deadline but never the cancellation, because
148
+ // `AbortSignal.timeout(0)` aborts instantly rather than "never".
149
+ function timedSignal(signal: AbortSignal, timeout: number): AbortSignal {
150
+ return timeout > 0 ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
151
+ }
152
+
141
153
  export const effect = (ctx: Plugin.Context) =>
142
154
  Effect.gen(function* () {
143
155
  // Make "DeepSeek" show up in /connect with an API-key method.
144
156
  yield* ctx.integration.transform((editor) => {
145
157
  editor.update(ID, (integration) => {
146
- integration.name = "DeepSeek"
158
+ integration.name = "DeepSeek (osp)"
147
159
  })
148
160
  editor.method.update({
149
161
  integrationID: ID,
@@ -200,7 +212,7 @@ export const effect = (ctx: Plugin.Context) =>
200
212
  }
201
213
 
202
214
  const response = yield* Effect.tryPromise({
203
- try: () =>
215
+ try: (signal) =>
204
216
  fetch("https://api.deepseek.com/v1/tools/search", {
205
217
  method: "POST",
206
218
  headers: {
@@ -212,8 +224,14 @@ export const effect = (ctx: Plugin.Context) =>
212
224
  query: queryKey,
213
225
  count: COUNT,
214
226
  }),
227
+ signal: timedSignal(signal, SEARCH_TIMEOUT),
215
228
  }),
216
- catch: (error) => new Error("DeepSeek request failed: " + String(error)),
229
+ // A timeout is its own failure: naming it stops the user hunting
230
+ // for a key problem that was never there.
231
+ catch: (error) =>
232
+ error instanceof Error && error.name === "TimeoutError"
233
+ ? new Error("DeepSeek search timed out after " + SEARCH_TIMEOUT + "ms")
234
+ : new Error("DeepSeek request failed: " + String(error)),
217
235
  })
218
236
 
219
237
  if (!response.ok) {
@@ -77,6 +77,10 @@ function envStr(name: string): string {
77
77
  // JINA_GL country code for results, e.g. "us", "cn"
78
78
  // JINA_HL language code for results, e.g. "en", "zh-cn"
79
79
  // JINA_LOCATION city-level origin to simulate, e.g. "Berlin"
80
+ // JINA_TIMEOUT ms to wait before failing (0 removes the deadline).
81
+ // Defaults to 25000, or 60000 when JINA_INCLUDE_CONTENT
82
+ // is on, because whole-page fetches legitimately take
83
+ // far longer than a snippet search
80
84
  // JINA_API_KEY required; free key at https://jina.ai/?sui=apikey
81
85
  const CACHE_TTL = envInt("JINA_CACHE_TTL", 600_000)
82
86
  const INCLUDE_CONTENT = envStr("JINA_INCLUDE_CONTENT") === "true"
@@ -84,6 +88,13 @@ const SEARCH_SIZE = Math.min(Math.max(envInt("JINA_NUM", 0), 0), 100)
84
88
  const GL = envStr("JINA_GL")
85
89
  const HL = envStr("JINA_HL")
86
90
  const LOCATION = envStr("JINA_LOCATION")
91
+ // OpenCode's own providers each bounded themselves at 25s, and the host adds
92
+ // no deadline of its own around `execute`, so this is the only one that exists.
93
+ // Jina is the one provider whose default has to vary: asking for page content
94
+ // turns a snippet search into a crawl, and a shared budget would either cut
95
+ // that off or be padded so high that a genuine hang takes a minute to surface.
96
+ const DEFAULT_TIMEOUT = INCLUDE_CONTENT ? 60_000 : 25_000
97
+ const SEARCH_TIMEOUT = envInt("JINA_TIMEOUT", DEFAULT_TIMEOUT)
87
98
 
88
99
  const CACHE_LIMIT = 200
89
100
  const cache = new Map<string, { at: number; results: SearchResult[] }>()
@@ -107,12 +118,20 @@ function writeCache(queryKey: string, results: SearchResult[]): void {
107
118
  }
108
119
  }
109
120
 
121
+ // A hung upstream must not hang the agent: the deadline bounds it, and the
122
+ // thunk's signal carries host cancellation into the socket. `timeout <= 0`
123
+ // removes the deadline but never the cancellation, because
124
+ // `AbortSignal.timeout(0)` aborts instantly rather than "never".
125
+ function timedSignal(signal: AbortSignal, timeout: number): AbortSignal {
126
+ return timeout > 0 ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
127
+ }
128
+
110
129
  export const effect = (ctx: Plugin.Context) =>
111
130
  Effect.gen(function* () {
112
131
  // Make "Jina" show up in /connect with an API-key method.
113
132
  yield* ctx.integration.transform((editor) => {
114
133
  editor.update(ID, (integration) => {
115
- integration.name = "Jina"
134
+ integration.name = "Jina (osp)"
116
135
  })
117
136
  editor.method.update({
118
137
  integrationID: ID,
@@ -169,7 +188,7 @@ export const effect = (ctx: Plugin.Context) =>
169
188
  }
170
189
 
171
190
  const response = yield* Effect.tryPromise({
172
- try: () =>
191
+ try: (signal) =>
173
192
  fetch("https://s.jina.ai/", {
174
193
  method: "POST",
175
194
  headers: {
@@ -188,8 +207,14 @@ export const effect = (ctx: Plugin.Context) =>
188
207
  ...(HL ? { hl: HL } : {}),
189
208
  ...(LOCATION ? { location: LOCATION } : {}),
190
209
  }),
210
+ signal: timedSignal(signal, SEARCH_TIMEOUT),
191
211
  }),
192
- catch: (error) => new Error("Jina request failed: " + String(error)),
212
+ // A timeout is its own failure: naming it stops the user hunting
213
+ // for a key problem that was never there.
214
+ catch: (error) =>
215
+ error instanceof Error && error.name === "TimeoutError"
216
+ ? new Error("Jina search timed out after " + SEARCH_TIMEOUT + "ms")
217
+ : new Error("Jina request failed: " + String(error)),
193
218
  })
194
219
 
195
220
  if (!response.ok) {
@@ -122,8 +122,12 @@ function envStr(name: string): string {
122
122
  // leave unset and the organization's default applies
123
123
  // KEENABLE_TITLE app name sent as X-Keenable-Title, which the keyless
124
124
  // endpoint requires to attribute traffic (max 256 chars)
125
+ // KEENABLE_TIMEOUT ms to wait before failing (0 removes the deadline)
125
126
  // KEENABLE_API_KEY optional; keyless access works without it
126
127
  const CACHE_TTL = envInt("KEENABLE_CACHE_TTL", 600_000)
128
+ // OpenCode's own providers each bounded themselves at 25s, and the host adds
129
+ // no deadline of its own around `execute`, so this is the only one that exists.
130
+ const SEARCH_TIMEOUT = envInt("KEENABLE_TIMEOUT", 25_000)
127
131
  const SEARCH_SIZE = Math.min(Math.max(envInt("KEENABLE_MAX_RESULTS", 10), 1), 50)
128
132
  const MODE = envStr("KEENABLE_MODE")
129
133
  const TITLE = (envStr("KEENABLE_TITLE") || "opencode-search-plugin").slice(0, 256)
@@ -153,6 +157,14 @@ function writeCache(queryKey: string, results: SearchResult[]): void {
153
157
  }
154
158
  }
155
159
 
160
+ // A hung upstream must not hang the agent: the deadline bounds it, and the
161
+ // thunk's signal carries host cancellation into the socket. `timeout <= 0`
162
+ // removes the deadline but never the cancellation, because
163
+ // `AbortSignal.timeout(0)` aborts instantly rather than "never".
164
+ function timedSignal(signal: AbortSignal, timeout: number): AbortSignal {
165
+ return timeout > 0 ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
166
+ }
167
+
156
168
  // Unlike the key-requiring providers in this repo, Keenable never gates on
157
169
  // `bound`: keyless access is a first-class mode, so the provider is always in
158
170
  // the pool. The key is still read per call, which is why no credential-event
@@ -163,7 +175,7 @@ export const effect = (ctx: Plugin.Context) =>
163
175
  // Make "Keenable" show up in /connect with an API-key method.
164
176
  yield* ctx.integration.transform((editor) => {
165
177
  editor.update(ID, (integration) => {
166
- integration.name = "Keenable"
178
+ integration.name = "Keenable (osp)"
167
179
  })
168
180
  editor.method.update({
169
181
  integrationID: ID,
@@ -213,7 +225,7 @@ export const effect = (ctx: Plugin.Context) =>
213
225
  if (key) headers["X-API-Key"] = key
214
226
 
215
227
  const response = yield* Effect.tryPromise({
216
- try: () =>
228
+ try: (signal) =>
217
229
  fetch(key ? SEARCH_URL : PUBLIC_URL, {
218
230
  method: "POST",
219
231
  headers,
@@ -222,8 +234,14 @@ export const effect = (ctx: Plugin.Context) =>
222
234
  max_results: SEARCH_SIZE,
223
235
  ...(MODE ? { mode: MODE } : {}),
224
236
  }),
237
+ signal: timedSignal(signal, SEARCH_TIMEOUT),
225
238
  }),
226
- catch: (error) => new Error("Keenable request failed: " + String(error)),
239
+ // A timeout is its own failure: naming it stops the user hunting
240
+ // for a key problem that was never there.
241
+ catch: (error) =>
242
+ error instanceof Error && error.name === "TimeoutError"
243
+ ? new Error("Keenable search timed out after " + SEARCH_TIMEOUT + "ms")
244
+ : new Error("Keenable request failed: " + String(error)),
227
245
  })
228
246
 
229
247
  if (!response.ok) {
@@ -54,8 +54,12 @@ function envStr(name: string): string {
54
54
  // vendor's recommended default for agents), "standard"
55
55
  // (single agentic pass) or "deep" (several iterations)
56
56
  // LINKUP_MAX_RESULTS results per search, clamped to 1-100
57
+ // LINKUP_TIMEOUT ms to wait before failing (0 removes the deadline)
57
58
  // LINKUP_API_KEY required; from https://app.linkup.so
58
59
  const CACHE_TTL = envInt("LINKUP_CACHE_TTL", 600_000)
60
+ // OpenCode's own providers each bounded themselves at 25s, and the host adds
61
+ // no deadline of its own around `execute`, so this is the only one that exists.
62
+ const SEARCH_TIMEOUT = envInt("LINKUP_TIMEOUT", 25_000)
59
63
  const SEARCH_SIZE = Math.min(Math.max(envInt("LINKUP_MAX_RESULTS", 10), 1), 100)
60
64
  const DEPTH = ["flash", "fast", "standard", "deep"].includes(envStr("LINKUP_DEPTH"))
61
65
  ? envStr("LINKUP_DEPTH")
@@ -83,12 +87,20 @@ function writeCache(queryKey: string, results: SearchResult[]): void {
83
87
  }
84
88
  }
85
89
 
90
+ // A hung upstream must not hang the agent: the deadline bounds it, and the
91
+ // thunk's signal carries host cancellation into the socket. `timeout <= 0`
92
+ // removes the deadline but never the cancellation, because
93
+ // `AbortSignal.timeout(0)` aborts instantly rather than "never".
94
+ function timedSignal(signal: AbortSignal, timeout: number): AbortSignal {
95
+ return timeout > 0 ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
96
+ }
97
+
86
98
  export const effect = (ctx: Plugin.Context) =>
87
99
  Effect.gen(function* () {
88
100
  // Make "Linkup" show up in /connect with an API-key method.
89
101
  yield* ctx.integration.transform((editor) => {
90
102
  editor.update(ID, (integration) => {
91
- integration.name = "Linkup"
103
+ integration.name = "Linkup (osp)"
92
104
  })
93
105
  editor.method.update({
94
106
  integrationID: ID,
@@ -145,7 +157,7 @@ export const effect = (ctx: Plugin.Context) =>
145
157
  }
146
158
 
147
159
  const response = yield* Effect.tryPromise({
148
- try: () =>
160
+ try: (signal) =>
149
161
  fetch("https://api.linkup.so/v1/search", {
150
162
  method: "POST",
151
163
  headers: {
@@ -159,8 +171,14 @@ export const effect = (ctx: Plugin.Context) =>
159
171
  outputType: "searchResults",
160
172
  maxResults: SEARCH_SIZE,
161
173
  }),
174
+ signal: timedSignal(signal, SEARCH_TIMEOUT),
162
175
  }),
163
- catch: (error) => new Error("Linkup request failed: " + String(error)),
176
+ // A timeout is its own failure: naming it stops the user hunting
177
+ // for a key problem that was never there.
178
+ catch: (error) =>
179
+ error instanceof Error && error.name === "TimeoutError"
180
+ ? new Error("Linkup search timed out after " + SEARCH_TIMEOUT + "ms")
181
+ : new Error("Linkup request failed: " + String(error)),
164
182
  })
165
183
 
166
184
  if (!response.ok) {
@@ -111,8 +111,12 @@ type SearchResult = ReturnType<typeof toResult>
111
111
  // METASO_INCLUDE_RAW_CONTENT true returns the full source text of every hit: same
112
112
  // search cost, far more context tokens. Only worth it
113
113
  // when you would otherwise webfetch those hits anyway.
114
+ // METASO_TIMEOUT ms to wait before failing (0 removes the deadline)
114
115
  // METASO_API_KEY required; from https://metaso.cn
115
116
  const CACHE_TTL = envInt("METASO_CACHE_TTL", 600_000)
117
+ // OpenCode's own providers each bounded themselves at 25s, and the host adds
118
+ // no deadline of its own around `execute`, so this is the only one that exists.
119
+ const SEARCH_TIMEOUT = envInt("METASO_TIMEOUT", 25_000)
116
120
  const SEARCH_SIZE = Math.min(Math.max(envInt("METASO_SEARCH_SIZE", 20), 1), 100)
117
121
  const INCLUDE_RAW_CONTENT = envStr("METASO_INCLUDE_RAW_CONTENT") === "true"
118
122
 
@@ -138,12 +142,20 @@ function writeCache(queryKey: string, results: SearchResult[]): void {
138
142
  }
139
143
  }
140
144
 
145
+ // A hung upstream must not hang the agent: the deadline bounds it, and the
146
+ // thunk's signal carries host cancellation into the socket. `timeout <= 0`
147
+ // removes the deadline but never the cancellation, because
148
+ // `AbortSignal.timeout(0)` aborts instantly rather than "never".
149
+ function timedSignal(signal: AbortSignal, timeout: number): AbortSignal {
150
+ return timeout > 0 ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
151
+ }
152
+
141
153
  export const effect = (ctx: Plugin.Context) =>
142
154
  Effect.gen(function* () {
143
155
  // Make "Metaso" show up in /connect with an API-key method.
144
156
  yield* ctx.integration.transform((editor) => {
145
157
  editor.update(ID, (integration) => {
146
- integration.name = "Metaso Search"
158
+ integration.name = "Metaso Search (osp)"
147
159
  })
148
160
  editor.method.update({
149
161
  integrationID: ID,
@@ -200,7 +212,7 @@ export const effect = (ctx: Plugin.Context) =>
200
212
  }
201
213
 
202
214
  const response = yield* Effect.tryPromise({
203
- try: () =>
215
+ try: (signal) =>
204
216
  fetch("https://metaso.cn/api/v1/search", {
205
217
  method: "POST",
206
218
  headers: {
@@ -216,8 +228,14 @@ export const effect = (ctx: Plugin.Context) =>
216
228
  includeRawContent: INCLUDE_RAW_CONTENT,
217
229
  conciseSnippet: true,
218
230
  }),
231
+ signal: timedSignal(signal, SEARCH_TIMEOUT),
219
232
  }),
220
- catch: (error) => new Error("Metaso request failed: " + String(error)),
233
+ // A timeout is its own failure: naming it stops the user hunting
234
+ // for a key problem that was never there.
235
+ catch: (error) =>
236
+ error instanceof Error && error.name === "TimeoutError"
237
+ ? new Error("Metaso search timed out after " + SEARCH_TIMEOUT + "ms")
238
+ : new Error("Metaso request failed: " + String(error)),
221
239
  })
222
240
 
223
241
  if (!response.ok) {
@@ -122,8 +122,12 @@ type SearchResult = ReturnType<typeof toResult>
122
122
  //
123
123
  // QUERIT_CACHE_TTL ms to reuse a response for the same query (0 disables)
124
124
  // QUERIT_COUNT results per search; Querit's own limit applies
125
+ // QUERIT_TIMEOUT ms to wait before failing (0 removes the deadline)
125
126
  // QUERIT_API_KEY required; from https://querit.ai
126
127
  const CACHE_TTL = envInt("QUERIT_CACHE_TTL", 600_000)
128
+ // OpenCode's own providers each bounded themselves at 25s, and the host adds
129
+ // no deadline of its own around `execute`, so this is the only one that exists.
130
+ const SEARCH_TIMEOUT = envInt("QUERIT_TIMEOUT", 25_000)
127
131
  const SEARCH_SIZE = envInt("QUERIT_COUNT", 8)
128
132
 
129
133
  const CACHE_LIMIT = 200
@@ -148,12 +152,20 @@ function writeCache(queryKey: string, results: SearchResult[]): void {
148
152
  }
149
153
  }
150
154
 
155
+ // A hung upstream must not hang the agent: the deadline bounds it, and the
156
+ // thunk's signal carries host cancellation into the socket. `timeout <= 0`
157
+ // removes the deadline but never the cancellation, because
158
+ // `AbortSignal.timeout(0)` aborts instantly rather than "never".
159
+ function timedSignal(signal: AbortSignal, timeout: number): AbortSignal {
160
+ return timeout > 0 ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
161
+ }
162
+
151
163
  export const effect = (ctx: Plugin.Context) =>
152
164
  Effect.gen(function* () {
153
165
  // Make "Querit" show up in /connect with an API-key method.
154
166
  yield* ctx.integration.transform((editor) => {
155
167
  editor.update(ID, (integration) => {
156
- integration.name = "Querit"
168
+ integration.name = "Querit (osp)"
157
169
  })
158
170
  editor.method.update({
159
171
  integrationID: ID,
@@ -210,7 +222,7 @@ export const effect = (ctx: Plugin.Context) =>
210
222
  }
211
223
 
212
224
  const response = yield* Effect.tryPromise({
213
- try: () =>
225
+ try: (signal) =>
214
226
  fetch("https://api.querit.ai/v1/search", {
215
227
  method: "POST",
216
228
  headers: {
@@ -219,8 +231,14 @@ export const effect = (ctx: Plugin.Context) =>
219
231
  Authorization: "Bearer " + key,
220
232
  },
221
233
  body: JSON.stringify({ query: queryKey, count: SEARCH_SIZE }),
234
+ signal: timedSignal(signal, SEARCH_TIMEOUT),
222
235
  }),
223
- catch: (error) => new Error("Querit request failed: " + String(error)),
236
+ // A timeout is its own failure: naming it stops the user hunting
237
+ // for a key problem that was never there.
238
+ catch: (error) =>
239
+ error instanceof Error && error.name === "TimeoutError"
240
+ ? new Error("Querit search timed out after " + SEARCH_TIMEOUT + "ms")
241
+ : new Error("Querit request failed: " + String(error)),
224
242
  })
225
243
 
226
244
  if (!response.ok) {
@@ -111,11 +111,15 @@ type SearchResult = ReturnType<typeof toResult>
111
111
  // SERPAPI_GL country code for results, e.g. "us", "cn"
112
112
  // SERPAPI_HL language code for results, e.g. "en", "zh-cn"
113
113
  // SERPAPI_TBS advanced filters, e.g. "qdr:d" (past day), "qdr:y"
114
+ // SERPAPI_TIMEOUT ms to wait before failing (0 removes the deadline)
114
115
  // SERPAPI_API_KEY required; from https://serpapi.com
115
116
  //
116
117
  // There is deliberately no result-count knob: Google withdrew the `num`
117
118
  // parameter, so SerpApi cannot honour one and a count would be a lie.
118
119
  const CACHE_TTL = envInt("SERPAPI_CACHE_TTL", 600_000)
120
+ // OpenCode's own providers each bounded themselves at 25s, and the host adds
121
+ // no deadline of its own around `execute`, so this is the only one that exists.
122
+ const SEARCH_TIMEOUT = envInt("SERPAPI_TIMEOUT", 25_000)
119
123
  const GL = envStr("SERPAPI_GL")
120
124
  const HL = envStr("SERPAPI_HL")
121
125
  const TBS = envStr("SERPAPI_TBS")
@@ -142,12 +146,20 @@ function writeCache(queryKey: string, results: SearchResult[]): void {
142
146
  }
143
147
  }
144
148
 
149
+ // A hung upstream must not hang the agent: the deadline bounds it, and the
150
+ // thunk's signal carries host cancellation into the socket. `timeout <= 0`
151
+ // removes the deadline but never the cancellation, because
152
+ // `AbortSignal.timeout(0)` aborts instantly rather than "never".
153
+ function timedSignal(signal: AbortSignal, timeout: number): AbortSignal {
154
+ return timeout > 0 ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
155
+ }
156
+
145
157
  export const effect = (ctx: Plugin.Context) =>
146
158
  Effect.gen(function* () {
147
159
  // Make "SerpApi" show up in /connect with an API-key method.
148
160
  yield* ctx.integration.transform((editor) => {
149
161
  editor.update(ID, (integration) => {
150
- integration.name = "SerpApi"
162
+ integration.name = "SerpApi (osp)"
151
163
  })
152
164
  editor.method.update({
153
165
  integrationID: ID,
@@ -212,13 +224,19 @@ export const effect = (ctx: Plugin.Context) =>
212
224
  if (TBS) url.searchParams.set("tbs", TBS)
213
225
 
214
226
  const response = yield* Effect.tryPromise({
215
- try: () =>
227
+ try: (signal) =>
216
228
  fetch(url, {
217
229
  headers: {
218
230
  Accept: "application/json",
219
231
  },
232
+ signal: timedSignal(signal, SEARCH_TIMEOUT),
220
233
  }),
221
- catch: (error) => new Error("SerpApi request failed: " + String(error)),
234
+ // A timeout is its own failure: naming it stops the user hunting
235
+ // for a key problem that was never there.
236
+ catch: (error) =>
237
+ error instanceof Error && error.name === "TimeoutError"
238
+ ? new Error("SerpApi search timed out after " + SEARCH_TIMEOUT + "ms")
239
+ : new Error("SerpApi request failed: " + String(error)),
222
240
  })
223
241
 
224
242
  if (!response.ok) {
@@ -112,8 +112,12 @@ type SearchResult = ReturnType<typeof toResult>
112
112
  // SERPER_HL language code for results, e.g. "en", "zh-cn"
113
113
  // SERPER_TBS time filter: "qdr:h" (hour) "qdr:d" (day) "qdr:w" (week)
114
114
  // "qdr:m" (month) "qdr:y" (year)
115
+ // SERPER_TIMEOUT ms to wait before failing (0 removes the deadline)
115
116
  // SERPER_API_KEY required; from https://serper.dev
116
117
  const CACHE_TTL = envInt("SERPER_CACHE_TTL", 600_000)
118
+ // OpenCode's own providers each bounded themselves at 25s, and the host adds
119
+ // no deadline of its own around `execute`, so this is the only one that exists.
120
+ const SEARCH_TIMEOUT = envInt("SERPER_TIMEOUT", 25_000)
117
121
  const SEARCH_SIZE = Math.min(Math.max(envInt("SERPER_NUM", 10), 1), 100)
118
122
  const GL = envStr("SERPER_GL")
119
123
  const HL = envStr("SERPER_HL")
@@ -141,12 +145,20 @@ function writeCache(queryKey: string, results: SearchResult[]): void {
141
145
  }
142
146
  }
143
147
 
148
+ // A hung upstream must not hang the agent: the deadline bounds it, and the
149
+ // thunk's signal carries host cancellation into the socket. `timeout <= 0`
150
+ // removes the deadline but never the cancellation, because
151
+ // `AbortSignal.timeout(0)` aborts instantly rather than "never".
152
+ function timedSignal(signal: AbortSignal, timeout: number): AbortSignal {
153
+ return timeout > 0 ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
154
+ }
155
+
144
156
  export const effect = (ctx: Plugin.Context) =>
145
157
  Effect.gen(function* () {
146
158
  // Make "Serper" show up in /connect with an API-key method.
147
159
  yield* ctx.integration.transform((editor) => {
148
160
  editor.update(ID, (integration) => {
149
- integration.name = "Serper"
161
+ integration.name = "Serper (osp)"
150
162
  })
151
163
  editor.method.update({
152
164
  integrationID: ID,
@@ -203,7 +215,7 @@ export const effect = (ctx: Plugin.Context) =>
203
215
  }
204
216
 
205
217
  const response = yield* Effect.tryPromise({
206
- try: () =>
218
+ try: (signal) =>
207
219
  fetch("https://google.serper.dev/search", {
208
220
  method: "POST",
209
221
  headers: {
@@ -218,8 +230,14 @@ export const effect = (ctx: Plugin.Context) =>
218
230
  ...(HL ? { hl: HL } : {}),
219
231
  ...(TBS ? { tbs: TBS } : {}),
220
232
  }),
233
+ signal: timedSignal(signal, SEARCH_TIMEOUT),
221
234
  }),
222
- catch: (error) => new Error("Serper request failed: " + String(error)),
235
+ // A timeout is its own failure: naming it stops the user hunting
236
+ // for a key problem that was never there.
237
+ catch: (error) =>
238
+ error instanceof Error && error.name === "TimeoutError"
239
+ ? new Error("Serper search timed out after " + SEARCH_TIMEOUT + "ms")
240
+ : new Error("Serper request failed: " + String(error)),
223
241
  })
224
242
 
225
243
  if (!response.ok) {
@@ -140,8 +140,12 @@ type SearchResult = ReturnType<typeof toResult>
140
140
  // YOUCOM_LANGUAGE result language, e.g. "EN", "ZH-HANS"
141
141
  // YOUCOM_COUNTRY geographic focus, e.g. "US", "CN"
142
142
  // YOUCOM_FRESHNESS "day" | "week" | "month" | "year" or "YYYY-MM-DDtoYYYY-MM-DD"
143
+ // YOUCOM_TIMEOUT ms to wait before failing (0 removes the deadline)
143
144
  // YDC_API_KEY required; You.com's canonical env var, shared with its SDKs
144
145
  const CACHE_TTL = envInt("YOUCOM_CACHE_TTL", 600_000)
146
+ // OpenCode's own providers each bounded themselves at 25s, and the host adds
147
+ // no deadline of its own around `execute`, so this is the only one that exists.
148
+ const SEARCH_TIMEOUT = envInt("YOUCOM_TIMEOUT", 25_000)
145
149
  const SEARCH_SIZE = Math.min(Math.max(envInt("YOUCOM_COUNT", 10), 1), 100)
146
150
  const EXTRACTION = ["none", "highlights", "full_page"].includes(envStr("YOUCOM_EXTRACTION"))
147
151
  ? envStr("YOUCOM_EXTRACTION")
@@ -172,12 +176,20 @@ function writeCache(queryKey: string, results: SearchResult[]): void {
172
176
  }
173
177
  }
174
178
 
179
+ // A hung upstream must not hang the agent: the deadline bounds it, and the
180
+ // thunk's signal carries host cancellation into the socket. `timeout <= 0`
181
+ // removes the deadline but never the cancellation, because
182
+ // `AbortSignal.timeout(0)` aborts instantly rather than "never".
183
+ function timedSignal(signal: AbortSignal, timeout: number): AbortSignal {
184
+ return timeout > 0 ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
185
+ }
186
+
175
187
  export const effect = (ctx: Plugin.Context) =>
176
188
  Effect.gen(function* () {
177
189
  // Make "You.com" show up in /connect with an API-key method.
178
190
  yield* ctx.integration.transform((editor) => {
179
191
  editor.update(ID, (integration) => {
180
- integration.name = "You.com"
192
+ integration.name = "You.com (osp)"
181
193
  })
182
194
  editor.method.update({
183
195
  integrationID: ID,
@@ -244,7 +256,7 @@ export const effect = (ctx: Plugin.Context) =>
244
256
  : undefined
245
257
 
246
258
  const response = yield* Effect.tryPromise({
247
- try: () =>
259
+ try: (signal) =>
248
260
  fetch("https://ydc-index.io/v1/search", {
249
261
  method: "POST",
250
262
  headers: {
@@ -260,8 +272,14 @@ export const effect = (ctx: Plugin.Context) =>
260
272
  ...(FRESHNESS ? { freshness: FRESHNESS } : {}),
261
273
  ...(extraction ? { extraction } : {}),
262
274
  }),
275
+ signal: timedSignal(signal, SEARCH_TIMEOUT),
263
276
  }),
264
- catch: (error) => new Error("You.com request failed: " + String(error)),
277
+ // A timeout is its own failure: naming it stops the user hunting
278
+ // for a key problem that was never there.
279
+ catch: (error) =>
280
+ error instanceof Error && error.name === "TimeoutError"
281
+ ? new Error("You.com search timed out after " + SEARCH_TIMEOUT + "ms")
282
+ : new Error("You.com request failed: " + String(error)),
265
283
  })
266
284
 
267
285
  if (!response.ok) {