opencode-search-plugin 3.1.0 → 3.3.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 +171 -134
- package/README.zh-CN.md +176 -139
- package/index.ts +5 -1
- package/package.json +4 -2
- package/plugins/perplexity-search.ts +332 -0
- package/plugins/zhipu-search.ts +426 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin/effect"
|
|
2
|
+
import { Effect, Schema, Stream } from "effect"
|
|
3
|
+
|
|
4
|
+
// Zhipu answers with `search_result` at the top level, alongside passengers
|
|
5
|
+
// this provider never reads: `id`/`created`/`request_id` name the call and
|
|
6
|
+
// `search_intent` echoes the query rewrite (present only when intent
|
|
7
|
+
// recognition ran). Each hit carries `media` (site name), `icon` and `refer`
|
|
8
|
+
// (the citation index the chat product stamps its answers with) next to the
|
|
9
|
+
// four fields mapped below. The docs show every hit with a `link`, but a
|
|
10
|
+
// search list is only as good as its worst hit: all four decode as nullable
|
|
11
|
+
// and a hit without a URL is dropped, so one malformed row cannot fail the
|
|
12
|
+
// whole list.
|
|
13
|
+
const ZhipuHit = Schema.Struct({
|
|
14
|
+
title: Schema.optional(Schema.NullOr(Schema.String)),
|
|
15
|
+
content: Schema.optional(Schema.NullOr(Schema.String)),
|
|
16
|
+
link: Schema.optional(Schema.NullOr(Schema.String)),
|
|
17
|
+
// "2025-05-23" in the documented examples, or absent when unknown.
|
|
18
|
+
publish_date: Schema.optional(Schema.NullOr(Schema.String)),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const ZhipuResponse = Schema.Struct({
|
|
22
|
+
// Absent or null when the search found nothing.
|
|
23
|
+
search_result: Schema.optional(Schema.NullOr(Schema.Array(ZhipuHit))),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// The /connect integration ID, and the websearch provider ID it binds to.
|
|
27
|
+
// The `osp-` prefix is this package's namespace: OpenCode's built-ins and a
|
|
28
|
+
// vendor plugin's own entry are bare vendor names, so `zhipu` would collide
|
|
29
|
+
// with them, and with the opencode.db row keyed by integration ID.
|
|
30
|
+
const ID = "osp-zhipu"
|
|
31
|
+
|
|
32
|
+
function envInt(name: string, fallback: number): number {
|
|
33
|
+
const raw = (process.env[name] ?? "").trim()
|
|
34
|
+
if (raw === "") return fallback
|
|
35
|
+
const value = Number(raw)
|
|
36
|
+
return Number.isFinite(value) && value >= 0 ? value : fallback
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function envStr(name: string): string {
|
|
40
|
+
return (process.env[name] ?? "").trim()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Zhipu stamps `publish_date` as a bare calendar date ("2025-05-23"), a shape
|
|
44
|
+
// the parser below already accepts as-is. The relative-date tables still ship
|
|
45
|
+
// byte-identical with the other providers in this package -- the parser comes
|
|
46
|
+
// from the same shared block, and `test/structure.test.ts` compares it byte
|
|
47
|
+
// for byte.
|
|
48
|
+
// See https://serpapi.com/blog/how-to-parse-dates-from-google-search/
|
|
49
|
+
const RELATIVE_MS: Record<string, number> = {
|
|
50
|
+
minute: 60_000,
|
|
51
|
+
hour: 3_600_000,
|
|
52
|
+
day: 86_400_000,
|
|
53
|
+
week: 604_800_000,
|
|
54
|
+
month: 2_592_000_000,
|
|
55
|
+
year: 31_536_000_000,
|
|
56
|
+
分钟: 60_000,
|
|
57
|
+
小时: 3_600_000,
|
|
58
|
+
天: 86_400_000,
|
|
59
|
+
周: 604_800_000,
|
|
60
|
+
个月: 2_592_000_000,
|
|
61
|
+
月: 2_592_000_000,
|
|
62
|
+
年: 31_536_000_000,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const RELATIVE_DATE =
|
|
66
|
+
/^(\d+)\s*(minute|hour|day|week|month|year|分钟|小时|天|周|个月|月|年)s?\s*(?:ago|前)$/i
|
|
67
|
+
|
|
68
|
+
const ABSOLUTE_DATE = [
|
|
69
|
+
/^\d{1,2}\s+[A-Za-z]{3,9}\s+\d{4}$/, // "3 Jan 2022", "31 Mar 2026"
|
|
70
|
+
/^[A-Za-z]{3,9}\s+\d{1,2},?\s+\d{4}$/, // "Sep 30, 2026", "Jan 27, 2025"
|
|
71
|
+
/^\d{4}-\d{2}-\d{2}$/, // "2026-10-16"
|
|
72
|
+
// ISO 8601 with a time component, which some providers return verbatim.
|
|
73
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/,
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
function parseSearchDate(value: string | undefined): number {
|
|
77
|
+
const text = value?.trim()
|
|
78
|
+
if (!text) return NaN
|
|
79
|
+
|
|
80
|
+
const relative = RELATIVE_DATE.exec(text)
|
|
81
|
+
if (relative) {
|
|
82
|
+
const unit = relative[2] !== undefined ? RELATIVE_MS[relative[2].toLowerCase()] : undefined
|
|
83
|
+
const amount = Number(relative[1])
|
|
84
|
+
if (unit === undefined || !Number.isFinite(amount)) return NaN
|
|
85
|
+
return Date.now() - amount * unit
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Only trust Date.parse on shapes these providers actually emit -- its lenient
|
|
89
|
+
// parser invents a year for strings like "Nov 1", which would date the result
|
|
90
|
+
// to 2001.
|
|
91
|
+
return ABSOLUTE_DATE.some((shape) => shape.test(text)) ? Date.parse(text) : NaN
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// `publish_date` is the only time field in a hit, and it is a bare calendar
|
|
95
|
+
// date in the documented examples -- parsed by the shared block above, and
|
|
96
|
+
// left as no `time` at all when absent or unparseable.
|
|
97
|
+
function toResult(item: {
|
|
98
|
+
title?: string | null | undefined
|
|
99
|
+
content?: string | null | undefined
|
|
100
|
+
link?: string | null | undefined
|
|
101
|
+
publish_date?: string | null | undefined
|
|
102
|
+
}) {
|
|
103
|
+
// A hit without a URL cannot be opened, cited, or read as a source, so it
|
|
104
|
+
// leaves the list instead of reaching the agent as a dead card.
|
|
105
|
+
if (!item.link) return undefined
|
|
106
|
+
const published = parseSearchDate(item.publish_date ?? undefined)
|
|
107
|
+
return {
|
|
108
|
+
url: item.link,
|
|
109
|
+
// The docs always fill `title` in their examples, but `link` is the field
|
|
110
|
+
// the mapping insists on, so it is also the one thing a title can fall
|
|
111
|
+
// back to rather than leave the result untitled.
|
|
112
|
+
title: item.title || item.link,
|
|
113
|
+
// `content` is the excerpt Zhipu derived for the hit. `||`, not `??`: an
|
|
114
|
+
// empty string means there was no excerpt worth reading, and an empty
|
|
115
|
+
// string is not an answer worth handing the agent.
|
|
116
|
+
content: item.content || "",
|
|
117
|
+
time: Number.isFinite(published) ? { published } : {},
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
type SearchResult = NonNullable<ReturnType<typeof toResult>>
|
|
122
|
+
|
|
123
|
+
// Zhipu bills per search -- ¥0.01 on Search-Std, ¥0.03 on Search-Pro, ¥0.05 on
|
|
124
|
+
// the Sogou and Quark engines, $0.01 on the international endpoint -- with no
|
|
125
|
+
// free tier and no anonymous tier, so a repeat query is a dispatch worth
|
|
126
|
+
// skipping. Knobs are read once at load time, so restart after changing them.
|
|
127
|
+
//
|
|
128
|
+
// ZHIPU_CACHE_TTL ms to reuse a response for the same query (0 disables)
|
|
129
|
+
// ZHIPU_COUNT results per search, clamped to 1-50 (the API's range)
|
|
130
|
+
// ZHIPU_ENGINE search_std (default, ¥0.01/search), search_pro (¥0.03,
|
|
131
|
+
// higher recall), search_pro_sogou, search_pro_quark
|
|
132
|
+
// (¥0.05 each); on ZHIPU_SITE=intl the only engine the
|
|
133
|
+
// docs accept is search-prime
|
|
134
|
+
// ZHIPU_RECENCY oneDay, oneWeek, oneMonth, oneYear, noLimit; unset
|
|
135
|
+
// omits the parameter, which is the API's noLimit default
|
|
136
|
+
// ZHIPU_CONTENT_SIZE medium or high; unset omits it (the API defaults to
|
|
137
|
+
// medium). high sends fuller excerpts at the same price
|
|
138
|
+
// ZHIPU_SITE cn (default) for open.bigmodel.cn, intl for api.z.ai --
|
|
139
|
+
// separate accounts, keys and prices, not interchangeable
|
|
140
|
+
// ZHIPU_TIMEOUT ms to wait before failing (0 removes the deadline)
|
|
141
|
+
// OSP_ZHIPU_API_KEY required; from https://bigmodel.cn/usercenter/proj-mgmt/apikeys
|
|
142
|
+
const CACHE_TTL = envInt("ZHIPU_CACHE_TTL", 600_000)
|
|
143
|
+
// OpenCode's own providers each bounded themselves at 25s, and the host adds
|
|
144
|
+
// no deadline of its own around `execute`, so this is the only one that exists.
|
|
145
|
+
const SEARCH_TIMEOUT = envInt("ZHIPU_TIMEOUT", 25_000)
|
|
146
|
+
const COUNT = Math.min(Math.max(envInt("ZHIPU_COUNT", 10), 1), 50)
|
|
147
|
+
const SITE = envStr("ZHIPU_SITE") === "intl" ? "intl" : "cn"
|
|
148
|
+
const ENGINE = envStr("ZHIPU_ENGINE") || (SITE === "intl" ? "search-prime" : "search_std")
|
|
149
|
+
const RECENCY = envStr("ZHIPU_RECENCY")
|
|
150
|
+
const CONTENT_SIZE = envStr("ZHIPU_CONTENT_SIZE")
|
|
151
|
+
|
|
152
|
+
// Two endpoints, two account systems: a cn key is not an intl key, and the
|
|
153
|
+
// engine enums and prices differ with the host. The path is shared.
|
|
154
|
+
const SEARCH_URL =
|
|
155
|
+
SITE === "intl"
|
|
156
|
+
? "https://api.z.ai/api/paas/v4/web_search"
|
|
157
|
+
: "https://open.bigmodel.cn/api/paas/v4/web_search"
|
|
158
|
+
|
|
159
|
+
// The spec caps `search_query` at 70 characters (`maxLength: 70`), so a longer
|
|
160
|
+
// query is cut here rather than sent to fail as a 400. Cut by code point, so
|
|
161
|
+
// a CJK character or an emoji is never sliced through its halves. The cache
|
|
162
|
+
// still keys on the full query: two queries that differ only past character
|
|
163
|
+
// 70 are two separate answers the agent gave, even if they bill alike.
|
|
164
|
+
function toSearchQuery(queryKey: string): string {
|
|
165
|
+
return [...queryKey].slice(0, 70).join("")
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// `count` is documented for `search_std`, `search_pro` and `search_pro_sogou`
|
|
169
|
+
// on the cn endpoint, and for every engine on the intl one, so it is omitted
|
|
170
|
+
// on `search_pro_quark` -- whose row omits it -- rather than sent to be
|
|
171
|
+
// ignored. Sogou accepts only five sizes (10/20/30/40/50), so a knob value
|
|
172
|
+
// snaps to the nearest of them instead of arriving as a rejected 15.
|
|
173
|
+
function countFor(engine: string, count: number): number | undefined {
|
|
174
|
+
if (engine === "search_pro_quark") return undefined
|
|
175
|
+
if (engine === "search_pro_sogou") {
|
|
176
|
+
return Math.min(Math.max(Math.round(count / 10) * 10, 10), 50)
|
|
177
|
+
}
|
|
178
|
+
return count
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const CACHE_LIMIT = 200
|
|
182
|
+
const cache = new Map<string, { at: number; results: SearchResult[] }>()
|
|
183
|
+
|
|
184
|
+
function cacheKey(query: string): string {
|
|
185
|
+
return query.trim().replace(/\s+/g, " ")
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function readCache(queryKey: string): SearchResult[] | undefined {
|
|
189
|
+
if (CACHE_TTL <= 0) return undefined
|
|
190
|
+
const hit = cache.get(queryKey)
|
|
191
|
+
return hit && Date.now() - hit.at < CACHE_TTL ? hit.results : undefined
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function writeCache(queryKey: string, results: SearchResult[]): void {
|
|
195
|
+
if (CACHE_TTL <= 0) return
|
|
196
|
+
cache.set(queryKey, { at: Date.now(), results })
|
|
197
|
+
if (cache.size > CACHE_LIMIT) {
|
|
198
|
+
const oldest = cache.keys().next().value
|
|
199
|
+
if (oldest !== undefined) cache.delete(oldest)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Zhipu does not document this endpoint's refusal body, so the detail is read
|
|
204
|
+
// for whichever shape it actually arrives in: the OpenAI-compatible
|
|
205
|
+
// `{"error":{"code","message"}}` the platform's chat API answers with, or a
|
|
206
|
+
// flat `{"code","message"}` carrying the search-specific codes (1701: search
|
|
207
|
+
// concurrency at its cap, 1702: no usable engine, 1703: the engine returned
|
|
208
|
+
// nothing). A body in any other shape stands in as the detail verbatim.
|
|
209
|
+
function describeFailure(detail: string): string {
|
|
210
|
+
try {
|
|
211
|
+
const body: unknown = JSON.parse(detail)
|
|
212
|
+
if (typeof body === "object" && body !== null) {
|
|
213
|
+
const record = body as Record<string, unknown>
|
|
214
|
+
const source = (
|
|
215
|
+
typeof record.error === "object" && record.error !== null ? record.error : record
|
|
216
|
+
) as Record<string, unknown>
|
|
217
|
+
const message =
|
|
218
|
+
typeof source.message === "string" && source.message !== ""
|
|
219
|
+
? source.message
|
|
220
|
+
: typeof source.msg === "string" && source.msg !== ""
|
|
221
|
+
? source.msg
|
|
222
|
+
: ""
|
|
223
|
+
if (message !== "") {
|
|
224
|
+
const code = source.code
|
|
225
|
+
const named =
|
|
226
|
+
code === undefined || code === null || code === "" ? "" : ` (code ${String(code)})`
|
|
227
|
+
return message + named
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
// Not JSON: the raw body is the only detail there is.
|
|
232
|
+
}
|
|
233
|
+
return detail
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// A hung upstream must not hang the agent: the deadline bounds it, and the
|
|
237
|
+
// thunk's signal carries host cancellation into the socket. `timeout <= 0`
|
|
238
|
+
// removes the deadline but never the cancellation, because
|
|
239
|
+
// `AbortSignal.timeout(0)` aborts instantly rather than "never".
|
|
240
|
+
function timedSignal(signal: AbortSignal, timeout: number): AbortSignal {
|
|
241
|
+
return timeout > 0 ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export const effect = (ctx: Plugin.Context) =>
|
|
245
|
+
Effect.gen(function* () {
|
|
246
|
+
// Make "Zhipu" show up in /connect with an API-key method.
|
|
247
|
+
yield* ctx.integration.transform((editor) => {
|
|
248
|
+
editor.update(ID, (integration) => {
|
|
249
|
+
integration.name = "Zhipu Search (osp)"
|
|
250
|
+
})
|
|
251
|
+
editor.method.update({
|
|
252
|
+
integrationID: ID,
|
|
253
|
+
method: { type: "key", label: "Zhipu API key" },
|
|
254
|
+
})
|
|
255
|
+
editor.method.update({
|
|
256
|
+
integrationID: ID,
|
|
257
|
+
method: { type: "env", names: ["OSP_ZHIPU_API_KEY"] },
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// The host instantiates this API's failure channel as `unknown`, which would
|
|
262
|
+
// make the whole plugin effect unrunnable. A credential read must never take
|
|
263
|
+
// the plugin down: a failed read degrades to "no stored key", and the
|
|
264
|
+
// environment fallback below applies exactly as it does when the credential
|
|
265
|
+
// simply does not exist.
|
|
266
|
+
const soften = <A>(read: Effect.Effect<A, unknown>): Effect.Effect<A | undefined, never> =>
|
|
267
|
+
read.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
268
|
+
|
|
269
|
+
// The key comes from the /connect credential, falling back to the
|
|
270
|
+
// environment. Resolved per call so a rotated key needs no restart.
|
|
271
|
+
const resolveKey = () =>
|
|
272
|
+
Effect.gen(function* () {
|
|
273
|
+
const connection = yield* soften(ctx.integration.connection.active(ID))
|
|
274
|
+
const credential = connection
|
|
275
|
+
? yield* soften(ctx.integration.connection.resolve(connection))
|
|
276
|
+
: undefined
|
|
277
|
+
return credential?.type === "key" ? credential.key : process.env.OSP_ZHIPU_API_KEY
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
// Zhipu has no anonymous tier, so the provider binds only while a key
|
|
281
|
+
// exists. Deleting the key unbinds it, and `random` can never pick a
|
|
282
|
+
// keyless Zhipu. The transform replays on reload and reads `bound` as it
|
|
283
|
+
// stands then.
|
|
284
|
+
let bound = Boolean(yield* resolveKey())
|
|
285
|
+
|
|
286
|
+
yield* ctx.websearch.transform((editor) => {
|
|
287
|
+
if (!bound) return
|
|
288
|
+
editor.add({
|
|
289
|
+
id: ID,
|
|
290
|
+
name: "Zhipu Search",
|
|
291
|
+
execute: ({ query }) =>
|
|
292
|
+
Effect.gen(function* () {
|
|
293
|
+
const queryKey = cacheKey(query)
|
|
294
|
+
const cached = readCache(queryKey)
|
|
295
|
+
if (cached) return cached
|
|
296
|
+
|
|
297
|
+
// Rechecked per call: binding can lag a key deletion by the
|
|
298
|
+
// event round-trip, so this keeps that window explicit.
|
|
299
|
+
const key = yield* resolveKey()
|
|
300
|
+
if (!key) {
|
|
301
|
+
return yield* Effect.fail(
|
|
302
|
+
new Error("Connect Zhipu Search in /connect, or set OSP_ZHIPU_API_KEY"),
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const response = yield* Effect.tryPromise({
|
|
307
|
+
try: (signal) =>
|
|
308
|
+
fetch(SEARCH_URL, {
|
|
309
|
+
method: "POST",
|
|
310
|
+
headers: {
|
|
311
|
+
Accept: "application/json",
|
|
312
|
+
"Content-Type": "application/json",
|
|
313
|
+
Authorization: "Bearer " + key,
|
|
314
|
+
},
|
|
315
|
+
body: JSON.stringify({
|
|
316
|
+
// Cut to the spec's 70-character ceiling (`maxLength: 70`)
|
|
317
|
+
// before dispatch.
|
|
318
|
+
search_query: toSearchQuery(queryKey),
|
|
319
|
+
search_engine: ENGINE,
|
|
320
|
+
// Intent recognition rewrites a query into a search phrase;
|
|
321
|
+
// the agent already writes one, and the cn endpoint marks
|
|
322
|
+
// this field required with a documented default of `false`.
|
|
323
|
+
// The intl endpoint documents no such field, so it never
|
|
324
|
+
// crosses the site boundary.
|
|
325
|
+
...(SITE === "cn" ? { search_intent: false } : {}),
|
|
326
|
+
// Omitted where the engine's own docs leave `count` out
|
|
327
|
+
// (Quark), and snapped to Sogou's five accepted sizes.
|
|
328
|
+
...(countFor(ENGINE, COUNT) === undefined
|
|
329
|
+
? {}
|
|
330
|
+
: { count: countFor(ENGINE, COUNT) }),
|
|
331
|
+
// Omitted rather than sent as "noLimit": the two mean the
|
|
332
|
+
// same thing, and noLimit is the documented default.
|
|
333
|
+
...(RECENCY ? { search_recency_filter: RECENCY } : {}),
|
|
334
|
+
// Snippet length, not price -- `high` sends fuller excerpts
|
|
335
|
+
// for the same per-search fee. cn-only, like `search_intent`.
|
|
336
|
+
...(SITE === "cn" && CONTENT_SIZE ? { content_size: CONTENT_SIZE } : {}),
|
|
337
|
+
}),
|
|
338
|
+
signal: timedSignal(signal, SEARCH_TIMEOUT),
|
|
339
|
+
}),
|
|
340
|
+
// A timeout is its own failure: naming it stops the user hunting
|
|
341
|
+
// for a key problem that was never there.
|
|
342
|
+
catch: (error) =>
|
|
343
|
+
error instanceof Error && error.name === "TimeoutError"
|
|
344
|
+
? new Error("Zhipu search timed out after " + SEARCH_TIMEOUT + "ms")
|
|
345
|
+
: new Error("Zhipu request failed: " + String(error)),
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
if (!response.ok) {
|
|
349
|
+
const detail = yield* Effect.tryPromise({
|
|
350
|
+
try: () => response.text(),
|
|
351
|
+
catch: () => new Error("response body unavailable"),
|
|
352
|
+
})
|
|
353
|
+
const described = describeFailure(String(detail))
|
|
354
|
+
|
|
355
|
+
// The two below carry a fix the user can act on -- a bare status
|
|
356
|
+
// code would send them hunting for an auth problem that was
|
|
357
|
+
// really a cap, or the other way round.
|
|
358
|
+
if (response.status === 401) {
|
|
359
|
+
return yield* Effect.fail(
|
|
360
|
+
new Error(
|
|
361
|
+
"Zhipu rejected the API key (HTTP 401): " +
|
|
362
|
+
described +
|
|
363
|
+
". Connect Zhipu Search in /connect or set OSP_ZHIPU_API_KEY to a key from https://bigmodel.cn/usercenter/proj-mgmt/apikeys",
|
|
364
|
+
),
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
if (response.status === 429) {
|
|
368
|
+
const retry = response.headers.get("Retry-After")
|
|
369
|
+
return yield* Effect.fail(
|
|
370
|
+
new Error(
|
|
371
|
+
"Zhipu rate limited the request (HTTP 429)" +
|
|
372
|
+
(retry ? ", retry after " + retry + "s" : "") +
|
|
373
|
+
": " +
|
|
374
|
+
described +
|
|
375
|
+
". Concurrent searches are capped per account (the API's code 1701); retry shortly or run fewer searches at once",
|
|
376
|
+
),
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
return yield* Effect.fail(
|
|
380
|
+
new Error("Zhipu API error " + response.status + ": " + described),
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const json = yield* Effect.tryPromise({
|
|
385
|
+
try: () => response.json(),
|
|
386
|
+
catch: (error) => new Error("Invalid Zhipu response: " + String(error)),
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
const body = yield* Schema.decodeUnknownEffect(ZhipuResponse)(json)
|
|
390
|
+
const results = (body.search_result ?? [])
|
|
391
|
+
.map(toResult)
|
|
392
|
+
.filter((result): result is SearchResult => result !== undefined)
|
|
393
|
+
|
|
394
|
+
// Zhipu charges per dispatch, so a result that never enters the
|
|
395
|
+
// cache is a query paid for again on every repeat.
|
|
396
|
+
writeCache(queryKey, results)
|
|
397
|
+
return results
|
|
398
|
+
}),
|
|
399
|
+
})
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
// Rebind on credential changes: connect, delete, or rotate a key and the
|
|
403
|
+
// provider joins or leaves the websearch pool without a restart. Env-var
|
|
404
|
+
// keys cannot change under a running process, so they bind at startup.
|
|
405
|
+
yield* Effect.forkScoped(
|
|
406
|
+
ctx.event.subscribe().pipe(
|
|
407
|
+
Stream.filter(
|
|
408
|
+
(event) =>
|
|
409
|
+
event.type === "credential.updated" ||
|
|
410
|
+
event.type === "credential.switched" ||
|
|
411
|
+
event.type === "integration.updated",
|
|
412
|
+
),
|
|
413
|
+
Stream.mapEffect(() =>
|
|
414
|
+
Effect.gen(function* () {
|
|
415
|
+
const next = Boolean(yield* resolveKey())
|
|
416
|
+
if (next === bound) return
|
|
417
|
+
bound = next
|
|
418
|
+
yield* ctx.websearch.reload()
|
|
419
|
+
}),
|
|
420
|
+
),
|
|
421
|
+
Stream.runDrain,
|
|
422
|
+
),
|
|
423
|
+
)
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
export default Plugin.define({ id: "zhipu-search", effect })
|