@maci0/dsh-quota-check 0.12.2

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 ADDED
@@ -0,0 +1,469 @@
1
+ /**
2
+ * dsh-quota-check: one statusbar figure for the provider the current session
3
+ * is using: the remaining balance on a pure API-billing route, or the plan
4
+ * quota on a subscription route.
5
+ *
6
+ * Two halves, one endpoint:
7
+ *
8
+ * - this host half owns the provider credential and the outbound request, and
9
+ * serves one JSON reply from `GET /quota-check?provider=<id>`. That is the
10
+ * only way a browser half can reach a provider key: the key stays in this
11
+ * process, and the browser only ever sees the formatted figure.
12
+ * - `lib/client.js` registers the chip in `conversation.composer.dock` (the
13
+ * statusbar row directly under the composer and its model selector) and
14
+ * asks this route for the provider the session's model selection names.
15
+ *
16
+ * Which endpoints answer is `probes.ts`; the credential and the reading are
17
+ * resolved once per provider and cached for {@link DEFAULT_CACHE_SECONDS}, so
18
+ * a rerender, a session switch, or a second tab never multiplies provider
19
+ * traffic.
20
+ *
21
+ * @module dsh-quota-check
22
+ */
23
+ import Schema from '@deepseek-ai/schemastery';
24
+ import { localRequests } from './local-usage.js';
25
+ import { resolveProbe } from './probes.js';
26
+ import { record } from './util.js';
27
+ /** Plugin name as it appears in the loader. */
28
+ export const name = 'quota-check';
29
+ /**
30
+ * The route carrier, and the trust fence the route checks first: without
31
+ * `connection` nothing would refuse a cross-origin or unauthenticated caller,
32
+ * so the plugin waits for it rather than serving unfenced.
33
+ */
34
+ export const inject = ['webServer', 'connection'];
35
+ /** The route the browser half reads. */
36
+ export const ROUTE = '/quota-check';
37
+ /** Seconds one provider's reading is served without re-asking the provider. */
38
+ export const DEFAULT_CACHE_SECONDS = 60;
39
+ /** Per-request ceiling for a provider call, in milliseconds. */
40
+ export const DEFAULT_TIMEOUT_MS = 10_000;
41
+ /** Seconds the browser half waits between re-reads, unless configured otherwise. */
42
+ export const DEFAULT_REFRESH_SECONDS = 300;
43
+ /** Live report entries one host keeps before it starts evicting. */
44
+ const MAX_REPORTS = 512;
45
+ /** Longest provider route id the route accepts. */
46
+ const MAX_PROVIDER_ID_LENGTH = 128;
47
+ /**
48
+ * Shape of a provider route id. The harness's built-in routes are lowercase
49
+ * and hyphenated (`deepseek-official`, `zai-coding-cn`); a profile's own route
50
+ * keys are user-chosen, so `_`, `.` and capitals are admitted too (the z.ai
51
+ * probe already recognizes `z_ai`).
52
+ */
53
+ const PROVIDER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
54
+ /**
55
+ * Field defaults and bounds with no volatility wrapper. {@link resolveRow}
56
+ * parses a plain row through this schema, so its output is plain values; the
57
+ * loader-facing {@link Config} below is the same shape with every field made
58
+ * `volatile()`. The pair is asserted equal in the suite.
59
+ */
60
+ const ValueSchema = Schema.object({
61
+ cacheSeconds: Schema.number().min(0).max(3_600).default(DEFAULT_CACHE_SECONDS),
62
+ timeoutMs: Schema.number().min(1).max(60_000).default(DEFAULT_TIMEOUT_MS),
63
+ refreshSeconds: Schema.number().min(10).max(3_600).default(DEFAULT_REFRESH_SECONDS),
64
+ });
65
+ /**
66
+ * Row schema as Cordis resolves it: what this plugin's `config` is validated
67
+ * against, and where each default lives. Every value here is a deployment
68
+ * choice (the cadences and the deadline vary by machine), so none is a
69
+ * constant only this plugin could change, and all three are editable from the
70
+ * Plugins page.
71
+ */
72
+ export const Config = Schema.object({
73
+ cacheSeconds: Schema.number().min(0).max(3_600).default(DEFAULT_CACHE_SECONDS).volatile(),
74
+ timeoutMs: Schema.number().min(1).max(60_000).default(DEFAULT_TIMEOUT_MS).volatile(),
75
+ refreshSeconds: Schema.number().min(10).max(3_600).default(DEFAULT_REFRESH_SECONDS).volatile(),
76
+ });
77
+ /**
78
+ * Read one configured field as a plain value.
79
+ *
80
+ * The loader hands a `volatile()` field a live reference; a direct caller (a
81
+ * test, another plugin composing this one) hands the value itself. Both are
82
+ * accepted, so one read path serves both.
83
+ * @param value - the configured value, live or plain.
84
+ * @returns the current plain value, or `undefined` when a reference holds none.
85
+ */
86
+ function readLive(value) {
87
+ if (value !== null && typeof value === 'object' && typeof value.get === 'function') {
88
+ // A scalar snapshot is the value; the generic cannot narrow that itself.
89
+ return value.get();
90
+ }
91
+ return value;
92
+ }
93
+ /**
94
+ * Turn a row (live references or plain values) into validated plain options.
95
+ * @param row - the configured row.
96
+ * @returns the resolved options, defaults filled by the schema.
97
+ */
98
+ export function resolveRow(row = {}) {
99
+ return ValueSchema({
100
+ cacheSeconds: readLive(row.cacheSeconds),
101
+ timeoutMs: readLive(row.timeoutMs),
102
+ refreshSeconds: readLive(row.refreshSeconds),
103
+ });
104
+ }
105
+ /**
106
+ * What a provider route's base URL must start with: an http(s) scheme and a
107
+ * host. A scheme-less `box:20128` parses as a URL whose origin is "null".
108
+ */
109
+ const BASE_URL_PATTERN = /^https?:\/\/[^\s/?#]+/iu;
110
+ /** Whether a configured base URL is an absolute http(s) URL. */
111
+ function isHttpUrl(baseURL) {
112
+ return BASE_URL_PATTERN.test(baseURL) && URL.canParse(baseURL);
113
+ }
114
+ /** Write a JSON reply whose body is already serialized. */
115
+ function sendBody(res, status, body) {
116
+ res.statusCode = status;
117
+ res.setHeader('content-type', 'application/json; charset=utf-8');
118
+ res.setHeader('cache-control', 'no-store');
119
+ res.end(body);
120
+ }
121
+ /** Write a JSON reply; readings are live facts and are never cached by the browser. */
122
+ function sendJson(res, status, payload) {
123
+ sendBody(res, status, JSON.stringify(payload));
124
+ }
125
+ /**
126
+ * Query parameters of a request target.
127
+ *
128
+ * Only the query matters here, so only the query is parsed: building a `URL`
129
+ * for every read pays for an origin the route never looks at.
130
+ * @param url - request target, path and query.
131
+ * @returns the decoded parameters.
132
+ */
133
+ function searchParamsOf(url) {
134
+ // The fragment is not part of the request target a `URL` would parse either,
135
+ // and a target whose `#` precedes its `?` must not have the fragment read as
136
+ // query text.
137
+ const hash = url.indexOf('#');
138
+ const target = hash < 0 ? url : url.slice(0, hash);
139
+ const start = target.indexOf('?');
140
+ if (start < 0)
141
+ return new URLSearchParams();
142
+ return new URLSearchParams(target.slice(start + 1));
143
+ }
144
+ /**
145
+ * Resolve one provider route's profile: the settings namespace the LLM registry
146
+ * points at, drilled to the route's own path.
147
+ * @param ctx - host context.
148
+ * @param providerId - route id.
149
+ * @returns the fields this plugin reads, empty when nothing resolves.
150
+ */
151
+ function providerConfigOf(ctx, providerId) {
152
+ const llm = ctx.get('llm');
153
+ let entry;
154
+ try {
155
+ entry = llm?.listConfigurableProviders().find(candidate => candidate.provider === providerId);
156
+ }
157
+ catch (error) {
158
+ ctx.logger.warn(`quota-check: could not read the provider directory (${String(error)})`);
159
+ }
160
+ if (entry === undefined)
161
+ return {};
162
+ const settings = ctx.get('settings');
163
+ const descriptor = settings?.describe().find(candidate => candidate.ns === entry.settingsNs);
164
+ let section = descriptor?.value;
165
+ for (const segment of entry.settingsPath)
166
+ section = record(section)?.[segment];
167
+ const fields = record(section) ?? {};
168
+ return {
169
+ ...entry.displayName === undefined ? {} : { displayName: entry.displayName },
170
+ ...typeof fields['baseURL'] === 'string' ? { baseURL: fields['baseURL'] } : {},
171
+ ...typeof fields['apiKeyEnv'] === 'string' ? { apiKeyEnv: fields['apiKeyEnv'] } : {},
172
+ };
173
+ }
174
+ /**
175
+ * Resolve the provider credential: the configured reference first, then the
176
+ * references the probe itself names.
177
+ * @param ctx - host context.
178
+ * @param config - the route's resolved profile.
179
+ * @param envNames - fallback references the probe names.
180
+ * @returns the key, or `undefined` when none is configured.
181
+ */
182
+ async function apiKeyOf(ctx, config, envNames) {
183
+ const credentials = ctx.get('credentials');
184
+ for (const ref of [config.apiKeyEnv, ...envNames]) {
185
+ if (ref === undefined || ref.length === 0)
186
+ continue;
187
+ const resolved = await credentials?.resolve(ref);
188
+ const stored = resolved?.value;
189
+ if (stored !== undefined && stored.length > 0)
190
+ return stored;
191
+ const ambient = process.env[ref];
192
+ if (ambient !== undefined && ambient.length > 0)
193
+ return ambient;
194
+ }
195
+ return undefined;
196
+ }
197
+ /**
198
+ * Build one provider's reading.
199
+ * @param ctx - host context.
200
+ * @param providerId - route id the browser half asked about.
201
+ * @param timeoutMs - per-request provider deadline.
202
+ * @param refreshMs - re-read cadence the browser half is told to use.
203
+ * @returns the report, never throwing: a failure is an `error` report.
204
+ */
205
+ async function buildReport(ctx, providerId, timeoutMs, refreshMs) {
206
+ let displayName = providerId;
207
+ try {
208
+ // Resolved per miss, not cached: the miss is network-bound, and a profile
209
+ // cache would hide a settings edit (baseURL, displayName) for its whole TTL.
210
+ const config = providerConfigOf(ctx, providerId);
211
+ displayName = config.displayName ?? providerId;
212
+ const base = { provider: providerId, displayName, fetchedAt: Date.now(), refreshMs };
213
+ // The route's baseURL belongs to another plugin's row, so it is checked
214
+ // here, at first use: no origin is guessed for a malformed one, and no
215
+ // credential is resolved or sent.
216
+ if (config.baseURL !== undefined && !isHttpUrl(config.baseURL)) {
217
+ return {
218
+ ...base,
219
+ status: 'error',
220
+ message: 'this route\'s baseURL is not an absolute http(s) URL; fix the provider row',
221
+ };
222
+ }
223
+ const probe = resolveProbe(providerId, config.baseURL);
224
+ if (probe === undefined) {
225
+ return {
226
+ ...base,
227
+ status: 'unsupported',
228
+ message: 'no balance or quota endpoint is known for this provider',
229
+ };
230
+ }
231
+ const envNames = probe.envNames ?? [];
232
+ const local = probe.local;
233
+ let requests;
234
+ if (local === undefined) {
235
+ const key = await apiKeyOf(ctx, config, envNames);
236
+ if (key === undefined) {
237
+ // A missing credential is a broken setup on a route that names its
238
+ // vendor; on a guessed route it only means the host is not that product.
239
+ return probe.tentative === true
240
+ ? {
241
+ ...base,
242
+ status: 'unsupported',
243
+ message: 'no balance or quota endpoint this plugin recognizes, and no credential to read for it',
244
+ }
245
+ : {
246
+ ...base,
247
+ status: 'error',
248
+ message: `no credential is configured for this route (${config.apiKeyEnv ?? envNames.join(' / ')})`,
249
+ };
250
+ }
251
+ if (probe.requests !== undefined) {
252
+ // The endpoint names ids only the provider knows: read its listing
253
+ // first, then ask each one it offers.
254
+ requests = await probe.requests({ key, timeoutMs });
255
+ }
256
+ else if (probe.url === undefined) {
257
+ return { ...base, status: 'error', message: 'this probe declares neither an endpoint nor a listing' };
258
+ }
259
+ else {
260
+ requests = [{ url: probe.url, headers: { authorization: `Bearer ${key}`, accept: 'application/json' } }];
261
+ }
262
+ if (requests.length === 0) {
263
+ return {
264
+ ...base,
265
+ status: 'error',
266
+ message: `${config.baseURL ?? providerId} listed no endpoint to ask`,
267
+ };
268
+ }
269
+ }
270
+ else {
271
+ requests = await localRequests(local, { timeoutMs });
272
+ if (requests.length === 0) {
273
+ return {
274
+ ...base,
275
+ status: 'error',
276
+ message: `no usable ${local} CLI credential is on this machine`,
277
+ };
278
+ }
279
+ }
280
+ let outcome = await fetchAll(requests, timeoutMs);
281
+ // A live-looking token can still be refused: rotate once and retry, which is
282
+ // what the CLI's own fetcher does. Cursor has no refresh path, so a retry
283
+ // there would only repeat the same refused request.
284
+ if (local !== undefined && local !== 'cursor' && !outcome.ok && outcome.refused) {
285
+ const retried = await localRequests(local, { forceRefresh: true, timeoutMs });
286
+ if (retried.length > 0)
287
+ outcome = await fetchAll(retried, timeoutMs);
288
+ }
289
+ if (!outcome.ok) {
290
+ // A 404 on a probe that only guessed the endpoint means the host does not
291
+ // publish it: absent data, not a reading that failed.
292
+ if (probe.tentative === true && outcome.status === 404) {
293
+ return {
294
+ ...base,
295
+ status: 'unsupported',
296
+ message: `${requests[0]?.url ?? providerId} is not on this host`,
297
+ };
298
+ }
299
+ return {
300
+ ...base,
301
+ status: 'error',
302
+ message: `${requests[0]?.url ?? providerId} answered HTTP ${String(outcome.status)}`,
303
+ };
304
+ }
305
+ const reading = probe.parse(outcome.payloads);
306
+ if (reading === null) {
307
+ return { ...base, status: 'error', message: 'the provider response carried no balance or quota figure' };
308
+ }
309
+ return {
310
+ ...base,
311
+ status: 'ok',
312
+ kind: probe.kind,
313
+ text: reading.text,
314
+ ...reading.remaining === undefined ? {} : { remaining: reading.remaining },
315
+ lines: reading.lines,
316
+ };
317
+ }
318
+ catch (error) {
319
+ // Another service's exception text (credentials, settings, a parser) may
320
+ // name host paths or internals, so it stays in the host log.
321
+ ctx.logger.warn(`quota-check: the ${providerId} reading failed (${String(error)})`);
322
+ return {
323
+ provider: providerId,
324
+ displayName,
325
+ fetchedAt: Date.now(),
326
+ refreshMs,
327
+ status: 'error',
328
+ message: 'the reading failed; the host log names the cause',
329
+ };
330
+ }
331
+ }
332
+ /**
333
+ * Run every request of one probe in order, tolerating a partial answer: Grok
334
+ * reports two meters and one of them may be unavailable, so only a round where
335
+ * nothing answered is a failure.
336
+ * @param requests - requests built from the provider's credential.
337
+ * @param timeoutMs - per-request deadline.
338
+ * @returns the decoded bodies, the last status, and the refusal flag.
339
+ */
340
+ async function fetchAll(requests, timeoutMs) {
341
+ const payloads = [];
342
+ let status = 0;
343
+ let refused = false;
344
+ for (const request of requests) {
345
+ try {
346
+ const response = await fetch(request.url, {
347
+ headers: request.headers,
348
+ signal: AbortSignal.timeout(timeoutMs),
349
+ });
350
+ status = response.status;
351
+ if (response.status === 401 || response.status === 403)
352
+ refused = true;
353
+ payloads.push(response.ok ? await response.json().catch(() => undefined) : undefined);
354
+ }
355
+ catch {
356
+ payloads.push(undefined);
357
+ }
358
+ }
359
+ return { ok: payloads.some(payload => payload !== undefined), status, refused, payloads };
360
+ }
361
+ /**
362
+ * Mount the host half.
363
+ * @param ctx - host context carrying the route carrier.
364
+ * @param config - this plugin's row configuration.
365
+ */
366
+ export function apply(ctx, row = {}) {
367
+ // Read the row at every use: each field is volatile, so a save from the
368
+ // Plugins card has to reach the next reading rather than a mount-time copy.
369
+ const live = () => resolveRow(row);
370
+ live();
371
+ const cache = new Map();
372
+ const inflight = new Map();
373
+ // Bumped by a settings write. A read that started before one belongs to the
374
+ // old row (its deadline and the `refreshMs` it reports), so it must not fill
375
+ // the cache the write just emptied.
376
+ let generation = 0;
377
+ /**
378
+ * Keep the report map at its cap: once it holds `MAX_REPORTS`, the oldest key
379
+ * goes before the next insert. `Map` iterates in insertion order, so the first
380
+ * key is the one inserted longest ago; an entry whose lifetime has passed is
381
+ * already a miss on read, so it needs no sweep here.
382
+ */
383
+ const pruneReports = () => {
384
+ if (cache.size < MAX_REPORTS)
385
+ return;
386
+ const oldest = cache.keys().next().value;
387
+ if (oldest !== undefined)
388
+ cache.delete(oldest);
389
+ };
390
+ /**
391
+ * Serve one provider's serialized reading: the cached body, or a fresh one.
392
+ *
393
+ * The body is serialized once, when the reading is taken, so a hit answers
394
+ * with the bytes written then instead of serializing the same report again on
395
+ * every poll.
396
+ */
397
+ const reportFor = (providerId, refresh) => {
398
+ const { cacheSeconds, timeoutMs, refreshSeconds } = live();
399
+ const hit = cache.get(providerId);
400
+ if (!refresh && hit !== undefined && Date.now() - hit.at < cacheSeconds * 1_000) {
401
+ return Promise.resolve(hit.body);
402
+ }
403
+ const running = inflight.get(providerId);
404
+ if (running !== undefined)
405
+ return running;
406
+ const started = generation;
407
+ const pending = buildReport(ctx, providerId, timeoutMs, refreshSeconds * 1_000).then((report) => {
408
+ const body = JSON.stringify(report);
409
+ // A write that landed mid-read emptied the cache; this body belongs to
410
+ // the row before it, so it is answered but not kept.
411
+ if (started === generation) {
412
+ pruneReports();
413
+ cache.set(providerId, { at: Date.now(), body });
414
+ }
415
+ return body;
416
+ }).finally(() => {
417
+ // Only this read's own entry: after a write, a later poll may have
418
+ // installed its own read under the same key.
419
+ if (inflight.get(providerId) === pending)
420
+ inflight.delete(providerId);
421
+ });
422
+ inflight.set(providerId, pending);
423
+ return pending;
424
+ };
425
+ const handler = async (req, res) => {
426
+ const rejection = ctx.connection.requestRejection(req);
427
+ if (rejection !== undefined) {
428
+ res.statusCode = rejection;
429
+ res.end();
430
+ return;
431
+ }
432
+ if ((req.method ?? 'GET').toUpperCase() !== 'GET') {
433
+ res.setHeader('allow', 'GET');
434
+ sendJson(res, 405, { status: 'error', message: 'this route answers GET only' });
435
+ return;
436
+ }
437
+ const params = searchParamsOf(String(req.url ?? ROUTE));
438
+ const providerId = params.get('provider') ?? '';
439
+ if (providerId.length === 0) {
440
+ sendJson(res, 400, { status: 'error', message: 'a provider query parameter is required' });
441
+ return;
442
+ }
443
+ // Checked before the id keys the cache or reaches any lookup.
444
+ if (providerId.length > MAX_PROVIDER_ID_LENGTH || !PROVIDER_ID_PATTERN.test(providerId)) {
445
+ sendJson(res, 400, {
446
+ status: 'error',
447
+ message: `the provider query parameter must be a route id: letters, digits, ".", "_" or "-", at most ${String(MAX_PROVIDER_ID_LENGTH)} characters`,
448
+ });
449
+ return;
450
+ }
451
+ const body = await reportFor(providerId, params.get('refresh') === '1');
452
+ sendBody(res, 200, body);
453
+ };
454
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: ROUTE, handler }), `quota-check: GET ${ROUTE}`);
455
+ // A settings write moves the live references in place. Dropping the served
456
+ // readings makes the next poll show the effect of the edit instead of a body
457
+ // cached under the previous window, and the log line records what it became.
458
+ ctx.on('loader/volatile-update', () => {
459
+ cache.clear();
460
+ // A poll that starts after the write must read with the new row, so the
461
+ // reads the old row started stop being handed out. Their callers still get
462
+ // an answer.
463
+ inflight.clear();
464
+ generation += 1;
465
+ const { cacheSeconds, timeoutMs, refreshSeconds } = live();
466
+ ctx.logger.warn(`quota-check: configuration updated: ${cacheSeconds}s cache, ${timeoutMs}ms deadline,`
467
+ + ` ${refreshSeconds}s browser refresh`);
468
+ });
469
+ }