opencode-search-plugin 2.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -3,12 +3,14 @@ import { Effect } from "effect"
3
3
  import type { Scope } from "effect"
4
4
 
5
5
  import { effect as anysearch } from "./plugins/anysearch-search"
6
+ import { effect as bocha } from "./plugins/bocha-search"
6
7
  import { effect as brave } from "./plugins/brave-search"
7
8
  import { effect as deepseek } from "./plugins/deepseek-search"
8
9
  import { effect as jina } from "./plugins/jina-search"
9
10
  import { effect as keenable } from "./plugins/keenable-search"
10
11
  import { effect as linkup } from "./plugins/linkup-search"
11
12
  import { effect as metaso } from "./plugins/metaso-search"
13
+ import { effect as ollama } from "./plugins/ollama-search"
12
14
  import { effect as querit } from "./plugins/querit-search"
13
15
  import { effect as serpapi } from "./plugins/serpapi-search"
14
16
  import { effect as serper } from "./plugins/serper-search"
@@ -17,7 +19,7 @@ import { effect as youcom } from "./plugins/youcom-search"
17
19
  type ProviderEffect = (ctx: Plugin.Context) => Effect.Effect<void, never, Scope.Scope>
18
20
 
19
21
  /**
20
- * The eleven providers this package registers, in alphabetical order. Each entry
22
+ * The thirteen providers this package registers, in alphabetical order. Each entry
21
23
  * is the same effect its standalone file uses as a default export, so a file
22
24
  * copied out of this repository behaves exactly as it does here.
23
25
  *
@@ -28,12 +30,14 @@ type ProviderEffect = (ctx: Plugin.Context) => Effect.Effect<void, never, Scope.
28
30
  */
29
31
  const PROVIDERS: ReadonlyArray<readonly [id: string, run: ProviderEffect]> = [
30
32
  ["osp-anysearch", anysearch],
33
+ ["osp-bocha", bocha],
31
34
  ["osp-brave", brave],
32
35
  ["osp-deepseek", deepseek],
33
36
  ["osp-jina", jina],
34
37
  ["osp-keenable", keenable],
35
38
  ["osp-linkup", linkup],
36
39
  ["osp-metaso", metaso],
40
+ ["osp-ollama", ollama],
37
41
  ["osp-querit", querit],
38
42
  ["osp-serpapi", serpapi],
39
43
  ["osp-serper", serper],
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-search-plugin",
3
- "version": "2.2.0",
4
- "description": "Eleven self-contained websearch provider plugins for OpenCode v2: AnySearch, Brave, DeepSeek, Jina, Keenable, Linkup, Metaso, Querit, SerpApi, Serper and You.com.",
3
+ "version": "3.0.0",
4
+ "description": "Thirteen self-contained websearch provider plugins for OpenCode v2: AnySearch, Bocha, Brave, DeepSeek, Jina, Keenable, Linkup, Metaso, Ollama, Querit, SerpApi, Serper and You.com.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -23,11 +23,14 @@
23
23
  "exports": {
24
24
  ".": "./index.ts",
25
25
  "./anysearch-search": "./plugins/anysearch-search.ts",
26
+ "./bocha-search": "./plugins/bocha-search.ts",
26
27
  "./brave-search": "./plugins/brave-search.ts",
28
+ "./deepseek-search": "./plugins/deepseek-search.ts",
27
29
  "./jina-search": "./plugins/jina-search.ts",
28
30
  "./keenable-search": "./plugins/keenable-search.ts",
29
31
  "./linkup-search": "./plugins/linkup-search.ts",
30
32
  "./metaso-search": "./plugins/metaso-search.ts",
33
+ "./ollama-search": "./plugins/ollama-search.ts",
31
34
  "./querit-search": "./plugins/querit-search.ts",
32
35
  "./serpapi-search": "./plugins/serpapi-search.ts",
33
36
  "./serper-search": "./plugins/serper-search.ts",
@@ -74,7 +74,7 @@ function envStr(name: string): string {
74
74
  // ANYSEARCH_LANGUAGE preferred result language, e.g. "zh-CN"
75
75
  // ANYSEARCH_FORMAT "markdown" (default) or "json" for the content field
76
76
  // ANYSEARCH_TIMEOUT ms to wait before failing (0 removes the deadline)
77
- // ANYSEARCH_API_KEY optional; anonymous access works without it
77
+ // OSP_ANYSEARCH_API_KEY optional; anonymous access works without it
78
78
  const CACHE_TTL = envInt("ANYSEARCH_CACHE_TTL", 600_000)
79
79
  // OpenCode's own providers each bounded themselves at 25s, and the host adds
80
80
  // no deadline of its own around `execute`, so this is the only one that exists.
@@ -133,7 +133,7 @@ export const effect = (ctx: Plugin.Context) =>
133
133
  })
134
134
  editor.method.update({
135
135
  integrationID: ID,
136
- method: { type: "env", names: ["ANYSEARCH_API_KEY"] },
136
+ method: { type: "env", names: ["OSP_ANYSEARCH_API_KEY"] },
137
137
  })
138
138
  })
