@linxin666/dsh-usage 0.3.7

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,1443 @@
1
+ import { Service } from "@deepseek-ai/cordis";
2
+ import z from "schemastery";
3
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
4
+ import { dirname, isAbsolute, join } from "node:path";
5
+ import { homedir } from "node:os";
6
+ import { isAbsolute as isAbsolute$1, join as join$1 } from "node:path/posix";
7
+ //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-settings@file+..+..+.dsh-cohorts+0.1.2-alpha.1+deepseek-ai-dsh-setting_b5034a2f9729f21532041bfd08a6ac26/node_modules/@deepseek-ai/dsh-settings/lib/index.js
8
+ /**
9
+ * Structural secret redaction for settings values. `role('secret')` fields are
10
+ * removed from a value before it crosses a wire boundary; a sidecar records
11
+ * each schema-declared secret position and whether it currently holds a value,
12
+ * so a configuration surface can render a write-only input without ever
13
+ * receiving the secret itself.
14
+ * @module @deepseek-ai/dsh-settings/redact
15
+ */
16
+ /** Whether a value is a plain data object the walker may recurse into. */
17
+ function isRecord(value) {
18
+ return typeof value === "object" && value !== null && !Array.isArray(value);
19
+ }
20
+ function walk(node, value, path, secrets) {
21
+ if (node === void 0) return value;
22
+ if (node.meta?.role === "secret") {
23
+ secrets.push({
24
+ path,
25
+ set: value !== void 0
26
+ });
27
+ return;
28
+ }
29
+ switch (node.type) {
30
+ case "object": {
31
+ const properties = node.dict ?? {};
32
+ const source = isRecord(value) ? value : void 0;
33
+ const rebuilt = {};
34
+ if (source !== void 0) for (const [key, entry] of Object.entries(source)) {
35
+ if (key in properties) continue;
36
+ rebuilt[key] = entry;
37
+ }
38
+ for (const [key, child] of Object.entries(properties)) {
39
+ const stripped = walk(child, source?.[key], [...path, key], secrets);
40
+ if (stripped !== void 0) rebuilt[key] = stripped;
41
+ }
42
+ return source === void 0 && Object.keys(rebuilt).length === 0 ? value : rebuilt;
43
+ }
44
+ case "dict": {
45
+ if (!isRecord(value)) return value;
46
+ const rebuilt = {};
47
+ for (const [key, entry] of Object.entries(value)) {
48
+ const stripped = walk(node.inner, entry, [...path, key], secrets);
49
+ if (stripped !== void 0) rebuilt[key] = stripped;
50
+ }
51
+ return rebuilt;
52
+ }
53
+ case "array":
54
+ if (!Array.isArray(value)) return value;
55
+ return value.map((entry, index) => walk(node.inner, entry, [...path, String(index)], secrets));
56
+ default: return value;
57
+ }
58
+ }
59
+ /**
60
+ * Service Definition for the user-settings capability seam (`ctx.settings`). Providers store one raw document of
61
+ * per-namespace sections; plugins register a namespace schema and read the
62
+ * resolved value, which layers schema defaults, the registrant's composition
63
+ * `base`, and the user document section, in that order.
64
+ * @module @deepseek-ai/dsh-settings
65
+ */
66
+ const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/;
67
+ /**
68
+ * Brand a raw string as a {@link SettingsNamespace}.
69
+ * @param value - candidate namespace; lowercase kebab-case, as in plugin short names.
70
+ * @returns the branded namespace.
71
+ */
72
+ function settingsNamespace(value) {
73
+ if (!NAMESPACE_PATTERN.test(value)) throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`);
74
+ return value;
75
+ }
76
+ /**
77
+ * Deep equality over JSON-compatible data (objects, arrays, primitives) — the
78
+ * Service Definition's single change-detection predicate, exported so the invariant
79
+ * companion checks exactly the implementation's relation.
80
+ * @param a - one JSON-compatible value.
81
+ * @param b - the other JSON-compatible value.
82
+ * @returns whether the two values are structurally equal.
83
+ */
84
+ function deepEqualJson(a, b) {
85
+ if (a === b) return true;
86
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
87
+ if (Array.isArray(a) || Array.isArray(b)) {
88
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
89
+ return a.every((entry, index) => deepEqualJson(entry, b[index]));
90
+ }
91
+ const left = a;
92
+ const right = b;
93
+ const keys = Object.keys(left);
94
+ if (keys.length !== Object.keys(right).length) return false;
95
+ return keys.every((key) => key in right && deepEqualJson(left[key], right[key]));
96
+ }
97
+ /** Whether a value is a plain data object (not an array, null, or class instance). */
98
+ function isPlainObject(value) {
99
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
100
+ const proto = Object.getPrototypeOf(value);
101
+ return proto === Object.prototype || proto === null;
102
+ }
103
+ /** Apply one path op to a detached section, returning the next section. */
104
+ function applyPathOp(section, op) {
105
+ const [head, ...rest] = op.path;
106
+ if (head === void 0) {
107
+ if (op.op === "unset") return {};
108
+ if (!isPlainObject(op.value)) throw new TypeError("settings mutate: setting the section root requires a plain object");
109
+ return { ...op.value };
110
+ }
111
+ if (rest.length === 0) {
112
+ if (op.op === "set") return {
113
+ ...section,
114
+ [head]: op.value
115
+ };
116
+ const { [head]: _removed, ...kept } = section;
117
+ return kept;
118
+ }
119
+ const child = section[head];
120
+ if (!isPlainObject(child)) {
121
+ if (op.op === "unset") return section;
122
+ return {
123
+ ...section,
124
+ [head]: applyPathOp({}, {
125
+ ...op,
126
+ path: rest
127
+ })
128
+ };
129
+ }
130
+ return {
131
+ ...section,
132
+ [head]: applyPathOp(child, {
133
+ ...op,
134
+ path: rest
135
+ })
136
+ };
137
+ }
138
+ /**
139
+ * Layer `over` onto `under`: plain objects merge recursively, every other
140
+ * value (arrays included) replaces the lower layer wholesale. `over` never
141
+ * carries `undefined` entries — sections come from parsed documents and write
142
+ * snapshots pass {@link cloneJsonShaped}, which strips them so a sparse patch
143
+ * cannot erase lower keys.
144
+ */
145
+ function mergeLayers(under, over) {
146
+ if (over === void 0) return under;
147
+ if (!isPlainObject(under) || !isPlainObject(over)) return over;
148
+ const merged = { ...under };
149
+ for (const [key, value] of Object.entries(over)) merged[key] = key in merged ? mergeLayers(merged[key], value) : value;
150
+ return merged;
151
+ }
152
+ /** Recursively freeze one resolved value so handed-out snapshots stay immutable. */
153
+ function deepFreeze(value) {
154
+ if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value;
155
+ for (const entry of Object.values(value)) deepFreeze(entry);
156
+ return Object.freeze(value);
157
+ }
158
+ Service.init;
159
+ /**
160
+ * Value mirror of the `FiberState` members {@link isUnloading} compares
161
+ * against: a const enum has no runtime object to import, and the value is
162
+ * needed at runtime (same rationale as the CLI boot driver's mirror).
163
+ */
164
+ const FIBER_DISPOSED = 4;
165
+ const FIBER_UNLOADING = 5;
166
+ /** Whether the consumer's own fiber is tearing down (not just losing the settings service). */
167
+ function isUnloading(ctx) {
168
+ const state = ctx.fiber.state;
169
+ return state === FIBER_UNLOADING || state === FIBER_DISPOSED;
170
+ }
171
+ /**
172
+ * Install the canonical optional-settings consumer wiring: while a settings
173
+ * service exists, register `ns` with the consumer's composition entry as the
174
+ * `base` layer and point the source thunk at the resolved scope; when the
175
+ * service goes away (disposal, provider reload), fall back to the entry so
176
+ * the consumer keeps working exactly as composed. The registration rides the
177
+ * scoped fiber, so no settings service ever mounted means none of this runs.
178
+ * @param ctx - consumer plugin context owning the wiring.
179
+ * @param ns - the consumer-owned settings namespace.
180
+ * @param schema - schema resolving the namespace (typically the plugin Config).
181
+ * @param entry - the consumer's composition entry config, used as `base`.
182
+ * @param hooks - source sink and change notification.
183
+ */
184
+ function installSettingsSection(ctx, ns, schema, entry, hooks) {
185
+ ctx.inject(["settings"], (sctx) => {
186
+ const scope = sctx.settings.register(ns, schema, {
187
+ base: entry,
188
+ ...hooks.validate === void 0 ? {} : { validate: hooks.validate }
189
+ });
190
+ hooks.setSource(() => scope.get());
191
+ sctx.effect(() => () => {
192
+ if (isUnloading(ctx)) return;
193
+ hooks.setSource(() => entry);
194
+ hooks.onChange();
195
+ });
196
+ hooks.onChange();
197
+ scope.watch(() => {
198
+ if (isUnloading(ctx)) return;
199
+ hooks.onChange();
200
+ });
201
+ });
202
+ }
203
+ //#endregion
204
+ //#region src/mount-once.ts
205
+ /**
206
+ * Host single-instance guard shared by the plugin family. The family bundle
207
+ * (dsh-web-all / dsh-skins) namespaces every child row id (web-ui-*), so
208
+ * the loader accepts a standalone install of the same package side by side;
209
+ * without this guard the second instance would still re-register the same
210
+ * webserver routes, tools, settings namespaces, and system-prompt sections
211
+ * and fail the boot. mountOnce makes the second host apply a no-op for the
212
+ * lifetime of the first instance (the browser half is already deduped by
213
+ * package name in the client module host).
214
+ *
215
+ * The registry rides a global symbol so two module instances of the same
216
+ * package (npm copy vs repository link) still share one verdict. cordis
217
+ * `ctx.effect` runs its callback immediately and treats the callback's
218
+ * return value as the fiber disposer, so the unmarker is returned, not run.
219
+ */
220
+ const MOUNTED = Symbol.for("dsh-web.mounted-plugins");
221
+ function mountedSet() {
222
+ const registry = globalThis;
223
+ return registry[MOUNTED] ??= /* @__PURE__ */ new Set();
224
+ }
225
+ /**
226
+ * Wrap a cordis plugin apply so the package runs at most once per process.
227
+ * The first mount registers normally and unmarks when its fiber disposes;
228
+ * any later mount of the same package name is a no-op.
229
+ * @param packageName - npm package identity shared by every install source.
230
+ * @param fn - the original plugin apply.
231
+ * @returns an apply of the same shape.
232
+ */
233
+ function mountOnce(packageName, fn) {
234
+ return ((...args) => {
235
+ const mounted = mountedSet();
236
+ if (mounted.has(packageName)) return;
237
+ mounted.add(packageName);
238
+ args[0]?.effect?.(() => () => {
239
+ mounted.delete(packageName);
240
+ });
241
+ return fn(...args);
242
+ });
243
+ }
244
+ //#endregion
245
+ //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-credentials@file+..+..+.dsh-cohorts+0.1.2-alpha.1+deepseek-ai-dsh-cred_2fd4ad69ebe2ff7cfc47e1238c01d827/node_modules/@deepseek-ai/dsh-credentials/lib/index.js
246
+ /**
247
+ * Service Definition for the credential-reference capability seam (`ctx.credentials`). Settings and composition files carry
248
+ * *references* to secrets — environment-variable names — while providers own
249
+ * the actual values and their storage. Consumers resolve a reference once per
250
+ * operation, so a changed credential reaches the next operation without any
251
+ * plugin restart, and configuration surfaces describe a reference without
252
+ * ever seeing its value.
253
+ * @module @deepseek-ai/dsh-credentials
254
+ */
255
+ const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
256
+ /** Both halves of a {@link CredentialKey}; the `/` between them is what keeps it out of {@link REF_PATTERN}. */
257
+ const KEY_SEGMENT_PATTERN = /^[a-z][a-z0-9-]*$/;
258
+ /**
259
+ * Brand a raw string as a {@link CredentialRef}.
260
+ * @param value - candidate reference; a POSIX shell identifier such as `DEEPSEEK_API_KEY`.
261
+ * @returns the branded reference.
262
+ */
263
+ function credentialRef(value) {
264
+ if (!isCredentialRefName(value)) throw new TypeError(`credential ref "${value}" must match ${String(REF_PATTERN)}`);
265
+ return value;
266
+ }
267
+ /**
268
+ * Whether a raw string could name a reference at all. Consumers that receive
269
+ * environment-variable names from somewhere else — a provider library's own
270
+ * ambient discovery, a hook payload — ask this before resolving, because a name
271
+ * outside the grammar has no reference to miss and should read as "not set"
272
+ * rather than as a thrown error.
273
+ * @param value - candidate reference.
274
+ * @returns true when {@link credentialRef} would accept it.
275
+ */
276
+ function isCredentialRefName(value) {
277
+ return REF_PATTERN.test(value);
278
+ }
279
+ /**
280
+ * Brand a scope and an id as a {@link CredentialKey}.
281
+ * @param scope - the owning plugin's registered name, such as `llm-pi-ai`.
282
+ * @param id - that plugin's own addressing unit, such as a provider route key.
283
+ * @returns the branded key.
284
+ * @throws TypeError when either segment is not a lowercase hyphenated identifier.
285
+ */
286
+ function credentialKey(scope, id) {
287
+ for (const segment of [scope, id]) if (!KEY_SEGMENT_PATTERN.test(segment)) throw new TypeError(`credential key segment "${segment}" must match ${String(KEY_SEGMENT_PATTERN)}`);
288
+ return `${scope}/${id}`;
289
+ }
290
+ //#endregion
291
+ //#region src/dsh-home.ts
292
+ /**
293
+ * DSH_HOME resolution shared by the plugin family's Host halves: the
294
+ * environment override wins, the platform home fallback follows. Mirrors
295
+ * what dsh-pet and dsh-liangshen each used to implement locally.
296
+ */
297
+ /** Expand a leading ~ (or ~user) in a path, platform-style. */
298
+ function expandHome(path, home = homedir()) {
299
+ const j = home.startsWith("/") ? join$1 : join;
300
+ if (path === "~") return home;
301
+ if (path.startsWith("~/") || path.startsWith("~\\")) return j(home, path.slice(2));
302
+ return path;
303
+ }
304
+ /**
305
+ * Resolve the DSH home directory.
306
+ * @param env - process environment to read DSH_HOME from.
307
+ * @param home - platform home directory fallback (test seam).
308
+ * @returns the absolute DSH home path.
309
+ */
310
+ function resolveDshHome(env = process.env, home = homedir()) {
311
+ const isPosix = home.startsWith("/");
312
+ const j = isPosix ? join$1 : join;
313
+ const isAbs = isPosix ? isAbsolute$1 : isAbsolute;
314
+ const raw = env.DSH_HOME;
315
+ if (raw !== void 0 && raw.trim() !== "") {
316
+ const expanded = expandHome(raw.trim(), home);
317
+ return isAbs(expanded) ? expanded : j(process.cwd(), expanded);
318
+ }
319
+ return j(home, ".dsh");
320
+ }
321
+ /** Resolve the DSH home directory from the live environment. */
322
+ function dshHome() {
323
+ return resolveDshHome();
324
+ }
325
+ //#endregion
326
+ //#region src/core/adapters.ts
327
+ /** Parse a string/number into a finite number, else undefined. */
328
+ function toNum(value) {
329
+ if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
330
+ if (typeof value === "string" && value.trim() !== "") {
331
+ const parsed = Number(value);
332
+ return Number.isFinite(parsed) ? parsed : void 0;
333
+ }
334
+ }
335
+ /** Read a string field that must be a non-empty string. */
336
+ function str(value) {
337
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
338
+ }
339
+ /** Format a number to a fixed 2-decimal display string. */
340
+ function money(value) {
341
+ return value.toFixed(2);
342
+ }
343
+ /** Millisecond epoch or ISO string → normalized ISO 8601, else undefined. */
344
+ function toIso(value) {
345
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
346
+ const ms = value < 0xe8d4a51000 ? value * 1e3 : value;
347
+ const date = new Date(ms);
348
+ return Number.isNaN(date.getTime()) ? void 0 : date.toISOString();
349
+ }
350
+ const text = str(value);
351
+ if (text === void 0) return void 0;
352
+ const date = new Date(text);
353
+ return Number.isNaN(date.getTime()) ? text : date.toISOString();
354
+ }
355
+ /** Used-percent helper guarding zero/absent limits. */
356
+ function usedPercent(used, limit) {
357
+ const usedNum = toNum(used);
358
+ const limitNum = toNum(limit);
359
+ if (usedNum === void 0 || limitNum === void 0 || limitNum <= 0) return void 0;
360
+ return Math.max(0, Math.min(100, usedNum / limitNum * 100));
361
+ }
362
+ function bearer(apiKey) {
363
+ return { authorization: `Bearer ${apiKey}` };
364
+ }
365
+ const DEEPSEEK = {
366
+ ids: ["deepseek"],
367
+ displayName: "DeepSeek",
368
+ balance: {
369
+ build: ({ apiKey }) => ({
370
+ url: "https://api.deepseek.com/user/balance",
371
+ headers: bearer(apiKey)
372
+ }),
373
+ parse: (status, body) => {
374
+ if (status !== 200 || typeof body !== "object" || body === null) return void 0;
375
+ const infos = body.balance_infos;
376
+ if (!Array.isArray(infos) || infos.length === 0) return void 0;
377
+ const first = infos[0];
378
+ if (typeof first !== "object" || first === null) return void 0;
379
+ const currency = str(first.currency);
380
+ const total = str(first.total_balance);
381
+ if (currency === void 0 || total === void 0) return void 0;
382
+ return {
383
+ currency,
384
+ totalBalance: total
385
+ };
386
+ }
387
+ }
388
+ };
389
+ /** Moonshot pay-as-you-go balance; CN bills in CNY, international in USD. */
390
+ function moonshotBalance(host, currency, ids) {
391
+ return {
392
+ ids,
393
+ displayName: "Moonshot AI",
394
+ balance: {
395
+ build: ({ apiKey }) => ({
396
+ url: `https://${host}/v1/users/me/balance`,
397
+ headers: bearer(apiKey)
398
+ }),
399
+ parse: (status, body) => {
400
+ if (status !== 200 || typeof body !== "object" || body === null) return void 0;
401
+ const data = body.data;
402
+ if (typeof data !== "object" || data === null) return void 0;
403
+ const available = toNum(data.available_balance);
404
+ if (available === void 0) return void 0;
405
+ return {
406
+ currency,
407
+ totalBalance: money(available)
408
+ };
409
+ }
410
+ }
411
+ };
412
+ }
413
+ /** Kimi For Coding quota: top-level `usage` is the weekly summary, `limits[]` the per-window rows. */
414
+ const KIMI_CODING = {
415
+ ids: ["kimi-coding"],
416
+ displayName: "Kimi For Coding",
417
+ plan: {
418
+ build: ({ apiKey }) => ({
419
+ url: "https://api.kimi.com/coding/v1/usages",
420
+ headers: bearer(apiKey)
421
+ }),
422
+ parse: (status, body) => {
423
+ if (status !== 200 || typeof body !== "object" || body === null) return void 0;
424
+ const root = body;
425
+ const windows = [];
426
+ const limits = root.limits;
427
+ if (Array.isArray(limits)) for (const entry of limits) {
428
+ if (typeof entry !== "object" || entry === null) continue;
429
+ const detail = entry.detail;
430
+ const window = entry.window;
431
+ if (typeof detail !== "object" || detail === null) continue;
432
+ const row = detail;
433
+ const duration = typeof window === "object" && window !== null ? toNum(window.duration) : void 0;
434
+ const unit = typeof window === "object" && window !== null ? str(window.timeUnit) : void 0;
435
+ const key = duration === 300 && unit === "TIME_UNIT_MINUTE" ? "5h" : duration !== void 0 ? `w-${duration}` : "window";
436
+ const percent = usedPercent(row.used, row.limit);
437
+ windows.push({
438
+ key,
439
+ name: str(row.name),
440
+ percent,
441
+ resetsAt: toIso(row.resetTime)
442
+ });
443
+ }
444
+ const usage = root.usage;
445
+ if (typeof usage === "object" && usage !== null) {
446
+ const weekly = usage;
447
+ windows.push({
448
+ key: "week",
449
+ name: "Weekly",
450
+ percent: usedPercent(weekly.used, weekly.limit),
451
+ resetsAt: toIso(weekly.resetTime)
452
+ });
453
+ }
454
+ if (windows.length === 0) return void 0;
455
+ const user = root.user;
456
+ const membership = typeof user === "object" && user !== null ? user.membership : void 0;
457
+ return {
458
+ planName: typeof membership === "object" && membership !== null ? str(membership.level) : void 0,
459
+ windows
460
+ };
461
+ }
462
+ }
463
+ };
464
+ /** GLM Coding Plan quota; auth is the RAW key without a Bearer prefix. */
465
+ function glmPlan(host, ids) {
466
+ return {
467
+ ids,
468
+ displayName: "GLM Coding Plan",
469
+ plan: {
470
+ build: ({ apiKey }) => ({
471
+ url: `https://${host}/api/monitor/usage/quota/limit`,
472
+ headers: {
473
+ authorization: apiKey,
474
+ "accept-language": "en-US,en"
475
+ }
476
+ }),
477
+ parse: (status, body) => {
478
+ if (status !== 200 || typeof body !== "object" || body === null) return void 0;
479
+ const root = body;
480
+ if (root.success !== true) return void 0;
481
+ const data = root.data;
482
+ if (typeof data !== "object" || data === null) return void 0;
483
+ const limits = data.limits;
484
+ if (!Array.isArray(limits)) return void 0;
485
+ const windows = [];
486
+ for (const entry of limits) {
487
+ if (typeof entry !== "object" || entry === null) continue;
488
+ const row = entry;
489
+ const unit = toNum(row.unit);
490
+ const percent = toNum(row.percentage);
491
+ windows.push({
492
+ key: unit === 3 ? "5h" : unit === 6 ? "week" : unit !== void 0 ? `unit-${unit}` : "window",
493
+ percent: percent === void 0 ? void 0 : Math.max(0, Math.min(100, percent)),
494
+ resetsAt: toIso(row.nextResetTime)
495
+ });
496
+ }
497
+ if (windows.length === 0) return void 0;
498
+ return {
499
+ planName: str(data.level),
500
+ windows
501
+ };
502
+ }
503
+ }
504
+ };
505
+ }
506
+ /** OpenCode Go quota: percent-only rolling/weekly/monthly windows. */
507
+ const OPENCODE_GO = {
508
+ ids: ["opencode-go"],
509
+ displayName: "OpenCode Go",
510
+ plan: {
511
+ build: ({ apiKey }) => ({
512
+ url: "https://opencode.ai/zen/go/v1/usage",
513
+ headers: bearer(apiKey)
514
+ }),
515
+ parse: (status, body) => {
516
+ if (status !== 200 || typeof body !== "object" || body === null) return void 0;
517
+ const usage = body.usage;
518
+ if (typeof usage !== "object" || usage === null) return void 0;
519
+ const windows = [];
520
+ for (const [field, key] of [
521
+ ["rolling", "5h"],
522
+ ["weekly", "week"],
523
+ ["monthly", "month"]
524
+ ]) {
525
+ const entry = usage[field];
526
+ if (typeof entry !== "object" || entry === null) continue;
527
+ const row = entry;
528
+ const percent = toNum(row.percent);
529
+ windows.push({
530
+ key,
531
+ percent: percent === void 0 ? void 0 : Math.max(0, Math.min(100, percent)),
532
+ resetsAt: percent === 0 ? void 0 : toIso(row.resetsAt)
533
+ });
534
+ }
535
+ if (windows.length === 0) return void 0;
536
+ return { windows };
537
+ }
538
+ }
539
+ };
540
+ /** MiniMax coding-plan remains: remaining-percent semantics, `general` model entry. */
541
+ function minimaxPlan(host, ids) {
542
+ return {
543
+ ids,
544
+ displayName: "MiniMax Coding Plan",
545
+ plan: {
546
+ build: ({ apiKey }) => ({
547
+ url: `https://${host}/v1/api/openplatform/coding_plan/remains`,
548
+ headers: bearer(apiKey)
549
+ }),
550
+ parse: (status, body) => {
551
+ if (status !== 200 || typeof body !== "object" || body === null) return void 0;
552
+ const remains = body.model_remains;
553
+ if (!Array.isArray(remains)) return void 0;
554
+ const general = remains.find((entry) => typeof entry === "object" && entry !== null && entry.model_name === "general");
555
+ if (typeof general !== "object" || general === null) return void 0;
556
+ const row = general;
557
+ const windows = [];
558
+ const intervalRemaining = toNum(row.current_interval_remaining_percent);
559
+ if (intervalRemaining !== void 0) windows.push({
560
+ key: "5h",
561
+ percent: Math.max(0, Math.min(100, 100 - intervalRemaining)),
562
+ resetsAt: toIso(row.end_time)
563
+ });
564
+ if (row.current_weekly_status === 1) {
565
+ const weeklyRemaining = toNum(row.current_weekly_remaining_percent);
566
+ if (weeklyRemaining !== void 0) windows.push({
567
+ key: "week",
568
+ percent: Math.max(0, Math.min(100, 100 - weeklyRemaining)),
569
+ resetsAt: toIso(row.weekly_end_time)
570
+ });
571
+ }
572
+ if (windows.length === 0) return void 0;
573
+ return { windows };
574
+ }
575
+ }
576
+ };
577
+ }
578
+ const OPENROUTER = {
579
+ ids: ["openrouter"],
580
+ displayName: "OpenRouter",
581
+ balance: {
582
+ build: ({ apiKey }) => ({
583
+ url: "https://openrouter.ai/api/v1/credits",
584
+ headers: bearer(apiKey)
585
+ }),
586
+ parse: (status, body) => {
587
+ if (status !== 200 || typeof body !== "object" || body === null) return void 0;
588
+ const data = body.data;
589
+ if (typeof data !== "object" || data === null) return void 0;
590
+ const credits = toNum(data.total_credits);
591
+ const used = toNum(data.total_usage) ?? 0;
592
+ if (credits === void 0) return void 0;
593
+ return {
594
+ currency: "USD",
595
+ totalBalance: money(credits - used)
596
+ };
597
+ }
598
+ }
599
+ };
600
+ function siliconFlow(host, ids, currency) {
601
+ return {
602
+ ids,
603
+ displayName: "SiliconFlow",
604
+ balance: {
605
+ build: ({ apiKey }) => ({
606
+ url: `https://${host}/v1/user/info`,
607
+ headers: bearer(apiKey)
608
+ }),
609
+ parse: (status, body) => {
610
+ if (status !== 200 || typeof body !== "object" || body === null) return void 0;
611
+ const data = body.data;
612
+ if (typeof data !== "object" || data === null) return void 0;
613
+ const total = str(data.totalBalance);
614
+ if (total === void 0) return void 0;
615
+ return {
616
+ currency,
617
+ totalBalance: total
618
+ };
619
+ }
620
+ }
621
+ };
622
+ }
623
+ /**
624
+ * The adapter registry, in no particular order. Route keys come from the
625
+ * pi-ai provider catalog plus the routes this deployment observed in user
626
+ * configuration (`zenmux`).
627
+ */
628
+ const PROVIDER_ADAPTERS = [
629
+ DEEPSEEK,
630
+ moonshotBalance("api.moonshot.cn", "CNY", ["moonshotai-cn"]),
631
+ moonshotBalance("api.moonshot.ai", "USD", ["moonshotai"]),
632
+ KIMI_CODING,
633
+ glmPlan("open.bigmodel.cn", ["zai-coding-cn"]),
634
+ glmPlan("api.z.ai", ["zai-coding"]),
635
+ OPENCODE_GO,
636
+ minimaxPlan("api.minimaxi.com", ["minimax-cn"]),
637
+ minimaxPlan("api.minimax.io", ["minimax"]),
638
+ OPENROUTER,
639
+ siliconFlow("api.siliconflow.cn", ["siliconflow", "siliconflow-cn"], "CNY"),
640
+ siliconFlow("api.siliconflow.com", ["siliconflow-intl"], "USD"),
641
+ {
642
+ ids: ["zenmux"],
643
+ displayName: "ZenMux",
644
+ balance: {
645
+ build: ({ apiKey }) => ({
646
+ url: "https://zenmux.ai/api/v1/management/payg/balance",
647
+ headers: bearer(apiKey)
648
+ }),
649
+ parse: (status, body) => {
650
+ if (status !== 200 || typeof body !== "object" || body === null) return void 0;
651
+ const data = body.data;
652
+ if (typeof data !== "object" || data === null) return void 0;
653
+ const credits = toNum(data.total_credits);
654
+ if (credits === void 0) return void 0;
655
+ return {
656
+ currency: "USD",
657
+ totalBalance: money(credits)
658
+ };
659
+ }
660
+ }
661
+ }
662
+ ];
663
+ /** Find the adapter serving a provider route key, if any. */
664
+ function adapterFor(provider) {
665
+ return PROVIDER_ADAPTERS.find((adapter) => adapter.ids.includes(provider));
666
+ }
667
+ /**
668
+ * Best-effort human message from a provider error body, for the per-provider
669
+ * error line. Never throws; truncated to one short sentence.
670
+ */
671
+ function providerErrorMessage(status, body) {
672
+ let message;
673
+ if (typeof body === "object" && body !== null) {
674
+ const root = body;
675
+ const nested = typeof root.error === "object" && root.error !== null ? root.error : void 0;
676
+ message = str(root.message) ?? str(root.msg) ?? (nested !== void 0 ? str(nested.message) : void 0);
677
+ }
678
+ return `HTTP ${status}${message === void 0 ? "" : `: ${message.slice(0, 120)}`}`;
679
+ }
680
+ //#endregion
681
+ //#region src/core/types.ts
682
+ /** A zeroed totals bucket. */
683
+ function emptyTotals() {
684
+ return {
685
+ inputTokens: 0,
686
+ outputTokens: 0,
687
+ cacheReadTokens: 0,
688
+ cacheWriteTokens: 0,
689
+ reasoningTokens: 0,
690
+ calls: 0
691
+ };
692
+ }
693
+ /** Add `right` into `left` in place. */
694
+ function addTotals(left, right) {
695
+ left.inputTokens += right.inputTokens;
696
+ left.outputTokens += right.outputTokens;
697
+ left.cacheReadTokens += right.cacheReadTokens;
698
+ left.cacheWriteTokens += right.cacheWriteTokens;
699
+ left.reasoningTokens += right.reasoningTokens;
700
+ left.calls += right.calls;
701
+ return left;
702
+ }
703
+ //#endregion
704
+ //#region src/core/ledger.ts
705
+ /**
706
+ * The usage ledger: a pure fold from session usage facts into a per-day,
707
+ * per-provider, per-model totals document, plus its JSON serialization.
708
+ * Host-side state lives only in the document; the service owns persistence.
709
+ * @module @linxin666/dsh-usage/core/ledger
710
+ */
711
+ /** Local-date key (`YYYY-MM-DD`) for an epoch ms timestamp. */
712
+ function localDateKey(ms) {
713
+ const date = new Date(ms);
714
+ const month = String(date.getMonth() + 1).padStart(2, "0");
715
+ const day = String(date.getDate()).padStart(2, "0");
716
+ return `${date.getFullYear()}-${month}-${day}`;
717
+ }
718
+ /** An empty ledger document. */
719
+ function createLedgerDocument() {
720
+ return {
721
+ version: 1,
722
+ days: {}
723
+ };
724
+ }
725
+ /**
726
+ * Fold one usage report into the ledger in place. `provider` is the route key
727
+ * and `model` the provider-owned model id the step ran under.
728
+ */
729
+ function foldUsage(doc, atMs, provider, model, usage) {
730
+ if (usage.calls <= 0 && usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens <= 0) return;
731
+ const dayKey = localDateKey(atMs);
732
+ const day = doc.days[dayKey] ?? {};
733
+ const models = day[provider] ?? {};
734
+ const totals = models[model] ?? emptyTotals();
735
+ addTotals(totals, usage);
736
+ models[model] = totals;
737
+ day[provider] = models;
738
+ doc.days[dayKey] = day;
739
+ }
740
+ /** Total tokens of a bucket (billed input + output; reasoning is inside output). */
741
+ function totalTokens(totals) {
742
+ return totals.inputTokens + totals.cacheReadTokens + totals.cacheWriteTokens + totals.outputTokens;
743
+ }
744
+ /** All local-date keys in the ledger, ascending. */
745
+ function ledgerDayKeys(doc) {
746
+ return Object.keys(doc.days).sort();
747
+ }
748
+ /**
749
+ * Drop every day older than `retainDays` local days before `todayKey`, in
750
+ * place. Returns the number of pruned days.
751
+ */
752
+ function pruneLedger(doc, todayKey, retainDays) {
753
+ const cutoff = /* @__PURE__ */ new Date(todayKey + "T00:00:00");
754
+ cutoff.setDate(cutoff.getDate() - retainDays);
755
+ const cutoffKey = localDateKey(cutoff.getTime());
756
+ let pruned = 0;
757
+ for (const key of Object.keys(doc.days)) if (key < cutoffKey) {
758
+ delete doc.days[key];
759
+ pruned += 1;
760
+ }
761
+ return pruned;
762
+ }
763
+ function reviveTotals(value) {
764
+ if (typeof value !== "object" || value === null) return void 0;
765
+ const source = value;
766
+ const num = (key) => typeof source[key] === "number" && Number.isFinite(source[key]) ? source[key] : 0;
767
+ return {
768
+ inputTokens: num("inputTokens"),
769
+ outputTokens: num("outputTokens"),
770
+ cacheReadTokens: num("cacheReadTokens"),
771
+ cacheWriteTokens: num("cacheWriteTokens"),
772
+ reasoningTokens: num("reasoningTokens"),
773
+ calls: num("calls")
774
+ };
775
+ }
776
+ /**
777
+ * Parse a ledger document from untrusted JSON: unknown shapes resolve to an
778
+ * empty document, malformed entries are dropped, numbers are coerced to
779
+ * finite values. Never throws.
780
+ */
781
+ function deserializeLedger(value) {
782
+ const doc = createLedgerDocument();
783
+ if (typeof value !== "object" || value === null) return doc;
784
+ const days = value.days;
785
+ if (typeof days !== "object" || days === null) return doc;
786
+ for (const [dateKey, providers] of Object.entries(days)) {
787
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(dateKey) || typeof providers !== "object" || providers === null) continue;
788
+ for (const [provider, models] of Object.entries(providers)) {
789
+ if (typeof models !== "object" || models === null) continue;
790
+ for (const [model, totals] of Object.entries(models)) {
791
+ const revived = reviveTotals(totals);
792
+ if (revived !== void 0) foldUsage(doc, (/* @__PURE__ */ new Date(dateKey + "T12:00:00")).getTime(), provider, model, revived);
793
+ }
794
+ }
795
+ }
796
+ return doc;
797
+ }
798
+ //#endregion
799
+ //#region src/host/usage-service.ts
800
+ /**
801
+ * The dsh-usage host service: folds live session usage into the persistent
802
+ * ledger, probes each configured provider's balance/coding-plan endpoint on
803
+ * a poll cycle, and announces the current provider's status to the pet
804
+ * bubble. Secrets stay in the host process; the browser only ever sees the
805
+ * overview document.
806
+ * @module @linxin666/dsh-usage/host/usage-service
807
+ */
808
+ /** Source tag the plugin stamps onto pet announcements. */
809
+ const USAGE_ANNOUNCE_SOURCE = "dsh-usage";
810
+ /** Probe timeout per HTTP call. */
811
+ const PROBE_TIMEOUT_MS = 1e4;
812
+ /** Ledger flush debounce. */
813
+ const FLUSH_DEBOUNCE_MS = 3e3;
814
+ /** Currency symbols the bubble and section render inline; other codes render as `12.00 EUR`. */
815
+ const CURRENCY_SYMBOLS = {
816
+ CNY: "¥",
817
+ USD: "$",
818
+ EUR: "€",
819
+ GBP: "£"
820
+ };
821
+ /** Format a balance for display: symbol prefix when known, code suffix otherwise. */
822
+ function formatMoney(currency, totalBalance) {
823
+ const symbol = CURRENCY_SYMBOLS[currency.toUpperCase()];
824
+ if (symbol !== void 0) return symbol + totalBalance;
825
+ return `${totalBalance} ${currency.toUpperCase()}`;
826
+ }
827
+ /** Map a used percent to the announcement tone. */
828
+ function planTone(percent) {
829
+ if (percent >= 90) return "low";
830
+ if (percent >= 70) return "warn";
831
+ return "ok";
832
+ }
833
+ /** Best-effort typed service read: absent services resolve to undefined at runtime. */
834
+ function service(ctx, name) {
835
+ try {
836
+ return ctx.get(name);
837
+ } catch {
838
+ return;
839
+ }
840
+ }
841
+ /** Atomic JSON write through a temp file + rename. */
842
+ async function writeJsonAtomic(path, value) {
843
+ const temp = path + ".tmp";
844
+ await writeFile(temp, JSON.stringify(value, null, 1), "utf8");
845
+ await rename(temp, path);
846
+ }
847
+ /**
848
+ * Read a foreign settings namespace's resolved value (the llm adapter
849
+ * profiles, the agent default model). Unregistered namespaces read as
850
+ * undefined; nothing here throws into the poll loop.
851
+ */
852
+ function readNamespace(ctx, ns) {
853
+ try {
854
+ const settings = service(ctx, "settings");
855
+ if (settings === void 0) return void 0;
856
+ return settings.get(settingsNamespace(ns));
857
+ } catch {
858
+ return;
859
+ }
860
+ }
861
+ var UsageService = class {
862
+ ctx;
863
+ options;
864
+ persistDir;
865
+ ledgerPath;
866
+ snapshotsPath;
867
+ ledger = createLedgerDocument();
868
+ snapshots = /* @__PURE__ */ new Map();
869
+ /** Per-live-session route attribution (WeakMap: disposed sessions age out). */
870
+ sessionRoutes = /* @__PURE__ */ new WeakMap();
871
+ /** The most recent route seen this boot; the pet bubble follows it. */
872
+ current = { source: "default" };
873
+ sessionListenerDisposer;
874
+ pollTimer;
875
+ flushTimer;
876
+ pollInFlight = false;
877
+ disposed = false;
878
+ lastSignature;
879
+ constructor(ctx, options) {
880
+ this.ctx = ctx;
881
+ this.options = options;
882
+ this.persistDir = join(dshHome(), "dsh-usage");
883
+ this.ledgerPath = join(this.persistDir, "usage-ledger.json");
884
+ this.snapshotsPath = join(this.persistDir, "provider-snapshots.json");
885
+ }
886
+ /** Start the listeners, load persisted state, and arm the first poll. */
887
+ start() {
888
+ this.sessionListenerDisposer = this.ctx.on("session/event", (session, event) => this.onSessionEvent(session, event));
889
+ this.loadPersisted();
890
+ this.rearmPoll(2e3);
891
+ }
892
+ /** Stop timers and flush pending ledger writes. */
893
+ stop() {
894
+ this.disposed = true;
895
+ if (this.pollTimer !== void 0) clearTimeout(this.pollTimer);
896
+ if (this.flushTimer !== void 0) clearTimeout(this.flushTimer);
897
+ this.sessionListenerDisposer?.();
898
+ this.flushLedger();
899
+ }
900
+ /** Re-apply options live (settings change); the poll cycle picks them up. */
901
+ applyOptions(options) {
902
+ this.options = options;
903
+ }
904
+ /** Force one poll now (manual refresh route). */
905
+ async refresh() {
906
+ await this.pollNow();
907
+ }
908
+ /** Assemble the overview document the browser section renders. */
909
+ overview() {
910
+ const todayKey = localDateKey(Date.now());
911
+ const routes = this.listProviderRoutes();
912
+ const providers = [];
913
+ for (const route of routes) {
914
+ const adapter = adapterFor(route.id);
915
+ const snapshot = this.snapshots.get(route.id);
916
+ providers.push({
917
+ provider: route.id,
918
+ displayName: route.displayName || adapter?.displayName || route.id,
919
+ credential: snapshot?.credential ?? "none",
920
+ supported: adapter !== void 0 && (adapter.balance !== void 0 || adapter.plan !== void 0),
921
+ ...snapshot?.balance !== void 0 ? { balance: snapshot.balance } : {},
922
+ ...snapshot?.plan !== void 0 ? { plan: snapshot.plan } : {},
923
+ ...snapshot?.error !== void 0 ? { error: snapshot.error } : {},
924
+ ...snapshot?.updatedAt !== void 0 ? { updatedAt: snapshot.updatedAt } : {}
925
+ });
926
+ }
927
+ providers.sort((a, b) => Number(b.supported) - Number(a.supported) || a.displayName.localeCompare(b.displayName));
928
+ const days = ledgerDayKeys(this.ledger).slice(-30);
929
+ return {
930
+ updatedAt: Date.now(),
931
+ providers,
932
+ current: { ...this.current },
933
+ usage: {
934
+ today: this.daySummary(todayKey),
935
+ days: days.map((date) => {
936
+ return {
937
+ date,
938
+ totals: this.daySummary(date).totals
939
+ };
940
+ })
941
+ }
942
+ };
943
+ }
944
+ /** One local day aggregated per provider. */
945
+ daySummary(dateKey) {
946
+ const day = this.ledger.days[dateKey] ?? {};
947
+ const providers = Object.entries(day).map(([provider, models]) => {
948
+ const totals = emptyTotals();
949
+ const modelRows = Object.entries(models).map(([model, modelTotals]) => ({
950
+ model,
951
+ totals: modelTotals
952
+ }));
953
+ for (const row of modelRows) {
954
+ totals.inputTokens += row.totals.inputTokens;
955
+ totals.outputTokens += row.totals.outputTokens;
956
+ totals.cacheReadTokens += row.totals.cacheReadTokens;
957
+ totals.cacheWriteTokens += row.totals.cacheWriteTokens;
958
+ totals.reasoningTokens += row.totals.reasoningTokens;
959
+ totals.calls += row.totals.calls;
960
+ }
961
+ modelRows.sort((a, b) => totalTokens(b.totals) - totalTokens(a.totals));
962
+ return {
963
+ provider,
964
+ totals,
965
+ models: modelRows.slice(0, 12)
966
+ };
967
+ });
968
+ providers.sort((a, b) => totalTokens(b.totals) - totalTokens(a.totals));
969
+ const totals = emptyTotals();
970
+ for (const row of providers) {
971
+ totals.inputTokens += row.totals.inputTokens;
972
+ totals.outputTokens += row.totals.outputTokens;
973
+ totals.cacheReadTokens += row.totals.cacheReadTokens;
974
+ totals.cacheWriteTokens += row.totals.cacheWriteTokens;
975
+ totals.reasoningTokens += row.totals.reasoningTokens;
976
+ totals.calls += row.totals.calls;
977
+ }
978
+ return {
979
+ date: dateKey,
980
+ totals,
981
+ providers
982
+ };
983
+ }
984
+ onSessionEvent(session, event) {
985
+ try {
986
+ if (event.type === "request/header") {
987
+ const config = event.data.header?.config;
988
+ if (config?.provider !== void 0) {
989
+ this.sessionRoutes.set(session, {
990
+ provider: config.provider,
991
+ model: config.model ?? ""
992
+ });
993
+ this.current = {
994
+ provider: config.provider,
995
+ model: config.model,
996
+ source: "live"
997
+ };
998
+ }
999
+ } else if (event.type === "request/context") {
1000
+ const data = event.data;
1001
+ if (data.provider !== void 0) {
1002
+ this.sessionRoutes.set(session, {
1003
+ provider: data.provider,
1004
+ model: data.model ?? ""
1005
+ });
1006
+ this.current = {
1007
+ provider: data.provider,
1008
+ model: data.model,
1009
+ source: "live"
1010
+ };
1011
+ }
1012
+ } else if (event.type === "assistant/message") {
1013
+ const usage = event.data.usage;
1014
+ if (usage === void 0) return;
1015
+ const route = this.sessionRoutes.get(session);
1016
+ if (route === void 0 || route.provider === "") return;
1017
+ foldUsage(this.ledger, Date.now(), route.provider, route.model || "unknown", this.totalsFrom(usage));
1018
+ this.scheduleFlush();
1019
+ }
1020
+ } catch {}
1021
+ }
1022
+ /** Normalize a provider TokenUsage into the ledger bucket (one call). */
1023
+ totalsFrom(usage) {
1024
+ const totals = emptyTotals();
1025
+ totals.inputTokens = usage.inputTokens;
1026
+ totals.outputTokens = usage.outputTokens;
1027
+ totals.cacheReadTokens = usage.cacheReadTokens ?? 0;
1028
+ totals.cacheWriteTokens = usage.cacheWriteTokens ?? 0;
1029
+ totals.reasoningTokens = usage.reasoningTokens ?? 0;
1030
+ totals.calls = 1;
1031
+ return totals;
1032
+ }
1033
+ async loadPersisted() {
1034
+ try {
1035
+ const [rawLedger, rawSnapshots] = await Promise.all([readFile(this.ledgerPath, "utf8").catch(() => void 0), readFile(this.snapshotsPath, "utf8").catch(() => void 0)]);
1036
+ if (rawLedger !== void 0) {
1037
+ this.ledger = deserializeLedger(JSON.parse(rawLedger));
1038
+ pruneLedger(this.ledger, localDateKey(Date.now()), this.options.retainDays);
1039
+ }
1040
+ if (rawSnapshots !== void 0) {
1041
+ const parsed = JSON.parse(rawSnapshots);
1042
+ if (typeof parsed === "object" && parsed !== null && typeof parsed.providers === "object" && parsed.providers !== null) {
1043
+ for (const [provider, snapshot] of Object.entries(parsed.providers)) if (typeof snapshot === "object" && snapshot !== null && typeof snapshot.provider === "string") this.snapshots.set(provider, snapshot);
1044
+ }
1045
+ }
1046
+ } catch {}
1047
+ }
1048
+ scheduleFlush() {
1049
+ if (this.flushTimer !== void 0 || this.disposed) return;
1050
+ this.flushTimer = setTimeout(() => {
1051
+ this.flushTimer = void 0;
1052
+ this.flushLedger();
1053
+ }, FLUSH_DEBOUNCE_MS);
1054
+ }
1055
+ async flushLedger() {
1056
+ try {
1057
+ await mkdir(dirname(this.ledgerPath), { recursive: true });
1058
+ await writeJsonAtomic(this.ledgerPath, this.ledger);
1059
+ } catch {}
1060
+ }
1061
+ async persistSnapshots() {
1062
+ try {
1063
+ await mkdir(dirname(this.snapshotsPath), { recursive: true });
1064
+ await writeJsonAtomic(this.snapshotsPath, {
1065
+ version: 1,
1066
+ providers: Object.fromEntries(this.snapshots)
1067
+ });
1068
+ } catch {}
1069
+ }
1070
+ rearmPoll(delayMs) {
1071
+ if (this.disposed) return;
1072
+ if (this.pollTimer !== void 0) clearTimeout(this.pollTimer);
1073
+ this.pollTimer = setTimeout(() => {
1074
+ this.pollTimer = void 0;
1075
+ this.pollNow().finally(() => this.rearmPoll(Math.max(30, this.options.pollIntervalSec) * 1e3));
1076
+ }, delayMs);
1077
+ }
1078
+ /** One poll cycle: enumerate routes, resolve credentials, probe, announce. */
1079
+ async pollNow() {
1080
+ if (this.pollInFlight || this.disposed) return;
1081
+ this.pollInFlight = true;
1082
+ try {
1083
+ const routes = this.listProviderRoutes();
1084
+ const seen = /* @__PURE__ */ new Set();
1085
+ for (const route of routes) {
1086
+ seen.add(route.id);
1087
+ const adapter = adapterFor(route.id);
1088
+ if (adapter === void 0 || adapter.balance === void 0 && adapter.plan === void 0) continue;
1089
+ await this.probeRoute(route, adapter);
1090
+ }
1091
+ for (const id of [...this.snapshots.keys()]) if (!seen.has(id)) this.snapshots.delete(id);
1092
+ await this.persistSnapshots();
1093
+ this.announceCurrent();
1094
+ } finally {
1095
+ this.pollInFlight = false;
1096
+ }
1097
+ }
1098
+ listProviderRoutes() {
1099
+ const routes = /* @__PURE__ */ new Map();
1100
+ const runtime = service(this.ctx, "llm");
1101
+ if (runtime !== void 0) try {
1102
+ for (const provider of runtime.listProviders()) if (provider.id !== "") routes.set(provider.id, provider.name);
1103
+ for (const provider of runtime.listConfigurableProviders()) if (!routes.has(provider.provider)) routes.set(provider.provider, provider.displayName);
1104
+ } catch {}
1105
+ return [...routes].map(([id, displayName]) => ({
1106
+ id,
1107
+ displayName
1108
+ }));
1109
+ }
1110
+ async probeRoute(route, adapter) {
1111
+ const credential = await this.resolveCredential(route.id);
1112
+ const previous = this.snapshots.get(route.id);
1113
+ const snapshot = {
1114
+ provider: route.id,
1115
+ displayName: route.displayName || adapter.displayName,
1116
+ credential: credential.kind,
1117
+ supported: true,
1118
+ ...previous?.balance !== void 0 ? { balance: previous.balance } : {},
1119
+ ...previous?.plan !== void 0 ? { plan: previous.plan } : {},
1120
+ updatedAt: Date.now()
1121
+ };
1122
+ if (credential.key === void 0) {
1123
+ snapshot.error = void 0;
1124
+ this.snapshots.set(route.id, snapshot);
1125
+ return;
1126
+ }
1127
+ let probed = false;
1128
+ const runProbe = async (kind) => {
1129
+ const half = adapter[kind];
1130
+ if (half === void 0) return void 0;
1131
+ try {
1132
+ const spec = half.build({ apiKey: credential.key });
1133
+ const response = await fetch(spec.url, {
1134
+ headers: spec.headers,
1135
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
1136
+ });
1137
+ const body = await response.json().catch(() => void 0);
1138
+ if (!response.ok) throw new Error(providerErrorMessage(response.status, body));
1139
+ const parsed = half.parse(response.status, body);
1140
+ if (parsed === void 0) throw new Error("unrecognized response shape");
1141
+ probed = true;
1142
+ const updatedAt = Date.now();
1143
+ if (kind === "balance") {
1144
+ const fact = parsed;
1145
+ return {
1146
+ currency: fact.currency,
1147
+ totalBalance: fact.totalBalance,
1148
+ updatedAt
1149
+ };
1150
+ }
1151
+ const plan = parsed;
1152
+ return {
1153
+ planName: plan.planName,
1154
+ windows: plan.windows,
1155
+ updatedAt
1156
+ };
1157
+ } catch (error) {
1158
+ snapshot.error = error instanceof Error ? error.message.slice(0, 200) : String(error);
1159
+ return;
1160
+ }
1161
+ };
1162
+ const balance = await runProbe("balance");
1163
+ if (balance !== void 0) snapshot.balance = balance;
1164
+ const plan = await runProbe("plan");
1165
+ if (plan !== void 0) snapshot.plan = plan;
1166
+ if (probed) snapshot.error = void 0;
1167
+ if (snapshot.balance === void 0 && snapshot.plan === void 0 && snapshot.error === void 0) snapshot.error = "no credential configured";
1168
+ this.snapshots.set(route.id, snapshot);
1169
+ }
1170
+ /**
1171
+ * Resolve the credential backing one route: pi-ai credential records
1172
+ * first, then the profile's apiKeyEnv reference, then the DeepSeek
1173
+ * official adapter's env reference. OAuth grants resolve to a kind with
1174
+ * no key (this plugin does not spend third-party OAuth budgets).
1175
+ */
1176
+ async resolveCredential(provider) {
1177
+ const credentials = service(this.ctx, "credentials");
1178
+ if (credentials === void 0) return { kind: "none" };
1179
+ try {
1180
+ const record = await credentials.readRecord(credentialKey("llm-pi-ai", provider));
1181
+ if (record?.kind === "api-key" && typeof record.key === "string" && record.key !== "") return {
1182
+ kind: "api-key",
1183
+ key: record.key
1184
+ };
1185
+ if (record?.kind === "grant") return { kind: "oauth" };
1186
+ } catch {}
1187
+ try {
1188
+ const envName = this.piAiProfile(provider)?.apiKeyEnv ?? (provider === "deepseek" ? this.deepseekApiKeyEnv() : void 0);
1189
+ if (typeof envName === "string" && envName !== "") {
1190
+ const resolved = await credentials.resolve(credentialRef(envName));
1191
+ if (resolved?.value !== void 0 && resolved.value !== "") return {
1192
+ kind: "env",
1193
+ key: resolved.value
1194
+ };
1195
+ }
1196
+ } catch {}
1197
+ return { kind: "none" };
1198
+ }
1199
+ /** The llm-pi-ai profile object for one provider route, when configured. */
1200
+ piAiProfile(provider) {
1201
+ return readNamespace(this.ctx, "llm-pi-ai")?.providers?.[provider];
1202
+ }
1203
+ /** The DeepSeek official adapter's credential reference name. */
1204
+ deepseekApiKeyEnv() {
1205
+ return readNamespace(this.ctx, "llm-deepseek")?.apiKeyEnv ?? "DEEPSEEK_API_KEY";
1206
+ }
1207
+ /**
1208
+ * Announce the current provider's balance or plan usage to the pet. In
1209
+ * `change` mode only meaningful value changes re-announce; `off` skips.
1210
+ */
1211
+ announceCurrent() {
1212
+ if (this.options.bubbleMode === "off") return;
1213
+ let provider = this.current.provider;
1214
+ if (provider === void 0) {
1215
+ const fallback = readNamespace(this.ctx, "agent-default-model");
1216
+ if (fallback?.provider !== void 0) {
1217
+ provider = fallback.provider;
1218
+ this.current = {
1219
+ provider: fallback.provider,
1220
+ model: fallback.model,
1221
+ source: "default"
1222
+ };
1223
+ } else return;
1224
+ }
1225
+ const snapshot = this.snapshots.get(provider);
1226
+ if (snapshot === void 0) return;
1227
+ let announcement;
1228
+ if (snapshot.balance !== void 0) announcement = {
1229
+ kind: "balance",
1230
+ title: snapshot.displayName,
1231
+ amount: formatMoney(snapshot.balance.currency, snapshot.balance.totalBalance),
1232
+ tone: "ok"
1233
+ };
1234
+ else if (snapshot.plan !== void 0 && snapshot.plan.windows.length > 0) {
1235
+ const window = [...snapshot.plan.windows].sort((a, b) => (b.percent ?? 0) - (a.percent ?? 0))[0];
1236
+ announcement = {
1237
+ kind: "plan",
1238
+ title: snapshot.displayName,
1239
+ ...window.percent !== void 0 ? { percent: window.percent } : {},
1240
+ ...window.resetsAt !== void 0 ? { resetAt: window.resetsAt } : {},
1241
+ ...snapshot.plan.planName !== void 0 ? { note: snapshot.plan.planName } : {},
1242
+ tone: window.percent !== void 0 ? planTone(window.percent) : "ok"
1243
+ };
1244
+ } else return;
1245
+ const signature = JSON.stringify(announcement);
1246
+ if (this.options.bubbleMode === "change" && signature === this.lastSignature) return;
1247
+ this.lastSignature = signature;
1248
+ const pet = service(this.ctx, "pet");
1249
+ try {
1250
+ pet?.announce({
1251
+ source: USAGE_ANNOUNCE_SOURCE,
1252
+ ttlMs: void 0,
1253
+ ...announcement
1254
+ });
1255
+ } catch {}
1256
+ }
1257
+ };
1258
+ //#endregion
1259
+ //#region src/host/loopback.ts
1260
+ /** IPv4 127/8 predicate (four decimal octets, first == 127). */
1261
+ function isIPv4Loopback(v4) {
1262
+ const parts = v4.split(".");
1263
+ return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
1264
+ }
1265
+ /** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
1266
+ function isLoopbackAddress(address) {
1267
+ if (address === void 0) return false;
1268
+ const normalized = address.toLowerCase();
1269
+ if (normalized === "::1") return true;
1270
+ if (normalized.startsWith("::ffff:")) return isIPv4Loopback(normalized.slice(7));
1271
+ return isIPv4Loopback(normalized);
1272
+ }
1273
+ /** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
1274
+ function isLoopbackHostname(hostname) {
1275
+ if (hostname === "localhost" || hostname === "[::1]") return true;
1276
+ return isIPv4Loopback(hostname);
1277
+ }
1278
+ /**
1279
+ * Request-level trust fence: a loopback socket address AND a loopback Host
1280
+ * header, plus browser same-origin markers. The socket address is
1281
+ * authoritative; X-Forwarded-For is never trusted.
1282
+ */
1283
+ function isLoopbackRequest(request) {
1284
+ if (!isLoopbackAddress(request.socket.remoteAddress)) return false;
1285
+ const host = request.headers.host;
1286
+ if (typeof host !== "string") return false;
1287
+ let hostUrl;
1288
+ try {
1289
+ hostUrl = new URL("http://" + host);
1290
+ } catch {
1291
+ return false;
1292
+ }
1293
+ if (!isLoopbackHostname(hostUrl.hostname)) return false;
1294
+ if (request.headers["sec-fetch-site"] === "cross-site") return false;
1295
+ const origin = request.headers.origin;
1296
+ if (origin === void 0) return true;
1297
+ try {
1298
+ return new URL(origin).host === hostUrl.host;
1299
+ } catch {
1300
+ return false;
1301
+ }
1302
+ }
1303
+ //#endregion
1304
+ //#region src/host/http.ts
1305
+ /** Family-default JSON response headers; callers may append or override. */
1306
+ const JSON_HEADERS = {
1307
+ "content-type": "application/json; charset=utf-8",
1308
+ "referrer-policy": "no-referrer"
1309
+ };
1310
+ /**
1311
+ * Write one JSON response. Default headers are the family defaults
1312
+ * (content-type and referrer-policy); caller headers are appended or
1313
+ * override them.
1314
+ */
1315
+ function writeJson(res, status, body, headers = {}) {
1316
+ const payload = JSON.stringify(body);
1317
+ res.writeHead(status, {
1318
+ ...JSON_HEADERS,
1319
+ ...headers
1320
+ });
1321
+ res.end(payload);
1322
+ }
1323
+ //#endregion
1324
+ //#region src/host/routes.ts
1325
+ /**
1326
+ * Loopback-fenced overview route: provider balances, plan quotas, and token
1327
+ * usage totals. Personal account data, so the loopback fence mirrors
1328
+ * dsh-perf's stats surface; the browser runs on the same machine.
1329
+ */
1330
+ function makeUsageOverviewRoute(service) {
1331
+ return {
1332
+ kind: "exact",
1333
+ path: "/api/dsh-usage/overview",
1334
+ handler: async (req, res) => {
1335
+ if (!isLoopbackRequest(req)) {
1336
+ writeJson(res, 403, {
1337
+ ok: false,
1338
+ error: "forbidden: loopback-only"
1339
+ });
1340
+ return;
1341
+ }
1342
+ writeJson(res, 200, service.overview(), { "cache-control": "no-store" });
1343
+ }
1344
+ };
1345
+ }
1346
+ /**
1347
+ * Loopback-fenced manual refresh: forces one probe cycle now and answers
1348
+ * with the fresh overview.
1349
+ */
1350
+ function makeUsageRefreshRoute(service) {
1351
+ return {
1352
+ kind: "exact",
1353
+ path: "/api/dsh-usage/refresh",
1354
+ handler: async (req, res) => {
1355
+ if (!isLoopbackRequest(req)) {
1356
+ writeJson(res, 403, {
1357
+ ok: false,
1358
+ error: "forbidden: loopback-only"
1359
+ });
1360
+ return;
1361
+ }
1362
+ if (req.method !== "POST") {
1363
+ writeJson(res, 405, {
1364
+ ok: false,
1365
+ error: "method not allowed"
1366
+ });
1367
+ return;
1368
+ }
1369
+ try {
1370
+ await service.refresh();
1371
+ } catch (error) {
1372
+ writeJson(res, 500, {
1373
+ ok: false,
1374
+ error: error instanceof Error ? error.message : "refresh failed"
1375
+ });
1376
+ return;
1377
+ }
1378
+ writeJson(res, 200, service.overview(), { "cache-control": "no-store" });
1379
+ }
1380
+ };
1381
+ }
1382
+ //#endregion
1383
+ //#region src/index.ts
1384
+ const name = "dsh-usage";
1385
+ const inject = ["webServer"];
1386
+ const USAGE_SETTINGS_NAMESPACE = settingsNamespace("dsh-usage");
1387
+ const Config = z.object({
1388
+ enabled: z.boolean().default(true),
1389
+ pollIntervalSec: z.number().min(30).max(3600).default(60),
1390
+ bubbleMode: z.string().default("always"),
1391
+ retainDays: z.number().min(7).max(730).default(180)
1392
+ });
1393
+ function resolveConfig(config) {
1394
+ const bubbleMode = config?.bubbleMode === "change" || config?.bubbleMode === "off" ? config.bubbleMode : "always";
1395
+ return {
1396
+ enabled: config?.enabled ?? true,
1397
+ pollIntervalSec: typeof config?.pollIntervalSec === "number" ? config.pollIntervalSec : 60,
1398
+ bubbleMode,
1399
+ retainDays: typeof config?.retainDays === "number" ? config.retainDays : 180
1400
+ };
1401
+ }
1402
+ const apply = mountOnce("@linxin666/dsh-usage", (ctx, config) => {
1403
+ let source = () => config ?? {};
1404
+ let service;
1405
+ let disposeRoutes;
1406
+ const rearm = () => {
1407
+ const value = resolveConfig(source());
1408
+ if (!value.enabled) {
1409
+ service?.stop();
1410
+ service = void 0;
1411
+ disposeRoutes?.();
1412
+ disposeRoutes = void 0;
1413
+ return;
1414
+ }
1415
+ if (service === void 0) {
1416
+ service = new UsageService(ctx, value);
1417
+ service.start();
1418
+ const disposers = [makeUsageOverviewRoute(service), makeUsageRefreshRoute(service)].map((route) => ctx.webServer.register(route));
1419
+ disposeRoutes = () => {
1420
+ for (const dispose of disposers) try {
1421
+ dispose();
1422
+ } catch {}
1423
+ };
1424
+ } else service.applyOptions(value);
1425
+ };
1426
+ installSettingsSection(ctx, USAGE_SETTINGS_NAMESPACE, Config, config ?? {}, {
1427
+ setSource: (next) => {
1428
+ source = next;
1429
+ rearm();
1430
+ },
1431
+ onChange: rearm
1432
+ });
1433
+ ctx.effect(() => {
1434
+ rearm();
1435
+ return () => {
1436
+ disposeRoutes?.();
1437
+ service?.stop();
1438
+ service = void 0;
1439
+ };
1440
+ }, "dsh-usage: runtime");
1441
+ });
1442
+ //#endregion
1443
+ export { Config, USAGE_SETTINGS_NAMESPACE, apply, inject, name, resolveConfig };