dsh-web-search-plugin 0.2.0 → 0.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/lib/index.js CHANGED
@@ -22,6 +22,9 @@ import { MAX_RESULTS_CAP } from "./shared.js";
22
22
  import { TAVILY_DEFAULT_API_KEY_ENV, TAVILY_DEFAULT_BASE_URL, TavilySearchProvider, resolveTavilyOptions } from "./tavily.js";
23
23
  import { BRAVE_DEFAULT_API_KEY_ENV, BRAVE_DEFAULT_BASE_URL, BraveSearchProvider, resolveBraveOptions } from "./brave.js";
24
24
  import { DEEPSEEK_DEFAULT_API_KEY_ENV, DEEPSEEK_DEFAULT_BASE_URL, DeepSeekSearchProvider, resolveDeepseekOptions } from "./deepseek.js";
25
+ import { TAVILY_USAGE_POLL_MS, UsageTracker } from "./usage.js";
26
+ import { RestSearchProvider, resolveRestProvider } from "./rest.js";
27
+ import { BUILTIN_REST_IDS, REST_PROVIDERS, findProvider } from "./providers.js";
25
28
 
26
29
  /** Stable id this plugin registers under on `ctx.web`. */
27
30
  const SEARCH_PROVIDER_ID = "dsh-web-search";
@@ -33,8 +36,8 @@ const inject = ["web"];
33
36
 
34
37
  /** Plugin config (all optional — `apply` fills defaults). */