139
139
 
@@ -162,7 +162,7 @@ export const effect = (ctx: Plugin.Context) =>
162
162
  ? yield* soften(ctx.integration.connection.resolve(connection))
163
163
  : undefined
164
164
  const key =
165
- credential?.type === "key" ? credential.key : process.env.ANYSEARCH_API_KEY
165
+ credential?.type === "key" ? credential.key : process.env.OSP_ANYSEARCH_API_KEY
166
166
 
167
167
  const headers: Record<string, string> = {
168
168
  Accept: "application/json",
@@ -199,7 +199,7 @@ export const effect = (ctx: Plugin.Context) =>
199
199
  if (response.status === 402) {
200
200
  return yield* Effect.fail(
201
201
  new Error(
202
- "AnySearch quota exhausted (HTTP 402). Set ANYSEARCH_API_KEY " +
202
+ "AnySearch quota exhausted (HTTP 402). Set OSP_ANYSEARCH_API_KEY " +
203
203
  "or connect AnySearch in /connect to continue. The response " +
204
204
  "body is withheld because it may contain credentials.",
205
205
  ),
@@ -0,0 +1,392 @@
1
+ import { Plugin } from "@opencode/plugin/effect"
2
+ import { Effect, Schema, Stream } from "effect"
3
+
4
+ // Bocha answers inside a Bing-compatible envelope: `code` and `msg` mirror the
5
+ // HTTP status, `log_id` names the call in Bocha's own logs, and
6
+ // `data.webPages.value` carries the hits. A live response carries every
7
+ // documented page key -- nulls on the nullable ones, and no `summary` key at
8
+ // all unless it was asked for -- so the fields this provider reads decode as
9
+ // nullable, and one bad page must not fail the whole list.
10
+ const BochaPage = Schema.Struct({
11
+ name: Schema.optional(Schema.NullOr(Schema.String)),
12
+ url: Schema.String,
13
+ snippet: Schema.optional(Schema.NullOr(Schema.String)),
14
+ summary: Schema.optional(Schema.NullOr(Schema.String)),
15
+ datePublished: Schema.optional(Schema.NullOr(Schema.String)),
16
+ dateLastCrawled: Schema.optional(Schema.NullOr(Schema.String)),
17
+ })
18
+
19
+ const BochaResponse = Schema.Struct({
20
+ data: Schema.optional(
21
+ Schema.Struct({
22
+ webPages: Schema.optional(
23
+ Schema.Struct({
24
+ value: Schema.optional(Schema.Array(BochaPage)),
25
+ }),
26
+ ),
27
+ }),
28
+ ),
29
+ })
30
+
31
+ // The /connect integration ID, and the websearch provider ID it binds to.
32
+ // The `osp-` prefix is this package's namespace: OpenCode's built-ins and a
33
+ // vendor plugin's own entry are bare vendor names, so `bocha` would collide
34
+ // with them, and with the opencode.db row keyed by integration ID.
35
+ const ID = "osp-bocha"
36
+
37
+ function envInt(name: string, fallback: number): number {
38
+ const raw = (process.env[name] ?? "").trim()
39
+ if (raw === "") return fallback
40
+ const value = Number(raw)
41
+ return Number.isFinite(value) && value >= 0 ? value : fallback
42
+ }
43
+
44
+ function envStr(name: string): string {
45
+ return (process.env[name] ?? "").trim()
46
+ }
47
+
48
+ // Bocha stamps both of its date fields in ISO 8601, which the parser below
49
+ // accepts as-is. The relative-date tables still ship byte-identical with the
50
+ // other providers in this package -- the parser comes from the same shared
51
+ // block, and `test/structure.test.ts` compares it byte for byte.
52
+ // See https://serpapi.com/blog/how-to-parse-dates-from-google-search/
53
+ const RELATIVE_MS: Record<string, number> = {
54
+ minute: 60_000,
55
+ hour: 3_600_000,
56
+ day: 86_400_000,
57
+ week: 604_800_000,
58
+ month: 2_592_000_000,
59
+ year: 31_536_000_000,
60
+ 分钟: 60_000,
61
+ 小时: 3_600_000,
62
+ 天: 86_400_000,
63
+ 周: 604_800_000,
64
+ 个月: 2_592_000_000,
65
+ 月: 2_592_000_000,
66
+ 年: 31_536_000_000,
67
+ }
68
+
69
+ const RELATIVE_DATE =
70
+ /^(\d+)\s*(minute|hour|day|week|month|year|分钟|小时|天|周|个月|月|年)s?\s*(?:ago|前)$/i
71
+
72
+ const ABSOLUTE_DATE = [
73
+ /^\d{1,2}\s+[A-Za-z]{3,9}\s+\d{4}$/, // "3 Jan 2022", "31 Mar 2026"
74
+ /^[A-Za-z]{3,9}\s+\d{1,2},?\s+\d{4}$/, // "Sep 30, 2026", "Jan 27, 2025"
75
+ /^\d{4}-\d{2}-\d{2}$/, // "2026-10-16"
76
+ // ISO 8601 with a time component, which Bocha stamps on both of its date
77
+ // fields -- with a UTC+8 offset on `datePublished`, and with a "Z" that
78
+ // lies on `dateLastCrawled`.
79
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/,
80
+ ]
81
+
82
+ function parseSearchDate(value: string | undefined): number {
83
+ const text = value?.trim()
84
+ if (!text) return NaN
85
+
86
+ const relative = RELATIVE_DATE.exec(text)
87
+ if (relative) {
88
+ const unit = relative[2] !== undefined ? RELATIVE_MS[relative[2].toLowerCase()] : undefined
89
+ const amount = Number(relative[1])
90
+ if (unit === undefined || !Number.isFinite(amount)) return NaN
91
+ return Date.now() - amount * unit
92
+ }
93
+
94
+ // Only trust Date.parse on shapes these providers actually emit -- its lenient
95
+ // parser invents a year for strings like "Nov 1", which would date the result
96
+ // to 2001.
97
+ return ABSOLUTE_DATE.some((shape) => shape.test(text)) ? Date.parse(text) : NaN
98
+ }
99
+
100
+ // `dateLastCrawled` is Bocha's misnamed publish time: the field name is a Bing
101
+ // compatibility shim, and Bocha's docs date the correction to a v2 that has not
102
+ // shipped. It also stamps a UTC+8 wall clock with a "Z" -- live responses carry
103
+ // both date fields on the same wall clock, one honest (`datePublished`, with
104
+ // its `+08:00` offset) and one lying (`dateLastCrawled`, with a `Z`), which is
105
+ // why the honest one is preferred above and this fallback rewrites the "Z" to
106
+ // "+08:00" before parsing, the workaround the docs themselves recommend. When
107
+ // v2 lands, this rewrite is what has to go with it.
108
+ function publishedAt(item: {
109
+ datePublished?: string | null | undefined
110
+ dateLastCrawled?: string | null | undefined
111
+ }): number {
112
+ const fromPublishDate = parseSearchDate(item.datePublished ?? undefined)
113
+ if (Number.isFinite(fromPublishDate)) return fromPublishDate
114
+ return parseSearchDate(item.dateLastCrawled?.replace(/Z$/, "+08:00"))
115
+ }
116
+
117
+ function toResult(item: {
118
+ name?: string | null | undefined
119
+ url: string
120
+ snippet?: string | null | undefined
121
+ summary?: string | null | undefined
122
+ datePublished?: string | null | undefined
123
+ dateLastCrawled?: string | null | undefined
124
+ }) {
125
+ const published = publishedAt(item)
126
+ return {
127
+ url: item.url,
128
+ // Bocha always fills `name` in its examples, but the URL is the one field
129
+ // the schema insists on, so it is also the one thing a title can fall back
130
+ // to rather than leave the result untitled.
131
+ title: item.name ?? item.url,
132
+ // `summary` is the generated abstract, present only when it was asked for
133
+ // and could be written; `snippet` is the excerpt that is always there.
134
+ // `||`, not `??`: an empty `summary` means Bocha declined to write one,
135
+ // and an empty string is not an answer worth handing the agent.
136
+ content: item.summary || item.snippet || "",
137
+ time: Number.isFinite(published) ? { published } : {},
138
+ }
139
+ }
140
+
141
+ type SearchResult = ReturnType<typeof toResult>
142
+
143
+ // Bocha bills per search, at a tiered rate, so a repeat query is a dispatch
144
+ // worth skipping. The knobs trade money for context: `BOCHA_SUMMARY` in
145
+ // particular swaps the free snippet for a generated summary, which is dearer
146
+ // per call than the search that produced it. Knobs are read once at load time,
147
+ // so restart after changing them.
148
+ //
149
+ // BOCHA_CACHE_TTL ms to reuse a response for the same query (0 disables)
150
+ // BOCHA_COUNT results per search, clamped to 1-50 (the API's range)
151
+ // BOCHA_FRESHNESS "noLimit", "oneDay", "oneWeek", "oneMonth", "oneYear",
152
+ // a "YYYY-MM-DD" date, or a "YYYY-MM-DD..YYYY-MM-DD"
153
+ // range. Unset omits the parameter, which is the same
154
+ // thing: the docs recommend noLimit because the search
155
+ // rewrites time ranges itself, and a pinned range often
156
+ // finds nothing within it.
157
+ // BOCHA_SUMMARY true swaps each snippet for a generated summary --
158
+ // more context to read, at the higher per-call price
159
+ // BOCHA_TIMEOUT ms to wait before failing (0 removes the deadline)
160
+ // OSP_BOCHA_API_KEY required; from https://open.bocha.cn
161
+ const CACHE_TTL = envInt("BOCHA_CACHE_TTL", 600_000)
162
+ // OpenCode's own providers each bounded themselves at 25s, and the host adds
163
+ // no deadline of its own around `execute`, so this is the only one that exists.
164
+ const SEARCH_TIMEOUT = envInt("BOCHA_TIMEOUT", 25_000)
165
+ const COUNT = Math.min(Math.max(envInt("BOCHA_COUNT", 10), 1), 50)
166
+ const FRESHNESS = envStr("BOCHA_FRESHNESS")
167
+ const SUMMARY = envStr("BOCHA_SUMMARY") === "true"
168
+
169
+ const SEARCH_URL = "https://api.bocha.cn/v1/web-search"
170
+
171
+ const CACHE_LIMIT = 200
172
+ const cache = new Map<string, { at: number; results: SearchResult[] }>()
173
+
174
+ function cacheKey(query: string): string {
175
+ return query.trim().replace(/\s+/g, " ")
176
+ }
177
+
178
+ function readCache(queryKey: string): SearchResult[] | undefined {
179
+ if (CACHE_TTL <= 0) return undefined
180
+ const hit = cache.get(queryKey)
181
+ return hit && Date.now() - hit.at < CACHE_TTL ? hit.results : undefined
182
+ }
183
+
184
+ function writeCache(queryKey: string, results: SearchResult[]): void {
185
+ if (CACHE_TTL <= 0) return
186
+ cache.set(queryKey, { at: Date.now(), results })
187
+ if (cache.size > CACHE_LIMIT) {
188
+ const oldest = cache.keys().next().value
189
+ if (oldest !== undefined) cache.delete(oldest)
190
+ }
191
+ }
192
+
193
+ // Bocha's failures arrive as `{"code","message","log_id"}`, and the `log_id`
194
+ // is what their support needs to find the call again. A body in any other
195
+ // shape still stands in as the detail verbatim.
196
+ function describeFailure(detail: string): string {
197
+ try {
198
+ const body: unknown = JSON.parse(detail)
199
+ if (typeof body === "object" && body !== null) {
200
+ const { message, log_id: logID } = body as { message?: unknown; log_id?: unknown }
201
+ if (typeof message === "string" && message !== "") {
202
+ return message + (typeof logID === "string" && logID !== "" ? ` (log_id ${logID})` : "")
203
+ }
204
+ }
205
+ } catch {
206
+ // Not JSON: the raw body is the only detail there is.
207
+ }
208
+ return detail
209
+ }
210
+
211
+ // A hung upstream must not hang the agent: the deadline bounds it, and the
212
+ // thunk's signal carries host cancellation into the socket. `timeout <= 0`
213
+ // removes the deadline but never the cancellation, because
214
+ // `AbortSignal.timeout(0)` aborts instantly rather than "never".
215
+ function timedSignal(signal: AbortSignal, timeout: number): AbortSignal {
216
+ return timeout > 0 ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
217
+ }
218
+
219
+ export const effect = (ctx: Plugin.Context) =>
220
+ Effect.gen(function* () {
221
+ // Make "Bocha" show up in /connect with an API-key method.
222
+ yield* ctx.integration.transform((editor) => {
223
+ editor.update(ID, (integration) => {
224
+ integration.name = "Bocha Search (osp)"
225
+ })
226
+ editor.method.update({
227
+ integrationID: ID,
228
+ method: { type: "key", label: "Bocha API key" },
229
+ })
230
+ editor.method.update({
231
+ integrationID: ID,
232
+ method: { type: "env", names: ["OSP_BOCHA_API_KEY"] },
233
+ })
234
+ })
235
+
236
+ // The host instantiates this API's failure channel as `unknown`, which would
237
+ // make the whole plugin effect unrunnable. A credential read must never take
238
+ // the plugin down: a failed read degrades to "no stored key", and the
239
+ // environment fallback below applies exactly as it does when the credential
240
+ // simply does not exist.
241
+ const soften = <A>(read: Effect.Effect<A, unknown>): Effect.Effect<A | undefined, never> =>
242
+ read.pipe(Effect.catch(() => Effect.succeed(undefined)))
243
+
244
+ // The key comes from the /connect credential, falling back to the
245
+ // environment. Resolved per call so a rotated key needs no restart.
246
+ const resolveKey = () =>
247
+ Effect.gen(function* () {
248
+ const connection = yield* soften(ctx.integration.connection.active(ID))
249
+ const credential = connection
250
+ ? yield* soften(ctx.integration.connection.resolve(connection))
251
+ : undefined
252
+ return credential?.type === "key" ? credential.key : process.env.OSP_BOCHA_API_KEY
253
+ })
254
+
255
+ // Bocha has no anonymous tier, so the provider binds only while a key
256
+ // exists. Deleting the key unbinds it, and `random` can never pick a
257
+ // keyless Bocha. The transform replays on reload and reads `bound` as it
258
+ // stands then.
259
+ let bound = Boolean(yield* resolveKey())
260
+
261
+ yield* ctx.websearch.transform((editor) => {
262
+ if (!bound) return
263
+ editor.add({
264
+ id: ID,
265
+ name: "Bocha Search",
266
+ execute: ({ query }) =>
267
+ Effect.gen(function* () {
268
+ const queryKey = cacheKey(query)
269
+ const cached = readCache(queryKey)
270
+ if (cached) return cached
271
+
272
+ // Rechecked per call: binding can lag a key deletion by the
273
+ // event round-trip, so this keeps that window explicit.
274
+ const key = yield* resolveKey()
275
+ if (!key) {
276
+ return yield* Effect.fail(
277
+ new Error("Connect Bocha in /connect, or set OSP_BOCHA_API_KEY"),
278
+ )
279
+ }
280
+
281
+ const response = yield* Effect.tryPromise({
282
+ try: (signal) =>
283
+ fetch(SEARCH_URL, {
284
+ method: "POST",
285
+ headers: {
286
+ Accept: "application/json",
287
+ "Content-Type": "application/json",
288
+ Authorization: "Bearer " + key,
289
+ },
290
+ body: JSON.stringify({
291
+ query: queryKey,
292
+ count: COUNT,
293
+ summary: SUMMARY,
294
+ // Omitted rather than sent as "noLimit": the two mean the
295
+ // same thing, and the docs describe noLimit as the search
296
+ // rewriting time ranges on its own.
297
+ ...(FRESHNESS ? { freshness: FRESHNESS } : {}),
298
+ }),
299
+ signal: timedSignal(signal, SEARCH_TIMEOUT),
300
+ }),
301
+ // A timeout is its own failure: naming it stops the user hunting
302
+ // for a key problem that was never there.
303
+ catch: (error) =>
304
+ error instanceof Error && error.name === "TimeoutError"
305
+ ? new Error("Bocha search timed out after " + SEARCH_TIMEOUT + "ms")
306
+ : new Error("Bocha request failed: " + String(error)),
307
+ })
308
+
309
+ if (!response.ok) {
310
+ const detail = yield* Effect.tryPromise({
311
+ try: () => response.text(),
312
+ catch: () => new Error("response body unavailable"),
313
+ })
314
+ const described = describeFailure(String(detail))
315
+
316
+ // Bocha names each refusal in its own body, and the three below
317
+ // carry a fix the user can act on -- a status code would send
318
+ // them hunting for an auth problem that was really a balance.
319
+ if (response.status === 401) {
320
+ return yield* Effect.fail(
321
+ new Error(
322
+ "Bocha rejected the API key (HTTP 401): " +
323
+ described +
324
+ ". Connect Bocha in /connect or set OSP_BOCHA_API_KEY to a key from https://open.bocha.cn",
325
+ ),
326
+ )
327
+ }
328
+ if (response.status === 403) {
329
+ return yield* Effect.fail(
330
+ new Error(
331
+ "Bocha balance is exhausted (HTTP 403): " +
332
+ described +
333
+ ". Top up at https://open.bocha.cn",
334
+ ),
335
+ )
336
+ }
337
+ if (response.status === 429) {
338
+ const retry = response.headers.get("Retry-After")
339
+ return yield* Effect.fail(
340
+ new Error(
341
+ "Bocha rate limited the request (HTTP 429)" +
342
+ (retry ? ", retry after " + retry + "s" : "") +
343
+ ": " +
344
+ described +
345
+ ". The request cap scales with the account's recharge amount",
346
+ ),
347
+ )
348
+ }
349
+ return yield* Effect.fail(
350
+ new Error("Bocha API error " + response.status + ": " + described),
351
+ )
352
+ }
353
+
354
+ const json = yield* Effect.tryPromise({
355
+ try: () => response.json(),
356
+ catch: (error) => new Error("Invalid Bocha response: " + String(error)),
357
+ })
358
+
359
+ const body = yield* Schema.decodeUnknownEffect(BochaResponse)(json)
360
+ const results = (body.data?.webPages?.value ?? []).map(toResult)
361
+
362
+ writeCache(queryKey, results)
363
+ return results
364
+ }),
365
+ })
366
+ })
367
+
368
+ // Rebind on credential changes: connect, delete, or rotate a key and the
369
+ // provider joins or leaves the websearch pool without a restart. Env-var
370
+ // keys cannot change under a running process, so they bind at startup.
371
+ yield* Effect.forkScoped(
372
+ ctx.event.subscribe().pipe(
373
+ Stream.filter(
374
+ (event) =>
375
+ event.type === "credential.updated" ||
376
+ event.type === "credential.switched" ||
377
+ event.type === "integration.updated",
378
+ ),
379
+ Stream.mapEffect(() =>
380
+ Effect.gen(function* () {
381
+ const next = Boolean(yield* resolveKey())
382
+ if (next === bound) return
383
+ bound = next
384
+ yield* ctx.websearch.reload()
385
+ }),
386
+ ),
387
+ Stream.runDrain,
388
+ ),
389
+ )
390
+ })
391
+
392
+ export default Plugin.define({ id: "bocha-search", effect })
@@ -116,7 +116,7 @@ type SearchResult = ReturnType<typeof toResult>
116
116
  // "YYYY-MM-DDtoYYYY-MM-DD" range
117
117
  // BRAVE_TIMEOUT ms to wait for Brave before failing (0 removes the
118
118
  // deadline; cancellation still applies)
119
- // BRAVE_SEARCH_API_KEY required; from https://brave.com/search/api/
119
+ // OSP_BRAVE_SEARCH_API_KEY required; from https://brave.com/search/api/
120
120
  const CACHE_TTL = envInt("BRAVE_CACHE_TTL", 600_000)
121
121
  const SEARCH_SIZE = Math.min(Math.max(envInt("BRAVE_NUM", 8), 1), 20)
122
122
  const COUNTRY = envStr("BRAVE_COUNTRY")
@@ -169,7 +169,7 @@ export const effect = (ctx: Plugin.Context) =>
169
169
  })
170
170
  editor.method.update({
171
171
  integrationID: ID,
172
- method: { type: "env", names: ["BRAVE_SEARCH_API_KEY"] },
172
+ method: { type: "env", names: ["OSP_BRAVE_SEARCH_API_KEY"] },
173
173
  })
174
174
  })
