dsh-web-search-plugin 0.2.0 → 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 +26 -0
- package/README.md +52 -18
- package/lib/brave.js +1 -0
- package/lib/client.js +563 -89
- package/lib/index.js +99 -4
- package/lib/shared.js +1 -1
- package/lib/tavily.js +10 -2
- package/lib/usage.js +352 -0
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -22,6 +22,7 @@ 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";
|
|
25
26
|
|
|
26
27
|
/** Stable id this plugin registers under on `ctx.web`. */
|
|
27
28
|
const SEARCH_PROVIDER_ID = "dsh-web-search";
|
|
@@ -94,11 +95,17 @@ const WEB_SEARCH_TAVILY_SETTINGS_NAMESPACE = WEB_SEARCH_SETTINGS_NAMESPACE;
|
|
|
94
95
|
class PluginSearchProvider {
|
|
95
96
|
id = SEARCH_PROVIDER_ID;
|
|
96
97
|
|
|
97
|
-
constructor(resolveOptions) {
|
|
98
|
+
constructor(resolveOptions, usage) {
|
|
98
99
|
this.resolveOptions = resolveOptions;
|
|
99
100
|
this.deepseek = new DeepSeekSearchProvider(() => this.resolveOptions().deepseek);
|
|
100
|
-
this.tavily = new TavilySearchProvider(() =>
|
|
101
|
-
|
|
101
|
+
this.tavily = new TavilySearchProvider(() => ({
|
|
102
|
+
...this.resolveOptions().tavily,
|
|
103
|
+
onUsage: (credits) => usage?.recordTavilyCredits(credits)
|
|
104
|
+
}));
|
|
105
|
+
this.brave = new BraveSearchProvider(() => ({
|
|
106
|
+
...this.resolveOptions().brave,
|
|
107
|
+
onRateLimit: (headers) => usage?.recordBraveHeaders(headers)
|
|
108
|
+
}));
|
|
102
109
|
}
|
|
103
110
|
|
|
104
111
|
available() {
|
|
@@ -128,6 +135,38 @@ function resolveOptions(ctx, config) {
|
|
|
128
135
|
};
|
|
129
136
|
}
|
|
130
137
|
|
|
138
|
+
/** JSON body helper for the usage refresh POST. */
|
|
139
|
+
function readJsonBody(req) {
|
|
140
|
+
return new Promise((resolve, reject) => {
|
|
141
|
+
let body = "";
|
|
142
|
+
req.on("data", (chunk) => {
|
|
143
|
+
body += chunk;
|
|
144
|
+
if (body.length > 1e6) {
|
|
145
|
+
req.destroy();
|
|
146
|
+
reject(new Error("Payload too large"));
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
req.on("end", () => {
|
|
150
|
+
try {
|
|
151
|
+
resolve(body ? JSON.parse(body) : {});
|
|
152
|
+
} catch {
|
|
153
|
+
reject(new Error("Invalid JSON"));
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
req.on("error", reject);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function sendJson(res, statusCode, data) {
|
|
161
|
+
const body = JSON.stringify(data);
|
|
162
|
+
res.writeHead(statusCode, {
|
|
163
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
164
|
+
"Cache-Control": "no-store",
|
|
165
|
+
"Content-Length": Buffer.byteLength(body)
|
|
166
|
+
});
|
|
167
|
+
res.end(body);
|
|
168
|
+
}
|
|
169
|
+
|
|
131
170
|
/** Register the dispatched search provider with `ctx.web`. */
|
|
132
171
|
function apply(ctx, config) {
|
|
133
172
|
let current = () => config;
|
|
@@ -137,7 +176,61 @@ function apply(ctx, config) {
|
|
|
137
176
|
},
|
|
138
177
|
onChange: () => {}
|
|
139
178
|
});
|
|
140
|
-
|
|
179
|
+
const live = () => resolveOptions(ctx, current());
|
|
180
|
+
const usage = new UsageTracker({
|
|
181
|
+
getTavilyOptions: () => live().tavily
|
|
182
|
+
});
|
|
183
|
+
ctx.effect(() => {
|
|
184
|
+
let cancelled = false;
|
|
185
|
+
void usage.hydrate().then(() => {
|
|
186
|
+
if (cancelled) return;
|
|
187
|
+
if (live().provider === "tavily" && live().tavily.mode === "keyed") return usage.refreshTavily(undefined, true);
|
|
188
|
+
}).catch(() => {});
|
|
189
|
+
return () => {
|
|
190
|
+
cancelled = true;
|
|
191
|
+
usage.dispose();
|
|
192
|
+
};
|
|
193
|
+
}, "dsh-web-search-plugin: usage hydrate");
|
|
194
|
+
ctx.effect(() => {
|
|
195
|
+
const tick = () => {
|
|
196
|
+
if (live().provider === "tavily" && live().tavily.mode === "keyed") void usage.refreshTavily().catch(() => {});
|
|
197
|
+
};
|
|
198
|
+
const timer = setInterval(tick, TAVILY_USAGE_POLL_MS);
|
|
199
|
+
return () => clearInterval(timer);
|
|
200
|
+
}, "dsh-web-search-plugin: tavily usage poll");
|
|
201
|
+
ctx.web.registerSearchProvider(new PluginSearchProvider(live, usage));
|
|
202
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
203
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
204
|
+
kind: "exact",
|
|
205
|
+
path: "/dsh-web-search/usage",
|
|
206
|
+
async handler(req, res) {
|
|
207
|
+
if (req.method !== "GET" && req.method !== "HEAD" && req.method !== "POST") {
|
|
208
|
+
res.writeHead(405, { Allow: "GET, HEAD, POST" });
|
|
209
|
+
res.end();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
213
|
+
const force = req.method === "POST" || url.searchParams.get("force") === "1" || url.searchParams.get("force") === "true";
|
|
214
|
+
const options = live();
|
|
215
|
+
const tavilyState = usage.snapshot().tavily;
|
|
216
|
+
const missingIdentity = tavilyState.plan == null && tavilyState.limit == null;
|
|
217
|
+
if ((force || missingIdentity) && options.provider === "tavily" && options.tavily.mode === "keyed") {
|
|
218
|
+
try {
|
|
219
|
+
if (req.method === "POST") await readJsonBody(req).catch(() => ({}));
|
|
220
|
+
await usage.refreshTavily(undefined, force);
|
|
221
|
+
} catch {
|
|
222
|
+
/* serialize still returns the last cache */
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (req.method === "HEAD") {
|
|
226
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
|
|
227
|
+
res.end();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
sendJson(res, 200, usage.serialize(options.provider, options.tavily.mode));
|
|
231
|
+
}
|
|
232
|
+
}), "dsh-web-search-plugin: usage route");
|
|
233
|
+
});
|
|
141
234
|
}
|
|
142
235
|
|
|
143
236
|
export {
|
|
@@ -151,7 +244,9 @@ export {
|
|
|
151
244
|
SEARCH_PROVIDER_ID,
|
|
152
245
|
TAVILY_DEFAULT_API_KEY_ENV,
|
|
153
246
|
TAVILY_DEFAULT_BASE_URL,
|
|
247
|
+
TAVILY_USAGE_POLL_MS,
|
|
154
248
|
TavilySearchProvider,
|
|
249
|
+
UsageTracker,
|
|
155
250
|
WEB_SEARCH_SETTINGS_NAMESPACE,
|
|
156
251
|
WEB_SEARCH_TAVILY_SETTINGS_NAMESPACE,
|
|
157
252
|
apply,
|
package/lib/shared.js
CHANGED
|
@@ -7,7 +7,7 @@ import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
|
|
|
7
7
|
import { WebError } from "@deepseek-ai/dsh-web";
|
|
8
8
|
|
|
9
9
|
/** Attribution header sent on every request. */
|
|
10
|
-
export const USER_AGENT = "dsh-web-search-plugin/0.1
|
|
10
|
+
export const USER_AGENT = "dsh-web-search-plugin/0.2.1";
|
|
11
11
|
/** Shared cap: Tavily `max_results` and Brave `count` both accept 1..20. */
|
|
12
12
|
export const MAX_RESULTS_CAP = 20;
|
|
13
13
|
|
package/lib/tavily.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { WebError } from "@deepseek-ai/dsh-web";
|
|
6
6
|
import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
|
|
7
7
|
import { MAX_RESULTS_CAP, USER_AGENT, isAbortError, resolveApiKey, resolveSecret, searchAborted, throwIfSearchAborted } from "./shared.js";
|
|
8
|
+
import { parseTavilySearchCredits } from "./usage.js";
|
|
8
9
|
|
|
9
10
|
/** Default Tavily REST API base; `/search` is appended. */
|
|
10
11
|
export const TAVILY_DEFAULT_BASE_URL = "https://api.tavily.com";
|
|
@@ -67,13 +68,15 @@ export class TavilySearchProvider {
|
|
|
67
68
|
throwIfSearchAborted(signal);
|
|
68
69
|
}
|
|
69
70
|
const endpoint = `${options.baseURL.replace(/\/+$/u, "")}/search`;
|
|
71
|
+
const keyed = options.mode === "keyed";
|
|
70
72
|
const body = {
|
|
71
73
|
query: request.query,
|
|
72
74
|
max_results: Math.min(request.maxResults ?? options.maxResults, MAX_RESULTS_CAP),
|
|
73
75
|
search_depth: options.searchDepth,
|
|
74
76
|
topic: options.topic,
|
|
75
77
|
include_answer: options.includeAnswer === true,
|
|
76
|
-
include_images: false
|
|
78
|
+
include_images: false,
|
|
79
|
+
...keyed ? { include_usage: true } : {}
|
|
77
80
|
};
|
|
78
81
|
const headers = {
|
|
79
82
|
"content-type": "application/json",
|
|
@@ -106,7 +109,12 @@ export class TavilySearchProvider {
|
|
|
106
109
|
throw new WebError(message, "WEB_PROVIDER_ERROR");
|
|
107
110
|
}
|
|
108
111
|
try {
|
|
109
|
-
|
|
112
|
+
const json = await response.json();
|
|
113
|
+
if (keyed) {
|
|
114
|
+
const credits = parseTavilySearchCredits(json);
|
|
115
|
+
if (credits !== null) options.onUsage?.(credits);
|
|
116
|
+
}
|
|
117
|
+
return mapTavilyResponse(json, options.includeAnswer === true);
|
|
110
118
|
} catch (error) {
|
|
111
119
|
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
|
|
112
120
|
if (error instanceof WebError) throw error;
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-web-search-plugin",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
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",
|
|
@@ -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 && node --check lib/deepseek.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"
|