35
38
  const Config = z.object({
36
- /** Active backend: `deepseek-official`, `tavily` (default), or `brave`. */
37
- provider: z.string().default("tavily"),
39
+ /** Active backend: `deepseek-official` (default), `tavily`, `brave`, `serper`, `serpapi`, `exa`, or `searxng`. */
40
+ provider: z.string().default("deepseek-official"),
38
41
  /** Tavily auth mode: `keyless` (default) or `keyed`. */
39
42
  mode: z.string().default("keyless"),
40
43
  /** Literal Tavily API key; prefer {@link apiKeyEnv}. */
@@ -65,6 +68,38 @@ const Config = z.object({
65
68
  freshness: z.string().default(""),
66
69
  /** Optional HTTP(S) proxy URL for Brave (falls back to HTTPS_PROXY / HTTP_PROXY). */
67
70
  proxy: z.string().default(""),
71
+ /** Literal Serper API key; prefer {@link serperApiKeyEnv}. */
72
+ serperApiKey: z.string().role("secret").default(""),
73
+ /** Credential reference for Serper search. */
74
+ serperApiKeyEnv: z.string().role("credential-ref").default("SERPER_API_KEY"),
75
+ /** Serper endpoint base. `/search` is appended. */
76
+ serperBaseURL: z.string().default(""),
77
+ /** Literal SerpApi API key; prefer {@link serpapiApiKeyEnv}. */
78
+ serpapiApiKey: z.string().role("secret").default(""),
79
+ /** Credential reference for SerpApi search. */
80
+ serpapiApiKeyEnv: z.string().role("credential-ref").default("SERPAPI_API_KEY"),
81
+ /** SerpApi endpoint base. `/search.json` is appended. */
82
+ serpapiBaseURL: z.string().default(""),
83
+ /** Literal Exa API key; prefer {@link exaApiKeyEnv}. */
84
+ exaApiKey: z.string().role("secret").default(""),
85
+ /** Credential reference for Exa search. */
86
+ exaApiKeyEnv: z.string().role("credential-ref").default("EXA_API_KEY"),
87
+ /** Exa endpoint base. `/search` is appended. */
88
+ exaBaseURL: z.string().default(""),
89
+ /** Optional SearXNG instance base URL (self-hosted). `/search` is appended. */
90
+ searxngBaseURL: z.string().default(""),
91
+ /** Literal Scavio API key; prefer {@link scavioApiKeyEnv}. */
92
+ scavioApiKey: z.string().role("secret").default(""),
93
+ /** Credential reference for Scavio search. */
94
+ scavioApiKeyEnv: z.string().role("credential-ref").default("SCAVIO_API_KEY"),
95
+ /** Scavio endpoint base. `/api/v2/google` is appended. */
96
+ scavioBaseURL: z.string().default(""),
97
+ /** Literal Firecrawl API key; prefer {@link firecrawlApiKeyEnv}. */
98
+ firecrawlApiKey: z.string().role("secret").default(""),
99
+ /** Credential reference for Firecrawl search. */
100
+ firecrawlApiKeyEnv: z.string().role("credential-ref").default("FIRECRAWL_API_KEY"),
101
+ /** Firecrawl endpoint base. `/v2/search` is appended. */
102
+ firecrawlBaseURL: z.string().default(""),
68
103
  /** Literal DeepSeek API key; prefer {@link deepseekApiKeyEnv}. */
69
104
  deepseekApiKey: z.string().role("secret").default(""),
70
105
  /** Credential reference for DeepSeek search. */
@@ -94,11 +129,24 @@ const WEB_SEARCH_TAVILY_SETTINGS_NAMESPACE = WEB_SEARCH_SETTINGS_NAMESPACE;
94
129
  class PluginSearchProvider {
95
130
  id = SEARCH_PROVIDER_ID;
96
131
 
97
- constructor(resolveOptions) {
132
+ constructor(resolveOptions, usage) {
98
133
  this.resolveOptions = resolveOptions;
99
134
  this.deepseek = new DeepSeekSearchProvider(() => this.resolveOptions().deepseek);
100
- this.tavily = new TavilySearchProvider(() => this.resolveOptions().tavily);
101
- this.brave = new BraveSearchProvider(() => this.resolveOptions().brave);
135
+ this.tavily = new TavilySearchProvider(() => ({
136
+ ...this.resolveOptions().tavily,
137
+ onUsage: (credits) => usage?.recordTavilyCredits(credits)
138
+ }));
139
+ this.brave = new BraveSearchProvider(() => ({
140
+ ...this.resolveOptions().brave,
141
+ onRateLimit: (headers) => usage?.recordBraveHeaders(headers)
142
+ }));
143
+ this.rest = new Map();
144
+ for (const providerId of BUILTIN_REST_IDS) {
145
+ this.rest.set(providerId, new RestSearchProvider(providerId, () => this.resolveOptions().rest[providerId], {
146
+ onUsage: providerId === "tavily" ? (credits) => usage?.recordTavilyCredits(credits) : void 0,
147
+ onRateLimit: providerId === "brave" ? (headers) => usage?.recordBraveHeaders(headers) : void 0
148
+ }));
149
+ }
102
150
  }
103
151
 
104
152
  available() {
@@ -110,24 +158,98 @@ class PluginSearchProvider {
110
158
  }
111
159
 
112
160
  backend() {
113
- const provider = this.resolveOptions().provider;
114
- if (provider === "brave") return this.brave;
161
+ const options = this.resolveOptions();
162
+ const provider = options.provider;
163
+ const restInstance = this.rest.get(provider);
164
+ if (restInstance !== void 0) return restInstance;
115
165
  if (provider === "deepseek-official") return this.deepseek;
116
- return this.tavily;
166
+ // Fallback: provider points at something unknown → official default.
167
+ return this.deepseek;
117
168
  }
118
169
  }
119
170
 
120
171
  /** Project one resolved section into options for each backend. */
121
172
  function resolveOptions(ctx, config) {
122
- const provider = config.provider === "brave" || config.provider === "deepseek-official" ? config.provider : "tavily";
173
+ const isBuiltIn = config.provider === "tavily" || config.provider === "brave" || config.provider === "deepseek-official" || BUILTIN_REST_IDS.includes(config.provider);
174
+ const provider = isBuiltIn ? config.provider : "deepseek-official";
175
+ // Per-provider literal key / optional endpoint override, used when the
176
+ // metadata row's default is not enough (self-hosted SearXNG, proxied
177
+ // providers, custom key refs).
178
+ const keySourceByProvider = {
179
+ brave: { literal: config.braveApiKey, envName: config.braveApiKeyEnv ?? BRAVE_DEFAULT_API_KEY_ENV, baseURL: config.braveBaseURL ?? "", proxy: config.proxy },
180
+ tavily: { literal: config.apiKey, envName: config.apiKeyEnv ?? TAVILY_DEFAULT_API_KEY_ENV, baseURL: config.baseURL ?? "" },
181
+ serper: { literal: config.serperApiKey, envName: config.serperApiKeyEnv ?? "SERPER_API_KEY", baseURL: config.serperBaseURL ?? "" },
182
+ serpapi: { literal: config.serpapiApiKey, envName: config.serpapiApiKeyEnv ?? "SERPAPI_API_KEY", baseURL: config.serpapiBaseURL ?? "" },
183
+ exa: { literal: config.exaApiKey, envName: config.exaApiKeyEnv ?? "EXA_API_KEY", baseURL: config.exaBaseURL ?? "" },
184
+ searxng: { baseURL: config.searxngBaseURL ?? "" },
185
+ scavio: { literal: config.scavioApiKey, envName: config.scavioApiKeyEnv ?? "SCAVIO_API_KEY", baseURL: config.scavioBaseURL ?? "" },
186
+ firecrawl: { literal: config.firecrawlApiKey, envName: config.firecrawlApiKeyEnv ?? "FIRECRAWL_API_KEY", baseURL: config.firecrawlBaseURL ?? "" }
187
+ };
188
+ const settings = {
189
+ country: config.country,
190
+ searchLang: config.searchLang,
191
+ freshness: config.freshness,
192
+ searchDepth: config.searchDepth,
193
+ topic: config.topic,
194
+ includeAnswer: config.includeAnswer
195
+ };
196
+ const rest = {};
197
+ for (const providerId of BUILTIN_REST_IDS) {
198
+ const row = findProvider(providerId);
199
+ if (row === void 0) continue;
200
+ const source = keySourceByProvider[providerId] ?? {};
201
+ const resolved = resolveRestProvider(ctx, row, {
202
+ config,
203
+ settings,
204
+ keyed: providerId === "tavily" ? config.mode === "keyed" : false,
205
+ keySource: source
206
+ });
207
+ // Endpoint override: an explicit config baseURL wins over the row default.
208
+ if (source.baseURL != null && source.baseURL.length > 0) resolved.baseURL = source.baseURL;
209
+ if (providerId === "brave" && (source.proxy ?? "").length > 0) resolved.proxy = source.proxy;
210
+ rest[providerId] = resolved;
211
+ }
123
212
  return {
124
213
  provider,
125
214
  deepseek: resolveDeepseekOptions(ctx, config),
126
215
  tavily: resolveTavilyOptions(ctx, config),
127
- brave: resolveBraveOptions(ctx, config)
216
+ brave: resolveBraveOptions(ctx, config),
217
+ rest
128
218
  };
129
219
  }
130
220
 
221
+ /** JSON body helper for the usage refresh POST. */
222
+ function readJsonBody(req) {
223
+ return new Promise((resolve, reject) => {
224
+ let body = "";
225
+ req.on("data", (chunk) => {
226
+ body += chunk;
227
+ if (body.length > 1e6) {
228
+ req.destroy();
229
+ reject(new Error("Payload too large"));
230
+ }
231
+ });
232
+ req.on("end", () => {
233
+ try {
234
+ resolve(body ? JSON.parse(body) : {});
235
+ } catch {
236
+ reject(new Error("Invalid JSON"));
237
+ }
238
+ });
239
+ req.on("error", reject);
240
+ });
241
+ }
242
+
243
+ function sendJson(res, statusCode, data) {
244
+ const body = JSON.stringify(data);
245
+ res.writeHead(statusCode, {
246
+ "Content-Type": "application/json; charset=utf-8",
247
+ "Cache-Control": "no-store",
248
+ "Content-Length": Buffer.byteLength(body)
249
+ });
250
+ res.end(body);
251
+ }
252
+
131
253
  /** Register the dispatched search provider with `ctx.web`. */
132
254
  function apply(ctx, config) {
133
255
  let current = () => config;
@@ -137,24 +259,85 @@ function apply(ctx, config) {
137
259
  },
138
260
  onChange: () => {}
139
261
  });
140
- ctx.web.registerSearchProvider(new PluginSearchProvider(() => resolveOptions(ctx, current())));
262
+ const live = () => resolveOptions(ctx, current());
263
+ const usage = new UsageTracker({
264
+ getTavilyOptions: () => live().tavily
265
+ });
266
+ ctx.effect(() => {
267
+ let cancelled = false;
268
+ void usage.hydrate().then(() => {
269
+ if (cancelled) return;
270
+ if (live().provider === "tavily" && live().tavily.mode === "keyed") return usage.refreshTavily(undefined, true);
271
+ }).catch(() => {});
272
+ return () => {
273
+ cancelled = true;
274
+ usage.dispose();
275
+ };
276
+ }, "dsh-web-search-plugin: usage hydrate");
277
+ ctx.effect(() => {
278
+ const tick = () => {
279
+ if (live().provider === "tavily" && live().tavily.mode === "keyed") void usage.refreshTavily().catch(() => {});
280
+ };
281
+ const timer = setInterval(tick, TAVILY_USAGE_POLL_MS);
282
+ return () => clearInterval(timer);
283
+ }, "dsh-web-search-plugin: tavily usage poll");
284
+ ctx.web.registerSearchProvider(new PluginSearchProvider(live, usage));
285
+ ctx.inject(["webServer"], (webCtx) => {
286
+ webCtx.effect(() => webCtx.webServer.register({
287
+ kind: "exact",
288
+ path: "/dsh-web-search/usage",
289
+ async handler(req, res) {
290
+ if (req.method !== "GET" && req.method !== "HEAD" && req.method !== "POST") {
291
+ res.writeHead(405, { Allow: "GET, HEAD, POST" });
292
+ res.end();
293
+ return;
294
+ }
295
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
296
+ const force = req.method === "POST" || url.searchParams.get("force") === "1" || url.searchParams.get("force") === "true";
297
+ const options = live();
298
+ const tavilyState = usage.snapshot().tavily;
299
+ const missingIdentity = tavilyState.plan == null && tavilyState.limit == null;
300
+ if ((force || missingIdentity) && options.provider === "tavily" && options.tavily.mode === "keyed") {
301
+ try {
302
+ if (req.method === "POST") await readJsonBody(req).catch(() => ({}));
303
+ await usage.refreshTavily(undefined, force);
304
+ } catch {
305
+ /* serialize still returns the last cache */
306
+ }
307
+ }
308
+ if (req.method === "HEAD") {
309
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
310
+ res.end();
311
+ return;
312
+ }
313
+ sendJson(res, 200, usage.serialize(options.provider, options.tavily.mode));
314
+ }
315
+ }), "dsh-web-search-plugin: usage route");
316
+ });
141
317
  }
142
318
 
143
319
  export {
144
320
  BRAVE_DEFAULT_API_KEY_ENV,
145
321
  BRAVE_DEFAULT_BASE_URL,
322
+ BUILTIN_REST_IDS,
146
323
  BraveSearchProvider,
147
324
  Config,
148
325
  DEEPSEEK_DEFAULT_API_KEY_ENV,
149
326
  DEEPSEEK_DEFAULT_BASE_URL,
150
327
  DeepSeekSearchProvider,
328
+ REST_PROVIDERS,
329
+ RestSearchProvider,
151
330
  SEARCH_PROVIDER_ID,
152
331
  TAVILY_DEFAULT_API_KEY_ENV,
153
332
  TAVILY_DEFAULT_BASE_URL,
333
+ TAVILY_USAGE_POLL_MS,
154
334
  TavilySearchProvider,
335
+ UsageTracker,
155
336
  WEB_SEARCH_SETTINGS_NAMESPACE,
156
337
  WEB_SEARCH_TAVILY_SETTINGS_NAMESPACE,
157
338
  apply,
339
+ findProvider,
158
340
  inject,
159
- name
341
+ name,
342
+ resolveRestProvider
160
343
  };
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Static metadata table for every built-in REST web-search provider.
3
+ *
4
+ * This module is intentionally **data, not code**: adding a new REST provider
5
+ * means adding one row here (plus an optional official "get a key" link in the
6
+ * settings card), with zero per-provider execution code. The generic backend in
7
+ * `lib/rest.js` reads a row and performs `method` / `path` / `queryParam` /
8
+ * `countParam` / `params` / `auth` / `response` / `hooks`.
9
+ *
10
+ * Only **pure REST providers** ("one query → one HTTP request → one result
11
+ * array") belong here. Model-native search tool (DeepSeek's Anthropic Messages
12
+ * `web_search_20250305`, Perplexity sonar tools, OpenAI Responses) is **not**
13
+ * REST and stays on its dedicated backend (`lib/deepseek.js`).
14
+ *
15
+ * @module dsh-web-search-plugin/providers
16
+ */
17
+
18
+ /**
19
+ * Built-in REST providers.
20
+ * `auth.type` ∈ { `bearer`, `header`, `none`, `query` }:
21
+ * - `bearer` — `Authorization: Bearer <key>`
22
+ * - `header` — key in a named header (`auth.header`)
23
+ * - `none` — no key (SearXNG, keyless)
24
+ * - `query` — key as a query-string parameter (`auth.param`, e.g. SerpApi)
25
+ * `params[].when` ∈ { `always`, `nonEmpty`, `keyed`, `keyless` }.
26
+ */
27
+ export const REST_PROVIDERS = [
28
+ {
29
+ id: "brave",
30
+ name: "Brave Search",
31
+ kind: "rest",
32
+ method: "GET",
33
+ baseURL: "https://api.search.brave.com/res/v1/web/search",
34
+ path: "",
35
+ queryIn: "query",
36
+ queryParam: "q",
37
+ countParam: "count",
38
+ auth: { type: "header", header: "X-Subscription-Token" },
39
+ keyRequired: true,
40
+ apiKeyRef: "BRAVE_API_KEY",
41
+ response: "brave",
42
+ params: [
43
+ { key: "country", setting: "country", when: "nonEmpty" },
44
+ { key: "search_lang", setting: "searchLang", when: "nonEmpty" },
45
+ { key: "freshness", setting: "freshness", when: "nonEmpty" }
46
+ ],
47
+ officialUrl: "https://api-dashboard.search.brave.com/",
48
+ hooks: ["rate-limit", "proxy"]
49
+ },
50
+ {
51
+ id: "tavily",
52
+ name: "Tavily",
53
+ kind: "rest",
54
+ method: "POST",
55
+ baseURL: "https://api.tavily.com",
56
+ path: "/search",
57
+ queryIn: "body",
58
+ queryParam: "query",
59
+ countParam: "max_results",
60
+ auth: { type: "bearer" },
61
+ keyRequired: false, // keyless by default; keyed only when a key is configured
62
+ apiKeyRef: "TAVILY_API_KEY",
63
+ response: "tavily",
64
+ params: [
65
+ { key: "search_depth", setting: "searchDepth", when: "nonEmpty" },
66
+ { key: "topic", setting: "topic", when: "nonEmpty" },
67
+ { key: "include_answer", setting: "includeAnswer", when: "nonEmpty" },
68
+ { key: "include_images", value: false, when: "always" },
69
+ { key: "include_usage", value: true, when: "keyed" }
70
+ ],
71
+ keylessHeaders: { "x-tavily-access-mode": "keyless" },
72
+ keylessHeaderWhen: "keyless",
73
+ officialUrl: "https://app.tavily.com/",
74
+ hooks: ["usage"]
75
+ },
76
+ {
77
+ id: "serper",
78
+ name: "Serper",
79
+ kind: "rest",
80
+ method: "POST",
81
+ baseURL: "https://google.serper.dev",
82
+ path: "/search",
83
+ queryIn: "body",
84
+ queryParam: "q",
85
+ countParam: "num",
86
+ auth: { type: "header", header: "X-API-KEY" },
87
+ keyRequired: true,
88
+ apiKeyRef: "SERPER_API_KEY",
89
+ response: "serper",
90
+ params: [],
91
+ officialUrl: "https://serper.dev/",
92
+ hooks: []
93
+ },
94
+ {
95
+ id: "serpapi",
96
+ name: "SerpApi",
97
+ kind: "rest",
98
+ method: "GET",
99
+ baseURL: "https://serpapi.com",
100
+ path: "/search.json",
101
+ queryIn: "query",
102
+ queryParam: "q",
103
+ countParam: "num",
104
+ auth: { type: "query", param: "api_key" },
105
+ keyRequired: true,
106
+ apiKeyRef: "SERPAPI_API_KEY",
107
+ response: "serper",
108
+ params: [],
109
+ officialUrl: "https://serpapi.com/",
110
+ hooks: []
111
+ },
112
+ {
113
+ id: "exa",
114
+ name: "Exa",
115
+ kind: "rest",
116
+ method: "POST",
117
+ baseURL: "https://api.exa.ai",
118
+ path: "/search",
119
+ queryIn: "body",
120
+ queryParam: "query",
121
+ countParam: "num_results",
122
+ auth: { type: "bearer" },
123
+ keyRequired: true,
124
+ apiKeyRef: "EXA_API_KEY",
125
+ response: "exa",
126
+ params: [
127
+ { key: "contents", value: "text", when: "always" }
128
+ ],
129
+ officialUrl: "https://dashboard.exa.ai/",
130
+ hooks: []
131
+ },
132
+ {
133
+ id: "searxng",
134
+ name: "SearXNG",
135
+ kind: "rest",
136
+ method: "GET",
137
+ baseURL: "https://searx.be",
138
+ path: "/search",
139
+ queryIn: "query",
140
+ queryParam: "q",
141
+ countParam: "count",
142
+ auth: { type: "none" },
143
+ keyRequired: false,
144
+ apiKeyRef: "",
145
+ response: "searxng",
146
+ params: [
147
+ { key: "format", value: "json", when: "always" }
148
+ ],
149
+ officialUrl: "https://docs.searxng.org/",
150
+ hooks: []
151
+ },
152
+ {
153
+ id: "scavio",
154
+ name: "Scavio",
155
+ kind: "rest",
156
+ method: "POST",
157
+ baseURL: "https://api.scavio.dev",
158
+ path: "/api/v2/google",
159
+ queryIn: "body",
160
+ queryParam: "query",
161
+ countParam: "",
162
+ auth: { type: "bearer" },
163
+ keyRequired: true,
164
+ apiKeyRef: "SCAVIO_API_KEY",
165
+ response: "serper",
166
+ params: [
167
+ { key: "hl", setting: "searchLang", when: "nonEmpty" },
168
+ { key: "gl", setting: "country", when: "nonEmpty" }
169
+ ],
170
+ officialUrl: "https://scavio.dev/",
171
+ hooks: []
172
+ },
173
+ {
174
+ id: "firecrawl",
175
+ name: "Firecrawl",
176
+ kind: "rest",
177
+ method: "POST",
178
+ baseURL: "https://api.firecrawl.dev",
179
+ path: "/v2/search",
180
+ queryIn: "body",
181
+ queryParam: "query",
182
+ countParam: "limit",
183
+ auth: { type: "bearer" },
184
+ keyRequired: true,
185
+ apiKeyRef: "FIRECRAWL_API_KEY",
186
+ response: "firecrawl",
187
+ params: [
188
+ { key: "sources", value: "web", when: "always" }
189
+ ],
190
+ officialUrl: "https://firecrawl.dev/",
191
+ hooks: []
192
+ }
193
+ ];
194
+
195
+ /** Location of one provider by id (the id doubles as the `provider` value). */
196
+ export function findProvider(id) {
197
+ for (const entry of REST_PROVIDERS) if (entry.id === id) return entry;
198
+ return void 0;
199
+ }
200
+
201
+ /** Set of ids that live in the metadata table. */
202
+ export function restProviderIds() {
203
+ const ids = /* @__PURE__ */ new Set();
204
+ for (const entry of REST_PROVIDERS) ids.add(entry.id);
205
+ return ids;
206
+ }
207
+
208
+ /** The built-in REST provider ids (seam-facing `provider` values). */
209
+ export const BUILTIN_REST_IDS = REST_PROVIDERS.map((entry) => entry.id);
210
+
211
+ /** The model-native (non-REST) provider id, kept on its own backend. */
212
+ export const TOOL_PROVIDER_IDS = ["deepseek-official"];