175
175
 
@@ -189,7 +189,7 @@ export const effect = (ctx: Plugin.Context) =>
189
189
  const credential = connection
190
190
  ? yield* soften(ctx.integration.connection.resolve(connection))
191
191
  : undefined
192
- return credential?.type === "key" ? credential.key : process.env.BRAVE_SEARCH_API_KEY
192
+ return credential?.type === "key" ? credential.key : process.env.OSP_BRAVE_SEARCH_API_KEY
193
193
  })
194
194
 
195
195
  // Brave is bound to websearch only while a key exists. Deleting the key
@@ -213,7 +213,7 @@ export const effect = (ctx: Plugin.Context) =>
213
213
  const key = yield* resolveKey()
214
214
  if (!key) {
215
215
  return yield* Effect.fail(
216
- new Error("Connect Brave in /connect, or set BRAVE_SEARCH_API_KEY"),
216
+ new Error("Connect Brave in /connect, or set OSP_BRAVE_SEARCH_API_KEY"),
217
217
  )
218
218
  }
219
219
 
@@ -113,7 +113,7 @@ type SearchResult = ReturnType<typeof toResult>
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
115
  // DEEPSEEK_TIMEOUT ms to wait before failing (0 removes the deadline)
116
- // DEEPSEEK_API_KEY required; from https://platform.deepseek.com
116
+ // OSP_DEEPSEEK_API_KEY required; from https://platform.deepseek.com
117
117
  const CACHE_TTL = envInt("DEEPSEEK_CACHE_TTL", 600_000)
