dsh-web-search-plugin 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/README.md +70 -26
- package/cordis.patch.yml +8 -2
- package/lib/brave.js +1 -0
- package/lib/client.js +632 -90
- package/lib/deepseek.js +148 -0
- package/lib/index.js +138 -18
- package/lib/shared.js +1 -1
- package/lib/tavily.js +10 -2
- package/lib/usage.js +352 -0
- package/package.json +3 -3
package/lib/usage.js
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local usage cache for Tavily keyed searches and Brave rate-limit headers.
|
|
3
|
+
*
|
|
4
|
+
* Tavily: each keyed search sends `include_usage` and adds those credits to a
|
|
5
|
+
* high-water local counter. A slower `GET /usage` poll supplies plan name /
|
|
6
|
+
* plan_limit and the vendor's own used count. Displayed used is
|
|
7
|
+
* `max(local, remote)`. A drop in remote used, or a plan/limit change, is
|
|
8
|
+
* treated as a new billing cycle (local resets to remote).
|
|
9
|
+
*
|
|
10
|
+
* Brave: there is no usage endpoint. Limit / remaining / reset come from
|
|
11
|
+
* `X-RateLimit-*` on each search response and are cached as-is.
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-web-search-plugin/usage
|
|
14
|
+
*/
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
18
|
+
import { USER_AGENT, resolveApiKey } from "./shared.js";
|
|
19
|
+
|
|
20
|
+
/** Persist path under `$DSH_HOME/storages`. */
|
|
21
|
+
export const USAGE_FILE = join(process.env.DSH_HOME || join(homedir(), ".dsh"), "storages", "dsh-web-search-usage.json");
|
|
22
|
+
/** Tavily `/usage` is itself rate-limited (~10 / 10 min); poll slower than that. */
|
|
23
|
+
export const TAVILY_USAGE_POLL_MS = 10 * 60 * 1000;
|
|
24
|
+
/** Coalesce disk writes. */
|
|
25
|
+
const SAVE_DEBOUNCE_MS = 800;
|
|
26
|
+
|
|
27
|
+
/** Finite non-negative number, otherwise `fallback`. */
|
|
28
|
+
function num(value, fallback = 0) {
|
|
29
|
+
const n = Number(value);
|
|
30
|
+
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Finite number or `null` (Tavily `key.limit` is null when unlimited). */
|
|
34
|
+
function numOrNull(value) {
|
|
35
|
+
if (value == null) return null;
|
|
36
|
+
const n = Number(value);
|
|
37
|
+
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Split `1, 15000` style Brave headers into numbers. */
|
|
41
|
+
function csvNumbers(header) {
|
|
42
|
+
if (header == null || header === "") return [];
|
|
43
|
+
return String(header).split(",").map((part) => Number(part.trim())).filter((n) => Number.isFinite(n));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse Brave `X-RateLimit-Policy` (`1;w=1, 15000;w=2592000`) into
|
|
48
|
+
* `{ limit, windowSec }[]` aligned with Limit / Remaining / Reset by index.
|
|
49
|
+
*/
|
|
50
|
+
export function parseBravePolicy(header) {
|
|
51
|
+
if (header == null || header === "") return [];
|
|
52
|
+
return String(header).split(",").flatMap((part) => {
|
|
53
|
+
const match = part.trim().match(/^(\d+)\s*;\s*w\s*=\s*(\d+)$/u);
|
|
54
|
+
if (match === null) return [];
|
|
55
|
+
return [{ limit: Number(match[1]), windowSec: Number(match[2]) }];
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Project Brave rate-limit headers into burst + monthly windows.
|
|
61
|
+
* Monthly is the largest window (≥ 1 day); burst is the smallest (≤ 60s).
|
|
62
|
+
* Brave documents monthly `limit: 0` as unlimited — not “zero quota left”.
|
|
63
|
+
*/
|
|
64
|
+
export function parseBraveRateLimitHeaders(headers) {
|
|
65
|
+
const get = typeof headers.get === "function"
|
|
66
|
+
? (name) => headers.get(name)
|
|
67
|
+
: (name) => headers[name] ?? headers[name.toLowerCase()];
|
|
68
|
+
const policy = parseBravePolicy(get("x-ratelimit-policy"));
|
|
69
|
+
const limits = csvNumbers(get("x-ratelimit-limit"));
|
|
70
|
+
const remaining = csvNumbers(get("x-ratelimit-remaining"));
|
|
71
|
+
const reset = csvNumbers(get("x-ratelimit-reset"));
|
|
72
|
+
const count = Math.max(policy.length, limits.length, remaining.length, reset.length);
|
|
73
|
+
if (count === 0) return null;
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
const windows = [];
|
|
76
|
+
for (let i = 0; i < count; i++) {
|
|
77
|
+
const windowSec = policy[i]?.windowSec ?? (i === 0 ? 1 : 2_592_000);
|
|
78
|
+
const limit = policy[i]?.limit ?? limits[i];
|
|
79
|
+
const left = remaining[i];
|
|
80
|
+
const resetSec = reset[i];
|
|
81
|
+
if (!Number.isFinite(limit) || !Number.isFinite(left)) continue;
|
|
82
|
+
const unlimited = limit === 0;
|
|
83
|
+
windows.push({
|
|
84
|
+
limit,
|
|
85
|
+
remaining: unlimited ? null : Math.max(0, left),
|
|
86
|
+
used: unlimited ? null : Math.max(0, limit - Math.max(0, left)),
|
|
87
|
+
unlimited,
|
|
88
|
+
windowSec,
|
|
89
|
+
resetAt: Number.isFinite(resetSec) ? now + Math.max(0, resetSec) * 1000 : null
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (windows.length === 0) return null;
|
|
93
|
+
const monthly = windows.reduce((best, item) => item.windowSec > best.windowSec ? item : best);
|
|
94
|
+
const burst = windows.find((item) => item.windowSec <= 60 && item !== monthly) ?? (monthly.windowSec <= 60 ? monthly : null);
|
|
95
|
+
return {
|
|
96
|
+
monthly: monthly.windowSec > 60 ? monthly : windows[windows.length - 1],
|
|
97
|
+
burst: burst !== null && burst.windowSec <= 60 ? burst : null,
|
|
98
|
+
fetchedAt: now
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Normalize Tavily `GET /usage` into plan-level used/limit. */
|
|
103
|
+
export function parseTavilyUsageBody(body) {
|
|
104
|
+
if (body === null || typeof body !== "object") return null;
|
|
105
|
+
const account = body.account !== null && typeof body.account === "object" ? body.account : null;
|
|
106
|
+
const key = body.key !== null && typeof body.key === "object" ? body.key : null;
|
|
107
|
+
if (account === null && key === null) return null;
|
|
108
|
+
const acc = account ?? {};
|
|
109
|
+
const ky = key ?? {};
|
|
110
|
+
const plan = typeof acc.current_plan === "string" && acc.current_plan.length > 0 ? acc.current_plan : null;
|
|
111
|
+
const limit = numOrNull(acc.plan_limit) ?? numOrNull(ky.limit);
|
|
112
|
+
const hasPlanUsage = acc.plan_usage != null;
|
|
113
|
+
const hasKeyUsage = ky.usage != null;
|
|
114
|
+
if (plan == null && limit == null && !hasPlanUsage && !hasKeyUsage) return null;
|
|
115
|
+
const remoteUsed = Math.max(hasPlanUsage ? num(acc.plan_usage) : 0, hasKeyUsage ? num(ky.usage) : 0);
|
|
116
|
+
return {
|
|
117
|
+
plan,
|
|
118
|
+
limit,
|
|
119
|
+
remoteUsed,
|
|
120
|
+
keyUsed: numOrNull(ky.usage),
|
|
121
|
+
keyLimit: numOrNull(ky.limit),
|
|
122
|
+
paygoUsed: numOrNull(acc.paygo_usage),
|
|
123
|
+
paygoLimit: numOrNull(acc.paygo_limit)
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Credits charged for one Tavily search (`include_usage` body). */
|
|
128
|
+
export function parseTavilySearchCredits(body) {
|
|
129
|
+
const credits = Number(body?.usage?.credits);
|
|
130
|
+
return Number.isFinite(credits) && credits >= 0 ? credits : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Empty persisted shape. */
|
|
134
|
+
function emptyState() {
|
|
135
|
+
return {
|
|
136
|
+
version: 1,
|
|
137
|
+
tavily: {
|
|
138
|
+
plan: null,
|
|
139
|
+
limit: null,
|
|
140
|
+
localUsed: 0,
|
|
141
|
+
remoteUsed: 0,
|
|
142
|
+
used: 0,
|
|
143
|
+
fetchedAt: 0,
|
|
144
|
+
error: null
|
|
145
|
+
},
|
|
146
|
+
brave: {
|
|
147
|
+
monthly: null,
|
|
148
|
+
burst: null,
|
|
149
|
+
fetchedAt: 0,
|
|
150
|
+
error: null
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Merge a Tavily `/usage` snapshot into local high-water used.
|
|
157
|
+
* Plan/limit change or a drop in remote used starts a new cycle.
|
|
158
|
+
*/
|
|
159
|
+
export function mergeTavilyRemote(current, remote) {
|
|
160
|
+
const prev = current ?? emptyState().tavily;
|
|
161
|
+
if (remote === null) return { ...prev, error: prev.error };
|
|
162
|
+
const hadIdentity = prev.plan != null || prev.limit != null;
|
|
163
|
+
const planChanged = hadIdentity && (remote.plan !== prev.plan || remote.limit !== prev.limit);
|
|
164
|
+
const cycleReset = prev.remoteUsed > 0 && remote.remoteUsed < prev.remoteUsed;
|
|
165
|
+
const localUsed = planChanged || cycleReset ? remote.remoteUsed : Math.max(prev.localUsed, remote.remoteUsed);
|
|
166
|
+
return {
|
|
167
|
+
plan: remote.plan ?? prev.plan,
|
|
168
|
+
limit: remote.limit ?? prev.limit,
|
|
169
|
+
localUsed,
|
|
170
|
+
remoteUsed: remote.remoteUsed,
|
|
171
|
+
used: Math.max(localUsed, remote.remoteUsed),
|
|
172
|
+
fetchedAt: Date.now(),
|
|
173
|
+
error: null,
|
|
174
|
+
keyUsed: remote.keyUsed,
|
|
175
|
+
keyLimit: remote.keyLimit,
|
|
176
|
+
paygoUsed: remote.paygoUsed,
|
|
177
|
+
paygoLimit: remote.paygoLimit
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Add one search's credits onto the Tavily high-water counter. */
|
|
182
|
+
export function mergeTavilyCredits(current, credits) {
|
|
183
|
+
const prev = current ?? emptyState().tavily;
|
|
184
|
+
if (!Number.isFinite(credits) || credits < 0) return prev;
|
|
185
|
+
const localUsed = Math.max(prev.localUsed, prev.used) + credits;
|
|
186
|
+
return {
|
|
187
|
+
...prev,
|
|
188
|
+
localUsed,
|
|
189
|
+
used: Math.max(localUsed, prev.remoteUsed),
|
|
190
|
+
error: null
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Host-side usage cache with disk persist and Tavily `/usage` polling. */
|
|
195
|
+
export class UsageTracker {
|
|
196
|
+
constructor(options) {
|
|
197
|
+
this.getTavilyOptions = options.getTavilyOptions;
|
|
198
|
+
this.state = emptyState();
|
|
199
|
+
this.saveTimer = null;
|
|
200
|
+
this.hydrated = false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Load the previous process's cache; missing file is a first run. */
|
|
204
|
+
async hydrate() {
|
|
205
|
+
try {
|
|
206
|
+
const raw = JSON.parse(await readFile(USAGE_FILE, "utf8"));
|
|
207
|
+
if (raw && typeof raw === "object") {
|
|
208
|
+
this.state = {
|
|
209
|
+
version: 1,
|
|
210
|
+
tavily: { ...emptyState().tavily, ...(raw.tavily && typeof raw.tavily === "object" ? raw.tavily : {}) },
|
|
211
|
+
brave: { ...emptyState().brave, ...(raw.brave && typeof raw.brave === "object" ? raw.brave : {}) }
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
/* first run */
|
|
216
|
+
}
|
|
217
|
+
this.hydrated = true;
|
|
218
|
+
return this.snapshot();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
snapshot() {
|
|
222
|
+
return this.state;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Browser-safe view used by `GET /dsh-web-search/usage`. */
|
|
226
|
+
serialize(provider, tavilyMode) {
|
|
227
|
+
const tavily = this.state.tavily;
|
|
228
|
+
const brave = this.state.brave;
|
|
229
|
+
const tavilyLimit = tavily.limit;
|
|
230
|
+
const tavilyUsed = tavily.used;
|
|
231
|
+
const tavilyRemaining = tavilyLimit == null ? null : Math.max(0, tavilyLimit - tavilyUsed);
|
|
232
|
+
return {
|
|
233
|
+
ok: true,
|
|
234
|
+
provider,
|
|
235
|
+
tavilyMode: tavilyMode ?? "keyless",
|
|
236
|
+
tavily: {
|
|
237
|
+
available: tavilyMode === "keyed",
|
|
238
|
+
plan: tavily.plan,
|
|
239
|
+
used: tavilyUsed,
|
|
240
|
+
limit: tavilyLimit,
|
|
241
|
+
remaining: tavilyRemaining,
|
|
242
|
+
localUsed: tavily.localUsed,
|
|
243
|
+
remoteUsed: tavily.remoteUsed,
|
|
244
|
+
fetchedAt: tavily.fetchedAt,
|
|
245
|
+
error: tavily.error,
|
|
246
|
+
paygoUsed: tavily.paygoUsed ?? null,
|
|
247
|
+
paygoLimit: tavily.paygoLimit ?? null
|
|
248
|
+
},
|
|
249
|
+
brave: {
|
|
250
|
+
available: provider === "brave",
|
|
251
|
+
monthly: brave.monthly,
|
|
252
|
+
burst: brave.burst,
|
|
253
|
+
fetchedAt: brave.fetchedAt,
|
|
254
|
+
error: brave.error
|
|
255
|
+
},
|
|
256
|
+
fetchedAt: Math.max(tavily.fetchedAt ?? 0, brave.fetchedAt ?? 0)
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
recordTavilyCredits(credits) {
|
|
261
|
+
this.state.tavily = mergeTavilyCredits(this.state.tavily, credits);
|
|
262
|
+
this.scheduleSave();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
recordBraveHeaders(headers) {
|
|
266
|
+
const parsed = parseBraveRateLimitHeaders(headers);
|
|
267
|
+
if (parsed === null) return;
|
|
268
|
+
this.state.brave = { ...parsed, error: null };
|
|
269
|
+
this.scheduleSave();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
recordBraveError(message) {
|
|
273
|
+
this.state.brave = { ...this.state.brave, error: String(message) };
|
|
274
|
+
this.scheduleSave();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Pull Tavily `/usage` and reconcile with the local high-water counter. */
|
|
278
|
+
async refreshTavily(signal, force = false) {
|
|
279
|
+
const options = this.getTavilyOptions?.();
|
|
280
|
+
if (options == null || options.mode !== "keyed") return this.snapshot();
|
|
281
|
+
if (!URL.canParse(options.baseURL)) return this.snapshot();
|
|
282
|
+
const last = this.state.tavily.fetchedAt || 0;
|
|
283
|
+
const hasIdentity = this.state.tavily.plan != null || this.state.tavily.limit != null;
|
|
284
|
+
const interval = hasIdentity ? TAVILY_USAGE_POLL_MS : 60_000;
|
|
285
|
+
if (!force && last > 0 && (Date.now() - last) < interval) return this.snapshot();
|
|
286
|
+
let apiKey;
|
|
287
|
+
try {
|
|
288
|
+
apiKey = await resolveApiKey(options, signal, "Tavily usage has no API key");
|
|
289
|
+
} catch (error) {
|
|
290
|
+
this.state.tavily = { ...this.state.tavily, error: String(error?.message ?? error), fetchedAt: Date.now() };
|
|
291
|
+
this.scheduleSave();
|
|
292
|
+
return this.snapshot();
|
|
293
|
+
}
|
|
294
|
+
const endpoint = `${options.baseURL.replace(/\/+$/u, "")}/usage`;
|
|
295
|
+
let response;
|
|
296
|
+
try {
|
|
297
|
+
response = await fetch(endpoint, {
|
|
298
|
+
method: "GET",
|
|
299
|
+
redirect: "error",
|
|
300
|
+
headers: {
|
|
301
|
+
authorization: `Bearer ${apiKey}`,
|
|
302
|
+
accept: "application/json",
|
|
303
|
+
"user-agent": USER_AGENT
|
|
304
|
+
},
|
|
305
|
+
...signal !== void 0 ? { signal } : {}
|
|
306
|
+
});
|
|
307
|
+
} catch (error) {
|
|
308
|
+
this.state.tavily = { ...this.state.tavily, error: String(error?.message ?? error), fetchedAt: Date.now() };
|
|
309
|
+
this.scheduleSave();
|
|
310
|
+
return this.snapshot();
|
|
311
|
+
}
|
|
312
|
+
if (!response.ok) {
|
|
313
|
+
this.state.tavily = { ...this.state.tavily, error: `Tavily /usage HTTP ${response.status}`, fetchedAt: Date.now() };
|
|
314
|
+
this.scheduleSave();
|
|
315
|
+
return this.snapshot();
|
|
316
|
+
}
|
|
317
|
+
let body;
|
|
318
|
+
try {
|
|
319
|
+
body = await response.json();
|
|
320
|
+
} catch (error) {
|
|
321
|
+
this.state.tavily = { ...this.state.tavily, error: String(error?.message ?? error), fetchedAt: Date.now() };
|
|
322
|
+
this.scheduleSave();
|
|
323
|
+
return this.snapshot();
|
|
324
|
+
}
|
|
325
|
+
const remote = parseTavilyUsageBody(body);
|
|
326
|
+
if (remote === null) {
|
|
327
|
+
this.state.tavily = { ...this.state.tavily, error: "Tavily /usage returned an unreadable body", fetchedAt: Date.now() };
|
|
328
|
+
} else {
|
|
329
|
+
this.state.tavily = mergeTavilyRemote(this.state.tavily, remote);
|
|
330
|
+
}
|
|
331
|
+
this.scheduleSave();
|
|
332
|
+
return this.snapshot();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
scheduleSave() {
|
|
336
|
+
if (this.saveTimer !== null) return;
|
|
337
|
+
this.saveTimer = setTimeout(() => {
|
|
338
|
+
this.saveTimer = null;
|
|
339
|
+
const payload = JSON.stringify(this.state);
|
|
340
|
+
mkdir(dirname(USAGE_FILE), { recursive: true })
|
|
341
|
+
.then(() => writeFile(USAGE_FILE, payload))
|
|
342
|
+
.catch(() => { /* in-memory cache still serves the UI */ });
|
|
343
|
+
}, SAVE_DEBOUNCE_MS);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
dispose() {
|
|
347
|
+
if (this.saveTimer !== null) {
|
|
348
|
+
clearTimeout(this.saveTimer);
|
|
349
|
+
this.saveTimer = null;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-web-search-plugin",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Unified web search for the DeepSeek Harness seam (ctx.web): DeepSeek (official), Tavily, and Brave backends with an in-browser selector",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh-plugin",
|
|
7
7
|
"deepseek-harness",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
}
|
|
54
54
|
},
|
|
55
55
|
"scripts": {
|
|
56
|
-
"check": "node --check lib/index.js && node --check lib/client.js && node --check lib/shared.js && node --check lib/tavily.js && node --check lib/brave.js"
|
|
56
|
+
"check": "node --check lib/index.js && node --check lib/client.js && node --check lib/shared.js && node --check lib/tavily.js && node --check lib/brave.js && node --check lib/deepseek.js && node --check lib/usage.js"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"undici": "^6.21.0"
|