118
118
  // OpenCode's own providers each bounded themselves at 25s, and the host adds
119
119
  // no deadline of its own around `execute`, so this is the only one that exists.
@@ -163,7 +163,7 @@ export const effect = (ctx: Plugin.Context) =>
163
163
  })
164
164
  editor.method.update({
165
165
  integrationID: ID,
166
- method: { type: "env", names: ["DEEPSEEK_API_KEY"] },
166
+ method: { type: "env", names: ["OSP_DEEPSEEK_API_KEY"] },
167
167
  })
168
168
  })
169
169
 
@@ -183,7 +183,7 @@ export const effect = (ctx: Plugin.Context) =>
183
183
  const credential = connection
184
184
  ? yield* soften(ctx.integration.connection.resolve(connection))
185
185
  : undefined
186
- return credential?.type === "key" ? credential.key : process.env.DEEPSEEK_API_KEY
186
+ return credential?.type === "key" ? credential.key : process.env.OSP_DEEPSEEK_API_KEY
187
187
  })
188
188
 
189
189
  // DeepSeek is bound to websearch only while a key exists. Deleting the key
@@ -207,7 +207,7 @@ export const effect = (ctx: Plugin.Context) =>
207
207
  const key = yield* resolveKey()
208
208
  if (!key) {
209
209
  return yield* Effect.fail(
210
- new Error("Connect DeepSeek in /connect, or set DEEPSEEK_API_KEY"),
210
+ new Error("Connect DeepSeek in /connect, or set OSP_DEEPSEEK_API_KEY"),
211
211
  )
212
212
  }
213
213
 
@@ -81,7 +81,7 @@ function envStr(name: string): string {
81
81
  // Defaults to 25000, or 60000 when JINA_INCLUDE_CONTENT
82
82
  // is on, because whole-page fetches legitimately take
83
83
  // far longer than a snippet search
84
- // JINA_API_KEY required; free key at https://jina.ai/?sui=apikey
84
+ // OSP_JINA_API_KEY required; free key at https://jina.ai/?sui=apikey
85
85
  const CACHE_TTL = envInt("JINA_CACHE_TTL", 600_000)
86
86
  const INCLUDE_CONTENT = envStr("JINA_INCLUDE_CONTENT") === "true"
87
87
  const SEARCH_SIZE = Math.min(Math.max(envInt("JINA_NUM", 0), 0), 100)
@@ -139,7 +139,7 @@ export const effect = (ctx: Plugin.Context) =>
139
139
  })
140
140
  editor.method.update({
141
141
  integrationID: ID,
142
- method: { type: "env", names: ["JINA_API_KEY"] },
142
+ method: { type: "env", names: ["OSP_JINA_API_KEY"] },
143
143
  })
144
144
  })
145
145
 
@@ -159,7 +159,7 @@ export const effect = (ctx: Plugin.Context) =>
159
159
  const credential = connection
160
160
  ? yield* soften(ctx.integration.connection.resolve(connection))
161
161
  : undefined
162
- return credential?.type === "key" ? credential.key : process.env.JINA_API_KEY
162
+ return credential?.type === "key" ? credential.key : process.env.OSP_JINA_API_KEY
163
163
  })
164
164
 
165
165
  // Jina is bound to websearch only while a key exists. Deleting the key
@@ -183,7 +183,7 @@ export const effect = (ctx: Plugin.Context) =>
183
183
  const key = yield* resolveKey()
184
184
  if (!key) {
185
185
  return yield* Effect.fail(
186
- new Error("Connect Jina in /connect, or set JINA_API_KEY"),
186
+ new Error("Connect Jina in /connect, or set OSP_JINA_API_KEY"),
187
187
  )
188
188
  }
189
189
 
@@ -123,7 +123,7 @@ function envStr(name: string): string {
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
125
  // KEENABLE_TIMEOUT ms to wait before failing (0 removes the deadline)
126
- // KEENABLE_API_KEY optional; keyless access works without it
126
+ // OSP_KEENABLE_API_KEY optional; keyless access works without it
127
127
  const CACHE_TTL = envInt("KEENABLE_CACHE_TTL", 600_000)
128
128
  // OpenCode's own providers each bounded themselves at 25s, and the host adds
129
129
  // no deadline of its own around `execute`, so this is the only one that exists.
@@ -183,7 +183,7 @@ export const effect = (ctx: Plugin.Context) =>
183
183
  })
184
184
  editor.method.update({
185
185
  integrationID: ID,
186
- method: { type: "env", names: ["KEENABLE_API_KEY"] },
186
+ method: { type: "env", names: ["OSP_KEENABLE_API_KEY"] },
187
187
  })
188
188
  })
189
189
 
@@ -212,7 +212,7 @@ export const effect = (ctx: Plugin.Context) =>
212
212
  ? yield* soften(ctx.integration.connection.resolve(connection))
213
213
  : undefined
214
214
  const key =
215
- credential?.type === "key" ? credential.key : process.env.KEENABLE_API_KEY
215
+ credential?.type === "key" ? credential.key : process.env.OSP_KEENABLE_API_KEY
216
216
 
217
217
  const headers: Record<string, string> = {
218
218
  Accept: "application/json",
@@ -253,7 +253,7 @@ export const effect = (ctx: Plugin.Context) =>
253
253
  new Error(
254
254
  "Keenable credits exhausted (HTTP 402): the free monthly " +
255
255
  "allowance of 100,000 requests is spent. Connect Keenable " +
256
- "in /connect or set KEENABLE_API_KEY to buy credits, or " +
256
+ "in /connect or set OSP_KEENABLE_API_KEY to buy credits, or " +
257
257
  "wait for the allowance to reset.",
258
258
  ),
259
259
  )
@@ -267,7 +267,7 @@ export const effect = (ctx: Plugin.Context) =>
267
267
  (key
268
268
  ? ""
269
269
  : ". The keyless pool allows 1,000 requests an hour and " +
270
- "10 a second per IP; set KEENABLE_API_KEY to spend your " +
270
+ "10 a second per IP; set OSP_KEENABLE_API_KEY to spend your " +
271
271
  "own 100,000-request monthly allowance instead."),
272
272
  ),
273
273
  )